Found while measuring the stack cost of the #730 json_encode depth bound. Not a crash in shipped code — a gap between what the depth guards claim to bound and what actually binds first on the constrained profile.
Measured
Probe binary = the normal hosted build, run under decreasing ulimit -s:
| Workload |
Stack floor |
print of 1 — baseline, no recursion |
32–40 KiB |
json_encode at 191 levels |
48–56 KiB |
Nested expression x is ((((1)))), at a 64 KiB stack |
SIGSEGV between 40 and 60 levels |
Cross-check from the other direction: a 250-level nested expression is rc=139 at ulimit -s 64, and a clean parse error(s) — aborting at ulimit -s 128.
The two findings
1. PARSE_MAX_DEPTH (256) cannot fire under a 64 KiB stack. The process dies at roughly 40–60 source nesting levels — four to six times below the guard. On that profile the depth limit is not the binding constraint; the stack is, and it binds far earlier. Same likely applies to COMPILE_MAX_DEPTH (128): compile_node + compile_node_inner measure 1552 B/level via -fstack-usage, so 128 levels is ~194 KiB on its own.
2. tools/embed_stack_soak.sh's comment is a fossil. It says the 64 KiB rlimit is "the EigenOS boot-stack size". EigenOS/src/boot.S:40 now allocates 1 MiB — raised as part of the #361 fix, with a comment explaining that 64 KiB was what produced the #UD heisenbug. So the gate tests against a stack size EigenOS no longer uses.
The baseline is the interesting number
~40 KiB is consumed before any recursive walker runs — startup, parse, VM. That is ~62% of the soak's 64 KiB budget gone before the first recursive descent, leaving ~24 KiB of usable headroom. The soak passes only because a REPL soak runs shallow programs.
That does not make it worthless — as a per-level cost regression detector it is what caught #361, and a tighter-than-reality limit is arguably the right design for that job. But it is not the worst-case bound its comment implies, and someone reading that comment would believe 64 KiB is the live EigenOS constraint.
Suggested
- Fix the comment in
tools/embed_stack_soak.sh — state that 64 KiB is a deliberately tight per-level regression budget, not EigenOS's stack, and that EigenOS is 1 MiB with a canary.
- Decide whether the depth guards are meant to be reachable on the constrained profile. If yes, they need to be stack-derived rather than fixed counts, or the profile needs a documented minimum stack. If no, document that the guards are sized for the 1 MiB boot stack and the constrained profile relies on the canary instead.
- Consider a soak case that actually approaches a guard, so "the guard fires cleanly" is tested rather than assumed.
Not addressed in #757 (the json_encode PR), which only shares the measurement.
Found while measuring the stack cost of the #730
json_encodedepth bound. Not a crash in shipped code — a gap between what the depth guards claim to bound and what actually binds first on the constrained profile.Measured
Probe binary = the normal hosted build, run under decreasing
ulimit -s:print of 1— baseline, no recursionjson_encodeat 191 levelsx is ((((1)))), at a 64 KiB stackCross-check from the other direction: a 250-level nested expression is
rc=139atulimit -s 64, and a cleanparse error(s) — abortingatulimit -s 128.The two findings
1.
PARSE_MAX_DEPTH(256) cannot fire under a 64 KiB stack. The process dies at roughly 40–60 source nesting levels — four to six times below the guard. On that profile the depth limit is not the binding constraint; the stack is, and it binds far earlier. Same likely applies toCOMPILE_MAX_DEPTH(128):compile_node+compile_node_innermeasure 1552 B/level via-fstack-usage, so 128 levels is ~194 KiB on its own.2.
tools/embed_stack_soak.sh's comment is a fossil. It says the 64 KiB rlimit is "the EigenOS boot-stack size".EigenOS/src/boot.S:40now allocates 1 MiB — raised as part of the #361 fix, with a comment explaining that 64 KiB was what produced the#UDheisenbug. So the gate tests against a stack size EigenOS no longer uses.The baseline is the interesting number
~40 KiB is consumed before any recursive walker runs — startup, parse, VM. That is ~62% of the soak's 64 KiB budget gone before the first recursive descent, leaving ~24 KiB of usable headroom. The soak passes only because a REPL soak runs shallow programs.
That does not make it worthless — as a per-level cost regression detector it is what caught #361, and a tighter-than-reality limit is arguably the right design for that job. But it is not the worst-case bound its comment implies, and someone reading that comment would believe 64 KiB is the live EigenOS constraint.
Suggested
tools/embed_stack_soak.sh— state that 64 KiB is a deliberately tight per-level regression budget, not EigenOS's stack, and that EigenOS is 1 MiB with a canary.Not addressed in #757 (the
json_encodePR), which only shares the measurement.