Compare commits

...

134 Commits

Author SHA1 Message Date
Alistair Smith
8edd9f4007 Merge branch 'main' into ali/inspector-cdp-pause 2026-02-17 16:43:08 -08:00
Alistair Smith
831f901e2d Merge branch 'ali/inspector-cdp-pause' of github.com:oven-sh/bun into ali/inspector-cdp-pause 2026-02-17 14:47:24 -08:00
Alistair Smith
87838209db fix: restore connected for isBootstrap check
The activated change broke inspector activation entirely.
Revert to connected which was the working state.
2026-02-17 14:12:32 -08:00
Alistair Smith
b078ae29dc Merge branch 'main' into ali/inspector-cdp-pause 2026-02-17 13:49:13 -08:00
autofix-ci[bot]
4c61221eb4 [autofix.ci] apply automated fixes 2026-02-17 20:28:22 +00:00
Alistair Smith
d95e006879 try fix race 2026-02-17 12:25:56 -08:00
Alistair Smith
9a2c9b3394 fix: drain CDP messages after doConnect to prevent message loss on reconnect
receiveMessagesOnInspectorThread was draining messages from the queue
before checking if the connection needed doConnect. If the connection
was Pending, it called doConnect and returned early, dropping the
already-drained messages on the floor.

Move the doConnect check before the message drain so messages that
arrive during reconnection are not lost.
2026-02-17 11:48:55 -08:00
Alistair Smith
4ca4f07d4c Merge branch 'main' into ali/inspector-cdp-pause 2026-02-16 23:21:24 -08:00
autofix-ci[bot]
75990c97de [autofix.ci] apply automated fixes 2026-02-17 04:00:21 +00:00
Alistair Smith
cbe3ce02ea Merge branch 'main' into ali/inspector-cdp-pause 2026-02-16 19:57:24 -08:00
Alistair Smith
32ab3a5a75 update WebKit to autobuild-preview-pr-161-0596ebdb (fix agent teardown on reconnect) 2026-02-16 19:19:25 -08:00
Alistair Smith
522a80dc76 update WebKit to autobuild-preview-pr-161-211cf848 (restore CDP drain in NeedDebuggerBreak) 2026-02-13 19:45:39 -08:00
Alistair Smith
b20003779f this is definitely wrong 2026-02-13 18:49:10 -08:00
Alistair Smith
4a9c54d03b fix: safely write usePollingTraps by temporarily unprotecting frozen JSC config page 2026-02-13 17:36:34 -08:00
Alistair Smith
49c2ef2732 fix: remove write to frozen JSC::Options::usePollingTraps (segfault on Linux)
The options page is mprotected read-only after JSC initialization.
Writing to usePollingTraps from Bun__activateRuntimeInspectorMode crashes
with SEGV at offset 0xB34 (the usePollingTraps field offset in the frozen page).
Confirmed via ASAN on Linux aarch64.
2026-02-13 17:16:57 -08:00
Alistair Smith
ee7d7bd99c bisect: WebKit with VMTraps.cpp reverted to PR 159 (test if Debugger.cpp causes crash) 2026-02-13 15:54:23 -08:00
Alistair Smith
0329c4a6a4 fix: use --inspect-port=0 in posix SIGUSR1 tests to avoid port conflicts 2026-02-13 15:07:14 -08:00
Alistair Smith
7214b0475e update WebKit to autobuild-preview-pr-161-d796f228 (fix SignalSender crash on jettisoned code) 2026-02-13 14:10:56 -08:00
Alistair Smith
7c136691a8 refactor(inspector): clean up BunDebugger.cpp naming and duplication
- Rename Bun__setRuntimeInspectorActivated → Bun__activateRuntimeInspectorMode
- Rename Bun__activateInspector → Bun__tryActivateInspector
- Rename Bun__jsDebuggerCallback → Bun__stopTheWorldCallback
- Extract forEachConnection/forEachConnectionForVM iteration helpers
- Merge findVMWithPendingConnections + findVMWithPendingPause → findVMWithPendingWork
- Extract installRunWhilePausedCallback and makeInspectable helpers
- Remove duplicate Bun__tickWhilePaused declarations
- Remove unnecessary messages.clear() before destructor
- Fix stale comment on kMessageDeliveryPause
2026-02-13 13:58:01 -08:00
Alistair Smith
fd1df3f892 fix declare 2026-02-13 13:44:44 -08:00
Alistair Smith
22ca21dce5 cleanup: use Bun__setRuntimeInspectorActivated in STW path too 2026-02-13 13:40:20 -08:00
Alistair Smith
68eeae70f3 fix: set usePollingTraps in event loop activation path to stop SignalSender 2026-02-13 13:38:27 -08:00
Alistair Smith
743b477244 try defer to fix a crash 2026-02-13 13:20:59 -08:00
Alistair Smith
a4c1defe77 remove accidentally committed printer.js 2026-02-13 13:11:21 -08:00
Alistair Smith
7eda29f2d6 fix: cancel VM stop in event loop activation path to prevent residual trap crash
When the event loop path wins the race against STW for inspector activation,
requestResumeAll() doesn't clear the VM's trap bits (world was never stopped).
The residual NeedStopTheWorld trap + poisoned stack limit then crashes when JS
next enters a function. Fix by explicitly calling vm.cancelStop() before
requestResumeAll() on the event loop path.
2026-02-13 13:09:44 -08:00
Alistair Smith
95d01f71c9 Merge branch 'main' into ali/inspector-cdp-pause 2026-02-13 13:02:53 -08:00
Alistair Smith
3680b3887c update WebKit to autobuild-preview-pr-161-aa4d1424 (fix heap walk crash on Linux) 2026-02-13 12:22:25 -08:00
Alistair Smith
71cccddf29 fix inspect_port in initBake/second init, reorder exit code assertions in tests 2026-02-12 19:40:09 -08:00
Alistair Smith
11d386c453 address review comments: fix inspect_port ordering, improve error messages, add timeout guards to test loops 2026-02-12 19:10:26 -08:00
Alistair Smith
bff435f3b1 update WebKit to autobuild-preview-pr-161-c7bcfafe (ali/stw3) 2026-02-12 18:49:22 -08:00
Alistair Smith
8bd240b5c2 Merge branch 'main' of github.com:oven-sh/bun into ali/inspector-cdp-pause 2026-02-12 18:14:44 -08:00
Alistair Smith
5bc8d247e0 set JSC::Options::usePollingTraps() = true; when the inspector starts because all js code will get recompiled and polling traps allow us to guarantee hitting STW 2026-02-12 17:57:52 -08:00
Alistair Smith
32abcf8736 some fixes 2026-02-11 17:30:31 -08:00
Alistair Smith
e5089fc1fd try to find a default port with runtime inspector in tests? 2026-02-11 11:34:17 -08:00
Alistair Smith
0d1a98b5e0 revert: restore preview WebKit with breakProgram() (reverts accidental revert)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-11 11:14:38 -08:00
Alistair Smith
005c9184bf revert: use original WebKit version (breakProgram causes test failures) 2026-02-11 11:10:40 -08:00
autofix-ci[bot]
5c165f8ab2 [autofix.ci] apply automated fixes 2026-02-11 07:06:31 +00:00
Alistair Smith
8ace3a5e27 make runtime-inspector less flaky 2026-02-10 23:00:29 -08:00
Alistair Smith
5fc0806f8c cleanup debugger file and tests 2026-02-10 22:14:31 -08:00
Alistair Smith
2df14412e3 simplify the atomics a bunch 2026-02-10 21:39:36 -08:00
Alistair Smith
3ba2cb888a fix the race + some pr review 2026-02-10 21:31:40 -08:00
Alistair Smith
3a6a346438 fix: use WebKit preview build with breakProgram() for reliable while(true) pause
Point to autobuild-preview-pr-159-f611d4a9 which includes the
breakProgram() call in the NeedDebuggerBreak handler, needed to
reliably enter the debugger pause loop from DFG tight loops.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-10 18:16:00 -08:00
Alistair Smith
a54d19e533 fix(test): remove explicit test timeout per test/CLAUDE.md guidelines
Bun's test runner has built-in timeouts, so explicit timeout params
are unnecessary.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-10 16:41:18 -08:00
Alistair Smith
7f01e99c55 Merge branch 'main' into ali/inspector-cdp-pause 2026-02-10 16:31:43 -08:00
Alistair Smith
d288559fd2 fix: replace preAttachedDebugger with runtimeInspectorActivated, add reconnection test
preAttachedDebugger was per-connection and broke on reconnect — a new
connection after disconnect wouldn't have the flag set (debugger already
attached from first connection), so interruptForMessageDelivery would
skip requestStopAll. Using the global runtimeInspectorActivated flag
fixes reconnection and eliminates a redundant atomic bool.

