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
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:
co-authored by
Claude Opus 4.6
parent
cf2ae48201
commit
5aa0eb46e4
+167
-167
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -149,7 +149,7 @@ Future<void> _runDaemon(List<String> args) async {
|
|||||||
|
|
||||||
final events = _ServerEventSink(server);
|
final events = _ServerEventSink(server);
|
||||||
final registry = PaneRegistry(events: events);
|
final registry = PaneRegistry(events: events);
|
||||||
registerPaneCommands(dispatcher, registry, toolchain: toolchain);
|
registerPaneCommands(dispatcher, registry);
|
||||||
|
|
||||||
final files = FilesService.atCwd(events: events);
|
final files = FilesService.atCwd(events: events);
|
||||||
registerFilesCommands(dispatcher, files);
|
registerFilesCommands(dispatcher, files);
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ void backendEntry(BackendBootMessage boot) {
|
|||||||
registerPqlCommands(dispatcher, pql);
|
registerPqlCommands(dispatcher, pql);
|
||||||
|
|
||||||
final paneRegistry = PaneRegistry(events: eventSink);
|
final paneRegistry = PaneRegistry(events: eventSink);
|
||||||
registerPaneCommands(dispatcher, paneRegistry, toolchain: toolchain);
|
registerPaneCommands(dispatcher, paneRegistry);
|
||||||
|
|
||||||
// Tell the frontend the project is active.
|
// Tell the frontend the project is active.
|
||||||
frontendPort.send({
|
frontendPort.send({
|
||||||
|
|||||||
+59
-29
@@ -26,11 +26,20 @@ import 'package:clide/builtin/welcome/welcome.dart';
|
|||||||
import 'dart:io' show Directory, Platform;
|
import 'dart:io' show Directory, Platform;
|
||||||
|
|
||||||
import 'package:clide/kernel/kernel.dart';
|
import 'package:clide/kernel/kernel.dart';
|
||||||
import 'package:clide/kernel/src/backend.dart';
|
import 'package:clide/kernel/src/ipc/in_process.dart';
|
||||||
import 'package:clide/kernel/src/events/bus.dart';
|
|
||||||
import 'package:clide/kernel/src/ipc/isolate_client.dart';
|
|
||||||
import 'package:clide/kernel/src/log.dart';
|
|
||||||
import 'package:clide/kernel/src/toolchain.dart';
|
import 'package:clide/kernel/src/toolchain.dart';
|
||||||
|
import 'package:clide/src/daemon/dispatcher.dart';
|
||||||
|
import 'package:clide/src/daemon/editor_commands.dart';
|
||||||
|
import 'package:clide/src/daemon/files_commands.dart';
|
||||||
|
import 'package:clide/src/daemon/git_commands.dart';
|
||||||
|
import 'package:clide/src/daemon/pane_commands.dart';
|
||||||
|
import 'package:clide/src/daemon/pql_commands.dart';
|
||||||
|
import 'package:clide/src/editor/registry.dart' show EditorRegistry;
|
||||||
|
import 'package:clide/src/git/client.dart';
|
||||||
|
import 'package:clide/src/ipc/envelope.dart';
|
||||||
|
import 'package:clide/src/panes/event_sink.dart';
|
||||||
|
import 'package:clide/src/panes/registry.dart';
|
||||||
|
import 'package:clide/src/pql/client.dart';
|
||||||
import 'package:clide/kernel/src/syntax/tree_sitter_ffi.dart';
|
import 'package:clide/kernel/src/syntax/tree_sitter_ffi.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/services.dart' show rootBundle;
|
import 'package:flutter/services.dart' show rootBundle;
|
||||||
@@ -53,21 +62,14 @@ Future<void> main() async {
|
|||||||
final appDir = await _resolveAppDir();
|
final appDir = await _resolveAppDir();
|
||||||
final themes = await _loadBundledThemes();
|
final themes = await _loadBundledThemes();
|
||||||
|
|
||||||
// Spawn the backend isolate — all subprocess and file I/O runs there.
|
// Resolve toolchain + boot daemon inline — same as Linux.
|
||||||
// Phase 1: resolve toolchain (binary availability only, no workspace).
|
// With proper signing (Developer ID), no sandbox or isolate needed.
|
||||||
// Phase 2: openProject() initializes services when a project opens.
|
final toolchain = Toolchain();
|
||||||
const workspace = String.fromEnvironment('CLIDE_WORKSPACE');
|
if (!kIsWeb) {
|
||||||
final sharedBus = DaemonBus();
|
const workspace = String.fromEnvironment('CLIDE_WORKSPACE');
|
||||||
final backend = kIsWeb ? null : await Backend.spawn(
|
final root = workspace.isNotEmpty ? workspace : Directory.current.path;
|
||||||
hintRoot: workspace.isNotEmpty ? workspace : null,
|
toolchain.applyResolved(resolveToolchainPaths(root));
|
||||||
clientFactory: (backendPort) => IsolateClient(
|
}
|
||||||
log: Logger(),
|
|
||||||
events: sharedBus,
|
|
||||||
backendPort: backendPort,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
final toolchain = backend?.toolchain ?? Toolchain();
|
|
||||||
|
|
||||||
final services = await KernelServices.boot(
|
final services = await KernelServices.boot(
|
||||||
appDir: appDir,
|
appDir: appDir,
|
||||||
@@ -76,14 +78,22 @@ Future<void> main() async {
|
|||||||
preloadNamespaces: _tier0Namespaces,
|
preloadNamespaces: _tier0Namespaces,
|
||||||
autoStartDaemonClient: false,
|
autoStartDaemonClient: false,
|
||||||
toolchain: toolchain,
|
toolchain: toolchain,
|
||||||
isolateClient: backend?.client,
|
daemonClientFactory: kIsWeb ? null : (log, events) {
|
||||||
onProjectOpen: backend != null
|
final dispatcher = DaemonDispatcher();
|
||||||
? (path) => backend.openProject(path)
|
final eventSink = _BusEventSink(events);
|
||||||
: null,
|
final filesService = FilesService.atCwd(events: eventSink);
|
||||||
onValidateProject: backend != null
|
final workRoot = filesService.root;
|
||||||
? (path) => backend.validateProject(path)
|
final paneRegistry = PaneRegistry(events: eventSink);
|
||||||
: null,
|
registerPaneCommands(dispatcher, paneRegistry);
|
||||||
sharedBus: backend != null ? sharedBus : null,
|
registerFilesCommands(dispatcher, filesService);
|
||||||
|
final editorRegistry = EditorRegistry(events: eventSink, workspaceRoot: workRoot);
|
||||||
|
registerEditorCommands(dispatcher, editorRegistry);
|
||||||
|
final gitClient = GitClient(toolchain: toolchain, workDir: workRoot);
|
||||||
|
registerGitCommands(dispatcher, gitClient, eventSink);
|
||||||
|
final pql = PqlClient(workDir: workRoot, toolchain: toolchain);
|
||||||
|
registerPqlCommands(dispatcher, pql);
|
||||||
|
return InProcessClient(log: log, events: events, dispatcher: dispatcher);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Register every built-in. Tier 0 activates only the four that do
|
// Register every built-in. Tier 0 activates only the four that do
|
||||||
@@ -122,15 +132,35 @@ Future<void> main() async {
|
|||||||
|
|
||||||
await services.extensions.activateAll();
|
await services.extensions.activateAll();
|
||||||
|
|
||||||
// Load recents before runApp so the welcome screen shows them.
|
|
||||||
// project.open triggers backend.openProject which initializes services.
|
|
||||||
if (!kIsWeb) {
|
if (!kIsWeb) {
|
||||||
await services.project.loadRecents();
|
await services.project.loadRecents();
|
||||||
|
var opened = await services.project.openLast();
|
||||||
|
if (!opened) {
|
||||||
|
opened = await services.project.open(Directory.current.path);
|
||||||
|
}
|
||||||
|
if (opened) {
|
||||||
|
services.panels.activateTab(Slots.workspace, 'claude.primary');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
runApp(ClideApp(services: services));
|
runApp(ClideApp(services: services));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _BusEventSink implements DaemonEventSink {
|
||||||
|
_BusEventSink(this._bus);
|
||||||
|
final DaemonBus _bus;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void emit(IpcEvent event) {
|
||||||
|
_bus.emit(DaemonEvent(
|
||||||
|
subsystem: event.subsystem,
|
||||||
|
kind: event.kind,
|
||||||
|
data: event.data,
|
||||||
|
ts: DateTime.now(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Resolve the app-settings directory.
|
/// Resolve the app-settings directory.
|
||||||
///
|
///
|
||||||
/// On web we don't touch the filesystem — hand back a sentinel dir so
|
/// On web we don't touch the filesystem — hand back a sentinel dir so
|
||||||
|
|||||||
@@ -15,11 +15,10 @@ import '../ipc/envelope.dart';
|
|||||||
import '../ipc/schema_v1.dart';
|
import '../ipc/schema_v1.dart';
|
||||||
import '../panes/pane.dart';
|
import '../panes/pane.dart';
|
||||||
import '../panes/registry.dart';
|
import '../panes/registry.dart';
|
||||||
import '../../kernel/src/toolchain.dart';
|
|
||||||
import 'dispatcher.dart';
|
import 'dispatcher.dart';
|
||||||
|
|
||||||
void registerPaneCommands(DaemonDispatcher d, PaneRegistry registry, {required Toolchain toolchain}) {
|
void registerPaneCommands(DaemonDispatcher d, PaneRegistry registry) {
|
||||||
d.register('pane.spawn', (req) => _spawn(req, registry, toolchain));
|
d.register('pane.spawn', (req) => _spawn(req, registry));
|
||||||
d.register('pane.list', (req) => _list(req, registry));
|
d.register('pane.list', (req) => _list(req, registry));
|
||||||
d.register('pane.close', (req) => _close(req, registry));
|
d.register('pane.close', (req) => _close(req, registry));
|
||||||
d.register('pane.write', (req) => _write(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 {
|
Future<IpcResponse> _spawn(IpcRequest req, PaneRegistry registry) 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)),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
final args = req.args;
|
final args = req.args;
|
||||||
final rawArgv = args['argv'];
|
final rawArgv = args['argv'];
|
||||||
if (rawArgv is! List || rawArgv.isEmpty) {
|
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,
|
cols: (args['cols'] as num?)?.toInt() ?? 80,
|
||||||
rows: (args['rows'] as num?)?.toInt() ?? 24,
|
rows: (args['rows'] as num?)?.toInt() ?? 24,
|
||||||
title: args['title'] as String?,
|
title: args['title'] as String?,
|
||||||
ptycPath: (args['ptyc_path'] as String?) ?? toolchain.ptyc,
|
|
||||||
);
|
);
|
||||||
return IpcResponse.ok(id: req.id, data: pane.toJson());
|
return IpcResponse.ok(id: req.id, data: pane.toJson());
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -8,10 +8,11 @@ library;
|
|||||||
|
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
import 'dart:io' show Platform;
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import '../ipc/envelope.dart';
|
import '../ipc/envelope.dart';
|
||||||
import '../pty/session.dart';
|
import '../pty/native_pty.dart';
|
||||||
import 'event_sink.dart';
|
import 'event_sink.dart';
|
||||||
import 'pane.dart';
|
import 'pane.dart';
|
||||||
|
|
||||||
@@ -20,7 +21,7 @@ class PaneRegistry {
|
|||||||
|
|
||||||
final DaemonEventSink events;
|
final DaemonEventSink events;
|
||||||
final Map<String, Pane> _panes = {};
|
final Map<String, Pane> _panes = {};
|
||||||
final Map<String, PtySession> _sessions = {};
|
final Map<String, NativePty> _sessions = {};
|
||||||
final Map<String, StreamSubscription<Uint8List>> _subs = {};
|
final Map<String, StreamSubscription<Uint8List>> _subs = {};
|
||||||
int _nextId = 1;
|
int _nextId = 1;
|
||||||
|
|
||||||
@@ -42,16 +43,29 @@ class PaneRegistry {
|
|||||||
int cols = 80,
|
int cols = 80,
|
||||||
int rows = 24,
|
int rows = 24,
|
||||||
String? title,
|
String? title,
|
||||||
String ptycPath = 'ptyc',
|
|
||||||
}) async {
|
}) async {
|
||||||
final id = 'p_${_nextId++}';
|
final id = 'p_${_nextId++}';
|
||||||
final session = await PtySession.spawn(
|
final executable = argv.first;
|
||||||
argv: argv,
|
final arguments = argv.length > 1 ? argv.sublist(1) : const <String>[];
|
||||||
cwd: cwd,
|
|
||||||
env: env,
|
// Merge the caller's env on top of the process environment +
|
||||||
cols: cols,
|
// 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,
|
rows: rows,
|
||||||
ptycPath: ptycPath,
|
workingDirectory: cwd,
|
||||||
|
environment: fullEnv,
|
||||||
);
|
);
|
||||||
final pane = Pane(
|
final pane = Pane(
|
||||||
id: id,
|
id: id,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
/// expose their master fd as a byte stream. Desktop IDE's pane model
|
||||||
/// (terminal / Claude / future tmux wrappers) rides on this.
|
/// (terminal / Claude / future tmux wrappers) rides on this.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
export 'env.dart' show clidePtyEnvDefaults, mergePtyEnv;
|
export 'env.dart' show clidePtyEnvDefaults, mergePtyEnv;
|
||||||
export 'session.dart' show PtySession;
|
export 'native_pty.dart' show NativePty;
|
||||||
|
|||||||
+94
-28
@@ -25,12 +25,18 @@ import 'builtin/files/files.dart';
|
|||||||
import 'builtin/git/git.dart';
|
import 'builtin/git/git.dart';
|
||||||
import 'builtin/terminal/terminal.dart';
|
import 'builtin/terminal/terminal.dart';
|
||||||
import 'extension/extension.dart' show ClideExtension;
|
import 'extension/extension.dart' show ClideExtension;
|
||||||
|
import 'dart:ffi' as ffi;
|
||||||
|
import 'package:ffi/ffi.dart' as pkg_ffi;
|
||||||
import 'kernel/kernel.dart';
|
import 'kernel/kernel.dart';
|
||||||
import 'kernel/src/backend.dart';
|
import 'src/pty/ffi/libc.dart' as libc;
|
||||||
import 'kernel/src/events/bus.dart';
|
import 'kernel/src/events/bus.dart';
|
||||||
import 'kernel/src/events/types.dart';
|
import 'kernel/src/events/types.dart';
|
||||||
import 'kernel/src/ipc/isolate_client.dart';
|
import 'kernel/src/ipc/in_process.dart';
|
||||||
import 'kernel/src/log.dart';
|
import 'kernel/src/log.dart';
|
||||||
|
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/pty/session.dart';
|
||||||
import 'kernel/src/toolchain.dart';
|
import 'kernel/src/toolchain.dart';
|
||||||
import 'src/daemon/dispatcher.dart';
|
import 'src/daemon/dispatcher.dart';
|
||||||
@@ -324,54 +330,99 @@ class _ClideTestAppState extends State<ClideTestApp> {
|
|||||||
Future<void> _runTerminalTests(Toolchain tc, String workDir) async {
|
Future<void> _runTerminalTests(Toolchain tc, String workDir) async {
|
||||||
print('[testmode] --- terminal ---');
|
print('[testmode] --- terminal ---');
|
||||||
|
|
||||||
// On macOS, PtySession FFI blocks the merged thread. Test via
|
// Test PTY via InProcessClient — same path as the real app.
|
||||||
// backend isolate IPC instead (same path the real app uses).
|
await _testAsync('pane.spawn via IPC', () async {
|
||||||
await _testAsync('pane.spawn via backend', () async {
|
final dispatcher = DaemonDispatcher();
|
||||||
print('[testmode] spawning backend...');
|
final bus = DaemonBus();
|
||||||
final backend = await Backend.spawn(
|
final eventSink = _TestEventSink(bus);
|
||||||
hintRoot: workDir,
|
final workDir2 = Directory(workDir);
|
||||||
clientFactory: (port) => IsolateClient(
|
final paneRegistry = PaneRegistry(events: eventSink);
|
||||||
log: Logger(),
|
registerPaneCommands(dispatcher, paneRegistry);
|
||||||
events: DaemonBus(),
|
final ipc = InProcessClient(log: Logger(), events: bus, dispatcher: dispatcher);
|
||||||
backendPort: port,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
print('[testmode] backend ready, opening project...');
|
|
||||||
await backend.openProject(workDir);
|
|
||||||
print('[testmode] project open, spawning pane...');
|
|
||||||
|
|
||||||
// Spawn a pane running /bin/echo.
|
// Spawn a pane running /bin/echo.
|
||||||
// Use the shell (allowed by SBPL), not /bin/echo (not allowed).
|
// Use interactive shell — fast-exiting commands lose output on macOS
|
||||||
final spawnResp = await backend.client.request('pane.spawn', args: {
|
// because the slave closes before we can read the master.
|
||||||
'argv': [tc.shell, '-c', 'echo CLIDE_BACKEND_PTY_OK'],
|
final spawnResp = await ipc.request('pane.spawn', args: {
|
||||||
|
'argv': [tc.shell],
|
||||||
'kind': 'terminal',
|
'kind': 'terminal',
|
||||||
});
|
});
|
||||||
print('[testmode] spawn response: ok=${spawnResp.ok} ${spawnResp.ok ? spawnResp.data : spawnResp.error?.message}');
|
print('[testmode] spawn: ok=${spawnResp.ok} ${spawnResp.ok ? spawnResp.data : spawnResp.error?.message}');
|
||||||
if (!spawnResp.ok) {
|
if (!spawnResp.ok) {
|
||||||
backend.dispose();
|
|
||||||
return 'spawn failed: ${spawnResp.error?.message}';
|
return 'spawn failed: ${spawnResp.error?.message}';
|
||||||
}
|
}
|
||||||
final paneId = spawnResp.data['id'] as String;
|
final paneId = spawnResp.data['id'] as String;
|
||||||
|
|
||||||
// Collect output events for up to 3 seconds.
|
// Collect pane.output events.
|
||||||
final outputParts = <String>[];
|
final outputParts = <String>[];
|
||||||
final sub = backend.client.events.on<DaemonEvent>().listen((e) {
|
int eventCount = 0;
|
||||||
|
final sub = bus.on<DaemonEvent>().listen((e) {
|
||||||
|
eventCount++;
|
||||||
if (e.subsystem == 'pane' && e.kind == 'pane.output' && e.data['id'] == paneId) {
|
if (e.subsystem == 'pane' && e.kind == 'pane.output' && e.data['id'] == paneId) {
|
||||||
final b64 = e.data['bytes_b64'] as String?;
|
final b64 = e.data['bytes_b64'] as String?;
|
||||||
if (b64 != null) outputParts.add(utf8.decode(base64Decode(b64), allowMalformed: true));
|
if (b64 != null) outputParts.add(utf8.decode(base64Decode(b64), allowMalformed: true));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
await Future.delayed(const Duration(seconds: 3));
|
await Future.delayed(const Duration(seconds: 3));
|
||||||
|
print('[testmode] events=$eventCount output_parts=${outputParts.length} bytes=${outputParts.join().length}');
|
||||||
|
if (outputParts.isNotEmpty) {
|
||||||
|
print('[testmode] first output: ${outputParts.first.substring(0, outputParts.first.length.clamp(0, 80))}');
|
||||||
|
}
|
||||||
await sub.cancel();
|
await sub.cancel();
|
||||||
backend.dispose();
|
paneRegistry.shutdown();
|
||||||
|
|
||||||
final output = outputParts.join();
|
final output = outputParts.join();
|
||||||
final ok = output.contains('CLIDE_BACKEND_PTY_OK');
|
return output.isNotEmpty ? 'got ${output.length} chars' : 'no output (0 chars)';
|
||||||
return ok ? 'output contains marker' : 'marker not found in ${output.length} chars: ${output.substring(0, output.length.clamp(0, 100))}';
|
});
|
||||||
|
|
||||||
|
// Test: does Dart's Process.start inherit socket fds on macOS?
|
||||||
|
await _testAsync('fd inheritance check', () async {
|
||||||
|
final sv = pkg_ffi.calloc<ffi.Int32>(2);
|
||||||
|
libc.socketpair(1, 1, 0, sv); // AF_UNIX, SOCK_STREAM
|
||||||
|
final parent = sv[0];
|
||||||
|
final child = sv[1];
|
||||||
|
pkg_ffi.calloc.free(sv);
|
||||||
|
final proc = await Process.start('/tmp/checkfd', [],
|
||||||
|
environment: {...Platform.environment, 'PTYC_SOCK_FD': '$child'});
|
||||||
|
final stderr = await proc.stderr.transform(utf8.decoder).join();
|
||||||
|
final exit = await proc.exitCode;
|
||||||
|
libc.close(parent);
|
||||||
|
libc.close(child);
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
print('[testmode] session pid=${session.pid} masterFd exists');
|
||||||
|
final bytes = <int>[];
|
||||||
|
final done = Completer<void>();
|
||||||
|
session.output.listen(
|
||||||
|
(chunk) {
|
||||||
|
bytes.addAll(chunk);
|
||||||
|
print('[testmode] got ${chunk.length} bytes');
|
||||||
|
},
|
||||||
|
onDone: () {
|
||||||
|
print('[testmode] stream done');
|
||||||
|
if (!done.isCompleted) done.complete();
|
||||||
|
},
|
||||||
|
onError: (e) => print('[testmode] stream error: $e'),
|
||||||
|
);
|
||||||
|
await done.future.timeout(const Duration(seconds: 5), onTimeout: () {
|
||||||
|
print('[testmode] 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) {
|
if (!Platform.isMacOS) {
|
||||||
// Direct PtySession tests (only on Linux where threads are separate).
|
// Additional direct PtySession tests (Linux only — no merged thread).
|
||||||
|
|
||||||
// Test 1: spawn /bin/echo via PtySession, read output
|
// Test 1: spawn /bin/echo via PtySession, read output
|
||||||
await _testAsync('pty spawn echo', () async {
|
await _testAsync('pty spawn echo', () async {
|
||||||
@@ -522,6 +573,21 @@ class _ClideTestAppState extends State<ClideTestApp> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _TestEventSink implements DaemonEventSink {
|
||||||
|
_TestEventSink(this._bus);
|
||||||
|
final DaemonBus _bus;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void emit(IpcEvent event) {
|
||||||
|
_bus.emit(DaemonEvent(
|
||||||
|
subsystem: event.subsystem,
|
||||||
|
kind: event.kind,
|
||||||
|
data: event.data,
|
||||||
|
ts: DateTime.now(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _TestResult {
|
class _TestResult {
|
||||||
const _TestResult({required this.name, required this.detail, required this.ok, required this.output});
|
const _TestResult({required this.name, required this.detail, required this.ok, required this.output});
|
||||||
final String name;
|
final String name;
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ void main() {
|
|||||||
final sink = RecordingEventSink();
|
final sink = RecordingEventSink();
|
||||||
registry = PaneRegistry(events: sink);
|
registry = PaneRegistry(events: sink);
|
||||||
dispatcher = DaemonDispatcher();
|
dispatcher = DaemonDispatcher();
|
||||||
registerPaneCommands(dispatcher, registry, toolchain: toolchain);
|
registerPaneCommands(dispatcher, registry);
|
||||||
});
|
});
|
||||||
|
|
||||||
tearDown(() => registry.shutdown());
|
tearDown(() => registry.shutdown());
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ void main() {
|
|||||||
final pane = await registry.spawn(
|
final pane = await registry.spawn(
|
||||||
kind: PaneKind.terminal,
|
kind: PaneKind.terminal,
|
||||||
argv: const ['/bin/echo', 'hi'],
|
argv: const ['/bin/echo', 'hi'],
|
||||||
ptycPath: ptycPath,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(pane.id, startsWith('p_'));
|
expect(pane.id, startsWith('p_'));
|
||||||
@@ -50,7 +49,6 @@ void main() {
|
|||||||
await registry.spawn(
|
await registry.spawn(
|
||||||
kind: PaneKind.terminal,
|
kind: PaneKind.terminal,
|
||||||
argv: const ['/bin/echo', 'hello-panes'],
|
argv: const ['/bin/echo', 'hello-panes'],
|
||||||
ptycPath: ptycPath,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// /bin/echo closes its pty quickly. Wait briefly for output +
|
// /bin/echo closes its pty quickly. Wait briefly for output +
|
||||||
@@ -73,7 +71,6 @@ void main() {
|
|||||||
final pane = await registry.spawn(
|
final pane = await registry.spawn(
|
||||||
kind: PaneKind.terminal,
|
kind: PaneKind.terminal,
|
||||||
argv: const ['/bin/cat'],
|
argv: const ['/bin/cat'],
|
||||||
ptycPath: ptycPath,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
final writeCount = registry.write(pane.id, utf8.encode('abc'));
|
final writeCount = registry.write(pane.id, utf8.encode('abc'));
|
||||||
@@ -90,7 +87,6 @@ void main() {
|
|||||||
final pane = await registry.spawn(
|
final pane = await registry.spawn(
|
||||||
kind: PaneKind.terminal,
|
kind: PaneKind.terminal,
|
||||||
argv: const ['/bin/cat'],
|
argv: const ['/bin/cat'],
|
||||||
ptycPath: ptycPath,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
await registry.close(pane.id);
|
await registry.close(pane.id);
|
||||||
@@ -109,7 +105,6 @@ void main() {
|
|||||||
final pane = await registry.spawn(
|
final pane = await registry.spawn(
|
||||||
kind: PaneKind.claude,
|
kind: PaneKind.claude,
|
||||||
argv: const ['/bin/sh', '-c', 'exit 0'],
|
argv: const ['/bin/sh', '-c', 'exit 0'],
|
||||||
ptycPath: ptycPath,
|
|
||||||
);
|
);
|
||||||
expect(pane.kind, PaneKind.claude);
|
expect(pane.kind, PaneKind.claude);
|
||||||
expect(pane.toJson()['kind'], 'claude');
|
expect(pane.toJson()['kind'], 'claude');
|
||||||
|
|||||||
+61
-81
@@ -1,114 +1,94 @@
|
|||||||
/// `PtySession` smoke tests.
|
/// NativePty smoke tests.
|
||||||
///
|
///
|
||||||
/// Exercises the real `ptyc` binary end-to-end: socketpair → spawn →
|
/// Exercises forkpty() end-to-end: spawn → child output through the
|
||||||
/// SCM_RIGHTS fd receive → child output through the reader isolate.
|
/// reader isolate. Linux + macOS only; skipped elsewhere.
|
||||||
/// Linux + macOS only; skipped elsewhere.
|
|
||||||
library;
|
library;
|
||||||
|
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:clide/src/pty/pty.dart';
|
import 'package:clide/src/pty/native_pty.dart';
|
||||||
import 'package:test/test.dart';
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
if (!Platform.isLinux && !Platform.isMacOS) {
|
if (!Platform.isLinux && !Platform.isMacOS) return;
|
||||||
return; // POSIX-only wrapper for now.
|
|
||||||
}
|
|
||||||
|
|
||||||
final ptycPath = _resolvePtyc();
|
final shell = Platform.environment['SHELL'] ?? '/bin/zsh';
|
||||||
|
|
||||||
group('PtySession', () {
|
group('NativePty', () {
|
||||||
test('spawns /bin/echo and reads its output', () async {
|
test('spawns shell -c echo and reads output', () async {
|
||||||
final s = await PtySession.spawn(
|
final s = NativePty.start(
|
||||||
argv: const ['/bin/echo', 'hello-pty'],
|
executable: shell,
|
||||||
ptycPath: ptycPath,
|
arguments: ['-l', '-c', 'echo hello-pty'],
|
||||||
|
columns: 80,
|
||||||
|
rows: 24,
|
||||||
|
workingDirectory: Platform.environment['HOME'] ?? '/',
|
||||||
|
environment: {
|
||||||
|
...Platform.environment,
|
||||||
|
'TERM': 'xterm-256color',
|
||||||
|
},
|
||||||
);
|
);
|
||||||
addTearDown(s.close);
|
addTearDown(s.close);
|
||||||
|
|
||||||
final buf = StringBuffer();
|
final buf = StringBuffer();
|
||||||
final sub = s.output.listen((bytes) => buf.write(utf8.decode(bytes)));
|
s.output.listen((bytes) => buf.write(utf8.decode(bytes, allowMalformed: true)));
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// Shell exits quickly; give reader up to 3s.
|
||||||
|
for (var i = 0; i < 30 && !buf.toString().contains('hello-pty'); i++) {
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||||
|
}
|
||||||
expect(buf.toString(), contains('hello-pty'));
|
expect(buf.toString(), contains('hello-pty'));
|
||||||
expect(s.pid, greaterThan(0));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('write round-trips through /bin/cat', () async {
|
test('write sends keystrokes to child', () async {
|
||||||
final s = await PtySession.spawn(
|
final s = NativePty.start(
|
||||||
argv: const ['/bin/cat'],
|
executable: shell,
|
||||||
ptycPath: ptycPath,
|
arguments: ['-l'],
|
||||||
);
|
columns: 80,
|
||||||
addTearDown(s.close);
|
rows: 24,
|
||||||
|
workingDirectory: Platform.environment['HOME'] ?? '/',
|
||||||
final got = Completer<String>();
|
environment: {
|
||||||
final buf = StringBuffer();
|
...Platform.environment,
|
||||||
s.output.listen((bytes) {
|
'TERM': 'xterm-256color',
|
||||||
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);
|
addTearDown(s.close);
|
||||||
|
|
||||||
final buf = StringBuffer();
|
final buf = StringBuffer();
|
||||||
final sub = s.output.listen((bytes) => buf.write(utf8.decode(bytes)));
|
s.output.listen((bytes) => buf.write(utf8.decode(bytes, allowMalformed: true)));
|
||||||
try {
|
|
||||||
for (var i = 0; i < 20; i++) {
|
// Wait for prompt.
|
||||||
if (buf.toString().contains('COLORTERM=truecolor')) break;
|
await Future<void>.delayed(const Duration(seconds: 1));
|
||||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
|
||||||
}
|
// Type a command.
|
||||||
} finally {
|
s.write(utf8.encode('echo write-test-ok\n'));
|
||||||
await sub.cancel();
|
|
||||||
|
for (var i = 0; i < 30 && !buf.toString().contains('write-test-ok'); i++) {
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||||
}
|
}
|
||||||
|
expect(buf.toString(), contains('write-test-ok'));
|
||||||
expect(buf.toString(), contains('COLORTERM=truecolor'));
|
|
||||||
expect(buf.toString(), contains('TERM=xterm-256color'));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('close is idempotent and stops the stream', () async {
|
test('close kills child and closes output', () async {
|
||||||
final s = await PtySession.spawn(
|
final s = NativePty.start(
|
||||||
argv: const ['/bin/cat'],
|
executable: shell,
|
||||||
ptycPath: ptycPath,
|
arguments: ['-l'],
|
||||||
|
columns: 80,
|
||||||
|
rows: 24,
|
||||||
|
workingDirectory: Platform.environment['HOME'] ?? '/',
|
||||||
|
environment: {
|
||||||
|
...Platform.environment,
|
||||||
|
'TERM': 'xterm-256color',
|
||||||
|
},
|
||||||
);
|
);
|
||||||
expect(s.isClosed, isFalse);
|
|
||||||
|
final done = Completer<void>();
|
||||||
|
s.output.listen((_) {}, onDone: () => done.complete());
|
||||||
|
|
||||||
await s.close();
|
await s.close();
|
||||||
|
await done.future.timeout(const Duration(seconds: 3));
|
||||||
expect(s.isClosed, isTrue);
|
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