Skip to content

Honor toJSON in JSON.stringify's fast path for non-enumerable own properties and swapped array prototypes - #267

Closed
robobun wants to merge 2 commits into
mainfrom
farm/d6770536/json-stringify-non-enumerable-tojson
Closed

robobun wants to merge 2 commits into
mainfrom
farm/d6770536/json-stringify-non-enumerable-tojson

Conversation

@robobun

@robobun robobun commented Jul 2, 2026 •

Copy link
Copy Markdown
Collaborator

FastStringifier decides on its own whether a value can have a toJSON, and it gets two shapes wrong. In both, JSON.stringify silently returns valid but wrong JSON: no throw, no crash.

1. An own toJSON that is not enumerable

const o = { uri: "u", cid: "c", text: "t" };
Object.defineProperty(o, "toJSON", { value: () => ({ uri: o.uri, cid: o.cid }), enumerable: false });
print(JSON.stringify(o));
// fast path: {"uri":"u","cid":"c","text":"t"}
// expected:  {"uri":"u","cid":"c"}

SerializeJSONProperty step 3 looks up toJSON with GetV, which finds own properties regardless of enumerability. The ObjectType/FinalObjectType case checks Object.prototype for toJSON (mayHaveToJSON) but never checks the object itself, and its property loop skips DontEnum entries first, so an own non-enumerable toJSON is dropped. An own enumerable toJSON is handled by accident: the loop reaches the callable property value and recordFailure("callable object") sends the whole object down the slow path.

Fix: look for toJSON in the object property loop before DontEnum entries are skipped, mirroring what the ArrayType case already does for own properties. It costs one pointer compare per property, and on a hit the object goes to the general Stringifier, which does the GetV lookup and the call.

2. An array whose prototype was replaced

const proto = { __proto__: Array.prototype, toJSON() { return "x"; } };
const array = [1, 2, 3];
Object.setPrototypeOf(array, proto);
print(JSON.stringify(array));
// fast path: [1,2,3]
// expected:  "x"

The ArrayType case checks the global Array.prototype for toJSON and scans the array's own properties, but never looks at the array's actual storedPrototype(). Object.setPrototypeOf keeps ArrayType and the indexing type, so the array stays on the fast path with a prototype nobody checked. The object case bails on a non-standard prototype; arrays had no equivalent guard.

Fix: bail when the stored prototype is not Array.prototype, inside the branch that already handles non-original array structures, so plain arrays are untouched. (Found by a review comment on this PR, same bug class, so it is fixed here.)

Verification

Built jsc (JSCOnly, ENABLE_STATIC_JSC=ON, USE_BUN_JSC_ADDITIONS=ON, Debug) at c9ad5813fd with and without the change.

  • JSTests/stress/json-stringify-non-enumerable-to-json.js and JSTests/stress/json-stringify-array-prototype-to-json.js (both new) fail before and pass after.
  • Every existing JSTests/stress test matching *json* / *stringif* passes (55 files, --useDollarVM=true). The one exclusion, missing-exception-check-in-json-stringifier-gap.js, fails the same way on an unmodified build in this environment (it is a memoryHog test and trips on toLocaleString).
  • Both new tests also diff the fast path against the general stringifier (identity callable replacer) for each case, with and without a gap, and cover the DynamicBuffer path, a non-callable own toJSON, an enumerable own toJSON, a null-prototype array, and an own toJSON shadowing the prototype's.
  • 170 existing JSTests/stress tests matching *array-prototype* / *set-prototype* / *freeze* / *seal* / *proto-chain* pass. Three fail (array-prototype-flat-reentrant-mutation, missing-exception-check-in-array-prototype-fastJoin, object-freeze-with-arguments-no-oom-error), identically on an unmodified build in this environment.
  • No measurable cost on a Release jsc built both ways (best of 5, in ms): 20k-row array 36.8 vs 36.7, 200-property object 6.6 vs 6.7, small nested object 47.8 vs 46.9, 20k-row array with space: 2 18.3 vs 18.4.

Both fixes match V8 for every shape probed: own non-enumerable toJSON, nested, inside an array, inherited from Object.prototype, inherited from a custom prototype, non-callable, enumerable, on a swapped array prototype, and shadowed by an own one.

History

Neither bug is a recent regression, and neither is fork-specific. The object case of FastStringifier has skipped DontEnum properties since it was introduced in e608d7d ("Speed up JSON.stringify by adding a separate fast case algorithm", 2022-07-10), and WebKit/WebKit main has the same code today, so this patch applies there unchanged.

What changed for Bun is narrower than the bug. On WebKit 5488984d20e0 (Bun 1.3.14) a frozen object escaped the fast path, because freezing transitioned it to NonArrayWithArrayStorage and canPerformFastPropertyEnumeration() rejects structures with indexed properties:

before freeze: ... {uri:0, text:1, toJSON:64}, NonArray, PropertyAddition ...
after freeze:  ... {uri:0, text:1, toJSON:64}, NonArrayWithArrayStorage, Freeze ...

On c9ad5813fd the frozen object stays NonArray, reaches the fast path, and hits the hole. (canPerformFastPropertyEnumeration itself is unchanged between those two revisions.) The same object without Object.freeze was already serialized wrong on 1.3.14.

SerializeJSONProperty looks up toJSON with GetV, which finds own properties
regardless of enumerability. FastStringifier's object case only checks
Object.prototype for toJSON and then skips DontEnum properties, so an own
non-enumerable toJSON was never called:

    const o = { uri: "u", text: "t" };
    Object.defineProperty(o, "toJSON", { value: () => ({ uri: o.uri }), enumerable: false });
    JSON.stringify(o); // {"uri":"u","text":"t"}, expected {"uri":"u"}

An own enumerable toJSON happens to be handled already, because the fast path
bails out when it reaches the callable property value. The array case checks
the structure for toJSON; do the same for objects, in the property loop that
is already walking the structure.
@robobun

robobun commented Jul 2, 2026

Copy link
Copy Markdown
Collaborator Author

The red Preview Build check is not this diff. It fails in Prepare all required actions:

The action actions/github-script@v7 is not allowed in oven-sh/WebKit because all actions must be pinned to a full-length commit SHA.

Every Preview Build run since 2026-07-02 17:17Z fails the same way (the last green one was 01:39Z), so the workflows need their uses: pinned to SHAs before any PR here can publish a preview tarball. build-reusable.yml uses unpinned actions too, so the autobuild-<sha> release on merge to main is likely blocked by the same policy.

Happy to send that pinning as a separate PR if it's useful.

@coderabbitai

coderabbitai Bot commented Jul 2, 2026 •

Copy link
Copy Markdown

Review Change Stack

Walkthrough

Updates JSON.stringify fast-path handling for toJSON on objects and arrays, and adds stress tests for non-enumerable and prototype-based toJSON cases across nested values, spacing, and replacer combinations.

Changes

JSON.stringify toJSON handling

Layer / File(s) Summary
Fast stringifier toJSON detection fix
Source/JavaScriptCore/runtime/JSONObject.cpp
FastStringifier::append checks enumerated object properties for toJSON before DontEnum skipping and marks fast-stringification as failed on a match.
Fast array prototype validation
Source/JavaScriptCore/runtime/JSONObject.cpp
Fast array serialization tightens validation for non-original array structures and removes the extra property scan for toJSON on those paths.
Stress test for non-enumerable toJSON
JSTests/stress/json-stringify-non-enumerable-to-json.js
Adds helpers and assertions for non-enumerable toJSON on objects, nested values, return-value variants, dynamic-buffer stress, non-callable properties, frozen objects, and replacer parity checks.
Stress test for array prototype toJSON
JSTests/stress/json-stringify-array-prototype-to-json.js
Adds helpers and assertions for prototype-provided toJSON, own non-enumerable toJSON, null prototypes, plain array behavior, and fast-path parity across spacing options.

Sequence Diagram(s)

Not applicable.

Related issues: None specified
Related PRs: None specified
Suggested labels: JavaScriptCore, tests
Suggested reviewers: None specified

