- 新增 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
226 lines
9.0 KiB
JavaScript
226 lines
9.0 KiB
JavaScript
#!/usr/bin/env node
|
|
/* STB-DISCONNECT-01 local transport-fault proxy for ONE test client connection.
|
|
*
|
|
* playable_fault_proxy.mjs --route LISTEN_PORT=UPSTREAM_HOST:UPSTREAM_PORT [--route ...]
|
|
* --events events.jsonl --log fault-proxy.jsonl [--run-id RUN_ID] [--poll-ms 200]
|
|
*
|
|
* - Listens on 127.0.0.1 only and forwards bytes to the upstream test server. The
|
|
* scenario's server.* points the client at these loopback ports; nothing else on
|
|
* the machine is touched: no routes, firewall rules, other clients or production.
|
|
* - Tails the client's events.jsonl and acts on each `fault_request` exactly once:
|
|
* close destroy the proxied connections ("connection closed")
|
|
* unreachable destroy them AND stop listening for payload.unreachable_seconds
|
|
* (capped at 20 s) so reconnect attempts are refused, then listen again
|
|
* - Every action is appended to --log as JSON (wall_ms, action, type, attempt, ...).
|
|
* The client, not this proxy, decides whether recovery happened.
|
|
* - Exits 0 on SIGTERM/SIGINT after closing its sockets. It never signals any process.
|
|
*
|
|
* Known limit: a server-side warp that hands out a direct game address bypasses the proxy.
|
|
* exit: 0 stopped, 2 usage error or a listen port could not be opened. */
|
|
import fs from 'node:fs';
|
|
import net from 'node:net';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const LISTEN_HOST = '127.0.0.1';
|
|
const MAX_UNREACHABLE_S = 20;
|
|
|
|
function usage(stream = process.stderr) {
|
|
stream.write('usage: playable_fault_proxy.mjs --route LISTEN_PORT=HOST:PORT [--route ...] --events events.jsonl --log fault-proxy.jsonl [--run-id ID] [--poll-ms 200]\n'
|
|
+ ' listens on 127.0.0.1 only; acts on fault_request events (close | unreachable)\n');
|
|
}
|
|
|
|
const isPort = (value) => Number.isInteger(value) && value >= 1 && value <= 65535;
|
|
|
|
export function parseArgs(argv) {
|
|
const options = { routes: [], poll_ms: 200 };
|
|
for (let i = 0; i < argv.length; i += 1) {
|
|
const arg = argv[i];
|
|
if (arg === '--help') { options.help = true; continue; }
|
|
const value = argv[i + 1];
|
|
if (value === undefined) throw new Error(`missing value for ${arg}`);
|
|
i += 1;
|
|
if (arg === '--route') {
|
|
const match = /^(\d+)=([A-Za-z0-9.:-]+):(\d+)$/.exec(value);
|
|
if (!match) throw new Error(`bad --route ${value}`);
|
|
const route = { listen: Number(match[1]), host: match[2], port: Number(match[3]) };
|
|
if (!isPort(route.listen) || !isPort(route.port)) throw new Error(`bad port in --route ${value}`);
|
|
if (['127.0.0.1', 'localhost', '::1'].includes(route.host) && route.port === route.listen) throw new Error(`route ${value} loops back to itself`);
|
|
if (options.routes.some((r) => r.listen === route.listen)) throw new Error(`duplicate listen port ${route.listen}`);
|
|
options.routes.push(route);
|
|
} else if (arg === '--events') options.events = value;
|
|
else if (arg === '--log') options.log = value;
|
|
else if (arg === '--run-id') options.run_id = value;
|
|
else if (arg === '--poll-ms') options.poll_ms = Number(value);
|
|
else throw new Error(`unknown option ${arg}`);
|
|
}
|
|
if (!options.help && (options.routes.length === 0 || !options.events || !options.log
|
|
|| !Number.isInteger(options.poll_ms) || options.poll_ms < 20)) throw new Error('--route, --events and --log are required');
|
|
return options;
|
|
}
|
|
|
|
/** Incremental JSONL reader: returns complete new lines since the last call. */
|
|
export function tailer(file) {
|
|
let offset = 0;
|
|
let partial = '';
|
|
return () => {
|
|
let size;
|
|
try { size = fs.statSync(file).size; } catch { return []; }
|
|
if (size < offset) { offset = 0; partial = ''; }
|
|
if (size === offset) return [];
|
|
const fd = fs.openSync(file, 'r');
|
|
const buffer = Buffer.alloc(size - offset);
|
|
fs.readSync(fd, buffer, 0, buffer.length, offset);
|
|
fs.closeSync(fd);
|
|
offset = size;
|
|
const text = partial + buffer.toString('utf8');
|
|
const lines = text.split('\n');
|
|
partial = lines.pop();
|
|
return lines.filter((line) => line.trim() !== '');
|
|
};
|
|
}
|
|
|
|
export class FaultProxy {
|
|
constructor(options) {
|
|
this.options = options;
|
|
this.servers = new Map();
|
|
this.pairs = new Set();
|
|
this.seen = new Set();
|
|
this.unreachableTimer = null;
|
|
this.stopped = false;
|
|
this.read = tailer(options.events);
|
|
}
|
|
|
|
log(action, detail = {}) {
|
|
fs.appendFileSync(this.options.log, `${JSON.stringify({ wall_ms: Date.now(), action, ...detail })}\n`);
|
|
}
|
|
|
|
listen(route) {
|
|
return new Promise((resolve, reject) => {
|
|
const server = net.createServer((client) => this.accept(route, client));
|
|
server.once('error', reject);
|
|
server.listen(route.listen, LISTEN_HOST, () => {
|
|
server.off('error', reject);
|
|
this.servers.set(route.listen, server);
|
|
resolve();
|
|
});
|
|
});
|
|
}
|
|
|
|
accept(route, client) {
|
|
const upstream = net.connect(route.port, route.host);
|
|
const pair = { client, upstream };
|
|
this.pairs.add(pair);
|
|
const drop = () => {
|
|
if (!this.pairs.delete(pair)) return;
|
|
client.destroy();
|
|
upstream.destroy();
|
|
};
|
|
client.on('error', drop).on('close', drop);
|
|
upstream.on('error', drop).on('close', drop);
|
|
client.pipe(upstream);
|
|
upstream.pipe(client);
|
|
this.log('accepted', { listen_port: route.listen, open: this.pairs.size });
|
|
}
|
|
|
|
dropAll() {
|
|
const count = this.pairs.size;
|
|
for (const pair of [...this.pairs]) {
|
|
this.pairs.delete(pair);
|
|
pair.client.destroy();
|
|
pair.upstream.destroy();
|
|
}
|
|
return count;
|
|
}
|
|
|
|
/** Stops accepting, drops open connections (server.close waits for them), returns the drop count. */
|
|
async closeListeners() {
|
|
const closing = [...this.servers.values()].map((server) => new Promise((resolve) => server.close(resolve)));
|
|
this.servers.clear();
|
|
const dropped = this.dropAll();
|
|
await Promise.all(closing);
|
|
return dropped;
|
|
}
|
|
|
|
async start() {
|
|
for (const route of this.options.routes) await this.listen(route);
|
|
this.log('listening', { listen_ports: this.options.routes.map((r) => r.listen) });
|
|
this.poller = setInterval(() => { this.poll().catch((error) => this.log('error', { reason: String(error.message).slice(0, 120) })); }, this.options.poll_ms);
|
|
}
|
|
|
|
async poll() {
|
|
// setInterval does not wait for an async fault; never run two polls at once.
|
|
if (this.polling) return;
|
|
this.polling = true;
|
|
try { await this.drain(); } finally { this.polling = false; }
|
|
}
|
|
|
|
async drain() {
|
|
for (const line of this.read()) {
|
|
let event;
|
|
try { event = JSON.parse(line); } catch { continue; }
|
|
if (event?.kind !== 'fault_request') continue;
|
|
if (this.options.run_id && event.run_id !== this.options.run_id) { this.log('ignored_foreign_run', {}); continue; }
|
|
const payload = event.payload ?? {};
|
|
const key = `${event.monotonic_us}:${payload.type}:${payload.attempt}`;
|
|
if (this.seen.has(key)) continue;
|
|
this.seen.add(key);
|
|
await this.fault(payload);
|
|
}
|
|
}
|
|
|
|
async fault(payload) {
|
|
const detail = { type: payload.type, attempt: payload.attempt, round: payload.round };
|
|
if (payload.type === 'close') {
|
|
this.log('close', { ...detail, closed: this.dropAll() });
|
|
} else if (payload.type === 'unreachable') {
|
|
if (this.unreachableTimer) { this.log('rejected', { ...detail, reason: 'unreachable window already active' }); return; }
|
|
const seconds = Math.min(MAX_UNREACHABLE_S, Math.max(1, Number.parseInt(payload.unreachable_seconds, 10) || 1));
|
|
const closed = await this.closeListeners();
|
|
this.log('unreachable_start', { ...detail, closed, seconds });
|
|
this.unreachableTimer = setTimeout(async () => {
|
|
this.unreachableTimer = null;
|
|
if (this.stopped) return;
|
|
try {
|
|
for (const route of this.options.routes) await this.listen(route);
|
|
this.log('unreachable_end', detail);
|
|
} catch (error) {
|
|
this.log('error', { ...detail, reason: `relisten failed: ${error.code || error.message}` });
|
|
}
|
|
}, seconds * 1000);
|
|
} else {
|
|
this.log('rejected', { ...detail, reason: 'unknown fault type' });
|
|
}
|
|
}
|
|
|
|
async stop() {
|
|
if (this.stopped) return;
|
|
this.stopped = true;
|
|
clearInterval(this.poller);
|
|
if (this.unreachableTimer) clearTimeout(this.unreachableTimer);
|
|
this.dropAll();
|
|
await this.closeListeners();
|
|
this.log('stopped', {});
|
|
}
|
|
}
|
|
|
|
async function main() {
|
|
let options;
|
|
try { options = parseArgs(process.argv.slice(2)); } catch (error) { usage(); console.error(error.message); return 2; }
|
|
if (options.help) { usage(process.stdout); return 0; }
|
|
const proxy = new FaultProxy(options);
|
|
try { await proxy.start(); } catch (error) {
|
|
console.error(`FAULT PROXY: cannot listen (${error.code || error.message})`);
|
|
await proxy.stop();
|
|
return 2;
|
|
}
|
|
await new Promise((resolve) => {
|
|
process.once('SIGTERM', resolve);
|
|
process.once('SIGINT', resolve);
|
|
});
|
|
await proxy.stop();
|
|
return 0;
|
|
}
|
|
|
|
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) process.exitCode = await main();
|