Files
clide/lib/kernel/src/watchdog_windows.dart
T
jpmschweitzerandClaude Opus 4.8 59c5c32b5b feat(watchdog): dedicated-isolate heartbeat + resource sampler (T-435)
A main-isolate Timer would freeze WITH the main isolate and tell us nothing,
so the watchdog runs in its own isolate: it fsyncs a heartbeat every ~500ms
(so the last on-disk heartbeat bounds a freeze to ~500ms) and every ~2s samples
this process's thread / handle-or-fd / child-host / RSS counts. A monotonically
climbing child or thread count is the leak signature the soak couldn't
reproduce on CI but a real freeze would show. Output is JSON-lines in
clide-watchdog.log, bounded by the same truncate-on-cap scheme as the crumb
files.

- watchdog.dart (Flutter-free, tested): ResourceSample, ResourceSampler
  (forPlatform), PosixResourceSampler (/proc/self: Threads, fd count, task
  children, ProcessInfo.currentRss), WatchdogFile (bounded fsynced JSON-lines),
  runWatchdog (the loop, bounded by maxTicks for tests), watchdogEntry (the
  sendable Isolate.spawn entry).
- watchdog_windows.dart (coverage:ignore — Win32 FFI, validated only at runtime
  on Windows): one CreateToolhelp32Snapshot for thread + conhost/OpenConsole
  child count, GetProcessHandleCount, ProcessInfo.currentRss. Exhaustively
  defensive: any failure yields a -1 field, snapshot handle always closed, never
  throws.
- main.dart: spawn the watchdog at boot (desktop only), non-fatal. Per-line
  fsync means the OS reaping the isolate at exit loses nothing.

Tests: ResourceSample.toJson, Posix sampler against real /proc, WatchdogFile
(JSON shape, cap, disabled), runWatchdog (immediate baseline tick). Coverage
gate 95.08%.

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

142 lines
4.9 KiB
Dart

