Skip to content

Commit b163866

Browse files
buenaflorcodexcursoragent
committed
fix(flutter): Serialize Android scope updates
Route paired Android scope mutations through the same worker queue so call order is preserved across breadcrumb and context updates. Handle worker startup failures without delaying native SDK initialization. Co-Authored-By: GPT-5.5 <noreply@openai.com> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent f4d9e97 commit b163866

4 files changed

Lines changed: 199 additions & 12 deletions

File tree

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

Lines changed: 100 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,15 +52,22 @@ class AndroidCoreWorker {
5252
return;
5353
}
5454
_worker = worker;
55+
} catch (exception, stackTrace) {
56+
internalLogger.error(
57+
'Failed to start Android core worker',
58+
error: exception,
59+
stackTrace: stackTrace,
60+
);
5561
} finally {
5662
_startFuture = null;
5763
}
5864
}
5965

60-
FutureOr<void> close() {
66+
FutureOr<void> close() async {
67+
_isClosed = true;
68+
await _startFuture;
6169
_worker?.close();
6270
_worker = null;
63-
_isClosed = true;
6471
}
6572

6673
void captureEnvelope(
@@ -183,6 +190,25 @@ class AndroidCoreWorker {
183190
'add breadcrumb',
184191
);
185192

193+
FutureOr<void> clearBreadcrumbs() {
194+
if (_isClosed) return null;
195+
196+
final client = _worker;
197+
if (client == null) {
198+
_clearBreadcrumbs(automatedTestMode: _config.automatedTestMode);
199+
return null;
200+
}
201+
202+
return _clearBreadcrumbsFromWorker(client);
203+
}
204+
205+
Future<void> _clearBreadcrumbsFromWorker(Worker client) =>
206+
_sendScopeUpdateToWorker(
207+
client,
208+
const _ClearBreadcrumbsRequest(),
209+
'clear breadcrumbs',
210+
);
211+
186212
FutureOr<void> setUser(SentryUser? user) {
187213
if (_isClosed) return null;
188214

@@ -232,6 +258,28 @@ class AndroidCoreWorker {
232258
'set context',
233259
);
234260

261+
FutureOr<void> removeContexts(String key) {
262+
if (_isClosed) return null;
263+
264+
final client = _worker;
265+
if (client == null) {
266+
_removeContexts(key, automatedTestMode: _config.automatedTestMode);
267+
return null;
268+
}
269+
270+
return _removeContextsFromWorker(client, key);
271+
}
272+
273+
Future<void> _removeContextsFromWorker(
274+
Worker client,
275+
String key,
276+
) =>
277+
_sendScopeUpdateToWorker(
278+
client,
279+
_RemoveContextsRequest(key),
280+
'remove context',
281+
);
282+
235283
Future<void> _sendScopeUpdateToWorker(
236284
Worker client,
237285
Object request,
@@ -273,12 +321,17 @@ class _AndroidCoreWorkerHandler extends WorkerHandler {
273321
case _AddBreadcrumbRequest request:
274322
_addBreadcrumb(request.breadcrumb,
275323
automatedTestMode: _config.automatedTestMode);
324+
case _ClearBreadcrumbsRequest _:
325+
_clearBreadcrumbs(automatedTestMode: _config.automatedTestMode);
276326
case _SetUserRequest request:
277327
_setUser(request.user,
278328
automatedTestMode: _config.automatedTestMode);
279329
case _SetContextsRequest request:
280330
_setContexts(request.key, request.value,
281331
automatedTestMode: _config.automatedTestMode);
332+
case _RemoveContextsRequest request:
333+
_removeContexts(request.key,
334+
automatedTestMode: _config.automatedTestMode);
282335
default:
283336
_unexpectedMessage(msg);
284337
}
@@ -298,6 +351,9 @@ class _AndroidCoreWorkerHandler extends WorkerHandler {
298351
_addBreadcrumb(request.breadcrumb,
299352
automatedTestMode: _config.automatedTestMode);
300353
return null;
354+
case _ClearBreadcrumbsRequest _:
355+
_clearBreadcrumbs(automatedTestMode: _config.automatedTestMode);
356+
return null;
301357
case _SetUserRequest request:
302358
_setUser(request.user,
303359
automatedTestMode: _config.automatedTestMode);
@@ -306,6 +362,10 @@ class _AndroidCoreWorkerHandler extends WorkerHandler {
306362
_setContexts(request.key, request.value,
307363
automatedTestMode: _config.automatedTestMode);
308364
return null;
365+
case _RemoveContextsRequest request:
366+
_removeContexts(request.key,
367+
automatedTestMode: _config.automatedTestMode);
368+
return null;
309369
default:
310370
return _unexpectedPayload(payload);
311371
}
@@ -354,6 +414,10 @@ class _AddBreadcrumbRequest {
354414
const _AddBreadcrumbRequest(this.breadcrumb);
355415
}
356416

417+
class _ClearBreadcrumbsRequest {
418+
const _ClearBreadcrumbsRequest();
419+
}
420+
357421
class _SetUserRequest {
358422
final Map<String, dynamic>? user;
359423

@@ -367,6 +431,12 @@ class _SetContextsRequest {
367431
const _SetContextsRequest(this.key, this.value);
368432
}
369433

434+
class _RemoveContextsRequest {
435+
final String key;
436+
437+
const _RemoveContextsRequest(this.key);
438+
}
439+
370440
void _captureEnvelope(Uint8List envelopeData, bool containsUnhandledException,
371441
{bool automatedTestMode = false}) {
372442
JObject? id;
@@ -487,6 +557,18 @@ void _addBreadcrumb(Map<String, dynamic> breadcrumb,
487557
}
488558
}
489559

560+
void _clearBreadcrumbs({bool automatedTestMode = false}) {
561+
try {
562+
native.Sentry.clearBreadcrumbs();
563+
} catch (exception, stackTrace) {
564+
internalLogger.error('JNI: Failed to clear breadcrumbs',
565+
error: exception, stackTrace: stackTrace);
566+
if (automatedTestMode) {
567+
rethrow;
568+
}
569+
}
570+
}
571+
490572
void _setUser(Map<String, dynamic>? user, {bool automatedTestMode = false}) {
491573
JByteArray? jBytes;
492574
try {
@@ -527,6 +609,22 @@ void _setContexts(String key, Object? value, {bool automatedTestMode = false}) {
527609
}
528610
}
529611

612+
void _removeContexts(String key, {bool automatedTestMode = false}) {
613+
JString? jKey;
614+
try {
615+
jKey = key.toJString();
616+
native.SentryFlutterPlugin.removeContext(jKey);
617+
} catch (exception, stackTrace) {
618+
internalLogger.error('JNI: Failed to remove context',
619+
error: exception, stackTrace: stackTrace);
620+
if (automatedTestMode) {
621+
rethrow;
622+
}
623+
} finally {
624+
jKey?.release();
625+
}
626+
}
627+
530628
JByteArray _jsonToJByteArray(Object? value) =>
531629
JByteArray.from(encodeUtf8Json(_normalizeJson(value)));
532630

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

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -125,9 +125,7 @@ class SentryNativeJava extends SentryNativeChannel {
125125
_coreWorker?.addBreadcrumb(breadcrumb);
126126

127127
@override
128-
void clearBreadcrumbs() => tryCatchSync('clearBreadcrumbs', () {
129-
native.Sentry.clearBreadcrumbs();
130-
});
128+
FutureOr<void> clearBreadcrumbs() => _coreWorker?.clearBreadcrumbs();
131129

132130
@override
133131
FutureOr<void> setUser(SentryUser? user) => _coreWorker?.setUser(user);
@@ -137,13 +135,7 @@ class SentryNativeJava extends SentryNativeChannel {
137135
_coreWorker?.setContexts(key, value);
138136

139137
@override
140-
void removeContexts(String key) => tryCatchSync('removeContexts', () {
141-
using((arena) {
142-
final jKey = key.toJString()..releasedBy(arena);
143-
144-
native.SentryFlutterPlugin.removeContext(jKey);
145-
});
146-
});
138+
FutureOr<void> removeContexts(String key) => _coreWorker?.removeContexts(key);
147139

148140
@override
149141
void setTag(String key, String value) => tryCatchSync('setTag', () {

packages/flutter/test/native/android_core_worker_test_real.dart

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,69 @@ void main() {
8080
expect(() => worker.close(), returnsNormally);
8181
});
8282

83+
test('logs when start fails', () async {
84+
final options = SentryFlutterOptions();
85+
final logs = <(SentryLevel, String)>[];
86+
SentryInternalLogger.configure(
87+
isEnabled: true,
88+
minLevel: SentryLevel.debug,
89+
logOutput: ({
90+
required String name,
91+
required SentryLevel level,
92+
required String message,
93+
Object? error,
94+
StackTrace? stackTrace,
95+
}) {
96+
logs.add((level, message.toString()));
97+
},
98+
);
99+
100+
Future<Worker> fakeSpawn(WorkerConfig config, WorkerEntry entry) async {
101+
throw StateError('spawn failed');
102+
}
103+
104+
final worker = AndroidCoreWorker(options, spawn: fakeSpawn);
105+
await worker.start();
106+
107+
expect(
108+
logs.any((e) =>
109+
e.$1 == SentryLevel.error &&
110+
e.$2.contains('Failed to start Android core worker')),
111+
isTrue,
112+
);
113+
});
114+
115+
test('close waits for in-flight start', () async {
116+
final options = SentryFlutterOptions();
117+
final spawnCompleter = Completer<Worker>();
118+
late ReceivePort inbox;
119+
late ReceivePort replies;
120+
121+
Future<Worker> fakeSpawn(WorkerConfig config, WorkerEntry entry) {
122+
inbox = ReceivePort();
123+
addTearDown(inbox.close);
124+
replies = ReceivePort();
125+
addTearDown(replies.close);
126+
return spawnCompleter.future;
127+
}
128+
129+
final worker = AndroidCoreWorker(options, spawn: fakeSpawn);
130+
unawaited(Future<void>.value(worker.start()));
131+
132+
final closeFuture = Future<void>.value(worker.close());
133+
var closeCompleted = false;
134+
unawaited(closeFuture.then((_) => closeCompleted = true));
135+
136+
await pumpEventQueue();
137+
expect(closeCompleted, isFalse);
138+
139+
spawnCompleter.complete(Worker(inbox.sendPort, replies));
140+
141+
await closeFuture;
142+
expect(closeCompleted, isTrue);
143+
expect(await inbox.first, '_shutdown_');
144+
});
145+
83146
test('sends envelope capture request after start', () async {
84147
final options = SentryFlutterOptions();
85148
options.debug = true;
@@ -269,6 +332,18 @@ void main() {
269332
});
270333
});
271334

335+
test('sends breadcrumb clear request and awaits response', () async {
336+
final fixture = _Fixture();
337+
final worker = fixture.getSut();
338+
await worker.start();
339+
340+
final payload = await fixture.expectPendingRequest(
341+
worker.clearBreadcrumbs(),
342+
);
343+
344+
expect(payload.runtimeType.toString(), '_ClearBreadcrumbsRequest');
345+
});
346+
272347
test('sends user update and awaits response', () async {
273348
final fixture = _Fixture();
274349
final worker = fixture.getSut();
@@ -320,6 +395,18 @@ void main() {
320395
'nested': {'value': true}
321396
});
322397
});
398+
399+
test('sends context remove request and awaits response', () async {
400+
final fixture = _Fixture();
401+
final worker = fixture.getSut();
402+
await worker.start();
403+
404+
final payload = await fixture.expectPendingRequest(
405+
worker.removeContexts('fixture-key'),
406+
);
407+
408+
expect((payload as dynamic).key, 'fixture-key');
409+
});
323410
});
324411
}
325412

packages/flutter/test/native/sentry_native_java_test_real.dart

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,11 @@ class _FakeCoreWorker implements AndroidCoreWorker {
121121
// No-op for testing
122122
}
123123

124+
@override
125+
FutureOr<void> clearBreadcrumbs() {
126+
// No-op for testing
127+
}
128+
124129
@override
125130
void setUser(SentryUser? user) {
126131
// No-op for testing
@@ -130,4 +135,9 @@ class _FakeCoreWorker implements AndroidCoreWorker {
130135
void setContexts(String key, value) {
131136
// No-op for testing
132137
}
138+
139+
@override
140+
FutureOr<void> removeContexts(String key) {
141+
// No-op for testing
142+
}
133143
}

0 commit comments

Comments
 (0)