Files
clide/lib/src/pty/native_pty.dart
T
Jeroen SchweitzerandClaude Opus 4.6 a028eb7ad3
test / unit + widget + golden + a11y (push) Failing after 27s
test / integration_test (xvfb) (push) Has been skipped
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped
pass -x/-y to tmux new-session for correct initial size
tmux running inside a PTY without a traditional terminal client
defaults to a huge window size (2000+ cols, 10000 rows). Pass
explicit -x and -y flags matching the TerminalView dimensions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-01 13:03:51 +02:00

313 lines
9.6 KiB
Dart

/// Native PTY via forkpty() — replaces the ptyc helper binary.
///
/// Uses Dart FFI to call forkpty() directly. The master fd stays
/// in-process (no socketpair, no SCM_RIGHTS). The reader isolate
/// uses poll() for clean shutdown.
///
/// Based on the pty-spike proof-of-concept. Platform-aware:
/// macOS: forkpty in libSystem (DynamicLibrary.process)
/// Linux: forkpty in libutil.so.1
library;
import 'dart:async';
import 'dart:ffi' as ffi;
import 'dart:io' show File, Platform;
import 'dart:isolate';
import 'dart:typed_data';
import 'package:ffi/ffi.dart';
// -- structs ----------------------------------------------------------------
final class _Winsize extends ffi.Struct {
@ffi.Uint16()
external int wsRow;
@ffi.Uint16()
external int wsCol;
@ffi.Uint16()
external int wsXpixel;
@ffi.Uint16()
external int wsYpixel;
}
final class _Pollfd extends ffi.Struct {
@ffi.Int32()
external int fd;
@ffi.Int16()
external int events;
@ffi.Int16()
external int revents;
}
// -- FFI bindings -----------------------------------------------------------
final ffi.DynamicLibrary _dl = _openLib();
ffi.DynamicLibrary _openLib() {
if (Platform.isMacOS) return ffi.DynamicLibrary.process();
// Linux: forkpty lives in libutil
return ffi.DynamicLibrary.open('libutil.so.1');
}
final _forkpty = _dl.lookupFunction<
ffi.Int32 Function(ffi.Pointer<ffi.Int32>, ffi.Pointer<ffi.Char>,
ffi.Pointer<ffi.Void>, ffi.Pointer<_Winsize>),
int Function(ffi.Pointer<ffi.Int32>, ffi.Pointer<ffi.Char>,
ffi.Pointer<ffi.Void>, ffi.Pointer<_Winsize>)>('forkpty');
final _execve = _dl.lookupFunction<
ffi.Int32 Function(ffi.Pointer<ffi.Char>,
ffi.Pointer<ffi.Pointer<ffi.Char>>, ffi.Pointer<ffi.Pointer<ffi.Char>>),
int Function(ffi.Pointer<ffi.Char>, ffi.Pointer<ffi.Pointer<ffi.Char>>,
ffi.Pointer<ffi.Pointer<ffi.Char>>)>('execve');
final _nativeWrite = ffi.DynamicLibrary.process().lookupFunction<
ffi.IntPtr Function(ffi.Int32, ffi.Pointer<ffi.Void>, ffi.IntPtr),
int Function(int, ffi.Pointer<ffi.Void>, int)>('write');
final _nativeClose = ffi.DynamicLibrary.process()
.lookupFunction<ffi.Int32 Function(ffi.Int32), int Function(int)>('close');
final _ioctl = ffi.DynamicLibrary.process().lookupFunction<
ffi.Int32 Function(ffi.Int32, ffi.UnsignedLong, ffi.Pointer<_Winsize>),
int Function(int, int, ffi.Pointer<_Winsize>)>('ioctl');
final _nativeKill = ffi.DynamicLibrary.process().lookupFunction<
ffi.Int32 Function(ffi.Int32, ffi.Int32), int Function(int, int)>('kill');
final _waitpid = ffi.DynamicLibrary.process().lookupFunction<
ffi.Int32 Function(ffi.Int32, ffi.Pointer<ffi.Int32>, ffi.Int32),
int Function(int, ffi.Pointer<ffi.Int32>, int)>('waitpid');
final _chdir = ffi.DynamicLibrary.process().lookupFunction<
ffi.Int32 Function(ffi.Pointer<ffi.Char>),
int Function(ffi.Pointer<ffi.Char>)>('chdir');
final _exit_ = ffi.DynamicLibrary.process().lookupFunction<
ffi.Void Function(ffi.Int32), void Function(int)>('_exit');
final int _kTiocsWinsz = Platform.isMacOS ? 0x80087467 : 0x5414;
const _kSighup = 1;
const _kWnohang = 1;
// -- NativePty --------------------------------------------------------------
/// A pseudo-terminal backed by forkpty() via Dart FFI.
///
/// Drop-in replacement for the old ptyc-based PtySession.
class NativePty {
final int _fd;
final int pid;
final _out = StreamController<Uint8List>.broadcast();
bool _dead = false;
NativePty._(this._fd, this.pid);
/// Byte stream of data produced by the child.
Stream<Uint8List> get output => _out.stream;
bool get isClosed => _dead;
/// Spawn a new PTY running [executable] with [arguments].
///
/// [environment] must be the complete environment — it goes straight
/// to execve's envp. Merge Platform.environment before calling.
static NativePty start({
required String executable,
List<String> arguments = const ['-l'],
required int columns,
required int rows,
String? workingDirectory,
Map<String, String> environment = const {},
}) {
// Resolve bare command names via PATH (execve doesn't search PATH).
if (!executable.contains('/')) {
final path = environment['PATH'] ?? Platform.environment['PATH'] ?? '';
for (final dir in path.split(':')) {
if (dir.isEmpty) continue;
final candidate = '$dir/$executable';
if (File(candidate).existsSync()) {
executable = candidate;
break;
}
}
}
// Force-resolve FFI functions that run in the child process.
// Top-level finals are lazy; touching them here ensures the FFI
// trampolines are compiled before fork() clones the process.
final execve = _execve;
final chdir = _chdir;
final exit = _exit_;
// Allocate ALL native memory before fork.
final shellN = executable.toNativeUtf8(allocator: malloc).cast<ffi.Char>();
final allArgs = [executable, ...arguments];
final argvN = malloc<ffi.Pointer<ffi.Char>>(allArgs.length + 1);
for (var i = 0; i < allArgs.length; i++) {
argvN[i] = allArgs[i].toNativeUtf8(allocator: malloc).cast();
}
argvN[allArgs.length] = ffi.nullptr;
final envList = environment.entries.toList();
final envpN = malloc<ffi.Pointer<ffi.Char>>(envList.length + 1);
for (var i = 0; i < envList.length; i++) {
envpN[i] = '${envList[i].key}=${envList[i].value}'
.toNativeUtf8(allocator: malloc)
.cast();
}
envpN[envList.length] = ffi.nullptr;
final wdN = (workingDirectory ?? '/')
.toNativeUtf8(allocator: malloc)
.cast<ffi.Char>();
final fdOut = calloc<ffi.Int32>();
final ws = calloc<_Winsize>()
..ref.wsRow = rows
..ref.wsCol = columns;
// Fork.
final pid = _forkpty(fdOut, ffi.nullptr, ffi.nullptr, ws);
if (pid == -1) {
_freeAll(shellN, argvN, allArgs.length, envpN, envList.length, wdN,
fdOut, ws);
throw StateError('forkpty() failed');
}
if (pid == 0) {
// CHILD — only pre-resolved FFI calls, no Dart heap.
chdir(wdN);
execve(shellN, argvN, envpN);
exit(1);
}
// PARENT
final fd = fdOut.value;
_freeAll(shellN, argvN, allArgs.length, envpN, envList.length, wdN,
fdOut, ws);
final pty = NativePty._(fd, pid);
pty._spawnReader();
return pty;
}
static void _freeAll(
ffi.Pointer shell,
ffi.Pointer<ffi.Pointer<ffi.Char>> argv, int argc,
ffi.Pointer<ffi.Pointer<ffi.Char>> envp, int envc,
ffi.Pointer wd, ffi.Pointer fdOut, ffi.Pointer ws,
) {
malloc.free(shell);
for (var i = 0; i < argc; i++) malloc.free(argv[i]);
malloc.free(argv);
for (var i = 0; i < envc; i++) malloc.free(envp[i]);
malloc.free(envp);
malloc.free(wd);
calloc.free(fdOut);
calloc.free(ws);
}
// -- I/O ------------------------------------------------------------------
void _spawnReader() async {
final rp = ReceivePort();
await Isolate.spawn(_readLoop, (rp.sendPort, _fd));
rp.listen((msg) {
if (msg == null) {
if (!_out.isClosed) _out.close();
rp.close();
_reap();
} else {
if (!_out.isClosed) _out.add(msg as Uint8List);
}
});
}
/// Isolate entry — polls then reads until EOF/error/fd-closed.
static void _readLoop((SendPort, int) msg) {
final (port, fd) = msg;
final dl = ffi.DynamicLibrary.process();
final rd = dl.lookupFunction<
ffi.IntPtr Function(ffi.Int32, ffi.Pointer<ffi.Void>, ffi.IntPtr),
int Function(int, ffi.Pointer<ffi.Void>, int)>('read');
final poll = dl.lookupFunction<
ffi.Int32 Function(ffi.Pointer<_Pollfd>, ffi.Uint32, ffi.Int32),
int Function(ffi.Pointer<_Pollfd>, int, int)>('poll');
final buf = malloc<ffi.Uint8>(65536);
final pfd = calloc<_Pollfd>();
pfd.ref.fd = fd;
pfd.ref.events = 0x0001; // POLLIN
try {
while (true) {
final ready = poll(pfd, 1, 100);
if (ready < 0) break;
if (ready == 0) continue;
if (pfd.ref.revents & 0x0038 != 0 && pfd.ref.revents & 0x0001 == 0) {
break;
}
final n = rd(fd, buf.cast(), 65536);
if (n <= 0) break;
port.send(Uint8List.fromList(buf.asTypedList(n)));
}
} finally {
calloc.free(pfd);
malloc.free(buf);
}
port.send(null);
}
/// Write bytes to the child's stdin.
int write(List<int> bytes) {
if (_dead || bytes.isEmpty) return 0;
final buf = malloc<ffi.Uint8>(bytes.length);
for (var i = 0; i < bytes.length; i++) buf[i] = bytes[i];
final n = _nativeWrite(_fd, buf.cast(), bytes.length);
malloc.free(buf);
return n;
}
/// Resize the terminal.
void resize({required int cols, required int rows}) {
if (_dead) return;
final ws = calloc<_Winsize>()
..ref.wsRow = rows
..ref.wsCol = cols;
final rc = _ioctl(_fd, _kTiocsWinsz, ws);
calloc.free(ws);
print('[pty-resize] fd=$_fd cols=$cols rows=$rows ioctl=$rc pid=$pid');
// Explicitly signal the child to re-query its terminal size.
_nativeKill(pid, 28); // SIGWINCH = 28 on macOS/Linux
}
/// Send a signal to the child.
bool kill([int signal = _kSighup]) {
if (_dead) return false;
return _nativeKill(pid, signal) == 0;
}
void _reap() {
if (_dead) return;
_dead = true;
final s = calloc<ffi.Int32>();
_waitpid(pid, s, _kWnohang);
calloc.free(s);
}
/// Kill the child and release resources.
Future<void> close() async {
if (_dead) return;
_dead = true;
_nativeClose(_fd);
_nativeKill(pid, _kSighup);
_nativeKill(pid, 9);
final s = calloc<ffi.Int32>();
_waitpid(pid, s, 0);
calloc.free(s);
if (!_out.isClosed) await _out.close();
}
}