Chio/Docs
LOGIN · JOIN

PlatformLifecycle & Health

Node

Node Lifecycle

One controller, one stop signal, one bounded drain: how a Chio serve process stops without severing an in-flight governed action.

This page is authoritative for the drain numbers

Sidecar HTTP Service covers one deployed process end to end. Where it and this page describe the drain differently, in step order or in seconds, the constants below are the ones read from the crate.

What a stop signal costs if nothing handles it

A node is one OS process hosting one kernel. Deploys, scale events, and node rotations all end that process the same way: a stop signal, a grace window, then an unconditional kill. With no handler installed, SIGTERM takes its default disposition and the process dies at once, ignoring the whole grace window. On a process that mediates every action an agent takes, that severs in-flight evaluations and loses the receipt for work that already happened.

crates/protocol/chio-http-serve is the leaf crate that turns the signal into a bounded drain. It depends on no other chio-* crate and carries no protocol, capability, or authorization logic. Its remit is two things: when a process stops accepting work, and how much concurrent load one site admits before it denies more. The second half is the hygiene stack (408 request timeout, 503 load shed in front of a concurrency limit, 413 body cap, and a semaphore-backed connection cap in MaxConnListener), and this page covers only the one knob that interacts with the drain window, the request timeout. Backpressure & Limits owns the rest of that stack, including which serve sites turn the request timeout off and what they bound instead. Six axum::serve sites in the repo route their stop path through the crate.

Source

crates/protocol/chio-http-serve/src/signal.rs, .../src/drain.rs, .../src/hygiene.rs, .../src/tests.rs, .../ARCHITECTURE.md; the three reference units under docs/release/systemd/ and docs/release/chio-pheromone-relay/systemd/; docs/release/OPERATIONS_RUNBOOK.md; and the design record docs/architecture/reliability/RFC-0010-graceful-shutdown-server-hygiene.md, which is still marked Draft. Where the RFC and the code disagree, this page follows the code and says so. The RFC's draft text quotes a 30s request timeout; the constant is 20s.

Model

Four terms, used consistently on this page. A stop signal is the first SIGTERM or Ctrl-C the process observes. The drain window is the wall-clock bound that arms when that signal fires. A flush hook is the site-supplied future that runs after the drain. A drain ends clean or forced, and nothing else.

ItemKindRole
ShutdownControllerstructOwns a tokio::sync::watch channel that fans one stop signal to the serve loop and every cooperating task. Receivers are cheap to clone.
install() / manual()constructorsinstall spawns the OS-signal task and needs a live Tokio runtime. manual is the same controller with no signal task, for tests and embeddings with their own source.
signalled()futureWhat with_graceful_shutdown takes. Resolves on the stop signal, and also on a dropped sender, so a lost controller cannot wedge a drain open.
subscribe()watch::Receiver<bool>Handed to run_until_drained and to every background loop that must stop with the server.
trigger()methodRequests the same drain programmatically. A test or a fatal-error handler takes exactly the path a signal takes.
is_shutdown()boolWhether a drain has already been requested. A point-in-time read of the channel, not a subscription.
run_until_drainedasync fnRuns the serve future, bounds only the post-signal drain, awaits the flush hook. Returns Result<DrainOutcome, ServeError>.
DrainOutcomeenumClean (every in-flight request finished inside the window) or Forced (the deadline elapsed). All six sites discard the outcome with .map(|_outcome| ()), so both are exit 0.
ServeErrorenumIo from the serve future, Flush from the hook. Every site maps both into its own terminal error type rather than swallowing them; the crate documents both as non-zero process exits.

How a node stops

rendering
One stop signal, then an unbounded serve phase and a bounded drain phase. Both drain outcomes reach the flush hook and exit 0; the non-zero exits are a flush error and an I/O error from the serve future.
sourcecrates/protocol/chio-http-serve/src/drain.rs:55-110at fe56570

Only the post-signal drain is bounded

