@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 +18 -0
- package/bin/health.mjs +1 -1
- package/lib/darwin.mjs +122 -2
- package/lib/features.mjs +1 -1
- package/lib/format.mjs +2 -0
- package/lib/probe.mjs +4 -1
- package/lib/run.mjs +4 -3
- package/lib/sample.mjs +113 -5
- package/lib/thresholds.mjs +11 -2
- package/package.json +1 -1
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()
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
233
|
-
|
|
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];
|
package/lib/thresholds.mjs
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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",
|