Skip to content

Cancellation

cancel() requests; the body stops at a checkpoint. Which checkpoint it is decides what happens to the operation behind it:

Job<void>((ctx) async {
// The database client's own stop signal, cancelled with the job.
final stop = CancelToken();
ctx.onCancel(stop.cancel);
// The wait ends at once; the read goes on, and its value is dropped.
final rows = await ctx.abandonable(database.readAll);
// Waited for until the migration ends or stops at the token, and
// only then does the job give up.
await ctx.join(() => database.migrate(stop));
// The cancellation waits for the whole step, and the child it
// runs is not cancelled; the next checkpoint throws it.
await ctx.uncancellable(() async {
await database.writeVersion();
await ctx.run(database.readyFlag());
});
// Nothing to wrap between the steps of a calculation.
ctx.check();
use(rows);
});
Method If cancellation arrives while it waits
ctx.abandonable(action) Abandons the action: stops waiting for it and throws Cancelled at once, while the action goes on. Its result is dropped, or goes to the call’s dispose or discard if it has one.
ctx.join(action) Joins the action: keeps waiting for it until it ends, then throws Cancelled instead of returning the value, or the action’s own error if it failed. If the call has a dispose or discard, the value goes there first, and join throws once that callback has finished.
ctx.uncancellable(action) Holds the request until the section ends: no onCancel, no cascade to children while it runs.
ctx.pause(duration) Throws Cancelled at once, and cancels its timer.
ctx.check() Throws when the job has already accepted cancellation.

A running job accepts the request inside cancel() itself, unless an uncancellable section holds it back or the job was created with cancellable: false. Accepting it makes the job cancelled: the cancellation passes to the children it has started, its onCancel callbacks run, and the job ends Cancelled whatever the body does next. What the members of the context do on a cancelled job:

  • From then on check, abandonable, join, pause, uncancellable, run, start, runAll and each throw Cancelled at their checkpoints.
  • onCancel throws too: its callbacks have already run, and one registered now never would.
  • onDispose, onDiscard, disown and unattended remain available, so the body can arrange cleanup after cancellation.

A body can cancel itself, by throwing Cancelled or by letting out the cancellation of a child. It accepts the cancellation as it throws, and the job is cancelled the same way: the cancellation passes to its children, its onCancel callbacks run, and it ends Cancelled.

Inside an action passed to the context, a plain await is right: the context adds no checkpoint between the steps of that action. A step that needs a check of its own takes a context call of its own. A step that must finish puts two writes in a join each and then in one join, and shows what each form does when the cancellation comes during the first write.

A pause and a deadline have a page of their own: Time puts ctx.pause next to a plain delay and gives a job a limit with timeout.

The lines under the code are what it prints when it runs: the database says what it writes, cancel is the moment the user cancels, and outcome: is what job.done completes with. printing, the observer of every job below, prints what reaches it: log: for ctx.log, onError: for an error.

The job opens a database, migrates it and hands it over; discard closes it unless the job ends Done. The migration takes three steps of 10 ms and reads a CancelToken before each, the database client’s stop signal and not a type of this package; finding it cancelled, it stops by throwing DatabaseStopped. When the user cancels, the migration should stop, and the database should close once nothing writes into it. The user cancels during the second step, 15 ms in.

The body waits for the migration through abandonable, so that a cancellation ends the job at once:

// The first attempt: the database closes under a running migration.
final job = Job<Database>(observer: printing, (ctx) async {
final database = await ctx.join(
Database.open,
discard: (database) => database.close(),
);
final stop = CancelToken();
await ctx.abandonable(() => database.migrate(stop));
return database;
});
step 1
cancel
database closed
outcome: Cancelled(manual)
step 2 on a closed database
step 3 on a closed database

abandonable ends the waiting, not the migration. This is what happens:

  1. The cancellation comes out of abandonable at once, and the job ends.
  2. discard closes the database with two steps of the migration still to write.
  3. Whatever the migration throws afterwards goes to onError and, by default, on to the zone.

abandonable is for an operation the job may walk away from, such as a read whose result nobody needs any more.

A result arriving that late is dropped, or goes to the dispose or discard passed to abandonable. While the body or the job’s children still run, that callback runs at once, and the job awaits it. If the job is already running its cleanup stack, the callback joins the stack. Once the job has finished, the callback runs on its own. A value that comes late on the cleanup rules page takes this order apart.

join stays with the migration to its end:

// The second attempt: the migration nobody wants runs to its end.
await ctx.join(() => database.migrate(stop));
step 1
cancel
step 2
step 3
database closed
outcome: Cancelled(manual)

Nothing writes into a closed database now: join waits for the migration, and the job gives up only after it. But nothing told the migration to stop either, so it writes the step nobody wants any more, and the outcome arrives when the migration would have ended anyway.

The migration knows nothing about the job’s cancellation: it listens only to its token. onCancel cancels the token together with the job:

final stop = CancelToken();
ctx.onCancel(stop.cancel);
await ctx.join(() => database.migrate(stop));
step 1
cancel
step 2
migration stopped
onError: DatabaseStopped
database closed
outcome: Cancelled(manual)

The callback given to ctx.onCancel runs synchronously when the job accepts the cancellation, before the body reaches a checkpoint. Here it cancels the token, the migration reads it before the third step and throws DatabaseStopped, and join waits for that before the job closes the database. A request to abort or a subscription to cancel goes through onCancel the same way. A stop that takes a while needs more, and A stop that takes time at the end of the page shows what.

The onError: line is that DatabaseStopped. join throws an error of its action as it is, even after a cancellation, and the body does not catch it. The job still ends Cancelled, so no outcome carries the error. It goes the way of any such failure of the body: to the observer, then to the zone, unless the observer catches it in onUnanswered. Each stop would reach the crash reports of the app as a failure. So the app names the stop of its database client once, in main, and every job on this page runs with that line:

Job.isCancellationError = (error) => error is DatabaseStopped;

An error it names reaches the observer and stops there. Errors of a cancellation on the errors page takes the filter apart. What a catch around this join sees is the subject of Catching errors of the operation, below.

The migration is over, and the last step marks the database ready: it writes the schema version and then the ready flag. A database with the version and no flag is neither old nor ready. The two writes are two calls of the database, writeVersion and writeReadyFlag. The user cancels while the version is being written.

join waits for its action, so each write goes through one:

// The first attempt: a cancellation during the first write skips the second.
await ctx.join(database.writeVersion);
await ctx.join(database.writeReadyFlag);
cancel
version written
outcome: Cancelled(manual)

The step does not finish: the database is left with a version and no ready flag. Each join is a checkpoint of its own. The first one waits for the version and then throws the cancellation, and the body never reaches the second.

await ctx.join(() async {
await database.writeVersion();
await database.writeReadyFlag();
});
cancel
version written
ready flag written
outcome: Cancelled(manual)

The action has no checkpoint inside, so both writes are made, and the cancellation comes out of join after them. That is enough while the step is plain code: it takes no token that onCancel cancels, starts no child and passes no checkpoint of the context.

The step is the same, and the flag is written by a job of its own now: database.readyFlag() returns it, and the step runs it as a child with ctx.run. The user cancels while the version is being written.

The step keeps its one join:

// The first attempt: ctx.run throws the cancellation inside join.
await ctx.join(() async {
await database.writeVersion();
await ctx.run(database.readyFlag());
});
cancel
version written
outcome: Cancelled(manual)

The flag is lost again. join waits for its action, but the job accepts the cancellation at once, and from then on ctx.run throws it instead of starting the child; a child already running would be cancelled.

uncancellable holds the cancellation back until the step is over:

await ctx.uncancellable(() async {
await database.writeVersion();
await ctx.run(database.readyFlag());
});
cancel
version written
ready flag written
outcome: Cancelled(manual)

While the section runs, the job does not accept the cancellation: ctx.run starts the child, the job’s children are not cancelled, and onCancel does not fire. Held, not refused: the job accepts it the moment the section closes, and the next checkpoint throws it. The job still ends Cancelled, even if the body returns a value, so whatever has to happen after the step anyway belongs inside the same section.

return ctx.uncancellable(...) does not carry the value out either: the job takes the cancellation before the body returns, the value reaches neither the outcome nor value, and the registrations of onDiscard run. A step that made something worth keeping keeps it inside the section, whether the job is cancelled or not:

final receipt = await ctx.uncancellable(() async {
final receipt = await api.pay(order);
// Kept either way: the job may still end Cancelled.
await store.save(receipt);
return receipt;
});

Saving only when ctx.heldCancellation shows a cancellation would lose the receipt. The getter says whether a cancellation is held at the moment it is read, and one can still come after the read: before the section closes, while the job waits for its children, while it cleans up. A cancellation the job cannot refuse, such as a rule an engine adds, is not held at all: it marks the job at once, and only ctx.job.isCancelled shows it.

If the step fails and the body lets the error through, the job still ends Cancelled. The failure goes where any failure of the body of a cancelled job goes: the observer hears it, and then the zone does, unless the observer catches it in onUnanswered. Where errors go on the errors page has the table.

Always await ctx.uncancellable. The section opens when called, even if you do not await its future. An unawaited section can outlive the body; if the job finishes first, the held cancellation is lost: cancel() completes, and the outcome is Done. If a child still runs when the section closes, the cancellation lands then and becomes the outcome over the body’s value. A section holds back a cancellation request, not a cancellation the body throws itself. If the body goes on without awaiting the section and then cancels itself, by throwing Cancelled or by letting out the cancellation of a child, the job accepts that cancellation at once. onCancel runs while the section is still open, and abandonable inside it throws.

The job writes a backup, and a backup cut short is worse than none: the whole body is a step that must finish. To protect the entire body instead of one section, create Job(body, cancellable: false). It refuses ordinary cancellation once the body starts, and before that it can still be cancelled:

When the cancellation comes What the job does
Before the body starts Ends Cancelled with started: false, and the body never runs
Once the body has started Refuses an ordinary cancellation: the body goes on, and cancel() waits until the job ends

The flag keeps a body that has begun from being cut short, and before the start there is nothing to cut. A job made with Job(body) starts on the next microtask, so only a cancellation that comes sooner finds it not started. A job made with Job.deferred waits until someone starts it, by start() or by ctx.run of a parent, and the core cannot tell whether anyone will. Had such a job refused the cancellation and then never been started, it would never finish: its done would not complete, and the cancel() it refused would wait forever. cancel() has no stronger form to drop the job with.

An engine that holds jobs before their start knows what the core does not: it starts them itself. solo, built on this core, starts the jobs of its queue, and it refuses on behalf of a cancellable: false job while that job waits there. A queue of your own on the engine page builds such a refusal. solo also cancels a job when the controller’s state no longer suits it: a job there declares which states it runs in. Neither a section nor cancellable: false holds that cancellation back.

A failed migration is not fatal here: the body logs it and goes on to mark the database ready. A cancelled job should still stop, and the log should hold failures, not cancellations. The user cancels 15 ms in.

A cancellation throws Cancelled, so a clause for it comes first and rethrows:

// The first attempt: the error the token causes is logged as a failure.
final stop = CancelToken();
ctx.onCancel(stop.cancel);
try {
await ctx.join(() => database.migrate(stop));
} on Cancelled {
rethrow;
} on Exception catch (error) {
ctx.log('migration failed: $error');
}
await ctx.join(() async {
await database.writeVersion();
await database.writeReadyFlag();
});
step 1
cancel
step 2
migration stopped
log: migration failed: DatabaseStopped
outcome: Cancelled(manual)

The log calls a cancellation a failed migration. This is what happens:

  1. The token stops the migration, and what comes out of join is the migration’s own DatabaseStopped, not a Cancelled: join throws an error of its action as it is.
  2. The clause for Cancelled lets it by, and on Exception takes it. The code inside on Exception runs on a job that is already cancelled.
  3. The job still ends Cancelled: the join of the next step throws before its action starts.

Without the token the clause for Cancelled holds: the migration runs to its end, and join throws the Cancelled. Without that clause, on Exception takes that Cancelled too, because Cancelled implements Exception, and logs migration failed: Cancelled(manual).

Whether the job is cancelled is a question for the job, not for the error:

