diff --git a/.github/workflows/build-debian.yml b/.github/workflows/build-debian.yml index 377e3b6..28ce472 100644 --- a/.github/workflows/build-debian.yml +++ b/.github/workflows/build-debian.yml @@ -4,6 +4,7 @@ on: push: branches: - main + - feat/improvements release: types: [released] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 03956b5..6c433dc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,6 +61,35 @@ jobs: - name: Run security audit run: cargo audit + - name: Install cargo-deny + run: cargo install cargo-deny + + - name: Run supply-chain checks (advisories + sources) + run: cargo deny check advisories sources + + # Miri lane over the only unsafe FFI in the tree: the recvmmsg batch-receive + # path in src/connection/batch_recv.rs. Miri interprets the pure pointer logic + # (self-referential iovec/mmsghdr setup, msg_len -> MTU clamp, sockaddr_storage + # decode) and fails the PR on any UB regression there. Hard limit: miri cannot + # execute the real recvmmsg syscall, so the live-syscall path is not covered. + # The `batch_recv` filter scopes the run to that module's pure tests; the + # mimalloc test allocator is cfg'd out under miri (C FFI miri cannot run). + miri: + name: Miri (batch_recv unsafe pointer logic) + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + toolchain: nightly + components: miri + + - name: Run batch_recv tests under miri + run: cargo miri test --lib batch_recv + test-stable: name: Test (Rust stable) runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index d64c123..152fb72 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,9 @@ target/ # but don't want them in the repo *.c *.h +# ...except the harness's own C source, which is a real tracked file that +# the network-sim crate embeds via include_str! and compiles on demand. +!crates/network-sim/src/adaptive_srt_send.c bond-bunny-main/ moblin/ /.claude diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c0cf206 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,60 @@ +# Changelog + +Changes on the `exp` branch since the `v3.0.0` release (https://github.com/irlserver/srtla_send/releases). + +This is an overview of the **current** state of `exp`, not a replay of every commit. Several ideas were prototyped and then removed before they reached this branch tip; those are listed under "Explored and removed" so the commit history makes sense. The version in `Cargo.toml` is still `3.0.0`, so this work is unreleased. + +## Scheduling and link selection + +The runtime still has exactly two scheduling modes: `classic` (capacity based, matches the original C behaviour) and `enhanced` (quality aware, the default). Enhanced selection gained an admission gate on top of its quality scoring: + +* **Weak-link admission gate.** A classifier (`sender/selection/classifier.rs`) marks a link weak when its RTT busts the chosen delay tier or its throughput share falls below an entering threshold, with enter/leave hysteresis. Enhanced selection excludes weak links from ranking when at least one healthy link can carry the packet, and falls back to the full pool when every link is weak (better to send on a weak link than drop the packet). +* **Congestion-aware skip.** Each connection carries a `cc_backing_off` flag set by the per-link CC controller. Enhanced selection treats a backing-off link as an extra weak signal and skips it under the same fallback rule. +* **Critical-packet priority.** During a keyframe (IDR / SPS / PPS) the scheduler routes packets to the highest-quality link. Two triggers feed this: a heuristic burst detector (`sender/keyframe.rs`, which declares a burst after 5 consecutive max-MTU 1316-byte packets), and an explicit critical window opened by an upstream encoder (see the priority sidecar below). + +## RTT estimation + +* **Kalman filter as the primary RTT estimator.** `RttTracker` now uses a 2-state Kalman filter (value, velocity) as its smooth RTT source, replacing the old smooth/fast EWMA pair. The filter tracks trends naturally, so its velocity term doubles as spike detection. A new `kalman.rs` module holds the filter and its RTT preset. +* **Dual-window minimum tracking and a sample filter.** RTT keeps a fast and a slow minimum window plus a small min-sample filter for stability, exposed through `LinkStats`. +* EWMA is retained only for the symmetric `rtt_avg_delta`. The asymmetric (fast-down, slow-up) EWMA variant exists in the tree but is `#[cfg(test)]` only, so it does not compile into the production binary. + +## Per-link congestion control + +* **`link_cc` controller** (`sender/selection/link_cc.rs`). A per-connection state machine (`CcState`: Bootstrap, Climbing, Holding, BackingOff, Drain) with a `ClimbMode` sub-state (Hai, FastRecovery, Normal) that produces a `target_bps` soft cap from age-bucketed RTT EWMA, RTT variance, a sliding-window loss permille, and observed bitrate. +* **Current wiring.** Only the `BackingOff` state influences selection today (via the `cc_backing_off` flag described above). The `target_bps` soft cap is computed and exported for telemetry but is not yet applied as a rate cap in the data path. + +## Transport and egress + +* **Pluggable uplink binder.** A new `UplinkBinder` trait makes egress steering injectable. `SourceIpBinder` keeps the existing source-IP bind (Linux source routing); a callback binder lets a library consumer steer the raw fd (for example Android `Network.bindSocket`) while keeping `IpAddr` as the uplink identity. The binder is threaded through sender startup, connection creation, and reconnect so the same binding is re-applied on reconnect. +* **Adaptive batch-send regimes.** `batch_send.rs` picks a batch threshold per connection from observed bitrate: LowActivity (under 500 kbps, threshold 4), Normal (500 kbps to 5 Mbps, threshold 16), HighLoad (above 5 Mbps, threshold 32). The 15ms flush interval is unchanged. The regime is recomputed once per housekeeping tick. + +## Configuration, control, and observability + +* **TOML config file.** A `--config` flag loads tunable constants from TOML (`toml_config.rs`), falling back to defaults on error, reloaded on SIGHUP. +* **Dynamic runtime config.** `DynamicConfig` replaces the old `DynamicToggles`, holding the scheduling mode and toggles behind atomics for thread-safe runtime changes over stdin and a control socket. +* **JSON-RPC control socket.** A Unix-socket control plane (`control.rs`, `control_socket.rs`, `--control-socket`) supports `set_mode` (classic or enhanced), `set_quality`, `get_status`, `get_stats`, and `subscribe` / `unsubscribe` to the `stats` and `priority.window` topics. Documented in `docs/CONTROL_PROTOCOL.md`. +* **Critical-packet priority sidecar.** A dedicated UDP socket (`priority.rs`, `--priority-bind`) takes a 5-byte datagram from an encoder to open a critical window of N milliseconds. Loopback UDP shares the data path's network stack, so the hint stays tightly ordered against the packets it describes (tighter than the out-of-band JSON-RPC channel). Overlapping windows extend the deadline monotonically. Documented in `docs/KEYFRAME_PRIORITY.md`. +* **Prometheus metrics endpoint.** A hand-rolled `/metrics` HTTP server (`metrics.rs`, `--metrics-bind`, no axum/hyper dependency) exports per-link and aggregate gauges plus the current mode. A shared stats layer (`stats.rs`, `subscriptions.rs`) backs both the metrics endpoint and the control socket subscriptions. + +## Testing and tooling + +* **`network-sim` crate.** A new workspace crate under `crates/network-sim` providing an integration-test harness, impairment models, scenario definitions, and topology helpers. +* **Network-namespace integration tests.** `tests/netns_basic.rs` (registration and forwarding), `tests/netns_failure.rs` (link failure and recovery), `tests/netns_impairment.rs` (adaptation to impairments), and `tests/netns_scenario.rs` (stability under evolving conditions). +* **CodeRabbit** review config added (`.coderabbit.yaml`). +* Test-only items moved from `#[allow(dead_code)]` to `#[cfg(test)]` gating, with new unit coverage for the CC state machine, batch regimes, weak-link gating, and the TOML config. + +## Housekeeping + +* Removed the bundled `receiver` symlink and the `.serena` memory files. +* Selection-strategy modules (`classic`, `enhanced`) made private. +* Connection-rotation tuning: `MIN_SWITCH_INTERVAL_MS` to 15, `STARTUP_GRACE_MS` to 5000. +* Dependency refreshes in `Cargo.lock`, plus `cargo fmt` and clippy cleanups across all targets. + +## Explored and removed (not in the current branch) + +These appear in the commit history between `v3.0.0` and `exp` but are **not present in the current tree**. They were prototyped, then dropped or superseded: + +* **EDPF scheduler (Earliest Delivery Path First), BLEST head-of-line guard, and IoDS reordering prevention.** The whole arrival-time-prediction scheduling pipeline was removed. No `edpf` / `blest` / `iods` modules exist; `congestion/` holds only `classic`, `enhanced`, and `mod`. +* **Shared bottleneck detection (RFC 8382).** Removed along with the EDPF pipeline it fed. +* **Connection exploration (`--exploration`, `set_exploration`).** Removed entirely: the flag, the JSON-RPC method, `sender/selection/exploration.rs`, and the `enable_explore` plumbing. The original version probed the *second-best* link (usually a healthy link already earning its own ACKs), fired every 30s whether or not anything was wrong, and had no budget. It diverted roughly half of all traffic for as long as its trigger held, surviving only because the switch cooldown happened to rate-limit it. It was rewritten as a bounded probe of *starved* links (one packet per link per 200ms) and then measured on the netem testbed against the scenario it exists for: a link gated to 0.00 Mbps, silently healed, then needed when the healthy link collapsed. It moved delivery 0.76 pts (Welch t=0.75, n=15), which is to say not at all. `GATED_LINK_PENALTY` already keeps a gated link *rankable* rather than excluded, so it retains a 0.2 to 0.5 Mbps trickle and re-adopts itself about 7s after healing, and the classifier's probation re-test covers the share-starvation latch on top of that. A mechanism that cannot be shown to help is debt, so it is gone rather than kept off by default. +* **RTT-threshold scheduling mode and the `edpf` mode.** `SchedulingMode` now has only `Classic` and `Enhanced`; the parser explicitly rejects `rtt-threshold` and `edpf`, and there is no `--rtt-delta-ms` flag in the CLI. `README.md` was updated to drop these modes (along with the stale `set_rtt_delta` and `mark_critical` control-socket examples), and `docs/RTT_THRESHOLD_SCHEDULING.md` was removed. diff --git a/Cargo.lock b/Cargo.lock index 9c5ef87..18a7479 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -73,6 +73,27 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b34d609dfbaf33d6889b2b7106d3ca345eacad44200913df5ba02bfd31d2ba9" +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bitflags" version = "2.10.0" @@ -195,6 +216,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.1.5" @@ -365,6 +392,7 @@ version = "0.1.0" dependencies = [ "anyhow", "rand 0.10.0", + "serde_json", "tempfile", "tracing", ] @@ -378,6 +406,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -424,6 +461,31 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bit-set", + "bit-vec", + "bitflags", + "num-traits", + "rand 0.9.2", + "rand_chacha", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + [[package]] name = "quote" version = "1.0.44" @@ -485,6 +547,15 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core 0.9.5", +] + [[package]] name = "regex-automata" version = "0.4.14" @@ -521,6 +592,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + [[package]] name = "semver" version = "1.0.27" @@ -559,9 +642,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", @@ -570,6 +653,15 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -628,6 +720,7 @@ dependencies = [ "libc", "mimalloc", "network-sim", + "proptest", "rand 0.9.2", "rustc-hash", "serde", @@ -637,6 +730,7 @@ dependencies = [ "tempfile", "tokio", "tokio-test", + "toml", "tracing", "tracing-subscriber", ] @@ -729,6 +823,47 @@ dependencies = [ "tokio-stream", ] +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "tracing" version = "0.1.44" @@ -790,6 +925,12 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + [[package]] name = "unicode-ident" version = "1.0.23" @@ -814,6 +955,15 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -961,6 +1111,15 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + [[package]] name = "wit-bindgen" version = "0.51.0" diff --git a/Cargo.toml b/Cargo.toml index 119d55e..a5dc10e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,7 @@ rand = "0.9" rustc-hash = "2.1" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" +toml = "0.8" tokio = { version = "1.49", features = [ "rt-multi-thread", "macros", @@ -46,7 +47,11 @@ mimalloc = { version = "0.1", default-features = false, features = [ libc = "0.2" [features] -test-internals = [] +# test_helpers exposes advance_test_clock(), which calls tokio::time::advance — +# only available with tokio's test-util. Pull it here so the test-internals +# surface compiles standalone (e.g. `cargo build --all-features`), not just under +# `cargo test` where dev-deps happen to unify test-util in. +test-internals = ["tokio/test-util"] [lib] name = "srtla_send" @@ -61,6 +66,7 @@ tokio-test = "0.4" tempfile = "3" assert_matches = "1" network-sim = { path = "crates/network-sim" } +proptest = "1.11.0" [profile.dev] opt-level = 1 diff --git a/README.md b/README.md index 055aa61..0160141 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ This Rust implementation builds upon several open source projects and ideas: ### Scheduling Modes -The sender supports three mutually exclusive scheduling modes: +The sender supports two mutually exclusive scheduling modes: #### Enhanced Mode (Default) @@ -44,23 +44,14 @@ The sender supports three mutually exclusive scheduling modes: - Pure capacity-based selection without quality awareness - Enable via `--mode classic` -#### RTT-Threshold Mode +### Stalled-Link Deselect (On by Default) -- **Reduces Packet Reordering**: Groups links by RTT and strongly prefers low-RTT ("fast") links -- **Threshold-Based Selection**: Links within `min_rtt + delta` are considered "fast" -- **Quality-Aware Within Fast Links**: Applies NAK penalties when choosing among fast links -- **Automatic Fallback**: Uses slow links only when fast links are saturated -- **Enable via**: `--mode rtt-threshold` -- **Configure delta**: `--rtt-delta-ms N` (default 30ms) or runtime `rtt-delta N` -- **Use Case**: Heterogeneous networks where some links have significantly higher latency (e.g., satellite + cellular) - -### Optional Smart Exploration (Enhanced Mode Only) - -- **Context-Aware Discovery**: Tests alternative connections when current best is degrading and alternatives have recovered -- **Periodic Fallback**: Every 30 seconds for 300ms as a safety net -- **Smart Switching**: Tries second-best connections instead of always sticking to current best -- **Enable via**: `--exploration` flag or runtime command `explore on` -- **Use Case**: More aggressive connection testing in unstable network conditions +- **What it does**: Temporarily excludes a link that is holding a large in-flight backlog while producing no fresh delivery proof (no earned ACK and no keepalive round-trip within the staleness window), as long as a healthier link can carry the traffic. +- **Independent liveness signal**: The staleness clock is stamped only on an earned ACK or a completed keepalive round-trip, never on generic inbound bytes, so a link that merely echoes traffic while its data path is dead still goes stale. +- **Self-recovering**: A deselected link keeps sending keepalives. Its next keepalive round-trip clears the stall on its own, so the scheduler never probes a dead link blindly. Genuinely dead links are still pruned by the normal 15 second connection timeout. +- **Selection penalty only**: It never affects timeouts, re-registration, or connection liveness. It is a routing decision, nothing more. +- **Disable via**: `--no-stall-deselect`, or the `set_stall_deselect` JSON-RPC method. Thresholds are tunable with `--stall-min-in-flight` and `--stall-ack-stale-ms`. +- **Use Case**: Satellite links (Starlink) during obstructions or handovers, where a link keeps a backlog but briefly stops delivering. ## Assumptions and Prerequisites @@ -136,11 +127,15 @@ srtla_send [OPTIONS] SRT_LISTEN_PORT SRTLA_HOST SRTLA_PORT BIND_IPS_FILE ### Options -- `--mode `: Scheduling mode: `classic`, `enhanced` (default), `rtt-threshold` -- `--no-quality`: Disable quality scoring (enhanced/rtt-threshold only) -- `--exploration`: Enable connection exploration (enhanced only) -- `--rtt-delta-ms `: RTT delta threshold in ms (default: 30, rtt-threshold only) +- `--mode `: Scheduling mode: `classic`, `enhanced` (default) +- `--no-quality`: Disable quality scoring (enhanced only) +- `--no-stall-deselect`: Disable the stalled-link deselect guard (on by default). The guard skips a link whose in-flight backlog is high while its last delivery proof (an earned ACK or keepalive round-trip) has gone stale, provided a healthier link can carry the traffic. The link recovers automatically on its next keepalive round-trip, so nothing is probed blindly. This mainly helps satellite links (Starlink obstructions and handovers) that keep a large backlog while briefly delivering nothing. +- `--stall-min-in-flight `: In-flight backlog (packets) at or above which a link becomes a stall candidate (default 32) +- `--stall-ack-stale-ms `: Delivery-proof staleness window in milliseconds after which a stall candidate is deselected (default 3000) +- `--config `: Path to a TOML config file (reloaded on SIGHUP) - `--control-socket `: Unix domain socket path for remote control (e.g., `/tmp/srtla.sock`) +- `--priority-bind `: UDP sidecar address for encoder keyframe priority hints +- `--metrics-bind `: Expose a Prometheus scrape endpoint at `/metrics` - `-v, --version`: Print version and exit ## Example Usage @@ -171,12 +166,6 @@ RUST_LOG=info ./target/release/srtla_send --control-socket /tmp/srtla.sock 6000 ./target/release/srtla_send --mode classic 6000 rec.example.com 5000 ./uplinks.txt ``` -**With RTT-threshold mode:** - -```bash -./target/release/srtla_send --mode rtt-threshold --rtt-delta-ms 50 6000 rec.example.com 5000 ./uplinks.txt -``` - **With quality scoring disabled:** ```bash @@ -212,34 +201,53 @@ Type commands directly into the running process and press Enter. ### Method 2: Unix Domain Socket (Unix only) -Use the `--control-socket` option to enable remote control via Unix socket: +Use the `--control-socket` option to enable remote control via Unix socket. The wire format is JSON-RPC 2.0, one request per line. Full method reference lives at [docs/CONTROL_PROTOCOL.md](docs/CONTROL_PROTOCOL.md). ```bash # Start with Unix socket control ./target/release/srtla_send --control-socket /tmp/srtla.sock 6000 10.0.0.1 5000 /tmp/srtla_ips -# Send commands remotely -echo 'mode classic' | socat - UNIX-CONNECT:/tmp/srtla.sock -echo 'status' | socat - UNIX-CONNECT:/tmp/srtla.sock +# Fetch current status +echo '{"jsonrpc":"2.0","id":1,"method":"get_status"}' \ + | socat - UNIX-CONNECT:/tmp/srtla.sock + +# Switch scheduler mode +echo '{"jsonrpc":"2.0","id":1,"method":"set_mode","params":{"mode":"classic"}}' \ + | socat - UNIX-CONNECT:/tmp/srtla.sock +``` + +### Available Methods + +- `set_mode { "mode": "classic"|"enhanced" }` +- `set_quality { "enabled": bool }` +- `get_status` returns the full config snapshot and priority-sidecar counters +- `get_stats` returns per-link telemetry JSON +- `subscribe` / `unsubscribe` to a topic (`stats` or `priority.window`) for streamed updates + +Keyframe priority hints travel on a dedicated UDP sidecar, not the control socket. See [docs/KEYFRAME_PRIORITY.md](docs/KEYFRAME_PRIORITY.md). + +## Prometheus `/metrics` + +Pass `--metrics-bind ADDR:PORT` to expose a Prometheus scrape endpoint at `/metrics`. No additional deps — hand-rolled over `tokio::net::TcpListener`. Serves `GET /metrics` and `GET /` with text format (version 0.0.4); anything else returns 404. Example: + +``` +srtla_send --metrics-bind 127.0.0.1:9099 \ + --priority-bind 127.0.0.1:7000 \ + --control-socket /tmp/srtla.sock \ + 6000 rec.example.com 5000 /tmp/uplinks ``` -### Available Commands +``` +curl -s 127.0.0.1:9099/metrics +``` -- `mode classic` - Switch to classic mode -- `mode enhanced` - Switch to enhanced mode (default) -- `mode rtt-threshold` - Switch to RTT-threshold mode -- `quality on|off` - Enable/disable quality scoring -- `explore on|off` - Enable/disable connection exploration -- `rtt-delta ` - Set RTT delta threshold in milliseconds -- `status` - Display current configuration +Exposed series include `srtla_send_link_up`, `srtla_send_link_rtt_ms`, `srtla_send_link_window`, `srtla_send_link_in_flight`, `srtla_send_link_nak_total`, `srtla_send_link_bitrate_bytes_per_second`, `srtla_send_link_quality_multiplier`, plus aggregate `srtla_send_active_links`, `srtla_send_total_window`, `srtla_send_critical_windows_total`, and the current `srtla_send_mode` as a numeric gauge. ### Connection Selection Algorithm Details **Classic Mode**: Matches the original srtla_send logic without any enhancements. -**Enhanced Mode** (default): Quality-based scoring that punishes connections with recent NAKs. More recent NAKs = more punishment. Additional 30% penalty (0.7x multiplier) for NAK bursts (≥5 NAKs in short time). Optional connection exploration for testing alternative connections. - -**RTT-Threshold Mode**: Groups links into "fast" and "slow" based on RTT measurements. Links within `min_rtt + delta` (default 30ms) are "fast" and strongly preferred. When quality scoring is also enabled, NAK penalties are applied within the fast link group. Falls back to slow links only when all fast links are saturated. Useful for reducing packet reordering in networks with heterogeneous latencies. +**Enhanced Mode** (default): Quality-based scoring that punishes connections with recent NAKs. More recent NAKs mean more punishment. Additional 30% penalty (0.7x multiplier) for NAK bursts (≥5 NAKs in short time). ## IP List Reload (Unix only) @@ -323,7 +331,6 @@ With properly configured connections, you should observe: - Per-packet connection selection decisions - Quality multiplier calculations - NAK burst detections and recovery -- Exploration attempts - Hysteresis decisions ### Troubleshooting @@ -331,8 +338,8 @@ With properly configured connections, you should observe: **If only some connections are used**: 1. Check for NAKs in logs - degraded connections naturally get less traffic in enhanced mode -2. Try classic mode: `mode classic` - disables quality awareness for pure capacity-based distribution -3. Temporarily disable quality scoring: `quality off` +2. Try classic mode via `set_mode { "mode": "classic" }` - disables quality awareness for pure capacity-based distribution +3. Temporarily disable quality scoring via `set_quality { "enabled": false }` 4. Verify all uplinks can reach the receiver (check for timeout messages) 5. Check RTT differences - high-RTT connections get slightly less traffic in enhanced mode (3% max difference) @@ -375,10 +382,6 @@ If needed, these can be adjusted in `src/sender/selection/`: - `MIN_RTT_MS`: 50ms - minimum RTT for calculation (prevents division issues) - `MAX_RTT_BONUS`: 1.03 (3% max bonus) - maximum RTT bonus multiplier -**Exploration (`enhanced.rs`):** - -- Exploration period: `should_explore_now()` function, currently 30s - adjust exploration interval - ### Runtime Optimization For maximum throughput: @@ -391,5 +394,4 @@ For maximum throughput: For maximum stability: - Use classic mode (`--mode classic`) for predictable, simple behavior -- Disable exploration (`explore off`) if not needed - Increase hysteresis threshold if experiencing unnecessary switching diff --git a/crates/network-sim/Cargo.toml b/crates/network-sim/Cargo.toml index 7d4c747..50f18cf 100644 --- a/crates/network-sim/Cargo.toml +++ b/crates/network-sim/Cargo.toml @@ -10,3 +10,4 @@ anyhow = "1.0" tracing = "0.1" rand = "0.10" tempfile = "3" +serde_json = "1.0.150" diff --git a/crates/network-sim/src/adaptive_srt_send.c b/crates/network-sim/src/adaptive_srt_send.c new file mode 100644 index 0000000..6f1a6f4 --- /dev/null +++ b/crates/network-sim/src/adaptive_srt_send.c @@ -0,0 +1,197 @@ +// Adaptive-bitrate SRT sender for the netns harness. +// +// This is belacoder's congestion response with the encoder removed: an +// SRT caller that generates dummy payload and lowers its send rate when +// the SRT send buffer backs up, exactly the closed loop a real BELABOX +// deployment relies on. Without it the harness can only pump a constant +// rate, which oversubscribes a busy bond and drives SRT into a +// retransmit-fuelled congestion collapse (see the notes in +// netns_wire_loss.rs). With it, the offered rate tracks what the bonded +// path can actually carry, so the run stays in a stable regime and +// per-link goodput becomes a meaningful thing to assert. +// +// Deliberately dependency-light: libsrt only (already required by the +// whole srtla stack), no GStreamer, no patched encoder. Compiled on +// demand by the harness, never as part of the Rust build. +// +// Usage: adaptive_srt_send HOST PORT [MIN_KBPS] [MAX_KBPS] [LATENCY_MS] + +#include +#include +#include +#include +#include +#include + +#include + +// SRT live-mode payload. Seven MPEG-TS packets, the libsrt default. +#define PKT 1316 + +// Adaptation cadence. +#define CONTROL_INTERVAL_NS 200000000L // 200 ms + +// Send-buffer occupancy (packets) that we treat as "the path cannot keep +// up": above the high mark we back off, below the low mark we ramp up, +// between them we hold. Keeping the buffer shallow is the whole point — +// a deep SRT send buffer is latency that turns into retransmits. +#define SNDBUF_HIGH 40 +#define SNDBUF_LOW 8 + +// RTT-based congestion, the earlier signal. The send buffer only grows a +// full round-trip after the bottleneck queue starts filling, so keying +// off it alone means always reacting a step late and overshooting into a +// standing queue. Watching RTT climb above its running minimum catches +// the bufferbloat as it forms. Threshold: 1.5x the baseline plus a fixed +// margin so ordinary jitter on a low-RTT path does not trip it. +#define RTT_INFLATION_FACTOR 1.5 +#define RTT_INFLATION_MARGIN_MS 30.0 + +static uint64_t now_ns(void) { + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + return (uint64_t)ts.tv_sec * 1000000000ull + ts.tv_nsec; +} + +int main(int argc, char **argv) { + if (argc < 3) { + fprintf(stderr, "usage: %s HOST PORT [MIN_KBPS] [MAX_KBPS] [LATENCY_MS]\n", + argv[0]); + return 2; + } + const char *host = argv[1]; + int port = atoi(argv[2]); + int min_kbps = argc > 3 ? atoi(argv[3]) : 500; + int max_kbps = argc > 4 ? atoi(argv[4]) : 12000; + int latency_ms = argc > 5 ? atoi(argv[5]) : 2000; + + int64_t cur_bps = (int64_t)min_kbps * 1000; + const int64_t min_bps = (int64_t)min_kbps * 1000; + const int64_t max_bps = (int64_t)max_kbps * 1000; + + if (srt_startup() != 0) { + fprintf(stderr, "srt_startup failed: %s\n", srt_getlasterror_str()); + return 1; + } + + SRTSOCKET s = srt_create_socket(); + if (s == SRT_INVALID_SOCK) { + fprintf(stderr, "srt_create_socket failed: %s\n", srt_getlasterror_str()); + return 1; + } + + int live = SRTT_LIVE; + srt_setsockflag(s, SRTO_TRANSTYPE, &live, sizeof(live)); + srt_setsockflag(s, SRTO_LATENCY, &latency_ms, sizeof(latency_ms)); + int payload = PKT; + srt_setsockflag(s, SRTO_PAYLOADSIZE, &payload, sizeof(payload)); + // Non-blocking send: a full buffer is itself the congestion signal, and + // we would rather drop and keep adapting than stall the control loop. + int no = 0; + srt_setsockflag(s, SRTO_SNDSYN, &no, sizeof(no)); + + struct sockaddr_in sa; + memset(&sa, 0, sizeof(sa)); + sa.sin_family = AF_INET; + sa.sin_port = htons((uint16_t)port); + if (inet_pton(AF_INET, host, &sa.sin_addr) != 1) { + fprintf(stderr, "bad host %s\n", host); + return 1; + } + + if (srt_connect(s, (struct sockaddr *)&sa, sizeof(sa)) == SRT_ERROR) { + fprintf(stderr, "srt_connect failed: %s\n", srt_getlasterror_str()); + return 1; + } + fprintf(stderr, "adaptive sender connected to %s:%d (%d-%d kbps)\n", host, + port, min_kbps, max_kbps); + + char buf[PKT]; + memset(buf, 0xb8, sizeof(buf)); // 0xb8 marks each byte, harmless payload + + uint64_t start = now_ns(); + uint64_t next_control = start + CONTROL_INTERVAL_NS; + uint64_t sent_pkts = 0; + int blocked_since_control = 0; + double rtt_min = 0.0; + + for (;;) { + uint64_t t = now_ns(); + + // Pace: hold the average send rate at cur_bps by gating on how many + // packets we should have sent by now. + uint64_t bytes_target = (uint64_t)((double)cur_bps / 8.0 * + ((double)(t - start) / 1e9)); + uint64_t pkts_target = bytes_target / PKT; + + if (sent_pkts < pkts_target) { + int n = srt_send(s, buf, PKT); + if (n == PKT) { + sent_pkts++; + } else { + // Buffer full (EASYNCSND) or a real error: treat as congestion. + int err = srt_getlasterror(NULL); + if (err == SRT_EASYNCSND) { + blocked_since_control++; + } else if (err == SRT_ECONNLOST || err == SRT_ECONNREJ || + err == SRT_ENOCONN) { + fprintf(stderr, "srt send: connection gone: %s\n", + srt_getlasterror_str()); + break; + } + // Small pause so we do not spin on a full buffer. + struct timespec ns = {0, 1000000L}; // 1 ms + nanosleep(&ns, NULL); + } + } else { + struct timespec ns = {0, 200000L}; // 0.2 ms: caught up, idle briefly + nanosleep(&ns, NULL); + } + + if (t < next_control) + continue; + next_control += CONTROL_INTERVAL_NS; + + SRT_TRACEBSTATS st; + if (srt_bstats(s, &st, 1) == 0) { + // Track the RTT baseline. Slow upward creep lets it follow a genuine + // path change (a handover raising the floor) instead of pinning to + // one early low sample and reading every later RTT as congestion. + if (st.msRTT > 0.0) { + if (rtt_min == 0.0 || st.msRTT < rtt_min) + rtt_min = st.msRTT; + else + rtt_min += (st.msRTT - rtt_min) * 0.02; + } + double rtt_ceiling = rtt_min * RTT_INFLATION_FACTOR + RTT_INFLATION_MARGIN_MS; + int bufferbloat = st.msRTT > rtt_ceiling; + + // pktSndBuf: packets sitting in the send buffer (offered minus + // drained). Any of a deep buffer, a blocked send, or an inflated RTT + // means we are pushing more than the bonded path drains. + int overdriving = + st.pktSndBuf > SNDBUF_HIGH || blocked_since_control > 0 || bufferbloat; + int has_room = st.pktSndBuf < SNDBUF_LOW && blocked_since_control == 0 && + !bufferbloat; + if (overdriving) { + cur_bps = (int64_t)((double)cur_bps * 0.85); // -15% + if (cur_bps < min_bps) + cur_bps = min_bps; + } else if (has_room) { + cur_bps += cur_bps / 33 + 50000; // +3% and a floor step + if (cur_bps > max_bps) + cur_bps = max_bps; + } + fprintf(stderr, + "ctl: bitrate=%lld kbps sndbuf=%d rtt=%.0f rttmin=%.0f bloat=%d " + "blocked=%d\n", + (long long)(cur_bps / 1000), st.pktSndBuf, st.msRTT, rtt_min, + bufferbloat, blocked_since_control); + } + blocked_since_control = 0; + } + + srt_close(s); + srt_cleanup(); + return 0; +} diff --git a/crates/network-sim/src/harness.rs b/crates/network-sim/src/harness.rs index 504ddb4..d86360c 100644 --- a/crates/network-sim/src/harness.rs +++ b/crates/network-sim/src/harness.rs @@ -6,8 +6,9 @@ //! (srt-live-transmit + srtla_rec + srtla_send). use std::io::{BufRead, BufReader}; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::process::{Child, Command, Stdio}; +use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; use anyhow::{Context, Result, bail}; @@ -80,6 +81,77 @@ pub fn check_integration_deps() -> std::result::Result<(), SkipReason> { Ok(()) } +/// C source for the adaptive SRT sender, compiled on demand. Kept out of +/// the Rust build on purpose: the workspace must build without libsrt +/// dev headers, and only a machine actually running the netns tests (a +/// superset of the srtla stack, which already needs libsrt) has to be +/// able to compile it. +const ADAPTIVE_SENDER_SRC: &str = include_str!("adaptive_srt_send.c"); + +/// Whether the adaptive SRT sender can be built here: a C compiler and +/// libsrt dev, the latter probed through `pkg-config srt`. +pub fn check_adaptive_sender_deps() -> std::result::Result<(), SkipReason> { + if check_binary("cc").is_none() { + return Err(SkipReason::MissingTool("cc".into())); + } + let srt_dev = Command::new("pkg-config") + .args(["--exists", "srt"]) + .status() + .map(|s| s.success()) + .unwrap_or(false); + if !srt_dev { + return Err(SkipReason::MissingBinary( + "libsrt dev (pkg-config srt)".into(), + )); + } + Ok(()) +} + +/// Compile the adaptive SRT sender against the system libsrt and return +/// the built binary. Gate on [`check_adaptive_sender_deps`] first. +/// +/// Recompiles every call — the source is tiny and this keeps a stale +/// binary from surviving a source edit. The output lands in the system +/// temp dir, not the cargo target tree. +pub fn build_adaptive_sender() -> Result { + let dir = std::env::temp_dir(); + let src = dir.join("network_sim_adaptive_srt_send.c"); + let bin = dir.join("network_sim_adaptive_srt_send"); + std::fs::write(&src, ADAPTIVE_SENDER_SRC).context("write adaptive sender source")?; + + let flags_out = Command::new("pkg-config") + .args(["--cflags", "--libs", "srt"]) + .output() + .context("pkg-config srt")?; + if !flags_out.status.success() { + bail!( + "pkg-config srt failed: {}", + String::from_utf8_lossy(&flags_out.stderr).trim() + ); + } + let flags = String::from_utf8_lossy(&flags_out.stdout); + + let mut args: Vec = vec![ + src.to_string_lossy().into_owned(), + "-O2".into(), + "-o".into(), + bin.to_string_lossy().into_owned(), + ]; + args.extend(flags.split_whitespace().map(str::to_string)); + + let out = Command::new("cc") + .args(&args) + .output() + .context("cc adaptive sender")?; + if !out.status.success() { + bail!( + "compiling adaptive sender failed:\n{}", + String::from_utf8_lossy(&out.stderr).trim() + ); + } + Ok(bin) +} + /// Check deps including netem (for tests that apply impairment). pub fn check_impairment_deps() -> std::result::Result<(), SkipReason> { check_integration_deps()?; @@ -104,10 +176,35 @@ pub fn check_impairment_deps() -> std::result::Result<(), SkipReason> { /// A child process running inside a network namespace. /// /// Captures stdout+stderr and kills the process on drop. +/// +/// The output pipes are drained continuously by background threads, and +/// that is load-bearing rather than a convenience. A piped child whose +/// output nobody reads blocks in `write()` as soon as the 64 KiB pipe +/// buffer fills. srtla_send runs here with `RUST_LOG=debug`, which at a +/// few Mbps fills that buffer in about a second — and because the task +/// doing the logging is the main select loop, the *sender* wedges while +/// its control socket (which logs almost nothing) carries on answering. +/// The symptom is a stats snapshot frozen at the values it held a second +/// into the run, which reads like an impossibly stable control loop +/// rather than a deadlocked one. Short tests never noticed because they +/// finish under 64 KiB. pub struct NamespaceProcess { child: Child, #[expect(dead_code)] label: String, + stdout: Arc>>, + stderr: Arc>>, +} + +/// Drain a child pipe into a shared buffer, line by line, until EOF. +fn drain_pipe(pipe: R, sink: Arc>>) { + std::thread::spawn(move || { + for line in BufReader::new(pipe).lines().map_while(|l| l.ok()) { + if let Ok(mut buf) = sink.lock() { + buf.push(line); + } + } + }); } impl NamespaceProcess { @@ -138,33 +235,38 @@ impl NamespaceProcess { .stdout(Stdio::piped()) .stderr(Stdio::piped()); - let child = cmd.spawn().with_context(|| format!("spawn {label}"))?; + let mut child = cmd.spawn().with_context(|| format!("spawn {label}"))?; + + // Start draining immediately — see the note on the struct. If we + // wait until the process exits to read these, it never gets there. + let stdout = Arc::new(Mutex::new(Vec::new())); + let stderr = Arc::new(Mutex::new(Vec::new())); + if let Some(pipe) = child.stdout.take() { + drain_pipe(pipe, Arc::clone(&stdout)); + } + if let Some(pipe) = child.stderr.take() { + drain_pipe(pipe, Arc::clone(&stderr)); + } tracing::debug!(%label, pid = child.id(), "spawned namespace process"); - Ok(Self { child, label }) + Ok(Self { + child, + label, + stdout, + stderr, + }) } - /// Read all captured stdout lines (non-blocking snapshot via `try_wait`). - /// Only meaningful after the process has exited. + /// Snapshot of the stdout lines captured so far. Safe to call while + /// the process is still running. pub fn stdout_lines(&mut self) -> Vec { - match self.child.stdout.take() { - Some(stdout) => BufReader::new(stdout) - .lines() - .map_while(|l| l.ok()) - .collect(), - None => vec![], - } + self.stdout.lock().map(|b| b.clone()).unwrap_or_default() } - /// Read all captured stderr lines. Only meaningful after exit. + /// Snapshot of the stderr lines captured so far. Safe to call while + /// the process is still running. pub fn stderr_lines(&mut self) -> Vec { - match self.child.stderr.take() { - Some(stderr) => BufReader::new(stderr) - .lines() - .map_while(|l| l.ok()) - .collect(), - None => vec![], - } + self.stderr.lock().map(|b| b.clone()).unwrap_or_default() } /// Send SIGTERM, wait briefly, then SIGKILL if needed. @@ -293,16 +395,118 @@ impl SrtlaTestTopology { receiver_ifaces.push(r_iface); } - let receiver_ip = "10.10.1.2".to_string(); + // One receiver endpoint reached over every uplink, which is how + // SRTLA actually bonds: N sender source IPs, one receiver + // address. A per-link near-end IP (the old `10.10.1.2`) only + // worked for link 0 — every other uplink routed its packets out + // link 0's veth and got dropped by the receiver's reverse-path + // filter, so it never registered and the bond was really one + // link. See `wire_bonding_routes`. + let receiver_ip = SRTLA_RECEIVER_SERVICE_IP.to_string(); - Ok(Self { + let topo = Self { sender_ns, receiver_ns, sender_ips, receiver_ip, sender_ifaces, receiver_ifaces, - }) + }; + topo.wire_bonding_routes()?; + Ok(topo) + } + + /// Make the single receiver endpoint reachable over each uplink + /// independently, so every source IP egresses its own (impairable) + /// veth. This is the piece that turns the topology from "one link + /// plus dead spares" into a real bond. + /// + /// Per uplink `i` on subnet `10.10.{i+1}.0/24` (sender `.1`, + /// receiver `.2`): + /// + /// - The receiver owns the service IP on `lo`, so it answers on it no + /// matter which veth a request arrived on. + /// - The sender gets a routing table per source IP + /// (`ip rule from lookup `) whose route to the service + /// IP points out that uplink's veth. Binding a socket to the source + /// IP therefore pins its traffic to that veth. + /// - The receiver returns packets to each sender source with the + /// service IP as source address (`src` on the route), so the + /// sender's connected UDP socket accepts the reply. + /// - Reverse-path filtering is relaxed on both ends. With per-source + /// policy routing the return route lives outside the main table, so + /// strict rp_filter (the default) would drop the very packets that + /// make the uplink work. + fn wire_bonding_routes(&self) -> Result<()> { + disable_rp_filter(&self.sender_ns, &self.sender_ifaces)?; + disable_rp_filter(&self.receiver_ns, &self.receiver_ifaces)?; + + // Service IP lives on the receiver's loopback. + self.receiver_ns + .exec_checked( + "ip", + &[ + "addr", + "add", + &format!("{SRTLA_RECEIVER_SERVICE_IP}/32"), + "dev", + "lo", + ], + ) + .context("add receiver service IP")?; + + for (i, (s_ip, s_iface)) in self + .sender_ips + .iter() + .zip(self.sender_ifaces.iter()) + .enumerate() + { + let subnet = i + 1; + let r_ip = format!("10.10.{subnet}.2"); + let table = (subnet).to_string(); + + // Sender: source-routed path to the service IP over this veth. + self.sender_ns + .exec_checked( + "ip", + &[ + "route", + "add", + &format!("{SRTLA_RECEIVER_SERVICE_IP}/32"), + "via", + &r_ip, + "dev", + s_iface, + "table", + &table, + ], + ) + .context("sender per-uplink route")?; + self.sender_ns + .exec_checked("ip", &["rule", "add", "from", s_ip, "lookup", &table]) + .context("sender per-uplink rule")?; + + // Receiver: return to this sender source with the service IP + // as the source address, so the reply's peer matches what the + // sender connected to. + let r_iface = &self.receiver_ifaces[i]; + self.receiver_ns + .exec_checked( + "ip", + &[ + "route", + "add", + &format!("{s_ip}/32"), + "dev", + r_iface, + "src", + SRTLA_RECEIVER_SERVICE_IP, + ], + ) + .context("receiver return route")?; + } + + Ok(()) } /// Apply impairment to sender-side veth link at `idx`. @@ -352,6 +556,42 @@ pub fn wait_for_udp_listener(ns: &Namespace, port: u16, timeout: Duration) -> Re } } +/// Poll `ss -uan` inside `ns` until at least `min_count` UDP sockets are +/// connected to `peer_ip:peer_port`. The sender `connect()`s one socket per +/// source IP to the receiver as it brings each uplink online, so a connected +/// peer entry is the observable readiness signal that replaces a fixed +/// registration sleep — it returns as soon as the state appears. +pub fn wait_for_connected_uplinks( + ns: &Namespace, + peer_ip: &str, + peer_port: u16, + min_count: usize, + timeout: Duration, +) -> Result<()> { + let start = Instant::now(); + let peer = format!("{peer_ip}:{peer_port}"); + let mut last_ss_output; + + loop { + let out = ns.exec("ss", &["-uan"])?; + let stdout = String::from_utf8_lossy(&out.stdout); + let count = stdout.lines().filter(|line| line.contains(&peer)).count(); + if count >= min_count { + return Ok(()); + } + last_ss_output = stdout.to_string(); + + if start.elapsed() > timeout { + bail!( + "timeout waiting for {min_count} connected uplink(s) to {peer} in ns {} (saw \ + {count})\nlast ss -uan output:\n{last_ss_output}", + ns.name + ); + } + std::thread::sleep(Duration::from_millis(200)); + } +} + // --------------------------------------------------------------------------- // SrtlaTestStack // --------------------------------------------------------------------------- @@ -362,9 +602,39 @@ pub struct SrtlaTestStack { srt_server: Option, srtla_rec: Option, srtla_send: Option, + srt_caller: Option, _ip_list_path: PathBuf, } +/// UDP port the SRT caller ingests from, when one is started. +pub const SRT_CALLER_INGEST_PORT: u16 = 6000; + +/// The single receiver endpoint every uplink connects to. Lives on the +/// receiver's loopback and is reachable over each veth via per-source +/// policy routing (see `SrtlaTestTopology::wire_bonding_routes`). +const SRTLA_RECEIVER_SERVICE_IP: &str = "10.99.0.1"; + +/// Disable reverse-path filtering in a namespace: set the `all` and +/// `default` keys plus every named interface to `0`. The effective value +/// is `max(all, iface)`, so both have to be cleared. +/// +/// Off, not loose (`2`): whether loose mode honours the per-source policy +/// routing this topology relies on is kernel-version-dependent, and a +/// test namespace has nothing to protect, so remove the variable. +fn disable_rp_filter(ns: &Namespace, ifaces: &[String]) -> Result<()> { + let mut keys = vec!["all".to_string(), "default".to_string()]; + keys.extend(ifaces.iter().cloned()); + for key in keys { + // Best-effort per key: a kernel may lack a given conf path, and + // that should not fail the whole topology. + let _ = ns.exec( + "sysctl", + &["-w", &format!("net.ipv4.conf.{key}.rp_filter=0")], + ); + } + Ok(()) +} + /// Output collected from all processes after stopping the stack. pub struct StackOutput { pub srt_server_stdout: Vec, @@ -373,6 +643,8 @@ pub struct StackOutput { pub srtla_rec_stderr: Vec, pub srtla_send_stdout: Vec, pub srtla_send_stderr: Vec, + pub srt_caller_stdout: Vec, + pub srt_caller_stderr: Vec, } /// Ports used by the test stack. @@ -463,10 +735,134 @@ impl SrtlaTestStack { srt_server: Some(srt_server), srtla_rec: Some(srtla_rec), srtla_send: Some(srtla_send), + srt_caller: None, _ip_list_path: ip_list_path, }) } + /// Start a real SRT caller in the sender namespace, in front of + /// srtla_send. + /// + /// Without this the stack carries no SRT session: injecting raw UDP + /// into srtla_send's listener gets it proxied over the bond, but the + /// far end never completes a handshake, so it never returns ACKs or + /// NAKs. Any test that depends on loss or RTT feedback reaching the + /// sender — i.e. anything touching congestion control or link + /// scoring — is silently vacuous without a caller here. + /// + /// With it, the chain is a genuine end-to-end SRT connection: + /// + /// ```text + /// UDP :6000 → srt-live-transmit (caller) → srtla_send :5555 + /// → [bonded uplinks] → srtla_rec → srt-live-transmit (listener) + /// ``` + /// + /// so the listener's ACK/NAK stream flows back through the bond and + /// drives the real feedback path. Feed it with + /// [`inject_udp_stream`] on [`SRT_CALLER_INGEST_PORT`]. + pub fn start_srt_caller(&mut self) -> Result<()> { + let in_uri = format!("udp://:{SRT_CALLER_INGEST_PORT}"); + // Latency must sit *above* the TBF buffer depth (`latency 1s` in + // impairment.rs). SRT declares a packet lost and retransmits it + // once it is older than this window. If that window is shorter + // than the shaper's queue, a packet that is merely waiting its + // turn in the TBF gets retransmitted while the original is still + // in flight — a false-loss storm that doubles offered load and + // tips a busy bond into congestion collapse. 2s clears the 1s + // buffer with margin. + let out_uri = format!("srt://127.0.0.1:{SRTLA_SEND_SRT_PORT}?mode=caller&latency=2000"); + let mut caller = NamespaceProcess::spawn( + &self.topo.sender_ns, + "srt-live-transmit", + &[&in_uri, &out_uri], + ) + .context("start srt-live-transmit caller")?; + + std::thread::sleep(Duration::from_millis(750)); + if let Some((code, stderr)) = caller.check_exit() { + bail!("srt caller exited immediately (code: {code:?})\nstderr:\n{stderr}"); + } + wait_for_udp_listener( + &self.topo.sender_ns, + SRT_CALLER_INGEST_PORT, + Duration::from_secs(5), + ) + .context("wait for srt caller udp ingest")?; + + self.srt_caller = Some(caller); + Ok(()) + } + + /// Start the adaptive SRT sender in the sender namespace, in front of + /// srtla_send. The counterpart to [`start_srt_caller`] for tests that + /// need a *realistic* offered load rather than a fixed one. + /// + /// `start_srt_caller` drives a constant bitrate, which oversubscribes + /// a busy bond and collapses it into a retransmit storm. This instead + /// runs a real SRT caller that lowers its rate when the SRT send + /// buffer backs up — belacoder's congestion response without the + /// encoder — so the offered rate tracks what the bond can carry and + /// the run stays in a regime where per-link goodput is meaningful. + /// + /// Build the binary once with [`build_adaptive_sender`] and pass it + /// in; the sender ramps between `min_kbps` and `max_kbps`. + pub fn start_adaptive_sender( + &mut self, + sender_bin: &Path, + min_kbps: u32, + max_kbps: u32, + ) -> Result<()> { + let bin = sender_bin.to_string_lossy().into_owned(); + let port = SRTLA_SEND_SRT_PORT.to_string(); + let min_s = min_kbps.to_string(); + let max_s = max_kbps.to_string(); + // 2s SRT latency, above the shaper buffer — same reason as the + // constant caller above. + let mut sender = NamespaceProcess::spawn( + &self.topo.sender_ns, + &bin, + &["127.0.0.1", &port, &min_s, &max_s, "2000"], + ) + .context("start adaptive SRT sender")?; + + std::thread::sleep(Duration::from_millis(1500)); + if let Some((code, stderr)) = sender.check_exit() { + bail!("adaptive sender exited immediately (code: {code:?})\nstderr:\n{stderr}"); + } + + // Reuse the caller slot: this *is* the SRT caller, and the slot's + // lifecycle (kill on stop/drop) is exactly what we want. + self.srt_caller = Some(sender); + Ok(()) + } + + /// Query srtla_send's control socket for a `get_stats` snapshot, + /// returning the parsed `result` object. + /// + /// Runs the query as root inside the namespace: srtla_send is spawned + /// under sudo, so the socket it binds is root-owned and a test process + /// running as the invoking user cannot connect to it directly. + pub fn get_stats(&self, socket_path: &str) -> Result { + let script = format!( + "import socket,sys\ns=socket.socket(socket.AF_UNIX,socket.SOCK_STREAM)\ns.\ + settimeout(5)\ns.connect('{socket_path}')\ns.sendall(b'{{\"jsonrpc\":\"2.0\",\"id\":\ + 1,\"method\":\"get_stats\",\"params\":{{}}}}\\n')\nbuf=b''\nwhile not \ + buf.endswith(b'\\n'):\n\x20 c=s.recv(65536)\n\x20 if not c: break\n\x20 \ + buf+=c\ns.close()\nsys.stdout.write(buf.decode())" + ); + let out = self + .topo + .sender_ns + .exec_checked("python3", &["-c", &script]) + .context("query control socket")?; + let raw = String::from_utf8_lossy(&out.stdout); + let resp: serde_json::Value = serde_json::from_str(raw.trim()) + .with_context(|| format!("parse stats reply: {raw}"))?; + resp.get("result") + .cloned() + .ok_or_else(|| anyhow::anyhow!("no result in stats reply: {resp}")) + } + /// Apply impairment to sender-side link at `idx`. pub fn impair_link(&self, idx: usize, config: ImpairmentConfig) -> Result<()> { self.topo.impair_link(idx, config) @@ -477,13 +873,23 @@ impl SrtlaTestStack { SRTLA_SEND_SRT_PORT } + /// The receiver-side srtla_rec port the sender's uplink sockets connect to. + pub fn receiver_srtla_port(&self) -> u16 { + SRTLA_REC_PORT + } + /// Stop all processes and collect their output. pub fn stop(&mut self) -> StackOutput { let mut send_out = (vec![], vec![]); let mut rec_out = (vec![], vec![]); let mut srt_out = (vec![], vec![]); + let mut caller_out = (vec![], vec![]); - // Kill in reverse order: sender → receiver → srt server + // Kill in reverse order: caller → sender → receiver → srt server + if let Some(mut p) = self.srt_caller.take() { + p.kill(); + caller_out = (p.stdout_lines(), p.stderr_lines()); + } if let Some(mut p) = self.srtla_send.take() { p.kill(); send_out = (p.stdout_lines(), p.stderr_lines()); @@ -504,6 +910,8 @@ impl SrtlaTestStack { srtla_rec_stderr: rec_out.1, srtla_send_stdout: send_out.0, srtla_send_stderr: send_out.1, + srt_caller_stdout: caller_out.0, + srt_caller_stderr: caller_out.1, } } } @@ -512,6 +920,7 @@ impl Drop for SrtlaTestStack { fn drop(&mut self) { // Ensure all processes are killed even if stop() wasn't called. // Dropping NamespaceProcess triggers its Drop impl which calls kill(). + drop(self.srt_caller.take()); drop(self.srtla_send.take()); drop(self.srtla_rec.take()); drop(self.srt_server.take()); @@ -537,31 +946,77 @@ pub fn inject_udp_packets(ns: &Namespace, target_ip: &str, port: u16, count: usi Ok(()) } -/// Inject UDP packets at a steady rate (packets/sec) for `duration`. -pub fn inject_udp_stream( - ns: &Namespace, +/// Default datagram size: one MPEG-TS packet. +pub const TS_PACKET_BYTES: usize = 188; +/// libsrt's default payload size. Prefer this when a test needs real +/// throughput: a Python pump cannot reliably sleep in the sub-millisecond +/// intervals that megabit rates demand at 188 bytes a datagram, so it +/// silently becomes the bottleneck instead of the network. +pub const SRT_PAYLOAD_BYTES: usize = 1316; + +/// Build the steady-rate UDP sender script shared by the blocking and +/// spawned stream injectors. +/// +/// The loop paces against a wall-clock deadline per packet rather than +/// sleeping a fixed interval, so send-call overhead does not accumulate +/// into a drifting, ever-slower rate. +fn udp_stream_script( target_ip: &str, port: u16, packets_per_sec: u32, + payload_bytes: usize, duration: Duration, -) -> Result<()> { +) -> Result { if packets_per_sec == 0 { bail!("packets_per_sec must be > 0"); } - let interval_us = 1_000_000 / packets_per_sec; + if payload_bytes == 0 { + bail!("payload_bytes must be > 0"); + } + let interval = 1.0 / f64::from(packets_per_sec); let dur_secs = duration.as_secs_f64(); - let script = format!( - "import socket,time\ns=socket.socket(socket.AF_INET,socket.SOCK_DGRAM)\nd=b'\\x00'*188\\ - nstart=time.time(); i=0\nwhile time.time()-start<{dur_secs}:\n\x20 \ - s.sendto(d,('{target_ip}',{port}))\n\x20 i+=1\n\x20 \ - time.sleep({interval_us}/1e6)\ns.close()\nprint(f'sent {{i}} packets')" - ); + Ok(format!( + "import socket,time\ns=socket.socket(socket.AF_INET,socket.SOCK_DGRAM)\nd=b'\\xb8'*\ + {payload_bytes}\nstart=time.time(); i=0\nwhile True:\n\x20 now=time.time()\n\x20 if \ + now-start>={dur_secs}: break\n\x20 s.sendto(d,('{target_ip}',{port}))\n\x20 \ + i+=1\n\x20 nxt=start+i*{interval}\n\x20 slp=nxt-time.time()\n\x20 if slp>0: \ + time.sleep(slp)\ns.close()\nprint(f'sent {{i}} packets')" + )) +} + +/// Inject UDP packets at a steady rate (packets/sec) for `duration`. +/// Blocks until the stream finishes. +pub fn inject_udp_stream( + ns: &Namespace, + target_ip: &str, + port: u16, + packets_per_sec: u32, + payload_bytes: usize, + duration: Duration, +) -> Result<()> { + let script = udp_stream_script(target_ip, port, packets_per_sec, payload_bytes, duration)?; ns.exec_checked("python3", &["-c", &script]) .context("inject UDP stream")?; Ok(()) } +/// Like [`inject_udp_stream`], but returns immediately with the running +/// process so the caller can observe the system *while* traffic flows. +/// Anything that samples a control loop's behaviour under load needs +/// this rather than the blocking form. +pub fn spawn_udp_stream( + ns: &Namespace, + target_ip: &str, + port: u16, + packets_per_sec: u32, + payload_bytes: usize, + duration: Duration, +) -> Result { + let script = udp_stream_script(target_ip, port, packets_per_sec, payload_bytes, duration)?; + NamespaceProcess::spawn(ns, "python3", &["-c", &script]).context("spawn UDP stream") +} + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- diff --git a/crates/network-sim/src/lib.rs b/crates/network-sim/src/lib.rs index 9b17890..5ccfb8b 100644 --- a/crates/network-sim/src/lib.rs +++ b/crates/network-sim/src/lib.rs @@ -17,8 +17,10 @@ pub mod test_util; pub mod topology; pub use harness::{ - NamespaceProcess, SkipReason, SrtlaTestStack, SrtlaTestTopology, StackOutput, check_binary, - check_impairment_deps, check_integration_deps, inject_udp_packets, inject_udp_stream, + NamespaceProcess, SRT_CALLER_INGEST_PORT, SRT_PAYLOAD_BYTES, SkipReason, SrtlaTestStack, + SrtlaTestTopology, StackOutput, TS_PACKET_BYTES, build_adaptive_sender, + check_adaptive_sender_deps, check_binary, check_impairment_deps, check_integration_deps, + inject_udp_packets, inject_udp_stream, spawn_udp_stream, wait_for_connected_uplinks, wait_for_udp_listener, }; pub use impairment::{GemodelConfig, ImpairmentConfig, apply_impairment}; diff --git a/deny.toml b/deny.toml new file mode 100644 index 0000000..49579f3 --- /dev/null +++ b/deny.toml @@ -0,0 +1,21 @@ +# Cargo-deny configuration for srtla_send. +# Enforces supply-chain security: advisories and source integrity. +# See https://embarkstudios.github.io/cargo-deny/ + +[advisories] +yanked = "deny" + +# Exception for pre-existing rand unsoundness (RUSTSEC-2026-0097). +# The vulnerability requires: log + thread_rng features + custom logger + specific conditions. +# srtla_send does not use custom loggers or the log crate directly; tracing-subscriber +# is used instead. This is a low-risk exception for dev-only and transitive deps. +[[advisories.ignore]] +id = "RUSTSEC-2026-0097" +reason = "rand unsoundness requires custom logger + log crate; srtla_send uses tracing-subscriber" + +[sources] +# Enforce crate source integrity. +unknown-registry = "deny" +unknown-git = "deny" +allow-registry = ["https://github.com/rust-lang/crates.io-index"] +allow-git = [] diff --git a/docs/CONTROL_PROTOCOL.md b/docs/CONTROL_PROTOCOL.md new file mode 100644 index 0000000..a6eeaa0 --- /dev/null +++ b/docs/CONTROL_PROTOCOL.md @@ -0,0 +1,135 @@ +# srtla_send control protocol + +srtla_send exposes runtime control over a Unix socket (preferred) or stdin. The wire format is JSON-RPC 2.0, one message per line. The socket path is set with `--control-socket `. + +## Request/response + +One request per line, UTF-8 JSON. Responses end with a newline. + +Request: + +```json +{"jsonrpc": "2.0", "id": 1, "method": "set_mode", "params": {"mode": "enhanced"}} +``` + +Success: + +```json +{"jsonrpc": "2.0", "result": {"mode": "enhanced"}, "id": 1} +``` + +Error (standard JSON-RPC codes): + +```json +{"jsonrpc": "2.0", "error": {"code": -32602, "message": "expected params.mode: string"}, "id": 1} +``` + +## Notifications + +Requests without `id` are notifications. srtla_send processes them and sends no response. Useful for one-way config pokes when round-tripping a reply would be wasteful. + +```json +{"jsonrpc": "2.0", "method": "set_mode", "params": {"mode": "classic"}} +``` + +## Methods + +### `set_mode` + +Switch the link scheduler. + +| param | type | values | +| --- | --- | --- | +| `mode` | string | `"classic"`, `"enhanced"` | + +Result: `{ "mode": "" }`. + +### `set_quality` + +Toggle quality scoring (enhanced mode). + +Params: `{ "enabled": bool }`. Result: `{ "enabled": bool }`. + +### `get_status` + +Return the full runtime configuration plus priority-sidecar telemetry. + +Result: + +```json +{ + "mode": "enhanced", + "quality_enabled": true, + "critical_windows_received": 142, + "critical_malformed_datagrams": 0 +} +``` + +### `get_stats` + +Return per-link telemetry (the JSON previously returned by `stats`). + +## Subscriptions + +The Unix control socket supports server-push subscriptions. Polling `get_stats` at 1 Hz misses sub-second link-state changes (NAK bursts, quality drops, reconnects); subscriptions let clients receive push events on the same socket they already use for requests. + +### `subscribe` + +Params: `{ "topic": "stats" | "priority.window" }`. Result: `{ "subscription_id": string }`. + +### `unsubscribe` + +Params: `{ "subscription_id": string }`. Result: `{ "removed": bool }`. + +### Push events + +Server-originated notifications are sent on the same connection: + +```json +{ + "jsonrpc": "2.0", + "method": "stats.update", + "params": { + "subscription_id": "sub-0", + "data": { /* StatsSnapshot */ } + } +} +``` + +Topics currently implemented: + +| Topic | Data | Cadence | +| --- | --- | --- | +| `stats` | Full `StatsSnapshot` (same shape as `get_stats`) | Once per second, aligned with housekeeping | +| `priority.window` | `{ at_ms, window_ms, deadline_ms }` | Once per keyframe window from the priority sidecar | + +Subscriptions live for the life of the connection. Dropping the socket cancels every subscription it owns. + +Stdin is request-only — subscriptions only work on the Unix socket. + +## Error codes + +| code | meaning | +| --- | --- | +| `-32700` | parse error (invalid JSON) | +| `-32600` | invalid request (missing / wrong `jsonrpc` field) | +| `-32601` | method not found | +| `-32602` | invalid params | +| `-32603` | internal error | + +## Examples + +Using `socat` on the Unix socket: + +``` +$ echo '{"jsonrpc":"2.0","id":1,"method":"get_status"}' \ + | socat - UNIX-CONNECT:/tmp/srtla.sock +{"jsonrpc":"2.0","result":{"mode":"enhanced",...},"id":1} +``` + +Switching mode at runtime: + +``` +$ echo '{"jsonrpc":"2.0","id":1,"method":"set_mode","params":{"mode":"classic"}}' \ + | socat - UNIX-CONNECT:/tmp/srtla.sock +``` diff --git a/docs/KEYFRAME_PRIORITY.md b/docs/KEYFRAME_PRIORITY.md new file mode 100644 index 0000000..6162a2c --- /dev/null +++ b/docs/KEYFRAME_PRIORITY.md @@ -0,0 +1,55 @@ +# Keyframe priority sidecar + +srtla_send offers two complementary ways to treat keyframe / parameter-set packets as critical and route them to the most reliable link: + +1. A packet-size heuristic (`src/sender/keyframe.rs`) that watches for runs of max-MTU 1316-byte SRT packets and declares a burst when 5 or more land in a row. +2. An out-of-band UDP sidecar where an upstream encoder explicitly opens a short "critical window". + +The two are OR-combined. An encoder that knows it is about to emit a keyframe opens a window; the heuristic keeps catching bursts on its own when no encoder feedback is available. + +## Why a sidecar UDP, not the JSON-RPC control socket + +The JSON-RPC control socket rides a separate path from SRT packets. A hint that arrives microseconds after the packets it describes misses them entirely. Sharing the network stack with the data (UDP loopback, same `recvfrom` discipline on srtla_send) keeps hints ordered tightly against the packets they describe. + +## Wire format + +One 5-byte UDP datagram per request: + +``` +byte 0 : 0xC1 — magic tag (Critical v1) +bytes 1..5 : u32 big-endian — window length in milliseconds +``` + +srtla_send stores `now + window_ms` as the current critical deadline. +`is_critical_now()` returns true while `now < deadline`. + +Overlapping windows extend the deadline monotonically (`fetch_max`). A late datagram referring to an earlier deadline is ignored — it can never shrink an active window. + +## Enabling + +Pass `--priority-bind ADDR:PORT` to `srtla_send`: + +``` +srtla_send --priority-bind 127.0.0.1:7000 \ + --control-socket /tmp/srtla.sock \ + 6000 rec.example.com 5000 /tmp/uplinks +``` + +The sender (belacoder or a custom encoder) binds any local UDP socket, connects to that address, and sends 5-byte datagrams when a keyframe is emitted. Any loopback UDP datagram that doesn't match the magic byte and length is counted as malformed (visible in `get_status` as `critical_malformed_datagrams`). + +## Picking a window length + +A window of 30–80 ms covers a typical keyframe burst at 24–60 fps. Err on the high side — marking a couple of non-keyframe trailing packets critical is harmless; missing the last keyframe packet is not. The default used by belacoder's keyframe probe is 50 ms. + +## Telemetry + +`get_status` exposes two counters: + +```json +{ + "critical_windows_received": 142, + "critical_malformed_datagrams": 0 +} +``` + +`critical_malformed_datagrams > 0` almost always means a mismatched magic byte (version skew) or a sender writing short datagrams. diff --git a/docs/RTT_THRESHOLD_SCHEDULING.md b/docs/RTT_THRESHOLD_SCHEDULING.md deleted file mode 100644 index afb9e08..0000000 --- a/docs/RTT_THRESHOLD_SCHEDULING.md +++ /dev/null @@ -1,113 +0,0 @@ -# RTT-Threshold Scheduling - -RTT-threshold scheduling is a connection selection mode that groups links by their round-trip time (RTT) to reduce packet reordering at the receiver. - -## Problem - -In heterogeneous networks where some links have significantly different latencies (e.g., 50ms cellular + 200ms satellite), capacity-based scheduling sends packets on both links. This causes packet reordering at the receiver because packets sent on the fast link arrive before packets sent earlier on the slow link. - -## Solution - -RTT-threshold scheduling: -1. Finds the minimum RTT among all eligible links -2. Marks links as "fast" if their RTT is within `min_rtt + delta` -3. Strongly prefers fast links, only using slow links when fast links are saturated -4. Applies quality scoring (NAK penalties) within the fast link group - -## Algorithm - -``` -1. Find min_rtt among eligible links -2. threshold = min_rtt + rtt_delta_ms (default 30ms) -3. For each link: - - If RTT <= threshold: mark as "fast" - - If no RTT data: treat as "fast" (eligible) -4. Select best quality-adjusted capacity among fast links -5. If no fast links have capacity: fallback to any eligible link -6. Apply time-based dampening (500ms cooldown between switches) -``` - -## Configuration - -### CLI Arguments - -```bash -# Enable RTT-threshold mode with default delta (30ms) -srtla_send --mode rtt-threshold 6000 host 5000 ./ips.txt - -# Enable with custom delta (50ms) -srtla_send --mode rtt-threshold --rtt-delta-ms 50 6000 host 5000 ./ips.txt -``` - -### Runtime Commands - -```bash -# Switch to RTT-threshold mode -echo "mode rtt-threshold" | socat - UNIX-CONNECT:/tmp/srtla.sock - -# Switch back to enhanced mode -echo "mode enhanced" | socat - UNIX-CONNECT:/tmp/srtla.sock - -# Change RTT delta threshold -echo "rtt-delta 50" | socat - UNIX-CONNECT:/tmp/srtla.sock - -# Check current status -echo "status" | socat - UNIX-CONNECT:/tmp/srtla.sock -``` - -## Parameters - -| Parameter | Default | Description | -|-----------|---------|-------------| -| `rtt_delta_ms` | 30 | Threshold above minimum RTT to be considered "fast" | - -### Choosing RTT Delta - -- **Lower delta (10-20ms)**: More aggressive, only very similar RTT links are "fast" -- **Default delta (30ms)**: Good balance for typical cellular networks -- **Higher delta (50-100ms)**: More inclusive, useful when link RTTs vary moderately - -## Interaction with Other Modes - -| Mode | Behavior | -|------|----------| -| `--mode rtt-threshold` | RTT grouping with quality scoring | -| `--mode rtt-threshold --no-quality` | RTT grouping, pure capacity within fast links | -| `--mode classic` | Classic mode, no RTT grouping | -| `--mode enhanced` | Enhanced mode (default), no RTT grouping | - -## Use Cases - -### Heterogeneous Networks -When combining links with very different latencies (cellular + satellite, WiFi + cellular): -```bash -srtla_send --mode rtt-threshold --rtt-delta-ms 30 6000 host 5000 ./ips.txt -``` - -### Reducing Reordering for Sensitive Applications -For applications that don't handle reordering well: -```bash -srtla_send --mode rtt-threshold --rtt-delta-ms 20 6000 host 5000 ./ips.txt -``` - -### Mixed Quality Links -When fast links may have quality issues, keep quality scoring enabled (default): -```bash -srtla_send --mode rtt-threshold 6000 host 5000 ./ips.txt -``` - -## Tradeoffs - -| Advantage | Disadvantage | -|-----------|--------------| -| Reduced packet reordering | Lower aggregate throughput | -| More predictable latency | Slow links may be underutilized | -| Better for latency-sensitive apps | Fast links may saturate faster | - -## Monitoring - -With `RUST_LOG=debug`, you'll see: -- RTT threshold calculations -- Fast/slow link classifications -- Fallback to slow links when fast are saturated -- Time-based dampening decisions diff --git a/src/config.rs b/src/config.rs index 28710ea..c9df938 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,25 +1,28 @@ //! Runtime configuration for SRTLA sender. //! -//! Manages dynamic settings that can be changed at runtime via stdin or Unix socket. +//! `DynamicConfig` holds atomic state that can be flipped at runtime. The +//! actual wire protocol lives in [`crate::control`] — this module exposes +//! plain getters/setters that the control dispatcher calls into. -#[cfg(unix)] -use std::io::Write; use std::io::{BufRead, BufReader}; -#[cfg(unix)] -use std::os::unix::net::{UnixListener, UnixStream}; use std::sync::Arc; -use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU32, Ordering}; - -#[cfg(unix)] -use tracing::debug; -use tracing::{info, warn}; +use std::sync::atomic::{AtomicBool, AtomicI32, AtomicU8, AtomicU64, Ordering}; +use crate::control::dispatch; use crate::mode::SchedulingMode; +use crate::priority::CriticalWindow; use crate::stats::SharedStats; -/// Default RTT delta threshold in milliseconds. -/// Links within min_rtt + delta are considered "fast" and preferred. -pub const DEFAULT_RTT_DELTA_MS: u32 = 30; +/// In-flight packet backlog at or above which a link is a stall candidate +/// under the `stall_deselect` guard (default on). +pub const STALL_MIN_IN_FLIGHT_PACKETS: i32 = 32; + +/// Staleness window (ms) for a link's last delivery proof (earned-ACK or +/// keepalive-RTT sample) under `stall_deselect`. A stall candidate whose last +/// proof is older than this is treated as stalled. Kept well below +/// `CONN_TIMEOUT` (15 s): deselect is a selection penalty ONLY, never a +/// liveness/timeout shortcut. +pub const STALL_ACK_STALE_MS: u64 = 3000; /// Snapshot of configuration for efficient hot-path access. /// Call `DynamicConfig::snapshot()` once per select iteration to avoid @@ -28,24 +31,37 @@ pub const DEFAULT_RTT_DELTA_MS: u32 = 30; pub struct ConfigSnapshot { pub mode: SchedulingMode, pub quality_enabled: bool, - pub exploration_enabled: bool, - pub rtt_delta_ms: u32, + /// Stalled-link deselect (default ON). On, the selection layer excludes a + /// link whose in-flight backlog is high while its last delivery proof has + /// gone stale, provided at least one healthier link can carry the traffic. + /// Off (`--no-stall-deselect`), selection is byte-for-byte unchanged. + pub stall_deselect: bool, + /// In-flight threshold for `stall_deselect` (default [`STALL_MIN_IN_FLIGHT_PACKETS`]). + pub stall_min_in_flight: i32, + /// Delivery-proof staleness window in ms for `stall_deselect` + /// (default [`STALL_ACK_STALE_MS`]). + pub stall_ack_stale_ms: u64, +} + +impl Default for ConfigSnapshot { + fn default() -> Self { + Self { + mode: SchedulingMode::Enhanced, + quality_enabled: true, + stall_deselect: true, + stall_min_in_flight: STALL_MIN_IN_FLIGHT_PACKETS, + stall_ack_stale_ms: STALL_ACK_STALE_MS, + } + } } impl ConfigSnapshot { /// Check if quality scoring is effective for the current mode. - /// Quality scoring only applies to enhanced and rtt-threshold modes. + /// Quality scoring only applies to enhanced mode. #[inline] pub fn effective_quality_enabled(&self) -> bool { self.quality_enabled && !self.mode.is_classic() } - - /// Check if exploration is effective for the current mode. - /// Exploration only applies to enhanced mode. - #[inline] - pub fn effective_exploration_enabled(&self) -> bool { - self.exploration_enabled && self.mode.is_enhanced() - } } /// Dynamic configuration that can be modified at runtime. @@ -54,8 +70,9 @@ impl ConfigSnapshot { pub struct DynamicConfig { mode: Arc, quality_enabled: Arc, - exploration_enabled: Arc, - rtt_delta_ms: Arc, + stall_deselect: Arc, + stall_min_in_flight: Arc, + stall_ack_stale_ms: Arc, } impl Default for DynamicConfig { @@ -69,8 +86,9 @@ impl DynamicConfig { Self { mode: Arc::new(AtomicU8::new(SchedulingMode::Enhanced.as_u8())), quality_enabled: Arc::new(AtomicBool::new(true)), - exploration_enabled: Arc::new(AtomicBool::new(false)), - rtt_delta_ms: Arc::new(AtomicU32::new(DEFAULT_RTT_DELTA_MS)), + stall_deselect: Arc::new(AtomicBool::new(true)), + stall_min_in_flight: Arc::new(AtomicI32::new(STALL_MIN_IN_FLIGHT_PACKETS)), + stall_ack_stale_ms: Arc::new(AtomicU64::new(STALL_ACK_STALE_MS)), } } @@ -78,14 +96,16 @@ impl DynamicConfig { pub fn from_cli( mode: SchedulingMode, no_quality: bool, - exploration: bool, - rtt_delta_ms: u32, + no_stall_deselect: bool, + stall_min_in_flight: i32, + stall_ack_stale_ms: u64, ) -> Self { Self { mode: Arc::new(AtomicU8::new(mode.as_u8())), quality_enabled: Arc::new(AtomicBool::new(!no_quality)), - exploration_enabled: Arc::new(AtomicBool::new(exploration)), - rtt_delta_ms: Arc::new(AtomicU32::new(rtt_delta_ms)), + stall_deselect: Arc::new(AtomicBool::new(!no_stall_deselect)), + stall_min_in_flight: Arc::new(AtomicI32::new(stall_min_in_flight)), + stall_ack_stale_ms: Arc::new(AtomicU64::new(stall_ack_stale_ms)), } } @@ -97,8 +117,9 @@ impl DynamicConfig { ConfigSnapshot { mode: SchedulingMode::from_u8(self.mode.load(Ordering::Relaxed)), quality_enabled: self.quality_enabled.load(Ordering::Relaxed), - exploration_enabled: self.exploration_enabled.load(Ordering::Relaxed), - rtt_delta_ms: self.rtt_delta_ms.load(Ordering::Relaxed), + stall_deselect: self.stall_deselect.load(Ordering::Relaxed), + stall_min_in_flight: self.stall_min_in_flight.load(Ordering::Relaxed), + stall_ack_stale_ms: self.stall_ack_stale_ms.load(Ordering::Relaxed), } } @@ -118,271 +139,31 @@ impl DynamicConfig { self.quality_enabled.store(enabled, Ordering::Relaxed); } - /// Set whether exploration is enabled. - pub fn set_exploration_enabled(&self, enabled: bool) { - self.exploration_enabled.store(enabled, Ordering::Relaxed); - } - - /// Set the RTT delta threshold in milliseconds. - pub fn set_rtt_delta_ms(&self, delta: u32) { - self.rtt_delta_ms.store(delta, Ordering::Relaxed); + /// Toggle the stalled-link deselect guard at runtime. + pub fn set_stall_deselect(&self, enabled: bool) { + self.stall_deselect.store(enabled, Ordering::Relaxed); } } -pub fn spawn_config_listener( +/// Spawn the stdin command reader in a std::thread. Stdin on Linux +/// doesn't have a clean async story — easier to stay blocking here. +/// The Unix control socket, which actually needs subscriptions, lives +/// in an async tokio task launched by main instead. +pub fn spawn_stdin_listener( config: DynamicConfig, - socket_path: Option, stats: SharedStats, + critical_window: CriticalWindow, ) { - if let Some(sock_path) = socket_path { - // Socket path specified: use Unix socket on Unix, fallback to stdin on other platforms - #[cfg(unix)] - { - let config_clone = config.clone(); - let stats_clone = stats.clone(); - std::thread::spawn(move || { - unix_socket_loop(&config_clone, &sock_path, &stats_clone); - }); - } - #[cfg(not(unix))] - { - // Unix sockets not available; fall back to stdin listener - let _ = sock_path; // suppress unused warning - let _ = stats; // suppress unused warning - let config_clone = config.clone(); - std::thread::spawn(move || { - let stdin = std::io::stdin(); - let reader = BufReader::new(stdin); - for cmd in reader.lines().map_while(Result::ok) { - apply_cmd(&config_clone, cmd.trim(), None); - } - }); - } - } else { - // No socket path: use stdin listener (backward compatibility) - let config_clone = config.clone(); - std::thread::spawn(move || { - let stdin = std::io::stdin(); - let reader = BufReader::new(stdin); - for cmd in reader.lines().map_while(Result::ok) { - apply_cmd(&config_clone, cmd.trim(), None); + std::thread::spawn(move || { + let reader = BufReader::new(std::io::stdin()); + for line in reader.lines().map_while(Result::ok) { + if let Some(resp) = dispatch(&config, Some(&stats), Some(&critical_window), line.trim()) + { + // Responses on stdin just go to stdout so scripts can pipe. + println!("{}", resp.to_json()); } - }); - } -} - -/// Response from apply_cmd that can be sent back to the client. -#[allow(dead_code)] // Json variant's inner value is read in #[cfg(unix)] code -pub enum CmdResponse { - /// No response needed (command logged via tracing) - None, - /// JSON response to send back - Json(String), -} - -/// Apply a runtime command to the configuration. -/// -/// Commands: -/// - `mode classic|enhanced|rtt-threshold` - switch scheduling mode -/// - `quality on|off` - toggle quality scoring -/// - `explore on|off` - toggle exploration -/// - `rtt-delta ` - set RTT delta threshold -/// - `status` - show current configuration -/// - `stats` - get per-link telemetry as JSON -pub fn apply_cmd(config: &DynamicConfig, cmd: &str, stats: Option<&SharedStats>) -> CmdResponse { - let cmd = cmd.trim(); - if cmd.is_empty() { - return CmdResponse::None; - } - - let parts: Vec<&str> = cmd.split_whitespace().collect(); - if parts.is_empty() { - return CmdResponse::None; - } - - match parts[0] { - "mode" => { - if parts.len() != 2 { - warn!("usage: mode classic|enhanced|rtt-threshold|edpf"); - return CmdResponse::None; - } - match parts[1] { - "classic" => { - config.set_mode(SchedulingMode::Classic); - info!("mode: classic"); - } - "enhanced" => { - config.set_mode(SchedulingMode::Enhanced); - info!("mode: enhanced"); - } - "rtt-threshold" => { - config.set_mode(SchedulingMode::RttThreshold); - info!("mode: rtt-threshold"); - } - "edpf" => { - config.set_mode(SchedulingMode::Edpf); - info!("mode: edpf"); - } - other => { - warn!( - "unknown mode '{}': use classic, enhanced, rtt-threshold, or edpf", - other - ); - } - } - } - - "quality" => { - if parts.len() != 2 { - warn!("usage: quality on|off"); - return CmdResponse::None; - } - match parts[1] { - "on" => { - config.set_quality_enabled(true); - info!("quality: on"); - } - "off" => { - config.set_quality_enabled(false); - info!("quality: off"); - } - other => { - warn!("invalid value '{}': use on or off", other); - } - } - } - - "explore" => { - if parts.len() != 2 { - warn!("usage: explore on|off"); - return CmdResponse::None; - } - match parts[1] { - "on" => { - config.set_exploration_enabled(true); - info!("explore: on"); - } - "off" => { - config.set_exploration_enabled(false); - info!("explore: off"); - } - other => { - warn!("invalid value '{}': use on or off", other); - } - } - } - - "rtt-delta" => { - if parts.len() != 2 { - warn!("usage: rtt-delta "); - return CmdResponse::None; - } - match parts[1].parse::() { - Ok(delta) => { - config.set_rtt_delta_ms(delta); - info!("rtt-delta: {}ms", delta); - } - Err(_) => { - warn!("invalid rtt-delta value: {}", parts[1]); - } - } - } - - "status" => { - let snap = config.snapshot(); - info!("mode: {}", snap.mode); - info!( - " quality: {}", - if snap.quality_enabled { "on" } else { "off" } - ); - info!( - " explore: {}", - if snap.exploration_enabled { - "on" - } else { - "off" - } - ); - info!(" rtt-delta: {}ms", snap.rtt_delta_ms); - } - - "stats" => { - if let Some(stats) = stats { - let json = stats.to_json(); - info!("stats requested, returning {} bytes", json.len()); - return CmdResponse::Json(json); - } else { - warn!("stats not available (no stats provider)"); - } - } - - other => { - warn!("unknown command: {}", other); } - } - - CmdResponse::None -} - -#[cfg(unix)] -fn unix_socket_loop(config: &DynamicConfig, socket_path: &str, stats: &SharedStats) { - // Remove existing socket file if it exists - let _ = std::fs::remove_file(socket_path); - - let listener = match UnixListener::bind(socket_path) { - Ok(l) => l, - Err(e) => { - warn!("failed to bind unix socket {}: {}", socket_path, e); - return; - } - }; - - info!("unix socket listening at: {}", socket_path); - - for stream in listener.incoming() { - match stream { - Ok(stream) => { - let config_clone = config.clone(); - let stats_clone = stats.clone(); - std::thread::spawn(move || { - handle_unix_client(config_clone, stream, stats_clone); - }); - } - Err(e) => { - debug!("unix socket accept error: {}", e); - } - } - } -} - -#[cfg(unix)] -fn handle_unix_client(config: DynamicConfig, mut stream: UnixStream, stats: SharedStats) { - // Clone stream for reading (we need separate read/write handles) - let read_stream = match stream.try_clone() { - Ok(s) => s, - Err(_) => return, - }; - let reader = BufReader::new(read_stream); - - for line in reader.lines() { - match line { - Ok(cmd) => { - let response = apply_cmd(&config, cmd.trim(), Some(&stats)); - if let CmdResponse::Json(json) = response { - // Write JSON response followed by newline - if let Err(e) = writeln!(stream, "{}", json) { - debug!("failed to write response: {}", e); - break; - } - if let Err(e) = stream.flush() { - debug!("failed to flush response: {}", e); - break; - } - } - } - Err(_) => break, - } - } + }); } #[cfg(test)] @@ -395,98 +176,40 @@ mod tests { let snap = config.snapshot(); assert_eq!(snap.mode, SchedulingMode::Enhanced); assert!(snap.quality_enabled); - assert!(!snap.exploration_enabled); - assert_eq!(snap.rtt_delta_ms, DEFAULT_RTT_DELTA_MS); } #[test] fn test_config_from_cli() { - let config = DynamicConfig::from_cli(SchedulingMode::Classic, true, true, 50); + let config = DynamicConfig::from_cli( + SchedulingMode::Classic, + true, + false, + STALL_MIN_IN_FLIGHT_PACKETS, + STALL_ACK_STALE_MS, + ); let snap = config.snapshot(); assert_eq!(snap.mode, SchedulingMode::Classic); assert!(!snap.quality_enabled); // no_quality=true means disabled - assert!(snap.exploration_enabled); - assert_eq!(snap.rtt_delta_ms, 50); - } - - #[test] - fn test_mode_commands() { - let config = DynamicConfig::new(); - - apply_cmd(&config, "mode classic", None); - assert_eq!(config.mode(), SchedulingMode::Classic); - - apply_cmd(&config, "mode enhanced", None); - assert_eq!(config.mode(), SchedulingMode::Enhanced); - - apply_cmd(&config, "mode rtt-threshold", None); - assert_eq!(config.mode(), SchedulingMode::RttThreshold); - } - - #[test] - fn test_quality_commands() { - let config = DynamicConfig::new(); - - apply_cmd(&config, "quality off", None); - assert!(!config.snapshot().quality_enabled); - - apply_cmd(&config, "quality on", None); - assert!(config.snapshot().quality_enabled); - } - - #[test] - fn test_exploration_commands() { - let config = DynamicConfig::new(); - - apply_cmd(&config, "explore on", None); - assert!(config.snapshot().exploration_enabled); - - apply_cmd(&config, "explore off", None); - assert!(!config.snapshot().exploration_enabled); - } - - #[test] - fn test_rtt_delta_commands() { - let config = DynamicConfig::new(); - - apply_cmd(&config, "rtt-delta 50", None); - assert_eq!(config.snapshot().rtt_delta_ms, 50); - - apply_cmd(&config, "rtt-delta 100", None); - assert_eq!(config.snapshot().rtt_delta_ms, 100); + assert!(snap.stall_deselect); // on by default (no_stall_deselect=false) } #[test] fn test_effective_quality() { - // Classic mode - quality never effective + // Classic mode - quality scoring never effective let snap = ConfigSnapshot { mode: SchedulingMode::Classic, quality_enabled: true, - exploration_enabled: true, - rtt_delta_ms: 30, + ..ConfigSnapshot::default() }; assert!(!snap.effective_quality_enabled()); - assert!(!snap.effective_exploration_enabled()); - // Enhanced mode - both can be effective + // Enhanced mode - effective let snap = ConfigSnapshot { mode: SchedulingMode::Enhanced, quality_enabled: true, - exploration_enabled: true, - rtt_delta_ms: 30, - }; - assert!(snap.effective_quality_enabled()); - assert!(snap.effective_exploration_enabled()); - - // RTT-threshold mode - quality effective, exploration not - let snap = ConfigSnapshot { - mode: SchedulingMode::RttThreshold, - quality_enabled: true, - exploration_enabled: true, - rtt_delta_ms: 30, + ..ConfigSnapshot::default() }; assert!(snap.effective_quality_enabled()); - assert!(!snap.effective_exploration_enabled()); } #[test] @@ -500,7 +223,6 @@ mod tests { for _ in 0..100 { config_clone.set_mode(SchedulingMode::Classic); config_clone.set_mode(SchedulingMode::Enhanced); - config_clone.set_mode(SchedulingMode::RttThreshold); } }); diff --git a/src/connection/ack_nak.rs b/src/connection/ack_nak.rs index 8528130..b99e30b 100644 --- a/src/connection/ack_nak.rs +++ b/src/connection/ack_nak.rs @@ -2,7 +2,6 @@ use std::cmp::min; use super::SrtlaConnection; use crate::protocol::*; -use crate::utils::now_ms; impl SrtlaConnection { /// Register a packet as in-flight. O(1) insert. @@ -18,7 +17,7 @@ impl SrtlaConnection { /// - Tracks highest_acked_seq to skip already-processed ACKs /// - Only removes packets in the range (highest_acked_seq, ack] /// - O(k) where k is packets in range, not O(n) for entire log - pub fn handle_srt_ack(&mut self, ack: i32) { + pub fn handle_srt_ack(&mut self, ack: i32, now_ms: u64) { // Skip if this ACK doesn't advance our highest acked sequence // This handles duplicate ACKs and out-of-order ACKs efficiently if ack <= self.highest_acked_seq { @@ -52,33 +51,39 @@ impl SrtlaConnection { // Update RTT estimate if we found the acked packet if let Some(sent_ms) = ack_send_time_ms { - let now = now_ms(); + let now = now_ms; let rtt = now.saturating_sub(sent_ms); if rtt > 0 && rtt <= 10_000 { - self.rtt.update_estimate(rtt); + self.rtt.update_estimate(rtt, now); } } } /// Handle NAK for a specific sequence. O(1) remove. #[inline] - pub fn handle_nak(&mut self, seq: i32) -> bool { + pub fn handle_nak(&mut self, seq: i32, now_ms: u64) -> bool { let found = self.packet_log.remove(&seq).is_some(); if found { self.in_flight_packets = self.packet_log.len() as i32; self.congestion - .handle_nak(&mut self.window, seq, &self.label); + .handle_nak(&mut self.window, seq, &self.label, now_ms); } found } /// Handle SRTLA ACK for a specific sequence. O(1) remove. #[inline] - pub fn handle_srtla_ack_specific(&mut self, seq: i32, classic_mode: bool) -> bool { + pub fn handle_srtla_ack_specific(&mut self, seq: i32, classic_mode: bool, now_ms: u64) -> bool { let found = self.packet_log.remove(&seq).is_some(); if found { self.in_flight_packets = self.packet_log.len() as i32; + // Delivery proof for `stall_deselect`: this link OWNED the acked seq, + // the strongest per-link proof it is still moving data. Stamped here + // and at the keepalive-RTT site only (see `packet_io.rs`), never on + // generic inbound bytes, so a stalled-but-echoing link stays stale. + self.last_ack_or_rtt_sample_ms = now_ms; + if classic_mode { self.congestion.handle_srtla_ack_specific_classic( &mut self.window, @@ -91,6 +96,7 @@ impl SrtlaConnection { &mut self.window, self.in_flight_packets, &self.label, + now_ms, ); } } diff --git a/src/connection/batch_recv.rs b/src/connection/batch_recv.rs index c4d1e56..e980fc7 100644 --- a/src/connection/batch_recv.rs +++ b/src/connection/batch_recv.rs @@ -15,6 +15,13 @@ use crate::protocol::MTU; #[cfg(target_os = "linux")] pub const BATCH_RECV_SIZE: usize = 32; +/// Maximum datagrams per `sendmmsg` call. Matches the largest batch the +/// send-side regime will accumulate (`BATCH_SIZE_HIGH_LOAD`), so a full +/// batch always leaves in a single syscall. Defined on every platform, since +/// `BatchSender::flush` chunks by it before calling `send_batch` and must +/// compile identically on the non-Linux fallback path. +pub const BATCH_SEND_SIZE: usize = 32; + // ============================================================================ // Linux implementation with recvmmsg // ============================================================================ @@ -30,11 +37,31 @@ mod unix_impl { use tokio::io::Interest; use tokio::io::unix::AsyncFd; - use super::{BATCH_RECV_SIZE, MTU}; + use super::{BATCH_RECV_SIZE, BATCH_SEND_SIZE, MTU}; const SOCKADDR_STORAGE_LENGTH: libc::socklen_t = std::mem::size_of::() as libc::socklen_t; + /// What the read loop should do after `recvmmsg` returns an error. + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + enum RecvAction { + /// EINTR: interrupted by a signal — re-issue the syscall. + Retry, + /// EAGAIN/EWOULDBLOCK: no datagram ready — wait for readiness. + WouldBlock, + /// Any other errno — propagate to the caller. + Hard, + } + + /// Classify a `recvmmsg` error. Pure so it is unit-testable with no syscall. + fn recv_retry_action(err: &std::io::Error) -> RecvAction { + match err.kind() { + ErrorKind::Interrupted => RecvAction::Retry, + ErrorKind::WouldBlock => RecvAction::WouldBlock, + _ => RecvAction::Hard, + } + } + /// Async UDP socket with batch receive support via `recvmmsg`. /// /// This wraps a `socket2::Socket` in tokio's `AsyncFd` for proper async @@ -70,11 +97,17 @@ mod unix_impl { match buffer.recvmmsg(self.as_raw_fd()) { Ok(count) => return Poll::Ready(Ok(count)), - Err(ref e) if e.kind() == ErrorKind::WouldBlock => { - guard.clear_ready(); - continue; - } - Err(e) => return Poll::Ready(Err(e)), + Err(e) => match recv_retry_action(&e) { + // EINTR: re-issue without dropping readiness (the fd is + // still ready, so the next poll returns immediately). A + // signal — e.g. our own SIGHUP — must not kill the reader. + RecvAction::Retry => continue, + RecvAction::WouldBlock => { + guard.clear_ready(); + continue; + } + RecvAction::Hard => return Poll::Ready(Err(e)), + }, } } } @@ -111,6 +144,78 @@ mod unix_impl { self.inner.get_ref().send(buf) } + /// Send several datagrams to the connected peer in one `sendmmsg` syscall. + /// + /// Returns the number of datagrams the kernel accepted, which may be + /// fewer than requested: `sendmmsg` reports a short send rather than + /// blocking once the socket buffer fills. The caller must resend the + /// remainder (see `BatchSender::flush`). + pub async fn send_batch(&self, bufs: &[&[u8]]) -> std::io::Result { + if bufs.is_empty() { + return Ok(0); + } + loop { + let mut guard = self.inner.ready(Interest::WRITABLE).await?; + + match self.try_send_batch(bufs) { + Ok(n) => return Ok(n), + Err(ref e) if e.kind() == ErrorKind::WouldBlock => { + guard.clear_ready(); + continue; + } + // A signal can interrupt the syscall before any datagram is + // queued; that is not a send failure, so retry rather than + // tearing the link down. + Err(ref e) if e.kind() == ErrorKind::Interrupted => continue, + Err(e) => return Err(e), + } + } + } + + /// Non-blocking `sendmmsg`. Sends at most [`BATCH_SEND_SIZE`] datagrams. + pub fn try_send_batch(&self, bufs: &[&[u8]]) -> std::io::Result { + let n = bufs.len().min(BATCH_SEND_SIZE); + if n == 0 { + return Ok(0); + } + + // SAFETY: `mmsghdr` and `iovec` are plain C structs whose all-zero + // bit pattern is a valid (empty) message; every field we rely on is + // overwritten below before the syscall reads it. + let mut iov: [libc::iovec; BATCH_SEND_SIZE] = unsafe { std::mem::zeroed() }; + let mut msgs: [libc::mmsghdr; BATCH_SEND_SIZE] = unsafe { std::mem::zeroed() }; + + for i in 0..n { + iov[i] = libc::iovec { + // sendmmsg only reads through this pointer; the cast to + // *mut is required by the C signature, not by us. + iov_base: bufs[i].as_ptr() as *mut libc::c_void, + iov_len: bufs[i].len(), + }; + // The socket is connected, so the destination is implicit and + // msg_name stays null. + msgs[i].msg_hdr.msg_iov = std::ptr::addr_of_mut!(iov[i]); + msgs[i].msg_hdr.msg_iovlen = 1; + } + + // SAFETY: `msgs[..n]` is initialised above and each `msg_iov` points + // at the matching live entry of `iov`, which outlives the call. The + // buffers in `bufs` are borrowed for the duration of the call. + let ret = unsafe { + libc::sendmmsg( + self.as_raw_fd(), + msgs.as_mut_ptr(), + n as libc::c_uint, + 0, // no flags: match the semantics of the old per-packet send() + ) + }; + + if ret < 0 { + return Err(std::io::Error::last_os_error()); + } + Ok(ret as usize) + } + /// Try to receive data without blocking. /// /// Returns WouldBlock if no data is available. @@ -251,6 +356,16 @@ mod unix_impl { pub fn is_empty(&self) -> bool { self.nrecv == 0 } + + /// Test seam: forge `nrecv` "received" packets and set message `idx`'s + /// reported `msg_len`, so a test can feed an out-of-range length without a + /// live socket and prove the iterator clamps the exposed slice to MTU. + #[cfg(test)] + pub fn test_forge_packet(&mut self, idx: usize, msg_len: u32, nrecv: u32) { + self.mmsghdr[idx].msg_hdr.msg_namelen = SOCKADDR_STORAGE_LENGTH; + self.mmsghdr[idx].msg_len = msg_len; + self.nrecv = nrecv; + } } /// Iterator over received packets in a RecvMmsgBuffer. @@ -277,7 +392,11 @@ mod unix_impl { // Convert sockaddr_storage to SocketAddr let addr = sockaddr_storage_to_socket_addr(storage); - let data = &self.buffer.buffers[idx][..msg.msg_len as usize]; + // The per-message buffer is exactly MTU bytes. No MSG_TRUNC is + // requested so msg_len is capped at MTU in practice, but clamp + // defensively so a mis-reported length can never index past it. + let len = (msg.msg_len as usize).min(MTU); + let data = &self.buffer.buffers[idx][..len]; Some((addr, data)) } } @@ -307,6 +426,94 @@ mod unix_impl { } } } + + #[cfg(test)] + mod tests { + use std::io::{Error, ErrorKind}; + use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr, SocketAddrV4, SocketAddrV6}; + + use super::{ + RecvAction, RecvMmsgBuffer, recv_retry_action, sockaddr_storage_to_socket_addr, + }; + use crate::protocol::MTU; + + // Exercises the unsafe sockaddr_storage → SocketAddr pointer casts with + // real AF_INET/AF_INET6 payloads (the iterator test only ever feeds + // zeroed storage, i.e. the None branch). Runs under miri in CI to vet + // the casts and the big-endian field decodes. + #[test] + fn sockaddr_storage_roundtrip() { + let mut storage: libc::sockaddr_storage = unsafe { std::mem::zeroed() }; + let v4 = libc::sockaddr_in { + sin_family: libc::AF_INET as libc::sa_family_t, + sin_port: 8000u16.to_be(), + sin_addr: libc::in_addr { + s_addr: u32::from(Ipv4Addr::new(192, 168, 1, 2)).to_be(), + }, + sin_zero: [0; 8], + }; + unsafe { std::ptr::write(&mut storage as *mut _ as *mut libc::sockaddr_in, v4) }; + assert_eq!( + sockaddr_storage_to_socket_addr(&storage), + Some(SocketAddr::V4(SocketAddrV4::new( + Ipv4Addr::new(192, 168, 1, 2), + 8000 + ))), + ); + + let mut storage: libc::sockaddr_storage = unsafe { std::mem::zeroed() }; + let v6 = libc::sockaddr_in6 { + sin6_family: libc::AF_INET6 as libc::sa_family_t, + sin6_port: 9000u16.to_be(), + sin6_flowinfo: 7, + sin6_addr: libc::in6_addr { + s6_addr: Ipv6Addr::LOCALHOST.octets(), + }, + sin6_scope_id: 3, + }; + unsafe { std::ptr::write(&mut storage as *mut _ as *mut libc::sockaddr_in6, v6) }; + assert_eq!( + sockaddr_storage_to_socket_addr(&storage), + Some(SocketAddr::V6(SocketAddrV6::new( + Ipv6Addr::LOCALHOST, + 9000, + 7, + 3 + ))), + ); + + let mut storage: libc::sockaddr_storage = unsafe { std::mem::zeroed() }; + storage.ss_family = libc::AF_UNIX as libc::sa_family_t; + assert_eq!(sockaddr_storage_to_socket_addr(&storage), None); + } + + #[test] + fn iter_clamps_oversized_msg_len_to_mtu() { + let mut buffer = RecvMmsgBuffer::new(); + buffer.test_forge_packet(0, (MTU as u32) * 4, 1); + + let mut iter = buffer.iter(); + let (_addr, data) = iter.next().expect("one forged packet"); + assert_eq!(data.len(), MTU, "oversized msg_len must clamp to MTU"); + assert!(iter.next().is_none(), "only one packet was forged"); + } + + #[test] + fn recv_retry_action_classifies_errors() { + assert_eq!( + recv_retry_action(&Error::from(ErrorKind::Interrupted)), + RecvAction::Retry, + ); + assert_eq!( + recv_retry_action(&Error::from(ErrorKind::WouldBlock)), + RecvAction::WouldBlock, + ); + assert_eq!( + recv_retry_action(&Error::from_raw_os_error(libc::ECONNREFUSED)), + RecvAction::Hard, + ); + } + } } // ============================================================================ @@ -362,6 +569,28 @@ mod fallback_impl { self.inner.send(buf).await } + /// Send several datagrams to the connected peer. + /// + /// There is no `sendmmsg` off Linux, so this sends one at a time and + /// exists only to keep [`BatchSender::flush`] platform-agnostic. It + /// reports how many datagrams were accepted before the first error, so + /// the caller's resend bookkeeping is identical on both paths. + pub async fn send_batch(&self, bufs: &[&[u8]]) -> std::io::Result { + let mut sent = 0; + for buf in bufs { + match self.inner.send(buf).await { + Ok(_) => sent += 1, + // Mirror `sendmmsg`: once at least one datagram is away, a + // failure is reported as a short send, not an error. The + // caller retries the remainder and will surface the error + // then if it persists. + Err(_) if sent > 0 => break, + Err(e) => return Err(e), + } + } + Ok(sent) + } + /// Try to send data without blocking. #[allow(dead_code)] pub fn try_send(&self, buf: &[u8]) -> std::io::Result { diff --git a/src/connection/batch_send.rs b/src/connection/batch_send.rs index c284192..ec55230 100644 --- a/src/connection/batch_send.rs +++ b/src/connection/batch_send.rs @@ -1,13 +1,37 @@ //! Batch send optimization for SRTLA connections //! //! This module implements packet batching inspired by Moblin's implementation: -//! - Buffers up to 16 data packets before sending +//! - Buffers up to 16 data packets before sending (default Normal regime) //! - Flushes on 15ms timer to ensure low latency -//! - Reduces syscall overhead significantly under high load +//! - Flushes each batch with a single `sendmmsg` (one syscall per batch) //! //! At 10 Mbps with ~1300 byte packets: //! - Without batching: ~960 syscalls/second per connection //! - With batching: ~60-67 batch sends/second per connection (~15x reduction) +//! +//! The syscall saving is the whole point of the queue, and until `sendmmsg` +//! landed it did not exist: `flush` looped over the queue issuing one `send` +//! per packet, so batching bought nothing but added up to `FLUSH_INTERVAL_MS` +//! of latency. Anything that trades scheduling quality for "batch integrity" +//! (holding the scheduler on one link so batches stay contiguous) is therefore +//! paying for a benefit that only exists while this stays a real batch syscall. +//! +//! ## Adaptive batch regimes +//! +//! Three regimes drive the size threshold based on observed link load: +//! +//! - `LowActivity` (≤ 500 kbps): batch=4. Less buffering per tick on +//! idle links so a sudden burst flushes promptly. +//! - `Normal` (default, 500 kbps – 5 Mbps): batch=16. The proven +//! Moblin sweet spot. +//! - `HighLoad` (> 5 Mbps): batch=32. Bigger batches amortise socket +//! syscalls better; future sendmmsg work benefits more here. +//! +//! Flush interval stays at 15 ms across regimes — going longer on +//! idle links would add latency when traffic returns, going shorter +//! under load would defeat the syscall-amortisation we batch for. +//! The `set_regime` setter is called from `housekeeping` based on each +//! connection's `current_bitrate_bps` snapshot. use std::sync::Arc; @@ -15,14 +39,77 @@ use smallvec::SmallVec; use tokio::time::Instant; use tracing::debug; -use super::batch_recv::BatchUdpSocket; +use super::batch_recv::{BATCH_SEND_SIZE, BatchUdpSocket}; + +/// Bitrate above which a connection is treated as high-load. +pub const HIGH_LOAD_THRESHOLD_BPS: f64 = 5_000_000.0; +/// Bitrate at or below which a connection is treated as low-activity. +pub const LOW_ACTIVITY_THRESHOLD_BPS: f64 = 500_000.0; -/// Maximum number of packets to buffer before flushing (Moblin uses 15+1=16) -pub const BATCH_SIZE_THRESHOLD: usize = 16; +/// Batch-size thresholds per regime. We don't vary the flush interval +/// because going longer on idle links would add latency on traffic +/// resumption and going shorter under load would erase the syscall +/// amortisation we batch for. +const BATCH_SIZE_LOW_ACTIVITY: usize = 4; +const BATCH_SIZE_NORMAL: usize = 16; +const BATCH_SIZE_HIGH_LOAD: usize = 32; + +/// Default size threshold. Public so existing tests can reference it +/// and to make the steady-state value easy to find. +#[allow(dead_code)] +pub const BATCH_SIZE_THRESHOLD: usize = BATCH_SIZE_NORMAL; /// Maximum time in milliseconds between flushes (Moblin uses 15ms) const FLUSH_INTERVAL_MS: u64 = 15; +/// Adaptive batch-size regime. Driven by observed per-link bitrate. +#[derive(Copy, Clone, Debug, Default, Eq, PartialEq)] +pub enum BatchRegime { + /// Quiet link (≤ 500 kbps). Smaller batches keep latency low when + /// traffic resumes. + LowActivity, + /// Normal cellular IRL operating range. + #[default] + Normal, + /// Heavy stream (> 5 Mbps). Bigger batches reduce syscall pressure. + HighLoad, +} + +impl BatchRegime { + /// Stable string used in stats / telemetry. Public; called from + /// future stats-export work even when nothing in this crate's + /// own tree consumes it. + #[allow(dead_code)] + pub fn as_str(self) -> &'static str { + match self { + BatchRegime::LowActivity => "low_activity", + BatchRegime::Normal => "normal", + BatchRegime::HighLoad => "high_load", + } + } + + /// Pick the regime for a given bitrate (bits per second). Hysteresis + /// is applied at the call site (housekeeping uses [`from_bps`] as a + /// debounced selector — see `connection::SrtlaConnection::recompute_batch_regime`). + pub fn from_bps(bps: f64) -> Self { + if bps > HIGH_LOAD_THRESHOLD_BPS { + BatchRegime::HighLoad + } else if bps <= LOW_ACTIVITY_THRESHOLD_BPS { + BatchRegime::LowActivity + } else { + BatchRegime::Normal + } + } + + fn batch_size(self) -> usize { + match self { + BatchRegime::LowActivity => BATCH_SIZE_LOW_ACTIVITY, + BatchRegime::Normal => BATCH_SIZE_NORMAL, + BatchRegime::HighLoad => BATCH_SIZE_HIGH_LOAD, + } + } +} + /// Batch sender that queues packets and flushes them efficiently #[derive(Debug)] pub struct BatchSender { @@ -37,6 +124,10 @@ pub struct BatchSender { /// Last time the queue was flushed last_flush_time: Instant, + + /// Current batch regime. Updated by housekeeping when the + /// connection's bitrate crosses a threshold. + regime: BatchRegime, } impl Default for BatchSender { @@ -49,10 +140,11 @@ impl BatchSender { /// Create a new batch sender pub fn new() -> Self { Self { - queue: Vec::with_capacity(BATCH_SIZE_THRESHOLD), - sequences: Vec::with_capacity(BATCH_SIZE_THRESHOLD), - queue_times: Vec::with_capacity(BATCH_SIZE_THRESHOLD), + queue: Vec::with_capacity(BATCH_SIZE_HIGH_LOAD), + sequences: Vec::with_capacity(BATCH_SIZE_HIGH_LOAD), + queue_times: Vec::with_capacity(BATCH_SIZE_HIGH_LOAD), last_flush_time: Instant::now(), + regime: BatchRegime::default(), } } @@ -65,7 +157,21 @@ impl BatchSender { self.sequences.push(seq); self.queue_times.push(current_time_ms); - self.queue.len() >= BATCH_SIZE_THRESHOLD + self.queue.len() >= self.regime.batch_size() + } + + /// Update the batch regime. Called from housekeeping each tick + /// based on the connection's observed bitrate. No effect when the + /// regime hasn't actually changed. + pub fn set_regime(&mut self, regime: BatchRegime) { + self.regime = regime; + } + + /// Current batch regime (for telemetry). + #[allow(dead_code)] + #[inline] + pub fn regime(&self) -> BatchRegime { + self.regime } /// Check if the queue needs flushing based on time @@ -104,16 +210,42 @@ impl BatchSender { let packet_count = self.queue.len(); let mut sent_count = 0; - // Send all packets - // TODO: On Linux, could use sendmmsg for even better performance - for packet in &self.queue { - match socket.send(packet).await { - Ok(_) => sent_count += 1, + // One `sendmmsg` per BATCH_SEND_SIZE datagrams. The kernel may accept + // fewer than offered (short send) once the socket buffer fills, so loop + // until the queue is drained rather than assuming a full batch left. + while sent_count < packet_count { + let take = (packet_count - sent_count).min(BATCH_SEND_SIZE); + + // Scoped so the borrow of `self.queue` ends before the error path + // below mutates it. + let result = { + let mut bufs: SmallVec<&[u8], BATCH_SEND_SIZE> = SmallVec::new(); + for packet in &self.queue[sent_count..sent_count + take] { + bufs.push(&packet[..]); + } + socket.send_batch(&bufs).await + }; + + match result { + // Ok(0) would spin forever; treat a no-progress send as an error + // so the link is retried rather than livelocked. + Ok(0) => { + self.queue.drain(..sent_count); + self.sequences.drain(..sent_count); + self.queue_times.drain(..sent_count); + self.last_flush_time = Instant::now(); + return Err(std::io::Error::new( + std::io::ErrorKind::WriteZero, + "sendmmsg accepted no datagrams", + )); + } + Ok(n) => sent_count += n, Err(e) => { // Partial failure: remove already-sent packets to avoid duplicates self.queue.drain(..sent_count); self.sequences.drain(..sent_count); self.queue_times.drain(..sent_count); + self.last_flush_time = Instant::now(); return Err(e); } } @@ -194,4 +326,54 @@ mod tests { assert!(sender.queue.is_empty()); } + + #[test] + fn regime_from_bps_thresholds() { + assert_eq!( + BatchRegime::from_bps(100_000.0), + BatchRegime::LowActivity, + "well below 500 kbps → LowActivity" + ); + assert_eq!( + BatchRegime::from_bps(LOW_ACTIVITY_THRESHOLD_BPS), + BatchRegime::LowActivity, + "exactly at the threshold stays LowActivity" + ); + assert_eq!( + BatchRegime::from_bps(2_000_000.0), + BatchRegime::Normal, + "between thresholds → Normal" + ); + assert_eq!( + BatchRegime::from_bps(HIGH_LOAD_THRESHOLD_BPS), + BatchRegime::Normal, + "exactly at the high threshold stays Normal — only past it" + ); + assert_eq!( + BatchRegime::from_bps(HIGH_LOAD_THRESHOLD_BPS + 1.0), + BatchRegime::HighLoad, + "just above 5 Mbps → HighLoad" + ); + } + + #[test] + fn batch_size_threshold_per_regime() { + let mut sender = BatchSender::new(); + let data = [0u8; 100]; + + // LowActivity: flushes after 4 packets. + sender.set_regime(BatchRegime::LowActivity); + for i in 0..3 { + assert!(!sender.queue_packet(&data, Some(i as u32), 0)); + } + assert!(sender.queue_packet(&data, Some(3), 0)); + sender.reset(); + + // HighLoad: flushes after 32. + sender.set_regime(BatchRegime::HighLoad); + for i in 0..31 { + assert!(!sender.queue_packet(&data, Some(i as u32), 0)); + } + assert!(sender.queue_packet(&data, Some(31), 0)); + } } diff --git a/src/connection/bitrate.rs b/src/connection/bitrate.rs index 7909357..03c4ce3 100644 --- a/src/connection/bitrate.rs +++ b/src/connection/bitrate.rs @@ -1,6 +1,9 @@ -use crate::utils::now_ms; - -/// Bitrate measurement and tracking +/// Bitrate measurement and tracking. +/// +/// Sans-IO leaf: every method that needs the current time takes it as `now_ms` +/// rather than reading a global clock, so the caller owns the single monotonic +/// clock. That is why there is no `Default` impl (construction needs a +/// timestamp) — use [`BitrateTracker::new`]. #[derive(Debug, Clone)] pub struct BitrateTracker { pub bytes_sent_total: u64, @@ -9,37 +12,36 @@ pub struct BitrateTracker { pub current_bitrate_bps: f64, } -impl Default for BitrateTracker { - fn default() -> Self { +impl BitrateTracker { + /// Start a fresh tracker whose measurement window opens at `now_ms`. + pub fn new(now_ms: u64) -> Self { Self { bytes_sent_total: 0, bytes_sent_window: 0, - last_rate_update_ms: now_ms(), + last_rate_update_ms: now_ms, current_bitrate_bps: 0.0, } } -} -impl BitrateTracker { /// Reset all bitrate tracking state to start fresh measurement window - pub fn reset(&mut self) { + pub fn reset(&mut self, now_ms: u64) { self.bytes_sent_total = 0; self.bytes_sent_window = 0; - self.last_rate_update_ms = now_ms(); + self.last_rate_update_ms = now_ms; self.current_bitrate_bps = 0.0; } - /// Update bitrate tracking when bytes are sent (matches Android C implementation) + /// Update bitrate tracking when bytes are sent #[inline] pub fn update_on_send(&mut self, bytes_sent: u64) { self.bytes_sent_total = self.bytes_sent_total.saturating_add(bytes_sent); } - /// Calculate current bitrate over a 2-second window (matching Android C implementation) - pub fn calculate(&mut self) { + /// Calculate current bitrate over a 2-second window + pub fn calculate(&mut self, now_ms: u64) { const BITRATE_UPDATE_INTERVAL_MS: u64 = 2000; - let now = now_ms(); + let now = now_ms; let time_diff_ms = now.saturating_sub(self.last_rate_update_ms); if time_diff_ms >= BITRATE_UPDATE_INTERVAL_MS { @@ -61,3 +63,65 @@ impl BitrateTracker { self.current_bitrate_bps / 1_000_000.0 } } + +#[cfg(test)] +mod tests { + use super::*; + + // A fixed virtual clock base. Injecting `now` means tests no longer read a + // real clock at all — the window arithmetic is exercised at chosen instants. + const T0: u64 = 1_000_000; + + #[test] + fn bitrate_send_raises_estimate() { + // Open the window 2.5s in the past so the next calculate() crosses 2s. + let mut t = BitrateTracker::new(T0); + assert_eq!(t.current_bitrate_bps, 0.0); + + t.update_on_send(500_000); + assert_eq!(t.bytes_sent_total, 500_000); + + t.calculate(T0 + 2_500); + assert!( + t.current_bitrate_bps > 0.0, + "sending bytes must raise the estimate, got {}", + t.current_bitrate_bps + ); + } + + #[test] + fn bitrate_idle_decay() { + // Establish a non-zero estimate. + let mut t = BitrateTracker::new(T0); + t.update_on_send(500_000); + t.calculate(T0 + 2_500); + assert!(t.current_bitrate_bps > 0.0); + + // Next window with no further sends: bytes_diff == 0 -> estimate decays to 0. + t.calculate(T0 + 5_000); + assert_eq!( + t.current_bitrate_bps, 0.0, + "an idle window must decay the estimate to zero" + ); + } + + #[test] + fn bitrate_wire_bytes_basis() { + let before = T0; + let mut t = BitrateTracker::new(before); + t.update_on_send(1_000_000); + + t.calculate(before + 4_000); + + // calculate() stamps last_rate_update_ms with the now it used, so the + // exact elapsed window is recoverable for a precise expectation. + let elapsed = t.last_rate_update_ms.saturating_sub(before); + let expected = (1_000_000u64 * 8) as f64 * 1000.0 / elapsed as f64; + assert!( + (t.current_bitrate_bps - expected).abs() < 1.0, + "bitrate is wire-bytes/s x8 (bps): got {}, expected {}", + t.current_bitrate_bps, + expected + ); + } +} diff --git a/src/connection/congestion/classic.rs b/src/connection/congestion/classic.rs index 5d26805..99284e0 100644 --- a/src/connection/congestion/classic.rs +++ b/src/connection/congestion/classic.rs @@ -17,7 +17,10 @@ pub fn handle_srtla_ack_specific(window: &mut i32, in_flight_packets: i32, seq: // CLASSIC MODE: Exact C implementation // Window increase logic from C version (lines 291-293) // Only increase if in_flight_pkts*WINDOW_MULT > window - if in_flight_packets * WINDOW_MULT > *window { + // saturating_mul: at extreme in-flight counts the i32 product would overflow + // (debug panic / release wrap to negative, which silently flips the comparison). + // Saturating at i32::MAX preserves the "should grow" verdict in the normal range. + if in_flight_packets.saturating_mul(WINDOW_MULT) > *window { let old = *window; // Note: WINDOW_INCR - 1 in C code *window = min(*window + WINDOW_INCR - 1, WINDOW_MAX * WINDOW_MULT); @@ -61,4 +64,18 @@ mod tests { assert!(window <= WINDOW_MAX * WINDOW_MULT); } + + #[test] + fn test_classic_ack_no_overflow_at_extreme_in_flight() { + // in_flight just past i32::MAX / WINDOW_MULT: a plain `*` overflows i32 + // (debug panic, release wraps negative and flips the verdict to "no grow"). + // saturating_mul caps at i32::MAX, so the comparison still reads "grow" + // and the window takes one ordinary classic step. + let mut window = 1500; + let in_flight = i32::MAX / WINDOW_MULT + 1; + + handle_srtla_ack_specific(&mut window, in_flight, 100, "test"); + + assert_eq!(window, 1500 + WINDOW_INCR - 1); + } } diff --git a/src/connection/congestion/enhanced.rs b/src/connection/congestion/enhanced.rs index d838473..fd2f35d 100644 --- a/src/connection/congestion/enhanced.rs +++ b/src/connection/congestion/enhanced.rs @@ -10,7 +10,6 @@ use std::cmp::min; use tracing::debug; use crate::protocol::*; -use crate::utils::now_ms; const NORMAL_MIN_WAIT_MS: u64 = 2000; const FAST_MIN_WAIT_MS: u64 = 500; @@ -28,13 +27,17 @@ pub fn handle_srtla_ack( fast_recovery_mode: &mut bool, fast_recovery_start_ms: u64, label: &str, + now_ms: u64, ) { // Enhanced mode: IDENTICAL window growth to classic mode // The only difference from classic is quality scoring in connection selection // This prevents thrashing while still avoiding bad connections - // Use exact classic logic for window increase - if in_flight_packets * WINDOW_MULT > *window { + // Use exact classic logic for window increase. + // saturating_mul: an extreme in-flight count would overflow the i32 product + // (debug panic / release wrap to negative); saturating at i32::MAX keeps the + // normal-range comparison identical while staying panic/wrap-free. + if in_flight_packets.saturating_mul(WINDOW_MULT) > *window { let old = *window; *window = min(*window + WINDOW_INCR - 1, WINDOW_MAX * WINDOW_MULT); @@ -47,7 +50,7 @@ pub fn handle_srtla_ack( } // Fast recovery mode helps connections recover from severe congestion - let current_time = now_ms(); + let current_time = now_ms; if *fast_recovery_mode && *window >= FAST_RECOVERY_DISABLE_WINDOW { *fast_recovery_mode = false; let recovery_duration = current_time.saturating_sub(fast_recovery_start_ms); @@ -59,6 +62,11 @@ pub fn handle_srtla_ack( } } +/// RTT velocity threshold (ms/sample) above which recovery rate is reduced. +/// Positive velocity means RTT is rising — recovering aggressively during +/// active congestion would just cause more loss. +const RTT_VELOCITY_GATE_THRESHOLD: f64 = 2.0; + /// Perform time-based window recovery (enhanced mode only) /// /// Progressively recovers window size based on time since last NAK: @@ -75,13 +83,15 @@ pub fn perform_window_recovery( nak_burst_start_time_ms: &mut u64, last_window_increase_ms: &mut u64, fast_recovery_mode: &mut bool, + rtt_velocity: f64, label: &str, + now_ms: u64, ) { if !connected || *window >= WINDOW_MAX * WINDOW_MULT { return; } - let now = now_ms(); + let now = now_ms; // Treat connections that never had NAKs as perfect connections. // Previously, last_nak_time_ms == 0 would skip recovery entirely, causing @@ -122,20 +132,35 @@ pub fn perform_window_recovery( // Conservative recovery multipliers (using cached values) let fast_mode_bonus = if *fast_recovery_mode { 2 } else { 1 }; + // Gate recovery rate on RTT velocity: if RTT is rising faster than + // the threshold, halve the recovery increment to avoid inflating + // in-flight during active congestion. + let velocity_scale = if rtt_velocity > RTT_VELOCITY_GATE_THRESHOLD { + debug!( + "{}: RTT velocity {:.2} ms/s > threshold, halving recovery rate", + label, rtt_velocity + ); + 0.5_f64 + } else { + 1.0 + }; + // Progressive recovery based on how long since last NAK - if time_since_last_nak > 10_000 { + let base_incr = if time_since_last_nak > 10_000 { // No NAKs for 10+ seconds (or never): aggressive recovery (200% rate) - *window += WINDOW_INCR * 2 * fast_mode_bonus; + WINDOW_INCR * 2 * fast_mode_bonus } else if time_since_last_nak > 7_000 { // No NAKs for 7+ seconds: moderate recovery (100% rate) - *window += WINDOW_INCR * fast_mode_bonus; + WINDOW_INCR * fast_mode_bonus } else if time_since_last_nak > 5_000 { // No NAKs for 5+ seconds: slow recovery (50% rate) - *window += WINDOW_INCR * fast_mode_bonus / 2; + WINDOW_INCR * fast_mode_bonus / 2 } else { // Recent NAKs: minimal recovery (25% rate) - *window += WINDOW_INCR * fast_mode_bonus / 4; - } + WINDOW_INCR * fast_mode_bonus / 4 + }; + + *window += (base_incr as f64 * velocity_scale) as i32; *window = min(*window, WINDOW_MAX * WINDOW_MULT); *last_window_increase_ms = now; @@ -147,8 +172,8 @@ pub fn perform_window_recovery( format!("{:.1}s", (time_since_last_nak as f64) / 1000.0) }; debug!( - "{}: Time-based window recovery {} → {} (last NAK: {}, fast_mode={})", - label, old_window, *window, time_str, *fast_recovery_mode + "{}: Time-based window recovery {} → {} (last NAK: {}, fast_mode={}, vel={:.2})", + label, old_window, *window, time_str, *fast_recovery_mode, rtt_velocity ); } @@ -166,13 +191,31 @@ pub fn perform_window_recovery( mod tests { use super::*; + // Fixed virtual clock: recovery fns take now as an argument, so these tests + // are deterministic with no real-clock read. + const T0: u64 = 1_000_000; + #[test] fn test_enhanced_ack_increases_window() { let mut window = 1500; let in_flight = 3; let mut fast_recovery = false; - handle_srtla_ack(&mut window, in_flight, &mut fast_recovery, 0, "test"); + handle_srtla_ack(&mut window, in_flight, &mut fast_recovery, 0, "test", T0); + + assert_eq!(window, 1500 + WINDOW_INCR - 1); + } + + #[test] + fn test_enhanced_ack_no_overflow_at_extreme_in_flight() { + // in_flight just past i32::MAX / WINDOW_MULT: a plain `*` overflows i32 + // (debug panic, release wraps negative). saturating_mul caps at i32::MAX, + // so the window still grows by one classic-equivalent step. + let mut window = 1500; + let in_flight = i32::MAX / WINDOW_MULT + 1; + let mut fast_recovery = false; + + handle_srtla_ack(&mut window, in_flight, &mut fast_recovery, 0, "test", T0); assert_eq!(window, 1500 + WINDOW_INCR - 1); } @@ -182,7 +225,7 @@ mod tests { let mut window = FAST_RECOVERY_DISABLE_WINDOW - 100; let in_flight = 100; let mut fast_recovery = true; - let start_time = now_ms(); + let start_time = T0; // Increase window enough to trigger fast recovery disable for _ in 0..20 { @@ -192,6 +235,7 @@ mod tests { &mut fast_recovery, start_time, "test", + T0, ); if !fast_recovery { break; @@ -205,7 +249,7 @@ mod tests { fn test_window_recovery_progressive() { // Test that recovery rate increases with time since NAK let mut window = 5000; - let last_nak = now_ms() - 10_500; // 10.5 seconds ago + let last_nak = T0 - 10_500; // 10.5 seconds ago let mut nak_burst_count = 0; let mut nak_burst_start = 0; let mut last_increase = 0; @@ -219,7 +263,9 @@ mod tests { &mut nak_burst_start, &mut last_increase, &mut fast_recovery, + 0.0, // stable RTT "test", + T0, ); // Should have increased (aggressive recovery for 10s+) @@ -246,7 +292,9 @@ mod tests { &mut nak_burst_start, &mut last_increase, &mut fast_recovery, + 0.0, // stable RTT "test", + T0, ); // Should have increased with aggressive recovery (treated as perfect connection) @@ -270,7 +318,7 @@ mod tests { let last_nak = 0; // Never had a NAK let mut nak_burst_count = 0; let mut nak_burst_start = 0; - let mut last_increase = now_ms(); // Just increased + let mut last_increase = T0; // Just increased let mut fast_recovery = false; perform_window_recovery( @@ -281,7 +329,9 @@ mod tests { &mut nak_burst_start, &mut last_increase, &mut fast_recovery, + 0.0, // stable RTT "test", + T0, ); // Should NOT have increased (increment wait not elapsed) @@ -290,4 +340,59 @@ mod tests { "Window should not grow if increment wait hasn't elapsed" ); } + + #[test] + fn test_window_recovery_gated_by_rtt_velocity() { + // Test that rising RTT (high velocity) halves the recovery rate + let mut window_stable = 5000; + let mut window_rising = 5000; + let last_nak = T0 - 10_500; // 10.5 seconds ago + let mut nbc1 = 0; + let mut nbs1 = 0; + let mut li1 = 0; + let mut fr1 = false; + let mut nbc2 = 0; + let mut nbs2 = 0; + let mut li2 = 0; + let mut fr2 = false; + + // Stable RTT: full recovery + perform_window_recovery( + &mut window_stable, + true, + last_nak, + &mut nbc1, + &mut nbs1, + &mut li1, + &mut fr1, + 0.0, + "stable", + T0, + ); + + // Rising RTT: gated recovery + perform_window_recovery( + &mut window_rising, + true, + last_nak, + &mut nbc2, + &mut nbs2, + &mut li2, + &mut fr2, + 5.0, // well above 2.0 threshold + "rising", + T0, + ); + + let stable_incr = window_stable - 5000; + let rising_incr = window_rising - 5000; + assert!( + rising_incr < stable_incr, + "Rising RTT recovery ({}) should be less than stable ({})", + rising_incr, + stable_incr + ); + // Should be roughly half + assert_eq!(rising_incr, stable_incr / 2); + } } diff --git a/src/connection/congestion/mod.rs b/src/connection/congestion/mod.rs index 7575540..09341a3 100644 --- a/src/connection/congestion/mod.rs +++ b/src/connection/congestion/mod.rs @@ -21,7 +21,6 @@ mod enhanced; use tracing::warn; use crate::protocol::*; -use crate::utils::now_ms; const NAK_BURST_WINDOW_MS: u64 = 1000; const NAK_BURST_LOG_THRESHOLD: i32 = 5; @@ -56,8 +55,8 @@ impl CongestionControl { /// Handle NAK reception (common to both classic and enhanced) /// /// Returns true if the NAK was handled successfully - pub fn handle_nak(&mut self, window: &mut i32, seq: i32, label: &str) -> bool { - let current_time = now_ms(); + pub fn handle_nak(&mut self, window: &mut i32, seq: i32, label: &str, now_ms: u64) -> bool { + let current_time = now_ms; self.nak_count = self.nak_count.saturating_add(1); let time_since_last_nak = current_time.saturating_sub(self.last_nak_time_ms); @@ -133,6 +132,7 @@ impl CongestionControl { window: &mut i32, in_flight_packets: i32, label: &str, + now_ms: u64, ) { enhanced::handle_srtla_ack( window, @@ -140,11 +140,23 @@ impl CongestionControl { &mut self.fast_recovery_mode, self.fast_recovery_start_ms, label, + now_ms, ); } /// Perform window recovery (enhanced mode only) - pub fn perform_window_recovery(&mut self, window: &mut i32, connected: bool, label: &str) { + /// + /// `rtt_velocity` is the Kalman velocity (ms/sample). Positive = rising RTT. + /// When velocity exceeds the gate threshold, recovery rate is halved to + /// avoid inflating in-flight during active congestion. + pub fn perform_window_recovery( + &mut self, + window: &mut i32, + connected: bool, + rtt_velocity: f64, + label: &str, + now_ms: u64, + ) { enhanced::perform_window_recovery( window, connected, @@ -153,16 +165,18 @@ impl CongestionControl { &mut self.nak_burst_start_time_ms, &mut self.last_window_increase_ms, &mut self.fast_recovery_mode, + rtt_velocity, label, + now_ms, ); } /// Get time since last NAK in milliseconds - pub fn time_since_last_nak_ms(&self) -> Option { + pub fn time_since_last_nak_ms(&self, now_ms: u64) -> Option { if self.last_nak_time_ms == 0 { None } else { - Some(now_ms().saturating_sub(self.last_nak_time_ms)) + Some(now_ms.saturating_sub(self.last_nak_time_ms)) } } } diff --git a/src/connection/mod.rs b/src/connection/mod.rs index d0f1bce..7b96524 100644 --- a/src/connection/mod.rs +++ b/src/connection/mod.rs @@ -21,8 +21,13 @@ pub use incoming::SrtlaIncoming; pub use reconnection::ReconnectionState; pub use rtt::RttTracker; use rustc_hash::FxHashMap; -pub use socket::{bind_from_ip, resolve_remote}; -use tokio::time::Instant; +// Host-side binder for platforms that steer egress by network handle (Android). +// Exported for library consumers; the CLI binary does not construct it. Unix +// only: it binds by raw fd, which Windows does not have. +#[cfg(unix)] +#[allow(unused_imports)] +pub use socket::CallbackBinder; +pub use socket::{SourceIpBinder, UplinkBinder, create_uplink_socket, resolve_remote}; use tracing::debug; use crate::protocol::*; @@ -30,6 +35,85 @@ use crate::utils::now_ms; pub(crate) const STARTUP_GRACE_MS: u64 = 5_000; +/// Number of RTT probes required before a link transitions from Warming to Live. +const WARMING_RTT_PROBES: u32 = 2; +/// Maximum time in ms a link may stay in Warming before auto-promoting to Live. +/// Prevents links from getting stuck if RTT probes are slow or lost. +const WARMING_TIMEOUT_MS: u64 = 5_000; + +/// Link lifecycle phase. +/// +/// A phase *weights* a link's score; it does not remove the link. `Registering` +/// is the sole exception, and it is not a quality judgement: the receiver has +/// not returned REG3, so data sent on that link would be discarded by the +/// protocol itself. +/// +/// This mirrors the model the phase machine was ported from, where the +/// scheduler multiplies a link's score by a per-phase weight and only a dead +/// link is filtered out. It also matches the rule the rest of this scheduler +/// follows: `weak` and `loss_degraded` crush a score but keep the link rankable +/// (`GATED_LINK_PENALTY`), and `stall_gated` only ever fires when a healthier +/// link exists. Nothing is hard-removed for quality. +/// +/// `Warming` used to be a hard exclusion, which broke that rule in the one place +/// it mattered most: at go-live *every* link is warming, so the candidate pool +/// was empty and the sender dropped the stream until the first link was promoted. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum LinkPhase { + /// Waiting for REG3 handshake to complete. + #[default] + Registering, + /// REG3 received, accumulating RTT probes. Usable, but de-rated: the link's + /// RTT baseline is only a keepalive or two old, so the window/in-flight + /// signal that drives selection is still coarse. + Warming { rtt_probes: u32, entered_ms: u64 }, + /// Fully operational — scheduler may use this link. + Live, + /// Quality has degraded (high NAK rate / low quality multiplier, or + /// a sustained loss EWMA). Scheduler still uses this link but its + /// score is reduced. There is no removed/cooldown phase: a link is + /// never excluded for quality, only de-prioritised, so it keeps the + /// ACK traffic that proves its recovery. Truly dead links are pruned + /// by `is_timed_out`/`CONN_TIMEOUT`. + Degraded, +} + +impl LinkPhase { + /// Whether the scheduler is allowed to send data on this link. + /// + /// Only `Registering` is excluded, and only because the protocol forbids it + /// (no REG3 yet). Every other phase is schedulable and expresses itself + /// through [`LinkPhase::weight`] instead. + pub fn is_schedulable(&self) -> bool { + !matches!(self, LinkPhase::Registering) + } + + /// Scheduling weight contributed by this phase, folded into the link's score. + /// + /// `Degraded` stays at 1.0 deliberately. Degradation is already priced in + /// twice — by the quality multiplier that demoted the link in the first + /// place, and by the `weak`/`loss_degraded` admission gates — so charging it + /// a third time here would just double-count the same signal. + pub fn weight(&self) -> f64 { + match self { + LinkPhase::Registering => 0.0, + LinkPhase::Warming { .. } => 0.8, + LinkPhase::Live | LinkPhase::Degraded => 1.0, + } + } +} + +impl std::fmt::Display for LinkPhase { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + LinkPhase::Registering => write!(f, "registering"), + LinkPhase::Warming { rtt_probes, .. } => write!(f, "warming({rtt_probes})"), + LinkPhase::Live => write!(f, "live"), + LinkPhase::Degraded => write!(f, "degraded"), + } + } +} + /// Interval in milliseconds between quality multiplier recalculations. /// Caching reduces expensive exp() calls from every packet to ~20 times per second. pub const QUALITY_CACHE_INTERVAL_MS: u64 = 50; @@ -94,15 +178,28 @@ pub struct SrtlaConnection { #[cfg(not(feature = "test-internals"))] pub(crate) highest_acked_seq: i32, #[cfg(feature = "test-internals")] - pub last_received: Option, + pub last_received: Option, #[cfg(not(feature = "test-internals"))] - pub(crate) last_received: Option, + pub(crate) last_received: Option, #[cfg(feature = "test-internals")] - pub last_sent: Option, + pub last_sent: Option, #[cfg(not(feature = "test-internals"))] - pub(crate) last_sent: Option, + pub(crate) last_sent: Option, /// Timestamp of the last keepalive sent (for periodic telemetry) - pub(crate) last_keepalive_sent: Option, + pub(crate) last_keepalive_sent: Option, + /// `now_ms()` of this link's last delivery proof: an EARNED ACK (this link + /// owned an acked seq) or a keepalive-RTT response. Stamped ONLY at those + /// two sites — NEVER on generic inbound bytes (unlike `last_received`), so a + /// link that merely echoes traffic while its ACK/RTT path is dead still goes + /// stale. `0` = no proof yet. Read only by the `stall_deselect` selection + /// guard; never a liveness/timeout signal. + pub(crate) last_ack_or_rtt_sample_ms: u64, + /// Transient per-select flag: set by `select_connection_idx` when + /// `stall_deselect` is on and this link is a stalled black hole while a + /// healthier link exists. Recomputed every select call and read only by the + /// mode selectors in that same call; it is a selection penalty ONLY and + /// never affects `is_timed_out`/re-registration. + pub(crate) stall_gated: bool, // Sub-structs for organized state management #[cfg(feature = "test-internals")] pub rtt: RttTracker, @@ -126,18 +223,60 @@ pub struct SrtlaConnection { /// Batch sender for optimized packet transmission. /// Buffers up to 16 packets before flushing, reducing syscall overhead. pub(crate) batch_sender: BatchSender, + /// Link lifecycle phase — determines scheduler eligibility. + #[cfg(feature = "test-internals")] + pub phase: LinkPhase, + #[cfg(not(feature = "test-internals"))] + pub(crate) phase: LinkPhase, + /// Latest weak-link classifier verdict. Updated each housekeeping + /// tick from `WeakLinkFilter::classify`. Consumed by Enhanced + /// selection as an admission gate. + pub(crate) weak: bool, + /// Latest CC state from `LinkCcController::tick_all`. Drives the CC + /// controller's own per-window bitrate backoff. It is intentionally + /// *not* a routing-admission gate: `BackingOff` flips on a single + /// loss window and would make selection twitchy, so the routing gate + /// uses the sustained `loss_degraded` latch instead. + pub(crate) cc_backing_off: bool, + /// Latest `target_bps` from `LinkCcController::tick_all`. Consumed + /// by Enhanced selection as a soft cap: when the link's measured + /// throughput approaches this value the link's score is scaled + /// down so the scheduler routes less traffic through it before + /// loss actually fires. `0` means "no signal" — selection skips + /// the cap. + pub(crate) cc_target_bps: u64, + /// Latched verdict from `LinkCongestionState`: the link's + /// time-decayed loss EWMA has been sustained high (see + /// `LOSS_DEGRADE_*`, ~4s sustain with hysteresis). Drives a graded + /// demotion to `Degraded` in the phase machine *and* the Enhanced + /// selection loss-admission gate. It never removes the link from + /// scheduling (a genuinely dead link is handled by + /// `is_timed_out`/`CONN_TIMEOUT`); a gated link keeps a trickle of + /// traffic so the loss EWMA can recover and clear the latch. + pub(crate) loss_degraded: bool, + /// Strategy for steering this uplink's socket onto its egress path. + /// Retained so reconnects re-apply the same binding (source IP on Linux, + /// host `Network.bindSocket` callback on Android). + pub(crate) binder: Arc, } impl SrtlaConnection { - pub async fn connect_from_ip(ip: IpAddr, host: &str, port: u16) -> Result { + pub async fn connect_from_ip( + ip: IpAddr, + host: &str, + port: u16, + binder: Arc, + ) -> Result { use rand::RngCore; let remote = resolve_remote(host, port).await?; - let sock = bind_from_ip(ip, 0)?; + let sock = create_uplink_socket(ip)?; + binder.bind(&sock, ip)?; sock.connect(&remote.into())?; sock.set_nonblocking(true)?; let socket = Arc::new(BatchUdpSocket::new(sock)?); - let startup_deadline = now_ms() + STARTUP_GRACE_MS; + let now = now_ms(); + let startup_deadline = now + STARTUP_GRACE_MS; Ok(Self { conn_id: rand::rng().next_u64(), socket, @@ -152,15 +291,23 @@ impl SrtlaConnection { last_received: None, last_sent: None, last_keepalive_sent: None, + last_ack_or_rtt_sample_ms: 0, + stall_gated: false, rtt: RttTracker::default(), congestion: CongestionControl::default(), - bitrate: BitrateTracker::default(), + bitrate: BitrateTracker::new(now), reconnection: ReconnectionState { startup_grace_deadline_ms: startup_deadline, ..Default::default() }, quality_cache: CachedQuality::default(), batch_sender: BatchSender::new(), + phase: LinkPhase::Registering, + weak: false, + cc_backing_off: false, + cc_target_bps: 0, + loss_degraded: false, + binder, }) } @@ -220,7 +367,7 @@ impl SrtlaConnection { self.register_packet(s as i32, send_time_ms); } } - self.last_sent = Some(Instant::now()); + self.last_sent = Some(now_ms()); Ok(()) } Err(e) => Err(anyhow::anyhow!("batch flush failed: {}", e)), @@ -239,23 +386,22 @@ impl SrtlaConnection { }; let pkt = create_keepalive_packet_ext(info); self.socket.send(&pkt).await?; - let now_instant = Instant::now(); let now = now_ms(); - self.last_sent = Some(now_instant); - self.last_keepalive_sent = Some(now_instant); + self.last_sent = Some(now); + self.last_keepalive_sent = Some(now); // Only set waiting flag and timestamp when we intend to measure RTT if !self.rtt.waiting_for_keepalive_response && (self.rtt.last_rtt_measurement_ms == 0 || now.saturating_sub(self.rtt.last_rtt_measurement_ms) > 3000) { - self.rtt.record_keepalive_sent(); + self.rtt.record_keepalive_sent(now); } Ok(()) } pub async fn send_srtla_packet(&mut self, pkt: &[u8]) -> Result<()> { self.socket.send(pkt).await?; - self.last_sent = Some(Instant::now()); + self.last_sent = Some(now_ms()); Ok(()) } @@ -272,7 +418,11 @@ impl SrtlaConnection { } pub fn get_smooth_rtt_ms(&self) -> f64 { - self.rtt.kalman_rtt.value() + // The 2-state Kalman filter can overshoot negative on a sharp high->low + // RTT transition; a negative RTT is meaningless and would leak into the + // selection/CC math, so clamp it. Callers that need to tell a never-measured + // link from a genuine ~0 already test `smooth_rtt <= 0.0`. + self.rtt.kalman_rtt.value().max(0.0) } /// RTT velocity (trend) in ms/sample from the Kalman filter. @@ -281,7 +431,6 @@ impl SrtlaConnection { self.rtt.kalman_rtt.velocity() } - pub fn get_rtt_min_ms(&self) -> f64 { self.rtt.rtt_min_ms } @@ -290,12 +439,24 @@ impl SrtlaConnection { self.rtt.rtt_jitter_ms } - pub fn needs_rtt_measurement(&self) -> bool { - self.rtt - .needs_measurement(self.connected, self.reconnection.connection_established_ms) + /// Whether this link's RTT shows a standing queue forming (the + /// recent propagation floor lifted above the long-term floor), + /// distinct from jitter. Consumed by the weak-link classifier as an + /// early-warning signal so the scheduler eases off before the queue + /// turns into loss. + pub fn queue_building_suspected(&self) -> bool { + self.rtt.queue_building_suspected() + } + + pub fn needs_rtt_measurement(&self, now_ms: u64) -> bool { + self.rtt.needs_measurement( + self.connected, + self.reconnection.connection_established_ms, + now_ms, + ) } - pub fn needs_keepalive(&self) -> bool { + pub fn needs_keepalive(&self, now_ms: u64) -> bool { // Send keepalive every IDLE_TIME (1s) unconditionally on all connections. // Moblin does this with standard 10-byte keepalives; we use extended 38-byte // keepalives to provide the receiver with telemetry (window, RTT, NAKs, bitrate). @@ -305,39 +466,151 @@ impl SrtlaConnection { match self.last_keepalive_sent { None => true, - Some(last) => last.elapsed().as_secs() >= IDLE_TIME, + Some(last) => now_ms.saturating_sub(last) >= IDLE_TIME * 1000, + } + } + + pub fn perform_window_recovery(&mut self, now_ms: u64) { + let velocity = self.rtt.kalman_rtt.velocity(); + self.congestion.perform_window_recovery( + &mut self.window, + self.connected, + velocity, + &self.label, + now_ms, + ); + } + + /// Record an RTT probe and advance warming → live if enough probes collected. + pub fn record_rtt_probe(&mut self) { + if let LinkPhase::Warming { rtt_probes, .. } = &mut self.phase { + *rtt_probes += 1; + if *rtt_probes >= WARMING_RTT_PROBES { + debug!("{}: warming complete, transitioning to Live", self.label); + self.phase = LinkPhase::Live; + } } } - pub fn perform_window_recovery(&mut self) { - self.congestion - .perform_window_recovery(&mut self.window, self.connected, &self.label); + /// Drive phase transitions based on current connection health. + /// + /// Called from housekeeping. A degraded link stays **schedulable**: + /// demotion only lowers its score (via quality + the `Degraded` + /// phase), it never removes the link. Removing a link starves it of + /// the ACK traffic that proves its own recovery, which on bonded + /// cellular turns a transient HARQ stall (400-800ms) into a + /// self-sustaining false death. A genuinely unresponsive link is + /// pruned by `is_timed_out`/`CONN_TIMEOUT`, not here. + pub fn update_phase(&mut self, now_ms: u64) { + const DEGRADED_QUALITY_THRESHOLD: f64 = 0.5; + const DEGRADED_NAK_BURST_THRESHOLD: i32 = 5; + + // Combined degradation signal: the fast NAK-quality path catches + // mild degradation; the sustained loss-EWMA verdict + // (`loss_degraded`, latched with hysteresis in + // `LinkCongestionState`) catches a link that is genuinely + // shedding most of its traffic without a binary kill. + let nak_degraded = self.quality_cache.multiplier < DEGRADED_QUALITY_THRESHOLD + && self.congestion.nak_burst_count >= DEGRADED_NAK_BURST_THRESHOLD; + let nak_recovered = self.quality_cache.multiplier >= DEGRADED_QUALITY_THRESHOLD + && self.congestion.nak_burst_count < DEGRADED_NAK_BURST_THRESHOLD; + + match self.phase { + // Auto-promote to Live if warming takes too long. + LinkPhase::Warming { entered_ms, .. } + if now_ms.saturating_sub(entered_ms) >= WARMING_TIMEOUT_MS => + { + debug!( + "{}: warming timeout ({}ms), auto-promoting to Live", + self.label, WARMING_TIMEOUT_MS + ); + self.phase = LinkPhase::Live; + } + LinkPhase::Live if nak_degraded || self.loss_degraded => { + debug!( + "{}: Live -> Degraded (quality={:.2}, nak_burst={}, loss_degraded={})", + self.label, + self.quality_cache.multiplier, + self.congestion.nak_burst_count, + self.loss_degraded + ); + self.phase = LinkPhase::Degraded; + } + // Recover to Live only when both signals clear: the fast + // NAK-quality path AND the latched loss-EWMA verdict. + LinkPhase::Degraded if nak_recovered && !self.loss_degraded => { + debug!( + "{}: Degraded -> Live (quality={:.2})", + self.label, self.quality_cache.multiplier + ); + self.phase = LinkPhase::Live; + } + // Registering, plus Warming/Live/Degraded whose guards did + // not fire, hold their phase. + _ => {} + } + } + + /// Whether this link is eligible for packet scheduling. + pub fn is_schedulable(&self) -> bool { + self.phase.is_schedulable() + } + + /// Scheduling weight contributed by this link's phase + /// (see [`LinkPhase::weight`]). + #[inline(always)] + pub fn phase_weight(&self) -> f64 { + self.phase.weight() + } + + /// `stall_deselect` signal (pure read; never mutates). True for a connected + /// link whose in-flight backlog is at or above `min_in_flight` AND whose + /// last delivery proof (earned-ACK or keepalive-RTT sample) is older than + /// `stale_ms`. `now_ms` is the selection clock. + /// + /// A link that has produced no proof yet (`last_ack_or_rtt_sample_ms == 0`) + /// is never stalled: a fresh burst before its first ACK must not be + /// mistaken for a black hole. A genuinely dead-from-birth link is pruned by + /// `is_timed_out`/`CONN_TIMEOUT`, not here. This is a selection penalty + /// input ONLY — it never affects `is_timed_out`/re-registration/CONN_TIMEOUT. + #[inline] + pub(crate) fn is_stalled(&self, now_ms: u64, min_in_flight: i32, stale_ms: u64) -> bool { + self.connected + && self.in_flight_packets >= min_in_flight + && self.last_ack_or_rtt_sample_ms != 0 + && now_ms.saturating_sub(self.last_ack_or_rtt_sample_ms) >= stale_ms } + /// Whether this link has gone silent past `CONN_TIMEOUT`. + /// + /// `last_received` is a `now_ms()` monotonic millisecond stamp (the single + /// clock this whole codebase runs on), so the timeout is a plain difference + /// against the caller's `now_ms`. Tests drive it by stamping `last_received` a + /// chosen interval in the past (e.g. `now_ms() - (CONN_TIMEOUT + 1) * 1000`); + /// they no longer advance a tokio virtual clock, because this compares against + /// the monotonic clock, not `tokio::time::Instant`. #[inline(always)] - pub fn is_timed_out(&self) -> bool { + pub fn is_timed_out(&self, now_ms: u64) -> bool { + let now = now_ms; // During initial registration (not yet connected), allow grace period if !self.connected { // If this connection was never established (connection_established_ms == 0), // check if we're still within the startup grace period - if self.reconnection.connection_established_ms == 0 { - let now = now_ms(); - if now < self.reconnection.startup_grace_deadline_ms { - return false; - } + if self.reconnection.connection_established_ms == 0 + && now < self.reconnection.startup_grace_deadline_ms + { + return false; } // After grace period, or for connections that were previously established, // if we've never received anything or haven't received in a while, consider it timed out - return self.last_received.is_none() - || self - .last_received - .map(|lr| lr.elapsed().as_secs() >= CONN_TIMEOUT) - .unwrap_or(true); + return self + .last_received + .is_none_or(|lr| now.saturating_sub(lr) >= CONN_TIMEOUT * 1000); } // For established connections, check normal timeout if let Some(lr) = self.last_received { - lr.elapsed().as_secs() >= CONN_TIMEOUT + now.saturating_sub(lr) >= CONN_TIMEOUT * 1000 } else { false } @@ -351,7 +624,7 @@ impl SrtlaConnection { /// data packets, creating `packet_log` entries that will never be /// properly ACKed. Early NAKs from these packets would also penalize /// the connection's quality score during startup. - pub(crate) fn clear_pre_registration_state(&mut self) { + pub(crate) fn clear_pre_registration_state(&mut self, now_ms: u64) { if !self.packet_log.is_empty() || self.congestion.nak_count > 0 { debug!( "{}: clearing pre-registration state ({} in-flight, {} NAKs)", @@ -366,6 +639,11 @@ impl SrtlaConnection { self.congestion.reset(); self.batch_sender.reset(); self.quality_cache = CachedQuality::default(); + // REG3 received — begin warming phase + self.phase = LinkPhase::Warming { + rtt_probes: 0, + entered_ms: now_ms, + }; } /// Reset core connection state (window, packet tracking, batch queue). @@ -377,6 +655,11 @@ impl SrtlaConnection { self.packet_log.clear(); self.highest_acked_seq = i32::MIN; self.batch_sender.reset(); + self.phase = LinkPhase::Registering; + // A reset link has no delivery proof; clear the stall signal so it is + // not classed as stalled the instant it reconnects with a backlog. + self.last_ack_or_rtt_sample_ms = 0; + self.stall_gated = false; } /// Mark connection for recovery (C-style), similar to setting last_rcvd = 1. @@ -392,8 +675,8 @@ impl SrtlaConnection { self.reconnection.startup_grace_deadline_ms = 0; } - pub fn time_since_last_nak_ms(&self) -> Option { - self.congestion.time_since_last_nak_ms() + pub fn time_since_last_nak_ms(&self, now_ms: u64) -> Option { + self.congestion.time_since_last_nak_ms(now_ms) } pub fn total_nak_count(&self) -> i32 { @@ -425,12 +708,12 @@ impl SrtlaConnection { self.quality_cache.multiplier } - pub fn should_attempt_reconnect(&self) -> bool { - self.reconnection.should_attempt_reconnect() + pub fn should_attempt_reconnect(&self, now_ms: u64) -> bool { + self.reconnection.should_attempt_reconnect(now_ms) } - pub fn record_reconnect_attempt(&mut self) { - self.reconnection.record_attempt(&self.label); + pub fn record_reconnect_attempt(&mut self, now_ms: u64) { + self.reconnection.record_attempt(&self.label, now_ms); } pub fn mark_reconnect_success(&mut self) { @@ -438,8 +721,8 @@ impl SrtlaConnection { } /// Calculate current bitrate - pub fn calculate_bitrate(&mut self) { - self.bitrate.calculate(); + pub fn calculate_bitrate(&mut self, now_ms: u64) { + self.bitrate.calculate(now_ms); } /// Get current bitrate in Mbps @@ -447,24 +730,36 @@ impl SrtlaConnection { self.bitrate.mbps() } + /// Pick the batch regime for this connection from its observed + /// bitrate. Called from housekeeping each tick; the underlying + /// `BatchSender::set_regime` is a cheap field write — no-op cost + /// when the regime is unchanged. + pub fn recompute_batch_regime(&mut self) { + let regime = + crate::connection::batch_send::BatchRegime::from_bps(self.bitrate.current_bitrate_bps); + self.batch_sender.set_regime(regime); + } + /// Reset connection state after socket replacement. /// Full reset: clears all state including congestion/bitrate stats. fn reset_state(&mut self) { + let now = now_ms(); self.last_received = None; self.reset_core_state(); // Reset submodule state self.congestion.reset(); self.rtt.reset(); - self.bitrate.reset(); + self.bitrate.reset(now); // Reset reconnection tracking - self.reconnection.last_reconnect_attempt_ms = now_ms(); + self.reconnection.last_reconnect_attempt_ms = now; self.reconnection.reconnect_failure_count = 0; } pub async fn reconnect(&mut self) -> Result<()> { - let sock = bind_from_ip(self.local_ip, 0)?; + let sock = create_uplink_socket(self.local_ip)?; + self.binder.bind(&sock, self.local_ip)?; sock.connect(&self.remote.into())?; sock.set_nonblocking(true)?; let socket = BatchUdpSocket::new(sock)?; @@ -475,7 +770,8 @@ impl SrtlaConnection { // Don't reset connection_established_ms for reconnections - only set when REG3 // is received self.mark_reconnect_success(); - self.reconnection.reset_startup_grace(); + // Connection-layer ambient read; ReconnectionState is clock-injected. + self.reconnection.reset_startup_grace(now_ms()); Ok(()) } } diff --git a/src/connection/packet_io.rs b/src/connection/packet_io.rs index 4006b87..f000df9 100644 --- a/src/connection/packet_io.rs +++ b/src/connection/packet_io.rs @@ -3,7 +3,6 @@ use std::net::SocketAddr; use anyhow::Result; use smallvec::SmallVec; use tokio::net::UdpSocket; -use tokio::time::Instant; use tracing::{debug, warn}; use super::SrtlaConnection; @@ -84,10 +83,10 @@ impl SrtlaConnection { incoming: &mut SrtlaIncoming, ) -> Result<()> { incoming.read_any = true; - let recv_time = Instant::now(); + let now = crate::utils::now_ms(); let pt = get_packet_type(data); if let Some(pt) = pt { - if let Some(event) = reg.process_registration_packet(conn_idx, data) { + if let Some(event) = reg.process_registration_packet(conn_idx, data, now) { match event { RegistrationEvent::RegNgp => { reg.try_send_reg1_immediately(conn_idx, self).await; @@ -95,11 +94,11 @@ impl SrtlaConnection { RegistrationEvent::Reg3 => { // Clear any phantom in-flight packets and NAK state // accumulated during pre-registration data forwarding - self.clear_pre_registration_state(); + self.clear_pre_registration_state(now); self.connected = true; - self.last_received = Some(recv_time); + self.last_received = Some(now); if self.reconnection.connection_established_ms == 0 { - self.reconnection.connection_established_ms = crate::utils::now_ms(); + self.reconnection.connection_established_ms = now; } self.reconnection.mark_success(&self.label); } @@ -112,7 +111,7 @@ impl SrtlaConnection { return Ok(()); } - self.last_received = Some(recv_time); + self.last_received = Some(now); if pt == SRT_TYPE_ACK { if let Some(ack) = parse_srt_ack(data) { @@ -160,7 +159,19 @@ impl SrtlaConnection { } } } else if pt == SRTLA_TYPE_KEEPALIVE { - self.rtt.handle_keepalive_response(data, &self.label); + if self + .rtt + .handle_keepalive_response(data, &self.label, now) + .is_some() + { + self.record_rtt_probe(); + // Delivery proof for `stall_deselect`: a completed keepalive + // round-trip proves this link's path is alive even while no + // data ACKs are landing. Pairs with the earned-ACK site + // (see `ack_nak.rs`); together they let a recovered link + // un-gate itself without the scheduler probing blindly. + self.last_ack_or_rtt_sample_ms = now; + } } else { incoming .forward_to_client diff --git a/src/connection/reconnection.rs b/src/connection/reconnection.rs index 710173c..4277abd 100644 --- a/src/connection/reconnection.rs +++ b/src/connection/reconnection.rs @@ -1,7 +1,6 @@ use tracing::{debug, info}; use super::STARTUP_GRACE_MS; -use crate::utils::now_ms; const BASE_RECONNECT_DELAY_MS: u64 = 5000; const MAX_BACKOFF_DELAY_MS: u64 = 120_000; const MAX_BACKOFF_COUNT: u32 = 5; @@ -23,9 +22,7 @@ impl ReconnectionState { delay.min(MAX_BACKOFF_DELAY_MS) } - pub fn should_attempt_reconnect(&self) -> bool { - let now = now_ms(); - + pub fn should_attempt_reconnect(&self, now: u64) -> bool { if self.connection_established_ms == 0 { if now <= self.startup_grace_deadline_ms { return false; @@ -46,8 +43,8 @@ impl ReconnectionState { time_since_last_attempt >= self.backoff_delay() } - pub fn record_attempt(&mut self, label: &str) { - self.last_reconnect_attempt_ms = now_ms(); + pub fn record_attempt(&mut self, label: &str, now: u64) { + self.last_reconnect_attempt_ms = now; // For initial registration we keep retry cadence fast and skip backoff if self.connection_established_ms == 0 { @@ -75,7 +72,7 @@ impl ReconnectionState { } } - pub fn reset_startup_grace(&mut self) { - self.startup_grace_deadline_ms = now_ms() + STARTUP_GRACE_MS; + pub fn reset_startup_grace(&mut self, now: u64) { + self.startup_grace_deadline_ms = now + STARTUP_GRACE_MS; } } diff --git a/src/connection/rtt.rs b/src/connection/rtt.rs index 1cca7d0..652da7b 100644 --- a/src/connection/rtt.rs +++ b/src/connection/rtt.rs @@ -5,7 +5,6 @@ use tracing::debug; use crate::ewma::Ewma; use crate::kalman::{KalmanConfig, KalmanFilter}; use crate::protocol::extract_keepalive_timestamp; -use crate::utils::now_ms; /// Number of samples in the fast sliding window (~3s at 300ms keepalive interval). const FAST_WINDOW_SAMPLES: usize = 10; @@ -14,6 +13,25 @@ const SLOW_WINDOW_SAMPLES: usize = 100; /// Number of samples in the min-RTT sample filter. const RTT_SAMPLE_FILTER_SIZE: usize = 15; +/// EWMA weight for the mean-absolute-successive-difference (MASD) of +/// RTT. MASD is the average step size between consecutive samples; it +/// measures jitter without being fooled by a slow standing-queue ramp +/// (a steady climb has small successive steps). A small alpha averages +/// over roughly the fast window. +const RTT_MASD_ALPHA: f64 = 0.1; + +/// Queue-building trips when the delay gradient (recent floor lifted +/// above the long-term floor) exceeds this many MASD units. A genuine +/// standing queue lifts the short-window minimum while MASD stays low, +/// so the ratio crosses; pure jitter lifts MASD in step with any +/// gradient, so it does not. +const GRAD_TRIP_SIGMA: f64 = 3.0; + +/// Floor on the queue-building trip threshold as a fraction of the +/// link's own minimum RTT, so a near-zero MASD on a very clean link +/// still needs a meaningful absolute gradient (5% of baseline) to trip. +const GRAD_TRIP_FLOOR_FRACTION: f64 = 0.05; + /// RTT measurement and tracking. /// /// Uses a 2-state Kalman filter [value, velocity] as the primary smooth RTT @@ -33,6 +51,13 @@ pub struct RttTracker { pub rtt_avg_delta: Ewma, /// Dual-window minimum RTT baseline. Computed as min(fast_window_min, slow_window_min). pub rtt_min_ms: f64, + /// Minimum of the fast (~3s) window only. The recent propagation floor. + pub rtt_min_fast_ms: f64, + /// Minimum of the slow (~30s) window only. The long-term floor. + pub rtt_min_slow_ms: f64, + /// Mean absolute successive difference of RTT (ms): the jitter-immune + /// queue-build detector compares the floor gradient against it. + pub rtt_masd_ms: f64, pub estimated_rtt_ms: f64, /// Fast sliding window for minimum RTT tracking (~3s). rtt_min_fast_window: VecDeque, @@ -53,6 +78,9 @@ impl Default for RttTracker { prev_rtt_ms: 0.0, rtt_avg_delta: Ewma::new(0.2), rtt_min_ms: 200.0, + rtt_min_fast_ms: 200.0, + rtt_min_slow_ms: 200.0, + rtt_masd_ms: 0.0, estimated_rtt_ms: 0.0, rtt_min_fast_window: VecDeque::with_capacity(FAST_WINDOW_SAMPLES), rtt_min_slow_window: VecDeque::with_capacity(SLOW_WINDOW_SAMPLES), @@ -71,6 +99,9 @@ impl RttTracker { self.prev_rtt_ms = 0.0; self.rtt_avg_delta.reset(); self.rtt_min_ms = 200.0; + self.rtt_min_fast_ms = 200.0; + self.rtt_min_slow_ms = 200.0; + self.rtt_masd_ms = 0.0; self.estimated_rtt_ms = 0.0; self.last_keepalive_sent_ms = 0; self.waiting_for_keepalive_response = false; @@ -79,7 +110,7 @@ impl RttTracker { self.rtt_sample_filter.clear(); } - pub fn update_estimate(&mut self, rtt_ms: u64) { + pub fn update_estimate(&mut self, rtt_ms: u64, now_ms: u64) { let current_rtt = rtt_ms as f64; // Min-RTT sample filter: smooth jitter before feeding baseline tracker. @@ -99,9 +130,12 @@ impl RttTracker { self.prev_rtt_ms = current_rtt; self.estimated_rtt_ms = current_rtt; self.rtt_min_ms = filtered_rtt; + self.rtt_min_fast_ms = filtered_rtt; + self.rtt_min_slow_ms = filtered_rtt; + self.rtt_masd_ms = 0.0; self.rtt_min_fast_window.push_back(filtered_rtt); self.rtt_min_slow_window.push_back(filtered_rtt); - self.last_rtt_measurement_ms = now_ms(); + self.last_rtt_measurement_ms = now_ms; return; } @@ -111,6 +145,12 @@ impl RttTracker { // Track RTT change rate let delta_rtt = current_rtt - self.prev_rtt_ms; self.rtt_avg_delta.update(delta_rtt); + // Mean absolute successive difference (jitter magnitude). A slow + // standing-queue ramp has small successive steps, so MASD stays + // low even as the floor lifts — that asymmetry is what makes the + // queue-build detector immune to jitter. + self.rtt_masd_ms = + self.rtt_masd_ms * (1.0 - RTT_MASD_ALPHA) + delta_rtt.abs() * RTT_MASD_ALPHA; self.prev_rtt_ms = current_rtt; // Dual-window minimum RTT baseline tracking (fed with filtered RTT). @@ -135,6 +175,8 @@ impl RttTracker { .iter() .copied() .fold(f64::MAX, f64::min); + self.rtt_min_fast_ms = fast_min; + self.rtt_min_slow_ms = slow_min; self.rtt_min_ms = fast_min.min(slow_min); // Track peak deviation with exponential decay @@ -145,27 +187,60 @@ impl RttTracker { // Smoothed RTT from Kalman self.estimated_rtt_ms = self.kalman_rtt.value(); - self.last_rtt_measurement_ms = now_ms(); + self.last_rtt_measurement_ms = now_ms; } pub fn is_stable(&self) -> bool { self.rtt_avg_delta.value().abs() < 1.0 } - pub fn record_keepalive_sent(&mut self) { - self.last_keepalive_sent_ms = now_ms(); + /// Jitter-immune delay gradient (ms): how far the recent (fast) + /// propagation floor has lifted above the long-term (slow) floor. + /// A pure-jitter link keeps both minima at the propagation floor, so + /// the gradient stays near zero; a genuine standing queue lifts the + /// recent floor above the long-term one. Clamped at zero (a falling + /// RTT is not queue building). + pub fn rtt_gradient_ms(&self) -> f64 { + (self.rtt_min_fast_ms - self.rtt_min_slow_ms).max(0.0) + } + + /// True when the delay gradient indicates a standing queue forming, + /// rather than jitter. Trips when the gradient exceeds + /// `GRAD_TRIP_SIGMA` MASD units, floored at `GRAD_TRIP_FLOOR_FRACTION` + /// of the link's own minimum RTT so a very clean link still needs a + /// meaningful absolute rise. Returns false until the baseline is + /// established. + pub fn queue_building_suspected(&self) -> bool { + if !self.kalman_rtt.is_initialized() || !self.rtt_min_ms.is_finite() { + return false; + } + let trip = + (GRAD_TRIP_SIGMA * self.rtt_masd_ms).max(GRAD_TRIP_FLOOR_FRACTION * self.rtt_min_ms); + self.rtt_gradient_ms() > trip + } + + pub fn record_keepalive_sent(&mut self, now_ms: u64) { + self.last_keepalive_sent_ms = now_ms; self.waiting_for_keepalive_response = true; } - pub fn handle_keepalive_response(&mut self, data: &[u8], label: &str) -> Option { + pub fn handle_keepalive_response( + &mut self, + data: &[u8], + label: &str, + now_ms: u64, + ) -> Option { if !self.waiting_for_keepalive_response { return None; } if let Some(ts) = extract_keepalive_timestamp(data) { - let now = now_ms(); + let now = now_ms; let rtt = now.saturating_sub(ts); - if rtt <= 10_000 { - self.update_estimate(rtt); + // Reject rtt == 0 (same-ms reply or future timestamp from clock skew): + // a 0ms RTT is not a real sample and would seed rtt_min_ms = 0, making + // the link look artificially fast. Matches the ACK path (ack_nak.rs). + if rtt > 0 && rtt <= 10_000 { + self.update_estimate(rtt, now); self.waiting_for_keepalive_response = false; debug!( "{}: RTT from keepalive: {}ms (kalman: {:.1}ms, velocity: {:.2}ms/s, jitter: \ @@ -183,7 +258,12 @@ impl RttTracker { None } - pub fn needs_measurement(&self, connected: bool, connection_established_ms: u64) -> bool { + pub fn needs_measurement( + &self, + connected: bool, + connection_established_ms: u64, + now_ms: u64, + ) -> bool { if connection_established_ms == 0 { return false; } @@ -191,7 +271,7 @@ impl RttTracker { connected && !self.waiting_for_keepalive_response && (self.last_rtt_measurement_ms == 0 - || now_ms().saturating_sub(self.last_rtt_measurement_ms) > 3000) + || now_ms.saturating_sub(self.last_rtt_measurement_ms) > 3000) } } @@ -199,13 +279,16 @@ impl RttTracker { mod tests { use super::*; + // Fixed virtual clock: injected `now` means these tests read no real clock. + const T0: u64 = 1_000_000; + #[test] fn test_dual_window_adapts_to_handover() { let mut tracker = RttTracker::default(); // Establish baseline at 50ms for _ in 0..FAST_WINDOW_SAMPLES { - tracker.update_estimate(50); + tracker.update_estimate(50, T0); } assert!( (tracker.rtt_min_ms - 50.0).abs() < 1.0, @@ -216,7 +299,7 @@ mod tests { // Simulate cellular handover: RTT jumps to 120ms. let flush_count = RTT_SAMPLE_FILTER_SIZE + SLOW_WINDOW_SAMPLES; for _ in 0..flush_count { - tracker.update_estimate(120); + tracker.update_estimate(120, T0); } assert!( @@ -230,11 +313,11 @@ mod tests { fn test_dual_window_tracks_minimum() { let mut tracker = RttTracker::default(); - tracker.update_estimate(100); - tracker.update_estimate(80); - tracker.update_estimate(60); - tracker.update_estimate(90); - tracker.update_estimate(70); + tracker.update_estimate(100, T0); + tracker.update_estimate(80, T0); + tracker.update_estimate(60, T0); + tracker.update_estimate(90, T0); + tracker.update_estimate(70, T0); assert!( (tracker.rtt_min_ms - 60.0).abs() < 1.0, @@ -248,7 +331,7 @@ mod tests { let mut tracker = RttTracker::default(); for _ in 0..20 { - tracker.update_estimate(50); + tracker.update_estimate(50, T0); } assert!((tracker.rtt_min_ms - 50.0).abs() < 1.0); @@ -256,7 +339,7 @@ mod tests { assert!((tracker.rtt_min_ms - 200.0).abs() < f64::EPSILON); - tracker.update_estimate(80); + tracker.update_estimate(80, T0); assert!( (tracker.rtt_min_ms - 80.0).abs() < 1.0, "after reset + new measurement, baseline should be 80ms, got {}", @@ -268,10 +351,10 @@ mod tests { fn test_dual_window_fast_window_forgets_old_minimum() { let mut tracker = RttTracker::default(); - tracker.update_estimate(20); + tracker.update_estimate(20, T0); for _ in 0..FAST_WINDOW_SAMPLES { - tracker.update_estimate(100); + tracker.update_estimate(100, T0); } assert!( @@ -282,7 +365,7 @@ mod tests { let flush_count = RTT_SAMPLE_FILTER_SIZE + SLOW_WINDOW_SAMPLES; for _ in 0..flush_count { - tracker.update_estimate(100); + tracker.update_estimate(100, T0); } assert!( @@ -292,13 +375,55 @@ mod tests { ); } + #[test] + fn test_queue_building_ignores_pure_jitter() { + let mut tracker = RttTracker::default(); + // High-amplitude jitter around a stable floor: both the fast and + // slow minima sit on the 40ms floor, so the gradient stays ~0 + // even though MASD is large. + for i in 0..80 { + let rtt = if i % 2 == 0 { 40 } else { 60 }; + tracker.update_estimate(rtt, T0); + } + assert!( + !tracker.queue_building_suspected(), + "pure jitter must not be read as a standing queue (gradient={:.1}, masd={:.1})", + tracker.rtt_gradient_ms(), + tracker.rtt_masd_ms + ); + } + + #[test] + fn test_queue_building_trips_on_standing_queue() { + let mut tracker = RttTracker::default(); + // Establish a low long-term floor. + for _ in 0..30 { + tracker.update_estimate(20, T0); + } + assert!(!tracker.queue_building_suspected()); + // Steady ramp (small successive steps -> low MASD) that lifts the + // recent floor well above the long-term floor still held by the + // slow window. + let mut rtt = 20u64; + for _ in 0..60 { + rtt += 2; + tracker.update_estimate(rtt, T0); + } + assert!( + tracker.queue_building_suspected(), + "a sustained delay ramp must trip the detector (gradient={:.1}, masd={:.1})", + tracker.rtt_gradient_ms(), + tracker.rtt_masd_ms + ); + } + #[test] fn test_kalman_smooths_rtt() { let mut tracker = RttTracker::default(); // Feed stable RTT for _ in 0..50 { - tracker.update_estimate(50); + tracker.update_estimate(50, T0); } assert!( (tracker.estimated_rtt_ms - 50.0).abs() < 1.0, @@ -308,7 +433,7 @@ mod tests { // Feed rising RTT — velocity should go positive for _ in 0..20 { - tracker.update_estimate(80); + tracker.update_estimate(80, T0); } assert!( tracker.kalman_rtt.velocity() > 0.0 || tracker.estimated_rtt_ms > 60.0, @@ -317,4 +442,40 @@ mod tests { tracker.kalman_rtt.velocity() ); } + + #[test] + fn test_keepalive_zero_rtt_rejected() { + // A keepalive reply whose timestamp is >= now (same-ms reply or future + // timestamp from clock skew) yields rtt == 0 via saturating_sub. A 0ms RTT + // is not a real sample and must be rejected, or it would initialize the + // filter and seed rtt_min_ms = 0, making the link look artificially fast. + // Parity with the ACK path (ack_nak.rs). + let mut tracker = RttTracker::default(); + assert!((tracker.rtt_min_ms - 200.0).abs() < f64::EPSILON); + + tracker.record_keepalive_sent(T0); + assert!(tracker.waiting_for_keepalive_response); + + let future_ts = T0 + 1_000_000; + let mut pkt = [0u8; 10]; + pkt[0..2].copy_from_slice(&crate::protocol::SRTLA_TYPE_KEEPALIVE.to_be_bytes()); + pkt[2..10].copy_from_slice(&future_ts.to_be_bytes()); + + let rtt = tracker.handle_keepalive_response(&pkt, "test", T0); + + assert_eq!(rtt, None, "zero-RTT keepalive must be rejected"); + assert!( + !tracker.kalman_rtt.is_initialized(), + "zero-RTT keepalive must not initialize the Kalman filter" + ); + assert!( + (tracker.rtt_min_ms - 200.0).abs() < f64::EPSILON, + "rtt_min_ms must stay at the default baseline, got {}", + tracker.rtt_min_ms + ); + assert!( + !tracker.waiting_for_keepalive_response, + "the keepalive-wait flag must be cleared after a rejected reply" + ); + } } diff --git a/src/connection/socket.rs b/src/connection/socket.rs index 2fd0044..1ceee76 100644 --- a/src/connection/socket.rs +++ b/src/connection/socket.rs @@ -1,11 +1,69 @@ use std::net::{IpAddr, SocketAddr}; +// The raw-fd binder below is a unix concept: it exists for Android, where the +// host steers a socket onto a radio via `Network.bindSocket` on its fd. Windows +// has no fd, and no such host integration, so the whole binder is unix-only. +#[cfg(unix)] +use std::os::fd::{AsRawFd, RawFd}; use anyhow::{Context, Result}; use socket2::{Domain, Protocol, Socket, Type}; use tracing::warn; -pub fn bind_from_ip(ip: IpAddr, port: u16) -> Result { - let domain = match ip { +/// Strategy for steering a freshly created uplink socket onto a specific egress +/// path before it is connected. +/// +/// On a multi-homed Linux host each uplink owns a source IP, and source-based +/// routing makes binding that source IP sufficient to pick the egress +/// (`SourceIpBinder`). On platforms where the kernel selects the egress by a +/// network handle rather than by source address (notably Android, where the app +/// must call `Network.bindSocket` on the wifi or cellular `Network`), the host +/// supplies a `CallbackBinder` that operates on the raw fd instead. +/// +/// The uplink identity stays keyed on `IpAddr` in both cases; only the act of +/// steering the socket differs. +pub trait UplinkBinder: Send + Sync { + /// Steer `sock` (already created with buffers set, not yet connected) onto + /// the egress identified by `ip`. + fn bind(&self, sock: &Socket, ip: IpAddr) -> Result<()>; +} + +/// Default binder. Binds the socket to the uplink source IP on an ephemeral +/// port, the behavior the CLI (`ips_file`) relies on. +pub struct SourceIpBinder; + +impl UplinkBinder for SourceIpBinder { + fn bind(&self, sock: &Socket, ip: IpAddr) -> Result<()> { + let addr = SocketAddr::new(ip, 0); + sock.bind(&addr.into()).context("bind socket") + } +} + +/// Binder that delegates to a host-supplied closure over the raw fd. The Android +/// integration wires this to `ConnectivityManager` / `Network.bindSocket`, +/// keying on the same `IpAddr` used as the uplink identity. The closure must +/// steer the fd onto the intended radio before the socket is connected. +/// +/// Exported for library consumers; the CLI binary never constructs it. +#[cfg(unix)] +#[allow(dead_code)] +pub struct CallbackBinder(pub F) +where + F: Fn(RawFd, IpAddr) -> std::io::Result<()> + Send + Sync; + +#[cfg(unix)] +impl UplinkBinder for CallbackBinder +where + F: Fn(RawFd, IpAddr) -> std::io::Result<()> + Send + Sync, +{ + fn bind(&self, sock: &Socket, ip: IpAddr) -> Result<()> { + (self.0)(sock.as_raw_fd(), ip).context("host bindSocket callback") + } +} + +/// Create a UDP socket with the standard nonblocking and buffer configuration. +/// The caller applies an [`UplinkBinder`] and then connects. +pub fn create_uplink_socket(domain_for: IpAddr) -> Result { + let domain = match domain_for { IpAddr::V4(_) => Domain::IPV4, IpAddr::V6(_) => Domain::IPV6, }; @@ -33,8 +91,6 @@ pub fn bind_from_ip(ip: IpAddr, port: u16) -> Result { } } - let addr = SocketAddr::new(ip, port); - sock.bind(&addr.into()).context("bind socket")?; Ok(sock) } diff --git a/src/control.rs b/src/control.rs new file mode 100644 index 0000000..d1477ae --- /dev/null +++ b/src/control.rs @@ -0,0 +1,449 @@ +//! Machine-friendly control protocol for `srtla_send`. +//! +//! Speaks JSON-RPC 2.0 over stdin or a Unix socket, one request per line. +//! Requests that omit `id` are notifications and get no response; this is +//! the hot path for `mark_critical` where an upstream encoder fires a +//! hint-per-keyframe and never wants to block waiting for an ACK. +//! +//! Methods: +//! - `set_mode { mode: "classic"|"enhanced" }` +//! - `set_quality { enabled: bool }` +//! - `set_stall_deselect { enabled: bool }` +//! - `get_status` → current `ConfigSnapshot` +//! - `get_stats` → per-link telemetry +//! +//! Keyframe / critical-packet hints travel on a dedicated UDP sidecar +//! (`crate::priority`) rather than over this control socket. The sidecar +//! shares the network stack with the SRT data path so priority events +//! are ordered tightly against the packets they describe. +//! +//! JSON-RPC error codes follow the spec: +//! `-32700` parse error, `-32600` invalid request, `-32601` method not found, +//! `-32602` invalid params, `-32603` internal error. Anything above `-32000` +//! is reserved for future app-specific errors. + +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +#[cfg(unix)] +use tokio::sync::mpsc; + +use crate::config::DynamicConfig; +use crate::mode::SchedulingMode; +use crate::priority::CriticalWindow; +use crate::stats::SharedStats; +#[cfg(unix)] +use crate::subscriptions::SubscriptionHub; + +const JSONRPC_VERSION: &str = "2.0"; + +const PARSE_ERROR: i32 = -32700; +const INVALID_REQUEST: i32 = -32600; +const METHOD_NOT_FOUND: i32 = -32601; +const INVALID_PARAMS: i32 = -32602; +const INTERNAL_ERROR: i32 = -32603; + +#[derive(Debug, Deserialize)] +struct Request { + jsonrpc: String, + method: String, + #[serde(default)] + params: Value, + #[serde(default)] + id: Option, +} + +#[derive(Debug, Serialize)] +pub struct Response { + jsonrpc: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + result: Option, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, + id: Value, +} + +#[derive(Debug, Serialize)] +struct ErrorObject { + code: i32, + message: String, + #[serde(skip_serializing_if = "Option::is_none")] + data: Option, +} + +impl ErrorObject { + fn new(code: i32, message: impl Into) -> Self { + Self { + code, + message: message.into(), + data: None, + } + } +} + +impl Response { + fn ok(id: Value, result: Value) -> Self { + Self { + jsonrpc: JSONRPC_VERSION, + result: Some(result), + error: None, + id, + } + } + + fn err(id: Value, err: ErrorObject) -> Self { + Self { + jsonrpc: JSONRPC_VERSION, + result: None, + error: Some(err), + id, + } + } + + pub fn to_json(&self) -> String { + serde_json::to_string(self).unwrap_or_else(|_| { + // Serializing our own Response type cannot realistically fail, + // but we never want to panic in the control plane. + r#"{"jsonrpc":"2.0","error":{"code":-32603,"message":"response serialization failed"},"id":null}"#.to_string() + }) + } +} + +/// Per-connection context needed for `subscribe` / `unsubscribe`. +/// +/// Unix-only, along with the rest of the async dispatch surface: its only +/// consumer is the Unix-domain control socket, which does not exist on Windows. +/// The sync [`dispatch`] path (stdin/readline, used on every platform) has no +/// push channel and answers subscribe-style methods with method-not-found. +#[cfg(unix)] +pub struct SubscriptionContext<'a> { + pub hub: &'a SubscriptionHub, + /// Push channel for *this* connection. Used by the hub to fan out + /// published events onto the socket. + pub push_tx: mpsc::Sender, + /// Subscription ids owned by this connection, for cleanup on drop. + pub owned_ids: &'a mut Vec, +} + +/// Sync dispatch — used by the stdin/readline loops. Subscriptions are +/// unsupported here (there's no push channel to write to) and requests +/// for `subscribe` / `unsubscribe` will come back with method not found. +pub fn dispatch( + config: &DynamicConfig, + stats: Option<&SharedStats>, + critical_window: Option<&CriticalWindow>, + line: &str, +) -> Option { + dispatch_inner(config, stats, critical_window, line) +} + +/// Async dispatch — used by the Unix socket handler, which has a push +/// channel and can support subscriptions. Any `subscribe`/`unsubscribe` +/// request routes through the given hub. +#[cfg(unix)] +pub async fn dispatch_async( + config: &DynamicConfig, + stats: Option<&SharedStats>, + critical_window: Option<&CriticalWindow>, + subscription_ctx: Option<&mut SubscriptionContext<'_>>, + line: &str, +) -> Option { + let line = line.trim(); + if line.is_empty() { + return None; + } + let req: Request = match serde_json::from_str(line) { + Ok(r) => r, + Err(e) => { + return Some(Response::err( + Value::Null, + ErrorObject { + code: PARSE_ERROR, + message: "parse error".into(), + data: Some(Value::String(e.to_string())), + }, + )); + } + }; + if req.jsonrpc != JSONRPC_VERSION { + return req.id.map(|id| { + Response::err( + id, + ErrorObject::new(INVALID_REQUEST, "jsonrpc version must be \"2.0\""), + ) + }); + } + + let is_notification = req.id.is_none(); + let id_for_response = req.id.clone().unwrap_or(Value::Null); + + let result = match (req.method.as_str(), subscription_ctx) { + ("subscribe", Some(ctx)) => handle_subscribe(ctx, &req.params).await, + ("unsubscribe", Some(ctx)) => handle_unsubscribe(ctx, &req.params).await, + ("get_subscription_count", Some(ctx)) => { + Ok(serde_json::json!({ "count": ctx.hub.len().await })) + } + (_, _) => handle_method(config, stats, critical_window, &req.method, &req.params), + }; + + if is_notification { + return None; + } + Some(match result { + Ok(value) => Response::ok(id_for_response, value), + Err(err) => Response::err(id_for_response, err), + }) +} + +#[cfg(unix)] +async fn handle_subscribe( + ctx: &mut SubscriptionContext<'_>, + params: &Value, +) -> Result { + let topic = params + .get("topic") + .and_then(Value::as_str) + .ok_or_else(|| ErrorObject::new(INVALID_PARAMS, "expected params.topic: string"))?; + if !is_known_topic(topic) { + return Err(ErrorObject::new( + INVALID_PARAMS, + format!("unknown topic: {topic}"), + )); + } + let id = ctx.hub.subscribe(topic, ctx.push_tx.clone()).await; + ctx.owned_ids.push(id.clone()); + Ok(serde_json::json!({ "subscription_id": id })) +} + +#[cfg(unix)] +async fn handle_unsubscribe( + ctx: &mut SubscriptionContext<'_>, + params: &Value, +) -> Result { + let id = params + .get("subscription_id") + .and_then(Value::as_str) + .ok_or_else(|| { + ErrorObject::new(INVALID_PARAMS, "expected params.subscription_id: string") + })?; + let removed = ctx.hub.unsubscribe(id).await; + ctx.owned_ids.retain(|x| x != id); + Ok(serde_json::json!({ "removed": removed })) +} + +#[cfg(unix)] +fn is_known_topic(topic: &str) -> bool { + matches!(topic, "stats" | "priority.window") +} + +fn dispatch_inner( + config: &DynamicConfig, + stats: Option<&SharedStats>, + critical_window: Option<&CriticalWindow>, + line: &str, +) -> Option { + let line = line.trim(); + if line.is_empty() { + return None; + } + + let req: Request = match serde_json::from_str(line) { + Ok(r) => r, + Err(e) => { + // No id available — reply with null per spec. + return Some(Response::err( + Value::Null, + ErrorObject { + code: PARSE_ERROR, + message: "parse error".into(), + data: Some(Value::String(e.to_string())), + }, + )); + } + }; + + if req.jsonrpc != JSONRPC_VERSION { + return req.id.map(|id| { + Response::err( + id, + ErrorObject::new(INVALID_REQUEST, "jsonrpc version must be \"2.0\""), + ) + }); + } + + let is_notification = req.id.is_none(); + let id_for_response = req.id.clone().unwrap_or(Value::Null); + let result = handle_method(config, stats, critical_window, &req.method, &req.params); + + if is_notification { + return None; + } + + Some(match result { + Ok(value) => Response::ok(id_for_response, value), + Err(err) => Response::err(id_for_response, err), + }) +} + +fn handle_method( + config: &DynamicConfig, + stats: Option<&SharedStats>, + critical_window: Option<&CriticalWindow>, + method: &str, + params: &Value, +) -> Result { + match method { + "set_mode" => { + let mode_str = params + .get("mode") + .and_then(Value::as_str) + .ok_or_else(|| ErrorObject::new(INVALID_PARAMS, "expected params.mode: string"))?; + let mode = parse_mode(mode_str)?; + config.set_mode(mode); + Ok(json!({ "mode": mode.to_string() })) + } + + "set_quality" => { + let enabled = params + .get("enabled") + .and_then(Value::as_bool) + .ok_or_else(|| ErrorObject::new(INVALID_PARAMS, "expected params.enabled: bool"))?; + config.set_quality_enabled(enabled); + Ok(json!({ "enabled": enabled })) + } + + "set_stall_deselect" => { + let enabled = params + .get("enabled") + .and_then(Value::as_bool) + .ok_or_else(|| ErrorObject::new(INVALID_PARAMS, "expected params.enabled: bool"))?; + config.set_stall_deselect(enabled); + Ok(json!({ "enabled": enabled })) + } + + "get_status" => { + let snap = config.snapshot(); + let (windows_received, malformed) = critical_window + .map(|w| (w.windows_received(), w.malformed_datagrams())) + .unwrap_or((0, 0)); + Ok(json!({ + "mode": snap.mode.to_string(), + "quality_enabled": snap.quality_enabled, + "stall_deselect": snap.stall_deselect, + "stall_min_in_flight": snap.stall_min_in_flight, + "stall_ack_stale_ms": snap.stall_ack_stale_ms, + "critical_windows_received": windows_received, + "critical_malformed_datagrams": malformed, + })) + } + + "get_stats" => { + let stats = stats + .ok_or_else(|| ErrorObject::new(INTERNAL_ERROR, "stats provider not registered"))?; + let json_str = stats.to_json(); + serde_json::from_str(&json_str).map_err(|e| ErrorObject { + code: INTERNAL_ERROR, + message: "failed to re-parse stats JSON".into(), + data: Some(Value::String(e.to_string())), + }) + } + + // Reserved for the future streaming API. A subscription-capable + // control plane will replace these returning METHOD_NOT_FOUND with + // a persistent-connection impl. Reserving the names now so clients + // written against the current protocol don't collide with built-in + // methods when the streaming upgrade lands. + "subscribe" | "unsubscribe" => Err(ErrorObject::new( + METHOD_NOT_FOUND, + format!("{method} is reserved for a future streaming protocol, not yet implemented"), + )), + + other => Err(ErrorObject::new( + METHOD_NOT_FOUND, + format!("unknown method: {other}"), + )), + } +} + +fn parse_mode(s: &str) -> Result { + match s { + "classic" => Ok(SchedulingMode::Classic), + "enhanced" => Ok(SchedulingMode::Enhanced), + other => Err(ErrorObject::new( + INVALID_PARAMS, + format!("unknown mode '{other}': use classic or enhanced"), + )), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_error_returns_jsonrpc_error() { + let config = DynamicConfig::new(); + let resp = dispatch(&config, None, None, "not valid json").unwrap(); + let v: Value = serde_json::from_str(&resp.to_json()).unwrap(); + assert_eq!(v["error"]["code"], PARSE_ERROR); + assert_eq!(v["id"], Value::Null); + } + + #[test] + fn notification_returns_none() { + let config = DynamicConfig::new(); + // set_mode happens to work as a notification; no id means no response. + let req = r#"{"jsonrpc":"2.0","method":"set_mode","params":{"mode":"classic"}}"#; + assert!(dispatch(&config, None, None, req).is_none()); + assert_eq!(config.mode(), SchedulingMode::Classic); + } + + #[test] + fn set_mode_happy_path() { + let config = DynamicConfig::new(); + let req = r#"{"jsonrpc":"2.0","id":1,"method":"set_mode","params":{"mode":"classic"}}"#; + let resp = dispatch(&config, None, None, req).unwrap(); + let v: Value = serde_json::from_str(&resp.to_json()).unwrap(); + assert_eq!(v["result"]["mode"], "classic"); + assert_eq!(v["id"], 1); + assert_eq!(config.mode(), SchedulingMode::Classic); + } + + #[test] + fn unknown_method_returns_method_not_found() { + let config = DynamicConfig::new(); + let req = r#"{"jsonrpc":"2.0","id":"abc","method":"noop"}"#; + let resp = dispatch(&config, None, None, req).unwrap(); + let v: Value = serde_json::from_str(&resp.to_json()).unwrap(); + assert_eq!(v["error"]["code"], METHOD_NOT_FOUND); + assert_eq!(v["id"], "abc"); + } + + #[test] + fn invalid_params_returns_invalid_params() { + let config = DynamicConfig::new(); + let req = r#"{"jsonrpc":"2.0","id":7,"method":"set_quality","params":{}}"#; + let resp = dispatch(&config, None, None, req).unwrap(); + let v: Value = serde_json::from_str(&resp.to_json()).unwrap(); + assert_eq!(v["error"]["code"], INVALID_PARAMS); + } + + #[test] + fn wrong_jsonrpc_version_rejects() { + let config = DynamicConfig::new(); + let req = r#"{"jsonrpc":"1.0","id":1,"method":"get_status"}"#; + let resp = dispatch(&config, None, None, req).unwrap(); + let v: Value = serde_json::from_str(&resp.to_json()).unwrap(); + assert_eq!(v["error"]["code"], INVALID_REQUEST); + } + + #[test] + fn get_status_returns_all_fields() { + let config = DynamicConfig::new(); + let req = r#"{"jsonrpc":"2.0","id":1,"method":"get_status"}"#; + let resp = dispatch(&config, None, None, req).unwrap(); + let v: Value = serde_json::from_str(&resp.to_json()).unwrap(); + let result = &v["result"]; + assert!(result["mode"].is_string()); + assert!(result["quality_enabled"].is_boolean()); + } +} diff --git a/src/control_socket.rs b/src/control_socket.rs new file mode 100644 index 0000000..e825c2f --- /dev/null +++ b/src/control_socket.rs @@ -0,0 +1,159 @@ +//! Async Unix control socket. +//! +//! Runs on the ambient tokio runtime so each connection can +//! `tokio::select` between reading client requests and writing +//! server-pushed subscription events on the same socket. Replaces the +//! earlier blocking `std::net::UnixListener` + `std::thread` design +//! which could only do strict request/response. +//! +//! Accepts the JSON-RPC protocol documented in +//! `docs/CONTROL_PROTOCOL.md`. Subscriptions described in that doc are +//! handled here — the hub's fan-out writes each published event onto +//! the appropriate connection's push channel. + +#[cfg(unix)] +use std::path::PathBuf; + +#[cfg(unix)] +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +#[cfg(unix)] +use tokio::net::{UnixListener, UnixStream}; +#[cfg(unix)] +use tokio::sync::mpsc; +#[cfg(unix)] +use tracing::{debug, info, warn}; + +use crate::config::DynamicConfig; +#[cfg(unix)] +use crate::control::{SubscriptionContext, dispatch_async}; +use crate::priority::CriticalWindow; +use crate::stats::SharedStats; +use crate::subscriptions::SubscriptionHub; + +#[cfg(unix)] +pub fn spawn( + socket_path: String, + config: DynamicConfig, + stats: SharedStats, + critical_window: CriticalWindow, + hub: SubscriptionHub, +) -> tokio::task::JoinHandle<()> { + tokio::spawn(async move { + if let Err(e) = run(socket_path.into(), config, stats, critical_window, hub).await { + warn!(error = %e, "control socket listener exited"); + } + }) +} + +#[cfg(not(unix))] +pub fn spawn( + _socket_path: String, + _config: DynamicConfig, + _stats: SharedStats, + _critical_window: CriticalWindow, + _hub: SubscriptionHub, +) -> tokio::task::JoinHandle<()> { + tokio::spawn(async {}) +} + +#[cfg(unix)] +async fn run( + socket_path: PathBuf, + config: DynamicConfig, + stats: SharedStats, + critical_window: CriticalWindow, + hub: SubscriptionHub, +) -> std::io::Result<()> { + // Remove stale socket file from a previous run. + let _ = std::fs::remove_file(&socket_path); + let listener = UnixListener::bind(&socket_path)?; + info!(?socket_path, "unix control socket listening"); + + loop { + match listener.accept().await { + Ok((stream, _addr)) => { + let config = config.clone(); + let stats = stats.clone(); + let cw = critical_window.clone(); + let hub = hub.clone(); + tokio::spawn(async move { + handle(stream, config, stats, cw, hub).await; + }); + } + Err(e) => { + debug!(error = %e, "accept failed"); + } + } + } +} + +#[cfg(unix)] +async fn handle( + stream: UnixStream, + config: DynamicConfig, + stats: SharedStats, + critical_window: CriticalWindow, + hub: SubscriptionHub, +) { + let (read_half, mut write_half) = stream.into_split(); + let mut reader = BufReader::new(read_half); + let mut line = String::new(); + let (push_tx, mut push_rx) = mpsc::channel::(128); + let mut owned_ids: Vec = Vec::new(); + + loop { + tokio::select! { + read_res = reader.read_line(&mut line) => { + match read_res { + Ok(0) => break, // EOF + Ok(_) => { + let trimmed = line.trim().to_string(); + line.clear(); + if trimmed.is_empty() { + continue; + } + let mut ctx = SubscriptionContext { + hub: &hub, + push_tx: push_tx.clone(), + owned_ids: &mut owned_ids, + }; + let resp = dispatch_async( + &config, + Some(&stats), + Some(&critical_window), + Some(&mut ctx), + &trimmed, + ) + .await; + if let Some(resp) = resp { + let json = resp.to_json(); + if write_half.write_all(json.as_bytes()).await.is_err() { + break; + } + if write_half.write_all(b"\n").await.is_err() { + break; + } + } + } + Err(e) => { + debug!(error = %e, "read failed"); + break; + } + } + } + Some(push_line) = push_rx.recv() => { + if write_half.write_all(push_line.as_bytes()).await.is_err() { + break; + } + if write_half.write_all(b"\n").await.is_err() { + break; + } + } + } + } + + // Clean up this connection's subscriptions from the hub. + for id in owned_ids { + hub.unsubscribe(&id).await; + } +} diff --git a/src/ewma.rs b/src/ewma.rs index 95ce972..6ccc699 100644 --- a/src/ewma.rs +++ b/src/ewma.rs @@ -51,6 +51,74 @@ impl Ewma { } } +/// Asymmetric Exponentially Weighted Moving Average filter. +/// +/// Uses separate smoothing factors for increasing vs decreasing measurements, +/// enabling fast-down/slow-up (or vice versa) tracking. This is useful for +/// congestion signals where you want to react quickly to degradation but +/// recover cautiously. +/// +/// - `alpha_down`: smoothing factor when the new measurement is *below* the +/// current value (value is decreasing). Higher = tracks drops faster. +/// - `alpha_up`: smoothing factor when the new measurement is *above* the +/// current value (value is increasing). Lower = recovers more slowly. +#[cfg(test)] +#[derive(Debug, Clone)] +pub struct AsymmetricEwma { + value: f64, + alpha_up: f64, + alpha_down: f64, + initialized: bool, +} + +#[cfg(test)] +impl AsymmetricEwma { + /// Creates a new asymmetric EWMA filter. + /// + /// * `alpha_up` – smoothing factor for increasing values (`0.0 < α ≤ 1.0`) + /// * `alpha_down` – smoothing factor for decreasing values (`0.0 < α ≤ 1.0`) + pub fn new(alpha_up: f64, alpha_down: f64) -> Self { + Self { + value: 0.0, + alpha_up, + alpha_down, + initialized: false, + } + } + + /// Feeds a new measurement into the filter, updating the smoothed value. + /// + /// Picks `alpha_down` when the measurement is below the current value, + /// `alpha_up` otherwise. NaN or infinite measurements are silently ignored. + pub fn update(&mut self, measurement: f64) { + if measurement.is_nan() || measurement.is_infinite() { + return; + } + if !self.initialized { + self.value = measurement; + self.initialized = true; + } else { + let alpha = if measurement < self.value { + self.alpha_down + } else { + self.alpha_up + }; + self.value = self.value * (1.0 - alpha) + measurement * alpha; + } + } + + /// Returns the current smoothed value. + pub fn value(&self) -> f64 { + self.value + } + + /// Resets the filter to its uninitialized state. + pub fn reset(&mut self) { + self.value = 0.0; + self.initialized = false; + } +} + #[cfg(test)] mod tests { use super::*; @@ -171,4 +239,118 @@ mod tests { ewma.update(50.0); assert!((ewma.value() - 50.0).abs() < f64::EPSILON); } + + // --- AsymmetricEwma tests --- + + #[test] + fn test_asymmetric_ewma_first_sample_initializes() { + let mut ewma = AsymmetricEwma::new(0.3, 0.7); + ewma.update(100.0); + assert!((ewma.value() - 100.0).abs() < f64::EPSILON); + } + + #[test] + fn test_asymmetric_ewma_fast_down() { + // alpha_down = 0.7 (fast), alpha_up = 0.3 (slow) + let mut ewma = AsymmetricEwma::new(0.3, 0.7); + ewma.update(100.0); + + // Decrease: value = 100 * 0.3 + 0 * 0.7 = 30 + ewma.update(0.0); + assert!((ewma.value() - 30.0).abs() < 0.001); + } + + #[test] + fn test_asymmetric_ewma_slow_up() { + // alpha_down = 0.7 (fast), alpha_up = 0.3 (slow) + let mut ewma = AsymmetricEwma::new(0.3, 0.7); + ewma.update(0.0); + + // Increase: value = 0 * 0.7 + 100 * 0.3 = 30 + ewma.update(100.0); + assert!((ewma.value() - 30.0).abs() < 0.001); + } + + #[test] + fn test_asymmetric_ewma_drops_faster_than_rises() { + let mut drop_ewma = AsymmetricEwma::new(0.3, 0.7); + let mut rise_ewma = AsymmetricEwma::new(0.3, 0.7); + + // Both start at 50 + drop_ewma.update(50.0); + rise_ewma.update(50.0); + + // Drop from 50 toward 0 (uses alpha_down = 0.7) + drop_ewma.update(0.0); + let drop_distance = (50.0 - drop_ewma.value()).abs(); + + // Rise from 50 toward 100 (uses alpha_up = 0.3) + rise_ewma.update(100.0); + let rise_distance = (rise_ewma.value() - 50.0).abs(); + + // Drop should cover more distance than rise + assert!(drop_distance > rise_distance); + } + + #[test] + fn test_asymmetric_ewma_equal_alphas_matches_ewma() { + let mut asym = AsymmetricEwma::new(0.5, 0.5); + let mut sym = Ewma::new(0.5); + + for &v in &[10.0, 20.0, 5.0, 30.0, 15.0] { + asym.update(v); + sym.update(v); + assert!( + (asym.value() - sym.value()).abs() < f64::EPSILON, + "Mismatch at input {v}: asym={} sym={}", + asym.value(), + sym.value() + ); + } + } + + #[test] + fn test_asymmetric_ewma_nan_guard() { + let mut ewma = AsymmetricEwma::new(0.3, 0.7); + ewma.update(10.0); + ewma.update(f64::NAN); + assert!((ewma.value() - 10.0).abs() < f64::EPSILON); + + ewma.update(f64::INFINITY); + assert!((ewma.value() - 10.0).abs() < f64::EPSILON); + + ewma.update(f64::NEG_INFINITY); + assert!((ewma.value() - 10.0).abs() < f64::EPSILON); + } + + #[test] + fn test_asymmetric_ewma_nan_on_first_sample() { + let mut ewma = AsymmetricEwma::new(0.3, 0.7); + ewma.update(f64::NAN); + assert!((ewma.value() - 0.0).abs() < f64::EPSILON); + + ewma.update(42.0); + assert!((ewma.value() - 42.0).abs() < f64::EPSILON); + } + + #[test] + fn test_asymmetric_ewma_reset() { + let mut ewma = AsymmetricEwma::new(0.3, 0.7); + ewma.update(100.0); + + ewma.reset(); + assert!((ewma.value() - 0.0).abs() < f64::EPSILON); + + ewma.update(50.0); + assert!((ewma.value() - 50.0).abs() < f64::EPSILON); + } + + #[test] + fn test_asymmetric_ewma_converges_to_constant() { + let mut ewma = AsymmetricEwma::new(0.3, 0.7); + for _ in 0..200 { + ewma.update(42.0); + } + assert!((ewma.value() - 42.0).abs() < 0.001); + } } diff --git a/src/kalman.rs b/src/kalman.rs index f36e579..418b5f8 100644 --- a/src/kalman.rs +++ b/src/kalman.rs @@ -202,4 +202,75 @@ mod tests { assert!(!kf.is_initialized()); assert!((kf.value() - 0.0).abs() < f64::EPSILON); } + + #[test] + fn kalman_init_value_is_zero() { + let kf = KalmanFilter::new(KalmanConfig::for_rtt()); + assert!(!kf.is_initialized()); + assert_eq!(kf.value(), 0.0); + assert_eq!(kf.velocity(), 0.0); + } + + #[test] + fn kalman_converges_toward_input() { + let mut kf = KalmanFilter::new(KalmanConfig::for_rtt()); + const INPUT: f64 = 73.0; + for _ in 0..200 { + kf.update(INPUT); + } + assert!( + (kf.value() - INPUT).abs() < 0.1, + "should converge toward input: got {}", + kf.value() + ); + assert!( + kf.velocity().abs() < 0.1, + "velocity should flatten on a constant signal: got {}", + kf.velocity() + ); + } + + #[test] + fn kalman_clamp_non_negative() { + let mut kf = KalmanFilter::new(KalmanConfig::for_rtt()); + + // Sustained very-high RTT then a sharp drop builds a steep negative + // velocity; the predict step (x + v) overshoots below 0 — the warm-up + // overshoot the clamp in get_smooth_rtt_ms exists to floor. + for _ in 0..5 { + kf.update(10_000.0); + } + for _ in 0..3 { + kf.update(50.0); + } + + assert!( + kf.value() < 0.0, + "precondition: Kalman should overshoot negative, got {}", + kf.value() + ); + + let clamped = kf.value().max(0.0); + assert_eq!(clamped, 0.0, "a negative estimate clamps to exactly 0.0"); + } + + #[test] + fn kalman_zero_sample_handling() { + let mut kf = KalmanFilter::new(KalmanConfig::for_rtt()); + + kf.update(0.0); + assert!(kf.is_initialized()); + assert_eq!(kf.value(), 0.0); + + for _ in 0..10 { + kf.update(0.0); + } + assert!( + kf.value().is_finite(), + "value must stay finite: {}", + kf.value() + ); + assert!(!kf.value().is_nan(), "value must not be NaN"); + assert!(kf.velocity().is_finite(), "velocity must stay finite"); + } } diff --git a/src/lib.rs b/src/lib.rs index 0ee9e6a..2348569 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,21 +4,30 @@ //! aggregation) sender implementation. It includes protocol handling, //! connection management, and dynamic configuration. -// Use mimalloc as the global allocator for tests (non-Windows only) +// Use mimalloc as the global allocator for tests (non-Windows only). Excluded +// under miri: the batch_recv miri CI lane interprets the test binary, and miri +// cannot execute mimalloc's C FFI, so those runs fall back to miri's own +// allocator instead. #[cfg(not(windows))] -#[cfg(test)] +#[cfg(all(test, not(miri)))] #[global_allocator] static ALLOC: mimalloc::MiMalloc = mimalloc::MiMalloc; pub mod config; pub mod connection; +pub mod control; +pub mod control_socket; pub mod ewma; pub mod kalman; +pub mod metrics; pub mod mode; +pub mod priority; pub mod protocol; pub mod registration; pub mod sender; pub mod stats; +pub mod subscriptions; +pub mod toml_config; pub mod utils; // Test helpers module - available when test-internals feature is enabled diff --git a/src/main.rs b/src/main.rs index 838b5f7..f56a1f7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,13 +9,19 @@ static ALLOC: mimalloc::MiMalloc = mimalloc::MiMalloc; mod config; mod connection; +mod control; +mod control_socket; mod ewma; mod kalman; +mod metrics; mod mode; +mod priority; mod protocol; mod registration; mod sender; mod stats; +mod subscriptions; +mod toml_config; mod utils; // Test helpers for binary tests @@ -58,18 +64,60 @@ struct Cli { #[arg(long = "control-socket")] control_socket: Option, - /// Scheduling mode: classic, enhanced (default), rtt-threshold + /// Path to TOML config file (reloaded on SIGHUP) + #[arg(long = "config")] + config_file: Option, + + /// Scheduling mode: classic, enhanced (default) #[arg(long = "mode", value_enum, default_value = "enhanced")] mode: SchedulingMode, - /// Disable quality scoring (enhanced/rtt-threshold only) + /// Disable quality scoring (enhanced only) #[arg(long = "no-quality")] no_quality: bool, - /// Enable connection exploration (enhanced only) - #[arg(long = "exploration")] - exploration: bool, - /// RTT delta threshold in ms (rtt-threshold only, links within min_rtt + delta are "fast") - #[arg(long = "rtt-delta-ms", default_value = "30")] - rtt_delta_ms: u32, + + /// Disable the stalled-link deselect guard (on by default). The guard skips + /// a link whose in-flight backlog is high while its last delivery proof has + /// gone stale, provided a healthier link can carry the traffic; it recovers + /// the link automatically on its next keepalive round-trip. + #[arg(long = "no-stall-deselect")] + no_stall_deselect: bool, + /// In-flight packet backlog at or above which a link becomes a stall + /// candidate for `--no-stall-deselect`. + #[arg(long = "stall-min-in-flight", default_value_t = config::STALL_MIN_IN_FLIGHT_PACKETS)] + stall_min_in_flight: i32, + /// Delivery-proof staleness window (ms) after which a stall-candidate link + /// is deselected. + #[arg(long = "stall-ack-stale-ms", default_value_t = config::STALL_ACK_STALE_MS)] + stall_ack_stale_ms: u64, + + /// UDP bind address for the keyframe priority sidecar. The encoder + /// front-end sends 5-byte datagrams here to open a critical routing + /// window. Unauthenticated same-device IPC: bind loopback. Omit to + /// disable the sidecar (the keyframe-priority override is then inactive). + /// Example: `127.0.0.1:7000`. + #[arg(long = "priority-bind")] + priority_bind: Option, + + /// TCP bind address for the Prometheus `/metrics` scrape endpoint. + /// Unauthenticated: bind loopback. Omit to disable. + /// Example: `127.0.0.1:9099`. + #[arg(long = "metrics-bind")] + metrics_bind: Option, +} + +/// Warn when a sidecar is bound to a non-loopback address. These endpoints +/// are unauthenticated same-device IPC (encoder front-end and local scrapers), +/// so a routable bind exposes an open control / scrape surface. We warn rather +/// than refuse so an operator can still bind elsewhere on a trusted network if +/// they explicitly choose to. +fn warn_if_not_loopback(what: &str, addr: std::net::SocketAddr) { + if !addr.ip().is_loopback() { + tracing::warn!( + %addr, + "{what} bound to a non-loopback address; it is unauthenticated and \ + should normally bind 127.0.0.1 / ::1" + ); + } } #[tokio::main(flavor = "multi_thread")] @@ -102,18 +150,66 @@ async fn main() -> Result<()> { let receiver_port = args.receiver_port.expect("required"); let ips_file = args.ips_file.as_deref().expect("required"); + // Load TOML config (if specified), then apply CLI overrides + if let Some(ref path) = args.config_file { + let toml_cfg = toml_config::TomlConfig::load_or_default(std::path::Path::new(path)); + tracing::debug!("TOML config loaded: {:?}", toml_cfg); + } + let config = config::DynamicConfig::from_cli( args.mode, args.no_quality, - args.exploration, - args.rtt_delta_ms, + args.no_stall_deselect, + args.stall_min_in_flight, + args.stall_ack_stale_ms, ); // Create shared stats for telemetry export let shared_stats = stats::SharedStats::new(); - // Start config listener (stdin or Unix socket) - config::spawn_config_listener(config.clone(), args.control_socket, shared_stats.clone()); + let subscription_hub = subscriptions::SubscriptionHub::new(); + + let critical_window = priority::CriticalWindow::new(); + if let Some(bind) = args.priority_bind { + warn_if_not_loopback("priority sidecar (--priority-bind)", bind); + priority::spawn_listener( + bind, + critical_window.clone(), + Some(subscription_hub.clone()), + ); + } + + if let Some(bind) = args.metrics_bind { + warn_if_not_loopback("metrics endpoint (--metrics-bind)", bind); + metrics::spawn_server( + bind, + shared_stats.clone(), + config.clone(), + critical_window.clone(), + ); + } + + // Stdin reader stays blocking; Unix socket goes async to support + // subscription pushes. + config::spawn_stdin_listener( + config.clone(), + shared_stats.clone(), + critical_window.clone(), + ); + if let Some(sock_path) = args.control_socket { + control_socket::spawn( + sock_path, + config.clone(), + shared_stats.clone(), + critical_window.clone(), + subscription_hub.clone(), + ); + } + + // The CLI binds each uplink by its source IP, which on a multi-homed host + // selects the egress via source-based routing. + let binder: std::sync::Arc = + std::sync::Arc::new(connection::SourceIpBinder); sender::run_sender_with_config( local_srt_port, @@ -122,6 +218,9 @@ async fn main() -> Result<()> { ips_file, config, shared_stats, + critical_window, + subscription_hub, + binder, ) .await .context("srtla_send failed") diff --git a/src/metrics.rs b/src/metrics.rs new file mode 100644 index 0000000..591e714 --- /dev/null +++ b/src/metrics.rs @@ -0,0 +1,382 @@ +//! Prometheus `/metrics` endpoint. +//! +//! Renders the current [`crate::stats::StatsSnapshot`], [`crate::priority::CriticalWindow`] +//! counters, and [`crate::config::DynamicConfig`] as Prometheus text format. +//! Intended for scraping by prometheus / VictoriaMetrics / grafana agent. +//! +//! The HTTP server is hand-rolled on top of `tokio::net::TcpListener` to +//! avoid pulling axum / hyper / tower into srtla_send's dep tree. Only +//! the bare minimum is supported: `GET /metrics` and `GET /` return the +//! exposition text; anything else gets a 404. Responses always close +//! the connection (no keep-alive, no pipelining). For a scraping endpoint +//! this is plenty — Prometheus opens a fresh connection per scrape. + +use std::fmt::Write; +use std::net::SocketAddr; + +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpListener; +use tracing::{debug, info, warn}; + +use crate::config::DynamicConfig; +use crate::mode::SchedulingMode; +use crate::priority::CriticalWindow; +use crate::stats::SharedStats; + +/// Render the current state as a Prometheus text-format exposition. +pub fn render(stats: &SharedStats, config: &DynamicConfig, cw: &CriticalWindow) -> String { + let snap = stats.get(); + let mut out = String::with_capacity(2048); + + // Link-level gauges. One series per link, labeled by local IP. + writeln!( + out, + "# HELP srtla_send_link_up 1 if the link is connected and not timed out" + ) + .ok(); + writeln!(out, "# TYPE srtla_send_link_up gauge").ok(); + for link in &snap.links { + let up = if link.connected && !link.timed_out { + 1 + } else { + 0 + }; + writeln!(out, r#"srtla_send_link_up{{ip="{}"}} {up}"#, link.ip).ok(); + } + + writeln!(out, "# HELP srtla_send_link_rtt_ms smoothed RTT").ok(); + writeln!(out, "# TYPE srtla_send_link_rtt_ms gauge").ok(); + for link in &snap.links { + writeln!( + out, + r#"srtla_send_link_rtt_ms{{ip="{}"}} {}"#, + link.ip, link.rtt_ms + ) + .ok(); + } + + writeln!( + out, + "# HELP srtla_send_link_rtt_min_ms dual-window minimum RTT baseline" + ) + .ok(); + writeln!(out, "# TYPE srtla_send_link_rtt_min_ms gauge").ok(); + for link in &snap.links { + writeln!( + out, + r#"srtla_send_link_rtt_min_ms{{ip="{}"}} {}"#, + link.ip, link.rtt_min_ms + ) + .ok(); + } + + writeln!( + out, + "# HELP srtla_send_link_rtt_velocity Kalman RTT velocity, ms/sample (positive = rising)" + ) + .ok(); + writeln!(out, "# TYPE srtla_send_link_rtt_velocity gauge").ok(); + for link in &snap.links { + writeln!( + out, + r#"srtla_send_link_rtt_velocity{{ip="{}"}} {}"#, + link.ip, link.rtt_velocity + ) + .ok(); + } + + writeln!( + out, + "# HELP srtla_send_link_window congestion window size (packets)" + ) + .ok(); + writeln!(out, "# TYPE srtla_send_link_window gauge").ok(); + for link in &snap.links { + writeln!( + out, + r#"srtla_send_link_window{{ip="{}"}} {}"#, + link.ip, link.window + ) + .ok(); + } + + writeln!( + out, + "# HELP srtla_send_link_in_flight packets sent but not yet ACKed" + ) + .ok(); + writeln!(out, "# TYPE srtla_send_link_in_flight gauge").ok(); + for link in &snap.links { + writeln!( + out, + r#"srtla_send_link_in_flight{{ip="{}"}} {}"#, + link.ip, link.in_flight + ) + .ok(); + } + + writeln!(out, "# HELP srtla_send_link_nak_total cumulative NAK count").ok(); + writeln!(out, "# TYPE srtla_send_link_nak_total counter").ok(); + for link in &snap.links { + writeln!( + out, + r#"srtla_send_link_nak_total{{ip="{}"}} {}"#, + link.ip, link.nak_count + ) + .ok(); + } + + // Renamed from srtla_send_link_bitrate_bps, which reported bytes/sec + // under a bits/sec name. Prometheus convention is base units, so the + // name now states the unit it actually carries. + writeln!( + out, + "# HELP srtla_send_link_bitrate_bytes_per_second measured send rate, bytes/sec" + ) + .ok(); + writeln!(out, "# TYPE srtla_send_link_bitrate_bytes_per_second gauge").ok(); + for link in &snap.links { + writeln!( + out, + r#"srtla_send_link_bitrate_bytes_per_second{{ip="{}"}} {}"#, + link.ip, link.bitrate_bytes_per_sec + ) + .ok(); + } + + writeln!( + out, + "# HELP srtla_send_link_quality_multiplier scheduler quality multiplier in [0.35, 1.1]" + ) + .ok(); + writeln!(out, "# TYPE srtla_send_link_quality_multiplier gauge").ok(); + for link in &snap.links { + writeln!( + out, + r#"srtla_send_link_quality_multiplier{{ip="{}"}} {}"#, + link.ip, link.quality_multiplier + ) + .ok(); + } + + // Aggregate gauges. + writeln!( + out, + "# HELP srtla_send_active_links links currently connected and live" + ) + .ok(); + writeln!(out, "# TYPE srtla_send_active_links gauge").ok(); + writeln!(out, "srtla_send_active_links {}", snap.active_links).ok(); + + writeln!(out, "# HELP srtla_send_total_links configured link count").ok(); + writeln!(out, "# TYPE srtla_send_total_links gauge").ok(); + writeln!(out, "srtla_send_total_links {}", snap.total_links).ok(); + + writeln!( + out, + "# HELP srtla_send_total_window summed window across active links" + ) + .ok(); + writeln!(out, "# TYPE srtla_send_total_window gauge").ok(); + writeln!(out, "srtla_send_total_window {}", snap.total_window).ok(); + + writeln!( + out, + "# HELP srtla_send_total_in_flight summed in-flight across active links" + ) + .ok(); + writeln!(out, "# TYPE srtla_send_total_in_flight gauge").ok(); + writeln!(out, "srtla_send_total_in_flight {}", snap.total_in_flight).ok(); + + // Scheduler config surfaced as a gauge so Grafana can pivot on it. + writeln!( + out, + "# HELP srtla_send_mode scheduling mode (0=classic,1=enhanced)" + ) + .ok(); + writeln!(out, "# TYPE srtla_send_mode gauge").ok(); + let mode = match config.mode() { + SchedulingMode::Classic => 0, + SchedulingMode::Enhanced => 1, + }; + writeln!(out, "srtla_send_mode {mode}").ok(); + + // Priority sidecar counters. + writeln!( + out, + "# HELP srtla_send_critical_windows_total total keyframe-priority datagrams applied" + ) + .ok(); + writeln!(out, "# TYPE srtla_send_critical_windows_total counter").ok(); + writeln!( + out, + "srtla_send_critical_windows_total {}", + cw.windows_received() + ) + .ok(); + + writeln!( + out, + "# HELP srtla_send_critical_malformed_datagrams_total malformed priority-sidecar datagrams" + ) + .ok(); + writeln!( + out, + "# TYPE srtla_send_critical_malformed_datagrams_total counter" + ) + .ok(); + writeln!( + out, + "srtla_send_critical_malformed_datagrams_total {}", + cw.malformed_datagrams() + ) + .ok(); + + // Suppress unused-variable warnings when all fields above are covered. + let _ = snap; + + out +} + +/// Spawn the Prometheus scrape endpoint. Runs on the main tokio runtime. +pub fn spawn_server( + bind: SocketAddr, + stats: SharedStats, + config: DynamicConfig, + cw: CriticalWindow, +) -> tokio::task::JoinHandle<()> { + tokio::spawn(async move { + let listener = match TcpListener::bind(bind).await { + Ok(l) => l, + Err(e) => { + warn!(%bind, error = %e, "failed to bind prometheus endpoint"); + return; + } + }; + let local = listener.local_addr().ok(); + info!(?local, "prometheus /metrics endpoint listening"); + + loop { + let (stream, peer) = match listener.accept().await { + Ok(pair) => pair, + Err(e) => { + debug!(error = %e, "prometheus accept error"); + continue; + } + }; + let stats = stats.clone(); + let config = config.clone(); + let cw = cw.clone(); + tokio::spawn(async move { + if let Err(e) = serve_one(stream, &stats, &config, &cw).await { + debug!(%peer, error = %e, "prometheus scrape error"); + } + }); + } + }) +} + +async fn serve_one( + mut stream: tokio::net::TcpStream, + stats: &SharedStats, + config: &DynamicConfig, + cw: &CriticalWindow, +) -> std::io::Result<()> { + // Read until we've seen the end of the request headers. One read + // usually suffices for a scraper-originated GET; cap at 4 KiB to + // prevent slowloris-style games. + let mut buf = [0u8; 4096]; + let mut len = 0; + loop { + if len == buf.len() { + break; + } + let n = stream.read(&mut buf[len..]).await?; + if n == 0 { + break; + } + len += n; + if buf[..len].windows(4).any(|w| w == b"\r\n\r\n") { + break; + } + } + let request = &buf[..len]; + let path = request_path(request); + let body = match path.as_deref() { + Some("/metrics") | Some("/") => render(stats, config, cw), + _ => { + let resp = b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"; + stream.write_all(resp).await?; + return Ok(()); + } + }; + + let header = format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/plain; version=0.0.4\r\nContent-Length: \ + {}\r\nConnection: close\r\n\r\n", + body.len() + ); + stream.write_all(header.as_bytes()).await?; + stream.write_all(body.as_bytes()).await?; + Ok(()) +} + +fn request_path(request: &[u8]) -> Option { + // GET /metrics HTTP/1.1 + let first_line_end = request.iter().position(|&b| b == b'\r')?; + let line = std::str::from_utf8(&request[..first_line_end]).ok()?; + let mut parts = line.split(' '); + let method = parts.next()?; + if !method.eq_ignore_ascii_case("GET") { + return None; + } + parts.next().map(str::to_string) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn render_contains_core_metric_lines() { + let stats = SharedStats::new(); + let config = DynamicConfig::new(); + let cw = CriticalWindow::new(); + let text = render(&stats, &config, &cw); + assert!(text.contains("srtla_send_active_links")); + assert!(text.contains("srtla_send_total_links")); + assert!(text.contains("srtla_send_critical_windows_total")); + assert!(text.contains("srtla_send_mode")); + } + + #[test] + fn render_outputs_valid_prom_shape() { + // Every HELP / TYPE comment should be followed by at least one sample. + let stats = SharedStats::new(); + let config = DynamicConfig::new(); + let cw = CriticalWindow::new(); + let text = render(&stats, &config, &cw); + for line in text.lines() { + // No NaN / weird unicode smuggled in. + assert!(line.is_ascii(), "non-ASCII metric line: {line}"); + } + } + + #[test] + fn request_path_parses_standard_get() { + let req = b"GET /metrics HTTP/1.1\r\nHost: x\r\n\r\n"; + assert_eq!(request_path(req).as_deref(), Some("/metrics")); + } + + #[test] + fn request_path_rejects_post() { + let req = b"POST /metrics HTTP/1.1\r\n\r\n"; + assert_eq!(request_path(req), None); + } + + #[test] + fn request_path_rejects_malformed() { + assert_eq!(request_path(b""), None); + assert_eq!(request_path(b"not http"), None); + } +} diff --git a/src/mode.rs b/src/mode.rs index 1d04c77..0ed5ea5 100644 --- a/src/mode.rs +++ b/src/mode.rs @@ -9,24 +9,14 @@ use std::fmt; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum SchedulingMode { /// Classic mode: pure capacity-based selection (window / in_flight). - /// No quality scoring, no dampening, no exploration. - /// Matches the original C implementation behavior. + /// No quality scoring, no dampening. Matches the original C + /// implementation behavior — kept as a known-good baseline for + /// diff-testing and fallback. Classic, /// Enhanced mode (default): quality-aware selection with dampening. - /// Supports quality scoring and smart exploration. #[default] Enhanced, - - /// RTT-threshold mode: groups links by RTT proximity. - /// Selects from "fast" links (within rtt_delta of minimum). - /// Supports quality scoring within the fast group. - RttThreshold, - - /// EDPF mode: Earliest Delivery Path First with BLEST + IoDS pipeline. - /// Selects link with lowest predicted arrival time, filtered by - /// head-of-line blocking guard and in-order delivery constraint. - Edpf, } impl SchedulingMode { @@ -35,8 +25,6 @@ impl SchedulingMode { match self { SchedulingMode::Classic => 0, SchedulingMode::Enhanced => 1, - SchedulingMode::RttThreshold => 2, - SchedulingMode::Edpf => 3, } } @@ -44,9 +32,6 @@ impl SchedulingMode { pub const fn from_u8(value: u8) -> Self { match value { 0 => SchedulingMode::Classic, - 1 => SchedulingMode::Enhanced, - 2 => SchedulingMode::RttThreshold, - 3 => SchedulingMode::Edpf, _ => SchedulingMode::Enhanced, } } @@ -55,23 +40,6 @@ impl SchedulingMode { pub const fn is_classic(self) -> bool { matches!(self, SchedulingMode::Classic) } - - /// Check if this mode is enhanced. - pub const fn is_enhanced(self) -> bool { - matches!(self, SchedulingMode::Enhanced) - } - - /// Check if this mode is RTT-threshold. - #[allow(dead_code)] - pub const fn is_rtt_threshold(self) -> bool { - matches!(self, SchedulingMode::RttThreshold) - } - - /// Check if this mode is EDPF. - #[allow(dead_code)] - pub const fn is_edpf(self) -> bool { - matches!(self, SchedulingMode::Edpf) - } } impl fmt::Display for SchedulingMode { @@ -79,8 +47,6 @@ impl fmt::Display for SchedulingMode { match self { SchedulingMode::Classic => write!(f, "classic"), SchedulingMode::Enhanced => write!(f, "enhanced"), - SchedulingMode::RttThreshold => write!(f, "rtt-threshold"), - SchedulingMode::Edpf => write!(f, "edpf"), } } } @@ -92,34 +58,20 @@ impl std::str::FromStr for SchedulingMode { match s { "classic" => Ok(SchedulingMode::Classic), "enhanced" => Ok(SchedulingMode::Enhanced), - "rtt-threshold" => Ok(SchedulingMode::RttThreshold), - "edpf" => Ok(SchedulingMode::Edpf), - _ => Err(format!( - "invalid mode '{}': use classic, enhanced, rtt-threshold, or edpf", - s - )), + _ => Err(format!("invalid mode '{}': use classic or enhanced", s)), } } } impl clap::ValueEnum for SchedulingMode { fn value_variants<'a>() -> &'a [Self] { - &[ - SchedulingMode::Classic, - SchedulingMode::Enhanced, - SchedulingMode::RttThreshold, - SchedulingMode::Edpf, - ] + &[SchedulingMode::Classic, SchedulingMode::Enhanced] } fn to_possible_value(&self) -> Option { match self { SchedulingMode::Classic => Some(clap::builder::PossibleValue::new("classic")), SchedulingMode::Enhanced => Some(clap::builder::PossibleValue::new("enhanced")), - SchedulingMode::RttThreshold => { - Some(clap::builder::PossibleValue::new("rtt-threshold")) - } - SchedulingMode::Edpf => Some(clap::builder::PossibleValue::new("edpf")), } } } @@ -135,12 +87,7 @@ mod tests { #[test] fn test_mode_u8_roundtrip() { - for mode in [ - SchedulingMode::Classic, - SchedulingMode::Enhanced, - SchedulingMode::RttThreshold, - SchedulingMode::Edpf, - ] { + for mode in [SchedulingMode::Classic, SchedulingMode::Enhanced] { assert_eq!(SchedulingMode::from_u8(mode.as_u8()), mode); } } @@ -155,40 +102,20 @@ mod tests { "enhanced".parse::().unwrap(), SchedulingMode::Enhanced ); - assert_eq!( - "rtt-threshold".parse::().unwrap(), - SchedulingMode::RttThreshold - ); - assert_eq!( - "edpf".parse::().unwrap(), - SchedulingMode::Edpf - ); - assert!("invalid".parse::().is_err()); + assert!("rtt-threshold".parse::().is_err()); + assert!("edpf".parse::().is_err()); } #[test] fn test_mode_display() { assert_eq!(format!("{}", SchedulingMode::Classic), "classic"); assert_eq!(format!("{}", SchedulingMode::Enhanced), "enhanced"); - assert_eq!(format!("{}", SchedulingMode::RttThreshold), "rtt-threshold"); - assert_eq!(format!("{}", SchedulingMode::Edpf), "edpf"); } #[test] fn test_mode_checks() { assert!(SchedulingMode::Classic.is_classic()); - assert!(!SchedulingMode::Classic.is_enhanced()); - assert!(!SchedulingMode::Classic.is_rtt_threshold()); assert!(!SchedulingMode::Enhanced.is_classic()); - assert!(SchedulingMode::Enhanced.is_enhanced()); - assert!(!SchedulingMode::Enhanced.is_rtt_threshold()); - - assert!(!SchedulingMode::RttThreshold.is_classic()); - assert!(!SchedulingMode::RttThreshold.is_enhanced()); - assert!(SchedulingMode::RttThreshold.is_rtt_threshold()); - - assert!(SchedulingMode::Edpf.is_edpf()); - assert!(!SchedulingMode::Edpf.is_classic()); } } diff --git a/src/priority.rs b/src/priority.rs new file mode 100644 index 0000000..ba8d15d --- /dev/null +++ b/src/priority.rs @@ -0,0 +1,229 @@ +//! Critical-packet priority sidecar. +//! +//! srtla_send's scheduler normally picks a link per packet by quality / +//! capacity / RTT. An upstream encoder that knows it is about to push a +//! keyframe (IDR / SPS / PPS burst) can open a short "critical window" +//! during which the scheduler routes packets to the highest-quality link +//! instead. This gives must-land video data the most reliable path at +//! the moment it matters most. +//! +//! The window is signalled over a dedicated UDP sidecar socket rather +//! than the JSON-RPC control channel. Same-host loopback UDP shares the +//! network stack path with the actual SRT data, so priority events are +//! ordered tightly against the packets they describe. The out-of-band +//! JSON-RPC socket, by contrast, could arrive microseconds late and miss +//! the earliest critical packets. +//! +//! ## Wire format +//! +//! One request per UDP datagram, 5 bytes fixed: +//! +//! ```text +//! byte 0 : 0xC1 — magic / version tag ("Critical v1") +//! bytes 1..5 : u32 big-endian — window length in milliseconds +//! ``` +//! +//! srtla_send stores `now + window_ms` as the critical deadline. +//! `is_critical_now()` returns true while `now < deadline`. Overlapping +//! windows extend the deadline monotonically (fetch_max) so a fresh +//! hint can only ever push the deadline forward, never shrink it. + +use std::net::SocketAddr; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; + +use tokio::net::UdpSocket; +use tracing::{info, trace, warn}; + +/// Magic byte identifying a priority-sidecar v1 datagram. Rejecting any +/// other leading byte lets us re-use the port for future framing later. +pub const PROTO_MAGIC: u8 = 0xc1; + +/// Datagram length in bytes: `[magic u8][window_ms u32 big-endian]`. +pub const DATAGRAM_LEN: usize = 5; + +/// Shared state reflecting the most recent critical-window deadline plus +/// observability counters. Cloned freely; all mutation is via atomics. +#[derive(Clone, Default)] +pub struct CriticalWindow { + deadline_ms: Arc, + windows_received: Arc, + /// Set when a malformed datagram arrives. Surfaced in telemetry so a + /// silently-dropped client becomes visible to operators. + malformed_datagrams: Arc, +} + +impl CriticalWindow { + pub fn new() -> Self { + Self::default() + } + + /// Push the critical deadline forward (fetch_max). Ignores older + /// deadlines, which keeps back-dated messages from shortening the + /// active window. + pub fn extend_to(&self, deadline_ms: u64) { + self.deadline_ms.fetch_max(deadline_ms, Ordering::Relaxed); + self.windows_received.fetch_add(1, Ordering::Relaxed); + } + + /// Scheduler hot-path check. Cheap: one relaxed atomic load. + #[inline] + pub fn is_critical_now(&self, now_ms: u64) -> bool { + self.deadline_ms.load(Ordering::Relaxed) > now_ms + } + + pub fn windows_received(&self) -> u64 { + self.windows_received.load(Ordering::Relaxed) + } + + pub fn malformed_datagrams(&self) -> u64 { + self.malformed_datagrams.load(Ordering::Relaxed) + } + + /// Test-only: force a window from synchronous code without talking to + /// the sidecar socket. + #[cfg(test)] + pub fn force_window(&self, deadline_ms: u64) { + self.extend_to(deadline_ms); + } +} + +/// Spawn a listener task that consumes priority datagrams from `bind_addr` +/// and pushes the derived deadlines into `state`. If `hub` is provided, +/// also publishes a `priority.window` event to subscribers on each +/// accepted datagram so downstream consumers can correlate priority +/// events with video keyframes in real time. +pub fn spawn_listener( + bind_addr: SocketAddr, + state: CriticalWindow, + hub: Option, +) -> tokio::task::JoinHandle<()> { + tokio::spawn(async move { + let sock = match UdpSocket::bind(bind_addr).await { + Ok(s) => s, + Err(e) => { + warn!(%bind_addr, error = %e, "failed to bind priority sidecar"); + return; + } + }; + let local = sock.local_addr().ok(); + info!(?local, "priority sidecar listening"); + + let mut buf = [0u8; 16]; + loop { + match sock.recv_from(&mut buf).await { + Ok((n, src)) => { + if n != DATAGRAM_LEN || buf[0] != PROTO_MAGIC { + state.malformed_datagrams.fetch_add(1, Ordering::Relaxed); + trace!(?src, n, "dropped malformed priority datagram"); + continue; + } + let window_ms = u32::from_be_bytes([buf[1], buf[2], buf[3], buf[4]]) as u64; + let now = crate::utils::now_ms(); + state.extend_to(now + window_ms); + trace!(window_ms, "critical window extended"); + if let Some(ref hub) = hub { + hub.publish( + "priority.window", + serde_json::json!({ + "at_ms": now, + "window_ms": window_ms, + "deadline_ms": now + window_ms, + }), + ) + .await; + } + } + Err(e) => { + warn!(error = %e, "priority sidecar recv error"); + } + } + } + }) +} + +/// Pick the highest-quality connection for a packet that lands inside a +/// critical window. Among connected, schedulable links, returns the one with +/// the best quality multiplier; `None` if none are schedulable (caller falls +/// back to normal selection). This is the action taken while +/// [`CriticalWindow::is_critical_now`] is true. +pub fn select_best_quality_idx(conns: &[crate::connection::SrtlaConnection]) -> Option { + let mut best_idx = None; + let mut best_quality = f64::NEG_INFINITY; + + for (i, conn) in conns.iter().enumerate() { + if !conn.connected || !conn.is_schedulable() { + continue; + } + let q = conn.quality_cache.multiplier; + if q > best_quality { + best_quality = q; + best_idx = Some(i); + } + } + + best_idx +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn is_critical_respects_deadline() { + let w = CriticalWindow::new(); + assert!(!w.is_critical_now(100)); + w.force_window(500); + assert!(w.is_critical_now(100)); + assert!(w.is_critical_now(499)); + assert!(!w.is_critical_now(500)); + assert!(!w.is_critical_now(501)); + } + + #[test] + fn extend_to_is_monotonic() { + let w = CriticalWindow::new(); + w.force_window(200); + w.force_window(100); // older: ignored + w.force_window(300); // newer: applied + assert!(w.is_critical_now(250)); + assert!(w.is_critical_now(299)); + assert!(!w.is_critical_now(300)); + assert_eq!(w.windows_received(), 3); + } + + #[test] + fn best_quality_idx_picks_highest() { + use crate::test_helpers::create_test_connections; + + let rt = tokio::runtime::Runtime::new().unwrap(); + let mut conns = rt.block_on(create_test_connections(3)); + + conns[0].quality_cache.multiplier = 0.8; + conns[1].quality_cache.multiplier = 1.1; + conns[2].quality_cache.multiplier = 0.95; + + assert_eq!(select_best_quality_idx(&conns), Some(1)); + } + + #[test] + fn best_quality_idx_skips_disconnected() { + use crate::test_helpers::create_test_connections; + + let rt = tokio::runtime::Runtime::new().unwrap(); + let mut conns = rt.block_on(create_test_connections(3)); + + conns[0].quality_cache.multiplier = 0.8; + conns[1].quality_cache.multiplier = 1.1; + conns[1].connected = false; // best quality but disconnected + conns[2].quality_cache.multiplier = 0.95; + + assert_eq!(select_best_quality_idx(&conns), Some(2)); + } + + #[test] + fn best_quality_idx_empty() { + let conns: Vec = vec![]; + assert_eq!(select_best_quality_idx(&conns), None); + } +} diff --git a/src/registration/mod.rs b/src/registration/mod.rs index bfc400d..e63655f 100644 --- a/src/registration/mod.rs +++ b/src/registration/mod.rs @@ -85,16 +85,17 @@ impl SrtlaRegistrationManager { &mut self, conn_idx: usize, buf: &[u8], + now_ms: u64, ) -> Option { match get_packet_type(buf) { Some(SRTLA_TYPE_REG_NGP) => { debug!("REG_NGP from uplink #{}", conn_idx); - self.handle_reg_ngp(conn_idx); + self.handle_reg_ngp(conn_idx, now_ms); Some(RegistrationEvent::RegNgp) } Some(SRTLA_TYPE_REG2) => { debug!("REG2 from uplink #{} (len={})", conn_idx, buf.len()); - self.handle_reg2(conn_idx, buf); + self.handle_reg2(conn_idx, buf, now_ms); Some(RegistrationEvent::Reg2) } Some(SRTLA_TYPE_REG3) => { @@ -104,7 +105,7 @@ impl SrtlaRegistrationManager { } Some(SRTLA_TYPE_REG_ERR) => { debug!("REG_ERR from uplink #{}", conn_idx); - self.handle_reg_err(conn_idx); + self.handle_reg_err(conn_idx, now_ms); Some(RegistrationEvent::RegErr) } _ => None, @@ -154,16 +155,16 @@ impl SrtlaRegistrationManager { } } - fn handle_reg_ngp(&mut self, conn_idx: usize) { + fn handle_reg_ngp(&mut self, conn_idx: usize, now_ms: u64) { if self.probing_state == ProbingState::WaitingForProbes { - self.handle_probe_response(conn_idx); + self.handle_probe_response(conn_idx, now_ms); return; } if self.active_connections == 0 && self.pending_reg2_idx.is_none() { debug!("REG_NGP from uplink #{} accepted as REG1 target", conn_idx); self.reg1_target_idx = Some(conn_idx); - self.reg1_next_send_at_ms = now_ms(); + self.reg1_next_send_at_ms = now_ms; } else { debug!( "REG_NGP from uplink #{} ignored (active connections present or pending)", @@ -172,7 +173,7 @@ impl SrtlaRegistrationManager { } } - fn handle_reg2(&mut self, conn_idx: usize, buf: &[u8]) { + fn handle_reg2(&mut self, conn_idx: usize, buf: &[u8], now_ms: u64) { if buf.len() < 2 + SRTLA_ID_LEN { return; } @@ -184,7 +185,7 @@ impl SrtlaRegistrationManager { conn_idx ); self.pending_reg2_idx = None; - self.pending_timeout_at_ms = now_ms() + REG3_TIMEOUT * 1000; + self.pending_timeout_at_ms = now_ms + REG3_TIMEOUT * 1000; self.broadcast_reg2_pending = true; // stop sending REG1 until next REG_NGP self.reg1_target_idx = None; @@ -196,7 +197,7 @@ impl SrtlaRegistrationManager { self.has_connected = true; } - fn handle_reg_err(&mut self, conn_idx: usize) { + fn handle_reg_err(&mut self, conn_idx: usize, now_ms: u64) { if self.pending_reg2_idx == Some(conn_idx) { debug!("REG_ERR for uplink #{} while awaiting REG2", conn_idx); } else { @@ -207,7 +208,7 @@ impl SrtlaRegistrationManager { self.pending_timeout_at_ms = 0; self.reg1_target_idx = None; // Wait for a fresh REG_NGP to select the next REG1 target - self.reg1_next_send_at_ms = now_ms() + REG2_TIMEOUT * 1000; + self.reg1_next_send_at_ms = now_ms + REG2_TIMEOUT * 1000; warn!("registration failed for connection {}", conn_idx); } diff --git a/src/registration/probing.rs b/src/registration/probing.rs index e22cf16..835b8fa 100644 --- a/src/registration/probing.rs +++ b/src/registration/probing.rs @@ -70,12 +70,11 @@ impl SrtlaRegistrationManager { } } - pub fn handle_probe_response(&mut self, conn_idx: usize) { + pub fn handle_probe_response(&mut self, conn_idx: usize, now: u64) { if self.probing_state != ProbingState::WaitingForProbes { return; } - let now = now_ms(); if let Some(result) = self .probe_results .iter_mut() diff --git a/src/sender/connections.rs b/src/sender/connections.rs index db13dcf..e102914 100644 --- a/src/sender/connections.rs +++ b/src/sender/connections.rs @@ -1,11 +1,12 @@ use std::collections::HashSet; use std::net::IpAddr; +use std::sync::Arc; use smallvec::SmallVec; use tracing::{info, warn}; use super::sequence::SequenceTracker; -use crate::connection::SrtlaConnection; +use crate::connection::{SrtlaConnection, UplinkBinder}; pub struct PendingConnectionChanges { pub new_ips: Option>, @@ -20,6 +21,7 @@ pub async fn apply_connection_changes( receiver_port: u16, last_selected_idx: &mut Option, seq_tracker: &mut SequenceTracker, + binder: &Arc, ) { let current_labels: HashSet = connections.iter().map(|c| c.label.clone()).collect(); let desired_labels: HashSet = new_ips @@ -62,7 +64,8 @@ pub async fn apply_connection_changes( if !new_ips_needed.is_empty() { let mut new_connections = - create_connections_from_ips(&new_ips_needed, receiver_host, receiver_port).await; + create_connections_from_ips(&new_ips_needed, receiver_host, receiver_port, binder) + .await; let added_count = new_connections.len(); connections.append(&mut new_connections); @@ -81,10 +84,13 @@ pub async fn create_connections_from_ips( ips: &[IpAddr], receiver_host: &str, receiver_port: u16, + binder: &Arc, ) -> SmallVec { let mut connections = SmallVec::new(); for ip in ips { - match SrtlaConnection::connect_from_ip(*ip, receiver_host, receiver_port).await { + match SrtlaConnection::connect_from_ip(*ip, receiver_host, receiver_port, binder.clone()) + .await + { Ok(conn) => { info!("added uplink {}", conn.label); connections.push(conn); diff --git a/src/sender/housekeeping.rs b/src/sender/housekeeping.rs index 1e4775c..eada0f9 100644 --- a/src/sender/housekeeping.rs +++ b/src/sender/housekeeping.rs @@ -2,13 +2,11 @@ use std::collections::HashMap; use anyhow::{Result, anyhow}; use tokio::sync::mpsc::UnboundedSender; -use tokio::time::Instant; use tracing::{debug, error, info, warn}; use super::uplink::{ConnectionId, ReaderHandle, UplinkPacket, restart_reader_for}; use crate::connection::{STARTUP_GRACE_MS, SrtlaConnection}; use crate::registration::SrtlaRegistrationManager; -use crate::utils::now_ms; pub const GLOBAL_TIMEOUT_MS: u64 = 10_000; @@ -21,12 +19,13 @@ pub async fn handle_housekeeping( connections: &mut [SrtlaConnection], reg: &mut SrtlaRegistrationManager, classic: bool, - all_failed_at: &mut Option, + now_ms: u64, + all_failed_at: &mut Option, reader_handles: &mut HashMap, packet_tx: &UnboundedSender, ) -> Result<()> { // If we're waiting on a REG2 response past the timeout, proactively retry REG1 - let current_ms = now_ms(); + let current_ms = now_ms; let _ = reg.clear_pending_if_timed_out(current_ms); if reg.is_probing() { @@ -49,10 +48,10 @@ pub async fn handle_housekeeping( // housekeeping: drive registration, send keepalives for (i, conn) in connections.iter_mut().enumerate() { // Simple reconnect-on-timeout, then allow reg driver to proceed - if conn.is_timed_out() { - if conn.should_attempt_reconnect() { + if conn.is_timed_out(current_ms) { + if conn.should_attempt_reconnect(current_ms) { let label = conn.label.clone(); - conn.record_reconnect_attempt(); + conn.record_reconnect_attempt(current_ms); if conn.connection_established_ms() == 0 { debug!("{} initial registration timed out; retrying", label); } else { @@ -89,17 +88,34 @@ pub async fn handle_housekeeping( continue; } - if conn.needs_keepalive() { + if conn.needs_keepalive(current_ms) { let _ = conn.send_keepalive().await; } - if conn.needs_rtt_measurement() { + if conn.needs_rtt_measurement(current_ms) { let _ = conn.send_keepalive().await; } if !classic { - conn.perform_window_recovery(); + conn.perform_window_recovery(current_ms); + } + // Update bitrate calculation + conn.calculate_bitrate(current_ms); + // Drive link lifecycle phase transitions + conn.update_phase(current_ms); + // Adapt the per-connection batch-send regime to the observed + // load. Cheap; no-op when the regime hasn't changed. + conn.recompute_batch_regime(); + + // The reader task self-heals via CONN_TIMEOUT, but a task death + // (panic / early return) would otherwise go undetected until that window. + // Poll its handle cheaply each tick and respawn it for a still-active link + // so inbound ACK/NAK/keepalive traffic resumes immediately, not seconds later. + let reader_dead = reader_handles + .get(&conn.conn_id) + .is_some_and(|reader| reader.handle.is_finished()); + if reader_dead { + warn!("{}: uplink reader task ended; restarting", conn.label); + restart_reader_for(conn, reader_handles, packet_tx); } - // Update bitrate calculation (from Android C implementation) - conn.calculate_bitrate(); } // Update active connections count (matches C implementation behavior) @@ -111,20 +127,24 @@ pub async fn handle_housekeeping( // Check for connection failures and output appropriate error messages // This matches the C implementation's connection_housekeeping logic - let active_connections = connections.iter().filter(|c| !c.is_timed_out()).count(); + let active_connections = connections.iter().filter(|c| !c.is_timed_out(current_ms)).count(); if active_connections == 0 { if all_failed_at.is_none() { - *all_failed_at = Some(Instant::now()); + // Monotonic ms stamp on the single now_ms() clock; the all-links-failed + // timeout below is a plain difference against the per-tick current_ms. + *all_failed_at = Some(current_ms); } if reg.has_connected { error!("warning: no available connections"); } - // Timeout when all connections have failed + // Timeout when all connections have failed. Measure elapsed-time-since-failure + // so a transient all-down blip only trips after a full GLOBAL_TIMEOUT_MS of + // sustained failure, not the instant uptime exceeds it. if let Some(failed_at) = all_failed_at - && crate::utils::instant_to_elapsed_ms(*failed_at) > GLOBAL_TIMEOUT_MS + && current_ms.saturating_sub(*failed_at) > GLOBAL_TIMEOUT_MS { if reg.has_connected { error!("Failed to re-establish any connections"); @@ -143,3 +163,137 @@ pub async fn handle_housekeeping( Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::sender::uplink::{create_uplink_channel, sync_readers}; + use crate::test_helpers::{create_test_connection, create_test_connections}; + use crate::utils::now_ms; + + #[tokio::test] + async fn dead_reader_is_restarted_for_active_connection() { + let mut connections = vec![create_test_connection().await]; + let conn_id = connections[0].conn_id; + let mut reg = SrtlaRegistrationManager::new(); + let mut all_failed_at: Option = None; + + let (packet_tx, _packet_rx) = create_uplink_channel(); + let mut reader_handles: HashMap = HashMap::new(); + sync_readers(&connections, &mut reader_handles, &packet_tx); + + // Abort the reader and let the runtime drive cancellation to completion, + // reproducing a silently dead task (a handle that reports is_finished()). + reader_handles.get(&conn_id).unwrap().handle.abort(); + for _ in 0..1000 { + if reader_handles.get(&conn_id).unwrap().handle.is_finished() { + break; + } + tokio::task::yield_now().await; + } + assert!( + reader_handles.get(&conn_id).unwrap().handle.is_finished(), + "reader task should be dead after abort" + ); + + handle_housekeeping( + &mut connections, + &mut reg, + false, + now_ms(), + &mut all_failed_at, + &mut reader_handles, + &packet_tx, + ) + .await + .expect("housekeeping on an active connection must not fail"); + + // A finished handle can never un-finish itself; a live handle proves + // housekeeping spawned a fresh reader in its place. + assert!( + !reader_handles.get(&conn_id).unwrap().handle.is_finished(), + "housekeeping must respawn the dead reader for the still-active connection" + ); + } + + /// The all-uplinks-failed timeout must measure time *since* the links failed, + /// not the uptime captured at the moment of failure. With the buggy + /// uptime-at-failure measure, the timer tripped on the first all-down pass as + /// soon as total uptime exceeded `GLOBAL_TIMEOUT_MS`, erroring on a transient + /// blip. Arming and the first re-check must not error; only a full + /// `GLOBAL_TIMEOUT_MS` of sustained failure may fire it. + /// + /// `handle_housekeeping` takes `now` as an argument, so the elapsed-since- + /// failure window is driven by the explicit timestamps passed here — no tokio + /// virtual clock. `now_ms()` is monotonic and not tokio-controlled, so a paused + /// clock could not drive this anymore. + #[tokio::test] + async fn all_failed_timeout_measures_elapsed_since_failure() { + let mut connections = create_test_connections(2).await; + let mut reg = SrtlaRegistrationManager::new(); + // Models a stream that was established and then lost every link. + reg.has_connected = true; + let mut reader_handles: HashMap = HashMap::new(); + let (packet_tx, _packet_rx) = tokio::sync::mpsc::unbounded_channel::(); + let mut all_failed_at: Option = None; + + let t0 = now_ms(); + + // Drop all uplinks. Pin the reconnect backoff well past the whole test + // window (max failure count -> 120s backoff) so housekeeping reaches the + // timeout branch instead of attempting a socket reconnection. + for conn in connections.iter_mut() { + conn.mark_for_recovery(); + conn.reconnection.last_reconnect_attempt_ms = t0; + conn.reconnection.reconnect_failure_count = 5; + } + + // Arm: first all-down pass. Uptime is irrelevant (only now - failed_at + // matters), so arming must not error however long the process has run. + let armed = handle_housekeeping( + &mut connections, + &mut reg, + false, + t0, + &mut all_failed_at, + &mut reader_handles, + &packet_tx, + ) + .await; + assert!(armed.is_ok(), "arming the all-failed timer must not error"); + assert!(all_failed_at.is_some(), "the failure timer should be armed"); + + // Still within the window: no error until a full GLOBAL_TIMEOUT_MS elapses. + let within = handle_housekeeping( + &mut connections, + &mut reg, + false, + t0 + GLOBAL_TIMEOUT_MS - 1000, + &mut all_failed_at, + &mut reader_handles, + &packet_tx, + ) + .await; + assert!( + within.is_ok(), + "no error until a full {GLOBAL_TIMEOUT_MS}ms has elapsed since the links failed" + ); + + // Past the window: fires. + let fired = handle_housekeeping( + &mut connections, + &mut reg, + false, + t0 + GLOBAL_TIMEOUT_MS + 1000, + &mut all_failed_at, + &mut reader_handles, + &packet_tx, + ) + .await; + assert!( + fired.is_err(), + "the all-failed timeout must fire once a full {GLOBAL_TIMEOUT_MS}ms has elapsed since \ + failure" + ); + } +} diff --git a/src/sender/mod.rs b/src/sender/mod.rs index 9c26a03..d40e40b 100644 --- a/src/sender/mod.rs +++ b/src/sender/mod.rs @@ -1,6 +1,7 @@ mod connections; mod housekeeping; mod packet_handler; +mod reload; #[cfg(any(test, feature = "test-internals"))] pub mod selection; #[cfg(not(any(test, feature = "test-internals")))] @@ -12,7 +13,6 @@ mod uplink; use std::collections::HashMap; use std::net::{IpAddr, Ipv6Addr, SocketAddr}; use std::path::Path; -use std::str::FromStr; use std::sync::Arc; use anyhow::{Context, Result, anyhow}; @@ -25,11 +25,25 @@ pub use connections::{ #[allow(unused_imports)] pub use housekeeping::GLOBAL_TIMEOUT_MS; use housekeeping::handle_housekeeping; +// Re-exported for the NAK-attribution conformance tests so they drive the real +// production path rather than a mirrored copy. +#[allow(unused_imports)] +pub(crate) use packet_handler::attribute_nak; use packet_handler::{ drain_packet_queue, flush_all_batches, handle_srt_packet, handle_uplink_packet, }; #[allow(unused_imports)] -pub use selection::{calculate_quality_multiplier, select_connection_idx}; +pub use selection::calculate_quality_multiplier; +pub use selection::classifier::{ClassificationResult, WeakReason}; +#[allow(unused_imports)] +pub use selection::enhanced::{in_flight_cap_exceeded, in_flight_cap_packets}; +#[allow(unused_imports)] +pub use selection::link_cc::{CcState, ClimbMode, LinkCcSnapshot}; +// `select_connection_idx` is consumed by `packet_handler` via its own +// `super::selection::select_connection_idx` path. The re-export is here +// for tests that import the sender public surface with a glob. +#[allow(unused_imports)] +pub use selection::select_connection_idx; #[allow(unused_imports)] pub use sequence::{SEQ_TRACKING_SIZE, SEQUENCE_TRACKING_MAX_AGE_MS, SequenceTracker}; use smallvec::SmallVec; @@ -48,6 +62,7 @@ use crate::stats::SharedStats; pub const HOUSEKEEPING_INTERVAL_MS: u64 = 1000; const STATUS_LOG_INTERVAL_MS: u64 = 30_000; +#[allow(clippy::too_many_arguments)] pub async fn run_sender_with_config( local_srt_port: u16, receiver_host: &str, @@ -55,6 +70,9 @@ pub async fn run_sender_with_config( ips_file: &str, config: DynamicConfig, shared_stats: SharedStats, + critical_window: crate::priority::CriticalWindow, + subscription_hub: crate::subscriptions::SubscriptionHub, + binder: std::sync::Arc, ) -> Result<()> { info!( "starting srtla_send: local_srt_port={}, receiver={}:{}, ips_file={}, mode={}", @@ -76,7 +94,8 @@ pub async fn run_sender_with_config( return Err(anyhow!("no IPs in list: {}", ips_file)); } - let mut connections = create_connections_from_ips(&ips, receiver_host, receiver_port).await; + let mut connections = + create_connections_from_ips(&ips, receiver_host, receiver_port, &binder).await; if connections.is_empty() { return Err(anyhow!("no uplinks available")); } @@ -132,9 +151,14 @@ pub async fn run_sender_with_config( // Zero-allocation ring buffer for sequence tracking let mut seq_tracker = SequenceTracker::new(); let mut last_selected_idx: Option = None; - let mut last_switch_time_ms: u64 = 0; // Track time of last connection switch - let mut all_failed_at: Option = None; + let mut all_failed_at: Option = None; let mut pending_changes: Option = None; + // Weak-link classifier. Its per-link `weak` verdict is consumed by + // Enhanced selection as an admission gate. + let mut weak_link_filter = selection::classifier::WeakLinkFilter::new(); + // Per-link CC soft-cap controller. `cc_target_bps` feeds the soft-cap + // multiplier and in-flight cap; `loss_degraded` feeds the loss gate. + let mut link_cc_controller = selection::link_cc::LinkCcController::new(); // Prepare SIGHUP stream (Unix only) or a never-completing future (non-Unix) #[cfg(unix)] @@ -148,6 +172,7 @@ pub async fn run_sender_with_config( &mut connections, &mut reg, classic, + crate::utils::now_ms(), &mut all_failed_at, &mut reader_handles, &packet_tx, @@ -171,11 +196,11 @@ pub async fn run_sender_with_config( &mut recv_buf, &mut connections, &mut last_selected_idx, - &mut last_switch_time_ms, &mut seq_tracker, &mut last_client_addr, reg.has_connected, &config_snap, + &critical_window, ) .await; drain_packet_queue( @@ -223,6 +248,7 @@ pub async fn run_sender_with_config( &mut connections, &mut reg, classic, + crate::utils::now_ms(), &mut all_failed_at, &mut reader_handles, &packet_tx, @@ -230,8 +256,41 @@ pub async fn run_sender_with_config( warn!("housekeeping failed: {err}"); } - // Update shared stats for telemetry export - shared_stats.update(&connections, &config.snapshot()); + // Run the weak-link classifier and per-link CC + // controller, stamp results onto each connection + // for selection to consume, and surface via stats. + let classification = weak_link_filter.classify(&connections); + let link_cc_snapshots = link_cc_controller + .tick_all(&connections, crate::utils::now_ms()); + for conn in connections.iter_mut() { + conn.weak = classification + .per_link + .iter() + .find(|e| e.conn_id == conn.conn_id) + .map(|e| e.weak) + .unwrap_or(false); + let cc_snap = link_cc_snapshots.get(&conn.conn_id); + conn.cc_backing_off = cc_snap + .map(|s| s.state == selection::link_cc::CcState::BackingOff) + .unwrap_or(false); + conn.cc_target_bps = cc_snap.map(|s| s.target_bps).unwrap_or(0); + conn.loss_degraded = + cc_snap.map(|s| s.loss_degraded).unwrap_or(false); + } + shared_stats.update( + &connections, + &config.snapshot(), + Some(&classification), + Some(&link_cc_snapshots), + ); + + // Fan the fresh snapshot out to any `stats` subscribers + // on the async control socket. Cheap no-op if no one + // is subscribed. + let snap = shared_stats.get(); + if let Ok(value) = serde_json::to_value(&snap) { + subscription_hub.publish("stats", value).await; + } if let Some(changes) = pending_changes.take() && let Some(new_ips) = changes.new_ips @@ -244,6 +303,7 @@ pub async fn run_sender_with_config( changes.receiver_port, &mut last_selected_idx, &mut seq_tracker, + &binder, ).await; info!("connection changes applied successfully"); sync_readers(&connections, &mut reader_handles, &packet_tx); @@ -281,14 +341,30 @@ pub async fn run_sender_with_config( #[cfg(unix)] event_loop! { _ = sighup.recv() => { - info!("received SIGHUP - queuing uplink IP reload from {}", ips_file); - if let Ok(new_ips) = read_ip_list(ips_file).await { - pending_changes = Some(PendingConnectionChanges { - new_ips: Some(new_ips), - receiver_host: receiver_host.to_string(), - receiver_port, - }); - info!("uplink IP changes queued for next processing cycle"); + info!("received SIGHUP - evaluating uplink IP reload from {}", ips_file); + // Guard against a reload that resolves to zero usable IPs (missing, + // empty, or all-garbage file): refuse it and keep the current links + // up rather than queuing an empty list, which would tear down every + // connection in apply_connection_changes. Mirrors the C sender. + match reload::analyze_ip_reload(ips_file) { + reload::IpReload::Apply { ips, first_invalid_line } => { + if let Some(line) = first_invalid_line { + warn!( + "ips file has an invalid entry starting at line {line}; applying valid IPs only" + ); + } + pending_changes = Some(PendingConnectionChanges { + new_ips: Some(ips), + receiver_host: receiver_host.to_string(), + receiver_port, + }); + info!("uplink IP changes queued for next processing cycle"); + } + reload::IpReload::Refuse(reason) => { + warn!( + "refusing SIGHUP reload ({reason:?}); keeping current connections" + ); + } } let config_snap = config.snapshot(); drain_packet_queue( @@ -311,16 +387,20 @@ pub async fn run_sender_with_config( pub async fn read_ip_list(path: &str) -> Result> { let text = std::fs::read_to_string(Path::new(path)).context("read IPs file")?; - let mut out = SmallVec::new(); - for line in text.lines() { - let l = line.trim(); - if l.is_empty() { - continue; - } - match IpAddr::from_str(l) { - Ok(ip) => out.push(ip), - Err(e) => warn!("skip invalid IP '{}': {}", l, e), + // Shares the SIGHUP reload guard's parser so startup and reload agree on what + // counts as a valid IP. At startup an empty or all-invalid file is tolerated + // (returns an empty list); the zero-valid-IP refusal only matters on reload, + // where dropping every live link would be worse than ignoring a bad edit. + match reload::analyze_ip_reload_text(&text) { + reload::IpReload::Apply { + ips, + first_invalid_line, + } => { + if let Some(line) = first_invalid_line { + warn!("ips file has an invalid entry starting at line {line}; skipping it"); + } + Ok(ips) } + reload::IpReload::Refuse(_) => Ok(SmallVec::new()), } - Ok(out) } diff --git a/src/sender/packet_handler.rs b/src/sender/packet_handler.rs index fb7e3dc..31b6543 100644 --- a/src/sender/packet_handler.rs +++ b/src/sender/packet_handler.rs @@ -4,7 +4,7 @@ use anyhow::Result; use smallvec::SmallVec; use tokio::net::UdpSocket; use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender}; -use tracing::{debug, warn}; +use tracing::{debug, trace, warn}; use super::selection::select_connection_idx; use super::sequence::SequenceTracker; @@ -17,6 +17,37 @@ use crate::registration::SrtlaRegistrationManager; /// Type alias for instant ACK forwarding: (client_addr, packet_data) pub type InstantForwarder = UnboundedSender<(SocketAddr, SmallVec)>; +/// Attribute a NAK to the uplink that sent the lost packet and shrink its window. +/// +/// Prefers the O(1) sequence-tracker mapping (the link that actually sent `nak`); +/// once that link is found we never fall through, so a duplicate NAK for an +/// already-cleared sequence can't be re-counted against a different link. Only +/// when the tracker has no record do we fall back to the first link that still +/// recognizes the sequence in its own packet log. Returns the index of the link +/// that counted the NAK, or `None` if none did. Production ignores the return; +/// it exists so the attribution path is unit-testable directly instead of mirrored. +pub(crate) fn attribute_nak( + connections: &mut [SrtlaConnection], + seq_tracker: &SequenceTracker, + nak: u32, + current_time_ms: u64, +) -> Option { + if let Some(conn_id) = seq_tracker.get(nak, current_time_ms) + && let Some(pos) = connections.iter().position(|c| c.conn_id == conn_id) + { + return connections[pos] + .handle_nak(nak as i32, current_time_ms) + .then_some(pos); + } + + for (i, conn) in connections.iter_mut().enumerate() { + if conn.handle_nak(nak as i32, current_time_ms) { + return Some(i); + } + } + None +} + #[allow(clippy::too_many_arguments)] pub async fn process_connection_events( idx: usize, @@ -50,15 +81,18 @@ pub async fn process_connection_events( return Ok(()); } + // One monotonic read drives every ACK/NAK handler in this receive batch. + let current_time_ms = crate::utils::now_ms(); + for ack in incoming.ack_numbers.iter() { for c in connections.iter_mut() { - c.handle_srt_ack(*ack as i32); + c.handle_srt_ack(*ack as i32, current_time_ms); } } for srtla_ack in incoming.srtla_ack_numbers.iter() { for c in connections.iter_mut() { - if c.handle_srtla_ack_specific(*srtla_ack as i32, classic) { + if c.handle_srtla_ack_specific(*srtla_ack as i32, classic, current_time_ms) { break; } } @@ -67,26 +101,8 @@ pub async fn process_connection_events( } } - // Get current time once for all NAK processing - let current_time_ms = crate::utils::now_ms(); for nak in incoming.nak_numbers.iter() { - let mut handled = false; - - // O(1) lookup in the ring buffer - if let Some(conn_id) = seq_tracker.get(*nak, current_time_ms) - && let Some(conn) = connections.iter_mut().find(|c| c.conn_id == conn_id) - { - conn.handle_nak(*nak as i32); - handled = true; - } - - if !handled { - for conn in connections.iter_mut() { - if conn.handle_nak(*nak as i32) { - break; - } - } - } + attribute_nak(connections, seq_tracker, *nak, current_time_ms); } if let Some(client) = last_client_addr { @@ -198,12 +214,13 @@ pub async fn drain_packet_queue( fn select_pre_registration_connection( connections: &[SrtlaConnection], last_selected_idx: Option, + now_ms: u64, ) -> Option { // Try to reuse the last selected connection if it's still valid if let Some(idx) = last_selected_idx && let Some(conn) = connections.get(idx) && conn.connected - && !conn.is_timed_out() + && !conn.is_timed_out(now_ms) { return Some(idx); } @@ -212,7 +229,7 @@ fn select_pre_registration_connection( connections .iter() .enumerate() - .find(|(_, c)| !c.is_timed_out()) + .find(|(_, c)| !c.is_timed_out(now_ms)) .map(|(i, _)| i) } @@ -220,17 +237,22 @@ fn select_pre_registration_connection( /// /// Uses a pre-cached `ConfigSnapshot` to avoid atomic loads per packet. /// The caller should create a snapshot once per select iteration for optimal performance. +/// +/// When a keyframe burst is detected (runs of consecutive max-MTU 1316-byte data +/// packets), the scheduler overrides normal selection and routes to the +/// highest-quality link. This ensures I-frame data — which is critical for +/// decoder recovery — travels over the most reliable path. #[allow(clippy::too_many_arguments)] pub async fn handle_srt_packet( res: Result<(usize, SocketAddr), std::io::Error>, recv_buf: &mut [u8], connections: &mut [SrtlaConnection], last_selected_idx: &mut Option, - last_switch_time_ms: &mut u64, seq_tracker: &mut SequenceTracker, last_client_addr: &mut Option, registration_complete: bool, config_snap: &ConfigSnapshot, + critical_window: &crate::priority::CriticalWindow, ) { match res { Ok((n, src)) => { @@ -243,7 +265,8 @@ pub async fn handle_srt_packet( let pkt = &recv_buf[..n]; let seq = protocol::get_srt_sequence_number(pkt); if !registration_complete { - let sel_idx = select_pre_registration_connection(connections, *last_selected_idx); + let sel_idx = + select_pre_registration_connection(connections, *last_selected_idx, packet_time_ms); if let Some(sel_idx) = sel_idx { forward_via_connection( sel_idx, @@ -251,7 +274,6 @@ pub async fn handle_srt_packet( seq, connections, last_selected_idx, - last_switch_time_ms, seq_tracker, packet_time_ms, ) @@ -261,13 +283,31 @@ pub async fn handle_srt_packet( return; } - let sel_idx = select_connection_idx( - connections, - *last_selected_idx, - *last_switch_time_ms, - packet_time_ms, - config_snap, - ); + // Normal scheduler selection + let mut sel_idx = + select_connection_idx(connections, *last_selected_idx, packet_time_ms, config_snap); + + // Keyframe priority: route critical packets to the highest-quality + // link. The critical time window is opened over the priority + // sidecar by the encoder front-end, which parses NAL units and + // knows exactly when a keyframe / parameter set is in flight (see + // crate::priority). srtla_send sees only opaque SRT payloads, so it + // never guesses at keyframes itself. + // + // Only data packets have seq != None (control packets have MSB set). + if seq.is_some() + && critical_window.is_critical_now(packet_time_ms) + && let Some(best_idx) = crate::priority::select_best_quality_idx(connections) + && sel_idx != Some(best_idx) + { + trace!( + "critical override (window): link {} -> {}", + sel_idx.map_or(-1, |i| i as i64), + best_idx as i64 + ); + sel_idx = Some(best_idx); + } + if let Some(sel_idx) = sel_idx { forward_via_connection( sel_idx, @@ -275,7 +315,6 @@ pub async fn handle_srt_packet( seq, connections, last_selected_idx, - last_switch_time_ms, seq_tracker, packet_time_ms, ) @@ -296,7 +335,6 @@ pub async fn forward_via_connection( seq: Option, connections: &mut [SrtlaConnection], last_selected_idx: &mut Option, - last_switch_time_ms: &mut u64, seq_tracker: &mut SequenceTracker, packet_time_ms: u64, ) { @@ -306,15 +344,20 @@ pub async fn forward_via_connection( if *last_selected_idx != Some(sel_idx) { if let Some(prev_idx) = *last_selected_idx { if prev_idx < connections.len() { - // Flush the previous connection's batch before switching - if connections[prev_idx].has_queued_packets() - && let Err(e) = connections[prev_idx].flush_batch().await - { - warn!( - "{}: batch flush on switch failed: {}", - connections[prev_idx].label, e - ); - } + // Deliberately does not flush the previous link's batch. Each + // connection owns its BatchSender and drains it with a single + // `sendmmsg` on its own size threshold or 15ms timer, so + // interleaved routing just fills several per-link batches + // concurrently instead of one serially — no syscall is lost. + // + // Flushing here emitted a one-packet batch on every switch, + // which made per-packet scheduling expensive and is what the + // `MIN_SWITCH_INTERVAL_MS` cooldown existed to suppress. That + // cooldown freezes the selector for ~15ms, and since + // `get_score()` counts queued packets as in-flight precisely so + // routing a packet immediately de-prioritises its link, freezing + // it opens that feedback loop and lets in-flight run away on one + // link. debug!( "Connection switch: {} → {} (seq: {:?})", connections[prev_idx].label, connections[sel_idx].label, seq @@ -327,7 +370,6 @@ pub async fn forward_via_connection( ); } *last_selected_idx = Some(sel_idx); - *last_switch_time_ms = packet_time_ms; // Track when switch occurred (use cached timestamp) } // Get conn_id before mutable borrow for seq_tracker diff --git a/src/sender/reload.rs b/src/sender/reload.rs new file mode 100644 index 0000000..33590ca --- /dev/null +++ b/src/sender/reload.rs @@ -0,0 +1,220 @@ +//! SIGHUP IP-list reload guard. +//! +//! Mirrors the C sender's reload guard (`srtla/src/sender_logic.h`, +//! `count_parseable_source_ips` / `analyze_reload_error`): a SIGHUP reload that +//! resolves to zero usable source IPs — a missing/unreadable, empty, or +//! all-garbage file — is REFUSED so the stream keeps running on the existing +//! links instead of tearing every connection down. A file mixing valid and +//! invalid lines still applies; the bad lines are skipped with a warning. + +use std::net::IpAddr; +use std::str::FromStr; + +use smallvec::SmallVec; + +/// Why a SIGHUP reload was refused. In every case the existing connections are +/// kept and the stream keeps running. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ReloadRefusal { + /// The ips file could not be opened or read. Only reachable from + /// [`analyze_ip_reload`], which is the SIGHUP entry point and therefore + /// unix-only. + #[cfg(unix)] + NotFound, + /// The ips file has no non-blank lines. + Empty, + /// The ips file has content but no line parses as an IP. Carries the 1-based + /// line number of the first invalid line for operator-facing logging. + NoValidIps { first_invalid_line: usize }, +} + +/// Outcome of analyzing an ips file for a SIGHUP reload. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum IpReload { + /// Apply this (guaranteed non-empty) IP list. `first_invalid_line` is + /// `Some(n)` when at least one line was skipped as invalid (a mixed + /// valid+invalid file), otherwise `None`. + Apply { + ips: SmallVec, + first_invalid_line: Option, + }, + /// Refuse the reload and keep the current connections. + Refuse(ReloadRefusal), +} + +/// Analyze ips-file `text` for a SIGHUP reload, applying the same +/// zero-valid-IP guard as the C sender. Pure and synchronous so it is +/// unit-testable without touching the filesystem; [`analyze_ip_reload`] layers +/// the file read on top. +pub fn analyze_ip_reload_text(text: &str) -> IpReload { + let mut ips: SmallVec = SmallVec::new(); + let mut first_invalid_line: Option = None; + let mut saw_content = false; + + for (idx, line) in text.lines().enumerate() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + saw_content = true; + match IpAddr::from_str(trimmed) { + Ok(ip) => ips.push(ip), + Err(_) => { + if first_invalid_line.is_none() { + first_invalid_line = Some(idx + 1); + } + } + } + } + + if ips.is_empty() { + return if saw_content { + IpReload::Refuse(ReloadRefusal::NoValidIps { + first_invalid_line: first_invalid_line.unwrap_or(1), + }) + } else { + IpReload::Refuse(ReloadRefusal::Empty) + }; + } + + IpReload::Apply { + ips, + first_invalid_line, + } +} + +/// Read `path` and analyze it for a SIGHUP reload. A read error maps to +/// [`ReloadRefusal::NotFound`] — the C guard treats an unreadable file as zero +/// valid IPs and refuses the reload. +/// +/// Unix-only: reload is driven by SIGHUP, which Windows does not have. Startup +/// parsing goes through [`analyze_ip_reload_text`] on every platform. +#[cfg(unix)] +pub fn analyze_ip_reload(path: &str) -> IpReload { + match std::fs::read_to_string(path) { + Ok(text) => analyze_ip_reload_text(&text), + Err(_) => IpReload::Refuse(ReloadRefusal::NotFound), + } +} + +#[cfg(test)] +mod tests { + use std::io::Write; + use std::net::Ipv4Addr; + + use tempfile::NamedTempFile; + + use super::*; + + fn ip(s: &str) -> IpAddr { + IpAddr::from_str(s).unwrap() + } + + #[test] + fn all_valid_applies_without_invalid_line() { + match analyze_ip_reload_text("10.0.0.1\n10.0.0.2\n") { + IpReload::Apply { + ips, + first_invalid_line, + } => { + assert_eq!(ips.as_slice(), [ip("10.0.0.1"), ip("10.0.0.2")]); + assert_eq!(first_invalid_line, None); + } + other => panic!("expected Apply, got {other:?}"), + } + } + + #[test] + fn blank_lines_are_skipped_not_counted_as_invalid() { + match analyze_ip_reload_text("\n10.0.0.1\n \n10.0.0.2\n\n") { + IpReload::Apply { + ips, + first_invalid_line, + } => { + assert_eq!(ips.as_slice(), [ip("10.0.0.1"), ip("10.0.0.2")]); + assert_eq!(first_invalid_line, None); + } + other => panic!("expected Apply, got {other:?}"), + } + } + + #[test] + fn mixed_valid_and_invalid_applies_and_reports_first_invalid_line() { + // Line 2 is the first invalid line; the valid IPs still apply. + match analyze_ip_reload_text("10.0.0.1\nnot-an-ip\n10.0.0.2\nalso-bad\n") { + IpReload::Apply { + ips, + first_invalid_line, + } => { + assert_eq!(ips.as_slice(), [ip("10.0.0.1"), ip("10.0.0.2")]); + assert_eq!(first_invalid_line, Some(2)); + } + other => panic!("expected Apply, got {other:?}"), + } + } + + #[test] + fn all_garbage_refuses_with_first_invalid_line() { + assert_eq!( + analyze_ip_reload_text("garbage\nstill-not-an-ip\n"), + IpReload::Refuse(ReloadRefusal::NoValidIps { + first_invalid_line: 1, + }) + ); + } + + #[test] + fn garbage_after_blanks_reports_correct_line_number() { + // Line 3 holds the first (and only) non-blank, invalid entry. + assert_eq!( + analyze_ip_reload_text("\n\n###garbage###\n"), + IpReload::Refuse(ReloadRefusal::NoValidIps { + first_invalid_line: 3, + }) + ); + } + + #[test] + fn empty_file_refuses_as_empty() { + assert_eq!( + analyze_ip_reload_text(""), + IpReload::Refuse(ReloadRefusal::Empty) + ); + } + + #[test] + fn only_blank_lines_refuses_as_empty() { + assert_eq!( + analyze_ip_reload_text("\n \n\t\n"), + IpReload::Refuse(ReloadRefusal::Empty) + ); + } + + #[test] + fn missing_file_refuses_as_not_found() { + assert_eq!( + analyze_ip_reload("/nonexistent/srtla-reload-guard-test.txt"), + IpReload::Refuse(ReloadRefusal::NotFound) + ); + } + + #[test] + fn reads_and_parses_a_real_file() { + let mut f = NamedTempFile::new().unwrap(); + writeln!(f, "127.0.0.1").unwrap(); + writeln!(f, "127.0.0.2").unwrap(); + f.flush().unwrap(); + match analyze_ip_reload(f.path().to_str().unwrap()) { + IpReload::Apply { ips, .. } => { + assert_eq!( + ips.as_slice(), + [ + IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), + IpAddr::V4(Ipv4Addr::new(127, 0, 0, 2)), + ] + ); + } + other => panic!("expected Apply, got {other:?}"), + } + } +} diff --git a/src/sender/selection/blest.rs b/src/sender/selection/blest.rs deleted file mode 100644 index f2db2f7..0000000 --- a/src/sender/selection/blest.rs +++ /dev/null @@ -1,160 +0,0 @@ -//! BLEST head-of-line blocking guard. -//! -//! Prevents head-of-line blocking by filtering out links whose one-way delay -//! would cause excessive waiting at the receiver relative to the fastest link. - -use crate::connection::SrtlaConnection; - -/// Maximum acceptable block time in milliseconds. -const DEFAULT_BLOCK_THRESHOLD_MS: f64 = 50.0; - -/// BLEST filter state. -#[derive(Debug)] -pub struct BlestFilter { - /// Maximum acceptable block time in ms. - threshold_ms: f64, - /// Dynamic penalty factor that grows on blocking events and decays per tick. - penalty: f64, -} - -impl BlestFilter { - pub fn new() -> Self { - Self { - threshold_ms: DEFAULT_BLOCK_THRESHOLD_MS, - penalty: 0.0, - } - } - - /// Decay the penalty factor. Call once per scheduling tick. - pub fn tick(&mut self) { - self.penalty *= 0.95; - if self.penalty < 0.01 { - self.penalty = 0.0; - } - } - - /// Record a blocking event (when a link caused HoL blocking). - #[allow(dead_code)] - pub fn record_blocking(&mut self) { - self.penalty = (self.penalty + 1.0).min(10.0); - } - - /// Get the effective threshold accounting for penalty. - fn effective_threshold(&self) -> f64 { - self.threshold_ms / (1.0 + self.penalty * 0.5) - } - - /// Filter connections, returning indices of non-blocked links. - /// - /// A link is blocked if its OWD estimate exceeds min_OWD + threshold. - /// OWD is estimated as rtt_min_ms / 2.0. - pub fn filter(&self, conns: &[SrtlaConnection]) -> Vec { - if conns.is_empty() { - return vec![]; - } - - // Find minimum OWD across all connected links with valid RTT - let min_owd = conns - .iter() - .filter(|c| c.connected && c.rtt.rtt_min_ms < 200.0) - .map(|c| c.rtt.rtt_min_ms / 2.0) - .fold(f64::MAX, f64::min); - - if min_owd == f64::MAX { - // No valid RTT data — return all connected indices - return conns - .iter() - .enumerate() - .filter(|(_, c)| c.connected) - .map(|(i, _)| i) - .collect(); - } - - let threshold = self.effective_threshold(); - - conns - .iter() - .enumerate() - .filter(|(_, c)| { - if !c.connected { - return false; - } - let owd = c.rtt.rtt_min_ms / 2.0; - let block_time = owd - min_owd; - block_time <= threshold - }) - .map(|(i, _)| i) - .collect() - } -} - -impl Default for BlestFilter { - fn default() -> Self { - Self::new() - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::test_helpers::create_test_connections; - - #[test] - fn test_filter_passes_all_close_rtt() { - let rt = tokio::runtime::Runtime::new().unwrap(); - let mut conns = rt.block_on(create_test_connections(3)); - - // All links have similar RTT - conns[0].rtt.rtt_min_ms = 40.0; - conns[1].rtt.rtt_min_ms = 50.0; - conns[2].rtt.rtt_min_ms = 60.0; - - let filter = BlestFilter::new(); - let result = filter.filter(&conns); - assert_eq!(result, vec![0, 1, 2], "All should pass with close RTTs"); - } - - #[test] - fn test_filter_rejects_high_owd() { - let rt = tokio::runtime::Runtime::new().unwrap(); - let mut conns = rt.block_on(create_test_connections(3)); - - conns[0].rtt.rtt_min_ms = 20.0; // OWD = 10 - conns[1].rtt.rtt_min_ms = 40.0; // OWD = 20, block_time = 10 < 50 → pass - conns[2].rtt.rtt_min_ms = 200.0; // excluded by rtt_min_ms < 200 check - - // Give conn 2 a very high RTT that's still under the valid threshold - conns[2].rtt.rtt_min_ms = 180.0; // OWD = 90, block_time = 80 > 50 → blocked - - let filter = BlestFilter::new(); - let result = filter.filter(&conns); - assert_eq!(result, vec![0, 1], "High-OWD link should be filtered out"); - } - - #[test] - fn test_penalty_shrinks_threshold() { - let mut filter = BlestFilter::new(); - assert!((filter.effective_threshold() - 50.0).abs() < 0.01); - - filter.record_blocking(); - // penalty=1.0, threshold = 50 / (1 + 0.5) = 33.3 - assert!(filter.effective_threshold() < 50.0); - assert!(filter.effective_threshold() > 30.0); - } - - #[test] - fn test_penalty_decays() { - let mut filter = BlestFilter::new(); - filter.record_blocking(); - assert!(filter.penalty > 0.0); - - for _ in 0..200 { - filter.tick(); - } - assert!( - filter.penalty < 0.01, - "Penalty should decay to near zero: {}", - filter.penalty - ); - } -} diff --git a/src/sender/selection/classic.rs b/src/sender/selection/classic.rs index a228d17..ccdc250 100644 --- a/src/sender/selection/classic.rs +++ b/src/sender/selection/classic.rs @@ -7,7 +7,6 @@ //! - Pure capacity-based: score = window / (in_flight + 1) //! - No quality awareness (no NAK penalties) //! - No RTT consideration -//! - No exploration //! - Simple "pick highest score" algorithm use crate::connection::SrtlaConnection; @@ -20,12 +19,14 @@ use crate::connection::SrtlaConnection; /// This matches the original C implementation's behavior exactly. /// No time-based dampening or hysteresis is applied in classic mode. #[inline(always)] -pub fn select_connection(conns: &[SrtlaConnection]) -> Option { +pub fn select_connection(conns: &[SrtlaConnection], now_ms: u64) -> Option { let mut best_idx: Option = None; let mut best_score: i32 = -1; for (i, c) in conns.iter().enumerate() { - if c.is_timed_out() { + // `stall_gated` is only ever set when a healthier link exists (see + // `apply_stall_gate`), so skipping it here can never starve the pool. + if c.is_timed_out(now_ms) || !c.is_schedulable() || c.stall_gated { continue; } let score = c.get_score(); diff --git a/src/sender/selection/classifier.rs b/src/sender/selection/classifier.rs new file mode 100644 index 0000000..7d71ddc --- /dev/null +++ b/src/sender/selection/classifier.rs @@ -0,0 +1,488 @@ +//! Weak-link classifier. +//! +//! Computes a per-connection `weak: bool` flag using a three-tier delay +//! cascade and entering/leaving thresholds with hysteresis. The result is +//! consumed by Enhanced selection as an admission gate (a weak link's +//! routing score is crushed but the link stays rankable). +//! +//! ## Algorithm +//! +//! 1. Estimate a per-stream max delay budget. We don't have a peer-side +//! estimate, so derive it locally as `max(longest_rtt * 3, 500ms)` +//! capped at `5000ms`. +//! 2. Three delay tiers: `best = 40%`, `safe = 50%`, `max = 60%` of the +//! estimate, capped at 2.5s / 2.5s / 5s. +//! 3. Bucket each link's recent throughput by which tier its RTT meets. +//! Pick the tightest tier where >=85% of throughput still fits, with +//! a 50%/25% cascade fallback for degraded conditions. +//! 4. Mark a link weak if either: +//! - its RTT exceeds the chosen tier (high latency) or a standing +//! queue is forming, sustained for `WEAK_SUSTAIN_TICKS` consecutive +//! housekeeping ticks. Both signals flip on a single evaluation, so +//! the streak latch filters one-tick (~1s) blips before the gate +//! demotes routing weight, or +//! - its share of total throughput falls below the entering +//! threshold. Once weak, the link stays weak until its share rises +//! above the (much higher) leaving threshold. +//! +//! ## Tuning +//! +//! Numbers below are starting points picked to be conservative. Real- +//! world soak data may suggest retuning. +//! +//! - **Tier ratios 40/50/60% with 2.5/2.5/5s caps**: physical +//! proportions of an estimated budget. +//! - **Bandwidth-share cutoffs 85/50/25%**: same. +//! - **Entering threshold = 0.25 / N of fair share**: a link delivering +//! less than a quarter of its expected share is suspect. +//! - **Leaving threshold = 0.75 / N of fair share**: to clear weak +//! status, a link must approach fair share. **3x hysteresis ratio** +//! between enter and leave keeps marginal links from flapping. + +use std::collections::HashMap; + +use crate::connection::SrtlaConnection; + +/// Cap on `target_best_delay_ms` and `target_safe_delay_ms`. +const TARGET_BEST_SAFE_CAP_MS: u32 = 2500; +/// Cap on `target_max_delay_ms`. +const TARGET_MAX_CAP_MS: u32 = 5000; + +/// Estimate-from-RTT multiplier when no peer-side budget is available. +const RTT_TO_DELAY_BUDGET_MULT: f64 = 3.0; +/// Floor for the derived budget — prevents pathological ramp on tiny RTTs. +const MIN_BUDGET_MS: u32 = 500; +/// Hard upper bound on the derived budget. +const MAX_BUDGET_MS: u32 = 5000; + +/// Bandwidth-share cutoffs for tier selection. +const SHARE_85_PERMILLE: u64 = 850; +const SHARE_50_PERMILLE: u64 = 500; +const SHARE_25_PERMILLE: u64 = 250; + +/// Entering / leaving thresholds expressed as a permille of fair share. +/// `enter_share = (1000 / n_links) * 0.25`; `leave_share = ... * 0.75`. +/// 3× hysteresis ratio. +const ENTER_FAIR_SHARE_NUMERATOR: u64 = 250; +const LEAVE_FAIR_SHARE_NUMERATOR: u64 = 750; + +/// Below this total throughput, classification is bypassed and every +/// connected link is treated as not-weak (we don't have enough signal). +const MIN_TOTAL_BPS_FOR_CLASSIFICATION: f64 = 100_000.0; + +/// Consecutive housekeeping ticks a delay signal (`HighRtt` / +/// `QueueBuilding`) must persist before the link is marked weak. The +/// housekeeping loop runs once per second, so 2 ticks ≈ 2s: long enough +/// to filter a single one-second RTT/queue blip, short enough to demote a +/// genuinely congesting link well before it hurts. A real collapse holds +/// the signal for many seconds, so reaction speed is unaffected. Do not +/// raise above 3. +const WEAK_SUSTAIN_TICKS: u32 = 2; + +/// After a link has been continuously share-weak (LowShare/NoTraffic) for +/// this many housekeeping ticks (~1Hz, so ~15s), force a probation re-test. +/// Delay- and loss-driven weakness are exempt: those self-clear from live +/// RTT/loss without needing traffic, so they can't latch. +const PROBATION_INTERVAL_TICKS: u32 = 15; + +/// Length of the probation re-test window in ticks (~3s). The link is +/// treated as not-weak for this long so selection routes it real traffic and +/// it can re-prove its throughput share. A link that is genuinely bad still +/// stays gated by the independent `loss_degraded` / delay gates even inside +/// this window, so probation only ever re-tests marginal-but-usable links. +/// +/// NOTE: both probation constants are starting points. Validate the window +/// length in the network-sim harness before treating them as final: too +/// short and a recovered link can't accrue enough share to clear the +/// entering threshold; too long and a genuinely starved link draws traffic +/// it can't use. +const PROBATION_WINDOW_TICKS: u32 = 3; + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum WeakReason { + /// Link passed all checks. Not weak. + Healthy, + /// Link's RTT exceeds the chosen delay tier. + HighRtt, + /// Link's RTT is still within tier but a standing queue is forming + /// (jitter-immune delay gradient). Early warning before HighRtt. + QueueBuilding, + /// Link is connected but delivered no traffic in the window. + NoTraffic, + /// Link's throughput share is below the entering threshold (or, if + /// previously weak, below the leaving threshold). + LowShare, + /// Total throughput below the classification floor — every link + /// reported as not-weak. + Bypassed, +} + +#[derive(Clone, Debug)] +pub struct LinkClassification { + pub conn_id: u64, + pub weak: bool, + pub reason: WeakReason, + /// Throughput share in permille of total (0..=1000). + pub share_permille: u32, + /// Threshold the share was checked against (permille). + pub threshold_permille: u32, +} + +#[derive(Clone, Debug)] +pub struct ClassificationResult { + /// Delay tier the cascade chose this run (ms). Zero when classification was bypassed. + pub selected_delay_ms: u32, + /// Estimated max delay budget the tiers were derived from. + pub estimated_max_delay_ms: u32, + pub per_link: Vec, +} + +/// Stateful filter: tracks `previously_weak` per connection so the +/// hysteresis pass can use the leaving threshold for those, and the +/// per-connection consecutive-tick streak of an active delay signal so +/// `HighRtt`/`QueueBuilding` only mark weak once sustained. +#[derive(Default)] +pub struct WeakLinkFilter { + prev_weak: HashMap, + delay_weak_streak: HashMap, + /// Consecutive ticks a link has been share-weak (LowShare/NoTraffic), + /// used to trigger a probation re-test once it exceeds + /// `PROBATION_INTERVAL_TICKS`. + weak_streak: HashMap, + /// Remaining forced not-weak ticks for a link currently inside a + /// probation re-test window. + probation_ticks: HashMap, +} + +impl WeakLinkFilter { + pub fn new() -> Self { + Self::default() + } + + pub fn classify(&mut self, conns: &[SrtlaConnection]) -> ClassificationResult { + let mut per_link: Vec = Vec::with_capacity(conns.len()); + + // First pass: gather signals from connected links. + let mut total_bps: f64 = 0.0; + let mut longest_rtt_ms: u32 = 0; + let mut connected_count: usize = 0; + + for conn in conns { + if !conn.connected { + continue; + } + connected_count += 1; + total_bps += conn.bitrate.current_bitrate_bps.max(0.0); + let rtt_ms = conn.get_smooth_rtt_ms() as u32; + if rtt_ms > longest_rtt_ms { + longest_rtt_ms = rtt_ms; + } + } + + // Below the floor — bypass classification, mark everything healthy. + if total_bps < MIN_TOTAL_BPS_FOR_CLASSIFICATION || connected_count == 0 { + for conn in conns { + per_link.push(LinkClassification { + conn_id: conn.conn_id, + weak: false, + reason: WeakReason::Bypassed, + share_permille: 0, + threshold_permille: 0, + }); + } + // Reset hysteresis history so we don't carry stale weak flags + // across an idle period. + self.prev_weak.clear(); + self.delay_weak_streak.clear(); + self.weak_streak.clear(); + self.probation_ticks.clear(); + return ClassificationResult { + selected_delay_ms: 0, + estimated_max_delay_ms: 0, + per_link, + }; + } + + let estimated_max_delay_ms = derive_max_delay_budget(longest_rtt_ms); + let target_best = target_best_delay_ms(estimated_max_delay_ms); + let target_safe = target_safe_delay_ms(estimated_max_delay_ms); + let target_max = target_max_delay_ms(estimated_max_delay_ms); + + // Second pass: bucket throughput by tier. + let mut bytes_per_sec_best: f64 = 0.0; + let mut bytes_per_sec_safe: f64 = 0.0; + let mut bytes_per_sec_max: f64 = 0.0; + for conn in conns { + if !conn.connected { + continue; + } + let bps = conn.bitrate.current_bitrate_bps.max(0.0); + let rtt_ms = conn.get_smooth_rtt_ms() as u32; + if rtt_ms <= target_best { + bytes_per_sec_best += bps; + } + if rtt_ms <= target_safe { + bytes_per_sec_safe += bps; + } + if rtt_ms <= target_max { + bytes_per_sec_max += bps; + } + } + + let selected_delay = pick_tier( + total_bps, + bytes_per_sec_best, + bytes_per_sec_safe, + bytes_per_sec_max, + target_best, + target_safe, + target_max, + ); + + // Third pass: classify each link. + let n_connected = connected_count as u64; + let enter_threshold_permille = (ENTER_FAIR_SHARE_NUMERATOR / n_connected) as u32; + let leave_threshold_permille = (LEAVE_FAIR_SHARE_NUMERATOR / n_connected) as u32; + let mut next_prev_weak: HashMap = HashMap::with_capacity(conns.len()); + let mut next_delay_streak: HashMap = HashMap::with_capacity(conns.len()); + let mut next_weak_streak: HashMap = HashMap::with_capacity(conns.len()); + let mut next_probation: HashMap = HashMap::with_capacity(conns.len()); + + for conn in conns { + if !conn.connected { + per_link.push(LinkClassification { + conn_id: conn.conn_id, + weak: false, + reason: WeakReason::Healthy, + share_permille: 0, + threshold_permille: 0, + }); + continue; + } + + let rtt_ms = conn.get_smooth_rtt_ms() as u32; + let bps = conn.bitrate.current_bitrate_bps.max(0.0); + let share_permille = if total_bps > 0.0 { + ((bps * 1000.0) / total_bps).clamp(0.0, 1000.0) as u32 + } else { + 0 + }; + + let was_weak = self.prev_weak.get(&conn.conn_id).copied().unwrap_or(false); + let threshold = if was_weak { + leave_threshold_permille + } else { + enter_threshold_permille + }; + + // Delay signals (RTT over tier, or a forming queue) flip on a + // single evaluation, so gate them behind a consecutive-tick + // streak. Count up while a delay signal is active, reset to 0 + // the moment it clears; only mark weak once the streak reaches + // WEAK_SUSTAIN_TICKS, filtering one-tick blips. + let delay_signal = if rtt_ms > selected_delay { + Some(WeakReason::HighRtt) + } else if conn.queue_building_suspected() { + Some(WeakReason::QueueBuilding) + } else { + None + }; + let delay_streak = if delay_signal.is_some() { + self.delay_weak_streak + .get(&conn.conn_id) + .copied() + .unwrap_or(0) + .saturating_add(1) + } else { + 0 + }; + next_delay_streak.insert(conn.conn_id, delay_streak); + let delay_weak = delay_streak >= WEAK_SUSTAIN_TICKS; + + let (weak, reason) = if delay_weak { + // Sustained: keep it rankable (the gate crushes score but + // never removes), so this only de-prioritises. + (true, delay_signal.unwrap()) + } else if bps == 0.0 { + (true, WeakReason::NoTraffic) + } else if was_weak && share_permille < leave_threshold_permille { + // Stays weak until share clears the leaving threshold. + (true, WeakReason::LowShare) + } else if !was_weak && share_permille < enter_threshold_permille { + (true, WeakReason::LowShare) + } else { + (false, WeakReason::Healthy) + }; + + // Probation re-test — breaks the share-starvation latch (R1). A + // link gated for low share earns a crushed routing score, gets + // ~no traffic, so its share stays low and it stays gated: a + // self-sustaining lock the GATED_LINK_PENALTY trickle can't escape. + // After PROBATION_INTERVAL_TICKS + // continuously share-weak, force a PROBATION_WINDOW_TICKS window + // treating the link as not-weak, so selection routes it real + // traffic and it can re-prove its share. Emitting not-weak clears + // prev_weak across the window, so the post-window judgement uses + // the (lower) entering threshold and a recovered link can actually + // win the re-test. Delay weakness is exempt (it self-clears from + // live RTT), and `loss_degraded` keeps gating an actually-bad link + // mid-window, so probation only ever re-tests marginal links. + let share_weak = weak && matches!(reason, WeakReason::LowShare | WeakReason::NoTraffic); + let mut probation = self + .probation_ticks + .get(&conn.conn_id) + .copied() + .unwrap_or(0); + let mut streak = self.weak_streak.get(&conn.conn_id).copied().unwrap_or(0); + let (weak, reason) = if probation > 0 { + probation -= 1; + streak = 0; + (false, WeakReason::Healthy) + } else if share_weak { + streak = streak.saturating_add(1); + if streak >= PROBATION_INTERVAL_TICKS { + // Arm the window; this trigger tick stays gated, the next + // PROBATION_WINDOW_TICKS ticks are forced not-weak. + streak = 0; + probation = PROBATION_WINDOW_TICKS; + } + (weak, reason) + } else { + streak = 0; + (weak, reason) + }; + next_weak_streak.insert(conn.conn_id, streak); + next_probation.insert(conn.conn_id, probation); + + next_prev_weak.insert(conn.conn_id, weak); + per_link.push(LinkClassification { + conn_id: conn.conn_id, + weak, + reason, + share_permille, + threshold_permille: threshold, + }); + // Suppress unused-variable warning when consumers ignore rtt_ms. + let _ = rtt_ms; + } + + self.prev_weak = next_prev_weak; + self.delay_weak_streak = next_delay_streak; + self.weak_streak = next_weak_streak; + self.probation_ticks = next_probation; + ClassificationResult { + selected_delay_ms: selected_delay, + estimated_max_delay_ms, + per_link, + } + } +} + +fn derive_max_delay_budget(longest_rtt_ms: u32) -> u32 { + let raw = (longest_rtt_ms as f64 * RTT_TO_DELAY_BUDGET_MULT) as u32; + raw.clamp(MIN_BUDGET_MS, MAX_BUDGET_MS) +} + +fn target_best_delay_ms(est_ms: u32) -> u32 { + ((est_ms as u64 * 40) / 100).min(TARGET_BEST_SAFE_CAP_MS as u64) as u32 +} + +fn target_safe_delay_ms(est_ms: u32) -> u32 { + ((est_ms as u64 * 50) / 100).min(TARGET_BEST_SAFE_CAP_MS as u64) as u32 +} + +fn target_max_delay_ms(est_ms: u32) -> u32 { + ((est_ms as u64 * 60) / 100).min(TARGET_MAX_CAP_MS as u64) as u32 +} + +fn pick_tier( + total_bps: f64, + best_bps: f64, + safe_bps: f64, + max_bps: f64, + best_delay: u32, + safe_delay: u32, + max_delay: u32, +) -> u32 { + // Permille shares of total in each bucket. + let best_pm = ((best_bps * 1000.0) / total_bps) as u64; + let safe_pm = ((safe_bps * 1000.0) / total_bps) as u64; + let max_pm = ((max_bps * 1000.0) / total_bps) as u64; + + if best_pm > SHARE_85_PERMILLE { + return best_delay; + } + if safe_pm > SHARE_85_PERMILLE { + return safe_delay; + } + if max_pm > SHARE_85_PERMILLE { + // Degraded — fall through to 50%/25% cascade. + if best_pm > SHARE_50_PERMILLE { + return best_delay; + } + if safe_pm > SHARE_50_PERMILLE { + return safe_delay; + } + if max_pm > SHARE_50_PERMILLE { + return max_delay; + } + if best_pm > SHARE_25_PERMILLE { + return best_delay; + } + if safe_pm > SHARE_25_PERMILLE { + return safe_delay; + } + return max_delay; + } + max_delay +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn target_tier_math() { + assert_eq!(target_best_delay_ms(1000), 400); + assert_eq!(target_safe_delay_ms(1000), 500); + assert_eq!(target_max_delay_ms(1000), 600); + + // Caps + assert_eq!(target_best_delay_ms(10_000), TARGET_BEST_SAFE_CAP_MS); + assert_eq!(target_safe_delay_ms(10_000), TARGET_BEST_SAFE_CAP_MS); + assert_eq!(target_max_delay_ms(10_000), TARGET_MAX_CAP_MS); + } + + #[test] + fn budget_floor_and_ceiling() { + assert_eq!(derive_max_delay_budget(50), MIN_BUDGET_MS); + assert_eq!(derive_max_delay_budget(2000), MAX_BUDGET_MS); + assert_eq!(derive_max_delay_budget(500), 1500); + } + + #[test] + fn pick_tier_picks_best_when_85pct_fits() { + let tier = pick_tier(1000.0, 900.0, 950.0, 1000.0, 100, 200, 300); + assert_eq!(tier, 100); + } + + #[test] + fn pick_tier_falls_back_to_safe() { + let tier = pick_tier(1000.0, 100.0, 900.0, 1000.0, 100, 200, 300); + assert_eq!(tier, 200); + } + + #[test] + fn pick_tier_falls_back_to_max() { + let tier = pick_tier(1000.0, 0.0, 0.0, 100.0, 100, 200, 300); + assert_eq!(tier, 300); + } + + #[test] + fn empty_classification_returns_bypassed() { + let mut filter = WeakLinkFilter::new(); + let result = filter.classify(&[]); + assert_eq!(result.selected_delay_ms, 0); + assert!(result.per_link.is_empty()); + } +} diff --git a/src/sender/selection/edpf.rs b/src/sender/selection/edpf.rs deleted file mode 100644 index 7620843..0000000 --- a/src/sender/selection/edpf.rs +++ /dev/null @@ -1,163 +0,0 @@ -//! EDPF (Earliest Delivery Path First) link selection. -//! -//! Selects the link with the lowest predicted arrival time, considering -//! in-flight data, link capacity, loss rate, and base RTT. - -use crate::connection::SrtlaConnection; - -/// SRT payload packet size in bytes. -const SRT_PKT_SIZE: usize = 1316; - -/// Compute predicted arrival time for a connection. -/// -/// Returns `None` if the connection lacks valid capacity or RTT data. -fn predicted_arrival(conn: &SrtlaConnection, pkt_size: usize) -> Option { - if !conn.connected { - return None; - } - - let bitrate_bps = conn.bitrate.current_bitrate_bps; - if bitrate_bps <= 0.0 { - return None; - } - let capacity_bytes_per_sec = bitrate_bps / 8.0; - - // Loss from quality multiplier - let loss = (1.0 - conn.quality_cache.multiplier).clamp(0.0, 0.99); - let effective_capacity = capacity_bytes_per_sec * (1.0 - loss); - if effective_capacity <= 0.0 { - return None; - } - - let in_flight_bytes = (conn.in_flight_packets.max(0) as usize * SRT_PKT_SIZE) as f64; - - // Use Kalman-smoothed RTT as propagation delay estimate. - // Falls back to rtt_min_ms if Kalman hasn't initialized yet. - let smooth_rtt = conn.rtt.kalman_rtt.value(); - let propagation_s = if smooth_rtt > 0.0 { - smooth_rtt / 1000.0 - } else { - conn.rtt.rtt_min_ms / 1000.0 - }; - - Some((in_flight_bytes + pkt_size as f64) / effective_capacity + propagation_s) -} - -/// Select the connection with lowest predicted arrival time from all connections. -pub fn select_from(conns: &[SrtlaConnection], pkt_size: usize) -> Option { - let mut best_idx = None; - let mut best_arrival = f64::MAX; - - for (i, conn) in conns.iter().enumerate() { - if let Some(arrival) = predicted_arrival(conn, pkt_size) { - if arrival < best_arrival { - best_arrival = arrival; - best_idx = Some(i); - } - } - } - - best_idx -} - -/// Select the connection with lowest predicted arrival time from a filtered subset. -/// -/// `indices` contains the indices of candidate connections in `conns`. -pub fn select_from_indices( - conns: &[SrtlaConnection], - indices: &[usize], - pkt_size: usize, -) -> Option { - let mut best_idx = None; - let mut best_arrival = f64::MAX; - - for &i in indices { - if i < conns.len() { - if let Some(arrival) = predicted_arrival(&conns[i], pkt_size) { - if arrival < best_arrival { - best_arrival = arrival; - best_idx = Some(i); - } - } - } - } - - best_idx -} - -/// Compute predicted arrival time for a connection (public for IoDS integration). -pub fn arrival_time(conn: &SrtlaConnection, pkt_size: usize) -> Option { - predicted_arrival(conn, pkt_size) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::test_helpers::create_test_connections; - - #[test] - fn test_select_prefers_lower_arrival() { - let rt = tokio::runtime::Runtime::new().unwrap(); - let mut conns = rt.block_on(create_test_connections(3)); - - // Make conn 1 have lowest arrival (low in-flight, high bitrate, low RTT) - conns[0].in_flight_packets = 10; - conns[0].bitrate.current_bitrate_bps = 1_000_000.0; - conns[0].rtt.rtt_min_ms = 50.0; - - conns[1].in_flight_packets = 0; - conns[1].bitrate.current_bitrate_bps = 2_000_000.0; - conns[1].rtt.rtt_min_ms = 20.0; - - conns[2].in_flight_packets = 20; - conns[2].bitrate.current_bitrate_bps = 500_000.0; - conns[2].rtt.rtt_min_ms = 100.0; - - let result = select_from(&conns, SRT_PKT_SIZE); - assert_eq!(result, Some(1), "Should pick conn with lowest predicted arrival"); - } - - #[test] - fn test_select_skips_disconnected() { - let rt = tokio::runtime::Runtime::new().unwrap(); - let mut conns = rt.block_on(create_test_connections(2)); - - conns[0].connected = false; - conns[0].bitrate.current_bitrate_bps = 10_000_000.0; - - conns[1].in_flight_packets = 5; - conns[1].bitrate.current_bitrate_bps = 1_000_000.0; - conns[1].rtt.rtt_min_ms = 50.0; - - let result = select_from(&conns, SRT_PKT_SIZE); - assert_eq!(result, Some(1)); - } - - #[test] - fn test_select_empty() { - let conns: Vec = vec![]; - assert_eq!(select_from(&conns, SRT_PKT_SIZE), None); - } - - #[test] - fn test_select_from_indices() { - let rt = tokio::runtime::Runtime::new().unwrap(); - let mut conns = rt.block_on(create_test_connections(3)); - - conns[0].in_flight_packets = 0; - conns[0].bitrate.current_bitrate_bps = 5_000_000.0; - conns[0].rtt.rtt_min_ms = 10.0; - - conns[1].in_flight_packets = 0; - conns[1].bitrate.current_bitrate_bps = 1_000_000.0; - conns[1].rtt.rtt_min_ms = 50.0; - - conns[2].in_flight_packets = 0; - conns[2].bitrate.current_bitrate_bps = 2_000_000.0; - conns[2].rtt.rtt_min_ms = 20.0; - - // Only consider indices 1 and 2 (exclude the best one, 0) - let result = select_from_indices(&conns, &[1, 2], SRT_PKT_SIZE); - assert_eq!(result, Some(2), "Should pick best from subset"); - } -} diff --git a/src/sender/selection/enhanced.rs b/src/sender/selection/enhanced.rs index 49e7fec..9b88fdf 100644 --- a/src/sender/selection/enhanced.rs +++ b/src/sender/selection/enhanced.rs @@ -4,72 +4,210 @@ //! - Quality-aware scoring based on NAK history //! - RTT-aware bonuses for low-latency connections //! - Score hysteresis to prevent flip-flopping (10%) -//! - Optional smart exploration of alternative connections //! //! The enhanced mode provides better connection quality awareness while //! maintaining natural load distribution across all connections. use tracing::debug; -use super::MIN_SWITCH_INTERVAL_MS; -use super::exploration::should_explore_now; +use super::link_cc::ASSUMED_SRT_PAYLOAD_BYTES; use crate::connection::SrtlaConnection; +/// Headroom multiplier on the bandwidth-delay product for the per-link +/// in-flight cap. The cap is `BDP * 1.5`: a link should be allowed +/// roughly one BDP of packets in flight to keep its pipe full, plus 50% +/// slack for bursts before we steer elsewhere. A fixed packet budget +/// (the old `pps / 40` ≈ 25 ms) starves a high-RTT link that needs a +/// deeper pipe and over-fills a low-RTT one; scaling by the link's own +/// `rtt_min` makes the cap correct across fibre, cellular, and satellite. +const IN_FLIGHT_CAP_BDP_MULT: f64 = 1.5; + /// Switching hysteresis: require new connection to be meaningfully better. /// At 10%, this prevents noise-driven flip-flopping between connections with /// similar scores while still allowing switches when one connection genuinely /// degrades (e.g., higher in_flight due to congestion or packet loss). const SWITCH_THRESHOLD: f64 = 1.10; // New connection must be 10% better +/// Floor on the per-link CC soft-cap multiplier. A link whose measured +/// throughput has saturated its `cc_target_bps` gets its score scaled +/// down to this fraction rather than zero — keeps a little keepalive +/// traffic flowing so the CC controller can still observe RTT and +/// loss for the recovery decision. +const CC_SOFT_CAP_FLOOR: f64 = 0.10; + +/// Score multiplier applied to a quality-gated link (`weak` or +/// `loss_degraded`) when at least one un-gated link is schedulable. +/// The link stays in the ranking at a crushed score instead of being +/// dropped outright. In steady state a healthy link's full score still +/// wins decisively, so routing is unchanged; the point is that the +/// demoted link keeps a trickle of data flowing, which is what lets it +/// earn the ACK and loss samples that clear the gate. Without this, an +/// excluded link earns zero throughput share, which the classifier +/// reads as `NoTraffic`/`LowShare` and keeps flagging weak — a +/// self-sustaining starvation lock that never re-tests the link. +/// +/// Measured: with a healthy peer available, a 70%-loss link is gated to +/// 0.00 Mbps, silently heals, and re-adopts itself ~7s later purely on +/// this trickle. That is why an explicit starved-link probe was tried +/// and dropped — it moved delivery 0.76 pts (t=0.75, n=15), i.e. not at +/// all, because this penalty already does the job. +const GATED_LINK_PENALTY: f64 = 0.02; + +/// In-flight cap (packets) as a bandwidth-delay product: the link's +/// predicted sustainable rate times its own minimum RTT, with +/// `IN_FLIGHT_CAP_BDP_MULT` headroom. +/// +/// Returns `None` when there's no rate signal (`cc_target_bps == 0`, +/// i.e. the CC controller hasn't published a target yet) — selection +/// treats the cap as inactive in that case. `rtt_min_ms` is the link's +/// windowed minimum RTT; a non-positive value falls back to 1 ms so the +/// cap stays well-defined before the baseline is established. +/// +/// `cap = max(1, cc_target_bps * rtt_min_s / 8 * 1.5 / packet_bytes)`. +/// Floored at 1 so even a very slow link can keep one packet in flight; +/// the cap bounds queueing delay, it does not gate the link entirely. +#[inline] +pub fn in_flight_cap_packets(cc_target_bps: u64, rtt_min_ms: f64) -> Option { + if cc_target_bps == 0 { + return None; + } + let rtt_ms = if rtt_min_ms.is_finite() && rtt_min_ms > 0.0 { + rtt_min_ms + } else { + 1.0 + }; + let bdp_bytes = (cc_target_bps as f64) * (rtt_ms / 1000.0) / 8.0 * IN_FLIGHT_CAP_BDP_MULT; + let cap = (bdp_bytes / ASSUMED_SRT_PAYLOAD_BYTES as f64) + .floor() + .max(1.0); + Some(cap.min(i32::MAX as f64) as i32) +} + +/// Whether the link is currently exceeding its in-flight cap. Used by +/// the admission gate alongside `weak` and `loss_degraded`. A capped +/// link is excluded from candidate ranking when at least one +/// non-capped, non-weak, non-loss-degraded link is schedulable. +#[inline(always)] +pub fn in_flight_cap_exceeded(c: &SrtlaConnection) -> bool { + in_flight_cap_packets(c.cc_target_bps, c.get_rtt_min_ms()) + .map(|cap| c.in_flight_packets > cap) + .unwrap_or(false) +} + +/// Compute the CC soft-cap multiplier for a connection. Reads +/// `cc_target_bps` (set by `LinkCcController::tick_all`) and the +/// connection's measured bitrate; returns a value in `[CC_SOFT_CAP_FLOOR, 1.0]` +/// that the caller folds into the link's score. +/// +/// Returns `1.0` (no cap) when: +/// - the CC controller hasn't published a target yet (`cc_target_bps == 0`), +/// - or measured throughput on this link is zero (idle link, plenty of headroom). +fn cc_soft_cap_multiplier(conn: &SrtlaConnection) -> f64 { + let cap = conn.cc_target_bps; + if cap == 0 { + return 1.0; + } + let measured = conn.bitrate.current_bitrate_bps; + if measured <= 0.0 { + return 1.0; + } + let cap_f = cap as f64; + let headroom = (cap_f - measured).max(0.0); + (headroom / cap_f).clamp(CC_SOFT_CAP_FLOOR, 1.0) +} + /// Select best connection using enhanced algorithm with quality awareness /// /// Returns the index of the connection with the best quality-adjusted score. -/// Implements time-based switch dampening to prevent rapid thrashing. /// -/// IMPORTANT: This function is called for EACH incoming SRT packet. The returned -/// connection index determines where that packet (and subsequent packets) will be routed. -/// Time-based dampening prevents changing the routing decision too frequently, ensuring -/// all packets continue flowing through the same connection during the cooldown period. -/// This is NOT a per-packet round-robin - it's a per-packet "best connection" selection -/// with dampening to prevent rapid switching under bursty network conditions. +/// IMPORTANT: This function is called for EACH incoming SRT packet, and it is +/// meant to be: `get_score()` counts a link's queued-but-unflushed packets as +/// in-flight, so routing a packet immediately lowers that link's own score. +/// Selection is therefore a closed feedback loop that bounds queue depth per +/// link — re-deciding on every packet is the mechanism, not thrashing. +/// +/// Score hysteresis ([`SWITCH_THRESHOLD`]) still resists flip-flopping between +/// links whose scores are within noise of each other. /// /// # Arguments /// * `conns` - Mutable slice of available connections (for quality cache updates) /// * `last_idx` - Previously selected connection index (for hysteresis) -/// * `last_switch_time_ms` - Timestamp of last connection switch /// * `current_time_ms` - Current timestamp in milliseconds /// * `enable_quality` - Whether to apply quality scoring -/// * `enable_explore` - Whether to enable smart exploration #[inline(always)] pub fn select_connection( conns: &mut [SrtlaConnection], last_idx: Option, - last_switch_time_ms: u64, current_time_ms: u64, enable_quality: bool, - enable_explore: bool, ) -> Option { - // Score connections by base score; apply quality multiplier if enabled + // First pass: discover whether at least one un-gated connection + // can carry the packet. The classifier marks links weak when their + // RTT busts the chosen delay tier (sustained, not a single blip), + // when a queue is building, or when they fall below the entering + // throughput-share threshold. The loss gate uses `loss_degraded` — + // the 4s-sustained, hysteretic loss latch — rather than the raw + // per-window `cc_backing_off`, so a single noisy loss window doesn't + // demote routing weight (cc_backing_off still drives the CC + // controller's own bitrate backoff; it just no longer gates routing). + // The in-flight cap gates a link whose in-flight packets already + // exceed its bandwidth-delay product (plus headroom), so the + // scheduler doesn't pile more on while the link drains. If any + // un-gated link is schedulable, the gated ones are excluded from + // ranking. Otherwise we fall back to the full pool — better to send + // on a gated link than to drop the packet. + let any_unconstrained = conns.iter().any(|c| { + !c.is_timed_out(current_time_ms) + && c.is_schedulable() + && !c.weak + && !c.loss_degraded + && !c.stall_gated + && !in_flight_cap_exceeded(c) + }); + + // Score connections by base score; apply quality multiplier if enabled. + // + // Only the best link is tracked; nothing consumes the runner-up's rank. let mut best_idx: Option = None; - let mut second_idx: Option = None; let mut best_score: f64 = -1.0; - let mut second_score: f64 = -1.0; let mut current_score: Option = None; for (i, c) in conns.iter_mut().enumerate() { - if c.is_timed_out() { + // A stall-gated link is a black hole with a healthier alternative + // available (see `apply_stall_gate`); hard-skip it like a timed-out link + // rather than crushing its score, since a trickle would only add latency. + if c.is_timed_out(current_time_ms) || !c.is_schedulable() || c.stall_gated { continue; } - let base = c.get_score() as f64; + // Hard-skip only the in-flight cap: it bounds queueing delay and + // is transient (self-clears as the link drains), so piling more + // on is counterproductive. Quality gates (`weak`, + // `loss_degraded`) instead crush the score but keep the link + // rankable, so it is never starved into a permanent weak lock. + if any_unconstrained && in_flight_cap_exceeded(c) { + continue; + } + let quality_gated = any_unconstrained && (c.weak || c.loss_degraded); + let gate_mult = if quality_gated { + GATED_LINK_PENALTY + } else { + 1.0 + }; + // The phase weight de-rates a warming link rather than excluding it. At + // go-live every link is warming, so an exclusion here would empty the + // candidate pool and drop the stream; an equal de-rating leaves the + // relative ranking intact and traffic flows immediately. + let base = c.get_score() as f64 * c.phase_weight(); + let cap_mult = cc_soft_cap_multiplier(c); let score = if !enable_quality { - base + base * cap_mult * gate_mult } else { // Use cached quality multiplier (recalculates every 50ms) let quality_mult = c.get_cached_quality_multiplier(current_time_ms); - let final_score = base * quality_mult; + let final_score = base * quality_mult * cap_mult * gate_mult; // Log quality issues and recoveries for debugging (cold path) - log_quality_state(c, quality_mult, base, final_score); + log_quality_state(c, quality_mult, base, final_score, current_time_ms); final_score }; @@ -80,39 +218,34 @@ pub fn select_connection( } if score > best_score { - second_score = best_score; - second_idx = best_idx; best_score = score; best_idx = Some(i); - } else if score > second_score { - second_score = score; - second_idx = Some(i); } } - // Time-based switch dampening: prevent rapid thrashing under bursty scores - // Check if we're within the minimum switch interval - let time_since_last_switch_ms = current_time_ms.saturating_sub(last_switch_time_ms); - let in_switch_cooldown = time_since_last_switch_ms < MIN_SWITCH_INTERVAL_MS; - + // No time-based switch cooldown. + // + // There used to be one (`MIN_SWITCH_INTERVAL_MS`, 15ms), which pinned the + // selector to the previously chosen link regardless of score. Its purpose was + // not scheduling: `forward_via_connection` flushed the previous link's batch + // on every switch, so per-packet switching emitted a one-packet batch each + // time, and the cooldown suppressed that. Batches are per-connection and now + // leave in a single `sendmmsg` on their own threshold/timer, so the flush is + // gone and switching is free. + // + // Keeping the cooldown would be actively harmful: `get_score()` counts queued + // packets as in-flight so that routing a packet immediately de-prioritises its + // link. Holding the decision fixed for 15ms (~24 packets at the rate this + // sender actually pushes) opens that feedback loop, and in-flight runs away on + // whichever link the timer happened to park on. + // + // Score hysteresis below still damps flip-flopping between links whose scores + // differ only by noise — that is a score-space guard, and costs no syscalls. if let Some(last) = last_idx { // If proposing a different connection if best_idx != Some(last) { - // Check if last connection is still valid - let last_still_valid = - last < conns.len() && !conns[last].is_timed_out() && conns[last].connected; - - // If in cooldown period and last connection is still valid, keep it - if in_switch_cooldown && last_still_valid { - debug!( - "Switch dampening: staying with current connection (cooldown: {}ms remaining)", - MIN_SWITCH_INTERVAL_MS.saturating_sub(time_since_last_switch_ms) - ); - return Some(last); - } - - // Apply score-based hysteresis if not in cooldown - // If current connection is still valid and new best isn't significantly better + // Apply score-based hysteresis: only move off the current link when + // the new best is meaningfully better. if let Some(current) = current_score && best_score < current * SWITCH_THRESHOLD { @@ -131,39 +264,26 @@ pub fn select_connection( } } - // Apply exploration if enabled (but respect cooldown to avoid rapid switching) - let explore_now = if enable_explore && !in_switch_cooldown { - should_explore_now(conns, best_idx, second_idx) - } else { - false - }; - - if explore_now { - // Exploration wants to try second-best, but only if different from current - if let (Some(second), Some(last)) = (second_idx, last_idx) - && second != last - { - debug!("Exploration: trying second-best connection"); - return second_idx.or(best_idx); - } - // If second is same as current, just use best - best_idx - } else { - best_idx - } + best_idx } /// Log quality state for debugging (cold path, marked for optimizer hints) #[cold] #[inline(never)] -fn log_quality_state(c: &SrtlaConnection, quality_mult: f64, base: f64, final_score: f64) { +fn log_quality_state( + c: &SrtlaConnection, + quality_mult: f64, + base: f64, + final_score: f64, + now_ms: u64, +) { if quality_mult < 0.8 { debug!( "{} quality degraded: {:.2} (NAKs: {}, last: {}ms ago, burst: {}) base: {} → final: {}", c.label, quality_mult, c.total_nak_count(), - c.time_since_last_nak_ms().unwrap_or(0), + c.time_since_last_nak_ms(now_ms).unwrap_or(0), c.nak_burst_count(), base as i32, final_score as i32 @@ -178,4 +298,95 @@ fn log_quality_state(c: &SrtlaConnection, quality_mult: f64, base: f64, final_sc } } -// Tests are in src/tests/sender_tests.rs +// Most enhanced-mode integration tests live in src/tests/sender_tests.rs; +// the pure cap-helper unit tests sit here so they don't drag in the +// async runtime needed to spin up test connections. +#[cfg(test)] +mod tests { + use super::*; + use crate::connection::SrtlaConnection; + use crate::test_helpers::create_test_connections; + + fn one_conn() -> SrtlaConnection { + let rt = tokio::runtime::Runtime::new().unwrap(); + rt.block_on(create_test_connections(1)).pop().unwrap() + } + + #[test] + fn cap_no_signal_returns_unity() { + let c = one_conn(); + // cc_target_bps default 0 → no cap. + assert!((cc_soft_cap_multiplier(&c) - 1.0).abs() < f64::EPSILON); + } + + #[test] + fn cap_idle_link_returns_unity() { + let mut c = one_conn(); + c.cc_target_bps = 1_000_000; + c.bitrate.current_bitrate_bps = 0.0; + // Plenty of headroom on an idle link. + assert!((cc_soft_cap_multiplier(&c) - 1.0).abs() < f64::EPSILON); + } + + #[test] + fn cap_at_target_falls_to_floor() { + let mut c = one_conn(); + c.cc_target_bps = 1_000_000; + c.bitrate.current_bitrate_bps = 1_000_000.0; + // Saturated → floor multiplier (10%). + let m = cc_soft_cap_multiplier(&c); + assert!((m - CC_SOFT_CAP_FLOOR).abs() < f64::EPSILON, "got {m}"); + } + + #[test] + fn in_flight_cap_no_signal() { + // cc_target_bps == 0 → cap inactive regardless of in_flight. + assert_eq!(in_flight_cap_packets(0, 50.0), None); + let mut c = one_conn(); + c.cc_target_bps = 0; + c.in_flight_packets = 10_000; + assert!(!in_flight_cap_exceeded(&c)); + } + + #[test] + fn in_flight_cap_floors_at_one() { + // 100 kbps over a 20 ms RTT: BDP = 1e5 * 0.02 / 8 = 250 bytes, + // x1.5 = 375 bytes < one packet, so the cap floors at 1. + let cap = in_flight_cap_packets(100_000, 20.0).unwrap(); + assert_eq!(cap, 1); + } + + #[test] + fn in_flight_cap_scales_with_bdp() { + // 10 Mbps over 50 ms: BDP = 1e7 * 0.05 / 8 = 62_500 bytes, x1.5 + // = 93_750, / 1316 ≈ 71 packets. + let cap = in_flight_cap_packets(10_000_000, 50.0).unwrap(); + assert!((68..=74).contains(&cap), "got {cap}"); + // Same rate at 4x the RTT gives ~4x the cap (path-relative). + let cap_high_rtt = in_flight_cap_packets(10_000_000, 200.0).unwrap(); + assert!(cap_high_rtt > cap * 3, "got {cap_high_rtt} vs {cap}"); + } + + #[test] + fn in_flight_cap_engaged_when_exceeded() { + let mut c = one_conn(); + c.cc_target_bps = 10_000_000; + let cap = in_flight_cap_packets(c.cc_target_bps, c.get_rtt_min_ms()).unwrap(); + c.in_flight_packets = cap; + assert!( + !in_flight_cap_exceeded(&c), + "at cap is allowed, only above triggers" + ); + c.in_flight_packets = cap + 1; + assert!(in_flight_cap_exceeded(&c)); + } + + #[test] + fn cap_half_target_returns_half() { + let mut c = one_conn(); + c.cc_target_bps = 1_000_000; + c.bitrate.current_bitrate_bps = 500_000.0; + let m = cc_soft_cap_multiplier(&c); + assert!((m - 0.5).abs() < 0.01, "got {m}"); + } +} diff --git a/src/sender/selection/exploration.rs b/src/sender/selection/exploration.rs deleted file mode 100644 index 87c83c4..0000000 --- a/src/sender/selection/exploration.rs +++ /dev/null @@ -1,61 +0,0 @@ -//! Connection exploration logic for enhanced mode -//! -//! This module implements smart exploration to discover better connections when: -//! - Current best connection is degrading (recent NAKs) -//! - Alternative connections have recovered from previous issues -//! - Periodic fallback exploration (safety net) - -use tracing::debug; - -use crate::connection::SrtlaConnection; -use crate::utils::elapsed_ms; - -/// Determine if we should explore alternative connections -/// -/// Returns true when exploration is likely to discover better options: -/// - Best connection has recent NAKs AND second-best has recovered -/// - Periodic exploration every 30s for 300ms (safety net) -pub fn should_explore_now( - conns: &[SrtlaConnection], - best_idx: Option, - second_idx: Option, -) -> bool { - // Need both best and second-best connections to explore - let (best_idx, second_idx) = match (best_idx, second_idx) { - (Some(b), Some(s)) => (b, s), - _ => return false, // Not enough connections - }; - - if best_idx >= conns.len() || second_idx >= conns.len() { - return false; - } - - let best_conn = &conns[best_idx]; - let second_conn = &conns[second_idx]; - - // Condition 1: Current best has recent NAKs (degrading) - let best_degraded = best_conn - .time_since_last_nak_ms() - .map(|t| t < 3000) - .unwrap_or(false); - - // Condition 2: Second-best has recovered from NAKs (potentially improved) - let second_recovered = second_conn - .time_since_last_nak_ms() - .map(|t| t > 5000) - .unwrap_or(true); // No NAKs = recovered - - // Condition 3: Periodic exploration as fallback (every 30s for 300ms) - let periodic_exploration = (elapsed_ms() % 30000) < 300; - - // Explore if best is degraded AND second has recovered, OR periodic fallback - let should_explore = (best_degraded && second_recovered) || periodic_exploration; - - if should_explore { - debug!("Exploration: trying second-best connection"); - } - - should_explore -} - -// Tests are in src/tests/sender_tests.rs diff --git a/src/sender/selection/iods.rs b/src/sender/selection/iods.rs deleted file mode 100644 index 5bf0c66..0000000 --- a/src/sender/selection/iods.rs +++ /dev/null @@ -1,114 +0,0 @@ -//! IoDS (In-order Delivery Scheduling) reordering prevention. -//! -//! Ensures packets are scheduled so that they arrive in order at the receiver, -//! reducing SRT retransmissions caused by out-of-order delivery. - -/// IoDS scheduling state. -#[derive(Debug)] -pub struct IodsFilter { - /// Last scheduled predicted arrival time. - last_arrival: f64, -} - -impl IodsFilter { - pub fn new() -> Self { - Self { last_arrival: 0.0 } - } - - /// Record that a packet was scheduled with the given predicted arrival time. - pub fn record_scheduled(&mut self, predicted_arrival: f64) { - if predicted_arrival > self.last_arrival { - self.last_arrival = predicted_arrival; - } - } - - /// Filter candidate indices to only those that maintain monotonic ordering. - /// - /// A candidate is valid if its predicted arrival time >= last_scheduled_arrival. - pub fn filter_valid( - &self, - indices: &[usize], - arrival_fn: impl Fn(usize) -> Option, - ) -> Vec { - indices - .iter() - .copied() - .filter(|&idx| { - if let Some(arrival) = arrival_fn(idx) { - arrival >= self.last_arrival - } else { - false - } - }) - .collect() - } - - /// Reset the ordering state (e.g., after a long gap). - #[allow(dead_code)] - pub fn reset(&mut self) { - self.last_arrival = 0.0; - } -} - -impl Default for IodsFilter { - fn default() -> Self { - Self::new() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_monotonic_ordering() { - let mut iods = IodsFilter::new(); - - let arrivals = vec![0.1, 0.05, 0.2, 0.15]; - let indices: Vec = (0..4).collect(); - - // Initially all pass (last_arrival = 0) - let valid = iods.filter_valid(&indices, |i| Some(arrivals[i])); - assert_eq!(valid, vec![0, 1, 2, 3]); - - // Schedule at t=0.15 - iods.record_scheduled(0.15); - - // Now only arrivals >= 0.15 should pass - let valid = iods.filter_valid(&indices, |i| Some(arrivals[i])); - assert_eq!(valid, vec![2, 3]); // 0.2 >= 0.15 and 0.15 >= 0.15 - } - - #[test] - fn test_empty_candidates() { - let iods = IodsFilter::new(); - let valid = iods.filter_valid(&[], |_: usize| Some(1.0)); - assert!(valid.is_empty()); - } - - #[test] - fn test_reset() { - let mut iods = IodsFilter::new(); - iods.record_scheduled(100.0); - - let valid = iods.filter_valid(&[0], |_| Some(1.0)); - assert!(valid.is_empty()); - - iods.reset(); - let valid = iods.filter_valid(&[0], |_| Some(1.0)); - assert_eq!(valid, vec![0]); - } - - #[test] - fn test_none_arrival_filtered_out() { - let iods = IodsFilter::new(); - let valid = iods.filter_valid(&[0, 1, 2], |i| { - if i == 1 { - None - } else { - Some(1.0) - } - }); - assert_eq!(valid, vec![0, 2]); - } -} diff --git a/src/sender/selection/link_cc.rs b/src/sender/selection/link_cc.rs new file mode 100644 index 0000000..4b506a3 --- /dev/null +++ b/src/sender/selection/link_cc.rs @@ -0,0 +1,1472 @@ +//! Per-link congestion-control soft cap. +//! +//! A small per-connection state machine that produces a `target_bps` — +//! a soft cap on the rate the scheduler should push down this link. +//! `target_bps` is consumed by Enhanced selection (the soft-cap score +//! multiplier and the BDP in-flight cap), and the sustained `loss_degraded` +//! latch feeds the routing loss gate. The instantaneous `BackingOff` state +//! drives this controller's own bitrate backoff but does not gate routing. +//! +//! ## State machine +//! +//! Three states cover the practical regimes for a SRTLA soft cap: +//! +//! - **Climbing**: RTT stable, and no loss that we caused. Additively +//! grow `target_bps`. Step is bounded by current cap and the link's +//! measured throughput so it doesn't run away on idle links. +//! - **Holding**: RTT inflating but no loss yet (delay-based signal of +//! approaching congestion). Hold target, don't grow. +//! - **BackingOff**: Loss observed (NAK rate up) *while we were driving +//! the link hard enough to have caused it*. Multiplicative decrease, +//! floored at measured throughput. +//! +//! Three states cover the steady-state, the bufferbloat-onset state, +//! and the loss state — which is what matters for a soft cap. +//! +//! ## Only back off for loss you caused +//! +//! Loss is not by itself evidence of congestion. A cellular link at the +//! cell edge sits at a percent or two of wire loss indefinitely, no +//! matter how little we send down it. A soft cap cannot repair that +//! loss, so reacting to it is pure downside: the decrease compounds for +//! as long as the loss lasts, and loss that outlives the backoff drives +//! the cap to the floor, where the BDP in-flight cap deselects a link +//! that was carrying real traffic. +//! +//! Three guards keep the decrease honest. Each covers a case the others +//! cannot, and all three were needed before a link with steady wire loss +//! stopped being ratcheted to the floor on a real netem run. +//! +//! - `BACKOFF_MIN_LOAD_PERMILLE` gates *entry*. A link we are barely +//! feeding cannot be the cause of its own loss, so it never backs off. +//! This is the only guard that helps a **starved** link: one carrying +//! nothing has no throughput signal for the other two to reason from. +//! +//! - The **delivered floor** bounds the *depth*: never cap a link below +//! the rate it is visibly sustaining. This is the load-bearing one, +//! and the reason is easy to get backwards. `target_bps` **does not +//! pace**. It steers the scheduler between links; it does not throttle +//! this one. So a cut does not reduce what this link sends, and +//! measured throughput does *not* follow the cap down. A cap under +//! proven delivery is not a conservative estimate, it is a wrong one, +//! and nothing about it self-corrects — so the decrease compounds +//! until it hits `MIN_TARGET_BPS`. Under genuine congestion the floor +//! still converges, because there a cut steers traffic *off* the link, +//! throughput really does fall, and the floor follows it down. +//! +//! - `BACKOFF_EFFICACY_TICKS` bounds the *duration*: if a ~39% cut has +//! not moved the loss, stop attributing it to ourselves +//! (`loss_uncongestive`) and let the link climb again. Congestive loss +//! responds to a lower offered rate; wire loss does not. +//! +//! The verdict expires only on `LOSS_UNCONGESTIVE_RETEST_TICKS`, or when +//! the loss regime ends. It deliberately does **not** expire on RTT +//! inflation. That was tried, on the theory that RTT is congestion +//! evidence independent of the loss signal, and it re-opened the backoff +//! path on every inflated tick: the controller alternated +//! `BackingOff → Drain → BackingOff`, cut on nearly all of them, and +//! ratcheted to the floor exactly as if the latch did not exist. +//! Congestion visible in RTT is already handled by `Drain` and +//! `Holding`, which are untouched by any of this — the loss path does +//! not need to double up on it. +//! +//! ## Age-bucketed RTT EWMA +//! +//! EWMA weight banded by time-since-last-sample to stay responsive +//! after stale periods. Power-of-2 ratios `1:1, 1:4, 1:8, 1:16` at age +//! bands `>= 2s, >= 1s, >= 500ms, >= 250ms`. After 2s with no sample we +//! reset to the new sample verbatim. +//! +//! All numbers here are starting points; soak data may suggest +//! retuning. + +use std::collections::HashMap; + +use crate::connection::SrtlaConnection; + +/// Sliding-window length for the loss-permille tracker, in +/// milliseconds. 1s matches the rough timescale of NAK feedback. +const LOSS_WINDOW_MS: u64 = 1_000; + +/// Loss-permille threshold above which we declare a backoff regime. +/// 5 parts-per-thousand = 0.5%. +const LOSS_BACKOFF_PERMILLE: u32 = 5; + +/// Multiplicative-decrease factor (permille). 0.85 = -15%. +const BACKOFF_PERMILLE: u32 = 850; + +/// Delivered throughput, as a permille of `target_bps`, above which +/// observed loss is attributed to our own offered rate. +/// +/// Loss below this line is loss we did not cause: we are not pushing +/// enough traffic for it to be filling the bottleneck, so what we are +/// seeing is wire loss (cell-edge SINR, HARQ residual, a lossy backhaul) +/// that a lower soft cap cannot repair. Backing off only sheds bonding +/// capacity we could otherwise use, and because the loss never clears, +/// the decrease compounds every tick until the link is pinned at +/// `MIN_TARGET_BPS` and the BDP in-flight cap deselects it — a link that +/// was carrying megabits gets thrown away over a percent of wire loss. +/// +/// The 30% line sits deliberately below the ~50% load a healthy link +/// settles at (`Climbing` bounds the target at 2x measured throughput, +/// so a fully-climbed active link reads ~0.5), and well above the near- +/// zero load of a link the scheduler has stopped feeding. Links the +/// scheduler is genuinely driving still back off; starved ones stop +/// being punished for loss that isn't theirs. +const BACKOFF_MIN_LOAD_PERMILLE: u32 = 300; + +/// Consecutive `BackingOff` ticks after which the decrease has to show +/// results. Three ticks is a ~39% cut (0.85^3), which is far more than +/// enough for a bottleneck we are actually overdriving to drain. +const BACKOFF_EFFICACY_TICKS: u32 = 3; + +/// The loss permille must fall to at most this fraction of its level at +/// the start of the episode for the backoff to count as working. +const BACKOFF_EFFICACY_IMPROVEMENT_PERMILLE: u32 = 800; + +/// How long an "this loss is not mine" verdict stands before we re-test +/// it. A verdict that never expires is one we can never correct, and a +/// link's conditions do change. Re-testing costs at most one more +/// `BACKOFF_EFFICACY_TICKS` episode (~39%) per interval, against which +/// `Climbing` recovers considerably more, so it cannot ratchet. +const LOSS_UNCONGESTIVE_RETEST_TICKS: u32 = 30; + +/// Climbing additive-increase step as a permille of the current target. +/// 0.02 = +2% per tick — the conservative baseline for steady state. +const AI_STEP_PERMILLE: u32 = 20; + +/// Bigger step (+6% per tick) used by High-Additive-Increase mode when +/// RTT is stable enough that we're confident headroom exists. "Stable" +/// here = RTT variance ≤ 10% of the smoothed RTT mean. +const HAI_STEP_PERMILLE: u32 = 60; + +/// Step used during fast-recovery after a backoff or drain. Faster +/// than normal AI, slower than HAI — we want to claw back quickly +/// but not overshoot the level that triggered the backoff. +const FAST_RECOVERY_STEP_PERMILLE: u32 = 40; + +/// Number of ticks we stay in fast-recovery after exiting BackingOff +/// or Drain. ~5s at 1Hz tick which roughly covers one cellular RTT +/// cycle plus margin. +const FAST_RECOVERY_TICKS: u32 = 5; + +/// RTT-inflation threshold for one-shot Drain. When the smoothed RTT +/// is more than 2.0x the running minimum without any loss observed, +/// the bandwidth-delay queue is overflowing — cut hard rather than +/// wait for ARQ to surface the loss. +const DRAIN_RTT_INFLATION: f64 = 2.0; + +/// Drain factor applied as a one-shot multiplicative decrease when +/// Drain triggers. 0.75 = -25%. +const DRAIN_PERMILLE: u32 = 750; + +/// "Stable RTT" threshold for HAI: rtt_var must be at most this +/// fraction of rtt_ewma. 0.10 = "variance < 10% of mean". +const HAI_VARIANCE_FRACTION: f64 = 0.10; + +/// Assumed SRT payload size for converting cumulative bytes-sent +/// counters into packet counts when feeding `record_loss`. Most SRTLA +/// deployments run with the libsrt 1316-byte default; off-by-a-factor +/// only matters for the loss-permille ratio, which is invariant under +/// uniform packet-size assumptions. +pub(crate) const ASSUMED_SRT_PAYLOAD_BYTES: u64 = 1316; + +/// Above this RTT-inflation factor (relative to the link's minimum +/// observed RTT) we declare a hold regime even when no loss has hit. +/// 1.5 = "RTT is 50% above the floor". +const RTT_HOLD_FACTOR: f64 = 1.5; + +/// Floor for `target_bps`. Below this we don't bother modulating. +const MIN_TARGET_BPS: u64 = 100_000; + +/// Ceiling we never let `target_bps` exceed before measured traffic +/// catches up. Soft cap; tuning starts here, may be revised. +const MAX_TARGET_BPS: u64 = 200_000_000; + +/// Initial target on first sample. Conservative on purpose. +const INITIAL_TARGET_BPS: u64 = 1_000_000; + +/// Time constant (ms) for the link loss EWMA that drives continuous +/// phase demotion. Cellular HARQ stalls last 400-800ms; a 2s tau keeps +/// the signal from reacting to a single stall while still demoting a +/// link that is genuinely shedding traffic. +const LOSS_EWMA_TAU_MS: f64 = 2_000.0; + +/// Loss-EWMA fraction (0..1) above which, once sustained for +/// `LOSS_DEGRADE_SUSTAIN_MS`, the link is flagged degraded. High on +/// purpose: only a link losing most of its traffic trips it, so +/// transient stalls do not cause a false demotion. This drives a graded +/// score penalty, never a hard removal — the scheduler keeps the link +/// so it can prove its own recovery on the next ACK. +const LOSS_DEGRADE_ENTER: f64 = 0.55; + +/// Hysteresis: the loss EWMA must fall back below this before the +/// degraded flag clears. +const LOSS_DEGRADE_CLEAR: f64 = 0.25; + +/// How long the loss EWMA must stay above `LOSS_DEGRADE_ENTER` before +/// the degraded flag latches. +const LOSS_DEGRADE_SUSTAIN_MS: u64 = 4_000; + +/// Window (ms) over which the CC's minimum RTT is tracked. A lifetime +/// minimum pins `rtt_inflation` high forever after one early low sample, +/// so a cellular handover that raises the true floor reads as permanent +/// congestion and traps the controller in Drain/Hold. Expiring the min +/// over ~30s lets the baseline follow the path. Matches the connection +/// RTT tracker's slow-window timescale. +const CC_RTT_MIN_WINDOW_MS: u64 = 30_000; + +/// Reject a single throughput sample that exceeds this factor times the +/// current target estimate. A stall-release ACK flush (a carrier NAT +/// rebind dumping thousands of queued ACKs in one window) or a +/// saturation burst can momentarily read 3-5x the link's true rate; +/// without this clamp it inflates the soft cap and causes bufferbloat a +/// few seconds later. The estimate may still rise, just never more than +/// this factor from one contaminated sample. +const CC_OUTLIER_FACTOR: f64 = 4.0; + +#[derive(Copy, Clone, Debug, Eq, PartialEq, Default)] +pub enum CcState { + /// Pre-RTT-sample bootstrap state. Target stays at the floor until + /// the first RTT update arrives. + #[default] + Bootstrap, + /// RTT stable, no loss. Additive increase. Step size depends on + /// the current [`ClimbMode`]: Normal (2%), Hai (6%), or + /// FastRecovery (4%). + Climbing, + /// RTT inflating, no loss yet. Hold target. + Holding, + /// Loss observed while the link was loaded past + /// `BACKOFF_MIN_LOAD_PERMILLE` — i.e. loss our own offered rate + /// plausibly caused. Multiplicative decrease, floored at measured + /// throughput. Loss on an under-driven link is wire loss and lands + /// in `Climbing` instead. + BackingOff, + /// One-shot drain when RTT inflation crosses + /// `DRAIN_RTT_INFLATION` without explicit loss — bandwidth-delay + /// queue is overflowing. Drops target to 75% on entry; the next + /// tick re-evaluates and typically lands in Holding. + Drain, +} + +impl CcState { + pub fn as_str(self) -> &'static str { + match self { + CcState::Bootstrap => "bootstrap", + CcState::Climbing => "climbing", + CcState::Holding => "holding", + CcState::BackingOff => "backing_off", + CcState::Drain => "drain", + } + } +} + +/// Sub-mode within [`CcState::Climbing`] that controls AI step size. +#[derive(Copy, Clone, Debug, Eq, PartialEq, Default)] +pub enum ClimbMode { + /// Standard 2% additive increase. + #[default] + Normal, + /// 6% additive increase when RTT is stable (variance ≤ 10% of mean). + /// "High Additive Increase" — we have confident headroom signal. + Hai, + /// 4% additive increase for `FAST_RECOVERY_TICKS` ticks after + /// exiting BackingOff or Drain. Claws back quickly without + /// overshooting the level that triggered the backoff. + FastRecovery, +} + +impl ClimbMode { + pub fn as_str(self) -> &'static str { + match self { + ClimbMode::Normal => "normal", + ClimbMode::Hai => "hai", + ClimbMode::FastRecovery => "fast_recovery", + } + } +} + +/// One sample of `(timestamp_ms, lost_packets)`. Used for the sliding +/// loss-permille window. +#[derive(Copy, Clone, Debug)] +struct LossSample { + ts_ms: u64, + lost: u32, + sent: u32, +} + +/// Per-connection CC state. One of these lives on each +/// `SrtlaConnection` (added in a follow-up patch). Today it's +/// instantiated next to the classifier filter and indexed by +/// `conn_id`. +#[derive(Debug)] +pub struct LinkCongestionState { + pub state: CcState, + /// Active climb sub-mode. Only meaningful when `state == Climbing`. + pub climb_mode: ClimbMode, + pub target_bps: u64, + /// Power-of-2 age-bucketed EWMA of RTT (ms). + rtt_ewma_ms: f64, + /// Variance proxy: EWMA of `|sample - rtt_ewma|` with weight 1:3. + rtt_var_ms: f64, + /// Lowest RTT in the recent window (see `CC_RTT_MIN_WINDOW_MS`). + /// Used to detect inflation. Windowed, not lifetime, so a handover + /// that raises the floor doesn't pin inflation high forever. + rtt_min_ms: f64, + /// Wall-clock the current `rtt_min_ms` was set. When it ages past + /// the window the min resets to the next sample. + rtt_min_stamp_ms: u64, + /// Wall-clock of the last RTT update. + last_rtt_update_ms: u64, + /// Sliding-window loss samples. + loss_samples: Vec, + /// Aggregated within the window. + window_lost: u32, + window_sent: u32, + /// Ticks remaining in fast-recovery mode. Decremented each + /// `tick()` call; while > 0 the climb sub-mode is `FastRecovery`. + fast_recovery_ticks: u32, + /// Cumulative bytes-sent the previous tick observed. Drives the + /// per-tick `sent` delta fed to `record_loss`. + prev_bytes_sent_total: u64, + /// Cumulative NAK count the previous tick observed. Drives the + /// per-tick `lost` delta. + prev_nak_total: i32, + /// Set after the first `observe_traffic` call. Until then we don't + /// know what "previous" means so we just stash the totals as a + /// baseline without emitting a loss sample. + traffic_baseline_set: bool, + /// Time-decayed EWMA of the windowed loss fraction (0..1). Drives + /// continuous phase demotion in place of a binary link-death gate. + loss_ewma: f64, + /// Wall-clock of the last `loss_ewma` update. 0 = never updated. + loss_ewma_last_ms: u64, + /// Wall-clock since which `loss_ewma` has been continuously above + /// `LOSS_DEGRADE_ENTER`. 0 = currently below the entry threshold. + loss_high_since_ms: u64, + /// Latched hysteretic verdict: true once loss has been sustained + /// high, false again once it recovers below `LOSS_DEGRADE_CLEAR`. + loss_degraded: bool, + /// Consecutive ticks spent in `BackingOff` since the efficacy test + /// last re-armed. + backoff_ticks: u32, + /// Loss permille at the point the current efficacy window opened. + /// The decrease is judged against this. + backoff_entry_loss_pm: u32, + /// Latched verdict: we cut hard and the loss did not respond, so it + /// is not loss our offered rate is causing. Suppresses further + /// loss-driven backoff until the loss regime ends, or until the + /// re-test timer expires. + loss_uncongestive: bool, + /// Ticks the `loss_uncongestive` verdict has been held, against + /// `LOSS_UNCONGESTIVE_RETEST_TICKS`. + uncongestive_ticks: u32, +} + +impl Default for LinkCongestionState { + fn default() -> Self { + Self { + state: CcState::Bootstrap, + climb_mode: ClimbMode::Normal, + target_bps: MIN_TARGET_BPS, + rtt_ewma_ms: 0.0, + rtt_var_ms: 0.0, + rtt_min_ms: f64::INFINITY, + rtt_min_stamp_ms: 0, + last_rtt_update_ms: 0, + loss_samples: Vec::new(), + window_lost: 0, + window_sent: 0, + fast_recovery_ticks: 0, + prev_bytes_sent_total: 0, + prev_nak_total: 0, + traffic_baseline_set: false, + loss_ewma: 0.0, + loss_ewma_last_ms: 0, + loss_high_since_ms: 0, + loss_degraded: false, + backoff_ticks: 0, + backoff_entry_loss_pm: 0, + loss_uncongestive: false, + uncongestive_ticks: 0, + } + } +} + +impl LinkCongestionState { + /// Feed an RTT sample. Updates the age-bucketed EWMA, variance + /// proxy, and minimum. + pub fn record_rtt(&mut self, rtt_ms: f64, now_ms: u64) { + if !rtt_ms.is_finite() || rtt_ms <= 0.0 { + return; + } + let age_ms = now_ms.saturating_sub(self.last_rtt_update_ms); + + // Age-bucketed EWMA weight (new : old). + // Bands: >=2s reset, >=1s 1:1, >=500ms 1:4, >=250ms 1:8, + // <250ms 1:16. First sample (rtt_ewma == 0) snaps verbatim — + // we don't gate on `last_rtt_update_ms == 0` because legitimate + // samples may arrive at t=0 in tests / monotonic-clock startup. + let new_w = if self.rtt_ewma_ms == 0.0 || age_ms >= 2_000 { + self.rtt_ewma_ms = rtt_ms; + self.rtt_var_ms = 0.0; + self.last_rtt_update_ms = now_ms; + self.update_rtt_min(rtt_ms, now_ms); + return; + } else if age_ms >= 1_000 { + (1.0, 1.0) + } else if age_ms >= 500 { + (1.0, 4.0) + } else if age_ms >= 250 { + (1.0, 8.0) + } else { + (1.0, 16.0) + }; + let (w_new, w_old) = new_w; + let denom = w_new + w_old; + let prev = self.rtt_ewma_ms; + self.rtt_ewma_ms = (rtt_ms * w_new + prev * w_old) / denom; + // Variance proxy: 1:3 weighted moving average of |dev|. + let dev = (rtt_ms - prev).abs(); + self.rtt_var_ms = (dev * 1.0 + self.rtt_var_ms * 3.0) / 4.0; + self.update_rtt_min(rtt_ms, now_ms); + self.last_rtt_update_ms = now_ms; + } + + /// Windowed minimum RTT: adopt any lower sample, and when the held + /// minimum ages past `CC_RTT_MIN_WINDOW_MS` reset it to the current + /// sample so the baseline follows a changed propagation floor (e.g. + /// after a cellular handover) instead of staying pinned to a stale + /// low sample. + fn update_rtt_min(&mut self, rtt_ms: f64, now_ms: u64) { + let stale = now_ms.saturating_sub(self.rtt_min_stamp_ms) > CC_RTT_MIN_WINDOW_MS; + if !self.rtt_min_ms.is_finite() || rtt_ms < self.rtt_min_ms || stale { + self.rtt_min_ms = rtt_ms; + self.rtt_min_stamp_ms = now_ms; + } + } + + /// Feed cumulative (bytes_sent, nak_total) snapshots from the + /// connection. Computes per-tick deltas against the previous call + /// and forwards them to `record_loss`. First call after creation + /// stashes the values as a baseline and returns without sampling. + /// + /// Decoupling the cumulative→delta conversion from `record_loss` + /// keeps the latter directly testable with synthetic deltas while + /// the production path only needs to thread totals. + pub fn observe_traffic(&mut self, bytes_sent_total: u64, nak_total: i32, now_ms: u64) { + if !self.traffic_baseline_set { + self.prev_bytes_sent_total = bytes_sent_total; + self.prev_nak_total = nak_total; + self.traffic_baseline_set = true; + return; + } + let delta_bytes = bytes_sent_total.saturating_sub(self.prev_bytes_sent_total); + let delta_nak = nak_total.saturating_sub(self.prev_nak_total).max(0); + self.prev_bytes_sent_total = bytes_sent_total; + self.prev_nak_total = nak_total; + + if delta_bytes == 0 && delta_nak == 0 { + // No traffic this tick — don't pollute the window with a + // zero-sample. evict_expired in tick() handles aging. + return; + } + let sent_pkts = (delta_bytes / ASSUMED_SRT_PAYLOAD_BYTES).min(u32::MAX as u64) as u32; + let lost_pkts = delta_nak as u32; + // Guard against a NAK delta with no corresponding bytes-sent + // delta (e.g. NAKs arriving on a now-quiet link) — the loss + // permille formula divides by `window_sent` which would + // saturate to 1000 with zero-divisor handling. Treat as a + // single-packet "sent" baseline so the ratio stays bounded. + let sent_pkts = sent_pkts.max(if lost_pkts > 0 { 1 } else { 0 }); + self.record_loss(sent_pkts, lost_pkts, now_ms); + } + + /// Feed a (sent, lost) sample directly. Sliding-window aggregates + /// evict entries older than `LOSS_WINDOW_MS`. Production code uses + /// [`observe_traffic`] which threads cumulative counters; this + /// method is exposed for unit tests. + pub fn record_loss(&mut self, sent: u32, lost: u32, now_ms: u64) { + self.loss_samples.push(LossSample { + ts_ms: now_ms, + sent, + lost, + }); + self.window_sent = self.window_sent.saturating_add(sent); + self.window_lost = self.window_lost.saturating_add(lost); + self.evict_expired(now_ms); + } + + fn evict_expired(&mut self, now_ms: u64) { + let cutoff = now_ms.saturating_sub(LOSS_WINDOW_MS); + while let Some(front) = self.loss_samples.first() { + if front.ts_ms < cutoff { + self.window_sent = self.window_sent.saturating_sub(front.sent); + self.window_lost = self.window_lost.saturating_sub(front.lost); + self.loss_samples.remove(0); + } else { + break; + } + } + } + + /// Current loss permille over the window. + pub fn loss_permille(&self) -> u32 { + if self.window_sent == 0 { + return 0; + } + let permille = (self.window_lost as u64).saturating_mul(1_000) / (self.window_sent as u64); + permille.min(1_000_000) as u32 + } + + /// Decide whether the loss-driven backoff is achieving anything, + /// and latch `loss_uncongestive` when it demonstrably is not. + /// + /// Called once per tick, before the state transition, so `self.state` + /// here is still the *previous* tick's state — i.e. "was I cutting?". + fn update_backoff_efficacy(&mut self, loss_high: bool, loss_pm: u32) { + if !loss_high { + // The loss regime is over. Everything we concluded about it + // is stale, so the next episode re-tests from scratch. + self.backoff_ticks = 0; + self.backoff_entry_loss_pm = 0; + self.loss_uncongestive = false; + self.uncongestive_ticks = 0; + return; + } + + // A held verdict expires on a timer, and on nothing else. + // + // It used to be cleared by RTT inflation, on the theory that + // this was congestion evidence independent of the loss signal. + // In practice that re-opened the backoff path on *every* tick + // where RTT was inflated, so the controller just alternated + // BackingOff → Drain → BackingOff and cut on almost every one, + // ratcheting the cap to the floor exactly as before. The latch + // was worthless. Congestion that shows up in RTT is already + // handled, independently and correctly, by `Drain` and + // `Holding` below — the loss path does not need to double up. + if self.loss_uncongestive { + self.uncongestive_ticks += 1; + if self.uncongestive_ticks >= LOSS_UNCONGESTIVE_RETEST_TICKS { + self.loss_uncongestive = false; + self.uncongestive_ticks = 0; + self.backoff_ticks = 0; + self.backoff_entry_loss_pm = loss_pm; + } + return; + } + + if self.state != CcState::BackingOff { + // First tick of a loss regime (or we are being held out of + // it): open the efficacy window against the current loss. + self.backoff_ticks = 0; + self.backoff_entry_loss_pm = loss_pm; + return; + } + + // We cut last tick. Give the decrease `BACKOFF_EFFICACY_TICKS` + // to move the loss, then judge it. + self.backoff_ticks += 1; + if self.backoff_ticks < BACKOFF_EFFICACY_TICKS { + return; + } + let improved = (loss_pm as u64) * 1_000 + < (self.backoff_entry_loss_pm as u64) * BACKOFF_EFFICACY_IMPROVEMENT_PERMILLE as u64; + if improved { + // Backing off is relieving the loss, so we are the cause. + // Re-arm and let the decrease keep walking the cap down. + self.backoff_ticks = 0; + self.backoff_entry_loss_pm = loss_pm; + } else { + // We cut ~39% and the loss did not care. It is not ours. + self.loss_uncongestive = true; + self.uncongestive_ticks = 0; + } + } + + /// Recompute the state and `target_bps` from the latest signals. + /// Called once per housekeeping tick. + pub fn tick(&mut self, observed_bps: u64, now_ms: u64) { + self.evict_expired(now_ms); + + if !self.rtt_ewma_ms.is_finite() || self.rtt_ewma_ms == 0.0 { + // No RTT yet: stay in bootstrap, hold the floor. + self.state = CcState::Bootstrap; + self.climb_mode = ClimbMode::Normal; + self.target_bps = MIN_TARGET_BPS; + return; + } + + let loss_pm = self.loss_permille(); + self.update_loss_ewma(loss_pm, now_ms); + let rtt_inflation = if self.rtt_min_ms.is_finite() && self.rtt_min_ms > 0.0 { + self.rtt_ewma_ms / self.rtt_min_ms + } else { + 1.0 + }; + + // Outlier rejection: clamp a single throughput sample to + // `CC_OUTLIER_FACTOR` times the running estimate (floored at the + // initial estimate so the first seed isn't pinned to the very + // low target_bps floor). This bounds how far one contaminated + // burst can move the soft cap, whether at the seed or via the + // climb's measured cap. + let baseline = self.target_bps.max(INITIAL_TARGET_BPS) as f64; + let sane_observed = (observed_bps as f64).min(CC_OUTLIER_FACTOR * baseline) as u64; + + // First non-bootstrap tick: seed the target from observed throughput + // (or a conservative floor if no traffic yet). + if self.target_bps == MIN_TARGET_BPS { + let seed = sane_observed.max(INITIAL_TARGET_BPS); + self.target_bps = seed.clamp(MIN_TARGET_BPS, MAX_TARGET_BPS); + } + + // Is this loss ours? Two independent things have to hold. + // + // First, we must be driving the link hard enough to be filling + // the bottleneck at all — see `BACKOFF_MIN_LOAD_PERMILLE`. A + // starved link's NAKs are wire loss, and cutting its cap in + // response is how a usable link gets ratcheted into oblivion. + let loaded = (sane_observed as u128) * 1_000 + >= (self.target_bps as u128) * (BACKOFF_MIN_LOAD_PERMILLE as u128); + + // Second, backing off has to actually be *working*. This is the + // only test that separates the two kinds of loss on a link we + // *are* driving hard, and it is a causal one: congestive loss + // responds to a lower offered rate, wire loss does not. Without + // it the load gate alone never converges — each cut lowers the + // target, which *raises* load, which holds the gate open all the + // way down to `MIN_TARGET_BPS`. + let loss_high = loss_pm > LOSS_BACKOFF_PERMILLE; + self.update_backoff_efficacy(loss_high, loss_pm); + + let prev_state = self.state; + let next_state = if loss_high && loaded && !self.loss_uncongestive { + CcState::BackingOff + } else if rtt_inflation >= DRAIN_RTT_INFLATION { + // BDQ overload before loss surfaces — drain hard. + CcState::Drain + } else if rtt_inflation > RTT_HOLD_FACTOR { + CcState::Holding + } else { + // Falling through here with loss above the threshold is + // deliberate: an under-driven link that is losing packets is + // losing them to the wire, and its capacity is still real. + // Let it climb — the 2x-measured clamp below keeps that + // honest, and the sustained `loss_degraded` latch is what + // penalises it in routing. + CcState::Climbing + }; + + // Fast-recovery accounting: arm the timer when leaving + // BackingOff or Drain into Climbing. The budget is consumed at the + // end of the tick, AFTER `pick_climb_mode` reads it below, so the + // arming tick itself counts toward the window and all + // FAST_RECOVERY_TICKS climbs use the fast step. Decrementing here + // would burn the first tick before it was ever used (the documented + // 5-tick window would only fire 4). + if let (CcState::BackingOff | CcState::Drain, CcState::Climbing) = (prev_state, next_state) + { + self.fast_recovery_ticks = FAST_RECOVERY_TICKS; + } + + self.state = next_state; + + let prev = self.target_bps as f64; + let next = match next_state { + CcState::Bootstrap => { + self.climb_mode = ClimbMode::Normal; + prev + } + CcState::Climbing => { + let mode = self.pick_climb_mode(); + self.climb_mode = mode; + let step_pm = match mode { + ClimbMode::Normal => AI_STEP_PERMILLE, + ClimbMode::Hai => HAI_STEP_PERMILLE, + ClimbMode::FastRecovery => FAST_RECOVERY_STEP_PERMILLE, + }; + let step = (prev * step_pm as f64) / 1000.0; + // Don't grow more than 2x measured traffic — prevents + // ramp on idle links. Same cap applies regardless of + // step size. Uses the outlier-clamped sample so a burst + // can't open a huge headroom for the AI to climb into. + let measured_cap = (sane_observed as f64) * 2.0; + if sane_observed > 0 { + prev.max(MIN_TARGET_BPS as f64) + step.min(measured_cap - prev).max(0.0) + } else { + // No measured traffic this tick: hold the target instead of + // ramping into headroom that doesn't exist. A previously-idle + // link must re-justify growth from real throughput, otherwise + // the BDP in-flight cap and the soft-cap multiplier (both + // derived from target_bps) climb to MAX_TARGET_BPS and go + // inert, so the link gets flooded with no brake on its first + // real burst. + prev + } + } + CcState::Holding => { + self.climb_mode = ClimbMode::Normal; + prev + } + CcState::BackingOff => { + self.climb_mode = ClimbMode::Normal; + // Multiplicative decrease, but never below what the link + // is provably carrying right now. + // + // The floor matters because `target_bps` does not pace + // anything. It steers the scheduler *between* links; it + // does not throttle this one. So cutting it does not + // reduce what this link sends, and measured throughput + // does *not* follow the cut downwards. A cap under the + // rate the link is visibly sustaining is therefore not a + // conservative estimate, it is just a wrong one — and + // since it never becomes self-correcting, the decrease + // compounds until it hits MIN_TARGET_BPS. + // + // It still converges under real congestion: there, + // cutting a link's cap steers traffic *off* it, so its + // measured throughput really does fall, and the floor + // falls with it. + // + // Clamped to `prev` so a backoff can never raise the cap + // when the link is already delivering above it. + let decreased = (prev * BACKOFF_PERMILLE as f64) / 1000.0; + let delivered_floor = (sane_observed as f64).min(prev); + decreased.max(delivered_floor) + } + CcState::Drain => { + self.climb_mode = ClimbMode::Normal; + // One-shot, per DRAIN_PERMILLE's contract: cut hard only on the + // transition into Drain, then hold while we stay drained. + // Applying the cut every tick compounds it, collapsing + // target_bps to the floor within ~11 ticks and producing + // saw-tooth oscillation under sustained RTT inflation. Leaving + // and re-entering Drain applies a fresh cut. + if prev_state != CcState::Drain { + (prev * DRAIN_PERMILLE as f64) / 1000.0 + } else { + prev + } + } + }; + + self.target_bps = (next as u64).clamp(MIN_TARGET_BPS, MAX_TARGET_BPS); + + // Consume one fast-recovery tick now that pick_climb_mode has read + // the budget for this tick. Drop the remaining budget if we left + // Climbing (a fresh backoff/drain re-arms it). + if next_state == CcState::Climbing { + self.fast_recovery_ticks = self.fast_recovery_ticks.saturating_sub(1); + } else { + self.fast_recovery_ticks = 0; + } + } + + /// Fold the latest windowed loss permille into the time-decayed + /// loss EWMA and update the latched degraded verdict. Demotion is + /// graded: the verdict only gates score (via the phase machine), it + /// never removes the link from scheduling, so a link that briefly + /// stalls and recovers is never starved of the ACK traffic that + /// proves its recovery. + fn update_loss_ewma(&mut self, loss_pm: u32, now_ms: u64) { + let inst = (loss_pm as f64 / 1_000.0).clamp(0.0, 1.0); + if self.loss_ewma_last_ms == 0 { + self.loss_ewma = inst; + } else { + let dt = now_ms.saturating_sub(self.loss_ewma_last_ms) as f64; + let alpha = 1.0 - (-dt / LOSS_EWMA_TAU_MS).exp(); + self.loss_ewma += (inst - self.loss_ewma) * alpha; + } + self.loss_ewma_last_ms = now_ms; + + if self.loss_ewma > LOSS_DEGRADE_ENTER { + if self.loss_high_since_ms == 0 { + self.loss_high_since_ms = now_ms; + } else if now_ms.saturating_sub(self.loss_high_since_ms) >= LOSS_DEGRADE_SUSTAIN_MS { + self.loss_degraded = true; + } + } else { + self.loss_high_since_ms = 0; + if self.loss_ewma < LOSS_DEGRADE_CLEAR { + self.loss_degraded = false; + } + } + } + + /// Decide which sub-mode applies on this Climbing tick. + /// + /// Order of precedence: + /// 1. FastRecovery while we're inside the post-backoff window. + /// 2. Hai when RTT is stable enough that variance is small + /// relative to the mean — confident there's headroom to take. + /// 3. Normal otherwise. + fn pick_climb_mode(&self) -> ClimbMode { + if self.fast_recovery_ticks > 0 { + return ClimbMode::FastRecovery; + } + if self.rtt_ewma_ms > 0.0 && self.rtt_var_ms <= self.rtt_ewma_ms * HAI_VARIANCE_FRACTION { + return ClimbMode::Hai; + } + ClimbMode::Normal + } + + /// Convenience for stats emission. + pub fn snapshot(&self) -> LinkCcSnapshot { + LinkCcSnapshot { + state: self.state, + climb_mode: self.climb_mode, + target_bps: self.target_bps, + rtt_ewma_ms: self.rtt_ewma_ms, + rtt_var_ms: self.rtt_var_ms, + rtt_min_ms: if self.rtt_min_ms.is_finite() { + self.rtt_min_ms + } else { + 0.0 + }, + loss_permille: self.loss_permille(), + loss_ewma: self.loss_ewma, + loss_degraded: self.loss_degraded, + } + } +} + +#[derive(Copy, Clone, Debug)] +pub struct LinkCcSnapshot { + pub state: CcState, + pub climb_mode: ClimbMode, + pub target_bps: u64, + pub rtt_ewma_ms: f64, + pub rtt_var_ms: f64, + pub rtt_min_ms: f64, + pub loss_permille: u32, + /// Time-decayed loss fraction (0..1) driving phase demotion. + pub loss_ewma: f64, + /// Latched hysteretic verdict that loss has been sustained high. + /// Consumed by the connection phase machine to demote (not remove) + /// the link. + pub loss_degraded: bool, +} + +/// Owns one [`LinkCongestionState`] per connection. Driven by the +/// sender's housekeeping tick: `tick_all` reads each connection's +/// current RTT, observed bitrate, cumulative bytes-sent, and +/// cumulative NAK count; feeds the per-link state; and produces +/// snapshots for the stats exporter. +#[derive(Default)] +pub struct LinkCcController { + per_conn: HashMap, +} + +impl LinkCcController { + pub fn new() -> Self { + Self::default() + } + + /// Update each connection's CC state from the latest signals. + /// Returns a per-conn snapshot map keyed by `conn_id` for stats + /// emission. + pub fn tick_all( + &mut self, + connections: &[SrtlaConnection], + now_ms: u64, + ) -> HashMap { + let mut alive: HashMap = HashMap::with_capacity(connections.len()); + for conn in connections { + let entry = self.per_conn.entry(conn.conn_id).or_default(); + let rtt_ms = conn.get_smooth_rtt_ms(); + if rtt_ms > 0.0 { + entry.record_rtt(rtt_ms, now_ms); + } + // Loss path: cumulative bytes-sent and NAK count from the + // connection — `observe_traffic` computes per-tick deltas + // and forwards to `record_loss`. Before this wiring landed, + // the loss window stayed empty and CcState::BackingOff was + // unreachable in production. + entry.observe_traffic( + conn.bitrate.bytes_sent_total, + conn.total_nak_count(), + now_ms, + ); + let observed_bps = conn.bitrate.current_bitrate_bps.max(0.0) as u64; + entry.tick(observed_bps, now_ms); + alive.insert(conn.conn_id, entry.snapshot()); + } + // Garbage-collect entries for connections that disappeared. + self.per_conn.retain(|id, _| alive.contains_key(id)); + alive + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bootstrap_holds_floor() { + let mut cc = LinkCongestionState::default(); + cc.tick(0, 0); + assert_eq!(cc.state, CcState::Bootstrap); + assert_eq!(cc.target_bps, MIN_TARGET_BPS); + } + + #[test] + fn climbing_grows_target() { + let mut cc = LinkCongestionState::default(); + cc.record_rtt(50.0, 1_000); + cc.tick(2_000_000, 1_000); + assert_eq!(cc.state, CcState::Climbing); + let first = cc.target_bps; + cc.tick(2_000_000, 1_100); + assert!(cc.target_bps >= first); + } + + #[test] + fn holding_when_rtt_inflates() { + let mut cc = LinkCongestionState::default(); + // Establish low baseline. + cc.record_rtt(20.0, 0); + cc.tick(2_000_000, 0); + + // Sustained inflation in the Holding band: 1.5x ≤ rtt/min < 2.0x. + // 20 → 35 = 1.75x; below DRAIN_RTT_INFLATION (2.0) so the + // controller picks Holding rather than Drain. The smoothing is + // intentionally slow for single-sample spikes — that's what + // the EWMA is for. + for i in 1..=10 { + cc.record_rtt(35.0, i * 600); + cc.tick(2_000_000, i * 600); + } + assert_eq!(cc.state, CcState::Holding); + } + + /// Drive one 1Hz tick: report `loss_pm` permille of loss and + /// `delivered_bps` of throughput, at a flat RTT. + fn drive_tick(cc: &mut LinkCongestionState, t_ms: u64, delivered_bps: u64, loss_pm: u32) { + cc.record_rtt(50.0, t_ms); + if loss_pm > 0 { + cc.record_loss(1_000, loss_pm, t_ms); + } else { + cc.record_loss(1_000, 0, t_ms); + } + cc.tick(delivered_bps, t_ms); + } + + /// The reported starvation latch. A link the scheduler has stopped + /// feeding still sees NAKs — that is wire loss, not congestion we + /// caused. Before the load gate, `BackingOff` compounded -15% every + /// tick for as long as the loss lasted and pinned a multi-megabit + /// link at `MIN_TARGET_BPS`, where the BDP in-flight cap deselects + /// it outright. + #[test] + fn starved_link_with_wire_loss_does_not_ratchet_to_the_floor() { + let mut cc = LinkCongestionState::default(); + cc.record_rtt(50.0, 0); + cc.tick(3_000_000, 0); + let seeded = cc.target_bps; + assert!(seeded >= 3_000_000); + + // The scheduler moves traffic elsewhere: we now deliver a + // trickle. The link keeps shedding 5% to the wire regardless. + for i in 1..=30 { + drive_tick(&mut cc, i * 1_000, 50_000, 50); + } + + assert_ne!(cc.state, CcState::BackingOff); + assert!( + cc.target_bps >= seeded, + "starved link was ratcheted from {seeded} to {} by loss it did not cause", + cc.target_bps + ); + } + + /// The other half: a link we *are* driving hard, whose loss is still + /// not ours. The load gate cannot catch this one (load stays high + /// precisely because each cut lowers the target), so the efficacy + /// test has to. We cut ~39%, the loss ignores it, and we stop. + #[test] + fn loaded_link_stops_cutting_when_the_backoff_does_not_move_the_loss() { + let mut cc = LinkCongestionState::default(); + cc.record_rtt(50.0, 0); + cc.tick(2_000_000, 0); + + // Wire loss: a flat 10%, wholly indifferent to our offered rate. + // Delivery tracks whatever cap we set, so the link stays loaded. + for i in 1..=25 { + let delivered = cc.target_bps.min(2_000_000); + drive_tick(&mut cc, i * 1_000, delivered, 100); + } + + assert!( + cc.loss_uncongestive, + "should have given up attributing the loss to itself" + ); + assert_ne!(cc.state, CcState::BackingOff); + // 0.85^25 would have taken this to MIN_TARGET_BPS. The descent + // is bounded to roughly the efficacy window instead. + assert!( + cc.target_bps > 1_000_000, + "target collapsed to {} despite the link delivering 2 Mbps", + cc.target_bps + ); + } + + /// Guard against the obvious way to get the above wrong: genuinely + /// congestive loss must still walk the cap down to real capacity. + /// Here the loss *is* ours — it grades down as we cut — so the + /// efficacy test keeps re-arming and the decrease keeps compounding. + #[test] + fn congestive_loss_still_converges_on_capacity() { + const CAPACITY_BPS: u64 = 1_500_000; + let mut cc = LinkCongestionState::default(); + cc.record_rtt(50.0, 0); + cc.tick(3_000_000, 0); + assert!(cc.target_bps > CAPACITY_BPS); + + // A real bottleneck: we offer at the cap, anything past capacity + // is dropped, so the loss ratio falls as the cap comes down. + for i in 1..=25 { + let offered = cc.target_bps; + let delivered = offered.min(CAPACITY_BPS); + let loss_pm = ((offered - delivered) * 1_000 / offered.max(1)) as u32; + drive_tick(&mut cc, i * 1_000, delivered, loss_pm); + } + + assert!( + !cc.loss_uncongestive, + "congestive loss was misread as wire loss — the backoff was working" + ); + // Converged to the bottleneck rather than overshooting to the floor. + assert!( + cc.target_bps > CAPACITY_BPS / 2 && cc.target_bps < CAPACITY_BPS * 2, + "target {} did not settle near capacity {CAPACITY_BPS}", + cc.target_bps + ); + } + + /// RTT inflation must NOT clear the verdict. + /// + /// This asserts the opposite of what it originally did, because a + /// real netem run proved the original wrong. Clearing on RTT + /// inflation re-opened the backoff path on every inflated tick: the + /// controller alternated BackingOff → Drain → BackingOff, cut on + /// nearly every one, and drove the cap to MIN_TARGET_BPS anyway. The + /// latch might as well not have existed. + /// + /// Congestion that shows up in RTT is still answered — by `Drain`. + #[test] + fn rtt_inflation_does_not_reopen_the_backoff_path() { + let mut cc = LinkCongestionState::default(); + cc.record_rtt(50.0, 0); + cc.tick(2_000_000, 0); + + for i in 1..=10 { + let delivered = cc.target_bps.min(2_000_000); + drive_tick(&mut cc, i * 1_000, delivered, 100); + } + assert!(cc.loss_uncongestive); + + // The path starts queueing: 50 → 120ms is past DRAIN_RTT_INFLATION. + for i in 11..=20 { + cc.record_rtt(120.0, i * 1_000); + cc.record_loss(1_000, 100, i * 1_000); + cc.tick(cc.target_bps.min(2_000_000), i * 1_000); + } + + assert!( + cc.loss_uncongestive, + "RTT inflation must not re-open the loss-backoff path — that nullifies the latch" + ); + assert_ne!( + cc.state, + CcState::BackingOff, + "the loss path must stay shut; Drain is what answers RTT inflation" + ); + } + + /// The verdict expires, so a link whose conditions change is + /// re-tested rather than trusted forever. + #[test] + fn uncongestive_verdict_is_retested_on_a_timer() { + let mut cc = LinkCongestionState::default(); + cc.record_rtt(50.0, 0); + cc.tick(2_000_000, 0); + + for i in 1..=10 { + let delivered = cc.target_bps.min(2_000_000); + drive_tick(&mut cc, i * 1_000, delivered, 100); + } + assert!(cc.loss_uncongestive); + + // Look for the verdict being *released*, not its value at some + // arbitrary later tick: once released it re-tests, fails again on + // loss that is still not ours, and re-latches. Sampling a single + // instant would be racing that cycle. + let mut released = false; + for i in 11..=(12 + u64::from(LOSS_UNCONGESTIVE_RETEST_TICKS)) { + let delivered = cc.target_bps.min(2_000_000); + drive_tick(&mut cc, i * 1_000, delivered, 100); + if !cc.loss_uncongestive { + released = true; + } + } + assert!( + released, + "verdict never expired — it should be re-tested every \ + {LOSS_UNCONGESTIVE_RETEST_TICKS} ticks, not trusted forever" + ); + } + + /// The load-bearing guard, and the one whose rationale is easiest to + /// get backwards. `target_bps` steers the scheduler between links; it + /// does not throttle this one. So measured throughput does not fall + /// when the cap is cut, and a cap below proven delivery never + /// self-corrects — it just compounds to the floor. + #[test] + fn backoff_never_caps_below_delivered_throughput() { + let mut cc = LinkCongestionState::default(); + cc.record_rtt(50.0, 0); + cc.tick(1_000_000, 0); + + // The link keeps delivering 4 Mbps flat whatever we set the cap + // to, and sheds a steady 2% that has nothing to do with our rate. + for i in 1..=30 { + drive_tick(&mut cc, i * 1_000, 4_000_000, 20); + } + + assert!( + cc.target_bps >= 4_000_000, + "target {} was cut below the 4 Mbps the link is demonstrably carrying", + cc.target_bps + ); + } + + /// A clean window ends the episode, so the next one re-tests from + /// scratch instead of inheriting a stale verdict. + #[test] + fn clean_loss_window_rearms_the_efficacy_test() { + let mut cc = LinkCongestionState::default(); + cc.record_rtt(50.0, 0); + cc.tick(2_000_000, 0); + + for i in 1..=10 { + let delivered = cc.target_bps.min(2_000_000); + drive_tick(&mut cc, i * 1_000, delivered, 100); + } + assert!(cc.loss_uncongestive); + + for i in 11..=14 { + drive_tick(&mut cc, i * 1_000, 2_000_000, 0); + } + assert!( + !cc.loss_uncongestive, + "a clean window should clear the verdict" + ); + assert_eq!(cc.state, CcState::Climbing); + } + + #[test] + fn backing_off_on_loss() { + let mut cc = LinkCongestionState::default(); + cc.record_rtt(50.0, 0); + cc.tick(2_000_000, 0); + let before = cc.target_bps; + + // Lose 1% of packets — well above LOSS_BACKOFF_PERMILLE. + cc.record_loss(1_000, 100, 100); + cc.tick(2_000_000, 100); + assert_eq!(cc.state, CcState::BackingOff); + assert!(cc.target_bps < before); + } + + #[test] + fn loss_window_evicts() { + let mut cc = LinkCongestionState::default(); + cc.record_loss(1_000, 100, 0); + assert_eq!(cc.loss_permille(), 100); + // Beyond window — should evict. + cc.record_loss(0, 0, LOSS_WINDOW_MS + 10); + assert_eq!(cc.loss_permille(), 0); + } + + #[test] + fn rtt_min_is_windowed_not_lifetime() { + let mut cc = LinkCongestionState::default(); + // Establish a low baseline, then a sustained higher floor (e.g. + // a cellular handover raised propagation delay to ~80ms). + cc.record_rtt(20.0, 0); + for t in (1_000..=40_000).step_by(1_000) { + cc.record_rtt(80.0, t); + } + // Past the 30s window the pinned 20ms minimum has expired and the + // baseline now follows the ~80ms floor, so inflation is ~1x and + // the controller won't sit in a spurious Drain. + assert!( + cc.rtt_min_ms >= 70.0, + "windowed rtt_min should follow the raised floor, got {}", + cc.rtt_min_ms + ); + } + + #[test] + fn rtt_min_holds_within_window() { + let mut cc = LinkCongestionState::default(); + cc.record_rtt(20.0, 0); + // Within the window the low floor is retained even as RTT rises. + for t in (1_000..=10_000).step_by(1_000) { + cc.record_rtt(80.0, t); + } + assert!( + (cc.rtt_min_ms - 20.0).abs() < 1.0, + "rtt_min should hold the true floor within the window, got {}", + cc.rtt_min_ms + ); + } + + #[test] + fn rtt_ewma_resets_after_2s_gap() { + let mut cc = LinkCongestionState::default(); + cc.record_rtt(50.0, 0); + // 2.5s later — should snap to the new sample. + cc.record_rtt(200.0, 2_500); + assert!((cc.rtt_ewma_ms - 200.0).abs() < 0.01); + } + + #[test] + fn rtt_ewma_weights_by_age() { + let mut cc = LinkCongestionState::default(); + cc.record_rtt(100.0, 0); + // Within 250ms — heavy weight on old (1:16). + cc.record_rtt(200.0, 100); + assert!(cc.rtt_ewma_ms < 110.0); + } + + #[test] + fn hai_kicks_in_when_rtt_is_stable() { + let mut cc = LinkCongestionState::default(); + // Feed identical RTT samples → variance stays at 0. + for i in 0..10 { + cc.record_rtt(50.0, i * 100); + cc.tick(2_000_000, i * 100); + } + assert_eq!(cc.state, CcState::Climbing); + assert_eq!(cc.climb_mode, ClimbMode::Hai); + } + + #[test] + fn hai_yields_to_normal_when_rtt_is_jittery() { + let mut cc = LinkCongestionState::default(); + // Alternate between 30 and 80 ms — variance grows past the + // HAI threshold. + for i in 0..10 { + let rtt = if i % 2 == 0 { 30.0 } else { 80.0 }; + cc.record_rtt(rtt, i * 100); + cc.tick(2_000_000, i * 100); + } + assert_eq!(cc.state, CcState::Climbing); + assert_eq!(cc.climb_mode, ClimbMode::Normal); + } + + #[test] + fn fast_recovery_engages_after_backoff() { + let mut cc = LinkCongestionState::default(); + cc.record_rtt(50.0, 0); + cc.tick(2_000_000, 0); + + // Inject loss → BackingOff. + cc.record_loss(1_000, 100, 100); + cc.tick(2_000_000, 100); + assert_eq!(cc.state, CcState::BackingOff); + + // Loss window evicts after 1s → Climbing with FastRecovery armed. + cc.tick(2_000_000, 1_200); + assert_eq!(cc.state, CcState::Climbing); + assert_eq!(cc.climb_mode, ClimbMode::FastRecovery); + + // After FAST_RECOVERY_TICKS more healthy ticks, drops back + // to Normal (or Hai if RTT stays flat). + for i in 1..=FAST_RECOVERY_TICKS as u64 { + cc.tick(2_000_000, 1_200 + i); + } + assert_eq!(cc.state, CcState::Climbing); + assert!(matches!(cc.climb_mode, ClimbMode::Normal | ClimbMode::Hai)); + } + + #[test] + fn drain_triggers_on_high_rtt_inflation_no_loss() { + let mut cc = LinkCongestionState::default(); + // Establish low rtt_min. + cc.record_rtt(20.0, 0); + cc.tick(2_000_000, 0); + + // Push EWMA past 2x rtt_min via sustained 60ms samples. + for i in 1..20 { + cc.record_rtt(60.0, i * 600); + cc.tick(2_000_000, i * 600); + } + // No loss observed → Drain should fire when inflation crosses 2x. + // 20→60 = 3x; the ewma should have crossed 40.0 by now. + assert!(cc.state == CcState::Drain || cc.state == CcState::Holding); + if cc.state == CcState::Drain { + // Drain dropped target by 25%. + assert!(cc.target_bps < 2_000_000); + } + } + + #[test] + fn drain_then_recovery_path() { + let mut cc = LinkCongestionState::default(); + cc.record_rtt(20.0, 0); + cc.tick(2_000_000, 0); + // Force into Drain. + for i in 1..15 { + cc.record_rtt(60.0, i * 600); + cc.tick(2_000_000, i * 600); + } + // Then RTT recovers — back to Climbing with FastRecovery armed. + for i in 15..30 { + cc.record_rtt(20.0, i * 600); + cc.tick(2_000_000, i * 600); + } + assert_eq!(cc.state, CcState::Climbing); + // Within the FastRecovery window we should see that mode at + // least once. Hard to assert exactly which tick — verify the + // path was traversed by checking rtt is back to baseline. + assert!(cc.rtt_ewma_ms < 30.0); + } + + #[test] + fn loss_ewma_latches_degraded_after_sustained_loss_and_clears_with_hysteresis() { + let mut cc = LinkCongestionState::default(); + cc.record_rtt(50.0, 0); + + // Drive sustained ~80% loss for well over LOSS_DEGRADE_SUSTAIN_MS. + // Each tick feeds a fresh high-loss window via record_loss so the + // permille stays high as the EWMA climbs past the entry threshold. + let mut t = 0u64; + for _ in 0..40 { + t += 200; + cc.record_loss(1_000, 800, t); + cc.tick(2_000_000, t); + } + assert!( + cc.snapshot().loss_degraded, + "sustained high loss should latch the degraded verdict (ewma={:.3})", + cc.loss_ewma + ); + + // Recover: zero loss long enough for the EWMA to fall below + // LOSS_DEGRADE_CLEAR. record_loss with a clean window drains it. + for _ in 0..60 { + t += 200; + cc.record_loss(1_000, 0, t); + cc.tick(2_000_000, t); + } + assert!( + !cc.snapshot().loss_degraded, + "recovered loss should clear the verdict (ewma={:.3})", + cc.loss_ewma + ); + } + + #[test] + fn outlier_burst_at_seed_is_clamped() { + let mut cc = LinkCongestionState::default(); + cc.record_rtt(50.0, 0); + // First non-bootstrap tick sees a 50 Mbps stall-release burst. + // The seed must be bounded to a few times the initial estimate, + // not pinned to the burst. + cc.tick(50_000_000, 0); + assert!( + cc.target_bps <= 5 * INITIAL_TARGET_BPS, + "seed inflated to {} from a burst", + cc.target_bps + ); + assert!(cc.target_bps >= INITIAL_TARGET_BPS); + } + + #[test] + fn outlier_burst_does_not_run_the_target_away() { + let mut cc = LinkCongestionState::default(); + cc.record_rtt(50.0, 0); + // Establish a steady ~2 Mbps estimate. + for i in 0..8 { + cc.record_rtt(50.0, i * 100); + cc.tick(2_000_000, i * 100); + } + let before = cc.target_bps; + // A sustained 50 Mbps burst over a few ticks: each sample is + // clamped to 4x the running estimate, so the target can't leap to + // the burst rate in one tick. + cc.record_rtt(50.0, 900); + cc.tick(50_000_000, 900); + assert!( + cc.target_bps <= before.saturating_mul(4).max(INITIAL_TARGET_BPS), + "one burst tick inflated target to {} from {}", + cc.target_bps, + before + ); + } + + #[test] + fn loss_ewma_does_not_latch_on_a_transient_spike() { + let mut cc = LinkCongestionState::default(); + cc.record_rtt(50.0, 0); + // One bad window, then clean. Must not latch (sustain not met). + cc.record_loss(1_000, 900, 200); + cc.tick(2_000_000, 200); + for _ in 0..10 { + cc.record_loss(1_000, 0, 400); + } + cc.tick(2_000_000, 1_600); + assert!( + !cc.snapshot().loss_degraded, + "a single bad window must not demote the link" + ); + } + + #[test] + fn observe_traffic_first_call_sets_baseline_without_sample() { + let mut cc = LinkCongestionState::default(); + cc.observe_traffic(1_000_000, 5, 100); + assert!( + cc.loss_samples.is_empty(), + "first call must not emit a sample" + ); + assert!(cc.traffic_baseline_set); + assert_eq!(cc.prev_bytes_sent_total, 1_000_000); + assert_eq!(cc.prev_nak_total, 5); + } + + #[test] + fn observe_traffic_delta_flows_into_record_loss() { + let mut cc = LinkCongestionState::default(); + cc.observe_traffic(0, 0, 0); + // 1 MB sent ≈ 760 packets at 1316-byte payload. 5 NAKs in same tick. + cc.observe_traffic(1_000_000, 5, 100); + let pm = cc.loss_permille(); + // 5 / 760 ≈ 6.6 permille + assert!(pm > 0, "loss permille should be non-zero after delta"); + assert!(pm < 20, "expected ~6 permille, got {pm}"); + } + + #[test] + fn observe_traffic_quiet_tick_with_naks_does_not_panic() { + // Pathological: NAKs arrive but no bytes were sent this tick. + // Without the synthesized 1-packet `sent` baseline this would + // skip the record_loss call entirely (delta_bytes == 0 path); + // verify the loss makes it into the window. + let mut cc = LinkCongestionState::default(); + cc.observe_traffic(1_000_000, 0, 0); + cc.observe_traffic(1_000_000, 5, 100); + let pm = cc.loss_permille(); + assert!( + pm > 0, + "loss with no fresh bytes should still register, got {pm}" + ); + // Hard cap from `loss_permille` saturation. + assert!(pm <= 1_000_000); + } +} diff --git a/src/sender/selection/mod.rs b/src/sender/selection/mod.rs index aacfc3e..32af309 100644 --- a/src/sender/selection/mod.rs +++ b/src/sender/selection/mod.rs @@ -1,6 +1,6 @@ //! Connection selection strategies for SRTLA bonding //! -//! This module provides three connection selection strategies: +//! This module provides two connection selection strategies: //! //! ## Classic Mode //! Matches the original C implementation exactly: @@ -14,29 +14,13 @@ //! - NAK burst detection and penalties //! - RTT-aware scoring (small bonus for low latency) //! - Hysteresis (10%) to prevent flip-flopping -//! - Optional smart exploration -//! - Time-based switch dampening to prevent rapid thrashing -//! -//! ## RTT-Threshold Mode -//! Groups links by RTT to reduce packet reordering: -//! - Links within min_rtt + delta are "fast" -//! - Strongly prefers fast links over slow ones -//! - Quality scoring applied within fast link group -//! - Falls back to slow links only when fast links saturated -pub mod blest; mod classic; -pub mod edpf; -mod enhanced; -mod exploration; -pub mod iods; +pub mod classifier; +pub mod enhanced; +pub mod link_cc; mod quality; -#[cfg(feature = "test-internals")] -pub mod rtt_threshold; -#[cfg(not(feature = "test-internals"))] -mod rtt_threshold; - // Re-export for backward compatibility pub use quality::calculate_quality_multiplier; @@ -44,18 +28,11 @@ use crate::config::ConfigSnapshot; use crate::connection::SrtlaConnection; use crate::mode::SchedulingMode; -/// Minimum time in milliseconds between connection switches -/// Prevents rapid thrashing when scores fluctuate due to bursty ACK/NAK patterns. -/// Aligned with FLUSH_INTERVAL_MS (15ms) so connections can rotate between batches -/// while avoiding intra-batch flip-flopping. -pub const MIN_SWITCH_INTERVAL_MS: u64 = 15; - /// Select the best connection index based on mode and configuration /// /// # Arguments /// * `conns` - Mutable slice of connections (for quality cache updates in enhanced mode) /// * `last_idx` - Previously selected connection (for hysteresis) -/// * `last_switch_time_ms` - Time of last switch (for time-based dampening) /// * `current_time_ms` - Current timestamp in milliseconds /// * `config` - Configuration snapshot with mode and settings /// @@ -65,94 +42,62 @@ pub const MIN_SWITCH_INTERVAL_MS: u64 = 15; pub fn select_connection_idx( conns: &mut [SrtlaConnection], last_idx: Option, - last_switch_time_ms: u64, current_time_ms: u64, config: &ConfigSnapshot, ) -> Option { + // Stalled-link deselect (default on). A link is gated only when it is a + // stalled black hole AND at least one healthier link can carry the traffic, + // so the last usable link is never gated — the mode selectors then skip + // `stall_gated` links exactly as they skip timed-out ones. Gating is a pure + // selection penalty: a gated link keeps sending keepalives, and its next + // keepalive-RTT sample clears the stall on its own (no blind reprobe). + // Recomputed for every link on every call so the transient flag can never go + // stale; collapses to clearing the flag when the guard is off. + apply_stall_gate(conns, current_time_ms, config); + match config.mode { SchedulingMode::Classic => { // Classic mode: simple capacity-based selection (no dampening, matches original C) - classic::select_connection(conns) + classic::select_connection(conns, current_time_ms) } SchedulingMode::Enhanced => { - // Enhanced mode: quality-aware selection with optional exploration and time-based dampening + // Enhanced mode: quality-aware selection with score hysteresis. enhanced::select_connection( conns, last_idx, - last_switch_time_ms, - current_time_ms, - config.effective_quality_enabled(), - config.effective_exploration_enabled(), - ) - } - SchedulingMode::RttThreshold => { - // RTT-threshold mode: prefer low-RTT links to reduce reordering - rtt_threshold::select_connection( - conns, - last_idx, - last_switch_time_ms, current_time_ms, - config.rtt_delta_ms, config.effective_quality_enabled(), ) } - SchedulingMode::Edpf => { - // EDPF mode: BLEST → IoDS → EDPF pipeline - edpf_pipeline_select(conns, config) - } } } -/// EDPF pipeline: BLEST filters → IoDS ordering → EDPF argmin. +/// Recompute the transient `stall_gated` flag on every link. /// -/// Matches strata's bonding.rs:30-35: -/// 1. BLEST filters out HoL-blocking links -/// 2. IoDS filters for monotonic ordering -/// 3. EDPF selects argmin(predicted_arrival) from remaining -fn edpf_pipeline_select( - conns: &[SrtlaConnection], - _config: &ConfigSnapshot, -) -> Option { - const SRT_PKT_SIZE: usize = 1316; - - // Use thread-local BLEST and IoDS state - thread_local! { - static BLEST: std::cell::RefCell = - std::cell::RefCell::new(blest::BlestFilter::new()); - static IODS: std::cell::RefCell = - std::cell::RefCell::new(iods::IodsFilter::new()); +/// A link is gated when the guard is on, the link is stalled +/// ([`SrtlaConnection::is_stalled`]), and at least one non-stalled schedulable +/// link exists to carry the traffic. That "any healthy" guard guarantees we +/// never gate the last usable link, so the mode selectors can treat a gated +/// link as unschedulable without a fallback pass. When the guard is off (or no +/// link is stalled) every flag is cleared, restoring byte-for-byte baseline +/// selection. +#[inline] +fn apply_stall_gate(conns: &mut [SrtlaConnection], current_time_ms: u64, config: &ConfigSnapshot) { + let min_in_flight = config.stall_min_in_flight; + let stale_ms = config.stall_ack_stale_ms; + + let any_healthy = config.stall_deselect + && conns.iter().any(|c| { + !c.is_timed_out(current_time_ms) + && c.is_schedulable() + && !c.is_stalled(current_time_ms, min_in_flight, stale_ms) + }); + + for c in conns.iter_mut() { + // Short-circuit keeps `is_stalled` off the hot path when the guard is + // off or nothing healthy exists to fail over to. + c.stall_gated = any_healthy && c.is_stalled(current_time_ms, min_in_flight, stale_ms); } - - BLEST.with(|blest_cell| { - IODS.with(|iods_cell| { - let mut blest_filter = blest_cell.borrow_mut(); - let mut iods_filter = iods_cell.borrow_mut(); - - blest_filter.tick(); - - // 1. BLEST filters out HoL-blocking links - let candidates = blest_filter.filter(conns); - - // 2. IoDS filters for monotonic ordering - let ordered = iods_filter.filter_valid(&candidates, |idx| { - edpf::arrival_time(&conns[idx], SRT_PKT_SIZE) - }); - - // 3. EDPF selects argmin from remaining, with fallbacks - let selected = edpf::select_from_indices(conns, &ordered, SRT_PKT_SIZE) - .or_else(|| edpf::select_from_indices(conns, &candidates, SRT_PKT_SIZE)) - .or_else(|| edpf::select_from(conns, SRT_PKT_SIZE)); - - // Record the scheduled arrival for IoDS - if let Some(idx) = selected { - if let Some(arrival) = edpf::arrival_time(&conns[idx], SRT_PKT_SIZE) { - iods_filter.record_scheduled(arrival); - } - } - - selected - }) - }) } #[cfg(test)] @@ -163,7 +108,7 @@ mod tests { #[test] fn test_select_connection_idx_classic() { - // Test that classic mode always picks highest score, ignoring dampening + // Test that classic mode always picks highest score let rt = tokio::runtime::Runtime::new().unwrap(); let mut connections = rt.block_on(create_test_connections(3)); @@ -171,24 +116,13 @@ mod tests { connections[1].in_flight_packets = 0; // Highest score connections[2].in_flight_packets = 10; // Lowest score - let last_switch_time_ms = now_ms(); - let current_time_ms = last_switch_time_ms + 100; // Within cooldown - let config = ConfigSnapshot { mode: SchedulingMode::Classic, quality_enabled: false, - exploration_enabled: false, - rtt_delta_ms: 30, + ..ConfigSnapshot::default() }; - // Classic mode should pick connection 1 (highest score) even during cooldown - let result = select_connection_idx( - &mut connections, - Some(0), - last_switch_time_ms, - current_time_ms, - &config, - ); + let result = select_connection_idx(&mut connections, Some(0), now_ms(), &config); assert_eq!( result, Some(1), @@ -197,52 +131,58 @@ mod tests { } #[test] - fn test_select_connection_idx_enhanced() { - // Test that enhanced mode enforces cooldown dampening + fn test_enhanced_switches_immediately_when_clearly_better() { + // Regression guard for the removed switch cooldown. Selection must be + // free to re-decide on every packet: `get_score()` counts queued packets + // as in-flight, so routing a packet de-prioritises its own link, and that + // feedback loop is what bounds per-link queue depth. A time-based lock + // would defer the switch below and let in-flight run away on link 0. let rt = tokio::runtime::Runtime::new().unwrap(); let mut connections = rt.block_on(create_test_connections(3)); connections[0].in_flight_packets = 5; // Currently selected, lower score - connections[1].in_flight_packets = 0; // Highest score + connections[1].in_flight_packets = 0; // Far better score connections[2].in_flight_packets = 10; // Lowest score - let last_switch_time_ms = now_ms(); - let current_time_ms = last_switch_time_ms + 5; // Within 15ms cooldown - let config = ConfigSnapshot { mode: SchedulingMode::Enhanced, quality_enabled: true, - exploration_enabled: false, - rtt_delta_ms: 30, + ..ConfigSnapshot::default() }; - // Enhanced mode should stay with connection 0 due to cooldown - let result = select_connection_idx( - &mut connections, - Some(0), - last_switch_time_ms, - current_time_ms, - &config, - ); + // Immediately after having selected link 0, with no elapsed time at all. + let result = select_connection_idx(&mut connections, Some(0), now_ms(), &config); assert_eq!( result, - Some(0), - "Enhanced mode should enforce cooldown and stay with current connection" + Some(1), + "Enhanced mode must switch to a clearly better link with no time-based delay" ); + } - // After cooldown expires, should allow switching - let current_time_after_cooldown = last_switch_time_ms + 20; // Past 15ms cooldown - let result_after = select_connection_idx( - &mut connections, - Some(0), - last_switch_time_ms, - current_time_after_cooldown, - &config, - ); + #[test] + fn test_enhanced_hysteresis_holds_when_gain_is_marginal() { + // Switching is damped in score space, not time: a link that is better by + // less than SWITCH_THRESHOLD (10%) does not win the packet. + let rt = tokio::runtime::Runtime::new().unwrap(); + let mut connections = rt.block_on(create_test_connections(3)); + + // score = window / (in_flight + 1), so 20 vs 19 in flight is only a ~5% + // improvement -- inside the hysteresis band. + connections[0].in_flight_packets = 20; // currently selected + connections[1].in_flight_packets = 19; // marginally better + connections[2].in_flight_packets = 40; // clearly worse + + let config = ConfigSnapshot { + mode: SchedulingMode::Enhanced, + quality_enabled: true, + ..ConfigSnapshot::default() + }; + + let result = select_connection_idx(&mut connections, Some(0), now_ms(), &config); assert_eq!( - result_after, - Some(1), - "Enhanced mode should allow switching after cooldown expires" + result, + Some(0), + "Enhanced mode should hold the current link when the alternative is <10% better" ); } @@ -252,10 +192,9 @@ mod tests { let config = ConfigSnapshot { mode: SchedulingMode::Enhanced, quality_enabled: false, - exploration_enabled: false, - rtt_delta_ms: 30, + ..ConfigSnapshot::default() }; - let result = select_connection_idx(&mut conns, None, 0, 0, &config); + let result = select_connection_idx(&mut conns, None, 0, &config); assert_eq!(result, None); } } diff --git a/src/sender/selection/quality.rs b/src/sender/selection/quality.rs index 69cae19..349c331 100644 --- a/src/sender/selection/quality.rs +++ b/src/sender/selection/quality.rs @@ -78,7 +78,7 @@ fn calculate_quality_multiplier_uncached(conn: &SrtlaConnection, current_time_ms }; } - let quality_mult = if let Some(nak_age_ms) = conn.time_since_last_nak_ms() { + let quality_mult = if let Some(nak_age_ms) = conn.time_since_last_nak_ms(current_time_ms) { // Exponential decay for smooth, gradual recovery from NAKs // This replaces the step function with a continuous curve // Exponential decay formula: penalty = max_penalty * e^(-age/half_life) diff --git a/src/sender/selection/rtt_threshold.rs b/src/sender/selection/rtt_threshold.rs deleted file mode 100644 index 454347d..0000000 --- a/src/sender/selection/rtt_threshold.rs +++ /dev/null @@ -1,140 +0,0 @@ -//! RTT-threshold connection selection algorithm -//! -//! Groups links into "fast" and "slow" based on RTT, preferring fast links -//! to reduce packet reordering at the receiver. -//! -//! Algorithm: -//! 1. Find minimum RTT among eligible links -//! 2. Mark links as "fast" if: rtt <= min_rtt + delta -//! 3. Select link with best quality-adjusted capacity among fast links -//! 4. Fallback to any eligible link if no fast links have capacity - -use tracing::debug; - -use super::MIN_SWITCH_INTERVAL_MS; -use crate::connection::SrtlaConnection; - -/// Select best connection using RTT-threshold algorithm -/// -/// Prefers low-RTT links to reduce packet reordering, while still considering -/// capacity and quality within the "fast" link group. -/// -/// # Arguments -/// * `conns` - Mutable slice of available connections (for quality cache updates) -/// * `last_idx` - Previously selected connection index (for dampening) -/// * `last_switch_time_ms` - Timestamp of last connection switch -/// * `current_time_ms` - Current timestamp in milliseconds -/// * `rtt_delta_ms` - RTT threshold above minimum to be considered "fast" -/// * `enable_quality` - Whether to apply quality scoring -#[inline(always)] -pub fn select_connection( - conns: &mut [SrtlaConnection], - last_idx: Option, - last_switch_time_ms: u64, - current_time_ms: u64, - rtt_delta_ms: u32, - enable_quality: bool, -) -> Option { - // Phase 1: Find minimum RTT among eligible links - let mut min_rtt = f64::MAX; - for c in conns.iter() { - if c.is_timed_out() || !c.connected { - continue; - } - let base_score = c.get_score(); - if base_score <= 0 { - continue; - } - let rtt = c.get_smooth_rtt_ms(); - // Only consider links with valid RTT measurements - if rtt > 0.0 && rtt < min_rtt { - min_rtt = rtt; - } - } - - // If no valid RTT data, treat all links as fast - let rtt_threshold = if min_rtt == f64::MAX { - f64::MAX - } else { - min_rtt + f64::from(rtt_delta_ms) - }; - - // Phase 2: Select best among fast links - let mut best_idx: Option = None; - let mut best_score: f64 = -1.0; - - for (i, c) in conns.iter_mut().enumerate() { - if c.is_timed_out() || !c.connected { - continue; - } - let base_score = c.get_score(); - if base_score <= 0 { - continue; - } - - let rtt = c.get_smooth_rtt_ms(); - // A link is "fast" if: - // - No RTT data (rtt <= 0), or - // - RTT is within threshold of minimum - let is_fast = rtt <= 0.0 || rtt <= rtt_threshold; - - if is_fast { - let score = if enable_quality { - let quality = c.get_cached_quality_multiplier(current_time_ms); - (base_score as f64) * quality - } else { - base_score as f64 - }; - - if score > best_score { - best_score = score; - best_idx = Some(i); - } - } - } - - // Phase 3: Fallback to any eligible link if no fast links have capacity - if best_idx.is_none() { - debug!( - "RTT-threshold: no fast links available (threshold: {:.0}ms), falling back", - rtt_threshold - ); - for (i, c) in conns.iter_mut().enumerate() { - if c.is_timed_out() || !c.connected { - continue; - } - let base_score = c.get_score(); - if base_score <= 0 { - continue; - } - let score = if enable_quality { - let quality = c.get_cached_quality_multiplier(current_time_ms); - (base_score as f64) * quality - } else { - base_score as f64 - }; - - if score > best_score { - best_score = score; - best_idx = Some(i); - } - } - } - - // Phase 4: Time-based dampening (prevent rapid thrashing) - let time_since_last_switch = current_time_ms.saturating_sub(last_switch_time_ms); - let in_cooldown = time_since_last_switch < MIN_SWITCH_INTERVAL_MS; - - if let Some(last) = last_idx - && best_idx != Some(last) - && in_cooldown - { - // Check if last connection is still valid - let last_valid = last < conns.len() && !conns[last].is_timed_out() && conns[last].connected; - if last_valid && conns[last].get_score() > 0 { - return Some(last); - } - } - - best_idx -} diff --git a/src/sender/status.rs b/src/sender/status.rs index c175d9a..d7e1361 100644 --- a/src/sender/status.rs +++ b/src/sender/status.rs @@ -22,6 +22,9 @@ pub(crate) fn log_connection_status( return; } + // Telemetry is display-layer; a single monotonic read drives every elapsed + // computation and timeout check in this report. + let now = now_ms(); let total_connections = connections.len(); // Single pass over connections to collect all stats @@ -30,7 +33,7 @@ pub(crate) fn log_connection_status( let mut total_in_flight = 0usize; for conn in connections.iter() { - if !conn.is_timed_out() { + if !conn.is_timed_out(now) { active_connections += 1; } total_bitrate_mbps += conn.current_bitrate_mbps(); @@ -68,29 +71,14 @@ pub(crate) fn log_connection_status( info!(" Mode: {}", snap.mode); match snap.mode { crate::mode::SchedulingMode::Classic => { - info!(" (quality/exploration/rtt-delta not applicable)"); + info!(" (quality scoring not applicable)"); } crate::mode::SchedulingMode::Enhanced => { info!( - " Quality: {}, Exploration: {}", - if snap.quality_enabled { "ON" } else { "OFF" }, - if snap.exploration_enabled { - "ON" - } else { - "OFF" - } + " Quality: {}", + if snap.quality_enabled { "ON" } else { "OFF" } ); } - crate::mode::SchedulingMode::RttThreshold => { - info!( - " Quality: {}, RTT delta: {}ms", - if snap.quality_enabled { "ON" } else { "OFF" }, - snap.rtt_delta_ms - ); - } - crate::mode::SchedulingMode::Edpf => { - info!(" EDPF pipeline: BLEST + IoDS + EDPF"); - } } // Show packet log utilization @@ -112,7 +100,7 @@ pub(crate) fn log_connection_status( // Show individual connection details for (i, conn) in connections.iter().enumerate() { - let status = if conn.is_timed_out() { + let status = if conn.is_timed_out(now) { "TIMED_OUT" } else { "ACTIVE" @@ -126,15 +114,15 @@ pub(crate) fn log_connection_status( s => s.to_string().into(), }; - // Use elapsed seconds directly + // Elapsed since the monotonic ms stamp. let last_recv = conn .last_received - .map(|t| format!("{:.1}s ago", t.elapsed().as_secs_f64())) + .map(|t| format!("{:.1}s ago", now.saturating_sub(t) as f64 / 1000.0)) .unwrap_or_else(|| "never".into()); let last_send = conn .last_sent - .map(|t| format!("{:.1}s ago", t.elapsed().as_secs_f64())) + .map(|t| format!("{:.1}s ago", now.saturating_sub(t) as f64 / 1000.0)) .unwrap_or_else(|| "never".into()); info!( @@ -153,8 +141,8 @@ pub(crate) fn log_connection_status( if conn.rtt.estimated_rtt_ms > 0.0 { info!( - " RTT: kalman={:.1}ms, velocity={:.2}ms/s, jitter={:.1}ms, stable={} (last: \ - {:.1}s ago)", + " RTT: kalman={:.1}ms, velocity={:.2}ms/s, jitter={:.1}ms, stable={} \ + (last: {:.1}s ago)", conn.get_smooth_rtt_ms(), conn.get_rtt_velocity(), conn.get_rtt_jitter_ms(), diff --git a/src/stats.rs b/src/stats.rs index 1ca1ec2..c7506c5 100644 --- a/src/stats.rs +++ b/src/stats.rs @@ -17,6 +17,7 @@ //! 4. **Simple aggregates**: Only sums and counts, no derived calculations like //! "capacity estimation" that would require assumptions about packet sizes. +use std::collections::HashMap; use std::net::IpAddr; use std::sync::{Arc, RwLock}; @@ -24,7 +25,10 @@ use serde::Serialize; use crate::config::ConfigSnapshot; use crate::connection::SrtlaConnection; -use crate::sender::calculate_quality_multiplier; +use crate::sender::{ + CcState, ClassificationResult, LinkCcSnapshot, WeakReason, calculate_quality_multiplier, + in_flight_cap_packets, +}; use crate::utils::now_ms; /// Per-link statistics. @@ -52,8 +56,14 @@ pub struct LinkStats { pub rtt_ms: u32, /// Total NAK count since connection established. Indicates packet loss. pub nak_count: i32, - /// Current send bitrate in bytes/sec (measured, not estimated). - pub bitrate_bps: u32, + /// Measured send rate in **bytes** per second (not estimated). + /// + /// Named for its unit on purpose. This was `bitrate_bps` while + /// carrying bytes/sec, sitting next to genuinely bit-denominated + /// fields like `cc_target_bps`, and feeding a Prometheus gauge whose + /// name also claimed bits. Anything that compared the two, or + /// graphed the gauge, was silently out by a factor of 8. + pub bitrate_bytes_per_sec: u32, // --- RTT baseline tracking --- /// Dual-window minimum RTT baseline in milliseconds. @@ -65,7 +75,7 @@ pub struct LinkStats { /// Base score: window / (in_flight + 1). Used by classic mode. /// Higher score = more available capacity on this link. pub base_score: i32, - /// Quality multiplier (0.35 to 1.1) used by enhanced/rtt-threshold modes. + /// Quality multiplier (0.35 to 1.1) used by enhanced mode. /// - 1.1 = perfect (no NAKs ever) /// - 1.0 = normal /// - <1.0 = degraded due to recent NAKs @@ -74,17 +84,88 @@ pub struct LinkStats { /// This is the EXACT multiplier used in `select_connection_idx()`. /// In classic mode, this is always 1.0 (quality scoring disabled). pub quality_multiplier: f64, + + // --- Weak-link classifier --- + // + // Output of `WeakLinkFilter::classify`. Currently informational only — + // not consumed by selection. Once we soak the classifier behaviour + // against real-world IRL traffic, the `weak` flag becomes an admission + // gate in Enhanced selection. + /// Whether the classifier flagged this link as weak this tick. + pub weak: bool, + /// Why the link was (or was not) flagged. One of: `healthy`, `high_rtt`, + /// `no_traffic`, `low_share`, `bypassed`. + pub weak_reason: String, + /// This link's share of total throughput in permille (0..=1000). + pub weak_share_permille: u32, + /// Threshold the share was checked against (permille). Reflects + /// entering vs leaving for hysteresis. + pub weak_threshold_permille: u32, + + // --- Per-link CC soft cap --- + // + // Output of `LinkCcController::tick_all`. `cc_state`/`cc_backing_off` + // are reported for telemetry and drive the CC controller's own + // bitrate backoff; the routing-admission gate uses the sustained + // `loss_degraded` latch, not the raw per-window backoff. `cc_target_bps` + // scales the score multiplicatively via `enhanced::cc_soft_cap_multiplier` + // so the scheduler steers traffic away from a link before it hits its + // CC-predicted ceiling. + /// Current state: `bootstrap` / `climbing` / `holding` / + /// `backing_off` / `drain`. + pub cc_state: String, + /// Active climb sub-mode when `cc_state == climbing`. One of + /// `normal` / `hai` / `fast_recovery`. `normal` for any other + /// state (informational only). + pub cc_climb_mode: String, + /// Target sendable rate this link's CC believes is sustainable (bps). + pub cc_target_bps: u64, + /// Age-bucketed RTT EWMA (ms) — input to the CC state machine. + pub cc_rtt_ewma_ms: f64, + /// 1:3 weighted moving deviation around `cc_rtt_ewma_ms` (ms). + pub cc_rtt_var_ms: f64, + /// Lowest RTT ever observed on this link (ms). + pub cc_rtt_min_ms: f64, + /// Loss permille over the 1s rolling window. + pub cc_loss_permille: u32, + /// Time-decayed loss fraction (0..1) driving continuous phase + /// demotion. Latches `cc_loss_degraded` once sustained high. + pub cc_loss_ewma: f64, + /// Whether the sustained-loss verdict has latched. A degraded link + /// is demoted in score but kept schedulable, never removed. + pub cc_loss_degraded: bool, + + // --- Adaptive batch-send regime --- + /// Current per-connection batch-send regime. One of + /// `low_activity` / `normal` / `high_load`. Driven from observed + /// bitrate in housekeeping; dashboards can use it to explain why + /// one link is batching more aggressively than another. + pub batch_regime: String, + + // --- In-flight cap soft admission gate --- + // + // Derived from `cc_target_bps`: cap = (pps / 40) packets ≈ 25 ms of + // sustainable in-flight. When `in_flight > in_flight_cap_packets` + // the link is excluded from Enhanced selection while at least one + // un-gated alternative is schedulable, bounding per-link queueing + // delay before the CC controller has to back off on loss. + /// In-flight cap in packets. `0` means "no signal" — the per-link + /// CC hasn't published a `cc_target_bps` yet, so the cap is + /// inactive. + pub in_flight_cap_packets: u32, + /// Whether the cap was active this tick (i.e. `in_flight` exceeded + /// `in_flight_cap_packets`). When true and at least one other link + /// is un-gated, this link is being skipped by Enhanced selection. + pub in_flight_cap_active: bool, } /// Aggregate statistics snapshot. #[derive(Clone, Debug, Serialize)] pub struct StatsSnapshot { - /// Current scheduling mode: "classic", "enhanced", or "rtt-threshold" + /// Current scheduling mode: "classic" or "enhanced" pub mode: String, /// Whether quality scoring is enabled (always false for classic mode) pub quality_enabled: bool, - /// RTT delta threshold in ms (only relevant for rtt-threshold mode) - pub rtt_delta_ms: u32, /// Number of links that are connected AND not timed out pub active_links: usize, @@ -96,6 +177,13 @@ pub struct StatsSnapshot { /// Sum of in_flight across active links pub total_in_flight: i32, + // --- Weak-link classifier output --- + /// Estimated max delay budget the classifier derived this tick (ms). + /// Zero when classification was bypassed (e.g. under the throughput floor). + pub weak_link_estimated_max_delay_ms: u32, + /// Delay tier the cascade chose this tick (ms). + pub weak_link_selected_delay_ms: u32, + /// Per-link details pub links: Vec, } @@ -105,11 +193,12 @@ impl Default for StatsSnapshot { Self { mode: "enhanced".to_string(), quality_enabled: true, - rtt_delta_ms: 30, active_links: 0, total_links: 0, total_window: 0, total_in_flight: 0, + weak_link_estimated_max_delay_ms: 0, + weak_link_selected_delay_ms: 0, links: Vec::new(), } } @@ -132,20 +221,33 @@ impl SharedStats { } /// Update stats from current connection state. - pub fn update(&self, connections: &[SrtlaConnection], config: &ConfigSnapshot) { + /// + /// `classification` carries the weak-link classifier's per-tick output. + /// Pass `None` when the classifier is disabled or unavailable; the weak + /// fields are populated with neutral defaults in that case. + pub fn update( + &self, + connections: &[SrtlaConnection], + config: &ConfigSnapshot, + classification: Option<&ClassificationResult>, + link_cc: Option<&HashMap>, + ) { let current_time_ms = now_ms(); let quality_enabled = config.quality_enabled && !config.mode.is_classic(); let mut snapshot = StatsSnapshot { mode: format!("{}", config.mode), quality_enabled, - rtt_delta_ms: config.rtt_delta_ms, total_links: connections.len(), + weak_link_estimated_max_delay_ms: classification + .map(|c| c.estimated_max_delay_ms) + .unwrap_or(0), + weak_link_selected_delay_ms: classification.map(|c| c.selected_delay_ms).unwrap_or(0), ..Default::default() }; for conn in connections { - let timed_out = conn.is_timed_out(); + let timed_out = conn.is_timed_out(current_time_ms); let is_active = conn.connected && !timed_out; // Quality multiplier: use actual selection algorithm calculation, @@ -156,6 +258,58 @@ impl SharedStats { 1.0 }; + let weak_entry = + classification.and_then(|c| c.per_link.iter().find(|e| e.conn_id == conn.conn_id)); + let (weak, weak_reason, weak_share, weak_threshold) = match weak_entry { + Some(e) => ( + e.weak, + weak_reason_str(e.reason).to_string(), + e.share_permille, + e.threshold_permille, + ), + None => (false, "unknown".to_string(), 0, 0), + }; + + let cc_entry = link_cc.and_then(|m| m.get(&conn.conn_id).copied()); + let ( + cc_state, + cc_climb_mode, + cc_target_bps, + cc_rtt_ewma, + cc_rtt_var, + cc_rtt_min, + cc_loss_pm, + cc_loss_ewma, + cc_loss_degraded, + ) = match cc_entry { + Some(s) => ( + cc_state_str(s.state).to_string(), + s.climb_mode.as_str().to_string(), + s.target_bps, + s.rtt_ewma_ms, + s.rtt_var_ms, + s.rtt_min_ms, + s.loss_permille, + s.loss_ewma, + s.loss_degraded, + ), + None => ( + "unknown".to_string(), + "normal".to_string(), + 0, + 0.0, + 0.0, + 0.0, + 0, + 0.0, + false, + ), + }; + + let cap = in_flight_cap_packets(cc_target_bps, conn.get_rtt_min_ms()); + let in_flight_cap_pkts = cap.unwrap_or(0).max(0) as u32; + let in_flight_cap_active = cap.map(|c| conn.in_flight_packets > c).unwrap_or(false); + let link = LinkStats { ip: conn.local_ip, label: conn.label.clone(), @@ -165,11 +319,27 @@ impl SharedStats { in_flight: conn.in_flight_packets, rtt_ms: conn.get_smooth_rtt_ms() as u32, nak_count: conn.total_nak_count(), - bitrate_bps: (conn.current_bitrate_mbps() * 1_000_000.0 / 8.0) as u32, + bitrate_bytes_per_sec: (conn.current_bitrate_mbps() * 1_000_000.0 / 8.0) as u32, rtt_min_ms: conn.get_rtt_min_ms(), rtt_velocity: conn.get_rtt_velocity(), base_score: conn.get_score(), quality_multiplier, + weak, + weak_reason, + weak_share_permille: weak_share, + weak_threshold_permille: weak_threshold, + cc_state, + cc_climb_mode, + cc_target_bps, + cc_rtt_ewma_ms: cc_rtt_ewma, + cc_rtt_var_ms: cc_rtt_var, + cc_rtt_min_ms: cc_rtt_min, + cc_loss_permille: cc_loss_pm, + cc_loss_ewma, + cc_loss_degraded, + batch_regime: conn.batch_sender.regime().as_str().to_string(), + in_flight_cap_packets: in_flight_cap_pkts, + in_flight_cap_active, }; if is_active { @@ -200,6 +370,21 @@ impl SharedStats { } } +fn weak_reason_str(reason: WeakReason) -> &'static str { + match reason { + WeakReason::Healthy => "healthy", + WeakReason::HighRtt => "high_rtt", + WeakReason::QueueBuilding => "queue_building", + WeakReason::NoTraffic => "no_traffic", + WeakReason::LowShare => "low_share", + WeakReason::Bypassed => "bypassed", + } +} + +fn cc_state_str(state: CcState) -> &'static str { + state.as_str() +} + #[cfg(test)] mod tests { use super::*; @@ -219,10 +404,9 @@ mod tests { let config = ConfigSnapshot { mode: SchedulingMode::Enhanced, quality_enabled: true, - exploration_enabled: false, - rtt_delta_ms: 30, + ..ConfigSnapshot::default() }; - stats.update(&[], &config); + stats.update(&[], &config, None, None); let snapshot = stats.get(); assert_eq!(snapshot.mode, "enhanced"); assert!(snapshot.quality_enabled); diff --git a/src/subscriptions.rs b/src/subscriptions.rs new file mode 100644 index 0000000..32e3cd3 --- /dev/null +++ b/src/subscriptions.rs @@ -0,0 +1,200 @@ +//! Server-push subscriptions over the JSON-RPC control socket. +//! +//! Scraping `get_stats` at 1 Hz misses sub-second link-state changes +//! (NAK bursts, quality drops, reconnects). A subscription lets the +//! client register interest in a topic and receive push events as +//! JSON-RPC notifications on the same Unix socket. +//! +//! Wire format (over the control socket): +//! +//! Client → server: +//! ```json +//! {"jsonrpc":"2.0","id":1,"method":"subscribe","params":{"topic":"stats"}} +//! ``` +//! Server reply: +//! ```json +//! {"jsonrpc":"2.0","result":{"subscription_id":"sub-0"},"id":1} +//! ``` +//! Server push (whenever the topic produces an event): +//! ```json +//! {"jsonrpc":"2.0","method":"stats.update", +//! "params":{"subscription_id":"sub-0","data":{ ... }}} +//! ``` +//! +//! Topics currently published: +//! +//! - `stats` — per-link snapshot, fired once per second alongside the +//! existing `get_stats` update. +//! - `priority.window` — fired on each critical-window extension from +//! the priority sidecar (encoder keyframe hint). + +use std::sync::Arc; +#[cfg(unix)] +use std::sync::atomic::{AtomicU64, Ordering}; + +use serde_json::{Value, json}; +use tokio::sync::{Mutex, mpsc}; + +/// One registered subscription. The sender is the *connection's* push +/// channel — all subscriptions multiplex over a single mpsc to the +/// client, with the subscription id carried in each notification so +/// clients can demux. +struct Entry { + id: String, + topic: String, + sender: mpsc::Sender, +} + +/// Shared fan-out hub. Cheap to clone. +#[derive(Clone, Default)] +pub struct SubscriptionHub { + /// Only the control socket hands out subscription ids, and that socket is + /// Unix-domain. On other platforms the hub still publishes -- to nobody, + /// since there is no way to subscribe. + #[cfg(unix)] + next_id: Arc, + entries: Arc>>, +} + +impl SubscriptionHub { + pub fn new() -> Self { + Self::default() + } + + /// Register a subscription. Returns the subscription id the client + /// should use to unsubscribe. Caller supplies their push channel; + /// every published event on the topic is written to it. + #[cfg(unix)] + pub async fn subscribe(&self, topic: &str, push_tx: mpsc::Sender) -> String { + let id = format!("sub-{}", self.next_id.fetch_add(1, Ordering::Relaxed)); + self.entries.lock().await.push(Entry { + id: id.clone(), + topic: topic.to_string(), + sender: push_tx, + }); + id + } + + /// Remove a subscription by id. Returns true if it was present. + #[cfg(unix)] + pub async fn unsubscribe(&self, id: &str) -> bool { + let mut entries = self.entries.lock().await; + let before = entries.len(); + entries.retain(|e| e.id != id); + before != entries.len() + } + + /// Fan-out a published event to every subscription of `topic`. + /// Full-channel pushes are dropped — a backed-up subscriber never + /// blocks the producer. Closed channels are cleaned up lazily next + /// time we iterate. + pub async fn publish(&self, topic: &str, data: Value) { + let mut to_prune = Vec::new(); + { + let entries = self.entries.lock().await; + for entry in entries.iter() { + if entry.topic != topic { + continue; + } + let envelope = json!({ + "jsonrpc": "2.0", + "method": format!("{topic}.update"), + "params": { + "subscription_id": entry.id, + "data": data, + }, + }); + let line = match serde_json::to_string(&envelope) { + Ok(s) => s, + Err(_) => continue, + }; + match entry.sender.try_send(line) { + Ok(()) => {} + Err(mpsc::error::TrySendError::Full(_)) => { + tracing::debug!(id = %entry.id, topic, "subscription channel full, dropped event"); + } + Err(mpsc::error::TrySendError::Closed(_)) => { + to_prune.push(entry.id.clone()); + } + } + } + } + if !to_prune.is_empty() { + let mut entries = self.entries.lock().await; + entries.retain(|e| !to_prune.contains(&e.id)); + } + } + + /// Number of active subscriptions (all topics combined). Exposed for + /// telemetry; not needed for correctness. + #[cfg(unix)] + pub async fn len(&self) -> usize { + self.entries.lock().await.len() + } + + #[allow(dead_code)] // Paired with `len()` for clippy; not called in-tree yet. + pub async fn is_empty(&self) -> bool { + self.entries.lock().await.is_empty() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn subscribe_publish_roundtrip() { + let hub = SubscriptionHub::new(); + let (tx, mut rx) = mpsc::channel::(8); + let id = hub.subscribe("stats", tx).await; + assert!(id.starts_with("sub-")); + + hub.publish("stats", json!({"active_links": 3})).await; + let line = rx.recv().await.unwrap(); + let v: Value = serde_json::from_str(&line).unwrap(); + assert_eq!(v["method"], "stats.update"); + assert_eq!(v["params"]["subscription_id"], id); + assert_eq!(v["params"]["data"]["active_links"], 3); + } + + #[tokio::test] + async fn publish_respects_topic_filtering() { + let hub = SubscriptionHub::new(); + let (tx1, mut rx1) = mpsc::channel::(8); + let (tx2, mut rx2) = mpsc::channel::(8); + hub.subscribe("stats", tx1).await; + hub.subscribe("priority.window", tx2).await; + + hub.publish("stats", json!({})).await; + assert!(rx1.try_recv().is_ok()); + assert!(rx2.try_recv().is_err()); + + hub.publish("priority.window", json!({})).await; + assert!(rx2.try_recv().is_ok()); + assert!(rx1.try_recv().is_err()); + } + + #[tokio::test] + async fn unsubscribe_removes_entry() { + let hub = SubscriptionHub::new(); + let (tx, mut rx) = mpsc::channel::(8); + let id = hub.subscribe("stats", tx).await; + assert!(hub.unsubscribe(&id).await); + hub.publish("stats", json!({})).await; + assert!(rx.try_recv().is_err()); + // Second unsubscribe is a no-op. + assert!(!hub.unsubscribe(&id).await); + } + + #[tokio::test] + async fn dropped_channel_is_pruned_on_next_publish() { + let hub = SubscriptionHub::new(); + { + let (tx, _rx) = mpsc::channel::(8); + hub.subscribe("stats", tx).await; + } // rx drops, closing the channel + assert_eq!(hub.len().await, 1); + hub.publish("stats", json!({})).await; + assert_eq!(hub.len().await, 0); + } +} diff --git a/src/test_helpers.rs b/src/test_helpers.rs index bea9808..a84578d 100644 --- a/src/test_helpers.rs +++ b/src/test_helpers.rs @@ -8,10 +8,10 @@ use std::sync::atomic::{AtomicU64, Ordering}; use rustc_hash::FxHashMap; use smallvec::SmallVec; use socket2::{Domain, Protocol, Socket, Type}; -use tokio::time::Instant; +use tokio::time::Duration; use crate::connection::{ - BatchSender, BatchUdpSocket, BitrateTracker, CachedQuality, CongestionControl, + BatchSender, BatchUdpSocket, BitrateTracker, CachedQuality, CongestionControl, LinkPhase, ReconnectionState, RttTracker, SrtlaConnection, }; use crate::protocol::{PKT_LOG_SIZE, WINDOW_DEF, WINDOW_MULT}; @@ -50,12 +50,14 @@ fn create_connection_from_socket( in_flight_packets: 0, packet_log: FxHashMap::with_capacity_and_hasher(PKT_LOG_SIZE, Default::default()), highest_acked_seq: i32::MIN, - last_received: Some(Instant::now()), + last_received: Some(now_ms()), last_sent: None, last_keepalive_sent: None, + last_ack_or_rtt_sample_ms: 0, + stall_gated: false, rtt: RttTracker::default(), congestion: CongestionControl::default(), - bitrate: BitrateTracker::default(), + bitrate: BitrateTracker::new(now_ms()), reconnection: ReconnectionState { connection_established_ms: now_ms(), startup_grace_deadline_ms: now_ms(), @@ -63,6 +65,12 @@ fn create_connection_from_socket( }, quality_cache: CachedQuality::default(), batch_sender: BatchSender::new(), + phase: LinkPhase::Live, + weak: false, + cc_backing_off: false, + cc_target_bps: 0, + loss_degraded: false, + binder: Arc::new(crate::connection::SourceIpBinder), } } @@ -90,3 +98,16 @@ pub async fn create_test_connections(count: usize) -> SmallVec= initial_window); } @@ -94,7 +96,7 @@ mod tests { conn.register_packet(103, current_time); // Test single NAK - conn.handle_nak(100); + conn.handle_nak(100, now_ms()); assert_eq!(conn.congestion.nak_count, 1); assert!(conn.window < initial_window); assert_eq!(conn.congestion.nak_burst_count, 0); @@ -105,15 +107,15 @@ mod tests { // Simulate NAK burst (multiple NAKs within 1 second) conn.congestion.last_nak_time_ms = current_time; - conn.handle_nak(101); + conn.handle_nak(101, now_ms()); assert_eq!(conn.congestion.nak_burst_count, 2); - conn.handle_nak(102); + conn.handle_nak(102, now_ms()); assert_eq!(conn.congestion.nak_burst_count, 3); // Test fast recovery mode activation conn.window = 1500; // Low enough to trigger fast recovery - conn.handle_nak(103); + conn.handle_nak(103, now_ms()); assert!(conn.congestion.fast_recovery_mode); } @@ -129,7 +131,7 @@ mod tests { conn.register_packet(100, current_time); // Now handle a NAK for that same packet - conn.handle_nak(100); + conn.handle_nak(100, now_ms()); assert!(conn.window < initial_window, "Window should shrink on NAK"); assert_eq!( @@ -149,21 +151,21 @@ mod tests { conn.register_packet(i, current_time); } - conn.handle_nak(100); + conn.handle_nak(100, now_ms()); assert_eq!(conn.congestion.nak_burst_count, 0); assert_eq!(conn.congestion.nak_count, 1); std::thread::sleep(std::time::Duration::from_millis(500)); - conn.handle_nak(101); + conn.handle_nak(101, now_ms()); assert_eq!(conn.congestion.nak_burst_count, 2); assert_eq!(conn.congestion.nak_count, 2); - conn.handle_nak(102); + conn.handle_nak(102, now_ms()); assert_eq!(conn.congestion.nak_burst_count, 3); assert_eq!(conn.congestion.nak_count, 3); std::thread::sleep(std::time::Duration::from_millis(1100)); - conn.handle_nak(103); + conn.handle_nak(103, now_ms()); assert_eq!(conn.congestion.nak_burst_count, 0); assert_eq!(conn.congestion.nak_count, 4); } @@ -178,16 +180,16 @@ mod tests { conn.register_packet(i, current_time); } - conn.handle_nak(100); - conn.handle_nak(101); - conn.handle_nak(102); + conn.handle_nak(100, now_ms()); + conn.handle_nak(101, now_ms()); + conn.handle_nak(102, now_ms()); assert_eq!(conn.congestion.nak_burst_count, 3); - conn.perform_window_recovery(); + conn.perform_window_recovery(now_ms()); assert_eq!(conn.congestion.nak_burst_count, 3); std::thread::sleep(std::time::Duration::from_millis(1100)); - conn.perform_window_recovery(); + conn.perform_window_recovery(now_ms()); assert_eq!(conn.congestion.nak_burst_count, 0); assert_eq!(conn.congestion.nak_burst_start_time_ms, 0); } @@ -204,7 +206,7 @@ mod tests { let initial_burst_count = conn.congestion.nak_burst_count; let initial_window = conn.window; - let found = conn.handle_nak(999); + let found = conn.handle_nak(999, now_ms()); assert!(!found); assert_eq!(conn.congestion.nak_count, initial_nak_count); assert_eq!(conn.congestion.nak_burst_count, initial_burst_count); @@ -221,15 +223,15 @@ mod tests { conn.register_packet(i, current_time); } - conn.handle_nak(100); - conn.handle_nak(101); + conn.handle_nak(100, now_ms()); + conn.handle_nak(101, now_ms()); assert_eq!(conn.congestion.nak_burst_count, 2); - conn.handle_nak(102); + conn.handle_nak(102, now_ms()); assert_eq!(conn.congestion.nak_burst_count, 3); std::thread::sleep(std::time::Duration::from_millis(1100)); - conn.handle_nak(103); + conn.handle_nak(103, now_ms()); assert_eq!(conn.congestion.nak_burst_count, 0); } @@ -243,9 +245,9 @@ mod tests { conn.register_packet(i, current_time); } - conn.handle_nak(100); - conn.handle_nak(101); - conn.handle_nak(102); + conn.handle_nak(100, now_ms()); + conn.handle_nak(101, now_ms()); + conn.handle_nak(102, now_ms()); assert_eq!(conn.congestion.nak_burst_count, 3); assert!(conn.congestion.nak_burst_start_time_ms > 0); @@ -270,12 +272,12 @@ mod tests { assert_eq!(conn.in_flight_packets, 3); // Test specific SRTLA ACK (using classic mode for original behavior) - let found = conn.handle_srtla_ack_specific(200, true); + let found = conn.handle_srtla_ack_specific(200, true, now_ms()); assert!(found); assert_eq!(conn.in_flight_packets, 2); // Test not found - let not_found = conn.handle_srtla_ack_specific(999, true); + let not_found = conn.handle_srtla_ack_specific(999, true, now_ms()); assert!(!not_found); assert_eq!(conn.in_flight_packets, 2); @@ -304,7 +306,7 @@ mod tests { // Test CLASSIC MODE: Should use simple C logic // With in_flight_packets=3 and window=1500, condition should be true // 3 * 1000 = 3000 > 1500, so SHOULD increase in classic mode - let found = conn.handle_srtla_ack_specific(100, true); // classic_mode = true + let found = conn.handle_srtla_ack_specific(100, true, now_ms()); // classic_mode = true assert!(found); assert_eq!(conn.window, initial_window + WINDOW_INCR - 1); // Should increase by WINDOW_INCR - 1 assert_eq!(conn.in_flight_packets, 2); // Should decrease @@ -331,7 +333,7 @@ mod tests { // First ACK - should NOT increase window immediately (boundary case: 5*1000 > 5000 is false) // ACK decrements in_flight 6→5, then check: 5*1000 > 5000? NO (boundary) - let found2 = conn.handle_srtla_ack_specific(200, false); + let found2 = conn.handle_srtla_ack_specific(200, false, now_ms()); assert!(found2); assert_eq!(conn.window, 5000); // Boundary case - no increase assert_eq!(conn.in_flight_packets, 5); @@ -342,7 +344,7 @@ mod tests { assert_eq!(conn.in_flight_packets, 7); // Second ACK - decrements 7→6, check: 6*1000 > 5000 → TRUE, increase! - let found3 = conn.handle_srtla_ack_specific(300, false); + let found3 = conn.handle_srtla_ack_specific(300, false, now_ms()); assert!(found3); assert_eq!(conn.window, 5000 + WINDOW_INCR - 1); // Should increase by WINDOW_INCR - 1 assert_eq!(conn.in_flight_packets, 6); @@ -353,16 +355,18 @@ mod tests { let rt = tokio::runtime::Runtime::new().unwrap(); let mut conn = rt.block_on(create_test_connection()); + let now = now_ms(); + // Should need keepalive initially (last_keepalive_sent is None) - assert!(conn.needs_keepalive()); + assert!(conn.needs_keepalive(now)); // After sending keepalive, should not need immediately - conn.last_keepalive_sent = Some(Instant::now()); - assert!(!conn.needs_keepalive()); + conn.last_keepalive_sent = Some(now); + assert!(!conn.needs_keepalive(now)); - // After timeout, should need again (simulate 2 seconds ago) - conn.last_keepalive_sent = Some(Instant::now() - Duration::from_secs(IDLE_TIME + 1)); - assert!(conn.needs_keepalive()); + // After timeout, should need again (stamp IDLE_TIME + 1 seconds in the past) + conn.last_keepalive_sent = Some(now - (IDLE_TIME + 1) * 1000); + assert!(conn.needs_keepalive(now)); } #[test] @@ -371,16 +375,16 @@ mod tests { let mut conn = rt.block_on(create_test_connection()); // Should need RTT measurement initially - assert!(conn.needs_rtt_measurement()); + assert!(conn.needs_rtt_measurement(now_ms())); // After waiting for response, should not need conn.rtt.waiting_for_keepalive_response = true; - assert!(!conn.needs_rtt_measurement()); + assert!(!conn.needs_rtt_measurement(now_ms())); // After timeout, should need again conn.rtt.waiting_for_keepalive_response = false; conn.rtt.last_rtt_measurement_ms = now_ms() - 4000; - assert!(conn.needs_rtt_measurement()); + assert!(conn.needs_rtt_measurement(now_ms())); } #[test] @@ -390,7 +394,7 @@ mod tests { // Simulate some NAKs to reduce window for _ in 0..5 { - conn.handle_nak(100); + conn.handle_nak(100, now_ms()); } let reduced_window = conn.window; @@ -398,7 +402,7 @@ mod tests { conn.congestion.last_nak_time_ms = now_ms() - 3000; conn.congestion.last_window_increase_ms = now_ms() - 2500; - conn.perform_window_recovery(); + conn.perform_window_recovery(now_ms()); assert!(conn.window > reduced_window); } @@ -407,15 +411,19 @@ mod tests { let rt = tokio::runtime::Runtime::new().unwrap(); let mut conn = rt.block_on(create_test_connection()); + // Injected clock: the whole reconnect-backoff decision is exercised at + // chosen instants, so the test no longer races two real-clock reads. + let now = now_ms(); + // Should allow first reconnect attempt - assert!(conn.should_attempt_reconnect()); + assert!(conn.should_attempt_reconnect(now)); // Record attempt - conn.record_reconnect_attempt(); + conn.record_reconnect_attempt(now); assert_eq!(conn.reconnection.reconnect_failure_count, 1); // Should not allow immediate retry - assert!(!conn.should_attempt_reconnect()); + assert!(!conn.should_attempt_reconnect(now)); // Test backoff behavior let initial_time = conn.reconnection.last_reconnect_attempt_ms; @@ -432,17 +440,48 @@ mod tests { let mut conn = rt.block_on(create_test_connection()); // Fresh connection should not be timed out - assert!(!conn.is_timed_out()); + assert!(!conn.is_timed_out(now_ms())); - // Simulate old last_received time - use std::time::Duration; - conn.last_received = - tokio::time::Instant::now().checked_sub(Duration::from_secs(CONN_TIMEOUT + 1)); - assert!(conn.is_timed_out()); + // Stamp last_received CONN_TIMEOUT + 1 seconds in the past. + conn.last_received = Some(now_ms() - (CONN_TIMEOUT + 1) * 1000); + assert!(conn.is_timed_out(now_ms())); // Disconnected connection should be timed out conn.connected = false; - assert!(conn.is_timed_out()); + assert!(conn.is_timed_out(now_ms())); + } + + /// Deterministic timeout on the single monotonic clock: `is_timed_out` reads + /// `now_ms()` and compares against the `last_received` stamp, so the test picks + /// the stamp instead of advancing a virtual clock. A stamp within `CONN_TIMEOUT` + /// is live; one past the window trips it. Completes in microseconds, no sleep. + #[test] + fn monotonic_clock_timeout() { + let rt = tokio::runtime::Runtime::new().unwrap(); + let mut conn = rt.block_on(create_test_connection()); + let now = now_ms(); + + conn.last_received = Some(now); + assert!(!conn.is_timed_out(now_ms()), "a just-received link is live"); + + conn.last_received = Some(now - (CONN_TIMEOUT + 1) * 1000); + assert!( + conn.is_timed_out(now_ms()), + "a stamp past CONN_TIMEOUT must mark the link timed out" + ); + } + + /// A freshly created connected link is not timed out: its `last_received` stamp + /// is essentially `now_ms()`, so the monotonic difference is far below + /// `CONN_TIMEOUT`. Guards against the timeout tripping on a live link. + #[test] + fn fresh_link_not_timed_out_yet() { + let rt = tokio::runtime::Runtime::new().unwrap(); + let conn = rt.block_on(create_test_connection()); + assert!( + !conn.is_timed_out(now_ms()), + "a freshly created link must not be timed out" + ); } #[test] @@ -475,7 +514,7 @@ mod tests { assert!(!conn.connected); // Should be in recovery mode with reset state - assert!(conn.is_timed_out()); + assert!(conn.is_timed_out(now_ms())); assert_eq!(conn.window, WINDOW_DEF * WINDOW_MULT); assert_eq!(conn.in_flight_packets, 0); @@ -494,15 +533,15 @@ mod tests { assert_eq!(conn.congestion.nak_count, 0); assert_eq!(conn.congestion.nak_burst_count, 0); - assert_eq!(conn.time_since_last_nak_ms(), None); + assert_eq!(conn.time_since_last_nak_ms(current_time), None); conn.register_packet(100, current_time); - conn.handle_nak(100); + conn.handle_nak(100, now_ms()); assert_eq!(conn.congestion.nak_count, 1); assert_eq!(conn.congestion.nak_burst_count, 0); - assert!(conn.time_since_last_nak_ms().is_some()); + assert!(conn.time_since_last_nak_ms(now_ms()).is_some()); - let time_since = conn.time_since_last_nak_ms().unwrap(); + let time_since = conn.time_since_last_nak_ms(now_ms()).unwrap(); assert!(time_since < 1000); // Should be very recent } @@ -517,14 +556,14 @@ mod tests { // Register packet first, then reduce window to trigger fast recovery conn.register_packet(100, current_time); conn.window = 1500; - conn.handle_nak(100); + conn.handle_nak(100, now_ms()); assert!(conn.congestion.fast_recovery_mode); // Test recovery exit condition conn.window = 15_000; conn.register_packet(200, current_time); - conn.handle_srtla_ack_specific(200, false); + conn.handle_srtla_ack_specific(200, false, now_ms()); assert!(!conn.congestion.fast_recovery_mode); } @@ -546,7 +585,7 @@ mod tests { // Verify that packets can be found and acknowledged let recent_seq = (PKT_LOG_SIZE + 5) as i32; - conn.handle_srt_ack(recent_seq); + conn.handle_srt_ack(recent_seq, now_ms()); // Should have reduced in-flight count and removed acked packets from log assert!(conn.in_flight_packets < PKT_LOG_SIZE as i32 + 10); @@ -560,14 +599,14 @@ mod tests { // Reduce window through NAKs conn.register_packet(100, current_time); - conn.handle_nak(100); + conn.handle_nak(100, now_ms()); let reduced_window = conn.window; // Test 1: Recent NAKs (<5 seconds) - should recover at 25% rate (minimal) conn.congestion.last_nak_time_ms = now_ms() - 3000; // 3 seconds ago conn.congestion.last_window_increase_ms = now_ms() - 2500; // Allow recovery let before_recovery = conn.window; - conn.perform_window_recovery(); + conn.perform_window_recovery(now_ms()); let recovery_amount_25 = conn.window - before_recovery; // Should be WINDOW_INCR * 1 / 4 = 30 / 4 = 7 (rounded down) assert_eq!( @@ -581,7 +620,7 @@ mod tests { conn.congestion.last_nak_time_ms = now_ms() - 6000; // 6 seconds ago conn.congestion.last_window_increase_ms = now_ms() - 2500; // Allow recovery let before_recovery = conn.window; - conn.perform_window_recovery(); + conn.perform_window_recovery(now_ms()); let recovery_amount_50 = conn.window - before_recovery; // Should be WINDOW_INCR * 1 / 2 = 30 / 2 = 15 assert_eq!( @@ -595,7 +634,7 @@ mod tests { conn.congestion.last_nak_time_ms = now_ms() - 8000; // 8 seconds ago conn.congestion.last_window_increase_ms = now_ms() - 2500; // Allow recovery let before_recovery = conn.window; - conn.perform_window_recovery(); + conn.perform_window_recovery(now_ms()); let recovery_amount_100 = conn.window - before_recovery; // Should be WINDOW_INCR * 1 = 30 assert_eq!( @@ -608,7 +647,7 @@ mod tests { conn.congestion.last_nak_time_ms = now_ms() - 11000; // 11 seconds ago conn.congestion.last_window_increase_ms = now_ms() - 2500; // Allow recovery let before_recovery = conn.window; - conn.perform_window_recovery(); + conn.perform_window_recovery(now_ms()); let recovery_amount_200 = conn.window - before_recovery; // Should be WINDOW_INCR * 2 = 30 * 2 = 60 assert_eq!( @@ -632,7 +671,7 @@ mod tests { // Reduce window and trigger fast recovery mode conn.register_packet(100, current_time); conn.window = 1500; - conn.handle_nak(100); + conn.handle_nak(100, now_ms()); assert!(conn.congestion.fast_recovery_mode); let reduced_window = conn.window; @@ -641,7 +680,7 @@ mod tests { conn.congestion.last_nak_time_ms = now_ms() - 3000; // 3 seconds ago conn.congestion.last_window_increase_ms = now_ms() - 600; // Allow fast recovery timing let before_recovery = conn.window; - conn.perform_window_recovery(); + conn.perform_window_recovery(now_ms()); let fast_recovery_25 = conn.window - before_recovery; // Should be WINDOW_INCR * 2 / 4 = 30 * 2 / 4 = 15 assert_eq!( @@ -655,7 +694,7 @@ mod tests { conn.congestion.last_nak_time_ms = now_ms() - 11000; // 11 seconds ago conn.congestion.last_window_increase_ms = now_ms() - 600; // Allow fast recovery timing let before_recovery = conn.window; - conn.perform_window_recovery(); + conn.perform_window_recovery(now_ms()); let fast_recovery_200 = conn.window - before_recovery; // Should be WINDOW_INCR * 2 * 2 = 30 * 2 * 2 = 120 assert_eq!( @@ -676,14 +715,14 @@ mod tests { // Reduce window conn.register_packet(100, current_time); - conn.handle_nak(100); + conn.handle_nak(100, now_ms()); let reduced_window = conn.window; // Test normal mode timing constraint (2000ms min wait + 1000ms increment wait) conn.congestion.last_nak_time_ms = now_ms() - 8000; // 8 seconds ago (should trigger) conn.congestion.last_window_increase_ms = now_ms() - 500; // Too recent let before = conn.window; - conn.perform_window_recovery(); + conn.perform_window_recovery(now_ms()); // Should NOT recover because increment wait time not met assert_eq!( conn.window, before, @@ -692,7 +731,7 @@ mod tests { // Now allow enough time conn.congestion.last_window_increase_ms = now_ms() - 1500; // Enough time - conn.perform_window_recovery(); + conn.perform_window_recovery(now_ms()); // Should recover now assert!( conn.window > before, @@ -705,7 +744,7 @@ mod tests { conn.congestion.last_nak_time_ms = now_ms() - 8000; // 8 seconds ago conn.congestion.last_window_increase_ms = now_ms() - 200; // Too recent even for fast mode let before_fast = conn.window; - conn.perform_window_recovery(); + conn.perform_window_recovery(now_ms()); // Should NOT recover assert_eq!( conn.window, before_fast, @@ -714,11 +753,234 @@ mod tests { // Now allow enough time for fast mode conn.congestion.last_window_increase_ms = now_ms() - 400; // Enough for fast mode - conn.perform_window_recovery(); + conn.perform_window_recovery(now_ms()); // Should recover now assert!( conn.window > before_fast, "Fast mode should recover when timing constraint is met" ); } + + /// Build a minimal 16-byte SRT control packet carrying `srt_type` in the + /// first two bytes (big-endian). + fn make_srt_control(srt_type: u16) -> [u8; 16] { + let mut pkt = [0u8; 16]; + pkt[0..2].copy_from_slice(&srt_type.to_be_bytes()); + pkt + } + + /// Covers the ACK fan-out in `process_connection_events`: an SRT ACK is + /// broadcast to *every* uplink and is cumulative, so each link clears its own + /// in-flight packets with seq ≤ ack; a NAK is never broadcast. We first lock + /// the broadcast *predicate* (an ACK classifies as ACK; a NAK / data packet + /// does not), then drive the real cumulative `handle_srt_ack` + /// (`connection/ack_nak.rs`) across a 3-uplink pool and assert in-flight drops + /// only where seq ≤ ack. + #[test] + fn ack_reduces_in_flight() { + // -- broadcast eligibility predicate -- + let ack_pkt = make_srt_control(SRT_TYPE_ACK); + let nak_pkt = make_srt_control(SRT_TYPE_NAK); + let data_pkt = make_srt_control(SRT_TYPE_DATA); + assert!( + is_srt_ack(&ack_pkt), + "an ACK packet must be ACK-classified (broadcast-eligible)" + ); + assert!(!is_srt_ack(&nak_pkt), "a NAK packet is not an ACK"); + assert_eq!( + get_packet_type(&nak_pkt), + Some(SRT_TYPE_NAK), + "the NAK packet must classify as NAK" + ); + assert!( + !is_srt_ack(&data_pkt), + "an SRT data packet is never ACK-broadcast-eligible" + ); + + // -- cumulative ACK reduces in-flight on the correct uplink(s) -- + let rt = tokio::runtime::Runtime::new().unwrap(); + let mut connections = rt.block_on(create_test_connections(3)); + let now = now_ms(); + + // uplink 0 sent 10/20/30 ; uplink 1 sent 15/25 ; uplink 2 sent 100 (beyond ack) + connections[0].register_packet(10, now); + connections[0].register_packet(20, now); + connections[0].register_packet(30, now); + connections[1].register_packet(15, now); + connections[1].register_packet(25, now); + connections[2].register_packet(100, now); + assert_eq!(connections[0].in_flight_packets, 3); + assert_eq!(connections[1].in_flight_packets, 2); + assert_eq!(connections[2].in_flight_packets, 1); + + // Broadcast a cumulative ACK of 30 to every uplink, exactly as + // process_connection_events does (`for c in connections { c.handle_srt_ack }`). + for c in connections.iter_mut() { + c.handle_srt_ack(30, now_ms()); + } + + assert_eq!( + connections[0].in_flight_packets, 0, + "uplink 0: 10/20/30 all ≤ 30, cleared" + ); + assert_eq!( + connections[1].in_flight_packets, 0, + "uplink 1: 15/25 ≤ 30, cleared" + ); + assert_eq!( + connections[2].in_flight_packets, 1, + "uplink 2: seq 100 > 30 stays in-flight (ACK is cumulative, not blanket)" + ); + } + + /// NAKs are attributed to the uplink that originally sent the sequence, + /// tracked via the `SequenceTracker`. Forward seq S on uplink 1, record it in + /// the tracker, inject a NAK for S through the production `attribute_nak`, and + /// assert only uplink 1 is penalized (nak_count++ and in-flight−−); the other + /// uplinks are untouched. + #[test] + fn nak_attributed_to_sending_uplink() { + let rt = tokio::runtime::Runtime::new().unwrap(); + let mut connections = rt.block_on(create_test_connections(3)); + let now = now_ms(); + + let seq: u32 = 500; + // Uplink 1 is the sender: register in its packet_log + record attribution. + connections[1].register_packet(seq as i32, now); + let mut seq_tracker = SequenceTracker::new(); + seq_tracker.insert(seq, connections[1].conn_id, now); + + let before: Vec = connections.iter().map(|c| c.congestion.nak_count).collect(); + let before_inflight = connections[1].in_flight_packets; + + let counted = attribute_nak(&mut connections, &seq_tracker, seq, now); + + assert_eq!( + counted, + Some(1), + "the NAK must be attributed to the uplink that sent the sequence" + ); + assert_eq!( + connections[1].congestion.nak_count, + before[1] + 1, + "sending uplink's nak_count increments" + ); + assert_eq!( + connections[1].in_flight_packets, + before_inflight - 1, + "sending uplink's in-flight decreases" + ); + assert_eq!( + connections[0].congestion.nak_count, before[0], + "uplink 0 (did not send S) is untouched" + ); + assert_eq!( + connections[2].congestion.nak_count, before[2], + "uplink 2 (did not send S) is untouched" + ); + } + + /// The NAK fallback: when the sequence is *not* tracked the sender scans + /// uplinks and lets the one still holding it in its packet_log account the + /// NAK. Because a sequence only ever lives in its real sender's packet_log, + /// the fallback still lands on the originating uplink. A sequence held by NO + /// uplink is silently ignored, never double-counted, never a panic. + #[test] + fn nak_unknown_uplink_fallback() { + let rt = tokio::runtime::Runtime::new().unwrap(); + let mut connections = rt.block_on(create_test_connections(3)); + let now = now_ms(); + let seq_tracker = SequenceTracker::new(); // deliberately empty: nothing tracked + + // (a) untracked but present in uplink 2's packet_log → fallback finds it. + let known: u32 = 700; + connections[2].register_packet(known as i32, now); + let before2 = connections[2].congestion.nak_count; + + let counted = attribute_nak(&mut connections, &seq_tracker, known, now); + assert_eq!( + counted, + Some(2), + "fallback attributes the NAK to the uplink still holding the sequence" + ); + assert_eq!(connections[2].congestion.nak_count, before2 + 1); + assert_eq!(connections[0].congestion.nak_count, 0); + assert_eq!(connections[1].congestion.nak_count, 0); + + // (b) truly unknown: not tracked and in no packet_log → no-op. + let counts_before: Vec = connections.iter().map(|c| c.congestion.nak_count).collect(); + let counted_unknown = attribute_nak(&mut connections, &seq_tracker, 999_999, now); + assert_eq!( + counted_unknown, None, + "a sequence no uplink holds is attributable to none" + ); + let counts_after: Vec = connections.iter().map(|c| c.congestion.nak_count).collect(); + assert_eq!( + counts_before, counts_after, + "an unattributable NAK must not perturb any uplink" + ); + } + + /// Dedup within the suppression window. Dedup is structural: `handle_nak` + /// removes the sequence from the packet_log, so a *second* NAK for the same + /// sequence finds nothing and `handle_nak` returns false (not counted). Inside + /// the `SequenceTracker` window both NAKs route to the same uplink, and the + /// attribution short-circuit keeps the duplicate from falling through to + /// another link. We assert single accounting under a *paused* virtual clock + /// (no real sleep); the tracker's own window is driven with explicit timestamps. + #[tokio::test(start_paused = true)] + async fn nak_dedup_within_window() { + let mut connections = create_test_connections(2).await; + let base = now_ms(); + + let seq: u32 = 800; + connections[0].register_packet(seq as i32, base); + let mut seq_tracker = SequenceTracker::new(); + seq_tracker.insert(seq, connections[0].conn_id, base); + assert_eq!(connections[0].in_flight_packets, 1); + + // First sighting inside the window: counted once on the sending uplink. + let first = attribute_nak(&mut connections, &seq_tracker, seq, base); + assert_eq!(first, Some(0)); + assert_eq!( + connections[0].congestion.nak_count, 1, + "first NAK is counted" + ); + assert_eq!( + connections[0].in_flight_packets, 0, + "first NAK clears the in-flight packet" + ); + + // Advance the paused clock well within the tracking window (no real sleep). + advance_test_clock(Duration::from_millis(50)).await; + let within = base + 50; + // 50ms must be inside the dedup window (checked at compile time). + const { assert!(50 < SEQUENCE_TRACKING_MAX_AGE_MS) }; + assert_eq!( + seq_tracker.get(seq, within), + Some(connections[0].conn_id), + "the tracker still resolves the sequence to uplink 0 inside the window" + ); + + // Duplicate NAK inside the window: routed to the same uplink, whose + // packet_log no longer holds the sequence → not re-counted, and the + // short-circuit keeps the other uplink clean. + let dup = attribute_nak(&mut connections, &seq_tracker, seq, within); + assert_eq!( + dup, None, + "the duplicate NAK is a no-op (single accounting)" + ); + assert_eq!( + connections[0].congestion.nak_count, 1, + "duplicate NAK within the window is NOT double-counted" + ); + assert_eq!( + connections[0].in_flight_packets, 0, + "in-flight stays cleared after the duplicate" + ); + assert_eq!( + connections[1].congestion.nak_count, 0, + "the duplicate never leaks onto another uplink" + ); + } } diff --git a/src/tests/integration_tests.rs b/src/tests/integration_tests.rs index c892e0c..498ecae 100644 --- a/src/tests/integration_tests.rs +++ b/src/tests/integration_tests.rs @@ -1,4 +1,8 @@ #![cfg(test)] +// These tests assert protocol invariants that happen to be compile-time +// constants (id lengths, packet-type bit masks); the assertions document the +// contract rather than test runtime values. +#![allow(clippy::assertions_on_constants, clippy::needless_range_loop)] use smallvec::SmallVec; diff --git a/src/tests/keepalive_interop_tests.rs b/src/tests/keepalive_interop_tests.rs new file mode 100644 index 0000000..4295ffc --- /dev/null +++ b/src/tests/keepalive_interop_tests.rs @@ -0,0 +1,169 @@ +//! Keepalive interop conformance. +//! +//! Pins the keepalive divergence with the reference srtla receiver and the +//! defensive-parse contract that lets the two interoperate: +//! +//! - A reference srtla receiver may send a *bare* 2-byte keepalive: the type +//! only (`htobe16(SRTLA_TYPE_KEEPALIVE)`), no timestamp. +//! - This sender uses a timestamped keepalive: a standard 10-byte frame (type + +//! `u64` ms timestamp) and a backwards-compatible *extended* 38-byte frame +//! (timestamp + a `0xC01F`-tagged `ConnectionInfo` telemetry trailer). +//! +//! These tests assert (a) our extended keepalive builds → parses → preserves its +//! RTT fields end-to-end through the real receive path, and (b)/(c) that a bare +//! 2-byte echo, and any truncated/oversized frame, is handled gracefully (no +//! error, no panic). They do not change the wire format. + +#[cfg(test)] +mod tests { + use crate::connection::RttTracker; + use crate::protocol::*; + + // Fixed virtual clock: the receive path takes `now` as an argument, so these + // interop tests exercise the wire format at a chosen instant, no real clock. + const T0: u64 = 1_000_000; + + /// (a) Our extended keepalive builds → parses → RTT fields preserved. + /// + /// Two round-trips in one: the `ConnectionInfo` telemetry survives a + /// build→parse cycle byte-for-byte (including `rtt_ms`), AND the standard + /// timestamp at bytes 2-9 still yields a correct RTT measurement through + /// the real receive path (`RttTracker::handle_keepalive_response`) even + /// though 28 extra extended bytes trail it. + #[test] + fn keepalive_extended_round_trip() { + let info = ConnectionInfo { + conn_id: 7, + window: 31_000, + in_flight: 12, + rtt_ms: 87, + nak_count: 4, + bitrate_bytes_per_sec: 3_125_000, + }; + + let pkt = create_keepalive_packet_ext(info); + assert_eq!(pkt.len(), SRTLA_KEEPALIVE_EXT_LEN); + assert_eq!(get_packet_type(&pkt), Some(SRTLA_TYPE_KEEPALIVE)); + assert!(is_srtla_keepalive(&pkt)); + + // Telemetry round-trip: every ConnectionInfo field preserved. + let parsed = extract_keepalive_conn_info(&pkt).expect("extended conn info parses"); + assert_eq!(parsed, info, "ConnectionInfo must round-trip byte-for-byte"); + assert_eq!(parsed.rtt_ms, 87, "rtt_ms field preserved across the wire"); + + // RTT measurement round-trip: craft an extended keepalive whose + // timestamp is a known interval in the past, echo it back through the + // real receive path, and confirm a plausible RTT sample is recovered + // from bytes 2-9 despite the extended trailer. + let mut tracker = RttTracker::default(); + tracker.record_keepalive_sent(T0); + assert!(tracker.waiting_for_keepalive_response); + + let sent_ts = T0.saturating_sub(50); + let mut echo = create_keepalive_packet_ext(info); + echo[2..10].copy_from_slice(&sent_ts.to_be_bytes()); + + let measured = tracker + .handle_keepalive_response(&echo, "interop", T0) + .expect("extended keepalive echo yields an RTT sample"); + assert!( + (40..=10_000).contains(&measured), + "measured RTT {measured}ms should reflect the ~50ms backdated timestamp" + ); + assert!( + tracker.kalman_rtt.is_initialized(), + "a valid extended-keepalive RTT sample must seed the filter" + ); + assert!( + !tracker.waiting_for_keepalive_response, + "the keepalive-wait flag must clear after a valid echo" + ); + } + + /// (b) A bare 2-byte keepalive echo is accepted without error or panic + /// (defensive parse). + /// + /// A reference srtla receiver may echo a bare `[0x90, 0x00]` keepalive (type + /// only, no timestamp). Our receive path must tolerate it: it is recognised + /// as a keepalive, yields no timestamp/telemetry (too short), and the RTT + /// path returns `None` cleanly instead of panicking. With no timestamp no + /// RTT can be measured off a bare echo. + #[test] + fn keepalive_bare_2byte_accepted() { + let bare: [u8; 2] = SRTLA_TYPE_KEEPALIVE.to_be_bytes(); + + // Recognised as a keepalive by the discriminator… + assert_eq!(get_packet_type(&bare), Some(SRTLA_TYPE_KEEPALIVE)); + assert!(is_srtla_keepalive(&bare)); + + // …but too short to carry a timestamp or extended telemetry: both + // return None, gracefully (no panic, no unwrap). + assert_eq!(extract_keepalive_timestamp(&bare), None); + assert!(extract_keepalive_conn_info(&bare).is_none()); + + // The real receive path tolerates the bare echo: no RTT sample, no + // panic, and the waiting flag is cleared so the next keepalive cycle + // is not wedged. + let mut tracker = RttTracker::default(); + tracker.record_keepalive_sent(T0); + let measured = tracker.handle_keepalive_response(&bare, "interop-bare", T0); + assert_eq!(measured, None, "a bare 2-byte echo yields no RTT sample"); + assert!( + !tracker.kalman_rtt.is_initialized(), + "a bare echo must not seed the RTT filter" + ); + assert!( + !tracker.waiting_for_keepalive_response, + "the keepalive-wait flag must clear after handling a bare echo" + ); + } + + /// (c) Truncated and oversized keepalive frames are handled gracefully — + /// every length from empty to past the extended frame parses without a + /// panic, returning None/empty as the length contract dictates. + #[test] + fn keepalive_truncated_graceful() { + let mut tracker = RttTracker::default(); + + for len in 0..=64usize { + let mut buf = vec![0u8; len]; + if len >= 2 { + buf[0..2].copy_from_slice(&SRTLA_TYPE_KEEPALIVE.to_be_bytes()); + } + + // None of these may panic at any length. + let _ = get_packet_type(&buf); + let _ = extract_keepalive_timestamp(&buf); + let _ = extract_keepalive_conn_info(&buf); + + // The receive path must never panic on a malformed echo. Re-arm + // before each call so the guard branch is actually exercised. + tracker.record_keepalive_sent(T0); + let _ = tracker.handle_keepalive_response(&buf, "interop-trunc", T0); + + // Length-specific contract: a timestamp needs >= 10 bytes; the + // extended telemetry needs the full 38-byte frame (magic+version). + if len < 10 { + assert_eq!(extract_keepalive_timestamp(&buf), None); + } + if len < SRTLA_KEEPALIVE_EXT_LEN { + assert!(extract_keepalive_conn_info(&buf).is_none()); + } + } + + // Oversized frame (well beyond the 38-byte extended keepalive): the + // trailing bytes are ignored, the standard timestamp still reads, and + // nothing panics. With no 0xC01F magic at bytes 10-11 it is NOT parsed + // as extended telemetry. + let mut oversized = vec![0u8; MTU]; + oversized[0..2].copy_from_slice(&SRTLA_TYPE_KEEPALIVE.to_be_bytes()); + let ts = T0.saturating_sub(20); + oversized[2..10].copy_from_slice(&ts.to_be_bytes()); + assert_eq!(get_packet_type(&oversized), Some(SRTLA_TYPE_KEEPALIVE)); + assert!(extract_keepalive_timestamp(&oversized).is_some()); + assert!( + extract_keepalive_conn_info(&oversized).is_none(), + "oversized frame without the 0xC01F magic must not parse as extended" + ); + } +} diff --git a/src/tests/mod.rs b/src/tests/mod.rs index 6aa10f8..d04b091 100644 --- a/src/tests/mod.rs +++ b/src/tests/mod.rs @@ -10,14 +10,17 @@ pub mod config_tests; #[cfg(test)] pub mod sender_tests; +#[cfg(test)] +pub mod stall_deselect_tests; + #[cfg(test)] pub mod protocol_tests; #[cfg(test)] -pub mod integration_tests; +pub mod keepalive_interop_tests; #[cfg(test)] -pub mod end_to_end_tests; +pub mod integration_tests; #[cfg(test)] -pub mod rtt_threshold_tests; +pub mod end_to_end_tests; diff --git a/src/tests/protocol_tests.rs b/src/tests/protocol_tests.rs index eda2e78..ec93ca1 100644 --- a/src/tests/protocol_tests.rs +++ b/src/tests/protocol_tests.rs @@ -1,5 +1,9 @@ #[cfg(test)] mod tests { + // Protocol invariant assertions over compile-time constants document the + // contract rather than test runtime values. + #![allow(clippy::assertions_on_constants)] + use crate::protocol::*; #[test] @@ -264,3 +268,212 @@ mod tests { assert!(WINDOW_MULT > 0); } } + +// Frozen on-wire byte pins for the registration handshake (REG1/REG2 = 258 B, +// REG3 = 2 B) and the bare keepalive (2 B). A failure here means a layout or +// constant drifted and wire compatibility with the receiver broke. +// Top-level module so `cargo test protocol_tests::encode` selects exactly this group. +#[cfg(test)] +mod encode { + use crate::protocol::*; + + #[test] + fn reg1_first_two_bytes_and_total_len() { + let id = [0xabu8; SRTLA_ID_LEN]; + let buf = create_reg1_packet(&id); + + assert_eq!(&buf[0..2], &[0x92u8, 0x00], "REG1 type must be 0x9200 BE"); + assert_eq!(buf.len(), 258, "REG1 frame is exactly 258 bytes"); + } + + #[test] + fn reg2_first_two_bytes_and_total_len() { + let id = [0xcdu8; SRTLA_ID_LEN]; + let buf = create_reg2_packet(&id); + + assert_eq!(&buf[0..2], &[0x92u8, 0x01], "REG2 type must be 0x9201 BE"); + assert_eq!(buf.len(), 258, "REG2 frame is exactly 258 bytes"); + } + + #[test] + fn reg3_type_and_len() { + // REG3 has no builder: the receiver emits the bare 2-byte type frame and + // the sender echo-handles it. Pin its wire form from the frozen constant. + let buf = SRTLA_TYPE_REG3.to_be_bytes(); + + assert_eq!(&buf[..], &[0x92u8, 0x02], "REG3 type must be 0x9202 BE"); + assert_eq!(buf.len(), 2, "REG3 frame is exactly 2 bytes"); + } + + #[test] + fn keepalive_is_bare_2_bytes() { + // Caveat that prevents a false "fix": the live send_keepalive() emits the + // backwards-compatible extended 38-byte keepalive, not this bare form. + // This pins the minimal 2-byte keepalive the protocol still guarantees; + // it does not assert which form the sender emits. + let buf = SRTLA_TYPE_KEEPALIVE.to_be_bytes(); + + assert_eq!( + &buf[..], + &[0x90u8, 0x00], + "bare KEEPALIVE type must be 0x9000 BE" + ); + assert_eq!(buf.len(), 2, "bare KEEPALIVE frame is exactly 2 bytes"); + assert!( + !buf.windows(2) + .any(|w| w == SRTLA_KEEPALIVE_MAGIC.to_be_bytes()), + "bare KEEPALIVE must not contain the 0xC01F extended magic" + ); + } +} + +// REG3/REG_ERR/REG_NGP arrive as bare 2-byte type frames (the receiver's +// pad_sendto 32 B padding is ignored), so the sender's "decode" of them is the +// get_packet_type discriminator plus the length-checked is_srtla_* validators. +#[cfg(test)] +mod decode { + use crate::protocol::*; + + #[test] + fn decode_reg2_valid() { + let id = [0x5au8; SRTLA_ID_LEN]; + let pkt = create_reg2_packet(&id); + + assert_eq!(get_packet_type(&pkt), Some(SRTLA_TYPE_REG2)); + assert!(is_srtla_reg2(&pkt)); + assert!(!is_srtla_reg1(&pkt)); + assert!(!is_srtla_reg3(&pkt)); + assert_eq!(&pkt[2..], &id[..]); + } + + #[test] + fn decode_reg3_valid() { + let pkt = SRTLA_TYPE_REG3.to_be_bytes(); + + assert_eq!(get_packet_type(&pkt), Some(SRTLA_TYPE_REG3)); + assert!(is_srtla_reg3(&pkt)); + assert!(!is_srtla_reg1(&pkt)); + assert!(!is_srtla_reg2(&pkt)); + } + + #[test] + fn decode_reg_err_valid() { + let pkt = SRTLA_TYPE_REG_ERR.to_be_bytes(); + + assert_eq!(get_packet_type(&pkt), Some(SRTLA_TYPE_REG_ERR)); + assert!(!is_srtla_reg1(&pkt)); + assert!(!is_srtla_reg2(&pkt)); + assert!(!is_srtla_reg3(&pkt)); + } + + #[test] + fn decode_reg_ngp_valid() { + let pkt = SRTLA_TYPE_REG_NGP.to_be_bytes(); + + assert_eq!(get_packet_type(&pkt), Some(SRTLA_TYPE_REG_NGP)); + assert!(!is_srtla_reg1(&pkt)); + assert!(!is_srtla_reg2(&pkt)); + assert!(!is_srtla_reg3(&pkt)); + } + + #[test] + fn decode_ack_valid() { + let acks = [1234u32, 5678, 9012]; + let pkt = create_ack_packet(&acks); + + assert_eq!(get_packet_type(&pkt), Some(SRTLA_TYPE_ACK)); + let parsed = parse_srtla_ack(&pkt); + assert_eq!(parsed.as_slice(), &acks[..]); + } + + #[test] + fn decode_srt_ack_nak() { + let mut ack = vec![0u8; 20]; + ack[0..2].copy_from_slice(&SRT_TYPE_ACK.to_be_bytes()); + ack[16..20].copy_from_slice(&424_242u32.to_be_bytes()); // ack seq at bytes 16..20 + + assert_eq!(get_packet_type(&ack), Some(SRT_TYPE_ACK)); + assert!(is_srt_ack(&ack)); + assert_eq!(parse_srt_ack(&ack), Some(424_242)); + + let mut nak = vec![0u8; 8]; + nak[0..2].copy_from_slice(&SRT_TYPE_NAK.to_be_bytes()); + nak[4..8].copy_from_slice(&777u32.to_be_bytes()); // single lost seq at bytes 4..8 + + assert_eq!(get_packet_type(&nak), Some(SRT_TYPE_NAK)); + let parsed = parse_srt_nak(&nak); + assert_eq!(parsed.as_slice(), &[777]); + } + + #[test] + fn decode_keepalive_valid() { + let pkt = create_keepalive_packet(); + + assert_eq!(get_packet_type(&pkt), Some(SRTLA_TYPE_KEEPALIVE)); + assert!(is_srtla_keepalive(&pkt)); + assert!(extract_keepalive_timestamp(&pkt).is_some()); + } +} + +// Pins the graceful-rejection contract: every degenerate input returns +// None/empty, never panics. The parsers return Option/SmallVec by design, so +// these tests guard against an upstream merge regressing that into an unwrap. +#[cfg(test)] +mod malformed { + use crate::protocol::*; + + #[test] + fn zero_length_returns_none_or_err() { + let empty: &[u8] = &[]; + + assert_eq!(get_packet_type(empty), None); + assert_eq!(get_srt_sequence_number(empty), None); + assert_eq!(parse_srt_ack(empty), None); + assert_eq!(extract_keepalive_timestamp(empty), None); + assert!(extract_keepalive_conn_info(empty).is_none()); + assert!(parse_srt_nak(empty).is_empty()); + assert!(parse_srtla_ack(empty).is_empty()); + } + + #[test] + fn truncated_id_returns_none_or_err() { + let mut buf = vec![0u8; 2 + SRTLA_ID_LEN / 2]; + buf[0..2].copy_from_slice(&SRTLA_TYPE_REG2.to_be_bytes()); + + // Length-checked validator rejects the half-length id, yet the bare + // 2-byte type still reads cleanly without panicking. + assert!(!is_srtla_reg2(&buf)); + assert!(!is_srtla_reg1(&buf)); + assert_eq!(get_packet_type(&buf), Some(SRTLA_TYPE_REG2)); + } + + #[test] + fn unknown_type_returns_none_or_err() { + let mut buf = vec![0u8; 20]; + buf[0..2].copy_from_slice(&0x9999u16.to_be_bytes()); + + assert_eq!(parse_srt_ack(&buf), None); + assert_eq!(extract_keepalive_timestamp(&buf), None); + assert!(extract_keepalive_conn_info(&buf).is_none()); + assert!(parse_srt_nak(&buf).is_empty()); + assert!(parse_srtla_ack(&buf).is_empty()); + assert!(!is_srtla_reg1(&buf)); + assert!(!is_srtla_reg2(&buf)); + assert!(!is_srtla_reg3(&buf)); + assert!(!is_srtla_keepalive(&buf)); + assert!(!is_srt_ack(&buf)); + } + + #[test] + fn short_frame_returns_none_or_err() { + let one = [0x91u8]; + + assert_eq!(get_packet_type(&one), None); + assert_eq!(get_srt_sequence_number(&one), None); + assert_eq!(parse_srt_ack(&one), None); + assert_eq!(extract_keepalive_timestamp(&one), None); + assert!(extract_keepalive_conn_info(&one).is_none()); + assert!(parse_srt_nak(&one).is_empty()); + assert!(parse_srtla_ack(&one).is_empty()); + } +} diff --git a/src/tests/registration_tests.rs b/src/tests/registration_tests.rs index 931f905..e4dd947 100644 --- a/src/tests/registration_tests.rs +++ b/src/tests/registration_tests.rs @@ -1,6 +1,8 @@ #[cfg(test)] mod tests { + + use crate::connection::STARTUP_GRACE_MS; use crate::protocol::*; use crate::registration::*; use crate::test_helpers::create_test_connection; @@ -29,7 +31,7 @@ mod tests { buf[0..2].copy_from_slice(&SRTLA_TYPE_REG_NGP.to_be_bytes()); // Process REG_NGP from connection 1 - let handled = reg.process_registration_packet(1, &buf); + let handled = reg.process_registration_packet(1, &buf, now_ms()); assert!(handled.is_some()); assert_eq!(reg.reg1_target_idx(), Some(1)); @@ -50,7 +52,7 @@ mod tests { modified_id[SRTLA_ID_LEN / 2..].fill(0xab); // Server modifies last half let buf = create_reg2_packet(&modified_id); - let handled = reg.process_registration_packet(0, &buf); + let handled = reg.process_registration_packet(0, &buf, now_ms()); assert!(handled.is_some()); // Should have updated the ID and set broadcast pending @@ -70,7 +72,7 @@ mod tests { // Create REG3 packet let buf = vec![(SRTLA_TYPE_REG3 >> 8) as u8, (SRTLA_TYPE_REG3 & 0xff) as u8]; - let handled = reg.process_registration_packet(2, &buf); + let handled = reg.process_registration_packet(2, &buf, now_ms()); assert!(handled.is_some()); // REG3 should set has_connected flag @@ -91,7 +93,7 @@ mod tests { let mut buf = vec![0u8; 4]; buf[0..2].copy_from_slice(&SRTLA_TYPE_REG_ERR.to_be_bytes()); - let handled = reg.process_registration_packet(1, &buf); + let handled = reg.process_registration_packet(1, &buf, now_ms()); assert!(handled.is_some()); // Should clear pending state and wait for a new REG_NGP before retrying @@ -110,7 +112,7 @@ mod tests { let mut buf = vec![0u8; 4]; buf[0..2].copy_from_slice(&SRT_TYPE_ACK.to_be_bytes()); - let handled = reg.process_registration_packet(0, &buf); + let handled = reg.process_registration_packet(0, &buf, now_ms()); assert!(handled.is_none()); } @@ -121,7 +123,7 @@ mod tests { let mut ngp = vec![0u8; 2]; ngp[0..2].copy_from_slice(&SRTLA_TYPE_REG_NGP.to_be_bytes()); - reg.process_registration_packet(0, &ngp); + reg.process_registration_packet(0, &ngp, now_ms()); // Should send REG1 to first connection when no connections are active reg.reg_driver_send_if_needed(&mut connections).await; @@ -158,7 +160,7 @@ mod tests { let mut ngp = vec![0u8; 2]; ngp[0..2].copy_from_slice(&SRTLA_TYPE_REG_NGP.to_be_bytes()); - reg.process_registration_packet(0, &ngp); + reg.process_registration_packet(0, &ngp, now_ms()); reg.reg_driver_send_if_needed(&mut connections).await; assert_eq!(reg.pending_reg2_idx(), Some(0)); @@ -246,7 +248,7 @@ mod tests { // Simulate multiple REG3 responses for i in 0..3 { - let handled = reg.process_registration_packet(i, ®3_packet); + let handled = reg.process_registration_packet(i, ®3_packet, now_ms()); assert!(handled.is_some()); } @@ -287,7 +289,7 @@ mod tests { // After REG_NGP let ngp_packet = [0x92, 0x11, 0x00, 0x00]; - reg.process_registration_packet(0, &ngp_packet); + reg.process_registration_packet(0, &ngp_packet, now_ms()); assert_eq!(reg.reg1_target_idx(), Some(0)); // Set up for REG2 @@ -297,14 +299,14 @@ mod tests { let mut modified_id = reg.srtla_id; modified_id[SRTLA_ID_LEN / 2..].fill(0xff); let reg2_packet = create_reg2_packet(&modified_id); - reg.process_registration_packet(0, ®2_packet); + reg.process_registration_packet(0, ®2_packet, now_ms()); assert!(reg.broadcast_reg2_pending()); assert_eq!(reg.pending_reg2_idx(), None); // Process REG3 let reg3_packet = vec![0x92, 0x02]; - reg.process_registration_packet(0, ®3_packet); + reg.process_registration_packet(0, ®3_packet, now_ms()); assert!(reg.has_connected); @@ -344,7 +346,7 @@ mod tests { let mut connections = vec![create_test_connection().await]; connections[0].connected = true; - connections[0].last_received = Some(tokio::time::Instant::now()); + connections[0].last_received = Some(now_ms()); reg.update_active_connections(&connections); let initial_target = reg.reg1_target_idx(); @@ -421,10 +423,10 @@ mod tests { reg.simulate_probe_result(1, 0); std::thread::sleep(std::time::Duration::from_millis(50)); - reg.handle_probe_response(0); + reg.handle_probe_response(0, now_ms()); std::thread::sleep(std::time::Duration::from_millis(50)); - reg.handle_probe_response(1); + reg.handle_probe_response(1, now_ms()); let completed = reg.check_probing_complete(); @@ -442,7 +444,7 @@ mod tests { let ngp_packet = [0x92, 0x11, 0x00, 0x00]; std::thread::sleep(std::time::Duration::from_millis(50)); - reg.process_registration_packet(0, &ngp_packet); + reg.process_registration_packet(0, &ngp_packet, now_ms()); assert!(reg.is_probing()); assert_eq!(reg.probe_results_count(), 1); @@ -460,8 +462,188 @@ mod tests { assert_eq!(reg.reg1_target_idx(), Some(0)); let ngp_packet = [0x92, 0x11, 0x00, 0x00]; - reg.process_registration_packet(1, &ngp_packet); + reg.process_registration_packet(1, &ngp_packet, now_ms()); assert_eq!(reg.reg1_target_idx(), Some(1)); } + + // Two-phase SRTLA v2 handshake, driven from the sender side: + // REG1 -> REG2(full_id) -> REG2 broadcast -> REG3. + #[tokio::test] + async fn reg_handshake_two_phase_flow() { + let mut reg = SrtlaRegistrationManager::new(); + let mut connections = vec![ + create_test_connection().await, + create_test_connection().await, + ]; + + let mut ngp = vec![0u8; 2]; + ngp[0..2].copy_from_slice(&SRTLA_TYPE_REG_NGP.to_be_bytes()); + reg.process_registration_packet(0, &ngp, now_ms()); + reg.reg_driver_send_if_needed(&mut connections).await; + assert_eq!( + reg.pending_reg2_idx(), + Some(0), + "REG1 sent on conn 0 -> awaiting REG2" + ); + + let sender_prefix = reg.srtla_id; + let mut full_id = sender_prefix; + full_id[SRTLA_ID_LEN / 2..].fill(0x5a); + reg.process_registration_packet(0, &create_reg2_packet(&full_id), now_ms()); + + assert_eq!(reg.srtla_id, full_id, "conn 0 adopts the receiver full_id"); + assert!(reg.broadcast_reg2_pending(), "REG2 broadcast queued"); + assert_eq!(reg.pending_reg2_idx(), None, "REG2 received clears pending"); + + let broadcast = create_reg2_packet(®.srtla_id); + assert_eq!( + &broadcast[2..], + &full_id[..], + "broadcast REG2 carries the full_id to conn N (not just conn 0)" + ); + reg.reg_driver_send_if_needed(&mut connections).await; + assert!( + !reg.broadcast_reg2_pending(), + "REG2 broadcast consumed after sending to all uplinks" + ); + + let reg3 = vec![(SRTLA_TYPE_REG3 >> 8) as u8, (SRTLA_TYPE_REG3 & 0xff) as u8]; + for idx in 0..connections.len() { + assert!( + reg.process_registration_packet(idx, ®3, now_ms()).is_some(), + "REG3 on conn {idx} handled" + ); + } + assert!(reg.has_connected(), "REG3 marks the handshake complete"); + } + + // REG2 reply echoes the client id in full_id: the first SRTLA_ID_LEN/2 bytes + // echo the sender id, the tail is receiver-substituted. + #[test] + fn full_id_propagation_byte_wise() { + let mut reg = SrtlaRegistrationManager::new(); + reg.set_pending_reg2_idx(Some(0)); + + let sender_id = reg.srtla_id; + let half = SRTLA_ID_LEN / 2; + + let mut full_id = sender_id; + for b in full_id[half..].iter_mut() { + *b = 0xc3; + } + reg.process_registration_packet(0, &create_reg2_packet(&full_id), now_ms()); + + assert_eq!( + ®.srtla_id[..half], + &sender_id[..half], + "first half (sender prefix) must be preserved byte-for-byte" + ); + assert_eq!( + ®.srtla_id[half..], + &full_id[half..], + "second half must equal the receiver-substituted tail" + ); + for (i, &b) in reg.srtla_id[half..].iter().enumerate() { + assert_eq!(b, 0xc3, "tail byte {i} not substituted"); + } + } + + // Registration timing is wall-clock (now_ms == SystemTime), so the timeout is + // exercised through the production seam clear_pending_if_timed_out with explicit + // logical now values — never a real sleep; the paused clock keeps it deterministic. + #[tokio::test(start_paused = true)] + async fn reg2_timeout_fires_at_4s_logical() { + let mut reg = SrtlaRegistrationManager::new(); + let mut conn = create_test_connection().await; + + let base = now_ms(); + reg.send_reg1_to(0, &mut conn).await; + assert_eq!(reg.pending_reg2_idx(), Some(0)); + + let deadline = reg.pending_timeout_at_ms(); + assert!( + deadline >= base + REG2_TIMEOUT * 1000 && deadline <= now_ms() + REG2_TIMEOUT * 1000, + "REG2 deadline must be REG2_TIMEOUT (4s) past the REG1 send" + ); + + assert_eq!( + reg.clear_pending_if_timed_out(deadline - 1), + None, + "must not time out before REG2_TIMEOUT" + ); + assert_eq!( + reg.clear_pending_if_timed_out(deadline), + Some(0), + "REG2 wait must time out at REG2_TIMEOUT (4s)" + ); + assert_eq!(reg.pending_reg2_idx(), None, "timeout clears pending"); + assert_eq!( + reg.pending_timeout_at_ms(), + 0, + "timeout clears the deadline" + ); + } + + // handle_reg2 arms the REG3 deadline (REG3_TIMEOUT, 4s) and clears pending on + // success; re-arming pending models "REG3 never arrived" so the same seam can be + // driven to the REG3 boundary in logical time. + #[tokio::test(start_paused = true)] + async fn reg3_timeout_fires_at_4s_logical() { + let mut reg = SrtlaRegistrationManager::new(); + + reg.set_pending_reg2_idx(Some(0)); + let mut full_id = reg.srtla_id; + full_id[SRTLA_ID_LEN / 2..].fill(0x7e); + + let base = now_ms(); + reg.process_registration_packet(0, &create_reg2_packet(&full_id), now_ms()); + + let deadline = reg.pending_timeout_at_ms(); + assert!( + deadline >= base + REG3_TIMEOUT * 1000 && deadline <= now_ms() + REG3_TIMEOUT * 1000, + "REG3 deadline must be REG3_TIMEOUT (4s) past the received REG2" + ); + + reg.set_pending_reg2_idx(Some(0)); + assert_eq!( + reg.clear_pending_if_timed_out(deadline - 1), + None, + "must not time out before REG3_TIMEOUT" + ); + assert_eq!( + reg.clear_pending_if_timed_out(deadline), + Some(0), + "REG3 wait must time out at REG3_TIMEOUT (4s)" + ); + } + + // A fresh link (last_received == None, not yet connected) drives registration, + // never reconnection, while it is inside its startup grace window. is_timed_out + // compares now_ms() against the grace deadline, so the test picks the deadline + // rather than advancing a virtual clock. + #[tokio::test] + async fn fresh_link_not_timed_out() { + let mut conn = create_test_connection().await; + + conn.connected = false; + conn.last_received = None; + conn.reconnection.connection_established_ms = 0; + + // Within the grace window: never-received link is not timed out, so + // housekeeping keeps driving registration instead of reconnection. + conn.reconnection.startup_grace_deadline_ms = now_ms() + STARTUP_GRACE_MS; + assert!( + !conn.is_timed_out(now_ms()), + "fresh never-received link within grace must NOT be timed out" + ); + + // Past the grace deadline: a never-established link that never received + // data is now timed out, which is what drives re-registration. + conn.reconnection.startup_grace_deadline_ms = now_ms().saturating_sub(1); + assert!( + conn.is_timed_out(now_ms()), + "a fresh link past its startup grace deadline must be timed out" + ); + } } diff --git a/src/tests/rtt_threshold_tests.rs b/src/tests/rtt_threshold_tests.rs deleted file mode 100644 index db258c9..0000000 --- a/src/tests/rtt_threshold_tests.rs +++ /dev/null @@ -1,278 +0,0 @@ -#[cfg(all(test, feature = "test-internals"))] -mod tests { - use crate::sender::selection::rtt_threshold::select_connection; - use crate::test_helpers::create_test_connections; - use crate::utils::now_ms; - - #[test] - fn test_prefers_fast_link() { - // Two links with different RTTs - should prefer the fast one - let rt = tokio::runtime::Runtime::new().unwrap(); - let mut connections = rt.block_on(create_test_connections(2)); - - let current_time = now_ms(); - - // Connection 0: Low RTT (50ms) - connections[0].rtt.kalman_rtt.update(50.0); - connections[0].in_flight_packets = 0; - - // Connection 1: High RTT (200ms) - connections[1].rtt.kalman_rtt.update(200.0); - connections[1].in_flight_packets = 0; - - // With 30ms delta, only connection 0 (50ms) is "fast" - // Connection 1 (200ms) is above threshold (50 + 30 = 80ms) - let selected = select_connection(&mut connections, None, 0, current_time, 30, true); - - assert_eq!(selected, Some(0), "Should prefer fast link (low RTT)"); - } - - #[test] - fn test_both_fast_picks_better_capacity() { - // Two links both within RTT threshold - picks higher capacity - let rt = tokio::runtime::Runtime::new().unwrap(); - let mut connections = rt.block_on(create_test_connections(2)); - - let current_time = now_ms(); - - // Connection 0: 50ms RTT, lower capacity - connections[0].rtt.kalman_rtt.update(50.0); - connections[0].in_flight_packets = 5; // Lower score - - // Connection 1: 70ms RTT (within 30ms delta), higher capacity - connections[1].rtt.kalman_rtt.update(70.0); - connections[1].in_flight_packets = 0; // Higher score - - // Both are "fast" (within 50 + 30 = 80ms), should pick higher capacity - let selected = select_connection(&mut connections, None, 0, current_time, 30, true); - - assert_eq!( - selected, - Some(1), - "Among fast links, should pick higher capacity" - ); - } - - #[test] - fn test_fallback_when_fast_saturated() { - // Fast link at 0 capacity - should fallback to slow link - let rt = tokio::runtime::Runtime::new().unwrap(); - let mut connections = rt.block_on(create_test_connections(2)); - - let current_time = now_ms(); - - // Connection 0: Fast but saturated (window=0) - connections[0].rtt.kalman_rtt.update(50.0); - connections[0].window = 0; - connections[0].in_flight_packets = 10; - - // Connection 1: Slow but has capacity - connections[1].rtt.kalman_rtt.update(200.0); - connections[1].window = 100; - connections[1].in_flight_packets = 0; - - let selected = select_connection(&mut connections, None, 0, current_time, 30, true); - - assert_eq!( - selected, - Some(1), - "Should fallback to slow link when fast is saturated" - ); - } - - #[test] - fn test_quality_within_fast_links() { - // Two fast links, one with recent NAKs - should pick cleaner one - let rt = tokio::runtime::Runtime::new().unwrap(); - let mut connections = rt.block_on(create_test_connections(2)); - - let current_time = now_ms(); - - // Connection 0: Fast, equal capacity, but has recent NAKs - connections[0].rtt.kalman_rtt.update(50.0); - connections[0].in_flight_packets = 0; - connections[0].congestion.nak_count = 5; - connections[0].congestion.last_nak_time_ms = current_time - 1000; - // Set connection established time to beyond startup grace - connections[0].reconnection.connection_established_ms = current_time - 35000; - - // Connection 1: Fast, equal capacity, no NAKs - connections[1].rtt.kalman_rtt.update(60.0); // Still fast (within delta) - connections[1].in_flight_packets = 0; - connections[1].congestion.nak_count = 0; - // Set connection established time to beyond startup grace - connections[1].reconnection.connection_established_ms = current_time - 35000; - - // With quality enabled, should prefer connection 1 (no NAKs) - let selected = select_connection(&mut connections, None, 0, current_time, 30, true); - - assert_eq!( - selected, - Some(1), - "Among fast links, should prefer one with better quality" - ); - } - - #[test] - fn test_no_rtt_data_treated_as_fast() { - // Links without RTT samples should be treated as fast - let rt = tokio::runtime::Runtime::new().unwrap(); - let mut connections = rt.block_on(create_test_connections(2)); - - let current_time = now_ms(); - - // Connection 0: No RTT data (0.0) - connections[0].rtt.kalman_rtt.update(0.0); - connections[0].in_flight_packets = 5; - - // Connection 1: Has RTT data - connections[1].rtt.kalman_rtt.update(100.0); - connections[1].in_flight_packets = 0; // Higher capacity - - // Connection 0 should be treated as fast (no RTT data) - // Both are eligible, should pick based on capacity - let selected = select_connection(&mut connections, None, 0, current_time, 30, true); - - assert_eq!( - selected, - Some(1), - "Should pick higher capacity when RTT data missing" - ); - } - - #[test] - fn test_rtt_threshold_with_large_delta() { - // With large delta, all links become "fast" - let rt = tokio::runtime::Runtime::new().unwrap(); - let mut connections = rt.block_on(create_test_connections(3)); - - let current_time = now_ms(); - - // Various RTTs - connections[0].rtt.kalman_rtt.update(50.0); - connections[0].in_flight_packets = 5; - - connections[1].rtt.kalman_rtt.update(150.0); - connections[1].in_flight_packets = 0; // Best capacity - - connections[2].rtt.kalman_rtt.update(200.0); - connections[2].in_flight_packets = 3; - - // With 200ms delta, all are fast (min 50 + 200 = 250ms threshold) - let selected = select_connection(&mut connections, None, 0, current_time, 200, true); - - assert_eq!( - selected, - Some(1), - "With large delta, all links fast, should pick best capacity" - ); - } - - #[test] - fn test_time_based_dampening() { - // Should stay with current connection during cooldown - let rt = tokio::runtime::Runtime::new().unwrap(); - let mut connections = rt.block_on(create_test_connections(2)); - - let current_time = now_ms(); - let last_switch_time = current_time - 5; // 5ms ago (within 15ms cooldown) - - // Connection 0: Currently selected, lower capacity - connections[0].rtt.kalman_rtt.update(50.0); - connections[0].in_flight_packets = 5; - - // Connection 1: Better link - connections[1].rtt.kalman_rtt.update(50.0); - connections[1].in_flight_packets = 0; - - let selected = select_connection( - &mut connections, - Some(0), // Currently on connection 0 - last_switch_time, - current_time, - 30, - true, - ); - - assert_eq!( - selected, - Some(0), - "Should stay with current connection during cooldown" - ); - - // After cooldown, should switch - let after_cooldown = current_time - 20; // 20ms ago (past 15ms cooldown) - let selected_after = select_connection( - &mut connections, - Some(0), - after_cooldown, - current_time, - 30, - true, - ); - - assert_eq!( - selected_after, - Some(1), - "Should switch after cooldown expires" - ); - } - - #[test] - fn test_empty_connections() { - let mut connections: Vec = vec![]; - let result = select_connection(&mut connections, None, 0, 0, 30, true); - assert_eq!(result, None, "Should return None for empty connections"); - } - - #[test] - fn test_all_timed_out() { - use tokio::time::{Duration, Instant}; - - let rt = tokio::runtime::Runtime::new().unwrap(); - let mut connections = rt.block_on(create_test_connections(2)); - - // Timeout all connections - let timeout_instant = Instant::now() - Duration::from_secs(60); - for conn in &mut connections { - conn.last_received = Some(timeout_instant); - } - - let result = select_connection(&mut connections, None, 0, now_ms(), 30, true); - assert_eq!( - result, None, - "Should return None when all connections timed out" - ); - } - - #[test] - fn test_quality_disabled() { - // With quality disabled, should only use base capacity score - let rt = tokio::runtime::Runtime::new().unwrap(); - let mut connections = rt.block_on(create_test_connections(2)); - - let current_time = now_ms(); - - // Connection 0: Fast, good capacity, but terrible NAK history - connections[0].rtt.kalman_rtt.update(50.0); - connections[0].in_flight_packets = 0; // Best capacity - connections[0].congestion.nak_count = 100; - connections[0].congestion.last_nak_time_ms = current_time - 100; - connections[0].reconnection.connection_established_ms = current_time - 35000; - - // Connection 1: Fast, slightly worse capacity, clean history - connections[1].rtt.kalman_rtt.update(50.0); - connections[1].in_flight_packets = 1; - connections[1].congestion.nak_count = 0; - connections[1].reconnection.connection_established_ms = current_time - 35000; - - // With quality disabled, should pick connection 0 (better base capacity) - let selected = select_connection(&mut connections, None, 0, current_time, 30, false); - - assert_eq!( - selected, - Some(0), - "With quality disabled, should pick based on capacity only" - ); - } -} diff --git a/src/tests/sender_tests.rs b/src/tests/sender_tests.rs index 819b127..df0af4d 100644 --- a/src/tests/sender_tests.rs +++ b/src/tests/sender_tests.rs @@ -1,5 +1,6 @@ #[cfg(test)] mod tests { + #![allow(clippy::assertions_on_constants)] use std::io::Write; use std::net::{IpAddr, Ipv4Addr}; @@ -26,14 +27,218 @@ mod tests { let config = ConfigSnapshot { mode: SchedulingMode::Classic, quality_enabled: false, - exploration_enabled: false, - rtt_delta_ms: 30, + ..ConfigSnapshot::default() }; - let selected = select_connection_idx(&mut connections, None, 0, 0, &config); + let selected = select_connection_idx(&mut connections, None, 0, &config); assert_eq!(selected, Some(1)); } + #[test] + fn test_enhanced_skips_weak_when_alternative_exists() { + let rt = tokio::runtime::Runtime::new().unwrap(); + let mut connections = rt.block_on(create_test_connections(3)); + let current_time = now_ms(); + + // Connection 1 has the highest base score but is flagged weak. + // Connection 0 is healthy. Selection should pick 0, not 1. + connections[0].in_flight_packets = 5; + connections[1].in_flight_packets = 0; + connections[1].weak = true; + connections[2].in_flight_packets = 10; + + let config = ConfigSnapshot { + mode: SchedulingMode::Enhanced, + quality_enabled: true, + ..ConfigSnapshot::default() + }; + let selected = select_connection_idx(&mut connections, None, current_time, &config); + assert_eq!( + selected, + Some(0), + "weak connection 1 must be skipped when a non-weak alternative exists" + ); + } + + #[test] + fn test_enhanced_falls_back_when_all_weak() { + let rt = tokio::runtime::Runtime::new().unwrap(); + let mut connections = rt.block_on(create_test_connections(3)); + let current_time = now_ms(); + + // Every link is weak. Selection must still pick the best — better + // a weak link than a dropped packet. + connections[0].weak = true; + connections[0].in_flight_packets = 5; + connections[1].weak = true; + connections[1].in_flight_packets = 0; // best score among the weak + connections[2].weak = true; + connections[2].in_flight_packets = 10; + + let config = ConfigSnapshot { + mode: SchedulingMode::Enhanced, + quality_enabled: false, + ..ConfigSnapshot::default() + }; + let selected = select_connection_idx(&mut connections, None, current_time, &config); + assert_eq!( + selected, + Some(1), + "with no non-weak alternatives, selection must fall back to the best available link" + ); + } + + #[test] + fn test_enhanced_skips_in_flight_cap_when_alternative_exists() { + let rt = tokio::runtime::Runtime::new().unwrap(); + let mut connections = rt.block_on(create_test_connections(3)); + let current_time = now_ms(); + + // Connection 1 would have the best base score (lowest in_flight) + // but is over its BDP in-flight cap: cc_target_bps = 200 kbps at + // the test RTT (~200 ms) gives a cap of ~5 packets, and + // in_flight = 6 exceeds it. Connection 0 is unconstrained, so the + // capped link must be skipped even though its score is higher. + connections[0].in_flight_packets = 12; + connections[1].in_flight_packets = 6; + connections[1].cc_target_bps = 200_000; + connections[2].in_flight_packets = 20; + + let config = ConfigSnapshot { + mode: SchedulingMode::Enhanced, + quality_enabled: false, + ..ConfigSnapshot::default() + }; + let selected = select_connection_idx(&mut connections, None, current_time, &config); + assert_eq!( + selected, + Some(0), + "in-flight-capped link must be skipped when an un-gated alternative exists" + ); + } + + #[test] + fn test_enhanced_falls_back_when_all_in_flight_capped() { + let rt = tokio::runtime::Runtime::new().unwrap(); + let mut connections = rt.block_on(create_test_connections(3)); + let current_time = now_ms(); + + // Every link is over its BDP in-flight cap (cc_target = 200 kbps + // at ~200 ms RTT → cap ~5 packets). Fallback rule: pick the best + // base score rather than drop the packet. + for c in connections.iter_mut() { + c.cc_target_bps = 200_000; + c.in_flight_packets = 10; + } + connections[1].in_flight_packets = 6; // best score among the capped, still > cap + + let config = ConfigSnapshot { + mode: SchedulingMode::Enhanced, + quality_enabled: false, + ..ConfigSnapshot::default() + }; + let selected = select_connection_idx(&mut connections, None, current_time, &config); + assert_eq!( + selected, + Some(1), + "with no un-gated alternatives, selection falls back to the best capped link" + ); + } + + #[test] + fn test_enhanced_treats_loss_degraded_as_weak() { + let rt = tokio::runtime::Runtime::new().unwrap(); + let mut connections = rt.block_on(create_test_connections(3)); + let current_time = now_ms(); + + connections[0].in_flight_packets = 5; + connections[1].in_flight_packets = 0; + // Sustained loss latch (not the raw per-window cc_backing_off) is the + // routing-admission gate, so a single noisy loss window can't demote. + connections[1].loss_degraded = true; + connections[2].in_flight_packets = 10; + + let config = ConfigSnapshot { + mode: SchedulingMode::Enhanced, + quality_enabled: false, + ..ConfigSnapshot::default() + }; + let selected = select_connection_idx(&mut connections, None, current_time, &config); + assert_eq!( + selected, + Some(0), + "loss-degraded link must be skipped when a healthy alternative exists" + ); + } + + #[test] + fn test_enhanced_does_not_gate_on_raw_backing_off() { + // cc_backing_off drives the CC controller's bitrate backoff but is + // intentionally NOT a routing gate (it flips on a single loss window). + // A link flagged only cc_backing_off, with the best base score, still + // wins selection. + let rt = tokio::runtime::Runtime::new().unwrap(); + let mut connections = rt.block_on(create_test_connections(3)); + let current_time = now_ms(); + + connections[0].in_flight_packets = 5; + connections[1].in_flight_packets = 0; // best base score + connections[1].cc_backing_off = true; + connections[2].in_flight_packets = 10; + + let config = ConfigSnapshot { + mode: SchedulingMode::Enhanced, + quality_enabled: false, + ..ConfigSnapshot::default() + }; + let selected = select_connection_idx(&mut connections, None, current_time, &config); + assert_eq!( + selected, + Some(1), + "cc_backing_off alone must not demote a link's routing weight" + ); + } + + #[test] + fn test_enhanced_weak_link_stays_rankable() { + // A quality-gated link is crushed in score but not removed, so it keeps + // a trickle of traffic and can still earn the ACK/loss samples that + // clear the gate. Without that it earns zero throughput share, the + // classifier reads NoTraffic/LowShare, and it stays weak forever: a + // starvation lock. This trickle is what makes an explicit re-probing + // mechanism unnecessary (measured: a 70%-loss link gated to 0.00 Mbps + // re-adopts itself ~7s after it silently heals). + let rt = tokio::runtime::Runtime::new().unwrap(); + let mut connections = rt.block_on(create_test_connections(2)); + let current_time = now_ms(); + + let config = ConfigSnapshot { + mode: SchedulingMode::Enhanced, + quality_enabled: true, + ..ConfigSnapshot::default() + }; + + // The healthy link wins while it is healthy -- the weak link is crushed + // by GATED_LINK_PENALTY, not removed. + connections[0].in_flight_packets = 0; // healthy + connections[1].in_flight_packets = 0; + connections[1].weak = true; + let selected = select_connection_idx(&mut connections, Some(0), current_time, &config); + assert_eq!(selected, Some(0), "healthy link should win over a weak one"); + + // Crushed, but still in the ranking: once the healthy link is loaded + // enough that even a 0.02x score beats it, the weak link takes the + // packet. An *excluded* link could never do this, and would earn zero + // share forever. + connections[0].in_flight_packets = 10_000; + let selected = select_connection_idx(&mut connections, Some(0), current_time, &config); + assert_eq!( + selected, + Some(1), + "weak link must remain rankable so its trickle can clear the gate" + ); + } + #[test] fn test_select_connection_idx_quality_scoring() { let rt = tokio::runtime::Runtime::new().unwrap(); @@ -54,11 +259,10 @@ mod tests { let config = ConfigSnapshot { mode: SchedulingMode::Enhanced, quality_enabled: true, - exploration_enabled: false, - rtt_delta_ms: 30, + ..ConfigSnapshot::default() }; - let selected = select_connection_idx(&mut connections, None, 0, current_time, &config); + let selected = select_connection_idx(&mut connections, None, current_time, &config); // Should prefer connection 1 (no NAKs) assert_eq!(selected, Some(1)); @@ -83,18 +287,17 @@ mod tests { let config = ConfigSnapshot { mode: SchedulingMode::Enhanced, quality_enabled: true, - exploration_enabled: false, - rtt_delta_ms: 30, + ..ConfigSnapshot::default() }; - let selected = select_connection_idx(&mut connections, None, 0, current_time, &config); + let selected = select_connection_idx(&mut connections, None, current_time, &config); // Should prefer connection 2 (never had NAKs, best quality) assert_eq!(selected, Some(2)); } #[test] - fn test_time_based_switch_dampening_blocks_within_cooldown() { + fn test_enhanced_reselects_immediately_no_time_lock() { let rt = tokio::runtime::Runtime::new().unwrap(); let mut connections = rt.block_on(create_test_connections(3)); @@ -103,34 +306,26 @@ mod tests { connections[1].in_flight_packets = 0; // Best score connections[2].in_flight_packets = 10; // Worst score - let last_switch_time_ms = now_ms(); - let current_time_ms = last_switch_time_ms + 5; // 5ms after last switch (within 15ms cooldown) - let config = ConfigSnapshot { mode: SchedulingMode::Enhanced, quality_enabled: true, - exploration_enabled: false, - rtt_delta_ms: 30, + ..ConfigSnapshot::default() }; - // Per-packet selection: Should keep sending ALL packets via connection 0 during cooldown - // This prevents rapid thrashing between connections under bursty score changes - let selected = select_connection_idx( - &mut connections, - Some(0), - last_switch_time_ms, - current_time_ms, - &config, - ); + // There is no switch cooldown: selection must re-decide on every packet. + // `get_score()` counts queued packets as in-flight, so routing a packet + // lowers its own link's score -- that feedback loop is what bounds + // per-link queue depth, and a time lock would open it. + let selected = select_connection_idx(&mut connections, Some(0), now_ms(), &config); assert_eq!( selected, - Some(0), - "Should continue routing all packets via current connection during cooldown period" + Some(1), + "Enhanced mode must be free to switch to a better link on the very next packet" ); } #[test] - fn test_time_based_switch_dampening_allows_after_cooldown() { + fn test_enhanced_switches_to_clearly_better_connection() { let rt = tokio::runtime::Runtime::new().unwrap(); let mut connections = rt.block_on(create_test_connections(3)); @@ -139,113 +334,45 @@ mod tests { connections[1].in_flight_packets = 0; // Best score (significantly better, exceeds 2% hysteresis) connections[2].in_flight_packets = 10; // Worst score - let last_switch_time_ms = now_ms(); - let current_time_ms = last_switch_time_ms + 20; // 20ms after last switch (past 15ms cooldown) - let config = ConfigSnapshot { mode: SchedulingMode::Enhanced, quality_enabled: true, - exploration_enabled: false, - rtt_delta_ms: 30, + ..ConfigSnapshot::default() }; - // After cooldown: per-packet selection can now choose the better connection - // From this point forward, all subsequent packets will route via connection 1 - let selected = select_connection_idx( - &mut connections, - Some(0), - last_switch_time_ms, - current_time_ms, - &config, - ); - assert_eq!( - selected, - Some(1), - "Should switch per-packet routing to better connection after cooldown expires" - ); + // A link better by more than SWITCH_THRESHOLD wins the packet. + let selected = select_connection_idx(&mut connections, Some(0), now_ms(), &config); + assert_eq!(selected, Some(1), "Should route to the better connection"); } #[test] - fn test_time_based_switch_dampening_allows_if_current_invalid() { - use tokio::time::{Duration, Instant}; - + fn test_enhanced_switches_away_from_timed_out_connection() { let rt = tokio::runtime::Runtime::new().unwrap(); let mut connections = rt.block_on(create_test_connections(3)); // Setup: Connection 0 is currently selected but becomes timed out connections[0].in_flight_packets = 5; - // Simulate timeout by setting last_received to 6 seconds ago (CONN_TIMEOUT is 5 seconds) - connections[0].last_received = Some(Instant::now() - Duration::from_secs(6)); + // Simulate timeout by stamping last_received 6 seconds ago (CONN_TIMEOUT is 5 seconds) + connections[0].last_received = Some(now_ms() - 6000); connections[1].in_flight_packets = 0; // Best score connections[2].in_flight_packets = 10; - let last_switch_time_ms = now_ms(); - let current_time_ms = last_switch_time_ms + 5; // Within 15ms cooldown - let config = ConfigSnapshot { mode: SchedulingMode::Enhanced, quality_enabled: true, - exploration_enabled: false, - rtt_delta_ms: 30, + ..ConfigSnapshot::default() }; - // Cooldown is bypassed when current connection is invalid/timed out - // Per-packet selection immediately switches to valid connection - let selected = select_connection_idx( - &mut connections, - Some(0), - last_switch_time_ms, - current_time_ms, - &config, - ); + let selected = select_connection_idx(&mut connections, Some(0), now_ms(), &config); assert_eq!( selected, Some(1), - "Should immediately route packets via valid connection if current is timed out, \ - bypassing cooldown" - ); - } - - #[test] - fn test_exploration_blocked_during_cooldown() { - let rt = tokio::runtime::Runtime::new().unwrap(); - let mut connections = rt.block_on(create_test_connections(3)); - - // Setup connections with distinct scores - connections[0].in_flight_packets = 2; // Currently selected - connections[1].in_flight_packets = 0; // Best - connections[2].in_flight_packets = 1; // Second-best - - let last_switch_time_ms = now_ms(); - let current_time_ms = last_switch_time_ms + 5; // Within 15ms cooldown - - let config = ConfigSnapshot { - mode: SchedulingMode::Enhanced, - quality_enabled: true, - exploration_enabled: true, // exploration enabled - rtt_delta_ms: 30, - }; - - // Enable exploration, but should be blocked by cooldown - // This prevents exploration from causing rapid per-packet routing changes - let selected = select_connection_idx( - &mut connections, - Some(0), - last_switch_time_ms, - current_time_ms, - &config, - ); - - // Should continue routing packets via connection 0, not explore during cooldown - assert_eq!( - selected, - Some(0), - "Exploration-triggered per-packet routing changes should be blocked during cooldown" + "Should route via a valid connection when the current one has timed out" ); } #[test] - fn test_classic_mode_ignores_time_dampening() { + fn test_classic_mode_picks_highest_score() { let rt = tokio::runtime::Runtime::new().unwrap(); let mut connections = rt.block_on(create_test_connections(3)); @@ -254,25 +381,15 @@ mod tests { connections[1].in_flight_packets = 0; // Best score connections[2].in_flight_packets = 10; // Worst score - let last_switch_time_ms = now_ms(); - let current_time_ms = last_switch_time_ms + 200; // 200ms after last switch (within cooldown) - let config = ConfigSnapshot { mode: SchedulingMode::Classic, quality_enabled: false, - exploration_enabled: false, - rtt_delta_ms: 30, + ..ConfigSnapshot::default() }; // Classic mode: per-packet selection ALWAYS picks highest score connection - // No dampening, no hysteresis - matches original C implementation - let selected = select_connection_idx( - &mut connections, - Some(0), - last_switch_time_ms, - current_time_ms, - &config, - ); + // No hysteresis - matches original C implementation + let selected = select_connection_idx(&mut connections, Some(0), now_ms(), &config); // Per-packet routing immediately uses connection 1 (best score) assert_eq!( @@ -299,21 +416,21 @@ mod tests { connections[2].congestion.nak_count, ]; - let found_0 = connections[0].handle_nak(100); + let found_0 = connections[0].handle_nak(100, now_ms()); assert!(found_0); assert_eq!(connections[0].congestion.nak_count, initial_counts[0] + 1); assert_eq!(connections[1].congestion.nak_count, initial_counts[1]); assert_eq!(connections[2].congestion.nak_count, initial_counts[2]); - let found_1 = connections[1].handle_nak(200); + let found_1 = connections[1].handle_nak(200, now_ms()); assert!(found_1); assert_eq!(connections[0].congestion.nak_count, initial_counts[0] + 1); assert_eq!(connections[1].congestion.nak_count, initial_counts[1] + 1); assert_eq!(connections[2].congestion.nak_count, initial_counts[2]); - let not_found_0 = connections[0].handle_nak(999); - let not_found_1 = connections[1].handle_nak(999); - let not_found_2 = connections[2].handle_nak(999); + let not_found_0 = connections[0].handle_nak(999, now_ms()); + let not_found_1 = connections[1].handle_nak(999, now_ms()); + let not_found_2 = connections[2].handle_nak(999, now_ms()); assert!(!not_found_0); assert!(!not_found_1); assert!(!not_found_2); @@ -376,6 +493,8 @@ mod tests { seq_tracker.insert(100, connections[1].conn_id, now); seq_tracker.insert(200, connections[2].conn_id, now); + let binder: std::sync::Arc = + std::sync::Arc::new(crate::connection::SourceIpBinder); rt.block_on(apply_connection_changes( &mut connections, &new_ips, @@ -383,6 +502,7 @@ mod tests { 8080, &mut last_selected_idx, &mut seq_tracker, + &binder, )); // Should have removed some connections @@ -431,7 +551,9 @@ mod tests { ]; // This will likely fail to connect but should not panic - let connections = create_connections_from_ips(&ips, "127.0.0.1", 9999).await; + let binder: std::sync::Arc = + std::sync::Arc::new(crate::connection::SourceIpBinder); + let connections = create_connections_from_ips(&ips, "127.0.0.1", 9999, &binder).await; // Connections may be empty due to connection failures, which is OK for testing assert!(connections.len() <= ips.len()); @@ -473,34 +595,15 @@ mod tests { let config = ConfigSnapshot { mode: SchedulingMode::Enhanced, quality_enabled: false, - exploration_enabled: false, - rtt_delta_ms: 30, + ..ConfigSnapshot::default() }; - let selected = select_connection_idx(&mut connections, None, 0, 0, &config); + let selected = select_connection_idx(&mut connections, None, 0, &config); // Should return None when all connections have score -1 assert_eq!(selected, None); } - #[test] - fn test_exploration_mode() { - let rt = tokio::runtime::Runtime::new().unwrap(); - let mut connections = rt.block_on(create_test_connections(3)); - - let config = ConfigSnapshot { - mode: SchedulingMode::Enhanced, - quality_enabled: false, - exploration_enabled: true, - rtt_delta_ms: 30, - }; - - // Test exploration - this is time-dependent so we just test that it doesn't panic - let _selected = select_connection_idx(&mut connections, None, 0, 0, &config); - - // The result depends on timing, but should not panic - } - #[test] fn test_config_integration() { let config = DynamicConfig::new(); @@ -509,8 +612,6 @@ mod tests { // Default values from DynamicConfig::new() assert_eq!(snap.mode, SchedulingMode::Enhanced); assert!(snap.quality_enabled); - assert!(!snap.exploration_enabled); - assert_eq!(snap.rtt_delta_ms, 30); } #[test] @@ -586,4 +687,93 @@ mod tests { mult_burst ); } + + // ---- link phase as a weight, not a gate -------------------------------- + + #[test] + fn test_warming_link_is_schedulable() { + // At go-live EVERY link is warming. When Warming was a hard exclusion the + // candidate pool was empty and the sender dropped the stream until the + // first link was promoted. A warming link must be usable. + let rt = tokio::runtime::Runtime::new().unwrap(); + let mut connections = rt.block_on(create_test_connections(2)); + let now = now_ms(); + + for c in connections.iter_mut() { + c.phase = crate::connection::LinkPhase::Warming { + rtt_probes: 0, + entered_ms: now, + }; + } + connections[0].in_flight_packets = 5; + connections[1].in_flight_packets = 0; // best + + let config = ConfigSnapshot { + mode: SchedulingMode::Enhanced, + quality_enabled: false, + ..ConfigSnapshot::default() + }; + let selected = select_connection_idx(&mut connections, None, now, &config); + assert_eq!( + selected, + Some(1), + "an all-warming pool must still schedule, not drop the packet" + ); + } + + #[test] + fn test_warming_link_is_derated_against_a_live_one() { + // The de-rating is what Warming buys us: a link whose RTT baseline is a + // keepalive old should not take a full share while a characterised link + // is available. 0.8 x a marginally better raw score loses to Live. + let rt = tokio::runtime::Runtime::new().unwrap(); + let mut connections = rt.block_on(create_test_connections(2)); + let now = now_ms(); + + // Warming link has the better *raw* score (fewer in flight)... + connections[0].phase = crate::connection::LinkPhase::Warming { + rtt_probes: 1, + entered_ms: now, + }; + connections[0].in_flight_packets = 4; + // ...but the Live link is close enough that the 0.8 weight flips it. + connections[1].in_flight_packets = 5; + + let config = ConfigSnapshot { + mode: SchedulingMode::Enhanced, + quality_enabled: false, + ..ConfigSnapshot::default() + }; + let selected = select_connection_idx(&mut connections, None, now, &config); + assert_eq!( + selected, + Some(1), + "a warming link's 0.8 weight should cede a close call to a live link" + ); + } + + #[test] + fn test_registering_link_is_never_scheduled() { + // The one hard exclusion, and it is not a quality judgement: without REG3 + // the receiver discards data on this link, so sending is pointless. + let rt = tokio::runtime::Runtime::new().unwrap(); + let mut connections = rt.block_on(create_test_connections(2)); + let now = now_ms(); + + connections[0].phase = crate::connection::LinkPhase::Registering; + connections[0].in_flight_packets = 0; // would otherwise be the best score + connections[1].in_flight_packets = 10; + + let config = ConfigSnapshot { + mode: SchedulingMode::Enhanced, + quality_enabled: false, + ..ConfigSnapshot::default() + }; + let selected = select_connection_idx(&mut connections, None, now, &config); + assert_eq!( + selected, + Some(1), + "a link that has not completed REG3 must never be scheduled" + ); + } } diff --git a/src/tests/stall_deselect_tests.rs b/src/tests/stall_deselect_tests.rs new file mode 100644 index 0000000..65e998e --- /dev/null +++ b/src/tests/stall_deselect_tests.rs @@ -0,0 +1,206 @@ +//! Tests for the stalled-link deselect guard (`stall_deselect`, default on). +//! +//! The guard excludes a link whose in-flight backlog is high while its last +//! delivery proof (earned-ACK or keepalive-RTT sample) has gone stale, but only +//! when a healthier link can carry the traffic. It is a selection penalty only: +//! it never mutates liveness state, and a link recovers on its own once a fresh +//! delivery proof lands (no blind reprobe). + +#[cfg(test)] +mod tests { + use crate::config::{ConfigSnapshot, STALL_ACK_STALE_MS, STALL_MIN_IN_FLIGHT_PACKETS}; + use crate::mode::SchedulingMode; + use crate::sender::select_connection_idx; + use crate::test_helpers::create_test_connections; + use crate::utils::now_ms; + + /// Mark a connection as a stalled black hole at `now`: a backlog at the + /// stall threshold whose last delivery proof is older than the staleness + /// window. Kept at exactly the threshold so its raw capacity score + /// (`window / (in_flight + 1)`) still *beats* a healthier link carrying a + /// larger backlog — that way a pick against it proves the guard, not score. + fn make_stalled(conn: &mut crate::connection::SrtlaConnection, now: u64) { + conn.in_flight_packets = STALL_MIN_IN_FLIGHT_PACKETS; + conn.last_ack_or_rtt_sample_ms = now.saturating_sub(STALL_ACK_STALE_MS + 1000); + } + + /// A busy-but-healthy link: a larger backlog than [`make_stalled`] (so it + /// loses on raw score) with a fresh delivery proof (so it is never stalled). + fn make_healthy_busy(conn: &mut crate::connection::SrtlaConnection, now: u64) { + conn.in_flight_packets = STALL_MIN_IN_FLIGHT_PACKETS * 2; + conn.last_ack_or_rtt_sample_ms = now; + } + + fn enhanced() -> ConfigSnapshot { + ConfigSnapshot { + mode: SchedulingMode::Enhanced, + quality_enabled: true, + ..ConfigSnapshot::default() + } + } + + #[test] + fn stalled_link_is_skipped_when_a_healthy_alternative_exists() { + let rt = tokio::runtime::Runtime::new().unwrap(); + let mut conns = rt.block_on(create_test_connections(2)); + let now = now_ms(); + + // Link 0 would win on raw capacity (smaller backlog) but is stalled. + // Link 1 carries a larger backlog yet is healthy. The guard must pick 1 + // despite link 0's higher raw score — proving it is the guard, not score. + make_stalled(&mut conns[0], now); + make_healthy_busy(&mut conns[1], now); + + let selected = select_connection_idx(&mut conns, None, now, &enhanced()); + assert_eq!( + selected, + Some(1), + "the stalled link must be deselected in favour of the healthy one" + ); + } + + #[test] + fn gating_never_mutates_liveness_state() { + // The whole point of the improved port: no `connected`/`last_received` + // mask hack. Selection must leave the stalled link's liveness untouched. + let rt = tokio::runtime::Runtime::new().unwrap(); + let mut conns = rt.block_on(create_test_connections(2)); + let now = now_ms(); + + make_stalled(&mut conns[0], now); + conns[1].in_flight_packets = 4; + + let _ = select_connection_idx(&mut conns, None, now, &enhanced()); + + assert!(conns[0].connected, "gating must not clear `connected`"); + assert!( + conns[0].last_received.is_some(), + "gating must not clear `last_received`" + ); + assert!( + !conns[0].is_timed_out(now_ms()), + "a stall-gated link must never be treated as timed out" + ); + } + + #[test] + fn all_stalled_falls_back_to_best_never_none() { + let rt = tokio::runtime::Runtime::new().unwrap(); + let mut conns = rt.block_on(create_test_connections(3)); + let now = now_ms(); + + // Every link is stalled — better to send on a stalled link than to drop + // the packet. The "any healthy" guard means none get gated. + for c in conns.iter_mut() { + make_stalled(c, now); + } + + let selected = select_connection_idx(&mut conns, None, now, &enhanced()); + assert!( + selected.is_some(), + "with every link stalled, selection must still return a link" + ); + } + + #[test] + fn a_link_with_no_delivery_proof_yet_is_not_stalled() { + // in_flight is high but the link has never produced a delivery proof + // (sample == 0): a fresh burst must not be mistaken for a black hole. + let rt = tokio::runtime::Runtime::new().unwrap(); + let mut conns = rt.block_on(create_test_connections(2)); + let now = now_ms(); + + conns[0].in_flight_packets = STALL_MIN_IN_FLIGHT_PACKETS + 8; + conns[0].last_ack_or_rtt_sample_ms = 0; // no proof yet + conns[1].in_flight_packets = 4; + + assert!( + !conns[0].is_stalled(now, STALL_MIN_IN_FLIGHT_PACKETS, STALL_ACK_STALE_MS), + "a link with no delivery proof yet must not be classed as stalled" + ); + let _ = select_connection_idx(&mut conns, None, now, &enhanced()); + assert!(!conns[0].stall_gated, "sample==0 link must not be gated"); + } + + #[test] + fn a_fresh_delivery_proof_ungates_the_link() { + // Recovery path: no blind reprobe timer. A stale link stamped with a + // fresh proof (as the keepalive-RTT / earned-ACK sites do) is instantly + // no longer stalled, even with the backlog still full. + let rt = tokio::runtime::Runtime::new().unwrap(); + let mut conns = rt.block_on(create_test_connections(1)); + let now = now_ms(); + + make_stalled(&mut conns[0], now); + assert!(conns[0].is_stalled(now, STALL_MIN_IN_FLIGHT_PACKETS, STALL_ACK_STALE_MS)); + + conns[0].last_ack_or_rtt_sample_ms = now; // fresh keepalive-RTT / ACK + assert!( + !conns[0].is_stalled(now, STALL_MIN_IN_FLIGHT_PACKETS, STALL_ACK_STALE_MS), + "a fresh delivery proof must clear the stall immediately" + ); + } + + #[test] + fn guard_off_leaves_selection_unchanged() { + let rt = tokio::runtime::Runtime::new().unwrap(); + let mut conns = rt.block_on(create_test_connections(2)); + let now = now_ms(); + + // Link 0 stalled but has the higher raw capacity score (smaller backlog). + make_stalled(&mut conns[0], now); + make_healthy_busy(&mut conns[1], now); + + let config = ConfigSnapshot { + mode: SchedulingMode::Classic, + quality_enabled: false, + stall_deselect: false, + ..ConfigSnapshot::default() + }; + let selected = select_connection_idx(&mut conns, None, now, &config); + assert_eq!( + selected, + Some(0), + "with the guard off, the stalled link's raw score must win as before" + ); + assert!(!conns[0].stall_gated); + } + + #[test] + fn classic_mode_also_deselects_stalled_links() { + let rt = tokio::runtime::Runtime::new().unwrap(); + let mut conns = rt.block_on(create_test_connections(2)); + let now = now_ms(); + + // Stalled link 0 is the raw-score winner; only the guard demotes it. + make_stalled(&mut conns[0], now); + make_healthy_busy(&mut conns[1], now); + + let config = ConfigSnapshot { + mode: SchedulingMode::Classic, + quality_enabled: false, + ..ConfigSnapshot::default() + }; + let selected = select_connection_idx(&mut conns, None, now, &config); + assert_eq!( + selected, + Some(1), + "classic mode must also skip the stalled link when the guard is on" + ); + } + + #[test] + fn a_backlog_below_threshold_is_not_stalled() { + let rt = tokio::runtime::Runtime::new().unwrap(); + let conns = rt.block_on(create_test_connections(1)); + let now = now_ms(); + let mut c = conns.into_iter().next().unwrap(); + + c.in_flight_packets = STALL_MIN_IN_FLIGHT_PACKETS - 1; + c.last_ack_or_rtt_sample_ms = now.saturating_sub(STALL_ACK_STALE_MS + 1000); + assert!( + !c.is_stalled(now, STALL_MIN_IN_FLIGHT_PACKETS, STALL_ACK_STALE_MS), + "a link below the in-flight threshold must not be stalled regardless of staleness" + ); + } +} diff --git a/src/toml_config.rs b/src/toml_config.rs new file mode 100644 index 0000000..0ef9876 --- /dev/null +++ b/src/toml_config.rs @@ -0,0 +1,136 @@ +//! Optional TOML file configuration for srtla_send. +//! +//! Loaded at startup via `--config ` and reloaded on SIGHUP. +//! All fields use `#[serde(default)]` so a partial config file is valid. + +use std::path::Path; + +use serde::Deserialize; +use tracing::{info, warn}; + +/// Top-level TOML configuration. +#[derive(Debug, Clone, Deserialize)] +#[serde(default)] +pub struct TomlConfig { + /// Scheduling mode: classic, enhanced. + pub mode: String, + /// Disable quality scoring. + pub no_quality: bool, + /// Disable the stalled-link deselect guard (on by default). + pub no_stall_deselect: bool, + /// In-flight backlog at or above which a link becomes a stall candidate. + pub stall_min_in_flight: i32, + /// Delivery-proof staleness window (ms) before a stall candidate is deselected. + pub stall_ack_stale_ms: u64, + + // --- Congestion control --- + /// RTT velocity threshold (ms/sample) above which window recovery is halved. + pub rtt_velocity_gate: f64, + + // --- Link lifecycle --- + /// RTT probes required during warming phase before going Live. + pub warming_rtt_probes: u32, + /// Maximum time (ms) in warming phase before auto-promoting. + pub warming_timeout_ms: u64, + /// Quality threshold below which a Live link becomes Degraded. + pub degraded_quality_threshold: f64, + /// NAK burst count threshold for degradation. + pub degraded_nak_burst_threshold: i32, + /// Cooldown duration (ms) before re-entering Live from Degraded. + pub cooldown_duration_ms: u64, + + // --- Selection --- + /// Minimum time (ms) between connection switches. + pub min_switch_interval_ms: u64, + /// Switch hysteresis threshold (1.10 = 10% better required). + pub switch_hysteresis: f64, +} + +impl Default for TomlConfig { + fn default() -> Self { + Self { + mode: "enhanced".to_string(), + no_quality: false, + no_stall_deselect: false, + stall_min_in_flight: crate::config::STALL_MIN_IN_FLIGHT_PACKETS, + stall_ack_stale_ms: crate::config::STALL_ACK_STALE_MS, + rtt_velocity_gate: 2.0, + warming_rtt_probes: 2, + warming_timeout_ms: 5_000, + degraded_quality_threshold: 0.5, + degraded_nak_burst_threshold: 5, + cooldown_duration_ms: 5_000, + min_switch_interval_ms: 15, + switch_hysteresis: 1.10, + } + } +} + +impl TomlConfig { + /// Load config from a TOML file. + pub fn load(path: &Path) -> Result { + let content = + std::fs::read_to_string(path).map_err(|e| format!("failed to read {path:?}: {e}"))?; + toml::from_str(&content).map_err(|e| format!("failed to parse {path:?}: {e}")) + } + + /// Load config, logging errors and falling back to defaults. + pub fn load_or_default(path: &Path) -> Self { + match Self::load(path) { + Ok(cfg) => { + info!("loaded config from {}", path.display()); + cfg + } + Err(e) => { + warn!("config load failed: {e}, using defaults"); + Self::default() + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_defaults() { + let cfg = TomlConfig::default(); + assert_eq!(cfg.mode, "enhanced"); + assert!(!cfg.no_quality); + assert!((cfg.rtt_velocity_gate - 2.0).abs() < f64::EPSILON); + } + + #[test] + fn test_partial_toml() { + let toml_str = r#" + mode = "classic" + rtt_velocity_gate = 3.5 + "#; + let cfg: TomlConfig = toml::from_str(toml_str).unwrap(); + assert_eq!(cfg.mode, "classic"); + assert!((cfg.rtt_velocity_gate - 3.5).abs() < f64::EPSILON); + // Defaults for unspecified fields + assert!(!cfg.no_quality); + } + + #[test] + fn test_full_toml() { + let toml_str = r#" + mode = "classic" + no_quality = true + rtt_velocity_gate = 1.0 + warming_rtt_probes = 3 + warming_timeout_ms = 10000 + degraded_quality_threshold = 0.3 + degraded_nak_burst_threshold = 10 + cooldown_duration_ms = 8000 + min_switch_interval_ms = 30 + switch_hysteresis = 1.20 + "#; + let cfg: TomlConfig = toml::from_str(toml_str).unwrap(); + assert_eq!(cfg.mode, "classic"); + assert!(cfg.no_quality); + assert_eq!(cfg.warming_rtt_probes, 3); + } +} diff --git a/src/utils.rs b/src/utils.rs index 15f0893..797352a 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,35 +1,43 @@ //! Utility functions shared across the codebase -use std::sync::LazyLock; -use std::time::{SystemTime, UNIX_EPOCH}; +use std::sync::OnceLock; +use std::time::{Instant, SystemTime, UNIX_EPOCH}; -use tokio::time::Instant; - -/// Static startup instant for stable epoch-based timing calculations -/// This is initialized once at program startup and used for periodic operations -/// that need to be based on a stable reference point. -pub static STARTUP_INSTANT: LazyLock = LazyLock::new(Instant::now); - -/// Get current time in milliseconds since Unix epoch -/// Returns 0 if system time is before Unix epoch (fallback behavior) -pub fn now_ms() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_else(|_| std::time::Duration::from_millis(0)) - .as_millis() as u64 +/// Process-wide monotonic clock anchor. +/// +/// `now_ms()` must never move backwards: every timeout, RTT sample, and +/// congestion-window deadline in this codebase is a difference between two +/// `now_ms()` reads (see the keepalive-echo RTT path in `connection::rtt`, +/// where `rtt = now_ms() - echoed_stamp` and *both* stamps are ours). A wall +/// clock (`SystemTime`) can step backwards on an NTP correction, which would +/// clamp an RTT to zero or falsely reset a link's timeout. `Instant` is +/// monotonic, so we anchor to it once and report `base_ms + elapsed`. +/// +/// `base_ms` is captured from the wall clock at first read purely so the value +/// keeps an epoch-scale magnitude. Nothing depends on the absolute base (no +/// `now_ms()` value is interpreted by a peer or persisted), only on differences. +struct Clock { + anchor: Instant, + base_ms: u64, } -/// Get elapsed milliseconds since program startup -/// Uses the stable STARTUP_INSTANT for consistent periodic timing -pub fn elapsed_ms() -> u64 { - STARTUP_INSTANT.elapsed().as_millis() as u64 +fn clock() -> &'static Clock { + static CLOCK: OnceLock = OnceLock::new(); + CLOCK.get_or_init(|| Clock { + anchor: Instant::now(), + base_ms: SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0), + }) } -/// Get elapsed milliseconds since program startup for a given Instant -/// Uses the stable STARTUP_INSTANT for consistent periodic timing -pub fn instant_to_elapsed_ms(instant: Instant) -> u64 { - STARTUP_INSTANT - .elapsed() - .saturating_sub(instant.elapsed()) - .as_millis() as u64 +/// Monotonic time in milliseconds, anchored to an epoch-scale base. +/// +/// Guaranteed non-decreasing within a process. Not a true wall clock: use it +/// only for measuring elapsed time between two reads, never as a timestamp to +/// compare against another machine's clock. +pub fn now_ms() -> u64 { + let c = clock(); + c.base_ms + c.anchor.elapsed().as_millis() as u64 } diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 4c8fef3..f6a7a67 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -3,7 +3,30 @@ use std::time::Duration; -use network_sim::{SrtlaTestStack, check_impairment_deps, check_integration_deps}; +use network_sim::{ + SrtlaTestStack, check_impairment_deps, check_integration_deps, wait_for_connected_uplinks, + wait_for_udp_listener, +}; + +/// Bounded readiness gate replacing a fixed "sleep N seconds for registration". +/// Returns once srtla_send's local SRT listener is up and its uplink sockets are +/// connected to the receiver, so callers wait on observed state, not a timer. +pub fn wait_until_ready(stack: &SrtlaTestStack) { + wait_for_udp_listener( + &stack.topo.sender_ns, + stack.sender_srt_port(), + Duration::from_secs(15), + ) + .expect("srtla_send local SRT listener up"); + wait_for_connected_uplinks( + &stack.topo.sender_ns, + &stack.topo.receiver_ip, + stack.receiver_srtla_port(), + stack.topo.sender_ips.len(), + Duration::from_secs(15), + ) + .expect("srtla_send uplink sockets connected to receiver"); +} /// Check all integration test dependencies. Returns `true` if tests should /// be skipped (prints the reason to stderr). Use at the top of every test. @@ -61,6 +84,7 @@ pub fn inject_stream( "127.0.0.1", stack.sender_srt_port(), packets_per_sec, + network_sim::TS_PACKET_BYTES, duration, ) } diff --git a/tests/netns_basic.rs b/tests/netns_basic.rs index 72db83b..e4417c5 100644 --- a/tests/netns_basic.rs +++ b/tests/netns_basic.rs @@ -19,8 +19,8 @@ fn test_two_link_registration() { let mut stack = SrtlaTestStack::start("reg2", 2, &[]).expect("start stack"); - // Allow time for registration handshake on both links - thread::sleep(Duration::from_secs(5)); + // Wait for registration to complete on both links (bounded readiness poll). + common::wait_until_ready(&stack); let output = stack.stop(); common::dump_output(&output); @@ -45,13 +45,13 @@ fn test_data_forwarding() { let mut stack = SrtlaTestStack::start("fwd", 2, &[]).expect("start stack"); - // Wait for registration - thread::sleep(Duration::from_secs(5)); + // Wait for registration to complete (bounded readiness poll). + common::wait_until_ready(&stack); // Inject UDP packets into sender's local SRT port common::inject_packets(&stack, 100).expect("inject packets"); - // Allow data to flow through the pipeline + // Steady-state window: let injected data flow through the pipeline. thread::sleep(Duration::from_secs(3)); let output = stack.stop(); diff --git a/tests/netns_failure.rs b/tests/netns_failure.rs index 18d93f8..f4cee99 100644 --- a/tests/netns_failure.rs +++ b/tests/netns_failure.rs @@ -19,8 +19,8 @@ fn test_link_failure_failover() { let mut stack = SrtlaTestStack::start("fail", 2, &[]).expect("start stack"); - // Let both links register - thread::sleep(Duration::from_secs(5)); + // Wait for registration to complete (bounded readiness poll). + common::wait_until_ready(&stack); // Inject background data common::inject_packets(&stack, 100).expect("inject initial data"); @@ -60,8 +60,8 @@ fn test_link_recovery() { let mut stack = SrtlaTestStack::start("recv", 2, &[]).expect("start stack"); - // Let both links register - thread::sleep(Duration::from_secs(5)); + // Wait for registration to complete (bounded readiness poll). + common::wait_until_ready(&stack); // Kill link 0 stack diff --git a/tests/netns_impairment.rs b/tests/netns_impairment.rs index f7467a4..4e74160 100644 --- a/tests/netns_impairment.rs +++ b/tests/netns_impairment.rs @@ -40,8 +40,8 @@ fn test_asymmetric_delay() { ) .expect("impair link 1"); - // Wait for registration + RTT measurement - thread::sleep(Duration::from_secs(5)); + // Wait for registration to complete (bounded readiness poll). + common::wait_until_ready(&stack); // Inject some data so RTT tracking kicks in common::inject_packets(&stack, 200).expect("inject packets"); @@ -66,8 +66,8 @@ fn test_loss_triggers_window_reduction() { let mut stack = SrtlaTestStack::start("loss", 2, &[]).expect("start stack"); - // Wait for clean registration first - thread::sleep(Duration::from_secs(5)); + // Wait for clean registration first (bounded readiness poll). + common::wait_until_ready(&stack); // Apply 10% loss on link 0 stack @@ -126,7 +126,8 @@ fn test_tbf_bandwidth_limit() { ) .expect("impair link 1"); - thread::sleep(Duration::from_secs(5)); + // Wait for registration to complete (bounded readiness poll). + common::wait_until_ready(&stack); // Inject a burst of data common::inject_packets(&stack, 500).expect("inject packets"); diff --git a/tests/netns_scenario.rs b/tests/netns_scenario.rs index 3611731..c459ee5 100644 --- a/tests/netns_scenario.rs +++ b/tests/netns_scenario.rs @@ -19,8 +19,8 @@ fn test_random_walk_stability() { let mut stack = SrtlaTestStack::start("rw", 2, &[]).expect("start stack"); - // Wait for registration - thread::sleep(Duration::from_secs(5)); + // Wait for registration to complete (bounded readiness poll). + common::wait_until_ready(&stack); let scenario_cfg = ScenarioConfig { seed: 42, @@ -123,8 +123,8 @@ fn test_step_change_convergence() { let mut stack = SrtlaTestStack::start("step", 2, &[]).expect("start stack"); - // Wait for registration - thread::sleep(Duration::from_secs(5)); + // Wait for registration to complete (bounded readiness poll). + common::wait_until_ready(&stack); // Phase 1: Stable, good conditions (5s) stack diff --git a/tests/netns_wire_loss.rs b/tests/netns_wire_loss.rs new file mode 100644 index 0000000..71efa7a --- /dev/null +++ b/tests/netns_wire_loss.rs @@ -0,0 +1,333 @@ +//! Does a link with steady wire loss survive in the bond? +//! +//! The per-link CC soft cap (`sender::selection::link_cc`) reacts to NAK +//! loss by cutting `target_bps`. Loss that our own offered rate did not +//! cause cannot be repaired by cutting, so a link with a percent or two +//! of steady radio loss must not be driven out of the bond by it. +//! +//! This is the end-to-end counterpart to the unit tests in `link_cc`, +//! and it exists because those tests can only check the controller +//! against a *model* of wire loss that we wrote ourselves. Here the loss +//! is real (`tc netem`), the NAKs are real (a genuine SRT session runs +//! through the bond), and the CC reads them through the production path. +//! +//! Note the stack deliberately runs a real SRT caller in front of +//! srtla_send. Injecting raw UDP — as the older impairment tests do — +//! never completes an SRT handshake at the far end, so no ACKs or NAKs +//! ever come back and the entire congestion-control path is dead code +//! under test. +//! +//! Two links, both live, so this exercises real bonding rather than one +//! link with dead spares. Offered load comes from the *adaptive* SRT +//! sender, not a constant pump: it lowers its bitrate when the SRT send +//! buffer backs up or RTT inflates (belacoder's congestion response, +//! minus the encoder), so the rate tracks what the bond can carry rather +//! than oversubscribing it into an immediate retransmit-fuelled collapse. +//! +//! It keeps the run far healthier than a constant pump, but it does not +//! make it pristine: a real SRT session over a hard-capped lossy link +//! still oscillates, because settling at a clean steady rate would take +//! production-grade congestion control tuned for the path. So the checks +//! are the ones that hold *through* that oscillation: the lossy link's CC +//! target never ratchets to the floor (the fix), and both links carry a +//! real share at once (the bond is bonding). Aggregate goodput is logged +//! but not asserted — it is not a stable enough number to threshold on. + +mod common; + +use std::thread; +use std::time::Duration; + +use network_sim::{ImpairmentConfig, SrtlaTestStack}; + +/// Both links are the same generous size. Each is far above the share it +/// ends up carrying, so the lossy link stays uncongested and its 2% loss +/// is genuinely wire loss, not congestion — which is what the fix is +/// about. The adaptive sender ramps toward the bond's real capacity, so +/// both links get used without anyone having to guess an offered rate. +const LOSSY_LINK_KBIT: u64 = 6_000; +const CLEAN_LINK_KBIT: u64 = 6_000; + +/// The adaptive sender's bitrate bounds. The ceiling sits above the +/// 12 Mbps the bond could carry, so the sender is free to ramp up until +/// the send buffer tells it to stop rather than being capped short. +const SENDER_MIN_KBPS: u32 = 500; +const SENDER_MAX_KBPS: u32 = 16_000; + +const RUN_SECS: u64 = 45; + +/// `MIN_TARGET_BPS` in link_cc. A link pinned here has a BDP in-flight +/// cap of about one packet and is effectively out of the bond. +const CC_FLOOR_BPS: u64 = 100_000; + +#[test] +fn wire_loss_link_is_not_ratcheted_out_of_the_bond() { + if common::skip_without_impairment_deps() { + return; + } + // The adaptive sender is compiled here against the system libsrt. + if let Err(reason) = network_sim::check_adaptive_sender_deps() { + eprintln!("Skipping: {reason}"); + return; + } + common::build_srtla_send(); + let sender_bin = network_sim::build_adaptive_sender().expect("build adaptive SRT sender"); + + let sock = format!("/tmp/srtla-wireloss-{}.sock", std::process::id()); + let mut stack = + SrtlaTestStack::start("wireloss", 2, &["--control-socket", &sock]).expect("start stack"); + + // Same delay on both, so RTT cannot be what separates them: the only + // difference the scheduler can see is loss. + stack + .impair_link( + 0, + ImpairmentConfig { + delay_ms: Some(25), + loss_percent: Some(2.0), + rate_kbit: Some(LOSSY_LINK_KBIT), + tbf_shaping: true, + ..Default::default() + }, + ) + .expect("impair link 0"); + stack + .impair_link( + 1, + ImpairmentConfig { + delay_ms: Some(25), + rate_kbit: Some(CLEAN_LINK_KBIT), + tbf_shaping: true, + ..Default::default() + }, + ) + .expect("impair link 1"); + + common::wait_until_ready(&stack); + stack + .start_adaptive_sender(&sender_bin, SENDER_MIN_KBPS, SENDER_MAX_KBPS) + .expect("start adaptive SRT sender"); + + let mut lossy_target_min = u64::MAX; + let mut samples = 0usize; + let mut total_ticks = 0usize; + let mut saw_naks = false; + let mut saw_any_traffic = false; + let mut bond_bps_steady: Vec = Vec::new(); + let mut lossy_bps_steady: Vec = Vec::new(); + let mut clean_bps_steady: Vec = Vec::new(); + let mut prev_line = String::new(); + let mut frozen_ticks = 0usize; + + for _ in 0..RUN_SECS { + thread::sleep(Duration::from_secs(1)); + let Ok(stats) = stack.get_stats(&sock) else { + continue; + }; + let Some(links) = stats.get("links").and_then(|l| l.as_array()) else { + continue; + }; + if links.len() < 2 { + continue; + } + // Print every link, not just the lossy one. Reading link 0 alone + // cannot distinguish "the bond carried nothing" from "the + // scheduler sent it all down link 1" — and those call for + // completely different fixes. + // `bitrate_bytes_per_sec` is bytes, `cc_target_bps` is bits. + // Normalise to bits here so the two are actually comparable. + let link_bps = |l: &serde_json::Value| { + l.get("bitrate_bytes_per_sec") + .and_then(|v| v.as_u64()) + .unwrap_or(0) + * 8 + }; + + let mut line = String::new(); + for (i, l) in links.iter().enumerate() { + let f = |k: &str| l.get(k).and_then(|v| v.as_u64()).unwrap_or(0); + let state = l.get("cc_state").and_then(|v| v.as_str()).unwrap_or("?"); + let naks = l.get("nak_count").and_then(|v| v.as_i64()).unwrap_or(0); + line.push_str(&format!( + " [{i}{}] {state:<11} target={:<9} sent_bps={:<9} window={:<6} inflight={:<4} \ + naks={naks}\n", + if i == 0 { "*lossy" } else { " " }, + f("cc_target_bps"), + link_bps(l), + f("window"), + f("in_flight"), + )); + } + eprintln!("t+{total_ticks}s\n{line}"); + total_ticks += 1; + + if line == prev_line { + frozen_ticks += 1; + } else { + frozen_ticks = 0; + } + prev_line = line; + + let lossy = &links[0]; + let target = lossy + .get("cc_target_bps") + .and_then(|v| v.as_u64()) + .unwrap_or(0); + let naks = lossy.get("nak_count").and_then(|v| v.as_i64()).unwrap_or(0); + let state = lossy + .get("cc_state") + .and_then(|v| v.as_str()) + .unwrap_or("?"); + let bond_bps: u64 = links.iter().map(link_bps).sum(); + + if bond_bps > 0 { + saw_any_traffic = true; + } + // Only judge steady state: give registration, the SRT handshake, + // and the CC seed time to settle before scoring throughput. + if total_ticks > 10 { + bond_bps_steady.push(bond_bps); + lossy_bps_steady.push(link_bps(lossy)); + clean_bps_steady.push(link_bps(&links[1])); + } + // Ignore the bootstrap ticks: the target is parked at the floor + // until the first RTT sample, which would trivially satisfy the + // assertion below in the wrong direction. + if state == "bootstrap" { + continue; + } + if naks > 0 { + saw_naks = true; + } + lossy_target_min = lossy_target_min.min(target); + samples += 1; + } + + // The adaptive sender lives in the stack's caller slot, so stop() + // kills it along with everything else. + let output = stack.stop(); + let _ = std::fs::remove_file(&sock); + + // srtla_send runs housekeeping, the weak-link classifier and the CC + // controller in one tokio task, and writes the stats snapshot at the + // end of it. A panic anywhere in there kills only that task: the + // control socket lives in a different task and keeps happily serving + // the last snapshot it saw. The symptom is a stats reply that is + // byte-identical forever, which reads like a suspiciously stable + // control loop rather than a dead one. Say so explicitly. + let panics: Vec<&String> = output + .srtla_send_stderr + .iter() + .filter(|l| l.contains("panicked at") || l.contains("PANIC")) + .collect(); + assert!( + panics.is_empty(), + "srtla_send panicked — housekeeping/CC task is dead, so every stat below is stale:\n{}", + panics + .iter() + .map(|l| format!(" {l}")) + .collect::>() + .join("\n") + ); + + // Even without a panic message, a snapshot that never changes while + // traffic is flowing means the loop that produces it is not running. + if frozen_ticks > 10 { + eprintln!("--- last 80 lines of srtla_send stderr (RUST_LOG=debug) ---"); + for l in output.srtla_send_stderr.iter().rev().take(80).rev() { + eprintln!("{l}"); + } + panic!( + "srtla_send's stats snapshot did not change for {frozen_ticks} consecutive seconds \ + while traffic was flowing — the housekeeping/CC task has stopped. Nothing below this \ + point is a measurement of congestion control." + ); + } + + assert!( + samples > 10, + "too few post-bootstrap CC samples ({samples})" + ); + + // Nothing crossed the bond at all: the SRT session never carried + // data, so this is a broken harness, not a result about the CC. + if !saw_any_traffic { + eprintln!("--- srt caller stderr ---"); + for l in &output.srt_caller_stderr { + eprintln!("{l}"); + } + eprintln!("--- srt listener stderr ---"); + for l in &output.srt_server_stderr { + eprintln!("{l}"); + } + panic!( + "no data crossed the bond on any link — the SRT session never established, so this \ + test is not exercising congestion control at all" + ); + } + + // Traffic flowed, but never down the lossy link. That is a real + // finding rather than a harness bug — it means link *scoring* sheds + // the lossy link before the CC ever sees it — but it still leaves + // the CC path untested, so it must not be reported as a pass. + assert!( + saw_naks, + "traffic crossed the bond but the lossy link never took any, so it never NAKed. The \ + scheduler is starving it on quality score before congestion control is reached — the CC \ + path under test is never executed" + ); + + let median = |mut v: Vec| -> u64 { + if v.is_empty() { + return 0; + } + v.sort_unstable(); + v[v.len() / 2] + }; + let bond_median = median(bond_bps_steady); + let lossy_median = median(lossy_bps_steady.clone()); + let clean_median = median(clean_bps_steady); + eprintln!( + "\nsteady state: bond={bond_median} bps, lossy={lossy_median} bps, clean={clean_median} \ + bps, lossy CC target low-water={lossy_target_min} bps" + ); + + // 1. THE fix. The lossy link's CC target must never ratchet to the + // floor. Before the load gate, the efficacy test, and the + // delivered floor, BackingOff compounded -15% every tick for as + // long as the loss lasted, pinning the target at MIN_TARGET_BPS in + // ~20s. Holds on every run so far, never near the floor. + assert!( + lossy_target_min > CC_FLOOR_BPS * 3, + "lossy link's CC target collapsed to {lossy_target_min} bps (floor is {CC_FLOOR_BPS}) — \ + wire loss ratcheted a healthy {LOSSY_LINK_KBIT} kbit link out of the bond" + ); + + // 2. Both links carry a real share at once — this is a bond, not a + // failover, and getting the second link to register and pull + // traffic is the point of the topology work in the harness. + // + // A modest per-link floor, not a goodput target. The adaptive + // sender keeps the run far healthier than the old constant pump + // (which collapsed almost immediately), but an SRT session over a + // hard-capped lossy link still oscillates — it does not settle at + // a clean steady rate. Reaching that would take production-grade + // congestion control (belacoder's, tuned for real cellular), which + // is well beyond what this test needs. So assert liveness, which + // holds through the oscillation, not a throughput figure that + // would be flaky. + let each_link_floor = 500_000; // 0.5 Mbps: clearly pulling weight, not idle + assert!( + lossy_median > each_link_floor, + "lossy link carried only {lossy_median} bps — it is nominally in the bond but effectively \ + idle" + ); + assert!( + clean_median > each_link_floor, + "clean link carried only {clean_median} bps — the bond is really running on one link" + ); + + // bond_median is logged above for the operator; not asserted, since + // under oscillation it is not a stable aggregate to threshold on. + let _ = bond_median; +} diff --git a/tests/parser_proptest.rs b/tests/parser_proptest.rs new file mode 100644 index 0000000..193e6c9 --- /dev/null +++ b/tests/parser_proptest.rs @@ -0,0 +1,181 @@ +//! Property-based fuzzing of the SRT/SRTLA packet parsers. +//! +//! Two contracts are exercised against generated inputs: +//! +//! 1. ROBUSTNESS — for ARBITRARY byte slices the parsers must never panic +//! (no index-out-of-bounds, no slice-range panic, no unwrap), and the +//! `SmallVec`-returning parsers must produce a provably bounded number of +//! entries. +//! 2. ROUND-TRIP — for well-formed packets emitted by the builders, +//! `parse(build(x)) == x`. +//! +//! These tests treat the parsers as a black box via the public `protocol` +//! re-exports; they add no test-only seams and assert nothing about parser +//! internals, so they cannot drift from production behavior. Input sizes are +//! capped (≤ 256 bytes, range/list lengths small) to keep every case cheap +//! while still covering all length/type branches. + +use proptest::prelude::*; +use srtla_send::protocol::{ + ConnectionInfo, SRT_TYPE_ACK, SRT_TYPE_NAK, SRTLA_ID_LEN, create_ack_packet, + create_keepalive_packet, create_keepalive_packet_ext, create_reg1_packet, create_reg2_packet, + extract_keepalive_conn_info, extract_keepalive_timestamp, get_packet_type, + get_srt_sequence_number, is_srt_ack, is_srtla_keepalive, is_srtla_reg1, is_srtla_reg2, + parse_srt_ack, parse_srt_nak, parse_srtla_ack, +}; + +/// Cap arbitrary inputs at 256 bytes: enough to reach every length branch and +/// to let proptest synthesize NAK range words, while keeping each case fast. +const MAX_INPUT: usize = 256; + +prop_compose! { + fn arb_conn_info()( + conn_id in any::(), + window in any::(), + in_flight in any::(), + rtt_ms in any::(), + nak_count in any::(), + bitrate_bytes_per_sec in any::(), + ) -> ConnectionInfo { + ConnectionInfo { conn_id, window, in_flight, rtt_ms, nak_count, bitrate_bytes_per_sec } + } +} + +proptest! { + // ---- ROBUSTNESS: arbitrary bytes never panic, results are bounded ---- + + /// `parse_srt_nak` on arbitrary bytes never panics and never indexes OOB. + /// Upper bound: each 4-byte word yields at most one single ack, and range + /// expansion is internally capped at 1000 total entries, so the result is + /// at most `buf.len()/4 + 1000`. + #[test] + fn parse_srt_nak_never_panics_and_is_bounded(buf in prop::collection::vec(any::(), 0..MAX_INPUT)) { + let out = parse_srt_nak(&buf); + prop_assert!(out.len() <= buf.len() / 4 + 1000); + } + + /// Same, but biased toward real NAK frames (correct type byte) so the + /// range/single decode branches are exercised far more often. + #[test] + fn parse_srt_nak_typed_never_panics_and_is_bounded(payload in prop::collection::vec(any::(), 0..MAX_INPUT)) { + let mut buf = Vec::with_capacity(payload.len() + 4); + buf.extend_from_slice(&SRT_TYPE_NAK.to_be_bytes()); + buf.extend_from_slice(&[0u8, 0u8]); + buf.extend_from_slice(&payload); + let out = parse_srt_nak(&buf); + prop_assert!(out.len() <= buf.len() / 4 + 1000); + } + + /// `parse_srtla_ack` on arbitrary bytes never panics / never indexes OOB, + /// and emits at most one u32 per 4 bytes consumed. + #[test] + fn parse_srtla_ack_never_panics_and_is_bounded(buf in prop::collection::vec(any::(), 0..MAX_INPUT)) { + let out = parse_srtla_ack(&buf); + prop_assert!(out.len() <= buf.len() / 4); + } + + /// Packet-type detection and the scalar parsers never panic on arbitrary + /// bytes regardless of length or content. + #[test] + fn type_detection_never_panics(buf in prop::collection::vec(any::(), 0..MAX_INPUT)) { + let _ = get_packet_type(&buf); + let _ = get_srt_sequence_number(&buf); + let _ = parse_srt_ack(&buf); + let _ = extract_keepalive_timestamp(&buf); + let _ = extract_keepalive_conn_info(&buf); + let _ = is_srt_ack(&buf); + let _ = is_srtla_keepalive(&buf); + let _ = is_srtla_reg1(&buf); + let _ = is_srtla_reg2(&buf); + // get_packet_type agrees with the leading 2 bytes whenever present. + if buf.len() >= 2 { + prop_assert_eq!(get_packet_type(&buf), Some(u16::from_be_bytes([buf[0], buf[1]]))); + } else { + prop_assert_eq!(get_packet_type(&buf), None); + } + } + + // ---- ROUND-TRIP: parse(build(x)) == x for well-formed packets ---- + + /// Extended keepalive: `extract_keepalive_conn_info(build(info)) == info`. + #[test] + fn keepalive_ext_roundtrips(info in arb_conn_info()) { + let pkt = create_keepalive_packet_ext(info); + prop_assert_eq!(get_packet_type(&pkt), Some(srtla_send::protocol::SRTLA_TYPE_KEEPALIVE)); + prop_assert!(extract_keepalive_timestamp(&pkt).is_some()); + prop_assert_eq!(extract_keepalive_conn_info(&pkt), Some(info)); + } + + /// SRTLA ACK: `parse_srtla_ack(create_ack_packet(acks)) == acks`. + #[test] + fn srtla_ack_roundtrips(acks in prop::collection::vec(any::(), 0..64)) { + let pkt = create_ack_packet(&acks); + let parsed = parse_srtla_ack(&pkt); + prop_assert_eq!(parsed.as_slice(), acks.as_slice()); + } + + /// SRT NAK, single-loss list: a frame whose words all have the high bit + /// clear decodes back to exactly those sequence numbers. + #[test] + fn srt_nak_singles_roundtrip(seqs in prop::collection::vec(0u32..0x8000_0000, 0..64)) { + let mut buf = Vec::with_capacity(4 + seqs.len() * 4); + buf.extend_from_slice(&SRT_TYPE_NAK.to_be_bytes()); + buf.extend_from_slice(&[0u8, 0u8]); + for &s in &seqs { + buf.extend_from_slice(&s.to_be_bytes()); + } + let parsed = parse_srt_nak(&buf); + prop_assert_eq!(parsed.as_slice(), seqs.as_slice()); + } + + /// SRT NAK, single range: a high-bit-set start word followed by an end word + /// expands to the inclusive `start..=end` sequence (delta kept small so the + /// expansion stays well under the parser's 1000-entry cap). + #[test] + fn srt_nak_range_roundtrips(start in 0u32..0x7fff_0000, delta in 0u32..200) { + let end = start + delta; + let mut buf = Vec::with_capacity(12); + buf.extend_from_slice(&SRT_TYPE_NAK.to_be_bytes()); + buf.extend_from_slice(&[0u8, 0u8]); + buf.extend_from_slice(&(start | 0x8000_0000).to_be_bytes()); + buf.extend_from_slice(&end.to_be_bytes()); + let parsed = parse_srt_nak(&buf); + let expected: Vec = (start..=end).collect(); + prop_assert_eq!(parsed.as_slice(), expected.as_slice()); + } + + /// SRT ACK: a well-formed 20-byte ACK frame round-trips its ack number. + #[test] + fn srt_ack_roundtrips(ack in any::()) { + let mut buf = vec![0u8; 20]; + buf[0..2].copy_from_slice(&SRT_TYPE_ACK.to_be_bytes()); + buf[16..20].copy_from_slice(&ack.to_be_bytes()); + prop_assert!(is_srt_ack(&buf)); + prop_assert_eq!(parse_srt_ack(&buf), Some(ack)); + } + + /// REG1 / REG2: the builders produce frames the type validators accept and + /// whose embedded id round-trips byte-for-byte. + #[test] + fn reg1_reg2_roundtrip(id in prop::collection::vec(any::(), SRTLA_ID_LEN..=SRTLA_ID_LEN)) { + let id: [u8; SRTLA_ID_LEN] = id.try_into().expect("length pinned to SRTLA_ID_LEN"); + + let r1 = create_reg1_packet(&id); + prop_assert!(is_srtla_reg1(&r1)); + prop_assert_eq!(&r1[2..], &id[..]); + + let r2 = create_reg2_packet(&id); + prop_assert!(is_srtla_reg2(&r2)); + prop_assert_eq!(&r2[2..], &id[..]); + } + + /// Standard keepalive: builder output carries a recoverable timestamp and + /// is detected as a keepalive, but is NOT an extended-info frame. + #[test] + fn standard_keepalive_has_timestamp_no_conn_info(_ in 0u8..1) { + let pkt = create_keepalive_packet(); + prop_assert!(is_srtla_keepalive(&pkt)); + prop_assert!(extract_keepalive_timestamp(&pkt).is_some()); + prop_assert!(extract_keepalive_conn_info(&pkt).is_none()); + } +} diff --git a/tests/srtla_wire_conformance.rs b/tests/srtla_wire_conformance.rs new file mode 100644 index 0000000..05b50b3 --- /dev/null +++ b/tests/srtla_wire_conformance.rs @@ -0,0 +1,207 @@ +//! SRTLA wire-conformance golden tests. +//! +//! These tests LOCK byte-level compatibility between this sender and the SRTLA +//! wire format the receiver speaks: +//! +//! - Type codes + `SRTLA_ID_LEN` + REG frame sizes (`common.h`). +//! - REG1/REG2/REG3 build: `htobe16(type)` header + 256-byte id. +//! - ACK layout `struct { uint32_t type; uint32_t acks[10]; }` with +//! `ack.type = htobe32(SRTLA_TYPE_ACK << 16)`. +//! +//! They treat our `constants`/`builders`/`parsers` as a black box via the public +//! `protocol` re-exports — no test-only seams, so they cannot drift from +//! production behavior. If any assertion here fails, a wire constant or layout +//! has changed and the sender is no longer interoperable with an SRTLA receiver; +//! that is a deliberate, versioned protocol change, never an accident. + +use srtla_send::protocol::{ + SRTLA_ID_LEN, SRTLA_TYPE_ACK, SRTLA_TYPE_KEEPALIVE, SRTLA_TYPE_REG_ERR, SRTLA_TYPE_REG_NAK, + SRTLA_TYPE_REG_NGP, SRTLA_TYPE_REG1, SRTLA_TYPE_REG1_LEN, SRTLA_TYPE_REG2, SRTLA_TYPE_REG2_LEN, + SRTLA_TYPE_REG3, SRTLA_TYPE_REG3_LEN, create_ack_packet, create_reg1_packet, + create_reg2_packet, parse_srtla_ack, +}; + +/// `RECV_ACK_INT` in the receiver (`srtla_rec.c`): the fixed number of +/// per-connection sequence numbers carried in one SRTLA ACK frame. +const RECV_ACK_INT: usize = 10; + +/// `sizeof(srtla_ack_pkt)` = `sizeof(u32 type) + sizeof(u32 acks[10])` = 44. +const ACK_PKT_LEN: usize = 4 + 4 * RECV_ACK_INT; + +// --------------------------------------------------------------------------- +// 1. Type codes — exact hex, big-endian wire order (common.h) +// --------------------------------------------------------------------------- + +#[test] +fn type_codes_match_common_h() { + assert_eq!(SRTLA_TYPE_KEEPALIVE, 0x9000, "KEEPALIVE type code drift"); + assert_eq!(SRTLA_TYPE_ACK, 0x9100, "ACK type code drift"); + assert_eq!(SRTLA_TYPE_REG1, 0x9200, "REG1 type code drift"); + assert_eq!(SRTLA_TYPE_REG2, 0x9201, "REG2 type code drift"); + assert_eq!(SRTLA_TYPE_REG3, 0x9202, "REG3 type code drift"); + assert_eq!(SRTLA_TYPE_REG_ERR, 0x9210, "REG_ERR type code drift"); + assert_eq!(SRTLA_TYPE_REG_NGP, 0x9211, "REG_NGP type code drift"); + assert_eq!(SRTLA_TYPE_REG_NAK, 0x9212, "REG_NAK type code drift"); +} + +#[test] +fn type_codes_serialize_big_endian_on_the_wire() { + // The receiver sends headers via `htobe16(type)`; our builders use + // `to_be_bytes()`. Pin the resulting on-wire byte pairs so a host-endian + // regression (little-endian leak) is caught. + assert_eq!(SRTLA_TYPE_KEEPALIVE.to_be_bytes(), [0x90, 0x00]); + assert_eq!(SRTLA_TYPE_ACK.to_be_bytes(), [0x91, 0x00]); + assert_eq!(SRTLA_TYPE_REG1.to_be_bytes(), [0x92, 0x00]); + assert_eq!(SRTLA_TYPE_REG2.to_be_bytes(), [0x92, 0x01]); + assert_eq!(SRTLA_TYPE_REG3.to_be_bytes(), [0x92, 0x02]); + assert_eq!(SRTLA_TYPE_REG_ERR.to_be_bytes(), [0x92, 0x10]); + assert_eq!(SRTLA_TYPE_REG_NGP.to_be_bytes(), [0x92, 0x11]); + assert_eq!(SRTLA_TYPE_REG_NAK.to_be_bytes(), [0x92, 0x12]); +} + +// --------------------------------------------------------------------------- +// 2. Sizes — SRTLA_ID_LEN and REG frame lengths (common.h) +// --------------------------------------------------------------------------- + +#[test] +fn srtla_id_len_is_256() { + assert_eq!(SRTLA_ID_LEN, 256, "SRTLA_ID_LEN drift from common.h"); +} + +#[test] +fn reg1_reg2_frame_is_258_bytes() { + // common.h: `SRTLA_TYPE_REG1_LEN = (2 + SRTLA_ID_LEN)` = 258. + assert_eq!(SRTLA_TYPE_REG1_LEN, 258, "REG1 frame length drift"); + assert_eq!(SRTLA_TYPE_REG2_LEN, 258, "REG2 frame length drift"); + assert_eq!(SRTLA_TYPE_REG1_LEN, 2 + SRTLA_ID_LEN); + assert_eq!(SRTLA_TYPE_REG2_LEN, 2 + SRTLA_ID_LEN); +} + +#[test] +fn reg3_frame_is_2_bytes() { + // common.h: `SRTLA_TYPE_REG3_LEN = 2` (bare type, no body). + assert_eq!(SRTLA_TYPE_REG3_LEN, 2, "REG3 frame length drift"); +} + +#[test] +fn ack_layout_is_44_bytes_type_plus_ten_acks() { + // srtla_rec.c: `struct { uint32_t type; uint32_t acks[10]; }`. + assert_eq!(RECV_ACK_INT, 10); + assert_eq!( + ACK_PKT_LEN, 44, + "ACK struct = 4 (type) + 40 (10x u32 acks) = 44" + ); +} + +// --------------------------------------------------------------------------- +// 3. Builders produce wire-exact bytes +// --------------------------------------------------------------------------- + +#[test] +fn reg1_builder_matches_wire_layout() { + // Distinct per-byte id so a misplaced copy is visible. + let mut id = [0u8; SRTLA_ID_LEN]; + for (i, b) in id.iter_mut().enumerate() { + *b = (i & 0xff) as u8; + } + let pkt = create_reg1_packet(&id); + + assert_eq!(pkt.len(), 258, "REG1 frame must be 258 bytes"); + // Header: htobe16(SRTLA_TYPE_REG1) at bytes 0-1. + assert_eq!(&pkt[0..2], &[0x92, 0x00], "REG1 header bytes"); + // Body: full 256-byte id at bytes 2..258. + assert_eq!(&pkt[2..], &id[..], "REG1 id body must be the id verbatim"); +} + +#[test] +fn reg2_builder_matches_wire_layout() { + let mut id = [0u8; SRTLA_ID_LEN]; + for (i, b) in id.iter_mut().enumerate() { + *b = (255 - (i & 0xff)) as u8; + } + let pkt = create_reg2_packet(&id); + + assert_eq!(pkt.len(), 258, "REG2 frame must be 258 bytes"); + // Header: htobe16(SRTLA_TYPE_REG2) at bytes 0-1. + assert_eq!(&pkt[0..2], &[0x92, 0x01], "REG2 header bytes"); + assert_eq!(&pkt[2..], &id[..], "REG2 id body must be the id verbatim"); +} + +#[test] +fn ack_builder_matches_wire_layout() { + // ack.type = htobe32(SRTLA_TYPE_ACK << 16) = 0x9100_0000 + // => on-wire bytes [0x91, 0x00, 0x00, 0x00]; then 10 big-endian acks. + let acks: [u32; 10] = [ + 0x0000_0001, + 0x0000_00ff, + 0x0000_abcd, + 0x1234_5678, + 0x7fff_ffff, + 0x0000_0000, + 0xdead_beef, + 0x0010_0000, + 0x00ff_ff00, + 0xcafe_babe, + ]; + let pkt = create_ack_packet(&acks); + + assert_eq!( + pkt.len(), + ACK_PKT_LEN, + "ACK frame must be exactly 44 bytes for 10 acks" + ); + + // Type field (4 bytes): high u16 = 0x9100, low u16 = 0x0000. + assert_eq!( + &pkt[0..4], + &[0x91, 0x00, 0x00, 0x00], + "ACK type word must be htobe32(0x9100 << 16)" + ); + + // Each ack at offset 4 + i*4, big-endian, in order. + for (i, &ack) in acks.iter().enumerate() { + let off = 4 + i * 4; + assert_eq!( + &pkt[off..off + 4], + &ack.to_be_bytes(), + "ACK seq #{i} must be big-endian at offset {off}" + ); + } +} + +// --------------------------------------------------------------------------- +// 4. Parser reads a wire-shaped ACK +// --------------------------------------------------------------------------- + +#[test] +fn parser_reads_wire_shaped_ack() { + // Construct the frame EXACTLY as srtla_rec.c emits it (independent of our + // own builder), then assert our parser recovers all 10 sequence numbers. + let seqs: [u32; 10] = [10, 20, 30, 40, 50, 60, 70, 80, 90, 0x7fff_ffff]; + + let mut frame = [0u8; ACK_PKT_LEN]; + // ack.type = htobe32(SRTLA_TYPE_ACK << 16) + frame[0..4].copy_from_slice(&((u32::from(SRTLA_TYPE_ACK)) << 16).to_be_bytes()); + // ack.acks[i] = htobe32(sn) + for (i, &sn) in seqs.iter().enumerate() { + let off = 4 + i * 4; + frame[off..off + 4].copy_from_slice(&sn.to_be_bytes()); + } + + let parsed = parse_srtla_ack(&frame); + assert_eq!( + parsed.as_slice(), + &seqs[..], + "parser must recover all 10 ACK seqs in order" + ); +} + +#[test] +fn ack_builder_parser_roundtrip() { + // Our own builder -> our own parser must round-trip the full 10-ack vector, + // confirming both ends agree on the 44-byte layout. + let acks: [u32; 10] = [1, 2, 3, 4, 5, 6, 7, 8, 9, 0xffff_fffe]; + let pkt = create_ack_packet(&acks); + let parsed = parse_srtla_ack(&pkt); + assert_eq!(parsed.as_slice(), &acks[..], "ACK build->parse round-trip"); +}