port clide runtime to Windows (ConPTY, AF_UNIX, PATHEXT)
Bring the runtime up on Windows without disturbing the POSIX paths. PTY: introduce a platform-neutral PtySession contract with a factory that picks NativePty (posix_openpt/posix_spawn) or the new WindowsPty (ConPTY via CreatePseudoConsole). The pane registry programs against the interface; NativePty now implements it. IPC: the per-workspace AF_UNIX socket lives under %LOCALAPPDATA% and is hashed from a canonical workspace key (backslash + ASCII-folded case) so the Dart server and the C client agree despite NTFS case- insensitivity. The C client grows a Win32 shim (winsock afunix); chmod is a no-op on Windows where the per-user ACL is the gate. Toolchain: PATH probing splits on ';' and tries PATHEXT extensions; the shell defaults to PowerShell (pwsh, then powershell); tmux is treated as optional since it has no Windows build; dugite falls back to PATH git for now. Build: add `make build-windows`, a clide-cli MSVC build wrapped by ci/build_cli_windows.sh, and a ConPTY smoke-test suite that self- skips off-platform. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
+31
-5
@@ -4,21 +4,47 @@ import 'dart:io';
|
||||
/// Resolve the per-workspace Unix-domain socket path served by the
|
||||
/// running clide app. Per D-70:
|
||||
///
|
||||
/// Linux: `$XDG_RUNTIME_DIR/clide/<hash>.sock`
|
||||
/// macOS: `$HOME/Library/Caches/clide/<hash>.sock`
|
||||
/// Linux: `$XDG_RUNTIME_DIR/clide/<hash>.sock`
|
||||
/// macOS: `$HOME/Library/Caches/clide/<hash>.sock`
|
||||
/// Windows: `%LOCALAPPDATA%\clide\<hash>.sock` (AF_UNIX — supported
|
||||
/// by winsock since Windows 10 1803 and by dart:io)
|
||||
///
|
||||
/// The C `clide` client and any other consumer derive the same path
|
||||
/// from the same workspace root, so server + client always agree
|
||||
/// without configuration.
|
||||
String workspaceSocketPath(String workspaceRoot) {
|
||||
final dir = socketDirectory();
|
||||
return '$dir/${_hash(workspaceRoot)}.sock';
|
||||
return '$dir/${_hash(canonicalWorkspaceKey(workspaceRoot))}.sock';
|
||||
}
|
||||
|
||||
/// Canonical form of the workspace root used as the FNV hash input.
|
||||
///
|
||||
/// On Windows one directory has many spellings — either slash kind,
|
||||
/// any letter case (NTFS is case-insensitive and getcwd preserves
|
||||
/// whatever the shell typed) — so the server and the C client could
|
||||
/// derive different hashes for the same workspace. Backslash +
|
||||
/// ASCII-lower-case is the canonical spelling; the C client applies
|
||||
/// the same byte-level fold (which is why this is NOT Unicode
|
||||
/// `toLowerCase()` — the fold must be reproducible over raw UTF-8
|
||||
/// bytes in C). POSIX paths pass through untouched.
|
||||
String canonicalWorkspaceKey(String workspaceRoot) {
|
||||
if (!Platform.isWindows) return workspaceRoot;
|
||||
final folded = workspaceRoot.replaceAll('/', r'\');
|
||||
final units = folded.codeUnits.map((u) => (u >= 0x41 && u <= 0x5a) ? u + 0x20 : u).toList();
|
||||
return String.fromCharCodes(units);
|
||||
}
|
||||
|
||||
/// Parent directory that holds every per-workspace socket for this
|
||||
/// user. Created with `0700` on bind (see D-71). Exposed separately
|
||||
/// so the server can prepare/perm-fix the directory before binding.
|
||||
/// user. Created with `0700` on bind (see D-71; on Windows the
|
||||
/// per-user ACL on `%LOCALAPPDATA%` is the equivalent gate). Exposed
|
||||
/// separately so the server can prepare/perm-fix the directory before
|
||||
/// binding.
|
||||
String socketDirectory() {
|
||||
if (Platform.isWindows) {
|
||||
final local = Platform.environment['LOCALAPPDATA'];
|
||||
final base = (local != null && local.isNotEmpty) ? local : '${Platform.environment['USERPROFILE'] ?? r'C:\'}\\AppData\\Local';
|
||||
return '$base\\clide';
|
||||
}
|
||||
if (Platform.isMacOS) {
|
||||
final home = Platform.environment['HOME'] ?? '/tmp';
|
||||
return '$home/Library/Caches/clide';
|
||||
|
||||
@@ -410,8 +410,12 @@ class IpcServer {
|
||||
// -- internals ------------------------------------------------------------
|
||||
|
||||
/// `chmod` via `chmod(1)` because dart:io doesn't expose the
|
||||
/// syscall on unix. Cheap; only runs at start/stop.
|
||||
/// syscall on unix. Cheap; only runs at start/stop. No-op on
|
||||
/// Windows: POSIX modes don't exist there, and the socket lives
|
||||
/// under `%LOCALAPPDATA%`, whose per-user ACL already provides the
|
||||
/// user-only gate D-71 wants.
|
||||
Future<void> _chmod(String path, int modeBits) async {
|
||||
if (Platform.isWindows) return;
|
||||
final octal = modeBits.toRadixString(8).padLeft(3, '0');
|
||||
final r = await Process.run('chmod', [octal, path]);
|
||||
if (r.exitCode != 0) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/// [PaneRegistry] — backend-side state for all live panes.
|
||||
///
|
||||
/// Owns the [NativePty] per pane, generates `p_N` ids, and forwards
|
||||
/// Owns the [PtySession] 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.
|
||||
@@ -12,7 +12,7 @@ import 'dart:io' show Platform;
|
||||
import 'dart:typed_data';
|
||||
|
||||
import '../ipc/envelope.dart';
|
||||
import '../pty/native_pty.dart';
|
||||
import '../pty/pty_session.dart';
|
||||
import 'event_sink.dart';
|
||||
import 'pane.dart';
|
||||
|
||||
@@ -21,7 +21,7 @@ class PaneRegistry {
|
||||
|
||||
final DaemonEventSink events;
|
||||
final Map<String, Pane> _panes = {};
|
||||
final Map<String, NativePty> _sessions = {};
|
||||
final Map<String, PtySession> _sessions = {};
|
||||
final Map<String, StreamSubscription<Uint8List>> _subs = {};
|
||||
int _nextId = 1;
|
||||
|
||||
@@ -55,7 +55,7 @@ class PaneRegistry {
|
||||
...?env,
|
||||
};
|
||||
|
||||
final session = NativePty.start(executable: executable, arguments: arguments, columns: cols, rows: rows, workingDirectory: cwd, environment: fullEnv);
|
||||
final session = startPtySession(executable: executable, arguments: arguments, columns: cols, rows: rows, workingDirectory: cwd, environment: fullEnv);
|
||||
final pane = Pane(id: id, kind: kind, pid: session.pid, argv: argv, cwd: cwd, title: title);
|
||||
_panes[id] = pane;
|
||||
_sessions[id] = session;
|
||||
|
||||
@@ -27,6 +27,7 @@ import 'package:ffi/ffi.dart';
|
||||
import 'errors.dart';
|
||||
import '../ipc/errno_mapping.dart' show PosixErrno;
|
||||
import 'ffi/libc.dart' as libc;
|
||||
import 'pty_session.dart';
|
||||
|
||||
// -- structs ----------------------------------------------------------------
|
||||
|
||||
@@ -137,8 +138,9 @@ const _kWnohang = 1;
|
||||
// -- NativePty --------------------------------------------------------------
|
||||
|
||||
/// A pseudo-terminal backed by forkpty() via Dart FFI.
|
||||
class NativePty {
|
||||
class NativePty implements PtySession {
|
||||
final int _fd;
|
||||
@override
|
||||
final int pid;
|
||||
final _out = StreamController<Uint8List>.broadcast();
|
||||
bool _dead = false;
|
||||
@@ -153,8 +155,10 @@ class NativePty {
|
||||
NativePty._(this._fd, this.pid);
|
||||
|
||||
/// Byte stream of data produced by the child.
|
||||
@override
|
||||
Stream<Uint8List> get output => _out.stream;
|
||||
|
||||
@override
|
||||
bool get isClosed => _dead;
|
||||
|
||||
/// Spawn a new PTY running [executable] with [arguments].
|
||||
@@ -393,6 +397,7 @@ class NativePty {
|
||||
/// Write bytes to the child's stdin. Loops on short writes; throws
|
||||
/// [PtyException] (with errno) on failure. Returns the total bytes
|
||||
/// written, which is always [bytes.length] on success.
|
||||
@override
|
||||
int write(List<int> bytes) {
|
||||
if (_dead || bytes.isEmpty) return 0;
|
||||
final buf = malloc<ffi.Uint8>(bytes.length);
|
||||
@@ -420,6 +425,7 @@ class NativePty {
|
||||
|
||||
/// Resize the terminal. Silently no-ops if the fd is already
|
||||
/// closed; flips [_dead] on EBADF so subsequent calls short-circuit.
|
||||
@override
|
||||
void resize({required int cols, required int rows}) {
|
||||
if (_dead) return;
|
||||
final ws = calloc<_Winsize>()
|
||||
@@ -435,10 +441,11 @@ class NativePty {
|
||||
_nativeKill(pid, libc.sigwinch);
|
||||
}
|
||||
|
||||
/// Send a signal to the child.
|
||||
bool kill([int signal = libc.sighup]) {
|
||||
/// Send a signal to the child. Null means SIGHUP.
|
||||
@override
|
||||
bool kill([int? signal]) {
|
||||
if (_dead) return false;
|
||||
return _nativeKill(pid, signal) == 0;
|
||||
return _nativeKill(pid, signal ?? libc.sighup) == 0;
|
||||
}
|
||||
|
||||
void _reap() {
|
||||
@@ -457,6 +464,7 @@ class NativePty {
|
||||
/// closing it before the isolate exits creates a window where the
|
||||
/// fd number could be reused and the isolate would briefly poll
|
||||
/// the wrong file.
|
||||
@override
|
||||
Future<void> close() async {
|
||||
if (_dead) return;
|
||||
_dead = true;
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
/// PTY subsystem — spawn child processes under a PTY via posix_openpt()
|
||||
/// + posix_spawn(), expose their master fd as a byte stream. Desktop
|
||||
/// IDE's pane model (terminal / Claude / future tmux wrappers) rides on
|
||||
/// this.
|
||||
/// PTY subsystem — spawn child processes under a PTY and expose their
|
||||
/// output as a byte stream. POSIX uses posix_openpt() + posix_spawn();
|
||||
/// Windows uses ConPTY. Desktop IDE's pane model (terminal / Claude /
|
||||
/// future tmux wrappers) rides on this.
|
||||
library;
|
||||
|
||||
export 'env.dart' show clidePtyEnvDefaults, mergePtyEnv;
|
||||
export 'native_pty.dart' show NativePty;
|
||||
export 'pty_session.dart' show PtySession, startPtySession;
|
||||
export 'windows_pty.dart' show WindowsPty;
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
/// Platform-neutral PTY session contract + factory.
|
||||
///
|
||||
/// The pane registry (and anything else that spawns PTY children)
|
||||
/// programs against [PtySession]; [startPtySession] picks the
|
||||
/// platform backend — `posix_openpt` + `posix_spawn` on Linux/macOS
|
||||
/// ([NativePty]), ConPTY on Windows ([WindowsPty]). Both backends
|
||||
/// share the same lifecycle: spawn → byte stream out → write/resize
|
||||
/// in → EOF on child exit → close() reaps.
|
||||
library;
|
||||
|
||||
import 'dart:io' show Platform;
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'native_pty.dart';
|
||||
import 'windows_pty.dart';
|
||||
|
||||
abstract interface class PtySession {
|
||||
/// OS process id of the spawned child.
|
||||
int get pid;
|
||||
|
||||
/// Byte stream of data produced by the child.
|
||||
Stream<Uint8List> get output;
|
||||
|
||||
bool get isClosed;
|
||||
|
||||
/// Write bytes to the child's stdin. Returns the bytes written.
|
||||
int write(List<int> bytes);
|
||||
|
||||
/// Resize the terminal.
|
||||
void resize({required int cols, required int rows});
|
||||
|
||||
/// Signal the child. [signal] is a POSIX signal number; backends
|
||||
/// without signals (Windows) treat any value as terminate. Null
|
||||
/// means the backend's default hang-up behaviour.
|
||||
bool kill([int? signal]);
|
||||
|
||||
/// Kill the child and release resources.
|
||||
Future<void> close();
|
||||
}
|
||||
|
||||
/// Spawn a child under a PTY using the platform backend.
|
||||
///
|
||||
/// [environment] must be the complete environment — it goes straight
|
||||
/// to the child. Merge `Platform.environment` before calling.
|
||||
PtySession startPtySession({
|
||||
required String executable,
|
||||
List<String> arguments = const [],
|
||||
required int columns,
|
||||
required int rows,
|
||||
String? workingDirectory,
|
||||
Map<String, String> environment = const {},
|
||||
}) {
|
||||
if (Platform.isWindows) {
|
||||
return WindowsPty.start(
|
||||
executable: executable,
|
||||
arguments: arguments,
|
||||
columns: columns,
|
||||
rows: rows,
|
||||
workingDirectory: workingDirectory,
|
||||
environment: environment,
|
||||
);
|
||||
}
|
||||
return NativePty.start(
|
||||
executable: executable,
|
||||
arguments: arguments,
|
||||
columns: columns,
|
||||
rows: rows,
|
||||
workingDirectory: workingDirectory,
|
||||
environment: environment,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,662 @@
|
||||
/// Native PTY on Windows via ConPTY (`CreatePseudoConsole`).
|
||||
///
|
||||
/// Mirrors the POSIX [NativePty] lifecycle (see `native_pty.dart`):
|
||||
/// spawn a child attached to a pseudo-console, surface its output as
|
||||
/// a byte stream, accept writes / resizes / kills, reap on close.
|
||||
///
|
||||
/// The Win32 sequence:
|
||||
///
|
||||
/// 1. Two anonymous pipes — one ConPTY reads child input from, one
|
||||
/// it writes rendered VT output to.
|
||||
/// 2. `CreatePseudoConsole(size, inRead, outWrite)` → `HPCON`. The
|
||||
/// conpty-side ends (`inRead` / `outWrite`) must stay open for
|
||||
/// the pseudo console's whole lifetime: on current Windows 11
|
||||
/// the conpty host runs IN-PROCESS and uses these very handles
|
||||
/// (the old "conhost dups them, close immediately" advice from
|
||||
/// the EchoCon sample era silently breaks output — the freed
|
||||
/// handle slot gets recycled and conhost writes land wherever
|
||||
/// it now points, observed empirically as output appearing on
|
||||
/// the parent's console).
|
||||
/// 3. `CreateProcessW` with `EXTENDED_STARTUPINFO_PRESENT`, the
|
||||
/// `HPCON` attached via `PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE`,
|
||||
/// and `STARTF_USESTDHANDLES` with NULL std handles — without
|
||||
/// that a console parent's std handles leak into the child and
|
||||
/// its stdout bypasses the conpty entirely (also empirical; the
|
||||
/// conpty handshake still fires, which makes it look attached).
|
||||
/// 4. A reader isolate blocks on `ReadFile(outRead)`; a waiter
|
||||
/// isolate blocks on `WaitForSingleObject(hProcess, INFINITE)`.
|
||||
/// On child exit the waiter reports back and the main isolate
|
||||
/// calls `ClosePseudoConsole` and closes the conpty-side pipe
|
||||
/// ends — that breaks the output pipe, so the reader drains
|
||||
/// whatever is still buffered, sees `ERROR_BROKEN_PIPE`, and
|
||||
/// sends EOF.
|
||||
///
|
||||
/// Requires Windows 10 1809+ (first ConPTY release). All symbols
|
||||
/// live in kernel32.dll. Errors carry `GetLastError()` in
|
||||
/// [PtyException.errno] (a Win32 error code, not a POSIX errno).
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:ffi' as ffi;
|
||||
import 'dart:io' show File, Platform;
|
||||
import 'dart:isolate';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:ffi/ffi.dart';
|
||||
|
||||
import 'errors.dart';
|
||||
import 'pty_session.dart';
|
||||
|
||||
// -- structs ----------------------------------------------------------------
|
||||
|
||||
/// Win32 `COORD` — passed BY VALUE to Create/ResizePseudoConsole.
|
||||
final class _Coord extends ffi.Struct {
|
||||
@ffi.Int16()
|
||||
external int x;
|
||||
@ffi.Int16()
|
||||
external int y;
|
||||
}
|
||||
|
||||
/// Win32 `STARTUPINFOEXW`. Field names follow the Win32 struct so the
|
||||
/// layout is checkable against `<processthreadsapi.h>`; Dart FFI derives
|
||||
/// offsets from declaration order + C alignment rules, which match MSVC
|
||||
/// here (cb is followed by 4 bytes of padding before the first pointer).
|
||||
final class _StartupInfoExW extends ffi.Struct {
|
||||
@ffi.Uint32()
|
||||
external int cb;
|
||||
external ffi.Pointer<ffi.Void> lpReserved;
|
||||
external ffi.Pointer<ffi.Void> lpDesktop;
|
||||
external ffi.Pointer<ffi.Void> lpTitle;
|
||||
@ffi.Uint32()
|
||||
external int dwX;
|
||||
@ffi.Uint32()
|
||||
external int dwY;
|
||||
@ffi.Uint32()
|
||||
external int dwXSize;
|
||||
@ffi.Uint32()
|
||||
external int dwYSize;
|
||||
@ffi.Uint32()
|
||||
external int dwXCountChars;
|
||||
@ffi.Uint32()
|
||||
external int dwYCountChars;
|
||||
@ffi.Uint32()
|
||||
external int dwFillAttribute;
|
||||
@ffi.Uint32()
|
||||
external int dwFlags;
|
||||
@ffi.Uint16()
|
||||
external int wShowWindow;
|
||||
@ffi.Uint16()
|
||||
external int cbReserved2;
|
||||
external ffi.Pointer<ffi.Void> lpReserved2;
|
||||
external ffi.Pointer<ffi.Void> hStdInput;
|
||||
external ffi.Pointer<ffi.Void> hStdOutput;
|
||||
external ffi.Pointer<ffi.Void> hStdError;
|
||||
external ffi.Pointer<ffi.Void> lpAttributeList;
|
||||
}
|
||||
|
||||
/// Win32 `PROCESS_INFORMATION`.
|
||||
final class _ProcessInformation extends ffi.Struct {
|
||||
external ffi.Pointer<ffi.Void> hProcess;
|
||||
external ffi.Pointer<ffi.Void> hThread;
|
||||
@ffi.Uint32()
|
||||
external int dwProcessId;
|
||||
@ffi.Uint32()
|
||||
external int dwThreadId;
|
||||
}
|
||||
|
||||
// -- FFI bindings -----------------------------------------------------------
|
||||
|
||||
final ffi.DynamicLibrary _k32 = ffi.DynamicLibrary.open('kernel32.dll');
|
||||
|
||||
typedef _Handle = ffi.Pointer<ffi.Void>;
|
||||
|
||||
final _createPipe = _k32
|
||||
.lookupFunction<
|
||||
ffi.Int32 Function(ffi.Pointer<_Handle>, ffi.Pointer<_Handle>, ffi.Pointer<ffi.Void>, ffi.Uint32),
|
||||
int Function(ffi.Pointer<_Handle>, ffi.Pointer<_Handle>, ffi.Pointer<ffi.Void>, int)
|
||||
>('CreatePipe');
|
||||
|
||||
final _createPseudoConsole = _k32
|
||||
.lookupFunction<
|
||||
ffi.Int32 Function(_Coord, _Handle, _Handle, ffi.Uint32, ffi.Pointer<_Handle>),
|
||||
int Function(_Coord, _Handle, _Handle, int, ffi.Pointer<_Handle>)
|
||||
>('CreatePseudoConsole');
|
||||
|
||||
final _resizePseudoConsole = _k32.lookupFunction<ffi.Int32 Function(_Handle, _Coord), int Function(_Handle, _Coord)>('ResizePseudoConsole');
|
||||
|
||||
final _closePseudoConsole = _k32.lookupFunction<ffi.Void Function(_Handle), void Function(_Handle)>('ClosePseudoConsole');
|
||||
|
||||
final _initAttrList = _k32
|
||||
.lookupFunction<
|
||||
ffi.Int32 Function(ffi.Pointer<ffi.Void>, ffi.Uint32, ffi.Uint32, ffi.Pointer<ffi.IntPtr>),
|
||||
int Function(ffi.Pointer<ffi.Void>, int, int, ffi.Pointer<ffi.IntPtr>)
|
||||
>('InitializeProcThreadAttributeList');
|
||||
|
||||
final _updateAttr = _k32
|
||||
.lookupFunction<
|
||||
ffi.Int32 Function(ffi.Pointer<ffi.Void>, ffi.Uint32, ffi.IntPtr, ffi.Pointer<ffi.Void>, ffi.IntPtr, ffi.Pointer<ffi.Void>, ffi.Pointer<ffi.Void>),
|
||||
int Function(ffi.Pointer<ffi.Void>, int, int, ffi.Pointer<ffi.Void>, int, ffi.Pointer<ffi.Void>, ffi.Pointer<ffi.Void>)
|
||||
>('UpdateProcThreadAttribute');
|
||||
|
||||
final _deleteAttrList = _k32.lookupFunction<ffi.Void Function(ffi.Pointer<ffi.Void>), void Function(ffi.Pointer<ffi.Void>)>('DeleteProcThreadAttributeList');
|
||||
|
||||
final _createProcessW = _k32
|
||||
.lookupFunction<
|
||||
ffi.Int32 Function(
|
||||
ffi.Pointer<Utf16>,
|
||||
ffi.Pointer<Utf16>,
|
||||
ffi.Pointer<ffi.Void>,
|
||||
ffi.Pointer<ffi.Void>,
|
||||
ffi.Int32,
|
||||
ffi.Uint32,
|
||||
ffi.Pointer<ffi.Void>,
|
||||
ffi.Pointer<Utf16>,
|
||||
ffi.Pointer<_StartupInfoExW>,
|
||||
ffi.Pointer<_ProcessInformation>,
|
||||
),
|
||||
int Function(
|
||||
ffi.Pointer<Utf16>,
|
||||
ffi.Pointer<Utf16>,
|
||||
ffi.Pointer<ffi.Void>,
|
||||
ffi.Pointer<ffi.Void>,
|
||||
int,
|
||||
int,
|
||||
ffi.Pointer<ffi.Void>,
|
||||
ffi.Pointer<Utf16>,
|
||||
ffi.Pointer<_StartupInfoExW>,
|
||||
ffi.Pointer<_ProcessInformation>,
|
||||
)
|
||||
>('CreateProcessW');
|
||||
|
||||
final _writeFile = _k32
|
||||
.lookupFunction<
|
||||
ffi.Int32 Function(_Handle, ffi.Pointer<ffi.Uint8>, ffi.Uint32, ffi.Pointer<ffi.Uint32>, ffi.Pointer<ffi.Void>),
|
||||
int Function(_Handle, ffi.Pointer<ffi.Uint8>, int, ffi.Pointer<ffi.Uint32>, ffi.Pointer<ffi.Void>)
|
||||
>('WriteFile');
|
||||
|
||||
final _closeHandle = _k32.lookupFunction<ffi.Int32 Function(_Handle), int Function(_Handle)>('CloseHandle');
|
||||
|
||||
final _getLastError = _k32.lookupFunction<ffi.Uint32 Function(), int Function()>('GetLastError');
|
||||
|
||||
final _terminateProcess = _k32.lookupFunction<ffi.Int32 Function(_Handle, ffi.Uint32), int Function(_Handle, int)>('TerminateProcess');
|
||||
|
||||
final _getExitCodeProcess = _k32.lookupFunction<ffi.Int32 Function(_Handle, ffi.Pointer<ffi.Uint32>), int Function(_Handle, ffi.Pointer<ffi.Uint32>)>(
|
||||
'GetExitCodeProcess',
|
||||
);
|
||||
|
||||
// Constants — duplicated from <processthreadsapi.h> / <winbase.h>.
|
||||
const int _kExtendedStartupinfoPresent = 0x00080000;
|
||||
const int _kCreateUnicodeEnvironment = 0x00000400;
|
||||
const int _kProcThreadAttributePseudoconsole = 0x00020016;
|
||||
const int _kInfinite = 0xffffffff;
|
||||
const int _kErrorBrokenPipe = 109;
|
||||
const int _kStartfUseStdHandles = 0x00000100;
|
||||
|
||||
// -- WindowsPty -------------------------------------------------------------
|
||||
|
||||
/// A pseudo-terminal backed by ConPTY via Dart FFI.
|
||||
class WindowsPty implements PtySession {
|
||||
WindowsPty._(this._hpc, this._hProcess, this._hThread, this._inWrite, this._outRead, this._conptyInRead, this._conptyOutWrite, this.pid);
|
||||
|
||||
/// HPCON — owned until [close] / child exit.
|
||||
ffi.Pointer<ffi.Void> _hpc;
|
||||
final ffi.Pointer<ffi.Void> _hProcess;
|
||||
final ffi.Pointer<ffi.Void> _hThread;
|
||||
|
||||
/// Our end of the child-stdin pipe (we write, ConPTY reads).
|
||||
final ffi.Pointer<ffi.Void> _inWrite;
|
||||
|
||||
/// Our end of the child-stdout pipe (ConPTY writes, we read).
|
||||
final ffi.Pointer<ffi.Void> _outRead;
|
||||
|
||||
/// The conpty-side pipe ends. Open for the HPCON's lifetime — the
|
||||
/// in-process conpty host uses them directly; released together
|
||||
/// with it in [_closeConsole]. Closing our `_conptyOutWrite` copy
|
||||
/// is also what finally breaks the pipe for the reader's EOF.
|
||||
final ffi.Pointer<ffi.Void> _conptyInRead;
|
||||
final ffi.Pointer<ffi.Void> _conptyOutWrite;
|
||||
|
||||
@override
|
||||
final int pid;
|
||||
|
||||
final _out = StreamController<Uint8List>.broadcast();
|
||||
bool _dead = false;
|
||||
bool _handlesReleased = false;
|
||||
|
||||
Future<void>? _readerReady;
|
||||
Isolate? _readerIsolate;
|
||||
ReceivePort? _readerPort;
|
||||
Completer<void>? _readerExited;
|
||||
ReceivePort? _waiterPort;
|
||||
|
||||
@override
|
||||
Stream<Uint8List> get output => _out.stream;
|
||||
|
||||
@override
|
||||
bool get isClosed => _dead;
|
||||
|
||||
/// Spawn a new ConPTY running [executable] with [arguments].
|
||||
///
|
||||
/// [environment] must be the complete environment — it becomes the
|
||||
/// child's whole environment block. Merge `Platform.environment`
|
||||
/// before calling.
|
||||
static WindowsPty start({
|
||||
required String executable,
|
||||
List<String> arguments = const [],
|
||||
required int columns,
|
||||
required int rows,
|
||||
String? workingDirectory,
|
||||
Map<String, String> environment = const {},
|
||||
}) {
|
||||
executable = _resolveExecutable(executable, environment);
|
||||
|
||||
// ---- Pipes + pseudo console ---------------------------------------
|
||||
final ha = calloc<_Handle>();
|
||||
final hb = calloc<_Handle>();
|
||||
if (_createPipe(ha, hb, ffi.nullptr, 0) == 0) {
|
||||
final err = _getLastError();
|
||||
calloc.free(ha);
|
||||
calloc.free(hb);
|
||||
throw PtyException('CreatePipe', 'stdin pipe creation failed', errno: err);
|
||||
}
|
||||
final inRead = ha.value;
|
||||
final inWrite = hb.value;
|
||||
if (_createPipe(ha, hb, ffi.nullptr, 0) == 0) {
|
||||
final err = _getLastError();
|
||||
_closeHandle(inRead);
|
||||
_closeHandle(inWrite);
|
||||
calloc.free(ha);
|
||||
calloc.free(hb);
|
||||
throw PtyException('CreatePipe', 'stdout pipe creation failed', errno: err);
|
||||
}
|
||||
final outRead = ha.value;
|
||||
final outWrite = hb.value;
|
||||
calloc.free(ha);
|
||||
calloc.free(hb);
|
||||
|
||||
final size = calloc<_Coord>()
|
||||
..ref.x = columns
|
||||
..ref.y = rows;
|
||||
final hpcOut = calloc<_Handle>();
|
||||
final hr = _createPseudoConsole(size.ref, inRead, outWrite, 0, hpcOut);
|
||||
calloc.free(size);
|
||||
if (hr != 0) {
|
||||
_closeHandle(inRead);
|
||||
_closeHandle(inWrite);
|
||||
_closeHandle(outRead);
|
||||
_closeHandle(outWrite);
|
||||
calloc.free(hpcOut);
|
||||
throw PtyException('CreatePseudoConsole', 'HRESULT 0x${(hr & 0xffffffff).toRadixString(16)}');
|
||||
}
|
||||
final hpc = hpcOut.value;
|
||||
calloc.free(hpcOut);
|
||||
// inRead / outWrite deliberately stay open — the in-process conpty
|
||||
// uses them for its whole lifetime (see the library docstring).
|
||||
// _closeConsole() releases them together with the HPCON.
|
||||
|
||||
// ---- Attribute list (attaches the HPCON to the child) -------------
|
||||
final sizeOut = calloc<ffi.IntPtr>();
|
||||
_initAttrList(ffi.nullptr, 1, 0, sizeOut); // sizing call; "fails" with ERROR_INSUFFICIENT_BUFFER by design
|
||||
final attrBytes = sizeOut.value;
|
||||
final attrList = calloc<ffi.Uint8>(attrBytes).cast<ffi.Void>();
|
||||
void freeAttrs() {
|
||||
calloc.free(attrList);
|
||||
calloc.free(sizeOut);
|
||||
}
|
||||
|
||||
void bail(String op, String message) {
|
||||
final err = _getLastError();
|
||||
freeAttrs();
|
||||
_closePseudoConsole(hpc);
|
||||
_closeHandle(inRead);
|
||||
_closeHandle(outWrite);
|
||||
_closeHandle(inWrite);
|
||||
_closeHandle(outRead);
|
||||
throw PtyException(op, message, errno: err);
|
||||
}
|
||||
|
||||
if (_initAttrList(attrList, 1, 0, sizeOut) == 0) {
|
||||
bail('InitializeProcThreadAttributeList', 'attribute list init failed');
|
||||
}
|
||||
// The HPCON itself is lpValue — the attribute machinery stores the
|
||||
// pointer, it does NOT copy through it. Passing a pointer-to-slot
|
||||
// here "succeeds" but hands the child a garbage console and ConPTY
|
||||
// silently produces no output. (Matches the EchoCon sample.)
|
||||
if (_updateAttr(attrList, 0, _kProcThreadAttributePseudoconsole, hpc, ffi.sizeOf<_Handle>(), ffi.nullptr, ffi.nullptr) == 0) {
|
||||
_deleteAttrList(attrList);
|
||||
bail('UpdateProcThreadAttribute', 'attaching HPCON failed');
|
||||
}
|
||||
|
||||
// ---- Marshal command line + environment + cwd ----------------------
|
||||
// App name stays null so CreateProcessW does its own first-token
|
||||
// parse (which also gives .bat/.cmd their cmd.exe host); the
|
||||
// executable is pre-resolved to an absolute path above so no PATH
|
||||
// ambiguity is left at this point.
|
||||
final cmdLine = [executable, ...arguments].map(_quoteArg).join(' ').toNativeUtf16(allocator: malloc);
|
||||
final envBlock = _environmentBlock(environment);
|
||||
final cwdN = workingDirectory == null ? ffi.nullptr : workingDirectory.toNativeUtf16(allocator: malloc);
|
||||
|
||||
// STARTF_USESTDHANDLES with NULL std handles (calloc zeroes them):
|
||||
// the console subsystem then assigns conpty-backed handles at
|
||||
// client connect instead of leaking the parent's (docstring §3).
|
||||
final si = calloc<_StartupInfoExW>()
|
||||
..ref.cb = ffi.sizeOf<_StartupInfoExW>()
|
||||
..ref.dwFlags = _kStartfUseStdHandles
|
||||
..ref.lpAttributeList = attrList;
|
||||
final pi = calloc<_ProcessInformation>();
|
||||
|
||||
final ok = _createProcessW(
|
||||
ffi.nullptr,
|
||||
cmdLine,
|
||||
ffi.nullptr,
|
||||
ffi.nullptr,
|
||||
0,
|
||||
_kExtendedStartupinfoPresent | _kCreateUnicodeEnvironment,
|
||||
envBlock.cast(),
|
||||
cwdN.cast(),
|
||||
si,
|
||||
pi,
|
||||
);
|
||||
final spawnErr = ok == 0 ? _getLastError() : 0;
|
||||
|
||||
_deleteAttrList(attrList);
|
||||
freeAttrs();
|
||||
malloc.free(cmdLine);
|
||||
malloc.free(envBlock);
|
||||
if (cwdN != ffi.nullptr) malloc.free(cwdN.cast<ffi.Uint8>());
|
||||
calloc.free(si);
|
||||
|
||||
if (ok == 0) {
|
||||
calloc.free(pi);
|
||||
_closePseudoConsole(hpc);
|
||||
_closeHandle(inRead);
|
||||
_closeHandle(outWrite);
|
||||
_closeHandle(inWrite);
|
||||
_closeHandle(outRead);
|
||||
throw PtyException('CreateProcessW', 'spawn of $executable failed', errno: spawnErr);
|
||||
}
|
||||
|
||||
final hProcess = pi.ref.hProcess;
|
||||
final hThread = pi.ref.hThread;
|
||||
final childPid = pi.ref.dwProcessId;
|
||||
calloc.free(pi);
|
||||
|
||||
final pty = WindowsPty._(hpc, hProcess, hThread, inWrite, outRead, inRead, outWrite, childPid);
|
||||
pty._spawnReader();
|
||||
pty._spawnWaiter();
|
||||
return pty;
|
||||
}
|
||||
|
||||
// -- I/O --------------------------------------------------------------
|
||||
|
||||
void _spawnReader() {
|
||||
_readerReady = _spawnReaderAsync();
|
||||
}
|
||||
|
||||
Future<void> _spawnReaderAsync() async {
|
||||
final rp = ReceivePort();
|
||||
_readerPort = rp;
|
||||
_readerExited = Completer<void>();
|
||||
rp.listen((msg) {
|
||||
if (msg == null) {
|
||||
if (!_out.isClosed) _out.close();
|
||||
rp.close();
|
||||
_readerPort = null;
|
||||
if (!_readerExited!.isCompleted) _readerExited!.complete();
|
||||
_reap();
|
||||
} else {
|
||||
if (!_out.isClosed) _out.add(msg as Uint8List);
|
||||
}
|
||||
});
|
||||
try {
|
||||
_readerIsolate = await Isolate.spawn(_readLoop, (rp.sendPort, _outRead.address));
|
||||
} catch (e) {
|
||||
_dead = true;
|
||||
if (!_out.isClosed) _out.addError(PtyException('reader-spawn', '$e'));
|
||||
rp.close();
|
||||
_readerPort = null;
|
||||
if (!_readerExited!.isCompleted) _readerExited!.complete();
|
||||
}
|
||||
}
|
||||
|
||||
/// Isolate entry — blocking ReadFile until the ConPTY side closes.
|
||||
static void _readLoop((SendPort, int) msg) {
|
||||
final (port, handleAddr) = msg;
|
||||
final handle = ffi.Pointer<ffi.Void>.fromAddress(handleAddr);
|
||||
final k32 = ffi.DynamicLibrary.open('kernel32.dll');
|
||||
final readFile = k32
|
||||
.lookupFunction<
|
||||
ffi.Int32 Function(_Handle, ffi.Pointer<ffi.Uint8>, ffi.Uint32, ffi.Pointer<ffi.Uint32>, ffi.Pointer<ffi.Void>),
|
||||
int Function(_Handle, ffi.Pointer<ffi.Uint8>, int, ffi.Pointer<ffi.Uint32>, ffi.Pointer<ffi.Void>)
|
||||
>('ReadFile');
|
||||
|
||||
final buf = malloc<ffi.Uint8>(65536);
|
||||
final nRead = calloc<ffi.Uint32>();
|
||||
try {
|
||||
while (true) {
|
||||
// Blocks until data, broken pipe (ConPTY closed), or invalid
|
||||
// handle (close() already released it).
|
||||
final ok = readFile(handle, buf, 65536, nRead, ffi.nullptr);
|
||||
if (ok == 0) break;
|
||||
final n = nRead.value;
|
||||
if (n == 0) break;
|
||||
port.send(Uint8List.fromList(buf.asTypedList(n)));
|
||||
}
|
||||
} finally {
|
||||
calloc.free(nRead);
|
||||
malloc.free(buf);
|
||||
}
|
||||
port.send(null);
|
||||
}
|
||||
|
||||
/// Watches for child exit so the pseudo console can be torn down —
|
||||
/// without ClosePseudoConsole the output pipe never breaks and the
|
||||
/// reader would block forever on an exited child.
|
||||
void _spawnWaiter() {
|
||||
final wp = ReceivePort();
|
||||
_waiterPort = wp;
|
||||
wp.listen((_) {
|
||||
wp.close();
|
||||
_waiterPort = null;
|
||||
_closeConsole();
|
||||
});
|
||||
Isolate.spawn(_waitLoop, (wp.sendPort, _hProcess.address)).catchError((Object e) {
|
||||
// Fall back to close()-driven teardown; the child just won't be
|
||||
// auto-reaped on self-exit.
|
||||
wp.close();
|
||||
_waiterPort = null;
|
||||
return Isolate.current; // satisfies the Future<Isolate> type; unused
|
||||
});
|
||||
}
|
||||
|
||||
static void _waitLoop((SendPort, int) msg) {
|
||||
final (port, handleAddr) = msg;
|
||||
final k32 = ffi.DynamicLibrary.open('kernel32.dll');
|
||||
final wait = k32.lookupFunction<ffi.Uint32 Function(_Handle, ffi.Uint32), int Function(_Handle, int)>('WaitForSingleObject');
|
||||
wait(ffi.Pointer<ffi.Void>.fromAddress(handleAddr), _kInfinite);
|
||||
port.send(null);
|
||||
}
|
||||
|
||||
@override
|
||||
int write(List<int> bytes) {
|
||||
if (_dead || bytes.isEmpty) return 0;
|
||||
final buf = malloc<ffi.Uint8>(bytes.length);
|
||||
final nWritten = calloc<ffi.Uint32>();
|
||||
try {
|
||||
for (var i = 0; i < bytes.length; i++) {
|
||||
buf[i] = bytes[i];
|
||||
}
|
||||
var written = 0;
|
||||
while (written < bytes.length) {
|
||||
final ok = _writeFile(_inWrite, buf + written, bytes.length - written, nWritten, ffi.nullptr);
|
||||
if (ok == 0) {
|
||||
final err = _getLastError();
|
||||
if (err == _kErrorBrokenPipe) _dead = true;
|
||||
throw PtyException('WriteFile', 'write to ConPTY failed', errno: err);
|
||||
}
|
||||
if (nWritten.value == 0) break;
|
||||
written += nWritten.value;
|
||||
}
|
||||
return written;
|
||||
} finally {
|
||||
malloc.free(buf);
|
||||
calloc.free(nWritten);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void resize({required int cols, required int rows}) {
|
||||
if (_dead || _hpc == ffi.nullptr) return;
|
||||
final size = calloc<_Coord>()
|
||||
..ref.x = cols
|
||||
..ref.y = rows;
|
||||
_resizePseudoConsole(_hpc, size.ref);
|
||||
calloc.free(size);
|
||||
}
|
||||
|
||||
/// Windows has no signals — any [signal] terminates the child.
|
||||
@override
|
||||
bool kill([int? signal]) {
|
||||
if (_dead || _handlesReleased) return false;
|
||||
return _terminateProcess(_hProcess, 1) != 0;
|
||||
}
|
||||
|
||||
/// Close the HPCON and the conpty-side pipe ends, once. With every
|
||||
/// write end of the output pipe gone the reader drains what's left
|
||||
/// and EOFs.
|
||||
void _closeConsole() {
|
||||
final hpc = _hpc;
|
||||
if (hpc == ffi.nullptr) return;
|
||||
_hpc = ffi.nullptr;
|
||||
_closePseudoConsole(hpc);
|
||||
_closeHandle(_conptyInRead);
|
||||
_closeHandle(_conptyOutWrite);
|
||||
}
|
||||
|
||||
void _reap() {
|
||||
if (_dead) return;
|
||||
_dead = true;
|
||||
_closeConsole();
|
||||
_releaseHandles();
|
||||
}
|
||||
|
||||
void _releaseHandles() {
|
||||
if (_handlesReleased) return;
|
||||
_handlesReleased = true;
|
||||
final code = calloc<ffi.Uint32>();
|
||||
_getExitCodeProcess(_hProcess, code);
|
||||
calloc.free(code);
|
||||
_closeHandle(_inWrite);
|
||||
_closeHandle(_outRead);
|
||||
_closeHandle(_hThread);
|
||||
_closeHandle(_hProcess);
|
||||
}
|
||||
|
||||
/// Kill the child and release resources.
|
||||
///
|
||||
/// Order matters, mirroring the POSIX close(): terminate the child,
|
||||
/// break the output pipe (ClosePseudoConsole), wait for the reader
|
||||
/// to EOF so nothing touches the handles after we close them.
|
||||
@override
|
||||
Future<void> close() async {
|
||||
if (_dead) return;
|
||||
_dead = true;
|
||||
|
||||
await _readerReady;
|
||||
|
||||
_terminateProcess(_hProcess, 1);
|
||||
_closeConsole();
|
||||
|
||||
if (_readerExited != null) {
|
||||
await _readerExited!.future.timeout(const Duration(milliseconds: 500), onTimeout: () {});
|
||||
}
|
||||
|
||||
_readerIsolate?.kill(priority: Isolate.immediate);
|
||||
_readerIsolate = null;
|
||||
_readerPort?.close();
|
||||
_readerPort = null;
|
||||
_waiterPort?.close();
|
||||
_waiterPort = null;
|
||||
|
||||
_releaseHandles();
|
||||
if (!_out.isClosed) await _out.close();
|
||||
}
|
||||
|
||||
// -- spawn helpers ------------------------------------------------------
|
||||
|
||||
/// Resolve a bare command name against the environment's PATH +
|
||||
/// PATHEXT (mirrors what the POSIX side does with `:`-split PATH —
|
||||
/// visible/debuggable resolution instead of CreateProcess magic).
|
||||
static String _resolveExecutable(String executable, Map<String, String> environment) {
|
||||
final pathext = (environment['PATHEXT'] ?? Platform.environment['PATHEXT'] ?? '.COM;.EXE;.BAT;.CMD').split(';').where((e) => e.isNotEmpty).toList();
|
||||
final hasKnownExt = pathext.any((e) => executable.toLowerCase().endsWith(e.toLowerCase()));
|
||||
|
||||
Iterable<String> candidates(String base) sync* {
|
||||
if (hasKnownExt) {
|
||||
yield base;
|
||||
} else {
|
||||
yield base;
|
||||
for (final ext in pathext) {
|
||||
yield '$base$ext';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (executable.contains('\\') || executable.contains('/')) {
|
||||
for (final c in candidates(executable)) {
|
||||
if (File(c).existsSync()) return c;
|
||||
}
|
||||
return executable;
|
||||
}
|
||||
final path = environment['PATH'] ?? Platform.environment['PATH'] ?? '';
|
||||
for (final dir in path.split(';')) {
|
||||
if (dir.isEmpty) continue;
|
||||
for (final c in candidates('$dir\\$executable')) {
|
||||
if (File(c).existsSync()) return c;
|
||||
}
|
||||
}
|
||||
return executable;
|
||||
}
|
||||
|
||||
/// Quote one argument per MSVCRT command-line parsing rules.
|
||||
static String _quoteArg(String arg) {
|
||||
if (arg.isNotEmpty && !arg.contains(RegExp(r'[ \t"\n\v]'))) return arg;
|
||||
final b = StringBuffer('"');
|
||||
var backslashes = 0;
|
||||
for (final ch in arg.runes) {
|
||||
final c = String.fromCharCode(ch);
|
||||
if (c == r'\') {
|
||||
backslashes++;
|
||||
continue;
|
||||
}
|
||||
if (c == '"') {
|
||||
b.write(r'\' * (backslashes * 2 + 1));
|
||||
b.write('"');
|
||||
backslashes = 0;
|
||||
continue;
|
||||
}
|
||||
if (backslashes > 0) {
|
||||
b.write(r'\' * backslashes);
|
||||
backslashes = 0;
|
||||
}
|
||||
b.write(c);
|
||||
}
|
||||
b.write(r'\' * (backslashes * 2));
|
||||
b.write('"');
|
||||
return b.toString();
|
||||
}
|
||||
|
||||
/// Compose a CREATE_UNICODE_ENVIRONMENT block: `K=V\0...\0\0`,
|
||||
/// entries sorted case-insensitively by key per CreateProcess docs.
|
||||
static ffi.Pointer<Utf16> _environmentBlock(Map<String, String> environment) {
|
||||
final entries = environment.entries.toList()..sort((a, b) => a.key.toUpperCase().compareTo(b.key.toUpperCase()));
|
||||
// NUL via fromCharCode — an inline NUL escape in a string literal
|
||||
// is invisible in review and trips up text tooling.
|
||||
final nul = String.fromCharCode(0);
|
||||
final joined = entries.map((e) => '${e.key}=${e.value}$nul').join();
|
||||
// toNativeUtf16 appends the final terminating NUL; the explicit one
|
||||
// after the last entry completes the required double-NUL ending (and
|
||||
// keeps an empty environment block valid too).
|
||||
return '$joined$nul'.toNativeUtf16(allocator: malloc);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user