roadrunner_listener (roadrunner v0.4.0)

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 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 inbound size caps (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

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}.

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. 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.

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}.

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

  • conn_window — connection-level receive window peak in bytes (1..2^31-1). RFC 9113 default 65535; values above the default emit an early WINDOW_UPDATE(0, peak - 65535) after the server SETTINGS. Worst-case memory is max_clients × peak.
  • stream_window — stream-level receive window peak in bytes (1..2^31-1). Advertised via SETTINGS_INITIAL_WINDOW_SIZE. Default 65535. 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 the remaining window drops below this, the conn refills back to the peak. Lower threshold = fewer WINDOW_UPDATE frames per byte consumed but a smaller live window between refills. Default 32768.
  • 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).

http3_opts()

-type http3_opts() :: #{listeners => 1..1024, max_header_block => 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. 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).

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(),
            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(),
            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(),
            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.

Optional middleware and timing knobs (durations in milliseconds):

  • middlewares — listener-wide pipeline applied to every request.
  • max_content_length — request-body cap; over-cap reads return payload_too_large. Default 10 MB.
  • ws — WebSocket inbound size caps as a nested map (see ws_opts/0): max_frame_size (per-frame payload cap) and max_message_size (reassembled + decompressed message cap). Both default to 10 MB; over-cap closes the connection with code 1009.
  • request_timeout — header-read timeout on a fresh conn. Default 30 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 150. Connections accepted while already at the cap are closed immediately without a response. The default bounds memory (the recv buffer alone is max_clients × 64 KB), so high-concurrency deployments should raise it. Rejections are observable: each one emits [roadrunner, 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.
  • 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; a high max_clients set for burst tolerance can let concurrent handler memory grow without limit under heavy multiplexing. This caps the product directly. 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 [roadrunner, request, throttled] and increments the throttled count from info/1. HTTP/1 is unaffected (one request per connection, already bounded by max_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 at net.core.somaxconn.
  • 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_bufferingauto (default; framework reads the full body before invoking the handler) or manual (handler calls roadrunner_req:read_body/1,2).
  • slot_reconciliationdisabled (default) or #{interval := Ms} to periodically reap slots orphaned by brutal-kill exits.
  • graceful_drain — opt out of the per-conn pg drain group (true default; false trades 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_lib options, default [{fullsweep_after, 0}] so the per-conn response heap is reclaimed instead of hoarding it as old-gen garbage across keep-alive requests) and start_timeout (init-ack deadline, default infinity). For the lowest resident memory you can also add +MHacul 0 +MBacul 0 to vm.args to 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 of protocol_entry/0. Default [http1]. On TLS this drives alpn_preferred_protocols automatically.
  • 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.

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}.

WebSocket inbound size caps (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.

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 (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.

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()}.

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 [roadrunner, listener, conn_rejected].
  • max_concurrent_requests — the configured in-flight ceiling (infinity when off).
  • throttled — cumulative count of streams refused 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 [roadrunner, 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 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).

port(Name)

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

Return the actual TCP port the listener is bound to.

reload_routes(Name, Routes)

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

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 or {error, no_routes} if the listener was started in single-handler mode (routes => Module or no routes opt) — there's no router table to reload.

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.
  • drainingdrain/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.