refactor(protocol): one subscription handshake instead of four (audit theme 1) - #137
Conversation
Subscription-style requests (/events/subscribe, /logs/stream) answer on the bidi stream before opening the data stream, and answer with an error in routine cases: server_busy from the stream semaphore, requirements_invalid or not_found from /logs/stream validation, server_busy from a journal-open failure. Every consumer hand-rolled the client half of that handshake and each got a different subset wrong. open_subscription reads the envelope to FIN (the framing i[stream.control] actually specifies, not read_line), classifies it through the same parse_response as request(), and only then awaits the data stream — under a timeout, because the server's own open_uni failure path merely logs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HtxQdsF6YhLzz9RrDmHEDv
Deletes the three hand-rolled copies. Each had its own defect: subscribe_events and the web gateway's start_log_stream read a line that the envelope does not terminate, discarded it, and then parked on accept_uni forever when the response was an error; ctl's subscribe path read it correctly but reported a rejection as a graceful close, exiting 0. The web gateway now relays the daemon's own code and message to the browser instead of flattening every failure to daemon_unavailable, and ctl events exits 1 on a rejection. A CI guard keeps accept_uni to the helper and the shell paths, whose framing genuinely differs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HtxQdsF6YhLzz9RrDmHEDv
There was a problem hiding this comment.
Pull request overview
This PR refactors the client-side handling of subscription-style OI requests (/events/subscribe, /logs/stream) so the handshake is implemented once (in OiClient::open_subscription{,_raw}) and consistently classifies server error envelopes before waiting for the server-initiated uni stream, preventing clients from discarding errors and hanging indefinitely.
Changes:
- Add
OiClient::open_subscription/open_subscription_rawwith sharedparse_response, FIN-bounded response reading, and a bounded wait for the data uni stream. - Update
ctlandwebcall sites to use the shared helper and surface daemon rejection details appropriately (includingctl eventsexiting non-zero on rejection). - Add protocol-level loopback tests for all handshake outcomes and a CI guard script to prevent new hand-rolled
accept_unisubscription handshakes.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| etc/ci/check-accept-uni.sh | CI enforcement to prevent new hand-rolled subscription handshakes using accept_uni outside an allowlist. |
| docs/spec/interface.md | Adds i[stream.subscribe] specifying the subscription handshake contract and required client classification/timeout behaviour. |
| crates/web/src/wt.rs | Relays daemon ClientError::Api code/message to the browser instead of flattening to daemon_unavailable. |
| crates/web/src/daemon.rs | Replaces hand-rolled /logs/stream handshake with OiClient::open_subscription_raw to preserve the browser actor. |
| crates/protocol/src/client.rs | Implements the shared subscription handshake helper, factors response parsing, adds timeout + tests, and adds connect_from for IPv4-bind testability. |
| crates/ctl/src/subscribe.rs | Switches event subscription to the shared helper and treats daemon rejection as a non-zero exit. |
| crates/ctl/src/logs.rs | Switches log streaming to the shared helper, ensuring error envelopes don’t lead to a hang. |
| .github/workflows/rust.yml | Runs the new check-accept-uni.sh in CI to prevent regressions. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Main has since added the server-initiated Canopy relay streams, which sit alongside i[stream.subscribe] in the dispatch section rather than conflicting with it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HtxQdsF6YhLzz9RrDmHEDv
Code Coverage OverviewLanguages: TypeScript, Rust TypeScript / code-coverage/vitestThe overall coverage in commit ab724d7 in the Rust / code-coverage/rustThe overall coverage in commit ab724d7 in the Show a code coverage summary of the most impacted files.
Updated |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 8 out of 8 changed files in this pull request and generated no new comments.
Suppressed comments (2)
etc/ci/check-accept-uni.sh:24
- With
set -euo pipefail, this will fail the CI job ifgrepfinds no matches (exit code 1), even though “no accept_uni usages” should be a clean pass. It’s better to treat the “no matches” case as an empty hit list so the script stays well-behaved ifaccept_uniis ever removed/renamed or temporarily absent in a refactor.
pattern='accept_uni'
mapfile -t hits < <(grep -rln --include='*.rs' "$pattern" crates/ | sort)
docs/spec/interface.md:107
- The spec currently uses both “stream boundary is the message boundary” (i[stream.control]) and “streams begin with a newline-terminated JSON object” (i[stream.dispatch]); in this new subscription contract, it would help to explicitly restate that the response envelope is delimited by FIN/stream close (not by a newline) to avoid ambiguity for anyone implementing a client from the spec alone.
> i[stream.subscribe]
> A subscription-style request — one whose success causes the server to open a server-initiated unidirectional stream carrying the subscribed data — is answered on the bidirectional stream exactly as any other control request, before any unidirectional stream is opened.
> A response carrying an error terminates the request: the server opens no unidirectional stream, and the connection remains usable for further requests.
> Clients must therefore read and classify the response envelope before waiting for the unidirectional stream, must treat closure of the bidirectional stream without a response envelope as an error, must surface the error envelope's `code` and `message` to their caller rather than discarding them, and must not wait indefinitely for a unidirectional stream that a server which answered successfully may still fail to open.
The section carries two framings — newline-terminated dispatch headers and stream-boundary messages — so which one bounds the response envelope was left to inference by anyone implementing a client from the spec alone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HtxQdsF6YhLzz9RrDmHEDv
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 8 out of 8 changed files in this pull request and generated no new comments.
Suppressed comments (3)
crates/web/src/wt.rs:263
- This file already imports
serde_json::jsonand usesjson!(...)in many places. These new arms use the fully-qualifiedserde_json::json!(...), which is inconsistent and unnecessary.
ClientError::Api { code, message } => serde_json::json!({
"error": { "code": code, "message": message }
}),
_ => serde_json::json!({
"error": { "code": "daemon_unavailable", "message": e.to_string() }
.github/workflows/rust.yml:41
- This step relies on
etc/ci/check-accept-uni.shbeing checked in with the executable bit set. Invoking it viabashis more robust (and still respectsset -euo pipefailinside the script).
- name: No hand-rolled subscription handshakes
run: etc/ci/check-accept-uni.sh
crates/web/src/wt.rs:253
ClientError::Apirepresents a daemon-side rejection (e.g. not_found / requirements_invalid), which is an expected outcome and will be surfaced to the browser. Logging it aterror!will likely create noisy gateway error logs for normal user mistakes; consider logging API rejections atinfo!/warn!and reservingerror!for transport/protocol failures.
Err(e) => {
tracing::error!("log stream setup failed: {e}");
Closes cross-cutting theme 1 from the logic bug audit: error responses on bidi streams are discarded by clients. See the pattern analysis.``
The class
Subscription-style requests (
/events/subscribe,/logs/stream) answer on the bidi stream before opening the data stream, and answer with an error routinely —server_busyfrom the stream semaphore,requirements_invalid/not_foundfrom/logs/streamvalidation,server_busyfrom a journal-open failure. In every error branch the server opens no uni stream and keeps the connection alive.Four call sites re-implemented the client half of that handshake, and each got a different part wrong. The bug is not subtle logic — it is per-caller re-implementation of a wire contract.
The change
OiClient::open_subscription(crates/protocol/src/client.rs) is now the only way to drive one:read_to_end, matching the FIN-is-the-boundary framingi[stream.control]actually specifies — neverread_line, which only ever returned because the FIN produced EOF;parse_responseasrequest(): error envelope →ClientError::Api, zero bytes →ClientError::Protocol, unparseable →Protocol;accept_unionly after a confirmed OK, under a 10 s timeout, because the server's ownopen_unifailure path only logs and returns.open_subscription_rawtakes a pre-serialised envelope for the one caller that must preserve an actor built elsewhere (the web gateway relays the browser session's actor, not its own).Findings closed
subscribe_eventsswallows error responses, then blocks forever on a uni stream that will never arriveaccept_uniunreachable without an OK/logs/streamhangs forever when the daemon returns an errorstart_log_streamon the shared helperctl eventsexits 0 when the server rejects the subscriptionSessionOutcome::Rejected→ exit 1Transitively, the web event broker's reconnect back-off (
event_broker.rs:81-116) stops being dead weight: it is built entirely onsubscribe_eventsreturning promptly.Two behaviour changes worth naming: the web gateway now relays the daemon's own
codeandmessageto the browser instead of flattening every failure todaemon_unavailable, so the UI can tell "no such app" from "daemon down"; andctl eventsexits non-zero on a rejection, which a script wrapping it will now notice.Enforcement
i[stream.subscribe]indocs/spec/interface.md, stating the wire contract (an error response terminates the request; clients must classify before waiting, must treat a response-less close as an error, and must not wait indefinitely) rather than the helper's shape.TestOidrivesdispatchdirectly and never toucheshandle_bidi_stream, so it could not reach this handshake. Each assertion is wrapped in a timeout so a regression fails fast instead of hanging CI.etc/ci/check-accept-uni.shfails the build onaccept_unioutside an allowlist (the helper, plus the shell paths whose framing genuinely differs), catching the fifth hand-rolled copy before review does.OiClient::connectgrew a privateconnect_fromtaking the local bind address, so the stub-server tests run on hosts without IPv6; production still binds the dual-stack wildcard.Not in scope
/shells/startuses genuinely different framing (newline-delimited JSON on a bidi that stays open, uni stream IDs announced in the handshake,i[stream.shell.framing]) and keeps its own handling. Data-phase defects on the uni stream itself — the web broker's lag/duplication findings in §17 — are separate.Overlap with other themes
Independent of the other seven, so it sits directly on
main. Theme 7's event-broker item is downstream of H16 but touches different files.Generated by Claude Code