Skip to content

node:quic: create the endpoint socket without IP_RECVERR and count a refused datagram as sent - #44800

Open
robobun wants to merge 4 commits into
mainfrom
robobun/36a567e9/quic-dead-peer-send-error
Open

robobun wants to merge 4 commits into
mainfrom
robobun/36a567e9/quic-dead-peer-send-error

Conversation

@robobun

@robobun robobun commented Oct 8, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • One refused UDP send stalls every session of a node:quic endpoint. One forged datagram stalls a listen() endpoint for 4 s, a connect() to a refused address for 11 s.
  • On Linux, a dead peer's ICMP error fails sends to live peers.
  • Cause: the socket has IP_RECVERR (src/runtime/node/quic/endpoint.rs:1376), and packets_out (:792) reports each errno except EMSGSIZE as EAGAIN.

Fix

  • The endpoint passes no receive-error callback, so its socket gets no IP_RECVERR.
  • packets_out counts a refused datagram as sent. Only a blocked socket or ENOBUFS pauses the engine.
  • A receive error then closes the socket, so on_close closes the sessions and the endpoint, as Node does.
  • Verified: test/js/node/quic/quic-endpoint.test.ts (17 tests, 14 fail on main), QUIC, UDP and HTTP/3 suites. Self-reviewed: 18 concerns raised, 17 addressed. Rejected: the errno in the close error (needs a uSockets field).

Background

  • No user reported this. node:quic is experimental.
  • lsquic reads EAGAIN from packets_out as a blocked socket: it stops the engine and drops DATAGRAM-only packets.
  • With IP_RECVERR, a queued ICMP error fails the socket's next send to any destination.
  • Weighed: the send change alone (stale ICMP errors still fail sends), udp: stop setting IP_RECVERR on UDP sockets #35922 alone (refused addresses still stall).

Downsides

  • If the kernel refuses every send for 8 s, a session resumes 2.4 to 5.7 s later (main: 0.3 s), or closes at the default idle timeout.
  • A receive error closes the endpoint. stats.packetsSent counts refused datagrams.
  • +852 bytes of text, +3 instructions and +0 syscalls per packet. During ENOBUFS: 800 failed sends per second (main: 3).
Notes

Reproductions

Release builds of bd599f5 ("main") and of the first revision of this change on top of it, linux x64. The later commits change only the ENOBUFS case. Release builds of a407eae and of 803bd64 give the same result for the two cases that were run again: 132 and 200 of 200 datagrams with 2 dead peers, and 11024 ms and 3 ms for the request beside a refused session.

case main this PR
One forged datagram from source port 0 to a listen() endpoint: worst round trip of its live session (3 runs) 4059, 4010, 4008 ms 9.8, 4.0, 5.1 ms
A live session beside connect("192.0.2.1:443") on the default client endpoint (EINVAL): datagrams that arrive 0 of 200 200 of 200
The same, failing sendmmsg calls 201 4
The same with 255.255.255.255:443 (EACCES) or [::1]:443 (EAFNOSUPPORT) 0 of 200 200 of 200
One request beside the refused session 11076 ms 4 ms
The live session with the default idle timeout closed with ERR_QUIC_TRANSPORT_ERROR at 11.1 s open, request in 5 ms
One listen() endpoint, 2 dead peers: datagrams the live client receives 132 of 200 (3 runs) 200 of 200 (3 runs)
The same, failing sendmmsg / recvmsg(MSG_ERRQUEUE) calls 135 / 270 0 / 0
1 dead peer 200 of 200, 66 / 135 200 of 200, 0 / 0
3 dead peers 65 of 200, 202 / 403 200 of 200, 0 / 0
Version negotiation probe after a connect() to a dead port 0 of 1 delivered (3 runs) 1 of 1 (3 runs)
A stale ICMP "fragmentation needed" about another path live session lost in 13 of 13 runs 0 of 13
A device queue that is full for one destination 12 sends fail with ENOBUFS, live session closed at 11.1 s 0 fail, 50 of 50 requests
  • The default client endpoint is bound to 127.0.0.1, and connect() reuses it. Each send from it to an address that is not on the loopback fails with EINVAL.
  • The source port 0 case: the server answers the datagram with Version Negotiation, and a send to port 0 fails with EINVAL. On main that reply stays first in the batch and fails again in each pass.
  • The first row and the last two rows need root (a raw socket, a network namespace, tc), so they are probes and not tests.

What lsquic does with a short return

  • send_batch (vendor/lsquic/src/liblsquic/lsquic_engine.c) reads n_sent < n with EAGAIN as a blocked socket. It clears ENPUB_CAN_SEND for the engine and gives each unsent packet back to its connection as a delayed packet.
  • With another errno, except EMSGSIZE, it also closes the connection that owns the first unsent packet. With EMSGSIZE it aborts that connection unless the packet is an MTU probe.
  • At its next tick a connection runs lsquic_send_ctl_squeeze_sched. That drops each delayed packet that holds only regenerable frames, and a DATAGRAM frame counts as regenerable. Stream data waits.
  • A connection that never sends keeps the lowest cn_last_sent, so its packet is first in each batch. That is why one refused destination holds the engine until its handshake timeout (10 s).
  • The vendored lsquic has no "drop this one packet" result for packets_out. So the callback reports the datagram as sent, and loss recovery does the rest.

The send verdict

unsent_stops_batch (endpoint.rs) decides what a failed send means. us_quic_packets_out (packages/bun-usockets/src/quic.c) is the same verdict for HTTP/3 since #42732, and the doc comment names it.

result of the send this PR main
the socket would block (uSockets returns 0 and arms the writable poll) EAGAIN: the engine waits for on_drain the same
ENOBUFS (the output queue of the interface is full) EAGAIN: the endpoint sends again in its next pass, 1 ms later EAGAIN: lsquic sends again 1 s later
EMSGSIZE reaches lsquic the same
any other errno (EINVAL, EACCES, EPERM, ENETUNREACH, ...) the datagram counts as sent, the batch continues EAGAIN: the engine stops, and nothing arms the writable poll
  • ENOBUFS is backpressure, as in libuv and in quic.c. It is common on macOS and it clears by itself.
  • The writable poll is the wrong signal for it. The socket stays writable while the queue is full, so the loop would send again with no pause (measured below). The endpoint sets retry_unsent, and its next process pass calls lsquic_engine_send_unsent_packets. The timer runs that pass 1 ms later.
  • On Windows, bsd_sendmmsg now reports WSAENOBUFS as ENOBUFS and not as EIO. Bun.udpSocket reads the Winsock code, so its errors do not change.

Why both changes

Each change was built alone against the first 8 tests (debug build).

test main socket change only send change only both
refused datagram, [::1]:443 fail, 0 of 10 fail, 0 of 10 pass pass
refused datagram, 192.0.2.1:443 (Linux) fail, 0 of 10 fail, 0 of 10 pass pass
refused datagram, 255.255.255.255:443 (Linux) fail, 0 of 10 fail, 0 of 10 pass pass
a session opens after a refused one fail, timeout fail, timeout pass pass
ICMP error, 2 dead peers (Linux) fail, 6 of 10 pass pass pass
ICMP error, 3 dead peers (Linux) fail, 4 of 10 pass fail, 9 of 10 pass
ICMP error and a version negotiation probe (Linux) fail pass fail, timeout pass
a receive error closes the endpoint (fault injection builds) fail pass fail pass
  • The send change alone keeps IP_RECVERR. A send to a live peer still fails with the error of a dead peer, and that datagram is then dropped. The "fragmentation needed" and ENOBUFS rows above also stay as on main.
  • A retry of a failed send was not added. With IP_RECVERR off it has nothing to consume.

Each other clause has a test that fails when a build removes the clause:

