mirror of
https://github.com/oven-sh/bun
synced 2026-02-04 07:58:54 +00:00
Compare commits
75 Commits
dylan/pyth
...
ali/sigusr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a6b804a37 | ||
|
|
b97f3f3f5c | ||
|
|
a2ba9cbd99 | ||
|
|
5024e20b50 | ||
|
|
cf53710bce | ||
|
|
d81941694e | ||
|
|
084f565b73 | ||
|
|
ad01185643 | ||
|
|
3800722478 | ||
|
|
37cbb0c633 | ||
|
|
82eb3f31f0 | ||
|
|
f2a13504a3 | ||
|
|
f6189c73f9 | ||
|
|
d780bf1a23 | ||
|
|
1c88d406c1 | ||
|
|
82bf72afd1 | ||
|
|
75c127ffde | ||
|
|
32ead03078 | ||
|
|
4c108149ed | ||
|
|
e3baed59b0 | ||
|
|
51e26fd045 | ||
|
|
a461b72ae7 | ||
|
|
88b19a848a | ||
|
|
e4cd00dda2 | ||
|
|
83a78f3336 | ||
|
|
6bd0cf31c9 | ||
|
|
1f70115906 | ||
|
|
098bcfa318 | ||
|
|
9195e68e27 | ||
|
|
f2a6c7c233 | ||
|
|
75a7ee527e | ||
|
|
3466088fcc | ||
|
|
e7ef32e9ca | ||
|
|
c83254abc0 | ||
|
|
e8f40c2329 | ||
|
|
ef5b11c1e4 | ||
|
|
f758a5f838 | ||
|
|
21fb83eb37 | ||
|
|
8990dfc2ef | ||
|
|
d9b396a29d | ||
|
|
3b67f3f77e | ||
|
|
0869bd738d | ||
|
|
b848ac202d | ||
|
|
aec9e812f1 | ||
|
|
ebcfbd2531 | ||
|
|
c06ef30736 | ||
|
|
57efbd0be5 | ||
|
|
d66255705a | ||
|
|
d28affd937 | ||
|
|
f188b9352f | ||
|
|
856eda2f24 | ||
|
|
cc6704de2f | ||
|
|
0ae67c72bc | ||
|
|
d607391e53 | ||
|
|
93de1c3b2c | ||
|
|
1d2becb314 | ||
|
|
1a70d189e1 | ||
|
|
19fa3d303e | ||
|
|
60b7424a34 | ||
|
|
a028ee95df | ||
|
|
c25572ebfe | ||
|
|
1366c692e8 | ||
|
|
87524734b1 | ||
|
|
1130675215 | ||
|
|
6aeadbf7d1 | ||
|
|
d1924b8b14 | ||
|
|
997c7764c5 | ||
|
|
a859227a66 | ||
|
|
e474a1d148 | ||
|
|
0a40bb54f4 | ||
|
|
5e504db796 | ||
|
|
3e15ddc5e2 | ||
|
|
f0f5d171fc | ||
|
|
91bfb9f7a8 | ||
|
|
05ea1a2044 |
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
#include "ZigGlobalObject.h"
|
||||
|
||||
#include <JavaScriptCore/InspectorFrontendChannel.h>
|
||||
#include <JavaScriptCore/StopTheWorldCallback.h>
|
||||
#include <JavaScriptCore/VMManager.h>
|
||||
#include <JavaScriptCore/JSGlobalObjectDebuggable.h>
|
||||
#include <JavaScriptCore/JSGlobalObjectDebugger.h>
|
||||
#include <JavaScriptCore/Debugger.h>
|
||||
@@ -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<JSC::VMManager::StopReason>(reason));
|
||||
}
|
||||
|
||||
extern "C" void VMManager__requestResumeAll(uint32_t reason)
|
||||
{
|
||||
JSC::VMManager::requestResumeAll(static_cast<JSC::VMManager::StopReason>(reason));
|
||||
}
|
||||
|
||||
@@ -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-<pid>` 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<LPTHREAD_START_ROUTINE*>(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
|
||||
|
||||
30
src/bun.js/bindings/VMManager.zig
Normal file
30
src/bun.js/bindings/VMManager.zig
Normal file
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
382
src/bun.js/event_loop/RuntimeInspector.zig
Normal file
382
src/bun.js/event_loop/RuntimeInspector.zig
Normal file
@@ -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 <pid>` to start debugger
|
||||
///
|
||||
/// On Windows:
|
||||
/// - Uses named file mapping mechanism (same as Node.js)
|
||||
/// - Creates "bun-debug-handler-<pid>" 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=<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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -87,6 +87,7 @@ pub const runtime_params_ = [_]ParamType{
|
||||
clap.parseParam("--inspect <STR>? Activate Bun's debugger") catch unreachable,
|
||||
clap.parseParam("--inspect-wait <STR>? Activate Bun's debugger, wait for a connection before executing") catch unreachable,
|
||||
clap.parseParam("--inspect-brk <STR>? 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 <STR> Specify the name of the CPU profile file") catch unreachable,
|
||||
clap.parseParam("--cpu-prof-dir <STR> 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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
39
src/sync/Semaphore.zig
Normal file
39
src/sync/Semaphore.zig
Normal file
@@ -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;
|
||||
@@ -1,5 +1,9 @@
|
||||
#include "Semaphore.h"
|
||||
|
||||
#if !OS(WINDOWS) && !OS(DARWIN)
|
||||
#include <cerrno>
|
||||
#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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string> = {
|
||||
...process.env,
|
||||
|
||||
442
test/js/bun/runtime-inspector/runtime-inspector-posix.test.ts
Normal file
442
test/js/bun/runtime-inspector/runtime-inspector-posix.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
295
test/js/bun/runtime-inspector/runtime-inspector-windows.test.ts
Normal file
295
test/js/bun/runtime-inspector/runtime-inspector-windows.test.ts
Normal file
@@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
448
test/js/bun/runtime-inspector/runtime-inspector.test.ts
Normal file
448
test/js/bun/runtime-inspector/runtime-inspector.test.ts
Normal file
@@ -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<Uint8Array>,
|
||||
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]);
|
||||
});
|
||||
});
|
||||
@@ -684,7 +684,6 @@ describe.concurrent(() => {
|
||||
|
||||
const undefinedStubs = [
|
||||
"_debugEnd",
|
||||
"_debugProcess",
|
||||
"_fatalException",
|
||||
"_linkedBinding",
|
||||
"_rawDebug",
|
||||
|
||||
Reference in New Issue
Block a user