Time
A pause and a deadline are the two places where cancellation meets time:
ctx.pause waits and ends the moment the job is cancelled, and timeout
cancels the job once its limit runs out. The rest of cancellation is on
Cancellation.
The code works with the database of the cancellation page: database.readAll
reads the rows in 10 ms, and the migration takes three steps of 10 ms. Before
each step the migration reads a CancelToken, the database client’s stop
signal and not a type of this package; finding it cancelled, it stops by
throwing DatabaseStopped. The app names that stop once, in main, as
A token through onCancel shows,
and every job on this page runs with that line.
The lines under the code are what it prints when it runs. The database says
what it writes, and printing, the observer of the migration jobs, prints what
reaches it: onError: for an error. outcome: is what job.done completes
with. A failure nobody observes goes on to the answer, which for printing is
the zone, as
A failure nobody waits for shows.
Letting time pass
Section titled “Letting time pass”The job reads the rows once a second.
The first attempt
Section titled “The first attempt”// The first attempt: a plain await sits the delay out.final job = Job<void>((ctx) async { while (true) { use(await ctx.abandonable(database.readAll)); await Future<void>.delayed(const Duration(seconds: 1)); }});The user cancels 500 ms in, halfway through the delay, and the job ends 510 ms
later. A plain await is no checkpoint: the body sits the delay out and learns
of the cancellation at the ctx.abandonable of the next turn. Until then
cancel() does not return, and whatever waits for the job waits with it.
The second attempt
Section titled “The second attempt”// The second attempt: the timer of the delay outlives the job.await ctx.abandonable( () => Future<void>.delayed(const Duration(seconds: 1)),);Now the delay is behind a checkpoint, and the job ends the moment it is
cancelled. But abandonable ends the waiting, not the work, and the work here
is a timer. A Future.delayed cannot be cancelled, so its timer runs for the
510 ms that are left, with nothing waiting for it. A program that has nothing
else to do does not exit until it fires, and a test that looks for pending
timers finds one.
A pause of the job
Section titled “A pause of the job”await ctx.pause(const Duration(seconds: 1));ctx.pause is a checkpoint that takes time. It throws Cancelled at once when
the cancellation arrives, as ctx.abandonable does, and its timer is cancelled
with it:
| How the body waits | The job ends | Timer left behind |
|---|---|---|
await Future.delayed(...) |
510 ms after the cancellation | none |
ctx.abandonable(() => Future.delayed(...)) |
at once | for 510 ms more |
ctx.pause(...) |
at once | none |
Without a duration, ctx.pause() comes back on the next turn of the event
loop: a place for a long calculation to let other work run and to hear a
cancellation. Inside ctx.uncancellable the cancellation is held like any
other, and the pause runs its whole length. A negative duration counts as zero,
as it does for Future.delayed: unlike a timeout, ctx.pause does not throw
for it.
A deadline
Section titled “A deadline”The migration is given 15 ms. Past that, the job should stop the way it stops
when the user cancels: the token stops the migration, and discard closes the
database. Nobody cancels by hand here.
The first attempt
Section titled “The first attempt”cancel is what stops a job, so a timer calls it once the limit has run out:
// The first attempt: the timer of the limit outlives the job.const limit = Duration(milliseconds: 15);
final job = Job<Database>(observer: printing, (ctx) async { final database = await ctx.join( Database.open, discard: (database) => database.close(), ); final stop = CancelToken(); ctx.onCancel(stop.cancel);
await ctx.join(() => database.migrate(stop));
return database;});Timer(limit, job.cancel);step 1step 2migration stoppedonError: DatabaseStoppeddatabase closedoutcome: Cancelled(manual)The job stops as it does for the user in
A token through onCancel on the
cancellation page, and a job that has already finished is left as it is:
cancel() does nothing to it. But the timer lives until the limit, whether the
job is over or not. A migration that fails on a bad schema ends the job
Failed at once, and the timer runs for the 15 ms that are left with nothing
to cancel. A program that has nothing else to do does not exit until it fires,
and a test that looks for pending timers finds one.
The second attempt
Section titled “The second attempt”The timer is cancelled when the job ends:
// The second attempt: reading done keeps the failure from the zone.final timer = Timer(limit, job.cancel);job.done.whenComplete(timer.cancel);No timer is left behind now, but reading job.done observes the outcome. The
failure on the bad schema still reaches the observer as onError and no longer
reaches the zone. Without an observer nobody hears it, and the code that
cancels the timer has to report it itself.
A deadline of the job
Section titled “A deadline of the job”final job = Job<Database>(observer: printing, timeout: limit, (ctx) async { final database = await ctx.join( Database.open, discard: (database) => database.close(), ); final stop = CancelToken(); ctx.onCancel(stop.cancel);
await ctx.join(() => database.migrate(stop));
return database;});step 1step 2migration stoppedonError: DatabaseStoppeddatabase closedoutcome: Cancelled(timeout)timeout gives the job a deadline of its own. When the deadline runs out, the
job is cancelled with TimeoutCancelReason, whose timeout is the limit. The
outcome says so: Cancelled(timeout), where the timer of the first attempt
left Cancelled(manual), the same as the user’s.
The deadline is a cancellation like that of cancel(), not the
TimeoutException of Future.timeout:
- The body stops at its next checkpoint.
- An
uncancellablesection holds the request until it closes, and the job takes it then, before the body returns: a body that ends withreturn ctx.uncancellable(...)endsCancelled(timeout), the value of the section reaches neither the outcome norvalue, and the registrations ofonDiscardrun. Only a body that went on without awaiting the section can be over while the section still holds the request: then the request goes with the timer, and the job ends with what the body returned. See Holding the cancellation back. - A job cancelled already keeps its own reason.
- The children hear the deadline as the cause of their
ParentCancelReason.
The deadline bounds the body and the children the job waits for after it, not the cleanup stack. How the core runs the timer:
- The core starts the timer when the body starts, so the time before the start
is not counted, such as the wait of a
Job.deferredfor itsstart()or for thectx.runof a parent. - The core cancels the timer before the cleanup stack unwinds: a cancellation
arriving there would turn a job that did its work into a cancelled one and
close its value through
discard. - The core cancels the timer however else the job ends.
The core observes nothing as it cancels the timer, so with timeout a failure
of the job still reaches the zone:
| How the job gets its limit | Timer left behind | A failure of the job |
|---|---|---|
Timer(limit, job.cancel) |
for 15 ms more | reaches the zone |
job.done.whenComplete(timer.cancel) |
none | reaches no zone |
timeout: limit |
none | reaches the zone |
A job still running when a test ends holds its timer like any other. A test
under fakeAsync or testWidgets ends such a job, by cancelling it or waiting
for it, before the test itself ends.
A deadline that is zero or negative throws ArgumentError, and so does one
that comes with cancellable: false: the deadline is a cancellation the job
may refuse, and a job with cancellable: false would refuse it.
A deadline for one step
Section titled “A deadline for one step”A page goes out without its thumbnail if the thumbnail takes longer than 15 ms;
renderThumbnail takes 20. One step gets a deadline as a child of its own,
made by ctx.child, which the engine of the job accepts:
final thumbnail = ctx.child<String>( renderThumbnail, key: 'thumbnail', timeout: const Duration(milliseconds: 15),);try { return 'page with ${await ctx.run(thumbnail)}';} on Cancelled catch (cancelled) { ctx.check(); if (cancelled.reason case TimeoutCancelReason(:final timeout)) { return 'page without a thumbnail after ${timeout.inMilliseconds} ms'; } else { rethrow; }}outcome: Done(page without a thumbnail after 15 ms)The child ends Cancelled(timeout), and ctx.run throws that cancellation
into the body of the parent. The parent itself is not cancelled: the deadline
ran out for the child. A catch clause in Dart filters by type alone, with no
pattern and no when, so the clause takes every Cancelled. ctx.check()
comes first in it and throws the job’s own cancellation, because the reason of
a deadline does not say whose deadline it is: a job whose own timeout ran out
before ctx.run gets its own Cancelled(timeout) from it up front, and
without the check would take it for the thumbnail’s and go on. The pattern goes
into an if after it: it matches the reason of the deadline and takes its
duration. The else rethrows a thumbnail somebody else cancelled. Let out, the
child’s deadline ends the parent
Cancelled(body: Job(thumbnail): Cancelled(timeout)). The key is there for
that line: a child without one prints as Job().