Skip to content

Commit 62b0b59

Browse files
authored
feat: add cross-platform transferables support and lean transport benchmarks (#57)
* Add cross-platform transferables support and wasm web coverage * Add native transferable support and streamline benchmarks * style: apply dart format * feat: Introduce `bufferToJSArrayBuffer` helper with conditional imports to abstract web-specific `JSArrayBuffer` conversion for transferables. * fix: Skip web transfer benchmarks 10MB and larger in WASM environments to prevent CI timeouts. * feat: Add `processBytes` worker function, integrate code generation for workers, and simplify web transferable handling by removing custom helpers. * fix: correct ByteBuffer equality matching in native transferable codec and add comprehensive tests - Fix HashSet<ByteBuffer>.identity() → HashSet<ByteBuffer>() in encodeNativeTransferPayload: Dart VM creates a new _ByteBuffer wrapper on every .buffer call, so identity comparison always returned false, silently bypassing all Uint8List/ByteBuffer transferable encoding. - Add TypedData guard in decodeValue before the List branch: Uint8List implements List<int>, so without the guard non-target typed-data values were iterated element-by-element and returned as List<Object?> instead of preserved as Uint8List. - Expand native_transferable_codec_test.dart from 4 to 17 tests, covering all encode/decode branches: Uint8List direct transferable, List payload, non-string key Map, buffer/TTD deduplication, non-matching type skip, and all decode edge cases. - Add ByteBuffer and List branch coverage to auto_transfer_test.dart via a new testWorkerBufferAndList worker. * docs: Add documentation for zero-copy data transfers (transferables) for `Uint8List` and `ByteBuffer`, including usage, auto-extraction, and platform-specific behaviors.
1 parent 74a7b65 commit 62b0b59

31 files changed

Lines changed: 21675 additions & 42 deletions

README.md

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -466,6 +466,85 @@ void main() async {
466466

467467
An `UnsupportedImTypeException` is thrown if `ImList.wrap` or `ImMap.wrap` encounters a type that cannot be converted.
468468

469+
### Zero-Copy Data Transfers (Transferables)
470+
471+
For large `Uint8List` or `ByteBuffer` payloads, pass a `transferables` list to `compute()` to enable zero-copy transport instead of copying bytes across isolate boundaries.
472+
473+
#### Basic usage
474+
475+
```dart
476+
@pragma('vm:entry-point')
477+
@isolateManagerWorker
478+
Uint8List processImage(Uint8List data) {
479+
// ... image processing ...
480+
return data;
481+
}
482+
483+
final manager = IsolateManager.create(processImage, workerName: 'processImage');
484+
await manager.start();
485+
486+
final pixels = Uint8List(1920 * 1080 * 4); // ~8 MB RGBA frame
487+
488+
final result = await manager.compute(
489+
pixels,
490+
transferables: [pixels.buffer], // zero-copy send
491+
);
492+
```
493+
494+
On **native (VM)** the buffer is wrapped in a `TransferableTypedData` and sent O(1) — no byte copying. On **web (dart2js)** the `ArrayBuffer` is transferred via the `postMessage` transfer list, also O(1), and the source buffer is **detached** (its `lengthInBytes` becomes 0) after the call returns.
495+
496+
#### Auto-extraction with `sendResultWithAutoTransfer`
497+
498+
Inside a custom isolate, the `AutoTransferExtension` recursively finds every `Uint8List` / `ByteBuffer` in the result and transfers them automatically — no manual bookkeeping needed:
499+
500+
```dart
501+
import 'package:isolate_manager/isolate_manager.dart';
502+
503+
@pragma('vm:entry-point')
504+
void processingWorker(dynamic params) {
505+
final controller =
506+
IsolateManagerController<Map<String, Object?>, Uint8List>(params);
507+
508+
controller.onIsolateMessage.listen((input) {
509+
final output = Uint8List(input.length);
510+
for (var i = 0; i < output.length; i++) {
511+
output[i] = (input[i] + 1) % 256;
512+
}
513+
514+
// Finds all Uint8List/ByteBuffer in the map and transfers them zero-copy.
515+
controller.sendResultWithAutoTransfer({'result': output, 'size': output.length});
516+
});
517+
518+
controller.initialized();
519+
}
520+
```
521+
522+
#### Pros, cons, and platform behaviour
523+
524+
| | Native (VM) | Web — dart2js | Web — dart2wasm |
525+
|---|---|---|---|
526+
| **No transferables** | Bytes deep-copied O(n) | Bytes serialised & copied O(n) | Bytes copied O(n) |
527+
| **`transferables: [data.buffer]`** | Zero-copy via `TransferableTypedData` (O(1) transport; small codec overhead) | Zero-copy via `ArrayBuffer` transfer (O(1)); source buffer detached | ⚠ No benefit — WASM linear memory must be copied to the JS heap regardless |
528+
| **Pre-built `TransferableTypedData`** | Fastest — skips codec overhead entirely | N/A | N/A |
529+
530+
**Native (VM)**
531+
532+
* ✅ Eliminates the O(n) copy for large buffers; measurable improvement at ~1 MB+.
533+
* ✅ Pre-building `TransferableTypedData` before calling `compute()` removes the codec overhead and is the fastest option.
534+
* ⚠ Small buffers (< ~100 KB) may see no net gain or a slight regression due to codec overhead.
535+
* ⚠ The source buffer is consumed by `TransferableTypedData`; do not reuse the original `Uint8List` after calling `compute()` with it as a transferable.
536+
537+
**Web — dart2js**
538+
539+
* ✅ Source `ArrayBuffer` is transferred in O(1); the worker receives the original memory.
540+
* ✅ Large speedups (2–10×) for MB-range payloads compared to copy-based transfer.
541+
* ⚠ Source buffer is **neutered** after `compute()` returns — `data.buffer.lengthInBytes` becomes 0. Keep a reference to the result instead.
542+
543+
**Web — dart2wasm**
544+
545+
* ⚠ WASM uses linear memory that is opaque to the JS engine. Every transfer still requires a copy from the WASM heap to a JS `ArrayBuffer`, so using `transferables` adds codec overhead with no speed benefit.
546+
* Prefer omitting `transferables` when targeting WASM.
547+
469548
### Handling Exceptions (Web)
470549

471550
To ensure custom exceptions are correctly propagated from Web Workers:

lib/isolate_manager.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,4 @@ export 'src/models/isolate_manager_shared_worker.dart';
1313
export 'src/models/isolate_manager_worker.dart';
1414
export 'src/models/isolate_types.dart';
1515
export 'src/models/queue_strategy.dart';
16+
export 'src/utils/auto_transfer.dart';

lib/src/base/contactor/isolate_contactor.dart

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,8 +65,11 @@ abstract class IsolateContactor<R, P> {
6565

6666
/// Send message to the `function` for computing
6767
///
68+
/// [transferables] - Optional list of transferable objects (e.g., ByteBuffer)
69+
/// that will be transferred instead of copied.
70+
///
6871
/// Throw `IsolateContactorException` when error occurs.
69-
Future<R> sendMessage(P message);
72+
Future<R> sendMessage(P message, {List<Object>? transferables});
7073

7174
/// Listen to the result from the isolate.
7275
Stream<R> get onMessage;

lib/src/base/contactor/isolate_contactor/isolate_contactor_stub.dart

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,9 +98,12 @@ class IsolateContactorInternal<R, P> extends IsolateContactor<R, P> {
9898
}
9999

100100
@override
101-
Future<R> sendMessage(P message) async {
101+
Future<R> sendMessage(P message, {List<Object>? transferables}) async {
102102
printDebug(() => '[Main App] Message sent to isolate: $message');
103-
_isolateContactorController.sendIsolate(message);
103+
_isolateContactorController.sendIsolate(
104+
message,
105+
transferables: transferables,
106+
);
104107
return _isolateContactorController.onMessage.first;
105108
}
106109
}

lib/src/base/contactor/isolate_contactor/web_platform/isolate_contactor_web.dart

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,9 +75,12 @@ class IsolateContactorInternalFuture<R, P>
7575
}
7676

7777
@override
78-
Future<R> sendMessage(P message) async {
78+
Future<R> sendMessage(P message, {List<Object>? transferables}) async {
7979
printDebug(() => '[Main App] Message sent to the Web Future: $message');
80-
_isolateContactorController.sendIsolate(message);
80+
_isolateContactorController.sendIsolate(
81+
message,
82+
transferables: transferables,
83+
);
8184
return _isolateContactorController.onMessage.first;
8285
}
8386
}

lib/src/base/contactor/isolate_contactor/web_platform/isolate_contactor_web_worker.dart

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,9 +81,12 @@ class IsolateContactorInternalWorker<R, P>
8181
}
8282

8383
@override
84-
Future<R> sendMessage(P message) async {
84+
Future<R> sendMessage(P message, {List<Object>? transferables}) async {
8585
printDebug(() => '[Main App] Message sent to the Web Worker: $message');
86-
_isolateContactorController.sendIsolate(message);
86+
_isolateContactorController.sendIsolate(
87+
message,
88+
transferables: transferables,
89+
);
8790
return _isolateContactorController.onMessage.first;
8891
}
8992
}

lib/src/base/contactor/isolate_contactor_controller.dart

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,13 +46,21 @@ abstract class IsolateContactorController<R, P> {
4646
Completer<void> get ensureInitialized;
4747

4848
/// Send `message` to the isolate for computation
49-
void sendIsolate(P message);
49+
///
50+
/// [transferables] - Optional list of transferable objects (e.g., ByteBuffer)
51+
/// that will be transferred instead of copied. Only works on web platform.
52+
/// On native platforms, listed buffers are encoded as TransferableTypedData.
53+
void sendIsolate(P message, {List<Object>? transferables});
5054

5155
/// Send an `IsolateState` message to the isolate
5256
void sendIsolateState(IsolateState state);
5357

5458
/// Send the `result` of computation to `onIsolateMessage` stream
55-
void sendResult(R result);
59+
///
60+
/// [transferables] - Optional list of transferable objects (e.g., ByteBuffer)
61+
/// that will be transferred instead of copied. Only works on web platform.
62+
/// On native platforms, listed buffers are encoded as TransferableTypedData.
63+
void sendResult(R result, {List<Object>? transferables});
5664

5765
/// Send the `Exception` to the main app
5866
void sendResultError(IsolateException exception);

lib/src/base/contactor/isolate_contactor_controller/isolate_contactor_controller_stub.dart

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import 'package:isolate_manager/src/base/contactor/isolate_contactor_controller.
55
import 'package:isolate_manager/src/base/contactor/models/isolate_port.dart';
66
import 'package:isolate_manager/src/base/contactor/models/isolate_state.dart';
77
import 'package:isolate_manager/src/models/isolate_exceptions.dart';
8+
import 'package:isolate_manager/src/utils/native_transferable_codec.dart';
89
import 'package:isolate_manager/src/utils/print.dart';
910
import 'package:stream_channel/isolate_channel.dart';
1011

@@ -63,8 +64,12 @@ class IsolateContactorControllerImpl<R, P>
6364
}
6465

6566
@override
66-
void sendIsolate(P message) {
67-
_delegate.sink.add({IsolatePort.isolate: message});
67+
void sendIsolate(P message, {List<Object>? transferables}) {
68+
final payload = encodeNativeTransferPayload(
69+
message,
70+
transferables: transferables,
71+
);
72+
_delegate.sink.add({IsolatePort.isolate: payload});
6873
}
6974

7075
@override
@@ -73,8 +78,12 @@ class IsolateContactorControllerImpl<R, P>
7378
}
7479

7580
@override
76-
void sendResult(R message) {
77-
_delegate.sink.add({IsolatePort.main: message});
81+
void sendResult(R message, {List<Object>? transferables}) {
82+
final payload = encodeNativeTransferPayload(
83+
message,
84+
transferables: transferables,
85+
);
86+
_delegate.sink.add({IsolatePort.main: payload});
7887
}
7988

8089
@override
@@ -105,11 +114,13 @@ class IsolateContactorControllerImpl<R, P>
105114
}
106115

107116
void _handleMainPort(dynamic value) {
117+
final decodedValue = decodeNativeTransferPayload(value);
118+
108119
debugPrinter(
109-
() => '[Main App] Message received from the Isolate: $value',
120+
() => '[Main App] Message received from the Isolate: $decodedValue',
110121
debug: _debugMode,
111122
);
112-
switch (value) {
123+
switch (decodedValue) {
113124
case == IsolateState.initialized:
114125
if (!ensureInitialized.isCompleted) {
115126
ensureInitialized.complete();
@@ -118,7 +129,9 @@ class IsolateContactorControllerImpl<R, P>
118129
_mainStreamController.addError(e.error, e.stackTrace);
119130
default:
120131
try {
121-
_mainStreamController.add(_converter?.call(value) ?? value as R);
132+
_mainStreamController.add(
133+
_converter?.call(decodedValue) ?? decodedValue as R,
134+
);
122135
// To catch both Error and Exception
123136
// ignore: avoid_catches_without_on_clauses
124137
} catch (e, stack) {
@@ -128,17 +141,19 @@ class IsolateContactorControllerImpl<R, P>
128141
}
129142

130143
Future<void> _handleIsolatePort(dynamic value) async {
144+
final decodedValue = decodeNativeTransferPayload(value);
145+
131146
debugPrinter(
132-
() => '[Isolate] Message received from the Main App: $value',
147+
() => '[Isolate] Message received from the Main App: $decodedValue',
133148
debug: _debugMode,
134149
);
135-
switch (value) {
150+
switch (decodedValue) {
136151
case == IsolateState.dispose:
137152
_onDispose?.call();
138153
await close();
139154
default:
140155
try {
141-
_isolateStreamController.add(value as P);
156+
_isolateStreamController.add(decodedValue as P);
142157
// To catch both Error and Exception
143158
// ignore: avoid_catches_without_on_clauses
144159
} catch (e, stack) {

lib/src/base/contactor/isolate_contactor_controller/web_platform/isolate_contactor_controller_web.dart

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,9 +64,17 @@ class IsolateContactorControllerImplFuture<R, P>
6464
}
6565

6666
@override
67-
void sendIsolate(P message) {
67+
void sendIsolate(P message, {List<Object>? transferables}) {
6868
if (_delegate.isClosed) return;
6969

70+
if (transferables != null && transferables.isNotEmpty) {
71+
debugPrinter(
72+
() =>
73+
'[Main App] transferables ignored in Web Future mode. Set workerName to use Worker transfer lists.',
74+
debug: _debugMode,
75+
);
76+
}
77+
7078
_delegate.sink.add({IsolatePort.isolate: message});
7179
}
7280

@@ -78,9 +86,17 @@ class IsolateContactorControllerImplFuture<R, P>
7886
}
7987

8088
@override
81-
void sendResult(R message) {
89+
void sendResult(R message, {List<Object>? transferables}) {
8290
if (_delegate.isClosed) return;
8391

92+
if (transferables != null && transferables.isNotEmpty) {
93+
debugPrinter(
94+
() =>
95+
'[Isolate] transferables ignored in Web Future mode. Set workerName to use Worker transfer lists.',
96+
debug: _debugMode,
97+
);
98+
}
99+
84100
_delegate.sink.add({IsolatePort.main: message});
85101
}
86102

lib/src/base/contactor/isolate_contactor_controller/web_platform/isolate_contactor_controller_web_worker.dart

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import 'package:isolate_manager/src/base/contactor/isolate_contactor_controller/
55
import 'package:isolate_manager/src/base/isolate_contactor.dart';
66
import 'package:isolate_manager/src/models/isolate_types.dart';
77
import 'package:isolate_manager/src/utils/check_subtype.dart';
8+
import 'package:isolate_manager/src/utils/extract_array_buffers.dart';
89
import 'package:isolate_manager/src/utils/print.dart';
910
import 'package:web/web.dart';
1011

@@ -50,11 +51,15 @@ class IsolateContactorControllerImplWorker<R, P>
5051
Stream<R> get onMessage => _mainStreamController.stream;
5152

5253
@override
53-
void sendIsolate(P message) {
54-
if (message is ImType) {
55-
_delegate.postMessage(message.unwrap.jsify());
54+
void sendIsolate(P message, {List<Object>? transferables}) {
55+
final jsMessage =
56+
message is ImType ? message.unwrap.jsify() : message.jsify();
57+
58+
if (transferables != null && transferables.isNotEmpty) {
59+
final jsTransferables = extractArrayBuffers(transferables);
60+
_delegate.postMessage(jsMessage, jsTransferables);
5661
} else {
57-
_delegate.postMessage(message.jsify());
62+
_delegate.postMessage(jsMessage);
5863
}
5964
}
6065

@@ -74,7 +79,7 @@ class IsolateContactorControllerImplWorker<R, P>
7479
throw UnimplementedError('initialized method is not implemented');
7580

7681
@override
77-
void sendResult(R message) =>
82+
void sendResult(R message, {List<Object>? transferables}) =>
7883
throw UnimplementedError('sendResult is not implemented');
7984

8085
@override

0 commit comments

Comments
 (0)