Files
clide/test/kernel/watchdog_test.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

103 lines
3.4 KiB
Dart

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);
});
});
}