Skip to content

Close the NativeStatement gap and guard its new native-reentry path - #60

Merged
revarbat merged 1 commit into
mainfrom
native-statement-gap
Jul 31, 2026
Merged

Close the NativeStatement gap and guard its new native-reentry path#60
revarbat merged 1 commit into
mainfrom
native-statement-gap

Conversation

@revarbat

Copy link
Copy Markdown
Member

Summary

  • evalChildren() now tries compiling its whole statement list (not just the leading assignment run) before falling back to the native per-statement loop, so a resolvable module call inside a builtin's own children (e.g. translate(v) recur(n-1);) gets real Op::CallModule bytecode instead of always dispatching natively — the "NativeStatement gap" from PR Rewrite the bytecode VM onto an explicit heap frame stack #59's own follow-up work.
  • That fix reintroduced a native C++ recursion path PR Rewrite the bytecode VM onto an explicit heap frame stack #59's explicit frame-stack VM was meant to eliminate: Op::NativeStatement still dispatches natively, and if its children re-enter the VM through the new shortcut, that's a genuine nested native call. A recursive module wrapped in a builtin at every level segfaulted around depth 3000 with no guard at all (the existing CSG tree-depth guard only fires after an already-crashed descent would unwind). Added driveVmNativeDepth_/kMaxDriveVmNativeDepth in driveVm() to catch this the same way kMaxUserCallDepth already does for the interpreted path — a clean, catchable error instead of a crash — while leaving the common case (recursion is pure Op::CallModule, only occasional non-recursing leaves are builtin-wrapped) uncapped by anything but the existing heap-bounded kMaxVmCallStackDepth.
  • Also fixed two pre-existing bugs surfaced by evalChildren's much broader compiled-path coverage: compileForLoop was missing Op::IterReset for inner cartesian dimensions (multi-variable for loops silently under-iterated after the first outer value), and assignBlockChunkCache_ was never actually cleared despite its own doc comment claiming it was.
  • Version bumped 0.11.0 → 0.12.0 per repo convention.

Test plan

  • Full oscad_eval_tests suite (700 tests) green with OSCAD_BYTECODE_VM on (default)
  • Full suite green with OSCAD_BYTECODE_VM=0
  • New regression test: the always-builtin-wrapped recursive pattern now throws a clean EvalError ("Recursion too deep") instead of segfaulting at depth 3000+
  • New regression test: deep pure-Op::CallModule recursion with an occasional non-recursing builtin-wrapped leaf still succeeds well past the old 30-frame native limit
  • Manual CLI depth sweep (30 → 50000) on the crash pattern confirms clean errors at every depth, no segfaults

🤖 Generated with Claude Code

evalChildren() now tries compiling its whole statement list (not just
its leading assignment run) before falling back to the native
per-statement loop, via tryRunCompiledChildren/tryCompileChildrenList.
This lets a resolvable module call inside a builtin's own children
(e.g. `translate(v) recur(n-1);`) get real Op::CallModule bytecode
instead of always falling through evalStatement/evalModularCall.

That fix reintroduced a native C++ recursion path that PR #59's
explicit frame-stack VM was supposed to have eliminated: a builtin
statement still dispatches natively via Op::NativeStatement, and if
its children re-enter the VM through the new shortcut, that's a
genuine nested native call, not a cheap Op::CallModule hop. A
recursive module wrapped in a builtin at every level segfaulted
around depth 3000 with no guard at all, since the existing CSG
tree-depth guard only fires after a full (already-crashed) descent
unwinds. Added driveVmNativeDepth_/kMaxDriveVmNativeDepth in
driveVm() to catch this the same way kMaxUserCallDepth already does
for the interpreted path -- a clean, catchable error instead of a
crash -- while leaving the common case (recursion is pure
Op::CallModule, only occasional non-recursing leaves are builtin-
wrapped) uncapped by anything but the existing heap-bounded
kMaxVmCallStackDepth.

Also fixed two pre-existing bugs surfaced by evalChildren's much
broader compiled-path coverage: compileForLoop was missing
Op::IterReset for inner cartesian dimensions (multi-variable for
loops silently under-iterated after the first outer value), and
assignBlockChunkCache_ was never actually cleared despite its own doc
comment claiming it was.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@revarbat
revarbat merged commit 4f1af76 into main Jul 31, 2026
3 checks passed
@revarbat
revarbat deleted the native-statement-gap branch July 31, 2026 18:29
revarbat added a commit that referenced this pull request Jul 31, 2026
Bisecting toward Windows' real safe ceiling for this native-reentry
chain: 30 is known-safe (PR #60's own successful merge), 50 is now
known-unsafe (segfaulted on the guard-safety regression test both at 100
and 50). Trying 40, the midpoint -- still functionally sufficient (all 3
real BOSL2 scripts this guard was regressing still render locally at
this value), full suite green locally both VM states. Awaiting CI to
confirm Windows safety before considering this settled.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
revarbat added a commit that referenced this pull request Jul 31, 2026
…; fix two crash bugs found by BOSL2 sweep (#61)

* Extend depth-safe compiled path to modifiers; compile leaf statements; fix two real crash bugs found by BOSL2 sweep

Item 1: evalModifier now routes its wrapped child through evalChildren
instead of a hand-rolled checkDebug+evalStatement pair, so a `#`/`!`-
wrapped recursive module call gets the same Op::CallModule depth
treatment translate()/union() already have from the NativeStatement-gap
fix. intersection_for/let() turned out to already be covered (both
already call evalChildren internally).

Item 2: Assignment/ModularEcho/ModularAssert/ModularLet now compile to
real bytecode instead of falling back to Op::NativeStatement. This closes
a real regression: compileOneStatement had no Assignment case at all, so
the whole-list compile added for the NativeStatement gap was silently
shadowing the older, more specialized tryRunCompiledAssignmentBlock path.
New opcodes: CheckDebugStatement, StoreModuleVar, AssertStatement (+
AssertSite, named-arg + eager-evaluation support matching
evalAssertStatement's contract, distinct from AssertOp's lazy-message
one), OpenLetScope/StoreLetVar. Module chunks can now have nonzero
numSlots (a nested let-expression inside one of these statements' own
arguments needs real slots, unlike anything else in a module body).

Landing item 2 surfaced and fixed two real bugs, both caught by the
existing test suite: a `$fn` override leaking past its own let() scope
into later statements (inlining sub-expressions bypassed the isolation
runCompiledExprChunk used to provide -- fixed with Op::OpenExprScope/
CloseExprScope), and function calls inside these new inline expressions
resolving against the wrong lexical scope (compileExpr's PrimaryCall case
reads the Compiler's fixed scope_ member directly, not per-statement --
fixed with an RAII scope swap in compileOneStatement).

A differential sweep of BOSL2's own test corpus (901 tests, VM on/off)
then found two further bugs, unrelated to items 1/2, both confirmed
present on the pre-existing PR #60 baseline via git stash before fixing:

- kMaxDriveVmNativeDepth=30 was too conservative for real BOSL2 usage --
  3 scripts (test_ball_bearing, test_teardrop_corner_mask,
  test_rounding_hole_mask) that used to render now failed via BOSL2's own
  deep _translate()/_show_highlight() wrapper nesting. Empirically raised
  to 100 (binary-searched: 50 is the minimum that fixes all 3, doubled for
  headroom) -- re-verified the original crash-safety guarantee still holds
  at the new value.

- A real memory-corruption crash: test_poly_roots segfaulted after
  printing a fully correct error message. Root-caused via a targeted
  AddressSanitizer build to Evaluator::releaseVmFrame never resetting
  ownsModuleSplice/moduleRandsBefore/moduleSpliceCallNode before returning
  a VmFrame to the pool -- a frame released after owning a module's own
  splice, later reused (LIFO) for an unrelated non-tail recursive
  function call, kept ownsModuleSplice=true; when that function call was
  torn down by an exception thrown mid-recursion, teardownVmCallStackDownTo
  popped treeStack_ once too many, corrupting it. Fixed by resetting all
  three fields in releaseVmFrame, matching the reset it already does for
  stack/slots/bound.

Full 708-test suite green both OSCAD_BYTECODE_VM states; BOSL2
differential sweep clean (zero crashes across all 901 tests, down from 2
before this commit). Version bumped 0.12.0 -> 0.13.0 per repo convention.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Lower kMaxDriveVmNativeDepth 100->50: 100 segfaulted on Windows CI

Windows CI's own crash-safety regression test
(ModuleBodyCompiles.NativeReentrantRecursionHitsAControlledErrorInsteadOfCrashing)
segfaulted at 100 -- confirms this native-reentry chain has a heavier
real per-level frame cost than kMaxUserCallDepth's own (which needed the
identical kind of comedown, an initial 50 down to 30, for a shallower
chain). Set to exactly the locally-determined functional minimum (50, the
smallest value that fixes the 3 real BOSL2 scripts this guard was
regressing) with no headroom this time, since headroom is what crashed.
Re-verified locally: full suite green both VM states, guard-safety test
passes, and all 3 BOSL2 scripts still succeed at 50.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Lower kMaxDriveVmNativeDepth 50->40: 50 also segfaulted on Windows CI

Bisecting toward Windows' real safe ceiling for this native-reentry
chain: 30 is known-safe (PR #60's own successful merge), 50 is now
known-unsafe (segfaulted on the guard-safety regression test both at 100
and 50). Trying 40, the midpoint -- still functionally sufficient (all 3
real BOSL2 scripts this guard was regressing still render locally at
this value), full suite green locally both VM states. Awaiting CI to
confirm Windows safety before considering this settled.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
revarbat added a commit that referenced this pull request Aug 1, 2026
…ls (#62)

Real crash, found via BelfrySCAD's own CI: pausing a debug session
deep inside a native (interpreter-forced, e.g. by an active breakpoint
on its own line) function call, with a COMPILED caller above it on the
call stack, segfaulted the instant anything tried to read that
caller's own frame locals (Evaluator::buildDebugFrames, walked by
DebugFramesFn/getFrame() -- exactly what a debugger's "step over a
call, but still honor a breakpoint set inside it" scenario needs).

Root cause: pushBracketedCallFrame/pushBracketedModuleFrame (the
explicit-stack VM's own compiled-call push helpers, bytecode_vm.cpp)
called enterUserCall with a reference to their OWN local `childCtx`
variable. enterUserCall stores that reference as
CallStackFrame::bodyCtx for later use ("per-frame locals for the
debugger") -- but the real, long-lived copy of that context ends up
living in the pushed VmFrame's own ctxChain (heap-allocated, owned by
vmCallStack_), not at the address bodyCtx pointed to. The instant
either push helper returned, its own local childCtx was destroyed,
leaving bodyCtx dangling for the ENTIRE lifetime of that call -- never
noticed until something actually reads a compiled caller's own frame
locals while a DEEPER (possibly native) call is still active on the
stack.

Confirmed present since the explicit-stack VM redesign itself (PR #59)
-- bisected by rebuilding at each of PR #59/#60/#61's own commits and
reinstalling into BelfrySCAD's venv, all three reproduce it. Root-
caused via a targeted AddressSanitizer build of the Python bindings
(a plain synchronous C++ probe didn't reproduce it -- needed the real
cross-thread blocking a GUI debug session's own hook does) plus a
threaded Python repro mirroring BelfrySCAD's debugger.py exactly.

Fix: reorder both push helpers to build the VmFrame FIRST (so its own
ctxChain owns the context), then call enterUserCall with a reference
to frame->ctxChain.back() -- stable for the VmFrame's whole lifetime,
since VmFrame lives behind a unique_ptr and never moves even if
vmCallStack_ itself reallocates.

New regression test (DebugHooks.
GetFrameDoesNotCrashWalkingACompiledCallerAboveAForcedInterpretedCallee)
reproduces the exact shape and crashes the test process without this
fix (verified via git stash before landing this commit). Full 709-test
suite green both OSCAD_BYTECODE_VM states. Verified against
BelfrySCAD's own previously-crashing test (both directly and via its
full 306-test suite) using an editable install of this fix.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
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