- 新增 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
146 lines
7.7 KiB
JavaScript
146 lines
7.7 KiB
JavaScript
#!/usr/bin/env node
|
|
/* STB-DISCONNECT-01 fault proxy regression. Uses only 127.0.0.1 ephemeral ports and a
|
|
* local echo server as the "upstream"; never contacts the test server. Proves that the
|
|
* proxy forwards bytes, closes the connection on `close`, refuses connections for the
|
|
* `unreachable` window and listens again, acts on each request once, ignores foreign
|
|
* run_ids, and stops cleanly on SIGTERM. */
|
|
import fs from 'node:fs';
|
|
import net from 'node:net';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import { spawn, spawnSync } from 'node:child_process';
|
|
import { parseArgs } from './playable_fault_proxy.mjs';
|
|
|
|
const script = path.join(path.dirname(new URL(import.meta.url).pathname), 'playable_fault_proxy.mjs');
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mt-fault-proxy.'));
|
|
let failures = 0;
|
|
const check = (ok, label) => { if (ok) console.log(`ok - ${label}`); else { failures += 1; console.error(`not ok - ${label}`); } };
|
|
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
|
|
async function until(predicate, timeoutMs, stepMs = 50) {
|
|
const end = Date.now() + timeoutMs;
|
|
while (Date.now() < end) {
|
|
if (await predicate()) return true;
|
|
await delay(stepMs);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function freePort() {
|
|
return new Promise((resolve) => {
|
|
const server = net.createServer();
|
|
server.listen(0, '127.0.0.1', () => { const { port } = server.address(); server.close(() => resolve(port)); });
|
|
});
|
|
}
|
|
|
|
function connect(port) {
|
|
return new Promise((resolve) => {
|
|
const socket = net.connect(port, '127.0.0.1');
|
|
const state = { socket, closed: false, data: '', error: null };
|
|
socket.on('data', (chunk) => { state.data += chunk.toString(); });
|
|
socket.on('close', () => { state.closed = true; });
|
|
socket.once('connect', () => resolve(state));
|
|
socket.once('error', (error) => { state.error = error.code; resolve(state); });
|
|
});
|
|
}
|
|
|
|
const logRows = (file) => (fs.existsSync(file) ? fs.readFileSync(file, 'utf8').trim().split('\n').filter(Boolean).map((l) => JSON.parse(l)) : []);
|
|
const actions = (file, action) => logRows(file).filter((row) => row.action === action);
|
|
let usClock = 1000;
|
|
const request = (file, type, attempt, extra = {}) => fs.appendFileSync(file, `${JSON.stringify({ monotonic_us: (usClock += 1000), run_id: 'run-a',
|
|
case_id: 'STB-DISCONNECT-01', connection_epoch: 2, stage: 'FAULT', kind: 'fault_request', actor_vid: 0, target_vid: 0,
|
|
payload: { type, attempt, round: 0, timeout_ms: 30000, unreachable_seconds: type === 'unreachable' ? 2 : 0, ...extra } })}\n`);
|
|
|
|
// Argument contract.
|
|
{
|
|
const help = spawnSync(process.execPath, [script, '--help'], { encoding: 'utf8' });
|
|
check(help.status === 0 && /127\.0\.0\.1/.test(help.stdout), '--help documents loopback-only listening');
|
|
for (const [label, args] of [
|
|
['no routes', ['--events', 'e', '--log', 'l']],
|
|
['malformed route', ['--route', 'abc', '--events', 'e', '--log', 'l']],
|
|
['route to itself', ['--route', '4000=127.0.0.1:4000', '--events', 'e', '--log', 'l']],
|
|
['port out of range', ['--route', '70000=10.0.0.1:1', '--events', 'e', '--log', 'l']],
|
|
['unknown option', ['--route', '4000=10.0.0.1:1', '--events', 'e', '--log', 'l', '--listen-host', '0.0.0.0']],
|
|
]) {
|
|
const run = spawnSync(process.execPath, [script, ...args], { encoding: 'utf8' });
|
|
check(run.status === 2, `usage error exits 2: ${label}`);
|
|
}
|
|
check(parseArgs(['--route', '4000=192.0.2.1:11000', '--events', 'e', '--log', 'l']).routes[0].host === '192.0.2.1', 'route parses upstream host');
|
|
}
|
|
|
|
// Live behaviour against a local echo upstream.
|
|
const upstream = net.createServer((socket) => socket.pipe(socket));
|
|
await new Promise((resolve) => upstream.listen(0, '127.0.0.1', resolve));
|
|
const upstreamPort = upstream.address().port;
|
|
const listenPort = await freePort();
|
|
const events = path.join(root, 'events.jsonl');
|
|
const log = path.join(root, 'fault-proxy.jsonl');
|
|
const proxy = spawn(process.execPath, [script, '--route', `${listenPort}=127.0.0.1:${upstreamPort}`, '--events', events,
|
|
'--log', log, '--run-id', 'run-a', '--poll-ms', '50'], { stdio: ['ignore', 'ignore', 'pipe'] });
|
|
let exited = null;
|
|
proxy.on('exit', (code) => { exited = code; });
|
|
|
|
check(await until(() => actions(log, 'listening').length === 1, 5000), 'proxy logs listening before any fault');
|
|
let a = await connect(listenPort);
|
|
a.socket.write('ping');
|
|
check(await until(() => a.data === 'ping', 2000), 'bytes are forwarded to the upstream and back');
|
|
|
|
request(events, 'close', 1);
|
|
check(await until(() => a.closed, 2000), 'close request drops the proxied connection');
|
|
check(actions(log, 'close')[0]?.closed === 1, 'close action records the dropped connection count');
|
|
let b = await connect(listenPort);
|
|
b.socket.write('again');
|
|
check(!b.error && await until(() => b.data === 'again', 2000), 'the listener stays open after close');
|
|
|
|
// The same line again (same monotonic_us/type/attempt) is not a second fault.
|
|
const lines = fs.readFileSync(events, 'utf8').trim().split('\n');
|
|
fs.appendFileSync(events, `${lines.at(-1)}\n`);
|
|
await delay(300);
|
|
check(!b.closed && actions(log, 'close').length === 1, 'a repeated request line is acted on once');
|
|
|
|
fs.appendFileSync(events, `${JSON.stringify({ monotonic_us: (usClock += 1000), run_id: 'other-run', kind: 'fault_request', payload: { type: 'close', attempt: 9 } })}\n`);
|
|
await delay(300);
|
|
check(!b.closed && actions(log, 'ignored_foreign_run').length === 1, 'fault requests from another run are ignored');
|
|
|
|
// Partial line: only acted on once the newline arrives.
|
|
const partial = JSON.stringify({ monotonic_us: (usClock += 1000), run_id: 'run-a', kind: 'fault_request',
|
|
payload: { type: 'unreachable', attempt: 1, unreachable_seconds: 2 } });
|
|
fs.appendFileSync(events, partial.slice(0, 20));
|
|
await delay(300);
|
|
check(!b.closed, 'a half-written event line is not parsed early');
|
|
const started = Date.now();
|
|
fs.appendFileSync(events, `${partial.slice(20)}\n`);
|
|
check(await until(() => b.closed, 2000), 'unreachable request drops the connection');
|
|
await until(() => actions(log, 'unreachable_start').length === 1, 1000);
|
|
const refused = await connect(listenPort);
|
|
check(refused.error === 'ECONNREFUSED', `connections are refused during the unreachable window (${refused.error})`);
|
|
check(await until(() => actions(log, 'unreachable_end').length === 1, 5000), 'listener comes back after unreachable_seconds');
|
|
const elapsed = Date.now() - started;
|
|
check(elapsed >= 1900 && elapsed < 4500, `unreachable window lasts about 2 s (${elapsed} ms)`);
|
|
const c = await connect(listenPort);
|
|
c.socket.write('back');
|
|
check(!c.error && await until(() => c.data === 'back', 2000), 'forwarding works again after the window');
|
|
c.socket.destroy();
|
|
|
|
proxy.kill('SIGTERM');
|
|
check(await until(() => exited !== null, 5000) && exited === 0, 'SIGTERM stops the proxy with exit 0');
|
|
check(actions(log, 'stopped').length === 1, 'stop is logged');
|
|
const after = await connect(listenPort);
|
|
check(after.error === 'ECONNREFUSED', 'no listener remains after stop');
|
|
check(!fs.readFileSync(log, 'utf8').includes('ping'), 'the action log never contains payload bytes');
|
|
|
|
// A port already in use is a startup error, not a silent run.
|
|
{
|
|
const busy = net.createServer();
|
|
await new Promise((resolve) => busy.listen(0, '127.0.0.1', resolve));
|
|
const run = spawnSync(process.execPath, [script, '--route', `${busy.address().port}=127.0.0.1:${upstreamPort}`, '--events', events,
|
|
'--log', path.join(root, 'busy.jsonl')], { encoding: 'utf8', timeout: 10000 });
|
|
check(run.status === 2 && /cannot listen/.test(run.stderr), 'busy listen port exits 2');
|
|
busy.close();
|
|
}
|
|
|
|
upstream.close();
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
console.log(failures === 0 ? 'PASS: playable_fault_proxy_test' : `FAIL: playable_fault_proxy_test (${failures})`);
|
|
process.exitCode = failures === 0 ? 0 : 1;
|