tinyhumansai/openhuman · warning

secret request {} cancelled before user submit

Error message

secret request {} cancelled before user submit

What it means

During MCP server install, secrets requested from the user are held in a process-local in-memory map keyed by SecretRef; await_fulfillment blocks on a oneshot receiver with a 5-minute budget (REQUEST_TIMEOUT = 300 s). This variant fires when the sender is dropped without a submit — per the source comment, usually because the GC sweep purged the entry (fulfilled-but-unused entries are purged after a 15-minute idle TTL) — and it is deliberately surfaced as a timeout-style error to keep callers simple. The entry is forgotten on this path, so the old SecretRef is dead.

Source

Thrown at src/openhuman/mcp/registry/setup.rs:155

    entry.value = Some(value);
    entry.last_touched = Instant::now();
    if let Some(tx) = entry.waiter.take() {
        let _ = tx.send(());
    }
    tracing::debug!("[mcp-setup] fulfilled ref={}", r.as_str());
    true
}

/// Block on a freshly-minted request with the global timeout. On timeout
/// the entry is removed and `Err(_)` is returned.
pub async fn await_fulfillment(r: &SecretRef, rx: oneshot::Receiver<()>) -> anyhow::Result<()> {
    match timeout(REQUEST_TIMEOUT, rx).await {
        Ok(Ok(())) => Ok(()),
        Ok(Err(_)) => {
            // Sender dropped — usually means GC purged the entry. Surface
            // as a timeout-style error to keep the caller simple.
            let _ = forget(r).await;
            anyhow::bail!("secret request {} cancelled before user submit", r.as_str())
        }
        Err(_) => {
            let _ = forget(r).await;
            anyhow::bail!(
                "secret request {} timed out after {}s",
                r.as_str(),
                REQUEST_TIMEOUT.as_secs()
            )
        }
    }
}

/// Resolve a `{KEY: SecretRef}` map into a `Vec<(KEY, VALUE)>`. Returns
/// `Err(_)` if any ref is unknown or not yet fulfilled — callers should
/// retry rather than partially-apply.
///
/// Touches the `last_touched` on every hit so iterative `test_connection`
/// calls reset the idle TTL.

View on GitHub (pinned to 7491200858)

Solutions

  1. Retry the install — a fresh request mints a new SecretRef and re-prompts the user.
  2. Avoid concurrent installs of the same server; let the first flow finish or cancel it cleanly first.
  3. If it recurs, check logs for GC/purge activity around the abort to confirm the entry was collected rather than a bug dropping senders.
Defensive patterns

Strategy: retry

Try / catch

Catch the anyhow error and branch on the message: `cancelled before user submit` → re-run the request flow with a fresh SecretRef (the old ref was forgotten); `timed out` → re-prompt only if a user is actually present. Never retry the dead ref itself.

Prevention

When it happens

Trigger: await_fulfillment is waiting while the pending entry is removed from the map: the idle-GC sweep collects it, a duplicate/superseding request replaces it, or the process is tearing the registry down. Distinct from the timeout path: the wait ended early because the channel closed, not because 300 s elapsed.

Common situations: User abandons the credential prompt long enough for GC; two installs of the same server racing so the second registration drops the first's sender; slow UI leaving prompts pending past the GC horizon.

Related errors


AI-assisted analysis of tinyhumansai/openhuman@7491200858 (2026-08-17). Data as JSON: /api/errors/5ca0aca00c8f2b00. Report an issue: GitHub.