diff --git a/scripts/verify-baseline-static/allowlist-x64-windows.txt b/scripts/verify-baseline-static/allowlist-x64-windows.txt index 25c50fdf61c..aa72e22a7cc 100644 --- a/scripts/verify-baseline-static/allowlist-x64-windows.txt +++ b/scripts/verify-baseline-static/allowlist-x64-windows.txt @@ -638,6 +638,7 @@ sinf [AVX, FMA] sinh_fma [AVX, FMA] sinhf_fma [AVX, FMA] strnlen [AVX, AVX2] +strspn [AVX512BW, AVX512VL] tan [AVX, FMA] tanf [AVX, FMA] tanh_fma [AVX, FMA] diff --git a/src/bun.js.zig b/src/bun.js.zig index 6d0978a92bd..fc1fb3d9b76 100644 --- a/src/bun.js.zig +++ b/src/bun.js.zig @@ -49,6 +49,8 @@ pub const Run = struct { .smol = ctx.runtime_options.smol, .debugger = ctx.runtime_options.debugger, .dns_result_order = DNSResolver.Order.fromStringOrDie(ctx.runtime_options.dns_result_order), + .disable_sigusr1 = ctx.runtime_options.disable_sigusr1, + .inspect_port = ctx.runtime_options.inspect_port, }), .arena = arena, .ctx = ctx, @@ -186,6 +188,8 @@ pub const Run = struct { .debugger = ctx.runtime_options.debugger, .dns_result_order = DNSResolver.Order.fromStringOrDie(ctx.runtime_options.dns_result_order), .is_main_thread = true, + .disable_sigusr1 = ctx.runtime_options.disable_sigusr1, + .inspect_port = ctx.runtime_options.inspect_port, }, ), .arena = arena, diff --git a/src/bun.js/Debugger.zig b/src/bun.js/Debugger.zig index b18db2e5b5a..905458cd0e7 100644 --- a/src/bun.js/Debugger.zig +++ b/src/bun.js/Debugger.zig @@ -119,7 +119,6 @@ pub fn create(this: *VirtualMachine, globalObject: *JSGlobalObject) !void { log("create", .{}); jsc.markBinding(@src()); if (!has_created_debugger) { - has_created_debugger = true; std.mem.doNotOptimizeAway(&TestReporterAgent.Bun__TestReporterAgentDisable); std.mem.doNotOptimizeAway(&LifecycleAgent.Bun__LifecycleAgentDisable); std.mem.doNotOptimizeAway(&TestReporterAgent.Bun__TestReporterAgentEnable); @@ -128,9 +127,13 @@ pub fn create(this: *VirtualMachine, globalObject: *JSGlobalObject) !void { debugger.script_execution_context_id = Bun__createJSDebugger(globalObject); if (!this.has_started_debugger) { this.has_started_debugger = true; - var thread = try std.Thread.spawn(.{}, startJSDebuggerThread, .{this}); + var thread = std.Thread.spawn(.{}, startJSDebuggerThread, .{this}) catch |err| { + this.has_started_debugger = false; + return err; + }; thread.detach(); } + has_created_debugger = true; this.eventLoop().ensureWaker(); if (debugger.wait_for_connection != .off) { diff --git a/src/bun.js/VirtualMachine.zig b/src/bun.js/VirtualMachine.zig index d862e935a4f..ccf782ed93f 100644 --- a/src/bun.js/VirtualMachine.zig +++ b/src/bun.js/VirtualMachine.zig @@ -164,6 +164,9 @@ hot_reload_counter: u32 = 0, debugger: ?jsc.Debugger = null, has_started_debugger: bool = false, +/// Pre-configured inspector port for runtime activation (via --inspect-port). +/// Used by RuntimeInspector when SIGUSR1/process._debugProcess activates the inspector. +inspect_port: ?[]const u8 = null, has_terminated: bool = false, debug_thread_id: if (Environment.allow_assert) std.Thread.Id else void, @@ -1086,9 +1089,12 @@ pub fn initWithModuleGraph( vm.jsc_vm = vm.global.vm(); uws.Loop.get().internal_loop_data.jsc_vm = vm.jsc_vm; + vm.inspect_port = opts.inspect_port; vm.configureDebugger(opts.debugger); vm.body_value_hive_allocator = Body.Value.HiveAllocator.init(bun.typedAllocator(jsc.WebCore.Body.Value)); + configureSigusr1Handler(vm, opts); + return vm; } @@ -1115,8 +1121,28 @@ pub const Options = struct { /// Worker VMs are always destroyed on exit, regardless of this setting. Setting this to /// true may expose bugs that would otherwise only occur using Workers. destruct_main_thread_on_exit: bool = false, + /// Disable SIGUSR1 handler for runtime debugger activation (matches Node.js). + disable_sigusr1: bool = false, + /// Pre-configured inspector port for runtime activation (--inspect-port). + inspect_port: ?[]const u8 = null, }; +/// Configure SIGUSR1 handling for runtime debugger activation (main thread only). +fn configureSigusr1Handler(vm: *const VirtualMachine, opts: Options) void { + if (!opts.is_main_thread) return; + + if (opts.disable_sigusr1) { + // User requested --disable-sigusr1, set SIGUSR1 to default action (terminate) + jsc.EventLoop.RuntimeInspector.setDefaultSigusr1Action(); + } else if (vm.debugger != null) { + // Debugger already enabled via CLI flags, ignore SIGUSR1 + jsc.EventLoop.RuntimeInspector.ignoreSigusr1(); + } else { + // Install RuntimeInspector signal handler for runtime activation + jsc.EventLoop.RuntimeInspector.installIfNotAlready(); + } +} + pub var is_smol_mode = false; pub fn init(opts: Options) !*VirtualMachine { @@ -1213,9 +1239,12 @@ pub fn init(opts: Options) !*VirtualMachine { if (opts.smol) is_smol_mode = opts.smol; + vm.inspect_port = opts.inspect_port; vm.configureDebugger(opts.debugger); vm.body_value_hive_allocator = Body.Value.HiveAllocator.init(bun.typedAllocator(jsc.WebCore.Body.Value)); + configureSigusr1Handler(vm, opts); + return vm; } @@ -1262,8 +1291,15 @@ fn configureDebugger(this: *VirtualMachine, cli_flag: bun.cli.Command.Debugger) } }, .enable => { + // If --inspect/--inspect-brk/--inspect-wait is used without an explicit port, + // use --inspect-port if provided. + const path_or_port = if (cli_flag.enable.path_or_port.len == 0) + this.inspect_port orelse cli_flag.enable.path_or_port + else + cli_flag.enable.path_or_port; + this.debugger = .{ - .path_or_port = cli_flag.enable.path_or_port, + .path_or_port = path_or_port, .from_environment_variable = unix, .wait_for_connection = if (cli_flag.enable.wait_for_connection) .forever else wait_for_connection, .set_breakpoint_on_first_line = set_breakpoint_on_first_line or cli_flag.enable.set_breakpoint_on_first_line, @@ -1470,9 +1506,12 @@ pub fn initBake(opts: Options) anyerror!*VirtualMachine { if (opts.smol) is_smol_mode = opts.smol; + vm.inspect_port = opts.inspect_port; vm.configureDebugger(opts.debugger); vm.body_value_hive_allocator = Body.Value.HiveAllocator.init(bun.typedAllocator(jsc.WebCore.Body.Value)); + configureSigusr1Handler(vm, opts); + return vm; } diff --git a/src/bun.js/bindings/BunDebugger.cpp b/src/bun.js/bindings/BunDebugger.cpp index c0db82afcee..c0dd94a7379 100644 --- a/src/bun.js/bindings/BunDebugger.cpp +++ b/src/bun.js/bindings/BunDebugger.cpp @@ -3,6 +3,8 @@ #include "ZigGlobalObject.h" #include +#include +#include #include #include #include @@ -16,14 +18,25 @@ #include "InspectorBunFrontendDevServerAgent.h" #include "InspectorHTTPServerAgent.h" -extern "C" void Bun__tickWhilePaused(bool*); extern "C" void Bun__eventLoop__incrementRefConcurrently(void* bunVM, int delta); namespace Bun { using namespace JSC; using namespace WebCore; +// True when the inspector was activated at runtime (SIGUSR1 / process._debugProcess), +// as opposed to --inspect at startup. When true, connect() uses requestStopAll to +// interrupt busy JS execution. When false (--inspect), the event loop handles delivery. +static std::atomic runtimeInspectorActivated { false }; + +// True after the first connection has been bootstrap-paused via the STW callback. +// Prevents subsequent connections (reconnects) from triggering bootstrap pause, +// which would trap the main thread in breakProgram() during Runtime.evaluate. +static std::atomic hasEverBootstrapped { false }; + class BunInspectorConnection; +static void installRunWhilePausedCallback(JSC::JSGlobalObject* globalObject); +static void makeInspectable(JSC::JSGlobalObject* globalObject); static WebCore::ScriptExecutionContext* debuggerScriptExecutionContext = nullptr; static WTF::Lock inspectorConnectionsLock = WTF::Lock(); @@ -62,6 +75,12 @@ class BunJSGlobalObjectDebuggable final : public JSC::JSGlobalObjectDebuggable { } }; +static void makeInspectable(JSC::JSGlobalObject* globalObject) +{ + globalObject->setInspectable(true); + globalObject->inspectorDebuggable().setInspectable(true); +} + enum class ConnectionStatus : int32_t { Pending = 0, Connected = 1, @@ -101,9 +120,7 @@ class BunInspectorConnection : public Inspector::FrontendChannel { if (this->unrefOnDisconnect) { Bun__eventLoop__incrementRefConcurrently(static_cast(globalObject)->bunVM(), 1); } - globalObject->setInspectable(true); - auto& inspector = globalObject->inspectorDebuggable(); - inspector.setInspectable(true); + makeInspectable(globalObject); static bool hasConnected = false; @@ -122,13 +139,17 @@ class BunInspectorConnection : public Inspector::FrontendChannel { this->hasEverConnected = true; globalObject->inspectorController().connectFrontend(*this, true, false); // waitingForConnection - Inspector::JSGlobalObjectDebugger* debugger = reinterpret_cast(globalObject->debugger()); - if (debugger) { - debugger->runWhilePausedCallback = [](JSC::JSGlobalObject& globalObject, bool& isDoneProcessingEvents) -> void { - BunInspectorConnection::runWhilePaused(globalObject, isDoneProcessingEvents); - }; + // Pre-attach the debugger so that schedulePauseAtNextOpportunity() can work + // during the STW callback. Only on the SIGUSR1 path — for --inspect, the + // debugger gets attached later via the Debugger.enable CDP command. + if (runtimeInspectorActivated.load()) { + auto* controllerDebugger = globalObject->inspectorController().debugger(); + if (controllerDebugger && !globalObject->debugger()) + controllerDebugger->attach(globalObject); } + installRunWhilePausedCallback(globalObject); + this->receiveMessagesOnInspectorThread(context, static_cast(globalObject), false); } @@ -158,6 +179,43 @@ class BunInspectorConnection : public Inspector::FrontendChannel { } } }); + + // Only use StopTheWorld for runtime-activated inspector (SIGUSR1 path) + // where the event loop may not be running (e.g., while(true){}). + // For --inspect, the event loop delivers doConnect via ensureOnContextThread above. + // + // Fire STW to interrupt busy JS (e.g., while(true){}) and process + // this connection via the Bun__stopTheWorldCallback. + // Note: do NOT fire a deferred requestStopAll here — if the target VM + // enters the pause loop before the deferred STW fires, the deferred STW + // deadlocks (target is in C++ pause loop, can't reach JS safe point, + // debugger thread blocks in STW and can't deliver messages). + if (runtimeInspectorActivated.load()) { + // If another connection on the same VM is already in the pause loop + // (runWhilePaused), skip requestStopAll to avoid deadlock — the target + // VM is in C++ code and can't reach a JS safe point, so STW would block + // forever. This mirrors the guard in interruptForMessageDelivery(). + bool anyInPauseLoop = false; + { + Locker locker(inspectorConnectionsLock); + if (inspectorConnections) { + for (auto& entry : *inspectorConnections) { + for (auto* connection : entry.value) { + if (connection != this && connection->globalObject + && &connection->globalObject->vm() == &this->globalObject->vm() + && (connection->pauseFlags.load() & kInPauseLoop)) { + anyInPauseLoop = true; + break; + } + } + if (anyInPauseLoop) + break; + } + } + } + if (!anyInPauseLoop) + VMManager::requestStopAll(VMManager::StopReason::JSDebugger); + } } void disconnect() @@ -214,9 +272,22 @@ class BunInspectorConnection : public Inspector::FrontendChannel { connections.appendVector(inspectorConnections->get(global->scriptExecutionContext()->identifier())); } + // Check if this is a bootstrap pause (from breakProgram in handleTraps). + // Bootstrap pauses dispatch messages and exit so the VM can re-enter + // a proper pause with Debugger.paused event after Debugger.pause is received. + bool isBootstrapPause = false; + for (auto* connection : connections) { + // Atomically read and clear pause reason flags. + uint8_t prev = connection->pauseFlags.exchange(0); + if (prev & BunInspectorConnection::kBootstrapPause) + isBootstrapPause = true; + } + for (auto* connection : connections) { if (connection->status == ConnectionStatus::Pending) { - connection->connect(); + auto* context = ScriptExecutionContext::getScriptExecutionContext(connection->scriptExecutionContextIdentifier); + if (context) + connection->doConnect(*context); continue; } @@ -225,11 +296,33 @@ class BunInspectorConnection : public Inspector::FrontendChannel { } } - // for (auto* connection : connections) { - // if (connection->status == ConnectionStatus::Connected) { - // connection->jsWaitForMessageFromInspectorLock.lock(); - // } - // } + if (isBootstrapPause) { + // Bootstrap pause: breakProgram() fired from VMTraps to provide a + // window for processing setup messages (e.g., Debugger.enable). + // The drain above may or may not have processed them (depends on + // timing — frontend messages may not have arrived yet). + // Resume immediately. Messages will be delivered via the + // NeedDebuggerBreak trap mechanism as they arrive. The user can + // click Pause later for a real pause with proper call frames. + // + // Previously, this sent a synthetic Debugger.paused with empty + // callFrames:[], but the frontend (DebuggerManager.js) auto-resumes + // when activeCallFrame is null, making it pointless. Scripts also + // weren't registered (no scriptParsed events), so even real pauses + // had their call frames filtered out → auto-resume. + for (auto* connection : connections) + connection->pauseFlags.store(0); + if (auto* debugger = global->debugger()) + debugger->continueProgram(); + return; + } + + // Mark all connections as being in the pause loop so that + // interruptForMessageDelivery skips requestStopAll (which would + // deadlock: the debugger thread blocks in STW while the target + // VM is in this C++ loop and never reaches a JS safe point). + for (auto* connection : connections) + connection->pauseFlags.store(BunInspectorConnection::kInPauseLoop); if (connections.size() == 1) { while (!isDoneProcessingEvents) { @@ -241,6 +334,13 @@ class BunInspectorConnection : public Inspector::FrontendChannel { break; } connection->receiveMessagesOnInspectorThread(*global->scriptExecutionContext(), global, true); + // Re-assert kInPauseLoop after message dispatch. If the dispatch + // triggered a nested breakpoint → recursive runWhilePaused, the + // inner call's cleanup (pauseFlags.store(0)) will have cleared + // this flag. Without re-asserting, interruptForMessageDelivery + // would see kInPauseLoop unset and call notifyNeedDebuggerBreak, + // which can deadlock. + connection->pauseFlags.fetch_or(BunInspectorConnection::kInPauseLoop); } } else { while (!isDoneProcessingEvents) { @@ -248,6 +348,8 @@ class BunInspectorConnection : public Inspector::FrontendChannel { for (auto* connection : connections) { closedCount += connection->status == ConnectionStatus::Disconnected || connection->status == ConnectionStatus::Disconnecting; connection->receiveMessagesOnInspectorThread(*global->scriptExecutionContext(), global, true); + // Re-assert after each dispatch (see single-connection comment above). + connection->pauseFlags.fetch_or(BunInspectorConnection::kInPauseLoop); if (isDoneProcessingEvents) break; } @@ -258,11 +360,49 @@ class BunInspectorConnection : public Inspector::FrontendChannel { } } } + + // Drain any remaining messages before clearing flags to prevent + // them from triggering a new interruptForMessageDelivery → STW → pause cascade. + for (auto* connection : connections) { + if (connection->status != ConnectionStatus::Disconnected) { + connection->receiveMessagesOnInspectorThread(*global->scriptExecutionContext(), global, false); + } + } + + for (auto* connection : connections) { + // Clear jsThreadMessageScheduled before pauseFlags so that when + // kInPauseLoop is cleared, any concurrent scheduleInspectorThreadDelivery + // will see the flag as false and correctly post a new task. + connection->jsThreadMessageScheduled.store(false); + connection->pauseFlags.store(0); + } } void receiveMessagesOnInspectorThread(ScriptExecutionContext& context, Zig::GlobalObject* globalObject, bool connectIfNeeded) { - this->jsThreadMessageScheduledCount.store(0); + // Only clear the scheduled flag when NOT in the pause loop. + // During the pause loop, receiveMessagesOnInspectorThread is called + // repeatedly by the busy-poll. Clearing the flag would cause the + // debugger thread to re-post a task + interruptForMessageDelivery + // on every subsequent message, which is wasteful (and the posted + // tasks pile up for after the loop exits). + if (!(this->pauseFlags.load() & kInPauseLoop)) + this->jsThreadMessageScheduled.store(false); + + // Connect pending connections BEFORE draining messages. + // If we drain first and then doConnect returns early, the drained + // messages would be lost (dropped on stack unwind). + auto& dispatcher = globalObject->inspectorDebuggable(); + Inspector::JSGlobalObjectDebugger* debugger = reinterpret_cast(globalObject->debugger()); + + if (!debugger && connectIfNeeded && this->status == ConnectionStatus::Pending) { + this->doConnect(context); + // doConnect calls receiveMessagesOnInspectorThread recursively, + // but jsThreadMessages may have been empty at that point. + // Fall through to drain any messages that arrived during doConnect. + debugger = reinterpret_cast(globalObject->debugger()); + } + WTF::Vector messages; { @@ -270,25 +410,14 @@ class BunInspectorConnection : public Inspector::FrontendChannel { this->jsThreadMessages.swap(messages); } - auto& dispatcher = globalObject->inspectorDebuggable(); - Inspector::JSGlobalObjectDebugger* debugger = reinterpret_cast(globalObject->debugger()); - if (!debugger) { - if (connectIfNeeded && this->status == ConnectionStatus::Pending) { - this->doConnect(context); - return; - } - for (auto message : messages) { dispatcher.dispatchMessageFromRemote(WTF::move(message)); if (!debugger) { debugger = reinterpret_cast(globalObject->debugger()); - if (debugger) { - debugger->runWhilePausedCallback = [](JSC::JSGlobalObject& globalObject, bool& isDoneProcessingEvents) -> void { - runWhilePaused(globalObject, isDoneProcessingEvents); - }; - } + if (debugger) + installRunWhilePausedCallback(globalObject); } } } else { @@ -296,13 +425,11 @@ class BunInspectorConnection : public Inspector::FrontendChannel { dispatcher.dispatchMessageFromRemote(WTF::move(message)); } } - - messages.clear(); } void receiveMessagesOnDebuggerThread(ScriptExecutionContext& context, Zig::GlobalObject* debuggerGlobalObject) { - debuggerThreadMessageScheduledCount.store(0); + debuggerThreadMessageScheduled.store(false); WTF::Vector messages; { @@ -319,19 +446,19 @@ class BunInspectorConnection : public Inspector::FrontendChannel { arguments.append(jsString(vm, message)); } - messages.clear(); - JSC::call(debuggerGlobalObject, onMessageFn, arguments, "BunInspectorConnection::receiveMessagesOnDebuggerThread - onMessageFn"_s); } void sendMessageToDebuggerThread(WTF::String&& inputMessage) { + bool wasScheduled; { Locker locker(debuggerThreadMessagesLock); debuggerThreadMessages.append(inputMessage); } - if (this->debuggerThreadMessageScheduledCount++ == 0) { + wasScheduled = this->debuggerThreadMessageScheduled.exchange(true); + if (!wasScheduled) { debuggerScriptExecutionContext->postTaskConcurrently([connection = this](ScriptExecutionContext& context) { connection->receiveMessagesOnDebuggerThread(context, static_cast(context.jsGlobalObject())); }); @@ -344,14 +471,7 @@ class BunInspectorConnection : public Inspector::FrontendChannel { Locker locker(jsThreadMessagesLock); jsThreadMessages.appendVector(inputMessages); } - - if (this->jsWaitForMessageFromInspectorLock.isLocked()) { - this->jsWaitForMessageFromInspectorLock.unlock(); - } else if (this->jsThreadMessageScheduledCount++ == 0) { - ScriptExecutionContext::postTaskTo(scriptExecutionContextIdentifier, [connection = this](ScriptExecutionContext& context) { - connection->receiveMessagesOnInspectorThread(context, static_cast(context.jsGlobalObject()), true); - }); - } + scheduleInspectorThreadDelivery(); } void sendMessageToInspectorFromDebuggerThread(const WTF::String& inputMessage) @@ -360,23 +480,63 @@ class BunInspectorConnection : public Inspector::FrontendChannel { Locker locker(jsThreadMessagesLock); jsThreadMessages.append(inputMessage); } + scheduleInspectorThreadDelivery(); + } +private: + void scheduleInspectorThreadDelivery() + { if (this->jsWaitForMessageFromInspectorLock.isLocked()) { this->jsWaitForMessageFromInspectorLock.unlock(); - } else if (this->jsThreadMessageScheduledCount++ == 0) { + } else if (!this->jsThreadMessageScheduled.exchange(true)) { ScriptExecutionContext::postTaskTo(scriptExecutionContextIdentifier, [connection = this](ScriptExecutionContext& context) { connection->receiveMessagesOnInspectorThread(context, static_cast(context.jsGlobalObject()), true); }); + // Also interrupt busy JS execution via the debugger's pause mechanism. + // If the debugger is attached, this triggers a pause at the next trap check, + // where runWhilePaused will dispatch the queued messages. + // If the debugger is not attached, the event loop delivery (above) is the fallback. + this->interruptForMessageDelivery(); + } else { } } +public: + // Interrupt the JS thread to process pending CDP messages via StopTheWorld. + // Only used on the SIGUSR1 runtime activation path where the event loop may + // not be running (e.g., while(true){}). For --inspect, the event loop + // delivers messages via postTaskTo. + void interruptForMessageDelivery() + { + if (!runtimeInspectorActivated.load()) + return; + // If kInPauseLoop is set, the target VM is already in the runWhilePaused + // message pump (busy-polling receiveMessagesOnInspectorThread). Skip the + // STW request to avoid deadlock. + uint8_t flags = this->pauseFlags.load(); + if (flags & kInPauseLoop) + return; + // Use requestStopAll(JSDebugger) to interrupt the target VM. + // This triggers StopTheWorld, whose callback (Bun__stopTheWorldCallback) + // calls schedulePauseForConnectedSessions → schedulePauseAtNextOpportunity + // + notifyNeedDebuggerBreak. After STW resumes, the NeedDebuggerBreak trap + // fires with stepping mode enabled, so the interpreter calls atStatement → + // pauseIfNeeded → enters the pause loop where CDP messages are drained. + // + // notifyNeedDebuggerBreak alone is insufficient because it only invalidates + // code blocks without enabling stepping mode. After continueProgram() clears + // stepping, the interpreter skips op_debug hooks entirely. + this->pauseFlags.fetch_or(kMessageDeliveryPause); + JSC::VMManager::requestStopAll(JSC::VMManager::StopReason::JSDebugger); + } + WTF::Vector debuggerThreadMessages; WTF::Lock debuggerThreadMessagesLock = WTF::Lock(); - std::atomic debuggerThreadMessageScheduledCount { 0 }; + std::atomic debuggerThreadMessageScheduled { false }; WTF::Vector jsThreadMessages; WTF::Lock jsThreadMessagesLock = WTF::Lock(); - std::atomic jsThreadMessageScheduledCount { 0 }; + std::atomic jsThreadMessageScheduled { false }; JSC::JSGlobalObject* globalObject; ScriptExecutionContextIdentifier scriptExecutionContextIdentifier; @@ -385,11 +545,61 @@ class BunInspectorConnection : public Inspector::FrontendChannel { WTF::Lock jsWaitForMessageFromInspectorLock; std::atomic status = ConnectionStatus::Pending; + // Pause state flags (consolidated into a single atomic). + // + // kBootstrapPause - runWhilePaused should send a synthetic Debugger.paused event + // kMessageDeliveryPause - a notifyNeedDebuggerBreak trap is needed to deliver CDP messages (no synthetic event) + // kInPauseLoop - the connection is in the runWhilePaused message pump loop; + // interruptForMessageDelivery must skip requestStopAll to avoid + // deadlock (debugger thread blocks in STW while target VM is in + // C++ code that never reaches a JS safe point) + // + static constexpr uint8_t kBootstrapPause = 1 << 0; + static constexpr uint8_t kMessageDeliveryPause = 1 << 1; + static constexpr uint8_t kInPauseLoop = 1 << 2; + std::atomic pauseFlags { 0 }; + bool unrefOnDisconnect = false; bool hasEverConnected = false; }; +// This callback is invoked by JSC when the debugger enters a paused state, +// delegating to BunInspectorConnection::runWhilePaused for CDP message pumping. +static void installRunWhilePausedCallback(JSC::JSGlobalObject* globalObject) +{ + auto* debugger = reinterpret_cast(globalObject->debugger()); + if (debugger) { + debugger->runWhilePausedCallback = [](JSC::JSGlobalObject& go, bool& done) { + BunInspectorConnection::runWhilePaused(go, done); + }; + } +} + +template +static auto forEachConnection(Func&& callback) -> void +{ + Locker locker(inspectorConnectionsLock); + if (!inspectorConnections) + return; + for (auto& entry : *inspectorConnections) { + for (auto* connection : entry.value) { + if (callback(connection)) + return; + } + } +} + +template +static auto forEachConnectionForVM(JSC::VM& vm, Func&& callback) -> void +{ + forEachConnection([&](BunInspectorConnection* connection) -> bool { + if (!connection->globalObject || &connection->globalObject->vm() != &vm) + return false; + return callback(connection); + }); +} + JSC_DECLARE_HOST_FUNCTION(jsFunctionSend); JSC_DECLARE_HOST_FUNCTION(jsFunctionDisconnect); @@ -500,7 +710,6 @@ extern "C" unsigned int Bun__createJSDebugger(Zig::GlobalObject* globalObject) return static_cast(globalObject->scriptExecutionContext()->identifier()); } -extern "C" void Bun__tickWhilePaused(bool*); extern "C" void Bun__ensureDebugger(ScriptExecutionContextIdentifier scriptId, bool pauseOnStart) { @@ -510,17 +719,9 @@ extern "C" void Bun__ensureDebugger(ScriptExecutionContextIdentifier scriptId, b globalObject->m_inspectorDebuggable = BunJSGlobalObjectDebuggable::create(*globalObject); globalObject->m_inspectorDebuggable->init(); - globalObject->setInspectable(true); + makeInspectable(globalObject); - auto& inspector = globalObject->inspectorDebuggable(); - inspector.setInspectable(true); - - Inspector::JSGlobalObjectDebugger* debugger = reinterpret_cast(globalObject->debugger()); - if (debugger) { - debugger->runWhilePausedCallback = [](JSC::JSGlobalObject& globalObject, bool& isDoneProcessingEvents) -> void { - BunInspectorConnection::runWhilePaused(globalObject, isDoneProcessingEvents); - }; - } + installRunWhilePausedCallback(globalObject); if (pauseOnStart) { waitingForConnection = true; } @@ -662,4 +863,239 @@ extern "C" void Debugger__willDispatchAsyncCall(JSGlobalObject* globalObject, As agent->willDispatchAsyncCall(getCallType(callType), callbackId); } + +// Helper functions called from the StopTheWorld callback. +// These run on the main thread at a safe point. + +bool processPendingConnections(JSC::VM& callbackVM) +{ + bool connected = false; + Vector pendingConnections; + forEachConnectionForVM(callbackVM, [&](BunInspectorConnection* connection) -> bool { + if (connection->status == ConnectionStatus::Pending) + pendingConnections.append(connection); + return false; + }); + + for (auto* connection : pendingConnections) { + auto* context = ScriptExecutionContext::getScriptExecutionContext(connection->scriptExecutionContextIdentifier); + if (!context) + continue; + connection->doConnect(*context); + connected = true; + } + return connected; +} + +// Find a VM (other than the given one) that has pending work: +// either a pending connection or a pending pause (bootstrap or message delivery). +// Used to switch the STW callback to the right VM thread. +JSC::VM* findVMWithPendingWork(JSC::VM& excludeVM) +{ + JSC::VM* result = nullptr; + forEachConnection([&](BunInspectorConnection* connection) -> bool { + if (!connection->globalObject || &connection->globalObject->vm() == &excludeVM) + return false; + bool hasPendingConnection = (connection->status == ConnectionStatus::Pending); + bool hasPendingPause = (connection->pauseFlags.load() + & (BunInspectorConnection::kBootstrapPause | BunInspectorConnection::kMessageDeliveryPause)); + if (hasPendingConnection || hasPendingPause) { + result = &connection->globalObject->vm(); + return true; + } + return false; + }); + return result; +} + +// Check if any connection has pending pause flags (bootstrap or message delivery). +uint8_t getPendingPauseFlags() +{ + uint8_t result = 0; + forEachConnection([&](BunInspectorConnection* connection) -> bool { + result |= connection->pauseFlags.load(); + return false; + }); + // Mask out kInPauseLoop — that's not a "pending pause request". + return result & (BunInspectorConnection::kBootstrapPause | BunInspectorConnection::kMessageDeliveryPause); +} + +// Check if breakProgram() should be called after draining CDP messages. +// Returns true if a pause was explicitly requested (bootstrap, Debugger.pause, +// breakpoint). Returns false for plain message delivery. +extern "C" bool Bun__shouldBreakAfterMessageDrain(JSC::VM& vm) +{ + bool hasBootstrapPause = false; + forEachConnectionForVM(vm, [&](BunInspectorConnection* connection) -> bool { + uint8_t flags = connection->pauseFlags.load(); + // Bootstrap pause always needs breakProgram + if (flags & BunInspectorConnection::kBootstrapPause) { + hasBootstrapPause = true; + return true; + } + return false; + }); + if (hasBootstrapPause) + return true; + // Check if the debugger agent scheduled a pause (e.g., Debugger.pause command + // was dispatched during the drain). schedulePauseAtNextOpportunity() enables + // stepping mode, so isStepping() serves as a proxy for detecting a pending + // pause request after CDP message processing. + auto* globalObject = vm.topCallFrame ? vm.topCallFrame->lexicalGlobalObject(vm) : nullptr; + if (globalObject) { + if (auto* debugger = globalObject->debugger()) { + if (debugger->isStepping()) + return true; + } + } + return false; +} + +// Drain queued CDP messages for a VM. Called from the NeedDebuggerBreak +// VMTraps handler before breakProgram() so that commands like Debugger.pause +// are processed first, setting the correct pause reason on the agent. +extern "C" void Bun__drainQueuedCDPMessages(JSC::VM& vm) +{ + // Copy matching connections under the lock, then release it before + // dispatching messages. receiveMessagesOnInspectorThread can trigger + // JS execution that hits a breakpoint → breakProgram() → runWhilePaused(), + // which also acquires inspectorConnectionsLock. Holding the lock across + // the dispatch would deadlock since WTF::Lock is non-recursive. + Vector connections; + forEachConnectionForVM(vm, [&](BunInspectorConnection* connection) -> bool { + if (connection->status == ConnectionStatus::Connected) + connections.append(connection); + return false; + }); + + for (auto* connection : connections) { + auto* context = ScriptExecutionContext::getScriptExecutionContext(connection->scriptExecutionContextIdentifier); + if (!context) + continue; + // Clear the message delivery flag — messages are being drained now. + connection->pauseFlags.fetch_and(~BunInspectorConnection::kMessageDeliveryPause); + connection->receiveMessagesOnInspectorThread( + *context, static_cast(connection->globalObject), false); + } +} + +// Schedule a debugger pause for connected sessions. +// Called during STW after doConnect has already attached the debugger. +// schedulePauseAtNextOpportunity + notifyNeedDebuggerBreak set up a pause +// that fires after STW resumes. The NeedDebuggerBreak handler in VMTraps +// calls breakProgram() to enter the pause from any JIT tier. + +void schedulePauseForConnectedSessions(JSC::VM& vm, bool isBootstrap) +{ + forEachConnectionForVM(vm, [&](BunInspectorConnection* connection) -> bool { + if (connection->status != ConnectionStatus::Connected) + return false; + + if (isBootstrap) + connection->pauseFlags.fetch_or(BunInspectorConnection::kBootstrapPause); + + auto* debugger = connection->globalObject->debugger(); + if (!debugger) + return false; + + // schedulePauseAtNextOpportunity() is NOT thread-safe in general (it calls + // enableStepping → recompileAllJSFunctions), but is safe here because we're + // inside a STW callback — all other VM threads are blocked. + debugger->schedulePauseAtNextOpportunity(); + vm.notifyNeedDebuggerBreak(); + return true; // Only need once per VM + }); +} + +} + +// StopTheWorld callback for SIGUSR1 debugger activation. +// This runs on the main thread at a safe point when VMManager::requestStopAll(JSDebugger) is called. +// +// This handles the case where JS is actively executing (including infinite loops). +// For idle VMs, RuntimeInspector::checkAndActivateInspector handles it via event loop. + +extern "C" bool Bun__tryActivateInspector(); +extern "C" void Bun__activateRuntimeInspectorMode(); +extern "C" void Bun__clearInspectorActivationRequest(); + +JSC::StopTheWorldStatus Bun__stopTheWorldCallback(JSC::VM& vm, JSC::StopTheWorldEvent event) +{ + using namespace JSC; + + // We only act on VMStopped (all VMs have reached a safe point). + // Other events (VMCreated, VMActivated) happen when a secondary VM + // (e.g. the debugger thread's VM, or a worker) is constructed/entered + // while our stop request is pending. We must return RESUME_ALL, not + // CONTINUE: CONTINUE leaves m_currentStopReason set, so if the event-loop + // path concurrently clears the stop request, the caller (notifyVMStop) + // loops with stale state and waits forever on m_worldConditionVariable. + if (event != StopTheWorldEvent::VMStopped) { + // Clear the activation flag so that a subsequent SIGUSR1 can + // re-request STW. Without this, the flag stays true and + // requestInspectorActivation skips requestStopAll (only doing + // eventLoop().wakeup(), which can't interrupt a busy loop). + // We can't call Bun__tryActivateInspector here because that + // activates the inspector, which requires main-thread context. + Bun__clearInspectorActivationRequest(); + return STW_RESUME_ALL(); + } + + // Phase 1: Activate inspector if requested (SIGUSR1 handler sets a flag) + bool activated = Bun__tryActivateInspector(); + if (activated) + Bun__activateRuntimeInspectorMode(); + + // Phase 2: Process pending connections for THIS VM. + // doConnect must run on the connection's owning VM thread. + bool connected = Bun::processPendingConnections(vm); + + // If pending connections or pauses exist on a DIFFERENT VM, switch to it. + if (!connected) { + if (auto* targetVM = Bun::findVMWithPendingWork(vm)) + return STW_CONTEXT_SWITCH(targetVM); + } + + // Phase 3: Handle pending pause/message flags. + uint8_t pendingFlags = Bun::getPendingPauseFlags(); + // Bootstrap pause is only for the FIRST-EVER connection after SIGUSR1 activation. + // On reconnect, connected=true but hasEverBootstrapped is already set, so + // isBootstrap=false. Without this gate, reconnecting clients get a stale + // kBootstrapPause flag that causes Bun__shouldBreakAfterMessageDrain to return + // true during Runtime.evaluate → breakProgram() → deadlock in runWhilePaused. + bool isBootstrap = (connected && !Bun::hasEverBootstrapped.exchange(true)) + || (pendingFlags & Bun::BunInspectorConnection::kBootstrapPause); + if (isBootstrap || (pendingFlags & Bun::BunInspectorConnection::kMessageDeliveryPause)) { + Bun::schedulePauseForConnectedSessions(vm, isBootstrap); + } + + return STW_RESUME_ALL(); +} + +// Zig bindings for VMManager +extern "C" void VMManager__requestStopAll(uint32_t reason) +{ + JSC::VMManager::requestStopAll(static_cast(reason)); +} + +extern "C" void VMManager__requestResumeAll(uint32_t reason) +{ + JSC::VMManager::requestResumeAll(static_cast(reason)); +} + +extern "C" void VM__cancelStop(JSC::VM* vm) +{ + vm->cancelStop(); +} + +// Called from Zig and from the STW callback when the inspector activates. +// Sets runtimeInspectorActivated so that connect() and +// interruptForMessageDelivery() use STW-based message delivery. +extern "C" void Bun__activateRuntimeInspectorMode() +{ + Bun::runtimeInspectorActivated.store(true); + // Mark bootstrap as done so that reconnecting clients on the event loop + // path (where the STW callback may not set hasEverBootstrapped due to + // short-circuit evaluation) don't get a spurious bootstrap pause. + Bun::hasEverBootstrapped.store(true); } diff --git a/src/bun.js/bindings/BunProcess.cpp b/src/bun.js/bindings/BunProcess.cpp index 9947e59ef0b..4ccfb700425 100644 --- a/src/bun.js/bindings/BunProcess.cpp +++ b/src/bun.js/bindings/BunProcess.cpp @@ -1336,6 +1336,9 @@ extern "C" bool Bun__shouldIgnoreOneDisconnectEventListener(JSC::JSGlobalObject* extern "C" void Bun__ensureSignalHandler(); extern "C" bool Bun__isMainThreadVM(); extern "C" void Bun__onPosixSignal(int signalNumber); +#ifdef SIGUSR1 +extern "C" void Bun__Sigusr1Handler__uninstall(); +#endif __attribute__((noinline)) static void forwardSignal(int signalNumber) { @@ -1494,6 +1497,14 @@ static void onDidChangeListeners(EventEmitter& eventEmitter, const Identifier& e action.sa_flags = SA_RESTART; sigaction(signalNumber, &action, nullptr); + +#ifdef SIGUSR1 + // When user adds a SIGUSR1 listener, uninstall the automatic + // inspector activation handler. User handlers take precedence. + if (signalNumber == SIGUSR1) { + Bun__Sigusr1Handler__uninstall(); + } +#endif #else signal_handle.handle = Bun__UVSignalHandle__init( eventEmitter.scriptExecutionContext()->jsGlobalObject(), @@ -3828,6 +3839,81 @@ JSC_DEFINE_HOST_FUNCTION(Process_functionReallyKill, (JSC::JSGlobalObject * glob RELEASE_AND_RETURN(scope, JSValue::encode(jsNumber(result))); } +JSC_DEFINE_HOST_FUNCTION(Process_functionDebugProcess, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame)) +{ + auto scope = DECLARE_THROW_SCOPE(JSC::getVM(globalObject)); + + if (callFrame->argumentCount() < 1) { + throwVMError(globalObject, scope, "process._debugProcess requires a pid argument"_s); + return {}; + } + + int pid = callFrame->argument(0).toInt32(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + + if (pid <= 0) { + throwVMError(globalObject, scope, "process._debugProcess requires a positive pid"_s); + return {}; + } + + // posix we can just send SIGUSR1, on windows we map a file to `bun-debug-handler-` and send to that +#if !OS(WINDOWS) + int result = kill(pid, SIGUSR1); + if (result < 0) { + throwVMError(globalObject, scope, makeString("Failed to send SIGUSR1 to process "_s, pid, ": process may not exist or permission denied"_s)); + return {}; + } +#else + wchar_t mappingName[64]; + swprintf(mappingName, 64, L"bun-debug-handler-%d", pid); + + HANDLE hMapping = OpenFileMappingW(FILE_MAP_READ, FALSE, mappingName); + if (!hMapping) { + DWORD err = GetLastError(); + if (err == ERROR_FILE_NOT_FOUND) { + // Match Node.js error message for compatibility + throwVMError(globalObject, scope, "The system cannot find the file specified."_s); + } else { + throwVMError(globalObject, scope, makeString("OpenFileMappingW failed with error "_s, static_cast(err))); + } + return {}; + } + + void* pFunc = MapViewOfFile(hMapping, FILE_MAP_READ, 0, 0, sizeof(void*)); + if (!pFunc) { + CloseHandle(hMapping); + throwVMError(globalObject, scope, makeString("Failed to map debug handler for process "_s, pid)); + return {}; + } + + LPTHREAD_START_ROUTINE threadProc = *reinterpret_cast(pFunc); + UnmapViewOfFile(pFunc); + CloseHandle(hMapping); + + HANDLE hProcess = OpenProcess(PROCESS_CREATE_THREAD | PROCESS_QUERY_INFORMATION | PROCESS_VM_OPERATION | PROCESS_VM_WRITE | PROCESS_VM_READ, FALSE, pid); + if (!hProcess) { + throwVMError(globalObject, scope, makeString("Failed to open process "_s, pid, ": access denied or process not found"_s)); + return {}; + } + + HANDLE hThread = CreateRemoteThread(hProcess, NULL, 0, threadProc, NULL, 0, NULL); + if (!hThread) { + CloseHandle(hProcess); + throwVMError(globalObject, scope, makeString("Failed to create remote thread in process "_s, pid)); + return {}; + } + + // Wait briefly for the thread to complete because closing the handles + // immediately could terminate the remote thread before it finishes + // triggering the inspector in the target process. + WaitForSingleObject(hThread, 1000); + CloseHandle(hThread); + CloseHandle(hProcess); +#endif + + return JSValue::encode(jsUndefined()); +} + JSC_DEFINE_HOST_FUNCTION(Process_functionKill, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame)) { auto scope = DECLARE_THROW_SCOPE(JSC::getVM(globalObject)); @@ -3967,7 +4053,7 @@ extern "C" void Process__emitErrorEvent(Zig::GlobalObject* global, EncodedJSValu /* Source for Process.lut.h @begin processObjectTable _debugEnd Process_stubEmptyFunction Function 0 - _debugProcess Process_stubEmptyFunction Function 0 + _debugProcess Process_functionDebugProcess Function 1 _eval processGetEval CustomAccessor _fatalException Process_stubEmptyFunction Function 1 _getActiveHandles Process_stubFunctionReturningArray Function 0 diff --git a/src/bun.js/bindings/VM.zig b/src/bun.js/bindings/VM.zig index cc29608cf0f..097ff40bc9a 100644 --- a/src/bun.js/bindings/VM.zig +++ b/src/bun.js/bindings/VM.zig @@ -147,6 +147,14 @@ pub const VM = opaque { return JSC__VM__isEntered(vm); } + extern fn VM__cancelStop(vm: *VM) void; + + /// Clears the NeedStopTheWorld trap bit and restores the stack limit. + /// Thread safe. See jsc's "VMTraps.h" for explanation on traps. + pub fn cancelStop(vm: *VM) void { + VM__cancelStop(vm); + } + pub fn isTerminationException(vm: *VM, exception: *bun.jsc.Exception) bool { return bun.cpp.JSC__VM__isTerminationException(vm, exception); } diff --git a/src/bun.js/bindings/VMManager.zig b/src/bun.js/bindings/VMManager.zig new file mode 100644 index 00000000000..07a6fe33248 --- /dev/null +++ b/src/bun.js/bindings/VMManager.zig @@ -0,0 +1,30 @@ +/// Zig bindings for JSC::VMManager +/// +/// VMManager coordinates multiple VMs (workers) and provides the StopTheWorld +/// mechanism for safely interrupting JavaScript execution at safe points. +/// +/// Note: StopReason values are bitmasks (1 << bit_position), not sequential. +/// This matches the C++ enum in VMManager.h which uses: +/// enum class StopReason : StopRequestBits { None = 0, GC = 1, WasmDebugger = 2, MemoryDebugger = 4, JSDebugger = 8 } +pub const StopReason = enum(u32) { + None = 0, + GC = 1 << 0, // 1 + WasmDebugger = 1 << 1, // 2 + MemoryDebugger = 1 << 2, // 4 + JSDebugger = 1 << 3, // 8 +}; + +extern fn VMManager__requestStopAll(reason: StopReason) void; +extern fn VMManager__requestResumeAll(reason: StopReason) void; + +/// Request all VMs to stop at their next safe point. +/// The registered StopTheWorld callback for the given reason will be called +/// on the main thread once all VMs have stopped. +pub fn requestStopAll(reason: StopReason) void { + VMManager__requestStopAll(reason); +} + +/// Clear the pending stop request and resume all VMs. +pub fn requestResumeAll(reason: StopReason) void { + VMManager__requestResumeAll(reason); +} diff --git a/src/bun.js/bindings/ZigGlobalObject.cpp b/src/bun.js/bindings/ZigGlobalObject.cpp index 660be965537..71ecc7a7eb5 100644 --- a/src/bun.js/bindings/ZigGlobalObject.cpp +++ b/src/bun.js/bindings/ZigGlobalObject.cpp @@ -55,6 +55,7 @@ #include "JavaScriptCore/StackFrame.h" #include "JavaScriptCore/StackVisitor.h" #include "JavaScriptCore/VM.h" +#include "JavaScriptCore/VMManager.h" #include "AddEventListenerOptions.h" #include "AsyncContextFrame.h" #include "BunClientData.h" @@ -268,6 +269,10 @@ extern "C" unsigned getJSCBytecodeCacheVersion() extern "C" void Bun__REPRL__registerFuzzilliFunctions(Zig::GlobalObject*); #endif +// StopTheWorld callback for SIGUSR1 debugger activation (defined in BunDebugger.cpp). +// Note: This is a C++ function - cannot use extern "C" because it returns std::pair. +JSC::StopTheWorldStatus Bun__stopTheWorldCallback(JSC::VM&, JSC::StopTheWorldEvent); + extern "C" void JSCInitialize(const char* envp[], size_t envc, void (*onCrash)(const char* ptr, size_t length), bool evalMode) { static std::once_flag jsc_init_flag; @@ -303,6 +308,15 @@ extern "C" void JSCInitialize(const char* envp[], size_t envc, void (*onCrash)(c JSC::Options::useJITCage() = false; JSC::Options::useShadowRealm() = true; JSC::Options::useV8DateParser() = true; + // NOTE: We intentionally do NOT set usePollingTraps = true here. + // Signal-based traps (InvalidationPoint in DFG/FTL) have zero steady-state + // overhead vs polling (CheckTraps), which adds a load+branch at every loop + // back-edge and inhibits DFG structure-watching optimizations. + // The tradeoff: signal-based trap delivery for requestStopAll (used by the + // runtime inspector via SIGUSR1) is ~94% reliable vs 100% with polling. + // We accept this for the inspector path since speed is the priority. + // IMPORTANT: JSC::Options are frozen (mprotected read-only) after init. + // Writing to usePollingTraps later crashes on Linux with SEGV at offset 0xB34. JSC::Options::evalMode() = evalMode; JSC::Options::heapGrowthSteepnessFactor() = 1.0; JSC::Options::heapGrowthMaxIncrease() = 2.0; @@ -331,6 +345,10 @@ extern "C" void JSCInitialize(const char* envp[], size_t envc, void (*onCrash)(c } JSC::Options::assertOptionsAreCoherent(); }); // end JSC::initialize lambda + + // Register the StopTheWorld callback for SIGUSR1 debugger activation. + // This allows us to interrupt infinite loops and activate the debugger. + JSC::VMManager::setJSDebuggerCallback(Bun__stopTheWorldCallback); }); // end std::call_once lambda // NOLINTEND diff --git a/src/bun.js/event_loop.zig b/src/bun.js/event_loop.zig index 849416dcf09..6c18cd894ef 100644 --- a/src/bun.js/event_loop.zig +++ b/src/bun.js/event_loop.zig @@ -294,6 +294,10 @@ pub fn runImminentGCTimer(this: *EventLoop) void { pub fn tickConcurrentWithCount(this: *EventLoop) usize { this.updateCounts(); + if (this.virtual_machine.is_main_thread) { + RuntimeInspector.checkAndActivateInspector(); + } + if (comptime Environment.isPosix) { if (this.signal_handler) |signal_handler| { signal_handler.drain(this); @@ -706,6 +710,7 @@ pub const DeferredTaskQueue = @import("./event_loop/DeferredTaskQueue.zig"); pub const DeferredRepeatingTask = DeferredTaskQueue.DeferredRepeatingTask; pub const PosixSignalHandle = @import("./event_loop/PosixSignalHandle.zig"); pub const PosixSignalTask = PosixSignalHandle.PosixSignalTask; +pub const RuntimeInspector = @import("./event_loop/RuntimeInspector.zig"); pub const MiniEventLoop = @import("./event_loop/MiniEventLoop.zig"); pub const MiniVM = MiniEventLoop.MiniVM; pub const JsVM = MiniEventLoop.JsVM; diff --git a/src/bun.js/event_loop/RuntimeInspector.zig b/src/bun.js/event_loop/RuntimeInspector.zig new file mode 100644 index 00000000000..aba097fe6f1 --- /dev/null +++ b/src/bun.js/event_loop/RuntimeInspector.zig @@ -0,0 +1,433 @@ +/// Runtime Inspector Activation Handler +/// +/// Activates the inspector/debugger at runtime via `process._debugProcess(pid)`. +/// +/// On POSIX (macOS/Linux): +/// - A "SignalInspector" thread sleeps on a semaphore +/// - SIGUSR1 handler runs on the main thread but in signal context (only +/// async-signal-safe functions allowed), posts to the semaphore +/// - SignalInspector thread wakes in normal context, calls VMManager::requestStopAll +/// - JSC stops all VMs at safe points and calls our StopTheWorld callback +/// - Callback runs on main thread, activates inspector, then resumes all VMs +/// - Usage: `kill -USR1 ` to start debugger +/// +/// On Windows: +/// - Uses named file mapping mechanism (same as Node.js) +/// - Creates "bun-debug-handler-" shared memory with function pointer +/// - External tools use CreateRemoteThread() to call that function +/// - The remote thread is already in normal context, so can call JSC APIs directly +/// - Usage: `process._debugProcess(pid)` from another Bun/Node process +/// +/// Why StopTheWorld? Unlike notifyNeedDebuggerBreak() which only works if a debugger +/// is already attached, StopTheWorld guarantees a callback runs on the main thread +/// at a safe point - even during `while(true) {}` loops. This allows us to CREATE +/// the debugger before pausing. +/// +const RuntimeInspector = @This(); + +const log = Output.scoped(.RuntimeInspector, .hidden); + +/// Default port for runtime-activated inspector (via SIGUSR1/process._debugProcess). +/// If the user pre-configured a port via --inspect-port=, that port is used +/// instead. Use --inspect-port=0 for automatic port selection. +const default_inspector_port = "6499"; + +var installed: std.atomic.Value(bool) = std.atomic.Value(bool).init(false); +var inspector_activation_requested: std.atomic.Value(bool) = std.atomic.Value(bool).init(false); + +/// Called from the dedicated SignalInspector thread (POSIX) or remote thread (Windows). +/// This runs in normal thread context, so it's safe to call JSC APIs. +fn requestInspectorActivation() void { + const already_requested = inspector_activation_requested.swap(true, .acq_rel); + + // Two mechanisms work together to handle all cases: + // + // 1. StopTheWorld (for busy loops like `while(true){}`): + // requestStopAll sets a trap that fires at the next JS safe point. + // Our callback (Bun__stopTheWorldCallback) then activates the inspector. + // + // 2. Event loop wakeup (for idle VMs waiting on I/O): + // The wakeup causes checkAndActivateInspector to run, which activates + // the inspector and calls requestResumeAll to clear any pending trap. + // + // Both mechanisms check inspector_activation_requested and clear it atomically, + // so only one will actually activate the inspector. + + if (!already_requested) { + // First request: start the StopTheWorld mechanism. + // On re-entry (retry), skip this — STW is already pending with its + // own SignalSender retry loop. + jsc.VMManager.requestStopAll(.JSDebugger); + } + + // Always fire event loop wakeup, even on retries. This is cheap and + // handles cases where the first wakeup arrived before the event loop + // was in its blocking wait. + if (VirtualMachine.getMainThreadVM()) |vm| { + vm.eventLoop().wakeup(); + } +} + +/// Called from main thread during event loop tick. +/// This handles the case where the VM is idle (waiting on I/O). +/// For active JS execution (including infinite loops), the StopTheWorld callback handles it. +pub fn checkAndActivateInspector() void { + if (!inspector_activation_requested.swap(false, .acq_rel)) { + return; + } + + // Cancel the pending STW and clear residual trap state BEFORE activating. + // This must happen before Debugger.create() spawns the debugger thread: + // otherwise the debugger VM's construction calls notifyVMConstruction + // while m_worldMode == Stopping, the debugger thread becomes the STW + // servicing thread (since the main VM is not entered while idle in + // epoll_wait), our callback returns STW_CONTINUE for VMCreated events, + // and when the main thread's cleanup runs concurrently, m_currentStopReason + // is left stale — leaving the debugger thread waiting forever on + // m_worldConditionVariable. The banner is never printed and tests time out. + if (VirtualMachine.getMainThreadVM()) |vm| { + vm.jsc_vm.cancelStop(); + } + jsc.VMManager.requestResumeAll(.JSDebugger); + + if (tryActivateInspector()) { + // Set the C++ runtimeInspectorActivated flag so that connect() and + // interruptForMessageDelivery() use STW-based message delivery, + // same as when activated via the StopTheWorld callback path. + activateRuntimeInspectorMode(); + } +} + +extern fn Bun__activateRuntimeInspectorMode() void; + +fn activateRuntimeInspectorMode() void { + Bun__activateRuntimeInspectorMode(); +} + +/// Tries to activate the inspector. Returns true if activated, false otherwise. +/// Caller must have already consumed the activation request flag. +fn tryActivateInspector() bool { + const vm = VirtualMachine.get(); + + if (vm.is_shutting_down) { + log("VM is shutting down, ignoring inspector activation request", .{}); + return false; + } + + if (vm.debugger != null) { + log("Debugger already active, ignoring activation request", .{}); + return false; + } + + activateInspector(vm) catch |err| { + Output.prettyErrorln("Failed to activate inspector: {s}\n", .{@errorName(err)}); + Output.flush(); + return false; + }; + + return true; +} + +fn activateInspector(vm: *VirtualMachine) !void { + log("Activating inspector", .{}); + + vm.debugger = .{ + .path_or_port = vm.inspect_port orelse default_inspector_port, + .from_environment_variable = "", + .wait_for_connection = .off, + .set_breakpoint_on_first_line = false, + .mode = .listen, + }; + + const saved_minify_identifiers = vm.transpiler.options.minify_identifiers; + const saved_minify_syntax = vm.transpiler.options.minify_syntax; + const saved_minify_whitespace = vm.transpiler.options.minify_whitespace; + const saved_debugger = vm.transpiler.options.debugger; + + vm.transpiler.options.minify_identifiers = false; + vm.transpiler.options.minify_syntax = false; + vm.transpiler.options.minify_whitespace = false; + vm.transpiler.options.debugger = true; + + Debugger.create(vm, vm.global) catch |err| { + vm.debugger = null; + vm.transpiler.options.minify_identifiers = saved_minify_identifiers; + vm.transpiler.options.minify_syntax = saved_minify_syntax; + vm.transpiler.options.minify_whitespace = saved_minify_whitespace; + vm.transpiler.options.debugger = saved_debugger; + return err; + }; +} + +pub fn isInstalled() bool { + return installed.load(.acquire); +} + +const posix = if (Environment.isPosix) struct { + var semaphore: ?Semaphore = null; + var thread: ?std.Thread = null; + var shutting_down: std.atomic.Value(bool) = std.atomic.Value(bool).init(false); + + fn signalHandler(_: c_int) callconv(.c) void { + // Signal handlers can only call async-signal-safe functions. + // Semaphore.post() is async-signal-safe (uses Mach semaphores on macOS, + // POSIX semaphores on Linux). + if (semaphore) |sem| _ = sem.post(); + } + + /// Dedicated thread that waits on the semaphore. + /// When woken, it calls requestInspectorActivation() in normal thread context. + fn signalInspectorThread() void { + Output.Source.configureNamedThread("SignalInspector"); + + while (true) { + _ = semaphore.?.wait(); + if (shutting_down.load(.acquire)) { + log("SignalInspector thread exiting", .{}); + return; + } + log("SignalInspector thread woke, activating inspector", .{}); + requestInspectorActivation(); + } + } + + fn install() bool { + semaphore = Semaphore.init() orelse { + log("semaphore init failed", .{}); + return false; + }; + + // Spawn the SignalInspector thread + thread = std.Thread.spawn(.{ + .stack_size = 512 * 1024, + }, signalInspectorThread, .{}) catch |err| { + log("thread spawn failed: {s}", .{@errorName(err)}); + if (semaphore) |sem| sem.deinit(); + semaphore = null; + return false; + }; + + // Install SIGUSR1 handler + var act: std.posix.Sigaction = .{ + .handler = .{ .handler = signalHandler }, + .mask = std.posix.sigemptyset(), + .flags = std.posix.SA.RESTART, + }; + std.posix.sigaction(std.posix.SIG.USR1, &act, null); + return true; + } + + fn uninstall() void { + // Signal the thread to exit. We don't join because: + // 1. This is called from JS context (process.on('SIGUSR1', ...)) + // 2. Blocking the JS thread is bad + // 3. The thread will exit on its own after checking shutting_down + // The thread and semaphore are "leaked" and anyway this happens once + // per process lifetime when user installs their own SIGUSR1 handler + shutting_down.store(true, .release); + if (semaphore) |sem| _ = sem.post(); + } +} else struct {}; + +const windows = if (Environment.isWindows) struct { + const win32 = std.os.windows; + const HANDLE = win32.HANDLE; + const DWORD = win32.DWORD; + const BOOL = win32.BOOL; + const LPVOID = *anyopaque; + const LPCWSTR = [*:0]const u16; + const SIZE_T = usize; + const INVALID_HANDLE_VALUE = win32.INVALID_HANDLE_VALUE; + + const SECURITY_ATTRIBUTES = extern struct { + nLength: DWORD, + lpSecurityDescriptor: ?LPVOID, + bInheritHandle: BOOL, + }; + + const PAGE_READWRITE: DWORD = 0x04; + const FILE_MAP_ALL_ACCESS: DWORD = 0xF001F; + + const LPTHREAD_START_ROUTINE = *const fn (?LPVOID) callconv(.winapi) DWORD; + + extern "kernel32" fn CreateFileMappingW( + hFile: HANDLE, + lpFileMappingAttributes: ?*SECURITY_ATTRIBUTES, + flProtect: DWORD, + dwMaximumSizeHigh: DWORD, + dwMaximumSizeLow: DWORD, + lpName: ?LPCWSTR, + ) callconv(.winapi) ?HANDLE; + + extern "kernel32" fn MapViewOfFile( + hFileMappingObject: HANDLE, + dwDesiredAccess: DWORD, + dwFileOffsetHigh: DWORD, + dwFileOffsetLow: DWORD, + dwNumberOfBytesToMap: SIZE_T, + ) callconv(.winapi) ?LPVOID; + + extern "kernel32" fn UnmapViewOfFile( + lpBaseAddress: LPVOID, + ) callconv(.winapi) BOOL; + + extern "kernel32" fn GetCurrentProcessId() callconv(.winapi) DWORD; + + var mapping_handle: ?HANDLE = null; + + /// Called via CreateRemoteThread from another process. + fn startDebugThreadProc(_: ?LPVOID) callconv(.winapi) DWORD { + requestInspectorActivation(); + return 0; + } + + fn install() bool { + const pid = GetCurrentProcessId(); + + var mapping_name_buf: [64]u8 = undefined; + const name_slice = std.fmt.bufPrint(&mapping_name_buf, "bun-debug-handler-{d}", .{pid}) catch return false; + + var wide_name: [64]u16 = undefined; + const wide_name_z = bun.strings.toWPath(&wide_name, name_slice); + + mapping_handle = CreateFileMappingW( + INVALID_HANDLE_VALUE, + null, + PAGE_READWRITE, + 0, + @sizeOf(LPTHREAD_START_ROUTINE), + wide_name_z.ptr, + ); + + if (mapping_handle) |handle| { + const handler_ptr = MapViewOfFile( + handle, + FILE_MAP_ALL_ACCESS, + 0, + 0, + @sizeOf(LPTHREAD_START_ROUTINE), + ); + + if (handler_ptr) |ptr| { + // MapViewOfFile returns page-aligned memory, which satisfies + // the alignment requirements for function pointers. + const typed_ptr: *LPTHREAD_START_ROUTINE = @ptrCast(@alignCast(ptr)); + typed_ptr.* = &startDebugThreadProc; + _ = UnmapViewOfFile(ptr); + return true; + } else { + log("MapViewOfFile failed", .{}); + _ = bun.windows.CloseHandle(handle); + mapping_handle = null; + return false; + } + } else { + log("CreateFileMappingW failed for bun-debug-handler-{d}", .{pid}); + return false; + } + } + + fn uninstall() void { + if (mapping_handle) |handle| { + _ = bun.windows.CloseHandle(handle); + mapping_handle = null; + } + } +} else struct {}; + +/// Install the runtime inspector handler. +/// Safe to call multiple times - subsequent calls are no-ops. +pub fn installIfNotAlready() void { + if (installed.swap(true, .acq_rel)) { + return; + } + + const success = if (comptime Environment.isPosix) + posix.install() + else if (comptime Environment.isWindows) + windows.install() + else + false; + + if (!success) { + installed.store(false, .release); + } +} + +/// Uninstall when a user SIGUSR1 listener takes over (POSIX only). +pub fn uninstallForUserHandler() void { + if (!installed.swap(false, .acq_rel)) { + return; + } + + if (comptime Environment.isPosix) { + posix.uninstall(); + } +} + +/// Set SIGUSR1 to default action when --disable-sigusr1 is used. +/// This allows SIGUSR1 to use its default behavior (terminate process). +pub fn setDefaultSigusr1Action() void { + if (comptime Environment.isPosix) { + var act: std.posix.Sigaction = .{ + .handler = .{ .handler = std.posix.SIG.DFL }, + .mask = std.posix.sigemptyset(), + .flags = 0, + }; + std.posix.sigaction(std.posix.SIG.USR1, &act, null); + } +} + +/// Ignore SIGUSR1 when debugger is already enabled via CLI flags. +/// This prevents SIGUSR1 from terminating the process when the user is already debugging. +pub fn ignoreSigusr1() void { + if (comptime Environment.isPosix) { + var act: std.posix.Sigaction = .{ + .handler = .{ .handler = std.posix.SIG.IGN }, + .mask = std.posix.sigemptyset(), + .flags = 0, + }; + std.posix.sigaction(std.posix.SIG.USR1, &act, null); + } +} + +/// Called from C++ when user adds a SIGUSR1 listener +export fn Bun__Sigusr1Handler__uninstall() void { + uninstallForUserHandler(); +} + +/// Called from C++ StopTheWorld callback. +/// Returns true if inspector was activated, false if already active or not requested. +export fn Bun__tryActivateInspector() bool { + if (!inspector_activation_requested.swap(false, .acq_rel)) { + return false; + } + return tryActivateInspector(); +} + +/// Clear the inspector activation flag without activating. +/// Called from the STW callback for non-VMStopped events (e.g. VMCreated) +/// where we can't activate the inspector (wrong thread context) but need +/// to unstick the flag so that a subsequent SIGUSR1 can re-request STW. +export fn Bun__clearInspectorActivationRequest() void { + _ = inspector_activation_requested.swap(false, .acq_rel); +} + +comptime { + if (Environment.isPosix) { + _ = Bun__Sigusr1Handler__uninstall; + } + _ = Bun__tryActivateInspector; + _ = Bun__clearInspectorActivationRequest; +} + +const Semaphore = @import("../../sync/Semaphore.zig"); +const std = @import("std"); + +const bun = @import("bun"); +const Environment = bun.Environment; +const Output = bun.Output; + +const jsc = bun.jsc; +const Debugger = jsc.Debugger; +const VirtualMachine = jsc.VirtualMachine; diff --git a/src/bun.js/jsc.zig b/src/bun.js/jsc.zig index ca434864f35..89c54be0051 100644 --- a/src/bun.js/jsc.zig +++ b/src/bun.js/jsc.zig @@ -77,6 +77,7 @@ pub const SystemError = @import("./bindings/SystemError.zig").SystemError; pub const URL = @import("./bindings/URL.zig").URL; pub const URLSearchParams = @import("./bindings/URLSearchParams.zig").URLSearchParams; pub const VM = @import("./bindings/VM.zig").VM; +pub const VMManager = @import("./bindings/VMManager.zig"); pub const Weak = @import("./Weak.zig").Weak; pub const WeakRefType = @import("./Weak.zig").WeakRefType; pub const Exception = @import("./bindings/Exception.zig").Exception; diff --git a/src/cli.zig b/src/cli.zig index 883d47d7a35..39114e7252e 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -408,6 +408,10 @@ pub const Command = struct { name: []const u8 = "", dir: []const u8 = "", } = .{}, + /// Disable SIGUSR1 handler for runtime debugger activation + disable_sigusr1: bool = false, + /// Pre-configure inspector port for runtime activation (SIGUSR1/process._debugProcess) + inspect_port: ?[]const u8 = null, }; var global_cli_ctx: Context = undefined; diff --git a/src/cli/Arguments.zig b/src/cli/Arguments.zig index a9953bbd323..40e794af0d6 100644 --- a/src/cli/Arguments.zig +++ b/src/cli/Arguments.zig @@ -87,6 +87,8 @@ pub const runtime_params_ = [_]ParamType{ clap.parseParam("--inspect ? Activate Bun's debugger") catch unreachable, clap.parseParam("--inspect-wait ? Activate Bun's debugger, wait for a connection before executing") catch unreachable, clap.parseParam("--inspect-brk ? Activate Bun's debugger, set breakpoint on first line of code and wait") catch unreachable, + clap.parseParam("--inspect-port Set inspector port for runtime debugger activation (0 for random)") catch unreachable, + clap.parseParam("--disable-sigusr1 Disable SIGUSR1 handler for runtime debugger activation") catch unreachable, clap.parseParam("--cpu-prof Start CPU profiler and write profile to disk on exit") catch unreachable, clap.parseParam("--cpu-prof-name Specify the name of the CPU profile file") catch unreachable, clap.parseParam("--cpu-prof-dir Specify the directory where the CPU profile will be saved") catch unreachable, @@ -819,6 +821,8 @@ pub fn parse(allocator: std.mem.Allocator, ctx: Command.Context, comptime cmd: C ctx.runtime_options.smol = args.flag("--smol"); ctx.runtime_options.preconnect = args.options("--fetch-preconnect"); ctx.runtime_options.expose_gc = args.flag("--expose-gc"); + ctx.runtime_options.disable_sigusr1 = args.flag("--disable-sigusr1"); + ctx.runtime_options.inspect_port = args.option("--inspect-port"); if (args.option("--console-depth")) |depth_str| { const depth = std.fmt.parseInt(u16, depth_str, 10) catch { diff --git a/src/cli/repl_command.zig b/src/cli/repl_command.zig index df9c2e7f817..b27759fd7b5 100644 --- a/src/cli/repl_command.zig +++ b/src/cli/repl_command.zig @@ -48,6 +48,8 @@ pub const ReplCommand = struct { .debugger = ctx.runtime_options.debugger, .dns_result_order = DNSResolver.Order.fromStringOrDie(ctx.runtime_options.dns_result_order), .is_main_thread = true, + .disable_sigusr1 = ctx.runtime_options.disable_sigusr1, + .inspect_port = ctx.runtime_options.inspect_port, }); var b = &vm.transpiler; diff --git a/src/cli/test_command.zig b/src/cli/test_command.zig index 5e81bf0a542..0c8fc9c965f 100644 --- a/src/cli/test_command.zig +++ b/src/cli/test_command.zig @@ -1413,6 +1413,8 @@ pub const TestCommand = struct { .smol = ctx.runtime_options.smol, .debugger = ctx.runtime_options.debugger, .is_main_thread = true, + .disable_sigusr1 = ctx.runtime_options.disable_sigusr1, + .inspect_port = ctx.runtime_options.inspect_port, }, ); vm.argv = ctx.passthrough; diff --git a/src/sync/Semaphore.zig b/src/sync/Semaphore.zig new file mode 100644 index 00000000000..341684a4a68 --- /dev/null +++ b/src/sync/Semaphore.zig @@ -0,0 +1,39 @@ +//! Async-signal-safe semaphore. +//! +//! This is a thin wrapper around the C++ Bun::Semaphore class, which uses: +//! - macOS: Mach semaphores (semaphore_signal is async-signal-safe) +//! - Linux: POSIX semaphores (sem_post is async-signal-safe) +//! - Windows: libuv semaphores +//! +//! Unlike std.Thread.Semaphore (which uses Mutex + Condition), this +//! implementation's post/signal operation is safe to call from signal handlers. + +const Semaphore = @This(); + +#ptr: *anyopaque, + +pub fn init() ?Semaphore { + const ptr = Bun__Semaphore__create(0) orelse return null; + return .{ .#ptr = ptr }; +} + +pub fn deinit(self: Semaphore) void { + Bun__Semaphore__destroy(self.#ptr); +} + +/// Signal the semaphore, waking one waiting thread. +/// This is async-signal-safe and can be called from signal handlers. +pub fn post(self: Semaphore) bool { + return Bun__Semaphore__signal(self.#ptr); +} + +/// Wait for the semaphore to be signaled. +/// Blocks until another thread calls post(). +pub fn wait(self: Semaphore) bool { + return Bun__Semaphore__wait(self.#ptr); +} + +extern fn Bun__Semaphore__create(value: c_uint) ?*anyopaque; +extern fn Bun__Semaphore__destroy(sem: *anyopaque) void; +extern fn Bun__Semaphore__signal(sem: *anyopaque) bool; +extern fn Bun__Semaphore__wait(sem: *anyopaque) bool; diff --git a/src/vm/Semaphore.cpp b/src/vm/Semaphore.cpp index f1eec7ef05f..e52d2f591ed 100644 --- a/src/vm/Semaphore.cpp +++ b/src/vm/Semaphore.cpp @@ -1,5 +1,9 @@ #include "Semaphore.h" +#if !OS(WINDOWS) && !OS(DARWIN) +#include +#endif + namespace Bun { Semaphore::Semaphore(unsigned int value) @@ -42,10 +46,45 @@ bool Semaphore::wait() uv_sem_wait(&m_semaphore); return true; #elif OS(DARWIN) - return semaphore_wait(m_semaphore) == KERN_SUCCESS; + // Retry on KERN_ABORTED — the Mach equivalent of EINTR. + // Signals like SIGCHLD or SIGWINCH can interrupt semaphore_wait. + kern_return_t result; + while ((result = semaphore_wait(m_semaphore)) != KERN_SUCCESS) { + if (result != KERN_ABORTED) + return false; + } + return true; #else - return sem_wait(&m_semaphore) == 0; + // Retry on EINTR - sem_wait can be interrupted by any signal + while (sem_wait(&m_semaphore) != 0) { + if (errno != EINTR) + return false; + } + return true; #endif } } // namespace Bun + +extern "C" { + +Bun::Semaphore* Bun__Semaphore__create(unsigned int value) +{ + return new Bun::Semaphore(value); +} + +void Bun__Semaphore__destroy(Bun::Semaphore* sem) +{ + delete sem; +} + +bool Bun__Semaphore__signal(Bun::Semaphore* sem) +{ + return sem->signal(); +} + +bool Bun__Semaphore__wait(Bun::Semaphore* sem) +{ + return sem->wait(); +} +} diff --git a/test/js/bun/runtime-inspector/runtime-inspector-posix.test.ts b/test/js/bun/runtime-inspector/runtime-inspector-posix.test.ts new file mode 100644 index 00000000000..625bf3d7ef2 --- /dev/null +++ b/test/js/bun/runtime-inspector/runtime-inspector-posix.test.ts @@ -0,0 +1,441 @@ +import { spawn } from "bun"; +import { describe, expect, setDefaultTimeout, test } from "bun:test"; +import { bunEnv, bunExe, isASAN, isWindows, tempDir } from "harness"; +import { join } from "path"; + +// Inspector tests spawn subprocesses and wait for inspector activation — 5s default is too short. +setDefaultTimeout(60_000); + +// Timeout for waiting on stream reader loops (30s matches runtime-inspector.test.ts) +const STREAM_TIMEOUT_MS = 30_000; + +// Helper: read from a stream until condition is met, with a timeout to prevent hanging +async function readStreamUntil( + reader: ReadableStreamDefaultReader, + condition: (output: string) => boolean, + timeoutMs = STREAM_TIMEOUT_MS, +): Promise { + const decoder = new TextDecoder(); + let output = ""; + const startTime = Date.now(); + + while (!condition(output)) { + if (Date.now() - startTime > timeoutMs) { + throw new Error(`Timeout after ${timeoutMs}ms waiting for stream condition. Got: "${output}"`); + } + const { value, done } = await reader.read(); + if (done) break; + output += decoder.decode(value, { stream: true }); + } + return output; +} + +// Helper: wait for the full inspector banner (header + footer = 2 occurrences of "Bun Inspector") +function hasBanner(stderr: string): boolean { + return (stderr.match(/Bun Inspector/g) || []).length >= 2; +} + +// POSIX-specific tests (SIGUSR1 mechanism) - macOS and Linux only +describe.skipIf(isWindows)("Runtime inspector SIGUSR1 activation", () => { + test.skipIf(isASAN)("activates inspector when no user listener", async () => { + using dir = tempDir("sigusr1-activate-test", { + "test.js": ` + const fs = require("fs"); + const path = require("path"); + + // Write PID so parent can send signal + fs.writeFileSync(path.join(process.cwd(), "pid"), String(process.pid)); + console.log("READY"); + + // Keep process alive + setInterval(() => {}, 1000); + `, + }); + + await using proc = spawn({ + cmd: [bunExe(), "--inspect-port=0", "test.js"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const reader = proc.stdout.getReader(); + await readStreamUntil(reader, s => s.includes("READY")); + reader.releaseLock(); + + const pid = parseInt(await Bun.file(join(String(dir), "pid")).text(), 10); + expect(pid).toBeGreaterThan(0); + + // Send SIGUSR1 + process.kill(pid, "SIGUSR1"); + + // Wait for inspector to activate by reading stderr until the full banner appears + const stderrReader = proc.stderr.getReader(); + const stderr = await readStreamUntil(stderrReader, hasBanner); + stderrReader.releaseLock(); + + // Kill process + proc.kill(); + await proc.exited; + + expect(stderr).toContain("Bun Inspector"); + expect(stderr).toMatch(/ws:\/\/localhost:\d+\//); + }); + + test("user SIGUSR1 listener takes precedence over inspector activation", async () => { + using dir = tempDir("sigusr1-user-test", { + "test.js": ` + const fs = require("fs"); + const path = require("path"); + + process.on("SIGUSR1", () => { + console.log("USER_HANDLER_CALLED"); + // Exit cleanly after receiving the signal + process.exit(0); + }); + + fs.writeFileSync(path.join(process.cwd(), "pid"), String(process.pid)); + console.log("READY"); + + setInterval(() => {}, 1000); + `, + }); + + await using proc = spawn({ + cmd: [bunExe(), "--inspect-port=0", "test.js"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const reader = proc.stdout.getReader(); + const decoder = new TextDecoder(); + let output = await readStreamUntil(reader, s => s.includes("READY")); + + const pid = parseInt(await Bun.file(join(String(dir), "pid")).text(), 10); + + process.kill(pid, "SIGUSR1"); + + while (true) { + const { value, done } = await reader.read(); + if (done) break; + output += decoder.decode(value, { stream: true }); + } + output += decoder.decode(); + + const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + + expect(output).toContain("USER_HANDLER_CALLED"); + expect(stderr).not.toContain("Bun Inspector"); + expect(exitCode).toBe(0); + }); + + test("multiple SIGUSR1s work after user installs handler", async () => { + // After user installs their own SIGUSR1 handler, multiple signals should all + // be delivered to the user handler correctly. + using dir = tempDir("sigusr1-uninstall-test", { + "test.js": ` + const fs = require("fs"); + const path = require("path"); + + let count = 0; + process.on("SIGUSR1", () => { + count++; + console.log("SIGNAL_" + count); + if (count >= 3) { + // Exit cleanly after receiving all signals + process.exit(0); + } + }); + + fs.writeFileSync(path.join(process.cwd(), "pid"), String(process.pid)); + console.log("READY"); + + setInterval(() => {}, 1000); + `, + }); + + await using proc = spawn({ + cmd: [bunExe(), "--inspect-port=0", "test.js"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const reader = proc.stdout.getReader(); + const decoder = new TextDecoder(); + let output = await readStreamUntil(reader, s => s.includes("READY")); + + const pid = parseInt(await Bun.file(join(String(dir), "pid")).text(), 10); + + // Send SIGUSR1s and wait for each handler to respond before sending the next + for (let i = 1; i <= 3; i++) { + process.kill(pid, "SIGUSR1"); + // Wait for handler output before sending next signal + while (!output.includes(`SIGNAL_${i}`)) { + const { value, done } = await reader.read(); + if (done) break; + output += decoder.decode(value, { stream: true }); + } + } + + // Read remaining output until process exits + while (true) { + const { value, done } = await reader.read(); + if (done) break; + output += decoder.decode(value, { stream: true }); + } + output += decoder.decode(); + + const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + + expect(output).toBe(`READY +SIGNAL_1 +SIGNAL_2 +SIGNAL_3 +`); + expect(stderr).not.toContain("Bun Inspector"); + expect(exitCode).toBe(0); + }); + + test.skipIf(isASAN)("inspector does not activate twice via SIGUSR1", async () => { + using dir = tempDir("sigusr1-twice-test", { + "test.js": ` + const fs = require("fs"); + const path = require("path"); + + fs.writeFileSync(path.join(process.cwd(), "pid"), String(process.pid)); + console.log("READY"); + + // Keep process alive until test kills it + setInterval(() => {}, 1000); + `, + }); + + await using proc = spawn({ + cmd: [bunExe(), "--inspect-port=0", "test.js"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const reader = proc.stdout.getReader(); + await readStreamUntil(reader, s => s.includes("READY")); + reader.releaseLock(); + + const pid = parseInt(await Bun.file(join(String(dir), "pid")).text(), 10); + + // Send first SIGUSR1 and wait for inspector to activate + process.kill(pid, "SIGUSR1"); + + const stderrReader = proc.stderr.getReader(); + let stderr = await readStreamUntil(stderrReader, hasBanner); + + // Send second SIGUSR1 - inspector should not activate again + process.kill(pid, "SIGUSR1"); + + // Kill process — the signal was delivered synchronously, so if a second banner + // were going to appear it would already be queued. Killing and reading remaining + // stderr is more reliable than sleeping. + proc.kill(); + + // Read any remaining stderr until process exits + const stderrDecoder = new TextDecoder(); + while (true) { + const { value, done } = await stderrReader.read(); + if (done) break; + stderr += stderrDecoder.decode(value, { stream: true }); + } + stderr += stderrDecoder.decode(); + stderrReader.releaseLock(); + + await proc.exited; + + // Should only see one "Bun Inspector" banner (two occurrences of the text, for header and footer) + const matches = stderr.match(/Bun Inspector/g); + expect(matches?.length ?? 0).toBe(2); + }); + + test.skipIf(isASAN)("SIGUSR1 to self activates inspector", async () => { + // Use a PID file approach instead of setTimeout to avoid timing-dependent self-signal + using dir = tempDir("sigusr1-self-test", { + "test.js": ` + const fs = require("fs"); + const path = require("path"); + + // Write PID so parent can send signal + fs.writeFileSync(path.join(process.cwd(), "pid"), String(process.pid)); + console.log("READY"); + + // Keep process alive until test kills it + setInterval(() => {}, 1000); + `, + }); + + await using proc = spawn({ + cmd: [bunExe(), "--inspect-port=0", "test.js"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const stdoutReader = proc.stdout.getReader(); + await readStreamUntil(stdoutReader, s => s.includes("READY")); + stdoutReader.releaseLock(); + + const pid = parseInt(await Bun.file(join(String(dir), "pid")).text(), 10); + + // Send SIGUSR1 from parent (equivalent to self-signal but without setTimeout race) + process.kill(pid, "SIGUSR1"); + + // Wait for inspector banner + const reader = proc.stderr.getReader(); + const stderr = await readStreamUntil(reader, hasBanner); + reader.releaseLock(); + + proc.kill(); + await proc.exited; + + expect(stderr).toContain("Bun Inspector"); + }); + + test("SIGUSR1 is ignored when started with --inspect", async () => { + // When the process is started with --inspect, the debugger is already active. + // The RuntimeInspector signal handler should NOT be installed, so SIGUSR1 + // should have no effect (default action is terminate, but signal may be ignored). + using dir = tempDir("sigusr1-inspect-test", { + "test.js": ` + const fs = require("fs"); + const path = require("path"); + + fs.writeFileSync(path.join(process.cwd(), "pid"), String(process.pid)); + console.log("READY"); + + // Keep process alive until parent kills it + setInterval(() => {}, 1000); + `, + }); + + await using proc = spawn({ + cmd: [bunExe(), "--inspect", "test.js"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const reader = proc.stdout.getReader(); + await readStreamUntil(reader, s => s.includes("READY")); + reader.releaseLock(); + + const pid = parseInt(await Bun.file(join(String(dir), "pid")).text(), 10); + + // Wait for the --inspect banner to appear before sending SIGUSR1 + const stderrReader = proc.stderr.getReader(); + let stderr = await readStreamUntil(stderrReader, hasBanner); + + // Send SIGUSR1 - should be ignored since RuntimeInspector is not installed + process.kill(pid, "SIGUSR1"); + + // Kill and collect remaining stderr — parent drives termination + proc.kill(); + const stderrDecoder = new TextDecoder(); + while (true) { + const { value, done } = await stderrReader.read(); + if (done) break; + stderr += stderrDecoder.decode(value, { stream: true }); + } + stderrReader.releaseLock(); + await proc.exited; + + // Should only see one "Bun Inspector" banner (from --inspect flag, not from SIGUSR1) + // The banner has two occurrences of "Bun Inspector" (header and footer) + const matches = stderr.match(/Bun Inspector/g); + expect(matches?.length ?? 0).toBe(2); + }); + + test("SIGUSR1 is ignored when started with --inspect-wait", async () => { + // When the process is started with --inspect-wait, the debugger is already active. + // Sending SIGUSR1 should NOT activate the inspector again. + await using proc = spawn({ + cmd: [bunExe(), "--inspect-wait", "-e", "setInterval(() => {}, 1000)"], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const reader = proc.stderr.getReader(); + const stderr = await readStreamUntil(reader, hasBanner); + + // Send SIGUSR1 - should be ignored since debugger is already active + process.kill(proc.pid, "SIGUSR1"); + + // Kill process since --inspect-wait would wait for connection + // Signal processing is synchronous, so no sleep needed + proc.kill(); + + // Read any remaining stderr + const decoder = new TextDecoder(); + let remaining = ""; + while (true) { + const { value, done } = await reader.read(); + if (done) break; + remaining += decoder.decode(value, { stream: true }); + } + remaining += decoder.decode(); + reader.releaseLock(); + + await proc.exited; + + // Should only see one "Bun Inspector" banner (from --inspect-wait flag, not from SIGUSR1) + // The banner has two occurrences of "Bun Inspector" (header and footer) + const fullStderr = stderr + remaining; + const matches = fullStderr.match(/Bun Inspector/g); + expect(matches?.length ?? 0).toBe(2); + }); + + test("SIGUSR1 is ignored when started with --inspect-brk", async () => { + // When the process is started with --inspect-brk, the debugger is already active. + // Sending SIGUSR1 should NOT activate the inspector again. + await using proc = spawn({ + cmd: [bunExe(), "--inspect-brk", "-e", "setInterval(() => {}, 1000)"], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const reader = proc.stderr.getReader(); + const stderr = await readStreamUntil(reader, hasBanner); + + // Send SIGUSR1 - should be ignored since debugger is already active + process.kill(proc.pid, "SIGUSR1"); + + // Kill process since --inspect-brk would wait for connection + // Signal processing is synchronous, so no sleep needed + proc.kill(); + + // Read any remaining stderr + const decoder = new TextDecoder(); + let remaining = ""; + while (true) { + const { value, done } = await reader.read(); + if (done) break; + remaining += decoder.decode(value, { stream: true }); + } + remaining += decoder.decode(); + reader.releaseLock(); + + await proc.exited; + + // Should only see one "Bun Inspector" banner (from --inspect-brk flag, not from SIGUSR1) + // The banner has two occurrences of "Bun Inspector" (header and footer) + const fullStderr = stderr + remaining; + const matches = fullStderr.match(/Bun Inspector/g); + expect(matches?.length ?? 0).toBe(2); + }); +}); diff --git a/test/js/bun/runtime-inspector/runtime-inspector-windows.test.ts b/test/js/bun/runtime-inspector/runtime-inspector-windows.test.ts new file mode 100644 index 00000000000..fbd843532cd --- /dev/null +++ b/test/js/bun/runtime-inspector/runtime-inspector-windows.test.ts @@ -0,0 +1,306 @@ +import { spawn } from "bun"; +import { describe, expect, setDefaultTimeout, test } from "bun:test"; +import { bunEnv, bunExe, isASAN, isWindows, tempDir } from "harness"; +import { join } from "path"; + +// Inspector tests spawn subprocesses and wait for inspector activation — 5s default is too short. +setDefaultTimeout(60_000); + +// Timeout for waiting on stream reader loops (30s matches runtime-inspector.test.ts) +const STREAM_TIMEOUT_MS = 30_000; + +// Helper: read from a stream until condition is met, with a timeout to prevent hanging +async function readStreamUntil( + reader: ReadableStreamDefaultReader, + condition: (output: string) => boolean, + timeoutMs = STREAM_TIMEOUT_MS, +): Promise { + const decoder = new TextDecoder(); + let output = ""; + const startTime = Date.now(); + + while (!condition(output)) { + if (Date.now() - startTime > timeoutMs) { + throw new Error(`Timeout after ${timeoutMs}ms waiting for stream condition. Got: "${output}"`); + } + const { value, done } = await reader.read(); + if (done) break; + output += decoder.decode(value, { stream: true }); + } + return output; +} + +// Helper: wait for the full inspector banner (header + footer = 2 occurrences of "Bun Inspector") +function hasBanner(stderr: string): boolean { + return (stderr.match(/Bun Inspector/g) || []).length >= 2; +} + +// Windows-specific tests (file mapping mechanism) - Windows only +describe.skipIf(!isWindows)("Runtime inspector Windows file mapping", () => { + test.skipIf(isASAN)("inspector activates via file mapping mechanism", async () => { + // This is the primary Windows test - verify the file mapping mechanism works + using dir = tempDir("windows-file-mapping-test", { + "target.js": ` + const fs = require("fs"); + const path = require("path"); + + fs.writeFileSync(path.join(process.cwd(), "pid"), String(process.pid)); + console.log("READY"); + + // Keep process alive + setInterval(() => {}, 1000); + `, + }); + + await using targetProc = spawn({ + cmd: [bunExe(), "--inspect-port=0", "target.js"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const reader = targetProc.stdout.getReader(); + await readStreamUntil(reader, s => s.includes("READY")); + reader.releaseLock(); + + const pid = parseInt(await Bun.file(join(String(dir), "pid")).text(), 10); + expect(pid).toBeGreaterThan(0); + + // Use _debugProcess which uses file mapping on Windows + await using debugProc = spawn({ + cmd: [bunExe(), "-e", `process._debugProcess(${pid})`], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [debugStderr, debugExitCode] = await Promise.all([debugProc.stderr.text(), debugProc.exited]); + + expect(debugStderr).toBe(""); + expect(debugExitCode).toBe(0); + + // Wait for the debugger to start by reading stderr until the full banner appears + const stderrReader = targetProc.stderr.getReader(); + const targetStderr = await readStreamUntil(stderrReader, hasBanner); + stderrReader.releaseLock(); + + targetProc.kill(); + await targetProc.exited; + + // Verify inspector actually started + expect(targetStderr).toContain("Bun Inspector"); + expect(targetStderr).toMatch(/ws:\/\/localhost:\d+\//); + }); + + test.skipIf(isASAN)("_debugProcess works with current process's own pid", async () => { + // On Windows, calling _debugProcess with our own PID should work. + // Use PID file approach to avoid timing-dependent setTimeout. + using dir = tempDir("windows-self-debug-test", { + "target.js": ` + const fs = require("fs"); + const path = require("path"); + + fs.writeFileSync(path.join(process.cwd(), "pid"), String(process.pid)); + console.log("READY"); + + // Keep process alive until parent sends _debugProcess and then kills us + setInterval(() => {}, 1000); + `, + }); + + await using proc = spawn({ + cmd: [bunExe(), "--inspect-port=0", "target.js"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const reader = proc.stdout.getReader(); + await readStreamUntil(reader, s => s.includes("READY")); + reader.releaseLock(); + + const pid = parseInt(await Bun.file(join(String(dir), "pid")).text(), 10); + + // Activate inspector via _debugProcess from a separate process + await using debugProc = spawn({ + cmd: [bunExe(), "-e", `process._debugProcess(${pid})`], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + await debugProc.exited; + + // Wait for inspector banner + const stderrReader = proc.stderr.getReader(); + const stderr = await readStreamUntil(stderrReader, hasBanner); + stderrReader.releaseLock(); + + proc.kill(); + await proc.exited; + + expect(stderr).toContain("Bun Inspector"); + }); + + test.skipIf(isASAN)("inspector does not activate twice via file mapping", async () => { + using dir = tempDir("windows-twice-test", { + "target.js": ` + const fs = require("fs"); + const path = require("path"); + + fs.writeFileSync(path.join(process.cwd(), "pid"), String(process.pid)); + console.log("READY"); + + // Keep process alive until parent kills it + setInterval(() => {}, 1000); + `, + }); + + await using targetProc = spawn({ + cmd: [bunExe(), "--inspect-port=0", "target.js"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const reader = targetProc.stdout.getReader(); + await readStreamUntil(reader, s => s.includes("READY")); + reader.releaseLock(); + + const pid = parseInt(await Bun.file(join(String(dir), "pid")).text(), 10); + expect(pid).toBeGreaterThan(0); + + // Set up stderr reader to wait for debugger to start + const stderrReader = targetProc.stderr.getReader(); + + // Call _debugProcess twice + await using debug1 = spawn({ + cmd: [bunExe(), "-e", `process._debugProcess(${pid})`], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + await debug1.exited; + + // Wait for the full banner + let stderr = await readStreamUntil(stderrReader, hasBanner); + + await using debug2 = spawn({ + cmd: [bunExe(), "-e", `process._debugProcess(${pid})`], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + await debug2.exited; + + // Kill and collect remaining stderr — parent drives termination + targetProc.kill(); + stderrReader.releaseLock(); + const remainingStderr = await targetProc.stderr.text(); + stderr += remainingStderr; + await targetProc.exited; + + // Should only see one "Bun Inspector" banner (two occurrences of the text, for header and footer) + const matches = stderr.match(/Bun Inspector/g); + expect(matches?.length ?? 0).toBe(2); + }); + + test.skipIf(isASAN)("multiple Windows processes can have inspectors sequentially", async () => { + // Test sequential activation: activate first, shut down, then activate second. + // Each process uses a random port, so concurrent would also work, but + // sequential tests the full lifecycle. + using dir = tempDir("windows-multi-test", { + "target.js": ` + const fs = require("fs"); + const path = require("path"); + const id = process.argv[2]; + + fs.writeFileSync(path.join(process.cwd(), "pid-" + id), String(process.pid)); + console.log("READY-" + id); + + // Keep process alive until parent kills it + setInterval(() => {}, 1000); + `, + }); + + // First process: activate inspector, verify, then shut down + { + await using target1 = spawn({ + cmd: [bunExe(), "--inspect-port=0", "target.js", "1"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const reader1 = target1.stdout.getReader(); + await readStreamUntil(reader1, s => s.includes("READY-1")); + reader1.releaseLock(); + + const pid1 = parseInt(await Bun.file(join(String(dir), "pid-1")).text(), 10); + expect(pid1).toBeGreaterThan(0); + + await using debug1 = spawn({ + cmd: [bunExe(), "-e", `process._debugProcess(${pid1})`], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [debug1Stderr, debug1ExitCode] = await Promise.all([debug1.stderr.text(), debug1.exited]); + expect(debug1Stderr).toBe(""); + expect(debug1ExitCode).toBe(0); + + // Wait for the full banner + const stderrReader1 = target1.stderr.getReader(); + const stderr1 = await readStreamUntil(stderrReader1, hasBanner); + stderrReader1.releaseLock(); + + expect(stderr1).toContain("Bun Inspector"); + + target1.kill(); + await target1.exited; + } + + // Second process + { + await using target2 = spawn({ + cmd: [bunExe(), "--inspect-port=0", "target.js", "2"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const reader2 = target2.stdout.getReader(); + await readStreamUntil(reader2, s => s.includes("READY-2")); + reader2.releaseLock(); + + const pid2 = parseInt(await Bun.file(join(String(dir), "pid-2")).text(), 10); + expect(pid2).toBeGreaterThan(0); + + await using debug2 = spawn({ + cmd: [bunExe(), "-e", `process._debugProcess(${pid2})`], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [debug2Stderr, debug2ExitCode] = await Promise.all([debug2.stderr.text(), debug2.exited]); + expect(debug2Stderr).toBe(""); + expect(debug2ExitCode).toBe(0); + + // Wait for the full banner + const stderrReader2 = target2.stderr.getReader(); + const stderr2 = await readStreamUntil(stderrReader2, hasBanner); + stderrReader2.releaseLock(); + + expect(stderr2).toContain("Bun Inspector"); + + target2.kill(); + await target2.exited; + } + }); +}); diff --git a/test/js/bun/runtime-inspector/runtime-inspector.test.ts b/test/js/bun/runtime-inspector/runtime-inspector.test.ts new file mode 100644 index 00000000000..f95f8b5f5a9 --- /dev/null +++ b/test/js/bun/runtime-inspector/runtime-inspector.test.ts @@ -0,0 +1,533 @@ +import { spawn } from "bun"; +import { describe, expect, setDefaultTimeout, test } from "bun:test"; +import { bunEnv, bunExe, isASAN, isWindows } from "harness"; + +// Inspector tests spawn subprocesses and wait for inspector activation — 5s default is too short. +setDefaultTimeout(60_000); + +/** + * Reads from a stderr stream until the full Bun Inspector banner appears. + * The banner has "Bun Inspector" in both header and footer lines. + * Returns the accumulated stderr output. + */ +async function waitForDebuggerListening( + stderrStream: ReadableStream, + timeoutMs: number = 30000, +): Promise<{ stderr: string }> { + const reader = stderrStream.getReader(); + const decoder = new TextDecoder(); + let stderr = ""; + + // Wait for the full banner (header + content + footer) + // The banner format is: + // --------------------- Bun Inspector --------------------- + // Listening: + // ws://localhost:/... + // Inspect in browser: + // https://debug.bun.sh/#localhost:/... + // --------------------- Bun Inspector --------------------- + // + // We race each read() against a timeout so that if the target process is + // alive but never writes (the activation hang bug), we throw a useful error + // instead of blocking forever and hitting the 90s CI harness timeout. + try { + let timeoutFired = false; + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => { + timeoutFired = true; + reject( + new Error( + `Timeout waiting for Bun Inspector banner after ${timeoutMs}ms. Got stderr: ${JSON.stringify(stderr)}`, + ), + ); + }, timeoutMs).unref(); + }); + + while ((stderr.match(/Bun Inspector/g) || []).length < 2) { + const { value, done } = await Promise.race([reader.read(), timeoutPromise]); + if (timeoutFired || done) break; + stderr += decoder.decode(value, { stream: true }); + } + } finally { + // Cancel the reader to avoid "Stream reader cancelled via releaseLock()" errors + await reader.cancel(); + reader.releaseLock(); + } + + return { stderr }; +} + +// Cross-platform tests - run on ALL platforms (Windows, macOS, Linux) +// Windows uses file mapping mechanism, POSIX uses SIGUSR1 +describe("Runtime inspector activation", () => { + describe("process._debugProcess", () => { + test.skipIf(isASAN)("activates inspector in target process", async () => { + // Start target process - prints PID to stdout then stays alive + await using targetProc = spawn({ + cmd: [bunExe(), "--inspect-port=0", "-e", `console.log(process.pid); setInterval(() => {}, 1000);`], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + // Read PID from stdout (confirms JS is executing) + const reader = targetProc.stdout.getReader(); + const { value } = await reader.read(); + reader.releaseLock(); + const pid = parseInt(new TextDecoder().decode(value).trim(), 10); + + // Use _debugProcess to activate inspector + await using debugProc = spawn({ + cmd: [bunExe(), "-e", `process._debugProcess(${pid})`], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const debugStderr = await debugProc.stderr.text(); + expect(debugStderr).toBe(""); + expect(await debugProc.exited).toBe(0); + + // Wait for inspector to activate by reading stderr until we see the message + const { stderr: targetStderr } = await waitForDebuggerListening(targetProc.stderr); + + // Kill target + targetProc.kill(); + await targetProc.exited; + + expect(targetStderr).toContain("Bun Inspector"); + expect(targetStderr).toMatch(/ws:\/\/localhost:\d+\//); + }); + + test.todoIf(isWindows)("throws error for non-existent process", async () => { + // Use a PID that definitely doesn't exist + const fakePid = 999999999; + + await using proc = spawn({ + cmd: [bunExe(), "-e", `process._debugProcess(${fakePid})`], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const stderr = await proc.stderr.text(); + expect(stderr).toContain("Failed"); + expect(await proc.exited).not.toBe(0); + }); + + test.skipIf(isASAN)("inspector does not activate twice", async () => { + // Start target process - prints PID to stdout then stays alive + await using targetProc = spawn({ + cmd: [bunExe(), "--inspect-port=0", "-e", `console.log(process.pid); setInterval(() => {}, 1000);`], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + // Read PID from stdout (confirms JS is executing) + const reader = targetProc.stdout.getReader(); + const { value } = await reader.read(); + reader.releaseLock(); + const pid = parseInt(new TextDecoder().decode(value).trim(), 10); + + // Start reading stderr before triggering debugger + const stderrReader = targetProc.stderr.getReader(); + const stderrDecoder = new TextDecoder(); + let stderr = ""; + + // Call _debugProcess the first time + await using debug1 = spawn({ + cmd: [bunExe(), "-e", `process._debugProcess(${pid})`], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const debug1Stderr = await debug1.stderr.text(); + expect(debug1Stderr).toBe(""); + expect(await debug1.exited).toBe(0); + + // Wait for the full debugger banner (header + content + footer) with timeout + const bannerStartTime = Date.now(); + const bannerTimeout = 30000; + while ((stderr.match(/Bun Inspector/g) || []).length < 2) { + if (Date.now() - bannerStartTime > bannerTimeout) { + throw new Error(`Timeout waiting for inspector banner. Got: "${stderr}"`); + } + const { value, done } = await stderrReader.read(); + if (done) break; + stderr += stderrDecoder.decode(value, { stream: true }); + } + + // Call _debugProcess again - inspector should not activate twice + await using debug2 = spawn({ + cmd: [bunExe(), "-e", `process._debugProcess(${pid})`], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const debug2Stderr = await debug2.stderr.text(); + expect(debug2Stderr).toBe(""); + expect(await debug2.exited).toBe(0); + + // Kill process — the signal was delivered synchronously, so if a second banner + // were going to appear it would already be queued. Killing and reading remaining + // stderr is more reliable than sleeping. + targetProc.kill(); + + // Read any remaining stderr until stream is done + while (true) { + const { value, done } = await stderrReader.read(); + if (done) break; + stderr += stderrDecoder.decode(value, { stream: true }); + } + stderr += stderrDecoder.decode(); + stderrReader.releaseLock(); + + await targetProc.exited; + + // Should only see one "Bun Inspector" banner (two occurrences of the text, for header and footer) + const matches = stderr.match(/Bun Inspector/g); + expect(matches?.length ?? 0).toBe(2); + }); + + test.skipIf(isASAN)("can activate inspector in multiple processes sequentially", async () => { + // Test sequential activation: activate first, shut down, then activate second. + // Each process uses a random port, so concurrent would also work, but + // sequential tests the full lifecycle. + const targetScript = `console.log(process.pid); setInterval(() => {}, 1000);`; + + // First process: activate inspector, verify, then shut down + { + await using target1 = spawn({ + cmd: [bunExe(), "--inspect-port=0", "-e", targetScript], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const reader1 = target1.stdout.getReader(); + const { value: v1 } = await reader1.read(); + reader1.releaseLock(); + const pid1 = parseInt(new TextDecoder().decode(v1).trim(), 10); + expect(pid1).toBeGreaterThan(0); + + await using debug1 = spawn({ + cmd: [bunExe(), "-e", `process._debugProcess(${pid1})`], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const debug1Stderr = await debug1.stderr.text(); + expect(debug1Stderr).toBe(""); + expect(await debug1.exited).toBe(0); + + const result1 = await waitForDebuggerListening(target1.stderr); + + expect(result1.stderr).toContain("Bun Inspector"); + + target1.kill(); + await target1.exited; + } + + // Second process + { + await using target2 = spawn({ + cmd: [bunExe(), "--inspect-port=0", "-e", targetScript], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const reader2 = target2.stdout.getReader(); + const { value: v2 } = await reader2.read(); + reader2.releaseLock(); + const pid2 = parseInt(new TextDecoder().decode(v2).trim(), 10); + expect(pid2).toBeGreaterThan(0); + + await using debug2 = spawn({ + cmd: [bunExe(), "-e", `process._debugProcess(${pid2})`], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const debug2Stderr = await debug2.stderr.text(); + expect(debug2Stderr).toBe(""); + expect(await debug2.exited).toBe(0); + + const result2 = await waitForDebuggerListening(target2.stderr); + + expect(result2.stderr).toContain("Bun Inspector"); + + target2.kill(); + await target2.exited; + } + }); + + test("throws when called with no arguments", async () => { + await using proc = spawn({ + cmd: [bunExe(), "-e", `process._debugProcess()`], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const stderr = await proc.stderr.text(); + expect(stderr).toContain("requires a pid argument"); + expect(await proc.exited).not.toBe(0); + }); + + test.skipIf(isASAN)("can interrupt an infinite loop", async () => { + // Start target process with infinite loop + await using targetProc = spawn({ + cmd: [bunExe(), "--inspect-port=0", "-e", `console.log(process.pid); while (true) {}`], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + // Read PID from stdout (written before the infinite loop starts) + const reader = targetProc.stdout.getReader(); + const { value } = await reader.read(); + reader.releaseLock(); + const pid = parseInt(new TextDecoder().decode(value).trim(), 10); + expect(pid).toBeGreaterThan(0); + + // Use _debugProcess to activate inspector - this should interrupt the infinite loop + await using debugProc = spawn({ + cmd: [bunExe(), "-e", `process._debugProcess(${pid})`], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const debugStderr = await debugProc.stderr.text(); + expect(debugStderr).toBe(""); + expect(await debugProc.exited).toBe(0); + + // Wait for inspector to activate - this proves we interrupted the infinite loop + const { stderr: targetStderr } = await waitForDebuggerListening(targetProc.stderr); + + // Kill target + targetProc.kill(); + await targetProc.exited; + + expect(targetStderr).toContain("Bun Inspector"); + expect(targetStderr).toMatch(/ws:\/\/localhost:\d+\//); + }); + + test.skip("can pause execution during while(true) via CDP", async () => { + // Start target process with infinite loop + await using targetProc = spawn({ + cmd: [bunExe(), "--inspect-port=0", "-e", `console.log(process.pid); while (true) {}`], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + // Read PID from stdout (written before the infinite loop starts) + const reader = targetProc.stdout.getReader(); + const { value } = await reader.read(); + reader.releaseLock(); + const pid = parseInt(new TextDecoder().decode(value).trim(), 10); + expect(pid).toBeGreaterThan(0); + + // Activate inspector via _debugProcess + await using debugProc = spawn({ + cmd: [bunExe(), "-e", `process._debugProcess(${pid})`], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const debugStderr = await debugProc.stderr.text(); + expect(debugStderr).toBe(""); + expect(await debugProc.exited).toBe(0); + + // Wait for inspector to activate and extract WebSocket URL + const { stderr: targetStderr } = await waitForDebuggerListening(targetProc.stderr); + const wsMatch = targetStderr.match(/ws:\/\/[^\s]+/); + expect(wsMatch).not.toBeNull(); + const wsUrl = wsMatch![0]; + + // Connect via WebSocket to the inspector + const ws = new WebSocket(wsUrl); + const { promise: openPromise, resolve: openResolve, reject: openReject } = Promise.withResolvers(); + ws.onopen = () => openResolve(); + ws.onerror = e => openReject(e); + await openPromise; + + try { + let msgId = 1; + const pendingResponses = new Map void; reject: (e: any) => void }>(); + const { promise: pausedPromise, resolve: pausedResolve } = Promise.withResolvers(); + + ws.onmessage = event => { + const msg = JSON.parse(event.data as string); + if (msg.id !== undefined) { + const pending = pendingResponses.get(msg.id); + if (pending) { + pendingResponses.delete(msg.id); + pending.resolve(msg); + } + } + if (msg.method === "Debugger.paused") { + pausedResolve(msg); + } + }; + + function sendCDP(method: string, params: Record = {}): Promise { + const id = msgId++; + const { promise, resolve, reject } = Promise.withResolvers(); + pendingResponses.set(id, { resolve, reject }); + ws.send(JSON.stringify({ id, method, params })); + return promise; + } + + // Enable Runtime and Debugger domains + await sendCDP("Runtime.enable"); + await sendCDP("Debugger.enable"); + + // Request pause - this should interrupt the while(true) loop + await sendCDP("Debugger.pause"); + + // Wait for Debugger.paused event (proves the JS thread was interrupted and paused) + const pausedEvent = await pausedPromise; + expect(pausedEvent.method).toBe("Debugger.paused"); + + // Resume execution + await sendCDP("Debugger.resume"); + } finally { + ws.close(); + targetProc.kill(); + await targetProc.exited; + } + }); + + test.skipIf(isASAN)("CDP messages work after client reconnects", async () => { + // Start target process - prints PID to stdout then stays alive + await using targetProc = spawn({ + cmd: [bunExe(), "--inspect-port=0", "-e", `console.log(process.pid); setInterval(() => {}, 200);`], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + // Read PID from stdout (confirms JS is executing) + const reader = targetProc.stdout.getReader(); + const { value } = await reader.read(); + reader.releaseLock(); + const pid = parseInt(new TextDecoder().decode(value).trim(), 10); + expect(pid).toBeGreaterThan(0); + + // Activate inspector via _debugProcess + await using debugProc = spawn({ + cmd: [bunExe(), "-e", `process._debugProcess(${pid})`], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [debugStderr, debugExitCode] = await Promise.all([debugProc.stderr.text(), debugProc.exited]); + expect(debugStderr).toBe(""); + expect(debugExitCode).toBe(0); + + // Wait for inspector banner and extract WS URL + const { stderr: targetStderr } = await waitForDebuggerListening(targetProc.stderr); + const wsMatch = targetStderr.match(/ws:\/\/[^\s]+/); + expect(wsMatch).not.toBeNull(); + const wsUrl = wsMatch![0]; + + // Helper to create a CDP WebSocket client + function createCDPClient(url: string) { + const ws = new WebSocket(url); + let msgId = 1; + const pendingResponses = new Map void; reject: (e: any) => void }>(); + + ws.onmessage = event => { + const msg = JSON.parse(event.data as string); + if (msg.id !== undefined) { + const pending = pendingResponses.get(msg.id); + if (pending) { + pendingResponses.delete(msg.id); + pending.resolve(msg); + } + } + }; + + function sendCDP(method: string, params: Record = {}): Promise { + const id = msgId++; + const { promise, resolve, reject } = Promise.withResolvers(); + pendingResponses.set(id, { resolve, reject }); + ws.send(JSON.stringify({ id, method, params })); + return promise; + } + + async function waitForOpen(): Promise { + const { promise, resolve, reject } = Promise.withResolvers(); + ws.onopen = () => resolve(); + ws.onerror = e => reject(e); + return promise; + } + + return { ws, sendCDP, waitForOpen }; + } + + // First connection: verify CDP works + const client1 = createCDPClient(wsUrl); + await client1.waitForOpen(); + + const result1 = await client1.sendCDP("Runtime.evaluate", { expression: "1 + 1" }); + expect(result1.result.result.value).toBe(2); + + const { promise, resolve } = Promise.withResolvers(); + client1.ws.onclose = () => resolve(); + client1.ws.close(); + await promise; + + // Second connection: verify CDP still works after reconnect + const client2 = createCDPClient(wsUrl); + await client2.waitForOpen(); + + const result2 = await client2.sendCDP("Runtime.evaluate", { expression: "2 + 3" }); + expect(result2.result.result.value).toBe(5); + + client2.ws.close(); + targetProc.kill(); + await targetProc.exited; + }); + }); +}); + +// POSIX-only: --disable-sigusr1 test +// On POSIX, when --disable-sigusr1 is set, no SIGUSR1 handler is installed, +// so SIGUSR1 uses the default action (terminate process with exit code 128+30=158) +// This test is skipped on Windows since there's no SIGUSR1 signal there. + +describe.skipIf(isWindows)("--disable-sigusr1", () => { + test("prevents inspector activation and uses default signal behavior", async () => { + // Start with --disable-sigusr1 - prints PID to stdout then stays alive + await using targetProc = spawn({ + cmd: [bunExe(), "--disable-sigusr1", "-e", `console.log(process.pid); setInterval(() => {}, 1000);`], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + // Read PID from stdout (confirms JS is executing) + const reader = targetProc.stdout.getReader(); + const { value } = await reader.read(); + reader.releaseLock(); + const pid = parseInt(new TextDecoder().decode(value).trim(), 10); + + // Send SIGUSR1 directly - without handler, this will terminate the process + process.kill(pid, "SIGUSR1"); + + const stderr = await targetProc.stderr.text(); + // Should NOT see Bun Inspector banner + expect(stderr).not.toContain("Bun Inspector"); + // Process should be terminated by SIGUSR1 + // Exit code = 128 + signal number (macOS: SIGUSR1=30 -> 158, Linux: SIGUSR1=10 -> 138) + expect(await targetProc.exited).toBeOneOf([158, 138]); + }); +}); diff --git a/test/js/node/process/process.test.js b/test/js/node/process/process.test.js index 09e9f246f7d..f44602df2f9 100644 --- a/test/js/node/process/process.test.js +++ b/test/js/node/process/process.test.js @@ -686,7 +686,6 @@ describe.concurrent(() => { const undefinedStubs = [ "_debugEnd", - "_debugProcess", "_fatalException", "_linkedBinding", "_rawDebug",