Skip to content

[Rust] Add PreparedSession for loss-free startup event subscription - #2319

Open
jmoseley wants to merge 6 commits into
mainfrom
jmoseley-early-session-event-subscription-7ba
Open

[Rust] Add PreparedSession for loss-free startup event subscription#2319
jmoseley wants to merge 6 commits into
mainfrom
jmoseley-early-session-event-subscription-7ba

Conversation

@jmoseley

Copy link
Copy Markdown
Contributor

The problem

Session::subscribe() can only be called once the session handle exists. Session::subscribe is backed by a tokio::sync::broadcast channel, and a broadcast send with zero receivers drops the value. So every event the runtime emitted while session.create / session.resume was 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_session allocated 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_session allocated and spawned the event loop before awaiting session.resume, so events were broadcast to nobody in real time.

getMessages can't paper over it: ephemeral events such as session.idle are never written to the session log. A consumer that needs to know the agent went idle during a resume with continuePendingWork has 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_session return a PreparedSession that owns the session's broadcast channel up front. Subscribe first, then start:

let prepared = client.prepare_session(
    SessionConfig::default().with_event_buffer_capacity(2048),
)?;
let mut events = prepared.subscribe();   // installed before anything hits the wire
let session = prepared.start().await?;

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 until start() is first polled. start(self) consumes the handle and PreparedSession is deliberately not Clone, so a prepared session can never produce two event loops.

create_session / resume_session are now wrappers over prepare_*(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 as InvalidConfig rather than clamped). The buffer is finite by design: slow subscribers observe Lagged with a skipped count instead of applying backpressure to the event loop.

Cancellation and cleanup

This is the other half of the change. resume_session already had a PendingSessionRegistration RAII guard; create_session had none, so dropping a create future mid-RPC leaked the router registration outright.

PendingSessionRegistration now 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:

  • Dropping an unstarted PreparedSession is fully inert and closes its subscriptions.
  • Dropping a polled start() future cancels the token, unregisters the session, and closes early subscriptions — a retry with the same session ID succeeds.
  • Startup errors do the same and keep the exact ErrorKinds these calls have always returned.

Drop is 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_id gets 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.idle delivered exactly once and in order on create (both concurrent-drain and deferred-consumer variants) and on resume with continuePendingWork; an undersized buffer surfacing Lagged rather than silent loss with the live tail still consumable; prepare inertness (no wire traffic, no registration, no spawned task, verified against num_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 that PreparedSession is Send + 'static and not Clone.

Verification

  • just lint-rust (nightly fmt check + clippy with the repo's full deny set) — clean
  • just test-rust — 791 tests, all targets green, including the 390 replay-proxy E2E tests
  • RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --all-features — clean

Docs: 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 a CHANGELOG.md Unreleased entry. No protocol or generated-type changes.

`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>
@jmoseley
jmoseley requested a review from a team as a code owner August 12, 2026 14:27
Copilot AI balanced review requested due to automatic review settings August 12, 2026 14:27
@jmoseley
jmoseley marked this pull request as draft August 12, 2026 14:31

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread docs/features/streaming-events.md Outdated
Comment thread rust/src/session.rs Outdated
Comment thread rust/src/session.rs
Comment thread CHANGELOG.md Outdated
jmoseley and others added 2 commits August 12, 2026 08:32
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
@jmoseley
jmoseley marked this pull request as ready for review August 12, 2026 16:03
jmoseley and others added 3 commits August 12, 2026 15:02
`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
@jmoseley

Copy link
Copy Markdown
Contributor Author

Pushed a7471a05d8b48c79. All four unresolved review threads are addressed and resolved:

  • de2ef259 — removed the lone <details> wrapper around the Rust example in docs/features/streaming-events.md.
  • 69da7703 — narrowed the prepare_session rustdoc and changelog to routed events, with pinning session_id called out as the requirement for complete pre-response coverage.
  • d8b48c79 — keeps session IDs out of the two prepared_session_test.rs polling helpers' failure diagnostics (a downstream consumer that vendors this crate flags identifiers reaching formatted output). Predicates and deadlines are unchanged, so no assertion is weakened.

The cancellation-race thread needed no code change: bb8ca432 already closes both interleavings, and I verified it against the final code and the six tests that cover the two orderings — details in the thread.

No API, behavior, or wire change on this head. Local gates green: nightly cargo fmt --check, cargo clippy --all-targets --features test-support,bundled-in-process -D warnings, RUSTDOCFLAGS=-D warnings cargo doc --no-deps --all-features, and the full non-E2E test matrix under both --no-default-features --features test-support and --all-features (prepared_session 21/21, session 117/117, lib 216/232, doctests 21). E2E is unrunnable locally (no CLI install) and is untouched by this delta.

@copilot-pull-request-reviewer ready for re-review.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants