Chio/Docs
LOGIN · JOIN

Session Lifecycle

Keep hosted MCP conversations alive through idle expiry, draining, and restart, and keep an ended session ended.

Concept chio@5f63e6e

On this page

An MCP client can lose its node to a restart and keep its session id. A session that ended before the restart stays ended, and only its own caller learns how it ended.

chio mcp serve-http keeps a ledger of live sessions and of tombstones for ended ones, and writes both to the file named by --session-db. The journal the session-aware guards read is separate and starts empty after a restart.

A restarted edge resumes qualifying sessions and refuses ended ones

At boot the edge reads tombstones first, then the resumable rows. A saved session is input to the new runtime: it comes back only when its record qualifies under the current configuration.

Session restoreChoose a case.
session databasenew runtimeintegrityisolationprincipalauthpeerrresumable rowttombstoneKkernel + workerκstored grantsaon reuse · servedsession databasenew runtimeresume integrity taghosted isolation modefederated principalauth contract fingerprintpeer capabilitiesrrowttombstoneKκstored grantsaservingon reuse · served

A qualifying Ready session comes back under its own id

The row has no tombstone, passes all five checks, and rebuilds a kernel and edge worker. The policy fingerprint is unchanged and every stored capability is current, trusted, and bound to the restored agent key, so the session keeps its capabilities and serves authenticated calls. Restore checks.

One session's records at boot on a hosted MCP edge with a session database and resume integrity material configured. The failing check in the refused case is one of five; malformed rows and the guard journal, which restarts empty, are left out.

A parseable tombstone for the same id drops the row before any check runs. A row that fails a check is deleted and boot moves on, so one bad record never stops the node. A row that qualifies rebuilds a kernel and edge worker, with either its own upstream or the configured shared upstream owner.

The policy fingerprint decides what the rebuilt session may do. Stored capabilities carry over only when the policy is unchanged and every token is still current, trusted, and bound to the restored agent key; otherwise the session receives the current policy's defaults. Boot ends with expiry cleanup, so a session whose deadline passed during the outage is tombstoned before the first request.

Read more in Restore checks.

Six states, and only Ready serves

A session enters the ledger only after its initialize request succeeds. From Ready it can drain, or end in one of three terminal states that each say who ended it.

StateEntered byOn reuseResumable
Initializingconstruction, before initialize succeeds409no
Readya successful initialize responseservedyes
DrainingPOST /admin/sessions/{id}/drain409no
Deletedclient DELETE /mcp, or the drain deadline410no
Expiredthe reaper, once idle_expires_at <= now410no
ClosedPOST /admin/sessions/{id}/shutdown410no

"On reuse" is what a client gets for the session id on POST /mcp or GET /mcp. DELETE /mcp skips the live check: it validates the auth context and terminalizes whatever active session it finds, so a draining session answers 204 there, and only a tombstoned id answers 410.

Read more in Remote MCP Edge, which owns this ledger.

A session expires the moment it is due, whichever path looks first

Every request that reaches a Ready session moves its idle deadline forward, by 15 minutes by default. A background reaper sweeps every 250 ms, and the same cleanup also runs on every session lookup, on the admin routes, and once at boot.

A request therefore never sees a session that was due for expiry when it arrived, whatever the sweep interval. Drain is a retirement: it deletes the resumable row first, then moves the session to Draining, where requests get 409 until the reaper deletes it five seconds later. A drain that reports failure has not started, and the session keeps serving.

Read more in Node Lifecycle, for the process-level drain that stops the reaper.

An ended session writes its tombstone before it gives up its row

One function moves a session to Deleted, Expired, or Closed, and it runs once per session. Its order is the durability argument:

  1. Write the tombstone. Once it lands, the restore path is guarded, whatever happens next.
  2. Delete the resumable row. If this fails after the tombstone landed, the edge warns and continues; the tombstone outranks the stale row at boot.
  3. Refuse if both failed. The caller answers 500 and the session stays live, because a surviving row with no tombstone could bring it back.
  4. Move the session. Only then does the in-memory state change and the record join the terminal map.

The tombstone keeps the session's auth context and the id, issuer, and subject of each capability it held. Reusing a tombstoned id revalidates the caller first, so a different principal gets 403. Ending a session leaves its capabilities valid until they expire or an operator revokes them through the trust route, within the 30-minute default tombstone retention.

Each session belongs to one process and one file

The ledger is per process and session ids carry no routing, so two hosted edges behind one load balancer need sticky routing to reach each other's sessions. Give each session file one serving process on a local filesystem. When the node runs admission locally, the durable-admission database beside the session file refuses a second serving owner.

Resuming live sessions takes secret material as well as a path. Without --session-db, the ledger stays in memory. With a database and no integrity seed, only tombstones persist. With both, Ready sessions write resumable records and boot validates them.

Runtime persistence favors availability: a failed resumable write warns and the session keeps serving, so a full disk degrades to memory-only sessions. A failed read at boot stops startup.

Session ledger reference

Ledger and tables

RemoteSessionLedger holds two async-locked maps and a path. Live sessions sit in active as Arc<RemoteSession>, each owning a worker thread, a kernel, and, unless the server runs with --shared-hosted-owner, its own upstream MCP child process. Ended sessions sit in terminal as Arc<RemoteSessionDiagnosticRecord>: a tombstone with no worker and no kernel. lookup checks active first and falls back to terminal.

crates/protocol/chio-mcp-remote/src/remote_mcp/session_store.rsrust
pub(super) const SESSION_ACTIVE_TABLE: &str = "remote_active_sessions";
pub(super) const SESSION_TOMBSTONE_TABLE: &str = "remote_session_tombstones";

// ...

pub(super) fn open_session_state_db(path: &FsPath) -> Result<Connection, CliError> {
    let conn = Connection::open(path)?;
    conn.execute_batch(&format!(
        "CREATE TABLE IF NOT EXISTS {active_table} (
            session_id TEXT PRIMARY KEY NOT NULL,
            updated_at INTEGER NOT NULL,
            record_json TEXT NOT NULL
        );
        CREATE TABLE IF NOT EXISTS {table} (
            session_id TEXT PRIMARY KEY NOT NULL,
            terminal_at INTEGER NOT NULL,
            record_json TEXT NOT NULL
        );",
        active_table = SESSION_ACTIVE_TABLE,
        table = SESSION_TOMBSTONE_TABLE,
    ))?;
    Ok(conn)
}

Each persist, delete, and purge opens the file again. When the node runs admission locally, the durable-admission database is derived as <session-db>.admission, opened through the provisioned path, and checked at startup against the receipt, revocation, authority, and budget paths so no two of them alias one file.

State names

Operator JSON reports states lowercase through RemoteSessionState::as_str; the persisted record_json uses the serde name, so a row read with sqlite3 says "Ready" where the API says "ready". The live check on reuse is validate_session_lifecycle and the tombstone check is terminal_session_response. Initializing answers 409 defensively, because no lookup returns a session before initialize succeeds. mcp_serve_http_admin_drain_shutdown_and_delete_have_distinct_terminal_states asserts the three terminal states end to end.

Persistence configurations

  • No --session-db. The ledger stays in memory and no rows are restored.
  • Session database without resume integrity material. Terminal tombstones persist; live sessions have no resumable record, because resume_record returns None without a seed.
  • Session database with resume integrity material. Eligible Ready sessions write resumable records, and boot validates the record, the current auth fingerprint, and the lifecycle conditions.

derive_resume_record_integrity_seed takes the seed from the authority database, the first configured seed file, the control token, or the auth token, in that order. The restart tests configure --authority-seed-file.

Timing settings

crates/protocol/chio-mcp-remote/src/remote_mcp/session_core.rsrust
const DEFAULT_SESSION_IDLE_EXPIRY_MILLIS: u64 = 15 * 60 * 1000;
const DEFAULT_SESSION_DRAIN_GRACE_MILLIS: u64 = 5 * 1000;
const DEFAULT_SESSION_REAPER_INTERVAL_MILLIS: u64 = 250;
const DEFAULT_SESSION_TOMBSTONE_RETENTION_MILLIS: u64 = 30 * 60 * 1000;
const SESSION_TOUCH_PERSIST_INTERVAL_MILLIS: u64 = 5_000;
FieldEnvironment variableDefaultGoverns
idle_expiry_millis
CHIO_MCP_SESSION_IDLE_EXPIRY_MILLIS
900000
Added to last_seen_at on every ready-state touch.
drain_grace_millis
CHIO_MCP_SESSION_DRAIN_GRACE_MILLIS
5000
How long a drained session stays Draining before the reaper deletes it.
reaper_interval_millis
CHIO_MCP_SESSION_REAPER_INTERVAL_MILLIS
250
Sleep between background sweeps; expiry is also evaluated on demand.
tombstone_retention_millis
CHIO_MCP_SESSION_TOMBSTONE_RETENTION_MILLIS
1800000
How long a terminal record is kept before purge, in memory and on disk.

read_env_u64 keeps a value only when it parses and is greater than zero, so an unset, unparseable, or zero variable takes the default. The four values are read once at startup and have no CLI flag or policy field. GET /admin/health reports all four, which confirms a tuning landed; GET /admin/sessions reports the first three.

Ready and touch

spawn_session builds a session before initialize is answered, and it reaches the ledger only on a successful response. mark_ready records the negotiated protocol version, the initialize params, and the peer capabilities, stamps idle_expires_at = now + idle_expiry_millis, and persists a resumable row when the database and integrity material are configured; a write error is logged and initialize still succeeds. Insertion into the active map follows, so anything that fails before then leaves no ledger entry and no row.

Every later POST and GET that resolves to a live session calls touch, which restamps last_seen_at and the idle deadline in Ready only. touch rewrites the saved row only when SESSION_TOUCH_PERSIST_INTERVAL_MILLIS has passed since the previous request. Requests arriving under five seconds apart extend the live deadline without refreshing the saved one, so a session active before a restart can be reaped from its stale saved deadline; a failed write also leaves the previous record.

Drain

The drain route deletes the resumable row, then moves the session to Draining and sets drain_deadline_at = now + drain_grace_millis. Deleting the row first means a crash mid-drain leaves nothing that could resurrect a session the operator began retiring. If that delete fails, begin_draining returns the error without changing state, the route answers 500, and the session keeps serving.

Reaper and cleanup

session_reaper_loop is one spawned task that races its interval against the process shutdown watch with tokio::select!, so a node drain stops it at once. cleanup_due_sessions also runs on every session resolution, on GET /admin/health, on GET /admin/sessions, and once at boot after restore, and it ends in purge_old_terminal_records.

crates/protocol/chio-mcp-remote/src/remote_mcp/session_core/ledger.rs97-138rust
match snapshot.state {
    RemoteSessionState::Ready if snapshot.idle_expires_at <= now => {
        match self.mark_expired(&session).await {
            Ok(()) => {
                self.active.lock().await.remove(&session.session_id);
            }
            Err(error) => {
                warn!(
                    session_id = %session.session_id,
                    error = %error,
                    "failed to expire MCP session without resumable-state risk"
                );
            }
        }
    }
    RemoteSessionState::Ready => {}
    RemoteSessionState::Draining => {
        if snapshot
            .drain_deadline_at
            .is_some_and(|deadline| deadline <= now)
        {
            match self.mark_deleted(&session).await {
                Ok(()) => {
                    self.active.lock().await.remove(&session.session_id);
                }
                Err(error) => {
                    warn!(
                        session_id = %session.session_id,
                        error = %error,
                        "failed to delete drained MCP session without resumable-state risk"
                    );
                }
            }
        }
    }
    RemoteSessionState::Initializing
    | RemoteSessionState::Deleted
    | RemoteSessionState::Expired => {}
    RemoteSessionState::Closed => {
        self.active.lock().await.remove(&session.session_id);
    }
}

A session leaves the active map only when its terminal transition succeeded, and the transition fails only when both the tombstone write and the row delete failed. A tombstone write that fails alone, with the row delete succeeding, still returns Ok: the session leaves this process with nothing on disk to answer 410, and after a restart its id answers 404.

Terminal transition

transition_to_terminal is the single path to Deleted, Expired, and Closed and rejects the other three states as arguments. It returns early when the session is already terminal, so a reaper expiry racing an admin shutdown keeps the first tombstone's state and its terminal_at, and retention does not stretch.

The tombstone stores one RemoteSessionCapability per grant (id, issuer_public_key, subject_public_key), the ownership snapshot, and the final lifecycle. POST /admin/sessions/{id}/trust uses those ids to revoke a dead session's grants. It needs somewhere to write the revocation: without a control plane, a durable-admission runtime, or --revocation-db it answers 409 remote trust admin requires durable revocation state. Once the tombstone is purged the id is unknown and the route answers 404, so revoking by session id works only within the retention window. The Revocation Store holds the list.

