Server bind defaults + documentation architecture cleanup #128

Merged
Lochnair merged 3 commits from feat/docs-layout-and-server-defaults into main 2026-08-20 15:29:02 +00:00
4 changed files with 216 additions and 15 deletions
Showing only changes of commit e9e1ff4218 - Show all commits

No files matched your search

+59 -12
View File
@@ -1,8 +1,14 @@
use std::{net::SocketAddr, time::Duration}; use std::{
net::{Ipv4Addr, Ipv6Addr, SocketAddr},
time::Duration,
};
use clap::{Parser, ValueEnum}; use clap::{Parser, ValueEnum};
use irtt_server::{ServerConfig, TimestampAllowance}; use irtt_server::{ServerConfig, TimestampAllowance};
/// The ordinary IRTT port, used to build the zero-argument default binds.
const DEFAULT_PORT: u16 = 2112;
// Every policy option is optional. An omitted option leaves the corresponding // Every policy option is optional. An omitted option leaves the corresponding
// `ServerConfig` default in place rather than restating it here, so library and // `ServerConfig` default in place rather than restating it here, so library and
// CLI defaults cannot drift apart. // CLI defaults cannot drift apart.
@@ -10,15 +16,14 @@ use irtt_server::{ServerConfig, TimestampAllowance};
#[command( #[command(
name = "irtt-server", name = "irtt-server",
about = "Minimal IRTT-compatible UDP server", about = "Minimal IRTT-compatible UDP server",
after_help = "Policy options left unset keep the irtt-server library defaults.\nRepeat --bind to serve multiple addresses in one process. All listeners use the same policy but keep independent session namespaces." after_help = "With no --bind, the server listens on the wildcard IRTT port for both address families ([::]:2112 and 0.0.0.0:2112) where the platform supports wildcard reply-source selection.\nRepeat --bind to serve multiple addresses in one process; any explicit --bind replaces the default pair entirely. All listeners use the same policy but keep independent session namespaces."
)] )]
pub struct ServerArgs { pub struct ServerArgs {
/// Local UDP address to bind, as ADDR:PORT. Repeatable. /// Local UDP address to bind, as ADDR:PORT. Repeatable.
#[arg( #[arg(
long, long,
required = true,
value_name = "ADDR", value_name = "ADDR",
long_help = "Local UDP address to bind, as ADDR:PORT, for example 127.0.0.1:2112 or [::1]:2112. Host names are not resolved.\n\nRepeat the option to serve several addresses from one process, in the order given. Every listener applies the same policy options, but each keeps its own sessions and tokens, so a session belongs to the address it was opened on. Binding is all or nothing: if any address cannot be bound, none are served.\n\nA wildcard bind such as 0.0.0.0:2112 answers each request from the address it was sent to, using per-packet destination metadata. That is supported on Linux, macOS and FreeBSD; on other systems a wildcard bind is refused and an explicit interface address is required.\n\nA port of 0 selects an unused port per listener, so two such binds get two different ports." long_help = "Local UDP address to bind, as ADDR:PORT, for example 127.0.0.1:2112 or [::1]:2112. Host names are not resolved.\n\nRepeat the option to serve several addresses from one process, in the order given. Every listener applies the same policy options, but each keeps its own sessions and tokens, so a session belongs to the address it was opened on. Binding is all or nothing: if any address cannot be bound, none are served.\n\nWith no --bind at all, the server binds the wildcard IRTT port on both address families, [::]:2112 then 0.0.0.0:2112, on platforms with wildcard reply-source support; elsewhere it fails and asks for an explicit address instead of guessing one. Any explicit --bind replaces that default pair rather than adding to it.\n\nA wildcard bind such as 0.0.0.0:2112 answers each request from the address it was sent to, using per-packet destination metadata. That is supported on Linux, macOS and FreeBSD; on other systems a wildcard bind is refused and an explicit interface address is required.\n\nA port of 0 selects an unused port per listener, so two such binds get two different ports."
)] )]
pub bind: Vec<SocketAddr>, pub bind: Vec<SocketAddr>,
@@ -47,12 +52,12 @@ pub struct ServerArgs {
)] )]
pub max_packet_length: Option<usize>, pub max_packet_length: Option<usize>,
/// Floor on the negotiated send interval, and the allowance refill cadence. /// Floor on the negotiated send interval.
#[arg( #[arg(
long, long,
value_name = "DURATION", value_name = "DURATION",
value_parser = parse_duration_allow_zero, value_parser = parse_duration_allow_zero,
long_help = "Floor on the send interval a session may negotiate, and the cadence its reply allowance refills at. The negotiated interval is still capped at a quarter of the idle timeout afterwards. Use 0 for no time-based throttling." long_help = "Floor on the send interval a session may negotiate. The negotiated interval is still capped at a quarter of the idle timeout afterwards, so a value above that cap is not what actually gets negotiated.\n\nA session's reply allowance refills at the shorter of this floor and the interval it actually negotiated. Ordinarily that is this floor itself; it is the shorter, negotiated value only where the idle-timeout cap above pulled the negotiated interval below it. Use 0 for no time-based throttling."
)] )]
pub min_interval: Option<Duration>, pub min_interval: Option<Duration>,
@@ -124,6 +129,20 @@ impl From<TimestampAllowanceArg> for TimestampAllowance {
} }
impl ServerArgs { impl ServerArgs {
/// The addresses this invocation binds: the requested `--bind` list, or
/// the wildcard IRTT port on both address families if none was given.
#[must_use]
pub fn resolve_binds(&self) -> Vec<SocketAddr> {
if self.bind.is_empty() {
vec![
SocketAddr::from((Ipv6Addr::UNSPECIFIED, DEFAULT_PORT)),
SocketAddr::from((Ipv4Addr::UNSPECIFIED, DEFAULT_PORT)),
]
} else {
self.bind.clone()
}
}
/// Builds the server configuration this invocation asks for. /// Builds the server configuration this invocation asks for.
#[must_use] #[must_use]
pub fn server_config(&self) -> ServerConfig { pub fn server_config(&self) -> ServerConfig {
2
@@ -236,12 +255,7 @@ mod tests {
} }
#[test] #[test]
fn bind_is_required_and_parses_both_families() { fn bind_is_optional_and_parses_both_families() {
assert!(
parse(&[]).is_err(),
"a server with no address serves nothing"
);
let ipv4 = parse(&["--bind", "127.0.0.1:2112"]).unwrap(); let ipv4 = parse(&["--bind", "127.0.0.1:2112"]).unwrap();
assert_eq!(ipv4.bind, [SocketAddr::from((Ipv4Addr::LOCALHOST, 2112))]); assert_eq!(ipv4.bind, [SocketAddr::from((Ipv4Addr::LOCALHOST, 2112))]);
@@ -255,6 +269,39 @@ mod tests {
assert!(parse(&["--bind", "127.0.0.1:2112,[::1]:2112"]).is_err()); assert!(parse(&["--bind", "127.0.0.1:2112,[::1]:2112"]).is_err());
} }
#[test]
fn no_explicit_bind_resolves_to_the_wildcard_default_pair() {
let args = parse(&[]).unwrap();
assert!(
args.bind.is_empty(),
"the empty list is the parsed representation of \"no explicit bind\""
);
assert_eq!(
args.resolve_binds(),
[
SocketAddr::from((Ipv6Addr::UNSPECIFIED, 2112)),
SocketAddr::from((Ipv4Addr::UNSPECIFIED, 2112)),
],
"zero explicit binds means the ordinary port on both wildcard families, IPv6 first"
);
}
#[test]
fn an_explicit_bind_replaces_the_defaults_rather_than_augmenting_them() {
let args = parse(&["--bind", "127.0.0.1:2112"]).unwrap();
assert_eq!(
args.resolve_binds(),
[SocketAddr::from((Ipv4Addr::LOCALHOST, 2112))],
"one explicit bind means exactly that one listener, not that listener plus defaults"
);
}
#[test]
fn repeated_explicit_binds_are_unaffected_by_the_default_resolution() {
let args = parse(&["--bind", "127.0.0.1:2112", "--bind", "[::1]:2112"]).unwrap();
assert_eq!(args.resolve_binds(), args.bind);
}
#[test] #[test]
fn bind_is_repeatable_and_keeps_the_requested_order() { fn bind_is_repeatable_and_keeps_the_requested_order() {
let args = parse(&[ let args = parse(&[
+119 -2
View File
@@ -1,9 +1,10 @@
use std::{ use std::{
net::SocketAddr,
sync::atomic::{AtomicBool, Ordering}, sync::atomic::{AtomicBool, Ordering},
time::Duration, time::Duration,
}; };
use irtt_server::ServerSet; use irtt_server::{address_family_available, ServerSet, ServerSetError};
use super::args::ServerArgs; use super::args::ServerArgs;
@@ -36,7 +37,29 @@ async fn serve(
shutdown_requested: &AtomicBool, shutdown_requested: &AtomicBool,
chatgpt-codex-connector[bot] commented 2026-08-20 12:00:55 +00:00 (Migrated from github.com)
Review

P2 Badge Keep the surviving IPv6 listener IPv6-only

On hosts where IPv6 sockets default to dual-stack, this retry can still fail when the IPv4 default listener is unavailable. The initial two-address bind makes [::]:2112 IPv6-only, but after the IPv4 bind fails—for example because another process occupies only 0.0.0.0:2112—retrying [::]:2112 alone no longer enables IPV6_V6ONLY, so it also tries to claim the occupied IPv4 port and exits. Preserve the family isolation used by the original pair so zero-argument startup can actually continue on the available IPv6 family.

Useful? React with 👍 / 👎.

**<sub><sub>![P2 Badge](https://img.shields.io/badge/P2-yellow?style=flat)</sub></sub> Keep the surviving IPv6 listener IPv6-only** On hosts where IPv6 sockets default to dual-stack, this retry can still fail when the IPv4 default listener is unavailable. The initial two-address bind makes `[::]:2112` IPv6-only, but after the IPv4 bind fails—for example because another process occupies only `0.0.0.0:2112`—retrying `[::]:2112` alone no longer enables `IPV6_V6ONLY`, so it also tries to claim the occupied IPv4 port and exits. Preserve the family isolation used by the original pair so zero-argument startup can actually continue on the available IPv6 family. Useful? React with 👍 / 👎.
Lochnair commented 2026-08-20 12:20:32 +00:00 (Migrated from github.com)
Review

Fixed: added ServerSet::bind_with_family_context, which decides IPV6_V6ONLY against a caller-supplied context instead of the addresses actually being bound (bind() is now this method using the addresses as their own context, so its existing behavior is unchanged). The CLI's fallback now passes the original two-address default as context, so a surviving lone IPv6 listener stays IPV6_V6ONLY instead of reverting to dual-stack. Added a test that reproduces the dual-stack collision without the context and confirms it's avoided with it. See 17ef443.

🤖 Addressed by Claude Code

Fixed: added `ServerSet::bind_with_family_context`, which decides IPV6_V6ONLY against a caller-supplied context instead of the addresses actually being bound (`bind()` is now this method using the addresses as their own context, so its existing behavior is unchanged). The CLI's fallback now passes the original two-address default as context, so a surviving lone IPv6 listener stays IPV6_V6ONLY instead of reverting to dual-stack. Added a test that reproduces the dual-stack collision without the context and confirms it's avoided with it. See 17ef443. _🤖 Addressed by [Claude Code](https://claude.com/claude-code)_
Lochnair commented 2026-08-20 14:00:37 +00:00 (Migrated from github.com)
Review

This fallback is currently too broad: any ListenerSetup failure for one of the implicit defaults causes a retry with the other family. Please only fall back when the underlying bind error specifically means that address family is unavailable/unsupported on this host. AddrInUse, permission errors, wildcard source-selection/setup failures, and unknown failures should abort startup. If fallback is restricted that way, the bind_with_family_context() retry path should no longer be needed, since we should never retry merely because the sibling family's port is occupied.

This fallback is currently too broad: any `ListenerSetup` failure for one of the implicit defaults causes a retry with the other family. Please only fall back when the underlying bind error specifically means that address family is unavailable/unsupported on this host. `AddrInUse`, permission errors, wildcard source-selection/setup failures, and unknown failures should abort startup. If fallback is restricted that way, the `bind_with_family_context()` retry path should no longer be needed, since we should never retry merely because the sibling family's port is occupied.
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
let config = args.server_config(); let config = args.server_config();
let set = ServerSet::bind(args.bind, config).await?; let binds = args.resolve_binds();
let is_implicit_default = args.bind.is_empty();
let set = match ServerSet::bind(binds.clone(), config.clone()).await {
Ok(set) => set,
Err(ServerSetError::ListenerSetup { addr, source }) => {
let family_available = address_family_available(addr);
match implicit_default_fallback(is_implicit_default, family_available, &binds, addr) {
Some(remaining) => {
eprintln!(
"irtt-server: default listener {addr} unavailable ({source}); \
continuing with the remaining default listener"
);
// `remaining`'s lone survivor is bound exactly as it
// would be alone: the address family that just failed
// has no local support at all, so nothing on that family
// could be competing for the surviving listener's port.
ServerSet::bind(remaining, config).await?
}
None => return Err(ServerSetError::ListenerSetup { addr, source }.into()),
}
}
Err(err) => return Err(err.into()),
};
// Only once every listener is up. Announcing one address and then failing // Only once every listener is up. Announcing one address and then failing
// to bind the next would report a service that never started. // to bind the next would report a service that never started.
for addr in set.local_addrs() { for addr in set.local_addrs() {
@@ -46,6 +69,37 @@ async fn serve(
Ok(()) Ok(())
} }
/// Whether a listener-setup failure for `failed_addr` should be retried with
/// a reduced bind set instead of failing the whole invocation, and if so,
/// what to retry with.
///
/// Only the implicit zero-argument default gets this treatment, and only
/// when `failed_addr`'s own address family has no local support on this host
/// at all (`family_available` is `false`) — not merely because its specific
/// port was unavailable for some other reason: already in use, permission
/// denied, no safe wildcard reply-source path, or anything else
/// `ServerSet::bind` can fail with. Those are real configuration or
/// environment problems this invocation should fail on, not silently work
/// around by dropping a listener the operator asked for. An explicit
/// `--bind` list keeps the library's ordinary all-or-nothing behavior
/// regardless.
fn implicit_default_fallback(
is_implicit_default: bool,
family_available: bool,
original_binds: &[SocketAddr],
failed_addr: SocketAddr,
) -> Option<Vec<SocketAddr>> {
if !is_implicit_default || family_available || !original_binds.contains(&failed_addr) {
return None;
}
let remaining: Vec<SocketAddr> = original_binds
.iter()
.copied()
.filter(|&addr| addr != failed_addr)
.collect();
(!remaining.is_empty()).then_some(remaining)
}
async fn shutdown_signal(shutdown_requested: &AtomicBool) { async fn shutdown_signal(shutdown_requested: &AtomicBool) {
while !shutdown_requested.load(Ordering::Relaxed) { while !shutdown_requested.load(Ordering::Relaxed) {
tokio::time::sleep(SHUTDOWN_POLL_INTERVAL).await; tokio::time::sleep(SHUTDOWN_POLL_INTERVAL).await;
@@ -82,4 +136,67 @@ mod tests {
run_server(args, &shutdown_requested).unwrap(); run_server(args, &shutdown_requested).unwrap();
} }
fn default_binds() -> Vec<SocketAddr> {
ServerArgs::try_parse_from(["irtt-server"])
.unwrap()
.resolve_binds()
}
#[test]
fn implicit_default_losing_an_unsupported_family_falls_back_to_the_other() {
let binds = default_binds();
let failed = binds[0];
let remaining = implicit_default_fallback(true, false, &binds, failed).unwrap();
assert_eq!(remaining, [binds[1]]);
}
#[test]
fn a_supported_familys_failure_never_falls_back() {
// The family itself works fine here; whatever made this specific
// bind fail (port in use, permission, ...) is a real problem this
// invocation should surface, not paper over.
let binds = default_binds();
assert_eq!(
implicit_default_fallback(true, true, &binds, binds[0]),
None,
"family_available: true means this is not a family-support problem"
);
}
#[test]
fn explicit_binds_never_fall_back() {
let binds = default_binds();
assert_eq!(
implicit_default_fallback(false, false, &binds, binds[0]),
None
);
}
#[test]
fn a_failure_outside_the_requested_set_does_not_fall_back() {
let binds = default_binds();
let unrelated = SocketAddr::from(([127, 0, 0, 1], 2113));
assert_eq!(
implicit_default_fallback(true, false, &binds, unrelated),
None,
"the failing address must actually be one this invocation asked for"
);
}
#[test]
fn losing_the_only_remaining_default_address_does_not_fall_back() {
let single = [SocketAddr::from(([0, 0, 0, 0], 2112))];
assert_eq!(
implicit_default_fallback(true, false, &single, single[0]),
None,
"no address would be left to retry with"
);
}
} }
+4 -1
View File
@@ -32,6 +32,9 @@
//! [`ServerConfig`] — `max_sessions` included — applies **per listener**, not //! [`ServerConfig`] — `max_sessions` included — applies **per listener**, not
//! per process. //! per process.
//! //!
//! See `examples/` in the repository for a runnable single-listener and
//! multi-listener example.
//!
//! # Rejection is silence //! # Rejection is silence
//! //!
//! The protocol defines no error reply, no reset and no NACK. Malformed, //! The protocol defines no error reply, no reset and no NACK. Malformed,
@@ -129,4 +132,4 @@ pub use config::{
pub use core::{OutboundDatagram, ServerCore}; pub use core::{OutboundDatagram, ServerCore};
pub use error::ServerError; pub use error::ServerError;
pub use runtime::{Server, ServerRuntimeError}; pub use runtime::{Server, ServerRuntimeError};
pub use set::{ServerSet, ServerSetError}; pub use set::{address_family_available, ServerSet, ServerSetError};
+34
View File
@@ -218,6 +218,40 @@ impl ServerSet {
} }
chatgpt-codex-connector[bot] commented 2026-08-20 15:10:04 +00:00 (Migrated from github.com)
Review

P2 Badge Detect families that cannot bind, not only missing socket domains

On Linux systems where IPv6 is disabled through interface/sysctl configuration, creating an AF_INET6 datagram socket can still succeed while binding [::]:2112 fails with EADDRNOTAVAIL. This probe therefore reports the family as available, so implicit_default_fallback rejects the fallback and the zero-argument server exits instead of serving the usable IPv4 default. Fresh evidence relative to the earlier IPv4-only-host finding is that the replacement helper tests only Socket::new and never the wildcard bind capability that actually failed.

Useful? React with 👍 / 👎.

**<sub><sub>![P2 Badge](https://img.shields.io/badge/P2-yellow?style=flat)</sub></sub> Detect families that cannot bind, not only missing socket domains** On Linux systems where IPv6 is disabled through interface/sysctl configuration, creating an `AF_INET6` datagram socket can still succeed while binding `[::]:2112` fails with `EADDRNOTAVAIL`. This probe therefore reports the family as available, so `implicit_default_fallback` rejects the fallback and the zero-argument server exits instead of serving the usable IPv4 default. Fresh evidence relative to the earlier IPv4-only-host finding is that the replacement helper tests only `Socket::new` and never the wildcard bind capability that actually failed. Useful? React with 👍 / 👎.
Lochnair commented 2026-08-20 15:20:00 +00:00 (Migrated from github.com)
Review

Fixed: address_family_available now actually binds the wildcard address of the family on an ephemeral port instead of only constructing a bare socket, so administratively-disabled-per-interface IPv6 (constructible AF_INET6 socket, every bind EADDRNOTAVAIL) is now correctly detected as unavailable. Folded into the relevant commit as part of this PR's squash/rebase cleanup.

🤖 Addressed by Claude Code

Fixed: address_family_available now actually binds the wildcard address of the family on an ephemeral port instead of only constructing a bare socket, so administratively-disabled-per-interface IPv6 (constructible AF_INET6 socket, every bind EADDRNOTAVAIL) is now correctly detected as unavailable. Folded into the relevant commit as part of this PR's squash/rebase cleanup. _🤖 Addressed by [Claude Code](https://claude.com/claude-code)_
} }
/// Whether `addr`'s address family has any local support on this host at
/// all, independent of whether this particular address or port can be
/// bound.
///
/// A [`ServerSetError::ListenerSetup`] failure can mean many different
/// things — the port is already in use, permission is denied, this target
/// has no safe wildcard reply-source path (see [`ServerRuntimeError`]) — and
/// only one of them, the address family itself having no local support at
/// all (for example IPv6 disabled on the host), is safe for a caller to work
/// around by silently trying a different address instead. This checks only
/// that, by actually binding the wildcard address of `addr`'s family on an
/// ephemeral port: a bare socket of an unsupported family can still
/// construct successfully on some systems (Linux administratively disables
/// IPv6 per-interface, so `AF_INET6` sockets keep working while every bind
/// fails with `EADDRNOTAVAIL`), so construction alone is not a reliable
/// signal — only a real bind attempt is.
#[must_use]
pub fn address_family_available(addr: SocketAddr) -> bool {
let wildcard = if addr.is_ipv6() {
SocketAddr::from((std::net::Ipv6Addr::UNSPECIFIED, 0))
} else {
SocketAddr::from((std::net::Ipv4Addr::UNSPECIFIED, 0))
};
let domain = if addr.is_ipv6() {
Domain::IPV6
} else {
Domain::IPV4
};
let Ok(socket) = Socket::new(domain, Type::DGRAM, Some(Protocol::UDP)) else {
return false;
};
socket.bind(&SockAddr::from(wildcard)).is_ok()
}
/// Binds one listener, pre-configuring the socket where the bind itself depends /// Binds one listener, pre-configuring the socket where the bind itself depends
/// on it. /// on it.
/// ///
4