Skip to content

udp: surface IP_RECVERR ICMP errors and MSG_TRUNC truncation flag#28827

Merged
Jarred-Sumner merged 2 commits into
mainfrom
farm/6b8f728f/udp-recverr-msgtrunc
Apr 4, 2026
Merged

udp: surface IP_RECVERR ICMP errors and MSG_TRUNC truncation flag#28827
Jarred-Sumner merged 2 commits into
mainfrom
farm/6b8f728f/udp-recverr-msgtrunc

Conversation

@robobun

@robobun robobun commented Apr 3, 2026

Copy link
Copy Markdown
Collaborator

Fixes #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:

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 (#18029).

2. MSG_TRUNCflags.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:

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.

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.
@robobun robobun requested a review from alii as a code owner April 3, 2026 23:08
@github-actions github-actions Bot added the claude label Apr 3, 2026
@robobun

robobun commented Apr 3, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 6:41 PM PT - Apr 3rd, 2026

@robobun, your commit 1bfaf3d has 6 failures in Build #43520 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 28827

That installs a local version of the PR into your bun-28827 executable, so you can run:

bun-28827 --bun

@github-actions

github-actions Bot commented Apr 3, 2026

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. Error occurred when attempting to send data to an UDP client which is not reachable #18029 - Reports UDP socket silently closing when sending to an unreachable client; this PR enables IP_RECVERR to surface ICMP errors via the error handler and fixes the EPOLLERR silent-close bug

If this is helpful, consider adding Fixes #18029 to the PR description to auto-close the issue on merge.

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Apr 3, 2026

Copy link
Copy Markdown
Contributor

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: b1de7b2c-b21a-46d6-acdc-f6d843e04548

📥 Commits

Reviewing files that changed from the base of the PR and between 306b445 and 1bfaf3d.

📒 Files selected for processing (3)
  • packages/bun-usockets/src/bsd.c
  • packages/bun-usockets/src/loop.c
  • src/bun.js/api/bun/udp_socket.zig

Walkthrough

Adds 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

Cohort / File(s) Summary
Type Definitions
packages/bun-types/bun.d.ts
Added udp.ReceiveFlags { truncated: boolean }. Updated udp.SocketHandler and udp.ConnectedSocketHandler data callbacks to accept final flags: ReceiveFlags parameter.
Public C API & Headers
packages/bun-usockets/src/libusockets.h, packages/bun-usockets/src/internal/networking/bsd.h
Added us_udp_packet_buffer_truncated(...) declaration and updated us_create_udp_socket(...) signature to include a new recv_error_cb callback parameter.
Internal C Structures
packages/bun-usockets/src/internal/internal.h
Extended struct us_udp_socket_t with void (*on_recv_error)(struct us_udp_socket_t *, int err);.
C Implementation (UDP/bsd/loop)
packages/bun-usockets/src/bsd.c, packages/bun-usockets/src/udp.c, packages/bun-usockets/src/loop.c
Added bsd_udp_packet_buffer_truncated(...) and us_udp_packet_buffer_truncated(...) glue; clamp reported payload length to LIBUS_UDP_MAX_SIZE; enable IP_RECVERR/IPV6_RECVERR on Linux; store recv_error_cb on socket; update recv loop to surface non-EAGAIN recv errors via on_recv_error without always closing socket.
Zig Bindings
src/deps/uws/udp.zig
Extended Socket.create to accept and forward recv_error_cb to C; added PacketBuffer.getTruncated() calling us_udp_packet_buffer_truncated.
Bun JS API (Zig integration)
src/bun.js/api/bun/udp_socket.zig
Added onRecvError(socket, errno) to convert kernel ICMP/IP_RECVERR errno into JS error and route to error handler. Extended onData to compute truncated and pass a flags object as an additional (5th) callback argument. Wired recv-error callback into socket creation.
Tests
test/js/bun/udp/udp_socket_recv_flags.test.ts
Added tests asserting flags argument ({ truncated: false }) for normal packets and a Linux-only test verifying ICMP ECONNREFUSED delivered via the sender's error callback while keeping socket usable.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the two main changes: enabling IP_RECVERR ICMP error reporting and exposing MSG_TRUNC truncation status through flags.
Description check ✅ Passed The description follows the template with sections for what the PR does and how it was verified, providing detailed technical context and test coverage.
Linked Issues check ✅ Passed The PR fully addresses issue #18029 by preventing silent socket closure on ICMP errors, enabling error surfacing via callbacks, and keeping sockets open for continued operation.
Out of Scope Changes check ✅ Passed All changes are directly scoped to the stated objectives: enabling IP_RECVERR/IPV6_RECVERR on Linux, exposing MSG_TRUNC truncation status, and supporting error callbacks throughout the stack.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

📥 Commits

Reviewing files that changed from the base of the PR and between 3f41407 and 306b445.

📒 Files selected for processing (10)
  • packages/bun-types/bun.d.ts
  • packages/bun-usockets/src/bsd.c
  • packages/bun-usockets/src/internal/internal.h
  • packages/bun-usockets/src/internal/networking/bsd.h
  • packages/bun-usockets/src/libusockets.h
  • packages/bun-usockets/src/loop.c
  • packages/bun-usockets/src/udp.c
  • src/bun.js/api/bun/udp_socket.zig
  • src/deps/uws/udp.zig
  • test/js/bun/udp/udp_socket_recv_flags.test.ts

Comment thread packages/bun-usockets/src/loop.c
Comment thread src/deps/uws/udp.zig
Comment thread test/js/bun/udp/udp_socket_recv_flags.test.ts
Comment thread packages/bun-usockets/src/loop.c
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.
@robobun

robobun commented Apr 4, 2026

Copy link
Copy Markdown
Collaborator Author

CI failures on 1bfaf3d are pre-existing flakes unrelated to this PR:

  1. test/js/third_party/@azure/service-bus/azure-service-bus.test.ts — SIGILL on debian-13 x64-asan, JSC exception scope assertion in JSArrayInlines.h:pushInline from Azure SDK code (no UDP usage)
  2. test/js/bun/s3/s3.test.ts — S3Error: 502 Bad Gateway from cloudflare on debian-13 x64-baseline (external network flake)

All other jobs including asan-build-bun, baseline-build-bun, and all non-debian test runners passed.

@Jarred-Sumner Jarred-Sumner merged commit 9850dd5 into main Apr 4, 2026
61 of 65 checks passed
@Jarred-Sumner Jarred-Sumner deleted the farm/6b8f728f/udp-recverr-msgtrunc branch April 4, 2026 02:23
robobun added a commit that referenced this pull request Apr 10, 2026
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
structwafel pushed a commit to structwafel/bun that referenced this pull request Apr 25, 2026
…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.
xhjkl pushed a commit to xhjkl/bun that referenced this pull request May 14, 2026
…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Error occurred when attempting to send data to an UDP client which is not reachable

2 participants