Skip to content

chore: merge upstream irlserver/srtla_send c9f6bb2 (0 behind) + selective port set - #24

Merged
andrescera merged 168 commits into
mainfrom
merge/upstream-2026-08
Aug 16, 2026
Merged

chore: merge upstream irlserver/srtla_send c9f6bb2 (0 behind) + selective port set#24
andrescera merged 168 commits into
mainfrom
merge/upstream-2026-08

Conversation

@andrescera

Copy link
Copy Markdown
Member

Summary

  • Merges 138 upstream commits from irlserver/srtla_send through c9f6bb2; branch is 0 behind main.
  • Selectively ports 8 adopted upstream behaviors: NAK offset-16, monotonic now_ms, RTT-velocity gate, sendmmsg/unconnected sockets, ACK-RTT ownership, EDPF velocity+BDP, DNS drift detection, and CLI-on-library deduplication.
  • Triage and rationale: docs/notes/upstream-sync-2026-08-evaluation.md.

Evaluation and registration hardening

  • The switch-cooldown behavior was evaluated with an A/B test and rejected based on the measured result; it is not included.
  • Registration hardening keeps three security fixes: REG3-replay, REG2-retry-rearm, and REG_ERR DoS.
  • Two later registration-hardening rounds were reverted after live-proving a liveness regression. The known residual and follow-up are documented in AGENTS.md under ROBUSTNESS FIXES.

Validation

The full authoritative gate is green per .omo/evidence/task-15-upstream-sync-irlserver.md: build, fmt, clippy, tests, Loom, Miri, audit, deny, contract scripts, and the TypeScript binding gate.

Merge policy

This is an upstream-sync PR. Per repository policy, upstream-sync PRs are never squashed: the true-merge history is the point of the change. Please merge with the true merge commit after CI passes; do not squash.

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.
andrescera and others added 28 commits August 15, 2026 23:26
…tent ported in follow-up commits per docs/notes/upstream-sync-2026-08-evaluation.md)
… hardening

The SRT control header is 16 bytes (type, type-specific info, timestamp,
destination socket id), but parse_srt_nak read the loss list from offset 4.
Every NAK therefore decoded three header words as lost sequence numbers while
dropping the real leading loss-list entries.

Add SRT_CONTROL_HEADER_LEN = 16 and read the loss list there; a frame shorter
than 20 bytes carries no loss list and yields an empty result.

Introduce SrtSeq, a newtype over the 31-bit sequence-number domain with
RFC1982-style modular comparison (serial_lt/le/gt/ge), a directional distance,
and a next() that wraps 0x7FFF_FFFF to 0. Range expansion now steps with
next() and breaks after emitting the end value, so a start==end==0x7FFF_FFFF
range emits exactly one entry instead of running the ring, and wrap-crossing
ranges expand correctly. Descending ranges, and the antipodal pair where
serial order is undefined, expand to nothing. A range end word with bit 31 set
is not a sequence number: that range is skipped and parsing continues.

parse_srt_nak now returns NakList { seqs, truncated }. The 1000-entry cap is
global across singles and range expansion, and hitting it stops parsing and
sets truncated so the loss report is not silently incomplete. The parser stays
pure; the caller in connection/packet_io.rs emits the warning, rate limited to
one per second per connection via SrtlaConnection::last_trunc_warn_ms.

Migrate every NAK fixture off the old 8-byte offset-4 assumption, using a
nonzero timestamp and socket id so a regression back to offset 4 would surface
those words instead of hiding in zeros. Add coverage for the ignored control
header, boundary sequence numbers, ranges at the domain maximum, wrap
crossing, descending and invalid-endpoint ranges, both sides of the truncation
boundary, and lengths 16..19. A dedicated test feeds the old offset-4 frame and
asserts it now decodes to nothing, documenting the deliberate break.
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
…wedge

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
…la_send, not a hardcoded target/debug path

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
…ND_BIN override

