Skip to content

fix(runtime): delete blanked the surviving values of an object that grew past 8 properties#6416

Merged
proggeramlug merged 1 commit into
mainfrom
fix/delete-field-count-inline-boundary
Jul 14, 2026
Merged

fix(runtime): delete blanked the surviving values of an object that grew past 8 properties#6416
proggeramlug merged 1 commit into
mainfrom
fix/delete-field-count-inline-boundary

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

The bug

field_count is the number of properties resident in an object's inline slots — every reader treats field_index >= field_count as living in the overflow map. It is not the property count: an object with 9 properties and 8 inline slots carries field_count == 8, with the 9th spilled to overflow.

js_object_delete_field decremented it by one regardless. Deleting one property from that 9-property object leaves 8 survivors — all of which now fit inline — but field_count became 7, so the last survivor's slot (index 7) sat at the boundary and was read from the overflow map, which held nothing:

const o = {};
for (let i = 0; i < 8; i++) o["k" + i] = "v" + i;
o.keep = "KEEP";
delete o.k0;

o.keep              // node: "KEEP"   perry: undefined
Object.keys(o)      // still lists "keep"   <-- the key survives
JSON.stringify(o)   // drops it entirely    <-- the value is gone

The key stays enumerable while its value vanishes, so the failure surfaces far from the delete. It bites any object that ever grew past 8 properties — the corruption appears exactly when the property count drops from above 8 back to ≤ 8.

The fix

After the shift, the survivors occupy slots 0..new_count, inline up to the allocation's capacity. The correct count is therefore min(new_count, alloc_limit).

One subtlety worth flagging for review: the shift must keep reading by slot index. My first attempt moved the values by reading them by name, which invokes a getter and stores its result as a data property — silently collapsing accessors. Next's module exports are Object.defineProperty(…, {get}), so that flattened them and every route 404'd. The test pins this down.

How it was found

Compiling a real Next.js 16 app. Next deletes a batch of x-middleware-request-* keys from the middleware's header object; the surviving x-middleware-rewrite then read back undefined, so Next concluded there was no rewrite, treated the middleware response as final, and served NextResponse.next()'s null body — a 200 with the correct headers and zero bytes, on every page.

Test

test_gap_delete_field_count_boundary:

  • the 9-property case above,
  • 766 exhaustive build/delete/compare cases against a Map model — keys, values, JSON.stringify and Object.entries must all agree,
  • re-adding a key after deleting it, with for-in and spread agreeing,
  • an accessor surviving a delete on the same object (still a getter, still recomputing).

Byte-identical to node.

Summary by CodeRabbit

  • Bug Fixes

    • Fixed object property deletion near storage boundaries, preventing remaining properties from becoming inaccessible or appearing as undefined.
    • Preserved correct property enumeration, value access, serialization, object spreading, and for...in behavior after deletions.
    • Ensured accessor properties remain getters when other properties are deleted.
  • Tests

    • Added comprehensive coverage for deletion, re-adding properties, enumeration, serialization, and accessor preservation.

…rew past 8 properties

`field_count` is the number of properties resident in an object's INLINE slots —
every reader treats `field_index >= field_count` as living in the overflow map. It
is NOT the property count: an object with 9 properties and 8 inline slots carries
`field_count == 8`, with the 9th spilled to overflow.

`js_object_delete_field` decremented it by one regardless. Deleting one property
from that 9-property object leaves 8 survivors — all of which now FIT inline — but
`field_count` became 7, so the last survivor's slot (index 7) sat at the boundary
and was read from the overflow map, which held nothing:

    const o = {};
    for (let i = 0; i < 8; i++) o["k" + i] = "v" + i;
    o.keep = "KEEP";
    delete o.k0;

    o.keep              // node: "KEEP"   perry: undefined
    Object.keys(o)      // still lists "keep"   <-- key survives
    JSON.stringify(o)   // drops it entirely    <-- value is gone

The key stays enumerable while its value vanishes, so the failure surfaces far from
the delete. It bites any object that ever grew past 8 properties, and the damage
scales: the corruption appears exactly when the property count drops from above 8
back to 8 or below.

After the shift the survivors occupy slots `0..new_count`, inline up to the
allocation's capacity — so the correct count is `min(new_count, alloc_limit)`.

Note the shift itself must keep reading by SLOT INDEX. Reading by name to move the
values would invoke a getter and store its result as a data property, silently
collapsing accessors — Next's module exports are `Object.defineProperty(…, {get})`,
and flattening them breaks every route. The test pins that down.

Next.js deletes a batch of `x-middleware-request-*` keys from the middleware's
header object; the surviving `x-middleware-rewrite` then read back `undefined`, so
Next concluded there was no rewrite, treated the middleware response as final, and
served `NextResponse.next()`'s null body — a 200 with the right headers and zero
bytes, on every page.

Found while compiling a real Next.js 16 app. Covered by
`test_gap_delete_field_count_boundary`: the 9-property case, 766 exhaustive
build/delete/compare cases against a `Map` model (keys, values, `JSON.stringify`,
`Object.entries`), re-adding after a delete, `for-in`/spread agreement, and an
accessor surviving a delete on the same object. Byte-identical to node.
@coderabbitai

coderabbitai Bot commented Jul 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: 9b811a9d-59a4-4c95-8f8f-c079470aada9

📥 Commits

Reviewing files that changed from the base of the PR and between 7b6369a and 5773e23.

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

📝 Walkthrough

Walkthrough

This change recomputes field_count after object property deletion to preserve correct inline and overflow slot boundaries. It adds regression coverage for boundary access, enumeration, serialization, property re-addition, and accessor descriptors.

Changes

Object deletion boundary correction

Layer / File(s) Summary
Recompute deletion storage boundaries
crates/perry-runtime/src/object/delete_rest.rs
Shifted values use index-based resolution, and field_count is recomputed from surviving properties and the allocation limit.
Validate deletion behavior
test-files/test_gap_delete_field_count_boundary.ts
Adds boundary reproduction, exhaustive model-based checks, re-addition and enumeration checks, and accessor preservation validation.

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

Possibly related PRs

  • PerryTS/perry#5967: Modifies the same object deletion implementation for exotic non-configurable property cases.

Suggested labels: run-extended-tests

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description is substantive but does not follow the required template headings and leaves required sections like Related issue and Checklist unfilled. Rewrite the PR description using the repo template: Summary, Changes, Related issue, Test plan, Screenshots/output, and Checklist.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly matches the main fix: deletion on objects that grew past 8 properties.
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/delete-field-count-inline-boundary

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.

@proggeramlug
proggeramlug merged commit 8ebf354 into main Jul 14, 2026
26 checks passed
@proggeramlug
proggeramlug deleted the fix/delete-field-count-inline-boundary branch July 14, 2026 21:39
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