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

Compute diagnostic positions incrementally to fix quadratic hang in Bun.JSONC.parse (#31273)

### Problem

Fuzzing found a hang in the JSONC parser: a 250 KB malformed input keeps
`Bun.JSONC.parse` busy for over a minute, sampled spinning in
`Source::init_error_position` ← `Location::init_or_null` ←
`Log::add_formatted_msg` ← `LexerLog::add_range_error` ←
`Lexer::expected_string`.

Synthetic reproduction (release build):

```js
// a number in object-key position desyncs the parser; recovery then walks the rest of the input
const input = '[{' + '-1' + '"":[{'.repeat(50_000); // ~250 KB
Bun.JSONC.parse(input); // ~60s before this change
```

12.5 KB → 152 ms · 25 KB → 644 ms · 50 KB → 2.4 s — doubling the input
quadruples the time.

The same blowup exists through duplicate-key **warnings** on valid
input: `'{' + '"a":1,'.repeat(40_000) + '"a":1}'` (240 KB) parses
successfully but takes 11 s. The same code path parses tsconfig.json,
package.json, and `.json` imports.

### Cause

Every logged diagnostic computes its line/column in
`Source::init_error_position`, which rescans the source from byte 0 —
O(offset) per message. The lenient JSONC/JSON parsers recover from
errors rather than bailing, so a desynced input produces a diagnostic
for nearly every token: ~150k messages × O(130 KB average scan) ⇒
quadratic.

### Fix

Make position computation incremental instead of limiting anything — the
`Log` now owns a `LineColumnTracker` (the esbuild approach):

- The tracker keeps the scanner state (offset, line count, column, line
start, cached end-of-line) for the source of the most recent diagnostic.
A diagnostic at a non-decreasing offset resumes that scan; a diagnostic
for a different source resets it.
- All `Log` message constructors route their `Location` computation
through the tracker (`tracked_range_data`), so lexer errors, parser
errors, and warnings (including the duplicate-key path) all benefit —
for any parser that logs through `Log`, N diagnostics against one source
now cost O(file size + N) instead of O(N × file size).
- Out-of-order offsets (e.g. notes pointing at earlier ranges) and
offsets that land inside a multi-byte codepoint fall back to the
existing one-off full scan, leaving the resumable state untouched, so
**results are bit-identical** to `Source::init_error_position`. That
function is now expressed through the same shared scan helpers
(`ErrorPositionState::advance`, `scan_line_end`), so there is exactly
one copy of the `\r`/`\r\n`/U+2028/U+2029 handling.

No caps or truncation of diagnostics anywhere.

### Measurements

| input | before (1.4.0 release) | after (debug + ASAN build) |
|---|---|---|
| malformed flood, 250 KB | ~60 s | 3.2 s (~150k diagnostics still
reported) |
| malformed flood, 50 KB | 2.4 s | 0.6 s |
| duplicate keys, 240 KB (valid) | 11.0 s | 1.5 s |
| original 250 KB fuzz input | killed at 30 s | 0.014 s |

Scaling is now linear (5× input ⇒ ~5× time); release builds are several
times faster than the debug+ASAN column. The remaining per-message cost
is formatting/allocating the messages themselves.

### Tests

- `src/ast/lib.rs` unit tests: differential tests driving
`LineColumnTracker` against a from-scratch `init_error_position` over
CRLF / lone `\r` / U+2028/U+2029 / multi-byte UTF-8 / invalid UTF-8 /
single-giant-line corpora with forward, repeated, backwards, and strided
offsets, plus a tracker-reuse-across-sources test.
- `test/js/bun/jsonc/jsonc.test.ts`: new pathological-input test spawns
a child with a kill switch (same pattern as the Bun.markdown
bracket-flood test) that parses the 250 KB malformed flood (expects the
usual `AggregateError`) and the 240 KB duplicate-key flood (expects the
correct value). Fails without the fix (child killed), passes with it.
- Existing suites: jsonc, resolve/jsonc, json5, tsconfig,
runtime-transpiler, transpiler.test.js, bundler esbuild/default,
resolve-error, toml-crash — all green locally.
R
robobun committed
3d5fe5ed0cf27cb9a60a76c383306012a07c3c7b
Parent: a207a77
Committed by GitHub <noreply@github.com> on 5/24/2026, 2:21:13 AM