diff --git a/CHANGELOG.md b/CHANGELOG.md index fd36fc2746..fefedbdabe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## 9.7.0 + ### Features - Add W3C `traceparent` header support ([#3246](https://github.com/getsentry/sentry-dart/pull/3246)) 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 - 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..2b6d8c3667 --- /dev/null +++ b/packages/flutter/lib/src/isolate/isolate_logger.dart @@ -0,0 +1,81 @@ +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`. +/// +/// 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 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. + /// - [loggerName]: logger name for the call sites + static void configure( + {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 + /// 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/isolate/isolate_worker.dart b/packages/flutter/lib/src/isolate/isolate_worker.dart new file mode 100644 index 0000000000..25b3fcb362 --- /dev/null +++ b/packages/flutter/lib/src/isolate/isolate_worker.dart @@ -0,0 +1,177 @@ +import 'dart:async'; +import 'dart:isolate'; + +import '../../sentry_flutter.dart'; +import 'isolate_logger.dart'; + +const _shutdownCommand = '_shutdown_'; + +// ------------------------------------------- +// HOST-SIDE API (runs on the main isolate) +// ------------------------------------------- + +/// Minimal config passed to isolates - extend as needed. +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, + }); +} + +/// 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, this._responses) { + _responses.listen(_handleResponse); + } + + final SendPort _workerPort; + SendPort get port => _workerPort; + final ReceivePort _responses; + 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) async { + if (_closed) throw StateError('Worker is closed'); + final id = _idCounter++; + final completer = Completer.sync(); + _pending[id] = completer; + _workerPort.send((id, payload)); + return await completer.future; + } + + void close() { + if (_closed) return; + _closed = true; + _workerPort.send(_shutdownCommand); + 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(); + } + } +} + +/// Worker (isolate) entry-point signature. +typedef WorkerEntry = void Function((SendPort, WorkerConfig)); + +/// Spawn a worker isolate and handshake to obtain its SendPort. +Future spawnWorker( + WorkerConfig config, + WorkerEntry entry, +) async { + final initPort = RawReceivePort(); + final connection = Completer<(ReceivePort, SendPort)>.sync(); + initPort.handler = (SendPort commandPort) { + connection.complete(( + ReceivePort.fromRawReceivePort(initPort), + commandPort, + )); + }; + + 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); +} + +// ------------------------------------------- +// ISOLATE-SIDE API (runs inside the worker isolate) +// ------------------------------------------- + +/// 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); + + /// Handle request/response payloads sent from the host. + /// Return value is sent back to the host. Default: no-op. + FutureOr onRequest(Object? payload) => {}; +} + +/// 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, + WorkerHandler handler, +) { + IsolateLogger.configure( + debug: config.debug, + level: config.diagnosticLevel, + loggerName: config.debugName, + ); + + final inbox = ReceivePort(); + host.send(inbox.sendPort); + + inbox.listen((msg) async { + if (msg == _shutdownCommand) { + IsolateLogger.log(SentryLevel.debug, 'Isolate received shutdown'); + inbox.close(); + IsolateLogger.log(SentryLevel.debug, 'Isolate closed'); + return; + } + + if (msg is (int, Object?)) { + final (id, payload) = msg; + try { + final result = await handler.onRequest(payload); + host.send((id, result)); + } catch (e, st) { + host.send((id, RemoteError(e.toString(), st.toString()))); + } + return; + } + + try { + await handler.onMessage(msg); + } catch (exception, stackTrace) { + IsolateLogger.log(SentryLevel.error, 'Isolate failed to handle message', + exception: exception, stackTrace: 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 new file mode 100644 index 0000000000..57f47325c7 --- /dev/null +++ b/packages/flutter/lib/src/native/cocoa/cocoa_envelope_sender.dart @@ -0,0 +1,96 @@ +import 'dart:async'; +import 'dart:isolate'; +import 'dart:typed_data'; + +import 'package:meta/meta.dart'; +import 'package:objective_c/objective_c.dart'; + +import '../../../sentry_flutter.dart'; +import '../../isolate/isolate_worker.dart'; +import '../../isolate/isolate_logger.dart'; +import 'binding.dart' as cocoa; + +typedef SpawnWorkerFn = Future Function(WorkerConfig, WorkerEntry); + +class CocoaEnvelopeSender { + final SentryFlutterOptions _options; + final WorkerConfig _config; + final SpawnWorkerFn _spawn; + Worker? _worker; + + CocoaEnvelopeSender(this._options, {SpawnWorkerFn? spawn}) + : _config = WorkerConfig( + debugName: 'SentryCocoaEnvelopeSender', + debug: _options.debug, + diagnosticLevel: _options.diagnosticLevel, + automatedTestMode: _options.automatedTestMode, + ), + _spawn = spawn ?? spawnWorker; + + @internal + static CocoaEnvelopeSender Function(SentryFlutterOptions) factory = + CocoaEnvelopeSender.new; + + FutureOr start() async { + if (_worker != null) return; + _worker = await _spawn(_config, _entryPoint); + } + + FutureOr close() { + _worker?.close(); + _worker = null; + } + + /// Fire-and-forget send of envelope bytes to the worker. + void captureEnvelope(Uint8List envelopeData) { + final client = _worker; + if (client == null) { + _options.log( + SentryLevel.warning, + 'captureEnvelope called before start; dropping', + ); + return; + } + client.send(TransferableTypedData.fromList([envelopeData])); + } + + static void _entryPoint((SendPort, WorkerConfig) init) { + final (host, config) = init; + runWorker(config, host, _CocoaEnvelopeHandler(config)); + } +} + +class _CocoaEnvelopeHandler extends WorkerHandler { + final WorkerConfig _config; + + _CocoaEnvelopeHandler(this._config); + + @override + FutureOr onMessage(Object? msg) { + if (msg is TransferableTypedData) { + final data = msg.materialize().asUint8List(); + _captureEnvelope(data); + } else { + IsolateLogger.log(SentryLevel.warning, 'Unexpected message type: $msg'); + } + } + + void _captureEnvelope(Uint8List envelopeData) { + try { + final nsData = envelopeData.toNSData(); + final envelope = cocoa.PrivateSentrySDKOnly.envelopeWithData(nsData); + if (envelope != null) { + cocoa.PrivateSentrySDKOnly.captureEnvelope(envelope); + } else { + IsolateLogger.log(SentryLevel.error, + 'Native Cocoa SDK returned null when capturing envelope'); + } + } 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/cocoa/sentry_native_cocoa.dart b/packages/flutter/lib/src/native/cocoa/sentry_native_cocoa.dart index 7145f39f71..0beb6e26b1 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 'cocoa_envelope_sender.dart'; @internal class SentryNativeCocoa extends SentryNativeChannel { CocoaReplayRecorder? _replayRecorder; + CocoaEnvelopeSender? _envelopeSender; SentryId? _replayId; SentryNativeCocoa(super.options); @@ -49,29 +51,22 @@ class SentryNativeCocoa extends SentryNativeChannel { }); } + _envelopeSender = CocoaEnvelopeSender(options); + await _envelopeSender?.start(); + return super.init(hub); } + @override + Future close() async { + await _envelopeSender?.close(); + return super.close(); + } + @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; - } - } + _envelopeSender?.captureEnvelope(envelopeData); } @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 new file mode 100644 index 0000000000..e3855e6f45 --- /dev/null +++ b/packages/flutter/lib/src/native/java/android_envelope_sender.dart @@ -0,0 +1,107 @@ +import 'dart:async'; +import 'dart:isolate'; +import 'dart:typed_data'; + +import 'package:jni/jni.dart'; +import 'package:meta/meta.dart'; + +import '../../../sentry_flutter.dart'; +import '../../isolate/isolate_worker.dart'; +import '../../isolate/isolate_logger.dart'; +import 'binding.dart' as native; + +typedef SpawnWorkerFn = Future Function(WorkerConfig, WorkerEntry); + +class AndroidEnvelopeSender { + final SentryFlutterOptions _options; + final WorkerConfig _config; + final SpawnWorkerFn _spawn; + Worker? _worker; + + AndroidEnvelopeSender(this._options, {SpawnWorkerFn? spawn}) + : _config = WorkerConfig( + debugName: 'SentryAndroidEnvelopeSender', + debug: _options.debug, + diagnosticLevel: _options.diagnosticLevel, + automatedTestMode: _options.automatedTestMode, + ), + _spawn = spawn ?? spawnWorker; + + @internal + static AndroidEnvelopeSender Function(SentryFlutterOptions) factory = + AndroidEnvelopeSender.new; + + FutureOr start() async { + if (_worker != null) return; + _worker = await _spawn(_config, _entryPoint); + } + + FutureOr close() { + _worker?.close(); + _worker = null; + } + + /// Fire-and-forget send of envelope bytes to the worker. + void captureEnvelope( + Uint8List envelopeData, bool containsUnhandledException) { + final client = _worker; + if (client == null) { + _options.log( + SentryLevel.warning, + 'captureEnvelope called before worker started; dropping', + ); + return; + } + client.send(( + TransferableTypedData.fromList([envelopeData]), + containsUnhandledException + )); + } + + static void _entryPoint((SendPort, WorkerConfig) init) { + final (host, config) = init; + 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)) { + final (transferable, containsUnhandledException) = msg; + final data = transferable.materialize().asUint8List(); + _captureEnvelope(data, containsUnhandledException); + } else { + IsolateLogger.log(SentryLevel.warning, 'Unexpected message type: $msg'); + } + } + + void _captureEnvelope( + Uint8List envelopeData, bool containsUnhandledException) { + JObject? id; + JByteArray? byteArray; + try { + byteArray = JByteArray.from(envelopeData); + id = native.InternalSentrySdk.captureEnvelope( + byteArray, containsUnhandledException); + + if (id == null) { + IsolateLogger.log(SentryLevel.error, + 'Native Android SDK returned null when capturing envelope'); + } + } catch (exception, stackTrace) { + IsolateLogger.log(SentryLevel.error, 'Failed to capture envelope', + exception: exception, stackTrace: stackTrace); + if (_config.automatedTestMode) { + rethrow; + } + } finally { + byteArray?.release(); + id?.release(); + } + } +} 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..58fb8657ce 100644 --- a/packages/flutter/lib/src/native/java/sentry_native_java.dart +++ b/packages/flutter/lib/src/native/java/sentry_native_java.dart @@ -8,12 +8,15 @@ import '../../../sentry_flutter.dart'; import '../../replay/scheduled_recorder_config.dart'; import '../sentry_native_channel.dart'; import '../utils/utf8_json.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 @@ -71,34 +74,16 @@ class SentryNativeJava extends SentryNativeChannel { }); } + _envelopeSender = AndroidEnvelopeSender.factory(options); + await _envelopeSender?.start(); + return super.init(hub); } @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(); - } + _envelopeSender?.captureEnvelope(envelopeData, containsUnhandledException); } @override @@ -189,6 +174,7 @@ class SentryNativeJava extends SentryNativeChannel { @override Future close() async { await _replayRecorder?.stop(); + await _envelopeSender?.close(); return super.close(); } } 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'; 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..5d6fddfc96 --- /dev/null +++ b/packages/flutter/test/isolate/isolate_logger_test.dart @@ -0,0 +1,70 @@ +@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() { + setUp(() { + IsolateLogger.reset(); + }); + + 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(); + + 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); + }); + + 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); + }); +} 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..f1b6d630fb --- /dev/null +++ b/packages/flutter/test/isolate/isolate_worker_test.dart @@ -0,0 +1,202 @@ +@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_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..9779018acb --- /dev/null +++ b/packages/flutter/test/native/android_envelope_sender_test.dart @@ -0,0 +1,2 @@ +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 new file mode 100644 index 0000000000..15590a436d --- /dev/null +++ b/packages/flutter/test/native/cocoa_envelope_sender_test.dart @@ -0,0 +1,2 @@ +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. + }); +} diff --git a/packages/flutter/test/sentry_native_channel_test.dart b/packages/flutter/test/sentry_native_channel_test.dart index c0a49c0aa4..1a5c7dd476 100644 --- a/packages/flutter/test/sentry_native_channel_test.dart +++ b/packages/flutter/test/sentry_native_channel_test.dart @@ -238,13 +238,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); },