107 lines
4.4 KiB
Python
107 lines
4.4 KiB
Python
"""Concurrency tests for the _config_lock pattern used in P2.
|
||
|
||
The real WSHandler requires a full engine/store/ws stack to boot, which makes
|
||
a pure-unit concurrency test prohibitively heavy. Instead we test the *pattern*
|
||
applied in P2 — a single asyncio.Lock serializing any number of mixed
|
||
`add_role` and `reorg_decide` style coroutines — and assert:
|
||
|
||
- Every operation completes (20 ops in our case).
|
||
- `asyncio.Lock` never produces a RuntimeError ("lock already held") — which
|
||
would indicate same-task reentry.
|
||
- The log shows strictly interleaved entries (no two tasks recorded an
|
||
"inside-lock" line between each other's enter/exit).
|
||
- The whole batch completes well under the 10s budget.
|
||
|
||
Evidence: ws_handler.py:175 defines `self._config_lock = asyncio.Lock()`.
|
||
P2 wraps `_handle_reorg_decide`, `_handle_set_mode`, and the `company_profile`
|
||
swap under this same lock. The proof of no deadlock is static (D4 in the plan);
|
||
this test is a dynamic smoke that the cooperative-locking behavior holds.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import time
|
||
|
||
import pytest
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_add_role_and_reorg_decide_serialized():
|
||
"""20 mixed ops share one lock; no exceptions, no deadlock, finishes fast."""
|
||
lock = asyncio.Lock()
|
||
# Event log records enter/exit around the critical section so we can verify
|
||
# the lock serialized the body. If two tasks ever interleaved between an
|
||
# enter and its matching exit, the test fails.
|
||
log: list[tuple[str, str]] = [] # (op, phase)
|
||
|
||
async def guarded(op: str) -> None:
|
||
async with lock:
|
||
log.append((op, "enter"))
|
||
# Yield to event loop so other tasks get a chance to observe
|
||
# whether they see an "inside-lock" window (they should not).
|
||
await asyncio.sleep(0)
|
||
log.append((op, "exit"))
|
||
|
||
started = time.monotonic()
|
||
tasks = []
|
||
for i in range(10):
|
||
tasks.append(asyncio.create_task(guarded(f"add_role:{i}")))
|
||
tasks.append(asyncio.create_task(guarded(f"reorg_decide:{i}")))
|
||
|
||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||
elapsed = time.monotonic() - started
|
||
|
||
exceptions = [r for r in results if isinstance(r, BaseException)]
|
||
assert exceptions == [], f"Unexpected exceptions: {exceptions}"
|
||
|
||
# Every op must produce exactly one enter + one exit.
|
||
assert len(log) == 40, f"Expected 40 log entries (20 ops × 2), got {len(log)}"
|
||
|
||
# Verify lock-mutual-exclusion: iterating the log, each enter must be
|
||
# immediately followed by the SAME op's exit (no interleave).
|
||
i = 0
|
||
while i < len(log):
|
||
assert log[i][1] == "enter", f"Expected enter at index {i}, got {log[i]}"
|
||
assert log[i + 1] == (log[i][0], "exit"), (
|
||
f"Enter for {log[i][0]} was not immediately followed by its exit; "
|
||
f"saw {log[i + 1]} — indicates lock was not held across the body."
|
||
)
|
||
i += 2
|
||
|
||
# Counts must balance.
|
||
add_role_count = sum(1 for (op, phase) in log if phase == "enter" and op.startswith("add_role"))
|
||
reorg_count = sum(1 for (op, phase) in log if phase == "enter" and op.startswith("reorg_decide"))
|
||
assert add_role_count == 10
|
||
assert reorg_count == 10
|
||
|
||
# 10s budget; a correct cooperative-lock implementation finishes in <1s.
|
||
assert elapsed < 10.0, f"Test took {elapsed:.2f}s, exceeded 10s budget"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_lock_is_not_reentrant_from_same_task():
|
||
"""asyncio.Lock must NOT be re-acquirable from the same task (would deadlock).
|
||
|
||
This is the invariant that the P2 plan relied on (D4 deadlock proof): if any
|
||
code path under `_handle_reorg_decide` re-acquired `_config_lock` we'd
|
||
deadlock. Here we verify the Lock primitive's behavior — the test passes
|
||
only when a re-acquire attempt blocks forever (we force it to time out).
|
||
"""
|
||
lock = asyncio.Lock()
|
||
|
||
async def try_reentrant():
|
||
async with lock:
|
||
# Attempting to acquire again from the same task must block.
|
||
try:
|
||
await asyncio.wait_for(lock.acquire(), timeout=0.2)
|
||
except asyncio.TimeoutError:
|
||
return "blocked_as_expected"
|
||
# Only reach here if stdlib ever became reentrant (it is not).
|
||
lock.release()
|
||
return "unexpected_reentry"
|
||
|
||
outcome = await try_reentrant()
|
||
assert outcome == "blocked_as_expected", (
|
||
f"Expected asyncio.Lock to block on same-task re-acquire, got {outcome}"
|
||
)
|