Перейти к содержимому

Исходы

Когда тело, дочерние задачи и уборка завершились, у задачи есть исход 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.

final message = switch (await report.done) {
Done(:final value) => 'report: $value',
Failed(:final error) => 'failed: $error',
Cancelled(:final reason) => 'cancelled: $reason',
};
print(message);
cancelled: manual

done отдаёт исход, а не бросает его, и у каждого варианта своя строка. 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 full
status: backup failed: Bad state: disk full

За ошибку, которую никто не наблюдал, отвечают на микротаске после завершения задачи: наблюдатель, который подмешивает JobAnswerer, отвечает в onUnanswered, а без него ошибку получает зона создания задачи, как здесь. Так она остаётся видна, даже когда вызывающий код не ждёт результата. В тесте это зона теста, и тест падает. Чтение outcome наблюдением не считается. Чтение после этой микротаски всё равно сработает: done отдаст Failed, а value — свою ошибку, но за ошибку к тому времени уже ответили.

final backup = Job<void>(upload)..ignoreFailure();
status: backup failed: Bad state: disk full

ignoreFailure() говорит ядру, что ошибку обрабатывают в другом месте (здесь это строка статуса), и в зону ничего не приходит. 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 этой причины.

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 expired

cause есть у четырёх причин:

Причина Кто получает и когда Что хранит 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 finished
cleanup
cancelling: manual

Экран говорит об этом, только когда шаг и уборка уже позади. done завершается исходом, а исход у задачи появляется, только когда закончились её тело, её дети и уборка. Ожидание report.cancel() быстрее не будет: оно кончается в тот же момент. report.isCancelled становится true в момент, когда отмена принята, но экран узнает об этом, только когда снова прочитает геттер.

Чтобы отреагировать на принятую отмену до получения итогового исхода, зарегистрируйте слушателя через job.whenCancelled(callback). Он вызывается синхронно и получает Cancelled с причиной и подробностями:

// Вызывается, когда отмена принята; задача ещё может завершать работу.
final unregister = report.whenCancelled((cancelled) {
print('cancelling: ${cancelled.reason}');
});
cancelling: manual
step finished
cleanup

Регистрация возвращает функцию, которая снимает слушателя. Её вызывает экран, который закрылся, пока отчёт ещё идёт, и отмена, пришедшая после этого, экрану ничего не сообщает:

// Экран закрылся; отчёт идёт дальше.
unregister();
step finished
cleanup

Когда задача завершилась, снимать уже нечего: каждый слушатель либо вызван, либо отпущен, и вызов этой функции ничего не делает. Повторное снятие регистрации безопасно.

Когда слушатель вызывается, зависит от того, как задачу отменили:

Как отменили задачу Когда вызывается слушатель
Снаружи, пока она работает после передачи отмены дочерним задачам и вызова колбэков 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, её тело идёт в зоне, откуда эту работу начали.