usockets: treat ENOBUFS/ENOMEM from send() as would-block, not fatal

Merged
RO
4mo ago
+72-2

us_socket_write_check_error classified every non-EWOULDBLOCK send() errno as a fatal write error. For transient kernel resource exhaustion (ENOBUFS/ENOMEM on POSIX, WSAENOBUFS on Windows) the connection is still healthy and a later retry can succeed, so the node:net drain path must keep the buffered bytes and re-arm the writable poll instead of dropping them.

Repro

Deterministic via the in-tree usockets fault injection (debug/ASan builds):

// net.Socket client: a short write leaves bytes in buffered_data_for_node_net,
// the drain-path flush (internal_flush) then hits ENOBUFS.
fault.set({ syscall: "send", action: "short", bytes: 1, repeat: 1, fd: clientFd });
client.write(head);  // send #0: 1 byte accepted, 199 buffered
fault.set({ syscall: "send", action: "errno", errno: "ENOBUFS", repeat: 1, fd: clientFd });
// on_writable -> internal_flush -> send() -> ENOBUFS

Without the fix the peer receives head[0] followed by whatever is written next; the 199 buffered bytes are gone, no 'error' event, no failed write callback, no close. The short send that leaves data buffered is ordinary kernel backpressure on any real-network upload; a one-off ENOBUFS/ENOMEM from send(2) under memory pressure is ordinary kernel behaviour.

Cause

packages/bun-usockets/src/socket.c us_socket_write_check_error():

if (bsd_would_block()) { ...retry... }   /* errno == EWOULDBLOCK only */
if (fatal_write_error) *fatal_write_error = 1;   /* ENOBUFS/ENOMEM land here */

src/runtime/socket/socket_body.rs internal_flush() then clears buffered_data_for_node_net on fatal, and on_writable dispatches 'drain' regardless, so JS keeps writing on a connection that has silently lost a span of its byte stream.

Fix

Add bsd_send_is_transient_error() (ENOBUFS/ENOMEM on POSIX, WSAENOBUFS on Windows) and treat it the same as would-block in us_socket_write_check_error: set last_write_failed, re-arm the writable poll, return 0, do not set fatal_write_error. This matches libuv's uv__write, which retries on EAGAIN || EWOULDBLOCK || ENOBUFS.

EPIPE/ECONNRESET still reach the fatal branch; those are connection-fatal and surfaced on the read side. Making the write side itself fail those pending writes is the complementary fix in #33506.

Verification

New test.each(["ENOBUFS", "ENOMEM"]) case in test/js/node/net/net-syscall-fault.test.ts.

Without the fix (git stash -- packages/ && bun bd test ...):

{ intact: false, length: 4097 }   // 1 head byte + 4096 body; 199 bytes dropped

With the fix:

{ intact: true, length: 4296 }    // 200 head + 4096 body, byte-exact

The rest of net-syscall-fault.test.ts, tls-syscall-fault.test.ts, and socket-syscall-fault.test.ts pass unchanged.


no test proof · iteration 0 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/node/net/net-syscall-fault.test.ts