@volter/supercode-health 0.1.21 → 0.1.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -42,6 +42,24 @@ still-raised alarm mails again every `repeatMin` (60). Mail comes from
42
42
  it, within its bounds, and `supercode message waiting` / `supercode message expired` list
43
43
  what it has not delivered.
44
44
 
45
+ ## fseventsd on macOS
46
+
47
+ fseventsd logs `event table has grown too large` once, when its path table reaches its maximum; after that it may
48
+ free nothing, and its memory then grows with every new file until it is restarted (26 GB and 23 GB on two fleet
49
+ Macs). The probe reads that line from the unified log every two minutes (`/usr/bin/log show`, the window since its
50
+ last read, asked by the pid launchd runs and its system image path, never by a process's name; unknown, and said so,
51
+ where the log cannot be read) and raises `fseventsd.overflow:<pid>` at crit with
52
+ what to look at and the restart. It clears when that daemon is gone. `daemon.footprint:fseventsd:<pid>` warns past
53
+ 192 MB and is critical past 256 MB (3–60 MB is normal).
54
+
55
+ The probe never restarts anything. It also reads whether the fseventsd guard is installed and running: a root job
56
+ outside this pack that performs that restart, installed only by a person's own sudo
57
+ ([its README](../../packaging/npm/guard/README.md)). The reading comes from the world-readable files the guard
58
+ leaves in `/Library/dev.volter.supercode-health`. `supercode health status` always prints it: not installed (with the
59
+ line a person would run), installed with its last run, installed and silent, or installed and unable to read the
60
+ log, and its last restart if it ever made one. Any state but the second raises `fseventsd.guard` at warn, mailed when it begins and when it ends, never as a
61
+ reminder.
62
+
45
63
  ## Failures that keep happening
46
64
 
47
65
  The `supercode` feature also reads the machine's own records of failure each minute: the slow log
