fix: bound JSICache slot growth, and give ReferenceState a real control block - #1464
Closed
jslok wants to merge 1 commit into
Closed
fix: bound JSICache slot growth, and give ReferenceState a real control block#1464jslok wants to merge 1 commit into
jslok wants to merge 1 commit into
Conversation
…ol block JSICache`s six weak-slot lists were append-only for the lifetime of a Runtime. Every makeShared() pushed a slot that was only freed in ~JSICache, so a caller that converts values to JS at a high rate grew them without bound long after the values themselves had been collected. They are now self-compacting: a slot whose value is definitively deleted is dropped, behind a doubling watermark that keeps this amortized O(1) per push. Compaction needs to probe liveness without resurrecting anything, so WeakReference gained isDeleted(), which reads the atomic flag instead of going through lock(). That probe exposed two lifetime bugs underneath: - ReferenceState was freed when "strong == 0 && weak == 0", reading two atomics non-atomically as a unit. A last-strong releaser and a last-weak releaser running concurrently could both observe (0, 0) and double-delete the state, or one could read a state the other had already freed. The strong cohort now owns one implicit weak reference, exactly like shared_ptr`s control block, so fetch_sub`s return value alone decides who frees the state and only one thread can ever see the 1 -> 0 transition. - WeakReference::lock() checked isDeleted and then incremented the strong count, but ~BorrowingReference decrements that count BEFORE setting isDeleted. A lock() landing in that window resurrected a dying value, and the resurrected reference then ran a second final release. lock() now uses an increment-if-not-zero, the same guard weak_ptr::lock() uses. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Owner
|
Hey - I'd definitely prefer separate atomic PRs for this. The change is quite big and a fundamental piece of Nitro architecture. |
Contributor
Author
|
Makes sense - split into three atomic PRs, closing this one:
Each is a single commit on top of the previous; the combined tip is code-identical to this PR. Dependency reasoning and per-PR testing status are in each description. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Found while chasing steady native-heap growth on a hot path that converts HybridObjects to JS every frame. The visible symptom is one leak, but fixing it properly surfaced two lifetime bugs underneath it.
1.
JSICache's weak-slot lists grow without boundEvery
JSICacheReference::makeShared(..)does_xCache.push_back(owning.weak()), and those sixstd::vector<WeakReference<T>>s are only ever freed wholesale in~JSICache. Nothing removes a slot when its value dies.That is invisible for the usual case — a handful of long-lived callbacks per Runtime. It is not invisible for a caller that converts values to JS at a high rate: the slots accumulate for the entire lifetime of the Runtime, long after the values themselves have been collected. On a per-frame path (a Frame Processor boxing one HybridObject plus two results per frame) that was ~2700 permanently-retained slots per minute, visible in heapprofd as steady growth under
JSICacheReference::makeShared.The lists are now a small
WeakCache<T>that compacts as it grows:compact()erases only slots whose value is definitively deleted, so it can never drop a slot~JSICachestill needs to force-destroy — it only removes bookkeeping for values that no longer exist._compactAtwatermark (doubling, min 64) keeps this amortized O(1) per push. A cache made mostly of long-lived values, where compaction reclaims nothing, does not re-scan on every insert.~JSICachewalks.references()instead, which is otherwise unchanged.2.
WeakReference::isDeleted(), and why it is notlock()The compaction probe must not materialize a strong reference.
lock()checksisDeletedand then increments the strong count, but~BorrowingReferencedecrements the count before callingforceDestroyValue(), and holds no mutex while doing so. Alock()landing in that window resurrects a value whose final release is already in flight, and the resurrected temporary's destructor then runs a secondforceDestroyValue()concurrently with the releaser's.Nitro allows HybridObject/callback releases on any thread, and compaction runs on the hot
makeSharedpath, so that race is reachable.isDeleted()reads the atomic flag only. It is one-sided-safe: during such a race it may still report a dying value as alive, which merely keeps the slot until the next compaction — never the reverse.3.
ReferenceStatewas freed by a non-atomic two-counter checkBoth
BorrowingReference::maybeDestroyState()andWeakReference::maybeDestroy()freed the state whenstrongRefCount == 0 && weakRefCount == 0. Those are two independent atomics read as a unit, so a last-strong releaser and a last-weak releaser running concurrently can both observe(0, 0)— a doubledelete _state— or one can read a counter out of a state the other has already freed.ReferenceStatenow usesshared_ptr's control-block scheme:weakRefCountstarts at 1, representing one implicit weak reference collectively owned by the strong cohort, released by whichever strong reference performs the final strong release (after it destroyed the value). The state is freed by whoever bringsweakRefCountto zero, decided byfetch_sub's return value alone — so exactly one thread ever sees the1 -> 0transition, andweakRefCountcannot reach zero before the final strong release has completed.4.
lock()now uses increment-if-not-zeroThis one falls out of #3. With the implicit weak reference, the resurrection window described in #2 becomes actively harmful rather than merely odd: a resurrected strong reference performs a second final strong release, which releases the implicit weak reference twice and can free the state while a
WeakReferencestill holds it.So
lock()now claims the strong count with a compare-exchange loop that refuses to go from zero, exactly likeweak_ptr::lock(). A zero strong count means the final release is already under way, so returning null there is also just correct on its own — it is the pre-existing resurrection bug, fixed. The privateWeakReference -> BorrowingReferencelock-constructor no longer increments, sincelock()has already claimed the count.Scope note
I'd have preferred to send #1 alone, but the compaction probe is only safe given #3 and #4, so they belong in one change. Happy to split if you'd rather review them separately — #3 + #4 stand on their own as a correctness fix.
This is independent of #1451 (thread-safe static per-Runtime caches); the two touch
JSICache.cppin different places and do not conflict.Testing
The
JSICachegrowth fix and theReferenceStatecontrol block have been running on-device (Android, release build) against a per-frame HybridObject conversion path, with all Nitro-consuming modules rebuilt: the retained-slot count stops ratcheting and the native-heap growth attributed tomakeSharedgoes away.The
lock()hardening in #4 is newer and has been compile-checked (NDK clang, C++20) and reasoned through, but not yet soaked on-device — please review that one closely. I have not run the repo's own test suite. Formatted withconfig/.clang-format.