Skip to content

fix: run deep non-tail recursion on fresh coroutine stacks (#93; builds on #90) - #95

Merged
pyrex41 merged 2 commits into
mainfrom
capcov/fix-93-lua-stack-depth
Oct 19, 2026
Merged

pyrex41 merged 2 commits into
mainfrom
capcov/fix-93-lua-stack-depth

Conversation

@pyrex41

@pyrex41 pyrex41 commented Oct 19, 2026 •

Copy link
Copy Markdown
Owner

Fixes #93

Base: main 039af36, where #89, #90, #91 and #94 are merged. The branch is 2fdb849 (the fix, built on #90) plus a merge of origin/main, e0902bc. That merge commit applies the compiler.lua resolution against #91 described under "Merging with #91" below.

On the merged head e0902bc

make test make certify
main 039af36 TOTAL: 1718 pass, 0 fail across 26 specs passed ... 154 / failed ... 0
this PR (e0902bc) TOTAL: 1762 pass, 0 fail across 27 specs passed ... 154 / failed ... 0
  • Repros. All five repros produce stdout byte-identical to shen-go 1f587de, with empty stderr.
  • Split let chain. A 100000-deep recursion through a function whose let chain is split into its own function prints 100000. The split part counts itself: its generated code contains SEG(KC[.
  • Capcov workloads. On fresh caches, round 0 is cold and rounds 1–2 are warm. Every payload is byte-identical to shen-go 1f587de on both main and this PR:
round=0 impl=mainM wl=b-typed wall=59.87s cpu=56.75s exit=0 sha=dd877be7df09f341 go-identical=True
round=0 impl=headM wl=b-typed wall=60.66s cpu=57.40s exit=0 sha=dd877be7df09f341 go-identical=True
round=0 impl=mainM wl=c-untyped wall=3.01s cpu=2.06s exit=0 sha=ea1dbec00c0a9816 go-identical=True
round=0 impl=headM wl=c-untyped wall=2.51s cpu=2.05s exit=0 sha=ea1dbec00c0a9816 go-identical=True
round=0 impl=mainM wl=d-g5 wall=19.17s cpu=10.03s exit=0 sha=86ef879c9b78f761 go-identical=True
round=0 impl=headM wl=d-g5 wall=24.59s cpu=11.24s exit=0 sha=86ef879c9b78f761 go-identical=True
round=0 impl=mainM wl=e-draft wall=3.52s cpu=0.71s exit=0 sha=bc80629a233e75af go-identical=True
round=0 impl=headM wl=e-draft wall=1.04s cpu=0.69s exit=0 sha=bc80629a233e75af go-identical=True
round=0 impl=mainM wl=f-modelcheck wall=1.86s cpu=0.47s exit=0 sha=93ccb82e6c2849e0 go-identical=True
round=0 impl=headM wl=f-modelcheck wall=0.67s cpu=0.44s exit=0 sha=93ccb82e6c2849e0 go-identical=True
round=1 impl=mainM wl=b-typed wall=43.56s cpu=40.17s exit=0 sha=dd877be7df09f341 go-identical=True
round=1 impl=headM wl=b-typed wall=45.87s cpu=38.14s exit=0 sha=dd877be7df09f341 go-identical=True
round=1 impl=mainM wl=c-untyped wall=3.56s cpu=1.56s exit=0 sha=ea1dbec00c0a9816 go-identical=True
round=1 impl=headM wl=c-untyped wall=3.22s cpu=1.26s exit=0 sha=ea1dbec00c0a9816 go-identical=True
round=1 impl=mainM wl=d-g5 wall=15.70s cpu=4.17s exit=0 sha=86ef879c9b78f761 go-identical=True
round=1 impl=headM wl=d-g5 wall=9.93s cpu=4.08s exit=0 sha=86ef879c9b78f761 go-identical=True
round=1 impl=mainM wl=e-draft wall=0.80s cpu=0.31s exit=0 sha=bc80629a233e75af go-identical=True
round=1 impl=headM wl=e-draft wall=0.47s cpu=0.26s exit=0 sha=bc80629a233e75af go-identical=True
round=1 impl=mainM wl=f-modelcheck wall=2.18s cpu=0.16s exit=0 sha=93ccb82e6c2849e0 go-identical=True
round=1 impl=headM wl=f-modelcheck wall=1.27s cpu=0.16s exit=0 sha=93ccb82e6c2849e0 go-identical=True
round=2 impl=mainM wl=b-typed wall=40.80s cpu=38.67s exit=0 sha=dd877be7df09f341 go-identical=True
round=2 impl=headM wl=b-typed wall=43.46s cpu=40.70s exit=0 sha=dd877be7df09f341 go-identical=True
round=2 impl=mainM wl=c-untyped wall=3.69s cpu=1.38s exit=0 sha=ea1dbec00c0a9816 go-identical=True
round=2 impl=headM wl=c-untyped wall=1.58s cpu=1.28s exit=0 sha=ea1dbec00c0a9816 go-identical=True
round=2 impl=mainM wl=d-g5 wall=18.01s cpu=4.40s exit=0 sha=86ef879c9b78f761 go-identical=True
round=2 impl=headM wl=d-g5 wall=12.43s cpu=4.83s exit=0 sha=86ef879c9b78f761 go-identical=True
round=2 impl=mainM wl=e-draft wall=0.88s cpu=0.39s exit=0 sha=bc80629a233e75af go-identical=True
round=2 impl=headM wl=e-draft wall=0.59s cpu=0.29s exit=0 sha=bc80629a233e75af go-identical=True
round=2 impl=mainM wl=f-modelcheck wall=2.86s cpu=0.17s exit=0 sha=93ccb82e6c2849e0 go-identical=True
round=2 impl=headM wl=f-modelcheck wall=0.96s cpu=0.17s exit=0 sha=93ccb82e6c2849e0 go-identical=True

The run above includes the cold round 0. b-typed and c-untyped then got 3 more rounds on warm caches, against main:

round=0 impl=mainM wl=b-typed wall=51.05s cpu=41.86s exit=0 sha=dd877be7df09f341 go-identical=True
round=0 impl=headM wl=b-typed wall=42.25s cpu=37.83s exit=0 sha=dd877be7df09f341 go-identical=True
round=0 impl=mainM wl=c-untyped wall=3.76s cpu=1.32s exit=0 sha=ea1dbec00c0a9816 go-identical=True
round=0 impl=headM wl=c-untyped wall=4.06s cpu=1.31s exit=0 sha=ea1dbec00c0a9816 go-identical=True
round=1 impl=mainM wl=b-typed wall=42.51s cpu=39.02s exit=0 sha=dd877be7df09f341 go-identical=True
round=1 impl=headM wl=b-typed wall=40.46s cpu=39.07s exit=0 sha=dd877be7df09f341 go-identical=True
round=1 impl=mainM wl=c-untyped wall=3.07s cpu=1.49s exit=0 sha=ea1dbec00c0a9816 go-identical=True
round=1 impl=headM wl=c-untyped wall=1.74s cpu=1.52s exit=0 sha=ea1dbec00c0a9816 go-identical=True
round=2 impl=mainM wl=b-typed wall=41.55s cpu=39.96s exit=0 sha=dd877be7df09f341 go-identical=True
round=2 impl=headM wl=b-typed wall=40.41s cpu=39.90s exit=0 sha=dd877be7df09f341 go-identical=True
round=2 impl=mainM wl=c-untyped wall=1.75s cpu=1.54s exit=0 sha=ea1dbec00c0a9816 go-identical=True
round=2 impl=headM wl=c-untyped wall=1.67s cpu=1.52s exit=0 sha=ea1dbec00c0a9816 go-identical=True

The sections below were measured before the merge, against #90 (4456af0) and a local merge of #89, #90 and #91.

Where it overflowed (step 1)

Every case failed on LuaJIT's Lua stack, which is LUAI_MAXSTACK 65500 slots per coroutine. luaconf.h marks this limit "<64K", and it is a compile-time constant, so a LuaJIT rebuild could not raise it much either. None of the cases failed on the C stack.

repro base 4456af0 (#90) #89+#90+#91 merged this branch shen-go 1f587de
nat-3000 ((shen.typecheck (mk 3000) peano)) prims.lua:2292: stack overflow (shen.curry) shen: stack overflow peano peano
nat-5000 (non-tail mk) [string "shen:mk"]:1: stack overflow shen: stack overflow peano peano
tc-10000 ([list number], non-tail mk) [string "shen:mk"]:1: stack overflow same [list number] [list number]
rec.shen (depth 5000/8000/12000/100000) 5000 8000, then [string "shen:depth"]:1: stack overflow same 5000 8000 12000 100000 5000 8000 12000 100000
rd-4000 ((length [1 … 4000])) reader error near here: (the reader, #88) [string "reader"]:26: stack overflow (post-reader walkers) ok 4000 ok 4000

Mechanism

Every coroutine has its own Lua stack, so deep recursion continues on a fresh one. This generalises what #90 does for the Prolog engine.

  • What counts. A compiled function that makes a non-tail call to anything that may run Shen code counts itself in SD[1] while it is on the stack:

    local d = SD[1]; if d >= 160 then return SEG(impl, a1, ...) end; SD[1] = d + 1
    ... SD[1] = d; return ...        -- every return restores the count first
  • Tail calls. A tail call stays a tail call. Its arguments run first, hoisted into locals when they make counted calls themselves. Then the count is restored, then the call runs. A constant-stack loop, whether a self-loop, mutual tail calls, or a CPS chain through lambdas, never opens a segment, and the spec checks this.

  • Opening a segment. Once 160 counted functions are open on one stack, the next one runs on a new coroutine (runtime.lua M.seg.run). Errors are rethrown unchanged, so Shen exceptions cross segments intact. A host yield is passed through, as in fix(prolog): run deep proofs on fresh coroutine stacks (#85) #90.

  • Other callers.

    • Lambda bodies inside a defun count themselves the same way (counted_body). On the rare segment path a lambda finds itself with debug.getinfo.
    • Value-position control forms (KC bodies) count for their enclosing function.
    • Freeze bodies, TRMC step functions and code outside a defun go through a vararg trampoline. There is one copy per defun, because a single shared trampoline makes every nested call look like recursion through one prototype to LuaJIT's trace recorder.
    • Natives that call back into Shen code (map, mapcan, and the Prolog engine's import) count inline.
    • KL primitives and protected system functions that never call back are not counted (LEAF).
  • Errors. Every catch point restores the count by assignment: compiled trap-error, the runtime's pcalls, the REPL, host entry points (shen.eval, shen.call, lua_interop), and the segment runner.

  • Limit. Past 10000 nested segments (fix(prolog): run deep proofs on fresh coroutine stacks (#85) #90's included), a call raises the trappable Lua stack depth limit exceeded (10000 stack segments). Without the cap, nested resumes crash with SIGSEGV between 40k and 50k levels on an 8 MB main-thread stack. At the default cap, a 2,000,000-deep recursion raises the trappable error, with 100 MB peak RSS.

Why not the alternatives

  • A trampoline around every non-tail call (NT(f, args)): c-untyped ran 15–25% slower.
    • With fixed arity, LuaJIT starts root traces at the shared trampoline's entry, and they abort with "return to lower frame".
    • With varargs, recording aborts with "call unroll limit reached", because one prototype repeats at every nesting level.
    • With one copy per defun, it is still about 10% slower, almost all of it in lambda bodies.
  • Counting in the callee costs no extra frame. After the LEAF set, c-untyped is within noise.
  • Raising LJ_STACK_MAX: not possible, as explained above.

Performance

In-process CPU time for c-untyped ((load driver.shen) timed with os.clock), 8 interleaved rounds on a shared machine:

base min 1.279 med 1.459 1.75 1.50 1.34 1.66 1.43 1.46 1.45 1.28
head min 1.200 med 1.509 1.64 1.46 1.61 1.73 1.56 1.36 1.43 1.20

Capcov workloads via the bench harness, with every cache warmed first. Wall and CPU time per round; payload sha256 compared to shen-go 1f587de:

round=0 impl=base wl=c-untyped wall=1.69s cpu=1.45s exit=0 sha=ea1dbec00c0a9816 go-identical=True
round=0 impl=head wl=c-untyped wall=1.75s cpu=1.54s exit=0 sha=ea1dbec00c0a9816 go-identical=True
round=0 impl=base wl=d-g5 wall=7.19s cpu=5.20s exit=0 sha=86ef879c9b78f761 go-identical=True
round=0 impl=head wl=d-g5 wall=5.48s cpu=4.98s exit=0 sha=86ef879c9b78f761 go-identical=True
round=1 impl=base wl=c-untyped wall=1.56s cpu=1.54s exit=0 sha=ea1dbec00c0a9816 go-identical=True
round=1 impl=head wl=c-untyped wall=1.37s cpu=1.34s exit=0 sha=ea1dbec00c0a9816 go-identical=True
round=1 impl=base wl=d-g5 wall=5.26s cpu=5.04s exit=0 sha=86ef879c9b78f761 go-identical=True
round=1 impl=head wl=d-g5 wall=5.05s cpu=4.87s exit=0 sha=86ef879c9b78f761 go-identical=True
round=2 impl=base wl=c-untyped wall=1.39s cpu=1.38s exit=0 sha=ea1dbec00c0a9816 go-identical=True
round=2 impl=head wl=c-untyped wall=1.33s cpu=1.32s exit=0 sha=ea1dbec00c0a9816 go-identical=True
round=2 impl=base wl=d-g5 wall=5.04s cpu=4.87s exit=0 sha=86ef879c9b78f761 go-identical=True
round=2 impl=head wl=d-g5 wall=5.06s cpu=4.92s exit=0 sha=86ef879c9b78f761 go-identical=True
round=0 impl=integbase wl=b-typed wall=36.49s cpu=36.04s exit=0 sha=dd877be7df09f341 go-identical=True
round=0 impl=integ2 wl=b-typed wall=53.08s cpu=49.02s exit=0 sha=dd877be7df09f341 go-identical=True
round=0 impl=integbase wl=c-untyped wall=2.44s cpu=1.98s exit=0 sha=ea1dbec00c0a9816 go-identical=True
round=0 impl=integ2 wl=c-untyped wall=2.25s cpu=1.96s exit=0 sha=ea1dbec00c0a9816 go-identical=True
round=0 impl=integbase wl=d-g5 wall=11.31s cpu=6.95s exit=0 sha=86ef879c9b78f761 go-identical=True
round=0 impl=integ2 wl=d-g5 wall=11.93s cpu=6.95s exit=0 sha=86ef879c9b78f761 go-identical=True
round=1 impl=integbase wl=b-typed wall=39.60s cpu=38.77s exit=0 sha=dd877be7df09f341 go-identical=True
round=1 impl=integ2 wl=b-typed wall=38.99s cpu=38.47s exit=0 sha=dd877be7df09f341 go-identical=True
round=1 impl=integbase wl=c-untyped wall=1.56s cpu=1.53s exit=0 sha=ea1dbec00c0a9816 go-identical=True
round=1 impl=integ2 wl=c-untyped wall=1.48s cpu=1.47s exit=0 sha=ea1dbec00c0a9816 go-identical=True
round=1 impl=integbase wl=d-g5 wall=5.53s cpu=5.07s exit=0 sha=86ef879c9b78f761 go-identical=True
round=1 impl=integ2 wl=d-g5 wall=5.40s cpu=5.12s exit=0 sha=86ef879c9b78f761 go-identical=True
round=2 impl=integbase wl=b-typed wall=36.00s cpu=35.98s exit=0 sha=dd877be7df09f341 go-identical=True
round=2 impl=integ2 wl=b-typed wall=35.67s cpu=35.65s exit=0 sha=dd877be7df09f341 go-identical=True
round=2 impl=integbase wl=c-untyped wall=1.58s cpu=1.57s exit=0 sha=ea1dbec00c0a9816 go-identical=True
round=2 impl=integ2 wl=c-untyped wall=1.43s cpu=1.42s exit=0 sha=ea1dbec00c0a9816 go-identical=True
round=2 impl=integbase wl=d-g5 wall=5.35s cpu=5.17s exit=0 sha=86ef879c9b78f761 go-identical=True
round=2 impl=integ2 wl=d-g5 wall=5.39s cpu=5.11s exit=0 sha=86ef879c9b78f761 go-identical=True
round=0 impl=integbase wl=b-typed wall=39.53s cpu=38.61s exit=0 sha=dd877be7df09f341 go-identical=True
round=0 impl=integ2 wl=b-typed wall=41.07s cpu=40.20s exit=0 sha=dd877be7df09f341 go-identical=True
round=1 impl=integbase wl=b-typed wall=43.67s cpu=41.86s exit=0 sha=dd877be7df09f341 go-identical=True
round=1 impl=integ2 wl=b-typed wall=47.20s cpu=42.62s exit=0 sha=dd877be7df09f341 go-identical=True

Tests

test/stack_segments_spec.lua is new and has 44 checks. Each recursion below runs 100000 deep and opens segments, and the count must come back to where it started:

It also checks:

  • Self, mutual and lambda/CPS tail loops open no segment.
  • A deep Shen error reaches trap-error and the host unchanged.
  • After 500 trapped errors there is no leak.
  • Both the default and a lowered nesting cap raise a trappable error.

Also, after a full make certify run, the count is back at 0 and no segments are nested; certify opened 4 segments in all.

make test make certify
base 4456af0 TOTAL: 1223 pass, 0 fail across 23 specs passed ... 154 / failed ... 0
this branch TOTAL: 1267 pass, 0 fail across 24 specs passed ... 154 / failed ... 0
#89+#90+#91 TOTAL: 1706 pass, 0 fail across 26 specs passed ... 154 / failed ... 0
#89+#91+this branch TOTAL: 1750 pass, 0 fail across 27 specs passed ... 154 / failed ... 0

Merging with #91

When this branch was merged with main, it conflicted with #91 in compiler.lua (and CHANGELOG.md). Taking #91's side wholesale still passes every test, but it leaves paths uncounted, and those overflow only at depth. The merge commit e0902bc applies this resolution:

resolved hunks
local function hoist_kc(form, env, lnames, own)
  lnames = lnames or kc_lnames(form, env)
  local params, args, remap, nslots = capture_plan(lnames)
  local saved_self = SELF
  local saved_h, saved_cc, saved_fnl = HOIST, CCACHE, FNL
  local saved_cont = push_inline_cont()
  remap_cont(remap)
  local saved_impl, saved_cnt = IN_IMPL, CNT
  SELF = nil; HOIST = nil; CCACHE = {}; FNL = new_fnl(true, nslots); IN_IMPL = false
  local owner
  if own then
    owner = { used = false, tok = "\1" .. gen("k") .. "\1" }
    CNT = { owner = owner, ret = true }
  else
    CNT = CNT and { owner = CNT.owner, ret = false }
  end
  local n0, t0 = NTCOUNT, TCOUNT
  local body_stmts = ctail(form, remap_env(env, remap))
  if FNL.pt then body_stmts = "local PT = {}; " .. body_stmts end
  SELF = saved_self; HOIST = saved_h; CCACHE = saved_cc; FNL = saved_fnl
  CONT = saved_cont; IN_IMPL = saved_impl; CNT = saved_cnt
  local idx = #CTX.cbodies + 1
  if owner and owner.used then
    local dv = gen("d")
    CTX.sd = true
    body_stmts = "local " .. dv .. " = SD[1]; if " .. dv .. " >= " .. SEG_FRAMES
      .. " then return SEG(KC[" .. idx .. "]" .. (params ~= "" and (", " .. params) or "")
      .. ") end; SD[1] = " .. dv .. " + 1; "
      .. (body_stmts:gsub(owner.tok, "SD[1] = " .. dv .. "; "))
  elseif owner then
    body_stmts = (body_stmts:gsub(owner.tok, ""))
  end
  CTX.cbodies[idx] = "function(" .. params .. ") " .. body_stmts .. " end"
  if not own and (NTCOUNT ~= n0 or TCOUNT ~= t0) and form ~= TAIL_FORM then
    return nt_call("KC[" .. idx .. "]", args, #lnames)
  end
  return "KC[" .. idx .. "](" .. args .. ")"
end
  • try_split_let ends with return ret(hoist_kc(form, env, lnames, true)). The split part is tail-called, so it counts itself.
  • The CURRY, LAMW and wide freeze paths compile their bodies with CNT = nil and cexpr_tail (save and restore CNT next to HOIST/IN_IMPL), e.g.:
        local saved_h, saved_impl, saved_cnt = HOIST, IN_IMPL, CNT
        HOIST = nil; IN_IMPL = false; CNT = nil   -- runs outside this frame
        local bodyc = cexpr_tail(inner, e2)
        HOIST = saved_h; IN_IMPL = saved_impl; CNT = saved_cnt

Limits and follow-ups

  • Coverage gaps. A recursion is not counted if its only frames waiting on a non-tail call are freeze bodies or lambdas outside a defun.
  • Host coroutines. One count is shared across host coroutines. If the host interleaves Shen evaluation across coroutines, the count is approximate. fix(prolog): run deep proofs on fresh coroutine stacks (#85) #90 has the same property.
  • C-stack budget. The 10000-segment cap assumes a main-thread-sized C stack: about 200 bytes per nested resume, roughly 2 MB. An embedding that runs Shen on a 512 KB pthread stack would need a lower cap (R.seg.limit).
  • debug library. SEGSELF needs debug.getinfo.
  • tc-10000 is slow. It now succeeds but takes ~8 s, against go's 0.06 s. The superlinear cost predates this change: tc-3000 takes the same time on base and head.

Commands

# repros (private caches)
SHEN_KERNEL_CACHE=… SHEN_FASL_DIR=… luajit bin/shen --hush-load {nat-3000,nat-5000,tc-10000,rec,rd-4000}.shen
shen-go-1f587de/bin/shen script <same files>
# suites
make test; make certify
# workloads: interleaved, b-typed/c-untyped/d-g5, payload compared to go's
python3 -I bench93.py 3 OUT b-typed,c-untyped,d-g5 integbase=…,integ2=…

🤖 Generated with Claude Code

pyrex41 and others added 2 commits October 19, 2026 07:55
LuaJIT's Lua stack is a fixed ~65500 slots per coroutine (LJ_STACK_MAX is
a compile-time constant), so non-tail recursion overflowed after ~12k
frames: user code, and the kernel's tree walkers (shen.curry,
shen.find-types, shen.process-applications). Every coroutine has its own
stack, so deep recursion now continues on a fresh one, generalising what
#90 does for the Prolog engine.

A compiled function that makes a non-tail call counts itself in SD[1]
while it is on the stack: it opens with a check and an increment, and
restores the count before every return. A tail call stays a tail call:
its arguments are evaluated first (hoisted when they make counted calls),
then the count is restored. Once 160 counted functions are open on one
stack, the next one runs on a new coroutine (runtime.lua M.seg.run). Lambda
bodies in a defun count themselves the same way; freeze bodies and code
outside a defun use a vararg trampoline (one copy per defun); natives that
call back (map, mapcan, the Prolog engine's import) count inline.

Errors restore the count by assignment at every catch point (compiled
trap-error, the runtime's pcalls, host entry points, the segment runner).
The Prolog engine's segments go through the same runner, so one nesting
cap (10000, a trappable error) covers the shared C stack.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Resolves compiler.lua against #91: hoist_kc takes the counting mode
(inherit for value-position control forms, own for a split let chain),
try_split_let returns ret(hoist_kc(..., true)), and the CURRY, LAMW and
wide freeze paths compile with CNT = nil and cexpr_tail; counted_body
uses new_fnl(true, 1). CHANGELOG keeps both sides.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@pyrex41
pyrex41 merged commit dcfe548 into main Oct 19, 2026
2 checks passed
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.

Lua stack depth outside the Prolog engine: shen.curry, post-reader walkers, user non-tail recursion (~12k frames)

1 participant