udp: surface IP_RECVERR ICMP errors and MSG_TRUNC truncation flag#28827
Conversation
Two UDP UX gaps vs libuv.
Linux silently drops ICMP errors (port unreachable, host unreachable,
TTL exceeded, EMSGSIZE) on unconnected UDP sockets. Enabling IP_RECVERR
and IPV6_RECVERR at socket creation (matching libuv) surfaces them as
errors on the next recv. We read the errno via recvmmsg and dispatch it
through the socket's 'error' handler; the socket stays open so the caller
can keep sending after a transient ICMP error.
The 'data' callback now receives a fifth argument:
data(socket, data, port, address, flags)
where flags.truncated is true when the datagram was larger than the
receive buffer (MSG_TRUNC set in msg_flags). Previously a truncated
payload was indistinguishable from a normal one.
|
Updated 6:41 PM PT - Apr 3rd, 2026
❌ @robobun, your commit 1bfaf3d has 6 failures in
502 Bad Gatewaycloudflare 🧪 To try this PR locally: bunx bun-pr 28827That installs a local version of the PR into your bun-28827 --bun |
|
Found 1 issue this PR may fix:
🤖 Generated with Claude Code |
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (3)
WalkthroughAdds UDP receive metadata (truncation status) and a receive-error callback path from kernel/ICMP into Bun's UDP stack, updating types, C API/implementation, Zig bindings, JS integration, and tests. Changes
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Comment |
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@packages/bun-usockets/src/loop.c`:
- Around line 613-620: When npackets == LIBUS_SOCKET_ERROR after calling
bsd_recvmmsg(), don't unconditionally read errno; on Windows extract the socket
error via WSAGetLastError() and translate/normalize it to the POSIX-style errno
before invoking u->on_recv_error(u, recv_err). Update the error-capture branch
around LIBUS_SOCKET_ERROR to use conditional compilation (e.g. `#ifdef` _WIN32) to
call WSAGetLastError(), map that value to a normalized errno (or use the
existing bsd translation utility if available), and then pass the normalized
recv_err into u->on_recv_error so the callback receives a valid cross-platform
error code.
In `@src/deps/uws/udp.zig`:
- Around line 104-111: The getTruncated() helper exposes when a datagram was
truncated but getPayload() still trusts us_udp_packet_buffer_payload_length(),
which on Darwin (recvmsg_x) can report the original datagram length larger than
the copied buffer and cause overreads; update getPayload() to clamp the returned
length against the actual buffer capacity when getTruncated() is true, or add a
new safe accessor (e.g., getCopiedPayloadLength()) that returns
min(us_udp_packet_buffer_payload_length(this, index), actual_buffer_capacity)
and use that for slicing payloads; reference the functions getTruncated,
getPayload (or create getCopiedPayloadLength),
us_udp_packet_buffer_payload_length, and us_udp_packet_buffer_truncated to
locate and change the logic.
In `@test/js/bun/udp/udp_socket_recv_flags.test.ts`:
- Around line 23-29: The sendRec function uses an unbounded setTimeout retry
loop; replace it with a bounded async loop that uses Bun.sleep and a max retry
count to avoid unbounded timers — e.g., convert sendRec to an async function
that for up to maxRetries checks client.closed, calls client.send("hello",
server.port, "127.0.0.1"), awaits Bun.sleep(10) between attempts, and exits
early if client.closed; apply the same refactor to the other retry loops
referenced around lines 67–72 and 82–87 so all retries use a bounded async sleep
loop instead of setTimeout.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 07562ba7-4b3c-4c37-bf05-1c45eb1ce984
📒 Files selected for processing (10)
packages/bun-types/bun.d.tspackages/bun-usockets/src/bsd.cpackages/bun-usockets/src/internal/internal.hpackages/bun-usockets/src/internal/networking/bsd.hpackages/bun-usockets/src/libusockets.hpackages/bun-usockets/src/loop.cpackages/bun-usockets/src/udp.csrc/bun.js/api/bun/udp_socket.zigsrc/deps/uws/udp.zigtest/js/bun/udp/udp_socket_recv_flags.test.ts
Address review feedback: 1. loop.c: The recv-on-error path that drains IP_RECVERR's error queue only makes sense on Linux. On kqueue/Windows, an error event (EV_ERROR/socket error) is a fatal socket condition with no drainable queue — preserve the pre-existing close-on-error behavior there by guarding the new logic with #if defined(__linux__). 2. bsd.c: Clamp bsd_udp_packet_buffer_payload_length() to LIBUS_UDP_MAX_SIZE. On Darwin, recvmsg_x()'s msg_datalen is defined as 'the length of the received datagram' which could exceed the per-iov buffer capacity when MSG_TRUNC is set. Defensive clamp prevents any downstream overread.
|
CI failures on 1bfaf3d are pre-existing flakes unrelated to this PR:
All other jobs including asan-build-bun, baseline-build-bun, and all non-debian test runners passed. |
After #28827 enabled IP_RECVERR unconditionally on UDP sockets, Linux started delivering ICMP port-unreachable / host-unreachable / etc. to the error queue on unconnected sockets, which uSockets drained and surfaced through the 'error' callback. node:dgram forwarded them to the dgram socket's 'error' event, breaking any script that sent a UDP packet to a closed port without a defensive listener — including the exact repro in #29116 which exited 1 after printing 'done'. Node.js/libuv don't enable IP_RECVERR by default, so the kernel drops these ICMP errors on unconnected sockets and only surfaces them on connected ones (via synchronous recv/send error delivery). Match that observable behavior in node:dgram by filtering ECONNREFUSED, EHOSTUNREACH, ENETUNREACH, EHOSTDOWN, ENETDOWN, ENONET and ENOPROTOOPT with syscall === 'recv' when the socket is not .connect()-ed. Connected sockets still surface the error, matching Node.js. Fixes #29116
…en-sh#28827) Fixes oven-sh#18029 Two UDP UX gaps vs libuv. ## 1. `IP_RECVERR` / `IPV6_RECVERR` on Linux Linux silently drops ICMP errors (port unreachable, host unreachable, TTL exceeded, EMSGSIZE) on unconnected UDP sockets by default. An app `sendto()`ing to a dead port only finds out via timeout. Enable `IP_RECVERR`/`IPV6_RECVERR` at UDP socket creation on Linux (matching libuv). The kernel then reports the queued ICMP error on the next `recvmmsg`; we read the errno and dispatch it through the socket's `error` handler: ```js const sock = await Bun.udpSocket({ socket: { error(err) { console.log(err.code); /* 'ECONNREFUSED' */ }, }, }); sock.send('ping', 1, '127.0.0.1'); // dead port ``` The socket stays open after the ICMP error — it's a one-shot error, not a fatal socket state, so the caller can keep sending. This also fixes a pre-existing behavior where `EPOLLERR` would silently close the UDP socket (oven-sh#18029). ## 2. `MSG_TRUNC` → `flags.truncated` When a datagram is larger than the receive buffer, the kernel truncates it and sets `MSG_TRUNC` in `msg_flags`. Previously the `data` callback couldn't tell a truncated payload from a complete one. The `data` callback now receives a fifth argument: ```ts data?(socket, data, port, address, flags: { truncated: boolean }): void ``` ## Verification `test/js/bun/udp/udp_socket_recv_flags.test.ts` — passes with the fix, fails without (`flags` is `undefined`; the ECONNREFUSED test times out waiting for the error event). All 167 existing `udpSocket()` tests still pass.
…en-sh#28827) Fixes oven-sh#18029 Two UDP UX gaps vs libuv. ## 1. `IP_RECVERR` / `IPV6_RECVERR` on Linux Linux silently drops ICMP errors (port unreachable, host unreachable, TTL exceeded, EMSGSIZE) on unconnected UDP sockets by default. An app `sendto()`ing to a dead port only finds out via timeout. Enable `IP_RECVERR`/`IPV6_RECVERR` at UDP socket creation on Linux (matching libuv). The kernel then reports the queued ICMP error on the next `recvmmsg`; we read the errno and dispatch it through the socket's `error` handler: ```js const sock = await Bun.udpSocket({ socket: { error(err) { console.log(err.code); /* 'ECONNREFUSED' */ }, }, }); sock.send('ping', 1, '127.0.0.1'); // dead port ``` The socket stays open after the ICMP error — it's a one-shot error, not a fatal socket state, so the caller can keep sending. This also fixes a pre-existing behavior where `EPOLLERR` would silently close the UDP socket (oven-sh#18029). ## 2. `MSG_TRUNC` → `flags.truncated` When a datagram is larger than the receive buffer, the kernel truncates it and sets `MSG_TRUNC` in `msg_flags`. Previously the `data` callback couldn't tell a truncated payload from a complete one. The `data` callback now receives a fifth argument: ```ts data?(socket, data, port, address, flags: { truncated: boolean }): void ``` ## Verification `test/js/bun/udp/udp_socket_recv_flags.test.ts` — passes with the fix, fails without (`flags` is `undefined`; the ECONNREFUSED test times out waiting for the error event). All 167 existing `udpSocket()` tests still pass.
Fixes #18029
Two UDP UX gaps vs libuv.
1.
IP_RECVERR/IPV6_RECVERRon LinuxLinux silently drops ICMP errors (port unreachable, host unreachable, TTL exceeded, EMSGSIZE) on unconnected UDP sockets by default. An app
sendto()ing to a dead port only finds out via timeout.Enable
IP_RECVERR/IPV6_RECVERRat UDP socket creation on Linux (matching libuv). The kernel then reports the queued ICMP error on the nextrecvmmsg; we read the errno and dispatch it through the socket'serrorhandler:The socket stays open after the ICMP error — it's a one-shot error, not a fatal socket state, so the caller can keep sending. This also fixes a pre-existing behavior where
EPOLLERRwould silently close the UDP socket (#18029).2.
MSG_TRUNC→flags.truncatedWhen a datagram is larger than the receive buffer, the kernel truncates it and sets
MSG_TRUNCinmsg_flags. Previously thedatacallback couldn't tell a truncated payload from a complete one.The
datacallback now receives a fifth argument:Verification
test/js/bun/udp/udp_socket_recv_flags.test.ts— passes with the fix, fails without (flagsisundefined; the ECONNREFUSED test times out waiting for the error event). All 167 existingudpSocket()tests still pass.