Skip to main content

Low-RAM ASGI HTTP server with a Zig backbone.

Project description

saltare

Low-RAM ASGI HTTP server with a Zig backbone. An alternative to uvicorn for FastAPI deployments where memory budget matters more than raw throughput.

Install

pip install saltare

Linux x86_64 / aarch64, manylinux + musllinux 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.

Quickstart

1. Write an ASGI app

# main.py
from fastapi import FastAPI

app = FastAPI()


@app.get("/")
def root():
    return {"hello": "world"}

2a. Run from the command line

saltare main:app --host 0.0.0.0 --port 8000

A 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-encoding

saltare --help prints every flag (there are many — most are opt-in and cost zero RAM when off).

2b. Run programmatically from Python

# server.py
import saltare
from main import app

if __name__ == "__main__":
    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.py

3. HTTPS

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.

4. Access-log line format (v1.6.1+)

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

Status: 1.11.0 — real HTTP/2 responses + outbound flow control, PEP 684 sub-interpreter groundwork, 368 tests pass. HTTP/2 now actually speaks HTTP/2. Through v1.10 http2=True over TLS silently fell back to HTTP/1.1: the TLS layer advertised ALPN with the client-side SSL_CTX_set_alpn_protos (a no-op on a server), so h2 never negotiated, and the dispatch path it hid had a PyObject_CallFunction format string with one too few y# byte-strings that would have segfaulted on the first real request. v1.11 fixes both — server-side SSL_CTX_set_alpn_select_cb (gated on http2=True, now plumbed through _core.serve) and the corrected Oiiy#y#y#y#y#Oy#iOiO marshalling — and adds a real response path: h2_response.zig, an incremental HTTP/1.1→HTTP/2 transcoder that reframes the dispatcher's existing response bytes into HEADERS (HPACK-encoded :status first, names lowercased, hop-by-hop headers dropped per RFC 7540 §8.1.2.2) + DATA frames bounded by the peer's SETTINGS_MAX_FRAME_SIZE, de-frames chunked bodies, and places END_STREAM on the last frame. The previously-dead h2_encoder.zig now backs it. Outbound flow control (RFC 7540 §6.9): the server never sends more DATA than the peer's stream + connection windows allow, holding the rest until a WINDOW_UPDATE grows the window; a stream completes only once END_STREAM has gone out, so flow-control-blocked bodies are never dropped. The server connection preface is now its own SETTINGS frame (was a premature ACK). Verified end to end against the real h2 sans-IO client (GET, POST-with-body, 60 KiB multi-frame, and a 50 KiB body pulled through a 1 KiB window). HTTP/2 multiplexing is still serial (one dispatch in flight per connection). PEP 684 (per-interpreter GIL) groundwork: _core uses multi-phase init, isolates the racy event-loop state into a per-serve Runtime, has a re-entrant serveLoop, and declares per-interpreter-GIL support — a full server runs in an own-GIL sub-interpreter (verified). But the measurement that motivated it refuted its premise — sub-interpreter workers cost more RAM than saltare's gc.freeze() pre-fork model (they can't share the Python object graph), so the worker spawner was not built; fork stays the multi-worker model. Fixes: a spurious -OO re-exec when the project lives under a saltare-named directory ("saltare" in argv[0] substring check → now matches the __main__.py parent dir), and the default Server: header version drifting from the package version (now derived from _core.version() / a single Zig VERSION constant). Test suite 368 passing, including h2-client conformance (incl. flow control) and own-GIL serve. Benchmarks (v1.11.0, mimalloc preload): saltare leads on all workloads — 3.0–9.1 MiB leaner than uvicorn, 12.1–12.9 MiB leaner than granian.

Status: 1.9.0 (historical entry) — HTTP/2 dispatch + Connection HTTP/WS union + WebSocket compression. HTTP/2 dispatch integration (Zig ↔ Python bridge): bridge.http2DispatchStart, bridge.http2DispatchPushBody, bridge.http2DispatchDrain in src/zig/bridge.zig call Python http2_dispatch_start/push_body/drain in _dispatcher.py, which delegate to the existing HTTP/1.1 dispatch path with http_version="2" in the ASGI scope. Internal Zig HTTP/2 framing (src/zig/h2.zig) handles connection preface, SETTINGS, DATA, HEADERS, PING, RST_STREAM, GOAWAY, and WINDOW_UPDATE (request-side parsing; the response side became real in v1.11). Connection HTTP/WS tagged union: all 10 WebSocket-only fields moved into WsState inside a union(Protocol) — HTTP connections pay zero bytes for WS state (~52 KiB saved at 1024 idles). WebSocket per-message-deflate configuration via --ws-compression-level, --ws-compression-server-takeover, and --ws-pump-interval-ms. --http2 flag added to CLI and saltare.run() signature (default False).

Status: 1.8.0 (historical entry) — header memory compression + edge-case coverage. Replaces http.Header's two []const u8 slices (32 B per header) with four u16 offsets into the request buffer (8 B per header). Pool buffer's [max_headers=32]Header array drops from 1 KiB → 256 B; at default max_concurrent_connections=1024 that's ~770 KiB peak RAM reclaimed. Request gains accessor methods (method(), target(), header.nameSlice(data)) so the API stays readable. Test suite grows by 17 → 139 total (tests/test_v18_edge.py): header compression edges (long values, near-max count, empty values), pipelined HTTP, WebSocket binary echo at varied sizes, HSTS combinations, drain endpoint verb matrix, method case-sensitivity, header injection guard.

Status: 1.7.2 (historical entry) — WebSocket lifecycle correctness + test suite. v1.7.1 wired the multi-tick + periodic-pump runtime that made Channels apps work end-to-end; v1.7.2 centralises WS teardown into Connection.destroy() so the leak window on socket-level errors (peer RST mid-write, abrupt TCP FIN) closes — every destroy callsite now emits WS-CLOSE, cancels the Python consumer task via bridge.wsDisconnect, decrements g_ws_conns, and unlinks from g_ws_head. Caches Connection.ws_log_path so the post-upgrade WS-CLOSE line still knows the path after wsAfterWrite cleared conn.parsed. Adds 13 lifecycle tests (tests/test_ws_lifecycle.py) covering close-code → HTTP status mapping, post-accept initial-state push, handshake-timeout cancellation, abrupt-disconnect resilience, access-log symmetry, --ws-reject-log content, and N sequential connect/close cycles. 122 tests pass.

Status: 1.7.1 (historical entry) — Django Channels WebSocket runtime. v1.7.0 fixed the WS scope shape (state, extensions, drop method, proxy_headers for WS); v1.7.1 fixes the WS runtime so Channels apps that work under daphne also work under saltare. Multi-tick pump during the upgrade (Phase 1 lets AuthMiddlewareStack async session lookup settle; Phase 2 lets connect() finish its group_add + initial state push before returning), periodic 50 ms asyncio pump for live WS conns + bridge.wsDrain so channel_layer.group_send actually reaches the wire (was: queued forever because saltare was idle between socket events), WS-task exception traceback surfaced to stderr + injected into --ws-reject-log reason, close-code → HTTP status forwarding (4001 → 401, 4003 → 403, 4004 → 404, 4008 → 408, 4029 → 429). Bench-stage Dockerfile now preloads mimalloc so the comparison vs uvicorn / granian runs under the same allocator. Release CI: 2-job → 4-job matrix (libc × arch) + tests split out → ~7 min wall (was 11–20 min).

Status: 1.7.0 (historical entry) — Django Channels / ASGI 3.0 compliance. v1.6 served WebSocket upgrades but the user-side ProtocolTypeRouter + AuthMiddlewareStack of a Channels app rejected the connect because saltare's scope missed state / extensions (ASGI 3.0), populated client=None on every WS regardless of --proxy-headers, and shipped a non-spec method key. v1.7 closes those: shared lifespan state dict surfaced as scope["state"] on every HTTP + WS scope, empty scope["extensions"] marker, WS path now runs the same _apply_proxy_headers helper as HTTP (so scope["client"] reflects the real peer behind nginx / traefik / k8s ingress, and scope["scheme"] flips to wss when X-Forwarded-Proto says so), method dropped from WS scope.

