Skip to main content

Module cancel

Module cancel 

Source
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::Cancelled immediately. 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::Completed arm 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::Panicked carrying the tokio::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:

  1. The future’s own completion (normal exit – desired path).
  2. 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, and crate::rbac::current_sub will return None even 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 in crate::tool_hooks).

Enums§

DetachOutcome
Outcome of run_with_cancel_and_timeout.

Functions§

run_with_cancel_and_timeout
Race a 'static future against client cancellation and an optional timeout, detaching the future on cancel/timeout so it can complete its own cleanup path.