Files
clide/tools/windows-verify/conpty_orphan_probe.dart
jpmschweitzerandClaude Opus 4.8 f40be4f758 feat(ci): capture crash evidence as artifacts when a run wedges (T-436)
Closes the observability loop: the log + breadcrumb + watchdog files are now
collected by CI so a wedged run leaves downloadable evidence instead of
nothing.

- logDirectory(): CLIDE_LOG_DIR overrides the per-platform default, so CI can
  point the logs at an uploadable workspace dir (and tests at a temp dir).
  Now takes an injectable env map; tested.
- test_app.dart: when CLIDE_LOG_DIR is set, the testmode harness tees its
  logger to a FileLogSink + spawns the watchdog (off by default — normal
  run-testmode keeps the stderr-only path, no isolate). _say breadcrumbs each
  test into the file.
- conpty_orphan_probe.dart: with CLIDE_LOG_DIR set it passes a verbose PtyLog,
  so when soak-conpty-kill.ps1 force-kills the parent, the reader/waiter
  isolates' LAST crumb is fsynced to disk — naming what the wedged isolate was
  doing at the instant of death.
- bundle-smoke job: runs the real release app with CLIDE_LOG=debug +
  CLIDE_LOG_DIR, uploads clide-logs (watchdog heartbeat/sample + FileLogSink)
  in an always() step.
- windows-soak kill-probe job: sets CLIDE_LOG_DIR, uploads the FFI crumbs.

Coverage gate 95.08%.

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

73 lines
3.4 KiB
Dart

/// ConPTY orphan probe — the ABRUPT-DEATH half of the windows-verify kit.
///
/// `soak-conpty.ps1` measures the CLEAN path: dart exits normally, the ConPTY
/// teardown (`close()` → ClosePseudoConsole → handle release) runs, and the
/// hosts are reaped. On GitHub's windows-latest that path showed NO leak —
/// orderly shutdown reclaims everything. But the freeze hypothesis (T-424) is
/// not about orderly shutdown; it is about the parent dying WITHOUT teardown
/// (a crash, a Ctrl-C, a wedged reader isolate) while the child is still live.
///
/// This probe exercises exactly that. It starts [count] real [WindowsPty]
/// sessions — the production backend, no mocks — each running a long-lived
/// child, prints a READY line carrying the dart pid + each child pid, then
/// blocks forever and NEVER calls `close()`. Its companion driver
/// (`soak-conpty-kill.ps1`) force-kills this dart.exe (`taskkill /F`, NOT
/// `/T`, so only the parent dies) once the children are up, then counts the
/// conhost / OpenConsole / cmd processes that SURVIVE the parent's death.
///
/// Because the children are not placed in a kill-on-close Job Object, abrupt
/// parent death is expected to orphan them — that is the leak this probe is
/// built to expose. The same probe will later PROVE the T-424 fix: once each
/// child lives in a JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE job, killing the
/// parent should take the job — and every host — down with it, and the
/// survivor count should drop to zero.
library;
import 'dart:io';
import 'package:clide/src/pty/pty_log.dart';
import 'package:clide/src/pty/windows_pty.dart';
Future<void> main(List<String> args) async {
if (!Platform.isWindows) {
stderr.writeln('conpty_orphan_probe: Windows only (ConPTY).');
exit(2);
}
final count = args.isNotEmpty ? (int.tryParse(args.first) ?? 1) : 1;
// When CLIDE_LOG_DIR is set (the soak workflow sets it), emit FFI breadcrumbs
// so that when soak-conpty-kill.ps1 force-kills this process mid-life, the
// reader/waiter isolates' LAST crumb (e.g. "ReadFile enter") is on disk —
// CI then uploads it, naming what the reader was doing when killed (T-436).
final logDir = Platform.environment['CLIDE_LOG_DIR'];
final ptyLog = (logDir == null || logDir.isEmpty) ? PtyLog.none : PtyLog(crumbPath: '$logDir/clide-pty.crumbs.log', verbose: true);
final sessions = <WindowsPty>[];
for (var i = 0; i < count; i++) {
final s = WindowsPty.start(
executable: 'cmd.exe',
// A long-lived child so the ConPTY host stays alive across the whole
// kill window. `ping -n 600` loops for ~10 min with no extra deps.
arguments: ['/c', 'ping -n 600 127.0.0.1'],
columns: 80,
rows: 24,
environment: {...Platform.environment, 'TERM': 'xterm-256color'},
log: ptyLog,
);
// Drain output so the reader isolate is actively pumping, closest to a
// real live pane. Discard the bytes.
s.output.listen((_) {}, onError: (_) {});
sessions.add(s);
}
// Signal the driver that the children are up. It waits for the host count
// to rise, then kills us.
stdout.writeln('PROBE READY dart_pid=$pid child_pids=${sessions.map((s) => s.pid).join(',')}');
await stdout.flush();
// Block WITHOUT ever calling close() — the driver kills us mid-sleep. That
// missing teardown is the entire point; do not add a finally/close here.
await Future<void>.delayed(const Duration(minutes: 10));
exit(0);
}