Skip to content

Listeners — the sockets you ask for

app.run(port=8000) is one listener stated the short way. When a deployment needs more than one socket — cleartext and TLS at once, two certificates, an admin port bound to loopback — you state them directly instead:

import ssl
from blackbull import BlackBull, Listener, Tcp

tls = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
tls.load_cert_chain('cert.pem', 'key.pem')

app = BlackBull()

@app.route(path='/')
async def index():
    return 'served on all four'

app.run(listeners=[
    Listener(Tcp(8080)),                  # cleartext: HTTP/1.1, h2c, WebSocket
    Listener(Tcp(8082)),                  # another one, same stack
    Listener(Tcp(8443), tls=tls),         # TLS: ALPN picks h2 or http/1.1
    Listener(Tcp(9443), tls=tls),         # and another
], workers=4)

All four HTTP listeners run in each worker process.

What a listener says

Listener(where, speaks='http', tls=None, workers=None)
where Tcp(port, host=None), Unix(path), or InheritedFd(fd)
speaks 'http', or the name of a protocol registered with raw_handler
tls an ssl.SSLContext, or None for cleartext
workers 'all' or 'one' — left unset it follows speaks

speaks='http' selects the stack that detects HTTP/1.1, h2c and WebSocket upgrades; under TLS, ALPN picks between h2 and http/1.1. So the four ports above need no protocol configuration — they differ only in address and in whether TLS terminates there.

Tcp(port) binds every interface, IPv4 and IPv6. Naming a host binds that interface and nothing else, which is the point of naming it:

Listener(Tcp(9000, host='127.0.0.1'))     # reachable from this machine only

TLS belongs to the listener

Each listener terminates the certificate it names, so two ports can present different ones — or one can be cleartext while its neighbour is not:

app.run(listeners=[
    Listener(Tcp(8080)),                     # no certificate here
    Listener(Tcp(8443), tls=public_cert),
    Listener(Tcp(9443), tls=internal_cert),  # a different one
])

app.run(certfile=..., keyfile=...) still means HTTPS: it builds the context that the one listener it constructs terminates.

Who serves a listener

workers says how many worker processes own a listener, and it is the only place that question is answered:

  • 'all' — every worker accepts on it. The default for speaks='http', and what lets HTTP scale.
  • 'one' — exactly one worker accepts on it. The default for anything else, because a protocol that keeps state between exchanges must answer them from the same process.

Workers that do not own a listener close its descriptor at startup, so a protocol with one owner cannot be answered elsewhere even by accident.

Saying it the short way

port=, unix_path= and inherited_fd= still work and are unchanged — they build one listener for you:

app.run(port=8000)                       # Listener(Tcp(8000))
app.run(unix_path='/run/bb.sock')        # Listener(Unix('/run/bb.sock'))
app.run(inherited_fd=3)                  # Listener(InheritedFd(3))

They are a shorthand for the same thing, not a separate path, so listeners= replaces them rather than joining them — passing both is refused.

Reading back what got bound

Tcp(0) asks the OS for a free port, which is what tests want. The request is not rewritten; the answer is read back:

server = Server(app, listeners=[Listener(Tcp(0))])
server.open_socket()
for listener, socks in server.bound_listeners:
    print(listener.speaks, socks[0].getsockname())

server.port and server.unix_path describe the first listener, as they always did.

For Linux abstract UNIX sockets, server.unix_path retains the leading NUL used to connect. Application addresses derived from the socket and logs render it as @name; the port is None.

Startup is all-or-nothing

BlackBull does not publish or serve a partial listener set. It first acquires every requested TCP, Unix, inherited-fd, and protocol-specific socket; only then does it expose the set through bound_listeners. If a later bind or socket inspection fails, all sockets acquired by that attempt are closed and the Server remains unbound, so the same object can retry open_socket().

Once event-loop servers are created, accepting still waits for lifespan startup to complete. A failure while grouping listeners by TLS context, during lifespan startup, or while starting acceptance closes every earlier group and reclaims the lifespan task before the original startup exception is reported.

A failing shutdown is a failing process

run() reports what the application answered:

the app … run() …
sends lifespan.startup.failed raises, carrying the app's message
sends lifespan.shutdown.failed raises on the way out, carrying the app's message
never answers waits at startup; at shutdown, ends at the cleanup budget

A raising @app.on_shutdown hook therefore makes a single-worker process log the failure and exit 1, so a supervisor can act on it. With workers > 1 or --reload, failed worker cleanup also makes the master exit non-zero; check the workers' logs for the cause.

Signals. SIGINT and SIGTERM both run the shutdown. SIGTERM lets in-flight requests finish for up to BB_WORKER_DRAIN_TIMEOUT; keep it below your supervisor's grace period (docker stop: 10 s).

Put shutdown work in @app.on_shutdown, not in a SIGTERM handler of your own. BlackBull takes SIGTERM while serving and restores your handler afterwards; it then runs once per signal received, after the drain, and its exit status wins. Off the main thread BlackBull leaves SIGTERM alone.

Server.stop() ends run() cleanly at any point, including during lifespan startup. Cancelling stop() propagates CancelledError; concurrent cleanup failures are logged separately.