Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 47 additions & 2 deletions packages/pdfrx_engine/lib/src/native/worker.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<R> _compute<M, R>(PdfrxComputeCallback<M, R> 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<R>).value;
}

/// Runs [callback] in the worker isolate with a new [Arena].
Expand Down Expand Up @@ -162,8 +172,43 @@ class _ExecuteParams<M, R> extends _ComputeParams {
final PdfrxComputeCallback<M, R> 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<R> 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<R> asFuture() async => callback(message);
asFuture().then(
(value) => sendPort.send(_ComputeResult<R>(value)),
onError: (Object error, StackTrace stackTrace) =>
sendPort.send(_ComputeError(error.toString(), stackTrace.toString())),
);
}
}

class _ComputeResult<R> {
_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 {
Expand Down
62 changes: 62 additions & 0 deletions packages/pdfrx_engine/test/background_worker_test.dart
Original file line number Diff line number Diff line change
@@ -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<Exception>().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<Exception>().having((e) => e.toString(), 'toString', contains('boom: async'))),
);
});
});
}