roadrunner_listener (roadrunner v0.11.1)

View Source

Listener gen_server — owns the listening socket and the acceptor pool for one named roadrunner instance.

Plain TCP is backed by gen_tcp with the legacy inet_drv backend. The OTP-27 {inet_backend, socket} NIF path was tried but adds significant own-time overhead on short-lived connections via per-socket-option lookups. TLS is backed by ssl, gated by the tls opt. Both paths share the same roadrunner_transport tagged-socket abstraction.

On init/1 the listener opens the listen socket, builds the shared roadrunner_conn:proto_opts() (dispatch + body limits + timeouts + max_clients counter), and spawn-links num_acceptors (default: one per online scheduler, at most 10) roadrunner_acceptor processes that pull from the same listen socket. Connection workers are unlinked from the acceptor so a single connection crash doesn't take the pool down.

All duration and interval values in opts() are in milliseconds — request_timeout, keep_alive_timeout, rate_check_interval, hibernate_after, and slot_reconciliation.interval.

Summary

Types

HTTP/1.1 listener tunables (under {http1, ThisMap} in protocols).

HTTP/2 listener tunables (under {http2, ThisMap} in protocols).

HTTP/3 listener tunables (under {http3, ThisMap} in protocols).

Listener configuration map.

One protocol entry in the listener's protocols list. Either a bare atom (http1 / http2 / http3) for default opts, or a tuple {Proto, ProtoOpts} carrying protocol-specific tuning. HTTP/1 tunables live under http1_opts/0, HTTP/2 under http2_opts/0, and HTTP/3 under http3_opts/0.

WebSocket session tunables (under ws in the listener opts).

Functions

Graceful shutdown. Closes the listen socket immediately so no new connections are accepted, broadcasts {roadrunner_drain, Deadline} to every active conn, and then polls the live-connection counter until it hits zero or Timeout milliseconds elapse. Conns still alive at the deadline are hard-killed via exit(Pid, shutdown).

Return runtime introspection for a listener

Broadcast a {roadrunner_drain, Deadline} notification to every conn / WS session in the listener's drain registry without stopping the listener or waiting on the counter.

Return the actual TCP port the listener is bound to.

Register Pid in the listener's drain registry so drain/2 and notify_drain/2 reach it with {roadrunner_drain, Deadline}. The connection processes register themselves; this is for a process that lives outside the conn lifecycle (a long-lived worker, say) and wants the same notification. Exits with noproc when no listener by that name is running.

Atomically swap the listener's compiled route table without restarting it. The new Routes are compiled via roadrunner_router:compile/2 (with the listener's middlewares re-baked) and published to persistent_term; in-flight conns keep using whatever they read at request-resolve time, but every subsequent dispatch sees the new table.

Start a named listener that binds the given TCP port.

Return the listener's lifecycle phase

Stop a listener and release its port. In-flight conns are not waited on.

Types

http1_opts()

-type http1_opts() ::
          #{max_request_line => 1..2147483647,
            max_header_line => 1..2147483647,
            max_header_block => 1..2147483647,
            max_header_count => 1..2147483647,
            linger_timeout => 0..2147483647}.

HTTP/1.1 listener tunables (under {http1, ThisMap} in protocols).

The four max_* keys cap inbound request sizes; oversized input is rejected before the handler runs (414 for the request line, 431 for headers). Raise them for clients that send large headers (long JWTs / cookies / tracing metadata); lower them to tighten the attack surface.

  • max_request_line — request-line byte cap (method + target + version). Over-cap → 414 URI Too Long. Default 8192.
  • max_header_line — per-header-line byte cap. Over-cap → 431. Default 8192.
  • max_header_block — cumulative header-block byte cap. Over-cap → 431. Default 10240.
  • max_header_count — maximum number of header lines. Over-cap → 431. Default 100.
  • linger_timeout — after a response that closes the connection before the request was read to its end (a 413 over the body cap, a 431, a handler answering without reading its body), how long in milliseconds the connection keeps reading and dropping what the client still sends before it closes. Closing with unread input makes the kernel reset the connection, which can wipe the response before the client reads it (RFC 9112 §9.6); lingering lets it through. It ends as soon as the client closes. 0 closes at once. Default 1000.

http2_opts()

-type http2_opts() ::
          #{conn_window => 1..2147483647,
            stream_window => 1..2147483647,
            window_refill_threshold => 1..2147483647,
            max_concurrent_streams => 1..2147483647,
            max_header_block => 1..2147483647,
            max_header_list_size => 1..2147483647}.

HTTP/2 listener tunables (under {http2, ThisMap} in protocols).

  • conn_window — connection-level receive window peak in bytes (1..2^31-1). Default 2^31-1, the largest allowed, sent as an early WINDOW_UPDATE(0, peak - 65535) after the server SETTINGS (RFC 9113 §6.9.2 starts it at 65535). Its credit is returned as DATA arrives, so it bounds no memory.
  • stream_window — the peak a stream's receive window grows to, in bytes (1..2^31-1). Default 16777216 (16 MB). Every stream opens at the RFC's 65535, so a stream refused or rejected before its body is read can draw at most that much; its window grows to the peak with its first refill, once its DATA is taken. An upload sends at most one window per round trip, so this bounds its rate: 16 MB allows about 160 MB/s over a 100 ms round trip, where the RFC's 65535 allows about 0.6 MB/s. A buffered body's credit is returned as it arrives, so the window bounds the rate, not the memory a body takes (max_content_length does). With manual body reading a stream holds at most its window until the handler reads it, up to stream_window × max_concurrent_streams per connection: lower stream_window if handlers read slowly and memory matters. Setting above conn_window is allowed but not useful — the conn-level peak is the binding constraint.
  • window_refill_threshold — refill trigger in bytes. When a window drops below this, the conn refills it back to its peak. Unset (the default), each window refills once it drops below half its own peak.
  • max_concurrent_streams — cap on concurrent client-initiated streams per connection, advertised via SETTINGS_MAX_CONCURRENT_STREAMS. HEADERS that would exceed it get RST_STREAM(REFUSED_STREAM). Default 100.
  • max_header_block — cumulative cap on an assembled HEADERS+CONTINUATION block (the CONTINUATION-flood guard); over-cap closes the connection with GOAWAY(ENHANCE_YOUR_CALM). Default 16384. This is the h2 counterpart to the {http1, ...} max_header_block opt, but the two are independent and default differently (h1 10240, h2 16384).
  • max_header_list_size — cap on the decoded (uncompressed) header-list size (RFC 7541 §4.1: sum of name + value + 32 per field), advertised via SETTINGS_MAX_HEADER_LIST_SIZE; an over-cap request gets 431 + RST_STREAM(NO_ERROR). Bounds a different unit than max_header_block (which caps the compressed block). Defaults to 2 * max_header_block, so raising the encoded cap lifts this one too unless set explicitly. The other way round, response headers are kept within the limit a client sends in its own SETTINGS_MAX_HEADER_LIST_SIZE (see roadrunner_handler:response/0).

http3_opts()

-type http3_opts() ::
          #{listeners => 1..1024,
            max_header_block => 1..2147483647,
            max_streams_bidi => 1..2147483647,
            max_field_section_size => 1..2147483647,
            qpack_max_table_capacity => 0..2147483647,
            qpack_blocked_streams => 0..2147483647,
            secret => binary()}.

HTTP/3 listener tunables (under {http3, ThisMap} in protocols).

  • listeners — number of reuseport listener processes in the QUIC pool (1..1024). They bind the same UDP port and share one connection registry; the kernel spreads inbound datagrams across them, so inbound demux parallelizes across cores. Default 8. 1 disables pooling (a single listener, no SO_REUSEPORT).
  • max_header_block — cap on the encoded request field section (the HEADERS block); over-cap answers 431. Default 16384. The h3 counterpart to the {http1, ...} / {http2, ...} max_header_block opts; the three are independent (h1 defaults to 10240, h2/h3 to 16384).
  • max_streams_bidi — cap on concurrent client-initiated bidirectional (request) streams, advertised to the peer in the QUIC transport parameters. Default 100. The h3 counterpart to the {http2, ...} max_concurrent_streams opt.
  • max_field_section_size — cap on the decoded (uncompressed) field-section size (RFC 7541 §4.1: sum of name + value + 32 per field), advertised via SETTINGS_MAX_FIELD_SECTION_SIZE (so conformant clients self-limit, RFC 9114 §4.2.2) and enforced after QPACK decode: an over-cap request gets 431. Bounds a different unit than max_header_block (which caps the compressed block). Defaults to 2 * max_header_block, so raising the encoded cap lifts this one too unless set explicitly. The h3 counterpart to the {http2, ...} max_header_list_size opt. The other way round, response headers are kept within the limit a client sends in its own SETTINGS_MAX_FIELD_SECTION_SIZE (see roadrunner_handler:response/0).
  • qpack_max_table_capacity: the most bytes of QPACK dynamic table (RFC 9204 §3.2) a client may set up to compress its request headers, advertised via SETTINGS_QPACK_MAX_TABLE_CAPACITY. Each connection holds at most this much table (counted as name + value + 32 per entry, §3.2.1). Default 4096, the HTTP/2 HPACK default; 0 turns the dynamic table off. The server's own response headers always use the static table.
  • qpack_blocked_streams: how many request streams of a connection may wait for dynamic table entries still on their way (RFC 9204 §2.1.2), advertised via SETTINGS_QPACK_BLOCKED_STREAMS. Most clients only put headers in the dynamic table when this is above 0. A waiting request is read once the entries it needs arrive; it holds no in-flight slot until then. Default 16; 0 allows none, so a client may only use entries it knows the server has.
  • secret — a binary of at least 32 random bytes the QUIC keys of the listener derive from: the key of the stateless reset tokens (RFC 9000 §10.3), so a client still talking to a connection the server has lost (after a restart, say) is told at once to stop rather than waiting out its idle timeout, and the key of the address validation tokens sent with NEW_TOKEN (§8.1.3), so a client coming back within the hour skips the limit on what the server sends before its address is validated. Every listener of the pool shares it. Keep it as secret as the TLS key. Give another server the same secret only if every packet of a connection reaches the server holding it: a server sent a packet for a connection it does not have answers with a valid reset, so anyone who can route packets to it can close connections on the others (RFC 9000 §10.3.2, §21.11). Unset, a random one is drawn when the listener starts, so a restarted listener can neither reset the connections of the one before it nor accept its tokens.

opts()

-type opts() ::
          #{port := inet:port_number(),
            routes =>
                module() |
                {module(), term()} |
                #{handler := module(),
                  state => term(),
                  middlewares => roadrunner_middleware:middleware_list()} |
                roadrunner_router:routes(),
            middlewares => roadrunner_middleware:middleware_list(),
            max_content_length => non_neg_integer(),
            ws => ws_opts(),
            request_timeout => non_neg_integer(),
            tls_handshake_timeout => pos_integer(),
            keep_alive_timeout => non_neg_integer(),
            num_acceptors => pos_integer(),
            max_keep_alive_requests => pos_integer(),
            max_clients => pos_integer(),
            max_concurrent_requests => infinity | pos_integer(),
            overload_mode =>
                refuse |
                {queue, #{max_queued => pos_integer(), timeout => pos_integer()}} |
                {shed, #{retry_after => pos_integer()}},
            socket_backlog => pos_integer(),
            ip => inet:ip_address(),
            recv_buffer => pos_integer(),
            min_bytes_per_second => non_neg_integer(),
            rate_check_interval => pos_integer(),
            body_buffering => auto | manual,
            slot_reconciliation => disabled | #{interval := pos_integer()},
            graceful_drain => boolean(),
            proxy_protocol => boolean(),
            real_ip =>
                #{trusted_proxies => [binary()],
                  trusted_hops => pos_integer(),
                  header => x_forwarded_for | forwarded},
            rate_limit =>
                #{rate := pos_integer(),
                  period => pos_integer(),
                  burst => pos_integer(),
                  sweep_interval => pos_integer()},
            hibernate_after => pos_integer(),
            handler_spawn => #{opts => [proc_lib:start_spawn_option()], start_timeout => timeout()},
            protocols => [protocol_entry(), ...],
            tls => [ssl:tls_server_option()]}.

Listener configuration map.

Required:

  • port — TCP port to bind. 0 lets the kernel pick an ephemeral port; query it back with port/1.

Routing (pick one):

  • routes => module() — single-handler dispatch. Every request goes to Module:handle/1 and roadrunner_req:state/1 returns undefined.
  • routes => {module(), term()} — single-handler dispatch with per-handler state. The opaque second element is reachable from the handler via roadrunner_req:state/1.
  • routes => #{handler := module(), state => term(), middlewares => [...]} — map form for single-handler dispatch; use it to attach per-handler middlewares (or future per-handler framework knobs) alongside the state.
  • routes => roadrunner_router:routes() — list of route entries; each entry is either a {Path, Handler} / {Path, Handler, State} tuple or a #{path := Path, handler := Handler, state => ..., middlewares => [...]} map. First match wins, so the specific route goes above the general one; a route the entries above it already answer in full is a config error and raises at listener start rather than turning into a runtime 404.

Whichever form is used, a handler that exports the optional roadrunner_handler callback init_route/1 has it run once per route when the listener starts (and on reload_routes/2), turning the route's state into the one the handler sees. A raise in it stops the listener from starting, with {init_route_failed, Path, Handler, Reason}.

Optional middleware and timing knobs (durations in milliseconds):

  • middlewares — listener-wide pipeline applied to every request.

  • max_content_length — request-body cap across HTTP/1.1, HTTP/2, and HTTP/3; an over-cap body answers 413 Payload Too Large (and resets the stream on h2/h3). Default 10 MB. A route can set its own, larger or smaller (roadrunner_router:route/0).

  • ws — WebSocket session tunables as a nested map (see ws_opts/0): max_frame_size (per-frame payload cap) and max_message_size (reassembled + decompressed message cap), both defaulting to 10 MB with over-cap closing the connection with code 1009, plus recv_buffer (per-session inet buffer override — bounds per-connection memory at high WebSocket concurrency; inherits the listener's recv_buffer when unset) and hibernate_after (idle-session hibernation timeout in milliseconds; off when unset).

  • request_timeout — header-read timeout on a fresh conn. Default 30 s.

  • tls_handshake_timeout — bound on the TLS handshake (TLS listeners only). The handshake runs in the connection process, not the acceptor, so a slow or silent peer never blocks other connections from being accepted; one that exceeds the bound is closed and reported as a per-connection {handshake, timeout} accept error, releasing its max_clients slot. On a TLS listener that also reads the PROXY protocol, the same bound covers the header read that precedes the handshake. Default 5 s.

  • keep_alive_timeout — idle timeout between requests on a keep-alive conn. Default 60 s.

  • num_acceptors — size of the acceptor pool. Default: one per online scheduler, at most 10. Every acceptor waits on the same listen socket, so more acceptors than schedulers only adds contention: on one or two schedulers, matching them accepts 35-55% more connections a second than 10 does, at about a third of the tail latency.

  • max_keep_alive_requests — requests served per conn before forced close. Default 1000.

  • max_clients — concurrent connection cap. Default 16384 (the modern Erlang/Elixir HTTP-server norm). A connection counts from the moment it is accepted, so on a TLS listener one still in its handshake holds a slot too. Connections accepted while already at the cap are closed immediately without a response. The effective cap is min(max_clients, the OS file-descriptor limit) — raise ulimit -n (and the systemd LimitNOFILE) for high concurrency, otherwise the acceptors hit emfile at the descriptor ceiling and emit listener_accept_error. Saturation memory scales as max_clients × recv_buffer, so memory-constrained deployments lower the cap or recv_buffer. Rejections are observable: each one emits listener_conn_rejected and increments the rejected count from info/1, so a rising rejected is the signal that the cap is the binding limit. For HTTP/2 and HTTP/3 the worst-case live-handler count is max_clients × max_concurrent_streams; bound it with max_concurrent_requests (default infinity) if heavy multiplexing memory is a concern.

  • max_concurrent_requests — cap on concurrent in-flight requests (live handler processes) across the whole listener, for the multiplexed protocols (HTTP/2 and HTTP/3). Default infinity (off). max_clients bounds connections and max_concurrent_streams bounds streams per connection, but their product (the worst-case live-handler count) is otherwise unbounded; at the defaults that product (16384 × 100 ≈ 1.6M) exceeds the BEAM process limit (+P, default 262144), so under heavy multiplexing it can exhaust the VM-global process table — failing spawns everywhere, not just this listener — on top of unbounded handler memory. This caps the product directly; set it to a finite value when enabling HTTP/2 or HTTP/3 under a high max_clients. Over-limit streams are refused with REFUSED_STREAM (h2) / H3_REQUEST_REJECTED (h3), which RFC 9113 §8.7 marks safe to retry; each refusal emits request_throttled and increments the throttled count from info/1. HTTP/1 is unaffected (one request per connection, already bounded by max_clients), except in shed mode below.

  • overload_mode — what happens to a request that arrives over max_concurrent_requests. Default refuse, which is the behavior described above. {queue, Opts} instead parks it until a slot frees, so handler memory stays bounded without shedding the request: latency rises under overload rather than the error rate. A request still waiting after Ms is refused exactly as refuse would, and one arriving when N are already parked is refused immediately, so overload still sheds rather than queueing without bound. Requires a finite max_concurrent_requests — there is nothing to queue behind otherwise, and asking for it without one is a configuration error.

    Opts takes max_queued and timeout, and both are derived when absent, so {queue, #{}} needs no tuning. timeout follows request_timeout, since a request already carries that deadline and waiting past it only produces a response nobody is left to read. max_queued follows the ceiling, so at most as many requests wait as run.

    Worth setting when a per-connection max_concurrent_streams is the only thing keeping handler memory down, because that ties concurrency to connection count: 64 connections with a window of 4 can never have more than 256 handlers running, however much budget is left. A listener-wide ceiling spends the budget you configured instead. Measured on 64 h2c connections against a cheap route, moving from a window of 4 to a window of 64 with a queued ceiling of 4096 took 252,259 to 397,747 resp/s with no refusals and concurrency still bounded.

    {shed, Opts} answers a request over the ceiling with 503 Service Unavailable and a Retry-After header (RFC 9110 §15.6.4, §10.2.3) instead of refusing its stream, so clients wait and come back rather than retrying at once. REFUSED_STREAM and H3_REQUEST_REJECTED invite an immediate retry (RFC 9113 §8.7), which suits a proxy that can send the request to another backend; a 503 with Retry-After is the signal for clients talking to this server directly. Opts takes retry_after, the seconds to send, default 1. The 503 goes out before any handler runs and carries no body. In this mode HTTP/1 requests count against the ceiling too, so it is one in-flight budget across every protocol: an HTTP/1 request over it gets the 503 with connection: close, before any of its body is read (the close lingers over a body the client may still be sending). A shed request is counted like a refused one: it emits request_throttled and increments the throttled count from info/1. A long-lived response (WebSocket, loop, stream) holds its slot until it ends, on every protocol. Requires a finite max_concurrent_requests.

    Shedding applies to the request ceiling only. A connection over max_clients is still closed without a response: answering it would mean a process, and on TLS a full handshake, for a client the server is turning away, which is the work the cap is there to stop. Set max_concurrent_requests below what max_clients allows, and the ceiling becomes the soft limit that sheds while max_clients stays the hard one.

  • socket_backlog — TCP listen backlog (kernel SYN/accept queue depth). Default 1024. Raise it for connection bursts (load tests, health-check storms); Linux clamps the effective value at net.core.somaxconn.

  • ip — local interface to bind. Unset (the default) binds all interfaces (0.0.0.0 / ::); set a specific address such as {127,0,0,1} to restrict the listener to loopback, e.g. a dev/admin/metrics endpoint that must not be reachable from the LAN. Applies to every socket the listener binds: plain TCP, TLS, and the HTTP/3 UDP socket.

  • recv_buffer — emulator user-space buffer (bytes) for inbound TCP data, per accepted connection. Default 64 KB. Larger values cut the per-body message count on high-MTU paths; smaller values cut per-connection memory at scale. See the buffer note in base_listen_opts/1.

  • min_bytes_per_second — slow-loris guard on the request-read phase (0 disables). Default 100.

  • rate_check_interval — how often the rate guard re-checks (ms). Default 1000.

  • body_buffering — auto (default; framework reads the full body before invoking the handler) or manual (handler calls roadrunner_req:read_body/1,2; a client sending Expect: 100-continue is asked for its body only when the handler first reads it, and one declaring a body over the cap gets its 413 at once). In manual mode an HTTP/2 or HTTP/3 handler runs as soon as the headers are in, and a stream holds at most one flow-control window of body it has not read (its window, or its QUIC stream credit, is given back only as the handler reads); a handler that answers without reading the body ends the client's upload (RST_STREAM(NO_ERROR) on HTTP/2, STOP_SENDING on HTTP/3).

  • slot_reconciliation — #{interval := Ms} (default 60000) for the periodic reaper that releases slots orphaned by brutal-kill exits, and with them the killed conns' rows in the drain registry, or disabled to turn it off. A graceful_drain => false listener keeps no registry to compare against, so the reaper is off there; asking for both is a configuration conflict.

  • graceful_drain — opt out of the per-conn drain registry (true default; false trades drain notification for a little less per-conn work on short-lived workloads).

  • proxy_protocol — true reads and strips the PROXY protocol header (HAProxy v1 / v2) an L4 balancer prepends, so roadrunner_req:peer/1 is the real client. On TLS listeners the header is read before the handshake. TCP only. Default false. Enable it only behind a balancer that always sends the header, or a direct peer can spoof its address.

  • real_ip — the real client IP behind HTTP reverse proxies, read from X-Forwarded-For (default) or Forwarded (header => forwarded). Set exactly one trust mode: trusted_proxies => [CIDR] (for example [~"10.0.0.0/8"]; /0 is refused) or trusted_hops => N. The resolved address feeds roadrunner_req:client_ip/1, the rate_limit key, and client_ip in logger and telemetry metadata. Use trusted_hops only when clients can reach the listener through the proxies alone.

  • rate_limit — per-client request-rate guard: #{rate := N, period => Seconds, burst => N, sweep_interval => Ms} (period default 1, burst default rate, sweep_interval default 10000). A client over the rate gets 429 Too Many Requests with Retry-After before the handler runs. Keyed on the real client IP; an IPv6 client is keyed by its /64. A route can add its own rate on top (roadrunner_router:rate_limit()).

  • hibernate_after — when set, idle conns hibernate after this many milliseconds of main-loop idle time, which sweeps and shrinks the conn's heap. Pairs with handler_spawn below for a pool of mostly-idle connections; waking a hibernated conn is charged to the request that wakes it.

  • handler_spawn — spawn config for every handler-running process (the connection process and HTTP/2/3 stream workers) as a nested map: opts (spawn_opt / proc_lib options, default [{fullsweep_after, 0}]) and start_timeout (init-ack deadline, default infinity). opts merges over the defaults per option key, so setting one option keeps the rest — asking for a heap hint does not silently change the GC policy.

    The default makes every collection a full sweep, so a connection that served a burst and went quiet holds far less heap than under the emulator's generational policy. That matters most for a pool of mostly-idle keep-alive connections, and it costs some throughput on handlers that allocate heavily. To take the emulator's generational policy instead, name fullsweep_after yourself (an empty list will not do, since opts merges): [{fullsweep_after, 65535}] for the emulator's usual value, or [{fullsweep_after, element(2, erlang:system_info(fullsweep_after))}] to follow whatever this node is set to, which ERL_FULLSWEEP_AFTER can move. Pair it with hibernate_after unless every connection stays busy, since idle connections otherwise keep their grown heaps. The intermediate values measured (5, 10 and 20) bought no middle ground. The measurements behind all of this are in Resource limits.

  • protocols — list of protocol_entry/0. Default [http1]. On TLS this drives alpn_preferred_protocols automatically.

  • tls — [ssl:tls_server_option()] for HTTPS. Absent for plain HTTP. The listener starts the ssl application when it is set, so a release that serves TLS must include ssl. Prefer an ECDSA P-256 certificate: every new TLS or QUIC connection signs with the certificate's key, and an ECDSA P-256 signature costs about 25 times less CPU than an RSA-2048 one (around 30 µs against 800 µs). In a connection-churn test that let the server accept 1.7 times more new HTTP/2 connections per second, with 44% less CPU per connection. All current browsers support it. To make a self-signed one for testing: openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes -days 365 -keyout key.pem -out cert.pem -subj /CN=localhost.

The inline source comments next to each field carry the deeper ops-tuning rationale.

protocol_entry()

-type protocol_entry() ::
          http1 | http2 | http3 | {http1, http1_opts()} | {http2, http2_opts()} | {http3, http3_opts()}.

One protocol entry in the listener's protocols list. Either a bare atom (http1 / http2 / http3) for default opts, or a tuple {Proto, ProtoOpts} carrying protocol-specific tuning. HTTP/1 tunables live under http1_opts/0, HTTP/2 under http2_opts/0, and HTTP/3 under http3_opts/0.

On TLS the list drives alpn_preferred_protocols for the TCP protocols. On plain TCP, [http2] means prior-knowledge h2c (client sends the h2 preface directly); [http1, http2] on plain TCP is rejected at init/1 since there's no Upgrade: h2c implementation.

http3 runs HTTP/3 over QUIC on the UDP port of the same number, and requires tls (QUIC mandates TLS 1.3) — listing it without tls is rejected at init/1. It co-listens with the TCP protocols: e.g. [http1, http2, http3] serves h1/h2 over TCP and h3 over UDP on the same port. The QUIC handshake advertises the h3 ALPN itself, so http3 does not appear in the TCP alpn_preferred_protocols.

ws_opts()

-type ws_opts() ::
          #{max_frame_size => 0..2147483647,
            max_message_size => 0..2147483647,
            recv_buffer => 1..2147483647,
            hibernate_after => 1..2147483647}.

WebSocket session tunables (under ws in the listener opts).

  • max_frame_size — per-frame payload cap in bytes. An inbound frame declaring more than this closes the connection with code 1009 before the payload is buffered. Default 10 MB.
  • max_message_size — cap on a reassembled message in bytes: the running fragment total, and the decompressed size when permessage-deflate is negotiated. Over-cap closes with 1009. Must be >= max_frame_size. Default 10 MB.
  • recv_buffer — inet buffer (user-space receive buffer, bytes) applied to the socket when a connection upgrades to a WebSocket session — the session-level override of the top-level recv_buffer listener opt. The emulator keeps a buffer of this size alive per socket, so it bounds how many bytes one {tcp, _, Data} delivery carries AND what every connection pays in resident memory. Unset (the default) inherits the listener socket's recv_buffer (64 KB default) — sized for HTTP request flow, where bodies arrive in bulk. Long-lived WebSocket sessions that exchange small messages should set this lower in production: at high connection counts the inherited 64 KB dominates per-connection memory (64 KB × connections), while a few KB is plenty for message-sized deliveries. Large inbound frames are still delivered correctly with a small buffer, just across more deliveries — deployments streaming multi-KB frames client→server should keep it larger.
  • hibernate_after — idle milliseconds after which a WebSocket session hibernates (heap shrinks to the minimum until the next inbound event wakes it). The session-side counterpart of the top-level hibernate_after conn knob, with the same tradeoff: each wake costs a heap rebuild, so it suits mostly-idle sessions (presence, notifications, low-traffic subscriptions) where the per-connection heap held across long silences dominates. A busy session never pays — the next frame arrives before the timer. Unset (the default) never hibernates on idle; handlers can still opt in per event via the hibernate return option.

Functions

drain(Name, Timeout)

-spec drain(Name :: atom(), Timeout :: non_neg_integer()) ->
               {ok, drained} | {timeout, non_neg_integer()}.

Graceful shutdown. Closes the listen socket immediately so no new connections are accepted, broadcasts {roadrunner_drain, Deadline} to every active conn, and then polls the live-connection counter until it hits zero or Timeout milliseconds elapse. Conns still alive at the deadline are hard-killed via exit(Pid, shutdown).

An idle HTTP/1.1 conn closes, and an HTTP/2 or HTTP/3 conn sends GOAWAY, refuses new requests and closes once its in-flight requests finish. The handler of every in-flight request gets the same message on every protocol: on HTTP/1.1 it runs in the conn process itself, and an HTTP/2 or HTTP/3 conn passes the message on to each of its stream workers, so a {loop, ...} handler sees it in handle_info/3 (see roadrunner_handler) and can wind down, as a WebSocket handler can in handle_drain/2.

Returns {ok, drained} when the counter reached zero before the deadline, or {timeout, Remaining} with the count that was still alive when the timeout fired (those processes are torn down before returning).

The listener then exits, and drain/2 returns only once it is gone, so the name is free and a roadrunner:start_listener/2 or start_link/2 right after it starts a fresh listener.

info(Name)

-spec info(Name :: atom()) ->
              #{active_clients := non_neg_integer(),
                max_clients := pos_integer(),
                requests_served := non_neg_integer(),
                rejected := non_neg_integer(),
                max_concurrent_requests := infinity | pos_integer(),
                throttled := non_neg_integer(),
                rate_limited := non_neg_integer()}.

