Skip to content

XML parser: SIMD structural index + tape rows, one-call rows→JS - #37146

Merged
Jarred-Sumner merged 27 commits into
mainfrom
claude/xml-simd-tape
Aug 8, 2026
Merged

XML parser: SIMD structural index + tape rows, one-call rows→JS#37146
Jarred-Sumner merged 27 commits into
mainfrom
claude/xml-simd-tape

Conversation

@Jarred-Sumner

@Jarred-Sumner Jarred-Sumner commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

Rewrites Bun.XML's parser in the shape of the JSON parser: a Highway SIMD "stage 1" indexes every < > & \r, control character and encoded U+FFFE/FFFF (plus tab/newline/quotes/= inside tags, tracked with a MatchStar carry across 64-byte blocks), and stage 2 hops from index entry to entry — text runs, attribute values, comments, CDATA and PIs are never walked byte by byte — writing immutable ObjectJSON/ArrayJSON rows on a JsonTape instead of a Vec per object. Elements are parsed iteratively (explicit depth cap); the DTD and entity replacement text keep the byte-level scanner.

Rows (JSON and XML alike) become JS values in one C++ call: objects via putDirect, arrays via MarkedArgumentBuffer + constructArray, keys and short values through the VM's jsonAtomStringCache as JSON.parse does, with three string paths keyed on the tape's encoding (UTF-8 / Latin-1 / UTF-16). An 8-bit JS string is parsed byte-per-character as is and yields Latin-1 strings without any re-encoding. Bun.{XML,JSONC,TOML,YAML,JSON5}.parse recycle a per-thread arena instead of a mi_heap per call.

JS levelbench/xml/xml.mjs with the CI-built PR binary (bunx bun-pr 37146, linux-x64) vs main, ms/iter, lower is better (one loaded machine → read as ratios). Input is a JS string: ASCII files take the Latin-1 in-place path, the CJK-heavy one the native UTF-16 path.

document PR (compact) main × PR {compact:false} main txml fast-xml-parser @xmldom
small, 188 B 0.0028 0.0149 5.4× 0.0048 0.0168 0.0079 0.029 0.032
S3 ListObjects, 231 KB 1.33 3.17 2.4× 3.97 8.54 4.79 24.8 33.6
Atom feed, 193 KB 1.10 3.25 3.0× 2.56 5.93 4.57 20.8 26.2
SVG (LibreOffice art), 1.1 MB 0.55 4.12 7.5× 0.53 3.93 5.34 4.10
SVG (hixie perf 007), 388 KB 0.34 1.62 4.8× 0.38 1.77 0.29 2.89 2.33
Vulkan vk.xml, 3.1 MB 23.1 61.9 2.7× 47.6 109 65.5 304 311
RFC xml, 363 KB 1.85 4.19 2.3× 3.37 8.20 2.98 32.1 40.4
libphonenumber, 959 KB 4.97 11.4 2.3× 9.56 18.9 11.8 62.5 60.8
chromium enums.xml, 1.4 MB 16.6 27.3 1.6× 27.9 47.7 47.2 153 112
freedesktop mime db, 2.2 MB (UTF-16) 26.7 60.2 2.3× 43.4 94.4 60.0 317 303

Native levelscripts/bench-json-rust.sh --xml (criterion; parse → compact tree), MiB/s, higher is better:

file bun new bun old pugixml quick-xml expat roxmltree libxml2 xml-rs
SVG (LibreOffice art) 1.2 MB 3634 341 2177 964 174 294 630 54
SVG (hixie perf 007) 400 KB 2348 300 2239 855 264 282 422 50
libphonenumber metadata 1 MB 438 161 836 577 171 175 88 37
RFC xml (opus draft) 370 KB 344 124 857 337 184 12
Vulkan vk.xml 3.2 MB 339 98 627 239 132 108 41
chromium enums.xml 1.4 MB 322 95 703 275 114 106 35 28
freedesktop mime db 2.4 MB 207 60 585 237 119 91 30
synthetic dense records 1 MB 162 37 583 132 86 82 22 15

So: 3–10× the parser on main, ahead of every Rust parser and expat/libxml2 on all files, ahead of pugixml on attribute/text-heavy input, still behind pugixml (which skips UTF-8 validation and duplicate-attribute checks and parses in-situ) on element-dense input; stage 1 alone runs at 2–3 GiB/s everywhere.

How did you verify your code works?

bun bd test test/js/bun/xml (1995/1995 W3C conformance cases with identical messages, 56 unit tests incl. new ones for > inside attribute values and key kinds), test/js/bun/jsonc (incl. a new object-shape parity test against JSON.parse), test/js/bun/toml, yaml, bundler_loader, bun-lock; SIMD-vs-scalar index agreement unit tests; bun run rust:check-all.

Jarred-Sumner and others added 9 commits August 7, 2026 10:39
…rser

WIP checkpoint: Highway kernel + scalar indexer, stage 2 hops index entries
for content/attribute values/comments/CDATA/PIs, ObjectJSON/ArrayJSON rows
on a JsonTape, criterion bench vs pugixml/expat/libxml2/quick-xml/roxmltree.
…tribute-value paths, nonchar detection in the kernel
… presize XML staging stacks; bench additions
…nded reservations, reused fold map, layout asserts, robust WTF-8 decode, shared ToJSError mapping, arena TLS without a destructor; tests
No-Verification-Needed: lint-only change
@robobun

robobun commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator
Updated 6:54 PM PT - Aug 7th, 2026

@Jarred-Sumner, your commit ee1d72390a314884209b0a34b5cbff5c4319539e passed in Build #90382! 🎉


🧪   To try this PR locally:

bunx bun-pr 37146

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

bun-37146 --bun

…rgets

No-Verification-Needed: CI allowlist only
@Jarred-Sumner
Jarred-Sumner marked this pull request as ready for review August 7, 2026 19:45

@Jarred-Sumner Jarred-Sumner left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fix your slop

Comment thread src/jsc/bindings/JSONRowsToJS.cpp Outdated
return {};
}
const RowValue* rows = m_items + a.first;
JSArray* array = constructEmptyArray(m_globalObject, nullptr, a.count);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Use a MarkedArgumentsBuffer with a JSC::constructArray, or tryCreateUninitializeRestricted but MarkedArgumentsBuffer is safer. Do that for both.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Done in 19dc05c: array() collects into a MarkedArgumentBuffer (ensureCapacity + hasOverflowed check) and calls JSC::constructArray; objects still constructEmptyObject + putDirect (now with the exception check right after construction).

Comment thread src/jsc/bindings/JSONRowsToJS.cpp Outdated

// The parsers hand over WTF-8: UTF-8 that may also encode lone surrogates (from JSON
// `\uD800`-style escapes). Anything malformed becomes U+FFFD, one per offending byte.
static String decodeWTF8(std::span<const Latin1Character> bytes)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Terrible slop. Not only are there at least 10 implementations of this function in the codebase, this precise one should be completely unnecessary.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Removed. The converter is now templated on the tape's encoding with three paths: Latin-1 and UTF-16 spans go straight into jsonAtomStringCache.makeIdentifier / tryMakeJSString, UTF-8 takes the same Latin-1 path when ASCII and String::fromUTF8 otherwise; the only thing that can fail strict decoding is WTF-8 from a JSON \uD800-style escape, and that falls back to the existing Rust wtf8_to_utf16_alloc path (Bun__JSONRows__wtf8ToJS) instead of a new decoder. Bun.XML.parse now parses an 8-bit JS string byte-per-character with no re-encoding and produces a Latin-1 tape (a UTF-8 reparse happens only if a character reference needs something above U+00FF); UTF-16 strings still go through one simdutf transcode to UTF-8 for now — making the scanner generic over the code unit so 16-bit strings are parsed natively (and produce a UTF-16 tape, which the converter already handles) is what I'm doing next.

@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Changes

The PR adds SIMD and scalar XML structural indexing with resumable chunk support. It routes XML rows through JavaScriptCore conversion, adds source-encoding handling, and expands XML benchmarks, parser tests, build generation, and documentation.

Changes

XML parsing and conversion pipeline

Layer / File(s) Summary
Byte classification and Highway build wiring
scripts/build/xmlByteClass.ts, scripts/build/codegen.ts, scripts/build/*.ts, scripts/verify-baseline-static/*
Generates synchronized XML byte-class tables and wires highway_xml.cpp into compilation and static verification.
Streaming XML structural index
src/jsc/bindings/highway_xml.cpp, src/highway/lib.rs, src/parsers/xml_index.rs, src/parsers/lib.rs
Adds SIMD and scalar structural indexing with tag-state preservation, chunk boundaries, sentinels, lookup, seeking, and equivalence tests.
JSON row ABI and JavaScriptCore conversion
src/ast/e.rs, src/ast/lib.rs, src/js_parser_jsc/*, src/jsc/bindings/JSONRowsToJS.cpp
Defines C-compatible JSON row layouts and converts tape-backed rows into JavaScriptCore values.
XML materialization and runtime conversion
src/bundler/ParseTask.rs, src/runtime/api/*, src/parsers/error.rs, src/parsers/json.rs, test/js/bun/xml/xml.test.ts, test/js/bun/jsonc/jsonc.test.ts
Routes XML through intermediate rows, preserves source encoding, centralizes JavaScript error handling, and adds regression tests.
XML benchmark matrix and build support
src/parsers/benches/*, src/parsers/Cargo.toml, src/parsers/build.rs, bench/xml/*, scripts/bench-json-rust.sh, docs/runtime/xml.mdx
Adds Bun, Rust, and optional native XML parser benchmarks with synthetic and discovered fixtures, feature detection, XML benchmark selection, and performance documentation.

Possibly related PRs

Suggested reviewers: dylan-conway

🚥 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 summarizes the main XML parser changes: SIMD structural indexing, tape rows, and one-call row-to-JavaScript conversion.
Description check ✅ Passed The description includes both required sections and provides detailed implementation scope, benchmark results, and verification steps.

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

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

Actionable comments posted: 11

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
scripts/build/bun.ts (1)

309-315: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Update the PCH opt-out comment to name both files. scripts/build/flags.ts applies -O2 in debug profiles to both highway_json.cpp and highway_xml.cpp.

🤖 Prompt for 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.

In `@scripts/build/bun.ts` around lines 309 - 315, Update the comment above the
cfg.debug PCH opt-out in the build script to explicitly name both
highway_json.cpp and highway_xml.cpp, accurately reflecting that flags.ts
applies -O2 to each file in debug profiles.
🤖 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 `@bench/xml/xml.mjs`:
- Around line 82-85: Define one strict BUN_XML_BENCH_FIXTURES contract across
both runners: in bench/xml/xml.mjs lines 82-85, distinguish unset from an empty
value, reject empty configuration, and resolve the configured directory to an
absolute path before file operations; in src/parsers/benches/xml_parse.rs lines
23-43, apply the same policy and propagate directory enumeration, entry, and
file-read errors instead of silently ignoring failures or unwrapping results.

In `@scripts/bench-json-rust.sh`:
- Line 55: Update the pugixml download and extraction command in the benchmark
setup to retain the optional fallback while explicitly warning when it fails;
include “pugixml” in the warning and advise retrying the benchmark or dependency
download, without silently treating the failure as success.

In `@src/js_parser_jsc/expr_jsc.rs`:
- Around line 28-35: Update to_js_error to replace the wildcard fallback with
explicit matching for every remaining ToJSError variant, including
CannotConvertIdentifierToJS and CannotConvertArgumentTypeToJS, and include each
variant’s name in the thrown error message. Keep the existing mappings for
OutOfMemory, JSError, and JSTerminated, and make the match exhaustive so future
variants require an explicit error message.

In `@src/jsc/bindings/JSONRowsToJS.cpp`:
- Around line 68-71: Add direct compile-time assertions next to the Rust
JsonValue definition in E, asserting its size is 16 bytes and its payload starts
at offset 4, with a brief note documenting the repr(C, u32) layout and matching
C++ assertion. Leave the existing PropertyJSON layout assertions unchanged.
- Around line 110-114: In the JSON row conversion flow, update the
constructEmptyObject call in JSONRowsToJS so it immediately checks for an
exception with RETURN_IF_EXCEPTION(scope, {}) before object is used by the
following loop.

In `@src/parsers/benches/support/xml_c_shim.cpp`:
- Around line 35-55: Update bench_expat_parse and bench_libxml2_parse to return
0 when len exceeds INT_MAX before narrowing it for the native parser calls. In
bench_expat_parse, also check XML_ParserCreate(NULL) immediately and return 0
before configuring or parsing with a null parser.

In `@src/parsers/benches/xml_parse.rs`:
- Around line 107-115: Harden the BUN_XML_BENCH_LOOP parsing flow so an unset
variable keeps the existing skip behavior, while any set value must contain
exactly three non-empty fields in impl:fixture:iterations format. Validate known
implementations and fixtures, reject invalid or zero iterations, and guard
contents.len() * n against overflow. Replace unwrap/expect/panic paths with one
recoverable contextual error naming the variable, rejected value, and accepted
format.

In `@src/parsers/xml_index.rs`:
- Around line 218-246: Update the test helpers and agreement tests around
build_both, simd_and_scalar_indexers_agree, and
streaming_and_scalar_indexers_agree_on_large_documents to detect when the SIMD
kernel is unavailable via bun_core::env::IS_NATIVE and skip with an explicit
reason instead of comparing scalar output against itself. Ensure all relevant
build_both call sites use this guard so the tests only run when SIMD execution
is actually available.
- Around line 51-107: Document and enforce the sequential-access invariant in at
and fill_to: add a debug assertion rejecting logical positions older than the
retained lookbehind/base before any wrapping subtraction, and ensure seek
handles the same backward-access violation rather than restarting from the
window end. In fill_to, clamp keep_from to win.len() before copy_within/truncate
so an initial forward jump with an empty window cannot panic.
- Around line 35-48: Add a recoverable size validation in Scanner::build_index
before constructing StructuralIndex, rejecting inputs larger than u32::MAX
rather than relying on with_producer’s debug_assert!. Apply the same validation
after UTF-16 or Latin-1 transcoding, and return the parser’s established error
type so finish, scalar_chunk, and the Highway kernel never receive oversized
documents.

In `@test/js/bun/xml/xml.test.ts`:
- Around line 376-389: Add a numeric-key ordering test to
test/js/bun/jsonc/jsonc.test.ts using Bun.JSONC.parse on {"a":1,"0":2,"b":3},
and assert Object.keys(result) equals ["0", "a", "b"]. Keep this coverage in the
JSONC tests rather than extending the XML test.

---

Outside diff comments:
In `@scripts/build/bun.ts`:
- Around line 309-315: Update the comment above the cfg.debug PCH opt-out in the
build script to explicitly name both highway_json.cpp and highway_xml.cpp,
accurately reflecting that flags.ts applies -O2 to each file in debug profiles.
🪄 Autofix

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: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: da6d6749-5b76-4b68-a482-cd58f88a434a

📥 Commits

Reviewing files that changed from the base of the PR and between 45ee955 and 704d974.

⛔ Files ignored due to path filters (2)
  • Cargo.lock is excluded by !**/*.lock
  • bench/xml/bun.lock is excluded by !**/*.lock
📒 Files selected for processing (31)
  • bench/xml/package.json
  • bench/xml/xml.mjs
  • scripts/bench-json-rust.sh
  • scripts/build/bun.ts
  • scripts/build/codegen.ts
  • scripts/build/flags.ts
  • scripts/build/unified.ts
  • scripts/build/xmlByteClass.ts
  • scripts/verify-baseline-static/allowlist-aarch64.txt
  • scripts/verify-baseline-static/allowlist-x64-windows.txt
  • scripts/verify-baseline-static/allowlist-x64.txt
  • src/ast/e.rs
  • src/ast/lib.rs
  • src/bundler/ParseTask.rs
  • src/highway/lib.rs
  • src/js_parser_jsc/expr_jsc.rs
  • src/js_parser_jsc/lib.rs
  • src/jsc/bindings/JSONRowsToJS.cpp
  • src/jsc/bindings/highway_xml.cpp
  • src/parsers/Cargo.toml
  • src/parsers/benches/support/xml_c_shim.cpp
  • src/parsers/benches/xml_parse.rs
  • src/parsers/build.rs
  • src/parsers/json.rs
  • src/parsers/lib.rs
  • src/parsers/xml.rs
  • src/parsers/xml_index.rs
  • src/runtime/api.rs
  • src/runtime/api/JSONCObject.rs
  • src/runtime/api/XMLObject.rs
  • test/js/bun/xml/xml.test.ts

Comment thread bench/xml/xml.mjs
Comment on lines +82 to +85
if (process.env.BUN_XML_BENCH_FIXTURES) {
const dir = process.env.BUN_XML_BENCH_FIXTURES;
for (const f of readdirSync(dir).sort()) {
if (/\.(xml|svg)$/.test(f)) docs.push([f, readFileSync(join(dir, f), "utf8")]);

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.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Define one strict BUN_XML_BENCH_FIXTURES contract.

The JavaScript runner treats an empty value as unset and accepts relative paths. The Rust runner treats an empty value as an unreadable directory, ignores that error, and can panic while reading a selected fixture. The same configuration can therefore benchmark different corpora or silently fall back to synthetic inputs.

  • bench/xml/xml.mjs#L82-L85: Distinguish unset from empty, reject an empty configured value, and resolve the configured directory to an absolute path before file operations.
  • src/parsers/benches/xml_parse.rs#L23-L43: Apply the same empty and absolute-path policy. Propagate directory enumeration, entry, and file-read errors instead of using if let Ok, filter_map(|e| e.ok()), and unwrap().

As per coding guidelines, use absolute file paths, distinguish empty and unset input, and propagate I/O failures explicitly.

📍 Affects 2 files
  • bench/xml/xml.mjs#L82-L85 (this comment)
  • src/parsers/benches/xml_parse.rs#L23-L43
🤖 Prompt for 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.

In `@bench/xml/xml.mjs` around lines 82 - 85, Define one strict
BUN_XML_BENCH_FIXTURES contract across both runners: in bench/xml/xml.mjs lines
82-85, distinguish unset from an empty value, reject empty configuration, and
resolve the configured directory to an absolute path before file operations; in
src/parsers/benches/xml_parse.rs lines 23-43, apply the same policy and
propagate directory enumeration, entry, and file-read errors instead of silently
ignoring failures or unwrapping results.

Source: Coding guidelines

XML_C_LIBS=()
PUGI_VERSION=1.14
if [ ! -f "$SUP/pugixml-$PUGI_VERSION/src/pugixml.cpp" ]; then
curl -fsSL "https://github.com/zeux/pugixml/releases/download/v$PUGI_VERSION/pugixml-$PUGI_VERSION.tar.gz" | tar -xz -C "$SUP" || true

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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Report the skipped pugixml comparison.

|| true converts a failed download or extraction into a successful script result. The benchmark then runs without identifying that it omitted pugixml. Keep the optional fallback, but print a warning that names the failed dependency and gives a retry action.

As per coding guidelines, never swallow failures or signal success after failure.

🤖 Prompt for 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.

In `@scripts/bench-json-rust.sh` at line 55, Update the pugixml download and
extraction command in the benchmark setup to retain the optional fallback while
explicitly warning when it fails; include “pugixml” in the warning and advise
retrying the benchmark or dependency download, without silently treating the
failure as success.

Source: Coding guidelines

Comment on lines +28 to +35
pub fn to_js_error(e: ToJSError, global: &JSGlobalObject) -> JsError {
match e {
ToJSError::OutOfMemory => JsError::OutOfMemory,
ToJSError::JSError => JsError::Thrown,
ToJSError::JSTerminated => JsError::Terminated,
_ => global.throw(format_args!("Cannot convert value to JS")),
}
}

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.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Name the rejected variant in the fallback error message.

to_js_error is public and re-exported from the crate root. The _ arm therefore reaches callers that convert arbitrary Expr values, not only JSON and XML rows. Those callers receive "Cannot convert value to JS", which does not say whether the failure was CannotConvertIdentifierToJS or CannotConvertArgumentTypeToJS.

Include the variant in the message so the thrown error identifies the rejected value kind.

🐛 Proposed fix
 pub fn to_js_error(e: ToJSError, global: &JSGlobalObject) -> JsError {
     match e {
         ToJSError::OutOfMemory => JsError::OutOfMemory,
         ToJSError::JSError => JsError::Thrown,
         ToJSError::JSTerminated => JsError::Terminated,
-        _ => global.throw(format_args!("Cannot convert value to JS")),
+        ToJSError::CannotConvertIdentifierToJS => {
+            global.throw(format_args!("Cannot convert an identifier to a JS value"))
+        }
+        ToJSError::CannotConvertArgumentTypeToJS => {
+            global.throw(format_args!("Cannot convert this expression type to a JS value"))
+        }
     }
 }

An exhaustive match also makes a new ToJSError variant a compile error instead of a silent generic message.

Based on the coding guideline "Error messages must identify the failed resource, violated constraint, rejected value, cause, and concrete remedy while preserving rich underlying errors."

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
pub fn to_js_error(e: ToJSError, global: &JSGlobalObject) -> JsError {
match e {
ToJSError::OutOfMemory => JsError::OutOfMemory,
ToJSError::JSError => JsError::Thrown,
ToJSError::JSTerminated => JsError::Terminated,
_ => global.throw(format_args!("Cannot convert value to JS")),
}
}
pub fn to_js_error(e: ToJSError, global: &JSGlobalObject) -> JsError {
match e {
ToJSError::OutOfMemory => JsError::OutOfMemory,
ToJSError::JSError => JsError::Thrown,
ToJSError::JSTerminated => JsError::Terminated,
ToJSError::CannotConvertIdentifierToJS => {
global.throw(format_args!("Cannot convert an identifier to a JS value"))
}
ToJSError::CannotConvertArgumentTypeToJS => {
global.throw(format_args!("Cannot convert this expression type to a JS value"))
}
}
}
🤖 Prompt for 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.

In `@src/js_parser_jsc/expr_jsc.rs` around lines 28 - 35, Update to_js_error to
replace the wildcard fallback with explicit matching for every remaining
ToJSError variant, including CannotConvertIdentifierToJS and
CannotConvertArgumentTypeToJS, and include each variant’s name in the thrown
error message. Keep the existing mappings for OutOfMemory, JSError, and
JSTerminated, and make the match exhaustive so future variants require an
explicit error message.

Source: Coding guidelines

Comment on lines +68 to +71
// Kept in step with the `offset_of!` assertions next to the Rust definitions.
static_assert(offsetof(RowValue, tag) == 0 && offsetof(RowValue, string) == 4);
static_assert(offsetof(RowProperty, key) == 0 && offsetof(RowProperty, keyLoc) == 12 && offsetof(RowProperty, value) == 16);
static_assert(offsetof(RowSpan, tape) == 0 && offsetof(RowSpan, first) == 8 && offsetof(RowSpan, count) == 12);

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.

📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Pin the JsonValue payload offset with a direct Rust assertion.

The C++ side asserts offsetof(RowValue, string) == 4 and sizeof(RowValue) == 16. The Rust side asserts neither for JsonValue. It only pins the size transitively, through size_of::<PropertyJSON>() == 32 and offset_of!(PropertyJSON, value) == 16.

If JsonValue gains a variant with 8-byte alignment, the Rust payload moves to offset 8 and the size becomes 24. PropertyJSON then becomes 40 bytes, so the existing assertion does fire. The failure message points at PropertyJSON, not at the enum that changed. Add the direct assertions next to JsonValue in src/ast/e.rs so the compile error names the real cause.

const _: () = assert!(core::mem::size_of::<E::JsonValue>() == 16);
// `#[repr(C, u32)]`: the payload follows the 4-byte discriminant.
// Read from C++ (JSONRowsToJS.cpp), which asserts the same offset.
🤖 Prompt for 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.

In `@src/jsc/bindings/JSONRowsToJS.cpp` around lines 68 - 71, Add direct
compile-time assertions next to the Rust JsonValue definition in E, asserting
its size is 16 bytes and its payload starts at offset 4, with a brief note
documenting the repr(C, u32) layout and matching C++ assertion. Leave the
existing PropertyJSON layout assertions unchanged.

Comment thread src/jsc/bindings/JSONRowsToJS.cpp
Comment on lines +107 to +115
let Ok(spec) = std::env::var("BUN_XML_BENCH_LOOP") else {
return;
};
let mut parts = spec.split(':');
let (imp, fx, n) = (
parts.next().unwrap(),
parts.next().unwrap(),
parts.next().unwrap().parse::<usize>().unwrap(),
);

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.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Reject malformed loop specifications without panic.

Empty fields, a zero iteration count, invalid integers, unknown fixtures, and unknown implementations reach unwrap, expect, or panic!. A large valid usize can also overflow contents.len() * n on Line 157.

Parse exactly three non-empty fields. Reject zero and overflowing iteration counts. Return one contextual error that identifies BUN_XML_BENCH_LOOP, the rejected value, and the accepted impl:fixture:iterations format.

As per coding guidelines, distinguish empty, zero, and unset values, validate numeric boundaries, and expose user-reachable failures as recoverable errors.

Also applies to: 151-157

🤖 Prompt for 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.

In `@src/parsers/benches/xml_parse.rs` around lines 107 - 115, Harden the
BUN_XML_BENCH_LOOP parsing flow so an unset variable keeps the existing skip
behavior, while any set value must contain exactly three non-empty fields in
impl:fixture:iterations format. Validate known implementations and fixtures,
reject invalid or zero iterations, and guard contents.len() * n against
overflow. Replace unwrap/expect/panic paths with one recoverable contextual
error naming the variable, rejected value, and accepted format.

Source: Coding guidelines

Comment thread src/parsers/xml_index.rs Outdated
Comment on lines +35 to +48
fn with_producer(contents: &'c [u8], use_scalar: bool) -> Self {
debug_assert!(contents.len() <= i32::MAX as usize);
let win_cap = contents.len().min(REFILL_INPUT) + 64 + SENTINELS + LOOKBEHIND;
StructuralIndex {
contents,
win: Vec::with_capacity(win_cap),
base: 0,
done: false,
src_off: 0,
kernel_state: [0; 3],
use_scalar,
s_in_tag: false,
}
}

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.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Find the callers of StructuralIndex and any document-size gate on the XML parse path.
set -euo pipefail

rg -n -C 6 'StructuralIndex::new|StructuralIndex::with_producer' --type=rust

echo '--- XML parse entry points and any length gates ---'
fd -t f 'xml.rs|XMLObject.rs' src --exec rg -n -C 4 'i32::MAX|u32::MAX|MAX_LEN|too large|len\(\) *[<>]' {}

Repository: oven-sh/bun

Length of output: 149


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- StructuralIndex definitions and uses ---'
rg -n -C 8 'StructuralIndex|with_producer|fn new' src/parsers --type rust || true

printf '%s\n' '--- XML-related source files ---'
fd -t f -i 'xml' src | head -80

printf '%s\n' '--- Relevant parser entry points and size checks ---'
rg -n -C 5 'parse|from_utf8|contents|i32::MAX|u32::MAX|MAX_LEN|too large|len\(\)' src/parsers --type rust | head -400

Repository: oven-sh/bun

Length of output: 50367


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- XML index implementation ---'
cat -n src/parsers/xml_index.rs | sed -n '1,170p'

printf '%s\n' '--- XML parse construction and scanner state ---'
cat -n src/parsers/xml.rs | sed -n '70,105p;540,610p;2635,2705p'

printf '%s\n' '--- All XML entry-point callers ---'
rg -n -C 5 'XML::parse|parse_document\(|build_index\(' src --type rust

printf '%s\n' '--- XML size-limit and error definitions ---'
rg -n -C 4 'DocumentTooLarge|AllocError|SyntaxError|pub enum Error|type Result' src/parsers/xml.rs src/parsers src --type rust | head -300

Repository: oven-sh/bun

Length of output: 48166


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- Highway XML wrapper and native signature ---'
rg -n -C 12 'xml_structural_index_chunk|base_offset|uint32_t|int32_t|uint32' src bun* scripts --glob '*.{rs,cpp,cc,h,hpp,zig,ts}' | head -300

printf '%s\n' '--- Scanner source-buffer transitions ---'
rg -n -C 10 'self\.src\s*=|src:|transcod|init_document|validate_utf8|UTF16|Vec<u8>|document_len' src/parsers/xml.rs | head -500

printf '%s\n' '--- XML API caller input construction and limits ---'
cat -n src/runtime/api/XMLObject.rs | sed -n '1,130p'
rg -n -C 8 'XMLObject|XML\.parse|xml_parse|parse_xml' src/runtime src/bundler --type rust | head -400

printf '%s\n' '--- Focused static invariant probe ---'
python3 - <<'PY'
from pathlib import Path
idx = Path("src/parsers/xml_index.rs").read_text()
xml = Path("src/parsers/xml.rs").read_text()
checks = {
    "release_retained_size_gate": "if contents.len()" in idx or "assert!(contents.len()" in idx,
    "debug_only_size_gate": "debug_assert!(contents.len() <= i32::MAX as usize)" in idx,
    "u32_position_storage": "win: Vec<u32>" in idx,
    "lossy_finish_cast": "self.contents.len() as u32" in idx,
    "lossy_scalar_cast": "(base + i) as u32" in idx,
    "xml_parse_calls_index_without_gate": "StructuralIndex::new(self.src)" in xml,
}
for name, value in checks.items():
    print(f"{name}={value}")
PY

Repository: oven-sh/bun

Length of output: 45163


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- Highway XML files and exact call definitions ---'
git ls-files | rg '(^|/)(highway|bun_highway)|highway_xml|xml.*highway' | head -100
rg -n -C 20 'xml_structural_index_chunk|base_offset|uint32_t|int32_t' src/jsc/bindings/highway_xml.cpp src --glob '*.{rs,cpp,cc,h,hpp,zig}' | rg -C 8 'xml_structural|base_offset|Highway|uint32_t|int32_t' | head -500

printf '%s\n' '--- Generated/in-tree binding declarations ---'
rg -n -C 10 'xml_structural_index_chunk' . --glob '!node_modules/**' --glob '!build/**' --glob '!dist/**' --glob '!target/**' | head -200

printf '%s\n' '--- Exact size-gate probe (excluding debug assertions) ---'
python3 - <<'PY'
from pathlib import Path
idx = Path("src/parsers/xml_index.rs").read_text()
xml = Path("src/parsers/xml.rs").read_text()
lines = idx.splitlines()
for n, line in enumerate(lines, 1):
    if "contents.len()" in line or " as u32" in line or "StructuralIndex::new" in line:
        print(f"xml_index.rs:{n}:{line}")
print("runtime_if_before_constructor=" + str(any(
    line.lstrip().startswith(("if ", "assert!", "return Err")) and "contents.len()" in line
    for line in lines[:48]
)))
print("xml_parse_result_has_size_error=" + str("DocumentTooLarge" in xml or "InputTooLarge" in xml))
PY

Repository: oven-sh/bun

Length of output: 39060


Reject oversized XML documents before building StructuralIndex.

Scanner::build_index has no size gate. finish, scalar_chunk, and the Highway kernel cast positions to u32; inputs larger than u32::MAX therefore produce wrapped offsets. Replace the debug-only check with recoverable parser error handling, including after UTF-16 or Latin-1 transcoding.

🤖 Prompt for 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.

In `@src/parsers/xml_index.rs` around lines 35 - 48, Add a recoverable size
validation in Scanner::build_index before constructing StructuralIndex,
rejecting inputs larger than u32::MAX rather than relying on with_producer’s
debug_assert!. Apply the same validation after UTF-16 or Latin-1 transcoding,
and return the parser’s established error type so finish, scalar_chunk, and the
Highway kernel never receive oversized documents.

Source: Coding guidelines

Comment thread src/parsers/xml_index.rs
Comment thread src/parsers/xml_index.rs
Comment on lines +218 to +246
#[test]
fn simd_and_scalar_indexers_agree() {
let mut state = 0x9E3779B97F4A7C15u64;
let mut rng = move || {
state ^= state << 13;
state ^= state >> 7;
state ^= state << 17;
state
};
let alphabet: &[u8] = b"<>&=\"' \t\n\rabc/?!-[]x01\x01\x1f\x0b\x80\xc3\xa9\xef\xbf\xbe";
for _ in 0..20_000 {
let len = (rng() % 300) as usize;
let mut buf = Vec::with_capacity(len);
for _ in 0..len {
buf.push(alphabet[(rng() as usize) % alphabet.len()]);
}
let (si, ci) = build_both(&buf);
assert_eq!(si, ci, "index mismatch for {:?}", bstr::BStr::new(&buf));
}
for pad in 40..=200usize {
let doc = format!(
"<r a=\"{}\">{}<b\n\tc='d'/>&e;\r\n</r>",
"v ".repeat(pad / 3),
"t\u{e9}\t".repeat(pad),
);
let (si, ci) = build_both(doc.as_bytes());
assert_eq!(si, ci, "mismatch for {doc:?}");
}
}

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.

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Make the SIMD/scalar agreement test prove that the SIMD path ran.

build_both builds the first index with StructuralIndex::new, which selects the scalar producer when bun_core::env::IS_NATIVE is false. On those targets this test compares the scalar indexer against itself and cannot fail for the intended reason. The same applies to streaming_and_scalar_indexers_agree_on_large_documents at Lines 248-270 and to the build_both call at Line 214.

Skip the comparison with a stated reason when the SIMD kernel is unavailable, so the test never passes vacuously.

♻️ Proposed change to `build_both`
     fn build_both(contents: &[u8]) -> (Vec<u32>, Vec<u32>) {
+        // Off native targets `StructuralIndex::new` also picks the scalar producer, so the
+        // comparison would be vacuous. Callers skip instead of asserting scalar == scalar.
+        assert!(
+            bun_core::env::IS_NATIVE,
+            "build_both requires the Highway kernel; guard callers with IS_NATIVE"
+        );
         let mut simd = StructuralIndex::new(contents);
🤖 Prompt for 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.

In `@src/parsers/xml_index.rs` around lines 218 - 246, Update the test helpers and
agreement tests around build_both, simd_and_scalar_indexers_agree, and
streaming_and_scalar_indexers_agree_on_large_documents to detect when the SIMD
kernel is unavailable via bun_core::env::IS_NATIVE and skip with an explicit
reason instead of comparing scalar output against itself. Ensure all relevant
build_both call sites use this guard so the tests only run when SIMD execution
is actually available.

Comment thread test/js/bun/xml/xml.test.ts
Comment thread src/parsers/xml.rs Outdated
Jarred-Sumner and others added 5 commits August 7, 2026 20:48
… the tape's encoding, arrays via MarkedArgumentBuffer + constructArray, no hand-rolled decoder; XML parses Latin-1 strings as is (Latin-1 tape) with a UTF-8 reparse only when a reference needs > U+00FF

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

Actionable comments posted: 2

🤖 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 `@src/ast/e.rs`:
- Line 995: The JsonTape encoding path must not expose StrEncoding::Utf16 unless
its storage invariant is enforced. Keep UTF-16 disabled in src/ast/e.rs at
JsonTape.encoding, and in src/jsc/bindings/JSONRowsToJS.cpp lines 195-198 make
no direct Utf16 cast unless the byte range is guaranteed aligned and has an even
length; otherwise preserve UTF-8-only handling.

In `@test/js/bun/jsonc/jsonc.test.ts`:
- Around line 395-403: Extend the test case around JSONC.parse to exercise WTF-8
fallback by adding a JSON string containing a lone \uD800 or \uDC00 escape and
comparing the parsed result with JSON.parse. Replace the repeated long key and
value construction with Buffer.alloc(count, fill).toString(), while preserving
the existing key-order, prototype, and ownership assertions.
🪄 Autofix

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: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 84dedcff-c4b8-451f-938b-b9707c6958e8

📥 Commits

Reviewing files that changed from the base of the PR and between 704d974 and 20dc228.

📒 Files selected for processing (8)
  • src/ast/e.rs
  • src/js_parser_jsc/expr_jsc.rs
  • src/jsc/bindings/JSONRowsToJS.cpp
  • src/parsers/error.rs
  • src/parsers/xml.rs
  • src/runtime/api.rs
  • src/runtime/api/XMLObject.rs
  • test/js/bun/jsonc/jsonc.test.ts

Comment thread src/ast/e.rs
item_locs: Vec<crate::Loc, TapeAlloc>,
str_chunks: Vec<Vec<u8, TapeAlloc>, TapeAlloc>,
str_used: usize,
pub encoding: StrEncoding,

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.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

ast-grep outline src/ast/e.rs --match JsonTape --view expanded

rg -n -C 4 \
  'str_chunks|StrEncoding::Utf16|encoding\s*=\s*.*Utf16|utf16\(const RowStr' \
  src test

Repository: oven-sh/bun

Length of output: 10272


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- JsonTape and Str definitions ---'
sed -n '930,1145p' src/ast/e.rs
rg -n -C 8 \
  'enum StrEncoding|struct Str\b|type Str\b|pub encoding: StrEncoding|encoding\s*[:=].*StrEncoding|StrEncoding::Utf16|RowEncoding::Utf16' \
  src

printf '%s\n' '--- JSON row construction and tape consumers ---'
rg -n -C 10 \
  'JSONRowsToJS|RowStr|str_chunks|alloc_str_join|alloc_str\(|\.encoding|encoding\(\)' \
  src/jsc src/ast src/runtime test

Repository: oven-sh/bun

Length of output: 50368


🏁 Script executed:

#!/bin/bash
set -euo pipefail
sed -n '930,1145p' src/ast/e.rs
rg -n -C 8 \
  'enum StrEncoding|struct Str\b|type Str\b|pub encoding: StrEncoding|encoding\s*[:=].*StrEncoding|StrEncoding::Utf16|RowEncoding::Utf16' \
  src
rg -n -C 10 \
  'JSONRowsToJS|RowStr|str_chunks|alloc_str_join|alloc_str\(|\.encoding|encoding\(\)' \
  src/jsc src/ast src/runtime test

Repository: oven-sh/bun

Length of output: 50368


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- src/ast/e.rs definitions and string allocation ---'
rg -n 'enum StrEncoding|struct Str\b|pub struct JsonTape|pub encoding: StrEncoding|StrEncoding::Utf16|encoding\s*=' src/ast/e.rs
sed -n '1,180p' src/ast/e.rs
sed -n '980,1120p' src/ast/e.rs

printf '%s\n' '--- exact JsonTape encoding mutations and consumers ---'
rg -n -C 5 'JsonTape|\.encoding\s*=|encoding: StrEncoding::Utf16|StrEncoding::Utf16' src --glob '*.rs' --glob '*.cpp' --glob '*.h' --glob '*.hpp' |
  rg 'ast/e.rs|JsonTape|encoding|Utf16|JSONRows|RowStr|row'

printf '%s\n' '--- JSON row bridge ---'
sed -n '1,240p' src/jsc/bindings/JSONRowsToJS.cpp
rg -n -C 8 'JSONRowsToJS|RowStr|RowEncoding|str_chunks|raw_rows|alloc_str_join|alloc_str\(' src/ast src/jsc --glob '*.rs' --glob '*.cpp' --glob '*.h' --glob '*.hpp'

Repository: oven-sh/bun

Length of output: 50369


🏁 Script executed:

#!/bin/bash
set -euo pipefail
rg -n 'enum StrEncoding|struct Str\b|pub struct JsonTape|pub encoding: StrEncoding|StrEncoding::Utf16|encoding\s*=' src/ast/e.rs
sed -n '1,180p' src/ast/e.rs
sed -n '980,1120p' src/ast/e.rs
rg -n -C 5 'JsonTape|\.encoding\s*=|encoding: StrEncoding::Utf16|StrEncoding::Utf16' src --glob '*.rs' --glob '*.cpp' --glob '*.h' --glob '*.hpp' |
  rg 'ast/e.rs|JsonTape|encoding|Utf16|JSONRows|RowStr|row'
sed -n '1,240p' src/jsc/bindings/JSONRowsToJS.cpp
rg -n -C 8 'JSONRowsToJS|RowStr|RowEncoding|str_chunks|raw_rows|alloc_str_join|alloc_str\(' src/ast src/jsc --glob '*.rs' --glob '*.cpp' --glob '*.h' --glob '*.hpp'

Repository: oven-sh/bun

Length of output: 50369


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- XML input encoding and tape output ---'
sed -n '1,125p' src/parsers/xml.rs
sed -n '1040,1110p' src/parsers/xml.rs
sed -n '2090,2265p' src/parsers/xml.rs
sed -n '2700,2760p' src/parsers/xml.rs

printf '%s\n' '--- all JsonTape encoding writes, restricted to parser/bridge crates ---'
rg -n -C 8 \
  'encoding\s*=|encoding\s*:' \
  src/parsers/json.rs src/parsers/json_stage2.rs src/parsers/xml.rs src/js_parser_jsc/expr_jsc.rs

printf '%s\n' '--- all alloc_str callers ---'
rg -n -C 6 'alloc_str_join|alloc_str\(' src/parsers src/js_parser_jsc src/ast --glob '*.rs'

Repository: oven-sh/bun

Length of output: 33302


🏁 Script executed:

#!/bin/bash
set -euo pipefail
sed -n '1,125p' src/parsers/xml.rs
sed -n '1040,1110p' src/parsers/xml.rs
sed -n '2090,2265p' src/parsers/xml.rs
sed -n '2700,2760p' src/parsers/xml.rs
rg -n -C 8 'encoding\s*=|encoding\s*:' \
  src/parsers/json.rs src/parsers/json_stage2.rs src/parsers/xml.rs src/js_parser_jsc/expr_jsc.rs
rg -n -C 6 'alloc_str_join|alloc_str\(' \
  src/parsers src/js_parser_jsc src/ast --glob '*.rs'

Repository: oven-sh/bun

Length of output: 33156


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path
import re

for pattern in (
    r'\bStrEncoding::Utf16\b',
    r'\.encoding\s*=\s*[^;]+',
    r'\bencoding\s*:\s*StrEncoding::Utf16\b',
):
    print(f'--- {pattern} ---')
    rx = re.compile(pattern)
    for path in Path('src').rglob('*'):
        if path.suffix not in {'.rs', '.cpp', '.h', '.hpp'}:
            continue
        try:
            text = path.read_text()
        except UnicodeDecodeError:
            continue
        for number, line in enumerate(text.splitlines(), 1):
            if rx.search(line):
                print(f'{path}:{number}:{line.strip()}')
PY

printf '%s\n' '--- XML UTF-16 conversion ---'
sed -n '880,1050p' src/parsers/xml.rs

printf '%s\n' '--- JSON/XML parser string shapes ---'
sed -n '470,545p' src/parsers/json_stage2.rs
sed -n '2460,2530p' src/parsers/xml.rs

Repository: oven-sh/bun

Length of output: 15412


🏁 Script executed:

#!/bin/bash
set -euo pipefail
python3 - <<'PY'
from pathlib import Path
import re

for pattern in (
    r'\bStrEncoding::Utf16\b',
    r'\.encoding\s*=\s*[^;]+',
    r'\bencoding\s*:\s*StrEncoding::Utf16\b',
):
    print(f'--- {pattern} ---')
    rx = re.compile(pattern)
    for path in Path('src').rglob('*'):
        if path.suffix not in {'.rs', '.cpp', '.h', '.hpp'}:
            continue
        try:
            text = path.read_text()
        except UnicodeDecodeError:
            continue
        for number, line in enumerate(text.splitlines(), 1):
            if rx.search(line):
                print(f'{path}:{number}:{line.strip()}')
PY
sed -n '880,1050p' src/parsers/xml.rs
sed -n '470,545p' src/parsers/json_stage2.rs
sed -n '2460,2530p' src/parsers/xml.rs

Repository: oven-sh/bun

Length of output: 15344


Do not expose StrEncoding::Utf16 without a storage invariant.

No current parser sets JsonTape.encoding to Utf16; UTF-16 XML input is transcoded to UTF-8. However, alloc_str_join stores bytes at arbitrary offsets in Vec<u8>. If Utf16 is enabled, JSONRowsToJS.cpp can read an unaligned pointer and silently truncate odd lengths with len / 2. Keep this path disabled, or guarantee alignment and even lengths before the cast.

📍 Affects 2 files
  • src/ast/e.rs#L995-L995 (this comment)
  • src/jsc/bindings/JSONRowsToJS.cpp#L195-L198
🤖 Prompt for 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.

In `@src/ast/e.rs` at line 995, The JsonTape encoding path must not expose
StrEncoding::Utf16 unless its storage invariant is enforced. Keep UTF-16
disabled in src/ast/e.rs at JsonTape.encoding, and in
src/jsc/bindings/JSONRowsToJS.cpp lines 195-198 make no direct Utf16 cast unless
the byte range is guaranteed aligned and has an even length; otherwise preserve
UTF-8-only handling.

Comment on lines +395 to +403
test("Bun.JSONC.parse builds objects the way JSON.parse does: index keys first, __proto__ own, keys of every kind", () => {
const doc = `{"b":1,"0":2,"a":3,"__proto__":{"x":1},"ünï":4,"${"k".repeat(40)}":5,"1":6,"":7,"s":"","t":"x","u":"${"y".repeat(40)}","v":"ünï"}`;
const parsed = Bun.JSONC.parse(doc) as any;
const reference = JSON.parse(doc);
expect(parsed).toEqual(reference);
expect(Object.keys(parsed)).toEqual(Object.keys(reference));
expect(Object.getPrototypeOf(parsed)).toBe(Object.prototype);
expect(Object.hasOwn(parsed, "__proto__")).toBe(true);
expect(Bun.JSONC.parse(`[[],[1,"a",{}],[[["deep"]]]]`)).toEqual([[], [1, "a", {}], [[["deep"]]]]);

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.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Test the WTF-8 fallback path.

The current Unicode values are valid UTF-8. They do not call the new lone-surrogate conversion path. Add a \\uD800 or \\uDC00 JSON escape and compare it with JSON.parse. Use Buffer.alloc(count, fill).toString() for the long key and value.

Proposed test update
 test("Bun.JSONC.parse builds objects the way JSON.parse does: index keys first, __proto__ own, keys of every kind", () => {
-  const doc = `{"b":1,"0":2,"a":3,"__proto__":{"x":1},"ünï":4,"${"k".repeat(40)}":5,"1":6,"":7,"s":"","t":"x","u":"${"y".repeat(40)}","v":"ünï"}`;
+  const longKey = Buffer.alloc(40, "k").toString();
+  const longValue = Buffer.alloc(40, "y").toString();
+  const doc = `{"b":1,"0":2,"a":3,"__proto__":{"x":1},"ünï":4,"${longKey}":5,"1":6,"":7,"s":"","t":"x","u":"${longValue}","v":"ünï"}`;
   const parsed = Bun.JSONC.parse(doc) as any;
   const reference = JSON.parse(doc);
   expect(parsed).toEqual(reference);
   expect(Object.keys(parsed)).toEqual(Object.keys(reference));
   expect(Object.getPrototypeOf(parsed)).toBe(Object.prototype);
   expect(Object.hasOwn(parsed, "__proto__")).toBe(true);
+  expect(Bun.JSONC.parse(`{"lone":"\\uD800"}`)).toEqual(JSON.parse(`{"lone":"\\uD800"}`));
   expect(Bun.JSONC.parse(`[[],[1,"a",{}],[[["deep"]]]]`)).toEqual([[], [1, "a", {}], [[["deep"]]]]);
 });

As per coding guidelines, “Tests must cover the complete relevant variant matrix” and repetitive strings must use Buffer.alloc(count, fill).toString().

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
test("Bun.JSONC.parse builds objects the way JSON.parse does: index keys first, __proto__ own, keys of every kind", () => {
const doc = `{"b":1,"0":2,"a":3,"__proto__":{"x":1},"ünï":4,"${"k".repeat(40)}":5,"1":6,"":7,"s":"","t":"x","u":"${"y".repeat(40)}","v":"ünï"}`;
const parsed = Bun.JSONC.parse(doc) as any;
const reference = JSON.parse(doc);
expect(parsed).toEqual(reference);
expect(Object.keys(parsed)).toEqual(Object.keys(reference));
expect(Object.getPrototypeOf(parsed)).toBe(Object.prototype);
expect(Object.hasOwn(parsed, "__proto__")).toBe(true);
expect(Bun.JSONC.parse(`[[],[1,"a",{}],[[["deep"]]]]`)).toEqual([[], [1, "a", {}], [[["deep"]]]]);
test("Bun.JSONC.parse builds objects the way JSON.parse does: index keys first, __proto__ own, keys of every kind", () => {
const longKey = Buffer.alloc(40, "k").toString();
const longValue = Buffer.alloc(40, "y").toString();
const doc = `{"b":1,"0":2,"a":3,"__proto__":{"x":1},"ünï":4,"${longKey}":5,"1":6,"":7,"s":"","t":"x","u":"${longValue}","v":"ünï"}`;
const parsed = Bun.JSONC.parse(doc) as any;
const reference = JSON.parse(doc);
expect(parsed).toEqual(reference);
expect(Object.keys(parsed)).toEqual(Object.keys(reference));
expect(Object.getPrototypeOf(parsed)).toBe(Object.prototype);
expect(Object.hasOwn(parsed, "__proto__")).toBe(true);
expect(Bun.JSONC.parse(`{"lone":"\uD800"}`)).toEqual(JSON.parse(`{"lone":"\uD800"}`));
expect(Bun.JSONC.parse(`[[],[1,"a",{}],[[["deep"]]]]`)).toEqual([[], [1, "a", {}], [[["deep"]]]]);
});
🤖 Prompt for 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.

In `@test/js/bun/jsonc/jsonc.test.ts` around lines 395 - 403, Extend the test case
around JSONC.parse to exercise WTF-8 fallback by adding a JSON string containing
a lone \uD800 or \uDC00 escape and comparing the parsed result with JSON.parse.
Replace the repeated long key and value construction with Buffer.alloc(count,
fill).toString(), while preserving the existing key-order, prototype, and
ownership assertions.

Source: Coding guidelines

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

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 `@docs/runtime/xml.mdx`:
- Line 51: Update the parser description near the well-formedness claims to
qualify UTF-8 validation as applying to byte input only. Also document that
JavaScript string input bypasses UTF-8 validation and preserves its Latin-1 or
UTF-16 representation.
🪄 Autofix

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: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 9dbca099-861f-47b2-9c13-7e3f25b622f9

📥 Commits

Reviewing files that changed from the base of the PR and between 20dc228 and bbb13a8.

📒 Files selected for processing (1)
  • docs/runtime/xml.mdx

Comment thread docs/runtime/xml.mdx

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

Additional findings (outside current diff — PR may have been updated during review):

  • 🔴 src/parsers/benches/support/xml_old.rs:1-3src/parsers/benches/support/xml_old.rs (3096 lines) was accidentally re-added in 19dc05c after you already deleted it in 072d131 ("bench: drop the accidentally added copy of the old parser") — nothing references it and it isn't compiled. git rm src/parsers/benches/support/xml_old.rs again.

    Extended reasoning...

    What the issue is

    src/parsers/benches/support/xml_old.rs is a 3096-line verbatim copy of the pre-rewrite xml.rs parser sitting in the bench support directory. It is completely unreferenced: rg xml_old returns zero hits anywhere in the repository, no mod xml_old; declaration or #[path] attribute pulls it in, and Cargo.toml has no [[bench]] entry for it — so cargo never even compiles it. The active benchmark, benches/xml_parse.rs, calls bun_parsers::xml::XML::parse (the real parser) and does not import this file.

    The specific history that makes this a mistake, not a design choice

    git log -- src/parsers/benches/support/xml_old.rs shows three commits:

    1. 228880f — file first added (presumably to compare old-vs-new in the bench, then never wired up).
    2. 072d131 — "bench: drop the accidentally added copy of the old parser" — the author explicitly deleted it, acknowledging it was accidental.
    3. 19dc05c (HEAD) — the rows→JS commit re-adds all 3096 lines. git show --stat 19dc05c8 | grep xml_old confirms +3096.

    So the author already agreed this file should not exist, deleted it, and then accidentally re-staged it in the very next commit (likely a stray git add -A while working on the rows→JS changes). The PR diff now shows it as a new file.

    Why nothing prevents this

    Because the file is never compiled (no mod declaration, no [[bench]] entry), rustc's dead-code lints never see it. Public items also escape dead-code lints per REVIEW.md, which is why that document requires manual grep-based verification.

    Impact

    No runtime impact — the file is inert source that never reaches the compiler. But it adds ~120 KB / 3096 lines of unmaintained, superseded parser code to the tree. Anyone grepping for XML parser symbols will hit two implementations; anyone reading the bench directory will wonder why there's a second parser that nothing uses.

    Why this is flagged at normal severity

    REVIEW.md's "Code style & idioms reviewers enforce" section says: "Delete dead code in the same PR that makes it dead (required scope — name the deletions in the description): superseded implementations … Do not keep empty files around … Delete dead code." The document's preamble states everything in it "has blocked merges." This file is exactly a "superseded implementation" — the old xml.rs that this PR rewrites. The repo rule raises this above a pure nit, and the author's own commit 072d131 confirms the intent.

    Step-by-step proof

    1. wc -l src/parsers/benches/support/xml_old.rs3096.
    2. rg xml_old (repo-wide, file contents) → no matches.
    3. git log --oneline -- src/parsers/benches/support/xml_old.rs228880f4 (add), 072d1319 (delete: "drop the accidentally added copy"), 19dc05c8 (re-add, HEAD).
    4. grep xml_old src/parsers/Cargo.toml src/parsers/benches/xml_parse.rs → nothing; the only [[bench]] entries are json_parse and xml_parse.
    5. Therefore the file is uncompiled, unreferenced dead code that the author already deleted once in this PR.

    Fix

    git rm src/parsers/benches/support/xml_old.rs

Comment thread src/parsers/xml.rs
Jarred-Sumner and others added 2 commits August 7, 2026 21:42
…ural index are generic over the code unit (u8 for UTF-8 / Latin-1, u16 for UTF-16), so a string is parsed in place in whichever representation it has and yields strings in the same one (UTF-16 tape)
Comment thread scripts/bench-json-rust.sh
Comment thread docs/runtime/xml.mdx Outdated

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

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 `@docs/runtime/xml.mdx`:
- Line 29: Update the parser description near the SIMD-pass explanation to
qualify that character data and attribute values avoid byte-at-a-time scanning
only during structural scanning; explicitly note that DTD/entity replacement
text still undergoes byte-level scanning and may affect those regions.
🪄 Autofix

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: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: f77c4b3c-e266-475f-a6c1-be1e4824d78b

📥 Commits

Reviewing files that changed from the base of the PR and between 2eff1ba and d7b33b8.

📒 Files selected for processing (2)
  • docs/runtime/xml.mdx
  • src/parsers/xml.rs

Comment thread docs/runtime/xml.mdx

## Performance

The parser works in two stages, like Bun's JSON parser: a SIMD pass (runtime-dispatched AVX2/AVX-512/NEON/SVE kernels) records the position of every byte that can change the parse — `<`, `>`, `&`, line ends, quotes and `=` inside tags — and the parser then hops from one of those positions to the next, so character data, attribute values, comments and CDATA sections are never scanned a byte at a time. The result is written as flat rows and turned into JavaScript objects in a single pass that reuses JavaScriptCore's atom-string cache for element and attribute names, the same way `JSON.parse` does. A JS string is parsed in place in whatever representation the engine holds it in (Latin-1 or UTF-16) and the strings in the result share that representation, so nothing is transcoded on the way in or out; `Buffer` and `Blob` input is parsed as UTF-8.

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.

🚀 Performance & Scalability | 🟡 Minor | ⚡ Quick win

Qualify the byte-scan claim for entity processing.

Line 29 says that character data and attribute values are never scanned a byte at a time. DTD and entity replacement text still use byte-level scanning. Entity replacement can occur in character data and attribute values. Limit this statement to structural scanning and document the exception.

Proposed wording
-... so character data, attribute values, comments and CDATA sections are never scanned a byte at a time.
+... so structural scanning of character data, attribute values, comments and CDATA sections avoids byte-at-a-time scans; DTD and entity replacement text still use byte-level scanning.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
The parser works in two stages, like Bun's JSON parser: a SIMD pass (runtime-dispatched AVX2/AVX-512/NEON/SVE kernels) records the position of every byte that can change the parse — `<`, `>`, `&`, line ends, quotes and `=` inside tags — and the parser then hops from one of those positions to the next, so character data, attribute values, comments and CDATA sections are never scanned a byte at a time. The result is written as flat rows and turned into JavaScript objects in a single pass that reuses JavaScriptCore's atom-string cache for element and attribute names, the same way `JSON.parse` does. A JS string is parsed in place in whatever representation the engine holds it in (Latin-1 or UTF-16) and the strings in the result share that representation, so nothing is transcoded on the way in or out; `Buffer` and `Blob` input is parsed as UTF-8.
The parser works in two stages, like Bun's JSON parser: a SIMD pass (runtime-dispatched AVX2/AVX-512/NEON/SVE kernels) records the position of every byte that can change the parse — `<`, `>`, `&`, line ends, quotes and `=` inside tags — and the parser then hops from one of those positions to the next, so structural scanning of character data, attribute values, comments and CDATA sections avoids byte-at-a-time scans; DTD and entity replacement text still use byte-level scanning. The result is written as flat rows and turned into JavaScript objects in a single pass that reuses JavaScriptCore's atom-string cache for element and attribute names, the same way `JSON.parse` does. A JS string is parsed in place in whatever representation the engine holds it in (Latin-1 or UTF-16) and the strings in the result share that representation, so nothing is transcoded on the way in or out; `Buffer` and `Blob` input is parsed as UTF-8.
🤖 Prompt for 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.

In `@docs/runtime/xml.mdx` at line 29, Update the parser description near the
SIMD-pass explanation to qualify that character data and attribute values avoid
byte-at-a-time scanning only during structural scanning; explicitly note that
DTD/entity replacement text still undergoes byte-level scanning and may affect
those regions.

Jarred-Sumner and others added 3 commits August 7, 2026 22:14
… in the bench script

No-Verification-Needed: comments and dev script only
…s classified through the same LUT gated on a zero high byte, 0xFFFE/0xFFFF as the non-characters), sharing the block core with the byte kernel; agreement tests; baseline allowlist
Comment thread src/parsers/xml_index.rs
Comment thread scripts/build/xmlByteClass.ts
Jarred-Sumner and others added 4 commits August 7, 2026 22:59
…VBMI/VBMI2 on the AVX-512 targets that have them)

No-Verification-Needed: CI allowlist only
… kernels

No-Verification-Needed: CI allowlist only
… index producers, incl. across blocks; entity values checked too); leaf-element fast path (text then own end tag finishes without the content loop); cheap all-distinct filter before folding repeated child names; tests

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

I reviewed the changes since my last pass (5c36d2f addressed the lone-surrogate regression, the xmlByteClass.ts inventory row, and earlier doc/bash nits) and the bug hunt found nothing new. Given the scope — a full rewrite of the XML parser plus a new Rust↔C++ FFI layout contract for JsonTape rows, a new Highway SIMD kernel, and thread-local arena recycling that now backs every Bun.{JSONC,TOML,YAML,JSON5,XML}.parse call — a human sign-off is still warranted.

Checked and ruled out: the #[repr(C, u32)] JsonValue layout vs. the C++ RowValue mirror (offset asserts match on both sides); MarkedArgumentBuffer overflow handling in array(); the UTF-16 lone-surrogate carry across 64-unit blocks in both the SIMD and scalar producers; tag_degraded reset on every markup </>//> consumer.

Extended reasoning...

Overview

This PR rewrites Bun.XML's parser as a two-stage SIMD-indexed scanner producing immutable JsonTape rows, and adds a one-call C++ rows→JS converter (JSONRowsToJS.cpp) shared with Bun.JSONC. It touches ~2,000 lines in src/parsers/xml.rs, adds src/parsers/xml_index.rs (431 lines), src/jsc/bindings/highway_xml.cpp (222 lines), src/jsc/bindings/JSONRowsToJS.cpp (229 lines), reshapes E::JsonValue/PropertyJSON/ObjectJSON/ArrayJSON to #[repr(C)] for direct C++ reads, makes the scanner generic over u8/u16 code units, switches element parsing from recursive to iterative with an explicit depth cap, and introduces a #[thread_local] recycled arena in with_text_format_source_encoded that now backs all five text-format parse() host functions. Plus benches, docs, allowlists, and codegen wiring.

Security risks

The XML parser handles adversarial input by design. Entity-expansion limits (MAX_AMPLIFICATION, MAX_ENTITY_DEPTH, MAX_DEPTH) are preserved or added. The new FFI surface reads Rust-owned tape memory from C++ via raw pointers with layout mirrored by static_assert/const _: () = assert! on both sides — a mismatch would be memory corruption, not a compile error on one side alone. The SIMD kernel writes into a caller-sized MaybeUninit buffer with an assert!(out.len() >= chunk.len() + 64) guard. No auth/crypto/permissions surface.

Level of scrutiny

High. This is a large architectural rewrite of a user-facing API with new unsafe blocks (set_len after kernel writes, get_unchecked in the hot at/seek path, raw NonNull<JsonTape> writes, transmute_copy in unit_from_u16), a new cross-language struct-layout contract, GC integration via MarkedArgumentBuffer/putDirect, and a behavioural change to arena lifetime for four other parsers. The repo's review guidance singles out exactly these categories (FFI layout, SIMD kernels, GC rooting, per-thread state) as the most-blocked.

Other factors

Test coverage is substantial (1995 W3C conformance cases unchanged, new SIMD-vs-scalar agreement tests over random and block-straddling input for both unit widths, new lone-surrogate rejection tests across content/attributes/entity-values/after-root, new >-in-attribute-value tests for the tag_degraded path, a JSONC object-shape parity test). All prior inline findings from earlier passes (surrogate acceptance regression, bash 3.2 empty-array expansion, doc overstatements, stale Sink/end_attributes references, missing CLAUDE.md inventory row) have been addressed with confirming replies. Two CodeRabbit nits remain open (WTF-8 fallback test coverage, doc byte-scan qualifier) but neither is load-bearing. The scale and the FFI/SIMD/GC surface put this outside what an automated approval should sign off on.

…UnderlyingString has no Drop; hold it in StringOrBuffer as before) — LSAN caught it via resolve/jsonc.test.ts

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

I reviewed this PR and the automated pass found no bugs. Given the scope — a full XML parser rewrite with a new SIMD stage-1 kernel, a new Rust↔C++ ABI surface (JSONRowsToJS.cpp reading #[repr(C)] tape rows), encoding-generic scanning over u8/u16, and the thread-local arena recycling in the text-format scaffold — a human sign-off is still warranted.

What was reviewed

  • Rust/C++ layout parity: offset_of!/static_assert pairs on RowValue/RowProperty/RowSpan line up; StrEncoding discriminants match.
  • RowsToJS: exception scopes after every fallible call, MarkedArgumentBuffer overflow check, isSafeToRecurse guard on both object/array recursion.
  • UTF-16 lone-surrogate rejection: both index producers (SIMD and scalar) flag unpaired surrogates including block-straddling leads and end-of-input; covered by new tests.
  • tag_degraded bookkeeping around literal > and the Latin-1 NeedsWiderEncoding retry path in XMLObject.rs.
Extended reasoning...

Overview

This PR rewrites Bun.XML's parser to mirror the JSON parser's two-stage design: a Highway SIMD structural indexer (highway_xml.cpp, xml_index.rs) plus an index-hopping stage-2 scanner, writing immutable rows onto an E::JsonTape instead of building E::Object/E::Array per node. Rows are converted to JS in one C++ call (JSONRowsToJS.cpp) that mirrors the #[repr(C)] layouts of JsonValue/PropertyJSON/ObjectJSON/ArrayJSON and routes keys through jsonAtomStringCache. The scanner is now generic over u8/u16 code units, enabling Latin-1 and UTF-16 JS strings to be parsed in place; the text-format scaffold in api.rs gains a SourceEncoding enum, a string_passthrough mode, and a per-thread recycled Arena. Supporting changes: codegen for the XML byte-class tables, verify-baseline allowlist updates, benchmark harnesses (Rust criterion + JS mitata), and docs.

Security risks

The parser handles untrusted input and enforces well-formedness limits (MAX_DEPTH, entity amplification, i32::MAX byte-length guard). The new UTF-16 path had a lone-surrogate acceptance gap that was caught and fixed during review (5c36d2f) with SIMD/scalar agreement tests. The rows→JS bridge reads Rust struct layouts by offset from C++; both sides carry matching static_assert/const _: () = assert! checks, so a silent ABI drift would fail to compile rather than corrupt memory. No auth/crypto/permissions surface is touched.

Level of scrutiny

High. This is a ~4K-line architectural rewrite of a hot-path parser with a new cross-language ABI, new SIMD kernels dispatched at runtime across five x64 targets plus SVE, three encoding paths, and a change to arena lifetime management shared by all text-format .parse host functions. It is well beyond the "simple/mechanical" bar for auto-approval.

Other factors

A maintainer has been actively reviewing (the MarkedArgumentBuffer and duplicate-decoder feedback were both addressed), and every earlier automated finding — including the lone-surrogate regression — has a fix commit and tests. The 1995-case W3C conformance suite, SIMD-vs-scalar index agreement tests, and jsonc/toml/yaml suites are cited as passing. Two minor CodeRabbit doc/test-coverage suggestions remain open but are non-blocking. Deferring rather than approving.

@Jarred-Sumner
Jarred-Sumner merged commit f972c28 into main Aug 8, 2026
54 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the claude/xml-simd-tape branch August 8, 2026 02:25
springmin pushed a commit to springmin/bun that referenced this pull request Aug 8, 2026
…-sh#37146)

### What does this PR do?

Rewrites `Bun.XML`'s parser in the shape of the JSON parser: a Highway
SIMD "stage 1" indexes every `<` `>` `&` `\r`, control character and
encoded U+FFFE/FFFF (plus tab/newline/quotes/`=` inside tags, tracked
with a MatchStar carry across 64-byte blocks), and stage 2 hops from
index entry to entry — text runs, attribute values, comments, CDATA and
PIs are never walked byte by byte — writing immutable
`ObjectJSON`/`ArrayJSON` rows on a `JsonTape` instead of a `Vec` per
object. Elements are parsed iteratively (explicit depth cap); the DTD
and entity replacement text keep the byte-level scanner.

Rows (JSON and XML alike) become JS values in one C++ call: objects via
`putDirect`, arrays via `MarkedArgumentBuffer` + `constructArray`, keys
and short values through the VM's `jsonAtomStringCache` as `JSON.parse`
does, with three string paths keyed on the tape's encoding (UTF-8 /
Latin-1 / UTF-16). An 8-bit JS string is parsed byte-per-character as is
and yields Latin-1 strings without any re-encoding.
`Bun.{XML,JSONC,TOML,YAML,JSON5}.parse` recycle a per-thread arena
instead of a `mi_heap` per call.

**JS level** — `bench/xml/xml.mjs` with the CI-built PR binary (`bunx
bun-pr 37146`, linux-x64) vs main, ms/iter, lower is better (one loaded
machine → read as ratios). Input is a JS string: ASCII files take the
Latin-1 in-place path, the CJK-heavy one the native UTF-16 path.

| document | PR (compact) | main | × | PR `{compact:false}` | main |
txml | fast-xml-parser | @xmldom |
|---|---|---|---|---|---|---|---|---|
| small, 188 B | **0.0028** | 0.0149 | 5.4× | 0.0048 | 0.0168 | 0.0079 |
0.029 | 0.032 |
| S3 ListObjects, 231 KB | **1.33** | 3.17 | 2.4× | 3.97 | 8.54 | 4.79 |
24.8 | 33.6 |
| Atom feed, 193 KB | **1.10** | 3.25 | 3.0× | 2.56 | 5.93 | 4.57 | 20.8
| 26.2 |
| SVG (LibreOffice art), 1.1 MB | **0.55** | 4.12 | 7.5× | 0.53 | 3.93 |
– | 5.34 | 4.10 |
| SVG (hixie perf 007), 388 KB | **0.34** | 1.62 | 4.8× | 0.38 | 1.77 |
0.29 | 2.89 | 2.33 |
| Vulkan vk.xml, 3.1 MB | **23.1** | 61.9 | 2.7× | 47.6 | 109 | 65.5 |
304 | 311 |
| RFC xml, 363 KB | **1.85** | 4.19 | 2.3× | 3.37 | 8.20 | 2.98 | 32.1 |
40.4 |
| libphonenumber, 959 KB | **4.97** | 11.4 | 2.3× | 9.56 | 18.9 | 11.8 |
62.5 | 60.8 |
| chromium enums.xml, 1.4 MB | **16.6** | 27.3 | 1.6× | 27.9 | 47.7 |
47.2 | 153 | 112 |
| freedesktop mime db, 2.2 MB (UTF-16) | **26.7** | 60.2 | 2.3× | 43.4 |
94.4 | 60.0 | 317 | 303 |

**Native level** — `scripts/bench-json-rust.sh --xml` (criterion; parse
→ compact tree), MiB/s, higher is better:

| file | bun new | bun old | pugixml | quick-xml | expat | roxmltree |
libxml2 | xml-rs |
|---|---|---|---|---|---|---|---|---|
| SVG (LibreOffice art) 1.2 MB | **3634** | 341 | 2177 | 964 | 174 | 294
| 630 | 54 |
| SVG (hixie perf 007) 400 KB | **2348** | 300 | 2239 | 855 | 264 | 282
| 422 | 50 |
| libphonenumber metadata 1 MB | 438 | 161 | 836 | 577 | 171 | 175 | 88
| 37 |
| RFC xml (opus draft) 370 KB | 344 | 124 | 857 | 337 | 184 | – | 12 | –
|
| Vulkan vk.xml 3.2 MB | 339 | 98 | 627 | 239 | 132 | 108 | 41 | – |
| chromium enums.xml 1.4 MB | 322 | 95 | 703 | 275 | 114 | 106 | 35 | 28
|
| freedesktop mime db 2.4 MB | 207 | 60 | 585 | 237 | 119 | 91 | 30 | –
|
| synthetic dense records 1 MB | 162 | 37 | 583 | 132 | 86 | 82 | 22 |
15 |

So: 3–10× the parser on main, ahead of every Rust parser and
expat/libxml2 on all files, ahead of pugixml on attribute/text-heavy
input, still behind pugixml (which skips UTF-8 validation and
duplicate-attribute checks and parses in-situ) on element-dense input;
stage 1 alone runs at 2–3 GiB/s everywhere.

### How did you verify your code works?

`bun bd test test/js/bun/xml` (1995/1995 W3C conformance cases with
identical messages, 56 unit tests incl. new ones for `>` inside
attribute values and key kinds), `test/js/bun/jsonc` (incl. a new
object-shape parity test against `JSON.parse`), `test/js/bun/toml`,
yaml, `bundler_loader`, `bun-lock`; SIMD-vs-scalar index agreement unit
tests; `bun run rust:check-all`.

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Jarred-Sumner added a commit that referenced this pull request Aug 9, 2026
… scraping (#37194)

### What does this PR do?

The S3 client extracted `ListObjectsV2` results, `<Error>` bodies
(simple requests and download streams) and the multipart `UploadId` with
`index_of("<Tag>")` scans. That returned XML-escaped text verbatim — S3
escapes every text node, so a key `Tom &amp; Jerry.mp4` came back with
the entity still in it, and the same for prefixes, continuation tokens
and error messages — could be confused by markup-looking text, and
half-parsed ill-formed bodies. Now that #37146 landed a fast conforming
parser, these paths read the response through `bun_parsers::xml` (node
shape, so text is byte-exact: entities/CDATA decoded, whitespace in keys
preserved) via a small `s3/xml_response.rs` helper. `list_objects.rs`
shrinks from a ~350-line state machine to field mapping.

Behavior changes worth calling out:
- Escaped text is decoded everywhere (the actual bug).
- A `200` ListObjectsV2 body that is not a well-formed
`<ListBucketResult>` now **rejects** with `code: "InvalidResponse"`
(message points at `encodingType: "url"` for keys with control
characters) instead of resolving with a partial/empty listing. The
existing tests that answered `<>` just to inspect the request URL now
answer `<ListBucketResult/>`.
- 404s whose `<Error>` has no `<Code>` still map to `NoSuchKey`.

Also folded in: the per-thread recycled parse arena is factored out of
the `Bun.{XML,JSONC,TOML,YAML,JSON5}.parse` scaffold as `RecycledArena`
and shared with the S3 path (no `mi_heap` per response), and the
`xml_parse` feature counter moves from the parser to its API entry
points so internal use isn't counted.

Not in scope: `uploadId` is still interpolated into the query string
unencoded (pre-existing; AWS upload IDs are URL-safe and are validated
as printable ASCII).

### How did you verify your code works?

- New test in `test/js/bun/s3/s3-list-objects.test.ts` covering escaped
keys/prefixes/owner, CDATA keys, `&#x…;` refs, `RestoreStatus` nesting,
whitespace-exact keys, and decoded `<Error>` code/message on 404 — fails
with `USE_SYSTEM_BUN=1`, passes on the build.
- `bun bd test` green for `s3-list-objects` (36 pass / 3 skip),
`s3-storage-class`, `s3-list-checksum-algorithm`,
`s3-list-encode-overflow`, `s3-stream-error-gc`, `s3-requester-pays`,
`s3-connection-close`, and the multipart upload-id / NetworkSink mock
tests in `s3.test.ts`; XML suites unchanged.
- Drove the debug binary against a local mock endpoint for all four
paths (list, error body, 404 without `<Code>`, malformed 200, multipart
create→complete).
- The credentialed `s3.test.ts` suites are skipped locally (no secrets);
CI runs them.

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
springmin pushed a commit to springmin/bun that referenced this pull request Aug 9, 2026
… scraping (oven-sh#37194)

### What does this PR do?

The S3 client extracted `ListObjectsV2` results, `<Error>` bodies
(simple requests and download streams) and the multipart `UploadId` with
`index_of("<Tag>")` scans. That returned XML-escaped text verbatim — S3
escapes every text node, so a key `Tom &amp; Jerry.mp4` came back with
the entity still in it, and the same for prefixes, continuation tokens
and error messages — could be confused by markup-looking text, and
half-parsed ill-formed bodies. Now that oven-sh#37146 landed a fast conforming
parser, these paths read the response through `bun_parsers::xml` (node
shape, so text is byte-exact: entities/CDATA decoded, whitespace in keys
preserved) via a small `s3/xml_response.rs` helper. `list_objects.rs`
shrinks from a ~350-line state machine to field mapping.

Behavior changes worth calling out:
- Escaped text is decoded everywhere (the actual bug).
- A `200` ListObjectsV2 body that is not a well-formed
`<ListBucketResult>` now **rejects** with `code: "InvalidResponse"`
(message points at `encodingType: "url"` for keys with control
characters) instead of resolving with a partial/empty listing. The
existing tests that answered `<>` just to inspect the request URL now
answer `<ListBucketResult/>`.
- 404s whose `<Error>` has no `<Code>` still map to `NoSuchKey`.

Also folded in: the per-thread recycled parse arena is factored out of
the `Bun.{XML,JSONC,TOML,YAML,JSON5}.parse` scaffold as `RecycledArena`
and shared with the S3 path (no `mi_heap` per response), and the
`xml_parse` feature counter moves from the parser to its API entry
points so internal use isn't counted.

Not in scope: `uploadId` is still interpolated into the query string
unencoded (pre-existing; AWS upload IDs are URL-safe and are validated
as printable ASCII).

### How did you verify your code works?

- New test in `test/js/bun/s3/s3-list-objects.test.ts` covering escaped
keys/prefixes/owner, CDATA keys, `&#x…;` refs, `RestoreStatus` nesting,
whitespace-exact keys, and decoded `<Error>` code/message on 404 — fails
with `USE_SYSTEM_BUN=1`, passes on the build.
- `bun bd test` green for `s3-list-objects` (36 pass / 3 skip),
`s3-storage-class`, `s3-list-checksum-algorithm`,
`s3-list-encode-overflow`, `s3-stream-error-gc`, `s3-requester-pays`,
`s3-connection-close`, and the multipart upload-id / NetworkSink mock
tests in `s3.test.ts`; XML suites unchanged.
- Drove the debug binary against a local mock endpoint for all four
paths (list, error body, 404 without `<Code>`, malformed 200, multipart
create→complete).
- The credentialed `s3.test.ts` suites are skipped locally (no secrets);
CI runs them.

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
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.

2 participants