Files
clide/lib/src/pty/windows_pty.dart
T
jpmschweitzerandClaude Opus 4.8 9837473ca7 feat(pty): FFI breadcrumbs around the syscalls that wedge (T-434)
The freeze hypothesis is a wedged FFI call — a reader isolate blocked forever
in ReadFile, a waiter in WaitForSingleObject, Isolate.kill unable to interrupt
either. To NAME the wedge after a power-cycle, each backend now drops a
breadcrumb before/after every risky syscall.

- pty_log.dart (new, Flutter-free, tested): PtyLog — an injectable, no-op-by-
  default breadcrumb hook for the MAIN isolate (wired to the kernel Logger,
  source 'conpty'/'pty' = an eager FileLogSink source) — and IsolateCrumbFile,
  which the SPAWNED reader/waiter isolates use to open their OWN append handle
  and flushSync per line, so a wedged isolate's last crumb survives even a
  frozen main isolate (the whole point). Bounded by a truncating size cap.
- native_pty.dart + windows_pty.dart: crumbs around posix_spawn/read and
  CreatePseudoConsole/CreateProcessW/ReadFile/WaitForSingleObject; the reader/
  waiter isolates carry a sendable crumb path + verbose flag. Per-syscall crumbs
  only at debug/trace; lifecycle crumbs always.
- Wiring: startPtySession → PaneRegistry → buildDispatcher build the PtyLog from
  the kernel Logger + a crumb file under logDirectory(); verbose follows the log
  level. Default everywhere is PtyLog.none — zero behaviour change off the wire.

Tested: PtyLog/IsolateCrumbFile units (cap-truncation, append, no-op) + an
end-to-end real-PTY test asserting the reader isolate writes its own crumbs
('reader started' / 'read -> n=' / 'reader exiting'), which validates the
identical Windows structure that can't run here. Coverage gate 95.10%.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 09:56:53 +02:00

738 lines
27 KiB
Dart

