Connection lifecycle
How one TCP connection moves through Viltrum’s HTTP/1.1 engine (engine/).
accept
→ read headers (+ body if Content-Length)
→ parse Request
→ Host check (HTTP/1.1)
→ upgrade route match? ──yes──▶ Conn ownership → UpgradeFn → close (no keep-alive loop)
→ handler
→ write Response
→ keep-alive idle wait ─┐
→ close / shutdown │
↑___________________┘ (next request on same conn)
Upgrade/hijack details: upgrade.md.
Accept
listen_and_serve_opt binds TCP and accepts in a loop. Each accepted conn is handled in its own spawned task (handle_conn). SIGINT/SIGTERM (when handle_signals is true) close the listener and end the accept loop.
epoll_cores (and use_epoll, which is one core) is Linux-only and off by default. The loop counts requests on ConnStats without taking the stats mutex, and a matched upgrade/WS connection is handed to its own thread. See benches/compare/CORES.md.
Graceful drain
After the accept loop stops, if ServerOptions.drain_timeout is > 0, the engine waits up to that duration for in-flight connections to finish (active count → 0), then returns. Default 0 means no wait (previous behavior): listen returns as soon as accept ends while handlers may still run briefly.
app.server_options(ServerOptions{
handle_signals: true
drain_timeout: 10 * time.second
})
Conn stats (ops)
The engine always tracks live connections for max_conns and drain. To observe counters from your process, pass a heap ConnStats:
mut stats := new_conn_stats()
app.server_options(ServerOptions{
max_conns: 1024
stats: stats
})
// later:
snap := stats.snapshot() // active, accepted, rejected_max, closed, requests
Read
- Idle vs active timeout. After the first message on a connection, the next read uses
idle_timeout. Once bytes arrive for a new request,read_timeoutapplies again. - Headers. Bytes accumulate until
\r\n\r\n. Cap:max_header_bytes→ 413 / error close. - Body. Only
Content-Lengthbodies are read. Size must be ≤max_body_bytes. - Chunked / Transfer-Encoding. Not supported. Request is rejected with 400 and the connection is closed (no keep-alive desync).
- Leftover. Extra bytes after the message stay in a per-conn buffer for the next request (pipelining-tolerant read path). Full HTTP/1.1 pipelining is not a product claim, but the read path is stress-tested with multi-request bursts (
engine/pipeline_test.v).
Parse and validate
http.parse_request builds method, target, normalized path, query, headers, body.
- HTTP/1.1 requires a Host header when
require_hostis true (default). - Absolute-form targets (
http://host/path) are reduced to path + query when parsing. OPTIONS *is accepted as path*(no special router magic).
Handler
The app/router runs and returns a Response. req.ctx is set from App.set_ctx before the handler runs. Shared mutable state behind ctx is the caller’s responsibility (use a mutex if needed).
Write
Response is serialized as HTTP/1.1 status line + headers + body.
- HEAD: the engine strips the response body before write but keeps
Content-Lengthas the handler set it (same as GET would have returned). - Connection:
should_closeconsiders responseConnection: close, requestConnection: close, and HTTP/1.0 default close unless keep-alive.
Idle and close
If keep-alive: loop waits for the next request with idle_timeout. On timeout, EOF, protocol error, or close decision, the conn is closed.
After upgrade / WebSocket
When a route matches app.upgrade or app.ws, the HTTP keep-alive loop stops. The hijacked Conn gets:
| Deadline | Value |
|---|---|
| Read | max(read_timeout, idle_timeout) |
| Write | write_timeout |
Rationale: HTTP read_timeout is sized for a single request (default 30s). Quiet WebSocket peers that only send occasional messages would otherwise be cut mid-session. Using the larger of the two keeps long-lived streams workable under default options (typically 60s quiet) without an infinite hang. Override in the handler with set_read_timeout if you need more; send application pings for multi-hour idle. See ws.md and upgrade.md.
Expect: 100-continue
If the request includes Expect: 100-continue and a non-negative Content-Length, the engine sends a minimal 100 Continue interim response before finishing the body read (when the body is not already fully buffered). Other Expect values are ignored (not treated as errors).
Limits (defaults)
| Option | Default |
|---|---|
max_header_bytes | 64 KiB |
max_body_bytes | 1 MiB |
read_timeout | 30s |
write_timeout | 30s |
idle_timeout | 60s |
read_header_timeout | 0 (= read_timeout) |
max_conns | 0 (unlimited); excess accepts get 503 + close |
drain_timeout | 0 — after accept stops, wait for in-flight (0 = no wait) |
stats | nil — optional &ConnStats for live counters (requests = HTTP messages on the spawn path) |
send_date | false — when true, add Date if handler omitted it |
server_header | "" — when non-empty, add Server if handler omitted it |
Date / Server apply only to engine-written HTTP responses (including 4xx/5xx). Upgrade handlers write their own bytes and are not auto-annotated.
See engine.ServerOptions and deploy.md for reverse-proxy timeout alignment.