Skip to content

fix(gc): root the ten unrooted runtime-side caches, and the scanner that walked 1 of 3 sibling slots (#7231) - #7239

Merged
proggeramlug merged 1 commit into
mainfrom
fix/7231-unrooted-runtime-caches
Aug 2, 2026
Merged

fix(gc): root the ten unrooted runtime-side caches, and the scanner that walked 1 of 3 sibling slots (#7231)#7239
proggeramlug merged 1 commit into
mainfrom
fix/7231-unrooted-runtime-caches

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

The #7231 enumeration, verified and closed. Ten unrooted runtime-side caches, plus one scanner that walked one of three sibling slots — the class #7226 established, which is strictly worse than the #7154 stale-register class and which no static checker can ever find, because gc_root_dominance_check.py reads emitted LLVM IR and a runtime table is not in it.

unrooted register (#7154) unrooted cache (this)
goes bad only if a collection lands in a narrow window at collection #0, permanently
reproduces intermittently — took ten rounds 10/10
found by the static IR checker nothing static

CACHED_ENVprocess.env

The headline, and it is a hard crash rather than a subtle wrong answer.

js_process_env_impl builds process.env once with crate::object::js_object_alloc — the nursery — and stores it in a thread-local Cell<f64>. That cell is the entire reference graph: process.env is not a field of the process object, it is a js_process_env() call that returns the cache. So the first minor collection swept or evacuated the object, and every later process.env.X = v wrote through a dangling pointer into abandoned memory.

The sibling PROCESS_FINALIZATION_OBJECT uses the identical materialize-once-cache idiom and is rooted (scan_process_finalization_roots_mut), which is what makes this an omission rather than a design.

The observable is ENUMERATION. A direct process.env.KEY read lowers to js_getenv and asks the OS, so it is correct whatever state the cached object is in. What walks the cached object is Object.keys(process.env), for…in and spread — which is exactly how @next/env and dotenv consume it. A witness that tested the read would be a gate that cannot fail.

[gc-fromspace-protect] FAULT: signal 10 at 0x4faa93e012c
  This address is RETIRED FROM-SPACE. The evacuating minor moved or
  freed the object here and the holder kept the pre-collection address.
  last-known object: user_ptr=0x4faa93e0128 obj_type=2 size=416

obj_type=2 is an object; 416 bytes is the header plus the inline env slots.

The rest of the population

Each was taken to its declaration, its allocator, its dereference and a grep of gc/ for a scanner before being accepted — the campaign's record is roughly 1 diagnosis in 3 naming the wrong cause, and this round refuted several (below).

holder why it is real
process/env_misc.rs CACHED_ENV above
process/permission.rs CACHED_PERMISSION same shape. The runtime_write_barrier_root_nanbox next to it is an incremental mark barrier, not a root registration — it does nothing for the sweep or the evacuation rewrite. Hoisted out of the fn body so a scanner can reach it.
process/report.rs CACHED_REPORT same shape, no barrier at all
object/global_this/populate.rs ERROR_CONSTRUCTOR_PTR raw usize of a js_closure_alloc closure. The canonical Error closure is a globalThis field, so the structural trace keeps it alive and rewrites that reference; this duplicate is outside the object graph, so error_prepare_stack_trace_override reads prepareStackTrace off an abandoned closure after a move. Move hazard only.
tui/input.rs INPUT_HANDLER the useInput arrow. Written inline in idiomatic ink style, so nothing else refers to it once js_perry_tui_use_input returns; drain_input calls through it on every keystroke
tty.rs RESIZE_CALLBACK process.stdout.on('resize', cb) stored in a native slot that bypasses the (rooted) EventEmitter listener array
frame.rs FRAME_CALLBACKS onFrame(cb), rooted only transiently by a RuntimeHandleScope during registration. The unsafe impl Send's SAFETY comment asserted the closures "point to global compiled code / GC-rooted data" — that premise was false and is corrected in place
object/class_registry/construct.rs CURRENT_NEW_TARGET a second copy of the value this_binding.rs's NEW_TARGET tracks correctly, held across a whole constructor body
object/field_get_set/accessors.rs ACCESSOR_RECEIVER_OVERRIDE armed across a prototype walk that can reach a Proxy get trap, i.e. arbitrary allocating user code
object/global_fetch.rs PENDING_FETCH_SIGNAL the in-flight AbortSignal, sole reference across the fetch call's own argument lowering

Plus one scanner gap of the shape #7230 found twice. worker_threads.rs's scan_parent_port_event_roots_mut visited MESSAGE_EVENT_CALLBACKS and not MESSAGE_CALLBACK or CLOSE_CALLBACK — three slots declared in the same thread_local! block, all holding the same thing (a raw ClosureHeader* as i64, the only reference to a user callback). The Node-style parentPort.on('message') / on('close') handlers were reclaimed by the next collection inside a worker. The box/visit/unbox dance is factored into one helper, because three copies of it is how the fourth gets forgotten.

Two more windows closed in frame.rs, both found while rooting the queue

Verification

Measured against c9cd73ba5, both arms built from this worktree. The change is runtime-only, so the arms differ in libperry_{runtime,stdlib}.a; PERRY_RUNTIME_DIR selects which is linked and the archives were hashed to prove they moved.

test-files/test_gap_gc_process_env_cache_rooting.ts, registered in test-parity/gc_repsel_corpus.txt (so #7228's gc-moving-witnesses arm gates it, and that arm rejects UNVER as hard as FAIL, so an inert cell fails):

arm base this PR
PERRY_GC_MOVING_LOOP_POLLS=1 (compile and run) + INCREMENTAL=0 CONSERVATIVE_STACK_SCAN=off HEAP_LIMIT=8 SIGBUS (exit 138) 10/10, no output at all bad 0 10/10
+ ZEAL=1 PROTECT_FROMSPACE=1 DEPTH=800 FAULT at the stale deref, obj_type=2 size=416 clean, exit 0 — no false positive
shipped default (no polls, no pressure) bad 0 5/5 bad 0 5/5
vs node --experimental-strip-types 26.5.1 byte-exact

The base arm being clean on the shipped default is what makes this a requires=move witness rather than a pre-existing failure this PR happens to sit next to.

cargo fmt --all --check clean.

What this does NOT close, stated rather than implied

  • promise/rejection.rs internally_handled — verified real and not fixed here. It is a HashSet<usize> of raw promise addresses consulted by identity (is_internally_handled), and scan_unhandled_rejection_roots_mut visits unhandled / reported / pending_handled and not it. Consequence is not a UAF: a stream's internal closed promise loses its marking after a move (spurious unhandledRejection), and an address reused by a later promise inherits it (a real unhandled rejection swallowed). The fix is a rekey, not a root — visit_metadata_usize_slot plus the remove/insert loop scan_map_iterator_array_roots_mut already uses — and it deserves its own witness. Note it is registered non-budgeted, so it is not a step-twin gap.
  • The save/restore residual. CURRENT_NEW_TARGET and ACCESSOR_RECEIVER_OVERRIDE both park the displaced value in a bare Rust local across the window and republish it afterwards. Runtime frames are not covered by the precise scan, so those locals are fix(gc): root the interned typeof strings, the rawJSON key, and every saved implicit this #7226's prev_this defect in Rust rather than in codegen. Rooting the cell closes the armed value, not the saved one; the residual is written at each declaration and needs RuntimeHandleScope plumbing.
  • module_require.rs MODULE_PATH_REGISTRY — holds module.exports values, but it is a process-global RwLock while arenas are per-thread, so a naive scanner would be unsound across perry/thread agents. Deliberately left.

Refuted — the negative results are the point

  • The step-twin gap shape is not present. All 8 budgeted (FULL, STEP) pairs were diffed field-by-field by reading both bodies (runtime_handles, temp_roots, promise ×13 tables, timer ×5, class side-table ×10, symbol ×5, tui hooks, tui state). No drift. fix(gc): root the callback and every staged argument of the timer family #7230's IntervalTimer.args fix reached both twins. Two symmetric oddities noted and left: CALLBACK_TIMERS.args is visited twice on both paths (a merge artifact — harmless, but it doubles the incremental root budget), and MOCK_TIMERS skips the crate::agent::owns filter its three siblings apply.
  • buffer/header.rs's nine address registries are not root gaps. GC_TYPE_BUFFER and GC_TYPE_TYPED_ARRAY both carry movable: false in gc/types.rs, so the addresses cannot go stale, and the registries are identity sets rather than liveness references. What they do share with the pre-fix SYMBOL_POINTERS is address-reuse aliasing after death, which wants pruning (prune_dead_symbol_pointers's shape), not rooting.
  • static_plugins.rs STATIC_PLUGINS is unreachable. perry_register_static_plugin has no caller anywhere in the workspace. Latent, not live — and by CLAUDE.md's kill-policy it should get a caller or be deleted rather than acquire a scanner nobody exercises.
  • json/mod.rs SHAPE_CACHE's safety comment ("within one top-level stringify call no GC runs over the user object graph") is falsified by toJSON(), but the consequence is a wrong shape template, and the fix is invalidation rather than rooting.
  • CACHED_PERMISSION's runtime_write_barrier_root_nanbox looked like a root registration and is not — recorded at the declaration so the next reader does not have to re-derive it.

Refs #7231, #7226, #7230, #7210, #7154, #7196.

Summary by CodeRabbit

  • Bug Fixes
    • Improved garbage-collection safety for runtime caches, callbacks, and transient state.
    • Prevented cached environment data, error handling, permissions, reports, and UI callbacks from becoming invalid after memory cleanup.
    • Improved callback handling for frame updates and worker-thread messaging.
  • Tests
    • Added regression coverage for environment variable access and enumeration during repeated garbage collection.
  • Documentation
    • Documented known runtime garbage-collection limitations and remaining edge cases.

proggeramlug pushed a commit that referenced this pull request Aug 2, 2026
@coderabbitai

coderabbitai Bot commented Aug 2, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@proggeramlug, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 18 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 1e7f06df-ea18-455b-8cd8-245ada0d4a1a

📥 Commits

Reviewing files that changed from the base of the PR and between cc9acf0 and 673751c.

📒 Files selected for processing (20)
  • changelog.d/7239-gc-unrooted-runtime-caches.md
  • crates/perry-runtime/src/frame.rs
  • crates/perry-runtime/src/gc/mod.rs
  • crates/perry-runtime/src/object/class_registry.rs
  • crates/perry-runtime/src/object/class_registry/construct.rs
  • crates/perry-runtime/src/object/field_get_set.rs
  • crates/perry-runtime/src/object/field_get_set/accessors.rs
  • crates/perry-runtime/src/object/global_fetch.rs
  • crates/perry-runtime/src/object/global_this.rs
  • crates/perry-runtime/src/object/global_this/populate.rs
  • crates/perry-runtime/src/object/mod.rs
  • crates/perry-runtime/src/process.rs
  • crates/perry-runtime/src/process/env_misc.rs
  • crates/perry-runtime/src/process/permission.rs
  • crates/perry-runtime/src/process/report.rs
  • crates/perry-runtime/src/tty.rs
  • crates/perry-runtime/src/tui/input.rs
  • crates/perry-stdlib/src/worker_threads.rs
  • test-files/test_gap_gc_process_env_cache_rooting.ts
  • test-parity/gc_repsel_corpus.txt
📝 Walkthrough

Walkthrough

The runtime now scans and rewrites cached heap values, callback pointers, and transient object state during moving garbage collection. Frame and worker callback handling was updated, and a process.env regression test was added.

Changes

Moving GC root coverage

Layer / File(s) Summary
Runtime value root scanners
crates/perry-runtime/src/object/...
Added scanners and crate-visible exports for new.target, accessor receiver state, pending fetch signals, and the cached Error constructor.
Process cache root scanners
crates/perry-runtime/src/process/...
Added relocation tracking for process.env, permission, and report caches. Permission and report caches now use module-scope thread-local storage.
Callback root scanning and dispatch
crates/perry-runtime/src/frame.rs, crates/perry-runtime/src/tty.rs, crates/perry-runtime/src/tui/input.rs, crates/perry-stdlib/src/worker_threads.rs, crates/perry-runtime/src/gc/mod.rs
Registered scanners for runtime callbacks and transient state. Frame dispatch roots drained batches, and worker-port scanners relocate all callback types.
Process environment regression coverage
test-files/test_gap_gc_process_env_cache_rooting.ts, test-parity/gc_repsel_corpus.txt, changelog.d/7239-gc-unrooted-runtime-caches.md
Added moving-GC allocation-churn coverage and recorded scanner validation, frame fixes, worker fixes, and remaining cases.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related issues

Possibly related PRs

  • PerryTS/perry#6858 — Adds moving-GC root scanners for stored callback and closure pointers.
  • PerryTS/perry#7226 — Addresses moving-GC rooting for additional runtime caches.
  • PerryTS/perry#7227 — Adds mutable root scanners and gc_init registration for untracked runtime-held values.

Suggested labels: run-extended-tests

Suggested reviewers: andrewtdiz, thehypnoo

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the primary GC cache-rooting and worker scanner fixes.
Description check ✅ Passed The description thoroughly covers the changes, related issues, unresolved items, and detailed verification results.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/7231-unrooted-runtime-caches

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.

❤️ Share

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

…hat walked 1 of 3 sibling slots

The #7231 enumeration, verified and closed. This is #7226's class -- a runtime
table holding a GC pointer that is not a registered root -- which is strictly
worse than #7154's stale-register class (it goes bad at collection #0 and stays
bad, rather than needing a collection to land in a narrow window) and which no
static instrument can find: `gc_root_dominance_check.py` reads emitted LLVM IR,
and a runtime table is not in it.

★ `CACHED_ENV`, the `process.env` object, is the load-bearing one and it is a
hard crash rather than a subtle wrong answer. `js_process_env_impl` builds it
once with `js_object_alloc` -- the NURSERY -- and caches it in a thread-local
`Cell<f64>` that is the ENTIRE reference graph: `process.env` is a
`js_process_env()` call, not a field of the `process` object. So the first
minor swept or evacuated it and every later `process.env.X = v` wrote through a
dangling pointer. The sibling `PROCESS_FINALIZATION_OBJECT` uses the identical
materialize-once idiom and was already rooted, which is what makes this an
omission rather than a design.

The observable is ENUMERATION -- `Object.keys(process.env)`, `for…in`, spread,
which is how `@next/env` and `dotenv` consume it. A direct `process.env.KEY`
read lowers to `js_getenv` and asks the OS, so a witness built on the read
would be a gate that cannot fail.

Also rooted: `CACHED_PERMISSION` and `CACHED_REPORT` (same shape; the
`runtime_write_barrier_root_nanbox` beside the first is an incremental MARK
barrier, not a root registration, and is now labelled as such);
`ERROR_CONSTRUCTOR_PTR` (a raw duplicate of a `globalThis` closure, outside the
object graph, stale after a move); `INPUT_HANDLER` (the inline `useInput`
arrow, which nothing else refers to); `RESIZE_CALLBACK` (a native slot that
bypasses the rooted EventEmitter listener array); `FRAME_CALLBACKS` (rooted
only transiently during registration -- its `unsafe impl Send` SAFETY comment
asserted the opposite and is corrected in place); `CURRENT_NEW_TARGET`;
`ACCESSOR_RECEIVER_OVERRIDE`; and `PENDING_FETCH_SIGNAL`.

Scanner gap, the shape #7230 found twice: `worker_threads.rs`'s
`scan_parent_port_event_roots_mut` visited `MESSAGE_EVENT_CALLBACKS` and
neither `MESSAGE_CALLBACK` nor `CLOSE_CALLBACK` -- three slots in the same
`thread_local!` block holding the same raw `ClosureHeader*`. The box/visit/
unbox dance is factored into one helper, because three copies of it is how the
fourth gets forgotten.

Two further windows closed in `frame.rs` while rooting its queue.
`js_frame_tick` drained into an unrooted local `Vec` and rooted each callback
only as it invoked it, leaving #2..#N naked while #1 ran arbitrary user code
(#7230's staging-buffer shape); one batch `RuntimeHandleScope` now covers the
set. And `js_on_frame_callback` held the queue mutex across an allocating
`capture_context()` -- harmless before, a self-deadlock once a scanner locks
the same mutex.

Verified against c9cd73b with `test_gap_gc_process_env_cache_rooting.ts`,
registered in `test-parity/gc_repsel_corpus.txt`. Compiled AND run under
`PERRY_GC_MOVING_LOOP_POLLS=1` with the evacuating base: SIGBUS (exit 138)
10/10 at base with no output at all, `bad 0` 10/10 after, byte-exact vs node
26.5.1. Clean 5/5 on the shipped default both sides, so this is a
`requires=move` witness rather than a pre-existing failure. Under
`ZEAL=1 PROTECT_FROMSPACE=1 DEPTH=800` the base arm faults at the stale
dereference (`obj_type=2 size=416`, RETIRED FROM-SPACE) and this build is
clean, so the instrument is a detector here and not a noise generator.

`scripts/gc_repsel_matrix.sh --arms loop_polls --filter test_gap_gc_`:
PASS=21 UNVER=0 FAIL=0, copy-minor live 21/21.

Refs #7231, #7226, #7230, #7210, #7154, #7196.
@proggeramlug
proggeramlug force-pushed the fix/7231-unrooted-runtime-caches branch from cc9acf0 to 673751c Compare August 2, 2026 06:49
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Gate results

scripts/gc_repsel_matrix.sh --no-build --arms loop_polls --filter test_gap_gc_ — the #7228 gc-moving-witnesses arm, which rejects UNVER as hard as FAIL:

summary: PASS=21 UNVER=0 XFAIL=0 FAIL=0
arm liveness across the corpus (cells where the arm actually bit):
  loop_polls   requires=move   collected 21/21   moved-objects 21/21   copy-minor 21/21
byte-exact vs node 26.5.1: 21/21 cells

The new cell gc_process_env_cache_rooting is PASS with the copying minor demonstrably live, so it is not an inert green.

cargo test --release -p perry-runtime — 1634 passed, 2 failed, and both are from the rotating order-flaky pool #7196 characterised over 33 runs on main and 19 on its branch:

test in isolation
gc::tests::teardown::map_set_side_allocations_release_exactly_once ok
object::global_this::global_this_webassembly::tests::namespace_members_exist_with_expected_shapes ok

Both names appear verbatim in #7196's list, and both pass when run alone against the same test binary. Not asserted against a from-scratch base test binary — see the unmeasured list below.

cargo fmt --all --check clean. scripts/check_file_size.sh: 16 offenders, the same set as the current baseline. This change grows two files that were already over the cap (env_misc.rs 2033→2064, worker_threads.rs 2125→2157) and adds no new offender, so the symmetric difference against main is empty.

⚠️ Unmeasured, stated plainly

  • Per-fix sabotage for the other ten caches. The base arm is the removal test for the whole set (every scanner absent → SIGBUS 10/10), and the attribution to CACHED_ENV specifically rests on elimination rather than on a single-line sabotage build: the witness touches process.env and nothing else — no process.permission, no process.report, no Error.prepareStackTrace, no new, no accessors, no TUI/TTY/frame/worker/fetch. That is a sound argument but it is an argument, not a measurement. The local host hit its disk floor (12 GB of ~30) before I could spend another release rebuild on it, and I would rather say so than round it up.
  • --arms all --pressure 8 was not run for the same reason. --arms loop_polls is the arm these witnesses are live on (every corpus note says so), so it is the one that can fail here; the full matrix would mainly re-measure the representation corpus. matrix: test_gap_repsel_p4a3_ptr_numarray red on main across ALL requires=move arms, untriaged — blocks clean gating of every barrier/GC PR (#7016 neighbourhood) #7194's known single-test/ten-arm red therefore did not come up.
  • No runtime witness for the TUI/TTY/frame/worker-thread caches — they need a tty, a SIGWINCH, a display link and a worker respectively. They are rooted on the strength of the source argument (declaration → allocator → dereference → absent scanner), which is stated per declaration.

@proggeramlug
proggeramlug merged commit eb3616e into main Aug 2, 2026
7 of 9 checks passed
@proggeramlug
proggeramlug deleted the fix/7231-unrooted-runtime-caches branch August 2, 2026 06:51

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

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

⚠️ Outside diff range comments (1)
crates/perry-runtime/src/frame.rs (1)

142-179: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Key LAST_FIRE_BY_CLOSURE on the rewritten closure pointer.

batch_handle.get_raw_const_ptr::<ClosureHeader>() is the current root pointer after GC relocation; cb.callback is a stale copy from the drained pending Vec. If an earlier callback in the loop relocates a closure, using that stale key drops the previous-fire timestamp or reuses a stale address. Store the refreshed address and use it for js_closure_call2 and LAST_FIRE_BY_CLOSURE.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-runtime/src/frame.rs` around lines 142 - 179, Within the
callback loop, derive the refreshed ClosureHeader pointer from batch_handle and
use it consistently for both js_closure_call2 and the LAST_FIRE_BY_CLOSURE map
key. Replace uses of the stale cb.callback pointer for timestamp tracking and
invocation, while preserving the existing rooting and callback iteration flow.
🧹 Nitpick comments (1)
crates/perry-runtime/src/process/env_misc.rs (1)

1104-1111: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract the repeated "root + rewrite cached f64" logic into one helper. All three scanner functions repeat the identical five-line body that reads a Cell<f64>, checks the zero sentinel, calls visitor.visit_nanbox_f64_slot, and writes back on relocation. The root cause is the lack of a shared helper for this cache-rooting idiom.

  • crates/perry-runtime/src/process/env_misc.rs#L1104-L1111: extract the body of scan_process_env_cache_roots_mut into a shared helper and call it with &CACHED_ENV bound via with.
  • crates/perry-runtime/src/process/permission.rs#L204-L211: call the same shared helper for CACHED_PERMISSION instead of repeating the body.
  • crates/perry-runtime/src/process/report.rs#L119-L126: call the same shared helper for CACHED_REPORT instead of repeating the body.
♻️ Proposed shared helper
pub(crate) fn scan_cached_f64_root(
    cell: &std::cell::Cell<f64>,
    visitor: &mut crate::gc::RuntimeRootVisitor<'_>,
) {
    let mut value = cell.get();
    if value != 0.0 && visitor.visit_nanbox_f64_slot(&mut value) {
        cell.set(value);
    }
}

Each site then becomes, e.g.:

-pub(crate) fn scan_process_env_cache_roots_mut(visitor: &mut crate::gc::RuntimeRootVisitor<'_>) {
-    CACHED_ENV.with(|cell| {
-        let mut value = cell.get();
-        if value != 0.0 && visitor.visit_nanbox_f64_slot(&mut value) {
-            cell.set(value);
-        }
-    });
-}
+pub(crate) fn scan_process_env_cache_roots_mut(visitor: &mut crate::gc::RuntimeRootVisitor<'_>) {
+    CACHED_ENV.with(|cell| scan_cached_f64_root(cell, visitor));
+}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-runtime/src/process/env_misc.rs` around lines 1104 - 1111,
Extract the repeated cached-f64 rooting logic into a shared scan_cached_f64_root
helper that reads the Cell<f64>, skips the zero sentinel, visits the value, and
writes back relocations. Update scan_process_env_cache_roots_mut in
crates/perry-runtime/src/process/env_misc.rs#L1104-L1111, and the corresponding
scanners in crates/perry-runtime/src/process/permission.rs#L204-L211 and
crates/perry-runtime/src/process/report.rs#L119-L126, to call this helper
through each cache’s with binding.
🤖 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 `@crates/perry-stdlib/src/worker_threads.rs`:
- Around line 1899-1915: Update js_worker_threads_on to call
ensure_parent_port_event_gc_scanner() at its start, before storing
MESSAGE_CALLBACK or CLOSE_CALLBACK raw closure pointers. Leave
scan_parent_port_event_roots_mut unchanged.

---

Outside diff comments:
In `@crates/perry-runtime/src/frame.rs`:
- Around line 142-179: Within the callback loop, derive the refreshed
ClosureHeader pointer from batch_handle and use it consistently for both
js_closure_call2 and the LAST_FIRE_BY_CLOSURE map key. Replace uses of the stale
cb.callback pointer for timestamp tracking and invocation, while preserving the
existing rooting and callback iteration flow.

---

Nitpick comments:
In `@crates/perry-runtime/src/process/env_misc.rs`:
- Around line 1104-1111: Extract the repeated cached-f64 rooting logic into a
shared scan_cached_f64_root helper that reads the Cell<f64>, skips the zero
sentinel, visits the value, and writes back relocations. Update
scan_process_env_cache_roots_mut in
crates/perry-runtime/src/process/env_misc.rs#L1104-L1111, and the corresponding
scanners in crates/perry-runtime/src/process/permission.rs#L204-L211 and
crates/perry-runtime/src/process/report.rs#L119-L126, to call this helper
through each cache’s with binding.
🪄 Autofix (Beta)

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: d91bbe6a-2145-482b-a6ac-9d20eb27cb04

📥 Commits

Reviewing files that changed from the base of the PR and between c9cd73b and cc9acf0.

📒 Files selected for processing (20)
  • changelog.d/7239-gc-unrooted-runtime-caches.md
  • crates/perry-runtime/src/frame.rs
  • crates/perry-runtime/src/gc/mod.rs
  • crates/perry-runtime/src/object/class_registry.rs
  • crates/perry-runtime/src/object/class_registry/construct.rs
  • crates/perry-runtime/src/object/field_get_set.rs
  • crates/perry-runtime/src/object/field_get_set/accessors.rs
  • crates/perry-runtime/src/object/global_fetch.rs
  • crates/perry-runtime/src/object/global_this.rs
  • crates/perry-runtime/src/object/global_this/populate.rs
  • crates/perry-runtime/src/object/mod.rs
  • crates/perry-runtime/src/process.rs
  • crates/perry-runtime/src/process/env_misc.rs
  • crates/perry-runtime/src/process/permission.rs
  • crates/perry-runtime/src/process/report.rs
  • crates/perry-runtime/src/tty.rs
  • crates/perry-runtime/src/tui/input.rs
  • crates/perry-stdlib/src/worker_threads.rs
  • test-files/test_gap_gc_process_env_cache_rooting.ts
  • test-parity/gc_repsel_corpus.txt

Comment on lines 1899 to 1915
fn scan_parent_port_event_roots_mut(visitor: &mut perry_runtime::gc::RuntimeRootVisitor<'_>) {
MESSAGE_EVENT_CALLBACKS.with(|cbs| {
for cb in cbs.borrow_mut().iter_mut() {
// Stored as a raw closure pointer (i64). Box it into a NaN-boxed
// pointer slot so the GC can visit + relocate it, then unbox.
let mut boxed = perry_runtime::value::js_nanbox_pointer(*cb).to_bits();
visitor.visit_nanbox_u64_slot(&mut boxed);
*cb = perry_runtime::value::js_nanbox_get_pointer(f64::from_bits(boxed));
visit_raw_closure_i64(visitor, cb);
}
});
MESSAGE_CALLBACK.with(|cb| {
if let Some(ptr) = cb.borrow_mut().as_mut() {
visit_raw_closure_i64(visitor, ptr);
}
});
CLOSE_CALLBACK.with(|cb| {
if let Some(ptr) = cb.borrow_mut().as_mut() {
visit_raw_closure_i64(visitor, ptr);
}
});
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C 5 'ensure_parent_port_event_gc_scanner\s*\(' crates/perry-stdlib/src/worker_threads.rs
rg -n -C 20 'pub extern "C" fn js_worker_threads_on\b' crates/perry-stdlib/src/worker_threads.rs

Repository: PerryTS/perry

Length of output: 2895


🏁 Script executed:

#!/bin/bash
set -euo pipefail

wc -l crates/perry-stdlib/src/worker_threads.rs
sed -n '1748,1930p' crates/perry-stdlib/src/worker_threads.rs
rg -n 'MESSAGE_CALLBACK|CLOSE_CALLBACK|parent_port_event_listener|ensure_parent_port_event_gc_scanner|scan_parent_port_event_roots_mut|gc_register_mutable_root_scanner_named' crates/perry-stdlib/src/worker_threads.rs

Repository: PerryTS/perry

Length of output: 9376


Register the parent-port event scanner before storing on() callbacks.

js_worker_threads_on saves MESSAGE_CALLBACK/CLOSE_CALLBACK as raw closure pointers, but that path does not call ensure_parent_port_event_gc_scanner(). These listeners can remain unscanned across GC collections. Add the guard at the start of js_worker_threads_on.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-stdlib/src/worker_threads.rs` around lines 1899 - 1915, Update
js_worker_threads_on to call ensure_parent_port_event_gc_scanner() at its start,
before storing MESSAGE_CALLBACK or CLOSE_CALLBACK raw closure pointers. Leave
scan_parent_port_event_roots_mut unchanged.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant