From 01830612bb40127dffc1e4a9b8a2da7daa96dceb Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 6 Oct 2026 18:16:38 +0000 Subject: [PATCH 1/8] inspect-brk: pause at the entry from the debugger `--inspect-brk` and `BUN_INSPECT=?break=1` asked the parser to put a `debugger;` statement in front of the entry. That statement pauses only when the client enabled pause on debugger statements, it is not there for an entry the parser does not read (`// @bun`, a compiled executable), and it moves every line of the entry down by one. The first entry load of the process now asks the debugger to pause when the code of the entry starts to run (JSC `schedulePauseAtEntry`). The request is taken back when the entry did not run or loads again. The parser option, the one-shot flag of the transpiler paths and their slot in the transpiler cache hash are gone. --- completions/bun-cli.json | 2 +- docs/runtime/debugger.mdx | 4 +- docs/snippets/cli/run.mdx | 2 +- scripts/build/deps/webkit.ts | 2 +- src/bundler/transpiler.rs | 4 - src/js_parser/parse/parse_entry.rs | 13 - src/js_parser/parser.rs | 6 +- src/jsc/Debugger.rs | 42 ++ src/jsc/RuntimeTranspilerCache.rs | 3 +- src/jsc/RuntimeTranspilerStore.rs | 18 +- src/jsc/VirtualMachine.rs | 14 + src/jsc/bindings/BunDebugger.cpp | 14 + src/runtime/api/JSTranspiler.rs | 2 - src/runtime/cli/Arguments.rs | 2 +- src/runtime/jsc_hooks.rs | 13 - test/cli/inspect/inspect-brk.test.ts | 591 +++++++++++++++++++++++++++ 16 files changed, 672 insertions(+), 60 deletions(-) create mode 100644 test/cli/inspect/inspect-brk.test.ts 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/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..ee4558549fb8 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 = "ac0a9aba7a8e27da4bb7133f0d0ccfaff898d255"; /** * 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..29a4ce227c5d 100644 --- a/src/jsc/Debugger.rs +++ b/src/jsc/Debugger.rs @@ -121,7 +121,11 @@ pub struct Debugger { pub poll_ref: KeepAlive, pub wait_for_connection: Wait, // wait_for_connection: bool = false, + /// `--inspect-brk` / `BUN_INSPECT=…?break=1`. The first entry load of the + /// process takes it (`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 +148,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 +174,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 +197,37 @@ struct DebuggerThreadInit { } impl Debugger { + /// `--inspect-brk` / `BUN_INSPECT=…?break=1`: the entry is about to load. + /// Asks JSC to pause when the code of `vm.main()` starts to run. Only the + /// first entry load of the process asks: a hot reload and the next test + /// file do not pause. + 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 a request that the entry did not use: it failed to load, it + /// has no code, or it loads again. + 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..89dac01f7fd7 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -3409,6 +3409,7 @@ impl VirtualMachine { let hooks = runtime_hooks(); let _ = self.ensure_debugger(true); + crate::debugger::Debugger::cancel_pause_at_entry(self); // Node.js `--trace-*` and `--stack-trace-limit` flags need // `internal/process/pre_execution` to run before any user code. @@ -3469,6 +3470,7 @@ impl VirtualMachine { // Check if Module.runMain was patched. if self.has_patched_run_main { bun_core::hint::cold(); + crate::debugger::Debugger::schedule_pause_at_entry(self); self.set_pending_internal_promise(None); let global_ref = self.global(); let argv1 = bun_string_jsc::create_utf8_for_js(global_ref, MAIN_FILE_NAME)?; @@ -3512,6 +3514,7 @@ impl VirtualMachine { // Preloads (evaluated above, synchronously) are not the entry: only // module evaluations from here on mark the entry graph as executing. self.entry_evaluation_started = false; + crate::debugger::Debugger::schedule_pause_at_entry(self); // Note: reshaped for borrowck — capture raw ptr before &self call. let global = self.global; let global_ref = self.global(); @@ -3534,6 +3537,7 @@ impl VirtualMachine { Ok(promise) } else { self.entry_evaluation_started = false; + crate::debugger::Debugger::schedule_pause_at_entry(self); let global = self.global; let main_str = bun_core::String::from_bytes(self.main()); let promise = @@ -3551,6 +3555,10 @@ impl VirtualMachine { &mut self, entry_path: &[u8], ) -> crate::CrateResult<*mut JSInternalPromise> { + // Only the entry takes the `--inspect-brk` pause. If it did not run, nothing else does. + let _cancel_pause_at_entry = scopeguard::guard((), |()| { + crate::debugger::Debugger::cancel_pause_at_entry(VirtualMachine::get()); + }); let promise = self.reload_entry_point(entry_path)?; // pending_internal_promise can change if hot module reloading is enabled @@ -5627,6 +5635,7 @@ impl VirtualMachine { self.event_loop_mut().ensure_waker(); let _ = self.ensure_debugger(true); + crate::debugger::Debugger::cancel_pause_at_entry(self); if !self.transpiler.options.disable_transpilation { if let Some(hooks) = runtime_hooks() { @@ -5639,6 +5648,7 @@ impl VirtualMachine { } } + crate::debugger::Debugger::schedule_pause_at_entry(self); // Note: reshaped for borrowck. let global = self.global; let main_str = bun_core::String::from_bytes(self.main()); @@ -5685,6 +5695,10 @@ impl VirtualMachine { &mut self, entry_path: &[u8], ) -> crate::CrateResult<*mut JSInternalPromise> { + // As in `load_entry_point`. + let _cancel_pause_at_entry = scopeguard::guard((), |()| { + crate::debugger::Debugger::cancel_pause_at_entry(VirtualMachine::get()); + }); let promise = self.reload_entry_point_for_test_runner(entry_path)?; // pending_internal_promise can change if hot module reloading is enabled diff --git a/src/jsc/bindings/BunDebugger.cpp b/src/jsc/bindings/BunDebugger.cpp index a94bd5a78b51..534532ac1e20 100644 --- a/src/jsc/bindings/BunDebugger.cpp +++ b/src/jsc/bindings/BunDebugger.cpp @@ -1017,6 +1017,20 @@ extern "C" void Debugger__willDispatchAsyncCall(JSGlobalObject* globalObject, As agent->willDispatchAsyncCall(getCallType(callType), callbackId); } +// --inspect-brk: the debugger pauses when the code of the entry at `url` starts to run. +// 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..3fb41171447d --- /dev/null +++ b/test/cli/inspect/inspect-brk.test.ts @@ -0,0 +1,591 @@ +// `--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, test } from "bun:test"; +import { bunEnv, bunExe, isWindows, tempDir } from "harness"; +import { realpathSync, writeFileSync } from "node:fs"; +import { basename, join } from "node:path"; +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](); + } +} + +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); + }); + + // Nothing transpiles the entry of an executable, with or without its bytecode. + 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); + }, + 60_000, + ); + + 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("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("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); + + writeFileSync(join(session.cwd, "entry.mjs"), `console.log("second");\n`); + // A second pause would hold the reloaded entry before it prints. + await session.output("stdout", /^second$/m); + 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("handshake", () => { + 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("a debugger statement of the program still needs Debugger.setPauseOnDebuggerStatements", async () => { + await using quiet = await Session.start(files, [brk, "entry.mjs"]); + await quiet.paused(); + expect(await quiet.finish()).toEqual({ + stdout: "done\n", + exitCode: 0, + pauses: [{ reason: start, file: "entry.mjs", line: 1, column: 1 }], + }); + + await using armed = await Session.start(files, [brk, "entry.mjs"], { + handshake: { pauseOnDebuggerStatements: true }, + }); + await armed.paused(); + expect(where(await armed.resumeToPause())).toEqual({ + reason: "DebuggerStatement", + file: "entry.mjs", + line: 1, + column: 1, + }); + expect(await armed.finish()).toMatchObject({ stdout: "done\n", exitCode: 0 }); + expect(armed.pauses).toHaveLength(2); + }); + }); + + describe("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 }); + }); + + test("a breakpoint on the first line is the same 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({ file: pause.file, line: pause.line, column: pause.column }).toEqual({ + 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("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); + }); + }); +}); From f94fe22b6f2b8ac494000248935ddb186e2a0c8a Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 6 Oct 2026 18:26:24 +0000 Subject: [PATCH 2/8] test: a failed --hot reload does not pause either --- test/cli/inspect/inspect-brk.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/test/cli/inspect/inspect-brk.test.ts b/test/cli/inspect/inspect-brk.test.ts index 3fb41171447d..2d899a20edcd 100644 --- a/test/cli/inspect/inspect-brk.test.ts +++ b/test/cli/inspect/inspect-brk.test.ts @@ -478,9 +478,14 @@ describe.concurrent("--inspect-brk", () => { await session.send("Debugger.resume"); await session.output("stdout", /^first$/m); - writeFileSync(join(session.cwd, "entry.mjs"), `console.log("second");\n`); // 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); }); From e25cd9ac1356aa2974611c4afdfe55c162182650 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 7 Oct 2026 04:25:19 +0000 Subject: [PATCH 3/8] inspect-brk: the request is for the top-level code of the entry, whenever it runs The request is scheduled before the preloads, so a preload that imports the entry does not lose the pause. It is not withdrawn when the load returns, so a patched Module.runMain that loads the entry later does not lose it either. The next load of an entry withdraws a request that was not used. WebKit 5d73d6291c: only the module code, the program code, or the wrapper of a CommonJS module takes the request, and the pause keeps its reason when a breakpoint is at the same place. --- scripts/build/deps/webkit.ts | 2 +- src/jsc/Debugger.rs | 11 ++-- src/jsc/VirtualMachine.rs | 17 ++---- test/cli/inspect/inspect-brk.test.ts | 84 ++++++++++++++++++++++++++-- 4 files changed, 89 insertions(+), 25 deletions(-) diff --git a/scripts/build/deps/webkit.ts b/scripts/build/deps/webkit.ts index ee4558549fb8..67866e61694f 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 = "ac0a9aba7a8e27da4bb7133f0d0ccfaff898d255"; +export const WEBKIT_VERSION = "5d73d6291c5d9457f4b906c5c99ab99a06cda555"; /** * WebKit (JavaScriptCore) — the JS engine. diff --git a/src/jsc/Debugger.rs b/src/jsc/Debugger.rs index 29a4ce227c5d..e47d237130c7 100644 --- a/src/jsc/Debugger.rs +++ b/src/jsc/Debugger.rs @@ -197,10 +197,10 @@ struct DebuggerThreadInit { } impl Debugger { - /// `--inspect-brk` / `BUN_INSPECT=…?break=1`: the entry is about to load. - /// Asks JSC to pause when the code of `vm.main()` starts to run. Only the - /// first entry load of the process asks: a hot reload and the next test - /// file do not pause. + /// `--inspect-brk` / `BUN_INSPECT=…?break=1`: an entry load starts. + /// Asks JSC to pause when the code of `vm.main()` starts to run, whoever + /// runs it and whenever. Only the first entry load of the process asks: a + /// hot reload and the next test file do not pause. pub(crate) fn schedule_pause_at_entry(vm: &VirtualMachine) { let asked = vm .debugger_mut() @@ -217,8 +217,7 @@ impl Debugger { } } - /// Takes back a request that the entry did not use: it failed to load, it - /// has no code, or it loads again. + /// 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() diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 89dac01f7fd7..02dd4d698536 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -3409,7 +3409,10 @@ impl VirtualMachine { let hooks = runtime_hooks(); let _ = self.ensure_debugger(true); + // Before the preloads, because one of them can import the entry. The request stays + // after this load returns: a patched `Module.runMain` can run the entry later. 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. @@ -3470,7 +3473,6 @@ impl VirtualMachine { // Check if Module.runMain was patched. if self.has_patched_run_main { bun_core::hint::cold(); - crate::debugger::Debugger::schedule_pause_at_entry(self); self.set_pending_internal_promise(None); let global_ref = self.global(); let argv1 = bun_string_jsc::create_utf8_for_js(global_ref, MAIN_FILE_NAME)?; @@ -3514,7 +3516,6 @@ impl VirtualMachine { // Preloads (evaluated above, synchronously) are not the entry: only // module evaluations from here on mark the entry graph as executing. self.entry_evaluation_started = false; - crate::debugger::Debugger::schedule_pause_at_entry(self); // Note: reshaped for borrowck — capture raw ptr before &self call. let global = self.global; let global_ref = self.global(); @@ -3537,7 +3538,6 @@ impl VirtualMachine { Ok(promise) } else { self.entry_evaluation_started = false; - crate::debugger::Debugger::schedule_pause_at_entry(self); let global = self.global; let main_str = bun_core::String::from_bytes(self.main()); let promise = @@ -3555,10 +3555,6 @@ impl VirtualMachine { &mut self, entry_path: &[u8], ) -> crate::CrateResult<*mut JSInternalPromise> { - // Only the entry takes the `--inspect-brk` pause. If it did not run, nothing else does. - let _cancel_pause_at_entry = scopeguard::guard((), |()| { - crate::debugger::Debugger::cancel_pause_at_entry(VirtualMachine::get()); - }); let promise = self.reload_entry_point(entry_path)?; // pending_internal_promise can change if hot module reloading is enabled @@ -5635,7 +5631,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() { @@ -5648,7 +5646,6 @@ impl VirtualMachine { } } - crate::debugger::Debugger::schedule_pause_at_entry(self); // Note: reshaped for borrowck. let global = self.global; let main_str = bun_core::String::from_bytes(self.main()); @@ -5695,10 +5692,6 @@ impl VirtualMachine { &mut self, entry_path: &[u8], ) -> crate::CrateResult<*mut JSInternalPromise> { - // As in `load_entry_point`. - let _cancel_pause_at_entry = scopeguard::guard((), |()| { - crate::debugger::Debugger::cancel_pause_at_entry(VirtualMachine::get()); - }); let promise = self.reload_entry_point_for_test_runner(entry_path)?; // pending_internal_promise can change if hot module reloading is enabled diff --git a/test/cli/inspect/inspect-brk.test.ts b/test/cli/inspect/inspect-brk.test.ts index 2d899a20edcd..b819f23f8231 100644 --- a/test/cli/inspect/inspect-brk.test.ts +++ b/test/cli/inspect/inspect-brk.test.ts @@ -451,6 +451,56 @@ describe.concurrent("--inspect-brk", () => { 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 }, [ @@ -550,21 +600,43 @@ describe.concurrent("--inspect-brk", () => { expect(await session.finish()).toMatchObject({ stdout: "3\n", exitCode: 0 }); }); - test("a breakpoint on the first line is the same pause", async () => { + // 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({ file: pause.file, line: pause.line, column: pause.column }).toEqual({ - file: "entry.mjs", - line: 1, - column: 1, - }); + 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("the program runs on when the debugger disconnects", async () => { await using session = await Session.start(files, [brk, "entry.mjs"]); await session.paused(); From 52361bc45455bd87a4f26a98139dd8950dce3db1 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 7 Oct 2026 08:24:56 +0000 Subject: [PATCH 4/8] inspect-brk: a request that the entry did not take ends when the entry starts WebKit cd9249323d: the request ends when the top-level code of the entry starts to run, with a pause or without one. Before, an entry that started while breakpoints were inactive left the request pending, and a later call of a function of a CommonJS entry could take it. The test for the two debugger-statement cases is now two tests, and the compiled-executable tests run after the other tests. --- scripts/build/deps/webkit.ts | 2 +- test/cli/inspect/inspect-brk.test.ts | 129 +++++++++++++++++---------- 2 files changed, 85 insertions(+), 46 deletions(-) diff --git a/scripts/build/deps/webkit.ts b/scripts/build/deps/webkit.ts index 67866e61694f..fc3ea5a4ed22 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 = "5d73d6291c5d9457f4b906c5c99ab99a06cda555"; +export const WEBKIT_VERSION = "cd9249323d6c93c007cc99ad609145cf6cb85cff"; /** * WebKit (JavaScriptCore) — the JS engine. diff --git a/test/cli/inspect/inspect-brk.test.ts b/test/cli/inspect/inspect-brk.test.ts index b819f23f8231..b8c460e45a0d 100644 --- a/test/cli/inspect/inspect-brk.test.ts +++ b/test/cli/inspect/inspect-brk.test.ts @@ -165,16 +165,25 @@ class Session implements AsyncDisposable { } session.#transport = await Promise.race([connected.promise, session.#exited.promise]); - const { breakpointsActive = true, pauseOnDebuggerStatements, pauseOnMicrotasks, breakpoints = [] } = - options.handshake ?? {}; + 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 (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 }); + 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(() => {}); @@ -393,38 +402,6 @@ describe.concurrent("--inspect-brk", () => { expect(session.pauses).toHaveLength(1); }); - // Nothing transpiles the entry of an executable, with or without its bytecode. - 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); - }, - 60_000, - ); - test("an entry that starts with a class pauses before the class", async () => { await using session = await Session.start( { @@ -559,27 +536,29 @@ describe.concurrent("--inspect-brk", () => { expect(await session.finish(false)).toEqual({ stdout: "done\n", exitCode: 0, pauses: [] }); }); - test("a debugger statement of the program still needs Debugger.setPauseOnDebuggerStatements", async () => { - await using quiet = await Session.start(files, [brk, "entry.mjs"]); - await quiet.paused(); - expect(await quiet.finish()).toEqual({ + 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 }], }); + }); - await using armed = await Session.start(files, [brk, "entry.mjs"], { + 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 armed.paused(); - expect(where(await armed.resumeToPause())).toEqual({ + await session.paused(); + expect(where(await session.resumeToPause())).toEqual({ reason: "DebuggerStatement", file: "entry.mjs", line: 1, column: 1, }); - expect(await armed.finish()).toMatchObject({ stdout: "done\n", exitCode: 0 }); - expect(armed.pauses).toHaveLength(2); + expect(await session.finish()).toMatchObject({ stdout: "done\n", exitCode: 0 }); + expect(session.pauses).toHaveLength(2); }); }); @@ -637,6 +616,31 @@ describe.concurrent("--inspect-brk", () => { 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 }); + await session.send("Runtime.evaluate", { expression: "globalThis.breakpointsAreBack = true" }); + 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(); @@ -666,3 +670,38 @@ describe.concurrent("--inspect-brk", () => { }); }); }); + +// After the block above: each of these writes and runs an executable of the size of bun. +describe.concurrent("--inspect-brk in a compiled executable", () => { + // Nothing transpiles the entry of an executable, with or without its bytecode. + 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); + }, + 60_000, + ); +}); From 9c6d9c071d2572453e5f7307799283aeea963254 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 7 Oct 2026 09:19:59 +0000 Subject: [PATCH 5/8] inspect-brk: tests through the debug adapter of the VS Code extension The adapter puts a breakpoint of its own at the start of a file that has breakpoints, and resumes when that one pauses. The tests run it with stopOnEntry and check that the stop on entry shows, with and without a breakpoint of the user in the entry. Also: the tests of this file have a 30 s ceiling in debug and ASAN builds, where each inspector process needs a second to start. The zsh completions describe --inspect-brk as --help does. --- completions/bun.zsh | 4 +- test/cli/inspect/inspect-brk.test.ts | 395 +++++++++++++++++---------- 2 files changed, 257 insertions(+), 142 deletions(-) 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/test/cli/inspect/inspect-brk.test.ts b/test/cli/inspect/inspect-brk.test.ts index b8c460e45a0d..53700b7c619d 100644 --- a/test/cli/inspect/inspect-brk.test.ts +++ b/test/cli/inspect/inspect-brk.test.ts @@ -4,10 +4,13 @@ // `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, test } from "bun:test"; -import { bunEnv, bunExe, isWindows, tempDir } from "harness"; +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 = { @@ -273,6 +276,10 @@ class Session implements AsyncDisposable { } } +// 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"; @@ -416,6 +423,16 @@ describe.concurrent("--inspect-brk", () => { 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` }, @@ -515,165 +532,262 @@ describe.concurrent("--inspect-brk", () => { await session.output("stdout", /^fourth$/m); 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 handshake of the client", () => { + const files = { "entry.mjs": `debugger;\nconsole.log("done");\n` }; - describe("handshake", () => { - 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: [] }); + }); - // 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("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 }, }); - - 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); + 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("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 }); - }); +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); + // 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("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, }); - - 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 }); - await session.send("Runtime.evaluate", { expression: "globalThis.breakpointsAreBack = true" }); - 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); + 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("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 }); - }); + 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); + }); - // 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); + 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, + ); }); -// After the block above: each of these writes and runs an executable of the size of bun. +// Nothing transpiles the entry of an executable, with or without its bytecode. describe.concurrent("--inspect-brk in a compiled executable", () => { - // Nothing transpiles the entry of an executable, with or without its bytecode. test.each([[[] as string[]], [["--bytecode", "--format=esm"]]])( "BUN_INSPECT=?break=1 pauses in a bun build --compile %j executable", async flags => { @@ -702,6 +816,7 @@ describe.concurrent("--inspect-brk in a compiled executable", () => { await session.output("stdout", /^done$/m); expect(session.pauses).toHaveLength(1); }, + // It writes and runs an executable of the size of bun. 60_000, ); }); From 0309ac04484075c93054353db89bbec144720887 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 7 Oct 2026 10:27:43 +0000 Subject: [PATCH 6/8] Pin WebKit to the preview build of oven-sh/WebKit#776 --- scripts/build/deps/webkit.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/build/deps/webkit.ts b/scripts/build/deps/webkit.ts index fc3ea5a4ed22..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 = "cd9249323d6c93c007cc99ad609145cf6cb85cff"; +export const WEBKIT_VERSION = "autobuild-preview-pr-776-1fbd9966"; /** * WebKit (JavaScriptCore) — the JS engine. From ec37009374ebf3fb8f9720b1d1fd2d9f1c4981cd Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 7 Oct 2026 10:55:23 +0000 Subject: [PATCH 7/8] test: no LeakSanitizer for inspect-brk.test.ts, like the other inspector tests With a client connected, the VM of the inspector server is not destroyed at exit. Under BUN_DESTRUCT_VM_ON_EXIT and LeakSanitizer an inspectee then reports two leaks of that VM (its Bun.stderr and the upgrade Request) and exits with 134, also under --inspect-wait. These tests check the exit code of the inspectee. --- test/no-validate-leaksan.txt | 1 + 1 file changed, 1 insertion(+) 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 From 2647ed2772f3527908f4fcd8febdea074cdd3fa8 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 7 Oct 2026 11:01:43 +0000 Subject: [PATCH 8/8] inspect-brk: one line for each comment --- src/jsc/Debugger.rs | 8 ++------ src/jsc/VirtualMachine.rs | 3 +-- src/jsc/bindings/BunDebugger.cpp | 1 - 3 files changed, 3 insertions(+), 9 deletions(-) diff --git a/src/jsc/Debugger.rs b/src/jsc/Debugger.rs index e47d237130c7..5ca675c4930c 100644 --- a/src/jsc/Debugger.rs +++ b/src/jsc/Debugger.rs @@ -121,8 +121,7 @@ pub struct Debugger { pub poll_ref: KeepAlive, pub wait_for_connection: Wait, // wait_for_connection: bool = false, - /// `--inspect-brk` / `BUN_INSPECT=…?break=1`. The first entry load of the - /// process takes it (`schedule_pause_at_entry`). + /// 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, @@ -197,10 +196,7 @@ struct DebuggerThreadInit { } impl Debugger { - /// `--inspect-brk` / `BUN_INSPECT=…?break=1`: an entry load starts. - /// Asks JSC to pause when the code of `vm.main()` starts to run, whoever - /// runs it and whenever. Only the first entry load of the process asks: a - /// hot reload and the next test file do not pause. + /// 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() diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 02dd4d698536..0c68a1853741 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -3409,8 +3409,7 @@ impl VirtualMachine { let hooks = runtime_hooks(); let _ = self.ensure_debugger(true); - // Before the preloads, because one of them can import the entry. The request stays - // after this load returns: a patched `Module.runMain` can run the entry later. + // 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); diff --git a/src/jsc/bindings/BunDebugger.cpp b/src/jsc/bindings/BunDebugger.cpp index 534532ac1e20..90c632fe724c 100644 --- a/src/jsc/bindings/BunDebugger.cpp +++ b/src/jsc/bindings/BunDebugger.cpp @@ -1017,7 +1017,6 @@ extern "C" void Debugger__willDispatchAsyncCall(JSGlobalObject* globalObject, As agent->willDispatchAsyncCall(getCallType(callType), callbackId); } -// --inspect-brk: the debugger pauses when the code of the entry at `url` starts to run. // 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) {