feat(server): add multi-listener server set #88

Merged
Lochnair merged 2 commits from feat/server-multi-listener into main 2026-08-14 09:46:45 +00:00
Lochnair commented 2026-08-14 09:36:05 +00:00 (Migrated from github.com)

Summary

Adds irtt_server::ServerSet, a reusable service-level owner of one or more
independent UDP listeners, and routes the server CLI through it.

  • ServerSet binds, supervises and shuts down one or more Servers. It holds
    no session state, no tokens and no clock of its own, and nothing it does is
    visible on the wire.
  • Server is unchanged and remains the public one-listener primitive: one
    socket, one ServerCore, one sequential receive/send loop, reusable
    &mut self run loop. It is not deprecated and does not construct a set
    internally.
  • --bind is now repeatable, so one process can serve several addresses.
  • The server CLI always uses ServerSet, including for a single bind. There
    is no direct CLI-to-Server path and no bind-count branch.

Ownership

ServerSet
    listener 0 -> Server -> ServerCore -> sessions
    listener 1 -> Server -> ServerCore -> sessions
  • One Server, one ServerCore, one session table and one token namespace per
    listener. A token issued by one listener is an unknown token at the others.
  • The single ServerConfig is cloned into each listener, so every bound in it is
    per listener: max_sessions = 100 over two listeners admits 100 each, not
    100 in total. Documented in the ServerSet rustdoc, --max-sessions help and
    the README.
  • No shared ServerCore, no Arc/Mutex around one, no process-global session
    registry or token allocator, and no shared clock — each core keeps its own
    SystemClock origin.

Supervision

  • One Tokio task per listener, via JoinSet. Each listener stays fully
    sequential inside its task: no per-packet spawn, no concurrent sender, so the
    one-sender-per-socket ownership the socket-wide reply traffic class depends on
    is unchanged.
  • run takes one external shutdown future and fans it out over a
    tokio::sync::watch channel. The library knows nothing about Ctrl-C, signals
    or atomics.
  • Every listener task is joined before run returns; no detached tasks and no
    abort_all() for ordinary shutdown. A JoinError is never discarded — it
    surfaces as ListenerTask, carrying the listener address where the task could
    be identified.
  • Fail-fast: a listener that errors, or that returns Ok(()) before shutdown was
    requested (ListenerStopped), shuts its siblings down; the group is drained
    and the first meaningful failure is returned. A service configured for IPv4 and
    IPv6 does not silently continue as IPv4 only. No error aggregation.
  • The CLI keeps its current-thread Tokio runtime — listener concurrency here is
    async I/O concurrency, not a worker pool.

Bind behavior

  • All-or-nothing: every listener is bound before any is served, nothing is
    spawned while iterating, and a later failure drops the sockets already opened.
  • Empty input is refused with ServerSetError::NoListeners.
  • Requested order is preserved and the actual bound addresses are retained and
    reported by local_addrs(), which resolves a requested port of 0.
  • Each :0 bind selects its own port; no attempt is made to share one ephemeral
    port across families.
  • Same-port IPv4/IPv6 coexistence is deliberate: a genuine IPv6 listener sharing
    an explicit port with an IPv4 sibling is created IPV6_V6ONLY through a small
    safe socket2 pre-bind helper and handed to Server::from_socket. No
    SO_REUSEPORT, no bind-order dependence, no unsafe code, and standalone
    Server::bind semantics are untouched.
  • An IPv4-mapped address ([::ffff:0.0.0.0]) is treated as the IPv4 listener it
    is — never given IPV6_V6ONLY, never counted as the IPv6 half of a pair —
    matching the existing wildcard and traffic-class handling.

CLI

irtt-server --bind 127.0.0.1:2112
irtt-server \
    --bind 0.0.0.0:2112 \
    --bind [::]:2112

Singleton and multi-bind invocations use the same ServerSet path. One bind
is an ordinary set of one, so startup errors, shutdown semantics and listener
supervision are identical either way, and the common case keeps the orchestration
exercised in normal use.

Listener lines are printed only once the whole set is up, so a set whose second
bind fails no longer announces the first as listening. The shutdown flag, its
polling future and the current-thread runtime are unchanged; the CLI creates
exactly one shutdown future.

