diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 387a912576e0..8af34f592a88 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -3841,11 +3841,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) @@ -3889,12 +3885,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..eb255cb67379 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})`)), fail); + + 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,138 @@ 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 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(), "--inspect-wait=127.0.0.1:0", join("sub", "target.mjs")], + cmd: [bunExe(), ...args], env: bunEnv, cwd, - stdout: "ignore", + stdout: "pipe", stderr: "pipe", }); + const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, exitCode }; +} - 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"); - } +// A Windows file name cannot contain LF or CR. +test.concurrent.skipIf(isWindows).each([ + ["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 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(); + `, + }); - 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(() => {}); + 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 }); +}); - 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); - } +// 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" })); + }, }); - }); - 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(); - } + 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 } })); + `, + }); + + 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"); + + 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 +// 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", { + [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(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, 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 }); }); diff --git a/test/js/node/inspector/inspector.test.ts b/test/js/node/inspector/inspector.test.ts index 2b4db78be71a..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( @@ -1071,6 +1089,48 @@ 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 = `${cdpClientPrelude} +await send("Debugger.enable", {}); +const { url } = await import("./sub-é-中/target.mjs"); +const breakpoint = await send("Debugger.setBreakpointByUrl", { url, lineNumber: 0 }); +closeInspector(); + +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();