diff --git a/CLAUDE.md b/CLAUDE.md index b75fb9fed1..db7f3d8d95 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Perry is a native TypeScript compiler written in Rust that compiles TypeScript source code directly to native executables. It uses SWC for TypeScript parsing and LLVM for code generation. -**Current Version:** 0.5.1413 +**Current Version:** 0.5.1414 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index 39a18ad498..67d909e4da 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5547,7 +5547,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perry" -version = "0.5.1413" +version = "0.5.1414" dependencies = [ "anyhow", "base64", @@ -5607,14 +5607,14 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1413" +version = "0.5.1414" dependencies = [ "serde", ] [[package]] name = "perry-audio-miniaudio" -version = "0.5.1413" +version = "0.5.1414" dependencies = [ "cc", "libc", @@ -5622,7 +5622,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1413" +version = "0.5.1414" dependencies = [ "anyhow", "inkwell", @@ -5639,7 +5639,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1413" +version = "0.5.1414" dependencies = [ "anyhow", "perry-hir", @@ -5647,7 +5647,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1413" +version = "0.5.1414" dependencies = [ "anyhow", "perry-hir", @@ -5655,7 +5655,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1413" +version = "0.5.1414" dependencies = [ "anyhow", "perry-dispatch", @@ -5664,7 +5664,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1413" +version = "0.5.1414" dependencies = [ "anyhow", "perry-hir", @@ -5672,7 +5672,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1413" +version = "0.5.1414" dependencies = [ "anyhow", "base64", @@ -5684,7 +5684,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1413" +version = "0.5.1414" dependencies = [ "anyhow", "perry-hir", @@ -5692,7 +5692,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1413" +version = "0.5.1414" dependencies = [ "anyhow", "async-trait", @@ -5721,14 +5721,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1413" +version = "0.5.1414" dependencies = [ "anyhow", ] [[package]] name = "perry-diagnostics" -version = "0.5.1413" +version = "0.5.1414" dependencies = [ "serde", "serde_json", @@ -5736,7 +5736,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1413" +version = "0.5.1414" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5747,7 +5747,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1413" +version = "0.5.1414" dependencies = [ "anyhow", "clap", @@ -5762,7 +5762,7 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1413" +version = "0.5.1414" dependencies = [ "block2", "objc2", @@ -5772,7 +5772,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.1413" +version = "0.5.1414" dependencies = [ "argon2", "perry-ffi", @@ -5780,7 +5780,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.1413" +version = "0.5.1414" dependencies = [ "perry-ffi", "reqwest", @@ -5789,7 +5789,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1413" +version = "0.5.1414" dependencies = [ "bcrypt", "perry-ffi", @@ -5797,7 +5797,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1413" +version = "0.5.1414" dependencies = [ "perry-ffi", "rusqlite", @@ -5805,7 +5805,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1413" +version = "0.5.1414" dependencies = [ "perry-ffi", "scraper", @@ -5813,7 +5813,7 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.1413" +version = "0.5.1414" dependencies = [ "perry-ffi", "perry-runtime", @@ -5821,7 +5821,7 @@ dependencies = [ [[package]] name = "perry-ext-cron" -version = "0.5.1413" +version = "0.5.1414" dependencies = [ "chrono", "cron", @@ -5831,7 +5831,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.1413" +version = "0.5.1414" dependencies = [ "chrono", "perry-ffi", @@ -5839,7 +5839,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1413" +version = "0.5.1414" dependencies = [ "perry-ffi", "rust_decimal", @@ -5847,7 +5847,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.1413" +version = "0.5.1414" dependencies = [ "perry-ffi", "serde_json", @@ -5855,7 +5855,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1413" +version = "0.5.1414" dependencies = [ "perry-ffi", "rand 0.10.1", @@ -5863,7 +5863,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1413" +version = "0.5.1414" dependencies = [ "perry-ffi", "perry-runtime", @@ -5871,14 +5871,14 @@ dependencies = [ [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.1413" +version = "0.5.1414" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.1413" +version = "0.5.1414" dependencies = [ "bytes", "http-body-util", @@ -5896,7 +5896,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.1413" +version = "0.5.1414" dependencies = [ "bytes", "lazy_static", @@ -5909,7 +5909,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1413" +version = "0.5.1414" dependencies = [ "bytes", "h2", @@ -5933,7 +5933,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1413" +version = "0.5.1414" dependencies = [ "lazy_static", "perry-ffi", @@ -5943,7 +5943,7 @@ dependencies = [ [[package]] name = "perry-ext-jsonwebtoken" -version = "0.5.1413" +version = "0.5.1414" dependencies = [ "base64", "jsonwebtoken", @@ -5954,7 +5954,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.1413" +version = "0.5.1414" dependencies = [ "lru", "perry-ffi", @@ -5963,7 +5963,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.1413" +version = "0.5.1414" dependencies = [ "chrono", "perry-ffi", @@ -5971,7 +5971,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1413" +version = "0.5.1414" dependencies = [ "bson", "futures-util", @@ -5983,7 +5983,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.1413" +version = "0.5.1414" dependencies = [ "chrono", "perry-ffi", @@ -5993,7 +5993,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.1413" +version = "0.5.1414" dependencies = [ "nanoid", "perry-ffi", @@ -6002,7 +6002,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1413" +version = "0.5.1414" dependencies = [ "bytes", "perry-ffi", @@ -6015,7 +6015,7 @@ dependencies = [ [[package]] name = "perry-ext-node-forge" -version = "0.5.1413" +version = "0.5.1414" dependencies = [ "const-oid 0.9.6", "der 0.7.10", @@ -6034,7 +6034,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1413" +version = "0.5.1414" dependencies = [ "lettre", "perry-ffi", @@ -6044,7 +6044,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1413" +version = "0.5.1414" dependencies = [ "perry-ffi", "printpdf", @@ -6052,7 +6052,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.1413" +version = "0.5.1414" dependencies = [ "perry-ffi", "sqlx", @@ -6061,7 +6061,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.1413" +version = "0.5.1414" dependencies = [ "governor", "perry-ffi", @@ -6069,7 +6069,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1413" +version = "0.5.1414" dependencies = [ "fast_image_resize", "image", @@ -6079,14 +6079,14 @@ dependencies = [ [[package]] name = "perry-ext-slugify" -version = "0.5.1413" +version = "0.5.1414" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-streams" -version = "0.5.1413" +version = "0.5.1414" dependencies = [ "lazy_static", "perry-ffi", @@ -6095,7 +6095,7 @@ dependencies = [ [[package]] name = "perry-ext-undici" -version = "0.5.1413" +version = "0.5.1414" dependencies = [ "perry-ffi", "perry-runtime", @@ -6104,7 +6104,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.1413" +version = "0.5.1414" dependencies = [ "perry-ffi", "uuid", @@ -6112,7 +6112,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.1413" +version = "0.5.1414" dependencies = [ "perry-ffi", "regex", @@ -6122,7 +6122,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1413" +version = "0.5.1414" dependencies = [ "futures-util", "lazy_static", @@ -6135,7 +6135,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1413" +version = "0.5.1414" dependencies = [ "brotli", "flate2", @@ -6145,7 +6145,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1413" +version = "0.5.1414" dependencies = [ "dashmap", "once_cell", @@ -6154,7 +6154,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1413" +version = "0.5.1414" dependencies = [ "anyhow", "perry-api-manifest", @@ -6172,7 +6172,7 @@ dependencies = [ [[package]] name = "perry-parser" -version = "0.5.1413" +version = "0.5.1414" dependencies = [ "anyhow", "perry-diagnostics", @@ -6184,7 +6184,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1413" +version = "0.5.1414" dependencies = [ "anyhow", "base64", @@ -6226,14 +6226,14 @@ dependencies = [ [[package]] name = "perry-runtime-static" -version = "0.5.1413" +version = "0.5.1414" dependencies = [ "perry-runtime", ] [[package]] name = "perry-stdlib" -version = "0.5.1413" +version = "0.5.1414" dependencies = [ "aes 0.8.4", "aes 0.9.1", @@ -6328,14 +6328,14 @@ dependencies = [ [[package]] name = "perry-stdlib-static" -version = "0.5.1413" +version = "0.5.1414" dependencies = [ "perry-stdlib", ] [[package]] name = "perry-transform" -version = "0.5.1413" +version = "0.5.1414" dependencies = [ "anyhow", "perry-hir", @@ -6344,14 +6344,14 @@ dependencies = [ [[package]] name = "perry-ui" -version = "0.5.1413" +version = "0.5.1414" dependencies = [ "perry-ui-model", ] [[package]] name = "perry-ui-android" -version = "0.5.1413" +version = "0.5.1414" dependencies = [ "base64", "itoa", @@ -6368,7 +6368,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1413" +version = "0.5.1414" dependencies = [ "rand 0.10.1", "serde", @@ -6378,7 +6378,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1413" +version = "0.5.1414" dependencies = [ "base64", "cairo-rs 0.22.0", @@ -6401,7 +6401,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1413" +version = "0.5.1414" dependencies = [ "base64", "block2", @@ -6417,7 +6417,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1413" +version = "0.5.1414" dependencies = [ "base64", "block2", @@ -6432,7 +6432,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1413" +version = "0.5.1414" [[package]] name = "perry-ui-test" @@ -6443,11 +6443,11 @@ dependencies = [ [[package]] name = "perry-ui-testkit" -version = "0.5.1413" +version = "0.5.1414" [[package]] name = "perry-ui-tvos" -version = "0.5.1413" +version = "0.5.1414" dependencies = [ "base64", "block2", @@ -6463,7 +6463,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1413" +version = "0.5.1414" dependencies = [ "base64", "block2", @@ -6479,7 +6479,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1413" +version = "0.5.1414" dependencies = [ "block2", "libc", @@ -6492,7 +6492,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1413" +version = "0.5.1414" dependencies = [ "base64", "libc", @@ -6509,14 +6509,14 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1413" +version = "0.5.1414" dependencies = [ "perry-ui-windows", ] [[package]] name = "perry-updater" -version = "0.5.1413" +version = "0.5.1414" dependencies = [ "anyhow", "base64", @@ -6532,7 +6532,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1413" +version = "0.5.1414" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index 59bf92ae68..4baa4585ef 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -315,7 +315,7 @@ codegen-units = 16 codegen-units = 16 [workspace.package] -version = "0.5.1413" +version = "0.5.1414" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry" diff --git a/changelog.d/7713-json-replacer-clean-ptr.md b/changelog.d/7713-json-replacer-clean-ptr.md new file mode 100644 index 0000000000..60f8668227 --- /dev/null +++ b/changelog.d/7713-json-replacer-clean-ptr.md @@ -0,0 +1,16 @@ +### Fixed + +- **`JSON.stringify(v, null, 2)` returned nondeterministic garbage — sometimes a crash — for an array grown past its initial inline capacity (#7269).** `js_array_grow` (#233) reallocates a grown array and leaves a `GC_FLAG_FORWARDED` stub at the OLD address, whose first 8 bytes are exactly `ArrayHeader.length` + `.capacity`, now holding the raw forwarding pointer to the new array. The plain (non-pretty) path always resolves this via `clean_arr_ptr` before every header read (`json/stringify.rs::stringify_array_depth`); the pretty-print path in `json/replacer.rs` did not, so a caller still holding the pre-grow address — the stub is retained *specifically* so such callers keep working — fed the raw forwarding-pointer bytes straight into `(*arr).length`/`.capacity`. That is why the symptom was "garbage" rather than a clean failure: the bytes are a real, live heap pointer, just not the field values they are pretending to be, so they vary run to run purely from ASLR/allocator placement. In the worst case the misread `length` (up to ~4 billion) drove the pretty-printer into a deep, self-feeding recursion over adjacent heap bytes reinterpreted as more NaN-boxed values, which overflowed the stack. + + Five call sites in `crates/perry-runtime/src/json/replacer.rs` cast an array pointer without resolving forwarding first: + + - `stringify_value_pretty`'s `TYPE_UNKNOWN` structural-fallback probe — the site actually hit by the issue's repro (`JSON.stringify(v, null, 2)`, no replacer). + - `stringify_array_pretty` — hardened as the shared choke point for both its callers (the explicit `type_hint == TYPE_ARRAY` dispatch and the fallback above). + - `stringify_array_with_replacer_pretty` — safe today only because its one current caller (`dispatch_pointer_with_replacer`) happens to resolve first; fixed to not depend on that. + - `extract_string_array` and `is_array_value` — the PropertyList *replacer* array (`JSON.stringify(v, ['a','b'])`) has the identical hazard on its own pointer. + + A sixth site the issue also cited, `stringify_array_with_array_replacer`, already resolved via `clean_arr_ptr` (predates this issue's filing) and needed no change. + + Fix: every site now resolves through `crate::array::clean_arr_ptr` before the first header read, matching the pattern already used by the plain-stringify path and by the two sites above that were already correct. + + Coverage: `pretty_stringify_resolves_array_grown_past_inline_capacity` in `crates/perry-runtime/src/json/replacer.rs` grows a real array past its allocated capacity (no GC cycle needed — `js_array_grow` installs the forwarding stub unconditionally on every reallocating grow), asserts the stale header's raw `(length, capacity)` bytes really do reconstruct the grown array's exact address (sabotage precondition), then pretty-stringifies the stale pre-grow pointer and asserts the output matches the array's real, current contents. Reverting the fix makes the test abort (stack overflow via runaway recursion) rather than merely fail an assertion — confirmed by hand before submitting. diff --git a/crates/perry-runtime/src/json/replacer.rs b/crates/perry-runtime/src/json/replacer.rs index 1dfd9bbcd6..7232acb009 100644 --- a/crates/perry-runtime/src/json/replacer.rs +++ b/crates/perry-runtime/src/json/replacer.rs @@ -519,6 +519,18 @@ pub(crate) unsafe fn stringify_array_with_replacer_pretty( indent: &str, depth: usize, ) { + // #7269: follow GC_FLAG_FORWARDED array-growth stubs (`js_array_grow`, + // issue #233) before any header read — mirrors the resolution in + // `dispatch_pointer_with_replacer`'s array arm and + // `stringify_array_with_array_replacer`. Every array's only current + // caller already resolves before calling in, but this function must not + // depend on that — a raw `ptr as *const ArrayHeader` cast on a stale + // pre-grow pointer reads the forwarding pointer as (length, capacity). + let ptr = crate::array::clean_arr_ptr(ptr as *const crate::ArrayHeader) as *const u8; + if ptr.is_null() { + buf.push_str("null"); + return; + } // Circular-reference detection. if STRINGIFY_STACK.with(|s| s.borrow().contains(&(ptr as usize))) { let msg = "Converting circular structure to JSON"; @@ -867,12 +879,24 @@ pub(crate) unsafe fn stringify_value_pretty( } else if type_hint == TYPE_ARRAY { stringify_array_pretty(ptr, buf, indent, depth); } else { - let arr = ptr as *const crate::ArrayHeader; - if !arr.is_null() { - let len = (*arr).length; - let cap = (*arr).capacity; + // #7269: this is the TYPE_UNKNOWN structural-fallback probe — an + // array-shaped pointer never got a `gc_obj_type` dispatch on this + // path, so an array grown past its initial inline capacity (16) + // reaches here still holding its stale pre-grow address. Its + // GC_FLAG_FORWARDED stub's first 8 bytes now hold the forwarding + // pointer, so reading them raw as (length, capacity) below + // yielded a bogus, run-to-run-different "shape" that either + // misclassified a live array as a string/object or walked the + // resulting garbage length. Resolve through `clean_arr_ptr` — + // same chain `json/stringify.rs::stringify_array_depth` and the + // replacer array-walks in this file already follow — BEFORE the + // length/capacity probe, and pass the resolved pointer on. + let resolved = crate::array::clean_arr_ptr(ptr as *const crate::ArrayHeader); + if !resolved.is_null() { + let len = (*resolved).length; + let cap = (*resolved).capacity; if len <= cap && cap > 0 && cap < 10000 && !is_object_pointer(ptr) { - stringify_array_pretty(ptr, buf, indent, depth); + stringify_array_pretty(resolved as *const u8, buf, indent, depth); return; } } @@ -1032,6 +1056,19 @@ pub(crate) unsafe fn stringify_array_pretty( indent: &str, depth: usize, ) { + // #7269: resolve GC_FLAG_FORWARDED array-growth stubs before any header + // read. `ptr_is_tracked_heap_object` below only confirms the address is a + // live tracked allocation — a forwarded stub still passes that check + // (its GcHeader is intact; only its body was overwritten with the + // forwarding pointer), so it does NOT substitute for this resolve. This + // is the shared choke point for both callers below (the explicit + // `type_hint == TYPE_ARRAY` dispatch and the TYPE_UNKNOWN structural + // fallback), so resolving once here protects both. + let ptr = crate::array::clean_arr_ptr(ptr as *const crate::ArrayHeader) as *const u8; + if ptr.is_null() { + buf.push_str("null"); + return; + } // Same gate as `stringify_object_pretty`: this is the fall-through branch for // a pointer that failed the object probes, so a corrupted pointer lands here // and the `(*arr).length` read below would fault. @@ -1348,6 +1385,13 @@ pub(crate) unsafe fn stringify_array_with_array_replacer( // ─── Extract array of strings from a JSValue array ────────────────────────── pub(crate) unsafe fn extract_string_array(ptr: *const u8) -> Vec { + // #7269: the PropertyList replacer array (`JSON.stringify(v, ['a','b'])`) + // is exactly as susceptible to the GC_FLAG_FORWARDED array-growth stub as + // any other array pointer in this file — resolve before the header read. + let ptr = crate::array::clean_arr_ptr(ptr as *const crate::ArrayHeader) as *const u8; + if ptr.is_null() { + return Vec::new(); + } let arr = ptr as *const crate::ArrayHeader; let len = (*arr).length; let elements = (arr as *const u8).add(std::mem::size_of::()) as *const f64; @@ -1425,9 +1469,14 @@ pub(crate) unsafe fn is_array_value(bits: u64) -> bool { if is_object_pointer(ptr) { return false; } - let arr = ptr as *const crate::ArrayHeader; - let len = (*arr).length; - let cap = (*arr).capacity; + // #7269: same GC_FLAG_FORWARDED array-growth hazard as every other + // raw `ArrayHeader` cast in this file — resolve first. + let resolved = crate::array::clean_arr_ptr(ptr as *const crate::ArrayHeader); + if resolved.is_null() { + return false; + } + let len = (*resolved).length; + let cap = (*resolved).capacity; len <= cap && cap > 0 && cap < 10000 } else { false @@ -1743,3 +1792,103 @@ pub unsafe extern "C" fn js_json_stringify_full( // Return as NaN-boxed string (STRING_TAG | (result_ptr as u64 & POINTER_MASK)) as i64 } + +#[cfg(test)] +mod forwarded_array_pretty_print_tests { + use super::*; + + /// #7269: `JSON.stringify(v, null, 2)` on an array grown past its + /// initial inline capacity (`MIN_ARRAY_CAPACITY`, 16) returned + /// nondeterministic garbage — a different, wrong-length string on every + /// run of the SAME binary. Root cause: `js_array_grow` (issue #233) + /// reallocates and installs a `GC_FLAG_FORWARDED` stub at the OLD + /// address, whose first 8 bytes — exactly `ArrayHeader.length` + + /// `.capacity` — now hold the raw forwarding pointer to the new array. + /// The stub is retained specifically so a caller still holding the + /// pre-grow address (its comment: "an async function's caller still + /// holding the pre-grow pointer") keeps resolving correctly *through + /// `clean_arr_ptr`*. The plain (non-pretty) path always went through + /// `clean_arr_ptr` before every header read + /// (`json/stringify.rs::stringify_array_depth`); the pretty-print path + /// in this file read `(*arr).length`/`.capacity` directly, so it saw the + /// forwarding pointer's bytes reinterpreted as a bogus, run-to-run- + /// different array shape — "garbage" because the bytes are a real, live + /// pointer, just not the field values they're pretending to be. + /// + /// This test needs no explicit GC cycle: `js_array_grow` unconditionally + /// installs the forwarding stub on every reallocating grow, independent + /// of any minor/major collection. + #[test] + fn pretty_stringify_resolves_array_grown_past_inline_capacity() { + unsafe { + let mut arr = crate::js_array_alloc(0); + let initial_capacity = (*arr).capacity; + assert!( + initial_capacity > 0, + "a freshly allocated array must report a real capacity" + ); + // Fill to capacity so the NEXT push must reallocate. + for i in 0..initial_capacity { + arr = crate::js_array_push_f64(arr, i as f64); + } + assert_eq!((*arr).length, initial_capacity); + + // Capture the pre-grow address. No allocation happens between this + // read and the growing push below, so nothing else can have moved + // or reused this address in between. + let stale_ptr = arr as *const crate::ArrayHeader; + let grown = crate::js_array_push_f64(arr, initial_capacity as f64); + assert_ne!( + grown as *const crate::ArrayHeader, stale_ptr, + "growth past capacity must reallocate to a new address \ + (otherwise this test exercises nothing)" + ); + assert_eq!((*grown).length, initial_capacity + 1); + + // Sabotage precondition, mirroring `array/subclass_tests.rs`'s + // style: prove the stale address really carries a raw forwarding + // pointer reinterpreted as (length, capacity), not merely stale + // or reused memory — reconstructing the u64 from the two u32 + // fields must recover the grown array's exact address. + let raw_len_bits = (*stale_ptr).length as u64; + let raw_cap_bits = (*stale_ptr).capacity as u64; + let forwarded_as_ptr = raw_len_bits | (raw_cap_bits << 32); + assert_eq!( + forwarded_as_ptr, grown as u64, + "the stale header's raw (length, capacity) bytes must be the \ + exact bit pattern of the new array's address" + ); + assert_ne!( + (*stale_ptr).length, + initial_capacity + 1, + "sabotage precondition: an unresolved read of the stale \ + header must not already report the real length by luck" + ); + let resolved = crate::array::clean_arr_ptr(stale_ptr); + assert_eq!( + resolved, grown as *const crate::ArrayHeader, + "clean_arr_ptr must resolve the stale pre-grow pointer to the grown array" + ); + + // Build the exact NaN-boxed value a caller still holding the + // pre-grow reference would carry, and pretty-stringify it — + // mirroring the issue's `JSON.stringify(v, null, 2)` repro. + let stale_value = f64::from_bits(POINTER_TAG | (stale_ptr as u64 & POINTER_MASK)); + let result_bits = js_json_stringify_full(stale_value, f64::from_bits(TAG_NULL), 2.0); + let result_ptr = (result_bits as u64 & POINTER_MASK) as *const StringHeader; + let s = str_from_header(result_ptr).expect("must produce a string"); + + let expected_body = (0..=initial_capacity) + .map(|i| format!(" {}", i)) + .collect::>() + .join(",\n"); + let expected = format!("[\n{}\n]", expected_body); + assert_eq!( + s, expected, + "pretty stringify of a stale forwarded array pointer must \ + equal the CURRENT (grown) array's real contents, not \ + garbage read from the forwarding stub" + ); + } + } +}