remove dissolved daemon, retire ptyc, fix golden cross-platform

Complete three overdue cleanups discovered during macOS health check:

D-56 daemon dissolution: delete bin/clide.dart, DaemonServer,
and orphaned tests (test/cli/, subprocess_test, in_process_test).
Update stale "clide --daemon" references in i18n catalogs, error
messages, editor_commands, CI scripts, and decision records.

ptyc retirement: delete ptyc/ source tree, PtySession, scm_rights.
Remove from Toolchain resolution, ToolCheck gate, backend
serialization, testmode harness, Makefile, CI, and sandbox
entitlements. PTY spawning uses NativePty (Dart FFI forkpty) since
the terminal was absorbed in-tree. D-5 amended.

Golden tests: wire the existing but never-applied clideGoldenConfig
via flutter_test_config.dart. Disable CI goldens (Skia anti-aliasing
differs between macOS/Linux even with Ahem). Keep platform-keyed
goldens only — goldens/linux/ and goldens/macos/ each run on their
own OS.

Test suite: 826 pass, 0 fail on macOS (was 829 pass, 11 fail).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jeroen Schweitzer
2026-05-07 18:40:01 +02:00
co-authored by Claude Opus 4.6
parent 6b7290dc42
commit a6eca2561b
51 changed files with 265 additions and 2719 deletions
+1 -1
View File
@@ -6,7 +6,7 @@
/// editor.read editor.set-selection editor.set-content
///
/// Single-word CLI shortcuts (`clide open`, `clide active`, …) map
/// one-to-one onto these in `bin/clide.dart`.
/// one-to-one onto these via the IPC dispatch layer.
library;
import 'dart:io' show FileSystemException;
-144
View File
@@ -1,144 +0,0 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:clide/src/ipc/envelope.dart';
typedef RequestDispatcher = Future<IpcResponse> Function(IpcRequest request);
/// Default per-request timeout. A handler that doesn't return within
/// this window gets a `tool_error` response so the connection's read
/// pipeline isn't blocked indefinitely. Long-running commands (git
/// pull/push, large pql queries) can override per-command later.
const Duration _kDefaultRequestTimeout = Duration(seconds: 60);
/// Unix-socket JSON-lines server. Each connection is an independent
/// bidirectional line-framed stream: client writes requests, daemon
/// writes responses + events on the same socket.
class DaemonServer {
DaemonServer({
required this.socketPath,
required this.dispatch,
Duration requestTimeout = _kDefaultRequestTimeout,
}) : _requestTimeout = requestTimeout;
final String socketPath;
final RequestDispatcher dispatch;
final Duration _requestTimeout;
ServerSocket? _server;
final Set<Socket> _clients = {};
/// Broadcast [event] to every currently-connected client. Sockets
/// that error on write are dropped — the client's read side will
/// notice the close. Errors are logged so silent event loss is
/// debuggable.
void broadcast(IpcEvent event) {
final line = event.encode();
for (final c in List<Socket>.from(_clients)) {
try {
c.writeln(line);
} catch (e) {
stderr.writeln('clide daemon: broadcast write failed (${event.subsystem}.${event.kind}): $e');
_clients.remove(c);
}
}
}
Future<void> start() async {
final addr = InternetAddress(socketPath, type: InternetAddressType.unix);
try {
_server = await ServerSocket.bind(addr, 0);
} on SocketException {
// Either a stale socket from a prior crash, or a live daemon.
// Probe by trying to connect — if a live peer answers, refuse
// to start so we don't rip its socket out.
try {
final probe = await Socket.connect(addr, 0).timeout(const Duration(milliseconds: 200));
await probe.close();
throw StateError('clide daemon already running at $socketPath');
} on TimeoutException {
// No one answered — proceed to unlink and rebind.
} on SocketException {
// No one listening — proceed to unlink and rebind.
}
try {
await File(socketPath).delete();
} catch (_) {}
_server = await ServerSocket.bind(addr, 0);
}
stderr.writeln('clide daemon listening on $socketPath');
_server!.listen(_handleClient, onError: (e) {
stderr.writeln('clide daemon accept error: $e');
});
}
Future<void> stop() async {
for (final c in List<Socket>.from(_clients)) {
await c.close();
}
_clients.clear();
await _server?.close();
_server = null;
try {
await File(socketPath).delete();
} catch (_) {}
}
void _handleClient(Socket client) {
_clients.add(client);
client.cast<List<int>>().transform(utf8.decoder).transform(const LineSplitter()).listen(
(line) => _handleLine(client, line),
onDone: () => _clients.remove(client),
onError: (Object e) {
stderr.writeln('clide daemon client error: $e');
_clients.remove(client);
},
cancelOnError: true,
);
}
Future<void> _handleLine(Socket client, String line) async {
if (line.isEmpty) return;
IpcMessage? msg;
try {
msg = IpcMessage.decode(line);
} on FormatException catch (e) {
stderr.writeln('clide daemon: bad line from client: $e');
return;
}
if (msg is! IpcRequest) return;
IpcResponse resp;
try {
resp = await dispatch(msg).timeout(_requestTimeout);
} on TimeoutException {
stderr.writeln('clide daemon: dispatch timeout for ${msg.cmd} (${_requestTimeout.inSeconds}s)');
resp = IpcResponse.err(
id: msg.id,
error: IpcError(
code: 2,
kind: 'tool_error',
message: 'request timed out after ${_requestTimeout.inSeconds}s: ${msg.cmd}',
),
);
} catch (e, st) {
stderr.writeln('clide daemon: dispatch error for ${msg.cmd}: $e\n$st');
resp = IpcResponse.err(
id: msg.id,
error: IpcError(
code: 2,
kind: 'tool_error',
message: 'dispatch failed: $e',
),
);
}
try {
client.writeln(resp.encode());
} catch (e) {
// Client disconnected mid-dispatch — drop it so future events
// don't try to write to a dead socket.
stderr.writeln('clide daemon: response write failed (${msg.cmd}): $e');
_clients.remove(client);
}
}
}
+2 -2
View File
@@ -2,8 +2,8 @@
/// travels cleanly into the Flutter app (which can't depend on
/// `dart:ffi`-using code for the web build).
///
/// The daemon's [PaneRegistry] keeps a parallel `PtySession` keyed on
/// [id] and mutates [isClosed] when the session exits.
/// [PaneRegistry] keeps a parallel [NativePty] keyed on [id] and
/// mutates [isClosed] when the session exits.
library;
/// Kind of a pane. Keep this enum small and explicit — each kind
+3 -7
View File
@@ -1,6 +1,6 @@
/// [PaneRegistry] — daemon-side state for all live panes.
/// [PaneRegistry] — backend-side state for all live panes.
///
/// Owns the [PtySession] per pane, generates `p_N` ids, and forwards
/// Owns the [NativePty] per pane, generates `p_N` ids, and forwards
/// pty output + lifecycle changes as IPC events via a [DaemonEventSink].
/// Pane commands (pane.spawn / list / write / resize / close) resolve
/// against this registry; extension UIs subscribe to the emitted events.
@@ -31,10 +31,6 @@ class PaneRegistry {
Pane? get(String id) => _panes[id];
/// Spawn a child under a PTY and wire its output to events.
///
/// [ptycPath] is plumbed through to [PtySession.spawn]; callers that
/// have a dev-built `ptyc/bin/ptyc` or a non-PATH install can point
/// at it explicitly.
Future<Pane> spawn({
required PaneKind kind,
required List<String> argv,
@@ -49,7 +45,7 @@ class PaneRegistry {
final arguments = argv.length > 1 ? argv.sublist(1) : const <String>[];
// Merge the caller's env on top of the process environment +
// terminal defaults, matching the old ptyc contract.
// Terminal defaults for the PTY child.
final fullEnv = <String, String>{
...Platform.environment,
'TERM': 'xterm-256color',
+4 -4
View File
@@ -37,10 +37,10 @@ String _buildExpandedPath() {
return [...missing, ...existing].join(':');
}
/// Base env clide's daemon builds for every PTY child. Callers merge
/// with the user's environment before passing to `ptyc` — a child that
/// needs user env like `HOME` / `USER` / `SHELL` still gets them; the
/// keys here override the ones the child cares about.
/// Base env clide builds for every PTY child. Callers merge with the
/// user's environment — a child that needs user env like `HOME` /
/// `USER` / `SHELL` still gets them; the keys here override the ones
/// the child cares about.
const Map<String, String> clidePtyEnvDefaults = {
'TERM': 'xterm-256color',
'COLORTERM': 'truecolor',
+3 -3
View File
@@ -5,9 +5,9 @@
/// core library stays Flutter-free per D-005.
library;
/// A PTY operation failed. [op] identifies the step (`recvmsg`,
/// `socketpair`, `ptyc`, etc.); [errno] is POSIX errno when the
/// failure came from a syscall, otherwise `null`.
/// A PTY operation failed. [op] identifies the step (`forkpty`,
/// `read`, `ioctl`, etc.); [errno] is POSIX errno when the failure
/// came from a syscall, otherwise `null`.
class PtyException implements Exception {
const PtyException(this.op, this.message, {this.errno});
+2 -4
View File
@@ -1,9 +1,7 @@
/// Raw FFI bindings to the libc functions the PTY wrapper needs.
///
/// `dart:io` covers neither `socketpair(2)`, `recvmsg(2)` with ancillary
/// data, nor read/write on arbitrary file descriptors — the three
/// things the [`ptyc`](../../../ptyc/README.md) fd-transfer protocol
/// requires. FFI is the minimum tool for the job.
/// `dart:io` doesn't expose `forkpty`, `read`/`write` on raw fds,
/// `ioctl`, or `poll` — FFI is the minimum tool for the job.
///
/// Linux + macOS only for now. Windows is covered by platform checks
/// higher up; when Windows support lands it'll need a parallel binding
-122
View File
@@ -1,122 +0,0 @@
/// Receive a single file descriptor over a unix socket via
/// `SCM_RIGHTS` ancillary data.
///
/// Pairs with `ptyc`'s `send_fd()`: the peer sends one byte of payload
/// plus the fd in cmsg; this function reads both and returns the fd.
library;
import 'dart:ffi' as ffi;
import 'dart:io' show Platform;
import 'package:ffi/ffi.dart' as pkg_ffi;
import '../errors.dart';
import 'libc.dart' as libc;
/// Blocks on [socketFd] waiting for a single-byte payload carrying a
/// fd over `SCM_RIGHTS`. Returns the received fd on success.
///
/// Throws a [PlatformException] if `recvmsg` fails or the peer sends
/// no ancillary data.
int recvFd(int socketFd) {
// Layout: one-byte payload buffer + CMSG_SPACE(sizeof(int)) control
// buffer. `CMSG_SPACE` is just `ALIGN(sizeof(cmsghdr)) + ALIGN(data)`
// — for a single int that's 16 + 4 rounded up to 8 = 24 on 64-bit,
// but we over-allocate to 32 to be safe across platforms.
const payloadLen = 1;
const controlLen = 32;
final payload = pkg_ffi.calloc<ffi.Uint8>(payloadLen);
final control = pkg_ffi.calloc<ffi.Uint8>(controlLen);
final iov = pkg_ffi.calloc<libc.Iovec>();
try {
iov.ref.iov_base = payload;
iov.ref.iov_len = payloadLen;
int received;
int msgControllen;
if (Platform.isMacOS) {
final msg = pkg_ffi.calloc<libc.MsghdrDarwin>();
try {
msg.ref.msg_name = ffi.nullptr;
msg.ref.msg_namelen = 0;
msg.ref.msg_iov = iov;
msg.ref.msg_iovlen = 1;
msg.ref.msg_control = control.cast();
msg.ref.msg_controllen = controlLen;
msg.ref.msg_flags = 0;
while (true) {
received = libc.recvmsgDarwin(socketFd, msg, 0);
if (received >= 0) break;
final err = libc.errno;
if (err == 4 /* EINTR */) continue;
throw PtyException('recvmsg', 'recvmsg failed', errno: err);
}
msgControllen = msg.ref.msg_controllen;
} finally {
pkg_ffi.calloc.free(msg);
}
} else {
final msg = pkg_ffi.calloc<libc.Msghdr>();
try {
msg.ref.msg_name = ffi.nullptr;
msg.ref.msg_namelen = 0;
msg.ref.msg_iov = iov;
msg.ref.msg_iovlen = 1;
msg.ref.msg_control = control.cast();
msg.ref.msg_controllen = controlLen;
msg.ref.msg_flags = 0;
while (true) {
received = libc.recvmsgLinux(socketFd, msg, 0);
if (received >= 0) break;
final err = libc.errno;
if (err == 4 /* EINTR */) continue;
throw PtyException('recvmsg', 'recvmsg failed', errno: err);
}
msgControllen = msg.ref.msg_controllen;
} finally {
pkg_ffi.calloc.free(msg);
}
}
if (received == 0 || msgControllen < 16) {
throw const PtyException(
'recvmsg',
'peer closed without sending ancillary data',
);
}
// Parse the first cmsghdr out of the control buffer. On macOS,
// cmsg_len is socklen_t (4 bytes); on Linux it's size_t (8 bytes).
int cmsgLevel, cmsgType, dataOffset;
if (Platform.isMacOS) {
final hdr = control.cast<libc.CmsghdrDarwin>().ref;
cmsgLevel = hdr.cmsg_level;
cmsgType = hdr.cmsg_type;
dataOffset = ffi.sizeOf<libc.CmsghdrDarwin>();
} else {
final hdr = control.cast<libc.CmsghdrLinux>().ref;
cmsgLevel = hdr.cmsg_level;
cmsgType = hdr.cmsg_type;
dataOffset = ffi.sizeOf<libc.CmsghdrLinux>();
}
if (cmsgLevel != libc.solSocket || cmsgType != libc.scmRights) {
throw PtyException(
'recvmsg',
'unexpected cmsg level=$cmsgLevel type=$cmsgType',
);
}
final fdPtr = (control + dataOffset).cast<ffi.Int32>();
return fdPtr.value;
} finally {
pkg_ffi.calloc.free(iov);
pkg_ffi.calloc.free(control);
pkg_ffi.calloc.free(payload);
}
}
+2 -5
View File
@@ -1,8 +1,7 @@
/// Native PTY via forkpty() — replaces the ptyc helper binary.
/// Native PTY via forkpty().
///
/// Uses Dart FFI to call forkpty() directly. The master fd stays
/// in-process (no socketpair, no SCM_RIGHTS). The reader isolate
/// uses poll() for clean shutdown.
/// in-process. The reader isolate uses poll() for clean shutdown.
///
/// Based on the pty-spike proof-of-concept. Platform-aware:
/// macOS: forkpty in libSystem (DynamicLibrary.process)
@@ -82,8 +81,6 @@ const _kWnohang = 1;
// -- NativePty --------------------------------------------------------------
/// A pseudo-terminal backed by forkpty() via Dart FFI.
///
/// Drop-in replacement for the old ptyc-based PtySession.
class NativePty {
final int _fd;
final int pid;
-442
View File
@@ -1,442 +0,0 @@
/// [PtySession] — high-level PTY lifecycle.
///
/// Spawns `ptyc` with the given argv/cwd/env, receives the master fd
/// via `SCM_RIGHTS`, and exposes:
///
/// - [output] — a broadcast stream of bytes read from the child.
/// - [write] — send bytes to the child's stdin.
/// - [resize] — change the child's window size.
/// - [kill] — send a signal to the child.
/// - [close] — close the master fd and stop reading.
///
/// Reading happens in a background isolate that loops on blocking
/// `read(fd)` calls and posts bytes to the main isolate via a
/// [ReceivePort]. Closing the fd from the main isolate causes `read()`
/// to return EBADF; the isolate sees that and exits.
library;
import 'dart:async';
import 'dart:convert';
import 'dart:ffi' as ffi;
import 'dart:io';
import 'dart:isolate';
import 'dart:typed_data';
import 'package:ffi/ffi.dart' as pkg_ffi;
import 'env.dart';
import 'errors.dart';
import 'ffi/libc.dart' as libc;
import 'ffi/scm_rights.dart' as scm;
class _RecvFdArgs {
const _RecvFdArgs(this.socketFd, this.sendPort);
final int socketFd;
final SendPort sendPort;
}
/// A running PTY child plus its master-fd plumbing.
class PtySession {
PtySession._({
required this.pid,
required int masterFd,
}) : _masterFd = masterFd {
_startReader();
}
/// The spawned child's PID (not ptyc's — ptyc has already exited).
final int pid;
int _masterFd;
final _outputCtrl = StreamController<Uint8List>.broadcast();
final _readerExited = Completer<void>();
Isolate? _readerIsolate;
ReceivePort? _readerPort;
/// Broadcast stream of raw bytes from the child's stdout/stderr.
Stream<Uint8List> get output => _outputCtrl.stream;
/// Whether the session is still alive.
bool get isClosed => _masterFd < 0;
/// Spawn a child under a PTY.
///
/// [argv] must be non-empty; [argv[0]] is resolved via PATH. [env]
/// is merged onto the parent process env via [mergePtyEnv] so
/// terminal children inherit `HOME` / `USER` while clide's
/// true-colour defaults still take effect.
///
/// [ptycPath] defaults to looking for `ptyc` on PATH; dev setups
/// that haven't `make install`'d the helper can point at the
/// development build under `ptyc/bin/ptyc`.
static Future<PtySession> spawn({
required List<String> argv,
String? cwd,
Map<String, String>? env,
int cols = 80,
int rows = 24,
String ptycPath = 'ptyc',
}) async {
if (argv.isEmpty) {
throw ArgumentError.value(argv, 'argv', 'must be non-empty');
}
// socketpair for the fd transfer.
final sv = pkg_ffi.calloc<ffi.Int32>(2);
int parentSock = -1;
int childSock = -1;
Process? proc;
try {
final rc = libc.socketpair(libc.afUnix, libc.sockStream, 0, sv);
if (rc < 0) {
throw PtyException('socketpair', 'socketpair failed', errno: libc.errno);
}
parentSock = sv[0];
childSock = sv[1];
// Build the JSON request for ptyc.
final req = _buildRequest(
argv: argv,
cwd: cwd,
env: mergePtyEnv(
processEnv: Platform.environment,
overrides: env,
),
cols: cols,
rows: rows,
);
// Launch ptyc. We pass childSock to it via PTYC_SOCK_FD so ptyc
// reads it from env rather than having to place it at fd 3
// specifically — Dart's Process.start doesn't give us fine
// control over child fd layout.
proc = await Process.start(
ptycPath,
const [],
environment: {
...Platform.environment,
'PTYC_SOCK_FD': childSock.toString(),
},
// Inherit the socket fd into the child. Dart exposes this via
// a private API in recent versions; until it lands we rely on
// default behaviour (Process.start doesn't close arbitrary
// fds inherited from the parent's open-fd set).
mode: ProcessStartMode.normal,
);
// Send the request and close stdin so ptyc sees EOF.
proc.stdin.add(req);
await proc.stdin.close();
// Receive the master fd over the parent side of the socketpair.
// recvFd blocks until ptyc sends — run in a child isolate so the
// calling isolate's event loop stays responsive.
final int masterFd;
try {
masterFd = await _recvFdAsync(parentSock);
} catch (_) {
proc.kill();
rethrow;
}
// Once we own masterFd, every error path below must close it
// before rethrowing. Wrap the rest of the spawn in its own
// try/catch so the cleanup is centralized.
try {
libc.setWinsize(masterFd, cols, rows);
final stdoutLine = await proc.stdout.transform(const Utf8Decoder()).transform(const LineSplitter()).first.timeout(const Duration(seconds: 5));
final pid = _extractPid(stdoutLine);
final code = await proc.exitCode;
if (code != 0) {
final stderr = await proc.stderr.transform(const Utf8Decoder()).join();
libc.close(masterFd);
throw PtyException('ptyc', 'ptyc exited with code $code: $stderr');
}
return PtySession._(pid: pid, masterFd: masterFd);
} catch (_) {
libc.close(masterFd);
rethrow;
}
} finally {
// parent keeps its own fd until the session is closed; ptyc-side
// fd is released either way (ptyc has exited by now).
if (childSock >= 0) libc.close(childSock);
if (parentSock >= 0) libc.close(parentSock);
pkg_ffi.calloc.free(sv);
}
}
/// Send bytes to the child's stdin. Loops on short writes; throws
/// [PtyException] (with errno) on failure. Returns total bytes
/// written, which equals [bytes.length] on success.
int write(List<int> bytes) {
if (isClosed) return 0;
final buf = pkg_ffi.calloc<ffi.Uint8>(bytes.length);
try {
for (var i = 0; i < bytes.length; i++) {
buf[i] = bytes[i];
}
var written = 0;
while (written < bytes.length) {
final n = libc.write(
_masterFd,
buf + written,
bytes.length - written,
);
if (n < 0) {
final err = libc.errno;
if (err == 4 /* EINTR */) continue;
throw PtyException('write', 'write to PTY failed', errno: err);
}
if (n == 0) break;
written += n;
}
return written;
} finally {
pkg_ffi.calloc.free(buf);
}
}
/// Resize the child's terminal.
void resize({required int cols, required int rows}) {
if (isClosed) return;
libc.setWinsize(_masterFd, cols, rows);
}
/// Send a signal to the child. Uses `Process.killPid` for now; a
/// future pass can deliver signals via the PTY's foreground process
/// group so Ctrl-C from the UI works naturally.
bool kill([ProcessSignal signal = ProcessSignal.sigterm]) {
return Process.killPid(pid, signal);
}
/// Close the session. Signals the child, waits briefly for the
/// reader isolate to see EOF on the master fd (natural wakeup), and
/// then closes + force-kills whatever's still around.
///
/// Ordering matters: closing the master fd alone does **not** unblock
/// a `read()` already in flight on Linux — the blocked syscall holds
/// a reference to the kernel file. Killing the child causes the PTY
/// to return EOF on master, which is the clean way to wake the
/// reader. See D-005 notes; a belt-and-braces `poll()` + self-pipe
/// wake path is possible but not worth the FFI surface at Tier 1.
Future<void> close() async {
if (isClosed) return;
final fd = _masterFd;
_masterFd = -1;
// 1. Ask the child nicely so the shell can run its exit traps.
try {
Process.killPid(pid, ProcessSignal.sigterm);
} catch (_) {
// Already gone — fine.
}
// 2. Give the reader isolate up to ~500ms to see EOF and signal
// back via its 'eof' message (set by the existing listener,
// which completes _readerExited).
await _readerExited.future.timeout(
const Duration(milliseconds: 500),
onTimeout: () {},
);
// 3. Belt and braces: SIGKILL the child, close the master, and
// force-kill the isolate regardless. Any still-pending read()
// returns on close via EIO; future reads return EBADF.
try {
Process.killPid(pid, ProcessSignal.sigkill);
} catch (_) {}
libc.close(fd);
_readerPort?.close();
_readerIsolate?.kill(priority: Isolate.immediate);
_readerPort = null;
_readerIsolate = null;
if (!_outputCtrl.isClosed) await _outputCtrl.close();
}
/// Run recvFd in a child isolate so the blocking FFI call doesn't
/// stall the calling isolate's event loop.
static Future<int> _recvFdAsync(int socketFd) async {
final port = ReceivePort();
Isolate? iso;
try {
iso = await Isolate.spawn(_recvFdEntry, _RecvFdArgs(socketFd, port.sendPort));
final result = await port.first;
if (result is int) return result;
throw PtyException('recvFd', '$result');
} finally {
iso?.kill(priority: Isolate.immediate);
port.close();
}
}
static void _recvFdEntry(_RecvFdArgs args) {
try {
final fd = scm.recvFd(args.socketFd);
args.sendPort.send(fd);
} catch (e) {
args.sendPort.send('error: $e');
}
}
// ---------------------------------------------------------------- //
void _startReader() {
final port = ReceivePort();
_readerPort = port;
port.listen((dynamic msg) {
if (msg is Uint8List) {
if (!_outputCtrl.isClosed) _outputCtrl.add(msg);
} else if (msg == 'eof') {
if (!_readerExited.isCompleted) _readerExited.complete();
}
});
Isolate.spawn<_ReaderArgs>(
_readerEntrypoint,
_ReaderArgs(fd: _masterFd, sendPort: port.sendPort),
).then(
(iso) => _readerIsolate = iso,
onError: (Object e) {
// Spawn failure leaves the session unable to ever produce
// output. Surface the error and mark the controller closed
// so consumers don't hang waiting on the stream.
if (!_outputCtrl.isClosed) {
_outputCtrl.addError(PtyException('reader-spawn', '$e'));
_outputCtrl.close();
}
if (!_readerExited.isCompleted) _readerExited.complete();
},
);
}
// -- request builder ------------------------------------------------------
static List<int> _buildRequest({
required List<String> argv,
required String? cwd,
required Map<String, String> env,
required int cols,
required int rows,
}) {
// Minimal JSON emitter — our request never contains non-ASCII,
// so we only need to escape ", \, and the standard control chars.
final sb = StringBuffer('{');
sb.write('"argv":[');
for (var i = 0; i < argv.length; i++) {
if (i > 0) sb.write(',');
sb.write(_json(argv[i]));
}
sb.write(']');
if (cwd != null) {
sb.write(',"cwd":${_json(cwd)}');
}
sb.write(',"env":{');
var first = true;
env.forEach((k, v) {
if (!first) sb.write(',');
first = false;
sb.write('${_json(k)}:${_json(v)}');
});
sb.write('}');
sb.write(',"cols":$cols,"rows":$rows');
sb.write('}');
return utf8.encode(sb.toString());
}
static String _json(String s) {
final b = StringBuffer('"');
for (var i = 0; i < s.length; i++) {
final c = s.codeUnitAt(i);
switch (c) {
case 0x22:
b.write(r'\"');
break;
case 0x5c:
b.write(r'\\');
break;
case 0x08:
b.write(r'\b');
break;
case 0x09:
b.write(r'\t');
break;
case 0x0a:
b.write(r'\n');
break;
case 0x0c:
b.write(r'\f');
break;
case 0x0d:
b.write(r'\r');
break;
default:
if (c < 0x20) {
b.write('\\u${c.toRadixString(16).padLeft(4, '0')}');
} else {
b.writeCharCode(c);
}
}
}
b.write('"');
return b.toString();
}
static int _extractPid(String json) {
// Narrow regex is enough — ptyc's success envelope is known-shape.
final m = RegExp(r'"pid"\s*:\s*(\d+)').firstMatch(json);
if (m == null) {
throw PtyException('ptyc', 'no pid in ptyc response: $json');
}
return int.parse(m.group(1)!);
}
}
// ---------------------------------------------------------------------------
// Reader isolate
// ---------------------------------------------------------------------------
class _ReaderArgs {
const _ReaderArgs({required this.fd, required this.sendPort});
final int fd;
final SendPort sendPort;
}
/// Runs in a separate isolate. Loops on blocking `read(fd)` and posts
/// each chunk back to the main isolate as a `Uint8List`. Exits on
/// EOF, close, or error.
void _readerEntrypoint(_ReaderArgs args) {
const chunk = 65536;
final buf = pkg_ffi.calloc<ffi.Uint8>(chunk);
try {
while (true) {
final n = libc.read(args.fd, buf, chunk);
if (n > 0) {
final bytes = Uint8List(n);
for (var i = 0; i < n; i++) {
bytes[i] = buf[i];
}
args.sendPort.send(bytes);
} else if (n == 0) {
// child closed pty → EOF
args.sendPort.send('eof');
return;
} else {
final err = libc.errno;
if (err == 4 /* EINTR */) continue;
// 9=EBADF (fd closed from main), 5=EIO (child exited on
// Linux). Either way, we're done.
args.sendPort.send('eof');
return;
}
}
} finally {
pkg_ffi.calloc.free(buf);
}
}