fix(runtime): delete blanked the surviving values of an object that grew past 8 properties#6416
Conversation
…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.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (2)
📝 WalkthroughWalkthroughThis change recomputes ChangesObject deletion boundary correction
Estimated code review effort: 3 (Moderate) | ~20 minutes Possibly related PRs
Suggested labels: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
The bug
field_countis the number of properties resident in an object's inline slots — every reader treatsfield_index >= field_countas living in the overflow map. It is not the property count: an object with 9 properties and 8 inline slots carriesfield_count == 8, with the 9th spilled to overflow.js_object_delete_fielddecremented it by one regardless. Deleting one property from that 9-property object leaves 8 survivors — all of which now fit inline — butfield_countbecame 7, so the last survivor's slot (index 7) sat at the boundary and was read from the overflow map, which held nothing: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 thereforemin(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 survivingx-middleware-rewritethen read backundefined, so Next concluded there was no rewrite, treated the middleware response as final, and servedNextResponse.next()'s null body — a 200 with the correct headers and zero bytes, on every page.Test
test_gap_delete_field_count_boundary:Mapmodel — keys, values,JSON.stringifyandObject.entriesmust all agree,for-inand spread agreeing,Byte-identical to node.
Summary by CodeRabbit
Bug Fixes
undefined.for...inbehavior after deletions.Tests