call/1 metacall recursion grows the native C stack and SIGSEGVs below the step limit #23
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
A predicate that recurses through
call/1(or any goal routed through theruntime metacall path) grows the native C stack one frame per level and
SIGSEGVs, instead of running in constant stack like direct recursion. The
crash is reachable under default settings — it happens below the default
10,000 step limit — so the bounded-execution guarantee (the step limit) is
bypassed by an uncatchable stack overflow with silent, empty output.
This contradicts
docs/ARCHITECTURE.md: "All transfers are tail calls andcontinuation frames are heap-allocated, so Prolog recursion depth never grows
the C stack." That holds for direct predicate recursion; it does not hold
for
call/1.Reproduction
Crossover is ~5–6k deep. With the identical predicate using direct
recursion (
count(N1)instead ofcall(count(N1))),go(5000000)succeeds inconstant stack — so the growth is specifically the metacall re-entry, not the
>/isarithmetic body.go(4000)go(6000)go(5000000)call(count(N1))count(N1)(direct)Why this is a bug (not just a missing optimization)
Even setting aside whether
call/1should get last-call optimization, theuncatchable SIGSEGV below the step limit is the defect: the step limit
exists to bound runaway execution and fail gracefully, and this path overflows
the native stack (~5k frames) before the limit (10k steps) is ever reached. The
process dies with exit 139 and no diagnostic.
Root cause
crates/runtime/src/solve.rs,dispatch()re-enters compiled code with a plainRust call:
This is not a tail call (Rust has no guaranteed TCO). The callee
frunsits full sub-search — including, for
call/1recursion, another trip throughcall_goal → dispatch → ffor the next level — before returning. So eachmetacall level leaves a
dispatch/call_goalRust frame on the C stack.Direct compiled goal calls are emitted as
musttailin generated IR and areexempt, which is why direct recursion stays constant.
This is the same class of issue as the fact-table delivery rule (delivery to the
continuation must be a
musttailin generated IR, not a Rust call tok).Scope / related paths
Anything that re-enters compiled code via
call_goal/dispatchshares thepath and should be checked:
call/Nmetacall (this report)findall/3goal re-entrycatch/3recovery-goal dispatch (unwind_to_catchcallscall_goal)Suggested direction
Route metacall dispatch into compiled code via a generated-IR
musttailtrampoline (deliver the target function pointer back to IR that tail-calls it),
mirroring the fact-table delivery fix, rather than
dispatch()callingfdirectly in Rust. At minimum, the step limit must bound this path so it fails
gracefully instead of SIGSEGV-ing below the limit.
Environment
Found on aarch64-apple-darwin during the WASM Checkpoint-0 spike. The native
binary is the artifact that crashes; the wasm path traps the same way (it's a
native-architecture issue, independent of target).
#24