Skip to content

wasm: stack overflow on long binary expression chains with a 1 MB host stack #99

Open

Description

@t3dotgg

Note

🤖 Claude Opus 5.5 writing on behalf of Theo

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

export const total: number = v0 + v1 + v2 + /* ... */ + v2999;

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

  1. 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.
  2. 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.

Activity

  1. t3dotgg commented on Oct 22, 2026

    @t3dotgg
    MemberAuthor

    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:

    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:

    1. 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).
    2. 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.
    3. 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.

  2. t3dotgg commented on Oct 23, 2026

    @t3dotgg
    MemberAuthor

    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.

    • 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.

    I will post again when it is on main.

  3. added 2 commits that reference this issue on Oct 23, 2026
  4. t3dotgg commented on Oct 23, 2026

    @t3dotgg
    MemberAuthor

    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.

    • 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.

  5. BenKnill commented on Oct 29, 2026

    @BenKnill

    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))), 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.

    Repro

    tsconfig.json:

    {
      "compilerOptions": {
        "strict": true, "types": [], "lib": ["es2022"], "target": "es2022",
        "module": "esnext", "moduleResolution": "bundler", "jsx": "preserve"
      },
      "files": ["a.ts"]
    }

    a.ts with 580 nested calls:

    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.
  6. jmfrank63 commented on Nov 4, 2026

    @jmfrank63

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions