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 run in one process, and every worker serves every one of them. Before listeners existed this deployment cost four processes, each with the full worker count.

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.