rescriptum
The request lifecycle
Development
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:
| Guard | Covers |
|---|---|
http1::Builder::header_read_timeout | a client that opens a connection and dribbles headers |
tokio::time::timeout around the whole connection | everything 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_timeoutrequires.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:
GET /health→200 OK. Before authentication, before anything, so monitoring never goes dark.- The answer token, when
RESCRIPTUM_ANSWER_TOKENis 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. - Method — anything but
GETorPOSTis405. Content-Length— an aberrant declared size is refused from the header, rather than by lettingLimitedtrip after buffering a megabyte.- The body, through
Limited::new(…, MAX_BODY). A length-limit error becomes413, anything else400.
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():
- asks the store for its
version()— onestatfor files, an atomic load for SQLite; - reuses the cached
Listing, or takes a freshsnapshot()and rebuilds it; - picks the best machine document and the best group (scoring);
- resolves
extends, within one format; - merges group chain → machine document;
- fills
{{ placeholders }}; - strips the control keys;
- renders.
5. Response
| Outcome | Response |
|---|---|
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.