Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 49 additions & 1 deletion Source/JavaScriptCore/debugger/Debugger.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -239,8 +239,22 @@

globalObject->setDebugger(nullptr);

if (m_globalObjects.isEmpty())
if (m_globalObjects.isEmpty()) {
clearParsedData();
#if USE(BUN_JSC_ADDITIONS)
// A pause started from a VM trap can be stepped out of into code that has no
// debug opcodes, so the step never completes and the frontend may disconnect
// (detaching us) while it is still armed. Nothing above runs in that case because
// we are not paused, so drop the stale state here: clearDebuggerRequests() has
// already reset the per-CodeBlock stepping flags, and a later attach() followed
// by setSteppingMode(SteppingModeEnabled) must not be a no-op.
m_currentCallFrame = nullptr;
resetImmediatePauseState();
resetEventualPauseState();
resetAsyncPauseState();
m_steppingMode = SteppingModeDisabled;
#endif
}
}

bool Debugger::isAttached(JSGlobalObject* globalObject)
Expand Down Expand Up @@ -969,11 +983,33 @@
m_doneProcessingDebuggerEvents = true;
}

#if USE(BUN_JSC_ADDITIONS)
// breakProgram() can be entered from a VM trap while the paused frame is running code that
// was compiled before the debugger attached. Such a frame has no op_debug sites, so the
// atStatement / returnEvent hooks that complete a step-next/over/out targeted at it never
// run: the step would silently act as "continue" while leaving m_pauseOnCallFrame pointing at
// a frame that is going away. Pausing at the next opportunity instead (the behaviour of
// step-into) completes as soon as any code with debug opcodes runs and leaves nothing armed.
bool Debugger::currentCallFrameCanCompleteStep() const
{
if (!m_currentCallFrame || m_currentCallFrame->isNativeCalleeFrame())
return true;
CodeBlock* codeBlock = m_currentCallFrame->codeBlock();
return !codeBlock || codeBlock->wasCompiledWithDebuggingOpcodes();
}
#endif

void Debugger::stepNextExpression()
{
if (!m_isPaused)
return;

#if USE(BUN_JSC_ADDITIONS)
if (!currentCallFrameCanCompleteStep()) {
stepIntoStatement();
return;
}
#endif
m_pauseOnCallFrame = m_currentCallFrame;
m_pauseOnStepNext = true;
setSteppingMode(SteppingModeEnabled);
Expand All @@ -995,6 +1031,12 @@
if (!m_isPaused)
return;

#if USE(BUN_JSC_ADDITIONS)
if (!currentCallFrameCanCompleteStep()) {
stepIntoStatement();
return;
}
#endif
m_pauseOnCallFrame = m_currentCallFrame;
setSteppingMode(SteppingModeEnabled);
m_doneProcessingDebuggerEvents = true;
Expand All @@ -1005,6 +1047,12 @@
if (!m_isPaused)
return;

#if USE(BUN_JSC_ADDITIONS)
if (!currentCallFrameCanCompleteStep()) {
stepIntoStatement();
return;
}
#endif

Check warning on line 1055 in Source/JavaScriptCore/debugger/Debugger.cpp

View check run for this annotation

Claude / Claude Code Review

currentCallFrameCanCompleteStep() checks the current frame, not the frame step-out targets

`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-o
Comment on lines +1050 to +1055

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.

EntryFrame* topEntryFrame = m_vm.topEntryFrame;
m_pauseOnCallFrame = m_currentCallFrame ? m_currentCallFrame->callerFrame(topEntryFrame) : nullptr;
m_pauseOnStepOut = true;
Expand Down
3 changes: 3 additions & 0 deletions Source/JavaScriptCore/debugger/Debugger.h
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,9 @@ class Debugger : public DoublyLinkedListNode<Debugger> {
void resetImmediatePauseState();
void NODELETE resetEventualPauseState();
void resetAsyncPauseState();
#if USE(BUN_JSC_ADDITIONS)
bool currentCallFrameCanCompleteStep() const;
#endif

enum SteppingMode {
SteppingModeDisabled,
Expand Down
10 changes: 10 additions & 0 deletions Source/JavaScriptCore/debugger/DebuggerCallFrame.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -150,11 +150,21 @@ DebuggerScope* DebuggerCallFrame::scope(VM& vm)

if (!m_scope) {
JSScope* scope;
#if !USE(BUN_JSC_ADDITIONS)
CodeBlock* codeBlock = m_validMachineFrame->isNativeCalleeFrame() ? nullptr : m_validMachineFrame->codeBlock();
#endif
if (isTailDeleted())
scope = m_shadowChickenFrame.scope;
#if USE(BUN_JSC_ADDITIONS)
// A pause entered through a VM trap can sit on frames whose scope register is not live
// (see CallFrame::scopeIfScopeRegisterIsLive); the callee's scope is the answer there,
// as it is for any frame of code compiled without debugging opcodes.
else if (JSScope* liveScope = m_validMachineFrame->scopeIfScopeRegisterIsLive())
scope = liveScope;
#else
else if (codeBlock && codeBlock->scopeRegister().isValid())
scope = m_validMachineFrame->scope(codeBlock->scopeRegister().offset());
#endif
else if (JSCallee* callee = dynamicDowncast<JSCallee>(m_validMachineFrame->jsCallee()))
scope = callee->scope();
else
Expand Down
14 changes: 14 additions & 0 deletions Source/JavaScriptCore/dfg/DFGByteCodeParser.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -7436,7 +7436,21 @@

void ByteCodeParser::handleCheckTraps()
{
#if USE(BUN_JSC_ADDITIONS)
if (Options::usePollingTraps() || m_graph.m_plan.isUnlinked()) {
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);

Check failure on line 7447 in Source/JavaScriptCore/dfg/DFGByteCodeParser.cpp

View check run for this annotation

Claude / Claude Code Review

InvalidationPoint after CheckTraps has exitOK=false, fails DFG validation

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` call
Comment on lines +7441 to +7447

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.

return;
}
addToGraph(InvalidationPoint);
#else
addToGraph((Options::usePollingTraps() || m_graph.m_plan.isUnlinked()) ? CheckTraps : InvalidationPoint);
#endif
}

