Commit Graph
100 Commits
Author SHA1 Message Date
Nils Andreas Svee 12b4816e14 fix(client): align the error-queue control buffer
The MSG_ERRQUEUE control buffer was a plain [u8; N], which only
guarantees byte alignment. recvmsg's cmsgs() walks cmsghdr references
out of that storage and requires cmsghdr alignment, so this mirrors
the parent module's #[repr(align(8))] ControlBuffer instead of relying
on incidental stack alignment.
2026-08-15 15:08:30 +07:00
Nils Andreas Svee 97b094539f refactor(client): unify Linux software timestamping
Migrate the Linux client's receive kernel timing from SO_TIMESTAMPNS to
SO_TIMESTAMPING with RX_SOFTWARE + SOFTWARE, reported via
SCM_TIMESTAMPING instead of SCM_TIMESTAMPNS. Linux documents fallback
quirks when SO_TIMESTAMP[NS] and SO_TIMESTAMPING are both enabled on a
socket, so this establishes one unambiguous software-timestamp facility
before client TX timestamp work builds on the same API.
ReceiveMeta.kernel_rx_timestamp semantics, PacketMeta propagation, and
all PR #100 downstream OWD/RTT/IPDV behavior are unchanged. The receive
control buffer is now sized deliberately from cmsg_space::<T>() instead
of an unexplained fixed constant.

Also add safe, test-only Linux primitives that a follow-up change needs
for client TX timestamping: SO_TIMESTAMPING TX flag construction,
nonblocking MSG_ERRQUEUE parsing, and extended-error classification
that distinguishes a genuine send-timestamp completion from a malformed
timestamp record and from a real socket/network error. Production
sockets do not enable SOF_TIMESTAMPING_TX_SOFTWARE and nothing drains
the error queue in production; these primitives only run under
#[cfg(test)].
2026-08-15 14:57:21 +07:00
Nils Andreas Svee 10e0a38a48 Merge pull request #102 from Lochnair/fix/server-kernel-rx-wall
fix(server): use kernel receive time for wall timestamps
2026-08-15 14:10:52 +07:00
Nils Andreas Svee d3f93a9370 fix(server): use kernel receive time for wall timestamps
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.
2026-08-15 13:46:29 +07:00
Nils Andreas Svee e6491a1f70 Merge pull request #101 from Lochnair/feat/server-kernel-rx-capture
feat(server): capture kernel receive timestamps
2026-08-15 13:18:55 +07:00
Nils Andreas Svee 1b0fc2cabe feat(server): capture kernel receive timestamps 2026-08-15 13:13:07 +07:00
Nils Andreas Svee c80edb460b Merge pull request #100 from Lochnair/fix/client-kernel-rx-owd
fix(client): use kernel receive time for downstream OWD
2026-08-15 12:53:41 +07:00
Nils Andreas Svee c28929bb78 fix(client): use kernel receive time for downstream OWD 2026-08-15 12:44:16 +07:00
Nils Andreas Svee 277fbfcb57 Merge pull request #99 from Lochnair/refactor/server-receive-drop-policy
refactor(server): centralize receive drop policy
2026-08-15 11:33:43 +07:00
Nils Andreas Svee 435315a8c0 test(server): assert oversized receives never reach the core 2026-08-15 11:30:53 +07:00
Nils Andreas Svee 7df85767ad refactor(server): centralize receive drop policy 2026-08-15 11:24:30 +07:00
Nils Andreas Svee ce462fd37b Merge pull request #98 from Lochnair/refactor/stats-exact-medians
refactor(stats): retain exact finite timing samples
2026-08-15 11:13:37 +07:00
Nils Andreas Svee 71b5819366 docs(stats): scope the exact-median contract to the cumulative snapshot
Rolling snapshots are recomputed with `SampleMode::RunningOnly` regardless
of the configured sample mode, so the new `SampleMode::Exact` wording — that
every timing metric a `Snapshot` exposes reports a median — was not true of
`rolling_count()` and `rolling_time()`.

Scope the promise to the cumulative snapshot and say plainly on both rolling
accessors that they use running statistics only and report no median, since
a rolling window is a bounded recent view rather than the run's retained
history. A test pins both halves so the wording cannot quietly go stale.

Rolling retention behavior itself is unchanged.
2026-08-15 10:48:45 +07:00
Nils Andreas Svee db9bd515bb refactor(stats): retain exact finite timing samples
`SampleMode::Exact` retained exact samples for eight of the eleven public
timing metrics. Send call, timer error, and server processing always built
their `TimeMetric` with retention disabled, so a finite run reported no
median for them even though they are the same public `TimeStats` type and
appear in the same summary table with a Median column.

Derive one `retain_exact` decision from the sample mode in `CoreStats::new`
and apply it to every timing metric, so exact mode means the same thing for
all of them. Running-only mode is unchanged and still retains none of them.

The retention estimator counted eight exact sample streams per measurable
reply. Rename it to the eleven exact samples an ordinary successful probe
can retain and document the wider optional-measurement upper bound, which
raises the finite estimate on this target from 668 to 764 bytes per probe.
A test counts the metrics a fully measured probe actually retains and
compares it against that constant, so retention and its estimate cannot
drift apart unnoticed.

No new median-capability type was needed: `TimeStats::median_ns` already
distinguishes a retained median from an absent one, and an optional
measurement the server never supplied still produces no samples at all.
2026-08-15 10:41:17 +07:00
Nils Andreas Svee 76dc904da1 Merge pull request #97 from Lochnair/refactor/stats-retention-estimate
refactor(stats): own retention memory estimates
2026-08-15 10:30:44 +07:00
Nils Andreas Svee b93e6d0ab7 refactor(stats): own retention memory estimates 2026-08-15 10:25:04 +07:00
Nils Andreas Svee 44026068cf Merge pull request #96 from Lochnair/test/client-driver-conformance
test(client): expand blocking and async conformance coverage
2026-08-15 10:11:37 +07:00
Nils Andreas Svee 0c3acce36a Merge pull request #95 from Lochnair/refactor/managed-target-locality
refactor(client): centralize managed target accounting
2026-08-15 10:06:18 +07:00
Nils Andreas Svee 006add3648 Merge pull request #94 from Lochnair/refactor/shared-target-preparation
refactor(cli): centralize managed target preparation
2026-08-15 10:05:43 +07:00
Nils Andreas Svee 50e4633c7a refactor(cli): centralize managed target preparation
Both client applets duplicated the step from parsed arguments to a runnable
managed setup: target specs, managed target preparation, a ClientConfig
built from an arbitrary primary target, pacing conversion, and an identical
ManagedClientConfig skeleton with its own copy of the event-capacity
constant. The primary-target step was the worst of it, since the managed
driver replaces server_addr per target, so the address the applets went out
of their way to find was immediately discarded.

shared::client::prepare now performs that step once: it validates the target
set first, then returns the validated targets, one target-free ClientConfig
template, and the pacing, with managed_config()/managed_targets() for the
driver. CommonClientArgs::to_client_config no longer takes a server address.

Applet-specific semantics are unchanged: each command keeps its own duration
default and help text, the client still accepts no target for
--list-columns, and the TUI still rejects an empty target list at parse
time.
2026-08-15 10:04:08 +07:00
Nils Andreas Svee c2666d776e Merge pull request #93 from Lochnair/test/tui-rendering
test(tui): add off-screen render coverage
2026-08-15 09:55:09 +07:00
Nils Andreas Svee ed8d212a17 Merge pull request #92 from Lochnair/refactor/stats-late-reply-policy
refactor(stats): make late-reply sampling explicit
2026-08-15 09:50:01 +07:00
Nils Andreas Svee 61fdff5c98 Merge pull request #91 from Lochnair/refactor/cli-scalar-formatting
refactor(cli): unify scalar presentation formatting
2026-08-15 09:49:50 +07:00
Nils Andreas Svee c1a8d70931 Merge pull request #90 from Lochnair/refactor/exhaustive-client-failures
refactor(client): make managed failure classification exhaustive
2026-08-15 09:49:37 +07:00
Nils Andreas Svee 17ade8e6cb test(tui): scope render assertions to their owning panels
The frame echoes most panel content elsewhere - the session token appears in
recent events, a replying target names a chart series, and the sample
panel's last-warning row satisfies a loose warning search - so whole-frame
assertions kept claiming coverage they did not have.

