Skip to content

Reject json() with the JSON.parse SyntaxError on the buffered path - #41387

Merged
Jarred-Sumner merged 4 commits into
mainfrom
robobun/05992770/json-parse-error-buffered-path
Sep 4, 2026
Merged

Jarred-Sumner merged 4 commits into
mainfrom
robobun/05992770/json-parse-error-buffered-path

Conversation

@robobun

@robobun robobun commented Sep 4, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • test/js/bun/spawn/readablestream-helpers.test.ts fails at random. On PR builds 110091 (debian 13 aarch64) and 110186 (windows 11 aarch64), a case got Failed to parse JSON where it expected JSON Parse error: Expected '}', or the reverse.
  • json() on a stream that holds the whole body parses it in BunString__toJSON (src/jsc/bindings/BunString.cpp:522) or EncodedSlice__toJSONObject (src/jsc/bindings/bindings.cpp:2748). These call JSC::JSONParse and throw the generic message. A streamed body gets the JSON.parse message.
  • For proc.stdout, timing selects the path. ReadableStream .text(), .json(), .blob(), .bytes() #20694 pinned one message per case. Since ci: retry only the test files listed in test/flaky-tests.txt #41204, one failure is final.

Fix

  • Both helpers call JSONParseWithException, and parse "" for an empty input. For the same text, they throw the SyntaxError that JSON.parse throws. The removed "See Bun.serve crashes with seg fault when decoding certain binary data as JSON #5859" comment is stale (see Notes).
  • Response.json(), Request.json(), Blob.json() and Bun.file().json() change the same way. The fetch spec rethrows the JSON.parse exception. Node does too.
  • scripts/verify-baseline-static/allowlist-x64.txt gets llint_op_jmp_wide32, a documented LLInt decode false positive that moved with the code layout (see Notes).
  • Verified on the ASAN build with BUN_JSC_validateExceptionChecks=1: readablestream-helpers.test.ts (new cases), body.test.ts, serve.test.ts. Self-reviewed: 5 concerns raised, 4 addressed.

Background

  • A stream that Bun creates has a native source. A Blob source returns the whole body at once, so json() tries it first.
  • JSC::JSONParse returns an empty value on a syntax error and throws nothing.
  • Bun creates proc.stdout when JS first reads it. If the pipe is at EOF then, it is a Blob stream. Bun.spawn() reads the pipe once before it returns, and on Windows exited can resolve before EOF.
