Skip to content

Configuration Reference

irrld edited this page Aug 4, 2026 · 6 revisions

Options are scoped the way Netty's are: options configure the thing you created, child_options configure each session a listener accepts. A client has no children, so its options are the session's.

void ConfigureServer() {
  // no ConnectionType, so this is ZDT, the default
  ServerConfig config{"0.0.0.0", 25000, std::chrono::seconds(10)};

  // options: the listener itself
  config.options.max_connections = 4096;

  // child_options: every session the listener accepts
  config.child_options.common.idle_timeout = std::chrono::seconds(30);
  config.child_options.common.encryption = true;
  config.child_options.common.compression = CompressionType::Zstandard;
  config.child_options.common.compression_threshold = 128;
  config.child_options.common.send_queue_capacity = 1024;
  config.child_options.zdt.max_datagrams_in_flight = 256;
  config.child_options.zdt.rto_min = std::chrono::milliseconds(50);
  config.child_options.zdt.mtu_ladder.Set({1492, 1200, 576});
}

void ConfigureClient() {
  // a client has no children, so its options are the session's
  ClientConfig config{"127.0.0.1", 25000, std::chrono::seconds(10)};
  config.options.common.idle_timeout = std::chrono::seconds(30);
  config.options.common.keepalive_interval = std::chrono::milliseconds(500);
}

The tcp and zdt groups are always present and always settable. Only the one matching the session's connection_type is read, and setting the other is neither an error nor a warning, so a zdt block on a TCP session is dead configuration that looks live.

They are plain structs, not a typed-key map: every option is known at compile time, so unset fields simply keep their defaults.

Config

Field Notes
server_ip / server_port Where to connect (client) or bind (server)
connection_timeout Give up on a connection attempt after this. Zero waits forever
connection_type ConnectionType::ZDT (the default) or ConnectionType::TCP
options Listener scope on a server; session scope on a client
child_options Server only: applied to each accepted session

The address string also accepts a Unix domain socket path, spelled unix:/run/app.sock. That requires ConnectionType::TCP (it is the stream backend; ZDT refuses paths), the port is ignored, and it is POSIX only. The server takes over a stale socket file on bind and unlinks it on close.

CommonOptions

Applies to any session, whatever the transport.

Option Default Notes
idle_timeout 10 s Drop a session that has heard nothing this long. Both transports implement it. Zero disables
keepalive_interval 1000 ms Ping a connection with nothing else to send, keeping it inside the peer's idle_timeout. Transport-internal; never reaches the application. Zero disables
encryption true Read only on the accepting side. See Encryption and Compression
compression Default Resolved at session start to whatever the build supports
compression_threshold 128 B Below this, messages go uncompressed
send_queue_capacity 512 Packets a session holds for its worker to encode
dump_on_decode_failure false Log a hex dump of a payload whose frame fails to decode, capped at 512 bytes
max_invalid_frames 16 Close a session once this many of its frames failed to decode. Zero disables

max_invalid_frames

A frame that fails to decode costs the rest of its buffer, since the framing after it cannot be trusted. This threshold is what stops a peer from making that a free, repeatable attack. It counts over the session's whole life: unreadable headers, declared lengths the buffer cannot back, serializers refusing a frame or reading past their frame. Unknown packet ids are not counted, since they skip cleanly and can be honest version skew. The count is visible as invalid_frames in Metrics and via session->invalid_frames().

dump_on_decode_failure logs the evidence when one happens: the offset of the failing frame and the payload bytes. Off by default because payloads are user data and this puts them in the log.

send_queue_capacity

This is the backpressure knob. SendPacket returns Result::QueueFull once the queue is full, which is how an application learns it is outrunning the link. A refusal loses nothing, since you still hold the packet.

The queue is a ring allocated whole at construction, roughly 32 bytes a slot rounded up to a power of two, with no per-message allocation afterwards. Size it to the largest burst worth absorbing between two of the worker's ticks, not to the total you ever expect to send. A transport queues further messages behind its own congestion window, so this is not the whole picture.

ZDTOptions

Read only when connection_type is ConnectionType::ZDT, which is the default. Ignored on a TCP session.

Timing and retries

Option Default Notes
rto_min 100 ms Floor on the retransmit timeout, however low the measured RTT
rto_max 2000 ms Ceiling, including backoff
max_retries 10 Retransmits of one message before the connection is closed

Lowering rto_min recovers from loss faster on a LAN. On the open internet it causes retransmits of packets that were merely late, which costs bandwidth and can push the congestion controller down.

Windows

Option Default Notes
max_datagrams_in_flight 512 Ceiling on the congestion window, in datagrams
max_messages_in_flight 4096 Reliable messages allowed in flight. A memory bound, not congestion control

max_datagrams_in_flight is a bound, not the window. ZDT slow-starts from 10 datagrams and backs off on queueing delay rather than on loss; this caps how far it may grow. It counts datagrams, not bytes, so a half-full datagram costs as much as a full one. Bytes in flight are roughly this times the MTU per round trip, which is what governs throughput on a long link.

The two are separate because coalescing puts many messages in one datagram. Holding max_messages_in_flight near max_datagrams_in_flight would throttle small messages far below what the window actually allows.

Handshake and MTU

Option Default Notes
mtu_ladder 1492, 1200, 576 Candidates, probed largest first. Set with .Set({...}), max 4
handshake_retransmit 250 ms Wait for a reply before resending
handshake_retries_per_rung 4 Attempts at one MTU before stepping down

The settled MTU determines the largest message the connection can carry. See Choosing a Transport.

Limits and abuse protection

Option Default Notes
cookie_secret_rotation 120 s How often the server rotates its return-routability secret
max_connections 4096 Established connections a server holds
per_source_handshake_rate 20/s Handshake messages accepted per source address
reassembly_timeout 5 s Discard a partial message after this
max_reassembly_bytes 16 MiB Ceiling on bytes held in partial reassemblies
max_reassemblies 256 Concurrent partially reassembled messages
max_inbox_datagrams 4096 Raw datagrams queued per connection before arrivals are dropped
outbound_queue_capacity 4096 Encoded messages the transport holds before Send() fails

Reaching max_reassembly_bytes refuses to start new messages rather than discarding data already accepted, so a peer stalls instead of losing anything.

outbound_queue_capacity sits past the point where a caller can be told to try again: the message is already encoded, so a refusal here drops it rather than pushing back the way a full send_queue_capacity does. That is why its default is generous rather than tuned. The transport also keeps a staging queue, where a shut congestion window parks messages, and refills from the ring only while it holds fewer than this many, so it can hold up to twice this figure in total.

Socket buffers

Option Default Notes
socket_recv_buffer 4 MiB SO_RCVBUF on the UDP socket. Zero leaves the OS default
socket_send_buffer 4 MiB SO_SNDBUF, on the same terms

One socket serves every connection on an endpoint, so these are per endpoint, not per session. They only have to absorb bursts that arrive between drains by the receive thread; max_inbox_datagrams is the designed backpressure point, not these.

Both are best-effort. The kernel clamps silently to its own ceiling (net.core.rmem_max and net.core.wmem_max on Linux, often well below 4 MiB out of the box), so asking for more than it allows is not an error and warns about nothing. znet reads the granted size back and logs both at debug level:

ZDT socket buffers: asked 4194304/4194304, granted 425984/425984 (recv/send bytes, ...)

Raise the sysctls if the larger ask has to take effect. Going much beyond what covers a scheduler stall buys little: the receive thread's drain rate is the bottleneck, and a very deep kernel queue makes every session wait behind a flood rather than letting the per-session inbox caps drop it fairly.

ServerOptions

Listener scope: things that exist before any session does.

Option Default Notes
backlog 0 Pending-connection backlog. Zero uses SOMAXCONN. TCP only
max_connections 0 Cap on concurrent sessions, refused at accept. Zero means unlimited
reuse_address true SO_REUSEADDR on the listening socket
allowlist empty Sources allowed to connect, as CIDRBlocks. Empty admits everyone the denylist does not refuse
denylist empty Sources always refused. Wins over the allowlist
max_attempts_per_source 0 Connection attempts one source IP may make per attempt_window. Zero disables
attempt_window 10 s The window the attempt count runs over

Note that ZDTOptions::max_connections and ServerOptions::max_connections are different settings: the ZDT one is enforced by the transport before a session exists, the listener one by the server.

Admission rules

The lists and the throttle are checked when a connection arrives: at accept on TCP, at first contact on ZDT, where refusal is a silent drop so an excluded source learns nothing. Rules are CIDRBlocks:

config.options.denylist.push_back(znet::CIDRBlock::Parse("203.0.113.0/24"));
config.options.allowlist.push_back(znet::CIDRBlock::Parse("10.0.0.0/8"));
config.options.max_attempts_per_source = 10;

A bare host parses as its /32 (or /128). IPv4 rules also match IPv4-mapped IPv6 sources, which is what a v4 client looks like to a dual-stack listener. Invalid blocks are dropped with an error at server construction, not enforced half-parsed. Unix socket listeners have no source address and bypass all of it; the socket file's permissions are their gate. On ZDT the attempt throttle counts every handshake-opening datagram, including retransmits of a lost one, so leave slack above the honest rate. Refusals show up as the admission_rejected counter in Metrics.

Clone this wiki locally