clause build without it test that fails
would-block arm of the verdict a would-block send counts as sent "the socket would block", "a send returns 0"
EMSGSIZE arm of the verdict EMSGSIZE counts as sent "EMSGSIZE ends only the session that owns the packet"
ENOBUFS arm of the verdict ENOBUFS counts as sent, or the pause has no retry "the kernel has no buffer space" (timeout)
refused datagram on a listen() endpoint the verdict of main "a refused datagram costs only that datagram", "the kernel refuses 3 sends"
|| self.socket.get().is_none() in the close gate of process() the gate of main "a receive error during a handshake" (timeout)
the None arm of close_silently (a session with no connection yet) the arm does nothing "a receive error during a handshake", "a receive error on a client endpoint"
closing reads the native state (quic.ts) the getters of main "a connect() after a receive error uses another endpoint"
  • packets_out with no socket now returns n and not 0. It runs when a connection that an engine still holds (a handshake) sends after uSockets closed the socket. A short return there would make lsquic read a stale errno. No test can observe this value, so no test covers it.

Measurements

Release builds of a407eae (the merge base) and of 803bd64, unless a line says otherwise. perf, valgrind, strace and bloaty are not installed on this machine. The counts come from gdb, objdump, size, nm and LD_PRELOAD counters.

  • packets_out, one packet, the send succeeds: 189 to 192 instructions, 25 to 25 conditional branches, 2 to 2 calls (gdb nexti over 10 calls, callee bodies excluded). The 3 instructions are register moves. The loop now continues after a refused datagram, and the compiler allocates registers differently. The verdict is in a cold function that runs only when a send fails.
  • process: +3 instructions in each pass (one compare, one store and one branch for retry_unsent, read from the disassembly).
  • Syscalls per sent packet: +0. 200 datagram rounds with no dead peer: 1708 and 1702 sendmmsg calls, one message each and 0 failed, against 1704 and 1702.
  • Release text: 106,006,447 to 106,007,299 bytes (size), data unchanged. packets_out 1450 to 1462 bytes, process 21,082 to 21,169 bytes, unsent_stops_batch 63 bytes, on_close 6 to 702 bytes.
  • setsockopt calls for an endpoint socket: 5 to 4 on IPv4, 5 to 3 on IPv6. An endpoint that is never used creates no socket, as before (first revision).
  • Statistics: an endpoint whose only session goes to 192.0.2.1:443 makes 7 failing sendmmsg calls, and packetsSent is 7. On main it makes 14 failing calls, and packetsSent is 0 (first revision).

The outage cost. A shim refuses each send of the client with ENETUNREACH for the given time. The session writes 64 KB each 50 ms. The table gives the time from the end of the outage to the next byte at the server.

outage main this PR
4 s, idle timeout 60 s (2 runs) 320, 412 ms 1601, 1647 ms
8 s, idle timeout 60 s (2 runs) 315, 276 ms 5728, 2413 ms
8 s, default idle timeout of 10 s (3 runs) 531, 470, 399 ms session closed in 3 of 3
  • On main the short return gives the packets back unsent, and lsquic tries again 1 s later. Nothing backs off.
  • With this PR each refused datagram is a lost packet for lsquic, so each retransmission timeout doubles the next. The idle timer restarts only when a packet arrives.
  • This is what an outage that drops packets with no error does on main too. HTTP/3 has this behaviour since quic: drop a datagram the kernel refuses to send instead of pausing the HTTP/3 engine #42732, and Node ignores the status of a send. An errno cannot tell a refusal for one destination from a refusal for all of them, so packets_out cannot keep both behaviours.

The ENOBUFS cost. A shim makes each sendmmsg of the process fail with ENOBUFS for 1 s, while a stream waits for its answer (debug builds).

main writable poll (7108350) this PR
failed sendmmsg calls in the window 3 about 54,000 about 800
CPU time in the window 38 to 81 ms about 880 ms about 155 ms
end of the window to the answer of the stream 1007 to 1013 ms 10 to 25 ms 8 to 16 ms

