diff --git a/packages/pdfrx_engine/lib/src/native/worker.dart b/packages/pdfrx_engine/lib/src/native/worker.dart index b138bfa2..b8de680a 100644 --- a/packages/pdfrx_engine/lib/src/native/worker.dart +++ b/packages/pdfrx_engine/lib/src/native/worker.dart @@ -109,7 +109,17 @@ class BackgroundWorker { /// Inside [callback], you can only use passed message and create new objects. /// You cannot access any variables from the outer scope, otherwise, it will throw an error. Future _compute(PdfrxComputeCallback callback, M message) async { - return await _sendComputeParams((sendPort) => _ExecuteParams(sendPort, callback, message)) as R; + final result = await _sendComputeParams((sendPort) => _ExecuteParams(sendPort, callback, message)); + if (result is _ComputeError) { + // The original error/stack trace object may itself be unsendable (arbitrary user exception types can hold + // non-sendable fields), so only their string forms cross the isolate boundary; reconstruct a generic + // exception here rather than losing the failure silently. + Error.throwWithStackTrace( + _WorkerComputeException(result.errorString), + StackTrace.fromString(result.stackTraceString), + ); + } + return (result as _ComputeResult).value; } /// Runs [callback] in the worker isolate with a new [Arena]. @@ -162,8 +172,43 @@ class _ExecuteParams extends _ComputeParams { final PdfrxComputeCallback callback; final M message; + // A pending Future is not a sendable isolate message on its own (SendPort.send throws + // "object is unsendable" for it), so a callback returning FutureOr only worked by + // accident for callbacks that happened to complete synchronously. Awaiting the result + // here, and always sending a plain, sendable wrapper (value or stringified error), + // makes genuinely asynchronous callbacks (and synchronous throws) work correctly too. + @override + void execute() { + Future asFuture() async => callback(message); + asFuture().then( + (value) => sendPort.send(_ComputeResult(value)), + onError: (Object error, StackTrace stackTrace) => + sendPort.send(_ComputeError(error.toString(), stackTrace.toString())), + ); + } +} + +class _ComputeResult { + _ComputeResult(this.value); + final R value; +} + +class _ComputeError { + _ComputeError(this.errorString, this.stackTraceString); + final String errorString; + final String stackTraceString; +} + +/// Thrown on the caller's isolate when a [BackgroundWorker.compute] callback throws. +/// +/// The original error object is not necessarily sendable across the isolate boundary, +/// so only its [toString] representation survives the round trip; [message] carries it. +class _WorkerComputeException implements Exception { + _WorkerComputeException(this.message); + final String message; + @override - void execute() => sendPort.send(callback(message)); + String toString() => 'Exception in BackgroundWorker.compute callback: $message'; } class _SuspendRequest extends _ComputeParams { diff --git a/packages/pdfrx_engine/test/background_worker_test.dart b/packages/pdfrx_engine/test/background_worker_test.dart new file mode 100644 index 00000000..f32e591e --- /dev/null +++ b/packages/pdfrx_engine/test/background_worker_test.dart @@ -0,0 +1,62 @@ +import 'package:pdfrx_engine/pdfrx_engine.dart'; +import 'package:pdfrx_engine/src/native/worker.dart'; +import 'package:test/test.dart'; + +import 'utils.dart'; + +void main() { + setUp(() => pdfrxInitialize(tmpPath: tmpRoot.path)); + + group('BackgroundWorker.compute', () { + test('still supports a synchronous callback (regression)', () async { + final result = await BackgroundWorker.compute((message) => message * 2, 21); + expect(result, 42); + }); + + test('supports a genuinely asynchronous callback with a real await', () async { + final sw = Stopwatch()..start(); + final result = await BackgroundWorker.compute((message) async { + await Future.delayed(const Duration(milliseconds: 300)); + return message * 2; + }, 21); + sw.stop(); + expect(result, 42); + expect( + sw.elapsedMilliseconds, + greaterThanOrEqualTo(280), + reason: 'the delay must actually have been awaited, not skipped', + ); + }); + + test('supports multiple sequential awaits inside one async callback', () async { + final result = await BackgroundWorker.compute((message) async { + var total = 0; + for (var i = 0; i < 3; i++) { + await Future.delayed(const Duration(milliseconds: 20)); + total += i; + } + return total; + }, null); + expect(result, 0 + 1 + 2); + }); + + test('propagates a synchronous throw as an exception on the caller side', () async { + await expectLater( + BackgroundWorker.compute((message) { + throw StateError('boom: $message'); + }, 'sync'), + throwsA(isA().having((e) => e.toString(), 'toString', contains('boom: sync'))), + ); + }); + + test('propagates an asynchronous throw as an exception on the caller side', () async { + await expectLater( + BackgroundWorker.compute((message) async { + await Future.delayed(const Duration(milliseconds: 10)); + throw StateError('boom: $message'); + }, 'async'), + throwsA(isA().having((e) => e.toString(), 'toString', contains('boom: async'))), + ); + }); + }); +}