Skip to content

fix(runtime): Set/Map forEach — fused array-receiver reroute + delete-during-iteration#6215

Open
proggeramlug wants to merge 1 commit into
mainfrom
fix/set-map-foreach-fused-and-delete
Open

fix(runtime): Set/Map forEach — fused array-receiver reroute + delete-during-iteration#6215
proggeramlug wants to merge 1 commit into
mainfrom
fix/set-map-foreach-fused-and-delete

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Problem

Two related forEach defects on native Sets/Maps, both hit by react-server-dom
during Next.js App Router SSR (#5989):

  1. Fused array forEach on a Set/Map receiver segfaults. .forEach on an
    unknown-typed receiver is statically fused to the ARRAY forEach entry point.
    When the receiver is actually a native Set or Map — e.g. a collection stored
    on an object and read back as a property — js_array_forEach treated the
    SetHeader as an ArrayHeader, feeding hash-table internals to the callback
    as "elements" and segfaulting on the first property read:

    const req = { abortableTasks: new Set() };
    req.abortableTasks.add({ status: 10 });
    req.abortableTasks.forEach((t) => t.status);   // segfault

    react-server-dom sweeps request.abortableTasks (a Set) exactly this way,
    reading .status off each task — this crashed the server on every dynamic
    route once the RSC flight started flowing.

  2. Deleting during Set.prototype.forEach / Map.prototype.forEach skipped
    the next entry.
    The backing vector compacts on delete (later entries shift
    left), so naive index advancement jumped over the shifted-in element:

    const s = new Set([a, b]);
    s.forEach((v) => s.delete(v));   // visited only `a`; Node visits both

    React's task sweeps delete entries synchronously while iterating.

Fix

  • array/iter_methods.rs: in js_array_forEach, detect a registered Set/Map
    receiver and route to the native js_set_foreach / js_map_foreach — the
    same pattern as the existing typed-array reroute. forEach is the only
    method name the fused array methods share with Set/Map, so this single
    reroute covers the hazard class.
  • set.rs / map.rs: in the forEach loops, only advance the index when the
    current slot still holds the just-visited entry (compare via a GC-rooted
    handle so a move during the callback can't skew the comparison). Handles
    delete-current, delete-earlier, delete-later, and delete-then-re-add per
    ECMA-262 (re-added entries are appended and re-visited); entries added during
    the callback are still visited (existing behavior, revalidated).

Validation

New gap test test_gap_set_map_foreach_fused_receiver.ts, byte-for-byte
against node --experimental-strip-types: the fused property-read receiver
shape for Set and Map, (value, value, set) argument order, plain arrays
through the same fused path, delete-during-iteration for Set and Map, deleting
an earlier entry mid-iteration, and adds-during-iteration regression.

Summary by CodeRabbit

  • Bug Fixes
    • Fixed .forEach handling for collections accessed through generic/unknown references so Set and Map iterate correctly instead of being treated like arrays.
    • Improved Set.forEach and Map.forEach so deletions during iteration no longer cause items to be skipped.
    • Strengthened callback iteration behavior to stay correct even when collection contents shift during traversal.
  • Tests
    • Added regression coverage for Set, Map, and array .forEach behavior, including deletion-while-iterating cases.

…-during-iteration

Two forEach defects on native Sets/Maps, both hit by react-server-dom during
Next.js App Router SSR:

1. `.forEach` on an unknown-typed receiver is statically fused to
   js_array_forEach; a Set/Map receiver (a collection stored on an object and
   read back as a property) was treated as an ArrayHeader, feeding hash-table
   internals to the callback and segfaulting on the first property read.
   react-server-dom sweeps `request.abortableTasks` (a Set) exactly this way.
   Detect registered Sets/Maps and route to the native forEach — the same
   pattern as the existing typed-array reroute; `forEach` is the only method
   name the fused array methods share with Set/Map.

2. Deleting during Set/Map forEach skipped the next entry: the backing vector
   compacts on delete, so naive index advancement jumped the shifted-in
   element. Only advance when the current slot still holds the just-visited
   entry (compared via a GC-rooted handle). Delete-current/earlier/later and
   delete-then-re-add now match ECMA-262; adds-during-iteration behavior is
   unchanged.

Adds test_gap_set_map_foreach_fused_receiver.ts, byte-for-byte against node.
@coderabbitai

coderabbitai Bot commented Jul 9, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The runtime now redirects unknown-typed Set and Map receivers to native forEach implementations, roots visited values across callbacks, and conditionally advances iteration after deletions. Regression tests cover receiver dispatch, callback arguments, arrays, and mutation during iteration.

Changes

Set/Map forEach runtime behavior

Layer / File(s) Summary
Unknown receiver dispatch
crates/perry-runtime/src/array/iter_methods.rs
Unknown-typed native Set and Map receivers are redirected to their dedicated forEach implementations.
Mutation-safe iteration
crates/perry-runtime/src/{set,map}.rs
Visited values are rooted across callbacks, and iteration advances only when the current slot still contains the visited entry.
Regression coverage
test-files/test_gap_set_map_foreach_fused_receiver.ts
Tests cover fused receiver dispatch, callback argument ordering, arrays, and Set/Map deletion during iteration.

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

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and accurately captures the main runtime fix for Set/Map forEach rerouting and delete-during-iteration behavior.
Description check ✅ Passed It covers the problem, fix, and validation clearly, though it uses custom headings instead of the template’s exact sections.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
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/set-map-foreach-fused-and-delete

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

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

26-32: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider verifying Map forEach third callback argument.

Test (3) verifies the Set forEach third argument (theSet) but there is no equivalent check for Map forEach. The upstream contract (map.rs:1918-1968) passes map_value as the third callback argument, and the subclass dispatch (collection_methods.rs) preserves the collection instance identity. Adding a third-argument assertion for Map would close this gap.

➕ Proposed addition to test (2)
 const holder: any = { cache: new Map() };
 holder.cache.set("a", { n: 1 });
 holder.cache.set("b", { n: 2 });
 const pairs: string[] = [];
-holder.cache.forEach((v: any, k: any) => pairs.push(`${k}=${v.n}`));
+holder.cache.forEach((v: any, k: any, m: any) => {
+   pairs.push(`${k}=${v.n}`);
+   console.log(m === holder.cache, m.get("a").n);
+ });
 console.log(pairs.join(","));
🤖 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_set_map_foreach_fused_receiver.ts` around lines 26 - 32,
Add a third-argument assertion to the Map forEach test in the callback used on
holder.cache so it verifies the passed collection is the same Map instance,
mirroring the Set test. Use the existing holder.cache.forEach path and check the
third callback parameter identity to confirm Map forEach preserves the map
instance as described by the map/collection dispatch behavior.
crates/perry-runtime/src/map.rs (1)

1947-1971: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Avoid growing the runtime-handle stack in this loop RuntimeHandleScope::root_nanbox_f64 pushes a new root that stays live until the scope drops, so this outer-scope loop retains one extra handle per entry. A per-iteration scope, or reusing a single handle slot, would keep the root set flat.

🤖 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/map.rs` around lines 1947 - 1971, The loop in the
Map iteration logic is creating a new long-lived root via
RuntimeHandleScope::root_nanbox_f64 for every entry, which grows the
runtime-handle stack across the whole traversal. Update the code around the
key_handle usage in the Map callback loop so the root is only held for the
duration of each iteration, either by introducing a per-iteration scope or by
reusing a single handle slot, while preserving the slot comparison that guards
the i++ advance.

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 `@test-files/test_gap_set_map_foreach_fused_receiver.ts`:
- Around line 1-80: The test file covers Set/Map forEach deletion and
receiver-reroute cases, but it is missing the add-during-iteration regression
that the PR objectives require. Add a new regression block alongside the
existing forEach cases that exercises js_set_foreach_impl and
js_map_foreach_impl by appending entries during the callback and verifying the
newly added items are visited in iteration order. Keep the scenario in the same
style as the existing Set/Map tests so future changes that accidentally snapshot
size will be caught.

---

Nitpick comments:
In `@crates/perry-runtime/src/map.rs`:
- Around line 1947-1971: The loop in the Map iteration logic is creating a new
long-lived root via RuntimeHandleScope::root_nanbox_f64 for every entry, which
grows the runtime-handle stack across the whole traversal. Update the code
around the key_handle usage in the Map callback loop so the root is only held
for the duration of each iteration, either by introducing a per-iteration scope
or by reusing a single handle slot, while preserving the slot comparison that
guards the i++ advance.

In `@test-files/test_gap_set_map_foreach_fused_receiver.ts`:
- Around line 26-32: Add a third-argument assertion to the Map forEach test in
the callback used on holder.cache so it verifies the passed collection is the
same Map instance, mirroring the Set test. Use the existing holder.cache.forEach
path and check the third callback parameter identity to confirm Map forEach
preserves the map instance as described by the map/collection dispatch behavior.
🪄 Autofix (Beta)

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: 608dbace-78b3-4803-86bf-e115655c4b7b

📥 Commits

Reviewing files that changed from the base of the PR and between b8cf5d3 and a46fad6.

📒 Files selected for processing (4)
  • crates/perry-runtime/src/array/iter_methods.rs
  • crates/perry-runtime/src/map.rs
  • crates/perry-runtime/src/set.rs
  • test-files/test_gap_set_map_foreach_fused_receiver.ts

Comment on lines +1 to +80
// `.forEach` on an unknown-typed receiver is statically fused to the ARRAY
// forEach entry point. When the receiver is actually a native Set or Map —
// e.g. a collection stored on an object and read back as a property — the
// fused call treated the SetHeader as an ArrayHeader, feeding hash-table
// internals to the callback as elements and segfaulting on the first property
// read. react-server-dom hits exactly this: `request.abortableTasks` is a Set
// it iterates via `.forEach`, reading `.status` off each task — this crashed
// every Next.js App Router dynamic route once the RSC flight started flowing
// (#5989).
//
// `forEach` is the only method name the fused array methods share with
// Set/Map, so the runtime reroute (mirroring the existing typed-array reroute)
// covers the hazard class.
//
// Validated byte-for-byte against `node --experimental-strip-types`.

// (1) the flight shape: Set stored on an object, read back, forEach'd
const req: any = { abortableTasks: new Set() };
req.abortableTasks.add({ status: 10 });
req.abortableTasks.add({ status: 11 });
req.abortableTasks.add({ status: 12 });
const got: any[] = [];
req.abortableTasks.forEach((t: any) => got.push(t.status));
console.log(JSON.stringify(got), req.abortableTasks.size);

// (2) Map stored on an object — callback receives (value, key, map)
const holder: any = { cache: new Map() };
holder.cache.set("a", { n: 1 });
holder.cache.set("b", { n: 2 });
const pairs: string[] = [];
holder.cache.forEach((v: any, k: any) => pairs.push(`${k}=${v.n}`));
console.log(pairs.join(","));

// (3) Set forEach argument order: (value, valueAgain, set)
const s: any = { s: new Set(["x"]) };
s.s.forEach((v: any, v2: any, theSet: any) =>
console.log(v === v2, theSet.has("x"), theSet.size),
);

// (4) plain arrays through the same fused path stay correct
const arrHolder: any = { list: [7, 8] };
const items: string[] = [];
arrHolder.list.forEach((v: any, i: any, a: any) => items.push(`${i}:${v}:${a.length}`));
console.log(items.join(","));

// (5) delete during Set.forEach (React deletes tasks while sweeping): the
// backing vector compacts on delete, so naive index advancement skipped the
// shifted-in next entry.
const req2: any = { tasks: new Set() };
const t1 = { id: 1 };
const t2 = { id: 2 };
req2.tasks.add(t1);
req2.tasks.add(t2);
const seen: number[] = [];
req2.tasks.forEach((t: any) => {
seen.push(t.id);
req2.tasks.delete(t);
});
console.log(JSON.stringify(seen), req2.tasks.size);

// (6) delete during Map.forEach — same compaction hazard
const m6: any = { m: new Map() };
m6.m.set("a", 1);
m6.m.set("b", 2);
m6.m.set("c", 3);
const seen6: string[] = [];
m6.m.forEach((v: any, k: any) => {
seen6.push(`${k}:${v}`);
m6.m.delete(k);
});
console.log(JSON.stringify(seen6), m6.m.size);

// (7) delete an EARLIER entry during iteration (must not skip or re-visit)
const s7 = new Set(["p", "q", "r"]);
const seen7: string[] = [];
s7.forEach((v: any) => {
seen7.push(v);
if (v === "q") s7.delete("p");
});
console.log(JSON.stringify(seen7), s7.size);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚖️ Poor tradeoff

Missing add-during-iteration regression test claimed in PR objectives.

The PR objectives state the test file covers "add-during-iteration regression cases," but no such test exists. The upstream forEach implementations (both js_map_foreach_impl and js_set_foreach_impl) explicitly re-read (*map).size / (*set).size each iteration so that entries appended during the callback are visited per ECMA-262. Without a regression test, a future change that snapshots the initial size could silently break this contract.

Suggested addition:

➕ Proposed add-during-iteration tests
 // (8) add during Set.forEach — entries appended in the callback MUST be visited
 const s8 = new Set([1]);
 const seen8: number[] = [];
 s8.forEach((v: any) => {
   seen8.push(v);
   if (v < 3) s8.add(v + 1);
 });
 console.log(JSON.stringify(seen8), s8.size);
 // Expected: [1,2,3] 3

 // (9) add during Map.forEach — same contract
 const m9 = new Map([["a", 1]]);
 const seen9: string[] = [];
 m9.forEach((v: any, k: any) => {
   seen9.push(`${k}:${v}`);
   if (v < 3) m9.set(String.fromCharCode(k.charCodeAt(0) + 1), v + 1);
 });
 console.log(JSON.stringify(seen9), m9.size);
 // Expected: ["a:1","b:2","c:3"] 3
📝 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
// `.forEach` on an unknown-typed receiver is statically fused to the ARRAY
// forEach entry point. When the receiver is actually a native Set or Map —
// e.g. a collection stored on an object and read back as a property — the
// fused call treated the SetHeader as an ArrayHeader, feeding hash-table
// internals to the callback as elements and segfaulting on the first property
// read. react-server-dom hits exactly this: `request.abortableTasks` is a Set
// it iterates via `.forEach`, reading `.status` off each task — this crashed
// every Next.js App Router dynamic route once the RSC flight started flowing
// (#5989).
//
// `forEach` is the only method name the fused array methods share with
// Set/Map, so the runtime reroute (mirroring the existing typed-array reroute)
// covers the hazard class.
//
// Validated byte-for-byte against `node --experimental-strip-types`.
// (1) the flight shape: Set stored on an object, read back, forEach'd
const req: any = { abortableTasks: new Set() };
req.abortableTasks.add({ status: 10 });
req.abortableTasks.add({ status: 11 });
req.abortableTasks.add({ status: 12 });
const got: any[] = [];
req.abortableTasks.forEach((t: any) => got.push(t.status));
console.log(JSON.stringify(got), req.abortableTasks.size);
// (2) Map stored on an object — callback receives (value, key, map)
const holder: any = { cache: new Map() };
holder.cache.set("a", { n: 1 });
holder.cache.set("b", { n: 2 });
const pairs: string[] = [];
holder.cache.forEach((v: any, k: any) => pairs.push(`${k}=${v.n}`));
console.log(pairs.join(","));
// (3) Set forEach argument order: (value, valueAgain, set)
const s: any = { s: new Set(["x"]) };
s.s.forEach((v: any, v2: any, theSet: any) =>
console.log(v === v2, theSet.has("x"), theSet.size),
);
// (4) plain arrays through the same fused path stay correct
const arrHolder: any = { list: [7, 8] };
const items: string[] = [];
arrHolder.list.forEach((v: any, i: any, a: any) => items.push(`${i}:${v}:${a.length}`));
console.log(items.join(","));
// (5) delete during Set.forEach (React deletes tasks while sweeping): the
// backing vector compacts on delete, so naive index advancement skipped the
// shifted-in next entry.
const req2: any = { tasks: new Set() };
const t1 = { id: 1 };
const t2 = { id: 2 };
req2.tasks.add(t1);
req2.tasks.add(t2);
const seen: number[] = [];
req2.tasks.forEach((t: any) => {
seen.push(t.id);
req2.tasks.delete(t);
});
console.log(JSON.stringify(seen), req2.tasks.size);
// (6) delete during Map.forEach — same compaction hazard
const m6: any = { m: new Map() };
m6.m.set("a", 1);
m6.m.set("b", 2);
m6.m.set("c", 3);
const seen6: string[] = [];
m6.m.forEach((v: any, k: any) => {
seen6.push(`${k}:${v}`);
m6.m.delete(k);
});
console.log(JSON.stringify(seen6), m6.m.size);
// (7) delete an EARLIER entry during iteration (must not skip or re-visit)
const s7 = new Set(["p", "q", "r"]);
const seen7: string[] = [];
s7.forEach((v: any) => {
seen7.push(v);
if (v === "q") s7.delete("p");
});
console.log(JSON.stringify(seen7), s7.size);
// `.forEach` on an unknown-typed receiver is statically fused to the ARRAY
// forEach entry point. When the receiver is actually a native Set or Map —
// e.g. a collection stored on an object and read back as a property — the
// fused call treated the SetHeader as an ArrayHeader, feeding hash-table
// internals to the callback as elements and segfaulting on the first property
// read. react-server-dom hits exactly this: `request.abortableTasks` is a Set
// it iterates via `.forEach`, reading `.status` off each task — this crashed
// every Next.js App Router dynamic route once the RSC flight started flowing
// (`#5989`).
//
// `forEach` is the only method name the fused array methods share with
// Set/Map, so the runtime reroute (mirroring the existing typed-array reroute)
// covers the hazard class.
//
// Validated byte-for-byte against `node --experimental-strip-types`.
// (1) the flight shape: Set stored on an object, read back, forEach'd
const req: any = { abortableTasks: new Set() };
req.abortableTasks.add({ status: 10 });
req.abortableTasks.add({ status: 11 });
req.abortableTasks.add({ status: 12 });
const got: any[] = [];
req.abortableTasks.forEach((t: any) => got.push(t.status));
console.log(JSON.stringify(got), req.abortableTasks.size);
// (2) Map stored on an object — callback receives (value, key, map)
const holder: any = { cache: new Map() };
holder.cache.set("a", { n: 1 });
holder.cache.set("b", { n: 2 });
const pairs: string[] = [];
holder.cache.forEach((v: any, k: any) => pairs.push(`${k}=${v.n}`));
console.log(pairs.join(","));
// (3) Set forEach argument order: (value, valueAgain, set)
const s: any = { s: new Set(["x"]) };
s.s.forEach((v: any, v2: any, theSet: any) =>
console.log(v === v2, theSet.has("x"), theSet.size),
);
// (4) plain arrays through the same fused path stay correct
const arrHolder: any = { list: [7, 8] };
const items: string[] = [];
arrHolder.list.forEach((v: any, i: any, a: any) => items.push(`${i}:${v}:${a.length}`));
console.log(items.join(","));
// (5) delete during Set.forEach (React deletes tasks while sweeping): the
// backing vector compacts on delete, so naive index advancement skipped the
// shifted-in next entry.
const req2: any = { tasks: new Set() };
const t1 = { id: 1 };
const t2 = { id: 2 };
req2.tasks.add(t1);
req2.tasks.add(t2);
const seen: number[] = [];
req2.tasks.forEach((t: any) => {
seen.push(t.id);
req2.tasks.delete(t);
});
console.log(JSON.stringify(seen), req2.tasks.size);
// (6) delete during Map.forEach — same compaction hazard
const m6: any = { m: new Map() };
m6.m.set("a", 1);
m6.m.set("b", 2);
m6.m.set("c", 3);
const seen6: string[] = [];
m6.m.forEach((v: any, k: any) => {
seen6.push(`${k}:${v}`);
m6.m.delete(k);
});
console.log(JSON.stringify(seen6), m6.m.size);
// (7) delete an EARLIER entry during iteration (must not skip or re-visit)
const s7 = new Set(["p", "q", "r"]);
const seen7: string[] = [];
s7.forEach((v: any) => {
seen7.push(v);
if (v === "q") s7.delete("p");
});
console.log(JSON.stringify(seen7), s7.size);
// (8) add during Set.forEach — entries appended in the callback MUST be visited
const s8 = new Set([1]);
const seen8: number[] = [];
s8.forEach((v: any) => {
seen8.push(v);
if (v < 3) s8.add(v + 1);
});
console.log(JSON.stringify(seen8), s8.size);
// (9) add during Map.forEach — same contract
const m9 = new Map([["a", 1]]);
const seen9: string[] = [];
m9.forEach((v: any, k: any) => {
seen9.push(`${k}:${v}`);
if (v < 3) m9.set(String.fromCharCode(k.charCodeAt(0) + 1), v + 1);
});
console.log(JSON.stringify(seen9), m9.size);
🤖 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_set_map_foreach_fused_receiver.ts` around lines 1 - 80,
The test file covers Set/Map forEach deletion and receiver-reroute cases, but it
is missing the add-during-iteration regression that the PR objectives require.
Add a new regression block alongside the existing forEach cases that exercises
js_set_foreach_impl and js_map_foreach_impl by appending entries during the
callback and verifying the newly added items are visited in iteration order.
Keep the scenario in the same style as the existing Set/Map tests so future
changes that accidentally snapshot size will be caught.

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