Each workspace crate now owns its own version instead of the previous
lockstep scheme; irtt-proto, irtt-client, irtt-server, irtt-stats, and
irtt-cli all become 0.5.0 as the first independently-versioned release.
Workspace dependency requirements are bumped to match.
Add a root CHANGELOG.md with a real per-crate 0.5.0 section drawn from
archaeology of every merge since v0.4.0, not just path-filtered commits.
Move cargo-dist to a crate-qualified `irtt-cli/vX.Y.Z` tag namespace so
only irtt-cli (the one dist-able application) triggers a GitHub Release;
library crate tags (irtt-proto/irtt-client/irtt-server/irtt-stats) exist
purely to pin the source for a crates.io release and no longer risk
tripping the release workflow. Regenerate the renamed
irtt-cli-release.yml via `dist generate` and update the few other files
that referenced the old release.yml path by name. Also fix a stale
`features = ["full"]` in dist-workspace.toml left over from a feature-flag
refactor that dropped the "full" feature entirely, which was silently
breaking every dist build.
Rescope semver.yml's tag trigger to the same five crate-qualified tags
so semver checks keep running under the new convention.
Real signal delivery on Linux (observed via x86_64-musl workspace
testing) can interrupt blocking and nonblocking recv/recvmsg calls
with EINTR. Several production and test receive paths treated that as
a fatal error rather than a transient interruption:
- Client::open's blocking receive loop and Client::recv_once/
recv_available's blocking receive returned ClientError::Socket on
Interrupted instead of retrying within their existing timeout
budget.
- AsyncClient::poll_open and poll_recv's nonblocking recvmsg path
(Linux ancillary) failed the same way instead of retrying through
the existing readiness/work-budget machinery.
- The server's ancillary receive loop could terminate the listener on
an interrupted nonblocking recvmsg.
- The client's MSG_ERRQUEUE TX-timestamp drain turned an interrupted
read of optional metadata into a fatal ClientError::Socket.
- Fake-server test helpers panicked or misclassified EINTR as a
timeout instead of retrying it.
Interrupted is now retried transparently everywhere a receive can
observe it, while preserving each path's existing timing contract:
- Blocking Open reuses its already-absolute per-attempt deadline, so
interruptions resume within the same attempt without retransmitting
or extending the deadline.
- recv_once/recv_available compute one absolute deadline per logical
receive (when a timeout is configured) so repeated interruptions
cannot extend it past the configured timeout, and restore the
socket's configured read timeout afterward so a later call in the
same recv_available budget is unaffected.
- AsyncClient's poll_open already bounds its retry loop with
OPEN_POLL_WORK_BUDGET; poll_recv gains an equivalent
RECV_POLL_WORK_BUDGET, since unlike WouldBlock, Interrupted does not
clear Tokio's readiness state, so an unbounded retry loop was
otherwise possible under sustained signal delivery.
- The server's receive loop retries Interrupted the same way it
already retries WouldBlock, yielding to the runtime after a bounded
number of consecutive interruptions for the same reason.
- The error-queue drain treats an interrupted read as no record
obtained this drain (deferring to the next natural drain point)
rather than a socket error, matching its existing EWOULDBLOCK
handling.
Deterministic regression tests use a small test-only injection hook
(mirroring existing send-injection test hooks) rather than real signal
timing, covering: Open completing despite interruptions with no
retransmit, Open's deadline not extending under repeated
interruptions, recv_once classifying a reply normally despite
interruptions, recv_once's timeout not extending under repeated
interruptions, and the AsyncClient equivalents for open and recv.
No protocol, session, or timeout-classification behavior changes; no
new dependencies; send-side EINTR handling is unchanged and out of
scope.
An echo reply's receive wall timestamp now prefers the kernel arrival
reading the Linux listener captured, where that reading is plausible for
the datagram it describes. That removes socket-queue and runtime wakeup
latency from the upstream one-way delay a client computes, and it is the
only thing the observation is allowed to change.
The runtime reaches the core through a crate-private entry point taking
the optional observation as an explicit per-datagram argument; the public
ServerCore::handle_datagram is unchanged in signature and in behavior, and
both run one implementation. There is no "next receive timestamp" side
channel and no clock wrapper.
ClockSample stays one paired userspace instant: the preferred wall value
is carried beside it on the classified echo, never inside it. Lifecycle,
rate and maximum-duration policy remain on received_at.mono_ns, midpoints
remain the mean of the paired userspace samples, and no monotonic instant
is estimated from a wall reading. A reading that does not convert, that is
later than the core's own sample, or that lags it by more than one second
falls back to the userspace wall; it is never a reason to drop a datagram.
`Server` answers on one address. A host that must serve several — an
explicit IPv4 and IPv6 pair, or two interface addresses — needs several
of them, and something has to own their construction, their shutdown and
each other's failures.
`ServerSet` is that owner, and deliberately not a protocol object: it
holds no session state, no tokens and no clock, and nothing it does is
visible on the wire. Each listener stays a complete `Server` with its own
socket, core, session table and tokens, so a token issued by one is
unknown at the others and every bound in the cloned `ServerConfig` —
`max_sessions` included — applies per listener rather than per process.
Construction binds every listener or none, preserving requested order and
recording the addresses actually bound. `run` moves each listener into
its own task, fans one shutdown future out over a watch channel, and
joins them all: a listener that fails, or that returns before shutdown
was asked for, shuts its siblings down and fails the set, because a
service configured for two families must not continue silently as one.
A genuine IPv6 listener sharing an explicit port with an IPv4 sibling is
created `IPV6_V6ONLY` before it is bound, since a dual-stack socket would
otherwise claim the IPv4 port too. That is the only pre-bind step, it
uses safe `socket2` APIs, and an IPv4-mapped address is read as the IPv4
listener it is — never as the IPv6 half of a pair. Standalone
`Server::bind` semantics are unchanged.
Endpoint identity documented the IPv6 scope identifier as an established
component alongside family, address and port. The server specification does
list the zone, but its remaining-unknowns section records that multi-zone
behavior could not be tested on a single-host platform, so no evidence settles
how a reference server compares scoped addresses. Presenting the choice as
verified compatibility overstated what is known, which the crate's own guidance
forbids.
Say what it is instead: a deliberate policy taken under a recorded unknown. Two
peers reachable at the same link-local address in different zones are genuinely
different peers, so treating the zone as identity is the conservative reading —
the failure it prevents is one peer closing another's session, and the failure
it risks is a scoped client having to reopen. The excluded flow label is
labelled the same way, and `irtt-server` guidance now carries the rule so the
echo slice reuses this comparison rather than inventing a second one.
Behavior is unchanged; this is documentation and a test comment.
Session identity is the address family, the IP address, the UDP source port
and — for IPv6 — the scope identifier. `SocketAddrV6` equality additionally
covers the flow label, so comparing endpoints with `==` admitted a field that
identifies no endpoint: a client whose flow label changed mid-session could be
refused its own close and strand the session, which nothing reclaims while
expiry is unimplemented. It bought nothing in exchange, since the token,
address, port and scope are what an off-path attacker would have to guess.
Compare the specified components instead. Scope still counts, and the address
is still stored verbatim, so a later reply goes back to exactly the endpoint
the request came from.
Not reachable through a default socket today: a receiver reports the flow
label only when asked to, and there is no runtime slice yet. It is reachable
through the public core, which takes caller-supplied addresses.
Release the session a client close names. A close is bound to a session only
when the token matches and the datagram arrived from that session's exact
endpoint, so holding a token is not by itself authority to tear the session
down: a close from a foreign source port, address, family or IPv6 scope leaves
the session live, and one close cannot take down a sibling session the same
peer opened separately. A token that is unknown, already closed or zero matches
nothing and leaves the table untouched; zero is reserved for no-test replies and
never issued.
Nothing is sent either way. Protocol version 1 defines no acknowledgement for a
client close, so a successful close returns the same `Ok(None)` as a rejected
one and a delivered close is indistinguishable to the client from a lost one.
A standalone `Reply|Close` datagram is tolerated by clients but has no
demonstrated use, so none is encoded.
Authentication still runs before the session is looked up. That keeps a bad MAC
on a live token indistinguishable from an unknown token, and means a close that
fails authentication — missing, wrongly keyed, corrupted, or unexpected on a
server with no key — cannot remove a session. Trailing bytes after the token
stay acceptable: the structural decoder already tolerates them and the server
does not re-parse the request.
The session table remains the only record of how full the server is, so a close
frees capacity immediately and a refused open can succeed once one is released.
Close draws no token and touches no other state; the endpoint lookup borrows
the table only until the comparison is made, so the removal needs no clone.
Echo remains ignored. The test that pinned close as a no-op is replaced by
`tests/close.rs`, which covers removal, silence, repeated close, echo after
close, endpoint binding, unknown/zero/stale tokens, authentication failure,
trailing bytes, sibling sessions and capacity reuse; `unsupported.rs` keeps
echo and structurally invalid traffic.
`Session` and the token lookup that returns it were public, which published
internal session state before there is a consumer for it. The next slices are
all but certain to add and restructure fields here — receive counts and
windows, sequence tracking, activity and expiry state — and nothing outside
the crate needs a session yet.
Make `Session` and `ServerCore::session` crate-private. `session_count`
stays public: how much session state the server holds against its bound is a
sensible stable observable, unlike the sessions themselves.
The endpoint accessors have no production reader until echo and close
processing arrive, since a session's endpoint has to be captured at open time
and is not recoverable later. They are marked accordingly rather than left to
warn.
Add `irtt-server` with the deterministic protocol and session engine the
upcoming Tokio runtime will call. `ServerCore::handle_datagram` takes a source
endpoint and the bytes of one datagram and returns the bytes to send back, or
nothing. It performs no I/O and keeps no clock, so every decision is a pure
function of its state and that input apart from drawing session tokens.
Rejection is silence. The protocol defines no error reply, no reset and no
NACK, so malformed, unauthenticated and policy-refused datagrams are discarded
without a response and without disturbing any live session; a client tells
"rejected" from "lost" only by timing out. `ServerError` is reserved for
internal failures on the server's own side, so an inbound `ProtoError` never
surfaces as a server failure.
Open handling authenticates before decoding parameters, so a packet that fails
authentication is discarded without parsing untrusted data and no reply
behavior reveals which stage rejected it. Duration and Interval are accepted
when absent and rejected when explicitly present and not positive, which is
what the presence-aware parameter decode is for. Negotiation currently rewrites
only the protocol version; the restriction knobs the specification describes
are deliberately left for their own slices, with one obvious place to add them.
Sessions are bounded to a finite default. A single unauthenticated datagram
creates one and opens are never deduplicated, so an unbounded table is a
liability rather than a compatibility feature; upstream's absence of session
bounds is explicitly not a target. A full table drops new opens silently and
evicts nothing. No-test opens keep working at capacity because they create no
state.
Session tokens come from the operating system's random source, since a token is
the only thing standing between an off-path attacker and a usable session on an
unauthenticated server. A drawn value that is zero or collides with a live
session is redrawn within a finite budget; there is no predictable fallback,
and a colliding token never overwrites the session that holds it. The reply is
encoded before the session is inserted, so an internal failure cannot leave a
half-created session behind.
Echo and close requests pass admission and are then deliberately ignored: this
slice creates sessions, and lifecycle and receive semantics belong to the
slices that implement them. Tests pin that so a half-implemented lifecycle
cannot appear by accident.