The defect this design exists to avoid is a naive tokio::time::timeout(drain_timeout, server) around the whole serve future, which kills a perfectly healthy server drain_timeout after boot. So run_until_drained takes a shutdown receiver rather than wrapping a timer around uptime, and runs two phases:

crates/protocol/chio-http-serve/src/drain.rsrust
let mut server = Box::pin(server.into_future());
let signalled = wait_for_shutdown(shutdown);

let outcome = tokio::select! {
    // Phase 1: serve until the server exits on its own or the signal fires.
    result = &mut server => match result {
        Ok(()) => DrainOutcome::Clean,
        Err(source) => return Err(ServeError::Io(source)),
    },
    () = signalled => {
        // Phase 2: graceful shutdown has stopped the accept loop; bound only
        // the remaining in-flight drain.
        match tokio::time::timeout(drain_timeout, &mut server).await {
            Ok(Ok(())) => DrainOutcome::Clean,
            Ok(Err(source)) => return Err(ServeError::Io(source)),
            Err(_elapsed) => {
                let drain_ms = u64::try_from(drain_timeout.as_millis()).unwrap_or(u64::MAX);
                warn!(
                    drain_timeout_ms = drain_ms,
                    "drain deadline exceeded; force-closing remaining connections"
                );
                DrainOutcome::Forced
            }
        }
    }
};

The serve future is box-pinned rather than stack-pinned for one reason: on the forced path it has to be dropped, and a stack Pin<&mut _> only borrows. Dropping the owned future releases the accept loop and the listener before the flush hook runs. What the drop does not do is stop the straggler: axum serves each accepted connection on a detached task that a bounded drain can neither join nor cancel, so the handler may still be finishing.

Clean and forced

OutcomeConditionConnectionsObservable
CleanEvery accepted connection completed inside the drain window, or the serve future ended on its own before any signal.All finished. The flush hook has the store to itself.No warning. Exit 0.
ForcedThe deadline elapsed with at least one connection still in flight.Serve future dropped, remaining connections force-closed. A detached handler task may still be running.warn! with drain_timeout_ms. Still exit 0.

A forced drain is not an error, and that is deliberate

Forced means a request outran both its own ceiling and the drain window. It is the backstop, not a failure signal, so it does not change the exit code. The one thing that does is a flush error. Every serve site drops the DrainOutcome on the floor, and no counter or metric records it, so the drain deadline exceeded log line at WARN is the only observable a forced drain produces. Alert on that, not on process exit.

The signal is fail-closed against its own installation

A handler that fails to install is the case that turns a shutdown mechanism into a crash loop. Both branches of shutdown_signal log loudly and park on pending() instead of resolving, so a failed install can never fire a spurious shutdown at startup:

crates/protocol/chio-http-serve/src/signal.rsrust
match signal(SignalKind::terminate()) {
    Ok(mut stream) => {
        stream.recv().await;
    }
    Err(source) => {
        error!(%source, "cannot install SIGTERM handler; the Ctrl-C path still governs shutdown");
        std::future::pending::<()>().await;
    }
}

If both installs fail the future never resolves, the process keeps running, and the platform escalates to an unconditional kill. That is the behavior of a process with no shutdown wiring at all, which is the point: the degraded mode is the old mode, logged, not a boot loop. On non-unix targets the SIGTERM branch is a compile-time pending(), so Ctrl-C is the only stop signal there.

The signal fires once, and a second one does nothing

install() spawns one task that awaits shutdown_signal(), publishes true, and exits. Nothing re-arms it, and the drain deadline is fixed at the first signal. Tokio documents that once a signal handler is registered with the process, the underlying libc handler is never unregistered, so a second SIGTERM or a second Ctrl-C is absorbed by that handler with no stream left listening: it will not terminate the process and it will not shorten the drain. An operator who needs the drain to end early has one option, SIGKILL, which is exactly what the supervisor sends at TimeoutStopSec.

Background tasks stop on the same channel

