Skip to content

Commit 9c98f86

Browse files
buenaflorcursoragentcodex
authored
fix(flutter): Release replay JNI refs on close (#3699)
* fix(flutter): Release replay JNI refs Release Android replay JNI references when worker isolates shut down and when replay integration handles are replaced. This prevents replay bitmap/config/native replay references from being retained after use. Fixes GH-3633 Co-Authored-By: Claude <noreply@anthropic.com> Co-authored-by: Cursor <cursoragent@cursor.com> * style: Format Dart files Apply formatter output to touched Dart files so the branch stays clean. Co-Authored-By: GPT-5.5 <noreply@openai.com> Co-authored-by: Cursor <cursoragent@cursor.com> * fix(flutter): Avoid replay JNI refs without worker hook Keep the isolate worker shutdown behavior unchanged and release the replay integration JNI handle around screenshot recording instead of adding a generic worker lifecycle callback. Co-Authored-By: GPT-5.5 <noreply@openai.com> Co-authored-by: Cursor <cursoragent@cursor.com> * ref(flutter): Use internal logger for JNI paths Route Android replay and native Java diagnostics through the shared internal logger instead of the options logging shim. Co-Authored-By: GPT-5.5 <noreply@openai.com> Co-authored-by: Cursor <cursoragent@cursor.com> * fix(flutter): Release replay bitmap refs Create replay bitmaps per capture and release their JNI references after recording so the replay worker does not retain bitmap handles. Co-Authored-By: GPT-5.5 <noreply@openai.com> Co-authored-by: Cursor <cursoragent@cursor.com> * ref(flutter): Simplify replay JNI cleanup Release the bitmap config reference in the same cleanup block as the other per-capture JNI references. Co-Authored-By: GPT-5.5 <noreply@openai.com> Co-authored-by: Cursor <cursoragent@cursor.com> * fix(flutter): Handle null replay bitmap Check the generated nullable bitmap creation result before using it so replay capture avoids a forced unwrap while preserving cleanup of JNI references. Co-Authored-By: GPT-5.5 <noreply@openai.com> Co-authored-by: Cursor <cursoragent@cursor.com> * style(flutter): Reorder replay recorder locals Keep the replay recorder local cleanup variables in use order. Co-Authored-By: GPT-5.5 <noreply@openai.com> Co-authored-by: Cursor <cursoragent@cursor.com> * fix(flutter): Clean up cached replay bitmap Keep the Android replay bitmap cached between same-size captures, but release it through a replay-specific worker request before the worker shuts down. Co-Authored-By: GPT-5.5 <noreply@openai.com> Co-authored-by: Cursor <cursoragent@cursor.com> * fix(flutter): Cache replay integration in worker Reuse the worker-side replay integration handle during Android replay capture and release it with the cached bitmap through the replay cleanup request. Co-Authored-By: GPT-5.5 <noreply@openai.com> Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: GPT-5.5 <noreply@openai.com>
1 parent 87fcdd8 commit 9c98f86

6 files changed

Lines changed: 149 additions & 69 deletions

File tree

packages/dart/lib/src/protocol/sentry_device.dart

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -420,8 +420,9 @@ class SentryDevice {
420420
}
421421
final deviceUniqueIdentifier = this.deviceUniqueIdentifier;
422422
if (deviceUniqueIdentifier != null) {
423-
attributes[SemanticAttributesConstants.deviceId] =
424-
SentryAttribute.string(deviceUniqueIdentifier);
423+
attributes[SemanticAttributesConstants.deviceId] = SentryAttribute.string(
424+
deviceUniqueIdentifier,
425+
);
425426
}
426427
return attributes;
427428
}

packages/dart/lib/src/telemetry/span/span_capture_pipeline.dart

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -31,17 +31,19 @@ class SpanCapturePipeline {
3131
span.addAttributesIfAbsent(scope.attributes);
3232
}
3333

34-
await _options.lifecycleRegistry
35-
.dispatchCallback<OnProcessSpan>(OnProcessSpan(span));
34+
await _options.lifecycleRegistry.dispatchCallback<OnProcessSpan>(
35+
OnProcessSpan(span),
36+
);
3637

3738
span.addAttributesIfAbsent(defaultAttributes(_options, scope: scope));
3839
span.addAttributesIfAbsent({
3940
SemanticAttributesConstants.sentrySegmentName:
4041
SentryAttribute.string(span.segmentSpan.name),
4142
SemanticAttributesConstants.sentryTransaction:
4243
SentryAttribute.string(span.segmentSpan.name),
43-
SemanticAttributesConstants.sentrySegmentId:
44-
SentryAttribute.string(span.segmentSpan.spanId.toString()),
44+
SemanticAttributesConstants.sentrySegmentId: SentryAttribute.string(
45+
span.segmentSpan.spanId.toString(),
46+
),
4547
});
4648

