Skip to content

fix(flutter): Release Android JNI refs#3712

Merged
buenaflor merged 16 commits into
mainfrom
buenaflor/fix/flutter-android-jni-leaks
May 21, 2026
Merged

fix(flutter): Release Android JNI refs#3712
buenaflor merged 16 commits into
mainfrom
buenaflor/fix/flutter-android-jni-leaks

Conversation

@buenaflor

@buenaflor buenaflor commented May 19, 2026

Copy link
Copy Markdown
Contributor

📜 Description

Release Android JNI refs in the Flutter replay and load debug image paths

This releases the replay Hint callback object after processing replay privacy options, releases the intermediate replay recording through the existing arena, and dedupes debug-image instruction addresses before creating JString refs.

💡 Motivation and Context

These paths can otherwise retain JNI global refs longer than needed while Android replay and debug-image loading are active.

Closes #3696

💚 How did you test it?

fvm dart format lib/src/native/java/sentry_native_java.dart lib/src/native/java/sentry_native_java_init.dart

fvm flutter analyze lib/src/native/java/sentry_native_java.dart lib/src/native/java/sentry_native_java_init.dart

fvm flutter test test/native/sentry_native_java_test.dart

Tried fvm flutter test -d emulator-5554 example/integration_test/native_jni_utils_test.dart, but it fails to compile in the current device-test context because package:test is unavailable for that target.

📝 Checklist

  • I reviewed submitted code
  • I added tests to verify changes
  • No new PII added or SDK only sends newly added PII if sendDefaultPii is enabled
  • I updated the docs if needed
  • All tests passing
  • No breaking changes

🔮 Next steps

Confirm the Android JNI integration test setup separately if reviewers want device-level coverage for these helpers.

Made with Cursor

buenaflor and others added 12 commits May 19, 2026 11:05
Send large Android scope payloads as JSON bytes instead of recursively
constructing Java maps and lists through JNI. This keeps nested user data
structured while reducing per-entry JNI object churn.

Co-Authored-By: GPT-5.5 <[email protected]>
Co-authored-by: Cursor <[email protected]>
Clarify that large or arbitrary Dart collection payloads should cross
JNI as JSON bytes, while primitives and small controlled payloads can use
direct conversion.

Co-Authored-By: GPT-5.5 <[email protected]>
Co-authored-by: Cursor <[email protected]>
Keep the Android JSON reader helper within ktlint formatting limits after
adding the scope sync byte-array bridge.

Co-Authored-By: GPT-5.5 <[email protected]>
Co-authored-by: Cursor <[email protected]>
Wrap all native JSON conversion when branches consistently so ktlint accepts
the multiline Kotlin helper added for Android scope sync.

Co-Authored-By: GPT-5.5 <[email protected]>
Co-authored-by: Cursor <[email protected]>
Use the existing native boundary normalizer before encoding Android scope
payloads as JSON bytes instead of maintaining a second normalization helper.

Co-Authored-By: GPT-5.5 <[email protected]>
Co-authored-by: Cursor <[email protected]>
Keep null values when converting JSON object and array payloads on Android
so the bridge remains lossless before native model deserialization.

Co-Authored-By: GPT-5.5 <[email protected]>
Co-authored-by: Cursor <[email protected]>
Cache JSON deserializers for scope sync payloads and replace the broad
Any extension with a private helper function for Kotlin JSON conversion.

Co-Authored-By: GPT-5.5 <[email protected]>
Co-authored-by: Cursor <[email protected]>
Use the Java SDK JSON reader for context payload parsing instead of a
custom recursive org.json conversion helper.

Co-Authored-By: GPT-5.5 <[email protected]>
Co-authored-by: Cursor <[email protected]>
Document that the Android JSON reader accepts root-level primitives
so future changes do not replace it with object-only parsing.

Co-Authored-By: GPT-5.5 <[email protected]>
Co-authored-by: Cursor <[email protected]>
Route Android context values through sentry-java's typed overloads so
primitive Dart context values are serialized as valid context objects.
Regenerate JNI bindings after removing the unused object overload entrypoint.

Co-Authored-By: GPT-5.5 <[email protected]>
Co-authored-by: Cursor <[email protected]>
Release replay callback JNI handles after processing replay privacy
options, and avoid creating duplicate JNI strings when loading debug
images. This reduces leaked global refs in Android replay and debug image
paths.

Fixes #3696
Co-Authored-By: GPT-5.5 <[email protected]>

Co-authored-by: Cursor <[email protected]>
@github-actions

github-actions Bot commented May 19, 2026

Copy link
Copy Markdown
Contributor

Semver Impact of This PR

🟢 Patch (bug fixes)

📋 Changelog Preview

This is how your changes will appear in the changelog.
Entries from this PR are highlighted with a left border (blockquote style).


Fixes

Dart

  • Make sentryOnError synchronous in runZonedGuarded by theprantadutta in #3697
  • Route SDK diagnostic logs to browser console on web by theprantadutta in #3698

Flutter

  • Release Android JNI refs by buenaflor in #3712
  • Release replay JNI refs by buenaflor in #3699

Enhancements

  • (flutter) Optimize Android scope sync by buenaflor in #3708

Internal Changes

  • (flutter) Align CI with stable SwiftPM defaults by buenaflor in #3710

🤖 This preview updates automatically when you update the PR.

@codecov

codecov Bot commented May 19, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 91.91%. Comparing base (79df88f) to head (696867d).
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #3712      +/-   ##
==========================================
+ Coverage   85.07%   91.91%   +6.83%     
==========================================
  Files         238      105     -133     
  Lines        8485     3737    -4748     
==========================================
- Hits         7219     3435    -3784     
+ Misses       1266      302     -964     
Flag Coverage Δ
sentry ?
sentry_dio ?
sentry_drift ?
sentry_file ?
sentry_firebase_remote_config 100.00% <ø> (ø)
sentry_flutter 91.53% <ø> (?)
sentry_hive ?
sentry_isar ?
sentry_link ?
sentry_logging ?
sentry_sqflite ?
sentry_supabase 97.27% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

buenaflor and others added 2 commits May 19, 2026 16:35
Update the native context sync test to match the Android bridge's
valid serialized shape for primitive context values.

Co-Authored-By: GPT-5.5 <[email protected]>
Co-authored-by: Cursor <[email protected]>
Use a single arena for replay callback JNI temporaries and avoid
releasing map keys before removing privacy options from the payload.

Refs #3696
Co-Authored-By: GPT-5.5 <[email protected]>

Co-authored-by: Cursor <[email protected]>
@buenaflor
buenaflor marked this pull request as ready for review May 19, 2026 16:26
@buenaflor
buenaflor requested a review from denrase as a code owner May 19, 2026 16:26
Copilot AI review requested due to automatic review settings May 19, 2026 16:26

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

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 tightens JNI reference cleanup in the Android-specific Flutter paths to prevent global ref retention while replay and debug-image loading are active. It deduplicates instruction addresses before allocating JString refs, switches the rrweb privacy-option mutation to explicit arena-managed handles (replacing removeWhere with a manual iterator so each materialized key/value can be released), and releases the replay callback's hint reference once the callback finishes.

Changes:

  • In loadDebugImages, dedupe instruction-address strings before converting them to JString, so duplicate addresses don't produce duplicate JNI refs that all need to be released.
  • In createBeforeSendReplayCallback, route every intermediate JNI object (replayRecording, payload data, optionsEvent, options payload map, key iterator and current keys, removed values, and jMap) through the existing arena.
  • Wrap the callback body in try/finally so hint.release() is always invoked after processing.

Reviewed changes

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

File Description
packages/flutter/lib/src/native/java/sentry_native_java.dart Deduplicate instruction addresses before allocating JString refs in loadDebugImages.
packages/flutter/lib/src/native/java/sentry_native_java_init.dart Arena-release intermediate replay JNI handles and release the hint ref in a finally block in the BeforeSendReplay callback.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Base automatically changed from perf/android-scope-sync-json to main May 19, 2026 22:25
@github-actions

Copy link
Copy Markdown
Contributor

Android Performance metrics 🚀

  Plain With Sentry Diff
Startup time 368.82 ms 355.98 ms -12.84 ms
Size 14.55 MiB 15.87 MiB 1.31 MiB

Baseline results on branch: main

Startup times

Revision Plain With Sentry Diff
fec56a1 370.66 ms 369.28 ms -1.38 ms
ea06d23 371.10 ms 358.56 ms -12.54 ms
dbd526b 504.88 ms 569.02 ms 64.15 ms
2cf9161 454.12 ms 512.67 ms 58.55 ms
640ad0c 466.00 ms 552.67 ms 86.67 ms
79f6b41 469.66 ms 525.90 ms 56.24 ms
40751bf 421.09 ms 424.82 ms 3.73 ms
13b8895 372.91 ms 370.13 ms -2.79 ms
cba2765 405.98 ms 422.02 ms 16.04 ms
a920b0f 391.75 ms 378.98 ms -12.77 ms

App size

Revision Plain With Sentry Diff
fec56a1 14.31 MiB 15.49 MiB 1.19 MiB
ea06d23 13.93 MiB 15.18 MiB 1.25 MiB
dbd526b 6.54 MiB 7.69 MiB 1.15 MiB
2cf9161 6.54 MiB 7.70 MiB 1.16 MiB
640ad0c 6.54 MiB 7.69 MiB 1.15 MiB
79f6b41 6.54 MiB 7.69 MiB 1.15 MiB
40751bf 14.31 MiB 15.49 MiB 1.19 MiB
13b8895 13.93 MiB 15.18 MiB 1.25 MiB
cba2765 13.93 MiB 15.18 MiB 1.25 MiB
a920b0f 14.09 MiB 15.28 MiB 1.19 MiB

