#!/usr/bin/env node /* STB-01 §9.1 external memory probe and leak gate. * * playable_soak_metrics.mjs --sample-rss --pid PID --output rss.jsonl [--interval-ms 1000] * Appends {wall_ms, rss_kib} for exactly one PID (the runner's own child) via * `ps -o stat=,rss= -p PID` once per interval and exits when that PID is gone or a zombie. It * never signals the PID. A failed read is written as rss_kib null, not 0. * * playable_soak_metrics.mjs --seal --report report.json --rss rss.jsonl --events events.jsonl * [--warmup-rounds 1] [--min-rounds 10] * Reads the client's soak_rest start/end events (payload.round, payload.wall_ms), * takes the RSS low water inside each >=30 s rest window, applies the leak rule * and writes report.memory + runner_cases[STB-MEMORY-01] back into the sealed * report. The verdict can only downgrade the report status. * * Leak rule: after warm-up, FAIL when the low water grew in >=5 consecutive rounds * AND median(last 5) - median(first 5) > max(50 MiB, 5% of median(first 5)). * Below the threshold is not proof that there is no long-term leak. * * exit: 0 PASS, 1 FAIL, 2 BLOCKED or usage error. */ import fs from 'node:fs'; import path from 'node:path'; import { execFile } from 'node:child_process'; import { fileURLToPath } from 'node:url'; const REST_MIN_MS = 30000; const STREAK = 5; const ABS_THRESHOLD_KIB = 50 * 1024; const PCT_THRESHOLD = 0.05; const NOTE = '阈值以下不能证明长期无泄漏;GPU 内存不可得(null/unavailable),不据此判断显存。'; const CASE_ID = 'STB-MEMORY-01'; const RANK = { PASS: 0, BLOCKED: 1, FAIL: 2 }; export function median(values) { const sorted = [...values].sort((a, b) => a - b); if (sorted.length === 0) return null; const mid = Math.floor(sorted.length / 2); return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2; } export function parseRss(text) { const samples = []; let badLines = 0; for (const line of String(text).split('\n')) { if (line.trim() === '') continue; try { const row = JSON.parse(line); if (!Number.isFinite(row.wall_ms) || !(row.rss_kib === null || Number.isInteger(row.rss_kib))) badLines += 1; else samples.push({ wall_ms: row.wall_ms, rss_kib: row.rss_kib }); } catch { badLines += 1; } } return { samples, bad_lines: badLines }; } /** Pair soak_rest start/end events per round. Unpaired or reordered events are errors. */ export function restWindows(eventsText) { const open = new Map(); const windows = []; const errors = []; for (const line of String(eventsText).split('\n')) { if (line.trim() === '') continue; let event; try { event = JSON.parse(line); } catch { continue; } if (event?.kind !== 'soak_rest') continue; const { round, state, wall_ms: wallMs } = event.payload ?? {}; if (!Number.isInteger(round) || !Number.isFinite(wallMs)) { errors.push('soak_rest event without integer round/wall_ms'); continue; } if (state === 'start') { if (open.has(round)) errors.push(`round ${round}: rest started twice`); open.set(round, wallMs); } else if (state === 'end') { if (!open.has(round)) { errors.push(`round ${round}: rest ended without a start`); continue; } const start = open.get(round); open.delete(round); if (wallMs < start) errors.push(`round ${round}: rest ends before it starts`); else windows.push({ round, start_ms: start, end_ms: wallMs, rest_ms: wallMs - start }); } } for (const round of open.keys()) errors.push(`round ${round}: rest never ended`); windows.sort((a, b) => a.round - b.round); return { windows, errors }; } export function roundLowWaters(windows, samples) { return windows.map((window) => { const inside = samples.filter((s) => s.wall_ms >= window.start_ms && s.wall_ms <= window.end_ms && Number.isInteger(s.rss_kib)); // At least one sample per two seconds of rest; a sparse window is not a measured low water. const needed = Math.max(1, Math.floor(window.rest_ms / 2000)); return { round: window.round, rest_ms: window.rest_ms, samples: inside.length, low_water_kib: inside.length >= needed ? Math.min(...inside.map((s) => s.rss_kib)) : null, }; }); } export function leakVerdict(lowWaters, { warmupRounds = 1, minRounds = 10 } = {}) { const verdict = { status: 'BLOCKED', reason: '', unit: 'KiB', warmup_rounds: warmupRounds, rounds_total: lowWaters.length, rounds_analysed: 0, first5_median_kib: null, last5_median_kib: null, growth_kib: null, threshold_kib: null, max_growth_streak: 0, rule: `FAIL iff >=${STREAK} consecutive growing rounds AND last5 median - first5 median > max(50MiB, 5%)`, warnings: [], note: NOTE, }; const short = lowWaters.find((row) => row.rest_ms < REST_MIN_MS); if (short) return { ...verdict, status: 'FAIL', reason: `round ${short.round}: rest ${short.rest_ms} ms is shorter than 30 s` }; const rows = lowWaters.slice(warmupRounds); verdict.rounds_analysed = rows.length; if (rows.length < minRounds) return { ...verdict, reason: `need at least ${minRounds} rounds after warm-up, got ${rows.length}` }; const missing = rows.find((row) => row.low_water_kib === null); if (missing) return { ...verdict, reason: `round ${missing.round}: no RSS low water (sampler gap)` }; const values = rows.map((row) => row.low_water_kib); let streak = 0; for (let i = 1; i < values.length; i += 1) { streak = values[i] > values[i - 1] ? streak + 1 : 0; verdict.max_growth_streak = Math.max(verdict.max_growth_streak, streak); } const first = Math.round(median(values.slice(0, STREAK))); const last = Math.round(median(values.slice(-STREAK))); verdict.first5_median_kib = first; verdict.last5_median_kib = last; verdict.growth_kib = last - first; verdict.threshold_kib = Math.max(ABS_THRESHOLD_KIB, Math.ceil(PCT_THRESHOLD * first)); const overThreshold = verdict.growth_kib > verdict.threshold_kib; const streakHit = verdict.max_growth_streak >= STREAK; if (overThreshold && streakHit) { verdict.status = 'FAIL'; verdict.reason = `low water grew ${verdict.max_growth_streak} rounds in a row and +${verdict.growth_kib} KiB > ${verdict.threshold_kib} KiB; analyse before release`; return verdict; } if (overThreshold) verdict.warnings.push(`median growth +${verdict.growth_kib} KiB exceeds the threshold without ${STREAK} consecutive growing rounds; needs analysis`); verdict.status = 'PASS'; verdict.reason = 'no leak signal at the gate threshold'; return verdict; } export function sealMemory(report, { eventsText, rssText, warmupRounds = 1, minRounds = 10 }) { const rss = parseRss(rssText ?? ''); const rest = restWindows(eventsText ?? ''); const rounds = roundLowWaters(rest.windows, rss.samples); let verdict = leakVerdict(rounds, { warmupRounds, minRounds }); if (rest.errors.length > 0) verdict = { ...verdict, status: 'FAIL', reason: `rest events: ${rest.errors.slice(0, 5).join('; ')}` }; else if (rest.windows.length === 0) verdict = { ...verdict, status: 'BLOCKED', reason: 'no soak_rest windows in events' }; // The client's own MEMORY_STATIC snapshots stay separate from the external RSS. const godot = report.memory?.godot ?? report.memory ?? null; report.memory = { sampler: 'external ps -o stat=,rss= on the runner-held child PID, 1 Hz', unit: 'KiB', sample_count: rss.samples.length, samples_null: rss.samples.filter((s) => s.rss_kib === null).length, bad_lines: rss.bad_lines, rounds, gpu_memory_kib: null, gpu_memory_status: 'unavailable', godot, verdict, }; report.runner_cases = (Array.isArray(report.runner_cases) ? report.runner_cases : []).filter((c) => c?.id !== CASE_ID); report.runner_cases.push({ id: CASE_ID, status: verdict.status, reason: verdict.reason }); if (!VALID_STATUS(report.status)) report.status = 'FAIL'; if (RANK[verdict.status] > RANK[report.status]) report.status = verdict.status; report.exit_gate = report.exit_gate && typeof report.exit_gate === 'object' ? report.exit_gate : { checked: false, errors: [] }; if (!Array.isArray(report.exit_gate.errors)) report.exit_gate.errors = []; if (verdict.status !== 'PASS') report.exit_gate.errors.push(`${CASE_ID}: ${verdict.status} ${verdict.reason}`); return { report, code: { PASS: 0, FAIL: 1, BLOCKED: 2 }[report.status] ?? 1 }; } function VALID_STATUS(status) { return Object.hasOwn(RANK, status); } function usage(stream = process.stderr) { stream.write('usage: playable_soak_metrics.mjs --sample-rss --pid PID --output rss.jsonl [--interval-ms 1000]\n' + ' playable_soak_metrics.mjs --seal --report report.json --rss rss.jsonl --events events.jsonl [--warmup-rounds 1] [--min-rounds 10]\n'); } function argsOf(argv) { const out = {}; for (let i = 0; i < argv.length; i += 1) { const arg = argv[i]; if (['--sample-rss', '--seal', '--help'].includes(arg)) out[arg.slice(2).replace('-', '_')] = true; else if (arg.startsWith('--') && i + 1 < argv.length) out[arg.slice(2).replaceAll('-', '_')] = argv[++i]; else throw new Error(`bad argument: ${arg}`); } return out; } function readRss(pid) { return new Promise((resolve) => { execFile('ps', ['-o', 'stat=,rss=', '-p', String(pid)], { timeout: 5000 }, (error, stdout) => { if (error) { resolve({ alive: false }); return; } const [stat = '', rss = ''] = String(stdout).trim().split(/\s+/); // An exited child the runner has not reaped yet is a zombie: it is gone, not 0 KiB. if (stat.startsWith('Z')) { resolve({ alive: false }); return; } const value = Number.parseInt(rss, 10); resolve({ alive: true, rss_kib: Number.isInteger(value) && value > 0 ? value : null }); }); }); } async function sampleRss(options) { const pid = Number(options.pid); const interval = Number(options.interval_ms ?? 1000); if (!Number.isInteger(pid) || pid <= 0 || !options.output || !Number.isInteger(interval) || interval < 100) { usage(); return 2; } let stop = false; process.on('SIGTERM', () => { stop = true; }); process.on('SIGINT', () => { stop = true; }); while (!stop) { const tick = Date.now(); const row = await readRss(pid); if (!row.alive) break; fs.appendFileSync(options.output, `${JSON.stringify({ wall_ms: tick, rss_kib: row.rss_kib })}\n`); await new Promise((resolve) => setTimeout(resolve, Math.max(0, interval - (Date.now() - tick)))); } return 0; } function seal(options) { for (const key of ['report', 'rss', 'events']) if (!options[key]) { usage(); return 2; } let report; try { report = JSON.parse(fs.readFileSync(options.report, 'utf8')); } catch { console.error(`PLAYABLE MEMORY: FAIL report unreadable ${path.basename(options.report)}`); return 1; } const read = (file) => (fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : ''); const { report: sealed, code } = sealMemory(report, { eventsText: read(options.events), rssText: read(options.rss), warmupRounds: Number(options.warmup_rounds ?? 1), minRounds: Number(options.min_rounds ?? 10), }); fs.writeFileSync(options.report, `${JSON.stringify(sealed, null, 2)}\n`); const v = sealed.memory.verdict; const line = `PLAYABLE MEMORY: ${v.status} ${v.reason} (first5=${v.first5_median_kib} last5=${v.last5_median_kib} KiB)`; if (v.status === 'PASS') console.log(line); else console.error(line); return code; } async function main() { let options; try { options = argsOf(process.argv.slice(2)); } catch (error) { usage(); console.error(error.message); return 2; } if (options.help) { usage(process.stdout); return 0; } if (options.sample_rss) return sampleRss(options); if (options.seal) return seal(options); usage(); return 2; } if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) process.exitCode = await main();