"""The thread-participation tracker's persist must not block the event loop. ``gateway/platforms/helpers.ThreadParticipationTracker._save`` ends in ``atomic_json_write`` -> ``os.replace``, whose duration is unbounded under filesystem pressure. Every caller sits on an inbound-message coroutine -- Matrix ``_resolve_message_context`` and ``create_handoff_thread``, Discord ``_handle_message`` (x2) and ``_handle_thread_create_slash`` -- so that rename was paid inline on the loop, stalling every other adapter's polling and every in-flight turn for as long as it took. The fix is a CHOKE-POINT one rather than five call-site ones: ``mark_async`` offloads only the persist via ``asyncio.to_thread``, and all five coroutine call sites await it. ``mark`` keeps its exact synchronous contract for the non-loop callers and tests. These tests are about OBSERVABLE BEHAVIOUR -- can the loop keep running while the persist is in its slow path -- not about which API was called. They pin the property the off-loop move would otherwise silently break. Moving the persist to a worker thread removes the accidental serialization the event loop used to provide, so the check/insert/trim/write sequence can now interleave across two marks and lose an entry. ``atomic_json_write`` makes each write atomic; it does not make the sequence atomic. And they pin the read-after-mark contract: the adapters gate on ``thread_id in self._threads`` immediately after marking, so the in-memory half of ``mark_async`` has to be synchronous even though the persist is not. """ from __future__ import annotations import asyncio import contextlib import json import threading import time import pytest from gateway.platforms import helpers from gateway.platforms.helpers import ThreadParticipationTracker # One constant drives both sides of the stall check below: the barrier timeout # (how long a worker parked inside the rename stalls the loop under the # pre-fix lock shape) and the threshold the loop must beat. Deriving the # threshold as ``BARRIER / 2`` keeps the red/green margin at half the stall # whatever the value, so a loaded runner overshooting ``sleep(0.05)`` cannot # flip the assertion. BARRIER = 1.0 @pytest.fixture() def tracker(tmp_path, monkeypatch): """A tracker whose state file lives in an isolated directory.""" path = tmp_path / "matrix_threads.json" monkeypatch.setattr( ThreadParticipationTracker, "_state_path", lambda self: path, raising=True ) obj = ThreadParticipationTracker("matrix") obj.state_path = path # convenience for assertions return obj def test_two_concurrent_marks_do_not_lose_an_entry(tracker, monkeypatch): """Off-loop marks run on worker threads, so the sequence must be locked. The event loop used to serialize every caller by accident. With the persist on ``asyncio.to_thread`` two marks genuinely overlap, and a check/insert/trim/write sequence without a lock drops one of the two. The oracle is the DURABLE FILE, not the in-memory dict. """ # With the io lock in place the second worker never reaches the barrier # (it waits on the lock), so the first one MUST time out here. That # timeout is the green path's cost, so keep it small. entered = threading.Barrier(2, timeout=BARRIER) real_write = helpers.atomic_json_write def _synchronised_write(path, payload, *a, **kw): # Force maximum overlap: neither write proceeds until both have begun. try: entered.wait() except threading.BrokenBarrierError: # pragma: no cover - timeout path pass return real_write(path, payload, *a, **kw) monkeypatch.setattr(helpers, "atomic_json_write", _synchronised_write, raising=True) async def scenario(): marks = asyncio.gather( tracker.mark_async("!a:example.org"), tracker.mark_async("!b:example.org"), ) # A worker is parked inside the rename. Neither the second mark's # in-memory insert nor a membership check may wait for it: both take # the set lock ON THE LOOP THREAD, so holding that lock across # ``os.replace`` stalls the loop for the whole rename. started = time.monotonic() await asyncio.sleep(0.05) assert "!a:example.org" in tracker elapsed = time.monotonic() - started assert elapsed < BARRIER / 2, ( "the loop stalled on the tracker lock while a worker held it " "across os.replace" ) await marks asyncio.run(scenario()) persisted = json.loads(tracker.state_path.read_text(encoding="utf-8")) assert sorted(persisted) == ["!a:example.org", "!b:example.org"], ( "a concurrent mark was lost from the DURABLE file: " f"{persisted!r}. atomic_json_write makes each write atomic; it does " "not make check/insert/trim/write atomic." ) def test_a_mark_is_visible_in_memory_before_the_persist_completes(tracker, monkeypatch): """``thread_id in tracker`` must be true before the persist runs AT ALL. Both adapters gate on membership right after marking (``in_bot_thread = bool(thread_id and thread_id in self._threads)``), so deferring the in-memory insert to the worker thread alongside the write would reopen the mention-gating hole the tracker exists to close -- and would make membership depend on executor availability. The oracle is a handoff that NEVER RUNS: ``helpers._to_thread`` is replaced by a coroutine that parks forever without ever invoking the callable. That is what makes this non-vacuous. Merely waiting for the worker to reach the rename -- the obvious shape -- also observes a True under the deferred implementation, because a deferred insert still happens before the write; this one cannot. """ handed_off: list[object] = [] async def _never(fn, *a, **kw): handed_off.append(fn) await asyncio.Event().wait() # park forever; fn is never called # Patch the module seam the tracker actually calls; ``asyncio.to_thread`` # itself is left alone. monkeypatch.setattr(helpers, "_to_thread", _never, raising=True) async def scenario(): mark = asyncio.create_task(tracker.mark_async("!first:example.org")) # One loop iteration runs the task up to its first suspension point, # which is inside ``_never`` -- no Event handoff, no wall-clock wait. await asyncio.sleep(0) assert handed_off, "mark_async never reached the to_thread handoff" try: assert "!first:example.org" in tracker, ( "the mark is not in memory once the persist has been handed " "off -- the in-memory insert was deferred onto the worker " "thread, so membership now depends on executor availability " "and mention gating can re-prompt for an @mention in a thread " "the bot just joined" ) finally: mark.cancel() with contextlib.suppress(asyncio.CancelledError): await mark asyncio.run(scenario())