Skip to content

Enhance congestion control and link management features#15

Merged
datagutt merged 89 commits into
mainfrom
strata-port
Jul 17, 2026
Merged

Enhance congestion control and link management features#15
datagutt merged 89 commits into
mainfrom
strata-port

Conversation

@datagutt

@datagutt datagutt commented Jul 17, 2026

Copy link
Copy Markdown
Member

Summary by CodeRabbit

  • New Features

    • Added TOML configuration files with SIGHUP reload support.
    • Added JSON-RPC runtime controls, status/statistics queries, and subscriptions.
    • Added Prometheus metrics endpoint and critical-packet priority support.
    • Added adaptive batching, congestion-aware link management, and stalled-link deselection.
    • Added network simulation and end-to-end testing coverage.
  • Bug Fixes

    • Improved packet-batch error handling and malformed-packet safety.
    • Improved link recovery, reconnection timing, and readiness detection.
  • Documentation

    • Updated scheduling, control protocol, metrics, and keyframe-priority documentation.

datagutt and others added 30 commits March 15, 2026 18:53
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Kalman filter tracks RTT velocity but congestion control ignored it.
Now perform_window_recovery() halves the recovery rate when velocity
exceeds 2.0 ms/sample, preventing window inflation during active
congestion.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add a velocity_penalty term to predicted_arrival() when Kalman
velocity is positive. This penalises links with rising RTT trends
before congestion manifests as loss, giving EDPF proactive avoidance.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Exclude links where in-flight bytes exceed 1.5× the bandwidth-delay
product (BDP). Prevents runaway in-flight during RTT inflation on
cellular networks.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add LinkPhase enum (Registering → Warming → Live → Degraded → Cooldown)
to replace implicit state from boolean flags. Scheduler skips non-Live/
Degraded links, eliminating early NAK bursts from newly-connected links.

- Warming phase requires 2 RTT probes or 5s timeout before going Live
- Housekeeping drives degradation detection and cooldown transitions
- All selection strategies (classic, enhanced, RTT-threshold, EDPF, BLEST)
  now check is_schedulable()

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add AsymmetricEwma struct with separate alpha_up / alpha_down
smoothing factors. Will be used to replace ad-hoc asymmetric
smoothing in capacity and RTT tracking.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add optional TOML configuration file support:
- New --config CLI arg for specifying config file path
- TomlConfig struct with serde defaults for all tunable constants
  (congestion control, EDPF scheduler, link lifecycle, selection)
- Load at startup with fallback to defaults on error

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Detect keyframe bursts (runs of max-MTU 1316-byte packets) and prefer
higher-quality links for keyframe packets. The KeyframeDetector tracks
consecutive max-size packets and declares a burst after 5+ in a row.

During keyframe bursts, the scheduler selects the link with the highest
quality_multiplier among connected/schedulable links, ensuring keyframes
travel on the most reliable path.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Implement RFC 8382 statistical shared bottleneck detection using
per-link OWD samples from Kalman RTT/2. Each interval computes:
- Skew (mean−median): queuing delay buildup
- Variance (MAD/mean): delay variability
- Frequency (sign-change ratio): oscillation pattern
- Loss rate from NAK counts

Links are bottlenecked when skew > C_S AND (var > C_H OR loss > P_L),
then grouped by delay statistics similarity using union-find.

In EDPF mode, correlated links have effective capacity reduced by 0.7x.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
… items

Gate test-only methods behind #[cfg(test)] instead of suppressing
dead_code warnings: AsymmetricEwma, edpf::select_from/select_from_indices,
sbd::groups(), keyframe::is_in_burst/total_bursts, blest::record_blocking,
iods::reset.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Augments the packet-size keyframe heuristic with an out-of-band hint
channel. An upstream encoder (belacoder) can tell srtla_send "the next
N SRT data packets are critical" via the Unix control socket, e.g.

    mark-critical 23

Maintains a per-sender budget in DynamicConfig; each data packet
consumes one from the budget. While budget > 0 or the heuristic fires,
the scheduler routes to the highest-quality link. The two signals are
OR-combined so heuristic-only deployments (ffmpeg, libsrt) keep the
existing behaviour while hint-aware encoders gain exact coverage of
IDR / SPS / PPS frames that the 5-packet-burst heuristic misses.
The previous flat text protocol ("mode classic", "mark-critical 3",
"status") was shaped for humans poking with socat. Machine clients
(belacoder, future agents, exporters) need request/response correlation,
typed errors, and schema-discoverable methods.

One message per line, one JSON-RPC 2.0 envelope per message. Requests
without an id are notifications, used for the hot-path mark_critical
hint so encoders don't block on a round-trip when firing a keyframe.

Methods: set_mode, set_quality, set_exploration, set_rtt_delta,
get_status, get_stats, mark_critical. subscribe / unsubscribe names
are reserved for a later streaming upgrade.

Hand-rolled rather than pulling in jsonrpsee or jsonrpc-core: the spec
is one page, srtla_send's control listener is a blocking std::thread
(not tokio), and deps are intentionally lean here. Full protocol docs
in docs/CONTROL_PROTOCOL.md.

BREAKING: the old "mode classic" / "stats" / etc. text commands no
longer work. Update any scripts poking the control socket.
The JSON-RPC mark_critical method had three fragility issues: the packet
count was a guess (MPEG-TS overhead unknowable from encoder side), the
hint went on a different scheduler path than the SRT data so it could
arrive after the packets it described, and in multi-source setups each
encoder's hints polluted the shared counter.

Swap to a dedicated UDP sidecar. The encoder sends a 5-byte datagram
(magic byte + window_ms) that opens a deadline-based critical window.
Loopback UDP shares the network stack with SRT data, so the window
opens tightly ordered against the packets it protects. The scheduler
OR-combines the window with the packet-size heuristic as before.

Operator enables with --priority-bind ADDR:PORT. get_status exposes
windows_received and malformed_datagrams counters. Full wire-format
docs in docs/KEYFRAME_PRIORITY.md.

