remove dissolved daemon, retire ptyc, fix golden cross-platform

Complete three overdue cleanups discovered during macOS health check:

D-56 daemon dissolution: delete bin/clide.dart, DaemonServer,
and orphaned tests (test/cli/, subprocess_test, in_process_test).
Update stale "clide --daemon" references in i18n catalogs, error
messages, editor_commands, CI scripts, and decision records.

ptyc retirement: delete ptyc/ source tree, PtySession, scm_rights.
Remove from Toolchain resolution, ToolCheck gate, backend
serialization, testmode harness, Makefile, CI, and sandbox
entitlements. PTY spawning uses NativePty (Dart FFI forkpty) since
the terminal was absorbed in-tree. D-5 amended.

Golden tests: wire the existing but never-applied clideGoldenConfig
via flutter_test_config.dart. Disable CI goldens (Skia anti-aliasing
differs between macOS/Linux even with Ahem). Keep platform-keyed
goldens only — goldens/linux/ and goldens/macos/ each run on their
own OS.

Test suite: 826 pass, 0 fail on macOS (was 829 pass, 11 fail).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jeroen Schweitzer
2026-05-07 18:40:01 +02:00
co-authored by Claude Opus 4.6
parent 6b7290dc42
commit a6eca2561b
51 changed files with 265 additions and 2719 deletions
+1 -1
View File
@@ -64,7 +64,7 @@ class _ClaudePaneState extends State<ClaudePane> {
// Primary panes leave the tmux session alive so the next launch
// re-attaches via `tmux new-session -A` (D-41).
//
// pane.close kills the ptyc-spawned tmux *client*; the tmux server
// pane.close kills the PTY-spawned tmux *client*; the tmux server
// keeps the session alive. We need an explicit kill-session for
// secondaries to actually disappear (D-41 close semantics).
if (id != null && !widget.isPrimary) {
+1 -1
View File
@@ -1,6 +1,6 @@
/// tmux server interactions for Claude panes (D-41 lifecycle).
///
/// `pane.close` only kills the ptyc-spawned tmux *client*; tmux is
/// `pane.close` only kills the PTY-spawned tmux *client*; tmux is
/// client/server, so the server-side session keeps running after the
/// client disconnects. To honour D-41 ("closing a secondary kills that
/// tmux session" + "secondary numbering resets between clide runs"),
+1 -1
View File
@@ -62,7 +62,7 @@ class _TerminalPaneState extends State<TerminalPane> {
if (!mounted) return;
final ipc = _kernelIpc();
if (ipc == null || !ipc.isConnected) {
setState(() => _error = 'Daemon not connected. Start `clide --daemon`.');
setState(() => _error = 'Backend not connected.');
return;
}
+5 -10
View File
@@ -1,8 +1,5 @@
/// clide — Dart core library.
///
/// Shared by `bin/clide.dart` (CLI + daemon) and by the Flutter app
/// under `app/` (which depends on this package via `path: ../`).
///
/// See:
/// * `decisions/architecture.md` `D-005` — layout + language rationale.
/// * `decisions/architecture.md` `D-006` — CLI + event contract.
@@ -10,11 +7,10 @@ library;
// Flutter-app-visible surface. Deliberately **does not** export the
// `pty/` or `panes/registry.dart` modules — those import `dart:ffi`
// and pull in the PTY machinery that only runs on desktop. The
// daemon entrypoint (`bin/clide.dart`) imports them via deep paths.
// and pull in the PTY machinery that only runs on desktop.
//
// `Pane` + `PaneKind` + the event-sink interfaces travel here because
// they're pure data types that both the app and the daemon reference.
// they're pure data types referenced throughout the app.
export 'src/daemon/dispatcher.dart';
export 'src/editor/buffer.dart';
@@ -28,15 +24,14 @@ export 'src/pql/client.dart' show PqlClient, PqlException;
export 'src/ipc/envelope.dart';
export 'src/ipc/paths.dart';
export 'src/ipc/schema_v1.dart';
export 'src/ipc/server.dart';
export 'src/panes/event_sink.dart';
export 'src/panes/pane.dart' show Pane, PaneKind;
/// Build-time-stamped version string.
///
/// The Makefile's `build` target passes `--define=clideVersion=…` when
/// invoking `dart compile exe`, stamping `project.yaml`'s `version:`
/// plus the git short SHA and dirty marker.
/// The Makefile's `build` target passes `--define=clideVersion=…`,
/// stamping `pubspec.yaml`'s `version:` plus the git short SHA and
/// dirty marker.
const clideVersion = String.fromEnvironment(
'clideVersion',
defaultValue: '2.0.0-dev',
-2
View File
@@ -60,7 +60,6 @@ class Backend {
git: tcData['git'] as String?,
pql: tcData['pql'] as String?,
tmux: tcData['tmux'] as String?,
ptyc: tcData['ptyc'] as String?,
shell: tcData['shell'] as String?,
gitEnv: (tcData['gitEnv'] as Map?)?.cast<String, String>(),
));
@@ -86,7 +85,6 @@ class Backend {
git: tcData['git'] as String?,
pql: tcData['pql'] as String?,
tmux: tcData['tmux'] as String?,
ptyc: tcData['ptyc'] as String?,
shell: tcData['shell'] as String?,
gitEnv: (tcData['gitEnv'] as Map?)?.cast<String, String>(),
));
+3 -4
View File
@@ -45,8 +45,8 @@ void backendEntry(BackendBootMessage boot) {
late Toolchain toolchain;
// Phase 1: resolve toolchain — just find binaries, don't init services.
// We need a project root for ptyc/dugite paths. Use a sensible
// default; the real project comes from project.open.
// We need a project root for dugite paths. Use a sensible default;
// the real project comes from project.open.
final resolveRoot = boot.hintRoot ?? Platform.environment['HOME'] ?? '/tmp';
toolchain = Toolchain();
toolchain.applyResolved(resolveToolchainPaths(resolveRoot));
@@ -78,7 +78,7 @@ void backendEntry(BackendBootMessage boot) {
final workDir = Directory(projectPath);
// Re-resolve toolchain with the actual project root (finds
// dugite in native/dugite/, ptyc in ptyc/bin/, etc.)
// dugite in native/dugite/, etc.)
toolchain = Toolchain();
toolchain.applyResolved(resolveToolchainPaths(projectPath));
@@ -138,7 +138,6 @@ Map<String, Object?> _serializeToolchain(Toolchain tc) => {
'git': tc.git,
'pql': tc.pql,
'tmux': tc.tmux,
'ptyc': tc.ptyc,
'shell': tc.shell,
'gitEnv': tc.gitEnv,
'missing': tc.missing,
@@ -1,6 +1,6 @@
{
"connected": { "translation": "connected" },
"connected.hint": { "translation": "clide daemon is reachable over the local socket" },
"connected.hint": { "translation": "backend isolate is reachable" },
"disconnected": { "translation": "disconnected" },
"disconnected.hint": { "translation": "clide daemon is not running — start it with `clide --daemon`" }
"disconnected.hint": { "translation": "backend isolate is not running" }
}
@@ -3,5 +3,5 @@
"subtitle.spawning": { "translation": "spawning shell…" },
"subtitle.exited": { "translation": "Shell exited." },
"error.unavailable": { "translation": "Terminal unavailable" },
"error.daemon": { "translation": "Daemon not connected. Start `clide --daemon`." }
"error.daemon": { "translation": "Backend not connected." }
}
-1
View File
@@ -58,7 +58,6 @@ class DaemonClient extends ChangeNotifier {
code: IpcExitCode.toolError,
kind: IpcErrorKind.toolError,
message: 'daemon not connected',
hint: 'is `clide --daemon` running?',
),
));
}
+1 -9
View File
@@ -5,16 +5,14 @@ import 'package:flutter/foundation.dart';
import '../../src/pty/env.dart';
class ToolCheck extends ChangeNotifier {
bool ptycOk = false;
bool pqlOk = false;
bool tmuxOk = false;
bool gitOk = false;
bool checked = false;
bool get allOk => ptycOk && pqlOk && tmuxOk && gitOk;
bool get allOk => pqlOk && tmuxOk && gitOk;
List<String> get errors => [
if (!ptycOk) 'ptyc not found',
if (!pqlOk) 'pql not found',
if (!tmuxOk) 'tmux not found',
if (!gitOk) 'git not found',
@@ -24,12 +22,6 @@ class ToolCheck extends ChangeNotifier {
static String? workspaceRoot;
Future<void> check() async {
final root = workspaceRoot ?? Directory.current.path;
ptycOk = File('$root/native/linux-x64/ptyc').existsSync() ||
File('$root/native/macos-arm64/ptyc').existsSync() ||
File('$root/native/macos-x64/ptyc').existsSync() ||
File('$root/ptyc/bin/ptyc').existsSync() ||
_existsOnPath('ptyc');
pqlOk = _existsOnPath('pql');
tmuxOk = _existsOnPath('tmux');
gitOk = _existsOnPath('git');
-23
View File
@@ -16,7 +16,6 @@ class ResolvedPaths {
this.git,
this.pql,
this.tmux,
this.ptyc,
this.shell,
this.gitEnv,
});
@@ -24,7 +23,6 @@ class ResolvedPaths {
final String? git;
final String? pql;
final String? tmux;
final String? ptyc;
final String? shell;
final Map<String, String>? gitEnv;
}
@@ -33,7 +31,6 @@ class Toolchain extends ChangeNotifier {
String? _git;
String? _pql;
String? _tmux;
String? _ptyc;
String? _shell;
Map<String, String>? _gitEnv;
bool _resolved = false;
@@ -41,7 +38,6 @@ class Toolchain extends ChangeNotifier {
String get git => _git ?? 'git';
String get pql => _pql ?? 'pql';
String get tmux => _tmux ?? 'tmux';
String get ptyc => _ptyc ?? 'ptyc';
String get shell => _shell ?? '/bin/bash';
/// Extra environment variables for git (e.g. GIT_EXEC_PATH for dugite).
@@ -76,7 +72,6 @@ class Toolchain extends ChangeNotifier {
_git = p.git;
_pql = p.pql;
_tmux = p.tmux;
_ptyc = p.ptyc;
_shell = p.shell;
_gitEnv = p.gitEnv;
_resolved = true;
@@ -106,20 +101,10 @@ class Toolchain extends ChangeNotifier {
final tmux = _findOnPath('tmux');
final shell = _findOnPath(Platform.environment['SHELL']?.split('/').last ?? 'bash');
final ptyc = _firstExisting([
'$workspaceRoot/ptyc/bin/ptyc',
'$workspaceRoot/native/linux-x64/ptyc',
'$workspaceRoot/native/macos-arm64/ptyc',
'$workspaceRoot/native/macos-x64/ptyc',
if (Platform.environment['HOME'] case final home?) '$home/.local/bin/ptyc',
]) ??
_findOnPath('ptyc');
return ResolvedPaths(
git: git,
pql: pql,
tmux: tmux,
ptyc: ptyc,
shell: shell,
gitEnv: gitEnv,
);
@@ -182,14 +167,6 @@ ResolvedPaths resolveToolchainPaths(String workspaceRoot) {
git: git,
pql: _findOnPathStandalone('pql'),
tmux: _findOnPathStandalone('tmux'),
ptyc: _firstExistingStandalone([
'$workspaceRoot/ptyc/bin/ptyc',
'$workspaceRoot/native/linux-x64/ptyc',
'$workspaceRoot/native/macos-arm64/ptyc',
'$workspaceRoot/native/macos-x64/ptyc',
if (Platform.environment['HOME'] case final home?) '$home/.local/bin/ptyc',
]) ??
_findOnPathStandalone('ptyc'),
shell: _findOnPathStandalone(Platform.environment['SHELL']?.split('/').last ?? 'bash'),
gitEnv: gitEnv,
);
+1 -1
View File
@@ -6,7 +6,7 @@ class LuaHost {
/// Boot the vendored liblua. Throws until Tier 6.
static Future<LuaHost> start() async {
throw UnsupportedError('Lua runtime lands at Tier 6 (supporter tool sibling of ptyc).');
throw UnsupportedError('Lua runtime lands at Tier 6.');
}
Future<void> dispose() async {}
+1 -1
View File
@@ -6,7 +6,7 @@
/// editor.read editor.set-selection editor.set-content
///
/// Single-word CLI shortcuts (`clide open`, `clide active`, …) map
/// one-to-one onto these in `bin/clide.dart`.
/// one-to-one onto these via the IPC dispatch layer.
library;
import 'dart:io' show FileSystemException;
-144
View File
@@ -1,144 +0,0 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:clide/src/ipc/envelope.dart';
typedef RequestDispatcher = Future<IpcResponse> Function(IpcRequest request);
/// Default per-request timeout. A handler that doesn't return within
/// this window gets a `tool_error` response so the connection's read
/// pipeline isn't blocked indefinitely. Long-running commands (git
/// pull/push, large pql queries) can override per-command later.
const Duration _kDefaultRequestTimeout = Duration(seconds: 60);
/// Unix-socket JSON-lines server. Each connection is an independent
/// bidirectional line-framed stream: client writes requests, daemon
/// writes responses + events on the same socket.
class DaemonServer {
DaemonServer({
required this.socketPath,
required this.dispatch,
Duration requestTimeout = _kDefaultRequestTimeout,
}) : _requestTimeout = requestTimeout;
final String socketPath;
final RequestDispatcher dispatch;
final Duration _requestTimeout;
ServerSocket? _server;
final Set<Socket> _clients = {};
/// Broadcast [event] to every currently-connected client. Sockets
/// that error on write are dropped — the client's read side will
/// notice the close. Errors are logged so silent event loss is
/// debuggable.
void broadcast(IpcEvent event) {
final line = event.encode();
for (final c in List<Socket>.from(_clients)) {
try {
c.writeln(line);
} catch (e) {
stderr.writeln('clide daemon: broadcast write failed (${event.subsystem}.${event.kind}): $e');
_clients.remove(c);
}
}
}
Future<void> start() async {
final addr = InternetAddress(socketPath, type: InternetAddressType.unix);
try {
_server = await ServerSocket.bind(addr, 0);
} on SocketException {
// Either a stale socket from a prior crash, or a live daemon.
// Probe by trying to connect — if a live peer answers, refuse
// to start so we don't rip its socket out.
try {
final probe = await Socket.connect(addr, 0).timeout(const Duration(milliseconds: 200));
await probe.close();
throw StateError('clide daemon already running at $socketPath');
} on TimeoutException {
// No one answered — proceed to unlink and rebind.
} on SocketException {
// No one listening — proceed to unlink and rebind.
}
try {
await File(socketPath).delete();
} catch (_) {}
_server = await ServerSocket.bind(addr, 0);
}
stderr.writeln('clide daemon listening on $socketPath');
_server!.listen(_handleClient, onError: (e) {
stderr.writeln('clide daemon accept error: $e');
});
}
Future<void> stop() async {
for (final c in List<Socket>.from(_clients)) {
await c.close();
}
_clients.clear();
await _server?.close();
_server = null;
try {
await File(socketPath).delete();
} catch (_) {}
}
void _handleClient(Socket client) {
_clients.add(client);
client.cast<List<int>>().transform(utf8.decoder).transform(const LineSplitter()).listen(
(line) => _handleLine(client, line),
onDone: () => _clients.remove(client),
onError: (Object e) {
stderr.writeln('clide daemon client error: $e');
_clients.remove(client);
},
cancelOnError: true,
);
}
Future<void> _handleLine(Socket client, String line) async {
if (line.isEmpty) return;
IpcMessage? msg;
try {
msg = IpcMessage.decode(line);
} on FormatException catch (e) {
stderr.writeln('clide daemon: bad line from client: $e');
return;
}
if (msg is! IpcRequest) return;
IpcResponse resp;
try {
resp = await dispatch(msg).timeout(_requestTimeout);
} on TimeoutException {
stderr.writeln('clide daemon: dispatch timeout for ${msg.cmd} (${_requestTimeout.inSeconds}s)');
resp = IpcResponse.err(
id: msg.id,
error: IpcError(
code: 2,
kind: 'tool_error',
message: 'request timed out after ${_requestTimeout.inSeconds}s: ${msg.cmd}',
),
);
} catch (e, st) {
stderr.writeln('clide daemon: dispatch error for ${msg.cmd}: $e\n$st');
resp = IpcResponse.err(
id: msg.id,
error: IpcError(
code: 2,
kind: 'tool_error',
message: 'dispatch failed: $e',
),
);
}
try {
client.writeln(resp.encode());
} catch (e) {
// Client disconnected mid-dispatch — drop it so future events
// don't try to write to a dead socket.
stderr.writeln('clide daemon: response write failed (${msg.cmd}): $e');
_clients.remove(client);
}
}
}
+2 -2
View File
@@ -2,8 +2,8 @@
/// travels cleanly into the Flutter app (which can't depend on
/// `dart:ffi`-using code for the web build).
///
/// The daemon's [PaneRegistry] keeps a parallel `PtySession` keyed on
/// [id] and mutates [isClosed] when the session exits.
/// [PaneRegistry] keeps a parallel [NativePty] keyed on [id] and
/// mutates [isClosed] when the session exits.
library;
/// Kind of a pane. Keep this enum small and explicit — each kind
+3 -7
View File
@@ -1,6 +1,6 @@
/// [PaneRegistry] — daemon-side state for all live panes.
/// [PaneRegistry] — backend-side state for all live panes.
///
/// Owns the [PtySession] per pane, generates `p_N` ids, and forwards
/// Owns the [NativePty] per pane, generates `p_N` ids, and forwards
/// pty output + lifecycle changes as IPC events via a [DaemonEventSink].
/// Pane commands (pane.spawn / list / write / resize / close) resolve
/// against this registry; extension UIs subscribe to the emitted events.
@@ -31,10 +31,6 @@ class PaneRegistry {
Pane? get(String id) => _panes[id];
/// Spawn a child under a PTY and wire its output to events.
///
/// [ptycPath] is plumbed through to [PtySession.spawn]; callers that
/// have a dev-built `ptyc/bin/ptyc` or a non-PATH install can point
/// at it explicitly.
Future<Pane> spawn({
required PaneKind kind,
required List<String> argv,
@@ -49,7 +45,7 @@ class PaneRegistry {
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.
// Terminal defaults for the PTY child.
final fullEnv = <String, String>{
...Platform.environment,
'TERM': 'xterm-256color',
+4 -4
View File
@@ -37,10 +37,10 @@ String _buildExpandedPath() {
return [...missing, ...existing].join(':');
}
/// 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.
/// Base env clide builds for every PTY child. Callers merge with the
/// user's environment — 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',
+3 -3
View File
@@ -5,9 +5,9 @@
/// 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`.
/// A PTY operation failed. [op] identifies the step (`forkpty`,
/// `read`, `ioctl`, 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});
+2 -4
View File
@@ -1,9 +1,7 @@
/// 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.
/// `dart:io` doesn't expose `forkpty`, `read`/`write` on raw fds,
/// `ioctl`, or `poll` — 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
-122
View File
@@ -1,122 +0,0 @@
/// 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 'dart:io' show Platform;
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>();
try {
iov.ref.iov_base = payload;
iov.ref.iov_len = payloadLen;
int received;
int msgControllen;
if (Platform.isMacOS) {
final msg = pkg_ffi.calloc<libc.MsghdrDarwin>();
try {
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;
while (true) {
received = libc.recvmsgDarwin(socketFd, msg, 0);
if (received >= 0) break;
final err = libc.errno;
if (err == 4 /* EINTR */) continue;
throw PtyException('recvmsg', 'recvmsg failed', errno: err);
}
msgControllen = msg.ref.msg_controllen;
} finally {
pkg_ffi.calloc.free(msg);
}
} else {
final msg = pkg_ffi.calloc<libc.Msghdr>();
try {
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;
while (true) {
received = libc.recvmsgLinux(socketFd, msg, 0);
if (received >= 0) break;
final err = libc.errno;
if (err == 4 /* EINTR */) continue;
throw PtyException('recvmsg', 'recvmsg failed', errno: err);
}
msgControllen = msg.ref.msg_controllen;
} finally {
pkg_ffi.calloc.free(msg);
}
}
if (received == 0 || msgControllen < 16) {
throw const PtyException(
'recvmsg',
'peer closed without sending ancillary data',
);
}
// Parse the first cmsghdr out of the control buffer. On macOS,
// cmsg_len is socklen_t (4 bytes); on Linux it's size_t (8 bytes).
int cmsgLevel, cmsgType, dataOffset;
if (Platform.isMacOS) {
final hdr = control.cast<libc.CmsghdrDarwin>().ref;
cmsgLevel = hdr.cmsg_level;
cmsgType = hdr.cmsg_type;
dataOffset = ffi.sizeOf<libc.CmsghdrDarwin>();
} else {
final hdr = control.cast<libc.CmsghdrLinux>().ref;
cmsgLevel = hdr.cmsg_level;
cmsgType = hdr.cmsg_type;
dataOffset = ffi.sizeOf<libc.CmsghdrLinux>();
}
if (cmsgLevel != libc.solSocket || cmsgType != libc.scmRights) {
throw PtyException(
'recvmsg',
'unexpected cmsg level=$cmsgLevel type=$cmsgType',
);
}
final fdPtr = (control + dataOffset).cast<ffi.Int32>();
return fdPtr.value;
} finally {
pkg_ffi.calloc.free(iov);
pkg_ffi.calloc.free(control);
pkg_ffi.calloc.free(payload);
}
}
+2 -5
View File
@@ -1,8 +1,7 @@
/// Native PTY via forkpty() — replaces the ptyc helper binary.
/// Native PTY via forkpty().
///
/// 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.
/// in-process. The reader isolate uses poll() for clean shutdown.
///
/// Based on the pty-spike proof-of-concept. Platform-aware:
/// macOS: forkpty in libSystem (DynamicLibrary.process)
@@ -82,8 +81,6 @@ 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;
-442
View File
@@ -1,442 +0,0 @@
/// [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;
class _RecvFdArgs {
const _RecvFdArgs(this.socketFd, this.sendPort);
final int socketFd;
final SendPort sendPort;
}
/// 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.
// recvFd blocks until ptyc sends — run in a child isolate so the
// calling isolate's event loop stays responsive.
final int masterFd;
try {
masterFd = await _recvFdAsync(parentSock);
} catch (_) {
proc.kill();
rethrow;
}
// Once we own masterFd, every error path below must close it
// before rethrowing. Wrap the rest of the spawn in its own
// try/catch so the cleanup is centralized.
try {
libc.setWinsize(masterFd, cols, rows);
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);
} catch (_) {
libc.close(masterFd);
rethrow;
}
} 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);
if (parentSock >= 0) libc.close(parentSock);
pkg_ffi.calloc.free(sv);
}
}
/// Send bytes to the child's stdin. Loops on short writes; throws
/// [PtyException] (with errno) on failure. Returns total bytes
/// written, which equals [bytes.length] on success.
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];
}
var written = 0;
while (written < bytes.length) {
final n = libc.write(
_masterFd,
buf + written,
bytes.length - written,
);
if (n < 0) {
final err = libc.errno;
if (err == 4 /* EINTR */) continue;
throw PtyException('write', 'write to PTY failed', errno: err);
}
if (n == 0) break;
written += n;
}
return written;
} 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();
}
/// Run recvFd in a child isolate so the blocking FFI call doesn't
/// stall the calling isolate's event loop.
static Future<int> _recvFdAsync(int socketFd) async {
final port = ReceivePort();
Isolate? iso;
try {
iso = await Isolate.spawn(_recvFdEntry, _RecvFdArgs(socketFd, port.sendPort));
final result = await port.first;
if (result is int) return result;
throw PtyException('recvFd', '$result');
} finally {
iso?.kill(priority: Isolate.immediate);
port.close();
}
}
static void _recvFdEntry(_RecvFdArgs args) {
try {
final fd = scm.recvFd(args.socketFd);
args.sendPort.send(fd);
} catch (e) {
args.sendPort.send('error: $e');
}
}
// ---------------------------------------------------------------- //
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,
onError: (Object e) {
// Spawn failure leaves the session unable to ever produce
// output. Surface the error and mark the controller closed
// so consumers don't hang waiting on the stream.
if (!_outputCtrl.isClosed) {
_outputCtrl.addError(PtyException('reader-spawn', '$e'));
_outputCtrl.close();
}
if (!_readerExited.isCompleted) _readerExited.complete();
},
);
}
// -- 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 = 65536;
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);
}
}
-102
View File
@@ -34,7 +34,6 @@ import 'src/daemon/pane_commands.dart';
import 'src/ipc/envelope.dart';
import 'src/panes/event_sink.dart';
import 'src/panes/registry.dart';
import 'src/pty/session.dart';
import 'src/daemon/dispatcher.dart';
import 'src/pty/env.dart' show expandedPath;
@@ -124,7 +123,6 @@ class _ClideTestAppState extends State<ClideTestApp> {
_log('toolchain.git', tc.git);
_log('toolchain.pql', tc.pql);
_log('toolchain.tmux', tc.tmux);
_log('toolchain.ptyc', tc.ptyc);
_log('toolchain.shell', tc.shell);
_log('toolchain.missing', tc.missing.isEmpty ? 'none' : tc.missing.join(', '));
_say('');
@@ -132,14 +130,12 @@ class _ClideTestAppState extends State<ClideTestApp> {
await _testExists('git', tc.git);
await _testExists('pql', tc.pql);
await _testExists('tmux', tc.tmux);
await _testExists('ptyc', tc.ptyc);
await _testExists('shell', tc.shell);
_say('');
await _testExec('git --version', tc.git, ['--version'], workDir);
await _testExec('pql --version', tc.pql, ['--version'], workDir);
await _testExec('tmux -V', tc.tmux, ['-V'], workDir);
await _testExec('ptyc (no args)', tc.ptyc, [], workDir);
await _testExec('shell --version', tc.shell, ['--version'], workDir);
_say('');
@@ -191,17 +187,6 @@ class _ClideTestAppState extends State<ClideTestApp> {
return 'exit=${r.exitCode} ${(r.stdout as String).trim()}';
});
// ptyc stdin/stdout test — send a valid request, verify JSON response
await _testAsync('ptyc spawn echo', () async {
final proc = await Process.start(tc.ptyc, []);
// Send a request for /bin/echo — simplest possible child
proc.stdin.write('{"argv":["/bin/echo","hello"],"cwd":"/tmp","env":{},"cols":80,"rows":24}');
await proc.stdin.close();
final stdout = await proc.stdout.transform(const SystemEncoding().decoder).join();
final exitCode = await proc.exitCode;
return 'exit=$exitCode stdout=${stdout.trim().split('\n').first}';
});
_say('');
}
@@ -395,93 +380,6 @@ class _ClideTestAppState extends State<ClideTestApp> {
return 'exit=$exit stderr=${stderr.trim()}';
});
// Direct PtySession test — bypasses IPC, tests fd transfer + reader.
await _testAsync('PtySession.spawn direct', () async {
final session = await PtySession.spawn(
argv: [tc.shell, '-c', 'echo DIRECT_PTY_TEST'],
cwd: workDir,
ptycPath: tc.ptyc,
);
_say(' session pid=${session.pid} masterFd exists');
final bytes = <int>[];
final done = Completer<void>();
session.output.listen(
(chunk) {
bytes.addAll(chunk);
_say(' got ${chunk.length} bytes');
},
onDone: () {
_say(' stream done');
if (!done.isCompleted) done.complete();
},
onError: (e) => _say(' stream error: $e'),
);
await done.future.timeout(const Duration(seconds: 5), onTimeout: () {
_say(' timeout waiting for output, got ${bytes.length} bytes so far');
});
await session.close();
final output = utf8.decode(bytes, allowMalformed: true);
final ok = output.contains('DIRECT_PTY_TEST');
return ok ? 'output=$output' : 'no marker in ${bytes.length} bytes: ${output.substring(0, output.length.clamp(0, 100))}';
});
if (!Platform.isMacOS) {
// Additional direct PtySession tests (Linux only — no merged thread).
// Test 1: spawn /bin/echo via PtySession, read output
await _testAsync('pty spawn echo', () async {
final session = await PtySession.spawn(
argv: ['/bin/echo', 'CLIDE_PTY_TEST_OK'],
cwd: workDir,
ptycPath: tc.ptyc,
);
final bytes = <int>[];
final done = Completer<void>();
session.output.listen(bytes.addAll, onDone: () => done.complete());
await done.future.timeout(const Duration(seconds: 5));
await session.close();
final output = utf8.decode(bytes, allowMalformed: true);
final ok = output.contains('CLIDE_PTY_TEST_OK');
return ok ? 'output contains marker' : 'marker not found in ${output.length} bytes';
});
// Test 2: spawn shell, write a command, verify output
await _testAsync('pty spawn shell', () async {
final session = await PtySession.spawn(
argv: [tc.shell, '-c', 'echo CLIDE_SHELL_TEST'],
cwd: workDir,
ptycPath: tc.ptyc,
);
final bytes = <int>[];
final done = Completer<void>();
session.output.listen(bytes.addAll, onDone: () => done.complete());
await done.future.timeout(const Duration(seconds: 5));
await session.close();
final output = utf8.decode(bytes, allowMalformed: true);
final ok = output.contains('CLIDE_SHELL_TEST');
return ok ? 'shell output contains marker' : 'marker not found in ${output.length} bytes';
});
// Test 3: spawn interactive shell, write to stdin, verify file creation
await _testAsync('pty write to child', () async {
final marker = '/tmp/clide-pty-test-${DateTime.now().millisecondsSinceEpoch}';
final session = await PtySession.spawn(
argv: [tc.shell],
cwd: workDir,
ptycPath: tc.ptyc,
);
session.write(utf8.encode('touch $marker && exit\n'));
final bytes = <int>[];
final done = Completer<void>();
session.output.listen(bytes.addAll, onDone: () => done.complete());
await done.future.timeout(const Duration(seconds: 5));
await session.close();
final fileCreated = File(marker).existsSync();
if (fileCreated) File(marker).deleteSync();
return fileCreated ? 'file created + cleaned up' : 'file not created';
});
} // end !Platform.isMacOS
_say('');
}