exec method

  1. @override
Future<Result<ShellExecResult, ExecutionError>> exec(
  1. String command, {
  2. ShellExecOptions? options,
})
override

Executes a shell command. Must never throw: all failures are encoded in the returned Result.

Implementation

@override
Future<Result<ShellExecResult, ExecutionError>> exec(
  String command, {
  ShellExecOptions? options,
}) async {
  final token = options?.cancelToken;
  if (token?.isCancelled ?? false) {
    return const Err(ExecutionError(ExecutionErrorCode.aborted, 'aborted'));
  }
  // gh-1053: start the child in its own session/process group when the
  // host can (same probe the background jobs use) — the timeout and
  // cancel paths below then reap the WHOLE tree with one group signal
  // instead of stranding a surviving grandchild on the output pipe.
  final ownGroup = LocalShell.ownProcessGroupAvailable;
  final started = await _start(command, options, ownSession: ownGroup);
  if (started.isErr) return Err(started.errorOrNull!);
  final process = started.valueOrNull!;

  final stdout = StringBuffer();
  final stderr = StringBuffer();
  ExecutionError? callbackError;
  await _wireProcessStdin(process, options);
  final stdoutDone = process.stdout
      .transform(utf8.decoder)
      .forEach(
        (chunk) => _collect(
          stdout,
          chunk,
          options?.onStdout,
          process,
          (error) => callbackError = error,
        ),
      );
  final stderrDone = process.stderr
      .transform(utf8.decoder)
      .forEach(
        (chunk) => _collect(
          stderr,
          chunk,
          options?.onStderr,
          process,
          (error) => callbackError = error,
        ),
      );
  // Open-pipe tracker for the kill guards below: both futures complete
  // when the pipe write end closes. A late stream error is consumed HERE
  // only for the counting future — the awaiters below keep the original
  // propagation semantics.
  var openStreams = 2;
  void streamClosed() => openStreams--;
  unawaited(
    stdoutDone.then(
      (_) => streamClosed(),
      onError: (Object _) => streamClosed(),
    ),
  );
  unawaited(
    stderrDone.then(
      (_) => streamClosed(),
      onError: (Object _) => streamClosed(),
    ),
  );
  var childGone = false;
  unawaited(process.exitCode.then((_) => childGone = true));

  Timer? timer;
  var timedOut = false;
  final timeout = options?.timeout;
  if (timeout != null) {
    timer = Timer(timeout, () {
      // The exec is fully settled: the child exited AND both pipes
      // closed — nothing to reap, never signal (mirrors the job
      // registry's isRunning check, which this approximates). With the
      // child gone but a drain still in flight the signal still fires:
      // a live group member is what's holding the pipe, so the group is
      // ours — the residual recycled-pid window (pid freed by the reap
      // and re-led before the signal lands) is theoretical and accepted,
      // as in the job path.
      if (childGone && openStreams == 0) return;
      timedOut = true;
      unawaited(_stopTree(process, ownGroup: ownGroup));
    });
  }
  void onCancel(_) {
    if (childGone && openStreams == 0) return;
    unawaited(_stopTree(process, ownGroup: ownGroup));
  }

  token?.onCancel.then(onCancel);

  final exitCode = await process.exitCode;
  // gh-1053: the timer stays ARMED after the direct child exits — an
  // orphaned descendant can hold the pipes past the child's death, and
  // this timer is what bounds the call ("≤ timeout + kill grace + drain
  // grace regardless of what descendants do"). It no-ops once the exec
  // is fully settled (guard above); the settled drain
  // cancels it below. Cancel-on-exit used to strand exactly the
  // run-36421037356 shape (shell long dead, grandchild on the pipe).
  if (options?.liveStdin != null) {
    unawaited(process.stdin.close().catchError((_) {}));
  }
  // gh-1053 (review rework): the drain is capped UNCONDITIONALLY. This
  // point is only reached after `process.exitCode` resolved, so every
  // remaining byte on the pipes comes from an ORPHANED descendant
  // holding the write end — waiting for it full-unbounded has no
  // legitimate use (a caller wanting daemon output should use
  // `run_in_bg`), with or without a timeout. The race never delays a
  // healthy call: after the child's death the pipe buffer drains in
  // milliseconds. Timeout/cancel'd calls complete in
  // ≤ timeout + _killGrace + _drainGrace; a no-timeout call in
  // ≤ child runtime + _drainGrace. No kill round for the no-timeout
  // case — a detached daemon is the caller's on purpose.
  final drained = Future.wait([stdoutDone, stderrDone]);
  await Future.any([drained, Future<void>.delayed(_drainGrace)]);
  // Once the grace won the race, a late stream error (malformed bytes
  // from the dying tree) must never surface unhandled.
  drained.ignore();
  timer?.cancel();
  // Read AFTER the waits: a cancel that lands mid-drain must still mark
  // the result (the flag used to be captured pre-drain and lost).
  final cancelled = token?.isCancelled ?? false;

  return _result(
    callbackError: callbackError,
    timedOut: timedOut,
    timeout: timeout,
    cancelled: cancelled,
    stdout: stdout,
    stderr: stderr,
    exitCode: exitCode,
  );
}