Skip to content

fix(gc): bind the catch parameter and the closure this/new.target slots, and snapshot the Object.assign string source - #7216

Merged
proggeramlug merged 2 commits into
mainfrom
fix/7214-object-assign-string-source
Aug 1, 2026
Merged

fix(gc): bind the catch parameter and the closure this/new.target slots, and snapshot the Object.assign string source#7216
proggeramlug merged 2 commits into
mainfrom
fix/7214-object-assign-string-source

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Fix-forward for #7207's review round plus the two next sites from its enumeration. Every one is reproducer-first; the one without a runtime witness says so in its own comment.

#7209 — the catch (e) slot is RESERVED and never BOUND

collect_pointer_typed_locals assigns the catch parameter an index (implicitly Any, i.e. pointer-possible), so js_shadow_frame_enter's count already included it. stmt/try_stmt.rs allocated the storage with alloca_entry, stored the exception, and never emitted js_shadow_slot_bindthe frame was sized for a root that did not exist.

Sharper than an ordinary missing root because js_clear_exception() runs two lines earlier, dropping the runtime's own reference. From there the exception is reachable only through the unrooted alloca, and the catch body is arbitrary user code — so a precise-roots collection can sweep it, not merely move it.

Fixed with emit_shadow_slot_bind_for_local, which reuses the reserved index rather than growing the frame. Emitted after the store rather than hoisted to entry setup, deliberately: on the non-throwing path the alloca is never written, and an entry-hoisted bind would make the slot active over uninitialized stack bytes.

#7208 — closures reserve no slot for captured this / new.target

codegen/method.rs:316    enable_shadow_frame(m.len() + 1)   // the +1 IS the `this` slot
codegen/method.rs:1344   enable_shadow_frame(m.len() + 1)
codegen/closure.rs:589   enable_shadow_frame(m.len())       // nothing reserved

method.rs then binds that slot; closures had no index to bind. Both slots are blk.alloca(DOUBLE) holding a heap receiver for the whole body, read by every ctx.this_stack.last() consumer. The in-tree comment claiming the capture reads are "exempt … they run in the entry-block prologue, ahead of any statement that could collect" justifies the timing of the read; it says nothing about the lifetime of the slot.

Now reserves one index per captured this / new.target and binds each inline in the entry block, right after its store.

#7214 — the Object.assign string source was a raw borrow

CodeRabbit Critical on #7207. str_bytes_from_jsvalue hands back a pointer into the source StringHeader (header + payload are one contiguous arena_alloc_gc block; GC_TYPE_STRING is movable), and its own safety note forbids holding it across a GC cycle. The loop held it across three allocation points per character. #7207 rooted the target, key and value on that arm and missed the source, because the source is a borrow rather than a JSValue in that frame.

Snapshotted once before the loop. Full evidence and the measurement in #7215.


Verification

Both defaults, on the pinned host, hashed binaries. Base is origin/main 1679e22b4.

base this PR
test_gap_gc_closure_this_capture_rooting, POLLS=1 exit 139 SIGSEGV, 3/3 bad 0 5/5
same, evacuating arm bad 4, 3/3 bad 0 3/3
test_gap_gc_catch_param_rooting, POLLS=1 message 8 field 8, 3/3 bad churn 0 message 0 field 0 5/5
test_gap_gc_assign_string_source_rooting green (no witness — see below) green
all three, shipped default green green
vs node --experimental-strip-types 26.5.1 byte-exact

#7214 has no runtime witness and the fix comment says so. An instrumented build rooted both the source and the target inside the loop and counted relocations: chars=26001 src_moves=0 tgt_moves=1. The target moved — so collections genuinely happen in that function — but that source could not, because at 26 KB it is over LARGE_OBJECT_THRESHOLD_BYTES and is born tenured in the non-moving old generation. Under the threshold it is movable but one call allocates too little to reliably span a collection; four shapes across four arm configurations (up to 120 cycles) stayed clean. The exposure is the band just under 16 KiB, and the margin rests on a tunable constant.

Both new gap files are registered in test-parity/gc_repsel_corpus.txt, with a note that catch_param_rooting is the one corpus member that can also go wrong under a non-moving precise-roots collection (the sweep case), so it belongs on cons_scan_off and not only on the requires=move arms.

Refs #7209, #7208, #7215, #7202, #7207, #7154, #7161.

Summary by CodeRabbit

  • Bug Fixes

    • Improved reliability when garbage collection occurs during Object.assign operations involving longer strings.
    • Fixed issues that could cause closure-captured this values or catch exception parameters to become invalid during memory cleanup.
    • Preserved object properties, error details, and captured receiver values during allocation-heavy execution.
  • Tests

    • Added regression coverage and stress cases for these garbage-collection scenarios.

@coderabbitai

coderabbitai Bot commented Aug 1, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 79516e96-1f22-4e04-9824-6cbed5d7496a

📥 Commits

Reviewing files that changed from the base of the PR and between d3eb229 and cad4c37.

📒 Files selected for processing (1)
  • changelog.d/7216-catch-closure-this-assign-source-roots.md

📝 Walkthrough

Walkthrough

The PR fixes moving-GC rooting for closure-captured this and new.target, catch parameters, and string sources used by Object.assign. It adds regression tests, GC stress-corpus entries, and a changelog entry.

Changes

Garbage-collection rooting

Layer / File(s) Summary
Closure capture root slots
crates/perry-codegen/src/codegen/closure.rs, test-files/test_gap_gc_closure_this_capture_rooting.ts, test-parity/gc_repsel_corpus.txt, changelog.d/...
Closure lowering reserves and binds shadow-frame slots for captured this and new.target. The regression test and corpus entry exercise closure receiver rooting under GC.
Catch parameter root binding
crates/perry-codegen/src/stmt/try_stmt.rs, test-files/test_gap_gc_catch_param_rooting.ts
Catch lowering binds the reserved slot after storing the exception. The regression test validates the caught error after allocation churn.
Stable string source iteration
crates/perry-runtime/src/object/alloc.rs, test-files/test_gap_gc_assign_string_source_rooting.ts
object_assign_string_source snapshots decoded text before allocation-heavy character iteration. The regression test validates indexed characters and property coverage.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related issues

Possibly related PRs

  • PerryTS/perry#7065 — Modifies closure code generation and shadow-stack rooting for closure-related values.
  • PerryTS/perry#7207 — Modifies Object.assign string-source handling and shadow-slot rooting.
  • PerryTS/perry#7007 — Modifies precise-root shadow-frame handling in crates/perry-codegen.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the three related garbage-collection fixes and uses the repository's accepted fix prefix.
Description check ✅ Passed The description clearly explains the changes, related issues, test coverage, verification results, and the string-source test limitation.
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 unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/7214-object-assign-string-source

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.

@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

🧹 Nitpick comments (1)
test-files/test_gap_gc_closure_this_capture_rooting.ts (1)

36-41: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Add a new.target rooting regression.

This test exercises captured this, but it does not execute the captures_new_target branch. Add a closure that captures new.target, runs churn(), and reads the captured value after collection. Register that test in test-parity/gc_repsel_corpus.txt.

Based on PR objectives: closure root slots cover both captured this and new.target.

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

In `@test-files/test_gap_gc_closure_this_capture_rooting.ts` around lines 36 - 41,
Add a regression case alongside the existing closure factory in the test, where
a returned closure captures new.target, invokes churn(), and reads the captured
value after garbage collection; then register the new test in
test-parity/gc_repsel_corpus.txt so it runs with the corpus.
🤖 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 `@test-files/test_gap_gc_assign_string_source_rooting.ts`:
- Around line 19-36: Update run and its Object.assign source-copy path so an
explicit moving-minor GC is triggered while object_assign_string_source still
holds the source view, rather than relying on repeated allocations to induce
collection. Place the trigger within the source-copy operation and preserve the
test’s existing assertions and per-iteration fresh-string setup.

---

Nitpick comments:
In `@test-files/test_gap_gc_closure_this_capture_rooting.ts`:
- Around line 36-41: Add a regression case alongside the existing closure
factory in the test, where a returned closure captures new.target, invokes
churn(), and reads the captured value after garbage collection; then register
the new test in test-parity/gc_repsel_corpus.txt so it runs with the corpus.
🪄 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: c44fe5b8-d7aa-43f8-9bf9-ab99bc9a64ae

📥 Commits

Reviewing files that changed from the base of the PR and between 9117097 and d3eb229.

📒 Files selected for processing (7)
  • crates/perry-codegen/src/codegen/closure.rs
  • crates/perry-codegen/src/stmt/try_stmt.rs
  • crates/perry-runtime/src/object/alloc.rs
  • test-files/test_gap_gc_assign_string_source_rooting.ts
  • test-files/test_gap_gc_catch_param_rooting.ts
  • test-files/test_gap_gc_closure_this_capture_rooting.ts
  • test-parity/gc_repsel_corpus.txt

Comment on lines +19 to +36
// LIVE BY CONSTRUCTION AND ONLY ON THE MOVING ARMS. The source is a FRESH heap
// string per iteration (built with `repeat`, well past the SSO limit), reachable
// only from a shadow-bound local — so it survives the minor, which means it
// MOVES. A non-moving collection leaves the bytes where they are and the borrow
// stays accidentally valid.

const ALPHA = "abcdefghijklmnopqrstuvwxyz";
const WIDTH = 208;

function run(): string {
let badChar = 0;
let badCount = 0;
for (let r = 0; r < 300; r++) {
// Past SHORT_STRING_MAX_LEN, so this is a real heap `StringHeader` in the
// nursery rather than an inline short string copied into the caller's
// scratch buffer.
const src: string = ALPHA.repeat(8);
const out: any = Object.assign({}, src);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate test registration and any moving-GC configuration.
fd -a -t f 'run_gap_tests\.sh|run_parity_tests\.sh|gc_repsel_corpus\.txt' . | sort
rg -n -C4 \
  'test_gap_gc_assign_string_source_rooting|PERRY_GC_MOVING_LOOP_POLLS|gc_repsel|moving' \
  scripts test-parity test-files || true

Repository: PerryTS/perry

Length of output: 50369


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the targeted test and its corpus registration around the relevant lines.
sed -n '1,80p' test-files/test_gap_gc_assign_string_source_rooting.ts
printf '\n--- corpus entries ---\n'
grep -n -C3 'test_gap_gc_assign_string_source_rooting\|requires=move\|cons_scan_off' test-parity/gc_repsel_corpus.txt

Repository: PerryTS/perry

Length of output: 5064


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect similar GC rooting tests that already establish a moving-GC trigger.
sed -n '1,90p' test-files/test_gap_gc_spread_accessor_rooting.ts
sed -n '1,55p' test-files/test_gap_gc_inline_ctor_this_rooting.ts

Repository: PerryTS/perry

Length of output: 4192


Make the GC trigger happen during the source copy.

This test relies on repeated allocation, but a non-moving minor leaves the source string bytes in place, so the alias can remain valid without a copy-time collection. Add an explicit moving-minor trigger that occurs while object_assign_string_source still holds the source view; otherwise regression coverage for this rooting bug depends on allocation pressure rather than the moving-GC root-liveness path.

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

In `@test-files/test_gap_gc_assign_string_source_rooting.ts` around lines 19 - 36,
Update run and its Object.assign source-copy path so an explicit moving-minor GC
is triggered while object_assign_string_source still holds the source view,
rather than relying on repeated allocations to induce collection. Place the
trigger within the source-copy operation and preserve the test’s existing
assertions and per-iteration fresh-string setup.

@proggeramlug

Copy link
Copy Markdown
Contributor Author

Verification update

Red-then-green on the final tree, all three arms

Rebuilt at the squashed commit (md5 ed3697e1…), both defaults plus the precise-roots arm:

test POLLS=1 cons_scan_off (precise, non-moving) shipped default
catch_param_rooting message 0 field 0 5/5 clean 3/3 clean 3/3
closure_this_capture_rooting bad 0 5/5 clean 3/3 clean 3/3
assign_string_source_rooting clean 5/5 clean 3/3 clean 3/3

Base (origin/main 1679e22b4) for the same two witnessed files: exit=139 SIGSEGV 3/3 and message 8 field 8 3/3 respectively.

Full runtime + codegen suites — no regressions

cargo test --release -p perry-runtime -p perry-codegen --no-fail-fast, same host, isolated target dir per arm:

origin/main 1679e22b4 this PR
unique failing test names 31 28
only-in-PR (regressions) none
only-in-base 3

The three that stopped failing are gc::tests::teardown::map_set_side_allocations_release_exactly_once, …release_on_thread_exit and gc::tests::root_words::bare_address_in_global_root_survives_a_real_collection — the order-dependent set #7161 recorded. I am not claiming this PR fixed them; they are the same flake that made native_proof_regressions swing 31→81 on #7207, observed here in the other direction. The load-bearing number is the empty only-in-PR column.

Note on frame sizing (#7208)

enable_shadow_frame(m.len() + capture_root_slots) means a closure with no pointer-typed locals but a captured this now gets a frame where it previously got none (a zero-slot frame is skipped as pure overhead). That is a real cost — one push/pop per call for those closures — and it is not optional: without a reserved index there is nothing to bind the receiver to, which is the defect. Closures that capture neither this nor new.target are byte-identical.

@proggeramlug

Copy link
Copy Markdown
Contributor Author

Matrix — the first run was contended, and the number is not usable

The --arms all --pressure 8 run reported PASS=361 UNVER=262 XFAIL=1 FAIL=90 — nine files × the ten requires=move arms. That run is invalid, and I am reporting it rather than quietly re-running, because the reason matters.

My idle gate was int($1) -gt 3, which lets 3.95 through, and a second agent's build (/Users/perry/tgt-gcroot, #7206's index_get receiver work) started on the same box mid-run. #7198's finding is exactly this: contended FAIL=70 against idle FAIL=10. Every failing cell also reported evacuated=0, i.e. the evacuating arms did not evacuate — the arm's own liveness requirement was not met, which is the matrix's built-in signal that a cell certifies nothing.

Targeted A/B instead — decisive, and it exonerates this PR

Rather than trust either number, all nine files were run standalone under the evac_minor arm env (PERRY_GC_HEAP_LIMIT=8 PERRY_GC_INCREMENTAL=0 PERRY_CONSERVATIVE_STACK_SCAN=off), base vs this PR, same host, same conditions, output hashed:

file base 1679e22b4 this PR
gc_spread_accessor_rooting exit=139 md5 d41d8cd9… exit=139 md5 d41d8cd9…
gc_static_block_this_rooting bad 1 md5 51c2d2a8… bad 1 md5 51c2d2a8…
repsel_loop_bounded_i32 md5 a68777af… md5 a68777af…
repsel_module_init_canonical md5 a165a754… md5 a165a754…
repsel_p4a_inline_tiers md5 e04697fd… md5 e04697fd…
repsel_p4a_logical_numeric md5 81729ace… md5 81729ace…
repsel_p4a3_ptr_numarray md5 83b83ddf… md5 83b83ddf…
repsel_p4b_field_store_elision exit=1 md5 96352e67… exit=1 md5 96352e67…
repsel_pshape_tower_delete md5 d62c7cdf… md5 d62c7cdf…

Byte-identical on every one, exit code included. Whatever these nine are, this PR does not cause them and does not change them.

An idle re-run is queued behind the other agent's build and will be posted when the box frees; the A/B above is the load-bearing evidence either way, since it holds base and fix to identical conditions rather than comparing a number against a different day's number.

What the A/B exposed, which is more interesting than the FAIL count

test_gap_gc_spread_accessor_rooting#7207's own test — SIGSEGVs on current main under this arm env, while passing under PERRY_GC_MOVING_LOOP_POLLS=1 and in #7207's idle matrix. #7207 verified that fix on the polls route; the evac_minor route forces the collection at the register-imprecise allocation point inside the helper, which is a different mechanism. Filed separately as #7217 with the caveat that it was observed under load and needs an idle confirmation before the mechanism is treated as settled.

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