Skip to content

fix(object): #5347 — Object.assign/spread with an array source no longer crashes#5527

Merged
proggeramlug merged 1 commit into
mainfrom
fix/5347-object-assign-array-source
Jun 22, 2026
Merged

fix(object): #5347 — Object.assign/spread with an array source no longer crashes#5527
proggeramlug merged 1 commit into
mainfrom
fix/5347-object-assign-array-source

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Jun 21, 2026

Copy link
Copy Markdown
Contributor

What

Object.assign(target, [..]) and { ...[..] } (object-spread of an array) segfaulted. js_object_assign_one read an array source's elements through (*src).keys_array, casting the ArrayHeader to an ObjectHeader — but ArrayHeader is just {length, capacity} + inline element slots, with no keys_array field. The read picked up a NaN-boxed element as a pointer, passed the >= 0x10000 guard, and js_array_length dereferenced it → hard SIGSEGV on a common operation.

Object.assign({}, [1, 2])   // SIGSEGV → now {"0":1,"1":2}
({ ...[10, 20, 30] })        // SIGSEGV → now {"0":10,"1":20,"2":30}

How

  • Array source: detect GC_TYPE_ARRAY and enumerate the dense index range directly via js_array_get, then copy enumerable string expandos (arr.foo) from the named-property side table. Expandos are snapshotted before the index loop — that loop allocates, which can trigger a GC that rekeys the side table to the moved array's new address, after which a stale-address lookup would miss them. Indices precede expandos, matching [[OwnPropertyKeys]] order.
  • Boxed-String target: Object.assign('abc', src) does ToObject('abc'), whose code units are non-writable own index properties not stored in keys_array. A strict Set to an in-range index must throw TypeError; detected up front in object_assign_throw_if_set_rejected. With the array-source fix, Object.assign('a', [1]) now reaches this check and throws.

The non-array-source path is untouched (else branch), so plain object/class/string sources behave exactly as before.

Testing

  • test262 built-ins/Object/assign: 31 → 32 pass. The readonly-target negative (assignment-to-readonly-property-of-target-must-throw-a-typeerror-exception) now passes; the remaining 5 are Proxy-source trap forwarding (out of scope).
  • New gap test test_gap_5347_object_assign_array_source.ts — array source (indices + expando), array target growth, boxed-String readonly negatives, normal merges — verified byte-for-byte against node --experimental-strip-types.
  • 8 existing spread/assign parity tests still pass; runtime object unit tests pass (the one isolation-order flake passes in isolation and also fails on main).

Chips the Object cluster of the #5347 umbrella and fixes a real crash.

🤖 Generated with Claude Code

Summary by CodeRabbit

Release Notes

  • Bug Fixes
    • Fixed crashes when Object.assign uses an array as the source
    • Improved property handling for boxed string targets in Object.assign
    • Enhanced array element copying and property ordering behavior

@coderabbitai

coderabbitai Bot commented Jun 21, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds a assign_canonical_index helper that parses property names into canonical array indices. object_assign_throw_if_set_rejected gains a boxed-String target check that throws a read-only TypeError for in-range canonical indices. js_object_assign_one is reworked to branch on array vs. object sources, using the ArrayHeader element buffer for arrays. A regression test validates all new behaviors.

Changes

Object.assign array-source fix and boxed-String index rejection

Layer / File(s) Summary
Canonical index parser and boxed-String set rejection
crates/perry-runtime/src/object/alloc.rs
Adds assign_canonical_index to parse a property name into a canonical u32 index (rejecting empty, leading-zero, and non-round-trip forms). object_assign_throw_if_set_rejected uses it to detect boxed-String targets and throw a read-only TypeError when the key is a canonical in-range index.
js_object_assign_one array vs. object source branching
crates/perry-runtime/src/object/alloc.rs
Reworks js_object_assign_one's string-key copy loop: array sources now iterate 0..length via ArrayHeader element buffer then snapshot and copy named expandos; non-array sources retain keys-array enumeration with SSO/unified string handling, enumerability filtering, and strict setter routing.
Regression test for gap #5347
test-files/test_gap_5347_object_assign_array_source.ts
New TypeScript test logging Object.assign/spread results for array sources (element copying, expando ordering, array-target growth), asserting TypeError for in-range boxed-string index writes, and confirming no-throw for out-of-range/empty/object-object cases.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • PerryTS/perry#5146: Modifies the same object_assign_throw_if_set_rejected function in alloc.rs, reordering accessor rejection and own_key_present checks in the same code path this PR extends with boxed-String handling.
  • PerryTS/perry#5177: Modifies js_object_assign_one's copy logic in alloc.rs to handle symbol-keyed accessor [[Get]] during assignment, directly intersecting with the source-branching rework in this PR.

