Skip to content

fix(gc): root the generator instance across its own prototype wiring (#7577) - #7584

Merged
proggeramlug merged 3 commits into
mainfrom
fix/7577-generator-attach-prototype
Aug 7, 2026
Merged

fix(gc): root the generator instance across its own prototype wiring (#7577)#7584
proggeramlug merged 3 commits into
mainfrom
fix/7577-generator-attach-prototype

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Closes #7577.

Root cause: an ordering bug, not a missing root

js_generator_attach_prototype (crates/perry-runtime/src/object/global_this/generator.rs:381)
bound the receiver's address at function entry and used it at the tail:

let obj_ptr = jv.as_pointer::<u8>() as usize;         // entry
...
let intermediate = js_object_alloc(0, 0);             // ALLOCATES
object_set_static_prototype(intermediate as usize, gen_proto_bits);   // ALLOCATES
object_set_static_prototype(obj_ptr, intermediate_bits);              // stale
obj                                                                    // stale

This frame owns the only reference. Codegen drops the caller's root
immediately before the call — from the emitted IR for the issue's own
reproducer, in perry_fn_..._small, the frame the fault report names:

%r65.rs4p = load ptr addrspace(1), ptr %r28
%r66 = or i64 %r65, POINTER_TAG
%r67 = bitcast i64 %r66 to double
store ptr addrspace(1) null, ptr %r28              ; <-- the caller's root, dropped
%r68 = call double @js_generator_attach_prototype(double %r67, i32 0)
ret double %r68

That is the ordinary contract (a runtime helper roots its own arguments) and
this one did not. Every call it makes allocates:

call allocation
wrap_async_generator_instance three closures + three js_object_set_field_by_name keys-array transitions
generator_prototype_ptr lazily builds the entire generator intrinsic tower on its first call
js_object_alloc by definition
object_set_static_prototype object_meta_ensure mints the object's meta record out of the arena

A copying minor in any of those windows gave two wrong answers with no
diagnostic:

  1. the [[Prototype]] link was recorded against the pre-move address, so
    Object.getPrototypeOf(gen()) on the live object found nothing;
  2. the function returned that pre-move address — the caller's generator was
    a dangling pointer into retired from-space.

That is #7577's SIGBUS. Without the instruments it is a wrong answer and exit 0,
which is the expensive half.

Same shape, same fix, in three more places reachable from that call:
js_generator_attach_closure_prototype, generator_function_prototype_of, and
wrap_async_generator_instance / set_method below them. Fixing only the named
function would have handed a freshly re-read receiver to a callee that lets it
go stale again.

Every pointer is now re-read from a RuntimeHandleScope handle after the
call that could have moved it. generator_prototype_ptr is deliberately called
a second time rather than cached: it reads a GC-rooted atomic the collector
rewrites, so a fresh call is the re-read.

make_method_wrapper is deleted rather than fixed. Its signature was the
defect — taking original as a parameter forces every caller to bind that
pointer before the js_closure_alloc inside, so the capture store writes a
pre-collection address. Its one caller now allocates the wrapper first and reads
original out of its handle afterwards, which leaves no correct way to call the
helper; per CLAUDE.md's kill-policy the losing shape stops compiling rather than
waiting for a future caller.

No new runtime cache of a heap pointer, so no gc_register_mutable_root_scanner
entry is required — the handles are covered by the existing runtime-handle
scanner.

Reproduction: deterministic, not zeal-dependent

The issue's end-to-end reproducer does not fault reliably on main as of
1e971d2, and I want to be explicit about that rather than claim a repro I did
not get. A moving collection needs a safepoint, and there is none inside
js_generator_attach_prototype — allocation-triggered minors force a
conservative stack scan, which makes the copying minor ineligible. Measured, with
the instrument proven live:

[gc-fromspace-protect] mode=ProtectPages retired_set=#0 blocks=2
                       sets_held=1/800 bytes_protected=2097152
copied_objects up to 6053           # real evacuation, subject was moving
exit 0, no FAULT

(The issue's own version of the program is worse than that: churn(i)'s result
is discarded and dead-code-eliminated, so every minor reports
copied_objects=0 — the instrument is live and the collector is moving
nothing. The --pressure failure mode from #7024, in miniature.)

So the tests here inject the collection into the window instead of hoping
one lands there. force_next_general_arena_alloc_slow() +
GcTriggerThresholdTestGuard::make_arena_trigger_due() arm the next arena block
allocation to collect, and the next one is the callee's own. Pre-fix, measured:

before=0x20001280008  after=0x200014c0008  moved=true
ret=0x20001280008     ret_is_current=false
proto_on_after=None   proto_on_before=Some(...)

The receiver moved during the call; the link went to the dead address and the
dead address came back.

Coverage

crates/perry-runtime/src/gc/tests/runtime_roots/generator_attach_prototype.rs
— two cargo test -p perry-runtime tests, one per entry point. Two details that
are load-bearing rather than decorative:

  • each asserts its subject was live (the receiver actually moved) before
    asserting anything else, per CLAUDE.md's "a gate must assert its subject was
    live" — a run in which nothing moved says so instead of passing;
  • each calls register_runtime_handle_root_scanner_for_tests(). Without it the
    RuntimeHandleScope inside the function under test is decorative
    (CopyingNurseryTestGuard mem::takes the thread's mutable-root scanners),
    and I hit exactly that: the first run of the fixed code still reported
    ret_is_current=false, and the fix looked absent.

test-files/test_gap_7577_generator_prototype_rooting.ts — the observable
half: Object.getPrototypeOf(gen()), its identity across reads, and its
stability over 4000 sync + 200 async constructions with allocation churn.
Byte-identical to node --experimental-strip-types on the pinned 26.5.1, and
clean under PERRY_GC_MOVING_LOOP_POLLS=1 PERRY_GC_ZEAL=1 PERRY_GC_PROTECT_FROMSPACE=1 PERRY_GC_PROTECT_FROMSPACE_DEPTH=800 with the
instrument printing mode=ProtectPages retired_set=#0 blocks=2 bytes_protected=2097152 and copied_objects up to 6053.

Sabotage evidence

Restoring the entry-bound obj_ptr in both functions (the pre-#7577 shape)
turns both tests red on the precise defect:

attach_closure_prototype_survives_a_copying_minor_inside_the_call ... FAILED
attach_prototype_survives_a_copying_minor_inside_the_call ... FAILED
assertion `left == right` failed: js_generator_attach_prototype: must return
the receiver's CURRENT address; returning the pre-move one hands the caller a
dangling generator object
  left: 2199036952584
 right: 2199039311880

Validation

Local; CI has a deep backlog and has not reported.

  • cargo test -p perry-runtime --no-fail-fast1820 passed, 0 failed.
  • 24 generator / yield* / async-generator / iterator gap tests byte-compared
    against Node: 24/24 pass. (A 25th, test_gap_iterator_helpers_2874, is a
    standing known failure fixed by fix(runtime): give iterator helpers their own class id (#7576) #7583 on a separate branch.)
  • python3 scripts/raw_handle_debt.py → 998 (baseline 998) — the fix uses
    NaN-boxed handles throughout, so the ledger does not move.
  • scripts/check_file_size.sh → OK.
  • cargo fmt --all -- --check → clean.
  • python3 scripts/addr_class_inventory.pyfails, pre-existing: it flags
    crates/perry-runtime/src/iter_result.rs:139 (a gcheader-cast introduced by
    perf(iterator): build one object per .next(), not five (#7564) #7579), and I verified it fails identically on a clean tree at origin/main
    1e971d2. Not touched here. It needs a mutable header write, so
    try_read_gc_header (which returns &'static) is not a drop-in — it wants an
    allowlist entry or a gc_flags setter.

Summary by CodeRabbit

  • Bug Fixes

    • Improved generator and async-generator prototype handling during garbage collection.
    • Preserved generator prototype links, closure identity, and receiver references when objects move in memory.
    • Improved reliability for synchronous and asynchronous generator creation and iteration under memory pressure.
  • Tests

    • Added runtime and TypeScript regression coverage for generator prototype attachment and garbage-collection scenarios.

@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: cd5a8934-dc49-42df-a936-745e921156e9

📥 Commits

Reviewing files that changed from the base of the PR and between e25a621 and dcc039d.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (8)
  • CLAUDE.md
  • Cargo.toml
  • changelog.d/7584-generator-attach-prototype-rooting.md
  • crates/perry-runtime/src/gc/tests/runtime_roots.rs
  • crates/perry-runtime/src/gc/tests/runtime_roots/generator_attach_prototype.rs
  • crates/perry-runtime/src/object/async_generator_queue.rs
  • crates/perry-runtime/src/object/global_this/generator.rs
  • test-files/test_gap_7577_generator_prototype_rooting.ts

📝 Walkthrough

Walkthrough

Generator prototype and async-generator wrapper code now roots heap objects and re-reads relocated pointers across allocating operations. Runtime-root tests and TypeScript stress tests cover synchronous and asynchronous generator attachment under copying GC.

Changes

Generator prototype GC rooting

Layer / File(s) Summary
Root generator prototype attachment
crates/perry-runtime/src/object/global_this/generator.rs
Generator prototype creation and both attachment paths use runtime handles, re-read moved pointers, and return current receiver addresses.
Root async wrapper installation
crates/perry-runtime/src/object/async_generator_queue.rs
Async-generator wrappers and methods root objects and closures across allocations. The obsolete make_method_wrapper helper was removed.
Validate moved generator receivers
crates/perry-runtime/src/gc/tests/runtime_roots/*, test-files/test_gap_7577_generator_prototype_rooting.ts, changelog.d/7584-generator-attach-prototype-rooting.md, CLAUDE.md, Cargo.toml
Deterministic runtime-root tests and TypeScript stress tests validate prototype links and generator behavior under copying GC. The changelog and version metadata were updated.

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

Sequence Diagram(s)

sequenceDiagram
  participant GeneratorAttachment
  participant RuntimeHandleScope
  participant CopyingMinorGC
  participant AsyncGeneratorQueue
  GeneratorAttachment->>RuntimeHandleScope: root receiver and closures
  GeneratorAttachment->>CopyingMinorGC: allocate prototype and wrappers
  CopyingMinorGC-->>RuntimeHandleScope: relocate rooted objects
  GeneratorAttachment->>RuntimeHandleScope: re-read current pointers
  GeneratorAttachment->>AsyncGeneratorQueue: install wrappers with current pointers
  AsyncGeneratorQueue-->>GeneratorAttachment: return current receiver
Loading

Possibly related PRs

  • PerryTS/perry#7065 — Both changes address moving-GC safety in generator and closure prototype handling.
  • PerryTS/perry#6994 — Both changes root runtime objects and re-read relocated pointers across allocations.
  • PerryTS/perry#6972 — Both changes preserve and refresh live heap pointers across moving-GC operations.

Suggested labels: type:bug

🚥 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 identifies the generator prototype-rooting fix and references issue #7577.
Description check ✅ Passed The description is detailed and covers the change, related issue, tests, validation results, and known pre-existing failure.
Linked Issues check ✅ Passed The changes root and re-read generator pointers across allocating calls and add regression coverage for issue #7577.
Out of Scope Changes check ✅ Passed The code, changelog, and regression tests are directly related to generator prototype rooting and moving-GC safety.
✨ 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/7577-generator-attach-prototype

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: 3

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/object/global_this/generator.rs (1)

523-546: 🩺 Stability & Availability | 🔴 Critical | 🏗️ Heavy lift

The closure operand in js_generator_attach_closure_prototype is not rooted. The receiver is rooted through obj_h, but the closure local remains a raw *const ClosureHeader across wrap_async_generator_instance, which allocates three closures and three keys-array transitions. A raw Rust local is not a GC root and the collector does not rewrite it, so generator_function_prototype_of can receive a pre-move closure address and cache g.prototype on a dead closure. This one gap explains the missing test coverage and the overstated changelog claim.

  • crates/perry-runtime/src/object/global_this/generator.rs#L523-L546: root closure in the existing RuntimeHandleScope after Line 523 and re-read it from the handle before the generator_function_prototype_of call at Line 544.
  • crates/perry-runtime/src/gc/tests/runtime_roots/generator_attach_prototype.rs#L133-L155: register the test function with js_register_closure_async_generator_function and install own next, return, and throw closures on the receiver, so wrap_async_generator_instance runs under the armed collection and the closure-movement window is exercised.
  • changelog.d/7584-generator-attach-prototype-rooting.md#L26-L30: correct the sentence "Every pointer now comes back out of a RuntimeHandleScope handle after the call that could have moved it" once the closure operand is rooted, or scope the claim to the pointers that are actually rooted.
🤖 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/object/global_this/generator.rs` around lines 523 -
546, Root the closure operand in the existing RuntimeHandleScope within
crates/perry-runtime/src/object/global_this/generator.rs#L523-L546, then re-read
it from the handle after wrap_async_generator_instance and before
generator_function_prototype_of. In
crates/perry-runtime/src/gc/tests/runtime_roots/generator_attach_prototype.rs#L133-L155,
register the function with js_register_closure_async_generator_function and
install own next, return, and throw closures to exercise the movement window. In
changelog.d/7584-generator-attach-prototype-rooting.md#L26-L30, revise the claim
about every pointer being re-read from rooted handles to match the corrected
implementation.
🧹 Nitpick comments (1)
crates/perry-runtime/src/gc/tests/runtime_roots/generator_attach_prototype.rs (1)

91-103: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Assert the recorded prototype's identity, not only its address class.

The check confirms that the recorded [[Prototype]] is a plausible heap address. It does not confirm that the address is the correct prototype object. A link to any surviving heap object passes.

Compare the recorded value against the expected prototype. For js_generator_attach_prototype that is the intermediate whose own prototype is generator_prototype_ptr(false). For js_generator_attach_closure_prototype that is the value returned by generator_function_prototype_of for the same closure. Passing the expected address into the helper would close the gap.

Using crate::value::addr_class::is_plausible_heap_addr for the address-class check is correct and matches the canonical predicate.

🤖 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/gc/tests/runtime_roots/generator_attach_prototype.rs`
around lines 91 - 103, Update the prototype-recording assertion helper around
object_static_prototype to accept and compare an expected prototype address, not
just validate heap-address plausibility. Pass the intermediate whose own
prototype is generator_prototype_ptr(false) from js_generator_attach_prototype,
and pass generator_function_prototype_of for the same closure from
js_generator_attach_closure_prototype; retain the existing
is_plausible_heap_addr check.

Source: Learnings

🤖 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-runtime/src/gc/tests/runtime_roots/generator_attach_prototype.rs`:
- Around line 37-46: Update warm_generator_intrinsics to call the generator
intrinsic tower builder directly, using the visible ensure_generator_intrinsics
or equivalent builder symbol, instead of passing TAG_UNDEFINED to
js_generator_attach_prototype. Confirm the builder’s visibility from the test
module and preserve the helper’s purpose of constructing the tower before the
allocation-triggered call under test.
- Around line 133-155: Add async coverage alongside
attach_closure_prototype_survives_a_copying_minor_inside_the_call: register the
fake function with js_register_closure_async_generator_function, install own
next, return, and throw closures on the receiver, and exercise
js_generator_attach_closure_prototype with collection armed so
wrap_async_generator_instance runs. Add the corresponding is_async = 1 case for
js_generator_attach_prototype while preserving the existing synchronous test.

In `@test-files/test_gap_7577_generator_prototype_rooting.ts`:
- Around line 31-34: Update the prototype checks around the generator examples:
store two separate reads of small.prototype in distinct bindings before
comparing them, and store the results of two separate small(1) calls in distinct
bindings before comparing their prototypes. Preserve both identity checks while
avoiding direct self-comparisons flagged by Biome.

---

Outside diff comments:
In `@crates/perry-runtime/src/object/global_this/generator.rs`:
- Around line 523-546: Root the closure operand in the existing
RuntimeHandleScope within
crates/perry-runtime/src/object/global_this/generator.rs#L523-L546, then re-read
it from the handle after wrap_async_generator_instance and before
generator_function_prototype_of. In
crates/perry-runtime/src/gc/tests/runtime_roots/generator_attach_prototype.rs#L133-L155,
register the function with js_register_closure_async_generator_function and
install own next, return, and throw closures to exercise the movement window. In
changelog.d/7584-generator-attach-prototype-rooting.md#L26-L30, revise the claim
about every pointer being re-read from rooted handles to match the corrected
implementation.

---

Nitpick comments:
In
`@crates/perry-runtime/src/gc/tests/runtime_roots/generator_attach_prototype.rs`:
- Around line 91-103: Update the prototype-recording assertion helper around
object_static_prototype to accept and compare an expected prototype address, not
just validate heap-address plausibility. Pass the intermediate whose own
prototype is generator_prototype_ptr(false) from js_generator_attach_prototype,
and pass generator_function_prototype_of for the same closure from
js_generator_attach_closure_prototype; retain the existing
is_plausible_heap_addr check.
🪄 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: d003a491-e958-4a76-ab74-c0d54474c742

📥 Commits

Reviewing files that changed from the base of the PR and between 9688bbe and 1dcb551.

📒 Files selected for processing (6)
  • changelog.d/7584-generator-attach-prototype-rooting.md
  • crates/perry-runtime/src/gc/tests/runtime_roots.rs
  • crates/perry-runtime/src/gc/tests/runtime_roots/generator_attach_prototype.rs
  • crates/perry-runtime/src/object/async_generator_queue.rs
  • crates/perry-runtime/src/object/global_this/generator.rs
  • test-files/test_gap_7577_generator_prototype_rooting.ts

Comment on lines +37 to +46
/// Build the generator intrinsic tower up front. It is lazily constructed on
/// the first `generator_prototype_ptr` call and costs dozens of allocations;
/// paying it here keeps the call under test down to its own two, so the
/// injected trigger lands where we intend.
fn warm_generator_intrinsics() {
let _ = crate::object::js_generator_attach_prototype(
f64::from_bits(crate::value::TAG_UNDEFINED),
0,
);
}

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 | ⚡ Quick win

warm_generator_intrinsics does not warm anything.

The helper calls js_generator_attach_prototype with TAG_UNDEFINED. That value fails the !jv.is_pointer() guard at the top of js_generator_attach_prototype, so the function returns immediately. It never reaches generator_prototype_ptr, so the intrinsic tower is never built.

The doc at Lines 37-40 states that warming "keeps the call under test down to its own two" allocations. That premise does not hold. The tower is still built lazily inside the call under test, which adds many allocations before the intended window. The armed trigger then fires during tower construction, not in the window the module doc describes at Lines 21-24.

Call the intrinsic builder directly instead.

💚 Proposed fix to actually build the tower
 fn warm_generator_intrinsics() {
-    let _ = crate::object::js_generator_attach_prototype(
-        f64::from_bits(crate::value::TAG_UNDEFINED),
-        0,
-    );
+    // Force the lazy tower build for both brands before the trigger is armed.
+    let _ = crate::object::global_this::generator::generator_prototype_ptr(false);
+    let _ = crate::object::global_this::generator::generator_prototype_ptr(true);
 }

Confirm the visibility of the builder from the test module:

#!/bin/bash
# Locate the intrinsic-tower entry points and their visibility.
rg -nP -C 3 'fn\s+(ensure_generator_intrinsics|generator_prototype_ptr)\s*\(' --type=rust
rg -nP -C 3 '\b(pub(\(crate\))?\s+)?mod\s+generator\b' --type=rust -g '**/global_this/**'
🤖 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/gc/tests/runtime_roots/generator_attach_prototype.rs`
around lines 37 - 46, Update warm_generator_intrinsics to call the generator
intrinsic tower builder directly, using the visible ensure_generator_intrinsics
or equivalent builder symbol, instead of passing TAG_UNDEFINED to
js_generator_attach_prototype. Confirm the builder’s visibility from the test
module and preserve the helper’s purpose of constructing the tower before the
allocation-triggered call under test.

Comment on lines +31 to +34
console.log("B stable:", small.prototype === small.prototype);

// Every instance of the same generator function shares one `g.prototype`.
console.log("C shared:", Object.getPrototypeOf(small(1)) === Object.getPrototypeOf(small(1)));

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 | 🟡 Minor | ⚡ Quick win

Fix the self-comparison at Line 31 and suppress the false positive at Line 34.

Biome reports lint/suspicious/noSelfCompare on both lines.

Line 31 is a genuine self-comparison. small.prototype === small.prototype is always true in any conforming engine and an optimizer may fold it. It does not test identity stability across reads. Capture two separate reads into bindings.

Line 34 is a false positive. The two small(1) calls produce distinct generator instances, so the comparison is meaningful. Bind the two results to make the intent explicit and to satisfy the rule.

💚 Proposed fix
 // `g.prototype` identity is stable across reads.
-console.log("B stable:", small.prototype === small.prototype);
+const protoRead1 = small.prototype;
+const protoRead2 = small.prototype;
+console.log("B stable:", protoRead1 === protoRead2);
 
 // Every instance of the same generator function shares one `g.prototype`.
-console.log("C shared:", Object.getPrototypeOf(small(1)) === Object.getPrototypeOf(small(1)));
+const shared1 = Object.getPrototypeOf(small(1));
+const shared2 = Object.getPrototypeOf(small(1));
+console.log("C shared:", shared1 === shared2);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
console.log("B stable:", small.prototype === small.prototype);
// Every instance of the same generator function shares one `g.prototype`.
console.log("C shared:", Object.getPrototypeOf(small(1)) === Object.getPrototypeOf(small(1)));
// `g.prototype` identity is stable across reads.
const protoRead1 = small.prototype;
const protoRead2 = small.prototype;
console.log("B stable:", protoRead1 === protoRead2);
// Every instance of the same generator function shares one `g.prototype`.
const shared1 = Object.getPrototypeOf(small(1));
const shared2 = Object.getPrototypeOf(small(1));
console.log("C shared:", shared1 === shared2);
🧰 Tools
🪛 Biome (2.5.6)

[error] 31-31: This comparison uses the same expression on both sides.

(lint/suspicious/noSelfCompare)


[error] 34-34: This comparison uses the same expression on both sides.

(lint/suspicious/noSelfCompare)

🤖 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_7577_generator_prototype_rooting.ts` around lines 31 -
34, Update the prototype checks around the generator examples: store two
separate reads of small.prototype in distinct bindings before comparing them,
and store the results of two separate small(1) calls in distinct bindings before
comparing their prototypes. Preserve both identity checks while avoiding direct
self-comparisons flagged by Biome.

Source: Linters/SAST tools

Ralph Küpper added 2 commits August 7, 2026 09:09
…7577)

`js_generator_attach_prototype` bound the receiver's address at function entry
and used it at the tail, with four allocating calls in between. Same shape in
`js_generator_attach_closure_prototype`, `generator_function_prototype_of`, and
`wrap_async_generator_instance` / `set_method` below them.

This frame owns the only reference. Codegen drops the caller's root immediately
before the call:

    %r67 = bitcast i64 %r66 to double
    store ptr addrspace(1) null, ptr %r28     ; the caller's root, dropped
    %r68 = call double @js_generator_attach_prototype(double %r67, i32 0)

which is the ordinary contract — a runtime helper roots its own arguments —
except this one did not. And every call it makes allocates:
`wrap_async_generator_instance` (three closures, three field sets),
`generator_prototype_ptr` (lazily builds the whole generator intrinsic tower on
first call), `js_object_alloc`, and `object_set_static_prototype` itself, whose
`object_meta_ensure` mints the object's meta record out of the arena.

A copying minor in any of those windows produced two wrong answers with no
diagnostic: the `[[Prototype]]` link was recorded against the PRE-MOVE address,
so `Object.getPrototypeOf(gen())` on the live object found nothing; and the
function RETURNED that pre-move address, so the caller's generator was a
dangling pointer into retired from-space. That is #7577's SIGBUS; without the
instruments it is a wrong answer and exit 0.

Every pointer is now re-read from a `RuntimeHandleScope` handle after the call
that could have moved it. `generator_prototype_ptr` is deliberately called a
second time rather than cached — it reads a GC-rooted atomic the collector
rewrites, so a fresh call IS the re-read.

`make_method_wrapper` is deleted rather than fixed: its signature was the
defect. Taking `original` as a parameter forces every caller to bind that
pointer before the `js_closure_alloc` inside, so the capture store writes a
pre-collection address. The one caller now allocates first and reads `original`
out of its handle, which leaves no correct way to call the helper.

Reproduced DETERMINISTICALLY rather than under zeal. The issue's end-to-end
reproducer needs a moving collection to land in a window that no safepoint
reaches, so it does not fault reliably (measured: instrument live —
`mode=ProtectPages retired_set=#0 blocks=2 bytes_protected=2097152` — and
`copied_objects` up to 6053, but no fault). Instead the new tests inject the
collection exactly where it belongs: `force_next_general_arena_alloc_slow()` +
`GcTriggerThresholdTestGuard::make_arena_trigger_due()` make the next arena
block allocation collect, and the next one is the callee's own. Pre-fix, both
assertions fire; the receiver moved 0x20001280008 → 0x200014c0008 and the
function returned 0x20001280008.

Coverage:

  * `crates/perry-runtime/src/gc/tests/runtime_roots/generator_attach_prototype.rs`
    — two `cargo test -p perry-runtime` tests, one per entry point. Each
    asserts its subject was live (the receiver actually moved) before asserting
    anything else, per CLAUDE.md's "a gate must assert its subject was live";
    each calls `register_runtime_handle_root_scanner_for_tests()`, without which
    the `RuntimeHandleScope` under test is decorative and the test would pass
    for the wrong reason.
  * `test-files/test_gap_7577_generator_prototype_rooting.ts` — the observable
    half (`Object.getPrototypeOf(gen())`, its identity, and its stability over
    4000 sync + 200 async constructions with allocation churn), byte-identical
    to `node --experimental-strip-types` 26.5.1, and clean under
    `PERRY_GC_MOVING_LOOP_POLLS=1 PERRY_GC_ZEAL=1 PERRY_GC_PROTECT_FROMSPACE=1
    PERRY_GC_PROTECT_FROMSPACE_DEPTH=800`.

No new runtime cache of a heap pointer, so no `gc_register_mutable_root_scanner`
entry is needed; the handles are covered by the existing runtime-handle scanner.

Sabotage-verified: restoring the entry-bound `obj_ptr` in both functions turns
both tests red on "must return the receiver's CURRENT address".
@proggeramlug
proggeramlug force-pushed the fix/7577-generator-attach-prototype branch from 1dcb551 to dcc039d Compare August 7, 2026 07:14
@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

@proggeramlug
proggeramlug merged commit b7cd2fb into main Aug 7, 2026
10 of 12 checks passed
@proggeramlug
proggeramlug deleted the fix/7577-generator-attach-prototype branch August 7, 2026 07:15
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Audit before merge — verified, merged as v0.5.1331

Sabotage-verified the fix myself: replacing the tail obj_h.get_nanbox_f64()
with the entry-bound obj turns attach_prototype_survives_a_copying_minor_inside_the_call
red with exactly the asserted message —

js_generator_attach_prototype: must return the receiver's CURRENT address; returning the pre-move one hands the caller a dangling generator object

Full runtime suite 1836 passed / 0 failed. All four lint gates re-run here:
addr_class_inventory passes, raw_handle_debt 998 (baseline 998),
check_file_size.sh and cargo fmt --check clean.

The fix is the right shape for this family — root once, then re-read from the
rooted slot after each allocating call, rather than caching the pointer across
them. Ordering, not a missing root, exactly as CLAUDE.md says every member of
this family has turned out to be.

One gap, found by sabotaging the wrong function first

My first sabotage attempt patched generator_function_prototype_of (the
tail return at what is now line 116) and both tests stayed green. They
target js_generator_attach_prototype and js_generator_attach_closure_prototype.

So of the functions this PR fixes — js_generator_attach_prototype,
js_generator_attach_closure_prototype, generator_function_prototype_of, and
wrap_async_generator_instance/set_method beneath them — two are covered
and the rest are not
. Not merge-blocking: the pattern is identical across all
of them and the two hardest paths are proven. But an unexercised fix in this
family is precisely what regresses silently, since the failure surfaces cycles
later in an unrelated function as TypeError: value is not a function. Worth a
follow-up that extends the same injected-collection harness to the other three.

Two things from the report worth keeping

The issue's own reproducer cannot reproduce. Its program discards
churn(i), which is dead-code-eliminated, so every minor reports
copied_objects=0 — the instrument armed and live, the collector moving
nothing. Catching that is the difference between "did not reproduce" and "the
reproducer was vacuous", and it is the same family as the traps CLAUDE.md
records under "a gate must assert its subject was live". The tests inject the
collection into the exact window instead (force_next_general_arena_alloc_slow()

  • make_arena_trigger_due()), with pre-fix evidence:
    before=0x20001280008 after=0x200014c0008 moved=true ret=0x20001280008 ret_is_current=false.

Why that frame owns the only reference: codegen nulls the caller's
statepoint slot immediately before the call (store ptr addrspace(1) null then
call @js_generator_attach_prototype). So there is no second root to save it —
the callee's own handle is the only thing standing between the receiver and a
dangling pointer.

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.

js_generator_attach_prototype derefs retired from-space memory (SIGBUS under GC zeal)

1 participant