Compare commits

...

53 Commits

Author SHA1 Message Date
Claude Bot
e3bde22ca1 fix(test): increase timeouts in runtime-inspector tests for ASAN builds
The runtime inspector tests were failing on Debian 13 (both x64-asan and
aarch64) due to timeout issues. The inspector activation can be slow on
ASAN builds, causing the target processes to exit before the inspector
banner is printed.

Changes:
- Increase test timeouts from 5s to 30s for the target process scripts
- Add explicit { timeout: 30_000 } option to the slow tests

This fixes the CI failures on debian-13-x64-asan and debian-13-aarch64.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 19:22:05 +00:00
Alistair Smith
e4cd00dda2 Merge branch 'main' into ali/sigusr1 2026-01-14 10:07:57 -08:00
Alistair Smith
83a78f3336 fix race where a second SIGUSR1 leaves stale trap 2026-01-13 18:20:00 -08:00
Alistair Smith
6bd0cf31c9 fix a case where we'd have a stale jsc trap for a second kill -USR1 pid 2026-01-13 18:09:40 -08:00
Alistair Smith
1f70115906 address review 2026-01-13 17:27:06 -08:00
Alistair Smith
098bcfa318 address coderabbit & DRYify some code that claude wrote twice 2026-01-13 17:16:39 -08:00
Alistair Smith
9195e68e27 Merge branch 'main' into ali/sigusr1 2026-01-13 17:15:04 -08:00
Alistair Smith
f2a6c7c233 Merge remote-tracking branch 'origin/jarred/webkit-upgrade-jan-10' into ali/siguser1
# Conflicts:
#	cmake/tools/SetupWebKit.cmake
2026-01-13 11:46:25 -08:00
Alistair Smith
75a7ee527e Merge branch 'main' into ali/siguser1 2026-01-13 11:44:03 -08:00
Sosuke Suzuki
3466088fcc Fix InternalPromise exposure in ReadableStream builtins
Use $promiseResolveWithThen to wrap promise chains in readableStreamIntoText
and readableStreamIntoArray to ensure they return regular Promise instead of
InternalPromise.

Builtin async functions return InternalPromise by design, but this caused
`stream.text() instanceof Promise` to return false. The fix uses the existing
$promiseResolveWithThen function which 'shields' InternalPromise by wrapping
it in a regular Promise.

Claude-Generated-By: Claude Code (cli/claude-opus-4-5=100%)
Claude-Steers: 19
Claude-Permission-Prompts: 1
Claude-Escapes: 0
2026-01-13 20:09:31 +09:00
Sosuke Suzuki
e7ef32e9ca Update WEBKIT_VERSION 2026-01-13 16:17:04 +09:00
Sosuke Suzuki
c83254abc0 Update WebKit to preview-pr-135-a6fa914b
This updates the WebKit version to use the preview build from PR #135
which fixes async context preservation across await for AsyncLocalStorage.

Fixes the failing test: test-diagnostics-channel-tracing-channel-promise-run-stores.js
2026-01-13 14:03:31 +09:00
Alistair Smith
e8f40c2329 Merge branch 'main' into ali/siguser1 2026-01-12 11:42:11 -08:00
Alistair Smith
ef5b11c1e4 use new WebKit version 2026-01-12 11:37:19 -08:00
Jarred Sumner
f758a5f838 Upgrade WebKit to d5bd162d9ab2
Updates WebKit from 1d0216219a3c to d5bd162d9ab2.

Key changes:
- Promise system refactored to use new callMicrotask variadic template
- Added performPromiseThenWithContext for Bun's context passing
- Fixed PromiseReactionJob to properly handle empty vs undefined context
- Added @then property to JSInternalPromisePrototype
- Restored BunPerformMicrotaskJob and BunInvokeJobWithArguments cases

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-10 22:41:20 -08:00
Jarred Sumner
21fb83eb37 wip 2026-01-10 20:51:57 -08:00
Alistair Smith
8990dfc2ef use VMManager StopTheWorld to interrupt infinite loops for SIGUSR1 inspector 2026-01-09 18:01:28 -08:00
autofix-ci[bot]
d9b396a29d [autofix.ci] apply automated fixes 2026-01-09 22:23:00 +00:00
Alistair Smith
3b67f3f77e Merge branch 'main' into ali/siguser1 2026-01-09 14:21:15 -08:00
Alistair Smith
0869bd738d do a best effort uninstall case 2026-01-09 14:20:53 -08:00
Alistair Smith
b848ac202d use a semaphore and properly inject jsc trap 2026-01-09 14:07:14 -08:00
Alistair Smith
aec9e812f1 Merge branch 'main' into ali/siguser1 2026-01-09 11:45:46 -08:00
Jarred Sumner
ebcfbd2531 Merge branch 'main' into ali/siguser1 2026-01-08 12:07:47 -08:00
Alistair Smith
c06ef30736 fix review comments 2026-01-07 11:15:07 +00:00
Alistair Smith
57efbd0be5 update test expectations, dont print tiwce 2026-01-07 10:59:28 +00:00
Alistair Smith
d66255705a Merge branch 'main' into ali/siguser1 2026-01-07 10:48:10 +00:00
Jarred Sumner
d28affd937 Skip failing test on Windows for now 2026-01-06 21:41:42 +00:00
Alistair Smith
f188b9352f fix: match Node.js error message for _debugProcess on Windows
Use the same error message as Node.js ('The system cannot find the file
specified.') when the debug handler file mapping doesn't exist, for
compatibility with existing Node.js tests.
2026-01-06 09:12:29 -08:00
Alistair Smith
856eda2f24 fix(test): update Windows _debugProcess test to match Bun's error message
Bun uses a file mapping mechanism for cross-process inspector activation,
which produces different error messages than Node.js's native implementation.
2026-01-06 09:12:28 -08:00
Alistair Smith
cc6704de2f Address PR review comments for runtime inspector
- Add PID validation in Windows tests to fail early on invalid PID file content
- Add diagnostic logging for Windows file mapping failures
- Remove log() call from signal handler context for async-signal-safety
- Remove unused previous_action variable from POSIX signal handler
2026-01-06 09:12:28 -08:00
Alistair Smith
0ae67c72bc refactor: extract configureSigusr1Handler helper function
Extract the duplicated SIGUSR1 configuration logic into a helper function
to improve maintainability. The same 12-line block was duplicated in
initWithModuleGraph, init, and initBake.
2026-01-06 09:12:28 -08:00
Alistair Smith
d607391e53 Add comment documenting alignment assumption for MapViewOfFile 2026-01-06 09:12:28 -08:00
Alistair Smith
93de1c3b2c test: improve exit code assertion clarity using toBeOneOf
Use toBeOneOf([158, 138]) instead of a boolean check for clearer
error messages on test failure. Also clarify the comment to show
the full derivation of each expected exit code.
2026-01-06 09:12:28 -08:00
Alistair Smith
1d2becb314 test: assert exit codes for debug helper processes in runtime-inspector tests
Add exit code assertions for debug helper processes in the "inspector does
not activate twice" and "can activate inspector in multiple independent
processes" tests for consistency with other tests in the file.
2026-01-06 09:12:28 -08:00
Alistair Smith
1a70d189e1 docs: document inspector port limitation in RuntimeInspector
Add documentation comment explaining that the hardcoded port 6499 may
fail if already in use, matching Node.js SIGUSR1 behavior. Users can
work around this by pre-configuring --inspect-port or using --inspect=0
for automatic port selection.
2026-01-06 09:12:28 -08:00
Alistair Smith
19fa3d303e fix(test): replace timing-based waits with condition-based waits in runtime-inspector tests
Replace Bun.sleep() calls with condition-based waits that read from stderr
until "Debugger listening" appears. This eliminates potential flakiness from
timing-dependent tests.

Changes:
- Add waitForDebuggerListening() helper to read stderr until message appears
- Update "activates inspector in target process" test
- Update "inspector does not activate twice" test
- Update "can activate inspector in multiple independent processes" test
2026-01-06 09:12:28 -08:00
Alistair Smith
60b7424a34 fix(test): replace timing-based waits with condition-based waits in Windows inspector tests
Replace Bun.sleep() calls with proper condition-based waits that read
from stderr until "Debugger listening" appears. This eliminates potential
test flakiness caused by timing dependencies.
2026-01-06 09:12:27 -08:00
Alistair Smith
a028ee95df fix(test): replace timing-based waits with condition-based waits in SIGUSR1 tests
Replace Bun.sleep() calls with condition-based waiting to avoid flaky tests.
Instead of waiting for an arbitrary amount of time, now we read from stderr
until the expected "Debugger listening" message appears.

Changes:
- First test: read stderr until "Debugger listening" appears before killing
- Third test: wait for "Debugger listening" before sending second SIGUSR1
- Fifth/sixth tests: remove unnecessary sleeps since signal processing is
  synchronous and we can kill immediately after sending SIGUSR1
2026-01-06 09:12:27 -08:00
Claude Bot
c25572ebfe Add tests for SIGUSR1 handling with --inspect-* flags
- Add tests verifying SIGUSR1 is ignored when process starts with
  --inspect, --inspect-wait, or --inspect-brk flags
- When debugger is already enabled via CLI, RuntimeInspector's signal
  handler is not installed, and SIGUSR1 is set to SIG_IGN
- Fix RuntimeInspector to use sigaction handler instead of sigwait thread
  (simpler and works regardless of signal blocking state)
- Fix test timing issues by increasing timeouts and using direct kill
  for POSIX-only tests
- Add ignoreSigusr1() and setDefaultSigusr1Action() functions for
  proper signal disposition control

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-06 09:12:27 -08:00
Alistair Smith
1366c692e8 cleaner 2026-01-06 09:12:27 -08:00
Alistair Smith
87524734b1 rm semaphore 2026-01-06 09:12:27 -08:00
Alistair Smith
1130675215 fix ban-words: use bun.strings.toWPath and proper struct defaults 2026-01-06 09:12:27 -08:00
Alistair Smith
6aeadbf7d1 fix --disable-sigusr1 test for CI
- Skip test on Windows (no SIGUSR1 signal)
- Accept both macOS (158) and Linux (138) exit codes
2026-01-06 09:12:27 -08:00
Alistair Smith
d1924b8b14 add --disable-sigusr1 2026-01-06 09:12:26 -08:00
Alistair Smith
997c7764c5 split up runtime inspector tests 2026-01-06 09:12:26 -08:00
Alistair Smith
a859227a66 implement process._debugProcess 2026-01-06 09:12:26 -08:00
Alistair Smith
e474a1d148 get it to compile 2026-01-06 09:12:26 -08:00
Alistair Smith
0a40bb54f4 consolidate windows/posix inspector logic with a new struct "RuntimeInspector" 2026-01-06 09:12:26 -08:00
Alistair Smith
5e504db796 a cheap shot at windows 2026-01-06 09:12:26 -08:00
Alistair Smith
3e15ddc5e2 be clear about idempotency 2026-01-06 09:12:25 -08:00
Alistair Smith
f0f5d171fc signal safe handler, some other review notes 2026-01-06 09:12:25 -08:00
autofix-ci[bot]
91bfb9f7a8 [autofix.ci] apply automated fixes 2026-01-06 09:12:25 -08:00
Alistair Smith
05ea1a2044 Initial work on supporting SIGUSR1 2026-01-06 09:12:25 -08:00
33 changed files with 1824 additions and 40 deletions

View File

@@ -6,8 +6,7 @@ To do that:
- git fetch upstream
- git merge upstream main
- Fix the merge conflicts
- cd ../../ (back to bun)
- make jsc-build (this will take about 7 minutes)
- bun build.ts debug
- While it compiles, in another task review the JSC commits between the last version of Webkit and the new version. Write up a summary of the webkit changes in a file called "webkit-changes.md"
- bun run build:local (build a build of Bun with the new Webkit, make sure it compiles)
- After making sure it compiles, run some code to make sure things work. something like ./build/debug-local/bun-debug --print '42' should be all you need
@@ -21,3 +20,7 @@ To do that:
- commit + push (without adding the webkit-changes.md file)
- create PR titled "Upgrade Webkit to the <commit-sha>", paste your webkit-changes.md into the PR description
- delete the webkit-changes.md file
Things to check for a successful upgrade:
- Did JSType in vendor/WebKit/Source/JavaScriptCore have any recent changes? Does the enum values align with whats present in src/bun.js/bindings/JSType.zig?
- Were there any changes to the webcore code generator? If there are C++ compilation errors, check for differences in some of the generated code in like vendor/WebKit/source/WebCore/bindings/scripts/test/JS/

View File

@@ -2,7 +2,7 @@ option(WEBKIT_VERSION "The version of WebKit to use")
option(WEBKIT_LOCAL "If a local version of WebKit should be used instead of downloading")
if(NOT WEBKIT_VERSION)
set(WEBKIT_VERSION 1d0216219a3c52cb85195f48f19ba7d5db747ff7)
set(WEBKIT_VERSION preview-pr-135-6ba5c161)
endif()
string(SUBSTRING ${WEBKIT_VERSION} 0 16 WEBKIT_VERSION_PREFIX)
@@ -33,8 +33,8 @@ if(WEBKIT_LOCAL)
${WEBKIT_PATH}/JavaScriptCore/PrivateHeaders
${WEBKIT_PATH}/bmalloc/Headers
${WEBKIT_PATH}/WTF/Headers
${WEBKIT_PATH}/JavaScriptCore/DerivedSources/inspector
${WEBKIT_PATH}/JavaScriptCore/PrivateHeaders/JavaScriptCore
${WEBKIT_PATH}/JavaScriptCore/DerivedSources/inspector
)
endif()

View File

@@ -189,6 +189,7 @@ pub const Run = struct {
.debugger = ctx.runtime_options.debugger,
.dns_result_order = DNSResolver.Order.fromStringOrDie(ctx.runtime_options.dns_result_order),
.is_main_thread = true,
.disable_sigusr1 = ctx.runtime_options.disable_sigusr1,
},
),
.arena = arena,

View File

@@ -1074,6 +1074,8 @@ pub fn initWithModuleGraph(
vm.configureDebugger(opts.debugger);
vm.body_value_hive_allocator = Body.Value.HiveAllocator.init(bun.typedAllocator(jsc.WebCore.Body.Value));
configureSigusr1Handler(vm, opts);
return vm;
}
@@ -1100,8 +1102,26 @@ pub const Options = struct {
/// Worker VMs are always destroyed on exit, regardless of this setting. Setting this to
/// true may expose bugs that would otherwise only occur using Workers.
destruct_main_thread_on_exit: bool = false,
/// Disable SIGUSR1 handler for runtime debugger activation (matches Node.js).
disable_sigusr1: bool = false,
};
/// Configure SIGUSR1 handling for runtime debugger activation (main thread only).
fn configureSigusr1Handler(vm: *const VirtualMachine, opts: Options) void {
if (!opts.is_main_thread) return;
if (opts.disable_sigusr1) {
// User requested --disable-sigusr1, set SIGUSR1 to default action (terminate)
jsc.EventLoop.RuntimeInspector.setDefaultSigusr1Action();
} else if (vm.debugger != null) {
// Debugger already enabled via CLI flags, ignore SIGUSR1
jsc.EventLoop.RuntimeInspector.ignoreSigusr1();
} else {
// Install RuntimeInspector signal handler for runtime activation
jsc.EventLoop.RuntimeInspector.installIfNotAlready();
}
}
pub var is_smol_mode = false;
pub fn init(opts: Options) !*VirtualMachine {
@@ -1201,6 +1221,8 @@ pub fn init(opts: Options) !*VirtualMachine {
vm.configureDebugger(opts.debugger);
vm.body_value_hive_allocator = Body.Value.HiveAllocator.init(bun.typedAllocator(jsc.WebCore.Body.Value));
configureSigusr1Handler(vm, opts);
return vm;
}
@@ -1451,6 +1473,8 @@ pub fn initBake(opts: Options) anyerror!*VirtualMachine {
vm.configureDebugger(opts.debugger);
vm.body_value_hive_allocator = Body.Value.HiveAllocator.init(bun.typedAllocator(jsc.WebCore.Body.Value));
configureSigusr1Handler(vm, opts);
return vm;
}

View File

@@ -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>
@@ -659,3 +661,36 @@ extern "C" void Debugger__willDispatchAsyncCall(JSGlobalObject* globalObject, As
agent->willDispatchAsyncCall(getCallType(callType), callbackId);
}
}
// StopTheWorld callback for SIGUSR1 debugger activation.
// This runs on the main thread at a safe point when VMManager::requestStopAll(JSDebugger) is called.
//
// This handles the case where JS is actively executing (including infinite loops).
// For idle VMs, RuntimeInspector::checkAndActivateInspector handles it via event loop.
extern "C" bool Bun__activateInspector();
JSC::StopTheWorldStatus Bun__jsDebuggerCallback(JSC::VM& vm, JSC::StopTheWorldEvent event)
{
using namespace JSC;
if (event != StopTheWorldEvent::VMStopped)
return STW_CONTINUE();
if (Bun__activateInspector()) {
vm.notifyNeedDebuggerBreak();
}
return STW_RESUME_ALL();
}
// Zig bindings for VMManager
extern "C" void VMManager__requestStopAll(uint32_t reason)
{
JSC::VMManager::requestStopAll(static_cast<JSC::VMManager::StopReason>(reason));
}
extern "C" void VMManager__requestResumeAll(uint32_t reason)
{
JSC::VMManager::requestResumeAll(static_cast<JSC::VMManager::StopReason>(reason));
}

View File

@@ -36,7 +36,7 @@ namespace WebCore {
class JSHeapData;
class DOMGCOutputConstraint : public JSC::MarkingConstraint {
WTF_DEPRECATED_MAKE_FAST_ALLOCATED(DOMEGCOutputConstraint);
WTF_DEPRECATED_MAKE_FAST_ALLOCATED(DOMGCOutputConstraint);
public:
DOMGCOutputConstraint(JSC::VM&, JSHeapData&);

View File

@@ -1346,6 +1346,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)
{
@@ -1504,6 +1507,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(),
@@ -3834,6 +3845,71 @@ JSC_DEFINE_HOST_FUNCTION(Process_functionReallyKill, (JSC::JSGlobalObject * glob
RELEASE_AND_RETURN(scope, JSValue::encode(jsNumber(result)));
}
JSC_DEFINE_HOST_FUNCTION(Process_functionDebugProcess, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame))
{
auto scope = DECLARE_THROW_SCOPE(JSC::getVM(globalObject));
if (callFrame->argumentCount() < 1) {
throwVMError(globalObject, scope, "process._debugProcess requires a pid argument"_s);
return {};
}
int pid = callFrame->argument(0).toInt32(globalObject);
RETURN_IF_EXCEPTION(scope, {});
// posix we can just send SIGUSR1, on windows we map a file to `bun-debug-handler-<pid>` and send to that
#if !OS(WINDOWS)
int result = kill(pid, SIGUSR1);
if (result < 0) {
throwVMError(globalObject, scope, makeString("Failed to send SIGUSR1 to process "_s, pid, ": process may not exist or permission denied"_s));
return {};
}
#else
wchar_t mappingName[64];
swprintf(mappingName, 64, L"bun-debug-handler-%d", pid);
HANDLE hMapping = OpenFileMappingW(FILE_MAP_READ, FALSE, mappingName);
if (!hMapping) {
// Match Node.js error message for compatibility
throwVMError(globalObject, scope, "The system cannot find the file specified."_s);
return {};
}
void* pFunc = MapViewOfFile(hMapping, FILE_MAP_READ, 0, 0, sizeof(void*));
if (!pFunc) {
CloseHandle(hMapping);
throwVMError(globalObject, scope, makeString("Failed to map debug handler for process "_s, pid));
return {};
}
LPTHREAD_START_ROUTINE threadProc = *reinterpret_cast<LPTHREAD_START_ROUTINE*>(pFunc);
UnmapViewOfFile(pFunc);
CloseHandle(hMapping);
HANDLE hProcess = OpenProcess(PROCESS_CREATE_THREAD | PROCESS_QUERY_INFORMATION | PROCESS_VM_OPERATION | PROCESS_VM_WRITE | PROCESS_VM_READ, FALSE, pid);
if (!hProcess) {
throwVMError(globalObject, scope, makeString("Failed to open process "_s, pid, ": access denied or process not found"_s));
return {};
}
HANDLE hThread = CreateRemoteThread(hProcess, NULL, 0, threadProc, NULL, 0, NULL);
if (!hThread) {
CloseHandle(hProcess);
throwVMError(globalObject, scope, makeString("Failed to create remote thread in process "_s, pid));
return {};
}
// Wait briefly for the thread to complete because closing the handles
// immediately could terminate the remote thread before it finishes
// triggering the inspector in the target process.
WaitForSingleObject(hThread, 1000);
CloseHandle(hThread);
CloseHandle(hProcess);
#endif
return JSValue::encode(jsUndefined());
}
JSC_DEFINE_HOST_FUNCTION(Process_functionKill, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame))
{
auto scope = DECLARE_THROW_SCOPE(JSC::getVM(globalObject));
@@ -3973,7 +4049,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

View 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);
}

