* fix(sse): scope gateway SSE probe and honor Last-Event-ID on /api/chat/stream Two narrowly-scoped cross-client SSE fixes from the hermes-android#58 follow-up: 1. The /api/sessions/gateway/stream?probe=1 response only describes the optional gateway/agent-sessions stream, but its payload gave clients no way to tell that. A 404 'agent sessions not enabled' result could be misread as SSE being unsupported server-wide, even though the persistent per-session stream (/api/session/stream) is always on and independent of the show_cli_sessions setting. Add additive scope markers to the probe payload (scope, session_stream_available, session_stream_path) and clarify the handler docstring. 2. /api/chat/stream emitted id: stream_id:seq on every journaled event but ignored the Last-Event-ID request header, so any spec-compliant SSE client that auto-reconnects (browser EventSource, Android/CLI clients) replayed the journal from seq 0 and double-rendered the transcript. _chat_stream_resume_after_seq() now resolves the resume cursor with explicit after_event_id/after_seq query params first and the Last-Event-ID header as fallback — the same resolution chain api/kanban_bridge.py already uses. Both the dead-stream journal replay and the live-subscribe gap check consume it; direct callers of the gap check that only pass query params keep historical behavior. Also documents the cross-client SSE endpoint contract in docs/sse-streams.md. Related hermes-webui/hermes-android#58 * fix(sse): split cursor presence from validity on /api/chat/stream resume Address PR #6886 review (gate-fail): the resume logic conflated "no cursor" with "invalid cursor" into a single None, which broke replay on three edges. Root cause and fix: Cursor presence vs validity — _chat_stream_resume_cursor() now returns (after_seq, resume_requested) instead of a bare seq. A client that SUPPLIED any cursor (query params, replay=1, or Last-Event-ID) asked to resume; an invalid / foreign-run / unparseable cursor resolves to after_seq=None but keeps resume_requested=True, and is normalized to replay-from-start so no journal events are silently skipped. A genuinely cursor-less request stays a fresh subscribe. 1. Explicit-query precedence is now decided by parameter PRESENCE, not successful parsing: a malformed/foreign after_event_id/after_seq never falls through to let the Last-Event-ID header override it and skip events. 2. Invalid/foreign cursors no longer bypass replay + gap detection and silently drain a truncated buffer; an ahead-of-stream cursor is no longer clamped onto the snapshot's terminal fence (which emptied the dead-stream replay by filtering the terminal frame). 3. The runner-local observe path now receives the resolved header cursor (retaining explicit-query precedence) so header-only runner reconnects resume instead of duplicating events from the start. Regression tests cover invalid-cursor replay-from-start, ahead-of-stream terminal survival, header-only runner reconnect, and explicit-query precedence; the stale clamp test is updated to the corrected contract. Related hermes-webui/hermes-android#58 * fix(sse): consume resolved resume cursor at every consumer on /api/chat/stream Address PR #6886 r2 review (gate-fail). The r1 presence/validity split fixed the entry resolution, but the individual consumers still re-derived ahead/equality/re-parse decisions, so the invariant leaked at the edges. This makes every consumer honor the resolved (after_seq, resume_requested) pair and normalizes ahead-of-stream in one place. 1. Equality off-by-one: a valid cursor EQUAL to the snapshot cutoff now enters the drain dedup bound (the event at the cursor was already delivered), instead of being treated as "ahead" and double-sending the retained buffer. Ahead is now strictly after_seq > snapshot_cutoff_seq. 2. Numeric ahead-of-stream cursors are normalized to replay-from-start: on the active path, after_seq > snapshot_cutoff_seq resets to 0 before gap-coverage/replay so a truncated buffer can no longer falsely declare coverage and lose the pre-tail events; on the dead-stream path the cursor is normalized against the journal summary's authoritative last_seq before _replay_run_journal, so an ahead cursor returns the full replay instead of an empty SSE body. 3. Runner-local now consumes the resolved cursor instead of re-parsing after_event_id or re-reading Last-Event-ID. The resolver also returns the opaque raw cursor so the runner uses the already-precedence-checked value; explicit opaque cursor= keeps precedence, invalid/foreign cursors pass no cursor (full replay), and the header is honored only when no explicit cursor was supplied. Regression tests cover the equality dedup, active+dead ahead-of-stream normalization (with after_seq-filtering mocks that assert the real replay body), and the three runner precedence probes (malformed-explicit blocks header, foreign passes no cursor, opaque cursor= wins). Related hermes-webui/hermes-android#58 * fix(sse): carry runner-cursor provenance through /api/chat/stream resume Address PR #6886 r3 review (gate-fail). The (after_seq, resume_requested, raw_cursor) tuple carried the cursor VALUE but not its PROVENANCE, so the runner handoff couldn't distinguish a valid opaque runner cursor from a journal-shaped one from a malformed one — and gated the runner handoff on journal-specific validity, which is wrong for the runner adapter (its cursors are opaque). The resolver now returns a distinct, centrally-resolved runner cursor: - a valid after_seq PAIRS with whatever after_event_id shape was supplied — including an opaque runner id like event:2 that the journal parser reads as a foreign run — so the paired runner cursor resumes at the seq (never None / full replay, which would duplicate tokens and tool events). after_seq is read directly (via _parse_run_journal_after_seq_value) rather than through the after_event_id-first journal parser, which would swallow a paired opaque runner id. - a header-only opaque runner id (Last-Event-ID: event:2) is preserved as-is so the runner resumes from it. - a malformed or foreign explicit cursor WITHOUT a valid paired after_seq still yields no runner cursor, blocking Last-Event-ID and replaying from start — the r2 malformed-blocks-header guarantee is unchanged. Regression tests pin the paired opaque id, header-only opaque id, and malformed-with-valid-seq runner resumes, plus the unchanged malformed-without-pair replay-from-start behavior. Related hermes-webui/hermes-android#58 * fix(sse): normalize ahead cursor when the snapshot cutoff is unknown Address PR #6886 r4 review (gate-fail). The ahead-cursor normalization was guarded by `snapshot_cutoff_seq is not None`, so when the subscribe snapshot had no parseable last_event_id (channel has not seen an id-bearing frame yet), a positive ahead cursor (e.g. Last-Event-ID run_1:999) was never normalized — it was installed verbatim as the live dedup bound, filtering EVERY queued frame including the terminal stream_end fence. The reconnect stalled on heartbeats with an empty SSE body. origin/master ignored the header on this path and delivered the events, so this was a new regression. Fix: treat an unknown, non-truncated snapshot cutoff as fence 0 before the ahead-cursor normalization, so a positive client cursor can't become the live dedup bound with no cutoff to bound it — it fails closed to replay-from-start, delivering the buffered events rather than silently losing them. Frames dropped while the cutoff is unknown still cannot prove gap coverage (cutoff None → not covered), so the recovery_control fail-closed path is preserved. Regression test: Last-Event-ID run_1:999 + snapshot_cutoff_seq=None delivers queued events run_1:1 and run_1:2 exactly once, emits the terminal stream_end, and releases the subscriber. Without the fix the test stalls on heartbeats (pytest-timeout kills it), proving the stall. Related hermes-webui/hermes-android#58 * Release — SSE Last-Event-ID resumption fix (#6886) --------- Co-authored-by: Paladin173 <35980893+Paladin173@users.noreply.github.com> Co-authored-by: n <a@n>
412 lines
15 KiB
Python
412 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_ahead_of_stream_cursor_does_not_filter_terminal_frame(monkeypatch):
|
|
"""An ahead-of-stream cursor (after_seq past the snapshot cutoff) is NOT
|
|
clamped onto the snapshot's terminal fence.
|
|
|
|
The old clamp (``min(cursor, snapshot_cutoff)``) set the dedup bound to the
|
|
terminal frame's own seq, so the drain loop's ``seq <=`` filter — which runs
|
|
BEFORE its terminal break — swallowed the queued terminal frame and pinned
|
|
the loop on heartbeats until the write deadline (Codex CORE #2 terminal
|
|
filter). Ahead-of-stream means the client already believes it holds
|
|
everything the buffer has; nothing must be filtered and the terminal frame
|
|
must survive."""
|
|
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
|
|
# Not clamped to 105: the cutoff stays None (journal replay found nothing,
|
|
# and an ahead-of-stream cursor contributes no dedup bound), so the queued
|
|
# terminal frame (seq 106) passes the drain loop's filter and terminates
|
|
# the connection instead of being filtered onto the fence.
|
|
assert cutoff is None
|