Skip to content

tst-udp: fire-and-forget unconnected sender + IPv4-first hostname resolution - #147

Merged
aklofas merged 2 commits into
mainfrom
fix/udp-fire-and-forget-send
Aug 6, 2026
Merged

tst-udp: fire-and-forget unconnected sender + IPv4-first hostname resolution#147
aklofas merged 2 commits into
mainfrom
fix/udp-fire-and-forget-send

Conversation

@aklofas

@aklofas aklofas commented Aug 6, 2026

Copy link
Copy Markdown
Owner

Problem (live-reproduced in an integrator field report)

UdpTransport used a connected UDP socket. On Linux, a connected socket surfaces ICMP port-unreachable as ECONNREFUSED on a later send, which the send path classified as fatal TransportError::Broken. Real receivers (e.g. a media server that unbinds its idle ingest socket periodically, or simply restarts) therefore killed long-running senders — a transient condition became a dead transport. Our own pipeline_round_trip test had a workaround comment for exactly this behavior.

Fix

  • Unconnected socket + send_to(peer) — fire-and-forget datagram semantics, matching what every TS-over-UDP sender (ffmpeg, VLC, mediamtx) does. The failure class is deleted structurally; the struct rustdoc documents why this is deliberate. No knob: connected mode had no consumer.
  • IPv4-first hostname resolution: the resolve probe (bind+connect, no packets) can only reject unconfigured/unroutable families — it cannot detect an absent listener, so dual-stack localhost → [::1, 127.0.0.1] against an IPv4-only listener previously picked ::1 and died. resolve_host now prefers the first probe-clean IPv4, then first probe-clean any-family, then first resolved; the preference is documented.
  • TransportError::is_connection_refused() (std-gated, tst-core): portable refused-classification via the io::ErrorKind mapping, so consumers stop hard-coding the 111/61/10061 errno split.

