SIGN IN SIGN UP
oven-sh / bun UNCLAIMED

Incredibly fast JavaScript runtime, bundler, test runner, and package manager – all in one

0 0 150 Rust

Fix quadratic hang reporting duplicate-binding parse errors in the transpiler (#31341)

### Problem

Fuzzing found a hang in the JS parser's error-position tracking: a ~9 KB
input full of parse errors keeps `Bun.Transpiler.transformSync` busy for
20+ seconds, sampled spinning in `ErrorPositionState::advance` ←
`LineColumnTracker::error_position` — the same line/column computation
that previously hung `Bun.JSONC.parse`, now reached through the
transpiler.

The input shape is lines of

```js
try {} catch ([a,a,a,/* ~200 more */,a, `]) {}
```

where every `a` after the first is a duplicate catch binding and the
backtick starts a template literal that swallows the next line.

Synthetic reproduction (debug build, before this change):

```js
const stmt = "try {} catch ([" + "a,".repeat(210) + "a, `]) {}\n";
new Bun.Transpiler({ loader: "js" }).transformSync(stmt.repeat(168)); // 75 KB → 15.5 s
```

9 KB → 0.35 s · 19 KB → 1.1 s · 37 KB → 4.0 s · 75 KB → 15.5 s —
doubling the input quadruples the time.

### Cause

`Log` computes diagnostic line/columns through a single resumable scan
(`LineColumnTracker`); any diagnostic whose offset is **behind** the
previous one falls back to a full rescan of the source from byte 0.

For this input that fallback is the common case, not the exception:

1. The parser lexes the whole `catch ([...])` binding first. The
template literal produces an "Expected identifier" error at the **end**
of the statement, advancing the tracker there.
2. `declare_binding` then declares the ~200 `a`s and logs one "has
already been declared" error per duplicate — plus a note pointing at the
first `a`. Both locations are behind the tracker, so **every** duplicate
pays two O(offset) rescans ⇒ O(duplicates × file size).

Even without the template literal, the notes alone (always pointing back
at the original declaration) make the pattern quadratic.

### Fix

Generalize the tracker's single resumable scan into a small pool of four
cursors, kept sorted by offset. A diagnostic resumes the
furthest-advanced cursor at or below its offset, so the interleaved
diagnostic streams the parsers actually produce (lexer high-water mark,
duplicate-declaration errors, the notes attached to them) each stay
incremental; each cursor only ever moves forward. Offsets below every
cursor and offsets inside a multi-byte codepoint keep the existing
one-off full-scan fallback, so results remain bit-identical to
`Source::init_error_position`.

No caps or truncation of diagnostics anywhere; diagnostic output is
unchanged.

### Measurements (debug + ASAN build)

| input | before | after |
|---|---|---|
| original 9 KB fuzz repro | 0.39 s | 0.14 s |
| 75 KB duplicate-catch flood | 15.5 s | 0.54 s |
| 356 KB duplicate-catch flood | > 5 min (extrapolated ~6 min) | 2.6 s |

Scaling is now linear (2× input ⇒ ~2× time).

### Tests

- `src/ast/lib.rs`: new differential unit test driving the tracker with
the parser's interleaved ordering (lexer error at end of statement, then
duplicate/note pairs) against from-scratch `init_error_position`;
existing forward/backwards/strided differential tests still pass.
- `test/bundler/transpiler/transpiler.test.js`: new "parse error flood"
test spawns a child with a 60 s kill switch (same pattern as the
`Bun.JSONC.parse` pathological test) that transpiles a 356 KB
duplicate-catch-binding flood and a plain duplicate-binding flood,
expecting the usual `AggregateError`. Fails without the fix (child
killed at 60 s), passes in ~6 s with it.
- `bun bd test test/bundler/transpiler/transpiler.test.js` (169 tests),
`test/js/bun/jsonc/jsonc.test.ts`,
`test/bundler/transpiler/runtime-transpiler.test.ts`,
`test/js/bun/resolve/resolve-error.test.ts`,
`test/js/bun/resolve/build-error.test.ts`,
`test/js/bun/resolve/toml/crash/toml-crash.test.ts` — all green locally.
R
robobun committed
1950a3a7c52754110f800d8e65dc2eaab0258e5a
Parent: 7bd0861
Committed by GitHub <noreply@github.com> on 5/25/2026, 2:01:16 AM