solo_lints 0.1.0
solo_lints: ^0.1.0 copied to clipboard
Analyzer plugin for async_job and solo: a wait in cleanup, a late release, a catch that takes a cancellation, a job in its own queue, a shared key, a needless ignoreFailure().
solo_lints #
solo_lints is an analyzer plugin for code built on
async_job and
solo. 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 job
that waits for itself, one key for every request. Most rules stand for a
section of
Common mistakes,
the page of solo that shows each mistake and the right way side by side.
Install #
Name the plugin in analysis_options.yaml at the root of the package or the
workspace:
plugins:
solo_lints: ^0.1.0
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,
flutter analyze and the editor report its diagnostics. 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 three lints,
catch_without_check, key_per_request and ignore_failure_hides_bugs, are
turned on by name:
plugins:
solo_lints:
version: ^0.1.0
diagnostics:
catch_without_check: true
key_per_request: true
ignore_failure_hides_bugs: true
A diagnostic is silenced with the name of the plugin in front of the rule:
// ignore: solo_lints/ctx_in_cleanup on the line above it, or
// ignore_for_file: solo_lints/ctx_in_cleanup for the whole file.
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 |
await_own_queue |
warning | a job that waits for a job of its own controller's queue |
key_per_request |
lint | one key for a Policy.droppable job whose body reads an argument |
ignore_failure_hides_bugs |
lint | ignoreFailure() on a job that names its expected errors |
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, wait 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. See
Each wait in its place.
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, wait and run with no release of their own, followed at once
by onDispose or onDiscard that mentions the variable. See
Registering a release on the call.
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 (error) { // catch_without_check
await hw.reset();
ctx.emit(Broken(error));
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,
wait 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
Letting cancellation through a catch.
The quick fix inserts ctx.check(); as the first line of the catch.
sync_fake_async #
await fakeAsync((async) async { // sync_fake_async
final outcome = await profile.load().done;
expect(outcome, isA<Done<String>>());
});
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
Moving the clock in fakeAsync.
await_own_queue #
Job<void> syncAndRecord(int item) =>
enqueue<Ready, void>(key: _Op.syncAndRecord, (ctx) async {
final path = await sync(item).value; // await_own_queue
await recordPath(path).value; // await_own_queue
});
sync(item) queues a job behind the one that waits for it, and nothing moves
until close() cancels both. Run the job through ctx.run(_sync(item))
instead. The rule takes a method of the same controller that brings a
SoloJob, by its declared type or by what it returns when it is declared in
the same file, and close() and cancelAll(). It looks at the body of a
SoloContext job queued on this controller, what the body returns included, at
the actions that body waits for through join, abandonable, wait and
uncancellable, and at its cleanup, which the queue waits for as well. A mixin
on Solo is the controller too. See
Steps as children.
key_per_request #
Job<Profile> load(String id) => enqueue<Ready, Profile>(
key: _Op.load, // key_per_request
policy: Policy.droppable,
(ctx) => ctx.abandonable(() => api.load(id)),
);
A policy compares keys, not arguments. With one key for every 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:
key: (_Op.load, id). The rule takes enqueue of Solo with
Policy.droppable and enqueueJob of a job with that policy, when the key
reads no parameter of the enclosing method or function and the body reads one.
A key that reads a local variable or a parameter of a closure is left alone. It
is a lint rather than a warning because one key is sometimes meant, when the
result does not depend on the argument. See
A key for each request.
The quick fix replaces the key K with the record (K, a, b) of the
parameters the body reads. It is offered when each of them compares by value: a
String, a number, a bool, an enum, or one of those that can be null.
ignore_failure_hides_bugs #
SoloJob<Profile> load() => enqueue<Ready, Profile>(
(ctx) => api.load(),
isExpectedError: (e) => e is ApiException,
);
profiles.load().ignoreFailure(); // ignore_failure_hides_bugs
isExpectedError already keeps the errors it names quiet and lets the bugs
through to the zone, and ignoreFailure() hides those as well. Drop
ignoreFailure(): the expected errors stay quiet, and a bug is still reported.
The rule takes ignoreFailure() on a job that comes from job or enqueue
with isExpectedError, or from the add of a collect or accumulate field
with one, also through a method or function of the same library whose body is
=> or a single return, when nobody uses the job afterwards: the call
itself, or a cascade whose value is dropped, as the onPressed of a button is.
The page of common mistakes has no section for this one. It is a lint rather
than a warning because a job whose outcome nobody needs, with the reporting
left to the hooks, may call ignoreFailure() on purpose: see
Answering for every failure.
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 and solo by their names and the libraries they come from, without depending on either package. On a version of those packages where a name has changed, a rule stays silent rather than reports code that is right.