Added test verifying CDP messages work after client disconnect/reconnect.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-10 16:25:45 -08:00
Alistair Smith
0fee9b809b fix: guard pre-attach debugger with runtimeInspectorActivated to fix Windows hang
The pre-attach code in doConnect() was firing for --inspect too (debugger
isn't attached at doConnect time, only after Debugger.enable CDP command).
This set preAttachedDebugger=true, causing interruptForMessageDelivery to
call requestStopAll for every CDP message — deadlocking on Windows.

Gate the pre-attach on runtimeInspectorActivated so it only fires for
the SIGUSR1 path where it's actually needed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-10 16:14:39 -08:00
Alistair Smith
c2c72f71dd revert: undo changes to test/harness.ts 2026-02-10 16:09:40 -08:00
Alistair Smith
4e26dbe304 fix(test): remove all setTimeout from inspector test fixtures
Replace timer-based process exits with parent-driven termination. Wait
for --inspect banner before sending SIGUSR1 in the ignored-signal test
to avoid killing before stderr is flushed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-10 15:34:04 -08:00
Alistair Smith
bf15eb7955 fix: deduplicate pre-attach comment in doConnect
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-10 15:28:18 -08:00
Alistair Smith
2e2159a9c3 fix: address coderabbit review - clarify pre-attach guard, fix hardcoded port
Improve the comment on the pre-attach debugger guard to explain why it
only fires on the SIGUSR1 path (--inspect already has the debugger
attached by JSC at startup). Replace hardcoded port 6499 assertion in
Windows test with regex match.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-10 15:21:54 -08:00
Alistair Smith
265f07a27c fix(test): improve runtime inspector test reliability to reduce CI flakiness
Add readStderrUntil helper with 30s timeout to prevent tests from hanging
indefinitely on stderr reader loops. Replace Bun.sleep(100) negative
assertion with kill-and-drain approach, and replace setTimeout-based
self-signaling with PID file approach to avoid timing races on slow CI.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-10 14:51:22 -08:00
Alistair Smith
81028e08a7 fix: address review - make preAttachedDebugger atomic, document STW threading safety
Make preAttachedDebugger std::atomic<bool> to fix data race between JS
thread (doConnect writer) and debugger thread (interruptForMessageDelivery
reader). Add comments documenting why schedulePauseAtNextOpportunity is
safe during STW, the double requestStopAll rationale with empirical
results, and STW_CONTINUE behavior for non-VMStopped events.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-10 14:45:18 -08:00
Alistair Smith
de1634b9cd fix: remove usePollingTraps to avoid DFG optimization regressions
Polling traps add a load+branch at every loop back-edge and inhibit DFG
structure-watching optimizations. Signal-based traps (InvalidationPoint)
have zero steady-state overhead with ~94% reliability for requestStopAll
delivery, which is acceptable for the runtime inspector (SIGUSR1) path.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-10 14:12:07 -08:00
Alistair Smith
868d3b6c0b fix: only use STW in connect() for runtime-activated inspector (SIGUSR1)
connect() was unconditionally calling requestStopAll for all inspector
connections, causing --inspect tests to hang because the STW cycle
interfered with normal event loop delivery. Added runtimeInspectorActivated
flag that is only set when the STW callback processes a SIGUSR1 activation,
so connect() only uses requestStopAll on the runtime activation path.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-10 12:40:47 -08:00
Alistair Smith
c417af9a2c Merge branch 'main' into ali/inspector-cdp-pause 2026-02-10 11:36:24 -08:00
Alistair Smith
5a4823285c fix: only use requestStopAll for message delivery when debugger was pre-attached
The --inspect flag tests were failing because interruptForMessageDelivery
called requestStopAll for every CDP message, even on the normal --inspect
path where the event loop delivers messages fine. Now we only use
requestStopAll when the debugger was pre-attached during SIGUSR1 activation
(where the event loop may not be running).
2026-02-10 09:44:25 -08:00
Alistair Smith
e56a08d58d fix: address review comments - guard redundant STW requests, add bootstrap pause VM switch 2026-02-10 02:16:36 -08:00
Alistair Smith
c27dd9048c Merge remote-tracking branch 'origin/main' into ali/inspector-cdp-pause
# Conflicts:
#	src/bun.js/bindings/ZigGlobalObject.cpp
2026-02-10 01:58:57 -08:00
Alistair Smith
be97a9771b WIP: inspector CDP delivery during busy JS execution 2026-02-10 01:05:36 -08:00
Claude Bot
1a6b804a37 fix: improve ASAN detection to check ASAN_OPTIONS env var
The Debian ASAN CI build doesn't have "bun-asan" in the executable name
but does set ASAN_OPTIONS. Also skip "can interrupt an infinite loop"
test on ASAN builds.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 01:56:57 +00:00
Claude Bot
b97f3f3f5c fix: skip ASAN for flaky inspector test and add timeout handling
- Skip "inspector does not activate twice" test for ASAN builds
- Add timeout to the stderr reading loop in this test to prevent
  potential infinite hangs

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 01:14:09 +00:00
Claude Bot
a2ba9cbd99 fix: address review comments
- Add disable_sigusr1 to bootStandalone options so standalone executables
  respect the --disable-sigusr1 flag
- Replace inline snapshot with explicit toBe() for clearer output comparison

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 22:59:23 +00:00
Claude Bot
5024e20b50 fix(sigusr1): handle EINTR in sem_wait and skip ASAN for flaky tests
- Fix spurious inspector activation by handling EINTR in Semaphore::wait()
  On Linux, sem_wait() can return EINTR when interrupted by any signal,
  not just SIGUSR1. This caused the SignalInspector thread to wake up
  spuriously and activate the inspector.

- Skip flaky SIGUSR1 tests under ASAN builds
  ASAN builds have timing issues that make signal handling unreliable.
  These tests already work correctly in non-ASAN builds.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 21:46:59 +00:00
Alistair Smith
cf53710bce Merge branch 'main' into ali/sigusr1 2026-02-02 12:46:38 -08:00
Claude Bot
d81941694e Fix runtime inspector tests to wait for conditions, not time
- Remove setTimeout exit timers from spawned processes
- Processes now stay alive until test explicitly kills them after condition is met
- Tests wait for inspector banner condition, then kill process

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 23:17:01 +00:00
Claude Bot
084f565b73 Fix runtime inspector tests: timing and stream reader issues
- Increase timeout in tests that need to wait for inspector banner
- Fix stream reader handling to avoid releaseLock() errors
- Add 30s timeout for multi-process sequential test

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 23:07:40 +00:00
Claude Bot
ad01185643 Merge main into ali/sigusr1 2026-01-27 22:53:52 +00:00
Claude Bot
3800722478 Address CodeRabbit review comments
- Fix overlapping reads in waitForDebuggerListening by reusing single readPromise
- Remove setTimeout from test fixtures (rely on parent process cleanup)
- Fix assertion to check for 'Bun Inspector' instead of 'Debugger listening'
2026-01-17 01:10:57 +00:00
Claude Bot
37cbb0c633 Skip flaky SIGUSR1 inspector tests on ASAN builds 2026-01-16 21:51:20 +00:00
autofix-ci[bot]
82eb3f31f0 [autofix.ci] apply automated fixes 2026-01-16 20:21:37 +00:00
Claude Bot
f2a13504a3 Revert Request.zig import reordering 2026-01-16 20:19:49 +00:00
Claude Bot
f6189c73f9 Revert ReadableStreamInternals.ts changes 2026-01-16 20:19:03 +00:00
autofix-ci[bot]
d780bf1a23 [autofix.ci] apply automated fixes 2026-01-16 20:18:52 +00:00
Claude Bot
1c88d406c1 Remove comment 2026-01-16 20:17:08 +00:00
Claude Bot
82bf72afd1 Merge remote-tracking branch 'origin/main' into claude/merge-sigusr1
# Conflicts:
#	cmake/tools/SetupWebKit.cmake
#	src/bun.js/bindings/bindings.cpp
2026-01-16 20:15:16 +00:00
Alistair Smith
75c127ffde Merge branch 'main' into ali/sigusr1 2026-01-14 16:33:38 -08:00
Claude Bot
32ead03078 test: await debug2 stderr before checking exit code
Ensure stderr is awaited before checking exit code for debug2 spawn
to provide better error information if the spawn fails.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 22:57:11 +00:00
Claude Bot
4c108149ed test: await stderr before checking exit code in runtime-inspector tests
Await stderr and check it before checking exit code for debug1 and debug2
spawn calls. This ensures useful error information is captured if the
spawned process fails.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 22:48:44 +00:00
Claude Bot
e3baed59b0 docs: add comment explaining QueuedTask payload parameter
The third parameter (0) in QueuedTask instantiation is the payload field
for task-specific metadata. For BunPerformMicrotaskJob, this is unused.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 22:47:33 +00:00
Claude Bot
51e26fd045 test: check stderr before exit code for better error messages
Per coding guidelines, await and assert stderr/stdout before exit code
to get more useful error messages on test failure.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 22:37:12 +00:00
Claude Bot
a461b72ae7 fix: clean up semaphore if thread spawn fails
When std.Thread.spawn fails in the install() function, the semaphore
that was already initialized needs to be cleaned up to prevent a
resource leak. The C++ Bun::Semaphore object allocated by init()
would otherwise never be destroyed.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 22:36:56 +00:00
Alistair Smith
88b19a848a Merge branch 'main' into ali/sigusr1 2026-01-14 12:00:00 -08: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
20 changed files with 2349 additions and 63 deletions

View File

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

View File

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

View File

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

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

View File

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

View File

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

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

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

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

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

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

View File

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

View File

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

@@ -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();
}
}

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

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

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

View File

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