* fix(streaming): lock-disciplined STREAMS reads via peek_stream() Writers pop STREAMS under STREAMS_LOCK, but four hot reads used bare STREAMS.get(): the streaming worker start, the gateway run start, and the two SSE attach paths in routes.py. Each is individually saved today by GIL-atomic dict.get plus a None-guard, but the check-then-act windows (get → register, get → subscribe) race teardown pops, and the bare pattern invites unprotected copies. peek_stream() centralizes the locked read; all four sites migrate with no behavioral change. * test: dual-patch config.STREAMS alongside routes.STREAMS in SSE seam tests peek_stream() reads the canonical api.config.STREAMS registry; tests that inject a fake registry by patching only the routes-level re-export left the helper reading the real (empty) registry and hung the SSE loop to the pytest-timeout ceiling. Mirror the test_issue6623 pattern: patch both bindings. * docs(changelog): stamp #7339 lock-disciplined STREAMS reads --------- Co-authored-by: Sebastian B <sb@wmc.sh> Co-authored-by: n <a@n>
905 lines
33 KiB
Python
905 lines
33 KiB
Python
from __future__ import annotations
|
|
|
|
import io
|
|
import queue
|
|
from types import SimpleNamespace
|
|
from urllib.parse import urlparse
|
|
|
|
from api.run_journal import append_run_event, read_session_run_events
|
|
|
|
|
|
class _FakeHandler:
|
|
def __init__(self):
|
|
self.status = None
|
|
self.headers = {}
|
|
self.wfile = io.BytesIO()
|
|
self.command = "GET"
|
|
self.path = "/"
|
|
self.client_address = ("127.0.0.1", 12345)
|
|
|
|
def send_response(self, status):
|
|
self.status = status
|
|
|
|
def send_header(self, key, value):
|
|
self.headers[key] = value
|
|
|
|
def end_headers(self):
|
|
pass
|
|
|
|
|
|
def _capture(monkeypatch):
|
|
cap = {}
|
|
|
|
def _j(_h, obj, *_, **kwargs):
|
|
cap["ok"] = obj
|
|
cap["status"] = kwargs.get("status", 200)
|
|
return True
|
|
|
|
def _bad(_h, msg, code=400):
|
|
cap["bad"] = (msg, code)
|
|
return True
|
|
|
|
monkeypatch.setattr("api.routes.j", _j)
|
|
monkeypatch.setattr("api.routes.bad", _bad)
|
|
return cap
|
|
|
|
|
|
def _stop_after_first_heartbeat(monkeypatch):
|
|
calls = {"count": 0}
|
|
|
|
def _sleep(_seconds):
|
|
calls["count"] += 1
|
|
raise BrokenPipeError("stop after the first heartbeat")
|
|
|
|
monkeypatch.setattr("api.routes.time.sleep", _sleep)
|
|
return calls
|
|
|
|
|
|
def test_session_route_and_global_route_stay_separate(monkeypatch):
|
|
import api.routes as routes
|
|
|
|
calls = {"global": 0, "session": 0}
|
|
|
|
monkeypatch.setattr(routes, "_handle_extension_sidecar_proxy", lambda *_args, **_kwargs: False)
|
|
monkeypatch.setattr(
|
|
routes,
|
|
"_handle_session_events_stream",
|
|
lambda *_args, **_kwargs: calls.__setitem__("global", calls["global"] + 1) or True,
|
|
)
|
|
monkeypatch.setattr(
|
|
routes,
|
|
"_handle_session_sse_stream_for_session",
|
|
lambda *_args, **_kwargs: calls.__setitem__("session", calls["session"] + 1) or True,
|
|
)
|
|
|
|
assert routes._session_events_path_session_id("/api/sessions/events") is None
|
|
assert routes._session_events_path_session_id("/api/sessions/session_1/events") == "session_1"
|
|
assert routes._session_events_path_session_id("/api/sessions/session_1/events/extra") is None
|
|
|
|
handler = _FakeHandler()
|
|
routes.handle_get(handler, urlparse("/api/sessions/events"))
|
|
routes.handle_get(handler, urlparse("/api/sessions/session_1/events"))
|
|
|
|
assert calls == {"global": 1, "session": 1}
|
|
|
|
|
|
def test_session_journal_replays_later_runs_after_cursor(tmp_path):
|
|
append_run_event(
|
|
"session_1",
|
|
"run_a",
|
|
"token",
|
|
{"text": "a1"},
|
|
session_dir=tmp_path,
|
|
seq=1,
|
|
created_at=100.0,
|
|
)
|
|
append_run_event(
|
|
"session_1",
|
|
"run_a",
|
|
"token",
|
|
{"text": "a2"},
|
|
session_dir=tmp_path,
|
|
seq=2,
|
|
created_at=101.0,
|
|
)
|
|
append_run_event(
|
|
"session_1",
|
|
"run_b",
|
|
"token",
|
|
{"text": "b1"},
|
|
session_dir=tmp_path,
|
|
seq=1,
|
|
created_at=200.0,
|
|
)
|
|
append_run_event(
|
|
"session_1",
|
|
"run_b",
|
|
"done",
|
|
{"session": {"session_id": "session_1"}},
|
|
session_dir=tmp_path,
|
|
seq=2,
|
|
created_at=201.0,
|
|
)
|
|
append_run_event(
|
|
"session_2",
|
|
"run_other",
|
|
"token",
|
|
{"text": "other"},
|
|
session_dir=tmp_path,
|
|
seq=1,
|
|
created_at=150.0,
|
|
)
|
|
|
|
replay = read_session_run_events("session_1", after_event_id="run_a:1", session_dir=tmp_path)
|
|
|
|
assert replay["status"] == "ok"
|
|
assert [event["event_id"] for event in replay["events"]] == ["run_a:2", "run_b:1", "run_b:2"]
|
|
|
|
|
|
def test_session_journal_distinguishes_missing_and_foreign_cursor(tmp_path):
|
|
append_run_event(
|
|
"session_1",
|
|
"run_a",
|
|
"token",
|
|
{"text": "a1"},
|
|
session_dir=tmp_path,
|
|
seq=1,
|
|
created_at=100.0,
|
|
)
|
|
append_run_event(
|
|
"session_2",
|
|
"run_b",
|
|
"token",
|
|
{"text": "b1"},
|
|
session_dir=tmp_path,
|
|
seq=1,
|
|
created_at=200.0,
|
|
)
|
|
|
|
missing = read_session_run_events("session_1", after_event_id="run_missing:1", session_dir=tmp_path)
|
|
foreign = read_session_run_events("session_1", after_event_id="run_b:1", session_dir=tmp_path)
|
|
|
|
assert missing["status"] == "cursor_run_missing"
|
|
assert foreign["status"] == "cursor_session_mismatch"
|
|
|
|
|
|
def test_session_journal_rejects_hostile_cursor_without_replay(tmp_path, monkeypatch):
|
|
append_run_event(
|
|
"session_1",
|
|
"run_a",
|
|
"token",
|
|
{"text": "a1"},
|
|
session_dir=tmp_path,
|
|
seq=1,
|
|
created_at=100.0,
|
|
)
|
|
monkeypatch.setattr(
|
|
"api.run_journal.find_run_summary",
|
|
lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError("invalid cursor should not scan summaries")),
|
|
)
|
|
|
|
malformed = read_session_run_events("session_1", after_event_id="bad/run:1", session_dir=tmp_path)
|
|
negative = read_session_run_events("session_1", after_event_id="run_a:-5", session_dir=tmp_path)
|
|
zero = read_session_run_events("session_1", after_event_id="run_a:0", session_dir=tmp_path)
|
|
|
|
assert malformed["status"] == "cursor_invalid"
|
|
assert malformed["events"] == []
|
|
assert negative["status"] == "cursor_invalid"
|
|
assert negative["events"] == []
|
|
assert zero["status"] == "cursor_invalid"
|
|
assert zero["events"] == []
|
|
|
|
|
|
def test_session_journal_blank_summary_session_stays_missing(tmp_path, monkeypatch):
|
|
monkeypatch.setattr("api.run_journal.find_run_summary", lambda *_args, **_kwargs: {"session_id": ""})
|
|
|
|
replay = read_session_run_events("session_1", after_event_id="run_missing:1", session_dir=tmp_path)
|
|
|
|
assert replay["status"] == "cursor_run_missing"
|
|
|
|
|
|
def test_session_journal_rejects_bounds_and_invalid_contiguous_rows(tmp_path):
|
|
append_run_event("session_1", "run_a", "token", {"text": "one"}, session_dir=tmp_path, seq=1)
|
|
append_run_event("session_1", "run_a", "token", {"text": "two"}, session_dir=tmp_path, seq=2)
|
|
|
|
rows_limited = read_session_run_events("session_1", after_event_id="run_a:1", session_dir=tmp_path, max_rows=1)
|
|
bytes_limited = read_session_run_events("session_1", after_event_id="run_a:1", session_dir=tmp_path, max_bytes=1)
|
|
assert rows_limited["status"] != "ok" and rows_limited["events"] == []
|
|
assert bytes_limited["status"] != "ok" and bytes_limited["events"] == []
|
|
|
|
path = tmp_path / "_run_journal" / "session_1" / "run_a.jsonl"
|
|
path.write_text(path.read_text(encoding="utf-8").replace('"event_id":"run_a:2"', '"event_id":"wrong:2"'), encoding="utf-8")
|
|
invalid = read_session_run_events("session_1", after_event_id="run_a:1", session_dir=tmp_path)
|
|
assert invalid["status"] == "replay_noncontiguous"
|
|
assert invalid["events"] == []
|
|
|
|
|
|
def test_session_journal_rejects_oversized_line_before_decode(tmp_path, monkeypatch):
|
|
path = tmp_path / "_run_journal" / "session_1" / "run_a.jsonl"
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
payload = '{"event_id":"run_a:1","seq":1,"run_id":"run_a","session_id":"session_1","event":"done","type":"done","payload":{"text":"' + ("x" * 5000) + '"}}\n'
|
|
path.write_bytes(payload.encode("utf-8"))
|
|
monkeypatch.setattr("api.run_journal.json.loads", lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError("oversized line should not decode")))
|
|
|
|
limited = read_session_run_events("session_1", after_event_id="run_a:1", session_dir=tmp_path, max_bytes=1024)
|
|
|
|
assert limited["status"] == "replay_limit_bytes"
|
|
assert limited["events"] == []
|
|
|
|
|
|
def test_session_journal_accepts_line_at_cap_and_rejects_next_byte(tmp_path):
|
|
path = tmp_path / "_run_journal" / "session_1" / "run_a.jsonl"
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
payload = '{"event_id":"run_a:1","seq":1,"run_id":"run_a","session_id":"session_1","event":"done","type":"done","payload":{"text":"ok"}}\n'
|
|
path.write_bytes(payload.encode("utf-8"))
|
|
cap = len(payload.encode("utf-8"))
|
|
|
|
at_cap = read_session_run_events("session_1", after_event_id="run_a:1", session_dir=tmp_path, max_bytes=cap)
|
|
over = read_session_run_events("session_1", after_event_id="run_a:1", session_dir=tmp_path, max_bytes=cap - 1)
|
|
|
|
assert at_cap["status"] == "ok"
|
|
assert at_cap["events"] == []
|
|
assert over["status"] == "replay_limit_bytes"
|
|
assert over["events"] == []
|
|
|
|
|
|
def test_session_journal_requires_exact_cursor_and_contiguous_suffix(tmp_path):
|
|
append_run_event("session_1", "run_a", "token", {}, session_dir=tmp_path, seq=1)
|
|
exact_missing = read_session_run_events("session_1", after_event_id="run_a:2", session_dir=tmp_path)
|
|
append_run_event("session_1", "run_a", "token", {}, session_dir=tmp_path, seq=3)
|
|
|
|
gap = read_session_run_events("session_1", after_event_id="run_a:1", session_dir=tmp_path)
|
|
|
|
assert exact_missing["status"] != "ok"
|
|
assert exact_missing["events"] == []
|
|
assert gap["events"] == []
|
|
|
|
|
|
def test_session_journal_accepts_final_cursor_and_replays_later_runs(tmp_path):
|
|
append_run_event("session_1", "run_a", "token", {}, session_dir=tmp_path, seq=1, created_at=100.0)
|
|
append_run_event("session_1", "run_b", "token", {}, session_dir=tmp_path, seq=1, created_at=200.0)
|
|
|
|
replay = read_session_run_events("session_1", after_event_id="run_a:1", session_dir=tmp_path)
|
|
|
|
assert replay["status"] == "ok"
|
|
assert [event["event_id"] for event in replay["events"]] == ["run_b:1"]
|
|
|
|
|
|
def test_session_route_prefers_last_event_id_then_query_fallback(monkeypatch):
|
|
import api.routes as routes
|
|
|
|
captured = []
|
|
stop = _stop_after_first_heartbeat(monkeypatch)
|
|
|
|
monkeypatch.setattr(routes, "_session_id_visible_to_request_profile", lambda *_args, **_kwargs: True)
|
|
monkeypatch.setattr(
|
|
routes,
|
|
"get_session",
|
|
lambda sid, metadata_only=False: SimpleNamespace(
|
|
session_id=sid,
|
|
compact=lambda **_kwargs: {"session_id": sid, "title": "Session"},
|
|
),
|
|
)
|
|
monkeypatch.setattr(routes, "_active_run_stream_for_session", lambda *_args, **_kwargs: None)
|
|
monkeypatch.setattr(
|
|
routes,
|
|
"read_session_run_events",
|
|
lambda _session_id, *, after_event_id=None, session_dir=None: captured.append(after_event_id) or {"status": "ok", "events": []},
|
|
)
|
|
|
|
handler = _FakeHandler()
|
|
handler.headers["Last-Event-ID"] = "run_a:2"
|
|
routes._handle_session_sse_stream_for_session(
|
|
handler,
|
|
urlparse("/api/sessions/session_1/events?after_event_id=run_b:1"),
|
|
"session_1",
|
|
)
|
|
|
|
handler = _FakeHandler()
|
|
routes._handle_session_sse_stream_for_session(
|
|
handler,
|
|
urlparse("/api/sessions/session_1/events?after_event_id=run_b:1"),
|
|
"session_1",
|
|
)
|
|
|
|
assert stop["count"] >= 2
|
|
assert captured == ["run_a:2", "run_b:1"]
|
|
|
|
|
|
def test_session_route_arms_deadline_immediately_after_headers(monkeypatch):
|
|
import api.routes as routes
|
|
|
|
calls = []
|
|
stop = _stop_after_first_heartbeat(monkeypatch)
|
|
monkeypatch.setattr(routes, "_session_id_visible_to_request_profile", lambda *_args, **_kwargs: True)
|
|
monkeypatch.setattr(routes, "get_session", lambda sid, metadata_only=False: SimpleNamespace(session_id=sid, compact=lambda **_kwargs: {}))
|
|
monkeypatch.setattr(routes, "_active_run_stream_for_session", lambda *_args, **_kwargs: None)
|
|
monkeypatch.setattr(routes, "end_sse_headers", lambda _handler: calls.append("headers"))
|
|
monkeypatch.setattr(routes, "_sse_set_write_deadline", lambda _handler: calls.append("deadline"))
|
|
|
|
routes._handle_session_sse_stream_for_session(_FakeHandler(), urlparse("/api/sessions/session_1/events"), "session_1")
|
|
|
|
assert calls[:2] == ["headers", "deadline"]
|
|
assert stop["count"] == 1
|
|
|
|
|
|
def test_session_route_emits_snapshot_without_id_for_missing_cursor_and_keepalive(monkeypatch):
|
|
import api.routes as routes
|
|
|
|
stop = _stop_after_first_heartbeat(monkeypatch)
|
|
monkeypatch.setattr(routes, "_session_id_visible_to_request_profile", lambda *_args, **_kwargs: True)
|
|
monkeypatch.setattr(
|
|
routes,
|
|
"get_session",
|
|
lambda sid, metadata_only=False: SimpleNamespace(
|
|
session_id=sid,
|
|
compact=lambda **_kwargs: {"session_id": sid, "title": "Session"},
|
|
),
|
|
)
|
|
monkeypatch.setattr(routes, "_active_run_stream_for_session", lambda *_args, **_kwargs: None)
|
|
monkeypatch.setattr(
|
|
routes,
|
|
"read_session_run_events",
|
|
lambda *_args, **_kwargs: {"status": "cursor_run_missing", "events": []},
|
|
)
|
|
|
|
handler = _FakeHandler()
|
|
routes._handle_session_sse_stream_for_session(
|
|
handler,
|
|
urlparse("/api/sessions/session_1/events?after_event_id=run_missing:1"),
|
|
"session_1",
|
|
)
|
|
|
|
body = handler.wfile.getvalue().decode("utf-8")
|
|
assert "event: session_snapshot\n" in body
|
|
assert ": keepalive\n\n" in body
|
|
assert "id: " not in body
|
|
assert stop["count"] == 1
|
|
|
|
|
|
def test_session_route_resyncs_when_run_completes_inside_idle_wait(monkeypatch):
|
|
"""Idle no-cursor subscriber: a run that starts+finishes inside one keepalive
|
|
tick advances the journal with no live stream to attach. The handler must emit
|
|
a session_snapshot re-sync so the client catches that run instead of silently
|
|
missing it until manual refresh (Fable gate finding on #5677)."""
|
|
import api.routes as routes
|
|
|
|
stop = _stop_after_first_heartbeat(monkeypatch)
|
|
monkeypatch.setattr(routes, "_session_id_visible_to_request_profile", lambda *_a, **_k: True)
|
|
monkeypatch.setattr(
|
|
routes,
|
|
"get_session",
|
|
lambda sid, metadata_only=False: SimpleNamespace(
|
|
session_id=sid,
|
|
compact=lambda **_kwargs: {"session_id": sid, "title": "Session"},
|
|
),
|
|
)
|
|
# No live stream ever attaches (the run completed before we could subscribe).
|
|
monkeypatch.setattr(routes, "_active_run_stream_for_session", lambda *_a, **_k: None)
|
|
# No cursor → no replay; the missing-cursor path returns ok/empty.
|
|
monkeypatch.setattr(routes, "read_session_run_events", lambda *_a, **_k: {"status": "ok", "events": []})
|
|
# Fingerprint advances between the pre-loop baseline and the in-loop check,
|
|
# simulating a run completing during the wait.
|
|
fps = iter([(0, 0.0, 0), (1, 123.0, 42)])
|
|
monkeypatch.setattr(routes, "session_journal_fingerprint", lambda *_a, **_k: next(fps, (1, 123.0, 42)))
|
|
|
|
handler = _FakeHandler()
|
|
routes._handle_session_sse_stream_for_session(
|
|
handler,
|
|
urlparse("/api/sessions/session_1/events"),
|
|
"session_1",
|
|
)
|
|
|
|
body = handler.wfile.getvalue().decode("utf-8")
|
|
assert "event: session_snapshot\n" in body, "idle journal advance must emit a snapshot re-sync"
|
|
assert ": keepalive\n\n" in body
|
|
assert stop["count"] == 1
|
|
|
|
|
|
def test_session_route_baselines_journal_before_first_attach(monkeypatch):
|
|
"""TOCTOU regression: the idle-wait fingerprint baseline must be captured BEFORE
|
|
the first live-attach lookup. If it were captured afterward, a run completing
|
|
during that first attach would fold into the baseline and be silently missed.
|
|
Assert the call ORDER: session_journal_fingerprint fires before the attach loop's
|
|
active-stream lookup, and a subsequent journal advance still emits a snapshot."""
|
|
import api.routes as routes
|
|
|
|
stop = _stop_after_first_heartbeat(monkeypatch)
|
|
monkeypatch.setattr(routes, "_session_id_visible_to_request_profile", lambda *_a, **_k: True)
|
|
monkeypatch.setattr(
|
|
routes,
|
|
"get_session",
|
|
lambda sid, metadata_only=False: SimpleNamespace(
|
|
session_id=sid,
|
|
compact=lambda **_kwargs: {"session_id": sid, "title": "Session"},
|
|
),
|
|
)
|
|
order = []
|
|
fp_calls = {"n": 0}
|
|
|
|
def _active(*_a, **_k):
|
|
order.append("attach")
|
|
return None
|
|
|
|
def _fp(*_a, **_k):
|
|
fp_calls["n"] += 1
|
|
order.append("fp")
|
|
# First fp call = pre-attach baseline (old); any later call = advanced.
|
|
return (0, 0.0, 0) if fp_calls["n"] == 1 else (1, 123.0, 42)
|
|
|
|
monkeypatch.setattr(routes, "_active_run_stream_for_session", _active)
|
|
monkeypatch.setattr(routes, "read_session_run_events", lambda *_a, **_k: {"status": "ok", "events": []})
|
|
monkeypatch.setattr(routes, "session_journal_fingerprint", _fp)
|
|
|
|
handler = _FakeHandler()
|
|
routes._handle_session_sse_stream_for_session(
|
|
handler,
|
|
urlparse("/api/sessions/session_1/events"),
|
|
"session_1",
|
|
)
|
|
|
|
body = handler.wfile.getvalue().decode("utf-8")
|
|
# The baseline fp must precede the FIRST in-loop attach that follows it. Concretely:
|
|
# the first fp call happens before the attach-lookup that enters the idle wait.
|
|
assert "fp" in order, "the idle path must baseline the journal fingerprint"
|
|
first_fp = order.index("fp")
|
|
# There must be an attach recorded AFTER the baseline (the loop attach) and the
|
|
# subsequent advance must surface as a snapshot re-sync.
|
|
assert "attach" in order[first_fp:], "an attach must follow the baseline in the idle loop"
|
|
assert body.count("event: session_snapshot\n") == 1, "a journal advance after baseline must emit one re-sync"
|
|
assert stop["count"] == 1
|
|
|
|
|
|
def test_session_route_no_resync_when_idle_journal_unchanged(monkeypatch):
|
|
"""The idle-wait re-sync must fire ONLY on a genuine journal advance — a quiet
|
|
idle connection (fingerprint unchanged) must NOT spam snapshots every tick."""
|
|
import api.routes as routes
|
|
|
|
stop = _stop_after_first_heartbeat(monkeypatch)
|
|
monkeypatch.setattr(routes, "_session_id_visible_to_request_profile", lambda *_a, **_k: True)
|
|
monkeypatch.setattr(
|
|
routes,
|
|
"get_session",
|
|
lambda sid, metadata_only=False: SimpleNamespace(
|
|
session_id=sid,
|
|
compact=lambda **_kwargs: {"session_id": sid, "title": "Session"},
|
|
),
|
|
)
|
|
monkeypatch.setattr(routes, "_active_run_stream_for_session", lambda *_a, **_k: None)
|
|
monkeypatch.setattr(routes, "read_session_run_events", lambda *_a, **_k: {"status": "ok", "events": []})
|
|
# Fingerprint never changes → no re-sync.
|
|
monkeypatch.setattr(routes, "session_journal_fingerprint", lambda *_a, **_k: (2, 99.0, 77))
|
|
|
|
handler = _FakeHandler()
|
|
routes._handle_session_sse_stream_for_session(
|
|
handler,
|
|
urlparse("/api/sessions/session_1/events"),
|
|
"session_1",
|
|
)
|
|
|
|
body = handler.wfile.getvalue().decode("utf-8")
|
|
assert "event: session_snapshot\n" not in body, "quiet idle wait must not emit a snapshot"
|
|
assert ": keepalive\n\n" in body
|
|
assert stop["count"] == 1
|
|
|
|
|
|
def test_session_route_blocks_hidden_sessions_before_replay_or_live_attach(monkeypatch):
|
|
import api.routes as routes
|
|
|
|
cap = _capture(monkeypatch)
|
|
monkeypatch.setattr(
|
|
routes,
|
|
"get_session",
|
|
lambda sid, metadata_only=False: SimpleNamespace(session_id=sid, profile="other"),
|
|
)
|
|
monkeypatch.setattr(routes, "_get_active_profile_name", lambda: "default")
|
|
monkeypatch.setattr(
|
|
routes,
|
|
"read_session_run_events",
|
|
lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError("replay should not run")),
|
|
)
|
|
monkeypatch.setattr(
|
|
routes,
|
|
"_active_run_stream_for_session",
|
|
lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError("live attach should not run")),
|
|
)
|
|
|
|
handler = _FakeHandler()
|
|
routes.handle_get(handler, urlparse("/api/sessions/hidden_session/events"))
|
|
|
|
assert cap["bad"] == ("Session not found", 404)
|
|
|
|
|
|
def test_session_route_live_delivery_skips_replayed_active_run_items(monkeypatch):
|
|
import api.routes as routes
|
|
|
|
class _FakeStream:
|
|
def __init__(self):
|
|
self.q = queue.Queue()
|
|
self.q.put_nowait(("token", {"text": "live replayed"}, "run_active:1"))
|
|
self.q.put_nowait(("stream_end", {"status": "done"}, "run_active:2"))
|
|
self.unsubscribed = False
|
|
|
|
def subscribe_with_snapshot(self):
|
|
return self.q, {"last_event_id": "run_active:1", "offline_buffered_events": 1}
|
|
|
|
def unsubscribe(self, q):
|
|
self.unsubscribed = q is self.q
|
|
|
|
stream = _FakeStream()
|
|
monkeypatch.setattr(routes, "_session_id_visible_to_request_profile", lambda *_args, **_kwargs: True)
|
|
monkeypatch.setattr(
|
|
routes,
|
|
"get_session",
|
|
lambda sid, metadata_only=False: SimpleNamespace(
|
|
session_id=sid,
|
|
compact=lambda **_kwargs: {"session_id": sid, "title": "Session"},
|
|
),
|
|
)
|
|
monkeypatch.setattr(routes, "_active_run_stream_for_session", lambda *_args, **_kwargs: "run_active")
|
|
monkeypatch.setattr(routes, "STREAMS", {"run_active": stream})
|
|
import api.config as config
|
|
monkeypatch.setattr(
|
|
config,
|
|
"STREAMS",
|
|
{"run_active": stream},
|
|
)
|
|
monkeypatch.setattr(
|
|
routes,
|
|
"read_session_run_events",
|
|
lambda *_args, **_kwargs: {
|
|
"status": "ok",
|
|
"events": [
|
|
{
|
|
"run_id": "run_prev",
|
|
"seq": 1,
|
|
"event": "token",
|
|
"payload": {"text": "replayed"},
|
|
"event_id": "run_prev:1",
|
|
},
|
|
{
|
|
"run_id": "run_active",
|
|
"seq": 1,
|
|
"event": "token",
|
|
"payload": {"text": "replayed current"},
|
|
"event_id": "run_active:1",
|
|
},
|
|
],
|
|
},
|
|
)
|
|
|
|
handler = _FakeHandler()
|
|
routes._handle_session_sse_stream_for_session(
|
|
handler,
|
|
urlparse("/api/sessions/session_1/events?after_event_id=run_prev:0"),
|
|
"session_1",
|
|
)
|
|
|
|
body = handler.wfile.getvalue().decode("utf-8")
|
|
assert "id: run_prev:1\n" in body
|
|
assert body.count("id: run_active:1\n") == 1
|
|
assert "id: run_active:2\n" in body
|
|
assert "event: stream_end\n" in body
|
|
assert stream.unsubscribed is True
|
|
|
|
|
|
def test_session_route_breaks_on_terminal_even_when_reconciliation_replayed_it(monkeypatch):
|
|
"""CORE regression (#5677 gate r3): if reconciliation replays the active run's
|
|
TERMINAL event at the subscribe cutoff, the live queue's copy is deduped — but
|
|
the loop must STILL break on it and unsubscribe. Otherwise the handler stays
|
|
blocked on the dead run's queue and misses subsequent session runs."""
|
|
import api.routes as routes
|
|
|
|
class _FakeStream:
|
|
def __init__(self):
|
|
self.q = queue.Queue()
|
|
# The live queue re-delivers the terminal event that reconciliation
|
|
# already replayed at/below the cutoff (same event_id) — it must be
|
|
# deduped for output yet still end the loop.
|
|
self.q.put_nowait(("stream_end", {"status": "done"}, "run_active:1"))
|
|
self.unsubscribed = False
|
|
|
|
def subscribe_with_snapshot(self):
|
|
return self.q, {"last_event_id": "run_active:1", "offline_buffered_events": 0}
|
|
|
|
def unsubscribe(self, q):
|
|
self.unsubscribed = q is self.q
|
|
|
|
stream = _FakeStream()
|
|
monkeypatch.setattr(routes, "_session_id_visible_to_request_profile", lambda *_a, **_k: True)
|
|
monkeypatch.setattr(
|
|
routes,
|
|
"get_session",
|
|
lambda sid, metadata_only=False: SimpleNamespace(
|
|
session_id=sid,
|
|
compact=lambda **_kwargs: {"session_id": sid, "title": "Session"},
|
|
),
|
|
)
|
|
monkeypatch.setattr(routes, "_active_run_stream_for_session", lambda *_a, **_k: "run_active")
|
|
monkeypatch.setattr(routes, "STREAMS", {"run_active": stream})
|
|
import api.config as config
|
|
monkeypatch.setattr(
|
|
config,
|
|
"STREAMS",
|
|
{"run_active": stream},
|
|
)
|
|
# Reconciliation replays the active run's terminal event (run_active:1) — same id
|
|
# as the queued live copy, at the cutoff.
|
|
monkeypatch.setattr(
|
|
routes,
|
|
"read_session_run_events",
|
|
lambda *_a, **_k: {
|
|
"status": "ok",
|
|
"events": [
|
|
{"run_id": "run_active", "seq": 1, "event": "stream_end", "payload": {"status": "done"}, "event_id": "run_active:1"},
|
|
],
|
|
},
|
|
)
|
|
|
|
handler = _FakeHandler()
|
|
# If the fix regresses, the loop never breaks and blocks on subscriber.get();
|
|
# the _stop_after_first_heartbeat safety turns an unexpected wait into a failure.
|
|
_stop_after_first_heartbeat(monkeypatch)
|
|
routes._handle_session_sse_stream_for_session(
|
|
handler,
|
|
urlparse("/api/sessions/session_1/events?after_event_id=run_active:0"),
|
|
"session_1",
|
|
)
|
|
|
|
# The handler must have exited cleanly and unsubscribed — not hung on the queue.
|
|
assert stream.unsubscribed is True, "handler must unsubscribe after a terminal, even when deduped"
|
|
|
|
|
|
def test_session_route_unsubscribes_when_replay_disconnects(monkeypatch):
|
|
import api.routes as routes
|
|
|
|
class _DisconnectingWfile:
|
|
def write(self, _data):
|
|
raise BrokenPipeError("client closed")
|
|
|
|
def flush(self):
|
|
pass
|
|
|
|
class _FakeStream:
|
|
def __init__(self):
|
|
self.q = queue.Queue()
|
|
self.unsubscribed = False
|
|
|
|
def subscribe_with_snapshot(self):
|
|
return self.q, {"last_event_id": "run_active:1"}
|
|
|
|
def unsubscribe(self, q):
|
|
self.unsubscribed = q is self.q
|
|
|
|
stream = _FakeStream()
|
|
monkeypatch.setattr(routes, "_session_id_visible_to_request_profile", lambda *_args, **_kwargs: True)
|
|
monkeypatch.setattr(
|
|
routes,
|
|
"get_session",
|
|
lambda sid, metadata_only=False: SimpleNamespace(
|
|
session_id=sid,
|
|
compact=lambda **_kwargs: {"session_id": sid, "title": "Session"},
|
|
),
|
|
)
|
|
monkeypatch.setattr(routes, "_active_run_stream_for_session", lambda *_args, **_kwargs: "run_active")
|
|
monkeypatch.setattr(routes, "STREAMS", {"run_active": stream})
|
|
import api.config as config
|
|
monkeypatch.setattr(
|
|
config,
|
|
"STREAMS",
|
|
{"run_active": stream},
|
|
)
|
|
monkeypatch.setattr(
|
|
routes,
|
|
"read_session_run_events",
|
|
lambda *_args, **_kwargs: {
|
|
"status": "ok",
|
|
"events": [
|
|
{
|
|
"run_id": "run_prev",
|
|
"seq": 1,
|
|
"event": "token",
|
|
"payload": {"text": "replayed"},
|
|
"event_id": "run_prev:1",
|
|
},
|
|
],
|
|
},
|
|
)
|
|
|
|
handler = _FakeHandler()
|
|
handler.wfile = _DisconnectingWfile()
|
|
assert routes._handle_session_sse_stream_for_session(
|
|
handler,
|
|
urlparse("/api/sessions/session_1/events?after_event_id=run_prev:0"),
|
|
"session_1",
|
|
)
|
|
assert stream.unsubscribed is True
|
|
|
|
|
|
def test_session_route_live_delivery_without_cursor_keeps_buffered_active_items(monkeypatch):
|
|
import api.routes as routes
|
|
|
|
class _FakeStream:
|
|
def __init__(self):
|
|
self.q = queue.Queue()
|
|
self.q.put_nowait(("token", {"text": "buffered"}, "run_active:1"))
|
|
self.q.put_nowait(("stream_end", {"status": "done"}, "run_active:2"))
|
|
|
|
def subscribe_with_snapshot(self):
|
|
return self.q, {"last_event_id": "run_active:1", "offline_buffered_events": 1}
|
|
|
|
stream = _FakeStream()
|
|
monkeypatch.setattr(routes, "_session_id_visible_to_request_profile", lambda *_args, **_kwargs: True)
|
|
monkeypatch.setattr(
|
|
routes,
|
|
"get_session",
|
|
lambda sid, metadata_only=False: SimpleNamespace(
|
|
session_id=sid,
|
|
compact=lambda **_kwargs: {"session_id": sid, "title": "Session"},
|
|
),
|
|
)
|
|
monkeypatch.setattr(routes, "_active_run_stream_for_session", lambda *_args, **_kwargs: "run_active")
|
|
monkeypatch.setattr(routes, "STREAMS", {"run_active": stream})
|
|
import api.config as config
|
|
monkeypatch.setattr(
|
|
config,
|
|
"STREAMS",
|
|
{"run_active": stream},
|
|
)
|
|
monkeypatch.setattr(
|
|
routes,
|
|
"read_session_run_events",
|
|
lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError("replay should not run without a cursor")),
|
|
)
|
|
|
|
handler = _FakeHandler()
|
|
routes._handle_session_sse_stream_for_session(
|
|
handler,
|
|
urlparse("/api/sessions/session_1/events"),
|
|
"session_1",
|
|
)
|
|
|
|
body = handler.wfile.getvalue().decode("utf-8")
|
|
assert "id: run_active:1\n" in body
|
|
assert "id: run_active:2\n" in body
|
|
assert "event: stream_end\n" in body
|
|
|
|
|
|
def test_session_route_reconciles_late_attach_cutoff_once(monkeypatch):
|
|
import api.routes as routes
|
|
|
|
class _FakeStream:
|
|
def __init__(self):
|
|
self.q = queue.Queue()
|
|
self.q.put_nowait(("token", {"text": "six"}, "run_active:6"))
|
|
self.q.put_nowait(("stream_end", {"status": "done"}, "run_active:7"))
|
|
self.unsubscribed = 0
|
|
|
|
def subscribe_with_snapshot(self):
|
|
return self.q, {"last_event_id": "run_active:6"}
|
|
|
|
def unsubscribe(self, q):
|
|
self.unsubscribed += q is self.q
|
|
|
|
stream = _FakeStream()
|
|
available = {"value": False}
|
|
reads = []
|
|
monkeypatch.setattr(routes, "_session_id_visible_to_request_profile", lambda *_args, **_kwargs: True)
|
|
monkeypatch.setattr(routes, "get_session", lambda sid, metadata_only=False: SimpleNamespace(session_id=sid, compact=lambda **_kwargs: {}))
|
|
monkeypatch.setattr(routes, "_active_run_stream_for_session", lambda *_args, **_kwargs: "run_active" if available["value"] else None)
|
|
monkeypatch.setattr(routes, "STREAMS", {"run_active": stream})
|
|
import api.config as config
|
|
monkeypatch.setattr(
|
|
config,
|
|
"STREAMS",
|
|
{"run_active": stream},
|
|
)
|
|
monkeypatch.setattr(
|
|
routes,
|
|
"read_session_run_events",
|
|
lambda *_args, **_kwargs: reads.append(1) or {"status": "ok", "events": [] if len(reads) == 1 else [{"event": "token", "payload": {"text": "six"}, "event_id": "run_active:6"}]},
|
|
)
|
|
monkeypatch.setattr(routes.time, "sleep", lambda _seconds: available.__setitem__("value", True))
|
|
|
|
handler = _FakeHandler()
|
|
routes._handle_session_sse_stream_for_session(handler, urlparse("/api/sessions/session_1/events?after_event_id=run_active:5"), "session_1")
|
|
|
|
body = handler.wfile.getvalue().decode("utf-8")
|
|
assert body.count("id: run_active:6\n") == 1
|
|
assert "id: run_active:7\n" in body
|
|
assert stream.unsubscribed == 1
|
|
|
|
|
|
def test_session_route_emits_snapshot_when_reconciliation_fails(monkeypatch):
|
|
import api.routes as routes
|
|
|
|
class _FakeStream:
|
|
def __init__(self):
|
|
self.q = queue.Queue()
|
|
self.q.put_nowait(("token", {"text": "buffered"}, "run_active:1"))
|
|
self.q.put_nowait(("stream_end", {"status": "done"}, "run_active:2"))
|
|
|
|
def subscribe_with_snapshot(self):
|
|
return self.q, {"last_event_id": "run_active:1"}
|
|
|
|
stream = _FakeStream()
|
|
reads = []
|
|
sessions = iter(
|
|
[
|
|
SimpleNamespace(session_id="session_1", compact=lambda **_kwargs: {"session_id": "session_1", "title": "stale"}),
|
|
SimpleNamespace(session_id="session_1", compact=lambda **_kwargs: {"session_id": "session_1", "title": "fresh"}),
|
|
]
|
|
)
|
|
monkeypatch.setattr(routes, "_session_id_visible_to_request_profile", lambda *_args, **_kwargs: True)
|
|
monkeypatch.setattr(
|
|
routes,
|
|
"get_session",
|
|
lambda sid, metadata_only=False: next(sessions),
|
|
)
|
|
monkeypatch.setattr(routes, "_active_run_stream_for_session", lambda *_args, **_kwargs: "run_active")
|
|
monkeypatch.setattr(routes, "STREAMS", {"run_active": stream})
|
|
import api.config as config
|
|
monkeypatch.setattr(
|
|
config,
|
|
"STREAMS",
|
|
{"run_active": stream},
|
|
)
|
|
monkeypatch.setattr(
|
|
routes,
|
|
"read_session_run_events",
|
|
lambda *_args, **_kwargs: reads.append(1) or (
|
|
{"status": "ok", "events": [{"event": "token", "payload": {"text": "replayed"}, "event_id": "run_prev:1"}]}
|
|
if len(reads) == 1
|
|
else {"status": "replay_noncontiguous", "events": []}
|
|
),
|
|
)
|
|
|
|
handler = _FakeHandler()
|
|
routes._handle_session_sse_stream_for_session(
|
|
handler,
|
|
urlparse("/api/sessions/session_1/events?after_event_id=run_prev:0"),
|
|
"session_1",
|
|
)
|
|
|
|
body = handler.wfile.getvalue().decode("utf-8")
|
|
assert "event: session_snapshot\n" in body
|
|
assert '"title": "fresh"' in body
|
|
assert '"title": "stale"' not in body
|
|
assert "id: run_prev:1\n" not in body
|
|
assert body.count("id: run_active:1\n") == 0
|
|
assert "id: run_active:2\n" in body
|
|
|
|
|
|
def test_session_route_bounds_sent_event_id_deduplication(monkeypatch):
|
|
import api.routes as routes
|
|
|
|
class _FakeStream:
|
|
def __init__(self):
|
|
self.q = queue.Queue()
|
|
for seq in (1, 2, 3, 1):
|
|
self.q.put_nowait(("token", {"seq": seq}, f"run_active:{seq}"))
|
|
self.q.put_nowait(("stream_end", {}, "run_active:4"))
|
|
|
|
def subscribe_with_snapshot(self):
|
|
return self.q, {}
|
|
|
|
stream = _FakeStream()
|
|
monkeypatch.setattr(routes, "_SESSION_SSE_SENT_EVENT_ID_LIMIT", 2)
|
|
monkeypatch.setattr(routes, "_session_id_visible_to_request_profile", lambda *_args, **_kwargs: True)
|
|
monkeypatch.setattr(routes, "get_session", lambda sid, metadata_only=False: SimpleNamespace(session_id=sid, compact=lambda **_kwargs: {}))
|
|
monkeypatch.setattr(routes, "_active_run_stream_for_session", lambda *_args, **_kwargs: "run_active")
|
|
monkeypatch.setattr(routes, "STREAMS", {"run_active": stream})
|
|
import api.config as config
|
|
monkeypatch.setattr(
|
|
config,
|
|
"STREAMS",
|
|
{"run_active": stream},
|
|
)
|
|
|
|
handler = _FakeHandler()
|
|
routes._handle_session_sse_stream_for_session(handler, urlparse("/api/sessions/session_1/events"), "session_1")
|
|
|
|
body = handler.wfile.getvalue().decode("utf-8")
|
|
assert body.count("id: run_active:1\n") == 2
|
|
assert "id: run_active:4\n" in body
|