Adds the deterministic server lifetime and rate policy layer on top of the echo core.
Server lifetime/rate policy.ServerConfig gains min_send_interval (default 10 ms), burst_allowance (default 5), idle_timeout (default 60 s) and an optional max_test_duration (none by default), each with a consuming builder and a reader. Session gains two new state groups alongside its receive state: RateState (burst, refill interval, allowance, last refill instant) and LifetimeState (last activity, first served echo).
Negotiation for maximum duration and send interval. A requested Duration above a configured maximum is reduced to it, and a continuous request (absent Duration) is answered with the maximum too. Interval is raised to the configured minimum and then capped at idle_timeout / 4, in that order.
Idle expiry, including never-used sessions. The idle deadline runs from the open. Expired sessions are released in a global sweep run after authentication and before the request is dispatched — so before capacity is judged for a new open.
Burst rate limiting. Per session: full burst at creation, one unit per served echo, one unit accrued per refill interval, capped at the burst. An echo with no allowance is dropped silently and advances neither reception statistic.
Maximum-duration final Close. When a maximum is configured, the first echo that would otherwise be served at or past first served echo + maximum + 2 s is answered with a complete echo reply carrying FLAG_CLOSE, and the session is released once that reply has encoded.
All transitions stay pure until commit: rate, receive and lifetime state are computed, the reply is built and encoded, and only then is anything committed — or, for a close, the session removed. An encoding failure leaves the session exactly as it was.
Time comes from the existing private ClockSource seam, monotonic only for every policy decision. One sample is taken per authenticated, structurally valid request (an echo reuses the receive sample it already has), plus the existing send sample per answered echo. Duration → wire nanoseconds goes through the one existing saturating helper in clock, now pub(crate).
Clean policy choices
Each of these is a deliberate irtt-rs project decision taken where the clean evidence records upstream behavior as policy rather than an interoperability requirement. They are documented in crates/irtt-server/AGENTS.md and labelled as policy at each site; no protocol evidence document was changed to match them.
Idle expiry is immediate at the configured deadline, and the comparison is inclusive.
irtt-rs does not reproduce upstream's +5 s idle grace or its lazy final reply to the first echo that reaches an expired session. An expired session is simply gone and the client observes silence, which is the only thing the specification requires of expiry. A zero idle timeout expires at the next evaluation; it is not "never expire".
Never-used sessions expire from OPEN. Upstream starts the idle clock at the first echo request, so an unused session never expires; a session is created by one unauthenticated datagram and opens are never deduplicated, which makes that a resource leak the specification recommends against reproducing.
Rate-limited echoes refresh the idle deadline but do not advance statistics. This is the observed behavior — the only tested drop class that refreshes — and is kept deliberately. No other drop class refreshes.
The refill cadence never contradicts a shorter negotiated interval created by the idle cap. The refill interval is min(configured minimum, negotiated interval). Upstream replenishes on the configured minimum regardless, so its own idle cap can hand a client a 2 s interval while the limiter enforces 5 s and rate-limits a fully conforming client. Ordinary configurations are unaffected: a 10 ms minimum against a 1 s negotiated interval still refills every 10 ms.
The maximum-duration close uses the observed +2 s compatibility grace and starts at the first served echo. Neither the open, nor a rejected first echo, nor a rate-limited one starts the clock. The grace is a fixed internal constant, not a configuration knob. Rate allowance is judged before the close trigger, so a deadline-crossing echo with no allowance is dropped and the close is carried by the next echo that is served. Other rejected classes likewise neither trigger nor defer it — the specification records that case as untested, so this is the deterministic reading rather than an observed one.
A zero configured maximum test duration is stored as no maximum. Duration zero on the wire means continuous, so a finite maximum of zero could only be expressed by turning a client's finite test into an endless one.
Expiry is packet-driven until the runtime exists, and the sweep is global — so a no-test open reclaims expired sessions like any other request, where upstream was observed not to. Logical expiry remains timestamp-based; nothing observable turns on the difference.
Scope exclusions
No Tokio runtime, socket, or listener code. The core interface is still handle_datagram(peer, packet) -> Result<Option<Vec<u8>>, ServerError>, with no public timer or clock parameter.
No DSCP socket application.
No full ServerFill policy — echo payloads remain zero-filled.
No changes to irtt-proto or irtt-client. The existing EchoReply encoder already permits Close on an echo reply and handles HMAC.
No changes to the protocol evidence documents (IRTT_SERVER_PROTOCOL_SPEC.md, BLACKBOX_VERIFICATION_REPORT.md, SERVER_BEHAVIORAL_VECTORS.md, captures/).
Tests
New tests/negotiation.rs, tests/rate.rs and tests/lifecycle.rs, all deterministic and sleep-free. A new hand-moved ManualClock test fake serves the rate and lifetime tests, which care when a datagram arrived rather than how many times the core read the clock; the existing ScriptedClock still serves the timestamp tests. Assertions are on wire behavior — which datagrams came back, the count and window they reported, reply flags, and session_count — with no accessors added for allowance, activity instants or the maximum-duration origin.
Negotiation: duration clamp (longer, shorter, continuous), no-maximum and zero-maximum cases, interval floor (faster, slower, absent), the idle_timeout / 4 cap, the floor-then-cap conflict, and a zero idle timeout capping nothing.
Rate: the measured 12-request blast against a 100 ms minimum and a burst of 5 answering exactly 5 with counts 1–5 then 6, 7, 8; the measured burst-of-1 blast of 4; the min/cap hazard proving a client sending at its 2 s negotiated interval is not limited by a 5 s configured minimum; the two distinct zero cases; and independent sibling allowances.
Lifetime: a never-used session expiring and freeing capacity in a one-session table; a served echo moving the deadline; the inclusive deadline with no final reply; a rate-limited echo keeping a session alive past the deadline the served one set, without counting; oversized and foreign-endpoint echoes not extending the deadline; a no-test open reclaiming an unrelated expired session while creating none of its own; a client close still releasing without a reply; the maximum-duration origin at the first served echo; the exact-deadline Close reply with its sequence, statistics, layout and released token; a rate-limited deadline crossing deferring rather than losing the close; a foreign-endpoint echo past the deadline neither closing nor releasing; and an authenticated final close carrying FLAG_HMAC with a verifying MAC.
Existing tests whose subject was parameter pass-through, receive statistics or timestamps now build their core from a helper that disables the interval floor, so they do not silently become tests of the new timing policy; the stale comment claiming duration and interval are unrestricted was corrected.
## Summary
Adds the deterministic server lifetime and rate policy layer on top of the echo core.
- **Server lifetime/rate policy.** `ServerConfig` gains `min_send_interval` (default 10 ms), `burst_allowance` (default 5), `idle_timeout` (default 60 s) and an optional `max_test_duration` (none by default), each with a consuming builder and a reader. `Session` gains two new state groups alongside its receive state: `RateState` (burst, refill interval, allowance, last refill instant) and `LifetimeState` (last activity, first served echo).
- **Negotiation for maximum duration and send interval.** A requested Duration above a configured maximum is reduced to it, and a *continuous* request (absent Duration) is answered with the maximum too. Interval is raised to the configured minimum and then capped at `idle_timeout / 4`, in that order.
- **Idle expiry, including never-used sessions.** The idle deadline runs from the open. Expired sessions are released in a global sweep run after authentication and before the request is dispatched — so before capacity is judged for a new open.
- **Burst rate limiting.** Per session: full burst at creation, one unit per served echo, one unit accrued per refill interval, capped at the burst. An echo with no allowance is dropped silently and advances neither reception statistic.
- **Maximum-duration final Close.** When a maximum is configured, the first echo that would otherwise be served at or past `first served echo + maximum + 2 s` is answered with a complete echo reply carrying `FLAG_CLOSE`, and the session is released once that reply has encoded.
All transitions stay pure until commit: rate, receive and lifetime state are computed, the reply is built and encoded, and only then is anything committed — or, for a close, the session removed. An encoding failure leaves the session exactly as it was.
Time comes from the existing private `ClockSource` seam, monotonic only for every policy decision. One sample is taken per authenticated, structurally valid request (an echo reuses the receive sample it already has), plus the existing send sample per answered echo. `Duration` → wire nanoseconds goes through the one existing saturating helper in `clock`, now `pub(crate)`.
## Clean policy choices
Each of these is a deliberate `irtt-rs` project decision taken where the clean evidence records upstream behavior as policy rather than an interoperability requirement. They are documented in `crates/irtt-server/AGENTS.md` and labelled as policy at each site; no protocol evidence document was changed to match them.
- **Idle expiry is immediate at the configured deadline**, and the comparison is inclusive.
- **`irtt-rs` does not reproduce upstream's +5 s idle grace or its lazy final reply** to the first echo that reaches an expired session. An expired session is simply gone and the client observes silence, which is the only thing the specification requires of expiry. A zero idle timeout expires at the next evaluation; it is not "never expire".
- **Never-used sessions expire from OPEN.** Upstream starts the idle clock at the first echo request, so an unused session never expires; a session is created by one unauthenticated datagram and opens are never deduplicated, which makes that a resource leak the specification recommends against reproducing.
- **Rate-limited echoes refresh the idle deadline but do not advance statistics.** This is the observed behavior — the only tested drop class that refreshes — and is kept deliberately. No other drop class refreshes.
- **The refill cadence never contradicts a shorter negotiated interval created by the idle cap.** The refill interval is `min(configured minimum, negotiated interval)`. Upstream replenishes on the configured minimum regardless, so its own idle cap can hand a client a 2 s interval while the limiter enforces 5 s and rate-limits a fully conforming client. Ordinary configurations are unaffected: a 10 ms minimum against a 1 s negotiated interval still refills every 10 ms.
- **The maximum-duration close uses the observed +2 s compatibility grace and starts at the first served echo.** Neither the open, nor a rejected first echo, nor a rate-limited one starts the clock. The grace is a fixed internal constant, not a configuration knob. Rate allowance is judged *before* the close trigger, so a deadline-crossing echo with no allowance is dropped and the close is carried by the next echo that is served. Other rejected classes likewise neither trigger nor defer it — the specification records that case as untested, so this is the deterministic reading rather than an observed one.
- **A zero configured maximum test duration is stored as no maximum.** Duration zero on the wire means continuous, so a finite maximum of zero could only be expressed by turning a client's finite test into an endless one.
- **Expiry is packet-driven until the runtime exists**, and the sweep is global — so a no-test open reclaims expired sessions like any other request, where upstream was observed not to. Logical expiry remains timestamp-based; nothing observable turns on the difference.
## Scope exclusions
- No Tokio runtime, socket, or listener code. The core interface is still `handle_datagram(peer, packet) -> Result<Option<Vec<u8>>, ServerError>`, with no public timer or clock parameter.
- No DSCP socket application.
- No full `ServerFill` policy — echo payloads remain zero-filled.
- No changes to `irtt-proto` or `irtt-client`. The existing `EchoReply` encoder already permits Close on an echo reply and handles HMAC.
- No changes to the protocol evidence documents (`IRTT_SERVER_PROTOCOL_SPEC.md`, `BLACKBOX_VERIFICATION_REPORT.md`, `SERVER_BEHAVIORAL_VECTORS.md`, `captures/`).
## Tests
New `tests/negotiation.rs`, `tests/rate.rs` and `tests/lifecycle.rs`, all deterministic and sleep-free. A new hand-moved `ManualClock` test fake serves the rate and lifetime tests, which care when a datagram arrived rather than how many times the core read the clock; the existing `ScriptedClock` still serves the timestamp tests. Assertions are on wire behavior — which datagrams came back, the count and window they reported, reply flags, and `session_count` — with no accessors added for allowance, activity instants or the maximum-duration origin.
- Negotiation: duration clamp (longer, shorter, continuous), no-maximum and zero-maximum cases, interval floor (faster, slower, absent), the `idle_timeout / 4` cap, the floor-then-cap conflict, and a zero idle timeout capping nothing.
- Rate: the measured 12-request blast against a 100 ms minimum and a burst of 5 answering exactly 5 with counts 1–5 then 6, 7, 8; the measured burst-of-1 blast of 4; the min/cap hazard proving a client sending at its 2 s negotiated interval is not limited by a 5 s configured minimum; the two distinct zero cases; and independent sibling allowances.
- Lifetime: a never-used session expiring and freeing capacity in a one-session table; a served echo moving the deadline; the inclusive deadline with no final reply; a rate-limited echo keeping a session alive past the deadline the served one set, without counting; oversized and foreign-endpoint echoes not extending the deadline; a no-test open reclaiming an unrelated expired session while creating none of its own; a client close still releasing without a reply; the maximum-duration origin at the first served echo; the exact-deadline Close reply with its sequence, statistics, layout and released token; a rate-limited deadline crossing deferring rather than losing the close; a foreign-endpoint echo past the deadline neither closing nor releasing; and an authenticated final close carrying `FLAG_HMAC` with a verifying MAC.
Existing tests whose subject was parameter pass-through, receive statistics or timestamps now build their core from a helper that disables the interval floor, so they do not silently become tests of the new timing policy; the stale comment claiming duration and interval are unrestricted was corrected.
## Verification
All run on the final branch state and passing:
- `cargo fmt --all -- --check`
- `cargo check --workspace --all-targets --all-features --locked`
- `cargo test --workspace --all-targets --all-features --locked` — exit 0; `irtt-server` 87 passed, 0 failed
- `cargo clippy --workspace --all-targets --all-features --locked -- -D warnings`
- `RUSTDOCFLAGS="-D warnings" cargo doc -p irtt-proto -p irtt-server --no-deps`
- `git diff --check main...HEAD`
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
### 💡 Codex Review
Here are some automated review suggestions for this pull request.
**Reviewed commit:** `c2994f64a4`
<details> <summary>ℹ️ About Codex in GitHub</summary>
<br/>
[Your team has set up Codex to review pull requests in this repo](https://chatgpt.com/codex/cloud/settings/general). Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
</details>
When idle_timeout exceeds i64::MAX nanoseconds, saturating it before division makes the cap i64::MAX / 4 even though the true quarter-timeout may be much larger and still representable on the wire. For example, Duration::MAX incorrectly reduces any requested interval above roughly 73 years rather than leaving every representable interval untouched. Compute the quarter in Duration/u128 space and saturate the result afterward so negotiation applies the configured (idle timeout ÷ 4) cap.
**<sub><sub></sub></sub> Divide the idle timeout before saturating it**
When `idle_timeout` exceeds `i64::MAX` nanoseconds, saturating it before division makes the cap `i64::MAX / 4` even though the true quarter-timeout may be much larger and still representable on the wire. For example, `Duration::MAX` incorrectly reduces any requested interval above roughly 73 years rather than leaving every representable interval untouched. Compute the quarter in `Duration`/`u128` space and saturate the result afterward so negotiation applies the configured `(idle timeout ÷ 4)` cap.
AGENTS.md reference: [crates/irtt-server/AGENTS.md:L156-L158](https://github.com/Lochnair/irtt-rs/blob/c2994f64a4535b74a71c1f16595f63da4e37de5e/crates/irtt-server/AGENTS.md#L156-L158)
Useful? React with 👍 / 👎.
Fixed in c7537b9 — the quarter is now taken in Duration space (config.idle_timeout() / 4) before the saturating nanosecond conversion, so a timeout beyond i64 nanoseconds caps nothing representable instead of clamping at i64::MAX / 4; identical for every in-range timeout, and pinned by a Duration::MAX case in a_negotiated_interval_is_capped_at_a_quarter_of_the_idle_timeout.
Fixed in c7537b9 — the quarter is now taken in `Duration` space (`config.idle_timeout() / 4`) before the saturating nanosecond conversion, so a timeout beyond `i64` nanoseconds caps nothing representable instead of clamping at `i64::MAX / 4`; identical for every in-range timeout, and pinned by a `Duration::MAX` case in `a_negotiated_interval_is_capped_at_a_quarter_of_the_idle_timeout`.
_🤖 Addressed by [Claude Code](https://claude.com/claude-code)_
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 the deterministic server lifetime and rate policy layer on top of the echo core.
ServerConfiggainsmin_send_interval(default 10 ms),burst_allowance(default 5),idle_timeout(default 60 s) and an optionalmax_test_duration(none by default), each with a consuming builder and a reader.Sessiongains two new state groups alongside its receive state:RateState(burst, refill interval, allowance, last refill instant) andLifetimeState(last activity, first served echo).idle_timeout / 4, in that order.first served echo + maximum + 2 sis answered with a complete echo reply carryingFLAG_CLOSE, and the session is released once that reply has encoded.All transitions stay pure until commit: rate, receive and lifetime state are computed, the reply is built and encoded, and only then is anything committed — or, for a close, the session removed. An encoding failure leaves the session exactly as it was.
Time comes from the existing private
ClockSourceseam, monotonic only for every policy decision. One sample is taken per authenticated, structurally valid request (an echo reuses the receive sample it already has), plus the existing send sample per answered echo.Duration→ wire nanoseconds goes through the one existing saturating helper inclock, nowpub(crate).Clean policy choices
Each of these is a deliberate
irtt-rsproject decision taken where the clean evidence records upstream behavior as policy rather than an interoperability requirement. They are documented incrates/irtt-server/AGENTS.mdand labelled as policy at each site; no protocol evidence document was changed to match them.irtt-rsdoes not reproduce upstream's +5 s idle grace or its lazy final reply to the first echo that reaches an expired session. An expired session is simply gone and the client observes silence, which is the only thing the specification requires of expiry. A zero idle timeout expires at the next evaluation; it is not "never expire".min(configured minimum, negotiated interval). Upstream replenishes on the configured minimum regardless, so its own idle cap can hand a client a 2 s interval while the limiter enforces 5 s and rate-limits a fully conforming client. Ordinary configurations are unaffected: a 10 ms minimum against a 1 s negotiated interval still refills every 10 ms.Scope exclusions
handle_datagram(peer, packet) -> Result<Option<Vec<u8>>, ServerError>, with no public timer or clock parameter.ServerFillpolicy — echo payloads remain zero-filled.irtt-protoorirtt-client. The existingEchoReplyencoder already permits Close on an echo reply and handles HMAC.IRTT_SERVER_PROTOCOL_SPEC.md,BLACKBOX_VERIFICATION_REPORT.md,SERVER_BEHAVIORAL_VECTORS.md,captures/).Tests
New
tests/negotiation.rs,tests/rate.rsandtests/lifecycle.rs, all deterministic and sleep-free. A new hand-movedManualClocktest fake serves the rate and lifetime tests, which care when a datagram arrived rather than how many times the core read the clock; the existingScriptedClockstill serves the timestamp tests. Assertions are on wire behavior — which datagrams came back, the count and window they reported, reply flags, andsession_count— with no accessors added for allowance, activity instants or the maximum-duration origin.idle_timeout / 4cap, the floor-then-cap conflict, and a zero idle timeout capping nothing.FLAG_HMACwith a verifying MAC.Existing tests whose subject was parameter pass-through, receive statistics or timestamps now build their core from a helper that disables the interval floor, so they do not silently become tests of the new timing policy; the stale comment claiming duration and interval are unrestricted was corrected.
Verification
All run on the final branch state and passing:
cargo fmt --all -- --checkcargo check --workspace --all-targets --all-features --lockedcargo test --workspace --all-targets --all-features --locked— exit 0;irtt-server87 passed, 0 failedcargo clippy --workspace --all-targets --all-features --locked -- -D warningsRUSTDOCFLAGS="-D warnings" cargo doc -p irtt-proto -p irtt-server --no-depsgit diff --check main...HEAD💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit:
c2994f64a4ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
@@ -46,5 +64,32 @@ pub(crate) fn negotiate_params(mut requested: Params, config: &ServerConfig) ->requested.length = max_length;When
idle_timeoutexceedsi64::MAXnanoseconds, saturating it before division makes the capi64::MAX / 4even though the true quarter-timeout may be much larger and still representable on the wire. For example,Duration::MAXincorrectly reduces any requested interval above roughly 73 years rather than leaving every representable interval untouched. Compute the quarter inDuration/u128space and saturate the result afterward so negotiation applies the configured(idle timeout ÷ 4)cap.AGENTS.md reference: crates/irtt-server/AGENTS.md:L156-L158
Useful? React with 👍 / 👎.
@@ -46,5 +64,32 @@ pub(crate) fn negotiate_params(mut requested: Params, config: &ServerConfig) ->requested.length = max_length;Fixed in
c7537b9— the quarter is now taken inDurationspace (config.idle_timeout() / 4) before the saturating nanosecond conversion, so a timeout beyondi64nanoseconds caps nothing representable instead of clamping ati64::MAX / 4; identical for every in-range timeout, and pinned by aDuration::MAXcase ina_negotiated_interval_is_capped_at_a_quarter_of_the_idle_timeout.🤖 Addressed by Claude Code