Measurement infrastructure for the switch-cooldown / flush-on-switch A/B
evaluation of upstream 0cc0c6d and 24b5f64. Committed unconditionally: a
rejected step still leaves reusable measurement tooling.

- src/ab_metrics.rs: three process-lifetime monotonic AtomicU64 counters
  (switch / NAK / cooldown-hold), Arc-shared, never reset. Deliberately not
  sourced from SrtlaConnection::nak_count (reset by the congestion controller)
  or SharedStats (housekeeping-cadence snapshots). With test-internals off every
  entry point is an empty inline fn, so the shipped binary is unchanged.
- config.rs: test-internals-gated `metrics` runtime command returning one JSON
  line, so a runner can difference the counters across an exact window.
- Increment sites: the NAK loop (packet_handler), the Some(prev)->Some(new)
  connection switch (initial selection excluded), and both cooldown-suppression
  returns (enhanced + rtt-threshold).
- tests/ab_switch_eval.rs: #[ignore]d paired runner over a real SRT path
  (srt-live-transmit caller -> srtla_send -> srtla_rec -> sink) with the frozen
  deterministic scenario (3 uplinks, fixed 20/60/120 ms delay, no loss or
  jitter, 4 Mbit/link, 6 Mbit offered, 5 s warm-up, 30 s window, 3 alternating
  paired runs per mode), plus a cheap single-run smoke test.
- network-sim: wait_for_registered_uplinks exposed as a free function so stacks
  assembled outside SrtlaTestStack share one readiness definition;
  SrtlaTestStack delegates to it.
- test_helpers: advance_test_clock is cfg(test) only. tokio::time::advance needs
  tokio/test-util, which arrives via dev-dependencies, so gating it on
  test-internals made `cargo build --features test-internals` fail to compile.

Step A (remove flush-on-switch) measured REJECT under the pre-committed numeric
adoption rule; Step B is therefore dependent-on-rejected and unmeasured. No
production selection behavior changes in this commit.
… gate

tests/netns_unconnected.rs was added alongside the sendmmsg/unconnected-socket
work but never listed in scripts/netns_test_gate.sh. AGENTS.md directs operators
to run privileged targets only through that bounded runner, so the new target
had no bounded path and was exercised only by an unbounded --all-features run.

Gate rerun: 7/7 targets executed and green, netns_unconnected 2 passed in 11.02s,
well inside the 90s per-target cap.
…nnable

`uv run scripts/workflow_authority_contract_test.py` is a documented gate
command but had neither PEP-723 inline metadata nor a __main__ entrypoint:
it exited 1 on ModuleNotFoundError for yaml, and once the dependency was
declared it exited 0 having run zero tests. Its five assertions only ever
executed indirectly, via the import in release_workflow_contract_test.py.

Add the same inline metadata and unittest.main() entrypoint both sibling
contract scripts already carry, and ignore the __pycache__/ directory those
scripts produce so a gate run leaves the tree clean.

Standalone run now reports 5 tests OK; release_workflow_contract_test.py
still reports 13 tests OK.
…ool reorder

Two gaps in the REG3 phase gate added by the sendmmsg/unconnected-sockets todo.

handle_reg3 checked awaiting_reg3 membership but never consumed the grant on the
success path (unlike handle_reg_err, which removes it). An index therefore stayed
"awaiting" forever after its first successful REG3, so a duplicate or replayed
REG3 -- a receiver retransmission, or a spoofed frame from any host that can
reach the uplink's ephemeral port, which the accept-any source policy explicitly
permits -- re-fired RegistrationEvent::Reg3 and made packet_io call
clear_pre_registration_state() on a live, forwarding link, wiping packet_log,
in_flight_packets, highest_acked_seq, congestion state and the batch queue. The
grant is now removed on success, so a replay falls through to Reg3OutOfPhase
(counted + ignored) and a legitimate reconnect re-arms it via send_reg2_to.

