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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,18 @@ This project follows [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0.

---

## [Unreleased]

### Added

- `start --timeout=<n>` option (default `90`): configures the maximum seconds the VM Service URI scrape loop waits for `flutter run` to print the debug URI in the log file. Previously the deadline was hardcoded to 90 s; cold starts on slow CI machines or after a fresh Flutter SDK install can exceed this limit. Setting `--timeout=120` (or higher) prevents false-timeout failures. The error message now reports the configured value rather than a literal "90s". Applies to the `--cdp-port` branch only; the non-CDP branch retains its own hardcoded deadline.
- `start --cdp-port=<n>` now probes the CDP port for availability BEFORE launching Chrome. When the port is already in use, the command exits 1 immediately with a clear message: "CDP port N is already in use; pass --cdp-port <free-port> or free it before running start." This replaces the previous misleading "Is Chrome installed?" catch-all that fired when Chrome failed to open the debug port it was asked to bind.

### Fixed

- `start --timeout=<n>` now rejects zero and negative values immediately with an actionable error ("--timeout must be a positive integer"), instead of silently passing a non-positive deadline to the VM Service scrape loop and producing a confusing "Timed out after 0s" failure.
- Passing an unknown option to any command now fails loudly instead of silently printing help and exiting as if help were requested (issue #12). The dispatcher writes `Unknown option: <flag>` to stderr (both long `--foo` and short `-x` forms), prints the command help, and exits non-zero. Other parse failures keep their original messages: a missing option value (`Missing argument for "..."`), a disallowed value, and a value given to a flag each surface their specific diagnostic unchanged. `--help` / `-h` and every valid invocation are unaffected. Because this is the shared dispatch path for every command, the fix benefits every plugin CLI built on the substrate.

## [0.0.8] - 2026-06-16

### Fixed
Expand Down
18 changes: 15 additions & 3 deletions doc/commands/start.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,10 @@ Launches a Chrome web dev session on port `3100` with the VM Service listener on
```
dart run artisan start [--device=<target>] [--port=<n>] [--vm-service-port=<n>]
[--[no-]dds] [--[no-]profile-static] [--cdp-port=<n>]
[--timeout=<n>]
```

`start` accepts no positional arguments. All configuration is done via named options and flags declared in `configure(ArgParser)` (see `lib/src/commands/start_command.dart:38`).
`start` accepts no positional arguments. All configuration is done via named options and flags declared in `configure(ArgParser)` in `lib/src/commands/start_command.dart`.

<a name="options"></a>
## Options
Expand All @@ -40,7 +41,8 @@ dart run artisan start [--device=<target>] [--port=<n>] [--vm-service-port=<n>]
| `--vm-service-port` | int | `8181` | VM Service listener port. Recorded in `state.json`; used by `mcp:serve` and connected-mode tools to open the WebSocket. |
| `--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` | Tag the session as a static-profile run. Sets `profile: "static"` in `state.json`; otherwise `"debug"`. |
| `--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. |
| `--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). Only applies to the `--cdp-port` branch. |

<a name="behavior"></a>
## Behavior
Expand Down Expand Up @@ -127,14 +129,24 @@ dart run artisan start --device=chrome --cdp-port=9222

Pre-launches Chrome with `--remote-debugging-port=9222` and runs Flutter on `-d web-server`, recording `chromePid` / `tmpProfileDir` / `cdpPort` in `state.json` so `dusk:resize` and `dusk:device` can drive the Chrome DevTools Protocol. Requires Flutter SDK 3.30.0 or newer. A subsequent `restart` preserves the CDP port.

**CDP session with extended VM Service timeout (cold CI or slow machine):**

```bash
dart run artisan start --device=chrome --cdp-port=9222 --timeout=180
```

Allows up to 180 seconds for the VM Service URI to appear in the log. Use this when the default 90 seconds is not enough for a cold Flutter SDK install or a slow CI host.

<a name="troubleshooting"></a>
## Troubleshooting

**Port already in use (`--port` collision).** If another process holds the web port, `flutter run` exits immediately and the log file shows `Error: Address already in use`. Pick a free port with `--port=<n>` or stop the occupying process with `lsof -ti:<port> | xargs kill`.

**`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 (90-second 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 90s...`. 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.
**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.

**CDP port already in use (`--cdp-port` collision).** When the configured CDP port is held by another process, `start` exits immediately before launching Chrome and emits: `CDP port <n> is already in use; pass --cdp-port <free-port> or free it before running start.` Choose a free port (for example `--cdp-port=9224`) or kill the occupying process with `lsof -ti:<port> | xargs kill`.

<a name="related"></a>
## Related
Expand Down
30 changes: 30 additions & 0 deletions doc/reference/signature-dsl.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ registration time; the registry applies it to the underlying `ArgParser`.
- [Option Modifiers](#option-modifiers)
- [Description Annotation](#description-annotation)
- [Name Validation](#name-validation)
- [Error Handling](#error-handling)
- [Examples](#examples)
- [Related](#related)

Expand Down Expand Up @@ -150,6 +151,35 @@ parse time with the invalid value and expected pattern quoted in the message.

---

<a name="error-handling"></a>
## Error Handling

When a command is invoked with an option the parser does not recognize, the
dispatcher fails loudly: it writes `Unknown option: <flag>` to stderr, prints
the command help, and exits with a non-zero code. This applies to both long
(`--unknown`) and short (`-x`) forms.

```
$ artisan make:command Foo --bogus
✗ Unknown option: --bogus
ℹ Description:
...
```

Every other parse failure keeps its original, specific message:

| Cause | Example invocation | Message |
|-------|--------------------|---------|
| Unknown option | `--bogus`, `-x` | `Unknown option: <flag>` |
| Missing option value | `--output` (value required) | `Missing argument for "--output".` |
| Disallowed value | `--mode=zoom` (not in `allowed`) | `"zoom" is not an allowed value for option "--mode".` |
| Value on a flag | `--force=1` | `Flag option "--force" should not be given a value.` |

`--help` / `-h` and every valid invocation are unaffected: they parse and
behave exactly as before.

---

<a name="examples"></a>
## Examples

Expand Down
66 changes: 59 additions & 7 deletions lib/src/commands/start_command.dart
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,13 @@ class StartCommand extends ArtisanCommand {
help: 'Chrome DevTools Protocol port. When set, pre-launches Chrome '
'with --remote-debugging-port=N and runs flutter with -d web-server. '
'Required for dusk:resize / dusk:device commands.',
)
..addOption(
'timeout',
defaultsTo: '90',
help: 'Seconds to wait for the VM Service URI to appear in the flutter '
'run log. Increase on cold starts where build + DartDev init takes '
'longer than the default. Applies to the --cdp-port branch only.',
);
}

Expand Down Expand Up @@ -263,6 +270,24 @@ class StartCommand extends ArtisanCommand {
resolvedCdpPort = parsed;
}

// 2. Resolve --timeout (applies to the CDP branch VM Service scrape).
final timeoutRaw = (ctx.input.option('timeout') as String?) ?? '90';
final resolvedTimeout = int.tryParse(timeoutRaw);
if (resolvedTimeout == null) {
ctx.output.error(
'--timeout expects an integer number of seconds, got "$timeoutRaw". '
'Pass e.g. --timeout=120.',
);
return 1;
}
Comment thread
anilcancakir marked this conversation as resolved.
if (resolvedTimeout <= 0) {
ctx.output.error(
'--timeout must be a positive integer (got $resolvedTimeout). '
'Pass e.g. --timeout=90.',
);
return 1;
}

if (resolvedCdpPort != null) {
return await _handleCdpBranch(
ctx: ctx,
Expand All @@ -272,6 +297,7 @@ class StartCommand extends ArtisanCommand {
ddsOn: ddsOn,
profileStatic: profileStatic,
cdpPort: resolvedCdpPort,
scrapeTimeout: resolvedTimeout,
);
}

Expand Down Expand Up @@ -308,7 +334,7 @@ class StartCommand extends ArtisanCommand {
);
}

final vmServiceUri = await _runVmServiceScrape(logFile);
final vmServiceUri = await _runVmServiceScrape(logFile, 90);

await StateFile.write(<String, dynamic>{
'pid': childPid,
Expand Down Expand Up @@ -344,6 +370,7 @@ class StartCommand extends ArtisanCommand {
required bool ddsOn,
required bool profileStatic,
required int cdpPort,
int scrapeTimeout = 90,
}) async {
Comment thread
anilcancakir marked this conversation as resolved.
// 1. Validate device value: only chrome (default) and web-server accepted.
if (device != 'chrome' && device != 'web-server') {
Expand Down Expand Up @@ -392,6 +419,18 @@ class StartCommand extends ArtisanCommand {
return 1;
}

// 4b. Probe CDP port availability before spawning Chrome. A busy CDP port
// means Chrome will fail to open the debug port, producing a misleading
// "Is Chrome installed?" error. Fail fast here with an actionable message.
final cdpPortFree = await cdpPortProbe(cdpPort);
if (!cdpPortFree) {
ctx.output.error(
'CDP port $cdpPort is already in use; pass --cdp-port <free-port> '
'or free it before running start.',
);
return 1;
}

// 5. Launch Chrome detached with debug port + dedicated user-data-dir.
final tmpRoot = cdpTmpProfileDirRoot ?? '/tmp';
final tmpProfileDir = '$tmpRoot/dusk-chrome-$cdpPort';
Expand Down Expand Up @@ -515,7 +554,7 @@ class StartCommand extends ArtisanCommand {
await cdpChromeNavigator(cdpPort, 'http://localhost:$webPort/');

// 11. NOW scrape the VM Service URI emitted by DWDS once Chrome connected.
final vmServiceUri = await _runVmServiceScrape(logFile);
final vmServiceUri = await _runVmServiceScrape(logFile, scrapeTimeout);

// 12. Write state with the new CDP fields so StopCommand can reap Chrome.
await StateFile.write(<String, dynamic>{
Expand Down Expand Up @@ -692,10 +731,12 @@ class StartCommand extends ArtisanCommand {
}

/// Runs the VM Service URI scrape, honoring the test seam when set.
Future<String> _runVmServiceScrape(File logFile) {
/// [timeoutSeconds] caps the scrape loop; the production implementation
/// uses it as the deadline, while the test seam ignores it.
Future<String> _runVmServiceScrape(File logFile, int timeoutSeconds) {
final scraper = cdpVmServiceScraper;
if (scraper != null) return scraper(logFile);
return _scrapeVmServiceUriFromFile(logFile);
return _scrapeVmServiceUriFromFile(logFile, timeoutSeconds);
}

/// Probes `flutter --version --machine` and extracts `frameworkVersion`.
Expand Down Expand Up @@ -948,10 +989,20 @@ class StartCommand extends ArtisanCommand {
return await completer.future.timeout(const Duration(seconds: 10));
}

Future<String> _scrapeVmServiceUriFromFile(File logFile) async {
/// Test-only entry to the live VM Service URI scrape loop, the production
/// path the [cdpVmServiceScraper] seam otherwise bypasses. Lets tests cover
/// the configurable deadline and the timeout error directly.
@visibleForTesting
Future<String> scrapeVmServiceUriForTest(File logFile, int timeoutSeconds) =>
_scrapeVmServiceUriFromFile(logFile, timeoutSeconds);

Future<String> _scrapeVmServiceUriFromFile(
File logFile,
int timeoutSeconds,
) async {
final sw = Stopwatch()..start();
int lastSize = 0;
while (sw.elapsed < const Duration(seconds: 90)) {
while (sw.elapsed < Duration(seconds: timeoutSeconds)) {
if (logFile.existsSync()) {
final size = logFile.lengthSync();
if (size > lastSize) {
Expand All @@ -966,7 +1017,8 @@ class StartCommand extends ArtisanCommand {
await Future<void>.delayed(const Duration(milliseconds: 250));
}
throw StateError(
'Timed out after 90s waiting for VM Service URI in ${logFile.path}.',
'Timed out after ${timeoutSeconds}s waiting for VM Service URI in '
'${logFile.path}.',
);
}

Expand Down
25 changes: 25 additions & 0 deletions lib/src/console/artisan_application.dart
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,20 @@ class ArtisanApplication {
final ArgResults parsed;
try {
parsed = parser.parse(commandArgs);
} on ArgParserException catch (e) {
// An unknown flag must fail loudly rather than silently printing help as
// if it were requested: name the offending option, then show the help so
// the caller can find the correct flag. Other parse failures (missing
// value, bad value, mandatory option) keep their original message.
if (_isUnknownOption(e)) {
stderr.writeln(
ConsoleStyle.error('Unknown option: ${e.argumentName ?? e.message}'),
);
} else {
stderr.writeln(ConsoleStyle.error(e.message));
}
_printCommandHelp(command, parser);
return 1;
} on FormatException catch (e) {
stderr.writeln(ConsoleStyle.error(e.message));
_printCommandHelp(command, parser);
Expand Down Expand Up @@ -136,6 +150,17 @@ class ArtisanApplication {
}
}

/// Whether [e] reports an unknown option / flag rather than a missing value,
/// a disallowed value, or a mandatory-option violation.
///
/// Dart's `args` has no dedicated unknown-flag error type, so the signal is
/// the message prefix the parser emits for every "Could not find an option"
/// path (`--long` and `-short` both share it). Matching the prefix keeps the
/// other [ArgParserException] causes on their original, helpful messages.
bool _isUnknownOption(ArgParserException e) {
return e.message.startsWith('Could not find an option');
}

void _printRootHelp() {
stdout.writeln(ConsoleStyle.header('artisan $version'));
stdout.writeln('');
Expand Down
Loading