From b7b23d8ef6b83843bbc373f44c93ec83f807f571 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 6 Aug 2026 09:48:14 +0200 Subject: [PATCH 1/7] fix(gc): root the iterator drain's live values across `.next()` (#7475) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `js_iterator_to_array` — the `[...iterable]` / `Array.from(iterable)` drain — held the iterator object, the accumulator array, the `next` closure and the two property keys in bare Rust locals across a `.next()` call that allocates the `{ value, done }` result. Any of those allocations can trigger the copying minor, which moves the values and rewrites only the slots it can see. A moved iterator leaves its pre-move copy in retired from-space; the next dispatch reads that copy's STALE field 0 and `dispatch_array_iterator_method` calls `js_array_length` on a from-space address. `make_iter_result` / `make_sqlite_iter_result` had the same shape one level down: the caller-supplied `value` (usually a heap element) and the freshly allocated result object were live across four more allocations before being stored. And `dispatch_array_iterator_method` re-used a backing-array pointer read BEFORE its cursor store, which can allocate. Root them all in a `RuntimeHandleScope` and re-read every address at its point of use. All handles are NaN-boxed rather than `root_raw_*_ptr`, so `scripts/raw_handle_debt.py` stays at 999. Claude-Session: https://claude.ai/code/session_019EHcmXKArA7m42SihYCcgH --- crates/perry-runtime/src/array/iter_object.rs | 81 ++++++++++++----- crates/perry-runtime/src/array/iterator.rs | 89 ++++++++++++++++--- 2 files changed, 133 insertions(+), 37 deletions(-) diff --git a/crates/perry-runtime/src/array/iter_object.rs b/crates/perry-runtime/src/array/iter_object.rs index 06dbd9caef..5bb98276d5 100644 --- a/crates/perry-runtime/src/array/iter_object.rs +++ b/crates/perry-runtime/src/array/iter_object.rs @@ -518,35 +518,62 @@ pub extern "C" fn js_array_entries_iter_obj(arr: *const ArrayHeader) -> i64 { } } +/// Root every heap value an iterator-result constructor carries across its own +/// allocations, and hand back the finished `{ … }` object. +/// +/// #7475: `value` is a caller-supplied JSValue that is very often a heap +/// pointer (an array element), `obj` is freshly allocated, and the two key +/// strings and the keys array are all allocated while the earlier ones are +/// still live. Every one of those lines can trigger the copying minor, and +/// before this helper each was held in a bare Rust local the collector cannot +/// see — so a collection during `make_iter_result` stored a from-space address +/// into the result object, which the spread/`Array.from` drain then read back. +/// +/// `first`/`second` name the two field slots in declaration order, since the +/// array iterator stores `{ value, done }` and the `node:sqlite` iterator +/// stores `{ done, value }`. +unsafe fn build_iter_result(first: (&[u8], JSValue), second: (&[u8], JSValue)) -> f64 { + let scope = crate::gc::RuntimeHandleScope::new(); + let first_h = scope.root_nanbox_u64(first.1.bits()); + let second_h = scope.root_nanbox_u64(second.1.bits()); + + let obj_h = scope.root_nanbox_f64(js_nanbox_pointer(js_object_alloc(0, 2) as i64)); + + // keys array so destructuring + property reads find named slots. + let first_key_h = scope.root_nanbox_u64( + JSValue::string_ptr(crate::string::js_string_from_bytes( + first.0.as_ptr(), + first.0.len() as u32, + )) + .bits(), + ); + let second_key_h = scope.root_nanbox_u64( + JSValue::string_ptr(crate::string::js_string_from_bytes( + second.0.as_ptr(), + second.0.len() as u32, + )) + .bits(), + ); + let keys_h = scope.root_nanbox_f64(js_nanbox_pointer(crate::array::js_array_alloc(2) as i64)); + let keys = || js_nanbox_get_pointer(keys_h.get_nanbox_f64()) as *mut ArrayHeader; + crate::array::js_array_push(keys(), JSValue::from_bits(first_key_h.get_nanbox_u64())); + crate::array::js_array_push(keys(), JSValue::from_bits(second_key_h.get_nanbox_u64())); + + let obj = || js_nanbox_get_pointer(obj_h.get_nanbox_f64()) as *mut ObjectHeader; + crate::object::js_object_set_keys(obj(), keys()); + js_object_set_field(obj(), 0, JSValue::from_bits(first_h.get_nanbox_u64())); + js_object_set_field(obj(), 1, JSValue::from_bits(second_h.get_nanbox_u64())); + js_nanbox_pointer(obj() as i64) +} + /// Build the `{ value, done }` iterator-result object. `value` arrives as /// a NaN-boxed JSValue; `done` is a JS boolean. unsafe fn make_iter_result(value: JSValue, done: bool) -> f64 { - let obj = js_object_alloc(0, 2); - - // keys array so destructuring + property reads find named slots. - let value_key = crate::string::js_string_from_bytes(b"value".as_ptr(), 5); - let done_key = crate::string::js_string_from_bytes(b"done".as_ptr(), 4); - let keys = crate::array::js_array_alloc(2); - crate::array::js_array_push(keys, JSValue::string_ptr(value_key)); - crate::array::js_array_push(keys, JSValue::string_ptr(done_key)); - crate::object::js_object_set_keys(obj, keys); - - js_object_set_field(obj, 0, value); - js_object_set_field(obj, 1, JSValue::bool(done)); - js_nanbox_pointer(obj as i64) + build_iter_result((b"value", value), (b"done", JSValue::bool(done))) } unsafe fn make_sqlite_iter_result(value: JSValue, done: bool) -> f64 { - let obj = js_object_alloc(0, 2); - let done_key = crate::string::js_string_from_bytes(b"done".as_ptr(), 4); - let value_key = crate::string::js_string_from_bytes(b"value".as_ptr(), 5); - let keys = crate::array::js_array_alloc(2); - crate::array::js_array_push(keys, JSValue::string_ptr(done_key)); - crate::array::js_array_push(keys, JSValue::string_ptr(value_key)); - crate::object::js_object_set_keys(obj, keys); - js_object_set_field(obj, 0, JSValue::bool(done)); - js_object_set_field(obj, 1, value); - js_nanbox_pointer(obj as i64) + build_iter_result((b"done", JSValue::bool(done)), (b"value", value)) } unsafe fn make_pair_array(idx: u32, value: f64) -> f64 { @@ -628,6 +655,14 @@ pub unsafe fn dispatch_array_iterator_method( // subsequent `.next()` call sees the bumped index. js_object_set_field(iter_obj, 1, JSValue::number((idx + 1) as f64)); + // #7475: the cursor store above can allocate (shape transition / + // storage growth), so `arr_ptr` — read before it — may now name + // from-space. Re-derive the backing array from the iterator's + // field 0, which the collector DOES rewrite, instead of reusing + // the pre-store copy. + let arr_ptr = js_nanbox_get_pointer(f64::from_bits( + js_object_get_field(iter_obj, 0).bits(), + )) as *const ArrayHeader; let elem = if arr_ptr.is_null() { f64::from_bits(TAG_UNDEFINED) } else { diff --git a/crates/perry-runtime/src/array/iterator.rs b/crates/perry-runtime/src/array/iterator.rs index 5cf1fa3e9a..976d9596c2 100644 --- a/crates/perry-runtime/src/array/iterator.rs +++ b/crates/perry-runtime/src/array/iterator.rs @@ -1123,15 +1123,41 @@ pub extern "C" fn js_iterator_to_array(iter_f64: f64) -> *mut ArrayHeader { use crate::string::js_string_from_bytes; use crate::value::{js_nanbox_get_pointer, TAG_UNDEFINED}; + // #7475: EVERY value this loop carries across a `.next()` call is a + // GC-managed object, and `.next()` allocates the `{ value, done }` result + // — so any of the four can be moved by the copying minor that allocation + // triggers. Before this scope they lived in bare Rust locals, which the + // collector cannot see and therefore never rewrites: + // + // * the iterator object itself. A moved iterator leaves the pre-move + // copy in retired from-space; the next `.next()` dispatch reads its + // STALE field 0 (an array iterator's backing array), and + // `dispatch_array_iterator_method` then calls `js_array_length` on a + // from-space address. That is the exact fault + // `PERRY_GC_PROTECT_FROMSPACE=1` reports for `[...arr]` at scale. + // * the accumulator array, re-read on every push. + // * the `next` closure (non-movable, but sweepable while unreferenced). + // * the two interned property keys. + // + // The per-iteration result object gets ONE reusable scratch slot rather + // than a fresh handle each turn — the loop runs up to 100k times and a + // push-per-iteration would grow the handle stack without bound. + // + // Every handle here is NaN-boxed rather than `root_raw_*_ptr`, so reading + // one back is a `get_nanbox_f64` at the point of use and the module stays + // out of `scripts/raw_handle_debt.py`'s ledger. + let scope = crate::gc::RuntimeHandleScope::new(); + let arr = js_array_alloc(8); // start with capacity 8 + let result_h = scope.root_nanbox_f64(crate::value::js_nanbox_pointer(arr as i64)); + let read_result = || js_nanbox_get_pointer(result_h.get_nanbox_f64()) as *mut ArrayHeader; // Get the iterator object pointer - let _iter_bits = iter_f64.to_bits(); let iter_ptr = js_nanbox_get_pointer(iter_f64); if iter_ptr == 0 { - return arr; + return read_result(); } - let _iter_obj = iter_ptr as *const ObjectHeader; + let iter_h = scope.root_nanbox_f64(iter_f64); // Look up the "next" method on the iterator object as a stored closure // FIELD (the common case for generator objects / effect's `SingleShotGen`, @@ -1155,19 +1181,31 @@ pub extern "C" fn js_iterator_to_array(iter_f64: f64) -> *mut ArrayHeader { // a `next` closure field, so the field lookup above misses. Fall back to a // method-call dispatch in that case instead of bailing with an empty array. let use_method_dispatch = next_ptr.is_null(); + // `next_f64` is already the NaN-boxed closure value (or `undefined`, which + // the root scanner ignores), so it roots directly. + let next_h = scope.root_nanbox_f64(next_f64); // Iterate: call next() until done - let done_key = js_string_from_bytes(b"done".as_ptr(), 4); - let value_key = js_string_from_bytes(b"value".as_ptr(), 5); - let mut result = arr; + let done_key_h = scope.root_nanbox_f64(nanbox_string_key(js_string_from_bytes( + b"done".as_ptr(), + 4, + ))); + let value_key_h = scope.root_nanbox_f64(nanbox_string_key(js_string_from_bytes( + b"value".as_ptr(), + 5, + ))); + // Reusable scratch slot for the `{ value, done }` object `.next()` returns. + let step_h = scope.root_nanbox_f64(f64::from_bits(TAG_UNDEFINED)); for _ in 0..100_000 { // safety limit // Call next() — stored-closure fast path, or class-id method dispatch. + // Both addresses are read fresh from their roots at the callsite: the + // previous iteration's `.next()` may have moved either one. let result_f64 = if use_method_dispatch { unsafe { crate::object::js_native_call_method( - iter_f64, + iter_h.get_nanbox_f64(), b"next".as_ptr() as *const i8, 4, std::ptr::null(), @@ -1175,7 +1213,10 @@ pub extern "C" fn js_iterator_to_array(iter_f64: f64) -> *mut ArrayHeader { ) } } else { - closure::js_closure_call1(next_ptr, f64::from_bits(TAG_UNDEFINED)) + closure::js_closure_call1( + js_nanbox_get_pointer(next_h.get_nanbox_f64()) as *const closure::ClosureHeader, + f64::from_bits(TAG_UNDEFINED), + ) }; // IteratorNext (ECMA-262 §7.4.2 step 3): if Type(result) is not // Object, throw a TypeError. `is_pointer()` is true only for @@ -1189,11 +1230,19 @@ pub extern "C" fn js_iterator_to_array(iter_f64: f64) -> *mut ArrayHeader { if !result_is_object { throw_iterator_result_not_object(); } - let result_ptr = js_nanbox_get_pointer(result_f64); - let result_obj = result_ptr as *const ObjectHeader; + // Root the result object before touching it: the two field reads below + // can allocate (key interning / shape lookup), and the push certainly + // can. + step_h.set_nanbox_f64(result_f64); + let result_obj = js_nanbox_get_pointer(result_f64) as *const ObjectHeader; // Check .done - let done_val = js_object_get_field_by_name(result_obj, done_key); + let (done_val, result_obj) = step_h.across_const::(|| { + js_object_get_field_by_name( + result_obj, + js_nanbox_get_pointer(done_key_h.get_nanbox_f64()) as *const crate::StringHeader, + ) + }); let done_bits = unsafe { std::mem::transmute::<_, u64>(done_val) }; // done is true when it's TAG_TRUE (0x7FFC_0000_0000_0004) or truthy number if done_bits == 0x7FFC_0000_0000_0004 { @@ -1201,12 +1250,24 @@ pub extern "C" fn js_iterator_to_array(iter_f64: f64) -> *mut ArrayHeader { } // TAG_TRUE // Get .value and push to array - let val = js_object_get_field_by_name(result_obj, value_key); + let val = js_object_get_field_by_name( + result_obj, + js_nanbox_get_pointer(value_key_h.get_nanbox_f64()) as *const crate::StringHeader, + ); let val_f64 = unsafe { f64::from_bits(std::mem::transmute::<_, u64>(val)) }; - result = js_array_push_f64(result, val_f64); + let pushed = js_array_push_f64(read_result(), val_f64); + result_h.set_nanbox_f64(crate::value::js_nanbox_pointer(pushed as i64)); } - result + read_result() +} + +/// NaN-box a `StringHeader*` so it can live in a `RuntimeHandleScope` slot as +/// an ordinary `Nanbox` root (marked AND rewritten) instead of a `RawTagged` +/// one that would have to be read back through `get_raw_const_ptr`. +#[inline] +fn nanbox_string_key(ptr: *mut crate::StringHeader) -> f64 { + f64::from_bits(crate::value::JSValue::string_ptr(ptr).bits()) } /// `BindingRestElement` / `AssignmentRestElement` iterator drain for From ef5d1d1ec058af35348dcf1416a5c38510f8b721 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 6 Aug 2026 09:58:23 +0200 Subject: [PATCH 2/7] test(gc): witness + auto-optimize gate for the iterator-drain rooting bug (#7475) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `test_gap_gc_iterator_drain_rooting.ts` mirrors the app-pattern kernel the bug was found in. Measured: `TypeError: next is not a function` before the fix under BOTH the default and the auto-optimize link, a from-space FAULT under `PERRY_GC_PROTECT_FROMSPACE=1`, byte-exact with the oracle after. Registered in `test-parity/gc_repsel_corpus.txt`, so `gc-moving-witnesses` runs it and rejects a cell where nothing moved. `scripts/auto_opt_app_patterns.sh` + `auto-opt-app-patterns.yml` close the blind spot that let this ship: every other gate sets `PERRY_NO_AUTO_OPTIMIZE=1` for a deterministic link, so the default path — which rebuilds the runtime with a per-app feature set and links it over PERRY_RUNTIME_DIR — was tested by nothing. The gate asserts its subject was live: it reads the linker command line out of `perry -v` and requires a `perry-auto-*/libperry_runtime.a` that exists on disk, because the auto-optimizer falls back to the prebuilt archives by design and a fallback run would pass every output comparison while exercising the wrong binary. Also fixes a handle-kind mismatch in the iterator drain: `across_const` panics on a NaN-boxed handle, so the `.done` read uses `across_nanbox`. Claude-Session: https://claude.ai/code/session_019EHcmXKArA7m42SihYCcgH --- .github/workflows/auto-opt-app-patterns.yml | 156 +++++++++++++ crates/perry-runtime/src/array/iter_object.rs | 6 +- crates/perry-runtime/src/array/iterator.rs | 13 +- scripts/auto_opt_app_patterns.sh | 205 ++++++++++++++++++ .../test_gap_gc_iterator_drain_rooting.ts | 84 +++++++ test-parity/gc_repsel_corpus.txt | 28 +++ 6 files changed, 483 insertions(+), 9 deletions(-) create mode 100644 .github/workflows/auto-opt-app-patterns.yml create mode 100755 scripts/auto_opt_app_patterns.sh create mode 100644 test-files/test_gap_gc_iterator_drain_rooting.ts diff --git a/.github/workflows/auto-opt-app-patterns.yml b/.github/workflows/auto-opt-app-patterns.yml new file mode 100644 index 0000000000..ee4ad7d198 --- /dev/null +++ b/.github/workflows/auto-opt-app-patterns.yml @@ -0,0 +1,156 @@ +name: Auto-Optimize App Patterns + +# Runs the `benchmarks/app-patterns` kernels through the AUTO-OPTIMIZE link — +# the default path, and the one no other gate in this repo covers. +# +# WHY THIS EXISTS (#7475) +# +# `perry file.ts -o out` rebuilds perry-runtime + perry-stdlib with a per-app +# Cargo feature set into `target/perry-auto-/` and links those archives +# OVER whatever `PERRY_RUNTIME_DIR` points at. Almost every other gate sets +# `PERRY_NO_AUTO_OPTIMIZE=1` for a deterministic link — gc-ratchet says so +# inline, and so do a dozen `crates/perry/tests` cases — so the binary users +# actually get was, until this job, tested by nothing. +# +# #7475 is what that cost. `object_deep_clone` threw `TypeError: next is not a +# function` under auto-optimize and printed the correct checksum under +# `PERRY_NO_AUTO_OPTIMIZE=1`. The bug was in neither the kernel nor the feature +# set: the iterator drain held live values in bare Rust locals across an +# allocating `.next()`. BOTH links had the defect — +# `PERRY_GC_PROTECT_FROMSPACE=1` faults on both — but only the feature-stripped +# one allocated in the order that made the stale read observable. That is the +# general shape: a latent stale-root read is invisible until something perturbs +# allocation timing, and the auto-optimize link perturbs it per app. +# +# CHECKED AGAINST CLAUDE.md's FOUR WAYS A GATE CAN BE UNABLE TO FAIL +# +# 1. no `continue-on-error`, no `|| true`, no pipe swallowing the script's +# exit status; +# 2. NOT in branch protection's required contexts yet, deliberately — a new +# gate has never been green, so promoting it on day one blocks every open +# PR. Promote after the first green run on `main`; leaving that undone is +# itself hazard 2 (see `gc-root-dominance`); +# 3. `concurrency` cancels pull-request runs only; push runs are keyed on the +# commit so queued `main` runs cannot cancel each other (#7205); +# 4. the subject is ASSERTED live. `scripts/auto_opt_app_patterns.sh` reads +# the linker command line out of `perry -v` and requires it to name a +# `perry-auto-*/…/libperry_runtime.a` that exists on disk. A run in which +# the auto-optimizer quietly fell back to the prebuilt archives — which it +# does, by design, whenever the cargo rebuild fails — would otherwise pass +# every output comparison while testing the exact configuration this job +# does not care about. +# +# The one skip (`promise_all_chains`) is named with a reason inside the script, +# and a skip entry that matches no kernel FAILS, so it cannot outlive its fix. + +on: + pull_request: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: auto-opt-app-patterns-${{ github.event_name }}-${{ github.event_name == 'push' && github.sha || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +env: + CARGO_TERM_COLOR: always + +jobs: + auto-opt-app-patterns: + runs-on: ubuntu-latest + # The auto-optimize rebuild is a second full release build of + # perry-runtime + perry-stdlib on top of the workspace build, so this is a + # long job even with a warm cargo cache. + timeout-minutes: 90 + permissions: + contents: read + pull-requests: read + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Decide whether this change can affect a compiled app + id: relevance + env: + GH_TOKEN: ${{ github.token }} + EVENT_NAME: ${{ github.event_name }} + REPOSITORY: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + set -euo pipefail + if [[ "$EVENT_NAME" != "pull_request" ]]; then + echo "run=true" >> "$GITHUB_OUTPUT" + echo "Not a pull request; running the kernels." + exit 0 + fi + gh api "repos/$REPOSITORY/pulls/$PR_NUMBER/files" --paginate --jq '.[].filename' > changed.txt + # Deliberately broad: anything under crates/ changes the compiled + # binary, and the auto-optimize feature selection reads the manifests. + # The filter only spares docs-only PRs a compiler build; `set -e` + # already aborted if the listing failed, so this cannot silently fall + # through to "not relevant". + if grep -qE '^(crates/|benchmarks/app-patterns/|scripts/auto_opt_app_patterns\.sh$|Cargo\.(toml|lock)$|\.node-version$|\.github/workflows/auto-opt-app-patterns\.yml$)' changed.txt; then + echo "run=true" >> "$GITHUB_OUTPUT" + echo "Change can affect a compiled app; running the kernels." + else + echo "run=false" >> "$GITHUB_OUTPUT" + echo "No app-affecting paths changed." + fi + + - name: Install Rust toolchain + if: steps.relevance.outputs.run == 'true' + uses: dtolnay/rust-toolchain@stable + - uses: ./.github/actions/setup-llvm22 + + - uses: Swatinem/rust-cache@v2 + if: steps.relevance.outputs.run == 'true' + with: + shared-key: "${{ runner.os }}-perry" + save-if: ${{ github.ref == 'refs/heads/main' }} + + - name: Install clang + if: steps.relevance.outputs.run == 'true' + run: | + sudo apt-get update + sudo apt-get install -y clang + + - name: Setup Node oracle + if: steps.relevance.outputs.run == 'true' + uses: actions/setup-node@v7 + with: + # Single source of truth: .node-version. Every kernel's stdout is + # diffed against this node, so the pin is a correctness input. + node-version-file: .node-version + + - name: Build perry and the prebuilt runtime archives + if: steps.relevance.outputs.run == 'true' + env: + CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUSTFLAGS: "-C linker-features=-lld" + run: | + set -euo pipefail + # The auto-optimize path builds its OWN archives, but the driver still + # needs the prebuilt ones on disk for its fallback probe — and they are + # what the failure mode under test silently substitutes, so a run + # without them could not distinguish the two. perry-runtime and + # perry-stdlib are rlib-only; the `.a`s come from the -static wrappers. + cargo build --release \ + -p perry -p perry-runtime -p perry-stdlib \ + -p perry-runtime-static -p perry-stdlib-static + for artifact in perry libperry_runtime.a libperry_stdlib.a; do + test -s "target/release/$artifact" \ + || { echo "::error::target/release/$artifact was not produced"; exit 1; } + done + + # GATING. No pipe, no `|| true`: this step's exit status IS the gate. The + # liveness assertion (the link line must name a perry-auto archive) lives + # inside the script so a local run gets it too. + - name: Run the app-pattern kernels through the auto-optimize link + if: steps.relevance.outputs.run == 'true' + env: + PERRY_RUNTIME_DIR: ${{ github.workspace }}/target/release + run: ./scripts/auto_opt_app_patterns.sh diff --git a/crates/perry-runtime/src/array/iter_object.rs b/crates/perry-runtime/src/array/iter_object.rs index 5bb98276d5..302f76b2af 100644 --- a/crates/perry-runtime/src/array/iter_object.rs +++ b/crates/perry-runtime/src/array/iter_object.rs @@ -660,9 +660,9 @@ pub unsafe fn dispatch_array_iterator_method( // from-space. Re-derive the backing array from the iterator's // field 0, which the collector DOES rewrite, instead of reusing // the pre-store copy. - let arr_ptr = js_nanbox_get_pointer(f64::from_bits( - js_object_get_field(iter_obj, 0).bits(), - )) as *const ArrayHeader; + let arr_ptr = + js_nanbox_get_pointer(f64::from_bits(js_object_get_field(iter_obj, 0).bits())) + as *const ArrayHeader; let elem = if arr_ptr.is_null() { f64::from_bits(TAG_UNDEFINED) } else { diff --git a/crates/perry-runtime/src/array/iterator.rs b/crates/perry-runtime/src/array/iterator.rs index 976d9596c2..84e43ad6eb 100644 --- a/crates/perry-runtime/src/array/iterator.rs +++ b/crates/perry-runtime/src/array/iterator.rs @@ -1186,10 +1186,8 @@ pub extern "C" fn js_iterator_to_array(iter_f64: f64) -> *mut ArrayHeader { let next_h = scope.root_nanbox_f64(next_f64); // Iterate: call next() until done - let done_key_h = scope.root_nanbox_f64(nanbox_string_key(js_string_from_bytes( - b"done".as_ptr(), - 4, - ))); + let done_key_h = + scope.root_nanbox_f64(nanbox_string_key(js_string_from_bytes(b"done".as_ptr(), 4))); let value_key_h = scope.root_nanbox_f64(nanbox_string_key(js_string_from_bytes( b"value".as_ptr(), 5, @@ -1236,13 +1234,16 @@ pub extern "C" fn js_iterator_to_array(iter_f64: f64) -> *mut ArrayHeader { step_h.set_nanbox_f64(result_f64); let result_obj = js_nanbox_get_pointer(result_f64) as *const ObjectHeader; - // Check .done - let (done_val, result_obj) = step_h.across_const::(|| { + // Check .done. `across_nanbox` runs the (allocating) read and hands + // back the POST-collection address of the result object, so the pre- + // call copy is never nameable afterwards. + let (done_val, result_after) = step_h.across_nanbox(|| { js_object_get_field_by_name( result_obj, js_nanbox_get_pointer(done_key_h.get_nanbox_f64()) as *const crate::StringHeader, ) }); + let result_obj = js_nanbox_get_pointer(result_after) as *const ObjectHeader; let done_bits = unsafe { std::mem::transmute::<_, u64>(done_val) }; // done is true when it's TAG_TRUE (0x7FFC_0000_0000_0004) or truthy number if done_bits == 0x7FFC_0000_0000_0004 { diff --git a/scripts/auto_opt_app_patterns.sh b/scripts/auto_opt_app_patterns.sh new file mode 100755 index 0000000000..05b69baa3c --- /dev/null +++ b/scripts/auto_opt_app_patterns.sh @@ -0,0 +1,205 @@ +#!/usr/bin/env bash +# Compile the `benchmarks/app-patterns` kernels through the AUTO-OPTIMIZE path +# and diff their output against the pinned Node oracle. +# +# WHY THIS EXISTS (#7475) +# +# The auto-optimize path — the default, i.e. exactly how `perry file.ts -o out` +# behaves and how the benchmark harness invokes it — rebuilds perry-runtime and +# perry-stdlib with a per-app Cargo feature set into `target/perry-auto-/` +# and links THOSE archives over whatever `PERRY_RUNTIME_DIR` points at. That is +# a different binary from the one every other gate in this repo tests: +# `gc-ratchet`, the gap suite's hand-rolled probes and most `crates/perry/tests` +# cases all set `PERRY_NO_AUTO_OPTIMIZE=1` for a deterministic link. +# +# #7475 is what that blind spot costs. `object_deep_clone` threw +# `TypeError: next is not a function` under auto-optimize while printing the +# correct checksum under `PERRY_NO_AUTO_OPTIMIZE=1`. The defect was in neither +# the kernel nor the feature set: `js_iterator_to_array` held the iterator, the +# accumulator array and the result object in bare Rust locals across a `.next()` +# call that allocates, so a copying minor moved them and left the pre-move +# addresses behind. Both builds had it — `PERRY_GC_PROTECT_FROMSPACE=1` faults +# on both — but only the feature-stripped one allocated in the order that made +# the stale read observable. A latent stale-root read is invisible until some +# unrelated change perturbs allocation timing, and the auto-optimize link +# perturbs it on every app. +# +# THIS SCRIPT IS DESIGNED TO BE ABLE TO FAIL. In particular it ASSERTS ITS +# SUBJECT WAS LIVE rather than assuming it: a run in which the auto-optimizer +# silently fell back to the prebuilt archives would exercise nothing this gate +# exists for while passing every output comparison. So for each kernel it +# requires the linker command line (from `perry -v`) to name a +# `perry-auto-*/…/libperry_runtime.a` that exists on disk. No archive, no pass. +# +# Usage: +# scripts/auto_opt_app_patterns.sh # every kernel except the skips +# scripts/auto_opt_app_patterns.sh promise_all_chains # named kernels only +# +# Environment: +# PERRY_BIN perry binary to test (default: target/release/perry) +# NODE_BIN node oracle (default: node) + +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +PERRY_BIN="${PERRY_BIN:-$ROOT/target/release/perry}" +NODE_BIN="${NODE_BIN:-node}" +KERNEL_DIR="$ROOT/benchmarks/app-patterns/kernels" + +# `PERRY_NO_AUTO_OPTIMIZE` would defeat the entire point of this gate, and it is +# set by several sibling scripts and CI jobs. Refuse rather than inherit it. +if [[ -n "${PERRY_NO_AUTO_OPTIMIZE:-}" ]]; then + echo "error: PERRY_NO_AUTO_OPTIMIZE is set; this gate tests the auto-optimize path." >&2 + exit 2 +fi + +# Kernels excluded from the gate, one `name:reason` per line. An entry that +# names a kernel which no longer exists FAILS below — the same rule +# `scripts/gc_root_dominance_allowlist.json` uses, so a fixed kernel cannot keep +# its exemption by inertia. +SKIPS=( + # #7475: fails under BOTH link modes, differently ("Uncaught (in promise) 0" + # with PERRY_NO_AUTO_OPTIMIZE=1, a rooting-shaped TypeError without). The + # promise-rejection half is a separate defect from the iterator-drain rooting + # bug this gate was created for; it is tracked on #7475 and this line comes + # out with it. + "promise_all_chains:pre-existing promise-rejection failure, #7475" +) + +if [[ ! -x "$PERRY_BIN" ]]; then + echo "error: perry binary not found at $PERRY_BIN (set PERRY_BIN)" >&2 + exit 2 +fi +if ! command -v "$NODE_BIN" >/dev/null 2>&1; then + echo "error: node oracle '$NODE_BIN' not found (set NODE_BIN)" >&2 + exit 2 +fi + +all_kernels=() +for f in "$KERNEL_DIR"/*.ts; do + all_kernels+=("$(basename "$f" .ts)") +done +if [[ ${#all_kernels[@]} -eq 0 ]]; then + echo "error: no kernels found in $KERNEL_DIR" >&2 + exit 2 +fi + +skip_names=() +for entry in "${SKIPS[@]}"; do + skip_names+=("${entry%%:*}") +done + +# A skip entry that matches nothing is a failure, not a no-op. +rotted=0 +for name in "${skip_names[@]}"; do + found=0 + for k in "${all_kernels[@]}"; do + [[ "$k" == "$name" ]] && found=1 + done + if [[ $found -eq 0 ]]; then + echo "error: skip entry '$name' matches no kernel in $KERNEL_DIR — delete its line." >&2 + rotted=1 + fi +done +[[ $rotted -eq 0 ]] || exit 1 + +requested=("$@") +selected=() +if [[ ${#requested[@]} -gt 0 ]]; then + selected=("${requested[@]}") +else + for k in "${all_kernels[@]}"; do + skip=0 + for name in "${skip_names[@]}"; do + [[ "$k" == "$name" ]] && skip=1 + done + [[ $skip -eq 1 ]] || selected+=("$k") + done +fi + +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +failures=0 +echo "auto-optimize app-pattern gate: ${#selected[@]} kernel(s), perry=$PERRY_BIN" +echo + +for name in "${selected[@]}"; do + src="$KERNEL_DIR/$name.ts" + if [[ ! -f "$src" ]]; then + echo "FAIL $name — no such kernel at $src" + failures=$((failures + 1)) + continue + fi + + out="$WORK/$name" + log="$WORK/$name.compile.log" + # `-v` makes the driver echo the linker command line, which is the only + # first-hand evidence of WHICH runtime archive the binary actually links. + if ! "$PERRY_BIN" -v "$src" -o "$out" >"$log" 2>&1; then + echo "FAIL $name — compile failed:" + sed 's/^/ /' "$log" | tail -30 + failures=$((failures + 1)) + continue + fi + + # LIVENESS. Pull the auto-optimize archive out of the linker invocation, not + # out of a status message: a message can be printed by a path that then falls + # back, the link line cannot. + archive="$(grep -o '[^ ]*perry-auto-[^ ]*libperry_runtime\.a' "$log" | head -1 || true)" + if [[ -z "$archive" ]]; then + echo "FAIL $name — the link line names no perry-auto-*/libperry_runtime.a." + echo " The auto-optimizer fell back to a prebuilt archive, so this run" + echo " exercised nothing this gate exists for. Compile log tail:" + grep -E 'auto-optimize|\[link\] invoking' "$log" | sed 's/^/ /' | tail -10 + failures=$((failures + 1)) + continue + fi + if [[ ! -s "$archive" ]]; then + echo "FAIL $name — link named $archive but it is missing or empty." + failures=$((failures + 1)) + continue + fi + + actual="$("$out" 2>&1)" && perry_status=0 || perry_status=$? + expected="$("$NODE_BIN" --experimental-strip-types "$src" 2>&1)" && node_status=0 || node_status=$? + + if [[ $node_status -ne 0 ]]; then + echo "FAIL $name — the NODE oracle exited $node_status; the comparison would be" + echo " vacuous. Check the pinned node in .node-version." + echo "$expected" | sed 's/^/ /' | tail -10 + failures=$((failures + 1)) + continue + fi + if [[ $perry_status -ne 0 ]]; then + echo "FAIL $name — the compiled binary exited $perry_status" + echo "$actual" | sed 's/^/ /' | tail -10 + failures=$((failures + 1)) + continue + fi + if [[ "$actual" != "$expected" ]]; then + echo "FAIL $name — output differs from node" + echo " perry: $actual" + echo " node : $expected" + failures=$((failures + 1)) + continue + fi + + echo "PASS $name ($(basename "$(dirname "$(dirname "$archive")")"))" +done + +echo +for entry in "${SKIPS[@]}"; do + echo "SKIP ${entry%%:*} — ${entry#*:}" +done + +if [[ $failures -gt 0 ]]; then + echo + echo "$failures kernel(s) failed under the auto-optimize link." + exit 1 +fi +echo +echo "OK: every selected kernel linked a freshly built perry-auto runtime archive" +echo " and matched the node oracle byte for byte." diff --git a/test-files/test_gap_gc_iterator_drain_rooting.ts b/test-files/test_gap_gc_iterator_drain_rooting.ts new file mode 100644 index 0000000000..7250127304 --- /dev/null +++ b/test-files/test_gap_gc_iterator_drain_rooting.ts @@ -0,0 +1,84 @@ +// #7475: `{ tags: [...o.meta.tags] }` drains the array iterator through +// `js_iterator_to_array`, and EVERY value that loop carried across a `.next()` +// call used to live in a bare Rust local: +// +// * the iterator object itself, +// * the accumulator array it is filling, +// * the `next` closure, and the `"done"` / `"value"` key strings, +// * and one level down, the element `value` and the freshly allocated +// `{ value, done }` object inside `make_iter_result`. +// +// `.next()` allocates that result object on every step, so any of those +// allocations can trigger the copying minor. It moves the values and rewrites +// only the slots it can see; a bare Rust local is not one. A moved iterator +// leaves its pre-move copy in retired from-space, and the next dispatch reads +// THAT copy's field 0 — the backing array — so +// `dispatch_array_iterator_method` called `js_array_length` on a from-space +// address. `PERRY_GC_PROTECT_FROMSPACE=1` faults on it precisely. +// +// STRUCTURE IS LOAD-BEARING, so this file deliberately mirrors +// `benchmarks/app-patterns/kernels/object_deep_clone.ts` where it was found +// rather than reducing further. `deepClone`'s size is what keeps it out of +// line, and an out-of-line spread behind two property hops is the lowering +// that calls `js_iterator_to_array` directly. Shrink the function and the +// spread re-routes through `array_from_spread_value` instead, which has its +// own separate stale-root defect (reported on #7475) and would make this file +// red for the wrong reason. +// +// LIVE BY CONSTRUCTION AND ONLY WHEN SOMETHING MOVES. A non-moving collection +// cannot expose it — the stale address still names the same bytes — which is +// why it surfaced first through the auto-optimize link, whose feature-stripped +// runtime shifts allocation timing enough to make the stale read observable. + +const N = 50_000; + +interface Inner { + code: string; + weight: number; +} + +interface Item { + id: number; + name: string; + meta: { created: string; updated: string; tags: string[] }; + inners: Inner[]; +} + +const proto: Item = { + id: 0, + name: "item", + meta: { created: "2026-01-01", updated: "2026-05-09", tags: ["a", "b", "c"] }, + inners: [ + { code: "x1", weight: 0.5 }, + { code: "x2", weight: 1.5 }, + { code: "x3", weight: 2.5 }, + ], +}; + +function deepClone(o: Item): Item { + return { + id: o.id, + name: o.name, + meta: { + created: o.meta.created, + updated: o.meta.updated, + tags: [...o.meta.tags], + }, + inners: o.inners.map((x) => ({ code: x.code, weight: x.weight })), + }; +} + +let totalIds = 0; +let badTagLen = 0; +let badTagVal = 0; +for (let i = 0; i < N; i++) { + proto.id = i; + const clone = deepClone(proto); + totalIds += clone.id; + if (clone.meta.tags.length !== 3) { + badTagLen++; + } else if (clone.meta.tags[2] !== "c") { + badTagVal++; + } +} +console.log("checksum:", totalIds, "badLen", badTagLen, "badVal", badTagVal); diff --git a/test-parity/gc_repsel_corpus.txt b/test-parity/gc_repsel_corpus.txt index 029a52496d..7698aa455e 100644 --- a/test-parity/gc_repsel_corpus.txt +++ b/test-parity/gc_repsel_corpus.txt @@ -561,3 +561,31 @@ test_gap_gc_dynamic_construct_receiver_rooting # `PROTECT_FROMSPACE=1 DEPTH=800 POLLS=1` (no zeal): 10/10 FAULT before, # 40/40 clean after. test_gap_gc_optional_param_receiver_rooting + +# --- #7475: the array-iterator drain (`[...obj.arr]` / Array.from) ---------- +# +# The FIRST witness in this corpus that fails on the SHIPPED DEFAULT, not only +# on a `requires=move` arm. It is registered here anyway, because the moving +# arms are what make its failure a fault instead of a wrong answer. +# +# `js_iterator_to_array` held five live values in bare Rust locals across a +# `.next()` call that allocates the `{ value, done }` object: the iterator, the +# accumulator array, the `next` closure, and the two key strings — and one +# level down `make_iter_result` held the element value and its own freshly +# allocated result object across four more allocations. A copying minor moved +# them and rewrote only the slots it can see. The iterator's pre-move copy then +# handed `dispatch_array_iterator_method` a from-space backing array, which is +# where `js_array_length` faulted. +# +# Found through the auto-optimize link (#7475): `object_deep_clone` threw +# `TypeError: next is not a function` there while printing the right checksum +# under `PERRY_NO_AUTO_OPTIMIZE=1`. The feature-stripped runtime did not have a +# different bug — `PERRY_GC_PROTECT_FROMSPACE=1` faults on BOTH links, at the +# same minor #0 — it allocated in the order that made the stale read visible. +# +# Measured on this branch: +# before, default link + default env TypeError: next is not a function +# before, auto-optimize link TypeError: next is not a function +# before, PROTECT_FROMSPACE=1 DEPTH=200 FAULT in retired from-space, both links +# after, both links byte-exact with the oracle +test_gap_gc_iterator_drain_rooting From 0163418a4665729479b193f9fbc3d7d2f02fb83e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 6 Aug 2026 10:03:35 +0200 Subject: [PATCH 3/7] docs(changelog): fragment for #7495 Claude-Session: https://claude.ai/code/session_019EHcmXKArA7m42SihYCcgH --- changelog.d/7495-auto-opt-archive.md | 58 ++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 changelog.d/7495-auto-opt-archive.md diff --git a/changelog.d/7495-auto-opt-archive.md b/changelog.d/7495-auto-opt-archive.md new file mode 100644 index 0000000000..de4acfed8c --- /dev/null +++ b/changelog.d/7495-auto-opt-archive.md @@ -0,0 +1,58 @@ +### Fixed + +**`[...iterable]` / `Array.from` could read a from-space object after a copying minor (#7475).** + +`js_iterator_to_array` — the drain behind array spread and `Array.from` — held +five live GC values in bare Rust locals across a `.next()` call that allocates +the `{ value, done }` result: the iterator object, the accumulator array, the +`next` closure, and the two property keys. One level down, `make_iter_result` +held the caller's element value and its own freshly allocated result object +across four more allocations before storing them, and +`dispatch_array_iterator_method` re-used a backing-array pointer read *before* +its cursor store, which can allocate. + +Any of those allocations can trigger the copying minor. It moves the values and +rewrites only the slots it can see; a bare Rust local is not one. A moved +iterator leaves its pre-move copy in retired from-space, and the next dispatch +reads THAT copy's field 0 — so `dispatch_array_iterator_method` called +`js_array_length` on a from-space address, surfacing as +`TypeError: next is not a function`. + +Everything is now rooted in a `RuntimeHandleScope` and every address is re-read +at its point of use. The per-iteration result object gets one reusable scratch +slot rather than a fresh handle per turn, since the loop runs up to 100k times. +All handles are NaN-boxed rather than `root_raw_*_ptr`, so +`scripts/raw_handle_debt.py` is unchanged at 999. + +**Found through the auto-optimize link, but present in both.** The +`benchmarks/app-patterns` kernel `object_deep_clone` threw under the default +`perry file.ts -o out` and printed the right checksum under +`PERRY_NO_AUTO_OPTIMIZE=1`. That was an exposure difference, not a second bug: +`PERRY_GC_PROTECT_FROMSPACE=1` faults on both binaries at the same retiring +minor. Rebuilding the runtime archive one axis at a time showed the auto-opt +RUSTFLAGS (`-C panic=abort`) are irrelevant and the stripped feature set only +changes allocation timing enough to make the stale read observable. + +### Added + +**`auto-opt-app-patterns` gate.** Every other gate in the repo sets +`PERRY_NO_AUTO_OPTIMIZE=1` for a deterministic link, so the default path — which +rebuilds perry-runtime/perry-stdlib with a per-app Cargo feature set into +`target/perry-auto-/` and links those over `PERRY_RUNTIME_DIR` — was +covered by nothing. `scripts/auto_opt_app_patterns.sh` compiles the app-pattern +kernels through it and diffs each against the pinned Node oracle. + +It asserts its subject was live rather than assuming it: the linker command line +(from `perry -v`) must name a `perry-auto-*/…/libperry_runtime.a` that exists on +disk. The auto-optimizer falls back to the prebuilt archives by design when its +cargo rebuild fails, and such a run would pass every output comparison while +exercising the wrong binary. Its one skip (`promise_all_chains`, a separate +promise-rejection defect still open on #7475) carries a reason, and a skip entry +matching no kernel fails the script. + +Not yet in branch protection's required contexts — a new gate has never been +green; promote after its first green run on `main`. + +**`test-files/test_gap_gc_iterator_drain_rooting.ts`**, registered in +`test-parity/gc_repsel_corpus.txt` so `gc-moving-witnesses` runs it and refuses +a cell in which nothing moved. From 94464f4bcc8e3ac95f7bc7fc0ffd6387365dcd21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 6 Aug 2026 10:18:08 +0200 Subject: [PATCH 4/7] test(ci): prove the auto-optimize gate's liveness matcher can fail (#7475) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `--self-test` feeds `archive_from_log` three canned compile logs and asserts it accepts a real auto-optimize link line, rejects a run that printed `auto-optimize: built …` and then linked the PREBUILT archive (the driver's documented fallback when its cargo rebuild fails), and rejects an empty log. The middle case is not hypothetical: the first matcher grepped the whole log and accepted it, so the gate would have passed a run that exercised the wrong binary — the exact hazard the liveness assertion exists for. The matcher now reads only the `[link] invoking:` command line, and CI runs the self-test before the kernels. Also validated all twelve app-pattern kernels through the auto-optimize link on this branch: eleven PASS (each linking a freshly built `perry-auto-*` archive and matching the node oracle byte for byte), `promise_all_chains` is the one documented skip. Claude-Session: https://claude.ai/code/session_019EHcmXKArA7m42SihYCcgH --- .github/workflows/auto-opt-app-patterns.yml | 12 +++++ scripts/auto_opt_app_patterns.sh | 58 ++++++++++++++++++++- 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/.github/workflows/auto-opt-app-patterns.yml b/.github/workflows/auto-opt-app-patterns.yml index ee4ad7d198..f17c95d8b0 100644 --- a/.github/workflows/auto-opt-app-patterns.yml +++ b/.github/workflows/auto-opt-app-patterns.yml @@ -146,6 +146,18 @@ jobs: || { echo "::error::target/release/$artifact was not produced"; exit 1; } done + # GATING, and the reason hazard 4 is actually closed rather than asserted. + # The liveness check is a text matcher over the linker command line; a + # matcher that stops matching reports "no archive" (loud), but one that + # matches too much reports a PASS for a fallback run (silent). `--self-test` + # feeds it a canned log of exactly that shape — the `auto-optimize: built …` + # message followed by a link line naming the PREBUILT archive — and fails + # if it is accepted. It caught a real over-match while this gate was being + # written. + - name: Prove the liveness matcher can still fail + if: steps.relevance.outputs.run == 'true' + run: ./scripts/auto_opt_app_patterns.sh --self-test + # GATING. No pipe, no `|| true`: this step's exit status IS the gate. The # liveness assertion (the link line must name a perry-auto archive) lives # inside the script so a local run gets it too. diff --git a/scripts/auto_opt_app_patterns.sh b/scripts/auto_opt_app_patterns.sh index 05b69baa3c..45694fd7f0 100755 --- a/scripts/auto_opt_app_patterns.sh +++ b/scripts/auto_opt_app_patterns.sh @@ -68,6 +68,60 @@ SKIPS=( "promise_all_chains:pre-existing promise-rejection failure, #7475" ) +# LIVENESS MATCHER. Reads the archive the linker was actually handed out of a +# `perry -v` compile log. Deliberately reads the `[link] invoking:` command +# line and NOT the `auto-optimize: built …` status message: the driver prints +# that message and can still fall back afterwards, and the fallback is exactly +# what this gate must not mistake for a pass. +archive_from_log() { + grep '\[link\] invoking:' "$1" \ + | grep -o '[^ ]*perry-auto-[^ ]*libperry_runtime\.a' \ + | head -1 || true +} + +# Guard the matcher against its own regressions. A matcher that silently stops +# matching reports "no archive" forever (loud), but one that matches too much +# reports a pass for a fallback run (silent) — so both directions are asserted. +if [[ "${1:-}" == "--self-test" ]]; then + tmp="$(mktemp)" + trap 'rm -f "$tmp"' EXIT + fails=0 + + cat >"$tmp" <<'LOG' + auto-optimize: built /w/target/perry-auto-11d6bccda5436414/release/libperry_runtime.a (18.7 MB) +[link] invoking: cc -o /tmp/k /tmp/k.o /w/target/perry-auto-11d6bccda5436414/release/libperry_runtime.a -dead_strip +LOG + got="$(archive_from_log "$tmp")" + if [[ "$got" != "/w/target/perry-auto-11d6bccda5436414/release/libperry_runtime.a" ]]; then + echo "self-test FAILED: matcher missed a real auto-optimize link line (got '$got')" >&2 + fails=1 + fi + + # THE ONE THAT MATTERS. The driver announced the rebuild and then linked the + # PREBUILT archive anyway — its documented behaviour when the cargo rebuild + # fails. A matcher keyed on the message would call this a pass. + cat >"$tmp" <<'LOG' + auto-optimize: built /w/target/perry-auto-11d6bccda5436414/release/libperry_runtime.a (18.7 MB) + auto-optimize: cargo build failed (exit status: 101), using prebuilt libraries. +[link] invoking: cc -o /tmp/k /tmp/k.o /w/target/release/libperry_runtime.a -dead_strip +LOG + if [[ -n "$(archive_from_log "$tmp")" ]]; then + echo "self-test FAILED: matcher accepted a run that linked the PREBUILT archive" >&2 + fails=1 + fi + + : >"$tmp" + if [[ -n "$(archive_from_log "$tmp")" ]]; then + echo "self-test FAILED: matcher found an archive in an empty log" >&2 + fails=1 + fi + + [[ $fails -eq 0 ]] || exit 1 + echo "self-test ok: the liveness matcher accepts a real auto-optimize link," + echo " rejects a prebuilt-archive fallback, and rejects an empty log." + exit 0 +fi + if [[ ! -x "$PERRY_BIN" ]]; then echo "error: perry binary not found at $PERRY_BIN (set PERRY_BIN)" >&2 exit 2 @@ -147,8 +201,8 @@ for name in "${selected[@]}"; do # LIVENESS. Pull the auto-optimize archive out of the linker invocation, not # out of a status message: a message can be printed by a path that then falls - # back, the link line cannot. - archive="$(grep -o '[^ ]*perry-auto-[^ ]*libperry_runtime\.a' "$log" | head -1 || true)" + # back, the link line cannot. `--self-test` proves this can still fail. + archive="$(archive_from_log "$log")" if [[ -z "$archive" ]]; then echo "FAIL $name — the link line names no perry-auto-*/libperry_runtime.a." echo " The auto-optimizer fell back to a prebuilt archive, so this run" From 4060514da7770805f53f7ca69f172e4330426406 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 6 Aug 2026 10:20:10 +0200 Subject: [PATCH 5/7] docs: point the two residual #7475 defects at their own issues `promise_all_chains` (#7497) and the `array_from_spread_value` symbol-lookup stale deref (#7498) are separate defects from the iterator-drain rooting bug, and both are unchanged by it. Naming them individually keeps them out of a vague remainder on #7475. Claude-Session: https://claude.ai/code/session_019EHcmXKArA7m42SihYCcgH --- changelog.d/7495-auto-opt-archive.md | 11 ++++++++--- scripts/auto_opt_app_patterns.sh | 12 ++++++------ test-files/test_gap_gc_iterator_drain_rooting.ts | 4 ++-- 3 files changed, 16 insertions(+), 11 deletions(-) diff --git a/changelog.d/7495-auto-opt-archive.md b/changelog.d/7495-auto-opt-archive.md index de4acfed8c..abc9e050dc 100644 --- a/changelog.d/7495-auto-opt-archive.md +++ b/changelog.d/7495-auto-opt-archive.md @@ -31,7 +31,10 @@ All handles are NaN-boxed rather than `root_raw_*_ptr`, so `PERRY_GC_PROTECT_FROMSPACE=1` faults on both binaries at the same retiring minor. Rebuilding the runtime archive one axis at a time showed the auto-opt RUSTFLAGS (`-C panic=abort`) are irrelevant and the stripped feature set only -changes allocation timing enough to make the stale read observable. +changes allocation timing enough to make the stale read observable. So this was +a latent correctness bug for **every** user of array spread / `Array.from`, not +an auto-optimize-only one — the auto-optimize link was the trigger that made it +visible. ### Added @@ -47,8 +50,10 @@ It asserts its subject was live rather than assuming it: the linker command line disk. The auto-optimizer falls back to the prebuilt archives by design when its cargo rebuild fails, and such a run would pass every output comparison while exercising the wrong binary. Its one skip (`promise_all_chains`, a separate -promise-rejection defect still open on #7475) carries a reason, and a skip entry -matching no kernel fails the script. +promise-rejection defect tracked as #7497) carries a reason, and a skip entry +matching no kernel fails the script. The remaining eleven kernels pass. A +`--self-test` mode proves the liveness matcher can still fail; it caught a real +over-match while the gate was being written. Not yet in branch protection's required contexts — a new gate has never been green; promote after its first green run on `main`. diff --git a/scripts/auto_opt_app_patterns.sh b/scripts/auto_opt_app_patterns.sh index 45694fd7f0..b1c4ba7c98 100755 --- a/scripts/auto_opt_app_patterns.sh +++ b/scripts/auto_opt_app_patterns.sh @@ -60,12 +60,12 @@ fi # `scripts/gc_root_dominance_allowlist.json` uses, so a fixed kernel cannot keep # its exemption by inertia. SKIPS=( - # #7475: fails under BOTH link modes, differently ("Uncaught (in promise) 0" - # with PERRY_NO_AUTO_OPTIMIZE=1, a rooting-shaped TypeError without). The - # promise-rejection half is a separate defect from the iterator-drain rooting - # bug this gate was created for; it is tracked on #7475 and this line comes - # out with it. - "promise_all_chains:pre-existing promise-rejection failure, #7475" + # #7497: fails under BOTH link modes, differently ("Uncaught (in promise) 0" + # with PERRY_NO_AUTO_OPTIMIZE=1, a rooting-shaped TypeError without), and was + # unchanged by #7495's iterator-drain rooting fix in both — which is the + # evidence that it is a separate promise-rejection defect rather than the + # rooting family this gate was created for. + "promise_all_chains:promise rejects with a resolution value at scale, #7497" ) # LIVENESS MATCHER. Reads the archive the linker was actually handed out of a diff --git a/test-files/test_gap_gc_iterator_drain_rooting.ts b/test-files/test_gap_gc_iterator_drain_rooting.ts index 7250127304..e9919604f4 100644 --- a/test-files/test_gap_gc_iterator_drain_rooting.ts +++ b/test-files/test_gap_gc_iterator_drain_rooting.ts @@ -22,8 +22,8 @@ // line, and an out-of-line spread behind two property hops is the lowering // that calls `js_iterator_to_array` directly. Shrink the function and the // spread re-routes through `array_from_spread_value` instead, which has its -// own separate stale-root defect (reported on #7475) and would make this file -// red for the wrong reason. +// own separate stale-root defect (#7498) and would make this file red for the +// wrong reason. // // LIVE BY CONSTRUCTION AND ONLY WHEN SOMETHING MOVES. A non-moving collection // cannot expose it — the stale address still names the same bytes — which is From cee40e980cd8306d1f9b33d89d9a999157ced306 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 6 Aug 2026 10:33:00 +0200 Subject: [PATCH 6/7] fix(gc): root the iterator receiver before the first allocation (#7475) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four review findings, all accepted. `js_iterator_to_array` rooted `iter_f64` AFTER `js_array_alloc(8)` — an allocation, so a copying minor could move the iterator while it existed only in the raw argument and the handle would then root a pre-move address. `iter_h` is now the first thing created in the scope; the null check and the `next` lookup read back through it. `dispatch_array_iterator_method` re-derived `arr_ptr` from field 0 after its cursor store but kept using the raw `iter_obj` PARAMETER to do so, which the same store could have invalidated. It now roots the receiver at entry and reads the current address at every use through a shadowing `iter_obj()` closure, so the pre-collection address is not nameable after that line. `scripts/auto_opt_app_patterns.sh` refuses to run when the node oracle disagrees with `.node-version`. The oracle version is a correctness input — every kernel is diffed byte for byte against it and node patch releases change observable output — and `gc_repsel_matrix.sh` refuses on the same grounds. The workflow's relevance filter now also matches `.github/actions/setup-llvm22/`: it configures the LLVM the gate's compiler is built against, so a change there can move generated code without touching a line under `crates/`. Claude-Session: https://claude.ai/code/session_019EHcmXKArA7m42SihYCcgH --- .github/workflows/auto-opt-app-patterns.yml | 5 ++- crates/perry-runtime/src/array/iter_object.rs | 40 ++++++++++++------- crates/perry-runtime/src/array/iterator.rs | 17 ++++++-- scripts/auto_opt_app_patterns.sh | 17 ++++++++ test-parity/gc_repsel_corpus.txt | 15 ++++++- 5 files changed, 74 insertions(+), 20 deletions(-) diff --git a/.github/workflows/auto-opt-app-patterns.yml b/.github/workflows/auto-opt-app-patterns.yml index f17c95d8b0..f8e6d3aae0 100644 --- a/.github/workflows/auto-opt-app-patterns.yml +++ b/.github/workflows/auto-opt-app-patterns.yml @@ -94,7 +94,10 @@ jobs: # The filter only spares docs-only PRs a compiler build; `set -e` # already aborted if the listing failed, so this cannot silently fall # through to "not relevant". - if grep -qE '^(crates/|benchmarks/app-patterns/|scripts/auto_opt_app_patterns\.sh$|Cargo\.(toml|lock)$|\.node-version$|\.github/workflows/auto-opt-app-patterns\.yml$)' changed.txt; then + # `.github/actions/setup-llvm22/` is in the list because it configures + # the LLVM the gate's compiler is built against — a change there can + # move the generated code without touching a single line under crates/. + if grep -qE '^(crates/|benchmarks/app-patterns/|scripts/auto_opt_app_patterns\.sh$|Cargo\.(toml|lock)$|\.node-version$|\.github/actions/setup-llvm22/|\.github/workflows/auto-opt-app-patterns\.yml$)' changed.txt; then echo "run=true" >> "$GITHUB_OUTPUT" echo "Change can affect a compiled app; running the kernels." else diff --git a/crates/perry-runtime/src/array/iter_object.rs b/crates/perry-runtime/src/array/iter_object.rs index 302f76b2af..5533d4066a 100644 --- a/crates/perry-runtime/src/array/iter_object.rs +++ b/crates/perry-runtime/src/array/iter_object.rs @@ -593,10 +593,21 @@ pub unsafe fn dispatch_array_iterator_method( iter_obj: *mut ObjectHeader, method_name: &str, ) -> f64 { + // #7475: the raw `iter_obj` parameter is not a GC root, and this function + // allocates in several places — `js_object_set_field` (shape transition / + // storage growth), `make_pair_array`, and the two result constructors. A + // copying minor landing in any of those windows moves the iterator and + // leaves the parameter naming retired from-space. Root it once and read + // the CURRENT address through `iter_obj()` at every use, so no pre- + // collection copy is nameable. + let scope = crate::gc::RuntimeHandleScope::new(); + let iter_h = scope.root_nanbox_f64(js_nanbox_pointer(iter_obj as i64)); + let iter_obj = || js_nanbox_get_pointer(iter_h.get_nanbox_f64()) as *mut ObjectHeader; + // Field 2: iterator kind — read up front so the exhausted paths can pick // the kind's done-value (`null` for KIND_VALUES_NULL_DONE, `undefined` // otherwise). - let kind = f64::from_bits(js_object_get_field(iter_obj, 2).bits()) as i32; + let kind = f64::from_bits(js_object_get_field(iter_obj(), 2).bits()) as i32; let done_value = || { if kind == KIND_VALUES_NULL_DONE { JSValue::null() @@ -607,10 +618,10 @@ pub unsafe fn dispatch_array_iterator_method( match method_name { "next" => { if kind == KIND_VALUES_NULL_DONE { - let epoch_ptr = - js_nanbox_get_pointer(f64::from_bits(js_object_get_field(iter_obj, 3).bits())) - as *const std::sync::atomic::AtomicU64; - let expected = f64::from_bits(js_object_get_field(iter_obj, 4).bits()) as u64; + let epoch_ptr = js_nanbox_get_pointer(f64::from_bits( + js_object_get_field(iter_obj(), 3).bits(), + )) as *const std::sync::atomic::AtomicU64; + let expected = f64::from_bits(js_object_get_field(iter_obj(), 4).bits()) as u64; if epoch_ptr.is_null() || (*epoch_ptr).load(std::sync::atomic::Ordering::Relaxed) != expected { @@ -621,7 +632,7 @@ pub unsafe fn dispatch_array_iterator_method( } } // Field 0: backing array pointer (NaN-boxed). - let backing_field = js_object_get_field(iter_obj, 0); + let backing_field = js_object_get_field(iter_obj(), 0); let backing_f64 = f64::from_bits(backing_field.bits()); // Array iterators clear their backing array at exhaustion. SQLite's // statement iterator restarts a completed execution on the next call. @@ -633,7 +644,7 @@ pub unsafe fn dispatch_array_iterator_method( } let arr_ptr = js_nanbox_get_pointer(backing_f64) as *const ArrayHeader; // Field 1: current index. - let idx_field = js_object_get_field(iter_obj, 1); + let idx_field = js_object_get_field(iter_obj(), 1); let idx = f64::from_bits(idx_field.bits()) as u32; let len = if arr_ptr.is_null() { @@ -644,24 +655,25 @@ pub unsafe fn dispatch_array_iterator_method( if idx >= len { if kind == KIND_VALUES_NULL_DONE { - js_object_set_field(iter_obj, 1, JSValue::number(0.0)); + js_object_set_field(iter_obj(), 1, JSValue::number(0.0)); return make_sqlite_iter_result(done_value(), true); } - js_object_set_field(iter_obj, 0, JSValue::undefined()); + js_object_set_field(iter_obj(), 0, JSValue::undefined()); return make_iter_result(done_value(), true); } // Advance the stored cursor before computing the value so a // subsequent `.next()` call sees the bumped index. - js_object_set_field(iter_obj, 1, JSValue::number((idx + 1) as f64)); + js_object_set_field(iter_obj(), 1, JSValue::number((idx + 1) as f64)); // #7475: the cursor store above can allocate (shape transition / // storage growth), so `arr_ptr` — read before it — may now name // from-space. Re-derive the backing array from the iterator's // field 0, which the collector DOES rewrite, instead of reusing - // the pre-store copy. + // the pre-store copy. `iter_obj()` re-reads the iterator's own + // address from its root for the same reason. let arr_ptr = - js_nanbox_get_pointer(f64::from_bits(js_object_get_field(iter_obj, 0).bits())) + js_nanbox_get_pointer(f64::from_bits(js_object_get_field(iter_obj(), 0).bits())) as *const ArrayHeader; let elem = if arr_ptr.is_null() { f64::from_bits(TAG_UNDEFINED) @@ -687,7 +699,7 @@ pub unsafe fn dispatch_array_iterator_method( // Iterators are themselves iterable — `[Symbol.iterator]()` on one // returns the same iterator (matches Node, and lets `js_get_iterator` // / `for (const v of arr.values())` re-enter without a wrapper). - "Symbol.iterator" | "@@iterator" | "values" => js_nanbox_pointer(iter_obj as i64), + "Symbol.iterator" | "@@iterator" | "values" => js_nanbox_pointer(iter_obj() as i64), // `return`/`throw` are part of the iterator spec; Node's array // iterator inherits them from %IteratorPrototype%. Return a // `{ value: undefined, done: true }` shape for early-exit code. @@ -696,7 +708,7 @@ pub unsafe fn dispatch_array_iterator_method( // `{ done: true, value: null }` — matching Node's sqlite iterator. "return" | "throw" => { if kind == KIND_VALUES_NULL_DONE { - js_object_set_field(iter_obj, 0, JSValue::undefined()); + js_object_set_field(iter_obj(), 0, JSValue::undefined()); } if kind == KIND_VALUES_NULL_DONE { make_sqlite_iter_result(done_value(), true) diff --git a/crates/perry-runtime/src/array/iterator.rs b/crates/perry-runtime/src/array/iterator.rs index 84e43ad6eb..127be1280b 100644 --- a/crates/perry-runtime/src/array/iterator.rs +++ b/crates/perry-runtime/src/array/iterator.rs @@ -1148,16 +1148,21 @@ pub extern "C" fn js_iterator_to_array(iter_f64: f64) -> *mut ArrayHeader { // out of `scripts/raw_handle_debt.py`'s ledger. let scope = crate::gc::RuntimeHandleScope::new(); + // The iterator is rooted FIRST, before anything in this function allocates: + // `js_array_alloc` below can trigger a copying minor, and until the value is + // in a scope slot the collector has nothing to rewrite. Rooting a + // non-pointer (`undefined`/`null`) is harmless — the visitor ignores it — + // so the null check reads back through the handle rather than gating it. + let iter_h = scope.root_nanbox_f64(iter_f64); + let arr = js_array_alloc(8); // start with capacity 8 let result_h = scope.root_nanbox_f64(crate::value::js_nanbox_pointer(arr as i64)); let read_result = || js_nanbox_get_pointer(result_h.get_nanbox_f64()) as *mut ArrayHeader; // Get the iterator object pointer - let iter_ptr = js_nanbox_get_pointer(iter_f64); - if iter_ptr == 0 { + if js_nanbox_get_pointer(iter_h.get_nanbox_f64()) == 0 { return read_result(); } - let iter_h = scope.root_nanbox_f64(iter_f64); // Look up the "next" method on the iterator object as a stored closure // FIELD (the common case for generator objects / effect's `SingleShotGen`, @@ -1167,7 +1172,11 @@ pub extern "C" fn js_iterator_to_array(iter_f64: f64) -> *mut ArrayHeader { // on `this` being bound by the class-id method tower — so an INHERITED // `.next` must take the method-dispatch path below, not this raw // closure-call (which doesn't bind `this`). - let next_val = crate::object::js_object_get_own_field_or_undef(iter_f64, b"next".as_ptr(), 4); + let next_val = crate::object::js_object_get_own_field_or_undef( + iter_h.get_nanbox_f64(), + b"next".as_ptr(), + 4, + ); let next_val = crate::value::JSValue::from_bits(next_val.to_bits()); let next_f64 = unsafe { f64::from_bits(std::mem::transmute::<_, u64>(next_val)) }; let next_ptr = if next_val.is_undefined() { diff --git a/scripts/auto_opt_app_patterns.sh b/scripts/auto_opt_app_patterns.sh index b1c4ba7c98..b2fff48969 100755 --- a/scripts/auto_opt_app_patterns.sh +++ b/scripts/auto_opt_app_patterns.sh @@ -131,6 +131,23 @@ if ! command -v "$NODE_BIN" >/dev/null 2>&1; then exit 2 fi +# The oracle version is a CORRECTNESS INPUT, not an incidental toolchain +# detail — CLAUDE.md is explicit, and node patch releases change observable +# output (error-message text, `v8` heap fields). Every kernel here is diffed +# byte for byte against this node, so a mismatched one silently turns the +# comparison into a different experiment. `scripts/gc_repsel_matrix.sh` refuses +# on the same grounds; do the same rather than quietly measuring the wrong pin. +if [[ -f "$ROOT/.node-version" ]]; then + pinned_node="$(tr -d '[:space:]' < "$ROOT/.node-version")" + running_node="$("$NODE_BIN" --version | sed 's/^v//')" + if [[ -n "$pinned_node" && "$running_node" != "$pinned_node" ]]; then + echo "error: node oracle is v$running_node but .node-version pins $pinned_node." >&2 + echo " Every kernel's stdout is diffed against this node, so the two must" >&2 + echo " agree. Install the pinned version or point NODE_BIN at it." >&2 + exit 2 + fi +fi + all_kernels=() for f in "$KERNEL_DIR"/*.ts; do all_kernels+=("$(basename "$f" .ts)") diff --git a/test-parity/gc_repsel_corpus.txt b/test-parity/gc_repsel_corpus.txt index 7698aa455e..47ce330124 100644 --- a/test-parity/gc_repsel_corpus.txt +++ b/test-parity/gc_repsel_corpus.txt @@ -586,6 +586,19 @@ test_gap_gc_optional_param_receiver_rooting # Measured on this branch: # before, default link + default env TypeError: next is not a function # before, auto-optimize link TypeError: next is not a function -# before, PROTECT_FROMSPACE=1 DEPTH=200 FAULT in retired from-space, both links +# before, PROTECT_FROMSPACE=1 DEPTH=200 FAULT at js_array_length inside +# dispatch_array_iterator_method, both links # after, both links byte-exact with the oracle +# after, PROTECT_FROMSPACE=1 DEPTH=200 that fault is GONE; a DIFFERENT one +# remains (#7498) +# +# THE INSTRUMENT IS NOT CLEAN AFTER THE FIX, and saying otherwise would be the +# overclaim this repo keeps paying for. `[...o.meta.tags]` reaches the drain +# through `array_from_spread_value`, whose `[Symbol.iterator]` prototype walk +# has its OWN stale from-space deref — `array_prototype_property_value` → +# `default_object_prototype_property_value` → `js_object_get_field_by_name`, on +# a 56-byte GC_TYPE_STRING at minor #3. That is #7498, unfixed here: it does not +# corrupt this file's result, so the file gates the drain and nothing else. When +# #7498 lands, a protected run of this file should go silent — if it does not, +# there is a third site. test_gap_gc_iterator_drain_rooting From 37384d31708ba0058aa8815d63bcb446fc106c73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 6 Aug 2026 10:40:30 +0200 Subject: [PATCH 7/7] chore: bump version to 0.5.1287 --- CLAUDE.md | 2 +- Cargo.lock | 152 ++++++++++++++++++++++++++--------------------------- Cargo.toml | 2 +- 3 files changed, 78 insertions(+), 78 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 503298aa25..279c0bfabd 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.1286 +**Current Version:** 0.5.1287 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index c598adaeee..dfde7b446a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5547,7 +5547,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perry" -version = "0.5.1286" +version = "0.5.1287" dependencies = [ "anyhow", "base64", @@ -5607,14 +5607,14 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1286" +version = "0.5.1287" dependencies = [ "serde", ] [[package]] name = "perry-audio-miniaudio" -version = "0.5.1286" +version = "0.5.1287" dependencies = [ "cc", "libc", @@ -5622,7 +5622,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1286" +version = "0.5.1287" dependencies = [ "anyhow", "inkwell", @@ -5638,7 +5638,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1286" +version = "0.5.1287" dependencies = [ "anyhow", "perry-hir", @@ -5646,7 +5646,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1286" +version = "0.5.1287" dependencies = [ "anyhow", "perry-hir", @@ -5654,7 +5654,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1286" +version = "0.5.1287" dependencies = [ "anyhow", "perry-dispatch", @@ -5663,7 +5663,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1286" +version = "0.5.1287" dependencies = [ "anyhow", "perry-hir", @@ -5671,7 +5671,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1286" +version = "0.5.1287" dependencies = [ "anyhow", "base64", @@ -5683,7 +5683,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1286" +version = "0.5.1287" dependencies = [ "anyhow", "perry-hir", @@ -5691,7 +5691,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1286" +version = "0.5.1287" dependencies = [ "anyhow", "async-trait", @@ -5720,14 +5720,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1286" +version = "0.5.1287" dependencies = [ "anyhow", ] [[package]] name = "perry-diagnostics" -version = "0.5.1286" +version = "0.5.1287" dependencies = [ "serde", "serde_json", @@ -5735,7 +5735,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1286" +version = "0.5.1287" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5746,7 +5746,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1286" +version = "0.5.1287" dependencies = [ "anyhow", "clap", @@ -5761,7 +5761,7 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1286" +version = "0.5.1287" dependencies = [ "block2", "objc2", @@ -5771,7 +5771,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.1286" +version = "0.5.1287" dependencies = [ "argon2", "perry-ffi", @@ -5779,7 +5779,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.1286" +version = "0.5.1287" dependencies = [ "perry-ffi", "reqwest", @@ -5788,7 +5788,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1286" +version = "0.5.1287" dependencies = [ "bcrypt", "perry-ffi", @@ -5796,7 +5796,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1286" +version = "0.5.1287" dependencies = [ "perry-ffi", "rusqlite", @@ -5804,7 +5804,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1286" +version = "0.5.1287" dependencies = [ "perry-ffi", "scraper", @@ -5812,7 +5812,7 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.1286" +version = "0.5.1287" dependencies = [ "perry-ffi", "perry-runtime", @@ -5820,7 +5820,7 @@ dependencies = [ [[package]] name = "perry-ext-cron" -version = "0.5.1286" +version = "0.5.1287" dependencies = [ "chrono", "cron", @@ -5830,7 +5830,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.1286" +version = "0.5.1287" dependencies = [ "chrono", "perry-ffi", @@ -5838,7 +5838,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1286" +version = "0.5.1287" dependencies = [ "perry-ffi", "rust_decimal", @@ -5846,7 +5846,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.1286" +version = "0.5.1287" dependencies = [ "perry-ffi", "serde_json", @@ -5854,7 +5854,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1286" +version = "0.5.1287" dependencies = [ "perry-ffi", "rand 0.10.1", @@ -5862,7 +5862,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1286" +version = "0.5.1287" dependencies = [ "perry-ffi", "perry-runtime", @@ -5870,14 +5870,14 @@ dependencies = [ [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.1286" +version = "0.5.1287" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.1286" +version = "0.5.1287" dependencies = [ "bytes", "http-body-util", @@ -5895,7 +5895,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.1286" +version = "0.5.1287" dependencies = [ "bytes", "lazy_static", @@ -5908,7 +5908,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1286" +version = "0.5.1287" dependencies = [ "bytes", "h2", @@ -5932,7 +5932,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1286" +version = "0.5.1287" dependencies = [ "lazy_static", "perry-ffi", @@ -5942,7 +5942,7 @@ dependencies = [ [[package]] name = "perry-ext-jsonwebtoken" -version = "0.5.1286" +version = "0.5.1287" dependencies = [ "base64", "jsonwebtoken", @@ -5953,7 +5953,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.1286" +version = "0.5.1287" dependencies = [ "lru", "perry-ffi", @@ -5962,7 +5962,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.1286" +version = "0.5.1287" dependencies = [ "chrono", "perry-ffi", @@ -5970,7 +5970,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1286" +version = "0.5.1287" dependencies = [ "bson", "futures-util", @@ -5982,7 +5982,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.1286" +version = "0.5.1287" dependencies = [ "chrono", "perry-ffi", @@ -5992,7 +5992,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.1286" +version = "0.5.1287" dependencies = [ "nanoid", "perry-ffi", @@ -6001,7 +6001,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1286" +version = "0.5.1287" dependencies = [ "bytes", "perry-ffi", @@ -6014,7 +6014,7 @@ dependencies = [ [[package]] name = "perry-ext-node-forge" -version = "0.5.1286" +version = "0.5.1287" dependencies = [ "const-oid 0.9.6", "der 0.7.10", @@ -6033,7 +6033,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1286" +version = "0.5.1287" dependencies = [ "lettre", "perry-ffi", @@ -6043,7 +6043,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1286" +version = "0.5.1287" dependencies = [ "perry-ffi", "printpdf", @@ -6051,7 +6051,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.1286" +version = "0.5.1287" dependencies = [ "perry-ffi", "sqlx", @@ -6060,7 +6060,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.1286" +version = "0.5.1287" dependencies = [ "governor", "perry-ffi", @@ -6068,7 +6068,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1286" +version = "0.5.1287" dependencies = [ "fast_image_resize", "image", @@ -6078,14 +6078,14 @@ dependencies = [ [[package]] name = "perry-ext-slugify" -version = "0.5.1286" +version = "0.5.1287" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-streams" -version = "0.5.1286" +version = "0.5.1287" dependencies = [ "lazy_static", "perry-ffi", @@ -6094,7 +6094,7 @@ dependencies = [ [[package]] name = "perry-ext-undici" -version = "0.5.1286" +version = "0.5.1287" dependencies = [ "perry-ffi", "perry-runtime", @@ -6103,7 +6103,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.1286" +version = "0.5.1287" dependencies = [ "perry-ffi", "uuid", @@ -6111,7 +6111,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.1286" +version = "0.5.1287" dependencies = [ "perry-ffi", "regex", @@ -6121,7 +6121,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1286" +version = "0.5.1287" dependencies = [ "futures-util", "lazy_static", @@ -6134,7 +6134,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1286" +version = "0.5.1287" dependencies = [ "brotli", "flate2", @@ -6144,7 +6144,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1286" +version = "0.5.1287" dependencies = [ "dashmap", "once_cell", @@ -6153,7 +6153,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1286" +version = "0.5.1287" dependencies = [ "anyhow", "perry-api-manifest", @@ -6171,7 +6171,7 @@ dependencies = [ [[package]] name = "perry-parser" -version = "0.5.1286" +version = "0.5.1287" dependencies = [ "anyhow", "perry-diagnostics", @@ -6183,7 +6183,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1286" +version = "0.5.1287" dependencies = [ "anyhow", "base64", @@ -6225,14 +6225,14 @@ dependencies = [ [[package]] name = "perry-runtime-static" -version = "0.5.1286" +version = "0.5.1287" dependencies = [ "perry-runtime", ] [[package]] name = "perry-stdlib" -version = "0.5.1286" +version = "0.5.1287" dependencies = [ "aes 0.8.4", "aes 0.9.1", @@ -6327,14 +6327,14 @@ dependencies = [ [[package]] name = "perry-stdlib-static" -version = "0.5.1286" +version = "0.5.1287" dependencies = [ "perry-stdlib", ] [[package]] name = "perry-transform" -version = "0.5.1286" +version = "0.5.1287" dependencies = [ "anyhow", "perry-hir", @@ -6343,14 +6343,14 @@ dependencies = [ [[package]] name = "perry-ui" -version = "0.5.1286" +version = "0.5.1287" dependencies = [ "perry-ui-model", ] [[package]] name = "perry-ui-android" -version = "0.5.1286" +version = "0.5.1287" dependencies = [ "base64", "itoa", @@ -6367,7 +6367,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1286" +version = "0.5.1287" dependencies = [ "rand 0.10.1", "serde", @@ -6377,7 +6377,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1286" +version = "0.5.1287" dependencies = [ "base64", "cairo-rs 0.22.0", @@ -6400,7 +6400,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1286" +version = "0.5.1287" dependencies = [ "base64", "block2", @@ -6416,7 +6416,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1286" +version = "0.5.1287" dependencies = [ "base64", "block2", @@ -6431,7 +6431,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1286" +version = "0.5.1287" [[package]] name = "perry-ui-test" @@ -6442,11 +6442,11 @@ dependencies = [ [[package]] name = "perry-ui-testkit" -version = "0.5.1286" +version = "0.5.1287" [[package]] name = "perry-ui-tvos" -version = "0.5.1286" +version = "0.5.1287" dependencies = [ "base64", "block2", @@ -6462,7 +6462,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1286" +version = "0.5.1287" dependencies = [ "base64", "block2", @@ -6478,7 +6478,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1286" +version = "0.5.1287" dependencies = [ "block2", "libc", @@ -6491,7 +6491,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1286" +version = "0.5.1287" dependencies = [ "base64", "libc", @@ -6508,14 +6508,14 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1286" +version = "0.5.1287" dependencies = [ "perry-ui-windows", ] [[package]] name = "perry-updater" -version = "0.5.1286" +version = "0.5.1287" dependencies = [ "anyhow", "base64", @@ -6531,7 +6531,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1286" +version = "0.5.1287" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index 3a3e98f21d..7b69ac4aaa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -315,7 +315,7 @@ codegen-units = 16 codegen-units = 16 [workspace.package] -version = "0.5.1286" +version = "0.5.1287" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry"