Skip to content

Cache source constructor name across prototype transitions - #312

Open
robobun wants to merge 1 commit into
mainfrom
farm/c73052e6/source-constructor-name
Open

Cache source constructor name across prototype transitions#312
robobun wants to merge 1 commit into
mainfrom
farm/c73052e6/source-constructor-name

Conversation

@robobun

@robobun robobun commented Jul 19, 2026

Copy link
Copy Markdown
Collaborator

When setPrototypeOf() clears a Structure's previousID (via pin()), the link back to the original prototype's constructor is lost. Node's util.inspect recovers the class name for null-prototype objects via V8's Map::constructor back-reference, producing [Foo: null prototype] for Object.setPrototypeOf(new Foo(), null). JSC has no equivalent: calculatedClassName() falls back to "Object" once the prototype is gone.

This caches the name on StructureRareData at the point of transition:

  • changePrototypeTransition computes it from the previous prototype's own constructor property (VMInquiry only, no JS execution) or from a previously cached value, and stores the result on the new structure's rare data. The lookup runs before DeferGC. The common Object.prototypenull path is skipped so Object.create(null) allocates no rare data.
  • toDictionaryTransition copies an existing cached name forward so dictionary conversion after setPrototypeOf does not lose it.
  • Structure::sourceConstructorName() walks previousID() to the first cached value so property-add transitions after the prototype change can still find it.

The cache is a plain WTF::String, so it adds no GC root (the old prototype can still be collected). All additions are guarded by USE(BUN_JSC_ADDITIONS).

Consumed on the Bun side by internalGetConstructorName in util.inspect to match Node's output for null-prototype class instances.

@coderabbitai

coderabbitai Bot commented Jul 19, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 1 minute

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: a0e4e34a-0d35-4331-bc10-52fcffcd5771

📥 Commits

Reviewing files that changed from the base of the PR and between 0bab8a9 and d4aabd1.

📒 Files selected for processing (3)
  • Source/JavaScriptCore/runtime/Structure.cpp
  • Source/JavaScriptCore/runtime/Structure.h
  • Source/JavaScriptCore/runtime/StructureRareData.h

Walkthrough

Changes

The change adds conditional source constructor name storage and lookup to Structure, then propagates the name through prototype and dictionary transitions with exception handling during constructor discovery.

Constructor name tracking

Layer / File(s) Summary
Name storage and discovery
Source/JavaScriptCore/runtime/Structure.h, Source/JavaScriptCore/runtime/StructureRareData.h, Source/JavaScriptCore/runtime/Structure.cpp
StructureRareData stores the optional name, while Structure discovers it from prototype constructors and previous structures under USE(BUN_JSC_ADDITIONS).
Transition propagation
Source/JavaScriptCore/runtime/Structure.cpp
Prototype transitions persist computed names, and dictionary transitions copy names from the source structure.

Suggested reviewers: geoffreygaren

🚥 Pre-merge checks | ✅ 3 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description is narrative but missing the required Bugzilla link, Reviewed by line, and template-formatted commit message. Rewrite it to match the template, including a Bugzilla link, Reviewed by line, explanation, and the changed-file list.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: caching the source constructor name across prototype transitions.
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.

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

@robobun
robobun force-pushed the farm/c73052e6/source-constructor-name branch from 24c318e to abf22f8 Compare July 19, 2026 23:06
Comment on lines +756 to +757
if (protoObject == globalObject->objectPrototype())
return String();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Only objectPrototype() is skipped here, but class X extends Y also reaches changePrototypeTransition — with a JSFunction whose stored prototype is Function.prototype. The lookup finds Function.prototype.constructor (an InternalFunction), so every subclass declaration now allocates a StructureRareData on its ChangePrototype transition just to cache the string "Function". Consider extending the early-out to globalObject->functionPrototype() so class-extends stays rare-data-free, matching the intent behind the Object.prototype skip.

Extended reasoning...

What happens

class X extends Y emits emitDirectSetPrototypeOf(constructor, superclass) (NodesCodegen.cpp:5743), which flows through globalFuncSetPrototypeDirectOrThrowJSObject::setPrototypeDirectStructure::changePrototypeTransition. The freshly-created class constructor is a JSFunction whose structure's storedPrototype() is Function.prototype (all the function structures in JSGlobalObject.cpp are built with m_functionPrototype).