void ByteCodeParser::emitPutById(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -149,9 +149,6 @@ void JSGlobalObjectInspectorController::connectFrontend(FrontendChannel& fronten

void JSGlobalObjectInspectorController::disconnectFrontend(FrontendChannel& frontendChannel)
{
// FIXME: change this to notify agents which frontend has disconnected (by id).
m_agents.willDestroyFrontendAndBackend(DisconnectReason::InspectorDestroyed);

m_frontendRouter->disconnectFrontend(frontendChannel);

m_isAutomaticInspection = false;
Expand All @@ -161,6 +158,9 @@ void JSGlobalObjectInspectorController::disconnectFrontend(FrontendChannel& fron
if (!disconnectedLastFrontend)
return;

// FIXME: change this to notify agents which frontend has disconnected (by id).
m_agents.willDestroyFrontendAndBackend(DisconnectReason::InspectorDestroyed);

#if ENABLE(INSPECTOR_ALTERNATE_DISPATCHERS)
if (m_augmentingClient)
m_augmentingClient->inspectorDisconnected();
Expand Down
19 changes: 19 additions & 0 deletions Source/JavaScriptCore/interpreter/CallFrame.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,25 @@ CodeOrigin CallFrame::codeOrigin() const
return CodeOrigin(callSiteIndex().bytecodeIndex());
}

#if USE(BUN_JSC_ADDITIONS)
JSScope* CallFrame::scopeIfScopeRegisterIsLive() const
{
// BytecodeUseDef keeps the scope register live only in code compiled with debugging
// opcodes, and only after op_enter. Outside of that the slot holds whatever happened to
// be there: the DFG does not allocate it, a baseline frame reached by OSR exit from such
// code has a dead value in it, and a VM trap serviced in the prologue sees a frame whose
// locals have not been written at all. Between op_enter and op_get_scope it is undefined.
if (isNativeCalleeFrame())
return nullptr;
CodeBlock* codeBlock = this->codeBlock();
if (!codeBlock || !codeBlock->wasCompiledWithDebuggingOpcodes() || !codeBlock->scopeRegister().isValid())
return nullptr;
if (!bytecodeIndex().offset())
return nullptr;
return dynamicDowncast<JSScope>(registers()[codeBlock->scopeRegister().offset()].jsValue());
}
#endif

Register* CallFrame::topOfFrameInternal()
{
CodeBlock* codeBlock = this->codeBlock();
Expand Down
6 changes: 6 additions & 0 deletions Source/JavaScriptCore/interpreter/CallFrame.h
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,12 @@ using JSInstruction = BaseInstruction<JSOpcodeTraits>;
// CodeOrigin(BytecodeIndex(0)) if we're in native code.
JS_EXPORT_PRIVATE CodeOrigin codeOrigin() const;

#if USE(BUN_JSC_ADDITIONS)
// The scope held in this frame's scope register, or null whenever that slot cannot be
// trusted; callers then fall back to the callee's scope. See CallFrame.cpp.
JSScope* scopeIfScopeRegisterIsLive() const;
#endif

inline Register* topOfFrame();

const JSInstruction* NODELETE currentVPC() const; // This only makes sense in the LLInt and baseline.
Expand Down
8 changes: 8 additions & 0 deletions Source/JavaScriptCore/interpreter/ShadowChicken.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -334,13 +334,21 @@ void ShadowChicken::update(VM& vm, CallFrame* callFrame)
bool isTailDeleted = false;
JSScope* scope = nullptr;
CodeBlock* codeBlock = callFrame->isNativeCalleeFrame() ? nullptr : callFrame->codeBlock();
#if USE(BUN_JSC_ADDITIONS)
// Same rule as DebuggerCallFrame::scope(): this walk can now also happen from a
// debugger pause entered through a VM trap, so the slot is only read when it is live.
if (JSScope* liveScope = callFrame->scopeIfScopeRegisterIsLive())
scope = liveScope;
else if (foundFrame) {
#else
JSValue scopeValue = callFrame->bytecodeIndex() && codeBlock && codeBlock->scopeRegister().isValid()
? callFrame->registers()[codeBlock->scopeRegister().offset()].jsValue()
: jsUndefined();
if (!scopeValue.isUndefined() && codeBlock->wasCompiledWithDebuggingOpcodes()) {
scope = uncheckedDowncast<JSScope>(scopeValue.asCell());
RELEASE_ASSERT(scope->inherits<JSScope>());
} else if (foundFrame) {
#endif
scope = m_log[indexInLog].scope;
if (scope)
RELEASE_ASSERT(scope->inherits<JSScope>());
Expand Down
9 changes: 8 additions & 1 deletion Source/JavaScriptCore/llint/LLIntSlowPaths.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -560,7 +560,14 @@ UGPRPair SYSV_ABI llint_check_stack_and_vm_traps(CallFrame* callFrame, const JSI
#endif
}

if (vm.traps().handleTrapsIfNeeded()) {
#if USE(BUN_JSC_ADDITIONS)
// This frame is at bytecode 0 with op_enter still to run, so it cannot be paused on; the
// debugger trap callback (VM::setDebuggerTrapCallback) waits for the next op_check_traps.
bool handledTraps = vm.traps().handleTrapsIfNeeded(VMTraps::NonDebuggerAsyncEvents);
#else
bool handledTraps = vm.traps().handleTrapsIfNeeded();
#endif
if (handledTraps) {
if (vm.hasPendingTerminationException()) {
throwScope.release();
callFrame->convertToZombieFrame(vm, codeBlock);
Expand Down
27 changes: 27 additions & 0 deletions Source/JavaScriptCore/runtime/VM.h
Original file line number Diff line number Diff line change
Expand Up @@ -1103,6 +1103,30 @@ class VM : public ThreadSafeRefCountedWithSuppressingSaferCPPChecking<VM> {
JS_EXPORT_PRIVATE bool hasExceptionsAfterHandlingTraps();

CONCURRENT_SAFE void notifyNeedDebuggerBreak() { traps().fireTrap(VMTraps::NeedDebuggerBreak); }
#if USE(BUN_JSC_ADDITIONS)
// Lets an embedder service NeedDebuggerBreak itself, e.g. to attach a debugger to a program
// that is already running and enter Debugger::breakProgram() from code that was compiled
// without op_debug sites. VMTraps::handleDebuggerBreak() calls it on the owning thread with
// the API lock held, with no exception pending, and it must return the same way; it may
// run JS and pause. It is never nested: a NeedDebuggerBreak fired while it runs is taken
// again once it returns. Termination requested meanwhile is deferred and re-fired after it.
//
// Where it runs: op_check_traps (loop headers, every tier), or wherever the embedder calls
// traps().handleTrapsIfNeeded(VMTraps::NeedDebuggerBreak) itself. The LLInt and IPInt
// function prologues deliberately do not service this bit, because they run before the
// callee's frame is initialised. Nothing else retires the bit either: an embedder that
// keeps the API lock while idle has to make that call from its idle loop, otherwise the
// signal sender keeps interrupting the thread until some loop happens to run.
//
// What it buys: pausing and evaluating in code that was already running. Breakpoints,
// `debugger` statements and stepping only take effect in code compiled after the debugger
// attached (CodeGenerationMode::Debugger), i.e. once the currently running code has been
// re-entered after the VM went idle; Debugger degrades steps out of such frames to a pause
// at the next opportunity.
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.

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.

DebuggerTrapCallback debuggerTrapCallback() const { return m_debuggerTrapCallback.load(std::memory_order_acquire); }
#endif
CONCURRENT_SAFE void notifyNeedShellTimeoutCheck() { traps().fireTrap(VMTraps::NeedShellTimeoutCheck); }
CONCURRENT_SAFE void notifyNeedTermination() { traps().fireTrap(VMTraps::NeedTermination); }
CONCURRENT_SAFE void notifyNeedWatchdogCheck() { traps().fireTrap(VMTraps::NeedWatchdogCheck); }
Expand Down Expand Up @@ -1360,6 +1384,9 @@ class VM : public ThreadSafeRefCountedWithSuppressingSaferCPPChecking<VM> {
const Ref<Waiter> m_syncWaiter;

std::atomic<int64_t> m_numberOfActiveJITPlans { 0 };
#if USE(BUN_JSC_ADDITIONS)
std::atomic<DebuggerTrapCallback> m_debuggerTrapCallback { nullptr };
#endif

Vector<Function<void()>> m_didPopListeners;

Expand Down
Loading
Loading