Return runtime introspection for a listener:

  • active_clients — current number of connections held open.
  • max_clients — the configured cap.
  • requests_served — cumulative count of requests whose headers parsed successfully since the listener started. Includes 4xx responses from the router (404) and the body-size pre-check (413); excludes wire-level parse failures, idle keep-alive timeouts, and silent slow-client closes.
  • rejected — cumulative count of connections dropped because the listener was at its max_clients cap when they arrived. A rising rejected means the cap is the binding limit and should be raised. Also emitted in real time as listener_conn_rejected.
  • max_concurrent_requests — the configured in-flight ceiling (infinity when off).
  • throttled — cumulative count of requests refused (or, in shed mode, answered 503) because the listener was at its max_concurrent_requests ceiling. A rising throttled means the in-flight cap is binding. Also emitted in real time as request_throttled.

Useful for ops dashboards / health endpoints.

notify_drain(Name, Deadline)

-spec notify_drain(Name :: atom(), Deadline :: integer()) -> ok.

Broadcast a {roadrunner_drain, Deadline} notification to every conn / WS session in the listener's drain registry without stopping the listener or waiting on the counter.

Use for soft-drain workflows — telling long-lived sessions to wind down ahead of a deploy, or in test suites that want to observe drain behavior without losing the listener for subsequent cases. Unlike drain/2, the listener keeps accepting new connections. Handlers get the message as they do from drain/2.

