replace ptyc with forkpty() via Dart FFI
test / unit + widget + golden + a11y (push) Failing after 3m13s
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

NativePty calls forkpty() directly — no helper binary, no socketpair,
no SCM_RIGHTS. The master fd stays in-process. Reader isolate uses
poll() for clean shutdown.

Based on the pty-spike proof-of-concept. Platform-aware: macOS uses
libSystem (DynamicLibrary.process), Linux needs libutil.so.1.
TIOCSWINSZ platform-detected.

PaneRegistry updated to use NativePty. registerPaneCommands no longer
needs a Toolchain parameter. All ptyc references removed from the
daemon layer.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jeroen Schweitzer
2026-04-30 20:30:15 +02:00
co-authored by Claude Opus 4.6
parent cf2ae48201
commit 5aa0eb46e4
12 changed files with 708 additions and 336 deletions
+3 -12
View File
@@ -15,11 +15,10 @@ import '../ipc/envelope.dart';
import '../ipc/schema_v1.dart';
import '../panes/pane.dart';
import '../panes/registry.dart';
import '../../kernel/src/toolchain.dart';
import 'dispatcher.dart';
void registerPaneCommands(DaemonDispatcher d, PaneRegistry registry, {required Toolchain toolchain}) {
d.register('pane.spawn', (req) => _spawn(req, registry, toolchain));
void registerPaneCommands(DaemonDispatcher d, PaneRegistry registry) {
d.register('pane.spawn', (req) => _spawn(req, registry));
d.register('pane.list', (req) => _list(req, registry));
d.register('pane.close', (req) => _close(req, registry));
d.register('pane.write', (req) => _write(req, registry));
@@ -48,14 +47,7 @@ IpcResponse _notFound(String id, String message) => IpcResponse.err(
),
);
Future<IpcResponse> _spawn(IpcRequest req, PaneRegistry registry, Toolchain toolchain) async {
// Wait for toolchain resolution if it hasn't completed yet.
if (!toolchain.resolved) {
await Future.any([
toolchain.waitForResolution(),
Future.delayed(const Duration(seconds: 5)),
]);
}
Future<IpcResponse> _spawn(IpcRequest req, PaneRegistry registry) async {
final args = req.args;
final rawArgv = args['argv'];
if (rawArgv is! List || rawArgv.isEmpty) {
@@ -92,7 +84,6 @@ Future<IpcResponse> _spawn(IpcRequest req, PaneRegistry registry, Toolchain tool
cols: (args['cols'] as num?)?.toInt() ?? 80,
rows: (args['rows'] as num?)?.toInt() ?? 24,
title: args['title'] as String?,
ptycPath: (args['ptyc_path'] as String?) ?? toolchain.ptyc,
);
return IpcResponse.ok(id: req.id, data: pane.toJson());
} catch (e) {
+23 -9
View File
@@ -8,10 +8,11 @@ library;
import 'dart:async';
import 'dart:convert';
import 'dart:io' show Platform;
import 'dart:typed_data';
import '../ipc/envelope.dart';
import '../pty/session.dart';
import '../pty/native_pty.dart';
import 'event_sink.dart';
import 'pane.dart';
@@ -20,7 +21,7 @@ class PaneRegistry {
final DaemonEventSink events;
final Map<String, Pane> _panes = {};
final Map<String, PtySession> _sessions = {};
final Map<String, NativePty> _sessions = {};
final Map<String, StreamSubscription<Uint8List>> _subs = {};
int _nextId = 1;
@@ -42,16 +43,29 @@ class PaneRegistry {
int cols = 80,
int rows = 24,
String? title,
String ptycPath = 'ptyc',
}) async {
final id = 'p_${_nextId++}';
final session = await PtySession.spawn(
argv: argv,
cwd: cwd,
env: env,
cols: cols,
final executable = argv.first;
final arguments = argv.length > 1 ? argv.sublist(1) : const <String>[];
// Merge the caller's env on top of the process environment +
// terminal defaults, matching the old ptyc contract.
final fullEnv = <String, String>{
...Platform.environment,
'TERM': 'xterm-256color',
'COLORTERM': 'truecolor',
'LANG': 'en_US.UTF-8',
'LC_ALL': 'en_US.UTF-8',
if (env != null) ...env,
};
final session = NativePty.start(
executable: executable,
arguments: arguments,
columns: cols,
rows: rows,
ptycPath: ptycPath,
workingDirectory: cwd,
environment: fullEnv,
);
final pane = Pane(
id: id,
+296
View File
@@ -0,0 +1,296 @@
/// 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 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 {},
}) {
// 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>(4096);
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(), 4096);
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;
_ioctl(_fd, _kTiocsWinsz, ws);
calloc.free(ws);
}
/// 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();
}
}
+2 -2
View File
@@ -1,7 +1,7 @@
/// PTY subsystem — spawn child processes under a PTY via `ptyc`,
/// PTY subsystem — spawn child processes under a PTY via forkpty(),
/// 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;
export 'native_pty.dart' show NativePty;