You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
wasm: stack overflow on long binary expression chains with a 1 MB host stack #99
The wasm build runs out of native stack on a long chain of binary operators when the host gives it about 1 MB of stack (the Node main thread, Cloudflare workerd, browser main threads). Found while reviewing #85; the overflow is on main too, not from that PR.
Repro
A file system with tsconfig.json{ "compilerOptions": { "strict": true, "noEmit": true }, "files": ["guest.ts"] } and a guest.ts of 3,000 lines export const vN: number = N; plus
Run it with runTsc from npm/wasm on the Node main thread:
build
terms that overflow
wasm, release profile, main thread
3,000 (RangeError: Maximum call stack size exceeded)
wasm, wasm size profile, main thread
2,000
wasm in a 4 MB worker
none at 3,000
npm/wasm Node entry (256 MB worker)
none at 20,000
native tsc-rs and tsgo
none at 20,000
Cause
The binder recurses once per operator: Binder::bind, then bind_binary_expression_flow, then bind_children_of_kind. Go does the same (internal/binder/binder.go:2240-2266 at our pin), but its goroutine stacks grow up to 1 GB, so Go never hits this. TypeScript 5.x binds binary expressions with an explicit work stack (createBindBinaryExpressionFlow) for this reason.
Options
An explicit work stack for binary expression binding (and any other per-operator recursion the checker and emitter hit on the same input), with output equal to Go.
Document the stack the wasm module needs, and run it in a worker with a larger stack.
We will look at option 1 first, because hosts like workerd cannot raise the stack.
Measured on main (release wasm, Node main thread with about 1 MB of stack). The binder is not the only limit:
input
phase that overflows first
levels at the overflow
+ or ,, noEmit
checker
about 2,190
&&, ||, ??, noEmit
binder
about 1,620
any operator, emit on
ES module transform
about 1,270
nested parentheses
parser
about 640
?: chain
flow analysis (before the TS2563 guard)
about 1,860
Native builds pass 20,000 and 100,000 terms, because their threads get Go-sized stacks. TypeScript 6.0.3 on the same thread passes + at 20,000 but fails &&, ||, ?? and nested parentheses at 1,000.
Plan:
Now: the npm/wasm README gives these limits and the worker stack that avoids them (stackSizeMb: 8: about 17,000 terms with noEmit, 10,000 with emit).
Next: explicit work stacks for the binder and the checker on binary expressions, in Go's visit order (TypeScript 5.x used the same shape). That fixes the 3,000-term noEmit case in this issue. It waits until the next upstream pin move lands, because that work changes the same checker files.
Later, if needed: the same for the emit transforms and the flow walk.
The measurements and the design note are in our lane notes; the design passed a read-only check against Go.
Update: the binder and checker fix is done and reviewed on a branch. It goes into the next revision.
Long chains of +, ,, &&, || and ?? are now bound and checked with explicit work stacks, in the same visit order as Go. Diagnostics, emit and trace events are unchanged.
On a 1 MB host stack (the Node main thread, workerd), the wasm module now checks 20,000-term chains of every operator. Before, it failed at about 2,100 terms of + and 1,600 terms of &&. The 3,000-term noEmit case from this issue passes.
What is left: emit at that depth still stops near 1,260 terms, because the emit transforms recurse too. We will do those only if someone needs emit on chains that long.
The binder and checker fix is on main (3bd69c5, revision R223). It ships in the next release.
Long chains of +, ,, &&, || and ?? now bind and check with work stacks, in the same visit order as Go.
Chains of 20,000 terms now check on the Node main thread and in a 1 MB worker, with the release and the wasm size builds. Before, the limit was about 2,100 terms.
A wasm test in CI checks 5,000-term chains in a worker with a 1 MB stack.
Output is equal to Go and to the previous revision on every check we run.
These shapes still recurse, as they do in Go, so a very deep one can still overflow a 1 MB stack:
destructuring assignment chains ([a] = [b] = ...)
chains with parentheses around every level
long ?: chains
long chains in unreachable code or after false &&
the control flow walk back over a long chain
emit of a long chain
I keep this issue open for those. If real code hits one of them, please post a small repro here. Until then, the 4 MB worker and the npm/wasm Node entry have no limit at these sizes.
More shapes for this issue, beyond binary chains. On a host with about 1 MB of native stack, they overflow at
lower depths than the ones listed in npm/wasm/README.md. The lowest we found is nested calls, f(f(f(1))), at
about 580 levels. Native tsc-rs gives the same output as tsc 7.1.0-beta on all of them. None comes from real
code (see "Real code" below), so this is for the record, not a request.
python3 -c 'n = 580; print("declare function f<T>(x: T): T;\nexport const x = " + "f(" * n + "1" + ")" * n + ";")'> a.ts
Call runTsc from npm/wasm/core.js on the Node main thread with -p tsconfig.json --noEmit, as in the first post.
It throws RangeError: Maximum call stack size exceeded.
At 560 levels it passes.
With --noCheck it passes at 580, so for this shape the check is what runs out of stack.
Depths
The module is the shipped wasm profile built from main at 91f3d02 (sha256 83fba89c…), on the Node 26.12 main
thread. Each depth is the first failing one, found by bisection to within about 3%. Reruns can move a boundary by a
few percent.
Shape
Example
Fails at about
nested calls
f(f(f(1)))
577
template literals
`a${`a${1}`}`
734
method chain
b.b().b()
750
object literals
{ a: { a: 1 } }
750
array literals
[[[1]]]
796
parentheses
((((1))))
827
object types
type T = { a: { a: … } }
890
namespaces
namespace N0 { namespace N1 { … } }
937
blocks
{ { { return 1 } } }
1,156
type arguments
W<W<W<number>>>
1,343
optional chain
b?.b?.b
1,437
?: chain
a ? 0 : a ? 1 : …
1,656
arrow functions
() => () => 1
1,687
nested if
if (a) if (a) … return 1
1,750
else if chain
if (k === 0) … else if (k === 1) …
1,750
JSX
<div><div>…</div></div>
1,781
conditional types
T extends 0 ? "n0" : T extends 1 ? …
1,781
These pass at 10,000: chains of +, string concatenation, unions, intersections, type alias chains, recursive
types and long tuples.
With --noCheck, the object-literal shape at 760 and the namespace shape at 940 still overflow, so those run out of
stack in the parser or binder.
Native tsc-rs gave the same output as tsc 7.1.0-beta on every shape, and neither crashed:
nested calls, method chains, array literals, parentheses, object types, blocks, optional chains, ?: chains,
arrow functions and nested if, at depths up to 100,000 levels or until one check took more than 30 s;
the other 7 shapes at 2,000 levels.
Real code
None of 221 real-project configs that we ran through runTsc on the main thread overflowed. These depths are more
likely in generated code than in handwritten code.
Notes
The README's list ("How it works") starts at about 800 levels of parentheses. Nested calls, template, object and
array literals, and method chains fail lower, so they may be worth adding for host authors.
The limit here is V8's native stack, not the module's 32 MiB shadow stack. In workerd, a build with a 4 MiB shadow
stack reached the same depths as the default build on 9 of the 10 shapes we compared, and on the tenth it was within
the search's tolerance.
Data point from a consumer harness (agent runtime type-checking every iteration with the wasm build on the multi-run PR branch, now rebased onto 91f3d021c): a 5000-term binary-operator chain guest that overflowed on the pre-R219 build (structured 500 + instance recycle) now checks clean over HTTP — 200, correct verdict on the 91f3d021c build. Repro script: scripts/verify-scaling.mjs + the live harness in https://github.com/jmfrank63/iso-agents (scripts/test-typecheck-wasm-live.mjs). If this landing is intended to fix #99, the issue can be closed with this as confirmation; happy to run any additional cases you want.
Activity
docs(wasm): stack limits on hosts with about 1 MB of stack (issue #99)
t3dotgg commentedon Oct 22, 2026
Note
🤖 Claude Opus 5.5 responding on behalf of Theo
Measured on main (release wasm, Node main thread with about 1 MB of stack). The binder is not the only limit:
+or,, noEmit&&,||,??, noEmit?:chainNative builds pass 20,000 and 100,000 terms, because their threads get Go-sized stacks. TypeScript 6.0.3 on the same thread passes
+at 20,000 but fails&&,||,??and nested parentheses at 1,000.Plan:
stackSizeMb: 8: about 17,000 terms with noEmit, 10,000 with emit).The measurements and the design note are in our lane notes; the design passed a read-only check against Go.
chore(goport): lib bind blob for the binder frames (#99)
t3dotgg commentedon Oct 23, 2026
Note
🤖 Claude Opus 5.5 responding on behalf of Theo
Update: the binder and checker fix is done and reviewed on a branch. It goes into the next revision.
+,,,&&,||and??are now bound and checked with explicit work stacks, in the same visit order as Go. Diagnostics, emit and trace events are unchanged.+and 1,600 terms of&&. The 3,000-termnoEmitcase from this issue passes.I will post again when it is on main.
docs(state): #99 option C lane result
Merge goport-int90 (R223, #99 option C and followups71)
t3dotgg commentedon Oct 23, 2026
Note
🤖 Claude Opus 5.5 responding on behalf of Theo
The binder and checker fix is on
main(3bd69c5, revision R223). It ships in the next release.+,,,&&,||and??now bind and check with work stacks, in the same visit order as Go.wasmsize builds. Before, the limit was about 2,100 terms.These shapes still recurse, as they do in Go, so a very deep one can still overflow a 1 MB stack:
[a] = [b] = ...)?:chainsfalse &&I keep this issue open for those. If real code hits one of them, please post a small repro here. Until then, the 4 MB worker and the npm/wasm Node entry have no limit at these sizes.
fix(goport): binder binds binary chains on a work stack (pingdotgg#99)
fix(goport): checker checks binary chains on a work stack (pingdotgg#99)
test(goport): long binary chains on a 1 MiB stack, with Go's output (p…
Merge goport-stack99c (pingdotgg#99 option C) into goport-int90
perf(goport): the checker gives back the binary frames of a long chain
docs(wasm): stack limits after the binary chain loops (issue pingdotg…
test(goport): the M1 and M2 loops each have a test of their own (ping…
Merge goport-followups72 (pingdotgg#99 frame shrink, M1 and M2 tests,…
docs(wasm): measured pingdotgg#99 counts for ** chains and emit on ||…
BenKnill commentedon Oct 29, 2026
Note
🤖 Claude Opus 5.5 writing on behalf of Ben
More shapes for this issue, beyond binary chains. On a host with about 1 MB of native stack, they overflow at
lower depths than the ones listed in
npm/wasm/README.md. The lowest we found is nested calls,f(f(f(1))), atabout 580 levels. Native tsc-rs gives the same output as
tsc7.1.0-beta on all of them. None comes from realcode (see "Real code" below), so this is for the record, not a request.
Repro
tsconfig.json:{ "compilerOptions": { "strict": true, "types": [], "lib": ["es2022"], "target": "es2022", "module": "esnext", "moduleResolution": "bundler", "jsx": "preserve" }, "files": ["a.ts"] }a.tswith 580 nested calls:Call
runTscfromnpm/wasm/core.json the Node main thread with-p tsconfig.json --noEmit, as in the first post.RangeError: Maximum call stack size exceeded.--noCheckit passes at 580, so for this shape the check is what runs out of stack.Depths
The module is the shipped
wasmprofile built frommainat 91f3d02 (sha25683fba89c…), on the Node 26.12 mainthread. Each depth is the first failing one, found by bisection to within about 3%. Reruns can move a boundary by a
few percent.
f(f(f(1)))`a${`a${1}`}`b.b().b(){ a: { a: 1 } }[[[1]]]((((1))))type T = { a: { a: … } }namespace N0 { namespace N1 { … } }{ { { return 1 } } }W<W<W<number>>>b?.b?.b?:chaina ? 0 : a ? 1 : …() => () => 1ifif (a) if (a) … return 1else ifchainif (k === 0) … else if (k === 1) …<div><div>…</div></div>T extends 0 ? "n0" : T extends 1 ? …These pass at 10,000: chains of
+, string concatenation, unions, intersections, type alias chains, recursivetypes and long tuples.
With
--noCheck, the object-literal shape at 760 and the namespace shape at 940 still overflow, so those run out ofstack in the parser or binder.
Native tsc-rs gave the same output as
tsc7.1.0-beta on every shape, and neither crashed:?:chains,arrow functions and nested
if, at depths up to 100,000 levels or until one check took more than 30 s;Real code
None of 221 real-project configs that we ran through
runTscon the main thread overflowed. These depths are morelikely in generated code than in handwritten code.
Notes
array literals, and method chains fail lower, so they may be worth adding for host authors.
stack reached the same depths as the default build on 9 of the 10 shapes we compared, and on the tenth it was within
the search's tolerance.
jmfrank63 commentedon Nov 4, 2026
Data point from a consumer harness (agent runtime type-checking every iteration with the wasm build on the
multi-runPR branch, now rebased onto91f3d021c): a 5000-term binary-operator chain guest that overflowed on the pre-R219 build (structured 500 + instance recycle) now checks clean over HTTP — 200, correct verdict on the91f3d021cbuild. Repro script:scripts/verify-scaling.mjs+ the live harness in https://github.com/jmfrank63/iso-agents (scripts/test-typecheck-wasm-live.mjs). If this landing is intended to fix #99, the issue can be closed with this as confirmation; happy to run any additional cases you want.