[JSC] Do not allocate promise reaction when it is initial `then` calls and one handler attachment
Component: JSC | 3f9955f
JSPromise previously subclassed JSInternalFieldObjectImpl, a base class that stores object state as JSValues in a fixed internal-fields array — the same model used by WeakRef and FinalizationRegistry. Under that model, every .then() call allocated a separate JSPromiseReaction heap cell holding the callbacks and the child-promise pointer, even when only one handler would ever fire. A CompactPointerTuple is JSC's idiom for packing a cell pointer plus a few bit flags into a single machine word.
JSTests/stress/promise-inline-child-reaction.js
This commit restructures JSPromise from JSInternalFieldObjectImpl to a plain JSObject and stores the first reaction inline in a CompactPointerTuple field on the promise itself. Heap allocation is deferred until a second .then() arrives, at which point spillInlineReaction promotes the inline cell into a JSSlimPromiseReaction linked list. Two new DFG nodes, NewPromise and PhantomNewPromise, mirror how JSFunction handles allocation sinking, letting the compiler eliminate promise allocations in hot paths with PhantomNewPromise participating in OSR-exit materialization.
Before (every .then()):
JSPromise [JSInternalFieldObjectImpl]
internalFields[0]: status/value (JSValue)
internalFields[1]: reactions ──► JSPromiseReaction (heap cell)
fulfillHandler
rejectHandler
nextReaction → null
After — first .then(f) [no heap allocation]:
JSPromise [JSObject]
flags: status | InlineHandler
packed: CompactPointerTuple
cell ──► fulfillHandler (JSFunction*)
bits: InlineHandler flag
After — second .then() triggers spillInlineReaction:
JSPromise [JSObject]
flags: status | Spilled
packed: CompactPointerTuple
cell ──► JSSlimPromiseReaction ──► JSSlimPromiseReaction ──► null
Significance
Every .then(f) call in the dominant single-handler case now avoids a heap allocation entirely, cutting GC pressure across Promise-heavy workloads. The cost is surface area: a new inline reaction state machine, spill logic, an object-layout change, and DFG node extensions all land at once in a class that JS can drive directly.
Audit directions
The narrow rung is the packed field itself: the CompactPointerTuple stores a raw JSCell* alongside bit flags in one word, and code that reads the cell without first consulting inlineReactionKind() will interpret a bare JSFunction handler as a JSSlimPromiseReaction head or vice versa. The flag-masking logic in payloadCell() and setPackedCell() is the gate; the match tell is any read of the packed word that is not immediately preceded by a kind check. The same dispatch question applies to settleInlineInternalMicrotask versus settleInlineHandler, two settlers sharing one storage word with different semantics — incorrect masking fires the wrong callback or double-settles.
One rung wider, the GC surface. Moving off JSInternalFieldObjectImpl means the internal-fields array is no longer visited automatically; the packed cell pointer must be visited by an explicit visitChildrenImpl(). Any transient state between setPackedCell() calls where inline-reaction state is reachable but unvisited lets the handler or child promise be collected while live. The spill path has the same shape in the temporal dimension: spillInlineReaction() must promote the inline cell to a heap list node such that a GC firing between reading the old cell and rooting it in the new JSSlimPromiseReaction cannot collect it. The added GC stress test targets that window directly. Audit the same pattern anywhere JSC replaces an internal-fields object with a hand-rolled packed field — the automatic-visit-to-manual-visit transition is the recurring hazard, and the tell is a class dropping JSInternalFieldObjectImpl from its bases in the same diff that adds a raw cell member.
Widest: PhantomNewPromise participates in scalar replacement and OSR-exit object reconstruction, so the mapping between phantom fields and the real JSPromise layout (flags word, packed tuple) must be exact. Field-offset mismatches during materialization produce a live promise with corrupt flags or a dangling cell pointer, reachable from JS once the promise settles. Every phantom node in DFG that materializes a hand-laid-out object carries this obligation — sweep PhantomNewFunction, PhantomNewObject, and the other materialization cases for offset agreement whenever their concrete type's layout changes.