fix(transport): treat Windows WSAENOBUFS as a transient send drop, not a stream teardown
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 7m18s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 8m14s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 9m17s
ci / web (push) Successful in 55s
ci / docs-site (push) Successful in 57s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 10s
decky / build-publish (push) Successful in 21s
apple / swift (push) Successful in 1m20s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 17s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 15s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 12s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 11s
ci / bench (push) Successful in 7m7s
flatpak / build-publish (push) Failing after 8m7s
docker / deploy-docs (push) Successful in 22s
release / apple (push) Successful in 9m3s
windows-host / package (push) Failing after 11m41s
deb / build-publish (push) Successful in 13m34s
ci / rust (push) Failing after 14m2s
arch / build-publish (push) Successful in 14m59s
android / android (push) Successful in 16m45s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 15m16s
apple / screenshots (push) Successful in 6m38s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 7m16s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 17m38s

`is_transient_io` only recognized `ENOBUFS` on unix; on Windows the
`#[cfg(not(unix))]` arm returned `false`, so `WSAENOBUFS` (10055) — which
Rust maps to `ErrorKind::Uncategorized`, missing the `WouldBlock` arm —
propagated out of the USO `send_gso` path through `Session::send_sealed`
and killed the session with `native::stream` "send failed — stopping
stream". A high-bitrate keyframe burst (one `WSASendMsg` USO super-buffer
is up to ~512 segments) momentarily exhausts the socket send buffer / AFD
non-paged pool; it's a lossy drop that FEC + the next frame recover,
exactly like the unix `ath11k` `ENOBUFS` case the classifier already
handles. Fires independently of client platform (Windows/macOS clients
both saw the crash) because it's the Windows *host's* send socket.

Add a `#[cfg(windows)]` arm matching `WSAENOBUFS` plus the
`WSAENET*`/`WSAEHOST*` network-path family (the Windows counterparts of
the droppable unix set), and extend the classifier test with per-platform
raw-errno coverage so the Windows CI runner exercises the 10055 path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-18 01:43:15 +02:00
parent 11cca33300
commit 08a397cf08
+70 -2
View File
@@ -45,12 +45,20 @@ const RECV_BUF: usize = MAX_DATAGRAM_BYTES + 1;
/// died at full rate over WiFi). Same lossy-drop contract as `WouldBlock`; FEC + the next frame /// died at full rate over WiFi). Same lossy-drop contract as `WouldBlock`; FEC + the next frame
/// recover. Asynchronous network-path blips (`ENETUNREACH`/`EHOSTUNREACH`/`ENETDOWN`/`EHOSTDOWN`) /// recover. Asynchronous network-path blips (`ENETUNREACH`/`EHOSTUNREACH`/`ENETDOWN`/`EHOSTDOWN`)
/// are droppable for the same reason a stale ICMP is. /// are droppable for the same reason a stale ICMP is.
/// - Windows `WSAENOBUFS` (10055): the exact analogue of unix `ENOBUFS` — a high-bitrate keyframe
/// burst (one `WSASendMsg` USO super-buffer is up to ~512 segments ≈ 700 KB) momentarily exhausts
/// the socket send buffer / AFD non-paged pool, and Winsock reports `WSAENOBUFS`, which Rust maps
/// to `ErrorKind::Uncategorized` (so the `WouldBlock` arm misses it, exactly like unix `ENOBUFS`).
/// Without treating it as transient a Windows host tears the whole session down under load
/// (observed live: `native::stream` "send failed — stopping stream" on a paced video burst). Same
/// lossy-drop contract; FEC + the next frame recover. The `WSAENET*`/`WSAEHOST*` family is the
/// Windows counterpart of the droppable unix network-path blips above.
fn is_transient_io(e: &std::io::Error) -> bool { fn is_transient_io(e: &std::io::Error) -> bool {
use std::io::ErrorKind::{ConnectionRefused, ConnectionReset, WouldBlock}; use std::io::ErrorKind::{ConnectionRefused, ConnectionReset, WouldBlock};
if matches!(e.kind(), WouldBlock | ConnectionRefused | ConnectionReset) { if matches!(e.kind(), WouldBlock | ConnectionRefused | ConnectionReset) {
return true; return true;
} }
// `ENOBUFS` & friends have no stable `ErrorKind`, so match the raw errno (unix only). // `ENOBUFS` & friends have no stable `ErrorKind`, so match the raw errno.
#[cfg(unix)] #[cfg(unix)]
{ {
matches!( matches!(
@@ -62,7 +70,20 @@ fn is_transient_io(e: &std::io::Error) -> bool {
| Some(libc::EHOSTDOWN) | Some(libc::EHOSTDOWN)
) )
} }
#[cfg(not(unix))] // Windows Winsock codes (WSAE*), raw like the sibling `uso_unsupported`. WSAEWOULDBLOCK (10035)
// already maps to `ErrorKind::WouldBlock` above, so it isn't repeated here.
#[cfg(windows)]
{
matches!(
e.raw_os_error(),
Some(10055) // WSAENOBUFS — tx queue / send buffer full (the dominant high-bitrate drop)
| Some(10051) // WSAENETUNREACH
| Some(10065) // WSAEHOSTUNREACH
| Some(10050) // WSAENETDOWN
| Some(10064) // WSAEHOSTDOWN
)
}
#[cfg(not(any(unix, windows)))]
{ {
false false
} }
@@ -324,6 +345,53 @@ mod tests {
} }
} }
/// The raw-errno tx-queue-full / network-blip codes have no stable `ErrorKind` (they surface as
/// `Uncategorized`), so they only get caught by the platform `raw_os_error()` arms. A burst that
/// momentarily exhausts the send buffer must stay a lossy drop, never a teardown — this is the
/// regression guard for the Windows `WSAENOBUFS` (10055) session crash and the unix `ENOBUFS`
/// wlan-driver case. Gated per platform because a code is only classified on its own OS.
#[test]
fn transient_io_covers_raw_tx_queue_and_path_codes() {
use std::io::Error;
#[cfg(unix)]
{
for code in [
libc::ENOBUFS,
libc::ENETUNREACH,
libc::EHOSTUNREACH,
libc::ENETDOWN,
libc::EHOSTDOWN,
] {
assert!(
is_transient_io(&Error::from_raw_os_error(code)),
"unix errno {code} should be transient"
);
}
// A genuine failure with no stable ErrorKind must still tear down.
assert!(
!is_transient_io(&Error::from_raw_os_error(libc::EACCES)),
"EACCES must stay fatal"
);
}
#[cfg(windows)]
{
// WSAENOBUFS / WSAENETUNREACH / WSAEHOSTUNREACH / WSAENETDOWN / WSAEHOSTDOWN.
for code in [10055, 10051, 10065, 10050, 10064] {
assert!(
is_transient_io(&Error::from_raw_os_error(code)),
"WSA code {code} should be transient"
);
}
// WSAEACCES (10013) — a real failure that must stay fatal.
assert!(
!is_transient_io(&Error::from_raw_os_error(10013)),
"WSAEACCES must stay fatal"
);
}
}
/// `send_batch` delivers a whole frame's worth of packets over real loopback UDP — exercising /// `send_batch` delivers a whole frame's worth of packets over real loopback UDP — exercising
/// the `sendmmsg` path on Linux (the scalar-loop default elsewhere). 100 × 200 B = 20 KB fits /// the `sendmmsg` path on Linux (the scalar-loop default elsewhere). 100 × 200 B = 20 KB fits
/// the socket buffer, so loopback is lossless and every packet must arrive intact + in order. /// the socket buffer, so loopback is lossless and every packet must arrive intact + in order.