Internals¶
This page defines ownership boundaries for contributors. Use the Guide for application APIs.
NamedTuple fields with imported types need resolvable annotations when the class
is created. Their modules omit from __future__ import annotations so package-wide
beartype checks can decorate the generated constructors. Run just typecheck
when changing these records.
Actor model¶
Each mutable state machine has one owner. HTTP/1.1 reuses request work sequentially on a connection; HTTP/2 requires separate stream work because requests overlap. MQTT coordinates routing and output with serial inboxes. Do not mutate another connection's or stream's state through a backchannel.
Read ConnectionActor, HTTP1Actor, RequestActor, HTTP2Actor and
StreamActor in blackbull/server/. The HTTP actors drive protocol loops;
their inherited inbox does not imply that dispatch goes through it.
gRPC: no dedicated actor¶
gRPC reuses HTTP/2 stream work and the receive/send boundary. Keep call
semantics in blackbull.grpc; see gRPC design.
Startup¶
blackbull.server.warmup.warm_tls() returns the number of attempts in which
both TLS endpoints completed their handshake, rather than the number tried.
Disabled warm-up or a missing TLS context returns zero; incomplete attempts
remain best-effort and do not prevent startup.
Events and failures¶
Connection events belong to ConnectionActor. Request-lifecycle events
belong to the application layer and fire exactly once on native and external
ASGI transports. request_completed follows the global middleware chain,
so buffering middleware has finished sending before observers run.
WebSocket clients clean up in the calling task. Interrupted cleanup must be retryable: cancellation alone creates a protected recovery task, and concurrent close calls alone allocate a completion event. Keep reader ownership until its join finishes or times out; cancellation of that join must not discard it.
Isolate application and stream failures from sibling streams. Connection
framing or HPACK failures terminate the connection; peer GOAWAY permits
accepted response work to drain. Read blackbull/event_aggregator.py and
app.py; public contracts are in Events.
Read-path invariant¶
The native server carries typed Connection objects end to end. scope
means an ASGI dictionary for an external host or BB_FORCE_ASGI_SCOPE=1.
Create it after pre-dispatch mutations; shared state and extensions
remain live for late updates.
Every Connection carries one header contract, and whoever creates it is
responsible for it: names are lowercase tchar, values have no edge SP/HTAB
and no CTL, host is at most one valid authority, and scheme is lowercase.
The HTTP/1.1 and HTTP/2 parsers produce it; Connection.from_scope
establishes it for a scope and refuses one that cannot meet it (a bare 400,
or close 1002 for WebSocket); code that builds a Connection directly must
keep it too.
Code past dispatch relies on it and does not normalise again; code that
rewrites a field after construction keeps it.
Built-in middleware's non-Connection guards preserve direct-call fallbacks;
they do not convert request scopes.
A NativeResponse keeps the same field contract from the moment it exists:
every way a field enters it (construction, header/trailers assignment,
append on either view) checks that field once, lowercases its name and
trims edge SP/HTAB from its value, so middleware and senders read fields
exactly and check nothing again. Its header and trailer
lists are _MinimalResponseHeaders; change them only through add, extend
and discard, which keep the located framing fields true.
A connection owns one read buffer, server or client. BufferReader owns receive policy,
ConnectionProtocol performs transport callbacks and pauses, and
ReadBuffer owns bytes and cursors. Detection must not consume the prefix;
an upgrade must retain bytes already received.
The WebSocket client retains validated Connection options in its response
recipient for both persistence and upgrade checks. Keep the persistence rule
shared with ordinary HTTP responses.
Treat transport size hints as advisory. Release grown allocations only through reader policy, which knows whether the connection is between messages.
Bounded reads¶
BufferReader is the only line reader; other readers refuse read_head
and readuntil with NotImplementedError. Positive limits include separators and constrain accumulation, not only
completed results. Every keep-alive request gets the same budget. Empty EOF
returns an empty head; partial EOF raises IncompleteReadError; overruns
carry bounded evidence in ReadLimitExceeded.
The protocol owns the verdict: a request-line terminator within the budget
permits a 431 header-overrun response; an unterminated or oversized request
line receives 400. See tests/unit/test_read_head_contract.py.
Backpressure¶
Pause when a consumer falls behind, but release the pause before waiting for more bytes. A reader waiting for a peer-selected large frame must not deadlock against its own high-water mark. Slice HTTP bodies before reading; a declared chunk size must not select an unbounded allocation.
Rejecting requires lingering¶
Closing with unread socket bytes can reset the connection and discard a
written refusal. ConnectionProtocol.linger_close discards remaining input
within byte and time bounds. Keep both: one bounds work, the other prevents
a refused peer from retaining the connection. Delivery stays best-effort
when the peer does not read.
Accept admission¶
New TCP listeners carry IPPROTO_TCP so asyncio enables TCP_NODELAY
on accepted connections, as uvloop does.
Count descriptors from accept() to close on every supported loop,
including TLS handshakes and refusals. connection_made misses handshakes.
Pause acceptance at the cap plus refusal reserve; do not consume the
process's descriptor budget to manufacture refusal replies.
Read _AcceptGate in blackbull/server/server.py.
Receive-path invariant¶
Native body readers return bytes, None for completion, or raise
ClientDisconnected. Empty bytes are not the completion sentinel.
Build ASGI events only at the ASGI boundary. Both channels share completion
state so switching between them cannot await input that will never arrive.
Read blackbull/server/recipient.py and blackbull/connection.py.
Keep-alive drain invariant¶
Before reusing an HTTP/1.1 connection, consume unread request content within the drain budget or close. Every answer path, including one bypassing routing, reaches this boundary. Never drain after a framing violation: the next bytes cannot be distinguished from a smuggled request.
A WebSocket upgrade leaves the request loop and cannot rely on its drain.
Refuse handshakes declaring content before switching; Content-Length: 0
may still upgrade. Read blackbull/server/http1_actor.py.
Send-path invariant¶
Protocol senders pass fragments to BaseSender._write_many(parts); only
that method chooses joining or vectored writes at the 32 KiB gate.
Adapters must support both paths: testing only small responses misses the
vectored backing contract.
See tests/architecture/test_writer_backing_contract.py.
HTTP/2 encodes each field block once and fragments it to the connection's
peer frame-size limit. Enqueue the complete HEADERS/PUSH_PROMISE and
CONTINUATION sequence before awaiting drain; writers must not suspend between
its parts. A cancelled drain leaves the queued sequence intact; a failed write
disables further writes. Raw FrameBase.save() and client send_raw_frame()
preserve single-frame output for fault injection.
Normalize ASGI events through blackbull.native, not separate handler and
sender conversions. A conversion gives the result its own field lists, and
senders read them in place without copying. Validate and lowercase response
heads and trailers once on entry. Push headers describe
a promised request and bypass response header injection and middleware.
NativeResponse owns response shapes. StaticFiles produces them;
Cache retains a storable terminal body to generate its ETag, while
Compression retains headers until it can encode a complete body.
These buffers do not store or dynamically compress streamed, trailer-bearing
or file responses. ETag matching and authority validation live in
protocol.field_grammar; the cache only canonicalizes a validated origin.
Bound socket drains and flow-control waits. File transfers use bounded
chunks under the write deadline rather than a whole-file timeout.
Read blackbull/server/sender.py and blackbull/native.py.
Parse-path invariant¶
Use bulk byte operations and blackbull.protocol.field_grammar.
Fast paths may reuse validated work but must not weaken checks or change
refusal diagnostics. A failed pre-scan falls back to detailed validation.
Header-line cache keys are exact bytes; admit only fully validated lines.
Learned entries remain per connection and bounded by count, line size and
accounted bytes. Shared seeds must be immutable, validated by the real
grammar, drawn from specification-enumerated values, and exclude framing
fields. Do not turn observations from one peer into shared state.
Both roles read HTTP/1.1 through blackbull/protocol/http1.py; a rule that
differs by role belongs in its role policy section, with its RFC 9112 section.
Read it and its cache tests.
Protocol and deadline owners¶
HTTP clients share HTTP1ResponseHeadReader for bounded response-head parsing.
HTTP1ResponseRecipient owns HTTP bodies and connection reuse; WebSocket
validates the upgrade directly and hands buffered bytes to its frame reader.
WebSocket-over-H2 sessions reuse their CONNECT stream's sender and flow-control state.
| Concern | Read first |
|---|---|
| HTTP/2 framing and retirement | blackbull/server/http2_actor.py, blackbull/protocol/frame.py |
| WebSocket framing and complete-message delivery | blackbull/server/ws_codec.py, websocket_actor.py, permessage_deflate.py |
| Shared timer scanner and cancellation | blackbull/server/deadline.py |
| HTTP/2 liveness, field-block and PING deadlines | blackbull/server/http2_actor.py |
| MQTT routing and sole-writer inboxes | MQTT broker design |
Connection-owned credit work survives stream-consumer cancellation; stream-owned credit must not be emitted after retirement. Request END_STREAM ends only input, not application or response work. HPACK belongs to one factory per connection. See HTTP/2 policies and Conformance before changing error scope or lifetime.