[Rust] Add PreparedSession for loss-free startup event subscription - #2319
[Rust] Add PreparedSession for loss-free startup event subscription#2319jmoseley wants to merge 6 commits into
Conversation
`Session::subscribe()` can only be called once the session handle exists, so every event the runtime broadcast during `session.create` / `session.resume` had no receiver installed and was dropped. Ephemeral events like `session.idle` are never written to the session log, so `get_messages` cannot recover them afterwards either. `Client::prepare_session` / `prepare_resume_session` return a `PreparedSession` that owns the session's broadcast channel up front: subscribe first, then `start()`. `prepare_*` is synchronous and inert — it validates the event buffer capacity, allocates a local channel and cancellation token, and performs no router registration, task spawn, or wire activity until `start()` is first polled. `start(self)` consumes the handle and the type is deliberately not `Clone`, so a prepared session can never produce two event loops. `create_session` / `resume_session` become wrappers over `prepare_*(config)?.start().await`, preserving their RPC sequences and error kinds. Their bodies moved into private start paths that take the sender and token by injection rather than allocating their own. Both configs gain a runtime-only `event_buffer_capacity` (default 512, `Some(0)` rejected as `InvalidConfig`, never clamped). The buffer is finite, so slow subscribers observe `Lagged` instead of applying backpressure. Cancellation cleanup is now symmetric. `PendingSessionRegistration` grew a deferred variant that resolves the session ID from the inline-response stash, so the create path — including the cloud server-assigned-ID path, which previously had no RAII guard at all — unregisters and cancels when the startup future is dropped or fails. Registration and stashing now happen under one lock hold to close the window where a concurrent drop would miss a just-registered session. The mcp-auth-interest error path on both create and resume now cancels and awaits the event loop instead of returning through `?`. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Adds Rust PreparedSession APIs to subscribe before create/resume startup events occur.
Changes:
- Adds configurable event buffering and cancellation-safe startup paths.
- Adds comprehensive prepared-session tests.
- Documents the API and startup-event semantics.
Show a summary per file
| File | Description |
|---|---|
rust/src/session.rs |
Implements prepared sessions and cleanup. |
rust/src/types.rs |
Adds event-buffer configuration. |
rust/src/lib.rs |
Adds test-only router inspection. |
rust/tests/prepared_session_test.rs |
Tests delivery, lag, cancellation, and wrappers. |
rust/Cargo.toml |
Registers the new test target. |
rust/README.md |
Documents Rust usage. |
docs/features/streaming-events.md |
Adds early-subscription guidance. |
CHANGELOG.md |
Announces the feature. |
Review details
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
- Files reviewed: 8/8 changed files
- Comments generated: 4
- Review effort level: Balanced
Follow-up to the `PreparedSession` change. Two cancellation races remained in session registration, both reachable from a caller simply dropping a `start()` future. **Deferred cloud-create registration.** For a cloud session with no caller-pinned ID the CLI assigns the ID, so the SDK can only register on the notification router from the inline `session.create` response callback. The read loop removes the pending-response entry *before* invoking that callback, so a startup future dropped in that window found an empty stash, cleaned up nothing, and the callback then registered a session with no owner — a permanent router leak. Registration state now lives in a shared `DeferredRegistration` slot (`Pending` / `Registered` / `Cancelled` / `Claimed`) that the callback, the startup path, and the cancellation guard all arbitrate through. The callback registers *under the slot lock*, so registering and publishing ownership are atomic with respect to cancellation: a concurrent guard either wins and marks the slot `Cancelled`, in which case the callback registers nothing, or it loses and finds a `Registered` slot to tear down. Never both, and never neither. The pinned-ID path uses the same slot, pre-populated, so create has one cleanup mechanism instead of two. **Stale cleanup versus a same-ID retry.** Unregistering by session ID alone removed whichever registration happened to hold the ID. Because cleanup of an abandoned startup is signalled rather than awaited, a caller that aborted a startup and immediately retried with the same pinned ID could have the retry's registration evicted by the dead attempt, silently stranding the live session with no event routing. The same applied to a `Session` dropped after being superseded. Registrations now carry a `RegistrationToken` identity and removal is a compare-and-remove: an owner removes only the exact registration it registered. Applied to create, resume, `Session::disconnect`, and `Session::drop`. `Client::stop` and `cleanup_sessions_for_test` keep removing unconditionally — they tear down every session and the runtime regardless of owner. Tests gate both windows deterministically rather than by timing. The slot state machine is driven directly at the exact interleaving the read loop creates, in both orders, and the router's compare-and-remove is covered on its own. End to end: a cancelled cloud create leaves no registration, no subscription, and no task behind whether cancellation lands before or after the callback registered, and a same-ID retry still succeeds; and create, resume, and `Session` drop each survive a stale owner's cleanup running after a retry has taken over the ID. Each test was confirmed to fail against a mutated implementation. No public API change. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0f9d5ac7-9999-4f37-82b3-a5533bfbc1f6
`Client::registered_session_ids` has no caller in a default-feature build: the in-crate unit tests reach it under `cfg(test)`, and the public `registered_session_ids_for_test` wrapper is gated on `feature = "test-support"`. A plain `cargo build` or `cargo clippy` therefore warned `dead_code` for it. Gate the method on `any(test, feature = "test-support")`, matching the convention already used for the other test-only helpers in this file. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0f9d5ac7-9999-4f37-82b3-a5533bfbc1f6
`Client::prepare_session` promised consumers would observe "every event a session emits". That is broader than the implementation for cloud creates with a server-assigned ID: the SDK cannot register the session on its notification router until the `session.create` response arrives, so notifications emitted before that point are not routable to any session and never reach a subscriber. Qualify the primary API documentation and the changelog as *routed* events, and point callers at pinning `SessionConfig::session_id` for complete pre-response coverage. `PreparedSession`'s type-level docs, `rust/README.md`, and `docs/features/streaming-events.md` already documented this limitation; the entry-point docs now match them. Documentation only: no API, behavior, or wire change. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0f9d5ac7-9999-4f37-82b3-a5533bfbc1f6
The "Subscribing before a session starts" section has only a Rust example, and the docs normalization pipeline converts a `<details>` group into a tabbed language switcher only when two or more consecutive blocks are present. A single block renders as raw collapsible HTML on docs.github.com. Drop the `<details>`/`<summary>` wrapper and leave the code fence directly in the article, matching the repository docs style guide. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0f9d5ac7-9999-4f37-82b3-a5533bfbc1f6
Two polling helpers formatted session identifiers into their failure
messages: `await_no_registrations` rendered the router's registered ID
list with `{:?}`, and `await_registered` interpolated the awaited ID.
A downstream consumer that vendors this crate has CodeQL rules flagging
identifiers reaching formatted output, so both were reported there even
though the SDK's own analysis was clean.
Report an outstanding-registration count and a static expectation
message instead. Both helpers keep their exact predicates and deadline
behavior: `await_no_registrations` still returns only when the router
holds zero registrations, and `await_registered` still blocks on the
exact ID it was given, so no assertion is weakened.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 0f9d5ac7-9999-4f37-82b3-a5533bfbc1f6
|
Pushed
The cancellation-race thread needed no code change: No API, behavior, or wire change on this head. Local gates green: nightly @copilot-pull-request-reviewer ready for re-review. |
The problem
Session::subscribe()can only be called once the session handle exists.Session::subscribeis backed by atokio::sync::broadcastchannel, and a broadcastsendwith zero receivers drops the value. So every event the runtime emitted whilesession.create/session.resumewas still in flight was broadcast into a channel nobody was listening to and silently discarded.Both startup paths have the hole, from opposite directions:
create_sessionallocated the broadcast sender before the RPC but only spawned the event loop after the response — the events sat in the router's unbounded queue and were then fanned out to nobody.resume_sessionallocated and spawned the event loop before awaitingsession.resume, so events were broadcast to nobody in real time.getMessagescan't paper over it: ephemeral events such assession.idleare never written to the session log. A consumer that needs to know the agent went idle during a resume withcontinuePendingWorkhas no way to recover that. Returning a(Session, EventSubscription)tuple after the await doesn't fix it either — the events are already gone by then.The change
Client::prepare_session/Client::prepare_resume_sessionreturn aPreparedSessionthat owns the session's broadcast channel up front. Subscribe first, then start:prepare_*is synchronous and inert. It validates the event buffer capacity, allocates a local channel and cancellation token, and does nothing else — no router registration, no task spawn, no bytes on the wire untilstart()is first polled.start(self)consumes the handle andPreparedSessionis deliberately notClone, so a prepared session can never produce two event loops.create_session/resume_sessionare now wrappers overprepare_*(config)?.start().await. Their bodies moved into private start paths that take the sender and cancellation token by injection instead of allocating their own, so there's one implementation rather than two.Both configs gain a runtime-only
event_buffer_capacity(default 512,Some(0)rejected asInvalidConfigrather than clamped). The buffer is finite by design: slow subscribers observeLaggedwith a skipped count instead of applying backpressure to the event loop.Cancellation and cleanup
This is the other half of the change.
resume_sessionalready had aPendingSessionRegistrationRAII guard;create_sessionhad none, so dropping a create future mid-RPC leaked the router registration outright.PendingSessionRegistrationnow carries either a known session ID or a deferred one that it resolves from the inline-response stash, which covers the cloud server-assigned-ID path where registration happens inside the JSON-RPC response callback. Registration and stashing happen under a single lock hold, closing the window where a concurrent guard drop would observe an empty stash and miss a session that was just registered. The mcp-auth-interest error path on both create and resume now cancels and awaits the event loop instead of returning through?.Net semantics:
PreparedSessionis fully inert and closes its subscriptions.start()future cancels the token, unregisters the session, and closes early subscriptions — a retry with the same session ID succeeds.ErrorKinds these calls have always returned.Dropis synchronous and can't await, so the event loop terminates promptly rather than synchronously. The docs say that rather than claiming otherwise.Known limitation, documented precisely
For cloud sessions where the server assigns the session ID, the SDK can't route notifications until the response arrives and the ID is known — pre-registration notifications aren't routable to any session. The guarantee is narrower and stated as such: routed events are never dropped for lack of an installed receiver. Pinning
session_idgets you registration before the RPC and full pre-response coverage.Tests
New
rust/tests/prepared_session_test.rs, 16 tests on the existing in-memory duplex harness with a hand-rolled JSON-RPC peer. No correctness sleeps — timeouts are failure backstops only.Covered: a 600-event pre-response burst plus an ephemeral
session.idledelivered exactly once and in order on create (both concurrent-drain and deferred-consumer variants) and on resume withcontinuePendingWork; an undersized buffer surfacingLaggedrather than silent loss with the live tail still consumable; prepare inertness (no wire traffic, no registration, no spawned task, verified againstnum_alive_tasks); dropping an unstarted handle; cancelling a polled create and resume with same-ID retry; RPC error and session-ID-mismatch cleanup preserving error kinds; one early plus one late subscriber sharing exactly one event loop; wrapper RPC sequences; and a compile-time assertion thatPreparedSessionisSend + 'staticand notClone.Verification
just lint-rust(nightly fmt check + clippy with the repo's full deny set) — cleanjust test-rust— 791 tests, all targets green, including the 390 replay-proxy E2E testsRUSTDOCFLAGS="-D warnings" cargo doc --no-deps --all-features— cleanDocs: rustdoc on the new surface, a "Subscribing before a session starts" section in
docs/features/streaming-events.md, the Rust README streaming section and Rust-only API list, and aCHANGELOG.mdUnreleased entry. No protocol or generated-type changes.