roadrunner_listener (roadrunner v0.9.2)
View SourceListener 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 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 (so {loop, ...} handlers can opt to honor it),
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 pg drain group without stopping the
listener or waiting on the counter.
Return the actual TCP port the listener is bound to.
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
-type http1_opts() ::
#{max_request_line => 1..2147483647,
max_header_line => 1..2147483647,
max_header_block => 1..2147483647,
max_header_count => 1..2147483647}.
HTTP/1.1 listener tunables (under {http1, ThisMap} in protocols).
All four 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. Default8192.max_header_line— per-header-line byte cap. Over-cap →431. Default8192.max_header_block— cumulative header-block byte cap. Over-cap →431. Default10240.max_header_count— maximum number of header lines. Over-cap →431. Default100.
-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). RFC 9113 default65535; values above the default emit an earlyWINDOW_UPDATE(0, peak - 65535)after the server SETTINGS. Worst-case memory ismax_clients × peak.stream_window— stream-level receive window peak in bytes (1..2^31-1). Advertised viaSETTINGS_INITIAL_WINDOW_SIZE. Default65535. Setting aboveconn_windowis allowed but not useful — the conn-level peak is the binding constraint.window_refill_threshold— refill trigger in bytes. When the remaining window drops below this, the conn refills back to the peak. Lower threshold = fewerWINDOW_UPDATEframes per byte consumed but a smaller live window between refills. Default32768.max_concurrent_streams— cap on concurrent client-initiated streams per connection, advertised viaSETTINGS_MAX_CONCURRENT_STREAMS. HEADERS that would exceed it getRST_STREAM(REFUSED_STREAM). Default100.max_header_block— cumulative cap on an assembled HEADERS+CONTINUATION block (the CONTINUATION-flood guard); over-cap closes the connection withGOAWAY(ENHANCE_YOUR_CALM). Default16384. This is the h2 counterpart to the{http1, ...}max_header_blockopt, but the two are independent and default differently (h110240, h216384).max_header_list_size— cap on the decoded (uncompressed) header-list size (RFC 7541 §4.1: sum of name + value + 32 per field), advertised viaSETTINGS_MAX_HEADER_LIST_SIZE; an over-cap request gets431+RST_STREAM(NO_ERROR). Bounds a different unit thanmax_header_block(which caps the compressed block). Defaults to2 * max_header_block, so raising the encoded cap lifts this one too unless set explicitly.
-type http3_opts() ::
#{listeners => 1..1024,
max_header_block => 1..2147483647,
max_streams_bidi => 1..2147483647,
max_field_section_size => 1..2147483647}.
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.1disables pooling (a single listener, noSO_REUSEPORT).max_header_block— cap on the encoded request field section (the HEADERS block); over-cap answers431. Default16384. The h3 counterpart to the{http1, ...}/{http2, ...}max_header_blockopts; the three are independent (h1 defaults to10240, h2/h3 to16384).max_streams_bidi— cap on concurrent client-initiated bidirectional (request) streams, advertised to the peer in the QUIC transport parameters. Default100. The h3 counterpart to the{http2, ...}max_concurrent_streamsopt.max_field_section_size— cap on the decoded (uncompressed) field-section size (RFC 7541 §4.1: sum of name + value + 32 per field), advertised viaSETTINGS_MAX_FIELD_SECTION_SIZE(so conformant clients self-limit, RFC 9114 §4.2.2) and enforced after QPACK decode: an over-cap request gets431. Bounds a different unit thanmax_header_block(which caps the compressed block). Defaults to2 * max_header_block, so raising the encoded cap lifts this one too unless set explicitly. The h3 counterpart to the{http2, ...}max_header_list_sizeopt.
-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(), 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(), rate_limit => #{rate := pos_integer(), period => pos_integer(), burst => pos_integer(), idle_ttl => 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.0lets the kernel pick an ephemeral port; query it back withport/1.
Routing (pick one):
routes => module()— single-handler dispatch. Every request goes toModule:handle/1androadrunner_req:state/1returnsundefined.routes => {module(), term()}— single-handler dispatch with per-handler state. The opaque second element is reachable from the handler viaroadrunner_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.
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 answers413 Payload Too Large(and resets the stream on h2/h3). Default 10 MB.ws— WebSocket session tunables as a nested map (seews_opts/0):max_frame_size(per-frame payload cap) andmax_message_size(reassembled + decompressed message cap), both defaulting to 10 MB with over-cap closing the connection with code 1009, plusrecv_buffer(per-session inetbufferoverride — bounds per-connection memory at high WebSocket concurrency; inherits the listener'srecv_bufferwhen unset) andhibernate_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 during accept (TLS listeners only). A handshake that exceeds it fails as a per-connection{handshake, timeout}accept error and the acceptor keeps accepting; without a bound, a client that never sends its ClientHello would park an acceptor indefinitely. 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 10.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). Connections accepted while already at the cap are closed immediately without a response. The effective cap ismin(max_clients, the OS file-descriptor limit)— raiseulimit -n(and the systemdLimitNOFILE) for high concurrency, otherwise the acceptors hitemfileat the descriptor ceiling and emit[roadrunner, listener, accept_error]. Saturation memory scales asmax_clients × recv_buffer, so memory-constrained deployments lower the cap orrecv_buffer. Rejections are observable: each one emits[roadrunner, listener, conn_rejected]and increments therejectedcount frominfo/1, so a risingrejectedis the signal that the cap is the binding limit. For HTTP/2 and HTTP/3 the worst-case live-handler count ismax_clients × max_concurrent_streams; bound it withmax_concurrent_requests(defaultinfinity) 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). Defaultinfinity(off).max_clientsbounds connections andmax_concurrent_streamsbounds 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 highmax_clients. Over-limit streams are refused withREFUSED_STREAM(h2) /H3_REQUEST_REJECTED(h3), which RFC 9113 §8.7 marks safe to retry; each refusal emits[roadrunner, request, throttled]and increments thethrottledcount frominfo/1. HTTP/1 is unaffected (one request per connection, already bounded bymax_clients).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 atnet.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 thebuffernote inbase_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) ormanual(handler callsroadrunner_req:read_body/1,2).slot_reconciliation—disabled(default) or#{interval := Ms}to periodically reap slots orphaned by brutal-kill exits.graceful_drain— opt out of the per-conn pg drain group (truedefault;falsetrades drain notification for ~10 % lower per-conn overhead on short-lived workloads).hibernate_after— when set, idle conns hibernate after this many milliseconds of main-loop idle time.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_liboptions, default[{fullsweep_after, 0}]so the per-conn response heap is reclaimed instead of hoarding it as old-gen garbage across keep-alive requests) andstart_timeout(init-ack deadline, defaultinfinity).optsmerges 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.fullsweep_after, 0makes every collection a full sweep, which is what keeps the heap flat — and it is not free on handlers that allocate heavily. Measured on a JSON route building a large transient iolist per request, against the emulator's generational default at the same CPU: throughput 51k vs 58k req/s and mean latency 3.25 ms vs 2.63 ms, for 189 MB vs 361 MB of RSS. Bounded memory is the safer default for a server, so that is the trade taken here; a service that would rather spend the memory namesfullsweep_afteritself to override it — an empty list will not do, sinceoptsmerges. Use[{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, whichERL_FULLSWEEP_AFTERcan move.The choice is effectively binary — an intermediate sweep interval buys no middle ground. Values of 5, 10 and 20 all measured within noise of the generational default on both axes (≈60k req/s, ≈340 MB) on that same route, because the refc-binary garbage driving the heap piles up between full sweeps whatever the interval between them is. Sweep every time or effectively never.
For the lowest resident memory you can also add
+MHacul 0 +MBacul 0tovm.argsto return freed allocator carriers to the OS, but that is a tradeoff, not a free win: it raises allocator↔OS traffic and can hurt throughput at high core counts, so measure it for your workload rather than enabling it blindly.protocols— list ofprotocol_entry/0. Default[http1]. On TLS this drivesalpn_preferred_protocolsautomatically.tls—[ssl:tls_server_option()]for HTTPS. Empty / absent for plain HTTP.
The inline source comments next to each field carry the deeper ops-tuning rationale.
-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.
-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— inetbuffer(user-space receive buffer, bytes) applied to the socket when a connection upgrades to a WebSocket session — the session-level override of the top-levelrecv_bufferlistener 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'srecv_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-levelhibernate_afterconn 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 thehibernatereturn option.
Functions
-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 (so {loop, ...} handlers can opt to honor it),
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).
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).
After drain/2 returns the listener exits — call start_link/2
again to bring it back up.
-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 itsmax_clientscap when they arrived. A risingrejectedmeans the cap is the binding limit and should be raised. Also emitted in real time as[roadrunner, listener, conn_rejected].max_concurrent_requests— the configured in-flight ceiling (infinitywhen off).throttled— cumulative count of streams refused because the listener was at itsmax_concurrent_requestsceiling. A risingthrottledmeans the in-flight cap is binding. Also emitted in real time as[roadrunner, request, throttled].
Useful for ops dashboards / health endpoints.
Broadcast a {roadrunner_drain, Deadline} notification to every conn /
WS session in the listener's pg drain group 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.
Requires the pg scope to be running (started by roadrunner_sup).
-spec port(Name :: atom()) -> inet:port_number().
Return the actual TCP port the listener is bound to.
-spec reload_routes(Name :: atom(), roadrunner_router:routes()) -> ok | {error, no_routes} | {error, roadrunner_router:validation_error()}.
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. That covers everything
decidable from the table itself; a per-route middleware that crashes
in its own init/1 still fails the reload the loud way, the same as
it would at listener start.
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 a named listener that binds the given TCP port.
port => 0 lets the kernel choose an ephemeral port — query it back
with port/1.
-spec status(Name :: atom()) -> accepting | draining.
Return the listener's lifecycle phase:
accepting— normal serving; new connections are being accepted.draining—drain/2is 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.
-spec stop(Name :: atom()) -> ok.
Stop a listener and release its port. In-flight conns are not waited on.