Panels are now addressed structurally: has_panel matches a title in a top
border, panel_rows slices a panel's interior to its own columns, and
assertions run against the owning panel. That fixes the session token,
recent-events warning line, graph-view panel exclusions and graph-view
target rows, and converts the remaining whole-frame checks in the module so
the class of defect does not come back.

Each tightened assertion was confirmed to fail against a deliberate
regression of the behavior it protects.
2026-08-15 02:12:23 +07:00
Nils Andreas Svee c12617a669 test(tui): tighten render assertions to the panels they claim
Four assertions did not protect what they described. Target labels were
searched across the whole frame, where the first target also appears in the
session header and a replying target appears in recent events and the graph
legend; labels are now matched against the target table's own columns, with
the status column and the replying target's last sample. The sample panel's
effective RTT is now asserted as a whole row, since the fixture's adjusted
and effective values coincide and a bare "2.5ms" still matched with the
effective field dropped. The minimum-height case now rejects dashboard
content like the minimum-width case does, and the compact layout checks for
the timing panel title rather than one incidental run of spaces from the
large timing formatter.

Each tightened assertion was confirmed to fail against a deliberate
regression of the behavior it protects.
2026-08-15 02:01:18 +07:00
Nils Andreas Svee e1bdd7920e test(client): compare full event streams before zipping
The zip loops stopped at the shorter list, and the sequence helpers filter
out everything that is not a probe or reply, so a driver emitting an extra
trailing event of another kind would still have passed. Event-list lengths
are now asserted before the element-wise comparison.
2026-08-15 01:54:54 +07:00
Nils Andreas Svee dd68608d56 refactor(client): correct late-reply docs and drop private counter tests
The counter tests instantiated the private TargetCounters and mirrored its
match arms, duplicating outcome-level coverage that already asserts
replies_received and late through ManagedTargetOutcome, and coupling a
private refactor to persistent test churn.

The new counter documentation also defined late replies in timeout-only
terms. A reply that arrives behind an already-received sequence is
classified late by process_echo_reply even when its own probe has not timed
out, so both counters are now described in terms of order and timeout.
2026-08-15 01:49:25 +07:00
Nils Andreas Svee 2062535c20 test(tui): assert the warning on its sample-panel row
The whole-frame substring assertions passed as long as the warning appeared
anywhere, so a sample panel regressing to "last warning: -" would still have
been reported as covered. The assertion now locates the last-warning row and
requires it to carry the warning itself.
2026-08-15 01:49:23 +07:00
Nils Andreas Svee 665f839333 test(client): drop the coverage-only classification enumeration
The removed test only checked that every failure kind appeared somewhere in
the same hand-maintained case table the mapping test already walks. It could
not prove exhaustiveness or the absence of a fallback, which the compiler
enforces, and it pinned the incidental fact that each category currently has
an input.
2026-08-15 01:49:22 +07:00
Nils Andreas Svee 150ee4fd3b test(client): expand blocking and async conformance coverage
The existing conformance suite compared the two driver styles on open
negotiation with HMAC and DSCP, no-test completion, and peer close. It said
nothing about failed-open rollback, multi-probe scheduling, or the errors
each driver promises for out-of-state operations.

Three tests close those gaps against externally observable behavior only:
a first open that times out against a deliberately mute peer, followed by a
retry that opens, probes, and closes with a fresh sequence; three
caller-paced probes against a real irtt-server, matched sequence for
sequence in both drivers; and equivalent errors for probing before open,
opening twice, and probing or closing after close.

