Skip to content

fix(hir): don't store the capture BOX in an object literal's field#6409

Merged
proggeramlug merged 1 commit into
mainfrom
fix/anon-shape-boxed-capture-arg
Jul 14, 2026
Merged

fix(hir): don't store the capture BOX in an object literal's field#6409
proggeramlug merged 1 commit into
mainfrom
fix/anon-shape-boxed-capture-arg

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

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 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], and works
    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.

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 does routing.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 is ty: Array(Any), init: Array([Undefined])).

Test

test_gap_anon_shape_boxed_capture — field identity (lit.routing === o), Array.isArray on 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

  • Bug Fixes
    • Fixed mutable captured values in anonymous object literals so updates remain synchronized across object construction, class methods, and destructured reads.
    • Preserved shared-state behavior when captured values are passed through real class constructors.
  • Tests
    • Added coverage for boxed mutable captures, object-literal properties, identity preservation, and subsequent reads after mutation.

#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.
@coderabbitai

coderabbitai Bot commented Jul 14, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The 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.

Changes

Shared mutable capture lowering

Layer / File(s) Summary
Anonymous-shape constructor rewriting
crates/perry-hir/src/lower/shared_mutable_capture.rs
Expr::New rewrites indexed capture arguments for synthetic __AnonShape_* targets while preserving capture handles for real class constructors.
Boxed capture regression coverage
test-files/test_gap_anon_shape_boxed_capture.ts
Adds scenarios validating shared state through anonymous object shapes, destructured reads, and a real Counter class.

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

Possibly related PRs

  • PerryTS/perry#6048: Adds related regression coverage for shared-mutable class capture boxing.
  • PerryTS/perry#6054: Addresses the underlying shared-mutable capture boxing transformation used by this change.
  • PerryTS/perry#6090: Modifies the same shared-mutable capture desugaring and constructor-argument rewriting logic.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description is detailed, but it doesn't follow the required template and omits the Related issue and Checklist sections. Rewrite it with the template headings: Summary, Changes, Related issue, Test plan, Screenshots/output, and Checklist, and include the issue reference or n/a.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main fix: avoiding storing the boxed capture in object-literal fields.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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 docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/anon-shape-boxed-capture-arg

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.

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

59-77: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add 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 in shared_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

📥 Commits

Reviewing files that changed from the base of the PR and between 51469b3 and f9cec7e.

📒 Files selected for processing (2)
  • crates/perry-hir/src/lower/shared_mutable_capture.rs
  • test-files/test_gap_anon_shape_boxed_capture.ts

@proggeramlug

Copy link
Copy Markdown
Contributor Author

The test_gap_node_fs failure here is not caused by this PR — the test is flaky.

Its scratch directory is '/tmp/perry_fs_test_' + Date.now(), which is not unique (millisecond resolution), and the test ends with a recursive rmSync. Two runs starting in the same millisecond share a directory and one deletes it under the other, which CI reports as EACCES … stat '/tmp/perry_fs_test_<ms>/test.txt' — a filesystem race, not a parity failure.

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 (mkdtempSync, atomic and unique; expected output unchanged). Rerunning here in the meantime.

@proggeramlug proggeramlug merged commit 4163ee6 into main Jul 14, 2026
48 of 50 checks passed
@proggeramlug proggeramlug deleted the fix/anon-shape-boxed-capture-arg branch July 14, 2026 18:47
proggeramlug added a commit that referenced this pull request Jul 14, 2026
…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>
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