feat(cli): route server through server set

`--bind` becomes repeatable, so one process can serve several addresses:
an explicit IPv4 and IPv6 pair on one port, or two interface addresses
that must stay distinct.

The applet now builds a `ServerSet` for every invocation, including a
single bind. There is deliberately no bind-count branch and no direct
path from the CLI to `Server`: one orchestration path means startup
errors, shutdown and listener-failure handling are identical whatever was
configured, and the single-listener case — the common one — keeps that
path exercised in normal use rather than only in tests.

The configuration stays one `ServerConfig`; addresses never enter it, and
`ServerSet` clones it into each listener. Bounds in it are therefore per
listener, which `--max-sessions` help now says. 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: exactly one shutdown future is created and the set fans it out
internally.
This commit is contained in:
Nils Andreas Svee committed 2026-08-14 16:34:02 +07:00
1 parent 09f0d6e02e
commit 2171d02203
4 files changed
+126 -31

No files matched your search

+3 -2
View File
@@ -57,7 +57,7 @@ Tokio must remain optional for `irtt-client`; default builds must remain runtime
### Server architecture (`irtt-server`)
`irtt-server` is a first-class reusable server crate. It contains the deterministic OPEN/ECHO/session core — packet admission, authentication policy, open negotiation, the bounded session table, open reply construction, echo processing with its per-session receive state and timestamps, per-session rate limiting, session lifetime with idle expiry, the maximum-duration server-initiated close, and client-initiated close — plus a reusable Tokio UDP `Server` that runs one sequential `ServerCore` per listener with caller-controlled shutdown and scheduled idle maintenance. Logical expiry remains exact on authenticated, structurally valid requests, while the runtime also reclaims expired sessions once per second when no traffic arrives. Each reply carries the raw traffic class it must be sent with, which the runtime applies to the listener before every send. A wildcard listener additionally recovers each request's local destination from packet metadata and sends that request's reply from it, on Linux, macOS and FreeBSD; a wildcard bind on a target without that path is refused at construction rather than served from a routing-table source address. Explicit-address listeners are unaffected everywhere. Echo replies fill their payload from the session's negotiated server fill; the policy and its deliberate divergences are in `crates/irtt-server/AGENTS.md`. Two optional negotiation policies restrict what a session may ask the server to provide — a timestamp allowance and DSCP permission — both off by default and both settled during open negotiation rather than in the runtime. Serving multiple listeners in one process is not implemented; that, and ordinary process-level polish and observability decisions, is what remains of the server architecture.
`irtt-server` is a first-class reusable server crate. It contains the deterministic OPEN/ECHO/session core — packet admission, authentication policy, open negotiation, the bounded session table, open reply construction, echo processing with its per-session receive state and timestamps, per-session rate limiting, session lifetime with idle expiry, the maximum-duration server-initiated close, and client-initiated close — plus a reusable Tokio UDP `Server` that runs one sequential `ServerCore` per listener with caller-controlled shutdown and scheduled idle maintenance. Logical expiry remains exact on authenticated, structurally valid requests, while the runtime also reclaims expired sessions once per second when no traffic arrives. Each reply carries the raw traffic class it must be sent with, which the runtime applies to the listener before every send. A wildcard listener additionally recovers each request's local destination from packet metadata and sends that request's reply from it, on Linux, macOS and FreeBSD; a wildcard bind on a target without that path is refused at construction rather than served from a routing-table source address. Explicit-address listeners are unaffected everywhere. Echo replies fill their payload from the session's negotiated server fill; the policy and its deliberate divergences are in `crates/irtt-server/AGENTS.md`. Two optional negotiation policies restrict what a session may ask the server to provide — a timestamp allowance and DSCP permission — both off by default and both settled during open negotiation rather than in the runtime. Above that one-listener primitive sits `ServerSet`, the service-level owner of one or more independent `Server`s: it binds them all or none, runs each in its own Tokio task, fans one external shutdown out to them, joins them all, and fails the group if any listener fails or stops early. Ordinary process-level polish and observability decisions are what remains of the server architecture.
- It shares wire semantics with the client through `irtt-proto` and follows the same clean-room boundary.
- It must **not** depend on `irtt-client` merely to reuse protocol behavior.
@@ -65,7 +65,8 @@ Tokio must remain optional for `irtt-client`; default builds must remain runtime
- There is currently no requirement for a blocking server API, alternate async runtimes, a runtime abstraction layer, or a runtime-free server feature. Do not build blocking/runtime variants merely for symmetry with the client, and do not invent a mirrored client-style API family (`BlockingServer`, `AsyncServer`, `ManagedServer`, `BlockingManagedServer`, or similar) ahead of agreement.
- Protocol/session logic should be separated from socket/runtime orchestration where that materially improves deterministic testing, ownership clarity, state-machine reasoning, or maintainability. A `ServerCore`-like internal boundary is therefore desirable, but its exact name/API is not prescribed, it need not be public, it is not a promise of runtime independence, and simple code should not be tortured merely to make the core artificially pure.
- Runtime/server orchestration belongs around that core and may freely use Tokio for UDP sockets, `recv_from`/`send_to`, IPv4/IPv6 listeners, timers, session expiry, max-duration deadlines, shutdown, bounded control/config channels where useful, and `select!`-style orchestration.
- The CLI server applet exists behind the `irtt-cli` `server` feature, which is part of that crate's default build. It is thin orchestration/configuration over reusable `irtt-server` — one current-thread Tokio runtime, one explicitly bound listener, one `Server` — and must not grow a second server state machine. Multi-listener orchestration, hostname resolution, daemonization, and configuration files are deliberately absent.
- Ownership is layered and the layers do not blur: `Server` is the one-listener primitive (one socket, one `ServerCore`, one sequential loop), and `ServerSet` supervises one or more of them without owning protocol or session state itself. A common `ServerConfig` is cloned into each listener, so its bounds — `max_sessions` included — are per listener and not per process. Listeners share nothing else: no shared `ServerCore`, no global session table or token namespace, no shared clock, and one sequential sender per socket, which is what keeps the socket-wide reply traffic class race-free.
- The CLI server applet exists behind the `irtt-cli` `server` feature, which is part of that crate's default build. It is thin orchestration/configuration over reusable `irtt-server` — one current-thread Tokio runtime, one repeatable `--bind`, one `ServerSet` — and must not grow a second server state machine. **It always uses `ServerSet`, including for a single bind**: there is no direct CLI-to-`Server` path and no bind-count branch, so startup, shutdown and listener-failure semantics are identical whatever was configured, and the singleton case exercises the same orchestration in normal use. Hostname resolution, interface expansion, daemonization, and configuration files are deliberately absent.
- Resource policy: the server should use bounded session/resource policy. Upstream's observed lack of useful default session/per-peer bounds, and its never expiring a session that has not carried an echo request (see `IRTT_SERVER_PROTOCOL_SPEC.md`), are not interoperability requirements and must not be reproduced merely for compatibility.
- Testing: prefer `irtt-server` for normal compliant fake-peer behavior where practical, as far as its implemented behavior reaches. Keep narrow adversarial/raw peers for intentionally malformed, ambiguous, or non-compliant wire behavior.