Skip to content

tryStreamingParse: input cut inside a \u escape or an exponent needs more data - #638

Open
robobun wants to merge 1 commit into
mainfrom
robobun/535b5681/jsonl-cut-escape-and-exponent
Open

tryStreamingParse: input cut inside a \u escape or an exponent needs more data#638
robobun wants to merge 1 commit into
mainfrom
robobun/535b5681/jsonl-cut-escape-and-exponent

Conversation

@robobun

@robobun robobun commented Sep 12, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • Bun.JSONL.parseChunk returns SyntaxError: Failed to parse JSONL for a chunk that ends inside a \uXXXX escape or after the e- / e+ of a number. Every other cut position reports an incomplete value, so NDJSON with \u escapes (Python json.dumps) fails at random read boundaries.
  • tryStreamingParse (LiteralParser.cpp:2060) reports NeedMoreData only when the lexer stopped at the end of the input. lexStringSlow returns at the u when fewer than 5 characters remain. parseJSONDouble accepts 1 from 1e-, and the rest lexes as an identifier.
  • A top-level 1e- also produces the value 1.

Fix

  • lexStringSlow advances over the hex digits that are present, then returns the error. It stops at the end of the input for a cut escape, and at the bad character otherwise.
  • lexNumber sends an exponent that has no digits to lexNumberError, which stops at the missing digit.
  • Both changes are inside USE(BUN_JSC_ADDITIONS). Malformed input is still an error. The JSON.parse message for [1e-] changes from Expected ']' to the lexNumberError message.
  • Verified: JSTests/stress/streaming-json-parse-cut-input.js fails on the pinned jsc and passes on the preview jsc. Bun tests: Bun.JSONL: a chunk cut inside a \u escape, an exponent or the BOM is incomplete, not an error bun#42488.

Background

  • tryStreamingParse parses one JSON value per line and returns Complete, NeedMoreData or Error. For NeedMoreData, Bun.JSONL.parseChunk returns error: null and the caller appends the next chunk.
  • After a lexer failure, m_ptr == m_end is the only sign that the input ran out.
  • parseJSONDouble is fast_float in json format. That format includes fixed, so it ignores an exponent without digits.
Notes

Reproduction (Bun 1.4.2 744846f844, canary, and main at the pinned WebKit cf1b36ec87):

const line = '{"name":"caf\\u00e9"}\n'; // what Python json.dumps emits for "café"
const bytes = new TextEncoder().encode(line);
for (let cut = 1; cut < bytes.length; cut++) {
  const r = Bun.JSONL.parseChunk(bytes.subarray(0, cut));
  if (r.error) console.log(JSON.stringify(line.slice(0, cut)), r.error.message);
}
console.log(Bun.JSONL.parseChunk("[1e-").error?.message); // Failed to parse JSONL
console.log(Bun.JSONL.parseChunk("1e-")); // values: [1], read: 1, error: SyntaxError

It prints four failing cuts, all inside the \u00e9. String input and Uint8Array input behave the same, and so do the 8-bit and the 16-bit lexer.

Why only these two. I went through every place where the lexer returns TokError or gives up on a token. All the others already stop at the end of the input when the input ends inside the token: an unterminated string, a \ as the last character, - or 1. with nothing after it, tru (lexed as an identifier to the end). A cut after 1e was reported as incomplete only by accident: the e lexes as an identifier that reaches the end of the input.

lexNumberError cannot reach its ASSERT_NOT_REACHED() through the new call. lexNumber calls it only after it checked that no digit follows [eE][+-]?, which is the condition lexNumberError returns on. For 1e5e7, fast_float consumes 1e5 and a digit follows the second e, so the number token is returned as before and the parse fails on the identifier e7.

Behaviour that changes outside of tryStreamingParse. Only the text of the JSON.parse error for a number with an exponent that has no digits: JSON.parse("1e-") and JSON.parse("2e-+10") said Unable to parse JSON string, JSON.parse("[1e-]") said Expected ']'. They now say Exponent symbols should be followed by an optional '+' or '-' and then by at least one number, the message lexNumberError has had since before the fast_float change made it unreachable. No valid input has an e right after a number in strict or sloppy mode, so nothing that parsed before fails now. LayoutTests/js/dom/JSON-parse-expected.txt expects the old text for 2e-+10 and 2e+-10, which is why the change is behind USE(BUN_JSC_ADDITIONS).

