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>
363 lines
11 KiB
Dart
363 lines
11 KiB
Dart
/// [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);
|
|
}
|
|
}
|
|
|