← Back to site Loading…

rescriptum

The request lifecycle

Development

Generated on August 30, 2026

The request lifecycle

The request lifecycle

sequenceDiagram
  participant C as Installer
  participant L as accept loop
  participant T as tokio task
  participant B as blocking pool

  C->>L: TCP connect
  L->>L: try_acquire_owned()
  alt no permit
    L-->>C: 503, close
  else
    L->>T: spawn(connection)
    Note over T: whole-connection timeout starts
    C->>T: request headers
    Note over T: header_read_timeout
    T->>T: /health? token? method? Content-Length?
    C->>T: body (capped at 1 MB)
    T->>B: spawn_blocking(Facts + resolve)
    B->>B: version() → cached listing or snapshot()
    B->>B: match · layer · merge · fill · strip
    B-->>T: Resolution | None | Err
    T-->>C: 200 + document · 404 · 500
    T->>T: log one line, capture if enabled
  end

1. Accept

serve() loops on listener.accept() inside a tokio::select! with the shutdown signal (SIGTERM, which DSM’s task scheduler sends, or Ctrl-C).

An accept failure — file-descriptor exhaustion, say — logs and continues. Ending the loop there would turn a transient resource problem into an outage.

A permit is taken from the semaphore before spawning. Without one, shed() writes a 503 and closes — answering honestly rather than dropping silently, so the client knows to retry rather than guessing.

2. The connection

Two timeouts, and neither is redundant:

GuardCovers
http1::Builder::header_read_timeouta client that opens a connection and dribbles headers
tokio::time::timeout around the whole connectioneverything after the headers

hyper has no body-read timeout. Without the second guard, a client that promises a body in its Content-Length and then sends nothing would park a connection indefinitely — inside a permit, so it costs a slot as well as memory.

hyper panics if a timeout is set without a timer. header_read_timeout requires .timer(TokioTimer::new()). Omit it and every connection panics at runtime — it does not fail to compile. See traps.

3. Routing

One if on method and path, in this order:

  1. GET /health200 OK. Before authentication, before anything, so monitoring never goes dark.
  2. The answer token, when RESCRIPTUM_ANSWER_TOKEN is set. Compared without an early return, so a wrong token cannot be recovered a byte at a time by whoever is timing the responses. Logged, never rate-limited.
  3. Method — anything but GET or POST is 405.
  4. Content-Length — an aberrant declared size is refused from the header, rather than by letting Limited trip after buffering a megabyte.
  5. The body, through Limited::new(…, MAX_BODY). A length-limit error becomes 413, anything else 400.

There is no path routing beyond that: POST and GET are answered on any path, because the URL is baked into an ISO. The path is not ignored — it becomes facts — it just does not decide whether to answer.

4. Resolution, off the async worker

let picked = tokio::task::spawn_blocking(move || {
    let facts = Facts::from_request(Some(&request_path), query.as_deref(), &body);
    answers.resolve(&facts)
}).await;

Both halves belong off the async worker: building facts is CPU work on an arbitrary-sized payload, and the lookup is blocking IO. Doing either on a runtime thread stalls every other connection that thread is driving.

Inside, resolve():

  1. asks the store for its version() — one stat for files, an atomic load for SQLite;
  2. reuses the cached Listing, or takes a fresh snapshot() and rebuilds it;
  3. picks the best machine document and the best group (scoring);
  4. resolves extends, within one format;
  5. merges group chain → machine document;
  6. fills {{ placeholders }};
  7. strips the control keys;
  8. renders.

5. Response

OutcomeResponse
Ok(Ok(Some(resolution)))200, the document, Content-Type from its format, Connection: close
Ok(Ok(None))404 no answer file applies
Ok(Err(e))500, with the reason on the log line
Err(join_error)500 answer lookup panicked — it cannot take the server with it, but it must not pass silently either

Then exactly one log line, and a capture if one is configured. The body was cloned before resolution took it, and only when capturing is on.

The admin listener

A separate TcpListener, a separate serve() task, spawned only when RESCRIPTUM_ADMIN_ADDR is set — and only after Config::validate has confirmed the store is SQLite and the token is long enough. Its own pipeline is in the admin API internals.

Shutdown

SIGTERM or Ctrl-C ends the accept loop and returns from serve(). In-flight connections are not drained: there is no state to lose, the client retries, and a provisioning server that refuses to stop is worse than one that drops a request.