Skip to content

fix(android): flush queued finalizers before a jsi runtime is marked destroying - #95

Open
NathanWalker wants to merge 1 commit into
android-react-nativefrom
fix/android-jsi-quickjs-worker-close
Open

NathanWalker wants to merge 1 commit into
android-react-nativefrom
fix/android-jsi-quickjs-worker-close

Conversation

@NathanWalker

Copy link
Copy Markdown
Contributor

Fixes #94.

Summary

On jsi, a worker aborts on QuickJS if it calls close() from its onmessage after a Java wrapper was collected in the same handler: JS_FreeRuntime asserts list_empty(&rt->gc_obj_list).

  • A collected host-object proxy queues its cleanup on FinalizerQueue, which drains on the next looper tick.
  • close() quits the looper first, so that tick never comes. DestroyRuntime then flushed the queue, but only after setting is_destroying.
  • While is_destroying is set, HostObjectProxyPostFinalizer leaves the target handle alone. That rule comes from napi, where js_free_napi_env frees leftover refs. engine:: handles have no such owner, so the JS wrapper outlived the VM. V8 and JSC only leak it; QuickJS asserts.
  • terminate() from the parent arrives after the tick has run, so it never hit this.

DestroyRuntime now flushes the queue before it sets is_destroying. Nothing else in teardown changes: finalizers that fire later still run inline. The napi tree is unaffected.

The PR also adds an Android worker spec: new java.lang.Object(); gc(); postMessage('closing'); close();.

Evidence

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

  • Repro. The worker needs a collection inside the handler to hit this. new java.lang.Object(); gc(); close(); in onmessage aborts on every run on the base branch (4 of 4). The same body ending in postMessage, then terminate() from the parent, does not.
  • Leak dump (temporary JS_DUMP_LEAKS build). It showed one leaked object, the JS wrapper of a Java object. Temporary logging showed the "destroying" branch of HostObjectProxyPostFinalizer firing on that thread right before the abort.
  • QuickJS + jsi.
  • QuickJS + napi: 523/523.
  • V8 + jsi: 451/451.
  • The new spec aborts the app on the base branch and passes with the fix.

Not verified:

  • Hermes, JSC and PrimJS.
  • A physical device.
  • The RN guest-runtime path.
  • Teardown-time cascades. A proxy can be freed by a refcount cascade during teardown itself, for example a released timer closure. If that happens before OnDisposeRuntime neutralises proxies, it still takes the "leak" branch. The worker tests here never reached it.

Merge Danger

Door: two-way

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

At dispose, queued proxy cleanups now run as a normal drain. They release their wrapper handle and call makeInstanceWeak on the already-detached Java runtime, which only updates that runtime's maps.

…destroying

DestroyRuntime set is_destroying before flushing the finalizer queue.
HostObjectProxyPostFinalizer treats is_destroying as "leave the target
handle", a rule carried over from napi, where js_free_napi_env frees
leftover refs. engine:: handles have no such owner, so every queued proxy
cleanup leaked its JS wrapper, and on QuickJS JS_FreeRuntime aborted on
list_empty(&rt->gc_obj_list).

The queue is normally drained on the next looper tick. A worker that calls
close() from onmessage quits its looper first, which drops that tick, so any
proxy collected during the handler was still queued at dispose. terminate()
from the parent arrives after the tick has run.

Flush the queue first, while the runtime is not yet marked destroying.

Fixes #94
@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: 60f3491c-b3c0-4310-8d90-f2cbcd2cba9c

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