Fix exponential backtracking on nested TypeScript infer constraints (#31307)
### Problem
Parser fuzzing found a 3 KB TypeScript input that keeps `Bun.build` busy
in the JS lexer for 20+ seconds (hang signature `mi_heap_malloc |
bun_js_parser::lexer::LexerType…`). The input nests `` `${infer own
extends `${infer own extends … `` about 140 levels deep inside template
literal types.
Parse time doubles per nesting level on the current release:
| nesting depth | time |
|---|---|
| 20 | 141 ms |
| 22 | 512 ms |
| 24 | 2.0 s |
| 26 | 8.0 s |
| 140 (fuzz input) | never finishes |
A fully valid variant blows up the same way:
```ts
type X = `${infer o extends `${infer o extends /* …deeper… */ ? 0 : 1}` ? 0 : 1}`;
```
(tsc parses the fuzz input in ~300 ms.)
Original fuzz repro:
```sh
bun -e 'await Bun.build({entrypoints:["./e.ts"], files:{"./e.ts": Buffer.from(Bun.gunzipSync(Buffer.from("H4sIAAAAAAACAyupLEhV8EnNSy/JcMkvz7Ph5VJQsFcozcvOA/IUUitKUvNSihUSVKoz89JSixRGBUcFh4eg/2iAjAqOCo4KjgqOCo4KjgqOCpIuqBKUWlxSm8ALAGuwGAJGDAAA", "base64"))).toString("latin1")}, target:"browser", minify:false, sourcemap:"none", throw:false})'
```
### Cause
`skip_type_script_type_with_opts` handles `infer X extends …` by trying
to skip the constraint with backtracking
(`try_skip_type_script_constraint_of_infer_type_with_backtracking`). The
attempt backtracks when the constraint fails to parse, or when it parses
but is followed by `?` in a context that allows conditional types (the
`T extends infer U extends V ? A : B` ambiguity). After backtracking,
the caller re-parses the same region as the `extends` clause of a
conditional type.
A template literal placeholder resets the "conditional types allowed"
context, so when the pattern nests inside template literal types, every
level both attempts the constraint and then re-parses it as a
conditional-type clause. Each level doubles the work of the level below
it — O(2^depth). In the fuzz input every attempt fails near EOF (hard
error); in the valid variant every attempt is followed by `?` (the soft
ambiguity). Both re-lex the nested template contents over and over,
which is why the profile sits in lexer allocations.
### Fix
Memoize backtracked constraint attempts on the parser
(`ts_infer_constraint_backtracks`), keyed by the byte offset of the
`extends` token and whether conditional types were disallowed — the only
inputs that determine the attempt's outcome. A repeated attempt at a
memoized position returns `false` immediately instead of re-parsing the
constraint.
This cannot change what parses: a skipped attempt would have parsed the
same text with the same flags, backtracked, and restored the lexer to
exactly the state it is already in. Worst case drops from O(2^depth) to
roughly O(depth²) token work.
### Verification
- The original 140-level fuzz input now finishes instantly on a
debug+ASAN build (~0.7 s total, dominated by startup) and reports the
same five diagnostics, byte for byte, that the unfixed parser reports
for smaller inputs of the same shape.
- The valid 128-level variant builds successfully in the same time.
- `bun bd test test/bundler/transpiler/` — 447 pass, 0 fail.
- New test in `test/bundler/transpiler/transpiler.test.js` builds both
the fuzz-shaped malformed input and the valid nested variant at depth
128 in a child process with a 30 s kill switch: it fails on the unfixed
build (child killed after 30 s) and passes in ~1 s with the fix. Added
conformance cases locking in that nested `infer … extends` constraints
inside template literal types still parse exactly as before.
---------
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> R
robobun committed
295026b7b9693c044cc8c352f313fd05b53be2d1
Parent: 02380d3
Committed by GitHub <noreply@github.com>
on 5/24/2026, 4:51:20 AM