Scope exclusions

  • No shared ServerCore and no global session or token table.
  • No global session cap — bounds stay per listener.
  • No dynamic listener add/remove; sets are fixed at construction.
  • No hostname resolution, interface-name expansion, or comma-separated address
    syntax.
  • No per-listener ServerConfig.
  • No config files, daemonization, or logging/tracing framework.
  • No protocol behavior changes, no irtt-client production changes, no protocol
    or clean-room document edits.
  • No unsafe code (#![forbid(unsafe_code)] still holds).

Verification

All run on macOS (aarch64-apple-darwin), all passing:

cargo fmt --all -- --check
cargo check --workspace --all-targets --all-features --locked
cargo test --workspace --all-targets --all-features --locked
cargo clippy --workspace --all-targets --all-features --locked -- -D warnings
RUSTDOCFLAGS="-D warnings" cargo doc --workspace --all-features --no-deps

Feature boundaries:

cargo check -p irtt-cli --no-default-features --all-targets --locked
cargo check -p irtt-cli --no-default-features --features server --all-targets --locked
cargo test -p irtt-cli --no-default-features --features server --locked

cargo tree -p irtt-cli --no-default-features --features server resolves to
clap, ctrlc, irtt-server, tokio only — no client, stats or TUI.

Cross-target checks (installed targets only, nothing new installed) for
irtt-server --all-targets, and for irtt-cli --no-default-features --features server on the first three:

  • x86_64-unknown-linux-gnu
  • aarch64-unknown-linux-musl
  • x86_64-unknown-freebsd
  • x86_64-pc-windows-gnu
  • i686-unknown-linux-gnu

New tests: nine integration tests in crates/irtt-server/tests/server_set.rs
(singleton set serving a whole session, two listeners with interleaved traffic,
cross-listener token isolation, per-listener session bound, configuration
reaching every listener, one shutdown releasing every socket, all-or-nothing
bind, same-port IPv4/IPv6, same-port wildcard pair), plus unit tests for the
IPv6-only classification including mapped addresses and the empty-set refusal.
CLI parser tests cover the missing, single, repeated, hostname and
comma-separated cases; runner tests cover immediate shutdown for one bind and for
two.

Manual smoke against the built binaries, driving the in-repo irtt-cli client
against a running irtt-server process and stopping it by exact PID:

  • --bind 127.0.0.1:0 — single listener, full session, clean exit.
  • --bind 127.0.0.1:0 --bind [::1]:0 — both endpoints driven while one process
    ran; a second IPv4 session afterwards.
  • --bind 127.0.0.1:PORT --bind [::1]:PORT and
    --bind 0.0.0.0:PORT --bind [::]:PORT — same-port dual-family, both families
    served. This host defaults to dual-stack sockets (net.inet6.ip6.v6only=0), so
    the pair genuinely depends on the pre-bind IPV6_V6ONLY step.
  • A set whose second listener cannot bind prints no listener line and exits 1.
## Summary Adds `irtt_server::ServerSet`, a reusable service-level owner of one or more independent UDP listeners, and routes the server CLI through it. - `ServerSet` binds, supervises and shuts down one or more `Server`s. It holds no session state, no tokens and no clock of its own, and nothing it does is visible on the wire. - `Server` is unchanged and remains the public one-listener primitive: one socket, one `ServerCore`, one sequential receive/send loop, reusable `&mut self` run loop. It is not deprecated and does not construct a set internally. - `--bind` is now repeatable, so one process can serve several addresses. - **The server CLI always uses `ServerSet`, including for a single bind.** There is no direct CLI-to-`Server` path and no bind-count branch. ## Ownership ServerSet listener 0 -> Server -> ServerCore -> sessions listener 1 -> Server -> ServerCore -> sessions - One `Server`, one `ServerCore`, one session table and one token namespace per listener. A token issued by one listener is an unknown token at the others. - The single `ServerConfig` is cloned into each listener, so every bound in it is **per listener**: `max_sessions = 100` over two listeners admits 100 each, not 100 in total. Documented in the `ServerSet` rustdoc, `--max-sessions` help and the README. - No shared `ServerCore`, no `Arc`/`Mutex` around one, no process-global session registry or token allocator, and no shared clock — each core keeps its own `SystemClock` origin. ## Supervision - One Tokio task per listener, via `JoinSet`. Each listener stays fully sequential inside its task: no per-packet spawn, no concurrent sender, so the one-sender-per-socket ownership the socket-wide reply traffic class depends on is unchanged. - `run` takes one external shutdown future and fans it out over a `tokio::sync::watch` channel. The library knows nothing about Ctrl-C, signals or atomics. - Every listener task is joined before `run` returns; no detached tasks and no `abort_all()` for ordinary shutdown. A `JoinError` is never discarded — it surfaces as `ListenerTask`, carrying the listener address where the task could be identified. - Fail-fast: a listener that errors, or that returns `Ok(())` before shutdown was requested (`ListenerStopped`), shuts its siblings down; the group is drained and the first meaningful failure is returned. A service configured for IPv4 and IPv6 does not silently continue as IPv4 only. No error aggregation. - The CLI keeps its current-thread Tokio runtime — listener concurrency here is async I/O concurrency, not a worker pool. ## Bind behavior - All-or-nothing: every listener is bound before any is served, nothing is spawned while iterating, and a later failure drops the sockets already opened. - Empty input is refused with `ServerSetError::NoListeners`. - Requested order is preserved and the actual bound addresses are retained and reported by `local_addrs()`, which resolves a requested port of `0`. - Each `:0` bind selects its own port; no attempt is made to share one ephemeral port across families. - Same-port IPv4/IPv6 coexistence is deliberate: a genuine IPv6 listener sharing an explicit port with an IPv4 sibling is created `IPV6_V6ONLY` through a small safe `socket2` pre-bind helper and handed to `Server::from_socket`. No `SO_REUSEPORT`, no bind-order dependence, no unsafe code, and standalone `Server::bind` semantics are untouched. - An IPv4-mapped address (`[::ffff:0.0.0.0]`) is treated as the IPv4 listener it is — never given `IPV6_V6ONLY`, never counted as the IPv6 half of a pair — matching the existing wildcard and traffic-class handling. ## CLI ```sh irtt-server --bind 127.0.0.1:2112 ``` ```sh irtt-server \ --bind 0.0.0.0:2112 \ --bind [::]:2112 ``` **Singleton and multi-bind invocations use the same `ServerSet` path.** One bind is an ordinary set of one, so startup errors, shutdown semantics and listener supervision are identical either way, and the common case keeps the orchestration exercised in normal use. Listener lines are printed only once the whole set is up, so a set whose second bind fails no longer announces the first as listening. The shutdown flag, its polling future and the current-thread runtime are unchanged; the CLI creates exactly one shutdown future. ## Scope exclusions - No shared `ServerCore` and no global session or token table. - No global session cap — bounds stay per listener. - No dynamic listener add/remove; sets are fixed at construction. - No hostname resolution, interface-name expansion, or comma-separated address syntax. - No per-listener `ServerConfig`. - No config files, daemonization, or logging/tracing framework. - No protocol behavior changes, no `irtt-client` production changes, no protocol or clean-room document edits. - No unsafe code (`#![forbid(unsafe_code)]` still holds). ## Verification All run on macOS (aarch64-apple-darwin), all passing: ```sh cargo fmt --all -- --check cargo check --workspace --all-targets --all-features --locked cargo test --workspace --all-targets --all-features --locked cargo clippy --workspace --all-targets --all-features --locked -- -D warnings RUSTDOCFLAGS="-D warnings" cargo doc --workspace --all-features --no-deps ``` Feature boundaries: ```sh cargo check -p irtt-cli --no-default-features --all-targets --locked cargo check -p irtt-cli --no-default-features --features server --all-targets --locked cargo test -p irtt-cli --no-default-features --features server --locked ``` `cargo tree -p irtt-cli --no-default-features --features server` resolves to `clap`, `ctrlc`, `irtt-server`, `tokio` only — no client, stats or TUI. Cross-target checks (installed targets only, nothing new installed) for `irtt-server --all-targets`, and for `irtt-cli --no-default-features --features server` on the first three: - `x86_64-unknown-linux-gnu` - `aarch64-unknown-linux-musl` - `x86_64-unknown-freebsd` - `x86_64-pc-windows-gnu` - `i686-unknown-linux-gnu` New tests: nine integration tests in `crates/irtt-server/tests/server_set.rs` (singleton set serving a whole session, two listeners with interleaved traffic, cross-listener token isolation, per-listener session bound, configuration reaching every listener, one shutdown releasing every socket, all-or-nothing bind, same-port IPv4/IPv6, same-port wildcard pair), plus unit tests for the IPv6-only classification including mapped addresses and the empty-set refusal. CLI parser tests cover the missing, single, repeated, hostname and comma-separated cases; runner tests cover immediate shutdown for one bind and for two. Manual smoke against the built binaries, driving the in-repo `irtt-cli` client against a running `irtt-server` process and stopping it by exact PID: - `--bind 127.0.0.1:0` — single listener, full session, clean exit. - `--bind 127.0.0.1:0 --bind [::1]:0` — both endpoints driven while one process ran; a second IPv4 session afterwards. - `--bind 127.0.0.1:PORT --bind [::1]:PORT` and `--bind 0.0.0.0:PORT --bind [::]:PORT` — same-port dual-family, both families served. This host defaults to dual-stack sockets (`net.inet6.ip6.v6only=0`), so the pair genuinely depends on the pre-bind `IPV6_V6ONLY` step. - A set whose second listener cannot bind prints no listener line and exits 1.
Sign in to join this conversation.