Commit 93fdc661b4 patched ``helpers.asyncio.to_thread``, but ``helpers.asyncio is asyncio`` so that was a global patch, not a module seam. ``helpers._to_thread = asyncio.to_thread`` is now the attribute ``mark_async`` awaits, and the read-after-mark test patches that name and leaves ``asyncio.to_thread`` untouched. PROOF: with ``mark_async`` mutated back to ``asyncio.to_thread(self._save)`` the test fails ``mark_async never reached the to_thread handoff``; reverted, ``helpers._to_thread is asyncio.to_thread`` under PYTHONSAFEPATH=1 and run_tests.sh over the 4 branch test files: 7 passed, 0 failed.
163 lines
7.0 KiB
Python
163 lines
7.0 KiB
Python
"""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())
|