// coverage:ignore-file
//
// Windows-only resource sampler for the watchdog (T-435). All of it is Win32
// FFI through kernel32/psapi, so it cannot execute on the Linux CI runner that
// produces the coverage report (PosixResourceSampler is used there). It is
// validated only when the app actually runs on Windows — which is acceptable
// because it is a DIAGNOSTIC that reads, never mutates, and is exhaustively
// defensive: every probe is wrapped so any failure yields a `-1` field rather
// than an exception, and the toolhelp snapshot handle is always closed. A
// missing sample is fine; a sampler that throws or leaks would not be.
library;
import 'dart:ffi' as ffi;
import 'dart:io' show ProcessInfo;
import 'package:ffi/ffi.dart';
import 'watchdog.dart';
const int _kTh32csSnapprocess = 0x00000002;
final ffi.DynamicLibrary _k32 = ffi.DynamicLibrary.open('kernel32.dll');
final ffi.DynamicLibrary _psapi = ffi.DynamicLibrary.open('psapi.dll');
final _getCurrentProcess = _k32.lookupFunction<ffi.Pointer<ffi.Void> Function(), ffi.Pointer<ffi.Void> Function()>('GetCurrentProcess');
final _getCurrentProcessId = _k32.lookupFunction<ffi.Uint32 Function(), int Function()>('GetCurrentProcessId');
final _createToolhelp32Snapshot = _k32.lookupFunction<ffi.Pointer<ffi.Void> Function(ffi.Uint32, ffi.Uint32), ffi.Pointer<ffi.Void> Function(int, int)>(
'CreateToolhelp32Snapshot',
);
final _process32First = _k32
.lookupFunction<ffi.Int32 Function(ffi.Pointer<ffi.Void>, ffi.Pointer<_ProcessEntry32>), int Function(ffi.Pointer<ffi.Void>, ffi.Pointer<_ProcessEntry32>)>(
'Process32First',
);
final _process32Next = _k32
.lookupFunction<ffi.Int32 Function(ffi.Pointer<ffi.Void>, ffi.Pointer<_ProcessEntry32>), int Function(ffi.Pointer<ffi.Void>, ffi.Pointer<_ProcessEntry32>)>(
'Process32Next',
);
final _closeHandle = _k32.lookupFunction<ffi.Int32 Function(ffi.Pointer<ffi.Void>), int Function(ffi.Pointer<ffi.Void>)>('CloseHandle');
final _getProcessHandleCount = _psapi
.lookupFunction<ffi.Int32 Function(ffi.Pointer<ffi.Void>, ffi.Pointer<ffi.Uint32>), int Function(ffi.Pointer<ffi.Void>, ffi.Pointer<ffi.Uint32>)>(
'GetProcessHandleCount',
);
/// Win32 `PROCESSENTRY32` (ANSI). szExeFile is `CHAR[MAX_PATH]`.
final class _ProcessEntry32 extends ffi.Struct {
@ffi.Uint32()
external int dwSize;
@ffi.Uint32()
external int cntUsage;
@ffi.Uint32()
external int th32ProcessID;
@ffi.IntPtr()
external int th32DefaultHeapID;
@ffi.Uint32()
external int th32ModuleID;
@ffi.Uint32()
external int cntThreads;
@ffi.Uint32()
external int th32ParentProcessID;
@ffi.Int32()
external int pcPriClassBase;
@ffi.Uint32()
external int dwFlags;
@ffi.Array<ffi.Uint8>(260)
external ffi.Array<ffi.Uint8> szExeFile;
}
/// Samples the current process via a single toolhelp snapshot (thread count +
/// ConPTY-host children) plus GetProcessHandleCount and ProcessInfo.currentRss.
class WindowsResourceSampler implements ResourceSampler {
@override
ResourceSample sample() {
final (threads, children) = _snapshotThreadsAndHosts();
return ResourceSample(threads: threads, children: children, handles: _handleCount(), rssBytes: _rss());
}
/// One toolhelp snapshot → (this process's thread count, count of its direct
/// conhost/OpenConsole children). Both `-1`/unavailable on any failure.
(int, int) _snapshotThreadsAndHosts() {
var threads = -1;
var conhosts = 0;
var sawAny = false;
ffi.Pointer<ffi.Void>? snap;
final entry = calloc<_ProcessEntry32>();
try {
final myPid = _getCurrentProcessId();
snap = _createToolhelp32Snapshot(_kTh32csSnapprocess, 0);
entry.ref.dwSize = ffi.sizeOf<_ProcessEntry32>();
var ok = _process32First(snap, entry);
while (ok != 0) {
sawAny = true;
if (entry.ref.th32ProcessID == myPid) threads = entry.ref.cntThreads;
if (entry.ref.th32ParentProcessID == myPid) {
final name = _exeName(entry.ref.szExeFile).toLowerCase();
if (name == 'conhost.exe' || name == 'openconsole.exe') conhosts++;
}
ok = _process32Next(snap, entry);
}
} catch (_) {
// any FFI failure → unavailable, not a crash
} finally {
if (snap != null) {
try {
_closeHandle(snap);
} catch (_) {}
}
calloc.free(entry);
}
return (threads, sawAny ? conhosts : -1);
}
int _handleCount() {
final out = calloc<ffi.Uint32>();
try {
final ok = _getProcessHandleCount(_getCurrentProcess(), out);
return ok != 0 ? out.value : -1;
} catch (_) {
return -1;
} finally {
calloc.free(out);
}
}
int _rss() {
try {
return ProcessInfo.currentRss;
} catch (_) {
return -1;
}
}
String _exeName(ffi.Array<ffi.Uint8> arr) {
final bytes = <int>[];
for (var i = 0; i < 260; i++) {
final b = arr[i];
if (b == 0) break;
bytes.add(b);
}
return String.fromCharCodes(bytes);
}
}