Skip to content

node:dgram: implicitly bind before membership operations - #33031

Closed
robobun wants to merge 7 commits into
mainfrom
farm/c4b6160e/dgram-implicit-bind-membership
Closed

robobun wants to merge 7 commits into
mainfrom
farm/c4b6160e/dgram-implicit-bind-membership

Conversation

@robobun

@robobun robobun commented Jun 29, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

Calling addMembership() on a node:dgram socket before bind() throws ERR_SOCKET_DGRAM_NOT_RUNNING in Bun. Node implicitly binds the socket to a random port first (libuv's uv__udp_maybe_deferred_bind) and then joins the group, as documented:

When calling addMembership for the first time on an unbound socket, it is implicitly bound to a random port, listening on all interfaces.

const s = require("dgram").createSocket("udp4");
s.addMembership("224.0.0.114", "127.0.0.1");
// node: ok, s.address() now reports a port
// bun:  Error: Socket is not running  (ERR_SOCKET_DGRAM_NOT_RUNNING)

The same applies to dropMembership, addSourceSpecificMembership, and dropSourceSpecificMembership, which all go through the same libuv deferred bind.

Cause

dgram.ts's membership functions throw ERR_SOCKET_DGRAM_NOT_RUNNING whenever handle.socket is missing. #16446 added a this.bind({ port: 0, exclusive: true }) for the unbound case, but placed it after that throw, so it was unreachable. It could not have worked anyway: Socket.prototype.bind is asynchronous (address lookup, then a .then on the Bun.udpSocket() promise), and Node's membership operations are synchronous, so there is nothing for them to wait on.

Fix

Bun.udpSocket() already creates and binds the uws socket synchronously; the Promise it returns is only the public API shape. This splits that body into a UDPSocket::create that returns the socket directly and exposes it to the dgram builtin as UDPSocket.jsCreate. A new implicitBind() helper calls it when a membership function is invoked on an unbound socket, binding to 0.0.0.0:0 (or [::]:0) synchronously, exactly like libuv's deferred bind.

Socket.prototype.bind now goes through the same attachSocket() helper instead of Bun.udpSocket(...).then(...), so there is a single place that creates the handle's socket. That also removes an extra microtask hop between the address lookup resolving and listening firing, matching Node: Node emits listening synchronously from inside the lookup callback. The only place the hop was observable is with a synchronous custom lookup option; the sendMany reentrancy fixture relied on it and now registers its listening listeners before calling bind() (the reentrancy assertions it exists for are unchanged).

Behavior preserved:

  • A closed socket, or one with a bind() still in flight, still throws ERR_SOCKET_DGRAM_NOT_RUNNING.
  • An invalid multicast address on an unbound socket still throws addMembership EINVAL without binding (Node validates the address before the deferred bind).

One deliberate divergence: after Node's implicit bind, a subsequent socket.bind(port) or socket.send(...) fails with EINVAL, because libuv binds the fd while the JS-level bind state stays unbound, and the later JS-level bind retries the bind(2) syscall on the already-bound fd. Bun marks the socket bound instead, so a later bind() throws ERR_SOCKET_ALREADY_BOUND synchronously, exactly as Node does (#33037, which landed on main during review, changed that from an "error" emit to a throw), and a later send() just works. Reproducing the EINVAL would mean deliberately creating a second, conflicting native socket.

A second bug this surfaced: dns.lookup invokes its callback twice

Review on this PR pointed out that emitting listening synchronously from the lookup callback means a throwing listening listener would re-enter that callback. Investigating it turned up a pre-existing node:dns bug (reproducible on main, independent of this PR):

let calls = 0;
require("dns").lookup("localhost", (err) => {
  calls++;                       // node: 1     bun (main): 2
  if (calls === 1) throw new Error("boom");
});

lookup() invokes the user callback from inside the .then of a .then(...).catch(...) chain, so the callback's own throw falls into the chained .catch, which invokes the callback a second time with that throw presented as the lookup error. Node invokes the callback exactly once and surfaces the throw as uncaughtException. For node:dgram, that double invocation would have turned a throwing listening listener into bindState = UNBOUND with a live handle.socket, so the next send() would silently create and leak a second socket.

The fix leaves the callback invocation exactly where it is (synchronous, inside the same .then reaction) and wraps only that invocation in a try/catch that re-raises the callback's own throw from a queueMicrotask. It surfaces as an uncaughtException exactly as in Node, and it can never reach the chained .catch. The error arm gets the same treatment. throwIfEmpty's intentional throw into the .catch is unaffected, and the whole node:dns module keeps invoking every callback exactly once.

An earlier iteration of this fix deferred the callback through process.nextTick instead. That regressed the upstream test/js/node/test/parallel/test-net-connect-memleak.js: the test is conservative-GC sensitive (its outcome already flips between a debug and a release build of the same main commit), net.connect(port) resolves localhost through dns.lookup, and adding any deferral (a process.nextTick or an extra promise reaction) to that path keeps the once('connect') listener's closure reachable across the test's gc(). That reproduces 30/30 on an otherwise unmodified main build with no build of this branch needed, which is how it was caught and why the deferral was dropped. The landed shape adds zero deferral and zero allocation to the success path, and the test is 40/40 green against this branch.

Verification

New tests, all failing on an unfixed build:

  • test/js/bun/udp/dgram.test.ts, "membership on an unbound socket": the implicit bind for all four operations plus the udp6 wildcard, that the implicitly bound socket can send, and the preserved not-running/EINVAL behaviors. Four fail with ERR_SOCKET_DGRAM_NOT_RUNNING.
  • test/js/bun/udp/dgram.test.ts: a throwing listening listener on bind(0, "localhost", cb) followed by a send() keeps the same socket and port, and the throw surfaces through uncaughtException like Node. Fails on main.
  • test/js/node/dns/dns-lookup-keepalive.test.ts: dns.lookup invokes its callback exactly once when the callback throws. Fails on main with two invocations.

test/js/bun/udp/, test/js/node/net/, test/js/node/dns/, test/js/node/test/parallel/test-net-connect-memleak.js (40/40), and the upstream test/js/node/test/parallel/ test-dgram-*, test-dns*, test-net-connect*, and test-net-autoselectfamily* suites all pass against the debug build with no failures beyond the pre-existing ones already present with the released binary.

Rebase

Rebased onto main after #33037 (dgram.Socket#bind() on an already-bound socket now throws ERR_SOCKET_ALREADY_BOUND synchronously instead of emitting "error") and #33035 (a bun_core::strings module-path cleanup) landed. The only conflict was the two dgram PRs appending independent describe blocks to the same point in test/js/bun/udp/dgram.test.ts; both blocks are kept. #33037 is compatible with and strengthens this change, and its two new bind() tests pass alongside this PR's on the rebased tree (229 passing across test/js/bun/udp/ plus the dns test).

@coderabbitai

coderabbitai Bot commented Jun 29, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Replaces async UDP socket creation with a synchronous Rust bridge, adds implicit bind for multicast membership APIs, and makes dns.lookup rethrow callback exceptions asynchronously.

Changes

UDP synchronous bind, implicit multicast bind, and dns.lookup callback rethrow

Layer / File(s) Summary
Rust js_create entry point for synchronous UDP socket creation
src/runtime/socket/udp_socket.rs
Splits udp_socket into a thin Promise wrapper and a create helper, adds js_create as a synchronous entry point, and returns the JS wrapper directly from create.
attachSocket and multicast bind flow
src/js/node/dgram.ts
Introduces createSocketFn and attachSocket() for synchronous UDP socket setup, updates Socket.prototype.bind() to call attachSocket() after DNS lookup, adds implicitBind(), and routes multicast membership methods through implicit binding with address validation.
dns.lookup callback rethrow
src/js/node/dns.ts
Wraps both success and error callback invocations in try/catch and rethrows user-thrown exceptions on a microtask via rethrowAsUncaughtException().
Tests and fixture updates
test/js/bun/udp/dgram.test.ts, test/js/bun/udp/sendMany-reentrancy-fixture.ts, test/js/node/dns/dns-lookup-keepalive.test.ts
Adds multicast membership and bind-state regression coverage, updates the UDP sendMany reentrancy fixture for synchronous listening emission, and adds a dns.lookup throwing-callback subprocess test.

Suggested reviewers

  • Jarred-Sumner
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: implicit binds before membership operations in node:dgram.
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.
Description check ✅ Passed The PR description is detailed and covers the change and verification, even though it uses different headings than the template.

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

@robobun

robobun commented Jun 29, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 8:39 AM PT - Jun 30th, 2026

❌ @robobun, your commit badb9e8 has 2 failures in Build #67253 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 33031

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

bun-33031 --bun

Comment thread src/js/node/dgram.ts
Comment thread test/js/bun/udp/dgram.test.ts Outdated

@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: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/js/bun/udp/dgram.test.ts`:
- Around line 382-389: The test in dgram.test.ts leaves the udp4 socket open if
the addMembership assertion fails, so register cleanup before the assertion
path. Update the bind/await flow in the a bind() already in flight still throws
ERR_SOCKET_DGRAM_NOT_RUNNING test to use try/finally or using so socket.close()
always runs, and keep the cleanup tied to the socket created by createSocket and
the Promise.withResolvers listening flow.
🪄 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: 07a25be3-1ce1-4639-a004-9e7cc74ee270

📥 Commits

Reviewing files that changed from the base of the PR and between 20d649f and a2072b3.

📒 Files selected for processing (6)
  • src/js/node/dgram.ts
  • src/js/node/dns.ts
  • src/runtime/socket/udp_socket.rs
  • test/js/bun/udp/dgram.test.ts
  • test/js/bun/udp/sendMany-reentrancy-fixture.ts
  • test/js/node/dns/dns-lookup-keepalive.test.ts

Comment thread test/js/bun/udp/dgram.test.ts
Comment thread test/js/bun/udp/dgram.test.ts
Comment thread src/js/node/dns.ts
@robobun

robobun commented Jun 29, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: the diff is complete, every review thread on this PR is resolved, and the remaining CI red is not caused by this change.

Note

Updated after a rebase. The branch has since been rebased onto main (past #33037 and #33035; summary in the PR description) and force-pushed as badb9e8. The build numbers below refer to the pre-rebase tree and are kept because the analysis of each failure class is what matters and is unchanged; The rebased tree's run, build 67253, finished at 283 passed, 3 failed, and every red lane is a failure class already analyzed below: the two alpine test-net-connect-memleak.js instances (item 1, the repository-wide ambient flake), and one darwin 14 x64 shard on test/js/bun/terminal/terminal.test.ts (the macOS PTY flake already proven non-deterministic above by the identical-tree comparison of builds 66651 and 66701, and by its appearance on the unrelated claude/websocket-recv-reentrancy branch). The darwin 26 aarch64 artifact-download infrastructure timeout from the pre-rebase builds did not recur on the new agents, confirming that classification too. There is nothing in any red lane, on any build of this PR, that is caused by this change.

At build 66640 (sha 8de7caa), 278 test jobs passed and 4 lanes were red. None of the 4 failures touch anything in this diff (node:dgram, node:dns's lookup(), udp_socket.rs, and their tests):

  1. test/js/node/test/parallel/test-net-connect-memleak.js on alpine 3.23 x64 and alpine 3.23 x64-baseline. This looked like the most plausibly related failure (the test goes through dns.lookup), so I dug in rather than waving it off. It is an ambient, branch-independent flake: the same test is failing on the same two alpine lanes in 25 of the 100 most recent Buildkite builds, across at least 15 completely unrelated branches, including builds 66620 (a symbol rename), 66635 (a TextDecoder encoding-name normalization), 66628 (a console.table fix), 66634 (Buffer.fill), 66622 (Buffer.from), 66625 (a Windows fs.rename fallback), 66602 (HTMLRewriter), and 66560 (ReadableStream). None of those go anywhere near net, dns, or the GC. Re-sampled at the time of the final build on this PR: it is red in 26 of the 60 most recent Buildkite builds, which is every build in that window that carries any test-failure annotation at all, across 25 more unrelated branches including several from a different automation pipeline entirely (claude/spawn-uid-gid-options, claude/websocket-recv-reentrancy, claude/node-https-server-tls-options). Its current per-build failure rate is effectively 100%. It also fails only on the two alpine (musl) x64 lanes while passing on the debian/ubuntu x64, x64-baseline, x64-asan, windows, and alpine aarch64 lanes in the same build, which fits a conservative-GC stack-layout sensitivity (the test's outcome already flips between a debug and a release build of the same unmodified main commit) rather than a JS-level retention.

    Separately from that flake, this test did drive a real design change here: an earlier iteration of the dns.lookup fix deferred the callback through process.nextTick, and that genuinely regresses this test, reproducible 30/30 on an unmodified main build with no build of this branch. That iteration was dropped; the landed fix adds zero deferral to the path. Against this branch's debug build the test passes 40/40 locally.

  2. test/js/bun/util/v8-heap-snapshot.test.ts on ubuntu 25.04 x64-baseline: SIGKILL with no core, i.e. the runner or the OOM killer, on the slowest lane. The test imports only v8, v8-heapsnapshot, and the harness.

  3. test/js/bun/test/parallel/test-docker-build-debian.ts on macOS aarch64: Docker Hub 429 Too Many Requests while pulling debian:trixie.

  4. test/cli/update_interactive_install.test.ts on Windows 2019: already auto-flagged flaky by CI and retried twice.

Every subsequent push has hit the same classes of unrelated red lanes. At build 66651 (sha bf17eed, two further test-only commits from bot review feedback: 282 passed, 4 failed), the only genuine test failure in the whole build was the alpine test-net-connect-memleak.js flake described above; the other two red jobs were a buildkite-agent artifact download timed out after 120s on both darwin 26 aarch64 shards, which is Buildkite infrastructure: those shards never obtained a binary and ran zero tests.

I then spent the one ci: retrigger I am willing to push (dff22bd, build 66701). It landed on the identical darwin 26 aarch64 artifact-download failure on both shards, again before a single test ran, so that build cannot go green either. The darwin 26 aarch64 agent pool is currently failing to download build artifacts on every run of this branch. That build's final tally is 280 passed, 6 failed: the two darwin artifact-download shards, the two alpine test-net-connect-memleak.js lanes, a darwin 14 x64 shard on test/js/bun/terminal/terminal.test.ts (a macOS PTY test this PR does not touch, which also failed on the unrelated claude/websocket-recv-reentrancy branch's build 66708 in the same window), and a darwin 14 aarch64 shard on test/js/bun/http/bun-serve-file.test.ts and test/js/bun/http/fetch-file-upload.test.ts, two file-serving tests this PR does not touch.

Those last three test failures need no further analysis. dff22bd is an empty commit, so build 66701 built a tree byte-identical to build 66651's, and all three were green on the same darwin 14 lanes at 66651. The same tree producing the opposite outcome is the definition of a non-deterministic test. The only failures common to both builds of that identical tree are the darwin 26 aarch64 artifact-download infrastructure and the alpine test-net-connect-memleak.js flake.

The source change has been stable since 6070f18; everything after it is test-only review feedback plus that one empty retrigger, and all 7 review findings from claude and CodeRabbit are addressed with every thread resolved. There will be no further pushes from me. This is ready for a maintainer: the diff is green on every lane that actually ran its code, and the remaining red is the infrastructure failure and ambient flake documented above.

@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: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/js/bun/udp/dgram.test.ts`:
- Around line 349-352: The retry loop in sendRec ignores async failures from
sender.send(), so a broken implicit-bind/send path can keep rescheduling until
timeout. Update sendRec to inspect the callback error from sender.send and
stop/reject the test immediately when it is non-null, instead of always
scheduling the next retry. Use the existing sendRec and sender.send flow in the
UDP dgram test to wire the failure path through the callback.
🪄 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: 67015cc7-93a6-454b-a3e7-0bfdb0e57e30

📥 Commits

Reviewing files that changed from the base of the PR and between a2072b3 and 6183933.

📒 Files selected for processing (2)
  • src/js/node/dns.ts
  • test/js/bun/udp/dgram.test.ts

Comment thread test/js/bun/udp/dgram.test.ts
robobun added 7 commits June 30, 2026 14:31
addMembership(), dropMembership(), addSourceSpecificMembership(), and
dropSourceSpecificMembership() on an unbound socket threw
ERR_SOCKET_DGRAM_NOT_RUNNING. Node implicitly binds the socket to a random
port first (libuv's uv__udp_maybe_deferred_bind) and then applies the
membership change.

The `this.bind({port: 0})` call that #16446 added for this was placed after
the not-running throw, so it never ran; and Socket.prototype.bind is
asynchronous (DNS lookup + promise), which a synchronous membership call
cannot wait on.

Bun.udpSocket already creates and binds its socket synchronously; the Promise
it returns is only API shape. Split that body into UDPSocket::create and
expose it to the dgram builtin as `UDPSocket.jsCreate`, so the implicit bind
can happen inline. Socket.prototype.bind now goes through the same helper,
which removes the extra microtask hop between the address lookup resolving
and `listening` firing; with a synchronous custom `lookup` option, `bind()`
now emits `listening` before it returns, exactly as Node does. The
sendMany reentrancy fixture relied on that extra hop, so it registers its
`listening` listeners before calling bind().
dns.lookup()'s callback ran inside the `.then` of a `.then(...).catch(...)`
chain, so a throw from the callback fell through to the chained `.catch`,
which invoked the callback a second time with the callback's own exception
presented as the lookup error. Node invokes the callback exactly once and
the throw surfaces as an uncaughtException.

This became observable through node:dgram after the previous commit removed
the extra microtask between bind()'s address lookup resolving and "listening"
being emitted: a throwing "listening" listener re-entered bind()'s lookup
callback as a lookup error, which reset bindState to UNBOUND while
state.handle.socket still held the live native socket, so the next send()
or bind() silently created and leaked a second socket.

Invoke the callback from process.nextTick in both arms of the chain, the
same idiom lookup() already uses for its IP-literal fast path. A throw from
the callback is then an uncaughtException, exactly as in node, and can
never reach the `.catch`. throwIfEmpty() still relies on the chained
`.catch`, so the two-argument `.then(onFulfilled, onRejected)` form the
rest of node:dns already uses would not have worked here.
The two new spawned-process tests set `stderr: "pipe"` without ever reading
it, so an unexpected child failure would have surfaced as an opaque
`{stdout: "", exitCode: 1}` with the diagnostic dropped. Drain it and
include it in the asserted object, filtering the benign ASAN startup
warning the same way the neighboring udp_socket.test.ts does.

Also point the "see also" comment at dns-lookup-keepalive.test.ts, where
that test actually lives.
The previous commit routed lookup()'s callback through process.nextTick so
a throw from it could not reach the chained `.catch` and invoke the
callback a second time. That deferral regressed
test/js/node/test/parallel/test-net-connect-memleak.js on the alpine CI
lanes, and reproduces 30/30 on an unmodified main build just by adding a
process.nextTick (or an extra promise reaction) to the net.connect lookup
path: the test exercises conservative GC, `net.connect(port)` resolves
"localhost" through dns.lookup, and any added deferral in that path keeps
the once('connect') listener's closure reachable across the test's gc().

Invoke the callback synchronously from the same `.then` reaction it has
always run in, so the success path is structurally unchanged, and wrap
only the invocation in a try/catch that re-raises the callback's own throw
from a queueMicrotask. That surfaces it as an uncaughtException exactly as
node does and keeps it out of the chained `.catch`. The error arm gets the
same treatment so a throwing error callback is an uncaughtException there
too, rather than an unhandled rejection of the chain's terminal promise.
The bind-in-flight test left its socket open if the addMembership
assertion failed. Register the cleanup before the assertions like the
rest of the describe block.
"the implicitly bound socket can send" was the only send-then-receive test
in test/js/bun/udp/ that fired a single datagram and then awaited the
receive unconditionally; a drop over loopback on a loaded host would hang
it to the per-file timeout. Drive it with the same sendRec retry loop the
other send tests in this file use.
dgram's send() reports errors only through its callback when one is
supplied, never as an "error" event, so the retry loop was ignoring them
and would have spun until the file timeout. Reject the awaited promise
with the real error instead.
@robobun
robobun force-pushed the farm/c4b6160e/dgram-implicit-bind-membership branch from dff22bd to badb9e8 Compare June 30, 2026 14:44
@robobun

robobun commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator Author

Closing as part of a cleanup of stale pull requests. This PR has had no new commits since 2026-06-30, it conflicts with main, and its last CI run failed. This is not a judgment on the fix itself. If the problem still reproduces on a current build, reopen this PR after a rebase or open a new one against main.

@robobun robobun closed this Sep 13, 2026
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