Skip to content

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.