🚥 Pre-merge checks | ✅ 3 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description is substantive but does not follow the required WebKit template or include the Bugzilla and commit-message sections. Reformat it to the template with bug title, Bugzilla link, Reviewed by line, explanation, and changed-file bullets.
✅ Passed checks (3 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 summarizes the fast-path JSON.stringify/toJSON fixes for objects and arrays.

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

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Source/JavaScriptCore/runtime/JSONObject.cpp`:
- Around line 1426-1434: The own-toJSON key check is duplicated in both the
object and array serialization paths, so consolidate it into a small shared
helper in JSONObject.cpp. Create a helper such as one that inspects an entry and
records the provided failure message when the key matches
m_vm.propertyNames->toJSON, then use it at both the object-side
structure.forEachProperty path and the array-side equivalent so the matching
logic stays consistent and easier to update later.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 28829ea2-6769-4213-a4fa-718e2b03db36

📥 Commits

Reviewing files that changed from the base of the PR and between c9ad581 and 453aa2e.

📒 Files selected for processing (2)
  • JSTests/stress/json-stringify-non-enumerable-to-json.js
  • Source/JavaScriptCore/runtime/JSONObject.cpp

Comment thread Source/JavaScriptCore/runtime/JSONObject.cpp
Comment thread Source/JavaScriptCore/runtime/JSONObject.cpp
…type

FastStringifier's array case checks the global Array.prototype for toJSON and
scans the array's own properties, but never looks at the array's actual
prototype, so a swapped prototype carrying toJSON was ignored:

    const proto = { __proto__: Array.prototype, toJSON() { return "x"; } };
    const array = [1, 2, 3];
    Object.setPrototypeOf(array, proto);
    JSON.stringify(array); // [1,2,3], expected "x"

The object case already bails on a non-standard prototype. Do the same for
arrays, in the branch that already handles non-original array structures, so
plain arrays are unaffected.
@robobun robobun changed the title Honor an own non-enumerable toJSON in JSON.stringify's fast path Honor toJSON in JSON.stringify's fast path for non-enumerable own properties and swapped array prototypes Jul 2, 2026
@robobun

robobun commented Jul 2, 2026

Copy link
Copy Markdown
Collaborator Author

Two updates.

c6a845a fixes a second hole in the same function, raised in review above: the ArrayType case never checks the array's storedPrototype(), so Object.setPrototypeOf(array, { __proto__: Array.prototype, toJSON() {} }) serialized as [1,2,3]. Same bug class, so it is fixed here rather than deferred. Title and description updated, JSTests/stress/json-stringify-array-prototype-to-json.js added.

The red Preview Build is the Actions pinning policy, not this diff. #269 pins every action in .github/workflows to the commit its current tag points at; its own Preview Build got past Trigger Preview Build and is now building, so the policy is satisfied by that change. Once #269 is on main, re-running this PR's check should be enough.

Both commits apply to WebKit/WebKit main unchanged (verified with git apply --check), so they are upstream-ready for whoever has a bugs.webkit.org account.

@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.

Thanks for folding in the array-prototype guard — the fix and its test look right to me. Both changes are conservative bailouts that only widen when FastStringifier defers to the general path, but this is hot JSC runtime code with subtle structure/spec reasoning, so I'd like a JSC maintainer to sign off.

Extended reasoning...

Overview

Two targeted spec-conformance fixes in FastStringifier::append (Source/JavaScriptCore/runtime/JSONObject.cpp), plus two new stress tests. The object-path fix adds a toJSON key check inside the existing structure.forEachProperty loop, before the DontEnum skip, so an own non-enumerable toJSON triggers recordFailure and falls back to the general Stringifier. The array-path fix (added in c6a845a in response to my earlier comment) adds hasPolyProto() and storedPrototype() != arrayPrototype() guards inside the already-[[unlikely]] !isOriginalArrayStructure branch, mirroring the object case's prototype guard. Both changes only add bailouts — they never alter what the fast path emits, only when it gives up.

Security risks

None. This is a correctness fix for JSON.stringify output on edge-case inputs (own non-enumerable toJSON, arrays with swapped prototypes). No new attack surface, no memory-safety implications; recordFailure is the established, side-effect-free bailout mechanism used throughout this function.

Level of scrutiny

High. JSON.stringify is one of the most heavily exercised built-ins, and FastStringifier is on the hot path for essentially every serialization. The reasoning is subtle: it depends on UniquedStringImpl* pointer equality against propertyNames->toJSON, on forEachProperty iteration order relative to the DontEnum skip, on storedPrototype()'s mono-proto precondition, and on isOriginalArrayStructure implying Array.prototype. The author's analysis is thorough and the patterns exactly mirror existing code in the same function, but a JSC maintainer is better positioned than I am to confirm there are no structure-transition or cross-realm corner cases I'm missing.

Other factors

The PR is well-supported: two new stress tests covering nested/array-wrapped/gap/DynamicBuffer/non-callable/null-prototype/shadowing shapes, differential checks against the general stringifier via an identity replacer, verified against V8, all 55 existing *json*/*stringif* stress tests passing, and Release benchmarks showing no measurable regression. All prior review threads (CodeRabbit's dedup nit, my array-prototype note) are resolved. The engine diff is ~16 lines and applies cleanly to upstream WebKit. I'm deferring rather than approving purely because of where the change sits, not because I found anything wrong with it.

@robobun

robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

Closing: superseded. The conflict with main is that main already contains both fixes.

They went upstream as 316941@main (bugs.webkit.org/318507, also covers a third case: objects with non-reified static properties, e.g. JSON.stringify(Date.prototype) now throws instead of returning {}), and upstream then moved the object check inside the DontEnum branch in 318072@main after it showed up as a JSON.stringify regression in their benchmarks. Both reached this fork in the 3722912 sync and bun picked them up in oven-sh/bun#36794 (WEBKIT_VERSION e6e37cd and later).

The two stress tests here exist on main under the same names, so there is nothing left in this branch to rebase.

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