In Dart, which objects can you send through SendPort.send, and why does sending a closure sometimes fail with an illegal-argument error?
answer
- same isolate group: almost anything
- native resources cannot cross
- ReceivePort itself is unsendable
- spawnUri: literals, collections, ports only
- closures drag their captured context
basics
~20 sBetween isolates sharing code, almost any object can be sent except native-resource holders (sockets, open files), ReceivePort, Finalizer and similar. spawnUri isolates accept only primitives, plain collections and ports. Closures fail when their captured context reaches something unsendable.
solid answer
~40 sIt depends on whether the two isolates share code. For isolates in the same isolate group, which is what `Isolate.spawn` and `Isolate.run` create, the message can contain almost any object, including instances of your own classes, records and closures. The exceptions are objects holding native resources, such as a `Socket` or an open file, plus `ReceivePort`, `DynamicLibrary`, `Finalizable`, `Finalizer`, `NativeFinalizer`, `UserTag` and classes marked `@pragma('vm:isolate-unsendable')`. For an isolate started with `Isolate.spawnUri`, the list is strict: `null`, booleans, numbers, strings, collections made by literals or the `List`/`Map`/`LinkedHashMap`/`Set`/`LinkedHashSet` constructors, `TransferableTypedData`, `Capability` and `SendPort`s. Closures fail because the VM copies their captured context, which can hold more than the closure uses, such as `this` with an open file inside; the send then fails with 'Illegal argument in isolate message'.
code
dart · 20 linesimport 'dart:io';
import 'dart:isolate';
class ThumbnailJob {
ThumbnailJob(this.source, this.log);
final List<int> source;
final RandomAccessFile log; // an open file: a native resource
// Fails: reading `source` captures `this`, and `this` holds `log`.
Future<int> badChecksum() => Isolate.run(() => _checksum(source));
// Works: the helper's closure captures only its own parameter.
Future<int> goodChecksum() => _checksumInIsolate(source);
static Future<int> _checksumInIsolate(List<int> bytes) =>
Isolate.run(() => _checksum(bytes));
}
int _checksum(List<int> bytes) =>
bytes.fold(0, (sum, b) => (sum + b) & 0xFFFF);go deeper
Remember the short answer: plain data goes through, things tied to native resources do not, and a ReceivePort never travels; you send its sendPort instead.
Explain the two rule sets, same group versus spawnUri, and why a closure that reads a field captures this and can fail with an illegal-argument error.
Show how you keep messages small and sendable by design: records or sealed command types, static helpers for isolate calls, and no service objects inside messages.
Weigh the convenience of sending rich objects inside one group against the coupling it creates; a flat message format keeps a worker replaceable and testable.
## Two rule sets, chosen by shared code `SendPort.send` checks the **transitive object graph** of every message: the object you pass plus everything it references. Which objects are allowed depends on whether the sender and receiver **share the same code**. - Isolates created with `Isolate.spawn`, and therefore `Isolate.run`, join the spawner's **isolate group**. They run the same program, so the receiver knows every class the sender might send. - An isolate created with `Isolate.spawnUri` loads a different program. It may not have your classes at all, so only a small set of universally understood values is allowed. ## Same isolate group: almost everything Inside one group, any object can be sent except these kinds, as listed in the `SendPort.send` documentation: - **Objects with native resources**, meaning subclasses of classes such as `NativeFieldWrapperClass1`. A `Socket` is the documented example, and open files behave the same way. - **`ReceivePort`**. A receive port belongs to the isolate that created it; you send its `sendPort` instead. - **`DynamicLibrary`**, **`Finalizable`**, **`Finalizer`**, **`NativeFinalizer`**, **`UserTag`** and the mirrors `MirrorReference`. - **Any class marked `@pragma('vm:isolate-unsendable')`**, or one that extends or implements such a class. Everything else goes: your own classes, records, enums, typed data, `SendPort`s and closures. Immutable objects such as strings are shared, everything else is copied. ## Different code: the strict list For a `spawnUri` isolate the transitive graph may contain only: | Allowed | Notes | |---|---| | `null`, `true`, `false` | | | `int`, `double`, `String` | | | lists, maps, sets | made by literals or by the `List`, `Map`, `LinkedHashMap`, `Set`, `LinkedHashSet` constructors | | `TransferableTypedData`, `Capability` | | | `SendPort` | from `ReceivePort.sendPort` or `RawReceivePort.sendPort` | | some `Type` objects | for the types above, `Object`, `dynamic`, `void`, `Never` | So a `HashMap`, a record or an instance of your own class cannot cross to a `spawnUri` isolate. Encode such data as maps and lists first. ## Why closures surprise people Since Dart 2.15, closures may be sent and may be used as spawn entry points. A closure is sent together with its **enclosing context**, the variables it captured, and that context is checked like any other message. Two things go wrong in practice: 1. **Capturing `this`.** Reading a field inside a closure written in an instance method captures `this`, so the whole object and everything it references travels too. If that object holds an open file or socket, the send fails. 2. **Over-capture.** The `SendPort.send` and `Isolate.run` documentation warn that the VM's current closure representation can capture more state than the closure needs (dartbug 36983), so a closure can carry variables it never reads. When the graph contains something unsendable, the send fails with an `ArgumentError` whose text reads "Illegal argument in isolate message". When nothing is illegal but the captured graph is large, the symptom is a slow send and extra memory instead. The documented fix is to move the isolate call into a **small helper that takes only the data it needs as parameters**, or to use a top-level or static function, so the captured context holds just those values. ## Diagnosing a failed or slow send 1. Read the error: "Illegal argument in isolate message" means something reachable from the message is on the forbidden list. 2. If a closure is involved, list what it captures, including `this` and variables shared with other closures in the same scope. 3. Move the isolate call into a static or top-level helper that receives only the values it needs. 4. If the send succeeds but is slow, measure the size of what is being copied; a closure can quietly drag a large cache or model along with it. ## Practical rules - Send data, not services: plain values, records and small immutable classes travel well. - Never put a `ReceivePort`, a socket, a file handle or a `Finalizer` in a message; send a `SendPort` or a path instead. - For `spawnUri` isolates, flatten data to literal-style maps and lists. - Keep isolate closures in helpers or static methods so `this` is never captured by accident. - In Flutter, avoid closures written inside a `State` method that read `widget` or other fields; they capture the `State` object and everything it points to.
- Why can you send a SendPort but not a ReceivePort?A `ReceivePort` is the receiving end owned by the isolate that created it; its messages are delivered on that isolate's event loop, so moving it makes no sense and the runtime refuses it. A `SendPort` is just an address. To let another isolate receive, you send it your `sendPort`, or it creates its own `ReceivePort` and sends you its `sendPort`.
- Can you send a record or a sealed-class instance to a worker started with Isolate.spawn?Yes. Isolates created by `Isolate.spawn` share code, so records, enums and your own class instances are allowed and copied, which makes `(id, payload)` records and sealed command types natural message formats. The same objects would be rejected by an isolate created with `Isolate.spawnUri`, which accepts only primitives, plain collections, ports and a few special types.
- How would you mark one of your own classes so it can never be sent by mistake?Annotate it with `@pragma('vm:isolate-unsendable')`. The `SendPort.send` documentation says instances of such classes, and of classes that extend or implement them, cannot be sent, so an accidental capture fails loudly instead of copying a handle-like object.
saying these in an interview costs you the question
- Closures can never be sent to another isolate
- An open file or socket can be sent and used on the other side
- Your own classes must be converted to JSON before any isolate send
- spawnUri isolates accept the same objects as Isolate.spawn ones
- A ReceivePort can be sent so the other isolate listens on it