spawnFleuryApp function
- required List<
String> command, - String? socketPath,
- Duration? connectTimeout = const Duration(seconds: 20),
- Duration killGrace = const Duration(seconds: 2),
- Map<
String, String> ? environment, - void onLog()?,
- Future<
void> ? abort, - Duration slowStartAfter = const Duration(seconds: 10),
- 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);
}