No production code changed, and no divergence between the drivers was
found.
2026-08-15 01:41:09 +07:00
Nils Andreas Svee 5506220f67 refactor(client): centralize managed target accounting
Target-local accounting was spread across the managed driver: the event
counter increments were inline in publish_client_events, the authoritative
packets_sent was copied from the client at fifteen separate call sites, and
ManagedTargetOutcome was constructed twice from the same six fields.

TargetCounters::observe now owns the per-event accounting,
TargetRuntime::sync_packets_sent/record_packets_sent own the authoritative
sent count, and TargetRuntime::outcome owns outcome construction, replacing
the free target_outcome function and the inline copy in finish_target. No
counter is touched outside TargetRuntime any more.

The replies_received semantics are unchanged and now documented on both the
private counter and the public outcome field: on-time unique echo replies
only, with late unique replies counted by late. observe matches ClientEvent
exhaustively, so a new event variant must be classified deliberately.
2026-08-15 01:36:04 +07:00
Nils Andreas Svee 17d3fed7c5 test(tui): add off-screen render coverage
The TUI had pure state and graph-viewport tests but nothing that rendered a
frame, so layout selection and panel presence were unprotected.

Eleven tests drive draw_dashboard through Ratatui's TestBackend across the
durable layouts: below the minimum width and height, the smallest usable
terminal, compact, large, multi-target, and the graph view in both target
modes, including active sample data, warning and error presentation, and
status-line state. Assertions are on visible text and layout facts rather
than full-screen snapshots.

Test-only change; production rendering is untouched.
2026-08-15 01:24:16 +07:00
Nils Andreas Svee e0dec9ac5f refactor(stats): make late-reply sampling explicit
The TUI suppressed late-reply timing by rebuilding the event with sent_at,
rtt, server_timing, one_way and received_stats erased. That leaked stats
policy into event construction and, as a side effect, reclassified a matched
late reply as an untracked late reply.

StatsConfig now carries an explicit LateReplyMode. Measure keeps today's
behavior for every existing caller. CountOnly keeps the reply a late unique
reply and keeps packet, byte and late counters updating, while suppressing
timing, one-way, server-processing and IPDV contributions and reporting
contributed_sample == false. Cumulative and rolling snapshots share the
policy. Genuinely untracked late replies stay untracked in both modes.

The TUI selects CountOnly and passes the original event straight to the
collector.
2026-08-15 01:17:46 +07:00
Nils Andreas Svee 69b90dd64a refactor(cli): unify scalar presentation formatting
Stream output, the run summary, and the TUI each carried their own copy of
the same scalar vocabulary, and they had drifted: the TUI spelled
microseconds "us" while the others used "µs", percentages guarded
non-finite values in one place but not another, and the absent-value
placeholder was spelled inline everywhere.

cmd::format now owns nanosecond-scalar, percentage, and count formatting
with one documented unit-selection and rounding policy, µs everywhere, and
signed values preserved. The frontends keep their own metric sets and
layouts; the TUI keeps its coarse wall-clock span policy, now named
format_span and documented as deliberately distinct, and uses the shared
scalar vocabulary below one second.
2026-08-15 01:11:33 +07:00
Nils Andreas Svee e3003c348f refactor(client): make managed failure classification exhaustive
classify_client_error now matches every ClientError variant explicitly, so a
new variant fails to compile here until its real classification is chosen.
NoTokioRuntime classifies as InvalidState and DatagramLengthMismatch as
Socket; ManagedTargetFailureKind::Other survives only as a deliberate
category with no default path into it.

