Исходы
Когда тело, дочерние задачи и уборка завершились, у задачи есть исход
Outcome<T>: Done со значением, которое вернуло тело, Failed с ошибкой,
которую оно бросило, или Cancelled. job.done завершается этим исходом
и никогда не бросает, job.value отдаёт только значение, а job.outcome
хранит исход после конца задачи и null до него. Outcome<T> объявлен как
sealed, поэтому switch по этим трём вариантам исчерпывающий.
Строки под кодом показывают, что он печатает при запуске, а строка zone:
означает ошибку, которая дошла до зоны необработанной.
Чтение результата
Заголовок раздела «Чтение результата»Задачу report типа Job<Report> пользователь может отменить. Когда она
кончилась, код, который её запустил, печатает одно из трёх: отчёт, ошибку,
с которой задача упала, или то, что её отменили.
Первая попытка
Заголовок раздела «Первая попытка»value отдаёт отчёт, а try принимает то, что пошло не так:
// Первая попытка: catch принимает отмену за сбой.try { print('report: ${await report.value}');} on Object catch (error) { print('failed: $error');}Пользователь отменяет, и отмена печатается как ошибка:
failed: Cancelled(manual)value при успехе завершается значением, а при ошибке или отмене бросает,
и для отмены бросает сам Cancelled. Cancelled реализует Exception,
поэтому catch принимает его вместе с ошибками: и on Exception,
и on Object.
switch по done
Заголовок раздела «switch по done»final message = switch (await report.done) { Done(:final value) => 'report: $value', Failed(:final error) => 'failed: $error', Cancelled(:final reason) => 'cancelled: $reason',};print(message);cancelled: manualdone отдаёт исход, а не бросает его, и у каждого варианта своя строка.
value подходит коду, для которого отмена ничем не отличается от любой другой
ошибки, например тесту, который ждёт значение. Если результат совсем не нужен,
подтвердите это вызовом report.ignoreFailure().
Ошибка, которую никто не ждёт
Заголовок раздела «Ошибка, которую никто не ждёт»backup загружает данные в фоне, и никто её не ждёт. Строка статуса при каждой
отрисовке читает, чем кончилась задача.
Первая попытка
Заголовок раздела «Первая попытка»outcome хранит то, чем кончилась задача, и равен null, пока она идёт:
// Первая попытка: чтение outcome оставляет сбой зоне.final backup = Job<void>(upload);
// Там, где рисуется строка статуса:final status = switch (backup.outcome) { null => 'backing up', Done() => 'backed up', Failed(:final error) => 'backup failed: $error', Cancelled() => 'backup cancelled',};print('status: $status');Загрузка падает. Зона, в которой создана задача, получает ошибку как необработанную, а строка статуса, нарисованная после этого, говорит то же:
zone: Bad state: disk fullstatus: backup failed: Bad state: disk fullЗа ошибку, которую никто не наблюдал, отвечают на микротаске после завершения
задачи: наблюдатель, который подмешивает JobAnswerer, отвечает
в onUnanswered, а без него ошибку получает зона создания задачи, как здесь.
Так она остаётся видна, даже когда вызывающий код не ждёт результата. В тесте
это зона теста, и тест падает. Чтение outcome наблюдением не считается.
Чтение после этой микротаски всё равно сработает: done отдаст Failed,
а value — свою ошибку, но за ошибку к тому времени уже ответили.
Сказать ядру, что она обработана
Заголовок раздела «Сказать ядру, что она обработана»final backup = Job<void>(upload)..ignoreFailure();status: backup failed: Bad state: disk fullignoreFailure() говорит ядру, что ошибку обрабатывают в другом месте (здесь
это строка статуса), и в зону ничего не приходит. ignoreFailure() —
не единственный способ наблюдать ошибку:
| Что делает код | Наблюдает ошибку? |
|---|---|
Читает done |
да, даже без ожидания, и done не бросает никогда |
Читает value |
да, даже без ожидания, и ожидающий код получает саму ошибку и должен обработать её, как с любой Future |
Читает done или value после микротаски, которая идёт за концом |
поздно: за ошибку уже ответили |
Передаёт ошибку через then |
да, и ответственность за неё переходит к продолжению |
Читает outcome |
нет |
Ждёт job.cancel() |
нет |
Регистрирует слушателя через whenCancelled или whenFinished |
нет |
| Даёт задаче наблюдателя | нет: onError и onFinish ошибку слышат, а отвечают за неё всё равно — в onUnanswered наблюдателя, если тот подмешивает JobAnswerer |
Поэтому коду, который ждёт задачу и сам разбирает её результат,
ignoreFailure() не нужен.
Ожидание не наблюдает провал тела задачи, которая кончается Cancelled. Если
backup отменили, а её тело упало, код, который её ждёт, получает отмену,
а не ошибку, и строка статуса пишет backup cancelled. Ошибка идёт путём
ошибки, которой не несёт ни один исход; этот путь показывает раздел
«Куда уходят ошибки» страницы об ошибках.
ignoreFailure() снимает с неё ответ, и тогда её слышит только onError
наблюдателя. Для такого провала вызов успевает до конца задачи или, самое
позднее, из onFinish или из слушателя whenCancelled или whenFinished,
зарегистрированного до конца. За провал отвечают, когда задача кончается,
поэтому вызов из слушателя done опаздывает.
Услышать конец, не забирая ошибку
Заголовок раздела «Услышать конец, не забирая ошибку»// Вызывается, когда копирование кончилось, и не наблюдает его ошибку.final unregister = backup.whenFinished((outcome) { print('status redrawn: $outcome');});status redrawn: Failed(Bad state: disk full)zone: Bad state: disk fullСтрока статуса, которая ждёт done, чтобы перерисоваться, наблюдает ошибку,
и зона перестаёт о ней слышать, даже если строка — не тот код, что отвечает
за backup. whenFinished вызывает слушателя с исходом, когда задача
закончилась: после её уборки и раньше, чем продолжится всё, что ждёт done.
Регистрация ничего не наблюдает: за ошибку отвечают, как в первой попытке,
а выбор, кто её наблюдает, остаётся за владельцем backup. Экран, который
закрывается раньше конца задачи, вызывает unregister(); слушатель,
зарегистрированный на уже законченной задаче, вызывается сразу.
Почему задачу отменили
Заголовок раздела «Почему задачу отменили»При отмене исход также объясняет, почему задача остановилась: Cancelled
хранит причину reason. В собственной причине можно хранить дополнительные
данные, например ошибку и её исходный стектрейс:
final class RequestCancelReason extends CancelReason { final Object error; final StackTrace stackTrace;
const RequestCancelReason(this.error, this.stackTrace);
@override String get name => 'request';}report запускает дочернюю задачу fetch за данными и ничего из того, что
из неё выходит, не ловит:
final fetch = Job.deferred<Data>(download);final report = Job<Report>((ctx) async { final data = await ctx.run(fetch); return Report(data);});fetch нужен токен, который обновляет запрос в другом месте. Когда этот запрос
падает, код, который его отправил, отменяет fetch и передаёт ошибку
в причине, а исход fetch хранит этот же экземпляр причины:
try { await refreshToken();} on Object catch (error, stackTrace) { await fetch.cancel(reason: RequestCancelReason(error, stackTrace));}Код, который запустил report, должен отличить упавший запрос от отмены
пользователем.
Первая попытка
Заголовок раздела «Первая попытка»switch по done получает вариант для причины:
// Первая попытка: причина запроса лежит на шаг глубже, в cause.final message = switch (await report.done) { Done(:final value) => 'report: $value', Failed(:final error) => 'failed: $error', Cancelled(reason: RequestCancelReason(:final error)) => 'request failed: $error', Cancelled(:final reason) => 'cancelled: $reason',};print(message);cancelled: bodyЗапрос отменил fetch, а не report. Отмена fetch вышла через тело
report, и report кончилась собственным Cancelled с причиной
BodyCancelReason: её тело отменило само себя. RequestCancelReason лежит
на шаг глубже, в cause этой причины.
По цепочке cause
Заголовок раздела «По цепочке cause»CancelReason origin(Cancelled cancelled) { var reason = cancelled.reason; while (true) { if (reason case BodyCancelReason(:final cause?) || ParentCancelReason(:final cause?) || ChainCancelReason(:final cause)) { reason = cause.reason; } else { return reason; } }}
final message = switch (await report.done) { Done(:final value) => 'report: $value', Failed(:final error) => 'failed: $error', final Cancelled cancelled => switch (origin(cancelled)) { RequestCancelReason(:final error) => 'request failed: $error', final reason => 'cancelled: $reason', },};print(message);request failed: Bad state: token expiredcause есть у четырёх причин:
| Причина | Кто получает и когда | Что хранит cause |
Идёт ли по нему origin |
|---|---|---|---|
ParentCancelReason |
дочерняя задача, когда до неё доходит отмена родителя | Cancelled родителя |
да |
BodyCancelReason |
родитель, когда отмена дочерней задачи выходит через его тело | Cancelled дочерней задачи |
да |
ChainCancelReason |
задача в цепочке then, когда до неё доходит отмена соседа |
Cancelled соседа |
да |
SiblingCancelReason |
ветка ctx.runAll, когда группа проваливается и останавливает остальные ветки |
то, из-за чего остановилась группа: ошибку или отмену, которой кончилась другая ветка, или ошибку самой группы | нет, origin возвращает сам SiblingCancelReason |
Три ссылки, по которым идёт origin, сохраняют исходную причину и её данные.
origin проходит по ним до конца: cause равен null там, где за причиной
ничего нет, как у тела, которое само бросило Cancelled('why'). Дочерняя
задача, у родителя которой вышел срок, тоже получает ParentCancelReason: её
origin даёт TimeoutCancelReason родителя. Длина цепочки then ничем
не ограничена, и её ChainCancelReason вложены друг в друга на всю её длину,
поэтому origin проходит по ним циклом: длинная цепочка не стоит ему стека.
Тело, которое ждёт value чужой задачи, BodyCancelReason для неё
не получает. Отмена чужой задачи проходит с причиной как есть, и исход читается
как Cancelled(manual) у задачи, у которой cancel никто не вызывал.
Кроме причины reason, Cancelled содержит флаг started, необязательное
описание description и стектрейс отмены. Флаг started показывает, успело ли
тело запуститься или отмена произошла до запуска. Встроенные классы причин:
ManualCancelReason, ParentCancelReason, BodyCancelReason,
ChainCancelReason, SiblingCancelReason и TimeoutCancelReason; все они
наследуют CancelReason. TimeoutCancelReason получает задача, у которой
вышел срок, заданный через timeout, как показывает раздел
«Срок».
Проверяйте причину по типу, например reason is ParentCancelReason. Свойство
name служит меткой для лога и не определяет равенство. По умолчанию причины
сравниваются по идентичности, но класс может определить равенство по данным.
Тело может бросить Cancelled.by(reason: reason) с явной причиной или
Cancelled('why') с причиной BodyCancelReason. Стектрейс ошибки, сохранённый
в причине, отделён от стектрейса отмены: RequestCancelReason.stackTrace
указывает, где упал запрос, а Cancelled.stackTrace указывает, откуда пришла
отмена.
Реакция до исхода
Заголовок раздела «Реакция до исхода»report выполняет шаг, который отмена прервать не может, и после него убирает
за собой:
final report = Job<void>((ctx) async { ctx.onDispose(() => print('cleanup')); await ctx.run( Job.deferred<void>(cancellable: false, (ctx) async { await ctx.pause(const Duration(milliseconds: 50)); print('step finished'); }), );});Экран, который показывает отчёт, должен сказать об отмене сразу, как только кто-то отменил задачу, пока шаг ещё идёт.
Первая попытка
Заголовок раздела «Первая попытка»Исход говорит, отменили ли задачу:
// Первая попытка: done приходит только после очистки.if (await report.done case Cancelled(:final reason)) { print('cancelling: $reason');}step finishedcleanupcancelling: manualЭкран говорит об этом, только когда шаг и уборка уже позади. done завершается
исходом, а исход у задачи появляется, только когда закончились её тело, её дети
и уборка. Ожидание report.cancel() быстрее не будет: оно кончается в тот же
момент. report.isCancelled становится true в момент, когда отмена принята,
но экран узнает об этом, только когда снова прочитает геттер.
Слушатель отмены
Заголовок раздела «Слушатель отмены»Чтобы отреагировать на принятую отмену до получения итогового исхода,
зарегистрируйте слушателя через job.whenCancelled(callback). Он вызывается
синхронно и получает Cancelled с причиной и подробностями:
// Вызывается, когда отмена принята; задача ещё может завершать работу.final unregister = report.whenCancelled((cancelled) { print('cancelling: ${cancelled.reason}');});cancelling: manualstep finishedcleanupРегистрация возвращает функцию, которая снимает слушателя. Её вызывает экран, который закрылся, пока отчёт ещё идёт, и отмена, пришедшая после этого, экрану ничего не сообщает:
// Экран закрылся; отчёт идёт дальше.unregister();step finishedcleanupКогда задача завершилась, снимать уже нечего: каждый слушатель либо вызван, либо отпущен, и вызов этой функции ничего не делает. Повторное снятие регистрации безопасно.
Когда слушатель вызывается, зависит от того, как задачу отменили:
| Как отменили задачу | Когда вызывается слушатель |
|---|---|
| Снаружи, пока она работает | после передачи отмены дочерним задачам и вызова колбэков ctx.onCancel, не дожидаясь окончания тела |
| До запуска | в момент отмены задачи |
Её собственным телом, которое бросает Cancelled или выпускает отмену другой задачи, когда задачу никто не отменял |
когда тело кончается: после передачи отмены дочерним задачам и вызова колбэков ctx.onCancel, не дожидаясь окончания дочерних задач |
Вызывается ли слушатель и в каком порядке:
- Отклонённая отмена прохода по слушателям не начинает. Секция
uncancellableоткладывает проход до принятия отмены. - Задача, завершившаяся с
DoneилиFailedбез отмены, освобождает слушателей без вызова. - Пока проход по слушателям не начался, новый слушатель встаёт в очередь
прохода после зарегистрированных раньше, даже если отмена уже расходится
по дочерним задачам или идут колбэки
ctx.onCancelсамой задачи. - Каждая регистрация срабатывает один раз. Слушатели, которые ждут своей очереди, вызываются в порядке регистрации, по снимку списка: снятие слушателя во время прохода не исключает его из этого прохода.
- Когда проход по слушателям уже начался, новый слушатель при регистрации вызывается сразу, даже если задача уже завершилась. Так бывает и тогда, когда слушатель регистрирует другого, пока выполняется сам: новый выполнится сразу, раньше слушателей, которые ещё ждут своей очереди.
Что бывает с ошибкой слушателя:
- Синхронная ошибка слушателя передаётся в
onError, а затем в зону создания задачи, если наблюдатель не перехватит её вonUnanswered, как показывает раздел «Куда уходят ошибки» страницы об ошибках. Без наблюдателя она уходит прямо в зону создания задачи. Cancelled, брошенный синхронным слушателем, в зону не передаётся.- Ошибки слушателей не меняют отмену и не мешают вызову остальных.
whenCancelledрассчитан на синхронный колбэк, хотя Dart позволяет передать иasync-колбэк. Future такого колбэка задача не ждёт. Ни одна ошибка такого колбэка не доходит доonError:async-функция синхронно не бросает никогда, и даже ошибка до её первогоawaitуходит в future, которую она возвращает.- Ошибка
async-слушателя становится необработанной ошибкой той зоны, где вызван слушатель. Для отмены, принятой внутриcancel(), это зона кода, который вызвалcancel(). Для отмены, которую придержала секцияuncancellable, и для тела, которое отменило само себя, это зона, где идёт тело. - Зона, где идёт тело, зависит от того, как задачу сделали. Тело задачи
из
Job(...)идёт в зоне, где её создали, а тело задачи изJob.deferredидёт в зоне, откуда её запустили. Если задачу изJob(...)создали или задачу изJob.deferredзапустили внутри работы, отданнойctx.unattended, её тело идёт в зоне, откуда эту работу начали.