Skip to content

YAML, JSON5, TOML, XML stringify: write a Proxy of an array as an array - #42650

Open
robobun wants to merge 3 commits into
mainfrom
robobun/f2dbe7c1/stringify-proxy-array
Open

robobun wants to merge 3 commits into
mainfrom
robobun/f2dbe7c1/stringify-proxy-array

Conversation

@robobun

@robobun robobun commented Sep 13, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • With p = new Proxy([1, 2, [3]], {}), Bun.YAML.stringify(p) returns {"0": 1,"1": 2,"2": [3]}. JSON5 and TOML also write index keys. Bun.XML.stringify({ r: { a: new Proxy(["1"], {}) } }) throws '0' is not a valid XML element name. JSON.stringify(p) returns [1,2,[3]].
  • The cause: every array check in the four stringifiers is JSValue::is_array() (src/jsc/JSValue.rs:284). It tests the cell type, and a Proxy is never an array cell.
  • No user reported this. It was found while testing YAML.stringify: keep visited collections alive so a reused address is not taken for an alias #42578. The likely trigger, reactive wrappers (Vue, MobX), is inferred.

Fix

  • Add JSValue::is_array_including_proxy (JSC::isArray, the ECMA-262 IsArray). It replaces all 19 is_array() calls in the four stringifiers. A non-Proxy value makes no FFI call. A revoked Proxy throws a TypeError, as before.
  • The iteration needs no change. JSArrayIterator already reads length and each index of a non-JSArray value with [[Get]].
  • Verified: the a Proxy of an array blocks in test/js/bun/{yaml,json5,toml,xml}/*.test.ts. Release 1.4.3-canary fails 14 of the 18 tests. Also the four conformance suites and expect.test.js.
  • Self-reviewed: 8 concerns raised, 6 addressed. The Notes name the 2 that are not in the PR.

Background

  • IsArray (ECMA-262 7.2.2) is true for an Array and for a Proxy whose target is an array. Array.isArray and JSON.stringify use it.
  • JSValue::is_array() reads the type byte of the cell. A Proxy has the ProxyObject type, whatever its target is.
  • Not changed here: a Proxy of a function. JSON.stringify skips it (IsCallable). The stringifiers write {} (is_function(), 19 sites). That is a separate change.
Notes
  • Reference libraries write the same Proxy as an array: json5 2.2.3 [1,2,[3]], yaml 2.8.1 and js-yaml 4.1.0 - 1\n- 2\n- - 3\n, smol-toml 1.4.2 a = [ 1, 2, [ 3 ] ], fast-xml-parser 5.2.5 <r><a>1</a><a>2</a></r>.
  • The four tests that pass on the release binary pin behaviour that must not change: a revoked Proxy throws a TypeError (YAML, JSON5, TOML), and a Proxy array that contains itself is a circular structure in JSON5. In XML a revoked Proxy as children threw a plain Error before.
  • The message for a revoked Proxy is now the IsArray message of JSC, Array.isArray cannot be called on a Proxy that has been revoked. Before, the ownKeys step threw Proxy has already been revoked. No more operations are allowed to be performed on it. Both are TypeErrors.
  • Trap order. Release 1.4.3-canary: ownKeys, getOwnPropertyDescriptor per key, then get per key. This branch, per pass over the array: has length, get length, get 0, get 1. JSON.stringify: get toJSON, get length, get 0, get 1. The extra has length comes from JSValue::get_length, which uses getIfPropertyExists. JSArrayIterator also truncates a length above u32::MAX, where JSON.stringify throws RangeError: Out of memory. Both belong to the shared iterator (93 call sites) and are not changed here.
  • Self-review items that are not in the PR: (1) drop the xml.test.ts depth change, as a duplicate of XML.stringify: throw for a string space that is not XML whitespace #42531. Without it that file times out on a debug build, so it stays. (2) Add doc lines on JSValue::is_array and JSType::is_array. The comment check of this repository flagged them, so they are removed.
  • An earlier revision also changed JSArrayIterator::init to read the length of a Proxy with LengthOfArrayLike. That changes Bun.spawn, the expect matchers and other callers with no test there, so it is out of this PR.
  • Other APIs that take an array also test the cell type and mishandle a Proxy of an array on release 1.4.3-canary: RedisClient.hset(key, proxy) writes fields 0 and 1, new Bun.CookieMap(proxy) is empty, the $ template joins the items into one argument, node:http2 joins header values into one string. Each API has its own contract. This PR does not change them.
  • Implement the replacer argument of YAML, TOML and JSON5 stringify #39925 (replacer argument) adds a helper with the same name and uses it for the replacer only. Its diff applies to main but not on top of this branch: it conflicts in JSValue.rs, bindings.cpp, TOMLObject.rs and YAMLObject.rs. The helper hunks sit at the same place on purpose, so the second PR to land gets a textual conflict and not a duplicate definition. Its rebase must keep is_array_including_proxy at the value checks.
  • bun:test: toBeBoolean accepts a Boolean object, toBeArray and toBeArrayOfSize accept a Proxy of an array #42504 and bun:test: toBeEmptyObject rejects a function and an array #42559 add is_array_or_proxied_array for bun:test matchers. It returns false for a revoked Proxy and does not throw, so it is a different predicate.
  • xml.test.ts: the "deep values are a catchable error" test builds 2 million nested objects. On a debug ASAN build that takes 13 s against the 5 s timeout, with no XML code involved. It now uses depth 100,000 on a debug build. XML.stringify: throw for a string space that is not XML whitespace #42531 makes the identical change, so the two merge cleanly in either order.
  • Ran the new tests with BUN_JSC_validateExceptionChecks=1: they pass.

[human-review] gate passed · iteration 0 · 10 files touched

fails on main (without fix)
ASAN without fix: 14 failed, 18 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" "test/js/bun/json5/json5.test.ts" test/js/bun/toml/toml.test.ts test/js/bun/xml/xml.test.ts test/js/bun/yaml/yaml.test.ts
bun test v1.4.3 (b99371011)

test/js/bun/json5/json5.test.ts:
(pass) escape sequences > \v vertical tab [2.63ms]
(pass) escape sequences > \0 null character [1.69ms]
(pass) escape sequences > \0 followed by non-digit [1.31ms]
(pass) escape sequences > \0 followed by digit throws [6.33ms]
(pass) escape sequences > \1 through \9 throw [8.10ms]
(pass) escape sequences > \xHH hex escape [2.16ms]
(pass) escape sequences > \xHH hex escape lowercase [1.30ms]
(pass) escape sequences > \xHH hex escape high byte [1.63ms]
(pass) escape sequences > \xHH hex escape null [1.73ms]
(pass) escape sequences > \x with insufficient hex digits throws [5.76ms]
(pass) escape sequences > \uHHHH unicode escape A [1.92ms]
(pass) escape sequences > \uHHHH unicode escape e-acute [1.65ms]
(pass) escape sequences > \uHHHH unicode escape CJK [1.41ms]
(pass) escape sequences > \u with insufficient hex digits throws [5.81ms]
(pass) escape sequences 
... (truncated)

release without fix: 14 failed, 18 skipped
bun test v1.4.3-canary.1 (b99371011)

test/js/bun/json5/json5.test.ts:
(pass) escape sequences > \v vertical tab [0.08ms]
(pass) escape sequences > \0 null character [0.02ms]
(pass) escape sequences > \0 followed by non-digit [0.01ms]
(pass) escape sequences > \0 followed by digit throws [0.09ms]
(pass) escape sequences > \1 through \9 throw [0.08ms]
(pass) escape sequences > \xHH hex escape [0.01ms]
(pass) escape sequences > \xHH hex escape lowercase [0.01ms]
(pass) escape sequences > \xHH hex escape high byte [0.01ms]
(pass) escape sequences > \xHH hex escape null [0.01ms]
(pass) escape sequences > \x with insufficient hex digits throws [0.04ms]
(pass) escape sequences > \uHHHH unicode escape A [0.01ms]
(pass) escape sequences > \uHHHH unicode escape e-acute [0.01ms]
(pass) escape sequences > \uHHHH unicode escape CJK [0.01ms]
(pass) escape sequences > \u with insufficient hex digits throws [0.04ms]
(pass) escape sequences > hex and unicode escape errors point at the first byte that is not a hex digit [8.61ms]
(pass) escape sequences > surrogate pairs [0.04ms]
(pass) escape sequences > identity escapes [0.02ms]
(pass) escape sequences > identity escape single char
... (truncated)
passes on PR (with fix)
ASAN with fix: 18 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" "test/js/bun/json5/json5.test.ts" test/js/bun/toml/toml.test.ts test/js/bun/xml/xml.test.ts test/js/bun/yaml/yaml.test.ts
bun test v1.4.3 (b99371011)

test/js/bun/json5/json5.test.ts:
(pass) escape sequences > \v vertical tab [2.61ms]
(pass) escape sequences > \0 null character [1.70ms]
(pass) escape sequences > \0 followed by non-digit [1.32ms]
(pass) escape sequences > \0 followed by digit throws [5.61ms]
(pass) escape sequences > \1 through \9 throw [8.15ms]
(pass) escape sequences > \xHH hex escape [1.92ms]
(pass) escape sequences > \xHH hex escape lowercase [1.29ms]
(pass) escape sequences > \xHH hex escape high byte [1.65ms]
(pass) escape sequences > \xHH hex escape null [1.73ms]
(pass) escape sequences > \x with insufficient hex digits throws [5.85ms]
(pass) escape sequences > \uHHHH unicode escape A [1.90ms]
(pass) escape sequences > \uHHHH unicode escape e-acute [1.63ms]
(pass) escape sequences > \uHHHH unicode escape CJK [1.36ms]
(pass) escape sequences > \u with insufficient hex digits throws [6.61ms]
(pass) escape sequences 
... (truncated)

release with fix: 18 skipped
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 688ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/8] gen generated_host_exports.rs
generated_host_exports.rs: 122 exports (host=5, lazy=10, generic=107, rust=0); 243 extern-C blocks audited
[2/8] gen cpp.rs (cppbind)
[2/8] cargo bun_runtime → libbun_runtime.a
�[1m�[92m   Compiling�[0m bun_core v0.0.0 (/workspace/bun/src/bun_core)
�[1m�[92m   Compiling�[0m bun_errno v0.0.0 (/workspace/bun/src/errno)
�[1m�[92m   Compiling�[0m bun_ptr v0.0.0 (/workspace/bun/src/ptr)
�[1m�[92m   Compiling�[0m bun_boringssl_sys v0.0.0 (/workspace/bun/src/boringssl_sys)
�[1m�[92m   Compiling�[0m bun_safety v0.0.0 (/workspace/bun/src/safety)
�[1m�[92m   Compiling�[0m bun_base64 v0.0.0 (/workspace/bun/src/base64)
�[1m�[92m   Compiling�[0m bun_cares_sys v0.0.0 (/workspace/bun/src/cares_sys)
�[1m�[92m   Compiling�[0m bun_zlib_sys v0.0.0 (/workspace/bun/src/zlib_sys)
�[1m�[92m   Compiling�[0m bun_zstd v0.0.0 (/workspace/bun/src/zstd)
�[1m�[92m   Compiling�[0m bun_picohttp v0.0.0 (/workspace/bun/src/picohttp)
�[1m�[92m   Compiling�[0m bun_brotli v0.0.0 (/workspace/bun/src/brotli
... (truncated)
diff hotspot
src/jsc/JSValue.rs              | 12 ++++++++
 src/jsc/bindings/bindings.cpp   |  6 ++++
 src/runtime/api/JSON5Object.rs  |  4 ++-
 src/runtime/api/TOMLObject.rs   | 10 +++---
 src/runtime/api/XMLObject.rs    | 25 ++++++++-------
 src/runtime/api/YAMLObject.rs   |  4 +--
 test/js/bun/json5/json5.test.ts | 51 +++++++++++++++++++++++++++++++
 test/js/bun/toml/toml.test.ts   | 59 ++++++++++++++++++++++++++++++++++++
 test/js/bun/xml/xml.test.ts     | 67 +++++++++++++++++++++++++++++++++++++++--
 test/js/bun/yaml/yaml.test.ts   | 59 ++++++++++++++++++++++++++++++++++++
 10 files changed, 276 insertions(+), 21 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                             reads  edits  tests
src/jsc/JSValue.rs                   1      1     29
src/jsc/bindings/bindings.cpp        1      4     27
src/runtime/api/JSON5Object.rs       1      2     27
src/runtime/api/TOMLObject.rs        1      0     27
src/runtime/api/XMLObject.rs         2      0     27
src/runtime/api/YAMLObject.rs        1      0     27
test/js/bun/json5/json5.test.ts      1      2     16
test/js/bun/toml/toml.test.ts        1      1     15
test/js/bun/xml/xml.test.ts          1      1     17
test/js/bun/yaml/yaml.test.ts        1      2     16

The four stringifiers decided "is this an array" with JSValue::is_array,
which checks the cell type. A Proxy is never an array cell, so a Proxy
whose target is an array was written as an object with index keys
(YAML, JSON5, TOML) or rejected with "'0' is not a valid XML element
name" (XML). Array.isArray and JSON.stringify use ECMA-262 IsArray,
which looks through a Proxy.

Add JSValue::is_array_including_proxy (JSC::isArray) and use it at every
array check in the four stringifiers. It throws a TypeError for a
revoked Proxy. A value that is not a Proxy does not cross FFI.

JSArrayIterator needs no change. For a value that is not a JSArray it
already reads "length" and each index with [[Get]].

xml.test.ts: the deep values test uses depth 100,000 on a debug build,
where it otherwise runs longer than the 5 s timeout.
@robobun

robobun commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator Author

Status

Reproduced on bun 1.4.3-canary (b993710), release build:

const p = new Proxy([1, 2, [3]], {});
JSON.stringify(p);                    // [1,2,[3]]
Array.isArray(p);                     // true
Bun.YAML.stringify(p);                // {"0": 1,"1": 2,"2": [3]}
Bun.JSON5.stringify(p);               // {'0':1,'1':2,'2':[3]}
Bun.TOML.stringify({ a: p });         // [a]\n0 = 1\n1 = 2\n2 = [3]\n

const q = new Proxy(["1", "2"], {});
Bun.XML.stringify({ r: { a: q } });   // throws: '0' is not a valid XML element name

With src/ at origin/main, 14 of the 18 new tests in test/js/bun/{yaml,json5,toml,xml}/*.test.ts fail. With this branch all 18 pass on a debug ASAN build. The calls above return [1,2,[3]], [1,2,[3]], a = [1, 2, [3]]\n and <r><a>1</a><a>2</a></r>.

Fix: this PR, #42650.

@coderabbitai

coderabbitai Bot commented Sep 13, 2026 •

Copy link
Copy Markdown
Contributor

Review Change StackReview 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: edbecc80-61bb-4b54-bc17-9d8957ffc13a

📥 Commits

Reviewing files that changed from the base of the PR and between 24c244a and ff06a8c.

📒 Files selected for processing (1)
  • src/jsc/JSValue.rs
💤 Files with no reviewable changes (1)
  • src/jsc/JSValue.rs

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


Walkthrough

The change adds ECMAScript IsArray semantics for proxy-wrapped arrays. JSON5, TOML, XML, and YAML serialization now use this detection. Tests cover proxy traps, nesting, cycles, aliases, and revoked proxies.

Changes

Proxy-aware array serialization

Layer / File(s) Summary
Proxy-aware array detection
src/jsc/JSValue.rs, src/jsc/bindings/bindings.cpp
Adds JSValue::is_array_including_proxy and its JSC binding. Ordinary values use existing type checks. Proxy values use JSC array detection, which can propagate revoked-proxy exceptions.
Serializer integration
src/runtime/api/JSON5Object.rs, src/runtime/api/TOMLObject.rs, src/runtime/api/XMLObject.rs, src/runtime/api/YAMLObject.rs
Updates array classification, validation, recursion, and output paths to recognize proxy-wrapped arrays.
Proxy-array serialization tests
test/js/bun/json5/json5.test.ts, test/js/bun/toml/toml.test.ts, test/js/bun/xml/xml.test.ts, test/js/bun/yaml/yaml.test.ts
Adds coverage for proxy arrays, traps, nested values, cycles, aliases, invalid placements, and revoked-proxy TypeError behavior. XML deep-value limits now vary by build type.

Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to ff06a

No merge-blocking risk is identified for the proxy-array serialization change.

🚥 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 identifies the four affected stringifiers and the main behavior change: serializing proxies of arrays as arrays.
Description check ✅ Passed The description explains the problem, fix, scope, limitations, and verification results. It provides the information required by the template, although it uses equivalent headings instead of the exact…

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

Comment thread src/jsc/JSType.rs Outdated
Comment thread src/jsc/JSValue.rs Outdated
@robobun

robobun commented Sep 13, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 1:20 PM PT - Sep 13th, 2026

✅ @robobun, your commit ff06a8c9af022f65c4ee007d735a190fae667274 passed in Build #115268! 🎉


🧪   To try this PR locally:

bunx bun-pr 42650

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

bun-42650 --bun

The doc on is_array_including_proxy, the next method, already states how
the two differ.

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

Code review found no issues

No high-confidence issues detected in this change.

This branch has not been deployed

No deployments
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.

1 participant