Skip to content

Harden arith parse recursion - #122

Merged
shsms merged 7 commits into
mainfrom
harden-arith-parse-recursion
May 30, 2026
Merged

Harden arith parse recursion#122
shsms merged 7 commits into
mainfrom
harden-arith-parse-recursion

Conversation

@shsms

@shsms shsms commented May 30, 2026

Copy link
Copy Markdown
Owner

No description provided.

shsms added 7 commits May 29, 2026 21:35
A list whose dot is the last token before EOF (`(1 .`, `(.`) panicked: `parse_list` called `.unwrap()` on `parse_value()`, which returns `None` at end of input. Return a `ParsingError` instead, like the quote / backquote / comma arms already do.
Emacs has `%` as the truncated integer remainder (sign of the dividend), distinct from the floored `mod`. It was missing. Integer-only operands (enforced by the `i64` args, matching Emacs), errors on a zero divisor, and uses `wrapping_rem` so `i64::MIN % -1` yields 0 rather than overflow-panicking.
Emacs returns 0, 1, and 0 for `(+)`, `(*)`, and `(-)`; tulisp required at least one argument. Both eval paths needed fixing — the VM compiles these as special forms (`compile_fn_*`) while the tree-walker dispatches the typed builtin — so each now yields its identity on empty input. `/` still requires an argument, as in Emacs. The funcall too-few-args test moves to `1+`, since `+` no longer errors on zero args.
Integer `(/ X Y)` and `(mod X Y)` panicked the host on `i64::MIN / -1` (Rust traps division overflow in debug *and* release, so `condition-case` could not catch it). Add `Number::checked_div` / `checked_mod` and route the `/` and `mod` builtins and the VM `BinaryOp::Div` through them, surfacing overflow as a catchable `OutOfRange`.

`mod` also used Rust truncated remainder, so it took the dividend's sign — `(mod -7 3)` gave -1 where Emacs gives 2. `checked_mod` is floored (sign of the divisor) for both integer and float operands. Tests for `/` and `mod` move to colocated unit tests.
Deep non-tail recursion grew the native stack until the process aborted with a stack overflow — uncatchable by `condition-case`, fatal to the embedding host. Track Lisp-call nesting on the context and raise a catchable `LispError` once it exceeds `max_eval_depth`. The counter is incremented at the two points that actually push native frames — the VM `run_impl` and the tree-walker `eval_lambda` — so tail calls, which trampoline through their own loops, do not count (a 30000-deep tail recursion still completes).

The default is sized to the stack each build needs. Under `cfg(test)` it is 16, since the harness runs on ~2 MiB threads; otherwise it targets the 8 MiB main thread of `cargo run` and typical embeddings — 64 in debug, where each frame is an order of magnitude larger, and 1000 in release. A test recurses past the default on an 8 MiB thread, on both eval paths, and asserts a catchable error rather than an overflow — so a cap set too high for that stack aborts the test instead of shipping. `set_max_eval_depth` tunes it for other stacks.
Deeply nested input (e.g. tens of thousands of nested parens, or a runtime-built nested form passed to `macroexpand`) recursed unguarded through `parse_value` / `parse_list` and `macroexpand`, overflowing the native stack and aborting the host — uncatchable, like the eval-recursion case. Add a `MAX_NESTING_DEPTH` cap (256 debug / 4000 release, sized for the 8 MiB main thread): the parser tracks nesting on a `Parser` field, `macroexpand` on a recursion-depth argument, and both raise a catchable error past it.

Guarding the parser is the keystone — every nested list/quote re-enters `parse_value`, so no parsed AST exceeds the cap, which transitively bounds the downstream compile-time walks (`recursive_update_ctxobj`, `free_vars`, backquote, substitute, …) for parsed input. `macroexpand` is additionally guarded because it is directly callable on runtime-built structures. Addresses the parser/macroexpand part of todo b4. The guards cost a couple of integer ops per node; benchmarks (parse_long_list, parse_many_forms, macroexpand_long) are unchanged within run-to-run noise.
A compiled `defun` that recurses through `(eval …)` bounces VM -> tree-walker -> VM each level. Because the depth counter is incremented at every `run_impl` / `eval_lambda` entry — one per interleaved level — the cap bounds this with a catchable error instead of a stack overflow. Lock that, since a regression would only surface as an uncatchable host abort.
@shsms
shsms merged commit bb87c8c into main May 30, 2026
2 checks passed
@shsms
shsms deleted the harden-arith-parse-recursion branch May 30, 2026 23:50
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