From edd8a20e0d214e852ad805a63d50fc6f873027d9 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 22 Apr 2026 09:01:01 +0200 Subject: [PATCH] add Dart PTY wrapper and test-core harness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 22 ++ Makefile | 8 +- app/assets/licenses.yaml | 14 +- ci/test_core.sh | 49 +++++ lib/clide.dart | 6 +- lib/src/pty/env.dart | 35 +++ lib/src/pty/errors.dart | 23 ++ lib/src/pty/ffi/libc.dart | 240 +++++++++++++++++++++ lib/src/pty/ffi/scm_rights.dart | 84 ++++++++ lib/src/pty/pty.dart | 7 + lib/src/pty/session.dart | 362 ++++++++++++++++++++++++++++++++ pubspec.lock | 8 + pubspec.yaml | 8 +- test/pty/session_test.dart | 114 ++++++++++ 14 files changed, 973 insertions(+), 7 deletions(-) create mode 100755 ci/test_core.sh create mode 100644 lib/src/pty/env.dart create mode 100644 lib/src/pty/errors.dart create mode 100644 lib/src/pty/ffi/libc.dart create mode 100644 lib/src/pty/ffi/scm_rights.dart create mode 100644 lib/src/pty/pty.dart create mode 100644 lib/src/pty/session.dart create mode 100644 test/pty/session_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 4639c0a3..c76bd8d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Makefile b/Makefile index 4a1b9e7f..48031dc2 100644 --- a/Makefile +++ b/Makefile @@ -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/). diff --git a/app/assets/licenses.yaml b/app/assets/licenses.yaml index 33314282..414f6f20 100644 --- a/app/assets/licenses.yaml +++ b/app/assets/licenses.yaml @@ -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. diff --git a/ci/test_core.sh b/ci/test_core.sh new file mode 100755 index 00000000..bcce2a81 --- /dev/null +++ b/ci/test_core.sh @@ -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" diff --git a/lib/clide.dart b/lib/clide.dart index eb5068ee..157d804f 100644 --- a/lib/clide.dart +++ b/lib/clide.dart @@ -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. /// diff --git a/lib/src/pty/env.dart b/lib/src/pty/env.dart new file mode 100644 index 00000000..31d7bf7d --- /dev/null +++ b/lib/src/pty/env.dart @@ -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 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 mergePtyEnv({ + required Map processEnv, + Map? overrides, +}) { + return { + ...processEnv, + ...clidePtyEnvDefaults, + if (overrides != null) ...overrides, + }; +} diff --git a/lib/src/pty/errors.dart b/lib/src/pty/errors.dart new file mode 100644 index 00000000..dde62099 --- /dev/null +++ b/lib/src/pty/errors.dart @@ -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'; + } +} diff --git a/lib/src/pty/ffi/libc.dart b/lib/src/pty/ffi/libc.dart new file mode 100644 index 00000000..f63d35a2 --- /dev/null +++ b/lib/src/pty/ffi/libc.dart @@ -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 sv, +); +typedef _SocketpairD = int Function( + int domain, + int type, + int protocol, + ffi.Pointer sv, +); + +typedef _RecvmsgC = ffi.IntPtr Function( + ffi.Int32 sockfd, + ffi.Pointer msg, + ffi.Int32 flags, +); +typedef _RecvmsgD = int Function( + int sockfd, + ffi.Pointer msg, + int flags, +); + +typedef _ReadC = ffi.IntPtr Function( + ffi.Int32 fd, + ffi.Pointer buf, + ffi.IntPtr count, +); +typedef _ReadD = int Function( + int fd, + ffi.Pointer buf, + int count, +); + +typedef _WriteC = ffi.IntPtr Function( + ffi.Int32 fd, + ffi.Pointer buf, + ffi.IntPtr count, +); +typedef _WriteD = int Function( + int fd, + ffi.Pointer 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 argp, +); +typedef _IoctlPtrD = int Function( + int fd, + int request, + ffi.Pointer 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 Function(); +typedef _ErrnoLocationD = ffi.Pointer Function(); + +// --------------------------------------------------------------------------- +// Native structs +// --------------------------------------------------------------------------- + +/// POSIX `struct iovec`. +final class Iovec extends ffi.Struct { + external ffi.Pointer 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 msg_name; + @ffi.Uint32() + external int msg_namelen; + external ffi.Pointer msg_iov; + @ffi.IntPtr() + external int msg_iovlen; + external ffi.Pointer 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(int bytes, T Function(ffi.Pointer) action) { + final p = pkg_ffi.calloc(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(); + try { + ws.ref.ws_col = cols; + ws.ref.ws_row = rows; + return ioctlWinsize(fd, tiocswinsz, ws); + } finally { + pkg_ffi.calloc.free(ws); + } +} diff --git a/lib/src/pty/ffi/scm_rights.dart b/lib/src/pty/ffi/scm_rights.dart new file mode 100644 index 00000000..5499e6ba --- /dev/null +++ b/lib/src/pty/ffi/scm_rights.dart @@ -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(payloadLen); + final control = pkg_ffi.calloc(controlLen); + final iov = pkg_ffi.calloc(); + final msg = pkg_ffi.calloc(); + + 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().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(); + final fdPtr = (control + dataOffset).cast(); + return fdPtr.value; + } finally { + pkg_ffi.calloc.free(msg); + pkg_ffi.calloc.free(iov); + pkg_ffi.calloc.free(control); + pkg_ffi.calloc.free(payload); + } +} diff --git a/lib/src/pty/pty.dart b/lib/src/pty/pty.dart new file mode 100644 index 00000000..9739041d --- /dev/null +++ b/lib/src/pty/pty.dart @@ -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; diff --git a/lib/src/pty/session.dart b/lib/src/pty/session.dart new file mode 100644 index 00000000..89f7905a --- /dev/null +++ b/lib/src/pty/session.dart @@ -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.broadcast(); + final _readerExited = Completer(); + Isolate? _readerIsolate; + ReceivePort? _readerPort; + + /// Broadcast stream of raw bytes from the child's stdout/stderr. + Stream 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 spawn({ + required List argv, + String? cwd, + Map? 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(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 bytes) { + if (isClosed) return 0; + final buf = pkg_ffi.calloc(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 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 _buildRequest({ + required List argv, + required String? cwd, + required Map 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(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); + } +} + diff --git a/pubspec.lock b/pubspec.lock index 07af8885..b2caedc4 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -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: diff --git a/pubspec.yaml b/pubspec.yaml index d1032fd2..78f66a59 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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 diff --git a/test/pty/session_test.dart b/test/pty/session_test.dart new file mode 100644 index 00000000..1efa0b96 --- /dev/null +++ b/test/pty/session_test.dart @@ -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.delayed(const Duration(milliseconds: 500)); + for (var i = 0; i < 20 && !buf.toString().contains('hello-pty'); i++) { + await Future.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(); + 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.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.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'; +}