package/bin/health.mjs CHANGED
@@ -38,7 +38,7 @@ if (values.help || verb === 'help') {
38
38
  } else if (verb === 'once') {
39
39
  const probe = new Probe({ machine: values.machine, dir: `${dir}/once-${process.pid}` });
40
40
  await probe.init();
41
- await probe.sampler.sample(probe.features(), Date.now(), { wait: true });
41
+ await probe.sampler.sample(probe.features(), Date.now());
42
42
  await new Promise((r) => setTimeout(r, 5_000));
43
43
  const now = Date.now();
44
44
  const { readings, observations, families, errors } = await probe.sampler.sample(probe.features(), now, { wait: true });
package/lib/darwin.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  // macOS readers. Every one runs unprivileged; each returns raw cumulative values and leaves rates to derive.mjs.
2
- import { existsSync } from 'node:fs';
3
- import { delimiter, isAbsolute, join } from 'node:path';
2
+ import { closeSync, existsSync, fstatSync, openSync, readFileSync, readSync, statSync } from 'node:fs';
3
+ import { delimiter, dirname, isAbsolute, join, resolve } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { run, clockSeconds, topBytes } from './run.mjs';
6
6
 
@@ -181,5 +181,125 @@ export async function services(labels = []) {
181
181
  return map;
182
182
  }
183
183
 
184
+ const FSE_SERVICE = 'system/com.apple.fseventsd';
185
+ const FSE_IMAGE = '/System/Library/Frameworks/CoreServices.framework/';
186
+
187
+ /**
188
+ * The fseventsd launchd runs as its system service: { pid, startedAt (ms, from ps's elapsed time) }, or null when it
189
+ * runs none. The pid is launchd's own record (`launchctl print` answers without root), never a process's name.
190
+ */
191
+ export async function fseventsd({ signal } = {}) {
192
+ const r = await run('/bin/launchctl', ['print', FSE_SERVICE], { timeoutMs: 5000, signal });
193
+ if (!r.ok) throw new Error(r.error);
194
+ const pid = Number(/^\s*pid = (\d+)\s*$/m.exec(r.stdout)?.[1]);
195
+ if (!pid) return null;
196
+ const ps = await run('/bin/ps', ['-o', 'etime=', '-p', String(pid)], { timeoutMs: 5000, signal });
197
+ if (!ps.ok || !ps.stdout.trim()) throw new Error(`fseventsd ${pid} was not listed by ps: ${ps.error ?? 'no row'}`);
198
+ return { pid, startedAt: Date.now() - clockSeconds(ps.stdout.trim()) * 1000 };
199
+ }
200
+
201
+ /** One process's footprint as top reads it (phys_footprint, compressed pages included), unprivileged; about 1 s. */
202
+ export async function footprintOf(pid, { signal } = {}) {
203
+ const r = await run('/usr/bin/top', ['-l', '1', '-pid', String(pid), '-stats', 'pid,mem'], { timeoutMs: 10_000, signal });
204
+ if (!r.ok) throw new Error(r.error);
205
+ for (const line of r.stdout.split('\n')) {
206
+ const m = /^\s*(\d+)\s+(\S+)\s*$/.exec(line);
207
+ if (m && +m[1] === pid) return topBytes(m[2]);
208
+ }
209
+ return null;
210
+ }
211
+
212
+ /** The records the unified log holds as the given fseventsd's own "event table has grown too large" line. Who wrote a
213
+ * record is asked of the log (its pid and its image, which only the system can place on that sealed path), never
214
+ * read out of text: any program may name itself fseventsd and print what it likes. The guard uses the same rule. */
215
+ export const overflowPredicate = (pid) => `processIdentifier == ${Number(pid)} AND processImagePath BEGINSWITH "${FSE_IMAGE}" AND composedMessage CONTAINS "grown too large"`;
216
+ const pad = (n) => String(n).padStart(2, '0');
217
+ const logTime = (ms) => { const d = new Date(ms); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; };
218
+ // `log show` takes local wall time: beside a clock change an hour of it is ambiguous, so the window is an hour wider there
219
+ const shifts = (ms) => new Date(ms - 3_600_000).getTimezoneOffset() !== new Date(ms + 3_600_000).getTimezoneOffset();
220
+
221
+ /**
222
+ * That line for the daemon `pid`, read unprivileged (`log show` answers a member of the admin group; anyone else gets
223
+ * an error, which is thrown). One window: `lastMin` (the minutes up to now; about 2 s for 5 or 30 min) or `from`..`to`
224
+ * (ms). Each record is one line of JSON, checked again field by field; a read that did not end with the log's own
225
+ * `finished` record is thrown, never taken as empty. Answers [{ at (ISO, null when the time is not read), line }].
226
+ */
227
+ export async function fseventsOverflow({ pid, lastMin, from, to, timeoutMs = 30_000, signal }) {
228
+ const window = lastMin != null ? ['--last', `${lastMin}m`] : ['--start', logTime(shifts(from) ? from - 3_600_000 : from), '--end', logTime(shifts(to) ? to + 3_600_000 : to)];
229
+ const r = await run('/usr/bin/log', ['show', ...window, '--predicate', overflowPredicate(pid), '--style', 'ndjson'], { timeoutMs, signal });
230
+ if (!r.ok) throw new Error(r.error);
231
+ const hits = [];
232
+ let finished = false;
233
+ for (const line of r.stdout.split('\n')) {
234
+ let record = null; try { record = JSON.parse(line); } catch { continue; }
235
+ if (record?.finished === 1) { finished = true; continue; }
236
+ if (record?.processID !== pid || !String(record.processImagePath ?? '').startsWith(FSE_IMAGE) || !String(record.eventMessage ?? '').includes('grown too large')) continue;
237
+ const at = new Date(String(record.timestamp ?? '').replace(' ', 'T').replace(/([+-]\d\d)(\d\d)$/, '$1:$2'));
238
+ hits.push({ at: Number.isNaN(at.getTime()) ? null : at.toISOString(), line: String(record.eventMessage).replace(/[^\x20-\x7e]/g, ' ').slice(0, 200) });
239
+ }
240
+ if (!finished) throw new Error(`log show did not finish its listing: ${(r.stderr || r.stdout).trim().slice(0, 200) || 'it printed nothing'}`);
241
+ return hits;
242
+ }
243
+
244
+ const GUARD_DIR = '/Library/dev.volter.supercode-health';
245
+ const GUARD_PLIST = '/Library/LaunchDaemons/dev.volter.supercode-health.fseventsd-guard.plist';
246
+
247
+ /**
248
+ * Where this install carries the guard's installer, or null. The guard ships in the release package
249
+ * (@volter/supercode/guard), so from this pack's own place: the release it is installed inside
250
+ * (<release>/node_modules/@volter/supercode-health), the release beside the package it is installed inside (a global
251
+ * @volter/supercode-teams next to @volter/supercode), or a checkout (packaging/npm/guard).
252
+ */
253
+ function guardInstaller() {
254
+ const lib = dirname(fileURLToPath(import.meta.url));
255
+ const named = (dir) => { try { return JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')).name; } catch { return null; } };
256
+ const outer = resolve(lib, '..', '..', '..', '..'), repo = resolve(lib, '..', '..', '..');
257
+ const dirs = [outer, resolve(outer, '..', 'supercode')].filter((dir) => named(dir) === '@volter/supercode').map((dir) => join(dir, 'guard'));
258
+ if (existsSync(join(repo, 'packaging', 'npm', 'package.json.tmpl'))) dirs.push(join(repo, 'packaging', 'npm', 'guard'));
259
+ for (const dir of dirs) {
260
+ const file = join(dir, 'fseventsd-guard-install.sh');
261
+ if (existsSync(file) && existsSync(join(dir, 'fseventsd-guard.sh'))) return file;
262
+ }
263
+ return null;
264
+ }
265
+
266
+ /** The last `bytes` of a file as text, or null: the guard's log is read by its end, never whole. */
267
+ function tail(file, bytes = 4096) {
268
+ let fd = null;
269
+ try {
270
+ fd = openSync(file, 'r');
271
+ const size = fstatSync(fd).size, buffer = Buffer.alloc(Math.min(size, bytes));
272
+ readSync(fd, buffer, 0, buffer.length, size - buffer.length);
273
+ return buffer.toString('utf8');
274
+ } catch { return null; } finally { if (fd != null) closeSync(fd); }
275
+ }
276
+ const quoted = (text) => `'${String(text).replace(/'/g, `'\\''`)}'`;
277
+
278
+ /**
279
+ * The fseventsd guard (packaging/npm/guard: a root job outside this pack, which a person installs with their own
280
+ * sudo and which restarts an overflowed fseventsd past its line), read from the files it leaves, all world-readable:
281
+ * whether it is installed (its plist and its script, both root's), its last run and its last restart. `install` and
282
+ * `uninstall` are the lines a person runs, named for the status; the pack reads and runs nothing of the guard's.
283
+ */
284
+ export function fseventsdGuard(critMB) {
285
+ const stat = (file) => { try { return statSync(file); } catch { return null; } };
286
+ const plist = stat(GUARD_PLIST), script = stat(`${GUARD_DIR}/fseventsd-guard.sh`);
287
+ const last = tail(`${GUARD_DIR}/fseventsd-guard.last`, 512)?.trim() || null, installer = guardInstaller();
288
+ const at = Date.parse(/^at=(\S+)/.exec(last ?? '')?.[1] ?? '');
289
+ // the log's last whole line (a tail may begin inside one)
290
+ const acts = tail(`${GUARD_DIR}/fseventsd-guard.log`)?.split('\n').filter(Boolean) ?? [];
291
+ return {
292
+ installed: Boolean(plist && script && plist.uid === 0 && script.uid === 0),
293
+ installedAt: plist ? new Date(plist.mtimeMs).toISOString() : null,
294
+ lastRunAt: Number.isNaN(at) ? null : new Date(at).toISOString(),
295
+ lastRun: last ? last.replace(/^at=\S+\s*/, '').replace(/[^\x20-\x7e]/g, ' ').slice(0, 300) : null,
296
+ // how many runs in a row could not read the log (the guard's own count)
297
+ unreadRuns: / line=unread /.test(` ${last} `) ? Number(/ unread_runs=(\d+)/.exec(last ?? '')?.[1] ?? 1) : 0,
298
+ lastAct: acts.at(-1)?.replace(/[^\x20-\x7e]/g, ' ').slice(0, 600) ?? null,
299
+ install: installer ? `sudo /bin/sh ${quoted(installer)}${critMB ? ` ${Math.round(critMB)}` : ''}` : null,
300
+ uninstall: installer ? `sudo /bin/sh ${quoted(installer)} uninstall` : null,
301
+ };
302
+ }
303
+
184
304
  export const isRoot = () => process.getuid?.() === 0;
185
305
  export const powermetricsAvailable = () => existsSync('/usr/bin/powermetrics');
package/lib/features.mjs CHANGED
@@ -8,7 +8,7 @@ export const FEATURES = [
8
8
  { id: 'process-footprint', barrier: 'none', platforms: ['darwin', 'linux'], reads: "this user's processes: phys_footprint and disk writes (libproc / /proc/<pid>/io)" },
9
9
  { id: 'all-users-footprint', barrier: 'root', platforms: ['darwin', 'linux'], reads: "every user's processes: footprint and disk writes" },
10
10
  { id: 'renderers', barrier: 'none', platforms: ['darwin', 'linux'], reads: 'browser renderers: hot, runaway; orphaned headless browsers' },
11
- { id: 'daemons', barrier: 'none', platforms: ['darwin', 'linux'], reads: 'system daemons: CPU, and footprint of the largest' },
11
+ { id: 'daemons', barrier: 'none', platforms: ['darwin', 'linux'], reads: "system daemons: CPU, and footprint of the largest; macOS: fseventsd's event-table overflow line in the unified log" },
12
12
  { id: 'disk', barrier: 'none', platforms: ['darwin', 'linux', 'win32'], reads: 'free space per volume, fill rate, time to full' },
13
13
  { id: 'disk-io', barrier: 'none', platforms: ['darwin', 'linux'], reads: 'device throughput; top writers per process' },
14
14
  { id: 'tcp', barrier: 'none', platforms: ['darwin', 'linux', 'win32'], reads: 'sockets per state, ephemeral ports in use, TIME_WAIT by listener and its pid' },
package/lib/format.mjs CHANGED
@@ -40,6 +40,8 @@ export function formatStatus(s, { all = false } = {}) {
40
40
  for (const t of r.browserTabs.tabs ?? []) out.push(` ${t.url.slice(0, 70)} · ${t.owner}${who(t.server)}${t.renderers.length ? ` · renderer ${t.renderers.join(', ')}` : ''}`);
41
41
  }
42
42
  if (r.daemons) out.push(`daemons ${r.daemons.filter((d) => (d.cpuPct ?? 0) >= 5 || d.footprintMB >= 512).map((d) => `${d.name} ${n(d.cpuPct, '%')} ${d.footprintMB} MB`).join(' · ') || 'all quiet'}`);
43
+ if (r.fseventsd) out.push(`fseventsd pid ${r.fseventsd.pid} (started ${r.fseventsd.startedAt}): event table ${r.fseventsd.eventTable}${r.fseventsd.overflowAt ? ` at ${r.fseventsd.overflowAt}` : ''} · log read to ${r.fseventsd.logReadTo}`);
44
+ if (r.fseventsdGuard) out.push(r.fseventsdGuard.state, ...(r.fseventsdGuard.acted ? [` ${r.fseventsdGuard.acted}`] : []));
43
45
  if (r.disk) for (const d of r.disk) out.push(`disk ${d.mount}: ${d.freeGB} GB free of ${d.totalGB} (${d.freePct}%)${d.fillGBPerHour != null ? ` · ${d.fillGBPerHour >= 0 ? 'filling' : 'freeing'} ${Math.abs(d.fillGBPerHour)} GB/h` : ''}${d.hoursToFull != null ? ` · full in ${d.hoursToFull} h` : ''}`);
44
46
  if (r.diskIO) out.push(`disk I/O ${Object.entries(r.diskIO).map(([k, v]) => `${k} ${n(v.MBps, ' MB/s')}`).join(' · ')}`);
45
47
  if (r.diskWriters?.length) out.push(`writers ${r.diskWriters.slice(0, 3).map((w) => `${w.pid} ${w.writeMBps} MB/s ${w.command.slice(0, 50)}${who(w)}`).join(' · ')}`);
package/lib/probe.mjs CHANGED
@@ -167,7 +167,8 @@ export class Probe {
167
167
  net.push({ key, from: was.level, to: 'none', value: null, summary: t?.to === 'none' ? t.summary : `${was.summary} — cleared`, detail: t?.detail ?? was.detail });
168
168
  }
169
169
  const raisedNow = new Set(net.filter((t) => t.to !== 'none').map((t) => t.key));
170
- const reminders = alarms.filter((a) => !raisedNow.has(a.key) && now - (this.state[a.key].lastMailAt ?? 0) >= repeatMs);
170
+ // A standing state (thresholds.mjs judge) is never reminded: its raise and its clear are its two mails.
171
+ const reminders = alarms.filter((a) => !raisedNow.has(a.key) && !this.state[a.key].standing && now - (this.state[a.key].lastMailAt ?? 0) >= repeatMs);
171
172
  // A reading unknown for unknownMin is the maintainers' to know (raised or not), and again each repeatMin until read.
172
173
  // Timed per kind (a key's prefix; a family's whole key): a kind is due once its earliest key has been unknown
173
174
  // unknownMin and the kind was last mailed repeatMin ago, and then every key of it is said. Keys that join a kind one
@@ -287,6 +288,8 @@ export class Probe {
287
288
  async run({ signal } = {}) {
288
289
  await this.init();
289
290
  this.log(`probe ${process.pid} started on ${this.machine} (${this.platform}); features on: ${this.features().filter((f) => f.on).map((f) => f.id).join(', ')}`);
291
+ // a probe told to stop ends the reads that run beside its loop, so no child of theirs outlives it
292
+ signal?.addEventListener('abort', () => this.sampler.stop(), { once: true });
290
293
  while (!signal?.aborted) {
291
294
  const started = Date.now();
292
295
  try { await this.cycle(started); } catch (error) { this.log(`cycle failed: ${error.stack ?? error.message}`); }
package/lib/run.mjs CHANGED
@@ -1,10 +1,11 @@
1
1
  // One bounded child process. Every instrument goes through here: a reading that cannot finish in its budget
2
- // is an error the feature reports, never a hang of the probe.
2
+ // is an error the feature reports, never a hang of the probe. `signal` ends the child with its caller (a probe that
3
+ // stops leaves none behind).
3
4
  import { execFile } from 'node:child_process';
4
5
 
5
- export function run(file, args = [], { timeoutMs = 10_000, env, input, maxBuffer = 64 * 1024 * 1024 } = {}) {
6
+ export function run(file, args = [], { timeoutMs = 10_000, env, input, signal, maxBuffer = 64 * 1024 * 1024 } = {}) {
6
7
  return new Promise((resolve) => {
7
- const child = execFile(file, args, { timeout: timeoutMs, env, maxBuffer, windowsHide: true, encoding: 'utf8', shell: process.platform === 'win32' }, (error, stdout, stderr) => {
8
+ const child = execFile(file, args, { timeout: timeoutMs, env, maxBuffer, signal, windowsHide: true, encoding: 'utf8', shell: process.platform === 'win32' }, (error, stdout, stderr) => {
8
9
  if (error) resolve({ ok: false, stdout: stdout ?? '', stderr: stderr ?? '', error: error.killed ? `${file} timed out after ${timeoutMs} ms` : (error.code === 'ENOENT' ? `${file} not found` : (stderr || error.message).trim().slice(0, 300)) });
9
10
  else resolve({ ok: true, stdout, stderr });
10
11
  });
package/lib/sample.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  // The sampler: reads every feature that is on, turns cumulative counters into rates against the previous
2
2
  // sample, and states each alarm's observation. It keeps only what a rate or a window needs between samples.
3
3
  import { cpus, homedir, loadavg } from 'node:os';
4
- import { existsSync, readFileSync, statSync } from 'node:fs';
4
+ import { existsSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
5
5
  import { run } from './run.mjs';
6
6
  import { daemonCall } from './mail.mjs';
7
7
  import { rendererOrigins, originOf, localPort } from './chrome.mjs';
@@ -26,6 +26,13 @@ const HARNESS = { claude: 'claude-code', codex: 'codex', gemini: 'gemini', goose
26
26
  const RENDERER = /Helper \(Renderer\)|--type=renderer/;
27
27
  const BROWSER_MAIN = /(Google Chrome|Chromium|chrome-headless-shell|chrome)(\s|$)/i;
28
28
  const LOOPBACK = /^(127\.|::1$|localhost$)/;
29
+ // fseventsd's alarms say what they mean, what to look at and the one act that clears them (ADR 0006). The pack does
30
+ // not perform the act: a person does, or the root guard a person installed (packaging/npm/guard, outside the pack).
31
+ const FSE_LINE = 'event table has grown too large';
32
+ const FSE_ACT = 'Restart it as root: `sudo launchctl kickstart -k system/com.apple.fseventsd` (or `sudo killall fseventsd`); launchd starts it again and its clients reconnect';
33
+ const FSE_GROWTH = 'a footprint that rises and never falls back while the disk is quiet is that growth (it reached 26 GB on a fleet Mac)';
34
+ // What a daemon past its own footprint line means, said in its alarm.
35
+ const DAEMON_PAST = { fseventsd: `3–60 MB is normal. If it has logged "${FSE_LINE}" (the fseventsd.overflow alarm), its path table is full and may free nothing more: ${FSE_GROWTH}. ${FSE_ACT}` };
29
36
  /** The session an agent names on its own command line: `--resume <id>`, `--session-id <id>`, `-r <id>`, `codex resume <id>`. */
30
37
  function argvSession(command) {
31
38
  return /(?:--resume|--session-id|\s-r)[=\s]+([0-9a-f]{8}-[0-9a-f-]{27,})/.exec(command)?.[1]
@@ -34,7 +41,7 @@ function argvSession(command) {
34
41
  // The slow set's readings and alarm families, by the feature that produces them.
35
42
  const SLOW_FEATURE = { promises: 'promises', promise: 'promises', disk: 'disk', services: 'services', service: 'services', supercode: 'supercode', browserTabs: 'browser-tabs', tabs: 'browser-tabs', power: 'power' };
36
43
  // Every alarm family, by the feature that observes it.
37
- const FAMILY_FEATURE = { promise: 'promises', load: 'load', memory: 'memory', process: 'processes', browser: 'renderers', daemon: 'daemons', 'disk-writer': 'disk-io', files: 'files', tcp: 'tcp', disk: 'disk', service: 'services', supercode: 'supercode', tabs: 'browser-tabs', power: 'power' };
44
+ const FAMILY_FEATURE = { promise: 'promises', load: 'load', memory: 'memory', process: 'processes', browser: 'renderers', daemon: 'daemons', fsevents: 'daemons', 'fsevents-guard': 'daemons', 'disk-writer': 'disk-io', files: 'files', tcp: 'tcp', disk: 'disk', service: 'services', supercode: 'supercode', tabs: 'browser-tabs', power: 'power' };
38
45
  // The families a feature's read observes: when its read fails, each is unread (judged neither raised nor cleared).
39
46
  // `processes` also observes the renderer, daemon and disk-writer families it reads beside its own; `disk-io`'s own read
40
47
  // (device throughput) judges no family: the disk writers are the process read's, so a throughput read that fails leaves
@@ -56,11 +63,18 @@ export class Sampler {
56
63
  this.serviceRuns = new Map(); // service label → spawn count at the last reading
57
64
  this.recentListeners = new Map(); // port → when it was last seen listening (its TIME_WAITs outlive it)
58
65
  this.topFoot = { at: 0, map: new Map() };
66
+ this.fse = null; // macOS: fseventsd's event-table reading (#fsevents), kept between its reads
67
+ this.fseDue = 0;
68
+ this.fseRunning = null;
69
+ this.stopping = new AbortController(); // ends the children of the reads that run beside the loop (stop())
59
70
  this.slow = { at: 0, readings: {}, observations: [], families: new Set(), errors: {} };
60
71
  this.slowStarted = 0;
61
72
  this.slowRunning = null;
62
73
  }
63
74
 
75
+ /** The probe is stopping: the fseventsd read's children (`log show`, `top`) end with it, and no read starts. */
76
+ stop() { this.stopping.abort(); }
77
+
64
78
  /** One cycle. `features` is resolveFeatures's output; the slow set runs when its interval has passed. */
65
79
  async sample(features, now = Date.now(), { wait = false } = {}) {
66
80
  const on = new Set(features.filter((f) => f.on).map((f) => f.id));
@@ -138,7 +152,8 @@ export class Sampler {
138
152
  const same = prev && prev.command === r.command && r.cpuSec >= prev.cpuSec;
139
153
  r.cpuPct = same && dt ? ((r.cpuSec - prev.cpuSec) / dt) * 100 : null;
140
154
  const ru = rus.get(r.pid);
141
- const top = this.topFoot.map.get(r.pid);
155
+ // fseventsd's own footprint is read for it alone (#fsevents): top's list holds only the 25 largest
156
+ const top = (this.fse?.pid === r.pid ? this.fse.footprintBytes : null) ?? this.topFoot.map.get(r.pid);
142
157
  r.footprintBytes = ru?.footprintBytes ?? top ?? r.rssBytes;
143
158
  r.footprintSource = ru ? 'footprint' : top != null ? 'footprint (top)' : 'rss';
144
159
  r.writeBps = same && dt && ru && prev.written != null && ru.diskWrittenBytes >= prev.written ? (ru.diskWrittenBytes - prev.written) / dt : null;
@@ -229,8 +244,14 @@ export class Sampler {
229
244
  for (const r of daemons) {
230
245
  const name = base(r.command);
231
246
  if (r.cpuPct != null) observations.push({ key: `daemon.cpu:${name}:${r.pid}`, family: 'daemon', def: alarm('daemon.cpu'), value: r.cpuPct, unit: '% CPU', summary: `${name} at ${round(r.cpuPct, 0)}% CPU`, detail: view(r) });
232
- const line = footDef.perDaemonMB?.[name] ?? footDef.warnMB;
233
- if (r.footprintSource !== 'rss' || this.platform !== 'darwin') observations.push({ key: `daemon.footprint:${name}:${r.pid}`, family: 'daemon', def: { warn: line, forSec: 0 }, value: r.footprintBytes / MB, unit: 'MB', summary: `${name} holds ${Math.round(r.footprintBytes / MB)} MB`, detail: view(r) });
247
+ const own = footDef.perDaemonMB?.[name];
248
+ const line = own && typeof own === 'object' ? own : { warn: own ?? footDef.warnMB };
249
+ const mb = r.footprintBytes / MB;
250
+ // macOS RSS leaves out compressed pages, so it is no footprint: where a daemon's footprint could not be read
251
+ // it is judged only for a daemon with its own line, and only past that line (an RSS past it is no less)
252
+ const resident = r.footprintSource === 'rss' && this.platform === 'darwin';
253
+ if (!resident || (own != null && mb > line.warn)) observations.push({ key: `daemon.footprint:${name}:${r.pid}`, family: 'daemon', def: { warn: line.warn, crit: line.crit, forSec: 0 }, value: mb, unit: 'MB',
254
+ summary: `${name} holds ${Math.round(mb)} MB${resident ? ' resident (its footprint was not read)' : ''}${mb > line.warn && DAEMON_PAST[name] ? `: ${DAEMON_PAST[name]}` : ''}`, detail: view(r) });
234
255
  }
235
256
  }
236
257
 
@@ -321,6 +342,57 @@ export class Sampler {
321
342
  for (const t of timeWaitByTarget) observations.push({ key: `tcp.timeWaitTarget:${t.target}`, family: 'tcp', def, value: t.pct, unit: '% of ports', summary: `${t.count} TIME_WAIT against ${t.target.startsWith(':') ? `listener ${t.target}${t.exited ? ' (listener has exited; its TIME_WAITs are draining)' : ''}` : t.target}${t.listener ? ` (pid ${t.listener.pid})` : ''}; range ${size}, TIME_WAIT lasts ${timeWaitSec} s`, detail: t });
322
343
  });
323
344
 
345
+ // ---- macOS: fseventsd's event table ---------------------------------------------------------------
346
+ // Its own read beside the fast loop (`log show` takes seconds), merged from what it last found.
347
+ if (on.has('daemons') && this.reader.fseventsOverflow) {
348
+ const def = alarm('fseventsd.overflow');
349
+ if (!this.fseRunning && now >= this.fseDue && !this.stopping.signal.aborted) {
350
+ this.fseDue = now + def.everySec * 1000;
351
+ this.fseRunning = this.#fsevents(def, now)
352
+ .then((more) => { if (more) this.fseDue = 0; }, (error) => { this.fse = { ...(this.fse ?? { pid: null }), unlisted: true, unread: error.message }; })
353
+ .finally(() => { this.fseRunning = null; });
354
+ }
355
+ if (wait && this.fseRunning) await this.fseRunning;
356
+ const fse = this.fse;
357
+ if (fse) families.add('fsevents');
358
+ if (fse?.unlisted) observations.push({ unreadFamily: 'fsevents', family: 'fsevents', why: `fseventsd could not be read: ${fse.unread}` });
359
+ else if (fse) {
360
+ // the one fseventsd was read: an alarm on another pid is on a daemon that is gone (it was restarted)
361
+ observations.push({ enumerated: 'fsevents', family: 'fsevents', by: 'pid', entities: new Set(fse.pid ? [fse.pid] : []) });
362
+ if (fse.pid) {
363
+ const key = `fseventsd.overflow:${fse.pid}`;
364
+ const startedAt = new Date(fse.startedAt).toISOString(), logReadTo = new Date(fse.readTo).toISOString();
365
+ const unread = fse.unread ? `the unified log could not be read for fseventsd's "${FSE_LINE}" line: ${fse.unread}`
366
+ : fse.behind ? `the unified log is read for fseventsd's "${FSE_LINE}" line from its start (${startedAt}) only as far as ${logReadTo} yet` : null;
367
+ readings.fseventsd = { pid: fse.pid, startedAt, logReadTo, eventTable: fse.overflow ? 'overflowed' : unread ? 'unknown' : 'not overflowed', overflowAt: fse.overflow?.at ?? null, ...(unread && !fse.overflow ? { unread } : {}) };
368
+ const row = procs?.byPid.get(fse.pid);
369
+ const holds = row ? ` (now ${Math.round(row.footprintBytes / MB)} MB${row.footprintSource === 'rss' ? ' resident' : ''}; 3–60 MB is normal)` : ' (3–60 MB is normal)';
370
+ if (fse.overflow) observations.push({ key, family: 'fsevents', def, value: 1, met: { warn: true, crit: true, clears: false },
371
+ summary: `fseventsd logged "${FSE_LINE}" at ${fse.overflow.at ?? 'a time that could not be read'}: its path table is at its maximum, and once its cleanup has stopped it frees nothing, so every new file adds to its memory until it is restarted. Look at its footprint${holds}: ${FSE_GROWTH}. ${FSE_ACT}`, detail: { pid: fse.pid, command: 'fseventsd', startedAt, loggedAt: fse.overflow.at, line: fse.overflow.line } });
372
+ else if (unread) observations.push({ unreadKey: key, family: 'fsevents', why: unread });
373
+ else observations.push({ key, family: 'fsevents', def, value: 0, met: { warn: false, crit: false, clears: true }, summary: `fseventsd ${fse.pid} has not logged "${FSE_LINE}" since it started at ${startedAt}` });
374
+ }
375
+ }
376
+ // The guard is not the pack's: the pack reads the files it leaves. Where it is not installed nothing on this Mac
377
+ // performs the restart; that is a standing state, said when it begins and when it ends and shown in every status.
378
+ try {
379
+ const guard = this.reader.fseventsdGuard(alarm('daemon.footprint').perDaemonMB?.fseventsd?.crit);
380
+ const ranAt = Date.parse(guard.lastRunAt ?? guard.installedAt ?? '');
381
+ const stale = guard.installed && !(now - ranAt < def.guardSilentMin * 60_000);
382
+ const line = guard.install ? `\`${guard.install}\`` : 'with the installer in a supercode release (this install carries none: guard/fseventsd-guard-install.sh)';
383
+ const blind = guard.installed && !stale && guard.unreadRuns >= def.guardUnreadRuns;
384
+ const state = !guard.installed ? `not installed: nothing on this Mac restarts fseventsd when it has logged "${FSE_LINE}" and its memory grows. Installing it is the one act on this machine no agent can take (none has root); a person runs it once: ${line}`
385
+ : stale ? `installed, but it has not run since ${guard.lastRunAt ?? guard.installedAt} (it runs every 5 min). A person installs it again: ${line}`
386
+ : blind ? `installed, but it could not read the unified log on its last ${guard.unreadRuns} runs (${guard.lastRun}): a guard that cannot read is not guarding`
387
+ : `installed, last run ${guard.lastRunAt ?? 'not yet'}${guard.lastRun ? ` (${guard.lastRun})` : ''}`;
388
+ const [actAt, act, ...actRest] = (guard.lastAct ?? '').split(' ');
389
+ const acted = guard.lastAct ? `fseventsd guard ${act === 'restarted' ? 'restarted' : 'could not restart'} the daemon at ${actAt} (${actRest.join(' ')})` : null;
390
+ readings.fseventsdGuard = { ...guard, state: `fseventsd guard: ${state}`, acted };
391
+ families.add('fsevents-guard');
392
+ observations.push({ key: 'fseventsd.guard', family: 'fsevents-guard', def: { forSec: 0 }, value: guard.installed && !stale && !blind ? 0 : 1, met: { warn: !guard.installed || stale || blind, crit: false, clears: guard.installed && !stale && !blind }, standing: true, summary: `fseventsd guard: ${state}` });
393
+ } catch (error) { observations.push({ unreadKey: 'fseventsd.guard', family: 'fsevents-guard', why: `the fseventsd guard's files could not be read: ${error.message}` }); }
394
+ }
395
+
324
396
  // ---- the slow set ---------------------------------------------------------------------------------
325
397
  // It runs beside the fast loop (the connector can take 20 s to answer) and is merged when it lands;
326
398
  // `wait` (a single look) waits for it.
@@ -344,6 +416,42 @@ export class Sampler {
344
416
  return { readings, observations, families, errors };
345
417
  }
346
418
 
419
+ /**
420
+ * macOS: whether the fseventsd launchd runs has logged that its event table overflowed, and its footprint (top, for
421
+ * that pid alone: a read that fails leaves the process list's reading). Each read covers the log since
422
+ * the last one; a daemon not read before is read from its start, a chunk a cycle (`true`: more is left), and a
423
+ * window that cannot be read in its budget is halved. The line, once read, stands for that pid and the log is not
424
+ * read for it again. What was read is kept in fseventsd.json, so a probe that restarts takes up where it stopped
425
+ * and keeps a line the log has since dropped. Anything not read is said (`unread`), never taken as no line.
426
+ */
427
+ async #fsevents(def, now) {
428
+ const file = join(healthDir(this.env), 'fseventsd.json');
429
+ let fse = this.fse;
430
+ if (!fse) try { fse = JSON.parse(readFileSync(file, 'utf8')); } catch { /* not read on this machine before */ }
431
+ let daemon;
432
+ const signal = this.stopping.signal;
433
+ try { daemon = await this.reader.fseventsd({ signal }); } catch (error) { this.fse = { ...(fse ?? { pid: null }), unlisted: true, unread: error.message }; return false; }
434
+ if (!daemon) { this.fse = { pid: null }; return false; }
435
+ // the same daemon keeps its pid and its start (ps gives the start to the second)
436
+ if (fse?.pid !== daemon.pid || Math.abs(fse.startedAt - daemon.startedAt) > 60_000) fse = { pid: daemon.pid, startedAt: daemon.startedAt, readTo: daemon.startedAt - 5000, overflow: null };
437
+ fse = { ...fse, unlisted: false, unread: null, footprintBytes: await this.reader.footprintOf(daemon.pid, { signal }).catch(() => null) };
438
+ if (fse.overflow) { this.fse = fse; return false; }
439
+ const chunk = fse.chunkMs ?? def.chunkHours * 3_600_000;
440
+ const behind = now - fse.readTo > chunk;
441
+ const to = behind ? fse.readTo + chunk : now;
442
+ try {
443
+ const hits = await this.reader.fseventsOverflow({ pid: fse.pid, ...(behind ? { from: fse.readTo, to } : { lastMin: Math.ceil((now - fse.readTo) / 60_000) + 1 }), timeoutMs: def.timeoutSec * 1000, signal });
444
+ // every record read is this daemon's own: the log was asked by its pid and its image (darwin.mjs overflowPredicate)
445
+ fse = { ...fse, readTo: to, behind, overflow: hits[0] ?? null };
446
+ } catch (error) {
447
+ fse = { ...fse, unread: error.message, ...(/timed out/.test(error.message) && chunk > 300_000 ? { chunkMs: Math.max(300_000, chunk / 2) } : {}) };
448
+ }
449
+ this.fse = fse;
450
+ // kept in memory when it cannot be written: the reading stands either way
451
+ try { const temp = `${file}.${process.pid}.tmp`; writeFileSync(temp, JSON.stringify(fse, null, 2) + '\n', { mode: 0o600 }); renameSync(temp, file); } catch { /* read again from the daemon's start after a restart */ }
452
+ return behind && !fse.unread && !fse.overflow;
453
+ }
454
+
347
455
  async #slow(on, slow, errors, now) {
348
456
  const cfg = this.config;
349
457
  const alarm = (name) => cfg.alarms[name];
@@ -27,7 +27,14 @@ export const DEFAULTS = {
27
27
  'process.growth': { minMB: 4096, mbPerMin: 1024 },
28
28
  'browser.orphan': { forSec: 600 },
29
29
  'daemon.cpu': { warn: 40, crit: 100, clear: 20, forSec: 120 },
30
- 'daemon.footprint': { warnMB: 4096, perDaemonMB: { fseventsd: 1024 } },
30
+ // a daemon's own line is a number (its warn) or { warn, crit }
31
+ 'daemon.footprint': { warnMB: 4096, perDaemonMB: { fseventsd: { warn: 192, crit: 256 } } },
32
+ // macOS: fseventsd's "event table has grown too large" line, read from the unified log each everySec, the window
33
+ // since the last read, with the daemon's own footprint; a daemon not read before is read from its start,
34
+ // chunkHours of log a cycle
35
+ // guardSilentMin: the root guard a person installs (packaging/npm/guard, every 5 min) reads as not running once silent this long,
36
+ // and as not guarding once guardUnreadRuns of its runs in a row could not read the log
37
+ 'fseventsd.overflow': { forSec: 0, everySec: 120, chunkHours: 6, timeoutSec: 30, guardSilentMin: 15, guardUnreadRuns: 3 },
31
38
  'disk.free': { below: true, warnPct: 10, critPct: 5, forSec: 1800 },
32
39
  'disk.filling': { warnHours: 24, warnBelowPct: 40, critHours: 4, lookbackMin: 60 },
33
40
  'disk.writer': { warn: 50, forSec: 300 },
@@ -89,6 +96,8 @@ const RANK = { none: 0, warn: 1, crit: 2 };
89
96
  * - its feature is off (`{ off: family }`).
90
97
  * Anything else not observed is unknown: the alarm holds, saying since when and why (`{ unreadFamily | unreadKey |
91
98
  * unreadPrefix, why }` give the why where a site knows it; otherwise it is "not observed this cycle").
99
+ * An observation marked `standing` is a known state, not an event: its record carries the mark, and it is mailed when
100
+ * it raises and when it clears, never as a reminder (probe.mjs).
92
101
  * `observations` also carry the readings: [{ key, family, def, value, met?, unit, summary, detail }].
93
102
  * Every unknown (raised or not: a key or a family that could not be read) is kept in `unknowns` (key → { why,
94
103
  * since }) for its notification (probe.mjs); one read again leaves it.
@@ -139,7 +148,7 @@ export function judge(state, observations, families, now, unknowns = {}) {
139
148
  else if (m.warn && now - s.warnSince >= warnFor) target = 'warn';
140
149
  // Hysteresis: a raised alarm holds at warn until the value is past its clear line.
141
150
  if (target === 'none' && s.level !== 'none' && !m.clears) target = 'warn';
142
- Object.assign(s, { value: o.value, unit: o.unit ?? null, summary: o.summary, detail: o.detail ?? null, at: now, unread: null, unreadAt: null });
151
+ Object.assign(s, { value: o.value, unit: o.unit ?? null, summary: o.summary, detail: o.detail ?? null, at: now, unread: null, unreadAt: null, standing: Boolean(o.standing) });
143
152
  if (target !== s.level) {
144
153
  transitions.push({ key: o.key, from: s.level, to: target, value: o.value, unit: o.unit ?? null, summary: o.summary, detail: o.detail ?? null });
145
154
  if (RANK[target] > RANK[s.level] && s.level === 'none') s.raisedAt = now;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-health",
3
- "version": "0.1.21",
3
+ "version": "0.1.22",
4
4
  "type": "module",
5
5
  "description": "Optional machine pack: a cheap, deterministic probe of a machine's vitals that mails the machine's maintainer when a threshold is crossed. Each feature sits behind its own permission barrier.",
6
6
  "license": "MIT",