Tests

  • send_to_absent_peer_never_errors — red pre-fix (real ECONNREFUSED on send P7(c-2) S2: cross-compile libsrt for bare-metal arm-none-eabi + boot smoke #2), green post-fix.
  • hostname_resolution_prefers_ipv4 — genuinely red pre-fix on this host (resolver returns ::1 first), green post-fix. An adjacent hostname-loopback test was un-flaked for IPv6-first resolvers as a direct consequence.
  • is_connection_refused_classifies_portably — per-OS errno constants through the helper; false for None/non-errno variants.

tst-core public-api baseline re-rendered for the one new method (+ surface-manifest coverage row); tst-udp surface unchanged. Full local rail battery green (3 feature modes, clippy, fmt, nightly doc, doc-tests, ratchet sweep, pinned public-api ×10, non_exhaustive 309, fuzz check, loopback binary stress ×5). No C ABI change.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR hardens UDP sending semantics and hostname resolution to match real-world TS-over-UDP sender/receiver behavior, and adds a portable helper for classifying connection-refused errors in tst-core.

Changes:

  • Switch UdpTransport from a connected UDP socket (send) to an unconnected socket using send_to(peer) to avoid Linux ECONNREFUSED surfacing as a fatal transport break.
  • Update udp:// hostname resolution to prefer IPv4 among “probe-clean” candidates to avoid dual-stack localhost choosing ::1 against IPv4-only listeners.
  • Add TransportError::is_connection_refused() (std-gated) plus new/updated regression tests and changelog/public-api baselines.

Reviewed changes

Copilot reviewed 7 out of 8 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
tests/coverage/surface-manifest.toml Adds surface coverage entry for the new TransportError::is_connection_refused API.
crates/tst-udp/tests/pipeline_round_trip.rs Updates test commentary to reflect unconnected UDP send behavior.
crates/tst-udp/tests/loopback_unicast.rs Adjusts hostname loopback test binding logic; adds regression test for “absent peer never errors”.
crates/tst-udp/src/url.rs Implements IPv4-first selection among probe-clean resolved addresses; adds resolution regression test.
crates/tst-udp/src/transport.rs Changes sender from connected UDP to unconnected send_to with stored peer address.
crates/tst-core/src/transport.rs Adds std-gated TransportError::is_connection_refused() plus a unit test.
crates/tst-core/public-api.txt Updates public API baseline for the new method.
CHANGELOG.md Documents the new helper and the UDP behavioral changes.
Suppressed comments (1)

crates/tst-core/src/transport.rs:215

  • This test hard-codes ECONNREFUSED values for only a few target OSes. On other platforms (e.g. netbsd/dragonfly or any non-matching target_os), refused will be undefined and the test won’t compile. Prefer libc::ECONNREFUSED for all Unix targets and keep the Windows constant for Windows.
        #[cfg(target_os = "linux")]
        let refused: i32 = 111; // ECONNREFUSED
        #[cfg(any(target_os = "macos", target_os = "freebsd", target_os = "openbsd"))]
        let refused: i32 = 61;
        #[cfg(windows)]
        let refused: i32 = 10061; // WSAECONNREFUSED

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread crates/tst-core/src/transport.rs Outdated
Comment on lines +204 to +206
#[cfg(test)]
mod error_tests {
use super::*;

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b31087berror_tests is now #[cfg(all(test, feature = "std"))]. Note for posterity: the in-workspace repro passes either way (a dev-dep re-enables tst-core/std via test-kind feature unification), so the genuine RED was demonstrated on the extracted cargo package tree, which is what a real downstream no-default-features consumer sees.

Comment thread crates/tst-udp/src/url.rs
Comment on lines +365 to +372
fn hostname_resolution_prefers_ipv4() {
let u = UdpUrl::parse("udp://localhost:5004").unwrap();
assert!(
u.addr.is_ipv4(),
"expected IPv4-first for localhost, got {}",
u.addr
);
}

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b31087b — the test now resolves localhost first and skips (with an eprintln) when the resolver returns no IPv4 candidate; the IPv4-preference assertion is unchanged whenever an A record exists.

Comment on lines +49 to +54
let ipv4 = ("localhost", 0u16)
.to_socket_addrs()
.expect("resolve localhost")
.next()
.expect("localhost resolved to no addresses");
let recv = UdpSocket::bind(first).expect("bind recv");
.find(|a| a.is_ipv4())
.expect("localhost resolved no IPv4 address");
let recv = UdpSocket::bind(ipv4).expect("bind recv");

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b31087b — the receiver bind now prefers an IPv4 candidate and falls back to the first resolved address of any family on IPv6-only hosts, mirroring resolve_host's prefer-not-require contract.

aklofas added 2 commits August 6, 2026 03:07
An integrator field report, reproduced against a live media server:
a receiver restarting or idling mid-stream sends back ICMP
port-unreachable, which a connected UDP socket surfaces on Linux as a
fatal ECONNREFUSED on the sender's next send. UdpTransport now sends
via send_to on an unconnected socket instead -- fire-and-forget
datagram semantics matching every other TS-over-UDP sender (ffmpeg,
VLC, mediamtx). No knob; this is the only mode.

Residual from the same report: a UDP connect-probe used to pick a
resolved hostname candidate can reject an unconfigured/unroutable
address family but can't detect an absent listener, so `localhost`
resolving [::1, 127.0.0.1] on a dual-stack host picked whichever
family the resolver listed first and could die against an IPv4-only
listener. resolve_host now prefers IPv4 among probe-clean candidates.

Also adds TransportError::is_connection_refused() (std-gated) so
callers can classify a refused connection without hard-coding the
platform errno split.
…t tolerance

TransportError's error_tests module was #[cfg(test)] only, so a truly
standalone no_std build of tst-core (no dev-dependency unifying std
back in) would fail to compile it against the std-gated
is_connection_refused method it exercises -- gate the module on
feature = "std" too.

hostname_resolution_prefers_ipv4 and the loopback_unicast hostname
test both assumed an IPv4 localhost record exists. Neither requirement
holds on an IPv6-only host: the unit test now skips (with an eprintln)
when there's no IPv4 candidate to prefer, and the loopback test falls
back to the first resolved candidate, mirroring resolve_host's own
prefer-not-require contract.
@aklofas
aklofas force-pushed the fix/udp-fire-and-forget-send branch from b31087b to 120add8 Compare August 6, 2026 10:07
@aklofas
aklofas merged commit f68bca0 into main Aug 6, 2026
26 checks passed
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