Skip to content

Add GC.KeepAlive to ComposeBridges JNI calls#11

Merged
jonathanpeppers merged 1 commit into
mainfrom
jonathanpeppers/bridge-gc-keepalive
Jun 3, 2026
Merged

Add GC.KeepAlive to ComposeBridges JNI calls#11
jonathanpeppers merged 1 commit into
mainfrom
jonathanpeppers/bridge-gc-keepalive

Conversation

@jonathanpeppers

Copy link
Copy Markdown
Owner

The raw-JNI bridges in ComposeBridges.cs were missing the GC.KeepAlive pattern that dotnet/java-interop emits for every bound member. Once ((Java.Lang.Object)obj).Handle is read into a JValue, the JIT considers the managed wrapper dead, so a GC during the JNI call can finalize it and invalidate the underlying handle. In practice it usually doesn't crash because the lambda wrappers are referenced from ComposableLambda*/the call stack and Compose calls are short, but under GC pressure it's exactly the kind of bug that surfaces as a "stale local reference" JNI error.

What changed

Every bridge (Text, Button, IconButton, FloatingActionButton, Surface, TextField/OutlinedTextField, AlertDialog, Card, AssistChip/FilterChip/InputChip/SuggestionChip, NavigationBar/NavigationBarItem, NavigationRail/NavigationRailItem) now wraps JNIEnv.CallStaticVoidMethod in a try { ... } finally { GC.KeepAlive(...); } block, with one KeepAlive per managed parameter whose .Handle was read into a JValue. The bridges that already had a try/finally to DeleteLocalRef a JNIEnv.NewString ref (Text, InvokeTextField) fold the new KeepAlive calls into that existing finally rather than nesting a second one.

GC.KeepAlive(null) is a no-op so the optional slot wrappers in AlertDialog, the chips, and the nav items are kept alive unconditionally without a null check.

Instructions update

Added rule #7 under the JNI-bridges section of .github/copilot-instructions.md documenting the try/finally + GC.KeepAlive shape, the rationale (matches dotnet/java-interop output), and a copy-paste pattern, so future bridges get it right from the first commit.

Co-authored-by: Copilot 223556219+Copilot@users.noreply.github.com

Wrap every JNIEnv.CallStaticVoidMethod in ComposeBridges.cs in a try/finally that calls GC.KeepAlive on each managed parameter whose .Handle was read into a JValue (lambdas, the composer, optional slot wrappers).

This matches what dotnet/java-interop emits for bound members. Without it, once .Handle is read the JIT can consider the managed wrapper dead and a GC during the JNI call can finalize it and invalidate the underlying handle.

Also document the pattern as rule #7 under the JNI-bridges section of .github/copilot-instructions.md so future bridges get it right.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings June 3, 2026 19:01

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR hardens the raw JNI bridge layer (ComposeBridges.cs) by ensuring managed wrapper objects (lambdas and the IComposer) remain alive across JNIEnv.CallStaticVoidMethod invocations, matching the lifetime-safety pattern emitted by dotnet/java-interop. This reduces the risk of stale/invalid JNI handles under GC pressure.

Changes:

  • Wrapped each JNIEnv.CallStaticVoidMethod in try/finally and added GC.KeepAlive(...) for every managed parameter whose .Handle is captured into a JValue.
  • Folded the GC.KeepAlive(...) calls into existing try/finally blocks where string DeleteLocalRef cleanup already existed (e.g., Text, InvokeTextField).
  • Documented the required try/finally + GC.KeepAlive JNI-bridge pattern in .github/copilot-instructions.md to prevent future regressions.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.

File Description
src/ComposeNet.Compose/ComposeBridges.cs Adds try/finally wrappers and GC.KeepAlive(...) calls around JNI invocations to prevent premature finalization of managed wrappers during JNI calls.
.github/copilot-instructions.md Documents the required JNI-bridge lifetime-safety pattern (matching dotnet/java-interop) for future bridge additions.

@jonathanpeppers jonathanpeppers merged commit bfb108d into main Jun 3, 2026
1 check passed
jonathanpeppers added a commit that referenced this pull request Jun 3, 2026
Mirror the pattern from PR #11 (bfb108d) — wrap each
JNIEnv.CallStatic*Method invocation in try/finally and
GC.KeepAlive every managed wrapper whose .Handle was read
into a JValue. Once the handle is in the JValue the JIT
can consider the wrapper dead, and a GC mid-JNI-call would
finalize it and invalidate the handle.

Applied to: ModalBottomSheet, BottomSheetScaffold,
DatePickerDialog, DatePicker, TimePicker, TimePickerDialog,
TooltipBox, RememberDatePickerState, RememberTimePickerState,
RememberTooltipState, RememberPlainTooltipPositionProvider.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@jonathanpeppers jonathanpeppers deleted the jonathanpeppers/bridge-gc-keepalive branch June 4, 2026 16:51
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.

2 participants