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:
@@ -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
|
||||
|
||||
@@ -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/).
|
||||
|
||||
@@ -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.
|
||||
|
||||
Executable
+49
@@ -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
@@ -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.
|
||||
///
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
Reference in New Issue
Block a user