JSPromise: inline reaction storage for single-handler .then(f)
JSTests/stress/promise-inline-child-reaction.js
JSPromise previously subclassed JSInternalFieldObjectImpl, storing state and reactions as JSValues in a fixed internal-fields array shared with WeakRef, FinalizationRegistry, and similar objects. Every .then() call allocated a separate JSPromiseReaction cell holding the callback and child-promise pointer, even when only one handler ever fires. This commit reworks JSPromise into a plain JSObject with a CompactPointerTuple member packing the first reaction's handler (or microtask context) alongside flag bits in one machine word; a second .then() triggers spillInlineReaction which promotes the inline cell to a JSSlimPromiseReaction linked list. New DFG nodes NewPromise and PhantomNewPromise mirror how JSFunction participates in allocation sinking, with PhantomNewPromise reconstructing the promise on OSR exit.
Before — every .then():
JSPromise [JSInternalFieldObjectImpl]
internalFields[1]: reactions ─► JSPromiseReaction (heap cell)
After — first .then(f) [no allocation]:
JSPromise [JSObject]
packed: CompactPointerTuple
cell ─► fulfillHandler (JSFunction*)
bits: InlineHandler flag
After — second .then() spills:
JSPromise [JSObject]
packed: CompactPointerTuple
cell ─► JSSlimPromiseReaction ─► JSSlimPromiseReaction
Significance
Eliminating the per-.then() JSPromiseReaction allocation removes heap pressure across every Promise-heavy workload and rewires the entire JSPromise object model in the process. Switching from JSInternalFieldObjectImpl to plain JSObject means the GC is no longer auto-visiting an internal-fields array; the CompactPointerTuple's cell pointer must now be visited by an explicit visitChildrenImpl. New IR nodes (NewPromise, PhantomNewPromise) participate in scalar replacement and OSR-exit materialization.
Audit directions
-
CompactPointerTuple type confusion in the inline reaction slot. The packed field stores a raw
JSCell*alongside bit flags in one word. Code that reads the cell without first checkinginlineReactionKind()could interpret a bareJSFunctionhandler as aJSSlimPromiseReactionhead, or vice versa. The flag-masking logic inpayloadCell()andsetPackedCell()is the critical gate; verify every consumer ofpackedCell()consults the kind before dispatching. -
Spill-path GC interaction.
spillInlineReaction()must atomically promote the inline cell to a heap-allocated list node. If a GC fires between reading the old inline cell and writing the new list head, the inline cell could be collected before being rooted in the newJSSlimPromiseReaction. Cross-referencepromise-packed-layout-gc-stress.jswhich targets exactly this window. -
visitChildrenImplcoverage in transient states. Moving fromJSInternalFieldObjectImpl(GC visits internal fields automatically) to explicitvisitChildrenImpl()means any transient state betweensetPackedCell()calls where the inline handler or child promise is reachable but not yet visited is a UAF surface. -
PhantomNewPromiseOSR-exit materialization. The mapping between phantom fields and the realJSPromiselayout (flags word, packedCompactPointerTuple) must be exact. A field-offset mismatch during reconstruction produces a liveJSPromisewith corrupt flags or a dangling cell pointer, exploitable from JS once the promise is settled. -
settleInlineInternalMicrotaskvs.settleInlineHandlerdispatch. Two settler functions share the sameCompactPointerTuplestorage but differ in semantics. Incorrect flag masking ininlineReactionKind()could dispatch to the wrong settler, firing the wrong callback or double-settling a promise.