contract(issue#7): author + amend #1/#3/#4 for empty-skip + MalformedSseData
Issue #7: mid-stream robustness fix discovered via 2026-05-22 crash. Long mimir TUI conversation (turn 93, 1077 events consumed) crashed on event 1078 with JSONDecodeError("Expecting value: line 1 column 1 (char 0)") from json.loads('') on an empty-data SSE frame. _iter_events unconditionally called json.loads on every dispatched event; when httpx_sse surfaces a frame with id: present but data: empty/missing (a known library-vs-spec divergence), parsing fails and propagates. Two-rule fix in _iter_events: - Empty sse.data (exact `== ''`): SKIP silently per SSE spec (keepalive semantics). Don't yield, don't advance last_sse_id, don't set terminal_seen. ORDERING: skip fires BEFORE _parse_sse_id, so a keepalive with a malformed id is still a keepalive (intentional). - Non-empty sse.data that fails json.loads: raise new MalformedSseData (sibling to MalformedSseId, mirrors raw[:200] truncation pattern). Wire-level protocol error; presenters route to [malformed_sse_data] + exit 22 in cli, transcript label + state→idle in tui (INV-008). Volva paraphrase round: 4 ambiguities, all amended: 1. INV-001 prose tightened — exact `sse.data == ''` rule made prominent; "keepalive" framing demoted to intent-not-rule; whitespace-only data explicitly listed as malformed (not skipped); specific state names (last_sse_id, terminal_seen) instead of vague "any counter". 2. STEPS pseudocode spells out the ordering — empty-skip happens BEFORE _parse_sse_id; empty-data with bad id is silently swallowed. 3. empty_data_skipped test description fixed (had off-by-one count + wrong wording around last_sse_id intermediate state). 4. (paired with #1 above). Volva code-review post-implementation: 3 findings, all addressed: F1 (test-gap): empty_data_skipped proves yielded events but not internal last_sse_id non-advancement. New empty_data_skip_preserves_last_seen_sse_id test probes via SseConnectionDropped.last_seen_sse_id after a drop following the skipped frame — if the skip had transiently advanced last_sse_id, the exception payload would carry the wrong value. F2 (precision, contract amend): MalformedSseData ERROR_ROUTING said "log truncated raw" but stream_turn doesn't log — sse_client is a library, presenters own observability. Amended to "propagate to caller (no logging at sse_client layer); presenters log exc.raw." F3 (test-gap): cli malformed_sse_data test asserted label but not `raw='X'` shape and not truncation. Tightened existing test + added malformed_sse_data_truncation with 5000-char payload — verifies MalformedSseData.raw truncation carries through the presenter's repr() rendering. **Calibration milestone**: issue #7 is the first issue with ZERO drift findings from Volva code-review. TDD caught all runtime behavior cleanly. The 3 findings were assertion-precision and architectural-correctness-of-wording, not behavioral. Hypothesis: tighter contract spec + smaller code surface shifts Volva's role from "catch behavioral drift" to "tighten observability + wording". Cumulative calibration table: #1 (4 findings, 3 drift + 1 test-gap), #2 (3, 1+1+1 precision), #3 (5, 3+1+1), #4 (8, 5+2+1), #7 (3, 0 drift + 2 test-gap + 1 precision). Contracts touched (all drift-check clean): - docs/contracts/issues/7.contract.md (new): the coordinating record. - docs/contracts/issues/1.contract.md: _iter_events STEP 3.0 empty-skip + ordering note; STEP 3.c JSONDecodeError → MalformedSseData; new MalformedSseData ERROR_ROUTING (propagate-to-caller wording per F2); 4 new TESTS entries including F1's last-seen probe. - docs/contracts/issues/3.contract.md: _run_turn ERROR_ROUTING + malformed_sse_data tests (incl. F3 truncation). - docs/contracts/issues/4.contract.md: INV-008 mentions MalformedSseData; _stream_turn_worker ERROR_ROUTING + new TEST.
This commit is contained in:
@@ -427,6 +427,11 @@ ERROR_ROUTING:
|
||||
flow_control: abort
|
||||
state_recovery: none (server-side wire bug; surface honestly)
|
||||
exit_code: 22
|
||||
MalformedSseData:
|
||||
local_handling: write `[malformed_sse_data] raw={exc.raw!r}` to stderr (issue #7)
|
||||
flow_control: abort
|
||||
state_recovery: none (server-side wire bug; payload corruption)
|
||||
exit_code: 22
|
||||
TurnIdFlip:
|
||||
local_handling: write `[turn_id_flip] expected={exc.established} got={exc.got}` to stderr
|
||||
flow_control: abort
|
||||
@@ -494,6 +499,8 @@ TESTS:
|
||||
sse_connect_failed_404 [error]: mock returns 404 before stream opens → returns 20; stderr "[sse_connect_failed] status=404 ..."
|
||||
connection_dropped [error]: mock raises RemoteProtocolError mid-stream → returns 21; stderr "[connection_dropped]"
|
||||
malformed_sse_id [error]: mock yields an event with `id: 42` (no seq) → returns 22; stderr "[malformed_sse_id]"
|
||||
malformed_sse_data [error,issue#7]: mock yields text + event with `data: not-json` → returns 22; stderr contains literal "[malformed_sse_data] raw='not-json'" (exact label+raw shape per ERROR_ROUTING)
|
||||
malformed_sse_data_truncation [security,issue#7]: mock yields text + event with 5000-char malformed data → returns 22; stderr "[malformed_sse_data]" present; full 5000-char payload NOT in stderr; truncated 200-char form IS present (verifies MalformedSseData.raw truncation carries through the presenter's repr() rendering)
|
||||
turn_id_flip [error]: mock yields events 42:1 then 99:2 → returns 22; stderr "[turn_id_flip] expected=42 got=99"
|
||||
sigint_before_first_event [scenario]: sigint_event set BEFORE the mock has yielded anything → returns 3; respx tracked zero cancel_turn POSTs (INV-008)
|
||||
sigint_mid_stream_drains_to_cancelled [scenario,tracer]: mock yields text(42:1) → caller sets sigint_event → mock yields cancelled(42:2) → returns 3; respx tracked exactly one POST /sessions/{id}/turns/42/cancel (INV-007)
|
||||
|
||||
Reference in New Issue
Block a user