try {
await ctx.join(() => database.migrate(stop));
} on Exception catch (error) {
ctx.check();
ctx.log('migration failed: $error');
}
step 1
cancel
step 2
migration stopped
outcome: Cancelled(manual)

ctx.check() throws the job’s cancellation if it has accepted one, whatever the catch took. A migration that failed on its own is logged, and the body goes on. There is no onError: DatabaseStopped line, unlike in A token through onCancel: on Exception catches the DatabaseStopped, so it never leaves the body. The body ends with the job’s cancellation from ctx.check(), and a cancellation does not go to onError. If the clause is to catch everything, Error included, as on Object catch (error) does, it needs the same ctx.check() as its first line.

The clause pays for this with the failures that come after a cancellation. Once the job has accepted one, ctx.check() throws it whatever the catch took, a real failure too. A migration that fails on its own after the user cancelled is not logged, and the body ends with the cancellation, so neither onError nor the zone hears of it. Where such a failure has to be heard, the clause rethrows on a cancelled job in place of ctx.check():

try {
await ctx.join(() => database.migrate(stop));
} on Exception catch (error) {
if (ctx.job.isCancelled) rethrow;
ctx.log('migration failed: $error');
}

The failure then reaches onError and the zone, and the stop of the migration goes no further than onError, because the filter set in main names it. So the choice is this: the clause with ctx.check() loses a failure that comes after a cancellation, and the clause that rethrows needs Job.isCancellationError set in main, or the stop reaches the zone too.

If the job has already accepted cancellation, catching its Cancelled does not undo it. Code after the catch runs, but the next checkpoint throws again, and the outcome is still Cancelled even if the body returns a value.

A Cancelled caught in the body is not always the job’s own. An operation throws one when it waits for value of a job that was cancelled. A child started with ctx.run and cancelled directly, not through its parent, throws its Cancelled out of run. ctx.check() passes then, because the job itself was not cancelled, and the body goes on and can end Done. The clause of Asking the job logs such a Cancelled as a failure.

A page shows a thumbnail when it renders and goes out without one when it does not; a thumbnail that failed goes to the log, and one that was cancelled does not. Past ctx.check() a Cancelled is not the job’s, so a test of its type tells an optional child that was cancelled from one that failed:

final thumbnail = Job.deferred<String>(renderThumbnail);
try {
return 'page with ${await ctx.run(thumbnail)}';
} on Exception catch (error) {
ctx.check();
if (error is! Cancelled) ctx.log('thumbnail failed: $error');
return 'page without a thumbnail';
}

A clause that rethrows every Cancelled ends the job too. If it is a child’s Cancelled, the job ends with the reason BodyCancelReason; if it is the Cancelled an operation got from value of a cancelled job, the job ends with that job’s own reason. The page on outcomes takes these reasons apart in Why a job was cancelled.

The job records from a device and releases it in onDispose. The device takes a while to stop, and the user cancels while it records.

The stop runs as work the job does not wait for:

// The first attempt: the device is released while it is still stopping.
ctx.onDispose(device.release);
ctx.onCancel(() => ctx.unattended(device.stop));
await ctx.abandonable(device.record);
cancel
device released
outcome: Cancelled(manual)
device stopped

The job does not wait for the stop. abandonable lets the cancellation out at once, the cleanup stack releases the device, and the device stops after that. ctx.unattended keeps the errors of the stop with the job, and that is all it does: they reach the observer, and after it, by default, the zone the job was created in. Work the job does not wait for on the errors page takes it apart.

To end only after the device has stopped, the job waits with join for an operation the stop interrupts, here a recording that ends only once the device has stopped:

ctx.onDispose(device.release);
ctx.onCancel(() => ctx.unattended(device.stop));
await ctx.join(device.record);
cancel
device stopped
device released
outcome: Cancelled(manual)

join waits until the stop has ended the recording, as it waits for the migration in A token through onCancel. An operation that ends as soon as the stop begins leaves join nothing to wait for, and the job ends while the device is still stopping.

The stop goes through ctx.unattended and not straight into an async callback. onCancel takes a void Function(). Dart lets an async function through too, but the job does not hear what becomes of it:

  1. Nobody awaits the future it returns.
  2. Its error goes past the observer to the zone the callback was called in. For a cancellation accepted inside cancel(), that is the zone of the code that called cancel().
  3. Only what the callback throws synchronously reaches onError, and an async function never throws synchronously: even an error before its first await goes into its future.

The job builds the search index of the database in an isolate of its own, so that the build does not hold up the isolate the app runs in. The build writes a file as it goes, and onDiscard removes the file unless the job ends Done. It takes three steps, each holding the CPU of its isolate, and the user cancels once the first step is done. The build should stop, and the file should go only once nothing writes into it.

Isolate.run runs a function in a new isolate and returns its result, and join waits for it:

// The first attempt: nothing stops the isolate.
ctx.onDiscard(index.removeFile);
await ctx.join(() => Isolate.run(index.build));
step 1
cancel
step 2
step 3
index file removed
outcome: Cancelled(manual)

The build runs to its end, as the migration did in The second attempt: join waits for it, and nothing tells it to stop. Nothing can: Isolate.run returns a future and keeps the isolate to itself, so there is no isolate to kill and no token for the build to read. abandonable in place of join ends the job at once, and the file is removed while the isolate is still writing it.

Isolate.spawn hands over the isolate it starts, and onCancel kills it. The function below takes the place of Isolate.run for synchronous work:

/// Runs the synchronous [work] in an isolate of its own, which the
/// cancellation of [ctx] kills.
Future<T> runInIsolate<T>(JobContext ctx, T Function() work) async {
final port = ReceivePort();
void Function()? unregister;
final Object? message;
try {
final isolate = await ctx.join(
() => Isolate.spawn(
_runAndExit<T>,
(work, port.sendPort),
paused: true,
onError: port.sendPort,
onExit: port.sendPort,
),
discard: (isolate) => isolate.kill(priority: Isolate.immediate),
);
unregister = ctx.onCancel(() => isolate.kill(priority: Isolate.immediate));
isolate.resume(isolate.pauseCapability!);
// The first message is the answer: the value, the error, or the exit of
// an isolate killed before it answered.
message = await ctx.join(() => port.first);
} finally {
unregister?.call();
port.close();
}
return switch (message) {
(final T value,) => value,
[final error, final stackTrace] => throw RemoteError(
'$error',
'$stackTrace',
),
_ => throw StateError('the isolate ended without an answer'),
};
}
void _runAndExit<T>((T Function(), SendPort) task) {
final (work, port) = task;
Isolate.exit(port, (work(),));
}
ctx.onDiscard(index.removeFile);
await runInIsolate(ctx, index.build);
step 1
cancel
index file removed
outcome: Cancelled(manual)

This is what happens:

  1. Isolate.spawn starts the isolate with the work and a port. The isolate sends the value there in a record, so that it cannot pass for the list that onError sends, and exits. onError and onExit send to the same port, so the first message is the answer: the value, the error of the work, or the exit of an isolate that died without answering.
  2. The isolate starts paused, through a join with a discard that kills it. A cancellation that comes while the isolate is starting finds join waiting, and, as the table at the top of the page says, join hands the isolate to discard as soon as it exists and throws the cancellation after that. The isolate dies without running a line of the work; without the discard it would stay paused for good.
  3. onCancel kills the isolate with Isolate.immediate, which stops it in the middle of a step of Dart code: the work needs no checkpoint of its own. A call into native code, such as sleep, ends first. resume lets the work run only once onCancel is in place, and finally unregisters it, so a job that runs isolates one after another does not keep a kill for each.
  4. The second join waits for the answer, and a killed isolate answers with its exit. The job ends only once the isolate is dead, and onDiscard removes the file after it. The answer is a message, never an error, so on a cancelled job join throws the job’s Cancelled; no ctx.check() is needed, unlike in Asking the job.

Unlike with Isolate.run, an error of the work comes out of runInIsolate as a RemoteError with the text and the stack trace of the error: onError sends both as strings, and the error object stays in the isolate. Whatever work holds is copied into the new isolate with it. A closure that holds a ReceivePort cannot be copied, the isolate does not start, and runInIsolate throws the error of Isolate.spawn.