Like every other call into a listener, exits with noproc when no listener by that name is running.

port(Name)

-spec port(Name :: atom()) -> inet:port_number().

Return the actual TCP port the listener is bound to.

register_for_drain(Name, Pid)

-spec register_for_drain(Name :: atom(), pid()) -> ok.

Register Pid in the listener's drain registry so drain/2 and notify_drain/2 reach it with {roadrunner_drain, Deadline}. The connection processes register themselves; this is for a process that lives outside the conn lifecycle (a long-lived worker, say) and wants the same notification. Exits with noproc when no listener by that name is running.

reload_routes(Name, Routes)

-spec reload_routes(Name :: atom(), roadrunner_router:routes()) ->
                       ok |
                       {error, no_routes} |
                       {error, roadrunner_router:validation_error()} |
                       {error, {init_failed, term()}}.

Atomically swap the listener's compiled route table without restarting it. The new Routes are compiled via roadrunner_router:compile/2 (with the listener's middlewares re-baked) and published to persistent_term; in-flight conns keep using whatever they read at request-resolve time, but every subsequent dispatch sees the new table.

Returns ok on success, {error, no_routes} if the listener was started in single-handler mode (routes => Module or no routes opt) since there's no router table to reload, or {error, Reason} carrying a roadrunner_router:validation_error/0 if the replacement table cannot work. A rejected table is refused whole: the listener keeps serving the routes it already had, and nothing about the running table changes. The same holds when a hook that runs for the new table fails, a per-route middleware's init/1 or a handler's init_route/1 (such as roadrunner_static rejecting an option): the reload returns {error, {init_failed, Reason}} and the listener keeps its current routes. At listener start the same failure stops the start, since there is no table to keep. Both run again for the new table, once per route.

Each call performs one global persistent_term swap, which scans every process heap to reclaim the old table. That cost is acceptable for a whole-table swap at deploy time, but callers should batch route changes into a single reload_routes/2 rather than calling it per route.

start_link(Name, Opts)

-spec start_link(Name :: atom(), opts()) -> {ok, pid()} | {error, term()}.

Start a named listener that binds the given TCP port.

port => 0 lets the kernel choose an ephemeral port — query it back with port/1.

status(Name)

-spec status(Name :: atom()) -> accepting | draining.

Return the listener's lifecycle phase:

  • accepting — normal serving; new connections are being accepted.
  • draining — drain/2 is in progress; the listen socket is closed and active conns are finishing.

After drain/2 (or stop/1) returns the listener has exited and this call would fail with a noproc.

stop(Name)

-spec stop(Name :: atom()) -> ok.

Stop a listener and release its port. In-flight conns are not waited on.