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>
This commit is contained in:
2026-06-15 09:56:53 +02:00
co-authored by Claude Opus 4.8
parent 1faa047393
commit 9837473ca7
9 changed files with 373 additions and 24 deletions
+47 -10
View File
@@ -45,6 +45,7 @@ import 'dart:typed_data';
import 'package:ffi/ffi.dart';
import 'errors.dart';
import 'pty_log.dart';
import 'pty_session.dart';
import 'pty_size.dart';
@@ -259,6 +260,11 @@ class WindowsPty implements PtySession {
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;
@@ -277,7 +283,9 @@ class WindowsPty implements PtySession {
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 ---------------------------------------
@@ -308,7 +316,9 @@ class WindowsPty implements PtySession {
..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);
@@ -375,6 +385,7 @@ class WindowsPty implements PtySession {
..ref.lpAttributeList = attrList;
final pi = calloc<_ProcessInformation>();
log.crumb('conpty: CreateProcessW enter');
final ok = _createProcessW(
ffi.nullptr,
cmdLine,
@@ -388,6 +399,7 @@ class WindowsPty implements PtySession {
pi,
);
final spawnErr = ok == 0 ? _getLastError() : 0;
log.crumb('conpty: CreateProcessW -> ok=$ok err=$spawnErr');
_deleteAttrList(attrList);
freeAttrs();
@@ -411,7 +423,10 @@ class WindowsPty implements PtySession {
final childPid = pi.ref.dwProcessId;
calloc.free(pi);
final pty = WindowsPty._(hpc, hProcess, hThread, inWrite, outRead, inRead, outWrite, childPid);
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;
@@ -439,7 +454,7 @@ class WindowsPty implements PtySession {
}
});
try {
_readerIsolate = await Isolate.spawn(_readLoop, (rp.sendPort, _outRead.address));
_readerIsolate = await Isolate.spawn(_readLoop, (rp.sendPort, _outRead.address, _crumbPath, _verbose));
} catch (e) {
_dead = true;
if (!_out.isClosed) _out.addError(PtyException('reader-spawn', '$e'));
@@ -450,8 +465,15 @@ class WindowsPty implements PtySession {
}
/// Isolate entry — blocking ReadFile until the ConPTY side closes.
static void _readLoop((SendPort, int) msg) {
final (port, handleAddr) = msg;
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
@@ -462,20 +484,31 @@ class WindowsPty implements PtySession {
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 (ok == 0) break;
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) break;
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);
}
@@ -490,7 +523,7 @@ class WindowsPty implements PtySession {
_waiterPort = null;
_closeConsole();
});
Isolate.spawn(_waitLoop, (wp.sendPort, _hProcess.address)).catchError((Object e) {
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();
@@ -499,11 +532,15 @@ class WindowsPty implements PtySession {
});
}
static void _waitLoop((SendPort, int) msg) {
final (port, handleAddr) = msg;
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');
wait(ffi.Pointer<ffi.Void>.fromAddress(handleAddr), _kInfinite);
final r = wait(ffi.Pointer<ffi.Void>.fromAddress(handleAddr), _kInfinite);
crumbs.crumb('WaitForSingleObject -> $r (child exited)');
crumbs.close();
port.send(null);
}