Skip to content

blackbull.server.protocol_registry

blackbull.server.protocol_registry

Unified protocol registry — Sprint 50.

BlackBull dispatches every accepted connection through a single :class:ProtocolRegistry. http1 and http2 are built-in bindings; non-HTTP protocols (raw TCP, and later MQTT/Redis) register their own bindings via :meth:BlackBull.raw_handler / :meth:BlackBull.register_protocol_handler.

A binding owns protocol selection, its own framing reads, and Actor construction. ConnectionActor peeks only a tiny protocol-agnostic discriminator prefix (decouple-connection-detection, Stage 2); the 24-byte HTTP/2 preface read and the HTTP/1.1 request-line read live in :class:Http2Binding / :class:Http1Binding, reached through the single :meth:ProtocolBinding.serve entry point.

Two dispatch routes:

  • Detection (the shared HTTP listener): ConnectionActor peeks the discriminator and asks each :class:ProtocolBinding via :meth:claims — ALPN first, then the ordered cleartext chain (http2 preface, http1 fallback) — then replays the peeked bytes to the winner's :meth:serve.
  • Port-bound (raw protocols): a binding registered with port= gets its own listening socket; connections there skip detection entirely.
Note

Do not export the internal classes from blackbull/__init__.py; the public surface is :meth:BlackBull.raw_handler and :meth:BlackBull.register_protocol_handler.

RawProtocolHandler = Callable[['AbstractReader', 'AbstractWriter', 'ProtocolContext'], Awaitable[None]] module-attribute

Async (reader, writer, ctx) -> None — owns one connection's lifetime.

ConnectionView dataclass

Everything a :class:ProtocolBinding needs to build its Actor.

Assembled once per connection by :class:ConnectionActor and handed to the selected binding's serve_* method. Keeps the binding API narrow and decouples bindings from ConnectionActor's internals.

Http1Binding

Bases: ProtocolBinding

HTTP/1.1 — the cleartext fallback (matches any first line).

Http2Binding

Bases: ProtocolBinding

HTTP/2 — ALPN h2 or the cleartext connection preface (RFC 9113 §3.4).

ProtocolBinding

One connection-level protocol.

A binding declares how many leading bytes it needs to recognise a connection (:attr:detect_prefix_len) and whether it :meth:claims a given peeked prefix; the winner's single :meth:serve then performs its own protocol reads from a reader positioned at the start of the stream (the peeked bytes are replayed via a :class:~blackbull.server.recipient.PrefixReader).

Collapsing the old serve_alpn / serve_cleartext / serve_raw trio into one serve(conn) is what lets ConnectionActor stay protocol-agnostic (decouple-connection-detection, Stage 2): the 24-byte HTTP/2 preface read and the HTTP/1.1 \r\n request-line read now live in the bindings, not in the dispatcher.

claims(prefix, alpn)

Unified detection predicate: does this binding own a connection whose first bytes are prefix (with negotiated alpn)?

The single selection seam for cleartext + shared-port dispatch (decouple-connection-detection, Stage 1). Default delegates to :meth:matches_cleartext; :class:RawBinding overrides it to consult its :class:ProtocolDetector. alpn is accepted so a future binding can claim on the negotiated token, not just the wire prefix.

matches_cleartext(first_line)

Return True if this binding owns a cleartext connection whose first line is first_line. Default: no match.

on_detect_timeout(conn) async

Called when the peer connected but sent no discriminator within the detection deadline. Default: close silently (the caller closes the transport) — appropriate for a protocol with no meaningful "you were too slow" wire message. HTTP overrides this to emit a 408. Lets ConnectionActor stay free of protocol-specific status strings (decouple-connection-detection, Stage 3).

serve(conn) async

Drive one connection. conn.reader is positioned at the first byte of the stream (detection peeks are replayed), so the binding reads whatever framing it needs — no bytes are pre-consumed on its behalf.

ProtocolContext dataclass

Context passed to a non-ASGI protocol handler.

Carries connection metadata and the shared :class:EventAggregator without exposing any ASGI concept (no scope / receive / send).

ProtocolDetector

Bases: ABC

Inspects the first bytes of a connection to identify the protocol.

Stateless — one instance is shared across all connections. Used for first-byte sniffing on shared ports (e.g. MQTT + HTTP on one port); consulted by ConnectionActor._dispatch() after ALPN detection and before the http1 cleartext fallback.

protocol_name abstractmethod property

Human-readable protocol name for logging.

detect(first_bytes, alpn) abstractmethod

Return True if first_bytes matches this protocol.

ProtocolRegistry

Single source of truth for all connection-level protocols.

Pre-populated with the built-in HTTP bindings; one instance per app. The cleartext-detection order is fixed: http2 (preface) before http1 (fallback) — http1 must be last because it matches any first line.

cleartext_bindings property

Ordered cleartext-detection chain (http2 then http1).

port_bindings property

Raw bindings that need their own listening socket, keyed by port.

__bool__()

Truthy once a non-HTTP protocol is registered (HTTP is always present).

register(name, handler, *, detector=None, port=None)

Register a non-ASGI protocol handler. Raises on duplicate name.

A detector enables shared-port sniffing (see :class:ProtocolDetector). When several registered detectors could match the same first bytes, dispatch picks the first registered one — raw_bindings preserves insertion order.

RawBinding

Bases: ProtocolBinding

A user-registered non-ASGI protocol, bound to its own listening port.

detect_prefix_len property

Bytes the detector needs to recognise the protocol. A detector may declare prefix_len; otherwise one byte (the common first-byte sniff, e.g. MQTT's 0x10) suffices. Port-bound bindings never detect → 0.

claims(prefix, alpn)

A raw binding claims a shared-port connection when its detector recognises the first bytes. Port-bound bindings (no detector) never claim via detection — they own their own listening socket instead.