SrtlaRegistrationManager is a single process-lifetime instance whose
awaiting_reg3 / pending_reg2_idx / reg1_target_idx / probe_results are keyed by
the positional index into the connections vector, but apply_connection_changes
rebuilds that vector in ips-file order on SIGHUP without touching them. A stale
grant could then authorize a REG3 on whichever uplink inherited the index. The
reload now calls reset_index_scoped_state() whenever the pool order actually
changed; surviving links keep their own SrtlaConnection::connected state, so only
incomplete registration attempts are discarded and they retry. An unchanged
reload still disturbs nothing.

Tests: replayed_reg3_does_not_wipe_a_live_connection drives the real
process_packet path and asserts the in-flight state is untouched by the replay;
sighup_reorder_clears_stale_registration_index_state and
sighup_unchanged_list_keeps_registration_index_state pin both halves of the
reload behavior. Each fails on the pre-fix code.
Two rows in the 138-commit triage table were stale.

b909220 (network-sim child-pipe drain) read REJECT / "not confirmed needed",
but todo 8 verified the wedge against our own harness and implemented it as
d08c517 (122 insertions in crates/network-sim/src/harness.rs, regression test
child_pipes_are_drained_during_sustained_output). Now ADOPT-WITH-FORK-FIX --
fork-fix rather than a clean port because the drain threads are shaped for our
harness rather than copied from upstream.

86b90aa (bind uplinks by interface index on apple targets) read ADOPT, but
nothing was ever ported: git grep for apple / IP_BOUND_IF finds nothing under
src/, and no todo in this plan had it in scope. Now DEFER-FOLLOWUP.

Row count, PENDING-EVAL count and verdict-vocabulary completeness are unchanged
(138 / 0 / 138); check-doc-refs.sh still exits 0.
…etry

A partially failed REG2 broadcast retries on the next tick, but the retry
resent to every uplink unconditionally — including ones already connected
whose one-shot awaiting_reg3 grant handle_reg3 had consumed. The re-insert
re-armed the gate, so a receiver-retransmitted REG3 could again wipe a
live, forwarding uplink's packet log, in-flight count, and congestion
state — the bug eaced59 fixed, reached through the broadcast retry.

The retry now skips uplinks that are connected or already awaiting REG3.

Also documents the registration hardening (eaced59 + this fix) in
AGENTS.md/README.md per Rule A, corrects the triage doc's stale
'swap only on success' DNS wording to the detect-only design todo 12
actually shipped (85f5544) plus a DEFER-FOLLOWUP note for coordinated
whole-bond receiver migration, and adds the missing todo-7 row to
todo 14's acceptance matrix.
…iled REG2

A REG_ERR was acted on unconditionally: `handle_reg_err` ran for any index and
`packet_io.rs` turned every one into `connected = false` on the receiving link.
Uplink sockets are deliberately unconnected and SRTLA control frames carry no
authentication, so a forged 2-byte REG_ERR from anything able to reach an
uplink's ephemeral port force-disconnected an established, actively-forwarding
link — and, because the handler cleared the global `pending_reg2_idx` /
`reg1_target_idx` / `pending_timeout_at_ms`, it also aborted an unrelated
uplink's concurrent handshake.

REG_ERR is now gated exactly like REG3: honored only when the index is genuinely
mid-registration (`pending_reg2_idx == Some(idx)` or an `awaiting_reg3` member).
Rejected frames increment `out_of_phase_reg_err` and return the new
`RegistrationEvent::RegErrOutOfPhase`, a no-op for the caller. In-phase clearing
is scoped to the state that index actually owns.

`send_reg2_to` additionally revokes any pre-existing `awaiting_reg3` entry when
the send fails, so a failed resend cannot leave a REG3 authorization alive for a
socket generation that was never re-armed.

Pinned by four new tests in `src/tests/batch_io_tests.rs`; the three defect tests
were proven to fail against a scratch mutation restoring the old behavior.
…n state