Notes
  • Reproduction on the released build (1.4.1):
    • new Blob([s]).stream().json() rejects with Failed to parse JSON. A stream with a JavaScript source rejects with JSON Parse error: Expected '}'.
    • proc.stdout.json() with no await after Bun.spawn(): 11 of 50 runs took the buffered path when the parent and a fast child (/bin/echo) share one CPU. This is the debian case.
    • After await proc.exited, a grandchild that keeps the pipe open makes the streamed path run. This is the Windows case.
  • Empty input: JSONParseWithException throws nothing for a null string, and an empty input converts to one. A child that printed only a byte order mark got JSON Parse error: Unexpected EOF on the streamed path and Failed to parse JSON on the buffered path. Both paths now give the first. A zero-byte stdout was already the same on both paths.
  • Bun.serve crashes with seg fault when decoding certain binary data as JSON #5859: in 2023 the helper caught the exception and returned it as a value. Today both helpers use a throw scope. The Bun.serve crashes with seg fault when decoding certain binary data as JSON #5859 tests pass with BUN_JSC_validateExceptionChecks=1.
  • Baseline scan: the :linux: x64-musl - verify-baseline step flagged one RDPMC in llint_op_jmp_wide32. Each LLInt opcode handler starts with an embedded 4-byte opcode id, and the linear-sweep decoder desyncs on it. The allowlist already covers two sibling handlers for this reason. The same scan lists those two entries as stale, so the desync moved to this handler. The emulator phase of the same job passes, and the step passed on main at the same base commit. The three LLInt entries are blanket passes, as the triage guide in scripts/verify-baseline-static/CLAUDE.md says for a data-in-.text misdecode.
  • The helpers keep Failed to parse JSON for a string that failed to convert (a dead string, or a failed allocation).
  • The empty-body checks in Blob.rs run before the helpers. They resolve null or throw Unexpected end of JSON input. That is Response.json behavior is different between bun and node #24955, and this PR does not change them.
  • Reject json() on an empty body with the JSON.parse SyntaxError #33658 (open since July) makes the same change to the two helpers, and also removes the empty-body checks for Response.json behavior is different between bun and node #24955. This PR supersedes the helper half of Reject json() on an empty body with the JSON.parse SyntaxError #33658. After it lands, Reject json() on an empty body with the JSON.parse SyntaxError #33658 only needs its Blob.rs change.
  • Self-review, 5 concerns. Addressed: the narrowed claim, the empty-input case, the note that the failures were on PR builds, and the relation to Reject json() on an empty body with the JSON.parse SyntaxError #33658. Not addressed: Blob::to_json_with_bytes decodes a body that starts with FF FE as UTF-16LE, and the streamed path decodes it as UTF-8. That difference is separate from this change.
  • Tests on the debug ASAN build. The first three also ran with BUN_JSC_validateExceptionChecks=1.
    • test/js/bun/spawn/readablestream-helpers.test.ts: 32 pass. The released build fails 3 (the two new cases and (after exited)).
    • test/js/web/fetch/body.test.ts: 476 pass. The released build fails 34 (17 inputs, for Request and Response).
    • test/js/bun/http/serve.test.ts: 295 pass, 2 fail. The 2 failures (root range port(#7187) and /bun:info to loopback clients) also fail on the released build in this environment, which runs as root.
    • Also pass: bun-file.test.ts, regression/issue/02367.test.ts, express.json.test.ts, spawn.ipc.test.ts, spawn.ipc.bun-node.test.ts, and the json cases of fetch.test.ts. The one failure there, bad permissions throws, also fails as root on the released build.

no test proof · iteration 1 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/web/fetch/body.test.ts, test/js/bun/http/serve.test.ts

BunString__toJSON and EncodedSlice__toJSONObject called JSC::JSONParse,
which returns an empty value on a syntax error, and then threw a
generic "Failed to parse JSON". The streamed path of
ReadableStream.prototype.json() calls JSONParseWithException, which
throws the parser message. So the error of proc.stdout.json() depended
on whether the pipe was at EOF when JS first read proc.stdout.

Both helpers now call JSONParseWithException. They parse "" for an
empty input, because JSONParseWithException does not throw for a null
string. For the same text, the helpers now throw the SyntaxError that
JSON.parse throws. The empty-body checks in Blob.rs run before the
helpers, and they do not change.
@robobun

robobun commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator Author

Reproduction, on the released build (1.4.1):

  • proc.stdout.json() of a child that prints { invalid json content } rejects with JSON Parse error: Expected '}' when JS reads proc.stdout while the pipe is open. It rejects with Failed to parse JSON when the pipe is already at EOF.
  • new Blob([text]).stream().json() and new Response(bytes).body.json() take the buffered path. A stream with a JavaScript source takes the streamed path. The new cases in readablestream-helpers.test.ts compare the two without a subprocess, so they do not depend on timing.

With this branch, both paths reject with the JSON.parse error. On the released build, readablestream-helpers.test.ts fails 3 cases and body.test.ts fails 34.

@github-actions github-actions Bot added the claude label Sep 4, 2026
@robobun

robobun commented Sep 4, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 1:29 PM PT - Sep 4th, 2026

✅ @robobun, your commit f681cf8b9d560fc1892fb10e9c4637d9f34bcf0c passed in Build #110218! 🎉


🧪   To try this PR locally:

bunx bun-pr 41387

That installs a local version of the PR into your bun-41387 executable, so you can run:

bun-41387 --bun

@coderabbitai

coderabbitai Bot commented Sep 4, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: 13e67381-0184-4c27-8a0c-6d089e7f5356

📥 Commits

Reviewing files that changed from the base of the PR and between 204670c and f681cf8.

📒 Files selected for processing (1)
  • scripts/verify-baseline-static/allowlist-x64.txt

Included review availability: Your plan provides up to 10 included reviews per hour; 6 remain after this review.


Walkthrough

Summary

JSON bindings now use JSONParseWithException and normalize empty inputs. Tests verify native syntax errors across HTTP, fetch, buffered, and streamed bodies. The x64 LLInt allowlist documents additional decoder noise and a third symbol.

Changes

JSON error consistency

Layer / File(s) Summary
Normalize JSON binding parse errors
src/jsc/bindings/BunString.cpp, src/jsc/bindings/bindings.cpp
JSON conversion paths normalize empty input and use JSONParseWithException.
Validate native errors across body sources
test/js/bun/http/serve.test.ts, test/js/bun/spawn/readablestream-helpers.test.ts, test/js/web/fetch/body.test.ts
Tests verify native syntax error messages for invalid JSON, invalid UTF-8, byte-order marks, streamed bodies, and empty bodies.

Baseline static allowlist

Layer / File(s) Summary
Update LLInt decoder-noise entries
scripts/verify-baseline-static/allowlist-x64.txt
The allowlist documents VEX and RDPMC decoder noise, removes AVX feature ceilings, updates the symbol count, and adds llint_op_jmp_wide32.

Suggested reviewers: jarred-sumner

Merge Risk: ⚪ Minimal · up to f681c

The update records additional LLInt decoder false positives in the x64 baseline allowlist without changing runtime behavior. No merge-readiness risk remains.

🚥 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 clearly and concisely describes the main change: buffered json() now reports the JSON.parse SyntaxError.
Description check ✅ Passed The description provides detailed problem, fix, background, verification results, and test evidence. It does not use the template headings exactly, but it contains the required information and is most…

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

@robobun

robobun commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator Author

The automated review found nothing to change. The red claude-find-issues check is not about this diff: that workflow stops after one turn with is_error: true on every recent PR, for example #41377 and #41380. CI build 110214 is running.

…sitive

The x64-musl static scan of this branch flags one RDPMC in
llint_op_jmp_wide32. This is the documented LLInt data-in-.text false
positive. Each opcode handler starts with an embedded opcode id, and
the linear sweep desyncs on it. The .text layout changed, so the desync
moved from llint_op_enter_wide32 and llint_op_wide16_wide16 to this
sibling. The same scan lists both of those entries as stale, and the
emulator phase of the same job passes.
Comment thread src/jsc/bindings/BunString.cpp Outdated
Comment thread src/jsc/bindings/bindings.cpp Outdated
Each helper now picks emptyString() for an empty input when it reads
the input, so the later parse needs no special case. A one-line
comment gives the reason: the conversion returns a null string for an
empty input, and JSONParseWithException throws nothing for null.
@robobun

robobun commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator Author

Two commits since the last review:

  • 4f0fb30c55: the x64-musl baseline scan flagged one RDPMC in llint_op_jmp_wide32. This is the LLInt decode false positive that the allowlist already documents, so the allowlist gets this handler. The same scan lists the two existing LLInt entries as stale, so the desync moved with the code layout. The emulator phase of that job passes.
  • 204670c703: both JSON helpers convert an empty input to "" where they read it. This answers the two comment-length notes, and both threads are resolved.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Comment thread scripts/verify-baseline-static/allowlist-x64.txt Outdated
…ries

The triage guide says to allowlist a confirmed data-in-.text misdecode
as a blanket pass. The LLInt handlers are offlineasm output, so no
compiler flag can add an instruction to them, and the feature that the
scan reports is garbage that moves with link layout. This branch shows
it: the same desync decoded as AVX in two handlers, and as RDPMC in a
third after the layout changed. A ceiling on these entries only makes
the next layout fail again.
@robobun

robobun commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator Author

The review found nothing blocking. I took its one optional note in f681cf8b9d: the three LLInt entries in the x64 baseline allowlist are now blanket passes, as the triage guide says for a data-in-.text misdecode.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM — the allowlist entries are now blanket passes per the data-in-.text rule, and the (3 symbols) count matches.

Checked the two C++ call sites: the empty-input guard precedes JSONParseWithException so a null WTF::String never reaches it, RETURN_IF_EXCEPTION follows the parse, and the !result && !scope.exception() fallback still covers the dead-string / failed-conversion case so zero_is_throw holds on every path. The isNull() branch in EncodedSlice__toJSONObject is now unreachable for len == 0, so the updated comment and the too-long check remain coherent. Tests exercise both the buffered and streamed paths against JSON.parse's own message, and the BOM case covers the empty-after-decode edge.

Extended reasoning...

Overview

This PR swaps JSC::JSONParse for JSC::JSONParseWithException in BunString__toJSON (src/jsc/bindings/BunString.cpp) and EncodedSlice__toJSONObject (src/jsc/bindings/bindings.cpp) so that buffered body.json() / ReadableStream.prototype.json() reject with the same SyntaxError message as JSON.parse and the streamed path, eliminating a timing-dependent flake in readablestream-helpers.test.ts. Both sites substitute emptyString() for the null WTF::String that empty input would otherwise produce. Tests in three existing files assert the exact JSON.parse message on both paths. Separately, scripts/verify-baseline-static/allowlist-x64.txt adds llint_op_jmp_wide32 and — after my earlier inline comment — converts all three LLInt embedded-opcode-ID entries to blanket passes with an updated group comment and count.

Security risks

None. This changes only which error message surfaces on a JSON parse failure; no new inputs are accepted, no bounds or allocation logic changes, and the zero_is_throw FFI contract is preserved on every path (fallback throw remains for a dead/null string). The allowlist change is a documented data-in-.text misdecode of LLInt opcode IDs, backed by the SDE emulation phase in the same job.

Level of scrutiny

Moderate. C++ JSC bindings with exception scopes warrant care, but the change is a two-line API swap plus a null-guard, matching the existing structure. The author reports running the affected tests under BUN_JSC_validateExceptionChecks=1 on the ASAN build. I verified the throw-scope discipline (RETURN_IF_EXCEPTION after the throwing call, no path returning encoded 0 without a pending exception) and that the empty-input branch cannot bypass the too-long check in EncodedSlice__toJSONObject.

Other factors

My previous review's only finding — that the LLInt allowlist entries needed to be blanket passes per scripts/verify-baseline-static/CLAUDE.md — was addressed in commit f681cf8: entries are now bare symbols, the comment explains "a ceiling has nothing to bound", and the (N symbols) count is correct. No outstanding third-party CHANGES_REQUESTED reviews. Test additions follow the conventions (existing files, test.each, single .toEqual for the compound assertion, no timing waits).

@Jarred-Sumner
Jarred-Sumner merged commit 5fb9ecb into main Sep 4, 2026
6 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the robobun/05992770/json-parse-error-buffered-path branch September 4, 2026 22:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants