Files
turnstone/tests/test_sse_recovery_e2e.py
T
Patrick Buckley 480a1426b3 Fail-closed history-commit handoff (#1005)
* fix(session): fail-closed history-commit handoff (#981)

The deleted-workstream discovery is now a terminal, ws_id-keyed latch:
keyed conversation commits refuse admission once the durable parent is
gone (convergence finalizers and force-abandon are exempt), history
handoff refuses to mint a proof token so /history fails closed with a
503 instead of silently wiping the pane, and the SSE stream carries a
workstream_gone resync reason. Discarded commits leave a forensic log
of commit keys and roles, never content.

Conversation rows gain a commit_key (migration 071): keyed saves are
idempotent under retry, validated against the full commit identity, and
refused when they would cross a workstream deletion. The prune orphan
category now requires a NULL alias plus a two-hour updated grace, with
cutoffs computed at discovery time and carried into both dialects'
rechecks.

The mid-turn interjection queue is owner-partitioned with no per-site
mode flags: pops take the acting principal's and unowned rows, other
participants' rows are structurally retained, and enforcement lives at
queue admission plus the shared before_spawn gates. The retraction
ledger is bounded by open pop windows: pops open a window atomically
with the queue delete, restores close their ids atomically with the
ledger consume, every other exit closes through one helper, and misses
for unheld ids record nothing. The workstream-gone latch refuses
unattended wakes at all three gates (watcher spawn, claim, delivery
pre-pop), and the retry dispatcher regained its pre-envelope
cancel/error convergence net.

Persistence-state reporting derives through the session bound to each
UI instead of a registry lookup by id that failed open to healthy
during tombstone retention. The dashboard roster no longer re-inserts
ghost entries from trailing activity events, the history tool-outcome
scan tolerates interleaved non-turn rows, and the shared
handoff-deadline handle owns its own retirement.

Single-sourced across call sites: keyed-commit row values, attachment
save wrappers, tail-truncation and conflict-resolution bodies for both
storage dialects; worker-slot lifecycle field sets; the direct-commit
admission frame; queued-row layout accessors; the string-aware comment
stripper shared by every JS harness suite.

Refs #981 #964

* fix(session): sweep handoff fixes to their sibling surfaces

The interactive replay loop treated a system row as a tool-batch
boundary, so every tool result after an interleaved row vanished from
that pane while the coordinator rendered the same history correctly.
Only a conversational turn ends the batch window now, matching the
shared outcome index.

Accepted user turns clear the composer's attachment chips on the same
viewer policy that settles optimistic bubbles rather than on having
matched a local bubble, so a workstream created with an upload no
longer keeps a chip for an attachment the create dispatch already
consumed. The coordinator's raced-Stop arm emits the stream-end hook it
inherits alongside the idle state, leaving no unfinalized bubble or
unflushed tool output. Ending a session surfaces a failure toast when
the request never lands or answers with a non-JSON body.

The per-second persistence reconcile now probes each session without
blocking: a workstream whose generation and handoff locks are held is
skipped until the next pass instead of contending the locks every
commit needs. The one-shot repair that gates workstream creation at
capacity keeps a definite probe — it has no next pass, and the sessions
likeliest to be contended are the ones whose unresolved journals
emptied its candidate list.

Single-sourced: the attachment lane builds its conversation row through
the shared commit-identity builder; the ordinary worker exit releases
its slot through the lifecycle owner; both operator surfaces snapshot
their counters through one non-consuming helper; the replay preamble
loses its per-kind wrappers and its config hook; the browser harness
suites share one brace walker; and each in-flight history attempt is
one record carrying both its abort controller and its deadline.

Refs #981 #964
2026-08-11 04:18:36 -07:00

563 lines
23 KiB
Python

"""End-to-end SSE recovery harness (opt-in) for the three fixes on
``fix/sse-truncated-resync``:
1. **tool_output_chunk batching (de-amplifier).** A parallel storm of
line-chatty bash tools no longer emits one SSE event per stdout line.
2. **Honest truncated replay.** An empty ring after eviction / node
restart reports ``replay_truncated`` with a real ``lost_count`` instead
of a silent ``replay_ok``; the client adopts the ``/history`` resume
cursor to rebuild the in-flight turn.
3. **Sub-agent attribution.** Every child tool event a task_agent's
sub-tools produce is stamped ``parent_call_id`` at the batch flush.
Each scenario drives a REAL interactive server (real ``SessionManager`` +
real ``ChatSession`` engine executing REAL bash) through a scripted
provider at the SDK boundary, with a ``BrowserlikeSSEClient`` that speaks
the exact interactive.js wire contract, and asserts convergence.
Marked only ``e2e_recovery`` (select with ``-m e2e_recovery``), not ``live``:
the harness uses a scripted provider and needs no LLM backend. CI's fast lane
explicitly deselects both marker families. Each scenario runs in tens of
seconds; the tier-1 suite stays under ~5 minutes.
"""
from __future__ import annotations
import time
from typing import TYPE_CHECKING
import pytest
from tests._sse_recovery_helpers import (
BrowserlikeSSEClient,
assert_children_stamped,
assert_chunk_result_ordering,
assert_contiguous_ids,
assert_converged,
history_tool_outputs,
)
from tests._sse_recovery_server import (
RecoveryServer,
bash_toolcall_script,
final_text_script,
parallel_bash_script,
)
if TYPE_CHECKING:
from collections.abc import Callable, Iterator
# NOT ``live`` — these run a scripted provider, no LLM backend. The CI
# lanes deselect via ``-m "not live and not e2e_recovery"``; select with
# ``-m e2e_recovery``.
pytestmark = pytest.mark.e2e_recovery
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def recovery_db(tmp_path: object) -> Iterator[str]:
"""A throwaway migrated sqlite DB (singleton registry) for one test."""
from turnstone.core.storage import init_storage, reset_storage
db_path = str(tmp_path / "recovery.db") # type: ignore[operator]
reset_storage()
init_storage("sqlite", path=db_path, run_migrations=True)
yield db_path
reset_storage()
@pytest.fixture
def make_server(recovery_db: str) -> Iterator[Callable[..., RecoveryServer]]:
"""Factory that builds RecoveryServers and stops them all at teardown
(LIFO), so a restart scenario can build a second node on the same DB."""
servers: list[RecoveryServer] = []
def _make(**kwargs: object) -> RecoveryServer:
srv = RecoveryServer(**kwargs) # type: ignore[arg-type]
servers.append(srv)
return srv
yield _make
for srv in reversed(servers):
srv.stop()
SEQ_OUTPUT = "".join(f"{n}\n" for n in range(1, 501)) # `seq 1 500` chunk stream
# A PACED storm: streams slowly so an early disconnect captures a cursor
# genuinely below the turn's committed-message counter (the restart-loss case).
PACED_STORM = "for i in $(seq 1 40); do echo r-$i; sleep 0.05; done"
# ---------------------------------------------------------------------------
# Recovery-server event pulses used by the browser latch probes
# ---------------------------------------------------------------------------
def test_recovery_server_pulses_follow_the_ordered_session_sse_path(
make_server: Callable[..., RecoveryServer],
) -> None:
"""Idle/tool pulses are real ordered UI events, not harness-only DOM calls."""
srv = make_server()
ws_id = srv.create_workstream(name="recovery-pulses")
client = BrowserlikeSSEClient(srv.base_url, ws_id, srv.token)
try:
client.connect()
client.wait_for_type("connected", timeout=10)
idle_id = srv.emit_idle_edge(ws_id)
pending_id = srv.emit_tool_pending(ws_id, "recovery-probe")
result_id = srv.emit_tool_result(ws_id, "recovery-probe")
expected_ids = {idle_id, pending_id, result_id}
client.wait_for(
lambda c: (
expected_ids
<= {f.event_id_int for f in c.all_frames() if f.event_id_int is not None}
),
timeout=10,
)
frames = {f.event_id_int: f for f in client.all_frames() if f.event_id_int in expected_ids}
assert [idle_id, pending_id, result_id] == list(range(idle_id, result_id + 1))
assert [frames[event_id].etype for event_id in sorted(expected_ids)] == [
"state_change",
"tool_pending",
"tool_result",
]
assert frames[idle_id].payload is not None
assert frames[idle_id].payload["state"] == "idle"
assert frames[pending_id].payload is not None
assert frames[pending_id].payload["items"][0]["call_id"] == "recovery-probe"
assert frames[result_id].payload is not None
assert frames[result_id].payload["call_id"] == "recovery-probe"
finally:
client.close()
# ---------------------------------------------------------------------------
# Scenario 1 — storm without loss (fix-3 efficacy)
# ---------------------------------------------------------------------------
def test_storm_batches_without_loss(make_server: Callable[..., RecoveryServer]) -> None:
"""One turn issues 4 parallel ``seq 1 500`` bash calls. Assert the
per-call chunk events are batched FAR below 500, each call's chunks
strictly precede its own result, the concatenated chunks reconstruct
the exact seq output, and a normally-draining consumer never overflows."""
srv = make_server()
call_ids = [f"call_{i}" for i in range(4)]
ws_id = srv.create_workstream(
parallel_bash_script(dict.fromkeys(call_ids, "seq 1 500")),
final_text_script("ran the storm"),
name="storm",
)
client = BrowserlikeSSEClient(srv.base_url, ws_id, srv.token)
try:
client.connect() # before send — the listener must be registered
srv.send(ws_id, "run the storm")
srv.wait_turn(ws_id, timeout=45)
for cid in call_ids:
client.wait_for_call_result(cid, timeout=20)
time.sleep(0.4) # settle trailing frames
# De-amplification: each call's chunk-event count is far below 500.
per_call: dict[str, int] = {}
for f in client.frames_of_type("tool_output_chunk"):
assert f.payload is not None
cid = str(f.payload["call_id"])
per_call[cid] = per_call.get(cid, 0) + 1
assert set(per_call) == set(call_ids), per_call
for cid, count in per_call.items():
assert count <= 60, f"{cid} emitted {count} chunk events (batching failed)"
# Lossless reconstruction per call + chunks precede results.
recon = client.tool_output_by_call()
for cid in call_ids:
assert recon[cid] == SEQ_OUTPUT, f"{cid} chunk stream diverged from seq 1..500"
assert_chunk_result_ordering(client.all_frames())
# A fresh connect made before any event has snap_seq==0, so the whole
# id-bearing stream is a gap-free contiguous run.
assert_contiguous_ids(client.conn_frames(0))
# A normally-draining consumer never overflows, and every committed
# tool result matches a fresh /history projection.
assert not client.has_type("stream_overflow")
assert_converged(client, srv.fetch_history(ws_id))
finally:
client.close()
# ---------------------------------------------------------------------------
# Scenario 2 — slow-consumer overflow -> poison -> lossless reconnect
# ---------------------------------------------------------------------------
def test_slow_consumer_overflow_then_lossless_reconnect(
make_server: Callable[..., RecoveryServer],
) -> None:
"""A stalled consumer poisons the listener queue; the server closes the
stream with an id-less ``stream_overflow`` frame; a native reconnect
with the pre-gap cursor replays the gap from the ring with contiguous
ids; the final transcript converges with a fresh /history projection."""
# fix-3 de-amplifies so hard that a real 500-cap overflow needs a
# pathological storm; a small cap + small buffers exercise the identical
# _ListenerOverflow -> stream_overflow -> reconnect-replay path.
srv = make_server(sndbuf=8192, listener_cap=64)
call_ids = [f"c{i}" for i in range(4)]
# ~4 KB-per-line paced output so the stall builds a poisoning backlog.
paced = "for i in $(seq 1 100); do printf '%04000d\\n' $i; sleep 0.03; done"
ws_id = srv.create_workstream(
parallel_bash_script(dict.fromkeys(call_ids, paced)),
final_text_script("done"),
name="overflow",
)
client = BrowserlikeSSEClient(srv.base_url, ws_id, srv.token)
try:
client.connect(rcvbuf=2048) # small buffer -> the stall poisons quickly
srv.send(ws_id, "overflow storm")
client.wait_for(lambda c: len(c.frames_of_type("tool_output_chunk")) >= 2, timeout=20)
pre_gap = client.last_event_id
assert pre_gap is not None
client.stall()
_wait(lambda: srv.listener_poisoned(ws_id), timeout=20, what="listener poison")
# The turn finishes while the consumer is stalled: the worker keeps
# enqueuing (dropped by the poisoned queue) but the ring keeps all.
srv.wait_turn(ws_id, timeout=45)
client.resume()
overflow = client.wait_for_type("stream_overflow", timeout=15)
assert overflow.event_id is None, "stream_overflow must be id-less (cursor stays below gap)"
# Native EventSource reconnect: the pre-gap cursor rides a
# Last-Event-ID header; the ring replays everything past it.
client.disconnect()
client.connect(native=True)
for cid in call_ids:
client.wait_for_call_result(cid, timeout=30)
# The reconnect replayed the gap on the replay_ok path (snap_seq==0):
# a gap-free, dup-free contiguous run.
assert_contiguous_ids(client.conn_frames(1))
assert_converged(client, srv.fetch_history(ws_id))
finally:
client.close()
# ---------------------------------------------------------------------------
# Scenario 3 — truncated mid-run -> cursor-adoption rebuild (fix-2 client)
# ---------------------------------------------------------------------------
def test_truncated_midrun_cursor_adoption_rebuild(
make_server: Callable[..., RecoveryServer],
tmp_path: object,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A consumer disconnects early in turn 1 (cursor C). Turn 1 commits
(boundary B1 > C). Turn 2 — the in-flight orphan storm — evicts C from a
small ring while keeping B1. Reconnect is truncated; /history's orphan
trim returns a non-null cursor (B1 still replayable); the cursor
reconnect's replay_ok delta rebuilds the in-flight turn; convergence
holds with no duplicate ids per connection."""
# A small ring so turn 1's tail evicts the disconnected consumer's cursor.
# Read at UI construction, so patch BEFORE the workstream is created.
monkeypatch.setattr("turnstone.core.session_ui_base._EVENT_BUFFER_MAX", 15)
release = str(tmp_path / "release_turn2") # type: ignore[operator]
srv = make_server()
t1 = "for i in $(seq 1 30); do echo t1-$i; sleep 0.04; done"
# Turn 2 is FILE-GATED: it stays in-flight until the test releases it,
# removing every timing race around "reconnect while the turn is live".
gated = f"echo t2-start; while [ ! -f {release} ]; do sleep 0.05; done; seq 1 20; echo t2-done"
t2_ids = [f"c{i}" for i in range(3)]
ws_id = srv.create_workstream(
bash_toolcall_script("t1call", t1),
final_text_script("t1 done"),
parallel_bash_script(dict.fromkeys(t2_ids, gated)),
final_text_script("t2 done"),
name="truncate",
)
client = BrowserlikeSSEClient(srv.base_url, ws_id, srv.token)
try:
client.connect()
srv.send(ws_id, "turn 1")
client.wait_for(lambda c: len(c.frames_of_type("tool_output_chunk")) >= 2, timeout=20)
cursor_c = int(client.last_event_id or "-1")
client.disconnect()
srv.wait_turn(ws_id, timeout=30) # turn 1 commits
b1 = srv.max_event_id(ws_id)
assert b1 is not None and b1 > cursor_c
srv.send(ws_id, "turn 2 storm") # the in-flight orphan
def orphan_inflight_and_c_evicted() -> bool:
earliest, latest = srv.ring_span(ws_id)
return (
srv.ws_state(ws_id) == "running"
and earliest is not None
and earliest > cursor_c
and latest > b1 + 2
)
_wait(orphan_inflight_and_c_evicted, timeout=20, what="turn 2 in-flight + C evicted")
# Reconnect stale -> replay_truncated with a real lost_count.
client.connect()
truncated = client.wait_for_type("replay_truncated", timeout=15)
assert truncated.payload is not None
assert truncated.payload["lost_count"] > 0
# /history's orphan trim returns a non-null (replayable) cursor while
# the turn is in flight.
hist = client.fetch_history()
assert hist["cursor"] is not None, "in-flight orphan should trim to a resume cursor"
# The fix's flow: disconnect -> /history -> adopt cursor -> reconnect.
client.load_history_then_connect()
# The cursor-adoption reconnect takes replay_ok (no second truncation)
# and its delta rebuilds the in-flight turn's tool events.
client.wait_for(
lambda c: any(
f.conn_index == c.num_connections() - 1
and f.etype in ("tool_pending", "tool_info", "tool_output_chunk")
for f in c.all_frames()
),
timeout=15,
)
adopt_conn = client.latest_conn_frames()
assert not any(f.etype == "replay_truncated" for f in adopt_conn)
# Release the orphan; it runs to completion and the transcript converges.
open(release, "w").close() # noqa: SIM115 — a one-shot release flag
srv.wait_turn(ws_id, timeout=40)
for cid in t2_ids:
client.wait_for_call_result(cid, timeout=25)
time.sleep(0.4)
assert_converged(client, srv.fetch_history(ws_id))
# No duplicate ids WITHIN any single connection (cross-connection
# overlap on reconnect is legal; intra-connection dups are not).
for i in range(client.num_connections()):
ids = [f.event_id_int for f in client.conn_frames(i) if f.event_id_int is not None]
assert len(ids) == len(set(ids)), f"duplicate ids on connection {i}: {ids}"
finally:
client.close()
# ---------------------------------------------------------------------------
# Scenario 4 — rehydrate / restart honesty (fix-2 server)
# ---------------------------------------------------------------------------
def test_restart_truncated_honesty(make_server: Callable[..., RecoveryServer]) -> None:
"""A client whose cursor predates the turn's committed messages
disconnects; the turn completes (storage counter = seeded); the node
restarts (fresh UI, empty ring, storage-seeded counter). Reconnecting
with the stale cursor draws ``replay_truncated`` (NOT a silent
``replay_ok``) with the exact lost_count; /history returns cursor=null
(empty ring); a fresh connect converges. The no-loss variant —
cursor == seeded — draws ``replay_ok`` (empty)."""
srv1 = make_server()
call_ids = ["r0", "r1"]
ws_id = srv1.create_workstream(
parallel_bash_script(dict.fromkeys(call_ids, PACED_STORM)),
final_text_script("done"),
name="restart",
)
watcher = BrowserlikeSSEClient(srv1.base_url, ws_id, srv1.token)
try:
watcher.connect()
srv1.send(ws_id, "go")
# Disconnect MID-turn: the cursor predates the committed messages.
watcher.wait_for(lambda c: len(c.frames_of_type("tool_output_chunk")) >= 2, timeout=20)
stale_cursor = int(watcher.last_event_id or "-1")
watcher.disconnect()
finally:
watcher.close()
srv1.wait_turn(ws_id, timeout=45) # turn completes; committed counter set
seeded = srv1.max_event_id(ws_id)
assert seeded is not None and stale_cursor < seeded
srv1.stop() # node crash — the ring dies with the process, the DB survives
# Restart on the same DB.
srv2 = make_server()
srv2.open_workstream(ws_id) # rehydrate: fresh UI, empty ring, seeded counter
assert srv2.max_event_id(ws_id) == seeded
# LOSS: reconnect with the stale cursor -> truncated with exact lost_count.
loss = BrowserlikeSSEClient(srv2.base_url, ws_id, srv2.token)
try:
loss._last_event_id = str(stale_cursor)
loss.connect()
tf = loss.wait_for_type("replay_truncated", timeout=15)
assert tf.payload is not None
assert tf.payload["lost_count"] == seeded - stale_cursor
# /history on an empty ring withholds the cursor (can_replay_from
# asymmetry) — the client rebuilds from the REST snapshot instead.
hist = loss.fetch_history()
assert hist["cursor"] is None
# The committed conversation is intact — a fresh /history projection
# carries every tool result (no permanently lost turn).
recovered = history_tool_outputs(hist)
assert set(call_ids).issubset(set(recovered))
finally:
loss.close()
# NO-LOSS: cursor == seeded counter -> replay_ok (empty), never truncated.
noloss = BrowserlikeSSEClient(srv2.base_url, ws_id, srv2.token)
try:
noloss._last_event_id = str(seeded)
noloss.connect()
time.sleep(1.0)
assert not noloss.has_type("replay_truncated"), (
"cursor == seeded is no loss — must be replay_ok, not truncated"
)
finally:
noloss.close()
# ---------------------------------------------------------------------------
# Scenario 5 — failed-resync retry via the truncation record
# ---------------------------------------------------------------------------
def test_failed_resync_retries_via_truncation_record(
make_server: Callable[..., RecoveryServer],
) -> None:
"""Restart truncation as in scenario 4, but the client's FIRST /history
resync fails: the reconnect re-presents the truncation-time cursor and
draws ``replay_truncated`` AGAIN (the retry stays armed). A subsequent
successful /history then converges."""
srv1 = make_server()
call_ids = ["r0", "r1"]
ws_id = srv1.create_workstream(
parallel_bash_script(dict.fromkeys(call_ids, PACED_STORM)),
final_text_script("done"),
name="failed-resync",
)
watcher = BrowserlikeSSEClient(srv1.base_url, ws_id, srv1.token)
try:
watcher.connect()
srv1.send(ws_id, "go")
watcher.wait_for(lambda c: len(c.frames_of_type("tool_output_chunk")) >= 2, timeout=20)
stale_cursor = int(watcher.last_event_id or "-1")
watcher.disconnect()
finally:
watcher.close()
srv1.wait_turn(ws_id, timeout=45)
seeded = srv1.max_event_id(ws_id)
assert seeded is not None and stale_cursor < seeded
srv1.stop()
srv2 = make_server()
srv2.open_workstream(ws_id)
client = BrowserlikeSSEClient(srv2.base_url, ws_id, srv2.token)
try:
client._last_event_id = str(stale_cursor)
client.connect()
client.wait_for_type("replay_truncated", timeout=15)
# The truncation-time cursor is recorded (keep-oldest).
assert client.truncated_from_cursor == str(stale_cursor)
# FIRST resync FAILS: /history is not fetched, so the record stays
# armed and the reconnect re-presents the truncation-time cursor ->
# the server re-answers replay_truncated on the NEW connection.
client.load_history_then_connect(fail_history=True)
client.wait_for(
lambda c: any(f.etype == "replay_truncated" for f in c.latest_conn_frames()),
timeout=15,
)
assert client.truncated_from_cursor == str(stale_cursor), "record must stay armed"
# SUCCESSFUL resync: /history clears the record and the committed
# conversation is recovered intact.
data = client.load_history_then_connect()
assert data is not None
assert client.truncated_from_cursor is None
recovered = history_tool_outputs(data)
assert set(call_ids).issubset(set(recovered))
finally:
client.close()
# ---------------------------------------------------------------------------
# Scenario 6 — sub-agent storm attribution (fix-3 parent stamping)
# ---------------------------------------------------------------------------
def test_subagent_storm_attribution(make_server: Callable[..., RecoveryServer]) -> None:
"""A task_agent whose sub-tools are chatty bashes. Every chunk / tool
event for a child call carries ``parent_call_id`` (stamped at the batch
flush by its still-registered call_id); none escape unstamped to the top
level; the child chunk streams reconstruct losslessly."""
import json
srv = make_server()
task_call = dict(
tool_calls=[
{
"id": "task1",
"name": "task_agent",
"arguments": json.dumps({"prompt": "run the sub-tools"}),
}
],
finish_reason="tool_calls",
)
sub_bash = dict(
tool_calls=[
{"id": "sub_a", "name": "bash", "arguments": json.dumps({"command": ": a; seq 1 300"})},
{"id": "sub_b", "name": "bash", "arguments": json.dumps({"command": ": b; seq 1 300"})},
],
finish_reason="tool_calls",
)
ws_id = srv.create_workstream(
task_call, # parent issues the task_agent
sub_bash, # sub-agent issues chatty bashes
final_text_script("sub done"), # sub-agent finishes
final_text_script("parent done"), # parent finishes
name="subagent",
)
client = BrowserlikeSSEClient(srv.base_url, ws_id, srv.token)
try:
client.connect()
srv.send(ws_id, "spawn a sub agent")
srv.wait_turn(ws_id, timeout=60)
client.wait_for_call_result("task1", timeout=25)
time.sleep(0.6)
# Every child tool event carries parent_call_id="task1"; none escape.
assert_children_stamped(client.all_frames(), "task1")
# The two sub-tools' minted call_ids appear and reconstruct losslessly.
child_output = {
cid: text for cid, text in client.tool_output_by_call().items() if "::" in cid
}
assert len(child_output) == 2, sorted(child_output)
seq_300 = "".join(f"{n}\n" for n in range(1, 301))
for cid, text in child_output.items():
assert text == seq_300, f"child {cid} chunk stream diverged from seq 1..300"
# The parent's synthesis result is present.
assert "task1" in client.tool_results_by_call()
finally:
client.close()
# ---------------------------------------------------------------------------
# Shared wait helper
# ---------------------------------------------------------------------------
def _wait(predicate: Callable[[], bool], *, timeout: float, what: str) -> None:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if predicate():
return
time.sleep(0.03)
raise AssertionError(f"timed out waiting for {what}")