Skip to content

fix(android): free runtime-owned JNI refs and method callback data on dispose - #92

Open
NathanWalker wants to merge 1 commit into
android-react-nativefrom
fix/android-runtime-teardown-leaks
Open

NathanWalker wants to merge 1 commit into
android-react-nativefrom
fix/android-runtime-teardown-leaks

Conversation

@NathanWalker

Copy link
Copy Markdown
Contributor

Split out of #89 at the reviewer's request. This PR has the leak fixes only, with no attach/detach changes.

Summary

Disposing a runtime left three things behind. Today that is a worker; once #89 lands it is also a React Native guest runtime.

  • LRU cache weak refs. ~LRUCache drops its map without evicting, so every cached weak global ref to a Java object stayed in the process-wide weak reference table. Added LRUCache::clear(), which evicts each entry, and ObjectManager calls it at dispose.

  • Java Runtime global ref. ~Runtime now deletes the JNI global ref it holds on com.tns.Runtime.

  • MethodCallbackData. CtorFuncCache owns only instance-method data. The data for these was never deleted:

    • static methods;
    • extension-only methods;
    • methods added from a skipped base class.

    MetadataNodeCache::methodCallbackData now owns it, and onDisposeEnv/onDisposeRuntime delete it.

Applied to both the napi and jsi trees.

Why this can't double free

Every allocation site registers its pointer with exactly one owner:

Allocated in Owner
instance methods (static or runtime metadata) CtorFuncCache entry (unchanged)
extension functions also listed as instance methods CtorFuncCache entry (unchanged)
extension-only functions methodCallbackData (new; only entries not in instanceMethodData)
static methods methodCallbackData (new; registered right after new)
SetMissingBaseMethods, new data methodCallbackData (new). A match borrowed from instanceMethodData is not registered again
  • CtorFuncCache has no duplicates. Its list for a class holds exactly that class's own instanceMethodData: GetConstructorFunctionInternal takes the vector by value, so it always starts empty.
  • Base-class data has one owner. It is linked only through parent, which owns nothing.
  • No engine frees the pointer. Neither napi nor jsi engines free the function data pointer; QuickJS's function finalizer frees only its own FunctionInfo.

Evidence

Pixel 9 emulator (API 36, arm64), standalone test app.

  • QuickJS + napi
    • Full suite: 522/522, 0 failures (27/27 worker specs).
    • With an extra worker-churn stress spec: 0 failures in 3 of 3 runs. The spec did 40× terminate(), 40× close() from inside the worker, and 12 concurrent terminates. Each worker used static, extension-only and instance methods.
  • QuickJS + jsi: 450/450, 0 failures (27/27 worker specs).
  • Ownership instrumentation (temporary, not in this PR). It counted allocations per thread and checked for duplicate pointers across both owners at dispose.
    • napi: 111 worker disposals, 0 duplicates; on every one, objects allocated = distinct owned.
    • jsi: 72 disposals, same result.
    • A worker that touches a handful of classes now frees 177 objects that used to leak, in addition to the 243 that were already freed.
  • Hermes + napi with the stress spec: 3 of 4 runs passed. One run timed out on the 12-concurrent-workers spec: no worker replied and nothing crashed. It didn't recur, and the baseline run passed.

Known and unchanged: on jsi + QuickJS, a worker that calls close() from inside its message handler after using Java classes aborts in JS_FreeRuntime (list_empty(&rt->gc_obj_list)). The base branch does the same.

Not verified:

Merge Danger

Door: two-way

Blast Radius: Android runtime dispose: worker terminate/close, and RN detach once #89 lands

The newly owned callback data is freed at the same point as the instance-method data already was. So code that calls a native method on a disposed runtime fails the same way for static methods as it already did for instance methods.

… dispose

Disposing a runtime (a worker, or a React Native guest runtime) left three
things behind:

- ObjectManager's LRU cache of Java objects: ~LRUCache drops its map without
  calling the evict callback, so every cached weak global ref stayed in the
  process-wide weak reference table. Add LRUCache::clear(), which evicts each
  entry, and call it from ObjectManager's dispose.
- The JNI global ref ~Runtime holds on the Java com.tns.Runtime.
- MethodCallbackData for static methods, extension-only methods and methods
  added from a skipped base class. CtorFuncCache only owns the instance
  method data, so these were never deleted (about 180 per worker that touches
  a handful of classes). MetadataNodeCache now owns them and deletes them in
  onDisposeEnv/onDisposeRuntime.

Applied to both the napi and jsi trees. Split out of #89.
@coderabbitai

coderabbitai Bot commented Oct 25, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 60b121d1-b63f-4fc1-9510-bdd162810f6f

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

This branch has not been deployed

No deployments
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.

1 participant