test(gateway): de-flake history-lookup timing tests; add worker-start-failure regression

This commit is contained in:
kshitij
2026-08-08 00:08:42 +05:30
parent 38cd1999cb
commit 0d312126a0
+64 -7
View File
@@ -286,15 +286,22 @@ async def test_bare_path_history_lookup_does_not_block_event_loop(tmp_path, monk
type(adapter), "filter_local_delivery_paths", staticmethod(lambda paths: list(paths))
)
class _SlowStore:
class _GatedStore:
def __init__(self):
self.release = threading.Event()
def peek_session_id(self, _session_key):
return "sess-slow"
def load_transcript(self, _session_id):
time.sleep(0.15)
# Block until the test has proven the event loop stayed
# responsive. An Event gate (instead of a fixed sleep) makes the
# assertion deterministic on slow/loaded CI hosts.
self.release.wait(timeout=5)
return [{"role": "user", "content": "current"}]
adapter.set_session_store(_SlowStore())
store = _GatedStore()
adapter.set_session_store(store)
async def handler(_event):
return f"Generated file: {pdf}"
@@ -305,10 +312,13 @@ async def test_bare_path_history_lookup_does_not_block_event_loop(tmp_path, monk
adapter._process_message_background(event, build_session_key(event.source))
)
await asyncio.sleep(0.02)
# The transcript read is still parked on the gate. If load_transcript ran
# on the event-loop thread, the sleep above could never have resumed
# while the read was in progress — reaching this point with the delivery
# still pending proves the read happened off-loop.
assert not delivery.done()
# If load_transcript ran on the event-loop thread, this 20 ms sleep would
# not resume until after the 150 ms blocking read completed.
assert not adapter.documents
store.release.set()
await delivery
assert adapter.documents == [str(pdf)]
@@ -343,7 +353,11 @@ async def test_bare_path_history_lookup_timeout_fails_open(tmp_path, monkeypatch
started = time.monotonic()
await adapter._process_message_background(event, build_session_key(event.source))
assert time.monotonic() - started < 0.15
# The lookup times out after 0.02s and fails open; the generous 1.0s
# bound only guards against delivery hanging on the wedged read
# indefinitely, without flaking on loaded CI hosts. Delivery of the
# document below is the real fail-open assertion.
assert time.monotonic() - started < 1.0
assert adapter.documents == [str(pdf)]
@@ -383,12 +397,55 @@ async def test_history_lookup_saturation_fails_open_without_new_worker(monkeypat
elapsed = time.monotonic() - began
assert third is None
assert elapsed < 0.1
# Saturation must fail open immediately (no waiting on the 1.0s lookup
# timeout); 0.5s is a generous bound that stays flake-free on loaded CI.
assert elapsed < 0.5
assert calls == 2
release.set()
await asyncio.gather(first, second)
@pytest.mark.asyncio
async def test_history_lookup_worker_start_failure_fails_open_and_releases_slot(
monkeypatch, caplog
):
"""If the worker thread cannot start, fail open without leaking a permit.
Regression: ``Thread.start()`` raising (thread exhaustion) used to
propagate into the delivery loop AND permanently leak the admission
permit acquired just above it, because the worker's finally-release
never runs when the worker never starts.
"""
admission = threading.BoundedSemaphore(1)
monkeypatch.setattr(
"gateway.platforms.base._HISTORY_MEDIA_LOOKUP_ADMISSION", admission
)
adapter = _DummyAdapter()
def _exhausted_start(self):
raise RuntimeError("can't start new thread")
monkeypatch.setattr(threading.Thread, "start", _exhausted_start)
with caplog.at_level(logging.WARNING):
first = await adapter._bounded_history_media_paths_for_session("one")
# With only one permit, a leak on the first call would force this
# second call down the capacity-exhausted path instead of the
# start-failure path.
second = await adapter._bounded_history_media_paths_for_session("two")
assert first is None
assert second is None
assert "capacity exhausted" not in caplog.text
assert (
caplog.text.count("Could not start media-delivery history lookup worker")
== 2
)
# The permit was returned both times: it is still acquirable.
assert admission.acquire(blocking=False)
admission.release()
# ---------------------------------------------------------------------------
# Streaming sibling (run.py _deliver_media_from_response)
# ---------------------------------------------------------------------------