skip to content

In a Dart command-line tool that shells out to git, when do you use Process.run versus Process.start, and why can a started child process hang?

level: seniorimportance: should knowfreq 30%

answer

  1. collect everything versus live streams
  2. ProcessResult: exitCode, stdout, stderr
  3. non-zero exit is not an exception
  4. pipes have limited capacity
  5. runInShell defaults to false

basics

~20 s

Process.run waits for the child to exit and returns a ProcessResult with exitCode, stdout and stderr. Process.start returns a live Process with stdin, stdout and stderr streams; it hangs if you never drain stdout or stderr.

solid answer

~40 s

`Process.run(executable, args, workingDirectory: ...)` fits a call like `git rev-parse HEAD`: small output, and you only need the result. It returns a `ProcessResult` whose `stdout` and `stderr` are decoded with `systemEncoding` into `String`s (or `Uint8List` if you pass a `null` encoding). A non-zero exit is not an exception - check `exitCode` yourself; only a failure to start throws `ProcessException`. `Process.start` suits long or large output (`git log` over a big repo), live progress, writing to the child's `stdin`, or `kill()`. The catch: the pipes have limited capacity, so if you await `exitCode` without reading `stdout` and `stderr`, the child blocks writing and never exits. `runInShell` defaults to `false`, so the argument list reaches git without shell parsing.

code

dart · 13 lines
dart
import 'dart:io';

Future<String> headCommit(String repo) async {
  final result = await Process.run(
    'git',
    ['rev-parse', 'HEAD'],
    workingDirectory: repo,
  );
  if (result.exitCode != 0) {
    throw StateError('git rev-parse failed: ${result.stderr}');
  }
  return (result.stdout as String).trim();
}

go deeper

for a junior

Recall that Process.run gives you exitCode, stdout and stderr after the command finishes.

for a middle

Explain the ProcessResult types, that a non-zero exit is not an exception, and when you need Process.start's streams.

for a senior

Diagnose the full-pipe hang, drain both streams, avoid shell parsing of untrusted arguments and interpret signal exit codes.

for a principal

Decide how a tool wraps external commands: error reporting, timeouts, cross-platform executable resolution and when to avoid shelling out.

## Two ways to run another program `dart:io` exposes child processes through the `Process` class. A command-line tool written in Dart - say, one that reads files in a repository and asks `git` about them - usually needs one of two shapes. | | `Process.run` | `Process.start` | |---|---|---| | Returns | `Future<ProcessResult>` after the child exits | `Future<Process>` as soon as it starts | | Output | whole `stdout`/`stderr`, decoded | live `Stream<List<int>>` for each | | Input | none | `stdin` as an `IOSink` | | Control | none | `pid`, `kill()`, `exitCode` future | | Best for | short commands with small output | long runs, large output, interaction | There is also `Process.runSync`, which takes the same arguments as `run` but blocks the isolate until the child finishes. ## Process.run: collect and inspect `Process.run` accepts `workingDirectory`, `environment`, `includeParentEnvironment` (default `true`), `runInShell` (default `false`) and `stdoutEncoding`/`stderrEncoding` (default `systemEncoding`). The `ProcessResult` holds `exitCode`, `stdout` and `stderr`; with the default encodings the outputs are `String`s, and passing `null` gives raw `Uint8List` bytes. Two behaviours catch people out: - **A failing command does not throw.** If `git` exits with status 128 because the directory is not a repository, `run` completes normally with `exitCode == 128`. You must check it. - **A command that cannot start does throw.** If `git` is not on the `PATH`, the Future completes with a `ProcessException`. ## Process.start: streams and the pipe trap `Process.start` returns a `Process` whose `stdout` and `stderr` are byte streams and whose `stdin` is a sink. This is how you process `git log` for a large history line by line, show progress while a clone runs, or feed input to a command. The documentation spells out the trap: `stdin`, `stdout` and `stderr` are **pipes with limited capacity**. If the child writes more than the pipe holds and nobody reads it, the child **blocks** waiting for space. So code that does `await process.exitCode` without consuming `stdout` - or that reads `stdout` but ignores a chatty `stderr` - can wait forever. The docs also require reading all data on both streams so system resources are released. Rules that follow: 1. Always start consuming **both** `stdout` and `stderr` before awaiting `exitCode`. 2. `exitCode` may complete before the output streams have delivered their last bytes, so wait for the streams' completion too if you need all output. 3. Use `ProcessStartMode.inheritStdio` when you simply want the child's output to appear in your terminal; `ProcessStartMode.detached` gives you only the `pid`, and reading `exitCode` then throws a `StateError`. ## Arguments, shells and exit codes - With `runInShell: false`, the list of arguments is passed to the executable as-is, so a branch name containing spaces or shell metacharacters is safe. Setting `runInShell: true` routes through `/bin/sh` or `cmd.exe`, which reintroduces quoting and injection concerns. On Windows, `.bat` and `.cmd` files may be run through a shell regardless. - On Linux and macOS, a child killed by a signal reports a **negative** exit code whose absolute value is the signal number. - `kill()` sends `ProcessSignal.sigterm` by default. ## Common mistakes - Treating `ProcessResult` as success without checking `exitCode`. - Using `Process.start` and awaiting only `exitCode`. - Building a single command string and setting `runInShell: true` to split it. - Calling `runSync` inside a Flutter desktop app's UI isolate.

  • What happens if git is not installed when you call Process.run('git', ...)?
    The child cannot be started, so the returned Future completes with a `ProcessException` carrying the executable, arguments and OS message. That is different from git starting and failing, which completes normally with a non-zero `exitCode`.
  • Why is runInShell: true risky when an argument comes from user input?
    With a shell, the command line is parsed by `/bin/sh` or `cmd.exe`, so characters such as `;`, `&` or backticks in the input can start other commands. With the default `false`, each list element reaches the executable as one argument and no shell parsing happens.
  • What does a negative exitCode from a Process mean on Linux or macOS?
    The child was terminated by a signal; the absolute value is the signal number, so -11 means SIGSEGV and -15 means SIGTERM. A normal exit on those systems is in the range 0 to 255.

saying these in an interview costs you the question

  • Assumes Process.run throws when the command exits non-zero
  • Awaits exitCode after Process.start without reading stdout or stderr
  • Sets runInShell: true just to split a command string
  • Thinks the child is killed automatically when its output is ignored
  • Believes ProcessResult.stdout is always a String regardless of encoding