[Rust] Add PreparedSession for loss-free startup event subscription - #2319
[Rust] Add PreparedSession for loss-free startup event subscription#2319jmoseley wants to merge 13 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. |
There was a problem hiding this comment.
Review details
Suppressed comments (4)
rust/src/session.rs:1826
- This promises delivery of every event to every subscriber, but the finite Tokio broadcast buffer explicitly allows slow subscribers to receive
Laggedand skip events. Qualify the statement so it does not contradict the buffering contract immediately above.
/// May be called any number of times; every subscriber receives every
/// event. Subscriptions taken here close if the prepared session is
/// dropped without starting, or if startup fails.
rust/src/session.rs:1421
- The new create cleanup branch for a failed
session.eventLog.registerInterestcall is not exercised: the MCP-auth tests inrust/tests/session_test.rs:292-430only return successful interest responses, while the prepared-session failure tests cover only create RPC errors and ID mismatches. Add a failing interest response test that verifies the original error kind, router unregistration, and closure of an early subscription.
if has_mcp_auth_handler
&& let Err(error) = register_mcp_auth_interest(self, &session_id).await
{
registration.cleanup(event_loop).await;
return Err(error);
rust/src/session.rs:1659
- The equivalent resume cleanup branch also lacks failure coverage: existing MCP-auth resume tests return a successful interest response. Add a failing interest response test for prepared resume and assert that the event loop/subscription closes and the router registration is removed before the error is returned.
if has_mcp_auth_handler
&& let Err(error) = register_mcp_auth_interest(self, &session_id).await
{
registration.cleanup(event_loop).await;
return Err(error);
docs/features/streaming-events.md:258
- For a consumer that requires lossless startup delivery, one of these actions is required. The docs style guide avoids “should” for required actions; use “must” to make the constraint definitive.
* Consumers that need a lossless view of a large startup burst should either configure a capacity that covers it or drain the subscription concurrently with `start()`.
- Files reviewed: 9/9 changed files
- Comments generated: 0 new
- Review effort level: Balanced
`session.eventLog.registerInterest` is the last fallible step of startup when an MCP-auth handler is installed, and its failure branch was untested: the existing MCP-auth tests only return successful interest responses, and the prepared-session failure tests covered create RPC errors and ID mismatches only. Add a failing-interest test for each of create and resume, asserting the same contract the sibling failure tests assert: the original error kind reaches the caller, the router registration is gone, and a subscription installed before `start()` is closed. The resume test also asserts the best-effort `session.skills.reload` is never issued, since interest registration runs ahead of it. Verified by mutation that both tests execute the branch. Removing the `registration.cleanup(event_loop)` call does not turn them red, because `PendingSessionRegistration::drop` cancels and releases the same registration synchronously — the explicit cleanup is defense in depth on this path, and the tests assert the observable contract rather than which of the two mechanisms performed it. Test-only change: no API, behavior, or wire impact. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0f9d5ac7-9999-4f37-82b3-a5533bfbc1f6
`PreparedSession::subscribe` promised that "every subscriber receives every event", which contradicts the paragraph directly above it: the broadcast buffer is finite, so a subscriber that falls behind the configured capacity observes `Lagged` and skips events instead of applying backpressure. Say that explicitly and link the `Lagged` type. Also replace "should" with "must" where the streaming-events article and the changelog describe what a consumer needing lossless startup delivery has to do. The docs style guide reserves ambiguous modals for optional actions, and `rust/README.md` already phrased this as "must". 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
|
Follow-up on the re-review's four suppressed comments — all addressed.
One honest note on those two: I verified by mutation that both tests execute the branch, and removing the Test-only and doc-only: Gates re-run green on |
`force_stop` sent a kill and returned. Nothing waited on the child, so on Unix it stayed a zombie for as long as the parent lived — fine for a short-lived consumer, but an embedded host is long-lived and accumulates them every time a shutdown times out. The deeper problem was ownership. `stop()` took the child out of its slot and then awaited the kill inline, so an outer timeout cancelling it in that window dropped the handle with the future: the process was neither reaped nor recoverable, and a following `force_stop` found nothing to do. A plain async sibling of `force_stop` would not have fixed that. Introduce `ChildLifecycle`, which owns the child rather than lending it to whoever asks. The kill is delivered synchronously under the slot lock — `start_kill` needs no reactor, so signalling never depends on a task being polled — and the claimed child is then reaped on a dedicated thread with its own runtime. A `tokio::spawn`ed reaper would be cancelled when its runtime drops, which for an embedded host is precisely when termination matters; a thread is independent of every caller runtime, so a handle can be awaited even from a different one. The `enable_all()` on that runtime is load-bearing: once `try_wait` misses, readiness comes from the signal driver. The outcome is published on a `watch` channel, so concurrent and repeat callers observe the same terminal result instead of racing for the handle, and `stop()` claims through the same path — cancelling it can no longer strand the process. Public API (additive; `force_stop` keeps its signature and semantics): Client::force_stop_and_wait() -> Result<Option<ExitStatus>> Client::start_force_stop() -> ForcedShutdown ForcedShutdown::wait() / ::pid() `Option<ExitStatus>` is `None` only when the client never spawned a child (stream-backed and in-process transports); failure is always `Err`. `ReapGuard` publishes a definitive error if the reaper cannot run at all, so no waiter is left pending — a guarantee about the state machine, not a time bound, since the underlying wait is unbounded by nature. 15 tests, three of them mutation-verified: reverting the detached ownership hangs the cancel-after-claim recovery; moving the kill back into the reaper leaves the process in state S; dropping `enable_all` hangs the still-running-child reap. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0f9d5ac7-9999-4f37-82b3-a5533bfbc1f6
Cover the three forced-shutdown shapes and when each resolves, the ownership guarantee that makes cancellation safe, and the cross-runtime promise for `ForcedShutdown` — each claim matching what the tests prove rather than what the design aspires to. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0f9d5ac7-9999-4f37-82b3-a5533bfbc1f6
|
Adds awaited CLI process termination — Problem. Fix. New Public API (additive; Client::force_stop_and_wait() -> Result<Option<ExitStatus>> // killed AND reaped
Client::start_force_stop() -> ForcedShutdown // owned handle
ForcedShutdown::wait() / ::pid()
Three mutation-verified guards — each fails when the property it protects is removed:
15 lifecycle tests: cross-runtime survival, no-runtime-context, cancelled Reviewed across three rounds by an independent reviewer, who caught a critical regression in my first attempt (I had wrongly believed Gates green in both feature configs: nightly fmt, clippy @copilot-pull-request-reviewer ready for re-review. |
There was a problem hiding this comment.
Review details
Suppressed comments (2)
CHANGELOG.md:10
- This PR is described as the
PreparedSessionstartup-subscription change, but this entry exposes a second, unrelated public feature (ForcedShutdown,start_force_stop, andforce_stop_and_wait) backed by a new 677-line process-lifecycle implementation and additional tests. That materially expands the API and risk beyond the stated scope. Please split the confirmed-process-termination work into its own PR (or, at minimum, retitle and fully describe the combined change) so it can be reviewed and released independently.
### Feature: confirmed CLI process termination (Rust)
rust/README.md:82
- This example first awaits
client.stop()unconditionally and then callsstop()a second time inside the timeout, so it does not demonstrate a forced fallback when the original graceful shutdown hangs. Replace the standalone shutdown call with a single timeout-wrapped call and force-stop only that call's timeout path.
// Forced shutdown that confirms the OS reaped the CLI process, for hosts
// that must not leak a zombie when graceful shutdown times out.
if tokio::time::timeout(Duration::from_secs(5), client.stop()).await.is_err() {
let exit_status = client.force_stop_and_wait().await?;
- Files reviewed: 10/10 changed files
- Comments generated: 1
- Review effort level: Balanced
`ClientInner::drop` signalled the child and dropped it without waiting, so a client that was never stopped left behind exactly the zombie this work set out to remove. Signalling is not termination. Give `ChildLifecycle` its own `Drop` that hands the child to the reaper instead, so every path out of a client — graceful stop, forced stop, or plain drop — ends with the process released by the OS. `ClientInner` no longer needs to do anything: the field's drop covers it. Mutation-verified: reverting drop-time termination to a bare `start_kill` leaves the process in `ps` state `Z`, which the new test catches. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0f9d5ac7-9999-4f37-82b3-a5533bfbc1f6
There was a problem hiding this comment.
Review details
Suppressed comments (3)
rust/src/session.rs:1752
- Dropping
start()cannot guarantee that a same-ID create retry succeeds once the RPC has been sent.Client::callcommits the frame before yielding and explicitly notes that a cancelledsession.createcan still take effect (rust/src/lib.rs:1897-1907); this guard only removes the local router registration. The abandoned create may therefore complete in the CLI and collide with the retry. Either implement server-side cancellation/late-response cleanup, or narrow this contract and its tests to guarantee only local registration cleanup.
/// * Dropping the [`start`](Self::start) future mid-flight cancels the
/// session token, unregisters the session from the router if it was
/// registered, and closes early subscriptions. A retry with the same
/// session ID succeeds — cleanup removes only the exact registration that
/// startup owned, so a retry started before the abandoned attempt has
rust/README.md:82
- This example performs a successful
stop()immediately before attempting the timeout fallback, so the secondstop()has no live child and the forced-shutdown branch cannot demonstrate the stated scenario. Replace the first shutdown call with the timeout/fallback sequence rather than running both sequentially.
// Forced shutdown that confirms the OS reaped the CLI process, for hosts
// that must not leak a zombie when graceful shutdown times out.
if tokio::time::timeout(Duration::from_secs(5), client.stop()).await.is_err() {
let exit_status = client.force_stop_and_wait().await?;
}
CHANGELOG.md:10
- The PR title and description cover only loss-free session startup, but this change also introduces a separate public forced-shutdown API and replaces child-process ownership/reaping across
rust/src/child.rs,rust/src/lib.rs, andrust/README.md. This independent lifecycle redesign materially expands the API and risk without being described by the PR. Split it into its own PR, or update the PR scope and verification so it can be reviewed as an intentional second feature.
### Feature: confirmed CLI process termination (Rust)
- Files reviewed: 10/10 changed files
- Comments generated: 1
- Review effort level: Balanced
CI caught this on Ubuntu: dropping a client left its CLI process in `Z` for the full 5s the new test allows. The reaper awaited `Child::wait`, which on Unix falls back to the SIGCHLD driver once `try_wait` misses — and the signal registration belongs to the runtime that spawned the child, not to the reaper's own. The reaper could therefore sleep waiting for a wakeup that was delivered elsewhere. macOS happened to win the race locally; Linux did not. Poll `try_wait` on a short backoff (2ms doubling to 50ms) instead. That makes the reap independent of which runtime owns the signal registration, which is the whole premise of running it on its own thread. It costs a handful of wakeups once per client teardown. `enable_all()` on the reaper runtime stays load-bearing, now for the timer rather than the signal driver, and the test that guards it is unchanged: it reaps a child that is still running at the first check. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0f9d5ac7-9999-4f37-82b3-a5533bfbc1f6
`start_kill` failures were logged and swallowed. That is right when the child is already gone — the reap then reports its real status — but wrong when the OS refuses the signal: nothing will make that process exit, so the reaper would poll forever, every `ForcedShutdown::wait()` would park indefinitely, and `force_stop()` would report success. Distinguish the two with `signal_refusal`, which treats a failed kill as benign only when the child has actually exited. A genuine refusal is published as a definitive error *before* the reaper starts, so waiters observe it rather than blocking. Ownership is unchanged: the reaper still takes the child and still overwrites the error with the truth if the process does go away, so a refusal degrades to an observable error rather than to a silent success or a hang. The decision is a pure function so it can be tested directly; fabricating a real refusal would mean signalling a process the test does not own. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0f9d5ac7-9999-4f37-82b3-a5533bfbc1f6
There was a problem hiding this comment.
Review details
Suppressed comments (5)
rust/src/child.rs:357
- This error contract is false for the new signal-refusal path:
start_killcan fail on a live child, and lines 161–165 publishFailedprecisely because no signal was delivered. Document refusal separately so callers do not assume the process was terminated after everyErr.
/// Returns [`ErrorKind::Io`] if waiting on the child failed, or if the
/// reaper could not run to completion — its thread failed to start,
/// its runtime could not be built, or it panicked. The child has
/// always been signalled by then; only confirmation is lost.
rust/src/child.rs:198
- When
start_killis refused, the code publishesFailedimmediately, so an early waiter returnsErr. This unconditional replacement can later publishReapedif the child exits naturally, causing later/concurrent handles to returnOkfor the same termination. That violates the documented single terminal outcome. Keep signal refusal sticky in the externally observed result, or model signal and reap outcomes separately so every waiter resolves identically.
This issue also appears on line 354 of the same file.
let outcome = runtime.block_on(reap(child));
guard.completed = true;
guard.state.send_replace(outcome);
rust/README.md:82
- This example shuts the client down successfully on line 76 and then invokes
stop()a second time inside the timeout, so it never demonstrates the intended graceful-then-forced fallback. Use a single timedstop()call and force-stop only when that call times out.
// Forced shutdown that confirms the OS reaped the CLI process, for hosts
// that must not leak a zombie when graceful shutdown times out.
if tokio::time::timeout(Duration::from_secs(5), client.stop()).await.is_err() {
let exit_status = client.force_stop_and_wait().await?;
CHANGELOG.md:10
- The PR title and description scope this change to
PreparedSession, but this adds a second, unrelated public feature (ForcedShutdown,start_force_stop,force_stop_and_wait) plus a substantial child-process lifecycle refactor. This makes the advertised scope and verification incomplete for the actual API changes; split the process-termination feature into its own PR (or update the PR metadata and review it independently).
### Feature: confirmed CLI process termination (Rust)
rust/src/lib.rs:2622
- The signal-refusal branch returns
ErrorKind::Iowithout signalling the live child, so “the child is signalled in every case” is not true. Include kill refusal in the error list and state that the process may still be running in that case.
/// Returns [`ErrorKind::Io`] if waiting on the child failed, or if the
/// reaper could not run to completion. The child is signalled in every
/// case; only confirmation of the reap is lost. No outcome is ever
- Files reviewed: 10/10 changed files
- Comments generated: 0 new
- Review effort level: Balanced
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.