diff --git a/src/js/node/dgram.ts b/src/js/node/dgram.ts index 48fb4620bf39..128e3166eeaa 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 @@ -327,6 +330,36 @@ 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(); +// 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) { + alone.storage = frame.storage; + if (AsyncContextFrame.graphOf(alone) !== 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.currentGraph() !== undefined) return Bun.udpSocket(udpOptions); + // 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); + } finally { + AsyncContextFrame.exchange(previous); + } +} + function newHandle(type: string, lookup: LookupFunction | undefined) { if (lookup === undefined) { lookup = require("node:dns").lookup as LookupFunction; @@ -363,6 +396,9 @@ function newHandle(type: string, lookup: LookupFunction | undefined) { handle.onmessage = onMessage; + const frame = $getInternalField($asyncContext, 0); + if (frame !== undefined) WeakMapPrototypeSet.$call(handleFrames, handle, hasFrameOfModuleGraph(frame) ? null : frame); + return handle; } @@ -454,6 +490,8 @@ function handleQueueSend( const length = data.byteLength; handle.sendQueue ??= []; 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; @@ -509,7 +547,22 @@ 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 = 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); + else { + const AsyncContextFrame = require("internal/async_context_frame"); + const previous = AsyncContextFrame.exchange(frame); + try { + process.nextTick(runQueuedSendComplete, req, err, sent); + } finally { + AsyncContextFrame.exchange(previous); + } + } } } @@ -911,8 +964,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 === null || frame === $getInternalField($asyncContext, 0) + ? Bun.udpSocket(udpOptions) + : openInFrameOfHandle(frame, udpOptions); + (opened as Promise<$Node.UDPSocketHandle>).$then( (socket: $Node.UDPSocketHandle) => { + // 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. socket.close(); 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 6ca81121299c..06b783fcadd1 100644 --- a/test/js/bun/module-graph/module-graph-gc.test.ts +++ b/test/js/bun/module-graph/module-graph-gc.test.ts @@ -1,15 +1,19 @@ // 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"; 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"; const ModuleGraph = Bun.ModuleGraph; type Graph = InstanceType; +const { kStateSymbol } = exposedInternals["internal/dgram"]; const dir = String( tempDir("module-graph-gc-", { @@ -39,8 +43,16 @@ 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"); } + // 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, 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; @@ -398,6 +410,191 @@ 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, 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 sockets: Record = {}; + const bind = (socket: 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 }); + // 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 { + await (async () => { + 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 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()))); + 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 { + const socket = (sockets[row] = dgram.createSocket("udp4")); + const bound = inListenerTheGraphCalls(graph, io, () => storage.run("bind()", () => bind(socket, 0))); + expect(await bound).toBe("listening"); + } + if (!row.endsWith("with no dispose()")) graph.dispose(); + } + })(); + 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(...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 () => { + const lifetimes = new Lifetimes(); + const socket = dgram.createSocket("udp4"); + const refusesEverySend = { send: () => 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 () => { + 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 { + socket.close(); + } + }); + + // 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 0737c4e5e434..a0d466e743f9 100644 --- a/test/js/bun/module-graph/module-graph-io.test.ts +++ b/test/js/bun/module-graph/module-graph-io.test.ts @@ -1,15 +1,19 @@ // 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"; 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"; 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)); @@ -635,6 +639,188 @@ 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. 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 } = {}) { + 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(); + 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) }), + ); + // dispose() closes the sockets of its graph at once, and the native socket says so. + 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(); + 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": "bind()", + "'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 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 { + 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 = []; + 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 storage = new AsyncLocalStorage(); + 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 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..876384a90aa6 --- /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 { 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 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(() => { + 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..00ca78616427 100644 --- a/test/js/bun/udp/dgram.test.ts +++ b/test/js/bun/udp/dgram.test.ts @@ -1,9 +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, isWindows } from "harness"; +import { bunEnv, bunExe, bunRun, disableAggressiveGCScope, isWindows, tempDir } from "harness"; import path from "path"; +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 @@ -240,6 +243,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 +860,350 @@ 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("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 })); + 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()", + "'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, 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.connect(socket.address().port, "127.0.0.1", (error?: Error) => (error ? reject(error) : 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, + }); +}); + +// 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 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 +// 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() { @@ -876,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 @@ -915,6 +1278,60 @@ 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 { 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 storeOfError = async (createIn: string | undefined, bindIn: string | undefined) => { + 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())), + ); + // 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 {} + 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); + }); + } +});