diff --git a/completions/bun-cli.json b/completions/bun-cli.json index a9c9ea764efa..8489e3210c78 100644 --- a/completions/bun-cli.json +++ b/completions/bun-cli.json @@ -4207,7 +4207,7 @@ }, { "name": "inspect-brk", - "description": "Activate Bun's debugger, set breakpoint on first line of code and wait", + "description": "Activate Bun's debugger, wait for a connection and pause before the first line of code", "hasValue": true, "valueType": "val", "required": false, diff --git a/completions/bun.zsh b/completions/bun.zsh index 53caa55759ed..b499a0405329 100644 --- a/completions/bun.zsh +++ b/completions/bun.zsh @@ -506,7 +506,7 @@ _bun_run_completion() { '--target[The intended execution environment for the bundle. "browser", "bun" or "node"]: :->target' \ '--inspect[Activate Bun'"'"'s Debugger]:inspect' \ '--inspect-wait[Activate Bun'"'"'s Debugger, wait for a connection before executing]:inspect-wait' \ - '--inspect-brk[Activate Bun'"'"'s Debugger, set breakpoint on first line of code and wait]:inspect-brk' \ + '--inspect-brk[Activate Bun'"'"'s Debugger, wait for a connection and pause before the first line of code]:inspect-brk' \ '--hot[Enable auto reload in bun'"'"'s JavaScript runtime]' \ '--watch[Automatically restart bun'"'"'s JavaScript runtime on file change]' \ '--no-install[Disable auto install in bun'"'"'s JavaScript runtime]' \ @@ -832,7 +832,7 @@ _bun_test_completion() { '--target[The intended execution environment for the bundle. "browser", "bun" or "node"]: :->target' \ '--inspect[Activate Bun'"'"'s Debugger]:inspect' \ '--inspect-wait[Activate Bun'"'"'s Debugger, wait for a connection before executing]:inspect-wait' \ - '--inspect-brk[Activate Bun'"'"'s Debugger, set breakpoint on first line of code and wait]:inspect-brk' \ + '--inspect-brk[Activate Bun'"'"'s Debugger, wait for a connection and pause before the first line of code]:inspect-brk' \ '--watch[Automatically restart bun'"'"'s JavaScript runtime on file change]' \ '--timeout[Set the per-test timeout in milliseconds, default is 5000.]:timeout' \ '--update-snapshots[Update snapshot files]' \ diff --git a/docs/runtime/debugger.mdx b/docs/runtime/debugger.mdx index d83684a0dca7..39b8d25e5868 100644 --- a/docs/runtime/debugger.mdx +++ b/docs/runtime/debugger.mdx @@ -35,7 +35,9 @@ Inspect in browser: ### `--inspect-brk` -The `--inspect-brk` flag behaves identically to `--inspect`, except it injects a breakpoint at the first line of the executed script. Use it to debug scripts that run quickly and exit immediately. +The `--inspect-brk` flag behaves identically to `--inspect`, except the code does not execute until a debugger attaches, and the debugger then pauses before the first statement of the executed script. Use it to debug scripts that run quickly and exit immediately. + +The pause needs a debugger that has enabled breakpoints. A client of the protocol sends `Debugger.enable` and `Debugger.setBreakpointsActive` with `active: true` before `Inspector.initialized`. `debug.bun.sh` and the VS Code extension do this. ### `--inspect-wait` diff --git a/docs/snippets/cli/run.mdx b/docs/snippets/cli/run.mdx index 20372999dfe4..e85df21d8625 100644 --- a/docs/snippets/cli/run.mdx +++ b/docs/snippets/cli/run.mdx @@ -144,7 +144,7 @@ bun run - Activate Bun's debugger, set breakpoint on first line of code and wait + Activate Bun's debugger, wait for a connection and pause before the first line of code ### Dependency & Module Resolution diff --git a/scripts/build/deps/webkit.ts b/scripts/build/deps/webkit.ts index 0e90a21f3856..027b0ab5972d 100644 --- a/scripts/build/deps/webkit.ts +++ b/scripts/build/deps/webkit.ts @@ -3,7 +3,7 @@ * for local mode. Override via `--webkit-version=` to test a branch. * From https://github.com/oven-sh/WebKit releases. */ -export const WEBKIT_VERSION = "0c06faadf65bf8e8c8ad3a5a8aca83e1e9ed653f"; +export const WEBKIT_VERSION = "autobuild-preview-pr-776-1fbd9966"; /** * WebKit (JavaScriptCore) — the JS engine. diff --git a/src/bundler/transpiler.rs b/src/bundler/transpiler.rs index 92bb829b163b..b65e8226080f 100644 --- a/src/bundler/transpiler.rs +++ b/src/bundler/transpiler.rs @@ -878,7 +878,6 @@ pub struct ParseOptions<'a, 'b> { pub virtual_source: Option<&'b bun_ast::Source>, pub replace_exports: bun_collections::StringArrayHashMap, pub inject_jest_globals: bool, - pub set_breakpoint_on_first_line: bool, pub emit_decorator_metadata: bool, pub experimental_decorators: bool, pub use_define_for_class_fields: bool, @@ -1496,8 +1495,6 @@ impl<'a> Transpiler<'a> { opts.features.standard_decorators = !loader.is_typescript() || !(this_parse.experimental_decorators || this_parse.emit_decorator_metadata); opts.features.allow_runtime = self.options.allow_runtime; - opts.features.set_breakpoint_on_first_line = - this_parse.set_breakpoint_on_first_line; opts.features.trim_unused_imports = self .options .trim_unused_imports @@ -2861,7 +2858,6 @@ impl<'a> Transpiler<'a> { virtual_source: None, replace_exports: Default::default(), inject_jest_globals: false, - set_breakpoint_on_first_line: false, remove_cjs_module_wrapper: false, dont_bundle_twice: false, allow_commonjs: false, diff --git a/src/js_parser/parse/parse_entry.rs b/src/js_parser/parse/parse_entry.rs index a33fa88f2da2..8abee95eaaea 100644 --- a/src/js_parser/parse/parse_entry.rs +++ b/src/js_parser/parse/parse_entry.rs @@ -251,7 +251,6 @@ impl<'a> Options<'a> { minify_keep_names: f.minify_keep_names, minify_whitespace: f.minify_whitespace, dead_code_elimination: f.dead_code_elimination, - set_breakpoint_on_first_line: f.set_breakpoint_on_first_line, trim_unused_imports: f.trim_unused_imports, auto_polyfill_require: f.auto_polyfill_require, replace_exports: Default::default(), @@ -1180,18 +1179,6 @@ impl<'a> Parser<'a> { before.push(js_ast::Part::default()); } - // --inspect-brk - if p.options.features.set_breakpoint_on_first_line { - let debugger_stmts = p.arena.alloc_slice_fill_with(1, |_| Stmt { - data: js_ast::StmtData::SDebugger(Default::default()), - loc: bun_ast::Loc::EMPTY, - }); - before.push(js_ast::Part { - stmts: debugger_stmts.into(), - ..Default::default() - }); - } - // When "using" declarations appear at the top level, we change all TDZ // variables in the top-level scope into "var" so that they aren't harmed // when they are moved into the try/catch statement that lowering will diff --git a/src/js_parser/parser.rs b/src/js_parser/parser.rs index 47fb760609ac..e6460b897fe0 100644 --- a/src/js_parser/parser.rs +++ b/src/js_parser/parser.rs @@ -205,8 +205,6 @@ pub mod Runtime { pub minify_whitespace: bool, pub dead_code_elimination: bool, - pub set_breakpoint_on_first_line: bool, - pub trim_unused_imports: bool, /// Allow runtime usage of require(), converting `require` into `__require` @@ -293,7 +291,6 @@ pub mod Runtime { minify_keep_names: false, minify_whitespace: false, dead_code_elimination: true, - set_breakpoint_on_first_line: false, trim_unused_imports: false, auto_polyfill_require: false, replace_exports: ReplaceableExportMap::default(), @@ -359,7 +356,7 @@ pub mod Runtime { pub(crate) fn hash_for_runtime_transpiler(&self, hasher: &mut Wyhash) { debug_assert!(self.runtime_transpiler_cache.is_some()); - let bools: [bool; 17] = [ + let bools: [bool; 16] = [ self.top_level_await, self.auto_import_jsx, self.allow_runtime, @@ -369,7 +366,6 @@ pub mod Runtime { self.minify_identifiers, self.minify_keep_names, self.dead_code_elimination, - self.set_breakpoint_on_first_line, self.trim_unused_imports, self.dont_bundle_twice, self.commonjs_at_runtime, diff --git a/src/jsc/Debugger.rs b/src/jsc/Debugger.rs index fdb9311bc9b7..5ca675c4930c 100644 --- a/src/jsc/Debugger.rs +++ b/src/jsc/Debugger.rs @@ -121,7 +121,10 @@ pub struct Debugger { pub poll_ref: KeepAlive, pub wait_for_connection: Wait, // wait_for_connection: bool = false, + /// Taken by the first entry load of the process (`schedule_pause_at_entry`). pub set_breakpoint_on_first_line: bool, + /// JSC holds a request to pause when the entry starts to run. + pub pause_at_entry_scheduled: bool, pub mode: Mode, pub protocol: Protocol, @@ -144,6 +147,7 @@ impl Default for Debugger { poll_ref: KeepAlive::default(), wait_for_connection: Wait::Off, set_breakpoint_on_first_line: false, + pause_at_entry_scheduled: false, mode: Mode::Listen, protocol: Protocol::Jsc, test_reporter_agent: TestReporterAgent::default(), @@ -169,6 +173,12 @@ unsafe extern "C" { is_connect: bool, is_node_inspector: bool, ); + safe fn Debugger__schedulePauseAtEntry( + global: &JSGlobalObject, + url: &BunString, + program_code: bool, + ) -> bool; + safe fn Debugger__cancelPauseAtEntry(global: &JSGlobalObject); } static FUTEX_ATOMIC: AtomicU32 = AtomicU32::new(0); @@ -186,6 +196,33 @@ struct DebuggerThreadInit { } impl Debugger { + /// Asks JSC to pause when the top-level code of `vm.main()` starts to run. + pub(crate) fn schedule_pause_at_entry(vm: &VirtualMachine) { + let asked = vm + .debugger_mut() + .is_some_and(|dbg| core::mem::take(&mut dbg.set_breakpoint_on_first_line)); + if !asked { + return; + } + let url = BunString::from_bytes(vm.main()); + // The code of `-e` and stdin runs as a program, not as a module or a CommonJS wrapper. + let program_code = vm.module_loader.eval_source.is_some(); + let scheduled = Debugger__schedulePauseAtEntry(vm.global(), &url, program_code); + if let Some(dbg) = vm.debugger_mut() { + dbg.pause_at_entry_scheduled = scheduled; + } + } + + /// Takes back the request of an earlier load whose entry did not run. + pub(crate) fn cancel_pause_at_entry(vm: &VirtualMachine) { + let scheduled = vm + .debugger_mut() + .is_some_and(|dbg| core::mem::take(&mut dbg.pause_at_entry_scheduled)); + if scheduled { + Debugger__cancelPauseAtEntry(vm.global()); + } + } + /// `Debugger.waitForDebuggerIfNecessary(vm)` — block on the futex until /// `start()` (debugger thread) signals, then run the wait-loop until a /// frontend connects (`Debugger__didConnect`) or the deadline elapses. diff --git a/src/jsc/RuntimeTranspilerCache.rs b/src/jsc/RuntimeTranspilerCache.rs index ff170d4c8c38..b6e93cd56ee2 100644 --- a/src/jsc/RuntimeTranspilerCache.rs +++ b/src/jsc/RuntimeTranspilerCache.rs @@ -64,7 +64,8 @@ bun_core::declare_scope!(cache, visible); /// `onResolve` rewrote (`namespace:path`). Older entries request the bare path, and /// the cache-HIT path reinstates #33904 for them. /// Version 34: An import that a plugin `onResolve` answers is printed as it is written. -const EXPECTED_VERSION: u32 = 34; +/// Version 35: The features hash has no slot for the `--inspect-brk` statement, which the parser no longer adds. +const EXPECTED_VERSION: u32 = 35; /// Source files smaller than this are not written to / read from the on-disk /// transpiler cache. Originally 50 KiB, which excluded almost every file in a diff --git a/src/jsc/RuntimeTranspilerStore.rs b/src/jsc/RuntimeTranspilerStore.rs index 5efa3a7879bb..49c52473bbe3 100644 --- a/src/jsc/RuntimeTranspilerStore.rs +++ b/src/jsc/RuntimeTranspilerStore.rs @@ -3,7 +3,7 @@ use core::cell::Cell; use core::ffi::c_void; use core::ptr::{self, NonNull}; -use core::sync::atomic::{AtomicBool, AtomicU32, Ordering}; +use core::sync::atomic::{AtomicU32, Ordering}; use bun_alloc::Arena; use bun_ast::Loader; @@ -182,15 +182,6 @@ fn dump_source_string_failiable( Ok(()) } -// `pub`: also consumed cross-crate by bun_runtime's ModuleLoader transpile path -// (runtime/jsc_hooks.rs). The one-shot AtomicBool -// must stay shared between that path and this store — whichever transpiles the -// main module first consumes it. -pub fn set_break_point_on_first_line() -> bool { - static SET_BREAK_POINT: AtomicBool = AtomicBool::new(true); - SET_BREAK_POINT.swap(false, Ordering::SeqCst) -} - // ────────────────────────────────────────────────────────────────────────── // RuntimeTranspilerStore // ────────────────────────────────────────────────────────────────────────── @@ -823,13 +814,6 @@ impl TranspilerJob { dont_bundle_twice: true, allow_commonjs: true, inject_jest_globals: transpiler.options.rewrite_jest_for_tests, - // SAFETY: leaf-field `&` borrow on `*vm.debugger`; see `vm` note above. - set_breakpoint_on_first_line: unsafe { &(*vm).debugger } - .as_ref() - .map(|d| d.set_breakpoint_on_first_line) - .unwrap_or(false) - && is_main - && set_break_point_on_first_line(), runtime_transpiler_cache: if !JscRuntimeTranspilerCache::is_disabled() { // SAFETY: `cache` is a stack local declared above and outlives // `parse_options`; `addr_of_mut!` avoids an intermediate `&mut` diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 2fe2f3b62a18..0c68a1853741 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -3409,6 +3409,9 @@ impl VirtualMachine { let hooks = runtime_hooks(); let _ = self.ensure_debugger(true); + // Before the preloads: one of them can import the entry. + crate::debugger::Debugger::cancel_pause_at_entry(self); + crate::debugger::Debugger::schedule_pause_at_entry(self); // Node.js `--trace-*` and `--stack-trace-limit` flags need // `internal/process/pre_execution` to run before any user code. @@ -5627,6 +5630,9 @@ impl VirtualMachine { self.event_loop_mut().ensure_waker(); let _ = self.ensure_debugger(true); + // As in `reload_entry_point`. + crate::debugger::Debugger::cancel_pause_at_entry(self); + crate::debugger::Debugger::schedule_pause_at_entry(self); if !self.transpiler.options.disable_transpilation { if let Some(hooks) = runtime_hooks() { diff --git a/src/jsc/bindings/BunDebugger.cpp b/src/jsc/bindings/BunDebugger.cpp index a94bd5a78b51..90c632fe724c 100644 --- a/src/jsc/bindings/BunDebugger.cpp +++ b/src/jsc/bindings/BunDebugger.cpp @@ -1017,6 +1017,19 @@ extern "C" void Debugger__willDispatchAsyncCall(JSGlobalObject* globalObject, As agent->willDispatchAsyncCall(getCallType(callType), callbackId); } +// False when no frontend enabled the debugger with breakpoints active: nothing can pause then. +extern "C" bool Debugger__schedulePauseAtEntry(JSGlobalObject* globalObject, const BunString* url, bool programCode) +{ + auto* agent = debuggerAgent(globalObject); + return agent && agent->schedulePauseAtEntry(url->toWTFString(), programCode); +} + +extern "C" void Debugger__cancelPauseAtEntry(JSGlobalObject* globalObject) +{ + if (auto* agent = debuggerAgent(globalObject)) + agent->cancelPauseAtEntry(); +} + extern "C" void Bun__InspectorConnection__disconnectAllOnExit(Zig::GlobalObject* globalObject) { // Snapshot under the lock, release before calling into the inspector — diff --git a/src/runtime/api/JSTranspiler.rs b/src/runtime/api/JSTranspiler.rs index df5d4bc5f529..721cbeb58fdd 100644 --- a/src/runtime/api/JSTranspiler.rs +++ b/src/runtime/api/JSTranspiler.rs @@ -783,7 +783,6 @@ impl TransformTask { macro_js_ctx: MacroJSCtx::ZERO, file_fd_ptr: None, inject_jest_globals: false, - set_breakpoint_on_first_line: false, remove_cjs_module_wrapper: false, dont_bundle_twice: false, allow_commonjs: false, @@ -1246,7 +1245,6 @@ impl JSTranspiler { .unwrap_or(true), file_fd_ptr: None, inject_jest_globals: false, - set_breakpoint_on_first_line: false, remove_cjs_module_wrapper: false, dont_bundle_twice: false, allow_commonjs: false, diff --git a/src/runtime/cli/Arguments.rs b/src/runtime/cli/Arguments.rs index bd5582050b59..eb25e979b87a 100644 --- a/src/runtime/cli/Arguments.rs +++ b/src/runtime/cli/Arguments.rs @@ -175,7 +175,7 @@ const RUNTIME_PARAMS_: &[ParamType] = &[ "--inspect-wait ? Activate Bun's debugger, wait for a connection before executing" ), parse_param!( - "--inspect-brk ? Activate Bun's debugger, set breakpoint on first line of code and wait" + "--inspect-brk ? Activate Bun's debugger, wait for a connection and pause before the first line of code" ), parse_param!( "--cpu-prof Start CPU profiler and write profile to disk on exit" diff --git a/src/runtime/jsc_hooks.rs b/src/runtime/jsc_hooks.rs index fee548e001d3..3f4ffbc1f945 100644 --- a/src/runtime/jsc_hooks.rs +++ b/src/runtime/jsc_hooks.rs @@ -2475,18 +2475,6 @@ fn transpile_source_code_inner( use bun_bundler::transpiler::{AlreadyBundled, ParseOptions, ParseResult}; use bun_jsc::resolved_source::Tag as ResolvedSourceTag; - // Keep the one-shot - // `set_break_point_on_first_line()` last so it is only - // consumed when the other conditions hold. - // SAFETY: per fn contract — leaf-field `&` borrow on - // `(*jsc_vm).debugger` (same shape as RuntimeTranspilerStore). - let set_breakpoint_on_first_line = is_main - && unsafe { &(*jsc_vm).debugger } - .as_ref() - .map(|d| d.set_breakpoint_on_first_line) - .unwrap_or(false) - && bun_jsc::runtime_transpiler_store::set_break_point_on_first_line(); - // `ParseOptions::path` is `bun_paths::fs::Path<'static>` // (the `'static`-slice flavour used by `bun_ast::Source`), but // `path` here is `bun_resolver::fs::Path<'_>`. The two structs @@ -2582,7 +2570,6 @@ fn transpile_source_code_inner( }, keep_json_and_toml_as_one_statement: true, allow_bytecode_cache: true, - set_breakpoint_on_first_line, runtime_transpiler_cache: if !disable_transpilying && !::disabled() { diff --git a/test/cli/inspect/inspect-brk.test.ts b/test/cli/inspect/inspect-brk.test.ts new file mode 100644 index 000000000000..53700b7c619d --- /dev/null +++ b/test/cli/inspect/inspect-brk.test.ts @@ -0,0 +1,822 @@ +// `--inspect-brk` and `BUN_INSPECT=?break=1` pause the program before the first +// statement of the entry. The debugger makes that pause when the entry's code starts to run: +// the entry's text is the same as under `--inspect-wait`, and the pause does not depend on +// `Debugger.setPauseOnDebuggerStatements`. A client has to send `Debugger.enable` and +// `Debugger.setBreakpointsActive{active:true}` before `Inspector.initialized`. +import { spawn, type Subprocess } from "bun"; +import { describe, expect, setDefaultTimeout, test } from "bun:test"; +import { bunEnv, bunExe, isASAN, isDebug, isWindows, tempDir } from "harness"; +import type { ChildProcess } from "node:child_process"; +import { once } from "node:events"; +import { realpathSync, writeFileSync } from "node:fs"; +import { basename, join } from "node:path"; +import { WebSocketDebugAdapter } from "../../../packages/bun-debug-adapter-protocol"; +import { SocketFramer } from "./socket-framer"; + +type Pause = { + reason: string; + /** Basename of the script of the top call frame. */ + file: string; + /** 1-based, in the text that Debugger.getScriptSource returns. */ + line: number; + column: number; + callFrameId: string; + scriptId: string; +}; + +type Handshake = { + /** Debugger.setBreakpointsActive. `null` sends nothing. */ + breakpointsActive?: boolean | null; + /** Debugger.setPauseOnDebuggerStatements{enabled:true}. */ + pauseOnDebuggerStatements?: boolean; + /** Debugger.setPauseOnMicrotasks{enabled:true}. */ + pauseOnMicrotasks?: boolean; + /** Breakpoints by URL, set before the program runs. `line` is 1-based. */ + breakpoints?: { file: string; line: number }[]; +}; + +type Options = { + exe?: string; + stdin?: string; + env?: Record; + /** Give the inspectee `BUN_INSPECT=tcp://?break=1` instead of a flag. */ + breakFromEnv?: boolean; + handshake?: Handshake; +}; + +type Transport = { send(message: string): void; close(): void }; + +/** One inspectee in a directory of its own, and the inspector session with it. */ +class Session implements AsyncDisposable { + readonly pauses: Pause[] = []; + stdout = ""; + stderr = ""; + #dir: ReturnType; + #proc: Subprocess<"ignore" | Blob, "pipe", "pipe">; + #transport: Transport | undefined; + #nextId = 1; + #pending = new Map void; reject: (error: Error) => void }>(); + #scripts = new Map(); + #unread = 0; + #wake: Array<() => void> = []; + #drained: Promise; + /** Rejects once the inspectee has exited and its output is read. */ + #exited = Promise.withResolvers(); + + private constructor(dir: ReturnType, proc: Subprocess<"ignore" | Blob, "pipe", "pipe">) { + this.#dir = dir; + this.#proc = proc; + this.#exited.promise.catch(() => {}); + const pump = async (stream: ReadableStream, sink: "stdout" | "stderr") => { + const decoder = new TextDecoder(); + for await (const chunk of stream) { + this[sink] += decoder.decode(chunk, { stream: true }); + this.#wakeAll(); + } + }; + this.#drained = Promise.allSettled([pump(proc.stdout, "stdout"), pump(proc.stderr, "stderr")]); + proc.exited.then(async code => { + await this.#drained; + const error = new Error( + `the inspectee exited (${code ?? proc.signalCode})\nstdout:\n${this.stdout}\nstderr:\n${this.stderr}`, + ); + this.#exited.reject(error); + for (const { reject } of this.#pending.values()) reject(error); + this.#pending.clear(); + }); + } + + #wakeAll() { + this.#wake.splice(0).forEach(wake => wake()); + } + + #woken(): Promise { + return Promise.race([new Promise(resolve => this.#wake.push(resolve)), this.#exited.promise]); + } + + #receive(text: string) { + const message = JSON.parse(text); + if (typeof message.id === "number") { + const pending = this.#pending.get(message.id); + this.#pending.delete(message.id); + if (message.error) pending?.reject(new Error(String(message.error.message))); + else pending?.resolve(message.result); + } else if (message.method === "Debugger.scriptParsed") { + this.#scripts.set(message.params.scriptId, message.params.url); + } else if (message.method === "Debugger.paused") { + const { callFrameId, location } = message.params.callFrames[0]; + this.pauses.push({ + reason: message.params.reason, + file: basename(this.#scripts.get(location.scriptId) ?? "?"), + line: location.lineNumber + 1, + column: location.columnNumber + 1, + callFrameId, + scriptId: location.scriptId, + }); + this.#wakeAll(); + } + } + + /** Writes `files` to a new directory, runs `bun ...args` in it and does the handshake of a debugger client. */ + static async start(files: Record, args: string[], options: Options = {}) { + const dir = tempDir("inspect-brk", files); + const cwd = realpathSync(String(dir)); + const env: Record = { ...bunEnv, ...options.env }; + const connected = Promise.withResolvers(); + connected.promise.catch(() => {}); + let receive = (_text: string) => {}; + let listener: ReturnType | undefined; + if (options.breakFromEnv) { + const framer = new SocketFramer(text => receive(text)); + listener = Bun.listen({ + hostname: "127.0.0.1", + port: 0, + socket: { + open(socket) { + connected.resolve({ send: message => framer.send(socket, message), close: () => socket.end() }); + }, + data(socket, bytes) { + framer.onData(socket, bytes); + }, + close() { + connected.reject(new Error("the inspector connection closed")); + }, + }, + }); + env.BUN_INSPECT = `tcp://127.0.0.1:${listener.port}?break=1`; + } + + const proc = spawn({ + cmd: [options.exe ?? bunExe(), ...args], + cwd, + env, + stdin: options.stdin === undefined ? "ignore" : new Blob([options.stdin]), + stdout: "pipe", + stderr: "pipe", + }); + const session = new Session(dir, proc); + receive = text => session.#receive(text); + try { + if (!options.breakFromEnv) { + const [, url] = await session.output("stderr", /^\s*(ws:\/\/\S+)\s*$/m); + // This header stops the connection from keeping the inspectee alive after its program ends. + const ws = new WebSocket(url, { headers: { "Ref-Event-Loop": "0" } } as any); + ws.onopen = () => connected.resolve({ send: message => ws.send(message), close: () => ws.close() }); + ws.onmessage = event => receive(String(event.data)); + ws.onerror = () => connected.reject(new Error("the inspector WebSocket failed")); + ws.onclose = () => connected.reject(new Error("the inspector WebSocket closed")); + } + session.#transport = await Promise.race([connected.promise, session.#exited.promise]); + + const { + breakpointsActive = true, + pauseOnDebuggerStatements, + pauseOnMicrotasks, + breakpoints = [], + } = options.handshake ?? {}; + await session.send("Inspector.enable"); + await session.send("Runtime.enable"); + await session.send("Debugger.enable"); + if (breakpointsActive !== null) + await session.send("Debugger.setBreakpointsActive", { active: breakpointsActive }); + if (pauseOnDebuggerStatements) await session.send("Debugger.setPauseOnDebuggerStatements", { enabled: true }); + if (pauseOnMicrotasks) await session.send("Debugger.setPauseOnMicrotasks", { enabled: true }); + for (const { file, line } of breakpoints) { + await session.send("Debugger.setBreakpointByUrl", { + url: join(cwd, file), + lineNumber: line - 1, + columnNumber: 0, + }); + } + // The program runs from here. It can end before this answer arrives. + session.send("Inspector.initialized").catch(() => {}); + } catch (error) { + await session[Symbol.asyncDispose](); + throw error; + } finally { + listener?.stop(); + } + return session; + } + + send(method: string, params: Record = {}): Promise { + const id = this.#nextId++; + const { promise, resolve, reject } = Promise.withResolvers(); + this.#pending.set(id, { resolve, reject }); + this.#transport!.send(JSON.stringify({ id, method, params })); + return Promise.race([promise, this.#exited.promise]); + } + + /** + * The next pause. Rejects when the inspectee exits before it pauses again, or when it prints + * `unless` first (for an inspectee that does not exit by itself). + */ + async paused(unless?: RegExp): Promise { + while (this.#unread >= this.pauses.length) { + if (unless?.test(this.stdout)) throw new Error(`the inspectee did not pause before it printed ${unless}`); + await this.#woken(); + } + return this.pauses[this.#unread++]; + } + + /** Resolves once `pattern` is in what the inspectee wrote to `sink`. Rejects when it exits first. */ + async output(sink: "stdout" | "stderr", pattern: RegExp): Promise { + for (;;) { + const match = this[sink].match(pattern); + if (match) return match; + await this.#woken(); + } + } + + /** `String(expression)` in the frame of `pause`, or the name of the error it throws. */ + async evaluate(pause: Pause, expression: string): Promise { + const { result } = await this.send("Debugger.evaluateOnCallFrame", { + callFrameId: pause.callFrameId, + expression: `(() => { try { return String(${expression}); } catch (e) { return e.name; } })()`, + returnByValue: true, + }); + return result.value; + } + + async scriptSource(pause: Pause): Promise { + const { scriptSource } = await this.send("Debugger.getScriptSource", { scriptId: pause.scriptId }); + return scriptSource; + } + + /** Resumes (or steps) and returns the pause that follows. */ + async resumeToPause(method = "Debugger.resume"): Promise { + await this.send(method); + return await this.paused(); + } + + /** Lets the program run to its end: what it printed, its exit code, and every pause of the session. */ + async finish(resume = true) { + // The inspectee may exit before it answers. + if (resume) this.send("Debugger.resume").catch(() => {}); + const exitCode = await this.#proc.exited; + await this.#drained; + return { stdout: this.stdout, exitCode, pauses: this.pauses.map(where) }; + } + + disconnect() { + this.#transport?.close(); + } + + /** The directory of the inspectee. */ + get cwd() { + return realpathSync(String(this.#dir)); + } + + async [Symbol.asyncDispose]() { + this.#transport?.close(); + this.#proc.kill(); + await this.#proc.exited; + await this.#drained; + this.#dir[Symbol.dispose](); + } +} + +// Every test starts a bun with its inspector. A debug build needs a second for that alone, and +// the tests of this file start together. +if (isDebug || isASAN) setDefaultTimeout(30_000); + +const where = ({ reason, file, line, column }: Pause) => ({ reason, file, line, column }); +const brk = "--inspect-brk=127.0.0.1:0"; +const start = "PauseOnNextStatement"; + +// The fixture of https://github.com/oven-sh/bun/issues/32591. +const fiveLines = [ + 'const label = "x";', + "const values = [2, 3, 5];", + "const total = values.reduce((s, v) => s + v, 0);", + "const payload = { label, values, total };", + "console.log(payload.total);", + "", +].join("\n"); + +describe.concurrent("--inspect-brk", () => { + test("pauses before the first statement of an ES module, with no opt-in for debugger statements", async () => { + await using session = await Session.start({ "entry.mjs": `globalThis.ran = 1;\nconsole.log("done");\n` }, [ + brk, + "entry.mjs", + ]); + const pause = await session.paused(); + expect(where(pause)).toEqual({ reason: start, file: "entry.mjs", line: 1, column: 1 }); + expect(await session.evaluate(pause, "typeof globalThis.ran")).toBe("undefined"); + expect(await session.finish()).toEqual({ + stdout: "done\n", + exitCode: 0, + pauses: [{ reason: start, file: "entry.mjs", line: 1, column: 1 }], + }); + }); + + test("BUN_INSPECT=?break=1 pauses the same way", async () => { + await using session = await Session.start( + { "entry.mjs": `globalThis.ran = 1;\nconsole.log("done");\n` }, + ["entry.mjs"], + { breakFromEnv: true }, + ); + const pause = await session.paused(/^done$/m); + expect(where(pause)).toEqual({ reason: start, file: "entry.mjs", line: 1, column: 1 }); + expect(await session.evaluate(pause, "typeof globalThis.ran")).toBe("undefined"); + // This connection keeps the inspectee alive, so the end of the program is what it printed. + await session.send("Debugger.resume"); + await session.output("stdout", /^done$/m); + expect(session.pauses).toHaveLength(1); + }); + + test("pauses in a CommonJS entry before its first statement", async () => { + await using session = await Session.start( + { "entry.cjs": `globalThis.ran = 1;\nmodule.exports = 2;\nconsole.log("done");\n` }, + [brk, "entry.cjs"], + ); + const pause = await session.paused(); + expect({ reason: pause.reason, file: pause.file, line: pause.line }).toEqual({ + reason: start, + file: "entry.cjs", + line: 1, + }); + // The frame is the module's function: its arguments are there, and nothing of its body ran. + expect(await session.evaluate(pause, "[typeof module, typeof require, typeof globalThis.ran]")).toBe( + "object,function,undefined", + ); + expect(await session.finish()).toMatchObject({ stdout: "done\n", exitCode: 0 }); + expect(session.pauses).toHaveLength(1); + }); + + test("pauses in a TypeScript entry", async () => { + await using session = await Session.start( + { "entry.ts": `const n: number = 1;\nglobalThis.ran = n;\nconsole.log("done");\n` }, + [brk, "entry.ts"], + ); + const pause = await session.paused(); + expect(where(pause)).toEqual({ reason: start, file: "entry.ts", line: 1, column: 1 }); + expect(await session.evaluate(pause, "typeof n")).toBe("ReferenceError"); + expect(await session.finish()).toMatchObject({ stdout: "done\n", exitCode: 0 }); + expect(session.pauses).toHaveLength(1); + }); + + test("pauses at the start of -e code, not in a builtin module that it loads", async () => { + await using session = await Session.start({}, [ + brk, + "-e", + `const fs = require("fs"); globalThis.ran = 1; console.log(typeof fs.readFileSync);`, + ]); + const pause = await session.paused(); + expect(where(pause)).toEqual({ reason: start, file: "[eval]", line: 1, column: 1 }); + expect(await session.evaluate(pause, "typeof globalThis.ran")).toBe("undefined"); + expect(await session.finish()).toMatchObject({ stdout: "function\n", exitCode: 0 }); + expect(session.pauses).toHaveLength(1); + }); + + test("pauses at the start of -e code that is an ES module", async () => { + await using session = await Session.start({}, [ + brk, + "-e", + `import { sep } from "node:path"; globalThis.ran = 1; console.log(typeof sep);`, + ]); + const pause = await session.paused(); + expect(where(pause)).toEqual({ reason: start, file: "[eval]", line: 1, column: 1 }); + expect(await session.evaluate(pause, "typeof globalThis.ran")).toBe("undefined"); + expect(await session.finish()).toMatchObject({ stdout: "string\n", exitCode: 0 }); + expect(session.pauses).toHaveLength(1); + }); + + test("pauses at the start of a script from stdin", async () => { + await using session = await Session.start({}, [brk, "-"], { + stdin: `globalThis.ran = 1;\nconsole.log("done");\n`, + }); + const pause = await session.paused(); + expect(where(pause)).toEqual({ reason: start, file: "[stdin]", line: 1, column: 1 }); + expect(await session.evaluate(pause, "typeof globalThis.ran")).toBe("undefined"); + expect(await session.finish()).toMatchObject({ stdout: "done\n", exitCode: 0 }); + expect(session.pauses).toHaveLength(1); + }); + + // The parser does not read an entry with the `// @bun` pragma (the output of `bun build --target=bun`). + test.each([ + ["// @bun", `// @bun\nglobalThis.ran = 1;\nconsole.log("done");\n`], + [ + "// @bun @bun-cjs", + `// @bun @bun-cjs\n(function(exports, require, module, __filename, __dirname) {globalThis.ran = 1;\nconsole.log("done");\n})\n`, + ], + ])("pauses in an entry that starts with %s", async (_pragma, source) => { + await using session = await Session.start({ "entry.js": source }, [brk, "entry.js"]); + const pause = await session.paused(); + expect({ reason: pause.reason, file: pause.file }).toEqual({ reason: start, file: "entry.js" }); + expect(await session.evaluate(pause, "typeof globalThis.ran")).toBe("undefined"); + expect(await session.finish()).toMatchObject({ stdout: "done\n", exitCode: 0 }); + expect(session.pauses).toHaveLength(1); + }); + + test("an entry that starts with a class pauses before the class", async () => { + await using session = await Session.start( + { + "entry.mjs": `class Foo { static x = compute(); }\nfunction compute() { return 1; }\nconsole.log(Foo.x);\n`, + }, + [brk, "entry.mjs"], + ); + const pause = await session.paused(); + expect(where(pause)).toEqual({ reason: start, file: "entry.mjs", line: 1, column: 1 }); + expect(await session.evaluate(pause, "typeof Foo")).toBe("ReferenceError"); + expect(await session.finish()).toMatchObject({ stdout: "1\n", exitCode: 0 }); + expect(session.pauses).toHaveLength(1); + }); + + test("an empty entry pauses once", async () => { + await using session = await Session.start({ "entry.mjs": "" }, [brk, "entry.mjs"]); + const pause = await session.paused(); + expect({ reason: pause.reason, file: pause.file }).toEqual({ reason: start, file: "entry.mjs" }); + expect(await session.finish()).toMatchObject({ stdout: "", exitCode: 0 }); + expect(session.pauses).toHaveLength(1); + }); +}); + +describe.concurrent("--inspect-brk: the code that runs before the entry", () => { + test("a preload does not take the pause", async () => { + await using session = await Session.start( + { "preload.mjs": `globalThis.preloaded = 1;\n`, "entry.mjs": `console.log(globalThis.preloaded);\n` }, + [brk, "--preload", "./preload.mjs", "entry.mjs"], + ); + const pause = await session.paused(); + expect(where(pause)).toEqual({ reason: start, file: "entry.mjs", line: 1, column: 1 }); + expect(await session.evaluate(pause, "globalThis.preloaded")).toBe("1"); + expect(await session.finish()).toMatchObject({ stdout: "1\n", exitCode: 0 }); + expect(session.pauses).toHaveLength(1); + }); + + test("a preload that imports the entry does not lose the pause", async () => { + await using session = await Session.start( + { + "preload.mjs": `await import("./entry.mjs");\n`, + "entry.mjs": `globalThis.ran = 1;\nconsole.log("done");\n`, + }, + [brk, "--preload", "./preload.mjs", "entry.mjs"], + ); + const pause = await session.paused(); + expect(where(pause)).toEqual({ reason: start, file: "entry.mjs", line: 1, column: 1 }); + expect(await session.evaluate(pause, "typeof globalThis.ran")).toBe("undefined"); + expect(await session.finish()).toMatchObject({ stdout: "done\n", exitCode: 0 }); + expect(session.pauses).toHaveLength(1); + }); + + test("a patched Module.runMain that loads the entry later does not lose the pause", async () => { + await using session = await Session.start( + { + "preload.cjs": `require("module").runMain = () => {\n setImmediate(() => require(process.argv[1]));\n};\n`, + "entry.cjs": `globalThis.ran = 1;\nconsole.log("done");\n`, + }, + [brk, "--preload", "./preload.cjs", "entry.cjs"], + ); + const pause = await session.paused(); + expect({ reason: pause.reason, file: pause.file, line: pause.line }).toEqual({ + reason: start, + file: "entry.cjs", + line: 1, + }); + expect(await session.evaluate(pause, "typeof globalThis.ran")).toBe("undefined"); + expect(await session.finish()).toMatchObject({ stdout: "done\n", exitCode: 0 }); + expect(session.pauses).toHaveLength(1); + }); + + test("a dependency that calls a function of the entry first does not take the pause", async () => { + await using session = await Session.start( + { + "dep.mjs": `import { early } from "./entry.mjs";\nglobalThis.fromDep = early();\n`, + "entry.mjs": `import "./dep.mjs";\nexport function early() {\n return 41;\n}\nconsole.log(globalThis.fromDep + 1);\n`, + }, + [brk, "entry.mjs"], + ); + // The import cycle runs `early` before the module code of the entry. The pause is for the module code. + const pause = await session.paused(); + expect(where(pause)).toEqual({ reason: start, file: "entry.mjs", line: 1, column: 1 }); + expect(await session.evaluate(pause, "globalThis.fromDep")).toBe("41"); + expect(await session.finish()).toMatchObject({ stdout: "42\n", exitCode: 0 }); + expect(session.pauses).toHaveLength(1); + }); + + test("bun test pauses in the first test file only", async () => { + const testFile = `import { expect, test } from "bun:test";\nglobalThis.ran = 1;\ntest("t", () => {\n expect(1).toBe(1);\n});\n`; + await using session = await Session.start({ "a.test.js": testFile, "b.test.js": testFile }, [ + brk, + "test", + "./a.test.js", + "./b.test.js", + ]); + const pause = await session.paused(); + expect(where(pause)).toEqual({ reason: start, file: "a.test.js", line: 1, column: 1 }); + expect(await session.evaluate(pause, "typeof globalThis.ran")).toBe("undefined"); + expect(await session.finish()).toMatchObject({ exitCode: 0 }); + expect(session.stderr).toContain("2 pass"); + expect(session.pauses).toHaveLength(1); + }); + + test("a --hot reload does not pause again", async () => { + await using session = await Session.start({ "entry.mjs": `console.log("first");\n` }, [brk, "--hot", "entry.mjs"]); + expect(where(await session.paused(/^first$/m))).toEqual({ + reason: start, + file: "entry.mjs", + line: 1, + column: 1, + }); + await session.send("Debugger.resume"); + await session.output("stdout", /^first$/m); + + // A second pause would hold the reloaded entry before it prints. + writeFileSync(join(session.cwd, "entry.mjs"), `console.log("second");\n`); + await session.output("stdout", /^second$/m); + // The same after a reload that failed to parse. + writeFileSync(join(session.cwd, "entry.mjs"), `console.log("third";\n`); + await session.output("stderr", /error: /); + writeFileSync(join(session.cwd, "entry.mjs"), `console.log("fourth");\n`); + await session.output("stdout", /^fourth$/m); + expect(session.pauses).toHaveLength(1); + }); +}); + +describe.concurrent("--inspect-brk: the handshake of the client", () => { + const files = { "entry.mjs": `debugger;\nconsole.log("done");\n` }; + + // Code has debug hooks only while breakpoints are active, so nothing can pause without them. + test.each([ + ["Debugger.setBreakpointsActive{active:false}", false], + ["no Debugger.setBreakpointsActive", null], + ])("%s: no pause", async (_name, breakpointsActive) => { + await using session = await Session.start(files, [brk, "entry.mjs"], { handshake: { breakpointsActive } }); + expect(await session.finish(false)).toEqual({ stdout: "done\n", exitCode: 0, pauses: [] }); + }); + + test("without Debugger.setPauseOnDebuggerStatements, a debugger statement of the program does not pause", async () => { + await using session = await Session.start(files, [brk, "entry.mjs"]); + await session.paused(); + expect(await session.finish()).toEqual({ + stdout: "done\n", + exitCode: 0, + pauses: [{ reason: start, file: "entry.mjs", line: 1, column: 1 }], + }); + }); + + test("with Debugger.setPauseOnDebuggerStatements, a debugger statement on line 1 pauses after the start pause", async () => { + await using session = await Session.start(files, [brk, "entry.mjs"], { + handshake: { pauseOnDebuggerStatements: true }, + }); + await session.paused(); + expect(where(await session.resumeToPause())).toEqual({ + reason: "DebuggerStatement", + file: "entry.mjs", + line: 1, + column: 1, + }); + expect(await session.finish()).toMatchObject({ stdout: "done\n", exitCode: 0 }); + expect(session.pauses).toHaveLength(2); + }); +}); + +describe.concurrent("--inspect-brk: after the pause", () => { + const files = { "entry.mjs": `const a = 1;\nconst b = a + 1;\nconsole.log(a + b);\n` }; + + test("a step goes to the second statement", async () => { + await using session = await Session.start(files, [brk, "entry.mjs"]); + const first = await session.paused(); + const second = await session.resumeToPause("Debugger.stepNext"); + // The first statement ran and the second did not: the step did not stop at 1:1 again. + expect([await session.evaluate(second, "a"), await session.evaluate(second, "b")]).toEqual(["1", "ReferenceError"]); + const third = await session.resumeToPause("Debugger.stepNext"); + expect([first, second, third].map(({ line, column }) => `${line}:${column}`)).toEqual(["1:1", "2:1", "3:1"]); + expect(await session.finish()).toMatchObject({ stdout: "3\n", exitCode: 0 }); + }); + + // A client that puts a breakpoint at the start of a script (the VS Code extension does, to + // hold the script until its breakpoints are in place) can still tell the pause it asked for. + test("a breakpoint on the first line is the same pause, with the reason of the start pause", async () => { + await using session = await Session.start(files, [brk, "entry.mjs"], { + handshake: { breakpoints: [{ file: "entry.mjs", line: 1 }] }, + }); + const pause = await session.paused(); + expect(where(pause)).toEqual({ reason: start, file: "entry.mjs", line: 1, column: 1 }); + expect(await session.evaluate(pause, "typeof a")).toBe("ReferenceError"); + expect(await session.finish()).toMatchObject({ stdout: "3\n", exitCode: 0 }); + expect(session.pauses).toHaveLength(1); + }); + + test("a breakpoint on the line of a CommonJS module's function does not rename the pause", async () => { + await using session = await Session.start( + { "entry.cjs": `globalThis.ran = 1;\nconsole.log("done");\n` }, + [brk, "entry.cjs"], + { handshake: { breakpoints: [{ file: "entry.cjs", line: 1 }] } }, + ); + // The program that makes the module's function is on that line too, and it is not the entry's code. + const program = await session.paused(); + expect({ reason: program.reason, file: program.file, line: program.line }).toEqual({ + reason: "Breakpoint", + file: "entry.cjs", + line: 1, + }); + const pause = await session.resumeToPause(); + expect({ reason: pause.reason, file: pause.file, line: pause.line }).toEqual({ + reason: start, + file: "entry.cjs", + line: 1, + }); + expect(await session.evaluate(pause, "[typeof module, typeof globalThis.ran]")).toBe("object,undefined"); + expect(await session.finish()).toMatchObject({ stdout: "done\n", exitCode: 0 }); + expect(session.pauses).toHaveLength(2); + }); + + test("an entry that started with breakpoints inactive does not pause later", async () => { + await using session = await Session.start( + { + "entry.cjs": `console.log("started");\nconst timer = setInterval(() => {\n if (!globalThis.breakpointsAreBack) return;\n clearInterval(timer);\n console.log("ran on");\n}, 1);\n`, + }, + [brk, "entry.cjs"], + { handshake: { breakpoints: [{ file: "entry.cjs", line: 1 }] } }, + ); + // The breakpoint stops in the program that makes the module's function, before the entry starts. + expect((await session.paused()).reason).toBe("Breakpoint"); + await session.send("Debugger.setBreakpointsActive", { active: false }); + await session.send("Debugger.resume"); + await session.output("stdout", /^started$/m); + // The timer callback is a function of the entry, and it is compiled again with debug hooks. + await session.send("Debugger.setBreakpointsActive", { active: true }); + // The program ends right after this. It can exit before the answer arrives. + session.send("Runtime.evaluate", { expression: "globalThis.breakpointsAreBack = true" }).catch(() => {}); + const outcome = await Promise.race([ + session.output("stdout", /^ran on$/m).then(() => "ran on"), + session.paused().then(where), + ]); + expect(outcome).toBe("ran on"); + expect(await session.finish(false)).toMatchObject({ exitCode: 0 }); + expect(session.pauses).toHaveLength(1); + }); + + test("the program runs on when the debugger disconnects", async () => { + await using session = await Session.start(files, [brk, "entry.mjs"]); + await session.paused(); + session.disconnect(); + expect(await session.finish(false)).toMatchObject({ stdout: "3\n", exitCode: 0 }); + }); + + // https://github.com/oven-sh/bun/issues/32591 + test("the entry has the text it has under --inspect-wait, so a breakpoint by line stops on that line", async () => { + await using session = await Session.start({ "entry.mjs": fiveLines }, [brk, "entry.mjs"], { + handshake: { breakpoints: [{ file: "entry.mjs", line: 5 }] }, + }); + const first = await session.paused(); + expect((await session.scriptSource(first)).split("\n").slice(0, 5)).toEqual([ + `const label = "x";`, + `const values = [2, 3, 5];`, + `const total = values.reduce((s, v) => s + v, 0);`, + `const payload = { label: "x", values, total };`, + `console.log(payload.total);`, + ]); + const atBreakpoint = await session.resumeToPause(); + expect(where(atBreakpoint)).toEqual({ reason: "Breakpoint", file: "entry.mjs", line: 5, column: 1 }); + // Line 4 ran: the bug was a pause one statement early, with `payload` not initialised. + expect(await session.evaluate(atBreakpoint, "payload.total")).toBe("10"); + expect(await session.finish()).toMatchObject({ stdout: "10\n", exitCode: 0 }); + expect(session.pauses).toHaveLength(2); + }); +}); + +/** + * Runs `program` with the debug adapter of the VS Code extension and `"stopOnEntry": true`, the + * way VS Code drives it, and resumes at each stop. Returns what the user sees: every stop until + * the program prints `lastOutput`. + */ +async function stopsInDebugAdapter( + files: Record, + program: string, + lastOutput: string, + /** 0 for no breakpoint. */ + breakpointLine: number, +) { + using dir = tempDir("inspect-brk-adapter", files); + const cwd = realpathSync(String(dir)); + const adapter = new WebSocketDebugAdapter(); + const stops: { reason: string; line: number }[] = []; + const ended = Promise.withResolvers(); + let debuggee: ChildProcess | undefined; + adapter.on("Process.spawned", subprocess => { + debuggee = subprocess; + }); + const pending = new Map void>(); + let seq = 0; + const request = (command: string, args: object = {}) => { + const { promise, resolve } = Promise.withResolvers(); + pending.set(++seq, resolve); + adapter.emit("Adapter.request", { seq, type: "request", command, arguments: args } as any); + return promise; + }; + adapter.on("Adapter.response", (response: any) => { + pending.get(response.request_seq)?.(response); + pending.delete(response.request_seq); + }); + adapter.on("Adapter.event", async (event: any) => { + try { + if (event.event === "initialized") { + if (breakpointLine) { + await request("setBreakpoints", { + source: { path: join(cwd, program) }, + breakpoints: [{ line: breakpointLine }], + }); + } + // No filter: the "Debugger Statements" box of VS Code is not checked. + await request("setExceptionBreakpoints", { filters: [] }); + await request("configurationDone"); + } else if (event.event === "stopped") { + const { threadId } = event.body; + const { body } = await request("stackTrace", { threadId }); + stops.push({ reason: event.body.reason, line: body.stackFrames[0].line }); + await request("continue", { threadId }); + } else if (event.event === "output" && String(event.body.output).includes(lastOutput)) { + ended.resolve(); + } else if (event.event === "terminated") { + ended.reject(new Error(`the program ended before it printed ${lastOutput}`)); + } + } catch (error) { + ended.reject(error); + } + }); + try { + await request("initialize", { + clientID: "vscode", + adapterID: "bun", + linesStartAt1: true, + columnsStartAt1: true, + pathFormat: "path", + supportsConfigurationDoneRequest: true, + }); + await request("launch", { + runtime: bunExe(), + program: join(cwd, program), + cwd, + stopOnEntry: true, + env: bunEnv, + strictEnv: true, + }); + await ended.promise; + } finally { + // The adapter keeps the program alive after its last statement, and close() kills it. + const exited = debuggee?.exitCode === null && debuggee.signalCode === null ? once(debuggee, "exit") : undefined; + adapter.close(); + await exited; + } + return stops; +} + +// The extension puts a breakpoint of its own at the start of a file that has breakpoints, and it +// resumes when that one pauses. The stop on entry must not look like that breakpoint. +describe.concurrent("--inspect-brk through the debug adapter of the VS Code extension", () => { + const source = `const a = 1;\nconst b = a + 1;\nconsole.log("sum " + (a + b));\n`; + + const entryStop = { reason: "pause", line: 1 }; + + test.each([ + ["entry.ts", 0, [entryStop]], + ["entry.ts", 1, [entryStop]], + ["entry.ts", 3, [entryStop, { reason: "breakpoint", line: 3 }]], + ["entry.cjs", 3, [entryStop, { reason: "breakpoint", line: 3 }]], + ])( + "stopOnEntry in %s with a breakpoint on line %i (0: none)", + async (program, breakpointLine, stops) => { + expect(await stopsInDebugAdapter({ [program]: source }, program, "sum 3", breakpointLine)).toEqual(stops); + }, + // The adapter waits for the program to announce itself before it connects. + 30_000, + ); +}); + +// Nothing transpiles the entry of an executable, with or without its bytecode. +describe.concurrent("--inspect-brk in a compiled executable", () => { + test.each([[[] as string[]], [["--bytecode", "--format=esm"]]])( + "BUN_INSPECT=?break=1 pauses in a bun build --compile %j executable", + async flags => { + using dir = tempDir("inspect-brk-compile", { "entry.ts": `globalThis.ran = 1;\nconsole.log("done");\n` }); + const exe = join(String(dir), isWindows ? "app.exe" : "app"); + await using build = spawn({ + cmd: [bunExe(), "build", "--compile", ...flags, join(String(dir), "entry.ts"), "--outfile", exe], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [buildStdout, buildStderr, buildExitCode] = await Promise.all([ + build.stdout.text(), + build.stderr.text(), + build.exited, + ]); + expect(buildStdout + buildStderr).toContain("compile"); + expect(buildExitCode).toBe(0); + + await using session = await Session.start({}, [], { exe, breakFromEnv: true }); + const pause = await session.paused(/^done$/m); + expect({ reason: pause.reason, file: pause.file }).toEqual({ reason: start, file: basename(exe) }); + expect(await session.evaluate(pause, "typeof globalThis.ran")).toBe("undefined"); + await session.send("Debugger.resume"); + await session.output("stdout", /^done$/m); + expect(session.pauses).toHaveLength(1); + }, + // It writes and runs an executable of the size of bun. + 60_000, + ); +}); diff --git a/test/no-validate-leaksan.txt b/test/no-validate-leaksan.txt index 32a677be1b1f..87412dfa5d7c 100644 --- a/test/no-validate-leaksan.txt +++ b/test/no-validate-leaksan.txt @@ -164,6 +164,7 @@ test/cli/test/parallel-startup-failure.test.ts test/js/node/test/parallel/test-child-process-windows-hide.js test/cli/inspect/BunFrontendDevServer.test.ts test/cli/inspect/HTTPServerAgent.test.ts +test/cli/inspect/inspect-brk.test.ts test/cli/inspect/inspect.test.ts test/cli/install/bun-publish.test.ts test/cli/install/catalogs.test.ts