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
+99
View File
@@ -0,0 +1,99 @@
// Unit tests for the PTY breadcrumb plumbing (T-434). Pure callback + file I/O
// (no real PTY), so this is NOT tagged `pty` — it runs in the coverage pool.
import 'dart:io';
import 'package:clide/src/pty/pty_log.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('PtyLog', () {
test('none is a no-op — crumb does nothing and never throws', () {
expect(() => PtyLog.none.crumb('x'), returnsNormally);
expect(PtyLog.none.onCrumb, isNull);
expect(PtyLog.none.crumbPath, isNull);
expect(PtyLog.none.verbose, isFalse);
});
test('crumb forwards messages to onCrumb', () {
final got = <String>[];
final log = PtyLog(onCrumb: got.add);
log.crumb('a');
log.crumb('b');
expect(got, ['a', 'b']);
});
test('crumb swallows an exception thrown by onCrumb', () {
final log = PtyLog(onCrumb: (_) => throw StateError('boom'));
expect(() => log.crumb('x'), returnsNormally);
});
});
group('IsolateCrumbFile', () {
late Directory dir;
setUp(() => dir = Directory.systemTemp.createTempSync('clide-crumb-'));
tearDown(() {
if (dir.existsSync()) dir.deleteSync(recursive: true);
});
String path() => '${dir.path}${Platform.pathSeparator}crumbs.log';
test('null path → disabled, crumb is a no-op', () {
final c = IsolateCrumbFile(null, 'pty.reader');
expect(c.enabled, isFalse);
expect(() => c.crumb('x'), returnsNormally);
c.close();
});
test('writes one tagged, timestamped line per crumb', () {
final c = IsolateCrumbFile(path(), 'pty.reader');
expect(c.enabled, isTrue);
c.crumb('ReadFile enter');
c.crumb('ReadFile -> ok=1 n=12');
c.close();
final lines = File(path()).readAsLinesSync();
expect(lines, hasLength(2));
expect(lines[0], contains('[pty.reader] ReadFile enter'));
expect(lines[1], contains('[pty.reader] ReadFile -> ok=1 n=12'));
// ISO-8601 UTC timestamp prefix.
expect(lines[0], matches(RegExp(r'^\d{4}-\d{2}-\d{2}T')));
});
test('appends across reopen (each isolate opens its own handle)', () {
IsolateCrumbFile(path(), 'conpty.reader')
..crumb('reader started')
..close();
IsolateCrumbFile(path(), 'conpty.waiter')
..crumb('waiter started')
..close();
final lines = File(path()).readAsLinesSync();
expect(lines, hasLength(2));
expect(lines[0], contains('[conpty.reader] reader started'));
expect(lines[1], contains('[conpty.waiter] waiter started'));
});
test('truncates back to empty past the cap, keeping the tail bounded', () {
final c = IsolateCrumbFile(path(), 's', capBytes: 200);
for (var i = 0; i < 50; i++) {
c.crumb('breadcrumb line number $i with some padding');
}
c.crumb('LAST');
c.close();
final bytes = File(path()).lengthSync();
// Bounded: cap + at most one over-cap line, never the full 50 lines.
expect(bytes, lessThan(400));
// The most recent crumb survived the wrap.
expect(File(path()).readAsStringSync(), contains('LAST'));
});
test('close is idempotent and post-close crumbs are no-ops', () {
final c = IsolateCrumbFile(path(), 's')..crumb('one');
c.close();
c.close();
c.crumb('after-close');
expect(File(path()).readAsLinesSync(), hasLength(1));
});
});
}
+35
View File
@@ -18,6 +18,7 @@ import 'dart:io';
import 'package:clide/src/pty/errors.dart';
import 'package:clide/src/pty/native_pty.dart';
import 'package:clide/src/pty/pty_log.dart';
import 'package:test/test.dart';
import '../helpers/timeouts.dart';
@@ -41,6 +42,40 @@ void main() {
expect(got, contains('hello-pty'));
});
test('emits FFI breadcrumbs to the main callback + the reader isolate crumb file (T-434)', tags: ['pty'], () async {
final dir = Directory.systemTemp.createTempSync('clide-pty-crumb-');
addTearDown(() {
if (dir.existsSync()) dir.deleteSync(recursive: true);
});
final crumbPath = '${dir.path}/pty.crumbs';
final mainCrumbs = <String>[];
final s = NativePty.start(
executable: '/bin/sh',
arguments: ['-c', 'echo crumb-test'],
columns: 80,
rows: 24,
workingDirectory: '/',
environment: {...Platform.environment, 'TERM': 'xterm-256color'},
log: PtyLog(onCrumb: mainCrumbs.add, crumbPath: crumbPath, verbose: true),
);
addTearDown(s.close);
// Drain to EOF so the reader isolate runs its full lifecycle (it writes
// its 'exiting' crumb + closes the file before sending the EOF we await).
await s.output.drain<void>().timeout(ioTimeout, onTimeout: () {});
// Main-isolate crumbs captured the spawn syscall sequence.
expect(mainCrumbs.join('\n'), contains('posix_spawn'));
// The SPAWNED reader isolate wrote its own crumbs to its own handle.
final crumbs = File(crumbPath).readAsStringSync();
expect(crumbs, contains('[pty.reader] reader started'));
expect(crumbs, contains('[pty.reader] reader exiting'));
// verbose:true → per-read crumbs around the (potentially blocking) read.
expect(crumbs, contains('[pty.reader] read -> n='));
});
test('write sends keystrokes to child', tags: ['pty'], () async {
final s = NativePty.start(
executable: '/bin/sh',