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>
103 lines
3.4 KiB
Dart
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);
|
|
});
|
|
});
|
|
}
|