From aed67f757ffd747fbe22df635ec641713aefce86 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 14 Sep 2026 03:18:07 +0000 Subject: [PATCH 1/3] inspector: do not append a //# sourceURL= comment to transpiled modules With the inspector on, the runtime transpiler appended `//# sourceURL=` after the inline source map comment, with the path bytes written raw. A line feed or a carriage return in the path ended the comment, and the rest of the path ran as code in that module. The source provider already carries the same path as its sourceURL(), and every reader of the directive falls back to it. JSC already discards the directive when the path has a space or a quote. For a non-ASCII path the directive was the UTF-8 bytes read as Latin-1, so stack traces showed a mangled path and a position that was not remapped. The debugger matches a breakpoint set by URL against the directive when there is one, so a breakpoint in a module under a non-ASCII directory never bound (#15035). It binds now. --- src/jsc/VirtualMachine.rs | 16 +- .../inspect/inspect-inline-sourcemap.test.ts | 232 ++++++++++++------ 2 files changed, 168 insertions(+), 80 deletions(-) diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 769b4cb550b0..64e9a3c23abf 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -3297,6 +3297,10 @@ impl<'a> bun_js_printer::OnSourceMapChunk for SourceMapHandlerGetter<'a> { /// When the inspector is enabled, we want to generate an inline sourcemap. /// And, for now, we also store it in `source_mappings` like normal. /// This is hideously expensive memory-wise... + /// + /// The module's path is not appended as a `//# sourceURL=` comment. The + /// source provider already has it as `sourceURL()`, and a line break in + /// the path ends the comment, so the rest of the path runs as code. fn on_source_map_chunk( &mut self, chunk: bun_sourcemap::Chunk, @@ -3309,11 +3313,7 @@ impl<'a> bun_js_printer::OnSourceMapChunk for SourceMapHandlerGetter<'a> { .map_err(|_| bun_js_printer::Error::WriteFailed)?; const SOURCE_MAP_URL_PREFIX_START: &[u8] = b"//# sourceMappingURL=data:application/json;base64,"; - // TODO: do we need to %-encode the path? - let source_url_len = source.path.text.len(); - const SOURCE_MAPPING_URL: &[u8] = b"\n//# sourceURL="; - let prefix_len = - SOURCE_MAP_URL_PREFIX_START.len() + SOURCE_MAPPING_URL.len() + source_url_len; + let prefix_len = SOURCE_MAP_URL_PREFIX_START.len(); self.vm_source_mappings_mut() .put_mappings(source, chunk.buffer) @@ -3357,12 +3357,6 @@ impl<'a> bun_js_printer::OnSourceMapChunk for SourceMapHandlerGetter<'a> { // spare capacity reserved by `grow_if_needed` above. unsafe { bun_core::vec::commit_spare(buf, wrote) }; } - printer - .ctx - .buffer - .append_assume_capacity(SOURCE_MAPPING_URL); - // TODO: do we need to %-encode the path? - printer.ctx.buffer.append_assume_capacity(source.path.text); printer.ctx.buffer.append(b"\n")?; Ok(()) } diff --git a/test/cli/inspect/inspect-inline-sourcemap.test.ts b/test/cli/inspect/inspect-inline-sourcemap.test.ts index 9d16f40145cd..ea442ad69a29 100644 --- a/test/cli/inspect/inspect-inline-sourcemap.test.ts +++ b/test/cli/inspect/inspect-inline-sourcemap.test.ts @@ -1,10 +1,85 @@ import { spawn } from "bun"; import { expect, test } from "bun:test"; import fs from "fs"; -import { bunEnv, bunExe, tempDir } from "harness"; +import { bunEnv, bunExe, isWindows, tempDir } from "harness"; import { join } from "node:path"; import { WebSocket } from "ws"; +// Starts `bun --inspect-wait ` in `cwd` and attaches to its inspector. +// A socket error, a socket close, or the exit of the inspectee rejects every +// promise that `send` and `event` return. +async function inspect(cwd: string, entry: string) { + const proc = spawn({ + cmd: [bunExe(), "--inspect-wait=127.0.0.1:0", entry], + env: bunEnv, + cwd, + stdout: "pipe", + stderr: "pipe", + }); + + let ws: WebSocket | undefined; + const dispose = async () => { + ws?.close(); + proc.kill(); + await proc.exited; + }; + + try { + let stderr = ""; + let url: string | undefined; + const decoder = new TextDecoder(); + for await (const chunk of proc.stderr as ReadableStream) { + stderr += decoder.decode(chunk, { stream: true }); + url = stderr.match(/^\s*(ws:\/\/\S+)\s*\n/m)?.[1]; + if (url) break; + } + if (!url) throw new Error("Unable to find listening URL in: " + JSON.stringify(stderr)); + + const socket = (ws = new WebSocket(url)); + const { promise: failed, reject: fail } = Promise.withResolvers(); + failed.catch(() => {}); + socket.addEventListener("error", cause => fail(new Error("WebSocket error", { cause }))); + socket.addEventListener("close", cause => fail(new Error("WebSocket closed", { cause }))); + proc.exited.then(code => fail(new Error(`inspectee exited (${code})`))); + + await Promise.race([new Promise(resolve => socket.addEventListener("open", () => resolve())), failed]); + + const pending = new Map void>(); + const waiters: { method: string; match: (params: any) => boolean; resolve: (params: any) => void }[] = []; + socket.addEventListener("message", ({ data }) => { + const msg = JSON.parse(data.toString()); + if (typeof msg.id === "number") { + pending.get(msg.id)?.(msg); + pending.delete(msg.id); + return; + } + const i = waiters.findIndex(w => w.method === msg.method && w.match(msg.params)); + if (i !== -1) waiters.splice(i, 1)[0].resolve(msg.params); + }); + + let nextId = 0; + return { + proc, + send: (method: string, params: Record = {}) => + Promise.race([ + new Promise(resolve => { + const id = ++nextId; + pending.set(id, resolve); + socket.send(JSON.stringify({ id, method, params })); + }), + failed, + ]), + // Resolves with the params of the next `method` event that `match` accepts. + event: (method: string, match: (params: any) => boolean = () => true) => + Promise.race([new Promise(resolve => waiters.push({ method, match, resolve })), failed]), + [Symbol.asyncDispose]: dispose, + }; + } catch (e) { + await dispose(); + throw e; + } +} + test("--inspect inline sourcemap sources[0] is a valid path under cwd", async () => { // VT (0x0B) / BEL (0x07) in the source exercise quote_for_json's RFC 8259 // escape handling for sourcesContent. @@ -14,79 +89,98 @@ test("--inspect inline sourcemap sources[0] is a valid path under cwd", async () }); const cwd = fs.realpathSync(String(dir)); + await using session = await inspect(cwd, join("sub", "target.mjs")); + const scriptParsed = session.event("Debugger.scriptParsed", p => String(p?.url ?? "").endsWith("target.mjs")); + await Promise.all([session.send("Inspector.enable"), session.send("Debugger.enable")]); + session.send("Inspector.initialized").catch(() => {}); + const params = await scriptParsed; + + const m = String(params.sourceMapURL ?? "").match(/base64,([A-Za-z0-9+/=]+)/); + expect(m).not.toBeNull(); + const raw = Buffer.from(m![1], "base64").toString(); + expect(raw).not.toMatch(/\\v|\\x[0-9A-Fa-f]{2}/); + const map = JSON.parse(raw); + + expect(map.sources[0]).toBe("/sub/target.mjs"); + expect(map.sourcesContent[0]).toBe(source); +}); + +// A Windows file name cannot contain LF or CR. +test.concurrent.skipIf(isWindows).each([ + ["LF", "\n"], + ["CR", "\r"], +])("--inspect does not run the part of a module path after a %s as code", async (_, lineBreak) => { + // A line break ends a `//` comment. If the runtime writes the path into one, + // the rest of the file name is a statement that sets a global. It is one + // `var` statement, which has no completion value, so the function wrapper + // of the CommonJS module stays the result of its script. + const name = (id: string, ext: string) => + `${id}${lineBreak}var x = (globalThis.${id} = 'ran'), y = { ${ext}: 1 }, z = y.${ext}`; + const esm = name("esm", "mjs"); + const cjs = name("cjs", "cjs"); + using dir = tempDir("inspect-path-line-break", { + [esm]: "export default 'esm';", + [cjs]: "module.exports = 'cjs';", + "main.mjs": ` + import a from ${JSON.stringify("./" + esm)}; + import { createRequire } from "node:module"; + const b = createRequire(import.meta.url)(${JSON.stringify("./" + cjs)}); + console.log(JSON.stringify({ loaded: [a, b], ran: { esm: globalThis.esm, cjs: globalThis.cjs } })); + `, + }); + await using proc = spawn({ - cmd: [bunExe(), "--inspect-wait=127.0.0.1:0", join("sub", "target.mjs")], + cmd: [bunExe(), "--inspect=127.0.0.1:0", "main.mjs"], env: bunEnv, - cwd, - stdout: "ignore", + cwd: String(dir), + stdout: "pipe", stderr: "pipe", }); + const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - let url: URL | undefined; - let stderr = ""; - const decoder = new TextDecoder(); - for await (const chunk of proc.stderr as ReadableStream) { - stderr += decoder.decode(chunk); - for (const line of stderr.split("\n")) { - try { - url = new URL(line); - } catch {} - if (url?.protocol.includes("ws")) break; - } - if (stderr.includes("Listening:")) break; - } - if (!url) { - process.stderr.write(stderr); - throw new Error("Unable to find listening URL"); - } + expect({ stdout, exitCode }).toEqual({ stdout: '{"loaded":["esm","cjs"],"ran":{}}\n', exitCode: 0 }); +}); - const ws = new WebSocket(url); - try { - const failed = new Promise((_, reject) => { - ws.addEventListener("error", cause => reject(new Error("WebSocket error", { cause }))); - ws.addEventListener("close", cause => reject(new Error("WebSocket closed", { cause }))); - proc.exited.then(code => reject(new Error(`inspectee exited (${code})`))); - }); - failed.catch(() => {}); +test.concurrent("--inspect reports the path and position of a frame in a non-ASCII directory", async () => { + using dir = tempDir("inspect-non-ascii-path", { + "sub-é-中/target.mjs": "\nconsole.log(new Error('x').stack.split('\\n')[1]);\n", + }); + const target = join("sub-é-中", "target.mjs"); - await Promise.race([new Promise(resolve => ws.addEventListener("open", () => resolve())), failed]); - - const pending = new Map void>(); - const scriptParsed = new Promise(resolve => { - ws.addEventListener("message", ({ data }) => { - const msg = JSON.parse(data.toString()); - if (typeof msg.id === "number" && pending.has(msg.id)) { - pending.get(msg.id)!(msg); - pending.delete(msg.id); - } else if (msg.method === "Debugger.scriptParsed" && String(msg.params?.url ?? "").endsWith("target.mjs")) { - resolve(msg.params); - } - }); - }); - let nextId = 0; - const send = (method: string, params: Record = {}) => - Promise.race([ - new Promise(resolve => { - const id = ++nextId; - pending.set(id, resolve); - ws.send(JSON.stringify({ id, method, params })); - }), - failed, - ]); - - await Promise.all([send("Inspector.enable"), send("Debugger.enable")]); - send("Inspector.initialized").catch(() => {}); - const params = await Promise.race([scriptParsed, failed]); - - const m = String(params.sourceMapURL ?? "").match(/base64,([A-Za-z0-9+/=]+)/); - expect(m).not.toBeNull(); - const raw = Buffer.from(m![1], "base64").toString(); - expect(raw).not.toMatch(/\\v|\\x[0-9A-Fa-f]{2}/); - const map = JSON.parse(raw); - - expect(map.sources[0]).toBe("/sub/target.mjs"); - expect(map.sourcesContent[0]).toBe(source); - } finally { - ws.close(); - } + await using proc = spawn({ + cmd: [bunExe(), "--inspect=127.0.0.1:0", target], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout.trimEnd()).toEndWith(target + ":2:17"); + expect(exitCode).toBe(0); +}); + +// https://github.com/oven-sh/bun/issues/15035 +test.concurrent("--inspect-wait binds a breakpoint set by URL in a non-ASCII directory", async () => { + using dir = tempDir("inspect-non-ascii-breakpoint", { + "script_español/index.js": 'console.log("1");\nconsole.log("2");\nconsole.log("3");\nprocess.exit(0);\n', + }); + const cwd = fs.realpathSync(String(dir)); + + await using session = await inspect(cwd, join("script_español", "index.js")); + await Promise.all([session.send("Inspector.enable"), session.send("Debugger.enable")]); + await session.send("Debugger.setBreakpointsActive", { active: true }); + // An editor sets its breakpoints before the script is parsed. `urlRegex` keeps + // the test independent of how the platform spells the temporary directory. + await session.send("Debugger.setBreakpointByUrl", { urlRegex: "script_español[\\\\/]index\\.js$", lineNumber: 1 }); + const paused = session.event("Debugger.paused"); + session.send("Inspector.initialized").catch(() => {}); + + // If the breakpoint does not bind, the script reaches `process.exit(0)` and this rejects. + const { reason, callFrames } = await paused; + expect({ reason, lineNumber: callFrames[0].location.lineNumber }).toEqual({ reason: "Breakpoint", lineNumber: 1 }); + + session.send("Debugger.resume").catch(() => {}); + const [stdout, exitCode] = await Promise.all([session.proc.stdout.text(), session.proc.exited]); + expect({ stdout, exitCode }).toEqual({ stdout: "1\n2\n3\n", exitCode: 0 }); }); From 24fe9b14047f234d047633dc7e1d4e9ecf1ae649 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 16 Oct 2026 17:42:05 +0000 Subject: [PATCH 2/3] inspector: test inspector.open(), plugin module ids and a non-ASCII file name Remove the doc comment on on_source_map_chunk. It described a write that no longer exists. Tests: - LF and CR in a module path run under --inspect and under inspector.open(). - LF and CR in a plugin module id (build.module and an onResolve namespace) run on every platform, because no file is needed. - The breakpoint test runs for a non-ASCII directory and for a non-ASCII file name, the two reproductions in #15035. It is no longer concurrent: on a timeout, bun:test kills the child processes of a test only when the test runs alone. - A CDP client over inspector.open() gets the real file URL in Debugger.scriptParsed, and a breakpoint set by that URL resolves. --- src/jsc/VirtualMachine.rs | 4 - .../inspect/inspect-inline-sourcemap.test.ts | 106 ++++++++++++------ test/js/node/inspector/inspector.test.ts | 66 +++++++++++ 3 files changed, 139 insertions(+), 37 deletions(-) diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 764f543fcf52..b94815c8b985 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -3824,10 +3824,6 @@ impl<'a> bun_js_printer::OnSourceMapChunk for SourceMapHandlerGetter<'a> { /// When the inspector is enabled, we want to generate an inline sourcemap. /// And, for now, we also store it in `source_mappings` like normal. /// This is hideously expensive memory-wise... - /// - /// The module's path is not appended as a `//# sourceURL=` comment. The - /// source provider already has it as `sourceURL()`, and a line break in - /// the path ends the comment, so the rest of the path runs as code. fn on_source_map_chunk( &mut self, chunk: bun_sourcemap::Chunk, diff --git a/test/cli/inspect/inspect-inline-sourcemap.test.ts b/test/cli/inspect/inspect-inline-sourcemap.test.ts index ea442ad69a29..be71cb9f16c3 100644 --- a/test/cli/inspect/inspect-inline-sourcemap.test.ts +++ b/test/cli/inspect/inspect-inline-sourcemap.test.ts @@ -105,74 +105,114 @@ test("--inspect inline sourcemap sources[0] is a valid path under cwd", async () expect(map.sourcesContent[0]).toBe(source); }); +// A line break ends a `//` comment. If the runtime writes a module's path into +// one, the text after the line break is a statement that sets a global. It is +// one `var` statement, which has no completion value, so the function wrapper +// of a CommonJS module stays the result of its script. +const nameWithCode = (id: string, lineBreak: string, ext: string) => + `${id}${lineBreak}var x = (globalThis.${id} = 'ran'), y = { ${ext}: 1 }, z = y.${ext}`; + +async function runMain(cwd: string, ...args: string[]) { + await using proc = spawn({ + cmd: [bunExe(), ...args], + env: bunEnv, + cwd, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, exitCode }; +} + // A Windows file name cannot contain LF or CR. test.concurrent.skipIf(isWindows).each([ - ["LF", "\n"], - ["CR", "\r"], -])("--inspect does not run the part of a module path after a %s as code", async (_, lineBreak) => { - // A line break ends a `//` comment. If the runtime writes the path into one, - // the rest of the file name is a statement that sets a global. It is one - // `var` statement, which has no completion value, so the function wrapper - // of the CommonJS module stays the result of its script. - const name = (id: string, ext: string) => - `${id}${lineBreak}var x = (globalThis.${id} = 'ran'), y = { ${ext}: 1 }, z = y.${ext}`; - const esm = name("esm", "mjs"); - const cjs = name("cjs", "cjs"); + ["LF", "--inspect", "\n"], + ["CR", "--inspect", "\r"], + ["LF", "inspector.open()", "\n"], + ["CR", "inspector.open()", "\r"], +])("%s in a module path does not run the rest of the path as code under %s", async (_, entryPoint, lineBreak) => { + const esm = nameWithCode("esm", lineBreak, "mjs"); + const cjs = nameWithCode("cjs", lineBreak, "cjs"); using dir = tempDir("inspect-path-line-break", { [esm]: "export default 'esm';", [cjs]: "module.exports = 'cjs';", "main.mjs": ` - import a from ${JSON.stringify("./" + esm)}; + import inspector from "node:inspector"; import { createRequire } from "node:module"; + const open = process.argv[2] === "open"; + if (open) inspector.open(0, "127.0.0.1", false); + const { default: a } = await import(${JSON.stringify("./" + esm)}); const b = createRequire(import.meta.url)(${JSON.stringify("./" + cjs)}); console.log(JSON.stringify({ loaded: [a, b], ran: { esm: globalThis.esm, cjs: globalThis.cjs } })); + if (open) inspector.close(); `, }); - await using proc = spawn({ - cmd: [bunExe(), "--inspect=127.0.0.1:0", "main.mjs"], - env: bunEnv, - cwd: String(dir), - stdout: "pipe", - stderr: "pipe", + const args = entryPoint === "--inspect" ? ["--inspect=127.0.0.1:0", "main.mjs"] : ["main.mjs", "open"]; + expect(await runMain(String(dir), ...args)).toEqual({ stdout: '{"loaded":["esm","cjs"],"ran":{}}\n', exitCode: 0 }); +}); + +// A module that a plugin serves has no file, so these cases run on every platform. +test.concurrent.each([ + ["LF", "\n"], + ["CR", "\r"], +])("%s in a plugin module id does not run the rest of the id as code under --inspect", async (_, lineBreak) => { + const viaModule = "virtual:" + nameWithCode("viaModule", lineBreak, "js"); + const viaOnLoad = "ns:" + nameWithCode("viaOnLoad", lineBreak, "js"); + using dir = tempDir("inspect-plugin-id-line-break", { + "main.mjs": ` + Bun.plugin({ + name: "module ids with a line break", + setup(build) { + build.module(${JSON.stringify(viaModule)}, () => ({ contents: "export default 'module';", loader: "js" })); + build.onResolve({ filter: /.*/, namespace: "ns" }, ({ path }) => ({ path, namespace: "ns" })); + build.onLoad({ filter: /.*/, namespace: "ns" }, () => ({ contents: "export default 'onLoad';", loader: "js" })); + }, + }); + const { default: a } = await import(${JSON.stringify(viaModule)}); + const { default: b } = await import(${JSON.stringify(viaOnLoad)}); + console.log(JSON.stringify({ loaded: [a, b], ran: { viaModule: globalThis.viaModule, viaOnLoad: globalThis.viaOnLoad } })); + `, }); - const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect({ stdout, exitCode }).toEqual({ stdout: '{"loaded":["esm","cjs"],"ran":{}}\n', exitCode: 0 }); + expect(await runMain(String(dir), "--inspect=127.0.0.1:0", "main.mjs")).toEqual({ + stdout: '{"loaded":["module","onLoad"],"ran":{}}\n', + exitCode: 0, + }); }); +// The non-ASCII names below have no space, quote or 0xA0 byte in UTF-8 (as in +// `à` or `根`). JSC discards a `//# sourceURL=` value that has one, and these +// tests would then pass with the comment back in place. test.concurrent("--inspect reports the path and position of a frame in a non-ASCII directory", async () => { using dir = tempDir("inspect-non-ascii-path", { "sub-é-中/target.mjs": "\nconsole.log(new Error('x').stack.split('\\n')[1]);\n", }); const target = join("sub-é-中", "target.mjs"); - await using proc = spawn({ - cmd: [bunExe(), "--inspect=127.0.0.1:0", target], - env: bunEnv, - cwd: String(dir), - stdout: "pipe", - stderr: "pipe", - }); - const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - + const { stdout, exitCode } = await runMain(String(dir), "--inspect=127.0.0.1:0", target); expect(stdout.trimEnd()).toEndWith(target + ":2:17"); expect(exitCode).toBe(0); }); // https://github.com/oven-sh/bun/issues/15035 -test.concurrent("--inspect-wait binds a breakpoint set by URL in a non-ASCII directory", async () => { +// Not concurrent: when a test times out, bun:test kills its child processes only +// if it runs alone, and this inspectee waits for a debugger. +test.each([ + ["directory", "script_español/index.js", "script_español[\\\\/]index\\.js$"], + ["file name", "ñ.ts", "[\\\\/]ñ\\.ts$"], +])("--inspect-wait binds a breakpoint set by URL for a non-ASCII %s", async (_, file, urlRegex) => { using dir = tempDir("inspect-non-ascii-breakpoint", { - "script_español/index.js": 'console.log("1");\nconsole.log("2");\nconsole.log("3");\nprocess.exit(0);\n', + [file]: 'console.log("1");\nconsole.log("2");\nconsole.log("3");\nprocess.exit(0);\n', }); const cwd = fs.realpathSync(String(dir)); - await using session = await inspect(cwd, join("script_español", "index.js")); + await using session = await inspect(cwd, join(file)); await Promise.all([session.send("Inspector.enable"), session.send("Debugger.enable")]); await session.send("Debugger.setBreakpointsActive", { active: true }); // An editor sets its breakpoints before the script is parsed. `urlRegex` keeps // the test independent of how the platform spells the temporary directory. - await session.send("Debugger.setBreakpointByUrl", { urlRegex: "script_español[\\\\/]index\\.js$", lineNumber: 1 }); + await session.send("Debugger.setBreakpointByUrl", { urlRegex, lineNumber: 1 }); const paused = session.event("Debugger.paused"); session.send("Inspector.initialized").catch(() => {}); diff --git a/test/js/node/inspector/inspector.test.ts b/test/js/node/inspector/inspector.test.ts index 2b4db78be71a..d153922161bd 100644 --- a/test/js/node/inspector/inspector.test.ts +++ b/test/js/node/inspector/inspector.test.ts @@ -1071,6 +1071,72 @@ test("Debugger.scriptParsed reports isModule and scriptLanguage from JSC's scrip ]); }); +// https://github.com/oven-sh/bun/issues/15035 +// The fixture is its own CDP client. It sets the breakpoint after the module is +// parsed, so the reply lists where the breakpoint resolved. +const nonAsciiUrlFixture = ` +import inspector from "node:inspector"; + +inspector.open(0, "127.0.0.1", false); +const ws = new WebSocket(inspector.url()); +const pending = new Map(); +const scripts = []; +let nextId = 1; +ws.onmessage = event => { + const message = JSON.parse(event.data); + if (message.id) { + pending.get(message.id)(message); + pending.delete(message.id); + } else if (message.method === "Debugger.scriptParsed") { + scripts.push(message.params); + } +}; +const send = (method, params) => + new Promise(resolve => { + const id = nextId++; + pending.set(id, resolve); + ws.send(JSON.stringify({ id, method, params })); + }); +await new Promise(resolve => (ws.onopen = resolve)); + +await send("Debugger.enable", {}); +const { url } = await import("./sub-é-中/target.mjs"); +const breakpoint = await send("Debugger.setBreakpointByUrl", { url, lineNumber: 0 }); +inspector.close(); + +const script = scripts.find(script => script.url.endsWith("/target.mjs")); +console.log( + JSON.stringify({ + url, + scriptParsedUrl: script?.url, + breakpointLocations: breakpoint.result.locations.map(({ scriptId, lineNumber }) => ({ + inTarget: scriptId === script?.scriptId, + lineNumber, + })), + }), +); +`; + +test("Debugger.scriptParsed gives the file URL of a module in a non-ASCII directory, and a breakpoint set by that URL resolves", async () => { + using dir = tempDir("inspector-non-ascii-url", { + "fixture.mjs": nonAsciiUrlFixture, + "sub-é-中/target.mjs": `export const url = import.meta.url;\n`, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "fixture.mjs"], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stderrIfFailed: exitCode === 0 ? "" : stderr, exitCode }).toEqual({ stderrIfFailed: "", exitCode: 0 }); + + const { url, ...reported } = JSON.parse(stdout.trim().split("\n").at(-1)!); + expect(url).toEndWith("/sub-%C3%A9-%E4%B8%AD/target.mjs"); + expect(reported).toEqual({ scriptParsedUrl: url, breakpointLocations: [{ inTarget: true, lineNumber: 0 }] }); +}); + test("disconnect does not clobber a console method reassigned by user code", () => { const session = new inspector.Session(); session.connect(); From 7cd0389e8803e511be2b5f0cffd4f848658d2709 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 17 Oct 2026 20:04:45 +0000 Subject: [PATCH 3/3] inspector tests: handle a rejected exit promise, share one CDP client prelude - inspect(): pass a rejection of proc.exited to fail. Bun.spawn rejects that promise when the wait for the child fails. - inspector.test.ts: the two fixtures that are their own CDP client now share one prelude. A socket error or close before closeInspector() ends the fixture with exit code 1 and the cause on stderr. --- .../inspect/inspect-inline-sourcemap.test.ts | 2 +- test/js/node/inspector/inspector.test.ts | 58 +++++++++---------- 2 files changed, 27 insertions(+), 33 deletions(-) diff --git a/test/cli/inspect/inspect-inline-sourcemap.test.ts b/test/cli/inspect/inspect-inline-sourcemap.test.ts index be71cb9f16c3..eb255cb67379 100644 --- a/test/cli/inspect/inspect-inline-sourcemap.test.ts +++ b/test/cli/inspect/inspect-inline-sourcemap.test.ts @@ -40,7 +40,7 @@ async function inspect(cwd: string, entry: string) { failed.catch(() => {}); socket.addEventListener("error", cause => fail(new Error("WebSocket error", { cause }))); socket.addEventListener("close", cause => fail(new Error("WebSocket closed", { cause }))); - proc.exited.then(code => fail(new Error(`inspectee exited (${code})`))); + proc.exited.then(code => fail(new Error(`inspectee exited (${code})`)), fail); await Promise.race([new Promise(resolve => socket.addEventListener("open", () => resolve())), failed]); diff --git a/test/js/node/inspector/inspector.test.ts b/test/js/node/inspector/inspector.test.ts index d153922161bd..6fb98ff46e70 100644 --- a/test/js/node/inspector/inspector.test.ts +++ b/test/js/node/inspector/inspector.test.ts @@ -998,11 +998,11 @@ export { after }; expect(await proc.exited).toBe(0); }); -// JSC's Debugger.scriptParsed classifies a script with scriptType ("program", -// "module" or "webassembly"); V8 clients read isModule and scriptLanguage. The -// fixture is its own CDP client: it enables the Debugger domain, then loads one -// script of each kind and prints what the adapter reported for them. -const scriptParsedFixture = ` +// The start of a fixture that is its own CDP client over the inspector.open() +// server: `send` resolves with the reply to a command, `scripts` collects +// Debugger.scriptParsed, and `closeInspector` ends the session. A socket that +// fails before that ends the fixture with exit code 1. +const cdpClientPrelude = ` import inspector from "node:inspector"; inspector.open(0, "127.0.0.1", false); @@ -1010,6 +1010,14 @@ const ws = new WebSocket(inspector.url()); const pending = new Map(); const scripts = []; let nextId = 1; +let closed = false; +const abandon = why => { + if (closed) return; + console.error(why); + process.exit(1); +}; +ws.onerror = () => abandon("the inspector WebSocket had an error"); +ws.onclose = () => abandon("the inspector WebSocket closed"); ws.onmessage = event => { const message = JSON.parse(event.data); if (message.id) { @@ -1025,8 +1033,18 @@ const send = (method, params) => pending.set(id, resolve); ws.send(JSON.stringify({ id, method, params })); }); +const closeInspector = () => { + closed = true; + inspector.close(); +}; await new Promise(resolve => (ws.onopen = resolve)); +`; +// JSC's Debugger.scriptParsed classifies a script with scriptType ("program", +// "module" or "webassembly"); V8 clients read isModule and scriptLanguage. The +// fixture is its own CDP client: it enables the Debugger domain, then loads one +// script of each kind and prints what the adapter reported for them. +const scriptParsedFixture = `${cdpClientPrelude} await send("Debugger.enable", {}); await import("./esm.mjs"); await import("./lib.cjs"); @@ -1036,7 +1054,7 @@ new WebAssembly.Module(new Uint8Array([0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, // on, so this reply arriving proves the scriptParsed events for the three // scripts above have arrived too. await send("Debugger.setBreakpointsActive", { active: true }); -inspector.close(); +closeInspector(); console.log( JSON.stringify( @@ -1074,35 +1092,11 @@ test("Debugger.scriptParsed reports isModule and scriptLanguage from JSC's scrip // https://github.com/oven-sh/bun/issues/15035 // The fixture is its own CDP client. It sets the breakpoint after the module is // parsed, so the reply lists where the breakpoint resolved. -const nonAsciiUrlFixture = ` -import inspector from "node:inspector"; - -inspector.open(0, "127.0.0.1", false); -const ws = new WebSocket(inspector.url()); -const pending = new Map(); -const scripts = []; -let nextId = 1; -ws.onmessage = event => { - const message = JSON.parse(event.data); - if (message.id) { - pending.get(message.id)(message); - pending.delete(message.id); - } else if (message.method === "Debugger.scriptParsed") { - scripts.push(message.params); - } -}; -const send = (method, params) => - new Promise(resolve => { - const id = nextId++; - pending.set(id, resolve); - ws.send(JSON.stringify({ id, method, params })); - }); -await new Promise(resolve => (ws.onopen = resolve)); - +const nonAsciiUrlFixture = `${cdpClientPrelude} await send("Debugger.enable", {}); const { url } = await import("./sub-é-中/target.mjs"); const breakpoint = await send("Debugger.setBreakpointByUrl", { url, lineNumber: 0 }); -inspector.close(); +closeInspector(); const script = scripts.find(script => script.url.endsWith("/target.mjs")); console.log(