Skip to content

fix(object): property-descriptor engine — named-array merge, accessor delete/set, symbol & non-extensible throws#5146

Merged
proggeramlug merged 1 commit into
mainfrom
fix/object-descriptor-cluster
Jun 14, 2026
Merged

fix(object): property-descriptor engine — named-array merge, accessor delete/set, symbol & non-extensible throws#5146
proggeramlug merged 1 commit into
mainfrom
fix/object-descriptor-cluster

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Jun 14, 2026

Copy link
Copy Markdown
Contributor

Summary

Closes a dense test262 built-ins/Object property-descriptor failure cluster by fixing shared root causes in the ToPropertyDescriptor / ValidateAndApplyPropertyDescriptor engine. +15 tests, 0 regressions across the full built-ins/Object suite (3062 → 3077 passing); built-ins/Reflect and built-ins/Proxy results are byte-identical to pristine origin/main (no regressions in adjacent descriptor consumers).

Root causes fixed

  1. Named (non-index) array property define didn't merge with the existing property. Object.defineProperty(arr, "p", {writable:false}) reset every omitted attribute to false and overwrote the value with undefined. Now retains omitted attrs/value on redefine (mirroring the index path); defaults omitted fields to false only for genuinely new properties. (array_object_ops.rs)

  2. delete of an array's named property left accessors behind. It only dropped the value-store entry, so a named accessor (which lives only in the side tables) survived and hasOwnProperty kept reporting it. Now also clears the accessor + attribute side-table state. (delete_rest.rs)

  3. [[Set]] on a frozen/sealed object's accessor threw instead of calling the setter. The frozen check ran before the accessor short-circuit. Hoisted the accessor short-circuit above the frozen/sealed/writable checks in both the O(1) sidecar and the linear-scan key-lookup paths — freezing an accessor only clears [[Configurable]], the setter still runs. (field_set_by_name.rs, alloc.rs)

  4. A Symbol accepted as a property descriptor. A Symbol is pointer-tagged, so it passed the "descriptor must be an object" check. ToPropertyDescriptor(Symbol()) now throws "Property description must be an object". (object_ops.rs)

  5. Defining a new own property on a non-extensible array didn't throw. Added the extensibility check to the array define path. (array_object_ops.rs)

Per-sub-area improvement (built-ins/Object)

  • defineProperty: +9
  • defineProperties: +5
  • assign: +1

Verification

  • built-ins/Object: 3062 → 3077 pass, 0 regressions (scripts/test262_subset.py).
  • built-ins/Reflect (97/102) and built-ins/Proxy (207/240): failure sets identical to pristine origin/main — no regressions.
  • cargo test --release -p perry-runtime -p perry-stdlib -p perry-codegen green (the lone parallel failure, object::tests::builtin_prototype_methods_reject_dynamic_new, is a pre-existing shared-global isolation flake — passes single-threaded and on pristine main).

Remaining (separate root causes, out of scope)

Distinct subsystems, not part of the descriptor engine: Array.prototype[N] accessor reads on the special built-in proto; Object/Function/Date.prototype mutation observed via inheritance; huge array.length (dense-array / sparse limitation — surfaces as crashes on ~4e9 lengths); Object.create getPrototypeOf identity (known prototype-observability limitation); Object.assign with Proxy sources / symbol-accessor enumeration order.

Summary by CodeRabbit

Bug Fixes

  • Fixed accessor handling order for Object.defineProperty and property assignment on frozen/non-extensible objects to prevent incorrect “forbidden new-property add” and related errors.
  • Improved Object.defineProperty descriptor processing for arrays and named properties to better match expected spec behavior for enumerable, configurable, and accessor/data transitions.
  • Enhanced delete behavior for named array properties to fully remove stored values and related descriptor metadata.
  • Corrected when accessor setters are invoked during obj[key] = value.
  • Strengthened validation so invalid descriptor arguments (including Symbol values) correctly trigger TypeErrors.

@coderabbitai

coderabbitai Bot commented Jun 14, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: fc5b9ccd-9df3-44d0-939d-6f3f86c863bd

📥 Commits

Reviewing files that changed from the base of the PR and between cb77f6b and 2fa3da2.

📒 Files selected for processing (5)
  • crates/perry-runtime/src/object/alloc.rs
  • crates/perry-runtime/src/object/array_object_ops.rs
  • crates/perry-runtime/src/object/delete_rest.rs
  • crates/perry-runtime/src/object/field_set_by_name.rs
  • crates/perry-runtime/src/object/object_ops.rs
🚧 Files skipped from review as they are similar to previous changes (5)
  • crates/perry-runtime/src/object/object_ops.rs
  • crates/perry-runtime/src/object/field_set_by_name.rs
  • crates/perry-runtime/src/object/delete_rest.rs
  • crates/perry-runtime/src/object/array_object_ops.rs
  • crates/perry-runtime/src/object/alloc.rs

📝 Walkthrough

Walkthrough

Five runtime object files are updated to improve ECMA-262 compliance: Object.defineProperty now rejects Symbol-tagged descriptor arguments; accessor dispatch in assignment and Object.assign is reordered to run before frozen/sealed/writable checks; array non-index defineProperty gains non-extensibility guards and correct descriptor merge/default logic; and named-property deletion now cleans up accessor and attribute side-table state.

Changes

Property Descriptor and Accessor Spec Compliance

Layer / File(s) Summary
Symbol-as-descriptor TypeError in Object.defineProperty
crates/perry-runtime/src/object/object_ops.rs
Both the proxy and non-proxy Object.defineProperty descriptor validation paths now also reject Symbol-pointer-tagged descriptor values, routing them through the existing "Property description must be an object" TypeError.
Accessor dispatch hoisted before frozen/sealed and own-key checks
crates/perry-runtime/src/object/alloc.rs, crates/perry-runtime/src/object/field_set_by_name.rs
object_assign_throw_if_set_rejected checks the accessor descriptor (requiring a setter) before own_key_present, preventing false "forbidden new-property add" errors on non-extensible targets. js_object_set_field_by_name hoists accessor lookup and setter invocation to run before frozen/sealed/writable checks and removes the now-redundant in-loop accessor dispatch.
Array named-property defineProperty ECMA-262 merge semantics
crates/perry-runtime/src/object/array_object_ops.rs
Adds a non-extensibility guard for new named properties on non-extensible arrays; changes accessor-definition enumerable/configurable defaults to derive from existing PropertyAttrs; reworks data-property merge to detect prior existence, remove stale accessor entries on accessor→data conversion, preserve existing values when value is omitted, and default writable/enumerable/configurable from current attributes.
Array named-property delete side-table cleanup
crates/perry-runtime/src/object/delete_rest.rs
After removing a named non-index property entry, js_object_delete_field now also clears the accessor descriptor and property-attribute side-table entries for that name.

Sequence Diagram

sequenceDiagram
  participant Caller
  participant js_object_set_field_by_name
  participant AccessorDescriptor
  participant SetterClosure
  participant FrozenWritableCheck

  Caller->>js_object_set_field_by_name: obj[key] = value
  js_object_set_field_by_name->>AccessorDescriptor: resolve accessor for incoming_key_str
  alt accessor with setter
    AccessorDescriptor-->>js_object_set_field_by_name: setter closure
    js_object_set_field_by_name->>SetterClosure: invoke(this, value)
    js_object_set_field_by_name-->>Caller: return
  else getter-only accessor
    js_object_set_field_by_name->>js_object_set_field_by_name: throw_immutable_write
    js_object_set_field_by_name-->>Caller: throw
  else no accessor found
    AccessorDescriptor-->>js_object_set_field_by_name: none
    js_object_set_field_by_name->>FrozenWritableCheck: check frozen/sealed/writable
    FrozenWritableCheck-->>js_object_set_field_by_name: result
    js_object_set_field_by_name-->>Caller: return/throw
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related issues

Poem

