Skip to content

Add Op::CallChildren: eliminate children()'s native reentry - #66

Merged
revarbat merged 2 commits into
mainfrom
op-call-children
Aug 1, 2026
Merged

Add Op::CallChildren: eliminate children()'s native reentry#66
revarbat merged 2 commits into
mainfrom
op-call-children

Conversation

@revarbat

@revarbat revarbat commented Aug 1, 2026

Copy link
Copy Markdown
Member

Summary

  • Closes the last dominant native-reentry gap: children() fell to Op::NativeStatement, costing one genuine native C++ reentry chain per call per recursion level — 85 of 93 native-reentry hits in Anklet.scad's real run, and the reason it still failed with "Recursion too deep (native call stack)" on 0.13.3 despite PR Close the NativeStatement gap for translate/color/modifier-wrapped recursion #63/Decouple kMaxUserCallDepth's guard from callStack_.size() #65.
  • Op::CallChildren: unlike the PushBuiltinWrap constructs, the forwarded children are runtime-varying (ctx.childrenNodes/childrenCallerCtx), so they can't compile inline. The new opcode resolves the list at runtime and pushes its chunk onto vmCallStack_ directly (mirroring Op::CallModule's zero-native-call push) with a third frame shape: splice-owning yet bracketless — both already handled unchanged by the completion branch and exception teardown.
  • The "which nodes, against what context" half of builtinChildren is factored into a shared prepareChildrenForward helper so native and compiled paths cannot drift ($-forwarding loop, caller-ctx re-derivation, children(N) filtering).
  • Two real pre-existing bugs found and fixed along the way (each with a regression test verified to fail before its fix):
    1. childrenListChunkCache_ keyed by list-front alone — children() and children(0) on the same caller shared a cache key, making whichever ran first poison the other (wrong geometry emitted, both statement orders). Re-keyed by (front, size). (Own commit.)
    2. IndexedScopeTrailStorage::popLevel did a blind pop_back() per dirty name — the exact out-of-order-pop corruption its non-indexed twin documents and fixed long ago, benign only while dyn-trail view lifetimes stayed LIFO. The new forwarding frame is the first real non-LIFO lifetime; a children($fn=9) override silently read back as 0. Now level-aware, same as the twin.

Results

  • All 721 tests green, both OSCAD_BYTECODE_VM states.
  • New tests: recursive wrap() { children(); } chain at depth 1500 (bare + indexed), $-forwarding parity (wrapper $fn writes and children($fn=N) named args), the trail-pop fix, the cache-key collision (both orders).
  • Anklet.scad renders end-to-end with fully unmodified guards — 315984 bytes, byte-identical to the disabled-guards reference. Peak native reentry depth: 10 (was 55 originally, 45 after PushBuiltinWrap), 4x inside the Windows-proven-safe ceiling of 40.

Test plan

  • Full suite, both VM states, locally green.
  • Real Windows CI — the acceptance gate for every native-stack change in this project.

🤖 Generated with Claude Code

revarbat and others added 2 commits August 1, 2026 04:32
The cache was keyed by the list's first element alone -- a convention
borrowed from assignBlockChunkCache_, where it IS sufficient (a leading-
assignment run's first member uniquely determines the run). Here it
wasn't: children()'s forwarding produces two DIFFERENT lists sharing a
first element (bare children() forwards the caller's whole list;
children(0) a single-element slice of it), and whichever form ran first
poisoned the cache for the other. Concrete failure:
`module m() { children(); children(0); }` called with two children made
children(0) emit BOTH shapes; reversed statement order truncated bare
children() instead.

Re-keyed by (front, size). Every evalChildren list producer enumerated:
fixed AST lists have unique fronts per call site, and the only same-
front pair (bare vs indexed forwarding) always differs in size except
when the lists are literally identical anyway. Two regression tests
(both statement orders), each verified to fail before the re-key.

Also splits the lookup half of tryRunCompiledChildren into
lookupOrCompileChildrenListChunk, needed shortly by Op::CallChildren's
own runtime handler (same list->chunk step, different run mechanism).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…xed-trail out-of-order pop

children() was the last dominant source of native C++ stack reentry from
compiled code (85 of 93 native-reentry hits in Anklet.scad's real run) --
its statement fell to Op::NativeStatement, whose handler re-entered the
VM through six native frames per children() per recursion level, and
BOSL2's attachable() calls it at nearly every wrapper level.

Unlike Op::PushBuiltinWrap's constructs, the forwarded children are
runtime-varying (ctx.childrenNodes/childrenCallerCtx, set by
buildModuleChildCtx from the CALLER's call-site AST), so they can't
compile inline. Op::CallChildren instead resolves the list at runtime,
looks up/compiles its chunk (the same pass-scoped childrenListChunkCache_
tryRunCompiledChildren uses), and pushes it onto vmCallStack_ directly --
mirroring Op::CallModule's zero-native-call push, but with a third frame
shape: splice-owning (ownsModuleSplice=true, matching evalModularCall's
unconditional splice branch for "children") yet bracketless (children()
never gets a callStack_/profiling entry natively either). The completion
branch and exception teardown already handle that combination unchanged.

The "which nodes, evaluated against what context" half of builtinChildren
(the caller-ctx re-derivation, the $-forwarding loop, the children(N)
index filtering) is factored into a shared prepareChildrenForward helper
so the native and compiled paths cannot drift. Handler ordering is
load-bearing: checkDebug against the scope-wrapped ctx (byte-for-byte
what Op::NativeStatement did for this node), randsBefore before argument
resolution (rands-in-args taint), treeStack_ pushed last (exception
safety without a try/catch), and a not-eligible fallback that reuses the
already-resolved args instead of re-resolving via evalStatement (which
would double rands()/echo/assert side effects).

Also fixes a REAL latent bug this surfaced: IndexedScopeTrailStorage::
popLevel did a blind pop_back() per dirty name -- the exact out-of-order-
pop corruption its non-indexed twin's own doc comment describes and fixed
long ago -- benign only while every dyn-trail view died LIFO. The new
forwarding frame's evalCtx (opened after, dying after, the call's own
effCtx) is the first real non-LIFO lifetime: popping effCtx's level
silently ate the still-live forwarded entry on top (caught for real:
children($fn=9) read back as the root default 0 inside the forwarded
child). Now level-aware, same as the twin, with a storage-level
regression test mirroring the exact level shape.

Verification: all 721 tests green on both OSCAD_BYTECODE_VM states; new
tests cover the recursive wrap/children() chain at depth 1500 (bare and
indexed), $-forwarding parity (wrapper writes and named-$ args), the
trail pop fix, and (previous commit) the (front,size) cache re-key.
Anklet.scad now renders end-to-end with fully unmodified guards --
315984 bytes, byte-identical to the disabled-guards reference -- and its
peak native reentry depth measured 10 (was 55 originally, 45 after
Op::PushBuiltinWrap), 4x inside the Windows-proven-safe ceiling of 40.

Bumps version to 0.13.4.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@revarbat
revarbat merged commit 8730557 into main Aug 1, 2026
3 checks passed
@revarbat
revarbat deleted the op-call-children branch August 1, 2026 12:10
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