spawnFleuryApp function

Future<SpawnedFleuryApp> spawnFleuryApp({
  1. required List<String> command,
  2. String? socketPath,
  3. Duration? connectTimeout = const Duration(seconds: 20),
  4. Duration killGrace = const Duration(seconds: 2),
  5. Map<String, String>? environment,
  6. void onLog(
    1. String tag,
    2. String line
    )?,
  7. Future<void>? abort,
  8. Duration slowStartAfter = const Duration(seconds: 10),
  9. void onSlowStart()?,
})

Binds a Unix socket, spawns command with FLEURY_HANDLE pointed at it, and awaits the app's FIRST connection — racing the process exiting before it connects and connectTimeout. Once connected the listening socket is CLOSED, so the host only ever reads that one connection: a later connect is refused (ECONNREFUSED), and any connection already queued in the OS backlog is never accepted or read — so it can inject nothing and its writes ultimately fail. The accepted connection stays open.

socketPath overrides the auto-generated path (e.g. a per-session socket); when null a short /tmp/fleury-host-*/app-$pid.sock is used and removed on SpawnedFleuryApp.dispose. With an explicit socketPath only the socket file is removed — the caller owns its directory.

onLog receives the app's own stdout/stderr line by line (sanitized), tagged out/err, so the host can forward it without it polluting the wire.

abort, if given and it completes (or fails) before the app connects, abandons the spawn (tears the process + socket down and throws). A host whose waits all have an owner, such as fleury serve, whose browser or shutdown ends each one, passes a null connectTimeout to wait for a slow start instead of failing it: a cold dart run compiles the whole app before runApp can connect, which takes far longer on a busy machine.

onSlowStart runs once if the app is still starting after slowStartAfter, so a host can say it is waiting rather than look stuck.

Throws FleurySpawnException on exit-before-connect, timeout, or abort, and rethrows a ProcessException if the command can't be launched.

Implementation

