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>
This commit is contained in:
2026-06-15 10:15:49 +02:00
co-authored by Claude Opus 4.8
parent b7d6d6f6f2
commit 59c5c32b5b
6 changed files with 459 additions and 0 deletions
+5
View File
@@ -30,6 +30,11 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
so a wedged isolate's last crumb survives a freeze that also froze the main
isolate — naming the wedge after the fact. Per-syscall crumbs at debug level.
(T-434)
- **Crash-diagnostic watchdog.** A dedicated isolate fsyncs a heartbeat every
~500ms (bounding a freeze to ~500ms) and every ~2s samples this process's
thread / handle / child-host / RSS counts to `clide-watchdog.log` — a climbing
child or thread count is the leak signature. Survives a frozen main isolate;
spawn failure is non-fatal. (T-435)
## [2.5.0] — 2026-06-14
+1
View File
@@ -18,6 +18,7 @@ export 'src/events/types.dart';
export 'src/ipc/client.dart';
export 'src/log.dart';
export 'src/file_log_sink.dart';
export 'src/watchdog.dart';
export 'src/settings.dart';
export 'src/facade.dart';
export 'src/clipboard.dart';
+201
View File
@@ -0,0 +1,201 @@
/// Crash-diagnostic watchdog (T-435, under the T-425 observability epic).
///
/// The Windows freeze leaves no evidence partly because it is a *whole-process*
/// stall: a main-isolate `Timer` heartbeat would freeze WITH the main isolate
/// and tell us nothing. So the watchdog runs in a DEDICATED isolate that:
///
/// - appends + fsyncs a heartbeat every ~500ms, so the last heartbeat on disk
/// bounds a freeze to ~500ms ("it was alive at T, dead by T+0.5s"); and
/// - every ~2s samples this process's resource counts — threads, open
/// handles/fds, child/ConPTY-host processes, RSS — and appends + fsyncs
/// them. A monotonically climbing child/thread/handle count is the leak
/// signature the soak couldn't reproduce on CI but a real freeze would show.
///
/// Output is JSON-lines in `logDirectory()/clide-watchdog.log`, matching
/// [FileLogSink] so it greps/parses the same way, bounded by the same
/// truncate-on-cap scheme as [IsolateCrumbFile]. Everything is synchronous and
/// swallows its own errors — the watchdog must never add a second hang or take
/// the app down.
///
/// Flutter-free (dart:io / dart:isolate / dart:convert + a thin FFI sampler on
/// Windows) so the entry point is `Isolate.spawn`-able and it unit-tests under
/// `dart test`.
library;
import 'dart:convert';
import 'dart:io';
import 'watchdog_windows.dart';
/// One resource sample of the current process. A field of `-1` means "not
/// available on this platform or the probe failed" — never an error.
class ResourceSample {
const ResourceSample({this.threads = -1, this.handles = -1, this.children = -1, this.rssBytes = -1});
/// Live OS thread count (culprit #2: blocked-FFI isolate threads piling up).
final int threads;
/// Open handle count (Windows) / open fd count (POSIX).
final int handles;
/// Child / ConPTY-host process count (the orphan-accumulation leak signature).
final int children;
/// Resident set size in bytes.
final int rssBytes;
Map<String, Object?> toJson() => {
if (threads >= 0) 'threads': threads,
if (handles >= 0) 'handles': handles,
if (children >= 0) 'children': children,
if (rssBytes >= 0) 'rssMB': (rssBytes / (1024 * 1024)).round(),
};
}
/// Samples the CURRENT process's resource counts. Cheap, synchronous, never
/// throws (returns `-1` fields on failure).
abstract class ResourceSampler {
ResourceSample sample();
/// The backend for the running OS — `/proc` on POSIX, a thin Win32 FFI
/// snapshot on Windows.
static ResourceSampler forPlatform() => Platform.isWindows ? WindowsResourceSampler() : PosixResourceSampler();
}
/// POSIX sampler — reads `/proc/self`. Runs (and is tested) on the Linux CI box.
class PosixResourceSampler implements ResourceSampler {
@override
ResourceSample sample() => ResourceSample(threads: _threads(), handles: _fdCount(), children: _childCount(), rssBytes: _rss());
int _threads() {
try {
for (final line in File('/proc/self/status').readAsLinesSync()) {
if (line.startsWith('Threads:')) return int.parse(line.split(RegExp(r'\s+'))[1]);
}
} catch (_) {}
return -1;
}
int _fdCount() {
try {
return Directory('/proc/self/fd').listSync().length;
} catch (_) {
return -1;
}
}
int _childCount() {
// Sum each thread's direct-children list (`/proc/<pid>/task/<tid>/children`,
// Linux 5.3+). Best-effort: absent file / old kernel → 0 from that thread.
try {
var n = 0;
for (final task in Directory('/proc/self/task').listSync()) {
final f = File('${task.path}/children');
if (!f.existsSync()) continue;
final s = f.readAsStringSync().trim();
if (s.isNotEmpty) n += s.split(RegExp(r'\s+')).length;
}
return n;
} catch (_) {
return -1;
}
}
int _rss() {
try {
return ProcessInfo.currentRss;
} catch (_) {
return -1;
}
}
}
/// The watchdog's on-disk writer: bounded, synchronously-fsynced JSON-lines
/// (heartbeats + samples). Separated from the loop so it unit-tests in
/// isolation. Mirrors [IsolateCrumbFile]'s truncate-on-cap bound.
class WatchdogFile {
WatchdogFile(String? path, {int capBytes = 256 * 1024}) : _capBytes = capBytes {
if (path == null) return;
try {
final f = File(path);
f.parent.createSync(recursive: true);
final raf = f.openSync(mode: FileMode.append);
_raf = raf;
_size = raf.lengthSync();
} catch (_) {
_raf = null;
}
}
final int _capBytes;
RandomAccessFile? _raf;
int _size = 0;
bool get enabled => _raf != null;
void heartbeat() => _write({'ts': _now(), 'evt': 'hb'});
void sample(ResourceSample s) => _write({'ts': _now(), 'evt': 'sample', 'pid': pid, ...s.toJson()});
String _now() => DateTime.now().toUtc().toIso8601String();
void _write(Map<String, Object?> json) {
final raf = _raf;
if (raf == null) return;
try {
if (_size >= _capBytes) {
raf.truncateSync(0);
raf.setPositionSync(0);
_size = 0;
}
final bytes = utf8.encode('${jsonEncode(json)}\n');
raf.writeFromSync(bytes);
raf.flushSync();
_size += bytes.length;
} catch (_) {}
}
void close() {
try {
_raf?.flushSync();
_raf?.closeSync();
} catch (_) {}
_raf = null;
}
}
/// The watchdog loop. Extracted from [watchdogEntry] so a test can bound it
/// with [maxTicks]; production passes null and the loop runs until the isolate
/// is killed at shutdown. Heartbeats fire every [hbIntervalMs], samples every
/// [sampleIntervalMs]; a short sleep between keeps the cadence without spinning.
void runWatchdog(WatchdogFile file, ResourceSampler sampler, {required int hbIntervalMs, required int sampleIntervalMs, int? maxTicks}) {
if (!file.enabled) return;
final sw = Stopwatch()..start();
// Seed both "last" markers a full interval in the past so the first tick
// emits an immediate heartbeat + sample (a baseline at startup).
var lastHb = -hbIntervalMs;
var lastSample = -sampleIntervalMs;
var ticks = 0;
while (maxTicks == null || ticks < maxTicks) {
final e = sw.elapsedMilliseconds;
if (e - lastHb >= hbIntervalMs) {
file.heartbeat();
lastHb = e;
}
if (e - lastSample >= sampleIntervalMs) {
file.sample(sampler.sample());
lastSample = e;
}
ticks++;
if (maxTicks != null && ticks >= maxTicks) break;
sleep(const Duration(milliseconds: 25));
}
file.close();
}
/// Top-level entry for `Isolate.spawn`. Args are a sendable tuple — the log
/// path (not a Logger; isolates can't share one) and the two intervals in ms.
void watchdogEntry((String, int, int) msg) {
final (logPath, hbMs, sampleMs) = msg;
runWatchdog(WatchdogFile(logPath), ResourceSampler.forPlatform(), hbIntervalMs: hbMs, sampleIntervalMs: sampleMs);
}
+141
View File
@@ -0,0 +1,141 @@
// 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);
}
}
+9
View File
@@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:isolate';
import 'package:clide/app.dart';
import 'package:clide/test_app.dart';
@@ -110,6 +111,14 @@ Future<void> main() async {
settingValue: bootSettings.get<String>('app.log.level'),
);
bootLogSinks = [FileLogSink(dir: Directory(logDirectory())).call];
// Crash-diagnostic watchdog in its own isolate (T-435): heartbeats +
// resource samples that survive a frozen main isolate. Non-fatal — a
// leak-detector that breaks startup is worse than a missing one. The OS
// reaps the isolate on exit; every line is fsynced, so abrupt death loses
// nothing.
try {
await Isolate.spawn(watchdogEntry, ('${logDirectory()}/clide-watchdog.log', 500, 2000));
} catch (_) {}
}
// Resolve toolchain + boot daemon inline — same as Linux.
+102
View File
@@ -0,0 +1,102 @@
import 'dart:convert';
import 'dart:io';
import 'package:clide/kernel/kernel.dart';
import 'package:flutter_test/flutter_test.dart';
class _FakeSampler implements ResourceSampler {
const _FakeSampler();
@override
ResourceSample sample() => const ResourceSample(threads: 7, handles: 42, children: 1, rssBytes: 100 * 1024 * 1024);
}
void main() {
late Directory dir;
setUp(() => dir = Directory.systemTemp.createTempSync('clide-wd-'));
tearDown(() {
if (dir.existsSync()) dir.deleteSync(recursive: true);
});
String path() => '${dir.path}${Platform.pathSeparator}wd.log';
group('ResourceSample', () {
test('toJson omits unavailable (-1) fields and converts RSS to MB', () {
expect(const ResourceSample(threads: 5, rssBytes: 2 * 1024 * 1024).toJson(), {'threads': 5, 'rssMB': 2});
expect(const ResourceSample().toJson(), isEmpty);
expect(const ResourceSample(handles: 9, children: 0).toJson(), {'handles': 9, 'children': 0});
});
});
group('ResourceSampler.forPlatform', () {
test('returns the POSIX sampler off Windows', () {
if (Platform.isWindows) return;
expect(ResourceSampler.forPlatform(), isA<PosixResourceSampler>());
});
});
group('PosixResourceSampler', () {
test('reads real /proc counts for this process', () {
if (!Platform.isLinux) return;
final s = PosixResourceSampler().sample();
expect(s.threads, greaterThan(0));
expect(s.handles, greaterThan(0)); // at least stdio fds
expect(s.rssBytes, greaterThan(0));
expect(s.children, greaterThanOrEqualTo(0));
});
});
group('WatchdogFile', () {
test('heartbeat + sample write tagged JSON lines', () {
WatchdogFile(path())
..heartbeat()
..sample(const ResourceSample(threads: 12, handles: 200, children: 0, rssBytes: 50 * 1024 * 1024))
..close();
final lines = File(path()).readAsLinesSync();
expect(lines, hasLength(2));
expect((jsonDecode(lines[0]) as Map)['evt'], 'hb');
final s = jsonDecode(lines[1]) as Map;
expect(s['evt'], 'sample');
expect(s['threads'], 12);
expect(s['rssMB'], 50);
expect(s['pid'], isA<int>());
});
test('null path → disabled, writes are no-ops', () {
final f = WatchdogFile(null);
expect(f.enabled, isFalse);
f
..heartbeat()
..sample(const ResourceSample())
..close();
});
test('bounded by the size cap', () {
final f = WatchdogFile(path(), capBytes: 200);
for (var i = 0; i < 100; i++) {
f.heartbeat();
}
f.close();
expect(File(path()).lengthSync(), lessThan(400));
});
});
group('runWatchdog', () {
test('emits an immediate heartbeat + sample on the first tick', () {
runWatchdog(WatchdogFile(path()), const _FakeSampler(), hbIntervalMs: 0, sampleIntervalMs: 0, maxTicks: 1);
final lines = File(path()).readAsLinesSync();
expect(lines, hasLength(2));
expect((jsonDecode(lines[0]) as Map)['evt'], 'hb');
final s = jsonDecode(lines[1]) as Map;
expect(s['evt'], 'sample');
expect(s['threads'], 7);
expect(s['handles'], 42);
expect(s['children'], 1);
expect(s['rssMB'], 100);
});
test('is a no-op when the file is disabled', () {
expect(() => runWatchdog(WatchdogFile(null), const _FakeSampler(), hbIntervalMs: 0, sampleIntervalMs: 0, maxTicks: 5), returnsNormally);
});
});
}