A drain that only stops the HTTP loop leaves timers and sync loops running past the deadline. A cooperating task takes a subscribe() receiver and races its own work against it. Subscribing is opt-in, not automatic: two background tasks in the repo cooperate this way, and api-protect's reserved-hold reaper is aborted instead. The canonical stop check is documented on subscribe itself:

crates/protocol/chio-http-serve/src/signal.rsrust
while !*rx.borrow_and_update() {
    if rx.changed().await.is_err() {
        break;
    }
}

The MCP edge session reaper (remote_mcp/http_service_auth.rs) races its interval sleep against rx.changed() so it stops promptly rather than after a full interval. Trust-control goes further: its cluster sync loop passes the same receiver into each blocking sync round, so a stop signal landing mid-round does not force it to wait out every remaining peer, and the post-drain join gets only drain_timeout minus the time already spent since the signal. cluster_join_never_extends_teardown_past_the_drain_window in service_runtime/init.rs asserts that elapsed plus join budget never exceeds one drain window, so teardown never stacks two waits inside a grace period sized for one. Only the outbound join is bounded that way: the timeout abandons the sync task rather than cancelling its in-flight peer call, which the code accepts because outbound sync is best-effort catch-up that resumes on the next boot. If the serve future ends without a signal ever arriving, no elapsed time was recorded and the join takes the full drain window.


The drain-window contract

This is the contract the Cloud Run, ECS, and Azure Container Apps recipes have to satisfy. Four numbers, in one order, and every recipe is judged against it:

text
request_timeout  <  drain_timeout  <=  TimeoutStopSec  <=  platform stop grace
      20s        <       25s       <=       35s        <=   operator-set

The first term is present only where the request timeout is on. Three of the six serve sites run without one, and there the drain deadline is the first bound a long handler meets. The last two terms are documented convention, not code: nothing in the crate reads TimeoutStopSec or the platform grace, so a misconfigured deployment fails silently as a truncated drain.

ValueDefaultSet inWhy it sits where it does
DEFAULT_REQUEST_TIMEOUT20shygiene.rsStrictly below the drain window so a request admitted just before the signal reaches its own 408 and completes, rather than being severed by the force-close.
DEFAULT_DRAIN_TIMEOUT25shygiene.rsThe wall-clock ceiling on the post-signal drain, passed to run_until_drained.
TimeoutStopSec35sthe reference unitsDrain deadline plus flush margin, so systemd escalates to SIGKILL strictly after the bounded drain can finish, not during it.
platform stop graceoperator-set, with a per-platform ceilingthe deploymentECS stopTimeout (30s default, 120s ceiling), Kubernetes and Azure Container Apps terminationGracePeriodSeconds. A shorter grace preempts the drain. Cloud Run has no equivalent field, which is why the Cloud Run recipe moves the hop ceiling instead.

The first inequality is not a convention, it is asserted. default_request_timeout_stays_within_drain_window fails the suite if the two constants ever invert, and slow_request_times_out_cleanly_inside_the_drain_window proves the behavior it buys: a handler parked for 30 seconds, stopped mid-request, returns 408 and the drain still reports Clean. A site that lengthens its request timeout must lengthen its drain to match.

The last two rows are the operator's half. The reference units make the stop signal explicit and hold the escalation:

docs/release/systemd/chio-mcp-edge.service (comments elided)toml
Restart=on-failure
RestartSec=5s
# Deliver SIGTERM first (systemd's default, made explicit) so the graceful drain
# runs, and hold the kill escalation until after the bounded drain plus the
# receipt-flush margin can finish. The drain deadline is 25s; the platform grace
# period must be at least TimeoutStopSec.
KillSignal=SIGTERM
KillMode=mixed
TimeoutStopSec=35s

KillMode=mixed is specific to the MCP edge. The upstream tool server launched after -- shares the unit's control group, and the default kill mode would signal it at the instant the edge began draining, tearing the upstream out from under in-flight calls. mixed signals the main process first and escalates the rest of the group to SIGKILL only after TimeoutStopSec. It is the one line of that unit the other reference units do not share, because the edge is the only serve site that launches a child.

