Weft is an ASGI and WSGI server in one Rust extension module (weft._weft)
plus a thin Python layer. The design follows one rule: the server and the
application run on the same thread, in the same event loop, and talk through
the CPython C API directly. Nothing crosses a thread boundary on the
request path, and no Python-level glue runs between the socket and the
application.
The protocols and numbers are in README.md and BENCHMARKS.md.
src/
lib.rs Worker type and module init
core.rs Tokio LocalRuntime, select driver, accept, shutdown
http.rs HTTP/1.1 framing, the per-request pump
asgi.rs Scope, send / receive, dispatch
wsgi.rs Environ, start_response, iterable, --wsgi-threads
types.rs Native Python types: ASGISend, ASGIReceive, TaskDone, Ready
ws.rs WebSocket
h2c.rs HTTP/2
h3c.rs, wt.rs HTTP/3 and WebTransport
tls.rs rustls, SNI
staticf.rs, compress.rs, cache.rs
ops.rs, metrics.rs, limit.rs
fdpoll.rs asyncio fds on the Tokio driver
winaccept.rs Windows shared-listener accept thread
py.rs, interned.rs
weft/
loop.py WeftSelector, WeftEventLoop
worker.py lifespan, serve, signals, drain
server.py bind; inline, process and thread workers
contrib/ WebTransport routers, AltSvc, resumable uploads
WeftEventLoop is an ordinary asyncio.SelectorEventLoop. Its selector,
WeftSelector, forwards to the native Worker:
-
register/modify/unregisteradd asyncio's descriptors to the Tokio I/O driver (on Unix throughAsyncFd; on Windows through a duplicated socket handle registered with Tokio's IOCP driver). -
select(timeout)callsCore::select, which runs the Tokio runtime withblock_onuntil one of:- a server task handed asyncio work to do (created a request task,
resolved a future), signalled by
py_scheduled; - an asyncio descriptor is ready (a Tokio readiness hint confirmed by a
zero-timeout
poll/WSAPoll, so that stale readiness never reaches asyncio); - another thread called
call_soon_threadsafe:_write_to_selfis replaced byWorker.wakeup(), a TokioNotify, instead of a self-pipe byte; - the timeout asyncio asked for elapsed.
A zero timeout (asyncio has ready callbacks) runs one tick: it polls a pinned
yield_now()future, so runnable server tasks run and the I/O driver is polled without blocking. - a server task handed asyncio work to do (created a request task,
resolved a future), signalled by
All server work (accepting, parsing, writing) happens inside select, as
Tokio tasks on the asyncio thread. Tasks call into Python directly, so the
GIL is held while they run. It is released in Tokio's on_thread_park hook,
only while the runtime waits in the OS, and only if no Python work is
pending. Other Python threads, such as the thread pool behind sync FastAPI
endpoints, run whenever the server is idle.
Wakeups that originate in Python calls (send flushing, receive waiting)
are deferred: the waker goes into a list that select runs inside the
runtime's context, where waking a task is a push onto the local run queue
instead of a syscall to unpark the driver.
- The connection task reads into its buffer and parses the head with
httparse. It validates framing (content-length, transfer-encoding and their conflicts,Connection,Expect) before any Python runs. asgi::startbuilds the scope:PyDict_Copyof a prototype holding every constant entry (type,asgi,scheme,root_path,server);- interned keys and interned method strings;
- header names lowercased once per distinct raw name and cached as
bytes(bounded cache); - the client tuple, cached per connection;
- a shallow copy of the lifespan state.
- It creates the
receiveandsendobjects. They are native vectorcall objects that carry a token: connection slot, generation and request sequence number. Calls from a stale request or a closed connection resolve to nothing instead of touching a reused slot. - It calls
app(scope, receive, send), wraps the coroutine inasyncio.Task(coro, loop=loop)(a vectorcall with kwnames), attaches the nativeTaskDonecallback, and signals asyncio. The task runs in the same_run_onceiteration.
HTTP/2 and HTTP/3 rebuild the head as HTTP/1.1 text and re-parse it, so the scope builder, the WSGI environ and the access log stay on one representation.
http.response.start validates and encodes the status line and headers into
a buffer, noting content-length, transfer-encoding, Connection, Date and
Server. Nothing is written yet.
The first http.response.body decides the framing:
- a declared content-length is used as is;
- a single complete body gets a computed content-length;
- a streamed body gets chunked encoding (HTTP/1.1), or close-delimited framing (HTTP/1.0).
HEAD, 1xx, 204 and 304 responses carry no body. The head and the first body
go out in one buffer, written to the socket with try_write inside the
call, before send returns. If everything was written, or the backlog is
under the high-water mark (512 KB), send returns a shared, pre-completed
awaitable. Otherwise it returns an asyncio.Future, resolved by the
connection task when the backlog drains below 64 KB. That is backpressure
for slow clients.
The pre-completed awaitable (Ready) implements am_await, am_send and
tp_iternext. It finishes immediately, returning None. On 3.12+ the
SEND opcode goes through tp_iternext for non-generator awaitables, so
returning a value means raising StopIteration(value), which Ready does.
If body bytes have already been read, receive returns a Ready that
yields the http.request message. The await does not suspend. A request
with no body returns {'type': 'http.request', 'body': b'', 'more_body': False} the same way. Otherwise receive parks an asyncio.Future on the
connection. The connection task resolves it when data arrives, or with
http.disconnect when the client goes away or the response completes.
Expect: 100-continue is answered on the first receive.
Request bodies are read ahead up to 256 KB. Beyond that, reading stops until the application consumes the body. While the application works, the connection keeps reading up to 64 KB past the request, to notice a client that disconnects, and to buffer pipelined requests.
The TaskDone callback retrieves the task's exception, if any, and reports
it through the weft logger. If no response was started, it sends a 500; a
half-sent response closes the connection. The connection then either reads
the next request (keep-alive, pipelining), discards up to 256 KB of request
body the application left unread and continues, or closes.
A two-argument callable is treated as WSGI; --protocol overrides the
guess. The body is read in full, then the application is called inline on
the worker thread. start_response is a native object that is also
write(). The response iterable is sent block by block; between blocks the
connection task flushes and parks on the socket, so a slow client stalls
only its own generator.
--wsgi-threads N moves the application call onto N threads per worker.
The connection task still owns every socket. Inline is the default because
it is the fastest path for a handler that returns immediately.
wsgi.input is currently io.BytesIO of the body. The HTTP connection
task is still the ASGI one: Tokio driven from asyncio.select(). That is
why raw WSGI on Weft is close to raw ASGI, and why Peregrine's asyncio-free
WSGI poller pulls ahead on that column in BENCHMARKS.md.
HTTP/2 is the default over TLS (ALPN h2). Cleartext prior knowledge is
also served. Streams are slots in the same connection table with fd = -1
and a pointer back to the parent.
HTTP/3 is QUIC (--http3, --quic-port). The UDP socket belongs to the
QUIC listener; the connection slot exists so a QUIC connection is a
connection like any other. alt-svc is added to HTTP/1.1 and HTTP/2
responses when HTTP/3 is on.
WebTransport is the extended CONNECT (:protocol: webtransport) as ASGI
type=webtransport. Streams and datagrams are demultiplexed in wt.rs.
weft.webtransport.WebTransportSession and weft.contrib sit on top of
that message protocol. Stream and datagram sends after an incoming
session are not yet reliable (the QUIC connection is locally closed on
that path); receive and the CONNECT handshake are.
TLS is rustls. --tls-cert / --tls-key may be repeated for SNI.
--hsts and --redirect-http are server-side. ACME is left to the
reverse proxy.
Each connection is an Rc<Conn> in a slab, with its state in a RefCell.
A borrow is never held across a call into Python, because Python code can
re-enter send or receive. The connection task alternates between reading
the next head (bounded by the keep-alive timeout when idle, the header
timeout otherwise) and pumping a request.
On close, it flushes any response the server wrote itself (for up to 1 s), resolves outstanding waiters, shuts down the write side, and lingers briefly reading until the client closes. The linger avoids a reset that would destroy the response when unread input is pending.
Limits and errors the server answers itself:
- 400 for malformed heads and framing conflicts;
- 431 for a head larger than
--max-head(64 KB by default); - 500 when the application fails before responding;
- 503 when
--max-connectionsis reached.
A worker is one Worker with one WeftEventLoop, on one thread.
weft/server.py binds the listening socket once, then runs:
- inline, the default single worker;
- processes (
--workers N), spawned with the socket passed to each child. Crashed workers restart. A worker whose lifespan startup fails ends the server (exit code 3); - threads (
--workers N --worker-mode thread), each with its own loop. They run in parallel only on free-threaded Python. The module declaresPy_mod_gil = Py_MOD_GIL_NOT_USED, so importing it does not re-enable the GIL.
All workers accept from the same socket. On Unix each worker accepts
through Tokio directly. On Windows a worker that shares the listener accepts
on a small dedicated thread (src/winaccept.rs) and hands connections to
its runtime. The dedicated thread is there for two reasons:
- When several processes wait on one listener, a non-blocking
accept()can block inside Winsock if another process takes the connection first. On the worker thread, that would hold the GIL until the next connection. The accept thread instead usesAcceptEx, which the kernel completes with a connection of its own and which can be cancelled. - Pending
AcceptExrequests are served most recent first, so a worker that kept one posted would take every connection. The thread posts only onceWSAPollreports a queued connection, and cancels if another worker got it within a few milliseconds.
The accept thread costs one handoff per connection and none per request. A single worker on a listener it owns accepts through Tokio on every platform.
--reload and SIGHUP replace process workers one at a time without
dropping the listener.
Shutdown (SIGINT/SIGTERM, Ctrl+C on Windows, or the supervisor's stop event) follows this order:
- stop accepting and close idle connections;
- let in-flight requests finish, answering them with
connection: close, until--graceful-timeout; - cancel what is left;
- run lifespan shutdown.
- No runtime thread. The request path never crosses threads, so it has
no queues, no GIL handoff and no
call_soon_threadsafe. The only extra thread is the Windows accept thread described above, which touches connections once, before any request is read.--wsgi-threadsis an opt-in pool around the application call only. - No Python-level protocol code. asyncio's transports and protocols are not used for the server's sockets.
- No ACME. Certificate issuance is left to Caddy (or another proxy).