From f01ea2944657ec3c5b2f416923aef621af6c4e3b Mon Sep 17 00:00:00 2001 From: Arshia Ghafoori Date: Thu, 9 Jul 2026 08:56:42 +0000 Subject: [PATCH 1/4] fix(wasix): de-flake udp-large-recv and stream-tcp-writev-partial tests Both socket integration tests were flaky under nextest (issue #6785), timing out / returning wrong values under load, primarily on macOS. udp-large-recv relied on reliable delivery of a single 20480-byte UDP datagram over loopback. UDP is best-effort, so a dropped datagram left the blocking recvfrom to wait out the socket's 30s read timeout and return -1. Retry the exchange a few times with a poll()-bounded wait (wasix-libc does not wire SO_RCVTIMEO), skip (exit 0) if no datagram is ever delivered, and only fail on truncation/sharding/corruption when a datagram does arrive. stream-tcp-writev-partial relied on the peer's RST landing in the window between two back-to-back internal send() calls of a single writev, which is inherently racy (returns first-iovec len, full len, or -1 depending on timing). Rewrite it to exercise the same "return bytes already transferred" fd_write contract via a deterministic short write: a tiny first iovec followed by an oversized second iovec that can never fully fit the send buffer. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../socket/stream-tcp-writev-partial/main.c | 109 +++++++++++------- .../wasm_tests/socket/udp-large-recv/main.c | 88 +++++++++++--- 2 files changed, 139 insertions(+), 58 deletions(-) diff --git a/lib/wasix/tests/wasm_tests/socket/stream-tcp-writev-partial/main.c b/lib/wasix/tests/wasm_tests/socket/stream-tcp-writev-partial/main.c index e1e7ff7d5c0c..45ed2ecdb18d 100644 --- a/lib/wasix/tests/wasm_tests/socket/stream-tcp-writev-partial/main.c +++ b/lib/wasix/tests/wasm_tests/socket/stream-tcp-writev-partial/main.c @@ -1,53 +1,55 @@ -//#ExpectedStdout: stream TCP writev returns partial success after peer close +//#ExpectedStdout: stream TCP writev returns partial count on short write /* * Regression test for stream-socket fd_write partial success. * - * WASIX implements stream writev(2) as a loop of per-iovec send() calls. When - * a later send() fails after earlier iovecs succeeded, fd_write must return the - * bytes already transferred (POSIX writev semantics) instead of failing the - * whole syscall. + * WASIX implements stream writev(2) as a loop of per-iovec send() calls. When a + * later iovec cannot be fully transferred after earlier iovecs already + * succeeded, fd_write must return the number of bytes already transferred + * (POSIX writev semantics) instead of failing the whole syscall. * - * Approach: - * 1. Connect a loopback TCP client and server. - * 2. Accept the connection and immediately close the server socket. - * 3. Client writev() with two small iovecs. The first per-iovec send() still - * succeeds, the second returns EPIPE/EAGAIN, and the syscall must return - * only the first iovec length. + * Approach (deterministic): + * 1. Connect a loopback TCP client and server, and never read on the server + * so the connection's buffers can be driven full. + * 2. Make the client non-blocking so a full send buffer produces a short + * write instead of blocking. + * 3. writev() two iovecs: a tiny first one and a second one far larger than + * any loopback socket buffer. On an empty send buffer the first iovec is + * always accepted in full; the oversized second iovec can never fit, so + * its send() is a short write. fd_write must break out of the per-iovec + * loop and return the partial total (first iovec + whatever of the second + * was accepted). * - * Why this is used instead of SO_SNDBUF/window filling: - * wasm_tests talk to host TCP. Virtual SO_SNDBUF/SO_RCVBUF tuning is ignored - * for host sockets, so "fill the send buffer then writev" still completes the - * second iovec via partial Ok(...) sends rather than Err(...). Closing the - * peer after accept reliably drives the second per-iovec send() down the - * error path while the first one has already succeeded, which is exactly the - * branch fixed in fd_write. - * - * This depends on WASIX's per-iovec stream writev implementation rather than on - * atomic host-kernel writev behaviour. + * Why this shape: + * The obvious alternative - close the peer and rely on a later send() failing + * with EPIPE/ECONNRESET after an earlier one succeeded - is inherently racy. + * That requires the peer's RST to be processed in the window between two + * back-to-back internal send() calls of a single writev; depending on RST + * timing the syscall returns the first iovec length, the full length, or -1, + * which made this test flaky (issue #6785). Virtual SO_SNDBUF/SO_RCVBUF + * tuning is a no-op for host sockets, so the buffer cannot be shrunk to make + * the boundary controllable either. Forcing a short write with an oversized + * iovec exercises the same "return bytes already transferred" contract with + * no dependence on asynchronous error timing. */ #include #include -#include +#include +#include #include +#include #include #include #include #include -enum { FIRST_IOV_LEN = 5, SECOND_IOV_LEN = 5 }; - -static int accept_one(int listener, struct sockaddr_in* peer) { - socklen_t len = sizeof(*peer); - memset(peer, 0, sizeof(*peer)); - return accept(listener, (struct sockaddr*)peer, &len); -} +enum { FIRST_IOV_LEN = 5 }; -static int close_peer(int server) { return close(server); } +// Larger than any plausible loopback TCP send+receive buffer, so the second +// iovec is guaranteed to be a short write regardless of host buffer autotuning. +#define SECOND_IOV_LEN (64 * 1024 * 1024) int main(void) { - signal(SIGPIPE, SIG_IGN); - int listener = socket(AF_INET, SOCK_STREAM, 0); if (listener < 0) { perror("socket(listener)"); @@ -97,8 +99,8 @@ int main(void) { return 1; } - struct sockaddr_in peer; - int server = accept_one(listener, &peer); + // Accept but never read, so the send path can be driven to a short write. + int server = accept(listener, NULL, NULL); if (server < 0) { perror("accept(server)"); close(client); @@ -107,27 +109,50 @@ int main(void) { } close(listener); - if (close_peer(server) != 0) { - perror("close_peer(server)"); + // Non-blocking: a full send buffer yields a short write instead of blocking. + int flags = fcntl(client, F_GETFL, 0); + if (flags < 0 || fcntl(client, F_SETFL, flags | O_NONBLOCK) != 0) { + perror("fcntl(O_NONBLOCK)"); + close(client); + close(server); + return 1; + } + + char* big = malloc(SECOND_IOV_LEN); + if (big == NULL) { + fprintf(stderr, "malloc failed\n"); close(client); + close(server); return 1; } + memset(big, 'w', SECOND_IOV_LEN); struct iovec iov[2] = { {.iov_base = "hello", .iov_len = FIRST_IOV_LEN}, - {.iov_base = "world", .iov_len = SECOND_IOV_LEN}, + {.iov_base = big, .iov_len = SECOND_IOV_LEN}, }; + ssize_t written = writev(client, iov, 2); - if (written != (ssize_t)FIRST_IOV_LEN) { + + // The first iovec always fits an empty send buffer, and the oversized second + // iovec never fully fits, so the result must be a partial total: strictly + // greater than the first iovec length and strictly less than the full length. + // A whole-syscall failure (the regression) would surface as -1 here. + if (written <= (ssize_t)FIRST_IOV_LEN || + written >= (ssize_t)(FIRST_IOV_LEN + (size_t)SECOND_IOV_LEN)) { fprintf(stderr, - "expected writev to return %d bytes after peer close, got %zd " - "errno=%d (%s)\n", - FIRST_IOV_LEN, written, errno, strerror(errno)); + "expected partial writev in (%d, %zu), got %zd errno=%d (%s)\n", + FIRST_IOV_LEN, FIRST_IOV_LEN + (size_t)SECOND_IOV_LEN, written, + errno, strerror(errno)); + free(big); close(client); + close(server); return 1; } + free(big); close(client); - puts("stream TCP writev returns partial success after peer close"); + close(server); + puts("stream TCP writev returns partial count on short write"); return 0; } diff --git a/lib/wasix/tests/wasm_tests/socket/udp-large-recv/main.c b/lib/wasix/tests/wasm_tests/socket/udp-large-recv/main.c index 468dfa682aa9..e2578c4edbc5 100644 --- a/lib/wasix/tests/wasm_tests/socket/udp-large-recv/main.c +++ b/lib/wasix/tests/wasm_tests/socket/udp-large-recv/main.c @@ -1,7 +1,26 @@ -//#ExpectedStdout: large UDP datagram receive works +/* + * udp-large-recv: when a large UDP datagram is delivered, the runtime must + * return it whole and uncorrupted. + * + * The payload is deliberately larger than the sock_recv_from fast-path + * threshold (10240 bytes) so this exercises the heap-allocated large-recv path. + * + * UDP delivery over loopback is best-effort: a single large datagram can be + * silently dropped, which is common on macOS (the default + * net.inet.udp.maxdgram is 9216, so an oversized datagram may be rejected + * outright, and loopback drops large datagrams under load) and under nextest's + * concurrent-process load. A datagram that never arrives is NOT the behaviour + * under test, so we retry a few times and, if delivery never succeeds, skip + * (exit 0) rather than blocking on a 30s recv timeout and failing. + * + * The real assertion is integrity: whenever a datagram IS delivered it must + * have the exact length and payload we sent (no truncation / sharding / + * corruption). + */ #include #include +#include #include #include #include @@ -9,6 +28,8 @@ #include #define PAYLOAD_SIZE 20480 +#define MAX_ATTEMPTS 8 +#define RECV_TIMEOUT_MS 1000 static uint8_t sendbuf[PAYLOAD_SIZE]; static uint8_t recvbuf[PAYLOAD_SIZE]; @@ -41,25 +62,60 @@ int main(void) { sendbuf[i] = (uint8_t)(i & 0xff); } - if (sendto(sender, sendbuf, PAYLOAD_SIZE, 0, (struct sockaddr*)&addr, - sizeof(addr)) != PAYLOAD_SIZE) { - perror("sendto"); - return 1; - } + for (int attempt = 0; attempt < MAX_ATTEMPTS; ++attempt) { + ssize_t nsent = sendto(sender, sendbuf, PAYLOAD_SIZE, 0, + (struct sockaddr*)&addr, sizeof(addr)); + if (nsent != PAYLOAD_SIZE) { + // e.g. macOS rejects datagrams larger than net.inet.udp.maxdgram with + // EMSGSIZE. That is not the behaviour under test, so treat it as a + // dropped attempt. + fprintf(stderr, "attempt %d: sendto returned %zd (errno %d: %s)\n", + attempt, nsent, errno, strerror(errno)); + continue; + } - ssize_t nread = recvfrom(receiver, recvbuf, sizeof(recvbuf), 0, NULL, NULL); - if (nread != PAYLOAD_SIZE) { - fprintf(stderr, "expected %d-byte datagram, got %zd\n", PAYLOAD_SIZE, - nread); - return 1; - } - if (memcmp(sendbuf, recvbuf, PAYLOAD_SIZE) != 0) { - fprintf(stderr, "payload mismatch\n"); - return 1; + // Wait for the datagram with a bounded timeout so a dropped datagram costs + // RECV_TIMEOUT_MS instead of the socket's default 30s read timeout. + // (wasix-libc does not wire SO_RCVTIMEO, so poll() is used instead.) + struct pollfd pfd = {.fd = receiver, .events = POLLIN}; + int pr = poll(&pfd, 1, RECV_TIMEOUT_MS); + if (pr <= 0 || (pfd.revents & POLLIN) == 0) { + // Timed out waiting for the datagram (dropped in transit); retry. + fprintf(stderr, "attempt %d: poll timed out (pr=%d revents=0x%x)\n", + attempt, pr, pfd.revents); + continue; + } + + ssize_t nread = recvfrom(receiver, recvbuf, sizeof(recvbuf), 0, NULL, NULL); + if (nread < 0) { + fprintf(stderr, "attempt %d: recvfrom failed (errno %d: %s)\n", attempt, + errno, strerror(errno)); + continue; + } + + // A datagram was delivered: it must match exactly. Anything else is the + // truncation/sharding/corruption bug this test guards against. + if (nread != PAYLOAD_SIZE) { + fprintf(stderr, "expected %d-byte datagram, got %zd\n", PAYLOAD_SIZE, + nread); + return 1; + } + if (memcmp(sendbuf, recvbuf, PAYLOAD_SIZE) != 0) { + fprintf(stderr, "payload mismatch\n"); + return 1; + } + + close(sender); + close(receiver); + puts("large UDP datagram receive works"); + return 0; } + // No datagram was ever delivered. That is best-effort UDP being lossy, not + // the behaviour under test, so skip instead of failing. + fprintf(stderr, "skipping: no datagram delivered after %d attempts\n", + MAX_ATTEMPTS); close(sender); close(receiver); - puts("large UDP datagram receive works"); return 0; } From 970da63406a5bbfb6965c15f7de50c556eaf729e Mon Sep 17 00:00:00 2001 From: Arshia Ghafoori Date: Thu, 9 Jul 2026 09:36:43 +0000 Subject: [PATCH 2/4] test(wasix): cover writev later-send-error branch via mock networking The stream-tcp-writev-partial rewrite exercises fd_write's partial-return contract through the short-write branch, but not the exact `Err(_) if sent > 0 => break` arm the original regression fixed. That arm requires a later per-iovec send() to error after an earlier iovec already succeeded, which cannot be triggered deterministically over real host sockets (it races an asynchronous RST landing between two back-to-back sends of one writev). Add a test-only mock VirtualNetworking whose TCP socket succeeds on the first send() and returns ECONNRESET afterwards, and drive a small C guest (connect + writev of two iovecs) against it via the existing runtime-config test harness. writev must return the first iovec length. Verified it fails (returns -1) when the partial-return arm is removed. No application code is changed; async-trait is added as a dev-dependency for the mock impl. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/wasix/Cargo.toml | 1 + lib/wasix/tests/wasm_tests/mock_net.rs | 175 ++++++++++++++++++ lib/wasix/tests/wasm_tests/mod.rs | 65 +++++++ .../socket/writev-partial-send-error/main.c | 68 +++++++ 4 files changed, 309 insertions(+) create mode 100644 lib/wasix/tests/wasm_tests/mock_net.rs create mode 100644 lib/wasix/tests/wasm_tests/socket/writev-partial-send-error/main.c diff --git a/lib/wasix/Cargo.toml b/lib/wasix/Cargo.toml index b11e07fbc957..e09b3d4556a1 100644 --- a/lib/wasix/Cargo.toml +++ b/lib/wasix/Cargo.toml @@ -186,6 +186,7 @@ ciborium.workspace = true strum.workspace = true dirs.workspace = true version-compare.workspace = true +async-trait.workspace = true [target.'cfg(target_arch = "wasm32")'.dev-dependencies] wasm-bindgen-test.workspace = true diff --git a/lib/wasix/tests/wasm_tests/mock_net.rs b/lib/wasix/tests/wasm_tests/mock_net.rs new file mode 100644 index 000000000000..1be87ab4e920 --- /dev/null +++ b/lib/wasix/tests/wasm_tests/mock_net.rs @@ -0,0 +1,175 @@ +//! A mock networking backend used by the `writev_partial_send_error` test. +//! +//! It hands out a TCP socket whose first `try_send` succeeds in full and whose +//! subsequent `try_send` calls fail with `ConnectionReset`. That deterministically +//! drives fd_write's per-iovec loop down the "a later send errors after an earlier +//! iovec already succeeded" branch, which cannot be triggered reliably over real +//! host sockets (it would depend on an asynchronous RST landing between two +//! back-to-back sends of a single writev - see issue #6785). + +use std::mem::MaybeUninit; +use std::net::{Shutdown, SocketAddr}; +use std::task::{Context, Poll}; +use std::time::Duration; + +use wasmer_wasix::virtual_net::{ + InterestHandler, NetworkError, Result as NetResult, SocketStatus, VirtualConnectedSocket, + VirtualIoSource, VirtualNetworking, VirtualSocket, VirtualTcpSocket, +}; + +/// A connected TCP socket whose first `try_send` succeeds and whose following +/// `try_send` calls return `ConnectionReset`. +#[derive(Debug)] +struct FailAfterFirstSendSocket { + local: SocketAddr, + peer: SocketAddr, + sends: usize, +} + +impl FailAfterFirstSendSocket { + fn new(local: SocketAddr, peer: SocketAddr) -> Self { + Self { + local, + peer, + sends: 0, + } + } +} + +impl VirtualIoSource for FailAfterFirstSendSocket { + fn remove_handler(&mut self) {} + + fn poll_read_ready(&mut self, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(0)) + } + + fn poll_write_ready(&mut self, _cx: &mut Context<'_>) -> Poll> { + // Report writable so a blocking connect() completes immediately. + Poll::Ready(Ok(8192)) + } +} + +impl VirtualSocket for FailAfterFirstSendSocket { + fn set_ttl(&mut self, _ttl: u32) -> NetResult<()> { + Ok(()) + } + + fn ttl(&self) -> NetResult { + Ok(64) + } + + fn addr_local(&self) -> NetResult { + Ok(self.local) + } + + fn status(&self) -> NetResult { + Ok(SocketStatus::Opened) + } + + fn set_handler(&mut self, _handler: Box) -> NetResult<()> { + Ok(()) + } +} + +impl VirtualConnectedSocket for FailAfterFirstSendSocket { + fn set_linger(&mut self, _linger: Option) -> NetResult<()> { + Ok(()) + } + + fn linger(&self) -> NetResult> { + Ok(None) + } + + fn try_send(&mut self, data: &[u8]) -> NetResult { + self.sends += 1; + if self.sends == 1 { + // First iovec is accepted in full. + Ok(data.len()) + } else { + // Any later iovec's send fails, exercising the partial-return branch. + Err(NetworkError::ConnectionReset) + } + } + + fn try_flush(&mut self) -> NetResult<()> { + Ok(()) + } + + fn close(&mut self) -> NetResult<()> { + Ok(()) + } + + fn try_recv(&mut self, _buf: &mut [MaybeUninit], _peek: bool) -> NetResult { + Err(NetworkError::WouldBlock) + } +} + +impl VirtualTcpSocket for FailAfterFirstSendSocket { + fn set_recv_buf_size(&mut self, _size: usize) -> NetResult<()> { + Ok(()) + } + + fn recv_buf_size(&self) -> NetResult { + Ok(0) + } + + fn set_send_buf_size(&mut self, _size: usize) -> NetResult<()> { + Ok(()) + } + + fn send_buf_size(&self) -> NetResult { + Ok(0) + } + + fn set_nodelay(&mut self, _nodelay: bool) -> NetResult<()> { + Ok(()) + } + + fn nodelay(&self) -> NetResult { + Ok(false) + } + + fn set_keepalive(&mut self, _keepalive: bool) -> NetResult<()> { + Ok(()) + } + + fn keepalive(&self) -> NetResult { + Ok(false) + } + + fn set_dontroute(&mut self, _dontroute: bool) -> NetResult<()> { + Ok(()) + } + + fn dontroute(&self) -> NetResult { + Ok(false) + } + + fn addr_peer(&self) -> NetResult { + Ok(self.peer) + } + + fn shutdown(&mut self, _how: Shutdown) -> NetResult<()> { + Ok(()) + } + + fn is_closed(&self) -> bool { + false + } +} + +/// Networking backend that hands out [`FailAfterFirstSendSocket`]s on connect. +/// Every other operation is left at the `VirtualNetworking` default (unsupported). +#[derive(Debug, Default)] +pub struct FailAfterFirstSendNetworking; + +#[async_trait::async_trait] +impl VirtualNetworking for FailAfterFirstSendNetworking { + async fn connect_tcp( + &self, + addr: SocketAddr, + peer: SocketAddr, + ) -> NetResult> { + Ok(Box::new(FailAfterFirstSendSocket::new(addr, peer))) + } +} diff --git a/lib/wasix/tests/wasm_tests/mod.rs b/lib/wasix/tests/wasm_tests/mod.rs index 135a721ffc76..58dc4d3dd000 100644 --- a/lib/wasix/tests/wasm_tests/mod.rs +++ b/lib/wasix/tests/wasm_tests/mod.rs @@ -96,6 +96,7 @@ use wasmer_wasix::virtual_fs::{ }; mod error; +mod mock_net; mod runner; const TESTED_LIBC_VERSIONS: &[Option<&str>] = &[None, Some("v2026-05-12.1")]; @@ -1247,6 +1248,19 @@ fn collect_tests(tests: &mut Vec) -> Result<()> { } })); + tests.push(libtest_mimic::Trial::test( + "wasm/writev_partial_send_error", + { + let tests_dir = tests_dir.clone(); + let tests_build_root = tests_build_root.clone(); + move || { + run_writev_partial_send_error(&tests_dir, &tests_build_root) + .map(|_| ()) + .map_err(|e| libtest_mimic::Failed::from(format!("{e:?}"))) + } + }, + )); + for entry in WalkDir::new(&tests_dir) .into_iter() .filter_map(Result::ok) @@ -1415,3 +1429,54 @@ fn run_dynamic_runtime_hook_smoke( Ok(libtest_mimic::Completion::Completed) } + +/// Drives the stream writev partial-success path where a *later* per-iovec +/// send() errors after an earlier iovec was fully sent. This cannot be +/// triggered deterministically over real host sockets (it would race an +/// asynchronous RST between two back-to-back sends of a single writev - see +/// issue #6785), so it runs against a mock networking backend whose TCP socket +/// succeeds on the first send and returns ECONNRESET afterwards. fd_write must +/// return the bytes already transferred (the first iovec length) rather than +/// failing the whole syscall. +fn run_writev_partial_send_error( + tests_dir: &Path, + tests_build_root: &Path, +) -> Result { + if cfg!(target_os = "windows") { + return Ok(libtest_mimic::Completion::ignored_with( + "WASIXCC toolchain does not cover Windows yet", + )); + } + + let source_dir = tests_dir.join("socket/writev-partial-send-error"); + let config = Config::new( + PrimarySource::CSourceFile("main.c".to_owned()), + source_dir, + tests_build_root.to_path_buf(), + "writev_partial_send_error".to_owned(), + ); + let wasm = run_build_script(&config)?; + let run_dir = config.build_path(); + + let result = runner::run_wasm_with_runner_and_runtime_config( + &wasm, + &run_dir, + config.engine, + config.program_name.as_deref(), + false, + |_| Ok(()), + |runtime| { + runtime.set_networking_implementation(mock_net::FailAfterFirstSendNetworking); + Ok(()) + }, + )?; + + ensure!( + result.exit_code == 0, + "writev partial send error exited with {}\n{}", + result.exit_code, + runner::format_captured_output(&result), + ); + + Ok(libtest_mimic::Completion::Completed) +} diff --git a/lib/wasix/tests/wasm_tests/socket/writev-partial-send-error/main.c b/lib/wasix/tests/wasm_tests/socket/writev-partial-send-error/main.c new file mode 100644 index 000000000000..6c3dc2d9bf78 --- /dev/null +++ b/lib/wasix/tests/wasm_tests/socket/writev-partial-send-error/main.c @@ -0,0 +1,68 @@ +//#Ignored: driven only by the writev_partial_send_error mock-networking harness test +/* + * Guest driver for the stream writev partial-success-on-later-error path. + * + * This is NOT run against real host networking (hence the Ignored directive + * above, which makes the auto-collected run skip it). It is compiled and run by + * the explicit `wasm/writev_partial_send_error` harness test against a mock + * VirtualNetworking whose TCP socket succeeds on the first send() and returns + * ECONNRESET on the second. That deterministically drives fd_write's per-iovec + * loop down the `Err(_) if sent > 0 => break` branch: the first iovec is fully + * sent, the second send() errors, and writev must return the bytes already + * transferred (the first iovec length) instead of failing the whole syscall. + * + * This complements stream-tcp-writev-partial, which covers the short-write + * branch of the same contract deterministically but cannot reach the error + * branch without racing an asynchronous RST (issue #6785). + */ + +#include +#include +#include +#include +#include +#include +#include + +enum { FIRST_IOV_LEN = 5, SECOND_IOV_LEN = 5 }; + +int main(void) { + int client = socket(AF_INET, SOCK_STREAM, 0); + if (client < 0) { + perror("socket"); + return 1; + } + + // The mock networking ignores the destination, so any valid address works. + struct sockaddr_in addr; + memset(&addr, 0, sizeof(addr)); + addr.sin_family = AF_INET; + addr.sin_port = htons(1234); + addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + if (connect(client, (struct sockaddr*)&addr, sizeof(addr)) != 0) { + perror("connect"); + close(client); + return 1; + } + + struct iovec iov[2] = { + {.iov_base = "hello", .iov_len = FIRST_IOV_LEN}, + {.iov_base = "world", .iov_len = SECOND_IOV_LEN}, + }; + + // The first per-iovec send() succeeds; the second returns ECONNRESET. writev + // must report the bytes already transferred, i.e. exactly FIRST_IOV_LEN. + ssize_t written = writev(client, iov, 2); + if (written != (ssize_t)FIRST_IOV_LEN) { + fprintf(stderr, + "expected writev to return %d after a later send error, got %zd " + "errno=%d (%s)\n", + FIRST_IOV_LEN, written, errno, strerror(errno)); + close(client); + return 1; + } + + close(client); + puts("stream TCP writev returns partial count after later send error"); + return 0; +} From bc1cf3f16c6d830f47970395376db32fb37fc3ca Mon Sep 17 00:00:00 2001 From: Arshia Ghafoori Date: Thu, 9 Jul 2026 11:19:32 +0000 Subject: [PATCH 3/4] =?UTF-8?q?test(wasix):=20address=20review=20=E2=80=94?= =?UTF-8?q?=20fail=20hard=20on=20real=20socket=20errors,=20skip=20full=20w?= =?UTF-8?q?ritev?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Incorporate PR review feedback so the de-flaked tests can still catch regressions instead of masking them: - udp-large-recv: distinguish a dropped datagram (poll() timeout, pr==0) from a genuine error. Retry on EINTR, treat pr==0 as a drop, but fail hard on any other poll() error or unexpected revents, and on a recvfrom() error after POLLIN (readiness reported then recv fails is a real bug, not a drop). - stream-tcp-writev-partial: if the host's socket buffers accept the whole oversized write, we cannot force a short write; treat that as an environment limitation and skip rather than false-fail. Still fail on written < 0 (the regression). Drop the ExpectedStdout directive since the skip path no longer prints it; assert via exit code. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../socket/stream-tcp-writev-partial/main.c | 37 ++++++++++--------- .../wasm_tests/socket/udp-large-recv/main.c | 33 +++++++++++++---- 2 files changed, 46 insertions(+), 24 deletions(-) diff --git a/lib/wasix/tests/wasm_tests/socket/stream-tcp-writev-partial/main.c b/lib/wasix/tests/wasm_tests/socket/stream-tcp-writev-partial/main.c index 45ed2ecdb18d..a3416e9ca864 100644 --- a/lib/wasix/tests/wasm_tests/socket/stream-tcp-writev-partial/main.c +++ b/lib/wasix/tests/wasm_tests/socket/stream-tcp-writev-partial/main.c @@ -1,4 +1,3 @@ -//#ExpectedStdout: stream TCP writev returns partial count on short write /* * Regression test for stream-socket fd_write partial success. * @@ -133,26 +132,30 @@ int main(void) { }; ssize_t written = writev(client, iov, 2); - - // The first iovec always fits an empty send buffer, and the oversized second - // iovec never fully fits, so the result must be a partial total: strictly - // greater than the first iovec length and strictly less than the full length. - // A whole-syscall failure (the regression) would surface as -1 here. - if (written <= (ssize_t)FIRST_IOV_LEN || - written >= (ssize_t)(FIRST_IOV_LEN + (size_t)SECOND_IOV_LEN)) { - fprintf(stderr, - "expected partial writev in (%d, %zu), got %zd errno=%d (%s)\n", - FIRST_IOV_LEN, FIRST_IOV_LEN + (size_t)SECOND_IOV_LEN, written, - errno, strerror(errno)); - free(big); - close(client); - close(server); - return 1; - } + size_t total = FIRST_IOV_LEN + (size_t)SECOND_IOV_LEN; free(big); close(client); close(server); + + if (written < 0) { + // The whole syscall failed instead of returning the bytes already + // transferred. This is the regression the test guards against. + fprintf(stderr, "writev failed instead of a partial count: %zd errno=%d (%s)\n", + written, errno, strerror(errno)); + return 1; + } + + if (written == (ssize_t)total) { + // This host's socket buffers were large enough to accept the whole write, + // so we could not force a short write. That is an environment limitation, + // not the behaviour under test, so skip instead of failing. + fprintf(stderr, "skipping: host accepted the full %zu-byte write\n", total); + return 0; + } + + // 0 <= written < total: fd_write returned the bytes already transferred from a + // short write instead of failing the whole syscall - the contract under test. puts("stream TCP writev returns partial count on short write"); return 0; } diff --git a/lib/wasix/tests/wasm_tests/socket/udp-large-recv/main.c b/lib/wasix/tests/wasm_tests/socket/udp-large-recv/main.c index e2578c4edbc5..8c64f2f3ee4e 100644 --- a/lib/wasix/tests/wasm_tests/socket/udp-large-recv/main.c +++ b/lib/wasix/tests/wasm_tests/socket/udp-large-recv/main.c @@ -79,18 +79,37 @@ int main(void) { // (wasix-libc does not wire SO_RCVTIMEO, so poll() is used instead.) struct pollfd pfd = {.fd = receiver, .events = POLLIN}; int pr = poll(&pfd, 1, RECV_TIMEOUT_MS); - if (pr <= 0 || (pfd.revents & POLLIN) == 0) { - // Timed out waiting for the datagram (dropped in transit); retry. - fprintf(stderr, "attempt %d: poll timed out (pr=%d revents=0x%x)\n", - attempt, pr, pfd.revents); + if (pr < 0) { + if (errno == EINTR) { + continue; // interrupted before the datagram arrived; retry + } + // A genuine poll error (bad fd, unsupported poll, ...) is a real failure, + // not a dropped datagram, so surface it instead of skipping. + fprintf(stderr, "poll failed (errno %d: %s)\n", errno, strerror(errno)); + return 1; + } + if (pr == 0) { + // Nothing arrived within the timeout: the datagram was dropped. Retry. + fprintf(stderr, "attempt %d: no datagram within %d ms\n", attempt, + RECV_TIMEOUT_MS); continue; } + if ((pfd.revents & POLLIN) == 0) { + // Readiness reported an error/hangup rather than readable data. + fprintf(stderr, "poll returned unexpected revents=0x%x\n", pfd.revents); + return 1; + } ssize_t nread = recvfrom(receiver, recvbuf, sizeof(recvbuf), 0, NULL, NULL); if (nread < 0) { - fprintf(stderr, "attempt %d: recvfrom failed (errno %d: %s)\n", attempt, - errno, strerror(errno)); - continue; + if (errno == EINTR) { + continue; // interrupted before reading; retry + } + // poll() reported the socket readable but recvfrom failed: a real bug, + // not a dropped datagram, so fail rather than silently retry/skip. + fprintf(stderr, "recvfrom failed after POLLIN (errno %d: %s)\n", errno, + strerror(errno)); + return 1; } // A datagram was delivered: it must match exactly. Anything else is the From 2bf9566de4be01c1d300ce1c17bb98c10f7961e4 Mon Sep 17 00:00:00 2001 From: Arshia Ghafoori Date: Thu, 9 Jul 2026 12:12:56 +0000 Subject: [PATCH 4/4] test(wasix): address re-review and fix clang-format - udp-large-recv: only treat expected best-effort/oversize/transient sendto() failures (EMSGSIZE/ENOBUFS/EAGAIN/EWOULDBLOCK/EINTR) as a dropped attempt; any other errno now fails the test instead of being skipped, so a real sendto regression (EBADF/EINVAL/...) cannot be masked. A short non-negative datagram send is also treated as a bug. - stream-tcp-writev-partial: skip (exit 0) if the 64 MiB allocation fails rather than hard-failing, so constrained environments don't reintroduce flakiness; drop the memset since only the returned byte count is asserted. - Apply clang-format (Google style) to satisfy the Code lint CI job. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../socket/stream-tcp-writev-partial/main.c | 18 ++++++++++----- .../wasm_tests/socket/udp-large-recv/main.c | 23 ++++++++++++++----- 2 files changed, 29 insertions(+), 12 deletions(-) diff --git a/lib/wasix/tests/wasm_tests/socket/stream-tcp-writev-partial/main.c b/lib/wasix/tests/wasm_tests/socket/stream-tcp-writev-partial/main.c index a3416e9ca864..d9d7adb49a1b 100644 --- a/lib/wasix/tests/wasm_tests/socket/stream-tcp-writev-partial/main.c +++ b/lib/wasix/tests/wasm_tests/socket/stream-tcp-writev-partial/main.c @@ -119,12 +119,16 @@ int main(void) { char* big = malloc(SECOND_IOV_LEN); if (big == NULL) { - fprintf(stderr, "malloc failed\n"); + // A 64 MiB allocation failing is an environment constraint, not the + // fd_write contract under test, so skip rather than fail. + fprintf(stderr, "skipping: could not allocate %zu bytes\n", + (size_t)SECOND_IOV_LEN); close(client); close(server); - return 1; + return 0; } - memset(big, 'w', SECOND_IOV_LEN); + // Contents are irrelevant - only the returned byte count is asserted - so the + // buffer is left uninitialized rather than paying for a 64 MiB memset. struct iovec iov[2] = { {.iov_base = "hello", .iov_len = FIRST_IOV_LEN}, @@ -141,7 +145,8 @@ int main(void) { if (written < 0) { // The whole syscall failed instead of returning the bytes already // transferred. This is the regression the test guards against. - fprintf(stderr, "writev failed instead of a partial count: %zd errno=%d (%s)\n", + fprintf(stderr, + "writev failed instead of a partial count: %zd errno=%d (%s)\n", written, errno, strerror(errno)); return 1; } @@ -154,8 +159,9 @@ int main(void) { return 0; } - // 0 <= written < total: fd_write returned the bytes already transferred from a - // short write instead of failing the whole syscall - the contract under test. + // 0 <= written < total: fd_write returned the bytes already transferred from + // a short write instead of failing the whole syscall - the contract under + // test. puts("stream TCP writev returns partial count on short write"); return 0; } diff --git a/lib/wasix/tests/wasm_tests/socket/udp-large-recv/main.c b/lib/wasix/tests/wasm_tests/socket/udp-large-recv/main.c index 8c64f2f3ee4e..be31a8efcb73 100644 --- a/lib/wasix/tests/wasm_tests/socket/udp-large-recv/main.c +++ b/lib/wasix/tests/wasm_tests/socket/udp-large-recv/main.c @@ -65,13 +65,24 @@ int main(void) { for (int attempt = 0; attempt < MAX_ATTEMPTS; ++attempt) { ssize_t nsent = sendto(sender, sendbuf, PAYLOAD_SIZE, 0, (struct sockaddr*)&addr, sizeof(addr)); + if (nsent < 0) { + // Only tolerate the expected best-effort/oversize/transient failures and + // treat them as a dropped attempt (e.g. macOS rejects datagrams larger + // than net.inet.udp.maxdgram with EMSGSIZE). Any other errno is a real + // bug in sendto rather than a lost datagram, so fail instead of skipping. + if (errno == EMSGSIZE || errno == ENOBUFS || errno == EAGAIN || + errno == EWOULDBLOCK || errno == EINTR) { + fprintf(stderr, "attempt %d: sendto could not deliver (errno %d: %s)\n", + attempt, errno, strerror(errno)); + continue; + } + fprintf(stderr, "sendto failed (errno %d: %s)\n", errno, strerror(errno)); + return 1; + } if (nsent != PAYLOAD_SIZE) { - // e.g. macOS rejects datagrams larger than net.inet.udp.maxdgram with - // EMSGSIZE. That is not the behaviour under test, so treat it as a - // dropped attempt. - fprintf(stderr, "attempt %d: sendto returned %zd (errno %d: %s)\n", - attempt, nsent, errno, strerror(errno)); - continue; + // A datagram send is all-or-nothing; a short count is a real bug. + fprintf(stderr, "sendto sent %zd of %d bytes\n", nsent, PAYLOAD_SIZE); + return 1; } // Wait for the datagram with a bounded timeout so a dropped datagram costs