Repository navigation
Conversation
A UDP descriptor that Bun adopts can be one that another process also
reads: child.send(message, socket) delivers a socket to
bind({ fd, exclusive: true }). Such a socket took up to 8 datagrams per
recvmmsg. When a 'message' listener closed it, it dropped the ones it
had not delivered, and the other process never got them.
us_create_udp_socket_from_fd loses its `shared` parameter. Every adopted
descriptor reads one datagram per receive call, as Node does. The
private sharedFd option of Bun.udpSocket, its builtin name and
UDPSocketConfig.shared_fd are deleted.
Sockets that Bun creates keep the batch. That includes the sender of
child.send(message, socket), which this change does not cover.
The Windows arm of bsd_recvmmsg ignored the count. It now honours it, as
the other arms do.
|
Updated 4:41 PM PT - Nov 10th, 2026
❌ @robobun, your commit a1d8b88 has 1 failures in
🧪 To try this PR locally: bunx bun-pr 48497That installs a local version of the PR into your bun-48497 --bun |
|
Status Reproduction: two child processes get one
Script (run it with
|
There was a problem hiding this comment.
Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.
Beyond the inline findings, I also checked the mechanical parts: the Rust extern for us_create_udp_socket_from_fd in src/uws_sys/udp.rs matches the C definition parameter-for-parameter after the shared removal, and no other caller remains; the remaining sharedFd hits in net.ts/dgram.ts/cluster are the public handle property, not the deleted private builtin name; the Windows bsd_recvmmsg hunk is behavior-preserving for the only prior caller (max_packets == LIBUS_UDP_RECV_COUNT) and returns 1 correctly for the new shared case; the SO_REUSEADDR claim in the libusockets.h comment matches bsd_prepare_adopted_udp_socket. Not run here: the new tests, bun run rust:check-all, and the mordant lint whose baseline entry was removed.
Extended reasoning...
The change removes the shared flag from the adopted-UDP-descriptor path so every bind({ fd }) / Bun.udpSocket({ fd }) socket reads one datagram per receive call, deletes the private sharedFd builtin name end-to-end (C, Rust FFI, bindings.cpp, builtin tables, dgram.ts), fixes the Windows bsd_recvmmsg loop to honour max_packets, and adds three POSIX test cases. It touches no security-sensitive surface. The plumbing is consistent across all consumers, but the PR deliberately reverts a prior design decision (batching on standalone adopted descriptors) with a measured ~50% receive-phase cost at K=8 and asks named maintainers to confirm, so a human should weigh that trade-off; the inline findings already signal that.
|
No actionable comments were generated in the recent review. WalkthroughThe change removes the ChangesUDP descriptor adoption
Merge Risk:
|
The raw handle, the sender and the adopted socket go on a DisposableStack as soon as they exist. The 'error' event of the dgram socket and the error handler of Bun.udpSocket reject the promise that the case awaits. The datagrams are queued before the socket adopts the descriptor, so the case does not wait for 'listening'.
There was a problem hiding this comment.
I re-reviewed after a1d8b88 and found no bugs; the follow-up resolves both of my earlier test notes (resources go on a DisposableStack before the assertions, and both the dgram 'error' event and the Bun.udpSocket error handler now reject the awaited promise). Because this reverses a deliberate choice from #31829 and trades up to ~50% on the receive phase of bursts on bind({ fd }) / Bun.udpSocket({ fd }) for one-datagram-per-call semantics, a maintainer should still confirm that tradeoff.
What was reviewed:
- Signature change of us_create_udp_socket_from_fd: diffed the C definition, the header, and the Rust extern + wrapper parameter-by-parameter; grepped for other callers (none, including cfg-gated code).
- Removal of the sharedFd private name across BunBuiltinNames.h, bindings.cpp enum/switch, src/jsc/lib.rs, globals.builtins.ts and dgram.ts; the remaining
sharedFdhits in net.ts/dgram.ts/cluster are the unrelated public handle property. - Windows bsd_recvmmsg now honours max_packets; the early-return-on-would-block path and loop.c's do/while(!closed) drain are unchanged and consistent with the new count.
- Not run here: the new test cases and
bun run rust:check-all(cross-platform change).
Extended reasoning...
The diff removes the shared parameter from us_create_udp_socket_from_fd so every adopted UDP descriptor reads one datagram per receive call, deletes the private sharedFd option from five mirror sites plus the Rust config field, fixes the Windows bsd_recvmmsg arm to honour max_packets, and adds three test.each cases in test/js/bun/udp/dgram.test.ts. It touches no security-sensitive surface (no auth, crypto, or untrusted parsing). The code is small and mechanically consistent, and the follow-up commit addressed the two inline nits from the prior review. Defer rather than approve because the change deliberately reverses a prior maintainer decision and carries a measured performance cost on adopted-descriptor bursts, which is a design tradeoff a human should sign off on; no CODEOWNER covers the changed paths and no third-party objection is outstanding.
Problem
child.send(message, socket)delivers a socket tobind({ fd, exclusive: true })(src/js/builtins/Ipc.ts:98).recvmmsg(packages/bun-usockets/src/loop.c:1100). When a'message'listener callsclose(),on_data(src/runtime/socket/udp_socket.rs:180) drops the rest. With 32 queued, the other process gets 24 (Node v26.3.0: 31).Fix
us_create_udp_socket_from_fdloses itssharedparameter. Every adopted descriptor reads one datagram per receive call, as Node does.sharedFdoption andUDPSocketConfig.shared_fdare deleted. Sockets that Bun creates keep the batch.test/js/bun/udp/dgram.test.ts, all fail on main. Notes lists the other suites.Background
recvmmsgtakes several datagrams out of the queue of a socket in one call. Nothing puts them back.bind({ fd })keeps the batch. b7a6441 then madechild.send(message, dgramSocket)adopt with that call.Ipc.ts:98only. It leavesbind({ fd })on an inherited descriptor lossy.Downsides
bind({ fd })andBun.udpSocket({ fd })sockets make K+1 receive calls for K queued datagrams, as Node does. It was ceil(K/8)+1 (Linux, macOS 15.6+). The receive phase of a burst of 8 takes 51% longer (Notes).child.send(message, socket), whose socket Bun created. node:dgram: read one datagram per receive call on a socket that was sent over IPC #48498 covers it.Notes
No issue reports this loss. It was found by a read of the code, then confirmed against Node v26.3.0 with the script in the status comment.
What two processes see. 32 datagrams are queued on one UDP socket that two processes hold. One process stops in its first
'message'listener. The table gives the count of datagrams that the other process then receives.child.send(message, socket)close()child.send(message, socket)process.exit()bind({ fd })close()Bun.udpSocket({ fd })close()child.send(message, socket)close()child.send(message, socket)andclose()in one listenerThe last two rows are the sender of an IPC send. Its socket is one that Bun created, so this PR does not change it.
History of the flag. All four commits are on the branch of #31829.
shared_fd: "Receive one datagram per syscall so a close() from the data callback cannot discard batched packets."sharedparameter. Its message: "standalone dgram.bind({fd}) and Bun.udpSocket({fd}) keep the recvmmsg batch".dgram.Socketover IPC. It adopts withbind({ fd, exclusive: true }), so it got the batch.What Node does. libuv reads one datagram per
recvmsgand checks the handle again before the next one (uv__udp_recvmsg,deps/uv/src/unix/udp.c).UV_UDP_RECVMMSGis an opt-in flag ofuv_udp_init_ex. Node'ssrc/udp_wrap.cccallsuv_udp_init, so Node never batches.Measurements. Linux x64, release builds of af566a3 with and without this change. Node is v26.3.0.
bind({ fd })andBun.udpSocket({ fd })on main: 200 / 200 / 200 / 500 / 2500. With this PR: 200 / 300 / 900 / 3300 / 19300. Node on a socket that it creates: 200 / 300 / 900 / 3200 / 19200.send()to the K-th'message'. One process sends and receives. Median of 7 interleaved runs of 20000 bursts on one pinned CPU.bind({ fd })mainBun.udpSocket({ fd })mainsend()calls included: +16% at K=8 and at K=64.bind(port)-0.9% / -0.4% / -1.8% andBun.udpSocket({ port })+1.9% / -1.2% / -0.4% at K = 1 / 8 / 64. That is noise.bind({ fd }), one process sends and receives, 6 interleaved pairs: 22289 to 21558 at K=1 (-3.3%), 14897 to 15685 at K=8 (+5.3%), 14328 to 15120 at K=32 (+5.5%).Bun.udpSocket({ fd }): -3.5% at K=1, +5.3% at K=8, +5.7% at K=32.perf,valgrindorstrace. The call counts come from anLD_PRELOADcounter ofrecvmmsgandrecvmsg. The instruction counts come fromperf_event_open.Other platforms.
recvmsgthere, so this PR adds no receive call on it. Not measured: no macOS machine here.bsd_recvmmsgignored the count and always took up to 8 datagrams. It now honours the count, as the macOS loop does. Not run: no Windows machine here.bind({ fd })refuses every descriptor on Windows, so onlyBun.udpSocket({ fd })reaches that arm with a count of 1.Tests.
endon a bound descriptor. Then a socket adopts the descriptor in one of three ways:bind({ fd }),bind({ fd, exclusive: true }),Bun.udpSocket({ fd }). The first listener reads the rest of the queue through the descriptor, then callsclose(). On main the listener finds 24 datagrams, because the socket already took 8.bind({ fd })refuses every descriptor.test/js/bun/udp/dgram.test.ts(65 pass),udp_socket.test.ts(234),udp_socket_recv_flags.test.ts(2),test/js/node/dgram/node-dgram.test.js(7),test/js/node/child_process/child_process_ipc_handle.test.ts(15), and the 89 files undertest/js/node/test/{parallel,sequential}withdgramorudpin the name.test/internal/builtins-table/(149 pass) ran with a release build.Other PRs. #48393 restructures
UDPSocketConfig.shared_fd. This PR deletes that field.