Skip to content

Fix debugger segfault: VmFrame::ctxChain must not relocate its elements - #129

Merged
revarbat merged 1 commit into
mainfrom
fix-debug-frame-dangling-bodyctx
Aug 28, 2026
Merged

Fix debugger segfault: VmFrame::ctxChain must not relocate its elements#129
revarbat merged 1 commit into
mainfrom
fix-debug-frame-dangling-bodyctx

Conversation

@revarbat

Copy link
Copy Markdown
Member

The crash

Setting a breakpoint inside a module and clicking Continue crashed instantly:

EXC_BAD_ACCESS (SIGSEGV) KERN_INVALID_ADDRESS at 0x0000000000000010
0  oscadeval::Evaluator::buildDebugFrame(EvalContext const*, bool) const + 120
1  oscadeval::Evaluator::buildDebugFrames(EvalContext const*) const + 224
9  oscadeval::Evaluator::checkDebug(...) + 1540
10 oscadeval::Evaluator::evalChildren(...) + 716
12 oscadeval::runCompiledModuleBody(...) + 1104
13 oscadeval::Evaluator::evalUserModule(...)

Root cause

Evaluator::enterUserCall stores CallStackFrame::bodyCtx as a pointer to frame->ctxChain.back(). That pointer is read much later, by any checkDebug() that walks the whole call stack to build per-frame debugger locals.

ctxChain was a std::vector<EvalContext>, so the next push_back reallocated and left bodyCtx pointing at the moved-from element — whose let_ shared_ptr is null. ctx->let_->items() then reads offset 0x10 from null. buildDebugFrame null-checks ctx but not ctx->let_.

The push that does it is Op::PushBuiltinWrapevery transform inside a compiled body. So the trigger is as ordinary as:

module mid(a) { translate([a,0,0]) inner(a); }

It only bites while debugging because a chunk compiles at all only when fastContinueBreakpoints_ is set (chunkEligibleNow) — exactly the "breakpoint set, now continue" state. The breakpointed callee is forced to interpret while its caller stays compiled, and pausing in the callee walks the caller's now-stale frame. The doc comment at bytecode_vm.cpp:276 describes that scenario for an earlier fix of the same class; this is the hole that fix left.

Fix

std::deque<EvalContext> — never invalidates references to existing elements on push_back/pop_back. lastCtx_ and childrenCallerCtx point into the same storage and are covered by the same change.

Cost

None measurable. 8 runs of eval_perf_benchmark (50,000 translate() calls — the same push path):

min median
vector 426ms 467ms
deque 432ms 451ms

Within noise both ways.

Verification

  • New regression test drives the user's exact scenario (breakpoint forces the callee to interpret while the caller runs compiled, then reads the caller's locals from the debug frames). It segfaults without the fix.
  • Full suite green on both engines: 1079/1079 with OSCAD_BYTECODE_VM=1 and =0.

🤖 Generated with Claude Code

Setting a breakpoint inside a module and clicking Continue crashed
instantly with SIGSEGV at address 0x10, in buildDebugFrame.

Evaluator::enterUserCall stores CallStackFrame::bodyCtx as a pointer to
frame->ctxChain.back() (pushBracketedCallFrame/pushBracketedModuleFrame).
That pointer is read much later, by any checkDebug() that walks the whole
call stack to build per-frame debugger locals. ctxChain was a
std::vector, so the next push onto it reallocated and left bodyCtx
pointing at the moved-from element -- whose let_ shared_ptr is null,
hence the read at offset 0x10 from null.

The push that does it is Op::PushBuiltinWrap: every transform inside a
compiled body pushes a context. So the trigger is as ordinary as
`module mid(a) { translate([a,0,0]) inner(a); }`.

It only bites while debugging, because a chunk is compiled at all only
when fastContinueBreakpoints_ is set (chunkEligibleNow) -- which is
exactly the "breakpoint set, now continue" state. A breakpointed callee
is forced to interpret while its caller stays compiled, and pausing in
the callee walks the caller's now-stale frame.

std::deque never invalidates references to existing elements on
push_back/pop_back, so the fix is the container. lastCtx_ and
childrenCallerCtx point into the same storage and are covered by it too.

No measurable cost: 8 runs of eval_perf_benchmark (50,000 translate()
calls, the same push path) give min 432ms vs 426ms for vector, medians
451ms vs 467ms -- within noise both ways.

Regression test drives the user's exact scenario: a breakpoint that
forces the callee to interpret while its caller runs compiled, then
reads the caller's locals from the debug frames. It segfaults without
the fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@revarbat
revarbat merged commit d56c2bb into main Aug 28, 2026
3 checks passed
@revarbat
revarbat deleted the fix-debug-frame-dangling-bodyctx branch August 28, 2026 02:13
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.

1 participant