Skip to content

fix(android): release JS errors handed to Java instead of leaking a handle per throw - #131

Open
ammarahm-ed wants to merge 2 commits into
fix/android-metadata-cache-lockfrom
fix/android-exception-handles
Open

ammarahm-ed wants to merge 2 commits into
fix/android-metadata-cache-lockfrom
fix/android-exception-handles

Conversation

@ammarahm-ed

@ammarahm-ed ammarahm-ed commented Nov 9, 2026 •

Copy link
Copy Markdown
Collaborator

Port of the NativeScriptException::m_javascriptException item of NativeScript/android#2013 to both the Node-API and the direct runtime.

When a JS error crosses into Java (a JS callback throws and a Java frame is above it), ReThrowToJava gave the Java NativeScriptException the address of the engine handle as a jlong. Java has nothing to free it with, and the handle is only released if the exception comes back to JS and WrapJavaToJsException converts it. An exception that Java catches and drops therefore pinned its Error and captured stack for the life of the process: a napi_ref in the Node-API runtime, a heap-allocated JsValue in the direct one.

Java now receives an id instead of an address, into a table owned by NativeScriptException (TransferToJava / TakeFromJava / ReleaseTransferred):

  • The table is bounded to 1024 entries per runtime. When it is full, the oldest entry is released.
  • An entry is also released when the error comes back to JS, and when the runtime is destroyed.
  • If an error is no longer in the table when it comes back (evicted, or converted on a different runtime), the existing fallback rebuilds a JS error from the Java throwable instead of returning the original object.
  • In the direct runtime the table is keyed by rt.identity(), because the engine::Runtime wrapper object is not the same on every call.

A weak JNI reference per entry, to drop an entry when its throwable is collected, would be the more precise lifetime, but a burst of 400,000 throws aborted the app inside ART's AddWeakGlobalRef: Android caps weak global references at about 51,200 per process.

No Java change is needed (jsValueAddress stays a long). The first commit adds the benchmark case that throws from a JS callback and is swallowed in Java.

Results

V8 release build on a Sony SO-01M (Android 11, arm64). Suite case: 50,000 throws, 5 launches, median.

Case Node-API before after Δ Direct before after Δ
Invoke callback that throws (ns/call) 137,042 139,502 +2% 197,598 195,886 -1%
Native heap after the suite (MB) 185 184 -1% 103 97 -6%

At 50,000 throws the leak is below this device's memory noise. At 400,000 throws (a one-off build that runs only the throwing case, Node-API, 3 launches, not committed):

400,000 throws before after
Total PSS (MB) 1,328 1,014
Native heap (MB) 978 913
Time per throw (ns) 137,793 136,657

So the handle leak is real and this removes about 300 MB of it, at no cost per throw. It does not remove all of the growth: about 900 MB of native heap remains after 400,000 throws, roughly 2 KB per throw once the roughly 100 MB baseline is excluded, and I have not found where it comes from. It is not the handle this PR frees.

The Jasmine suite on a debug build of the top of this stack, same device:

main stack top
Node-API 523 specs, 0 failures 522 specs, 0 failures
Direct 451 specs, 1 failure (triggers interval, a timing spec) 450 specs, 0 failures

That includes TestThrowJSExceptionThroughJavaAndCatchInJS, which checks that the same JS object comes back through five nested Java frames. It failed on the direct runtime while the table was keyed by runtime address and passes with rt.identity().

The 400,000-throw comparison was not run on the direct runtime. Only V8 was built and run.

@coderabbitai

coderabbitai Bot commented Nov 9, 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: b402c92c-d7b7-4fd8-b991-37556125d3fc

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

@ammarahm-ed
ammarahm-ed added this pull request to stack #121 November 9, 2026 08:37

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