Skip to content

Common mistakes

Seven mistakes that compile, run and often pass a first test. Each section shows the mistake, says what goes wrong, shows the right way in a few lines and links to the section that shows the mistake in full. The analyzer plugin solo_lints reports most of them in the code.

A plain await in a body is not a checkpoint.

Don’t do this:

// Wrong: a cancelled upload goes on writing.
for (final chunk in chunks) {
await device.write(chunk);
}

A cancelled job goes on until it meets a checkpoint, and close() waits that long: cancelled during the second of four chunks, the upload writes the third and the fourth as well. Wait through the context instead. ctx.join waits for the call and then throws Cancelled in place of its result; ctx.abandonable throws at once and leaves the call running.

Do this:

for (final chunk in chunks) {
// Checks before the write and after it.
await ctx.join(() => device.write(chunk));
}

Cleanup is the other way round. It runs after the body, where join throws StateError.

Don’t do this:

// Wrong: the flush never happens.
ctx.onDispose(() async {
// The flush must finish before the queue moves on.
await ctx.join(() => device.flush());
});

No matter how the job ended, Done included, the device buffer stays unflushed. The StateError is told to the controller’s onError hook and then handed to Solo.unansweredHandler, or to the zone when no handler is set. Wait in cleanup with a plain await.

Do this:

ctx.onDispose(() async {
// Cleanup runs after the body, where the waiting methods are
// gone: a plain await is the wait that works here.
await device.flush();
});

The buffer is flushed however the upload ended. See Ordinary await and context lifetime on the cancellation page.

A resource registered for release on the line under the call that brings it can be lost.

Don’t do this:

// Wrong: a cancellation fits between open and onDispose.
final db = await ctx.join(Database.open);
ctx.onDispose(db.close);

A cancellation during the open throws before the second line, and the open database is lost. Pass the release to the call that brings the resource.

Do this:

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

A value that does not reach the body is released on the spot, and a value that does is released when the job ends. See Taking a resource from a call on the resources page.

Cancelled implements Exception, so a broad catch takes it along with the failures it was written for.

Don’t do this:

// Wrong: a cancellation resets a working camera.
try {
await ctx.join(hw.open);
await ctx.join(() => hw.setZoom(zoom));
} on Object catch (error) {
await hw.reset();
ctx.emit(Broken(error));
rethrow;
}

Cancelled during setZoom, the job resets a device nobody broke, and whoever cancelled it waits for the reset as well. Ask the job first.

Do this:

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() throws the job’s Cancelled if the job has accepted one, whatever error the catch took. A clause for the error, on Cancelled, is not enough: a call stopped through a token throws an error of its own. A catch that only publishes a failure state, ctx.emit(Broken(error)), gains nothing either: on a cancelled job the emit throws the Cancelled again, and a final failure state belongs to the ifFailed parameter of enqueue. See Catching errors inside a body on the errors page.

The controller runs one root job at a time, and a method of the controller queues one more. The examples use two pairs of the controller’s methods from the children page. _sync(item) and _recordPath(path) make a job with job(...) and start nothing. sync(item) and recordPath(path) are the public methods that queue it: sync(item) returns enqueueJob(_sync(item)).

Don’t do this:

// Wrong: sync waits for this job to end; this job waits for sync.
Job<void> syncAndRecord(int item) =>
enqueue<Ready, void>(key: _Op.syncAndRecord, (ctx) async {
final path = await sync(item).value;
await recordPath(path).value;
});

The current job waits for the new one, and the new one waits for the current one: nothing moves until close() cancels both. Run the jobs through ctx.run instead: they start at once as children of the current job, bypassing the queue.

Do this:

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 warns of such a wait when the job is queued. Awaiting close() or cancelAll() from a body hangs the same way: both wait for the running job. See Steps in a row on the children page and Closing from a job on the cancellation page.

A failure nobody reads is answered for: by Solo.unansweredHandler when one is set, and otherwise in the zone the job was created in, where an unhandled error can end the app. An error the job names in isExpectedError is not answered for at all.

Don’t do this:

// Wrong: the failures still go to the zone.
final class Failures extends SoloObserver {
@override
void onError(
Solo<Object> solo,
Job<Object?> job,
Object error,
StackTrace stackTrace, {
required JobErrorAnswer answer,
}) => reportCrash(error, stackTrace);
}

The onError hook of an observer or of the controller, an ifFailed handler and a look at job.outcome all see the failure, but none of them counts as reading it. Await job.value and catch the error, read job.done, or call ignoreFailure() when nobody needs the result and the reporting is left to the hooks, like the observer above. A job that knows which of its errors are expected names them in isExpectedError, and nobody is asked to answer for those.

Do this:

profile.load().ignoreFailure();

Do it by the microtask after the job ends: a failure nobody has asked about is answered for on that microtask. See Handled and unhandled failures and Naming the expected errors on the errors page. To answer for every such failure in one place instead, set Solo.unansweredHandler once at startup, as Answering for an error shows.

The callback of fakeAsync has to stay synchronous.

Don’t do this:

// Wrong: nobody moves the fake clock.
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();
});
});

An await in the callback suspends it, and nobody is left to move the fake clock: the test hangs until its timeout or, with no await in front of fakeAsync, ends at once and green with nothing run. Move the clock with elapse and read the outcome.

Do this:

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();
});
});

Nothing can be awaited inside, so ignoreFailure() marks the failure as read, and close() with a last flushTimers() ends the test with an empty clock. See Awaiting inside fakeAsync on the testing page.

A policy compares keys, not arguments.

Don’t do this:

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

With one key for load(id), Policy.droppable takes load('grace'), called while load('ada') runs, for a duplicate and hands its caller Ada’s profile. Put the argument into the key.

Do this:

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

A key is any object compared with ==, so a record will do. See A key for each request on the jobs page.