← All reports

[JSC] Cache `isDefinitelyNonThenable` result on Structure

Component: JSC | cdf77b6

ECMAScript 스펙에서는 Promise.resolveawait의 desugaring 과정에서 thenable을 식별하도록 요구합니다. Thenable이란 호출 가능한 .then을 가진 객체를 뜻하는데, thenable은 감싸지 않고 그대로 흡수되어야 하기 때문입니다. JSC는 이를 isDefinitelyNonThenable()로 구현하며, 매번 객체의 prototype chain을 순회하는 방식을 사용해왔습니다. JSC의 Structure는 V8의 Map에 해당하는 hidden class로, shape와 property layout, prototype pointer를 인코딩합니다. promiseThenWatchpointSetFunction.prototype.then이 변경될 때 발동하는 기존 invalidation 메커니즘입니다.

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; }

이 결과는 각 Structure마다 2비트 크기의 lazily-computed 상태로 캐시됩니다. NonThenable 상태는 realm의 promiseThenWatchpointSet이 유효한 동안에만 신뢰되는데, 이번 변경으로 Object.prototypethen이 없는 상태까지 이 watchpoint의 감시 범위에 포함되었습니다. 바로 이 확장 덕분에 긍정 결과를 캐시하는 동작이 안전해집니다. [self, Object.prototype]이나 [self] (null proto)보다 깊은 chain은 Uncacheable로 표시되는데, 그보다 깊은 custom chain은 전역적으로 감시할 수 없기 때문입니다. Dictionary structure는 아예 캐싱 대상에서 제외됩니다.

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)

이제 일반 객체 리터럴에 대한 모든 await exprPromise.resolve(obj)가 prototype chain 순회를 완전히 건너뛰게 되며, promise/await microbenchmark 기준 3~8%의 성능 향상을 보입니다. 다만 correctness 측면에서는 "thenable이 아니다"라는 긍정 결과가 매번 다시 계산되는 대신 캐시된다는 점이 대가로 따릅니다. 즉 이 결과의 유효성은 이제 매번의 순회가 아니라 watchpoint의 커버리지에 의존하게 됩니다.

좁게 보면, 캐시된 NonThenable 결과는 nonThenableStatus()promiseThenWatchpointSet().isStillValid()라는 서로 분리된 두 개의 load로 보호되며, 그 사이에는 아무런 추가 검증이 없습니다.

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

Watchpoint는 Object.prototype.then 대입 시점에 동기적으로 발동합니다. 다만 다른 agent나 SAB context에서 발생하는 동시적인 invalidation이 이 cache read와 race할 수 있는지는 점검이 필요합니다. 리뷰 과정에서 눈여겨봐야 할 패턴은, 캐시된 값을 읽는 지점과 별도로 로드되는 validity check가 짝을 이루는 형태입니다.

넓게 보면, mutated structure 위에서 캐시된 비트가 stale한 상태로 남을 수 있는 상태 전이가 문제입니다. 어떤 structure가 NonThenable을 캐시한 이후, Object.assign()이나 대량 property 추가가 dictionary inflation을 유발하는 경우를 가정할 수 있습니다. Dictionary structure는 동일한 Structure* 위에서 in-place로 변경되기 때문에, then이 추가된 시점과 watchpoint가 발동하는 시점 사이에 stale한 NonThenable 비트가 여전히 읽힐 수 있는 window가 존재합니다. 이 commit은 dictionary의 경우 hasSpecialProperties가 in-place로 갱신된다고 설명하지만, 이 갱신이 cache 상태 비트 갱신과 어떤 순서로 이루어지는지는 별도로 확인해야 합니다. Null-proto 객체에 대해서도 같은 질문이 적용됩니다. Null-proto chain은 캐싱 대상에 포함되며, then 추가는 원칙적으로 새로운 Structure로의 전이를 유발해야 합니다. 다만 Object.defineProperty(obj, 'then', {get: ...})가 항상 in-place 변경이 아닌 새 Structure 생성으로 이어지는지는 확인이 필요합니다. 이런 질문은 Structure에 있는 모든 lazily-computed 캐시 비트로 일반화할 수 있으므로, 각각을 나열한 뒤 dictionary inflation 경로와 in-place mutation 경로에 대해 동일하게 점검해볼 필요가 있습니다.

가장 넓게 보면, cross-realm trust 문제가 남아 있습니다. Realm 검증은 structure->globalObject() == globalObject 비교로 이루어지므로, realm A에서 생성된 structure가 realm B의 promise 파이프라인에서 resolve될 경우 이 검증에서 반드시 실패해야 합니다. createGlobalObject()로 생성된 객체나 cross-realm eval로 생성된 객체의 structure가 항상 호출 측 realm이 아니라 원래 생성된 realm의 global을 가리키는지 확인이 필요합니다. 이 지점에서 오분류가 발생하면 thenable이 일반 값으로 취급될 수 있습니다. Promise로 보호되는 객체가 non-thenable로 위장되어 통과할 수 있다면 이는 capability bypass에 해당하므로, 캐시된 최적화를 보호하는 다른 모든 realm-identity check에 대해서도 같은 질문을 점검해볼 필요가 있습니다.