async_job_lints

async_job_lints is an analyzer plugin for code built on async_job. It reports the mistakes that compile, run and often pass a first test: a wait in cleanup that never happens, a resource lost to a cancellation, a catch that resets a working device, a test that never moves its fake clock, a wait that a cancellation does not end. Each rule links to the page of async_job that shows the mistake and the right way.

Code built on solo or flutter_solo needs solo_lints instead: it includes these rules and adds those of the controller. Name one of the two plugins, not both: the analysis server resolves the versions of all plugins together, and when they disagree, no plugin runs.

Also on the documentation site, with search.

Install

Name the plugin in analysis_options.yaml at the root of the package or the workspace:

plugins:
  async_job_lints: ^0.1.1

The analysis server fetches the plugin itself, so it is not a dependency in pubspec.yaml. Plugins need Dart 3.10 or later. dart analyze and the editor report its diagnostics. Run dart analyze on the root of the package or on single files: on a folder below the root, such as dart analyze lib, it shows none of them. flutter analyze reports them on Flutter 3.38, but from Flutter 3.44 on it shows none of them; in a Flutter package, run dart analyze for the plugin. An editor sees a change to the plugins section after its analysis server restarts.

The warnings are on as soon as the plugin is named. The two lints, catch_without_check and bare_await, are turned on by name:

plugins:
  async_job_lints:
    version: ^0.1.1
    diagnostics:
      catch_without_check: true
      bare_await: true

A rule takes a severity in place of true: error, warning or info. bare_await: warning turns the lint on as a warning, and dart analyze then fails on it, as on any warning. false turns a warning off.

A diagnostic is silenced with the name of the plugin in front of the rule: // ignore: async_job_lints/ctx_in_cleanup on the line above it, or // ignore_for_file: async_job_lints/ctx_in_cleanup for the whole file.

The quick fixes are offered in the editor. dart fix does not apply them: it applies the fixes of the analyzer itself, not those of plugins.

On Dart 3.12, dart analyze in a package that names a plugin prints each diagnostic of the analyzer itself twice. Dart 3.13 prints it once.

The library package:async_job_lints/plugin_api.dart is for analyzer plugins built on top of this one, such as solo_lints, and not for the code a plugin checks.

Rules

Rule Kind Reports
ctx_in_cleanup warning a waiting member of the context called in cleanup
release_on_the_call warning a release registered on the line under the call that brings the resource
catch_without_check lint a broad catch in a body that acts before asking the job
sync_fake_async warning an async callback of fakeAsync
bare_await lint an await in a job body that does not go through the context

ctx_in_cleanup

ctx.onDispose(() async {
  await ctx.join(() => device.flush()); // ctx_in_cleanup
});

Cleanup runs after the body, where join throws StateError, so the buffer stays unflushed however the job ended. Wait there with a plain await. The rule looks into the functions given to ctx.onDispose and ctx.onDiscard and to dispose: and discard: of join, abandonable and run, literals, and local functions and final variables holding a literal passed by name, and reports the members that throw there: the checkpoints, onCancel, start and each. onDispose, onDiscard, disown, log and unattended are left alone. The checkpoints include those solo adds to its context, such as emit. See Cleanup.

release_on_the_call

final db = await ctx.join(Database.open);
ctx.onDispose(db.close); // release_on_the_call

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: ctx.join(Database.open, dispose: (db) => db.close()). The rule takes join, abandonable and run with no release of their own, followed at once by onDispose or onDiscard that mentions the variable. See Registering on arrival.

The quick fix removes the onDispose or onDiscard and passes the release to the call as dispose: or discard:, so the lines above become ctx.join(Database.open, dispose: (db) => db.close()). It is offered for a release that is a tear-off of a method of the variable, such as db.close, or a function literal without parameters.

catch_without_check

try {
  await ctx.join(hw.open);
  await ctx.join(() => hw.setZoom(zoom));
} on Object { // catch_without_check
  await hw.reset();
  rethrow;
}

Cancelled implements Exception, so a catch without on, or on Object, Exception or Outcome, takes it along with the failures it was written for, and a cancelled job resets a camera nobody broke. Make ctx.check() the first line of the catch. The rule looks at a job body, at the local functions declared in it and at the actions it waits for through join, abandonable and uncancellable; cleanup and unattended work are left alone. It is quiet when the try block neither awaits nor reaches a checkpoint, and when the catch begins with a checkpoint, after any if (...) rethrow; lines, begins with if (ctx.job.isCancelled) rethrow;, or ends in rethrow; with no wait and no checkpoint on the way, as a synchronous release or a log does. It is a lint rather than a warning because a catch that has to wait for a release on a cancellation too is reported as well: there ctx.check() first would leave the resource behind, and the catch keeps its lines under an // ignore:. See Catching errors of the operation.