ManagedTargetFailurePhase and ManagedTargetFailureKind gain controlled
Display impls, and the CLI client/TUI failure diagnostics use them instead
of leaking Debug enum spelling into user-facing text.
2026-08-15 01:03:35 +07:00
Nils Andreas Svee 90c04b6569 refactor(cli): simplify feature flags and make stats mandatory for clients
Remove the optional "stats" feature by merging it into the "client" and "tui" features to reduce conditional compilation complexity. Replace the "client-runtime" internal feature with direct dependency sets.
2026-08-15 00:24:28 +07:00
Nils Andreas Svee 88419d4144 build(cli): enable ancillary feature for client-runtime
Add the ancillary feature dependency to client-runtime to support advanced socket operations and metadata handling.
2026-08-14 22:23:33 +07:00
Nils Andreas Svee 25616969c4 Merge pull request #89 from Lochnair/test/real-server-peers
test: prefer real server for compliant peers
2026-08-14 18:25:24 +07:00
Nils Andreas Svee 446028671b test(cli): use real server for compliant peers 2026-08-14 17:21:22 +07:00
Nils Andreas Svee fca7a2fcc6 test(client): use real server for compliant peers 2026-08-14 17:17:41 +07:00
Nils Andreas Svee cbef62be7a Merge pull request #88 from Lochnair/feat/server-multi-listener
feat(server): add multi-listener server set
2026-08-14 16:46:45 +07:00
Nils Andreas Svee 2171d02203 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.
2026-08-14 16:34:02 +07:00
Nils Andreas Svee 09f0d6e02e feat(server): add multi-listener server set
`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.
2026-08-14 16:04:46 +07:00
Nils Andreas Svee fabd698187 Merge pull request #87 from Lochnair/feat/server-negotiation-policy
feat(server): add negotiation policy controls
2026-08-14 15:06:45 +07:00
Nils Andreas Svee 8607720219 docs(server): describe single allowance as one instant 2026-08-14 15:00:38 +07:00
Nils Andreas Svee 5be224d1b2 feat(cli): expose server negotiation policy 2026-08-14 14:30:28 +07:00
Nils Andreas Svee 8308c077eb feat(server): add negotiation policy controls 2026-08-14 14:24:57 +07:00
Nils Andreas Svee 58c04e8d04 Merge pull request #86 from Lochnair/feat/server-fill-policy
feat(server): implement server fill
2026-08-14 13:54:16 +07:00
Nils Andreas Svee a471fb3bfd docs: distinguish no fill preference from none 2026-08-14 13:07:39 +07:00
Nils Andreas Svee e4239f45c5 feat(server): generate echo payload fill 2026-08-14 12:58:30 +07:00
Nils Andreas Svee 36500e1ea0 feat(server): negotiate server fill 2026-08-14 12:32:37 +07:00
Nils Andreas Svee 24465bc1da Merge pull request #85 from Lochnair/feat/server-wildcard-source-selection
feat(server): pin wildcard reply source address
2026-08-14 12:11:17 +07:00
Nils Andreas Svee aed7eef454 fix(server): mark a mapped listener with the IPv4 option 2026-08-14 11:49:05 +07:00
Nils Andreas Svee 2d88939ed4 fix(server): pin the reply source, not the route 2026-08-14 11:31:10 +07:00
Nils Andreas Svee e154508cb1 fix(server): treat a mapped wildcard bind as wildcard 2026-08-14 00:50:25 +07:00
Nils Andreas Svee 3c554a78d7 test(server): verify wildcard source selection 2026-08-14 00:30:02 +07:00
Nils Andreas Svee 265b857b84 feat(server): pin wildcard reply source 2026-08-14 00:20:17 +07:00
Nils Andreas Svee 047a842f2d Merge pull request #84 from Lochnair/feat/server-reply-traffic-class
feat(server): apply reply traffic class
2026-08-13 23:12:59 +07:00
Nils Andreas Svee 965369ae2a docs(server): drop a private link from public docs 2026-08-13 22:30:54 +07:00
Nils Andreas Svee 1830429ce5 fix(server): keep unmarkable listeners serving 2026-08-13 22:26:58 +07:00
Nils Andreas Svee 5420387846 feat(server): apply reply traffic class 2026-08-13 20:40:07 +07:00
Nils Andreas Svee a4a5219b9e feat(server): carry reply traffic class 2026-08-13 20:34:42 +07:00
Nils Andreas Svee 68e7d18c12 Merge pull request #83 from Lochnair/feat/cli-server-applet
feat(cli): add server applet
2026-08-13 19:49:54 +07:00
Nils Andreas Svee 565d7fb163 fix(cli): ignore the executable suffix in argv0 dispatch 2026-08-13 19:17:20 +07:00
Nils Andreas Svee a672f78e86 feat(cli): add server applet 2026-08-13 19:01:39 +07:00
Nils Andreas Svee ae23e410bb Merge pull request #82 from Lochnair/test/upstream-client-interop
test(server): validate upstream 0.9.1 client interop
2026-08-13 18:19:43 +07:00
Nils Andreas Svee 9d7566955d docs(server): drop the unobserved continuous-mode wire claim
The continuous-request row said a -d 0 request never reaches the wire,
which no observation in this validation supports: the run took no packet
capture and the server was not instrumented for datagram arrival, so
what the client emitted before exiting was never seen.

Record what was actually observed — the argument was refused, the client
exited non-zero, and it printed none of the connection line it prints in
every other scenario — and state plainly that the emission was not
observed. The conclusion that matters is unchanged: continuous mode was
not exercisable through this CLI.

No implementation, test or protocol evidence changes.
2026-08-13 17:39:28 +07:00
Nils Andreas Svee 750e28200a docs(server): scope the strict-mode restriction claim
The environment notes said restriction scenarios were run both with and
without --loose, but the matrix records a strict run for the
packet-length restriction only; the interval and maximum-duration rows
were --loose alone. Say that, so the artifact does not credit
strict-mode coverage it never recorded.

No implementation, test or protocol evidence changes.
2026-08-13 17:35:21 +07:00
Nils Andreas Svee 02bdb495a1 docs(server): qualify interop validation claims
Two claims in the validation record were stronger than the evidence
behind them.

The maximum-duration Close conclusion was stated for every conforming
client. That does not follow: the deadline is evaluated on arrival, not
on transmission, so an echo sent legitimately before the negotiated
Duration elapsed can still be queued long enough to arrive after the
server's two-second grace and carry Close. Scope the result to the
loopback run that was actually performed, and describe the Close as a
termination path for traffic continuing or arriving beyond the grace,
including delayed in-flight traffic.

The document also claimed the validation was reproducible from the
commands above, while carrying only summarized client arguments and no
harness — the harness was disposable on purpose and is not being
promoted into repository infrastructure. Say plainly that this is a
record of the validation performed rather than a reproduction harness.

No implementation, test or protocol evidence changes.
2026-08-13 17:15:37 +07:00
Nils Andreas Svee a50edff624 docs(server): record upstream client interop
Run the released upstream IRTT 0.9.1 client as a black box against the
Tokio server over explicit loopback listeners, and record the result.

Twelve of thirteen matrix rows pass and one is skipped for a client-side
CLI reason: IPv4 and IPv6 ordinary sessions, HMAC with a wrong-key
negative control, packet-length and interval restrictions both strictly
and with --loose, maximum-duration restriction, no-test, and midpoint
timestamps on each clock selection. No incompatibility was found, so
nothing in the implementation changed.

Two results are worth keeping. The single-field midpoint reply this
server deliberately emits instead of upstream 0.9.1's dual-field form is
accepted by the released client, which derives RTT and IPDV from it. And
the maximum-duration close is unreachable by a conforming client: the
deadline is the configured maximum plus two seconds while negotiation
caps the session Duration at that same maximum, so a client honoring the
Duration it was given always stops early. The two-second grace is the
measured margin already recorded in the protocol evidence, so this is
the implementation matching that evidence rather than a defect, and the
close path stays covered by the lifecycle tests.

Upstream source, tests and history were not inspected; only the
executable's version, help, exit status and normal output were observed.
No test in this repository depends on the upstream binary.
2026-08-13 16:47:09 +07:00
Nils Andreas Svee ace3defe22 Merge pull request #81 from Lochnair/feat/server-tokio-runtime
feat(server): add Tokio UDP runtime
2026-08-13 11:35:06 +07:00
Nils Andreas Svee 8d240e352d docs(server): update runtime status 2026-08-13 02:06:41 +07:00
Nils Andreas Svee 41e39bd5b9 fix(server): keep shutdown responsive during sends 2026-08-13 01:50:25 +07:00
Nils Andreas Svee 85c432fcf8 test(server): exercise async client interop 2026-08-13 01:35:34 +07:00
Nils Andreas Svee 4fb5ca6d15 feat(server): add tokio udp runtime 2026-08-13 01:32:05 +07:00
Nils Andreas Svee d45f607b9c Merge pull request #80 from Lochnair/feat/server-lifecycle-policy
feat(server): enforce rate and session lifetime
2026-08-13 01:06:55 +07:00
Nils Andreas Svee c7537b9c42 fix(server): take the idle interval cap before saturating 2026-08-13 00:55:23 +07:00
Nils Andreas Svee c2994f64a4 feat(server): enforce rate and session lifetime 2026-08-13 00:45:40 +07:00
Nils Andreas Svee 683b52d334 feat(server): add lifetime negotiation policy 2026-08-13 00:24:42 +07:00
Nils Andreas Svee 391748f907 Merge pull request #79 from Lochnair/feat/server-echo-core
feat(server): implement echo core
2026-08-12 23:52:44 +07:00
Nils Andreas Svee cce2ff731a fix(server): order an echo reply's timestamp pair 2026-08-12 23:47:34 +07:00
Nils Andreas Svee 8abab7ee38 feat(server): implement echo core 2026-08-12 23:21:02 +07:00
Nils Andreas Svee 5901c52323 Merge pull request #78 from Lochnair/feat/server-packet-length-policy
feat(server): bound negotiated packet lengths
2026-08-12 22:41:03 +07:00
Nils Andreas Svee 0e7d363f96 feat(server): bound negotiated packet lengths 2026-08-12 22:25:14 +07:00
Nils Andreas Svee 964e7d6339 Merge pull request #77 from Lochnair/fix/server-open-executable-params
fix(server): require an executable open before acknowledging it
2026-08-12 20:58:26 +07:00
Nils Andreas Svee 3845142d30 fix(proto): reject packet lengths usize cannot represent 2026-08-12 20:05:01 +07:00
Nils Andreas Svee c833b0663d docs(proto): correct the echo length saturation note 2026-08-12 19:40:17 +07:00
Nils Andreas Svee ca2c3dcbd8 fix(server): reject unusable timestamp sessions 2026-08-12 17:21:23 +07:00
Nils Andreas Svee 68ed5c6e2e fix(proto): floor negative echo lengths 2026-08-12 17:16:03 +07:00
Nils Andreas Svee 2e2db4d3e0 Merge pull request #76 from Lochnair/docs/server-deep-dive-airlock
docs(protocol): airlock server black-box findings
2026-08-12 16:40:57 +07:00
Nils Andreas Svee 58472dbf7b docs(protocol): airlock server black-box findings
Transfer independently black-box-verified server behavior from a source-assisted
research phase conducted outside this repository. Only a sanitizer-candidate
report of black-box findings and the packet captures it referenced were admitted;
no upstream source, tests, history, harnesses, build trees, result files or
source-assisted analysis crossed the boundary. Provenance and the boundary
discipline are recorded in docs/clean-room/CLEANROOM_NOTES.md.

New upstream robustness defect (Section 22.5). An open selecting timestamps while
omitting the Clock parameter is accepted with a valid token and no Clock echoed,
and the first echo on that session is fatal to the server process, taking
unrelated sessions on the same process with it. An explicit Clock=0 is instead
rejected at open, so parameter presence is externally meaningful. Recorded as an
observed defect with a must-not-crash recommendation; the clean server's
resolution is deliberately left to the implementation work.

Also established or strengthened:

- the idle grace and maximum-duration grace are fixed additive margins across the
  settings swept, and the maximum-duration clock starts at the first echo that is
  actually served;
- a rate-limited request is the only tested drop class that keeps a session
  alive, and in one documented configuration a conforming client can observe the
  difference;
- a post-deadline packet of several tested classes consumes an expired session's
  one remaining reply, including packets that are themselves dropped;
- the reply-length cap is the bind interface's MTU on the interfaces where it was
  reachable, and a length the host cannot transmit ends the process;
- DSCP marking follows the session, with unmarked open replies and no
  cross-session leakage across interleaved sessions;
- repeated tags are last-valid-wins, with invalid-duplicate handling scoped to
  the orders actually tested, and unknown tag values are consumed as varints.

Three captures were admitted, each rewritten to the container convention already
used here and verified byte-identical in packet data and timestamps:
server-clock-absent (3 packets), server-expiry-consumption (14) and
server-dscp-interleaved (32). Two further captures were reviewed and rejected as
redundant or unsupporting; their textual results were kept instead.

Host-specific figures are labelled as such throughout, upstream defects are not
promoted to protocol requirements, observations are separated from inferences and
from recommendations, and no implementation policy is chosen here. The client
specification gains matching refinements where the two compatibility baselines
would otherwise state a lifecycle or marking rule differently.

Documentation and evidence only; no production code changed.
2026-08-12 16:24:57 +07:00
Nils Andreas Svee 035fb5a385 Merge pull request #75 from Lochnair/feat/server-client-close
feat(server): handle client close
2026-08-12 10:55:07 +07:00
Nils Andreas Svee 6c34fd3d20 docs(server): record IPv6 zone identity as project policy
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.
2026-08-12 10:48:42 +07:00
Nils Andreas Svee e79ea94318 fix(server): exclude IPv6 flow information from endpoint identity
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.
2026-08-12 10:42:11 +07:00
Nils Andreas Svee d269f77c87 feat(server): handle client close
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.
2026-08-12 10:30:50 +07:00
Nils Andreas Svee be66a6a61d Merge pull request #74 from Lochnair/feat/server-core-open
feat(server): add open session core
2026-08-12 01:12:41 +07:00
Nils Andreas Svee b7e206f939 refactor(server): keep session state crate-private
`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.
2026-08-12 01:10:56 +07:00
Nils Andreas Svee 59f6e88005 feat(server): add open session core
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.
2026-08-12 00:47:46 +07:00
Nils Andreas Svee 93491e7762 refactor(proto): preserve open parameter presence
`Params::decode` seeded `Params::default()` and overwrote only the tags it
saw, so an omitted tag and one explicitly encoded as zero were
indistinguishable afterwards. The protocol gives those the same value but not
the same meaning, and a server cannot apply its open admission rules without
telling them apart: an absent Duration or Interval is accepted as the wire
default zero, while one explicitly encoded as zero is invalid.

Add `Params::decode_with_presence`, returning the decoded values alongside a
`ParamPresence` recording which known tags appeared. `Params::decode` now
delegates to it, so there is still exactly one parser and the two cannot
diverge. Unknown tags stay ignored and untracked, and repeated known tags keep
last-value-wins.

Add `Clock::Unspecified = 0` as the default, which is the wire default for an
absent Clock tag and what an empty parameter payload decodes to. An explicit
Clock tag encoding zero remains an invalid enum value, so `Unspecified` is
only ever reached by omission; `encode` omits the tag for it rather than
emitting an explicit zero. It selects no clock, so `has_wall`/`has_mono` are
false and no timestamp field is laid out.

`ClientConfig::clock` is public and therefore now constructible as
`Unspecified`, which is a wire default rather than a client mode. Reject it
during config validation. The client default stays `Clock::Both`, so normal
client-generated params and wire vectors are unchanged.
2026-08-12 00:23:14 +07:00