From 00a4d5303205f0cf16386b0733b2a805d0c5250e Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 31 Oct 2026 22:14:32 +0000 Subject: [PATCH 01/11] node:dgram: run 'message' in the async context of createSocket() Node.js makes the UDP handle of a socket in createSocket(). What that handle delivers, 'message' and a receive error, runs in that async context. Bun opens the native socket in bind(), or in the send() or connect() that binds, and stored its handlers with the async context of that call. So a 'message' listener read the AsyncLocalStorage store of the call that bound the socket. bind() now opens the native socket inside the async context of createSocket(). A shared bind of a cluster worker does not: its handle is the one the primary sent. A socket that another Bun.ModuleGraph binds stays the binder's. The drain handler moves with the handle. So a send() that waited behind backpressure keeps the async context of that send() for its callback, as the request of Node.js does. --- src/js/node/dgram.ts | 41 +++- .../bun/module-graph/module-graph-gc.test.ts | 42 ++++ .../bun/module-graph/module-graph-io.test.ts | 181 +++++++++++++++ .../dgram-cluster-async-context-fixture.ts | 55 +++++ test/js/bun/udp/dgram.test.ts | 213 ++++++++++++++++++ .../async-context-dgram-createSocket.js | 154 +++++++++++++ 6 files changed, 683 insertions(+), 3 deletions(-) create mode 100644 test/js/bun/udp/dgram-cluster-async-context-fixture.ts create mode 100644 test/js/node/async_hooks/async-context/async-context-dgram-createSocket.js diff --git a/src/js/node/dgram.ts b/src/js/node/dgram.ts index 48fb4620bf39..7ac295f8a75d 100644 --- a/src/js/node/dgram.ts +++ b/src/js/node/dgram.ts @@ -66,6 +66,9 @@ const SymbolDispose = Symbol.dispose; const SymbolAsyncDispose = Symbol.asyncDispose; const ObjectDefineProperty = Object.defineProperty; const FunctionPrototypeBind = Function.prototype.bind; +const WeakMapPrototypeGet = WeakMap.prototype.get; +const WeakMapPrototypeSet = WeakMap.prototype.set; +const WeakMapPrototypeDelete = WeakMap.prototype.delete; // Mirrors Node's SystemError shape for ERR_SOCKET_BUFFER_SIZE: name // "SystemError", an enumerable `info` object, errno/syscall accessors that @@ -309,6 +312,8 @@ interface QueuedSend { port: number | undefined; address: string | null | undefined; req: SendRequest; + // The async context of the send(), where node makes the request that it completes: https://github.com/nodejs/node/blob/v26.3.0/lib/dgram.js#L738 + frame: unknown; } interface UDPHandle { @@ -327,6 +332,17 @@ interface UDPHandle { [kOwnerSymbol]?: $Node.DgramSocket; } +// The async context of createSocket(), for a handle whose native socket is not open yet. node makes the UDP handle there, and what that handle delivers ('message', a receive error) runs in it: https://github.com/nodejs/node/blob/v26.3.0/lib/dgram.js#L143 +const handleFrames = new WeakMap(); + +// Bun.udpSocket() takes the socket's Bun.ModuleGraph from the async context, so a socket that another graph binds opens in the context of bind(): it stays the binder's. +function openInFrameOfHandle(frame: unknown, udpOptions: any) { + const AsyncContextFrame = require("internal/async_context_frame"); + return AsyncContextFrame.graphOf(frame) === AsyncContextFrame.currentGraph() + ? AsyncContextFrame.run(frame, Bun.udpSocket, Bun, udpOptions) + : Bun.udpSocket(udpOptions); +} + function newHandle(type: string, lookup: LookupFunction | undefined) { if (lookup === undefined) { lookup = require("node:dns").lookup as LookupFunction; @@ -363,6 +379,9 @@ function newHandle(type: string, lookup: LookupFunction | undefined) { handle.onmessage = onMessage; + const frame = $getInternalField($asyncContext, 0); + if (frame !== undefined) WeakMapPrototypeSet.$call(handleFrames, handle, frame); + return handle; } @@ -453,7 +472,7 @@ function handleQueueSend( // same way. const length = data.byteLength; handle.sendQueue ??= []; - handle.sendQueue.push({ data, length, port, address, req }); + handle.sendQueue.push({ data, length, port, address, req, frame: $getInternalField($asyncContext, 0) }); handle.queueCount += 1; handle.queueSize += length; return 0; @@ -509,7 +528,14 @@ function completeQueuedSend(handle: UDPHandle, entry: QueuedSend, err: unknown) // callback cannot skip a sibling entry or run inside close()/drain. const req = entry.req; if (req && typeof req.oncomplete === "function") { - process.nextTick(runQueuedSendComplete, req, err, err === undefined ? entry.length : 0); + const sent = err === undefined ? entry.length : 0; + const frame = entry.frame; + // Queued inside the frame of the send(), so that what the callback throws is reported in it too. The frames are equal in every program without AsyncLocalStorage and Bun.ModuleGraph, which never loads the helper. + if (frame === $getInternalField($asyncContext, 0)) process.nextTick(runQueuedSendComplete, req, err, sent); + else { + const run = require("internal/async_context_frame").run; + run(frame, process.nextTick, process, runQueuedSendComplete, req, err, sent); + } } } @@ -911,8 +937,17 @@ function startBunSocket( }; // Private name: a cluster-shared descriptor is read one datagram at a time so workers share the load. if (sharedHandle) $putByIdDirectPrivate(udpOptions, "sharedFd", true); - (Bun.udpSocket(udpOptions) as Promise<$Node.UDPSocketHandle>).$then( + // Not for a shared bind: its handle is the one the cluster primary sent (node's replaceHandle(): https://github.com/nodejs/node/blob/v26.3.0/lib/dgram.js#L213). + const handle = state.handle!; + const frame = WeakMapPrototypeGet.$call(handleFrames, handle); + const opened = + sharedHandle !== undefined || frame === $getInternalField($asyncContext, 0) + ? Bun.udpSocket(udpOptions) + : openInFrameOfHandle(frame, udpOptions); + (opened as Promise<$Node.UDPSocketHandle>).$then( (socket: $Node.UDPSocketHandle) => { + // The native socket holds the frame now. Until a bind succeeds, the handle keeps it. + WeakMapPrototypeDelete.$call(handleFrames, handle); if (!state.handle) { // Closed while the bind was in flight. socket.close(); diff --git a/test/js/bun/module-graph/module-graph-gc.test.ts b/test/js/bun/module-graph/module-graph-gc.test.ts index 6ca81121299c..b8e856e7b751 100644 --- a/test/js/bun/module-graph/module-graph-gc.test.ts +++ b/test/js/bun/module-graph/module-graph-gc.test.ts @@ -39,8 +39,11 @@ const dir = String( // What the graph opens reports to, and is stopped through, `control` (a host object in // `globals`), so the host can stop it without holding anything of the graph. "io.mjs": ` + import dgram from "node:dgram"; import http from "node:http"; import https from "node:https"; + // A node:dgram socket that nothing has bound yet. + export function dgramSocket() { return dgram.createSocket("udp4"); } export function serve() { const server = Bun.serve({ port: 0, fetch: () => new Response("graph " + TAG) }); control.httpPort = server.port; @@ -398,6 +401,45 @@ describe("ModuleGraph GC: what the graph's context owns", () => { expect(await accepts(state.httpPort)).toBe(false); }); + // node:dgram keeps the async context of createSocket() for the bind() that opens the socket, and the frame of the + // graph is in it. So a socket that the host holds keeps the disposed graph that made it for as long as nothing has + // opened it. A socket the graph bound is the graph's: its handlers hold the graph until the host closes the socket. + test("node:dgram sockets the host holds: which keep the disposed graph that made them, and until when", async () => { + const lifetimes = new Lifetimes(); + const storage = new AsyncLocalStorage(); + const sockets: Record = {}; + const bind = (socket: import("node:dgram").Socket, port: number) => + new Promise(resolve => { + socket.once("error", (error: NodeJS.ErrnoException) => resolve(String(error.code))); + socket.bind(port, "127.0.0.1", () => resolve("listening")); + }); + // A bind to its port fails. + const holder = await Bun.udpSocket({ hostname: "127.0.0.1", port: 0 }); + try { + await (async () => { + for (const row of ["never bound", "a bind by the host failed", "bound by the host", "bound by the graph"]) { + const graph = lifetimes.track(row, new ModuleGraph({ globals: { TAG: row, control: control() } })); + const io = await graph.import(file("io.mjs")); + const socket = (sockets[row] = graph.run(() => storage.run("createSocket()", () => io.dgramSocket()))); + if (row === "a bind by the host failed") expect(await bind(socket, holder.port)).toBe("EADDRINUSE"); + if (row === "bound by the host") expect(await bind(socket, 0)).toBe("listening"); + if (row === "bound by the graph") expect(await graph.run(() => bind(socket, 0))).toBe("listening"); + graph.dispose(); + } + })(); + expect(await lifetimes.stillAlive("bound by the host")).toEqual([]); + const kept: Record = {}; + for (const row of ["never bound", "a bind by the host failed", "bound by the graph"]) { + kept[row] = await lifetimes.survives(row); + } + expect(kept).toEqual({ "never bound": true, "a bind by the host failed": true, "bound by the graph": true }); + } finally { + for (const socket of Object.values(sockets)) socket.close(); + holder.close(); + } + expect(await lifetimes.stillAlive(...Object.keys(sockets))).toEqual([]); + }); + test("a repeating timer keeps its graph alive; clearing it lets the graph go", async () => { const lifetimes = new Lifetimes(); const state = control(); diff --git a/test/js/bun/module-graph/module-graph-io.test.ts b/test/js/bun/module-graph/module-graph-io.test.ts index 0737c4e5e434..70d63da0b604 100644 --- a/test/js/bun/module-graph/module-graph-io.test.ts +++ b/test/js/bun/module-graph/module-graph-io.test.ts @@ -4,6 +4,7 @@ import { afterAll, describe, expect, test } from "bun:test"; import fs, { rmSync, writeFileSync } from "fs"; import { bunEnv, bunExe, tempDir, tls } from "harness"; import { AsyncLocalStorage } from "node:async_hooks"; +import dgram from "node:dgram"; import net from "node:net"; import nodeTls from "node:tls"; import { promisify } from "node:util"; @@ -635,6 +636,186 @@ describe.concurrent("ModuleGraph: what a graph opens is the graph's", () => { }); }); + // node:dgram opens a socket in bind(), inside the async context of createSocket(): 'message' then runs in the + // AsyncLocalStorage store node runs it in. The socket is still of the graph whose script binds it, because + // Bun.udpSocket() takes the owner from the async context. So when another graph made the socket, or the host, + // bind() opens it in its own context: those rows read "bind()", as they did before. + test("a node:dgram socket is of the graph that binds it, not of the one that made it", async () => { + const { kStateSymbol } = require("bun:internal-for-testing").exposedInternals["internal/dgram"]; + const storage = new AsyncLocalStorage(); + type Where = "A" | "B" | "the host"; + async function layout(madeIn: Where, boundIn: Where, { disposeMakerFirst = false } = {}) { + const graphs = { A: new Bun.ModuleGraph(), B: new Bun.ModuleGraph() }; + const enter = (where: Where, fn: () => T): T => (where === "the host" ? fn() : graphs[where].run(fn)); + const nameOf = (graph: unknown) => (graph === graphs.A ? "A" : graph === graphs.B ? "B" : "the host"); + const socket = enter(madeIn, () => storage.run("createSocket()", () => dgram.createSocket("udp4"))); + const sender = dgram.createSocket("udp4"); + try { + if (disposeMakerFirst) graphs.A.dispose(); + const message = Promise.withResolvers<{ store: string; graph: string }>(); + socket.on("message", () => + message.resolve({ store: String(storage.getStore()), graph: nameOf(Bun.ModuleGraph.current) }), + ); + const port = await enter(boundIn, () => + storage.run( + "bind()", + () => new Promise(resolve => socket.bind(0, "127.0.0.1", () => resolve(socket.address().port))), + ), + ); + // dispose() closes the sockets of its graph at once, and the native socket says so. + const native = socket[kStateSymbol].handle.socket; + sender.send("x", port, "127.0.0.1"); + const { store, graph } = await message.promise; + graphs.A.dispose(); + const closedByA: boolean = native.closed; + graphs.B.dispose(); + const closedBy = closedByA ? "A" : native.closed ? "B" : "nobody"; + return { "'message' reads": store, "'message' runs in": graph, "closed by the dispose() of": closedBy }; + } finally { + graphs.A.dispose(); + graphs.B.dispose(); + socket.close(); + sender.close(); + } + } + expect({ + "made and bound in one graph": await layout("A", "A"), + "made in one graph, bound in another": await layout("A", "B"), + "made in a graph, bound by the host": await layout("A", "the host"), + "made by the host, bound in a graph": await layout("the host", "A"), + }).toEqual({ + "made and bound in one graph": { + "'message' reads": "createSocket()", + "'message' runs in": "A", + "closed by the dispose() of": "A", + }, + "made in one graph, bound in another": { + "'message' reads": "bind()", + "'message' runs in": "B", + "closed by the dispose() of": "B", + }, + "made in a graph, bound by the host": { + "'message' reads": "bind()", + "'message' runs in": "the host", + "closed by the dispose() of": "nobody", + }, + "made by the host, bound in a graph": { + "'message' reads": "bind()", + "'message' runs in": "A", + "closed by the dispose() of": "A", + }, + }); + // What a disposed graph's code opens never settles: the bind of a socket that such a graph made is the host's. + expect(await layout("A", "the host", { disposeMakerFirst: true })).toEqual({ + "'message' reads": "bind()", + "'message' runs in": "the host", + "closed by the dispose() of": "nobody", + }); + }); + + // A send() that waited behind backpressure is completed by the socket's drain or by close(). Its callback still + // runs where that send() was made: in the caller's graph and store, as the callback of a ws send() does. With a + // queue, every send() waits behind it, which is what backpressure does. + test("a queued send() on a node:dgram socket completes in the graph and the store of its own call", async () => { + const { kStateSymbol } = require("bun:internal-for-testing").exposedInternals["internal/dgram"]; + const storage = new AsyncLocalStorage(); + type Who = "the graph" | "the host"; + async function completions(socketOf: Who, completedBy: "the drain" | "close()") { + const graph = new Bun.ModuleGraph(); + const enter = (who: Who, fn: () => T): T => (who === "the graph" ? graph.run(fn) : fn()); + const socket = enter(socketOf, () => dgram.createSocket("udp4")); + let closed = false; + try { + await enter(socketOf, () => new Promise(resolve => socket.bind(0, "127.0.0.1", resolve))); + const port = socket.address().port; + await enter(socketOf, () => new Promise(resolve => socket.connect(port, "127.0.0.1", resolve))); + const handle = socket[kStateSymbol].handle; + const realSocket = handle.socket; + handle.sendQueue = []; + const queued = (["the graph", "the host"] as const).map(who => { + const completed = Promise.withResolvers<[string, string]>(); + enter(who, () => + storage.run(`the send() of ${who}`, () => + socket.send("x", () => + completed.resolve([ + `the send() of ${who}`, + `runs as ${Bun.ModuleGraph.current === graph ? "the graph" : "the host"}, in the store of ${storage.getStore()}`, + ]), + ), + ), + ); + return completed.promise; + }); + // The socket's native handlers, and whoever closes it, run in the context of the socket's owner. + if (completedBy === "the drain") { + handle.socket = { send: () => true }; + enter(socketOf, () => storage.run("the drain", () => handle.drain())); + handle.socket = realSocket; + } else { + closed = true; + enter(socketOf, () => storage.run("close()", () => socket.close())); + } + return Object.fromEntries(await Promise.all(queued)); + } finally { + if (!closed) socket.close(); + graph.dispose(); + } + } + const own = { + "the send() of the graph": "runs as the graph, in the store of the send() of the graph", + "the send() of the host": "runs as the host, in the store of the send() of the host", + }; + expect({ + "the graph's socket drains": await completions("the graph", "the drain"), + "the graph's socket closes": await completions("the graph", "close()"), + "the host's socket drains": await completions("the host", "the drain"), + "the host's socket closes": await completions("the host", "close()"), + }).toEqual({ + "the graph's socket drains": own, + "the graph's socket closes": own, + "the host's socket drains": own, + "the host's socket closes": own, + }); + }); + + // As for anything a disposed graph left behind: the callback is still called, once, inside the disposed graph, + // and what it starts there does not start. + test("a queued send() of a graph that is disposed before a node:dgram socket of the host's drains", async () => { + const { kStateSymbol } = require("bun:internal-for-testing").exposedInternals["internal/dgram"]; + const storage = new AsyncLocalStorage(); + const socket = dgram.createSocket("udp4"); + await new Promise(resolve => socket.bind(0, "127.0.0.1", resolve)); + await new Promise(resolve => socket.connect(socket.address().port, "127.0.0.1", resolve)); + const handle = socket[kStateSymbol].handle; + const realSocket = handle.socket; + const graph = new Bun.ModuleGraph(); + try { + handle.sendQueue = []; + const seen = { calls: 0, context: "", timerFired: false }; + const called = Promise.withResolvers(); + graph.run(() => + storage.run("the graph's send()", () => + socket.send("x", () => { + seen.calls++; + seen.context = `${Bun.ModuleGraph.current === graph ? "the graph" : "the host"}, in the store of ${storage.getStore()}`; + setTimeout(() => (seen.timerFired = true), 1); + called.resolve(); + }), + ), + ); + graph.dispose(); + handle.socket = { send: () => true }; + handle.drain(); + await called.promise; + await hostTimerTurns(); + expect(seen).toEqual({ calls: 1, context: "the graph, in the store of the graph's send()", timerFired: false }); + } finally { + handle.socket = realSocket; + socket.close(); + graph.dispose(); + } + }); + test("code of the graph the host calls directly runs in the host's context; run() enters the graph's", async () => { const dir = fixture({ "fn.mjs": ` diff --git a/test/js/bun/udp/dgram-cluster-async-context-fixture.ts b/test/js/bun/udp/dgram-cluster-async-context-fixture.ts new file mode 100644 index 000000000000..0a79a1320cb5 --- /dev/null +++ b/test/js/bun/udp/dgram-cluster-async-context-fixture.ts @@ -0,0 +1,55 @@ +// The AsyncLocalStorage store that 'listening' and 'message' run in for the sockets of a cluster worker. +// A bind that goes through the primary gives the socket the handle the primary sent: node swaps it in +// (replaceHandle() in lib/dgram.js), so the store of createSocket() is not that handle's. An exclusive bind keeps the +// socket's own handle. +import { AsyncLocalStorage } from "node:async_hooks"; +import cluster from "node:cluster"; +import dgram from "node:dgram"; + +if (cluster.isPrimary) { + // A bound descriptor of the primary's for the worker's bind({ fd }). + const { _createSocketHandle } = require("bun:internal-for-testing").exposedInternals["internal/dgram"]; + const wrap = _createSocketHandle("127.0.0.1", 0, "udp4"); + const worker = cluster.fork(); + // Fail fast instead of hanging the harness. + const watchdog = setTimeout(() => { + worker.process.kill("SIGKILL"); + console.error("the worker did not report"); + process.exit(1); + }, 15_000); + watchdog.unref(); + worker.on("message", rows => console.log(JSON.stringify(rows))); + worker.send({ fd: wrap.fd }); + const code = await new Promise(resolve => worker.on("exit", resolve)); + wrap.close(); + if (code !== 0) throw new Error(`worker exited with ${code}`); +} else { + const storage = new AsyncLocalStorage(); + const store = () => String(storage.getStore()); + process.once("message", ({ fd }: { fd: number }) => { + const binds: Record = { + "shared": { port: 0, address: "127.0.0.1", exclusive: false }, + "the primary's descriptor": { fd }, + "exclusive": { port: 0, address: "127.0.0.1", exclusive: true }, + }; + const rows: Record> = {}; + let open = Object.keys(binds).length; + const sender = dgram.createSocket("udp4"); + for (const [name, options] of Object.entries(binds)) { + const row: Record = (rows[name] = {}); + const socket = storage.run("createSocket()", () => dgram.createSocket("udp4")); + socket.on("listening", () => { + row["'listening'"] = store(); + sender.send("x", socket.address().port, "127.0.0.1"); + }); + socket.on("message", () => { + row["'message'"] = store(); + socket.close(); + if (--open > 0) return; + sender.close(); + process.send!(rows, () => process.exit(0)); + }); + storage.run("bind()", () => socket.bind(options)); + } + }); +} diff --git a/test/js/bun/udp/dgram.test.ts b/test/js/bun/udp/dgram.test.ts index 26574b6eb8ab..e8bdc2c9cdc8 100644 --- a/test/js/bun/udp/dgram.test.ts +++ b/test/js/bun/udp/dgram.test.ts @@ -1,3 +1,4 @@ +import { AsyncLocalStorage } from "async_hooks"; import { describe, expect, jest, test } from "bun:test"; import { createSocket } from "dgram"; import { Worker } from "node:worker_threads"; @@ -240,6 +241,21 @@ describe("unref()", () => { // Cluster-shared dgram descriptors are POSIX-only (Windows reports ENOTSUP). describe.skipIf(isWindows)("cluster", () => { + // A bind that goes through the primary gives the socket the handle the primary sent, so the store of + // createSocket() is not that handle's. Node.js v26.3.0 reads these stores. + test("a shared socket's 'message' runs in no store, an exclusive socket's in the store of createSocket()", async () => { + const { stdout, stderr, exitCode } = await runClusterFixture("dgram-cluster-async-context-fixture.ts", 20_000); + expect({ stdout: stdout && JSON.parse(stdout), stderr, exitCode }).toEqual({ + stdout: { + "shared": { "'listening'": "undefined", "'message'": "undefined" }, + "the primary's descriptor": { "'listening'": "undefined", "'message'": "undefined" }, + "exclusive": { "'listening'": "bind()", "'message'": "createSocket()" }, + }, + stderr: "", + exitCode: 0, + }); + }, 40_000); + // The shared wrap's close(cb) must invoke cb (Node's HandleWrap contract) or // cluster's disconnect refcount never reaches zero and the worker hangs. test("worker.disconnect() with a shared socket lets the worker exit", async () => { @@ -842,6 +858,156 @@ test("handleDrain resumes after renewed backpressure and steps past a throwing e } }); +// bind({ fd }) opens the socket's own handle on the descriptor, so 'message' still runs in the store of +// createSocket(). Node.js v26.3.0 reads these stores. +test.skipIf(isWindows)("bind({ fd }): 'message' runs in the store of createSocket()", async () => { + const { _createSocketHandle } = require("bun:internal-for-testing").exposedInternals["internal/dgram"]; + const storage = new AsyncLocalStorage(); + const wrap = _createSocketHandle("127.0.0.1", 0, "udp4"); + const socket = storage.run("createSocket()", () => createSocket("udp4")); + const sender = createSocket("udp4"); + try { + const listening = Promise.withResolvers(); + const message = Promise.withResolvers(); + socket.on("listening", () => listening.resolve(String(storage.getStore()))); + socket.on("message", () => message.resolve(String(storage.getStore()))); + storage.run("bind()", () => socket.bind({ fd: wrap.fd })); + const seen = { "'listening'": await listening.promise }; + sender.send("x", socket.address().port, "127.0.0.1"); + expect({ ...seen, "'message'": await message.promise }).toEqual({ + "'listening'": "bind()", + "'message'": "createSocket()", + }); + } finally { + sender.close(); + socket.close(); + wrap.close(); + } +}); + +// Node.js makes the request of a send() inside that send(), and completes it in that async context. A request that +// waited behind backpressure is completed from the socket's drain, or from close(): neither is its context. The +// queued path is forced as in the two tests above. +test("a send() that waited behind backpressure runs its callback in the store of that send()", async () => { + const { kStateSymbol } = require("bun:internal-for-testing").exposedInternals["internal/dgram"]; + const storage = new AsyncLocalStorage(); + const inStore = (store: string | undefined, fn: () => T) => (store === undefined ? fn() : storage.run(store, fn)); + const socket = storage.run("createSocket()", () => createSocket("udp4")); + await new Promise(resolve => socket.bind(0, "127.0.0.1", resolve)); + await new Promise(resolve => socket.connect(socket.address().port, "127.0.0.1", resolve)); + + const handle = socket[kStateSymbol].handle; + const realSocket = handle.socket; + const seen: Record = {}; + // Queues a send() made in `store`, and resolves when its callback has run. + const send = (name: string, store: string | undefined) => { + const { promise, resolve } = Promise.withResolvers(); + inStore(store, () => + socket.send("x", err => { + seen[name] = String(storage.getStore()) + (err ? ` (${(err as NodeJS.ErrnoException).code})` : ""); + resolve(); + }), + ); + return promise; + }; + let closed = false; + try { + for (const drainIn of ["the drain", undefined]) { + handle.sendQueue = []; + const where = drainIn ? "a drain inside a store" : "a drain in no store"; + const completed = [ + send(`made in a store, completed by ${where}`, "send() 1"), + send(`made in another store, completed by ${where}`, "send() 2"), + send(`made in no store, completed by ${where}`, undefined), + ]; + handle.socket = { send: () => true }; + inStore(drainIn, () => handle.drain()); + await Promise.all(completed); + handle.socket = realSocket; + } + + handle.sendQueue = []; + const cancelled = [ + send("made in a store, cancelled by close() inside a store", "send() 3"), + send("made in no store, cancelled by close() inside a store", undefined), + ]; + closed = true; + storage.run("close()", () => socket.close()); + await Promise.all(cancelled); + + expect(seen).toEqual({ + "made in a store, completed by a drain inside a store": "send() 1", + "made in another store, completed by a drain inside a store": "send() 2", + "made in no store, completed by a drain inside a store": "undefined", + "made in a store, completed by a drain in no store": "send() 1", + "made in another store, completed by a drain in no store": "send() 2", + "made in no store, completed by a drain in no store": "undefined", + "made in a store, cancelled by close() inside a store": "send() 3 (ECANCELED)", + "made in no store, cancelled by close() inside a store": "undefined (ECANCELED)", + }); + } finally { + handle.socket = realSocket; + if (!closed) socket.close(); + } +}); + +// What such a callback throws is reported in that async context too, as Node.js reports it (v26.3.0, run with +// --test-udp-no-try-send): the tick that calls it is queued inside the context of the send(). An 'uncaughtException' +// listener needs a process of its own, because bun:test takes an uncaught throw for itself. +test("a send() that waited behind backpressure reports what its callback throws in the store of the callback", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + const { AsyncLocalStorage } = require("node:async_hooks"); + const { kStateSymbol } = require("bun:internal-for-testing").exposedInternals["internal/dgram"]; + const storage = new AsyncLocalStorage(); + const socket = require("node:dgram").createSocket("udp4"); + const ways = ["throws", "enterWith() then throws"]; + const seen = {}; + let row; + function next() { + const way = ways.shift(); + if (way === undefined) return socket.close(() => console.log(JSON.stringify(seen))); + row = seen[way] = {}; + const handle = socket[kStateSymbol].handle; + const realSocket = handle.socket; + // With a queue, every send() waits behind it: what backpressure does. + handle.sendQueue = []; + storage.run("send()", () => + socket.send("x", () => { + row["the callback"] = String(storage.getStore()); + if (way !== "throws") storage.enterWith("entered"); + throw new Error("boom"); + }), + ); + handle.socket = { send: () => true }; + storage.run("the drain", () => handle.drain()); + handle.socket = realSocket; + } + process.on("uncaughtException", () => { + row["'uncaughtException'"] = String(storage.getStore()); + setImmediate(next); + }); + socket.bind(0, "127.0.0.1", () => socket.connect(socket.address().port, "127.0.0.1", next)); + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout: stdout && JSON.parse(stdout), stderr, exitCode }).toEqual({ + stdout: { + "throws": { "the callback": "send()", "'uncaughtException'": "send()" }, + "enterWith() then throws": { "the callback": "send()", "'uncaughtException'": "entered" }, + }, + stderr: "", + exitCode: 0, + }); +}); + // Reserves an ephemeral UDP port and frees it, so datagrams sent there get // ICMP port-unreachable replies on loopback. async function getDeadPort() { @@ -915,6 +1081,53 @@ for (const [kind, bind] of Object.entries(icmpBindModes)) { }); } +// Node's handle delivers the receive error too, so its 'error' listener runs in the store of createSocket(), +// whichever call binds the socket. Node.js v26.3.0 reads these stores. +for (const [kind, bind] of Object.entries(icmpBindModes)) { + test.skipIf(isWindows)(`${kind} socket emits the ICMP error in the store of createSocket()`, async () => { + const storage = new AsyncLocalStorage(); + const inStore = (store: string | undefined, fn: () => T) => + store === undefined ? fn() : storage.run(store, fn); + const storeOfError = async (createIn: string | undefined, bindIn: string | undefined) => { + const deadPort = await getDeadPort(); + const socket = inStore(createIn, () => createSocket("udp4")); + try { + const { promise: errored, resolve: onError } = Promise.withResolvers(); + socket.on("error", error => onError(`${storage.getStore()} (${error.message})`)); + await inStore(bindIn, () => bind(socket)); + await new Promise(resolve => socket.connect(deadPort, "127.0.0.1", () => resolve())); + // Keep sending until the queued ICMP error surfaces. + let stop = false; + const pump = (async () => { + while (!stop) { + try { + socket.send("x"); + } catch {} + await Bun.sleep(10); + } + })(); + try { + return await errored; + } finally { + stop = true; + await pump; + } + } finally { + socket.close(); + } + }; + expect({ + "createSocket() and bind() each inside a store of its own": await storeOfError("createSocket()", "bind()"), + "createSocket() in no store, bind() inside a store": await storeOfError(undefined, "bind()"), + "createSocket() inside a store, bind() in none": await storeOfError("createSocket()", undefined), + }).toEqual({ + "createSocket() and bind() each inside a store of its own": "createSocket() (recvmsg ECONNREFUSED)", + "createSocket() in no store, bind() inside a store": "undefined (recvmsg ECONNREFUSED)", + "createSocket() inside a store, bind() in none": "createSocket() (recvmsg ECONNREFUSED)", + }); + }); +} + // A worker that calls process.exit() from the FIRST 'message' of a batch // leaves a TerminationException pending for the rest of that poll dispatch: // the remaining on_data iterations, the drain, and any recv error must all diff --git a/test/js/node/async_hooks/async-context/async-context-dgram-createSocket.js b/test/js/node/async_hooks/async-context/async-context-dgram-createSocket.js new file mode 100644 index 000000000000..4a9088f469b9 --- /dev/null +++ b/test/js/node/async_hooks/async-context/async-context-dgram-createSocket.js @@ -0,0 +1,154 @@ +process.exitCode = 1; +const { AsyncLocalStorage } = require("async_hooks"); +const dgram = require("dgram"); + +// node makes the UDP handle of a socket in createSocket(). What that handle delivers ('message') runs in the store of +// createSocket(), whichever call binds the socket later. Every other event runs in the store of the call that caused it. +const asyncLocalStorage = new AsyncLocalStorage(); +const store = () => String(asyncLocalStorage.getStore()); +const create = () => dgram.createSocket("udp4"); + +// Each layout makes a socket and sends a datagram to `port`, which answers it. It notes the store of each callback. +const layouts = { + "bind() inside a store, createSocket() in none": { + start(port, seen) { + const socket = create(); + asyncLocalStorage.run("bind()", () => + socket.bind(0, "127.0.0.1", () => { + seen["bind() callback"] = store(); + socket.send("x", port, "127.0.0.1", () => (seen["send() callback"] = store())); + }), + ); + return socket; + }, + expected: { + "bind() callback": "bind()", + "'listening'": "bind()", + "send() callback": "bind()", + "'message'": "undefined", + "'close'": "close()", + }, + }, + "send() binds inside a store, createSocket() in none": { + start(port, seen) { + const socket = create(); + asyncLocalStorage.run("send()", () => + socket.send("x", port, "127.0.0.1", () => (seen["send() callback"] = store())), + ); + return socket; + }, + expected: { + "'listening'": "send()", + "send() callback": "send()", + "'message'": "undefined", + "'close'": "close()", + }, + }, + "connect() binds inside a store, createSocket() in none": { + start(port, seen) { + const socket = create(); + asyncLocalStorage.run("connect()", () => + socket.connect(port, "127.0.0.1", () => { + seen["connect() callback"] = store(); + socket.send("x", () => (seen["send() callback"] = store())); + }), + ); + return socket; + }, + expected: { + "'listening'": "connect()", + "'connect'": "connect()", + "connect() callback": "connect()", + "send() callback": "connect()", + "'message'": "undefined", + "'close'": "close()", + }, + }, + "createSocket() inside a store, bind() in none": { + start(port, seen) { + const socket = asyncLocalStorage.run("createSocket()", create); + socket.bind(0, "127.0.0.1", () => { + seen["bind() callback"] = store(); + socket.send("x", port, "127.0.0.1", () => (seen["send() callback"] = store())); + }); + return socket; + }, + expected: { + "bind() callback": "undefined", + "'listening'": "undefined", + "send() callback": "undefined", + "'message'": "createSocket()", + "'close'": "close()", + }, + }, + "createSocket() and send() each inside a store of its own": { + start(port, seen) { + const socket = asyncLocalStorage.run("createSocket()", create); + asyncLocalStorage.run("send()", () => + socket.send("x", port, "127.0.0.1", () => (seen["send() callback"] = store())), + ); + return socket; + }, + expected: { + "'listening'": "send()", + "send() callback": "send()", + "'message'": "createSocket()", + "'close'": "close()", + }, + }, + // A bind() that fails leaves the socket its handle. The bind() that opens the socket later is still not where + // 'message' runs. + "createSocket() inside a store, a bind() that fails, then a bind() inside a store of its own": { + start(port, seen) { + const socket = asyncLocalStorage.run("createSocket()", create); + socket.once("error", error => { + seen["'error'"] = `${store()} ${error.code}`; + asyncLocalStorage.run("second bind()", () => + socket.bind(0, "127.0.0.1", () => { + seen["bind() callback"] = store(); + socket.send("x", port, "127.0.0.1", () => (seen["send() callback"] = store())); + }), + ); + }); + // The socket that answers holds `port`. + asyncLocalStorage.run("first bind()", () => socket.bind(port, "127.0.0.1")); + return socket; + }, + expected: { + "'error'": "first bind() EADDRINUSE", + "bind() callback": "second bind()", + "'listening'": "second bind()", + "send() callback": "second bind()", + "'message'": "createSocket()", + "'close'": "close()", + }, + }, +}; + +let open = Object.keys(layouts).length; +let failed = false; +const answers = create(); +answers.on("message", (message, from) => answers.send(message, from.port, from.address)); +answers.bind(0, "127.0.0.1", () => { + for (const [name, { start, expected }] of Object.entries(layouts)) { + const seen = {}; + const socket = start(answers.address().port, seen); + socket.on("listening", () => (seen["'listening'"] = store())); + socket.on("connect", () => (seen["'connect'"] = store())); + socket.on("message", () => { + seen["'message'"] = store(); + asyncLocalStorage.run("close()", () => socket.close()); + }); + socket.on("close", () => { + seen["'close'"] = store(); + for (const key of new Set([...Object.keys(expected), ...Object.keys(seen)])) { + if (seen[key] === expected[key]) continue; + failed = true; + console.error(`FAIL: ${name}: ${key} ran in the store ${seen[key]}, not ${expected[key]}`); + } + if (--open > 0) return; + answers.close(); + process.exit(failed ? 1 : 0); + }); + } +}); From 7807ccbddf9469aea695981fdf3a0e8762ab81f3 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 1 Nov 2026 23:29:34 +0000 Subject: [PATCH 02/11] node:dgram tests: wire failures, and drop the rows that pin an unbound socket's graph The GC test no longer asserts that an unbound socket keeps the graph that made it. It has a row for a socket of the host's that is bound in a listener that a graph's script calls: main keeps the graph, this branch does not. The tests that wait for a bind or a connect reject when the socket emits 'error'. The new ICMP store test stops when the native socket is closed without an error, as its sibling does. --- .../bun/module-graph/module-graph-gc.test.ts | 60 ++++++++++++------- .../bun/module-graph/module-graph-io.test.ts | 26 ++++---- test/js/bun/udp/dgram.test.ts | 14 ++++- 3 files changed, 63 insertions(+), 37 deletions(-) diff --git a/test/js/bun/module-graph/module-graph-gc.test.ts b/test/js/bun/module-graph/module-graph-gc.test.ts index b8e856e7b751..94d416af0a62 100644 --- a/test/js/bun/module-graph/module-graph-gc.test.ts +++ b/test/js/bun/module-graph/module-graph-gc.test.ts @@ -5,6 +5,7 @@ import { afterAll, describe, expect, jest, test } from "bun:test"; import { rmSync, writeFileSync } from "fs"; import { bunEnv, bunExe, tempDir, tls } from "harness"; import { AsyncLocalStorage } from "node:async_hooks"; +import dgram from "node:dgram"; import { join } from "path"; import { collect, heapFromTimer, Lifetimes } from "./module-graph-lifetimes"; @@ -44,6 +45,8 @@ const dir = String( import https from "node:https"; // A node:dgram socket that nothing has bound yet. export function dgramSocket() { return dgram.createSocket("udp4"); } + // Calls the listeners of the host's target while the graph's script is on the stack. + export function dispatch(target) { target.dispatchEvent(new Event("call")); } export function serve() { const server = Bun.serve({ port: 0, fetch: () => new Response("graph " + TAG) }); control.httpPort = server.port; @@ -401,43 +404,56 @@ describe("ModuleGraph GC: what the graph's context owns", () => { expect(await accepts(state.httpPort)).toBe(false); }); - // node:dgram keeps the async context of createSocket() for the bind() that opens the socket, and the frame of the - // graph is in it. So a socket that the host holds keeps the disposed graph that made it for as long as nothing has - // opened it. A socket the graph bound is the graph's: its handlers hold the graph until the host closes the socket. - test("node:dgram sockets the host holds: which keep the disposed graph that made them, and until when", async () => { + // node:dgram opens the socket in bind(), inside the async context of createSocket(). A socket that the host bound + // does not keep the graph that made it, and not a graph whose script was on the stack when the host bound it. A + // socket the graph bound is the graph's: its handlers hold the graph until the host closes the socket. + test("of the node:dgram sockets the host holds, one that the graph bound keeps the graph and one that the host bound does not", async () => { const lifetimes = new Lifetimes(); const storage = new AsyncLocalStorage(); - const sockets: Record = {}; - const bind = (socket: import("node:dgram").Socket, port: number) => + const sockets: Record = {}; + const bind = (socket: dgram.Socket) => new Promise(resolve => { socket.once("error", (error: NodeJS.ErrnoException) => resolve(String(error.code))); - socket.bind(port, "127.0.0.1", () => resolve("listening")); + socket.bind(0, "127.0.0.1", () => resolve("listening")); }); - // A bind to its port fails. - const holder = await Bun.udpSocket({ hostname: "127.0.0.1", port: 0 }); + const rows = [ + "made in the graph, bound by the host", + "made in the graph, bound by the graph", + "made by the host before, bound by the host in a listener the graph's script calls", + ]; try { await (async () => { - for (const row of ["never bound", "a bind by the host failed", "bound by the host", "bound by the graph"]) { + for (const row of rows) { const graph = lifetimes.track(row, new ModuleGraph({ globals: { TAG: row, control: control() } })); const io = await graph.import(file("io.mjs")); - const socket = (sockets[row] = graph.run(() => storage.run("createSocket()", () => io.dgramSocket()))); - if (row === "a bind by the host failed") expect(await bind(socket, holder.port)).toBe("EADDRINUSE"); - if (row === "bound by the host") expect(await bind(socket, 0)).toBe("listening"); - if (row === "bound by the graph") expect(await graph.run(() => bind(socket, 0))).toBe("listening"); + if (row.startsWith("made in the graph")) { + const socket = (sockets[row] = graph.run(() => storage.run("createSocket()", () => io.dgramSocket()))); + const bound = row.endsWith("bound by the host") ? bind(socket) : graph.run(() => bind(socket)); + expect(await bound).toBe("listening"); + } else { + // A listener of the host's that the graph's script calls runs as the host, in an async context that holds the graph. + const socket = (sockets[row] = dgram.createSocket("udp4")); + const target = new EventTarget(); + let ranAs: string | undefined; + let bound: Promise | undefined; + target.addEventListener("call", () => { + ranAs = ModuleGraph.current === undefined ? "the host" : "a graph"; + bound = storage.run("bind()", () => bind(socket)); + }); + graph.run(() => io.dispatch(target)); + expect(ranAs).toBe("the host"); + expect(await bound).toBe("listening"); + } graph.dispose(); } })(); - expect(await lifetimes.stillAlive("bound by the host")).toEqual([]); - const kept: Record = {}; - for (const row of ["never bound", "a bind by the host failed", "bound by the graph"]) { - kept[row] = await lifetimes.survives(row); - } - expect(kept).toEqual({ "never bound": true, "a bind by the host failed": true, "bound by the graph": true }); + const boundByTheGraph = "made in the graph, bound by the graph"; + expect(await lifetimes.stillAlive(...rows.filter(row => row !== boundByTheGraph))).toEqual([]); + expect(await lifetimes.survives(boundByTheGraph)).toBe(true); } finally { for (const socket of Object.values(sockets)) socket.close(); - holder.close(); } - expect(await lifetimes.stillAlive(...Object.keys(sockets))).toEqual([]); + expect(await lifetimes.stillAlive(...rows)).toEqual([]); }); test("a repeating timer keeps its graph alive; clearing it lets the graph go", async () => { diff --git a/test/js/bun/module-graph/module-graph-io.test.ts b/test/js/bun/module-graph/module-graph-io.test.ts index 70d63da0b604..534ce7cf19ab 100644 --- a/test/js/bun/module-graph/module-graph-io.test.ts +++ b/test/js/bun/module-graph/module-graph-io.test.ts @@ -5,6 +5,7 @@ import fs, { rmSync, writeFileSync } from "fs"; import { bunEnv, bunExe, tempDir, tls } from "harness"; import { AsyncLocalStorage } from "node:async_hooks"; import dgram from "node:dgram"; +import { once } from "node:events"; import net from "node:net"; import nodeTls from "node:tls"; import { promisify } from "node:util"; @@ -652,19 +653,17 @@ describe.concurrent("ModuleGraph: what a graph opens is the graph's", () => { const sender = dgram.createSocket("udp4"); try { if (disposeMakerFirst) graphs.A.dispose(); + enter(boundIn, () => storage.run("bind()", () => socket.bind(0, "127.0.0.1"))); + await once(socket, "listening"); const message = Promise.withResolvers<{ store: string; graph: string }>(); + socket.on("error", message.reject); + sender.on("error", message.reject); socket.on("message", () => message.resolve({ store: String(storage.getStore()), graph: nameOf(Bun.ModuleGraph.current) }), ); - const port = await enter(boundIn, () => - storage.run( - "bind()", - () => new Promise(resolve => socket.bind(0, "127.0.0.1", () => resolve(socket.address().port))), - ), - ); // dispose() closes the sockets of its graph at once, and the native socket says so. const native = socket[kStateSymbol].handle.socket; - sender.send("x", port, "127.0.0.1"); + sender.send("x", socket.address().port, "127.0.0.1"); const { store, graph } = await message.promise; graphs.A.dispose(); const closedByA: boolean = native.closed; @@ -726,9 +725,10 @@ describe.concurrent("ModuleGraph: what a graph opens is the graph's", () => { const socket = enter(socketOf, () => dgram.createSocket("udp4")); let closed = false; try { - await enter(socketOf, () => new Promise(resolve => socket.bind(0, "127.0.0.1", resolve))); - const port = socket.address().port; - await enter(socketOf, () => new Promise(resolve => socket.connect(port, "127.0.0.1", resolve))); + enter(socketOf, () => socket.bind(0, "127.0.0.1")); + await once(socket, "listening"); + enter(socketOf, () => socket.connect(socket.address().port, "127.0.0.1")); + await once(socket, "connect"); const handle = socket[kStateSymbol].handle; const realSocket = handle.socket; handle.sendQueue = []; @@ -784,8 +784,10 @@ describe.concurrent("ModuleGraph: what a graph opens is the graph's", () => { const { kStateSymbol } = require("bun:internal-for-testing").exposedInternals["internal/dgram"]; const storage = new AsyncLocalStorage(); const socket = dgram.createSocket("udp4"); - await new Promise(resolve => socket.bind(0, "127.0.0.1", resolve)); - await new Promise(resolve => socket.connect(socket.address().port, "127.0.0.1", resolve)); + socket.bind(0, "127.0.0.1"); + await once(socket, "listening"); + socket.connect(socket.address().port, "127.0.0.1"); + await once(socket, "connect"); const handle = socket[kStateSymbol].handle; const realSocket = handle.socket; const graph = new Bun.ModuleGraph(); diff --git a/test/js/bun/udp/dgram.test.ts b/test/js/bun/udp/dgram.test.ts index e8bdc2c9cdc8..eea81fcca7d5 100644 --- a/test/js/bun/udp/dgram.test.ts +++ b/test/js/bun/udp/dgram.test.ts @@ -869,6 +869,8 @@ test.skipIf(isWindows)("bind({ fd }): 'message' runs in the store of createSocke try { const listening = Promise.withResolvers(); const message = Promise.withResolvers(); + socket.on("error", listening.reject); + sender.on("error", message.reject); socket.on("listening", () => listening.resolve(String(storage.getStore()))); socket.on("message", () => message.resolve(String(storage.getStore()))); storage.run("bind()", () => socket.bind({ fd: wrap.fd })); @@ -893,8 +895,10 @@ test("a send() that waited behind backpressure runs its callback in the store of const storage = new AsyncLocalStorage(); const inStore = (store: string | undefined, fn: () => T) => (store === undefined ? fn() : storage.run(store, fn)); const socket = storage.run("createSocket()", () => createSocket("udp4")); - await new Promise(resolve => socket.bind(0, "127.0.0.1", resolve)); - await new Promise(resolve => socket.connect(socket.address().port, "127.0.0.1", resolve)); + await new Promise((resolve, reject) => socket.once("error", reject).bind(0, "127.0.0.1", resolve)); + await new Promise((resolve, reject) => + socket.once("error", reject).connect(socket.address().port, "127.0.0.1", () => resolve()), + ); const handle = socket[kStateSymbol].handle; const realSocket = handle.socket; @@ -1085,6 +1089,7 @@ for (const [kind, bind] of Object.entries(icmpBindModes)) { // whichever call binds the socket. Node.js v26.3.0 reads these stores. for (const [kind, bind] of Object.entries(icmpBindModes)) { test.skipIf(isWindows)(`${kind} socket emits the ICMP error in the store of createSocket()`, async () => { + const { kStateSymbol } = require("bun:internal-for-testing").exposedInternals["internal/dgram"]; const storage = new AsyncLocalStorage(); const inStore = (store: string | undefined, fn: () => T) => store === undefined ? fn() : storage.run(store, fn); @@ -1096,10 +1101,13 @@ for (const [kind, bind] of Object.entries(icmpBindModes)) { socket.on("error", error => onError(`${storage.getStore()} (${error.message})`)); await inStore(bindIn, () => bind(socket)); await new Promise(resolve => socket.connect(deadPort, "127.0.0.1", () => resolve())); - // Keep sending until the queued ICMP error surfaces. + // Keep sending until the queued ICMP error surfaces. If the native socket is silently closed instead, say + // that rather than time out. + const native = socket[kStateSymbol].handle.socket; let stop = false; const pump = (async () => { while (!stop) { + if (native.closed) return onError("the native socket was silently closed instead of emitting 'error'"); try { socket.send("x"); } catch {} From 3e4777ea66a6f1487dc57469c6f364f132ef9bcf Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 1 Nov 2026 23:41:58 +0000 Subject: [PATCH 03/11] node:dgram tests: reject when connect() fails connect() with a callback hands a failure to the callback and emits no 'error', so the tests that wait for it now read the callback's argument. --- test/js/bun/udp/dgram.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/test/js/bun/udp/dgram.test.ts b/test/js/bun/udp/dgram.test.ts index eea81fcca7d5..021bdcf978f3 100644 --- a/test/js/bun/udp/dgram.test.ts +++ b/test/js/bun/udp/dgram.test.ts @@ -896,8 +896,9 @@ test("a send() that waited behind backpressure runs its callback in the store of const inStore = (store: string | undefined, fn: () => T) => (store === undefined ? fn() : storage.run(store, fn)); const socket = storage.run("createSocket()", () => createSocket("udp4")); await new Promise((resolve, reject) => socket.once("error", reject).bind(0, "127.0.0.1", resolve)); + // connect() hands a failure to its callback. await new Promise((resolve, reject) => - socket.once("error", reject).connect(socket.address().port, "127.0.0.1", () => resolve()), + socket.connect(socket.address().port, "127.0.0.1", (error?: Error) => (error ? reject(error) : resolve())), ); const handle = socket[kStateSymbol].handle; @@ -1100,7 +1101,9 @@ for (const [kind, bind] of Object.entries(icmpBindModes)) { const { promise: errored, resolve: onError } = Promise.withResolvers(); socket.on("error", error => onError(`${storage.getStore()} (${error.message})`)); await inStore(bindIn, () => bind(socket)); - await new Promise(resolve => socket.connect(deadPort, "127.0.0.1", () => resolve())); + await new Promise((resolve, reject) => + socket.connect(deadPort, "127.0.0.1", (error?: Error) => (error ? reject(error) : resolve())), + ); // Keep sending until the queued ICMP error surfaces. If the native socket is silently closed instead, say // that rather than time out. const native = socket[kStateSymbol].handle.socket; From db1346b4a735a5fdeb9850e6e764e52c8b001c73 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 1 Nov 2026 23:46:29 +0000 Subject: [PATCH 04/11] node:dgram tests: a socket error after 'listening' rejects the wait for 'message' --- test/js/bun/udp/dgram.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/test/js/bun/udp/dgram.test.ts b/test/js/bun/udp/dgram.test.ts index 021bdcf978f3..e03203481f48 100644 --- a/test/js/bun/udp/dgram.test.ts +++ b/test/js/bun/udp/dgram.test.ts @@ -875,6 +875,7 @@ test.skipIf(isWindows)("bind({ fd }): 'message' runs in the store of createSocke socket.on("message", () => message.resolve(String(storage.getStore()))); storage.run("bind()", () => socket.bind({ fd: wrap.fd })); const seen = { "'listening'": await listening.promise }; + socket.on("error", message.reject); sender.send("x", socket.address().port, "127.0.0.1"); expect({ ...seen, "'message'": await message.promise }).toEqual({ "'listening'": "bind()", From b8905b128a0737831ca1c78eda4620eb3ec536f2 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 2 Nov 2026 00:35:32 +0000 Subject: [PATCH 05/11] node:dgram: a queued send() with no callback keeps no async context The entry of a send() that waits behind backpressure kept the async context of its call. Only the callback runs in it. A send() with no callback, made by the script of a Bun.ModuleGraph on a socket of the host's, kept that graph until the queue drained or the socket closed. --- src/js/node/dgram.ts | 14 ++++++-- .../bun/module-graph/module-graph-gc.test.ts | 33 +++++++++++++++++++ 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/src/js/node/dgram.ts b/src/js/node/dgram.ts index 7ac295f8a75d..5d81f4a2b980 100644 --- a/src/js/node/dgram.ts +++ b/src/js/node/dgram.ts @@ -312,7 +312,7 @@ interface QueuedSend { port: number | undefined; address: string | null | undefined; req: SendRequest; - // The async context of the send(), where node makes the request that it completes: https://github.com/nodejs/node/blob/v26.3.0/lib/dgram.js#L738 + // The async context of a send() that has a callback, where node makes the request that it completes: https://github.com/nodejs/node/blob/v26.3.0/lib/dgram.js#L738 frame: unknown; } @@ -472,7 +472,14 @@ function handleQueueSend( // same way. const length = data.byteLength; handle.sendQueue ??= []; - handle.sendQueue.push({ data, length, port, address, req, frame: $getInternalField($asyncContext, 0) }); + handle.sendQueue.push({ + data, + length, + port, + address, + req, + frame: req.callback ? $getInternalField($asyncContext, 0) : undefined, + }); handle.queueCount += 1; handle.queueSize += length; return 0; @@ -531,7 +538,8 @@ function completeQueuedSend(handle: UDPHandle, entry: QueuedSend, err: unknown) const sent = err === undefined ? entry.length : 0; const frame = entry.frame; // Queued inside the frame of the send(), so that what the callback throws is reported in it too. The frames are equal in every program without AsyncLocalStorage and Bun.ModuleGraph, which never loads the helper. - if (frame === $getInternalField($asyncContext, 0)) process.nextTick(runQueuedSendComplete, req, err, sent); + if (!req.callback || frame === $getInternalField($asyncContext, 0)) + process.nextTick(runQueuedSendComplete, req, err, sent); else { const run = require("internal/async_context_frame").run; run(frame, process.nextTick, process, runQueuedSendComplete, req, err, sent); diff --git a/test/js/bun/module-graph/module-graph-gc.test.ts b/test/js/bun/module-graph/module-graph-gc.test.ts index 94d416af0a62..4d8fab0a797f 100644 --- a/test/js/bun/module-graph/module-graph-gc.test.ts +++ b/test/js/bun/module-graph/module-graph-gc.test.ts @@ -6,6 +6,7 @@ import { rmSync, writeFileSync } from "fs"; import { bunEnv, bunExe, tempDir, tls } from "harness"; import { AsyncLocalStorage } from "node:async_hooks"; import dgram from "node:dgram"; +import { once } from "node:events"; import { join } from "path"; import { collect, heapFromTimer, Lifetimes } from "./module-graph-lifetimes"; @@ -47,6 +48,7 @@ const dir = String( export function dgramSocket() { return dgram.createSocket("udp4"); } // Calls the listeners of the host's target while the graph's script is on the stack. export function dispatch(target) { target.dispatchEvent(new Event("call")); } + export function dgramSend(socket) { socket.send("from the graph"); } export function serve() { const server = Bun.serve({ port: 0, fetch: () => new Response("graph " + TAG) }); control.httpPort = server.port; @@ -456,6 +458,37 @@ describe("ModuleGraph GC: what the graph's context owns", () => { expect(await lifetimes.stillAlive(...rows)).toEqual([]); }); + // A send() that waits behind backpressure keeps the async context of its call for its callback. With no callback + // it keeps nothing: the queue of a socket of the host's does not keep the graph whose script sent. + test("a queued node:dgram send() with no callback does not keep the graph that sent it", async () => { + const { kStateSymbol } = require("bun:internal-for-testing").exposedInternals["internal/dgram"]; + const lifetimes = new Lifetimes(); + const socket = dgram.createSocket("udp4"); + socket.bind(0, "127.0.0.1"); + await once(socket, "listening"); + socket.connect(socket.address().port, "127.0.0.1"); + await once(socket, "connect"); + const handle = socket[kStateSymbol].handle; + const realSocket = handle.socket; + const refusesEverySend = { send: () => false }; + try { + await (async () => { + const graph = lifetimes.track("graph", new ModuleGraph({ globals: { TAG: "sends", control: control() } })); + const io = await graph.import(file("io.mjs")); + // With a queue, every send() waits behind it, and a drain that is refused leaves it there: backpressure. + handle.socket = refusesEverySend; + handle.sendQueue = []; + graph.run(() => io.dgramSend(socket)); + graph.dispose(); + })(); + expect(await lifetimes.stillAlive("graph")).toEqual([]); + expect(handle.sendQueue).toHaveLength(1); + } finally { + handle.socket = realSocket; + socket.close(); + } + }); + test("a repeating timer keeps its graph alive; clearing it lets the graph go", async () => { const lifetimes = new Lifetimes(); const state = control(); From b435377e9b809e9eefe6ef1fcf42f67cfdbbd714 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 2 Nov 2026 01:25:04 +0000 Subject: [PATCH 06/11] node:dgram: swap the async context in place, not through AsyncContextFrame.run() run() calls its callee through apply() with a `this` value. An ahead-of-time build then takes what Bun.udpSocket() returns for any value, and `process` handed over as a value keeps every lazy member of `process` in the executable. A fastify executable and its test run compiled 56% of their functions alike, where main has 64% (test/aot/servers/a-test-run-compiles-what-an-executable-compiles.test.ts). dgram.ts now exchanges the frame itself around the two calls. --- src/js/node/dgram.ts | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/src/js/node/dgram.ts b/src/js/node/dgram.ts index 5d81f4a2b980..5044035004ed 100644 --- a/src/js/node/dgram.ts +++ b/src/js/node/dgram.ts @@ -338,9 +338,14 @@ const handleFrames = new WeakMap(); // Bun.udpSocket() takes the socket's Bun.ModuleGraph from the async context, so a socket that another graph binds opens in the context of bind(): it stays the binder's. function openInFrameOfHandle(frame: unknown, udpOptions: any) { const AsyncContextFrame = require("internal/async_context_frame"); - return AsyncContextFrame.graphOf(frame) === AsyncContextFrame.currentGraph() - ? AsyncContextFrame.run(frame, Bun.udpSocket, Bun, udpOptions) - : Bun.udpSocket(udpOptions); + if (AsyncContextFrame.graphOf(frame) !== AsyncContextFrame.currentGraph()) return Bun.udpSocket(udpOptions); + // Not AsyncContextFrame.run(): handed `Bun` or `process` as a value, an ahead-of-time build keeps every member of it, and the apply() in run() hides the type of what the call returns. + const previous = AsyncContextFrame.exchange(frame); + try { + return Bun.udpSocket(udpOptions); + } finally { + AsyncContextFrame.exchange(previous); + } } function newHandle(type: string, lookup: LookupFunction | undefined) { @@ -541,8 +546,13 @@ function completeQueuedSend(handle: UDPHandle, entry: QueuedSend, err: unknown) if (!req.callback || frame === $getInternalField($asyncContext, 0)) process.nextTick(runQueuedSendComplete, req, err, sent); else { - const run = require("internal/async_context_frame").run; - run(frame, process.nextTick, process, runQueuedSendComplete, req, err, sent); + const AsyncContextFrame = require("internal/async_context_frame"); + const previous = AsyncContextFrame.exchange(frame); + try { + process.nextTick(runQueuedSendComplete, req, err, sent); + } finally { + AsyncContextFrame.exchange(previous); + } } } } From 2ce33fe745607d464050a0fa2aff899ed2d51c3d Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 2 Nov 2026 02:29:38 +0000 Subject: [PATCH 07/11] node:dgram tests: import the internals at module scope, and close the socket when the setup of the queued send test fails The comment on the frame swap in dgram.ts now states what was measured. --- src/js/node/dgram.ts | 2 +- .../bun/module-graph/module-graph-gc.test.ts | 42 ++++++++++--------- .../bun/module-graph/module-graph-io.test.ts | 5 +-- .../dgram-cluster-async-context-fixture.ts | 4 +- 4 files changed, 28 insertions(+), 25 deletions(-) diff --git a/src/js/node/dgram.ts b/src/js/node/dgram.ts index 5044035004ed..044c86daaf34 100644 --- a/src/js/node/dgram.ts +++ b/src/js/node/dgram.ts @@ -339,7 +339,7 @@ const handleFrames = new WeakMap(); function openInFrameOfHandle(frame: unknown, udpOptions: any) { const AsyncContextFrame = require("internal/async_context_frame"); if (AsyncContextFrame.graphOf(frame) !== AsyncContextFrame.currentGraph()) return Bun.udpSocket(udpOptions); - // Not AsyncContextFrame.run(): handed `Bun` or `process` as a value, an ahead-of-time build keeps every member of it, and the apply() in run() hides the type of what the call returns. + // Not through AsyncContextFrame.run(), here and in completeQueuedSend(): with it an ahead-of-time executable compiles more of a program's functions to other bytes than its test run does, and test/aot/servers has a floor for that. const previous = AsyncContextFrame.exchange(frame); try { return Bun.udpSocket(udpOptions); diff --git a/test/js/bun/module-graph/module-graph-gc.test.ts b/test/js/bun/module-graph/module-graph-gc.test.ts index 4d8fab0a797f..0f8e8cdcb2a4 100644 --- a/test/js/bun/module-graph/module-graph-gc.test.ts +++ b/test/js/bun/module-graph/module-graph-gc.test.ts @@ -1,5 +1,6 @@ // Bun.ModuleGraph and the garbage collector: what keeps a graph (its loader, module // records, CommonJS modules and its context) alive, and that nothing else does. +import { exposedInternals } from "bun:internal-for-testing"; import { heapStats } from "bun:jsc"; import { afterAll, describe, expect, jest, test } from "bun:test"; import { rmSync, writeFileSync } from "fs"; @@ -12,6 +13,7 @@ import { collect, heapFromTimer, Lifetimes } from "./module-graph-lifetimes"; const ModuleGraph = Bun.ModuleGraph; type Graph = InstanceType; +const { kStateSymbol } = exposedInternals["internal/dgram"]; const dir = String( tempDir("module-graph-gc-", { @@ -461,30 +463,32 @@ describe("ModuleGraph GC: what the graph's context owns", () => { // A send() that waits behind backpressure keeps the async context of its call for its callback. With no callback // it keeps nothing: the queue of a socket of the host's does not keep the graph whose script sent. test("a queued node:dgram send() with no callback does not keep the graph that sent it", async () => { - const { kStateSymbol } = require("bun:internal-for-testing").exposedInternals["internal/dgram"]; const lifetimes = new Lifetimes(); const socket = dgram.createSocket("udp4"); - socket.bind(0, "127.0.0.1"); - await once(socket, "listening"); - socket.connect(socket.address().port, "127.0.0.1"); - await once(socket, "connect"); - const handle = socket[kStateSymbol].handle; - const realSocket = handle.socket; const refusesEverySend = { send: () => false }; try { - await (async () => { - const graph = lifetimes.track("graph", new ModuleGraph({ globals: { TAG: "sends", control: control() } })); - const io = await graph.import(file("io.mjs")); - // With a queue, every send() waits behind it, and a drain that is refused leaves it there: backpressure. - handle.socket = refusesEverySend; - handle.sendQueue = []; - graph.run(() => io.dgramSend(socket)); - graph.dispose(); - })(); - expect(await lifetimes.stillAlive("graph")).toEqual([]); - expect(handle.sendQueue).toHaveLength(1); + socket.bind(0, "127.0.0.1"); + await once(socket, "listening"); + socket.connect(socket.address().port, "127.0.0.1"); + await once(socket, "connect"); + const handle = socket[kStateSymbol].handle; + const realSocket = handle.socket; + try { + await (async () => { + const graph = lifetimes.track("graph", new ModuleGraph({ globals: { TAG: "sends", control: control() } })); + const io = await graph.import(file("io.mjs")); + // With a queue, every send() waits behind it, and a drain that is refused leaves it there: backpressure. + handle.socket = refusesEverySend; + handle.sendQueue = []; + graph.run(() => io.dgramSend(socket)); + graph.dispose(); + })(); + expect(await lifetimes.stillAlive("graph")).toEqual([]); + expect(handle.sendQueue).toHaveLength(1); + } finally { + handle.socket = realSocket; + } } finally { - handle.socket = realSocket; socket.close(); } }); diff --git a/test/js/bun/module-graph/module-graph-io.test.ts b/test/js/bun/module-graph/module-graph-io.test.ts index 534ce7cf19ab..145c3ce28d01 100644 --- a/test/js/bun/module-graph/module-graph-io.test.ts +++ b/test/js/bun/module-graph/module-graph-io.test.ts @@ -1,5 +1,6 @@ // Bun.ModuleGraph — a graph has a context of its own // for timers and I/O: what its code opens belongs to it, and dispose() closes all of it. +import { exposedInternals } from "bun:internal-for-testing"; import { afterAll, describe, expect, test } from "bun:test"; import fs, { rmSync, writeFileSync } from "fs"; import { bunEnv, bunExe, tempDir, tls } from "harness"; @@ -12,6 +13,7 @@ import { promisify } from "node:util"; import zlib from "node:zlib"; import { join } from "path"; +const { kStateSymbol } = exposedInternals["internal/dgram"]; const fixtureDirs: string[] = []; function fixture(files: Record): string { const dir = String(tempDir("module-graph-io-", files)); @@ -642,7 +644,6 @@ describe.concurrent("ModuleGraph: what a graph opens is the graph's", () => { // Bun.udpSocket() takes the owner from the async context. So when another graph made the socket, or the host, // bind() opens it in its own context: those rows read "bind()", as they did before. test("a node:dgram socket is of the graph that binds it, not of the one that made it", async () => { - const { kStateSymbol } = require("bun:internal-for-testing").exposedInternals["internal/dgram"]; const storage = new AsyncLocalStorage(); type Where = "A" | "B" | "the host"; async function layout(madeIn: Where, boundIn: Where, { disposeMakerFirst = false } = {}) { @@ -716,7 +717,6 @@ describe.concurrent("ModuleGraph: what a graph opens is the graph's", () => { // runs where that send() was made: in the caller's graph and store, as the callback of a ws send() does. With a // queue, every send() waits behind it, which is what backpressure does. test("a queued send() on a node:dgram socket completes in the graph and the store of its own call", async () => { - const { kStateSymbol } = require("bun:internal-for-testing").exposedInternals["internal/dgram"]; const storage = new AsyncLocalStorage(); type Who = "the graph" | "the host"; async function completions(socketOf: Who, completedBy: "the drain" | "close()") { @@ -781,7 +781,6 @@ describe.concurrent("ModuleGraph: what a graph opens is the graph's", () => { // As for anything a disposed graph left behind: the callback is still called, once, inside the disposed graph, // and what it starts there does not start. test("a queued send() of a graph that is disposed before a node:dgram socket of the host's drains", async () => { - const { kStateSymbol } = require("bun:internal-for-testing").exposedInternals["internal/dgram"]; const storage = new AsyncLocalStorage(); const socket = dgram.createSocket("udp4"); socket.bind(0, "127.0.0.1"); diff --git a/test/js/bun/udp/dgram-cluster-async-context-fixture.ts b/test/js/bun/udp/dgram-cluster-async-context-fixture.ts index 0a79a1320cb5..876384a90aa6 100644 --- a/test/js/bun/udp/dgram-cluster-async-context-fixture.ts +++ b/test/js/bun/udp/dgram-cluster-async-context-fixture.ts @@ -2,14 +2,14 @@ // A bind that goes through the primary gives the socket the handle the primary sent: node swaps it in // (replaceHandle() in lib/dgram.js), so the store of createSocket() is not that handle's. An exclusive bind keeps the // socket's own handle. +import { exposedInternals } from "bun:internal-for-testing"; import { AsyncLocalStorage } from "node:async_hooks"; import cluster from "node:cluster"; import dgram from "node:dgram"; if (cluster.isPrimary) { // A bound descriptor of the primary's for the worker's bind({ fd }). - const { _createSocketHandle } = require("bun:internal-for-testing").exposedInternals["internal/dgram"]; - const wrap = _createSocketHandle("127.0.0.1", 0, "udp4"); + const wrap = exposedInternals["internal/dgram"]._createSocketHandle("127.0.0.1", 0, "udp4"); const worker = cluster.fork(); // Fail fast instead of hanging the harness. const watchdog = setTimeout(() => { From fe0b0e9dbed80ac02c5063eaae1654b237a77b3f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 2 Nov 2026 16:43:59 +0000 Subject: [PATCH 08/11] node:dgram: keep the async context of a queued send() in a table, not in a field of the queue entry With the frame in a field of the entry, an ahead-of-time build of a program that can reach send() took every AsyncLocalStorage store to be used by code it cannot see. node:child_process reaches bind() and send(), so a program that only spawns a child was one of them: bun check --aot --explain=trim kept what only a method of a store names, and 1 of 8 reads of a store in a loop was one load where main has 5 of 6. --- src/js/node/dgram.ts | 17 ++++-------- test/js/bun/udp/dgram.test.ts | 52 ++++++++++++++++++++++++++++++++++- 2 files changed, 57 insertions(+), 12 deletions(-) diff --git a/src/js/node/dgram.ts b/src/js/node/dgram.ts index 044c86daaf34..ae7c249d0f87 100644 --- a/src/js/node/dgram.ts +++ b/src/js/node/dgram.ts @@ -312,8 +312,6 @@ interface QueuedSend { port: number | undefined; address: string | null | undefined; req: SendRequest; - // The async context of a send() that has a callback, where node makes the request that it completes: https://github.com/nodejs/node/blob/v26.3.0/lib/dgram.js#L738 - frame: unknown; } interface UDPHandle { @@ -334,6 +332,8 @@ interface UDPHandle { // The async context of createSocket(), for a handle whose native socket is not open yet. node makes the UDP handle there, and what that handle delivers ('message', a receive error) runs in it: https://github.com/nodejs/node/blob/v26.3.0/lib/dgram.js#L143 const handleFrames = new WeakMap(); +// The async context of a queued send() that has a callback, where node makes the request that it completes (https://github.com/nodejs/node/blob/v26.3.0/lib/dgram.js#L738). Not a field of the queue entry: with a frame in one, an ahead-of-time build of a program that can reach send() loses the types of its AsyncLocalStorage stores. +const queuedSendFrames = new WeakMap(); // Bun.udpSocket() takes the socket's Bun.ModuleGraph from the async context, so a socket that another graph binds opens in the context of bind(): it stays the binder's. function openInFrameOfHandle(frame: unknown, udpOptions: any) { @@ -477,14 +477,9 @@ function handleQueueSend( // same way. const length = data.byteLength; handle.sendQueue ??= []; - handle.sendQueue.push({ - data, - length, - port, - address, - req, - frame: req.callback ? $getInternalField($asyncContext, 0) : undefined, - }); + handle.sendQueue.push({ data, length, port, address, req }); + const frame = req.callback ? $getInternalField($asyncContext, 0) : undefined; + if (frame !== undefined) WeakMapPrototypeSet.$call(queuedSendFrames, req, frame); handle.queueCount += 1; handle.queueSize += length; return 0; @@ -541,7 +536,7 @@ function completeQueuedSend(handle: UDPHandle, entry: QueuedSend, err: unknown) const req = entry.req; if (req && typeof req.oncomplete === "function") { const sent = err === undefined ? entry.length : 0; - const frame = entry.frame; + const frame = req.callback && WeakMapPrototypeGet.$call(queuedSendFrames, req); // Queued inside the frame of the send(), so that what the callback throws is reported in it too. The frames are equal in every program without AsyncLocalStorage and Bun.ModuleGraph, which never loads the helper. if (!req.callback || frame === $getInternalField($asyncContext, 0)) process.nextTick(runQueuedSendComplete, req, err, sent); diff --git a/test/js/bun/udp/dgram.test.ts b/test/js/bun/udp/dgram.test.ts index e03203481f48..e21fdc16dda1 100644 --- a/test/js/bun/udp/dgram.test.ts +++ b/test/js/bun/udp/dgram.test.ts @@ -3,8 +3,9 @@ import { describe, expect, jest, test } from "bun:test"; import { createSocket } from "dgram"; import { Worker } from "node:worker_threads"; -import { bunEnv, bunExe, bunRun, disableAggressiveGCScope, isWindows } from "harness"; +import { bunEnv, bunExe, bunRun, disableAggressiveGCScope, isDebug, isWindows, tempDir } from "harness"; import path from "path"; +import { unsupported as aotUnsupported } from "../../../aot/harness/compiler"; import { nodeDataCases } from "./testdata"; // Spawn a cluster fixture with a hard deadline and no-orphan protection so a @@ -1014,6 +1015,55 @@ test("a send() that waited behind backpressure reports what its callback throws }); }); +// node:dgram saves the async context of createSocket() and of a queued send(). Where an ahead-of-time build cannot +// follow a saved context, it takes every AsyncLocalStorage store of the program to be used by code it cannot see, and +// keeps all that a store names. `bun check --aot` says what a build keeps. node:child_process reaches bind() for a +// socket that a child receives. (Not on a debug build: the two reports take 50 s there.) +test.skipIf(aotUnsupported !== undefined || isDebug)( + "an ahead-of-time build removes what only a method of a store names, in a program that binds and sends and in one that spawns a child", + async () => { + const programs = { + "binds a node:dgram socket and sends on it": `import { createSocket } from "node:dgram"; +const socket = createSocket("udp4"); +socket.bind(0, "127.0.0.1", () => socket.send("x", socket.address().port, "127.0.0.1", () => socket.close()));`, + "spawns a child process": `import { spawn } from "node:child_process"; +spawn(process.execPath, ["--version"]).on("exit", () => {});`, + }; + const collation = "Intl.Collator and localeCompare ("; + const reports = await Promise.all( + Object.entries(programs).map(async ([program, uses]) => { + using dir = tempDir("dgram-aot-store", { + "main.ts": `// @ts-nocheck +import { AsyncLocalStorage } from "node:async_hooks"; +class Store { + used() { return 1; } + neverCalled() { return new Intl.Collator("en").compare("a", "b"); } +} +const storage = new AsyncLocalStorage(); +const store = new Store(); +console.log(storage.run(store, () => store.used())); +${uses} +`, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "check", "--aot", "--no-pretty", "--explain=trim", "main.ts"], + cwd: String(dir), + env: { ...bunEnv, NO_COLOR: "1" }, + stdout: "ignore", + stderr: "pipe", + }); + const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + return [program, { collation: stderr.split("\n").filter(line => line.includes(collation)), exitCode }]; + }), + ); + const removed = { collation: [expect.stringContaining(`aot: trim: removed ${collation}`)], exitCode: 0 }; + expect(Object.fromEntries(reports)).toEqual({ + "binds a node:dgram socket and sends on it": removed, + "spawns a child process": removed, + }); + }, +); + // Reserves an ephemeral UDP port and frees it, so datagrams sent there get // ICMP port-unreachable replies on loopback. async function getDeadPort() { From a703962e928d8b7516e9b24c4b205dbc5e9a1fe0 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 3 Nov 2026 09:01:57 +0000 Subject: [PATCH 09/11] node:dgram: a handle does not keep an async context that has a frame of a Bun.ModuleGraph in it A socket that the host holds then does not keep the graph that made it. bind() opens such a socket in its own context, as before. --- src/js/node/dgram.ts | 20 ++++-- .../bun/module-graph/module-graph-gc.test.ts | 68 +++++++++++++------ .../bun/module-graph/module-graph-io.test.ts | 11 +-- test/js/bun/udp/dgram.test.ts | 54 +++++++++++++++ 4 files changed, 122 insertions(+), 31 deletions(-) diff --git a/src/js/node/dgram.ts b/src/js/node/dgram.ts index ae7c249d0f87..8045e79de462 100644 --- a/src/js/node/dgram.ts +++ b/src/js/node/dgram.ts @@ -331,14 +331,24 @@ interface UDPHandle { } // The async context of createSocket(), for a handle whose native socket is not open yet. node makes the UDP handle there, and what that handle delivers ('message', a receive error) runs in it: https://github.com/nodejs/node/blob/v26.3.0/lib/dgram.js#L143 -const handleFrames = new WeakMap(); +// null: that context has a frame of a Bun.ModuleGraph in it. A handle that kept it would keep the graph, so bind() opens such a socket in its own context. +const handleFrames = new WeakMap(); // The async context of a queued send() that has a callback, where node makes the request that it completes (https://github.com/nodejs/node/blob/v26.3.0/lib/dgram.js#L738). Not a field of the queue entry: with a frame in one, an ahead-of-time build of a program that can reach send() loses the types of its AsyncLocalStorage stores. const queuedSendFrames = new WeakMap(); -// Bun.udpSocket() takes the socket's Bun.ModuleGraph from the async context, so a socket that another graph binds opens in the context of bind(): it stays the binder's. +// Whether a frame of a Bun.ModuleGraph is in the async context `frame`, under a frame that leaves a graph too. +function hasFrameOfModuleGraph(frame: any) { + const AsyncContextFrame = require("internal/async_context_frame"); + for (; frame !== undefined; frame = frame.prev) { + if (AsyncContextFrame.graphOf(frame) !== undefined) return true; + } + return false; +} + +// Bun.udpSocket() takes the socket's Bun.ModuleGraph from the async context, so a socket that a graph binds opens in the context of bind(): it stays the binder's. function openInFrameOfHandle(frame: unknown, udpOptions: any) { const AsyncContextFrame = require("internal/async_context_frame"); - if (AsyncContextFrame.graphOf(frame) !== AsyncContextFrame.currentGraph()) return Bun.udpSocket(udpOptions); + if (AsyncContextFrame.currentGraph() !== undefined) return Bun.udpSocket(udpOptions); // Not through AsyncContextFrame.run(), here and in completeQueuedSend(): with it an ahead-of-time executable compiles more of a program's functions to other bytes than its test run does, and test/aot/servers has a floor for that. const previous = AsyncContextFrame.exchange(frame); try { @@ -385,7 +395,7 @@ function newHandle(type: string, lookup: LookupFunction | undefined) { handle.onmessage = onMessage; const frame = $getInternalField($asyncContext, 0); - if (frame !== undefined) WeakMapPrototypeSet.$call(handleFrames, handle, frame); + if (frame !== undefined) WeakMapPrototypeSet.$call(handleFrames, handle, hasFrameOfModuleGraph(frame) ? null : frame); return handle; } @@ -954,7 +964,7 @@ function startBunSocket( const handle = state.handle!; const frame = WeakMapPrototypeGet.$call(handleFrames, handle); const opened = - sharedHandle !== undefined || frame === $getInternalField($asyncContext, 0) + sharedHandle !== undefined || frame === null || frame === $getInternalField($asyncContext, 0) ? Bun.udpSocket(udpOptions) : openInFrameOfHandle(frame, udpOptions); (opened as Promise<$Node.UDPSocketHandle>).$then( diff --git a/test/js/bun/module-graph/module-graph-gc.test.ts b/test/js/bun/module-graph/module-graph-gc.test.ts index 0f8e8cdcb2a4..f2d9dd1b1271 100644 --- a/test/js/bun/module-graph/module-graph-gc.test.ts +++ b/test/js/bun/module-graph/module-graph-gc.test.ts @@ -48,6 +48,8 @@ const dir = String( import https from "node:https"; // A node:dgram socket that nothing has bound yet. export function dgramSocket() { return dgram.createSocket("udp4"); } + // The same, made in a store whose value holds a function of the graph. + export function dgramSocketInStoreOfTheGraph(storage) { return storage.run({ log() {} }, dgramSocket); } // Calls the listeners of the host's target while the graph's script is on the stack. export function dispatch(target) { target.dispatchEvent(new Event("call")); } export function dgramSend(socket) { socket.send("from the graph"); } @@ -408,21 +410,45 @@ describe("ModuleGraph GC: what the graph's context owns", () => { expect(await accepts(state.httpPort)).toBe(false); }); - // node:dgram opens the socket in bind(), inside the async context of createSocket(). A socket that the host bound - // does not keep the graph that made it, and not a graph whose script was on the stack when the host bound it. A - // socket the graph bound is the graph's: its handlers hold the graph until the host closes the socket. - test("of the node:dgram sockets the host holds, one that the graph bound keeps the graph and one that the host bound does not", async () => { + // node:dgram keeps the async context of createSocket() for the bind() that opens the socket, but not a context + // that has a frame of a graph in it. So a socket that the host holds does not keep the graph that made it. Nor does + // a socket of the host's keep a graph whose script was on the stack when the host made or bound it: the frame that + // leaves a graph links to the frame of the graph, and what else the host opens over it (Bun.udpSocket() with a + // handler, a timer) does keep the graph. A socket the graph bound is the graph's: its handlers hold the graph + // until the host closes the socket. + test("of the node:dgram sockets the host holds, only one that the graph bound keeps the graph", async () => { const lifetimes = new Lifetimes(); - const storage = new AsyncLocalStorage(); + const storage = new AsyncLocalStorage(); const sockets: Record = {}; - const bind = (socket: dgram.Socket) => + const bind = (socket: dgram.Socket, port: number) => new Promise(resolve => { socket.once("error", (error: NodeJS.ErrnoException) => resolve(String(error.code))); - socket.bind(0, "127.0.0.1", () => resolve("listening")); + socket.bind(port, "127.0.0.1", () => resolve("listening")); }); + // A bind to its port fails. + const holder = await Bun.udpSocket({ hostname: "127.0.0.1", port: 0 }); + // A listener of the host's that the graph's script calls runs as the host, over the frame that leaves the graph. + const inListenerTheGraphCalls = (graph: Graph, io: { dispatch(target: EventTarget): void }, fn: () => T): T => { + const target = new EventTarget(); + let ranAs: string | undefined; + let result!: T; + target.addEventListener("call", () => { + ranAs = ModuleGraph.current === undefined ? "the host" : "a graph"; + result = fn(); + }); + graph.run(() => io.dispatch(target)); + expect(ranAs).toBe("the host"); + return result; + }; const rows = [ + "made in the graph, never bound", + "made in the graph, a bind by the host failed", + "made in the graph, never bound, and the graph is dropped with no dispose()", "made in the graph, bound by the host", "made in the graph, bound by the graph", + "made in the graph in a store whose value holds a function of the graph, never bound", + "made by the host in a listener the graph's script calls, never bound", + "made by the host in a listener the graph's script calls, bound by the host later", "made by the host before, bound by the host in a listener the graph's script calls", ]; try { @@ -430,25 +456,24 @@ describe("ModuleGraph GC: what the graph's context owns", () => { for (const row of rows) { const graph = lifetimes.track(row, new ModuleGraph({ globals: { TAG: row, control: control() } })); const io = await graph.import(file("io.mjs")); - if (row.startsWith("made in the graph")) { + if (row.startsWith("made in the graph in a store")) { + sockets[row] = graph.run(() => io.dgramSocketInStoreOfTheGraph(storage)); + } else if (row.startsWith("made in the graph")) { const socket = (sockets[row] = graph.run(() => storage.run("createSocket()", () => io.dgramSocket()))); - const bound = row.endsWith("bound by the host") ? bind(socket) : graph.run(() => bind(socket)); - expect(await bound).toBe("listening"); + if (row.endsWith("a bind by the host failed")) expect(await bind(socket, holder.port)).toBe("EADDRINUSE"); + if (row.endsWith("bound by the host")) expect(await bind(socket, 0)).toBe("listening"); + if (row.endsWith("bound by the graph")) expect(await graph.run(() => bind(socket, 0))).toBe("listening"); + } else if (row.startsWith("made by the host in a listener")) { + const socket = (sockets[row] = inListenerTheGraphCalls(graph, io, () => + storage.run("createSocket()", () => dgram.createSocket("udp4")), + )); + if (row.endsWith("bound by the host later")) expect(await bind(socket, 0)).toBe("listening"); } else { - // A listener of the host's that the graph's script calls runs as the host, in an async context that holds the graph. const socket = (sockets[row] = dgram.createSocket("udp4")); - const target = new EventTarget(); - let ranAs: string | undefined; - let bound: Promise | undefined; - target.addEventListener("call", () => { - ranAs = ModuleGraph.current === undefined ? "the host" : "a graph"; - bound = storage.run("bind()", () => bind(socket)); - }); - graph.run(() => io.dispatch(target)); - expect(ranAs).toBe("the host"); + const bound = inListenerTheGraphCalls(graph, io, () => storage.run("bind()", () => bind(socket, 0))); expect(await bound).toBe("listening"); } - graph.dispose(); + if (!row.endsWith("with no dispose()")) graph.dispose(); } })(); const boundByTheGraph = "made in the graph, bound by the graph"; @@ -456,6 +481,7 @@ describe("ModuleGraph GC: what the graph's context owns", () => { expect(await lifetimes.survives(boundByTheGraph)).toBe(true); } finally { for (const socket of Object.values(sockets)) socket.close(); + holder.close(); } expect(await lifetimes.stillAlive(...rows)).toEqual([]); }); diff --git a/test/js/bun/module-graph/module-graph-io.test.ts b/test/js/bun/module-graph/module-graph-io.test.ts index 145c3ce28d01..b9fc7b37f857 100644 --- a/test/js/bun/module-graph/module-graph-io.test.ts +++ b/test/js/bun/module-graph/module-graph-io.test.ts @@ -640,10 +640,11 @@ describe.concurrent("ModuleGraph: what a graph opens is the graph's", () => { }); // node:dgram opens a socket in bind(), inside the async context of createSocket(): 'message' then runs in the - // AsyncLocalStorage store node runs it in. The socket is still of the graph whose script binds it, because - // Bun.udpSocket() takes the owner from the async context. So when another graph made the socket, or the host, - // bind() opens it in its own context: those rows read "bind()", as they did before. - test("a node:dgram socket is of the graph that binds it, not of the one that made it", async () => { + // AsyncLocalStorage store node runs it in. Not where a graph is involved. The socket is of the graph whose script + // binds it, because Bun.udpSocket() takes the owner from the async context, and a handle that kept a context with + // a frame of a graph in it would keep that graph. So when a graph made the socket or binds it, bind() opens it in + // its own context: every row reads "bind()", as it did before. + test("a node:dgram socket is of the graph that binds it, and 'message' reads the store of bind()", async () => { const storage = new AsyncLocalStorage(); type Where = "A" | "B" | "the host"; async function layout(madeIn: Where, boundIn: Where, { disposeMakerFirst = false } = {}) { @@ -685,7 +686,7 @@ describe.concurrent("ModuleGraph: what a graph opens is the graph's", () => { "made by the host, bound in a graph": await layout("the host", "A"), }).toEqual({ "made and bound in one graph": { - "'message' reads": "createSocket()", + "'message' reads": "bind()", "'message' runs in": "A", "closed by the dispose() of": "A", }, diff --git a/test/js/bun/udp/dgram.test.ts b/test/js/bun/udp/dgram.test.ts index e21fdc16dda1..c2450b9e6976 100644 --- a/test/js/bun/udp/dgram.test.ts +++ b/test/js/bun/udp/dgram.test.ts @@ -1064,6 +1064,60 @@ ${uses} }, ); +// `bun test --isolate=modules` runs each test file inside a Bun.ModuleGraph, so every socket of a test file is made +// with a frame of a graph in its async context. node:dgram does not keep such a context (it would keep the graph), so +// there 'message' still reads the store of bind(). Node.js has no such mode: it reads the store of createSocket(). +const storesInATestFile = ` +import { test } from "bun:test"; +import { AsyncLocalStorage } from "node:async_hooks"; +import dgram from "node:dgram"; +import { once } from "node:events"; + +test("what 'message' reads", async () => { + const storage = new AsyncLocalStorage(); + const socket = storage.run("createSocket()", () => dgram.createSocket("udp4")); + const sender = dgram.createSocket("udp4"); + try { + storage.run("bind()", () => socket.bind(0, "127.0.0.1")); + await once(socket, "listening"); + const message = Promise.withResolvers(); + socket.on("message", () => message.resolve(String(storage.getStore()))); + socket.on("error", message.reject); + sender.on("error", message.reject); + sender.send("x", socket.address().port, "127.0.0.1"); + console.log( + JSON.stringify({ + "inside a Bun.ModuleGraph": Bun.ModuleGraph.current !== undefined, + "'message' reads the store of": await message.promise, + }), + ); + } finally { + socket.close(); + sender.close(); + } +}); +`; +test.concurrent.each([ + { mode: "no flag", flags: [] as string[], inGraph: false, reads: "createSocket()" }, + { mode: "--isolate", flags: ["--isolate"], inGraph: false, reads: "createSocket()" }, + { mode: "--isolate=modules", flags: ["--isolate=modules"], inGraph: true, reads: "bind()" }, +])("bun test with $mode: 'message' reads the store of $reads", async ({ flags, inGraph, reads }) => { + using dir = tempDir("dgram-test-modes", { "stores.test.js": storesInATestFile }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", ...flags, "./stores.test.js"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + const line = stdout.split("\n").find(each => each.startsWith("{")); + expect({ read: line === undefined ? stderr : JSON.parse(line), exitCode }).toEqual({ + read: { "inside a Bun.ModuleGraph": inGraph, "'message' reads the store of": reads }, + exitCode: 0, + }); +}); + // Reserves an ephemeral UDP port and frees it, so datagrams sent there get // ICMP port-unreachable replies on loopback. async function getDeadPort() { From eb6fb717df32781f438bf2bad1de59f5762b656c Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 3 Nov 2026 09:05:33 +0000 Subject: [PATCH 10/11] node:dgram: one comment line for the handle's frame, and the reason for null at the check --- src/js/node/dgram.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/js/node/dgram.ts b/src/js/node/dgram.ts index 8045e79de462..613c13b78245 100644 --- a/src/js/node/dgram.ts +++ b/src/js/node/dgram.ts @@ -331,12 +331,11 @@ interface UDPHandle { } // The async context of createSocket(), for a handle whose native socket is not open yet. node makes the UDP handle there, and what that handle delivers ('message', a receive error) runs in it: https://github.com/nodejs/node/blob/v26.3.0/lib/dgram.js#L143 -// null: that context has a frame of a Bun.ModuleGraph in it. A handle that kept it would keep the graph, so bind() opens such a socket in its own context. const handleFrames = new WeakMap(); // The async context of a queued send() that has a callback, where node makes the request that it completes (https://github.com/nodejs/node/blob/v26.3.0/lib/dgram.js#L738). Not a field of the queue entry: with a frame in one, an ahead-of-time build of a program that can reach send() loses the types of its AsyncLocalStorage stores. const queuedSendFrames = new WeakMap(); -// Whether a frame of a Bun.ModuleGraph is in the async context `frame`, under a frame that leaves a graph too. +// Whether a frame of a Bun.ModuleGraph is in the async context `frame`, under a frame that leaves a graph too. A handle that kept such a context would keep the graph: it keeps null, and bind() opens its socket in the context of bind(). function hasFrameOfModuleGraph(frame: any) { const AsyncContextFrame = require("internal/async_context_frame"); for (; frame !== undefined; frame = frame.prev) { From c9101b47a27f1d670a1a3810ba8d6e309cfdb888 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 3 Nov 2026 22:47:57 +0000 Subject: [PATCH 11/11] node:dgram: walk the frames of createSocket() in one pass, and let the frame of a queued send() go when it completes hasFrameOfModuleGraph() asks graphOf() about each frame alone. graphOf() walks down from the frame it gets, so with a Bun.ModuleGraph in the realm the check read the rest of the context again for every frame. completeQueuedSend() deletes the entry of its request from the table of frames. The table grew with every queued send() until a collection. Tests: the store that an unbound socket keeps until close(), the store that a queued send() keeps until its callback has run, the store of createSocket() after a bind by a graph's script, the graph that a queued send() with a callback keeps, and the size of the table after a drain. The ICMP tests take the dead port after the socket under test is bound. The test of the ahead-of-time report is in test/aot now. The layouts of module-graph-io.test.ts leave the callback of a graph's socket before they dispose the graph. --- src/js/node/dgram.ts | 15 +- ...thod-of-a-store-that-nothing-calls.test.ts | 53 +++++ .../bun/module-graph/module-graph-gc.test.ts | 78 ++++++- .../bun/module-graph/module-graph-io.test.ts | 3 + test/js/bun/udp/dgram.test.ts | 190 +++++++++++++----- 5 files changed, 282 insertions(+), 57 deletions(-) create mode 100644 test/aot/a-saved-async-context-keeps-no-method-of-a-store-that-nothing-calls.test.ts diff --git a/src/js/node/dgram.ts b/src/js/node/dgram.ts index 613c13b78245..128e3166eeaa 100644 --- a/src/js/node/dgram.ts +++ b/src/js/node/dgram.ts @@ -332,14 +332,17 @@ interface UDPHandle { // The async context of createSocket(), for a handle whose native socket is not open yet. node makes the UDP handle there, and what that handle delivers ('message', a receive error) runs in it: https://github.com/nodejs/node/blob/v26.3.0/lib/dgram.js#L143 const handleFrames = new WeakMap(); -// The async context of a queued send() that has a callback, where node makes the request that it completes (https://github.com/nodejs/node/blob/v26.3.0/lib/dgram.js#L738). Not a field of the queue entry: with a frame in one, an ahead-of-time build of a program that can reach send() loses the types of its AsyncLocalStorage stores. +// The async context of a queued send() that has a callback, where node makes the request that it completes (https://github.com/nodejs/node/blob/v26.3.0/lib/dgram.js#L738). Not a field of the queue entry: with a frame in one, an ahead-of-time build of a program that can reach send() loses the types of its AsyncLocalStorage stores (test/aot/a-saved-async-context-keeps-no-method-of-a-store-that-nothing-calls.test.ts). const queuedSendFrames = new WeakMap(); // Whether a frame of a Bun.ModuleGraph is in the async context `frame`, under a frame that leaves a graph too. A handle that kept such a context would keep the graph: it keeps null, and bind() opens its socket in the context of bind(). function hasFrameOfModuleGraph(frame: any) { const AsyncContextFrame = require("internal/async_context_frame"); + // graphOf() walks down from the frame it gets, so it gets each frame alone, with no `prev`: one pass over the context. + const alone: { storage: unknown } = { storage: undefined }; for (; frame !== undefined; frame = frame.prev) { - if (AsyncContextFrame.graphOf(frame) !== undefined) return true; + alone.storage = frame.storage; + if (AsyncContextFrame.graphOf(alone) !== undefined) return true; } return false; } @@ -348,7 +351,7 @@ function hasFrameOfModuleGraph(frame: any) { function openInFrameOfHandle(frame: unknown, udpOptions: any) { const AsyncContextFrame = require("internal/async_context_frame"); if (AsyncContextFrame.currentGraph() !== undefined) return Bun.udpSocket(udpOptions); - // Not through AsyncContextFrame.run(), here and in completeQueuedSend(): with it an ahead-of-time executable compiles more of a program's functions to other bytes than its test run does, and test/aot/servers has a floor for that. + // Not through AsyncContextFrame.run(), here and in completeQueuedSend(): with it the share of functions that an ahead-of-time executable and its test run compile to the same bytes falls from 65% to 55% for a fastify server that imports node:child_process (test/aot/servers measures that share). const previous = AsyncContextFrame.exchange(frame); try { return Bun.udpSocket(udpOptions); @@ -545,7 +548,9 @@ function completeQueuedSend(handle: UDPHandle, entry: QueuedSend, err: unknown) const req = entry.req; if (req && typeof req.oncomplete === "function") { const sent = err === undefined ? entry.length : 0; - const frame = req.callback && WeakMapPrototypeGet.$call(queuedSendFrames, req); + const frame = req.callback ? WeakMapPrototypeGet.$call(queuedSendFrames, req) : undefined; + // A request completes once: its entry would hold the frame for as long as something holds the request. + if (frame !== undefined) WeakMapPrototypeDelete.$call(queuedSendFrames, req); // Queued inside the frame of the send(), so that what the callback throws is reported in it too. The frames are equal in every program without AsyncLocalStorage and Bun.ModuleGraph, which never loads the helper. if (!req.callback || frame === $getInternalField($asyncContext, 0)) process.nextTick(runQueuedSendComplete, req, err, sent); @@ -968,7 +973,7 @@ function startBunSocket( : openInFrameOfHandle(frame, udpOptions); (opened as Promise<$Node.UDPSocketHandle>).$then( (socket: $Node.UDPSocketHandle) => { - // The native socket holds the frame now. Until a bind succeeds, the handle keeps it. + // The native socket has the context it runs in, so a bound handle needs its frame no more. Until a bind succeeds, the handle keeps it. WeakMapPrototypeDelete.$call(handleFrames, handle); if (!state.handle) { // Closed while the bind was in flight. diff --git a/test/aot/a-saved-async-context-keeps-no-method-of-a-store-that-nothing-calls.test.ts b/test/aot/a-saved-async-context-keeps-no-method-of-a-store-that-nothing-calls.test.ts new file mode 100644 index 000000000000..8b66372c80d5 --- /dev/null +++ b/test/aot/a-saved-async-context-keeps-no-method-of-a-store-that-nothing-calls.test.ts @@ -0,0 +1,53 @@ +// node:dgram saves the async context of createSocket() and of a queued send(). Where the compiler cannot follow a saved context, it +// takes every AsyncLocalStorage store of the program to be used by code it cannot see, and keeps all that a store names. So a method +// of a store that nothing calls shows it: the report says whether what only that method names was removed. node:child_process +// reaches bind() for a socket that a child receives. +import { expect } from "bun:test"; +import { bunEnv, bunExe, tempDir } from "harness"; +import { test } from "./supported"; + +const collation = "Intl.Collator and localeCompare ("; + +test.concurrent.each([ + [ + "binds a node:dgram socket and sends on it", + `import { createSocket } from "node:dgram"; +const socket = createSocket("udp4"); +socket.bind(0, "127.0.0.1", () => socket.send("x", socket.address().port, "127.0.0.1", () => socket.close()));`, + ], + [ + "spawns a child process", + `import { spawn } from "node:child_process"; +spawn(process.execPath, ["--version"]).on("exit", () => {});`, + ], +])( + "a program that %s keeps nothing for a method of a store that it never calls", + async (_, uses) => { + using dir = tempDir("aot-saved-context", { + "main.ts": `// @ts-nocheck +import { AsyncLocalStorage } from "node:async_hooks"; +class Store { + used() { return 1; } + neverCalled() { return new Intl.Collator("en").compare("a", "b"); } +} +const storage = new AsyncLocalStorage(); +const store = new Store(); +console.log(storage.run(store, () => store.used())); +${uses} +`, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "check", "--aot", "--no-pretty", "--explain=trim", "main.ts"], + cwd: String(dir), + env: { ...bunEnv, NO_COLOR: "1" }, + stdout: "ignore", + stderr: "pipe", + }); + const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + expect({ collation: stderr.split("\n").filter(line => line.includes(collation)), exitCode }).toEqual({ + collation: [expect.stringContaining(`aot: trim: removed ${collation}`)], + exitCode: 0, + }); + }, + 600_000, +); diff --git a/test/js/bun/module-graph/module-graph-gc.test.ts b/test/js/bun/module-graph/module-graph-gc.test.ts index f2d9dd1b1271..06b783fcadd1 100644 --- a/test/js/bun/module-graph/module-graph-gc.test.ts +++ b/test/js/bun/module-graph/module-graph-gc.test.ts @@ -52,7 +52,7 @@ const dir = String( export function dgramSocketInStoreOfTheGraph(storage) { return storage.run({ log() {} }, dgramSocket); } // Calls the listeners of the host's target while the graph's script is on the stack. export function dispatch(target) { target.dispatchEvent(new Event("call")); } - export function dgramSend(socket) { socket.send("from the graph"); } + export function dgramSend(socket, callback) { socket.send("from the graph", callback); } export function serve() { const server = Bun.serve({ port: 0, fetch: () => new Response("graph " + TAG) }); control.httpPort = server.port; @@ -486,6 +486,37 @@ describe("ModuleGraph GC: what the graph's context owns", () => { expect(await lifetimes.stillAlive(...rows)).toEqual([]); }); + // A bind by the host opens the socket in the async context of createSocket(), so the open socket keeps the store + // of createSocket(), as in Node.js. A bind by a graph's script opens it in the context of bind(): nothing needs + // the context of createSocket() after that, and the handle lets it go when the bind succeeds. + test("an open node:dgram socket keeps the store of createSocket() only when it opened in it", async () => { + const lifetimes = new Lifetimes(); + const storage = new AsyncLocalStorage(); + const sockets: dgram.Socket[] = []; + const graph = new ModuleGraph({ globals: {} }); + const bind = (socket: dgram.Socket) => + new Promise(resolve => { + socket.once("error", (error: NodeJS.ErrnoException) => resolve(String(error.code))); + socket.bind(0, "127.0.0.1", () => resolve("listening")); + }); + const rows = ["bound by the host", "bound by a graph's script"]; + try { + await (async () => { + for (const row of rows) { + const socket = storage.run(lifetimes.track(row, { row }), () => dgram.createSocket("udp4")); + sockets.push(socket); + expect(await (row === "bound by the host" ? bind(socket) : graph.run(() => bind(socket)))).toBe("listening"); + } + })(); + expect(await lifetimes.stillAlive("bound by a graph's script")).toEqual([]); + expect(await lifetimes.survives("bound by the host")).toBe(true); + } finally { + for (const socket of sockets) socket.close(); + graph.dispose(); + } + expect(await lifetimes.stillAlive(...rows)).toEqual([]); + }); + // A send() that waits behind backpressure keeps the async context of its call for its callback. With no callback // it keeps nothing: the queue of a socket of the host's does not keep the graph whose script sent. test("a queued node:dgram send() with no callback does not keep the graph that sent it", async () => { @@ -519,6 +550,51 @@ describe("ModuleGraph GC: what the graph's context owns", () => { } }); + // With a callback it keeps the async context of its call until the callback has run, as a queued send() of a + // WebSocket does. A frame of a graph in that context keeps the graph for that time: until the drain, or close(). + test("a queued node:dgram send() with a callback keeps the graph that sent it until it completes", async () => { + const lifetimes = new Lifetimes(); + const socket = dgram.createSocket("udp4"); + const takesEverySend = { send: () => true }; + const refusesEverySend = { send: () => false }; + let closed = false; + try { + socket.bind(0, "127.0.0.1"); + await once(socket, "listening"); + socket.connect(socket.address().port, "127.0.0.1"); + await once(socket, "connect"); + const handle = socket[kStateSymbol].handle; + const realSocket = handle.socket; + try { + await (async () => { + for (const row of ["completed by a drain", "still queued"]) { + const graph = lifetimes.track(row, new ModuleGraph({ globals: { TAG: row, control: control() } })); + const io = await graph.import(file("io.mjs")); + const completed = Promise.withResolvers(); + // With a queue, every send() waits behind it. The callback is the host's: only the context is the graph's. + handle.sendQueue = []; + handle.socket = row === "completed by a drain" ? takesEverySend : refusesEverySend; + graph.run(() => io.dgramSend(socket, completed.resolve)); + handle.drain(); + if (row === "completed by a drain") await completed.promise; + graph.dispose(); + } + })(); + expect(await lifetimes.stillAlive("completed by a drain")).toEqual([]); + expect(await lifetimes.survives("still queued")).toBe(true); + } finally { + handle.socket = realSocket; + } + closed = true; + const done = once(socket, "close"); + socket.close(); + await done; + expect(await lifetimes.stillAlive("still queued")).toEqual([]); + } finally { + if (!closed) socket.close(); + } + }); + test("a repeating timer keeps its graph alive; clearing it lets the graph go", async () => { const lifetimes = new Lifetimes(); const state = control(); diff --git a/test/js/bun/module-graph/module-graph-io.test.ts b/test/js/bun/module-graph/module-graph-io.test.ts index b9fc7b37f857..a0d466e743f9 100644 --- a/test/js/bun/module-graph/module-graph-io.test.ts +++ b/test/js/bun/module-graph/module-graph-io.test.ts @@ -667,6 +667,9 @@ describe.concurrent("ModuleGraph: what a graph opens is the graph's", () => { const native = socket[kStateSymbol].handle.socket; sender.send("x", socket.address().port, "127.0.0.1"); const { store, graph } = await message.promise; + // 'message' is a callback of the binder's socket. A test that ends inside it, after dispose(), leaves the runner + // inside a disposed graph, and the runner does not call the tests that it starts from there. + await hostTimerTurns(1); graphs.A.dispose(); const closedByA: boolean = native.closed; graphs.B.dispose(); diff --git a/test/js/bun/udp/dgram.test.ts b/test/js/bun/udp/dgram.test.ts index c2450b9e6976..00ca78616427 100644 --- a/test/js/bun/udp/dgram.test.ts +++ b/test/js/bun/udp/dgram.test.ts @@ -1,11 +1,12 @@ import { AsyncLocalStorage } from "async_hooks"; import { describe, expect, jest, test } from "bun:test"; import { createSocket } from "dgram"; +import { once } from "node:events"; import { Worker } from "node:worker_threads"; -import { bunEnv, bunExe, bunRun, disableAggressiveGCScope, isDebug, isWindows, tempDir } from "harness"; +import { bunEnv, bunExe, bunRun, disableAggressiveGCScope, isWindows, tempDir } from "harness"; import path from "path"; -import { unsupported as aotUnsupported } from "../../../aot/harness/compiler"; +import { Lifetimes } from "../module-graph/module-graph-lifetimes"; import { nodeDataCases } from "./testdata"; // Spawn a cluster fixture with a hard deadline and no-orphan protection so a @@ -1015,54 +1016,139 @@ test("a send() that waited behind backpressure reports what its callback throws }); }); -// node:dgram saves the async context of createSocket() and of a queued send(). Where an ahead-of-time build cannot -// follow a saved context, it takes every AsyncLocalStorage store of the program to be used by code it cannot see, and -// keeps all that a store names. `bun check --aot` says what a build keeps. node:child_process reaches bind() for a -// socket that a child receives. (Not on a debug build: the two reports take 50 s there.) -test.skipIf(aotUnsupported !== undefined || isDebug)( - "an ahead-of-time build removes what only a method of a store names, in a program that binds and sends and in one that spawns a child", - async () => { - const programs = { - "binds a node:dgram socket and sends on it": `import { createSocket } from "node:dgram"; -const socket = createSocket("udp4"); -socket.bind(0, "127.0.0.1", () => socket.send("x", socket.address().port, "127.0.0.1", () => socket.close()));`, - "spawns a child process": `import { spawn } from "node:child_process"; -spawn(process.execPath, ["--version"]).on("exit", () => {});`, - }; - const collation = "Intl.Collator and localeCompare ("; - const reports = await Promise.all( - Object.entries(programs).map(async ([program, uses]) => { - using dir = tempDir("dgram-aot-store", { - "main.ts": `// @ts-nocheck -import { AsyncLocalStorage } from "node:async_hooks"; -class Store { - used() { return 1; } - neverCalled() { return new Intl.Collator("en").compare("a", "b"); } -} -const storage = new AsyncLocalStorage(); -const store = new Store(); -console.log(storage.run(store, () => store.used())); -${uses} -`, - }); - await using proc = Bun.spawn({ - cmd: [bunExe(), "check", "--aot", "--no-pretty", "--explain=trim", "main.ts"], - cwd: String(dir), - env: { ...bunEnv, NO_COLOR: "1" }, - stdout: "ignore", - stderr: "pipe", - }); - const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); - return [program, { collation: stderr.split("\n").filter(line => line.includes(collation)), exitCode }]; - }), +// What node:dgram keeps of an async context, it keeps as Node.js does: the handle has the store of createSocket() until +// close(), and a request that waits behind backpressure has the store of its send() until its callback has run. Each +// row tracks the store object of one run(). +test("an unbound socket keeps the store of createSocket() until close()", async () => { + const lifetimes = new Lifetimes(); + const storage = new AsyncLocalStorage(); + const sockets: Record> = {}; + // A bind to its port fails. + const holder = await Bun.udpSocket({ hostname: "127.0.0.1", port: 0 }); + const rows = ["held", "closed", "dropped", "a bind failed, then closed"]; + try { + await (async () => { + for (const row of rows) { + const socket = storage.run(lifetimes.track(row, { row }), () => createSocket("udp4")); + if (row !== "dropped") sockets[row] = socket; + if (row.startsWith("a bind failed")) { + const failed = once(socket, "error"); + socket.bind(holder.port, "127.0.0.1"); + expect((await failed)[0].code).toBe("EADDRINUSE"); + } + if (row.endsWith("closed")) { + const closed = once(socket, "close"); + socket.close(); + await closed; + } + } + })(); + expect(await lifetimes.stillAlive("closed", "dropped", "a bind failed, then closed")).toEqual([]); + expect(await lifetimes.survives("held")).toBe(true); + } finally { + holder.close(); + sockets.held?.close(); + } + expect(await lifetimes.stillAlive(...rows)).toEqual([]); +}); + +test("a send() that waits behind backpressure keeps the store of its call until its callback has run", async () => { + const { kStateSymbol } = require("bun:internal-for-testing").exposedInternals["internal/dgram"]; + const lifetimes = new Lifetimes(); + const storage = new AsyncLocalStorage(); + const socket = createSocket("udp4"); + const takesEverySend = { send: () => true }; + const refusesEverySend = { send: () => false }; + let closed = false; + try { + await new Promise((resolve, reject) => socket.once("error", reject).bind(0, "127.0.0.1", resolve)); + await new Promise((resolve, reject) => + socket.connect(socket.address().port, "127.0.0.1", (error?: Error) => (error ? reject(error) : resolve())), ); - const removed = { collation: [expect.stringContaining(`aot: trim: removed ${collation}`)], exitCode: 0 }; - expect(Object.fromEntries(reports)).toEqual({ - "binds a node:dgram socket and sends on it": removed, - "spawns a child process": removed, - }); - }, -); + const handle = socket[kStateSymbol].handle; + const realSocket = handle.socket; + try { + await (async () => { + for (const row of ["completed by a drain", "still queued"]) { + const completed = Promise.withResolvers(); + // With a queue, every send() waits behind it: what backpressure does. + handle.sendQueue = []; + handle.socket = row === "completed by a drain" ? takesEverySend : refusesEverySend; + storage.run(lifetimes.track(row, { row }), () => socket.send("x", completed.resolve)); + handle.drain(); + if (row === "completed by a drain") await completed.promise; + } + })(); + expect(await lifetimes.stillAlive("completed by a drain")).toEqual([]); + expect(await lifetimes.survives("still queued")).toBe(true); + } finally { + handle.socket = realSocket; + } + closed = true; + const done = once(socket, "close"); + socket.close(); + await done; + expect(await lifetimes.stillAlive("still queued")).toEqual([]); + } finally { + if (!closed) socket.close(); + } +}); + +// The frame of a queued send() is in a table, by request. A request completes once, and its entry goes then: the table +// does not grow with the requests that a drain completes. When drain() returns the requests are still in the queue of +// ticks, so no collection has removed an entry. +test("the drain of queued send()s leaves none of them in the table of their frames", async () => { + // A CommonJS file: its require() calls run in order, so node:dgram loads after the patch. + using dir = tempDir("dgram-queued-send-table", { + "table.cjs": ` + // node:dgram takes WeakMap.prototype.set when it loads. Its table of frames is the one that gets a request. + const set = WeakMap.prototype.set; + let table; + WeakMap.prototype.set = function (key, value) { + if (table === undefined && key !== null && typeof key === "object" && "oncomplete" in key) table = this; + return set.call(this, key, value); + }; + const { AsyncLocalStorage } = require("node:async_hooks"); + const dgram = require("node:dgram"); + WeakMap.prototype.set = set; + const { estimateShallowMemoryUsageOf } = require("bun:jsc"); + const { kStateSymbol } = require("bun:internal-for-testing").exposedInternals["internal/dgram"]; + const storage = new AsyncLocalStorage(); + const socket = dgram.createSocket("udp4"); + socket.bind(0, "127.0.0.1", () => + socket.connect(socket.address().port, "127.0.0.1", () => { + const handle = socket[kStateSymbol].handle; + const realSocket = handle.socket; + // With a queue, every send() waits behind it: what backpressure does. + handle.sendQueue = []; + storage.run("send()", () => { + for (let i = 0; i < 2000; i++) socket.send("x", () => {}); + }); + const queued = estimateShallowMemoryUsageOf(table); + handle.socket = { send: () => true }; + handle.drain(); + handle.socket = realSocket; + const drained = estimateShallowMemoryUsageOf(table); + console.log(JSON.stringify({ "has a table": table !== undefined, "shrinks at the drain": queued > 16 * drained })); + socket.close(); + }), + ); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "table.cjs"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout: stdout && JSON.parse(stdout), stderr, exitCode }).toEqual({ + stdout: { "has a table": true, "shrinks at the drain": true }, + stderr: "", + exitCode: 0, + }); +}); // `bun test --isolate=modules` runs each test file inside a Bun.ModuleGraph, so every socket of a test file is made // with a frame of a graph in its async context. node:dgram does not keep such a context (it would keep the graph), so @@ -1152,12 +1238,13 @@ const icmpBindModes = { for (const [kind, bind] of Object.entries(icmpBindModes)) { test.skipIf(isWindows)(`${kind} socket emits the ICMP error instead of silently dying`, async () => { const { kStateSymbol } = require("bun:internal-for-testing").exposedInternals["internal/dgram"]; - const deadPort = await getDeadPort(); const socket = createSocket("udp4"); const { promise: errored, resolve: onError } = Promise.withResolvers(); socket.on("error", onError); await bind(socket); + // After the bind: a port that is taken before it can become the port of this socket, and then nothing refuses. + const deadPort = await getDeadPort(); await new Promise(resolve => socket.connect(deadPort, "127.0.0.1", () => resolve())); // Keep sending until the queued ICMP error surfaces. If the native socket @@ -1200,12 +1287,13 @@ for (const [kind, bind] of Object.entries(icmpBindModes)) { const inStore = (store: string | undefined, fn: () => T) => store === undefined ? fn() : storage.run(store, fn); const storeOfError = async (createIn: string | undefined, bindIn: string | undefined) => { - const deadPort = await getDeadPort(); const socket = inStore(createIn, () => createSocket("udp4")); try { const { promise: errored, resolve: onError } = Promise.withResolvers(); socket.on("error", error => onError(`${storage.getStore()} (${error.message})`)); await inStore(bindIn, () => bind(socket)); + // After the bind: a port that is taken before it can become the port of this socket, and then nothing refuses. + const deadPort = await getDeadPort(); await new Promise((resolve, reject) => socket.connect(deadPort, "127.0.0.1", (error?: Error) => (error ? reject(error) : resolve())), );