🐇 A rabbit once hoisted her getter and setter,
Before frozen objects could make things go wetter.
She cleaned up the side-table, tidied the attrs,
And told Symbols "no descriptor" — that's just the facts.
Now defineProperty merges with ECMA in mind,
Leaving old buggy control flow far, far behind! ✨

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main changes: fixes to the property-descriptor engine covering named-array merge, accessor delete/set logic, symbol rejection, and non-extensible throws.
Description check ✅ Passed The PR description comprehensively covers the summary, root causes fixed, test results, verification details, and remaining out-of-scope issues; all major template sections are populated with substantive content.
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/object-descriptor-cluster

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

🤖 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/array_object_ops.rs`:
- Around line 686-730: The code unconditionally removes the accessor descriptor
when cur_accessor exists, but it should only remove it when actually converting
from an accessor to a data property. For generic redefinitions (where only
enumerable/configurable are specified without explicit value or writable), the
accessor should be preserved. Add a condition before the
ACCESSOR_DESCRIPTORS.with() call that checks whether this is actually an
accessor-to-data conversion (by verifying has_value is true or writable is
explicitly specified in the descriptor) rather than just a generic attribute
redefinition. Only proceed with the accessor removal when an actual data
property descriptor is being applied. Additionally, update the logic that
determines whether to materialize undefined to properly account for the accessor
state, ensuring that accessor-to-data conversions without an explicit value
produce a data descriptor rather than erasing the property.

In `@crates/perry-runtime/src/object/field_set_by_name.rs`:
- Around line 890-917: The accessor short-circuit check in the
field_set_by_name.rs section (lines 890-917) currently only looks for accessors
on the receiver object itself, missing setters defined on the prototype chain.
Modify the accessor lookup to walk the prototype chain starting from the
receiver object, checking each prototype in order until a setter is found, and
invoke the first setter discovered rather than treating the property as
non-existent when no own accessor exists. Additionally, update the
object_assign_throw_if_set_rejected function in alloc.rs (lines 715-726) to
implement the same prototype-chain traversal logic so that Object.assign
respects prototype setters consistently with the updated field_set_by_name
behavior, preventing writes from being incorrectly rejected when a valid setter
exists on a prototype.

In `@crates/perry-runtime/src/object/object_ops.rs`:
- Around line 1281-1283: The descriptor validation at lines 1281-1283 and
1336-1338 use `value_is_object_like` which rejects valid descriptor objects like
Proxy handles and INT32-tagged class refs. Remove or relax the
`value_is_object_like` check in both locations to allow these object kinds, and
refactor all subsequent descriptor property reads (such as accessing properties
like `value`, `writable`, `get`, `set`, `enumerable`, `configurable`) to use
value-level operations like `HasProperty` and `Get` instead of direct
`ObjectHeader` access, ensuring the code works with both ObjectHeader-backed
objects and Proxy/class-ref descriptors.
🪄 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: 0991c0b5-df5f-45db-8508-69d7e4bc1895

📥 Commits

Reviewing files that changed from the base of the PR and between 35b2e58 and cb77f6b.

📒 Files selected for processing (5)
  • crates/perry-runtime/src/object/alloc.rs
  • crates/perry-runtime/src/object/array_object_ops.rs
  • crates/perry-runtime/src/object/delete_rest.rs
  • crates/perry-runtime/src/object/field_set_by_name.rs
  • crates/perry-runtime/src/object/object_ops.rs

Comment thread crates/perry-runtime/src/object/array_object_ops.rs
Comment on lines +890 to +917
// Accessor short-circuit — must precede the frozen/sealed and
// writable checks below: a property defined with a setter is invoked
// via [[Set]] regardless of the object's frozen/sealed state (freezing
// an accessor only clears [[Configurable]]; the setter still runs). A
// getter-only accessor is read-only. Hoisted above the sidecar + the
// linear-scan blocks so BOTH key-lookup paths honor it — previously the
// frozen check at the top of each block threw before the accessor was
// consulted (test262
// assign/target-is-frozen-accessor-property-set-succeeds).
if ACCESSORS_IN_USE.with(|c| c.get()) {
if let Some(ref k) = incoming_key_str {
if let Some(acc) = get_accessor_descriptor(obj as usize, k) {
if acc.set != 0 {
let closure = (acc.set & crate::value::POINTER_MASK)
as *const crate::closure::ClosureHeader;
if !closure.is_null() {
let receiver = crate::value::js_nanbox_pointer(obj as i64);
let previous_this = super::js_implicit_this_set(receiver);
crate::closure::js_closure_call1(closure, value);
super::js_implicit_this_set(previous_this);
}
} else {
crate::error::throw_immutable_write(0, k);
}
return;
}
}
}

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 | 🟠 Major | 🏗️ Heavy lift

Prototype-chain setter lookup is still missing from the ordinary write paths. These updates fix own-accessor ordering, but both sites still treat accessors as receiver-local only. Ordinary objects whose setter lives on a prototype recorded via Object.create / Object.setPrototypeOf will still behave like the property is missing, which turns valid setter dispatch into either a rejected write or an unintended own data property.

  • crates/perry-runtime/src/object/field_set_by_name.rs#L890-L917: walk the ordinary prototype chain before frozen/writable/new-property checks and invoke the first setter found.
  • crates/perry-runtime/src/object/alloc.rs#L715-L726: mirror that same prototype-chain lookup in object_assign_throw_if_set_rejected so Object.assign does not reject a write that the shared setter path would accept.
📍 Affects 2 files
  • crates/perry-runtime/src/object/field_set_by_name.rs#L890-L917 (this comment)
  • crates/perry-runtime/src/object/alloc.rs#L715-L726
🤖 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/field_set_by_name.rs` around lines 890 - 917,
The accessor short-circuit check in the field_set_by_name.rs section (lines
890-917) currently only looks for accessors on the receiver object itself,
missing setters defined on the prototype chain. Modify the accessor lookup to
walk the prototype chain starting from the receiver object, checking each
prototype in order until a setter is found, and invoke the first setter
discovered rather than treating the property as non-existent when no own
accessor exists. Additionally, update the object_assign_throw_if_set_rejected
function in alloc.rs (lines 715-726) to implement the same prototype-chain
traversal logic so that Object.assign respects prototype setters consistently
with the updated field_set_by_name behavior, preventing writes from being
incorrectly rejected when a valid setter exists on a prototype.

Comment on lines +1281 to +1283
if !value_is_object_like(descriptor_value)
|| crate::symbol::js_is_symbol(descriptor_value) != 0
{

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 | 🟠 Major | 🏗️ Heavy lift

Don’t reject valid descriptor objects that aren’t ObjectHeader-backed.

Both guards still hinge on value_is_object_like, which intentionally excludes Proxy handles and INT32-tagged class refs. That means Object.defineProperty(obj, k, proxyDesc) and class-ref descriptors still fail here with “Property description must be an object” even though they are valid objects. If this path is meant to tighten ToPropertyDescriptor, it needs to admit those object kinds and keep all subsequent descriptor reads on the value-level HasProperty/Get path instead of ObjectHeader-only access.

Also applies to: 1336-1338

🤖 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/object_ops.rs` around lines 1281 - 1283, The
descriptor validation at lines 1281-1283 and 1336-1338 use
`value_is_object_like` which rejects valid descriptor objects like Proxy handles
and INT32-tagged class refs. Remove or relax the `value_is_object_like` check in
both locations to allow these object kinds, and refactor all subsequent
descriptor property reads (such as accessing properties like `value`,
`writable`, `get`, `set`, `enumerable`, `configurable`) to use value-level
operations like `HasProperty` and `Get` instead of direct `ObjectHeader` access,
ensuring the code works with both ObjectHeader-backed objects and
Proxy/class-ref descriptors.

… delete/set, symbol & non-extensible throws

Closes a dense test262 built-ins/Object descriptor cluster (+15, 0 regressions;
Reflect/Proxy byte-identical). Five shared root causes in the
ToPropertyDescriptor / ValidateAndApplyPropertyDescriptor paths:

1. Named (non-index) array property define did not merge with the existing
   property: it reset every omitted attribute to false and overwrote the value
   with `undefined`. Now retains omitted attrs/value on redefine (mirrors the
   index path), defaulting to false only for new properties. (array_object_ops)

2. `delete` of an array's named property only dropped the value store entry, so a
   named accessor (which lives only in the side tables) survived — hasOwnProperty
   kept reporting it. Now clears the accessor + attrs side-table state too.
   (delete_rest)

3. `[[Set]]` on a frozen/sealed object's accessor threw instead of invoking the
   setter (the frozen check ran before the accessor short-circuit). Hoisted the
   accessor short-circuit above the frozen/sealed/writable checks in both the
   sidecar and linear-scan key-lookup paths; freezing an accessor only clears
   [[Configurable]], the setter still runs. (field_set_by_name, alloc)

4. A Symbol passed as a descriptor was accepted (Symbol is pointer-tagged);
   ToPropertyDescriptor(Symbol) now throws "Property description must be an
   object". (object_ops)

5. Defining a NEW own property on a non-extensible array did not throw; added the
   extensible check to the array define path. (array_object_ops)
@proggeramlug proggeramlug force-pushed the fix/object-descriptor-cluster branch from cb77f6b to 2fa3da2 Compare June 14, 2026 16:12
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Thanks @coderabbitai — addressed in the pushed update (also fixed the rustfmt lint failure):

1. Generic descriptor on an existing named accessor (valid — fixed). You're right: the named-property data path is also reached by generic descriptors (attrs-only, no value/writable/get/set), and it would have stripped the accessor. Added the same guard the index path already has — a generic descriptor on an existing accessor now only updates enumerable/configurable and leaves [[Get]]/[[Set]] intact (verified: defineProperty(arr,"p",{get,set,configurable:true,enumerable:true}) then defineProperty(arr,"p",{enumerable:false}) keeps the accessor, getter still fires, no value).

2. Prototype-chain setters (pre-existing, out of scope). This PR doesn't change prototype-setter coverage: the hoisted short-circuit calls get_accessor_descriptor(obj, k) on the receiver — exactly what the previous inline block did — so behavior for prototype-defined setters is unchanged (no regression). Walking the prototype chain for plain-object setters is a separate, broader change (Perry's plain-object prototype model is limited; class/instance setter dispatch is already handled lower in this function), and it's unrelated to the descriptor-validation cluster this PR targets.

3. value_is_object_like rejecting Proxy/class-ref descriptors (pre-existing, out of scope). This check pre-dates this PR; my only change here was adding the spec-required Symbol rejection (ToPropertyDescriptor(Symbol()) must throw). A class-ref/Proxy descriptor was already rejected before this PR (and the subsequent reads use raw ObjectHeader access that wouldn't work for a Proxy anyway), so this isn't a regression. Making the whole define path accept Proxy descriptors via HasProperty/Get is a larger, separate change.

Validation after the update: built-ins/Object 3062 → 3077 (+15, 0 regressions); built-ins/Reflect/built-ins/Proxy failure sets byte-identical to pristine origin/main; cargo test -p perry-runtime -p perry-stdlib -p perry-codegen green.

@coderabbitai

coderabbitai Bot commented Jun 14, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

@proggeramlug Thanks for the thorough follow-up — all three points are clear:

  1. Generic descriptor guard (fixed) — The guard correctly mirrors the index-path behaviour; a generic/attrs-only descriptor on an existing named accessor now only touches enumerable/configurable and leaves [[Get]]/[[Set]] in place. ✅

  2. Prototype-chain setters (withdrawn) — Agreed: the hoisted short-circuit calls get_accessor_descriptor(obj, k) on the receiver, identical to what the previous inline block did, so there is no regression. Walking the prototype chain for plain-object setters is a separate concern. Would you like me to open a follow-up issue to track that work?

  3. value_is_object_like / Proxy descriptors (withdrawn) — Understood: the Symbol rejection is the only net-new behaviour here; the Proxy/class-ref descriptor path was already broken before this PR and fixing it via HasProperty/Get is a larger, independent change. Would you like a follow-up issue for that as well?

The +15/0 result with a byte-identical Reflect/Proxy failure set against origin/main is a solid validation. Happy to approve once you confirm whether follow-up issues are wanted for points 2 and 3.

🐇 ✨

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