On the preview build of this PR (autobuild-preview-pr-638-2a5a22bc): its debug+ASAN jsc passes streaming-json-parse-cut-input.js, the pinned jsc exits 3 at {"name":"caf\u. Bun main with WEBKIT_VERSION set to the preview (oven-sh/bun#42488): the 7 new tests in test/js/bun/jsonl/jsonl-parse.test.ts pass and the rest of the file gives the same result as before (274 of 276 pass, the two 4 GB tests time out under debug+ASAN).

How I tested before the preview build existed. The fork's CI does not run JSTests. I compiled the changed LiteralParser.cpp with the flags from the pinned debug+ASAN build's compile_commands.json and linked the object ahead of the pinned libJavaScriptCore.a (every definition in it is a template instantiation, so the first one wins), once into the jsc shell and once into a debug build of Bun. The baseline is the same link without the object.

pinned build with this change
streaming-json-parse-cut-input.js fails at {"name":"caf\u passes
72 other JSTests/stress/*json* tests 69 pass, 2 time out (debug+ASAN), 1 fails in toLocaleString the same
Bun test/js/bun/jsonl/jsonl-parse.test.ts (269 tests) 267 pass, the two 4 GB tests time out under debug+ASAN the same

Differential run. A seeded generator made 6000 lines of nested arrays, objects, strings with \u escapes and numbers with exponents, 8-bit and 16-bit. Half of them were cut at every position (149725 chunks), the other half were mutated (a character inserted, removed or replaced, once or twice) and cut at a random position (2881 chunks). Both jsc shells parsed every chunk with streamingJSONParse.

  • Valid line, every cut: the pinned build says error for 34501 chunks. With the change, none. Every cut of a line that is an array, an object or a string says needMoreData with no value, and the whole line says complete with one value.
  • Mutated input: 116 chunks differ, all error to needMoreData, and every one of them ends in \u plus 0 to 3 hex digits or in a digit plus e, e- or e+. No other result differs.

Not changed here. A top-level number that ends exactly at the end of a chunk (12 then 3\n) is still returned as a complete value, because nothing tells tryStreamingParse that more input can follow. That is a question about the API and not about the lexer.

…more data

tryStreamingParse reports NeedMoreData when a value fails to parse and the
lexer stopped at the end of the input. Two lexer paths stop earlier when the
input ends inside a token, so a chunk that ends there was an error:

- lexStringSlow returned at the 'u' of a \uXXXX escape with fewer than four
  characters after it. It now stops at the first character that is not a hex
  digit, which is the end of the input when the escape was cut short.
- parseJSONDouble accepts "1" from "1e-" and leaves "e-" behind, which lexes
  as an identifier. lexNumber now reports an exponent without digits through
  lexNumberError, which stops at the missing digit. A top-level "1e-" no
  longer produces the value 1 before the error.

Malformed escapes and exponents whose bad character is in the input are
still errors.
@robobun

robobun commented Sep 12, 2026

Copy link
Copy Markdown
Collaborator Author

Status

Reproduced on Bun 1.4.2 (744846f844), on 1.4.3-canary.1+6a92015fc, and on the jsc of the pinned build (cf1b36ec87):

  • Bun.JSONL.parseChunk('{"name":"caf\\u00') returns error: SyntaxError: Failed to parse JSONL. The same for a chunk that ends after \u, \u0 or \u00e, and for [1e- and [1e+. A chunk that ends anywhere else in the line returns error: null.
  • Bun.JSONL.parseChunk("1e-") returns values: [1], read: 1 and the error.
  • streamingJSONParse("[1e-") in the jsc shell returns status: "error".

Verified on the preview build of this PR (autobuild-preview-pr-638-2a5a22bc):

Before the preview existed, I ran the same checks with the changed LiteralParser.cpp compiled with the pinned debug+ASAN flags and linked ahead of the pinned libJavaScriptCore.a. The other 72 *json* stress tests give the same result with and without the change. Every one of 149725 cuts of 3000 generated lines is needMoreData or complete with the change. The pinned build says error for 34501 of them.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed this PR and didn't find any bugs. Because it edits LiteralParser::lexNumber / lexStringSlow — a hot path shared by JSON.parse — and both changed paths fall under CODEOWNERS (@ WebKit/jsc-reviewers), a human look is still worthwhile.

What was reviewed:

  • \u slow path: verified the do/while starts on 'u', is bounded by m_end, and leaves m_ptr == m_end exactly when the truncated hex digits run out — non-hex before end still yields a hard error.
  • Exponent check: confirmed the peek matches lexNumberError's own [eE][+-]? walk, so the new call always hits the existing "Exponent symbols…" TokError and cannot reach ASSERT_NOT_REACHED(); 1e5e7 still returns TokNumber(1e5) because a digit follows the second e.
  • No previously-valid input regresses: in strict/sloppy JSON a number token can never be legitimately followed by e/E, so only error text changes for non-streaming callers.
  • New stress test throws on failure, does not print, and is placed in JSTests/stress per JSTests/README.md.
Extended reasoning...

Overview

The PR adjusts two TokError return sites in Source/JavaScriptCore/runtime/LiteralParser.cpp so that m_ptr lands at m_end when a \uXXXX escape or a numeric exponent is merely truncated by end-of-input, letting tryStreamingParse (line 2081, m_lexer.isAtEnd()) classify the failure as NeedMoreData instead of Error. Both edits are wrapped in #if USE(BUN_JSC_ADDITIONS). A new JSTests/stress/streaming-json-parse-cut-input.js slices valid JSONL lines at every offset and asserts needMoreData, checks top-level 1e-/1E+ prefixes yield no premature value, and asserts genuinely malformed escapes/exponents still report error.

Security risks

None identified. The \u loop is bounded by (m_end - m_ptr) < 5 on entry and re-checks m_ptr < m_end each iteration, so it cannot read past the buffer. The exponent peek dereferences *m_ptr and *exponentDigits only after < m_end guards. No new allocation, no user-controlled indexing, no change to how parsed values are constructed. The only non-streaming observable effect is a different (pre-existing) error message string for inputs like JSON.parse("[1e-]").

Level of scrutiny

Medium-high. lexNumber sits on the JSON.parse hot path for every non-int32 number, and LiteralParser is instantiated for both 8-bit and 16-bit character types across strict/sloppy/JSONP modes. I traced that the int32 fast path (line 1164) already excludes a trailing e/E, so the new check is only reached after parseJSONDouble; and that lexNumberError re-derives the same [eE][+-]? position, guaranteeing it returns at the exponent-digit check rather than falling through to ASSERT_NOT_REACHED(). Because JSON grammar never allows an identifier immediately after a number, no previously-accepted input is now rejected — only error classification/message changes.

Other factors

Both changed paths are listed in .github/CODEOWNERS under @ WebKit/jsc-reviewers, which per the approval guidelines means a human owner should sign off. The regression test complies with JSTests/README.md conventions (throws on failure, no logging, lives in JSTests/stress, small iteration count). One minor imprecision I noted but did not flag as a bug: pathological input like 1e5e- at end-of-chunk now reports NeedMoreData even though no suffix can make it valid; the error simply surfaces one chunk later, which is acceptable streaming behavior. Given the CODEOWNERS coverage and the shared-with-JSON.parse surface, deferring to a human reviewer is the right call.

@coderabbitai

coderabbitai Bot commented Sep 12, 2026

Copy link
Copy Markdown

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Essentials

Run ID: 5824fdf0-0538-4753-9d7f-fc63383959ca

📥 Commits

Reviewing files that changed from the base of the PR and between cf1b36e and 2a5a22b.

📒 Files selected for processing (2)
  • JSTests/stress/streaming-json-parse-cut-input.js
  • Source/JavaScriptCore/runtime/LiteralParser.cpp

Included review availability: Your plan provides up to 5 included reviews per hour; 1 remains after this review.


Walkthrough

The streaming JSON parser now handles truncated Unicode escapes and incomplete exponent forms as incomplete input. A stress test validates truncation behavior, complete parsing, malformed input errors, and multibyte input handling.

Changes

Streaming JSON parser

Layer / File(s) Summary
Incomplete escape and exponent handling
Source/JavaScriptCore/runtime/LiteralParser.cpp
Truncated Unicode escapes advance through incomplete hexadecimal sequences. Incomplete exponent forms such as 1e, 1e+, and 1e- route to streaming error handling.
Truncation and malformed-input coverage
JSTests/stress/streaming-json-parse-cut-input.js
The stress test checks truncations across JSON values, complete parsing, incomplete exponents, malformed escapes, malformed exponents, and multibyte input.

Priority: ⬇️ Low

Merge Risk: ⚪ Minimal · up to 2a5a2

The change is ready to merge after normal checks.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title is concise, specific, and accurately identifies the two main streaming-parser fixes: incomplete Unicode escapes and exponents requiring more data.
Description check ✅ Passed The description is detailed, directly related to the changes, and includes the problem, fix, behavior changes, affected code, and test results. It does not include an explicit Bugzilla URL or the exac…

Warning

Git: CodeRabbit could not clone the repository, so clone-backed analysis was skipped and this review may be incomplete. Verify repository clone access, such as SSH credentials, before requesting another full review. If clone access is intentionally unavailable, use path_filters to narrow the review scope.


Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown

Preview Builds

Commit Release Date
2a5a22bc autobuild-preview-pr-638-2a5a22bc 2026-09-12 14:42:55 UTC

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant