Expand description
Cancellation primitives that detach in-flight async work on
cancel/timeout instead of dropping it mid-.await.
Cancellation primitives that detach in-flight async work on the
cancel/timeout branch instead of dropping it mid-.await.
§Motivation
tokio::select! arms that race a long-running future against
tokio_util::sync::CancellationToken or tokio::time::sleep drop
the losing future when another branch wins. For work that owns a
remote-side resource (an SSH channel, an in-flight HTTP body, a DB
transaction), dropping mid-flight leaves that resource half-open
until some outer lifetime ends – the inner future’s own
close().await calls only run on its own early-return paths, never
on outer Drop.
The fix: hand the future to tokio::spawn so it owns its own task
frame. Race the resulting tokio::task::JoinHandle – not the
future itself – against the cancel/timeout sources. When the
cancel/timeout branch wins, the JoinHandle is dropped (NOT
.abort()); the detached task keeps running to completion and
drives the inner close path. The client gets its cancel/timeout
response immediately, and the remote-side resource is released as
soon as the spawned future finishes its current work.
§Semantics
- Pre-cancel check: if the token is already cancelled at entry,
the future is NEVER spawned. Returns
DetachOutcome::Cancelledimmediately. Avoids starting expensive (often mutating) work for requests the client has already abandoned. - Completion wins on tie: if the spawned future and a
cancel/timeout signal are both ready in the same poll, the
DetachOutcome::Completedarm wins. This prevents reporting cancel/timeout for an operation that actually succeeded (especially harmful for mutating tools where the client might then retry). - Panic surfacing: a panic in the spawned future is exposed as
DetachOutcome::Panickedcarrying thetokio::task::JoinError. Callers decide how to translate it; the helper does not fold it into Cancelled/TimedOut.
§Lifetime
Spawned tasks live on the tokio runtime. They are bounded by:
- The future’s own completion (normal exit – desired path).
- Tokio runtime shutdown (unavoidable – TCP teardown forces the remote side to release resources regardless).
They are NOT bounded by the request handler that started them, by
CancellationToken cancel,
or by any tokio::task::JoinHandle the caller might hold. That
is the entire point.
§Caller obligations
Detached tasks can accumulate if the inner future hangs forever
(dead channel, wedged HTTP body, deadlock, missing protocol-level
timeout). Callers MUST ensure the inner future has its own
eventual-completion guarantee. The timeout argument here is a
response timeout, not an operation timeout: it bounds how long
the client waits, not how long the work runs.
§Caveats
These properties of the calling context are lost when work detaches onto the runtime:
-
Task-local RBAC scope. The helper does NOT propagate RBAC task-locals into the spawned future. Inside the detached task, the accessors
crate::rbac::current_role,crate::rbac::current_identity,crate::rbac::current_token, andcrate::rbac::current_subwill returnNoneeven if the originating request was authenticated. This is intentional: detached work should finish or close already-authorized resources, not initiate fresh RBAC-gated operations. Holding secrets and tokens alive past the request boundary would extend credential lifetime past the request that authorized them.If a caller genuinely needs RBAC context inside detached work (e.g. emitting an audit event that names the originating identity), it MUST capture the values before the spawn and rebind them with
crate::rbac::with_rbac_scope:use rmcp_server_kit::{cancel, rbac}; use std::time::Duration; use tokio_util::sync::CancellationToken; // Capture BEFORE spawn. let role = rbac::current_role().unwrap_or_default(); let identity = rbac::current_identity().unwrap_or_default(); let token = rbac::current_token().unwrap_or_else(|| { use rmcp_server_kit::secret::SecretString; SecretString::new(String::new().into()) }); let sub = rbac::current_sub().unwrap_or_default(); let fut = async move { rbac::with_rbac_scope(role, identity, token, sub, async { // Detached work here can call current_role() etc. }) .await; }; let _ = cancel::run_with_cancel_and_timeout(fut, &ct, Some(Duration::from_secs(5))).await; -
Tracing span: the originating request’s span IS preserved. The helper wraps the spawned future in
.instrument(tracing::Span::current()), so log lines from the detached task remain attached to the request span (matching the convention incrate::tool_hooks).
Enums§
- Detach
Outcome - Outcome of
run_with_cancel_and_timeout.
Functions§
- run_
with_ cancel_ and_ timeout - Race a
'staticfuture against client cancellation and an optional timeout, detaching the future on cancel/timeout so it can complete its own cleanup path.