Skip to content

Object.seal / Object.freeze: take the JSObject fast path for JSArray and JSFinalObject with indexed properties - #337

Open
robobun wants to merge 1 commit into
mainfrom
farm/fbe5fb68/fast-seal-freeze-arrays
Open

robobun wants to merge 1 commit into
mainfrom
farm/fbe5fb68/fast-seal-freeze-arrays

Conversation

@robobun

@robobun robobun commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator

Problem

objectConstructorSeal / objectConstructorFreeze only take the JSObject::seal / JSObject::freeze fast path for a JSFinalObject with no indexed properties. Every other receiver, including every JSArray, falls through to the generic SetIntegrityLevel loop:

preventExtensions(object);                                 // enterDictionaryIndexingMode
getOwnPropertyNames(object, names);                        // atomizes every index
for (name : names)
    methodTable()->defineOwnProperty(object, name, desc);  // HashMap lookup + descriptor validation per element

On a dense N-element array that is three O(n) passes on top of the contiguous→sparse conversion, each going through Identifier allocation and method-table dispatch.

For oven-sh/bun#6360 (2000 arrays, avg 1000 elements) Object.seal takes ~460ms and Object.freeze ~530ms vs ~3ms in V8.

Change

  • SparseArrayValueMap::seal() / freeze(): bulk-OR DontDelete (and ReadOnly for data entries on freeze) onto every entry's attributes, mirroring PropertyTable::seal() / freeze().
  • JSObject::seal / freeze: after enterDictionaryIndexingMode, apply the bulk attribute update to the sparse map; freeze also sets LengthIsReadOnly on the map for JSArray so isLengthWritable() reports false the same way the generic SetIntegrityLevel loop would. The early-return skips its short-circuit when indexed properties are present because Structure::isSealed / isFrozen only consult the named-property table.
  • materializeLazyOwnProperties: skip JSArray, whose only special own property is length, which is never reified onto the PropertyTable; enumerating own names there just atomizes every index for no effect.
  • objectConstructorSeal / objectConstructorFreeze: widen the fast-path gate to is<JSFinalObject>() || isJSArray(). isJSArray matches exact ArrayType, so DerivedArrayType subclasses and every other receiver with overridden [[DefineOwnProperty]] / [[PreventExtensions]] still take the generic observable loop.

Results (x86_64, release)

2000 iterations, array size i filled with i:

before after
Object.seal(array) 462 ms 89 ms
Object.freeze(array) 530 ms 98 ms
Object.preventExtensions(array) 102 ms 102 ms
baseline (fill only) 5 ms 4 ms

The remaining ~90ms is enterDictionaryIndexingMode itself (contiguous → ArrayStorage vector → SparseArrayValueMap). JSC represents non-configurable indexed elements via the sparse map and has no sealed/frozen contiguous elements kind like V8, so the conversion is still required; a further ~2x is available by folding the two conversions into one (the "horribly inefficient" note in enterDictionaryIndexingMode).

Tests

  • JSTests/stress/object-seal-freeze-array-fast-path.js covers: descriptor attributes after seal/freeze on dense / holey / accessor-bearing / named-prop-bearing arrays and plain objects, length writability, seal-after-preventExtensions, freeze-after-seal, idempotence, strict-mode throw semantics, and that a Proxy target still observes the preventExtensions / ownKeys / defineProperty traps.
  • JSTests/microbenchmarks/object-seal-freeze-large-array.js is the sealing/freezing loop for the perf dashboard.
  • All 279 test262 tests under built-ins/Object/{seal,freeze,isSealed,isFrozen,preventExtensions} and the existing JSTests/stress/*seal*, *freeze*, *frozen* pass.

@coderabbitai

coderabbitai Bot commented Jul 24, 2026 •

Copy link
Copy Markdown

Review Change Stack

Walkthrough

JavaScriptCore now applies sealing and freezing attributes to sparse indexed properties, extends array-specific integrity handling, centralizes fast-path selection, and adds stress-test coverage plus a large-array microbenchmark.

Changes

Array integrity operations

Layer / File(s) Summary
Sparse indexed property integrity
Source/JavaScriptCore/runtime/SparseArrayValueMap.*
Adds seal() and freeze() operations that update deletion and writability attributes for indexed entries.
JSObject integrity transitions
Source/JavaScriptCore/runtime/JSObject.cpp
Updates seal/freeze processing for indexed storage, array length, lazy properties, and repeated integrity checks.
Constructor fast-path selection
Source/JavaScriptCore/runtime/ObjectConstructor.cpp
Uses a shared predicate to select direct seal/freeze operations for supported objects.
Array integrity behavior validation
JSTests/stress/object-seal-freeze-array-fast-path.js, JSTests/microbenchmarks/object-seal-freeze-large-array.js
Adds behavioral coverage for arrays, objects, accessors, proxies, prototype chains, and strict mode, plus a sealing/freezing benchmark.】【。
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
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.
Title check ✅ Passed The title clearly and accurately summarizes the main change: fast-path sealing/freezing for arrays and JSFinalObject with indexed properties.
Description check ✅ Passed The description is detailed and covers problem, changes, results, and tests, though it omits the Bugzilla link and Reviewed by/template boilerplate.

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

@github-actions

github-actions Bot commented Jul 24, 2026 •

Copy link
Copy Markdown

Preview Builds

Commit Release Date
e4f1d0d0 autobuild-preview-pr-337-e4f1d0d0 2026-07-25 05:19:59 UTC
c00301cf autobuild-preview-pr-337-c00301cf 2026-07-25 00:49:35 UTC
8bb57a36 autobuild-preview-pr-337-8bb57a36 2026-07-24 23:42:17 UTC

Comment thread Source/JavaScriptCore/runtime/JSObject.cpp
@robobun
robobun force-pushed the farm/fbe5fb68/fast-seal-freeze-arrays branch from 8bb57a3 to c00301c Compare July 25, 2026 00:11
…and JSFinalObject with indexed properties

objectConstructorSeal / objectConstructorFreeze only take the
JSObject::seal / JSObject::freeze fast path for a JSFinalObject with no
indexed properties; every other receiver falls through to the generic
SetIntegrityLevel loop, which per element allocates an Identifier,
dispatches through the method table, and walks defineOwnProperty. On a
1000-element array that is three O(n) passes on top of the dictionary
conversion.

Teach JSObject::seal / freeze to also set the integrity attributes on
the SparseArrayValueMap (new seal()/freeze() mirroring
PropertyTable::seal/freeze), freeze JSArray "length" from the same
place, and widen the fast-path gate to JSFinalObject || isJSArray().

JSArray has no lazily reified own properties so
materializeLazyOwnProperties skips it, and Structure::isSealed/isFrozen
only cover named properties so the early returns skip their short
circuit when indexed properties are present.

For the oven-sh/bun#6360 benchmark (2000 arrays, avg 1000 elements)
this brings Object.seal from ~460ms to ~90ms and Object.freeze from
~530ms to ~100ms on x86_64; the remainder is the contiguous -> sparse
map conversion that JSC uses to represent non-configurable indices.
@robobun
robobun force-pushed the farm/fbe5fb68/fast-seal-freeze-arrays branch from c00301c to e4f1d0d Compare July 25, 2026 04:45

@claude claude 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.

The earlier notifyPresenceOfIndexedAccessors issue is fixed and covered by the new prototype-chain regression tests, and I didn't find further problems on this pass — but this widens a spec-equivalence fast path in core JSObject runtime code, so it's worth a human look.

What was reviewed:

  • JSObject::seal/freeze early-return gating vs. indexed properties, and that enterDictionaryIndexingMode always yields ArrayStorage + sparse map for JSArray (incl. empty arrays) so setLengthIsReadOnly is reached.
  • SparseArrayValueMap::freeze() accessor-vs-data attribute handling matches PropertyTable::freeze() and the generic SetIntegrityLevel descriptor.
  • canFastSetIntegrityLevel gate: isJSArray is exact-ArrayType, so DerivedArrayType/Proxy/typed arrays still hit the observable loop.
  • materializeLazyOwnProperties skipping JSArray — JSArray::getOwnSpecialPropertyNames only adds length, which is virtual and already DontDelete.
Extended reasoning...

Overview

The PR widens the Object.seal/Object.freeze fast path from "JSFinalObject with no indexed properties" to "JSFinalObject or exact JSArray", and teaches JSObject::seal/freeze to bulk-apply DontDelete/ReadOnly to the SparseArrayValueMap in place of the per-index defineOwnProperty loop. Six files touched: JSObject.cpp, ObjectConstructor.cpp, SparseArrayValueMap.{h,cpp}, plus a stress test and a microbenchmark.

Security risks

None identified. This is a spec-equivalence optimization on ordinary object internal methods; no attacker-controlled input reaches new parsing or allocation paths, and the gate is narrowed to exact JSFinalObject/ArrayType so exotic [[DefineOwnProperty]]/[[PreventExtensions]] overrides (Proxy, typed arrays, module namespaces, DerivedArrayType) still take the generic observable loop.

Level of scrutiny

High. This is core JSC runtime code where the fast path must be observationally identical to the ECMAScript SetIntegrityLevel algorithm across every reachable object shape. The first revision already had one real bug (missing MayHaveIndexedAccessors structure bit → prototype-chain ReadOnly enforcement bypassed), now fixed with regression coverage. That kind of invariant — structure-transition side effects the old defineOwnIndexedProperty path performed implicitly — is exactly what makes this change hard to verify by inspection alone, so it warrants a human reviewer with JSC object-model expertise.

Other factors

  • The follow-up commit adds notifyPresenceOfIndexedAccessors(vm) after map->freeze() and regression tests for both freeze-before-prototype and freeze-after-prototype cases, plus a positive test that a sealed prototype still allows shadowing. This addresses the earlier finding fully.
  • I re-checked that enterDictionaryIndexingMode for ArrayClass (empty array) still allocates ArrayStorage and a sparse map, so setLengthIsReadOnly() is reached for Object.freeze([]); the stress test asserts this.
  • The isSealed(vm)/isFrozen(vm) short-circuit after the sparse-map update is sound because at that point indexed attributes have already been applied and Structure::didTransition() is true (the preventExtensionsTransition inside enterDictionaryIndexingMode's callers isn't relevant here, but the storage-type transition is), so the only remaining work is the named-property sealTransition/freezeTransition.
  • test262 (built-ins/Object/{seal,freeze,isSealed,isFrozen,preventExtensions}, 279 tests) and existing JSTests/stress/*seal*|*freeze* are reported passing.
  • Not approving because this is a non-mechanical change to a critical, spec-sensitive runtime path that already surfaced one subtle correctness bug during review.

robobun added a commit that referenced this pull request Oct 1, 2026
…ArrayStorage vector

Object.preventExtensions, Object.seal, Object.freeze and a read-only
"length" all went through JSObject::enterDictionaryIndexingMode, which
moved every element of the object into the SparseArrayValueMap (sparse
mode, vector length 0). After that every a[i] read missed the JIT vector
fast paths and did a hash lookup in C++, and the freeze itself cost two
butterfly conversions, a map cell and one hash insert per element.

Now enterDictionaryIndexingMode keeps the elements in the vector and only
switches the object to the SlowPutArrayStorage shape. LLInt and the
baseline JIT never store into a SlowPutArrayStorage vector inline, and
DFG / FTL now check the structure before they do, so every write reaches
the C++ paths. The attributes of the elements live on the Structure:
vectorElementsAreNonConfigurable (Seal), vectorElementsAreReadOnly
(Freeze) and arrayLengthIsReadOnly (Freeze and the new
SetArrayLengthReadOnly transition, which replaces the LengthIsReadOnly
flag of the sparse map). Entries of the sparse map keep their own
attributes; elements only move into the map when a single index needs
attributes of its own (Object.defineProperty on an element), and they
carry the structure's attributes with them.

A non-extensible object therefore never has the plain ArrayStorage shape
(suggestedArrayStorageTransition), which is what lets the plain
ArrayStorage paths stay as they were. The C++ vector writers and readers
consult the bits: putByIndex, trySetIndexQuickly, putDirectIndex and the
beyond-vector-length paths, deletePropertyByIndex, defineOwnIndexedProperty,
getOwnPropertySlotByIndex, the prototype-chain put intercept,
JSArray::setLength / pop / push and Array.prototype.reverse.

The DFG folds a read of a frozen element from the vector as it folded one
from the sparse map; JSObject::freeze fences the element stores before the
structure store. ArrayAllocationProfile ignores the shape of an array that
became non-extensible, so one frozen array does not make the arrays its
allocation site creates next SlowPutArrayStorage. The baseline indexed
load and "in" ICs accept the SlowPutArrayStorage shape, whose vector they
already read. Object.isSealed / isFrozen answer from the structure for an
object that JSObject::seal / freeze sealed or froze.

On the oven-sh/bun#44305 repro (300k arrays of 16 ints, x86_64 release
jsc): freeze 970 ms -> 35-47 ms, reads from the frozen arrays 9.5x plain
-> 1.0-1.2x plain.

Builds on #337, which is the first commit of this branch.
robobun added a commit that referenced this pull request Oct 1, 2026
…ArrayStorage vector

Object.preventExtensions, Object.seal, Object.freeze and a read-only
"length" all went through JSObject::enterDictionaryIndexingMode, which
moved every element of the object into the SparseArrayValueMap (sparse
mode, vector length 0). After that every a[i] read missed the JIT vector
fast paths and did a hash lookup in C++, and the freeze itself cost two
butterfly conversions, a map cell and one hash insert per element.

Now enterDictionaryIndexingMode keeps the elements in the vector and only
switches the object to the SlowPutArrayStorage shape. LLInt and the
baseline JIT never store into a SlowPutArrayStorage vector inline, and
DFG / FTL now check the structure before they do, so every write reaches
the C++ paths. The attributes of the elements live on the Structure:
vectorElementsAreNonConfigurable (Seal), vectorElementsAreReadOnly
(Freeze) and arrayLengthIsReadOnly (Freeze and the new
SetArrayLengthReadOnly transition, which replaces the LengthIsReadOnly
flag of the sparse map). Entries of the sparse map keep their own
attributes; elements only move into the map when a single index needs
attributes of its own (Object.defineProperty on an element), and they
carry the structure's attributes with them.

A non-extensible object therefore never has the plain ArrayStorage shape
(suggestedArrayStorageTransition), which is what lets the plain
ArrayStorage paths stay as they were. The C++ vector writers and readers
consult the bits: putByIndex, trySetIndexQuickly, putDirectIndex and the
beyond-vector-length paths, deletePropertyByIndex, defineOwnIndexedProperty,
getOwnPropertySlotByIndex, the prototype-chain put intercept,
JSArray::setLength / pop / push and Array.prototype.reverse.

An array with no elements (ArrayClass, such as Array.prototype) stays
blank: the bits and isStructureExtensible() cover the later writes, so
freezing Array.prototype keeps the array prototype chain watchpoint. A
frozen object tells the objects that inherit from it about its read-only
elements (notifyPresenceOfIndexedAccessors) when it becomes a prototype,
not when it is frozen, so a frozen array that is no prototype keeps the
builtin fast paths.

The DFG folds a read of a frozen element from the vector as it folded one
from the sparse map; JSObject::freeze fences the element stores before the
structure store. ArrayAllocationProfile never records the
SlowPutArrayStorage shape, so one frozen array does not make the arrays
its allocation site creates next SlowPutArrayStorage. The baseline indexed
load and "in" ICs accept the SlowPutArrayStorage shape, whose vector they
already read. Object.isSealed / isFrozen answer from the structure for an
object that JSObject::seal / freeze sealed or froze.

Object.seal / Object.freeze take the JSObject::seal / freeze path for an
exact JSArray and for a JSFinalObject with indexed properties instead of
the generic per-index SetIntegrityLevel loop (from #337,
which this supersedes); JSArray has no lazily reified own properties, so
materializeLazyOwnProperties skips it.

On the oven-sh/bun#44305 repro (300k arrays of 16 ints, x86_64 release
jsc): freeze 970 ms -> 35-47 ms, reads from the frozen arrays 9.5x plain
-> 1.0-1.2x plain.

Supersedes #337. The ArrayClass handling and
freeze-array-prototype.js come from #622.

Co-authored-by: Jarred Sumner <jarred@jarredsumner.com>
@robobun

robobun commented Oct 1, 2026

Copy link
Copy Markdown
Collaborator Author

#752 carries this change (the objectConstructorSeal / Freeze gate widening and SparseArrayValueMap::seal / freeze) and also keeps the elements in the ArrayStorage vector instead of moving them into the sparse map, which is what made reads from frozen arrays slow. If #752 lands, this one can be closed.

robobun added a commit that referenced this pull request Oct 1, 2026
…ArrayStorage vector

Object.preventExtensions, Object.seal, Object.freeze and a read-only
"length" all went through JSObject::enterDictionaryIndexingMode, which
moved every element of the object into the SparseArrayValueMap (sparse
mode, vector length 0). After that every a[i] read missed the JIT vector
fast paths and did a hash lookup in C++, and the freeze itself cost two
butterfly conversions, a map cell and one hash insert per element.

Now enterDictionaryIndexingMode keeps the elements in the vector and only
switches the object to the SlowPutArrayStorage shape. LLInt and the
baseline JIT never store into a SlowPutArrayStorage vector inline, and
DFG / FTL now check the structure before they do, so every write reaches
the C++ paths. The attributes of the elements live on the Structure:
vectorElementsAreNonConfigurable (Seal), vectorElementsAreReadOnly
(Freeze) and arrayLengthIsReadOnly (Freeze and the new
SetArrayLengthReadOnly transition, which replaces the LengthIsReadOnly
flag of the sparse map). Entries of the sparse map keep their own
attributes; elements only move into the map when a single index needs
attributes of its own (Object.defineProperty on an element), and they
carry the structure's attributes with them.

A non-extensible object therefore never has the plain ArrayStorage shape
(suggestedArrayStorageTransition), which is what lets the plain
ArrayStorage paths stay as they were. The C++ vector writers and readers
consult the bits: putByIndex, trySetIndexQuickly, putDirectIndex and the
beyond-vector-length paths, deletePropertyByIndex, defineOwnIndexedProperty,
getOwnPropertySlotByIndex, the prototype-chain put intercept,
JSArray::setLength / pop / push and Array.prototype.reverse.

An array with no elements (ArrayClass, such as Array.prototype) stays
blank: the bits and isStructureExtensible() cover the later writes, so
freezing Array.prototype keeps the array prototype chain watchpoint. A
frozen object tells the objects that inherit from it about its read-only
elements (notifyPresenceOfIndexedAccessors) when it becomes a prototype,
not when it is frozen, so a frozen array that is no prototype keeps the
builtin fast paths.

The DFG folds a read of a frozen element from the vector as it folded one
from the sparse map; JSObject::freeze fences the element stores before the
structure store. ArrayAllocationProfile never records the
SlowPutArrayStorage shape, so one frozen array does not make the arrays
its allocation site creates next SlowPutArrayStorage. The baseline indexed
load and "in" ICs accept the SlowPutArrayStorage shape, whose vector they
already read. Object.isSealed / isFrozen answer from the structure for an
object that JSObject::seal / freeze sealed or froze.

Object.seal / Object.freeze take the JSObject::seal / freeze path for an
exact JSArray and for a JSFinalObject with indexed properties instead of
the generic per-index SetIntegrityLevel loop (from #337,
which this supersedes); JSArray has no lazily reified own properties, so
materializeLazyOwnProperties skips it.

On the oven-sh/bun#44305 repro (300k arrays of 16 ints, x86_64 release
jsc): freeze 970 ms -> 35-47 ms, reads from the frozen arrays 9.5x plain
-> 1.0-1.2x plain.

Supersedes #337. The ArrayClass handling and
freeze-array-prototype.js come from #622.

Co-authored-by: Jarred Sumner <jarred@jarredsumner.com>
robobun added a commit that referenced this pull request Oct 1, 2026
…ArrayStorage vector

Object.preventExtensions, Object.seal, Object.freeze and a read-only
"length" all went through JSObject::enterDictionaryIndexingMode, which
moved every element of the object into the SparseArrayValueMap (sparse
mode, vector length 0). After that every a[i] read missed the JIT vector
fast paths and did a hash lookup in C++, and the freeze itself cost two
butterfly conversions, a map cell and one hash insert per element.

Now enterDictionaryIndexingMode keeps the elements in the vector and only
switches the object to the SlowPutArrayStorage shape. LLInt and the
baseline JIT never store into a SlowPutArrayStorage vector inline, and
DFG / FTL now check the structure before they do, so every write reaches
the C++ paths. The attributes of the elements live on the Structure:
vectorElementsAreNonConfigurable (Seal), vectorElementsAreReadOnly
(Freeze) and arrayLengthIsReadOnly (Freeze and the new
SetArrayLengthReadOnly transition, which replaces the LengthIsReadOnly
flag of the sparse map). Entries of the sparse map keep their own
attributes; elements only move into the map when a single index needs
attributes of its own (Object.defineProperty on an element), and they
carry the structure's attributes with them.

A non-extensible object therefore never has the plain ArrayStorage shape
(suggestedArrayStorageTransition), which is what lets the plain
ArrayStorage paths stay as they were. The C++ vector writers and readers
consult the bits: putByIndex, trySetIndexQuickly, putDirectIndex and the
beyond-vector-length paths, deletePropertyByIndex, defineOwnIndexedProperty,
getOwnPropertySlotByIndex, the prototype-chain put intercept,
JSArray::setLength / pop / push and Array.prototype.reverse.

An array with no elements (ArrayClass, such as Array.prototype) stays
blank: the bits and isStructureExtensible() cover the later writes, so
freezing Array.prototype keeps the array prototype chain watchpoint. A
frozen object tells the objects that inherit from it about its read-only
elements (notifyPresenceOfIndexedAccessors) when it becomes a prototype,
not when it is frozen, so a frozen array that is no prototype keeps the
builtin fast paths.

The DFG folds a read of a frozen element from the vector as it folded one
from the sparse map; JSObject::freeze fences the element stores before the
structure store. ArrayAllocationProfile never records the
SlowPutArrayStorage shape, so one frozen array does not make the arrays
its allocation site creates next SlowPutArrayStorage. The baseline indexed
load and "in" ICs accept the SlowPutArrayStorage shape, whose vector they
already read. Object.isSealed / isFrozen answer from the structure for an
object that JSObject::seal / freeze sealed or froze.

Object.seal / Object.freeze take the JSObject::seal / freeze path for an
exact JSArray and for a JSFinalObject with indexed properties instead of
the generic per-index SetIntegrityLevel loop (from #337,
which this supersedes); JSArray has no lazily reified own properties, so
materializeLazyOwnProperties skips it.

On the oven-sh/bun#44305 repro (300k arrays of 16 ints, x86_64 release
jsc): freeze 970 ms -> 35-47 ms, reads from the frozen arrays 9.5x plain
-> 1.0-1.2x plain.

Supersedes #337. The ArrayClass handling and
freeze-array-prototype.js come from #622.

Co-authored-by: Jarred Sumner <jarred@jarredsumner.com>
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