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

Частые ошибки

Семь ошибок, которые компилируются, работают и часто проходят первый тест. Каждый раздел показывает ошибку, говорит, что идёт не так, показывает правильный способ в несколько строк и ссылается на раздел, где ошибка показана целиком. О большинстве из них сообщает в коде плагин анализатора solo_lints.

Простой await в теле не служит контрольной точкой.

Так не делайте:

// Неверно: отменённая загрузка продолжает писать.
for (final chunk in chunks) {
await device.write(chunk);
}

Отменённая задача идёт дальше до первой контрольной точки, и close() ждёт столько же: загрузка, отменённая на втором из четырёх кусков, пишет и третий, и четвёртый. Ждите через контекст. ctx.join дожидается вызова и бросает Cancelled вместо его результата; ctx.abandonable бросает сразу и оставляет вызов работать.

Делайте так:

for (final chunk in chunks) {
// Проверяет и до записи, и после неё.
await ctx.join(() => device.write(chunk));
}

В уборке всё наоборот. Она идёт после тела, где join бросает StateError.

Так не делайте:

// Неверно: сброса не происходит.
ctx.onDispose(() async {
// Сброс должен закончиться до продвижения очереди.
await ctx.join(() => device.flush());
});

Буфер устройства остаётся несброшенным, чем бы задача ни кончилась, и при Done тоже. О StateError узнаёт хук onError контроллера, а затем ошибка уходит в Solo.unansweredHandler или, если обработчик не задан, в зону. В уборке ждите простым await.

Делайте так:

ctx.onDispose(() async {
// Уборка идёт после тела, где методов ожидания уже нет:
// здесь работает обычный await.
await device.flush();
});

Буфер сбрасывается, чем бы загрузка ни кончилась. Подробнее в разделе «Обычный await и время жизни контекста» страницы об отмене.

Ресурс можно потерять, если его освобождение регистрируют строкой ниже вызова, который его приносит.

Так не делайте:

// Неверно: отмена влезает между открытием и onDispose.
final db = await ctx.join(Database.open);
ctx.onDispose(db.close);

Отмена во время открытия бросает раньше второй строки, и открытая база теряется. Передайте освобождение вызову, который приносит ресурс.

Делайте так:

final db = await ctx.join(Database.open, dispose: (db) => db.close());

Значение, которое не дошло до тела, освобождается на месте, а дошедшее освобождается, когда задача кончится. Подробнее в разделе «Получение ресурса вызовом» страницы о ресурсах.

Cancelled реализует Exception, поэтому широкий catch ловит его вместе с ошибками, ради которых его писали.

Так не делайте:

// Неверно: отмена сбрасывает исправную камеру.
try {
await ctx.join(hw.open);
await ctx.join(() => hw.setZoom(zoom));
} on Object catch (error) {
await hw.reset();
ctx.emit(Broken(error));
rethrow;
}

Задача, отменённая во время setZoom, сбрасывает устройство, которое никто не ломал, и тот, кто её отменил, ждёт ещё и сброса. Сначала спросите задачу.

Делайте так:

try {
await ctx.join(hw.open);
await ctx.join(() => hw.setZoom(zoom));
} on Object catch (error) {
ctx.check();
await hw.reset();
ctx.emit(Broken(error));
rethrow;
}

ctx.check() бросает Cancelled задачи, если задача его приняла, какую бы ошибку ни поймал catch. Секции on Cancelled, которая спрашивает саму ошибку, мало: вызов, остановленный через токен, бросает свою ошибку. catch, который только публикует состояние сбоя, ctx.emit(Broken(error)), тоже ничего не даёт: на отменённой задаче emit снова бросает Cancelled, а конечное состояние сбоя — дело параметра ifFailed у enqueue. Подробнее в разделе «Перехват ошибок внутри тела» страницы об ошибках.

Контроллер выполняет по одной корневой задаче за раз, а метод контроллера ставит в очередь ещё одну. В примерах — две пары методов контроллера со страницы о детях. _sync(item) и _recordPath(path) создают задачу через job(...) и ничего не запускают. sync(item) и recordPath(path) — публичные методы, которые ставят её в очередь: sync(item) возвращает enqueueJob(_sync(item)).

Так не делайте:

// Неверно: sync ждёт конца этой задачи, а задача ждёт sync.
Job<void> syncAndRecord(int item) =>
enqueue<Ready, void>(key: _Op.syncAndRecord, (ctx) async {
final path = await sync(item).value;
await recordPath(path).value;
});

