Skip to content

fix(sender): bind the local SRT listener before dialing uplinks - #20

Merged
andrescera merged 1 commit into
mainfrom
fix/bind-local-listener-before-uplinks
Jul 27, 2026
Merged

fix(sender): bind the local SRT listener before dialing uplinks#20
andrescera merged 1 commit into
mainfrom
fix/bind-local-listener-before-uplinks

Conversation

@andrescera

Copy link
Copy Markdown
Member

What

srtla_send now binds its local SRT listener (SRT_LISTEN_PORT) as the very first thing
run_sender_with_config does — before the ips file is read and before any bonded uplink is
dialed. Previously the bind happened after create_connections_from_ips().

Adds tests/startup_bind_ordering.rs (3 tests) to lock the ordering, plus the Rule-A doc
updates in AGENTS.md and README.md.

Why

CeraUI spawns srtla_send and then immediately tells the streaming engine to open an SRT
connection to 127.0.0.1:<SRT_LISTEN_PORT>. There is no readiness handshake between the two
steps — by design, since binding a UDP port should be instantaneous.

It wasn't. create_connections_from_ips() is a sequential loop: one resolve_remote + bind

  • connect await per uplink. All of that ran ahead of the local bind, so the port the encoder
    was already dialing stayed closed for the whole duration of bond setup. When the encoder's
    handshake landed in that window it went unanswered and SRT gave up with rejection code 16
    (SRT_REJ_TIMEOUT), which surfaces to the operator as a hard, non-retriable stream-start
    failure.

This was reproduced live on device: 2 of 3 start → stop → (1-3s) → start cycles failed with
internal engine error: transport connect: SRT connect failed (reason 16).

The window scales with the bond — N modems means up to N sequential resolve+connect round
trips before the bind — so the failure gets more likely on exactly the multi-link
deployments this sender exists for, not less. Fixing it here rather than adding a retry in
CeraUI removes the race instead of papering over it.

How to verify

Full gate on the pinned nightly, all green:

cargo build --release
cargo fmt --all -- --check
cargo clippy -- -D warnings
cargo test --lib                                  # 366 passed
timeout --foreground --kill-after=10s 300s cargo test --all-features
timeout --foreground --kill-after=10s 300s cargo test --features test-internals

The new test proves the regression, not just the fix. Reverting src/sender/mod.rs to the
old ordering and re-running cargo test --test startup_bind_ordering fails 2 of 3 tests,
with the captured process log showing the inversion directly:

INFO added uplink 127.0.0.1:9999 via 127.0.0.1
INFO listening for SRT on [::]:40860

After the fix those two lines are in the opposite order, and the multi-link test additionally
asserts the bind precedes even a failing uplink attempt — a link that errors or stalls must
not hold the local listener closed.

the_bound_port_is_really_taken_once_the_listener_is_logged checks the log isn't lying: once
listening for SRT is emitted, binding the same address from the test must fail with
AddrInUse.

The tests need no privileges, no network namespaces, and no reachable receiver.

Risks

Low. The change is a pure statement move inside one function — the bind expression itself is
byte-identical, and nothing between the old and new position reads local_listener.

  • No parity-contract behavior changes: binary name, CLI positional order, telemetry shape,
    SIGHUP reload semantics, empty-start, and clean-shutdown unlinking are all untouched.
  • --dry-run never reaches run_sender_with_config, so that path (which must bind no
    sockets) is unaffected — tests/dry_run.rs still passes.
  • main.rs startup order (config listener, telemetry writer) is unchanged.
  • reg.start_probing(&mut connections) and every other post-bind step still run in the same
    relative order against the connection pool.
  • One observable difference: on a fatal bind error (port already in use) the process now
    fails before attempting uplinks rather than after. That is strictly better — it no longer
    dials modems for a session it cannot serve.

run_sender_with_config bound the local SRT_LISTEN_PORT listener only after
create_connections_from_ips had walked every bonded uplink sequentially
(resolve + bind + connect per link). CeraUI spawns srtla_send and then
immediately tells the encoder to SRT-connect to that port with no readiness
handshake, so uplink setup latency left the port closed under an in-flight
handshake and the operator got a hard SRT_REJ_TIMEOUT stream-start failure.
Reproduced live on device on 2 of 3 start-stop-start cycles.

The window scales with the bond: N modems means up to N sequential connects
ahead of the bind, so the failure gets more likely on exactly the multi-link
deployments this sender exists for.

Binding a UDP port depends on nothing the uplinks provide, so the bind moves
to the top of the function, ahead of read_ip_list and the connect loop.
Nothing else changes: uplink setup, start_probing, SIGHUP reload, --dry-run
and the main.rs startup order are untouched, and no parity-contract behavior
is affected.

tests/startup_bind_ordering.rs locks the ordering (unprivileged, no receiver
needed); its two ordering assertions fail on the previous code.
@andrescera
andrescera merged commit 974c8b9 into main Jul 27, 2026
9 checks passed
@andrescera
andrescera deleted the fix/bind-local-listener-before-uplinks 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.

1 participant