fix(jni): keep a wrapper reachable while its handle is in a native call - #635
Merged
Conversation
andiwand
force-pushed
the
fix/jni-handle-reachability
branch
from
July 30, 2026 21:07
f6989bd to
e01f042
Compare
`jni/AGENTS.md` already asks for it - "natives that use a handle are instance
methods" - because the JNI frame's reference to the receiver is what stops the
collector from enqueuing the wrapper, and the reaper from freeing the handle,
while the call is still running. Six calls did not follow it.
Five passed the handle of the object they belong to into a *static* native, so
nothing referred to the wrapper for the duration: `Logger.willLog`/`log`/
`flush`, `Html.edit` and `new DecodedFile(File)`. The natives move onto their
owner - `Logger`'s three lose `static`, `Html.edit` delegates to
`Document.edit`, and the `DecodedFile(File)` constructor to `File.decode` -
which puts the wrapper in the JNI frame where the rule intends it.
The sixth kind the rule does not cover: a handle in *argument* position,
`a.fooNative(handle(), b.handle())`, has no receiver to hold it. `Html.translate`
gets away with it by accident, passing the file to the HtmlService constructor
afterwards; `DocumentPath.join`, `Element.isSame`, `Element.navigatePath`,
`HttpServer.connectService` and `Odr.open(path, logger)` do not. Those get
`NativeResource.keepAlive()` in a `finally`.
`keepAlive` is `synchronized (this) {}`, which is what
`Reference.reachabilityFence` was before it became an intrinsic: android has
that method only from API 28, this artifact's floor is 26, and core library
desugaring does not cover `java.lang.ref` - the same wall the `PhantomReference`
reaper hit with `Cleaner`. Lock elision needs the object to be provably
confined, and one the reference queue can see is not.
Nothing here is an observed crash; the window needs a collection to land inside
a native call. `Html.translate` and `Odr.open(path, logger)` are the ones long
enough to make that plausible.
Tests for the two renamed symbols, which would otherwise fail only at first call
with UnsatisfiedLinkError, and for the argument-position pair that had none:
`File.decodeAnOpenFile`, `Document.editAppliesADiff`, and join/navigatePath in
`Document.documentPath`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PEvpyHCo15bkwhKgsEviGi
andiwand
force-pushed
the
fix/jni-handle-reachability
branch
from
July 30, 2026 21:19
e01f042 to
64af916
Compare
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.
🤖 Generated with Claude Code
Stacked on #633 — review that first, this branch only adds the commit on top.
Fallout from reviewing the
close()-races-listen()finding on #633: that oneis about explicit close, this is the other way a handle can go while it is in
use — the collector.
The rule that already exists
jni/AGENTS.md:That is exactly right, and it is what makes 199 of the natives safe. Six calls
did not follow it.
Handle in receiver position — five of them
Logger.willLog/log/flush,Html.edit,new DecodedFile(File)allpassed the handle of the object they belong to into a static native:
Once
handle()returns, nothing on the stack refers tothis. The JIT maytreat it as dead, the collector enqueues the phantom reference, the reaper calls
destroy(handle)— andwillLogNativeis running on freed memory. The fix isthe rule: the natives move onto their owner.
Logger's three simply losestatic(jclass→jobjectin the JNI).Html.edit(document, diff)→ delegates to a package-privateDocument.edit(diff);Java_…_Html_editDocument→Java_…_Document_editNative.new DecodedFile(File file)→this(file.decode());Java_…_DecodedFile_createFromFile→Java_…_File_decodeNative. Constructorchaining, so this one had to be restructured rather than fenced.
Public API is unchanged —
Html.editand theDecodedFile(File)constructorstill exist and still do the same thing.
Handle in argument position — the kind the rule does not cover
a.fooNative(handle(), b.handle())holdsathrough the receiver but nothingholds
b. Five sites:DocumentPath.join,Element.isSame,Element.navigatePath,HttpServer.connectService,Odr.open(path, logger).There is no receiver to move them to, so they get
NativeResource.keepAlive()in a
finally. The threeHtml.translateoverloads look like the same shape andare fine by accident — the owner is the second constructor argument, evaluated
after the native call returns, so it is live across it.
Why
keepAliveand notreachabilityFenceReference.reachabilityFenceis the API for this and is unusable here: androidhas it from API 28,
android/build.gradle.ktssetsminSdk = 26, and corelibrary desugaring does not cover
java.lang.ref— the same wall thePhantomReferencereaper already hit withCleaner.keepAlive()issynchronized (this) {}, which is whatreachabilityFencewasin the JDK before it became an intrinsic. Lock elision requires the object to be
provably confined; one that the reference queue can see is not.
Honesty about severity
Nothing here is an observed crash. The window needs a collection to land inside
a native call on an object the caller has no further use for.
Html.translateand
Odr.open(path, logger)are the calls long enough to make that plausible;Element.isSameis theory. It is cheap to be right, and the rule now covers thecase that was missing.
Tests
The two renamed JNI symbols would otherwise fail only on first call, with
UnsatisfiedLinkError, and neither path was covered:FileTest.decodeAnOpenFileand
DocumentTest.editAppliesADiff.DocumentTest.documentPathgainsjoin/navigatePath, the argument-position pair that had no test either. 35JUnit tests pass; gtest and pytest unaffected and re-run green.
jni/AGENTS.mdextended with the argument-position case and the API-28 trap.