Chio/Docs
LOGIN · JOIN

Backpressure & Limits

Size the load one node accepts, and know which refusals return an HTTP status and which return a signed kernel denial.

Concept chio@5f63e6e

On this page

A node bounds the work it accepts at the layer that owns each resource. The HTTP layer caps connections, requests in flight, handler time, and body size; the kernel caps resident memory and signs a deny receipt for each tool call it sheds.

Two configs carry the limits. ServeHygieneConfig in chio-http-serve sets the serving limits at each serve site, and MemoryBudgetConfig on the kernel sets the memory ceiling and the caps on its long-lived collections. Both are local to one serving process; Budgets Across Nodes covers spending authority shared across nodes.

Each limit refuses at the layer that owns it

A request meets the HTTP limits first and the kernel last. Each answers in the form its layer can produce: a wait, an HTTP status, or a signed deny receipt.

Serving limitsChoose a limit.
HTTP layer · no signing keykernel · signswaits503408413Overloadedsignsrrequestpermit2048in flight1024deadline20 sbody capNoneKρreceiptdtoolHTTP layerkernel · signswaits503408413Overloadedsignsrrequestpermitin flightdeadlinebody capKρreceiptdtool

Each limit refuses at its own layer

The connection permit, the in-flight ceiling, the request deadline, and the body cap belong to the HTTP layer and answer with a wait or a status code. Only the kernel’s memory shed signs a receipt. Fail-Closed Semantics.

One serving node’s limits in the order a request meets them, with the defaults from ServeHygieneConfig and MemoryBudgetConfig. Per-site overrides, the rate limiters, and the drain are left out.

The connection permit is backpressure: a connection over the cap waits in the OS backlog and receives no HTTP status. The other HTTP limits refuse with 503, 408, or 413. chio-http-serve depends on no chio-* crate and holds no signing key, so those refusals carry no Chio receipt.

A deadline can fire after a handler has started work. Read a 408 together with the kernel and tool evidence before concluding that nothing executed or that no other receipt exists.

Read more in Node Lifecycle, which covers the stop signal and the bounded drain.

One config sets the serving limits at every site

ServeHygieneConfig is a plain struct read at three call sites. apply_server_hygiene reads three of its fields; each site passes max_connections to MaxConnListener::new and drain_timeout to run_until_drained itself.

FieldDefaultAt the limitEnforced by
max_connections2048The accept loop waitsMaxConnListener, a semaphore
max_concurrent_requests1024503, empty body, no Retry-AfterLoadShedLayer over GlobalConcurrencyLimitLayer
request_timeout20s408TimeoutLayer::with_status_code
max_body_bytesNone413 at extractionDefaultBodyLimit
drain_timeout25sForce-close after the stop signalrun_until_drained

The body cap defaults to None so a site with a large upload limit on one route keeps that limit, and hygiene_defaults_are_conservative asserts the default. With no DefaultBodyLimit layer in scope, axum applies its own 2 MB limit to Bytes-based extractors. A handler that reads the raw body sets its own bound.

Every site wires the connection cap and the drain. Three sites switch off the blanket request timeout and bound the work that would outlive it: the chio api protect proxy bounds its upstream hop, trust-control bounds its quorum wait, and the MCP edge keeps idle event streams open. The per-site values are under Serve sites.

Connections wait for a permit and excess requests shed at once

axum::serve has no connection cap, so the cap is a listener adapter. Inside accept, the adapter takes a permit before it takes the socket:

crates/protocol/chio-http-serve/src/listener.rs61-77rust
async fn accept(&mut self) -> (Self::Io, Self::Addr) {
    // Acquire a permit BEFORE pulling the next connection off the OS queue,
    // so a saturated server stops draining the accept backlog rather than
    // accepting sockets it cannot serve. `acquire_owned` only errors when
    // the semaphore is closed, which this adapter never does; treat that
    // impossible case as "serve without a permit" so it can never wedge the
    // accept loop.
    let permit = Arc::clone(&self.permits).acquire_owned().await.ok();
    let (io, addr) = self.inner.accept().await;
    (
        PermittedIo {
            io,
            _permit: permit,
        },
        addr,
    )
}

The permit lasts for the connection’s whole lifetime and frees when the connection’s IO drops. The adapter never closes its semaphore, so a drain leaves a finishing connection its permit, and usize::MAX is a valid uncapped setting, clamped to Semaphore::MAX_PERMITS.

Wrapping the listener moves the site off axum’s built-in Connected<_> for SocketAddr impl, so a site that needs the peer address serves into_make_service_with_connect_info::<CappedPeerAddr>(), which is how the MCP edge keys its rate limiter by IP and the proxy runs its loopback checks behind the cap.

Requests on an accepted connection meet the in-flight ceiling. The load-shed layer sits outside the concurrency limit, so a request with no free slot fails at once with 503 and never parks behind admitted work. The 20s deadline stays below the 25s drain window, so a request admitted moments before a stop signal reaches its own 408 and the drain finishes cleanly. A site that lengthens the request timeout lengthens the drain to match.

Two limiters key load by source and bound their own tables

Two sites add a limiter keyed by who is asking. Each caps its key table as well as its rate, so a flood of new keys saturates the table instead of growing it.

  • MCP edge. McpRateLimiter keys requests by ip:<peer ip> from CappedPeerAddr and allows 600 requests per fixed 60-second window. The port is dropped, so every connection from one host, and everything behind one NAT egress, shares a bucket. At MCP_RATE_LIMIT_MAX_KEYS = 4_096 an unseen key first drops every window that is not current, and the request fails only if the table is still full. A refusal is a plain-text 429 with a retry-after header computed from the window edge; a poisoned mutex refuses with the same hint.
  • Trust control. FederationAdmissionRateLimiter buckets federation admission attempts by policy_id:subject_key for a federation policy whose record declares anti_sybil.rate_limit, with the window and count taken from that record. Its table is capped at MemoryBudgetConfig::admission_key_cap (4096). At the cap it prunes expired buckets and then refuses an unseen subject, which keeps a flood of throwaway subjects from resetting an active bucket’s window.

The MCP limiter is a route_layer on the MCP endpoint (POST, GET, DELETE). The admin routes and the OAuth metadata, authorization, token, and JWKS routes on the same listener take the connection cap, the concurrency limit, and the drain. Its window is fixed, per process, and in memory: a burst that straddles a window edge passes 1200 requests in close to one second, and a source rotating addresses can fill the 4096-key table and push new addresses to 429 until the window turns.

Read more in Remote MCP Edge, which runs its authentication gates after the limiter.

The kernel sheds on memory and signs the refusal

The HTTP limits count connections and requests. Memory has its own ceiling in the kernel, MemoryBudgetConfig::rss_soft_limit_bytes, sampled by a dedicated OS thread.

RssSamplerHandle reads /proc/self/statm every rss_sample_interval_secs (default 30), multiplies resident pages by the page size from sysconf(_SC_PAGESIZE), read once so a host with 64 KiB pages is measured correctly, and stores whether RSS exceeds the ceiling in one shared AtomicBool. It waits in 200ms slices so a stop is prompt, and joins on drop. On Linux the default ceiling is derived from the process’s cgroup memory limit; an embedder that builds KernelConfig sets its own value, roughly 85 to 90 percent of memory.max.

  • async_evaluation_core admits every top-level mediated call, and signs a deny receipt when it sheds.
  • nested_flow_evaluation admits sampling and elicitation-bearing calls, and signs a deny receipt when it sheds.
  • validate_non_tool_capability admits resource reads, prompt gets, and completions under the same flag.

Each path loads the flag right after the emergency-stop check, one relaxed atomic load. On a tool-call path record_overload_shed_deny_receipt builds a local, non-federated v1 deny under the guard name kernel.overload with the reason kernel shed load to stay within its memory budget (resource: Allocation), persists it through the emergency stop’s fail-closed path (the durable store first, the in-process mirror when the store is serving-closed), and only then returns Overloaded { resource: Allocation }, a retryable error. A failed persist is logged and the shed still holds.

The flag is one per process, with no per-tenant or per-session share, and it clears itself: under pressure every admission is refused, and a process back under the ceiling admits again. The ceiling is sampled, so a burst that allocates and finishes inside one interval goes unseen and the flag can trail by one interval. Off Linux, read_process_rss_bytes returns None and the ceiling stays inert.

Read more in Fail-Closed Semantics, which explains why an overload refusal carries a receipt.

Serve sites, caps, and tested behavior

Serve sites

Every site wires the connection cap and the drain. They differ on the blanket request timeout and the body cap, and each site states its choice.

Serve siteRequest timeoutBody capDrain
chio api protect proxy
Off
Each handler drains the raw body: 10 MiB on the proxied upstream route and on /v1/evaluate, 1 MiB on the other sidecar routes; over the limit is 400
Upstream hop ceiling plus 5s (25s at the 20s default)
Trust-control service
Off
1 MiB service-wide, raised on four routes carrying three ceilings
25s
Remote MCP edge
Off
8 MiB, enforced inside the handler
25s
Signals relay (chio pheromone relay)
20s
Router-wide from the relay profile: 256,000 bytes production, 1 MiB local-dev, plus per-peer frame caps
25s
Proof room
20s
32 MiB on the upload route; axum’s 2 MB default elsewhere
25s
chio proof serve
20s
Read-only static assets; axum’s 2 MB default
25s

The proxy writes its receipt in the handler after the upstream hop returns. An outer timeout would drop the handler mid-hop and skip receipt finalization for a call that may already have reached the upstream, so the configured upstream timeout bounds the hop and the drain sits a margin above it.

Trust-control’s budget authorize parks in a rollback-aware quorum wait with its own bound. A blanket timeout firing after the local exposure write would drop the handler before its rollback branch and leave a charged, leader-visible write the client saw fail. The MCP edge answers GET and POST with Server-Sent Event streams that stay open while a session waits for notifications, and a blanket 408 would close a healthy idle stream and push resumable clients into reconnect churn.

On these three sites the drain deadline is the last bound on an idle request that neither streams nor waits on a bounded operation, and it arms once a stop signal fires.

Load-shed layer order

In a ServiceBuilder the first layer listed is outermost, giving HandleError(LoadShed(ConcurrencyLimit(router))). Both inner services are fallible, so a HandleErrorLayer turns the shed error into a status and keeps the result an infallible Router:

crates/protocol/chio-http-serve/src/hygiene.rsrust
async fn shed_to_status(error: BoxError) -> StatusCode {
    if error.is::<tower::load_shed::error::Overloaded>() {
        StatusCode::SERVICE_UNAVAILABLE
    } else {
        StatusCode::INTERNAL_SERVER_ERROR
    }
}

acquire_owned errors only on a closed semaphore, and the listener treats that case as serving without a permit, so it can never wedge the accept loop. Embedders that serve the kernel behind tower add chio-tower, whose per-tenant concurrency limiter maps a kernel Overloaded to the service’s shed edge and refuses with TenantTableFull when its tenant table is full.

Body caps

DefaultBodyLimit inserts a limit into the request extensions that FromRequest extractors built on Bytes consume; it counts no bytes itself. A handler that drains the raw body brings its own bound. The MCP edge refuses an oversized Content-Length up front, then reads with an explicit 8 MiB ceiling, and answers a JSON-RPC-shaped 413 either way. The proxy reads without a Content-Length pre-check, and an over-limit read falls out of to_bytes as a plain 400 reading failed to read request body, the same answer a truncated upload gets.

Trust-control sets 1 MiB service-wide and overrides it on four routes: 384 MiB for internal admission authority, 128 MiB on each of the two receipt-append routes, and 64 MiB for evidence import. A stream receipt carries one 64-hex-character chunk digest per retained chunk, and DEFAULT_MAX_STREAM_CHUNKS is 1,048,576, so a full receipt reaches 64 MiB of digest text and the 128 MiB cap leaves room for the envelope. DefaultBodyLimitService inserts its limit unconditionally, so the innermost route-local layer writes last and wins.

Relay frame caps

The signals relay adds structural caps to its byte cap. A batch over max_batch_frames (128 in the production profile) is refused, a catch-up request over max_catchup_frames (256) is refused, and a catch-up response is bounded by max_catchup_bytes (1 MiB). The values are per peer, from the signed peer directory, and directory validation refuses a peer entry that declares zero or a value above the profile ceiling.

Operating system settings

The soft ceiling is the in-process counterpart of a cgroup hard limit. It works when the OS lets allocations fail early and locally. The four settings, in the order they take effect:

  1. memory.high and memory.max, for example 3.5G and 4G, with rss_soft_limit_bytes at about 85 to 90 percent of memory.max. The shed fires at or shortly before aggressive reclaim, well under the OOM boundary.
  2. vm.overcommit_memory = 2 with a tuned vm.overcommit_ratio. A large allocation fails at malloc or mmap time, so the try_reserve paths turn it into a typed deny instead of an abort.
  3. oom_score_adj: +500 for the untrusted sidecar, 0 for the edge, -500 for the mediator. If the OOM killer runs anyway, it takes the least-trusted process first and the kernel last.
  4. RLIMIT_AS with soft and hard set to memory.max, a coarse address-space backstop.

Under systemd these are MemoryHigh=, MemoryMax=, OOMScoreAdjust=, and LimitAS=, with the untrusted sidecar unit at OOMScoreAdjust=500. The reference units under docs/release/systemd/ set KillSignal=SIGTERM and TimeoutStopSec=35s for the drain and name the directives each deployment adds (MemoryMax, MemoryHigh, OOMScoreAdjust, LimitNOFILE, LimitAS), so both units share one OOM ordering. A drop-in for the mediator, with the example values above:

/etc/systemd/system/chio-trust-control.service.d/memory.confini
[Service]
MemoryHigh=3.5G
MemoryMax=4G
OOMScoreAdjust=-500
LimitAS=4G

Bounded collections

A node stays under its ceiling because its long-lived collections have capacity policies. crates/core/chio-bounded holds one invariant: no long-lived collection in a serving process without a capacity policy and a live size metric. It depends on no kernel or federation crate and exports three types. Ring<T> is a fixed-capacity append-only buffer that evicts oldest-first and hands the evicted item back. BoundedMap<K, V> is a capacity-bounded cache with an optional idle sweep that runs inline every 256 inserts. SizeGauge is a cloneable atomic handle anything can read and only the crate can write. On both collections capacity == 0 is a disabled mode that stores nothing and hands the item straight back.

BoundedMap evicts by oldest insert: a re-insert moves a key to newest, and get refreshes only the idle timestamp, so a key read constantly and never rewritten can be evicted ahead of a colder one. The idle sweep runs on inserts, so a map that stops taking inserts holds its entries past their TTL until the next write.

In the kernel these are a receipt mirror capped at 4096, a child receipt mirror at the same cap, and two federation artifact caches at 8192 with a one-hour idle TTL. Receipt lookups read the durable store first, so the mirror is a cache. An evicted receipt resolves when the store can point-load it through ReceiptStore::load_chio_receipt; with an append-only or remote store that cannot, a dependent call-chain claim fails closed with a deny, and a test asserts it. Each structure reports chio_mem_entries and chio_mem_capacity under its structure label, the process reports chio_process_rss_bytes and chio_process_rss_soft_limit_bytes, and chio_overload_total counts sheds by resource.

Memory budget caps

MemoryBudgetConfig::defaults() carries five more caps. Two of them refuse rather than evict.

FieldDefaultBounds
velocity_bucket_cap
65,536
The velocity and agent-velocity guards’ token-bucket tables
admission_key_cap
4096
Trust-control’s federation admission limiter; refuses an unseen subject at the cap
journal_entry_cap
4096
Each session journal’s entry and tool-sequence rings
journal_tool_counts_cap
4096
Distinct tool names in a session’s cumulative counts; past the cap an unseen name is dropped, and a required-predecessor check then refuses
max_stream_chunks
1,048,576
Chunks retained from one streamed result; truncates at finalize, 0 disables

Streamed results

The kernel bounds an oversized stream at finalize: it truncates the retained chunks and marks the receipt incomplete. A hard refusal at the dispatch seam would have to unwind the monetary charge for a stream that already executed. invoke_stream returns a materialized result, so a connector bounds its own accumulation with the public helpers push_chunk_bounded and enforce_stream_byte_limit; a connector that skips them is bounded by the process ceiling.

Tested behavior

  • request_timeout_returns_408 and body_limit_returns_413: each request control returns its status at its boundary.
  • load_shed_returns_503_over_concurrency_limit: a request over the concurrency limit sheds with 503 while the admitted request still returns 200.
  • max_conn_listener_caps_concurrent_connections: a second connection over the cap is established at TCP level but never accepted until a permit frees.
  • slow_request_times_out_cleanly_inside_the_drain_window: a 30-second handler under a 100ms timeout inside a 600ms drain returns 408 and the drain finishes clean. default_request_timeout_stays_within_drain_window keeps the two defaults ordered.
  • rss_shed_persists_signed_overload_deny_receipt: a top-level shed records exactly one signed deny receipt naming the resource and returns Overloaded. nested_admission_denied_while_rss_shedding finds a matching signed receipt for the nested flow and verifies it.
  • mcp_rate_limiter_caps_session_window and mcp_rate_limiter_caps_tracked_keys: the MCP limiter caps requests per window and tracked keys, and reports the seconds left in the window.
  • kernel_bounded_registry_lists_every_labelled_structure: bounded_structure_gauges() enumerates every labeled kernel structure.

Next: drain a node without dropping work