Poem

🐇 A bunny once hopped through an array so wide,
And found that the indices crash when you tried.
Now strings boxed up tight won't let numbers write through,
And spread on an array gives elements true.
With canonical checks and a neat element loop,
The assign hops safely — no crash, no drooop! 🥕

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and accurately summarizes the main fix: resolving the crash in Object.assign/spread when using an array source, which is the primary change in this PR.
Description check ✅ Passed The description covers all key template sections: summary of the crash and fix, detailed changes with code examples, related issue (#5347), and comprehensive test plan with verification against test262 and Node.js, but does not explicitly check all optional checklist items as completed.
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.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ 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/5347-object-assign-array-source

Comment @coderabbitai help to get the list of available commands and usage tips.

@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 (1)
crates/perry-runtime/src/object/alloc.rs (1)

849-862: 🧹 Nitpick | 🔵 Trivial

Consolidate assign_canonical_index to reuse the existing canonical_array_index helper.

The new assign_canonical_index function (lines 853–862) duplicates logic already present in crates/perry-runtime/src/object/field_get_set.rs:1539 with the same semantics. Both functions:

  • Reject empty strings and strings with leading zeros (except "0")
  • Reject non-ASCII-digit characters
  • Accept the range 0..=u32::MAX-1 (canonical's 4_294_967_294, assign's u32::MAX check)
  • Implicitly validate the round-trip property through their validation logic

Since canonical_array_index is widely used (26+ call sites) and well-tested, replacing the single call at line 885 with super::canonical_array_index(name) eliminates this maintenance burden and consolidates the canonical index parsing logic.

🤖 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/object/alloc.rs` around lines 849 - 862, The
`assign_canonical_index` function duplicates logic that already exists in the
`canonical_array_index` helper function in the same module. Remove the entire
`assign_canonical_index` function definition and replace the call to
`assign_canonical_index` at line 885 with a call to the existing
`canonical_array_index` function using `super::canonical_array_index(name)` or
`canonical_array_index(name)` depending on module visibility, thereby
consolidating the canonical index parsing logic and reducing maintenance burden.
🤖 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 `@crates/perry-runtime/src/object/alloc.rs`:
- Around line 1113-1125: In the loop within the array assignment section that
calls object_assign_set_string_key, add an explicit hole check to skip holes
instead of materializing them as undefined properties. Before the
object_assign_set_string_key call, insert a condition to check if the value
returned from js_array_get is a hole (using value.bits() == TAG_HOLE) and
continue to the next iteration if true, following the same pattern used in other
parts of the codebase like flat_clone.rs and field_get_set.rs.

---

Nitpick comments:
In `@crates/perry-runtime/src/object/alloc.rs`:
- Around line 849-862: The `assign_canonical_index` function duplicates logic
that already exists in the `canonical_array_index` helper function in the same
module. Remove the entire `assign_canonical_index` function definition and
replace the call to `assign_canonical_index` at line 885 with a call to the
existing `canonical_array_index` function using
`super::canonical_array_index(name)` or `canonical_array_index(name)` depending
on module visibility, thereby consolidating the canonical index parsing logic
and reducing maintenance burden.
🪄 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: 90ae96e6-7653-4b77-a1b6-2b3a42e00203

📥 Commits

Reviewing files that changed from the base of the PR and between d032369 and 378a130.

📒 Files selected for processing (2)
  • crates/perry-runtime/src/object/alloc.rs
  • test-files/test_gap_5347_object_assign_array_source.ts

Comment on lines +1113 to +1125
for i in 0..n {
// js_array_get yields `undefined` for a hole; array literals and
// pushed arrays are dense, matching Node for the common case.
let value = crate::array::js_array_get(arr, i);
let key = i.to_string();
let key_ptr = crate::string::js_string_from_bytes(key.as_ptr(), key.len() as u32);
object_assign_set_string_key(
target,
target_is_array,
key_ptr,
f64::from_bits(value.bits()),
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check if there's a hole-detection mechanism available
rg -n "TAG_HOLE|is_hole" --type rust -C2

Repository: PerryTS/perry

Length of output: 32713


🌐 Web query:

Does Object.assign copy holes from sparse arrays as undefined or skip them?

💡 Result:

Object.assign does NOT copy array “holes” from a sparse array as undefined. Instead, it only copies that source’s enumerable own properties; indices that are holes have no own property key, so they are skipped and remain absent in the result. [1][2] In other words: - If the source sparse array has an actual element value at index i (even if that value is undefined), Object.assign will create/overwrite key “i” on the target with that value. [1][2] - If the source has a hole at index i (no own property for “i”), Object.assign does nothing for that key; the target will not get a property for that index (so it won’t become a “filled” undefined slot). [1][2] Example behavior is discussed in practice: when assigning from a sparse array, empty slots are preserved as empty (non-existing) rather than turned into undefined values. [3] To “materialize” holes as actual undefined values, you need a different operation (e.g., some array-copying methods that preserve empties vs ones that convert them); spread/other copying mechanisms can treat holes differently. [3][4]

Citations:


Holes in sparse arrays are incorrectly materialized as undefined properties instead of being skipped.

The code calls js_array_get(arr, i) which converts holes to undefined (per issue #323 hole handling), then assigns that to the target object via object_assign_set_string_key. Per the ES spec and Node behavior, Object.assign should skip holes entirely (they are absent own properties), not create undefined properties.

For dense arrays (literals, push), this works correctly since there are no holes. For sparse arrays like Object.assign({}, [1,,3]), index 1 becomes undefined rather than remaining absent.

Add an explicit hole check before assignment:

if (value.bits() == TAG_HOLE) continue;

This pattern is used elsewhere in the codebase (e.g., flat_clone.rs, field_get_set.rs) to properly handle holes.

🤖 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/object/alloc.rs` around lines 1113 - 1125, In the
loop within the array assignment section that calls
object_assign_set_string_key, add an explicit hole check to skip holes instead
of materializing them as undefined properties. Before the
object_assign_set_string_key call, insert a condition to check if the value
returned from js_array_get is a hole (using value.bits() == TAG_HOLE) and
continue to the next iteration if true, following the same pattern used in other
parts of the codebase like flat_clone.rs and field_get_set.rs.

…ger crashes

`js_object_assign_one` read an array source's indexed elements through
`(*src).keys_array`, casting the `ArrayHeader` to an `ObjectHeader`. ArrayHeader
has no `keys_array` field (it is `{length, capacity}` followed by inline element
slots), so the read picked up a NaN-boxed element as a pointer, passed the
`>= 0x10000` guard, and `js_array_length` dereferenced it — a hard SIGSEGV on
`Object.assign(target, [..])` and `{ ...[..] }` (object-spread of an array).

Detect an array source (`GC_TYPE_ARRAY`) and enumerate its dense index range
directly via `js_array_get`, then copy its enumerable string expandos from the
named-property side table. The expandos are snapshotted BEFORE the index loop:
that loop allocates, which can trigger a GC that rekeys the side table to the
moved array's new address, after which a stale-address lookup would miss them.
Indices precede expandos, matching `[[OwnPropertyKeys]]` order.

Also: a boxed-String target (`Object.assign('abc', src)` → `ToObject('abc')`)
exposes its code units as non-writable own index properties that aren't in
`keys_array`. A strict `Set` to an in-range index must throw `TypeError`; detect
it up front in `object_assign_throw_if_set_rejected`. With the array-source fix,
`Object.assign('a', [1])` now reaches this check and throws.

test262 built-ins/Object/assign: 31→32 pass (the readonly-target negative now
passes; the rest of the cluster is Proxy-source forwarding, out of scope).
New gap test `test_gap_5347_object_assign_array_source.ts` verified byte-for-byte
against Node; 8 existing spread/assign parity tests still pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@proggeramlug proggeramlug force-pushed the fix/5347-object-assign-array-source branch from 378a130 to 21abc74 Compare June 22, 2026 02:07
@proggeramlug proggeramlug merged commit 31c24f6 into main Jun 22, 2026
14 checks passed
@proggeramlug proggeramlug deleted the fix/5347-object-assign-array-source branch June 22, 2026 02:10
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