One site derives its window instead of taking the default

chio api protect writes each receipt synchronously in the handler after the upstream hop returns, so its drain has to outlast the hop, not a constant. It computes drain_timeout = upstream_request_timeout + PROXY_DRAIN_MARGIN, where the margin is a 5s constant in proxy/state.rs, and runs no request timeout at all, because an outer timeout layer would drop the handler mid-hop and skip receipt finalization for a call that may already have reached the upstream. Two tests pin it: drain_window_always_outlasts_the_configured_upstream_timeout over hop ceilings of 1s, 20s, 30s, 60s, and 300s, and default_upstream_timeout_preserves_the_default_drain_window, which holds the default 20s hop and 25s drain pairing. DEFAULT_UPSTREAM_REQUEST_TIMEOUT is 20s and --upstream-timeout-secs overrides it. The margin is not itself configurable.

The derived drain, not the request timeout, is what has to fit the grace

On a platform with a fixed grace the derived drain is the number that moves, and it moves upward with every increase to the hop ceiling. --upstream-timeout-secs 60 asks for a 65-second drain. Even the default asks for 25 seconds, which the Cloud Run drain row reports that platform cannot hold: it puts Cloud Run's grace at a fixed 10 seconds with no service-level field to raise it, so SIGKILL arrives 10 seconds in and takes the last 15 seconds of a 25-second drain away. That figure comes from the Cloud Run page, not from the Chio repo. Bring the hop ceiling down until the derived drain fits the grace, never the other way around.

The flush-hook contract

on_drained is a Future<Output = Result<(), String>> the site supplies. It runs last, on both outcomes, and its error becomes ServeError::Flush and a non-zero exit. That is loud on purpose: a failed flush means a receipt may not be durable, and an operator has to see that rather than a silent success.

The obligation runs the other way too. On a clean drain the hook has the store to itself. On a forced drain a straggler survived the deadline on a detached task, so the hook must be safe against a concurrent writer. Sites keep that safe by making every acknowledged receipt durable synchronously in the handler, or through a commit actor that acknowledges only after the write reaches the WAL, never in the hook itself. A straggler can then never cost an acknowledged receipt. This is a contract stated in the run_until_drained doc comment, not a property the compiler or the suite enforces: nothing checks a hook's concurrency safety, and no site currently supplies a hook that could violate it.

Note what the drop on the forced path does and does not buy. drain.rs drops the serve future before awaiting the hook, which releases the accept loop and the listener. RFC-0010 reads that as closing the remaining connections before the flush so no handler can enqueue a write afterwards; drain.rs is narrower and states that the drop does not guarantee the handler has stopped. This page follows the crate.

Today every site passes a no-op

All six serve sites hand async { Ok::<(), String>(()) } to run_until_drained. Each site gives a reason at the call site, and they fall into three shapes: trust-control, api-protect, and the relay commit synchronously inside their handlers; proof-room and chio proof serve serve read-only assets; the MCP edge runs a commit actor that acknowledges an append only after the batch reaches the WAL, so finishing the request that wrote it is what makes it durable. The edge is the one site RFC-0010 designed a real hook for, and it does not have one: each session kernel runs behind its own worker thread reachable only through a message channel, so there is no in-process handle to flush after the drain. ChioKernel::flush_receipt_writes_with_timeout exists in chio-kernel, but no drain path calls it. The hook is a contract with a tested error path (flush_error_surfaces_as_serve_error) and no non-trivial implementation in tree.

What a dropped evaluation still writes

A forced drain can leave a handler running on a task nothing can join. If that handler was mediating a tool call, the evaluation future inside it is dropped mid-flight, and the question worth answering is not whether the task stopped but whether the governed action it was carrying left a record. That answer is not in chio-http-serve at all. It is a kernel-side guard that runs from Drop, which is what makes it reachable when a future is discarded rather than returned from.

