Files
hermes-webui/tests/test_stream_offline_gap_recovery.py
T
2b457f1646 Release: cap StreamChannel offline replay buffer (#5788) (#5849)
Bounded deque(maxlen) drop-oldest so a disconnect-without-cancel can't grow the
per-turn offline replay buffer unbounded (per-abandoned-turn OOM risk, #4633 family).
Dropped frames recovered via run-journal last_event_id replay path.

Deep-review gate: Codex SAFE (journal-recovery verified end-to-end), 20 own + 1106
regression tests green. Crown-jewel streaming path.

Co-authored-by: t <a@b>
Co-authored-by: ai-ag2026 <ai-ag2026@users.noreply.github.com>
2026-07-09 12:26:05 -07:00

407 lines
15 KiB
Python

"""
Regression tests for #4633/#5788 — the SSE handler must ENFORCE run-journal
coverage before draining a truncated offline tail.
``StreamChannel.subscribe_with_snapshot()`` reports ``offline_dropped_events``
when the capped offline buffer evicted frames during a disconnect gap. The
retained tail is only safe to drain when the run journal provably backfills
everything from the client's replay cursor through the snapshot cutoff —
otherwise the browser would render later frames plus a normal ``stream_end``
with a silent transcript hole in the middle (the journal being missing,
incomplete, or unreadable leaves the evicted frames unrecoverable).
Enforcement contract pinned here:
* dropped > 0 and the journal does NOT cover the gap → emit the established
``apperror`` + ``recovery_control: true`` event (same client contract as
``run_journal.stale_interrupted_event`` — the tab restores the transcript
from persisted state) and do NOT stream the tail.
* dropped > 0 and the journal DOES cover the gap → replay + tail as before.
* a client cursor already at/past the snapshot cutoff has no gap to cover —
no recovery even when the journal is missing.
* the retained tail itself covers [first buffered frame → cutoff]: the journal
only has to bridge (cursor → first buffered frame), a cursor already inside
the retained range needs no journal at all, and the replay/dedup cutoff stops
at the bridge so queued frames the journal never emitted are not skipped.
"""
import io
import json
import queue
from urllib.parse import urlparse
import api.routes as routes
class _Handler:
def __init__(self):
self.wfile = io.BytesIO()
def send_response(self, _code):
pass
def send_header(self, _name, _value):
pass
def end_headers(self):
pass
class _FakeStream:
def __init__(self, snapshot, items):
self.q = queue.Queue()
for item in items:
self.q.put_nowait(item)
self._snapshot = dict(snapshot)
self.unsubscribed = False
def subscribe_with_snapshot(self):
return self.q, dict(self._snapshot)
def unsubscribe(self, q):
self.unsubscribed = q is self.q
def _run_handler(monkeypatch, stream, query):
handler = _Handler()
previous_streams = dict(routes.STREAMS)
routes.STREAMS.clear()
routes.STREAMS["run_1"] = stream
try:
routes._handle_sse_stream(handler, urlparse(f"/api/chat/stream?{query}"))
finally:
routes.STREAMS.clear()
routes.STREAMS.update(previous_streams)
return handler.wfile.getvalue().decode("utf-8")
def _sse_payloads(body, event):
"""Extract parsed data payloads for all SSE frames of the given event."""
payloads = []
lines = body.splitlines()
for i, line in enumerate(lines):
if line == f"event: {event}" and i + 1 < len(lines):
data = lines[i + 1]
assert data.startswith("data: ")
payloads.append(json.loads(data[len("data: "):]))
return payloads
def _journal_events(lo, hi, run_id="run_1"):
return [
{
"event": "token",
"payload": {"text": f"journal-{seq}"},
"event_id": f"{run_id}:{seq}",
"seq": seq,
}
for seq in range(lo, hi + 1)
]
def test_dropped_frames_without_journal_emit_recovery_not_tail(monkeypatch):
"""Missing journal + evicted frames → recovery_control apperror, no tail."""
stream = _FakeStream(
{
"last_event_id": "run_1:9000",
"offline_buffered_events": 2,
"offline_dropped_events": 25,
"offline_first_event_id": "run_1:8999",
},
[
("token", {"text": "tail-frame"}, "run_1:8999"),
("stream_end", {}, "run_1:9000"),
],
)
monkeypatch.setattr(routes, "find_run_summary", lambda _sid: None)
monkeypatch.setattr(routes, "stream_owner_session_id", lambda _sid: "session_1")
body = _run_handler(monkeypatch, stream, "stream_id=run_1&replay=1&after_seq=100")
payloads = _sse_payloads(body, "apperror")
assert len(payloads) == 1
assert payloads[0]["recovery_control"] is True
assert payloads[0]["type"] == "interrupted"
assert payloads[0]["session_id"] == "session_1"
assert payloads[0]["stream_id"] == "run_1"
assert payloads[0]["offline_dropped_events"] == 25
# The truncated tail must NOT be drained: no silent hole + stream_end.
assert "tail-frame" not in body
assert "event: stream_end" not in body
# Every exit path returns the queue to the channel.
assert stream.unsubscribed is True
def test_dropped_frames_with_full_journal_coverage_stream_normally(monkeypatch):
"""Journal covers (cursor → cutoff] → replay + tail drain, no recovery."""
stream = _FakeStream(
{
"last_event_id": "run_1:105",
"offline_buffered_events": 2,
"offline_dropped_events": 25,
"offline_first_event_id": "run_1:106",
},
[
("token", {"text": "tail-frame"}, "run_1:106"),
("stream_end", {}, "run_1:107"),
],
)
monkeypatch.setattr(
routes,
"find_run_summary",
lambda sid: {"session_id": "session_1", "run_id": sid, "terminal": False},
)
monkeypatch.setattr(
routes,
"read_run_events",
lambda _session_id, run_id, after_seq=None, max_seq=None: {
"events": _journal_events(101, 105, run_id=run_id)
},
)
body = _run_handler(monkeypatch, stream, "stream_id=run_1&replay=1&after_seq=100")
assert _sse_payloads(body, "apperror") == []
assert "journal-101" in body and "journal-105" in body
assert "tail-frame" in body
assert "event: stream_end" in body
assert stream.unsubscribed is True
def test_incomplete_journal_coverage_emits_recovery(monkeypatch):
"""A journal that stops short of the snapshot cutoff is NOT coverage."""
stream = _FakeStream(
{
"last_event_id": "run_1:105",
"offline_buffered_events": 2,
"offline_dropped_events": 25,
"offline_first_event_id": "run_1:106",
},
[
("token", {"text": "tail-frame"}, "run_1:106"),
("stream_end", {}, "run_1:107"),
],
)
monkeypatch.setattr(
routes,
"find_run_summary",
lambda sid: {"session_id": "session_1", "run_id": sid, "terminal": False},
)
monkeypatch.setattr(
routes,
"read_run_events",
# Journal lost its newest entries: stops at 103 < cutoff 105.
lambda _session_id, run_id, after_seq=None, max_seq=None: {
"events": _journal_events(101, 103, run_id=run_id)
},
)
monkeypatch.setattr(routes, "stream_owner_session_id", lambda _sid: "session_1")
body = _run_handler(monkeypatch, stream, "stream_id=run_1&replay=1&after_seq=100")
payloads = _sse_payloads(body, "apperror")
assert len(payloads) == 1
assert payloads[0]["recovery_control"] is True
assert "tail-frame" not in body
assert stream.unsubscribed is True
def test_cursor_at_cutoff_has_no_gap_and_streams_tail(monkeypatch):
"""A client already at/past the snapshot cutoff missed nothing the buffer
ever held — no recovery even when the journal is missing entirely."""
stream = _FakeStream(
{
"last_event_id": "run_1:105",
"offline_buffered_events": 2,
"offline_dropped_events": 25,
"offline_first_event_id": "run_1:106",
},
[
("token", {"text": "tail-frame"}, "run_1:106"),
("stream_end", {}, "run_1:107"),
],
)
monkeypatch.setattr(routes, "find_run_summary", lambda _sid: None)
body = _run_handler(monkeypatch, stream, "stream_id=run_1&replay=1&after_seq=105")
assert _sse_payloads(body, "apperror") == []
assert "tail-frame" in body
assert "event: stream_end" in body
assert stream.unsubscribed is True
def test_coverage_helper_rejects_mid_window_holes(monkeypatch):
"""Endpoint checks are not enough: a malformed/dropped line INSIDE the
window (journal seqs are contiguous by construction) must fail coverage."""
monkeypatch.setattr(
routes,
"find_run_summary",
lambda sid: {"session_id": "session_1", "run_id": sid, "terminal": False},
)
events = [e for e in _journal_events(101, 105) if e["seq"] != 103]
monkeypatch.setattr(
routes,
"read_run_events",
lambda _session_id, _run_id, after_seq=None, max_seq=None: {"events": events},
)
assert routes._run_journal_covers_offline_gap("run_1", 100, 105) is False
def test_coverage_helper_requires_known_cutoff():
"""Without a same-run journaled cutoff nothing can be proven → not covered."""
assert routes._run_journal_covers_offline_gap("run_1", 100, None) is False
def test_cursor_inside_retained_tail_needs_no_journal(monkeypatch):
"""A reconnect whose cursor already sits inside the retained buffer range
is contiguous from the queue alone — no journal required, no recovery,
even though frames were evicted before the retained head (they are all
≤ the client's cursor)."""
stream = _FakeStream(
{
"last_event_id": "run_1:9000",
"offline_buffered_events": 2,
"offline_dropped_events": 25,
"offline_first_event_id": "run_1:8999",
},
[
("token", {"text": "tail-frame"}, "run_1:8999"),
("stream_end", {}, "run_1:9000"),
],
)
monkeypatch.setattr(routes, "find_run_summary", lambda _sid: None)
body = _run_handler(monkeypatch, stream, "stream_id=run_1&replay=1&after_seq=8998")
assert _sse_payloads(body, "apperror") == []
assert "tail-frame" in body
assert "event: stream_end" in body
assert stream.unsubscribed is True
def test_replay_cutoff_stops_at_buffer_head_so_dedup_keeps_queued_frames(monkeypatch):
"""The journal only bridges (cursor → first buffered frame); the dedup
cutoff must stop there too. With the cutoff at the snapshot's last_event_id
the drain loop's `seq <= replay_cutoff_seq` filter would silently skip the
queued frames the journal never emitted — a hole certified as covered."""
stream = _FakeStream(
{
"last_event_id": "run_1:105",
"offline_buffered_events": 3,
"offline_dropped_events": 25,
"offline_first_event_id": "run_1:103",
},
[
("token", {"text": "buffered-103"}, "run_1:103"),
("token", {"text": "buffered-104"}, "run_1:104"),
("token", {"text": "buffered-105"}, "run_1:105"),
("stream_end", {}, "run_1:106"),
],
)
monkeypatch.setattr(
routes,
"find_run_summary",
lambda sid: {"session_id": "session_1", "run_id": sid, "terminal": False},
)
monkeypatch.setattr(
routes,
"read_run_events",
# The journal only holds the bridge (100 → 102]; 103.. live in the buffer.
lambda _session_id, run_id, after_seq=None, max_seq=None: {
"events": _journal_events(101, 102, run_id=run_id)
},
)
body = _run_handler(monkeypatch, stream, "stream_id=run_1&replay=1&after_seq=100")
assert _sse_payloads(body, "apperror") == []
assert "journal-101" in body and "journal-102" in body
# The queued frames past the bridge must all survive the dedup filter.
assert "buffered-103" in body
assert "buffered-104" in body
assert "buffered-105" in body
assert "event: stream_end" in body
assert stream.unsubscribed is True
def test_cursor_past_buffer_head_does_not_double_render_queued_frames(monkeypatch):
"""A cursor AT/INSIDE the retained tail (after_seq >= first buffered frame)
must not receive the queued copy of frames it already rendered.
The journal-emission bound (buffer head) and the client-cursor bound are
two DIFFERENT dedup cutoffs: collapsing them onto the buffer head would
re-stream [head, cursor] from the queue — a visible double-render, since
the drain loop's `seq <=` filter is the only dedup for replayed streams
(the client does not de-duplicate token frames itself)."""
stream = _FakeStream(
{
"last_event_id": "run_1:105",
"offline_buffered_events": 3,
"offline_dropped_events": 0,
"offline_first_event_id": "run_1:103",
},
[
("token", {"text": "buffered-103"}, "run_1:103"),
("token", {"text": "buffered-104"}, "run_1:104"),
("token", {"text": "buffered-105"}, "run_1:105"),
("stream_end", {}, "run_1:106"),
],
)
monkeypatch.setattr(
routes,
"find_run_summary",
lambda sid: {"session_id": "session_1", "run_id": sid, "terminal": False},
)
all_events = _journal_events(101, 105)
def _filtering_read(_session_id, _run_id, after_seq=None, max_seq=None):
# Mirror the real read_run_events window filters.
events = [
e
for e in all_events
if (after_seq is None or e["seq"] > after_seq)
and (max_seq is None or e["seq"] <= max_seq)
]
return {"events": events}
monkeypatch.setattr(routes, "read_run_events", _filtering_read)
# Client already rendered up to 104 (e.g. tab flap after a partial drain).
body = _run_handler(monkeypatch, stream, "stream_id=run_1&replay=1&after_seq=104")
assert _sse_payloads(body, "apperror") == []
assert "buffered-103" not in body
assert "buffered-104" not in body
assert "buffered-105" in body
assert "event: stream_end" in body
assert stream.unsubscribed is True
def test_bogus_cursor_is_clamped_so_terminal_frame_survives(monkeypatch):
"""The client-cursor dedup bound is clamped at the snapshot cutoff.
A legitimate cursor can never exceed the channel's last known frame; an
out-of-range one (corrupt client state) must not raise the dedup cutoff
past the queued terminal frame — the drain loop's `seq <=` skip runs
BEFORE its terminal break, so a filtered stream_end would pin the loop on
heartbeats until the write deadline."""
from urllib.parse import parse_qs
monkeypatch.setattr(routes, "find_run_summary", lambda _sid: None)
handler = _Handler()
handled, cutoff = routes._sse_replay_run_journal_gap_checked(
handler,
parse_qs("stream_id=run_1&replay=1&after_seq=999"),
"run_1",
{
"last_event_id": "run_1:105",
"offline_buffered_events": 3,
"offline_dropped_events": 0,
"offline_first_event_id": "run_1:103",
},
)
assert handled is False
# Clamped to the cutoff (105): the queued stream_end (seq 106) passes the
# drain loop's dedup filter and terminates the connection.
assert cutoff == 105