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,
})

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 before the app connects, abandons the spawn (tears the process + socket down and throws) — e.g. fleury serve passes the browser-closed signal so a vanished browser doesn't leave an orphan app.

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,
}) 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.
  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),
    );
    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,
          onError: (Object error, StackTrace stack) {
            capture.flushPartials();
            capture.addChunk(
              'subprocess log stream failed: $error\n',
              sourceTag,
            );
          },
          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;
    }
  }

  // A cancellable timeout arm, so a successful connect doesn't leave a timer
  // armed for the rest of connectTimeout.
  final timedOut = Completer<Object>();
  final timer = Timer(connectTimeout, () {
    if (!timedOut.isCompleted) timedOut.complete(const _ConnectTimedOut());
  });
  final connection = await Future.any<Object>([
    server.first,
    process.exitCode.then((code) => _AppExited(code)),
    timedOut.future,
    if (abort != null) abort.then((_) => const _Aborted()),
  ]);
  timer.cancel();

  if (connection is! Socket) {
    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();
    final reason = switch (connection) {
      _AppExited(:final code) => 'app exited (code $code) before connecting',
      _Aborted() => 'aborted before the app connected',
      _ => 'app did not connect within ${connectTimeout.inSeconds}s',
    };
    throw FleurySpawnException(
      'Failed to attach to `${command.join(' ')}`: $reason. '
      'Make sure it calls runApp(...) so it auto-discovers FLEURY_HANDLE.',
    );
  }

  // 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);
}