Node Lifecycle
Stop a Chio service so admitted calls finish and record their receipts, then confirm what completed before you restart it.
On this page
A stopped node has closed its listener, let admitted calls finish inside a 25-second drain, flushed its receipt stores, and exited. You restart it once you know which calls completed and which the stop interrupted.
Every Chio HTTP service shares this path: trust-control, the remote MCP edge, the API-protect sidecar, the signals relay, proof-room, and chio proof serve. A supervisor ends it with SIGTERM. The commands below use the reference systemd units.
ringressKchio-sidecar :9090dhost app :8080draingrace
SIGTERM runs the drain sequence
The sidecar stops accepting connections, lets admitted requests finish and emit receipts, flushes the receipt log, drains the signing task with a final checkpoint where a boundary is crossed, and exits 0. A 30-second grace holds the 25-second drain. Graceful shutdown.
1. Stop new traffic, then signal the service
Remove the node from ingress first: the proxy, the scheduler, or the calling application. Then ask the supervisor for a normal stop. The reference unit delivers SIGTERM, which starts the drain.
systemctl stop chio-mcp-edge.service
systemctl is-active chio-mcp-edge.service
journalctl -u chio-mcp-edge.service -n 100 --no-pagerinactive … systemd[1]: Stopping chio-mcp-edge.service... … systemd[1]: chio-mcp-edge.service: Deactivated successfully. … systemd[1]: Stopped chio-mcp-edge.service.
You should see inactive, which is-active reports with a nonzero exit status, and a journal that ends in Stopped. Read the journal for a forced drain or a flush failure before you go on; step 3 shows both. The unit itself is in the reference service unit.
2. Give the drain room inside the platform’s grace
Four values decide whether an admitted call finishes. Each must fit inside the next:
request_timeout < drain_timeout <= TimeoutStopSec <= platform stop grace
20s < 25s <= 35s <= operator-setA request admitted shortly before the signal reaches its own 20-second timeout, returns 408, and completes before the 25-second drain ends. The first term applies only where the request timeout is on; three of the six serving sites run without one, and there the drain deadline is the first bound a long handler meets.
Chio reads neither TimeoutStopSec nor the platform grace. You set both, and a grace shorter than the drain truncates it: SIGKILL ends calls before their receipts are written. Set ECS stopTimeout or the Kubernetes and Azure Container Apps terminationGracePeriodSeconds at or above the unit’s 35 seconds. Cloud Run fixes its grace at 10 seconds, so the Cloud Run recipe lowers the hop ceiling instead.
The API-protect sidecar derives its drain from that ceiling: the upstream timeout plus a fixed 5-second margin. The default 20-second hop asks for 25 seconds, and --upstream-timeout-secs 60 asks for 65. On a platform with a fixed grace, lower the hop ceiling until the derived drain fits.
Read more in the derived drain and the timing constants.
3. Read how the drain ended before you restart
A drain ends clean or forced, and the flush that follows either succeeds or fails. Only the flush changes the exit status.
| Outcome | Condition | Exit | Before restart |
|---|---|---|---|
Clean | Every admitted call finished in the window | 0 | Confirm the process exited |
Forced | The deadline passed with a call in flight | 0 | Keep traffic stopped; reconcile in step 5 |
| Flush error | The post-drain flush returned an error | nonzero | Treat the stop as failed |
A forced drain logs one line at WARN and nothing else. Search for it, and alert on it, because the exit status is 0 either way:
journalctl -u chio-mcp-edge.service --no-pager | grep "drain deadline exceeded"… WARN drain deadline exceeded; force-closing remaining connections drain_timeout_ms=25000
You should see no match after a clean drain. A match means a call outran both its own ceiling and the window. The service dropped its listener and closed the remaining connections, but a handler task already running can still finish its work after that, so confirm that the process and every child it launched have exited.
4. Confirm every receipt store flushed
After either outcome, each service runs its flush hook. The MCP edge calls flush_receipt_writes_with_timeout on every live session kernel, bounded by the drain timeout. Trust-control, the API-protect sidecar, and the signals relay commit each receipt inside its handler, so their hooks have nothing left to write; proof-room and chio proof serve serve read-only content.
A failed flush means a receipt may not be durable. It becomes ServeError::Flush, a loud log line, and a nonzero exit. The sidecar also drains its signing task, with a final checkpoint where a boundary is crossed, before it exits.
A host that embeds the controller and supplies its own hook must bound the hook’s work and keep it safe beside a handler that is still writing. A completed drain does not hand the hook exclusive ownership of a shared store.
Read more in the shutdown controller.
5. Reconcile calls the stop interrupted
When an evaluation is dropped while the process is alive, a kernel guard records what happened. Which record it writes depends on one bit: whether dispatch had started.
- Before dispatch. No effect is possible, so the guard releases the budget hold, the invocation count, the runtime-admission reservations, and a delegated capability’s share of its parent budget. A clean release writes no receipt.
- After dispatch. The tool may already have acted, so the guard keeps the reservations as replay protection, flushes buffered child receipts, and signs a receipt with
Decision::Cancelledand terminal stateOperationTerminalState::Cancelled.
The guard runs only while the process is alive. SIGKILL runs no Rust destructors, so none of this cleanup happens and reservations stay held, which is why a grace shorter than the drain costs receipts as well as latency. A guard that cannot record logs a warn line carrying an audit_fault field. Find those lines and the cancelled receipts, then resolve each call whose effect is uncertain; Chio never dispatches it a second time.
Read more in Unknown outcomes and the drop guard reference.
A stop that goes wrong has one of three causes
The supervisor killed the process mid-drain
The journal shows the stop timing out and systemd killing the unit before the drain finished. The platform grace or TimeoutStopSec is shorter than the drain the service needs. Raise the grace above the drain, or lower the hop ceiling at the API-protect sidecar, and reconcile the calls that were in flight.
The service logged that it cannot install a SIGTERM handler
A handler that fails to install parks instead of firing, so the service keeps running and starts no drain; the supervisor’s SIGKILL at TimeoutStopSec ends it. The log line is cannot install SIGTERM handler; the Ctrl-C path still governs shutdown. Treat the stop as forced. On non-Unix targets, Ctrl-C is the only stop signal.
A second SIGTERM did not shorten the drain
The stop signal fires once, and the drain deadline is fixed at the first signal. A second SIGTERM or Ctrl-C is absorbed. To end a drain early, send SIGKILL, which is what the supervisor sends at TimeoutStopSec.
Shutdown controller, serving sites, and drop guard
Try TimeoutStopSec, run_until_drained, or POST_ADMISSION_DROP_REASON.
Reference systemd unit
The reference units make the stop signal explicit and hold the kill escalation until the bounded drain and the receipt-flush margin can finish.
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=35sKillMode=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 the instant the edge began draining. mixed signals the main process first and escalates the rest of the group to SIGKILL only after TimeoutStopSec. The edge is the only serving site that launches a child, so the other reference units omit this line.
Timing constants
| Value | Default | Set in | Why it sits where it does |
|---|---|---|---|
DEFAULT_ | 20s | hygiene.rs | Below the drain window, so a request admitted shortly before the signal reaches its own 408 and completes. |
DEFAULT_ | 25s | hygiene.rs | The wall-clock ceiling on the post-signal drain, passed to run_. |
TimeoutStopSec | 35s | the reference units | Drain deadline plus flush margin, so systemd escalates to SIGKILL after the bounded drain can finish. |
platform stop grace | operator-set | the deployment | ECS stopTimeout; Kubernetes and Azure Container Apps termination. A shorter grace preempts the drain. |
The flush hook runs after the drain, outside its timeout, so 25 seconds bounds the drain and not the whole stop. default_request_timeout_stays_within_drain_window checks the constant ordering, and slow_request_times_out_cleanly_inside_the_drain_window runs a slow handler through shutdown and checks its 408 and a clean drain.
Derived drain at the API-protect sidecar
chio api protect writes each receipt synchronously in the handler after the upstream hop returns, so its drain has to outlast the hop. It computes drain_timeout = upstream_request_timeout + PROXY_DRAIN_MARGIN, with the margin a 5-second constant in proxy/state.rs, and runs no request timeout: an outer timeout would drop the handler mid-hop and skip receipt finalization for a call that may already have reached the upstream.
DEFAULT_UPSTREAM_REQUEST_TIMEOUT is 20 seconds, and --upstream-timeout-secs overrides it; the margin is fixed. drain_window_always_outlasts_the_configured_upstream_timeout covers hop ceilings of 1, 20, 30, 60, and 300 seconds, and default_upstream_timeout_preserves_the_default_drain_window holds the default 20-second hop and 25-second drain.
On a platform with a fixed grace, the derived drain is the number that moves. The Cloud Run drain row puts that platform’s grace at a fixed 10 seconds with no service-level field to raise it, so SIGKILL arrives 10 seconds into a 25-second default drain and takes the last 15 seconds. Bring the hop ceiling down until the derived drain fits the grace.
Two-phase drain
A timer wrapped around the whole serve future would kill a healthy server drain_timeout after boot. run_until_drained takes a shutdown receiver instead and bounds only the in-flight drain that follows the signal.
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 so the forced path can drop it; a stack Pin<&mut _> only borrows. Dropping the owned future releases the accept loop and the listener before the flush hook runs. Axum serves each accepted connection on a detached task that a bounded drain can neither join nor cancel, so a handler may still be finishing.
Signal installation
Both branches of shutdown_signal log and park on pending() when a handler fails to install, so a failed install never fires a spurious shutdown at startup and never becomes a crash loop. If both installs fail, the process runs until the platform kills it, the behavior of a process with no shutdown wiring.
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;
}
}install() spawns one task that awaits shutdown_signal(), publishes true, and exits; nothing re-arms it. Once a handler is registered, Tokio never unregisters the underlying libc handler, so a second SIGTERM or Ctrl-C reaches no listening stream and neither terminates the process nor shortens the drain. On non-Unix targets the SIGTERM branch compiles to pending().
Background tasks
A cooperating task takes a subscribe() receiver and races its own work against it. Subscribing is opt-in: two background tasks cooperate this way, and the API-protect sidecar aborts its reserved-hold reaper instead. The canonical stop check is documented on subscribe:
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. Trust-control passes the receiver into each blocking cluster sync round, so a signal mid-round does not wait out every remaining peer, and the post-drain join gets 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 time plus the join budget never exceeds one drain window. The timeout abandons the sync task without cancelling its in-flight peer call; outbound sync is catch-up that resumes on the next boot. If the serve future ends without a signal, no elapsed time was recorded and the join takes the full window.
Shutdown controller
Embedding hosts can use ShutdownController::manual with their own signal source; shipped services install the OS-signal handler. Cooperating tasks subscribe to the same watch channel, and closing that channel also wakes the shutdown path. A stop signal is the first SIGTERM or Ctrl-C the process observes; the drain window arms when it fires; the flush hook runs after the drain; a drain ends clean or forced.
| Item | Kind | Role |
|---|---|---|
Shutdown | struct | Owns a tokio:: channel that fans one stop signal to the serve loop and every cooperating task. |
install() / manual() | constructors | install spawns the OS-signal task and needs a live Tokio runtime; manual has no signal task. |
signalled() | future | Passed to with_. Resolves on the signal or a dropped sender, so a lost controller cannot wedge a drain open. |
subscribe() | watch::Receiver<bool> | Handed to run_ and to every loop that stops with the server. |
trigger() | method | Requests the same drain programmatically, on the path a signal takes. |
is_shutdown() | bool | A point-in-time read of whether a drain was requested. |
run_ | async fn | Runs the serve future, bounds the post-signal drain, awaits the flush hook. Returns Result<DrainOutcome, ServeError>. |
DrainOutcome | enum | Clean or Forced. Every site maps it with .map(|_outcome| ()), so both exit 0. |
ServeError | enum | Io from the serve future, Flush from the hook. Each site maps both to a nonzero exit. |
on_drained is a Future<Output = Result<(), String>> that runs last, on both outcomes; its error becomes ServeError::Flush. The run_until_drained doc comment states the hook’s contract: it must be safe against a concurrent writer. Sites meet it by making every acknowledged receipt durable synchronously in the handler, or through a commit actor that acknowledges only after the write reaches the WAL, so a straggling handler never costs an acknowledged receipt. flush_error_surfaces_as_serve_error tests the error path.
Serving sites
Six sites share one implementation. Each keeps its own axum::serve expression, appends .with_graceful_shutdown(controller.signalled()), and returns through run_until_drained.
| Serving site | Source | Request timeout | Cooperating task | Flush hook |
|---|---|---|---|---|
Trust-control | chio- | Off: an HA budget authorize waits in a rollback-aware quorum wait with its own bound | Cluster sync loop, joined inside the drain window | Commits in handlers |
Remote MCP edge | chio- | Off: its routes hold Server-Sent Event streams open | Session reaper, left detached | Flushes each session kernel |
API-protect sidecar | chio- | Off; drain derived from the hop ceiling | None; its reaper is aborted | Commits in handlers |
Signals relay | chio- | Default 20s | None | Commits in handlers |
Proof-room | chio- | Default 20s | None | Read-only |
chio proof serve | chio- | Default 20s, on a current-thread runtime built with enable_all() | None | Read-only |
A blanket timeout at trust-control would drop an HA budget authorize after the local exposure write but before its rollback branch; at the MCP edge it would close a healthy idle stream with 408. Trust-control is the only site that joins a cooperating task after the drain.
Drop guard
PostAdmissionDropGuard (chio-kernel/src/kernel/kernel_drop_guard.rs) borrows the request, the capability, the pre-execution budget mutation, and any payment authorization, and owns the buffered child-request receipts that the nested-flow bridge pushes into it during dispatch, so a drop can still flush them. Three instances guard one evaluation, each armed across one await 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.
Before dispatch, handle_pre_dispatch_drop attempts four independent 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. The fourth is gated on budget_lease_acquired, because releasing a holder lease this evaluation never took would free another evaluation’s live share. Only a failing step produces a receipt. After dispatch, the guard flushes child receipts onto the append-only log one at a time, so one failed write does not discard those queued behind it, and records persistence failures as audit faults. Four constants name the cancellation reason:
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";| Constant | Set by | When it is the reason |
|---|---|---|
POST_ | new | The constructor default: the future went away while the invoke was in flight. |
POST_ | mark_ | An error after the tool completed but before its replay credentials committed; the guard stays armed so the error becomes a signed receipt. |
POST_ | persist_ | The tool server answered with a URL elicitation after dispatch. The guard disarms only once the append succeeds, so Drop retries a synchronous failure. |
PRE_ | record_ | A pre-dispatch drop whose unwind failed; the only pre-dispatch case that writes anything. |
The reason becomes Decision::Cancelled { reason } on a receipt that build_cancelled_response_with_metadata_and_payee_binding signs and records through the normal receipt path. A fault receipt adds a chio_runtime.pre_dispatch_cleanup_faults array naming the failing step, its redacted reason, and the hold or reservation ids it was unwinding, so a stuck hold is locatable from the receipt. 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.
Every path logs and continues, because a panic inside Drop during an unwind aborts the process. The audit_fault values are post_admission_drop_receipt_unrecorded, post_admission_drop_child_receipts_unrecorded, post_admission_drop_admission_unterminalized, and pre_dispatch_cleanup_fault_receipt_unrecorded. A receipt store already unreachable when the guard runs produces one of those lines and nothing else.