mirror of
https://github.com/oven-sh/bun
synced 2026-02-19 15:21:54 +00:00
Compare commits
134 Commits
claude/fix
...
ali/inspec
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8edd9f4007 | ||
|
|
831f901e2d | ||
|
|
87838209db | ||
|
|
b078ae29dc | ||
|
|
4c61221eb4 | ||
|
|
d95e006879 | ||
|
|
9a2c9b3394 | ||
|
|
4ca4f07d4c | ||
|
|
75990c97de | ||
|
|
cbe3ce02ea | ||
|
|
32ab3a5a75 | ||
|
|
522a80dc76 | ||
|
|
b20003779f | ||
|
|
4a9c54d03b | ||
|
|
49c2ef2732 | ||
|
|
ee7d7bd99c | ||
|
|
0329c4a6a4 | ||
|
|
7214b0475e | ||
|
|
7c136691a8 | ||
|
|
fd1df3f892 | ||
|
|
22ca21dce5 | ||
|
|
68eeae70f3 | ||
|
|
743b477244 | ||
|
|
a4c1defe77 | ||
|
|
7eda29f2d6 | ||
|
|
95d01f71c9 | ||
|
|
3680b3887c | ||
|
|
71cccddf29 | ||
|
|
11d386c453 | ||
|
|
bff435f3b1 | ||
|
|
8bd240b5c2 | ||
|
|
5bc8d247e0 | ||
|
|
32abcf8736 | ||
|
|
e5089fc1fd | ||
|
|
0d1a98b5e0 | ||
|
|
005c9184bf | ||
|
|
5c165f8ab2 | ||
|
|
8ace3a5e27 | ||
|
|
5fc0806f8c | ||
|
|
2df14412e3 | ||
|
|
3ba2cb888a | ||
|
|
3a6a346438 | ||
|
|
a54d19e533 | ||
|
|
7f01e99c55 | ||
|
|
d288559fd2 | ||
|
|
0fee9b809b | ||
|
|
c2c72f71dd | ||
|
|
4e26dbe304 | ||
|
|
bf15eb7955 | ||
|
|
2e2159a9c3 | ||
|
|
265f07a27c | ||
|
|
81028e08a7 | ||
|
|
de1634b9cd | ||
|
|
868d3b6c0b | ||
|
|
c417af9a2c | ||
|
|
5a4823285c | ||
|
|
e56a08d58d | ||
|
|
c27dd9048c | ||
|
|
be97a9771b | ||
|
|
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 |
@@ -6,11 +6,11 @@ option(WEBKIT_LOCAL "If a local version of WebKit should be used instead of down
|
||||
option(WEBKIT_BUILD_TYPE "The build type for local WebKit (defaults to CMAKE_BUILD_TYPE)")
|
||||
|
||||
if(NOT WEBKIT_VERSION)
|
||||
set(WEBKIT_VERSION 8af7958ff0e2a4787569edf64641a1ae7cfe074a)
|
||||
set(WEBKIT_VERSION autobuild-preview-pr-161-0596ebdb)
|
||||
endif()
|
||||
|
||||
# Use preview build URL for Windows ARM64 until the fix is merged to main
|
||||
set(WEBKIT_PREVIEW_PR 140)
|
||||
# Use preview build URL for PR branches until merged to main
|
||||
set(WEBKIT_PREVIEW_PR 161)
|
||||
|
||||
string(SUBSTRING ${WEBKIT_VERSION} 0 16 WEBKIT_VERSION_PREFIX)
|
||||
string(SUBSTRING ${WEBKIT_VERSION} 0 8 WEBKIT_VERSION_SHORT)
|
||||
|
||||
@@ -38,6 +38,8 @@ pub const Run = struct {
|
||||
.smol = ctx.runtime_options.smol,
|
||||
.debugger = ctx.runtime_options.debugger,
|
||||
.dns_result_order = DNSResolver.Order.fromStringOrDie(ctx.runtime_options.dns_result_order),
|
||||
.disable_sigusr1 = ctx.runtime_options.disable_sigusr1,
|
||||
.inspect_port = ctx.runtime_options.inspect_port,
|
||||
}),
|
||||
.arena = arena,
|
||||
.ctx = ctx,
|
||||
@@ -186,6 +188,8 @@ pub const Run = struct {
|
||||
.debugger = ctx.runtime_options.debugger,
|
||||
.dns_result_order = DNSResolver.Order.fromStringOrDie(ctx.runtime_options.dns_result_order),
|
||||
.is_main_thread = true,
|
||||
.disable_sigusr1 = ctx.runtime_options.disable_sigusr1,
|
||||
.inspect_port = ctx.runtime_options.inspect_port,
|
||||
},
|
||||
),
|
||||
.arena = arena,
|
||||
|
||||
@@ -164,6 +164,9 @@ hot_reload_counter: u32 = 0,
|
||||
|
||||
debugger: ?jsc.Debugger = null,
|
||||
has_started_debugger: bool = false,
|
||||
/// Pre-configured inspector port for runtime activation (via --inspect-port).
|
||||
/// Used by RuntimeInspector when SIGUSR1/process._debugProcess activates the inspector.
|
||||
inspect_port: ?[]const u8 = null,
|
||||
has_terminated: bool = false,
|
||||
|
||||
debug_thread_id: if (Environment.allow_assert) std.Thread.Id else void,
|
||||
@@ -1082,9 +1085,12 @@ pub fn initWithModuleGraph(
|
||||
vm.jsc_vm = vm.global.vm();
|
||||
uws.Loop.get().internal_loop_data.jsc_vm = vm.jsc_vm;
|
||||
|
||||
vm.inspect_port = opts.inspect_port;
|
||||
vm.configureDebugger(opts.debugger);
|
||||
vm.body_value_hive_allocator = Body.Value.HiveAllocator.init(bun.typedAllocator(jsc.WebCore.Body.Value));
|
||||
|
||||
configureSigusr1Handler(vm, opts);
|
||||
|
||||
return vm;
|
||||
}
|
||||
|
||||
@@ -1111,8 +1117,28 @@ pub const Options = struct {
|
||||
/// Worker VMs are always destroyed on exit, regardless of this setting. Setting this to
|
||||
/// true may expose bugs that would otherwise only occur using Workers.
|
||||
destruct_main_thread_on_exit: bool = false,
|
||||
/// Disable SIGUSR1 handler for runtime debugger activation (matches Node.js).
|
||||
disable_sigusr1: bool = false,
|
||||
/// Pre-configured inspector port for runtime activation (--inspect-port).
|
||||
inspect_port: ?[]const u8 = null,
|
||||
};
|
||||
|
||||
/// Configure SIGUSR1 handling for runtime debugger activation (main thread only).
|
||||
fn configureSigusr1Handler(vm: *const VirtualMachine, opts: Options) void {
|
||||
if (!opts.is_main_thread) return;
|
||||
|
||||
if (opts.disable_sigusr1) {
|
||||
// User requested --disable-sigusr1, set SIGUSR1 to default action (terminate)
|
||||
jsc.EventLoop.RuntimeInspector.setDefaultSigusr1Action();
|
||||
} else if (vm.debugger != null) {
|
||||
// Debugger already enabled via CLI flags, ignore SIGUSR1
|
||||
jsc.EventLoop.RuntimeInspector.ignoreSigusr1();
|
||||
} else {
|
||||
// Install RuntimeInspector signal handler for runtime activation
|
||||
jsc.EventLoop.RuntimeInspector.installIfNotAlready();
|
||||
}
|
||||
}
|
||||
|
||||
pub var is_smol_mode = false;
|
||||
|
||||
pub fn init(opts: Options) !*VirtualMachine {
|
||||
@@ -1209,9 +1235,12 @@ pub fn init(opts: Options) !*VirtualMachine {
|
||||
if (opts.smol)
|
||||
is_smol_mode = opts.smol;
|
||||
|
||||
vm.inspect_port = opts.inspect_port;
|
||||
vm.configureDebugger(opts.debugger);
|
||||
vm.body_value_hive_allocator = Body.Value.HiveAllocator.init(bun.typedAllocator(jsc.WebCore.Body.Value));
|
||||
|
||||
configureSigusr1Handler(vm, opts);
|
||||
|
||||
return vm;
|
||||
}
|
||||
|
||||
@@ -1258,8 +1287,15 @@ fn configureDebugger(this: *VirtualMachine, cli_flag: bun.cli.Command.Debugger)
|
||||
}
|
||||
},
|
||||
.enable => {
|
||||
// If --inspect/--inspect-brk/--inspect-wait is used without an explicit port,
|
||||
// use --inspect-port if provided.
|
||||
const path_or_port = if (cli_flag.enable.path_or_port.len == 0)
|
||||
this.inspect_port orelse cli_flag.enable.path_or_port
|
||||
else
|
||||
cli_flag.enable.path_or_port;
|
||||
|
||||
this.debugger = .{
|
||||
.path_or_port = cli_flag.enable.path_or_port,
|
||||
.path_or_port = path_or_port,
|
||||
.from_environment_variable = unix,
|
||||
.wait_for_connection = if (cli_flag.enable.wait_for_connection) .forever else wait_for_connection,
|
||||
.set_breakpoint_on_first_line = set_breakpoint_on_first_line or cli_flag.enable.set_breakpoint_on_first_line,
|
||||
@@ -1459,9 +1495,12 @@ pub fn initBake(opts: Options) anyerror!*VirtualMachine {
|
||||
if (opts.smol)
|
||||
is_smol_mode = opts.smol;
|
||||
|
||||
vm.inspect_port = opts.inspect_port;
|
||||
vm.configureDebugger(opts.debugger);
|
||||
vm.body_value_hive_allocator = Body.Value.HiveAllocator.init(bun.typedAllocator(jsc.WebCore.Body.Value));
|
||||
|
||||
configureSigusr1Handler(vm, opts);
|
||||
|
||||
return vm;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -16,14 +18,20 @@
|
||||
#include "InspectorBunFrontendDevServerAgent.h"
|
||||
#include "InspectorHTTPServerAgent.h"
|
||||
|
||||
extern "C" void Bun__tickWhilePaused(bool*);
|
||||
extern "C" void Bun__eventLoop__incrementRefConcurrently(void* bunVM, int delta);
|
||||
|
||||
namespace Bun {
|
||||
using namespace JSC;
|
||||
using namespace WebCore;
|
||||
|
||||
// True when the inspector was activated at runtime (SIGUSR1 / process._debugProcess),
|
||||
// as opposed to --inspect at startup. When true, connect() uses requestStopAll to
|
||||
// interrupt busy JS execution. When false (--inspect), the event loop handles delivery.
|
||||
static std::atomic<bool> runtimeInspectorActivated { false };
|
||||
|
||||
class BunInspectorConnection;
|
||||
static void installRunWhilePausedCallback(JSC::JSGlobalObject* globalObject);
|
||||
static void makeInspectable(JSC::JSGlobalObject* globalObject);
|
||||
|
||||
static WebCore::ScriptExecutionContext* debuggerScriptExecutionContext = nullptr;
|
||||
static WTF::Lock inspectorConnectionsLock = WTF::Lock();
|
||||
@@ -62,6 +70,12 @@ public:
|
||||
}
|
||||
};
|
||||
|
||||
static void makeInspectable(JSC::JSGlobalObject* globalObject)
|
||||
{
|
||||
globalObject->setInspectable(true);
|
||||
globalObject->inspectorDebuggable().setInspectable(true);
|
||||
}
|
||||
|
||||
enum class ConnectionStatus : int32_t {
|
||||
Pending = 0,
|
||||
Connected = 1,
|
||||
@@ -101,9 +115,7 @@ public:
|
||||
if (this->unrefOnDisconnect) {
|
||||
Bun__eventLoop__incrementRefConcurrently(static_cast<Zig::GlobalObject*>(globalObject)->bunVM(), 1);
|
||||
}
|
||||
globalObject->setInspectable(true);
|
||||
auto& inspector = globalObject->inspectorDebuggable();
|
||||
inspector.setInspectable(true);
|
||||
makeInspectable(globalObject);
|
||||
|
||||
static bool hasConnected = false;
|
||||
|
||||
@@ -122,13 +134,17 @@ public:
|
||||
this->hasEverConnected = true;
|
||||
globalObject->inspectorController().connectFrontend(*this, true, false); // waitingForConnection
|
||||
|
||||
Inspector::JSGlobalObjectDebugger* debugger = reinterpret_cast<Inspector::JSGlobalObjectDebugger*>(globalObject->debugger());
|
||||
if (debugger) {
|
||||
debugger->runWhilePausedCallback = [](JSC::JSGlobalObject& globalObject, bool& isDoneProcessingEvents) -> void {
|
||||
BunInspectorConnection::runWhilePaused(globalObject, isDoneProcessingEvents);
|
||||
};
|
||||
// Pre-attach the debugger so that schedulePauseAtNextOpportunity() can work
|
||||
// during the STW callback. Only on the SIGUSR1 path — for --inspect, the
|
||||
// debugger gets attached later via the Debugger.enable CDP command.
|
||||
if (runtimeInspectorActivated.load()) {
|
||||
auto* controllerDebugger = globalObject->inspectorController().debugger();
|
||||
if (controllerDebugger && !globalObject->debugger())
|
||||
controllerDebugger->attach(globalObject);
|
||||
}
|
||||
|
||||
installRunWhilePausedCallback(globalObject);
|
||||
|
||||
this->receiveMessagesOnInspectorThread(context, static_cast<Zig::GlobalObject*>(globalObject), false);
|
||||
}
|
||||
|
||||
@@ -158,6 +174,20 @@ public:
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Only use StopTheWorld for runtime-activated inspector (SIGUSR1 path)
|
||||
// where the event loop may not be running (e.g., while(true){}).
|
||||
// For --inspect, the event loop delivers doConnect via ensureOnContextThread above.
|
||||
//
|
||||
// Fire STW to interrupt busy JS (e.g., while(true){}) and process
|
||||
// this connection via the Bun__stopTheWorldCallback.
|
||||
// Note: do NOT fire a deferred requestStopAll here — if the target VM
|
||||
// enters the pause loop before the deferred STW fires, the deferred STW
|
||||
// deadlocks (target is in C++ pause loop, can't reach JS safe point,
|
||||
// debugger thread blocks in STW and can't deliver messages).
|
||||
if (runtimeInspectorActivated.load()) {
|
||||
VMManager::requestStopAll(VMManager::StopReason::JSDebugger);
|
||||
}
|
||||
}
|
||||
|
||||
void disconnect()
|
||||
@@ -214,6 +244,17 @@ public:
|
||||
connections.appendVector(inspectorConnections->get(global->scriptExecutionContext()->identifier()));
|
||||
}
|
||||
|
||||
// Check if this is a bootstrap pause (from breakProgram in handleTraps).
|
||||
// Bootstrap pauses dispatch messages and exit so the VM can re-enter
|
||||
// a proper pause with Debugger.paused event after Debugger.pause is received.
|
||||
bool isBootstrapPause = false;
|
||||
for (auto* connection : connections) {
|
||||
// Atomically read and clear pause reason flags.
|
||||
uint8_t prev = connection->pauseFlags.exchange(0);
|
||||
if (prev & BunInspectorConnection::kBootstrapPause)
|
||||
isBootstrapPause = true;
|
||||
}
|
||||
|
||||
for (auto* connection : connections) {
|
||||
if (connection->status == ConnectionStatus::Pending) {
|
||||
connection->connect();
|
||||
@@ -225,11 +266,31 @@ public:
|
||||
}
|
||||
}
|
||||
|
||||
// for (auto* connection : connections) {
|
||||
// if (connection->status == ConnectionStatus::Connected) {
|
||||
// connection->jsWaitForMessageFromInspectorLock.lock();
|
||||
// }
|
||||
// }
|
||||
if (isBootstrapPause) {
|
||||
// Bootstrap pause: breakProgram() fired from VMTraps to provide a
|
||||
// window for processing setup messages (e.g., Debugger.enable).
|
||||
// The drain above may or may not have processed them (depends on
|
||||
// timing — frontend messages may not have arrived yet).
|
||||
// Resume immediately. Messages will be delivered via the
|
||||
// NeedDebuggerBreak trap mechanism as they arrive. The user can
|
||||
// click Pause later for a real pause with proper call frames.
|
||||
//
|
||||
// Previously, this sent a synthetic Debugger.paused with empty
|
||||
// callFrames:[], but the frontend (DebuggerManager.js) auto-resumes
|
||||
// when activeCallFrame is null, making it pointless. Scripts also
|
||||
// weren't registered (no scriptParsed events), so even real pauses
|
||||
// had their call frames filtered out → auto-resume.
|
||||
if (auto* debugger = global->debugger())
|
||||
debugger->continueProgram();
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark all connections as being in the pause loop so that
|
||||
// interruptForMessageDelivery skips requestStopAll (which would
|
||||
// deadlock: the debugger thread blocks in STW while the target
|
||||
// VM is in this C++ loop and never reaches a JS safe point).
|
||||
for (auto* connection : connections)
|
||||
connection->pauseFlags.store(BunInspectorConnection::kInPauseLoop);
|
||||
|
||||
if (connections.size() == 1) {
|
||||
while (!isDoneProcessingEvents) {
|
||||
@@ -258,11 +319,48 @@ public:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Drain any remaining messages before clearing flags to prevent
|
||||
// them from triggering a new interruptForMessageDelivery → STW → pause cascade.
|
||||
for (auto* connection : connections) {
|
||||
if (connection->status != ConnectionStatus::Disconnected) {
|
||||
connection->receiveMessagesOnInspectorThread(*global->scriptExecutionContext(), global, false);
|
||||
}
|
||||
}
|
||||
|
||||
for (auto* connection : connections) {
|
||||
connection->pauseFlags.store(0);
|
||||
// Reset the scheduled flag so the debugger thread can post new
|
||||
// tasks after the pause loop exits.
|
||||
connection->jsThreadMessageScheduled.store(false);
|
||||
}
|
||||
}
|
||||
|
||||
void receiveMessagesOnInspectorThread(ScriptExecutionContext& context, Zig::GlobalObject* globalObject, bool connectIfNeeded)
|
||||
{
|
||||
this->jsThreadMessageScheduledCount.store(0);
|
||||
// Only clear the scheduled flag when NOT in the pause loop.
|
||||
// During the pause loop, receiveMessagesOnInspectorThread is called
|
||||
// repeatedly by the busy-poll. Clearing the flag would cause the
|
||||
// debugger thread to re-post a task + interruptForMessageDelivery
|
||||
// on every subsequent message, which is wasteful (and the posted
|
||||
// tasks pile up for after the loop exits).
|
||||
if (!(this->pauseFlags.load() & kInPauseLoop))
|
||||
this->jsThreadMessageScheduled.store(false);
|
||||
|
||||
// Connect pending connections BEFORE draining messages.
|
||||
// If we drain first and then doConnect returns early, the drained
|
||||
// messages would be lost (dropped on stack unwind).
|
||||
auto& dispatcher = globalObject->inspectorDebuggable();
|
||||
Inspector::JSGlobalObjectDebugger* debugger = reinterpret_cast<Inspector::JSGlobalObjectDebugger*>(globalObject->debugger());
|
||||
|
||||
if (!debugger && connectIfNeeded && this->status == ConnectionStatus::Pending) {
|
||||
this->doConnect(context);
|
||||
// doConnect calls receiveMessagesOnInspectorThread recursively,
|
||||
// but jsThreadMessages may have been empty at that point.
|
||||
// Fall through to drain any messages that arrived during doConnect.
|
||||
debugger = reinterpret_cast<Inspector::JSGlobalObjectDebugger*>(globalObject->debugger());
|
||||
}
|
||||
|
||||
WTF::Vector<WTF::String, 12> messages;
|
||||
|
||||
{
|
||||
@@ -270,25 +368,14 @@ public:
|
||||
this->jsThreadMessages.swap(messages);
|
||||
}
|
||||
|
||||
auto& dispatcher = globalObject->inspectorDebuggable();
|
||||
Inspector::JSGlobalObjectDebugger* debugger = reinterpret_cast<Inspector::JSGlobalObjectDebugger*>(globalObject->debugger());
|
||||
|
||||
if (!debugger) {
|
||||
if (connectIfNeeded && this->status == ConnectionStatus::Pending) {
|
||||
this->doConnect(context);
|
||||
return;
|
||||
}
|
||||
|
||||
for (auto message : messages) {
|
||||
dispatcher.dispatchMessageFromRemote(WTF::move(message));
|
||||
|
||||
if (!debugger) {
|
||||
debugger = reinterpret_cast<Inspector::JSGlobalObjectDebugger*>(globalObject->debugger());
|
||||
if (debugger) {
|
||||
debugger->runWhilePausedCallback = [](JSC::JSGlobalObject& globalObject, bool& isDoneProcessingEvents) -> void {
|
||||
runWhilePaused(globalObject, isDoneProcessingEvents);
|
||||
};
|
||||
}
|
||||
if (debugger)
|
||||
installRunWhilePausedCallback(globalObject);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -296,13 +383,11 @@ public:
|
||||
dispatcher.dispatchMessageFromRemote(WTF::move(message));
|
||||
}
|
||||
}
|
||||
|
||||
messages.clear();
|
||||
}
|
||||
|
||||
void receiveMessagesOnDebuggerThread(ScriptExecutionContext& context, Zig::GlobalObject* debuggerGlobalObject)
|
||||
{
|
||||
debuggerThreadMessageScheduledCount.store(0);
|
||||
debuggerThreadMessageScheduled.store(false);
|
||||
WTF::Vector<WTF::String, 12> messages;
|
||||
|
||||
{
|
||||
@@ -319,19 +404,19 @@ public:
|
||||
arguments.append(jsString(vm, message));
|
||||
}
|
||||
|
||||
messages.clear();
|
||||
|
||||
JSC::call(debuggerGlobalObject, onMessageFn, arguments, "BunInspectorConnection::receiveMessagesOnDebuggerThread - onMessageFn"_s);
|
||||
}
|
||||
|
||||
void sendMessageToDebuggerThread(WTF::String&& inputMessage)
|
||||
{
|
||||
bool wasScheduled;
|
||||
{
|
||||
Locker<Lock> locker(debuggerThreadMessagesLock);
|
||||
debuggerThreadMessages.append(inputMessage);
|
||||
}
|
||||
|
||||
if (this->debuggerThreadMessageScheduledCount++ == 0) {
|
||||
wasScheduled = this->debuggerThreadMessageScheduled.exchange(true);
|
||||
if (!wasScheduled) {
|
||||
debuggerScriptExecutionContext->postTaskConcurrently([connection = this](ScriptExecutionContext& context) {
|
||||
connection->receiveMessagesOnDebuggerThread(context, static_cast<Zig::GlobalObject*>(context.jsGlobalObject()));
|
||||
});
|
||||
@@ -344,14 +429,7 @@ public:
|
||||
Locker<Lock> locker(jsThreadMessagesLock);
|
||||
jsThreadMessages.appendVector(inputMessages);
|
||||
}
|
||||
|
||||
if (this->jsWaitForMessageFromInspectorLock.isLocked()) {
|
||||
this->jsWaitForMessageFromInspectorLock.unlock();
|
||||
} else if (this->jsThreadMessageScheduledCount++ == 0) {
|
||||
ScriptExecutionContext::postTaskTo(scriptExecutionContextIdentifier, [connection = this](ScriptExecutionContext& context) {
|
||||
connection->receiveMessagesOnInspectorThread(context, static_cast<Zig::GlobalObject*>(context.jsGlobalObject()), true);
|
||||
});
|
||||
}
|
||||
scheduleInspectorThreadDelivery();
|
||||
}
|
||||
|
||||
void sendMessageToInspectorFromDebuggerThread(const WTF::String& inputMessage)
|
||||
@@ -360,23 +438,60 @@ public:
|
||||
Locker<Lock> locker(jsThreadMessagesLock);
|
||||
jsThreadMessages.append(inputMessage);
|
||||
}
|
||||
scheduleInspectorThreadDelivery();
|
||||
}
|
||||
|
||||
private:
|
||||
void scheduleInspectorThreadDelivery()
|
||||
{
|
||||
if (this->jsWaitForMessageFromInspectorLock.isLocked()) {
|
||||
this->jsWaitForMessageFromInspectorLock.unlock();
|
||||
} else if (this->jsThreadMessageScheduledCount++ == 0) {
|
||||
} else if (!this->jsThreadMessageScheduled.exchange(true)) {
|
||||
ScriptExecutionContext::postTaskTo(scriptExecutionContextIdentifier, [connection = this](ScriptExecutionContext& context) {
|
||||
connection->receiveMessagesOnInspectorThread(context, static_cast<Zig::GlobalObject*>(context.jsGlobalObject()), true);
|
||||
});
|
||||
// Also interrupt busy JS execution via the debugger's pause mechanism.
|
||||
// If the debugger is attached, this triggers a pause at the next trap check,
|
||||
// where runWhilePaused will dispatch the queued messages.
|
||||
// If the debugger is not attached, the event loop delivery (above) is the fallback.
|
||||
this->interruptForMessageDelivery();
|
||||
} else {
|
||||
}
|
||||
}
|
||||
|
||||
public:
|
||||
// Interrupt the JS thread to process pending CDP messages via StopTheWorld.
|
||||
// Only used on the SIGUSR1 runtime activation path where the event loop may
|
||||
// not be running (e.g., while(true){}). For --inspect, the event loop
|
||||
// delivers messages via postTaskTo.
|
||||
void interruptForMessageDelivery()
|
||||
{
|
||||
if (!runtimeInspectorActivated.load())
|
||||
return;
|
||||
// If kInPauseLoop is set, the target VM is already in the runWhilePaused
|
||||
// message pump (busy-polling receiveMessagesOnInspectorThread). Skip the
|
||||
// STW request to avoid deadlock.
|
||||
uint8_t flags = this->pauseFlags.load();
|
||||
if (flags & kInPauseLoop)
|
||||
return;
|
||||
// Use notifyNeedDebuggerBreak instead of requestStopAll.
|
||||
// This sets the NeedDebuggerBreak trap on the target VM only,
|
||||
// WITHOUT stopping the debugger thread's VM. The trap handler
|
||||
// drains CDP messages and only enters breakProgram() if a pause
|
||||
// was explicitly requested (e.g., Debugger.pause).
|
||||
// This avoids the cascade where every message delivery stops
|
||||
// the debugger thread, preventing response delivery.
|
||||
this->pauseFlags.fetch_or(kMessageDeliveryPause);
|
||||
this->globalObject->vm().notifyNeedDebuggerBreak();
|
||||
}
|
||||
|
||||
WTF::Vector<WTF::String, 12> debuggerThreadMessages;
|
||||
WTF::Lock debuggerThreadMessagesLock = WTF::Lock();
|
||||
std::atomic<uint32_t> debuggerThreadMessageScheduledCount { 0 };
|
||||
std::atomic<bool> debuggerThreadMessageScheduled { false };
|
||||
|
||||
WTF::Vector<WTF::String, 12> jsThreadMessages;
|
||||
WTF::Lock jsThreadMessagesLock = WTF::Lock();
|
||||
std::atomic<uint32_t> jsThreadMessageScheduledCount { 0 };
|
||||
std::atomic<bool> jsThreadMessageScheduled { false };
|
||||
|
||||
JSC::JSGlobalObject* globalObject;
|
||||
ScriptExecutionContextIdentifier scriptExecutionContextIdentifier;
|
||||
@@ -385,11 +500,61 @@ public:
|
||||
WTF::Lock jsWaitForMessageFromInspectorLock;
|
||||
std::atomic<ConnectionStatus> status = ConnectionStatus::Pending;
|
||||
|
||||
// Pause state flags (consolidated into a single atomic).
|
||||
//
|
||||
// kBootstrapPause - runWhilePaused should send a synthetic Debugger.paused event
|
||||
// kMessageDeliveryPause - a notifyNeedDebuggerBreak trap is needed to deliver CDP messages (no synthetic event)
|
||||
// kInPauseLoop - the connection is in the runWhilePaused message pump loop;
|
||||
// interruptForMessageDelivery must skip requestStopAll to avoid
|
||||
// deadlock (debugger thread blocks in STW while target VM is in
|
||||
// C++ code that never reaches a JS safe point)
|
||||
//
|
||||
static constexpr uint8_t kBootstrapPause = 1 << 0;
|
||||
static constexpr uint8_t kMessageDeliveryPause = 1 << 1;
|
||||
static constexpr uint8_t kInPauseLoop = 1 << 2;
|
||||
std::atomic<uint8_t> pauseFlags { 0 };
|
||||
|
||||
bool unrefOnDisconnect = false;
|
||||
|
||||
bool hasEverConnected = false;
|
||||
};
|
||||
|
||||
// This callback is invoked by JSC when the debugger enters a paused state,
|
||||
// delegating to BunInspectorConnection::runWhilePaused for CDP message pumping.
|
||||
static void installRunWhilePausedCallback(JSC::JSGlobalObject* globalObject)
|
||||
{
|
||||
auto* debugger = reinterpret_cast<Inspector::JSGlobalObjectDebugger*>(globalObject->debugger());
|
||||
if (debugger) {
|
||||
debugger->runWhilePausedCallback = [](JSC::JSGlobalObject& go, bool& done) {
|
||||
BunInspectorConnection::runWhilePaused(go, done);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
template<typename Func>
|
||||
static auto forEachConnection(Func&& callback) -> void
|
||||
{
|
||||
Locker<Lock> locker(inspectorConnectionsLock);
|
||||
if (!inspectorConnections)
|
||||
return;
|
||||
for (auto& entry : *inspectorConnections) {
|
||||
for (auto* connection : entry.value) {
|
||||
if (callback(connection))
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
template<typename Func>
|
||||
static auto forEachConnectionForVM(JSC::VM& vm, Func&& callback) -> void
|
||||
{
|
||||
forEachConnection([&](BunInspectorConnection* connection) -> bool {
|
||||
if (!connection->globalObject || &connection->globalObject->vm() != &vm)
|
||||
return false;
|
||||
return callback(connection);
|
||||
});
|
||||
}
|
||||
|
||||
JSC_DECLARE_HOST_FUNCTION(jsFunctionSend);
|
||||
JSC_DECLARE_HOST_FUNCTION(jsFunctionDisconnect);
|
||||
|
||||
@@ -500,7 +665,6 @@ extern "C" unsigned int Bun__createJSDebugger(Zig::GlobalObject* globalObject)
|
||||
|
||||
return static_cast<unsigned int>(globalObject->scriptExecutionContext()->identifier());
|
||||
}
|
||||
extern "C" void Bun__tickWhilePaused(bool*);
|
||||
|
||||
extern "C" void Bun__ensureDebugger(ScriptExecutionContextIdentifier scriptId, bool pauseOnStart)
|
||||
{
|
||||
@@ -510,17 +674,9 @@ extern "C" void Bun__ensureDebugger(ScriptExecutionContextIdentifier scriptId, b
|
||||
globalObject->m_inspectorDebuggable = BunJSGlobalObjectDebuggable::create(*globalObject);
|
||||
globalObject->m_inspectorDebuggable->init();
|
||||
|
||||
globalObject->setInspectable(true);
|
||||
makeInspectable(globalObject);
|
||||
|
||||
auto& inspector = globalObject->inspectorDebuggable();
|
||||
inspector.setInspectable(true);
|
||||
|
||||
Inspector::JSGlobalObjectDebugger* debugger = reinterpret_cast<Inspector::JSGlobalObjectDebugger*>(globalObject->debugger());
|
||||
if (debugger) {
|
||||
debugger->runWhilePausedCallback = [](JSC::JSGlobalObject& globalObject, bool& isDoneProcessingEvents) -> void {
|
||||
BunInspectorConnection::runWhilePaused(globalObject, isDoneProcessingEvents);
|
||||
};
|
||||
}
|
||||
installRunWhilePausedCallback(globalObject);
|
||||
if (pauseOnStart) {
|
||||
waitingForConnection = true;
|
||||
}
|
||||
@@ -662,4 +818,206 @@ extern "C" void Debugger__willDispatchAsyncCall(JSGlobalObject* globalObject, As
|
||||
|
||||
agent->willDispatchAsyncCall(getCallType(callType), callbackId);
|
||||
}
|
||||
|
||||
// Helper functions called from the StopTheWorld callback.
|
||||
// These run on the main thread at a safe point.
|
||||
|
||||
bool processPendingConnections(JSC::VM& callbackVM)
|
||||
{
|
||||
bool connected = false;
|
||||
Vector<BunInspectorConnection*, 8> pendingConnections;
|
||||
forEachConnectionForVM(callbackVM, [&](BunInspectorConnection* connection) -> bool {
|
||||
if (connection->status == ConnectionStatus::Pending)
|
||||
pendingConnections.append(connection);
|
||||
return false;
|
||||
});
|
||||
|
||||
for (auto* connection : pendingConnections) {
|
||||
auto* context = ScriptExecutionContext::getScriptExecutionContext(connection->scriptExecutionContextIdentifier);
|
||||
if (!context)
|
||||
continue;
|
||||
connection->doConnect(*context);
|
||||
connected = true;
|
||||
}
|
||||
return connected;
|
||||
}
|
||||
|
||||
// Find a VM (other than the given one) that has pending work:
|
||||
// either a pending connection or a pending pause (bootstrap or message delivery).
|
||||
// Used to switch the STW callback to the right VM thread.
|
||||
JSC::VM* findVMWithPendingWork(JSC::VM& excludeVM)
|
||||
{
|
||||
JSC::VM* result = nullptr;
|
||||
forEachConnection([&](BunInspectorConnection* connection) -> bool {
|
||||
if (!connection->globalObject || &connection->globalObject->vm() == &excludeVM)
|
||||
return false;
|
||||
bool hasPendingConnection = (connection->status == ConnectionStatus::Pending);
|
||||
bool hasPendingPause = (connection->pauseFlags.load()
|
||||
& (BunInspectorConnection::kBootstrapPause | BunInspectorConnection::kMessageDeliveryPause));
|
||||
if (hasPendingConnection || hasPendingPause) {
|
||||
result = &connection->globalObject->vm();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
// Check if any connection has pending pause flags (bootstrap or message delivery).
|
||||
uint8_t getPendingPauseFlags()
|
||||
{
|
||||
uint8_t result = 0;
|
||||
forEachConnection([&](BunInspectorConnection* connection) -> bool {
|
||||
result |= connection->pauseFlags.load();
|
||||
return false;
|
||||
});
|
||||
// Mask out kInPauseLoop — that's not a "pending pause request".
|
||||
return result & (BunInspectorConnection::kBootstrapPause | BunInspectorConnection::kMessageDeliveryPause);
|
||||
}
|
||||
|
||||
// Check if breakProgram() should be called after draining CDP messages.
|
||||
// Returns true if a pause was explicitly requested (bootstrap, Debugger.pause,
|
||||
// breakpoint). Returns false for plain message delivery.
|
||||
extern "C" bool Bun__shouldBreakAfterMessageDrain(JSC::VM& vm)
|
||||
{
|
||||
bool hasBootstrapPause = false;
|
||||
forEachConnectionForVM(vm, [&](BunInspectorConnection* connection) -> bool {
|
||||
uint8_t flags = connection->pauseFlags.load();
|
||||
// Bootstrap pause always needs breakProgram
|
||||
if (flags & BunInspectorConnection::kBootstrapPause) {
|
||||
hasBootstrapPause = true;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (hasBootstrapPause)
|
||||
return true;
|
||||
// Check if the debugger agent scheduled a pause (e.g., Debugger.pause command
|
||||
// was dispatched during the drain).
|
||||
auto* globalObject = vm.topCallFrame ? vm.topCallFrame->lexicalGlobalObject(vm) : nullptr;
|
||||
if (globalObject) {
|
||||
if (auto* debugger = globalObject->debugger()) {
|
||||
// schedulePauseAtNextOpportunity sets m_pauseAtNextOpportunity
|
||||
if (debugger->isPauseAtNextOpportunitySet())
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Drain queued CDP messages for a VM. Called from the NeedDebuggerBreak
|
||||
// VMTraps handler before breakProgram() so that commands like Debugger.pause
|
||||
// are processed first, setting the correct pause reason on the agent.
|
||||
extern "C" void Bun__drainQueuedCDPMessages(JSC::VM& vm)
|
||||
{
|
||||
forEachConnectionForVM(vm, [&](BunInspectorConnection* connection) -> bool {
|
||||
if (connection->status != ConnectionStatus::Connected)
|
||||
return false;
|
||||
auto* context = ScriptExecutionContext::getScriptExecutionContext(connection->scriptExecutionContextIdentifier);
|
||||
if (!context)
|
||||
return false;
|
||||
// Clear the message delivery flag — messages are being drained now.
|
||||
connection->pauseFlags.fetch_and(~BunInspectorConnection::kMessageDeliveryPause);
|
||||
connection->receiveMessagesOnInspectorThread(
|
||||
*context, static_cast<Zig::GlobalObject*>(connection->globalObject), false);
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
// Schedule a debugger pause for connected sessions.
|
||||
// Called during STW after doConnect has already attached the debugger.
|
||||
// schedulePauseAtNextOpportunity + notifyNeedDebuggerBreak set up a pause
|
||||
// that fires after STW resumes. The NeedDebuggerBreak handler in VMTraps
|
||||
// calls breakProgram() to enter the pause from any JIT tier.
|
||||
|
||||
void schedulePauseForConnectedSessions(JSC::VM& vm, bool isBootstrap)
|
||||
{
|
||||
forEachConnectionForVM(vm, [&](BunInspectorConnection* connection) -> bool {
|
||||
if (connection->status != ConnectionStatus::Connected)
|
||||
return false;
|
||||
|
||||
if (isBootstrap)
|
||||
connection->pauseFlags.fetch_or(BunInspectorConnection::kBootstrapPause);
|
||||
|
||||
auto* debugger = connection->globalObject->debugger();
|
||||
if (!debugger)
|
||||
return false;
|
||||
|
||||
// schedulePauseAtNextOpportunity() is NOT thread-safe in general (it calls
|
||||
// enableStepping → recompileAllJSFunctions), but is safe here because we're
|
||||
// inside a STW callback — all other VM threads are blocked.
|
||||
debugger->schedulePauseAtNextOpportunity();
|
||||
vm.notifyNeedDebuggerBreak();
|
||||
return true; // Only need once per VM
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// StopTheWorld callback for SIGUSR1 debugger activation.
|
||||
// This runs on the main thread at a safe point when VMManager::requestStopAll(JSDebugger) is called.
|
||||
//
|
||||
// This handles the case where JS is actively executing (including infinite loops).
|
||||
// For idle VMs, RuntimeInspector::checkAndActivateInspector handles it via event loop.
|
||||
|
||||
extern "C" bool Bun__tryActivateInspector();
|
||||
extern "C" void Bun__activateRuntimeInspectorMode();
|
||||
|
||||
JSC::StopTheWorldStatus Bun__stopTheWorldCallback(JSC::VM& vm, JSC::StopTheWorldEvent event)
|
||||
{
|
||||
using namespace JSC;
|
||||
|
||||
// We only act on VMStopped (all VMs have reached a safe point).
|
||||
// For other events (VMCreated, VMActivated), just continue the STW process.
|
||||
if (event != StopTheWorldEvent::VMStopped)
|
||||
return STW_CONTINUE();
|
||||
|
||||
// Phase 1: Activate inspector if requested (SIGUSR1 handler sets a flag)
|
||||
bool activated = Bun__tryActivateInspector();
|
||||
if (activated)
|
||||
Bun__activateRuntimeInspectorMode();
|
||||
|
||||
// Phase 2: Process pending connections for THIS VM.
|
||||
// doConnect must run on the connection's owning VM thread.
|
||||
bool connected = Bun::processPendingConnections(vm);
|
||||
|
||||
// If pending connections or pauses exist on a DIFFERENT VM, switch to it.
|
||||
if (!connected) {
|
||||
if (auto* targetVM = Bun::findVMWithPendingWork(vm))
|
||||
return STW_CONTEXT_SWITCH(targetVM);
|
||||
}
|
||||
|
||||
// Phase 3: Handle pending pause/message flags.
|
||||
// Phase 3: Handle pending pause/message flags.
|
||||
uint8_t pendingFlags = Bun::getPendingPauseFlags();
|
||||
bool isBootstrap = connected || (pendingFlags & Bun::BunInspectorConnection::kBootstrapPause);
|
||||
if (isBootstrap || (pendingFlags & Bun::BunInspectorConnection::kMessageDeliveryPause)) {
|
||||
Bun::schedulePauseForConnectedSessions(vm, isBootstrap);
|
||||
}
|
||||
|
||||
return STW_RESUME_ALL();
|
||||
}
|
||||
|
||||
// Zig bindings for VMManager
|
||||
extern "C" void VMManager__requestStopAll(uint32_t reason)
|
||||
{
|
||||
JSC::VMManager::requestStopAll(static_cast<JSC::VMManager::StopReason>(reason));
|
||||
}
|
||||
|
||||
extern "C" void VMManager__requestResumeAll(uint32_t reason)
|
||||
{
|
||||
JSC::VMManager::requestResumeAll(static_cast<JSC::VMManager::StopReason>(reason));
|
||||
}
|
||||
|
||||
extern "C" void VM__cancelStop(JSC::VM* vm)
|
||||
{
|
||||
vm->cancelStop();
|
||||
}
|
||||
|
||||
// Called from Zig and from the STW callback when the inspector activates.
|
||||
// Sets runtimeInspectorActivated so that connect() and
|
||||
// interruptForMessageDelivery() use STW-based message delivery.
|
||||
extern "C" void Bun__activateRuntimeInspectorMode()
|
||||
{
|
||||
Bun::runtimeInspectorActivated.store(true);
|
||||
}
|
||||
|
||||
@@ -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,76 @@ 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) {
|
||||
DWORD err = GetLastError();
|
||||
if (err == ERROR_FILE_NOT_FOUND) {
|
||||
// Match Node.js error message for compatibility
|
||||
throwVMError(globalObject, scope, "The system cannot find the file specified."_s);
|
||||
} else {
|
||||
throwVMError(globalObject, scope, makeString("OpenFileMappingW failed with error "_s, static_cast<unsigned>(err)));
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
void* pFunc = MapViewOfFile(hMapping, FILE_MAP_READ, 0, 0, sizeof(void*));
|
||||
if (!pFunc) {
|
||||
CloseHandle(hMapping);
|
||||
throwVMError(globalObject, scope, makeString("Failed to map debug handler for process "_s, pid));
|
||||
return {};
|
||||
}
|
||||
|
||||
LPTHREAD_START_ROUTINE threadProc = *reinterpret_cast<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 +4044,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
|
||||
|
||||
@@ -147,6 +147,14 @@ pub const VM = opaque {
|
||||
return JSC__VM__isEntered(vm);
|
||||
}
|
||||
|
||||
extern fn VM__cancelStop(vm: *VM) void;
|
||||
|
||||
/// Clears the NeedStopTheWorld trap bit and restores the stack limit.
|
||||
/// Thread safe. See jsc's "VMTraps.h" for explanation on traps.
|
||||
pub fn cancelStop(vm: *VM) void {
|
||||
VM__cancelStop(vm);
|
||||
}
|
||||
|
||||
pub fn isTerminationException(vm: *VM, exception: *bun.jsc.Exception) bool {
|
||||
return bun.cpp.JSC__VM__isTerminationException(vm, exception);
|
||||
}
|
||||
|
||||
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__stopTheWorldCallback(JSC::VM&, JSC::StopTheWorldEvent);
|
||||
|
||||
extern "C" void JSCInitialize(const char* envp[], size_t envc, void (*onCrash)(const char* ptr, size_t length), bool evalMode)
|
||||
{
|
||||
static std::once_flag jsc_init_flag;
|
||||
@@ -302,6 +307,15 @@ extern "C" void JSCInitialize(const char* envp[], size_t envc, void (*onCrash)(c
|
||||
JSC::Options::useJITCage() = false;
|
||||
JSC::Options::useShadowRealm() = true;
|
||||
JSC::Options::useV8DateParser() = true;
|
||||
// NOTE: We intentionally do NOT set usePollingTraps = true here.
|
||||
// Signal-based traps (InvalidationPoint in DFG/FTL) have zero steady-state
|
||||
// overhead vs polling (CheckTraps), which adds a load+branch at every loop
|
||||
// back-edge and inhibits DFG structure-watching optimizations.
|
||||
// The tradeoff: signal-based trap delivery for requestStopAll (used by the
|
||||
// runtime inspector via SIGUSR1) is ~94% reliable vs 100% with polling.
|
||||
// We accept this for the inspector path since speed is the priority.
|
||||
// IMPORTANT: JSC::Options are frozen (mprotected read-only) after init.
|
||||
// Writing to usePollingTraps later crashes on Linux with SEGV at offset 0xB34.
|
||||
JSC::Options::evalMode() = evalMode;
|
||||
JSC::Options::heapGrowthSteepnessFactor() = 1.0;
|
||||
JSC::Options::heapGrowthMaxIncrease() = 2.0;
|
||||
@@ -330,6 +344,10 @@ extern "C" void JSCInitialize(const char* envp[], size_t envc, void (*onCrash)(c
|
||||
}
|
||||
JSC::Options::assertOptionsAreCoherent();
|
||||
}); // end JSC::initialize lambda
|
||||
|
||||
// Register the StopTheWorld callback for SIGUSR1 debugger activation.
|
||||
// This allows us to interrupt infinite loops and activate the debugger.
|
||||
JSC::VMManager::setJSDebuggerCallback(Bun__stopTheWorldCallback);
|
||||
}); // end std::call_once lambda
|
||||
|
||||
// NOLINTEND
|
||||
|
||||
@@ -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);
|
||||
@@ -691,6 +695,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;
|
||||
|
||||
412
src/bun.js/event_loop/RuntimeInspector.zig
Normal file
412
src/bun.js/event_loop/RuntimeInspector.zig
Normal file
@@ -0,0 +1,412 @@
|
||||
/// 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).
|
||||
/// If the user pre-configured a port via --inspect-port=<port>, that port is used
|
||||
/// instead. Use --inspect-port=0 for automatic port selection.
|
||||
const default_inspector_port = "6499";
|
||||
|
||||
var installed: std.atomic.Value(bool) = std.atomic.Value(bool).init(false);
|
||||
var inspector_activation_requested: std.atomic.Value(bool) = std.atomic.Value(bool).init(false);
|
||||
|
||||
/// Called from the dedicated SignalInspector thread (POSIX) or remote thread (Windows).
|
||||
/// This runs in normal thread context, so it's safe to call JSC APIs.
|
||||
fn requestInspectorActivation() void {
|
||||
const already_requested = inspector_activation_requested.swap(true, .acq_rel);
|
||||
|
||||
// Two mechanisms work together to handle all cases:
|
||||
//
|
||||
// 1. StopTheWorld (for busy loops like `while(true){}`):
|
||||
// requestStopAll sets a trap that fires at the next JS safe point.
|
||||
// Our callback (Bun__stopTheWorldCallback) then activates the inspector.
|
||||
//
|
||||
// 2. Event loop wakeup (for idle VMs waiting on I/O):
|
||||
// The wakeup causes checkAndActivateInspector to run, which activates
|
||||
// the inspector and calls requestResumeAll to clear any pending trap.
|
||||
//
|
||||
// Both mechanisms check inspector_activation_requested and clear it atomically,
|
||||
// so only one will actually activate the inspector.
|
||||
|
||||
if (!already_requested) {
|
||||
// First request: start the StopTheWorld mechanism.
|
||||
// On re-entry (retry), skip this — STW is already pending with its
|
||||
// own SignalSender retry loop.
|
||||
jsc.VMManager.requestStopAll(.JSDebugger);
|
||||
}
|
||||
|
||||
// Always fire event loop wakeup, even on retries. This is cheap and
|
||||
// handles cases where the first wakeup arrived before the event loop
|
||||
// was in its blocking wait.
|
||||
if (VirtualMachine.getMainThreadVM()) |vm| {
|
||||
vm.eventLoop().wakeup();
|
||||
}
|
||||
}
|
||||
|
||||
/// Called from main thread during event loop tick.
|
||||
/// This handles the case where the VM is idle (waiting on I/O).
|
||||
/// For active JS execution (including infinite loops), the StopTheWorld callback handles it.
|
||||
pub fn checkAndActivateInspector() void {
|
||||
if (!inspector_activation_requested.swap(false, .acq_rel)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Cancel the pending STW and clear residual trap state. requestStopAll()
|
||||
// poisons the stack limit and sets NeedStopTheWorld trap bits. If the event
|
||||
// loop path wins the race (activates the inspector before handleTraps fires),
|
||||
// these must be cleared. Otherwise when JS next enters a function, the
|
||||
// poisoned stack limit triggers handleTraps → notifyVMStop on a VMManager
|
||||
// that already had its request bits cleared by requestResumeAll, causing
|
||||
// inconsistent state (crashes on some Linux aarch64 kernels).
|
||||
defer {
|
||||
if (VirtualMachine.getMainThreadVM()) |vm| {
|
||||
vm.jsc_vm.cancelStop();
|
||||
}
|
||||
jsc.VMManager.requestResumeAll(.JSDebugger);
|
||||
}
|
||||
|
||||
if (tryActivateInspector()) {
|
||||
// Set the C++ runtimeInspectorActivated flag so that connect() and
|
||||
// interruptForMessageDelivery() use STW-based message delivery,
|
||||
// same as when activated via the StopTheWorld callback path.
|
||||
activateRuntimeInspectorMode();
|
||||
}
|
||||
}
|
||||
|
||||
extern fn Bun__activateRuntimeInspectorMode() void;
|
||||
|
||||
fn activateRuntimeInspectorMode() void {
|
||||
Bun__activateRuntimeInspectorMode();
|
||||
}
|
||||
|
||||
/// Tries to activate the inspector. Returns true if activated, false otherwise.
|
||||
/// Caller must have already consumed the activation request flag.
|
||||
fn tryActivateInspector() bool {
|
||||
const vm = VirtualMachine.get();
|
||||
|
||||
if (vm.is_shutting_down) {
|
||||
log("VM is shutting down, ignoring inspector activation request", .{});
|
||||
return false;
|
||||
}
|
||||
|
||||
if (vm.debugger != null) {
|
||||
log("Debugger already active, ignoring activation request", .{});
|
||||
return false;
|
||||
}
|
||||
|
||||
activateInspector(vm) catch |err| {
|
||||
Output.prettyErrorln("Failed to activate inspector: {s}\n", .{@errorName(err)});
|
||||
Output.flush();
|
||||
return false;
|
||||
};
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
fn activateInspector(vm: *VirtualMachine) !void {
|
||||
log("Activating inspector", .{});
|
||||
|
||||
vm.debugger = .{
|
||||
.path_or_port = vm.inspect_port orelse default_inspector_port,
|
||||
.from_environment_variable = "",
|
||||
.wait_for_connection = .off,
|
||||
.set_breakpoint_on_first_line = false,
|
||||
.mode = .listen,
|
||||
};
|
||||
|
||||
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__tryActivateInspector() bool {
|
||||
if (!inspector_activation_requested.swap(false, .acq_rel)) {
|
||||
return false;
|
||||
}
|
||||
return tryActivateInspector();
|
||||
}
|
||||
|
||||
comptime {
|
||||
if (Environment.isPosix) {
|
||||
_ = Bun__Sigusr1Handler__uninstall;
|
||||
}
|
||||
_ = Bun__tryActivateInspector;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
@@ -403,6 +403,10 @@ pub const Command = struct {
|
||||
name: []const u8 = "",
|
||||
dir: []const u8 = "",
|
||||
} = .{},
|
||||
/// Disable SIGUSR1 handler for runtime debugger activation
|
||||
disable_sigusr1: bool = false,
|
||||
/// Pre-configure inspector port for runtime activation (SIGUSR1/process._debugProcess)
|
||||
inspect_port: ?[]const u8 = null,
|
||||
};
|
||||
|
||||
var global_cli_ctx: Context = undefined;
|
||||
|
||||
@@ -87,6 +87,8 @@ 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("--inspect-port <STR> Set inspector port for runtime debugger activation (0 for random)") catch unreachable,
|
||||
clap.parseParam("--disable-sigusr1 Disable SIGUSR1 handler for runtime debugger activation") catch unreachable,
|
||||
clap.parseParam("--cpu-prof Start CPU profiler and write profile to disk on exit") catch unreachable,
|
||||
clap.parseParam("--cpu-prof-name <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,
|
||||
@@ -809,6 +811,8 @@ pub fn parse(allocator: std.mem.Allocator, ctx: Command.Context, comptime cmd: C
|
||||
ctx.runtime_options.smol = args.flag("--smol");
|
||||
ctx.runtime_options.preconnect = args.options("--fetch-preconnect");
|
||||
ctx.runtime_options.expose_gc = args.flag("--expose-gc");
|
||||
ctx.runtime_options.disable_sigusr1 = args.flag("--disable-sigusr1");
|
||||
ctx.runtime_options.inspect_port = args.option("--inspect-port");
|
||||
|
||||
if (args.option("--console-depth")) |depth_str| {
|
||||
const depth = std.fmt.parseInt(u16, depth_str, 10) catch {
|
||||
|
||||
@@ -1413,6 +1413,8 @@ pub const TestCommand = struct {
|
||||
.smol = ctx.runtime_options.smol,
|
||||
.debugger = ctx.runtime_options.debugger,
|
||||
.is_main_thread = true,
|
||||
.disable_sigusr1 = ctx.runtime_options.disable_sigusr1,
|
||||
.inspect_port = ctx.runtime_options.inspect_port,
|
||||
},
|
||||
);
|
||||
vm.argv = ctx.passthrough;
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
438
test/js/bun/runtime-inspector/runtime-inspector-posix.test.ts
Normal file
438
test/js/bun/runtime-inspector/runtime-inspector-posix.test.ts
Normal file
@@ -0,0 +1,438 @@
|
||||
import { spawn } from "bun";
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { bunEnv, bunExe, isASAN, isWindows, tempDir } from "harness";
|
||||
import { join } from "path";
|
||||
|
||||
// Timeout for waiting on stream reader loops (30s matches runtime-inspector.test.ts)
|
||||
const STREAM_TIMEOUT_MS = 30_000;
|
||||
|
||||
// Helper: read from a stream until condition is met, with a timeout to prevent hanging
|
||||
async function readStreamUntil(
|
||||
reader: ReadableStreamDefaultReader<Uint8Array>,
|
||||
condition: (output: string) => boolean,
|
||||
timeoutMs = STREAM_TIMEOUT_MS,
|
||||
): Promise<string> {
|
||||
const decoder = new TextDecoder();
|
||||
let output = "";
|
||||
const startTime = Date.now();
|
||||
|
||||
while (!condition(output)) {
|
||||
if (Date.now() - startTime > timeoutMs) {
|
||||
throw new Error(`Timeout after ${timeoutMs}ms waiting for stream condition. Got: "${output}"`);
|
||||
}
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
output += decoder.decode(value, { stream: true });
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
// Helper: wait for the full inspector banner (header + footer = 2 occurrences of "Bun Inspector")
|
||||
function hasBanner(stderr: string): boolean {
|
||||
return (stderr.match(/Bun Inspector/g) || []).length >= 2;
|
||||
}
|
||||
|
||||
// POSIX-specific tests (SIGUSR1 mechanism) - macOS and Linux only
|
||||
describe.skipIf(isWindows)("Runtime inspector SIGUSR1 activation", () => {
|
||||
test.skipIf(isASAN)("activates inspector when no user listener", async () => {
|
||||
using dir = tempDir("sigusr1-activate-test", {
|
||||
"test.js": `
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
// Write PID so parent can send signal
|
||||
fs.writeFileSync(path.join(process.cwd(), "pid"), String(process.pid));
|
||||
console.log("READY");
|
||||
|
||||
// Keep process alive
|
||||
setInterval(() => {}, 1000);
|
||||
`,
|
||||
});
|
||||
|
||||
await using proc = spawn({
|
||||
cmd: [bunExe(), "--inspect-port=0", "test.js"],
|
||||
cwd: String(dir),
|
||||
env: bunEnv,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
|
||||
const reader = proc.stdout.getReader();
|
||||
await readStreamUntil(reader, s => s.includes("READY"));
|
||||
reader.releaseLock();
|
||||
|
||||
const pid = parseInt(await Bun.file(join(String(dir), "pid")).text(), 10);
|
||||
expect(pid).toBeGreaterThan(0);
|
||||
|
||||
// Send SIGUSR1
|
||||
process.kill(pid, "SIGUSR1");
|
||||
|
||||
// Wait for inspector to activate by reading stderr until the full banner appears
|
||||
const stderrReader = proc.stderr.getReader();
|
||||
const stderr = await readStreamUntil(stderrReader, hasBanner);
|
||||
stderrReader.releaseLock();
|
||||
|
||||
// Kill process
|
||||
proc.kill();
|
||||
await proc.exited;
|
||||
|
||||
expect(stderr).toContain("Bun Inspector");
|
||||
expect(stderr).toMatch(/ws:\/\/localhost:\d+\//);
|
||||
});
|
||||
|
||||
test("user SIGUSR1 listener takes precedence over inspector activation", async () => {
|
||||
using dir = tempDir("sigusr1-user-test", {
|
||||
"test.js": `
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
process.on("SIGUSR1", () => {
|
||||
console.log("USER_HANDLER_CALLED");
|
||||
// Exit cleanly after receiving the signal
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
fs.writeFileSync(path.join(process.cwd(), "pid"), String(process.pid));
|
||||
console.log("READY");
|
||||
|
||||
setInterval(() => {}, 1000);
|
||||
`,
|
||||
});
|
||||
|
||||
await using proc = spawn({
|
||||
cmd: [bunExe(), "--inspect-port=0", "test.js"],
|
||||
cwd: String(dir),
|
||||
env: bunEnv,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
|
||||
const reader = proc.stdout.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let output = await readStreamUntil(reader, s => s.includes("READY"));
|
||||
|
||||
const pid = parseInt(await Bun.file(join(String(dir), "pid")).text(), 10);
|
||||
|
||||
process.kill(pid, "SIGUSR1");
|
||||
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
output += decoder.decode(value, { stream: true });
|
||||
}
|
||||
output += decoder.decode();
|
||||
|
||||
const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]);
|
||||
|
||||
expect(output).toContain("USER_HANDLER_CALLED");
|
||||
expect(stderr).not.toContain("Bun Inspector");
|
||||
expect(exitCode).toBe(0);
|
||||
});
|
||||
|
||||
test("multiple SIGUSR1s work after user installs handler", async () => {
|
||||
// After user installs their own SIGUSR1 handler, multiple signals should all
|
||||
// be delivered to the user handler correctly.
|
||||
using dir = tempDir("sigusr1-uninstall-test", {
|
||||
"test.js": `
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
let count = 0;
|
||||
process.on("SIGUSR1", () => {
|
||||
count++;
|
||||
console.log("SIGNAL_" + count);
|
||||
if (count >= 3) {
|
||||
// Exit cleanly after receiving all signals
|
||||
process.exit(0);
|
||||
}
|
||||
});
|
||||
|
||||
fs.writeFileSync(path.join(process.cwd(), "pid"), String(process.pid));
|
||||
console.log("READY");
|
||||
|
||||
setInterval(() => {}, 1000);
|
||||
`,
|
||||
});
|
||||
|
||||
await using proc = spawn({
|
||||
cmd: [bunExe(), "--inspect-port=0", "test.js"],
|
||||
cwd: String(dir),
|
||||
env: bunEnv,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
|
||||
const reader = proc.stdout.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let output = await readStreamUntil(reader, s => s.includes("READY"));
|
||||
|
||||
const pid = parseInt(await Bun.file(join(String(dir), "pid")).text(), 10);
|
||||
|
||||
// Send SIGUSR1s and wait for each handler to respond before sending the next
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
process.kill(pid, "SIGUSR1");
|
||||
// Wait for handler output before sending next signal
|
||||
while (!output.includes(`SIGNAL_${i}`)) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
output += decoder.decode(value, { stream: true });
|
||||
}
|
||||
}
|
||||
|
||||
// Read remaining output until process exits
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
output += decoder.decode(value, { stream: true });
|
||||
}
|
||||
output += decoder.decode();
|
||||
|
||||
const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]);
|
||||
|
||||
expect(output).toBe(`READY
|
||||
SIGNAL_1
|
||||
SIGNAL_2
|
||||
SIGNAL_3
|
||||
`);
|
||||
expect(stderr).not.toContain("Bun Inspector");
|
||||
expect(exitCode).toBe(0);
|
||||
});
|
||||
|
||||
test.skipIf(isASAN)("inspector does not activate twice via SIGUSR1", async () => {
|
||||
using dir = tempDir("sigusr1-twice-test", {
|
||||
"test.js": `
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
fs.writeFileSync(path.join(process.cwd(), "pid"), String(process.pid));
|
||||
console.log("READY");
|
||||
|
||||
// Keep process alive until test kills it
|
||||
setInterval(() => {}, 1000);
|
||||
`,
|
||||
});
|
||||
|
||||
await using proc = spawn({
|
||||
cmd: [bunExe(), "--inspect-port=0", "test.js"],
|
||||
cwd: String(dir),
|
||||
env: bunEnv,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
|
||||
const reader = proc.stdout.getReader();
|
||||
await readStreamUntil(reader, s => s.includes("READY"));
|
||||
reader.releaseLock();
|
||||
|
||||
const pid = parseInt(await Bun.file(join(String(dir), "pid")).text(), 10);
|
||||
|
||||
// Send first SIGUSR1 and wait for inspector to activate
|
||||
process.kill(pid, "SIGUSR1");
|
||||
|
||||
const stderrReader = proc.stderr.getReader();
|
||||
let stderr = await readStreamUntil(stderrReader, hasBanner);
|
||||
|
||||
// Send second SIGUSR1 - inspector should not activate again
|
||||
process.kill(pid, "SIGUSR1");
|
||||
|
||||
// Kill process — the signal was delivered synchronously, so if a second banner
|
||||
// were going to appear it would already be queued. Killing and reading remaining
|
||||
// stderr is more reliable than sleeping.
|
||||
proc.kill();
|
||||
|
||||
// Read any remaining stderr until process exits
|
||||
const stderrDecoder = new TextDecoder();
|
||||
while (true) {
|
||||
const { value, done } = await stderrReader.read();
|
||||
if (done) break;
|
||||
stderr += stderrDecoder.decode(value, { stream: true });
|
||||
}
|
||||
stderr += stderrDecoder.decode();
|
||||
stderrReader.releaseLock();
|
||||
|
||||
await proc.exited;
|
||||
|
||||
// Should only see one "Bun Inspector" banner (two occurrences of the text, for header and footer)
|
||||
const matches = stderr.match(/Bun Inspector/g);
|
||||
expect(matches?.length ?? 0).toBe(2);
|
||||
});
|
||||
|
||||
test.skipIf(isASAN)("SIGUSR1 to self activates inspector", async () => {
|
||||
// Use a PID file approach instead of setTimeout to avoid timing-dependent self-signal
|
||||
using dir = tempDir("sigusr1-self-test", {
|
||||
"test.js": `
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
// Write PID so parent can send signal
|
||||
fs.writeFileSync(path.join(process.cwd(), "pid"), String(process.pid));
|
||||
console.log("READY");
|
||||
|
||||
// Keep process alive until test kills it
|
||||
setInterval(() => {}, 1000);
|
||||
`,
|
||||
});
|
||||
|
||||
await using proc = spawn({
|
||||
cmd: [bunExe(), "--inspect-port=0", "test.js"],
|
||||
cwd: String(dir),
|
||||
env: bunEnv,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
|
||||
const stdoutReader = proc.stdout.getReader();
|
||||
await readStreamUntil(stdoutReader, s => s.includes("READY"));
|
||||
stdoutReader.releaseLock();
|
||||
|
||||
const pid = parseInt(await Bun.file(join(String(dir), "pid")).text(), 10);
|
||||
|
||||
// Send SIGUSR1 from parent (equivalent to self-signal but without setTimeout race)
|
||||
process.kill(pid, "SIGUSR1");
|
||||
|
||||
// Wait for inspector banner
|
||||
const reader = proc.stderr.getReader();
|
||||
const stderr = await readStreamUntil(reader, hasBanner);
|
||||
reader.releaseLock();
|
||||
|
||||
proc.kill();
|
||||
await proc.exited;
|
||||
|
||||
expect(stderr).toContain("Bun Inspector");
|
||||
});
|
||||
|
||||
test("SIGUSR1 is ignored when started with --inspect", async () => {
|
||||
// When the process is started with --inspect, the debugger is already active.
|
||||
// The RuntimeInspector signal handler should NOT be installed, so SIGUSR1
|
||||
// should have no effect (default action is terminate, but signal may be ignored).
|
||||
using dir = tempDir("sigusr1-inspect-test", {
|
||||
"test.js": `
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
fs.writeFileSync(path.join(process.cwd(), "pid"), String(process.pid));
|
||||
console.log("READY");
|
||||
|
||||
// Keep process alive until parent kills it
|
||||
setInterval(() => {}, 1000);
|
||||
`,
|
||||
});
|
||||
|
||||
await using proc = spawn({
|
||||
cmd: [bunExe(), "--inspect", "test.js"],
|
||||
cwd: String(dir),
|
||||
env: bunEnv,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
|
||||
const reader = proc.stdout.getReader();
|
||||
await readStreamUntil(reader, s => s.includes("READY"));
|
||||
reader.releaseLock();
|
||||
|
||||
const pid = parseInt(await Bun.file(join(String(dir), "pid")).text(), 10);
|
||||
|
||||
// Wait for the --inspect banner to appear before sending SIGUSR1
|
||||
const stderrReader = proc.stderr.getReader();
|
||||
let stderr = await readStreamUntil(stderrReader, hasBanner);
|
||||
|
||||
// Send SIGUSR1 - should be ignored since RuntimeInspector is not installed
|
||||
process.kill(pid, "SIGUSR1");
|
||||
|
||||
// Kill and collect remaining stderr — parent drives termination
|
||||
proc.kill();
|
||||
const stderrDecoder = new TextDecoder();
|
||||
while (true) {
|
||||
const { value, done } = await stderrReader.read();
|
||||
if (done) break;
|
||||
stderr += stderrDecoder.decode(value, { stream: true });
|
||||
}
|
||||
stderrReader.releaseLock();
|
||||
await proc.exited;
|
||||
|
||||
// Should only see one "Bun Inspector" banner (from --inspect flag, not from SIGUSR1)
|
||||
// The banner has two occurrences of "Bun Inspector" (header and footer)
|
||||
const matches = stderr.match(/Bun Inspector/g);
|
||||
expect(matches?.length ?? 0).toBe(2);
|
||||
});
|
||||
|
||||
test("SIGUSR1 is ignored when started with --inspect-wait", async () => {
|
||||
// When the process is started with --inspect-wait, the debugger is already active.
|
||||
// Sending SIGUSR1 should NOT activate the inspector again.
|
||||
await using proc = spawn({
|
||||
cmd: [bunExe(), "--inspect-wait", "-e", "setInterval(() => {}, 1000)"],
|
||||
env: bunEnv,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
|
||||
const reader = proc.stderr.getReader();
|
||||
const stderr = await readStreamUntil(reader, hasBanner);
|
||||
|
||||
// Send SIGUSR1 - should be ignored since debugger is already active
|
||||
process.kill(proc.pid, "SIGUSR1");
|
||||
|
||||
// Kill process since --inspect-wait would wait for connection
|
||||
// Signal processing is synchronous, so no sleep needed
|
||||
proc.kill();
|
||||
|
||||
// Read any remaining stderr
|
||||
const decoder = new TextDecoder();
|
||||
let remaining = "";
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
remaining += decoder.decode(value, { stream: true });
|
||||
}
|
||||
remaining += decoder.decode();
|
||||
reader.releaseLock();
|
||||
|
||||
await proc.exited;
|
||||
|
||||
// Should only see one "Bun Inspector" banner (from --inspect-wait flag, not from SIGUSR1)
|
||||
// The banner has two occurrences of "Bun Inspector" (header and footer)
|
||||
const fullStderr = stderr + remaining;
|
||||
const matches = fullStderr.match(/Bun Inspector/g);
|
||||
expect(matches?.length ?? 0).toBe(2);
|
||||
});
|
||||
|
||||
test("SIGUSR1 is ignored when started with --inspect-brk", async () => {
|
||||
// When the process is started with --inspect-brk, the debugger is already active.
|
||||
// Sending SIGUSR1 should NOT activate the inspector again.
|
||||
await using proc = spawn({
|
||||
cmd: [bunExe(), "--inspect-brk", "-e", "setInterval(() => {}, 1000)"],
|
||||
env: bunEnv,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
|
||||
const reader = proc.stderr.getReader();
|
||||
const stderr = await readStreamUntil(reader, hasBanner);
|
||||
|
||||
// Send SIGUSR1 - should be ignored since debugger is already active
|
||||
process.kill(proc.pid, "SIGUSR1");
|
||||
|
||||
// Kill process since --inspect-brk would wait for connection
|
||||
// Signal processing is synchronous, so no sleep needed
|
||||
proc.kill();
|
||||
|
||||
// Read any remaining stderr
|
||||
const decoder = new TextDecoder();
|
||||
let remaining = "";
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
remaining += decoder.decode(value, { stream: true });
|
||||
}
|
||||
remaining += decoder.decode();
|
||||
reader.releaseLock();
|
||||
|
||||
await proc.exited;
|
||||
|
||||
// Should only see one "Bun Inspector" banner (from --inspect-brk flag, not from SIGUSR1)
|
||||
// The banner has two occurrences of "Bun Inspector" (header and footer)
|
||||
const fullStderr = stderr + remaining;
|
||||
const matches = fullStderr.match(/Bun Inspector/g);
|
||||
expect(matches?.length ?? 0).toBe(2);
|
||||
});
|
||||
});
|
||||
303
test/js/bun/runtime-inspector/runtime-inspector-windows.test.ts
Normal file
303
test/js/bun/runtime-inspector/runtime-inspector-windows.test.ts
Normal file
@@ -0,0 +1,303 @@
|
||||
import { spawn } from "bun";
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { bunEnv, bunExe, isWindows, tempDir } from "harness";
|
||||
import { join } from "path";
|
||||
|
||||
// Timeout for waiting on stream reader loops (30s matches runtime-inspector.test.ts)
|
||||
const STREAM_TIMEOUT_MS = 30_000;
|
||||
|
||||
// Helper: read from a stream until condition is met, with a timeout to prevent hanging
|
||||
async function readStreamUntil(
|
||||
reader: ReadableStreamDefaultReader<Uint8Array>,
|
||||
condition: (output: string) => boolean,
|
||||
timeoutMs = STREAM_TIMEOUT_MS,
|
||||
): Promise<string> {
|
||||
const decoder = new TextDecoder();
|
||||
let output = "";
|
||||
const startTime = Date.now();
|
||||
|
||||
while (!condition(output)) {
|
||||
if (Date.now() - startTime > timeoutMs) {
|
||||
throw new Error(`Timeout after ${timeoutMs}ms waiting for stream condition. Got: "${output}"`);
|
||||
}
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
output += decoder.decode(value, { stream: true });
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
// Helper: wait for the full inspector banner (header + footer = 2 occurrences of "Bun Inspector")
|
||||
function hasBanner(stderr: string): boolean {
|
||||
return (stderr.match(/Bun Inspector/g) || []).length >= 2;
|
||||
}
|
||||
|
||||
// Windows-specific tests (file mapping mechanism) - Windows only
|
||||
describe.skipIf(!isWindows)("Runtime inspector Windows file mapping", () => {
|
||||
test("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();
|
||||
await readStreamUntil(reader, s => s.includes("READY"));
|
||||
reader.releaseLock();
|
||||
|
||||
const pid = parseInt(await Bun.file(join(String(dir), "pid")).text(), 10);
|
||||
expect(pid).toBeGreaterThan(0);
|
||||
|
||||
// Use _debugProcess which uses file mapping on Windows
|
||||
await using debugProc = spawn({
|
||||
cmd: [bunExe(), "-e", `process._debugProcess(${pid})`],
|
||||
env: bunEnv,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
|
||||
const [debugStderr, debugExitCode] = await Promise.all([debugProc.stderr.text(), debugProc.exited]);
|
||||
|
||||
expect(debugStderr).toBe("");
|
||||
expect(debugExitCode).toBe(0);
|
||||
|
||||
// Wait for the debugger to start by reading stderr until the full banner appears
|
||||
const stderrReader = targetProc.stderr.getReader();
|
||||
const targetStderr = await readStreamUntil(stderrReader, hasBanner);
|
||||
stderrReader.releaseLock();
|
||||
|
||||
targetProc.kill();
|
||||
await targetProc.exited;
|
||||
|
||||
// Verify inspector actually started
|
||||
expect(targetStderr).toContain("Bun Inspector");
|
||||
expect(targetStderr).toMatch(/ws:\/\/localhost:\d+\//);
|
||||
});
|
||||
|
||||
test("_debugProcess works with current process's own pid", async () => {
|
||||
// On Windows, calling _debugProcess with our own PID should work.
|
||||
// Use PID file approach to avoid timing-dependent setTimeout.
|
||||
using dir = tempDir("windows-self-debug-test", {
|
||||
"target.js": `
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
fs.writeFileSync(path.join(process.cwd(), "pid"), String(process.pid));
|
||||
console.log("READY");
|
||||
|
||||
// Keep process alive until parent sends _debugProcess and then kills us
|
||||
setInterval(() => {}, 1000);
|
||||
`,
|
||||
});
|
||||
|
||||
await using proc = spawn({
|
||||
cmd: [bunExe(), "target.js"],
|
||||
cwd: String(dir),
|
||||
env: bunEnv,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
|
||||
const reader = proc.stdout.getReader();
|
||||
await readStreamUntil(reader, s => s.includes("READY"));
|
||||
reader.releaseLock();
|
||||
|
||||
const pid = parseInt(await Bun.file(join(String(dir), "pid")).text(), 10);
|
||||
|
||||
// Activate inspector via _debugProcess from a separate process
|
||||
await using debugProc = spawn({
|
||||
cmd: [bunExe(), "-e", `process._debugProcess(${pid})`],
|
||||
env: bunEnv,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
await debugProc.exited;
|
||||
|
||||
// Wait for inspector banner
|
||||
const stderrReader = proc.stderr.getReader();
|
||||
const stderr = await readStreamUntil(stderrReader, hasBanner);
|
||||
stderrReader.releaseLock();
|
||||
|
||||
proc.kill();
|
||||
await proc.exited;
|
||||
|
||||
expect(stderr).toContain("Bun Inspector");
|
||||
});
|
||||
|
||||
test("inspector does not activate twice via file mapping", async () => {
|
||||
using dir = tempDir("windows-twice-test", {
|
||||
"target.js": `
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
fs.writeFileSync(path.join(process.cwd(), "pid"), String(process.pid));
|
||||
console.log("READY");
|
||||
|
||||
// Keep process alive until parent kills it
|
||||
setInterval(() => {}, 1000);
|
||||
`,
|
||||
});
|
||||
|
||||
await using targetProc = spawn({
|
||||
cmd: [bunExe(), "target.js"],
|
||||
cwd: String(dir),
|
||||
env: bunEnv,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
|
||||
const reader = targetProc.stdout.getReader();
|
||||
await readStreamUntil(reader, s => s.includes("READY"));
|
||||
reader.releaseLock();
|
||||
|
||||
const pid = parseInt(await Bun.file(join(String(dir), "pid")).text(), 10);
|
||||
expect(pid).toBeGreaterThan(0);
|
||||
|
||||
// Set up stderr reader to wait for debugger to start
|
||||
const stderrReader = targetProc.stderr.getReader();
|
||||
|
||||
// Call _debugProcess twice
|
||||
await using debug1 = spawn({
|
||||
cmd: [bunExe(), "-e", `process._debugProcess(${pid})`],
|
||||
env: bunEnv,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
await debug1.exited;
|
||||
|
||||
// Wait for the full banner
|
||||
let stderr = await readStreamUntil(stderrReader, hasBanner);
|
||||
|
||||
await using debug2 = spawn({
|
||||
cmd: [bunExe(), "-e", `process._debugProcess(${pid})`],
|
||||
env: bunEnv,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
await debug2.exited;
|
||||
|
||||
// Kill and collect remaining stderr — parent drives termination
|
||||
targetProc.kill();
|
||||
stderrReader.releaseLock();
|
||||
const remainingStderr = await targetProc.stderr.text();
|
||||
stderr += remainingStderr;
|
||||
await targetProc.exited;
|
||||
|
||||
// Should only see one "Bun Inspector" banner (two occurrences of the text, for header and footer)
|
||||
const matches = stderr.match(/Bun Inspector/g);
|
||||
expect(matches?.length ?? 0).toBe(2);
|
||||
});
|
||||
|
||||
test("multiple Windows processes can have inspectors sequentially", async () => {
|
||||
// Test sequential activation: activate first, shut down, then activate second.
|
||||
// Each process uses a random port, so concurrent would also work, but
|
||||
// sequential tests the full lifecycle.
|
||||
using dir = tempDir("windows-multi-test", {
|
||||
"target.js": `
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const id = process.argv[2];
|
||||
|
||||
fs.writeFileSync(path.join(process.cwd(), "pid-" + id), String(process.pid));
|
||||
console.log("READY-" + id);
|
||||
|
||||
// Keep process alive until parent kills it
|
||||
setInterval(() => {}, 1000);
|
||||
`,
|
||||
});
|
||||
|
||||
// First process: activate inspector, verify, then shut down
|
||||
{
|
||||
await using target1 = spawn({
|
||||
cmd: [bunExe(), "target.js", "1"],
|
||||
cwd: String(dir),
|
||||
env: bunEnv,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
|
||||
const reader1 = target1.stdout.getReader();
|
||||
await readStreamUntil(reader1, s => s.includes("READY-1"));
|
||||
reader1.releaseLock();
|
||||
|
||||
const pid1 = parseInt(await Bun.file(join(String(dir), "pid-1")).text(), 10);
|
||||
expect(pid1).toBeGreaterThan(0);
|
||||
|
||||
await using debug1 = spawn({
|
||||
cmd: [bunExe(), "-e", `process._debugProcess(${pid1})`],
|
||||
env: bunEnv,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
|
||||
const [debug1Stderr, debug1ExitCode] = await Promise.all([debug1.stderr.text(), debug1.exited]);
|
||||
expect(debug1Stderr).toBe("");
|
||||
expect(debug1ExitCode).toBe(0);
|
||||
|
||||
// Wait for the full banner
|
||||
const stderrReader1 = target1.stderr.getReader();
|
||||
const stderr1 = await readStreamUntil(stderrReader1, hasBanner);
|
||||
stderrReader1.releaseLock();
|
||||
|
||||
expect(stderr1).toContain("Bun Inspector");
|
||||
|
||||
target1.kill();
|
||||
await target1.exited;
|
||||
}
|
||||
|
||||
// Second process
|
||||
{
|
||||
await using target2 = spawn({
|
||||
cmd: [bunExe(), "target.js", "2"],
|
||||
cwd: String(dir),
|
||||
env: bunEnv,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
|
||||
const reader2 = target2.stdout.getReader();
|
||||
await readStreamUntil(reader2, s => s.includes("READY-2"));
|
||||
reader2.releaseLock();
|
||||
|
||||
const pid2 = parseInt(await Bun.file(join(String(dir), "pid-2")).text(), 10);
|
||||
expect(pid2).toBeGreaterThan(0);
|
||||
|
||||
await using debug2 = spawn({
|
||||
cmd: [bunExe(), "-e", `process._debugProcess(${pid2})`],
|
||||
env: bunEnv,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
|
||||
const [debug2Stderr, debug2ExitCode] = await Promise.all([debug2.stderr.text(), debug2.exited]);
|
||||
expect(debug2Stderr).toBe("");
|
||||
expect(debug2ExitCode).toBe(0);
|
||||
|
||||
// Wait for the full banner
|
||||
const stderrReader2 = target2.stderr.getReader();
|
||||
const stderr2 = await readStreamUntil(stderrReader2, hasBanner);
|
||||
stderrReader2.releaseLock();
|
||||
|
||||
expect(stderr2).toContain("Bun Inspector");
|
||||
|
||||
target2.kill();
|
||||
await target2.exited;
|
||||
}
|
||||
});
|
||||
});
|
||||
509
test/js/bun/runtime-inspector/runtime-inspector.test.ts
Normal file
509
test/js/bun/runtime-inspector/runtime-inspector.test.ts
Normal file
@@ -0,0 +1,509 @@
|
||||
import { spawn } from "bun";
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { bunEnv, bunExe, isASAN, isWindows } from "harness";
|
||||
|
||||
/**
|
||||
* 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:<port>/...
|
||||
// Inspect in browser:
|
||||
// https://debug.bun.sh/#localhost:<port>/...
|
||||
// --------------------- 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(isASAN)("activates inspector in target process", async () => {
|
||||
// Start target process - prints PID to stdout then stays alive
|
||||
await using targetProc = spawn({
|
||||
cmd: [bunExe(), "--inspect-port=0", "-e", `console.log(process.pid); setInterval(() => {}, 1000);`],
|
||||
env: bunEnv,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
|
||||
// Read PID from stdout (confirms JS is executing)
|
||||
const reader = targetProc.stdout.getReader();
|
||||
const { value } = await reader.read();
|
||||
reader.releaseLock();
|
||||
const pid = parseInt(new TextDecoder().decode(value).trim(), 10);
|
||||
|
||||
// Use _debugProcess to activate inspector
|
||||
await using debugProc = spawn({
|
||||
cmd: [bunExe(), "-e", `process._debugProcess(${pid})`],
|
||||
env: bunEnv,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
|
||||
const debugStderr = await debugProc.stderr.text();
|
||||
expect(debugStderr).toBe("");
|
||||
expect(await debugProc.exited).toBe(0);
|
||||
|
||||
// Wait for inspector to activate by reading stderr until we see the message
|
||||
const { stderr: targetStderr } = await waitForDebuggerListening(targetProc.stderr);
|
||||
|
||||
// Kill target
|
||||
targetProc.kill();
|
||||
await targetProc.exited;
|
||||
|
||||
expect(targetStderr).toContain("Bun Inspector");
|
||||
expect(targetStderr).toMatch(/ws:\/\/localhost:\d+\//);
|
||||
});
|
||||
|
||||
test.todoIf(isWindows)("throws error for non-existent process", async () => {
|
||||
// Use a PID that definitely doesn't exist
|
||||
const fakePid = 999999999;
|
||||
|
||||
await using proc = spawn({
|
||||
cmd: [bunExe(), "-e", `process._debugProcess(${fakePid})`],
|
||||
env: bunEnv,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
|
||||
const stderr = await proc.stderr.text();
|
||||
expect(stderr).toContain("Failed");
|
||||
expect(await proc.exited).not.toBe(0);
|
||||
});
|
||||
|
||||
test.skipIf(isASAN)("inspector does not activate twice", async () => {
|
||||
// Start target process - prints PID to stdout then stays alive
|
||||
await using targetProc = spawn({
|
||||
cmd: [bunExe(), "--inspect-port=0", "-e", `console.log(process.pid); setInterval(() => {}, 1000);`],
|
||||
env: bunEnv,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
|
||||
// Read PID from stdout (confirms JS is executing)
|
||||
const reader = targetProc.stdout.getReader();
|
||||
const { value } = await reader.read();
|
||||
reader.releaseLock();
|
||||
const pid = parseInt(new TextDecoder().decode(value).trim(), 10);
|
||||
|
||||
// Start reading stderr before triggering debugger
|
||||
const stderrReader = targetProc.stderr.getReader();
|
||||
const stderrDecoder = new TextDecoder();
|
||||
let stderr = "";
|
||||
|
||||
// Call _debugProcess the first time
|
||||
await using debug1 = spawn({
|
||||
cmd: [bunExe(), "-e", `process._debugProcess(${pid})`],
|
||||
env: bunEnv,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
const debug1Stderr = await debug1.stderr.text();
|
||||
expect(debug1Stderr).toBe("");
|
||||
expect(await debug1.exited).toBe(0);
|
||||
|
||||
// Wait for the full debugger banner (header + content + footer) with timeout
|
||||
const bannerStartTime = Date.now();
|
||||
const bannerTimeout = 30000;
|
||||
while ((stderr.match(/Bun Inspector/g) || []).length < 2) {
|
||||
if (Date.now() - bannerStartTime > bannerTimeout) {
|
||||
throw new Error(`Timeout waiting for inspector banner. Got: "${stderr}"`);
|
||||
}
|
||||
const { value, done } = await stderrReader.read();
|
||||
if (done) break;
|
||||
stderr += stderrDecoder.decode(value, { stream: true });
|
||||
}
|
||||
|
||||
// Call _debugProcess again - inspector should not activate twice
|
||||
await using debug2 = spawn({
|
||||
cmd: [bunExe(), "-e", `process._debugProcess(${pid})`],
|
||||
env: bunEnv,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
const debug2Stderr = await debug2.stderr.text();
|
||||
expect(debug2Stderr).toBe("");
|
||||
expect(await debug2.exited).toBe(0);
|
||||
|
||||
// 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(isASAN)("can activate inspector in multiple processes sequentially", async () => {
|
||||
// Test sequential activation: activate first, shut down, then activate second.
|
||||
// Each process uses a random port, so concurrent would also work, but
|
||||
// sequential tests the full lifecycle.
|
||||
const targetScript = `console.log(process.pid); setInterval(() => {}, 1000);`;
|
||||
|
||||
// First process: activate inspector, verify, then shut down
|
||||
{
|
||||
await using target1 = spawn({
|
||||
cmd: [bunExe(), "--inspect-port=0", "-e", targetScript],
|
||||
env: bunEnv,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
|
||||
const reader1 = target1.stdout.getReader();
|
||||
const { value: v1 } = await reader1.read();
|
||||
reader1.releaseLock();
|
||||
const pid1 = parseInt(new TextDecoder().decode(v1).trim(), 10);
|
||||
expect(pid1).toBeGreaterThan(0);
|
||||
|
||||
await using debug1 = spawn({
|
||||
cmd: [bunExe(), "-e", `process._debugProcess(${pid1})`],
|
||||
env: bunEnv,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
|
||||
const debug1Stderr = await debug1.stderr.text();
|
||||
expect(debug1Stderr).toBe("");
|
||||
expect(await debug1.exited).toBe(0);
|
||||
|
||||
const result1 = await waitForDebuggerListening(target1.stderr);
|
||||
|
||||
expect(result1.stderr).toContain("Bun Inspector");
|
||||
|
||||
target1.kill();
|
||||
await target1.exited;
|
||||
}
|
||||
|
||||
// Second process
|
||||
{
|
||||
await using target2 = spawn({
|
||||
cmd: [bunExe(), "--inspect-port=0", "-e", targetScript],
|
||||
env: bunEnv,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
|
||||
const reader2 = target2.stdout.getReader();
|
||||
const { value: v2 } = await reader2.read();
|
||||
reader2.releaseLock();
|
||||
const pid2 = parseInt(new TextDecoder().decode(v2).trim(), 10);
|
||||
expect(pid2).toBeGreaterThan(0);
|
||||
|
||||
await using debug2 = spawn({
|
||||
cmd: [bunExe(), "-e", `process._debugProcess(${pid2})`],
|
||||
env: bunEnv,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
|
||||
const debug2Stderr = await debug2.stderr.text();
|
||||
expect(debug2Stderr).toBe("");
|
||||
expect(await debug2.exited).toBe(0);
|
||||
|
||||
const result2 = await waitForDebuggerListening(target2.stderr);
|
||||
|
||||
expect(result2.stderr).toContain("Bun Inspector");
|
||||
|
||||
target2.kill();
|
||||
await target2.exited;
|
||||
}
|
||||
});
|
||||
|
||||
test("throws when called with no arguments", async () => {
|
||||
await using proc = spawn({
|
||||
cmd: [bunExe(), "-e", `process._debugProcess()`],
|
||||
env: bunEnv,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
|
||||
const stderr = await proc.stderr.text();
|
||||
expect(stderr).toContain("requires a pid argument");
|
||||
expect(await proc.exited).not.toBe(0);
|
||||
});
|
||||
|
||||
test.skipIf(isASAN)("can interrupt an infinite loop", async () => {
|
||||
// Start target process with infinite loop
|
||||
await using targetProc = spawn({
|
||||
cmd: [bunExe(), "--inspect-port=0", "-e", `console.log(process.pid); while (true) {}`],
|
||||
env: bunEnv,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
|
||||
// Read PID from stdout (written before the infinite loop starts)
|
||||
const reader = targetProc.stdout.getReader();
|
||||
const { value } = await reader.read();
|
||||
reader.releaseLock();
|
||||
const pid = parseInt(new TextDecoder().decode(value).trim(), 10);
|
||||
expect(pid).toBeGreaterThan(0);
|
||||
|
||||
// Use _debugProcess to activate inspector - this should interrupt the infinite loop
|
||||
await using debugProc = spawn({
|
||||
cmd: [bunExe(), "-e", `process._debugProcess(${pid})`],
|
||||
env: bunEnv,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
|
||||
const debugStderr = await debugProc.stderr.text();
|
||||
expect(debugStderr).toBe("");
|
||||
expect(await debugProc.exited).toBe(0);
|
||||
|
||||
// Wait for inspector to activate - this proves we interrupted the infinite loop
|
||||
const { stderr: targetStderr } = await waitForDebuggerListening(targetProc.stderr);
|
||||
|
||||
// Kill target
|
||||
targetProc.kill();
|
||||
await targetProc.exited;
|
||||
|
||||
expect(targetStderr).toContain("Bun Inspector");
|
||||
expect(targetStderr).toMatch(/ws:\/\/localhost:\d+\//);
|
||||
});
|
||||
|
||||
test.skipIf(isASAN)("can pause execution during while(true) via CDP", async () => {
|
||||
// Start target process with infinite loop
|
||||
await using targetProc = spawn({
|
||||
cmd: [bunExe(), "--inspect-port=0", "-e", `console.log(process.pid); while (true) {}`],
|
||||
env: bunEnv,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
|
||||
// Read PID from stdout (written before the infinite loop starts)
|
||||
const reader = targetProc.stdout.getReader();
|
||||
const { value } = await reader.read();
|
||||
reader.releaseLock();
|
||||
const pid = parseInt(new TextDecoder().decode(value).trim(), 10);
|
||||
expect(pid).toBeGreaterThan(0);
|
||||
|
||||
// Activate inspector via _debugProcess
|
||||
await using debugProc = spawn({
|
||||
cmd: [bunExe(), "-e", `process._debugProcess(${pid})`],
|
||||
env: bunEnv,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
|
||||
const debugStderr = await debugProc.stderr.text();
|
||||
expect(debugStderr).toBe("");
|
||||
expect(await debugProc.exited).toBe(0);
|
||||
|
||||
// Wait for inspector to activate and extract WebSocket URL
|
||||
const { stderr: targetStderr } = await waitForDebuggerListening(targetProc.stderr);
|
||||
const wsMatch = targetStderr.match(/ws:\/\/[^\s]+/);
|
||||
expect(wsMatch).not.toBeNull();
|
||||
const wsUrl = wsMatch![0];
|
||||
|
||||
// Connect via WebSocket to the inspector
|
||||
const ws = new WebSocket(wsUrl);
|
||||
const { promise: openPromise, resolve: openResolve, reject: openReject } = Promise.withResolvers<void>();
|
||||
ws.onopen = () => openResolve();
|
||||
ws.onerror = e => openReject(e);
|
||||
await openPromise;
|
||||
|
||||
try {
|
||||
let msgId = 1;
|
||||
const pendingResponses = new Map<number, { resolve: (v: any) => void; reject: (e: any) => void }>();
|
||||
const { promise: pausedPromise, resolve: pausedResolve } = Promise.withResolvers<any>();
|
||||
|
||||
ws.onmessage = event => {
|
||||
const msg = JSON.parse(event.data as string);
|
||||
if (msg.id !== undefined) {
|
||||
const pending = pendingResponses.get(msg.id);
|
||||
if (pending) {
|
||||
pendingResponses.delete(msg.id);
|
||||
pending.resolve(msg);
|
||||
}
|
||||
}
|
||||
if (msg.method === "Debugger.paused") {
|
||||
pausedResolve(msg);
|
||||
}
|
||||
};
|
||||
|
||||
function sendCDP(method: string, params: Record<string, any> = {}): Promise<any> {
|
||||
const id = msgId++;
|
||||
const { promise, resolve, reject } = Promise.withResolvers<any>();
|
||||
pendingResponses.set(id, { resolve, reject });
|
||||
ws.send(JSON.stringify({ id, method, params }));
|
||||
return promise;
|
||||
}
|
||||
|
||||
// Enable Runtime and Debugger domains
|
||||
await sendCDP("Runtime.enable");
|
||||
await sendCDP("Debugger.enable");
|
||||
|
||||
// Request pause - this should interrupt the while(true) loop
|
||||
await sendCDP("Debugger.pause");
|
||||
|
||||
// Wait for Debugger.paused event (proves the JS thread was interrupted and paused)
|
||||
const pausedEvent = await pausedPromise;
|
||||
expect(pausedEvent.method).toBe("Debugger.paused");
|
||||
|
||||
// Resume execution
|
||||
await sendCDP("Debugger.resume");
|
||||
} finally {
|
||||
ws.close();
|
||||
targetProc.kill();
|
||||
await targetProc.exited;
|
||||
}
|
||||
});
|
||||
|
||||
test.skipIf(isASAN)("CDP messages work after client reconnects", async () => {
|
||||
// Start target process - prints PID to stdout then stays alive
|
||||
await using targetProc = spawn({
|
||||
cmd: [bunExe(), "--inspect-port=0", "-e", `console.log(process.pid); setInterval(() => {}, 1000);`],
|
||||
env: bunEnv,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
|
||||
// Read PID from stdout (confirms JS is executing)
|
||||
const reader = targetProc.stdout.getReader();
|
||||
const { value } = await reader.read();
|
||||
reader.releaseLock();
|
||||
const pid = parseInt(new TextDecoder().decode(value).trim(), 10);
|
||||
expect(pid).toBeGreaterThan(0);
|
||||
|
||||
// Activate inspector via _debugProcess
|
||||
await using debugProc = spawn({
|
||||
cmd: [bunExe(), "-e", `process._debugProcess(${pid})`],
|
||||
env: bunEnv,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
const [debugStderr, debugExitCode] = await Promise.all([debugProc.stderr.text(), debugProc.exited]);
|
||||
expect(debugStderr).toBe("");
|
||||
expect(debugExitCode).toBe(0);
|
||||
|
||||
// Wait for inspector banner and extract WS URL
|
||||
const { stderr: targetStderr } = await waitForDebuggerListening(targetProc.stderr);
|
||||
const wsMatch = targetStderr.match(/ws:\/\/[^\s]+/);
|
||||
expect(wsMatch).not.toBeNull();
|
||||
const wsUrl = wsMatch![0];
|
||||
|
||||
// Helper to create a CDP WebSocket client
|
||||
function createCDPClient(url: string) {
|
||||
const ws = new WebSocket(url);
|
||||
let msgId = 1;
|
||||
const pendingResponses = new Map<number, { resolve: (v: any) => void; reject: (e: any) => void }>();
|
||||
|
||||
ws.onmessage = event => {
|
||||
const msg = JSON.parse(event.data as string);
|
||||
if (msg.id !== undefined) {
|
||||
const pending = pendingResponses.get(msg.id);
|
||||
if (pending) {
|
||||
pendingResponses.delete(msg.id);
|
||||
pending.resolve(msg);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function sendCDP(method: string, params: Record<string, any> = {}): Promise<any> {
|
||||
const id = msgId++;
|
||||
const { promise, resolve, reject } = Promise.withResolvers<any>();
|
||||
pendingResponses.set(id, { resolve, reject });
|
||||
ws.send(JSON.stringify({ id, method, params }));
|
||||
return promise;
|
||||
}
|
||||
|
||||
async function waitForOpen(): Promise<void> {
|
||||
const { promise, resolve, reject } = Promise.withResolvers<void>();
|
||||
ws.onopen = () => resolve();
|
||||
ws.onerror = e => reject(e);
|
||||
return promise;
|
||||
}
|
||||
|
||||
return { ws, sendCDP, waitForOpen };
|
||||
}
|
||||
|
||||
// First connection: verify CDP works
|
||||
const client1 = createCDPClient(wsUrl);
|
||||
await client1.waitForOpen();
|
||||
|
||||
const result1 = await client1.sendCDP("Runtime.evaluate", { expression: "1 + 1" });
|
||||
expect(result1.result.result.value).toBe(2);
|
||||
|
||||
const { promise, resolve } = Promise.withResolvers<void>();
|
||||
client1.ws.onclose = () => resolve();
|
||||
client1.ws.close();
|
||||
await promise;
|
||||
|
||||
// Second connection: verify CDP still works after reconnect
|
||||
const client2 = createCDPClient(wsUrl);
|
||||
await client2.waitForOpen();
|
||||
|
||||
const result2 = await client2.sendCDP("Runtime.evaluate", { expression: "2 + 3" });
|
||||
expect(result2.result.result.value).toBe(5);
|
||||
|
||||
client2.ws.close();
|
||||
targetProc.kill();
|
||||
await targetProc.exited;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// POSIX-only: --disable-sigusr1 test
|
||||
// On POSIX, when --disable-sigusr1 is set, no SIGUSR1 handler is installed,
|
||||
// so SIGUSR1 uses the default action (terminate process with exit code 128+30=158)
|
||||
// This test is skipped on Windows since there's no SIGUSR1 signal there.
|
||||
|
||||
describe.skipIf(isWindows)("--disable-sigusr1", () => {
|
||||
test("prevents inspector activation and uses default signal behavior", async () => {
|
||||
// Start with --disable-sigusr1 - prints PID to stdout then stays alive
|
||||
await using targetProc = spawn({
|
||||
cmd: [bunExe(), "--disable-sigusr1", "-e", `console.log(process.pid); setInterval(() => {}, 1000);`],
|
||||
env: bunEnv,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
|
||||
// Read PID from stdout (confirms JS is executing)
|
||||
const reader = targetProc.stdout.getReader();
|
||||
const { value } = await reader.read();
|
||||
reader.releaseLock();
|
||||
const pid = parseInt(new TextDecoder().decode(value).trim(), 10);
|
||||
|
||||
// Send SIGUSR1 directly - without handler, this will terminate the process
|
||||
process.kill(pid, "SIGUSR1");
|
||||
|
||||
const stderr = await targetProc.stderr.text();
|
||||
// Should NOT see Bun Inspector banner
|
||||
expect(stderr).not.toContain("Bun Inspector");
|
||||
// Process should be terminated by SIGUSR1
|
||||
// Exit code = 128 + signal number (macOS: SIGUSR1=30 -> 158, Linux: SIGUSR1=10 -> 138)
|
||||
expect(await targetProc.exited).toBeOneOf([158, 138]);
|
||||
});
|
||||
});
|
||||
@@ -684,7 +684,6 @@ describe.concurrent(() => {
|
||||
|
||||
const undefinedStubs = [
|
||||
"_debugEnd",
|
||||
"_debugProcess",
|
||||
"_fatalException",
|
||||
"_linkedBinding",
|
||||
"_rawDebug",
|
||||
|
||||
Reference in New Issue
Block a user