Status: 1.6.1 (historical entry) — access-log polish + ops affordances. Carries v1.6.0 baseline and adds: --access-log-exclude PATH (repeatable; exact-match request-target filter), new plain-text access-log line format DD/MM/YYYY:HH:MM:SS [METHOD] [URL] [STATUS] [BYTES] (replaces the v0.15 JSON shape — easier to grep / awk), test-isolation fix (_core.request_shutdown() + autouse pytest fixture) closing the cp313-musllinux cibuildwheel segfault race, server.run() resets g_draining=false on entry so stale drain flags don't sink the next worker. 108 tests pass.

Status: 1.6.0 (historical entry) — full compression matrix + WebSocket extensions + operational hardening. Carries v1.5 baseline (musllinux wheels, /debug/dispatch + token, SIGHUP hot reload, process_* metrics, kTLS, runbook) and adds: streaming brotli + streaming zstd (per-codec encoder state across _send via Zig lazy-dlopen handle API — closes the gap where v1.5 only streamed gzip), WebSocket per-message-deflate (RFC 7692; permessage-deflate; client_no_context_takeover; server_no_context_takeover negotiated; RSV1 + raw-deflate framing on outbound text/binary; zip-bomb-capped inflate on inbound), and the v1.6 operational set: --hsts-max-age / --hsts-include-subdomains / --hsts-preload (RFC 6797 Strict-Transport-Security line, opt-in, zero per-response cost when off), --drain-path PATH (Zig-side intercept that flips the worker into the same graceful-drain mode SIGTERM triggers — POST/PUT to begin, GET for an idempotent state probe; pair with --health-path for k8s rolling deploys), TLS observability counters on /metrics (saltare_tls_handshakes_total, saltare_tls_session_reuse_total via lazy dlsym("SSL_session_reused")), PROXY-protocol acceptance counters on /metrics (saltare_proxy_protocol_accepted_total{version="v1|v2"}), and the OpenMetrics 1.0 # EOF marker at the end of every /metrics body. 108 tests pass. Streaming br/zstd + WS p-m-d are zero-RAM-cost when off — if not self.pmd_active / if not self._brotli_handle early-outs keep v1.5 hot path byte-identical; HSTS is a cold module-level bytes object until set, drain endpoint is one optional []const u8 field.

Status: 1.5.0 (historical entry) — operational depth + distribution reach. Carries the v1.4 baseline (body streaming, cgroup awareness, mimalloc default, sendfile(2), full compression suite gzip/brotli/zstd, 414/431 caps, W3C traceparent, Prometheus latency histogram, --reload, Django integration) and adds: musllinux wheels (Alpine), /debug/dispatch JSON introspection endpoint (no GIL) with Bearer-token gate (--dispatch-token), SIGHUP hot config reload (rate_limit_*, max_connections_per_ip, access_log swap from a key=value file without restart), compression counters on /metrics (saltare_response_compression_total{encoding} + _bytes_in_total + _bytes_out_total + _skipped_total{reason}), process_* Prometheus metrics (process_open_fds, process_cpu_seconds_total, process_start_time_seconds — Grafana / Prom-client conventions), pytest-rerunfailures for the previously-flaky test_streaming (3 reruns instead of skip), production runbook + day-2 ops table in README, make smoke-alpine (real Alpine container smoke test against the freshly-built musllinux wheel), make soak (sustained-load RSS-drift gate, defaults 1800 s @ 200 rps), --ktls (kernel TLS offload via OpenSSL ≥ 3.0 — closes the v1.4 sendfile-over-HTTPS gap; off by default). v1.4-cycle bug fixes (sendfile HEAD strip, supervisor SIGTERM forwarding, _pending_sendfiles cleanup, encoder-param warnings, codec probe safe defaults, sendfile/head-write EINTR retry) all rolled forward.

Status: 1.4.0 (historical entry) — body streaming + cgroup awareness + mimalloc default + sendfile(2) + .pyc embed + tracemalloc cache + full compression suite (gzip single-shot + streaming, brotli, zstd) + 414 / 431 caps + W3C traceparent propagation + Prometheus latency histogram. Production target is Linux x86_64. v1.4 lifts the long-standing 16 KiB body cap: when an incoming request's Content-Length exceeds the read buffer, the dispatcher engages an ASGI-streaming path — the user app sees http.request {body=chunk, more_body=True} events and saltare reads + pushes more chunks as the kernel hands them over. Per-task RAM stays bounded by the dispatcher's 64 KiB backpressure threshold regardless of declared body length (was: 413 above 16 KiB). Plus: cgroup-v2 memory awareness auto-tunes max_concurrent_connections from /sys/fs/cgroup/memory.max when running under k8s resources.limits.memory, mimalloc is the default LD_PRELOAD in Dockerfile.production, saltare.sendfile ASGI extension for zero-copy static-asset paths (sendfile(2) syscall, plain-HTTP only), 5-second tracemalloc snapshot cache, .pyc precompile in Dockerfile builder stage, full compression matrix: lazy dlopen("libz.so.1") at src/zig/zlib.zig wired into --response-gzip (single-shot and chunked-streaming via Z_SYNC_FLUSH) + --request-decompression, lazy dlopen("libbrotlienc.so.1") at src/zig/brotli.zig wired into --response-brotli, lazy dlopen("libzstd.so.1") at src/zig/zstd.zig wired into --response-zstd. Server-preference ordering (br > zstd > gzip) negotiates per-request from Accept-Encoding honouring q=0 and * per RFC 7231 §5.3.4. Hardening: --max-request-uri returns 414 URI Too Long (default 8192 B), --max-request-head-bytes returns 431 Request Header Fields Too Large. Observability: --latency-histogram emits saltare_request_duration_seconds_bucket with 14 fixed buckets (1 ms..60 s) on /metrics, --traceparent-propagation surfaces W3C Trace Context on scope and echoes back. Framework integrations: pip install saltare[django] adds src/saltare/contrib/django/ — drop "saltare.contrib.django" into INSTALLED_APPS and manage.py runserver runs your project under saltare (ASGI) instead of wsgiref, with autoreload + staticfiles preserved. Dev autoreload: --reload watches code and SIGTERMs + respawns the child on change (poll-based, no inotify dep — works inside containers / overlayfs / NFS). Saltare is the leanest of the three benchmarked ASGI servers — 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 on the same host. Tests 66 core + 10 v1.3 + 8 v1.4 zlib + 11 v1.4 extras + 4 v1.4 sendfile (tests/test_v14_*); 99 total. Build is clean on Zig 0.16.0.

v1.9.x candidates (still pending)

  • Free-threaded Python (cp314t) evaluation, static-link OpenSSL build.

Status: 1.3.0 — lazy TLS + ~40 operational knobs, leanest of three (historical entry). Production target is Linux x86_64. v1.3 lands ~30 orthogonal features. RAM-floor cuts (default-on): lazy OpenSSL via dlopen, mallopt(M_ARENA_MAX=1, M_TRIM_THRESHOLD=64K, M_TOP_PAD=64K, M_MMAP_THRESHOLD=64K), MALLOC_ARENA_MAX=1 in the CLI re-exec env, gated PYTHONOPTIMIZE=2 auto re-exec, URL decode moved to Zig (drops urllib.parse import), traceback lazy-imported (drops ~150 KiB), TCP_NODELAY + SO_KEEPALIVE on every accepted socket, periodic gc.collect(2) + malloc_trim(0) after 3 s of idle, gc.freeze() re-trigger inside the idle-maintenance pass. Operational knobs (opt-in, zero RAM when off): health_path, cors_preflight_allow_all, IPv6 listen (auto-detect from host), per-IP rate limiter, max_connections_per_ip, max_connection_lifetime, tracemalloc_path, favicon_204, access_log_path, request_id_header, server_timing, listen_backlog, tcp_keepidle/tcp_keepintvl/tcp_keepcnt, tcp_user_timeout_ms, auto_raise_nofile, tls_session_cache_size, startup_request (warm app), server_header (white-label / hide identity), proxy_protocol (v1 + v2 binary auto-detect — required behind L4 LBs), systemd socket activation (LISTEN_FDS=1), SIGUSR1 JSON stats dump, workers=0 auto-detects cpu_count(). Bug fixes / RFC compliance: WebSocket subprotocol (Sec-WebSocket-Protocol was always being dropped), HTTP trailers (http.response.trailers was silently ignored), HTTP/1.1 mandatory Host validation, header-name tchar validation (RFC 7230 §3.2.6 — defends against \0/CRLF smuggling), HEAD method body strip (RFC 7230 §3.3.3 — same headers as GET, no body). Combined: saltare is the leanest of the three benchmarked ASGI servers — 46.4 / 45.4 / 45.4 MiB, vs uvicorn 49.30 / 49.51 / 54.68 MiB and Granian 57.18–57.71 MiB on the same host. Tests 66 passing core + 10 new in tests/test_v13.py. Most v1.3 features are opt-in: defaults match v1.2.2 behaviour at zero RAM cost.


Why

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 / kqueue (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.

Architecture

                          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

Benchmarks

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 200

Results 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.

Sequential — 1 client, 1000 requests (v1.11.0, mimalloc preload)

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

Concurrent — 100 clients × 20 requests (2000 total) (v1.11.0, mimalloc preload)

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

Idle keep-alive — 500 connections held open (v1.11.0, mimalloc preload)

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

Multi-worker idle — Pss across the whole cluster (saltare only) (v1.11.0)

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 negotiates h2 via 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 the h2-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 — its mallopt(M_TRIM_THRESHOLD=64K, M_ARENA_MAX=1) tuning plus MALLOC_ARENA_MAX=1 injected 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 why Dockerfile.production ships 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 h2 via 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.

v1.2.2 vs v1.2.1 on the same host (saltare-only A/B)

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.

Optional bench workloads

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 5000

These 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.zig bundles 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_queue and outgoing list. 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 Python bytes — 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_DONTNEED returns 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=2 in 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).

Roadmap

  • v0.1.0 — Build pipeline. saltare._core extension built with Zig via scikit-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-Length framing). Server echoes method + target back so the parser is observable end-to-end. Zero allocations per request.

  • v0.3.0 — ASGI dispatcher. Persistent asyncio loop reused across requests; per-request loop.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 @compileError until 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.startup before the I/O loop accepts connections, and through lifespan.shutdown after it stops. Apps using FastAPI(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= and ssl_keyfile= to saltare.run() to serve HTTPS. The connection state machine gains a handshaking phase; doRead/doWrite route through SSL_read/SSL_write and translate WANT_READ/WANT_WRITE into epoll interest changes. SSL_pending drained between keep-alive cycles. auditwheel bundles 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_buf that 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 holding Connection structs indefinitely. Wheel uses 128 buckets of 1 second; nodes are intrusive in Connection (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_queue and outgoing list; the app's send({type: "http.response.body", more_body: True/False}) calls flow chunk-by-chunk through the bridge into Zig's write_buf instead of being buffered into a single Python bytes. When the app does not declare a Content-Length, saltare adds Transfer-Encoding: chunked automatically. 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]Header array previously inlined into Connection (~2 KiB) is now bundled into the same pool.zig Buffer that 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×. A malloc_trim(0) call after lifespan.startup returns ~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 recommending MALLOC_ARENA_MAX=2 for another 5–15 MiB.

  • v0.13.0 — Resource caps + Expect: 100-continue. New Limits struct (max_request_body, max_concurrent_connections, max_keepalive_requests) wired into serve() and the CLI. Body cap fires 413 on declared Content-Length overflow and on incremental chunked-decode growth. Connection cap accepts overflow sockets (to drain the listen backlog) and immediately closes them. Keepalive-requests cap forces Connection: close on the Nth response, recycling pymalloc arenas. Expect: 100-continue writes 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_draining atomic 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 when g_active_conns reaches zero or shutdown_timeout (default 30 s) elapses. Idle keep-alive connections drain naturally via keep_alive_timeout. After the loop exits, lifespan.shutdown runs 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 in test_shutdown.py, 3 of which exercise real SIGTERM via subprocess).

  • v0.15.0 — Observability + UDS. Observability struct (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_bytes from /proc/self/status on Linux). access_log emits 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 in Connection); a single write(2) keeps lines atomic. proxy_headers lets the dispatcher read X-Forwarded-For (leftmost IP into scope["client"]) and X-Forwarded-Proto (into scope["scheme"]); only enable behind a trusted proxy. uds_path makes serve() bind an AF_UNIX socket instead of TCP — the bind path is unlinked on shutdown so restarts don't fail with EADDRINUSE. All four off by default; bench numbers indistinguishable from v0.14. Tests now 50/50 (6 new in test_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.headers is invalidated and re-parsed because it pointed into the small buffer's headers array). Buffer.data becomes a []u8 slice (page-allocated via mmap so the OS can later reclaim its pages); Buffer.released_at_ns records when a buffer entered the free list. Each main-loop iteration calls pool.sweepIdle(monoNs()), which walks both free lists and issues MADV_DONTNEED for 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. Header offset compression deferred — too much API churn for the marginal saving.

  • v0.17.0 — Stability + Python RAM polish. Replaced the per-request asyncio.Queue in _HttpState with a single-slot mailbox + on-demand Future: the typical request that does await 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 the test_fastapi_lifespan_startup_runs flake 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 ping frame every ws_keepalive_timeout seconds (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 updates last_activity_ns, and fireExpired'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 inside buildHeadersList so _dispatcher.py drops 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) avoids PyBytes_FromStringAndSize on 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.zig module supervises N forked workers via pause() + waitpid(). Master flow: bind+listen via the existing bindAndListen; 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 call prctl(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_path reports per-worker (aggregate across workers in your scraper). New workers kwarg on saltare.run() and --workers N CLI flag (default 1, single-worker behaviour unchanged). Tests in tests/test_multiworker.py use subprocess + /proc/<master>/task/.../children to 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_headers lowered from 64 to 32 (typical request has <20; 31 KiB → 1 KiB per active pool buffer worth of [Header]N storage). Static asgi ASGI sub-dict cached as a module-level constant, shared across all requests instead of re-allocated. Bench harness gains a multi-worker idle workload 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 _HttpState instances with a reset(...) method that rewrites every slot — saves the slot-allocation step + GC-tracking overhead per request and reuses the outgoing list. (2) receive and send callables 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 rebuilding b"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._send tracks bytes appended to outgoing since the last drain; once the running total crosses _HTTP_SEND_YIELD_BYTES (64 KiB), the next intermediate await send(...) does an await asyncio.sleep(0) so the asyncio loop hands control back. Zig's main-loop stalled-pump path harvests via http_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_bytes is a running total; once _WS_OUTGOING_MAX_BYTES is exceeded the connection is marked closed and further sends drop. (3) _HTTP_POOL_MAX bumped 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.production with jemalloc preloaded + MALLOC_ARENA_MAX=2, make production-image. (7) make valgrind target with CPython suppressions for periodic C-API leak checks across bridge.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 (lazy import in route handlers) is now called out. LTO on the Zig side was attempted but rolled back — Zig 0.16's Build.Module and Build.Step.Compile no longer expose an LTO field, and -fLLVM-lto is not wired through b.standardOptimizeOption; will revisit when a public API lands.

  • v1.4.0 — Body streaming + cgroup awareness + mimalloc default + sendfile(2) ASGI extension + .pyc embed + tracemalloc cache + lazy zlib infra. Phase 1 (lift current ceilings): (1) Request body streaming — when declared Content-Length exceeds the read buffer, the dispatcher engages an ASGI streaming path. App sees http.request{body=chunk, more_body=True} events and saltare reads + pushes more chunks via http_dispatch_push_body as 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. New Connection.body_streaming state + streamReceiveMore handler. max_request_body re-checked incrementally during streaming so adversarial clients can't bypass it. (2) mimalloc default in Dockerfile.production (with jemalloc fallback if mimalloc isn't packaged); ~5 MiB lower steady-state vs glibc default. (3) sendfile(2) zero-copy via the new saltare.sendfile ASGI 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 via httpDispatchPopSendfile. Zig builds the head, calls sendfile(2) syscall in a loop honouring EAGAIN, 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-heap bytes. Phase 2 (floor reduction): 2.3 + 2.4 already optimised in v1.3 (lazy traceback, gen-0 GC). (6) .pyc precompile in the Dockerfile builder stage (python -OO -m compileall src/saltare ... optimize=2) so __pycache__/*.opt-2.pyc is shipped alongside the wheel; first-request import latency drops. Phase 3 (functional gains): (7) lazy dlopen("libz.so.1") at src/zig/zlib.ziggunzip(src, allocator, dst_cap) ?[]u8 with zip-bomb cap + gzipEncode(src, allocator, level) ?[]u8 with gzip wrapper (15+16 window bits). Function-pointer table populated by dlsym on 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 carries Accept-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's Content-Length, and emits Content-Encoding: gzip + Vary: Accept-Encoding. The negotiation honours q=0 weights and the * wildcard per RFC 7231 §5.3.4. Streaming responses skip gzip — chunked + per-chunk Z_SYNC_FLUSH is deferred. (7b) --request-decompression — request bodies with Content-Encoding: gzip are decompressed before the app's first await receive(), capped at max_request_body (zip-bomb defense returns 413 on overflow). Both flags are off by default — when off, _core.gzip_encode / _core.gunzip are 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 set max_concurrent_connections, saltare reads /sys/fs/cgroup/memory.max (or v1's memory.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 k8s resources.limits.memory. (11) 5-second tracemalloc snapshot cachedump_tracemalloc previously 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_co carries a zlib.compressobj(level, DEFLATED, 31) across _send calls; intermediate chunks Z_SYNC_FLUSH, the final chunk Z_FINISH. Apps that emit more_body=True now compress end-to-end (was: streaming responses passed through unchanged in v1.4.0 day-1). (13) Brotli via lazy dlopen("libbrotlienc.so.1") at src/zig/brotli.zig + _core.brotli_encode/brotli_decode exposed to the dispatcher. Single-shot only. Wired into the encoder negotiation. (14) zstd via lazy dlopen("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) parses Accept-Encoding, honours q=0 and 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 on scope["traceparent"] / scope["tracestate"] and echoed back on the response. ~30 B/req when on, zero when off. (19) latency_histogram — Prometheus saltare_request_duration_seconds_bucket with 14 fixed buckets (1 ms..60 s) + _sum + _count; emitted on /metrics only 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 timetls.zig declares OpenSSL types as opaque {}, hard-codes ABI constants, and ships a function-pointer table populated by dlopen + dlsym on first newContext() call. Plain-HTTP deployments never load libssl/libcrypto. (2) mallopt(M_ARENA_MAX, 1) at module init caps glibc's per-thread arenas. The saltare CLI re-exec also injects MALLOC_ARENA_MAX=1 into the child env so even CPython's bootstrap allocations land in a single arena. (3) PYTHONOPTIMIZE=2 auto re-exec strips docstrings + asserts from FastAPI / Pydantic / Starlette — SALTARE_NO_OPTIMIZE=1 opts 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.py no longer imports urllib.parse. (5) TCP_NODELAY + SO_KEEPALIVE on 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 from host, IPV6_V6ONLY=1). (9) Per-IP rate limiter (rate_limit_per_sec, rate_limit_burst) — 4096-IP bounded LRU table, honors proxy_headers (X-Forwarded-For leftmost when behind trusted proxy). (10) tracemalloc_path auto-starts tracking + serves top-30 dump. (11) favicon_204 — Zig answers GET /favicon.ico with 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 via O_APPEND | O_CLOEXEC instead of stderr. (14) request_id_header — auto-generates an 8-byte hex ID per request, exposes via scope["x-request-id"], echoes as response header. (15) server_timing=TrueServer-Timing: total;dur=<ms> on every response. Tier-3 ops + RAM additions: (16) Aggressive mallopt thresholds (M_TRIM_THRESHOLD, M_TOP_PAD, M_MMAP_THRESHOLD all 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 runs gc.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) SIGUSR1 JSON 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_backlog configurable (default 256). (20) tcp_keepidle/tcp_keepintvl/tcp_keepcnt tunable cadence on accepted sockets — kernel defaults are too generous for mobile / NAT-heavy fronts. (21) X-Real-IP honored alongside X-Forwarded-For (nginx convention; X-Real-IP wins when both are present). (22) HTTP/1.1 mandatory Host: enforcement — missing or empty Host header gets a 400 per RFC 7230 §5.4. (23) systemd socket activation — auto-detects LISTEN_PID=$$ + LISTEN_FDS=1 and inherits fd 3 instead of binding; the env is unset so forked workers don't double-activate. (24) HAProxy PROXY-protocol v1 — when proxy_protocol=True, the first line of every accepted connection is parsed as PROXY <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 returning scope["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) Header tchar validation — 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 — internal GET / 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_nofilesetrlimit(RLIMIT_NOFILE) to hard at startup. (35) server_header configurable — refactor of all comptime concat sites to runtime g_server_line; empty string omits. (36) Lazy traceback import — defer the ~150 KiB stdlib until first error path. (37) workers=0 auto-detects min(cpu_count(), 4). Tier-5 final: (38) PYTHONFAULTHANDLER=1 auto in re-exec env. (39) SIGUSR1 dump now includes draining flag. (40) setproctitle via prctl(PR_SET_NAME)saltare, saltare:master, saltare:wkrN visible in ps. (41) TCP_FASTOPEN server-side queue (Linux ≥ 3.7). (42) Periodic gc.collect(0) every N requests for gen-0 churn. (43) X-Forwarded-Host + Forwarded: (RFC 7239) — both feed scope["server"] and the rate-limit key when proxy_headers=True. (44) /metrics saltare_health_state gauge — 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) mTLSssl_ca_file + ssl_verify_client=True enforce 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 ✓.

v1.4.x candidates

  • 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 _send calls (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 lazy dlopen path.

Examples

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.pysaltare.sendfile ASGI extension for zero-copy static-asset responses.
  • examples/observability.py — W3C traceparent propagation + Prometheus latency histogram on /metrics.
  • examples/django.mdpip install saltare[django] integration: manage.py runserver boots 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.

Django Channels (WebSockets)

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 ingress

Required settings

  • ALLOWED_HOSTS must include the host the browser is on (localhost, mysite.com, container DNS name, …). Channels' AllowedHostsOriginValidator reads this to decide whether to accept the upgrade — mismatched Origin → close code 4003 → saltare returns HTTP 403.
  • django.contrib.sessions in INSTALLED_APPS + SessionMiddleware in MIDDLEWARE if you use AuthMiddlewareStack (it looks up the session backend).
  • SECRET_KEY present (any env-driven default is fine).
  • A configured channel layer (in-memory for dev, Redis or PostgreSQL for prod). Without it, group_send raises.

Diagnostics

--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]

Tuning

  • --ws-handshake-timeout SECS — how long the upgrade pump waits for the consumer to accept/close (default 2.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 (default 50). Lower for server-push workloads that need sub-50 ms group_send delivery; raise on bandwidth-pinched deployments. Floor 10 ms.

Streaming responses

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.

Idle timeouts

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.

Streaming request bodies (v1.4)

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.

cgroup memory awareness (v1.4)

When saltare runs inside a memory-limited cgroup (typical k8s resources.limits.memory) and the operator hasn't explicitly set max_concurrent_connections, saltare reads /sys/fs/cgroup/memory.max (cgroup v2) or memory.limit_in_bytes (v1), reserves a 64 MiB floor for Python heap + libs, and budgets the rest at ~50 KiB per concurrent request. The auto-cap is logged at startup:

saltare: cgroup memory.max=128 MiB → max_concurrent_connections=1310

Setting max_concurrent_connections=N explicitly disables the auto-cap.

Zero-copy file responses (saltare.sendfile, v1.4)

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 / Connection in headers; saltare derives those from the file's fstat().
  • Content-Length is set automatically from fstat(); the head and body are written separately so HEAD-method handling is correct.
  • The extension is opt-in: apps that don't emit saltare.sendfile keep the existing dispatch path with no behavioural change.

Response compression — gzip / brotli / zstd (v1.4)

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 10485760

Negotiation: 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: gzip is 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 returns 413 Payload Too Large immediately. Zip-bomb defense — a 1 KiB gzipped payload that decompresses to 1 GiB never makes it past the dispatcher.
  • The Content-Encoding header is stripped from scope["headers"] after decompression.

Request size hardening — 414 / 431 (v1.4)

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 returns 414 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 returns 431 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 8192

W3C Trace Context propagation (v1.4)

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 call

The 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.

Prometheus latency histogram (v1.4)

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.

Resource caps

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.

Connection lifecycle caps

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.

TCP tuning

saltare.run(
    app,
    listen_backlog=1024,         # listen(2) backlog (default 256)
    tcp_keepidle=60,             # seconds idle before first probe
    tcp_keepintvl=10,            # seconds between probes
    tcp_keepcnt=4,               # unanswered probes = drop
    tcp_user_timeout_ms=30000,   # max in-flight unacked write window
)

listen_backlog is capped by /proc/sys/net/core/somaxconn. The keepalive trio tightens dead-connection detection past the kernel default (~2 hours idle); typical mobile-friendly setting is 60 / 10 / 4. tcp_user_timeout_ms (Linux only) is more aggressive than keepalive — it caps stuck WRITE windows too, useful on flaky network paths.

File descriptor limit

saltare.run(app, auto_raise_nofile=True)

Raises the soft RLIMIT_NOFILE to the hard limit at startup so max_concurrent_connections isn't bottlenecked by the user's default 1024 fd cap. Linux only. Equivalent to ulimit -n $(ulimit -Hn) before invoking saltare.

Pre-warming the user app

saltare.run(app, startup_request=True)

After lifespan.startup finishes, saltare issues an internal GET / against the app to warm route compilation, pydantic validators, and JIT caches. The first real client request then doesn't pay the cold-start cliff (typically 50-200 ms drop to 1-5 ms). Skipped if your app's / route does work that's expensive or has side effects — design startup_request accordingly. Best-effort: any exception during warmup is swallowed.

TLS session cache

saltare.run(
    app,
    ssl_certfile="...", ssl_keyfile="...",
    tls_session_cache_size=1024,   # OpenSSL server-side cache (0 = disabled)
)

When set, OpenSSL caches up to N completed TLS sessions; repeat clients negotiating a session resumption skip the full handshake (~3 RTTs → 1 RTT). Cost: ~20 KiB resident per cached session at peak. 1024 ≈ 20 MiB ceiling, fine for production. 0 (default) keeps the floor low; flip on once your TLS workload warrants it.

Customising / hiding the Server: header

saltare.run(app, server_header="my-api/2.1")  # white-label
saltare.run(app, server_header="")            # omit the line entirely

The default is Server: saltare/1.3.0. Setting an explicit value overrides it (built once at start; per-response cost is one {s} substitution). Empty string omits the header. Useful behind a reverse proxy that already advertises a server line, or for hiding the saltare identity for security-by-obscurity.

HEAD requests

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.

Auto worker count

saltare.run(app, workers=0)   # min(cpu_count, 4)

workers=0 (and --workers 0) reads os.cpu_count() and caps at 4 — past 4 the GIL-locked dispatch sees diminishing returns under saltare's architecture. Set explicitly when you know better.

Autoreload (--reload, v1.4)

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.0

Behaviour:

  • --workers > 1 is 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 inotify dep) so it works the same in containers, on overlayfs / NFS / 9p mounts, and across rename storms during git checkout.
  • Each respawn first deletes __pycache__ directories under the watched roots. saltare's PYTHONOPTIMIZE=2 re-exec writes .opt-2.pyc keyed 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.

mTLS (client certificate verification)

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.

TCP Fast Open

saltare.run(app, tcp_fastopen_qlen=256)

Enables TCP_FASTOPEN (Linux ≥ 3.7) on the listen socket. Repeat clients can include payload in the SYN, saving 1 RTT. Wins are visible only when clients themselves opt into TFO and the kernel has net.ipv4.tcp_fastopen set to a value that includes server-side support (typically 3). 256 (matching the default listen_backlog) is a safe value.

Generational GC tuning

saltare.run(app, gc_collect_every_n_requests=1000)

Triggers a gc.collect(0) (gen-0 only — cheap, ~tens of µs) every N completed dispatches. Useful for apps that allocate many cyclic small objects per request (heavy pydantic / dataclass construction): keeps the gen-1 set small so the eventual full-gen sweep stays cheap. The idle-window full GC still runs on top.

Forwarded: header (RFC 7239) + X-Forwarded-Host

proxy_headers=True parses, in order of preference:

  1. RFC 7239 Forwarded: for=...;proto=...;host=... — modern standard, used by some proxies.
  2. nginx X-Real-IP — single client IP.
  3. 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.

WebSocket fragmentation (continuation frames)

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.

Operational diagnostics

  • kill -USR1 $(pidof saltare) → JSON line on stderr with open_conns, in_flight, requests_total, rss_kib, rl_table_size, draining.
  • PYTHONFAULTHANDLER=1 set automatically in CLI re-exec → CPython prints stack on segfault / SIGABRT.
  • Process visible in ps / top / htop as saltare (single-worker), saltare:master, or saltare:wkrN (multi-worker).
  • /metrics endpoint exposes saltare_health_state gauge (0 = healthy, 1 = draining).

Observability and deployment knobs

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.

PROXY protocol v1 + v2 (L4 load balancers)

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.

systemd socket activation

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"

SIGUSR1 stats dump

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 limiting

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 debug endpoint

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).

IPv6

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 loopback

For 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/httpsscope["scheme"]). Only enable behind a proxy that strips client-supplied X-Forwarded-* headers, otherwise clients can spoof their identity.

Django integration (saltare[django], v1.4)

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_APPLICATIONASGI_APPLICATIONdjango.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.

Dispatch introspection endpoint (--dispatch-path, v1.5)

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/dispatch

Without the matching Authorization: Bearer <token> header the endpoint returns 401. Compare is constant-time.

Hot config reload (--runtime-config-path + SIGHUP, v1.5)

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_sec
  • rate_limit_burst
  • max_connections_per_ip
  • max_connection_lifetime_secs
  • access_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` allowed
saltare 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 unknown

Unknown 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 (sendfile-over-HTTPS, v1.5)

--ktls flips OpenSSL's SSL_OP_ENABLE_KTLS so cipher state is handed to the Linux kernel after handshake. Two consequences:

  1. saltare.sendfile works on TLS connections. v1.4 returned 500 because sendfile(2) can't encrypt; with kTLS the kernel applies TLS records on the socket and the syscall just works.
  2. TX-zerocopy on plaintext writes. SSL_OP_ENABLE_KTLS_TX_ZEROCOPY_SENDFILE removes one buffer copy per write. Throughput win on large responses.

Requirements:

  • OpenSSL ≥ 3.0 at runtime. The flag bit is 0 on older OpenSSLs, so silently ignored — saltare falls back to userspace TLS and serveSendfile returns 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 tls available (modprobe tls or 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 \
    --ktls

Off by default. When off, behaviour is identical to v1.4 (serveSendfile returns 500 on TLS connections; userspace SSL_write handles the rest).

Compression counters on /metrics (v1.5)

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.

CLI reference

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)
  --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)
  --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)

Operational
  --startup-request                 issue an internal GET / after lifespan startup (warm app)
  --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.

Production deployment

Pre-deploy checklist (v1.5)

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 valgrind builds the tester image and runs pytest under valgrind --leak-check=full. CI doesn't gate this — run it locally before tagging.
  • Connection cap: pick max_concurrent_connections that 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_keyfile and verify cert chain via openssl s_client -connect host:port.
  • Rate limit: enable --rate-limit-per-sec matched 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-path from Prometheus and set alerts on saltare_responses_5xx_total rate, process_resident_memory_bytes ceiling, and saltare_health_state (= 1 means draining).
  • /debug/dispatch token: when exposing the dispatch endpoint outside the pod's network namespace, set --dispatch-token to a long random string.
  • SIGHUP config: if you'll need to tune rate limits without restart, set --runtime-config-path and document the file in your runbook.
  • Shutdown grace: --shutdown-timeout should match k8s terminationGracePeriodSeconds minus a few seconds. Default 30 s.
  • MALLOC_ARENA_MAX: set to 1 (single worker) or 2 (multi). Already in Dockerfile.production.
  • Workers count: start with min(cpu_count, 4). Higher inflates per-conn Pss without throughput gain under GIL-locked dispatch.

Recommended starting flags

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-gzip

For an Alpine / distroless container, swap Dockerfile.production for one based on python:3.14-alpine — saltare ships musllinux wheels.

Day-2 operations

Symptom First check Likely fix
RSS growing unbounded curl /debug/dispatchin_flightopen_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 and CPU

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 4

The 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.

Environment

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.

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 65535

The 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.

Eager imports under multi-worker

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.

systemd

[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-failure

TimeoutStopSec 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.

Kubernetes

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.

Behind nginx (Unix domain socket)

saltare main:app --uds /run/saltare.sock --workers 4
upstream 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.

What saltare does for you automatically

  • malloc_trim(0) after lifespan.startup returns 1–3 MiB of glibc heap fragmentation (FastAPI/Pydantic imports) to the OS.
  • Idle pool buffers older than 30 s get MADV_DONTNEED so RSS recovers after traffic peaks.
  • App exceptions during dispatch are caught: pre-response.start raises 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.

Building from source

Local development with Zig

Easiest dev loop. saltare's build pipeline (scikit-build-core → CMake → Zig) needs three things on your machine:

  1. Zig 0.16+
  2. Python development headers (Python.h)
  3. OpenSSL development headers (<openssl/ssl.h>, used by src/zig/tls.zig)

Linux (x86_64 or aarch64)

# Debian/Ubuntu
sudo apt install python3-dev libssl-dev cmake build-essential

# Fedora/RHEL/Rocky
sudo dnf install python3-devel openssl-devel cmake gcc

# Zig: pinned 0.16.0 tarball, both archs handled
bash scripts/install-zig.sh

macOS

brew install zig openssl@3
# Python headers come with Homebrew Python or python.org installers.

Then:

uv sync                # or: pip install -e ".[dev]"
pip install -e .       # builds the extension in place
pytest -q

If 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. If it errors with openssl/ssl.h: No such file or directory, the OpenSSL dev headers are missing (see the OS commands above). Both errors apply equally on x86_64 and aarch64; the Docker pipeline (make build) sidesteps them entirely by running everything inside the manylinux container.

Docker (no Zig on host)

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.whl

This invokes Dockerfile, which:

  1. Pulls quay.io/pypa/manylinux_2_28_x86_64.
  2. Downloads pinned Zig (scripts/install-zig.sh).
  3. Builds the wheel and runs auditwheel repair.
  4. Exports dist/*.whl to the host.

Override target via env: PYTHON_TAG=cp310-cp310 MANYLINUX_TAG=manylinux_2_28_aarch64 ./scripts/build-wheel.sh.

Releasing

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) and macOS (x86_64 + arm64), builds the sdist, and publishes to PyPI via Trusted Publishing.

Project layout

.
├── 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     # epoll wrapper (Linux; kqueue TBD)
│   │   ├── 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

License

MIT

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

saltare-1.11.0.tar.gz (413.8 kB view details)

Uploaded Source

Built Distributions

If you're not sure about the file name format, learn more about wheel file names.

saltare-1.11.0-cp314-cp314-musllinux_1_2_x86_64.whl (234.3 kB view details)

Uploaded CPython 3.14musllinux: musl 1.2+ x86-64

saltare-1.11.0-cp314-cp314-musllinux_1_2_aarch64.whl (235.6 kB view details)

Uploaded CPython 3.14musllinux: musl 1.2+ ARM64

saltare-1.11.0-cp314-cp314-manylinux_2_28_x86_64.whl (236.0 kB view details)

Uploaded CPython 3.14manylinux: glibc 2.28+ x86-64

saltare-1.11.0-cp314-cp314-manylinux_2_28_aarch64.whl (236.6 kB view details)

Uploaded CPython 3.14manylinux: glibc 2.28+ ARM64

saltare-1.11.0-cp313-cp313-musllinux_1_2_x86_64.whl (234.3 kB view details)

Uploaded CPython 3.13musllinux: musl 1.2+ x86-64

saltare-1.11.0-cp313-cp313-musllinux_1_2_aarch64.whl (235.6 kB view details)

Uploaded CPython 3.13musllinux: musl 1.2+ ARM64

saltare-1.11.0-cp313-cp313-manylinux_2_28_x86_64.whl (236.0 kB view details)

Uploaded CPython 3.13manylinux: glibc 2.28+ x86-64

saltare-1.11.0-cp313-cp313-manylinux_2_28_aarch64.whl (236.6 kB view details)

Uploaded CPython 3.13manylinux: glibc 2.28+ ARM64

saltare-1.11.0-cp312-cp312-musllinux_1_2_x86_64.whl (234.4 kB view details)

Uploaded CPython 3.12musllinux: musl 1.2+ x86-64

saltare-1.11.0-cp312-cp312-musllinux_1_2_aarch64.whl (235.6 kB view details)

Uploaded CPython 3.12musllinux: musl 1.2+ ARM64

saltare-1.11.0-cp312-cp312-manylinux_2_28_x86_64.whl (236.0 kB view details)

Uploaded CPython 3.12manylinux: glibc 2.28+ x86-64

saltare-1.11.0-cp312-cp312-manylinux_2_28_aarch64.whl (236.6 kB view details)

Uploaded CPython 3.12manylinux: glibc 2.28+ ARM64

saltare-1.11.0-cp311-cp311-musllinux_1_2_x86_64.whl (234.4 kB view details)

Uploaded CPython 3.11musllinux: musl 1.2+ x86-64

saltare-1.11.0-cp311-cp311-musllinux_1_2_aarch64.whl (235.6 kB view details)

Uploaded CPython 3.11musllinux: musl 1.2+ ARM64

saltare-1.11.0-cp311-cp311-manylinux_2_28_x86_64.whl (235.9 kB view details)

Uploaded CPython 3.11manylinux: glibc 2.28+ x86-64

saltare-1.11.0-cp311-cp311-manylinux_2_28_aarch64.whl (236.6 kB view details)

Uploaded CPython 3.11manylinux: glibc 2.28+ ARM64

saltare-1.11.0-cp310-cp310-musllinux_1_2_x86_64.whl (234.5 kB view details)

Uploaded CPython 3.10musllinux: musl 1.2+ x86-64

saltare-1.11.0-cp310-cp310-musllinux_1_2_aarch64.whl (235.8 kB view details)

Uploaded CPython 3.10musllinux: musl 1.2+ ARM64

saltare-1.11.0-cp310-cp310-manylinux_2_28_x86_64.whl (236.1 kB view details)

Uploaded CPython 3.10manylinux: glibc 2.28+ x86-64

saltare-1.11.0-cp310-cp310-manylinux_2_28_aarch64.whl (236.7 kB view details)

Uploaded CPython 3.10manylinux: glibc 2.28+ ARM64

File details

Details for the file saltare-1.11.0.tar.gz.

File metadata

  • Download URL: saltare-1.11.0.tar.gz
  • Upload date:
  • Size: 413.8 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for saltare-1.11.0.tar.gz
Algorithm Hash digest
SHA256 88eb85c572be4673405b6d8e5775ad4f0c2efbf243b1c7df6265520e3b0a295a
MD5 e90e0f58e427d0c870936313bfe79db5
BLAKE2b-256 596fee9f9203a25c177c4961f09355d63fa72167b98c459be6a2fd5cc04887f1

See more details on using hashes here.

Provenance

The following attestation bundles were made for saltare-1.11.0.tar.gz:

Publisher: release.yml on rroblf01/saltare

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file saltare-1.11.0-cp314-cp314-musllinux_1_2_x86_64.whl.

File metadata

File hashes

Hashes for saltare-1.11.0-cp314-cp314-musllinux_1_2_x86_64.whl
Algorithm Hash digest
SHA256 734948f99f899aa91ddc4cc446474b352b205be95621fae4373b4564fe2c018a
MD5 815613231933a5855f7abac670366b5f
BLAKE2b-256 b4536cfe787bd524b64d4914c2de642a7914f3a1d3d661c986a779089e08528e

See more details on using hashes here.

Provenance

The following attestation bundles were made for saltare-1.11.0-cp314-cp314-musllinux_1_2_x86_64.whl:

Publisher: release.yml on rroblf01/saltare

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file saltare-1.11.0-cp314-cp314-musllinux_1_2_aarch64.whl.

File metadata

File hashes

Hashes for saltare-1.11.0-cp314-cp314-musllinux_1_2_aarch64.whl
Algorithm Hash digest
SHA256 153b83ecae925fe692fca9ef6ed6d29f67f0a2f2fdb75461b0d95170c786ec3f
MD5 3317719bce727ea0879a0b733b786397
BLAKE2b-256 d19efba1b0a8524b2e9ef1eaa07de135ec60255b875bbd48b4a70dcd4540711a

See more details on using hashes here.

Provenance

The following attestation bundles were made for saltare-1.11.0-cp314-cp314-musllinux_1_2_aarch64.whl:

Publisher: release.yml on rroblf01/saltare

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file saltare-1.11.0-cp314-cp314-manylinux_2_28_x86_64.whl.

File metadata

File hashes

Hashes for saltare-1.11.0-cp314-cp314-manylinux_2_28_x86_64.whl
Algorithm Hash digest
SHA256 11e0125c39b44dbb6c55c59a3228acb78b3221e1bee3a84716afe9f60356067a
MD5 27b6b674c3b0954ed68e2a43855d2449
BLAKE2b-256 8d3326c79876b53a2c205659e218043eb48bf2ea7df480ca6224e45a0ea6a825

See more details on using hashes here.

Provenance

The following attestation bundles were made for saltare-1.11.0-cp314-cp314-manylinux_2_28_x86_64.whl:

Publisher: release.yml on rroblf01/saltare

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file saltare-1.11.0-cp314-cp314-manylinux_2_28_aarch64.whl.

File metadata

File hashes

Hashes for saltare-1.11.0-cp314-cp314-manylinux_2_28_aarch64.whl
Algorithm Hash digest
SHA256 6ed5f3d17e8fc5f0614d9d9c578cbfa6be40961fca1fb67c4fdad133f1ac2dea
MD5 6e1a466642cd85b119733eb7b488ca94
BLAKE2b-256 926f207c0d422fe8a27317dc5ffaee97c88f63fb9a98c3d3e52db12ca42a2e8c

See more details on using hashes here.

Provenance

The following attestation bundles were made for saltare-1.11.0-cp314-cp314-manylinux_2_28_aarch64.whl:

Publisher: release.yml on rroblf01/saltare

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file saltare-1.11.0-cp313-cp313-musllinux_1_2_x86_64.whl.

File metadata

File hashes

Hashes for saltare-1.11.0-cp313-cp313-musllinux_1_2_x86_64.whl
Algorithm Hash digest
SHA256 2401452cbf8d888234bba8cca11a733cfef898cef6b6f45116190f712b9bb632
MD5 1ca43b06bf853ba0626334e54fa087b6
BLAKE2b-256 5b834cd5dd88cb0ae4a4273c2df7c8bdea8ab833d36e70766d8105d2cdc847bb

See more details on using hashes here.

Provenance

The following attestation bundles were made for saltare-1.11.0-cp313-cp313-musllinux_1_2_x86_64.whl:

Publisher: release.yml on rroblf01/saltare

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file saltare-1.11.0-cp313-cp313-musllinux_1_2_aarch64.whl.

File metadata

File hashes

Hashes for saltare-1.11.0-cp313-cp313-musllinux_1_2_aarch64.whl
Algorithm Hash digest
SHA256 c969d578a19dc11169fce0da2d5357729437fb98a144737df885dee2e72f8518
MD5 2f299516b9988ecce9607b2924091c0e
BLAKE2b-256 b77382c110fee4332e3dbc7311e659df564147d600eaf94739537e9c0ce0c939

See more details on using hashes here.

Provenance

The following attestation bundles were made for saltare-1.11.0-cp313-cp313-musllinux_1_2_aarch64.whl:

Publisher: release.yml on rroblf01/saltare

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file saltare-1.11.0-cp313-cp313-manylinux_2_28_x86_64.whl.

File metadata

File hashes

Hashes for saltare-1.11.0-cp313-cp313-manylinux_2_28_x86_64.whl
Algorithm Hash digest
SHA256 9d037856b0e1094ca31efd2b4424eabc745d168b941a67032fc9a546974298ec
MD5 efac0e2da1a92bb269e0ec74f498b814
BLAKE2b-256 8adaa2907256bd5b2451e5065290ab843b5c13d554ed5a24cb28fbd4c1367a3c

See more details on using hashes here.

Provenance

The following attestation bundles were made for saltare-1.11.0-cp313-cp313-manylinux_2_28_x86_64.whl:

Publisher: release.yml on rroblf01/saltare

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file saltare-1.11.0-cp313-cp313-manylinux_2_28_aarch64.whl.

File metadata

File hashes

Hashes for saltare-1.11.0-cp313-cp313-manylinux_2_28_aarch64.whl
Algorithm Hash digest
SHA256 4ccb028ea02c967b12baabf6a584a3bfad73490990d29f5745494682f7cde37a
MD5 f35e792f8e8c76fe140b274377e0e301
BLAKE2b-256 b869bbc1e85a3816009d120969704ffc07d12da7563918a24f3a71998a09fea7

See more details on using hashes here.

Provenance

The following attestation bundles were made for saltare-1.11.0-cp313-cp313-manylinux_2_28_aarch64.whl:

Publisher: release.yml on rroblf01/saltare

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file saltare-1.11.0-cp312-cp312-musllinux_1_2_x86_64.whl.

File metadata

File hashes

Hashes for saltare-1.11.0-cp312-cp312-musllinux_1_2_x86_64.whl
Algorithm Hash digest
SHA256 5708da4b44e073c9c53bbc00a14335dda9c934488b70578d9534e6c44088bb17
MD5 963cc65a6027ccbfa12cd110a9dd7523
BLAKE2b-256 68fc7a7a9e0a4d81f8f13866f185ed5afdfdcb59f0b000a97371758d238ba559

See more details on using hashes here.

Provenance

The following attestation bundles were made for saltare-1.11.0-cp312-cp312-musllinux_1_2_x86_64.whl:

Publisher: release.yml on rroblf01/saltare

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file saltare-1.11.0-cp312-cp312-musllinux_1_2_aarch64.whl.

File metadata

File hashes

Hashes for saltare-1.11.0-cp312-cp312-musllinux_1_2_aarch64.whl
Algorithm Hash digest
SHA256 f17cfd0dd9d6a33428ae0c3aabc84b88e65ba11a4eec4bdafe6a9bb14842f058
MD5 4e7f9de54edc9276d38ec41cd6630eb0
BLAKE2b-256 cf57f4088d4f3c157418531621a53d89c4f75916bc19269dbad7181935c45c82

See more details on using hashes here.

Provenance

The following attestation bundles were made for saltare-1.11.0-cp312-cp312-musllinux_1_2_aarch64.whl:

Publisher: release.yml on rroblf01/saltare

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file saltare-1.11.0-cp312-cp312-manylinux_2_28_x86_64.whl.

File metadata

File hashes

Hashes for saltare-1.11.0-cp312-cp312-manylinux_2_28_x86_64.whl
Algorithm Hash digest
SHA256 b1e0158715a400c4520ca1399c126818ab1e5b42af2a21de7d5889e2461411a1
MD5 72b46c159395b27a3000e83eacfebff1
BLAKE2b-256 772747efb1a486d05a4af5751a977611719e6ee4528cb1e50f99bd9eb612dd09

See more details on using hashes here.

Provenance

The following attestation bundles were made for saltare-1.11.0-cp312-cp312-manylinux_2_28_x86_64.whl:

Publisher: release.yml on rroblf01/saltare

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file saltare-1.11.0-cp312-cp312-manylinux_2_28_aarch64.whl.

File metadata

File hashes

Hashes for saltare-1.11.0-cp312-cp312-manylinux_2_28_aarch64.whl
Algorithm Hash digest
SHA256 257bf0cc9e65187316a346b9a5cff3ad6e08859d7f4ff1668efe0b7db442ff05
MD5 2070657b6162a7f0ef50bd45e9fb2e4c
BLAKE2b-256 1c638e494a16a1ce37321ca154b1e4200d954bb4c02c8c2096396b739f721c82

See more details on using hashes here.

Provenance

The following attestation bundles were made for saltare-1.11.0-cp312-cp312-manylinux_2_28_aarch64.whl:

Publisher: release.yml on rroblf01/saltare

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file saltare-1.11.0-cp311-cp311-musllinux_1_2_x86_64.whl.

File metadata

File hashes

Hashes for saltare-1.11.0-cp311-cp311-musllinux_1_2_x86_64.whl
Algorithm Hash digest
SHA256 953bd6b5be2701cedbed0e7da377b2264876039693e2fe503cb6abe1bd57052f
MD5 a6f2b423953020ae0f21430ae7c70aef
BLAKE2b-256 411086dca340fa70a0082bca823e4a607bd4d2ac2afa79e4eee373182518aecf

See more details on using hashes here.

Provenance

The following attestation bundles were made for saltare-1.11.0-cp311-cp311-musllinux_1_2_x86_64.whl:

Publisher: release.yml on rroblf01/saltare

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file saltare-1.11.0-cp311-cp311-musllinux_1_2_aarch64.whl.

File metadata

File hashes

Hashes for saltare-1.11.0-cp311-cp311-musllinux_1_2_aarch64.whl
Algorithm Hash digest
SHA256 ef90495a0d2604fc2d0f509bb6f7c97760aeed2c5463b684c9bc0e84f2257b2d
MD5 46b595805a4dd58855b7996d4a9ae9bb
BLAKE2b-256 fad368fb5f4432ee339bb7daac98f870cdd720be609fcb4c211585edacf8fd01

See more details on using hashes here.

Provenance

The following attestation bundles were made for saltare-1.11.0-cp311-cp311-musllinux_1_2_aarch64.whl:

Publisher: release.yml on rroblf01/saltare

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file saltare-1.11.0-cp311-cp311-manylinux_2_28_x86_64.whl.

File metadata

File hashes

Hashes for saltare-1.11.0-cp311-cp311-manylinux_2_28_x86_64.whl
Algorithm Hash digest
SHA256 901f75978245806cd60e7cec36fba4d5860c4024f4979f76ad59a851ef8e92c0
MD5 460c9378ed2692b5a9f0598905074fe0
BLAKE2b-256 bde7fccd91a49c5a85c265bb5e6d4776fc4318be46b4d11682b6a4d2184bcba2

See more details on using hashes here.

Provenance

The following attestation bundles were made for saltare-1.11.0-cp311-cp311-manylinux_2_28_x86_64.whl:

Publisher: release.yml on rroblf01/saltare

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file saltare-1.11.0-cp311-cp311-manylinux_2_28_aarch64.whl.

File metadata

File hashes

Hashes for saltare-1.11.0-cp311-cp311-manylinux_2_28_aarch64.whl
Algorithm Hash digest
SHA256 37117ef5f89894bd153aee99213e47e9f5ac3828ca0845644ebdf937520b7418
MD5 66b2896f0425191fb7979d8297b90686
BLAKE2b-256 103c60e6380e641e900013c74efe44f8f7b33345218354955d6c3df4f2b5f48e

See more details on using hashes here.

Provenance

The following attestation bundles were made for saltare-1.11.0-cp311-cp311-manylinux_2_28_aarch64.whl:

Publisher: release.yml on rroblf01/saltare

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file saltare-1.11.0-cp310-cp310-musllinux_1_2_x86_64.whl.

File metadata

File hashes

Hashes for saltare-1.11.0-cp310-cp310-musllinux_1_2_x86_64.whl
Algorithm Hash digest
SHA256 a1792a1b99b43ba58e6c8a3d3480fa530d46636b8ba556d5f9d37c0950d626e1
MD5 8fd4d5fe148c3a8ea5da5b61fb8d7c69
BLAKE2b-256 fcef966f2350c21996bfd96df9d3ccf27726c87da9f8b43d3db8dfbcc662960b

See more details on using hashes here.

Provenance

The following attestation bundles were made for saltare-1.11.0-cp310-cp310-musllinux_1_2_x86_64.whl:

Publisher: release.yml on rroblf01/saltare

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file saltare-1.11.0-cp310-cp310-musllinux_1_2_aarch64.whl.

File metadata

File hashes

Hashes for saltare-1.11.0-cp310-cp310-musllinux_1_2_aarch64.whl
Algorithm Hash digest
SHA256 822dfaaea3a03f5e60d742d692072d004a7f6d07e02b0234e25b99d44ff7955e
MD5 43bb9b004c290843fd0c95ddf738c366
BLAKE2b-256 7c031e0b096980311e5416d1b798f46f3e58050225953b683dc4911c8ae41dc8

See more details on using hashes here.

Provenance

The following attestation bundles were made for saltare-1.11.0-cp310-cp310-musllinux_1_2_aarch64.whl:

Publisher: release.yml on rroblf01/saltare

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file saltare-1.11.0-cp310-cp310-manylinux_2_28_x86_64.whl.

File metadata

File hashes

Hashes for saltare-1.11.0-cp310-cp310-manylinux_2_28_x86_64.whl
Algorithm Hash digest
SHA256 29034c6c7e09df280f431ce62d8dcdc020ef45ce8fe7693a188794eaea00f0db
MD5 2ffec6199615472df90db7b3bf0ec539
BLAKE2b-256 af4f310326b6290430914802312d80d80ee0b7d9cbb002f47985840b6208b87a

See more details on using hashes here.

Provenance

The following attestation bundles were made for saltare-1.11.0-cp310-cp310-manylinux_2_28_x86_64.whl:

Publisher: release.yml on rroblf01/saltare

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file saltare-1.11.0-cp310-cp310-manylinux_2_28_aarch64.whl.

File metadata

File hashes

Hashes for saltare-1.11.0-cp310-cp310-manylinux_2_28_aarch64.whl
Algorithm Hash digest
SHA256 febaca29aab76b83c2bafc2e1999f49dc854c53ef304a1f64ccbf2a5e967f068
MD5 3b91672894345e46515df30fe831d634
BLAKE2b-256 ab7c2b9b69fa442785bb7a8a14ba6e767f7240c0c581098fc2b8d34f787e9021

See more details on using hashes here.

Provenance

The following attestation bundles were made for saltare-1.11.0-cp310-cp310-manylinux_2_28_aarch64.whl:

Publisher: release.yml on rroblf01/saltare

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page