Initial commit
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
"""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}"
|
||||
)
|
||||
Reference in New Issue
Block a user