diff --git a/src/bun.js.zig b/src/bun.js.zig index d7abd368387..b0a382fa213 100644 --- a/src/bun.js.zig +++ b/src/bun.js.zig @@ -38,6 +38,7 @@ 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, }), .arena = arena, .ctx = ctx, @@ -186,6 +187,7 @@ 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, }, ), .arena = arena, diff --git a/src/bun.js/VirtualMachine.zig b/src/bun.js/VirtualMachine.zig index 8d70f6db231..2c646f52679 100644 --- a/src/bun.js/VirtualMachine.zig +++ b/src/bun.js/VirtualMachine.zig @@ -1084,6 +1084,8 @@ pub fn initWithModuleGraph( vm.configureDebugger(opts.debugger); vm.body_value_hive_allocator = Body.Value.HiveAllocator.init(bun.typedAllocator(jsc.WebCore.Body.Value)); + configureSigusr1Handler(vm, opts); + return vm; } @@ -1110,8 +1112,26 @@ 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, }; +/// 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 { @@ -1211,6 +1231,8 @@ pub fn init(opts: Options) !*VirtualMachine { vm.configureDebugger(opts.debugger); vm.body_value_hive_allocator = Body.Value.HiveAllocator.init(bun.typedAllocator(jsc.WebCore.Body.Value)); + configureSigusr1Handler(vm, opts); + return vm; } @@ -1461,6 +1483,8 @@ pub fn initBake(opts: Options) anyerror!*VirtualMachine { 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..543da4f2813 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 @@ -663,3 +665,36 @@ extern "C" void Debugger__willDispatchAsyncCall(JSGlobalObject* globalObject, As agent->willDispatchAsyncCall(getCallType(callType), callbackId); } } + +// 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__activateInspector(); + +JSC::StopTheWorldStatus Bun__jsDebuggerCallback(JSC::VM& vm, JSC::StopTheWorldEvent event) +{ + using namespace JSC; + + if (event != StopTheWorldEvent::VMStopped) + return STW_CONTINUE(); + + if (Bun__activateInspector()) { + vm.notifyNeedDebuggerBreak(); + } + + 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)); +} diff --git a/src/bun.js/bindings/BunProcess.cpp b/src/bun.js/bindings/BunProcess.cpp index b09a220fa38..6a68a712b08 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(), @@ -3824,6 +3835,71 @@ 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, {}); + + // 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) { + // Match Node.js error message for compatibility + throwVMError(globalObject, scope, "The system cannot find the file specified."_s); + 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)); @@ -3963,7 +4039,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/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 996b007e548..1d36d7932d6 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" @@ -267,6 +268,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__jsDebuggerCallback(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; @@ -331,6 +336,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__jsDebuggerCallback); }); // end std::call_once lambda // NOLINTEND diff --git a/src/bun.js/event_loop.zig b/src/bun.js/event_loop.zig index a922b6b38a5..f427a18716c 100644 --- a/src/bun.js/event_loop.zig +++ b/src/bun.js/event_loop.zig @@ -290,6 +290,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); @@ -687,6 +691,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..350a5afa4d6 --- /dev/null +++ b/src/bun.js/event_loop/RuntimeInspector.zig @@ -0,0 +1,382 @@ +/// 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). +/// Note: If this port is already in use, activation will fail with an error message. +/// This matches Node.js behavior where SIGUSR1-activated inspectors also use a fixed +/// port (9229). Users can pre-configure a different port using --inspect-port= +/// or --inspect=0 for automatic port selection when starting the process. +const 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 { + inspector_activation_requested.store(true, .release); + + // 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__jsDebuggerCallback) 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. + + jsc.VMManager.requestStopAll(.JSDebugger); + + 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; + } + + defer jsc.VMManager.requestResumeAll(.JSDebugger); + _ = tryActivateInspector(); +} + +/// 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 = inspector_port, + .from_environment_variable = "", + .wait_for_connection = .off, + .set_breakpoint_on_first_line = false, + .mode = .listen, + }; + + vm.transpiler.options.minify_identifiers = false; + vm.transpiler.options.minify_syntax = false; + vm.transpiler.options.minify_whitespace = false; + vm.transpiler.options.debugger = true; + + try Debugger.create(vm, vm.global); +} + +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__activateInspector() bool { + if (!inspector_activation_requested.swap(false, .acq_rel)) { + return false; + } + return tryActivateInspector(); +} + +comptime { + if (Environment.isPosix) { + _ = Bun__Sigusr1Handler__uninstall; + } + _ = Bun__activateInspector; +} + +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 3d459f067d3..d0e68dd38a2 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -402,6 +402,8 @@ pub const Command = struct { name: []const u8 = "", dir: []const u8 = "", } = .{}, + /// Disable SIGUSR1 handler for runtime debugger activation + disable_sigusr1: bool = false, }; var global_cli_ctx: Context = undefined; diff --git a/src/cli/Arguments.zig b/src/cli/Arguments.zig index 5c7ad1c2e8d..a012634e21b 100644 --- a/src/cli/Arguments.zig +++ b/src/cli/Arguments.zig @@ -87,6 +87,7 @@ 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("--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, @@ -796,6 +797,7 @@ 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"); if (args.option("--console-depth")) |depth_str| { const depth = std.fmt.parseInt(u16, depth_str, 10) catch { diff --git a/src/cli/test_command.zig b/src/cli/test_command.zig index 04868c37f2d..2ce4148ee64 100644 --- a/src/cli/test_command.zig +++ b/src/cli/test_command.zig @@ -1395,6 +1395,7 @@ 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, }, ); 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..e0fd576315b 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) @@ -44,8 +48,36 @@ bool Semaphore::wait() #elif OS(DARWIN) return semaphore_wait(m_semaphore) == KERN_SUCCESS; #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/harness.ts b/test/harness.ts index 4bba068ad77..5f582246a89 100644 --- a/test/harness.ts +++ b/test/harness.ts @@ -5,6 +5,7 @@ * without always needing to run `bun install` in development. */ +import * as numeric from "_util/numeric.ts"; import { gc as bunGC, sleepSync, spawnSync, unsafe, which, write } from "bun"; import { heapStats } from "bun:jsc"; import { beforeAll, describe, expect } from "bun:test"; @@ -13,7 +14,6 @@ import { readdir, readFile, readlink, rm, writeFile } from "fs/promises"; import fs, { closeSync, openSync, rmSync } from "node:fs"; import os from "node:os"; import { dirname, isAbsolute, join } from "path"; -import * as numeric from "_util/numeric.ts"; export const BREAKING_CHANGES_BUN_1_2 = false; @@ -44,7 +44,7 @@ export const isVerbose = process.env.DEBUG === "1"; // test.todoIf(isFlaky && isMacOS)("this test is flaky"); export const isFlaky = isCI; export const isBroken = isCI; -export const isASAN = basename(process.execPath).includes("bun-asan"); +export const isASAN = basename(process.execPath).includes("bun-asan") || process.env.ASAN_OPTIONS !== undefined; export const bunEnv: NodeJS.Dict = { ...process.env, 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..8693b50aa2c --- /dev/null +++ b/test/js/bun/runtime-inspector/runtime-inspector-posix.test.ts @@ -0,0 +1,442 @@ +import { spawn } from "bun"; +import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe, isASAN, isWindows, tempDir } from "harness"; +import { join } from "path"; + +// ASAN builds have issues with signal handling reliability for SIGUSR1-based inspector activation +const skipASAN = isASAN; + +// POSIX-specific tests (SIGUSR1 mechanism) - macOS and Linux only +describe.skipIf(isWindows)("Runtime inspector SIGUSR1 activation", () => { + test.skipIf(skipASAN)("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(), "test.js"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const reader = proc.stdout.getReader(); + const decoder = new TextDecoder(); + + let output = ""; + while (!output.includes("READY")) { + const { value, done } = await reader.read(); + if (done) break; + output += decoder.decode(value, { stream: true }); + } + 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 stderrDecoder = new TextDecoder(); + let stderr = ""; + + // Wait for the full banner (header + content + footer) + while ((stderr.match(/Bun Inspector/g) || []).length < 2) { + const { value, done } = await stderrReader.read(); + if (done) break; + stderr += stderrDecoder.decode(value, { stream: true }); + } + 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"); + setTimeout(() => process.exit(0), 100); + }); + + fs.writeFileSync(path.join(process.cwd(), "pid"), String(process.pid)); + console.log("READY"); + + setInterval(() => {}, 1000); + `, + }); + + await using proc = spawn({ + cmd: [bunExe(), "test.js"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const reader = proc.stdout.getReader(); + const decoder = new TextDecoder(); + + let output = ""; + while (!output.includes("READY")) { + const { value, done } = await reader.read(); + if (done) break; + output += decoder.decode(value, { stream: true }); + } + + 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) { + setTimeout(() => process.exit(0), 100); + } + }); + + fs.writeFileSync(path.join(process.cwd(), "pid"), String(process.pid)); + console.log("READY"); + + setInterval(() => {}, 1000); + `, + }); + + await using proc = spawn({ + cmd: [bunExe(), "test.js"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const reader = proc.stdout.getReader(); + const decoder = new TextDecoder(); + + let output = ""; + while (!output.includes("READY")) { + const { value, done } = await reader.read(); + if (done) break; + output += decoder.decode(value, { stream: true }); + } + + 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(skipASAN)("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(), "test.js"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const reader = proc.stdout.getReader(); + const decoder = new TextDecoder(); + + let output = ""; + while (!output.includes("READY")) { + const { value, done } = await reader.read(); + if (done) break; + output += decoder.decode(value, { stream: true }); + } + 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(); + const stderrDecoder = new TextDecoder(); + let stderr = ""; + + // Wait for the full banner (header + content + footer) before sending second signal + while ((stderr.match(/Bun Inspector/g) || []).length < 2) { + const { value, done } = await stderrReader.read(); + if (done) break; + stderr += stderrDecoder.decode(value, { stream: true }); + } + + // Send second SIGUSR1 - inspector should not activate again + process.kill(pid, "SIGUSR1"); + + // Give a brief moment for any potential second banner to appear, then kill the process + // We can't wait indefinitely since a second banner should NOT appear + await Bun.sleep(100); + + // Kill process and collect remaining stderr + proc.kill(); + stderrReader.releaseLock(); + const [remainingStderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + stderr += remainingStderr; + + // 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(skipASAN)("SIGUSR1 to self activates inspector", async () => { + await using proc = spawn({ + cmd: [ + bunExe(), + "-e", + ` + // Small delay to ensure handler is installed + setTimeout(() => { + process.kill(process.pid, "SIGUSR1"); + }, 50); + // Keep process alive until test kills it + setInterval(() => {}, 1000); + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + // Wait for inspector banner before collecting all output + const reader = proc.stderr.getReader(); + const decoder = new TextDecoder(); + let stderr = ""; + + // Wait for the full banner (header + content + footer) + while ((stderr.match(/Bun Inspector/g) || []).length < 2) { + const { value, done } = await reader.read(); + if (done) break; + stderr += decoder.decode(value, { stream: true }); + } + 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"); + + setTimeout(() => process.exit(0), 500); + 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(); + const decoder = new TextDecoder(); + + let output = ""; + while (!output.includes("READY")) { + const { value, done } = await reader.read(); + if (done) break; + output += decoder.decode(value, { stream: true }); + } + reader.releaseLock(); + + const pid = parseInt(await Bun.file(join(String(dir), "pid")).text(), 10); + + // Send SIGUSR1 - should be ignored since RuntimeInspector is not installed + process.kill(pid, "SIGUSR1"); + + const [stderr, exitCode] = await Promise.all([proc.stderr.text(), 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); + expect(exitCode).toBe(0); + }); + + 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", "setTimeout(() => process.exit(0), 500)"], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const reader = proc.stderr.getReader(); + const decoder = new TextDecoder(); + let stderr = ""; + + while ((stderr.match(/Bun Inspector/g) || []).length < 2) { + const { value, done } = await reader.read(); + if (done) break; + stderr += decoder.decode(value, { stream: true }); + } + + // 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 + while (true) { + const { value, done } = await reader.read(); + if (done) break; + stderr += decoder.decode(value, { stream: true }); + } + stderr += 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 matches = stderr.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", "setTimeout(() => process.exit(0), 500)"], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const reader = proc.stderr.getReader(); + const decoder = new TextDecoder(); + let stderr = ""; + + while ((stderr.match(/Bun Inspector/g) || []).length < 2) { + const { value, done } = await reader.read(); + if (done) break; + stderr += decoder.decode(value, { stream: true }); + } + + // 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 + while (true) { + const { value, done } = await reader.read(); + if (done) break; + stderr += decoder.decode(value, { stream: true }); + } + stderr += 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 matches = stderr.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..0c677957d20 --- /dev/null +++ b/test/js/bun/runtime-inspector/runtime-inspector-windows.test.ts @@ -0,0 +1,295 @@ +import { spawn } from "bun"; +import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe, isWindows, tempDir } from "harness"; +import { join } from "path"; + +// Windows-specific tests (file mapping mechanism) - Windows only +describe.skipIf(!isWindows)("Runtime inspector Windows file mapping", () => { + test("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(), "target.js"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const reader = targetProc.stdout.getReader(); + const decoder = new TextDecoder(); + let output = ""; + while (!output.includes("READY")) { + const { value, done } = await reader.read(); + if (done) break; + output += decoder.decode(value, { stream: true }); + } + 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 stderrDecoder = new TextDecoder(); + let targetStderr = ""; + // Wait for the full banner (header + content + footer) + while ((targetStderr.match(/Bun Inspector/g) || []).length < 2) { + const { value, done } = await stderrReader.read(); + if (done) break; + targetStderr += stderrDecoder.decode(value, { stream: true }); + } + stderrReader.releaseLock(); + + targetProc.kill(); + await targetProc.exited; + + // Verify inspector actually started + expect(targetStderr).toContain("Bun Inspector"); + expect(targetStderr).toContain("ws://localhost:6499/"); + }); + + test("_debugProcess works with current process's own pid", async () => { + // On Windows, calling _debugProcess with our own PID should work + await using proc = spawn({ + cmd: [ + bunExe(), + "-e", + ` + setTimeout(() => process.exit(0), 300); + // Small delay to ensure handler is installed + setTimeout(() => { + process._debugProcess(process.pid); + }, 50); + setInterval(() => {}, 1000); + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + + expect(stderr).toContain("Bun Inspector"); + expect(exitCode).toBe(0); + }); + + test("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"); + + setTimeout(() => process.exit(0), 500); + setInterval(() => {}, 1000); + `, + }); + + await using targetProc = spawn({ + cmd: [bunExe(), "target.js"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const reader = targetProc.stdout.getReader(); + const decoder = new TextDecoder(); + let output = ""; + while (!output.includes("READY")) { + const { value, done } = await reader.read(); + if (done) break; + output += decoder.decode(value, { stream: true }); + } + 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(); + const stderrDecoder = new TextDecoder(); + let stderr = ""; + + // 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 (header + content + footer) + while ((stderr.match(/Bun Inspector/g) || []).length < 2) { + const { value, done } = await stderrReader.read(); + if (done) break; + stderr += stderrDecoder.decode(value, { stream: true }); + } + + await using debug2 = spawn({ + cmd: [bunExe(), "-e", `process._debugProcess(${pid})`], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + await debug2.exited; + + // Collect any remaining stderr and wait for process to exit + stderrReader.releaseLock(); + const remainingStderr = await targetProc.stderr.text(); + stderr += remainingStderr; + const exitCode = 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); + expect(exitCode).toBe(0); + }); + + test("multiple Windows processes can have inspectors sequentially", async () => { + // Note: Runtime inspector uses hardcoded port 6499, so we must test + // sequential activation (activate first, shut down, then activate second) + // rather than concurrent activation. + 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); + + setTimeout(() => process.exit(0), 5000); + setInterval(() => {}, 1000); + `, + }); + + const decoder = new TextDecoder(); + + // First process: activate inspector, verify, then shut down + { + await using target1 = spawn({ + cmd: [bunExe(), "target.js", "1"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const reader1 = target1.stdout.getReader(); + let output1 = ""; + while (!output1.includes("READY-1")) { + const { value, done } = await reader1.read(); + if (done) break; + output1 += decoder.decode(value, { stream: true }); + } + 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", + }); + + expect(await debug1.exited).toBe(0); + + // Wait for the full banner + const stderrReader1 = target1.stderr.getReader(); + const stderrDecoder1 = new TextDecoder(); + let stderr1 = ""; + while ((stderr1.match(/Bun Inspector/g) || []).length < 2) { + const { value, done } = await stderrReader1.read(); + if (done) break; + stderr1 += stderrDecoder1.decode(value, { stream: true }); + } + stderrReader1.releaseLock(); + + expect(stderr1).toContain("Bun Inspector"); + + target1.kill(); + await target1.exited; + } + + // Second process: now that first is shut down, port 6499 is free + { + await using target2 = spawn({ + cmd: [bunExe(), "target.js", "2"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const reader2 = target2.stdout.getReader(); + let output2 = ""; + while (!output2.includes("READY-2")) { + const { value, done } = await reader2.read(); + if (done) break; + output2 += decoder.decode(value, { stream: true }); + } + 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", + }); + + expect(await debug2.exited).toBe(0); + + // Wait for the full banner + const stderrReader2 = target2.stderr.getReader(); + const stderrDecoder2 = new TextDecoder(); + let stderr2 = ""; + while ((stderr2.match(/Bun Inspector/g) || []).length < 2) { + const { value, done } = await stderrReader2.read(); + if (done) break; + stderr2 += stderrDecoder2.decode(value, { stream: true }); + } + 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..587edf1954c --- /dev/null +++ b/test/js/bun/runtime-inspector/runtime-inspector.test.ts @@ -0,0 +1,448 @@ +import { spawn } from "bun"; +import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe, isASAN, isWindows, tempDir } from "harness"; +import { join } from "path"; + +// ASAN builds have issues with signal handling reliability for SIGUSR1-based inspector activation +const skipASAN = isASAN; + +/** + * 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 = ""; + + const startTime = Date.now(); + + // Wait for the full banner (header + content + footer) + // The banner format is: + // --------------------- Bun Inspector --------------------- + // Listening: + // ws://localhost:6499/... + // Inspect in browser: + // https://debug.bun.sh/#localhost:6499/... + // --------------------- Bun Inspector --------------------- + try { + while ((stderr.match(/Bun Inspector/g) || []).length < 2) { + if (Date.now() - startTime > timeoutMs) { + throw new Error(`Timeout waiting for Bun Inspector banner after ${timeoutMs}ms. Got stderr: "${stderr}"`); + } + + const { value, done } = await reader.read(); + if (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(skipASAN)("activates inspector in target process", async () => { + using dir = tempDir("debug-process-test", { + "target.js": ` + const fs = require("fs"); + const path = require("path"); + + // Write PID so parent can find us + fs.writeFileSync(path.join(process.cwd(), "pid"), String(process.pid)); + console.log("READY"); + + // Keep process alive + setInterval(() => {}, 1000); + `, + }); + + // Start target process + await using targetProc = spawn({ + cmd: [bunExe(), "target.js"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + // Wait for target to be ready + const reader = targetProc.stdout.getReader(); + const decoder = new TextDecoder(); + let output = ""; + while (!output.includes("READY")) { + const { value, done } = await reader.read(); + if (done) break; + output += decoder.decode(value, { stream: true }); + } + reader.releaseLock(); + + const pid = parseInt(await Bun.file(join(String(dir), "pid")).text(), 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(skipASAN)("inspector does not activate twice", async () => { + using dir = tempDir("debug-process-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(), "target.js"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const reader = targetProc.stdout.getReader(); + const decoder = new TextDecoder(); + let output = ""; + while (!output.includes("READY")) { + const { value, done } = await reader.read(); + if (done) break; + output += decoder.decode(value, { stream: true }); + } + reader.releaseLock(); + + const pid = parseInt(await Bun.file(join(String(dir), "pid")).text(), 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); + + // Release the reader and kill the target + stderrReader.releaseLock(); + targetProc.kill(); + 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(skipASAN)( + "can activate inspector in multiple processes sequentially", + async () => { + // Note: Runtime inspector uses hardcoded port 6499, so we must test + // sequential activation (activate first, shut down, then activate second) + // rather than concurrent activation. + using dir = tempDir("debug-process-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); + `, + }); + + const decoder = new TextDecoder(); + + // First process: activate inspector, verify, then shut down + { + await using target1 = spawn({ + cmd: [bunExe(), "target.js", "1"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const reader1 = target1.stdout.getReader(); + let output1 = ""; + while (!output1.includes("READY-1")) { + const { value, done } = await reader1.read(); + if (done) break; + output1 += decoder.decode(value, { stream: true }); + } + 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 = 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: now that first is shut down, port 6499 is free + { + await using target2 = spawn({ + cmd: [bunExe(), "target.js", "2"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const reader2 = target2.stdout.getReader(); + let output2 = ""; + while (!output2.includes("READY-2")) { + const { value, done } = await reader2.read(); + if (done) break; + output2 += decoder.decode(value, { stream: true }); + } + 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 = 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; + } + }, + 30000, + ); + + 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(skipASAN)("can interrupt an infinite loop", async () => { + using dir = tempDir("debug-infinite-loop-test", { + "target.js": ` + const fs = require("fs"); + const path = require("path"); + + // Write PID so parent can find us + fs.writeFileSync(path.join(process.cwd(), "pid"), String(process.pid)); + + // Infinite loop - the inspector should be able to interrupt this + while (true) {} + `, + }); + + // Start target process with infinite loop + await using targetProc = spawn({ + cmd: [bunExe(), "target.js"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + // Wait for PID file to be written + const pidPath = join(String(dir), "pid"); + let pid: number | undefined; + for (let i = 0; i < 50; i++) { + try { + const pidText = await Bun.file(pidPath).text(); + pid = parseInt(pidText, 10); + if (pid > 0) break; + } catch { + // File not ready yet + } + await Bun.sleep(100); + } + 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+\//); + }); + }); +}); + +// 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 () => { + using dir = tempDir("disable-sigusr1-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 signal terminates it + setInterval(() => {}, 1000); + `, + }); + + // Start with --disable-sigusr1 + await using targetProc = spawn({ + cmd: [bunExe(), "--disable-sigusr1", "target.js"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const reader = targetProc.stdout.getReader(); + const decoder = new TextDecoder(); + let output = ""; + while (!output.includes("READY")) { + const { value, done } = await reader.read(); + if (done) break; + output += decoder.decode(value, { stream: true }); + } + reader.releaseLock(); + + const pid = parseInt(await Bun.file(join(String(dir), "pid")).text(), 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 7ca6e853380..f12575feda1 100644 --- a/test/js/node/process/process.test.js +++ b/test/js/node/process/process.test.js @@ -684,7 +684,6 @@ describe.concurrent(() => { const undefinedStubs = [ "_debugEnd", - "_debugProcess", "_fatalException", "_linkedBinding", "_rawDebug",