"""Regression test: _handle_clarify_sse_stream must not deadlock on its own lock. The naive implementation called clarify.get_pending(sid) inside a `with _clarify_lock:` block, but get_pending also acquires _lock. Because clarify._lock is a non-reentrant threading.Lock(), the second acquisition would deadlock the SSE handler thread the moment any client connected to /api/clarify/stream. This test runs the inlined snapshot logic under the lock and verifies it completes — both with an empty queue and with a pending entry — within a short timeout. If the regression returns (someone re-introduces the recursive get_pending() call), this test will hang and the timeout will fire. """ from __future__ import annotations import pathlib import queue import sys import threading import time REPO_ROOT = pathlib.Path(__file__).parent.parent.resolve() sys.path.insert(0, str(REPO_ROOT)) def _run_handler_snapshot(sid: str, timeout: float = 2.0): """Replicate the snapshot logic from _handle_clarify_sse_stream and return ``(initial_pending, initial_count)`` or raise on deadlock.""" from api.clarify import ( _lock as _clarify_lock, _clarify_sse_subscribers as _clarify_subs, _gateway_queues as _clarify_gateway_queues, _pending as _clarify_pending, ) result: list = [] def worker(): try: q: queue.Queue = queue.Queue(maxsize=16) initial_pending = None initial_count = 0 with _clarify_lock: _clarify_subs.setdefault(sid, []).append(q) gw_q = _clarify_gateway_queues.get(sid) or [] if gw_q: initial_pending = dict(gw_q[0].data) initial_count = len(gw_q) else: legacy = _clarify_pending.get(sid) if legacy: initial_pending = dict(legacy) initial_count = 1 # Cleanup the subscriber so we don't leak between tests. with _clarify_lock: subs = _clarify_subs.get(sid) if subs and q in subs: subs.remove(q) if not subs: _clarify_subs.pop(sid, None) result.append((initial_pending, initial_count)) except BaseException as exc: # noqa: BLE001 result.append(exc) t = threading.Thread(target=worker, daemon=True) t.start() t.join(timeout=timeout) if t.is_alive(): raise AssertionError( f"_handle_clarify_sse_stream snapshot deadlocked (>{timeout}s). " "Did someone re-introduce a recursive _lock acquisition? " "The handler must NOT call clarify.get_pending() — read " "_gateway_queues / _pending inline under the same _lock." ) if isinstance(result[0], BaseException): raise result[0] return result[0] def test_handler_snapshot_does_not_deadlock_when_queue_is_empty(): sid = f"clarify-sse-empty-{time.time_ns()}" initial_pending, initial_count = _run_handler_snapshot(sid) assert initial_pending is None assert initial_count == 0 def test_handler_snapshot_does_not_deadlock_when_queue_has_entry(): """With a real pending entry, the snapshot must capture it without deadlock.""" from api import clarify sid = f"clarify-sse-populated-{time.time_ns()}" try: clarify.submit_pending(sid, { "question": "Pick one?", "choices_offered": ["a", "b"], }) initial_pending, initial_count = _run_handler_snapshot(sid) assert initial_count == 1 assert initial_pending is not None assert initial_pending.get("question") == "Pick one?" assert initial_pending.get("choices_offered") == ["a", "b"] finally: clarify.resolve_clarify(sid, "a") def test_routes_handler_does_not_call_get_pending_under_lock(): """Source-level invariant: routes.py must not call get_clarify_pending() inside the `with _clarify_lock:` block — that would re-acquire _lock and deadlock.""" src = (REPO_ROOT / "api" / "routes.py").read_text(encoding="utf-8") # Find _handle_clarify_sse_stream start = src.find("def _handle_clarify_sse_stream(") assert start != -1, "_handle_clarify_sse_stream must exist" end = src.find("\ndef ", start + 1) body = src[start:end if end != -1 else len(src)] # Find the lock block lock_start = body.find("with _clarify_lock:") assert lock_start != -1, "Handler must use `with _clarify_lock:`" # Walk forward by indentation to find the end of the lock block lines = body[lock_start:].split("\n") lock_block_lines = [lines[0]] # The `with` line itself base_indent = None for line in lines[1:]: if not line.strip(): lock_block_lines.append(line) continue indent = len(line) - len(line.lstrip(" ")) if base_indent is None: base_indent = indent if indent < base_indent and line.strip(): break lock_block_lines.append(line) lock_body = "\n".join(lock_block_lines) assert "get_clarify_pending(" not in lock_body, ( "_handle_clarify_sse_stream must not call get_clarify_pending() " "inside `with _clarify_lock:` — it acquires _lock internally and " "would deadlock the handler thread (non-reentrant Lock)." ) assert "clarify.get_pending(" not in lock_body, ( "Same — clarify.get_pending() acquires _lock internally." )