Future<SpawnedFleuryApp> spawnFleuryApp({
  required List<String> command,
  String? socketPath,
  Duration? connectTimeout = const Duration(seconds: 20),
  Duration killGrace = const Duration(seconds: 2),
  Map<String, String>? environment,
  void Function(String tag, String line)? onLog,
  Future<void>? abort,
  Duration slowStartAfter = const Duration(seconds: 10),
  void Function()? onSlowStart,
}) async {
  if (command.isEmpty) {
    throw ArgumentError.value(command, 'command', 'must be non-empty');
  }

  final Directory? ownedDir = socketPath == null ? _createHandleDir() : null;
  final path = socketPath ?? '${ownedDir!.path}/app-$pid.sock';
  try {
    File(path).deleteSync();
  } on FileSystemException {
    // Not there — fine.
  }

  final ServerSocket server;
  try {
    server = await ServerSocket.bind(
      InternetAddress(path, type: InternetAddressType.unix),
      0,
    );
  } catch (_) {
    // Bind failed (e.g. the unix path exceeded the ~104-byte limit, or the
    // address is in use) — release the temp dir we just created so a failed
    // spawn doesn't leak it under /tmp. The socket file (if any) goes too.
    try {
      File(path).deleteSync();
    } catch (_) {}
    if (ownedDir != null) {
      try {
        ownedDir.deleteSync(recursive: true);
      } catch (_) {}
    }
    rethrow;
  }
  var serverClosed = false;
  Future<void> closeServer() async {
    if (serverClosed) return;
    serverClosed = true;
    await server.close();
  }

  Future<void> removeSocket() async {
    await closeServer();
    try {
      File(path).deleteSync();
    } catch (_) {}
    if (ownedDir != null) {
      try {
        ownedDir.deleteSync(recursive: true);
      } catch (_) {}
    }
  }

  final env = Map<String, String>.from(environment ?? Platform.environment)
    ..['FLEURY_HANDLE'] = path
    // Every app spawned through here is SUPERVISED: this function reaps the
    // process on abort/dispose/exit-before-connect, and serve additionally
    // reaps on browser-close + an admission cap. So the app's
    // RemoteTerminalDriver must wait UNBOUNDED for the peer's INIT rather than
    // self-destruct on its leak-guard fuse — a serve warm standby legitimately
    // idles between connecting and a browser attaching. Without this the
    // standby dies at initTimeout and every reconnect pays full cold-start
    // cost, defeating the warm pre-spawn optimization.
    ..['FLEURY_REMOTE_WAIT'] = 'supervised';
  env['COLORTERM'] = env['COLORTERM'] ?? 'truecolor';

  final Process process;
  try {
    process = await Process.start(
      command.first,
      command.sublist(1),
      environment: env,
    );
  } on ProcessException {
    await removeSocket();
    rethrow;
  }

  // The app renders to the socket; its stdout/stderr is its own log output.
  // OutputCapture bounds an unterminated line before it reaches the host log;
  // LineSplitter would retain an attacker-controlled line until EOF/newline.
  final forwarded = <Future<void>>[];
  StreamSubscription<String> forward(
    String tag,
    LogSource sourceTag,
    Stream<List<int>> source,
  ) {
    final capture = OutputCapture(
      buffer: LogBuffer(capacity: 1),
      sanitizeForTerminal: true,
      onLine: (line) => onLog?.call(tag, line.text),
    );
    final done = Completer<void>();
    forwarded.add(done.future);
    return source
        // A child's logs are arbitrary bytes, independent of the render wire.
        // Bad encoding must not terminate the process hosting other sessions.
        .transform(const Utf8Decoder(allowMalformed: true))
        .listen(
          (chunk) => capture.addChunk(chunk, sourceTag),
          onDone: () {
            capture.flushPartials();
            done.complete();
          },
          onError: (Object error, StackTrace stack) {
            capture.flushPartials();
            capture.addChunk(
              'subprocess log stream failed: $error\n',
              sourceTag,
            );
            done.complete();
          },
          cancelOnError: true,
        );
  }

  final outSub = forward('out', LogSource.stdout, process.stdout);
  final errSub = forward('err', LogSource.stderr, process.stderr);

  Future<int> killProcess() async {
    process.kill(ProcessSignal.sigterm);
    try {
      return await process.exitCode.timeout(killGrace);
    } on TimeoutException {
      process.kill(ProcessSignal.sigkill);
      try {
        // Sending SIGKILL is not the end of process ownership: wait for the
        // operating system to report the exit so callers cannot finish
        // shutdown while the child is still live (or leave it unreaped).
        return await process.exitCode;
      } catch (_) {
        return -1;
      }
    } catch (_) {
      return -1;
    }
  }

  // Cancellable timer arms, so a connect doesn't leave them armed for the rest
  // of their durations.
  final timedOut = Completer<Object>();
  final timer = connectTimeout == null
      ? null
      : Timer(connectTimeout, () {
          if (!timedOut.isCompleted) {
            timedOut.complete(const _ConnectTimedOut());
          }
        });
  final slowStartTimer = onSlowStart == null
      ? null
      : Timer(slowStartAfter, onSlowStart);
  Object connection;
  try {
    connection = await Future.any<Object>([
      server.first,
      process.exitCode.then((code) => _AppExited(code)),
      if (timer != null) timedOut.future,
      if (abort != null)
        abort.then(
          (_) => const _Aborted(),
          onError: (Object _) => const _Aborted(),
        ),
    ]);
  } catch (error) {
    // The listening socket failed, so nothing can connect any more.
    connection = _AcceptFailed(error);
  } finally {
    timer?.cancel();
    slowStartTimer?.cancel();
  }

  if (connection is! Socket) {
    if (connection is _AppExited) {
      // The exit can be observed before its output is read. Let the app's last
      // lines (usually why it stopped) reach onLog before the forwarders are
      // cancelled, bounded because a descendant may hold the pipes open.
      await Future.wait(
        forwarded,
      ).timeout(killGrace, onTimeout: () => const <void>[]);
    }
    await outSub.cancel();
    await errSub.cancel();
    // SIGTERM→grace→SIGKILL and, crucially, AWAIT exitCode — a bare fire-and-
    // forget kill never reaps the child, leaking a process handle per failed
    // spawn (a host that retries flaky apps accumulates them). For _AppExited the
    // process is already gone, so this resolves instantly.
    await killProcess();
    await removeSocket();
    const connects =
        'A Fleury app connects through FLEURY_HANDLE when it calls '
        'runApp(...)';
    final reason = switch (connection) {
      _AppExited(:final code) =>
        'the app exited (code $code) before connecting. $connects; its output '
            'may say why it stopped.',
      _Aborted() => 'aborted before the app connected.',
      _AcceptFailed(:final error) =>
        "could not accept the app's connection: $error",
      _ =>
        'the app did not connect within ${_describe(connectTimeout!)}. '
            '$connects, so a command that never calls it, or that clears the '
            'environment, cannot connect. A `dart run` command compiles the '
            'app first, which takes longer on a busy machine.',
    };
    throw FleurySpawnException(
      'Failed to attach to `${command.join(' ')}`: $reason',
    );
  }

  // Connected: stop accepting, so the host only ever reads this one socket. A
  // later connect is refused; one already queued in the backlog may complete at
  // the socket layer but is never accept()ed or read, so it injects nothing and
  // its writes eventually fail. The accepted socket lives on.
  await closeServer();

  // Cache the teardown future so concurrent (or repeat) dispose() calls all await
  // the SAME run and observe the real exit code — not a stale sentinel from a
  // second call that raced in while the first was still inside killProcess().
  Future<int>? disposing;
  Future<int> dispose() {
    return disposing ??= () async {
      await outSub.cancel();
      await errSub.cancel();
      final code = await killProcess();
      await removeSocket();
      return code;
    }();
  }

  return SpawnedFleuryApp._(connection, process, dispose);
}