A REG_NGP restarts the handshake at REG1, which re-arms `pending_reg2_idx` —
exactly the state the REG_ERR phase gate treats as in-phase. `handle_reg_ngp`
accepted one on `active_connections == 0 && pending_reg2_idx.is_none()`, so a
forged REG_NGP followed by a REG_ERR could still tear down an established uplink,
routing around the REG_ERR gate through a legal-looking transition.

`active_connections` is a manager-side counter recomputed only by a housekeeping
tick, while `SrtlaConnection::connected` is set immediately in the REG3 dispatch,
so a genuinely connected link briefly reads zero. `handle_reg3` has also already
consumed that index's `awaiting_reg3` entry by then, so a grant check alone does
not close the window.

Acceptance now requires that nothing is in flight anywhere — no pending REG2, no
outstanding REG3 grant on any index — and reads the uplink's own `connected`
flag, threaded through `process_registration_packet` from `packet_io.rs`. The
probing branch is unchanged.

Also records the round-3 certified HEAD (`ded2854`) in the task-15 evidence
ledger, and appends the round-4 re-certification.
…rand the sender

`handle_reg2` clears `pending_reg2_idx` and re-points `pending_timeout_at_ms` at a
`REG3_TIMEOUT` deadline in the same statement block, but `clear_pending_if_timed_out`
only fires `if let Some(idx) = self.pending_reg2_idx` — already `None` by then. The
REG3-wait deadline was therefore set and never consulted, so an `awaiting_reg3` grant
never expired.

Round 4 made that permanent: `handle_reg_ngp` refuses to restart registration while any
grant is outstanding, so a receiver that restarts between our REG2 and its REG3 answers
the group it no longer knows with an entirely legitimate fresh REG_NGP, which the sender
then refuses for the life of the process.

Add `clear_awaiting_reg3_if_timed_out`, called from the same housekeeping tick right
after `clear_pending_if_timed_out`: at the deadline it revokes every outstanding grant,
zeroes the deadline, drops a queued REG2 rebroadcast (it would re-arm the grants just
revoked), and re-opens the REG1 path. A pending REG2 cedes ownership back to the REG2
seam. The recovery is timeout-bounded, not an open door — the fresh REG1/REG2/REG3 cycle
re-arms and re-gates.

Tests: `expired_reg3_grant_lets_a_fresh_reg_ngp_restart_registration` drives the whole
chain through `SrtlaConnection::process_packet`, including a second full cycle proving
the gate re-closes; `housekeeping_expires_a_stale_reg3_grant` pins the production call
site; `reg3_timeout_fires_at_4s_logical` no longer hand-restores `pending_reg2_idx` (it
was silently exercising the REG2 seam) and now runs on the state a real REG1 → REG2 →
REG2-broadcast leaves behind.
…lback

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
…a port-0 peer

Both send-failure helpers simulated a failing uplink by naming a port-0 peer.
That is only synchronously invalid on Linux; macOS accepts an unconnected
sendto to port 0, so the ten tests that exercise the send-failure recovery
paths observed a successful send and never reached the code under test.

The dependency is new to this branch: uplink sockets used to be connect(2)-ed,
and connect() to port 0 is rejected uniformly across platforms. Unconnected
sockets moved the validation to per-datagram sendto, where the semantics
diverge.

BatchUdpSocket now carries a cfg(test)/test-internals AtomicBool that makes
every send path return a synthetic ConnectionRefused, so the trigger no longer
depends on OS destination validation at all. Outside test builds the field is
absent and the check is an inline(always) None, matching the ab_metrics
zero-cost pattern. No assertion was weakened.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
@andrescera
andrescera merged commit d12c9b7 into main Aug 16, 2026
10 checks passed
@andrescera
andrescera deleted the merge/upstream-2026-08 branch August 19, 2026 13:24
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.

2 participants