Skip to content

Throw a catchable RangeError on deep recursion - #805

Merged
ctate merged 1 commit into
mainfrom
ctate/throw-catchable-rangeerror-cf7fb6
Oct 9, 2026
Merged

ctate merged 1 commit into
mainfrom
ctate/throw-catchable-rangeerror-cf7fb6

Conversation

@ctate

@ctate ctate commented Oct 9, 2026

Copy link
Copy Markdown
Collaborator

Deep recursion in a native executable used to stop the process with a signal. It now throws Node's catchable RangeError: Maximum call stack size exceeded, so catch, finally, worker 'error' events and exit codes, and the uncaught exit status 1 all behave as in Node.

  • Where the checks go: only functions on call-graph cycles get one, including recursion through closures, methods and callbacks. The cycles come from strongly connected components over direct, new and virtual call edges, plus reentrant operations that call closures and class members. Non-recursive code, including leaf callbacks, stays check-free, and tail calls keep tail-call and recursion elimination.
  • The check: compares the stack pointer against a per-thread limit (two instructions). On overflow it raises the RangeError through the existing exception path. The runtime sets the limit per thread and per async fiber, from the main thread's stack bounds, the worker thread's attributes, or the platform stack address. It keeps a reserve for unwinding and error reporting. Worker threads now get an explicit 8 MiB stack.
  • Scope: main thread, worker threads, and async functions and generators. Windows, WASI and library builds keep the previous behavior. limitations.mdx is updated.

Measured overhead (release, minimum of 11 runs × 3 rounds, before → after):

Workload Before After
fib(26..32) 40 ms 37 ms
binary trees, depth 20 ×10 818 ms 817 ms (median +0–2%)
mutual tail recursion 27 ms 27 ms
closure/reduce tree walk 1347 ms 1342 ms (median about +1%)
non-recursive loops 300–314 ms 300–314 ms
Ackermann 485 ms 562 ms (+16%)

Ackermann is the worst case: its inner non-tail call ack(m, n - 1) now needs an exception check after the call, which also forces extra register and constant reloads in the recursion loop. The static hello executable is unchanged (68,912 bytes), and async hello grows by 120 bytes.

Validation: full plain and sanitized corpus 2177/2177 each; the compiler compiles itself; self-hosting suites 54/54; compiler package tests 632 and unit tests 1858; diagnostics, coverage and surface-manifest 263; runtime-tree-shaking passes; runtime tests pass apart from known environment-only cases; npm-static 91/91; library-mode passes apart from known environment-only cases; TypeScript preflight/order baselines complete; lint and format clean. After merging the latest main, the focused stack-overflow, recursion and recently changed programs pass in the sanitized lane (34). The Effect suites are still running.

Known limits: async bodies run on 256 KiB fibers, so they overflow earlier than in Node. Recursion inside runtime C over deeply nested data is not checked. Recursive functions can now throw, so LLVM no longer folds repeated pure recursive calls.

Native executables used to terminate with a signal when recursion
exhausted the stack. Functions that can take part in unbounded recursion
now compare the stack pointer against a per-stack guard on entry and
throw Node's `RangeError: Maximum call stack size exceeded` through the
ordinary exception path, so catch handlers, finally blocks, worker error
events and the uncaught-error exit code behave as they do under Node.

- The checked set is every function on a cycle of the call graph, where
  operations that can reenter compiled code indirectly (indirect calls,
  callbacks, dynamic and library operations) reach closure targets and
  class members. Non-recursive functions, including leaf callbacks, stay
  check-free, and the may-throw analysis accounts for the new throw.
- The runtime installs the guard per context (main thread from the stack
  rlimit, workers from their thread attributes) and per async fiber, with
  a reserve for the throw, unwinding and error reporting. Worker threads
  now get an explicit 8 MiB stack.
- Scalar call results pass through the unwind path as its dummy value, so
  calls in tail position keep tail-call and recursion elimination.
@vercel

vercel Bot commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
scriptc Ready Ready Preview, v0 Oct 9, 2026 9:56pm UTC

@ctate
ctate merged commit c0208fb into main Oct 9, 2026
73 of 78 checks passed

This branch was successfully deployed

1 active deployment
Preview — 3bcf761f Deployed Oct 9, 2026 by vercel[bot]
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