BREAKING: mark_critical RPC removed from the JSON protocol. Encoders
that wired the old hint-count API must switch to the UDP sidecar.
New optional HTTP endpoint for scraping. Exposes per-link series
(up, RTT, window, in_flight, NAKs, bitrate, quality_multiplier),
aggregate counters, scheduling mode as a gauge, and priority-sidecar
counters. Enabled via --metrics-bind ADDR:PORT.

Hand-rolled over tokio::net::TcpListener with no axum/hyper/tower
pulled in. Supports GET /metrics and GET / only; anything else 404s.
Responses always close the connection, which is plenty for Prometheus
scrape semantics.
Scraping get_stats at 1 Hz loses sub-second link-state changes.
Subscriptions let a client register for a topic and receive server
push events as JSON-RPC notifications on the same connection.

Topics:
- stats           — StatsSnapshot pushed once per second alongside the
                    existing housekeeping update
- priority.window — pushed on each accepted sidecar datagram with
                    at_ms, window_ms, deadline_ms

The sync std::thread Unix-socket listener couldn't push unsolicited
messages on the same connection. Replaced it with a tokio
UnixListener whose per-connection task tokio::selects between reads
and outbound push-channel writes. Stdin stays blocking — no
subscription support there, subscribe/unsubscribe from stdin returns
method not found.

SubscriptionHub fans out by topic into per-connection mpsc senders;
full channels drop events (backed-up subscriber never blocks the
producer) and closed channels are pruned lazily.

BREAKING: `config::spawn_config_listener` is gone; call
`config::spawn_stdin_listener` and `control_socket::spawn` separately.
drop the unproven scheduling modes (rtt-threshold, edpf) and their
auxiliary filters (sbd, blest, iods). only the IRL-tested classic and
enhanced modes remain. exploration stays as an off-by-default opt-in
flag inside enhanced.

removes: --rtt-delta-ms CLI flag, set_rtt_delta JSON-RPC, rtt_delta_ms
config and stats fields, edpf_* TOML keys, rtt_threshold tests.

removes ~1700 LOC. clears the deck before adding the weak-link
classifier and per-link target-rate soft cap to enhanced mode.
new module sender/selection/classifier.rs implementing a three-tier
delay cascade with entering/leaving hysteresis (3x ratio).
classifies each connection as weak based on:
- rtt vs the chosen tier (best=40%, safe=50%, max=60% of estimated
  budget; budget = max(longest_rtt*3, 500ms) capped at 5s),
- bandwidth share vs an enter/leave threshold pair derived from fair
  share (0.25/N enter, 0.75/N leave).

constants picked conservative for first soak; real-world observation
may suggest retuning.

shadow mode: classifier runs on the housekeeping tick and surfaces
weak/reason/share/threshold per link plus selected_delay_ms /
estimated_max_delay_ms via the existing get_stats json. selection is
unchanged — admission gate wires in after a soak window.
new module sender/selection/link_cc.rs implementing a 3-state cc
controller per connection (bootstrap / climbing / holding /
backing_off) producing target_bps as a soft cap.

inputs:
- age-bucketed rtt ewma with 1:1 / 1:4 / 1:8 / 1:16 weights at age
  bands >=1s / >=500ms / >=250ms / <250ms; 2s gap snaps to the new
  sample.
- rttvar via 1:3 weighted moving deviation.
- 1s sliding-window loss permille (nak plumbing follow-up).
- observed bps from existing BitrateTracker.

