← All reports

[JSC] Inline allocation for `Promise.resolve()` of a non-thenable object

Component: JSC | b1a6241

DFGConstantFoldingPhase uses condition-set watchpoints to prove properties at compile time — here, that an object's prototype chain has no then property, making it a non-thenable. The clobberize pass determines which heap locations each IR node can read and write, feeding alias analysis and store elimination: clobberTop() is maximally conservative (any heap location may change), while HeapObjectCount signals only an allocation side effect.

JSTests/stress/dfg-promise-resolve-fulfilled-object.js

+// Branch 3 (new): object arg, watchpoint-proven non-thenable → NewResolvedPromise(flag=true)
+function resolveObject(o) { return Promise.resolve(o); }
+noInline(resolveObject);
+
+// Watchpoint-invalidation scenario — the critical deopt boundary:
+function Proto() { }
+function resolveProto(o) { return Promise.resolve(o); }
+noInline(resolveProto);
+// Later: Proto.prototype.then = async function() { ... }
+// Verifies compiled code correctly deoptimizes when watchpoint fires
+
+// Thenable: tryEnsureAbsence fails, no fold, flag never set
+function resolveThenable(o) { return Promise.resolve(o); }
+
+// Non-default constructor: species path, flag never set
+class MyPromise extends Promise { }
+function resolveSubclass(o) { return Promise.resolve.call(MyPromise, o); }

This fixes a DFG/FTL regression introduced by 313220@main. An isResolvedValueKnownNonThenable flag is added to the NewResolvedPromise node so that constant-folding-proven object arguments produce inline promise allocation instead of falling back to an operationNewResolvedPromise C call with clobberTop(). Throughput recovers ~1.53x.

Before (313220@main regression):
  ConstantFoldingPhase
    └─ obj proven non-thenable via watchpoint
         └─► NewResolvedPromise(obj)
               └─► object arg detected
                     └─► operationNewResolvedPromise()   ← C call
                           clobberTop()                  ← every heap loc may change

After (this commit):
  ConstantFoldingPhase
    └─ obj proven non-thenable via watchpoint
         └─► NewResolvedPromise(obj, isKnownNonThenable=true)
               └─► flag set
                     └─► inline allocate fulfilled promise  ← no C call
                           HeapObjectCount                  ← only alloc side effect

A clobbering C-call path is promoted to pure inline allocation, and the node's modeled side effects narrow from clobberTop() to HeapObjectCount. Alias analysis, store elimination, LICM, and GVN all now reason about this node under the narrower model.

Narrow: the flag is baked into the compiled node from a watchpoint-guarded assumption. If prototype-chain mutation fires the watchpoint after ConstantFoldingPhase sets the flag but before deoptimization fully takes effect, the inline allocation path runs without verifying thenable-ness — a classic deopt-boundary race. The stress test's resolveProto case sits exactly on that surface, which is the same surface that has historically produced exploitable JIT bugs in V8 and SpiderMonkey.

Wider: the clobberize loosening is the reusable audit target. Any commit that narrows a node's effect model from clobberTop() to something specific is asserting that the node's every reachable path does only what the narrow model describes. If the inline allocation path can do more than allocate — run a finalizer, touch a weak reference, trigger a GC callback — the modeling is wrong and every downstream pass built on it inherits the error. Sweep recent clobberize.h diffs for effect-model narrowings and check each against the full set of paths its node can take, including slow-path transitions. The tell is a diff that removes clobberTop() from a case without adding an explicit escape or slow-path guard alongside it. Third, the abstract-interpreter change propagates a different inferred type downstream; incorrect propagation there makes speculations elsewhere in the graph unsound.