/// 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_log.dart';
import 'pty_session.dart';
import 'pty_size.dart';
// coverage:ignore-start
//
// Everything from here to the `resolveExecutable` helper below is the
// Windows-only ConPTY FFI path: Win32 structs, kernel32 bindings, and the
// `WindowsPty` session that calls CreatePseudoConsole / CreateProcessW /
// WaitForSingleObject. The kernel32 symbols all resolve through one
// `DynamicLibrary.open('kernel32.dll')` handle (below), which has no Linux
// equivalent — so any method that touches a binding cannot even be ENTERED
// on the ubuntu-latest runner that produces the coverage report, and the
// syscall sites are genuinely uncoverable off-Windows.
//
// Honest caveat: this span is excluded at FILE granularity, but it is not
// 100% syscall. A few fragments are pure input->output and COULD be unit-
// tested on Linux if extracted into free helpers (the way the three pure
// helpers below already were): the _Coord / _StartupInfoExW struct packing
// and write()'s empty/length guard. They stay entangled here only because
// they share a method with a kernel32 binding; T-431 tracks pulling them
// (and the POSIX marshalling in native_pty.dart) into testable helpers and
// shrinking this ignore span to the raw syscalls.
//
// The pure, platform-agnostic spawn helpers (resolveExecutable / quoteArg /
// composeEnvironmentBlock) sit AFTER the ignore-end below and ARE covered on
// every platform by test/pty/windows_pty_args_test.dart. The FFI path's
// BEHAVIOUR — not its line coverage — is what the Windows runner validates:
// windows.yml spawns real ConPTY children (start/write/resize/kill/errors)
// but collects no coverage, so there is intentionally no line-coverage
// metric for this span anywhere. End-to-end leak/handle correctness is
// proven by the Windows VM soak (tools/windows-verify/).
// -- 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;
/// Breadcrumb file path + verbosity threaded into the reader/waiter isolates
/// (T-434). Plain values so they survive `Isolate.spawn`.
String? _crumbPath;
bool _verbose = false;
@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 {},
PtyLog log = PtyLog.none,
}) {
log.crumb('conpty: start exe=$executable');
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 = clampPtyDimension(columns)
..ref.y = clampPtyDimension(rows);
final hpcOut = calloc<_Handle>();
log.crumb('conpty: CreatePseudoConsole enter');
final hr = _createPseudoConsole(size.ref, inRead, outWrite, 0, hpcOut);
log.crumb('conpty: CreatePseudoConsole -> hr=$hr');
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 = composeEnvironmentBlock(environment).toNativeUtf16(allocator: malloc);
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>();
log.crumb('conpty: CreateProcessW enter');
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;
log.crumb('conpty: CreateProcessW -> ok=$ok err=$spawnErr');
_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)
.._crumbPath = log.crumbPath
.._verbose = log.verbose;
log.crumb('conpty: spawned pid=$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, _crumbPath, _verbose));
} 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, String?, bool) msg) {
final (port, handleAddr, crumbPath, verbose) = msg;
// This isolate is the prime suspect for the freeze: ReadFile blocks
// forever if the ConPTY host never closes the pipe, and Isolate.kill
// can't interrupt the FFI (dart-lang/sdk#46680). It opens its OWN append
// handle so its last "ReadFile enter" crumb survives even a frozen main
// isolate — the breadcrumb that NAMES the wedge after a power-cycle (T-434).
final crumbs = IsolateCrumbFile(crumbPath, 'conpty.reader');
crumbs.crumb('reader started handle=$handleAddr');
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>();
var reason = 'eof';
try {
while (true) {
// Blocks until data, broken pipe (ConPTY closed), or invalid
// handle (close() already released it).
if (verbose) crumbs.crumb('ReadFile enter');
final ok = readFile(handle, buf, 65536, nRead, ffi.nullptr);
if (verbose) crumbs.crumb('ReadFile -> ok=$ok n=${nRead.value}');
if (ok == 0) {
reason = 'broken-pipe/invalid';
break;
}
final n = nRead.value;
if (n == 0) {
reason = 'n=0';
break;
}
port.send(Uint8List.fromList(buf.asTypedList(n)));
}
} finally {
calloc.free(nRead);
malloc.free(buf);
}
crumbs.crumb('reader exiting ($reason)');
crumbs.close();
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, _crumbPath)).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, String?) msg) {
final (port, handleAddr, crumbPath) = msg;
final crumbs = IsolateCrumbFile(crumbPath, 'conpty.waiter');
crumbs.crumb('waiter started; WaitForSingleObject(INFINITE) enter');
final k32 = ffi.DynamicLibrary.open('kernel32.dll');
final wait = k32.lookupFunction<ffi.Uint32 Function(_Handle, ffi.Uint32), int Function(_Handle, int)>('WaitForSingleObject');
final r = wait(ffi.Pointer<ffi.Void>.fromAddress(handleAddr), _kInfinite);
crumbs.crumb('WaitForSingleObject -> $r (child exited)');
crumbs.close();
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 = clampPtyDimension(cols)
..ref.y = clampPtyDimension(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();
}
// coverage:ignore-end
// -- 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).
///
/// [exists] overrides the on-disk probe so the resolution logic is
/// unit-testable off-Windows; production passes the default. Public for
/// that reason — not part of the backend's external contract.
static String resolveExecutable(String executable, Map<String, String> environment, {bool Function(String path)? exists}) {
final fileExists = exists ?? ((String path) => File(path).existsSync());
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 (fileExists(c)) 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 (fileExists(c)) return c;
}
}
return executable;
}
/// Quote one argument per MSVCRT command-line parsing rules. Public so the
/// quoting rules can be unit-tested off-Windows; not an external contract.
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 the body of a CREATE_UNICODE_ENVIRONMENT block: `K=V\0...\0`
/// with one trailing NUL, entries sorted case-insensitively by key per
/// CreateProcess docs. The caller nativizes via `toNativeUtf16`, whose own
/// terminator completes the required double-NUL ending (which also keeps an
/// empty environment block valid). Public for unit testing.
static String composeEnvironmentBlock(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();
return '$joined$nul';
}
}