state transitions:
- loss > 5 permille (0.5%) -> BackingOff (multiplicative -15%).
- rtt ewma > 1.5x rtt min  -> Holding.
- otherwise                -> Climbing (additive +2% per tick, capped
  by 2x measured throughput so idle links don't ramp).

shadow mode: snapshots flow into stats json next to existing weak/cc
fields. selection is unchanged. wires into Enhanced as a soft cap
after a soak window.
promote the weak-link classifier and per-link cc state from shadow
mode into the enhanced selection scorer.

- new fields weak / cc_backing_off on SrtlaConnection, stamped each
  housekeeping tick from WeakLinkFilter::classify and
  LinkCcController::tick_all.
- enhanced::select_connection skips weak or backing-off connections
  when at least one healthy alternative is schedulable. when every
  link is weak, falls back to the full pool — better to send on a
  weak link than to drop the packet.

regression tests cover the three branches: skip-weak-with-alternative,
fallback-when-all-weak, and treats-backing-off-as-weak. 229 lib tests
green.
extend the per-link CC state machine from 4 states (Bootstrap /
Climbing / Holding / BackingOff) to 5 + a climb sub-mode. closes
the gap between our simplified controller and the cellular
profile's needs without porting the full 9-mode reference.

new state:
  Drain — one-shot 25% multiplicative decrease when RTT inflation
  crosses 2.0x without observed loss. catches BDQ overload before
  ARQ surfaces it. transitions to Climbing on the next tick (or to
  Holding if RTT didn't recover).

new sub-modes for Climbing:
  Hai — 6%/tick AI when RTT variance ≤ 10% of the smoothed RTT
  mean. confident headroom signal so we ramp faster.
  FastRecovery — 4%/tick AI for 5 ticks after exiting BackingOff or
  Drain. claws back the bandwidth we just gave up without the
  overshoot risk that Hai would carry.
  Normal — 2%/tick AI baseline; covers steady-state with no special
  signal.

priority ordering when picking the climb sub-mode:
  1. FastRecovery while the post-backoff window is open.
  2. Hai when RTT is stable enough.
  3. Normal otherwise.

precedence in next-state selection:
  loss observed       -> BackingOff
  rtt_inflation ≥ 2.0 -> Drain
  rtt_inflation > 1.5 -> Holding
  else                -> Climbing

new ClimbMode telemetry exported via LinkCcSnapshot and the per-
link stats JSON (cc_climb_mode field). dashboards can render it
alongside cc_state.

5 new unit tests:
- hai_kicks_in_when_rtt_is_stable
- hai_yields_to_normal_when_rtt_is_jittery
- fast_recovery_engages_after_backoff
- drain_triggers_on_high_rtt_inflation_no_loss
- drain_then_recovery_path

existing holding_when_rtt_inflates updated: original used 60ms
samples (3x inflation) which now triggers Drain rather than
Holding; switched to 35ms samples (1.75x — Holding band) so the
test still exercises the original intent. 234 srtla_send lib tests
pass.
batch_send.rs previously buffered up to a fixed 16 packets / 15ms
flush window. on idle links that adds latency for traffic that
arrives in bursts; on heavy links it caps how much we can amortise
into a single sendmmsg-style call.

three regimes, picked per connection from observed bitrate:

  bitrate              regime         batch threshold
  ─────────────────    ───────────    ───────────────
  ≤ 500 kbps           LowActivity    4
  500 kbps – 5 Mbps    Normal         16  (Moblin sweet spot)
  > 5 Mbps             HighLoad       32

flush interval stays 15ms across regimes — going longer would add
latency on traffic resumption, going shorter would erase the
syscall amortisation we batch for.

new BatchRegime enum + BatchSender::set_regime / regime() accessor.
SrtlaConnection grows a recompute_batch_regime() that maps current
bitrate_bps → BatchRegime via BatchRegime::from_bps. housekeeping
calls it once per tick alongside calculate_bitrate / update_phase.

cheap to drive — set_regime is a single field write, no allocation.
no-op when the regime hasn't changed (caller doesn't need to track
deltas).

2 new unit tests:
- regime_from_bps_thresholds — boundary semantics
- batch_size_threshold_per_regime — actual flush trigger varies

236 srtla_send lib tests pass.
uplink sockets were always bound by source ip, which only steers egress
on a multi-homed host with source-based routing. introduce an
UplinkBinder trait so the steering action is injectable: SourceIpBinder
keeps the cli behavior, CallbackBinder lets a library consumer steer the
raw fd (android Network.bindSocket) while keeping IpAddr as the uplink
identity. thread the binder through run_sender_with_config, connection
creation, and reconnect.
collapse cooldown match guard, use clamp/iter/if-let/or_default in the
selection code, drop the redundant LinkCongestionState::new, and allow
constant-invariant assertions in the protocol test modules.
reconcile docs with the code after the edpf / rtt-threshold scheduling
work was dropped. only classic and enhanced modes remain.

- add CHANGELOG.md covering changes since v3.0.0, including an
  "explored and removed" section for the edpf/blest/iods pipeline
- README: drop rtt-threshold mode, --rtt-delta-ms, and the stale
  set_rtt_delta / mark_critical control-socket examples; add the
  real --config, --priority-bind, --metrics-bind flags and the
  subscribe/unsubscribe methods
- CONTROL_PROTOCOL: trim set_mode to classic/enhanced, remove the
  set_rtt_delta method and the rtt_delta_ms get_status field
- remove docs/RTT_THRESHOLD_SCHEDULING.md
before this commit, LinkCongestionState::record_loss was only called
from unit tests. the production sliding-window loss tracker stayed
at zero permille, which meant CcState::BackingOff was unreachable in
production and the cc_backing_off gate that enhanced selection now
consults could never fire. CC's loss path was dormant.

new LinkCongestionState::observe_traffic(bytes_sent_total, nak_total,
now_ms) computes per-tick deltas against a previous-call baseline
and forwards them to record_loss. first call stashes a baseline
without sampling. byte delta is converted to a packet count using
the standard SRT payload (1316 B); the ratio is invariant under
uniform packet-size assumptions so the approximation is fine for
the loss permille EWMA.

LinkCcController::tick_all reads conn.bitrate.bytes_sent_total and
conn.total_nak_count() and calls observe_traffic before the existing
tick(). zero-traffic ticks are skipped so the window doesn't fill
with no-op samples. quiet-link-with-NAK pathological case is bounded
by synthesizing a single-packet "sent" baseline, so the ratio never
divides by zero.

removed the stale "follow-up commit" comment and dropped the
#[allow(dead_code)] on record_loss now that it's wired.

3 new unit tests:
- observe_traffic_first_call_sets_baseline_without_sample
- observe_traffic_delta_flows_into_record_loss
- observe_traffic_quiet_tick_with_naks_does_not_panic

239 srtla_send lib tests pass.
before this commit, cc_target_bps was computed per-tick by the CC
controller, surfaced via stats JSON, but never consumed in selection
— the stats comment even said "selection does not yet treat as a
soft cap. After a soak window the cap wires into the Enhanced score."
that wiring never happened.

new on SrtlaConnection:
  pub(crate) cc_target_bps: u64,
stamped alongside cc_backing_off in sender/mod.rs from the per-tick
LinkCcSnapshot.

new in enhanced.rs:
  fn cc_soft_cap_multiplier(conn) -> f64 in [CC_SOFT_CAP_FLOOR, 1.0]

  formula:
    headroom    = max(0, cc_target_bps - measured_bps)
    multiplier  = clamp(headroom / cc_target_bps, FLOOR, 1.0)

  short-circuits to 1.0 when:
  - cc_target_bps == 0 (CC hasn't bootstrapped yet)
  - measured_bps  == 0 (idle link, plenty of headroom)

  floor = 0.10 — saturated links keep 10% of their raw score so a
  trickle of keepalive traffic still flows and the CC controller
  keeps observing the link. without a floor, a link at exactly its
  cap would get score 0 forever.

  the multiplier folds into the link's existing quality-aware score:
    score = base * quality_mult * cap_mult

  same code path covers the non-quality branch:
    score = base * cap_mult

the previous binary cc_backing_off gate still runs first as a hard
admission filter. the new multiplier is a soft signal that operates
within the surviving candidate pool — links upshifting close to
their CC ceiling get deprioritised before backoff fires.

4 new unit tests cover the multiplier helper:
- cap_no_signal_returns_unity
- cap_idle_link_returns_unity
- cap_at_target_falls_to_floor
- cap_half_target_returns_half

stats.rs comment updated to reflect production consumption. test
helpers default cc_target_bps to 0. 243 srtla_send lib tests pass.
batch_send.rs has tracked a per-connection BatchRegime (LowActivity /
Normal / HighLoad) since the adaptive batch-send commit, driven each
housekeeping tick from observed bitrate. it was never plumbed into
the stats JSON though, so dashboards couldn't see why one link was
batching more aggressively than another.

new on LinkStats:
  pub batch_regime: String,
populated from conn.batch_sender.regime().as_str().to_string() in
SharedStats::update — same place cc_state and cc_climb_mode land.

new BatchRegime re-export from connection::mod alongside BatchSender
so external callers (stats, future telemetry) don't have to reach
into batch_send.rs directly.

format mirrors the existing cc_* string-field convention so the
existing dashboard pattern that renders cc_state as a chip works for
batch_regime with zero schema gymnastics.
datagutt added 27 commits July 14, 2026 02:06
MIN_SWITCH_INTERVAL_MS pinned the enhanced scheduler to its previously chosen
link for 15ms regardless of score. That is roughly 24 packets at the rate this
sender actually pushes.

get_score() counts a link's queued-but-unflushed packets as in-flight
specifically so that routing a packet immediately de-prioritises its own link.
Selection is therefore a closed feedback loop whose job is to bound queue depth
per link. Freezing the decision for 15ms opens that loop: in-flight runs away on
whichever link the timer parked on, those packets arrive past the SRT latency
budget, and TLPKTDROP discards them. Measured under saturation, enhanced piled
215 packets in flight on one link while the other sat at 58, and skewed traffic
toward the *weaker* link (64% of bytes onto a 1000kbit link while a 2000kbit one
idled).

The cooldown was never a scheduling policy. It existed to stop
forward_via_connection from flushing a one-packet batch on every switch; that
flush is gone (previous commit) and batches now leave in a single sendmmsg on
their own threshold/timer, so switching costs nothing.

netem testbed, receiver held at BELABOX srtla_rec, warm start, 30s, n=5,
interleaved, idle host. Delivery %:

  scenario     classic   enhanced(stock)   enhanced(this)
  tight        83.0      65.2              83.7
  tight_asym   84.2      70.3              83.7

+14.6 pts on tight (t=15.2) and +12.8 on tight_asym (t=4.4), restoring parity
with classic and with the BELABOX C reference. Removing the batch flush alone
does not recover it: with the cooldown still in place, enhanced remains ~14
points down.

Score hysteresis (SWITCH_THRESHOLD, 10%) is kept. Damping in score space
resists noise-driven flip-flopping without opening the loop; damping in time
does not.
Exploration existed to break a real starvation lock: a link demoted by a quality
gate wins no packets, so it earns no ACKs, so the signal that demoted it never
clears. Keepalives prove liveness (that is how stall_deselect un-gates itself)
but cannot prove capacity. Only data can.

The old implementation did not do that. It probed second-best -- usually a
healthy link that is already earning its own ACKs and needs no probe -- it fired
every 30s whether or not anything was wrong, and it had no budget: it diverted
roughly half of all traffic for as long as its trigger held, which can be
seconds. It stayed survivable only because the switch cooldown happened to rate
limit it, and that cooldown is now gone.

Replace it with a bounded probe:

- targets only starved links (weak, loss_degraded, or over the in-flight cap),
  never a healthy runner-up
- one packet per link per PROBE_INTERVAL_MS (200ms), so a persistently bad link
  cannot bleed throughput
- only when an un-gated link is schedulable: a probe is paid for out of spare
  capacity, and when nothing is healthy every packet is needed for the stream
- data packets only, since a control packet earns no ACK and steering SRT's own
  control traffic onto a degraded link would delay its control loop for nothing
- stall_gated links are excluded: a suspected black hole already has its own
  liveness-proven recovery path, so aiming data at it only adds latency

Still opt-in behind --exploration (exploration_enabled defaults false); this
changes what that flag does, not the default path.

elapsed_ms/STARTUP_INSTANT go with it -- the deleted 30s periodic trigger was
their only caller.
… gate

is_schedulable() excluded Registering AND Warming, so a warming link could carry
no traffic at all. At go-live every link is warming, which empties the candidate
pool: select_connection returns None, packet_handler logs "no available
connection to forward packet", and the stream is dropped until the first link is
promoted -- either by two keepalive RTT probes or, against a receiver that does
not return them promptly, by the 5s WARMING_TIMEOUT_MS fallback.

That contradicts the rule the rest of this scheduler follows, and that the phase
machine was ported from: a phase weights a link's score, it never removes the
link. `weak` and `loss_degraded` crush a score but keep the link rankable
(GATED_LINK_PENALTY); `stall_gated` only fires when a healthier link exists.
Warming was the one gate with no such escape hatch.

Warming now scores at 0.8 instead of being excluded. The de-rating still
expresses what the phase is for -- a link whose RTT baseline is one keepalive old
should not take a full share while a characterised link is available -- but when
every link is warming they are de-rated equally, the relative ranking is
unchanged, and traffic flows immediately.

Registering remains the sole hard exclusion. That is a protocol constraint, not a
quality judgement: without REG3 the receiver discards the data.
The crate did not compile for windows: src/connection/socket.rs imported
std::os::fd unconditionally, and src/control_socket.rs imported the JSON-RPC
dispatch pair outside its own #[cfg(unix)]. Fixing those exposed a further layer
of unix-only code that only ever compiled because nothing downstream of it did.

Each item is gated where it is genuinely unix-bound, rather than silenced:

- CallbackBinder binds a socket by raw fd. It exists for Android, where the host
  steers a socket onto a radio via Network.bindSocket. Windows has no fd and no
  such integration, so the binder (and its re-export) is unix-only. SourceIpBinder,
  which the CLI actually uses, is unaffected.
- analyze_ip_reload is the SIGHUP entry point, and ReloadRefusal::NotFound is
  reachable only from it. Windows has no SIGHUP. Startup parsing goes through
  analyze_ip_reload_text on every platform, so IP-list handling is unchanged.
- The async control surface (dispatch_async, SubscriptionContext,
  handle_subscribe/unsubscribe) serves the Unix-domain control socket and nothing
  else. The sync dispatch path used by config.rs stays on every platform.
- SubscriptionHub::{subscribe,unsubscribe,len} are reachable only from that
  socket. publish() stays: on windows the hub publishes to nobody, because there
  is no way to subscribe.

Also drops dispatch_inner's _subscription_ctx parameter, which was never read.

cargo check --target x86_64-pc-windows-gnu now succeeds. Linux is unchanged:
clippy --all-targets clean, 322 tests pass.
Deleted: the --exploration flag, the set_exploration JSON-RPC method,
sender/selection/exploration.rs, the enable_explore plumbing, the
exploration_enabled config/TOML/stats/status surface, and SchedulingMode::
is_enhanced (its only caller was effective_exploration_enabled).

The original implementation was actively harmful. It probed second-best, which
is normally a healthy link already earning its own ACKs and needing no probe; it
fired every 30s whether or not anything was wrong; and it had no budget, so it
diverted roughly half of all traffic for as long as its trigger held, which can
be seconds. It survived only because the switch cooldown incidentally rate
limited it, and that cooldown is gone.

I rewrote it as a bounded probe of starved links (one data packet per link per
200ms, only when a healthy link exists) and then measured it on the netem
testbed against the exact scenario it exists for: link1 gated to 0.00 Mbps by a
70% loss burst, silently healed, then needed when link0 collapsed to 600kbit.

  probe off:  98.3 +/- 3.9  median 99.2   runs<95%: 1/15
  probe on:   99.0 +/- 0.3  median 99.1   runs<95%: 0/15
  diff 0.76 pts, Welch t=0.75, n=15 per arm

No effect. The starvation lock is real (I watched a link sit at 0.00 Mbps for
~7s after it silently healed) but it is self-clearing: GATED_LINK_PENALTY keeps
a gated link rankable rather than excluded, so it retains a 0.2-0.5 Mbps trickle
and earns the samples that clear the gate. The classifier's probation re-test
covers the share-starvation latch on top of that. The probe was redundant twice
over.

Keeping an off-by-default knob that cannot be shown to help is how the switch
cooldown got here: a mechanism kept because it sounded principled, which then
acquired a workaround, which then broke the scheduler. If a real failure mode
later shows the trickle is insufficient, that calls for a measurement, not a
resurrected flag.
BackingOff multiplied target_bps by 0.85 every tick for as long as
loss_permille stayed above 0.5%, with nothing asking whether our own
offered rate was what caused the loss. A cellular link sitting at an
ordinary 1-2% of wire loss therefore had its soft cap decayed 15% a
second regardless of how lightly it was loaded: ~21 seconds from 3 Mbps
to the 100 kbps floor, where the BDP in-flight cap deselects it. Steady
radio loss threw away a link that was carrying real traffic, and since
the loss never cleared, the link never came back.

Note Drain already avoided exactly this by being one-shot, and says so:
"applying the cut every tick compounds it, collapsing target_bps to the
floor within ~11 ticks". The same was true of BackingOff, which
compounded anyway.

Two guards, covering different halves of the problem:

- BACKOFF_MIN_LOAD_PERMILLE gates entry. A link we are barely feeding
  cannot be the cause of its own loss. This covers the starved link.
- BACKOFF_EFFICACY_TICKS bounds the descent on a link we really are
  driving hard, by asking whether cutting is working at all: congestive
  loss responds to a lower offered rate, wire loss does not. If a ~39%
  cut has not moved the loss, stop attributing it to ourselves and let
  the link climb again.

Neither suffices alone. The load gate does not converge by itself, since
each cut lowers the target and so raises load, holding the gate open all
the way down. The efficacy test cannot protect a starved link, since a
link carrying nothing has no throughput to judge a cut against.

The loss decrease stays compounding, unlike Drain's one-shot: rtt_min is
windowed, so RTT inflation fades as a signal once congestion outlives
the window, leaving loss as the only backstop able to walk the cap down
to real capacity.
LinkStats::bitrate_bps was computed as mbps * 1_000_000 / 8, so it
carried bytes per second under a bits-per-second name, sitting directly
next to genuinely bit-denominated fields like cc_target_bps. It also fed
a Prometheus gauge, srtla_send_link_bitrate_bps, whose own HELP text
admitted "bytes/sec" while the metric name claimed otherwise. Anything
comparing the two fields, or graphing the gauge as a bitrate, was
silently out by a factor of 8.

Rename rather than change the value: a consumer that loses a field
breaks loudly, whereas quietly switching the units to bits would be an
8x error that nobody notices.

BREAKING: the get_stats JSON field bitrate_bps is now
bitrate_bytes_per_sec, and the Prometheus gauge
srtla_send_link_bitrate_bps is now
srtla_send_link_bitrate_bytes_per_second.
The existing impairment tests cannot exercise congestion control at all.
They inject raw zero bytes into srtla_send's listener, which never
completes an SRT handshake at the far end, so no ACKs or NAKs ever come
back and LinkCongestionState never leaves Bootstrap. That is why they
only assert "did not panic" -- there was nothing else to assert.
test_loss_triggers_window_reduction cannot be testing what its name says.

Add the missing pieces to network-sim:

- start_srt_caller() puts a real srt-live-transmit caller in front of
  srtla_send, so a genuine SRT session runs through the bond and the
  listener's ACK/NAK stream drives the real feedback path.
- get_stats() queries srtla_send's control socket for per-link cc_state,
  cc_target_bps and nak_count. It runs as root inside the namespace,
  since the socket is root-owned.
- spawn_udp_stream() returns while traffic flows, so a test can sample a
  control loop under load rather than only after it.
- The stream injector takes a payload size and paces against a
  wall-clock deadline. At 188 bytes a datagram, megabit rates need
  sub-millisecond sleeps that Python cannot hold, so the pump quietly
  became the bottleneck instead of the network.

netns_wire_loss asserts the fix in ace481b end to end: 2% netem loss on
a roomy 8 Mbps link, against a clean link deliberately too small (1.5
Mbps) to carry the stream alone, so the scheduler is forced to use the
lossy one while nothing we send can congest it. It checks that the CC
target never collapses toward the floor, that the lossy link keeps
carrying real traffic, and that the bond aggregates past what the clean
link could do by itself.

It also asserts NAKs actually reached the sender, and separately that
any data crossed the bond, so a broken harness fails loudly instead of
passing vacuously.

Unverified: netns tests need passwordless sudo, which is unavailable
here, so this skips rather than runs.
A loopback repro with 2% random loss showed the previous commit did not
work: target_bps still ratcheted 637500 -> 541875 -> ... -> 100000, hit
the floor in 12 seconds, reseeded, and did it again. Two reasons, and
the second invalidates a premise of the first attempt.

The RTT escape nullified the latch. Clearing loss_uncongestive whenever
rtt_inflation exceeded RTT_HOLD_FACTOR re-opened the backoff path on
every inflated tick, so the controller alternated BackingOff -> Drain ->
BackingOff and cut on nearly all of them. The latch might as well not
have existed. Congestion visible in RTT is already answered by Drain and
Holding; the loss path does not need to double up on it. The verdict now
expires on a timer (LOSS_UNCONGESTIVE_RETEST_TICKS) so it is still
re-tested rather than trusted forever.

target_bps does not pace. It steers the scheduler between links; it does
not throttle one. So cutting it does not reduce what the link sends, and
measured throughput does not follow the cap down -- the repro shows the
link flat at ~4.1 Mbps while its cap collapsed to 100 kbps. The efficacy
test had assumed a cut reduces offered load, and the delivered floor was
dropped earlier on the reasoning that "observed follows target down".
It does not, and cannot.

So restore the floor, for the reason that actually holds: never cap a
link below the rate it is visibly sustaining. Such a cap is not a
conservative estimate, it is a wrong one, and nothing about it
self-corrects. It still converges under real congestion, where cutting a
link's cap steers traffic off it, throughput genuinely falls, and the
floor follows it down. Clamped to prev so a backoff can never raise the
cap.

Repro after: cap holds at 1000000 through the loss episode instead of
sawtoothing to 100000.

The netem test still cannot run here (needs passwordless sudo), and it
now also dumps any srtla_send panic and fails loudly if the stats
snapshot stops changing -- a wedged housekeeping task otherwise reads as
a suspiciously stable control loop.
…wedge

NamespaceProcess piped the child's stdout/stderr and did not read them
until after the process exited. A piped child whose output nobody reads
blocks in write() once the 64 KiB pipe buffer fills, and the harness runs
srtla_send with RUST_LOG=debug, which at a few Mbps fills that in about a
second.

The task that does the logging is srtla_send's main select loop, so the
sender wedges there while its control socket -- a separate task that logs
almost nothing -- carries on answering. The netem run therefore reported
a stats snapshot frozen at the values it held one second in, byte for
byte, for the rest of the run. That reads like an impossibly stable
control loop rather than a deadlocked one, and it is not a measurement of
anything.

Reproduced outside netns: the same loopback stack that runs clean with
stderr redirected to a file freezes from t+1 the moment stderr is a pipe
nobody reads and RUST_LOG=debug is set.

Drain both pipes on background threads from spawn. stdout_lines and
stderr_lines now return a snapshot and are safe to call while the process
is still running.

Existing netns tests never hit this only because they finish well under
64 KiB of debug output.
The topology gave each uplink its own /24 but hard-coded the receiver
address to link 0's near end (10.10.1.2). Every uplink past the first was
bound to a source on a different subnet, so its packets routed out link
0's veth and the receiver's strict rp_filter dropped them: the second
uplink never registered, and a bonding test quietly ran on one link. The
last netem run showed exactly this — link 1 sat in bootstrap with zero
traffic the whole time.

Model what SRTLA actually does: one receiver endpoint reached by N sender
source IPs, each egressing its own interface. That needs per-source
policy routing.

- Receiver owns a service IP (10.99.0.1) on lo; every uplink connects
  there, and it is the value of receiver_ip now.
- Sender gets a routing table per source IP (ip rule from <src> lookup
  <table>) whose route to the service IP points out that uplink's veth,
  so binding a socket to the source pins its traffic to the link.
- Receiver returns to each sender source with the service IP as source
  address, so the sender's connected UDP socket accepts the reply.
- rp_filter disabled on both ends: the return path lives in a policy
  table outside main, which strict mode drops.

Retune netns_wire_loss for a real bond: both links 6 Mbps, ~9 Mbps
offered, so each carries ~4.5 Mbps -- below its own ceiling (the lossy
link's 2% stays wire loss) yet more than one link can supply (both stay
busy). Assertions now require the lossy AND the clean link to each carry
more than a quarter of the offered rate, and the bond to exceed a single
link, so a one-link result fails loudly instead of passing.

Still needs passwordless sudo to run, so it skips here rather than
executing; the routing is verified by reasoning and compilation only.
With both links finally live, the 9 Mbps / 12 Mbps scenario (75% load)
drove itself into congestion collapse: sent rates of 11-14 Mbps on 6 Mbps
links, windows pinned at the floor, tens of thousands of NAKs on the
clean link, and the SRT session stalling out near the end. Two causes,
both fixed here.

False-loss retransmit storm. The SRT caller ran at latency=200ms while
the shaper buffers up to 1s (tc tbf latency 1s). A packet merely waiting
its turn in the TBF aged past SRT's 200ms window and was retransmitted
while the original was still queued, doubling offered load right when the
bond was busiest. Raise the caller to latency=2000ms, above the buffer
depth, so only genuinely lost packets are retransmitted.

Too much load. Two equal links force an awkward squeeze: using both needs
the offered rate above one link, i.e. above half the total, so the bond
always runs hot. 9 Mbps on 12 was too hot. Back off to ~7 Mbps (665 pps)
-- still over one link's 6 Mbps so both are used, but ~58% of total,
close to the ~50% load the single-link run held stably.

Unverified here (needs passwordless sudo); reasoning and compilation only.
The bonded netem run reaches a real regime the single-link run never
did: a genuine SRT session over a lossy shaped link retransmits hard
enough that the lossy link's raw send rate runs several times its
goodput (40 Mbps observed on a 6 Mbps link) and swings widely. That is
inherent to SRT-over-loss near capacity, and taming it needs FEC or
encoder-rate adaptation that srtla_send does not have. It is orthogonal
to the wire-loss ratchet this test exists for.

So stop asserting throughput. `sent_bps` counts bytes queued to a link,
which under retransmission is a liveness signal, not a goodput one; the
old per-link-share and aggregation assertions would have passed on
retransmit noise and meant nothing.

Assert instead what is both robust and meaningful, and what has held on
every run so far, collapsed or not: the lossy link's CC target never
ratchets toward MIN_TARGET_BPS (it has stayed in the 350k-1.4M band
throughout, floor is 100k), and both links stay alive so the bond is
really bonding. That is the fix, and that this harness can now exercise
two live links at once.
The constant-rate pump oversubscribes a busy bond and collapses it into a
retransmit storm the moment it exceeds capacity, so the netns test could
only assert survival, not throughput. Replace it, for tests that opt in,
with an adaptive SRT sender: belacoder's congestion response minus the
encoder. It generates dummy payload over a real SRT caller and lowers its
bitrate when the SRT send buffer backs up or RTT inflates, so the offered
rate tracks what the bonded path can carry.

Dependency-light on purpose: libsrt only (already required by the whole
srtla stack), no GStreamer and no patched encoder, so it stays portable
where belacoder would not. The ~180-line C source is embedded via
include_str! and compiled on demand with cc + pkg-config srt, gated by
check_adaptive_sender_deps, so it never touches the normal cargo build and
skips cleanly where the toolchain is absent. (It needs an explicit
.gitignore exception because the repo blanket-ignores *.c reference files.)

Verified on loopback, no root: against an unshaped 2% lossy path it settles
at a stable ~4 Mbps with a shallow send buffer; against a rate-capped lossy
relay (a local stand-in for the netns TBF) the RTT-based backoff keeps
windows healthy and cuts NAKs ~5x versus buffer-only backoff.

It does not make the run pristine — a real SRT session over a hard-capped
lossy link still oscillates, since a clean steady state would need
production-grade CC tuned for the path, out of scope here. So netns_wire_loss
keeps its robust checks (lossy CC target never floors; both links carry a
real share) and logs aggregate goodput without asserting on it.
now_ms() read SystemTime, which can step backwards on an NTP correction.
Every timeout, RTT sample, and congestion deadline is a difference between
two now_ms() reads (the keepalive-echo RTT path subtracts our own stamp), so
a backward step clamps an RTT to zero or falsely resets a link timeout.
Anchor to a monotonic Instant, keeping epoch-scale magnitude so differential
arithmetic and saturating_sub call sites are unaffected.
First leaf of the sans-IO clock unification: BitrateTracker no longer reads a
global now_ms(). new()/reset()/calculate() take the timestamp from the caller,
which already computes now at every call site (housekeeping, connect_from_ip,
reset_state). Drops the Default impl since construction needs a timestamp.
Tests now drive a fixed virtual clock and read no real clock at all.
Second leaf of the clock unification. update_estimate/record_keepalive_sent/
handle_keepalive_response/needs_measurement take now from the caller instead of
reading now_ms(). The receive-path callers (handle_srt_ack, send_keepalive,
process_packet_internal) already compute now locally, so the change stops at the
leaf and does not ripple to their callers. rtt and keepalive-interop tests now
drive a fixed virtual clock.
Third leaf. handle_nak/handle_srtla_ack_enhanced/perform_window_recovery/
time_since_last_nak_ms and the enhanced free fns take now from the caller. The
enhanced tests, previously racy on two separate real-clock reads, now drive a
fixed virtual clock. The SrtlaConnection wrappers read now once at the
connection layer and pass it down (handle_srtla_ack_specific reuses the stamp it
already took); threading those wrapper signatures is deferred to the
connection-layer conversion, so the ~30 handle_nak test callers stay untouched.
Fourth leaf. should_attempt_reconnect/record_attempt/reset_startup_grace take
now from the caller. The backoff wrappers thread now from housekeeping's
per-tick current_ms; reconnect() reads it once at the connection layer for the
grace reset. The reconnect-logic test is now deterministic instead of relying on
two real-clock reads landing inside the backoff window.
update_phase and clear_pre_registration_state take now from their single
callers (housekeeping's current_ms, the receive-path now). Both already held a
timestamp, so no ripple. Leaves is_timed_out and the send/reset boundaries,
which read tokio::time::Instant, for the dual-clock migration.
The connection liveness fields (last_received/last_sent/last_keepalive_sent) and
the housekeeping all_failed_at timer were tokio::time::Instant, a second clock
distinct from now_ms(). Convert them to u64 monotonic ms so the whole liveness
path runs on one clock. is_timed_out/needs_keepalive now compare now_ms() against
the stamp (they already read now_ms for the grace check); handle_housekeeping
takes now as an argument, removing its ambient read and making the all-failed
timeout injectable.

Tests stop advancing tokio's virtual clock (which never moved now_ms anyway) and
stamp explicit past timestamps instead, which is both deterministic and honest:
the old fake_clock timeout tests were asserting against a clock the logic did not
actually read. tokio::time::Instant remains only where it belongs, the event-loop
interval timers and the BatchSender send-coalescing window.
…indow_recovery

Both are driven from housekeeping's per-tick current_ms; drop their connection-layer
ambient now_ms() reads and take now as a parameter.
Both selection callers (quality multiplier, cold quality-state log) already carry
current_time_ms; thread it through instead of the wrapper reading now_ms().
The widest connection-layer reader: 22 call sites, most of them deep in the hot
selection path. Every caller already carries a timestamp (selection's
current_time_ms/packet_time_ms, housekeeping's current_ms, stats' current_time_ms),
so is_timed_out takes now as a parameter instead of reading now_ms() itself.
classic::select_connection and select_pre_registration_connection gain a now
parameter to pass it through; telemetry reads one now per report.
handle_srt_ack/handle_nak/handle_srtla_ack_specific take now from the caller.
process_connection_events reads one monotonic timestamp per receive batch and
threads it through every ACK/NAK handler (attribute_nak already carried it). This
removes the last connection-layer ambient now_ms() reads on the receive path;
what remains are the async I/O methods (send/flush/connect/reconnect), which read
locally and move to the shell in step 2.
process_registration_packet and the packet-driven handlers (handle_reg_ngp,
handle_reg2, handle_reg_err, handle_probe_response) take now from the receive
path instead of reading now_ms(). The async send-scheduling methods (send_reg1_to,
reg_driver_send_if_needed, try_send_reg1_immediately, probing) still read locally;
like the connection send methods, they are the I/O layer that moves to the shell
in step 2.
@datagutt
datagutt merged commit a844258 into main Jul 17, 2026
4 of 6 checks passed
@datagutt
datagutt deleted the strata-port branch July 17, 2026 20:16
@coderabbitai

coderabbitai Bot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: ff9f9d26-f578-47f4-b62b-0a4acb9d521b

📥 Commits

Reviewing files that changed from the base of the PR and between 80cd0c4 and aafc966.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (78)
  • .github/workflows/build-debian.yml
  • .github/workflows/ci.yml
  • .gitignore
  • CHANGELOG.md
  • Cargo.toml
  • README.md
  • crates/network-sim/Cargo.toml
  • crates/network-sim/src/adaptive_srt_send.c
  • crates/network-sim/src/harness.rs
  • crates/network-sim/src/lib.rs
  • deny.toml
  • docs/CONTROL_PROTOCOL.md
  • docs/KEYFRAME_PRIORITY.md
  • docs/RTT_THRESHOLD_SCHEDULING.md
  • src/config.rs
  • src/connection/ack_nak.rs
  • src/connection/batch_recv.rs
  • src/connection/batch_send.rs
  • src/connection/bitrate.rs
  • src/connection/congestion/classic.rs
  • src/connection/congestion/enhanced.rs
  • src/connection/congestion/mod.rs
  • src/connection/mod.rs
  • src/connection/packet_io.rs
  • src/connection/reconnection.rs
  • src/connection/rtt.rs
  • src/connection/socket.rs
  • src/control.rs
  • src/control_socket.rs
  • src/ewma.rs
  • src/kalman.rs
  • src/lib.rs
  • src/main.rs
  • src/metrics.rs
  • src/mode.rs
  • src/priority.rs
  • src/registration/mod.rs
  • src/registration/probing.rs
  • src/sender/connections.rs
  • src/sender/housekeeping.rs
  • src/sender/mod.rs
  • src/sender/packet_handler.rs
  • src/sender/reload.rs
  • src/sender/selection/blest.rs
  • src/sender/selection/classic.rs
  • src/sender/selection/classifier.rs
  • src/sender/selection/edpf.rs
  • src/sender/selection/enhanced.rs
  • src/sender/selection/exploration.rs
  • src/sender/selection/iods.rs
  • src/sender/selection/link_cc.rs
  • src/sender/selection/mod.rs
  • src/sender/selection/quality.rs
  • src/sender/selection/rtt_threshold.rs
  • src/sender/status.rs
  • src/stats.rs
  • src/subscriptions.rs
  • src/test_helpers.rs
  • src/tests/config_tests.rs
  • src/tests/connection_tests.rs
  • src/tests/integration_tests.rs
  • src/tests/keepalive_interop_tests.rs
  • src/tests/mod.rs
  • src/tests/protocol_tests.rs
  • src/tests/registration_tests.rs
  • src/tests/rtt_threshold_tests.rs
  • src/tests/sender_tests.rs
  • src/tests/stall_deselect_tests.rs
  • src/toml_config.rs
  • src/utils.rs
  • tests/common/mod.rs
  • tests/netns_basic.rs
  • tests/netns_failure.rs
  • tests/netns_impairment.rs
  • tests/netns_scenario.rs
  • tests/netns_wire_loss.rs
  • tests/parser_proptest.rs
  • tests/srtla_wire_conformance.rs

📝 Walkthrough

Walkthrough

This change adds two-mode scheduling, weak-link and congestion-control selection, monotonic timing, adaptive batching, JSON-RPC control, priority windows, Prometheus metrics, TOML/reload configuration, expanded protocol validation, and network-namespace integration coverage.

Changes

Runtime scheduling and transport

Layer / File(s) Summary
Timing, phases, RTT, and batching
src/utils.rs, src/connection/*
Connection timing uses injected monotonic milliseconds; RTT tracking, congestion recovery, link phases, socket binding, and adaptive batch send/receive paths are updated accordingly.
Selection and congestion control
src/mode.rs, src/sender/selection/*, src/stats.rs
Scheduling is limited to Classic and Enhanced modes, with stall gating, weak-link classification, per-link congestion state, in-flight caps, and expanded telemetry.
Sender orchestration
src/sender/*
Housekeeping, NAK attribution, critical-window routing, binder propagation, stats publication, and guarded IP-list reloads are integrated into the sender loop.

Control and observability

Layer / File(s) Summary
Runtime configuration and control
src/config.rs, src/control.rs, src/control_socket.rs, src/toml_config.rs
Configuration now supports stall controls and TOML loading; stdin and Unix-socket control use JSON-RPC methods and Unix-socket subscriptions.
Priority and metrics
src/priority.rs, src/subscriptions.rs, src/metrics.rs
A UDP critical-window sidecar, subscription hub, and Prometheus HTTP endpoint expose runtime controls and telemetry.

Validation and tooling

Layer / File(s) Summary
CI and dependency policy
.github/workflows/*, deny.toml, Cargo.toml, .gitignore
CI adds cargo-deny and Miri checks, dependency features are updated, and the adaptive sender source is preserved by ignore rules.
Protocol and unit coverage
src/tests/*, tests/parser_proptest.rs, tests/srtla_wire_conformance.rs
Tests cover explicit timing, registration and keepalive interoperability, malformed packets, wire layouts, parser properties, selection behavior, and congestion state transitions.
Network simulation
crates/network-sim/*, tests/common/*, tests/netns_*
The namespace harness adds adaptive SRT traffic, caller lifecycle management, readiness polling, configurable UDP payloads, and wire-loss end-to-end validation.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

  • irlserver/srtla_send#1: Earlier test-feature and CI setup connected to the current testing changes.
  • irlserver/srtla_send#7: Earlier connection, bitrate, RTT, and congestion refactoring related to the current timing changes.
  • irlserver/srtla_send#13: Earlier network-simulation harness work extended by the current adaptive sender and namespace tests.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch strata-port

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant