Non-ASGI protocols (raw TCP)¶
Most BlackBull traffic is HTTP/1.1, HTTP/2, or WebSocket — protocols that map
cleanly onto the ASGI (scope, receive, send) contract. Some protocols do not:
MQTT, Redis RESP, a raw line protocol, the PostgreSQL wire protocol. They have no
method, path, status, or headers, so forcing them through ASGI would be a leaky
abstraction.
The Non-ASGI bridge lets such a protocol run inside the same BlackBull
process, sharing the connection machinery and the event system, while speaking
the socket directly. You register an async handler with raw_handler and give it
a port:
from blackbull import BlackBull
app = BlackBull()
@app.route(path='/')
async def index():
return "HTTP here; raw TCP echo on :9000."
@app.raw_handler('echo', port=9000)
async def echo(reader, writer, ctx):
while True:
data = await reader.read(1024)
if not data:
break
await writer.write(data)
app.run(port=8000) # HTTP on 8000, echo on 9000
A full runnable version is in examples/echo_tcp.py.
The handler contract¶
A raw handler is an async callable (reader, writer, ctx) that owns the
connection for its entire lifetime — it decides when to stop. This is
deliberately unlike HTTP's request-per-connection model; raw protocols are
typically long-lived and stateful.
readeris anAbstractReader:await reader.read(n),await reader.readexactly(n),await reader.readuntil(sep).writeris anAbstractWriter:await writer.write(data),await writer.close().ctxis aProtocolContextcarryingpeername,sockname,ssl,connection_id(an opaque hex string, unique per connection, for log correlation),protocol(the registered name), andaggregator(for emitting events — see below).
When the handler returns, or raises, BlackBull closes the connection. A raised
exception is reported through the error event and otherwise isolated — one bad
connection never affects another.
register_protocol_handler(name, handler, *, port=...) is the non-decorator
form of raw_handler and behaves identically.
How dispatch works¶
BlackBull routes every accepted connection through a single protocol
registry. http1 and http2 are built-in registry bindings on the shared
HTTP listener; a raw_handler adds a binding on its own dedicated port.
Connections arriving on a raw protocol's port skip HTTP detection entirely and
go straight to the handler. WebSocket is unaffected — it is still reached through
an HTTP Upgrade on the HTTP listener, not as a top-level protocol.
The bridge is dormant by default: an app with no raw_handler binds only the
HTTP port and runs no raw code paths.
Protocol ownership¶
HTTP-family wire handling stays in BlackBull's core. Non-HTTP protocols register through extensions and raw handlers; do not force them into ASGI request/response events. gRPC already runs over the shared HTTP/2 transport; HTTP/3 is unsupported.
Events¶
Raw protocols emit the protocol-agnostic Level B events (subscribe with
@app.on):
| Event | Fires when | Detail |
|---|---|---|
connection_accepted |
a connection is accepted | peername, protocol |
connection_closed |
the connection closes | peername, protocol, duration_ms |
message_received |
the handler reports an inbound message | protocol, message_type, payload_size |
message_sent |
the handler reports an outbound message | protocol, message_type, payload_size |
error |
the handler raises | scope, exception |
message_received / message_sent are emitted by the handler itself when it has
parsed an application-level message, via the aggregator on ctx:
if ctx.aggregator is not None:
await ctx.aggregator.on_message_received('echo', 'data', len(data))
connection_accepted and connection_closed fire automatically.
State, and who answers an exchange¶
A raw protocol is stateful by default: stateful=True says that an
exchange can depend on what an earlier one left behind, which is true of a
broker, a session-oriented line protocol, and most things worth writing a raw
handler for. It decides who may answer:
- Its own port is served by one worker.
app.run(port=8000, workers=4)with araw_handlerscales HTTP across all four workers and runs the raw protocol on one; if that worker crashes it is respawned and re-adopts the listener. The other workers close the descriptor at startup, so nothing else can answer on it even by accident. - The shared port is given up once workers multiply. A binding with a
detectoris also reachable on the HTTP port by first-byte sniffing — but that port is served by every worker, so a later exchange would be answered by a worker that never saw the earlier one. Withworkers > 1a stateful binding stops claiming there and stays reachable on its own port. With one worker there is nothing to scatter across, and both routes work. - A stateful protocol with no port of its own is refused, before the
workers fork, because there is no way to give it one owner. Give it a port,
run
workers=1, or saystateful=Falseif it really keeps nothing.
@app.raw_handler('metrics', detector=LineDetector(), stateful=False)
async def metrics(reader, writer, ctx):
... # each exchange stands alone, so any worker may answer it
Limitations¶
- TLS on a
raw_handlerport is the server's certificate.tls=Trueserves the binding's port through the certificate the server was configured with; the binding cannot name one of its own. An HTTP listener can. --reloadpinsworkers=1when a port-bound protocol is registered: the exec handoff carries the HTTP sockets only.
A note on the server class¶
The server class that drives all this is blackbull.server.Server (it serves
both ASGI and non-ASGI listeners). Its previous name, ASGIServer, remains as a
backward-compatible alias, so existing
from blackbull.server import ASGIServer imports keep working.