fix(gc): await polled a promise the event-loop pump had already moved - #7375
Conversation
The await loop's `wait` block calls js_promise_run_microtasks_await_loop, js_run_stdlib_pump and js_await_loop_tick_timers -- all of which allocate and can drive an evacuating minor -- then branches back to `check`, which re-unboxes the SAME SSA value. After one pump the loop polls retired from-space and js_promise_state dereferences it. The comment already there -- 'unbox the promise in each block that uses it' -- solves LLVM's dominance requirement, which is a different problem. The box names the pre-collection promise, so unboxing it again in each of the five blocks reproduced the stale address rather than fixing it. The promise now takes one temp root and every block re-reads the slot the collector rewrites. 4/4 cluster tests clean (6/6 faults before, 0/6 after), byte-identical to Node. Closes 4 of the 31 catches in #7341, all obj_type=5 (GC_TYPE_PROMISE) with frame #1 in generated code.
📝 WalkthroughWalkthroughThis PR fixes a garbage collection bug in the ChangesAwait promise rooting fix
Estimated code review effort: 2 (Simple) | ~10 minutes Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 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.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
crates/perry-codegen/src/expr/fs_await.rs (1)
111-279: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick winTruncate the promise temp root before returning it.
temp_root_push_doublerequires an@js_gc_temp_root_truncatecall after the last read/use contractually. The merge path must truncatepromise_rootbefore returningresult_slot; the async rejection path can omit it because it returns afterret. Skip the throw/unreachable 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 `@crates/perry-codegen/src/expr/fs_await.rs` around lines 111 - 279, Truncate the promise temporary root on the normal merge path after the final use of promise_root and before loading and returning result_slot. Add the required js_gc_temp_root_truncate call in the merge block, while leaving the async rejection ret path and throw/unreachable path unchanged.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.
Outside diff comments:
In `@crates/perry-codegen/src/expr/fs_await.rs`:
- Around line 111-279: Truncate the promise temporary root on the normal merge
path after the final use of promise_root and before loading and returning
result_slot. Add the required js_gc_temp_root_truncate call in the merge block,
while leaving the async rejection ret path and throw/unreachable path unchanged.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 8203c002-a576-42e6-946f-0171b97ae9da
📒 Files selected for processing (2)
changelog.d/7375-await-promise-rooting.mdcrates/perry-codegen/src/expr/fs_await.rs
…r it was written (#7378) #7341's quarantine produced 31 real stale-pointer bugs; four were fixed (#7373-#7376). None existed when the RFC's 'would it have caught the real bugs' table was written, so they are the strongest available calibration. It would have caught ONE of the four. #7375 (await polling a moved promise) is squarely in scope and would have been caught completely -- notably it had survived a comment explicitly reasoning about the surrounding hazard, which is the RFC's own central argument. The other three lived in perry-runtime, which this RFC does not govern. Recording that honestly rather than only the win, because the ratio is the useful part: it says where the remaining risk lives. Three of four were layer 3, where RuntimeHandleScope exists (675 uses) but is optional. The sharper finding is that all four were the SAME defect shape -- ordering a root relative to a collection point, never a missing root. That is exactly what a Raw that dies at the next &mut emit enforces, so the design generalises; the open question is whether layer 3 needs the same discipline rather than whether codegen does. Also notes the sample is favourable: the eight catches left open in #7341 are caller-side, and Raw<'e> does not cross a function boundary either. No code change -- the RFC says step 1 wants a quiet tree, and expr/ is under edit by #7375. Co-authored-by: Ralph Küpper <ralph@skelpo.com>
Closes 4 of 31 catches in #7341 — all
obj_type=5(GC_TYPE_PROMISE) with frame #1 in generated code.The bug
The await loop's
waitblock callsjs_promise_run_microtasks_await_loop,js_run_stdlib_pumpandjs_await_loop_tick_timers— all of which allocate and can drive an evacuating minor — then branches back tocheck, which re-unboxes the same SSA value:After one pump the loop polls retired from-space and
js_promise_statedereferences it.The comment that was already there solved a different problem
That addresses dominance, not GC movement. The box names the pre-collection promise, so unboxing it again in each of the five blocks reproduced the stale address five times rather than fixing it.
The promise now takes one temp root, and every block re-reads the slot the collector rewrites instead of reusing the register.
Verification
4/4 cluster tests go from 6/6 faults to 0/6, byte-identical to Node. 614/614 codegen tests pass.
Summary by CodeRabbit