Files
clide/lib/kernel/src/watchdog.dart
T
jpmschweitzerandClaude Opus 4.8 ca08c2a17d feat(web): fence dart:ffi behind web stubs so the WASM build compiles (T-438, D-100)
`flutter build web --wasm` had been broken since the tree-sitter/PTY dart:ffi
pivot. Per D-100 (resolving Q-50: keep the web "happy accident" alive), every
native binding now sits behind a `dart.library.ffi` conditional import with a
graceful web stub. Desktop builds are unchanged — no fidelity loss; the web
target degrades (no terminal, native git, or syntax highlighting).

Discriminator is `dart.library.ffi`, not `dart.library.io` — dart2wasm provides
dart:io, so FFI is the only blocker.

Fences:
- PTY: pty_session → pty_backend_io / pty_backend_web (stub throws).
- tree-sitter: pure types → syntax_result.dart; tree_sitter_service is now a
  facade over _ffi/_stub; tree_sitter_boot_io/stub fences TreeSitterLib.init().
- watchdog: watchdog_windows_stub (all -1 sampler).
- claude ABI probe: native_abi_io/stub (was `dart:ffi show Abi`).
- testmode fd-check: fd_check_io/stub.

Also dart2js-safe: the 64-bit FNV literals in session_naming.dart + paths.dart
(the dual JS fallback rejected them) — split into 32-bit halves, dropped a
no-op 64-bit mask. Desktop/wasm hash values unchanged.

CI: added a `web-wasm` job (flutter build web --wasm) so the fence can't rot.
Two FFI-constructing tree-sitter tests import _ffi.dart directly (the analyzer
resolves the conditional facade to the stub branch).

Verified: `flutter build web --wasm` → built; `flutter analyze` clean;
`make test` green. Full Playwright e2e harness wiring is the tracked follow-on.

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

204 lines
7.0 KiB
Dart

/// 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';
// Web fence (T-438, D-100): the FFI-backed Windows sampler is reachable only
// when `dart.library.ffi` is available; the web build gets an all-`-1` stub.
import 'watchdog_windows_stub.dart' if (dart.library.ffi) '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);
}