Skip to content

Errors and observation

State handlers and reporting hooks have different responsibilities. run(onError: ...) computes a state after a job fails. The controller’s onError method and SoloObserver.onError receive errors for logging or reporting, including errors from cleanup and abandoned operations.

A controller can override onStart, onFinish, onError, onLog and onChange. For example, add an error hook to the profile controller:

final class ProfileController extends Solo<ProfileState> {
final ProfileApi api;
ProfileController(this.api) : super(const Initial());
// ...the jobs from the quick start...
@override
void onError(Job<Object?> job, Object error, StackTrace stackTrace) {
reportCrash(error, stackTrace);
}
}

SoloObserver receives the same events across controllers, plus onCreate and onClose. Install one at application startup:

final class LoggingObserver extends SoloObserver {
@override
void onStart(SoloBase<Object> solo, Job<Object?> job) =>
print('$job started');
@override
void onFinish(SoloBase<Object> solo, Job<Object?> job) =>
print('$job finished ${job.outcome}');
@override
void onChange(SoloBase<Object> solo, SoloTransition<Object> transition) =>
print('${transition.job ?? 'external'}: ${transition.current}');
}
void main() {
SoloBase.observer = LoggingObserver();
}

A change hook is given a SoloTransition: the state before and after, the job whose emit made it — null for an externalSetState, and a child of the running job rather than the root it belongs to — and a revision that grows by one per change, so two transitions are in order even when a hook changed the state again from inside the first. It answers who changed the state, which nothing outside the engine can work out.

The observer is called before the controller’s corresponding hook. Each call is independent; omitting super in a controller hook does not disable the observer. An error thrown by either hook is sent to the current Dart zone without changing the job’s outcome, stopping the queue, or preventing the other hook from running.

An observer only watches. Setting one changes nothing about where an error then goes. To answer for the errors that have nowhere else to go, set a handler:

SoloBase.errorHandler = (solo, job, error, stackTrace) =>
Sentry.captureException(error, stackTrace: stackTrace);

One handler for the process, set once at startup. With it set, those errors go to it instead of the zone; with nobody set, they go to the zone. Separating the two is deliberate: answering for an error is a responsibility somebody takes, not a side effect of switching a log on.

close() waits for the running job, and a job can take its time. The controller says what it is waiting for:

unawaited(controller.close().timeout(
const Duration(seconds: 5),
onTimeout: () => log('closing is held by ${controller.pending}'),
));

SoloPending names the job, its phase — body, children, cleanup — the cancellation it carries, whether a ctx.uncancellable section is holding one back, and whether the job was created with cancellable: false and turns them down.

It reports and does not diagnose. A long wait does not prove a forgotten ctx.wait: a body inside an external call looks the same, and so does a resource that takes its time to release. What the engine cannot see is SoloPhase.unknown, not a guess.

Accessing job.done or job.value, or calling job.ignore(), marks the outcome as observed. A Failed outcome that nobody observes goes to the job’s creation zone through Zone.handleUncaughtError, in addition to the error hooks. An installed observer alone does not mark outcomes as observed. If reporting is handled elsewhere and the caller needs no result:

profile.load().ignore(); // the counterpart of Future.ignore

Errors from cleanup, cancellation callbacks and operations abandoned by wait go to the reporting hooks. Without an overridden error hook or an installed SoloBase.errorHandler, they fall back to the job’s creation zone. Such an error can arrive after the job has already completed. It does not replace an existing cancellation outcome. These reporting paths exclude Cancelled itself.

An unhandled error of job.value or ctx.run(child) is still an unhandled Future error under Dart’s rules, even if that error is Cancelled. Handle those futures with await, catchError or ignore() as appropriate.

Cancelled implements Exception, so a broad catch also catches cancellation. If a body needs its own error handling, pass cancellation through first. This fragment handles a camera failure while preserving cancellation:

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

Once a job has accepted cancellation, its outcome remains Cancelled even if the body catches it. A catch block can still execute unwanted work, such as retrying the operation. For a final failure state, prefer the onError parameter of run rather than writing that correction inside a broad catch.

// A rule answers; it does not throw to refuse.
canStart: (state) => state.free > 0,

Where a rule throws anyway decides who hears about it:

Where it throws What happens
A start rule The job fails and the queue continues.
A rule at a context checkpoint The body receives the error.
Re-evaluation after a state update Reported; it does not itself cancel the running body.
A check that also controls a final state handler The handler is disabled.

Re-evaluation errors fall back to the controller’s creation zone when no error hook and no SoloBase.errorHandler answers for them. In the root Dart zone, an unhandled error can terminate the application. Install error reporting and observe job outcomes according to your application’s needs.

// Work with a life of its own: the job neither waits for it nor cancels
// it, and its errors still reach the job's hooks.
ctx.unattended(() => analytics.send('zoom'));
// Application data for the log hooks and observers.
ctx.log(('zoom', zoom));
// And the engine's own trace, when the queue itself needs watching.
SoloBase.debug = print;

ctx.unattended(action) starts work that the job does not wait for or cancel. Its errors are reported through the job’s error hooks, even after the job finishes, with the same zone fallback when no handler is installed. Use it for work with an independent lifetime. Starting children from this work is prohibited. A captured context still belongs to the original job: emit can work while that job is active, but is rejected after cancellation or completion. The background operation does not extend the context’s lifetime. A bare unawaited(future) does not provide the error routing of unattended.

ctx.log(data) forwards application data to log hooks and observers as it is, so a listener that wants a line makes one. SoloBase.debug additionally traces the controller’s internal queue and lifecycle operations.