Skip to content

fix: prune expired JSICache weak references - #1533

Open
patrickkabwe wants to merge 2 commits into
margelo:mainfrom
patrickkabwe:fix/jsicache-leak
Open

patrickkabwe wants to merge 2 commits into
margelo:mainfrom
patrickkabwe:fix/jsicache-leak

Conversation

@patrickkabwe

Copy link
Copy Markdown
Contributor

fix for #1532

@vercel

vercel Bot commented Aug 26, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated
nitro-docs Skipped Skipped Nov 11, 2026 1:44pm UTC

Request Review

@risavian

Copy link
Copy Markdown

Adding a data point in support of this fix for #1532.

Another way into the same leak: ArrayBuffer arguments. JSIConverter+ArrayBuffer.hpp:68 calls cache.makeShared(object.getArrayBuffer(runtime)) for every JS ArrayBuffer passed to a native method. The JSArrayBuffer usually dies when the call returns, but its WeakReference stays in _arrayBufferCache, holding weakRefCount = 1. The ReferenceState (88 bytes, served from the 96-byte malloc bucket on arm64 Apple platforms) is never freed. Any crypto, codec or streaming module therefore leaks once per chunk. Minimal repro: for (...) HybridBenchmarkObjectCpp.copyBuffer(buf), or createHash('sha256').update(new Uint8Array(32)).digest() with react-native-quick-crypto.

On hardware (iPhone 13, RN 0.87, Hermes, react-native-nitro-modules@0.37.1): we run a libp2p node, so every frame goes through quick-crypto. Instruments Allocations attributed the 96-byte blocks to BorrowingReference<jsi::ArrayBuffer>::BorrowingReference and BorrowingReference<jsi::WeakObject>::BorrowingReference. After ~23 minutes of traffic, ~2.69 million were live (~247 MB), while the JS heap stayed at ~144 MB. iOS then killed the app at the ~2 GB limit, with no crash report.

What we carry on 0.37.1 (a yarn patch):

  • a WeakReference::isExpired() (_state == nullptr || _state->isDeleted);
  • a jsi-free helper that, when the vector is full (size == capacity), erases expired entries, then doubles the capacity only if more than half are still live;
  • all six makeShared overloads append through that helper.

That's amortised O(1) per append, and the vector stays proportional to the live values. A host unit test with ReferenceStates counted by operator new/delete reads 100000/100000 live on stock and stays bounded with the patch. On the same iPhone, over 1 h 45 min of the same traffic, the 96-byte count went 35k → 40k (vs ~2.7M) and the heap stayed flat at ~1 GB.

Caveat: without the control-block change (#1468 here, or #1766/#1839 on v1), a last strong release on another thread can race the prune on the two-counter check. Our patch relies on JSI references being released on the JS thread.

Request: a fix on the 0.37.x line, since that's what npm install gives today (0.37.2 nightlies still have the same push_back(owning.weak())). We're glad to test a 0.37.x build on iOS and Android hardware.

@mrousavy

Copy link
Copy Markdown
Member

JFYI this has been fixed entirely in v1.

This branch was previously deployed

1 inactive deployment
Preview — e9cc4a2e Deployed Nov 11, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants