Status: exercised by the runtime suite (904 tests, 0 failures); the flag-driven stress
scenarios below are still unrun
Patch: v8_resurrecting_finalizers.patch — 6 files, +184/-3,
against V8 14.9.207.39 (branch-heads/14.9). The patch and the pinned
version live in NativeScript/v8-buildscripts.
Built clean for arm64-iphonesimulator (lite mode, no sandbox, no pointer compression).
Also applies unmodified to 14.4, 14.5 and 14.6; on 14.8+ the heap-symbols.h hunk needs
re-alignment (whitespace only).
ObjectManager::FinalizerCallback (ObjectManager.mm:34)
reads the dying object's internal fields and may refuse disposal by re-arming its
handle (ClearWeak() + SetWeak()). That requires v8::WeakCallbackType::kFinalizer,
which upstream removed one day after our pinned 10.3.22:
| Change | Commit | Landed in |
|---|---|---|
kFinalizer API removed |
2ae2aa92 |
~10.3.24 |
| GC machinery removed | cb92ed09 |
~10.3.27 |
| Node state machine simplified | 015632a0 |
~10.3.46 |
On stock 14.x the pattern is not merely unsupported but fatal: the handle slot is zapped
to 0xCA11 before the callback runs, and CHECK_WITH_MSG(Node::FREE == node->state(), ...)
crashes if a callback re-arms instead of resetting. Re-arming a kParameter handle is not
a workaround — that callback only fires once V8 has already committed to the object's death.
This patch exists to buy time: it keeps the current ownership semantics working on a modern V8 while the reachability-based redesign in RESURRECTION_TO_REACHABILITY.md is proven out. It is deliberately narrower than what upstream removed.
Upstream supported finalizers in both young and full GC. This patch does not.
- Young GC (scavenger + minor mark-sweep): finalizer handles are strong roots. They are
visited by
IterateYoungStrongAndDependentRoots()and skipped byProcessWeakYoungObjects(). A young collection never finalizes; it just promotes. Collection is deferred to the next full GC. - Full GC: the keep-alive pass runs, objects are queued, and callbacks fire after the pause.
This is what deletes the hardest part of a faithful revert. Upstream's young-generation
hooks lived in MinorMarkCompactCollector, which no longer exists (replaced by
minor-mark-sweep.cc), so a faithful port would have had to re-derive them. Both young
collectors call the same two GlobalHandles entry points, so treating finalizers as strong
is one change covering both, and scavenger.cc / minor-mark-sweep.cc are untouched.
1. Keep-alive (mark-compact.cc, end of MarkLiveObjects()). After the final marking
closure, IdentifyDeadFinalizerHandles() queues every finalizer handle whose object is
unmarked, IterateFinalizerHandlesAsRoots() visits them, and the marking closure
(MarkTransitiveClosureFixpoint(), falling back to MarkTransitiveClosureLinear())
re-drains the worklists.
2. Pending bit (global-handles.cc). A queued node carries a pending_finalizer flag
(bit 5 of flags_, previously unused) and is added to pending_finalizers_. While set, the
node is visited by IterateStrongRoots(), and re-identification is suppressed. The bit —
not the queue — is the source of truth: entries are re-checked before invocation, because GC
epilogue callbacks run before the drain and may reset a queued handle out from under it.
3. Post-pause invocation (GlobalHandles::PostGarbageCollectionProcessing). Callbacks
run from the GC epilogue via InvokeExternalCallbacks — not from the first-pass phantom
path, which runs in-pause and forbids allocation. The drain is wrapped in
AllowJavascriptExecution; see below for why that is required rather than incidental.
4. Lifting DisallowJavascriptExecution. Heap::CollectGarbage holds one across the
entire collection, epilogue included — heap.cc, "JS execution is not allowed in any of
the callbacks" — and InvokeExternalCallbacks() re-enables allocation
(AllowGarbageCollection) but deliberately not JS, asserting on entry that JS is disallowed.
Entering JS from a callback therefore hits
// src/execution/execution.cc, Invoke()
if (!AllowJavascriptExecution::IsAllowed(isolate)) {
GRACEFUL_FATAL("Invoke in DisallowJavascriptExecutionScope");
}and aborts the process. This is a behavioural change from 10.3, where
InvokeSecondPassPhantomCallbacks() ran under an explicit AllowJavascriptExecution.
The runtime depends on the 10.3 behaviour and cannot avoid it:
ObjectManager::FinalizerCallback → DisposeValue → [target release] runs -dealloc, and
any JS-backed override that teardown reaches — a JS UIView subclass being removed from its
superview, a delegate, a block — re-enters JS through ArgConverter::MethodCallback. So the
patch lifts the scope around the finalizer drain only: InvokeSecondPassPhantomCallbacks()
below it still asserts JS is disallowed, so the scope is closed before it runs.
Both scopes are live in release builds. Only the …DebugOnly aliases generated in
assert-scope.h compile away, and neither Heap::CollectGarbage nor this patch uses those.
The Android runtime shares this patch but does not depend on the lift: its finalizer makes a
single runtime-internal JNI call (makeInstanceWeakAndCheckIfAlive) and Java has no
synchronous destructor that could re-enter JS.
The public enum gains a third value; api.cc needs no change (it passes the type through,
and the only switch is in Node::MakeWeak).
These are the things that make it correct. Breaking any of them is a use-after-free, not a leak.
-
The keep-alive must run at the end of
MarkLiveObjects(), inside the cppgc atomic pause. Not inClearNonLiveReferences(). At the chosen point the cppgc marker is still live (CppHeap::FinishMarkingAndProcessWeakness()has not run), so the re-drained closure still pushes to cppgc viaVisitCppHeapPointer→AdvanceMarking. A resurrected wrapper whose JS properties reach cppgc-managed objects therefore marks correctly. Moving this block afterCollectGarbage:534silently drops those pushes and sweeps live cppgc objects. -
The keep-alive must precede all weakness processing. Everything that consumes liveness — phantom handles, ephemerons,
WeakRef/FinalizationRegistry, map-transition pruning, code flushing — lives inClearNonLiveReferences(), which runs after. Every such pass must observe the resurrected closure as live. -
The pending bit must never survive into a reallocated node. Cleared in
ClearImplFields()(release path),ClearWeakness()and bothMakeWeak()overloads (re-arm path). A freed node carries a zapped location; visiting it as a root would crash. -
A finalizer callback must reset or re-arm its handle. Enforced by
CHECK_WITH_MSGafter invocation. Doing neither leaves the handle rooted forever.
Two supporting facts, verified in 14.9 and worth re-checking on any upgrade: marking iterates
global handles via IterateStrongRoots (SkipRoot::kWeak), so pending nodes are protected
during a nested GC; root updating uses IterateAllRoots, a single pass over NORMAL||WEAK,
so a pending node's slot is updated exactly once after evacuation.
- No
PENDINGstate, noNEAR_DEATHfor finalizers. 14.9'sNodeStateis 2 bits with all four values used; a separate flag bit avoids widening it, and keeps finalizer nodes out of theNEAR_DEATHaccounting inUpdateListOfYoungNodesImpl(). - Pending nodes are strong roots, not weak retainers. Upstream left a
NEAR_DEATHfinalizer node weak while its callback ran, which leaves the object unprotected against a GC nested inside the callback. Rooting it is stricter. ResetWeakNodeIfDead()reports finalizer handles alive rather thanUNREACHABLE(). Client isolates of a shared space get their ownIterateWeakRootsForPhantomHandles()pass (mark-compact.cc:3166) that the keep-alive does not cover. Reporting alive defers collection by a cycle in that exotic case instead of crashing.Node::PostGarbageCollectionProcessingkeeps upstream's name deliberately.tools/cfi/ignores.txtblocklists*GlobalHandles*PostGarbageCollectionProcessing*becauseweak_callback_is invoked on the wrong type. Renaming it breaks CFI builds.
IdentifyDeadFinalizerHandles() snapshots the dead set before IterateFinalizerHandlesAsRoots()
and the re-drain, so the queue is built against pre-resurrection marking. If two wrappers are
both JS-unreachable and one references the other, both are queued:
n1holdsn2; neither is reachable from a JS root, so both are queued.- The keep-alive marks
n1's whole closure —n2included — live, so nothing is swept. n2's callback runs first and disposes:[N2 release], wrapper deleted.n1's callback findsIsGcProtected()and re-arms.
n1 comes back holding a JS object whose native half is gone. DisposeValue does neuter the
husk (delete wrapper then tns::DeleteValue, which sets internal field 0 to Undefined), so
it is not a dangling BaseDataWrapper* — but consumers that read the field without checking,
Pointer.cpp among them, will read Undefined as an External and dereference it.
This is not fixable by deferring the release or by re-checking liveness after the drain.
Every queued node is rooted by IterateFinalizerHandlesAsRoots() and treated as a strong
retainer for the rest of the cycle, so immediately after the GC every disposed object still
looks alive; the check cannot separate "alive because a referrer revived" from "alive because
it was rooted for its own finalization". Waiting a cycle does not converge either: n1 re-arms
weak, so the next IdentifyDeadFinalizerHandles() finds both unmarked again and re-queues the
same pair.
Nor is it fixable by making GcProtect() a strong root instead of a disposal veto. Disposal
here is driven by whether native still needs the object, not by JS reachability, and that is
precisely what lets a JS↔ObjC cycle collect at all: an unprotected object is released even
though surviving JS still references it. Turning protection into an opaque strong root would
convert every such cycle into a permanent leak.
The real fix is to stop expressing liveness with roots and express it by tracing — CppHeap,
v8::Object::Wrap/Unwrap, TracedReference and cppgc Trace(), which mark through the
embedder graph and so collect cross-heap cycles without resurrection. That is
RESURRECTION_TO_REACHABILITY.md. Note that the intermediate
option is gone: EmbedderRootsHandler::IsRoot(), which used to let an embedder declare a
TracedReference a root per-GC, no longer exists in 14.9 — only ResetRoot/TryResetRoot
remain.
Until then, the cheap mitigation is to give the husk defined behaviour: throw "native object
has already been released" from a single checked accessor rather than letting each call site
read Undefined as a pointer.
- Runs. The TestRunner suite is green against the patched V8 — 904 tests, 0 failures, 0 errors, 11 skipped — including the GC tests named at the end of the next section.
- Compiles. Full V8 build for
arm64-iphonesimulatorat 14.9.207.39, exit 0, no warnings in the patched files. - Applies cleanly to pristine 14.9.207.39.
- Public API surface compiles: a standalone TU using
SetWeak(..., kFinalizer),Get()inside the callback, and both the re-arm and reset paths passesclang++ -fsyntax-only -std=c++20. - Adds no clang-format violations (mark-compact.cc's 30 are pre-existing).
- No exhaustive switch over
WeakCallbackTypeexists outsideNode::MakeWeak, so adding the enum value breaks no other translation unit.
The runtime suite passes against the patched V8, which drives the resurrection path in anger — see "Worker instance should not be garbage collected if the worker thread is alive" below. Everything in this section is narrower: it needs a V8 run under specific flags, and the suite's default configuration reaches none of it.
- Re-running the marking closure after
EnterProcessGlobalAtomicPause(). The closure is asserted empty at that point and this patch pushes new roots and re-drives it. TheCHECK(IsCppHeapMarkingFinished(...))after the second drain is the tripwire. Run under--stress-incremental-markingand--stress-concurrent-marking. Highest-value test. - Resurrect-then-rearm, end to end. A finalizer that re-arms N times then resets, across
repeated
--expose-gcmajor GCs, under--verify-heap. Confirms the callback site, the un-zapped slot throughMakeWeak'sCHECK_NE(object_, kGlobalHandleZapValue), and theCHECK_WITH_MSGcontract. - Resurrected object reaching a cppgc object. Give a resurrected wrapper a JS property chain to a cppgc-managed wrapper and confirm it is not swept. This is invariant 1's test; it is the failure mode with no loud assertion.
- Young-generation path. A finalizer handle on a young object: confirm scavenge promotes it
rather than finalizing, and that
IncrementNodesDiedInNewSpaceaccounting stays sane. - Nested GC inside a finalizer callback. Allocate heavily in the callback; confirm no double-invocation and no collection of the object under inspection.
The runtime's existing GC tests are the acceptance gate for the patch as the runtime uses it,
and they pass — in particular "Worker instance should not be garbage collected if the worker
thread is alive", which exercises the WorkerWrapper resurrection site directly.
Every V8 bump requires re-checking the four invariants above, since three of them are about where the block sits relative to phases that upstream reorders freely. This is intermediate scaffolding with a real carrying cost — it is cheaper than a faithful revert, not free.