fix(gc): root the iterator drain's live values across .next() (#7475) - #7495
Conversation
|
Warning Review limit reached
Next review available in: 38 seconds You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: ⛔ Files ignored due to path filters (1)
📒 Files selected for processing (9)
📝 WalkthroughWalkthroughChangesThe runtime roots iterator values across moving-GC allocations and refreshes relocated pointers. A regression test covers iterator draining during deep cloning. A Bash gate and GitHub Actions workflow validate auto-optimized app-pattern kernels against Node. Iterator GC safety and validation
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant GitHub Actions
participant auto_opt_app_patterns.sh
participant Perry
participant Node
GitHub Actions->>auto_opt_app_patterns.sh: Run selected app-pattern kernels
auto_opt_app_patterns.sh->>Perry: Compile with AUTO-OPTIMIZE
Perry-->>auto_opt_app_patterns.sh: Compiled output and linked runtime archive
auto_opt_app_patterns.sh->>Perry: Execute compiled kernel
auto_opt_app_patterns.sh->>Node: Execute oracle kernel
Perry-->>auto_opt_app_patterns.sh: Kernel output
Node-->>auto_opt_app_patterns.sh: Oracle output
auto_opt_app_patterns.sh-->>GitHub Actions: Pass or failure status
Possibly related PRs
Suggested labels: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 2📝 Generate docstrings 💡
🛠️ Fix failing CI checks 💡
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 4
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In @.github/workflows/auto-opt-app-patterns.yml:
- Around line 91-103: Update the relevant-path grep expression in the workflow’s
changed-file gate to match `.github/actions/setup-llvm22/` and any files beneath
it. Preserve the existing paths and run=true behavior so changes to the LLVM
setup action trigger the build and kernel steps.
In `@crates/perry-runtime/src/array/iter_object.rs`:
- Around line 658-665: Root iter_obj at the start of the containing iterator
function using a RuntimeHandleScope, rather than retaining the raw parameter
across allocations. After every allocating operation, especially
js_object_set_field, reload iter_obj from the handle before reading field 0 or
otherwise dereferencing it, then derive arr_ptr from the reloaded object.
In `@crates/perry-runtime/src/array/iterator.rs`:
- Around line 1149-1160: Move creation of the rooted iterator handle immediately
after RuntimeHandleScope::new(), before js_array_alloc(8). Derive iter_ptr from
iter_h and use iter_h for all subsequent iterator accesses, including the null
check, so the iterator value is rooted before allocation and reloaded after
potential GC.
In `@scripts/auto_opt_app_patterns.sh`:
- Around line 47-48: Update the startup flow in auto_opt_app_patterns.sh after
resolving NODE_BIN to read the pinned version from .node-version and compare it
with the selected Node executable. Reject mismatches with a clear error and exit
before compilation or oracle/kernel execution, while preserving the existing
NODE_BIN override behavior.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: dbb4c949-2e07-437b-a5bd-5a3c094ab4fb
📒 Files selected for processing (7)
.github/workflows/auto-opt-app-patterns.ymlchangelog.d/7495-auto-opt-archive.mdcrates/perry-runtime/src/array/iter_object.rscrates/perry-runtime/src/array/iterator.rsscripts/auto_opt_app_patterns.shtest-files/test_gap_gc_iterator_drain_rooting.tstest-parity/gc_repsel_corpus.txt
|
Review round applied in 5649e8e — all four CodeRabbit findings accepted, each with an inline reply. Two were real rooting bugs in the fix itself: Re-validated on the rebuilt archives:
One correction to an earlier claim in this PR body. I had written that a
|
|
Second pre-existing red, for the record:
The new |
`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
… bug (#7475) `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
…7475) `--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
`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
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
5649e8e to
37384d3
Compare
`[...obj.arr]` has two lowerings. Out of line it calls `js_iterator_to_array` (rooted by #7495); inlined it routes through `array_from_spread_value`, which resolves `[Symbol.iterator]` through the whole prototype-walk tower first. Three frames on that walk held a GC-managed value the collector cannot see, and `PERRY_GC_PROTECT_FROMSPACE=1` faults on all of them. * `symbol::get::req_handle_symbol_fallback` read the receiver into a bare `usize`, interned a `"_req"` key -- an allocation -- and then read a field off the PRE-move address. It runs on every heap-object symbol read whose own-symbol lookup missed, so the window is unconditional; the reproducer faults 5/5, not intermittently. * `array_prototype_property_value`, and the array + object arms of `get_field_by_name_object_tail`, took the property name as a `&str` / `&[u8]` BORROWED OUT OF THE KEY'S `StringHeader`. No root fixes that shape: a borrow is not a slot the collector can rewrite. They copy the bytes off the heap once, before their first allocation, through the new `HeapKeyBytes` (stack buffer, spill only for a >64-byte key). * `array_from_spread_value` carried the spread receiver through a dozen classification probes and the entire symbol walk, then used it to rebind `this` for the `[Symbol.iterator]()` factory. Also roots `default_object_prototype_property_value`'s key/receiver and the two subclass-marker probes (`fetch_subclass_handle_id`, `temporal_subclass_cell`), each of which allocated a key string between reading its receiver and using it, and re-reads `js_object_get_symbol_property`'s receiver after the fallback. All handles are NaN-boxed, so `scripts/raw_handle_debt.py` stays at 999. Claude-Session: https://claude.ai/code/session_019EHcmXKArA7m42SihYCcgH
…7527) * fix(gc): root the inlined spread's `[Symbol.iterator]` walk (#7498) `[...obj.arr]` has two lowerings. Out of line it calls `js_iterator_to_array` (rooted by #7495); inlined it routes through `array_from_spread_value`, which resolves `[Symbol.iterator]` through the whole prototype-walk tower first. Three frames on that walk held a GC-managed value the collector cannot see, and `PERRY_GC_PROTECT_FROMSPACE=1` faults on all of them. * `symbol::get::req_handle_symbol_fallback` read the receiver into a bare `usize`, interned a `"_req"` key -- an allocation -- and then read a field off the PRE-move address. It runs on every heap-object symbol read whose own-symbol lookup missed, so the window is unconditional; the reproducer faults 5/5, not intermittently. * `array_prototype_property_value`, and the array + object arms of `get_field_by_name_object_tail`, took the property name as a `&str` / `&[u8]` BORROWED OUT OF THE KEY'S `StringHeader`. No root fixes that shape: a borrow is not a slot the collector can rewrite. They copy the bytes off the heap once, before their first allocation, through the new `HeapKeyBytes` (stack buffer, spill only for a >64-byte key). * `array_from_spread_value` carried the spread receiver through a dozen classification probes and the entire symbol walk, then used it to rebind `this` for the `[Symbol.iterator]()` factory. Also roots `default_object_prototype_property_value`'s key/receiver and the two subclass-marker probes (`fetch_subclass_handle_id`, `temporal_subclass_cell`), each of which allocated a key string between reading its receiver and using it, and re-reads `js_object_get_symbol_property`'s receiver after the fallback. All handles are NaN-boxed, so `scripts/raw_handle_debt.py` stays at 999. Claude-Session: https://claude.ai/code/session_019EHcmXKArA7m42SihYCcgH * docs(changelog): fragment for #7527 Claude-Session: https://claude.ai/code/session_019EHcmXKArA7m42SihYCcgH * docs: name the residual protected-run fault as #7528 The corpus note on `test_gap_gc_iterator_drain_rooting` asked for exactly this check ("if it does not go silent, there is a third site"). There is, and it now has an issue with the repro, the lldb faulting instruction, the knob bisect and the reason the one-line patch was reverted. Claude-Session: https://claude.ai/code/session_019EHcmXKArA7m42SihYCcgH * chore: bump version to 0.5.1298 --------- Co-authored-by: Ralph Küpper <ralph@skelpo.com>
Fixes #7475.
The filed hypothesis was wrong, and that matters
#7475 was filed on the reading that the auto-optimize path's feature-stripped
runtime archive (
--no-default-features --features full,alloc-mimalloc) wasmissing something. It isn't. Nothing in the feature set gates GC behaviour
here, and no missing feature needs adding.
The real cause is a #7341-family rooting bug that is present in the ordinary
build too.
js_iterator_to_array— the drain behind[...iterable]/Array.from— held five live GC values in bare Rust locals across a.next()call that allocates the{ value, done }result: the iteratorobject, the accumulator array, the
nextclosure, and the two property keys.One level down,
make_iter_resultheld the caller's elementvalueand its ownfreshly allocated result object across four more allocations before storing
them. And
dispatch_array_iterator_methodre-used a backing-array pointer readbefore 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, so the next dispatch
reads THAT copy's field 0 — the backing array — and
dispatch_array_iterator_methodcallsjs_array_lengthon a from-spaceaddress.
Trigger, not cause: this was latent for every user
The distinction decides who was exposed, so it was measured rather than argued.
Both links have the defect.
PERRY_GC_PROTECT_FROMSPACE=1faults on thePERRY_NO_AUTO_OPTIMIZE=1binary too — the one that prints the correctchecksum — at the same retiring minor (
#0):That binary "passed" only because the stale read landed on bytes that had not
yet been recycled. Every user of array spread /
Array.fromwas exposed;the auto-optimize link merely allocates in an order that makes the stale read
observable.
Isolated by rebuilding the runtime archive one axis at a time and linking each
through the plain no-auto path, so the link line was identical in all four rows:
object_deep_clone-C panic=abort -C force-unwind-tables=yes)--no-default-features --features full,alloc-mimalloc), default RUSTFLAGSTypeError: next is not a functionTypeError: next is not a functionRUSTFLAGS are irrelevant; the feature set is an exposure change. Runtime knobs
agree it is the copying minor and not policy evacuation:
PERRY_GEN_GC=0,PERRY_GC_SCAVENGE=0andPERRY_WRITE_BARRIERS=0each make the failing binarypass, while
PERRY_GEN_GC_EVACUATE=0does not.Fix
Root them in a
RuntimeHandleScopeand re-read every address at its point ofuse (
crates/perry-runtime/src/array/iterator.rs,crates/perry-runtime/src/array/iter_object.rs). The per-iteration resultobject gets one reusable scratch slot rather than a fresh handle per turn — the
loop runs up to 100k times.
make_iter_result/make_sqlite_iter_resultcollapse into one rooted
build_iter_result.Raw-handle debt: green, and no new bare reads.
python3 scripts/raw_handle_debt.pyreports999 (baseline 999)— unchanged, with thezero headroom the gate has.
crates/perry-runtime/src/array/iterator.rsanditer_object.rshave no ceiling entry, so a single bareget_raw_{mut,const}_ptrin either would turn it red. The diff adds none: everyhandle is NaN-boxed and read back with
get_nanbox_f64/get_nanbox_u64(which the ratchet does not count and which cannot go stale in the way a raw
copy does), and the one read that follows an allocating call uses the
combinator form,
step_h.across_nanbox(|| …). The onlyget_raw_const_ptrstring the diff introduces is inside a doc comment explaining why it is avoided.
(
across_constwas the first attempt there and panics at runtime on a NaN-boxedhandle —
runtime handle kind mismatch: expected raw pointer. Loudly, which ishow it was caught;
across_nanboxis the right combinator for aNanboxslot.)Regression coverage
test-files/test_gap_gc_iterator_drain_rooting.ts, registered intest-parity/gc_repsel_corpus.txtsogc-moving-witnessesruns it and refusesa cell where nothing moved. It mirrors the kernel deliberately:
deepClone'ssize 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_arraydirectly.TypeError: next is not a functionTypeError: next is not a functionPERRY_GC_PROTECT_FROMSPACE=1 DEPTH=200js_array_lengthinsidedispatch_array_iterator_method, both linksThe instrument is not clean after this fix, and claiming otherwise would be
the overclaim this repo keeps paying for.
[...o.meta.tags]reaches the drainthrough
array_from_spread_value, whose[Symbol.iterator]prototype walkhas its own stale from-space deref — a 56-byte
GC_TYPE_STRINGat minor#3.That is #7498, unfixed here. It does not corrupt this file's result, so the
witness gates the drain and nothing else;
test-parity/gc_repsel_corpus.txtrecords that a protected run should go silent when #7498 lands, and that a
remaining fault would mean a third site.
scripts/auto_opt_app_patterns.sh+.github/workflows/auto-opt-app-patterns.ymlclose the blind spot that let this ship. Every other gate in the repo sets
PERRY_NO_AUTO_OPTIMIZE=1for a deterministic link —gc-ratchetsays soinline, and so do a dozen
crates/perry/testscases — so the default path, theone users actually get, was tested by nothing.
The gate asserts its subject was live: it reads the linker command line out
of
perry -vand requires it to name aperry-auto-*/…/libperry_runtime.athatexists on disk. The auto-optimizer falls back to the prebuilt archives by design
whenever its cargo rebuild fails, and such a run would pass every output
comparison while exercising the exact configuration this job does not care
about.
--self-testproves that matcher can still fail — and it alreadycaught a real over-match: the first version grepped the whole log and accepted
a canned fallback run that printed
auto-optimize: built …and then linked theprebuilt archive. It now reads only the
[link] invoking:line, and CI runs theself-test before the kernels.
Measured on this branch: 11 of 12 app-pattern kernels PASS through the
auto-optimize link, each linking a freshly built
perry-auto-*archive andmatching the node oracle byte for byte.
promise_all_chainsis the onedocumented skip, and a skip entry naming a kernel that no longer exists fails
the script, so the line cannot outlive its fix.
Per CLAUDE.md's corollary the workflow is deliberately not added to branch
protection's required contexts in this PR — a new gate has never been green.
Promote it after its first green run on
main.What is left, and where it lives
#7475 closes with this merge. Its two residual defects are not a vague
remainder; each now has its own issue, with a repro and a first thing to check:
promise_all_chainsrejects with a resolution value at scale.Fails under both links, differently (
Uncaught (in promise) 0withPERRY_NO_AUTO_OPTIMIZE=1;Uncaught (in promise) TypeError: value is not a functionwithout), and is unchanged by this PR in both, which is theevidence that it is a promise-rejection defect and not the rooting family.
Correct at
N_BATCHES=3, BATCH_SIZE=5. This is the remaining blocker on thepublic benchmark artifact, and the one skip in the new gate.
array_from_spread_value'sSymbol.iteratorlookup derefs afrom-space object.
[...obj.arr]has two lowerings; when the enclosingfunction is small enough to inline, the spread routes through
array_from_spread_valuerather thanjs_iterator_to_array, andPERRY_GC_PROTECT_FROMSPACE=1faults inside the prototype walk(
array_prototype_property_value→default_object_prototype_property_value→
js_object_get_field_by_name). Reproduces on this branch with the shippedwitness, so no shrinking is needed: the two
[...obj.arr]lowerings converge —array_from_spread_valueends by callingjs_iterator_to_array— and thespread reaches this prototype walk first either way. Both links, so every user
is exposed. Table-vs-register is open: the two traces differ in both the stale
object (72-byte
GC_TYPE_OBJECTvs 56-byteGC_TYPE_STRING) and the retiringminor (
#0vs#3), so CLAUDE.md's "deterministic at minor #0 ⇒ a table"shortcut does not apply.
Review
All four CodeRabbit findings accepted and applied; each has an inline reply. Two
were real rooting bugs in the fix itself and are worth naming here:
js_iterator_to_arrayrootediter_f64afterjs_array_alloc(8)— anallocation, 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. The
same mistake the PR is about, one line earlier than where I fixed it.
dispatch_array_iterator_methodre-derivedarr_ptrfrom field 0 after itscursor store but used the raw
iter_objparameter to do so, which thesame store could have invalidated. Half a fix. It now roots the receiver at
entry and reads the current address through a shadowing
iter_obj()closure,so the pre-collection address is not nameable after that line.
The other two: the gate now refuses when the node oracle disagrees with
.node-version(the oracle version is a correctness input, andgc_repsel_matrix.shrefuses on the same grounds), and the workflow's relevancefilter also matches
.github/actions/setup-llvm22/.Validation
object_deep_clonethrough the real auto-optimize path (noPERRY_NO_AUTO_OPTIMIZE, freshly builtperry-auto-*archive linked):checksum: 1249975000, matching node and bun.scripts/auto_opt_app_patterns.shover all twelve kernels: 11 PASS, 1documented SKIP;
--self-testgreen.cargo fmt --all -- --check,scripts/check_file_size.sh,scripts/check_test_registration.py,scripts/raw_handle_debt.py(999,unchanged) and its
--self-testall clean.https://claude.ai/code/session_019EHcmXKArA7m42SihYCcgH