fix(codegen): PreallocateBoxes must not shadow a module-level global (#7521) - #7580
Conversation
|
Caution Review failedThe pull request is closed. ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: ⛔ Files ignored due to path filters (1)
📒 Files selected for processing (6)
📝 WalkthroughWalkthroughThis change prevents ChangesModule-global preallocation
Estimated code review effort: 3 (Moderate) | ~20 minutes Suggested labels: Suggested reviewers: ✨ 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 |
Raw GC-ratchet A/B, both arms same sessionBoth arms built from one target directory with the identical The one semantic cell is
That last row is #7559 verbatim. It does not move here in either direction — this PR is not its cause and not its fix. The eight two-sided breaches on Caveat stated plainly: this pair ran on the shared dev machine (load 42), so only the semantic families — which the harness documents at 0.000% spread and which are machine-independent — carry weight. The wall-time table in the PR body is from the pinned quiet host. Correction to the issue brief, and one extra changeThe brief said the gap test is in neither That entry is now stale — the feature cluster it was filed for has long since Caveat stated plainly: that verification is macOS/arm64 only. If the test turns |
`emit_preallocate_boxes` allocated a heap box — and, decisively, a `ctx.locals` slot — for every id in a `Stmt::PreallocateBoxes` directive, including ids that `codegen/module_globals_emit.rs` had already promoted to `@perry_global_<mod>__<id>`. Every other read/write site in codegen already encodes the rule that the module global wins over the box (`ctx.boxed_vars.contains(id) && !ctx.module_globals.contains_key(id)`). This one did not, and the stale `ctx.locals` entry it left behind is consulted BEFORE `ctx.module_globals` on the `Stmt::Let` reuse path (`let_stmt.rs`) and on the `LocalGet`/`LocalSet` store paths (`expr/literals_vars.rs`). The declaration therefore wrote its value into the local box-pointer slot; the module global was never stored; and every closure that reads the binding through the global saw the `undefined` it was defined with. Since #7105 added `lower_strict_block_fn_decls`, a bare block at the top level of any ES module emits `PreallocateBoxes` for the block's `let`/`const` bindings, so this hit ordinary code: { const events = []; function t() { events.push("x") } t(); events.length } `t()` ran, `events.length` was 0, and nothing threw. Objects, numbers and strings lost the same way (`acc` read as `undefined` inside `t`). Fix: skip promoted ids in `emit_preallocate_boxes`. The global is statically initialized to `TAG_UNDEFINED`, which is exactly what a non-TDZ prealloc box seeds. The TDZ variant is skipped too — module-global reads are raw `load double @g` with no `js_box_get_bits` choke point, so seeding `TAG_TDZ` there would leak the sentinel into arithmetic rather than throwing. Closes #7521 Claude-Session: https://claude.ai/code/session_019EHcmXKArA7m42SihYCcgH
…l gap test `test_gap_diagchannel_3082_3084_3085_3086` was suppressed on 2026-07-04 for the original #3082/#3084/#3085/#3086 feature cluster, which has since landed. With the PreallocateBoxes fix it byte-matches `node --experimental-strip-types` (26.5.1) with exit 0 across all four blocks. `parity_known_failures.py` only computes `failures - allowed`, so a stale entry never fails — unlike the gc-root-dominance allowlist, where an entry that matches nothing is a red build. Leaving it would silence a future regression of exactly the bug just fixed. Verified on macOS/arm64 only; a platform-specific surprise would surface in the tag-gated `parity` job. Claude-Session: https://claude.ai/code/session_019EHcmXKArA7m42SihYCcgH
…p test The fragment said the test was in neither gap_snapshot.json nor known_failures.json, while the same fragment (and the diff) retire its known_failures.json entry. It was absent from gap_snapshot.json and SUPPRESSED by a stale known_failures.json entry.
3dab6cf to
5b25de5
Compare
Audit before merge — verified independently, merged as v0.5.1328Reproduced the minimal case on a clean build first, because the root cause That is the whole bug in four lines, and it confirms the report's key This is much wider than one gap test. Every ES module with a top-level bare Post-fix, verified on my own build: minimal repro correct, and the gap test Sabotage-verified the new tests myself. Commenting out the Worth singling out, because it is the kind of thing that usually ships Gates re-run here: One correction to the fragment, applied before merge. It stated the gap test The suppression finding deserved its own ticket and now has one: #7582. |
Closes #7521.
Root cause
Not in
diagnostics_channel, and not a closure-capture bug.traceCallbackisa bystander — the minimal repro has no
traceCallbackin it, and no array:Objects, numbers and strings lose the same way — inside
t1, the capturedbinding simply reads
undefined, soacc.n++throwsCannot read properties of undefinedandevents.push(...)silentlyno-ops. The
importmatters only because it makes the file an ES module,hence strict, which is what selects the affected lowering. Everything else
about the failing gap test (
traceCallback, subscribers, arrows, the array)was incidental.
crates/perry-codegen/src/stmt/mod.rs,emit_preallocate_boxes: it allocateda heap box — and, decisively, a
ctx.localsslot — for every id in aStmt::PreallocateBoxesdirective, including ids thatcodegen/module_globals_emit.rshad already promoted to@perry_global_<mod>__<id>.Codegen already has a rule for this collision, spelled out identically at every
read and write site in
expr/literals_vars.rsandstmt/let_stmt.rs:The module global wins — it is already the shared, forward-visible, GC-rooted
cell a box would provide.
emit_preallocate_boxeswas the one place that neverchecked, and the stale
ctx.localsentry it left behind is consulted beforectx.module_globalson both theStmt::Letreuse path (let_stmt.rs:322) andthe
LocalGet/LocalSetstore paths (literals_vars.rs:642,:814). So thedeclaration wrote its value into the local box-pointer slot, the module global
was never stored, and the closure — which reads through the global, with zero
captures — saw the
undefinedthe global was defined with.The emitted IR, before:
@perry_global_m3_ts__1is defined, registered as a GC root, read and writtenby the closure — and never stored by
main.Why now
c6ed8175d(#6853) is not the window.lower_strict_block_fn_declswas addedby #7105 (
76fa7b4c9, 2026-08-01), which made a bare block at the top levelof any ES module emit
PreallocateBoxesfor itslet/constbindings. That isthe first time a promoted module-level id was ever handed to
emit_preallocate_boxes. The two commits the issue records as PASS(
17c0ff952,c6ed8175d, both 2026-07-30) both predate it, so the recordedevidence is consistent — no bisect was needed once the diff was in view.
Fix
Skip promoted ids in
emit_preallocate_boxes. The global is staticallyinitialized to
TAG_UNDEFINED, which is exactly what a non-TDZ prealloc boxseeds, so nothing is lost. The TDZ variant is skipped too and the reason is
written into the code: module-global reads are a raw
load double @gwith nojs_box_get_bitschoke point, so seedingTAG_TDZthere would leak thesentinel into arithmetic instead of throwing a
ReferenceError— strictly worsethan the
undefineda forward read gets today.Tests
crates/perry-codegen/src/stmt/prealloc_module_global_tests.rs— an in-srcunit test module, deliberately not
crates/*/tests/*.rs, so it runs in theper-PR
cargo-testgate forever rather than only nightly (CLAUDE.md #5960).The gap test that surfaced this (
test_gap_diagchannel_3082_3084_3085_3086.ts)is in neither
gap_snapshot.jsonnorknown_failures.jsonandparityistag-gated, which is how this sat unnoticed since 2026-07-30.
Four tests, and they are sabotage-checked rather than merely green — with the
ctx.module_globalsguard reverted:the_captured_module_binding_is_promoted_to_a_global(fixture premise)a_preallocated_box_does_not_swallow_the_module_global_storeprealloc_is_a_no_op_for_a_promoted_bindinga_function_local_prealloc_still_gets_its_box(#569/#6044 not gutted)The store assertion counts
store … ptr @perry_global_…lines insidemain()specifically. A plain
containswas not enough and was caught doing nothing:mainalso takes the global's address forjs_gc_register_global_root, whichis emitted whether or not the declaration ever writes the cell, so the first
draft of that test passed under sabotage.
Validation (local — CI has a deep backlog and may not report)
test_gap_diagchannel_3082_3084_3085_3086.tsbyte-matchesnode --experimental-strip-types(Node 26.5.1), exit 0, all four blocks(node:diagnostics_channel: forward all runStores callback arguments #3082, node:diagnostics_channel: reject symbol tracingChannel names #3084, node:diagnostics_channel: preserve non-callable bindStore transform errors #3085, node:diagnostics_channel: honor traceCallback position and arguments #3086) — not just the one that was failing.
traceCallback, plainchannel, no channel atall, function decl / function expression / arrow, array / object / number /
string accumulators) all match node exactly.
cargo test -p perry-codegen --lib— 671 passed, 0 failed.cargo test -p perry-hir --lib— 281 passed, 0 failed.RUST_TEST_THREADS=1 cargo test -p perry-runtime --lib— 1812 passed, 0 failed.(the exact shape this touches) plus a broader gap slice — all pass.
cargo fmt --all -- --checkclean;scripts/check_file_size.shOK;scripts/raw_handle_debt.py998 (baseline 998, unchanged).Scope note
The blast radius is wider than one gap test: every ES module with a
top-level
{ … }block containing afunctiondeclaration that reads asibling
let/constcompiled to a silently-empty binding. Nothing threw, sothis class of breakage was invisible except where a test happened to print the
accumulated value.
https://claude.ai/code/session_019EHcmXKArA7m42SihYCcgH
Summary by CodeRabbit
Bug Fixes
Tests
Chores