A fast ASGI server using Rust. status: experimental

$ git clone https://gitman.io/git/patx/skirr

Skirr

A small, experimental ASGI-only Rust server for Linux. Python owns the application's asyncio loop; Rust owns nonblocking sockets, HTTP/1 framing, buffering, and native ASGI send/receive objects. The native epoll descriptor is registered with loop.add_reader. There is no second async runtime or thread.

This is an architecture prototype, not a production release. Profile-driven optimizations and same-session before/after comparisons live in the latest comparison. Results are workload-specific; this is not a claim of overall superiority to other servers. The fourth round improves FastAPI throughput by 1.9–3.4% over its starting release and fixed-rate p99 by 4.4%. It does not consistently beat Peregrine on both FastAPI throughput and latency; some saturation p99 regressions remain. See the profile and regression study.

Build and run

Requires Linux, Rust 1.88 or newer, and CPython 3.12 or newer. This initial implementation has been tested on CPython 3.14.4 on Linux x86-64; other supported Python versions and ARM64 still need qualification. Free-threaded Python is not supported.

python -m pip install .
skirr app:app --host 127.0.0.1 --port 8000

Run this from the directory containing app.py; app:app selects its ASGI callable named app. The current directory is searched by default. To load an application from another directory, use --app-dir /path/to/project.

pip install '.[speed]' installs optional uvloop. Standard asyncio also works. Applications use the ordinary ASGI 3 (scope, receive, send) interface. --lifespan auto|on|off defaults to auto. Only one worker is implemented; other worker counts fail explicitly.

For development without installing the package:

python tools/build.py
PYTHONPATH=python python -m pytest tests -q
cargo test --lib

Before publishing, install the release wheel in a virtual environment and run /path/to/venv/bin/python tools/check_release.py. This checks the installed skirr app:app console command from a separate application directory.

Implemented scope

  • HTTP/1.0 and HTTP/1.1, sequential pipelining, fixed-length and chunked bodies.
  • Request dispatch at headers, incremental upload consumption, demand-driven 100 Continue, streaming responses, and HTTP/1.1 response trailers.
  • Native completed awaitables; Futures for blocked receive and output drain.
  • Eager task startup when no custom task factory is installed; normal factory behavior is preserved. Completed tasks are cleaned up without another callback.
  • Read and write backpressure, per-callback I/O budgets and an ASGI operation budget per request so immediately completed sends and receives cooperate with the loop. Native event batches and eager application starts are also bounded.
  • Request identities that prevent retained channels from writing into a later request, disconnect delivery, closed-send errors, and cancelled receive reuse.
  • Lifespan startup/shutdown on the serving loop, request state copies and graceful drain. IPv4 and IPv6 TCP listeners.

No WSGI, routing, application framework, caching, compression, or static files. WebSockets and other upgrades return 501. TLS, HTTP/2, HTTP/3, Unix sockets, proxy-header processing, worker supervision and structured access logs are not implemented. Informational-response extensions are not advertised. CONNECT is not implemented and returns 501. An HTTP/1.0 response cannot carry trailers; trailer events complete it without transmitting trailer fields.

Standards and limits

The HTTP scope advertises ASGI 3.0 and HTTP sub-specification 2.5. Lifespan uses 2.0. The implementation targets RFC 9110/9112 for HTTP semantics/framing. This describes the intended contract, not a claim of completed conformance certification. See the requirements matrix for tested behavior and outstanding qualification. Supporting HTTP/1 does not imply HTTP/2 or HTTP/3 support, nor support for the ASGI WebSocket protocol.

Current fixed prototype limits:

Resource Limit
Request head 32 KiB / 100 fields
Decoded request body 16 MiB
Chunk framing and trailer metadata combined 32 KiB
Individual chunk-size line 8 KiB
Buffered request-body high-water mark 64 KiB, plus one decoded segment
Unconsumed wire buffer 1 MiB
Output pause / resume thresholds 512 KiB / 64 KiB
Connections 10,000
Header deadline / keepalive idle timeout 10 s / 5 s
Stalled client I/O timeout 30 s

Output backpressure bounds queued work between awaited sends. One application body message can exceed the threshold; any unsent suffix is copied into its output buffer. There is no aggregate worker byte budget yet. Consequently, the connection limit is not a sufficient production memory limit.

Architecture and dependency budget

Three direct Rust dependencies: PyO3 (Python ownership/integration), httparse (request-head syntax), and libc (isolated socket/poller calls). Cargo.lock pins the full graph, including build dependencies. Python runtime dependencies are optional.

  • src/http.rs: framing without Python or sockets, plus segmentation tests.
  • src/net.rs: owned epoll descriptor and OS-specific socket/poller operations.
  • src/vectorcall.rs: guarded CPython call entry for the existing PyO3 Sender.
  • src/completed.rs: native ready-awaitable slots, with isolated CPython ownership.
  • src/lib.rs: connection ownership, I/O, ASGI objects and worker callbacks.

The worker reuses one initialized read buffer. An empty socket remains armed between requests, avoiding two epoll changes per request; buffered pipelined input still pauses reads. Small writes are coalesced; large bodies use vectored I/O with an owned copy of any unsent suffix. Python body pointers are never retained after a send call. Header-name caching is capped at 512 names, each at most 128 bytes; application-visible mutable scope dictionaries remain separate. Identical short request-header blocks can share immutable tuples and bytes, with a fresh mutable header list per request. Snapshots are bounded to eight fields and 512 wire bytes per connection, with a weak hint for sharing across connections. Native request-head storage is reused only after Python owns the scope values. With explicit lifespan off, the optional state namespace is omitted; auto/on still provide a fresh shallow copy. Fixed scope keys are interned once; each request still gets separate mutable scope and ASGI-version dictionaries. Header spans use one owned request block. Response-header storage is reused up to 64 KiB per connection. Upload segments copy directly into owned Python bytes. Completed sends allocate no iterator, while real I/O waits still use Futures. Small final responses after more expensive application work copy into the kernel with MSG_MORE and push at the end of the bounded native callback (or a queued callback for independently resumed apps). Streaming sends flush immediately. Queued flushes do not arm EPOLLOUT until an attempted write blocks. - python/skirr: application loading, lifespan, CLI and loop orchestration.

Readability takes priority over a line-count target. CPython integration still has overhead worth measuring: native callable dispatch, scope/message allocation, buffer copies, task scheduling, and done callbacks. The Sender uses a guarded vectorcall entry while retaining PyO3 ownership, thread checks and error/panic translation. This integration needs qualification on every supported Python version.

Design research used pinned Weft cec2030 and Peregrine 3f8cac1 source snapshots. The implementation is new Rust code; their architectural ideas are credited in DESIGN.md. The latest study also reviews the exact Peregrine 1.1.7 release source; profiles and findings explain the retained and rejected experiments. The benchmark apps and resource-sampling harness were adapted from our MRHTTP benchmark tooling.

Benchmark reproduction

Install the optional benchmark dependencies and wrk. Run from this directory:

PYTHONPATH=python python bench/compare.py --workers 1 --connections 64 \
  --duration 5 --repeats 3 --output bench/results/my-c64

The default server CPU is core 0 and load-generator CPUs are 4–7; override --server-cpus and --client-cpus for your machine. Their sets must not overlap. Each result records versions, build/source hashes, CPU and memory sampling, worker readiness, response validation, raw wrk output and streaming latency. The main throughput run validates exact responses before and after load; it does not validate every response body during the run.

These are closed-loop local microbenchmarks, not production capacity estimates. wrk's timeout counter also includes completed responses outside its three-second latency histogram. Compare that separately from connect/read/write failures. A separate fixed-rate comparison uses wrk2 corrected histograms; do not pool those percentiles with wrk results. Extended soak tests, fuzzing, broader sanitizer/leak qualification, and broader platform testing remain required before a production release.

Verification: 69 CLI/socket/ASGI cases pass under both standard asyncio and uvloop; three native framing-invariant tests pass; strict Clippy passes. An installable CPython 3.14 Linux wheel was built and the same 69 cases passed against that installed wheel. These checks cover the listed contracts, not every requirement in the HTTP specifications. A separate AddressSanitizer build passes the 64 cases under both loops; its embedding-host limitations and commands are recorded in the sanitizer log.

To reproduce the same-session before/after comparison, extract bench/profiles/round4-before-source.tar.gz into a separate directory and include --servers skirr-before skirr weft peregrine --baseline /path/to/extracted. The archive includes the original source and CPython 3.14 x86-64 release binary. Rebuild that binary when using another interpreter or architecture.

The latest before/after baseline is bench/profiles/round3-before-source.tar.gz; its results are in bench/results/latency-round4-final-report.txt. The FastAPI study records profiling, retained changes, rejected prototypes, and measurement limitations. Earlier reports and their distinct original baseline remain available for historical comparison.

For a comparable run on this laptop, the final matrices were launched with powerprofilesctl launch --profile performance --appid skirr-benchmark -- before the Python benchmark command. This temporary hold releases on command exit; it is optional benchmark tooling, not a Skirr runtime dependency. CPU-frequency samples and the active power profile are recorded in each result. Discarded power-saver and mixed-clock trials remain clearly named in the results folder.