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.
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 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.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Summary
Adds
irtt_server::ServerSet, a reusable service-level owner of one or moreindependent UDP listeners, and routes the server CLI through it.
ServerSetbinds, supervises and shuts down one or moreServers. It holdsno session state, no tokens and no clock of its own, and nothing it does is
visible on the wire.
Serveris unchanged and remains the public one-listener primitive: onesocket, one
ServerCore, one sequential receive/send loop, reusable&mut selfrun loop. It is not deprecated and does not construct a setinternally.
--bindis now repeatable, so one process can serve several addresses.ServerSet, including for a single bind. Thereis no direct CLI-to-
Serverpath and no bind-count branch.Ownership
Server, oneServerCore, one session table and one token namespace perlistener. A token issued by one listener is an unknown token at the others.
ServerConfigis cloned into each listener, so every bound in it isper listener:
max_sessions = 100over two listeners admits 100 each, not100 in total. Documented in the
ServerSetrustdoc,--max-sessionshelp andthe README.
ServerCore, noArc/Mutexaround one, no process-global sessionregistry or token allocator, and no shared clock — each core keeps its own
SystemClockorigin.Supervision
JoinSet. Each listener stays fullysequential 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.
runtakes one external shutdown future and fans it out over atokio::sync::watchchannel. The library knows nothing about Ctrl-C, signalsor atomics.
runreturns; no detached tasks and noabort_all()for ordinary shutdown. AJoinErroris never discarded — itsurfaces as
ListenerTask, carrying the listener address where the task couldbe identified.
Ok(())before shutdown wasrequested (
ListenerStopped), shuts its siblings down; the group is drainedand the first meaningful failure is returned. A service configured for IPv4 and
IPv6 does not silently continue as IPv4 only. No error aggregation.
async I/O concurrency, not a worker pool.
Bind behavior
spawned while iterating, and a later failure drops the sockets already opened.
ServerSetError::NoListeners.reported by
local_addrs(), which resolves a requested port of0.:0bind selects its own port; no attempt is made to share one ephemeralport across families.
an explicit port with an IPv4 sibling is created
IPV6_V6ONLYthrough a smallsafe
socket2pre-bind helper and handed toServer::from_socket. NoSO_REUSEPORT, no bind-order dependence, no unsafe code, and standaloneServer::bindsemantics are untouched.[::ffff:0.0.0.0]) is treated as the IPv4 listener itis — never given
IPV6_V6ONLY, never counted as the IPv6 half of a pair —matching the existing wildcard and traffic-class handling.
CLI
Singleton and multi-bind invocations use the same
ServerSetpath. One bindis 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
ServerCoreand no global session or token table.syntax.
ServerConfig.irtt-clientproduction changes, no protocolor clean-room document edits.
#![forbid(unsafe_code)]still holds).Verification
All run on macOS (aarch64-apple-darwin), all passing:
Feature boundaries:
cargo tree -p irtt-cli --no-default-features --features serverresolves toclap,ctrlc,irtt-server,tokioonly — no client, stats or TUI.Cross-target checks (installed targets only, nothing new installed) for
irtt-server --all-targets, and forirtt-cli --no-default-features --features serveron the first three:x86_64-unknown-linux-gnuaarch64-unknown-linux-muslx86_64-unknown-freebsdx86_64-pc-windows-gnui686-unknown-linux-gnuNew 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-cliclientagainst a running
irtt-serverprocess 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 processran; a second IPv4 session afterwards.
--bind 127.0.0.1:PORT --bind [::1]:PORTand--bind 0.0.0.0:PORT --bind [::]:PORT— same-port dual-family, both familiesserved. This host defaults to dual-stack sockets (
net.inet6.ip6.v6only=0), sothe pair genuinely depends on the pre-bind
IPV6_V6ONLYstep.