← All reports

[JSC] Cache `isDefinitelyNonThenable` result on Structure

Component: JSC | cdf77b6

The ECMAScript spec requires Promise.resolve and the await desugaring to detect thenables — objects with a callable .then — because a thenable must be assimilated rather than wrapped. JSC implements this via isDefinitelyNonThenable(), walking the object's prototype chain each time. A Structure in JSC is the hidden class (V8's Map): it encodes shape, property layout, and prototype pointer. The promiseThenWatchpointSet is an existing invalidation mechanism firing when Function.prototype.then is mutated.

Source/JavaScriptCore/runtime/JSPromise.cpp

-bool isDefinitelyNonThenable(JSGlobalObject* globalObject, JSObject* object)
-{
- VM& vm = globalObject->vm();
- auto scope = DECLARE_THROW_SCOPE(vm);
- for (JSObject* current = object; current; ) {
- if (current->hasSpecialProperties()) return false;
- JSValue proto = current->getPrototypeDirect();
- if (!proto.isObject()) return true;
- current = asObject(proto);
- }
- return true;
-}
+bool isDefinitelyNonThenable(JSGlobalObject* globalObject, JSObject* object)
+{
+ Structure* structure = object->structure();
+ switch (structure->nonThenableStatus()) {
+ case NonThenableStatus::NonThenable:
+ if (structure->globalObject() == globalObject
+ && globalObject->promiseThenWatchpointSet().isStillValid())
+ return true;
+ break;
+ case NonThenableStatus::MaybeThenable:
+ return false;
+ case NonThenableStatus::Uncacheable:
+ break; // fall through to walk, skip cache write
+ case NonThenableStatus::Unknown:
+ break; // fall through to walk + cache
+ }
+ // ... prototype chain walk, then update structure->setNonThenableStatus(...)
+}

Source/JavaScriptCore/runtime/Structure.h

+enum class NonThenableStatus : uint8_t { Unknown, NonThenable, MaybeThenable, Uncacheable };
+
+NonThenableStatus nonThenableStatus() const { return m_nonThenableStatus; }
+void setNonThenableStatus(NonThenableStatus status) { m_nonThenableStatus = status; }

The result is cached on each Structure as a 2-bit lazily-computed state. The NonThenable state is trusted only while the realm's promiseThenWatchpointSet is intact — now extended to also cover then-absence on Object.prototype, which is what makes caching a positive result safe. Chains deeper than [self, Object.prototype] or [self] (null proto) are marked Uncacheable because deeper custom chains cannot be globally watched; dictionary structures are excluded entirely.

2-bit NonThenableStatus state machine on Structure:

  Unknown
    │
    ├─[walk: short chain, watchpoint intact]──► NonThenable  ──[watchpoint fires]──► Unknown
    │
    ├─[walk: `then` property found]──────────► MaybeThenable  (always safe to cache)
    │
    └─[walk: deep chain]─────────────────────► Uncacheable   (skip cache entirely)

Every await expr and Promise.resolve(obj) on a plain object literal now skips the prototype-chain walk entirely, worth 3-8% on promise/await microbenchmarks. The correctness cost is that a positive "not a thenable" answer is now cached rather than recomputed, so its validity depends on watchpoint coverage rather than on a fresh walk.

Narrow: the cached NonThenable result is guarded by two separate loads — nonThenableStatus() and promiseThenWatchpointSet().isStillValid() — with nothing between them.

case NonThenableStatus::NonThenable:
    if (structure->globalObject() == globalObject
        && globalObject->promiseThenWatchpointSet().isStillValid())
        return true;
    break; // stale or wrong realm — fall through to walk

The watchpoint fires synchronously on Object.prototype.then assignment, but investigate whether a concurrent invalidation from another agent or SAB context can race the cache read. The tell in review is any cached-truth read paired with a separately-loaded validity check.

Wider: state transitions that can leave the cached bit stale on a mutated structure. A structure caches NonThenable, then Object.assign() or bulk property addition triggers dictionary inflation — dictionary structures are mutated in place under the same Structure*, so the window between adding then and the watchpoint firing is where a stale NonThenable bit could still be readable. The commit claims hasSpecialProperties is updated in place for dictionaries; the timing of that update relative to the cache state bit needs verification. Same question for null-proto objects: null-proto chains qualify for caching, and adding then should transition to a new Structure, but confirm Object.defineProperty(obj, 'then', {get: ...}) always produces a new Structure rather than mutating in place. Generalize this to every lazily-computed cached bit on Structure — enumerate them and check each against the dictionary-inflation and in-place-mutation paths.

Widest: cross-realm trust. The realm check compares structure->globalObject() == globalObject, so a structure created in realm A resolving in realm B's promise pipeline must fail it. Confirm that structures of objects created by createGlobalObject() or cross-realm eval always carry the originating realm's global rather than the calling realm's. A misclassification here turns a thenable into a plain value, which is a capability bypass if promise-guarded objects can be smuggled through as non-thenables — audit every other realm-identity check guarding a cached optimization for the same question.