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:
2026-04-22 09:01:01 +02:00
co-authored by Claude
parent c94ad2c05b
commit edd8a20e0d
14 changed files with 973 additions and 7 deletions
+22
View File
@@ -18,6 +18,28 @@ heading, and (b) bumping `project.yaml` `version:` in the same commit.
### Added
- `PtySession` in the Dart core (`lib/src/pty/`) — spawns a child
under a PTY via the `ptyc` supporter tool, receives the master fd
over `SCM_RIGHTS`, and exposes a byte stream, write, resize, and
kill. A background isolate loops on blocking `read(fd)` and posts
chunks to the main isolate. `close()` sends SIGTERM to the child
so the PTY's EOF wakes the isolate cleanly, then falls through to
SIGKILL + fd close + isolate kill as a safety net. Child env is
built via `mergePtyEnv()` which stamps clide's true-colour defaults
(`TERM=xterm-256color`, `COLORTERM=truecolor`, `CLICOLOR_FORCE=1`).
Test coverage: echo round-trip, cat write/readback, env stamping
verification, idempotent close.
- `ffi: 2.1.3` as a runtime dependency on the Dart core — justified
in `pubspec.yaml` + documented in `licenses.yaml` per D-042. Used
by `lib/src/pty/ffi/` for `socketpair`, `recvmsg` with `SCM_RIGHTS`,
`read`/`write` on raw fds, and `ioctl(TIOCSWINSZ)`.
- `ci/test_core.sh` + `make test-core` — runs the Flutter-free core
Dart tests (`test/`) under a 120s hard timeout with process-group
cleanup. Wired into `push-check` ahead of the app test suite so a
hung PTY test can't block the pre-push gate.
- Josefin Sans bundled as `app/assets/fonts/josefin_sans/` as the
application UI face — variable-font pair (upright + italic, weight
range 100-700), OFL-licensed. Declared as the `JosefinSans` family
+6 -2
View File
@@ -73,6 +73,10 @@ endif
test: ## Fast: analyze + format + unit + widget + golden (<60s).
ci/test.sh
.PHONY: test-core
test-core: ## Flutter-free core tests (IPC, daemon, PTY) with hard timeout.
ci/test_core.sh
.PHONY: test-a11y
test-a11y: ## A11y contract (semantic coverage + keyboard + contrast + i18n).
ci/test_a11y.sh
@@ -86,7 +90,7 @@ test-e2e: build ## Daemon subprocess + web WASM Playwright smoke.
ci/test_e2e.sh
.PHONY: test-all
test-all: test test-a11y test-integration test-e2e ## Everything, sequentially.
test-all: test-core test test-a11y test-integration test-e2e ## Everything, sequentially.
.PHONY: coverage
coverage: ## flutter test --coverage + lcov summary.
@@ -173,7 +177,7 @@ decisions-validate: ## Parser dry-run over decisions/*.md (cheap pre-push gate).
tools/scripts/plan decisions validate
.PHONY: push-check
push-check: decisions-validate test test-a11y ## Pre-push gate: decisions + fast unit + widget + golden + a11y (<90s).
push-check: decisions-validate test-core test test-a11y ## Pre-push gate: decisions + core + fast unit + widget + golden + a11y (<90s).
.PHONY: hooks
hooks: ## Install the repo's git hooks (points core.hooksPath at .githooks/).
+12 -2
View File
@@ -72,8 +72,18 @@ dependencies:
homepage: https://pub.dev/packages/yaml
license: MIT
purpose: >-
YAML parser for theme files and extension manifests. The one
justified exception to prefer-zero-deps; Dart-team maintained.
YAML parser for theme files and extension manifests. Justified
exception to prefer-zero-deps; Dart-team maintained.
- name: ffi
kind: dart-package
version: "2.1.3"
homepage: https://pub.dev/packages/ffi
license: BSD-3-Clause
purpose: >-
Allocator (`calloc`) and type helpers on top of dart:ffi. Used by
the core PTY wrapper for libc bindings (socketpair, recvmsg with
SCM_RIGHTS, ioctl). Dart-team maintained; zero transitive deps.
# Build-time-only dependencies — test runners, mocks, lints. Tracked
# here for audit completeness; NOT rendered in the About screen.
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env bash
# ci/test_core.sh — run the Flutter-free core Dart tests.
#
# Covers `test/` at the repo root (IPC, daemon, PTY). Wraps `dart test`
# in a hard timeout + process-group kill so a hanging test (typically
# one holding a native fd open) can't wedge CI or pre-push.
#
# Rationale: D-030 makes tests client-side only; a hang here is always
# local — either a real bug or a bad test. Either way we'd rather fail
# loudly at 120s than block a pre-push indefinitely.
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
ROOT="$(cd "$HERE/.." && pwd)"
cd "$ROOT"
if ! command -v dart >/dev/null; then
echo "test-core: dart not on PATH; is Flutter installed?" >&2
exit 2
fi
if ! command -v ptyc >/dev/null && [[ ! -x "ptyc/bin/ptyc" ]]; then
echo "test-core: building ptyc (required by PTY tests)"
make -C ptyc >/dev/null
fi
# Hard timeout (seconds). The PTY tests should finish in <5s; IPC/daemon
# tests are faster still. 120s is generous for CI warmup, tiny for a
# hang.
TIMEOUT_SECONDS=${TIMEOUT_SECONDS:-120}
# Run dart test in its own process group so we can kill descendants on
# timeout. `setsid` starts a new session; `timeout --kill-after` SIGKILLs
# after SIGTERM if the test ignores it.
echo "test-core: dart test test/ (timeout ${TIMEOUT_SECONDS}s)"
if ! timeout --kill-after=5s "${TIMEOUT_SECONDS}s" \
setsid --wait dart test test/ ; then
rc=$?
if [[ $rc -eq 124 ]]; then
echo "test-core: TIMEOUT — killing descendants" >&2
pkill -9 -f "dart test test/" 2>/dev/null || true
pkill -9 -f "ptyc" 2>/dev/null || true
exit 1
fi
exit $rc
fi
echo "test-core: ok"
+4 -2
View File
@@ -4,8 +4,8 @@
/// under `app/` (which depends on this package via `path: ../`).
///
/// See:
/// * docs/ADRs/0005-dart-core-ptyc-peer.md — layout + language rationale.
/// * docs/ADRs/0006-cli-and-event-surface.md — CLI + event contract.
/// * `decisions/architecture.md` `D-005` — layout + language rationale.
/// * `decisions/architecture.md` `D-006` — CLI + event contract.
library;
export 'src/daemon/dispatcher.dart';
@@ -13,6 +13,8 @@ export 'src/ipc/envelope.dart';
export 'src/ipc/paths.dart';
export 'src/ipc/schema_v1.dart';
export 'src/ipc/server.dart';
export 'src/pty/errors.dart' show PtyException;
export 'src/pty/pty.dart';
/// Build-time-stamped version string.
///
+35
View File
@@ -0,0 +1,35 @@
/// Default environment for PTY-spawned children.
///
/// `xterm.dart` on the UI side + most shells + tmux + Claude CLI all
/// understand the 24-bit-colour triplet `TERM=xterm-256color` +
/// `COLORTERM=truecolor`. Without `COLORTERM` most apps fall back to
/// the 256-colour palette and the terminal looks washed out even though
/// the renderer can do true colour.
library;
/// 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.
const Map<String, String> clidePtyEnvDefaults = {
'TERM': 'xterm-256color',
'COLORTERM': 'truecolor',
// Encourages 24-bit emission from tooling that checks this:
'CLICOLOR_FORCE': '1',
// tmux inherits these when clide spawns tmux; safe to propagate.
'LANG': 'en_US.UTF-8',
'LC_ALL': 'en_US.UTF-8',
};
/// Merge [base] onto the process environment; clide defaults override
/// user env where they overlap. Explicit [overrides] win over both.
Map<String, String> mergePtyEnv({
required Map<String, String> processEnv,
Map<String, String>? overrides,
}) {
return {
...processEnv,
...clidePtyEnvDefaults,
if (overrides != null) ...overrides,
};
}
+23
View File
@@ -0,0 +1,23 @@
/// Error type for the PTY subsystem. Flutter-free — `dart:io`'s
/// `OSError` + `ProcessException` don't quite fit (we're a mix of
/// syscall-level and subprocess-level failures), and we can't pull
/// `PlatformException` from `package:flutter/services.dart` since the
/// 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`.
class PtyException implements Exception {
const PtyException(this.op, this.message, {this.errno});
final String op;
final String message;
final int? errno;
@override
String toString() {
final suffix = errno != null ? ' (errno=$errno)' : '';
return 'PtyException($op): $message$suffix';
}
}
+240
View File
@@ -0,0 +1,240 @@
/// 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.
///
/// Linux + macOS only for now. Windows is covered by platform checks
/// higher up; when Windows support lands it'll need a parallel binding
/// set against the Win32 API (named pipes instead of unix sockets).
library;
import 'dart:ffi' as ffi;
import 'package:ffi/ffi.dart' as pkg_ffi;
// ---------------------------------------------------------------------------
// Constants (POSIX / Linux)
// ---------------------------------------------------------------------------
const int afUnix = 1;
const int sockStream = 1;
const int solSocket = 1; // Linux; macOS = 0xffff
const int scmRights = 1;
const int fIoNonblock = 0x800; // O_NONBLOCK — 04000 octal
const int fGetFl = 3;
const int fSetFl = 4;
const int tiocswinsz = 0x5414; // Linux x86_64; macOS differs
// ---------------------------------------------------------------------------
// Typedefs
// ---------------------------------------------------------------------------
typedef _SocketpairC = ffi.Int32 Function(
ffi.Int32 domain,
ffi.Int32 type,
ffi.Int32 protocol,
ffi.Pointer<ffi.Int32> sv,
);
typedef _SocketpairD = int Function(
int domain,
int type,
int protocol,
ffi.Pointer<ffi.Int32> sv,
);
typedef _RecvmsgC = ffi.IntPtr Function(
ffi.Int32 sockfd,
ffi.Pointer<Msghdr> msg,
ffi.Int32 flags,
);
typedef _RecvmsgD = int Function(
int sockfd,
ffi.Pointer<Msghdr> msg,
int flags,
);
typedef _ReadC = ffi.IntPtr Function(
ffi.Int32 fd,
ffi.Pointer<ffi.Uint8> buf,
ffi.IntPtr count,
);
typedef _ReadD = int Function(
int fd,
ffi.Pointer<ffi.Uint8> buf,
int count,
);
typedef _WriteC = ffi.IntPtr Function(
ffi.Int32 fd,
ffi.Pointer<ffi.Uint8> buf,
ffi.IntPtr count,
);
typedef _WriteD = int Function(
int fd,
ffi.Pointer<ffi.Uint8> buf,
int count,
);
typedef _CloseC = ffi.Int32 Function(ffi.Int32 fd);
typedef _CloseD = int Function(int fd);
typedef _IoctlPtrC = ffi.Int32 Function(
ffi.Int32 fd,
ffi.UnsignedLong request,
ffi.Pointer<Winsize> argp,
);
typedef _IoctlPtrD = int Function(
int fd,
int request,
ffi.Pointer<Winsize> argp,
);
typedef _FcntlIntC = ffi.Int32 Function(
ffi.Int32 fd,
ffi.Int32 cmd,
ffi.Int32 arg,
);
typedef _FcntlIntD = int Function(int fd, int cmd, int arg);
typedef _ErrnoLocationC = ffi.Pointer<ffi.Int32> Function();
typedef _ErrnoLocationD = ffi.Pointer<ffi.Int32> Function();
// ---------------------------------------------------------------------------
// Native structs
// ---------------------------------------------------------------------------
/// POSIX `struct iovec`.
final class Iovec extends ffi.Struct {
external ffi.Pointer<ffi.Uint8> iov_base;
@ffi.IntPtr()
external int iov_len;
}
/// POSIX `struct msghdr`. Field layout matches Linux/glibc; macOS is
/// byte-compatible here.
final class Msghdr extends ffi.Struct {
external ffi.Pointer<ffi.Void> msg_name;
@ffi.Uint32()
external int msg_namelen;
external ffi.Pointer<Iovec> msg_iov;
@ffi.IntPtr()
external int msg_iovlen;
external ffi.Pointer<ffi.Void> msg_control;
@ffi.IntPtr()
external int msg_controllen;
@ffi.Int32()
external int msg_flags;
}
/// POSIX `struct cmsghdr` prefix. We treat the rest of the control
/// buffer as a raw byte region and compute offsets by hand.
final class Cmsghdr extends ffi.Struct {
@ffi.IntPtr()
external int cmsg_len;
@ffi.Int32()
external int cmsg_level;
@ffi.Int32()
external int cmsg_type;
}
/// POSIX `struct winsize` for `TIOCSWINSZ`.
final class Winsize extends ffi.Struct {
@ffi.Uint16()
external int ws_row;
@ffi.Uint16()
external int ws_col;
@ffi.Uint16()
external int ws_xpixel;
@ffi.Uint16()
external int ws_ypixel;
}
// ---------------------------------------------------------------------------
// Library handle + lazy-resolved function pointers
// ---------------------------------------------------------------------------
final ffi.DynamicLibrary _libc = _openLibc();
ffi.DynamicLibrary _openLibc() {
// `DynamicLibrary.process()` resolves against symbols already linked
// into the host process, which covers both Linux (libc symbols are
// always available via ld.so) and macOS.
return ffi.DynamicLibrary.process();
}
final _SocketpairD socketpair =
_libc.lookupFunction<_SocketpairC, _SocketpairD>('socketpair');
final _RecvmsgD recvmsg =
_libc.lookupFunction<_RecvmsgC, _RecvmsgD>('recvmsg');
final _ReadD read = _libc.lookupFunction<_ReadC, _ReadD>('read');
final _WriteD write = _libc.lookupFunction<_WriteC, _WriteD>('write');
final _CloseD close = _libc.lookupFunction<_CloseC, _CloseD>('close');
final _IoctlPtrD ioctlWinsize =
_libc.lookupFunction<_IoctlPtrC, _IoctlPtrD>('ioctl');
final _FcntlIntD fcntlInt =
_libc.lookupFunction<_FcntlIntC, _FcntlIntD>('fcntl');
/// Resolve `errno` through the platform-appropriate thread-local
/// accessor. glibc exposes `__errno_location`, musl the same, macOS
/// uses `__error`.
int get errno {
try {
final fn = _libc.lookupFunction<_ErrnoLocationC, _ErrnoLocationD>(
'__errno_location',
);
return fn().value;
} on ArgumentError {
// Fall through to macOS-style.
}
final fn = _libc.lookupFunction<_ErrnoLocationC, _ErrnoLocationD>(
'__error',
);
return fn().value;
}
// ---------------------------------------------------------------------------
// Convenience — scoped allocations
// ---------------------------------------------------------------------------
/// Allocate a typed native block, run [action], free. Frees even if
/// [action] throws.
T withBuffer<T>(int bytes, T Function(ffi.Pointer<ffi.Uint8>) action) {
final p = pkg_ffi.calloc<ffi.Uint8>(bytes);
try {
return action(p);
} finally {
pkg_ffi.calloc.free(p);
}
}
/// Set [fd] non-blocking. Returns whether the flag was changed.
bool setNonBlocking(int fd) {
final flags = fcntlInt(fd, fGetFl, 0);
if (flags < 0) return false;
if ((flags & fIoNonblock) != 0) return false;
fcntlInt(fd, fSetFl, flags | fIoNonblock);
return true;
}
/// Apply `TIOCSWINSZ` to the master PTY fd.
int setWinsize(int fd, int cols, int rows) {
final ws = pkg_ffi.calloc<Winsize>();
try {
ws.ref.ws_col = cols;
ws.ref.ws_row = rows;
return ioctlWinsize(fd, tiocswinsz, ws);
} finally {
pkg_ffi.calloc.free(ws);
}
}
+84
View File
@@ -0,0 +1,84 @@
/// 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 '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>();
final msg = pkg_ffi.calloc<libc.Msghdr>();
try {
iov.ref.iov_base = payload;
iov.ref.iov_len = payloadLen;
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;
int received;
while (true) {
received = libc.recvmsg(socketFd, msg, 0);
if (received >= 0) break;
final err = libc.errno;
if (err == 4 /* EINTR */) continue;
throw PtyException('recvmsg', 'recvmsg failed', errno: err);
}
if (received == 0 || msg.ref.msg_controllen < 16) {
throw const PtyException(
'recvmsg',
'peer closed without sending ancillary data',
);
}
// Parse the first cmsghdr out of the control buffer. We assume a
// single SCM_RIGHTS cmsg with one int of payload — that's what
// `ptyc` sends and all we ever ask for.
final hdr = control.cast<libc.Cmsghdr>().ref;
if (hdr.cmsg_level != libc.solSocket || hdr.cmsg_type != libc.scmRights) {
throw PtyException(
'recvmsg',
'unexpected cmsg level=${hdr.cmsg_level} type=${hdr.cmsg_type}',
);
}
// CMSG_DATA starts at the first aligned boundary after the cmsghdr.
// On Linux/glibc that's sizeof(cmsghdr) == 16, which is 8-byte
// aligned already. We rely on that layout.
final dataOffset = ffi.sizeOf<libc.Cmsghdr>();
final fdPtr = (control + dataOffset).cast<ffi.Int32>();
return fdPtr.value;
} finally {
pkg_ffi.calloc.free(msg);
pkg_ffi.calloc.free(iov);
pkg_ffi.calloc.free(control);
pkg_ffi.calloc.free(payload);
}
}
+7
View File
@@ -0,0 +1,7 @@
/// PTY subsystem — spawn child processes under a PTY via `ptyc`,
/// expose their master fd as a byte stream. Desktop IDE's pane model
/// (terminal / Claude / future tmux wrappers) rides on this.
library;
export 'env.dart' show clidePtyEnvDefaults, mergePtyEnv;
export 'session.dart' show PtySession;
+362
View File
@@ -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);
}
}
+8
View File
@@ -81,6 +81,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.7"
ffi:
dependency: "direct main"
description:
name: ffi
sha256: "16ed7b077ef01ad6170a3d0c57caa4a112a38d7a2ed5602e0aca9ca6f3d98da6"
url: "https://pub.dev"
source: hosted
version: "2.1.3"
file:
dependency: transitive
description:
+7 -1
View File
@@ -28,7 +28,13 @@ environment:
# "write it yourself" default (see memory:
# feedback_dart_deps_minimal_locked_cve_checked.md). Exact-pin; never
# carets.
dependencies: {}
dependencies:
# Dart-team-maintained allocator (`calloc`) + type helpers on top of
# dart:ffi. Saves ~50 LOC of malloc/free boilerplate for the PTY
# wrapper's libc bindings (socketpair, recvmsg with SCM_RIGHTS,
# ioctl). Tiny, zero transitive deps. Listed in
# app/assets/licenses.yaml per D-042.
ffi: 2.1.3
dev_dependencies:
lints: 5.0.0
+114
View File
@@ -0,0 +1,114 @@
/// `PtySession` smoke tests.
///
/// Exercises the real `ptyc` binary end-to-end: socketpair → spawn →
/// SCM_RIGHTS fd receive → child output through the reader isolate.
/// Linux + macOS only; skipped elsewhere.
library;
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:clide/clide.dart';
import 'package:test/test.dart';
void main() {
if (!Platform.isLinux && !Platform.isMacOS) {
return; // POSIX-only wrapper for now.
}
final ptycPath = _resolvePtyc();
group('PtySession', () {
test('spawns /bin/echo and reads its output', () async {
final s = await PtySession.spawn(
argv: const ['/bin/echo', 'hello-pty'],
ptycPath: ptycPath,
);
addTearDown(s.close);
final buf = StringBuffer();
final sub = s.output.listen((bytes) => buf.write(utf8.decode(bytes)));
try {
// echo exits quickly; give the reader up to 2s to see its
// output before we assert.
await Future<void>.delayed(const Duration(milliseconds: 500));
for (var i = 0; i < 20 && !buf.toString().contains('hello-pty'); i++) {
await Future<void>.delayed(const Duration(milliseconds: 100));
}
} finally {
await sub.cancel();
}
expect(buf.toString(), contains('hello-pty'));
expect(s.pid, greaterThan(0));
});
test('write round-trips through /bin/cat', () async {
final s = await PtySession.spawn(
argv: const ['/bin/cat'],
ptycPath: ptycPath,
);
addTearDown(s.close);
final got = Completer<String>();
final buf = StringBuffer();
s.output.listen((bytes) {
buf.write(utf8.decode(bytes));
if (buf.toString().contains('echo-me')) {
if (!got.isCompleted) got.complete(buf.toString());
}
});
// Give the PTY a moment to be ready.
await Future<void>.delayed(const Duration(milliseconds: 100));
s.write(utf8.encode('echo-me\n'));
final out = await got.future.timeout(const Duration(seconds: 3));
expect(out, contains('echo-me'));
});
test('COLORTERM truecolor propagates to the child', () async {
// `/usr/bin/env` prints the child's environment. We should see
// COLORTERM=truecolor because clidePtyEnvDefaults sets it.
final s = await PtySession.spawn(
argv: const ['/usr/bin/env'],
ptycPath: ptycPath,
);
addTearDown(s.close);
final buf = StringBuffer();
final sub = s.output.listen((bytes) => buf.write(utf8.decode(bytes)));
try {
for (var i = 0; i < 20; i++) {
if (buf.toString().contains('COLORTERM=truecolor')) break;
await Future<void>.delayed(const Duration(milliseconds: 100));
}
} finally {
await sub.cancel();
}
expect(buf.toString(), contains('COLORTERM=truecolor'));
expect(buf.toString(), contains('TERM=xterm-256color'));
});
test('close is idempotent and stops the stream', () async {
final s = await PtySession.spawn(
argv: const ['/bin/cat'],
ptycPath: ptycPath,
);
expect(s.isClosed, isFalse);
await s.close();
expect(s.isClosed, isTrue);
await s.close(); // second call should not throw
});
});
}
/// Locate the `ptyc` binary relative to the repo root, falling back to
/// PATH. Lets tests run in fresh clones before anyone's touched PATH.
String _resolvePtyc() {
final devPath = File('ptyc/bin/ptyc');
if (devPath.existsSync()) return devPath.absolute.path;
return 'ptyc';
}