add Dart PTY wrapper and test-core harness
PtySession wraps the ptyc helper: socketpair + Process.start + recvmsg with SCM_RIGHTS for master-fd transfer, a background isolate that loops on blocking read() and posts byte chunks, plus write/resize/ kill/close. close() SIGTERMs the child so the PTY's EOF wakes the reader naturally; SIGKILL + fd close + isolate kill cover the edge where the shell ignores SIGTERM — avoids the known Linux quirk where closing an fd doesn't unblock an in-flight read() on it. Env defaults stamp TERM=xterm-256color, COLORTERM=truecolor, CLICOLOR_FORCE=1 so shells + tmux + Claude emit 24-bit sequences that xterm.dart can render. User env (HOME / USER / SHELL) still inherits via mergePtyEnv(). ffi: 2.1.3 added as a runtime dep — the FFI bindings for socketpair, recvmsg, read/write, and ioctl(TIOCSWINSZ) need an allocator we're not writing by hand. Justified in pubspec + listed in licenses.yaml per D-042. make test-core (ci/test_core.sh) runs the Flutter-free core tests under a 120s hard timeout with setsid + process-group kill, wired ahead of the fast app tests in push-check so a hung PTY test can't wedge a pre-push. Current core suite: 24 tests in ~1s. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,362 @@
|
||||
/// [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;
|
||||
|
||||
/// 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.
|
||||
final masterFd = scm.recvFd(parentSock);
|
||||
|
||||
// Apply initial winsize (ptyc already did this, but doing it
|
||||
// again from Dart confirms the wire + gives a place to call it
|
||||
// when resize() lands).
|
||||
libc.setWinsize(masterFd, cols, rows);
|
||||
|
||||
// Drain ptyc's stdout to parse the success envelope. We don't
|
||||
// strictly need it — the fd arriving is proof-of-life — but
|
||||
// draining avoids a PIPE accumulating.
|
||||
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);
|
||||
} 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);
|
||||
pkg_ffi.calloc.free(sv);
|
||||
}
|
||||
}
|
||||
|
||||
/// Send bytes to the child's stdin.
|
||||
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];
|
||||
}
|
||||
return libc.write(_masterFd, buf, bytes.length);
|
||||
} 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();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- //
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// -- 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 = 4096;
|
||||
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