Why does a Dart command-line program or worker isolate keep running after its work is done, and how do you close ports so it exits?
answer
- open receive ports keep isolates alive
- close() or cancel the subscription
- first cancels, so it closes
- RawReceivePort.keepIsolateAlive defaults to true
- shutdown command, then drain pending
basics
~20 sAn open ReceivePort or RawReceivePort keeps its isolate alive, so a forgotten port stops a worker, or a CLI program, from exiting. Close each port with close() or by cancelling its subscription; tell a worker to close its own port.
solid answer
~40 sAn isolate ends when its event loop has nothing left to do and nothing can deliver new events, and an open receive port is exactly such a source: by default a `ReceivePort` or `RawReceivePort` keeps the isolate that created it alive until `close()` is called. So a CLI program whose `main` created a port and never closed it hangs, and a worker that listens on a command port lives forever. Fixes: call `close()` on the port, or cancel its subscription, which closes it too; awaiting `port.first` does that after one message. For a long-lived worker, send a shutdown command that makes the worker close its own `ReceivePort`, and close the main-side port once pending replies have arrived. A `RawReceivePort` can also set `keepIsolateAlive = false` when it should not pin the isolate.
code
dart · 21 linesimport 'dart:isolate';
Future<void> main() async {
final replies = ReceivePort();
await Isolate.spawn(_worker, replies.sendPort);
// Hangs: listen() alone would keep this port open forever.
// replies.listen(print);
// first takes one message, cancels the subscription and closes the port.
final commands = await replies.first as SendPort;
commands.send('shutdown'); // the worker closes its own port and exits
}
void _worker(SendPort replyTo) {
final commands = ReceivePort();
replyTo.send(commands.sendPort);
commands.listen((Object? message) {
if (message == 'shutdown') commands.close();
});
}go deeper
Remember that an open ReceivePort keeps its isolate running, so every port you create needs a close() somewhere.
Explain the ways a port closes: close(), cancelling the subscription, awaiting first, and keepIsolateAlive on a RawReceivePort, and why each side closes its own port.
Design shutdown: a closed flag, a shutdown command, draining pending replies before closing, and closing handshake ports when spawning fails.
Treat workers as owned resources with a lifecycle, deciding which component owns and closes each one, instead of spawning them ad hoc per screen.
## What keeps an isolate alive A Dart isolate runs its entry function and then keeps serving its **event loop**. It ends when the loop is empty and nothing can bring new events in. Pending timers and outstanding I/O are such sources, and so is every **open receive port**, because another isolate might still send to it. The `RawReceivePort.keepIsolateAlive` documentation states the rule directly: by default, receive ports keep the isolate that created them alive until `close()` is called. A `ReceivePort` behaves the same way. ## The symptoms - **A CLI program never returns to the shell.** `main` created a `ReceivePort` for replies, got its answer, printed it and returned, yet the process keeps running because the port is still open. - **A worker isolate never goes away.** The worker's entry point listens on a command `ReceivePort`. Even when the main isolate drops every reference to the worker, the worker's own port keeps it alive and its memory stays allocated. - **In a Flutter app** the UI isolate lives as long as the app, so the visible symptom is not a hang but a leaked worker: memory held by an idle isolate, and one more for every screen that spawned and forgot one. ## Ways to close a port | Action | Effect | |---|---| | `receivePort.close()` | stops delivery; an active listener gets its `onDone` callback | | cancelling the `StreamSubscription` | closes the `ReceivePort`: "a receive port is closed by canceling its subscription" | | `await receivePort.first` | takes one message, cancels the subscription, so the port closes | | `rawPort.close()` | later messages are silently dropped; the handler never runs again | | `rawPort.keepIsolateAlive = false` | the port stays open but no longer pins the isolate; if the isolate exits, the port closes | Messages sent to a closed port are dropped without an error in the sender, so a closed channel fails silently. Track closed state yourself if callers need to know. ## A clean shutdown for a long-lived worker The dart.dev robust worker example closes both ends in order: 1. `close()` on the main-side wrapper sets a `_closed` flag, so new requests throw a `StateError` instead of being sent into the void. 2. It sends a **shutdown command**, any agreed message such as `'shutdown'`, to the worker's command port. 3. The worker's listener sees the command and calls `close()` on its own `ReceivePort`. With no open ports and no pending work, the worker isolate exits. 4. The main side closes its response port immediately if nothing is pending, or in the response handler once the last pending reply arrives. Closing the response port before pending replies arrive would drop them and leave their futures incomplete forever, which is why step 4 waits. ## Choosing between polite and forceful - **Polite shutdown**, closing ports from inside, lets the worker finish the job in hand and exit cleanly. - **Forceful termination** with `Isolate.kill` ends the worker without its cooperation. It belongs to the spawning API, and it needs no port protocol, but in-flight work is lost. - A port that only exists to observe something optional, such as a debug channel, can set `keepIsolateAlive = false` on a `RawReceivePort` so it never blocks exit. ## Where Flutter apps leak workers - A screen spawns a worker in `initState` and never closes it in `dispose`, so every visit adds another idle isolate. - A worker is created lazily inside a service object, but that object is recreated, for example when the provider holding it rebuilds, and nobody closes the old worker. - A handshake times out or spawning fails, and the handshake port is left open, pinning resources in the main isolate. - A test spawns a worker and never shuts it down, so the test process hangs after the last test finishes. ## Checklist - Every `ReceivePort` you create has a matching `close()` on some path, including error paths. - Handshake ports are closed when spawning fails. - Worker wrappers expose `close()` and callers, such as a Flutter `State.dispose`, call it. - Pending requests are completed or failed before the response port closes.
- Does closing the main isolate's ReceivePort stop the worker isolate?No. Each isolate is kept alive by its own open ports. Closing the main side only means later replies are dropped; the worker keeps listening on its command port until it closes that port itself, is killed, or the process ends.
- Why does the robust worker throw a StateError from requests made after close()?Because a send into a closed channel fails silently: the message is dropped and the caller's future would never complete. Checking a `_closed` flag first turns that silent hang into an immediate, visible error.
- Where should a Flutter screen that owns a worker close it?In `State.dispose`, alongside other resources the state owns. If several screens share the worker, give it an owner with a longer life, such as an app-level service, and close it when that owner is torn down.
saying these in an interview costs you the question
- A worker isolate exits once the main isolate drops its reference to it
- Closing the main isolate's ReceivePort also stops the worker
- An isolate exits as soon as its entry function returns, whatever ports are open
- Sending to a closed port throws an error in the sender
- The garbage collector closes a ReceivePort nobody references