spawnFleuryApp function
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);
}