Files
mtgodot-poc/script/playable_soak_metrics.mjs
T
shenleiandClaude Opus 5 f39a55fdd5 feat(playable): 首个 Mac 联网内测版 STB-01 soak 工具与发布状态文档
- 新增 script/playable_soak.sh:先做 soak 配置/资源校验,无 --allow-gameplay
  只校验不启动客户端;N(>=10) 次独立正常退出运行 + 墙钟 soak,失败即停止后续
  运行并记 BLOCKED;写本批次 release-manifest.json 后聚合
- run_client_gate.sh:确认的故障代理对所有 suite 启动(共享场景的退出运行也经代理);
  soak 超时下限只约束 soak 客户端(validate_soak 增加 soak_client 参数)
- 新增本地 127.0.0.1 故障代理、RSS 采样/内存判定、窗口/指标/流程模块及其测试
- forest_mob_render_test 输出 PASS/FAIL 标记,供 rendering_batch_test.sh 识别
- 新增 docs/FIRST-MAC-PLAYABLE-STATUS.md:如实记录 PASS/BLOCKED、已知问题与环境需求
- .gitignore 排除本地场景配置、凭据文件与运行输出

离线回归:rendering_batch_test.sh failures=0,playable_gate_test.sh PASS,
node 夹具测试 PASS。未联网运行,未重建候选包。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ft3kXpNdLbKEfg5uDLfqVF
2026-09-11 22:06:48 +09:00

243 lines
12 KiB
JavaScript

#!/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();