Skip to content

interpreter: opt-in fastcall depth guard (max_fast_call_depth) - #4220

Merged
borisbat merged 1 commit into
masterfrom
bbatkin/fastcall-depth-guard
Oct 7, 2026
Merged

borisbat merged 1 commit into
masterfrom
bbatkin/fastcall-depth-guard

Conversation

@borisbat

@borisbat borisbat commented Oct 6, 2026 •

Copy link
Copy Markdown
Collaborator

C++ ABI break: Context gains two trailing members and CodeOfPolicies a trailing field (DAS_POLICIES_VERSION 2) - external modules and embedding hosts must rebuild.

Why. A fastcall function pushes no das stack frame, so unbounded fastcall recursion runs past the native guard page and kills the process; a regular call fails cleanly at stack.push, and nothing a recover can see fires on the fastcall path.

What changes.

  • CodeOfPolicies::max_fast_call_depth, also options max_fast_call_depth, caps fastcall nesting; zero, the default, leaves the interpreter unchanged.
  • With a cap set, Function::makeSimNode emits a second node family, SimNode_FastCallChecked, that counts Context::fastCallDepth around the body and panics past the cap; the fused one- and two-argument shapes get their own FastCallChecked fusion set.
  • Every handler that restores abiArg after a caught panic restores the counter too, and restart zeroes it, so a recovered overflow leaves no drift; the AOT das_try_recover restores it before the recover body runs.
  • DAS_POLICIES_VERSION moves to 2: the new field sits in the struct's tail padding, so sizeof cannot tell a stale host apart.
  • AOT TTable::moveT (aot.h) now moves tombstones with the rest of the header. It was the one field the move skipped, and das_move is a raw copy, so a table returned by value out of to_table_move carried whatever the stack held there into TableHash::reserve's rehash test; one extra local in runWithCatch changed those bytes and tests/language/resize_locked.das started rehashing mid-iteration under AOT on darwin, leaving a stale array lock for delete to refuse.
  • daslib/ARCHITECTURE_EMIT.md records the das_try_recover pair beside the das_finally one, which the daslib checklist requires; that bullet takes the file past the 300-line gate, so its shader sections (7, 28, 29) move whole into the new daslib/ARCHITECTURE_SHADER.md, with the two [arch] citations in shader_lingua_franca.das repointed and daslib/ARCHITECTURE.md's routing line naming both companions.

Observable behavior.

  • unbounded fastcall recursion, cap set: process crash -> panic stack overflow, max_fast_call_depth <cap> exceeded while calling <fn>, recoverable.
  • cap at zero: same nodes as before, no new cost on the call path; the panic handlers gain one 32-bit load and store.
  • a host built against the previous headers: passes the policy stamp -> refused at the stamp.
  • module cache: a record written under another max_fast_call_depth is another record (stream version 221).

Where to look. Context::enterCheckedFastCall in simulate.h, the checked family in simulate_nodes.h, the six catch points in simulate_exceptions.cpp and jit_runtime.cpp, and the architecture section include/daScript/simulate/ARCHITECTURE.md#fastcall-depth-guard.

Validation, claims, ledger

Validation

  • #nightly - the diff touches src/ and include/, the policy and Context layouts, and the module-cache stream.
  • Full preflight ran once, on the pre-batch tip. Its reds: the handmade rtti line one slot off (fixed) and the docs digest (regenerated); tests-interp and tests-jit only on the per-file time budget of test_watchdog.das and jit_lib.das, the standing Windows reds on this box (both pass alone); utils-tests on MSBuild reading daspkg's deliberate error: refusal lines as errors while every suite's summary is green (a Windows-only lane defect, CI runs the lane on Linux). The fix batch was validated with the targeted gates on the final tip: review-md, lint, ast-verify, docs (all eight cells, sphinx included), tests-cpp, tests-aot (the full suite) all pass; utils-tests reds on the MSBuild parse above with every suite green.
  • tests/language/fast_call_depth.das passes under the interpreter, -jit and the full test_aot binary (11 arms each). Its recursers live in a no_aot module, so each sweep exercises its own recover handler. The tests/language sweep is 1860/1860 and tests/module_cache 79/79.
  • Under an instrumented run (dastest --cov-path, the debugger) every body is rewritten, nothing is fastcall and the guard counts nothing, so the exactness arms skip there with that reason; the nightly's extended_checks (linux, all) coverage cell caught the first version failing all six arms on the das-stack overflow of the coverage hook.
  • The darwin26 Release nightly cell failed resize_locked.das under AOT on the first rebased tip while master passed it; reproduced 3 of 3 on an M5 worktree of the branch, 0 of 2 on a master worktree beside it, bisected to the handler file by reverting it, then to stack layout (restoring the save in another order or dropping it from das_try_recover alone changed nothing), and the panic text can't delete locked array led to the uninitialized tombstones. tests-cpp/small/test_aot_table_move_tombstones.cpp fails on master and passes with the fix.
  • tests-cpp-small passes in the lane, with the new test_fast_call_depth_context.cpp. Run by hand on a module cache minted by the previous binary, dasbind: a registrar served from the module cache fails until the cache is cleared, and jit abi check: a drift is one stderr warning fails identically on a September master binary - neither is this change.
  • --jit-check-abi (a cross-compiled bundle's first launch on the target) was not run: this box has no wasm target. Both new members are appended last, so no bound offset of Context, CodeOfPolicies or Program moves and the cross-target picture is what it was.
  • ABI sweep: additions only - no symbol removed, renamed or retyped; external modules need a rebuild and no source change. The nightly_daspkg_index.yml dispatch is yours to arm.
  • Architecture citations: the three // include/daScript/simulate/ARCHITECTURE.md#fastcall-depth-guard pointers (simulate.h members, simulate_nodes.h family, ast_simulate.cpp node choice) were audited against the section - it matches the code.
  • New names the docs tell a reader to use: options max_fast_call_depth (checked by the dastest run of tests/language/fast_call_depth.das), CodeOfPolicies.max_fast_call_depth (checked by the rtti binding and the C++ test), recover (an existing construct, used by the same test).
  • Woodpecker: one round at the pre-batch tip, exec trace over every changed file, one P1 - the tail-padding stamp bug, fixed by the version bump.

Claims - stated, not tested

  • SimNodeDebug_TryCatch restores the counter, but options debugger disables fastcall, so no capped program reaches it; a break would show only in a build that emits debug try nodes for a fastcall program.
  • LLVM_JIT_CODEGEN_VERSION is not bumped: every bound Program field sits at or before policies, so no offset a cached JIT DLL baked moves.
  • The checked fused shapes were confirmed with a node-shape probe (options log_nodes), not by a test that inspects nodes; a lost registration would fall back to the unrolled checked node and count the same.

Not done

  • Two template folds the dupe audit proposed, both touching the unchecked path's source: SimNode_FastCall<N> / SimNode_FastCallChecked<N> as one SimNode_FastCallT<int, bool CHECKED> with if constexpr (precedent SimNode_AtT), and the fused sets defined once with DAS_FASTCALL_ENTER_##OPNAME hooks (the unchecked family preprocesses to identical tokens). Declined here because the two-family design was the explicit call; your ruling.
  • Context::callOrFastcall, the entry AOT and JIT code use to call back into an interpreted function, stays uncounted; native code has no guard of its own either.
  • The six try/recover handlers were already twins before this branch; a save/restore struct would make the next field a one-line change.
  • Rule-document findings from the audits (checklist self-review defects in tests/REVIEW.md, include/daScript/ast/REVIEW.md, include/daScript/simulate/REVIEW.md, src/ast/REVIEW.md, src/builtin/REVIEW.md, doc/REVIEW.md, doc/source/stdlib/handmade/REVIEW.md, daslib/REVIEW.md, skills/comment_style_hygiene.md) are held for a rule-doc PR.

Copilot AI balanced review requested due to automatic review settings October 6, 2026 22:18

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot review overview

🔵 Needs a closer look

It is a deliberate C++ ABI break touching the interpreter's hot call path, panic/recover handling across six catch points, and the module-cache/policy versioning, which warrants final human review despite no defects being found.

Review effort: Balanced
Findings: None

What changed in this PR

This PR adds an opt-in guard against unbounded fastcall recursion in the daslang interpreter. Because a fastcall function pushes no das stack frame, deep fastcall recursion runs past the native guard page and crashes the process with nothing a recover can catch (unlike a regular call, which fails cleanly at stack.push). The new CodeOfPolicies::max_fast_call_depth / options max_fast_call_depth caps fastcall nesting; the default (0) leaves the interpreter unchanged.

Changes:

  • When the cap is set, Function::makeSimNode emits a parallel SimNode_FastCallChecked node family (including fused one- and two-argument shapes under a new "FastCallChecked" fusion key) that increments/decrements Context::fastCallDepth around the body and panics past the cap; at cap 0 only the unchecked nodes are emitted, so the hot path is unaffected.
  • Every panic handler (SimNode_TryCatch + debugger twin, das_try_recover, jit_try_recover, and the evalWithCatch/runWithCatch family) now saves/restores the counter, and restart zeroes it, so a recovered overflow leaves no drift.
  • ABI/version bookkeeping: Context gains two trailing members, CodeOfPolicies gains a trailing /*option*/ field, DAS_POLICIES_VERSION→2 (tail-padding stamp), module-cache stream getVersion→221; docs, architecture notes, and an unrelated ARCHITECTURE_EMIT.md→ARCHITECTURE_SHADER.md split are included.
File Description
include/​daScript/​simulate/​simulate.h Adds fastCallDepth/maxFastCallDepth members, enterCheckedFastCall, and zeroes the counter in restart()
include/​daScript/​simulate/​simulate_nodes.h New SimNode_FastCallChecked<N> templated + variadic node family mirroring SimNode_FastCall
src/​simulate/​simulate_fusion_call1.cpp /​ call2.cpp Checked fused op1/op2 node families + "FastCallChecked" fusion registration
src/​simulate/​simulate_visit.cpp V_OP(FastCallChecked) visit for the new Any base
src/​simulate/​simulate_exceptions.cpp Counter save/restore at all interpreter/AOT catch points
src/​builtin/​jit_runtime.cpp Counter save/restore in jit_try_recover
src/​ast/​ast_simulate.cpp Selects checked nodes when maxFastCallDepth != 0; mirrors option into the context
src/​runtime/​context.cpp Reads the option in setup; copies maxFastCallDepth into clones
include/​daScript/​simulate/​code_of_policies.h New /*option*/ max_fast_call_depth tail field; DAS_POLICIES_VERSION→2
include/​daScript/​ast/​ast_serializer.h getVersion()→221 for the policy-stream change
src/​builtin/​module_builtin_ast_serialize.cpp Adds max_fast_call_depth to the policy X-macro cache-key stream
src/​builtin/​module_builtin_rtti.cpp Binds the new policy field for rtti
tests/​language/​fast_call_depth.das, _fast_call_depth_recursers.das End-to-end coverage: all node shapes, no-drift, host-policy/host-catch
tests-cpp/​small/​test_fast_call_depth_context.cpp Verifies setup/clone/restart counter semantics
tests/​module_cache/​* Verifies a cap keys a distinct cold cache record
docs, ARCHITECTURE*.md, CHANGELIST.md, daslib/​shader_lingua_franca.das Option/policy docs, architecture section, and the EMIT→SHADER doc split

I verified the core logic in depth and found no objective defects: the checked node family and fused shapes mirror the unchecked paths exactly (added only the increment/decrement), fusion struct names are scoped so there is no redefinition clash, the counter is saved/restored symmetrically at all six catch points, maxFastCallDepth is set before makeSimNode reads it, the clone copies the cap while resetting the counter (matching the C++ test), the printf format matches its args under DAS_FORMAT_PRINT_ATTRIBUTE, version bumps are consistent, and the architecture-doc split leaves no dangling [arch] citations.


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@borisbat
borisbat force-pushed the bbatkin/fastcall-depth-guard branch from f0b6a33 to 8e92d94 Compare October 6, 2026 23:09
Copilot AI balanced review requested due to automatic review settings October 6, 2026 23:09

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot review overview

🔵 Needs a closer look

It is a declared C++ ABI break touching the interpreter's core call path, exception recovery across six handlers, and the module-cache stream version, which warrants final human verification even though the implementation and tests reviewed as correct.

Review effort: Balanced
Findings: None

@borisbat
borisbat force-pushed the bbatkin/fastcall-depth-guard branch from 8e92d94 to de21fc2 Compare October 6, 2026 23:47
Copilot AI balanced review requested due to automatic review settings October 6, 2026 23:47

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot review overview

🔵 Needs a closer look

It is a deliberate C++ ABI break touching the core interpreter call/fusion path, exception/recover handlers, JIT/AOT runtime, and the module-cache serialization version, which warrants final human review despite no defects being found.

Review effort: Balanced
Findings: None

@borisbat

borisbat commented Oct 7, 2026

Copy link
Copy Markdown
Collaborator Author

Control for the playground / verify_samples red on this tip: not this PR's.

The three failing rows (Arcanoid, Boulder Dash, River Run, wasm) fail to cross-compile on the deployed dasweb-buildd with error[30151]: syntax error, unexpected "new line, semicolon" at def ... => line ends in examples/games/arcanoid/arcanoid_postfx.das:125, examples/games/boulder-dash/cave.das:174 and examples/games/river_run/rr_postfx.das:191. Those line ends are the => line continuation that the lint sweeps of #4217 introduced, and master carries them as of e66bb58; the build daemon runs a daslang that predates the parser change. This PR changes neither those files nor the parser. The lane is a nightly one (nightly_playground.yml), armed here by #nightly; master's last nightly playground run predates the merge, so the same red lands on master at the next nightly until the daemon is rolled to a daslang that parses the continuation.

@borisbat
borisbat force-pushed the bbatkin/fastcall-depth-guard branch from de21fc2 to 4f870a7 Compare October 7, 2026 01:29
@borisbat
borisbat requested a balanced review from Copilot October 7, 2026 01:30

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot review overview

🔵 Needs a closer look

It is a deliberate C++ ABI break touching core interpreter call/fusion paths, every exception/recover handler, and the module-cache serialization stream, which warrants final human review despite the implementation appearing correct and well-validated.

Review effort: Balanced
Findings: 1 Low severity

Open (1)

Comment thread daslib/ARCHITECTURE.md
…unbounded fastcall recursion into a recoverable panic

A fastcall function pushes no das stack frame, so a chain of fastcall calls grows only the
native stack: a regular call fails cleanly at stack.push, unbounded fastcall recursion runs to
the native guard page and the process dies with no panic to recover. The guard is a policy,
CodeOfPolicies::max_fast_call_depth (also options max_fast_call_depth), zero by default. When
it is set, Function::makeSimNode emits a second node family, SimNode_FastCallChecked, that
counts Context::fastCallDepth around the body and panics past the cap naming the option; the
fused one- and two-argument shapes get their own FastCallChecked fusion set, so a checked call
keeps its superinstructions. The unchecked FastCall family is untouched, so a program with the
cap at zero emits only the unchecked nodes. A panic is a longjmp that unwinds no frame, so
every handler that restores abiArg after a caught panic restores the counter too (the AOT
das_try_recover before its recover body runs), and restart zeroes it. The policy field sits
last in the struct because cached JIT DLLs bind the earlier fields by offset, and it lands in
the struct's tail padding, so DAS_POLICIES_VERSION moves to 2 for the stamp to refuse a stale
host; the module-cache stream version moves to 221. Context gains two trailing members (C++
ABI: external modules rebuild). The test's recursers live in a no_aot module, so the file runs
under the interpreter, the JIT and test_aot and each sweep exercises its own recover handler;
a tests-cpp case covers setup, clone and restart, and the module-cache suite keys a record on
the new policy. daslib/ARCHITECTURE_EMIT.md records the das_try_recover pair and, at the
300-line cap, hands its shader sections to ARCHITECTURE_SHADER.md.

AOT TTable::moveT (aot.h) now moves tombstones with the rest of the table header. It was the
one field the move skipped, and das_move is a raw copy, so a table returned by value out of
to_table_move carried whatever the stack held there into the rehash test of
TableHash::reserve; the extra local the counter save puts in runWithCatch changed those bytes,
and tests/language/resize_locked.das began rehashing mid-iteration under AOT on darwin, leaving
a stale array lock for delete to refuse. tests-cpp/small/test_aot_table_move_tombstones.cpp
fails on master and passes with the fix.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@borisbat
borisbat force-pushed the bbatkin/fastcall-depth-guard branch from 4f870a7 to 1b0510c Compare October 7, 2026 02:05
Copilot AI balanced review requested due to automatic review settings October 7, 2026 02:05

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot review overview

🔵 Needs a closer look

It is a C++ ABI break touching core interpreter execution, all panic/recover paths, and the module-cache/policy stamp, which warrants final human review despite no defects being found.

Review effort: Balanced
Findings: None

Resolved since last review (1)

@borisbat
borisbat merged commit 857e115 into master Oct 7, 2026
65 checks passed
@borisbat
borisbat deleted the bbatkin/fastcall-depth-guard branch October 7, 2026 04:18
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