4749
final beforeSendSpan = _options.beforeSendSpan;
@@ -62,8 +64,11 @@ class SpanCapturePipeline {
6264

6365
_options.telemetryProcessor.addSpan(span);
6466
} catch (error, stackTrace) {
65-
internalLogger.error('Error while capturing span ${span.name}',
66-
error: error, stackTrace: stackTrace);
67+
internalLogger.error(
68+
'Error while capturing span ${span.name}',
69+
error: error,
70+
stackTrace: stackTrace,
71+
);
6772
if (_options.automatedTestMode) {
6873
rethrow;
6974
}

packages/flutter/lib/src/native/java/android_replay_recorder.dart

Lines changed: 95 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -51,33 +51,50 @@ class AndroidReplayRecorder extends ScheduledScreenshotRecorder {
5151
@override
5252
Future<void> stop() async {
5353
await super.stop();
54-
_worker?.close();
55-
_worker = null;
54+
final worker = _worker;
55+
try {
56+
await worker?.request(const _CloseRequest());
57+
} catch (error, stackTrace) {
58+
internalLogger.error(
59+
'$logName: native call `close` failed',
60+
error: error,
61+
stackTrace: stackTrace,
62+
);
63+
if (options.automatedTestMode) {
64+
rethrow;
65+
}
66+
} finally {
67+
worker?.close();
68+
_worker = null;
69+
}
5670
}
5771

5872
Future<void> _addReplayScreenshot(
59-
Screenshot screenshot, bool isNewlyCaptured) async {
73+
Screenshot screenshot,
74+
bool isNewlyCaptured,
75+
) async {
6076
final timestamp = screenshot.timestamp.millisecondsSinceEpoch;
6177

6278
try {
6379
final data = await screenshot.rawRgbaData;
64-
options.log(
65-
SentryLevel.debug,
66-
'$logName: captured screenshot ('
67-
'${screenshot.width}x${screenshot.height} pixels, '
68-
'${data.lengthInBytes} bytes)');
69-
70-
await _worker!.request(_WorkItem(
71-
timestamp: timestamp,
72-
data: data.buffer.asUint8List(),
73-
width: screenshot.width,
74-
height: screenshot.height,
75-
));
80+
internalLogger.debug(
81+
'$logName: captured screenshot ('
82+
'${screenshot.width}x${screenshot.height} pixels, '
83+
'${data.lengthInBytes} bytes)',
84+
);
85+
86+
await _worker!.request(
87+
_WorkItem(
88+
timestamp: timestamp,
89+
data: data.buffer.asUint8List(),
90+
width: screenshot.width,
91+
height: screenshot.height,
92+
),
93+
);
7694
} catch (error, stackTrace) {
77-
options.log(
78-
SentryLevel.error,
95+
internalLogger.error(
7996
'$logName: native call `addReplayScreenshot` failed',
80-
exception: error,
97+
error: error,
8198
stackTrace: stackTrace,
8299
);
83100
if (options.automatedTestMode) {
@@ -96,62 +113,101 @@ class _AndroidReplayHandler extends WorkerHandler {
96113
final WorkerConfig _config;
97114
// Android Bitmap creation is a bit costly so we reuse it between captures.
98115
native.Bitmap? _bitmap;
99-
late final native.ReplayIntegration _nativeReplay;
116+
native.ReplayIntegration? _nativeReplay;
100117

101-
_AndroidReplayHandler(this._config) {
102-
_nativeReplay =
103-
native.SentryFlutterPlugin.privateSentryGetReplayIntegration()!;
104-
}
118+
_AndroidReplayHandler(this._config);
105119

106120
@override
107121
FutureOr<void> onMessage(Object? message) {
108122
internalLogger.warning(
109-
'${_config.debugName}: Unexpected fire-and-forget message: $message');
123+
'${_config.debugName}: Unexpected fire-and-forget message: $message',
124+
);
110125
}
111126

112127
@override
113128
FutureOr<Object?> onRequest(Object? payload) {
129+
if (payload is _CloseRequest) {
130+
_releaseNativeRefs();
131+
return null;
132+
}
133+
114134
if (payload is! _WorkItem) {
115-
internalLogger
116-
.warning('${_config.debugName}: Unexpected payload type: $payload');
135+
internalLogger.warning(
136+
'${_config.debugName}: Unexpected payload type: $payload',
137+
);
117138
return null;
118139
}
119140

120141
final item = payload;
121142
JByteBuffer? jBuffer;
143+
native.Bitmap$Config? bitmapConfig;
122144

123145
try {
124-
if (_bitmap != null) {
125-
if (_bitmap!.getWidth() != item.width ||
126-
_bitmap!.getHeight() != item.height) {
127-
_bitmap!.release();
128-
_bitmap = null;
146+
var bitmap = _bitmap;
147+
if (bitmap != null) {
148+
if (bitmap.getWidth() != item.width ||
149+
bitmap.getHeight() != item.height) {
150+
_releaseBitmap();
151+
bitmap = null;
129152
}
130153
}
131154

132-
// https://developer.android.com/reference/android/graphics/Bitmap#createBitmap(int,%20int,%20android.graphics.Bitmap.Config)
133-
// Note: while the generated API is nullable, the docs say the returned value cannot be null..
134-
_bitmap ??= native.Bitmap.createBitmap$10(
135-
item.width, item.height, native.Bitmap$Config.ARGB_8888);
155+
if (bitmap == null) {
156+
// https://developer.android.com/reference/android/graphics/Bitmap#createBitmap(int,%20int,%20android.graphics.Bitmap.Config)
157+
// Note: while the generated API is nullable, the docs say the returned value cannot be null..
158+
bitmapConfig = native.Bitmap$Config.ARGB_8888;
159+
final newBitmap = native.Bitmap.createBitmap$10(
160+
item.width,
161+
item.height,
162+
bitmapConfig,
163+
);
164+
if (newBitmap == null) {
165+
internalLogger.error('Failed to create replay bitmap');
166+
return null;
167+
}
168+
_bitmap = newBitmap;
169+
bitmap = newBitmap;
170+
}
136171

137172
jBuffer = JByteBuffer.fromList(item.data);
138-
_bitmap!.copyPixelsFromBuffer(jBuffer);
173+
bitmap.copyPixelsFromBuffer(jBuffer);
139174

140175
// TODO timestamp is currently missing in onScreenshotRecorded()
141-
_nativeReplay.onScreenshotRecorded(_bitmap!);
176+
_nativeReplay ??=
177+
native.SentryFlutterPlugin.privateSentryGetReplayIntegration();
178+
_nativeReplay?.onScreenshotRecorded(bitmap);
142179

143180
return null;
144181
} catch (exception, stackTrace) {
145-
internalLogger.error('Failed to add replay screenshot',
146-
error: exception, stackTrace: stackTrace);
182+
internalLogger.error(
183+
'Failed to add replay screenshot',
184+
error: exception,
185+
stackTrace: stackTrace,
186+
);
147187
if (_config.automatedTestMode) {
148188
rethrow;
149189
}
150190
return null;
151191
} finally {
192+
bitmapConfig?.release();
152193
jBuffer?.release();
153194
}
154195
}
196+
197+
void _releaseNativeRefs() {
198+
_releaseBitmap();
199+
_nativeReplay?.release();
200+
_nativeReplay = null;
201+
}
202+
203+
void _releaseBitmap() {
204+
_bitmap?.release();
205+
_bitmap = null;
206+
}
207+
}
208+
209+
class _CloseRequest {
210+
const _CloseRequest();
155211
}
156212

157213
class _WorkItem {

packages/flutter/lib/src/native/java/sentry_native_java.dart

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import 'package:sentry/src/utils/iterable_utils.dart';
1111
import '../../../sentry_flutter.dart';
1212
import '../../replay/replay_config.dart';
1313
import '../../replay/scheduled_recorder_config.dart';
14+
import '../../utils/internal_logger.dart';
1415
import '../native_app_start.dart';
1516
import '../sentry_native_channel.dart';
1617
import '../utils/data_normalizer.dart';
@@ -44,6 +45,11 @@ class SentryNativeJava extends SentryNativeChannel {
4445
@visibleForTesting
4546
AndroidReplayRecorder? get testRecorder => _replayRecorder;
4647

48+
void _setNativeReplay(native.ReplayIntegration? nativeReplay) {
49+
_nativeReplay?.release();
50+
_nativeReplay = nativeReplay;
51+
}
52+
4753
@override
4854
void init(Hub hub) {
4955
initSentryAndroid(hub: hub, options: options, owner: this);
@@ -91,8 +97,11 @@ class SentryNativeJava extends SentryNativeChannel {
9197
final debugImageMaps = decodeUtf8JsonListOfMaps(bytes);
9298
return debugImageMaps.map(DebugImage.fromJson).toList(growable: false);
9399
} catch (exception, stackTrace) {
94-
options.log(SentryLevel.error, 'JNI: Failed to load debug images',
95-
exception: exception, stackTrace: stackTrace);
100+
internalLogger.error(
101+
'JNI: Failed to load debug images',
102+
error: exception,
103+
stackTrace: stackTrace,
104+
);
96105
if (options.automatedTestMode) {
97106
rethrow;
98107
}
@@ -127,8 +136,11 @@ class SentryNativeJava extends SentryNativeChannel {
127136
byteRange.buffer, byteRange.offsetInBytes, byteRange.length);
128137
return decodeUtf8JsonMap(bytes);
129138
} catch (exception, stackTrace) {
130-
options.log(SentryLevel.error, 'JNI: Failed to load contexts',
131-
exception: exception, stackTrace: stackTrace);
139+
internalLogger.error(
140+
'JNI: Failed to load contexts',
141+
error: exception,
142+
stackTrace: stackTrace,
143+
);
132144
if (options.automatedTestMode) {
133145
rethrow;
134146
}
@@ -187,7 +199,7 @@ class SentryNativeJava extends SentryNativeChannel {
187199
Future<void> close() async {
188200
await _replayRecorder?.stop();
189201
await _envelopeSender?.close();
190-
_nativeReplay?.release();
202+
_setNativeReplay(null);
191203
return super.close();
192204
}
193205

@@ -335,13 +347,13 @@ class SentryNativeJava extends SentryNativeChannel {
335347
config.windowWidth == 0.0 ||
336348
config.windowHeight == 0.0;
337349
if (invalidConfig) {
338-
options.log(
339-
SentryLevel.error,
340-
'Replay config is not valid: '
341-
'width: ${config.width}, '
342-
'height: ${config.height}, '
343-
'windowWidth: ${config.windowWidth}, '
344-
'windowHeight: ${config.windowHeight}');
350+
internalLogger.error(
351+
'Replay config is not valid: '
352+
'width: ${config.width}, '
353+
'height: ${config.height}, '
354+
'windowWidth: ${config.windowWidth}, '
355+
'windowHeight: ${config.windowHeight}',
356+
);
345357
return;
346358
}
347359

packages/flutter/lib/src/native/java/sentry_native_java_init.dart

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ void initSentryAndroid({
2424
final context = native.SentryFlutterPlugin.getApplicationContext()
2525
?..releasedBy(arena);
2626
if (context == null) {
27-
options.log(SentryLevel.error,
27+
internalLogger.error(
2828
'Failed to initialize Sentry Android, application context is null.');
2929
return;
3030
}
@@ -107,8 +107,9 @@ native.ReplayRecorderCallbacks? createReplayRecorderCallbacks({
107107
SentryId.fromId(replayIdString.toDartString(releaseOriginal: true));
108108

109109
owner._replayId = replayId;
110-
owner._nativeReplay =
111-
native.SentryFlutterPlugin.privateSentryGetReplayIntegration();
110+
owner._setNativeReplay(
111+
native.SentryFlutterPlugin.privateSentryGetReplayIntegration(),
112+
);
112113
owner._replayRecorder = AndroidReplayRecorder.factory(options);
113114
await owner._replayRecorder!.start();
114115
hub.configureScope((s) {
@@ -131,6 +132,7 @@ native.ReplayRecorderCallbacks? createReplayRecorderCallbacks({
131132
final future = owner._replayRecorder?.stop();
132133
owner._replayRecorder = null;
133134
await future;
135+
owner._setNativeReplay(null);
134136
},
135137
replayReset: () {
136138
// ignored
@@ -236,17 +238,18 @@ void configureAndroidOptions({
236238

237239
native.SdkVersion? sdkVersion = androidOptions.getSdkVersion()
238240
?..releasedBy(arena);
241+
final versionName = native.BuildConfig.VERSION_NAME!..releasedBy(arena);
242+
final versionNameString = versionName.toDartString();
239243
if (sdkVersion == null) {
240244
sdkVersion = native.SdkVersion(
241245
androidSdkName.toJString()..releasedBy(arena),
242-
native.BuildConfig.VERSION_NAME!..releasedBy(arena),
246+
versionName,
243247
)..releasedBy(arena);
244248
} else {
245249
sdkVersion.setName(androidSdkName.toJString()..releasedBy(arena));
246250
}
247251
androidOptions.setSentryClientName(
248-
'$androidSdkName/${native.BuildConfig.VERSION_NAME}'.toJString()
249-
..releasedBy(arena));
252+
'$androidSdkName/$versionNameString'.toJString()..releasedBy(arena));
250253
androidOptions
251254
.setNativeSdkName(nativeSdkName.toJString()..releasedBy(arena));
252255
for (final integration in options.sdk.integrations) {

0 commit comments

Comments
 (0)