Internals¶
For readers who want to know what BlackBull is doing under the
hood. Application authors don't need any of this — @app.route
and the Guide cover the user surface.
This page exists for contributors and for anyone curious about
how the protocol stack is built.
Actor model¶
BlackBull's server core is organized as a small hierarchy of actors — each one owns its state and communicates with the others through queues, not shared variables. No actor reaches into another actor's internals; coordination is exclusively via messages.
This isn't a third-party actor framework — every actor is a
plain asyncio.Task reading from an asyncio.Queue:
class Actor:
def __init__(self):
self._inbox: asyncio.Queue[Message] = asyncio.Queue()
async def run(self):
async for msg in self._inbox_iter():
await self._handle(msg)
async def send(self, msg):
await self._inbox.put(msg)
The benefit isn't sophistication — it's the discipline. An actor that can only communicate by sending messages is much easier to reason about under burst load than one with arbitrary back-channels into other components.
Hierarchy¶
ASGIServer (one per process; owns the listening socket)
│
└── ConnectionActor (one per accepted TCP connection)
│
├── HTTP1Actor (HTTP/1.1 driver)
│ └── RequestActor (one per connection, rebound per request)
│
├── HTTP2Actor (HTTP/2 connection driver)
│ └── StreamActor (one per HTTP/2 stream)
│ └── RequestActor (per-stream ASGI call, short-lived)
│
├── WebSocketActor (after upgrade, replaces HTTP1/2Actor)
│
└── raw protocol handler (non-HTTP bridge: the binding calls a
long-lived (reader, writer, ctx) handler
directly — no separate actor layer)
ASGIServer is the only non-actor in this hierarchy — it's a
plain asyncio server that owns the listening socket and
spawns a ConnectionActor task per accepted connection.
Everything below it is an Actor subclass.
That task is started eagerly: the coroutine runs inside the
transport's connection_made callback until its first suspension
— which is the peek that waits for the peer's first bytes, the
same place it would have parked anyway. asyncio.create_task
would instead queue that first step for a later loop iteration,
so the prologue (deadline handle, protocol-detection order) costs
a scheduling hop that a connection pays once. Measured at 0.387
µs per accepted connection, so it is churn bookkeeping rather
than a latency win — an accepted connection waits for the peer's
packet either way. Eager start does not move where a failure
lands: a coroutine that raises before its first suspension
completes the task with that exception rather than raising out of
the transport callback, so the connection's done-callback still
reports it.
A separate EventAggregator translates the low-level
inter-actor messages into the wire-level user-facing events
documented in Events —
request_disconnected, error, websocket_message,
connection_accepted, etc. The request-lifecycle events
(request_received, before_handler, after_handler,
request_completed) are emitted by the application layer
instead (BlackBull._dispatch, except request_completed,
which fires after the global middleware chain returns so a
buffering middleware such as Compression has finished
sending), so they fire identically under BlackBull's own
server and under external ASGI hosts.
Responsibilities¶
ASGIServer¶
- Owns the listening socket.
- Accepts TCP connections; spawns a
ConnectionActortask per connection. - Supervisor strategy: restart with backoff — accept loop must stay alive even if individual binds fail.
- The
app_startupandapp_shutdownevents are surfaced separately, byBlackBullitself as part of the ASGI lifespan handshake — not by the server (see Events).
ConnectionActor¶
- Owns the reader / writer for one TCP connection, and the
connection-lifecycle events: it fires
connection_acceptedon entry andconnection_closed(with duration) on exit for every protocol — HTTP and non-HTTP alike. - Detects the protocol without any HTTP-specific wire knowledge:
it peeks a tiny protocol-agnostic discriminator prefix, asks each
registered
ProtocolBindingwhether itclaimsit (ALPN first, then the cleartext chain), and replays the peeked bytes to the winning binding'sserve. Each binding does its own framing reads (the HTTP/2 preface, the HTTP/1.1 request line) —ConnectionActorholds no hardcoded byte counts, delimiters, or status strings. - Supervisor strategy: isolate — one connection dying does
not affect others. A handler/actor exception from any protocol is
caught here, emitted once as an
errorevent, and the connection is closed.
HTTP1Actor¶
- Drives the HTTP/1.1 request/response cycle.
- For each request: points its
RequestActorat the request and awaits completion, then loops for keep-alive. The actor, the sender, and the body recipient are built once per connection and rebound per request — a connection dispatches one request at a time, so rebinding is indistinguishable from rebuilding except for the allocation. - On
Connection: Upgrade(WebSocket): hands the reader / writer to a newWebSocketActorand exits. - Supervisor strategy: isolate — an error in one request closes the connection only.
RequestActor¶
- Owns one HTTP/1.1 request lifetime. Under HTTP/1.1 one instance
serves the whole connection, rebound to each request in turn
(
bind); HTTP/2 builds one per stream, because streams are concurrent and would otherwise interleave their state. - Receives the parsed headers and body from
HTTP1Actor. - Calls the ASGI app, collects the response, writes it back via the connection's writer.
- Fires the
errorevent when the app call raises. (The request-lifecycle events are emitted by the application layer, not here.)
HTTP2Actor¶
- Drives the HTTP/2 connection state machine: settings, flow control, GOAWAY.
- For each
HEADERSframe with a new stream ID, spawns aStreamActor. - Owns the connection-level send window;
StreamActors block on it when the window is exhausted. - Keeps its state native:
stream.connis aConnectionon every lane, and the one place an ASGI scope can come into existence is the app boundary in the sharedRequestActor.run, matching what HTTP/1.1 does (theBB_FORCE_ASGI_SCOPE=1compat lane). Anything that reads the request mid-flight — a latePRIORITY_UPDATE, a server push reading its parent — reads attributes, never a dict. - Supervisor strategy: propagate — a framing error on the
connection is fatal.
HTTP2Actorsends GOAWAY and exits, causingConnectionActorto close.
The boundary is a snapshot, taken after every pre-dispatch
mutation of the Connection. Fields the two share by reference
(state, extensions) stay live afterwards — which is how a
PRIORITY_UPDATE arriving after dispatch still reaches the
application under extensions['http.response.priority'].
That sharing is why the extension became the hint's only home. The
top-level scope['http2_priority'] alias, removed in v0.75.0, was
a dispatch-time copy: a late PRIORITY_UPDATE could not reach it,
so the two disagreed for the rest of the request.
StreamActor¶
- Owns one HTTP/2 stream.
- Assembles DATA frames into a request body, calls the ASGI app, writes response frames back.
- Supervisor strategy: isolate — stream errors send
RST_STREAMand exit; the connection continues serving other streams.
WebSocketActor¶
- Owns one WebSocket connection after the upgrade handshake.
- Runs the fragment-assembler internally — the ASGI app receives only complete messages (see WebSockets — Fragmented messages).
- Supervisor strategy: isolate — a protocol error closes this connection only.
Non-HTTP protocol handlers (the Non-ASGI bridge)¶
- A non-HTTP protocol registers a
ProtocolBindingwhoseservecalls a long-lived(reader, writer, ctx)handler directly — there is no separate actor layer betweenConnectionActorand the handler.ConnectionActorreaches it the same way it reaches HTTP: by a binding (port-bound, or matched by a first-byte sniff) for protocols that don't map to ASGI request/response: MQTT, raw TCP, and the like. - Unlike the HTTP actors the handler does not run a request loop — it owns the connection until it decides to close (raw protocols are typically stateful and persistent).
gRPC: no dedicated actor¶
Unary gRPC has no Actor of its own. It is a dialect of HTTP/2, so it
rides the existing HTTP2Actor → StreamActor stack: each gRPC call is
just an HTTP/2 stream and therefore already gets per-stream actor
isolation for free. The gRPC-specific logic lives at the application
layer — BlackBull._dispatch routes content-type: application/grpc to
blackbull.grpc.serve_grpc, which uses the ordinary ASGI
(scope, receive, send) interface (the http.response.trailers event
carries grpc-status). ConnectionActor and HTTP2Actor stay entirely
gRPC-agnostic. The full rationale — and where streaming RPCs may
reopen the question — is in the
gRPC assessment.
- Connection timing, error isolation, and the connection_closed
event are provided uniformly by ConnectionActor.run() for every
protocol (an earlier RawProtocolActor Layer-2 wrapper was folded
into ConnectionActor so HTTP and non-HTTP share one lifecycle
owner).
- The handler is where a protocol's own actors live. MQTT, for
example, runs a lifespan-owned BrokerActor and TapActor
plus a per-connection MQTT5Actor beneath it — and
these are the framework's first genuine users of the Actor
inbox (the HTTP actors override run() and call each other
directly). See
MQTT broker design for the deep dive
and Non-ASGI protocols for the
user-facing API.
Supervisor strategies — at a glance¶
| Actor | Strategy | Rationale |
|---|---|---|
ASGIServer |
Restart with backoff | Accept loop must stay alive |
ConnectionActor |
Isolate | One bad connection must not affect others |
HTTP1Actor |
Isolate | Error closes the connection cleanly |
RequestActor |
Isolate | Error fires error event; connection survives |
HTTP2Actor |
Propagate to ConnectionActor |
Framing error is connection-fatal (GOAWAY) |
StreamActor |
Isolate (RST_STREAM) |
Stream error is stream-fatal only |
WebSocketActor |
Isolate | Protocol error closes this WS connection only |
| raw protocol handler | Isolate (via ConnectionActor) |
One raw connection's handler failure is bounded |
Message types¶
All inter-actor messages are typed dataclass instances, not
dicts. A base class carries a sender reference for reply
routing under the ask pattern:
@dataclass
class Message:
sender: Actor | None = None # None for externally-originated
@dataclass
class StreamHeadersReceived(Message):
stream_id: int
headers: HeaderList
end_stream: bool
@dataclass
class WindowUpdate(Message): # ask-pattern reply
stream_id: int
increment: int
The HTTP/2 actor does not exchange dataclass messages with its callers:
per-stream request/response events flow as ASGI dicts through the
HTTP2Recipient (put_event), handler coroutines are tracked as
asyncio.Task objects (_stream_tasks), and connection-level frames
are dispatched through the Responder registry
(blackbull/server/response.py). HTTP/1.1 and WebSocket equivalents are
colocated with their respective actors.
How exceptions propagate¶
| Scenario | Strategy | Reason |
|---|---|---|
RequestActor handler raises |
Isolate + re-emit as error event |
Handler error must not kill the connection |
HTTP2Actor framing error |
Propagate to ConnectionActor |
GOAWAY required; connection is unusable |
StreamActor flow-control violation |
Isolate (RST_STREAM) |
Stream-fatal only; other streams keep going |
| raw protocol handler raises | Caught by ConnectionActor, re-emit as error event |
Bridge handler error must not kill other connections |
ConnectionActor unhandled |
Log + close connection | One connection's failure is bounded |
ASGIServer accept error |
Backoff + retry | Server stays alive across transient errors |
Read-path invariant¶
A connection owns one buffer, and every protocol reads by moving a cursor over it.
ConnectionProtocol is an asyncio.BufferedProtocol: the kernel writes
straight into the connection's ReadBuffer through get_buffer, and the
actor's coroutine parks on a future that buffer_updated resolves. There
is no second buffer between the socket and the parser — which is the
whole point, because the cost being removed was never the parked
coroutine but reading through another layer that had already copied the
bytes once.
One non-obvious rule keeps that buffer cheap: get_buffer treats the
transport's sizehint as advisory, not as a minimum window. uvloop's
cleartext path passes a fixed 64 KiB hint on every read; honouring it
would grow every connection's buffer to 64 KiB and the release policy
would hand it back — a 64 KiB alloc/free churn per request. The buffer
grows only when bytes actually arriving need the room, and a grown
allocation returns to the floor only once the connection has shown a few
small messages, so a connection repeating large bodies reuses its
allocation.
Who decides, and who acts¶
The read path is three objects, split by competence rather than by layer:
| object | owns |
|---|---|
BufferReader |
the receive decisions — how much to let the peer send, when to stop it, when to wait, and when a grown allocation goes back to the floor |
ConnectionProtocol |
the transport — socket callbacks, the callback↔coroutine rendezvous, and executing pause_reading / resume_reading |
ReadBuffer |
the bytes — the allocation, the cursors, and the scanning primitives |
The reader gets the decisions because reading is the only activity that knows both what was asked for and what was consumed; every judgement on this path follows from that pair. The transport front end previously decided when to stop reading, which meant reconstructing the reader's state from outside — resident bytes plus a guess, taken from the rendezvous future, at whether anybody was parked. The guess had a gap: the future clears when a reader is woken, not when it stops waiting, so an arrival in that window armed a pause the next park released. Moving the decision deleted the inference rather than relocating it.
Two of these decisions cross the split as published state rather than as
a call, in opposite directions: the reader writes read_offer (how much
the next recv should be able to deliver) and reads reading_paused.
Both live on the per-arrival or per-read path, where an attribute load is
free and a method call is not — the /conn A/B measured one such call at
the same order as the whole delta it was chasing. Ownership is unchanged
by that: the party that decides is the party that writes.
ReadBuffer keeps no policy for the same reason. It reports the one
moment a message is provably gone — a compaction that leaves it empty —
and offers release_to_floor(); whether to take that offer is the
reader's call, because only the reader knows whether the connection is
between large messages or done with them.
Three consequences follow, and each retires a workaround that existed only because the buffer was somewhere else:
- Protocol detection consumes nothing.
ConnectionActorinspects the smallest discriminating prefix viaAbstractReader.fill, which for a buffer-owning reader is just "do not calltake". The winning binding receives a stream still positioned at its own first byte, so there is no prefix to replay. - The message head is found in one scan.
read_head(limit)looks for\r\n\r\nacross everything resident, resumably, and the head leaves the buffer in a single copy — rather than onereaduntilcall per header line. - A hand-off carries nothing. When an upgrade passes the connection to WebSocket or h2c, whatever the peer already sent is already resident; the successor gets the same reader.
read_head is a contract, not a capability¶
read_head lives on AbstractReader, so a protocol asks for a head the
same way whatever is underneath it, and never branches on which reader it
holds. Three outcomes, because the caller answers each differently:
| outcome | means | HTTP/1.1 answers |
|---|---|---|
| the head | complete | parse it |
b'' |
EOF, nothing sent | close, silently |
IncompleteReadError |
EOF part-way in | 400, with .partial as evidence |
ReadLimitExceeded |
over the byte budget | see below |
BufferReader overrides it with the single scan; the default
implementation is the line-by-line loop, which is what a reader holding
only readuntil can do. Both are held to the same behaviour by
tests/unit/test_read_head_contract.py, which drives every reader kind
through the same cases — a reader that disagreed on one of them would put
a hole in exactly one deployment shape.
The same reader contract supports readuntil(separator, limit=0). A positive
limit includes the separator and is enforced while BufferReader accumulates
bytes, so a chunk-framing line cannot use the transport's released
backpressure window as unbounded storage. AsyncioReader temporarily applies
the same budget to the native stream read, and PrefixReader counts replayed
bytes and underlying bytes as one unit, including separators that cross that
boundary. An overrun remains resident in whichever reader owns those bytes
and carries bounded evidence in ReadLimitExceeded; the HTTP/1.1 recipient
turns that evidence into its 400 framing response.
The public reader methods choose the policy once: limit <= 0 enters an
unbounded implementation, while a positive value enters a bounded
implementation whose precondition is that the budget is positive. Concrete
readers override those two paths separately, so accumulation loops do not
scatter checks for whether a limit is enabled.
The default line-by-line read_head path passes the head limit to each line
read as a per-line accumulation bound, then applies the same limit to the
completed head. If a later line overruns, evidence from earlier lines is
prepended before the exception reaches HTTP/1.1. This keeps one malformed
line bounded while retaining the request-line terminator needed to distinguish
a 431-sized header block from a 400-sized unterminated request line.
limit is what makes the read bounded, and it applies to every request
on the connection, not just the first. A keep-alive peer's second head
gets the same budget as its first.
One breach, two verdicts¶
ReadLimitExceeded carries the bytes the reader saw, and the protocol
decides what they were:
- the first CRLF lands inside the budget → the lines are well-formed and merely numerous → 431 (RFC 6585 §5);
- the first CRLF lands beyond it, or there is none → the start-line never ended → 400 (RFC 9112 §3). Telling a peer its header fields are too large when it never sent a header field is a misdiagnosis, and it advises a retry that cannot help.
The evidence travels with the exception rather than being re-derived from the reader, so both reader kinds reach the same verdict from the same bytes.
Backpressure pauses for a handler that is behind, not one that is waiting¶
The reader stops the transport once unconsumed bytes reach a high-water mark, so a fast peer cannot grow the buffer without bound while a handler falls behind. That rule inverts for a reader that is blocked waiting for more: the bytes it is parked on are exactly the ones the pause is refusing to read, and the connection hangs with no error and no response.
So parking releases the pause, and a reader that is already waiting arms no
pause in the first place. A reader about to block is starved, not behind, and
the condition backpressure exists to prevent is not the one in play.
asyncio.StreamReader resumes at the same point for the same reason.
One shape reaches this and the request body does not. A WebSocket frame is
read with one readexactly of a size the peer chose, so it can exceed the
mark; it is bounded only by BB_WS_MAX_FRAME_PAYLOAD (64 MiB), which is a
ceiling on the damage rather than a slice. Both HTTP/1.1 body framings are
sliced by BB_BODY_CHUNK_SIZE below the mark before they are read — a
Content-Length body by its remaining count, a Transfer-Encoding: chunked
body by what is left of the chunk in progress. Slicing the chunked case is
what keeps a peer-declared chunk-size from choosing how much the server
buffers: reading a whole chunk would have to reopen the very pause the
high-water mark had just applied.
Rejecting requires lingering¶
Enforcing a byte budget means, by construction, not reading the rest —
and closing a socket with unread bytes in its receive queue makes the
kernel send RST, which discards data already written. The 431 or 400
would never arrive. ConnectionProtocol.linger_close briefly discards
what the peer is still sending before closing, bounded both by bytes and
by time: no byte cap would hand an attacker the unbounded read the budget
exists to refuse, and no deadline would let a slow peer hold an
already-answered connection open. nginx calls this lingering_close.
It is skipped unless the connection is closing with bytes it chose not to consume — a completed request leaves the buffer empty, so the ordinary close stays a bare close.
Receive-path invariant¶
The request body crosses the framework as bytes, and the end of
the body is carried by the call protocol rather than by a field beside
the payload:
chunk = await recipient.next_chunk() # bytes → more body follows
chunk is None # → body complete
raise ClientDisconnected # → peer vanished mid-body
None rather than b'', for the same reason NativeResponse decides
presence with is not None: an empty body is a real body. The sentinel
is unambiguous on both framings — a zero-length chunk is the
terminator in chunked encoding (RFC 9112 §7.1), and a Content-Length
slice is never empty.
Connection.body() / stream() — and read_body / stream_body
underneath them — consume that channel directly. The ASGI
http.request dict is built only by HTTP1Recipient.__call__ /
HTTP2Recipient.__call__, which is to say only when something asks for
the ASGI encoding: a full-form handler calling receive(), or an
external host. It used to be built unconditionally, so a handler using
conn.body() paid one dict per chunk that it never read.
The two channels share one end marker on both protocols. __call__
does not consult it — a full-form handler calling receive() past the
end still gets http.disconnect (H1) or still waits for the disconnect
event (H2), exactly as before — but it does set it, so a reader that
starts on one channel and finishes on the other cannot block on a queue
that will never be fed again.
Measured on the real H1 recipient — 4 KiB chunks, 2000 requests per run, mean ± SE over 5 runs:
| channel | ns/chunk |
|---|---|
receive() (ASGI) |
803.2 ± 52.6 |
next_chunk() (native) |
536.3 ± 12.3 |
267 ns/chunk, or 4.27 µs saved on a 64 KiB upload — and the dict count for that upload goes from 16 to 0.
Keep-alive drain invariant¶
On HTTP/1.1 the connection is a byte stream with no framing of its own:
the only thing separating request N from request N+1 is that
exactly as many body bytes were consumed as the headers declared. So
every request that stays on the connection passes through the drain in
HTTP1Actor's loop tail before the connection is reused — not just the
ones a handler answered:
if inner_receive.needs_drain(): # loop tail — one site, no exceptions
if not await inner_receive.drain(_MAX_KEEPALIVE_DRAIN):
break # over the bound → close instead
The predicate belongs to the loop, not to any one answer path. A
handler that ignores receive (a POST that 404s) is the obvious case,
but a request can also be answered before routing — OPTIONS * is
answered server-wide per RFC 9112 §3.2.4, and RFC 9110 §9.3.7 permits
content on it. Any such path that skipped the drain would leave the
body resident in the connection's buffer, where the next read_head
finds it and the parser reads it as a request-line: body followed by
GET / HTTP/1.1 becomes the method bodyGET. The client's next request is then destroyed by the
framing of the previous one, and behind a reverse proxy that pools
upstream connections the leftover bytes prefix another client's
request — the standard request-smuggling shape.
Draining is bounded (_MAX_KEEPALIVE_DRAIN, 64 KiB) so an unbounded
read is never the price of keeping a connection alive; past the bound
the connection closes, which is safe because any desync dies with it.
A recipient that has already seen a chunked-framing violation reports
needs_drain() as False for the same reason — that stream is
desynced, so draining it would be reading smuggled bytes rather than
discarding them.
A WebSocket upgrade leaves the loop before the tail is reached, so
it cannot rely on the drain — and it must not. After a 101 the same
octets are request content (per Content-Length) and the first
frames (per the switch); left alone they were read back as frames and
delivered to the application as a message. That contradiction is what
a front end and this server would disagree about, so
_do_ws_handshake refuses a handshake that declares content —
400, before anything switches — instead of resolving it in one
direction. RFC 9110 §9.3.1 gives content on GET no defined
semantics, so nothing legitimate is refused; Content-Length: 0 is
not content and still upgrades. This is the one place where the
drain's logic and the framing decision differ, and it differs because
OPTIONS is the opposite case: §9.3.7 permits content there, which
is exactly why that path drains rather than refusing.
Send-path invariant¶
Protocol senders never choose between joining and vectored I/O
themselves. They always call BaseSender._write_many(parts),
and the internal size gate (_VECTORED_JOIN_THRESHOLD, 32 KiB)
decides the path:
- Join path (≤ 32 KiB total): concatenates parts into one
buffer and writes once. Cheaper for small payloads — avoids
the memoryview setup,
sendmsgsyscall overhead, and writer re-registration under backpressure that vectored I/O imposes per call. - Vectored path (> 32 KiB): uses
transport.writelineswith memoryviews. Worth the overhead only for large payloads.
Callers must never call transport.writelines directly with
small parts. The gate is the single decision point, and the
invariant keeps performance predictable across payload sizes.
The obligation runs the other way too: anything the sender writes
through must implement both branches. A backing object with only
write serves every response under 32 KiB and fails every response
above it — and because the gate's own tests use a double that has both,
that failure is invisible to them. Whatever is under AsyncioWriter
owes it write, writelines, drain, close, and a transport
(for sendfile); tests/architecture/test_writer_backing_contract.py
reads that list out of the sender's source rather than restating it.
sendfile goes out a megabyte at a time¶
AsyncioWriter.sendfile splits the transfer into _SENDFILE_CHUNK
(1 MiB) calls to loop.sendfile rather than handing it the whole
file at once. The kernel copies the same total either way; the
boundary exists so BB_WRITE_TIMEOUT has somewhere to re-arm.
Without it, the static-file path was the one response body no
write bound could reach. "Send this 4 GiB file within 30 seconds"
is not a policy any operator can set, so a single unbounded
loop.sendfile had to go unguarded — and a peer that read a large
file one byte per second held the connection, its FD, and its
transport for as long as it liked, which is precisely the
slow-read slowloris shape the timeout was built to stop. Per
chunk the policy becomes "make a megabyte of progress within the
budget", which bounds a stall without penalising a legitimately
large transfer.
The chunk sits far above ordinary web assets deliberately: anything smaller still goes out in exactly one call, so the common path is unchanged and only genuinely large files pay for the loop. The header flush that precedes the transfer runs under the same bound as every other drain.
Response values that come from tables¶
Two values are rebuilt on every single response and drawn from a small, known domain, so both are precomputed once at import:
- The status line.
_STATUS_LINESmaps everyHTTPStatusmember to the completeHTTP/1.1 <code> <phrase>\r\nline, CRLF included. - Small Content-Length values.
_CONTENT_LENGTHSholds the decimal ASCII for0…8192, which covers the body sizes that dominate request count.
Both fall back to the original expression for an input outside the
table's domain — a status code IANA has not registered renders with an
empty reason phrase (legal: RFC 9112 §4 makes it optional) rather than
raising KeyError, and a large body formats with str(n).encode().
Tests assert the tables agree with the expressions they replaced across
the whole domain, because a table that disagrees anywhere is a wire
bug that no amount of speed pays for.
Parse-path invariant¶
The HTTP/1.1 parser validates bytes with C-level bulk operations,
never with Python-level loops or index arithmetic. In practice that
means bytes.translate, bytes.find, bytes.split, bytes.strip,
bytes.count, and precompiled regexes — and it means resisting the
intuition that fewer allocations or fewer passes must be faster.
The idiom used throughout _parse is a delete-the-allowed table:
_TCHAR_OCTETS = b"!#$%&'*+-.^_`|~0123456789ABC...xyz"
if key.translate(None, _TCHAR_OCTETS): # non-empty ⇒ a non-token octet
raise BadRequestError(...)
Deleting every permitted octet leaves the empty string for valid
input, so a non-empty result is the rejection — one pass in C, and
no allocation in the accept case (CPython returns the shared empty
bytes). Which direction to use depends on the set's size: a large
allowed set (request-target, tchar) is cheapest as a translate
delete table, a small forbidden set (the Host authority delimiters) as
a regex character class.
Field values are checked once for the whole header block rather than once per header. Deleting everything a block may contain leaves only CR, LF and the CTLs a value may not carry; if what remains tiles exactly into CRLF pairs, no field value can hold a forbidden octet and the per-header check is skipped. A block that fails the pre-scan falls back to the per-header regex, so error messages never change — the bulk pass is a fast path, never a rejection.
Three plausible-sounding alternatives were measured and rejected; do not reintroduce them without new numbers:
| Idea | Why it loses |
|---|---|
while + find() line loop instead of split(b'\r\n') |
1.5–2.2× slower. split does the finding and the slicing in C; the loop moves the iteration back into Python. |
| Interning pool for well-known header names | Slower than key.lower(). The lookup key is a fresh slice every request, so its hash is never cached and hashing costs more than lowercasing. |
Offset arithmetic instead of value.strip(b' \t') |
~1.5× slower. Two Python-level scans lose to one C call. |
The through-line: an optimisation that replaces a C bulk operation with Python bytecode pays ~30 ns per byte instead of ~2 ns, and no reduction in pass count or allocation count makes that back.
Validated header-line cache¶
The cheapest validation is the one already done. A keep-alive peer
resends byte-identical header lines on every request — User-Agent,
Accept, Accept-Language, Cookie — so each connection keeps a
bounded dict mapping the exact raw line bytes to the
(lowercased-name, stripped-value) pair that line produced the first
time it was seen and fully validated. A hit replaces the colon split,
the token check, the lowercase, the OWS strip and the value scan with
one dict lookup.
Four properties are what make this a replay of validated work rather than a way to skip validation, and none of them is optional:
- The key is the exact line bytes. One changed byte is a miss, and a miss is validated from scratch. There is no normalisation, no case-folding and no prefix matching on the lookup path.
- Only a line that reached the bottom of the loop is admitted.
Admission is the last statement in the body, after every
raise, so a rejected line can never enter. - The cache is per connection. It lives on the
HTTP1Actorinstance, so validated lines never cross a connection — and therefore never cross a tenant. - It is bounded (64 entries). The key can be attacker-controlled, so a peer cycling unique header names must not be able to grow it; admission simply stops at the limit rather than evicting.
The precedent is HPACK's dynamic table, which is this same idea blessed at protocol level — HTTP/1.1 merely lacks the wire form for it.
The shared spec table¶
A per-connection cache cannot help the first request on a
connection — there is nothing in it yet — and for a
Connection: close client that first request is the entire
interaction. Populating a cache it will never read made that shape
21 % slower than having no cache at all.
So the cache has a second tier that needs no warming: a process-wide
table of header lines whose value set is fixed by a specification,
validated once at import and shared by every connection. Fetch
Metadata's Sec-Fetch-Site / -Mode / -Dest / -User, the client
hints' boolean and platform forms, and the fixed tokens of RFC 9110
(Connection, TE, Pragma, Upgrade-Insecure-Requests, DNT)
are the same bytes for every client on every deployment. On captured
browser traffic they are 56 % of all header lines, and that share
does not decay with connection churn.
HPACK's static table is the same idea, again — and again HTTP/1.1 lacks the wire form, so the table lives in the parser instead.
Three rules keep it from being an over-fit:
- Specification, not observation. A line qualifies because a spec
enumerates its values, not because it was frequent in a capture.
That is why no
User-Agent,sec-ch-ua,Accept-Language,Cookie,RefererorHostline is seeded, though every one of them is more frequent than most entries that are. - No framing header, ever.
Content-Length,Transfer-Encoding,Host,ExpectandUpgradeare excluded by an explicit check that raises at import. A shared table is the last place a framing decision should come from. - Built through the real rules. Each entry is validated at import with the same octet checks the parse loop applies, and a test asserts every entry equals what parsing that line actually produces — so a hand-written pair cannot drift from the parser.
The table is immutable and never written to at runtime; learning goes to the per-connection dict alone, so nothing a peer sends can affect another connection.
Why it is not a benchmark artefact. A load generator resends one
byte-identical request, which is the cache's best case and not what a
real client does. The numbers below therefore come from captured
traffic, not from a model of it: a real Chromium loading a real page
(document, 2 stylesheets, 3 scripts, 12 images, 2 XHRs), with every
request head recorded exactly as it arrived
(bench/hotpath/capture_browser_headers.py).
That capture is what makes the mechanism legible. 21 requests carried
275 header lines drawn from only 26 distinct ones — 8 of which
(Host, Connection, User-Agent, Accept-Encoding,
Accept-Language, three client hints) appeared on every single
request, while Accept, Referer and the Sec-Fetch-* family varied
by destination. Because the cache is keyed per line, a request
that changes four of its thirteen lines still hits on the other nine;
what governs the hit rate is the variety of distinct lines on a
connection, not whether requests are identical. Chromium also emits
its headers in different orders for navigations and subresources —
which a whole-block cache would be defeated by and a per-line one does
not notice.
A cache that can only learn has a ceiling of
(lines − distinct) / lines — every distinct line must miss once —
which is 90.5 % on this capture. The shared table exceeds it,
because a pre-seeded line never misses at all.
The one thing a single capture cannot settle is how far a page spreads across connections: Chromium opens up to six per origin, and the learned tier sees fewer repeats the wider the spread. The seeded tier does not care. Re-dealing the same requests across N connections shows which half is which:
| connections | hit rate | seeded | learned | parse cost |
|---|---|---|---|---|
| 1 (as captured, no latency) | 96.7 % | 56.0 % | 40.7 % | 8.96 → 5.85 µs (−35 %) |
| 2 | 94.2 % | 56.0 % | 38.2 % | 8.93 → 6.17 µs (−31 %) |
| 4 | 89.8 % | 56.0 % | 33.8 % | 8.95 → 6.28 µs (−30 %) |
| 6 (Chromium's per-origin max) | 85.5 % | 56.0 % | 29.5 % | 8.90 → 6.51 µs (−27 %) |
| 12 | 72.7 % | 56.0 % | 16.7 % | 9.00 → 7.27 µs (−19 %) |
Read the two middle columns rather than the total. That the seeded column is flat is structural — the table does not depend on connections, so churn cannot erode it. That it sits at 56 % is a property of this client's header mix, and another client's would put it somewhere else. The shape generalises; the level does not.
Where these numbers come from
AMD Ryzen 5 7600X (6C/12T, Zen 4), Ubuntu 24.04 on WSL2 — a
desktop VM, not an isolated benchmark host. CPython 3.14.6,
stock asyncio (BB_UVLOOP=0). Both arms in one session: the
"before" column is v0.66.0 (450df4a) in a git worktree,
the "after" column the same commit plus this work. min of 7
for the µs-scale parser figures, medians of 5–7 interleaved
sweeps for anything driven by wrk (whose run-to-run spread on
this host is ~10 %). Browser capture: Edge/Chromium 150 headless.
Two caveats that bound how far these generalise. The capture is n = 1 — one page load in one browser, whose requests are intra-correlated by construction, so it is a point estimate with no variance estimate. And localhost has no latency, which is why Chromium used a single connection; over a real RTT it opens up to six per origin, so −17 % is the figure to quote for WAN traffic, not −30 %.
Full record, including how to size a proper multi-site sample from
the HTTP Archive corpus:
bench/results/sprint87-frontend-spike-20260730/ENVIRONMENT.md.
Every harness under bench/hotpath/ prints its own environment
stamp (bench/hotpath/provenance.py) as its first output.
So on traffic shaped like this capture, −27 % to −35 % is what one can expect — the lower figure over a real network. Not a guarantee: the hit rate follows how much of a client's header set has spec-enumerated values and how many requests share a connection, and both vary by client and by deployment. A client sending mostly bespoke headers on connections it closes immediately gets close to nothing; it does not get less than nothing, which is the part that is guaranteed.
Short-lived connections. The seeded tier needs no warming, so
reconnection costs only the learned half. Replaying the same capture while
closing the connection every K requests — HttpArena's limited-conn
profile is K = 10, and Connection: close is K = 1:
| requests per connection | parse cost |
|---|---|
1 (Connection: close) |
8.75 → 8.21 µs (−6 %) |
| 2 | 8.96 → 7.23 µs (−19 %) |
10 (limited-conn) |
8.90 → 6.21 µs (−30 %) |
| keep-alive | 8.77 → 6.27 µs (−29 %) |
Without the seeded tier the K = 1 row was +21 % — a real regression on connection-churn traffic, and the reason the tier exists.
On ordinary traffic that merely fails to repeat — every header value
unique, or the cache already full — the path is still 5 % faster
than not having the cache, because the same change hoisted the
per-request settings read out of _parse to connection setup. A
connection whose headers carry a per-request unique value (a request
id, a timestamp, a rotating token) misses on those lines and still hits
on the rest.
The worst case, stated¶
The cache key is attacker-controlled, so the bounds above are not
tuning — they are the security argument, and the resource that needs
bounding is bytes, not entries. With an entry-count bound alone,
64 entries × BB_HEADER_MAX_LINE (8 KiB) retains ~1 MiB per
connection, which a peer pins by sending each line once and then
idling on keep-alive — 9.6 GiB across 10k connections, against 988 B
of real need.
Hence the per-line cap and the byte budget. A peer choosing the line
length to maximise damage (bench/hotpath/line_cache_worst_case.py
sweeps it, because a per-line cap moves the worst case down to just
under itself rather than up to the limit):
| worst case | at | |
|---|---|---|
| CPU | +19.2 % vs no cache | 64 never-repeating 128 B lines/request |
| memory | 16 KiB/connection (153 MiB at 10k) | same |
(Same host and toolchain as above; the memory figure is accounted bytes — key plus the retained name/value slices — which is what multiplies by concurrent connections.)
Both are bounded, and the CPU figure sits on top of a request the peer chose to make expensive — 40.7 µs against ~6 µs for an ordinary one. Above the per-line cap the cache is skipped entirely, lookup included, so a peer sending 8 KiB lines makes the parse faster than the no-cache build rather than slower.
None of this applies to HTTP/2: HPACK already does it on the wire, and better. This is the HTTP/1.1 path recovering what H/2 gets for free.
The pieces around the actor core¶
The actor hierarchy is the control side. The data side is the protocol stack:
- HTTP/1.1 parser —
blackbull/server/parser.py. Pure Python. - HTTP/2 frame layer —
blackbull/protocol/(frame types, flow-control windows, RFC 9218PRIORITY_UPDATE). Header compression delegates to thehpacklibrary — the only third-party Python package in the protocol stack — wrapped by the BlackBull-ownedhpack_fastpath.pyfor the common short-header path. - WebSocket codec —
blackbull/server/ws_codec.py(RFC 6455 framing) +blackbull/server/websocket_actor.py(fragment reassembly, RFC 7692permessage-deflate). - Deadline subsystem — per-process tick scanner tracking
every connection's idle timer and the write timeout that
bounds a single
drain(); arming and disarming a deadline are attribute writes + set operations rather than per-armloop.call_latercalls. Every configurable timeout goes through it, so turning one on costs no per-request timer. The write deadline binds its task when it is armed rather than when it is built, because HTTP/2 drains one connection-level writer from per-stream tasks. - HTTP/2 liveness watchdog —
HTTP2Actordoes not use the scanner for its own three time bounds, because the scanner cancels the task parked in the read and this actor's read is a frame loop the server usually intends to keep. A per-connection watchdog observes three timestamps instead — the last frame, an open header block, an outstanding liveness PING — and ends the connection by closing the writer, which turns the parked read into the EOF the loop already handles. It sleeps until the earliest applicable deadline rather than on a fixed tick, so an idle connection costs one wake-up per idle period. The idle bound probes rather than reaps: an idle HTTP/2 connection is normal (a browser holds one across a page's lifetime, a gRPC channel idles between calls), and only a question distinguishes it from a dead one. - Sender / Recipient —
blackbull/server/sender.py,blackbull/server/recipient.py. Buffer responses on the way out, parse incoming frames on the way in. Cache headers (Date, common content-types) for hot-path savings. - Send-path size gate — protocol senders hand multi-fragment
responses (
(head, body),(frame_header, payload)) toBaseSender._write_many, which owns the join-vs-vectored decision: fragments totalling at most 32 KiB are joined and sent as onewrite(); larger payloads use vectoredtransport.writelinesto skip the full-body copy. The gate exists because CPython's selector transport makeswritelinesa net loss for small payloads — per-fragmentmemoryviewallocations plussendmsgsetup outweigh the avoided memcpy, and when the kernel send buffer is full,writelinesattempts a send and re-registers the writer on every call wherewrite()merely appends to the transport buffer. Measured crossover on a drained socketpair: joining wins up to 16 KiB, vectored I/O wins from 64 KiB. Protocol senders express what they have, never how to send it — the transport strategy lives in exactly one method.
For the wire-level behaviour of each layer, the RFC conformance suites are the up-to-date source of truth.
Why pure-Python¶
blackbull[speed] adds uvloop as an optional dependency.
The HTTP/1.1 parser, HTTP/2 frame layer, and WebSocket codec
are all BlackBull's own code in pure Python. The one exception
is HPACK header compression,
which delegates to the hpack
library (pure Python, layered under the BlackBull-owned
hpack_fastpath.py); re-implementing a conformant HPACK
encoder/decoder is a sub-project of its own, and hpack is the
de-facto Python reference. Two reasons we keep the rest in
pure Python:
- Debuggability. An issue in HTTP/2 flow control or
frame parsing can be stepped into with
pdb. The stack is the application's code, not an opaque C extension. This applies tohpacktoo — it is itself pure Python, so HPACK bugs remain debuggable in-process. - Identity. BlackBull exists in part to demonstrate that CPython is fast enough for a pure-Python ASGI implementation when the framework itself stays out of the way. Swapping in a C parser would make it a different project.
That said: stdlib C extensions (_json, _hashlib, _ssl)
are used freely — they ship with CPython and don't require a
separate build step. "Pure Python" means no third-party C
extensions in the protocol stack, not "no C anywhere."
Next¶
- Conformance — RFC test suites that exercise the stack end-to-end.
- Events — the user-facing event API,
produced by
EventAggregatorfrom the low-level messages shown above. - HTTP/2 — the user surface of the HTTP/2 protocol features whose internals are summarized here.