Skip to content

Add VM::setDebuggerTrapCallback for runtime debugger activation - #287

Open
robobun wants to merge 1 commit into
mainfrom
robobun/debugger-trap-callback
Open

Add VM::setDebuggerTrapCallback for runtime debugger activation#287
robobun wants to merge 1 commit into
mainfrom
robobun/debugger-trap-callback

Conversation

@robobun

@robobun robobun commented Jul 14, 2026

Copy link
Copy Markdown
Collaborator

Adds a per-VM callback for the NeedDebuggerBreak trap so an embedder can attach a debugger to a program that is already running and enter Debugger::breakProgram() from code that was compiled without op_debug sites (bun uses it for SIGUSR1 / process._debugProcess, oven-sh/bun#37336). Recompiling is not an alternative: code generation mode changes apply when the VM goes idle and already linked functions keep their bytecode.

Everything below is under USE(BUN_JSC_ADDITIONS) unless noted.

The callback (VM.h, VMTraps): VMTraps::handleDebuggerBreak() runs it with termination deferred (re-fired as a trap afterwards), release-asserts it leaves no exception behind, and masks NeedDebuggerBreak while it is on the stack so JS run by the callback cannot nest another invocation; a re-fire is taken by the same handleTraps() loop once it returns. The contract, the idle-loop requirement (handleTrapsIfNeeded(NeedDebuggerBreak), since nothing else retires the bit for an embedder that idles holding the API lock) and the limitations (pause and evaluate; breakpoints, debugger and stepping only once the running code is re-entered) are documented on setDebuggerTrapCallback.

Where it may run: llint_check_stack_and_vm_traps and IPInt check_stack_and_vm_traps service NonDebuggerAsyncEvents only. The JS frame there is at bytecode 0 before op_enter, and the wasm path has not stored topCallFrame; the bit is taken at the next op_check_traps or from the idle loop instead.

Optimized frames: with signal based traps the whole-stack invalidation is skipped when a callback is installed (the frame that was running optimized code already left through the trap breakpoints, the rest are parked at calls); bun fires this trap once per CDP batch, so the per-delivery jettison mattered. With polling traps the callback can run inside a live DFG/FTL frame, so invalidateCodeBlocksOnStack is now built in those configurations too, the callback path jettisons the stack unconditionally, and ByteCodeParser::handleCheckTraps emits an InvalidationPoint after CheckTraps so the frame exits there on return rather than continuing with hoisted state. Upstream behaviour is unchanged when no callback is installed.

Scope register: DebuggerCallFrame::scope() and ShadowChicken::update() share CallFrame::scopeIfScopeRegisterIsLive(), which reads the slot only under the conditions BytecodeUseDef keeps it live (debugging opcodes, valid register, bytecode offset past op_enter), as a JSValue with dynamicDowncast, and otherwise falls through to the callee's scope. A trap pause can otherwise sit on a frame whose slot is dead (baseline reached by OSR exit from non-debug DFG) or unwritten.

Stepping out of a trap pause: step next/over/out on a frame without debugging opcodes degrade to stepIntoStatement() (pause at the next opportunity), since the hooks that complete them never run on that frame; Debugger::detach() resets stepping mode, the pause state and m_currentCallFrame when the last global object detaches, so a frontend disconnecting in that state does not leave stepping stuck on for the next one.

Inspector (not gated): JSGlobalObjectInspectorController::disconnectFrontend() tears the agents down only when the last frontend disconnects, so a second frontend keeps working after the first goes away.

Compared with the first revision, Debugger::isPauseAtNextOpportunitySet() and the idempotent attach() are gone; bun reads InspectorDebuggerAgent::pauseOnNextStatementEnabled() and no longer pre-attaches. The consumer-side tests that pin these hunks are listed in oven-sh/bun#37336.

Replaces #168.

@coderabbitai

coderabbitai Bot commented Jul 14, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

Changes

The PR updates Bun debugger stepping, scope recovery, VM debugger-trap callbacks, trap entry points, and last-frontend inspector notifications.

Debugger runtime updates

Layer / File(s) Summary
Reset debugger state and adapt stepping
Source/JavaScriptCore/debugger/Debugger.*
Last-global detachment clears stale debugger state. Step-next, step-over, and step-out use step-into when the current frame cannot complete the requested step.
Guard scope-register recovery
Source/JavaScriptCore/interpreter/*, Source/JavaScriptCore/debugger/DebuggerCallFrame.cpp, Source/JavaScriptCore/interpreter/ShadowChicken.cpp
Bun builds read a scope register only when it is live. Scope recovery falls back to logged, callee, or global scope data.
Handle debugger-break callbacks
Source/JavaScriptCore/runtime/VM.*, Source/JavaScriptCore/runtime/VMTraps.*
Bun VM builds expose an atomic debugger-trap callback and invoke it with guarded invalidation and termination handling.
Separate debugger and asynchronous traps
Source/JavaScriptCore/dfg/DFGByteCodeParser.cpp, Source/JavaScriptCore/llint/LLIntSlowPaths.cpp, Source/JavaScriptCore/wasm/WasmIPIntSlowPaths.cpp
Bun trap paths process non-debugger asynchronous events separately and emit debugger-aware invalidation nodes during DFG parsing.

Inspector disconnect handling

Layer / File(s) Summary
Notify agents for the last frontend
Source/JavaScriptCore/inspector/JSGlobalObjectInspectorController.cpp
The inspector emits InspectorDestroyed only after the disconnected frontend is confirmed to be the last connected frontend.

Suggested reviewers: constellation, kmiller68, geoffreygaren

🚥 Pre-merge checks | ✅ 3 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description explains the implementation in detail but omits the required Bugzilla link, review line, and template-formatted bug and change details. Add the required Bugzilla bug title and URL, “Reviewed by NOBODY (OOPS!).”, and the template-formatted explanation and changed-file/function list.
✅ Passed checks (3 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly identifies the primary change: adding a per-VM debugger trap callback for runtime debugger activation.

Comment @coderabbitai help to get the list of available commands.

Comment thread Source/JavaScriptCore/debugger/Debugger.cpp Outdated
@github-actions

github-actions Bot commented Jul 14, 2026

Copy link
Copy Markdown

Preview Builds

Commit Release Date
1f04bd0f autobuild-preview-pr-287-1f04bd0f 2026-08-11 02:55:27 UTC
d55f967e autobuild-preview-pr-287-d55f967e 2026-08-10 19:43:21 UTC
9af52a72 autobuild-preview-pr-287-9af52a72 2026-07-15 23:46:43 UTC
b6b55b57 autobuild-preview-pr-287-b6b55b57 2026-07-14 04:41:42 UTC

@robobun
robobun force-pushed the robobun/debugger-trap-callback branch from b6b55b5 to 9af52a7 Compare July 15, 2026 23:09
robobun added a commit to oven-sh/bun that referenced this pull request Jul 16, 2026
Port of #26867 to the Rust runtime with a simplified architecture:
SIGUSR1 posts to an async-signal-safe semaphore; a dedicated thread fires
notifyNeedDebuggerBreak on the main VM; JSC's SignalSender interrupts the
VM (all tiers via InvalidationPoint patching) and VMTraps::handleTraps
invokes a per-VM callback that activates the inspector and, when a pause
is requested, enters Debugger::breakProgram().

Requires oven-sh/WebKit#287 (VM::setDebuggerTrapCallback + idempotent
Debugger::attach + DebuggerCallFrame scope guard + disconnectFrontend
ordering).
robobun added a commit to oven-sh/bun that referenced this pull request Jul 16, 2026
…45d)

Main bumped WEBKIT_VERSION to 4895f45d in #34009. Re-rebase
oven-sh/WebKit#287 onto that commit (previously it was c9ad5813 with the
cmake fix cherry-picked) so the preview build matches main's WebKit plus
just the 49-line setDebuggerTrapCallback patch.

Resolved conflicts in serve-response-stream-sink-leak.test.ts by taking
main's version (the threshold widen in this PR was reverted anyway).
@robobun
robobun force-pushed the robobun/debugger-trap-callback branch from 9af52a7 to d55f967 Compare August 10, 2026 18:55
Comment thread Source/JavaScriptCore/debugger/Debugger.h Outdated

@alii alii left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The trap callback is the right primitive. Recompiling with op_debug instead doesn't work because every codegen mode switch goes through whenIdle and already-linked functions keep their op_debug-less bytecode, so something has to be able to breakProgram() from a trap. But as placed the hook hands the callback frames it can't safely pause on, it has no re-entrancy/termination/exception contract, and two of the side hunks (isPauseAtNextOpportunitySet, idempotent attach) I think should just be dropped. Details inline, all from reading the code at d55f967 plus bun#37336, nothing here has been run.

Separately: six files change and nothing tests them. The consumer's suite passes with DebuggerCallFrame.cpp:159, the disconnectFrontend reorder, and the Debugger.cpp:180 early return each reverted, so none of the un-gated hunks are pinned by anything. I'll list the tests I'd want on the bun PR.

Things I checked that hold up: the disconnectFrontend reorder (no agent sends to the departing frontend during teardown, and PageInspectorController already does it this way), the old NoEvent race in handleTraps (already closed on base), m_globalObjects staying in sync with the idempotent attach, double scriptParsed on enable-after-preattach (doesn't happen), worker VMs getting the trap with no callback installed, and the function-pointer shape vs the other fork hooks.

invalidateCodeBlocksOnStack(vm.topCallFrame);
#if USE(BUN_JSC_ADDITIONS)
if (auto callback = vm.debuggerTrapCallback())
callback(vm);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

blocking: this is not always a point where the top frame can be paused on. fireTrap poisons the trap-aware soft stack limit, so every LLInt function entry fails the check at LowLevelInterpreter.asm:1535 and lands in llint_check_stack_and_vm_traps (LLIntSlowPaths.cpp:543-563) with topCallFrame being the new callee at bc#0, before op_enter has run, before sp is lowered, and before the locals are zeroed (asm:1566-1580 does that after the slow call returns). For call-heavy code that is the main place this trap gets serviced. If the callback calls breakProgram() there and the block has since been relinked with debug opcodes (setBreakpointsActive plus one idle tick), DebuggerCallFrame::scope() takes the register path and reads an uninitialised stack slot as a JSScope*.

Two ways out that I can see, probably want both: make llint_check_stack_and_vm_traps service only VMTraps::NonDebuggerAsyncEvents (the mask VM::hasExceptionsAfterHandlingTraps already uses at VM.cpp:1078-1082 for the same reason) so the bit waits for the next op_check_traps at a loop back-edge, and make the scope() predicate refuse bc#0 (see the DebuggerCallFrame comment).

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 1f04bd0: llint_check_stack_and_vm_traps services NonDebuggerAsyncEvents, and scope() now goes through CallFrame::scopeIfScopeRegisterIsLive(), which also refuses bytecode offset 0. One consequence I want to be explicit about: busy code that recurses without looping keeps the bit armed (and SignalSender busy) until it reaches a loop header or returns to the idle loop. If you would rather keep prologue servicing on the strength of the tightened predicate, it is a one-line change back.

Comment thread Source/JavaScriptCore/runtime/VM.h Outdated
CONCURRENT_SAFE void notifyNeedDebuggerBreak() { traps().fireTrap(VMTraps::NeedDebuggerBreak); }
#if USE(BUN_JSC_ADDITIONS)
// Invoked from VMTraps::handleTraps for NeedDebuggerBreak after
// invalidateCodeBlocksOnStack, on the VM's owning thread at a safe point.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

blocking: the wasm IPInt prologue is another caller where "safe point" doesn't hold. check_stack_and_vm_traps (WasmIPIntSlowPaths.cpp:1445-1461) goes straight to handleTrapsIfNeeded() with no frame tracer, and neither InPlaceInterpreter.asm nor the JS to wasm stub stores topCallFrame first, unlike every JS service site (LLINT_BEGIN, JITOperationPrologueCallFrameTracer at JITOperations.cpp:2979). So both the existing invalidateCodeBlocksOnStack(vm.topCallFrame) and the callback's breakProgram() walk a stale pointer. Upstream gets away with it because its handler never re-enters.

Either put a NativeCallFrameTracer on callFrame before handleTrapsIfNeeded there (callFrame is already trusted by setPrologueStopData on the line above), or mask NeedDebuggerBreak out of that site and say so in this comment.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done: the IPInt site is masked the same way, with the reason in a comment there and in the VM.h contract. Adding a frame tracer would still have left breakProgram() building a DebuggerCallFrame on a wasm top frame, which nothing here exercises, so masking seemed the honest option.

@@ -484,6 +484,10 @@ bool VMTraps::handleTraps(VMTraps::BitField mask)
switch (event) {
case NeedDebuggerBreak:
invalidateCodeBlocksOnStack(vm.topCallFrame);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

blocking: on !ENABLE(SIGNAL_BASED_VM_TRAPS) builds (Windows, since it has no HAVE_MACHINE_CONTEXT, PlatformHave.h:235 / PlatformEnable.h:986) and anywhere usePollingTraps is on, this line is {} (VMTraps.h:315-318) and DFG emits CheckTraps rather than InvalidationPoint (DFGByteCodeParser.cpp:7437-7440), modelled as touching only internal state. So the callback runs Runtime.evaluate or a whole nested pause loop from inside a live DFG/FTL frame and then returns into it with its hoisted checks intact. bun#37336 advertises _debugProcess on Windows so this is a shipping config.

invalidateCodeBlocksOnStack has no signal dependency in its body, so I'd un-gate it, and in polling mode either follow CheckTraps with an InvalidationPoint in handleCheckTraps or treat CheckTraps as clobbering the world under BUN_JSC_ADDITIONS. Until then the callback should only ever be reached from LLInt/baseline/C++ frames.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done: invalidateCodeBlocksOnStack is built whenever signals or BUN_JSC_ADDITIONS are on, handleDebuggerBreak() jettisons the stack unconditionally under Options::usePollingTraps() (which Options.cpp forces on for !SIGNAL_BASED_VM_TRAPS and no-JIT), and handleCheckTraps emits an InvalidationPoint after CheckTraps for linked plans so the frame exits right after the poll. I went with the invalidation point rather than making CheckTraps clobber the world, since the latter would be paid by every loop on Windows whether or not a debugger is involved.

case NeedDebuggerBreak:
invalidateCodeBlocksOnStack(vm.topCallFrame);
#if USE(BUN_JSC_ADDITIONS)
if (auto callback = vm.debuggerTrapCallback())

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

blocking: this is the first trap handler that re-enters the VM and it has no guard for that. takeTopPriorityTrap clears the bit with clearTrapWithoutCancellingThreadStop (:470) and the thread stop is only cancelled at scope exit (:476), so if the debugger thread re-fires NeedDebuggerBreak while callback(vm) is running (bun does this per queued CDP message), any JS the callback runs polls the bit at its next loop hint or prologue and recurses into the callback mid-dispatch. Message N+1 gets dispatched inside message N's BackendDispatcher::dispatch, and a Debugger.pause can land inside an injected-script evaluation.

It also has no termination or exception bracket. Interpreter::debug (Interpreter.cpp:1771) and pauseIfNeeded (Debugger.cpp:1069, :1213) both wrap debugger UI entry in DeferTermination plus assert-no-exception before and after. Here a termination that arrives during the CDP drain is swallowed, a stray exception is rethrown at whatever op_check_traps comes next (or trips throwScope.assertNoException() at LLIntSlowPaths.cpp:571 in debug), and under validateExceptionChecks a second callback in the same handleTraps loop asserts deterministically. The consumer's tests strip BUN_JSC_validateExceptionChecks from the inspectee which is why nobody has seen it.

Suggest: track "inside debugger trap callback" and mask &= ~NeedDebuggerBreak on entry when it's set (same shape as the isDeferringTermination() handling at :450) so a re-fire is coalesced by the outer while loop after return; wrap the call in { DeferTerminationForAWhile defer(vm); callback(vm); } followed by releaseAssertNoExceptionExceptTermination; and write the contract on setDebuggerTrapCallback (entered with no exception pending, must return that way, not re-entered).

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done: VMTraps::handleDebuggerBreak() sets m_isHandlingDebuggerBreak, which handleTraps() uses to mask the bit on entry (same shape as the termination deferral), wraps the call in DeferTerminationForAWhile, and releaseAssertNoExceptionExceptTermination()s afterwards. The contract is on setDebuggerTrapCallback. The consumer keeps a guard of its own as well, and I am looking at whether the InjectedScript exception-check gaps are small enough to fix here so the inspectee can run with validateExceptionChecks on (see the bun PR).

// Code compiled without CodeGenerationMode::Debugger may have its scope
// register repurposed by DFGStackLayoutPhase (needsScopeRegister() is
// false), leaving stale data in the slot. Fall through to callee->scope().
else if (codeBlock && codeBlock->scopeRegister().isValid() && codeBlock->wasCompiledWithDebuggingOpcodes())

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should fix: the comment names the wrong mechanism and the predicate is still not tight enough. DFGStackLayoutPhase.cpp:169-170 sets the DFG CodeBlock's scopeRegister to invalid when needsScopeRegister() is false, so optimizing frames already fell through to callee->scope() before this change. What wasCompiledWithDebuggingOpcodes() is really standing in for is bytecode liveness: BytecodeUseDef.h:42-43 only keeps the scope register live (and only after op_enter) when the block has debug opcodes, so a baseline frame reached by OSR exit from non-debug DFG has jsUndefined or worse in that slot. That still leaves the bc#0 prologue case from the VMTraps comment, where the block does have debug opcodes but op_enter hasn't run.

ShadowChicken.cpp:336-342 walks the same frames one call earlier with a third rule (reads the slot as a JSValue, checks bytecodeIndex(), RELEASE_ASSERTs the type). I'd pull one helper both use that restates BytecodeUseDef faithfully: codeBlock && wasCompiledWithDebuggingOpcodes() && scopeRegister().isValid() && bytecodeIndex() is past op_enter, read as JSValue, dynamicDowncast, null falls through to callee->scope(). And reword the comment to name the two real hazards (dead scope local after OSR exit from non-debug DFG, trap serviced before op_enter).

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done: CallFrame::scopeIfScopeRegisterIsLive() is the shared helper (debugging opcodes, valid register, offset past op_enter, read as JSValue, dynamicDowncast<JSScope>, null falls through), used by both DebuggerCallFrame::scope() and ShadowChicken::update(); the comment now describes the OSR-exit and prologue cases.

// Enumerate source providers for scripts already loaded before the
// debugger attached and replay sourceParsed for each so observers
// (e.g. InspectorDebuggerAgent) can send scriptParsed events.
if (!canDispatchFunctionToObservers())

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

consider: this early return isn't gated and isn't behaviour-neutral for the rest of the tree. sourceParsed is virtual and WebKitLegacy's WebScriptDebugger (WebScriptDebugger.mm:64-119) overrides it and calls attach() with zero observers, so its replay to WebScriptDebugDelegate silently stops happening. Doesn't matter for bun but it's the kind of thing that bites on the next upstream merge. Goes away if the attach hunk above is reverted.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gone along with the attach hunk.

Comment thread Source/JavaScriptCore/runtime/VM.h Outdated

CONCURRENT_SAFE void notifyNeedDebuggerBreak() { traps().fireTrap(VMTraps::NeedDebuggerBreak); }
#if USE(BUN_JSC_ADDITIONS)
// Invoked from VMTraps::handleTraps for NeedDebuggerBreak after

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should fix: running the callback after an unconditional whole-stack jettison means the app deopts on every CDP batch even when nothing pauses. requestThreadStopIfNeeded sets m_needToInvalidateCodeBlocks on every idle to pending transition (VMTraps.cpp:404) and invalidateCodeBlocksOnStack (:180-196) then jettisons every optimizing block on the stack. Upstream fires this once per inspector open; bun fires it per inbound CDP frame, so console autocomplete or getProperties against a busy target is a steady deopt storm, and the pause path already jettisons heap-wide through setSteppingMode anyway.

Under BUN_JSC_ADDITIONS, when a callback is installed I'd skip the pre-walk and let the callback decide, or make the signature bool (*)(VM&) and only invalidate when it returns true. Keep upstream behaviour when the callback is null.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, split by trap mode: with a callback installed and signal based traps the pre-walk is skipped entirely (m_needToInvalidateCodeBlocks is just cleared), since the frame that was running optimized code has already exited via the breakpoints and the rest are at calls; with polling traps it has to happen for the reason in your other comment, so there it is unconditional. Null callback keeps upstream behaviour. The consumer also stops firing when its queue was already non-empty.

// Invoked from VMTraps::handleTraps for NeedDebuggerBreak after
// invalidateCodeBlocksOnStack, on the VM's owning thread at a safe point.
using DebuggerTrapCallback = void (*)(VM&);
CONCURRENT_SAFE void setDebuggerTrapCallback(DebuggerTrapCallback cb) { m_debuggerTrapCallback.store(cb, std::memory_order_release); }

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should fix (doc + consumer): nothing retires NeedDebuggerBreak for an embedder that is idle while holding the API lock. handleTraps is only reached from bytecode poll sites, C++ VM entry masks the bit out (NonDebuggerAsyncEvents, VM.cpp:1078-1082), and there is no other clearTrap(NeedDebuggerBreak). bun's idle path uses its own ACTIVATION_REQUESTED flag and never touches vm.traps(), and connect/disconnect re-fire the trap after activation, so on a mostly idle server (the headline kill -USR1 case) SignalSender keeps suspending and resuming the main thread every 1ms until some unrelated JS happens to run. The tests' idle fixture ticks a 1s timer which hides it.

Worth a line here saying an embedder that idles with the lock held must call vm.traps().handleTrapsIfNeeded(VMTraps::NeedDebuggerBreak) from its idle loop, and then bun can replace its flag re-check with exactly that so idle and busy activation share one path.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done: documented on the setter, and bun's idle path becomes traps().handleTrapsIfNeeded(NeedDebuggerBreak), driven off a flag every firing site sets, so connect/disconnect/message delivery on a quiet process retire the bit too.

#if USE(BUN_JSC_ADDITIONS)
// Invoked from VMTraps::handleTraps for NeedDebuggerBreak after
// invalidateCodeBlocksOnStack, on the VM's owning thread at a safe point.
using DebuggerTrapCallback = void (*)(VM&);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should fix (doc): say what this does and doesn't buy. emitDebugHook is gated on CodeGenerationMode::Debugger (BytecodeGenerator.cpp:4152-4155), the only recompile is whenIdle (VM.cpp:1041-1050, RELEASE_ASSERT(!vm.entryScope) in Heap), and attach doesn't recompile. So for code that was already running at activation you get pause and evaluate, but not breakpoints, debugger; or stepping until that code is re-entered after an idle. In the headline while(true){} case idle never comes, setBreakpointByUrl reports resolved and never fires. Neither this comment, the PR text nor bun's docs ("matches Node.js") mention it.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done: the comment on the setter now says it buys pause and evaluate in running code, and that breakpoints, debugger and stepping wait for re-entry after an idle; bun's docs get the same paragraph.

void clearBlackbox();

bool isPaused() const { return m_isPaused; }
bool isStepping() const { return m_steppingMode == SteppingModeEnabled; }

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should fix: stepping from a trap-initiated pause in op_debug-less code never completes and leaves stepping armed. stepOverStatement/stepOutOfFunction/stepNextExpression (Debugger.cpp:991-1031) arm m_pauseOnCallFrame as a raw CallFrame* that only the op_debug-driven return/didExecuteProgram events (:1430-1486) or unwind (:1509) ever clear, and the paused frame has no op_debug and won't get any until idle. So Step quietly becomes Continue, m_steppingMode stays Enabled (blocks DFG tier-up realm-wide), and the stale pointer can address-match some later frame. If the last frontend then disconnects from inside the callback, detach() runs unpaused and its only step cleanup is under m_isPaused (:244-248), while clearDebuggerRequests zeroes the per-block flags; on reconnect setSteppingMode(Enabled) is a no-op and step-over/into behave as resume until a plain pause+continue resets things.

Under BUN_JSC_ADDITIONS I'd have the step entry points notice m_currentCallFrame && codeBlock && !codeBlock->wasCompiledWithDebuggingOpcodes() and degrade to schedulePauseAtNextOpportunity() (the consumer re-fires the trap after runWhilePaused returns), and have detach reset m_steppingMode / immediate and eventual pause state / m_currentCallFrame when m_globalObjects empties regardless of m_isPaused.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done: next/over/out on a frame without debugging opcodes forward to stepIntoStatement() (so only m_pauseAtNextOpportunity is armed), and detach() resets m_currentCallFrame, the immediate/eventual/async pause state and m_steppingMode once m_globalObjects empties, regardless of m_isPaused. Pinned on the bun side by the stepInto-then-unrelated-message test and a reconnect-after-step test.

Lets an embedder service the NeedDebuggerBreak trap itself, so a debugger
can be attached to a program that is already running and breakProgram()
entered from code that was compiled without op_debug sites (bun uses this
for SIGUSR1 / process._debugProcess). Recompiling with debug opcodes is not
an alternative: code generation mode changes apply when the VM goes idle,
and already linked functions keep their bytecode.

VMTraps::handleDebuggerBreak() runs the callback with termination deferred
(re-fired as a trap afterwards), asserts it leaves no exception behind, and
masks NeedDebuggerBreak for the duration so JS run by the callback cannot
nest another invocation; a re-fire is taken by the same handleTraps() loop
once the callback returns. With signal based traps the whole-stack
invalidation is skipped when a callback is installed (the frame that was
running optimized code has already left it, the rest are parked at calls),
since bun fires this trap once per CDP batch. With polling traps the
callback can run inside a live DFG/FTL frame, so the stack is jettisoned
unconditionally (invalidateCodeBlocksOnStack is now built in those
configurations too) and the DFG emits an InvalidationPoint after each
CheckTraps so that frame exits on return instead of continuing with
hoisted state.

The LLInt and IPInt prologue stack checks only service
NonDebuggerAsyncEvents: the JS frame there is at bytecode 0 with op_enter
still to run, and the wasm path has not stored topCallFrame, so neither can
be paused on. The bit is taken at op_check_traps instead, or by the
embedder calling handleTrapsIfNeeded(NeedDebuggerBreak) from its idle loop,
which the VM.h comment now requires together with the rest of the contract
and the limitations (pause and evaluate only, until running code is
re-entered).

DebuggerCallFrame::scope() and ShadowChicken::update() share
CallFrame::scopeIfScopeRegisterIsLive(), which only reads the scope
register under the conditions BytecodeUseDef keeps it live: debugging
opcodes, a valid register, and a bytecode offset past op_enter. A pause
entered through a trap can otherwise sit on a frame whose slot is dead
(baseline reached by OSR exit from non-debug DFG) or unwritten.

Step next/over/out issued while paused on a frame without debugging
opcodes degrade to pausing at the next opportunity, since the hooks that
would complete them never run on that frame, and Debugger::detach() resets
the stepping and pause state once the last global object detaches, so a
frontend disconnecting in that state does not leave the next one with
stepping mode stuck on.

JSGlobalObjectInspectorController::disconnectFrontend() tears the agents
down only when the last frontend goes away, so a second frontend keeps
working after the first disconnects.
@robobun
robobun force-pushed the robobun/debugger-trap-callback branch from d55f967 to 1f04bd0 Compare August 11, 2026 02:19
@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

Comment on lines +7441 to +7447
addToGraph(CheckTraps);
// With polling traps the debugger trap callback (VMTraps::handleDebuggerBreak) runs
// arbitrary JS from inside this frame and jettisons the code blocks on the stack before
// returning; this is where the frame then leaves, instead of carrying on with whatever
// it had hoisted above the loop.
if (!m_graph.m_plan.isUnlinked())
addToGraph(InvalidationPoint);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 The InvalidationPoint emitted immediately after CheckTraps gets origin.exitOK=false (because CheckTraps writes InternalState and so clobbers exit state), while mayExit(InvalidationPoint)==Exits — this fails the DFGValidate.cpp:225 assertion on the very first DFG compilation whenever usePollingTraps() is on and asserts are enabled (Windows debug builds, or any debug build with JSC_usePollingTraps=1). Insert m_exitOK = true; addToGraph(ExitOK); between the two addToGraph calls, mirroring the emitExitOK() pattern at DFGByteCodeParser.cpp:7979.

Extended reasoning...

What the bug is

Under USE(BUN_JSC_ADDITIONS), the new handleCheckTraps() branch for Options::usePollingTraps() && !m_graph.m_plan.isUnlinked() emits two nodes back-to-back:

addToGraph(CheckTraps);
// ...
if (!m_graph.m_plan.isUnlinked())
    addToGraph(InvalidationPoint);

CheckTraps clobbers exit state, so the InvalidationPoint is created with origin.exitOK == false. But InvalidationPoint may exit, and DFG graph validation asserts that no node with mayExit() == Exits may have exitOK == false. The assertion fires immediately after bytecode parsing on every function that tiers up to DFG, before any optimization phase runs.

The specific code path

  1. CheckTraps writes InternalState — DFGClobberize.h:621-624 has case CheckTraps: read(InternalState); write(InternalState); return;.
  2. That counts as clobbering exit state — DFGClobbersExitState.cpp has no explicit case for CheckTraps, so it falls through to the default at :113-127, which returns true for any write to a heap other than SideState or HeapObjectCount. InternalState (DFGAbstractHeap.h:85) is neither.
  3. addToGraph() therefore clears m_exitOK — DFGByteCodeParser.cpp:871-872: if (clobbersExitState(m_graph, node)) m_exitOK = false; runs right after appending CheckTraps.
  4. The next addToGraph(InvalidationPoint) inherits thatcurrentNodeOrigin() (DFGByteCodeParser.cpp:842-848) constructs NodeOrigin(..., m_exitOK), so the InvalidationPoint at line 7447 gets origin.exitOK = false.
  5. mayExit(InvalidationPoint) == Exits — DFGMayExit.cpp has no case for InvalidationPoint; it hits the default return Exits; at :449-451.
  6. Validation fails — DFGValidate.cpp:225: VALIDATE((node), !(mayExit(m_graph, node) == Exits && !node->origin.exitOK)); fires. validate(dfg) runs immediately after parsing (DFGPlan.cpp:232-233) whenever validationEnabled(), which is unconditionally true under ASSERT_ENABLED (DFGCommon.h:88-94).

Step-by-step proof

Consider a Windows debug build (or any debug build run with JSC_usePollingTraps=1):

  1. !ENABLE(SIGNAL_BASED_VM_TRAPS) on Windows (PlatformHave.h:235 / PlatformEnable.h:986), so Options.cpp:650-652 forces Options::usePollingTraps() = true.
  2. Any function f runs enough to tier up. DFG bytecode parsing begins for the linked plan (isUnlinked() is only true under Options::forceUnlinkedDFG(), off by default).
  3. Parsing reaches op_enter (DFGByteCodeParser.cpp:7981) → handleCheckTraps(). The usePollingTraps() && !isUnlinked() branch is taken.
  4. addToGraph(CheckTraps) runs: node appended, clobbersExitState returns true, m_exitOK is set to false.
  5. addToGraph(InvalidationPoint) runs: currentNodeOrigin() returns an origin with exitOK = false; the node is appended with that origin.
  6. Parsing completes; DFGPlan::compileInThreadImpl calls validate(dfg) at :232-233.
  7. Validate::validate() reaches the InvalidationPoint: mayExit(m_graph, node) == Exits is true, node->origin.exitOK is false → VALIDATE fails → dataLogs the graph and calls CRASH().

The same happens at every op_check_traps (loop headers, DFGByteCodeParser.cpp:10492), so even if op_enter were somehow avoided, any loop would hit it.

Why nothing prevents it

The upstream (#else) branch emits either CheckTraps or InvalidationPoint, never both, so there is no exit-state ordering between them. The other place that emits back-to-back state-clobbering nodes before handleCheckTraps() — the op_enter handler — explicitly calls emitExitOK() (DFGByteCodeParser.cpp:7977-7979) to reset m_exitOK before the trap check. The new code does not.

Impact

  • Debug/asserts-on builds with polling traps are unusable: the compiler thread crashes on the first DFG tier-up. Windows is a shipping config for Bun (the PR description and alii's review both call it out), and robobun's reply on the attach() thread says the plan is to run "the runtime-inspector suite against the debug (asserts-on) build" — that run will crash immediately.
  • Release builds are unaffected at runtime: exitOK is a compile-time invariant only enforced by validate(). The OSR exit itself would be sound (re-executing op_check_traps after the trap bit is cleared is idempotent), so no miscompilation occurs when validation is off.

Fix

Insert an ExitOK reset between the two nodes, exactly as op_enter does before calling handleCheckTraps():

addToGraph(CheckTraps);
if (!m_graph.m_plan.isUnlinked()) {
    // CheckTraps wrote InternalState; re-executing the trap poll on OSR exit is idempotent.
    m_exitOK = true;
    addToGraph(ExitOK);
    addToGraph(InvalidationPoint);
}

(or equivalently emitExitOK(); — DFGByteCodeParser.cpp:372-375). The ExitOK node satisfies both DFGValidate.cpp:225 and the previous-node check at :229-234.

Comment on lines +577 to +585
auto scope = DECLARE_THROW_SCOPE(vm);
{
SetForScope handling(m_isHandlingDebuggerBreak, true);
// A termination that arrives while the debugger is being serviced is re-fired as a trap
// when this scope ends and taken by the handleTraps() loop that called us.
DeferTerminationForAWhile deferTermination(vm);
callback(vm);
}
scope.releaseAssertNoExceptionExceptTermination();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Under JSC_validateExceptionChecks=1, a second handleDebuggerBreak() in the same handleTraps() loop still trips verifyExceptionCheckNeedIsSatisfied: releaseAssertNoExceptionExceptTermination() clears m_needExceptionCheck but does not release() the scope, so ~ThrowScope() re-simulates a throw and the next iteration's DECLARE_THROW_SCOPE asserts. Add scope.release() before the assert (or assert on the outer handleTraps() scope after the switch case instead of declaring a nested one here). This is opt-in-debug-only, but it is the exact "second callback in the same handleTraps loop" case @alii called out.

Extended reasoning...

What the bug is

handleDebuggerBreak() ends with:

auto scope = DECLARE_THROW_SCOPE(vm);
{
    SetForScope handling(m_isHandlingDebuggerBreak, true);
    DeferTerminationForAWhile deferTermination(vm);
    callback(vm);
}
scope.releaseAssertNoExceptionExceptTermination();

releaseAssertNoExceptionExceptTermination() (ExceptionScope.h:64) reads exception()VM::exception(), which clears m_needExceptionCheck — but it does not set m_isReleased. The ThrowScope destructor (ThrowScope.cpp:44–72) then runs with m_isReleased == false, passes its own verifyExceptionCheckNeedIsSatisfied() (the flag was just cleared), computes previousScopeStackPosition = &S_ht (the outer scope in handleTraps()), finds previousScopeStackPosition > topEntryFrame false (S_ht sits below topEntryFrame when reached from op_check_traps; topEntryFrame is null when reached from the embedder idle loop), so willBeHandleByLLIntOrJIT = false and simulateThrow() sets m_needExceptionCheck = true again.

If NeedDebuggerBreak was re-fired while callback(vm) was running — the coalescing behaviour this PR explicitly relies on (VM.h: "a NeedDebuggerBreak fired while it runs is taken again once it returns") — the outer while (needHandling(mask)) loop calls handleDebuggerBreak() again. Nothing between the destructor and the second DECLARE_THROW_SCOPE reads vm.exception(), so the second ThrowScope constructor hits verifyExceptionCheckNeedIsSatisfied() with m_needExceptionCheck == true and RELEASE_ASSERT_NOT_REACHED_WITH_MESSAGE("...") fires.

Step-by-step trace

  1. op_check_trapsoperationHandleTrapshandleTraps(). Outer auto scope = DECLARE_THROW_SCOPE(vm) (call it S_ht).
  2. Loop takes NeedDebuggerBreakhandleDebuggerBreak(). Inner DECLARE_THROW_SCOPE (S_hdb); its ctor's verifyExceptionCheckNeedIsSatisfied() passes.
  3. callback(vm) runs. During it another thread re-fires NeedDebuggerBreak (bun does this per queued CDP message; nested handleTraps() masks the bit via m_isHandlingDebuggerBreak so it stays set).
  4. scope.releaseAssertNoExceptionExceptTermination() calls VM::exception()m_needExceptionCheck = false.
  5. S_hdb destructs. m_isReleased == false → verify passes (flag cleared in step 4). previousScopeStackPosition = &S_ht; on a downward-growing stack S_ht is at a lower address than vm.topEntryFrame (or topEntryFrame is null from the idle loop), so willBeHandleByLLIntOrJIT = falsesimulateThrow()m_needExceptionCheck = true.
  6. Back in handleTraps(). needHandling(mask) (atomic load), takeTopPriorityTrap() (lock + bit ops) → NeedDebuggerBreak again → handleDebuggerBreak().
  7. Inside: vm.debuggerTrapCallback() (atomic load), Options::usePollingTraps() branch (Locker / jettisonOptimizedCodeBlocksOnStack — no exception scopes), then DECLARE_THROW_SCOPE(vm). Ctor → verifyExceptionCheckNeedIsSatisfied() sees m_needExceptionCheck == true → RELEASE_ASSERT.

Why the added ThrowScope doesn't close it

releaseAssertNoExceptionExceptTermination() clears the flag from any simulated throw inside callback(vm), but S_hdb's own destructor immediately re-simulates, and the outer handleTraps() loop never reads scope.exception() between iterations. Pre-PR the NeedDebuggerBreak handler had no nested ThrowScope at all, so this two-in-one-loop path is new to this PR — and is exactly the path the PR relies on for coalescing re-fires.

Impact

Only fires under Options::validateExceptionChecks() (off by default), so no production impact — it's a false positive in the validation machinery, not a real exception-handling bug. But it is the precise scenario @alii flagged ("under validateExceptionChecks a second callback in the same handleTraps loop asserts deterministically"), and robobun's reply says the goal is for the inspectee to run with validateExceptionChecks on, so leaving this in means that goal still can't be met on the coalescing path.

Fix

Easiest: add scope.release(); immediately before scope.releaseAssertNoExceptionExceptTermination(); — the destructor's m_isReleased branch then clears m_needExceptionCheck instead of verifying, and the outer S_ht is what the destructor's previousScope check considers anyway. Alternatively, drop the nested ThrowScope entirely and instead call scope.assertNoExceptionExceptTermination() on the outer handleTraps() scope right after handleDebuggerBreak() returns in the switch case.

Comment on lines +1050 to +1055
#if USE(BUN_JSC_ADDITIONS)
if (!currentCallFrameCanCompleteStep()) {
stepIntoStatement();
return;
}
#endif

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 currentCallFrameCanCompleteStep() inspects m_currentCallFrame, but stepOutOfFunction() arms m_pauseOnCallFrame at the caller — so when paused in a freshly-compiled callee H (has debug opcodes) whose caller F was compiled pre-attach (no debug opcodes), the guard passes and step-out targets F, which has no op_debug hooks to complete it. The step silently acts as continue, m_pauseOnCallFrame dangles once F returns, and m_steppingMode stays enabled. returnEvent()'s step-over→step-out retargeting (m_pauseOnCallFrame = callerFrame) has the same gap. Consider also checking the target frame's wasCompiledWithDebuggingOpcodes() and degrading to m_pauseAtNextOpportunity when it lacks them.

Extended reasoning...

What the guard checks vs. what it needs to check

currentCallFrameCanCompleteStep() (Debugger.cpp:993-998) tests whether m_currentCallFrame->codeBlock()->wasCompiledWithDebuggingOpcodes() is true. That is the correct question for stepNextExpression() and stepOverStatement(), which set m_pauseOnCallFrame = m_currentCallFrame — the frame being checked is the frame being targeted. But stepOutOfFunction() (Debugger.cpp:1057) sets m_pauseOnCallFrame = m_currentCallFrame->callerFrame(topEntryFrame): the target is one frame up. The guard says nothing about whether that caller can complete a step.

Why the mixed-opcode stack is reachable

This PR exists precisely to enable it. When the trap callback runs attach()setBreakpointsActivated(true), hasInteractiveDebugger() flips immediately, so ScriptExecutable::defaultCodeGenerationMode() starts adding CodeGenerationMode::Debugger to any function compiled after attach. Meanwhile VM::deleteAllCode() defers via whenIdle, so already-linked functions on the stack keep their non-debug bytecode until the VM goes idle. During that window the stack is mixed: pre-attach frames have no op_debug, freshly-compiled callees do.

Step-by-step trace

  1. Trap-attach while F (compiled pre-attach, no debug opcodes) is running. breakProgram() pauses in F.
  2. User step-into. stepIntoStatement() sets m_pauseAtNextOpportunity = true. F resumes; F has no op_debug, so nothing fires until F calls a function H that is freshly compiled (with debug opcodes). H's callEvent/atStatement fires → pauseIfNeeded sees m_pauseAtNextOpportunity → pause in H. m_currentCallFrame = H.
  3. User step-out. currentCallFrameCanCompleteStep() reads H's code block → wasCompiledWithDebuggingOpcodes() == true → returns true. Guard bypassed. stepOutOfFunction() sets m_pauseOnCallFrame = H->callerFrame() = F, m_pauseOnStepOut = true.
  4. H resumes and returns. returnEvent(H) fires (H has op_debug). First updateCallFrame(H, AttemptPause)pauseIfNeeded sees m_pauseOnCallFrame(F) != m_currentCallFrame(H) and m_pauseAtNextOpportunity == false → no pause. Then if (m_currentCallFrame == m_pauseOnCallFrame) is H == F → false, so no retargeting. Second updateCallFrame(F, NoPause) sets m_currentCallFrame = F.
  5. Control returns to F. F has no op_debug, so atStatement/atExpression/returnEvent never fire on F. pauseIfNeeded is never entered with m_currentCallFrame == F == m_pauseOnCallFrame. The step-out never completes; m_pauseOnCallFrame dangles once F's stack slot is reused (can address-match a later frame → spurious pause); m_steppingMode stays Enabled (blocking DFG tier-up realm-wide) until detach().

The second site: returnEvent()'s retarget

returnEvent() (Debugger.cpp:~1459-1462) does if (m_currentCallFrame == m_pauseOnCallFrame) { m_pauseOnCallFrame = callerFrame; m_pauseOnStepOut = true; } — treating step-over-off-the-end as step-out. If the user step-over in H instead, m_pauseOnCallFrame = H; when H returns, this branch retargets m_pauseOnCallFrame to callerFrame = F without checking whether F has debug opcodes. Same failure mode. unwindEvent() has the analogous retarget.

Impact and why this is a nit

The user-visible symptom is "step-out silently acts as continue" plus stuck stepping mode and a possible later spurious pause — a UX degradation in a specific interaction sequence, not a crash or data loss. The PR already documents on setDebuggerTrapCallback that stepping in trap-attached code is degraded and only works fully after re-entry post-idle, so user expectations are set. The detach() cleanup this PR adds (Debugger.cpp:242-257) resets m_pauseOnCallFrame/m_steppingMode on last-frontend disconnect, bounding the stale-state lifetime. The reviewer classified the parent issue as "should fix", not blocking, and the guard closes the primary case (stepping from a non-debug frame); this is the transitive case (stepping out into one).

Suggested fix

In stepOutOfFunction(), after computing the caller, check it too:

CallFrame* caller = m_currentCallFrame ? m_currentCallFrame->callerFrame(topEntryFrame) : nullptr;
if (caller && !caller->isNativeCalleeFrame()) {
    if (CodeBlock* cb = caller->codeBlock(); cb && !cb->wasCompiledWithDebuggingOpcodes()) {
        stepIntoStatement();
        return;
    }
}
m_pauseOnCallFrame = caller;

And in returnEvent() / unwindEvent(), when retargeting m_pauseOnCallFrame = callerFrame, apply the same check on callerFrame and set m_pauseAtNextOpportunity = true instead when it lacks debug opcodes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants