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:
co-authored by
Claude Opus 4.6
parent
6b7290dc42
commit
a6eca2561b
@@ -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;
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user