In computeSourceConstructorName:

  • protoObject is Function.prototype, which is not globalObject->objectPrototype(), so the early-out on line 756 does not fire.
  • Function.prototype has an own value property constructor set to FunctionConstructor (JSGlobalObject.cpp: m_functionPrototype->putDirectWithoutTransition(vm, vm.propertyNames->constructor, functionConstructor, ...)).
  • FunctionConstructor inherits from InternalFunction, so jsDynamicCast<InternalFunction*> succeeds and function->name() returns "Function".

Back in changePrototypeTransition, sourceName is non-empty, so transition->ensureRareData(vm)->setSourceConstructorName(...) runs. Previously these ChangePrototype transition structures needed no rare data (setMaxOffset stays in the short range for functions), so this is a new StructureRareData allocation per unique (base-function-structure, superclass) pair — plus a Function.prototype.constructor property lookup on every class-extends evaluation, since computeSourceConstructorName runs before the transition-table cache is checked.

Step-by-step example

class Base {}
class Sub extends Base {}
  1. Codegen for Sub calls setPrototypeDirect(SubConstructor, Base).
  2. changePrototypeTransition calls computeSourceConstructorName(vm, SubConstructor->structure()).
  3. SubConstructor->structure()->storedPrototype() == Function.prototypeObject.prototype → no early return.
  4. getOwnPropertySlot(Function.prototype, "constructor")FunctionConstructor (value slot).
  5. jsDynamicCast<InternalFunction*> succeeds → returns "Function".
  6. New transition structure calls ensureRareData(vm) and stores "Function".

Why it isn't useful

The cached "Function" string is technically correct but never needed: a class constructor is still a JSFunction after reparenting, and util.inspect/calculatedClassName already derive its display from the function's own name / class info. The PR description explicitly special-cases Object.prototype "so Object.create(null) allocates no rare data" — the same reasoning applies to Function.prototype on the class-extends path, which is at least as hot.

Suggested fix

Extend the early-out to also skip Function.prototype:

if (protoObject == globalObject->objectPrototype()
    || protoObject == globalObject->functionPrototype())
    return String();

You might also consider skipping other well-known built-in prototypes (Array.prototype, etc.) for the same reason, though Function.prototype is the one on a hot bytecode-driven path.

Impact

Memory/CPU overhead only — nothing breaks functionally. One extra StructureRareData per subclass declaration and one extra property lookup per class-extends evaluation. Marking as a nit.

Comment on lines +939 to +945
#if USE(BUN_JSC_ADDITIONS)
{
String sourceName = structure->sourceConstructorName();
if (!sourceName.isEmpty())
transition->ensureRareData(vm)->setSourceConstructorName(WTF::move(sourceName));
}
#endif

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 forward-copy here handles toDictionaryTransition, but two other paths also pin() a fresh transition (clearing previousID) without copying the cached name: nonPropertyTransitionSlow's isDictionary() branch and setBrandTransition's isDictionary() branch. So setPrototypeOf(new Foo(), null) → force dictionary → Object.preventExtensions() (or seal/freeze/private-brand) drops the name and util.inspect falls back to Object. Minor edge case — worth adding the same forward-copy in those two branches for completeness.

Extended reasoning...

What the bug is