What a user can see

  • A send that fails with ENOBUFS leaves 1 ms later. On main it waited for 1 s.
  • A receive error on the endpoint socket closes the endpoint: each session closes with no error, then endpoint.closed rejects with ERR_QUIC_ENDPOINT_CLOSED ("Receive failure"). With an injected recvmmsg error, main keeps the sessions open.
  • endpoint.closing is true from the receive error on, and a connect() with no endpoint option then uses another endpoint. Before this, it took the dying endpoint and failed with ERR_QUIC_ENDPOINT_CLOSED.
  • On Windows a receive error, and on macOS a poll error, makes uSockets close a UDP socket with or without a callback. The endpoint then kept the freed handle, because on_close was empty. This is read from the code, not run.
  • The statistics count a refused datagram, as Node counts a packet at hand-off.
  • The refused session still ends at its handshake timeout with ERR_QUIC_TRANSPORT_ERROR, as on main.

Node (v26.3.0)

  • Endpoint::UDP::Bind passes only UV_UDP_IPV6ONLY and UV_UDP_REUSEPORT (endpoint.cc#L496-L504). libuv sets IP_RECVERR only for UV_UDP_LINUX_RECVERR (udp.c#L470-L474).
  • A refused datagram fails one uv_udp_send request, and Endpoint::PacketDone ignores the status (endpoint.cc#L1921-L1930). The statistics count at hand-off (endpoint.cc#L956-L957).
  • A receive error destroys the endpoint with RECEIVE_FAILURE (endpoint.cc#L458-L461). Endpoint::Destroy closes each session with CloseMethod::SILENT, then closes the socket and emits the close (endpoint.cc#L1291-L1340, session_manager.cc#L126-L145).
  • One difference stays. Node destroys the endpoint with SEND_FAILURE when uv_udp_send or uv_udp_try_send returns an error other than EAGAIN. On Windows an immediate WSASendTo error does that. Bun drops the datagram on each platform and never emits SEND_FAILURE.
  • The installed node has no QUIC, so this is from the source, not from a run.

Tests

  • The refused-datagram tests use [::1]:443 on each platform: an IPv6 destination cannot leave the IPv4 socket of the endpoint. Linux also runs 192.0.2.1:443 and 255.255.255.255:443. No packet leaves the machine.
  • The ICMP tests are Linux only. IP_RECVERR does not exist elsewhere.
  • The other 10 tests use socketFaultInjection from bun:internal-for-testing, so they run on debug and ASAN builds and skip on Windows. For them, bsd_sendmmsg and bsd_recvmmsg get the hooks that bsd_sendmsg and bsd_recvmsg have (3 lines each, compiled out of release builds). These are the only changes in uSockets.
  • A fault rule has no fd, so it hits the next UDP send or receive of the process. The receive rules are set inside the receive loop of the socket that must fail. The send rules of the listen() tests are set in the pass that sends. The round-trip tests count the failed sends of both endpoints.
  • On the debug build of main, 12 of the 17 fail by an assertion and 2 by the 5 s timeout. The 3 tests for a blocked socket pass there: main has no send hook, so no send fails.
  • Suites on the debug build of this branch: test/js/node/quic/ (39 tests), test/js/bun/udp/ (294), serve-http3.test.ts (79), fetch-http3-client (67), fetch-http3-adversarial (27), fetch-http3-cold-post (2), fetch-http3-syscall-fault (7), socket-fault-injection (17). cd test && bun check reports no type error in the quic tests.
  • Some tests in those suites are near their timeout on debug builds on this machine, on main too. Examples: quic-sni.test.ts "connect() with the default verifyPeer...", udp_socket.test.ts "does not leak the hostname", fetch-http3-cold-post, fetch-http3-adversarial "8 concurrent POST /echo" with a 4 MB body, and fetch-http3-syscall-fault "a DPLPMTUD probe above the egress MTU...". Each passes when it runs alone.
  • At the first push: the 76 vendored test-dgram-* pass, and 227 of the 235 vendored test-quic-* pass on the debug build. The other 8 fail or are unstable on the debug build of main too: 2 need node:stream/iter, 1 asserts a round-trip time under 10 ms, and 5 are test-quic-callback-error-*.
  • CI ran the first push on macOS and Windows. The fault tests run only where the build has the fault hooks. bun run rust:check-all passes for x86_64-pc-windows-msvc and aarch64-apple-darwin.

Other PRs

Not in this change

  • HTTP/3 does not handle the close of its socket. us_quic_udp_on_close (quic.c) only clears the socket pointer, and us_quic_packets_out then reports EBADF as EAGAIN. After a receive error on an HTTP/3 socket the engine stays paused. The recvmsg hook of this PR makes that reproducible. It needs its own PR, because the right result for a Bun.serve listener is not Node's endpoint close.
  • The version negotiation probe (connect_verneg_probe) is the other send on the endpoint socket. It has no engine, so the verdict does not apply. Its result is still ignored, and a probe that the socket refuses is lost like one lost on the wire.
  • us_quic_packets_out arms the writable poll for ENOBUFS, so HTTP/3 sends again in each loop turn while it lasts. The retry in the next pass fits there too.
  • One sender for HTTP/3 and node:quic in uSockets is possible. It would rewrite the merged HTTP/3 sender.
  • lsquic still drops a delayed packet that holds only DATAGRAM frames on a real EAGAIN.
  • node:quic sends one datagram for each sendmmsg call.

Self-review

  • 17 of 18 concerns are in the code, the tests or this text: the ENOBUFS decision (after review it is backpressure, with a retry in the next pass), the comment that names the HTTP/3 twin, the send hook and its tests, the reconnect after a receive error, the tests for the close gate and for a session with no connection, the outage cost, and the lists above.
  • Rejected: carry the receive errno into the close error. Node prints Receive failure (-12) for ENOMEM, and this PR prints Receive failure (0). The errno reaches a handler only through the receive-error callback, and today that callback also turns IP_RECVERR on. A new field on us_udp_socket_t would carry it. After udp: stop setting IP_RECVERR on UDP sockets #35922 the callback no longer turns IP_RECVERR on, and the endpoint can take the errno from it with no change in uSockets.

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/quic/quic-endpoint.test.ts

…refused datagram as sent

A UDP send error that belongs to one destination stopped the sends of
every session on the endpoint. `packets_out` reported each errno except
EMSGSIZE to lsquic as EAGAIN. lsquic reads that as a blocked socket: it
pauses the engine, and at the next tick it drops each waiting packet
that holds only DATAGRAM frames.

- The endpoint passes no receive-error callback, so its socket never
  gets IP_RECVERR. An ICMP error about one peer no longer fails a send
  to another peer. `uws::udp::Socket::create` takes the callback as an
  `Option`.
- `packets_out` counts a datagram that the kernel refuses as sent, which
  lsquic sees as a packet lost on the wire. Only a socket that would
  block pauses the engine. EMSGSIZE still reaches lsquic.
- With no receive-error callback, uSockets closes the socket when a
  receive fails. `on_close` now ends each session with no packet and
  closes the endpoint with RECEIVE_FAILURE. `packets_out` with no socket
  reports the batch as sent, and the close does not wait for the
  connections that an engine still holds.
- `QuicEndpoint.closing` and the endpoint reuse of `connect()` also read
  the native closing state. A `connect()` after a receive error uses
  another endpoint.
- `bsd_sendmmsg` and `bsd_recvmmsg` get the fault-injection hooks that
  `bsd_sendmsg` and `bsd_recvmsg` have, so a test can make a UDP send or
  receive fail. Release builds compile them out.
@robobun

robobun commented Oct 8, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status

How this was reproduced, on linux x64 with release builds of main (bd599f5) and of this branch:

case main this PR
connect("192.0.2.1:443") beside a live session on the default client endpoint: datagrams of the live session that arrive 0 of 200 200 of 200
The same, one request on the live session 11076 ms 4 ms
One listen() endpoint, 2 dead peers behind UDP relays: datagrams the live client receives 132 of 200 200 of 200
One forged datagram from source port 0 to a listen() endpoint: worst round trip of the live session 4 s under 10 ms

The tests are in test/js/node/quic/quic-endpoint.test.ts. On a debug build of main, 14 of the 17 new tests fail. All pass with this branch.

CI passed on 803bd64 (build 126065). Each review thread is resolved. The PR waits for a maintainer review.

PR: #44800

@coderabbitai

coderabbitai Bot commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Repository: oven-sh/bun/.coderabbit.yaml
  • Review profile: ASSERTIVE
  • Plan: Essentials
  • Run ID: 64f4a0b6-0363-4fb9-a7ef-6c17079fd61d
📥 Commits

Reviewing files that changed from the base of the PR and between 620b50f and fa8e30a.

📒 Files selected for processing (7)
  • packages/bun-usockets/src/bsd.c
  • src/js/internal/quic/quic.ts
  • src/runtime/node/quic/endpoint.rs
  • src/runtime/node/quic/session.rs
  • src/runtime/socket/udp_socket.rs
  • src/uws_sys/udp.rs
  • test/js/node/quic/quic-endpoint.test.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 8 remain after this review.

Walkthrough

UDP fault injection and send-error handling have changed. QUIC endpoints now handle socket closure by closing sessions and completing endpoint closure. The JavaScript endpoint wrapper includes native closing state in its closing checks and inspection output. Tests cover send failures, receive errors, and endpoint closure.

Changes

QUIC UDP Error Handling

Layer / File(s) Summary
UDP callbacks and fault injection
packages/bun-usockets/src/bsd.c, src/uws_sys/udp.rs, src/runtime/socket/udp_socket.rs
UDP creation APIs accept an optional receive-error callback. UDP batch send and receive functions return injected fault results.
QUIC send-batch handling
src/runtime/node/quic/endpoint.rs, test/js/node/quic/quic-endpoint.test.ts
Packet output applies different handling to zero returns, EMSGSIZE, other send errors, and a missing socket. Tests cover send failures and effects across sessions.
Socket-error endpoint closure
src/runtime/node/quic/endpoint.rs, src/runtime/node/quic/session.rs, test/js/node/quic/quic-endpoint.test.ts
Socket closure records its close context, silently closes live sessions, and allows endpoint closure when the socket is absent. Tests cover receive errors and endpoint reuse.
JavaScript endpoint closing state
src/js/internal/quic/quic.ts
The endpoint wrapper uses native isClosing state in its closing getter, closed-or-closing checks, session removal, and inspection output.

Suggested reviewers: cirospaciari

Priority: ⬆️ High

Merge Risk: ⚪ Minimal · up to fa8e3

The change stops UDP send errors for one destination from stalling other QUIC sessions on the endpoint, and it closes the endpoint cleanly on receive errors. No merge-blocking issue was found. macOS and Windows behavior was not run, so check those platforms in CI.

Pre-merge checks: Passed 4
Passed Passed checks: 4 passed
Check name Status Explanation
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Title check Passed The title clearly summarizes the primary changes: disabling IP_RECVERR for the node:quic endpoint socket and counting refused datagrams as sent.
Description check Passed The description explains the problem, implementation, behavioral trade-offs, testing performed, and verification results. It does not use the exact template headings, but it provides the required info…
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@claude claude 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.

Beyond the inline finding, I also checked the FFI change: recv_error_cb becoming Option<extern "C" fn> matches the C side, which already branches on a NULL recv_error_cb in us_udp_socket_create (udp.c:238) and on u->on_recv_error in loop.c, and the only other caller (src/runtime/socket/udp_socket.rs) passes Some(...). The on_close deref of the user pointer is gated by the self.socket == Some(socket) identity check, so a close issued from release_native after the handle was taken is a no-op.

Extended reasoning...

The change touches the node:quic endpoint's lsquic packets_out send verdict, the uSockets UDP FFI signature (nullable recv_error_cb), a new socket-closed path that silently closes sessions and finishes the endpoint close with CLOSECONTEXT_RECEIVE_FAILURE, and fault-injection hooks in bsd.c; no auth or crypto surface. An inline finding on the ENOBUFS arm of unsent_stops_batch was confirmed, so a human should weigh that before merging; the FFI nullability and on_close pointer-liveness concerns were checked against udp.c and loop.c and are consistent.

Comment thread src/runtime/node/quic/endpoint.rs Outdated
A send that fails with ENOBUFS counted as sent, so lsquic saw a lost
packet. ENOBUFS means that the output queue of the interface is full.
It is common on macOS and it clears by itself.

- `packets_out` reports ENOBUFS to lsquic as a blocked socket, as
  `us_quic_packets_out` does for HTTP/3. uSockets arms the writable
  poll only when a send would block, so the endpoint arms it with the
  new `us_udp_socket_poll_writable`. `on_drain` then sends the packets.
- `bsd_sendmmsg` on Windows reports WSAENOBUFS as ENOBUFS and not as
  EIO. `Bun.udpSocket` reads the Winsock code, so its errors do not
  change.
- The tests in quic-endpoint.test.ts have no type errors, and one
  `afterEach` clears the fault rules.
Comment thread src/runtime/node/quic/endpoint.rs Outdated
Comment thread src/runtime/node/quic/endpoint.rs Outdated
Comment thread src/runtime/node/quic/endpoint.rs Outdated
Comment thread src/runtime/node/quic/endpoint.rs Outdated
Comment thread src/runtime/node/quic/session.rs Outdated
Comment thread src/uws_sys/udp.rs Outdated
@robobun

robobun commented Oct 15, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 1:09 PM PT - Oct 15th, 2026

✅ @robobun, your commit 803bd64077859da0fedcec50c1c521619afb3eb7 passed in Build #126065! 🎉


🧪   To try this PR locally:

bunx bun-pr 44800

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

bun-44800 --bun

Comment thread src/uws_sys/udp.rs Outdated

@claude claude 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.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Beyond the inline finding, I also checked three things on the new revision and ruled them out: the on_close -> on_socket_closed re-entry during release_native (it takes the handle before close(), so the self.socket.get() != Some(socket) guard returns early); the None arm of close_silently pushing Closed without session.schedule_process() (the endpoint process pass walks every session's process_events, so the close is delivered); and the dropped EWOULDBLOCK clause from the old errno rewrite (us_udp_socket_send returns 0 and arms WRITABLE on would-block rather than -1, so the rv == 0 arm covers it on every platform).

Extended reasoning...

The change touches the node:quic endpoint send/receive error handling in src/runtime/node/quic/endpoint.rs, the uSockets UDP socket creation signature (recv_error_cb now optional) and a new us_udp_socket_poll_writable, plus a close_silently path in session.rs and JS-side closing state in quic.ts; it touches no auth, crypto or injection surface. The ENOBUFS re-arm busy-loop finding is posted inline; the other specific lifecycle and errno-mapping concerns examined were ruled out from the code.

One part of this review's analysis that stopped early was run once more. It finished.

Comment thread src/runtime/node/quic/endpoint.rs Outdated
The writable poll is the wrong signal for ENOBUFS. The socket stays
writable while the output queue of the interface is full, so the poll
made the loop send again at once, without a pause.

- `packets_out` still reports ENOBUFS to lsquic as a blocked socket.
  It now sets `retry_unsent`, and the next `process` pass sends the
  waiting packets again. The timer runs that pass 1 ms later.
- `us_udp_socket_poll_writable` is removed again.
- Each comment that this branch adds in src/ is one line.

@claude claude 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.

Code review found no issues

No high-confidence issues detected in this change.

This branch has not been deployed

No deployments
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.

1 participant