[1] JSC MicrotaskCallCache retains detached CodeBlock entry points
The detach that guarantees no compiled code survives missed one cache.
High. A forced code-detach that exists precisely to guarantee "no compiled code survives" left one VM-lifetime cache still holding a JIT entry point keyed on a still-live executable. Escalation depends on the detach landing between microtask drains and the JIT memory being reclaimed before the next GC reconciliation.
JavaScript engines cache the address of already-compiled machine code so a repeated call can jump straight to it instead of re-resolving the callee. In JSC, a ScriptExecutable is the persistent representation of a piece of JS source, and the CodeBlock installed on it is the compiled artifact for one specialization; a cache that memoises the executable-to-entry-point pairing is only correct while that pairing is the one the VM actually has installed. The engine has a forced-detach operation that severs every such pairing across the whole heap, and the invariant it is meant to establish is that no previously compiled code remains reachable afterward.
The angle: script that arranges a forced code detach to land between microtask drains could have an async-generator resumption continue executing through a cached entry point the engine already declared dead, and, once that JIT memory is reclaimed, call through a dangling executable-memory pointer.
Commit message
When
Heap::deleteAllCodeBlocksis called,CodeBlockis forcefully detached from theScriptExecutable. As a result, many ofCallLinkInfocache's Executable -> CodeBlock pair gets broken. When usingCachedCall/MicrotaskCallon the stack, this is fine since we use them only after enteringVMEntryScope, andHeap::deleteAllCodeBlockscannot be called withVMEntryScope. ButMicrotaskCallCachein VM is not scoped withVMEntryScope, thus its pairing is important and needs to be cleared whenHeap::deleteAllCodeBlocksis called.We are doing the same thing for Wasm's DataIC below, and we need to do the same for VM's
MicrotaskCallCache. This patch clears that cache inHeap::deleteAllCodeBlocks.
Source/JavaScriptCore/heap/Heap.cpp
Source/JavaScriptCore/interpreter/MicrotaskCall.cpp
Source/JavaScriptCore/runtime/VM.cpp
JSTests/stress/microtask-call-cache-delete-all-code.js
Patch Details
MicrotaskCall::clear() is factored out of reconcileWeakReferencesAtGCEnd(): it removes the entry from its CodeBlock incoming-call list when linked and nulls m_addressForCall, m_codeBlock, m_functionExecutable and m_numParameters. MicrotaskCallCache::clear() in MicrotaskCall.h loops the 8-entry m_entries array calling the new per-entry clear(). A new VM::clearMicrotaskCallCaches() forwards to m_syncResumeCallCache->clear(). Heap::deleteAllCodeBlocks() calls it immediately after the loop that clears code on every clearable ScriptExecutable, just ahead of the existing WebAssembly JS-call-IC clearing block.
Two collateral edits ride along: MicrotaskQueue::drainImpl gains a comment documenting why the stack-local MicrotaskCallCache is sound, and CodeCache::clear() now calls write() before m_sourceCode.clear() — a bytecode-cache flush unrelated to the memory-safety fix. The regression test drives 3000 async-generator resumptions through a for await loop and then calls $vm.deleteAllCodeWhenIdle() so the detach lands between microtask drains.
Caching a derived pointer under a key that stays valid, without hooking the invalidation event that severed the derivation.
Background
Where this lives.
JSC compiles JavaScript through several tiers and caches call targets at call sites so a repeated call skips re-resolution. ScriptExecutable is the persistent representation of a piece of JS source — a function body, program, or module. CodeBlock is the compiled, linked bytecode/JIT artifact installed on that executable for a given specialization kind.
Forced code detach.
ScriptExecutable::clearCode() detaches the pair: it clears m_codeBlockForCall/m_codeBlockForConstruct and nulls the cached m_jitCodeForCall pointers. Heap::deleteAllCodeBlocks(DeleteAllCodeEffort) performs that detach across every clearable executable in the heap; its callers are memory pressure, debugger attach, and option changes. $vm.deleteAllCodeWhenIdle() is the test shell hook that schedules it for the next idle point.
Scope and stack liveness.
VMEntryScope marks the window during which the VM is executing JS on this thread. Conservative stack scanning means the GC treats machine-stack words as potential object references, so a raw pointer held in a stack frame keeps its target marked — which is why a cache that only ever lives on the stack can hold untraced pointers safely.
Call-site caches.
CallLinkInfoBase is the shared base for call-site caches (CallLinkInfo, CachedCall, PolymorphicCallNode, MicrotaskCall); instances live on a CodeBlock's incoming-call SentinelLinkedList and receive unlinkOrUpgradeImpl(VM&, oldCodeBlock, newCodeBlock) when that CodeBlock is replaced or torn down. MicrotaskCall caches, for one callee executable, the resolved CodeBlock*, its numParameters, and the raw JIT entry point m_addressForCall; tryCallWithArguments uses those to jump directly via vmEntryToJavaScriptWithNArguments when the argument count fits the callee's parameter count. MicrotaskCallCache is an 8-entry array of such records with find() matching on the callee's ExecutableBase*.
reconcileWeakReferencesAtGCEnd().
The weak-reference sweep hook: at GC end it drops entries whose executable or CodeBlock was not marked. It is a liveness hook, not an invalidation hook.
Two homes for the same cache.
Async generators and for await loops resume by scheduling microtasks. VM::m_syncResumeCallCache is the VM-lifetime cache used for those synchronous resumptions (its role is indicated by the member name and the regression test's shape rather than by a call site in the supplied context), in contrast to the per-drain cache created as a stack local in MicrotaskQueue::drainImpl.
Analysis
The bug is a stale cached code pointer that becomes a use-after-free once the detached CodeBlock and its JITCode are reclaimed. Two invalidation mechanisms each looked complete on its own, and the VM-owned cache sat outside both.
Reaches cache entries? deleteAllCodeBlocks GC-end reconcile
──────────────────────── ─────────────────── ────────────────
walks clearable executables yes no
walks incoming-call list no no
checks mark bits no yes
VM::m_syncResumeCallCache: not on a CodeBlock list, executable still
marked ──► survives BOTH
MicrotaskCallCache::find() keys only on the callee's executable (entry.isInitializedFor(executable)), and MicrotaskCall::tryCallWithArguments takes the fast path whenever m_addressForCall is non-null, jumping straight to that entry point and passing m_codeBlock to vmEntryToJavaScriptWithNArguments. The design depends on the executable-to-CodeBlock pairing recorded at link time still being the VM's installed pairing at call time. Heap::deleteAllCodeBlocks() breaks exactly that: it walks the clearable-code sets and calls ScriptExecutable::clearCode() on each executable, nulling the JIT code pointers and clearing the CodeBlock fields — the executable is detached, but the still-live executable continues to satisfy the cache's callee check. (The Heap.cpp excerpt is truncated before that loop body; the loop's shape is consistent with the surrounding code and with the comment the patch adds, and the detach itself is what the commit message describes.)
The stack-local twin in MicrotaskQueue::drainImpl is sound for two reasons the patch now spells out in a comment: conservative stack scanning keeps the untraced CodeBlock alive, and Heap::deleteAllCodeBlocks cannot run while a VMEntryScope is active. The VM-owned copy has neither property. It survives across drains, across VM entry and exit, and its only invalidation hook was reconcileWeakReferencesAtGCEnd(), which fires at GC end and only clears entries whose executable or CodeBlock went unmarked. A forced detach is not a GC-liveness event — the executable is still perfectly reachable — so between deleteAllCodeBlocks and the next reconciliation the cache would still route an async-generator or await resumption into a CodeBlock the VM has declared dead, through a code pointer derived from a JITCode the executable no longer references.
The regression test constructs precisely that window: 3000 resumptions are still pending when the script returns, and $vm.deleteAllCodeWhenIdle() schedules the detach to land between drains (the deferred-to-idle behavior is stated by the test's own comment rather than by any supplied implementation), so the resumptions that follow must not reuse the entry points cached for them beforehand.
Note also the asymmetry the fix exposes on the incoming-call side: unlinkOrUpgradeImpl(vm, old, nullptr) only nulls m_addressForCall, leaving the executable and CodeBlock fields, which is fine because the null entry forces a relink. Forced detach reaches entries through neither the incoming-call list nor GC marking. (That the detach path does not invoke unlinkOrUpgradeIncomingCalls for the affected CodeBlocks is a necessary premise here; the relevant teardown code is not among the supplied excerpts.)
Discovery reads as self-audit rather than fuzzing. The commit message points at the WebAssembly DataIC clearing already present in Heap::deleteAllCodeBlocks — "We are doing the same thing for Wasm's DataIC below" — i.e. someone enumerated the consumers that must be notified on forced code detach and found the newly added VM-level cache missing from that list. The MicrotaskCall/MicrotaskCallCache files carry 2026 copyrights, so this is a recent addition audited by its author; the regression test's hand-built shape supports that reading, though a stress run with aggressive deleteAllCode options could have surfaced the same crash.
This vulnerability weakens memory-type safety and code integrity inside the WebContent process. Heap::deleteAllCodeBlocks exists precisely to guarantee that no previously compiled code remains reachable after a forced detach, and every caller — memory pressure, debugger attach, option changes — relies on that guarantee; before the fix the VM-owned microtask call cache violated it by holding an executable-keyed entry point that outlived the detach. An attacker who arranged the detach to land between microtask drains could have JavaScript continue executing through a code pointer the engine considers dead, and if the detached CodeBlock and its JIT memory were reclaimed before the cache entry was reconciled, the resumption would call through a dangling executable-memory pointer — the kind of primitive that, under favourable JIT-memory reuse, could escalate toward control-flow hijack in the renderer.
Insight
The deeper distinction is between caches whose safety comes from scope and caches whose safety must come from invalidation. CachedCall and the MicrotaskCallCache stack local in drainImpl are safe because conservative stack scanning marks their untraced fields and detach cannot happen inside a VMEntryScope; the moment the same type is hoisted into a VM-lifetime UniqueRef member, both properties silently evaporate while the code compiles unchanged. Heap::deleteAllCodeBlocks has become a broadcast point that every code-pointer cache must subscribe to, and subscription is manual — nothing in the type system forces a new cache to register. The comment added to drainImpl is effectively a warning label for the next person who considers promoting a stack cache to a member.
Audit directions
-
A cache that keys on a long-lived identity but stores a pointer derived from it. The derivation can be severed by an event the cache does not observe. Audit every JSC cache holding a raw
CodeBlock*orCodePtr<JSEntryPtrTag>whose owner outlives a VM entry scope and confirm it is invalidated fromHeap::deleteAllCodeBlocksanddeleteAllUnlinkedCodeBlocks, not only fromreconcileWeakReferencesAtGCEnd— start by enumeratingCallLinkInfoBase::CallSiteTypesubclasses (CallLinkInfo,PolymorphicCallNode,DirectCall,CachedCall,MicrotaskCall) and everyVM/JSGlobalObjectmember of those types. Match tell (narrow): a member declared onVMor a global object that has areconcileWeakReferencesAtGCEndhook but noclear()call reachable fromHeap::deleteAllCodeBlocks. Wider: the same shape appears with any derived-artifact cache — megamorphic and property inline caches,MegamorphicCache,HasOwnPropertyCache,StringSplitCache, watchpoint-backed constant folds — where the key survives an invalidation event that only invalidates the value; the tell is an invalidation routine that iterates one container while other containers hold pointers derived from its elements. Widest: this is the general "invalidation broadcast with unregistered subscribers" class — check any JIT or query planner with multiple independent caches keyed on schema/type identity (V8's feedback vectors and deopt, SpiderMonkey's ICs anddiscardJitCode, database plan caches after DDL); the invariant to carry is that every cache memoising a derivation must be reachable from every event that can sever it, and the enumeration of such events must be written down somewhere a new cache author will read. -
Safety that rests on "this only ever lives on the stack." Conservative-stack-scanning liveness plus a scope-based exclusion silently break when the type is hoisted to a heap-allocated owner. Grep JSC for types marked
WTF_FORBID_HEAP_ALLOCATION(whichMicrotaskCallcarries) and check whether any of them are embedded, directly or as an array member, in something owned byVM,JSGlobalObject, or aUniqueRef/Reffield —MicrotaskCallCachereachingVM::m_syncResumeCallCacheviastd::array<MicrotaskCall, cacheSize>is exactly how this one slipped through the annotation. Match tell (narrow): a stack-only-annotated type appearing as a member or array element of a heap-allocated aggregate. Wider: the same class covers any structure whose untraced raw fields are kept alive only by conservative scanning, and any invariant of the form "X cannot happen while we are inside scope Y" where a copy of the object escapes scope Y — look for comments asserting scope exclusivity as the correctness argument. Widest: this maps onto every GC-integrated runtime that distinguishes stack roots from heap roots — SpiderMonkeyRooted<T>versusHeap<T>, V8Handle/HandleScopeversusGlobal, Go's stack-scanned locals versus heap objects needing write barriers; promoting a stack-rooted type to heap storage requires converting every untraced field into a traced or explicitly invalidated one, and the compiler will not tell you. -
Incomplete invalidation fan-out. Several teardown entry points were each individually audited but never cross-checked against the full set of consumers. Trace, for each of
Heap::deleteAllCodeBlocks,Heap::deleteAllUnlinkedCodeBlocks,CodeBlock::jettison,ScriptExecutable::installCode, andScriptExecutable::clearCode, which caches each one clears, and build the matrix — the gap fixed here was thatclearCodereached the executable whileunlinkOrUpgradeIncomingCallsreached only list-linked consumers, and the VM cache was in neither set. Match tell (narrow): an invalidation routine that clears state by walking one ownership graph (the incoming-callSentinelLinkedList, or the clearable-codeIsoCellSet) when a consumer reaches the same state through a third path such as executable-keyed lookup. Wider: the same shape shows up in any WebKit subsystem with more than one teardown trigger — style/render tree invalidation on detach versus on style recalc, IPC-side object teardown on connection close versus explicit destroy message. Widest: applies to any system with several independent invalidation triggers over shared derived state (cache-coherence protocols, CDN purge paths, ORM identity maps after raw SQL); for each invalidation trigger, ask whether the set of subscribers is the same set, and if not, which trigger has the smaller set and why.