Files
dsh_shenxian/scripts/overlay-failover-drill.cjs
T
admin 146c3d25ef feat(overlay): 覆盖网络线序①–⑮ 代码与测试产物入库
覆盖网络线累积产物(此前只在工作区、未入版本库):
- 新增 relay 子系统 src/net/relay/**(wire/duplex/server/client/dialer/switcher/directory/identity/keys/placement/network/addr-override/main/index)
- 新增 src/worker/relay-tunnel.ts、src/web/routes/overlay.ts
- 新增观测/演练脚本 overlay-probe、overlay-failover-drill、overlay-keyring、overlay-holepunch、overlay-jitter、overlay-wan、overlay-relaykey-add、relay-mem-calibrate
- 新增测试 12 个(relay / relay-failover / remote-spawner / instance-port / overlay-{network,auth,bootstrap,identity} / remote-user-fs 等)

验收基线:npm test = 162 pass / 0 fail / 1 skip;--scene all = 12 PASS / 0 SKIP / 0 FAIL;overlay-probe = 12/12
2026-09-17 17:03:27 +08:00

1051 lines
55 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 覆盖网络 · **序⑦ 中继失败切流 · 真机演练**(交接单 §5 S7 / §6 E5–E7 的判据本体)
*
* ## 这个脚本要回答的唯一问题
* 「**杀掉任一台中继 ⇒ 正在使用它的客户端在 `RELAY_FAILOVER_DEADLINE_MS` 内切到另一台**」
* —— 在**真机**上、由**一条命令**产出 PASS/FAIL/SKIP(⛔ 不把真机取证交给 agent 手工做)。
*
* ## 三幕(序⑦)
* - **幕 1**:杀掉 Manager **当前所在**那台中继(`DRILL_KILLED_MATCH`)⇒ 断言切换在 deadline 内发生,
* 且**目标 ≠ 被杀入口**、47 的 Manager 自身仍存活(失败域分离)、门户仍 200。
* - **幕 2**:两台全杀 ⇒ 断言**无新切换**、有 `[relay-skip]` 的"无候选"证据、⛔ 无静默回退(D6 / R11)。
* 🔴 **序⑩ 修(2026-09-17)**:原实现**只停 106** —— 若此刻活跃通道正好在 47(幕 1 的余波 / 单跑本幕),
* 停的就是**当前不用**的那台 ⇒ 通道根本没被打断 ⇒ 窗口内 0 行 `[relay-skip]` / 0 行 `[relay-switch]`
* ⇒ **幕2-B 假红**(序⑨ 实测:该 120 s 窗口里 Manager 一行 relay 日志都没有)。现在本幕**现场重读一次
* 权威通道归属**,再把**两台都停掉**(幂等)⇒ 断言不再依赖上一幕留下的通道归属。
* - **幕 3**:只恢复 47 ⇒ 断言**冷却期内不回跳**;随后复原两台。
*
* ## 序⑧ 新增(2026-09-17):**幕 4 系列** —— "切流冷却语义"的真机判据
* - **幕 4**(构 A):停 47 ⇒ 47 的**两条候选**各按原因进冷却 ⇒ 恢复 47、停 106 ⇒ **D6 现场**
* ⇒ 断言"一跳豁免"把通道切回 47(日志带 `|豁免 … 剩 Nms` ⇒ **直接证明 47 当时确在冷却**)。
* - **幕 4b**(对照):同上但 `RELAY_FAILOVER_EXEMPT=0` + `RELAY_FAILOVER_COOLDOWN_MS` 缩到
* `DRILL_COOLDOWN_MS` ⇒ 断言 ① D6 现场原样复现 ② 冷却未过期前**不切流** ③ 冷却过期后**自然**回归。
* ⚠️ 该幕**用的是非生产冷却值**,报告必须标注(D9)。
* - 🔴 **序⑩ 删(2026-09-17)**:原 `--scene 4c` 与 `--scene ctrl`(**两者都靠** `RELAY_FAILOVER_COOLDOWN_MS` **置 0**)
* **已整体移除** —— 该值已判「**看似合法**(`num()` 的 `/^\d+$/` 放行 `'0'`)、**实际自锁**」:归零会让
* "失败候选必须被排除"一并失效 ⇒ 候选链卡在第一个失败候选(实测 **121–123 s 无切换**)。
* ⇒ ⛔ **演练 / 回滚路径一律不许再出现该值**;要"强制冷却过期"只走 `DRILL_COOLDOWN_MS`(幕 4b 已覆盖)。
* ⚠️ 这两个场景名**现在被显式拒绝**(退出码 2)—— ⛔ 不许静默空跑("0 项判定"会被误读成通过)。
* - ⚠️ 幕 4 系列**会重启 Manager 单元**(为了确定的前置 + 施加 env 覆盖);覆盖一律走
* `dshs.service.d/zz-drill-override.conf` 这一个 drop-in,`finally` 里**删除 + 重启**回到生产值。
* 🔴 **`RELAY_FAILOVER_COOLDOWN_MS` 的代码默认值恒为 `300000`**(D9)—— 演练期缩短只经
* **新键** `DRILL_COOLDOWN_MS`,⛔ 不改生产默认。
*
* ## ✳️ 首轮实测逼出来的三条设计修正(**诚实记录**)
* 1. **前置状态必须归零**:首轮 幕1 判 FAIL,真因是 Manager 的当前通道**遗留在 106**(上一轮演练的余波)
* ⇒ 杀 47 对**它**毫无影响,"不切换"其实是**正确行为**。现在脚本先读"当前通道线索",
* 若它不在被杀入口上 ⇒ 记 **SKIP**(⛔ 不把"状态没归零"算成产品失败,也不假装 PASS)。
* 2. **观察窗必须 ≥ 失效检测时延**:经 CF 的**静默失效**下,客户端要等**半开检测**(`2.5 × HB_SEC`)
* 才进 `backoff`,首轮实测 `unhealthyForMs` 已到 47–63 s ⇒ 幕2 的 30 s 窗口**必然漏判**。
* 改用 `DRILL_DETECT_BUDGET_MS`(⛔ 仍把"是否 ≤ `RELAY_FAILOVER_DEADLINE_MS`"如实报出来)。
* 3. **`systemctl is-active` 在 inactive 时退出码 = 3** ⇒ `execFileSync` 会抛 ⇒ 首轮**中断在幕1 半路**
* 且把 47 的 relay 留在停用态。凡是**读状态**的远端命令一律 `|| true`。
*
* ## 运行(cwd = 工作区根;⛔ 阈值零硬编码)
* `node "D:/github/dsh_shenxian/scripts/overlay-failover-drill.cjs" [--scene 1|2|3|all]`
*
* ⚠️ **本脚本会真停生产单元**(`dshs-relay`);`finally` 里**一律复原**(失败也复原)。
*/
'use strict'
const { execFileSync } = require('node:child_process')
const fs = require('node:fs')
const path = require('node:path')
/** 参数表文件名(⛔ 不写死日期数字)。 */
const TABLE_RE = /^参数表_覆盖网络_.+\.md$/
const KEY_RE = /^\|\s*`([A-Z0-9_]+)`\s*\|\s*([^|]*)\|/
const OBS_RE = /^\|\s*`(OBS-[0-9]+)`\s*\|\s*([^|]*)\|/
const KEY_ONLY_RE = /^`([A-Z0-9_]+)`$/
/* ─────────── 参数表装载(与 overlay-probe.cjs 同款;⛔ 脚本内无魔数) ─────────── */
function resolveTablePath(argv) {
const argOf = (name) => {
const i = argv.indexOf(name)
return i >= 0 && argv[i + 1] !== undefined ? argv[i + 1] : undefined
}
const explicit = argOf('--table')
if (explicit !== undefined) return explicit
const dir = argOf('--dir') ?? process.env.DSHS_OVERLAY_TABLE_DIR ?? process.cwd()
const hits = fs.readdirSync(dir).filter((n) => TABLE_RE.test(n))
if (hits.length !== 1) {
throw new Error(`在 ${dir} 下按 /${TABLE_RE.source}/ 找到 ${hits.length} 个参数表(要求恰好 1 个)`)
}
return path.join(dir, hits[0])
}
const cleanValue = (raw) => String(raw).replace(/[*`]/g, '').trim()
function loadTable(file) {
const text = fs.readFileSync(file, 'utf8')
const params = new Map()
for (const line of text.split(/\r?\n/)) {
if (OBS_RE.test(line)) continue
const k = KEY_RE.exec(line)
if (k !== null && !params.has(k[1])) params.set(k[1], cleanValue(k[2]))
}
return { file, params }
}
function makeReaders(table) {
const bad = []
const need = (key) => {
if (!table.params.has(key) || table.params.get(key) === '') {
bad.push(key)
return ''
}
return table.params.get(key)
}
const num = (keyOrRef) => {
const m = KEY_ONLY_RE.exec(keyOrRef)
let v
if (m !== null) v = need(m[1])
else if (table.params.has(keyOrRef)) v = need(keyOrRef)
else v = String(keyOrRef).trim()
const n = Number(v)
if (v === '' || !Number.isFinite(n)) {
bad.push(`${keyOrRef}=${JSON.stringify(v)} 不是数`)
return Number.NaN
}
return n
}
return { need, num, bad }
}
/* ─────────── 远端动作 ─────────── */
function ssh(port, target, command, timeoutMs) {
return execFileSync('ssh', ['-p', String(port), '-q', '-o', 'LogLevel=ERROR', '-o', 'BatchMode=yes', target, command], {
encoding: 'utf8',
timeout: timeoutMs,
}).trim()
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
const EXIT_OK = 0
const EXIT_FAIL = 1
const EXIT_USAGE = 2
async function main() {
const argv = process.argv.slice(2)
if (argv.includes('--help')) {
process.stdout.write(
'用法:node scripts/overlay-failover-drill.cjs [--scene 1|2|3|4|4b|all] [--table <参数表.md>] [--dir <目录>]\n' +
' node scripts/overlay-failover-drill.cjs --trace [--since -6h|@<epoch>] [--out <json>]\n' +
' --trace = 序⑨:**只读**,从 journal 复现「杀中继 ⇒ 切流完成」的四段分解(检测/tick相位/白等/建连),\n' +
' ⛔ 不停 relay、不改 env;检测+白等 < 27.0 s ⇒ 分解被证伪(退出码 1)\n' +
' all = 1|2|3|4(不含 4b —— 它会重启 Manager,须单独跑)\n' +
' 4 = 序⑧ 构 A:D6 现场 + 一跳豁免(2 秒内切回 47)\n' +
' 4b = 序⑧ 对照:豁免关 ⇒ D6 现场原样复现 + 冷却过期后**自然**回归(缺陷时限)\n' +
' ⛔ 序⑩ 起 `4c` / `ctrl` 已移除(两者都依赖 `RELAY_FAILOVER_COOLDOWN_MS` 置 0 = 自锁值,见文件头)\n',
)
return EXIT_USAGE
}
const sceneArg = (() => {
const i = argv.indexOf('--scene')
return i >= 0 && argv[i + 1] !== undefined ? argv[i + 1] : 'all'
})()
/**
* ⛔ **序⑩:已移除的场景显式报错** —— 靠 `RELAY_FAILOVER_COOLDOWN_MS` 置 0 的两个对照场景(`4c` / `ctrl`)
* 已整体删除。⛔ **不许静默空跑**(那会产出"0 项判定",被人误读成通过)。
*/
if (['4c', 'ctrl'].includes(sceneArg)) {
process.stderr.write(
`❌ --scene ${sceneArg} 已于序⑩ 移除:它依赖 RELAY_FAILOVER_COOLDOWN_MS 置 0(已判"看似合法、实际自锁")——\n` +
` 归零会让「失败候选必须被排除」失效 ⇒ 候选链卡死(实测 121–123 s 无切换)。\n` +
` 要缩短冷却请用 --scene 4b(走 DRILL_COOLDOWN_MS,属非生产值)。\n`,
)
return EXIT_USAGE
}
let r
try {
r = makeReaders(loadTable(resolveTablePath(argv)))
} catch (err) {
process.stderr.write(`❌ 参数表装载失败:${err.message}\n`)
return EXIT_USAGE
}
const sshPort = r.num('SSH_PORT')
const sshTo = r.num('SSH_TIMEOUT_MS')
const h47 = r.need('SSH_TARGET_47')
const h106 = r.need('SSH_TARGET_106')
const relayUnit = r.need('DRILL_RELAY_UNIT')
const managerUnit = r.need('DRILL_MANAGER_UNIT')
const deadlineMs = r.num('RELAY_FAILOVER_DEADLINE_MS')
const pollMs = r.num('DRILL_POLL_MS')
const observeMs = r.num('DRILL_COOLDOWN_OBSERVE_MS')
const detectBudgetMs = r.num('DRILL_DETECT_BUDGET_MS')
const killedMatch = r.need('DRILL_KILLED_MATCH')
const drillCooldownMs = r.num('DRILL_COOLDOWN_MS')
const noSwitchObserveMs = r.num('DRILL_NO_SWITCH_OBSERVE_MS')
const portalUrl = r.need('PORTAL_URL')
const portalHost = r.need('PORTAL_HOST_HEADER')
if (r.bad.length > 0) {
process.stderr.write(`❌ 参数表缺键/坏值:${r.bad.join(' , ')}\n`)
return EXIT_USAGE
}
const results = []
const record = (name, verdict, detail) => {
results.push({ name, verdict })
process.stdout.write(`${verdict} ${name} ${detail}\n`)
}
/**
* 窗口起点 = **`@<epoch 秒>`**(journalctl 原生、**与时区无关**)。
*
* 🔴 **实测教训(序⑦ 第三轮,一条命令定案)**:`date -Is` 产出的 `2026-09-17T11:56:00+08:00`
* 喂给 `journalctl --since` 会被判 **`Failed to parse timestamp`** ⇒ stdout 空 ⇒ `grep` 无命中
* ⇒ 尾部 `|| true` 掩盖 ⇒ **所有窗口判定失真**:幕2-A / 幕3-A 成了**假绿**(空窗口被当"没切换")、
* 幕1-A / 幕2-B 成了**假红**(日志里明明有 `[relay-switch]` / `[relay-skip]`)。
* 对照取证:`--since "2026-09-17T11:56:00+08:00"` → `lines=0 err=Failed to parse timestamp`;
* `--since @1789617360` → `lines=2`;`--since -6h` → `lines=6170`。
*/
const sinceNow = async () => `@${await ssh(sshPort, h47, 'date +%s', sshTo)}`
/**
* 自某时刻起的某类日志行(原文)。
* - 尾部 `|| true`:`grep` 无命中退出码 1 不许把流程打断。
* - 🔴 **`JOURNALCTL-ERR` 哨兵**:查询本身失败(时间戳解析不了 / 单元不存在)时**不再静默返回空**
* —— 「查询失败」与「确实没有该行」必须可区分,否则又是一次假绿。
*/
const logSince = (since, kind) =>
ssh(
sshPort,
h47,
`o=$(journalctl -u ${managerUnit} --since ${since} --no-pager 2>&1) || { printf 'JOURNALCTL-ERR %s\\n' "$o"; exit 0; }; ` +
`printf '%s\\n' "$o" | grep -F '${kind}' || true`,
sshTo,
)
/**
* 把 `JOURNALCTL-ERR` 哨兵剥出来 ⇒ `{ err, lines }`。
* ⛔ `err !== ''` 时**一律不许**给出 PASS/FAIL —— 判据不可信就如实报"查询失败"。
*/
const splitErr = (raw) => {
const i = raw.indexOf('JOURNALCTL-ERR')
if (i < 0) return { err: '', lines: raw }
return { err: raw.slice(i).split('\n')[0].replace('JOURNALCTL-ERR', '').trim(), lines: '' }
}
/** ⚠️ `|| true` 是**必须的**:`is-active` 在 inactive 时退出码 3。 */
const relayActive = (target) =>
ssh(sshPort, target, `systemctl is-active ${relayUnit} || true`, sshTo)
const stopRelay = (target) => ssh(sshPort, target, `systemctl stop ${relayUnit}`, sshTo)
const startRelay = (target) => ssh(sshPort, target, `systemctl start ${relayUnit}`, sshTo)
const portalCode = () =>
ssh(
sshPort,
h47,
`curl -s -o /dev/null -w '%{http_code}' --http1.1 -H ${JSON.stringify(`Host: ${portalHost}`)} ${portalUrl}`,
sshTo,
)
/**
* **当前通道线索** = 最近一条 `[relay-switch]` 的 `->` 目标;没有切换过 ⇒ 回落到最近一条
* `[overlay-dir] 取址 = …:<url>` 的 url。⚠️ 它只是"线索"(用于**判定前置状态是否归零**),
* 不是权威读数 —— 权威读数在进程内存里,本脚本不碰。
*/
/**
* **当前通道线索**(**仅供人读**,不参与判定)= 最近一条 `[relay-switch] -> X`;
* 无切换过 ⇒ 回落最近一条 `[overlay-dir] 取址 = …:X`。
*
* 🔴 **两次踩坑记录(序⑦ 第四/五轮)**:
* ① 改前实现"switch 与 取址 取较新者"是**语义错误** —— `取址` 是**目录解析结果**(每 2 s 刷一条、
* 永远等于目录首位),**不是已建立的通道**;一旦发生切换,取址 就把线索带偏。
* ② 只信 switch 也不够:**进程重启后首连不打 switch 日志**(通道 = 取址)⇒ 单独用 switch 会读到上一轮的旧值。
* ⇒ **判定不再依赖日志线索**,改用 `lastManagerAuthOn()` 的权威读数;本函数只打印给人看。
*/
const currentChannelHint = () => {
const sw = splitErr(logSince('-6h', '[relay-switch]')).lines
if (sw !== '') {
const last = sw.split('\n').slice(-1)[0]
const m = /->\s*(\S+?)(/.exec(last) ?? /->\s*(\S+)\s*$/.exec(last)
if (m !== null) return m[1].trim()
}
const od = splitErr(logSince('-6h', '[overlay-dir] 取址')).lines
const m2 = od === '' ? null : (/:(\S+?)(/.exec(od.split('\n').slice(-1)[0]) ?? /:(\S+)\s*$/.exec(od.split('\n').slice(-1)[0]))
return m2 !== null && m2 !== undefined ? m2[1].trim() : ''
}
/**
* **权威读数:Manager 最近一次在**某台**relay 上注册成功**的 epoch 秒(`-1` = 读不到/从未)。
*
* 🔑 **为什么必须问 relay 的日志**:Manager 是**拨出方**,它的当前通道在进程内存里,日志无法直接读。
* 而 relay 侧每接受一次拨号就写一行 `[relay] AUTH OK host=ops/manager …`(带时间戳)⇒
* **两台 relay 谁的时间戳更新,Manager 就在谁那儿**。
*
* ⛔ **不要用 relay `/status` 的 `dialers`**:实测它**会留陈旧条目**(序⑦ 第五轮:Manager 12:05:18
* 已切走,47 的 `/status` 仍列 `dialers:["manager"]`,而 47 日志里最后一次 manager AUTH 停在 12:04:19)。
*/
const lastManagerAuthOn = (target) => {
const raw = splitErr(
ssh(
sshPort,
target,
`o=$(journalctl -u ${relayUnit} --since -6h -o short-unix --no-pager 2>&1) || { printf 'JOURNALCTL-ERR %s\\n' "$o"; exit 0; }; ` +
`printf '%s\\n' "$o" | grep -E 'AUTH OK host=ops/manager' | tail -1 || true`,
sshTo,
),
).lines
if (raw === '') return -1
const t = Number.parseFloat(raw.split(/\s+/)[0])
return Number.isFinite(t) ? t : -1
}
/** 等一条**新的** `[relay-switch]` 行(≤ budget),返回 { ok, ms, lines, err }。 */
const waitSwitch = async (since, budgetMs) => {
const t0 = Date.now()
for (;;) {
const { err, lines } = splitErr(logSince(since, '[relay-switch]'))
if (err !== '') return { ok: false, ms: Date.now() - t0, lines: '', err }
if (lines !== '') return { ok: true, ms: Date.now() - t0, lines, err: '' }
if (Date.now() - t0 >= budgetMs) return { ok: false, ms: Date.now() - t0, lines: '', err: '' }
await sleep(pollMs)
}
}
/* ═══════════════════════════════════════════════════════════════════════════
* 序⑨ · `--trace`:把「杀中继 ⇒ 切流完成」的墙钟**拆成四段**(**只读**)
*
* 判据来源 = **现有日志行的时间戳**(D2):⛔ 不停 relay / ⛔ 不改 env / ⛔ 不写远端文件。
* 三行锚点切样本:
* ① `[relay-client] down (…(was up)); attempt #0 [graceful, burst window Nms]` = 样本起点 t_bye
* ② 其后第一条**不带方括号标签**的 `attempt #M, retry in …`(M ≥ 1)= burst 窗口耗尽 ⇒ 检测段结束
* ③ 其后**最后一条不带标签的 `attempt #1`** = **新候选客户端的第一条失败**(窗口内唯一能独立量出的
* "白等起点":新客户端是全新实例 ⇒ 首败必为 `attempt #1` 且无标签;老通道那条 `attempt #1`
* 正好等于 ② 本身,被排除)
* ④ 其后第一条 `[relay-skip] ⛔ 新通道起不来` = 放弃点 t_skip
* ⑤ 其后第一条 `[relay-switch]` = 样本终点 t_switch
*
* 四段:
* **检测** = ② − ①(graceful burst 窗口地板,实测 15.0–16.6 s)
* **首试延迟** = ③ − ②(tick 相位 + 拨号耗时,实测 0.4–3.0 s)
* **白等** = ④ − ③(**实测**;作为对照也已知修前它 ≡ `upTimeoutMs`(12 s))
* **建连** = ⑤ − ④
* 自洽校验:检测 + 首试延迟 + 白等 + 建连 ≡ ⑤ − ①(≤ 0.05 s)。
* 🔴 **两套签名两种判据**(同一个脚本同时认):
* · **修前签名**(白等 ≥ 0.9 × `upTimeoutMs`):`检测 + 白等 < 27.0 s` ⇒ 分解被证伪(§1.2 可证伪条款);
* · **修后签名**(白等 只剩"首败 → 放弃"):`白等 ≤ 1.0 s` 且 `总长 ≤ RELAY_FAILOVER_DEADLINE_MS`。
* ═══════════════════════════════════════════════════════════════════════════ */
if (argv.includes('--trace')) {
const argOf = (name) => {
const i = argv.indexOf(name)
return i >= 0 && argv[i + 1] !== undefined ? argv[i + 1] : undefined
}
const since = argOf('--since') ?? '-6h'
const upTimeoutMs = r.num('RELAY_FAILOVER_UP_TIMEOUT_MS')
const raw = ssh(
sshPort,
h47,
`o=$(journalctl -u ${managerUnit} --since ${since} -o short-unix --no-pager 2>&1) || { printf 'JOURNALCTL-ERR %s\\n' "$o"; exit 0; }; ` +
`printf '%s\\n' "$o" | grep -E 'relay-client. down|relay-skip|relay-switch|relay-failover' || true`,
sshTo,
)
const { err, lines } = splitErr(raw)
if (err !== '') {
process.stderr.write(`❌ 窗口查询失败(判据不可信,⛔ 不给出 PASS/FAIL):${err}\n`)
return EXIT_FAIL
}
const rows = lines
.split('\n')
.map((l) => l.trim())
.filter((l) => l !== '')
.map((l) => {
const i = l.indexOf(']: ')
return { t: Number.parseFloat(l.split(/\s+/)[0]), body: i < 0 ? l : l.slice(i + 3) }
})
.filter((x) => Number.isFinite(x.t))
const isBye = (b) => b.includes('[relay-client] down') && b.includes('(was up)') && b.includes('burst window')
const isBurstEnd = (b) => b.includes('[relay-client] down') && /attempt #\d+, retry in/.test(b)
const isSkip = (b) => b.includes('[relay-skip]') && b.includes('新通道起不来')
const isSwitch = (b) => b.includes('[relay-switch]')
/**
* **新候选客户端的第一条失败行**。
*
* 为什么它是"白等"的可靠起点:`open()` 里的新客户端是**全新**实例(`attempts=0`、无 burst 窗口)
* ⇒ 它第一次拨号失败就 `attempts=1` 且**不带方括号标签** ⇒ 窗口内"最后一条不带标签的
* `attempt #1`"就是它。⚠️ 老通道客户端在 burst 窗口耗尽时**也**会打一条 `attempt #1` ——
* 但那条正好**等于 burst 耗尽行**(在窗口之外,被排除),所以不会混淆。
* ⇒ 白等 = `t(放弃/早退) − t(新候选首败)`,**修前修后同一口径**(⛔ 不依赖"白等 ≡ upTimeoutMs"这条
* 只在修前成立的定义式 —— 序⑨ S7 之后它已被修掉)。
*/
const isFirstTry = (b) => b.includes('[relay-client] down') && /\battempt #1, retry in/.test(b)
const samples = []
for (let i = 0; i < rows.length; i += 1) {
if (!isBye(rows[i].body)) continue
const tBye = rows[i].t
const idxOf = (from, pred) => {
for (let k = from; k < rows.length; k += 1) if (pred(rows[k].body)) return k
return -1
}
const iBurst = idxOf(i + 1, isBurstEnd)
const iSkip = idxOf(i + 1, isSkip)
const iSw = idxOf(i + 1, isSwitch)
if (iBurst < 0 || iSkip < 0 || iSw < 0 || !(iBurst < iSkip && iSkip < iSw)) continue
let iFirst = -1
for (let k = iBurst + 1; k < iSkip; k += 1) if (isFirstTry(rows[k].body)) iFirst = k
const tBurst = rows[iBurst].t
const tSkip = rows[iSkip].t
const tSw = rows[iSw].t
const detect = tBurst - tBye
const firstTry = iFirst < 0 ? null : rows[iFirst].t - tBurst
/** 白等:有"新候选首败"行 ⇒ **实测**;没有 ⇒ 回落修前的定义式 `upTimeoutMs`(标注 `waitSrc`)。 */
const wait = iFirst < 0 ? upTimeoutMs / 1000 : tSkip - rows[iFirst].t
const waitSrc = iFirst < 0 ? '定义式' : '实测'
const connect = tSw - tSkip
const total = tSw - tBye
const mUn = /unhealthyForMs=(\d+)/.exec(rows[iSw].body)
const mAt = /attempts=(\d+)/.exec(rows[iSw].body)
/** 🔴 修前的签名 = 死候选**白等吃满** `upTimeoutMs`(0.9× 余量),此时"检测+白等"的可证伪地板才成立。 */
const prefixed = waitSrc === '定义式' || wait >= 0.9 * (upTimeoutMs / 1000)
samples.push({
tBye,
tBurstEnd: tBurst,
tFirstTry: iFirst < 0 ? null : rows[iFirst].t,
tSkip,
tSwitch: tSw,
detect,
firstTry,
wait,
waitSrc,
connect,
total,
identityErr: (iFirst < 0 ? NaN : 0) + detect + (firstTry ?? 0) + wait + connect - total,
unhealthyForMs: mUn === null ? null : Number.parseInt(mUn[1], 10),
switchAttempts: mAt === null ? null : Number.parseInt(mAt[1], 10),
prefixed,
falsified: prefixed && detect + wait < 27.0,
raw: [rows[i], rows[iBurst], ...(iFirst < 0 ? [] : [rows[iFirst]]), rows[iSkip], rows[iSw]].map(
(x) => x.body,
),
})
i = iSw
}
const f = (x) => x.toFixed(3)
const checkMs = r.num('RELAY_FAILOVER_CHECK_MS')
/**
* **只判"单跳样本"**:四段分解描述的是「杀入口 ⇒ 首个候选死 ⇒ 切到第二台」这一跳。
* 三条同时成立才算:
* ① **首试延迟** ∈ [0, `checkMs` + 3.2 s](= tick 相位 + 拨号耗时;实测修前 0.3–1.4 s、
* 修后 0.3–5.0 s —— 修后多出来的就是"等到新客户端真拨一次失败"的拨号+重试耗时);
* ② 建连 ∈ [0, 10 s](真跨机建连实测 2.7–5.2 s ⇒ >10 s 一定是"后续候选链推进");
* ③ `unhealthyForMs`(切流行自带)与四段总长之差 ≤ 3 s(同一起点的两个独立读数;
* 实测系统性偏移 0.7–2.2 s = 「BYE 行落盘」到「状态机记账」的差)。
* ⛔ 不满足 = 判 `N/A`,**不许**把"候选链推进/回跳"错算成"白等/建连"。
*/
const applicable = (s) =>
s.firstTry !== null &&
s.firstTry >= -0.05 &&
s.firstTry <= checkMs / 1000 + 3.2 &&
s.connect >= -0.05 &&
s.connect <= 10 &&
(s.unhealthyForMs === null || Math.abs(s.unhealthyForMs / 1000 - s.total) <= 3.0)
const single = samples.filter(applicable)
process.stdout.write(
`\n# 序⑨ --trace|目标 ${h47} · 单元 ${managerUnit}|窗口 --since ${since}|` +
`样本数 ${samples.length}(单跳 ${single.length})|白等 = 新候选首败 → 放弃(实测)` +
`|修前签名判定阈值 = 白等 ≥ ${(0.9 * (upTimeoutMs / 1000)).toFixed(1)}s\n`,
)
process.stdout.write(
`# 样本 | 检测 | 首试延迟 | 白等[口径] | 建连 | 总 | 自洽 | unhealthyForMs | 检测+白等\n`,
)
for (const s of samples) {
process.stdout.write(
`# ${s.tBye} | ${f(s.detect)} | ${f(s.firstTry ?? Number.NaN)} | ${f(s.wait)}[${s.waitSrc}] | ` +
`${f(s.connect)} | ${f(s.total)} | ` +
`${Math.abs(s.identityErr) <= 0.05 ? '✅' : `❌${f(s.identityErr)}`} | ${s.unhealthyForMs} | ` +
`${f(s.detect + s.wait)}${applicable(s) ? (s.falsified ? ' 🔴证伪' : '') : ' ⚪N/A'}\n`,
)
}
for (const s of samples) {
const bad = []
if (!applicable(s)) {
process.stdout.write(
`# ${s.tBye} N/A ⚪ 非单跳样本(首试延迟 ${f(s.firstTry ?? Number.NaN)} / 建连 ${f(s.connect)} ⇒ ` +
`归因到后续候选链,⛔ 不计入四段分解)\n`,
)
continue
}
if (s.detect < 14.5) bad.push(`检测 ${f(s.detect)} < 15.0(burst 地板)`)
if (s.detect > 17.0) bad.push(`检测 ${f(s.detect)} > 17.0(burst 地板 + 容差)`)
if (Math.abs(s.identityErr) > 0.05) bad.push(`四段之和 ≠ 墙钟(差 ${f(s.identityErr)})`)
if (s.prefixed) {
/* 修前签名:白等被 `upTimeoutMs` 吃满 ⇒ 用 §1.2 的可证伪地板 */
if (s.falsified) bad.push(`检测+白等 ${f(s.detect + s.wait)} < 27.0 ⇒ 🔴 分解被证伪`)
} else {
/* 修后签名:白等应当只剩"首败 → 放弃"这一小段 ⇒ 判据换成"白等 ≤ 1 s 且总长 ≤ deadline" */
if (s.wait > 1.0) bad.push(`白等 ${f(s.wait)} > 1.0 s(死候选应提前失败)`)
if (s.total > deadlineMs / 1000) bad.push(`总长 ${f(s.total)} > deadline ${deadlineMs}ms`)
}
process.stdout.write(
`# ${s.tBye} ${bad.length === 0 ? `PASS ✅ ${s.prefixed ? '修前签名(白等吃满 upTimeoutMs)' : '修后签名(死候选提前失败)'}` : `FAIL ❌ ${bad.join(';')}`}\n` +
s.raw.map((l) => `# · ${l}\n`).join(''),
)
}
if (samples.length === 0) {
process.stderr.write('❌ 窗口内无可切分的完整样本(⛔ 不给出 PASS/FAIL —— 先确认窗口里真有"杀中继 ⇒ 切流"两轮)\n')
return EXIT_FAIL
}
const outDir = path.join(process.cwd(), '_中间产物_待清理', 'seq9-trace')
fs.mkdirSync(outDir, { recursive: true })
const outFile = argOf('--out') ?? path.join(outDir, `seq9-trace-${Math.floor(Date.now() / 1000)}.json`)
fs.writeFileSync(outFile, `${JSON.stringify({ since, h47, upTimeoutMs, samples }, null, 2)}\n`, 'utf8')
const det = single.map((s) => s.detect).sort((a, b) => a - b)
const wht = single.map((s) => s.wait).sort((a, b) => a - b)
const tot = single.map((s) => s.total).sort((a, b) => a - b)
const med = (xs) => (xs.length === 0 ? Number.NaN : xs[Math.floor(xs.length / 2)])
const pre = single.filter((s) => s.prefixed).length
process.stdout.write(
`# 单跳样本统计(n=${single.length},其中修前签名 ${pre} / 修后签名 ${single.length - pre}):\n` +
`# 检测 中位 ${f(med(det))} / 最小 ${f(det[0] ?? Number.NaN)} / 最大 ${f(det[det.length - 1] ?? Number.NaN)}\n` +
`# 白等 中位 ${f(med(wht))} / 最小 ${f(wht[0] ?? Number.NaN)} / 最大 ${f(wht[wht.length - 1] ?? Number.NaN)}\n` +
`# 总长 中位 ${f(med(tot))} / 最小 ${f(tot[0] ?? Number.NaN)} / 最大 ${f(tot[tot.length - 1] ?? Number.NaN)}\n` +
`# 原始行已落盘:${outFile}\n`,
)
const bad = single.filter(
(s) => Math.abs(s.identityErr) > 0.05 || s.falsified || (!s.prefixed && s.wait > 1.0),
).length
return bad === 0 ? EXIT_OK : EXIT_FAIL
}
/* ═══════════════════════════════════════════════════════════════════════════
* 序⑨ · `--sample N`:**N 次"杀入口 ⇒ 切流"采样**(S2 基线 / S7 复测共用一套动作)
*
* 每轮的**归零序列**(缺一即读数不可比):
* ① 两台 relay `systemctl start` ② `systemctl restart dshs`(⇒ 通道回到目录首位 = 47)
* ③ 等 47 的 **relay 日志**出现新的 `AUTH OK host=ops/manager`(权威就绪读数)
* ④ 记 `t0` → `systemctl stop dshs-relay`(47)→ 等新的 `[relay-switch]`(≤ `DRILL_DETECT_BUDGET_MS`)
* 读数口径:**切换耗时 = `waitSwitch` 的墙钟**(含 `DRILL_POLL_MS` 的 0–`pollMs` 量化误差,
* 所以每轮**必须记录当时 `DRILL_POLL_MS`**;⚠️ 改过 `DRILL_POLL_MS` 前后的读数**不可混比**)。
* 四段分解另由 `--trace` 从 journal 时间戳复算(⛔ 与这里的读数不互相替代)。
* ⛔ 不施加任何演练 env 覆盖(尤其**不许把冷却归零** —— 归零连带废掉"失败候选必须被排除",
* 上单 §8.8-4 已实测其自锁后果;演练期要缩短冷却**只走 `DRILL_COOLDOWN_MS`**)。
* ═══════════════════════════════════════════════════════════════════════════ */
if (argv.includes('--sample')) {
const argOf = (name) => {
const i = argv.indexOf(name)
return i >= 0 && argv[i + 1] !== undefined ? argv[i + 1] : undefined
}
const n = Number.parseInt(argOf('--sample') ?? String(r.num('DRILL_SAMPLE_N')), 10)
if (!Number.isFinite(n) || n < 1) {
process.stderr.write('❌ --sample 需要一个正整数(或用参数表 DRILL_SAMPLE_N)\n')
return EXIT_USAGE
}
const authCountSince = (target, epoch) =>
splitErr(
ssh(
sshPort,
target,
`o=$(journalctl -u ${relayUnit} --since @${epoch} -o short-unix --no-pager 2>&1) || { printf 'JOURNALCTL-ERR %s\\n' "$o"; exit 0; }; ` +
`printf '%s\\n' "$o" | grep -c 'AUTH OK host=ops/manager' || true`,
sshTo,
),
).lines.trim()
process.stdout.write(
`\n# 序⑨ --sample ${n}|DRILL_POLL_MS=${pollMs}ms(⚠️ 读数含 0–${pollMs}ms 量化误差)|` +
`deadline=${deadlineMs}ms|upTimeoutMs=${r.num('RELAY_FAILOVER_UP_TIMEOUT_MS')}ms\n` +
`# 轮 | AUTH就绪ms | 切换ms | ≤deadline | 目标 | 行原文\n`,
)
const rounds = []
try {
for (let i = 1; i <= n; i += 1) {
/* ① 归零:两台 relay 都起来 */
await startRelay(h47)
await startRelay(h106)
/* ② 归零:重启 Manager ⇒ 通道回到目录首位(47) */
const tRestart = Number.parseInt(await ssh(sshPort, h47, 'date +%s', sshTo), 10)
await ssh(sshPort, h47, `systemctl restart ${managerUnit}`, sshTo)
/* ③ 等"新的" AUTH OK(权威就绪读数) */
const tAuth0 = Date.now()
let authed = '0'
for (;;) {
authed = await authCountSince(h47, tRestart)
if (authed !== '0' || Date.now() - tAuth0 >= detectBudgetMs) break
await sleep(1000)
}
const authMs = Date.now() - tAuth0
/* ④ 杀入口 ⇒ 等切换 */
const t0 = Number.parseInt(await ssh(sshPort, h47, 'date +%s', sshTo), 10)
await stopRelay(h47)
const sw = await waitSwitch(`@${t0}`, detectBudgetMs)
const last = sw.ok ? sw.lines.split('\n').slice(-1)[0] : ''
const tgt = sw.ok ? ((/->\s*(\S+?)((|$)/.exec(last) ?? [])[1] ?? '') : ''
const row = {
round: i,
authed,
authMs,
switchMs: sw.ok ? sw.ms : null,
withinDeadline: sw.ok ? sw.ms <= deadlineMs : false,
target: tgt,
err: sw.err,
line: last,
}
rounds.push(row)
process.stdout.write(
`# ${i}/${n} | ${authMs} | ${sw.ok ? sw.ms : 'N/A'} | ${sw.ok ? (row.withinDeadline ? '✅' : '❌') : '❌'} | ` +
`${tgt} | ${sw.err !== '' ? `❌窗口查询失败:${sw.err}` : last}\n`,
)
/* 复原(下一轮的 ① 会再 start 一次,幂等) */
await startRelay(h47)
await sleep(2_000)
}
} finally {
try {
await startRelay(h47)
await startRelay(h106)
} catch (err) {
process.stderr.write(`❌ 复原 relay 失败:${err.message}\n`)
}
}
const ok = rounds.filter((x) => x.withinDeadline && x.err === '')
process.stdout.write(
`\n# 采样结果:${ok.length}/${rounds.length} 在 deadline(${deadlineMs}ms) 内侧|` +
`切换耗时 min/中位/max = ${rounds.map((x) => x.switchMs).filter((x) => x !== null).sort((a, b) => a - b).join(' / ')}\n`,
)
const outDir = path.join(process.cwd(), '_中间产物_待清理', 'seq9-trace')
fs.mkdirSync(outDir, { recursive: true })
const outFile = argOf('--out') ?? path.join(outDir, `seq9-sample-${Math.floor(Date.now() / 1000)}.json`)
fs.writeFileSync(outFile, `${JSON.stringify({ pollMs, deadlineMs, rounds }, null, 2)}\n`, 'utf8')
process.stdout.write(`# 采样明细已落盘:${outFile}\n`)
return ok.length === rounds.length && rounds.length === n ? EXIT_OK : EXIT_FAIL
}
/**
* ⚠️ **序⑧ 起本脚本会重启 Manager 单元**(1 次/幕 4 变体)—— 目的是给幕 4 一个**确定的前置**
* (重启 ⇒ 通道回到目录首位 = 47)并施加演练期 env 覆盖(D9)。`finally` 里**一律删覆盖**。
*/
const DRILL_DROPIN = `/etc/systemd/system/${managerUnit}.service.d/zz-drill-override.conf`
/** 是否留下过演练 env 覆盖 ⇒ `finally` 据此决定要不要"删文件 + 重启"回到生产值。 */
let drillEnvApplied = false
/**
* 施加演练期 env 覆盖(D9):**只走 drop-in**。
*
* 🔴 **为什么不改 `/etc/dshs.env`**:那份文件是**平台自己的配置**(600/root,09-13 起的既有内容),
* 演练去改它 = 把"演练"与"生产配置"耦合;drop-in 是**可整份删除**的独立单元 ⇒ 回滚 = `rm` 一行。
* (本线既有规矩:env 改动一律走 drop-in —— cluster 配置就在 `dshs.service.d/cluster.conf`。)
*
* ⛔ **生产默认值一个都不动**:`RELAY_FAILOVER_COOLDOWN_MS` 在代码里的默认仍是 `300000`;
* 演练期要缩短只能通过**新键** `DRILL_COOLDOWN_MS`(它在参数表里,本函数只负责把它塞进 drop-in)。
*/
const applyDrillEnv = (vars) => {
const keys = Object.keys(vars)
const body =
'# 序⑧ 演练期 env 覆盖 —— 由 scripts/overlay-failover-drill.cjs 自动生成\n' +
'# ⛔ 临时产物:删除本文件 + daemon-reload + restart 即回到生产值\n' +
'[Service]\n' +
keys.map((k) => `Environment="${k}=${vars[k]}"\n`).join('')
/**
* ⚠️ **heredoc 必须独占一行收尾**(`bodyEOF && cmd` 会让终止符变成 `bodyEOF && cmd` ⇒ 语法错误)
* ⇒ 这里用**换行分隔**而不是 `&&` 串。
*/
const remote =
`mkdir -p "$(dirname ${DRILL_DROPIN})"\n` +
(keys.length === 0 ? `rm -f ${DRILL_DROPIN}\n` : `cat > ${DRILL_DROPIN} <<"EOF"\n${body}EOF\n`) +
`systemctl daemon-reload && systemctl restart ${managerUnit}\n`
ssh(sshPort, h47, remote, sshTo)
drillEnvApplied = keys.length > 0
}
/**
* 删掉演练覆盖并**重启** ⇒ **回到生产值**(运行中的进程也一起回)。
* ⚠️ 只删文件不重启 = 进程里仍是旧 env ⇒ "回滚"没生效。
*/
const clearDrillEnv = () => {
ssh(
sshPort,
h47,
`rm -f ${DRILL_DROPIN}\nsystemctl daemon-reload && systemctl restart ${managerUnit}\n`,
sshTo,
)
drillEnvApplied = false
}
/** 等门户 200(= Manager 起完了)。⚠️ 重启后前几秒必然拿不到 200,不是失败。 */
const waitManagerReady = async (budgetMs) => {
const t0 = Date.now()
for (;;) {
let pc = ''
try {
pc = await portalCode()
} catch {
pc = ''
}
if (pc === '200') return true
if (Date.now() - t0 >= budgetMs) return false
await sleep(pollMs)
}
}
/** 归零:重启 Manager 并等它**确实注册在 47**(重启 ⇒ 通道回到目录首位)。 */
const normalizeManagerOn47 = async (budgetMs) => {
if ((await waitManagerReady(budgetMs)) === false) return false
const t0 = Date.now()
for (;;) {
const a47 = await lastManagerAuthOn(h47)
const a106 = await lastManagerAuthOn(h106)
if (a47 > a106) return true
if (Date.now() - t0 >= budgetMs) return false
await sleep(pollMs)
}
}
process.stdout.write(`# 参数表=${resolveTablePath(argv)}\n`)
const hint = currentChannelHint()
/**
* **杀哪台 = 权威读数比对**(⛔ 原先硬编码只杀 47 ⇒ 单 §5-S7 的"杀 106"方向**从未被实测**;
* 也⛔ 不再用日志线索猜 —— 见 `currentChannelHint` 的两次踩坑记录)。
* 两台时间戳相等或都读不到 ⇒ **无法唯一确定** ⇒ SKIP(⛔ 不把"杀错了台"算成产品失败)。
*/
const match106 = r.need('DRILL_SWITCH_MATCH_106')
const t47 = await lastManagerAuthOn(h47)
const t106 = await lastManagerAuthOn(h106)
const killTarget = t47 > t106 ? h47 : t106 > t47 ? h106 : null
const killMatch = killTarget === h106 ? match106 : killedMatch
const otherTarget = killTarget === h47 ? h106 : h47
process.stdout.write(
`# 前置:Manager 最近注册 epoch 47=${t47} / 106=${t106} ⇒ 实际被杀 = ` +
`${killTarget === null ? '(无法唯一确定 ⇒ SKIP)' : killTarget}` +
`|线索(仅人读)= ${hint === '' ? '(读不到)' : hint}\n`,
)
/**
* 幕 1 的**场景体**(序⑧ 抽成函数):断言只写这一份(⛔ 不许再抄第二份)。
* ⚠️ 序⑩ 起 `--scene ctrl` 对照已被移除 ⇒ 本函数只有**一个**调用点(`all` / `1`);
* `suffix` 参数保留仅为结果行可读性(默认空串,不影响任何断言语义)。
*/
const runScene1 = async (suffix = '') => {
{
if (killTarget === null) {
record(
`幕1-A 杀当前入口 ⇒ 在 deadline 内切到另一台${suffix}`,
'SKIP',
`⚠️ 无法唯一确定 Manager 当前在哪台(47 最近注册=${t47} / 106=${t106})⇒ 杀任一台都可能是空操作,` +
`本轮不作判定(归零办法:重启 ${managerUnit} ⇒ 通道回到目录首位,其 relay 会留下新的 AUTH 行)`,
)
} else {
const since = await sinceNow()
const before = await relayActive(killTarget)
await stopRelay(killTarget)
const after = await relayActive(killTarget)
process.stdout.write(
`# 幕1 前置:被杀 = ${killTarget}|relay ${before} ⇒ ${after}|窗口起 ${since}\n`,
)
const hit = await waitSwitch(since, detectBudgetMs)
const last = hit.ok ? hit.lines.split('\n').slice(-1)[0] : ''
const to = hit.ok ? (/(->\s*)(\S+?)((|$)/.exec(last) ?? [])[2] : undefined
const sameTarget = to !== undefined && to.includes(killMatch)
const withinDeadline = hit.ok && hit.ms <= deadlineMs
/**
* 🔴 **无切换 ≠ 产品失败**:若同一窗口里判别器给出了"链里无其他候选"(D6 的原生证据),
* 那"不切换"就是**设计预期行为** ⇒ 记 **SKIP**(⛔ 不假装 PASS,也不误报 FAIL)。
* 实测来源(序⑦ 第五轮):Manager 刚切到 106 后杀 106,唯一替代 47 **仍在 300 s 冷却窗内**
* ⇒ `[relay-skip] … 链里无其他候选(候选 3 条,排除 3 条)` 刷了 120 s。
*/
const noCand =
!hit.ok && hit.err === ''
? (splitErr(logSince(since, '[relay-skip]'))
.lines.split('\n')
.find((l) => l.includes('无其他候选')) ?? '')
: ''
record(
`幕1-A 杀当前入口 ⇒ 切到另一台(≠ 被杀入口)${suffix}`,
hit.err !== ''
? 'FAIL'
: noCand !== ''
? 'SKIP'
: hit.ok && !sameTarget
? withinDeadline
? 'PASS'
: 'FAIL'
: 'FAIL',
hit.err !== ''
? `❌ 窗口查询失败(判据不可信,⛔ 非产品结论):${hit.err}`
: noCand !== ''
? `⚠️ 窗口内无切换,但判别器给出「链里无其他候选」⇒ **D6 预期行为,⛔ 不算产品失败**(归零办法:等冷却期满再复跑):${noCand}`
: hit.ok
? `被杀 ${killTarget}|耗时 ${hit.ms}ms(deadline ${deadlineMs}ms)|目标 ${to}` +
(sameTarget ? ' ❌ 目标仍是被杀入口' : '') +
(withinDeadline ? '' : ' ⚠️ **超出 deadline**') +
`|${last}`
: `被杀 ${killTarget}|❌ ${hit.ms}ms 内无 [relay-switch]、也无「无其他候选」证据`,
)
const mgr = await ssh(sshPort, h47, `systemctl is-active ${managerUnit} || true`, sshTo)
record(`幕1-B Manager 自身仍存活(失败域分离)${suffix}`, mgr === 'active' ? 'PASS' : 'FAIL', `is-active=${mgr}`)
const pc = await portalCode()
record(`幕1-C 门户仍 200(业务面无人工干预恢复)${suffix}`, pc === '200' ? 'PASS' : 'FAIL', `http_code=${pc}`)
}
const rother = await relayActive(otherTarget)
record(`幕1-D 另一台(${otherTarget})的中继未被误动${suffix}`, rother === 'active' ? 'PASS' : 'FAIL', `is-active=${rother}`)
}
}
try {
/* ═══ 幕 1:杀"当前所在那台"⇒ 必须在 deadline 内切到**另一台** ═══ */
if (sceneArg === 'all' || sceneArg === '1') await runScene1()
/* ═══ 幕 2:两台全杀 ⇒ 无切换、无静默回退(D6 / R11) ═══ */
if (sceneArg === 'all' || sceneArg === '2') {
/**
* 🔴 **序⑩:幕 2 不再依赖上一幕的余波** —— 本幕语义是"两台全杀",但原实现**只停 106**:
* 幕 1 已把 Manager 的通道切到 47(或单跑本幕时活跃的本就是 47)⇒ 停的是**当前不用**的那台
* ⇒ 通道没被打断 ⇒ 窗口内 0 行判别器 ⇒ **幕2-B 假红**(序⑨ 实测:Manager 120 s 零 relay 日志)。
* ⇒ 修法 = **现场重读一次权威通道归属**(与幕 1 同一套 `lastManagerAuthOn` 比对),再把**两台都停掉**
* (幂等;已 inactive 的 `systemctl stop` 是空操作)⇒ 前置与"上一轮留下谁"彻底解耦。
*/
const t47b = await lastManagerAuthOn(h47)
const t106b = await lastManagerAuthOn(h106)
const activeAtStart = t47b > t106b ? h47 : t106b > t47b ? h106 : null
const since = await sinceNow()
await stopRelay(h47)
await stopRelay(h106)
const a47 = await relayActive(h47)
const a106 = await relayActive(h106)
process.stdout.write(
`# 幕2 前置:47 relay=${a47} / 106 relay=${a106}(两台全杀)|` +
`本幕开始时活跃通道 = ${activeAtStart === null ? '(两读数相同 ⇒ 无法唯一确定)' : activeAtStart}` +
`(epoch 47=${t47b} / 106=${t106b})|窗口起 ${since}\n`,
)
// ⚠️ 观察窗必须 ≥ **半开检测**时延(`2.5 × HB_SEC`),否则必然漏判(首轮实测踩到)
await sleep(detectBudgetMs)
const sw = splitErr(logSince(since, '[relay-switch]'))
const sk = splitErr(logSince(since, '[relay-skip]'))
const qErr = sw.err !== '' ? sw.err : sk.err
record(
'幕2-A 两台全挂 ⇒ 无新切换(⛔ 不切到空)',
qErr !== '' ? 'FAIL' : sw.lines === '' ? 'PASS' : 'FAIL',
qErr !== ''
? `❌ 窗口查询失败(⛔ 空窗口不可当 PASS):${qErr}`
: sw.lines === ''
? `0 行 [relay-switch](窗口 ${detectBudgetMs}ms)`
: `❌ 出现:${sw.lines.slice(0, 300)}`,
)
record(
'幕2-B 留下「无候选 ⇒ 原地退避」的判别器证据(D6)',
qErr !== '' ? 'FAIL' : sk.lines !== '' ? 'PASS' : 'FAIL',
qErr !== ''
? `❌ 窗口查询失败:${qErr}`
: sk.lines === ''
? '❌ 无 [relay-skip] 行(静默失效没有判别器)'
: `首个:${sk.lines.split('\n')[0]}`,
)
const pc = await portalCode()
record('幕2-C 门户仍 200(⛔ 不比现状更差 = R11 现场判据)', pc === '200' ? 'PASS' : 'FAIL', `http_code=${pc}`)
}
/* ═══ 幕 3:只恢复 47 ⇒ 冷却期内不回跳;随后复原 ═══ */
if (sceneArg === 'all' || sceneArg === '3') {
const since = await sinceNow()
await startRelay(h47)
await sleep(observeMs)
const s3 = splitErr(logSince(since, '[relay-switch]'))
record(
`幕3-A 恢复 47 后 ${observeMs}ms 内不回跳(冷却期内)`,
s3.err !== '' ? 'FAIL' : s3.lines === '' ? 'PASS' : 'FAIL',
s3.err !== ''
? `❌ 窗口查询失败(⛔ 空窗口不可当 PASS):${s3.err}`
: s3.lines === ''
? `0 行 [relay-switch](窗口 ${observeMs}ms < 冷却 ${r.num('RELAY_FAILOVER_COOLDOWN_MS')}ms)`
: `❌ 出现:${s3.lines.slice(0, 300)}`,
)
}
/* ═══ 幕 4(序⑧):**冷却语义拆分**的真机判据(E9 / 构 A) ═══
*
* 立项依据(上单 §8.8-4):生产目录 3 条候选里 **2 条同机**(`alotbuy.com` + `relay-direct.alotbuy.com`
* 都在 47)⇒ 一次 47 故障会把它们**同时**耗进冷却 ⇒ 杀另一台时"唯一可能的出路"被自己设的冷却挡住
* ⇒ 真机读数 `仍在冷却(剩 59201ms / 共 300000ms)` ⇒ **最长 ~300 s 不切流**。
*
* 序列:① 归零(重启 Manager ⇒ 通道回目录首位 47)→ ② 停 47 ⇒ 切 106
* (47 的**两条**候选各按自己的原因进冷却:`relay-direct` = open-failed,`alotbuy` = switched-away)
* → ③ 恢复 47 → ④ 停 106 ⇒ 此刻"当前挂了 + 所有候选都在冷却" = **D6 现场**。
*
* ⚠️ 本幕**会重启 Manager**(为了①的确定性 + 施加演练期覆盖);`finally` 一律清覆盖并重启回生产值。
*/
const runScene4 = async (variant) => {
const isB = variant === '4b'
const tag = isB ? '幕4b(豁免关+短冷却)' : '幕4'
/** ⛔ 序⑩ 起只剩两个变体:构 A(**零 env 覆盖**)与 4b(豁免关 + **非生产**短冷却 `DRILL_COOLDOWN_MS`)。 */
const overlay = isB
? { RELAY_FAILOVER_EXEMPT: '0', RELAY_FAILOVER_COOLDOWN_MS: String(drillCooldownMs) }
: {}
await applyDrillEnv(overlay)
/**
* ⚠️ **必须先确保两台 relay 都 active** —— 幕 4 的 ② 步要求"停 47 ⇒ 切到 **106**",
* 而 `--scene all` 里前面的**幕 2 已经把 106 停掉**、幕 3 只恢复 47
* ⇒ 不补这一步,幕 4 会在"三条候选全死"下走成假失败(首轮实测踩到)。
*/
const ensureRelay = async (target) => {
if ((await relayActive(target)) !== 'active') {
await startRelay(target)
process.stdout.write(`# ${tag} 前置:${target} ${relayUnit} 原为 inactive ⇒ 已拉起(${await relayActive(target)})\n`)
}
}
await ensureRelay(h47)
await ensureRelay(h106)
const on47 = await normalizeManagerOn47(detectBudgetMs)
process.stdout.write(
`# ${tag} 前置:env 覆盖=${JSON.stringify(overlay)}|Manager 归零到 47 = ${on47}` +
`(⚠️ 含一次 Manager 重启)\n`,
)
if (!on47) {
record(`${tag}-0 Manager 未在窗口内归零到 47`, 'SKIP', '⛔ 状态未归零 ⇒ 不作产品判定')
return
}
// ② 停 47 ⇒ 期待切到 106;**47 的两条候选分别按 open-failed / switched-away 进冷却**
const sinceA = await sinceNow()
await stopRelay(h47)
const hit1 = await waitSwitch(sinceA, detectBudgetMs)
record(
`${tag}-A 停 47 ⇒ 切到 106,且 47 的两条候选都进冷却(造出"候选池被耗干"的结构前提)`,
hit1.err !== '' ? 'FAIL' : hit1.ok ? 'PASS' : 'FAIL',
hit1.err !== ''
? `❌ 窗口查询失败(判据不可信):${hit1.err}`
: hit1.ok
? `耗时 ${hit1.ms}ms|${hit1.lines.split('\n').slice(-1)[0]}`
: `❌ ${hit1.ms}ms 内无 [relay-switch](窗口 ${detectBudgetMs}ms)`,
)
// ③ 恢复 47(**必须在 ④ 之前**:否则"回跳"会撞上一条真的不可用通道,判据失去意义)
await startRelay(h47)
// ④ 停 106 ⇒ D6 现场
const sinceB = await sinceNow()
await stopRelay(h106)
process.stdout.write(`# ${tag} ④ 已停 106(此刻 47 的两条候选仍在冷却)|窗口起 ${sinceB}\n`)
if (isB) {
/* 幕 4b:**缺陷正面复现 + 时限**(豁免关、冷却被缩到 DRILL_COOLDOWN_MS) */
await sleep(noSwitchObserveMs)
const sw = splitErr(logSince(sinceB, '[relay-switch]'))
const sk = splitErr(logSince(sinceB, '[relay-skip]'))
const qErr = sw.err !== '' ? sw.err : sk.err
const noCand = sk.lines.split('\n').find((l) => l.includes('无其他候选')) ?? ''
record(
`${tag}-A 关掉豁免 ⇒ **D6 现场**原样复现(判别器原文)`,
qErr !== '' ? 'FAIL' : noCand !== '' ? 'PASS' : 'SKIP',
qErr !== ''
? `❌ 窗口查询失败:${qErr}`
: noCand !== ''
? `判别器原文:${noCand}`
: '⚠️ 窗口内没拿到「链里无其他候选」行(可能是失效检测还没走完)⇒ 本项不作判定',
)
record(
`${tag}-B 冷却未过期前**不切流**(= §8.8-4 缺陷本身;⛔ 这不是本单的产品失败)`,
qErr !== '' ? 'FAIL' : sw.lines === '' ? 'PASS' : 'FAIL',
qErr !== ''
? `❌ 窗口查询失败:${qErr}`
: sw.lines === ''
? `0 行 [relay-switch](观察窗 ${noSwitchObserveMs}ms < 演练冷却 ${drillCooldownMs}ms)`
: `❌ 出现:${sw.lines.slice(0, 300)}`,
)
const hit3 = await waitSwitch(sinceB, drillCooldownMs + detectBudgetMs)
const last3 = hit3.ok ? hit3.lines.split('\n').slice(-1)[0] : ''
const m3 = hit3.ok ? (/->\s*(\S+?)((|$)/.exec(last3) ?? [])[1] : undefined
record(
`${tag}-C 冷却过期后**自然**切回 47 ⇒ 归因收敛到"冷却语义"而非方向逻辑(E9-b 归因)`,
hit3.err !== ''
? 'FAIL'
: hit3.ok && m3 !== undefined && m3.includes('alotbuy.com')
? 'PASS'
: 'FAIL',
hit3.err !== ''
? `❌ 窗口查询失败:${hit3.err}`
: hit3.ok
? `耗时 ${hit3.ms}ms(演练冷却 ${drillCooldownMs}ms)|目标 ${m3}|${last3}`
: `❌ ${hit3.ms}ms 内仍无切换(预算 ${drillCooldownMs + detectBudgetMs}ms)`,
)
} else {
/* 幕 4(构 A):**必须在 deadline 内切回 47**,且带 `|豁免` 标记(= 47 当时确在冷却的直接证据) */
const hit2 = await waitSwitch(sinceB, detectBudgetMs)
const last2 = hit2.ok ? hit2.lines.split('\n').slice(-1)[0] : ''
const m2 = hit2.ok ? (/->\s*(\S+?)((|$)/.exec(last2) ?? [])[1] : undefined
const to47 = m2 !== undefined && m2.includes('alotbuy.com')
const exemptMark = /|豁免 kind=(switched-away|open-failed) 剩 \d+ms/.test(last2)
const healthReason = last2.includes('原因:当前通道不健康')
if (hit2.ok) process.stdout.write(`# ${tag} ④ 后的切换原文:${last2}\n`)
record(
`${tag}-A **一跳豁免**把通道切回 47(|豁免 标记 = "47 当时确在冷却"的直接证据)`,
hit2.err !== '' ? 'FAIL' : hit2.ok && to47 && exemptMark ? 'PASS' : 'FAIL',
hit2.err !== ''
? `❌ 窗口查询失败(判据不可信):${hit2.err}`
: hit2.ok
? `耗时 ${hit2.ms}ms|目标 ${m2}|豁免标记=${exemptMark}` +
(to47 ? '' : ' ❌ 目标不是 47 那台') +
(exemptMark ? '' : ' ❌ 缺 `|豁免` 标记')
: `❌ ${hit2.ms}ms 内无 [relay-switch]`,
)
record(
`${tag}-B 原因必须是 health 路径(E9③):原文含「原因:当前通道不健康」`,
hit2.err !== '' ? 'FAIL' : healthReason ? 'PASS' : 'FAIL',
healthReason ? '✅' : `❌ 原文:${last2.slice(0, 260)}`,
)
record(
`${tag}-C 切换耗时 ≤ RELAY_FAILOVER_DEADLINE_MS`,
hit2.err !== '' ? 'FAIL' : hit2.ok && hit2.ms <= deadlineMs ? 'PASS' : 'FAIL',
`实测 ${hit2.ms}ms / deadline ${deadlineMs}ms` +
`(⚠️ 该口径**含失效检测时延**;§8.8-2 已登记其为临界项)`,
)
}
await startRelay(h106)
}
if (sceneArg === 'all' || sceneArg === '4') await runScene4('4')
if (sceneArg === '4b') await runScene4('4b')
} finally {
/* ⛔ 一律复原(失败也复原)—— 首轮踩过"停半路把 relay 留在停用态"。 */
/**
* 序⑧:演练期的 env 覆盖**必须一并清掉**(否则 Manager 带着"豁免关 / 短冷却"继续跑 =
* 静默的配置漂移)。⚠️ 只删文件不够:**进程里仍是旧 env** ⇒ 必须跟着重启一次。
*/
if (drillEnvApplied) {
try {
await clearDrillEnv()
const envv = await ssh(sshPort, h47, `systemctl show ${managerUnit} -p Environment | tr ' ' '\\n' | grep -c RELAY_FAILOVER || true`, sshTo)
process.stdout.write(`# 复原:演练 env 覆盖已删 + 已重启;残留 RELAY_FAILOVER_* 计数 = ${envv}(⚙️ 若为 0 说明回到生产值)\n`)
} catch (err) {
process.stderr.write(`❌ 清除演练 env 覆盖失败(🔴 请手工检查 ${DRILL_DROPIN}):${err.message}\n`)
}
}
const restore = async (target) => {
try {
if ((await relayActive(target)) !== 'active') {
await startRelay(target)
process.stdout.write(`# 复原:${target} ${relayUnit} ⇒ ${await relayActive(target)}\n`)
}
} catch (err) {
process.stderr.write(`❌ 复原 ${target} 失败:${err.message}\n`)
}
}
await restore(h47)
await restore(h106)
}
const fails = results.filter((x) => x.verdict === 'FAIL')
const skips = results.filter((x) => x.verdict === 'SKIP')
process.stdout.write(
`\n# 结果:${results.length - fails.length - skips.length} PASS / ${skips.length} SKIP / ${fails.length} FAIL` +
(skips.length > 0 ? `;SKIP:${skips.map((f) => f.name).join(' / ')}` : '') +
(fails.length > 0 ? `;FAIL:${fails.map((f) => f.name).join(' / ')}` : '') +
'\n',
)
return fails.length === 0 ? EXIT_OK : EXIT_FAIL
}
main()
.then((code) => process.exit(code))
.catch((err) => {
process.stderr.write(`❌ 演练异常:${err.stack ?? err.message}\n`)
process.exit(EXIT_FAIL)
})