fix(hir): don't store the capture BOX in an object literal's field#6409
Conversation
#5951 boxes a mutable local that a class captures AND mutates into a one-element array cell, and rewrites every value read of it into `o[0]`. A real class construction auto-appends CAPTURE arguments that must pass the raw array handle, so the `Expr::New` arm of that rewrite deliberately skips a bare `LocalGet(boxed)` argument. But an OBJECT LITERAL also lowers to `Expr::New` — against a synthetic `__AnonShape_*` whose constructor arguments are the PROPERTY VALUES, not capture handles. The skip therefore stored the CELL in the field: function factory() { let o; class Holder { constructor() { this.snap = o; } update(x) { o = x; } } o = { locales: ["en", "de"] }; new Holder(); return function () { const lit = { routing: o, a: 1, b: 2, c: 3, d: 4 }; o.locales.length; // 2 — this read IS rewritten to o[0] lit.routing; // node: o perry: [o] <-- the box lit.routing.locales; // node: [...] perry: undefined }; } Every other read of `o` works, so the failure surfaces far from its cause: a guard like `o.locales.length > 1` passes on the line *before* the literal that then hands its consumer a `routing` whose `.locales` is `undefined`. A synthetic shape has no captures, so every one of its args is a value and must be rewritten. Real class constructions keep the existing behavior — their capture arguments still pass the array handle by reference, so writes through the class stay visible to the declaring function (covered by the test). This is next-intl's alternate-links config, reached on every page render of a localized Next.js app: it built `{routing: o, …}` from a module-scope config that a bundled class captures and mutates, and the reader then did `routing.locales.map(…)` → `TypeError: Cannot read properties of undefined (reading 'map')`, i.e. a 500 on every page. Found while compiling a real Next.js 16 + Auth.js + next-intl app. Covered by `test_gap_anon_shape_boxed_capture` — field identity, `Array.isArray`, reading the field back through a destructured parameter, the cell still being SHARED with the class after a write, and the class-capture-by-reference case. Byte-identical to node.
📝 WalkthroughWalkthroughThe shared-mutable capture rewriter now treats synthetic anonymous-shape constructions differently from real class constructors. A regression test covers boxed capture identity and updates through object literals, destructuring, and class methods. ChangesShared mutable capture lowering
Estimated code review effort: 3 (Moderate) | ~20 minutes Possibly related PRs
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 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.
🧹 Nitpick comments (1)
test-files/test_gap_anon_shape_boxed_capture.ts (1)
59-77: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winAdd test coverage for explicitly passed capture arguments to real classes.
The current tests verify that a real class can capture a shared cell internally, but they do not test what happens when a developer explicitly passes a captured variable as an argument to a real class (e.g.,
new Consumer(count)). As noted inshared_mutable_capture.rs, the current rewriting heuristic mistakenly skips user-supplied capture arguments for real classes, passing the underlying array cell instead of the primitive value.Please add a test case to assert that explicit user arguments are unwrapped correctly.
🧪 Proposed test case addition
const c = new Counter(); c.bump(); c.bump(); count += 10; + + // Verify that explicitly passing the captured variable unwraps it correctly. + class Consumer { + val: any; + constructor(v: any) { this.val = v; } + } + const consumer = new Consumer(count); + - return c.read() + "," + count; + return c.read() + "," + count + ",isArray:" + Array.isArray(consumer.val); } -console.log("shared cell :", sharedCell()); +console.log("shared cell :", sharedCell()); // Expected: 12,12,isArray:false🤖 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_anon_shape_boxed_capture.ts` around lines 59 - 77, Add a test case in test-files/test_gap_anon_shape_boxed_capture.ts covering a real class instantiated with an explicitly passed captured variable, such as new Consumer(count). Assert the class receives the unwrapped primitive value rather than the underlying capture cell, while preserving the existing sharedCell coverage.
🤖 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.
Nitpick comments:
In `@test-files/test_gap_anon_shape_boxed_capture.ts`:
- Around line 59-77: Add a test case in
test-files/test_gap_anon_shape_boxed_capture.ts covering a real class
instantiated with an explicitly passed captured variable, such as new
Consumer(count). Assert the class receives the unwrapped primitive value rather
than the underlying capture cell, while preserving the existing sharedCell
coverage.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: df9ca796-a7d8-4eb3-b897-baa4c008a0e6
📒 Files selected for processing (2)
crates/perry-hir/src/lower/shared_mutable_capture.rstest-files/test_gap_anon_shape_boxed_capture.ts
|
The Its scratch directory is The giveaway: this PR failed one run and passed the next on the same commit. Running 8 copies of the binary concurrently reproduces it — 2 of 8 fail before the fix, 0 of 8 after. Fix is up as #6410 ( |
…6410) The test built its scratch directory by hand: const tmpDir = '/tmp/perry_fs_test_' + Date.now(); fs.mkdirSync(tmpDir, { recursive: true }); Date.now() has millisecond resolution, so two executions that start in the same millisecond pick the SAME directory — and the test ends by tearing its directory down with a recursive rmSync. One run then touches a file another run is deleting. On Linux CI that surfaced as FAIL test_gap_node_fs (output mismatch) Node.js: true Perry: Error: EACCES: Permission denied (os error 13), stat '/tmp/perry_fs_test_1784051019891/test.txt' which is not a parity failure at all. It red-lit unrelated PRs at random — #6409 (an HIR capture-box change, nothing to do with fs) failed one run and passed the next on the SAME commit, and #6403 failed once then went green untouched. mkdtempSync is the primitive for this: the kernel picks the suffix and creates the directory atomically, so no two runs can collide. Proof, 8 concurrent runs of the compiled binary: before — 2 of 8 fail (ENOENT: another run's rmSync removed the shared dir) after — 0 of 8 fail Output is byte-for-byte unchanged (30 lines); no assertion touched. Co-authored-by: Ralph Küpper <ralph3@skelpo.com>
The bug
#5951 boxes a mutable local that a class captures and mutates into a one-element array cell, and rewrites every value read of it into
o[0]. A real class construction auto-appends capture arguments that must pass the raw array handle by reference, so theExpr::Newarm of that rewrite deliberately skips a bareLocalGet(boxed)argument.But an object literal also lowers to
Expr::New— against a synthetic__AnonShape_*whose constructor arguments are the property values, not capture handles. The skip therefore stored the cell in the field:Every other read of
oworks, so the failure surfaces far from its cause: a guard likeo.locales.length > 1passes on the line before the literal that then hands its consumer aroutingwhose.localesisundefined.The fix
A synthetic shape has no captures, so every one of its args is a value and must be rewritten. Real class constructions keep the existing behavior — their capture arguments still pass the array handle by reference, so writes through the class stay visible to the declaring function.
How it was found
Compiling a real Next.js 16 + Auth.js v5 + next-intl app. This is next-intl's alternate-links config, reached on every page render of a localized Next.js app: it builds
{routing: o, …}from a module-scope config that a bundled class captures and mutates, and the consumer then doesrouting.locales.map(…)→TypeError: Cannot read properties of undefined (reading 'map'). Every page returned a 500; with this fix they render.Worth noting how visible-but-invisible this is: the object's keys are all correct and every direct read of the variable is correct — only the field's value is the cell. Perry's own HIR made it obvious once dumped (
New { class_name: "__AnonShape_…", args: [LocalGet(3960), …] }where local 3960 isty: Array(Any), init: Array([Undefined])).Test
test_gap_anon_shape_boxed_capture— field identity (lit.routing === o),Array.isArrayon the field, reading the field back through a destructured parameter, the cell still being shared with the class after a write through it, and the class-capture-by-reference case (12,12) that the skip exists to protect. Byte-identical to node.Summary by CodeRabbit