Skip to content

client: enforce the streaming terminal contract by construction#160

Merged
rpb-ant merged 3 commits into
mainfrom
stream-end-record
Jun 10, 2026
Merged

client: enforce the streaming terminal contract by construction#160
rpb-ant merged 3 commits into
mainfrom
stream-end-record

Conversation

@rpb-ant

@rpb-ant rpb-ant commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

Summary

Follow-up to #159: restructure the client streaming receive path so the contract #159 introduced — Ok(None) ⇔ the RPC succeeded, every Err terminal and sticky, error()/trailers() always consistent with message() — is enforced by construction rather than convention. No public-API or intended behavior change; #159's eight streaming-contract tests pass with their assertions unchanged (only the private struct-literal initializers in tests were touched).

What changes structurally:

  • The done/error/trailers field triple becomes one private StreamEnd record (outcome: Result<(), ConnectError> + trailers), written exactly once by message() and read by the sticky replay, error(), and trailers() — one fact, three readers, so they cannot disagree.
  • message_inner's error type is the terminal record: "ended the stream without recording why" is now unrepresentable. The cross-function invariants client: streaming message() returns terminal errors as Err #159 documented and debug_assert!ed ("every Ok(None) sets done first", "error implies done") are deleted because the types carry them.
  • poll_body becomes a pure transport reader returning Data | Trailers(HeaderMap) | Eof as values — no more Ok(false)-means-two-things, no terminal-state writes from the transport layer.
  • All gRPC/gRPC-Web status classification (server-error parse, the Trailers-Only carve-out, the internal/unknown split, the deadline relabel) consolidates into one classify_grpc_end function, sitting next to the trailer parse it depends on.
  • The whole-call deadline moves from wrapping the decode loop to bounding each body frame poll. Observably equivalent on three verified facts: the deadline is an absolute instant; all inter-poll work is non-yielding; timeout_at polls the inner future before the timer. Pinned by a new paused-clock test (deadline_bounds_multi_frame_message): a server trickling frames every 40ms against a 100ms deadline — a per-poll relative timeout would let every frame through and fail the test.

Validation

  • 397 connectrpc lib tests (incl. client: streaming message() returns terminal errors as Err #159's contract tests unchanged), full workspace green, clippy/fmt/docs clean.
  • All three client conformance suites pass: Connect 2580/2580, gRPC 1454/1454, gRPC-Web 2838/2838.
  • An end-path-by-end-path divergence audit against the pre-refactor code found zero behavioral differences (every code, message, trailer population, and stickiness identical), including the deadline edge cases (buffer-resident message past deadline, Ready-frame-at-deadline).

Notes for reviewers

  • One technically-observable non-API delta, deliberate: the Debug impl renames doneended. (An earlier draft of this description claimed a second delta around gRPC-Web double-trailer handling; it is not real — the path it described is provably unreachable in both the old and new shapes.)
  • The gRPC-Web EOF-residual trailer parse is preserved verbatim even though it is provably dead in both the old and new shapes (a complete frame is always consumed in-loop before poll_body runs, and EOF appends nothing) — the in-code comment now states this; removing the path belongs in a separate PR, not a pure-structure one.

Post-review nits addressed (7d1bc7a)

Truthful EOF-arm comment; deadline test now pins the early-fire direction too (elapsed >= deadline under paused time) and one success-path test runs under an unexpired deadline; get_or_insert so first-write-wins matches the written-exactly-once claim; message_inner renamed to next_message_or_end to telegraph the Err-means-stream-ended inversion.

🤖 Generated with Claude Code

rpb-ant and others added 2 commits June 10, 2026 15:32
Replace the done/error/trailers field triple on ServerStream with one
end: Option<StreamEnd> record written exactly once. The decode loop now
returns Result<message, StreamEnd>, so ending the stream without
recording the outcome is unrepresentable; classify_grpc_end is the
single site deciding clean-vs-failed for gRPC/gRPC-Web ends, and
process_end_stream classifies Connect ends as a pure function. The
whole-call deadline moves to the body frame poll, which is observably
equivalent (all inter-poll work is non-yielding) and lets every
terminal cause exit the loop as a StreamEnd.

No public-API or behavior change intended; streaming contract test
assertions are unchanged.
…king

Review hardenings on the restructure: debug_assert that the terminal
record is written once, and state the third fact the per-frame-poll
deadline equivalence rests on (timeout_at polls the inner future
before the timer).

Co-Authored-By: Claude <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jun 10, 2026

Copy link
Copy Markdown

All contributors have signed the CLA ✍️ ✅
Posted by the CLA Assistant Lite bot.

- Fix the gRPC-Web EOF-arm comment to state the truth: the residual
  trailer-frame parse is provably dead (the loop-top completeness
  check consumes any complete frame before poll_body runs, and EOF
  appends nothing); it is preserved verbatim for pure-structure
  fidelity with removal as a follow-up
- Pin the early-fire direction of the deadline test (elapsed >= the
  deadline) and run one success-path test under an unexpired deadline
- get_or_insert over insert so first-write-wins matches the
  written-exactly-once claim even if the guard were ever violated
- Rename message_inner -> next_message_or_end to telegraph the
  Err-means-stream-ended inversion at call sites

Co-Authored-By: Claude <noreply@anthropic.com>
@rpb-ant rpb-ant marked this pull request as ready for review June 10, 2026 15:58
@rpb-ant rpb-ant enabled auto-merge June 10, 2026 16:04
@rpb-ant rpb-ant added this pull request to the merge queue Jun 10, 2026
Merged via the queue into main with commit 954d3d0 Jun 10, 2026
24 of 25 checks passed
@github-actions github-actions Bot locked and limited conversation to collaborators Jun 10, 2026
@iainmcgin iainmcgin deleted the stream-end-record branch June 10, 2026 16:50
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants