Low-RAM ASGI HTTP server with a Zig backbone. An alternative to uvicorn for FastAPI deployments where memory budget matters more than raw throughput.
pip install saltareLinux x86_64 / aarch64 wheels (manylinux + musllinux) and macOS arm64 wheels, CPython 3.10–3.14. Zero runtime deps for plain HTTP; TLS / compression libraries (libssl, libz, libbrotlienc, libzstd) are dlopen'd on first use, so they only need to be present on the host when the matching feature is enabled. On macOS, TLS needs Homebrew openssl@3 (brew install openssl@3) — the system libssl is LibreSSL, which saltare refuses to run against — and brotli/zstd likewise come from Homebrew when wanted.
# main.py
from fastapi import FastAPI
app = FastAPI()
@app.get("/")
def root():
return {"hello": "world"}saltare main:app --host 0.0.0.0 --port 8000A handful of frequently-used flags:
saltare main:app \
--host 0.0.0.0 --port 8000 \
--workers 0 \ # 0 = min(cpu_count(), 4)
--access-log \ # one line per request
--access-log-exclude /healthz \ # silence noisy probes
--access-log-exclude /metrics \
--health-path /healthz \ # 200 from Zig, no Python dispatch
--metrics-path /metrics \ # Prometheus text on this path
--response-gzip # negotiated content-encodingsaltare --help prints every flag (there are many — most are opt-in and cost zero RAM when off).
# server.py
import saltare
if __name__ == "__main__":
saltare.optimize_process() # first statement — see below
from main import app
saltare.run(
app,
host="0.0.0.0",
port=8000,
workers=0, # 0 = min(cpu_count(), 4)
access_log=True,
access_log_exclude=["/healthz", "/metrics"],
health_path="/healthz",
metrics_path="/metrics",
response_gzip=True,
)python server.pyRunning via the CLI already gets you the production RAM posture: the
python -OO -m saltare entry re-execs itself with -OO and
MALLOC_ARENA_MAX=1 on startup. A script that calls saltare.run()
yourself did not, because that re-exec is deliberately gated on saltare
being the real main entry — the gate exists because a naive argv check
once hijacked pytest and other tools launched from a directory whose
name contained "saltare`.
saltare.optimize_process() is the explicit opt-in for that case. It
re-execs your script under python -OO, which makes CPython discard
every docstring and assert in your dependency tree. Measured on the
FastAPI benchmark app, three samples each with no overlap:
| RSS | |
|---|---|
python server.py |
49.63 MiB |
with optimize_process() |
48.57 MiB |
Two rules. It must be the first statement, because the re-exec
replays the script from the top — anything done before it happens twice.
And it needs a real file in argv[0], so it declines under python -c,
python - and the REPL rather than dropping your session.
It declines harmlessly when the process is already optimized, when
SALTARE_REEXECED=1 is set (so a second call cannot loop), or when
SALTARE_NO_OPTIMIZE=1 is set — for apps that read __doc__ at runtime
or rely on their own asserts surviving. MALLOC_ARENA_MAX and
PYTHONFAULTHANDLER are set with setdefault, so an operator who
deliberately set them keeps their values.
For Django, set PYTHONOPTIMIZE=2 in the environment instead: by the
time manage.py runserver reaches saltare, the process is already deep
in Django's own bootstrap and a re-exec would discard the configured
settings.
Worth knowing before you spend effort on the wrong thing. Measured on this host, a running server with the FastAPI benchmark app:
| component | RSS | share |
|---|---|---|
| CPython 3.14 interpreter | 15.20 MiB | 31% |
import fastapi (starlette + pydantic + pydantic-core) |
29.38 MiB | 59% |
| building the app (routes, validators) | 2.25 MiB | 5% |
| saltare running | 2.78 MiB | 6% |
saltare's own cost is 2.78 MiB, of which the Zig core's zero-initialised
globals (.bss + .tbss) are 385 KiB. If you are RAM-bound, the lever
is the framework's import, not the server — and a 64 MiB container budget
is mostly spent on CPython plus FastAPI before saltare starts.
Pass a certificate and private key (PEM, both required together):
saltare main:app --port 8443 \
--ssl-certfile /etc/ssl/saltare/cert.pem \
--ssl-keyfile /etc/ssl/saltare/key.pem(or ssl_certfile= / ssl_keyfile= kwargs on saltare.run). Combine with --ktls for kernel TLS + sendfile(2) over HTTPS.
08/05/2026:14:32:11 [GET] [/api/users] [200] [347]
^^^
bytes sent
Local time. Drops the v0.15 JSON shape — easier to grep / awk. The format is parseable with one regex; latency distributions live on /metrics (--latency-histogram).
Status: 1.12.0 — macOS arm64 ships. 520 tests pass on Apple Silicon. The kqueue backend is a gated release platform: wheels build on
macos-14, the suite runs against them,test_macosblocks publish. Bring-up fixed five real Darwin bugs (wrongCLOCK_MONOTONICid hung the loop at 100% CPU; FreeBSD'ssendfilesignature declared as Darwin's; two use-after-free crashes silent on glibc; system LibreSSL refused at load; RSS rendered as 0) — two of them latent Linux bugs found only because Darwin crashes. No Intel wheel (declined), benchmarks stay Linux-only (/proc), kTLS is accepted-but-inert on macOS. Full detail, including what was not built and why, in CHANGELOG.md.
Earlier release history is in CHANGELOG.md, which records each version's decisions — including what was deliberately not built and the measurements behind the decision. It is a decision record, not a summary, so it is kept out of this file rather than mirrored here.
uvicorn is fast and battle-tested, but a typical worker (Python + asyncio + FastAPI + your code) sits around 60–90 MB resident before the first request. A meaningful chunk is asyncio bookkeeping: Transport/Protocol/Task/Future objects per connection, plus Python bytes buffers.
saltare keeps these in Zig:
| Layer | uvicorn | saltare |
|---|---|---|
| Event loop | asyncio (Python) | epoll (Zig) |
| Socket I/O | asyncio Transport | direct read/write (Zig) |
| HTTP/1.1 parser | httptools (C) |
hand-rolled (Zig) |
| Per-connection state | Python objects (~KB) | Zig structs (~hundreds B) |
| ASGI app callable | Python | Python (unchanged) |
Python only wakes up to dispatch a request to the user's ASGI app.
PyInit__core
│
┌──────────────────────┴──────────────────────┐
│ │
[ Python ] [ Zig core ]
saltare.run(app) ─── _core.serve ───► bind / listen
saltare CLI epoll accept loop
HTTP/1.1 parser
chunked decoder
TLS via OpenSSL
WebSocket framing
timer wheel (idle
timeouts)
│
dispatch_request ◄─────┘
app(scope, receive, send) ─────────────────► send()/receive()
backed by Zig sockets
Run with make bench (Docker; no Zig or Python needed on the host). The harness boots each server with the same FastAPI app, takes a /proc/<pid>/status reading at idle, drives a load with httpx, and samples VmRSS every 10 ms during the load to capture peaks. Granian (Rust + Python ASGI) is included as a third comparison point alongside uvicorn so saltare-vs-uvicorn isn't taken in isolation.
Optional extra workloads (off by default — pass through docker run):
# A 5000-conn idle-keepalive workload + a 1000-request /large workload.
docker run --rm saltare-bench python -m benchmarks.bench \
--high-conc-idle 5000 --large-requests 1000
# Increase the /large response size beyond 100 KiB (default).
docker run --rm -e BENCH_LARGE_BYTES=1048576 saltare-bench \
python -m benchmarks.bench --large-requests 200Results on x86_64 (manylinux_2_28_x86_64 inside Docker, CPython 3.14.4, FastAPI 0.136, pydantic 2.13, uvicorn 0.49 plain — no [standard] extras, granian 2.7 ASGI), v1.11.0 with default settings (single worker except where noted). Same host, same image. Each server's launcher imports the FastAPI app at module level so RSS readings reflect the same import footprint — without that normalisation, granian's master appears artificially small (~37 MiB) because it spawns a worker subprocess that holds the actual app, and the bench harness reads the master's /proc/<pid>/status.
| server | idle RSS | RSS after load | peak RSS | reqs ok | rps |
|---|---|---|---|---|---|
| saltare | 46.50 MiB | 46.64 MiB | 46.64 MiB | 1000 | 1093 |
| uvicorn | 49.60 MiB | 49.64 MiB | 49.64 MiB | 1000 | 1214 |
| granian | 58.78 MiB | 58.78 MiB | 58.78 MiB | 1000 | 1217 |
| server | idle RSS | RSS after load | peak RSS | reqs ok | rps |
|---|---|---|---|---|---|
| saltare | 45.23 MiB | 45.58 MiB | 45.59 MiB | 2000 | 1947 |
| uvicorn | 49.62 MiB | 50.50 MiB | 50.50 MiB | 2000 | 1784 |
| granian | 58.47 MiB | 58.47 MiB | 58.47 MiB | 2000 | 1931 |
| server | idle RSS | RSS after load | peak RSS | reqs ok | conn rate |
|---|---|---|---|---|---|
| saltare | 45.05 MiB | 45.43 MiB | 45.43 MiB | 500 | 1400 |
| uvicorn | 49.15 MiB | 54.51 MiB | 54.51 MiB | 500 | 1513 |
| granian | 58.14 MiB | 58.14 MiB | 58.14 MiB | 500 | 1373 |
| workers | observed | master Pss | Σ workers Pss | total Pss | vs naive N× single |
|---|---|---|---|---|---|
| 1 | — | 40.15 MiB | 0.00 MiB | 40.15 MiB | — |
| 4 | 4 | 14.51 MiB | 40.37 MiB | 54.88 MiB | 160.60 MiB (−66%) |
v1.11 note: these numbers are the HTTP/1.1 footprint (the load harness uses
httpx, which speaks HTTP/1.1), so they are directly comparable to prior releases — the v1.11 HTTP/2 response framing + outbound flow control add no RAM on the HTTP/1.1 hot path and the HTTP/2 code is inert unless a client negotiatesh2via ALPN. The PEP 684 sub-interpreter groundwork is likewise zero-cost when running the default pre-fork model. Real HTTP/2 (HEADERS + DATA frames, HPACK, flow control) is exercised separately by theh2-client conformance tests, not this RAM/throughput harness.
mimalloc note (v1.7): The bench image now preloads
libmimalloc.so.2(matches the production Dockerfile) so all three servers run under the same allocator and the comparison is apples to apples. mimalloc cuts granian's peak by ~2 MiB on the sequential / idle-keepalive workloads vs the v1.6 glibc-default bench. Saltare's numbers are unchanged within noise — itsmallopt(M_TRIM_THRESHOLD=64K, M_ARENA_MAX=1)tuning plusMALLOC_ARENA_MAX=1injected into the CLI re-exec env already drove glibc to behave as aggressively as mimalloc. mimalloc still wins where the operator is on musl (Alpine) or doesn't want to rely on glibc-specific knobs — that's whyDockerfile.productionships it by default.
Pss (Proportional Set Size, from /proc/<pid>/smaps_rollup) accounts for shared CoW pages — summing across master + N workers gives the real physical RAM of the cluster, not the inflated Σ RSS you'd get by counting each shared page N times. The "naive N× single" column is what the cluster would cost if every worker was a fresh independent process (no CoW / no gc.freeze()); saltare sits at 34% of that — 4 workers add only ~4.91 MiB Pss per worker beyond the first, vs tripling the floor. Granian uses a different supervision model (multiprocessing.spawn, not pre-fork-CoW), so the harness doesn't include it in this column.
saltare is the leanest of the three on every workload (v1.11.0). Saltare leads uvicorn by 3.0–9.1 MiB and granian by 12.1–12.9 MiB on this run; the v1.11 additions (real HTTP/2 response framing + outbound flow control, PEP 684 sub-interpreter groundwork) cost zero RAM when off — the HTTP/1.1 hot path is unchanged, and the HTTP/2 code is dead unless a client negotiates
h2via ALPN. Throughput is competitive: concurrent rps ahead of uvicorn (1947 vs 1784, ~9%) and granian (1931); idle-keepalive within ~7% of uvicorn (1400 vs 1513). Per-connection slope where saltare's architecture shows clearest: idle-keepalive 500 conns adds +0.38 MiB to saltare (~800 B/conn) vs uvicorn's +5.36 MiB (~11 KiB/conn) — only uvicorn pays per-connection cost at idle.
To isolate v1.2.2's effect from the FastAPI / Python version bump that lifted the floor across both servers, the bench harness was run twice in a row, same Docker image, swapping only saltare's source.
| workload (saltare peak RSS) | v1.2.1 baseline | v1.2.2 | delta |
|---|---|---|---|
| sequential | 45.70 MiB | 46.43 MiB | +0.73 MiB |
| concurrent | 45.44 MiB | 45.12 MiB | −0.32 MiB |
| idle-keepalive | 45.05 MiB | 44.59 MiB | −0.46 MiB |
| 1-worker Pss | 39.57 MiB | 40.47 MiB | +0.90 MiB |
| 4-worker per-extra Pss | 4.64 MiB | 4.51 MiB | −0.13 MiB |
Mixed signs, all within run-to-run noise (~±1 MiB on the worker baseline; glibc heap layout shifts between fresh python -m benchmarks.bench invocations). v1.2.2 is not a benign-workload RAM reduction — the gains land under streaming / WebSocket abuse that the harness doesn't exercise. Absolute rps in both runs is roughly half the v1.2.0 README numbers (sequential ~1000–1300 here vs 2447 there) because the host was a busy developer laptop rather than a clean CI box; the saltare/uvicorn ratio is unchanged.
The harness now supports two extra workloads off by default. Pass through docker run:
# Large-response workload: 1000 GETs against /large (default 100 KiB body).
docker run --rm saltare-bench python -m benchmarks.bench --large-requests 1000
# Crank the response size to 1 MiB:
docker run --rm -e BENCH_LARGE_BYTES=1048576 saltare-bench \
python -m benchmarks.bench --large-requests 200
# 5000-conn idle-keepalive (needs `ulimit -n` headroom on the host):
docker run --rm --ulimit nofile=65535 saltare-bench \
python -m benchmarks.bench --high-conc-idle 5000These exercise the v1.2.2 streaming backpressure (large-response) and the per-connection slope at scale (5000 idle conns) that the default workload doesn't touch.
Read this honestly:
- Per-connection slope vs uvicorn: 500 idle connections cost saltare +0.24 MiB (~490 B/conn) vs uvicorn's +5.37 MiB (~11 KiB/conn). That's a ~22× per-connection memory saving vs uvicorn for a realistic workload (clients that hold connections open between bursts of activity). Granian adds essentially nothing (
+0.00 MiB), so on per-conn slope saltare and granian are both comparable; saltare leads on the floor and on uvicorn-vs-anyone. - The reason: saltare's
pool.zigbundles the 16 KiB read buffer and the per-request headers array into a single pool node, returned to a free list as soon as a keep-alive connection goes idle. uvicorn's asyncio Transport keeps its per-connection buffers and Protocol/Task state alive for the lifetime of the socket. - The floor dropped ~2 MiB between v0.12.0 and v0.12.1 thanks to a
malloc_trim(0)call after lifespan startup — glibc returns the fragmented heap left over from the FastAPI/Pydantic import chain to the OS in one syscall. Sequential idle went from 45.56 MiB to 43.15 MiB. - Throughput parity (concurrent): saltare 3790 rps vs uvicorn 3951 rps — within ~4%. The remaining gap is primarily
httptools(uvicorn's tuned C parser) and uvicorn's tighter asyncio integration vs the bridge-driven dispatch. - Streaming dispatch (v0.12) cost a few percent on sequential because every HTTP request now runs as a long-lived asyncio Task with a per-request
recv_queueandoutgoinglist. Sequential RPS sits at ~2316 (was 2599 pre-streaming); concurrent and idle-keepalive workloads were largely unaffected because they were already gated by other costs. The new architecture pays off as soon as response sizes go up: a streaming endpoint that emits 10 MiB across 100 chunks now keeps RSS flat instead of buffering the whole 10 MiB in Pythonbytes— a saving the bench harness above doesn't measure (its FastAPI app returns ~30 bytes). - v0.16 buffer adaptivity is also bench-invisible. Read buffers shrink from 16 KiB → 4 KiB for the typical short request, saving ~12 KiB per in-flight request — but the bench's FastAPI app receives sub-1 KiB requests, so even the v0.15 16 KiB buffer was nearly empty. Wins show up in: services with high concurrency of small requests (savings compound across hundreds in-flight) and bursty traffic with valleys (
MADV_DONTNEEDreturns long-idle committed pages to the kernel after 30 s, so RSS shrinks back toward the floor instead of staying at peak forever). - The remaining ~42 MiB floor is Python + FastAPI itself. No userland server can shrink that without changing what the user app loads. Python 3.14 raises this floor a few MiB versus 3.12 because 3.14 imports more stdlib eagerly. Setting
MALLOC_ARENA_MAX=2in the environment shaves another 5–15 MiB on multi-threaded glibc systems (see Production deployment).
Where saltare's architectural win shows up most: long-lived idle connections (the WebSocket and keep-alive workloads above), very high concurrency (10k+ open sockets), and large streamed responses (file downloads, SSE, JSON over MB).
-
v0.1.0 — Build pipeline.
saltare._coreextension built with Zig viascikit-build-core. Listening socket + accept loop in Zig. Single fixed HTTP response. Local Docker build + cibuildwheel CI. -
v0.2.0 — HTTP/1.1 request parser in Zig (request line, headers,
Content-Lengthframing). Server echoes method + target back so the parser is observable end-to-end. Zero allocations per request. -
v0.3.0 — ASGI dispatcher. Persistent
asyncioloop reused across requests; per-requestloop.run_until_complete. Zig calls into Python via the C API only at dispatch time. FastAPI runs end-to-end (path params, JSON bodies, 404). No lifespan, no keep-alive, no streaming yet. -
v0.4.0 — Non-blocking event loop (epoll on Linux). Per-connection state machine in Zig with heap-allocated structs. Multiple connections progress in parallel; ASGI dispatch is the GIL serialization point. macOS (kqueue) raises
@compileErroruntil v0.4.x. -
v0.5.0 — HTTP/1.1 keep-alive. Persistent connections reset their state machine in place (read buffer compacted, write buffer freed, epoll switched back to read interest). Pipelined requests handled inline without an extra epoll round-trip.
-
v0.6.0 — Pooled read buffers. Idle keep-alive connections release their 16 KiB read buffer back to a shared pool; the next read event re-acquires one. RSS now scales with in-flight requests, not with open connections. Result: ~5× less per-connection memory than uvicorn at idle.
-
v0.7.0 — ASGI lifespan protocol. The dispatcher creates a long-lived asyncio Task that drives the app through
lifespan.startupbefore the I/O loop accepts connections, and throughlifespan.shutdownafter it stops. Apps usingFastAPI(lifespan=...)now get their startup/shutdown hooks executed. Apps that raise on lifespan scope (no support) are tolerated. -
v0.8.0 — Chunked Transfer-Encoding for request bodies. Decoder runs in place over the read buffer; resumable across kernel reads. Streaming response bodies (true chunked output) still buffer in Python and emit Content-Length — that lands when the dispatcher gets a callback path back into Zig.
-
v0.9.0 — TLS termination via OpenSSL. Pass
ssl_certfile=andssl_keyfile=tosaltare.run()to serve HTTPS. The connection state machine gains ahandshakingphase;doRead/doWriteroute through SSL_read/SSL_write and translate WANT_READ/WANT_WRITE into epoll interest changes. SSL_pending drained between keep-alive cycles.auditwheelbundles libssl/libcrypto into the wheel — self-contained, no host OpenSSL dependency. Single-cert/single-key, server-only (no mTLS, no SNI, no ALPN). -
v0.10.0 — WebSockets. RFC 6455 handshake, single-frame text/binary messages, ping auto-pong, close echo. Frames unmasked in place over the existing 16 KiB read buffer; outbound frames concatenated onto the same
write_bufthat HTTP responses use. Out of scope: continuation frames, message-level fragmentation, per-message deflate. -
v0.11.0 — Per-connection idle timeouts via a hashed timer wheel (
src/zig/timer.zig). Four configurable deadlines (header_timeout,keep_alive_timeout,body_timeout,write_timeout) with defaults of 5/5/30/30 seconds. Slowloris and slow-body attacks are now reaped instead of holdingConnectionstructs indefinitely. Wheel uses 128 buckets of 1 second; nodes are intrusive inConnection(24 B / conn) so arming and cancelling are allocation-free O(1). WS connections are exempt — long-lived idle sockets are expected there; ping/pong-driven WS keepalive lands post-v0.11. -
v0.12.0 — Streaming response bodies. Each HTTP request runs as a long-lived asyncio Task with its own
recv_queueandoutgoinglist; the app'ssend({type: "http.response.body", more_body: True/False})calls flow chunk-by-chunk through the bridge into Zig'swrite_bufinstead of being buffered into a single Pythonbytes. When the app does not declare a Content-Length, saltare addsTransfer-Encoding: chunkedautomatically. Concurrency uses a global "stalled list" of connections whose Task is parked on framework-internal awaits (e.g. FastAPI middleware chains): the main loop runs one global asyncio pump per iteration to advance every parked Task in lockstep, then drains each one — no per-connection multi-pumping, no level-triggered EPOLLOUT spin. Request bodies are still capped to the 16 KiB read buffer (request-side streaming lands in v0.12.x). -
v0.12.1 — Per-connection RAM polish. The
[64]Headerarray previously inlined intoConnection(~2 KiB) is now bundled into the samepool.zigBufferthat holds the read data, so it's released atomically when the connection goes idle: idle keep-alive cost drops from ~2 KiB to ~390 B per connection, taking the per-conn advantage over uvicorn from ~5× to ~28×. Amalloc_trim(0)call afterlifespan.startupreturns ~2 MiB of glibc heap fragmentation (left over from FastAPI/Pydantic imports) to the OS — the sequential-idle floor dropped from 45.56 MiB to 43.15 MiB. README gains a "Production deployment" section recommendingMALLOC_ARENA_MAX=2for another 5–15 MiB. -
v0.13.0 — Resource caps +
Expect: 100-continue. NewLimitsstruct (max_request_body,max_concurrent_connections,max_keepalive_requests) wired intoserve()and the CLI. Body cap fires 413 on declaredContent-Lengthoverflow and on incremental chunked-decode growth. Connection cap accepts overflow sockets (to drain the listen backlog) and immediately closes them. Keepalive-requests cap forcesConnection: closeon the Nth response, recycling pymalloc arenas.Expect: 100-continuewrites the interim response before reading the body, except when the declared body would exceed the cap (in which case the client gets a 413 directly). Caps add zero RAM cost in benign workloads; under adversarial load they convert the architectural advantage into a hard guarantee. -
v0.14.0 — Graceful shutdown + ASGI exception isolation. New
g_drainingatomic flag; the SIGTERM/SIGINT handler sets it (and a second signal promotes to immediate force-exit). Main loop, on first observing drain mode, removes the listen fd from epoll (stops accepting), stamps a deadline, and continues processing in-flight requests — exit happens wheng_active_connsreaches zero orshutdown_timeout(default 30 s) elapses. Idle keep-alive connections drain naturally viakeep_alive_timeout. After the loop exits,lifespan.shutdownruns as before, then the process exits 0. App exceptions during dispatch are caught at the bridge: pre-headers raises produce a synthesized 500, mid-stream raises close the connection — server keeps serving subsequent requests. Tests now 44/44 (5 new intest_shutdown.py, 3 of which exercise real SIGTERM viasubprocess). -
v0.15.0 — Observability + UDS.
Observabilitystruct (metrics_path,access_log,proxy_headers) all opt-in.metrics_path(e.g./metrics) intercepts requests in Zig and serves Prometheus text from atomic counters (saltare_open_connections,saltare_in_flight_requests,saltare_requests_total,saltare_responses_4xx_total/_5xx_total,saltare_bytes_sent_total/_received_total,saltare_process_resident_memory_bytesfrom/proc/self/statuson Linux).access_logemits a JSON line per completed request to stderr from a 4 KiB stack-buffered writer (status line parsed once from the wire bytes; bytes/latency tracked inConnection); a singlewrite(2)keeps lines atomic.proxy_headerslets the dispatcher readX-Forwarded-For(leftmost IP intoscope["client"]) andX-Forwarded-Proto(intoscope["scheme"]); only enable behind a trusted proxy.uds_pathmakesserve()bind anAF_UNIXsocket instead of TCP — the bind path is unlinked on shutdown so restarts don't fail withEADDRINUSE. All four off by default; bench numbers indistinguishable from v0.14. Tests now 50/50 (6 new intest_observability.py). -
v0.16.0 — Adaptive read buffer +
MADV_DONTNEED. The single 16 KiB pool from v0.6–v0.15 splits into two free lists: a 4 KiB primary covering the typical short request, and a 16 KiB overflow used either as the initial buffer for big payloads or as the upgrade target when a partial parse fills the small one (in-flight bytes are memcpy'd across;parsed.headersis invalidated and re-parsed because it pointed into the small buffer's headers array).Buffer.databecomes a[]u8slice (page-allocated via mmap so the OS can later reclaim its pages);Buffer.released_at_nsrecords when a buffer entered the free list. Each main-loop iteration callspool.sweepIdle(monoNs()), which walks both free lists and issuesMADV_DONTNEEDfor any block idle >30 s — page-aligned mmaps mean the kernel actually drops the physical pages. Linux only; macOS short-circuits the sweep. Bench numbers are within noise of v0.15 (the FastAPI bench app sends sub-1 KiB requests, so even the v0.15 16 KiB buffer was nearly empty); the wins manifest in real-world bursty traffic and high-concurrency-low-payload services.Headeroffset compression deferred — too much API churn for the marginal saving. -
v0.17.0 — Stability + Python RAM polish. Replaced the per-request
asyncio.Queuein_HttpStatewith a single-slot mailbox + on-demandFuture: the typical request that doesawait receive()once never allocates a Queue object, an internal deque, or a getters list. Saves ~300 B of GC churn per request, lower transient peak under concurrency, and conceptually simpler dispatcher (fewer asyncio internals to reason about). Also fixed thetest_fastapi_lifespan_startup_runsflake by adding a small retry around the first httpx call — the race was FastAPI's first-dispatch warm-up trip, not saltare itself, and 2 retries make it deterministic in CI. The pre-alpha status note now states explicitly that production is x86_64 Linux — macOS dev-builds still work for everything except the actual server (kqueue still@compileError). -
v0.18.0 — WebSocket keepalive + Python RAM polish. Server now sends an empty
pingframe everyws_keepalive_timeoutseconds (default 20) on each open WS; if no inbound frame (incl. pong) is observed in 2× that window, the connection is reaped. Implemented by reusing the existing timer wheel: WS upgrade arms it, every inbound frame updateslast_activity_ns, andfireExpired's WS branch is now ping-or-teardown rather than just teardown. Plus two Python-side wins: (1) header names are lowercased in Zig in-place insidebuildHeadersListso_dispatcher.pydrops the per-request.lower()list-comprehension and the per-header tuple rebuild it forced; (2) a 16-entry PyBytes cache for common header names (host, user-agent, content-type, etc) avoidsPyBytes_FromStringAndSizeon every cached header. Net: first run where saltare's concurrent rps (4006) edges past uvicorn's (3988), and ~0.2 MiB shaved across all three bench workloads. -
v1.0.0 — Pre-fork multi-worker. New
src/zig/master.zigmodule supervises N forked workers viapause()+waitpid(). Master flow: bind+listen via the existingbindAndListen; fork N children that each run the v0.18 single-worker flow (lifespan startup → accept loop on the inherited fd → lifespan shutdown →_exit); supervise. Children callprctl(PR_SET_PDEATHSIG, SIGTERM)so an SIGKILL'd master doesn't leave orphan workers. v1.0 policy on worker death: propagate shutdown to the rest, return — let the supervisor restart the pod. Each worker keeps its own counters;metrics_pathreports per-worker (aggregate across workers in your scraper). Newworkerskwarg onsaltare.run()and--workers NCLI flag (default 1, single-worker behaviour unchanged). Tests intests/test_multiworker.pyuse subprocess +/proc/<master>/task/.../childrento verify worker spawn, request serving, SIGTERM drain, and unexpected-worker-death propagation. -
v1.1.0 — Multi-worker RAM polish.
gc.freeze()is called once in the master right before the fork loop (and once per single-worker dispatch path, after lifespan startup) so CPython's cyclic-GC bookkeeping doesn't dirty CoW pages on each worker's first sweep — verified: 4 workers cost 51 MiB Pss instead of the naive 150 MiB (~66% saved).http.max_headerslowered from 64 to 32 (typical request has <20; 31 KiB → 1 KiB per active pool buffer worth of[Header]Nstorage). StaticasgiASGI sub-dict cached as a module-level constant, shared across all requests instead of re-allocated. Bench harness gains amulti-worker idleworkload that reports Pss across master + workers, with a "naive N× single" comparison column. -
v1.2.0 — Python hot-path polish. Three orthogonal cuts to per-request work in
_dispatcher.py: (1) module-level free-list pool of_HttpStateinstances with areset(...)method that rewrites every slot — saves the slot-allocation step + GC-tracking overhead per request and reuses theoutgoinglist. (2)receiveandsendcallables converted from per-request closures to bound methods (_HttpState._receive,_HttpState._send) — half the per-instance memory of a closure cell, no per-instance compile, plays well with the pool. (3) Pre-built byte-string constants for the wire format:_SERVER_LINE,_CONNECTION_KEEPALIVE_LINE,_CONNECTION_CLOSE_LINE,_TRANSFER_ENCODING_CHUNKED_LINE,_CHUNKED_TERMINATOR,_CRLF, plus a precomputed status-line cache for every reason code in_REASONS. Each response now references shared bytes instead of rebuildingb"server: " + _SERVER_HEADER + b"\r\n"etc. Net: sequential rps 2335 → 2447 (+4.3%), concurrent peak −0.3 MiB. Multi-worker numbers unchanged from v1.1 (these wins are per-request, multi-worker is per-process). -
v1.2.2 — Worst-case RAM caps + bench / CI / production polish. Source caps: (1) HTTP send-yield backpressure —
_HttpState._sendtracks bytes appended tooutgoingsince the last drain; once the running total crosses_HTTP_SEND_YIELD_BYTES(64 KiB), the next intermediateawait send(...)does anawait asyncio.sleep(0)so the asyncio loop hands control back. Zig's main-loop stalled-pump path harvests viahttp_dispatch_drain, the counter resets, and the app keeps producing — per-task accumulated RAM is now bounded to ~one threshold's worth no matter how many sends a streaming endpoint chains in a row. The yield is skipped on the final chunk (more_body=False) so plain request/response apps never pay it. (2) WebSocket outbound 1 MiB cap —_WsState.outgoing_bytesis a running total; once_WS_OUTGOING_MAX_BYTESis exceeded the connection is markedclosedand further sends drop. (3)_HTTP_POOL_MAXbumped 32 → 128. (4) epoll event array 128 → 64. Bench delta vs v1.2.1 same-host: mixed-sign, within ±1 MiB noise — these are caps, not benign-workload RAM cuts. Plus tooling: (5) Granian added as a third bench comparison point, which surfaced a fact the saltare-vs-uvicorn comparison was hiding: Granian sits ~10–12 MiB below saltare on the floor. Closing that gap is on the v1.3 roadmap. (6)Dockerfile.productionwith jemalloc preloaded +MALLOC_ARENA_MAX=2,make production-image. (7)make valgrindtarget with CPython suppressions for periodic C-API leak checks acrossbridge.zig. (8) Bench harness extra workloads:--large-response,--high-conc-idle 5000. (9) README CoW eager-import doc — workers only stay lean if all imports happen in the master before the fork, and the typical FastAPI footgun (lazyimportin route handlers) is now called out. LTO on the Zig side was attempted but rolled back — Zig 0.16'sBuild.ModuleandBuild.Step.Compileno longer expose an LTO field, and-fLLVM-ltois not wired throughb.standardOptimizeOption; will revisit when a public API lands. -
v1.4.0 — Body streaming + cgroup awareness + mimalloc default +
sendfile(2)ASGI extension +.pycembed + tracemalloc cache + lazy zlib infra. Phase 1 (lift current ceilings): (1) Request body streaming — when declaredContent-Lengthexceeds the read buffer, the dispatcher engages an ASGI streaming path. App seeshttp.request{body=chunk, more_body=True}events and saltare reads + pushes more chunks viahttp_dispatch_push_bodyas the kernel hands them over. Per-task RAM stays bounded by the dispatcher's 64 KiB backpressure threshold instead of the body's declared size — was: 413 above 16 KiB. NewConnection.body_streamingstate +streamReceiveMorehandler.max_request_bodyre-checked incrementally during streaming so adversarial clients can't bypass it. (2) mimalloc default inDockerfile.production(with jemalloc fallback if mimalloc isn't packaged); ~5 MiB lower steady-state vs glibc default. (3)sendfile(2)zero-copy via the newsaltare.sendfileASGI extension — the app emits{"type": "saltare.sendfile", "path": "/var/www/file.bin", "status": 200, "headers": [...]}and the dispatcher signals Zig to extract the path + headers viahttpDispatchPopSendfile. Zig builds the head, callssendfile(2)syscall in a loop honouringEAGAIN, and never copies file bytes through Python. Plain-HTTP only; TLS path 500s gracefully (kTLS not wired). Static-asset endpoints save MiBs per response that would otherwise live in app-heapbytes. Phase 2 (floor reduction): 2.3 + 2.4 already optimised in v1.3 (lazy traceback, gen-0 GC). (6).pycprecompile in theDockerfilebuilder stage (python -OO -m compileall src/saltare ... optimize=2) so__pycache__/*.opt-2.pycis shipped alongside the wheel; first-request import latency drops. Phase 3 (functional gains): (7) lazydlopen("libz.so.1")at src/zig/zlib.zig —gunzip(src, allocator, dst_cap) ?[]u8with zip-bomb cap +gzipEncode(src, allocator, level) ?[]u8with gzip wrapper (15+16window bits). Function-pointer table populated bydlsymon first use; mirrors the TLS pattern. Exposed to the dispatcher as_core.gzip_encode/_core.gunzip. Wired into two opt-in code paths: (7a)--response-gzip— when the request carriesAccept-Encoding: ...gzip...and the response is single-shot, compressible content-type (text/*,application/json,application/javascript,application/xml,image/svg+xml, etc.), and ≥--response-gzip-min-bytes(default 512), saltare gzip-encodes the body, drops the app'sContent-Length, and emitsContent-Encoding: gzip+Vary: Accept-Encoding. The negotiation honoursq=0weights and the*wildcard per RFC 7231 §5.3.4. Streaming responses skip gzip — chunked + per-chunkZ_SYNC_FLUSHis deferred. (7b)--request-decompression— request bodies withContent-Encoding: gzipare decompressed before the app's firstawait receive(), capped atmax_request_body(zip-bomb defense returns 413 on overflow). Both flags are off by default — when off,_core.gzip_encode/_core.gunzipare never called and libz stays unmapped (isAvailable()only fires on first call). Phase 4 (hardening): (5) cgroup-v2 memory awareness — when the operator hasn't explicitly setmax_concurrent_connections, saltare reads/sys/fs/cgroup/memory.max(or v1'smemory.limit_in_bytes), reserves a 64 MiB floor for Python heap + libs, and budgets the rest at 50 KiB per concurrent — auto-cap stays sane under k8sresources.limits.memory. (11) 5-secondtracemallocsnapshot cache —dump_tracemallocpreviously rebuilt a top-30 statistics list on every poll (~10–50 ms blocking the dispatch loop); now caches the rendered bytes for 5 s so monitoring agents on a 1 s scrape interval get cheap reads. Phase 5 (compression suite + ops): (12) Streaming response gzip —_HttpState._gzip_cocarries azlib.compressobj(level, DEFLATED, 31)across_sendcalls; intermediate chunksZ_SYNC_FLUSH, the final chunkZ_FINISH. Apps that emitmore_body=Truenow compress end-to-end (was: streaming responses passed through unchanged in v1.4.0 day-1). (13) Brotli via lazydlopen("libbrotlienc.so.1")at src/zig/brotli.zig +_core.brotli_encode/brotli_decodeexposed to the dispatcher. Single-shot only. Wired into the encoder negotiation. (14) zstd via lazydlopen("libzstd.so.1")at src/zig/zstd.zig +_core.zstd_encode/zstd_decode. Single-shot only. Wired into the encoder negotiation. (15) Encoding negotiation —_negotiate_encoding(value)parsesAccept-Encoding, honoursq=0and the*wildcard per RFC 7231 §5.3.4, and picks per server-preference order br > zstd > gzip when multiple are offered. Disabled encoders are skipped automatically. (16)max_request_uri→ 414 URI Too Long (default 8192 B). Cheaper guard than letting a multi-KiB target hit the routing table. (17)max_request_head_bytes→ 431 Request Header Fields Too Large — explicit cap (the implicit pool-buffer ceiling is ~64 KiB; this lets operators tighten further). (18)traceparent_propagation— W3C Trace Context surfaced onscope["traceparent"]/scope["tracestate"]and echoed back on the response. ~30 B/req when on, zero when off. (19)latency_histogram— Prometheussaltare_request_duration_seconds_bucketwith 14 fixed buckets (1 ms..60 s) +_sum+_count; emitted on/metricsonly when enabled. ~140 B of bucket counters per worker. Bench: 46.52 / 45.24 / 45.29 MiB vs uvicorn 48.91 / 49.86 / 54.55 MiB and granian 52.90 / 50.26 / 49.78 MiB. Tests 66 core + 10 v1.3 + 8 v1.4 zlib + 11 v1.4 extras + 4 v1.4 sendfile = 99 total ✓ on Zig 0.16.0. -
v1.3.0 — Lazy-loaded TLS + ~25 operational knobs, leanest of three. RAM-floor cuts (default-on): (1) OpenSSL link gone at build time —
tls.zigdeclares OpenSSL types asopaque {}, hard-codes ABI constants, and ships a function-pointer table populated bydlopen+dlsymon firstnewContext()call. Plain-HTTP deployments never load libssl/libcrypto. (2)mallopt(M_ARENA_MAX, 1)at module init caps glibc's per-thread arenas. ThesaltareCLI re-exec also injectsMALLOC_ARENA_MAX=1into the child env so even CPython's bootstrap allocations land in a single arena. (3)PYTHONOPTIMIZE=2auto re-exec strips docstrings + asserts from FastAPI / Pydantic / Starlette —SALTARE_NO_OPTIMIZE=1opts out, and_is_saltare_main_entry()gates the re-exec to only fire when this module is the actual main entry. (4) URL decode moved to Zig (http.urlDecode) —_dispatcher.pyno longer importsurllib.parse. (5)TCP_NODELAY+SO_KEEPALIVEon accept — small-response latency loses the Nagle delay; dead peers (NAT timeouts, mobile drops) get reaped by kernel keepalive. Operational knobs (opt-in, zero RAM when off): (6) Health intercept (health_path). (7) CORS preflight intercept (cors_preflight_allow_all). (8) IPv6 listen (auto-detect fromhost,IPV6_V6ONLY=1). (9) Per-IP rate limiter (rate_limit_per_sec,rate_limit_burst) — 4096-IP bounded LRU table, honorsproxy_headers(X-Forwarded-Forleftmost when behind trusted proxy). (10)tracemalloc_pathauto-starts tracking + serves top-30 dump. (11)favicon_204— Zig answersGET /favicon.icowith 204. (12)max_connections_per_ip— TCP-RST over-cap peers; shares the rate-limit table so the per-IP cap costs no extra memory beyond the limiter that's already there. (13)access_log_path— JSON log lines to a file viaO_APPEND | O_CLOEXECinstead of stderr. (14)request_id_header— auto-generates an 8-byte hex ID per request, exposes viascope["x-request-id"], echoes as response header. (15)server_timing=True—Server-Timing: total;dur=<ms>on every response. Tier-3 ops + RAM additions: (16) Aggressivemalloptthresholds (M_TRIM_THRESHOLD,M_TOP_PAD,M_MMAP_THRESHOLDall clamped to 64 KiB at module init) so heap fragmentation returns to the OS more eagerly. (17) Idle-maintenance tick — after 3 s with zero events and zero in-flight requests, the main loop runsgc.collect(2)+gc.freeze()+malloc_trim(0)to recover memory accumulated during the previous burst. Cheap when steady-state, capped to once per idle window. (18)SIGUSR1JSON stats dump to stderr ({"event":"saltare.stats","open_conns":N,"in_flight":M,"requests_total":...,"rss_kib":...,"rl_table_size":...}) — operational diagnostic without an HTTP probe. (19)listen_backlogconfigurable (default 256). (20)tcp_keepidle/tcp_keepintvl/tcp_keepcnttunable cadence on accepted sockets — kernel defaults are too generous for mobile / NAT-heavy fronts. (21)X-Real-IPhonored alongsideX-Forwarded-For(nginx convention; X-Real-IP wins when both are present). (22) HTTP/1.1 mandatoryHost:enforcement — missing or emptyHostheader gets a 400 per RFC 7230 §5.4. (23) systemd socket activation — auto-detectsLISTEN_PID=$$+LISTEN_FDS=1and inherits fd 3 instead of binding; the env is unset so forked workers don't double-activate. (24) HAProxy PROXY-protocol v1 — whenproxy_protocol=True, the first line of every accepted connection is parsed asPROXY <fam> <src> <dst> <sport> <dport>\r\n(TCP4 / TCP6 / UNKNOWN); src replaces the TCP peer for rate-limit + access-log, so saltare gets real client IPs behind L4 LBs (AWS NLB, GCP TCP LB, HAProxy v1) that strip HTTP-level headers. (25) WebSocket subprotocol finally honored (real bug — was always returningscope["subprotocols"]=[]). (26) HTTP trailers (http.response.trailers) emitted as chunked-encoding trailer block per RFC 7230. Tier-4 hardening + RFC additions: (27) PROXY-protocol v2 binary auto-detect at accept (AWS NLB/ALB default, modern HAProxy). (28) HEAD method body strip — same headers as GET, no body (RFC 7230 §3.3.3). (29) Headertcharvalidation — RFC 7230 §3.2.6 reject\0/CRLF in header names. (30) Connection age cap (max_connection_lifetime_secs) — wall-clock connection budget. (31)startup_request— internalGET /after lifespan startup to warm FastAPI route compilation + pydantic validators. (32) TLS session cache (tls_session_cache_size). (33) TCP_USER_TIMEOUT — sub-second failure detection. (34)auto_raise_nofile—setrlimit(RLIMIT_NOFILE)to hard at startup. (35)server_headerconfigurable — refactor of all comptime concat sites to runtimeg_server_line; empty string omits. (36) Lazytracebackimport — defer the ~150 KiB stdlib until first error path. (37)workers=0auto-detectsmin(cpu_count(), 4). Tier-5 final: (38)PYTHONFAULTHANDLER=1auto in re-exec env. (39)SIGUSR1dump now includesdrainingflag. (40)setproctitleviaprctl(PR_SET_NAME)—saltare,saltare:master,saltare:wkrNvisible inps. (41)TCP_FASTOPENserver-side queue (Linux ≥ 3.7). (42) Periodicgc.collect(0)every N requests for gen-0 churn. (43)X-Forwarded-Host+Forwarded:(RFC 7239) — both feedscope["server"]and the rate-limit key whenproxy_headers=True. (44)/metricssaltare_health_stategauge — 0=healthy, 1=draining. (45) WebSocket continuation frames — RFC 6455 §5.4 reassembled per-connection up to 1 MiB cap (was a real bug — fragmented messages from any client got the connection torn down). (46) mTLS —ssl_ca_file+ssl_verify_client=Trueenforce client cert verification at handshake. Bench: 46.6 / 45.6 / 45.4 MiB vs uvicorn 48.93 / 49.92 / 54.21 MiB and granian 56.16–57.68 MiB. Tests 66/5 ✓.
- WebSocket per-message-deflate (RFC 7692) — handshake negotiation + per-message inflate/deflate. zlib infra is already loaded by the HTTP path; this just plumbs it through
ws.zig(rsv1 bit on outbound frames + decompress inbound when negotiated). - Streaming brotli + zstd — currently single-shot only. Streaming-encode requires a per-state encoder carried across
_sendcalls (analogous to_gzip_co). - HTTP/2 + ALPN via
nghttp2. Multiplexing many requests over one connection. Big win for high-concurrency clients but tens of KLoC of wire-format work; v1.5 candidate. - Free-threaded Python (
cp314t) — measure RSS + rps with GIL gone. Could let dispatch run concurrently; could also inflate the floor. Decision after benchmarking. - Static-link OpenSSL build experiment — alternative wheel (
saltare-with-tls) that links libssl/libcrypto statically for environments without manylinux's runtime libs. Plain wheel keeps the lazydlopenpath.
- HTTP/2 stream multiplexing. v1.11 made the HTTP/2 wire real but still serial: one dispatch in flight per connection. Concurrent streams are the next piece of real work there.
- kTLS on macOS, and any
PR_SET_PDEATHSIGequivalent (kqueueEVFILT_PROCon the parent pid). Both declined in v1.12 with reasoning in CHANGELOG.md; revisit if macOS multi-worker becomes a deployment target rather than a developer-machine one. - x86_64 macOS wheels — declined; Rosetta on an arm64 runner, for negligible demand.
- Benchmarks on macOS —
benchmarks/reads/proc, which does not exist on Darwin. Would need a Mach-based sampler. - Free-threaded Python (
cp314t) and static-link OpenSSL, as above.
See CHANGELOG.md for the per-version highlights and the examples/ directory for runnable end-to-end snippets:
examples/compression.py— gzip / brotli / zstd negotiation, single-shot + streaming.examples/sendfile.py—saltare.sendfileASGI extension for zero-copy static-asset responses.examples/observability.py— W3Ctraceparentpropagation + Prometheus latency histogram on/metrics.examples/django.md—pip install saltare[django]integration:manage.py runserverboots Django under saltare instead of wsgiref.examples/channels/— Django Channels (ProtocolTypeRouter+AuthMiddlewareStack+ a runnable WebSocket consumer); drop-in replacement for daphne.
Both per-request HTTP dispatch and ASGI lifespan startup/shutdown are wired up: FastAPI(lifespan=...) and the older @app.on_event("startup") work as expected.
Saltare speaks the standard ASGI WebSocket protocol and runs Channels' ProtocolTypeRouter directly — same asgi.py you point daphne at:
# myproject/asgi.py
import os
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "myproject.settings")
import django
django.setup() # MUST run before importing Channels routing
from channels.routing import ProtocolTypeRouter, URLRouter
from channels.auth import AuthMiddlewareStack
from channels.security.websocket import AllowedHostsOriginValidator
from django.core.asgi import get_asgi_application
from myapp.routing import websocket_urlpatterns
django_asgi_app = get_asgi_application()
application = ProtocolTypeRouter({
"http": django_asgi_app,
"websocket": AllowedHostsOriginValidator(
AuthMiddlewareStack(URLRouter(websocket_urlpatterns))
),
})Run:
saltare myproject.asgi:application --host 0.0.0.0 --port 8000 \
--access-log --ws-reject-log \
--proxy-headers # add behind nginx / traefik / k8s ingressALLOWED_HOSTSmust include the host the browser is on (localhost,mysite.com, container DNS name, …). Channels'AllowedHostsOriginValidatorreads this to decide whether to accept the upgrade — mismatched Origin → close code 4003 → saltare returns HTTP 403.django.contrib.sessionsinINSTALLED_APPS+SessionMiddlewareinMIDDLEWAREif you useAuthMiddlewareStack(it looks up the session backend).SECRET_KEYpresent (any env-driven default is fine).- A configured channel layer (in-memory for dev, Redis or PostgreSQL for prod). Without it,
group_sendraises.
--ws-reject-log writes one stderr line per rejected upgrade with the WebSocket close code (4003, 4001, etc.) and reason, so you can tell at a glance whether OriginValidator, AuthMiddleware, or the consumer itself shut the connection. Saltare also maps the close code onto an HTTP status (4003 → 403, 4001 → 401, 4004 → 404, 4008 → 408, 4029 → 429, anything else → 403) so browser-side WebSocket.onclose carries a meaningful status.
The --access-log stream emits [WS-CONNECT] and [WS-CLOSE] lines in the same format as the HTTP traffic, with the close code in place of the status:
19/05/2026:11:46:30 [WS-CONNECT] [/ws/notifications/] [101] [0]
19/05/2026:11:48:15 [WS-CLOSE] [/ws/notifications/] [1000] [1234]
--ws-handshake-timeout SECS— how long the upgrade pump waits for the consumer to accept/close (default2.0). Raise on cold-start middleware that does a slow DB warm-up.--ws-pump-interval-ms MS— interval between asyncio-loop pumps for live WS connections (default50). Lower for server-push workloads that need sub-50 msgroup_senddelivery; raise on bandwidth-pinched deployments. Floor 10 ms.
Apps can emit response bodies in chunks via the standard ASGI more_body flag — saltare flushes each chunk to the wire as soon as the app produces it instead of buffering the full response in Python:
async def streaming_endpoint(scope, receive, send):
await receive()
await send({"type": "http.response.start", "status": 200,
"headers": [(b"content-type", b"text/plain")]})
for chunk in produce_chunks(): # arbitrary length, no upfront size needed
await send({"type": "http.response.body", "body": chunk, "more_body": True})
await send({"type": "http.response.body", "body": b"", "more_body": False})When the app does not declare a Content-Length, saltare adds Transfer-Encoding: chunked automatically. Apps that do declare a Content-Length get raw bytes streamed (no chunked framing). FastAPI's StreamingResponse and Starlette's SSE helpers both work without changes.
Every connection is bounded by four deadlines, all configurable in seconds:
saltare.run(
app,
header_timeout=5, # accept → headers parsed
keep_alive_timeout=5, # between requests on a kept-alive conn
body_timeout=30, # headers parsed → body fully received
write_timeout=30, # max time held in the writing state
)The same flags are exposed on the CLI (--header-timeout, --keep-alive-timeout, --body-timeout, --write-timeout). Defaults match the values above. WebSocket connections are exempt — long-lived idle WS sockets are expected, and ping/pong-driven keepalive lands post-v0.11.
Apps receiving large request bodies (file uploads, multi-MiB JSON) get them via the standard ASGI more_body flag — saltare reads the kernel's bytes incrementally and pushes them into the running ASGI task as http.request{body=chunk, more_body=True} events:
async def upload_endpoint(scope, receive, send):
total = 0
while True:
msg = await receive()
if msg["type"] != "http.request":
break
total += len(msg["body"])
# process chunk in place — never keep the whole body in memory.
if not msg.get("more_body"):
break
await send({"type": "http.response.start", "status": 200,
"headers": [(b"content-type", b"application/json")]})
await send({
"type": "http.response.body",
"body": f'{{"received": {total}}}'.encode(),
"more_body": False,
})The streaming path engages automatically when the declared Content-Length exceeds the read buffer (4 KiB / 16 KiB depending on pool tier). For smaller bodies saltare keeps the full-buffer fast path. max_request_body is enforced incrementally — adversarial clients announcing a small Content-Length then streaming more bytes get a 413 mid-stream and the connection is closed.
Single-kwarg options that tune the socket, the allocator and the process.
Defaults are the saltare.run() defaults; the CLI flag equivalent is in the
CLI reference.
| kwarg | default | effect |
|---|---|---|
listen_backlog |
256 |
listen(2) backlog, silently truncated by the kernel to net.core.somaxconn. A higher value only helps absorb SYN bursts. |
tcp_keepidle |
0 |
Seconds idle before the first keepalive probe. 0 (or negative) takes the kernel default, typically 7200 s — far too long for mobile clients. |
tcp_keepintvl |
0 |
Seconds between probes. 0 = kernel default. |
tcp_keepcnt |
0 |
Unanswered probes before the connection is dropped. 0 = kernel default. |
tcp_user_timeout_ms |
0 |
Linux TCP_USER_TIMEOUT: how long a stuck write may stay unacked. More aggressive than keepalive, which only fires on idle conns. 0 = disabled. Not available on macOS. |
tcp_fastopen_qlen |
0 |
TCP_FASTOPEN on the listen socket (Linux), letting repeat clients carry payload in the SYN. Only pays off under high connection churn. |
auto_raise_nofile |
False |
Raise the soft RLIMIT_NOFILE to the hard limit at startup, so max_concurrent_connections is not capped by the user's fd limit. |
startup_request |
False |
Issue an internal GET / once lifespan.startup completes, warming route compilation, validators and JIT caches before real traffic. |
gc_collect_every_n_requests |
0 |
Run a gen-0 gc.collect(0) (tens of µs) every N completed dispatches. For apps that allocate many short-lived cyclic objects per request. 0 = never. |
tls_session_cache_size |
0 |
OpenSSL server-side session cache, so repeat clients skip the handshake (~3 RTT → 1). Requires ssl_certfile/ssl_keyfile. 0 = disabled. |
server_header |
None |
Override the Server: line; "" omits it entirely. The default is derived from _core.version() at runtime, so it never drifts from the package version. |
max_concurrent_connections |
1024 |
Accepted sockets held open. While it is left at the default, a cgroup v2 memory.max limit (k8s resources.limits.memory) lowers it automatically — budget after a 64 MiB floor at ~50 KiB per concurrent, never below 16. Any value other than 1024 disables the auto-cap; setting it to 1024 explicitly does not, since 1024 is the sentinel. |
Static-asset endpoints can avoid copying file bytes through Python heap by emitting a saltare.sendfile ASGI extension event instead of http.response.start + http.response.body. saltare's Zig core opens the file, builds the response head, and uses the sendfile(2) syscall directly to the socket — never reads file bytes into userspace.
async def app(scope, receive, send):
if scope["type"] != "http":
return
if scope["path"] == "/static/big.bin":
await send({
"type": "saltare.sendfile",
"path": "/var/www/big.bin",
"status": 200,
"headers": [(b"content-type", b"application/octet-stream")],
})
return
# …regular response path…Notes:
- Plain-HTTP only — TLS connections return
500 Internal Server Error(kTLS isn't wired in this version). - Don't include
Content-Length/Transfer-Encoding/Connectioninheaders; saltare derives those from the file'sfstat(). Content-Lengthis set automatically fromfstat(); the head and body are written separately so HEAD-method handling is correct.- The extension is opt-in: apps that don't emit
saltare.sendfilekeep the existing dispatch path with no behavioural change.
Three opt-in flags expose the lazy-dlopen path for each codec. All default off; when off, the corresponding lib (libz.so.1 / libbrotlienc.so.1 / libzstd.so.1) is never loaded and the mapping never enters the process. Plain-HTTP / no-compression deployments keep the v1.3 RAM floor unchanged.
saltare.run(
app, host="0.0.0.0", port=8000,
response_gzip=True, # zlib (universally supported)
response_gzip_min_bytes=512,
response_gzip_level=6, # 1 (fastest) - 9 (best)
response_brotli=True, # ~15-20% smaller text vs gzip
response_brotli_quality=4, # 0-11
response_zstd=True, # ~10× faster decompression
response_zstd_level=3, # 1-22
request_decompression=True,
max_request_body=10 * 1024 * 1024,
)CLI equivalent:
saltare app:app --response-gzip --response-brotli --response-zstd \
--request-decompression --max-request-body 10485760Negotiation: saltare parses Accept-Encoding, drops q=0 tokens, expands * per RFC 7231 §5.3.4, and picks the best encoder both enabled at startup and offered with q>0. Server-side preference within an equal client-q tier is br > zstd > gzip (br compresses tightest for text, zstd is fastest, gzip is the universal fallback). When libbrotlienc / libzstd aren't present in the image the encoder call falls through to identity — the response is sent raw, the client gets a still-valid (uncompressed) body.
What gets compressed on the response side:
- Response is single-shot (gzip also supports streaming; brotli and zstd are single-shot only in v1.4 — streaming-encode for those is v1.4.x).
- Content-Type must be in the compressible set:
text/*,application/json,application/javascript,application/xml,application/xhtml+xml,application/atom+xml,application/rss+xml,application/x-javascript,image/svg+xml. Binary types (PNG/MP4/WOFF2) compress poorly and are skipped. - Body must be ≥
response_gzip_min_bytes(default 512) and the encoded result must be smaller than the raw body. - App must not have already set
Content-Encoding.
When all conditions hold, saltare drops the app's Content-Length, encodes the body, emits the new Content-Length, adds Content-Encoding: <enc> and Vary: Accept-Encoding. The app sees no API change.
Streaming gzip (v1.4): when more_body=True on the first chunk and gzip negotiated, saltare initializes a zlib.compressobj(level, DEFLATED, 31) carried across _send calls. Intermediate chunks flush with Z_SYNC_FLUSH; the final chunk uses Z_FINISH to write the gzip trailer (CRC + isize). Decompressors see decoded bytes promptly — works with SSE, file downloads, multi-MB JSON streams. Brotli / zstd streaming-encode is deferred (per-state encoder objects across _send calls; analogous design but more code surface).
Request decompression:
- Triggered when
Content-Encoding: gzipis the sole encoding (case-insensitive). Other encodings pass through unchanged. - Only fires for non-streaming bodies (full body buffered before dispatch). Streaming gzipped uploads are passed raw — the streaming path sees compressed bytes.
- The decompressed body is capped at
max_request_body; over-cap returns413 Payload Too Largeimmediately. Zip-bomb defense — a 1 KiB gzipped payload that decompresses to 1 GiB never makes it past the dispatcher. - The
Content-Encodingheader is stripped fromscope["headers"]after decompression.
Two cheap caps round out RFC 7230 status codes for malformed clients:
max_request_uri(default 8192 bytes; 0 disables) — request-line target longer than the cap returns414 URI Too Long. Cheaper than letting a multi-KiB target hit the routing table.max_request_head_bytes(default 0 / pool-buffer ceiling; non-zero tightens) — total head-section bytes (request line + all headers + CRLFs up to and including the blank line) past the cap returns431 Request Header Fields Too Large. The implicit ceiling is the read-buffer (~16 KiB small / 64 KiB after upgrade); this knob lets operators tighten that further.
saltare app:app --max-request-uri 4096 --max-request-head-bytes 8192Two behaviours worth knowing (v1.12):
- A rejected head is reported, not reset. Both caps trip while the client is still sending, so saltare drains a bounded amount of the leftover request before closing — otherwise the kernel answers RST, the RST discards the response, and the client sees "connection reset" instead of the status naming the problem. The drain is capped by both a byte budget (64 KiB) and a wall-clock timer (1 s), so a peer that will not stop sending cannot hold the connection.
- Which code you get. A target past
max_request_uriis414. A target too long to buffer never reaches that check and is reported as431, since 414 is only reachable once the head parses. Exceedingmax_headers(32 fields, countingHost) is also431— RFC 6585 §5 — rather than a 400 for malformed syntax.
Off by default. When --traceparent-propagation is on, saltare reads incoming traceparent and tracestate headers, surfaces them on scope["traceparent"] / scope["tracestate"] (ASGI extension keys), and echoes traceparent back on every response. ~30 bytes per request when on; zero work when off.
saltare.run(app, host="0.0.0.0", traceparent_propagation=True)
# In the ASGI app:
async def handler(scope, receive, send):
tp = scope.get("traceparent") # str | None
# ... use with OpenTelemetry SDK or a downstream HTTP callThe format is not validated (32-hex trace-id + 16-hex span-id + flags per W3C). Invalid values pass through unchanged so the app can decide.
When --latency-histogram is on, /metrics emits a standard Prometheus histogram for request wall-clock latency:
# HELP saltare_request_duration_seconds Wall-clock request latency, in seconds.
# TYPE saltare_request_duration_seconds histogram
saltare_request_duration_seconds_bucket{le="0.001"} 12
saltare_request_duration_seconds_bucket{le="0.005"} 89
…
saltare_request_duration_seconds_bucket{le="60"} 1024
saltare_request_duration_seconds_bucket{le="+Inf"} 1024
saltare_request_duration_seconds_sum 4.213701
saltare_request_duration_seconds_count 1024
14 fixed buckets cover 1 ms..60 s. Cost per worker: 14 × u64 counters = 112 bytes plus 16 bytes of _sum accumulator = ~128 B steady-state. Off by default — counter-only /metrics keeps the previous footprint.
saltare.run(
app,
max_concurrent_connections=1024, # accepted sockets held open at once
max_keepalive_requests=1000, # requests per keep-alive conn before close
max_request_body=1024 * 1024, # bytes; oversize gets 413
)CLI flags: --max-concurrent-connections, --max-keepalive-requests, --max-request-body. Defaults match the values above. Expect: 100-continue is honoured automatically (the interim response is written before the body is read, except when the declared Content-Length already exceeds max_request_body — in which case the client gets a 413 directly). In v0.13 the read buffer (16 KiB) is the practical hard ceiling for max_request_body; request-body streaming for larger bodies lands in a follow-up.
saltare.run(
app,
max_connections_per_ip=50, # TCP-RST over-cap peers (defends DDoS)
max_connection_lifetime=3600, # force-close after N seconds
rate_limit_per_sec=100, # per-IP token bucket (0 = disabled)
rate_limit_burst=200,
)max_connections_per_ip shares the per-IP table with the rate limiter (4096-entry LRU); over-cap peers get a TCP-level RST at accept time before any HTTP work happens. max_connection_lifetime (seconds) is a wall-clock cap — stricter than max_keepalive_requests for clients that keep a connection open for hours. Both default to 0 (disabled). When proxy_headers=True, the rate limiter uses X-Real-IP / X-Forwarded-For instead of the TCP peer; when proxy_protocol=True, it uses the source from the PROXY header.
HEAD /path returns the same headers as GET /path but no response body, per RFC 7230 §3.3.3. saltare detects HEAD in the dispatcher and suppresses body bytes the app emits (the app itself doesn't have to special-case HEAD). Transfer-Encoding: chunked is forced off for HEAD (no body to chunk). Working as expected — no flag.
Dev-only file watcher: parent process supervises a saltare child, polls the configured directories for *.py mtime changes (default 0.5 s), and on change does SIGTERM → drain → respawn. Same shutdown path as production, so the new child starts on a clean socket.
saltare myapp:app --reload
saltare myapp:app --reload --reload-dir src --reload-dir lib
saltare myapp:app --reload --reload-include '*.py' --reload-include '*.toml'
saltare myapp:app --reload --reload-exclude '*/migrations/*' --reload-poll-secs 1.0Behaviour:
--workers > 1is auto-coerced to 1 — the reloader and the pre-fork supervisor can't share a listen socket.- A syntax-error crash in the child does not restart-loop. The supervisor waits for the next file change before respawning.
- Default excludes already cover
__pycache__,.git,.venv,node_modules,.pytest_cache,.mypy_cache,.ruff_cache,.pyc. Override with--reload-exclude(repeatable). - The watcher is poll-based (no
inotifydep) so it works the same in containers, on overlayfs / NFS / 9p mounts, and across rename storms duringgit checkout. - Each respawn first deletes
__pycache__directories under the watched roots. saltare'sPYTHONOPTIMIZE=2re-exec writes.opt-2.pyckeyed by second-resolution source mtime; without the purge an edit within 1 s of the previous import would leave the new child running stale bytecode.
Implementation: src/saltare/_reload.py. Production deployments should run without --reload and let your supervisor (systemd, k8s) handle restart on crash.
saltare.run(
app,
ssl_certfile="/path/to/server.crt",
ssl_keyfile="/path/to/server.key",
ssl_ca_file="/path/to/ca.pem",
ssl_verify_client=True,
)ssl_ca_file loads the CA bundle clients must present a cert from; ssl_verify_client=True flips OpenSSL into SSL_VERIFY_PEER | SSL_VERIFY_FAIL_IF_NO_PEER_CERT — connections without a valid client cert are rejected at handshake. Useful for zero-trust deployments and service-to-service auth.
proxy_headers=True parses, in order of preference:
- RFC 7239
Forwarded: for=...;proto=...;host=...— modern standard, used by some proxies. - nginx
X-Real-IP— single client IP. - legacy
X-Forwarded-For— comma-separated chain (leftmost = client).
Plus Forwarded: ...;host= or X-Forwarded-Host populates scope["server"] so apps see the public hostname:port instead of the raw listen address. Only enable behind a trusted reverse proxy.
RFC 6455 §5.4 fragmented messages (FIN=0 first frame + 0..N continuation frames + FIN=1 final continuation) are reassembled per-connection up to a 1 MiB cap. Apps see one websocket.receive event with the full payload — no special handling required.
kill -USR1 $(pidof saltare)→ JSON line on stderr withopen_conns,in_flight,requests_total,rss_kib,rl_table_size,draining.PYTHONFAULTHANDLER=1set automatically in CLI re-exec → CPython prints stack on segfault / SIGABRT.- Process visible in
ps/top/htopassaltare(single-worker),saltare:master, orsaltare:wkrN(multi-worker). /metricsendpoint exposessaltare_health_stategauge (0 = healthy, 1 = draining).
saltare.run(
app,
metrics_path="/metrics", # Prometheus text from Zig counters
health_path="/healthz", # 204 No Content from Zig — k8s probe friendly
favicon_204=True, # GET /favicon.ico → 204 from Zig (skip Python)
cors_preflight_allow_all=True, # OPTIONS w/ Origin → permissive CORS, no Python
rate_limit_per_sec=100, # per-IP token-bucket rate cap (0 = disabled)
rate_limit_burst=200, # burst ceiling per IP (default 100)
max_connections_per_ip=50, # per-IP open-connection cap (0 = disabled)
tracemalloc_path="/debug/tracemalloc",
access_log=True,
access_log_path="/var/log/saltare/access.log", # file instead of stderr
proxy_headers=True,
request_id_header="X-Request-ID", # auto-gen + scope["x-request-id"] + response hdr
server_timing=True, # `Server-Timing: total;dur=<ms>` on every response
uds_path="/run/saltare.sock",
)CLI flags: --metrics-path, --health-path, --favicon-204, --cors-preflight-allow-all, --rate-limit-per-sec, --rate-limit-burst, --max-connections-per-ip, --tracemalloc-path, --access-log, --access-log-path, --proxy-headers, --request-id-header, --server-timing, --uds PATH, --listen-backlog, --tcp-keepidle, --tcp-keepintvl, --tcp-keepcnt, --proxy-protocol. All off by default. The Zig-side intercepts (metrics, health, favicon, CORS preflight, tracemalloc) skip the Python dispatch entirely.
When saltare sits behind an L4 LB that won't add HTTP headers (AWS NLB / ALB, GCP TCP LB, HAProxy mode tcp), the TCP peer is the LB, not the real client — X-Forwarded-For doesn't exist at this layer. Pass proxy_protocol=True (--proxy-protocol) and saltare auto-detects either:
- v1 (text):
PROXY <TCP4|TCP6|UNKNOWN> <src> <dst> <sport> <dport>\r\n— what HAProxy 1.x emits. - v2 (binary): 12-byte signature
\r\n\r\n\0\r\nQUIT\n+ 4-byte header + variable address block — what AWS NLB/ALB and modern HAProxy emit by default.
Saltare reads the appropriate header at every accept, uses the source as the rate-limit / access-log key, then proceeds to TLS or HTTP. Connections that don't begin with a valid PROXY header are closed.
When invoked under systemd with a .socket unit, saltare auto-detects LISTEN_FDS=1 + LISTEN_PID=$$ and inherits fd 3 instead of binding the host:port. Drop-in for zero-downtime reload via systemctl reload:
# /etc/systemd/system/saltare.socket
[Socket]
ListenStream=0.0.0.0:8000
[Install]
WantedBy=sockets.target# /etc/systemd/system/saltare.service
[Service]
ExecStart=/usr/bin/saltare main:app --workers 4
Environment="MALLOC_ARENA_MAX=1"kill -USR1 $(pidof saltare) makes saltare emit a single JSON line on stderr:
{"event":"saltare.stats","open_conns":47,"in_flight":3,"requests_total":18432,"rss_kib":48132,"rl_table_size":124}
Useful in production for snapshotting state without an HTTP probe.
rate_limit_per_sec enables a per-IP token-bucket implemented in Zig: each peer IP gets rate_limit_burst tokens, refilled at rate_limit_per_sec per second up to the burst ceiling. Each request consumes one token; over-rate IPs get a 429 Too Many Requests from Zig before the Python app sees the request. The tracking table is bounded at 4096 IPs; once full, the oldest entry evicts. Disabled (default) costs nothing — a single if (rate_limit_per_sec > 0) per request. UDS connections are not rate-limited (no peer IP).
tracemalloc_path auto-calls tracemalloc.start(25) at server init and serves a top-30 snapshot at the given path:
# top 30 allocations (group: lineno)
542.3 KiB 8 blocks /opt/.../pydantic/_internal/_model_construction.py:204
213.7 KiB 91 blocks /opt/.../starlette/routing.py:97
...
Tracking has CPU + RAM cost (5–10% RSS depending on app). Don't leave it on in production permanently — flip the flag, scrape once, flip off (requires a process restart).
Pass an IPv6 address (with or without brackets) as host. saltare auto-detects v6 by the presence of a colon and creates an AF_INET6 socket with IPV6_V6ONLY=1 set:
saltare.run(app, host="::", port=8000) # all v6 interfaces
saltare.run(app, host="[::1]", port=8000) # v6 loopbackFor dual-stack (v4 + v6) listeners run two saltare processes — IPV6_V6ONLY=1 is set explicitly because the kernel default varies by distro.
Metrics endpoint exposes:
saltare_open_connections gauge – active TCP/UDS sockets
saltare_in_flight_requests gauge – HTTP requests being dispatched right now
saltare_requests_total counter – HTTP requests dispatched since startup
saltare_responses_4xx_total counter
saltare_responses_5xx_total counter
saltare_bytes_sent_total counter
saltare_bytes_received_total counter
saltare_process_resident_memory_bytes gauge – RSS from /proc/self/status (Linux)
The metrics_path request is answered entirely from Zig — your ASGI app never sees it.
Access log format (one JSON line per completed request, to stderr):
{"method":"GET","path":"/users/42","status":200,"bytes":318,"latency_us":1234,"user_agent":"curl/8.0"}
Stack-buffered, JSON-escaped, single write(2) per line so concurrent workers don't interleave.
Proxy headers: X-Real-IP (single client IP, nginx convention; takes precedence) or X-Forwarded-For (comma-separated chain; leftmost address) → scope["client"], plus X-Forwarded-Proto (http/https → scope["scheme"]). Only enable behind a proxy that strips client-supplied X-Forwarded-* headers, otherwise clients can spoof their identity.
Drop-in replacement for Django's wsgiref-based runserver, so local development happens under the same ASGI core as production. See examples/django.md for the full walkthrough.
pip install 'saltare[django]'# settings.py
INSTALLED_APPS = [
# ...
"django.contrib.staticfiles",
"saltare.contrib.django", # AFTER staticfiles
]python manage.py runserver
# saltare 1.4.0 (ASGI) — Django 5.1.0
# Listening on http://127.0.0.1:8000/Autoreload, --noreload, and STATIC_URL (via ASGIStaticFilesHandler in DEBUG) all keep working unchanged. ASGI app resolution honours SALTARE_ASGI_APPLICATION → ASGI_APPLICATION → django.core.asgi.get_asgi_application(). Saltare-specific dev flags: --workers N (pre-fork; disables reloader), --access-log, --proxy-headers.
The integration is dev-only — production deployments call the saltare CLI directly against myproject.asgi:application; no Django dependency needed at runtime.
Dev / debugging probe. JSON snapshot of the dispatcher's runtime state — same fields as the SIGUSR1 stats dump but reachable via HTTP. No GIL is acquired during the response, so a deadlocked Python dispatcher still answers the probe.
saltare app:app --dispatch-path /debug/dispatch
curl http://127.0.0.1:8000/debug/dispatch
# {"open_conns":3,"in_flight":1,"requests_total":847,"responses_4xx":2,
# "responses_5xx":0,"bytes_sent":124871,"bytes_received":18432,
# "rl_table_size":12,"draining":0,"rss_bytes":48472064}Off by default. Recommend gating behind a sidecar / network policy in production — it leaks no secrets but does expose RSS + connection counts. For added defense-in-depth, set a Bearer-token gate:
# CLI flag (visible in `ps aux` — fine for dev, avoid for prod)
saltare app:app --dispatch-path /debug/dispatch --dispatch-token s3cr3t
# Env var (preferred for prod — secret stays out of process listing)
SALTARE_DISPATCH_TOKEN="$(openssl rand -hex 16)" \
saltare app:app --dispatch-path /debug/dispatchWithout the matching Authorization: Bearer <token> header the endpoint returns 401. Compare is constant-time.
A subset of Limits / Observability is re-readable from a key=value file on SIGHUP without restarting the process. Useful for canary tuning of rate limits or toggling access-log under load.
Supported keys:
rate_limit_per_secrate_limit_burstmax_connections_per_ipmax_connection_lifetime_secsaccess_log(true/false)
# /etc/saltare/runtime.cfg
rate_limit_per_sec=200
rate_limit_burst=400
access_log=true
# anything else is silently ignored — `# comments` allowedsaltare app:app --runtime-config-path /etc/saltare/runtime.cfg
# … later, after editing the file …
kill -HUP $(pidof saltare)
# stderr: saltare: SIGHUP: applied 3 key(s), 0 unknownUnknown keys + parse errors log a warning and keep the previous value — a typo never crashes the running server. To verify a config push before sending the SIGHUP, dry-run it with:
saltare --check-config /etc/saltare/runtime.cfg
# saltare check-config: 3 key(s) ok in /etc/saltare/runtime.cfg
echo $? # 0 on clean, 1 if any line was malformed / unknown--ktls flips OpenSSL's SSL_OP_ENABLE_KTLS so cipher state is handed to the Linux kernel after handshake. Two consequences:
saltare.sendfileworks on TLS connections. v1.4 returned 500 becausesendfile(2)can't encrypt; with kTLS the kernel applies TLS records on the socket and the syscall just works.- TX-zerocopy on plaintext writes.
SSL_OP_ENABLE_KTLS_TX_ZEROCOPY_SENDFILEremoves one buffer copy per write. Throughput win on large responses.
Requirements:
- OpenSSL ≥ 3.0 at runtime. The flag bit is
0on older OpenSSLs, so silently ignored — saltare falls back to userspace TLS andserveSendfilereturns 500 on HTTPS as before. - Linux ≥ 4.13 for AES-128-GCM, ≥ 5.2 for AES-256-GCM kTLS support. Older kernels: OpenSSL detects the missing kernel feature and itself falls back to userspace.
- Kernel module
tlsavailable (modprobe tlsor built into the kernel; standard on modern distros).
saltare app:app --host 0.0.0.0 --port 8443 \
--ssl-certfile /etc/tls/cert.pem --ssl-keyfile /etc/tls/key.pem \
--ktlsOff by default. When off, behaviour is identical to v1.4 (serveSendfile returns 500 on TLS connections; userspace SSL_write handles the rest).
When any response encoder is enabled (--response-gzip / -brotli / -zstd), /metrics automatically grows four families:
saltare_response_compression_total{encoding="gzip"} 12431
saltare_response_compression_total{encoding="br"} 0
saltare_response_compression_total{encoding="zstd"} 0
saltare_response_compression_bytes_in_total{encoding="gzip"} 18943210
saltare_response_compression_bytes_out_total{encoding="gzip"} 2102983
saltare_response_compression_skipped_total{reason="small_body"} 4892
saltare_response_compression_skipped_total{reason="non_compressible"} 1132
saltare_response_compression_skipped_total{reason="encoder_unavailable"} 0
saltare_response_compression_skipped_total{reason="not_smaller"} 87
Counters live in Zig atomics — the /metrics scrape never acquires the GIL. Operators can validate "is gzip actually doing work?" by computing bytes_in / bytes_out per encoding from the rates.
saltare APP [options]
ASGI app target as 'module:attr' (e.g. 'main:app').
Network
--host HOST bind address (default 127.0.0.1; use :: or [::1] for v6)
--port PORT bind port (default 8000)
--uds PATH bind a Unix domain socket instead
--listen-backlog N listen(2) backlog (default 256)
--workers N number of pre-fork workers (0 = auto-cpu-count, capped at 4)
TLS (lazy-loaded — system libssl needed only when these are passed)
--ssl-certfile PATH TLS certificate (PEM)
--ssl-keyfile PATH TLS private key (PEM)
--ssl-ca-file PATH CA bundle for client cert verification (mTLS)
--ssl-verify-client require + verify client cert (mTLS)
--tls-session-cache-size N OpenSSL session cache size (0 = disabled)
Timeouts (seconds)
--header-timeout SECS accept → headers parsed (default 5)
--keep-alive-timeout SECS idle keepalive (default 5)
--body-timeout SECS headers → body fully received (default 30)
--write-timeout SECS maximum time in writing state (default 30)
--shutdown-timeout SECS graceful drain ceiling on SIGTERM (default 30)
--drain-wait-seconds SECS wait for in-flight requests during drain (default 5)
--ws-keepalive-timeout SECS WebSocket ping interval (default 20)
--ws-pump-interval-ms MS asyncio pump cadence for live WS connections (default 50)
WebSocket compression (v1.9)
--ws-compression-level N per-message-deflate compression level (default 6)
--ws-compression-server-takeover allow server-side sliding window across messages (default: client_no_context_takeover only)
TCP tuning
--tcp-keepidle SECS seconds idle before kernel keepalive probe
--tcp-keepintvl SECS seconds between keepalive probes
--tcp-keepcnt N unanswered probes before drop
--tcp-user-timeout-ms MS TCP_USER_TIMEOUT (Linux)
--tcp-fastopen-qlen N TCP_FASTOPEN queue length (Linux ≥ 3.7)
Resource caps
--max-concurrent-connections N accepted sockets held open (default 1024)
--max-keepalive-requests N requests per connection before close (default 1000)
--max-request-body BYTES oversize body → 413 (default 1 MiB)
--http-pool-max N pooled HTTP state objects per worker (0 = no pool, default 128)
--max-connections-per-ip N per-IP open connection cap (0 = disabled)
--max-connection-lifetime SECS wall-clock connection age cap (0 = disabled)
--rate-limit-per-sec N per-IP token-bucket rate (0 = disabled)
--rate-limit-burst N burst ceiling (default 100)
--auto-raise-nofile raise soft RLIMIT_NOFILE to hard at startup
Observability + Zig-side intercepts (no Python dispatch when matched)
--metrics-path PATH Prometheus text from Zig counters
--health-path PATH k8s-style probe → 200 'ok' from Zig
--tracemalloc-path PATH top-30 Python alloc dump (Linux/CPython)
--favicon-204 GET /favicon.ico → 204 from Zig
--cors-preflight-allow-all OPTIONS+Origin → permissive CORS from Zig
Request / response shaping
--access-log one JSON line per request to stderr
--access-log-path FILE route the JSON log to a file instead
--proxy-headers parse X-Forwarded-* / X-Real-IP into scope + rate-limit
--proxy-protocol HAProxy PROXY-protocol v1 + v2 at every accept
--request-id-header NAME auto-generate request ID + scope key + response header
--server-timing emit `Server-Timing: total;dur=<ms>` per response
--server-header VALUE override `Server:` (empty string omits the header)
--hsts-max-age SECS emit Strict-Transport-Security max-age (0 = off)
--hsts-include-subdomains add includeSubDomains to the HSTS line
--hsts-preload add preload to the HSTS line
Operational
--startup-request issue an internal GET / after lifespan startup (warm app)
--drain-path PATH POST/PUT flips the worker into graceful drain, GET probes it
--gc-collect-every-n-requests N periodic gc.collect(0) cadence (0 = disabled)
--version print saltare version
Development (v1.4)
--reload autoreload: poll watch dirs + SIGTERM/respawn on change
--reload-dir DIR watch directory (repeatable; default: cwd)
--reload-include GLOB fnmatch glob to include (repeatable; default: '*.py')
--reload-exclude GLOB fnmatch glob to exclude (repeatable)
--reload-poll-secs SECS poll interval (default 0.5)
Operational depth (v1.5)
--dispatch-path PATH JSON dispatch-state snapshot endpoint
--dispatch-token TOKEN Bearer-token gate on --dispatch-path (also reads SALTARE_DISPATCH_TOKEN env)
--runtime-config-path FILE key=value file re-read on SIGHUP for hot config swap
--check-config FILE dry-run validate a runtime-config-path file (exit 0=ok, 1=fail)
--ktls enable OpenSSL kTLS (sendfile-over-HTTPS; needs OpenSSL>=3.0 + Linux>=4.13)
HTTP/2 (v1.9)
--http2 enable ALPN h2 on TLS handshakes (HTTP/2 dispatch via existing ASGI path)
Compression (v1.4; lazy dlopen — libs only loaded when flags are on)
--response-gzip negotiate Accept-Encoding: gzip (single-shot + streaming)
--response-gzip-min-bytes N skip gzip below N bytes (default 512)
--response-gzip-level N zlib level 1-9 (default 6)
--response-brotli negotiate Accept-Encoding: br (single-shot; needs libbrotli)
--response-brotli-quality N brotli quality 0-11 (default 4)
--response-zstd negotiate Accept-Encoding: zstd (single-shot; needs libzstd)
--response-zstd-level N zstd level 1-22 (default 3)
--request-decompression decompress request bodies with Content-Encoding: gzip
Hardening (v1.4)
--max-request-uri N reject targets > N bytes with 414 (default 8192; 0 disables)
--max-request-head-bytes N reject head sections > N bytes with 431 (0 = pool-buffer ceiling)
Observability extras (v1.4)
--latency-histogram Prometheus saltare_request_duration_seconds_* on /metrics
--traceparent-propagation W3C Trace Context on scope + echo on response
Same flags are available on the saltare.run() Python API — the kwarg names match the CLI flags with hyphens replaced by underscores.
Before pointing real traffic at saltare, walk this list:
- Soak: run the bench harness with your real ASGI app for ≥ 30 min at expected concurrency. RSS should stabilise within a few MiB of the idle baseline. Drift > 10 MiB/h = leak.
- Memory leak check:
make valgrindbuilds the tester image and runs pytest undervalgrind --leak-check=full. CI doesn't gate this — run it locally before tagging. - Connection cap: pick
max_concurrent_connectionsthat fits your container memory budget. Saltare auto-tunes from the cgroup limit if you don't set it (~50 KiB/conn budget after a 64 MiB Python floor). - TLS: if terminating TLS in saltare (vs nginx in front), set
ssl_certfile+ssl_keyfileand verify cert chain viaopenssl s_client -connect host:port. - Rate limit: enable
--rate-limit-per-secmatched to your downstream-service capacity, not your saltare capacity. Saltare's bucket is the last rate limit before the app. - Access log: enable
--access-log(stderr) or--access-log-path(file). Without it, post-mortems on individual requests are impossible. -
/metrics+ alerting: scrape--metrics-pathfrom Prometheus and set alerts onsaltare_responses_5xx_totalrate,process_resident_memory_bytesceiling, andsaltare_health_state(= 1 means draining). -
/debug/dispatchtoken: when exposing the dispatch endpoint outside the pod's network namespace, set--dispatch-tokento a long random string. -
SIGHUPconfig: if you'll need to tune rate limits without restart, set--runtime-config-pathand document the file in your runbook. - Shutdown grace:
--shutdown-timeoutshould match k8sterminationGracePeriodSecondsminus a few seconds. Default 30 s. -
MALLOC_ARENA_MAX: set to1(single worker) or2(multi). Already inDockerfile.production. - Workers count: start with
min(cpu_count, 4). Higher inflates per-conn Pss without throughput gain under GIL-locked dispatch.
For a typical FastAPI deployment behind a reverse proxy:
saltare myapp:app \
--host 0.0.0.0 --port 8000 \
--workers 4 \
--proxy-headers \
--shutdown-timeout 25 \
--max-keepalive-requests 10000 \
--max-connection-lifetime 3600 \
--tcp-keepidle 60 --tcp-keepintvl 30 --tcp-keepcnt 4 \
--metrics-path /metrics --access-log \
--health-path /healthz \
--rate-limit-per-sec 200 --rate-limit-burst 400 \
--max-request-uri 8192 --max-request-head-bytes 32768 \
--latency-histogram \
--traceparent-propagation \
--response-gzipFor an Alpine / distroless container, swap Dockerfile.production for one based on python:3.14-alpine — saltare ships musllinux wheels.
| Symptom | First check | Likely fix |
|---|---|---|
| RSS growing unbounded | curl /debug/dispatch — in_flight ≫ open_conns? |
dispatcher backlog; check --max-concurrent-connections, look at app's downstream call latency |
| 5xx spike | saltare_responses_5xx_total rate; recent deploy? |
rollback or check app exception logs (saltare prints exceptions via _print_exception_lazy) |
| Latency p99 spike | saltare_request_duration_seconds_bucket (needs --latency-histogram) |
which bucket? > 1 s typically a downstream stall, not saltare |
Connection refused from clients |
saltare_open_connections near max_concurrent_connections |
bump cap or scale horizontally |
| Slow shutdown | logs show saltare draining for tens of seconds |
--shutdown-timeout lower, or app lifespan.shutdown hook is blocking |
| Compression doing nothing | saltare_response_compression_total{encoding="gzip"} near 0; ..._skipped_total{reason="non_compressible"} high |
check Content-Type whitelist; PNG/MP4/WOFF2 bodies don't compress |
kill -HUP does nothing |
stderr should print saltare: SIGHUP: applied N key(s) |
check --runtime-config-path is set; check file permissions |
workers=1 (the default) is one process serving all traffic. For multi-core machines, set workers to roughly min(cpu_count, 4) as a starting point. Pre-fork CoW + gc.freeze() mean each additional worker costs only ~5 MiB of physical RAM on top of the single-worker baseline — measured at 4 workers = 51 MiB Pss, vs ~150 MiB if every worker were independent (see Benchmarks).
saltare main:app --host 0.0.0.0 --port 8000 --workers 4The master process binds + listens once and forks the workers; the kernel load-balances accept() across them. A worker exiting unexpectedly causes the master to propagate shutdown to the rest and exit — your pod supervisor then restarts the whole thing. v1.0 deliberately doesn't respawn within the master; that's the supervisor's job.
The recommended production image is the v1.5 Alpine variant — see
Dockerfile.production (make production-image).
It uses python:3.14-alpine, installs the saltare musllinux wheel,
preloads mimalloc, and runs under tini for clean signal forwarding.
Image size lands around 60 MiB total.
In a container you get -OO for free, because the entry point is the
saltare CLI and it re-execs itself. If you instead bake your own
application image around saltare.run(), add
optimize_process() to the
entry point, or set PYTHONOPTIMIZE=2 in the ENV — the docstring
saving is ~1.4 MiB and costs nothing.
If you need to run saltare on a glibc base instead (CentOS / RHEL / manylinux-style), the pre-Alpine knobs still apply:
# glibc-only: cap per-thread malloc arenas. musl ignores this.
export MALLOC_ARENA_MAX=2
# Both libc flavours: preload an alternative malloc. Typical extra
# saving on top of glibc default + arena cap: 5–15 MiB.
# Alpine (musl) path: /usr/lib/libmimalloc.so.2
# glibc/manylinux path: /usr/lib64/libmimalloc.so.2
export LD_PRELOAD=/usr/lib/libmimalloc.so.2
# Conservative fd limit if you're not behind a reverse proxy that
# already rate-limits accept(). Alpine's default ulimit is usually
# already 1024 — bump explicitly.
ulimit -n 65535The Alpine production image bakes the LD_PRELOAD line. MALLOC_ARENA_MAX
is not set in the Alpine image because musl libc has no per-thread
arenas to cap — it's a glibc-specific knob. If you switch the image to a
glibc base, re-add it.
gc.freeze() runs in the master right before the fork loop so
already-imported modules don't dirty CoW pages on each worker's first GC
sweep. For this to work, every module the app needs must be imported in
the master before the fork. FastAPI's deferred-import patterns are the
common gotcha: a route handler that does import heavy_dep lazily at
first request will dirty 500+ KiB of pages in every worker
independently, killing the CoW saving.
Pattern: do all imports at module top-level, and exercise the heavy paths
once during lifespan.startup so any deferred initialisation
(connection pools, JIT caches) is materialised in the master:
from fastapi import FastAPI
import heavy_dep
import asyncpg
@asynccontextmanager
async def lifespan(app: FastAPI):
# Force imports + warm caches in the master, before the fork.
pool = await asyncpg.create_pool(...)
app.state.pool = pool
# Touch any lazy initialisers so the cost lands in master's RSS,
# not each worker's.
heavy_dep.warm_up()
yield
await pool.close()
app = FastAPI(lifespan=lifespan)After lifespan.startup, saltare calls malloc_trim(0) to return
fragmented heap to the OS, then gc.freeze()s the surviving objects
before forking — workers only allocate dirty pages for their own
per-request state.
[Service]
Environment="MALLOC_ARENA_MAX=2"
LimitNOFILE=65535
ExecStart=/usr/bin/saltare main:app \
--host 0.0.0.0 --port 8000 \
--workers 4 \
--metrics-path /metrics --access-log
KillSignal=SIGTERM
TimeoutStopSec=35
Restart=on-failureTimeoutStopSec should be a couple of seconds higher than --shutdown-timeout (default 30 s) so systemd doesn't escalate to SIGKILL while saltare is still draining.
apiVersion: apps/v1
kind: Deployment
spec:
template:
spec:
terminationGracePeriodSeconds: 35
containers:
- name: api
image: your-image
env:
- name: MALLOC_ARENA_MAX
value: "2"
args:
- "--workers=4"
- "--metrics-path=/metrics"
- "--access-log"
- "--proxy-headers"
ports:
- containerPort: 8000
readinessProbe:
httpGet:
path: /healthz # your app's endpoint
port: 8000
# Prometheus pulls /metrics from each pod individually. With
# --workers > 1 each scrape may land on a different worker, so
# configure Prometheus to sum across pods and treat per-pod
# counters as samples.saltare honours SIGTERM with a graceful drain (--shutdown-timeout, default 30 s): in-flight requests get to finish, lifespan.shutdown runs, then the process exits 0.
saltare main:app --uds /run/saltare.sock --workers 4upstream saltare {
server unix:/run/saltare.sock;
}
server {
location / {
proxy_pass http://saltare;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
}
}Pair with --proxy-headers so saltare reads X-Forwarded-For / X-Forwarded-Proto into scope["client"] / scope["scheme"] instead of seeing nginx as the client.
malloc_trim(0)afterlifespan.startupreturns 1–3 MiB of glibc heap fragmentation (FastAPI/Pydantic imports) to the OS.- Idle pool buffers older than 30 s get
MADV_DONTNEEDso RSS recovers after traffic peaks. - App exceptions during dispatch are caught: pre-
response.startraises become a 500; mid-stream raises close the connection. Workers keep serving. - WebSocket connections get server-side ping/pong every 20 s (configurable); silent dead WS sockets are reaped at 2× that window.
Easiest dev loop. saltare's build pipeline (scikit-build-core → CMake → Zig) needs two things on your machine:
- Zig 0.16+
- Python development headers (
Python.h)
OpenSSL development headers are not required. src/zig/tls.zig declares OpenSSL as opaque {} and resolves every entry point through a lazy dlopen + dlsym table at first serve(ssl_certfile=...) call, hard-coding the ABI constants it needs instead of including <openssl/ssl.h>. A machine without OpenSSL headers builds the wheel fine; it just cannot serve TLS at runtime until libssl is installed.
# Debian/Ubuntu
sudo apt install python3-dev cmake build-essential
# Fedora/RHEL/Rocky
sudo dnf install python3-devel cmake gcc
# Zig: pinned 0.16.0 tarball, both archs handled
bash scripts/install-zig.shbrew install zig
# Python headers come with Homebrew Python or python.org installers.
uv sync
uv pip install -e .
.venv/bin/python -m pytest -qA kqueue backend for macOS exists in the source tree but is not released:
there is no macOS wheel and no macOS CI job. See
CHANGELOG.md for the reasoning — a hosted macOS runner
builds the wheel but cannot pass the suite, and test_macos was a gate on
publish, so shipping it would have meant shipping red.
If you are on Linux and touching platform-specific Zig code, you do not need a Mac to catch the obvious breakage:
make check-macos # cross-compile-checks 17 modules against aarch64-macosIt skips module.zig and server.zig, which need a macOS Python.h; with
no macOS runner in the matrix those two are unverified until someone builds
on a Mac. See AGENTS.md for how to force server.zig through
the check anyway.
Then:
uv sync # installs the build backend + the test deps
uv pip install -e . # builds the extension in place (~6 s)
.venv/bin/python -m pytest -quv sync is enough on its own: the [dependency-groups] dev group carries scikit-build-core and ninja alongside the test dependencies. That is deliberate — [build-system].requires is not a project dependency, so without the group a sync prunes the build backend and the next uv pip install -e . has nothing to build with. uv sync does not install extras, which is why the test deps live in the group and not only in the dev extra.
If uv pip install -e . errors with zig was not found on PATH, your Zig install didn't end up in PATH — bash scripts/install-zig.sh symlinks /usr/local/bin/zig for you. The Docker pipeline (make build) sidesteps the host toolchain entirely by running everything inside the manylinux container.
If you don't want Zig on the host (CI-style builds):
./scripts/build-wheel.sh
# -> dist/saltare-0.1.0-cp312-cp312-manylinux_2_28_x86_64.whlThis invokes Dockerfile, which:
- Pulls
quay.io/pypa/manylinux_2_28_x86_64. - Downloads pinned Zig (
scripts/install-zig.sh). - Builds the wheel and runs
auditwheel repair. - Exports
dist/*.whlto the host.
Override target via env: PYTHON_TAG=cp310-cp310 MANYLINUX_TAG=manylinux_2_28_aarch64 ./scripts/build-wheel.sh.
Tag a version and push:
git tag v0.1.0 && git push origin v0.1.0.github/workflows/release.yml runs cibuildwheel on Linux (x86_64 + aarch64, manylinux + musllinux), builds the sdist, and publishes to PyPI via Trusted Publishing.
.
├── build.zig # Zig build script (produces _core extension)
├── build.zig.zon # Zig package manifest
├── CMakeLists.txt # scikit-build-core invokes Zig from here
├── pyproject.toml # build backend + cibuildwheel config
├── Dockerfile # local manylinux+Zig build (builder/tester/bench/export)
├── Dockerfile.production # slim runtime image w/ jemalloc + MALLOC_ARENA_MAX=2
├── Makefile # build / test / bench / valgrind / production-image
├── scripts/
│ ├── install-zig.sh # pin & install Zig (used by Docker + CI)
│ └── build-wheel.sh # one-liner local Docker build
├── src/
│ ├── zig/
│ │ ├── module.zig # Python C-API surface (PyInit__core)
│ │ ├── server.zig # epoll accept loop + per-connection state machine
│ │ ├── eventloop.zig # comptime backend selection (re-exports one)
│ │ ├── eventloop_epoll.zig # epoll backend (Linux)
│ │ ├── eventloop_kqueue.zig # kqueue backend (macOS)
│ │ ├── procstats_darwin.zig # RSS / fd / CPU readers for /metrics
│ │ ├── http.zig # zero-alloc HTTP/1.1 parser + chunked decoder
│ │ ├── pool.zig # 4 KiB / 16 KiB read-buffer free-lists + MADV_DONTNEED
│ │ ├── timer.zig # hashed timer wheel for idle timeouts
│ │ ├── tls.zig # OpenSSL wrapper (handshake, read/write, pending)
│ │ ├── ws.zig # WebSocket framing (RFC 6455)
│ │ ├── master.zig # pre-fork multi-worker supervisor
│ │ └── bridge.zig # GIL-aware Python <-> Zig request dispatch
│ └── saltare/
│ ├── __init__.py # public Python API: run(), __version__
│ ├── cli.py # `saltare app:app --host ... --port ...`
│ ├── _dispatcher.py # asyncio loop + ASGI scope build / lifespan / WS
│ ├── __main__.py
│ └── _core.pyi # type stubs for the native module
├── benchmarks/ # `make bench` harness — saltare vs uvicorn vs granian
│ ├── app.py # shared FastAPI app (small + /large endpoint)
│ ├── bench.py # workload runners + Markdown table renderer
│ ├── run_saltare.py # single-worker / multi-worker saltare launcher
│ ├── run_uvicorn.py # plain uvicorn launcher (no [standard] extras)
│ └── run_granian.py # Rust+Python ASGI peer for triangulation
├── tests/ # pytest suite (HTTP, keepalive, chunked, lifespan,
│ │ # TLS, WebSocket, timeouts, multi-worker, shutdown,
│ │ # observability)
│ └── valgrind.supp # CPython-side leak suppressions for `make valgrind`
└── .github/workflows/
└── release.yml # cibuildwheel + PyPI publish on tag
MIT