add Dart PTY wrapper and test-core harness
PtySession wraps the ptyc helper: socketpair + Process.start + recvmsg with SCM_RIGHTS for master-fd transfer, a background isolate that loops on blocking read() and posts byte chunks, plus write/resize/ kill/close. close() SIGTERMs the child so the PTY's EOF wakes the reader naturally; SIGKILL + fd close + isolate kill cover the edge where the shell ignores SIGTERM — avoids the known Linux quirk where closing an fd doesn't unblock an in-flight read() on it. Env defaults stamp TERM=xterm-256color, COLORTERM=truecolor, CLICOLOR_FORCE=1 so shells + tmux + Claude emit 24-bit sequences that xterm.dart can render. User env (HOME / USER / SHELL) still inherits via mergePtyEnv(). ffi: 2.1.3 added as a runtime dep — the FFI bindings for socketpair, recvmsg, read/write, and ioctl(TIOCSWINSZ) need an allocator we're not writing by hand. Justified in pubspec + listed in licenses.yaml per D-042. make test-core (ci/test_core.sh) runs the Flutter-free core tests under a 120s hard timeout with setsid + process-group kill, wired ahead of the fast app tests in push-check so a hung PTY test can't wedge a pre-push. Current core suite: 24 tests in ~1s. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user