Terminalizing drops the ledger's reference to the session; the worker thread and any upstream child exit when the last reference drops, so an attached stream can briefly outlive the tombstone.

Purge

In memory, retain drops any record past the retention window. On disk the delete carries a NOT EXISTS subquery against the active table, so a tombstone whose id still has an active row is kept past retention. A failed purge warns and the sweep continues. Past retention both records are gone and the id answers 404.

Restore checks

The ledger loads the tombstone table first and skips, with a log line, rows that do not parse and rows whose payload id disagrees with the primary key. load_active_session_records then reads the active table with the tombstone ids in hand and drops any id with a parseable tombstone, any row whose payload disagrees with its key, and any row that does not parse, deleting each from disk before the process serves. A malformed tombstone counts as absent, as load_active_session_records_keeps_active_row_when_matching_tombstone_is_malformed pins.

restore_session refuses a surviving row for any of five reasons; a refusal logs, deletes the row, and moves on:

  • Resume integrity tag. The record has no tag, the server has no local secret material to derive one, or the recomputed tag differs. The tag is a SHA-256 over a domain-separated label, a seed, and the canonical JSON of an envelope covering identity, both fingerprints, lifecycle, initialize params, and issued capabilities: a secret-prefixed hash, distinct from an HMAC. peer_capabilities sits outside the envelope.
  • Hosted isolation mode. The record was created under a different --shared-hosted-owner setting.
  • Federated principal. With identity federation configured, re-deriving the agent keypair from the stored principal fails to reproduce the stored agent_id.
  • Auth contract fingerprint. The stored fingerprint is absent or differs. It covers the auth mode, issuer, audience, required scopes, provider profile, the verification key identity or static-token digest, discovery and introspection URLs, and a hash of the enterprise provider file.
  • Peer capabilities. Re-parsing the stored initialize params fails to reproduce the stored peer_capabilities.

A policy change keeps the session and replaces its capabilities:

crates/protocol/chio-mcp-remote/src/remote_mcp/session_core/factory.rsrust
let issued_capabilities = match record.policy_fingerprint.as_deref() {
    Some(stored)
        if stored == policy_fingerprint
            && stored_capabilities_are_current(&record.issued_capabilities)
            && stored_capability_issuers_are_trusted(&kernel, &record.issued_capabilities)
            && record
                .issued_capabilities
                .iter()
                .all(|capability| capability.subject == agent_public_key) =>
    {
        record.issued_capabilities.clone()
    }
    _ => issue_default_capabilities(&kernel, &agent_public_key, &default_capabilities)?,
};

Stored capabilities carry over only when the policy fingerprint is unchanged, every token is inside its expiry, every issuer is trusted by the rebuilt kernel, and every subject is the restored agent key. Fail any one and the session comes back with freshly issued defaults from the current policy: mcp_serve_http_ready_sessions_reissue_capabilities_after_policy_tightening restarts a node with a narrower tool list and the same session id is denied a tool it held before. Changing the auth mode drops the session instead; mcp_serve_http_drops_restored_sessions_when_auth_mode_changes swaps a static bearer for JWT and the old id answers 404.

idle_expires_at is absolute wall-clock milliseconds, so the closing cleanup_due_sessions expires and tombstones a row whose deadline passed during the outage. Restore runs first, so a node back from a long outage pays full session construction, worker thread and any per-session upstream included, for rows it then reaps. The listening socket is bound before restore begins, so that cost appears as connection latency.

Restart tests

  • load_active_session_records_skips_terminal_tombstoned_rows: a parseable tombstone with a matching payload id outranks the resumable row.
  • mcp_serve_http_terminal_tombstones_survive_restart_and_block_reuse: deleted, expired, and shut-down sessions, created under separate server generations, answer 410 after a fourth start against one session database.
  • mcp_serve_http_ready_sessions_survive_restart_and_resume_authenticated_calls: a ready session keeps serving authenticated calls under the same id.
  • mcp_serve_http_idle_expiry_reaps_sessions_and_blocks_reuse and hosted_mcp_sessions_expire_under_ttl_and_cannot_be_reused: an idle session reaches expired, reports resumable: false, and answers 410 on POST and GET.

Only identity, capabilities, protocol version, and the initialize contract are persisted in RemoteSessionResumeRecord. The session-aware guard journal lives in process memory and starts empty after a restart.

Next: the edge that owns these sessions