Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,16 @@ This project follows [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0.

## [Unreleased]

### Added

- **`ExtensionNotRegisteredException` and `ArtisanCommand.exitTryAgain` (75).** `VmServiceClient.callServiceExtension` (and so `ArtisanContext.callExtension`) throws `ExtensionNotRegisteredException` when the app answers JSON-RPC `-32601`, so a command can tell "not registered yet" from a failed call without importing `package:vm_service` (which trips `depend_on_referenced_packages` in a consumer). `exitTryAgain` is `EX_TEMPFAIL` from `sysexits.h`: the exit code for "not ready yet, retry". (`lib/src/vm/`, `lib/src/console/artisan_command.dart`)

### Fixed

- **A connected command calling an extension the app has not registered prints one line, not an RPC stack.** Run while the app was still booting, `player:state` printed `Connected command player:state failed: ext.watchools.player: (-32601) Method not found` and a vm_service stack, then worked a few seconds later. The dispatcher now prints `ext.watchools.player is not registered in the running app yet (still booting, a release build, or the plugin is not installed). Retry in a few seconds.` and exits 75. Every other RPC error keeps the unexpected-failure path (exit 3, with the stack). The mapping covers the retries inside `callServiceExtension` too, so a hot restart whose fresh isolate has not registered its extensions yet gets the same line. The mapping applies where the runtime answers `-32601` (the Dart VM, and the Chrome proxy in DWDS 27); a web runtime that reports a missing extension with another code keeps the old output. (`lib/src/vm/vm_service_client.dart`, `lib/src/console/artisan_application.dart`, `test/`)

- **`start` on a cold native build no longer exits on a stack fragment when `--timeout` runs out.** A first macOS build (CocoaPods, SwiftPM) outlasts the 90s scrape, and the timeout's `StateError` escaped as an unexpected error whose visible tail was `<asynchronous suspension>`, while the app kept building and the next connected command recovered the URI from the log. The plain branch now checks the `flutter run` pid: alive, it prints `flutter run (pid <n>) is still building after <n>s` with the `artisan status`, retry and `--timeout` hints, keeps the booting session and exits 75; gone, it reports that `flutter run` exited before announcing a URI, names the log, stops the FIFO holder, removes the session and exits 1. A connected command against a booting session whose `flutter run` is still alive exits 75 as well (it was 1), so a caller can retry on one code from `start` to the first command that connects. The scrape loop throws `TimeoutException` instead of `StateError`; the `--cdp-port` branch still fails and reaps on a timeout, and prints the timeout's message without the `TimeoutException after <duration>` prefix. (`lib/src/commands/start_command.dart`, `test/commands/start_command_early_state_test.dart`, `doc/commands/start.md`)

## [0.0.19] - 2026-10-09

### Changed
Expand Down
11 changes: 9 additions & 2 deletions doc/commands/start.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ dart run artisan start [--device=<target>] [--port=<n>] [--vm-service-port=<n>]
| `--dds` | flag | `false` | Enable Dart Development Service. When absent (default), `--no-dds` is forwarded to `flutter run` so dusk and tinker connect directly to the VM Service. |
| `--profile-static` | flag | `false` | Start a static-profile run. Sets `profile: "static"` in `state.json`; otherwise `"debug"`. On a device (Android, iOS, desktop) it also forwards `--profile` to `flutter run`, once even when `--flutter-arg=--profile` is passed too; there is no hot reload in that mode. On Chrome it stays a label, because a web profile build has no VM Service. `restart` replays it from `state.json`; `restart --no-profile-static` drops it. |
| `--cdp-port` | int | (none) | Chrome DevTools Protocol port. When set, `start` pre-launches Chrome with `--remote-debugging-port=<n>` and runs Flutter on `-d web-server`, recording `chromePid` / `tmpProfileDir` / `cdpPort` in `state.json`. Required for `dusk:resize` / `dusk:device`. Only valid with `--device=chrome` or `--device=web-server`, and requires Flutter SDK 3.30.0 or newer. A subsequent `restart` preserves this port. The port is probed before Chrome launches; if it is already in use you receive a clear error with the `--cdp-port` hint instead of a misleading "Is Chrome installed?" message. |
| `--timeout` | int | `90` | Seconds to wait for the VM Service URI to appear in the `flutter run` log. Increase on cold starts where build and DartDev initialisation takes longer than the default (common on first run after a clean Flutter SDK install or on low-powered CI). Applies to the `--cdp-port` branch and to the plain one alike; on the `--cdp-port` branch it also bounds the wait for the web server's `is being served at` line, which used to be a fixed 60 seconds and failed a web compile that ran long under load. |
| `--timeout` | int | `90` | Seconds to wait for the VM Service URI to appear in the `flutter run` log. Increase on cold starts where build and DartDev initialisation takes longer than the default (common on first run after a clean Flutter SDK install or on low-powered CI). On the plain branch a timeout while `flutter run` is still alive exits `75` and leaves the session recording (see [Troubleshooting](#troubleshooting)). Applies to the `--cdp-port` branch and to the plain one alike; on the `--cdp-port` branch it also bounds the wait for the web server's `is being served at` line, which used to be a fixed 60 seconds and failed a web compile that ran long under load. |
| `--flutter-arg` | string | (none) | Extra argument forwarded verbatim to `flutter run`, repeatable. Use it for anything `start` has no flag of its own for: `--flutter-arg=--dart-define=KEY=VALUE`, `--flutter-arg=--flavor=dev`, `--flutter-arg=--web-renderer=html`. Forwarded AFTER the arguments `start` builds, so a repeated flag overrides the default it chose. A value containing commas is kept whole (`splitCommas` is off), so `--flutter-arg=--dart-define=TAGS=a,b` reaches Flutter as one argument. Recorded in `state.json` as `flutterArgs` and replayed by `restart`; `restart --flutter-arg=<arg>` replaces the whole carried set, which is also how to clear one. |

<a name="behavior"></a>
Expand Down Expand Up @@ -160,7 +160,14 @@ Allows up to 180 seconds for the VM Service URI to appear in the log. Use this w

**`mkfifo` permission denied.** `start` creates the FIFO at `~/.artisan/flutter-dev.fifo`. If `~/.artisan/` was created by a previous run with different ownership or mode bits, `mkfifo` fails. Fix with `rm -rf ~/.artisan && dart run artisan start`. Note: `start` is POSIX-only (macOS/Linux). It will not work on Windows.

**VM Service URI never appears (timeout).** If `flutter run` stalls before printing the URI (for example, the Chrome binary is missing, the Flutter SDK is not on `PATH`, or a Dart compilation error occurs), `start` throws `StateError: Timed out after <n>s...`. Inspect the log at `~/.artisan/flutter-dev.log` for the underlying Flutter output. Common causes: wrong `--device` value, missing `CHROME_EXECUTABLE` env var for headless environments, or a syntax error in the app's entry point. On cold starts (first run after a fresh SDK install, slow CI), increase the deadline with `--timeout=120` or higher.
**VM Service URI never appears (timeout).** When `--timeout` runs out before the URI shows up in the log, `start` checks whether `flutter run` is still alive.

- **Still running (exit 75).** A first native build (CocoaPods, SwiftPM, Gradle) routinely takes longer than 90 seconds, and the app keeps building after `start` returns. `start` prints `flutter run (pid <n>) is still building after <n>s ...` and exits `75` (`EX_TEMPFAIL`, `ArtisanCommand.exitTryAgain`), so a script can tell "not ready yet" from a failure. The session stays recorded with `booting: true`; `status` shows it, and the next connected command reads the URI from the log once the build finishes. A connected command run before that point also exits `75` while the recorded `flutter run` is alive, so a script can retry on the one code until it succeeds. Pass `--timeout=600` to make `start` itself wait for a cold build.
- **Exited (exit 1).** `flutter run` died before announcing a URI, almost always a build or launch error. `start` prints `flutter run (pid <n>) exited before announcing a VM Service URI` with the log path, stops the FIFO holder and removes the session (the log stays), so `status` does not report a dead app as booting. Common causes in `~/.artisan/flutter-dev.log`: a wrong `--device` value, a missing `CHROME_EXECUTABLE` env var in headless environments, or a compile error in the app's entry point.

On the `--cdp-port` branch a timeout still fails the start with exit 1 and reaps Chrome and the flutter tool, since a web build has no long native phase to wait out.

**Connected command reports an unregistered extension (exit 75).** A connected command that calls `ctx.callExtension('ext.<name>')` before the app has registered that extension (the app is still booting, it is a release build, or the plugin is not installed in it) prints one line, `ext.<name> is not registered in the running app yet (...). Retry in a few seconds.`, and exits `75`. The JSON-RPC `-32601` error reaches the command as `ExtensionNotRegisteredException`, so a command that wants to wait for the extension can catch it without importing `package:vm_service`.

**Web server not ready in time (`--cdp-port`).** On the `--cdp-port` branch `start` waits for the `is being served at` line before it navigates Chrome, and fails with `Timed out after <n>s waiting for "is being served at"`. The deadline is `--timeout`; raise it when the web compile is slow. The failed start stops the flutter tool's and Chrome's process groups and waits for their ports, so a retry straight after it does not collide with the leftovers.

Expand Down
1 change: 1 addition & 0 deletions lib/artisan.dart
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ export 'src/extensions/register_extension_idempotent.dart';
export 'src/state/state_file.dart';
export 'src/state/vm_service_log.dart';
export 'src/stubs/stub_loader.dart';
export 'src/vm/extension_not_registered_exception.dart';
export 'src/vm/vm_service_client.dart';

// Builtin commands (ship with artisan; the consumer's bin/dispatcher.dart
Expand Down
69 changes: 66 additions & 3 deletions lib/src/commands/start_command.dart
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import '../console/artisan_command.dart';
import '../console/artisan_context.dart';
import '../console/command_boot.dart';
import '../console/pid_parser.dart';
import '../console/process_alive.dart';
import '../console/shell_quote.dart';
import '../state/state_file.dart';
import '../state/vm_service_log.dart';
Expand Down Expand Up @@ -200,6 +201,11 @@ class StartCommand extends ArtisanCommand {
@visibleForTesting
static Duration cdpReapGrace = const Duration(seconds: 5);

/// Test seam: liveness check for the `flutter run` pid when the plain
/// branch's scrape times out (default [processAlive]).
@visibleForTesting
static bool Function(int pid) startIsAlive = processAlive;

/// Default [CdpPortProbe] implementation. Binds [ServerSocket] on
/// [InternetAddress.loopbackIPv4] and immediately closes it.
/// Returns `true` when the port is free; `false` on [SocketException]
Expand Down Expand Up @@ -523,7 +529,19 @@ class StartCommand extends ArtisanCommand {
);
await StateFile.write(booting);

final vmServiceUri = await _runVmServiceScrape(logFile, resolvedTimeout);
final String vmServiceUri;
try {
vmServiceUri = await _runVmServiceScrape(logFile, resolvedTimeout);
} on TimeoutException {
return _reportScrapeTimeout(
ctx,
childPid: childPid,
holderPid: holderPid,
fifoPath: fifoPath,
timeoutSeconds: resolvedTimeout,
logFile: logFile,
);
}

await StateFile.write(readyState(booting, vmServiceUri: vmServiceUri));

Expand All @@ -534,6 +552,46 @@ class StartCommand extends ArtisanCommand {
return 0;
}

/// Reports a scrape that ran out of time on the plain branch.
///
/// A first native build (CocoaPods, SwiftPM, Gradle) routinely outlasts the
/// default 90s, and the app keeps building after this command returns: the
/// booting session stays on disk, and the next connected command reads the
/// URI back from the log. So a live `flutter run` is "not ready yet"
/// ([ArtisanCommand.exitTryAgain]), not a failure. A `flutter run` that is
/// gone exited before announcing a URI, almost always a build error, and the
/// log is where it says why. Its session is taken down with it (the FIFO
/// holder, the FIFO, the record) so `status` does not report a dead app as
/// booting; the log stays.
Future<int> _reportScrapeTimeout(
ArtisanContext ctx, {
required int childPid,
required int holderPid,
required String fifoPath,
required int timeoutSeconds,
required File logFile,
}) async {
if (!startIsAlive(childPid)) {
cdpKillPid(holderPid);
final File fifo = File(fifoPath);
if (fifo.existsSync()) await fifo.delete();
await StateFile.delete();
ctx.output.error(
'flutter run (pid $childPid) exited before announcing a VM Service '
'URI. See ${logFile.path} for the reason.',
);
return 1;
}
ctx.output.warning(
'flutter run (pid $childPid) is still building after ${timeoutSeconds}s '
'and has not announced a VM Service URI yet. The session is recorded: '
'run `artisan status`, or retry a connected command once the build '
'finishes. Pass --timeout=<seconds> to wait longer. Log: '
'${logFile.path}',
);
return ArtisanCommand.exitTryAgain;
}

/// Handles the `--cdp-port=N` branch: validate, pre-launch Chrome,
/// run flutter on web-server, scrape VM Service URI, navigate Chrome,
/// write state.
Expand Down Expand Up @@ -762,7 +820,11 @@ class StartCommand extends ArtisanCommand {
// FIFO, no tmp profile dir, and holds none of the ports the retry
// that usually follows will bind. The error surfaced to the operator
// is the ORIGINAL throw, never a cleanup error.
ctx.output.error('CDP start failed after launch: $error');
// A TimeoutException prints its Duration first; the message already
// names the deadline.
final Object reason =
error is TimeoutException ? (error.message ?? error) : error;
ctx.output.error('CDP start failed after launch: $reason');
await _reapAfterCdpFailure(
ctx: ctx,
flutterProcess: flutterProcess,
Expand Down Expand Up @@ -1224,9 +1286,10 @@ class StartCommand extends ArtisanCommand {
}
await Future<void>.delayed(const Duration(milliseconds: 250));
}
throw StateError(
throw TimeoutException(
'Timed out after ${timeoutSeconds}s waiting for VM Service URI in '
'${logFile.path}.',
Duration(seconds: timeoutSeconds),
);
}

Expand Down
14 changes: 13 additions & 1 deletion lib/src/console/artisan_application.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@ import 'package:args/args.dart';
import '../helpers/console_style.dart';
import '../state/state_file.dart';
import '../state/vm_service_log.dart';
import '../vm/extension_not_registered_exception.dart';
import '../vm/vm_service_client.dart';
import 'artisan_command.dart';
import 'artisan_context.dart';
import 'artisan_input.dart';
import 'artisan_output.dart';
import 'artisan_registry.dart';
import 'command_boot.dart';
import 'process_alive.dart';

/// Core console application + dispatcher.
///
Expand Down Expand Up @@ -167,14 +169,24 @@ class ArtisanApplication {
'one. The app may still be building: run `artisan status` and retry, '
'or `artisan start` again if it is not.',
);
return 1;
// A live app mid-build is the retry `start` pointed the caller at when
// it timed out with the same exit code; anything else is a failure.
final Object? pid = state['pid'];
final bool building =
state['booting'] == true && pid is int && processAlive(pid);
return building ? ArtisanCommand.exitTryAgain : 1;
}
final vmClient = VmServiceClient(wsUri);
try {
await vmClient.connect();
return await command.handle(
ArtisanContext.connected(input, output, vmClient, registry: registry),
);
} on ExtensionNotRegisteredException catch (e) {
// An expected state, not a crash: the stack would only point into
// vm_service, and the line below says everything the operator can act on.
output.error(e.message);
return ArtisanCommand.exitTryAgain;
} catch (e, s) {
output.error('Connected command ${command.name} failed: $e');
stderr.writeln(s);
Expand Down
6 changes: 6 additions & 0 deletions lib/src/console/artisan_command.dart
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,12 @@ import 'command_signature.dart';
/// args, file conflicts, unreachable VM) should write to
/// `ctx.output.error(...)` and return non-zero.
abstract class ArtisanCommand {
/// Exit code for "not ready yet, retry": the app is still building, or has
/// not registered the extension a connected command called. Matches
/// `EX_TEMPFAIL` from `sysexits.h`, so a script can retry on it and treat
/// any other non-zero code as a real failure.
static const int exitTryAgain = 75;

/// Optional Laravel-style signature DSL. When set, [name] derives from
/// it and [configure] auto-registers the declared options/flags.
///
Expand Down
23 changes: 23 additions & 0 deletions lib/src/vm/extension_not_registered_exception.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/// Thrown by [VmServiceClient.callServiceExtension] when the running app has
/// no handler for [method] (JSON-RPC `-32601`, method not found).
///
/// The usual cause is timing: a connected command ran while the app was still
/// booting, before its plugin registered the extension, and the same call
/// succeeds a few seconds later. A release build (which registers no service
/// extensions) or a plugin that is not installed in the app answers the same
/// way. The dispatcher prints [message] as one line and exits with
/// [ArtisanCommand.exitTryAgain]; a command that wants to wait for the
/// extension can catch this type without importing `package:vm_service`.
final class ExtensionNotRegisteredException implements Exception {
const ExtensionNotRegisteredException(this.method);

/// The extension that was called, e.g. `ext.dusk.snapshot`.
final String method;

String get message => '$method is not registered in the running app yet '
'(still booting, a release build, or the plugin is not installed). '
'Retry in a few seconds.';

@override
String toString() => message;
}
Loading
Loading