startShellJob method

  1. @override
Future<Result<ShellJob, ExecutionError>> startShellJob(
  1. String command, {
  2. required String id,
  3. required String logPath,
  4. ShellExecOptions? options,
})
override

Starts command detached: stdout/stderr append to logPath and the returned ShellJob keeps running until it exits or is stopped. ShellExecOptions.timeout and ShellExecOptions.cancelToken still apply (both stop the job).

Implementation

@override
Future<Result<ShellJob, ExecutionError>> startShellJob(
  String command, {
  required String id,
  required String logPath,
  ShellExecOptions? options,
}) async {
  final token = options?.cancelToken;
  if (token?.isCancelled ?? false) {
    return const Err(ExecutionError(ExecutionErrorCode.aborted, 'aborted'));
  }
  // Issue #919 (review): build the ceiling BEFORE anything is spawned —
  // a bad ceiling used to throw after `Process.start` plus the eager log
  // open, stranding an orphan child and leaking the fd with no job
  // object to stop or settle. Here it degrades to a plain Err.
  final warn = options?.onJobLogWarning;
  final JobLogCeiling ceiling;
  try {
    ceiling = JobLogCeiling(
      maxBytes: options?.jobLogMaxBytes ?? defaultJobLogMaxBytes,
      probe: diskFreeProbe == null
          ? null
          : () => diskFreeProbe!(File(logPath).parent.path),
      onWarn: warn == null
          ? null
          : (message) => warn('background job $id: $message'),
    );
  } on ArgumentError catch (error) {
    return Err(
      ExecutionError(
        ExecutionErrorCode.spawnError,
        'invalid jobLogMaxBytes: ${error.message}',
        cause: error,
      ),
    );
  }
  final ownGroup = LocalShell.ownProcessGroupAvailable;
  final started = await _start(command, options, ownSession: ownGroup);
  if (started.isErr) return Err(started.errorOrNull!);
  final process = started.valueOrNull!;
  // Feed optional stdin data (bash tool `stdin` param). With a live
  // stdin channel (issue #367) the pipe stays OPEN for the process's
  // lifetime so a mid-run password ask can be answered; otherwise it
  // closes right after start — background jobs are not interactive
  // beyond this.
  final liveStdin = options?.liveStdin;
  if (liveStdin != null) {
    liveStdin.bind(process.stdin.write);
  }
  if (options?.stdinData != null) {
    try {
      process.stdin.write(options!.stdinData);
      await process.stdin.flush();
    } on Object {
      // Process already gone — the settle path reports the real status.
    }
  }
  if (liveStdin == null) unawaited(process.stdin.close());
  final RandomAccessFile logSink;
  try {
    // Issue #925: open the log eagerly and guard it HERE. `File.openWrite`
    // starts its open lazily-but-eagerly with no owner for the failure —
    // an error (missing directory, permissions, ENOSPC) surfaced as an
    // unlistened future and reached the root-zone handler, killing the
    // whole fa process. An awaited open turns every open-class failure
    // into this clean Err instead.
    logSink = await File(logPath).open(mode: FileMode.append);
  } on Object catch (error) {
    process.kill();
    return Err(
      ExecutionError(
        ExecutionErrorCode.spawnError,
        'cannot open job log file $logPath: $error',
        cause: error,
      ),
    );
  }
  // Issue #919: bound the log — the ceiling (size ceiling with
  // head+marker+rolling tail, plus the low-disk guard with the probe
  // injectable via [LocalShell]) was built pre-spawn above.
  return Ok(
    _LocalShellJob(
      id: id,
      command: command,
      logPath: logPath,
      process: process,
      logSink: logSink,
      ceiling: ceiling,
      timeout: options?.timeout,
      token: token,
      ownGroup: ownGroup,
    ),
  );
}