View File

@@ -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"
@@ -266,6 +267,10 @@ extern "C" unsigned getJSCBytecodeCacheVersion()
extern "C" void Bun__REPRL__registerFuzzilliFunctions(Zig::GlobalObject*);
#endif
// StopTheWorld callback for SIGUSR1 debugger activation (defined in BunDebugger.cpp).
// Note: This is a C++ function - cannot use extern "C" because it returns std::pair.
JSC::StopTheWorldStatus Bun__jsDebuggerCallback(JSC::VM&, JSC::StopTheWorldEvent);
extern "C" void JSCInitialize(const char* envp[], size_t envc, void (*onCrash)(const char* ptr, size_t length), bool evalMode)
{
static std::once_flag jsc_init_flag;
@@ -287,6 +292,11 @@ extern "C" void JSCInitialize(const char* envp[], size_t envc, void (*onCrash)(c
#endif
JSC::initialize();
// Register the StopTheWorld callback for SIGUSR1 debugger activation.
// This allows us to interrupt infinite loops and activate the debugger.
JSC::VMManager::setJSDebuggerCallback(Bun__jsDebuggerCallback);
{
JSC::Options::AllowUnfinalizedAccessScope scope;
@@ -730,13 +740,18 @@ JSC::ScriptExecutionStatus Zig::GlobalObject::scriptExecutionStatus(JSC::JSGloba
void unsafeEvalNoop(JSGlobalObject*, const WTF::String&) {}
static void queueMicrotaskToEventLoop(JSGlobalObject& globalObject, QueuedTask&& task)
{
globalObject.vm().queueMicrotask(WTF::move(task));
}
const JSC::GlobalObjectMethodTable& GlobalObject::globalObjectMethodTable()
{
static const JSC::GlobalObjectMethodTable table = {
&supportsRichSourceInfo,
&shouldInterruptScript,
&javaScriptRuntimeFlags,
nullptr, // &queueMicrotaskToEventLoop, // queueTaskToEventLoop
&queueMicrotaskToEventLoop,
nullptr, // &shouldInterruptScriptBeforeTimeout,
&moduleLoaderImportModule, // moduleLoaderImportModule
&moduleLoaderResolve, // moduleLoaderResolve
@@ -765,8 +780,7 @@ const JSC::GlobalObjectMethodTable& EvalGlobalObject::globalObjectMethodTable()
&supportsRichSourceInfo,
&shouldInterruptScript,
&javaScriptRuntimeFlags,
// &queueMicrotaskToEventLoop, // queueTaskToEventLoop
nullptr,
&queueMicrotaskToEventLoop,
nullptr, // &shouldInterruptScriptBeforeTimeout,
&moduleLoaderImportModule, // moduleLoaderImportModule
&moduleLoaderResolve, // moduleLoaderResolve
@@ -1072,7 +1086,7 @@ JSC_DEFINE_HOST_FUNCTION(functionQueueMicrotask,
// BunPerformMicrotaskJob accepts a variable number of arguments (up to: performMicrotask, job, asyncContext, arg0, arg1).
// The runtime inspects argumentCount to determine which arguments are present, so callers may pass only the subset they need.
// Here we pass: function, callback, asyncContext.
JSC::QueuedTask task { nullptr, JSC::InternalMicrotask::BunPerformMicrotaskJob, globalObject, function, callback, asyncContext };
JSC::QueuedTask task { nullptr, JSC::InternalMicrotask::BunPerformMicrotaskJob, 0, globalObject, function, callback, asyncContext };
globalObject->vm().queueMicrotask(WTF::move(task));
return JSC::JSValue::encode(JSC::jsUndefined());
@@ -3103,7 +3117,7 @@ extern "C" void JSC__JSGlobalObject__queueMicrotaskCallback(Zig::GlobalObject* g
// Do not use JSCell* here because the GC will try to visit it.
// Use BunInvokeJobWithArguments to pass the two arguments (ptr and callback) to the trampoline function
JSC::QueuedTask task { nullptr, JSC::InternalMicrotask::BunInvokeJobWithArguments, globalObject, function, JSValue(std::bit_cast<double>(reinterpret_cast<uintptr_t>(ptr))), JSValue(std::bit_cast<double>(reinterpret_cast<uintptr_t>(callback))) };
JSC::QueuedTask task { nullptr, JSC::InternalMicrotask::BunInvokeJobWithArguments, 0, globalObject, function, JSValue(std::bit_cast<double>(reinterpret_cast<uintptr_t>(ptr))), JSValue(std::bit_cast<double>(reinterpret_cast<uintptr_t>(callback))) };
globalObject->vm().queueMicrotask(WTF::move(task));
}

View File

@@ -3540,7 +3540,7 @@ void JSC__JSPromise__rejectOnNextTickWithHandled(JSC::JSPromise* promise, JSC::J
value = jsUndefined();
}
JSC::QueuedTask task { nullptr, JSC::InternalMicrotask::BunPerformMicrotaskJob, globalObject, microtaskFunction, rejectPromiseFunction, globalObject->m_asyncContextData.get()->getInternalField(0), promise, value };
JSC::QueuedTask task { nullptr, JSC::InternalMicrotask::BunPerformMicrotaskJob, 0, globalObject, microtaskFunction, rejectPromiseFunction, globalObject->m_asyncContextData.get()->getInternalField(0), promise, value };
globalObject->vm().queueMicrotask(WTF::move(task));
RETURN_IF_EXCEPTION(scope, );
}
@@ -5428,7 +5428,7 @@ extern "C" void JSC__JSGlobalObject__queueMicrotaskJob(JSC::JSGlobalObject* arg0
#endif
JSC::QueuedTask task { nullptr, JSC::InternalMicrotask::BunPerformMicrotaskJob, globalObject, microTaskFunction, WTF::move(microtaskArgs[0]), WTF::move(microtaskArgs[1]), WTF::move(microtaskArgs[2]), WTF::move(microtaskArgs[3]) };
JSC::QueuedTask task { nullptr, JSC::InternalMicrotask::BunPerformMicrotaskJob, 0, globalObject, microTaskFunction, WTF::move(microtaskArgs[0]), WTF::move(microtaskArgs[1]), WTF::move(microtaskArgs[2]), WTF::move(microtaskArgs[3]) };
globalObject->vm().queueMicrotask(WTF::move(task));
}

View File

@@ -11,7 +11,7 @@ JSC_DECLARE_HOST_FUNCTION(jsVerifyOneShot);
static const unsigned int NoDsaSignature = static_cast<unsigned int>(-1);
struct SignJobCtx {
WTF_MAKE_TZONE_ALLOCATED(name);
WTF_MAKE_TZONE_ALLOCATED(SignJobCtx);
public:
enum class Mode {

View File

@@ -33,7 +33,7 @@ struct EventInit {
bool composed { false };
template<class Encoder> void encode(Encoder&) const;
template<class Decoder> WARN_UNUSED_RETURN static bool decode(Decoder&, EventInit&);
template<class Decoder> [[nodiscard]] static bool decode(Decoder&, EventInit&);
};
template<class Encoder>

View File

@@ -261,7 +261,7 @@ public:
}
template<class Encoder> void encode(Encoder &) const;
template<class Decoder> WARN_UNUSED_RETURN static bool decode(Decoder &, HTTPHeaderMap &);
template<class Decoder> [[nodiscard]] static bool decode(Decoder &, HTTPHeaderMap &);
void setUncommonHeader(const String &name, const String &value);
void setUncommonHeaderCloneName(const StringView name, const String &value);

View File

@@ -43,7 +43,7 @@ namespace WebCore {
using NavigationTimingFunction = unsigned long long (PerformanceTiming::*)() const;
static constexpr std::array<std::pair<ComparableASCIILiteral, NavigationTimingFunction>, 21> restrictedMarkMappings { {
static constexpr SortedArrayMap restrictedMarkFunctions { std::to_array<std::pair<ComparableASCIILiteral, NavigationTimingFunction>>({
{ "connectEnd"_s, &PerformanceTiming::connectEnd },
{ "connectStart"_s, &PerformanceTiming::connectStart },
{ "domComplete"_s, &PerformanceTiming::domComplete },
@@ -65,8 +65,7 @@ static constexpr std::array<std::pair<ComparableASCIILiteral, NavigationTimingFu
{ "secureConnectionStart"_s, &PerformanceTiming::secureConnectionStart },
{ "unloadEventEnd"_s, &PerformanceTiming::unloadEventEnd },
{ "unloadEventStart"_s, &PerformanceTiming::unloadEventStart },
} };
static constexpr SortedArrayMap restrictedMarkFunctions { restrictedMarkMappings };
}) };
bool PerformanceUserTiming::isRestrictedMarkName(const String& markName)
{

View File

@@ -87,12 +87,11 @@ template<> JSString* convertEnumerationToJS(JSGlobalObject& lexicalGlobalObject,
template<> std::optional<CryptoKey::Type> parseEnumeration<CryptoKey::Type>(JSGlobalObject& lexicalGlobalObject, JSValue value)
{
auto stringValue = value.toWTFString(&lexicalGlobalObject);
static constexpr std::array<std::pair<ComparableASCIILiteral, CryptoKey::Type>, 3> mappings { {
static constexpr SortedArrayMap enumerationMapping { std::to_array<std::pair<ComparableASCIILiteral, CryptoKey::Type>>({
{ "private"_s, CryptoKey::Type::Private },
{ "public"_s, CryptoKey::Type::Public },
{ "secret"_s, CryptoKey::Type::Secret },
} };
static constexpr SortedArrayMap enumerationMapping { mappings };
}) };
if (auto* enumerationValue = enumerationMapping.tryGet(stringValue); enumerationValue) [[likely]]
return *enumerationValue;
return std::nullopt;

View File

@@ -64,7 +64,7 @@ template<> JSString* convertEnumerationToJS(JSGlobalObject& lexicalGlobalObject,
template<> std::optional<CryptoKeyUsage> parseEnumeration<CryptoKeyUsage>(JSGlobalObject& lexicalGlobalObject, JSValue value)
{
auto stringValue = value.toWTFString(&lexicalGlobalObject);
static constexpr std::array<std::pair<ComparableASCIILiteral, CryptoKeyUsage>, 8> mappings { {
static constexpr SortedArrayMap enumerationMapping { std::to_array<std::pair<ComparableASCIILiteral, CryptoKeyUsage>>({
{ "decrypt"_s, CryptoKeyUsage::Decrypt },
{ "deriveBits"_s, CryptoKeyUsage::DeriveBits },
{ "deriveKey"_s, CryptoKeyUsage::DeriveKey },
@@ -73,8 +73,7 @@ template<> std::optional<CryptoKeyUsage> parseEnumeration<CryptoKeyUsage>(JSGlob
{ "unwrapKey"_s, CryptoKeyUsage::UnwrapKey },
{ "verify"_s, CryptoKeyUsage::Verify },
{ "wrapKey"_s, CryptoKeyUsage::WrapKey },
} };
static constexpr SortedArrayMap enumerationMapping { mappings };
}) };
if (auto* enumerationValue = enumerationMapping.tryGet(stringValue); enumerationValue) [[likely]]
return *enumerationValue;
return std::nullopt;

View File

@@ -96,13 +96,12 @@ template<> JSString* convertEnumerationToJS(JSGlobalObject& lexicalGlobalObject,
template<> std::optional<SubtleCrypto::KeyFormat> parseEnumeration<SubtleCrypto::KeyFormat>(JSGlobalObject& lexicalGlobalObject, JSValue value)
{
auto stringValue = value.toWTFString(&lexicalGlobalObject);
static constexpr std::array<std::pair<ComparableASCIILiteral, SubtleCrypto::KeyFormat>, 4> mappings { {
static constexpr SortedArrayMap enumerationMapping { std::to_array<std::pair<ComparableASCIILiteral, SubtleCrypto::KeyFormat>>({
{ "jwk"_s, SubtleCrypto::KeyFormat::Jwk },
{ "pkcs8"_s, SubtleCrypto::KeyFormat::Pkcs8 },
{ "raw"_s, SubtleCrypto::KeyFormat::Raw },
{ "spki"_s, SubtleCrypto::KeyFormat::Spki },
} };
static constexpr SortedArrayMap enumerationMapping { mappings };
}) };
if (auto* enumerationValue = enumerationMapping.tryGet(stringValue); enumerationValue) [[likely]]
return *enumerationValue;
return std::nullopt;

View File

@@ -290,6 +290,10 @@ pub fn runImminentGCTimer(this: *EventLoop) void {
pub fn tickConcurrentWithCount(this: *EventLoop) usize {
this.updateCounts();
if (this.virtual_machine.is_main_thread) {
RuntimeInspector.checkAndActivateInspector();
}
if (comptime Environment.isPosix) {
if (this.signal_handler) |signal_handler| {
signal_handler.drain(this);
@@ -687,6 +691,7 @@ pub const DeferredTaskQueue = @import("./event_loop/DeferredTaskQueue.zig");
pub const DeferredRepeatingTask = DeferredTaskQueue.DeferredRepeatingTask;
pub const PosixSignalHandle = @import("./event_loop/PosixSignalHandle.zig");
pub const PosixSignalTask = PosixSignalHandle.PosixSignalTask;
pub const RuntimeInspector = @import("./event_loop/RuntimeInspector.zig");
pub const MiniEventLoop = @import("./event_loop/MiniEventLoop.zig");
pub const MiniVM = MiniEventLoop.MiniVM;
pub const JsVM = MiniEventLoop.JsVM;

View File

@@ -0,0 +1,380 @@
/// Runtime Inspector Activation Handler
///
/// Activates the inspector/debugger at runtime via `process._debugProcess(pid)`.
///
/// On POSIX (macOS/Linux):
/// - A "SignalInspector" thread sleeps on a semaphore
/// - SIGUSR1 handler runs on the main thread but in signal context (only
/// async-signal-safe functions allowed), posts to the semaphore
/// - SignalInspector thread wakes in normal context, calls VMManager::requestStopAll
/// - JSC stops all VMs at safe points and calls our StopTheWorld callback
/// - Callback runs on main thread, activates inspector, then resumes all VMs
/// - Usage: `kill -USR1 <pid>` to start debugger
///
/// On Windows:
/// - Uses named file mapping mechanism (same as Node.js)
/// - Creates "bun-debug-handler-<pid>" shared memory with function pointer
/// - External tools use CreateRemoteThread() to call that function
/// - The remote thread is already in normal context, so can call JSC APIs directly
/// - Usage: `process._debugProcess(pid)` from another Bun/Node process
///
/// Why StopTheWorld? Unlike notifyNeedDebuggerBreak() which only works if a debugger
/// is already attached, StopTheWorld guarantees a callback runs on the main thread
/// at a safe point - even during `while(true) {}` loops. This allows us to CREATE
/// the debugger before pausing.
///
const RuntimeInspector = @This();
const log = Output.scoped(.RuntimeInspector, .hidden);
/// Default port for runtime-activated inspector (via SIGUSR1/process._debugProcess).
/// Note: If this port is already in use, activation will fail with an error message.
/// This matches Node.js behavior where SIGUSR1-activated inspectors also use a fixed
/// port (9229). Users can pre-configure a different port using --inspect-port=<port>
/// or --inspect=0 for automatic port selection when starting the process.
const inspector_port = "6499";
var installed: std.atomic.Value(bool) = std.atomic.Value(bool).init(false);
var inspector_activation_requested: std.atomic.Value(bool) = std.atomic.Value(bool).init(false);
/// Called from the dedicated SignalInspector thread (POSIX) or remote thread (Windows).
/// This runs in normal thread context, so it's safe to call JSC APIs.
fn requestInspectorActivation() void {
inspector_activation_requested.store(true, .release);
// Two mechanisms work together to handle all cases:
//
// 1. StopTheWorld (for busy loops like `while(true){}`):
// requestStopAll sets a trap that fires at the next JS safe point.
// Our callback (Bun__jsDebuggerCallback) then activates the inspector.
//
// 2. Event loop wakeup (for idle VMs waiting on I/O):
// The wakeup causes checkAndActivateInspector to run, which activates
// the inspector and calls requestResumeAll to clear any pending trap.
//
// Both mechanisms check inspector_activation_requested and clear it atomically,
// so only one will actually activate the inspector.
jsc.VMManager.requestStopAll(.JSDebugger);
if (VirtualMachine.getMainThreadVM()) |vm| {
vm.eventLoop().wakeup();
}
}
/// Called from main thread during event loop tick.
/// This handles the case where the VM is idle (waiting on I/O).
/// For active JS execution (including infinite loops), the StopTheWorld callback handles it.
pub fn checkAndActivateInspector() void {
if (!inspector_activation_requested.swap(false, .acq_rel)) {
return;
}
defer jsc.VMManager.requestResumeAll(.JSDebugger);
_ = tryActivateInspector();
}
/// Tries to activate the inspector. Returns true if activated, false otherwise.
/// Caller must have already consumed the activation request flag.
fn tryActivateInspector() bool {
const vm = VirtualMachine.get();
if (vm.is_shutting_down) {
log("VM is shutting down, ignoring inspector activation request", .{});
return false;
}
if (vm.debugger != null) {
log("Debugger already active, ignoring activation request", .{});
return false;
}
activateInspector(vm) catch |err| {
Output.prettyErrorln("Failed to activate inspector: {s}\n", .{@errorName(err)});
Output.flush();
return false;
};
return true;
}
fn activateInspector(vm: *VirtualMachine) !void {
log("Activating inspector", .{});
vm.debugger = .{
.path_or_port = inspector_port,
.from_environment_variable = "",
.wait_for_connection = .off,
.set_breakpoint_on_first_line = false,
.mode = .listen,
};
vm.transpiler.options.minify_identifiers = false;
vm.transpiler.options.minify_syntax = false;
vm.transpiler.options.minify_whitespace = false;
vm.transpiler.options.debugger = true;
try Debugger.create(vm, vm.global);
}
pub fn isInstalled() bool {
return installed.load(.acquire);
}
const posix = if (Environment.isPosix) struct {
var semaphore: ?Semaphore = null;
var thread: ?std.Thread = null;
var shutting_down: std.atomic.Value(bool) = std.atomic.Value(bool).init(false);
fn signalHandler(_: c_int) callconv(.c) void {
// Signal handlers can only call async-signal-safe functions.
// Semaphore.post() is async-signal-safe (uses Mach semaphores on macOS,
// POSIX semaphores on Linux).
if (semaphore) |sem| _ = sem.post();
}
/// Dedicated thread that waits on the semaphore.
/// When woken, it calls requestInspectorActivation() in normal thread context.
fn signalInspectorThread() void {
Output.Source.configureNamedThread("SignalInspector");
while (true) {
_ = semaphore.?.wait();
if (shutting_down.load(.acquire)) {
log("SignalInspector thread exiting", .{});
return;
}
log("SignalInspector thread woke, activating inspector", .{});
requestInspectorActivation();
}
}
fn install() bool {
semaphore = Semaphore.init() orelse {
log("semaphore init failed", .{});
return false;
};
// Spawn the SignalInspector thread
thread = std.Thread.spawn(.{
.stack_size = 512 * 1024,
}, signalInspectorThread, .{}) catch |err| {
log("thread spawn failed: {s}", .{@errorName(err)});
return false;
};
// Install SIGUSR1 handler
var act: std.posix.Sigaction = .{
.handler = .{ .handler = signalHandler },
.mask = std.posix.sigemptyset(),
.flags = std.posix.SA.RESTART,
};
std.posix.sigaction(std.posix.SIG.USR1, &act, null);
return true;
}
fn uninstall() void {
// Signal the thread to exit. We don't join because:
// 1. This is called from JS context (process.on('SIGUSR1', ...))
// 2. Blocking the JS thread is bad
// 3. The thread will exit on its own after checking shutting_down
// The thread and semaphore are "leaked" and anyway this happens once
// per process lifetime when user installs their own SIGUSR1 handler
shutting_down.store(true, .release);
if (semaphore) |sem| _ = sem.post();
}
} else struct {};
const windows = if (Environment.isWindows) struct {
const win32 = std.os.windows;
const HANDLE = win32.HANDLE;
const DWORD = win32.DWORD;
const BOOL = win32.BOOL;
const LPVOID = *anyopaque;
const LPCWSTR = [*:0]const u16;
const SIZE_T = usize;
const INVALID_HANDLE_VALUE = win32.INVALID_HANDLE_VALUE;
const SECURITY_ATTRIBUTES = extern struct {
nLength: DWORD,
lpSecurityDescriptor: ?LPVOID,
bInheritHandle: BOOL,
};
const PAGE_READWRITE: DWORD = 0x04;
const FILE_MAP_ALL_ACCESS: DWORD = 0xF001F;
const LPTHREAD_START_ROUTINE = *const fn (?LPVOID) callconv(.winapi) DWORD;
extern "kernel32" fn CreateFileMappingW(
hFile: HANDLE,
lpFileMappingAttributes: ?*SECURITY_ATTRIBUTES,
flProtect: DWORD,
dwMaximumSizeHigh: DWORD,
dwMaximumSizeLow: DWORD,
lpName: ?LPCWSTR,
) callconv(.winapi) ?HANDLE;
extern "kernel32" fn MapViewOfFile(
hFileMappingObject: HANDLE,
dwDesiredAccess: DWORD,
dwFileOffsetHigh: DWORD,
dwFileOffsetLow: DWORD,
dwNumberOfBytesToMap: SIZE_T,
) callconv(.winapi) ?LPVOID;
extern "kernel32" fn UnmapViewOfFile(
lpBaseAddress: LPVOID,
) callconv(.winapi) BOOL;
extern "kernel32" fn GetCurrentProcessId() callconv(.winapi) DWORD;
var mapping_handle: ?HANDLE = null;
/// Called via CreateRemoteThread from another process.
fn startDebugThreadProc(_: ?LPVOID) callconv(.winapi) DWORD {
requestInspectorActivation();
return 0;
}
fn install() bool {
const pid = GetCurrentProcessId();
var mapping_name_buf: [64]u8 = undefined;
const name_slice = std.fmt.bufPrint(&mapping_name_buf, "bun-debug-handler-{d}", .{pid}) catch return false;
var wide_name: [64]u16 = undefined;
const wide_name_z = bun.strings.toWPath(&wide_name, name_slice);
mapping_handle = CreateFileMappingW(
INVALID_HANDLE_VALUE,
null,
PAGE_READWRITE,
0,
@sizeOf(LPTHREAD_START_ROUTINE),
wide_name_z.ptr,
);
if (mapping_handle) |handle| {
const handler_ptr = MapViewOfFile(
handle,
FILE_MAP_ALL_ACCESS,
0,
0,
@sizeOf(LPTHREAD_START_ROUTINE),
);
if (handler_ptr) |ptr| {
// MapViewOfFile returns page-aligned memory, which satisfies
// the alignment requirements for function pointers.
const typed_ptr: *LPTHREAD_START_ROUTINE = @ptrCast(@alignCast(ptr));
typed_ptr.* = &startDebugThreadProc;
_ = UnmapViewOfFile(ptr);
return true;
} else {
log("MapViewOfFile failed", .{});
_ = bun.windows.CloseHandle(handle);
mapping_handle = null;
return false;
}
} else {
log("CreateFileMappingW failed for bun-debug-handler-{d}", .{pid});
return false;
}
}
fn uninstall() void {
if (mapping_handle) |handle| {
_ = bun.windows.CloseHandle(handle);
mapping_handle = null;
}
}
} else struct {};
/// Install the runtime inspector handler.
/// Safe to call multiple times - subsequent calls are no-ops.
pub fn installIfNotAlready() void {
if (installed.swap(true, .acq_rel)) {
return;
}
const success = if (comptime Environment.isPosix)
posix.install()
else if (comptime Environment.isWindows)
windows.install()
else
false;
if (!success) {
installed.store(false, .release);
}
}
/// Uninstall when a user SIGUSR1 listener takes over (POSIX only).
pub fn uninstallForUserHandler() void {
if (!installed.swap(false, .acq_rel)) {
return;
}
if (comptime Environment.isPosix) {
posix.uninstall();
}
}
/// Set SIGUSR1 to default action when --disable-sigusr1 is used.
/// This allows SIGUSR1 to use its default behavior (terminate process).
pub fn setDefaultSigusr1Action() void {
if (comptime Environment.isPosix) {
var act: std.posix.Sigaction = .{
.handler = .{ .handler = std.posix.SIG.DFL },
.mask = std.posix.sigemptyset(),
.flags = 0,
};
std.posix.sigaction(std.posix.SIG.USR1, &act, null);
}
}
/// Ignore SIGUSR1 when debugger is already enabled via CLI flags.
/// This prevents SIGUSR1 from terminating the process when the user is already debugging.
pub fn ignoreSigusr1() void {
if (comptime Environment.isPosix) {
var act: std.posix.Sigaction = .{
.handler = .{ .handler = std.posix.SIG.IGN },
.mask = std.posix.sigemptyset(),
.flags = 0,
};
std.posix.sigaction(std.posix.SIG.USR1, &act, null);
}
}
/// Called from C++ when user adds a SIGUSR1 listener
export fn Bun__Sigusr1Handler__uninstall() void {
uninstallForUserHandler();
}
/// Called from C++ StopTheWorld callback.
/// Returns true if inspector was activated, false if already active or not requested.
export fn Bun__activateInspector() bool {
if (!inspector_activation_requested.swap(false, .acq_rel)) {
return false;
}
return tryActivateInspector();
}
comptime {
if (Environment.isPosix) {
_ = Bun__Sigusr1Handler__uninstall;
}
_ = Bun__activateInspector;
}
const Semaphore = @import("../../sync/Semaphore.zig");
const std = @import("std");
const bun = @import("bun");
const Environment = bun.Environment;
const Output = bun.Output;
const jsc = bun.jsc;
const Debugger = jsc.Debugger;
const VirtualMachine = jsc.VirtualMachine;

View File

@@ -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;

View File

@@ -393,6 +393,8 @@ pub const Command = struct {
name: []const u8 = "",
dir: []const u8 = "",
} = .{},
/// Disable SIGUSR1 handler for runtime debugger activation
disable_sigusr1: bool = false,
};
var global_cli_ctx: Context = undefined;

View File

@@ -87,6 +87,7 @@ pub const runtime_params_ = [_]ParamType{
clap.parseParam("--inspect <STR>? Activate Bun's debugger") catch unreachable,
clap.parseParam("--inspect-wait <STR>? Activate Bun's debugger, wait for a connection before executing") catch unreachable,
clap.parseParam("--inspect-brk <STR>? Activate Bun's debugger, set breakpoint on first line of code and wait") catch unreachable,
clap.parseParam("--disable-sigusr1 Disable SIGUSR1 handler for runtime debugger activation") catch unreachable,
clap.parseParam("--cpu-prof Start CPU profiler and write profile to disk on exit") catch unreachable,
clap.parseParam("--cpu-prof-name <STR> Specify the name of the CPU profile file") catch unreachable,
clap.parseParam("--cpu-prof-dir <STR> Specify the directory where the CPU profile will be saved") catch unreachable,
@@ -773,6 +774,7 @@ pub fn parse(allocator: std.mem.Allocator, ctx: Command.Context, comptime cmd: C
ctx.runtime_options.smol = args.flag("--smol");
ctx.runtime_options.preconnect = args.options("--fetch-preconnect");
ctx.runtime_options.expose_gc = args.flag("--expose-gc");
ctx.runtime_options.disable_sigusr1 = args.flag("--disable-sigusr1");
if (args.option("--console-depth")) |depth_str| {
const depth = std.fmt.parseInt(u16, depth_str, 10) catch {

View File

@@ -1395,6 +1395,7 @@ pub const TestCommand = struct {
.smol = ctx.runtime_options.smol,
.debugger = ctx.runtime_options.debugger,
.is_main_thread = true,
.disable_sigusr1 = ctx.runtime_options.disable_sigusr1,
},
);
vm.argv = ctx.passthrough;

View File

@@ -755,13 +755,12 @@ function emitConvertEnumFunction(w: CodeWriter, type: TypeImpl) {
w.line(`template<> std::optional<${name}> parseEnumerationFromString<${name}>(const String& stringValue)`);
w.line(`{`);
w.line(
` static constexpr std::array<std::pair<ComparableASCIILiteral, ${name}>, ${type.data.length}> mappings { {`,
` static constexpr SortedArrayMap enumerationMapping { std::to_array<std::pair<ComparableASCIILiteral, ${name}>>({`,
);
for (const value of type.data) {
w.line(` { ${str(value)}_s, ${name}::${pascal(value)} },`);
}
w.line(` } };`);
w.line(` static constexpr SortedArrayMap enumerationMapping { mappings };`);
w.line(` }) };`);
w.line(` if (auto* enumerationValue = enumerationMapping.tryGet(stringValue); enumerationValue) [[likely]]`);
w.line(` return *enumerationValue;`);
w.line(` return std::nullopt;`);

View File

@@ -143,7 +143,7 @@ export function enumeration(
template<> std::optional<${qualifiedName}>
WebCore::parseEnumerationFromString<${qualifiedName}>(const WTF::String& stringVal)
{
static constexpr ::std::array<${pairType}, ${valueMap.size}> mappings {
static constexpr ::WTF::SortedArrayMap enumerationMapping { ::std::to_array<${pairType}>({
${joinIndented(
12,
Array.from(valueMap.entries())
@@ -155,8 +155,7 @@ export function enumeration(
},`;
}),
)}
};
static constexpr ::WTF::SortedArrayMap enumerationMapping { mappings };
}) };
if (auto* enumerationValue = enumerationMapping.tryGet(stringVal)) [[likely]] {
return *enumerationValue;
}

View File

@@ -325,7 +325,9 @@ $$capture_start$$(${fn.async ? "async " : ""}${
directives: fn.directives,
source: finalReplacement,
params: fn.params,
visibility: fn.directives.visibility ?? (fn.directives.linkTimeConstant ? "Private" : "Public"),
// Async functions automatically get Private visibility because the parser
// upgrades them when they use await (see Parser.cpp parseFunctionBody)
visibility: fn.directives.visibility ?? (fn.directives.linkTimeConstant || fn.async ? "Private" : "Public"),
isGetter: !!fn.directives.getter,
constructAbility: fn.directives.ConstructAbility ?? "CannotConstruct",
constructKind: fn.directives.ConstructKind ?? "None",

View File

@@ -2224,11 +2224,12 @@ export function readableStreamIntoArray(stream) {
return chunks;
}
// Use $promiseResolveWithThen to convert InternalPromise to regular Promise
if (manyResult && $isPromise(manyResult)) {
return manyResult.$then(processManyResult);
return $promiseResolveWithThen(Promise, manyResult.$then(processManyResult));
}
return processManyResult(manyResult);
return $promiseResolveWithThen(Promise, processManyResult(manyResult));
}
export function withoutUTF8BOM(result) {
@@ -2245,10 +2246,13 @@ export function readableStreamIntoText(stream: ReadableStream) {
const prom = $readStreamIntoSink(stream, textStream, false);
if (prom && $isPromise(prom)) {
return Promise.$resolve(prom).$then(closer.promise).$then($withoutUTF8BOM);
// Use $promiseResolveWithThen to convert InternalPromise to regular Promise
// since $readStreamIntoSink is an async builtin function that returns InternalPromise
return $promiseResolveWithThen(Promise, Promise.$resolve(prom).$then(closer.promise).$then($withoutUTF8BOM));
}
return closer.promise.$then($withoutUTF8BOM);
// Also wrap the non-promise path since closer.promise.$then() returns InternalPromise in builtin context
return $promiseResolveWithThen(Promise, closer.promise.$then($withoutUTF8BOM));
}
export function readableStreamToArrayBufferDirect(

39
src/sync/Semaphore.zig Normal file
View 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;

View File

@@ -49,3 +49,26 @@ bool Semaphore::wait()
}
} // 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();
}
}

View File

@@ -0,0 +1,425 @@
import { spawn } from "bun";
import { describe, expect, test } from "bun:test";
import { bunEnv, bunExe, isWindows, tempDir } from "harness";
import { join } from "path";
// POSIX-specific tests (SIGUSR1 mechanism) - macOS and Linux only
describe.skipIf(isWindows)("Runtime inspector SIGUSR1 activation", () => {
test("activates inspector when no user listener", async () => {
using dir = tempDir("sigusr1-activate-test", {
"test.js": `
const fs = require("fs");
const path = require("path");
// Write PID so parent can send signal
fs.writeFileSync(path.join(process.cwd(), "pid"), String(process.pid));
console.log("READY");
// Keep process alive
setInterval(() => {}, 1000);
`,
});
await using proc = spawn({
cmd: [bunExe(), "test.js"],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const reader = proc.stdout.getReader();
const decoder = new TextDecoder();
let output = "";
while (!output.includes("READY")) {
const { value, done } = await reader.read();
if (done) break;
output += decoder.decode(value, { stream: true });
}
reader.releaseLock();
const pid = parseInt(await Bun.file(join(String(dir), "pid")).text(), 10);
expect(pid).toBeGreaterThan(0);
// Send SIGUSR1
process.kill(pid, "SIGUSR1");
// Wait for inspector to activate by reading stderr until the full banner appears
const stderrReader = proc.stderr.getReader();
const stderrDecoder = new TextDecoder();
let stderr = "";
// Wait for the full banner (header + content + footer)
while ((stderr.match(/Bun Inspector/g) || []).length < 2) {
const { value, done } = await stderrReader.read();
if (done) break;
stderr += stderrDecoder.decode(value, { stream: true });
}
stderrReader.releaseLock();
// Kill process
proc.kill();
await proc.exited;
expect(stderr).toContain("Bun Inspector");
expect(stderr).toMatch(/ws:\/\/localhost:\d+\//);
});
test("user SIGUSR1 listener takes precedence over inspector activation", async () => {
using dir = tempDir("sigusr1-user-test", {
"test.js": `
const fs = require("fs");
const path = require("path");
process.on("SIGUSR1", () => {
console.log("USER_HANDLER_CALLED");
setTimeout(() => process.exit(0), 100);
});
fs.writeFileSync(path.join(process.cwd(), "pid"), String(process.pid));
console.log("READY");
setInterval(() => {}, 1000);
`,
});
await using proc = spawn({
cmd: [bunExe(), "test.js"],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const reader = proc.stdout.getReader();
const decoder = new TextDecoder();
let output = "";
while (!output.includes("READY")) {
const { value, done } = await reader.read();
if (done) break;
output += decoder.decode(value, { stream: true });
}
const pid = parseInt(await Bun.file(join(String(dir), "pid")).text(), 10);
process.kill(pid, "SIGUSR1");
while (true) {
const { value, done } = await reader.read();
if (done) break;
output += decoder.decode(value, { stream: true });
}
output += decoder.decode();
const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]);
expect(output).toContain("USER_HANDLER_CALLED");
expect(stderr).not.toContain("Bun Inspector");
expect(exitCode).toBe(0);
});
test("multiple SIGUSR1s work after user installs handler", async () => {
// After user installs their own SIGUSR1 handler, multiple signals should all
// be delivered to the user handler correctly.
using dir = tempDir("sigusr1-uninstall-test", {
"test.js": `
const fs = require("fs");
const path = require("path");
let count = 0;
process.on("SIGUSR1", () => {
count++;
console.log("SIGNAL_" + count);
if (count >= 3) {
setTimeout(() => process.exit(0), 100);
}
});
fs.writeFileSync(path.join(process.cwd(), "pid"), String(process.pid));
console.log("READY");
setInterval(() => {}, 1000);
`,
});
await using proc = spawn({
cmd: [bunExe(), "test.js"],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const reader = proc.stdout.getReader();
const decoder = new TextDecoder();
let output = "";
while (!output.includes("READY")) {
const { value, done } = await reader.read();
if (done) break;
output += decoder.decode(value, { stream: true });
}
const pid = parseInt(await Bun.file(join(String(dir), "pid")).text(), 10);
// Send SIGUSR1s and wait for each handler to respond before sending the next
for (let i = 1; i <= 3; i++) {
process.kill(pid, "SIGUSR1");
// Wait for handler output before sending next signal
while (!output.includes(`SIGNAL_${i}`)) {
const { value, done } = await reader.read();
if (done) break;
output += decoder.decode(value, { stream: true });
}
}
// Read remaining output until process exits
while (true) {
const { value, done } = await reader.read();
if (done) break;
output += decoder.decode(value, { stream: true });
}
output += decoder.decode();
const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]);
expect(output).toMatchInlineSnapshot(`
"READY
SIGNAL_1
SIGNAL_2
SIGNAL_3
"
`);
expect(stderr).not.toContain("Bun Inspector");
expect(exitCode).toBe(0);
});
test("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, exit after a bit
setTimeout(() => process.exit(0), 500);
setInterval(() => {}, 1000);
`,
});
await using proc = spawn({
cmd: [bunExe(), "test.js"],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const reader = proc.stdout.getReader();
const decoder = new TextDecoder();
let output = "";
while (!output.includes("READY")) {
const { value, done } = await reader.read();
if (done) break;
output += decoder.decode(value, { stream: true });
}
reader.releaseLock();
const pid = parseInt(await Bun.file(join(String(dir), "pid")).text(), 10);
// Send first SIGUSR1 and wait for inspector to activate
process.kill(pid, "SIGUSR1");
const stderrReader = proc.stderr.getReader();
const stderrDecoder = new TextDecoder();
let stderr = "";
// Wait for the full banner (header + content + footer) before sending second signal
while ((stderr.match(/Bun Inspector/g) || []).length < 2) {
const { value, done } = await stderrReader.read();
if (done) break;
stderr += stderrDecoder.decode(value, { stream: true });
}
// Send second SIGUSR1 - inspector should not activate again
process.kill(pid, "SIGUSR1");
// Read any remaining stderr until process exits
stderrReader.releaseLock();
const [remainingStderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]);
stderr += remainingStderr;
// Should only see one "Bun Inspector" banner (two occurrences of the text, for header and footer)
const matches = stderr.match(/Bun Inspector/g);
expect(matches?.length ?? 0).toBe(2);
expect(exitCode).toBe(0);
});
test("SIGUSR1 to self activates inspector", async () => {
await using proc = spawn({
cmd: [
bunExe(),
"-e",
`
setTimeout(() => process.exit(0), 300);
// Small delay to ensure handler is installed
setTimeout(() => {
process.kill(process.pid, "SIGUSR1");
}, 50);
setInterval(() => {}, 1000);
`,
],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]);
expect(stderr).toContain("Bun Inspector");
expect(exitCode).toBe(0);
});
test("SIGUSR1 is ignored when started with --inspect", async () => {
// When the process is started with --inspect, the debugger is already active.
// The RuntimeInspector signal handler should NOT be installed, so SIGUSR1
// should have no effect (default action is terminate, but signal may be ignored).
using dir = tempDir("sigusr1-inspect-test", {
"test.js": `
const fs = require("fs");
const path = require("path");
fs.writeFileSync(path.join(process.cwd(), "pid"), String(process.pid));
console.log("READY");
setTimeout(() => process.exit(0), 500);
setInterval(() => {}, 1000);
`,
});
await using proc = spawn({
cmd: [bunExe(), "--inspect", "test.js"],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const reader = proc.stdout.getReader();
const decoder = new TextDecoder();
let output = "";
while (!output.includes("READY")) {
const { value, done } = await reader.read();
if (done) break;
output += decoder.decode(value, { stream: true });
}
reader.releaseLock();
const pid = parseInt(await Bun.file(join(String(dir), "pid")).text(), 10);
// Send SIGUSR1 - should be ignored since RuntimeInspector is not installed
process.kill(pid, "SIGUSR1");
const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]);
// Should only see one "Bun Inspector" banner (from --inspect flag, not from SIGUSR1)
// The banner has two occurrences of "Bun Inspector" (header and footer)
const matches = stderr.match(/Bun Inspector/g);
expect(matches?.length ?? 0).toBe(2);
expect(exitCode).toBe(0);
});
test("SIGUSR1 is ignored when started with --inspect-wait", async () => {
// When the process is started with --inspect-wait, the debugger is already active.
// Sending SIGUSR1 should NOT activate the inspector again.
await using proc = spawn({
cmd: [bunExe(), "--inspect-wait", "-e", "setTimeout(() => process.exit(0), 500)"],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const reader = proc.stderr.getReader();
const decoder = new TextDecoder();
let stderr = "";
while ((stderr.match(/Bun Inspector/g) || []).length < 2) {
const { value, done } = await reader.read();
if (done) break;
stderr += decoder.decode(value, { stream: true });
}
// Send SIGUSR1 - should be ignored since debugger is already active
process.kill(proc.pid, "SIGUSR1");
// Kill process since --inspect-wait would wait for connection
// Signal processing is synchronous, so no sleep needed
proc.kill();
// Read any remaining stderr
while (true) {
const { value, done } = await reader.read();
if (done) break;
stderr += decoder.decode(value, { stream: true });
}
stderr += decoder.decode();
reader.releaseLock();
await proc.exited;
// Should only see one "Bun Inspector" banner (from --inspect-wait flag, not from SIGUSR1)
// The banner has two occurrences of "Bun Inspector" (header and footer)
const matches = stderr.match(/Bun Inspector/g);
expect(matches?.length ?? 0).toBe(2);
});
test("SIGUSR1 is ignored when started with --inspect-brk", async () => {
// When the process is started with --inspect-brk, the debugger is already active.
// Sending SIGUSR1 should NOT activate the inspector again.
await using proc = spawn({
cmd: [bunExe(), "--inspect-brk", "-e", "setTimeout(() => process.exit(0), 500)"],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const reader = proc.stderr.getReader();
const decoder = new TextDecoder();
let stderr = "";
while ((stderr.match(/Bun Inspector/g) || []).length < 2) {
const { value, done } = await reader.read();
if (done) break;
stderr += decoder.decode(value, { stream: true });
}
// Send SIGUSR1 - should be ignored since debugger is already active
process.kill(proc.pid, "SIGUSR1");
// Kill process since --inspect-brk would wait for connection
// Signal processing is synchronous, so no sleep needed
proc.kill();
// Read any remaining stderr
while (true) {
const { value, done } = await reader.read();
if (done) break;
stderr += decoder.decode(value, { stream: true });
}
stderr += decoder.decode();
reader.releaseLock();
await proc.exited;
// Should only see one "Bun Inspector" banner (from --inspect-brk flag, not from SIGUSR1)
// The banner has two occurrences of "Bun Inspector" (header and footer)
const matches = stderr.match(/Bun Inspector/g);
expect(matches?.length ?? 0).toBe(2);
});
});

View File

@@ -0,0 +1,295 @@
import { spawn } from "bun";
import { describe, expect, test } from "bun:test";
import { bunEnv, bunExe, isWindows, tempDir } from "harness";
import { join } from "path";
// Windows-specific tests (file mapping mechanism) - Windows only
describe.skipIf(!isWindows)("Runtime inspector Windows file mapping", () => {
test("inspector activates via file mapping mechanism", async () => {
// This is the primary Windows test - verify the file mapping mechanism works
using dir = tempDir("windows-file-mapping-test", {
"target.js": `
const fs = require("fs");
const path = require("path");
fs.writeFileSync(path.join(process.cwd(), "pid"), String(process.pid));
console.log("READY");
// Keep process alive
setInterval(() => {}, 1000);
`,
});
await using targetProc = spawn({
cmd: [bunExe(), "target.js"],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const reader = targetProc.stdout.getReader();
const decoder = new TextDecoder();
let output = "";
while (!output.includes("READY")) {
const { value, done } = await reader.read();
if (done) break;
output += decoder.decode(value, { stream: true });
}
reader.releaseLock();
const pid = parseInt(await Bun.file(join(String(dir), "pid")).text(), 10);
expect(pid).toBeGreaterThan(0);
// Use _debugProcess which uses file mapping on Windows
await using debugProc = spawn({
cmd: [bunExe(), "-e", `process._debugProcess(${pid})`],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [debugStderr, debugExitCode] = await Promise.all([debugProc.stderr.text(), debugProc.exited]);
expect(debugStderr).toBe("");
expect(debugExitCode).toBe(0);
// Wait for the debugger to start by reading stderr until the full banner appears
const stderrReader = targetProc.stderr.getReader();
const stderrDecoder = new TextDecoder();
let targetStderr = "";
// Wait for the full banner (header + content + footer)
while ((targetStderr.match(/Bun Inspector/g) || []).length < 2) {
const { value, done } = await stderrReader.read();
if (done) break;
targetStderr += stderrDecoder.decode(value, { stream: true });
}
stderrReader.releaseLock();
targetProc.kill();
await targetProc.exited;
// Verify inspector actually started
expect(targetStderr).toContain("Bun Inspector");
expect(targetStderr).toContain("ws://localhost:6499/");
});
test("_debugProcess works with current process's own pid", async () => {
// On Windows, calling _debugProcess with our own PID should work
await using proc = spawn({
cmd: [
bunExe(),
"-e",
`
setTimeout(() => process.exit(0), 300);
// Small delay to ensure handler is installed
setTimeout(() => {
process._debugProcess(process.pid);
}, 50);
setInterval(() => {}, 1000);
`,
],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]);
expect(stderr).toContain("Bun Inspector");
expect(exitCode).toBe(0);
});
test("inspector does not activate twice via file mapping", async () => {
using dir = tempDir("windows-twice-test", {
"target.js": `
const fs = require("fs");
const path = require("path");
fs.writeFileSync(path.join(process.cwd(), "pid"), String(process.pid));
console.log("READY");
setTimeout(() => process.exit(0), 500);
setInterval(() => {}, 1000);
`,
});
await using targetProc = spawn({
cmd: [bunExe(), "target.js"],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const reader = targetProc.stdout.getReader();
const decoder = new TextDecoder();
let output = "";
while (!output.includes("READY")) {
const { value, done } = await reader.read();
if (done) break;
output += decoder.decode(value, { stream: true });
}
reader.releaseLock();
const pid = parseInt(await Bun.file(join(String(dir), "pid")).text(), 10);
expect(pid).toBeGreaterThan(0);
// Set up stderr reader to wait for debugger to start
const stderrReader = targetProc.stderr.getReader();
const stderrDecoder = new TextDecoder();
let stderr = "";
// Call _debugProcess twice
await using debug1 = spawn({
cmd: [bunExe(), "-e", `process._debugProcess(${pid})`],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
await debug1.exited;
// Wait for the full banner (header + content + footer)
while ((stderr.match(/Bun Inspector/g) || []).length < 2) {
const { value, done } = await stderrReader.read();
if (done) break;
stderr += stderrDecoder.decode(value, { stream: true });
}
await using debug2 = spawn({
cmd: [bunExe(), "-e", `process._debugProcess(${pid})`],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
await debug2.exited;
// Collect any remaining stderr and wait for process to exit
stderrReader.releaseLock();
const remainingStderr = await targetProc.stderr.text();
stderr += remainingStderr;
const exitCode = await targetProc.exited;
// Should only see one "Bun Inspector" banner (two occurrences of the text, for header and footer)
const matches = stderr.match(/Bun Inspector/g);
expect(matches?.length ?? 0).toBe(2);
expect(exitCode).toBe(0);
});
test("multiple Windows processes can have inspectors sequentially", async () => {
// Note: Runtime inspector uses hardcoded port 6499, so we must test
// sequential activation (activate first, shut down, then activate second)
// rather than concurrent activation.
using dir = tempDir("windows-multi-test", {
"target.js": `
const fs = require("fs");
const path = require("path");
const id = process.argv[2];
fs.writeFileSync(path.join(process.cwd(), "pid-" + id), String(process.pid));
console.log("READY-" + id);
setTimeout(() => process.exit(0), 5000);
setInterval(() => {}, 1000);
`,
});
const decoder = new TextDecoder();
// First process: activate inspector, verify, then shut down
{
await using target1 = spawn({
cmd: [bunExe(), "target.js", "1"],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const reader1 = target1.stdout.getReader();
let output1 = "";
while (!output1.includes("READY-1")) {
const { value, done } = await reader1.read();
if (done) break;
output1 += decoder.decode(value, { stream: true });
}
reader1.releaseLock();
const pid1 = parseInt(await Bun.file(join(String(dir), "pid-1")).text(), 10);
expect(pid1).toBeGreaterThan(0);
await using debug1 = spawn({
cmd: [bunExe(), "-e", `process._debugProcess(${pid1})`],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
expect(await debug1.exited).toBe(0);
// Wait for the full banner
const stderrReader1 = target1.stderr.getReader();
const stderrDecoder1 = new TextDecoder();
let stderr1 = "";
while ((stderr1.match(/Bun Inspector/g) || []).length < 2) {
const { value, done } = await stderrReader1.read();
if (done) break;
stderr1 += stderrDecoder1.decode(value, { stream: true });
}
stderrReader1.releaseLock();
expect(stderr1).toContain("Bun Inspector");
target1.kill();
await target1.exited;
}
// Second process: now that first is shut down, port 6499 is free
{
await using target2 = spawn({
cmd: [bunExe(), "target.js", "2"],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const reader2 = target2.stdout.getReader();
let output2 = "";
while (!output2.includes("READY-2")) {
const { value, done } = await reader2.read();
if (done) break;
output2 += decoder.decode(value, { stream: true });
}
reader2.releaseLock();
const pid2 = parseInt(await Bun.file(join(String(dir), "pid-2")).text(), 10);
expect(pid2).toBeGreaterThan(0);
await using debug2 = spawn({
cmd: [bunExe(), "-e", `process._debugProcess(${pid2})`],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
expect(await debug2.exited).toBe(0);
// Wait for the full banner
const stderrReader2 = target2.stderr.getReader();
const stderrDecoder2 = new TextDecoder();
let stderr2 = "";
while ((stderr2.match(/Bun Inspector/g) || []).length < 2) {
const { value, done } = await stderrReader2.read();
if (done) break;
stderr2 += stderrDecoder2.decode(value, { stream: true });
}
stderrReader2.releaseLock();
expect(stderr2).toContain("Bun Inspector");
target2.kill();
await target2.exited;
}
});
});

View File

@@ -0,0 +1,429 @@
import { spawn } from "bun";
import { describe, expect, test } from "bun:test";
import { bunEnv, bunExe, isWindows, tempDir } from "harness";
import { join } from "path";
/**
* 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>,
): Promise<{ stderr: string; reader: ReadableStreamDefaultReader<Uint8Array> }> {
const reader = stderrStream.getReader();
const decoder = new TextDecoder();
let stderr = "";
// Wait for the full banner (header + content + footer)
// The banner format is:
// --------------------- Bun Inspector ---------------------
// Listening:
// ws://localhost:6499/...
// Inspect in browser:
// https://debug.bun.sh/#localhost:6499/...
// --------------------- Bun Inspector ---------------------
while ((stderr.match(/Bun Inspector/g) || []).length < 2) {
const { value, done } = await reader.read();
if (done) break;
stderr += decoder.decode(value, { stream: true });
}
return { stderr, reader };
}
// 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("activates inspector in target process", async () => {
using dir = tempDir("debug-process-test", {
"target.js": `
const fs = require("fs");
const path = require("path");
// Write PID so parent can find us
fs.writeFileSync(path.join(process.cwd(), "pid"), String(process.pid));
console.log("READY");
// Keep process alive
setInterval(() => {}, 1000);
`,
});
// Start target process
await using targetProc = spawn({
cmd: [bunExe(), "target.js"],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
// Wait for target to be ready
const reader = targetProc.stdout.getReader();
const decoder = new TextDecoder();
let output = "";
while (!output.includes("READY")) {
const { value, done } = await reader.read();
if (done) break;
output += decoder.decode(value, { stream: true });
}
reader.releaseLock();
const pid = parseInt(await Bun.file(join(String(dir), "pid")).text(), 10);
// Use _debugProcess to activate inspector
await using debugProc = spawn({
cmd: [bunExe(), "-e", `process._debugProcess(${pid})`],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [debugStderr, debugExitCode] = await Promise.all([debugProc.stderr.text(), debugProc.exited]);
expect(debugStderr).toBe("");
expect(debugExitCode).toBe(0);
// Wait for inspector to activate by reading stderr until we see the message
const { stderr: targetStderr, reader: stderrReader } = await waitForDebuggerListening(targetProc.stderr);
stderrReader.releaseLock();
// 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, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]);
expect(stderr).toContain("Failed");
expect(exitCode).not.toBe(0);
});
test("inspector does not activate twice", { timeout: 30_000 }, async () => {
using dir = tempDir("debug-process-twice-test", {
"target.js": `
const fs = require("fs");
const path = require("path");
fs.writeFileSync(path.join(process.cwd(), "pid"), String(process.pid));
console.log("READY");
// Keep process alive long enough for both _debugProcess calls
// Use longer timeout for ASAN builds which can be slower
setTimeout(() => process.exit(0), 30000);
setInterval(() => {}, 1000);
`,
});
await using targetProc = spawn({
cmd: [bunExe(), "target.js"],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const reader = targetProc.stdout.getReader();
const decoder = new TextDecoder();
let output = "";
while (!output.includes("READY")) {
const { value, done } = await reader.read();
if (done) break;
output += decoder.decode(value, { stream: true });
}
reader.releaseLock();
const pid = parseInt(await Bun.file(join(String(dir), "pid")).text(), 10);
// Start reading stderr before triggering debugger
const stderrReader = targetProc.stderr.getReader();
const stderrDecoder = new TextDecoder();
let stderr = "";
// Call _debugProcess the first time
await using debug1 = spawn({
cmd: [bunExe(), "-e", `process._debugProcess(${pid})`],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
expect(await debug1.exited).toBe(0);
// Wait for the full debugger banner (header + content + footer)
while ((stderr.match(/Bun Inspector/g) || []).length < 2) {
const { value, done } = await stderrReader.read();
if (done) break;
stderr += stderrDecoder.decode(value, { stream: true });
}
// Call _debugProcess again - inspector should not activate twice
await using debug2 = spawn({
cmd: [bunExe(), "-e", `process._debugProcess(${pid})`],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
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("can activate inspector in multiple processes sequentially", { timeout: 30_000 }, async () => {
// Note: Runtime inspector uses hardcoded port 6499, so we must test
// sequential activation (activate first, shut down, then activate second)
// rather than concurrent activation.
using dir = tempDir("debug-process-multi-test", {
"target.js": `
const fs = require("fs");
const path = require("path");
const id = process.argv[2];
fs.writeFileSync(path.join(process.cwd(), "pid-" + id), String(process.pid));
console.log("READY-" + id);
// Keep alive long enough for _debugProcess call
// Use longer timeout for ASAN builds which can be slower
setTimeout(() => process.exit(0), 30000);
setInterval(() => {}, 1000);
`,
});
const decoder = new TextDecoder();
// First process: activate inspector, verify, then shut down
{
await using target1 = spawn({
cmd: [bunExe(), "target.js", "1"],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const reader1 = target1.stdout.getReader();
let output1 = "";
while (!output1.includes("READY-1")) {
const { value, done } = await reader1.read();
if (done) break;
output1 += decoder.decode(value, { stream: true });
}
reader1.releaseLock();
const pid1 = parseInt(await Bun.file(join(String(dir), "pid-1")).text(), 10);
expect(pid1).toBeGreaterThan(0);
await using debug1 = spawn({
cmd: [bunExe(), "-e", `process._debugProcess(${pid1})`],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
expect(await debug1.exited).toBe(0);
const result1 = await waitForDebuggerListening(target1.stderr);
result1.reader.releaseLock();
expect(result1.stderr).toContain("Bun Inspector");
target1.kill();
await target1.exited;
}
// Second process: now that first is shut down, port 6499 is free
{
await using target2 = spawn({
cmd: [bunExe(), "target.js", "2"],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const reader2 = target2.stdout.getReader();
let output2 = "";
while (!output2.includes("READY-2")) {
const { value, done } = await reader2.read();
if (done) break;
output2 += decoder.decode(value, { stream: true });
}
reader2.releaseLock();
const pid2 = parseInt(await Bun.file(join(String(dir), "pid-2")).text(), 10);
expect(pid2).toBeGreaterThan(0);
await using debug2 = spawn({
cmd: [bunExe(), "-e", `process._debugProcess(${pid2})`],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
expect(await debug2.exited).toBe(0);
const result2 = await waitForDebuggerListening(target2.stderr);
result2.reader.releaseLock();
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, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]);
expect(stderr).toContain("requires a pid argument");
expect(exitCode).not.toBe(0);
});
test("can interrupt an infinite loop", async () => {
using dir = tempDir("debug-infinite-loop-test", {
"target.js": `
const fs = require("fs");
const path = require("path");
// Write PID so parent can find us
fs.writeFileSync(path.join(process.cwd(), "pid"), String(process.pid));
// Infinite loop - the inspector should be able to interrupt this
while (true) {}
`,
});
// Start target process with infinite loop
await using targetProc = spawn({
cmd: [bunExe(), "target.js"],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
// Wait for PID file to be written
const pidPath = join(String(dir), "pid");
let pid: number | undefined;
for (let i = 0; i < 50; i++) {
try {
const pidText = await Bun.file(pidPath).text();
pid = parseInt(pidText, 10);
if (pid > 0) break;
} catch {
// File not ready yet
}
await Bun.sleep(100);
}
expect(pid).toBeGreaterThan(0);
// Use _debugProcess to activate inspector - this should interrupt the infinite loop
await using debugProc = spawn({
cmd: [bunExe(), "-e", `process._debugProcess(${pid})`],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [debugStderr, debugExitCode] = await Promise.all([debugProc.stderr.text(), debugProc.exited]);
expect(debugStderr).toBe("");
expect(debugExitCode).toBe(0);
// Wait for inspector to activate - this proves we interrupted the infinite loop
const { stderr: targetStderr, reader: stderrReader } = await waitForDebuggerListening(targetProc.stderr);
stderrReader.releaseLock();
// Kill target
targetProc.kill();
await targetProc.exited;
expect(targetStderr).toContain("Bun Inspector");
expect(targetStderr).toMatch(/ws:\/\/localhost:\d+\//);
});
});
});
// POSIX-only: --disable-sigusr1 test
// On POSIX, when --disable-sigusr1 is set, no SIGUSR1 handler is installed,
// so SIGUSR1 uses the default action (terminate process with exit code 128+30=158)
// This test is skipped on Windows since there's no SIGUSR1 signal there.
describe.skipIf(isWindows)("--disable-sigusr1", () => {
test("prevents inspector activation and uses default signal behavior", async () => {
using dir = tempDir("disable-sigusr1-test", {
"target.js": `
const fs = require("fs");
const path = require("path");
fs.writeFileSync(path.join(process.cwd(), "pid"), String(process.pid));
console.log("READY");
// Keep alive long enough for signal to be sent
setTimeout(() => process.exit(0), 5000);
setInterval(() => {}, 1000);
`,
});
// Start with --disable-sigusr1
await using targetProc = spawn({
cmd: [bunExe(), "--disable-sigusr1", "target.js"],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const reader = targetProc.stdout.getReader();
const decoder = new TextDecoder();
let output = "";
while (!output.includes("READY")) {
const { value, done } = await reader.read();
if (done) break;
output += decoder.decode(value, { stream: true });
}
reader.releaseLock();
const pid = parseInt(await Bun.file(join(String(dir), "pid")).text(), 10);
// Send SIGUSR1 directly - without handler, this will terminate the process
process.kill(pid, "SIGUSR1");
const [stderr, exitCode] = await Promise.all([targetProc.stderr.text(), targetProc.exited]);
// Should NOT see debugger listening message
expect(stderr).not.toContain("Debugger listening");
// Process should be terminated by SIGUSR1
// Exit code = 128 + signal number (macOS: SIGUSR1=30 -> 158, Linux: SIGUSR1=10 -> 138)
expect(exitCode).toBeOneOf([158, 138]);
});
});

View File

@@ -684,7 +684,6 @@ describe.concurrent(() => {
const undefinedStubs = [
"_debugEnd",
"_debugProcess",
"_fatalException",
"_linkedBinding",
"_rawDebug",