Текущая задача ждёт новую, а новая ждёт текущую: ничего не движется, пока close() не отменит обе. Вместо этого выполняйте задачи через ctx.run: они сразу запускаются детьми текущей задачи, минуя очередь.

Делайте так:

Job<void> syncAndRecord(int item) =>
enqueue<Ready, void>(key: _Op.syncAndRecord, (ctx) async {
final path = await ctx.run(_sync(item));
await ctx.run(_recordPath(path));
});

Solo.debug предупреждает о таком ожидании, когда задача встаёт в очередь. close() или cancelAll(), которых ждут из тела, зависают так же: оба ждут работающую задачу. Подробнее в разделе «Шаги подряд» страницы о дочерних задачах и в разделе «Закрытие из задачи» страницы об отмене.

За ошибку, которую никто не прочитал, отвечают: Solo.unansweredHandler, если он задан, а иначе зона, где задачу создали, и там необработанная ошибка может завершить приложение. За ошибку, которую задача называет в isExpectedError, не отвечает никто.

Так не делайте:

// Неверно: сбои всё равно уходят в зону.
final class Failures extends SoloObserver {
@override
void onError(
Solo<Object> solo,
Job<Object?> job,
Object error,
StackTrace stackTrace, {
required JobErrorAnswer answer,
}) => reportCrash(error, stackTrace);
}

Хук onError наблюдателя или контроллера, обработчик ifFailed и взгляд на job.outcome видят ошибку, но ни один из них не считается чтением. Дождитесь job.value и поймайте ошибку, прочитайте job.done либо вызовите ignoreFailure(), когда результат никому не нужен, а отчёты оставлены хукам, как наблюдателю выше. Задача, которая знает, какие её ошибки ожидаемы, называет их в isExpectedError, и ответа за них не просят ни у кого.

Делайте так:

profile.load().ignoreFailure();

Сделайте это до микрозадачи после конца задачи: за ошибку, о которой никто не спросил, отвечают на этой микрозадаче. Подробнее в разделах «Обработанные и необработанные ошибки» и «Назвать ожидаемые ошибки» страницы об ошибках. Чтобы отвечать за все такие ошибки в одном месте, задайте Solo.unansweredHandler один раз при запуске, как показывает раздел «Ответ за ошибку».

Колбэк fakeAsync должен оставаться синхронным.

Так не делайте:

// Неверно: никто не двигает поддельные часы.
test('load fills in the name', () async {
await fakeAsync((async) async {
final profile = ProfileController(FakeProfileApi());
final outcome = await profile.load().done;
expect(outcome, isA<Done<String>>());
await profile.close();
});
});

await в колбэке приостанавливает его, и двигать поддельные часы больше некому: тест висит до своего таймаута, а без await перед fakeAsync сразу кончается зелёным, ничего не выполнив. Двигайте часы через elapse и читайте исход.

Делайте так:

test('load fills in the name', () {
fakeAsync((async) {
final profile = ProfileController(FakeProfileApi());
final job = profile.load()..ignoreFailure();
async.elapse(const Duration(milliseconds: 20));
expect(job.outcome, isA<Done<String>>());
expect(profile.currentState, isA<Loaded>());
profile.close();
async.flushTimers();
});
});

Внутри ничего нельзя дождаться, поэтому ошибку отмечает прочитанной ignoreFailure(), а close() и последний flushTimers() заканчивают тест с пустыми часами. Подробнее в разделе «Ожидание внутри fakeAsync» страницы о тестировании.

Политика сравнивает ключи, а не аргументы.

Так не делайте:

// Неверно: один ключ на все профили.
Job<Profile> load(String id) => enqueue<Ready, Profile>(
key: _Op.load,
policy: Policy.droppable,
(ctx) => ctx.abandonable(() => api.load(id)),
);

С одним ключом на load(id) политика Policy.droppable принимает load('grace'), вызванный, пока идёт load('ada'), за дубликат и отдаёт вызывающему профиль Ады. Включите аргумент в ключ.

Делайте так:

Job<Profile> load(String id) => enqueue<Ready, Profile>(
key: (_Op.load, id),
policy: Policy.droppable,
(ctx) => ctx.abandonable(() => api.load(id)),
);

Ключом служит любой объект, сравниваемый через ==, так что подойдёт и запись. Подробнее в разделе «Ключ на каждый запрос» страницы о задачах.