PostAdmissionDropGuard (chio-kernel/src/kernel/kernel_drop_guard.rs) borrows the request, the capability, the pre-execution budget mutation, and any payment authorization, and it owns the buffered child-request receipts the nested-flow bridge pushes into it during dispatch. It owns them rather than the evaluation stack frame precisely so a drop can still flush them. Three instances exist along one evaluation, each armed across exactly one await that an abandoning caller could drop: the guard pipeline, the runtime-admission readiness wait, and the tool-server dispatch. The first two disarm on return. The third calls mark_dispatch_started before lending its receipt buffer to the bridge.

That one bit decides the whole behavior. Before dispatch no side effect is possible, so handle_pre_dispatch_drop reverses every pre-execution mutation in four independently attempted steps: the monetary hold with its payment release, an invocation-only budget increment, the runtime-admission reservations, and a delegated capability's share of its parent budget. Step four is gated on budget_lease_acquired, because releasing a holder lease this evaluation never took would free another evaluation's live share. A clean unwind records no receipt at all, which is the intended exit; only a failing step produces one.

After dispatch starts the guard is fail-closed. The tool server may already have performed the side effect, so releasing the runtime-admission reservations would license a replay. The guard instead flushes the buffered child receipts onto the append-only log one at a time, so a wedged writer that fails one does not discard the receipts queued behind it, retains the reservations, and always records a signed cancellation receipt. Four constants name the reason that receipt can carry:

crates/kernel/chio-kernel/src/kernel/kernel_drop_guard.rs14-20rust
const POST_ADMISSION_DROP_REASON: &str = "tool evaluation future dropped after admission";
const POST_DISPATCH_CREDENTIAL_COMMIT_FAILURE_REASON: &str =
    "dispatch credential commit failed after tool execution";
const POST_DISPATCH_URL_ELICITATION_REASON: &str =
    "tool server returned URL elicitation after dispatch; outcome is unknown";
const PRE_DISPATCH_CLEANUP_FAULT_REASON: &str =
    "tool evaluation future dropped before dispatch with cleanup fault";
ConstantSet byWhen it is the reason
POST_ADMISSION_DROP_REASONnewThe constructor default, so it is the reason on any post-dispatch drop nothing else reclassified. The future went away while the invoke was in flight.
POST_DISPATCH_CREDENTIAL_COMMIT_FAILURE_REASONmark_dispatch_credential_commit_failedAn ordinary error return after the tool completed but before its replay credentials committed. The guard stays armed so the error becomes a signed receipt instead of a bare error with no audit trail.
POST_DISPATCH_URL_ELICITATION_REASONpersist_url_elicitation_cancellationThe tool server answered with a URL elicitation after dispatch. Recorded on the normal return path, and the guard disarms only once the append succeeds, so a synchronous failure goes back to the caller and Drop retries it.
PRE_DISPATCH_CLEANUP_FAULT_REASONrecord_pre_dispatch_cleanup_fault_receiptA pre-dispatch drop whose unwind failed. The only pre-dispatch case that writes anything.

Whichever string applies becomes Decision::Cancelled { reason } on a receipt that build_cancelled_response_with_metadata_and_payee_binding signs and records through the same path a normal receipt takes, with terminal state OperationTerminalState::Cancelled. The fault receipt carries more: a chio_runtime.pre_dispatch_cleanup_faults array whose entries name the failing step, its redacted reason, and the hold or reservation ids that step was unwinding, so a stuck hold is locatable from the receipt rather than by cross-referencing the admission metadata. On a durable kernel the post-dispatch path also terminalizes the admission record, and terminalize_dispatch_committed_admission refuses when a durable outcome already exists, so it cannot overwrite a return that did complete.

Best effort, and only while the process is alive

Every one of these paths logs and continues rather than panicking, because a panic inside Drop during an unwind aborts the process. When the guard cannot do its job it emits a warn carrying an audit_fault field, one of post_admission_drop_receipt_unrecorded, post_admission_drop_child_receipts_unrecorded, post_admission_drop_admission_unterminalized, or pre_dispatch_cleanup_fault_receipt_unrecorded. A receipt store already unreachable when the guard runs produces one of those lines and nothing else. And all of it needs an unwind: Drop does not run when the kernel is torn out by SIGKILL. The drain window is what buys the time for it to run at all, which is why a grace period shorter than the drain costs receipts and not just latency.

Where it is wired

Six sites, one implementation. Each keeps its own axum::serve expression, appends .with_graceful_shutdown(controller.signalled()), and returns through run_until_drained.

Serve siteSourceRequest timeoutCooperating task
Trust-controlchio-control-plane/.../service_runtime/init.rsOff. An HA budget authorize parks in a rollback-aware quorum wait bounded on its own; a blanket timeout would drop the handler after the local exposure write but before the rollback branch.Cluster sync loop, spawned only in cluster mode and joined inside the same drain window.
Remote MCP edgechio-mcp-remote/src/remote_mcp/http_service.rsOff. Its routes hold Server-Sent Event streams open while a session waits, and a blanket timeout would close a healthy idle stream with 408.Session reaper.
API-protect proxychio-api-protect/src/proxy/state.rsOff, with the drain window derived from the hop ceiling instead.None. Its reaper is aborted after the serve returns.
Pheromone relaychio-pheromone-relay/src/service.rsDefault 20s.None.
Proof-roomchio-proof-room/src/server.rsDefault 20s.None.
chio proof servechio-cli/src/cli/dispatch/proof/serve.rsDefault 20s, on a current-thread runtime built with enable_all() so the signal driver exists.None.

Trust-control is the only site that joins a cooperating task after the drain. The MCP edge reaper observes the same signal but is left detached and ends with the process, and api-protect aborts its reaper rather than stopping it cooperatively.


Guarantees and limits

StatusClaimEvidence
ShippedOne controller fans a single stop signal to the serve loop and every subscribed task.signal.rs; six call sites, each calling ShutdownController::install.
TestedA request stopped mid-flight completes with 200 and its write lands before the flush hook runs.sigterm_drains_in_flight_request_before_exit, which asserts the write from inside the hook.
TestedA handler that never returns yields Forced at the deadline, and the flush hook still runs.drain_deadline_forces_close_and_still_flushes.
TestedA controller with no trigger does not resolve its shutdown future, and a flush error is never an Ok.manual_controller_does_not_signal_until_triggered (asserted over a 100ms window, so it bounds spurious resolution rather than proving it impossible), flush_error_surfaces_as_serve_error.
Not implementedFlipping readiness to NotReady at the start of a drain so a load balancer stops routing during the grace window.RFC-0010 section 6 step 2 specifies an optional readiness: Option<watch::Sender<ReadyState>> on the controller and defers the live flip to a separate finding (F59). Neither the field nor the type exists in the crate; a grep for readiness or ReadyState over chio-http-serve returns nothing.
Not implementedA post-drain flush of live session receipt stores at the MCP edge.The passthrough exists on ChioKernel; the edge passes a no-op hook and says why at the call site.
UnsupportedCancelling a straggler that outlived the drain deadline.axum dispatches each connection on a detached task. drain.rs states plainly that the drop does not guarantee the handler stopped.

Two further limits, stated outright. shutdown_signal registers SIGTERM and Ctrl-C only, so SIGHUP and SIGQUIT keep their default disposition and kill the process outright with no drain. That follows from the two branches in signal.rs and a grep of the crate; individual binaries were not each audited for a separate handler. And the crate's suite runs in-process against ephemeral listeners with trigger() standing in for the signal; a repo-wide grep finds no SIGTERM-under-load soak. The numbers here are bounds the code enforces, not measured drain times from a fleet.


Next Steps