Previous results on branch: buenaflor/fix/flutter-android-jni-leaks

Startup times

Revision Plain With Sentry Diff
f4d8991 351.66 ms 342.51 ms -9.15 ms
68412d3 430.18 ms 442.46 ms 12.27 ms

App size

Revision Plain With Sentry Diff
f4d8991 14.55 MiB 15.87 MiB 1.31 MiB
68412d3 14.55 MiB 15.87 MiB 1.31 MiB

@github-actions

Copy link
Copy Markdown
Contributor

iOS Performance metrics 🚀

  Plain With Sentry Diff
Startup time 1252.49 ms 1257.56 ms 5.07 ms
Size 5.83 MiB 6.28 MiB 461.20 KiB

Baseline results on branch: main

Startup times

Revision Plain With Sentry Diff
79df88f 1255.16 ms 1263.51 ms 8.35 ms
765aa8b 1259.09 ms 1269.90 ms 10.82 ms
7cfee3b 1260.90 ms 1273.14 ms 12.24 ms
dc53d48 1252.06 ms 1255.61 ms 3.55 ms
027fcfd 1239.04 ms 1243.65 ms 4.61 ms
584a4b7 1235.62 ms 1233.62 ms -2.00 ms
396cb30 1265.06 ms 1265.73 ms 0.67 ms
e1ab497 1260.92 ms 1260.22 ms -0.69 ms
a920b0f 1254.67 ms 1261.62 ms 6.95 ms
640ad0c 1241.04 ms 1253.96 ms 12.92 ms

App size

Revision Plain With Sentry Diff
79df88f 5.83 MiB 6.28 MiB 461.20 KiB
765aa8b 7.86 MiB 9.44 MiB 1.58 MiB
7cfee3b 20.70 MiB 22.46 MiB 1.75 MiB
dc53d48 5.65 MiB 6.09 MiB 446.96 KiB
027fcfd 5.73 MiB 6.17 MiB 455.48 KiB
584a4b7 5.66 MiB 6.10 MiB 451.31 KiB
396cb30 5.53 MiB 6.01 MiB 488.17 KiB
e1ab497 5.53 MiB 6.01 MiB 487.96 KiB
a920b0f 5.66 MiB 6.10 MiB 453.28 KiB
640ad0c 7.86 MiB 9.44 MiB 1.58 MiB

Previous results on branch: buenaflor/fix/flutter-android-jni-leaks

Startup times

Revision Plain With Sentry Diff
68412d3 1235.45 ms 1240.79 ms 5.34 ms
f4d8991 1246.58 ms 1257.05 ms 10.47 ms

App size

Revision Plain With Sentry Diff
68412d3 5.83 MiB 6.28 MiB 461.19 KiB
f4d8991 5.83 MiB 6.28 MiB 461.20 KiB

Comment on lines +71 to +75
final instructionAddresses =
stackTrace.frames.map((f) => f.instructionAddr).nonNulls.toSet();

instructionAddressJStrings =
instructionAddresses.map((s) => s.toJString()).toSet();

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

dedupe before transforming to java string (micro optimization)

Comment on lines +68 to +98
final data = replayRecording?.getPayload()?.use(
(payload) => payload.firstOrNull,
)?..releasedBy(arena);
if (data?.isA(native.RRWebOptionsEvent.type) ?? false) {
final optionsEvent = data!.as(native.RRWebOptionsEvent.type)
..releasedBy(arena);
final payload = optionsEvent.getOptionsPayload()
..releasedBy(arena);

final jMap = dartToJMap(options.privacy.toJson());
payload?.addAll(jMap);
jMap.release();
}
});
return sentryReplayEvent;
final keys = payload.keys..releasedBy(arena);
final iterator = keys.iterator..releasedBy(arena);
final keysToRemove = <JString>[];
while (iterator.moveNext()) {
final key = iterator.current?..releasedBy(arena);
if (key?.toDartString().contains('mask') ?? false) {
keysToRemove.add(key!);
}
}

for (final key in keysToRemove) {
payload.remove(key)?.releasedBy(arena);
}

final jMap = dartToJMap(options.privacy.toJson())
..releasedBy(arena);
payload.addAll(jMap);
}
});
return sentryReplayEvent;
} finally {
hint.release();

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

release hint and consolidate to releasing via the arena

@buenaflor
buenaflor merged commit 3fe9e80 into main May 21, 2026
92 of 93 checks passed
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.

Investigate JNI for possible memory leaks and misuse

3 participants