-module(nquic). -moduledoc """ QUIC transport library for Erlang/OTP, implementing RFC 9000 (transport), RFC 9001 (TLS binding), RFC 9002 (loss detection and congestion control), and RFC 9221 (unreliable datagrams). This module is the umbrella API: client connect, server listen and accept, and listener lifecycle and metrics. `connect/3` and `accept/1,2` return an opaque `t:ctx/0` connection handle; all post-handshake traffic (streams, datagrams, close, timers) is driven through `m:nquic_lib`, which threads the updated context through each call. Closed-form error values are defined in `m:nquic_error`. ## Connection model nquic owns exactly one process per connection: the handshake driver. When the handshake completes the connection (a `t:ctx/0` value) is handed to the caller of `connect/3` or `accept/1,2` and the driver exits. From that point the owning process *is* the connection: it drives the protocol core directly through `m:nquic_lib` by function call against the context, with no message hop and no nquic-owned process. The owner pulls data when ready (`nquic_lib:recv/2`, `nquic_lib:recv_pending/1`); there is no pushed-message envelope and no ownership-reassignment call. To serve a connection from a dedicated worker, call `accept/1,2` (or `connect/3`) from that worker. This is the only connection model; read the **owner-liveness contract** below before writing one. ## Owner-liveness contract There is no per-connection nquic process after the handshake, so **nothing services the connection unless the owner's loop does**. ACKs, PTO, loss detection, and the idle timer all fire only when the owner calls back into `m:nquic_lib`. An owner that blocks, or that reads streams without also draining inbound packets and timer expiries, will silently stall the connection: the peer times it out while the owner waits forever. The contract the owner MUST satisfy, in one loop: 1. ingest every inbound packet: `nquic_lib:handle_packet/3` for the dispatched server path (`{packet, Source, Bin}` messages from the listener's receiver) or `nquic_lib:recv_direct/1` for a connected or client-owned socket; 2. service every `{quic_timeout, Type}` message via `nquic_lib:timeout/2` (this is what drives ACK/PTO/idle); 3. `nquic_lib:flush/1` after each of the above so queued frames reach the wire; 4. observe `{quic_drain, Listener}` (the listener cascade-stop signal) and close. ```erlang owner_loop(Ctx) -> Socket = nquic_lib:ctx_socket(Ctx), receive {'$socket', Socket, select, _Info} -> after_io(nquic_lib:recv_direct(Ctx)); {packet, Source, Bin} -> after_io(nquic_lib:handle_packet(Ctx, Source, Bin)); {quic_timeout, Type} -> after_io(nquic_lib:timeout(Ctx, Type)); {quic_drain, _Listener} -> {ok, _Ctx1} = nquic_lib:close(Ctx) end. after_io({ok, Events, Ctx1}) -> {ok, Ctx2} = nquic_lib:flush(Ctx1), owner_loop(handle_events(Events, Ctx2)); after_io({error, _Reason, _Ctx1}) -> ok. ``` `nquic_lib:recv_direct/1` folds packet ingest and timer servicing into a single blocking call (it handles `{packet, _}`, `{quic_timeout, _}`, and the connected-socket select cycle internally), so a connected/client owner can collapse steps 1-2 to `recv_direct/1`; it still does not observe `{quic_drain, _}`, which the owner handles itself. ## Quick start: client ```erlang {ok, Ctx0} = nquic:connect("example.com", 443, #{tls => #{alpn => [<<"h3">>]}}), {ok, Sid, Ctx1} = nquic_lib:open_stream(Ctx0, #{type => bidi}), {ok, Ctx2} = nquic_lib:send_fin(Ctx1, Sid, <<"GET / HTTP/1.1\\r\\n\\r\\n">>), {ok, Body, fin, Ctx3} = nquic_lib:recv(Ctx2, Sid), {ok, _Ctx4} = nquic_lib:close(Ctx3). ``` `connect/3` blocks until the handshake completes (or `timeout` elapses) and returns `{ok, Ctx}`. The context cannot exist before the handshake, so `nowait => true` is rejected with `{error, {opts, ctx_requires_wait}}`. ## Quick start: server ```erlang {ok, Listener} = nquic:listen(4433, #{ tls => #{certfile => "server.pem", keyfile => "server.key", alpn => [<<"h3">>]} }), {ok, Ctx0} = nquic:accept(Listener), {ok, Ctx1} = nquic_lib:takeover(Ctx0), {ok, Events, Ctx2} = nquic_lib:recv_pending(Ctx1). ``` The listener owns one or more UDP sockets (set `receivers => N` to fan packets across schedulers with `SO_REUSEPORT`) and a striped dispatch table that routes datagrams to existing connections without spawning a process per packet. An accepted context stays on the listener's dispatch path; the owner calls `nquic_lib:takeover/1` to re-target the connection's CIDs to itself and then drives it through `m:nquic_lib`. Listener-wide observability is exported through `metrics/1`. ## Listener lifecycle `listen/2` starts a supervision tree with `supervisor:start_link/2`, so the listener is **linked to the calling process**. If that process dies, the listener tree comes down with it; if the listener tree crashes abnormally, the link propagates to the owner. This is the intended contract: tie listener lifetime to an owning process. Stop a listener explicitly with `stop_listener/1,2`. It is synchronous, idempotent, and exits the listener supervisor with reason `normal`, so the owner link never turns into a kill. `cascade` (the default) stops accepting, signals every owner-held established connection to close (a `{quic_drain, Listener}` message the owner loop observes), then tears down the listener tree and frees the port; `detach` stops accepting and frees the port but sends no drain signal, leaving established connections to run until their own idle timeout. A blocked `accept/1,2` on a stopped listener returns `{error, closed}`. ## Send and backpressure A stream send is bounded by two windows: the connection's `MAX_DATA` and the stream's `MAX_STREAM_DATA` credit, both granted by the peer. `nquic_lib:send/3` / `nquic_lib:send_fin/3` admit as much as the windows allow and return the updated context; the owner's recv loop processes inbound `MAX_DATA` / `MAX_STREAM_DATA` frames that reopen the window and resumes parked stream writes. `nquic_lib:is_writable/2` is a point-in-time probe the owner can check between recv turns: a `false` does not guarantee the next send fails, and a `true` can be stale before the owner acts on it. ## Datagrams RFC 9221 unreliable datagrams are supported when both peers negotiate `max_datagram_frame_size`. Datagrams are not retransmitted and are not flow-controlled. Send via `nquic_lib:send_datagram/2`; receive via `nquic_lib:recv_datagram/1`. ## Session caching and 0-RTT The optional `session_cache` and `token_cache` options on `connect/3` opt into TLS 1.3 session resumption and address validation tokens. The server exposes the same surface via the `replay_protection` and `new_token` listen options. See `m:nquic_session_cache` for the cache contract and `m:nquic_zero_rtt` for early-data handling on the server. ## Error handling Every public function returns `{ok, _} | {error, t:error_reason/0}`. `t:error_reason/0` is the closed taxonomy defined in `m:nquic_error`, covering `closed`, `{timeout, Phase}`, `{transport, _}`, `{tls, _}`, `{application, Code, Reason}`, `{protocol, _}`, `{flow_control, _}`, `{opts, _}`, `{connect, _}`, and `{listen, _}`. Use `nquic_error:category/1`, `nquic_error:is_retryable/1`, and `nquic_error:format/1` to interrogate values without pattern-matching on the inner shape. """. -export([ accept/1, accept/2, connect/3, get_port/1, listen/2, metrics/1, stop_listener/1, stop_listener/2 ]). -export_type([ accept_opts/0, cc_opts/0, cipher_suite/0, client_tls_opts/0, close_opts/0, conn_info/0, connect_opts/0, connection_id/0, ctx/0, error_code/0, error_reason/0, listen_opts/0, listener/0, listener_metrics/0, stop_listener_opts/0, stream_id/0, stream_opts/0, tls_opts/0, transport_opts/0, verify_mode/0 ]). -type connection_id() :: binary(). -type error_code() :: non_neg_integer(). -type stream_id() :: non_neg_integer(). -type verify_mode() :: verify_none | verify_peer. -opaque listener() :: pid(). -doc """ Closed error taxonomy returned by every public function. Use `nquic_error:category/1`, `nquic_error:is_retryable/1`, and `nquic_error:format/1` to interrogate values. Outer arms: * `closed` - connection has been closed locally or by the peer. * `{timeout, Phase}` - `Phase :: handshake | idle | recv | send | accept`. * `{transport, Reason}` - peer-sent CONNECTION_CLOSE (0x1c) or local transport-layer I/O failure (`{posix, _}` payload). * `{tls, Reason}` - TLS alert or local handshake-side validation error. * `{application, Code, Reason}` - peer-sent CONNECTION_CLOSE (0x1d). * `{protocol, Reason}` - wire-format or RFC 9000 violation detected locally (frame encoding, transport parameters, packet integrity, ...). * `{flow_control, Reason}` - local flow or congestion blocking (`eagain`, `congestion_control_blocked`, `partial_send`, ...). * `{opts, Reason}` - options / configuration / API misuse. * `{connect, Posix}` - DNS or client-side connect failure. * `{listen, Posix}` - server-side bind failure. Example: ```erlang case nquic:connect(Host, Port, #{tls => #{alpn => [<<"h3">>]}}) of {ok, Conn} -> ok; {error, {timeout, handshake}} -> retry; {error, {tls, _}} -> bad_tls; {error, {connect, _}} -> network; {error, _} -> giveup end. ``` """. -type error_reason() :: nquic_error:error_reason(). -doc """ Options for `open_stream/2` and `nquic_lib:open_stream/2`. Defaults to a bidirectional stream if omitted entirely. """. -type stream_opts() :: #{type => bidi | uni}. -doc """ Options for `close/2`. * `scope` (default `transport`) - `transport` emits CONNECTION_CLOSE type 0x1c, `application` emits type 0x1d (RFC 9000 ยง19.19). * `error_code` (default `0`) - protocol or application error code. * `reason` (default `<<>>`) - operator-visible reason phrase. """. -type close_opts() :: #{ scope => transport | application, error_code => non_neg_integer(), reason => binary() }. -doc """ Options for `stop_listener/2`. * `mode` (default `cascade`) - `cascade` stops accepting, broadcasts a `{quic_drain, Listener}` close signal to every owner-held established connection, then tears the whole listener tree down (port released, handshake-phase processes terminated); `detach` stops accepting and frees the port but sends no drain signal, leaving established connections running until their own idle timeout. * `timeout` (default `5000`) - milliseconds to wait for a graceful `cascade` shutdown before the supervisor is brutally killed. """. -type stop_listener_opts() :: #{ mode => cascade | detach, timeout => timeout() }. -doc """ Public connection-info map returned by `nquic_conn:info/1`. The shape is intentionally a plain map (not a record) so the library can evolve fields without breaking callers. """. -type conn_info() :: #{ state := initial | handshake | established | draining | closed, role := client | server, scid := binary(), dcid := binary(), rtt := #{ smoothed_rtt := non_neg_integer(), min_rtt := non_neg_integer(), latest_rtt := non_neg_integer(), rttvar := non_neg_integer() }, cwnd := non_neg_integer(), bytes_in_flight := non_neg_integer(), streams_open := non_neg_integer(), data_sent := non_neg_integer(), data_received := non_neg_integer(), _ => _ }. -doc """ Per-listener metrics snapshot returned by `metrics/1`. The listener's atomic counters are monotonic; consumers compute deltas themselves. """. -type listener_metrics() :: #{ packets_in := non_neg_integer(), packets_dropped_mailbox := non_neg_integer(), packets_dropped_ratelimit := non_neg_integer(), conns_established := non_neg_integer(), conns_closed_normal := non_neg_integer(), conns_closed_idle_timeout := non_neg_integer(), conns_closed_peer := non_neg_integer(), conns_closed_protocol_error := non_neg_integer(), handshakes_inflight := integer(), accept_queue_depth := integer(), udp_rcvbuf_errs := non_neg_integer(), uptime_ms := non_neg_integer() }. -type cipher_suite() :: aes_128_gcm | aes_256_gcm | chacha20_poly1305. -doc """ Options accepted by `accept/2`. """. -type accept_opts() :: #{ timeout => timeout() }. -doc """ Server TLS / credentials submap (`listen/2`). `certfile` and `keyfile` are required; the rest tune verification and negotiation. """. -type tls_opts() :: #{ certfile := file:filename(), keyfile := file:filename(), cacertfile => file:filename(), cacerts => [binary()], verify => verify_mode(), cipher_suites => [cipher_suite()], alpn => [binary()] }. -doc """ Client TLS submap (`connect/3`). All keys optional: a client presents no server certificate, so `certfile`/`keyfile` are absent. """. -type client_tls_opts() :: #{ cacertfile => file:filename(), cacerts => [binary()], verify => verify_mode(), cipher_suites => [cipher_suite()], alpn => [binary()] }. -doc """ Transport tuning submap shared by `listen/2` and `connect/3`: offload (GSO/GRO), pacing, datagram sizing, socket buffering. """. -type transport_opts() :: #{ gso => boolean() | pos_integer(), gro => boolean(), pacing => boolean(), pacing_factor => number(), pacing_burst => pos_integer(), max_payload_size => pos_integer(), send_buffer => pos_integer(), send_timeout => timeout() }. -doc """ Congestion-control submap shared by `listen/2` and `connect/3`. `algo` selects the controller; `slow_start` selects the start-up phase behaviour. """. -type cc_opts() :: #{ algo => newreno | cubic, slow_start => standard | hystart_plus_plus }. -doc """ Options for `listen/2`. Endpoint TLS credentials live in the required `tls` submap; transport and congestion-control tuning in the optional `transport` / `cc` submaps. Remaining keys are listener-level policy. """. -type listen_opts() :: #{ tls := tls_opts(), transport => transport_opts(), cc => cc_opts(), qlog => nquic_qlog:backend_config(), receivers => pos_integer(), idle_timeout => timeout(), max_new_conns_per_sec => non_neg_integer(), max_accept_queue => non_neg_integer(), retry => boolean(), retry_token_lifetime => pos_integer(), new_token => boolean(), new_token_lifetime => pos_integer(), spin_bit => boolean(), version => non_neg_integer(), version_preference => [non_neg_integer()], replay_protection => module(), server_per_conn_fd => boolean(), conn_handler => module(), conn_handler_opts => term() }. -doc """ Options for `connect/3`. TLS, transport, and congestion-control tuning live in the optional `tls` / `transport` / `cc` submaps. Remaining keys are connection-level policy and session resumption. """. -type connect_opts() :: #{ timeout => timeout(), tls => client_tls_opts(), transport => transport_opts(), cc => cc_opts(), qlog => nquic_qlog:backend_config(), idle_timeout => timeout(), nowait => boolean(), version => non_neg_integer(), session_ticket => map(), session_cache => false | atom() | {module, module()}, token_cache => false | atom() | {module, module()}, client_token => binary(), proactive_cids => boolean() }. -type ctx() :: nquic_ctx:t(). -doc """ Accept an incoming QUIC connection with default options (`timeout => infinity`). """. -spec accept(listener()) -> {ok, ctx()} | {error, error_reason()}. accept(Listener) -> accept(Listener, #{}). -doc """ Accept an incoming QUIC connection. Blocks until a client connects and the handshake completes, or until `timeout` expires, then returns the connection as an opaque `t:ctx/0`. The owning process drives it through `m:nquic_lib`, starting with `nquic_lib:takeover/1`. """. -spec accept(listener(), accept_opts()) -> {ok, ctx()} | {error, error_reason()}. accept(Listener, Opts) when is_map(Opts) -> Timeout = maps:get(timeout, Opts, infinity), case nquic_listener:accept(Listener, Timeout) of {ok, Entry} -> promote_to_ctx(Entry); {error, Reason} -> nquic_error:wrap(Reason) end. -doc """ Connect to a QUIC server. `Host` and `Port` identify the endpoint; `Opts` carries everything else: `timeout` (default `infinity`) and the `tls` / `transport` / `cc` tuning submaps (e.g. `#{tls => #{alpn => [<<"h3">>], verify => verify_peer}}`). See `connect_opts/0`. Blocks until the handshake completes (or `timeout` elapses) and returns the connection as an opaque `t:ctx/0`, driven through `m:nquic_lib`. The context cannot exist before the handshake, so `nowait => true` is rejected with `{error, {opts, ctx_requires_wait}}`. """. -spec connect( inet:hostname() | inet:ip_address(), inet:port_number(), connect_opts() ) -> {ok, ctx()} | {error, error_reason()}. connect(Host, Port, Opts) when is_map(Opts) -> Timeout = maps:get(timeout, Opts, infinity), ConnectOpts0 = maps:without([timeout], Opts), case maps:get(nowait, ConnectOpts0, false) of true -> nquic_error:opts(ctx_requires_wait); false -> do_connect(Host, Port, ConnectOpts0#{nowait => false}, Timeout) end. %%%----------------------------------------------------------------------------- %% INTERNAL %%%----------------------------------------------------------------------------- -define(RELOCATED_KEYS, [ certfile, keyfile, cacertfile, cacerts, verify, cipher_suites, alpn, gso, gro, pacing, pacing_factor, pacing_burst, max_payload_size, send_buffer, send_timeout, congestion_control, slow_start ]). -doc false. -spec await_client_export(pid(), reference(), timeout()) -> {ok, ctx()} | {error, error_reason()}. await_client_export(Pid, MRef, Timeout) -> receive {nquic_conn_export, Pid, {ok, State, Socket, Table}} -> demonitor(MRef, [flush]), Peer = nquic_protocol:peer(State), {ok, nquic_ctx:new(State, Socket, Peer, Table)}; {'DOWN', MRef, process, Pid, Reason} -> client_down_error(Reason) after Timeout -> demonitor(MRef, [flush]), catch gen_statem:stop(Pid), nquic_error:timeout(handshake) end. -spec cc_flat(map()) -> map(). cc_flat(Cc) -> M0 = case maps:find(algo, Cc) of {ok, Algo} -> #{congestion_control => Algo}; error -> #{} end, case maps:find(slow_start, Cc) of {ok, SlowStart} -> M0#{slow_start => SlowStart}; error -> M0 end. -spec client_down_error(term()) -> {error, error_reason()}. client_down_error(normal) -> nquic_error:closed(); client_down_error(noproc) -> nquic_error:closed(); client_down_error(shutdown) -> nquic_error:closed(); client_down_error({shutdown, _}) -> nquic_error:closed(); client_down_error(Reason) -> nquic_error:wrap(Reason). -spec connect_socket_opts(map()) -> map(). connect_socket_opts(Opts) -> M0 = case maps:get(gso, Opts, false) of false -> #{}; GSO -> #{gso => GSO} end, case maps:get(gro, Opts, false) of true -> M0#{gro => true}; false -> M0 end. -spec do_connect(inet:hostname() | inet:ip_address(), inet:port_number(), map(), timeout()) -> {ok, ctx()} | {error, error_reason()}. do_connect(Host, Port, Opts, Timeout) -> case validate_connect_opts(Opts) of {ok, Validated} -> Validated1 = nquic_session_cache:maybe_load_ticket(Host, Port, Validated), Validated2 = nquic_token_cache:maybe_load_token(Host, Port, Validated1), case inet:getaddr(Host, inet) of {error, Posix} -> nquic_error:connect(Posix); {ok, IP} -> SockOpts = nquic_recv:socket_options(connect_socket_opts(Validated2)), case nquic_socket:open(SockOpts) of {error, Posix} -> nquic_error:from_socket(connect, Posix); {ok, Socket} -> InitialDCID = nquic_keys:generate_connection_id(8), Peer = nquic_socket:make_sockaddr(IP, Port), ConnOpts = Validated2#{ role => client, socket => Socket, peer => Peer, dcid => InitialDCID, hostname => Host, owner => self() }, case nquic_conn_statem:start_link(ConnOpts) of {ok, Pid} -> unlink(Pid), MRef = monitor(process, Pid), ok = nquic_socket:controlling_process(Socket, Pid), await_client_export(Pid, MRef, Timeout); {error, Reason} -> _ = nquic_socket:close(Socket), nquic_error:wrap(Reason) end end end; {error, _} = Err -> Err end. -spec flatten_opts(map()) -> map(). flatten_opts(Opts) -> Tls = maps:get(tls, Opts, #{}), Transport = maps:get(transport, Opts, #{}), Cc = maps:get(cc, Opts, #{}), Base = maps:without([tls, transport, cc], Opts), Merged = maps:merge(maps:merge(Base, Tls), Transport), maps:merge(Merged, cc_flat(Cc)). -doc """ Return the UDP port the listener is bound to. Useful when the listener was started with port `0` (let the kernel pick a free port) and the caller needs the actual port to advertise. """. -spec get_port(listener()) -> {ok, inet:port_number()} | {error, error_reason()}. get_port(Listener) -> case nquic_listener:get_port(Listener) of {ok, _} = Ok -> Ok; {error, Reason} -> nquic_error:wrap(Reason) end. -doc """ Start a QUIC listener bound to `Port`. `Opts` requires a `tls` submap carrying at least `certfile` and `keyfile` (e.g. `#{tls => #{certfile => C, keyfile => K}}`). Transport and congestion-control tuning live in the optional `transport` / `cc` submaps. See `listen_opts/0` for the full set. ## Returns - `{ok, Listener}` on success - `{error, Reason}` on failure ## See Also - `accept/1` - `accept/2` - `stop_listener/1` """. -spec listen(inet:port_number(), listen_opts()) -> {ok, listener()} | {error, error_reason()}. listen(Port, Opts) when is_map(Opts) -> case validate_listen_opts(maps:without([port], Opts)) of {ok, Validated} -> case nquic_listener:start_link(Validated#{port => Port}) of {ok, _} = Ok -> Ok; {error, Reason} -> nquic_error:wrap(Reason) end; {error, _} = Err -> Err end. -doc """ Snapshot of the listener-wide observability counters. Counters are monotonic; consumers compute deltas between snapshots themselves. The `udp_rcvbuf_errs` field is a delta against the kernel value seen at listener start on Linux; on other platforms it is always 0. """. -spec metrics(listener()) -> {ok, listener_metrics()} | {error, error_reason()}. metrics(Listener) -> case nquic_listener:get_metrics(Listener) of {ok, M} -> {ok, nquic_metrics:snapshot(M)}; {error, Reason} -> nquic_error:wrap(Reason) end. -spec misplaced_key(map()) -> atom() | none. misplaced_key(Opts) -> case [K || K <- ?RELOCATED_KEYS, is_map_key(K, Opts)] of [Key | _] -> Key; [] -> none end. -spec promote_to_ctx(nquic_listener_mgr:accept_entry()) -> {ok, ctx()}. promote_to_ctx({exported, State, Socket, Table, Connected, _ConnPid}) -> Peer = nquic_protocol:peer(State), Ctx0 = nquic_ctx:new(State, Socket, Peer, Table), Ctx = case Connected of true -> nquic_ctx:set_connected(Ctx0, true); false -> Ctx0 end, {ok, Ctx}. -doc """ Stop a listener returned by `listen/2`. Cascade shutdown: stops accepting, broadcasts `{quic_drain, Listener}` to every owner-held established connection so each closes gracefully, then closes the listen sockets, releases the port, and tears down the listener tree (receivers and dispatch). The drain message is delivered to the connection's owner process; the reference owner loop closes the connection and exits on it. Idempotent: stopping an already-stopped listener returns `ok`. Safe to call from any process, including one that did not open the listener; the owner's link is never turned into an exit. See `stop_listener/2` for the detach variant and the lifecycle contract. """. -spec stop_listener(listener()) -> ok. stop_listener(Listener) -> stop_listener(Listener, #{}). -doc """ Stop a listener with options. ```erlang nquic:stop_listener(L, #{mode => detach, timeout => 10000}). ``` `mode => cascade` (default) stops accepting, drains every owner-held established connection via a `{quic_drain, Listener}` signal, then tears the whole tree down; `mode => detach` stops accepting and frees the port but sends no drain signal, leaving established connections running until their own idle timeout. `timeout` (default `5000` ms) bounds a graceful cascade before the supervisor is brutally killed. See `stop_listener_opts/0`. """. -spec stop_listener(listener(), stop_listener_opts()) -> ok. stop_listener(Listener, Opts) when is_pid(Listener), is_map(Opts) -> Mode = maps:get(mode, Opts, cascade), Timeout = maps:get(timeout, Opts, 5000), nquic_listener:stop(Listener, Mode, Timeout). -spec validate_connect_opts(map()) -> {ok, map()} | {error, nquic_error:error_reason()}. validate_connect_opts(Opts) when is_map(Opts) -> case misplaced_key(Opts) of none -> Defaults = #{ alpn => [<<"h3">>], idle_timeout => 30000, nowait => false, congestion_control => cubic, verify => verify_peer, version => 1, send_buffer => 1048576, send_timeout => infinity, proactive_cids => false }, {ok, maps:merge(Defaults, flatten_opts(Opts))}; Key -> nquic_error:opts({misplaced_option, Key}) end. -spec validate_listen_opts(map()) -> {ok, map()} | {error, nquic_error:error_reason()}. validate_listen_opts(Opts) when is_map(Opts) -> case misplaced_key(Opts) of none -> case Opts of #{tls := #{certfile := _, keyfile := _}} -> Defaults = #{ alpn => [<<"h3">>], idle_timeout => 30000, receivers => 1, congestion_control => cubic, verify => verify_none, max_new_conns_per_sec => 0, max_accept_queue => 0, retry => false, retry_token_lifetime => 30, send_buffer => 1048576, send_timeout => infinity, server_per_conn_fd => false, version_preference => [1] }, {ok, maps:merge(Defaults, flatten_opts(Opts))}; _ -> nquic_error:opts({missing_option, tls}) end; Key -> nquic_error:opts({misplaced_option, Key}) end.