The quick fix inserts ctx.check(); as the first line of the catch.

sync_fake_async

fakeAsync((async) async { // sync_fake_async
  async.elapse(const Duration(milliseconds: 10));
  await job.cancel();
  expect(job.outcome, isA<Cancelled>());
});

An await in the callback suspends it, and nobody is left to move the fake clock: the test hangs, or ends green with nothing run. Keep the callback synchronous, move the clock with elapse and read job.outcome. The rule reports the async of a callback given to fakeAsync or FakeAsync.run. See A cancelled open.

bare_await

final job = Job<void>((ctx) async {
  while (true) {
    use(await ctx.abandonable(database.readAll));
    await Future<void>.delayed(const Duration(seconds: 1)); // bare_await
  }
});

A plain await does not hear a cancellation: the job is cancelled, and the body still sits the delay out and goes on to the next line. Wait through the context: ctx.pause instead of Future.delayed, ctx.each instead of await for, which the rule reports with a message of its own, and for a call ctx.join or ctx.abandonable. The two differ in what a cancellation does to the call. join is for a call that must finish: it accepts the cancellation at once, so onCancel can stop the call, waits for the call to come back and throws only then. abandonable stops waiting at the cancellation and leaves the call in flight. Steps that must not hear a cancellation until they end go into ctx.uncancellable. See The context at a glance and Letting time pass.

The rule looks at a job body, a function literal or a top-level function whose first parameter is a JobContext, also when it is named _. Functions declared inside the body are left alone: the actions of join and uncancellable, cleanup, unattended work, local functions. A local function is reported where the body awaits its call. The rule is quiet on a wait through a member of the context of this body: join, abandonable, uncancellable, pause, run and runAll. It is quiet on value, done and cancel() of a child that ctx.each brings or that the body passed to ctx.start before in a final variable or a final field, and on Future.wait, [...].wait and (...).wait of such futures, a conditional of them and a final local variable holding one. A call that passes the context to a parameter typed JobContext, positional or named, waits through it. A release right after ctx.disown(v), on the next line or under if (ctx.disown(v)), has to run for a cancellation too. It is quiet on a value that is not a future, in finally, in a catch that is on Cancelled, and in a catch that takes a cancellation, untyped, on Object, on Exception or on Outcome, and ends in rethrow; with no return and no break or continue out of it before. A check at the top of a catch is not enough: it hears a cancellation that has come, not one that comes during the wait after it. A body of a job made with cancellable: false, as a literal or a constant, is left alone, and so is a top-level function every job of which is made so.

A loop of plain waits inside one ctx.join(() async { ... }) is not reported, although a cancellation does not end it either. A child held in a var, a parameter or a field that is not final, and a ... in the list of Future.wait, are reported though they wait through the context. So are a local function that waits through the context itself, where the body awaits its call, a future of the context taken further by timeout or then, Future.any, a mapped list such as jobs.map(ctx.run) and a variable of a pattern.

In a solo controller, a job that waits for a job of the controller's own queue waits for itself: the job it waits for is queued behind it. This rule reports that wait like any other plain one. solo_lints reports it as await_own_queue, with a message about the queue, and keeps bare_await quiet there.

It is a lint rather than a warning because tests wait in bodies on purpose, to cancel a job midway. A project that gates CI on it turns it on as bare_await: warning and puts // ignore_for_file: async_job_lints/bare_await into the test files that hold a body on a plain wait.

Versions

The plugin works on Dart 3.10 and later, with analyzer from 12.1 to 14. A Dart SDK that needs analyzer 15 does not load this version, and its rules stay silent until a release lifts the bound.

The rules know the types of async_job by their names and the library they come from, without depending on the package, and the same way the checkpoints solo adds to the context. On a version of those packages where a name has changed, a rule stays silent rather than reports code that is right. The rules also know wait, the name of abandonable before async_job 1.0, so they check code on 0.x the same way.

Libraries

async_job_lints
The rules of async_job_lints, registered by AsyncJobLintsPlugin.
main
The analyzer plugin of async_job_lints.
plugin_api
The API for an analyzer plugin built on top of async_job_lints.