Structure::pin() (StructureInlines.h:483) calls clearPreviousID(), so any structure that has been pinned can no longer walk back through previousID() to find a cached sourceConstructorName. This PR handles that for changePrototypeTransition (stores the name directly on the new structure's rare data) and for toDictionaryTransition (copies the name forward before pinning). But there are two more transition paths that call pin() on a freshly-created transition without copying the name forward:

  • nonPropertyTransitionSlow, in the if (structure->isDictionary()) branch near the end. This is reached by Object.seal, Object.freeze, Object.preventExtensions, becomePrototypeTransition, and indexing-type changes when the source structure is already a dictionary.
  • setBrandTransition, in the if (structure->isDictionary()) branch.

In both cases the new transition is created via Structure::create(vm, structure, ...) (which sets previousID to the source), then immediately pinned, which clears previousID. The new structure's rare data has never had setSourceConstructorName called on it, so sourceConstructorName() on the resulting structure walks previousID()nullptr immediately and returns an empty string.

Step-by-step example

class Foo {}
const o = new Foo();
Object.setPrototypeOf(o, null);         // changePrototypeTransition: caches "Foo" on rare data of the new structure
for (let i = 0; i < 200; i++) o['k'+i] = i;  // eventually toDictionaryTransition: PR copies "Foo" forward — OK
Object.preventExtensions(o);            // nonPropertyTransitionSlow, source isDictionary → transition->pin()
                                        //   → clearPreviousID(), name NOT copied
util.inspect(o);                        // sourceConstructorName() returns "", falls back to "Object"

Walking through nonPropertyTransitionSlow for the last step:

  1. structure->isDictionary() is true (it became a dictionary in the previous step), so the existing-transition lookup is skipped.
  2. transition = Structure::create(vm, structure, deferred)previousID initially points at the dictionary structure that does carry the cached name.
  3. preventsExtensions(transitionKind) is true → setDidPreventExtensions(true). It is not seal/freeze, so it takes the else branch (takePropertyTableOrCloneIfPinned).
  4. Then if (structure->isDictionary()) { transition->pin(...); } runs → clearPreviousID().
  5. The new structure has no rare-data m_sourceConstructorName and no previousID, so sourceConstructorName() returns String().

The same reasoning applies to setBrandTransition when a private brand is stamped onto an object that is already a dictionary.

Why existing code doesn't prevent it

sourceConstructorName() relies on either (a) the current structure's rare data holding the name, or (b) previousID() being intact so it can walk back to the structure that does. pin() breaks (b), and only changePrototypeTransition / toDictionaryTransition establish (a). The non-dictionary paths in nonPropertyTransitionSlow are fine — pinForCaching does not clear previousID, and the transition-table path preserves it — so this only bites when the source is already a dictionary.

Impact

Cosmetic only: util.inspect on such an object shows [Object: null prototype] { ... } instead of [Foo: null prototype] { ... }. Requires the fairly narrow sequence of setPrototypeOf → dictionary conversion → seal/freeze/preventExtensions/private-brand. No crash, no JS-semantic change. The PR still improves the common case; this is a completeness gap in the caching.

Fix

Add the same forward-copy block used in toDictionaryTransition to the two isDictionary() pin sites (or, more robustly, hoist it into a helper invoked wherever a fresh transition is about to be pin()ed):

#if USE(BUN_JSC_ADDITIONS)
{
    String sourceName = structure->sourceConstructorName();
    if (!sourceName.isEmpty())
        transition->ensureRareData(vm)->setSourceConstructorName(WTF::move(sourceName));
}
#endif

Comment on lines +782 to +784
#if USE(BUN_JSC_ADDITIONS)
String sourceName = computeSourceConstructorName(vm, structure);
#endif

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Nit: computeSourceConstructorName() runs before the shouldChain / m_transitionTable.get() early return, so on repeated Object.setPrototypeOf(new Foo(), null) with same-shaped objects the previousID() walk + VMInquiry constructor lookup happens every time and the result is discarded. Since the transition-table probe doesn't allocate, you could hoist it above both this call and DeferGC and skip the compute on cache hits — minor, but it keeps the fast path as cheap as it was before.

Extended reasoning...

What the issue is

changePrototypeTransition now calls computeSourceConstructorName(vm, structure) unconditionally at the top of the function, before the cached-transition fast path:

#if USE(BUN_JSC_ADDITIONS)
    String sourceName = computeSourceConstructorName(vm, structure);
#endif

    DeferGC deferGC(vm);
    ...
    if (shouldChain) {
        if (Structure* existingTransition = structure->m_transitionTable.get(key, 0, TransitionKind::ChangePrototype)) {
            ...
            return existingTransition;   // sourceName is discarded
        }
    }

When an existing ChangePrototype transition is already in the table, the function returns it immediately and sourceName is thrown away — but the work to compute it has already been done.

Concrete walk-through

Consider a loop like:

class Foo {}
for (let i = 0; i < N; i++) Object.setPrototypeOf(new Foo(), null);
  1. First iteration: new Foo() produces an object with structure S (prototype Foo.prototype). computeSourceConstructorName walks S's previousID() chain (no cached name found), then does a VMInquiry getOwnPropertySlot for "constructor" on Foo.prototype, reads the function name "Foo". No cached transition exists yet, so a new structure T is created, "Foo" is stored on T's rare data, and T is inserted into S's transition table.
  2. Second iteration: Another new Foo() again has structure S. computeSourceConstructorName is called again on S. The cached name lives on T's rare data, not on S or its ancestors, so sourceConstructorName() finds nothing and falls through to the getOwnPropertySlot VMInquiry on Foo.prototype again. Then shouldChain is true, m_transitionTable.get(nullptr, 0, ChangePrototype) returns T, and the function returns early — the freshly computed "Foo" is discarded.
  3. Steps in (2) repeat for every subsequent iteration.

So the transition-table caching that previously made repeated same-shape setPrototypeOf a simple hash-table hit now also pays for a previousID() walk plus a VMInquiry own-property lookup on every call.

Why nothing prevents it

The compute is deliberately placed before DeferGC (per the PR description, since the property lookup could allocate). But the transition-table probe itself (StructureTransitionTable::get, whether single-slot or map) doesn't allocate — it's a pointer compare or a read-only hash lookup. So the ordering constraint against DeferGC doesn't force the compute to precede the cache check.

Impact

This is a minor performance nit, not a correctness issue. setPrototypeOf is already an inherently slow, deoptimizing operation; the extra cost is a short pointer-chain walk plus one VMInquiry own-property lookup (no user JS runs). The Object.prototype → null case (Object.create(null)) already short-circuits before the lookup. It's unlikely to be measurable in real workloads, but it does make the cached-transition path strictly more expensive than it was before this PR.

Suggested fix

Duplicate the fast-path probe ahead of the compute (and ahead of DeferGC), roughly:

JSObject* key = prototype.isNull() ? nullptr : asObject(prototype);
bool shouldChain = !structure->hasPolyProto() && structure->typeInfo().type() != GlobalObjectType && !structure->hasBeenDictionary();
if (shouldChain) {
    if (Structure* existingTransition = structure->m_transitionTable.get(key, 0, TransitionKind::ChangePrototype))
        return existingTransition;
}

#if USE(BUN_JSC_ADDITIONS)
String sourceName = computeSourceConstructorName(vm, structure);
#endif
DeferGC deferGC(vm);
...

(or equivalently, keep a single shouldChain block but move both it and the compute above DeferGC). Either way the redundant lookup on the cache-hit path goes away.

@github-actions

github-actions Bot commented Jul 20, 2026

Copy link
Copy Markdown

Preview Builds

Commit Release Date
d4aabd18 autobuild-preview-pr-312-d4aabd18 2026-07-20 06:37:09 UTC
0bab8a9a autobuild-preview-pr-312-0bab8a9a 2026-07-20 05:45:00 UTC
73c011c1 autobuild-preview-pr-312-73c011c1 2026-07-20 04:13:59 UTC
0bab8a9a autobuild-preview-pr-312-0bab8a9a 2026-07-20 03:24:35 UTC

@robobun
robobun force-pushed the farm/c73052e6/source-constructor-name branch from 0bab8a9 to 73c011c Compare July 20, 2026 03:34
robobun added a commit to oven-sh/bun that referenced this pull request Jul 20, 2026
The first preview build was based on oven-sh/WebKit main which includes
the upstream 2603e9eb41f0 merge (DeferredWorkTimer::TicketData removal)
that bun's main does not have yet. Rebased oven-sh/WebKit#312 onto
639550acdcb2 (bun's current pin) so the preview prebuilt is
ABI-compatible.

jsFunctionGetSourceConstructorName now returns undefined for non
FinalObjectType instances. V8's GetConstructorName() reports the base
type for Error/Array/etc. subclasses after setPrototypeOf(null), which
inspect.js already recovers via Object.prototype.toString; only plain
objects need the Structure cache.

Added a 30s per-test timeout to the 'no assertion failures 2' block,
which runs ~1500 assertions and exceeds the 5s default under the
debug+ASAN build regardless of this change.
@robobun

robobun commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto 639550a (bun's current WEBKIT_VERSION) so the preview prebuilt is ABI-compatible with oven-sh/bun main. The previous preview was based on current main which includes the upstream 2603e9e merge and breaks bun's build until oven-sh/bun#34373 lands.

Consumed in oven-sh/bun#34755.

@robobun
robobun force-pushed the farm/c73052e6/source-constructor-name branch from 73c011c to 0bab8a9 Compare July 20, 2026 05:03
robobun added a commit to oven-sh/bun that referenced this pull request Jul 20, 2026
…nces

Object.setPrototypeOf(new Foo(), null) now inspects as
'[Foo: null prototype] {}' instead of '[Object: null prototype] {}',
matching Node.js.

Node recovers the name via V8's Map::constructor back-reference which
survives prototype transitions. JSC's changePrototypeTransition pins the
new Structure and clears previousID(), so neither the old prototype nor
its constructor is reachable from the object afterwards.

The JSC side (oven-sh/WebKit#312, preview autobuild-preview-pr-312-0bab8a9a)
caches the previous prototype's constructor name on StructureRareData
during changePrototypeTransition and carries it through
toDictionaryTransition. Structure::sourceConstructorName() walks
previousID() to find it. The cache is a plain WTF::String so it adds no
GC root.

On the Bun side, internalGetConstructorName consults a new
jsFunctionGetSourceConstructorName binding before falling back to
'[object X]' parsing. The binding only answers for FinalObjectType
instances; V8 reports the base type for Error/Array/etc. subclasses
after setPrototypeOf(null), which Object.prototype.toString already
covers.

The 'no assertion failures 2' test block (~1500 util.inspect assertions)
already exceeds the 5s default under debug+ASAN on main; gave it a 30s
ceiling so the file is runnable via bun bd.
@robobun
robobun force-pushed the farm/c73052e6/source-constructor-name branch from 0bab8a9 to cd20f60 Compare July 20, 2026 05:49
When setPrototypeOf() clears a Structure's previousID (via pin()), the
link back to the original prototype's constructor is lost. Node's
util.inspect recovers the class name for null-prototype objects via V8's
Map::constructor back-reference, producing '[Foo: null prototype]' for
Object.setPrototypeOf(new Foo(), null).

Cache the name on StructureRareData at the point of transition:
changePrototypeTransition computes it from the previous prototype's own
'constructor' property (VMInquiry, no JS execution) or from a
previously cached value, and toDictionaryTransition carries it forward.
The lookup runs before DeferGC; the cache is a plain WTF::String so it
adds no GC root.

Used by Bun to implement Node-compatible util.inspect output for
null-prototype class instances.
@robobun
robobun force-pushed the farm/c73052e6/source-constructor-name branch from cd20f60 to d4aabd1 Compare July 20, 2026 06:01
robobun added a commit to oven-sh/bun that referenced this pull request Jul 20, 2026
…nces

Object.setPrototypeOf(new Foo(), null) now inspects as
'[Foo: null prototype] {}' instead of '[Object: null prototype] {}',
matching Node.js.

Node recovers the name via V8's Map::constructor back-reference which
survives prototype transitions. JSC's changePrototypeTransition pins the
new Structure and clears previousID(), so neither the old prototype nor
its constructor is reachable from the object afterwards.

The JSC side (oven-sh/WebKit#312, preview autobuild-preview-pr-312-0bab8a9a)
caches the previous prototype's constructor name on StructureRareData
during changePrototypeTransition and carries it through
toDictionaryTransition. Structure::sourceConstructorName() walks
previousID() to find it. The cache is a plain WTF::String so it adds no
GC root.

On the Bun side, internalGetConstructorName consults a new
jsFunctionGetSourceConstructorName binding before falling back to
'[object X]' parsing. The binding only answers for FinalObjectType
instances; V8 reports the base type for Error/Array/etc. subclasses
after setPrototypeOf(null), which Object.prototype.toString already
covers.

The 'no assertion failures 2' test block (~1500 util.inspect assertions)
already exceeds the 5s default under debug+ASAN on main; gave it a 30s
ceiling so the file is runnable via bun bd.
@robobun

robobun commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator Author

Reworked computeSourceConstructorName at d4aabd1: the TopExceptionScope in the earlier revision tripped ExceptionScope::assertNoException() under EXCEPTION_SCOPE_VERIFICATION when setPrototypeOf was called from a context with an active throw scope (seen in bun's test-http2-reset-flood.js on x64-asan). The lookup now skips prototypes whose typeInfo().overridesGetOwnPropertySlot() is set, dispatches through the base JSObject::getOwnPropertySlot (pure butterfly read for non-index names), and uses slot.getPureResult(), so it cannot throw and needs no exception scope.

Comment on lines +936 to +941
#if USE(BUN_JSC_ADDITIONS)
{
String sourceName = structure->sourceConstructorName();
if (!sourceName.isEmpty())
transition->ensureRareData(vm)->setSourceConstructorName(WTF::move(sourceName));
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Nit: sourceConstructorName() here walks the full previousID() chain (~128–512 hops at the dictionary-transition threshold) for every object that becomes a dictionary, even though the vast majority never went through changePrototypeTransition and have no cached name anywhere in their ancestry — the walk always reaches the root and returns empty. A sticky bit in m_bitField propagated through the Structure(VM&, StructureVariant, Structure* previous) copy-ctor (like hasBeenDictionary/mayBePrototype) would let the common case skip the walk in O(1). Very minor — the transition already does O(N) work cloning the property table — so feel free to defer.

Extended reasoning...

What this adds

toDictionaryTransition now calls structure->sourceConstructorName() unconditionally before pinning:

String sourceName = structure->sourceConstructorName();
if (!sourceName.isEmpty())
    transition->ensureRareData(vm)->setSourceConstructorName(WTF::move(sourceName));

sourceConstructorName() (Structure.cpp:726–735) is a plain previousID() walk to the root, checking each structure's rare data for a cached name. Dictionary conversion is triggered by shouldDoCacheableDictionaryTransitionForAdd() exactly when transitionCountEstimate() exceeds s_maxTransitionLength (128) or s_maxTransitionLengthForNonEvalPutById (512), so the chain being walked is on the order of hundreds of Structure* dereferences — each of which loads m_previousOrRareData and does an isRareData type check on the pointed-to cell.

For the overwhelmingly common case — an object that grew many properties but never had setPrototypeOf called on it — no ancestor carries a cached name, so the walk always reaches the root and returns String(). The ensureRareData/setSourceConstructorName that follows is correctly skipped, but the pointer-chase itself is pure overhead.

Step-by-step example

const o = {};
for (let i = 0; i < 600; i++) o['k' + i] = i;

At roughly the 512th putById, addNewPropertyTransition sees transitionCountEstimate() > 512 and calls toCacheableDictionaryTransition. The source structure's previousID() chain is ~512 links of plain PropertyAddition transitions rooted at the empty-object structure. sourceConstructorName() dereferences all ~512 m_previousOrRareData pointers (scattered Structure cells, each with a header load for isRareData), finds nothing, and returns empty. Before this PR, the same transition did no chain walk in the common case — addNewPropertyTransition hands the property table forward via takePropertyTableOrCloneIfPinned, so copyPropertyTableForPinning here just clones the existing table without touching previousID().

Why nothing prevents it

The forward-copy is guarded only by !sourceName.isEmpty() after the walk. There's no cheap flag on Structure indicating "some ancestor holds a cached source-constructor name" that would let the common case bail out without walking.

Impact / why this is only a nit

toDictionaryTransition is one-time per object (at most twice: cacheable → uncacheable) and already does O(N) work: copyPropertyTableForPinning clones an ~N-entry PropertyTable, and if GC has cleared m_propertyTableUnsafe it falls into materializePropertyTable which itself walks previousID(). So the added cost is a constant-factor increase on an already-O(N) one-shot path, not a new asymptotic behavior — likely unmeasurable in practice. The distinction is only that the property-table clone is necessary contiguous-memory work, whereas this is an unnecessary scattered-pointer chase for the >99% of dictionary conversions that never involved changePrototypeTransition.

Suggested fix

Add a sticky bit alongside the existing ones in m_bitField — e.g. DEFINE_BITFIELD(bool, hasCachedSourceConstructorName, HasCachedSourceConstructorName, 1, 31) — set it in changePrototypeTransition when setSourceConstructorName runs, and copy it forward in the Structure(VM&, StructureVariant, Structure* previous) constructor the same way hasBeenDictionary/mayBePrototype are copied. Then both this call site and the one at the top of computeSourceConstructorName can guard the walk with a single-bit test, making the common case O(1).

(Distinct from the other comment at this location, which is about the name being lost on other pin() paths; this is about the walk being unconditional on a path that almost never has anything to find.)

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