From 03f81187e23a01845d06f3b0719ebfbf48dfc7aa Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Wed, 3 Sep 2025 01:25:16 +0200 Subject: [PATCH 01/30] Update --- .../native/java/android_envelope_worker.dart | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 packages/flutter/lib/src/native/java/android_envelope_worker.dart diff --git a/packages/flutter/lib/src/native/java/android_envelope_worker.dart b/packages/flutter/lib/src/native/java/android_envelope_worker.dart new file mode 100644 index 0000000000..b87d8cf92f --- /dev/null +++ b/packages/flutter/lib/src/native/java/android_envelope_worker.dart @@ -0,0 +1,96 @@ +class AndroidEnvelopeWorker extends WorkerIsolate { + AndroidEnvelopeWorker(super.config); + + static Future spawn(WorkerConfig config) async { + // 1) Create a ReceivePort the worker can talk to immediately. + final init = ReceivePort(); + + // 2) Pass BOTH the config and init.sendPort into the isolate. + await Isolate.spawn<(WorkerConfig, SendPort)>( + AndroidEnvelopeWorker.entryPoint, + (config, init.sendPort), + debugName: 'SentryAndroidEnvelopeWorker', + ); + + // 3) First message from worker is its inbox SendPort. + final SendPort workerInbox = await init.first as SendPort; + return workerInbox; + } + + void startMessageLoop() { + final receivePort = ReceivePort(); + + // Handshake: tell host how to send messages to this worker. + hostPort.send(receivePort.sendPort); + + receivePort.listen((message) { + try { + processMessage(message); + } catch (e, st) { + // sendError(e, st); + } + }); + } + + void processMessage(dynamic message) { + IsolateDiagnosticLog.log(SentryLevel.warning, + 'EnvelopeWorker invoked; starting captureEnvelope'); + + if (message is TransferableTypedData) { + final envelopeData = message.materialize().asUint8List(); + _captureEnvelope(envelopeData, false); + } + } + + void _captureEnvelope( + Uint8List envelopeData, bool containsUnhandledException) { + JObject? id; + JByteArray? byteArray; + try { + byteArray = JByteArray.from(envelopeData); + id = native.InternalSentrySdk.captureEnvelope( + byteArray, containsUnhandledException); + + if (id == null) { + IsolateDiagnosticLog.log(SentryLevel.error, + 'Native Android SDK returned null id when capturing envelope'); + } + } catch (exception, stackTrace) { + IsolateDiagnosticLog.log(SentryLevel.error, 'Failed to capture envelope', + exception: exception, stackTrace: stackTrace); + // if (options.automatedTestMode) { + // rethrow; + // } + } finally { + byteArray?.release(); + id?.release(); + } + } + + void send(Object message) => hostPort.send(message); + + static void entryPoint((WorkerConfig, SendPort) args) { + final (config, hostPort) = args; + + final level = config.environment['logLevel'] as SentryLevel; + final debug = config.environment['debug'] as bool; + IsolateDiagnosticLog.configure(debug: debug, level: level); + IsolateDiagnosticLog.log( + SentryLevel.warning, 'AndroidEnvelopeWorker started'); + + // Construct worker with the hostPort we just received. + final worker = AndroidEnvelopeWorker(config); + + // Start loop and complete the handshake by sending our inbox SendPort. + final receivePort = ReceivePort(); + hostPort.send(receivePort.sendPort); // <- completes init.first in spawn() + + // Option A: reuse startMessageLoop’s listener: + receivePort.listen(worker.processMessage); + + // Option B: if you prefer your existing method, you can: + // worker.startMessageLoop(); + // but then remove the duplicate handshake above from startMessageLoop, or + // let startMessageLoop accept the already-created receivePort. + } +} From 6928f3a92a3c43f0b1c64618f7445dc164bdd84a Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Wed, 3 Sep 2025 15:03:19 +0200 Subject: [PATCH 02/30] Update --- .../native/java/android_envelope_worker.dart | 107 +++++----- .../src/native/java/sentry_native_java.dart | 31 +-- packages/flutter/lib/src/worker_isolate.dart | 201 ++++++++++++++++++ 3 files changed, 268 insertions(+), 71 deletions(-) create mode 100644 packages/flutter/lib/src/worker_isolate.dart diff --git a/packages/flutter/lib/src/native/java/android_envelope_worker.dart b/packages/flutter/lib/src/native/java/android_envelope_worker.dart index b87d8cf92f..7e2d76e88e 100644 --- a/packages/flutter/lib/src/native/java/android_envelope_worker.dart +++ b/packages/flutter/lib/src/native/java/android_envelope_worker.dart @@ -1,38 +1,69 @@ -class AndroidEnvelopeWorker extends WorkerIsolate { - AndroidEnvelopeWorker(super.config); +import 'dart:async'; +import 'dart:isolate'; +import 'dart:typed_data'; - static Future spawn(WorkerConfig config) async { - // 1) Create a ReceivePort the worker can talk to immediately. - final init = ReceivePort(); +import 'package:jni/jni.dart'; +import 'package:meta/meta.dart'; - // 2) Pass BOTH the config and init.sendPort into the isolate. - await Isolate.spawn<(WorkerConfig, SendPort)>( - AndroidEnvelopeWorker.entryPoint, - (config, init.sendPort), +import '../../../sentry_flutter.dart'; +import '../../worker_isolate.dart'; +import 'binding.dart' as native; + +/// Host-side proxy for the Android envelope worker isolate. +class AndroidEnvelopeWorker { + AndroidEnvelopeWorker(this._options); + + final SentryFlutterOptions _options; + + WorkerClient? _client; + + @internal // visible for testing/mocking + static AndroidEnvelopeWorker Function(SentryFlutterOptions) factory = + AndroidEnvelopeWorker.new; + + Future start() async { + if (_client != null) return; + final config = WorkerConfig( + debug: _options.debug, + logLevel: _options.diagnosticLevel, debugName: 'SentryAndroidEnvelopeWorker', ); - - // 3) First message from worker is its inbox SendPort. - final SendPort workerInbox = await init.first as SendPort; - return workerInbox; + final (_, port) = await WorkerIsolate.spawn( + config, + AndroidEnvelopeWorkerIsolate.entryPoint, + ); + _client = WorkerClient(port); } - void startMessageLoop() { - final receivePort = ReceivePort(); + Future stop() async { + _close(); + } - // Handshake: tell host how to send messages to this worker. - hostPort.send(receivePort.sendPort); + /// Fire-and-forget send of envelope bytes to the worker. + void captureEnvelope(Uint8List envelopeData) { + final client = _client; + if (client == null) { + _options.log( + SentryLevel.warning, + 'AndroidEnvelopeWorker.captureEnvelope called before start; dropping', + ); + return; + } + client.send(TransferableTypedData.fromList([envelopeData])); + } - receivePort.listen((message) { - try { - processMessage(message); - } catch (e, st) { - // sendError(e, st); - } - }); + void _close() { + _client?.close(); + _client = null; } +} + +/// Worker isolate implementation handling envelope capture via JNI. +class AndroidEnvelopeWorkerIsolate extends WorkerIsolate { + AndroidEnvelopeWorkerIsolate(super.host); - void processMessage(dynamic message) { + @override + FutureOr handleMessage(Object? message) { IsolateDiagnosticLog.log(SentryLevel.warning, 'EnvelopeWorker invoked; starting captureEnvelope'); @@ -67,30 +98,8 @@ class AndroidEnvelopeWorker extends WorkerIsolate { } } - void send(Object message) => hostPort.send(message); - static void entryPoint((WorkerConfig, SendPort) args) { - final (config, hostPort) = args; - - final level = config.environment['logLevel'] as SentryLevel; - final debug = config.environment['debug'] as bool; - IsolateDiagnosticLog.configure(debug: debug, level: level); - IsolateDiagnosticLog.log( - SentryLevel.warning, 'AndroidEnvelopeWorker started'); - - // Construct worker with the hostPort we just received. - final worker = AndroidEnvelopeWorker(config); - - // Start loop and complete the handshake by sending our inbox SendPort. - final receivePort = ReceivePort(); - hostPort.send(receivePort.sendPort); // <- completes init.first in spawn() - - // Option A: reuse startMessageLoop’s listener: - receivePort.listen(worker.processMessage); - - // Option B: if you prefer your existing method, you can: - // worker.startMessageLoop(); - // but then remove the duplicate handshake above from startMessageLoop, or - // let startMessageLoop accept the already-created receivePort. + final (config, host) = args; + WorkerIsolate.bootstrap(config, host, AndroidEnvelopeWorkerIsolate(host)); } } diff --git a/packages/flutter/lib/src/native/java/sentry_native_java.dart b/packages/flutter/lib/src/native/java/sentry_native_java.dart index 0c57f79179..509a86d66f 100644 --- a/packages/flutter/lib/src/native/java/sentry_native_java.dart +++ b/packages/flutter/lib/src/native/java/sentry_native_java.dart @@ -6,8 +6,10 @@ import 'package:meta/meta.dart'; import '../../../sentry_flutter.dart'; import '../../replay/scheduled_recorder_config.dart'; +import '../../worker_isolate.dart'; import '../sentry_native_channel.dart'; import '../utils/utf8_json.dart'; +import 'android_envelope_worker.dart'; import 'android_replay_recorder.dart'; import 'binding.dart' as native; @@ -71,34 +73,18 @@ class SentryNativeJava extends SentryNativeChannel { }); } + envelopeWorker = AndroidEnvelopeWorker.factory(options); + await envelopeWorker.start(); + return super.init(hub); } + late AndroidEnvelopeWorker envelopeWorker; + @override FutureOr captureEnvelope( Uint8List envelopeData, bool containsUnhandledException) { - JObject? id; - JByteArray? byteArray; - try { - byteArray = JByteArray.from(envelopeData); - id = native.InternalSentrySdk.captureEnvelope( - byteArray, containsUnhandledException); - - if (id == null) { - options.log(SentryLevel.error, - 'Native Android SDK returned null id when capturing envelope'); - } - } catch (exception, stackTrace) { - options.log(SentryLevel.error, 'Failed to capture envelope', - exception: exception, stackTrace: stackTrace); - - if (options.automatedTestMode) { - rethrow; - } - } finally { - byteArray?.release(); - id?.release(); - } + envelopeWorker.captureEnvelope(envelopeData); } @override @@ -189,6 +175,7 @@ class SentryNativeJava extends SentryNativeChannel { @override Future close() async { await _replayRecorder?.stop(); + envelopeWorker.close(); return super.close(); } } diff --git a/packages/flutter/lib/src/worker_isolate.dart b/packages/flutter/lib/src/worker_isolate.dart new file mode 100644 index 0000000000..db15ab3932 --- /dev/null +++ b/packages/flutter/lib/src/worker_isolate.dart @@ -0,0 +1,201 @@ +import 'dart:developer' as developer; +import 'dart:async'; +import 'dart:isolate'; + +import 'package:meta/meta.dart'; + +import '../sentry_flutter.dart'; + +class WorkerConfig { + final bool debug; + final SentryLevel logLevel; + final String? debugName; + + const WorkerConfig({ + required this.debug, + required this.logLevel, + this.debugName, + }); +} + +class IsolateDiagnosticLog { + IsolateDiagnosticLog._(); + + static late final bool _debug; + static late final SentryLevel _level; + + static void configure({required bool debug, required SentryLevel level}) { + _debug = debug; + _level = level; + } + + static void log( + SentryLevel level, + String message, { + String? logger, + Object? exception, + StackTrace? stackTrace, + }) { + if (_isEnabled(level)) { + developer.log( + '[${level.name}] $message', + level: level.toDartLogLevel(), + name: logger ?? 'sentry', + time: DateTime.now(), + error: exception, + stackTrace: stackTrace, + ); + } + } + + static bool _isEnabled(SentryLevel level) { + return _debug && level.ordinal >= _level.ordinal || + level == SentryLevel.fatal; + } +} + +/// Unified V3 worker API combining the robustness of the native replay worker +/// pattern (request/response with correlation IDs) with the minimal +/// WorkerIsolateBase bootstrap/spawn flow. +abstract class WorkerIsolate { + static const String shutdownMessage = 'shutdown'; + + @protected + final SendPort hostPort; + + WorkerIsolate(this.hostPort); + + /// Handle fire-and-forget messages from host → worker. + FutureOr handleMessage(Object? message); + + /// Handle a request expecting a response. Default implementation returns null. + FutureOr handleRequest(Object? payload) => null; + + /// Worker-side bootstrap: configures logging, handshakes, starts loop. + static void bootstrap( + WorkerConfig config, + SendPort hostPort, + WorkerIsolate worker, + ) { + IsolateDiagnosticLog.configure( + debug: config.debug, + level: config.logLevel, + ); + final receivePort = ReceivePort(); + + // Handshake: provide worker's inbox to host. + hostPort.send(receivePort.sendPort); + + receivePort.listen((message) { + if (message == shutdownMessage) { + IsolateDiagnosticLog.log( + SentryLevel.debug, 'Worker V3 received shutdown request'); + try { + receivePort.close(); + } catch (e, st) { + IsolateDiagnosticLog.log( + SentryLevel.error, + 'Worker V3 ReceivePort close error', + exception: e, + stackTrace: st, + ); + } + IsolateDiagnosticLog.log(SentryLevel.debug, 'Worker V3 closed'); + return; + } + + // Minimal RPC pattern: (id, payload, replyTo) + if (message is (int, Object?, SendPort)) { + final (id, payload, replyTo) = message; + Future.sync(() => worker.handleRequest(payload)) + .then((result) => replyTo.send((id, result))) + .catchError((Object error, StackTrace stackTrace) { + // RemoteError is a simple, transferable error container. + replyTo + .send((id, RemoteError(error.toString(), stackTrace.toString()))); + }); + return; + } + + // Fire-and-forget path + try { + worker.handleMessage(message); + } catch (e, st) { + IsolateDiagnosticLog.log( + SentryLevel.error, + 'Worker V3 error while handling message', + exception: e, + stackTrace: st, + ); + } + }); + } + + /// Host-side spawn: returns worker inbox SendPort after handshake + static Future<(Isolate isolate, SendPort workerPort)> spawn( + WorkerConfig cfg, + void Function((WorkerConfig, SendPort)) entryPoint, + ) async { + final init = ReceivePort(); + final isolate = await Isolate.spawn<(WorkerConfig, SendPort)>( + entryPoint, + (cfg, init.sendPort), + debugName: cfg.debugName, + ); + final SendPort workerPort = await init.first as SendPort; + return (isolate, workerPort); + } +} + +/// Host-side helper for workers to perform minimal request/response. +class WorkerClient { + WorkerClient(this._workerPort) { + _responses.listen(_handleResponse); + } + + final SendPort _workerPort; + final ReceivePort _responses = ReceivePort(); + final Map> _pending = {}; + int _idCounter = 0; + bool _closed = false; + + /// Fire-and-forget send to the worker. + void send(Object? message) { + _workerPort.send(message); + } + + /// Send a request to the worker and await a response. + Future request(Object? payload) { + if (_closed) throw StateError('WorkerClientV3 is closed'); + final id = _idCounter++; + final completer = Completer.sync(); + _pending[id] = completer; + _workerPort.send((id, payload, _responses.sendPort)); + return completer.future; + } + + void close() { + if (_closed) return; + _closed = true; + _workerPort.send(WorkerIsolate.shutdownMessage); + if (_pending.isEmpty) { + _responses.close(); + } + } + + void _handleResponse(dynamic message) { + final (int id, Object? response) = message as (int, Object?); + final completer = _pending.remove(id); + if (completer == null) return; + + if (response is RemoteError) { + completer.completeError(response); + } else { + completer.complete(response); + } + + if (_closed && _pending.isEmpty) { + _responses.close(); + } + } +} From a91cbae5ff2db8dbe921814013a4afafb55bc026 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Wed, 3 Sep 2025 17:30:19 +0200 Subject: [PATCH 03/30] Update --- .../lib/src/isolate_diagnostic_log.dart | 39 +++ .../native/java/android_envelope_worker.dart | 80 +++--- .../src/native/java/sentry_native_java.dart | 2 +- packages/flutter/lib/src/worker_isolate.dart | 237 ++++++++---------- 4 files changed, 184 insertions(+), 174 deletions(-) create mode 100644 packages/flutter/lib/src/isolate_diagnostic_log.dart diff --git a/packages/flutter/lib/src/isolate_diagnostic_log.dart b/packages/flutter/lib/src/isolate_diagnostic_log.dart new file mode 100644 index 0000000000..96ac1e5e69 --- /dev/null +++ b/packages/flutter/lib/src/isolate_diagnostic_log.dart @@ -0,0 +1,39 @@ +import 'dart:developer' as developer; + +import '../sentry_flutter.dart'; + +class IsolateDiagnosticLog { + IsolateDiagnosticLog._(); + + static late final bool _debug; + static late final SentryLevel _level; + + static void configure({required bool debug, required SentryLevel level}) { + _debug = debug; + _level = level; + } + + static void log( + SentryLevel level, + String message, { + String? logger, + Object? exception, + StackTrace? stackTrace, + }) { + if (_isEnabled(level)) { + developer.log( + '[${level.name}] $message', + level: level.toDartLogLevel(), + name: logger ?? 'sentry', + time: DateTime.now(), + error: exception, + stackTrace: stackTrace, + ); + } + } + + static bool _isEnabled(SentryLevel level) { + return _debug && level.ordinal >= _level.ordinal || + level == SentryLevel.fatal; + } +} diff --git a/packages/flutter/lib/src/native/java/android_envelope_worker.dart b/packages/flutter/lib/src/native/java/android_envelope_worker.dart index 7e2d76e88e..65f5d80305 100644 --- a/packages/flutter/lib/src/native/java/android_envelope_worker.dart +++ b/packages/flutter/lib/src/native/java/android_envelope_worker.dart @@ -7,40 +7,39 @@ import 'package:meta/meta.dart'; import '../../../sentry_flutter.dart'; import '../../worker_isolate.dart'; +import '../../isolate_diagnostic_log.dart'; import 'binding.dart' as native; -/// Host-side proxy for the Android envelope worker isolate. -class AndroidEnvelopeWorker { - AndroidEnvelopeWorker(this._options); - +class AndroidEnvelopeWorker implements WorkerHandle { final SentryFlutterOptions _options; + final IsolateConfig _config; + IsolateClient? _client; - WorkerClient? _client; + AndroidEnvelopeWorker(this._options) + : _config = IsolateConfig( + debug: _options.debug, + logLevel: _options.diagnosticLevel, + debugName: 'SentryAndroidEnvelopeWorker', + ); @internal // visible for testing/mocking static AndroidEnvelopeWorker Function(SentryFlutterOptions) factory = AndroidEnvelopeWorker.new; - Future start() async { + @override + FutureOr start() async { if (_client != null) return; - final config = WorkerConfig( - debug: _options.debug, - logLevel: _options.diagnosticLevel, - debugName: 'SentryAndroidEnvelopeWorker', - ); - final (_, port) = await WorkerIsolate.spawn( - config, - AndroidEnvelopeWorkerIsolate.entryPoint, - ); - _client = WorkerClient(port); + _client = await spawnIsolate(_config, _entryPoint); } - Future stop() async { - _close(); + static void _entryPoint((SendPort, IsolateConfig) init) { + final (host, config) = init; + runIsolate(config, host, _AndroidEnvelopeMessageHandler()); } /// Fire-and-forget send of envelope bytes to the worker. - void captureEnvelope(Uint8List envelopeData) { + void captureEnvelope( + Uint8List envelopeData, bool containsUnhandledException) { final client = _client; if (client == null) { _options.log( @@ -49,27 +48,30 @@ class AndroidEnvelopeWorker { ); return; } - client.send(TransferableTypedData.fromList([envelopeData])); + client.send(( + TransferableTypedData.fromList([envelopeData]), + containsUnhandledException + )); } - void _close() { + @override + FutureOr close() { _client?.close(); _client = null; } } -/// Worker isolate implementation handling envelope capture via JNI. -class AndroidEnvelopeWorkerIsolate extends WorkerIsolate { - AndroidEnvelopeWorkerIsolate(super.host); - +class _AndroidEnvelopeMessageHandler implements IsolateMessageHandler { @override - FutureOr handleMessage(Object? message) { - IsolateDiagnosticLog.log(SentryLevel.warning, - 'EnvelopeWorker invoked; starting captureEnvelope'); - - if (message is TransferableTypedData) { - final envelopeData = message.materialize().asUint8List(); - _captureEnvelope(envelopeData, false); + FutureOr onMessage(Object? msg) { + if (msg is (TransferableTypedData, bool)) { + final (transferable, containsUnhandledException) = msg; + final data = transferable.materialize().asUint8List(); + _captureEnvelope(data, containsUnhandledException); + } else { + IsolateDiagnosticLog.log(SentryLevel.warning, + 'Unexpected message type while handling a message: $msg', + logger: 'SentryAndroidEnvelopeWorker'); } } @@ -84,11 +86,15 @@ class AndroidEnvelopeWorkerIsolate extends WorkerIsolate { if (id == null) { IsolateDiagnosticLog.log(SentryLevel.error, - 'Native Android SDK returned null id when capturing envelope'); + 'Native Android SDK returned null id when capturing envelope', + logger: 'SentryAndroidEnvelopeWorker'); } } catch (exception, stackTrace) { IsolateDiagnosticLog.log(SentryLevel.error, 'Failed to capture envelope', - exception: exception, stackTrace: stackTrace); + exception: exception, + stackTrace: stackTrace, + logger: 'SentryAndroidEnvelopeWorker'); + // TODO: // if (options.automatedTestMode) { // rethrow; // } @@ -98,8 +104,6 @@ class AndroidEnvelopeWorkerIsolate extends WorkerIsolate { } } - static void entryPoint((WorkerConfig, SendPort) args) { - final (config, host) = args; - WorkerIsolate.bootstrap(config, host, AndroidEnvelopeWorkerIsolate(host)); - } + @override + FutureOr onRequest(Object? payload) => null; // not used for now } diff --git a/packages/flutter/lib/src/native/java/sentry_native_java.dart b/packages/flutter/lib/src/native/java/sentry_native_java.dart index 509a86d66f..380292d375 100644 --- a/packages/flutter/lib/src/native/java/sentry_native_java.dart +++ b/packages/flutter/lib/src/native/java/sentry_native_java.dart @@ -84,7 +84,7 @@ class SentryNativeJava extends SentryNativeChannel { @override FutureOr captureEnvelope( Uint8List envelopeData, bool containsUnhandledException) { - envelopeWorker.captureEnvelope(envelopeData); + envelopeWorker.captureEnvelope(envelopeData, containsUnhandledException); } @override diff --git a/packages/flutter/lib/src/worker_isolate.dart b/packages/flutter/lib/src/worker_isolate.dart index db15ab3932..8ad6921ee5 100644 --- a/packages/flutter/lib/src/worker_isolate.dart +++ b/packages/flutter/lib/src/worker_isolate.dart @@ -5,155 +5,39 @@ import 'dart:isolate'; import 'package:meta/meta.dart'; import '../sentry_flutter.dart'; +import 'isolate_diagnostic_log.dart'; -class WorkerConfig { +// ------------------------------------------- +// HOST-SIDE API (runs on the main isolate) +// ------------------------------------------- + +/// Uniform lifecycle for any host-facing worker facade. +abstract class WorkerHandle { + FutureOr start(); + FutureOr close(); +} + +/// Minimal config passed to isolates. Extend as needed. +class IsolateConfig { final bool debug; final SentryLevel logLevel; final String? debugName; - const WorkerConfig({ + const IsolateConfig({ required this.debug, required this.logLevel, this.debugName, }); } -class IsolateDiagnosticLog { - IsolateDiagnosticLog._(); - - static late final bool _debug; - static late final SentryLevel _level; - - static void configure({required bool debug, required SentryLevel level}) { - _debug = debug; - _level = level; - } - - static void log( - SentryLevel level, - String message, { - String? logger, - Object? exception, - StackTrace? stackTrace, - }) { - if (_isEnabled(level)) { - developer.log( - '[${level.name}] $message', - level: level.toDartLogLevel(), - name: logger ?? 'sentry', - time: DateTime.now(), - error: exception, - stackTrace: stackTrace, - ); - } - } - - static bool _isEnabled(SentryLevel level) { - return _debug && level.ordinal >= _level.ordinal || - level == SentryLevel.fatal; - } -} - -/// Unified V3 worker API combining the robustness of the native replay worker -/// pattern (request/response with correlation IDs) with the minimal -/// WorkerIsolateBase bootstrap/spawn flow. -abstract class WorkerIsolate { - static const String shutdownMessage = 'shutdown'; - - @protected - final SendPort hostPort; - - WorkerIsolate(this.hostPort); - - /// Handle fire-and-forget messages from host → worker. - FutureOr handleMessage(Object? message); - - /// Handle a request expecting a response. Default implementation returns null. - FutureOr handleRequest(Object? payload) => null; - - /// Worker-side bootstrap: configures logging, handshakes, starts loop. - static void bootstrap( - WorkerConfig config, - SendPort hostPort, - WorkerIsolate worker, - ) { - IsolateDiagnosticLog.configure( - debug: config.debug, - level: config.logLevel, - ); - final receivePort = ReceivePort(); - - // Handshake: provide worker's inbox to host. - hostPort.send(receivePort.sendPort); - - receivePort.listen((message) { - if (message == shutdownMessage) { - IsolateDiagnosticLog.log( - SentryLevel.debug, 'Worker V3 received shutdown request'); - try { - receivePort.close(); - } catch (e, st) { - IsolateDiagnosticLog.log( - SentryLevel.error, - 'Worker V3 ReceivePort close error', - exception: e, - stackTrace: st, - ); - } - IsolateDiagnosticLog.log(SentryLevel.debug, 'Worker V3 closed'); - return; - } - - // Minimal RPC pattern: (id, payload, replyTo) - if (message is (int, Object?, SendPort)) { - final (id, payload, replyTo) = message; - Future.sync(() => worker.handleRequest(payload)) - .then((result) => replyTo.send((id, result))) - .catchError((Object error, StackTrace stackTrace) { - // RemoteError is a simple, transferable error container. - replyTo - .send((id, RemoteError(error.toString(), stackTrace.toString()))); - }); - return; - } - - // Fire-and-forget path - try { - worker.handleMessage(message); - } catch (e, st) { - IsolateDiagnosticLog.log( - SentryLevel.error, - 'Worker V3 error while handling message', - exception: e, - stackTrace: st, - ); - } - }); - } - - /// Host-side spawn: returns worker inbox SendPort after handshake - static Future<(Isolate isolate, SendPort workerPort)> spawn( - WorkerConfig cfg, - void Function((WorkerConfig, SendPort)) entryPoint, - ) async { - final init = ReceivePort(); - final isolate = await Isolate.spawn<(WorkerConfig, SendPort)>( - entryPoint, - (cfg, init.sendPort), - debugName: cfg.debugName, - ); - final SendPort workerPort = await init.first as SendPort; - return (isolate, workerPort); - } -} - /// Host-side helper for workers to perform minimal request/response. -class WorkerClient { - WorkerClient(this._workerPort) { +class IsolateClient { + IsolateClient(this._workerPort) { _responses.listen(_handleResponse); } final SendPort _workerPort; + SendPort get port => _workerPort; final ReceivePort _responses = ReceivePort(); final Map> _pending = {}; int _idCounter = 0; @@ -166,7 +50,7 @@ class WorkerClient { /// Send a request to the worker and await a response. Future request(Object? payload) { - if (_closed) throw StateError('WorkerClientV3 is closed'); + if (_closed) throw StateError('IsolateClient is closed'); final id = _idCounter++; final completer = Completer.sync(); _pending[id] = completer; @@ -176,8 +60,8 @@ class WorkerClient { void close() { if (_closed) return; + _workerPort.send(_Ctl.shutdown); _closed = true; - _workerPort.send(WorkerIsolate.shutdownMessage); if (_pending.isEmpty) { _responses.close(); } @@ -199,3 +83,86 @@ class WorkerClient { } } } + +class _Ctl { + static const shutdown = '_shutdown_'; +} + +/// Isolate entry-point signature. +typedef IsolateEntry = void Function((SendPort, IsolateConfig)); + +/// Spawn an isolate and handshake to obtain its SendPort. +Future spawnIsolate( + IsolateConfig config, + IsolateEntry entry, +) async { + final receivePort = ReceivePort(); + await Isolate.spawn<(SendPort, IsolateConfig)>( + entry, + (receivePort.sendPort, config), + debugName: config.debugName, + ); + final workerPort = await receivePort.first as SendPort; + return IsolateClient(workerPort); +} + +// ------------------------------------------- +// ISOLATE-SIDE API (runs inside the worker isolate) +// ------------------------------------------- + +/// Domain behavior contract implemented INSIDE the worker isolate. +abstract class IsolateMessageHandler { + FutureOr onMessage(Object? message); + FutureOr onRequest(Object? payload) => null; +} + +/// Generic isolate runtime. Reuse for every Sentry worker. +void runIsolate( + IsolateConfig config, + SendPort host, + IsolateMessageHandler logic, +) { + // TODO: we might want to configure this at init overall since we shouldn't need isolate specific log setups + IsolateDiagnosticLog.configure( + debug: config.debug, + level: config.logLevel, + ); + + final inbox = ReceivePort(); + host.send(inbox.sendPort); + + inbox.listen((msg) async { + if (msg == _Ctl.shutdown) { + IsolateDiagnosticLog.log( + SentryLevel.debug, 'Isolate received shutdown request', + logger: config.debugName); + inbox.close(); + IsolateDiagnosticLog.log(SentryLevel.debug, 'Isolate closed.', + logger: config.debugName); + return; + } + + // RPC: (id, payload, replyTo) + if (msg is (int, Object?, SendPort)) { + final (id, payload, replyTo) = msg; + try { + final result = await logic.onRequest(payload); + replyTo.send((id, result)); + } catch (e, st) { + replyTo.send((id, RemoteError(e.toString(), st.toString()))); + } + return; + } + + // Fire-and-forget + try { + await logic.onMessage(msg); + } catch (exception, stackTrace) { + IsolateDiagnosticLog.log( + SentryLevel.error, 'Isolate error while handling message', + exception: exception, + stackTrace: stackTrace, + logger: config.debugName); + } + }); +} From a6bd3ccc542b60570d57185a400a50ff29d0923d Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Wed, 3 Sep 2025 17:47:52 +0200 Subject: [PATCH 04/30] Update --- .../native/cocoa/cococa_envelope_worker.dart | 101 ++++++++++++++++++ .../src/native/cocoa/sentry_native_cocoa.dart | 23 ++-- .../native/java/android_envelope_worker.dart | 7 +- 3 files changed, 109 insertions(+), 22 deletions(-) create mode 100644 packages/flutter/lib/src/native/cocoa/cococa_envelope_worker.dart diff --git a/packages/flutter/lib/src/native/cocoa/cococa_envelope_worker.dart b/packages/flutter/lib/src/native/cocoa/cococa_envelope_worker.dart new file mode 100644 index 0000000000..91191a14fc --- /dev/null +++ b/packages/flutter/lib/src/native/cocoa/cococa_envelope_worker.dart @@ -0,0 +1,101 @@ +import 'dart:async'; +import 'dart:isolate'; +import 'dart:typed_data'; + +import 'package:jni/jni.dart'; +import 'package:meta/meta.dart'; +import 'package:objective_c/objective_c.dart'; + +import '../../../sentry_flutter.dart'; +import '../../worker_isolate.dart'; +import '../../isolate_diagnostic_log.dart'; +import 'binding.dart' as cocoa; + +class CocoaEnvelopeWorker implements Worker { + final SentryFlutterOptions _options; + final IsolateConfig _config; + IsolateClient? _client; + + CocoaEnvelopeWorker(this._options) + : _config = IsolateConfig( + debug: _options.debug, + logLevel: _options.diagnosticLevel, + debugName: 'SentryCocoaEnvelopeWorker', + ); + + @internal // visible for testing/mocking + static CocoaEnvelopeWorker Function(SentryFlutterOptions) factory = + CocoaEnvelopeWorker.new; + + @override + FutureOr start() async { + if (_client != null) return; + _client = await spawnIsolate(_config, _entryPoint); + } + + static void _entryPoint((SendPort, IsolateConfig) init) { + final (host, config) = init; + runIsolate(config, host, _CocoaEnvelopeMessageHandler()); + } + + /// Fire-and-forget send of envelope bytes to the worker. + void captureEnvelope(Uint8List envelopeData) { + final client = _client; + if (client == null) { + _options.log( + SentryLevel.warning, + 'CocoaEnvelopeWorker.captureEnvelope called before start; dropping', + ); + return; + } + client.send(TransferableTypedData.fromList([envelopeData])); + } + + @override + FutureOr close() { + _client?.close(); + _client = null; + } +} + +class _CocoaEnvelopeMessageHandler extends IsolateMessageHandler { + @override + FutureOr onMessage(Object? msg) { + if (msg is TransferableTypedData) { + final data = msg.materialize().asUint8List(); + _captureEnvelope(data); + } else { + IsolateDiagnosticLog.log(SentryLevel.warning, + 'Unexpected message type while handling a message: $msg', + logger: 'SentryCocoaEnvelopeWorker'); + } + } + + void _captureEnvelope(Uint8List envelopeData) { + JObject? id; + JByteArray? byteArray; + try { + final nsData = envelopeData.toNSData(); + final envelope = cocoa.PrivateSentrySDKOnly.envelopeWithData(nsData); + if (envelope != null) { + cocoa.PrivateSentrySDKOnly.captureEnvelope(envelope); + } else { + IsolateDiagnosticLog.log(SentryLevel.error, + 'Native Cocoa SDK returned null when capturing envelope', + logger: 'SentryCocoaEnvelopeWorker'); + } + } catch (exception, stackTrace) { + IsolateDiagnosticLog.log(SentryLevel.error, 'Failed to capture envelope', + exception: exception, + stackTrace: stackTrace, + logger: 'SentryCocoaEnvelopeWorker'); + // TODO: + // if (options.automatedTestMode) { + // rethrow; + // } + } finally { + byteArray?.release(); + id?.release(); + } + } +} diff --git a/packages/flutter/lib/src/native/cocoa/sentry_native_cocoa.dart b/packages/flutter/lib/src/native/cocoa/sentry_native_cocoa.dart index 7145f39f71..4bd66e81ab 100644 --- a/packages/flutter/lib/src/native/cocoa/sentry_native_cocoa.dart +++ b/packages/flutter/lib/src/native/cocoa/sentry_native_cocoa.dart @@ -9,10 +9,12 @@ import '../sentry_native_channel.dart'; import '../utils/utf8_json.dart'; import 'binding.dart' as cocoa; import 'cocoa_replay_recorder.dart'; +import 'cococa_envelope_worker.dart'; @internal class SentryNativeCocoa extends SentryNativeChannel { CocoaReplayRecorder? _replayRecorder; + CocoaEnvelopeWorker? _envelopeWorker; SentryId? _replayId; SentryNativeCocoa(super.options); @@ -49,29 +51,16 @@ class SentryNativeCocoa extends SentryNativeChannel { }); } + _envelopeWorker = CocoaEnvelopeWorker(options); + _envelopeWorker?.start(); + return super.init(hub); } @override FutureOr captureEnvelope( Uint8List envelopeData, bool containsUnhandledException) { - try { - final nsData = envelopeData.toNSData(); - final envelope = cocoa.PrivateSentrySDKOnly.envelopeWithData(nsData); - if (envelope != null) { - cocoa.PrivateSentrySDKOnly.captureEnvelope(envelope); - } else { - options.log( - SentryLevel.error, 'Failed to capture envelope: envelope is null'); - } - } catch (exception, stackTrace) { - options.log(SentryLevel.error, 'Failed to capture envelope', - exception: exception, stackTrace: stackTrace); - - if (options.automatedTestMode) { - rethrow; - } - } + _envelopeWorker?.captureEnvelope(envelopeData); } @override diff --git a/packages/flutter/lib/src/native/java/android_envelope_worker.dart b/packages/flutter/lib/src/native/java/android_envelope_worker.dart index 65f5d80305..f19f338539 100644 --- a/packages/flutter/lib/src/native/java/android_envelope_worker.dart +++ b/packages/flutter/lib/src/native/java/android_envelope_worker.dart @@ -10,7 +10,7 @@ import '../../worker_isolate.dart'; import '../../isolate_diagnostic_log.dart'; import 'binding.dart' as native; -class AndroidEnvelopeWorker implements WorkerHandle { +class AndroidEnvelopeWorker implements Worker { final SentryFlutterOptions _options; final IsolateConfig _config; IsolateClient? _client; @@ -61,7 +61,7 @@ class AndroidEnvelopeWorker implements WorkerHandle { } } -class _AndroidEnvelopeMessageHandler implements IsolateMessageHandler { +class _AndroidEnvelopeMessageHandler extends IsolateMessageHandler { @override FutureOr onMessage(Object? msg) { if (msg is (TransferableTypedData, bool)) { @@ -103,7 +103,4 @@ class _AndroidEnvelopeMessageHandler implements IsolateMessageHandler { id?.release(); } } - - @override - FutureOr onRequest(Object? payload) => null; // not used for now } From b9269c7dd28ec4b6faea963c07ffcc2451ebabd9 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Thu, 4 Sep 2025 00:01:49 +0200 Subject: [PATCH 05/30] Update --- .../integrations/native_sdk_integration.dart | 7 ++ ...worker.dart => cocoa_envelope_sender.dart} | 61 +++++++--------- .../src/native/cocoa/sentry_native_cocoa.dart | 10 +-- ...rker.dart => android_envelope_sender.dart} | 51 ++++++------- .../src/native/java/sentry_native_java.dart | 11 +-- packages/flutter/lib/src/worker_isolate.dart | 73 +++++++++---------- 6 files changed, 105 insertions(+), 108 deletions(-) rename packages/flutter/lib/src/native/cocoa/{cococa_envelope_worker.dart => cocoa_envelope_sender.dart} (59%) rename packages/flutter/lib/src/native/java/{android_envelope_worker.dart => android_envelope_sender.dart} (68%) diff --git a/packages/flutter/lib/src/integrations/native_sdk_integration.dart b/packages/flutter/lib/src/integrations/native_sdk_integration.dart index 76c91eda6e..14164c6617 100644 --- a/packages/flutter/lib/src/integrations/native_sdk_integration.dart +++ b/packages/flutter/lib/src/integrations/native_sdk_integration.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:sentry/sentry.dart'; +import '../isolate_diagnostic_log.dart'; import '../native/sentry_native_binding.dart'; import '../sentry_flutter_options.dart'; @@ -25,6 +26,12 @@ class NativeSdkIntegration implements Integration { return; } + // Configure static Isolate logger before spawning isolates + IsolateDiagnosticLog.configure( + debug: options.debug, + level: options.diagnosticLevel, + ); + try { await _native.init(hub); options.sdk.addIntegration('nativeSdkIntegration'); diff --git a/packages/flutter/lib/src/native/cocoa/cococa_envelope_worker.dart b/packages/flutter/lib/src/native/cocoa/cocoa_envelope_sender.dart similarity index 59% rename from packages/flutter/lib/src/native/cocoa/cococa_envelope_worker.dart rename to packages/flutter/lib/src/native/cocoa/cocoa_envelope_sender.dart index 91191a14fc..33e48f9662 100644 --- a/packages/flutter/lib/src/native/cocoa/cococa_envelope_worker.dart +++ b/packages/flutter/lib/src/native/cocoa/cocoa_envelope_sender.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'dart:isolate'; import 'dart:typed_data'; -import 'package:jni/jni.dart'; import 'package:meta/meta.dart'; import 'package:objective_c/objective_c.dart'; @@ -11,54 +10,55 @@ import '../../worker_isolate.dart'; import '../../isolate_diagnostic_log.dart'; import 'binding.dart' as cocoa; -class CocoaEnvelopeWorker implements Worker { +class CocoaEnvelopeSender implements WorkerHost { final SentryFlutterOptions _options; - final IsolateConfig _config; - IsolateClient? _client; + final WorkerConfig _config; + Worker? _worker; - CocoaEnvelopeWorker(this._options) - : _config = IsolateConfig( - debug: _options.debug, - logLevel: _options.diagnosticLevel, - debugName: 'SentryCocoaEnvelopeWorker', + static final String name = 'SentryCocoaEnvelopeSender'; + + CocoaEnvelopeSender(this._options) + : _config = WorkerConfig( + debugName: name, ); @internal // visible for testing/mocking - static CocoaEnvelopeWorker Function(SentryFlutterOptions) factory = - CocoaEnvelopeWorker.new; + static CocoaEnvelopeSender Function(SentryFlutterOptions) factory = + CocoaEnvelopeSender.new; @override FutureOr start() async { - if (_client != null) return; - _client = await spawnIsolate(_config, _entryPoint); + if (_worker != null) return; + _worker = await spawnWorker(_config, _entryPoint); } - static void _entryPoint((SendPort, IsolateConfig) init) { - final (host, config) = init; - runIsolate(config, host, _CocoaEnvelopeMessageHandler()); + @override + FutureOr close() { + _worker?.close(); + _worker = null; } /// Fire-and-forget send of envelope bytes to the worker. void captureEnvelope(Uint8List envelopeData) { - final client = _client; + final client = _worker; if (client == null) { _options.log( SentryLevel.warning, - 'CocoaEnvelopeWorker.captureEnvelope called before start; dropping', + 'captureEnvelope called before start; dropping', + logger: name, ); return; } client.send(TransferableTypedData.fromList([envelopeData])); } - @override - FutureOr close() { - _client?.close(); - _client = null; + static void _entryPoint((SendPort, WorkerConfig) init) { + final (host, config) = init; + runWorker(config, host, _CocoaEnvelopeHandler()); } } -class _CocoaEnvelopeMessageHandler extends IsolateMessageHandler { +class _CocoaEnvelopeHandler extends WorkerHandler { @override FutureOr onMessage(Object? msg) { if (msg is TransferableTypedData) { @@ -67,13 +67,11 @@ class _CocoaEnvelopeMessageHandler extends IsolateMessageHandler { } else { IsolateDiagnosticLog.log(SentryLevel.warning, 'Unexpected message type while handling a message: $msg', - logger: 'SentryCocoaEnvelopeWorker'); + logger: CocoaEnvelopeSender.name); } } void _captureEnvelope(Uint8List envelopeData) { - JObject? id; - JByteArray? byteArray; try { final nsData = envelopeData.toNSData(); final envelope = cocoa.PrivateSentrySDKOnly.envelopeWithData(nsData); @@ -82,20 +80,13 @@ class _CocoaEnvelopeMessageHandler extends IsolateMessageHandler { } else { IsolateDiagnosticLog.log(SentryLevel.error, 'Native Cocoa SDK returned null when capturing envelope', - logger: 'SentryCocoaEnvelopeWorker'); + logger: CocoaEnvelopeSender.name); } } catch (exception, stackTrace) { IsolateDiagnosticLog.log(SentryLevel.error, 'Failed to capture envelope', exception: exception, stackTrace: stackTrace, - logger: 'SentryCocoaEnvelopeWorker'); - // TODO: - // if (options.automatedTestMode) { - // rethrow; - // } - } finally { - byteArray?.release(); - id?.release(); + logger: CocoaEnvelopeSender.name); } } } diff --git a/packages/flutter/lib/src/native/cocoa/sentry_native_cocoa.dart b/packages/flutter/lib/src/native/cocoa/sentry_native_cocoa.dart index 4bd66e81ab..6707817f2d 100644 --- a/packages/flutter/lib/src/native/cocoa/sentry_native_cocoa.dart +++ b/packages/flutter/lib/src/native/cocoa/sentry_native_cocoa.dart @@ -9,12 +9,12 @@ import '../sentry_native_channel.dart'; import '../utils/utf8_json.dart'; import 'binding.dart' as cocoa; import 'cocoa_replay_recorder.dart'; -import 'cococa_envelope_worker.dart'; +import 'cocoa_envelope_sender.dart'; @internal class SentryNativeCocoa extends SentryNativeChannel { CocoaReplayRecorder? _replayRecorder; - CocoaEnvelopeWorker? _envelopeWorker; + CocoaEnvelopeSender? _envelopeSender; SentryId? _replayId; SentryNativeCocoa(super.options); @@ -51,8 +51,8 @@ class SentryNativeCocoa extends SentryNativeChannel { }); } - _envelopeWorker = CocoaEnvelopeWorker(options); - _envelopeWorker?.start(); + _envelopeSender = CocoaEnvelopeSender(options); + await _envelopeSender?.start(); return super.init(hub); } @@ -60,7 +60,7 @@ class SentryNativeCocoa extends SentryNativeChannel { @override FutureOr captureEnvelope( Uint8List envelopeData, bool containsUnhandledException) { - _envelopeWorker?.captureEnvelope(envelopeData); + _envelopeSender?.captureEnvelope(envelopeData); } @override diff --git a/packages/flutter/lib/src/native/java/android_envelope_worker.dart b/packages/flutter/lib/src/native/java/android_envelope_sender.dart similarity index 68% rename from packages/flutter/lib/src/native/java/android_envelope_worker.dart rename to packages/flutter/lib/src/native/java/android_envelope_sender.dart index f19f338539..71171badba 100644 --- a/packages/flutter/lib/src/native/java/android_envelope_worker.dart +++ b/packages/flutter/lib/src/native/java/android_envelope_sender.dart @@ -10,41 +10,43 @@ import '../../worker_isolate.dart'; import '../../isolate_diagnostic_log.dart'; import 'binding.dart' as native; -class AndroidEnvelopeWorker implements Worker { +class AndroidEnvelopeSender implements WorkerHost { final SentryFlutterOptions _options; - final IsolateConfig _config; - IsolateClient? _client; + final WorkerConfig _config; + Worker? _worker; - AndroidEnvelopeWorker(this._options) - : _config = IsolateConfig( - debug: _options.debug, - logLevel: _options.diagnosticLevel, - debugName: 'SentryAndroidEnvelopeWorker', + static final String name = 'SentryAndroidEnvelopeSender'; + + AndroidEnvelopeSender(this._options) + : _config = WorkerConfig( + debugName: name, ); @internal // visible for testing/mocking - static AndroidEnvelopeWorker Function(SentryFlutterOptions) factory = - AndroidEnvelopeWorker.new; + static AndroidEnvelopeSender Function(SentryFlutterOptions) factory = + AndroidEnvelopeSender.new; @override FutureOr start() async { - if (_client != null) return; - _client = await spawnIsolate(_config, _entryPoint); + if (_worker != null) return; + _worker = await spawnWorker(_config, _entryPoint); } - static void _entryPoint((SendPort, IsolateConfig) init) { - final (host, config) = init; - runIsolate(config, host, _AndroidEnvelopeMessageHandler()); + @override + FutureOr close() { + _worker?.close(); + _worker = null; } /// Fire-and-forget send of envelope bytes to the worker. void captureEnvelope( Uint8List envelopeData, bool containsUnhandledException) { - final client = _client; + final client = _worker; if (client == null) { _options.log( SentryLevel.warning, - 'AndroidEnvelopeWorker.captureEnvelope called before start; dropping', + 'captureEnvelope called before worker started; dropping', + logger: name, ); return; } @@ -54,14 +56,13 @@ class AndroidEnvelopeWorker implements Worker { )); } - @override - FutureOr close() { - _client?.close(); - _client = null; + static void _entryPoint((SendPort, WorkerConfig) init) { + final (host, config) = init; + runWorker(config, host, _AndroidEnvelopeHandler()); } } -class _AndroidEnvelopeMessageHandler extends IsolateMessageHandler { +class _AndroidEnvelopeHandler extends WorkerHandler { @override FutureOr onMessage(Object? msg) { if (msg is (TransferableTypedData, bool)) { @@ -71,7 +72,7 @@ class _AndroidEnvelopeMessageHandler extends IsolateMessageHandler { } else { IsolateDiagnosticLog.log(SentryLevel.warning, 'Unexpected message type while handling a message: $msg', - logger: 'SentryAndroidEnvelopeWorker'); + logger: AndroidEnvelopeSender.name); } } @@ -87,13 +88,13 @@ class _AndroidEnvelopeMessageHandler extends IsolateMessageHandler { if (id == null) { IsolateDiagnosticLog.log(SentryLevel.error, 'Native Android SDK returned null id when capturing envelope', - logger: 'SentryAndroidEnvelopeWorker'); + logger: AndroidEnvelopeSender.name); } } catch (exception, stackTrace) { IsolateDiagnosticLog.log(SentryLevel.error, 'Failed to capture envelope', exception: exception, stackTrace: stackTrace, - logger: 'SentryAndroidEnvelopeWorker'); + logger: AndroidEnvelopeSender.name); // TODO: // if (options.automatedTestMode) { // rethrow; diff --git a/packages/flutter/lib/src/native/java/sentry_native_java.dart b/packages/flutter/lib/src/native/java/sentry_native_java.dart index 380292d375..a6df55c0af 100644 --- a/packages/flutter/lib/src/native/java/sentry_native_java.dart +++ b/packages/flutter/lib/src/native/java/sentry_native_java.dart @@ -6,16 +6,17 @@ import 'package:meta/meta.dart'; import '../../../sentry_flutter.dart'; import '../../replay/scheduled_recorder_config.dart'; -import '../../worker_isolate.dart'; import '../sentry_native_channel.dart'; import '../utils/utf8_json.dart'; -import 'android_envelope_worker.dart'; +import 'android_envelope_sender.dart'; import 'android_replay_recorder.dart'; import 'binding.dart' as native; @internal class SentryNativeJava extends SentryNativeChannel { AndroidReplayRecorder? _replayRecorder; + AndroidEnvelopeSender? _envelopeSender; + SentryNativeJava(super.options); @override @@ -73,13 +74,13 @@ class SentryNativeJava extends SentryNativeChannel { }); } - envelopeWorker = AndroidEnvelopeWorker.factory(options); - await envelopeWorker.start(); + _envelopeSender = AndroidEnvelopeSender.factory(options); + await _envelopeSender?.start(); return super.init(hub); } - late AndroidEnvelopeWorker envelopeWorker; + late AndroidEnvelopeSender envelopeWorker; @override FutureOr captureEnvelope( diff --git a/packages/flutter/lib/src/worker_isolate.dart b/packages/flutter/lib/src/worker_isolate.dart index 8ad6921ee5..d087d94efa 100644 --- a/packages/flutter/lib/src/worker_isolate.dart +++ b/packages/flutter/lib/src/worker_isolate.dart @@ -1,9 +1,6 @@ -import 'dart:developer' as developer; import 'dart:async'; import 'dart:isolate'; -import 'package:meta/meta.dart'; - import '../sentry_flutter.dart'; import 'isolate_diagnostic_log.dart'; @@ -11,28 +8,27 @@ import 'isolate_diagnostic_log.dart'; // HOST-SIDE API (runs on the main isolate) // ------------------------------------------- -/// Uniform lifecycle for any host-facing worker facade. -abstract class WorkerHandle { +/// Host-side lifecycle interface for a worker isolate. +/// +/// Responsible for spawning the worker isolate, sending messages, +/// and shutting it down. It does not define the worker logic. +abstract class WorkerHost { FutureOr start(); FutureOr close(); } /// Minimal config passed to isolates. Extend as needed. -class IsolateConfig { - final bool debug; - final SentryLevel logLevel; +class WorkerConfig { final String? debugName; - const IsolateConfig({ - required this.debug, - required this.logLevel, - this.debugName, + const WorkerConfig({ + required this.debugName, }); } /// Host-side helper for workers to perform minimal request/response. -class IsolateClient { - IsolateClient(this._workerPort) { +class Worker { + Worker(this._workerPort) { _responses.listen(_handleResponse); } @@ -50,7 +46,7 @@ class IsolateClient { /// Send a request to the worker and await a response. Future request(Object? payload) { - if (_closed) throw StateError('IsolateClient is closed'); + if (_closed) throw StateError('WorkerClient is closed'); final id = _idCounter++; final completer = Completer.sync(); _pending[id] = completer; @@ -88,46 +84,47 @@ class _Ctl { static const shutdown = '_shutdown_'; } -/// Isolate entry-point signature. -typedef IsolateEntry = void Function((SendPort, IsolateConfig)); +/// Worker (isolate) entry-point signature. +typedef WorkerEntry = void Function((SendPort, WorkerConfig)); -/// Spawn an isolate and handshake to obtain its SendPort. -Future spawnIsolate( - IsolateConfig config, - IsolateEntry entry, +/// Spawn a worker isolate and handshake to obtain its SendPort. +Future spawnWorker( + WorkerConfig config, + WorkerEntry entry, ) async { final receivePort = ReceivePort(); - await Isolate.spawn<(SendPort, IsolateConfig)>( + await Isolate.spawn<(SendPort, WorkerConfig)>( entry, (receivePort.sendPort, config), debugName: config.debugName, ); final workerPort = await receivePort.first as SendPort; - return IsolateClient(workerPort); + return Worker(workerPort); } // ------------------------------------------- // ISOLATE-SIDE API (runs inside the worker isolate) // ------------------------------------------- -/// Domain behavior contract implemented INSIDE the worker isolate. -abstract class IsolateMessageHandler { +/// Message/request handler that runs inside the worker isolate. +/// +/// This does not represent the isolate lifecycle; it only defines how +/// the worker processes incoming messages and optional request/response. +abstract class WorkerHandler { + /// Handle fire-and-forget messages sent from the host. FutureOr onMessage(Object? message); - FutureOr onRequest(Object? payload) => null; + + /// Handle request/response payloads sent from the host. + /// Return value is sent back to the host. Default: no-op. + FutureOr onRequest(Object? payload) => {}; } -/// Generic isolate runtime. Reuse for every Sentry worker. -void runIsolate( - IsolateConfig config, +/// Generic worker runtime. Reuse for every Sentry worker. +void runWorker( + WorkerConfig config, SendPort host, - IsolateMessageHandler logic, + WorkerHandler handler, ) { - // TODO: we might want to configure this at init overall since we shouldn't need isolate specific log setups - IsolateDiagnosticLog.configure( - debug: config.debug, - level: config.logLevel, - ); - final inbox = ReceivePort(); host.send(inbox.sendPort); @@ -146,7 +143,7 @@ void runIsolate( if (msg is (int, Object?, SendPort)) { final (id, payload, replyTo) = msg; try { - final result = await logic.onRequest(payload); + final result = await handler.onRequest(payload); replyTo.send((id, result)); } catch (e, st) { replyTo.send((id, RemoteError(e.toString(), st.toString()))); @@ -156,7 +153,7 @@ void runIsolate( // Fire-and-forget try { - await logic.onMessage(msg); + await handler.onMessage(msg); } catch (exception, stackTrace) { IsolateDiagnosticLog.log( SentryLevel.error, 'Isolate error while handling message', From 6ef9960d313ff7994679ae9fc92e53745faedbc3 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Thu, 4 Sep 2025 11:47:31 +0200 Subject: [PATCH 06/30] Update --- packages/flutter/lib/src/worker_isolate.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/flutter/lib/src/worker_isolate.dart b/packages/flutter/lib/src/worker_isolate.dart index d087d94efa..3f9a2ec3e3 100644 --- a/packages/flutter/lib/src/worker_isolate.dart +++ b/packages/flutter/lib/src/worker_isolate.dart @@ -10,8 +10,8 @@ import 'isolate_diagnostic_log.dart'; /// Host-side lifecycle interface for a worker isolate. /// -/// Responsible for spawning the worker isolate, sending messages, -/// and shutting it down. It does not define the worker logic. +/// Responsible for spawning the worker isolate, and shutting it down. +/// It does not define the worker logic. abstract class WorkerHost { FutureOr start(); FutureOr close(); From a43f2e1d03367f40496eed9740fd20aa94bdc334 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Thu, 4 Sep 2025 12:45:09 +0200 Subject: [PATCH 07/30] Configure diagnostic log --- .../lib/src/integrations/native_sdk_integration.dart | 6 ------ packages/flutter/lib/src/worker_isolate.dart | 9 +++++++++ 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/flutter/lib/src/integrations/native_sdk_integration.dart b/packages/flutter/lib/src/integrations/native_sdk_integration.dart index 14164c6617..9cdb8f65d7 100644 --- a/packages/flutter/lib/src/integrations/native_sdk_integration.dart +++ b/packages/flutter/lib/src/integrations/native_sdk_integration.dart @@ -26,12 +26,6 @@ class NativeSdkIntegration implements Integration { return; } - // Configure static Isolate logger before spawning isolates - IsolateDiagnosticLog.configure( - debug: options.debug, - level: options.diagnosticLevel, - ); - try { await _native.init(hub); options.sdk.addIntegration('nativeSdkIntegration'); diff --git a/packages/flutter/lib/src/worker_isolate.dart b/packages/flutter/lib/src/worker_isolate.dart index 3f9a2ec3e3..2c15e0f425 100644 --- a/packages/flutter/lib/src/worker_isolate.dart +++ b/packages/flutter/lib/src/worker_isolate.dart @@ -19,9 +19,13 @@ abstract class WorkerHost { /// Minimal config passed to isolates. Extend as needed. class WorkerConfig { + final bool debug; + final SentryLevel diagnosticLevel; final String? debugName; const WorkerConfig({ + required this.debug, + required this.diagnosticLevel, required this.debugName, }); } @@ -125,6 +129,11 @@ void runWorker( SendPort host, WorkerHandler handler, ) { + IsolateDiagnosticLog.configure( + debug: config.debug, + level: config.diagnosticLevel, + ); + final inbox = ReceivePort(); host.send(inbox.sendPort); From e334269ece7adbf36efdf228b3bdd4d25544a478 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Thu, 4 Sep 2025 13:28:07 +0200 Subject: [PATCH 08/30] Update log messages --- .../integrations/native_sdk_integration.dart | 2 +- .../integrations/thread_info_integration.dart | 2 +- .../lib/src/{ => isolate}/isolate_helper.dart | 0 .../lib/src/isolate/isolate_logger.dart | 66 +++++++++++++++++++ .../isolate_worker.dart} | 23 +++---- .../lib/src/isolate_diagnostic_log.dart | 39 ----------- .../native/cocoa/cocoa_envelope_sender.dart | 26 +++----- .../native/java/android_envelope_sender.dart | 26 +++----- 8 files changed, 97 insertions(+), 87 deletions(-) rename packages/flutter/lib/src/{ => isolate}/isolate_helper.dart (100%) create mode 100644 packages/flutter/lib/src/isolate/isolate_logger.dart rename packages/flutter/lib/src/{worker_isolate.dart => isolate/isolate_worker.dart} (87%) delete mode 100644 packages/flutter/lib/src/isolate_diagnostic_log.dart diff --git a/packages/flutter/lib/src/integrations/native_sdk_integration.dart b/packages/flutter/lib/src/integrations/native_sdk_integration.dart index 9cdb8f65d7..edb17e0c8b 100644 --- a/packages/flutter/lib/src/integrations/native_sdk_integration.dart +++ b/packages/flutter/lib/src/integrations/native_sdk_integration.dart @@ -2,7 +2,7 @@ import 'dart:async'; import 'package:sentry/sentry.dart'; -import '../isolate_diagnostic_log.dart'; +import '../isolate_logger.dart'; import '../native/sentry_native_binding.dart'; import '../sentry_flutter_options.dart'; diff --git a/packages/flutter/lib/src/integrations/thread_info_integration.dart b/packages/flutter/lib/src/integrations/thread_info_integration.dart index 94ad83bb8c..a647b1b10e 100644 --- a/packages/flutter/lib/src/integrations/thread_info_integration.dart +++ b/packages/flutter/lib/src/integrations/thread_info_integration.dart @@ -3,7 +3,7 @@ import 'package:meta/meta.dart'; import '../../sentry_flutter.dart'; -import '../isolate_helper.dart'; +import '../isolate/isolate_helper.dart'; /// Integration for adding thread information to spans. /// diff --git a/packages/flutter/lib/src/isolate_helper.dart b/packages/flutter/lib/src/isolate/isolate_helper.dart similarity index 100% rename from packages/flutter/lib/src/isolate_helper.dart rename to packages/flutter/lib/src/isolate/isolate_helper.dart diff --git a/packages/flutter/lib/src/isolate/isolate_logger.dart b/packages/flutter/lib/src/isolate/isolate_logger.dart new file mode 100644 index 0000000000..9abf4e6ac2 --- /dev/null +++ b/packages/flutter/lib/src/isolate/isolate_logger.dart @@ -0,0 +1,66 @@ +import 'dart:developer' as developer; + +import '../../sentry_flutter.dart'; + +/// Isolate-local logger that writes diagnostic messages to `dart:developer.log`. +/// +/// Intended for worker/background isolates where a `SentryOptions` instance +/// or hub may not be available. Because Dart statics are isolate-local, +/// you must call [configure] once per isolate before using [log]. +class IsolateLogger { + IsolateLogger._(); + + static late final bool _debug; + static late final SentryLevel _level; + static late final String _loggerName; + static bool _isConfigured = false; + + /// Configures this logger for the current isolate. + /// + /// Must be called once per isolate before invoking [log]. + /// + /// - [debug]: when false, suppresses all logs except [SentryLevel.fatal]. + /// - [level]: minimum severity threshold (inclusive) when [debug] is true. + /// - [loggerName]: logger name for the call sites + static void configure( + {required bool debug, + required SentryLevel level, + required String loggerName}) { + _debug = debug; + _level = level; + _loggerName = loggerName; + _isConfigured = true; + } + + /// Emits a log entry if enabled for this isolate. + /// + /// Messages are forwarded to [developer.log]. The provided [level] is + /// mapped via [SentryLevel.toDartLogLevel] to a `developer.log` numeric level. + /// If logging is disabled or [level] is below the configured threshold, + /// nothing is emitted. [SentryLevel.fatal] is always emitted. + static void log( + SentryLevel level, + String message, { + String? logger, + Object? exception, + StackTrace? stackTrace, + }) { + assert( + _isConfigured, 'IsolateLogger.configure must be called before logging'); + if (_isEnabled(level)) { + developer.log( + '[${level.name}] $message', + level: level.toDartLogLevel(), + name: logger ?? _loggerName, + time: DateTime.now(), + error: exception, + stackTrace: stackTrace, + ); + } + } + + static bool _isEnabled(SentryLevel level) { + return _debug && level.ordinal >= _level.ordinal || + level == SentryLevel.fatal; + } +} diff --git a/packages/flutter/lib/src/worker_isolate.dart b/packages/flutter/lib/src/isolate/isolate_worker.dart similarity index 87% rename from packages/flutter/lib/src/worker_isolate.dart rename to packages/flutter/lib/src/isolate/isolate_worker.dart index 2c15e0f425..92a924e55c 100644 --- a/packages/flutter/lib/src/worker_isolate.dart +++ b/packages/flutter/lib/src/isolate/isolate_worker.dart @@ -1,8 +1,8 @@ import 'dart:async'; import 'dart:isolate'; -import '../sentry_flutter.dart'; -import 'isolate_diagnostic_log.dart'; +import '../../sentry_flutter.dart'; +import 'isolate_logger.dart'; // ------------------------------------------- // HOST-SIDE API (runs on the main isolate) @@ -50,7 +50,7 @@ class Worker { /// Send a request to the worker and await a response. Future request(Object? payload) { - if (_closed) throw StateError('WorkerClient is closed'); + if (_closed) throw StateError('Worker is closed'); final id = _idCounter++; final completer = Completer.sync(); _pending[id] = completer; @@ -129,9 +129,10 @@ void runWorker( SendPort host, WorkerHandler handler, ) { - IsolateDiagnosticLog.configure( + IsolateLogger.configure( debug: config.debug, level: config.diagnosticLevel, + loggerName: config.debugName ?? 'SentryIsolateWorker', ); final inbox = ReceivePort(); @@ -139,12 +140,9 @@ void runWorker( inbox.listen((msg) async { if (msg == _Ctl.shutdown) { - IsolateDiagnosticLog.log( - SentryLevel.debug, 'Isolate received shutdown request', - logger: config.debugName); + IsolateLogger.log(SentryLevel.debug, 'Isolate received shutdown'); inbox.close(); - IsolateDiagnosticLog.log(SentryLevel.debug, 'Isolate closed.', - logger: config.debugName); + IsolateLogger.log(SentryLevel.debug, 'Isolate closed'); return; } @@ -164,11 +162,8 @@ void runWorker( try { await handler.onMessage(msg); } catch (exception, stackTrace) { - IsolateDiagnosticLog.log( - SentryLevel.error, 'Isolate error while handling message', - exception: exception, - stackTrace: stackTrace, - logger: config.debugName); + IsolateLogger.log(SentryLevel.error, 'Isolate failed to handle message', + exception: exception, stackTrace: stackTrace); } }); } diff --git a/packages/flutter/lib/src/isolate_diagnostic_log.dart b/packages/flutter/lib/src/isolate_diagnostic_log.dart deleted file mode 100644 index 96ac1e5e69..0000000000 --- a/packages/flutter/lib/src/isolate_diagnostic_log.dart +++ /dev/null @@ -1,39 +0,0 @@ -import 'dart:developer' as developer; - -import '../sentry_flutter.dart'; - -class IsolateDiagnosticLog { - IsolateDiagnosticLog._(); - - static late final bool _debug; - static late final SentryLevel _level; - - static void configure({required bool debug, required SentryLevel level}) { - _debug = debug; - _level = level; - } - - static void log( - SentryLevel level, - String message, { - String? logger, - Object? exception, - StackTrace? stackTrace, - }) { - if (_isEnabled(level)) { - developer.log( - '[${level.name}] $message', - level: level.toDartLogLevel(), - name: logger ?? 'sentry', - time: DateTime.now(), - error: exception, - stackTrace: stackTrace, - ); - } - } - - static bool _isEnabled(SentryLevel level) { - return _debug && level.ordinal >= _level.ordinal || - level == SentryLevel.fatal; - } -} diff --git a/packages/flutter/lib/src/native/cocoa/cocoa_envelope_sender.dart b/packages/flutter/lib/src/native/cocoa/cocoa_envelope_sender.dart index 33e48f9662..4f6de62582 100644 --- a/packages/flutter/lib/src/native/cocoa/cocoa_envelope_sender.dart +++ b/packages/flutter/lib/src/native/cocoa/cocoa_envelope_sender.dart @@ -6,8 +6,8 @@ import 'package:meta/meta.dart'; import 'package:objective_c/objective_c.dart'; import '../../../sentry_flutter.dart'; -import '../../worker_isolate.dart'; -import '../../isolate_diagnostic_log.dart'; +import '../../isolate/isolate_worker.dart'; +import '../../isolate/isolate_logger.dart'; import 'binding.dart' as cocoa; class CocoaEnvelopeSender implements WorkerHost { @@ -15,11 +15,11 @@ class CocoaEnvelopeSender implements WorkerHost { final WorkerConfig _config; Worker? _worker; - static final String name = 'SentryCocoaEnvelopeSender'; - CocoaEnvelopeSender(this._options) : _config = WorkerConfig( - debugName: name, + debugName: 'SentryCocoaEnvelopeSender', + debug: _options.debug, + diagnosticLevel: _options.diagnosticLevel, ); @internal // visible for testing/mocking @@ -45,7 +45,6 @@ class CocoaEnvelopeSender implements WorkerHost { _options.log( SentryLevel.warning, 'captureEnvelope called before start; dropping', - logger: name, ); return; } @@ -65,9 +64,7 @@ class _CocoaEnvelopeHandler extends WorkerHandler { final data = msg.materialize().asUint8List(); _captureEnvelope(data); } else { - IsolateDiagnosticLog.log(SentryLevel.warning, - 'Unexpected message type while handling a message: $msg', - logger: CocoaEnvelopeSender.name); + IsolateLogger.log(SentryLevel.warning, 'Unexpected message type: $msg'); } } @@ -78,15 +75,12 @@ class _CocoaEnvelopeHandler extends WorkerHandler { if (envelope != null) { cocoa.PrivateSentrySDKOnly.captureEnvelope(envelope); } else { - IsolateDiagnosticLog.log(SentryLevel.error, - 'Native Cocoa SDK returned null when capturing envelope', - logger: CocoaEnvelopeSender.name); + IsolateLogger.log(SentryLevel.error, + 'Native Cocoa SDK returned null when capturing envelope'); } } catch (exception, stackTrace) { - IsolateDiagnosticLog.log(SentryLevel.error, 'Failed to capture envelope', - exception: exception, - stackTrace: stackTrace, - logger: CocoaEnvelopeSender.name); + IsolateLogger.log(SentryLevel.error, 'Failed to capture envelope', + exception: exception, stackTrace: stackTrace); } } } diff --git a/packages/flutter/lib/src/native/java/android_envelope_sender.dart b/packages/flutter/lib/src/native/java/android_envelope_sender.dart index 71171badba..ff1b1b9cc8 100644 --- a/packages/flutter/lib/src/native/java/android_envelope_sender.dart +++ b/packages/flutter/lib/src/native/java/android_envelope_sender.dart @@ -6,8 +6,8 @@ import 'package:jni/jni.dart'; import 'package:meta/meta.dart'; import '../../../sentry_flutter.dart'; -import '../../worker_isolate.dart'; -import '../../isolate_diagnostic_log.dart'; +import '../../isolate/isolate_worker.dart'; +import '../../isolate/isolate_logger.dart'; import 'binding.dart' as native; class AndroidEnvelopeSender implements WorkerHost { @@ -15,11 +15,11 @@ class AndroidEnvelopeSender implements WorkerHost { final WorkerConfig _config; Worker? _worker; - static final String name = 'SentryAndroidEnvelopeSender'; - AndroidEnvelopeSender(this._options) : _config = WorkerConfig( - debugName: name, + debugName: 'SentryAndroidEnvelopeSender', + debug: _options.debug, + diagnosticLevel: _options.diagnosticLevel, ); @internal // visible for testing/mocking @@ -46,7 +46,6 @@ class AndroidEnvelopeSender implements WorkerHost { _options.log( SentryLevel.warning, 'captureEnvelope called before worker started; dropping', - logger: name, ); return; } @@ -70,9 +69,7 @@ class _AndroidEnvelopeHandler extends WorkerHandler { final data = transferable.materialize().asUint8List(); _captureEnvelope(data, containsUnhandledException); } else { - IsolateDiagnosticLog.log(SentryLevel.warning, - 'Unexpected message type while handling a message: $msg', - logger: AndroidEnvelopeSender.name); + IsolateLogger.log(SentryLevel.warning, 'Unexpected message type: $msg'); } } @@ -86,15 +83,12 @@ class _AndroidEnvelopeHandler extends WorkerHandler { byteArray, containsUnhandledException); if (id == null) { - IsolateDiagnosticLog.log(SentryLevel.error, - 'Native Android SDK returned null id when capturing envelope', - logger: AndroidEnvelopeSender.name); + IsolateLogger.log(SentryLevel.error, + 'Native Android SDK returned null when capturing envelope'); } } catch (exception, stackTrace) { - IsolateDiagnosticLog.log(SentryLevel.error, 'Failed to capture envelope', - exception: exception, - stackTrace: stackTrace, - logger: AndroidEnvelopeSender.name); + IsolateLogger.log(SentryLevel.error, 'Failed to capture envelope', + exception: exception, stackTrace: stackTrace); // TODO: // if (options.automatedTestMode) { // rethrow; From aa728e71c027f6bb8c993e2ea6d6f083cdfdaa7d Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Thu, 4 Sep 2025 13:43:09 +0200 Subject: [PATCH 09/30] Update --- .../lib/src/isolate/isolate_worker.dart | 34 ++++++++++--------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/packages/flutter/lib/src/isolate/isolate_worker.dart b/packages/flutter/lib/src/isolate/isolate_worker.dart index 92a924e55c..70d1760e69 100644 --- a/packages/flutter/lib/src/isolate/isolate_worker.dart +++ b/packages/flutter/lib/src/isolate/isolate_worker.dart @@ -4,19 +4,12 @@ import 'dart:isolate'; import '../../sentry_flutter.dart'; import 'isolate_logger.dart'; +const _shutdownCommand = '_shutdown_'; + // ------------------------------------------- // HOST-SIDE API (runs on the main isolate) // ------------------------------------------- -/// Host-side lifecycle interface for a worker isolate. -/// -/// Responsible for spawning the worker isolate, and shutting it down. -/// It does not define the worker logic. -abstract class WorkerHost { - FutureOr start(); - FutureOr close(); -} - /// Minimal config passed to isolates. Extend as needed. class WorkerConfig { final bool debug; @@ -30,6 +23,15 @@ class WorkerConfig { }); } +/// Host-side lifecycle interface for a worker isolate. +/// +/// Responsible for spawning the worker isolate, and shutting it down. +/// It does not define the worker logic. +abstract class WorkerHost { + FutureOr start(); + FutureOr close(); +} + /// Host-side helper for workers to perform minimal request/response. class Worker { Worker(this._workerPort) { @@ -60,7 +62,7 @@ class Worker { void close() { if (_closed) return; - _workerPort.send(_Ctl.shutdown); + _workerPort.send(_shutdownCommand); _closed = true; if (_pending.isEmpty) { _responses.close(); @@ -84,10 +86,6 @@ class Worker { } } -class _Ctl { - static const shutdown = '_shutdown_'; -} - /// Worker (isolate) entry-point signature. typedef WorkerEntry = void Function((SendPort, WorkerConfig)); @@ -123,7 +121,11 @@ abstract class WorkerHandler { FutureOr onRequest(Object? payload) => {}; } -/// Generic worker runtime. Reuse for every Sentry worker. +/// Runs the Sentry worker loop inside a background isolate. +/// +/// Call this only from the worker isolate entry-point spawned via +/// [spawnWorker]. It configures logging, handshakes with the host, and routes +/// messages void runWorker( WorkerConfig config, SendPort host, @@ -139,7 +141,7 @@ void runWorker( host.send(inbox.sendPort); inbox.listen((msg) async { - if (msg == _Ctl.shutdown) { + if (msg == _shutdownCommand) { IsolateLogger.log(SentryLevel.debug, 'Isolate received shutdown'); inbox.close(); IsolateLogger.log(SentryLevel.debug, 'Isolate closed'); From 45cc8c30e99bd4685589694d9992b8f56b155f70 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Thu, 4 Sep 2025 13:43:22 +0200 Subject: [PATCH 10/30] Update --- .../flutter/lib/src/integrations/native_sdk_integration.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/flutter/lib/src/integrations/native_sdk_integration.dart b/packages/flutter/lib/src/integrations/native_sdk_integration.dart index edb17e0c8b..76c91eda6e 100644 --- a/packages/flutter/lib/src/integrations/native_sdk_integration.dart +++ b/packages/flutter/lib/src/integrations/native_sdk_integration.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'package:sentry/sentry.dart'; -import '../isolate_logger.dart'; import '../native/sentry_native_binding.dart'; import '../sentry_flutter_options.dart'; From a603960cc2b420f600eb69b864599f6026444f76 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Thu, 4 Sep 2025 15:16:00 +0200 Subject: [PATCH 11/30] Update --- packages/flutter/lib/src/isolate/isolate_worker.dart | 4 ++-- .../lib/src/native/cocoa/cocoa_envelope_sender.dart | 10 +++++++--- .../lib/src/native/java/android_envelope_sender.dart | 10 +++++++--- 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/packages/flutter/lib/src/isolate/isolate_worker.dart b/packages/flutter/lib/src/isolate/isolate_worker.dart index 70d1760e69..5ad2900b4e 100644 --- a/packages/flutter/lib/src/isolate/isolate_worker.dart +++ b/packages/flutter/lib/src/isolate/isolate_worker.dart @@ -14,7 +14,7 @@ const _shutdownCommand = '_shutdown_'; class WorkerConfig { final bool debug; final SentryLevel diagnosticLevel; - final String? debugName; + final String debugName; const WorkerConfig({ required this.debug, @@ -134,7 +134,7 @@ void runWorker( IsolateLogger.configure( debug: config.debug, level: config.diagnosticLevel, - loggerName: config.debugName ?? 'SentryIsolateWorker', + loggerName: config.debugName, ); final inbox = ReceivePort(); diff --git a/packages/flutter/lib/src/native/cocoa/cocoa_envelope_sender.dart b/packages/flutter/lib/src/native/cocoa/cocoa_envelope_sender.dart index 4f6de62582..be70515ab5 100644 --- a/packages/flutter/lib/src/native/cocoa/cocoa_envelope_sender.dart +++ b/packages/flutter/lib/src/native/cocoa/cocoa_envelope_sender.dart @@ -10,17 +10,21 @@ import '../../isolate/isolate_worker.dart'; import '../../isolate/isolate_logger.dart'; import 'binding.dart' as cocoa; +typedef SpawnWorkerFn = Future Function(WorkerConfig, WorkerEntry); + class CocoaEnvelopeSender implements WorkerHost { final SentryFlutterOptions _options; final WorkerConfig _config; + final SpawnWorkerFn _spawn; Worker? _worker; - CocoaEnvelopeSender(this._options) + CocoaEnvelopeSender(this._options, {SpawnWorkerFn? spawn}) : _config = WorkerConfig( debugName: 'SentryCocoaEnvelopeSender', debug: _options.debug, diagnosticLevel: _options.diagnosticLevel, - ); + ), + _spawn = spawn ?? spawnWorker; @internal // visible for testing/mocking static CocoaEnvelopeSender Function(SentryFlutterOptions) factory = @@ -29,7 +33,7 @@ class CocoaEnvelopeSender implements WorkerHost { @override FutureOr start() async { if (_worker != null) return; - _worker = await spawnWorker(_config, _entryPoint); + _worker = await _spawn(_config, _entryPoint); } @override diff --git a/packages/flutter/lib/src/native/java/android_envelope_sender.dart b/packages/flutter/lib/src/native/java/android_envelope_sender.dart index ff1b1b9cc8..e77c398311 100644 --- a/packages/flutter/lib/src/native/java/android_envelope_sender.dart +++ b/packages/flutter/lib/src/native/java/android_envelope_sender.dart @@ -10,17 +10,21 @@ import '../../isolate/isolate_worker.dart'; import '../../isolate/isolate_logger.dart'; import 'binding.dart' as native; +typedef SpawnWorkerFn = Future Function(WorkerConfig, WorkerEntry); + class AndroidEnvelopeSender implements WorkerHost { final SentryFlutterOptions _options; final WorkerConfig _config; + final SpawnWorkerFn _spawn; Worker? _worker; - AndroidEnvelopeSender(this._options) + AndroidEnvelopeSender(this._options, {SpawnWorkerFn? spawn}) : _config = WorkerConfig( debugName: 'SentryAndroidEnvelopeSender', debug: _options.debug, diagnosticLevel: _options.diagnosticLevel, - ); + ), + _spawn = spawn ?? spawnWorker; @internal // visible for testing/mocking static AndroidEnvelopeSender Function(SentryFlutterOptions) factory = @@ -29,7 +33,7 @@ class AndroidEnvelopeSender implements WorkerHost { @override FutureOr start() async { if (_worker != null) return; - _worker = await spawnWorker(_config, _entryPoint); + _worker = await _spawn(_config, _entryPoint); } @override From 147da011587df77e6644302e77fe715cbd96930b Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Thu, 4 Sep 2025 15:20:52 +0200 Subject: [PATCH 12/30] Update --- packages/flutter/lib/src/native/java/sentry_native_java.dart | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/flutter/lib/src/native/java/sentry_native_java.dart b/packages/flutter/lib/src/native/java/sentry_native_java.dart index a6df55c0af..df84f63978 100644 --- a/packages/flutter/lib/src/native/java/sentry_native_java.dart +++ b/packages/flutter/lib/src/native/java/sentry_native_java.dart @@ -80,12 +80,10 @@ class SentryNativeJava extends SentryNativeChannel { return super.init(hub); } - late AndroidEnvelopeSender envelopeWorker; - @override FutureOr captureEnvelope( Uint8List envelopeData, bool containsUnhandledException) { - envelopeWorker.captureEnvelope(envelopeData, containsUnhandledException); + _envelopeSender?.captureEnvelope(envelopeData, containsUnhandledException); } @override From 2b11149a0239bf3506796d6a943d7e3eec920392 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Thu, 4 Sep 2025 15:21:29 +0200 Subject: [PATCH 13/30] Update --- packages/flutter/lib/src/native/java/sentry_native_java.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/flutter/lib/src/native/java/sentry_native_java.dart b/packages/flutter/lib/src/native/java/sentry_native_java.dart index df84f63978..58fb8657ce 100644 --- a/packages/flutter/lib/src/native/java/sentry_native_java.dart +++ b/packages/flutter/lib/src/native/java/sentry_native_java.dart @@ -174,7 +174,7 @@ class SentryNativeJava extends SentryNativeChannel { @override Future close() async { await _replayRecorder?.stop(); - envelopeWorker.close(); + await _envelopeSender?.close(); return super.close(); } } From 83259527ff83f22a4107a0a16a01c1e91bb145bc Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Thu, 4 Sep 2025 15:27:55 +0200 Subject: [PATCH 14/30] Update --- .../test/isolate/isolate_logger_test.dart | 52 +++++ .../test/isolate/isolate_worker_test.dart | 203 ++++++++++++++++++ .../native/android_envelope_sender_test.dart | 191 ++++++++++++++++ .../native/cocoa_envelope_sender_test.dart | 188 ++++++++++++++++ 4 files changed, 634 insertions(+) create mode 100644 packages/flutter/test/isolate/isolate_logger_test.dart create mode 100644 packages/flutter/test/isolate/isolate_worker_test.dart create mode 100644 packages/flutter/test/native/android_envelope_sender_test.dart create mode 100644 packages/flutter/test/native/cocoa_envelope_sender_test.dart diff --git a/packages/flutter/test/isolate/isolate_logger_test.dart b/packages/flutter/test/isolate/isolate_logger_test.dart new file mode 100644 index 0000000000..5e804bdf5c --- /dev/null +++ b/packages/flutter/test/isolate/isolate_logger_test.dart @@ -0,0 +1,52 @@ +@TestOn('vm') +library; + +import 'dart:isolate'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; +import 'package:sentry_flutter/src/isolate/isolate_logger.dart'; + +void _entryUnconfigured(SendPort sendPort) { + try { + IsolateLogger.log(SentryLevel.info, 'x'); + sendPort.send('no-error'); + } catch (e) { + sendPort.send(e.runtimeType.toString()); + } +} + +void main() { + test('configure required before log (debug builds)', () async { + final rp = ReceivePort(); + await Isolate.spawn(_entryUnconfigured, rp.sendPort, + debugName: 'LoggerUnconfigured'); + final result = await rp.first; + rp.close(); + + // In debug mode, assert triggers AssertionError before any late fields are read. + expect(result, 'AssertionError'); + }); + + test('fatal logs even when debug=false', () { + IsolateLogger.configure( + debug: false, + level: SentryLevel.error, + loggerName: 't', + ); + expect(() => IsolateLogger.log(SentryLevel.fatal, 'fatal ok'), + returnsNormally); + }); + + test('threshold gating (no-throw at info below warning)', () { + IsolateLogger.configure( + debug: true, + level: SentryLevel.warning, + loggerName: 't', + ); + expect( + () => IsolateLogger.log(SentryLevel.info, 'info ok'), returnsNormally); + expect(() => IsolateLogger.log(SentryLevel.warning, 'warn ok'), + returnsNormally); + }); +} diff --git a/packages/flutter/test/isolate/isolate_worker_test.dart b/packages/flutter/test/isolate/isolate_worker_test.dart new file mode 100644 index 0000000000..be5784a4a2 --- /dev/null +++ b/packages/flutter/test/isolate/isolate_worker_test.dart @@ -0,0 +1,203 @@ +@TestOn('vm') +library; + +import 'dart:async'; +import 'dart:isolate'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; +import 'package:sentry_flutter/src/isolate/isolate_worker.dart'; + +class _EchoHandler extends WorkerHandler { + @override + Future onMessage(Object? message) async { + if (message is (SendPort, Object?)) { + message.$1.send(message.$2); + } + } + + @override + Future onRequest(Object? payload) async => payload; +} + +class _ErrorHandler extends WorkerHandler { + @override + Future onMessage(Object? message) async {} + + @override + Future onRequest(Object? payload) async { + throw Exception('boom'); + } +} + +class _DelayHandler extends WorkerHandler { + @override + Future onMessage(Object? message) async {} + + @override + Future onRequest(Object? payload) async { + final milliseconds = payload as int; + await Future.delayed(Duration(milliseconds: milliseconds)); + return 'd:$milliseconds'; + } +} + +class _DebugNameHandler extends WorkerHandler { + @override + Future onMessage(Object? message) async {} + + @override + Future onRequest(Object? payload) async { + return Isolate.current.debugName; + } +} + +void _entryEcho((SendPort, WorkerConfig) init) { + final (host, config) = init; + runWorker(config, host, _EchoHandler()); +} + +void _entryError((SendPort, WorkerConfig) init) { + final (host, config) = init; + runWorker(config, host, _ErrorHandler()); +} + +void _entryDelay((SendPort, WorkerConfig) init) { + final (host, config) = init; + runWorker(config, host, _DelayHandler()); +} + +void _entryDebugName((SendPort, WorkerConfig) init) { + final (host, config) = init; + runWorker(config, host, _DebugNameHandler()); +} + +void main() { + group('Worker isolate', () { + test('request/response echoes', () async { + final worker = await spawnWorker( + const WorkerConfig( + debug: true, + diagnosticLevel: SentryLevel.debug, + debugName: 'EchoWorker', + ), + _entryEcho, + ); + try { + final result = await worker.request('ping'); + expect(result, 'ping'); + } finally { + worker.close(); + } + }); + + test('fire-and-forget can ack via SendPort', () async { + final worker = await spawnWorker( + const WorkerConfig( + debug: true, + diagnosticLevel: SentryLevel.debug, + debugName: 'AckWorker', + ), + _entryEcho, + ); + try { + final rp = ReceivePort(); + worker.send((rp.sendPort, 'ok')); + expect(await rp.first, 'ok'); + rp.close(); + } finally { + worker.close(); + } + }); + + test('request errors propagate as RemoteError', () async { + final worker = await spawnWorker( + const WorkerConfig( + debug: true, + diagnosticLevel: SentryLevel.debug, + debugName: 'ErrorWorker', + ), + _entryError, + ); + try { + expect(() => worker.request('any'), throwsA(isA())); + } finally { + worker.close(); + } + }); + + test('concurrent requests are correlated', () async { + final worker = await spawnWorker( + const WorkerConfig( + debug: true, + diagnosticLevel: SentryLevel.debug, + debugName: 'DelayWorker', + ), + _entryDelay, + ); + try { + final futures = >[ + worker.request(50), + worker.request(10), + worker.request(30), + ]; + final results = await Future.wait(futures); + expect(results, ['d:50', 'd:10', 'd:30']); + } finally { + worker.close(); + } + }); + + test('close rejects new requests; in-flight completes', () async { + final worker = await spawnWorker( + const WorkerConfig( + debug: true, + diagnosticLevel: SentryLevel.debug, + debugName: 'CloseWorker', + ), + _entryDelay, + ); + try { + final inFlight = worker.request(30); + worker.close(); + expect(() => worker.request(1), throwsA(isA())); + expect(await inFlight, 'd:30'); + } finally { + // idempotent + worker.close(); + } + }); + + test('send after close is a no-op and does not throw', () async { + final worker = await spawnWorker( + const WorkerConfig( + debug: true, + diagnosticLevel: SentryLevel.debug, + debugName: 'NoThrowSendAfterCloseWorker', + ), + _entryEcho, + ); + worker.close(); + // Fire-and-forget send should be safe and not throw even after close. + expect(() => worker.send('ignored'), returnsNormally); + }); + + test('debugName propagates to worker isolate', () async { + const debugName = 'DebugNameWorker'; + final worker = await spawnWorker( + const WorkerConfig( + debug: true, + diagnosticLevel: SentryLevel.debug, + debugName: debugName, + ), + _entryDebugName, + ); + try { + final result = await worker.request(null); + expect(result, debugName); + } finally { + worker.close(); + } + }); + }); +} diff --git a/packages/flutter/test/native/android_envelope_sender_test.dart b/packages/flutter/test/native/android_envelope_sender_test.dart new file mode 100644 index 0000000000..d1ba80e282 --- /dev/null +++ b/packages/flutter/test/native/android_envelope_sender_test.dart @@ -0,0 +1,191 @@ +@TestOn('vm') +// ignore_for_file: invalid_use_of_internal_member +library; + +import 'dart:async'; +import 'dart:isolate'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; +import 'package:sentry_flutter/src/native/java/android_envelope_sender.dart'; +import 'package:sentry_flutter/src/isolate/isolate_worker.dart'; + +void main() { + group('AndroidEnvelopeSender host behavior', () { + test('warns and drops when not started', () { + final options = SentryFlutterOptions(); + options.debug = true; + options.diagnosticLevel = SentryLevel.debug; + final logs = <(SentryLevel, String)>[]; + options.log = (level, message, {logger, exception, stackTrace}) { + logs.add((level, message)); + }; + + final sender = AndroidEnvelopeSender(options); + sender.captureEnvelope(Uint8List.fromList([1, 2, 3]), false); + + expect( + logs.any((e) => + e.$1 == SentryLevel.warning && + e.$2.contains( + 'captureEnvelope called before worker started; dropping')), + isTrue, + ); + }); + + test('close is a no-op when not started', () { + final options = SentryFlutterOptions(); + final sender = AndroidEnvelopeSender(options); + expect(() => sender.close(), returnsNormally); + expect(() => sender.close(), returnsNormally); + }); + + test('warns and drops after close', () async { + final options = SentryFlutterOptions(); + options.debug = true; + options.diagnosticLevel = SentryLevel.debug; + final logs = <(SentryLevel, String)>[]; + options.log = (level, message, {logger, exception, stackTrace}) { + logs.add((level, message)); + }; + + final sender = AndroidEnvelopeSender(options); + await sender.start(); + sender.close(); + + sender.captureEnvelope(Uint8List.fromList([9]), false); + + expect( + logs.any((e) => + e.$1 == SentryLevel.warning && + e.$2.contains( + 'captureEnvelope called before worker started; dropping')), + isTrue, + ); + }); + + test('start is a no-op when already started', () async { + final options = SentryFlutterOptions(); + options.debug = true; + options.diagnosticLevel = SentryLevel.debug; + + var spawnCount = 0; + Future fakeSpawn(WorkerConfig config, WorkerEntry entry) async { + spawnCount++; + final inbox = ReceivePort(); + addTearDown(() => inbox.close()); + return Worker(inbox.sendPort); + } + + final sender = AndroidEnvelopeSender(options, spawn: fakeSpawn); + + await sender.start(); + await sender.start(); + expect(spawnCount, 1); + + sender.close(); + spawnCount = 0; + + await sender.start(); + expect(spawnCount, 1); + + // Close twice should be safe. + expect(() => sender.close(), returnsNormally); + expect(() => sender.close(), returnsNormally); + }); + + test('delivers tuple to worker after start', () async { + final options = SentryFlutterOptions(); + options.debug = true; + options.diagnosticLevel = SentryLevel.debug; + + final inboxes = []; + Future fakeSpawn(WorkerConfig config, WorkerEntry entry) async { + final inbox = ReceivePort(); + inboxes.add(inbox); + addTearDown(() => inbox.close()); + return Worker(inbox.sendPort); + } + + final sender = AndroidEnvelopeSender(options, spawn: fakeSpawn); + await sender.start(); + + final payload = Uint8List.fromList([4, 5, 6]); + sender.captureEnvelope(payload, true); + + final msg = await inboxes.last.first; + expect(msg, isA<(TransferableTypedData, bool)>()); + final (transferable, containsUnhandled) = + msg as (TransferableTypedData, bool); + expect(containsUnhandled, true); + final data = transferable.materialize().asUint8List(); + expect(data, [4, 5, 6]); + + sender.close(); + }); + + test('uses expected WorkerConfig', () async { + final options = SentryFlutterOptions(); + options.debug = true; + options.diagnosticLevel = SentryLevel.debug; + + WorkerConfig? seenConfig; + Future fakeSpawn(WorkerConfig config, WorkerEntry entry) async { + seenConfig = config; + final inbox = ReceivePort(); + addTearDown(() => inbox.close()); + return Worker(inbox.sendPort); + } + + final sender = AndroidEnvelopeSender(options, spawn: fakeSpawn); + await sender.start(); + + expect(seenConfig, isNotNull); + expect(seenConfig!.debugName, 'SentryAndroidEnvelopeSender'); + expect(seenConfig!.debug, options.debug); + expect(seenConfig!.diagnosticLevel, options.diagnosticLevel); + + sender.close(); + }); + + test('sends are delivered sequentially with flags', () async { + final options = SentryFlutterOptions(); + options.debug = true; + options.diagnosticLevel = SentryLevel.debug; + + final inboxes = []; + Future fakeSpawn(WorkerConfig config, WorkerEntry entry) async { + final inbox = ReceivePort(); + inboxes.add(inbox); + addTearDown(() => inbox.close()); + return Worker(inbox.sendPort); + } + + final sender = AndroidEnvelopeSender(options, spawn: fakeSpawn); + await sender.start(); + + sender.captureEnvelope(Uint8List.fromList([10]), true); + sender.captureEnvelope(Uint8List.fromList([11]), false); + + final inbox = inboxes.last; + final msgs = await inbox.take(2).toList(); + final msg1 = msgs[0]; + final msg2 = msgs[1]; + + expect(msg1, isA<(TransferableTypedData, bool)>()); + expect(msg2, isA<(TransferableTypedData, bool)>()); + + final (t1, f1) = msg1 as (TransferableTypedData, bool); + final (t2, f2) = msg2 as (TransferableTypedData, bool); + expect(f1, true); + expect(f2, false); + final data1 = t1.materialize().asUint8List(); + final data2 = t2.materialize().asUint8List(); + expect(data1, [10]); + expect(data2, [11]); + + sender.close(); + }); + }); +} diff --git a/packages/flutter/test/native/cocoa_envelope_sender_test.dart b/packages/flutter/test/native/cocoa_envelope_sender_test.dart new file mode 100644 index 0000000000..37aed72847 --- /dev/null +++ b/packages/flutter/test/native/cocoa_envelope_sender_test.dart @@ -0,0 +1,188 @@ +@TestOn('vm') +// ignore_for_file: invalid_use_of_internal_member +library; + +import 'dart:async'; +import 'dart:isolate'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; +import 'package:sentry_flutter/src/native/cocoa/cocoa_envelope_sender.dart'; +import 'package:sentry_flutter/src/isolate/isolate_worker.dart'; + +void main() { + group('CocoaEnvelopeSender host behavior', () { + test('warns and drops when not started', () { + final options = SentryFlutterOptions(); + options.debug = true; + options.diagnosticLevel = SentryLevel.debug; + final logs = <(SentryLevel, String)>[]; + options.log = (level, message, {logger, exception, stackTrace}) { + logs.add((level, message)); + }; + + final sender = CocoaEnvelopeSender(options); + sender.captureEnvelope(Uint8List.fromList([1, 2, 3])); + + expect( + logs.any((e) => + e.$1 == SentryLevel.warning && + e.$2.contains('captureEnvelope called before start; dropping')), + isTrue, + ); + }); + + test('close is a no-op when not started', () { + final options = SentryFlutterOptions(); + final sender = CocoaEnvelopeSender(options); + expect(() => sender.close(), returnsNormally); + expect(() => sender.close(), returnsNormally); + }); + + test('start is a no-op when already started', () async { + final options = SentryFlutterOptions(); + options.debug = true; + options.diagnosticLevel = SentryLevel.debug; + + var spawnCount = 0; + Future fakeSpawn(WorkerConfig config, WorkerEntry entry) async { + spawnCount++; + final inbox = ReceivePort(); + late final StreamSubscription sub; + sub = inbox.listen((msg) async { + if (msg == '_shutdown_') { + await sub.cancel(); + inbox.close(); + } + }); + return Worker(inbox.sendPort); + } + + final sender = CocoaEnvelopeSender(options, spawn: fakeSpawn); + + await sender.start(); + await sender.start(); + expect(spawnCount, 1); + + sender.close(); + spawnCount = 0; + + await sender.start(); + expect(spawnCount, 1); + + // Close twice should be safe. + expect(() => sender.close(), returnsNormally); + expect(() => sender.close(), returnsNormally); + }); + + test('warns and drops after close', () async { + final options = SentryFlutterOptions(); + options.debug = true; + options.diagnosticLevel = SentryLevel.debug; + final logs = <(SentryLevel, String)>[]; + options.log = (level, message, {logger, exception, stackTrace}) { + logs.add((level, message)); + }; + + final sender = CocoaEnvelopeSender(options); + await sender.start(); + sender.close(); + + sender.captureEnvelope(Uint8List.fromList([9])); + + expect( + logs.any((e) => + e.$1 == SentryLevel.warning && + e.$2.contains('captureEnvelope called before start; dropping')), + isTrue, + ); + }); + + test('sends are delivered sequentially', () async { + final options = SentryFlutterOptions(); + options.debug = true; + options.diagnosticLevel = SentryLevel.debug; + + final inboxes = []; + Future fakeSpawn(WorkerConfig config, WorkerEntry entry) async { + final inbox = ReceivePort(); + inboxes.add(inbox); + addTearDown(() => inbox.close()); + return Worker(inbox.sendPort); + } + + final sender = CocoaEnvelopeSender(options, spawn: fakeSpawn); + await sender.start(); + + sender.captureEnvelope(Uint8List.fromList([10])); + sender.captureEnvelope(Uint8List.fromList([11])); + + final inbox = inboxes.last; + final msgs = await inbox.take(2).toList(); + final msg1 = msgs[0]; + final msg2 = msgs[1]; + + expect(msg1, isA()); + expect(msg2, isA()); + + final data1 = (msg1 as TransferableTypedData).materialize().asUint8List(); + final data2 = (msg2 as TransferableTypedData).materialize().asUint8List(); + expect(data1, [10]); + expect(data2, [11]); + + sender.close(); + }); + + test('delivers to worker after start', () async { + final options = SentryFlutterOptions(); + options.debug = true; + options.diagnosticLevel = SentryLevel.debug; + + final inboxes = []; + Future fakeSpawn(WorkerConfig config, WorkerEntry entry) async { + final inbox = ReceivePort(); + inboxes.add(inbox); + addTearDown(() => inbox.close()); + return Worker(inbox.sendPort); + } + + final sender = CocoaEnvelopeSender(options, spawn: fakeSpawn); + await sender.start(); + + final payload = Uint8List.fromList([1, 2, 3]); + sender.captureEnvelope(payload); + + final msg = await inboxes.last.first; + expect(msg, isA()); + final data = (msg as TransferableTypedData).materialize().asUint8List(); + expect(data, [1, 2, 3]); + + sender.close(); + }); + + test('uses expected WorkerConfig', () async { + final options = SentryFlutterOptions(); + options.debug = true; + options.diagnosticLevel = SentryLevel.debug; + + WorkerConfig? seenConfig; + Future fakeSpawn(WorkerConfig config, WorkerEntry entry) async { + seenConfig = config; + final inbox = ReceivePort(); + addTearDown(() => inbox.close()); + return Worker(inbox.sendPort); + } + + final sender = CocoaEnvelopeSender(options, spawn: fakeSpawn); + await sender.start(); + + expect(seenConfig, isNotNull); + expect(seenConfig!.debugName, 'SentryCocoaEnvelopeSender'); + expect(seenConfig!.debug, options.debug); + expect(seenConfig!.diagnosticLevel, options.diagnosticLevel); + + sender.close(); + }); + }); +} From 3dbe75120cff66962ba9565b814b7e630366c6ab Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Thu, 4 Sep 2025 15:56:48 +0200 Subject: [PATCH 15/30] Update --- .../lib/src/isolate/isolate_worker.dart | 34 ++++++++++++------- .../native/android_envelope_sender_test.dart | 12 ++++--- .../native/cocoa_envelope_sender_test.dart | 12 ++++--- 3 files changed, 38 insertions(+), 20 deletions(-) diff --git a/packages/flutter/lib/src/isolate/isolate_worker.dart b/packages/flutter/lib/src/isolate/isolate_worker.dart index 5ad2900b4e..31ca49cec9 100644 --- a/packages/flutter/lib/src/isolate/isolate_worker.dart +++ b/packages/flutter/lib/src/isolate/isolate_worker.dart @@ -33,14 +33,15 @@ abstract class WorkerHost { } /// Host-side helper for workers to perform minimal request/response. +/// Adapted from https://dart.dev/language/isolates#robust-ports-example class Worker { - Worker(this._workerPort) { + Worker(this._workerPort, this._responses) { _responses.listen(_handleResponse); } final SendPort _workerPort; SendPort get port => _workerPort; - final ReceivePort _responses = ReceivePort(); + final ReceivePort _responses; final Map> _pending = {}; int _idCounter = 0; bool _closed = false; @@ -56,7 +57,7 @@ class Worker { final id = _idCounter++; final completer = Completer.sync(); _pending[id] = completer; - _workerPort.send((id, payload, _responses.sendPort)); + _workerPort.send((id, payload)); return completer.future; } @@ -94,14 +95,23 @@ Future spawnWorker( WorkerConfig config, WorkerEntry entry, ) async { - final receivePort = ReceivePort(); + final initPort = RawReceivePort(); + final connection = Completer<(ReceivePort, SendPort)>.sync(); + initPort.handler = (SendPort commandPort) { + connection.complete(( + ReceivePort.fromRawReceivePort(initPort), + commandPort, + )); + }; + await Isolate.spawn<(SendPort, WorkerConfig)>( entry, - (receivePort.sendPort, config), + (initPort.sendPort, config), debugName: config.debugName, ); - final workerPort = await receivePort.first as SendPort; - return Worker(workerPort); + + final (ReceivePort receivePort, SendPort sendPort) = await connection.future; + return Worker(sendPort, receivePort); } // ------------------------------------------- @@ -148,14 +158,14 @@ void runWorker( return; } - // RPC: (id, payload, replyTo) - if (msg is (int, Object?, SendPort)) { - final (id, payload, replyTo) = msg; + // RPC: (id, payload) + if (msg is (int, Object?)) { + final (id, payload) = msg; try { final result = await handler.onRequest(payload); - replyTo.send((id, result)); + host.send((id, result)); } catch (e, st) { - replyTo.send((id, RemoteError(e.toString(), st.toString()))); + host.send((id, RemoteError(e.toString(), st.toString()))); } return; } diff --git a/packages/flutter/test/native/android_envelope_sender_test.dart b/packages/flutter/test/native/android_envelope_sender_test.dart index d1ba80e282..dcd157c3e6 100644 --- a/packages/flutter/test/native/android_envelope_sender_test.dart +++ b/packages/flutter/test/native/android_envelope_sender_test.dart @@ -75,7 +75,8 @@ void main() { spawnCount++; final inbox = ReceivePort(); addTearDown(() => inbox.close()); - return Worker(inbox.sendPort); + final replies = ReceivePort(); + return Worker(inbox.sendPort, replies); } final sender = AndroidEnvelopeSender(options, spawn: fakeSpawn); @@ -105,7 +106,8 @@ void main() { final inbox = ReceivePort(); inboxes.add(inbox); addTearDown(() => inbox.close()); - return Worker(inbox.sendPort); + final replies = ReceivePort(); + return Worker(inbox.sendPort, replies); } final sender = AndroidEnvelopeSender(options, spawn: fakeSpawn); @@ -135,7 +137,8 @@ void main() { seenConfig = config; final inbox = ReceivePort(); addTearDown(() => inbox.close()); - return Worker(inbox.sendPort); + final replies = ReceivePort(); + return Worker(inbox.sendPort, replies); } final sender = AndroidEnvelopeSender(options, spawn: fakeSpawn); @@ -159,7 +162,8 @@ void main() { final inbox = ReceivePort(); inboxes.add(inbox); addTearDown(() => inbox.close()); - return Worker(inbox.sendPort); + final replies = ReceivePort(); + return Worker(inbox.sendPort, replies); } final sender = AndroidEnvelopeSender(options, spawn: fakeSpawn); diff --git a/packages/flutter/test/native/cocoa_envelope_sender_test.dart b/packages/flutter/test/native/cocoa_envelope_sender_test.dart index 37aed72847..31c18373c3 100644 --- a/packages/flutter/test/native/cocoa_envelope_sender_test.dart +++ b/packages/flutter/test/native/cocoa_envelope_sender_test.dart @@ -56,7 +56,8 @@ void main() { inbox.close(); } }); - return Worker(inbox.sendPort); + final replies = ReceivePort(); + return Worker(inbox.sendPort, replies); } final sender = CocoaEnvelopeSender(options, spawn: fakeSpawn); @@ -109,7 +110,8 @@ void main() { final inbox = ReceivePort(); inboxes.add(inbox); addTearDown(() => inbox.close()); - return Worker(inbox.sendPort); + final replies = ReceivePort(); + return Worker(inbox.sendPort, replies); } final sender = CocoaEnvelopeSender(options, spawn: fakeSpawn); @@ -144,7 +146,8 @@ void main() { final inbox = ReceivePort(); inboxes.add(inbox); addTearDown(() => inbox.close()); - return Worker(inbox.sendPort); + final replies = ReceivePort(); + return Worker(inbox.sendPort, replies); } final sender = CocoaEnvelopeSender(options, spawn: fakeSpawn); @@ -171,7 +174,8 @@ void main() { seenConfig = config; final inbox = ReceivePort(); addTearDown(() => inbox.close()); - return Worker(inbox.sendPort); + final replies = ReceivePort(); + return Worker(inbox.sendPort, replies); } final sender = CocoaEnvelopeSender(options, spawn: fakeSpawn); From 71ba593d163e856e30819ed8dc9bb181069e4ef7 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Thu, 4 Sep 2025 16:01:55 +0200 Subject: [PATCH 16/30] Update --- packages/flutter/lib/src/isolate/isolate_worker.dart | 9 --------- .../lib/src/native/cocoa/cocoa_envelope_sender.dart | 4 +--- .../lib/src/native/java/android_envelope_sender.dart | 4 +--- 3 files changed, 2 insertions(+), 15 deletions(-) diff --git a/packages/flutter/lib/src/isolate/isolate_worker.dart b/packages/flutter/lib/src/isolate/isolate_worker.dart index 31ca49cec9..d76f4b6b33 100644 --- a/packages/flutter/lib/src/isolate/isolate_worker.dart +++ b/packages/flutter/lib/src/isolate/isolate_worker.dart @@ -23,15 +23,6 @@ class WorkerConfig { }); } -/// Host-side lifecycle interface for a worker isolate. -/// -/// Responsible for spawning the worker isolate, and shutting it down. -/// It does not define the worker logic. -abstract class WorkerHost { - FutureOr start(); - FutureOr close(); -} - /// Host-side helper for workers to perform minimal request/response. /// Adapted from https://dart.dev/language/isolates#robust-ports-example class Worker { diff --git a/packages/flutter/lib/src/native/cocoa/cocoa_envelope_sender.dart b/packages/flutter/lib/src/native/cocoa/cocoa_envelope_sender.dart index be70515ab5..a371518235 100644 --- a/packages/flutter/lib/src/native/cocoa/cocoa_envelope_sender.dart +++ b/packages/flutter/lib/src/native/cocoa/cocoa_envelope_sender.dart @@ -12,7 +12,7 @@ import 'binding.dart' as cocoa; typedef SpawnWorkerFn = Future Function(WorkerConfig, WorkerEntry); -class CocoaEnvelopeSender implements WorkerHost { +class CocoaEnvelopeSender { final SentryFlutterOptions _options; final WorkerConfig _config; final SpawnWorkerFn _spawn; @@ -30,13 +30,11 @@ class CocoaEnvelopeSender implements WorkerHost { static CocoaEnvelopeSender Function(SentryFlutterOptions) factory = CocoaEnvelopeSender.new; - @override FutureOr start() async { if (_worker != null) return; _worker = await _spawn(_config, _entryPoint); } - @override FutureOr close() { _worker?.close(); _worker = null; diff --git a/packages/flutter/lib/src/native/java/android_envelope_sender.dart b/packages/flutter/lib/src/native/java/android_envelope_sender.dart index e77c398311..05be049268 100644 --- a/packages/flutter/lib/src/native/java/android_envelope_sender.dart +++ b/packages/flutter/lib/src/native/java/android_envelope_sender.dart @@ -12,7 +12,7 @@ import 'binding.dart' as native; typedef SpawnWorkerFn = Future Function(WorkerConfig, WorkerEntry); -class AndroidEnvelopeSender implements WorkerHost { +class AndroidEnvelopeSender { final SentryFlutterOptions _options; final WorkerConfig _config; final SpawnWorkerFn _spawn; @@ -30,13 +30,11 @@ class AndroidEnvelopeSender implements WorkerHost { static AndroidEnvelopeSender Function(SentryFlutterOptions) factory = AndroidEnvelopeSender.new; - @override FutureOr start() async { if (_worker != null) return; _worker = await _spawn(_config, _entryPoint); } - @override FutureOr close() { _worker?.close(); _worker = null; From fe7f6dfccd60e50ad01b4508acb8d34911502a80 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Thu, 4 Sep 2025 16:04:52 +0200 Subject: [PATCH 17/30] Update --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a567ca68f6..df6db27aca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Enhancements + +- Offload `captureEnvelope` to background isolate for iOS and Android ([#3232](https://github.com/getsentry/sentry-dart/pull/3232)) + ## 9.7.0-beta.2 ### Features From 39a951e49fcbe53ef4785897637156a730637d08 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 7 Oct 2025 11:08:19 +0200 Subject: [PATCH 18/30] Update --- packages/flutter/lib/src/isolate/isolate_logger.dart | 4 ++-- packages/flutter/lib/src/isolate/isolate_worker.dart | 4 +--- .../flutter/lib/src/native/cocoa/cocoa_envelope_sender.dart | 2 +- .../flutter/lib/src/native/java/android_envelope_sender.dart | 2 +- packages/flutter/test/isolate/isolate_worker_test.dart | 1 - 5 files changed, 5 insertions(+), 8 deletions(-) diff --git a/packages/flutter/lib/src/isolate/isolate_logger.dart b/packages/flutter/lib/src/isolate/isolate_logger.dart index 9abf4e6ac2..5fe97dff86 100644 --- a/packages/flutter/lib/src/isolate/isolate_logger.dart +++ b/packages/flutter/lib/src/isolate/isolate_logger.dart @@ -2,7 +2,7 @@ import 'dart:developer' as developer; import '../../sentry_flutter.dart'; -/// Isolate-local logger that writes diagnostic messages to `dart:developer.log`. +/// Static logger for Isolates that writes diagnostic messages to `dart:developer.log`. /// /// Intended for worker/background isolates where a `SentryOptions` instance /// or hub may not be available. Because Dart statics are isolate-local, @@ -32,7 +32,7 @@ class IsolateLogger { _isConfigured = true; } - /// Emits a log entry if enabled for this isolate. + /// Emits a log entry if enabled. /// /// Messages are forwarded to [developer.log]. The provided [level] is /// mapped via [SentryLevel.toDartLogLevel] to a `developer.log` numeric level. diff --git a/packages/flutter/lib/src/isolate/isolate_worker.dart b/packages/flutter/lib/src/isolate/isolate_worker.dart index d76f4b6b33..434e4b232e 100644 --- a/packages/flutter/lib/src/isolate/isolate_worker.dart +++ b/packages/flutter/lib/src/isolate/isolate_worker.dart @@ -10,7 +10,7 @@ const _shutdownCommand = '_shutdown_'; // HOST-SIDE API (runs on the main isolate) // ------------------------------------------- -/// Minimal config passed to isolates. Extend as needed. +/// Minimal config passed to isolates - extend as needed. class WorkerConfig { final bool debug; final SentryLevel diagnosticLevel; @@ -149,7 +149,6 @@ void runWorker( return; } - // RPC: (id, payload) if (msg is (int, Object?)) { final (id, payload) = msg; try { @@ -161,7 +160,6 @@ void runWorker( return; } - // Fire-and-forget try { await handler.onMessage(msg); } catch (exception, stackTrace) { diff --git a/packages/flutter/lib/src/native/cocoa/cocoa_envelope_sender.dart b/packages/flutter/lib/src/native/cocoa/cocoa_envelope_sender.dart index a371518235..72f3eb74e6 100644 --- a/packages/flutter/lib/src/native/cocoa/cocoa_envelope_sender.dart +++ b/packages/flutter/lib/src/native/cocoa/cocoa_envelope_sender.dart @@ -26,7 +26,7 @@ class CocoaEnvelopeSender { ), _spawn = spawn ?? spawnWorker; - @internal // visible for testing/mocking + @internal static CocoaEnvelopeSender Function(SentryFlutterOptions) factory = CocoaEnvelopeSender.new; diff --git a/packages/flutter/lib/src/native/java/android_envelope_sender.dart b/packages/flutter/lib/src/native/java/android_envelope_sender.dart index 05be049268..59d53a3960 100644 --- a/packages/flutter/lib/src/native/java/android_envelope_sender.dart +++ b/packages/flutter/lib/src/native/java/android_envelope_sender.dart @@ -26,7 +26,7 @@ class AndroidEnvelopeSender { ), _spawn = spawn ?? spawnWorker; - @internal // visible for testing/mocking + @internal static AndroidEnvelopeSender Function(SentryFlutterOptions) factory = AndroidEnvelopeSender.new; diff --git a/packages/flutter/test/isolate/isolate_worker_test.dart b/packages/flutter/test/isolate/isolate_worker_test.dart index be5784a4a2..f1b6d630fb 100644 --- a/packages/flutter/test/isolate/isolate_worker_test.dart +++ b/packages/flutter/test/isolate/isolate_worker_test.dart @@ -1,7 +1,6 @@ @TestOn('vm') library; -import 'dart:async'; import 'dart:isolate'; import 'package:flutter_test/flutter_test.dart'; From 884642f5bd105bbaa36870fa1febdb7a085d1bed Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 7 Oct 2025 11:31:31 +0200 Subject: [PATCH 19/30] Fix test --- packages/flutter/test/sentry_native_channel_test.dart | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/packages/flutter/test/sentry_native_channel_test.dart b/packages/flutter/test/sentry_native_channel_test.dart index c0a49c0aa4..f0ab1bceb6 100644 --- a/packages/flutter/test/sentry_native_channel_test.dart +++ b/packages/flutter/test/sentry_native_channel_test.dart @@ -194,6 +194,7 @@ void main() { }); test('startProfiler', () { + sut.startProfiler(SentryId.newId()); final matcher = _nativeUnavailableMatcher( mockPlatform, androidUnsupported: true, @@ -238,13 +239,8 @@ void main() { when(channel.invokeMethod('captureEnvelope', any)) .thenAnswer((_) async => {}); - final matcher = _nativeUnavailableMatcher( - mockPlatform, - includeLookupSymbol: true, - ); - final data = Uint8List.fromList([1, 2, 3]); - expect(() => sut.captureEnvelope(data, false), matcher); + sut.captureEnvelope(data, false); verifyZeroInteractions(channel); }, @@ -267,7 +263,7 @@ void main() { mockPlatform, includeLookupSymbol: true, ); - + sut.loadDebugImages(SentryStackTrace(frames: [])); expect( () => sut.loadDebugImages(SentryStackTrace(frames: [])), matcher); From f5f5401069b3eef5de55d10dfbd79f8e061022b4 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 7 Oct 2025 11:40:39 +0200 Subject: [PATCH 20/30] Update --- .../lib/src/isolate/isolate_logger.dart | 21 +++++++++++++++--- .../test/isolate/isolate_logger_test.dart | 22 +++++++++++++++++-- 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/packages/flutter/lib/src/isolate/isolate_logger.dart b/packages/flutter/lib/src/isolate/isolate_logger.dart index 5fe97dff86..bb8a8b7fe0 100644 --- a/packages/flutter/lib/src/isolate/isolate_logger.dart +++ b/packages/flutter/lib/src/isolate/isolate_logger.dart @@ -1,5 +1,7 @@ import 'dart:developer' as developer; +import 'package:meta/meta.dart'; + import '../../sentry_flutter.dart'; /// Static logger for Isolates that writes diagnostic messages to `dart:developer.log`. @@ -10,14 +12,15 @@ import '../../sentry_flutter.dart'; class IsolateLogger { IsolateLogger._(); - static late final bool _debug; - static late final SentryLevel _level; - static late final String _loggerName; + static late bool _debug; + static late SentryLevel _level; + static late String _loggerName; static bool _isConfigured = false; /// Configures this logger for the current isolate. /// /// Must be called once per isolate before invoking [log]. + /// Throws [StateError] if called more than once without calling [reset] first. /// /// - [debug]: when false, suppresses all logs except [SentryLevel.fatal]. /// - [level]: minimum severity threshold (inclusive) when [debug] is true. @@ -26,12 +29,24 @@ class IsolateLogger { {required bool debug, required SentryLevel level, required String loggerName}) { + if (_isConfigured) { + throw StateError( + 'IsolateLogger.configure has already been called. It can only be configured once per isolate.'); + } _debug = debug; _level = level; _loggerName = loggerName; _isConfigured = true; } + /// Resets the logger state to allow reconfiguration. + /// + /// This is intended for testing purposes only. + @visibleForTesting + static void reset() { + _isConfigured = false; + } + /// Emits a log entry if enabled. /// /// Messages are forwarded to [developer.log]. The provided [level] is diff --git a/packages/flutter/test/isolate/isolate_logger_test.dart b/packages/flutter/test/isolate/isolate_logger_test.dart index 5e804bdf5c..5d6fddfc96 100644 --- a/packages/flutter/test/isolate/isolate_logger_test.dart +++ b/packages/flutter/test/isolate/isolate_logger_test.dart @@ -17,6 +17,10 @@ void _entryUnconfigured(SendPort sendPort) { } void main() { + setUp(() { + IsolateLogger.reset(); + }); + test('configure required before log (debug builds)', () async { final rp = ReceivePort(); await Isolate.spawn(_entryUnconfigured, rp.sendPort, @@ -24,8 +28,7 @@ void main() { final result = await rp.first; rp.close(); - // In debug mode, assert triggers AssertionError before any late fields are read. - expect(result, 'AssertionError'); + expect(result, '_AssertionError'); }); test('fatal logs even when debug=false', () { @@ -49,4 +52,19 @@ void main() { expect(() => IsolateLogger.log(SentryLevel.warning, 'warn ok'), returnsNormally); }); + + test('prevents reconfiguration without reset', () { + IsolateLogger.configure( + debug: true, + level: SentryLevel.info, + loggerName: 't', + ); + expect( + () => IsolateLogger.configure( + debug: false, + level: SentryLevel.error, + loggerName: 't2', + ), + throwsStateError); + }); } From de232c6ef9958eee4bcb65c8e5935f6fb52cfe6c Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 7 Oct 2025 11:42:42 +0200 Subject: [PATCH 21/30] Update --- .../flutter/test/integrations/thread_info_integration_test.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/flutter/test/integrations/thread_info_integration_test.dart b/packages/flutter/test/integrations/thread_info_integration_test.dart index 3615e84814..75e4a8595b 100644 --- a/packages/flutter/test/integrations/thread_info_integration_test.dart +++ b/packages/flutter/test/integrations/thread_info_integration_test.dart @@ -4,8 +4,8 @@ library; import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/mockito.dart'; import 'package:sentry_flutter/src/integrations/thread_info_integration.dart'; -import 'package:sentry_flutter/src/isolate_helper.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; +import 'package:sentry_flutter/src/isolate/isolate_helper.dart'; import '../mocks.mocks.dart'; From 69d51119dc220ce4479a59ee79cf477416099240 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 7 Oct 2025 12:01:22 +0200 Subject: [PATCH 22/30] Update --- packages/flutter/test/native/android_envelope_sender_test.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/flutter/test/native/android_envelope_sender_test.dart b/packages/flutter/test/native/android_envelope_sender_test.dart index dcd157c3e6..64067db2bb 100644 --- a/packages/flutter/test/native/android_envelope_sender_test.dart +++ b/packages/flutter/test/native/android_envelope_sender_test.dart @@ -2,7 +2,6 @@ // ignore_for_file: invalid_use_of_internal_member library; -import 'dart:async'; import 'dart:isolate'; import 'dart:typed_data'; From 62bb12bbf0c42aa56cd31d1fd43c9878394b6b02 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 7 Oct 2025 12:13:27 +0200 Subject: [PATCH 23/30] Add automatedTestMode option --- .../flutter/lib/src/isolate/isolate_worker.dart | 2 ++ .../src/native/cocoa/cocoa_envelope_sender.dart | 10 +++++++++- .../src/native/java/android_envelope_sender.dart | 14 +++++++++----- 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/packages/flutter/lib/src/isolate/isolate_worker.dart b/packages/flutter/lib/src/isolate/isolate_worker.dart index 434e4b232e..bcf3bdbb67 100644 --- a/packages/flutter/lib/src/isolate/isolate_worker.dart +++ b/packages/flutter/lib/src/isolate/isolate_worker.dart @@ -15,11 +15,13 @@ class WorkerConfig { final bool debug; final SentryLevel diagnosticLevel; final String debugName; + final bool automatedTestMode; const WorkerConfig({ required this.debug, required this.diagnosticLevel, required this.debugName, + this.automatedTestMode = false, }); } diff --git a/packages/flutter/lib/src/native/cocoa/cocoa_envelope_sender.dart b/packages/flutter/lib/src/native/cocoa/cocoa_envelope_sender.dart index 72f3eb74e6..57f47325c7 100644 --- a/packages/flutter/lib/src/native/cocoa/cocoa_envelope_sender.dart +++ b/packages/flutter/lib/src/native/cocoa/cocoa_envelope_sender.dart @@ -23,6 +23,7 @@ class CocoaEnvelopeSender { debugName: 'SentryCocoaEnvelopeSender', debug: _options.debug, diagnosticLevel: _options.diagnosticLevel, + automatedTestMode: _options.automatedTestMode, ), _spawn = spawn ?? spawnWorker; @@ -55,11 +56,15 @@ class CocoaEnvelopeSender { static void _entryPoint((SendPort, WorkerConfig) init) { final (host, config) = init; - runWorker(config, host, _CocoaEnvelopeHandler()); + runWorker(config, host, _CocoaEnvelopeHandler(config)); } } class _CocoaEnvelopeHandler extends WorkerHandler { + final WorkerConfig _config; + + _CocoaEnvelopeHandler(this._config); + @override FutureOr onMessage(Object? msg) { if (msg is TransferableTypedData) { @@ -83,6 +88,9 @@ class _CocoaEnvelopeHandler extends WorkerHandler { } catch (exception, stackTrace) { IsolateLogger.log(SentryLevel.error, 'Failed to capture envelope', exception: exception, stackTrace: stackTrace); + if (_config.automatedTestMode) { + rethrow; + } } } } diff --git a/packages/flutter/lib/src/native/java/android_envelope_sender.dart b/packages/flutter/lib/src/native/java/android_envelope_sender.dart index 59d53a3960..e3855e6f45 100644 --- a/packages/flutter/lib/src/native/java/android_envelope_sender.dart +++ b/packages/flutter/lib/src/native/java/android_envelope_sender.dart @@ -23,6 +23,7 @@ class AndroidEnvelopeSender { debugName: 'SentryAndroidEnvelopeSender', debug: _options.debug, diagnosticLevel: _options.diagnosticLevel, + automatedTestMode: _options.automatedTestMode, ), _spawn = spawn ?? spawnWorker; @@ -59,11 +60,15 @@ class AndroidEnvelopeSender { static void _entryPoint((SendPort, WorkerConfig) init) { final (host, config) = init; - runWorker(config, host, _AndroidEnvelopeHandler()); + runWorker(config, host, _AndroidEnvelopeHandler(config)); } } class _AndroidEnvelopeHandler extends WorkerHandler { + final WorkerConfig _config; + + _AndroidEnvelopeHandler(this._config); + @override FutureOr onMessage(Object? msg) { if (msg is (TransferableTypedData, bool)) { @@ -91,10 +96,9 @@ class _AndroidEnvelopeHandler extends WorkerHandler { } catch (exception, stackTrace) { IsolateLogger.log(SentryLevel.error, 'Failed to capture envelope', exception: exception, stackTrace: stackTrace); - // TODO: - // if (options.automatedTestMode) { - // rethrow; - // } + if (_config.automatedTestMode) { + rethrow; + } } finally { byteArray?.release(); id?.release(); From 53c603611e73e55c0b5c86a78d40be8d1bce8c3e Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 7 Oct 2025 14:30:34 +0200 Subject: [PATCH 24/30] Update --- packages/flutter/example/pubspec_overrides.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/flutter/example/pubspec_overrides.yaml b/packages/flutter/example/pubspec_overrides.yaml index 8f6b711d3b..7dafca339e 100644 --- a/packages/flutter/example/pubspec_overrides.yaml +++ b/packages/flutter/example/pubspec_overrides.yaml @@ -21,4 +21,3 @@ dependency_overrides: isar_flutter_libs: git: url: https://github.com/MrLittleWhite/isar_flutter_libs.git - From 06ee227757bebda500cd3c3f34f68f760ed52250 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 7 Oct 2025 14:59:28 +0200 Subject: [PATCH 25/30] Fix web tests --- .../native/android_envelope_sender_test.dart | 196 +----------------- .../android_envelope_sender_test_real.dart | 194 +++++++++++++++++ .../android_envelope_sender_test_web.dart | 10 + .../native/cocoa_envelope_sender_test.dart | 194 +---------------- .../cocoa_envelope_sender_test_real.dart | 192 +++++++++++++++++ .../cocoa_envelope_sender_test_web.dart | 10 + 6 files changed, 410 insertions(+), 386 deletions(-) create mode 100644 packages/flutter/test/native/android_envelope_sender_test_real.dart create mode 100644 packages/flutter/test/native/android_envelope_sender_test_web.dart create mode 100644 packages/flutter/test/native/cocoa_envelope_sender_test_real.dart create mode 100644 packages/flutter/test/native/cocoa_envelope_sender_test_web.dart diff --git a/packages/flutter/test/native/android_envelope_sender_test.dart b/packages/flutter/test/native/android_envelope_sender_test.dart index 64067db2bb..9779018acb 100644 --- a/packages/flutter/test/native/android_envelope_sender_test.dart +++ b/packages/flutter/test/native/android_envelope_sender_test.dart @@ -1,194 +1,2 @@ -@TestOn('vm') -// ignore_for_file: invalid_use_of_internal_member -library; - -import 'dart:isolate'; -import 'dart:typed_data'; - -import 'package:flutter_test/flutter_test.dart'; -import 'package:sentry_flutter/sentry_flutter.dart'; -import 'package:sentry_flutter/src/native/java/android_envelope_sender.dart'; -import 'package:sentry_flutter/src/isolate/isolate_worker.dart'; - -void main() { - group('AndroidEnvelopeSender host behavior', () { - test('warns and drops when not started', () { - final options = SentryFlutterOptions(); - options.debug = true; - options.diagnosticLevel = SentryLevel.debug; - final logs = <(SentryLevel, String)>[]; - options.log = (level, message, {logger, exception, stackTrace}) { - logs.add((level, message)); - }; - - final sender = AndroidEnvelopeSender(options); - sender.captureEnvelope(Uint8List.fromList([1, 2, 3]), false); - - expect( - logs.any((e) => - e.$1 == SentryLevel.warning && - e.$2.contains( - 'captureEnvelope called before worker started; dropping')), - isTrue, - ); - }); - - test('close is a no-op when not started', () { - final options = SentryFlutterOptions(); - final sender = AndroidEnvelopeSender(options); - expect(() => sender.close(), returnsNormally); - expect(() => sender.close(), returnsNormally); - }); - - test('warns and drops after close', () async { - final options = SentryFlutterOptions(); - options.debug = true; - options.diagnosticLevel = SentryLevel.debug; - final logs = <(SentryLevel, String)>[]; - options.log = (level, message, {logger, exception, stackTrace}) { - logs.add((level, message)); - }; - - final sender = AndroidEnvelopeSender(options); - await sender.start(); - sender.close(); - - sender.captureEnvelope(Uint8List.fromList([9]), false); - - expect( - logs.any((e) => - e.$1 == SentryLevel.warning && - e.$2.contains( - 'captureEnvelope called before worker started; dropping')), - isTrue, - ); - }); - - test('start is a no-op when already started', () async { - final options = SentryFlutterOptions(); - options.debug = true; - options.diagnosticLevel = SentryLevel.debug; - - var spawnCount = 0; - Future fakeSpawn(WorkerConfig config, WorkerEntry entry) async { - spawnCount++; - final inbox = ReceivePort(); - addTearDown(() => inbox.close()); - final replies = ReceivePort(); - return Worker(inbox.sendPort, replies); - } - - final sender = AndroidEnvelopeSender(options, spawn: fakeSpawn); - - await sender.start(); - await sender.start(); - expect(spawnCount, 1); - - sender.close(); - spawnCount = 0; - - await sender.start(); - expect(spawnCount, 1); - - // Close twice should be safe. - expect(() => sender.close(), returnsNormally); - expect(() => sender.close(), returnsNormally); - }); - - test('delivers tuple to worker after start', () async { - final options = SentryFlutterOptions(); - options.debug = true; - options.diagnosticLevel = SentryLevel.debug; - - final inboxes = []; - Future fakeSpawn(WorkerConfig config, WorkerEntry entry) async { - final inbox = ReceivePort(); - inboxes.add(inbox); - addTearDown(() => inbox.close()); - final replies = ReceivePort(); - return Worker(inbox.sendPort, replies); - } - - final sender = AndroidEnvelopeSender(options, spawn: fakeSpawn); - await sender.start(); - - final payload = Uint8List.fromList([4, 5, 6]); - sender.captureEnvelope(payload, true); - - final msg = await inboxes.last.first; - expect(msg, isA<(TransferableTypedData, bool)>()); - final (transferable, containsUnhandled) = - msg as (TransferableTypedData, bool); - expect(containsUnhandled, true); - final data = transferable.materialize().asUint8List(); - expect(data, [4, 5, 6]); - - sender.close(); - }); - - test('uses expected WorkerConfig', () async { - final options = SentryFlutterOptions(); - options.debug = true; - options.diagnosticLevel = SentryLevel.debug; - - WorkerConfig? seenConfig; - Future fakeSpawn(WorkerConfig config, WorkerEntry entry) async { - seenConfig = config; - final inbox = ReceivePort(); - addTearDown(() => inbox.close()); - final replies = ReceivePort(); - return Worker(inbox.sendPort, replies); - } - - final sender = AndroidEnvelopeSender(options, spawn: fakeSpawn); - await sender.start(); - - expect(seenConfig, isNotNull); - expect(seenConfig!.debugName, 'SentryAndroidEnvelopeSender'); - expect(seenConfig!.debug, options.debug); - expect(seenConfig!.diagnosticLevel, options.diagnosticLevel); - - sender.close(); - }); - - test('sends are delivered sequentially with flags', () async { - final options = SentryFlutterOptions(); - options.debug = true; - options.diagnosticLevel = SentryLevel.debug; - - final inboxes = []; - Future fakeSpawn(WorkerConfig config, WorkerEntry entry) async { - final inbox = ReceivePort(); - inboxes.add(inbox); - addTearDown(() => inbox.close()); - final replies = ReceivePort(); - return Worker(inbox.sendPort, replies); - } - - final sender = AndroidEnvelopeSender(options, spawn: fakeSpawn); - await sender.start(); - - sender.captureEnvelope(Uint8List.fromList([10]), true); - sender.captureEnvelope(Uint8List.fromList([11]), false); - - final inbox = inboxes.last; - final msgs = await inbox.take(2).toList(); - final msg1 = msgs[0]; - final msg2 = msgs[1]; - - expect(msg1, isA<(TransferableTypedData, bool)>()); - expect(msg2, isA<(TransferableTypedData, bool)>()); - - final (t1, f1) = msg1 as (TransferableTypedData, bool); - final (t2, f2) = msg2 as (TransferableTypedData, bool); - expect(f1, true); - expect(f2, false); - final data1 = t1.materialize().asUint8List(); - final data2 = t2.materialize().asUint8List(); - expect(data1, [10]); - expect(data2, [11]); - - sender.close(); - }); - }); -} +export 'android_envelope_sender_test_real.dart' + if (dart.library.js_interop) 'android_envelope_sender_test_web.dart'; diff --git a/packages/flutter/test/native/android_envelope_sender_test_real.dart b/packages/flutter/test/native/android_envelope_sender_test_real.dart new file mode 100644 index 0000000000..64067db2bb --- /dev/null +++ b/packages/flutter/test/native/android_envelope_sender_test_real.dart @@ -0,0 +1,194 @@ +@TestOn('vm') +// ignore_for_file: invalid_use_of_internal_member +library; + +import 'dart:isolate'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; +import 'package:sentry_flutter/src/native/java/android_envelope_sender.dart'; +import 'package:sentry_flutter/src/isolate/isolate_worker.dart'; + +void main() { + group('AndroidEnvelopeSender host behavior', () { + test('warns and drops when not started', () { + final options = SentryFlutterOptions(); + options.debug = true; + options.diagnosticLevel = SentryLevel.debug; + final logs = <(SentryLevel, String)>[]; + options.log = (level, message, {logger, exception, stackTrace}) { + logs.add((level, message)); + }; + + final sender = AndroidEnvelopeSender(options); + sender.captureEnvelope(Uint8List.fromList([1, 2, 3]), false); + + expect( + logs.any((e) => + e.$1 == SentryLevel.warning && + e.$2.contains( + 'captureEnvelope called before worker started; dropping')), + isTrue, + ); + }); + + test('close is a no-op when not started', () { + final options = SentryFlutterOptions(); + final sender = AndroidEnvelopeSender(options); + expect(() => sender.close(), returnsNormally); + expect(() => sender.close(), returnsNormally); + }); + + test('warns and drops after close', () async { + final options = SentryFlutterOptions(); + options.debug = true; + options.diagnosticLevel = SentryLevel.debug; + final logs = <(SentryLevel, String)>[]; + options.log = (level, message, {logger, exception, stackTrace}) { + logs.add((level, message)); + }; + + final sender = AndroidEnvelopeSender(options); + await sender.start(); + sender.close(); + + sender.captureEnvelope(Uint8List.fromList([9]), false); + + expect( + logs.any((e) => + e.$1 == SentryLevel.warning && + e.$2.contains( + 'captureEnvelope called before worker started; dropping')), + isTrue, + ); + }); + + test('start is a no-op when already started', () async { + final options = SentryFlutterOptions(); + options.debug = true; + options.diagnosticLevel = SentryLevel.debug; + + var spawnCount = 0; + Future fakeSpawn(WorkerConfig config, WorkerEntry entry) async { + spawnCount++; + final inbox = ReceivePort(); + addTearDown(() => inbox.close()); + final replies = ReceivePort(); + return Worker(inbox.sendPort, replies); + } + + final sender = AndroidEnvelopeSender(options, spawn: fakeSpawn); + + await sender.start(); + await sender.start(); + expect(spawnCount, 1); + + sender.close(); + spawnCount = 0; + + await sender.start(); + expect(spawnCount, 1); + + // Close twice should be safe. + expect(() => sender.close(), returnsNormally); + expect(() => sender.close(), returnsNormally); + }); + + test('delivers tuple to worker after start', () async { + final options = SentryFlutterOptions(); + options.debug = true; + options.diagnosticLevel = SentryLevel.debug; + + final inboxes = []; + Future fakeSpawn(WorkerConfig config, WorkerEntry entry) async { + final inbox = ReceivePort(); + inboxes.add(inbox); + addTearDown(() => inbox.close()); + final replies = ReceivePort(); + return Worker(inbox.sendPort, replies); + } + + final sender = AndroidEnvelopeSender(options, spawn: fakeSpawn); + await sender.start(); + + final payload = Uint8List.fromList([4, 5, 6]); + sender.captureEnvelope(payload, true); + + final msg = await inboxes.last.first; + expect(msg, isA<(TransferableTypedData, bool)>()); + final (transferable, containsUnhandled) = + msg as (TransferableTypedData, bool); + expect(containsUnhandled, true); + final data = transferable.materialize().asUint8List(); + expect(data, [4, 5, 6]); + + sender.close(); + }); + + test('uses expected WorkerConfig', () async { + final options = SentryFlutterOptions(); + options.debug = true; + options.diagnosticLevel = SentryLevel.debug; + + WorkerConfig? seenConfig; + Future fakeSpawn(WorkerConfig config, WorkerEntry entry) async { + seenConfig = config; + final inbox = ReceivePort(); + addTearDown(() => inbox.close()); + final replies = ReceivePort(); + return Worker(inbox.sendPort, replies); + } + + final sender = AndroidEnvelopeSender(options, spawn: fakeSpawn); + await sender.start(); + + expect(seenConfig, isNotNull); + expect(seenConfig!.debugName, 'SentryAndroidEnvelopeSender'); + expect(seenConfig!.debug, options.debug); + expect(seenConfig!.diagnosticLevel, options.diagnosticLevel); + + sender.close(); + }); + + test('sends are delivered sequentially with flags', () async { + final options = SentryFlutterOptions(); + options.debug = true; + options.diagnosticLevel = SentryLevel.debug; + + final inboxes = []; + Future fakeSpawn(WorkerConfig config, WorkerEntry entry) async { + final inbox = ReceivePort(); + inboxes.add(inbox); + addTearDown(() => inbox.close()); + final replies = ReceivePort(); + return Worker(inbox.sendPort, replies); + } + + final sender = AndroidEnvelopeSender(options, spawn: fakeSpawn); + await sender.start(); + + sender.captureEnvelope(Uint8List.fromList([10]), true); + sender.captureEnvelope(Uint8List.fromList([11]), false); + + final inbox = inboxes.last; + final msgs = await inbox.take(2).toList(); + final msg1 = msgs[0]; + final msg2 = msgs[1]; + + expect(msg1, isA<(TransferableTypedData, bool)>()); + expect(msg2, isA<(TransferableTypedData, bool)>()); + + final (t1, f1) = msg1 as (TransferableTypedData, bool); + final (t2, f2) = msg2 as (TransferableTypedData, bool); + expect(f1, true); + expect(f2, false); + final data1 = t1.materialize().asUint8List(); + final data2 = t2.materialize().asUint8List(); + expect(data1, [10]); + expect(data2, [11]); + + sender.close(); + }); + }); +} diff --git a/packages/flutter/test/native/android_envelope_sender_test_web.dart b/packages/flutter/test/native/android_envelope_sender_test_web.dart new file mode 100644 index 0000000000..6b061ee80a --- /dev/null +++ b/packages/flutter/test/native/android_envelope_sender_test_web.dart @@ -0,0 +1,10 @@ +// Stub for web - these tests only run on VM +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('Android envelope sender tests are not supported on web', () { + // This test file exists only to satisfy the compiler when running web tests. + // The actual tests in android_envelope_sender_test_real.dart are only + // executed on VM platforms. + }); +} diff --git a/packages/flutter/test/native/cocoa_envelope_sender_test.dart b/packages/flutter/test/native/cocoa_envelope_sender_test.dart index 31c18373c3..15590a436d 100644 --- a/packages/flutter/test/native/cocoa_envelope_sender_test.dart +++ b/packages/flutter/test/native/cocoa_envelope_sender_test.dart @@ -1,192 +1,2 @@ -@TestOn('vm') -// ignore_for_file: invalid_use_of_internal_member -library; - -import 'dart:async'; -import 'dart:isolate'; -import 'dart:typed_data'; - -import 'package:flutter_test/flutter_test.dart'; -import 'package:sentry_flutter/sentry_flutter.dart'; -import 'package:sentry_flutter/src/native/cocoa/cocoa_envelope_sender.dart'; -import 'package:sentry_flutter/src/isolate/isolate_worker.dart'; - -void main() { - group('CocoaEnvelopeSender host behavior', () { - test('warns and drops when not started', () { - final options = SentryFlutterOptions(); - options.debug = true; - options.diagnosticLevel = SentryLevel.debug; - final logs = <(SentryLevel, String)>[]; - options.log = (level, message, {logger, exception, stackTrace}) { - logs.add((level, message)); - }; - - final sender = CocoaEnvelopeSender(options); - sender.captureEnvelope(Uint8List.fromList([1, 2, 3])); - - expect( - logs.any((e) => - e.$1 == SentryLevel.warning && - e.$2.contains('captureEnvelope called before start; dropping')), - isTrue, - ); - }); - - test('close is a no-op when not started', () { - final options = SentryFlutterOptions(); - final sender = CocoaEnvelopeSender(options); - expect(() => sender.close(), returnsNormally); - expect(() => sender.close(), returnsNormally); - }); - - test('start is a no-op when already started', () async { - final options = SentryFlutterOptions(); - options.debug = true; - options.diagnosticLevel = SentryLevel.debug; - - var spawnCount = 0; - Future fakeSpawn(WorkerConfig config, WorkerEntry entry) async { - spawnCount++; - final inbox = ReceivePort(); - late final StreamSubscription sub; - sub = inbox.listen((msg) async { - if (msg == '_shutdown_') { - await sub.cancel(); - inbox.close(); - } - }); - final replies = ReceivePort(); - return Worker(inbox.sendPort, replies); - } - - final sender = CocoaEnvelopeSender(options, spawn: fakeSpawn); - - await sender.start(); - await sender.start(); - expect(spawnCount, 1); - - sender.close(); - spawnCount = 0; - - await sender.start(); - expect(spawnCount, 1); - - // Close twice should be safe. - expect(() => sender.close(), returnsNormally); - expect(() => sender.close(), returnsNormally); - }); - - test('warns and drops after close', () async { - final options = SentryFlutterOptions(); - options.debug = true; - options.diagnosticLevel = SentryLevel.debug; - final logs = <(SentryLevel, String)>[]; - options.log = (level, message, {logger, exception, stackTrace}) { - logs.add((level, message)); - }; - - final sender = CocoaEnvelopeSender(options); - await sender.start(); - sender.close(); - - sender.captureEnvelope(Uint8List.fromList([9])); - - expect( - logs.any((e) => - e.$1 == SentryLevel.warning && - e.$2.contains('captureEnvelope called before start; dropping')), - isTrue, - ); - }); - - test('sends are delivered sequentially', () async { - final options = SentryFlutterOptions(); - options.debug = true; - options.diagnosticLevel = SentryLevel.debug; - - final inboxes = []; - Future fakeSpawn(WorkerConfig config, WorkerEntry entry) async { - final inbox = ReceivePort(); - inboxes.add(inbox); - addTearDown(() => inbox.close()); - final replies = ReceivePort(); - return Worker(inbox.sendPort, replies); - } - - final sender = CocoaEnvelopeSender(options, spawn: fakeSpawn); - await sender.start(); - - sender.captureEnvelope(Uint8List.fromList([10])); - sender.captureEnvelope(Uint8List.fromList([11])); - - final inbox = inboxes.last; - final msgs = await inbox.take(2).toList(); - final msg1 = msgs[0]; - final msg2 = msgs[1]; - - expect(msg1, isA()); - expect(msg2, isA()); - - final data1 = (msg1 as TransferableTypedData).materialize().asUint8List(); - final data2 = (msg2 as TransferableTypedData).materialize().asUint8List(); - expect(data1, [10]); - expect(data2, [11]); - - sender.close(); - }); - - test('delivers to worker after start', () async { - final options = SentryFlutterOptions(); - options.debug = true; - options.diagnosticLevel = SentryLevel.debug; - - final inboxes = []; - Future fakeSpawn(WorkerConfig config, WorkerEntry entry) async { - final inbox = ReceivePort(); - inboxes.add(inbox); - addTearDown(() => inbox.close()); - final replies = ReceivePort(); - return Worker(inbox.sendPort, replies); - } - - final sender = CocoaEnvelopeSender(options, spawn: fakeSpawn); - await sender.start(); - - final payload = Uint8List.fromList([1, 2, 3]); - sender.captureEnvelope(payload); - - final msg = await inboxes.last.first; - expect(msg, isA()); - final data = (msg as TransferableTypedData).materialize().asUint8List(); - expect(data, [1, 2, 3]); - - sender.close(); - }); - - test('uses expected WorkerConfig', () async { - final options = SentryFlutterOptions(); - options.debug = true; - options.diagnosticLevel = SentryLevel.debug; - - WorkerConfig? seenConfig; - Future fakeSpawn(WorkerConfig config, WorkerEntry entry) async { - seenConfig = config; - final inbox = ReceivePort(); - addTearDown(() => inbox.close()); - final replies = ReceivePort(); - return Worker(inbox.sendPort, replies); - } - - final sender = CocoaEnvelopeSender(options, spawn: fakeSpawn); - await sender.start(); - - expect(seenConfig, isNotNull); - expect(seenConfig!.debugName, 'SentryCocoaEnvelopeSender'); - expect(seenConfig!.debug, options.debug); - expect(seenConfig!.diagnosticLevel, options.diagnosticLevel); - - sender.close(); - }); - }); -} +export 'cocoa_envelope_sender_test_real.dart' + if (dart.library.js_interop) 'cocoa_envelope_sender_test_web.dart'; diff --git a/packages/flutter/test/native/cocoa_envelope_sender_test_real.dart b/packages/flutter/test/native/cocoa_envelope_sender_test_real.dart new file mode 100644 index 0000000000..31c18373c3 --- /dev/null +++ b/packages/flutter/test/native/cocoa_envelope_sender_test_real.dart @@ -0,0 +1,192 @@ +@TestOn('vm') +// ignore_for_file: invalid_use_of_internal_member +library; + +import 'dart:async'; +import 'dart:isolate'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; +import 'package:sentry_flutter/src/native/cocoa/cocoa_envelope_sender.dart'; +import 'package:sentry_flutter/src/isolate/isolate_worker.dart'; + +void main() { + group('CocoaEnvelopeSender host behavior', () { + test('warns and drops when not started', () { + final options = SentryFlutterOptions(); + options.debug = true; + options.diagnosticLevel = SentryLevel.debug; + final logs = <(SentryLevel, String)>[]; + options.log = (level, message, {logger, exception, stackTrace}) { + logs.add((level, message)); + }; + + final sender = CocoaEnvelopeSender(options); + sender.captureEnvelope(Uint8List.fromList([1, 2, 3])); + + expect( + logs.any((e) => + e.$1 == SentryLevel.warning && + e.$2.contains('captureEnvelope called before start; dropping')), + isTrue, + ); + }); + + test('close is a no-op when not started', () { + final options = SentryFlutterOptions(); + final sender = CocoaEnvelopeSender(options); + expect(() => sender.close(), returnsNormally); + expect(() => sender.close(), returnsNormally); + }); + + test('start is a no-op when already started', () async { + final options = SentryFlutterOptions(); + options.debug = true; + options.diagnosticLevel = SentryLevel.debug; + + var spawnCount = 0; + Future fakeSpawn(WorkerConfig config, WorkerEntry entry) async { + spawnCount++; + final inbox = ReceivePort(); + late final StreamSubscription sub; + sub = inbox.listen((msg) async { + if (msg == '_shutdown_') { + await sub.cancel(); + inbox.close(); + } + }); + final replies = ReceivePort(); + return Worker(inbox.sendPort, replies); + } + + final sender = CocoaEnvelopeSender(options, spawn: fakeSpawn); + + await sender.start(); + await sender.start(); + expect(spawnCount, 1); + + sender.close(); + spawnCount = 0; + + await sender.start(); + expect(spawnCount, 1); + + // Close twice should be safe. + expect(() => sender.close(), returnsNormally); + expect(() => sender.close(), returnsNormally); + }); + + test('warns and drops after close', () async { + final options = SentryFlutterOptions(); + options.debug = true; + options.diagnosticLevel = SentryLevel.debug; + final logs = <(SentryLevel, String)>[]; + options.log = (level, message, {logger, exception, stackTrace}) { + logs.add((level, message)); + }; + + final sender = CocoaEnvelopeSender(options); + await sender.start(); + sender.close(); + + sender.captureEnvelope(Uint8List.fromList([9])); + + expect( + logs.any((e) => + e.$1 == SentryLevel.warning && + e.$2.contains('captureEnvelope called before start; dropping')), + isTrue, + ); + }); + + test('sends are delivered sequentially', () async { + final options = SentryFlutterOptions(); + options.debug = true; + options.diagnosticLevel = SentryLevel.debug; + + final inboxes = []; + Future fakeSpawn(WorkerConfig config, WorkerEntry entry) async { + final inbox = ReceivePort(); + inboxes.add(inbox); + addTearDown(() => inbox.close()); + final replies = ReceivePort(); + return Worker(inbox.sendPort, replies); + } + + final sender = CocoaEnvelopeSender(options, spawn: fakeSpawn); + await sender.start(); + + sender.captureEnvelope(Uint8List.fromList([10])); + sender.captureEnvelope(Uint8List.fromList([11])); + + final inbox = inboxes.last; + final msgs = await inbox.take(2).toList(); + final msg1 = msgs[0]; + final msg2 = msgs[1]; + + expect(msg1, isA()); + expect(msg2, isA()); + + final data1 = (msg1 as TransferableTypedData).materialize().asUint8List(); + final data2 = (msg2 as TransferableTypedData).materialize().asUint8List(); + expect(data1, [10]); + expect(data2, [11]); + + sender.close(); + }); + + test('delivers to worker after start', () async { + final options = SentryFlutterOptions(); + options.debug = true; + options.diagnosticLevel = SentryLevel.debug; + + final inboxes = []; + Future fakeSpawn(WorkerConfig config, WorkerEntry entry) async { + final inbox = ReceivePort(); + inboxes.add(inbox); + addTearDown(() => inbox.close()); + final replies = ReceivePort(); + return Worker(inbox.sendPort, replies); + } + + final sender = CocoaEnvelopeSender(options, spawn: fakeSpawn); + await sender.start(); + + final payload = Uint8List.fromList([1, 2, 3]); + sender.captureEnvelope(payload); + + final msg = await inboxes.last.first; + expect(msg, isA()); + final data = (msg as TransferableTypedData).materialize().asUint8List(); + expect(data, [1, 2, 3]); + + sender.close(); + }); + + test('uses expected WorkerConfig', () async { + final options = SentryFlutterOptions(); + options.debug = true; + options.diagnosticLevel = SentryLevel.debug; + + WorkerConfig? seenConfig; + Future fakeSpawn(WorkerConfig config, WorkerEntry entry) async { + seenConfig = config; + final inbox = ReceivePort(); + addTearDown(() => inbox.close()); + final replies = ReceivePort(); + return Worker(inbox.sendPort, replies); + } + + final sender = CocoaEnvelopeSender(options, spawn: fakeSpawn); + await sender.start(); + + expect(seenConfig, isNotNull); + expect(seenConfig!.debugName, 'SentryCocoaEnvelopeSender'); + expect(seenConfig!.debug, options.debug); + expect(seenConfig!.diagnosticLevel, options.diagnosticLevel); + + sender.close(); + }); + }); +} diff --git a/packages/flutter/test/native/cocoa_envelope_sender_test_web.dart b/packages/flutter/test/native/cocoa_envelope_sender_test_web.dart new file mode 100644 index 0000000000..be0d7c08f0 --- /dev/null +++ b/packages/flutter/test/native/cocoa_envelope_sender_test_web.dart @@ -0,0 +1,10 @@ +// Stub for web - these tests only run on VM +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('Cocoa envelope sender tests are not supported on web', () { + // This test file exists only to satisfy the compiler when running web tests. + // The actual tests in cocoa_envelope_sender_test_real.dart are only + // executed on VM platforms. + }); +} From 10d9419548d0b160a0facef6cb9455dcbcca0cf5 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 7 Oct 2025 15:02:02 +0200 Subject: [PATCH 26/30] Update --- packages/flutter/test/sentry_native_channel_test.dart | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/flutter/test/sentry_native_channel_test.dart b/packages/flutter/test/sentry_native_channel_test.dart index f0ab1bceb6..1a5c7dd476 100644 --- a/packages/flutter/test/sentry_native_channel_test.dart +++ b/packages/flutter/test/sentry_native_channel_test.dart @@ -194,7 +194,6 @@ void main() { }); test('startProfiler', () { - sut.startProfiler(SentryId.newId()); final matcher = _nativeUnavailableMatcher( mockPlatform, androidUnsupported: true, @@ -263,7 +262,7 @@ void main() { mockPlatform, includeLookupSymbol: true, ); - sut.loadDebugImages(SentryStackTrace(frames: [])); + expect( () => sut.loadDebugImages(SentryStackTrace(frames: [])), matcher); From e6771bb1d28bed655ca75f584138387fd8740b9d Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 7 Oct 2025 16:45:56 +0200 Subject: [PATCH 27/30] Update --- CHANGELOG.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fa24a7d360..e8238842ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -79,8 +79,6 @@ - Replay: continue processing if encountering `InheritedWidget` ([#3200](https://github.com/getsentry/sentry-dart/pull/3200)) - Prevents false debug warnings when using [provider](https://pub.dev/packages/provider) for example which extensively uses `InheritedWidget` ->>> main - ## 9.7.0-beta.2 ### Features From e2ae6a3d481b6c16860b34bbcffd08fa0fcaf130 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 7 Oct 2025 16:48:28 +0200 Subject: [PATCH 28/30] Add close --- .../flutter/lib/src/native/cocoa/sentry_native_cocoa.dart | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/flutter/lib/src/native/cocoa/sentry_native_cocoa.dart b/packages/flutter/lib/src/native/cocoa/sentry_native_cocoa.dart index 6707817f2d..0beb6e26b1 100644 --- a/packages/flutter/lib/src/native/cocoa/sentry_native_cocoa.dart +++ b/packages/flutter/lib/src/native/cocoa/sentry_native_cocoa.dart @@ -57,6 +57,12 @@ class SentryNativeCocoa extends SentryNativeChannel { return super.init(hub); } + @override + Future close() async { + await _envelopeSender?.close(); + return super.close(); + } + @override FutureOr captureEnvelope( Uint8List envelopeData, bool containsUnhandledException) { From ae9b24c8e5b9ed1bed382cc4fdd1ebf4423ab953 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Wed, 8 Oct 2025 11:07:32 +0200 Subject: [PATCH 29/30] Review --- .../lib/src/isolate/isolate_worker.dart | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/packages/flutter/lib/src/isolate/isolate_worker.dart b/packages/flutter/lib/src/isolate/isolate_worker.dart index bcf3bdbb67..25b3fcb362 100644 --- a/packages/flutter/lib/src/isolate/isolate_worker.dart +++ b/packages/flutter/lib/src/isolate/isolate_worker.dart @@ -45,19 +45,19 @@ class Worker { } /// Send a request to the worker and await a response. - Future request(Object? payload) { + Future request(Object? payload) async { if (_closed) throw StateError('Worker is closed'); final id = _idCounter++; final completer = Completer.sync(); _pending[id] = completer; _workerPort.send((id, payload)); - return completer.future; + return await completer.future; } void close() { if (_closed) return; - _workerPort.send(_shutdownCommand); _closed = true; + _workerPort.send(_shutdownCommand); if (_pending.isEmpty) { _responses.close(); } @@ -97,11 +97,16 @@ Future spawnWorker( )); }; - await Isolate.spawn<(SendPort, WorkerConfig)>( - entry, - (initPort.sendPort, config), - debugName: config.debugName, - ); + try { + await Isolate.spawn<(SendPort, WorkerConfig)>( + entry, + (initPort.sendPort, config), + debugName: config.debugName, + ); + } on Object { + initPort.close(); + rethrow; + } final (ReceivePort receivePort, SendPort sendPort) = await connection.future; return Worker(sendPort, receivePort); From 4b440d01f8aa721b53b520ca9d62f83120399c99 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Wed, 8 Oct 2025 12:59:27 +0200 Subject: [PATCH 30/30] Review --- packages/flutter/lib/src/isolate/isolate_logger.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/flutter/lib/src/isolate/isolate_logger.dart b/packages/flutter/lib/src/isolate/isolate_logger.dart index bb8a8b7fe0..2b6d8c3667 100644 --- a/packages/flutter/lib/src/isolate/isolate_logger.dart +++ b/packages/flutter/lib/src/isolate/isolate_logger.dart @@ -75,7 +75,7 @@ class IsolateLogger { } static bool _isEnabled(SentryLevel level) { - return _debug && level.ordinal >= _level.ordinal || + return (_debug && level.ordinal >= _level.ordinal) || level == SentryLevel.fatal; } }