Skip to content

Guard against native stack overflow in deep non-tail recursion - #57

Merged
revarbat merged 5 commits into
mainfrom
nontail-recursion-depth-guard
Jul 31, 2026
Merged

Guard against native stack overflow in deep non-tail recursion#57
revarbat merged 5 commits into
mainfrom
nontail-recursion-depth-guard

Conversation

@revarbat

@revarbat revarbat commented Jul 31, 2026

Copy link
Copy Markdown
Member

Summary

  • A genuinely non-tail-recursive user function — closure or not, letrec or not, compiled or interpreted — makes a real, unavoidable C++ recursive call per logical call (there is no trampoline for a call whose result still needs further work after it returns, e.g. 1 + f(n-1)). With no depth guard at all, this silently overflowed the native stack and segfaulted the whole process, with no exception to catch.
  • Confirmed present for a plain, closure-free function f(n) = n<=0 ? 0 : 1+f(n-1);, on both the interpreted and compiled paths — discovered while verifying Extend letrec pre-declare to ternary-wrapped self/mutual references #56's ternary-wrapped letrec fix at scale, but entirely unrelated to it (test_tail_calls.cpp's own header comment already flagged the compiled path's exposure here as a known gap when tail-call-optimization Phase B first landed; never actually closed until now).
  • Adds a depth check to evalUserFunctionCore, the one choke point every genuine (non-trampolined) function call already passes through on both paths.
  • The cap value took two rounds of CI-only diagnostics to calibrate (no local Windows access): this evaluator's own C++ recursion is safe into the thousands on an ordinary macOS/Linux desktop main-thread stack, but only into the low hundreds on CI's windows-latest runner — and, more surprisingly, actually throwing from depth N and unwinding back out through N nested evalUserFunctionCore try/catch frames costs meaningfully more native stack than simply being that deep without throwing (300 survived plain recursion on Windows; throwing+unwinding through as few as 100 nested frames did not — 80 was the last depth that survived). Final cap: 50, with real margin below that 80 figure for whatever's already on the stack from a caller, and for a smaller stack still that this evaluator can't probe for directly (e.g. BelfrySCAD's own debug-session worker thread).
  • Trampolined tail recursion is entirely unaffected — the existing 500,000-deep tail-recursion test passes unchanged, since a trampolined hop never re-enters evalUserFunctionCore.

Test plan

  • Full test suite (678 tests) passes locally (macOS) and on all three CI platforms.
  • New tests: DeepNonTailRecursionHitsAControlledErrorInsteadOfCrashingInterpreted/...Compiled, ShallowNonTailRecursionStillWorksUnderTheDepthGuard.
  • Manually verified via the CLI: the crash is gone (controlled ERROR: Recursion too deep... instead of a segfault) on both paths; legitimate shallow non-tail recursion (fact(10), fact(30)) still works correctly.

🤖 Generated with Claude Code

revarbat and others added 5 commits July 30, 2026 17:17
Any genuinely non-tail-recursive user function -- closure or not,
letrec or not, compiled or interpreted -- makes a real, unavoidable
C++ recursive call per logical call (there is no trampoline for a
call whose result still needs further work after it returns, e.g.
`1 + f(n-1)`). With no depth guard at all, this silently overflowed
the native stack and segfaulted the whole process a few thousand
levels deep, with no exception to catch. Confirmed present for a
plain, closure-free `function f(n) = n<=0 ? 0 : 1+f(n-1);`, on both
paths -- discovered while verifying the previous PR's ternary-wrapped
letrec fix at scale, but entirely unrelated to it: this test file's
own header comment already flagged the compiled path's exposure here
as a known gap when TCO Phase B first landed, just never closed.

Adds a conservative depth check to evalUserFunctionCore, the one
choke point every genuine (non-trampolined) function call already
passes through on both paths -- throwing a controlled EvalError well
before the point this evaluator's own C++ recursion has been measured
unsafe at, rather than a precisely-calibrated limit (a real deployment
may run this on a smaller-stack thread than the 8MB desktop main
thread this was measured against, e.g. BelfrySCAD's own debug-session
worker thread). Trampolined tail recursion (reduce()/accumulate()/
while(), the deep self/mutual-recursion tests from the last several
releases) is entirely unaffected, confirmed by the existing
500,000-deep tail-recursion test passing unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
kMaxUserCallDepth=1000 was calibrated against this evaluator's own
measured-safe threshold on an 8MB macOS/Linux desktop main-thread
stack (a few thousand levels) with a stated safety margin -- CI's
windows-latest runner still segfaulted on the new tests at that
depth, meaning its actual usable stack (and/or MSVC's own per-frame
cost) is substantially smaller than assumed. Lowers the cap to 200 --
a real, precisely-calibrated-per-platform figure isn't available
without probing actual remaining stack space at runtime (out of
scope for this fix), so this trades away some deep-non-tail-recursion
headroom for actual cross-platform safety, matching the reasoning
already laid out in the guard's own doc comment.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Temporary commit: even a cap of 200 still segfaulted on CI's
windows-latest runner, with no way to reproduce or bisect locally (no
Windows access, and this evaluator's own measured-safe native
recursion depth on ordinary macOS/Linux desktop hardware is in the
thousands). Bumps kMaxUserCallDepth to 5000 (comfortably past every
locally-measured-safe threshold) and adds a diagnostic test that
tries increasing depths in a loop, printing (and flushing) success
before each one -- so wherever this run actually crashes on
windows-latest, the CI log itself shows the exact last-safe depth
directly, without needing local reproduction. Temporarily disables
the two tests that assert the guard actually fires, since
kMaxUserCallDepth=5000 now exceeds even this evaluator's own
measured-safe depth on ordinary hardware, crashing before the guard
gets a chance to run at all.

Follow-up commit sets kMaxUserCallDepth to a real value based on this
run's own log and restores the disabled tests.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Round 1 found plain (non-throwing) recursion safe to depth 300 on
windows-latest CI, but kMaxUserCallDepth=200 (a THROW from depth 200,
unwinding back out through 200 nested evalUserFunctionCore try/catch
frames) still segfaulted there -- meaning throwing+unwinding through
N nested frames costs meaningfully more native stack than simply
being N frames deep without throwing. This measures that cost
directly (assert(false) throws/unwinds through the exact same nested-
try/catch shape Evaluator::error() does) at finer granularity around
the round-1 boundary.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Two rounds of CI diagnostics found the real numbers: plain (non-
throwing) recursion is safe to depth 300 on windows-latest CI, but
throwing+unwinding back out through that many nested
evalUserFunctionCore try/catch frames -- what this guard actually
does -- crashed by depth 100 (80 was the last depth that survived).
50 leaves real margin below that empirically-found figure for
whatever's already on the stack from a call's own caller, and for a
smaller stack still that this evaluator has no way to probe for
directly (e.g. BelfrySCAD's own debug-session worker thread).

Removes both temporary diagnostic tests and the now-unused <iostream>
include; restores the two "hits the guard" tests (no longer disabled)
with depths appropriate to the final cap.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@revarbat
revarbat merged commit 8fc236d into main Jul 31, 2026
3 checks passed
@revarbat
revarbat deleted the nontail-recursion-depth-guard branch July 31, 2026 01:19
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