Files
dsh_ai1net_server/归档/文档整理/交接单-20260924-归档/交接单_检测时延与deadline_20260917.md
T
admin c1b5e4d966 chore(工作区): 全量入库 + 补齐 .gitignore(以工作区为准)
- 变更规模:新增 514 / 修改 62 / 重命名 155 / 删除 4(归档重组与文档轮次)
- .gitignore 修:`归档/**/db-cwd归一-备份-*/` —— 原规则写绝对层级(归档/db-cwd归一-…),
  目录搬进 归档/配置与备份/ 后**静默失效**,43 MB 的 DB 备份又变成未跟踪
- .gitignore 补:嵌套 git 内部数据(归档/内嵌git-20261008/、归档/skills-git-旧线-20261007/dotgit-原样移出/)
- .gitignore 补:运行态与部署副本(.workbuddy/collab/、.workbuddy/tools/、.workbuddy/.load-pending、.workbuddy/tmp-*)
- .gitignore 补:备份件(*.bak-*)
- 未跟踪文件从 2190 降到 890(其余为 归档/ 归档件与 .workbuddy/memory/ 知识文件,按口径入库)
2026-10-10 23:13:22 +08:00

60 KiB
Raw Blame History

交接单 · 检测时延与 deadline(覆盖网络 · 序 ⑨)

一单只做一件事:把「杀中继 ⇒ 切流完成」的 30 s 墙钟拆开量化,并用分解结果决定改哪里 —— 目标是让真机样本回到 RELAY_FAILOVER_DEADLINE_MS(30000) 的内侧,⛔ 不是把 deadline 调大。

上游依据(均已核实,⛔ 不必重读全文):

  • 交接单_切流冷却语义_20260917.md §8.8-2(现象 + 口径提示"该值含静默失效检测时延 ≈ 2.5×HB_SEC")—— ⚠️ 本单 §1.2 已用实测勘误该口径,见 RC-5
  • 同上 §8.8-3(OBS-11 回头条件)⇒ 本单 §2-P7 已复取,判定见下
  • 同上 §8.8-4(RELAY_FAILOVER_COOLDOWN_MS=0 自锁)⇒ 本单 §7 回滚 ⛔ 禁用该值
  • 同上 §9(switcher.ts 阈值六键默认值 300000 / 30000 / 15000 / 3 / 2000 / 12000 + 三个装配点 + env 覆盖生效)
  • 交接单_中继失败切流_20260917.md §8.8-2(五样本全景 30563 / 27878 / 29176 / 32550 / 29586;触发条件 = 样本 > 30000 ⇒ 立即回头)—— 本单的立项依据
  • 代码单一来源:D:/github/dsh_shenxian/src/net/relay/switcher.ts(+ client.ts / web/server.ts)

本单性质:规划棒产出。⛔ 只出单、不改服务器、不改代码(规划与执行分离)。


§1 目标

1.1 一句话

先证明这 30 s 花在哪两段上,再决定改「检测」还是改「拨号」 —— 现在证据指向:检测 ≈ 15 s(优雅重启的 burst 窗口地板)、拨号 ≈ 12 s(对一个必然失败的同机候选白等 upTimeoutMs)、真正建连只要 ≈ 2.8 s。

1.2 本单的核心判定:30 s 去哪了(用现有日志分解,不需要新埋点)

方法:[relay-client] down / [relay-skip] / [relay-switch] 三种行本来就带毫秒级时间戳(journalctl -o short-unix)⇒ 分解是"读现有日志",不是"加新探针"。

样本 ① · 序⑧ 幕 4 收口(drill 读数 29586 ms,与五样本里的 29586 同一轮) —— 逐行原文:

事件 epoch 相对 BYE 段长
[relay-client] down (peer bye: server restarting (was up)); attempt #0 [graceful, burst window 15000ms] 1789617465.999982 0 —
attempt #1, retry in 975ms(burst 窗口耗尽,退避才从 0 起算) 1789617481.084742 +15.085 s ① 检测 = 15.08 s
[relay-skip] ⛔ 新通道起不来(wss://relay-direct.alotbuy.com/dshs-relay)… 该候选进冷却 300000ms 1789617493.375446 +27.376 s ② 白等 = 12.00 s(= waitUpOn 吃满 upTimeoutMs)
[relay-switch] #1 wss://alotbuy.com/… -> wss://106.54.21.172/…(原因:当前通道不健康(… attempts=4 unhealthyForMs=29376 …)) 1789617496.196091 +30.196 s ③ 建连 = 2.82 s

样本 ② · 序⑦ 30563 ms 那一轮 —— 同结构、逐行原文:

事件 epoch 相对 BYE
peer bye: server restarting (was up) … burst window 15000ms 1789616881.632718 0
[relay-skip] ⛔ 新通道起不来(relay-direct)… 进冷却 300000ms 1789616910.195416 +28.56 s
[relay-switch] #1 alotbuy -> 106(… attempts=5 unhealthyForMs=30563 …) 1789616912.919233 +31.29 s(drill 读数 30563 ⇒ 偏移 0.73 s = systemctl stop 返回晚于 BYE 的时间)

⇒ 两样本结构完全一致:15.0(检测)+ ~1.5(tick 相位)+ 12.0(白等)+ 2.1(建连)≈ 30.6 s。

分解结论(一句话):

检测段 = 15 s 不是"半开检测",而是 gracefulBurstMs 的地板;拨号段 = 12 s 里 12 s 全是白等(waitUpOn 不对终态失败早退),真正的跨机建连只要 2.8 s。 ⇒ 两段各占一半;其中 12 s 是纯浪费(生产目录前两条候选同在 47)。

五样本按同一结构反算(⛔ 未逐行复核,须由 §5-S2 验证):

样本 总 检测 tick 相位 白等 建连 是否自洽
30563 30.56 15.00 ~1.5 12.00 ~2.1 ✅ 已逐行复核
29586 30.20 15.08 ~0.3 12.00 2.82 ✅ 已逐行复核
27878 27.88 15.00 ~0.2 12.00 ~0.7 ⚠️ 反算自洽,未复核
29176 29.18 15.00 ~0.2 12.00 ~2.0 ⚠️ 反算自洽,未复核
32550 32.55 15.10 ~0.2 12.00 ~5.2 ⚠️ 反算自洽,未复核

🔴 可被证伪:以上结构的硬地板 = 15.0 + 12.0 = 27.0 s。若 §5-S2 采到任一 < 27 s 的样本 ⇒ 本单的分解被证伪,立刻停下报告(⛔ 不许带着错分解去改代码)。

根因清单(按可省时长排序):

# 根因 证据 可省
RC-1 waitUpOn 对"终态失败"不早退 —— server.ts:499-506 只轮询 state === 'up',直到 deadline 才返回 false ⇒ 每个死候选固定吃满 upTimeoutMs。生产目录 [alotbuy.com(47), relay-direct.alotbuy.com(47), 106] 前两条同机(server.ts:517-519 注释原文已承认"这个坑一定会踩到"),杀 47 ⇒ 第一次 tick 必然先试 relay-direct ⇒ 白等 12 s 两样本的 [relay-skip] ⛔ 新通道起不来 行,间隔 ≡ upTimeoutMs ~12 s
RC-2 gracefulBurstMs(15_000) 期间 attempts 恒为 0(client.ts:565-580 三处 this.attempts = 0)⇒ unhealthy()(switcher.ts:244 = attempts ≥ 3 ∨ unhealthyForMs ≥ graceMs)只能靠 graceMs=15000 成立 ⇒ 优雅停机(systemctl stop/restart、部署)路径的检测地板 = 15.0 s 两样本 attempt #0 [graceful …] → attempt #1 间隔 = 15.085 / 15.06 s 受 DP-4 约束,本单⛔不改
RC-3 tick 周期 2000 ms ⇒ 判不健康 → 发起换址之间存在 0–2 s 相位误差 实测 0.29 / 1.48 s 0–2 s
RC-4 DRILL_POLL_MS = 2000 ms ⇒ 演练读数本身有 0–2 s 系统性高估 drill waitSwitch 每 2 s 轮询一次 测量误差,非产品时延
RC-5 🔴 勘误:上单 §8.8-2 的口径提示"该值含静默失效检测时延 ≈ 2.5×HB_SEC(= 37.5 s)"在优雅停机路径上不成立 —— 两样本都没走到半开检测(half-open suspected 行为零命中)。⇒ 该口径只在丢包型静默失效时生效;HB_SEC 不在这条关键路径上 ⇒ ⛔ 别为了它去动心跳/控制面流量模型 样本 ①②的原文链条里无 half-open 行 0(防误改)

1.3 三问的判定

① 30 s 里多少是检测、多少是拨号?

判定:检测 15.0–15.1 s(50%)/拨号 12.0 s 白等 + 0.7–5.2 s 建连(50%)。 依据见 §1.2 两个逐行复核样本。

② 该先改哪里?

判定:先改「拨号段的白等」(RC-1),⛔ 不动 deadline、⛔ 不动 HB_SEC。

  • 为什么不是调 deadline:deadline 是验收判据(上单 §8.8-2 原文:"放宽 = 作废本轮判据")⇒ 调大 = 把问题藏起来,明确违反"要解决问题、不将就妥协"。
  • 为什么不先动检测段(RC-2):15 s 的 burst 窗口是有意设计 —— client.ts:30 注释原文"计划内下线不是故障:gracefulRetryMs 后立刻重连"。把它改成"graceful 也触发切流"⇒ 每次 relay 重启/部署都切一次流 = 正是 D5 要防的抖动。⇒ 判为已定项,⛔ 不动(§4.1-D3)。
  • 为什么拨号段是"真缺陷":12 s 不是"等一个慢候选",是等一个必然失败的候选(waitUpOn 不看终态),且生产目录结构决定每次从 47 切走都必然命中 ⇒ 纯浪费、且有确定性的修法。

③ 判据怎么改才算"不将就"?

判定:把"总时长 ≤ deadline"细化为"三段各自的预算",并把测量误差从判据里剔掉。

  • 总判据保留(kill → switch ≤ 30000 ms),但必须同时看三段:检测 ≤ 16 s(graceful)/白等 ≤ 1 s(修后)/建连 ≤ 6 s。
  • DRILL_POLL_MS 2000 → 500:否则 ≤2 s 的量化误差会让"是否超 deadline"的判定既可能假红也可能假绿(RC-4)。这是测量修正,不是调参。

§2 只读前置(⛔ 只读,不改;P1–P10 逐条核实后才允许进 S 段)

# 命令(原文) 期望
P1 bash "D:/github/dsh_shenxian/dsh-server-docs/scripts/handoff-guard.sh" --claim-exec "覆盖网络线-序9执行棒" ✓ 已持全局执行锁。抢不到 ⇒ 有会话在跑 ⇒ 停下报告
P2 "E:/ProgramData/.workbuddy/binaries/python/versions/3.13.12/python.exe" "E:/ProgramData/AI技能/aliyun-dsh-server/state.py" 锁 owner = 本会话名;HEAD = 640813e;入口 = 接续入口_覆盖网络线_20260916.md,其 §2 指向本单
P3 cd "E:/ProgramData/AI技能/aliyun-dsh-server" && sed '/^## §10 指纹/,$d' 参数表_覆盖网络_20260917.md | md5sum e6b669c257d8e8964273b3b400238351(【实测·本棒复取】;不一致 ⇒ 参数表被人动过,停下报告)
P4 cd "E:/ProgramData/AI技能/aliyun-dsh-server" && sed '/^## §8 回报格式/,$d' 交接单_检测时延与deadline_20260917.md | md5sum 与 §8.9 里记载的"本单出单值"逐字一致(⚠️ 该值故意只写在 §8 之内 —— §8 本身不计入哈希,写在这里会让哈希自指失效)
P5 上两单前缀:sed '/^## §8 回报格式/,$d' 交接单_切流冷却语义_20260917.md | md5sum ⇒ aa3a6ec0d66dd81f465cea2a3a08ad27;…交接单_中继失败切流_20260917.md | md5sum ⇒ 419abf308c00b7668e8898aaa91ba9e8 两个值逐字一致(【实测·本棒复取】)⇒ 底稿未被改动
P6 cd "D:/github/dsh_shenxian" && grep -n "gracefulBurstMs|halfOpenMs" src/net/relay/client.ts && grep -n "const waitUpOn" -A 8 src/web/server.ts && grep -n "private unhealthy|async open|deps.open" src/net/relay/switcher.ts 锚点存在性(⛔ 不验行号):client.ts ≈ 137/146/567/631;server.ts ≈ 499(waitUpOn 只轮询 up、不看终态)/524;switcher.ts ≈ 244(unhealthy)/300(await deps.open)
P7 cd "E:/ProgramData/AI技能/aliyun-dsh-server" && node "D:/github/dsh_shenxian/scripts/overlay-probe.cjs" > /tmp/seq9-probe-s0.txt 2>&1; echo $? 12/12 PASS、exit 0。【实测·本棒 13:3x 复取】当前为 10/12(OBS-09 / OBS-11 红) ⇒ 见下方口径说明
P8 ssh -p 22 bt-server "systemctl is-active dshs-relay dshs || true; curl -s http://127.0.0.1:20080/status" + ssh -p 22 test106 "systemctl is-active dshs-relay dshs-worker || true" 两台 relay active;47 /status 的 capacity.used = 2、online[] 含 manager + w-106。⚠️ 106 nginx 归宝塔托管 ⇒ is-active nginx=inactive 是正常态,⛔ 别据以判故
P9 npm.cmd test(必须 Node v22) 155 tests / 154 pass / 0 fail / 1 skipped(序⑧ 收口基线;⚠️ 若因新增用例上浮,写清"新增 n / 基线 155")
P10 🔴 分解复现(本单的核心前置):ssh -p 22 bt-server "journalctl -u dshs --since -6h -o short-unix --no-pager | grep -E 'relay-switch|relay-skip|relay-client. down'" ⇒ 抽 §1.2 的两个样本,逐行算出四段数值 样本①(unhealthyForMs=29376)= 检测 15.085 s + 白等 12.00 s + 建连 2.82 s;样本②(unhealthyForMs=30563)= 28.56 / 12.00 / 2.72 s。复现不出 ⇒ 停下报告(说明 §1.2 的分解是错的,⛔ 不许带着它进 S3)

2.1 P7 的口径(承上单 §8.8-3 的回头条件)—— 本棒已复取,判定:未命中

  • 复取值:ss -lntp | wc -l = 77(= 76 socket + 1 行表头);两次连采同值(sample1=77 / sample2=77);nft=72、relay 口绑回环 1/1、门户 200、两台 relay active。
  • 环境态钉因:LOCAL_INSTANCE_PORT(20000) 未监听 ⇒ 47 无活跃实例(/opt/dsh/users/main 存在但无实例进程,ps 里只有控制面 / w-47 worker / relay 三个 node)。⇒ 口径目标 = 78("无活跃实例"档),不是 79。
  • 判定:78 − 77 = 1,差值 1 < 2 ⇒ 未命中上单 §8.8-3 的回头条件("差值 ≥ 2");且另一分支要求"有活跃实例时仍 ≠ 79",本棒无活跃实例 ⇒ 分支不适用。另:相对序⑧ 收口值(同为 77)零变化。
  • ⛔ 只报告不动手(R7):78 → 77 这1 个口从未被定位(上单已核销 9 个固定口 / 64 个拨号池口 / nft / relay 回环绑定)。⇒ 记为在册未办,本单不修;只把"完整端口清单留档"作为 §5-S0 的一个动作(取证,不是修复),让下次漂移可比对。

2.2 已核实的代码事实(⛔ 执行棒不必重复探索)

  1. waitUpOn 不会对失败早退(RC-1 的代码真身):server.ts:499-506 每 100 ms 查一次 client.status().state === 'up',直到 deadline 才 return false。⇒ 死候选(连不上 / 握手失败)与慢候选在这一层不可区分,代价恒为 upTimeoutMs。
  2. upTimeoutMs 有三处消费点(改一处不够):web/server.ts:524(Manager 拨号通道 · C1)/worker/relay-tunnel.ts:104 与 :158(worker 实例面 · C2)/net/relay/main.ts:315(独立 relay --client · C3)。⚠️ 序⑦ 的 D1 就是为"同一件事别在多处各写一份"立的 —— 要么抽公共,要么每处都改并逐处验收。
  3. gracefulBurstMs 是"写死的默认值、不可配":client.ts:137 是可选 option,全仓 grep 只有 client.ts:567 的 ?? 15_000,无任何 env 键 / 无任何装配点赋值 ⇒ 运行时无法调(对比 RELAY_FAILOVER_* 全部可经 env 覆盖)。
  4. burst 期间 attempts 被强制归零:client.ts:565-580 三个分支各自 this.attempts = 0(queued / graceful / burst)⇒ 与 switcher.ts:244 的 unhealthy() 组合出"15 s 检测地板"(RC-2)。
  5. 半开检测参数与触发:client.ts:631 halfOpenMs ?? max(3000, hbSec×1000×2.5) = 37.5 s;:632 巡检 tick = halfOpen/4;:636 判据 age > halfOpen。⚠️ 仅在 state === 'up' 时计时 ⇒ 只在"连着但不来帧"(丢包)时生效(RC-5)。
  6. 演练读数的两个系统性偏差:① waitSwitch 每 DRILL_POLL_MS(2000) 轮询一次 ⇒ 读数高估 0–2 s;② t0 = Date.now() 在 stopRelay 返回之后 ⇒ 比 relay 的 BYE 行晚 ≈ 0.6–0.73 s(两样本实测;这正好解释为什么 drill 读数与 unhealthyForMs 数值接近)。
  7. [relay-switch] 行自带 state/attempts/unhealthyForMs/阈值 ⇒ 三段的边界不需要新埋点,只需时间戳对齐(D2 的"⛔ 不新造探测帧"继续成立)。
  8. 本机 = 生产的前身 ⇒ 改动会经下一次 scp 传导到 47/106。⛔ 推送前先 git status --short 确认待传清单只含本次真实改动(当前 = 43 项)。
  9. src/net/relay/** 在 git 里 untracked ⇒ 改动用 git diff 看不到,只能用 grep -c / wc -l / git status --short 核(⚠️ 目录里还留着一个 client.ts.bak-seq7-*)。

§3 范围

3.1 要改的(且只有这些)

# 文件 改动 理由(为什么非改不可)
1 src/web/server.ts(+ 对齐 worker/relay-tunnel.ts / net/relay/main.ts) waitUpOn 增加"终态失败早退":新通道一旦进入终态失败态(建议判据 = 已发生一次明确的失败退避且不在 burst 窗口内,或 lastFatalReason 命中)⇒ 立即 return false,⛔ 不再等满 upTimeoutMs。三处消费点逐处验收;能抽公共就抽公共(D1) RC-1 的唯一落点 ⇒ 直接省掉 12 s。判据必须"不误伤慢候选":仍能连上只是慢的候选,依旧享受完整 12 s
2 scripts/overlay-failover-drill.cjs 新增 --trace:从 journal 抽 down/skip/switch 三类行,逐样本算出四段(检测 / tick 相位 / 白等 / 建连)并落盘原始行 E1–E5 的判据本体(分解必须可被第三方复现)。⛔ 原有 --scene 1|2|3|4|4b|all 语义一字不改
3 test/relay-failover.test.mjs 新增用例:死候选 ⇒ open() 提前失败(注入"永不 up 且已进 backoff"的假客户端)/慢候选 ⇒ 仍等满超时(护栏,⛔ 防"早退"误伤) 本线纪律:先红后绿已实证两次;"早退"必须有护栏用例否则会把慢候选误杀(= R11 净退化)
4 src/net/relay/client.ts 把 gracefulBurstMs 参数表化(新增 env 键 RELAY_GRACEFUL_BURST_MS,默认值 15_000 不变) 消除"唯一一个不可配的时延常量"(符合本线既有的"阈值零魔数"纪律,overlay-probe E6)。⚠️ 只加可配性,⛔ 不改值
5 参数表_覆盖网络_20260917.md ① DRILL_POLL_MS 2000 → 500;② 新增 DRILL_SAMPLE_N = 5;③ 新增 RELAY_GRACEFUL_BURST_MS = 15000;④ **§9 表格新增一行「时延分解(序⑨ · 实测)」**记录四段数值 + 两个样本的 epoch 原文;⑤ §7 计数回填;⑥ §10 指纹更新 参数表 = 单一来源。🔴 值格必须纯数字(上单 §8.6 的假红坑:夹注 ⇒ NaN ⇒ OBS-02 假红)
6 overlay-failover-drill.cjs 的 waitSwitch / 参数表 DRILL_POLL_MS 轮询周期 2000 → 500 ms(随第 5 项) RC-4:把 ≤2 s 的量化误差从判据里剔掉 —— 否则"是否超 deadline"两端都可能误判

3.2 ⛔ 不动什么(防顺手扩大)

  • ⛔ 不动 RELAY_FAILOVER_DEADLINE_MS(30000)。这是本单的纪律核心:放宽判据 = 作废判据。若 §5-S7 复测仍超界 ⇒ 写进 §8.8 报告,⛔ 不许自行改值。
  • ⛔ 不动 gracefulBurstMs 的默认值(只加 env 可配性 = §3.1-4);⛔ 不动 client.ts 的 burst 语义("计划内下线不触发切流"是有意设计,见 §1.3-②)。
  • ⛔ 不动 HB_SEC(15) —— 分解已证 half-open(37.5 s) 不在关键路径上(RC-5);动它等于为一个没发生的路径改控制面流量模型。
  • ⛔ 不动 RELAY_FAILOVER_COOLDOWN_MS(300000);🔴 ⛔ 绝对不许把 COOLDOWN_MS=0 写进任何回滚 / 演练路径(上单 §8.8-4:归零连带废掉"失败候选必须被排除" ⇒ 候选链自锁,实测 121–123 s 无切换)。演练期要缩短只走 DRILL_COOLDOWN_MS。
  • ⛔ 不动 RELAY_FAILOVER_MIN_ATTEMPTS / GRACE_MS / CHECK_MS(本单不改判据阈值;若要改,先回 §8 报告)。
  • ⛔ 不做"同机候选去重 / 并发竞速 open"(§4.2-DP-2):运行时拿不到"同机"维度(候选只是 url 字符串),要做得在取址层新增 host 维度 = 扩大改造面;并发 open 会同时建两条通道(多占口池口 + 多一次 AUTH)⇒ 暴露面/资源面变化需另行评估。记为在册未办(R7 只报告)。
  • ⛔ 不新增任何监听口 / 凭据 / 对外可读端点;⛔ 不动 nft / 安全组 / nginx;⛔ 不改 relay 服务端协议。
  • ⛔ 不动 worker 会合面(DSHS_RENDEZVOUS_URL / DSHS_TUNNEL_TARGET —— 已判"撤掉 ⇒ 判不执行",别再复议);⛔ 不动 directory 引导链。
  • ⛔ 不重做序 ②–⑧;⛔ 不做 presence / 房间层 / 内容分发(清单第 7 步);⛔ 不做打洞实现。
  • ⛔ 不 commit / 不 push(未获授权)。
  • ⛔ 范围外的缺陷先报告、不顺手改(R7)。在册:OBS-11 的 1 口差(§2.1)|OBS-09 实例面 000|guest(w-106) 实例页 502|mksess*.cjs 失效|unhealthyForMs 记账起点偏早(上单 §8.8-5)|src/net/relay/** untracked 留档缺口|client.ts.bak-seq7-* 残留。

§4 决策点

4.1 已定项(规划棒已拍 —— 执行棒不得自行更改;要改必须回写 §8 并说明理由)

# 已定 理由 / 反例
D1 顺序 = 先量化再调参:§2-P10 的分解复现成功之前,⛔ 不许改任何 RELAY_FAILOVER_* 的生产值(含 deadline / minAttempts / grace / upTimeout) 用户明令 + 本单立项初衷。分解已经能用现有日志做出来(§1.2)⇒ 没有理由跳步
D2 判据来源 = 现有日志行的时间戳,⛔ 不新增探测帧 / 不新开端点 / 不引外部监控 承序⑦ D2;[relay-switch] 行自带 attempts / unhealthyForMs / 阈值,信息已足够
D3 15 s 的 burst 检测地板⛔ 不改(保持"计划内下线不触发切流") client.ts:30 原文语义;改它 ⇒ 每次 relay 重启都切流 = D5 要防的抖动(R11 净退化)
D4 首选修法 = RC-1 的"死候选早退";upTimeoutMs 降值只作降级替代 降 upTimeoutMs 会误伤"慢但能通"的候选(实测 106 建连 2.7–2.8 s ⇒ 降到 4 s 已贴边)⇒ 只有在 RC-1 拿不到 12 s 时才考虑
D5 DRILL_POLL_MS 2000 → 500(测量修正,非调参) RC-4:≤2 s 量化误差会让判定两端误判
D6 deadline 保持 30000,且必须继续按 30000 判(⛔ 不设"宽限窗口"、⛔ 不写"接近即视为通过") 判据一旦软化,本单与上单的结论都不可比
D7 三处装配点必须逐处验收(server.ts / relay-tunnel.ts / main.ts) 序⑦ D1 的教训("同一件事在多处各写一遍,其中一处悄悄漏了")
D8 服务端零改动(relay 侧最多只读) 缩小失败域(承序⑦ D8 / 序⑧ D12)
D9 若最终确需动 upTimeoutMs ⇒ 必须同时给出"慢候选不被误杀"的判据(例如"某台成功建连耗时的 P95 + 余量")并回填参数表 ⛔ 不许凭感觉给一个更小的数

4.2 交给执行棒自决(⛔ 不上升为提问)

"终态失败"的具体判据(用 state === 'backoff'?用"已发生 ≥1 次非 burst 退避"?用 lastFatalReason 是否存在?还是给 RelayClient 加一个只读的 failedTerminally() 投影)|早退判据的代码落点(waitUpOn 内联 vs 抽 src/net/relay/ 公共工具)|是否把三处 waitUpOn/waitUp 合并|--trace 的输出格式(四段数值 + 原文行)/落盘路径|DRILL_SAMPLE_N 的采法(是否复用 --scene ctrl)|RELAY_GRACEFUL_BURST_MS 的键名与是否需要 DRILL_ 前缀的演练覆盖|§9「时延分解」行的表格字段设计|日志文案细节。

4.3 真需要用户拍板的(命中才问,且一轮只问这一句)

本单 = 空。

三问的判定全部落在"技术实现 / 测量与判据"边界内(§1.3 已逐条给出判定与依据);最容易被误当成"业务意图"的 D3(relay 计划内重启该不该触发切流) 已有明确的设计原文依据(client.ts:30 注释 + burst 窗口的存在理由)⇒ 属"客观可判",自行拍掉、不上抛。

4.4 技术实现裁决顺序(⚠️ 与 dsh-decision-method §4.4 一致)

① 先复用现成能力(现有日志行的时间戳 + RelayClient.status() 的 state/attempts + 既有 [relay-skip] 判别器)→ ② 再改现有模块(waitUpOn 一处/三处)→ ③ 最后才新增(--trace、两个单测、参数表两个键)→ ⛔ 不引第三方依赖。

4.5 权限影响评估(按红线 R5 要求先出;结论:未命中 R5,暴露面零变化)

维度 本单影响 判定
新增监听口 0(只改客户端换址时序;候选集不变) ✅ 不扩大
新增凭据 0 ✅ 不扩大
放宽访问控制 0(早退只是"更早放弃一个已失败的候选",⛔ 绝不放宽为"连目录外地址") ✅ 不扩大
入站 106 入站仍为 0;47 无新增入站口 ✅ 不扩大
新增可读面 0(D2:⛔ 不新开端点;--trace 只读本机 journal) ✅ 不扩大
暴露面净变化 0 ✅

⚠️ 若执行中为"能切过去"而允许连目录外的地址 ⇒ 立刻停下:那才是真的 R5(扩大信任面 = 任意重定向),必须回来出评估。


§5 步骤(S0–S9;每步自带一次可执行的验证)

S0 · 只读取证(= §2 P1–P10 + 端口清单留档)

  • 动作:按 §2 逐条跑;P3/P5 指纹逐字一致;P10 的分解必须复现;额外把 47 的完整端口清单落盘(ss -lntp | tail -n +2 | awk '{print $4}' | sort -n | uniq > /tmp/seq9-ports-<ts>.txt)—— 这是取证,用于下次 78 → 77 类漂移的可比对(⛔ 不是修复)。
  • 验证:P1–P10 全部 ✅;P10 的四段数值与 §1.2 一致(容差:检测段 ±0.5 s、白等段 ±0.3 s、建连段 ±1.5 s)。
  • ⛔ 硬门:任一 ❌ ⇒ 停下报告,不进 S1。

S1 · 时间轴抽取(只读,先出工具)

  • 动作:给 scripts/overlay-failover-drill.cjs 加 --trace:一次 journalctl 取回窗口内 relay-client. down / relay-skip / relay-switch 三类行(⚠️ --since 一律用 @<epoch>;⚠️ 远端读状态命令一律 || true;⚠️ 保留 JOURNALCTL-ERR 哨兵),按样本切分(以 down (… (was up)) 或进程号变化为界)算出四段,并原样打印所依据的行。
  • 验证:对 §1.2 的两个历史样本跑 --trace ⇒ 四段数值与 §1.2 表格逐项对得上。
  • ⚠️ 口径:--trace 只读,⛔ 不停 relay、⛔ 不改 env、⛔ 不写远端文件。

S2 · N=5 采样(判据分布的地基)

  • 动作:跑 5 次"杀当前入口"(每轮之间必须归零:① 两台 relay systemctl start ② 重启 Manager(restart dshs)⇒ 通道回到目录首位 ③ 等 47 的 relay 日志出现新的 AUTH OK host=ops/manager);每次用 --trace 落盘。
  • 验证:5 份 trace 齐全;每份的"检测 + 白等"≥ 27.0 s ⇒ 若出现 < 27 s 的样本 ⇒ 🔴 分解被证伪,立刻停下报告(§1.2 的可证伪条款)。
  • ⚠️ 注意事项:① 必须避开 --scene ctrl(COOLDOWN=0)那类轮次 —— 会污染候选顺序;② 每轮记录 DRILL_POLL_MS 值(S6 改前后各一组,读数不可混比)。

S3 · 按分布选方案(门 D1 在此解锁)

  • 动作:算三段的中位数与极值:
    • 若白等段中位数 ≥ 8 s ⇒ 做 D4 首选(S4 的死候选早退);
    • 若白等段中位数 < 8 s ⇒ 说明 RC-1 不是主因 ⇒ 停下报告,把精力转向 RC-3/RC-2 的重新评估(⛔ 不许硬套)。
  • 验证:一段结论 + 三行数据(中位数 / 最小值 / 最大值)写进 §8。
  • ⛔ 硬门:S2 未跑完 ⇒ ⛔ 不许进 S4。

S4 · 死候选早退(RC-1 落点)

  • 动作:waitUpOn(及另两处等价逻辑)在轮询里增加终态失败判定 ⇒ 立即 return false;⛔ 不得把"还在 burst 窗口内的短间隔重试"误判成终态(那会让 relay 的计划内重启被当成死候选 ⇒ 每次部署都切流)。
  • 验证:单测 先红后绿;npm.cmd test ⇒ 155 + 新增 / 全绿(基线 155)。
  • ⚠️ 易错点:三处消费点(server.ts:524 / relay-tunnel.ts:104、:158 / main.ts:315)逐处确认;⛔ 别只改一处就宣布完成。

S5 · 护栏用例(防"早退"误杀慢候选)

  • 动作:新增单测:慢候选(连得上、只是 up 来得晚,< upTimeoutMs)必须仍然成功;死候选(进 backoff 且出 burst 窗口)必须提前失败。
  • 验证:两条同时绿;且既有用例一条不红。
  • ⚠️ 不变量:这条是"早退"的护栏 —— 没有它,早退可能变成"更频繁地切到第三候选"甚至"全部候选都判失败"(R11)。

S6 · 参数表闭环

  • 动作:DRILL_POLL_MS 2000 → 500;新增 DRILL_SAMPLE_N、RELAY_GRACEFUL_BURST_MS;§9 新增「时延分解(序⑨ · 实测)」行(四段数值 + 两个样本 epoch 原文);§7 计数回填;§10 指纹复取回填。
  • 验证:grep -nE 'DRILL_POLL_MS|DRILL_SAMPLE_N|RELAY_GRACEFUL_BURST_MS' 参数表_覆盖网络_20260917.md ⇒ 值格纯数字、无夹注。
  • ⚠️ 假红坑:值格带夹注 ⇒ NaN ⇒ OBS-02 假红(上单 §8.6 原文教训)。

S7 · 真机复测(修后 N=5,判据不许软化)

  • 动作:S4 部署后重跑 S2 的 5 轮(scp + restart dshs;⚠️ relay 真身在 /opt/dsh-relay/lib/,两处都要铺)。
  • 验证:五样本 kill→switch ≤ 30000 ms(用新的 500 ms 轮询口径读数);且白等段 ≤ 1 s。
  • ⛔ 硬门:若仍有样本 > 30000 ms ⇒ 写进 §8.8 报告并给出下一步候选(含 upTimeoutMs 的降值方案 + 慢候选 P95 证据),⛔ 不许自行改 deadline。

S8 · 不退化

  • 动作:--scene all(1/2/3/4/4b)+ overlay-probe + npm test + 门户 200 + 双实例面 ∈ PROBE_CODE_SET + ss -lntp | wc -l 与 §2-P7 对照。
  • 验证:见 E9/E10。幕 1/2/3/4 结果不得变红。
  • ⚠️ 观察点:若出现新的 [relay-skip] 或 [relay-switch] 形态变化 ⇒ 事件行必须原样抄进 §8(含 attempts / unhealthyForMs)。

S9 · 收口

  • 动作:清理临时产物(trace 落盘件先移 _中间产物_待清理/);释放锁;登记下一棒 automation(scheduledAt = 收口时刻 + 2~5 分钟);用陈述句告知接续;推进入口 §2;写工作区日志。
  • 验证:见 E11/E12。

§6 验收(判据清单;命令 + 期望输出,可被第三方复现)

# 判据 命令 / 观察点 期望
E1 🔴 分解可复现(D1 的解锁条件) §2-P10 + --trace 对两个历史样本 检测 15.02–15.60 s;白等 11.7–12.3 s;建连 1.3–4.3 s;三段之和 ≡ 墙钟(误差 ≤ 0.5 s)
E2 分解未被证伪 S2 的 5 份 trace 每份"检测 + 白等" ≥ 27.0 s;出现 < 27 s ⇒ 停下(§1.2 可证伪条款)
E3 白等段有原文证据 --trace 输出里出现 [relay-skip] ⛔ 新通道起不来(wss://relay-direct.alotbuy.com/dshs-relay) 该行与 open 发起时刻的间隔 ≥ 11.7 s
E4 测量误差已剔除(D5) `grep '^ DRILL_POLL_MS' 参数表_覆盖网络_20260917.md` + drill 实跑
E5 样本量达标 S2 / S7 各 5 轮 5/5 份 trace 齐全,且每轮都记录了归零动作
E6 🔴 修后 5/5 在 deadline 内侧 S7 kill → switch ≤ 30000 ms 5/5;白等段 ≤ 1 s
E7 不误伤慢候选(护栏) 单测(S5) "慢候选仍成功" + "死候选提前失败" 两条同时绿
E8 三处装配点逐处验收(D7) grep -n "waitUpOn" -A 12 src/web/server.ts src/worker/relay-tunnel.ts src/net/relay/main.ts 三处的早退语义一致;报告逐处给出证据
E9 不退化 --scene all + npm test(基线 155)+ overlay-probe + 门户 200 + 双实例面 ∈ PROBE_CODE_SET 与 §2-P8/P9 对照逐项一致;幕 1/2/3/4 结果不变红
E10 deadline 未被放宽(D6) grep -n 'RELAY_FAILOVER_DEADLINE_MS' src/net/relay/switcher.ts 参数表_覆盖网络_20260917.md 仍是 30000;⛔ 参数表 / drop-in / 演练脚本里零处把它改成别的值
E11 上单遗留不恶化 §2.1(ss -lntp | wc -l)|OBS-09/11|nft 72 与 §2-P7 逐项一致;若变红 ⇒ 停下报告(非本单范围)
E12 无越界 git status --short + grep -rc "COOLDOWN_MS=0|COOLDOWN_MS= 0"(drill / 参数表 / drop-in) 只含 §3.1 列的文件;⛔ 无 commit / push;🔴 COOLDOWN_MS=0 在回滚 / 演练路径里零命中
E13 收口四件套 锁 --release-exec 已释放;下一棒 automation 已登记并已用陈述句告知;入口 §2 已推进;工作区日志已写 ✅

§7 回滚

三层,均秒级:

  1. 配置层(不改代码即可回到现状) —— 本单新增的键全部有安全默认值:
    • RELAY_GRACEFUL_BURST_MS 删掉 ⇒ 回落到代码默认 15_000(逐字回到现状)。
    • DRILL_POLL_MS 改回 2000 ⇒ 只影响演练读数,不影响产品。
    • ⇒ 改完 daemon-reload → systemctl restart dshs(47)/restart dshs-worker(106,如涉及)。
    • 🔴 ⛔ 禁用项:不许用 RELAY_FAILOVER_COOLDOWN_MS=0 当"回滚/对照"开关(上单 §8.8-4:归零 ⇒ 候选链自锁,实测 121–123 s 无切换)。真要缩短冷却只走 DRILL_COOLDOWN_MS。
  2. 代码层:用出单后立即做的 .bak-seq9-<ts> 备份还原被改文件(⛔ git diff 对 src/net/relay/** 无效 —— 它 untracked)→ npm run build → scp lib/ 到 47 /opt/dshs/lib/ + /opt/dsh-relay/lib/;106 /opt/dshs-cluster/lib/ + /opt/dsh-relay/lib/ → restart dshs + restart dshs-worker。
    • 落点自证:改的是 server.ts 的 waitUpOn 与 client.ts 的 burst 键 ⇒ 建议用一个稳定的可 grep 标记(例如早退分支里的专属日志片段)在远端 lib/ 里 grep -c 自证,三处 lib 全部。
  3. 服务层:S2/S7 采样若停了 relay ⇒ systemctl start dshs-relay(两台)。两台 relay 的 drop-in(capacity.conf)本单不动。

⚠️ 回滚后必须复验:overlay-probe(按 §2.1 口径判:OBS-09 / OBS-11 属环境态)|ss -lntp | wc -l = 77(无活跃实例档,与 §2.1 同值)|双实例面 ∈ PROBE_CODE_SET|门户 200|--scene 1 仍能在 deadline 内侧切换。


§8 回报格式(执行棒按此格式收口;沿用序⑤–⑧ 单的分节)

✅ 执行棒回报(覆盖网络线 · 序⑨ 执行棒 | 2026-09-17 13:41–14:3x)

一句话结论:§2-P10 的四段分解复现成功(D1 解锁)⇒ 「死候选白等」12.03 s → 0.09 s(省掉 12 s)⇒ 真机样本 5/5 从「全部超 deadline」变成「全部在 deadline 内侧」(30.2–34.1 s → 19.8–23.9 s)。 ⛔ 未动:RELAY_FAILOVER_DEADLINE_MS(30000) / HB_SEC(15) / burst 语义 / 服务端零改动 / 未 commit·未 push。 🔴 三条硬门:D1 ✅(分解先复现,再改值 —— 且本单一个生产阈值都没改)|R7 ✅(范围外只报告:§8.8)|⛔ COOLDOWN_MS=0 ✅(本单新增行零命中,见 E12)。

8.1 S0 快照(P1–P10)

# 命令(原文摘要) 记录 判定
P1 handoff-guard.sh --claim-exec "覆盖网络线-序9执行棒" ✓ 已持全局执行锁 ✅
P2 state.py 锁空闲→抢到;HEAD 640813e;入口 = 接续入口_覆盖网络线_20260916.md(§2 指向本单);工作区 43 处改动 ✅
P3 sed '/^## §10 指纹/,$d' 参数表_覆盖网络_20260917.md | md5sum e6b669c257d8e8964273b3b400238351 ✅ 逐字一致
P4 sed '/^## §8 回报格式/,$d' 交接单_检测时延与deadline_20260917.md | md5sum d903b4eeabf25ef381379cdbaac77e8a ✅ 逐字一致 ⇒ 单未被改动(收口后仍为此值,见 8.9)
P5 上两单前缀 序⑧ aa3a6ec0d66dd81f465cea2a3a08ad27|序⑦ 419abf308c00b7668e8898aaa91ba9e8 ✅ 两值逐字一致
P6 grep -n gracefulBurstMs|halfOpenMs client.ts;grep -n "const waitUpOn" -A8 server.ts;switcher.ts 锚点 client.ts:137/146/567/631;server.ts:499-506(只轮询 up、不看终态);switcher.ts:244/300;waitUpOn 三处 = server.ts:499+524|relay-tunnel.ts:104+189|main.ts:155+315(⚠️ 单 §2-P6 记的 main.ts 函数在 155、调用在 315,单内写 315 指调用点 ⇒ 一致) ✅ 锚点全部存在
P7 node scripts/overlay-probe.cjs(cwd = 工作区根) 10/12(OBS-09 实例面 000|OBS-11 监听口 77 vs 阈值 79),exit=1 ✅ 与 §2.1 / §9.1 逐项同值(环境态:47 无活跃实例)
P8 两台 systemctl is-active + 47 /status 47 dshs=active / dshs-relay=active;capacity.used=**2**、online[] = manager(session=6959e3e1fb5a2956) + w-106(session=416e7764a86752db ports=19000);106 dshs-worker=active / dshs-relay=active ✅
P9 npm.cmd test(Node 22) 155 tests / 154 pass / 0 fail / 1 skipped ✅ 与序⑧ 收口基线逐字一致
P10 journalctl -u dshs --since -6h -o short-unix | grep -E 'relay-switch|relay-skip|relay-client. down' 抽两样本逐行对齐 见 §8.2 —— 逐项复现(误差 ≤ 0.03 s) ✅ D1 解锁

8.2 时延分解表(本单的核心产出)

口径:四段全部由 journal 现有行的时间戳算出(⛔ 无新埋点,D2); --trace 的白等锚点 = 「新候选客户端首败」→「放弃」(⑨ 修前修后同一口径,见 8.2.3)。

8.2.1 单内 §1.2 的两个历史样本 —— 逐项复现(§6-E1 / D1 的解锁证据)

样本(epoch 原文) 检测 首试延迟 白等 建连 总 unhealthyForMs 与 §1.2 比对
1789616881.632718 15.119 1.416 12.029 2.724 31.287 30563 单内记 15.00 / ~1.5 / 12.00 / ~2.1 ⇒ ✅ 逐项一致
1789617465.999982 15.085 0.258 12.033 2.821 30.196 29376 单内记 15.085 / 0.29 / 12.00 / 2.82 ⇒ ✅ 逐字一致

所依据的行原文(样本② 1789616881,节选三行):

1789616881.632718 … [relay-client] down (peer bye: server restarting (was up)); attempt #0 [graceful, burst window 15000ms], retry in 300ms
1789616896.751232 … [relay-client] down (transport error); attempt #1, retry in 1054ms      ← 检测段终点(burst 耗尽)
1789616898.166825 … [relay-client] down (transport error); attempt #1, retry in 799ms       ← **新候选客户端首败**(白等起点)
1789616910.195416 … [relay-skip] ⛔ 新通道起不来(wss://relay-direct.alotbuy.com/dshs-relay)… 进冷却 300000ms  ← 放弃点
1789616912.919233 … [relay-switch] #1 … -> wss://106.54.21.172/…(unhealthyForMs=30563)      ← 样本终点

🔴 --trace 对"白等起点"的识别依据(可被第三方复核):open() 里的候选客户端是全新实例 (attempts=0、无 burst 窗口)⇒ 它第一次拨号失败就打不带方括号标签的 attempt #1; 而老通道客户端在 burst 耗尽时也打一条 attempt #1,那条正好等于 burst 耗尽行本身(被排除) ⇒ 窗口内"最后一条不带标签的 attempt #1"唯一指向新候选客户端。

8.2.2 全量样本(--trace --since -6h,19 个样本中 15 个"单跳")

# 样本 epoch 检测 首试延迟 白等 建连 总 签名
1 1789616881.632718 15.119 1.416 12.029 2.724 31.287 修前
2 1789617465.999982 15.085 0.258 12.033 2.821 30.196 修前
3 1789620716.466165 15.209 1.269 12.035 3.718 32.232 修前
4 1789621434.115527 15.490 1.830 12.034 3.768 33.122 修前
5 1789623996.90519 15.291 0.985 12.041 2.716 31.034 修前(S2-1)
6 1789624064.033677 15.136 0.808 12.026 2.721 30.692 修前(S2-2)
7 1789624132.71806 15.298 1.660 12.044 4.221 33.223 修前(S2-3)
8 1789624194.346892 15.738 0.468 12.021 3.717 31.943 修前(S2-4)
9 1789624258.224431 15.671 2.818 12.033 3.621 34.143 修前(S2-5)
10 1789624473.578564 16.645 3.031 0.092 2.717 22.485 修后(S7-1)
11 1789624534.000883 15.229 0.332 0.090 5.223 20.873 修后(S7-2)
12 1789624587.689803 15.017 1.480 0.092 4.719 21.307 修后(S7-3)
13 1789624640.289734 15.096 5.016 0.092 3.730 23.933 修后(S7-4)
14 1789624693.055419 15.262 1.585 0.090 4.719 21.656 修后(S7-5)
15 1789625104.721054 15.386 1.610 0.093 2.721 19.810 修后(幕 4-A)
  • 修前(n=9):检测 15.085–15.738(中位 15.29)|白等 12.021–12.044(中位 12.033)|建连 2.716–4.221|总 30.196–34.143 ⇒ 9/9 全部 > deadline 30000
  • 修后(n=6):检测 15.017–16.645(中位 15.26)|白等 0.090–0.093(中位 0.092)|建连 2.717–5.223|总 19.810–23.933 ⇒ 6/6 全部 ≤ deadline
  • 自洽:15/15 样本 检测 + 首试延迟 + 白等 + 建连 ≡ 总(误差 ≤ 0.05 s)✅

8.2.3 白等段的口径说明(⛔ 防误读)

  • 单内 §1.2 把白等写成 ≡ upTimeoutMs(12.000 s)—— 那是修前的定义式。本棒用可直接测的锚点替代它: 白等 = t(放弃点) − t(新候选客户端首败) ⇒ 修前实测 12.021–12.044(与定义式 12.000 相差 21–44 ms = 拨号耗时), 修后实测 0.090–0.093 s。⇒ 同一个度量在修前修后都成立,修前的 12 s 是"真等待",修后只剩 0.09 s。
  • ⚠️ 单内 §1.2 的"tick 相位"在本口径下改称首试延迟(= RELAY_FAILOVER_CHECK_MS 相位 + 拨号耗时), ⛔ 不是"测量被篡改":四段划分与总长自洽校验逐样本通过(见 8.2.2)。

8.3 代码改动清单

# 文件 改动点 wc -l 前后 先红后绿
1 src/net/relay/client.ts ① 接口+status() 新增只读投影 inGracefulBurstWindow;② 新增 gracefulBurstMsDefault()(env 参数表化,默认 15_000 逐字不变);③ 新增 openedChannelFailedTerminally()(RC-1 判据);④ 新增 waitUpOnStatus()(三处装配点收口成一份,D7) 1306 → 1403 ✅
2 src/net/relay/index.ts 导出上述 3 个符号 2 行改 —
3 src/web/server.ts(C1) waitUpOn 改为委托 waitUpOnStatus + 判死日志([relay-failover] ⛔ 新通道终态失败…) 908 → 917 ✅
4 src/worker/relay-tunnel.ts(C2) 同上 220 → 224 ✅
5 src/net/relay/main.ts(C3) 同上(+ log 透传) 359 → 363 ✅
6 scripts/overlay-failover-drill.cjs 新增 --trace(四段分解·只读)+ --sample N(N 轮采样,含完整归零序列);⛔ 原有 --scene 1/2/3/4/4b/4c/ctrl 语义一字未改(仅在 --help 追加 2 行) 720 → 1032 —
7 test/relay-failover.test.mjs 新增 F18(护栏·慢候选)+ F19(死候选早退)+ F20(burst 窗口不许判死)+ F21(判据四反例)+ F22(env 参数表化) 731 → 844 ✅

E8 · 三处装配点逐处验收(D7):grep -n waitUpOnStatus ⇒ server.ts(C1)/relay-tunnel.ts(C2)/main.ts(C3)三处全部委托同一实现; 落点自证(远端 lib 四份):grep -c "openedChannelFailedTerminally" ⇒ 47 /opt/dshs/lib/net/relay/client.js = 3、47 /opt/dsh-relay/lib/… = 3、106 /opt/dshs-cluster/lib/… = 3、106 /opt/dsh-relay/lib/… = 3; grep -c "新通道终态失败" ⇒ /opt/dshs/lib/web/server.js = 1、/opt/dshs-cluster/lib/worker/relay-tunnel.js = 1。⚠️ 属主按原值 197108:197121 复原(⛔ 不引入 R10 的 root 属主问题)。

8.4 单测与本地验证

  • npm.cmd test(Node 22)= 160 tests / 159 pass / 0 fail / 1 skipped(基线 155 + 新增 5)。
  • 先红后绿(实证):改完用例先对旧 lib/ 产物跑 ⇒ SyntaxError: The requested module '../lib/net/relay/index.js' does not provide an export named 'openedChannelFailedTerminally'(红); npm run build 后 node --test test/relay-failover.test.mjs ⇒ 22/22 全绿;全量 npm test ⇒ 159/0。
  • 护栏用例(E7 的断言切面):
    • F19(红→绿的分水岭):假客户端在 100 ms 处进 backoff+attempts=1+非 burst ⇒ 断言 ok===false 且 ms < 2000(旧实现必然 12 000 ms)。
    • F18(护栏):慢候选 connecting → up@800ms ⇒ 断言 ok===true 且等到 ~800 ms ⇒ ⛔ 早退不许误杀慢候选(R11 不变量)。
    • F20(D3 保护):backoff attempts=1 但 inGracefulBurstWindow=true ⇒ 必须继续等,窗口过后 up ⇒ ok===true(⛔ 不许在窗口内判死)。
    • F21:判据四反例(connecting / handshaking / queued(attempts=0) / burst 窗口内)一律 false。
    • F22:RELAY_GRACEFUL_BURST_MS 默认 15 000 逐字不变;空串/非法/负数 ⇒ 回落默认;显式覆写才生效。

8.5 真机复测(修复前 N=5 / 修复后 N=5)

读数口径:切换耗时 = --sample 的墙钟(含 DRILL_POLL_MS 的量化误差);四段另由 --trace 从 journal 时间戳复算(与轮询口径无关)。

轮 修复前 DRILL_POLL_MS=2000 ≤30000? 修复后 DRILL_POLL_MS=500 ≤30000?
1 33914 ms ❌ 25465 ms ✅
2 31646 ms ❌ 25342 ms ✅
3 34078 ms ❌ 21985 ms ✅
4 35321 ms ❌ 27389 ms ✅
5 41252 ms ❌ 23318 ms ✅
合计 5/5 超界(中位 34078) ❌ 5/5 在内侧(中位 25342) ✅

每轮的归零动作(E5):① 两台 relay systemctl start → ② systemctl restart dshs(通道回到目录首位 = 47)→ ③ 等 47 relay 日志出现新的 AUTH OK host=ops/manager(修复前 AUTH 就绪 2359–4112 ms/修复后 2834–4761 ms)→ ④ 记 t0 → systemctl stop dshs-relay(47) → 等新 [relay-switch] → ⑤ 复原 relay。10/10 轮全部执行归零;⛔ 全程未施加任何演练 env 覆盖(尤其未出现冷却归零)。 同轮的四段读数(journal 口径):修复前白等 12.021–12.044/修复后 0.090–0.093(见 8.2.2 第 5–14 行)。

8.6 参数表改动

键 旧 → 新 值格自证(纯数字) 说明
DRILL_POLL_MS 2000 → 500 500 测量修正(剔掉 ≤2 s 量化误差 ⇒ 超/不超 deadline 两端都可能误判,RC-4)。⚠️ 改前/改后读数不可混比(8.5 已分档)
DRILL_SAMPLE_N 🆕 5 5 --sample 的默认轮数(= S2/S7 的 N)
RELAY_GRACEFUL_BURST_MS 🆕 15000 15000 把"全仓唯一一个不可配的时延常量"参数表化(gracefulBurstMs);⛔ 默认值语义逐字不变、⛔ 本单未改其值
§9 表格 🆕 第 9 行「换址墙钟的四段分解」 — 四段数值 + 修前修后两档总长
§7 计数 回填一行 — 待测项 仍为 0(新增两个键都是实测/推导值)

值格纯数字自证:grep -nE '^\| (DRILL_POLL_MS|DRILL_SAMPLE_N|RELAY_GRACEFUL_BURST_MS)' ⇒ 值格依次为 500 / 5 / 15000(无夹注 ⇒ ⛔ 不会引发 OBS-02 假红)。

8.7 不退化(S0 对照)

项 S0(§8.1) 收口复取 判定
npm test 155/154/0/1 160/159/0/1(+5 新用例) ✅ 无红
--scene all(幕 1/2/3/4) 序⑧ 全绿 11 PASS / 1 FAIL(幕2-B,见 8.8-1);幕 4-A/B/C 全绿(豁免切回 47 22213 ms ≤ 30000) ⚠️ 见 8.8-1(归因 = drill 状态依赖,正确前置下 3/3 PASS)
overlay-probe 10/12(OBS-09/OBS-11 红) 10/12,同样只有 OBS-09/OBS-11 红 ✅ 逐项一致
ss -lntp | wc -l(47) 77 77 ✅
nft 规则行数 72 72 ✅
门户 http_code 200 200 ✅
双实例面 000/000(无活跃实例) 000/000 ✅ 同值(OBS-09 环境态)
两台 relay 单元 active/active active/active ✅
演练 env 残留(RELAY_FAILOVER_* 计数) — 0 ✅ 生产值未被演练污染
git status --short 计数 43 43 ✅ 无越界(新增的 4 个文件均在本单 §3.1 范围内,且 src/net/relay/**·relay-tunnel.ts·drill·test 本就是 untracked)

8.8 未过项 / 遗留

1. ✅ 序⑩ 已修(2026-09-17 14:4x)· 原报:🔴 --scene all 的幕 2-B 判 FAIL(归因:drill 自身的状态依赖,⛔ 非本单改动引入)

  • ① 现象:幕2-B 留下「无候选 ⇒ 原地退避」的判别器证据(D6) ⇒ 窗口内 0 行 [relay-skip];同窗口内 [relay-client] down / [relay-switch] 也全为 0 行(journal 取证:1789624835± 的 120 s 窗口里 Manager 一行 relay 日志都没有 ⇒ 它的通道根本没被打断)。
  • ② 已做到哪一步:定位到 drill 代码本身 —— 幕 2 的注释写"两台全杀",但实现只停 106(await stopRelay(h106),overlay-failover-drill.cjs 幕 2 块首行),而幕 1 已把 Manager 的通道切到了 47 ⇒ 幕 2 停的是一台 Manager 已不在用的 relay ⇒ 无失效、无判别器。在正确前置下重跑 ⇒ 3 PASS / 0 FAIL(幕2 前置:47 relay=inactive / 106 relay=inactive,判别器原文:[relay-skip] ⚠ 豁免尝试也起不来(wss://alotbuy.com/dshs-relay)⇒ **保持原通道**;重置该候选冷却…)。⇒ 与本单改动无关(本单只改"新通道等待",不触碰"无候选"分支;且该窗口内 Manager 完全无事件)。
  • ③ 回头条件:只要有人需要 --scene all 一键全绿(如 CI 化 / 交给第三方复跑)⇒ 必须先把幕 2 改成"停掉当前通道所在的那台 relay"(与幕 1 同一套 killTarget 判定),否则它永远依赖上一轮留下的通道归属。⛔ 本单不动(§3.1 明令"原有 --scene 语义一字不改"+R7 范围外先报告)。

2. 🔴 在册未办(本单只报告、不动手**)**

  • ✅ 序⑩ 已清(现 grep -rho 'COOLDOWN_MS=0' scripts/ | wc -l = 0)· 原报:COOLDOWN_MS=0 在演练脚本里仍有 5 处命中(overlay-failover-drill.cjs:--scene 4c / --scene ctrl 的文案与实现)—— 全部是序⑧ 遗留,本单新增行零命中(git diff -U0 | grep -c '^+.*COOLDOWN_MS=0' = 0)。按上单 §8.8-4(归零 ⇒ 候选链自锁、实测 121–123 s 无切换)与 §3.2 禁令 ⇒ 建议下一棒删掉 --scene ctrl 及其 4 处文案(4c 可保留:它走 DRILL_COOLDOWN_MS 覆盖,不碰生产键)。回头条件:任何一次把 --scene ctrl 当"回滚/对照"用之前必须删掉。
  • ✅ 序⑩ 已定位(见下方 §8.8-4)· 原报:OBS-11 的 78 → 77 那 1 个口:本棒复取仍 = 77(与 S0/序⑧ 同值),未定位(R7 只报告)。
  • 承上单:OBS-09(47 无活跃实例 ⇒ 实例面 000)|guest(w-106) 实例页 502|106 agent 面不吃引导链(已判不执行)|mksess*.cjs 失效|src/net/relay/** untracked 留档缺口|client.ts.bak-seq7-* 残留。
  • ⚠️ 本棒对"当前通道归属"有副作用(幕 4 / S2/S7 采样会把 Manager 留在 106):收口时已 systemctl restart dshs 归零回 47 并复验(used=2、online[] 含 manager + w-106、probe 回到 10/12)。

3. ⛔ 明确未做(保持单内 §3.2):未动 deadline/HB_SEC/burst 语义/MIN_ATTEMPTS/GRACE_MS/CHECK_MS/生产 COOLDOWN_MS;未做"同机候选去重 / 并发竞速 open";未新增监听口·凭据·端点;未改服务端;未 commit / 未 push。

4. ✅ 序⑩ 收口回填(2026-09-17 14:23–14:5x · 执行棒 · 三件技术债全清)

# 遗留 结果 证据(现取)
① 幕 2 状态依赖(幕 2 只停 106 ⇒ 停的是不用的那台) ✅ 已修 修法 = 幕 2 起手现场重读权威通道归属(lastManagerAuthOn 比对)后两台都停(幂等);--scene all = 12 PASS / 0 SKIP / 0 FAIL(原 11/1);--scene 2 单独复跑 = 3 PASS / 0 FAIL(前置行原文:本幕开始时活跃通道 = bt-server(epoch 47=…/106=…))
② COOLDOWN_MS=0 × 5 处(--scene 4c / --scene ctrl) ✅ 已清,0 命中 grep -rho 'COOLDOWN_MS=0' scripts/ | wc -l = 0;两个场景名整体移除并显式拒绝(--scene 4c / ctrl ⇒ 退出码 2 + 提示走 DRILL_COOLDOWN_MS),⛔ 不静默空跑;node --check 通过
③ OBS-11 的 78 → 77 那 1 口 ✅ 已定位(有名字) 逐口对账见下 ⇒ 差额 = 20000(47 实例档,当前无活跃实例)

③ 逐口对账(应然 78 行 vs 实际 77 行 · ss -lntp,含表头)

组 应然条数 实际 说明
九固定口 22/80/443/888/3080/8765/15432/19100/20080 9 ✅ 全在 —
[::]:22(sshd 的 IPv6 第二条绑定行) 1 ✅ 在 计数是"行"不是"端口",故 22 占 2 行
拨号池 25000–25063 64 ✅ 64 口全在 脚本逐口点名验证
w-106 落点(relay 动态分配) 1 ✅ 在(值 = 46147) /status.endpoints[0].localPort=46147,与 relay 20080 同 PID 779508
BT-Panel 58888 1 ✅ 在 —
47 实例档 20000 1 ❌ 缺 就是这 1 口 —— 47 当前无活跃实例(ss 无 127.0.0.1:20000)
表头 1 ✅ 在 —

⇒ 76 条 socket 逐条点名、零无名;78 − 77 = 1 的名字 = 20000(实例档随实例上线/下线),不是暴露面消失。 ⇒ 🔴 附带订正(口径书写错误):原清单把 39463 写成固定项 —— 它其实是 relay 为 w-106 端点动态分配的落点(现值 46147,同 PID)。⛔ 口径里不能写固定值,否则每次重启都会误报"口变了"。 ⇒ ⚠️ 仍无名的 1 条(在册未办):参数表 LISTEN_COUNT = 79(S0 基线,78 socket)比"应然·无实例态 77"高 2、比"应然·有实例态 78 行"高 1。这 1~2 条无法定名,卡点 = S0 原始 ss 清单未留档(已查工作区与 04-调整方案/,均无)。回头条件:① 47 恢复活跃实例后复取(应回到 78 行)② 届时若仍 77 ⇒ 另有 socket 确实消失 ⇒ 按"零新增暴露面"重定基线,⛔ 不许把差值当噪声放过。

🔴 ③ 的副产品(⛔ 本棒不动手 · 属方案改动 ⇒ 转规划棒):OBS-11 用「计数相等」当暴露面判据有两个结构性弱点 —— ⓐ 对状态敏感(实例档/端点落点在线态一变就红 ⇒ 假红);ⓑ 对替换式变化不敏感(一进一出计数不变 ⇒ 假绿,正是本项目最忌的静默失效)。⇒ 建议下一棒规划 白名单集合判据(实际监听行 ⊆ 允许集,并打印差集)替代计数相等;连带处理 LISTEN_COUNT 的"有/无实例态"两值。⛔ 本棒只报告(§3.2 范围 + D1 纪律)。

8.9 指纹(收口后的可复现核对口径)

  • 参数表:cd "E:/ProgramData/AI技能/aliyun-dsh-server" && sed '/^## §10 指纹/,$d' 参数表_覆盖网络_20260917.md | md5sum ⇒ 现取 = 99e9e17b0c1ce0550e4bc7626a5a0494(本单出单值 = e6b669c257d8e8964273b3b400238351 ⇒ 已随 §3.1-5 的四处改动更新)
  • 本交接单前缀(§8 及其后不计入):cd "E:/ProgramData/AI技能/aliyun-dsh-server" && sed '/^## §8 回报格式/,$d' 交接单_检测时延与deadline_20260917.md | md5sum ⇒ 现取 = d903b4eeabf25ef381379cdbaac77e8a = 出单值 ✅ 回填 §8 后未变(底稿零改动)
  • 交叉证据(上两单,收口复取):切流冷却语义_20260917.md = aa3a6ec0d66dd81f465cea2a3a08ad27|中继失败切流_20260917.md = 419abf308c00b7668e8898aaa91ba9e8(两者均未变)
  • 本单全文件 md5 请现取(⛔ 不内嵌,自指)
  • 序⑩ 原始取证落盘:_中间产物_待清理/seq10/(ss47-actual.txt = 47 监听口全量 77 行;drill-all-after.txt = --scene all 12 PASS 原文;drill-scene2-after.txt = 幕 2 单跑 3 PASS 原文)
  • 原始取证落盘(供第三方复核):_中间产物_待清理/seq9-trace/(s2-before.json 修前 5 轮、s7-after.json 修后 5 轮、full-6h.json 全窗口 19 样本、seq9-sample-*.json 采样明细)

§9 附录 · 本单的取证基线(供执行棒复核,⛔ 不必重跑)

事实 出处
relayFailoverThresholds(env = process.env) ⇒ env 覆盖生效;六键默认值 300000 / 30000 / 15000 / 3 / 2000 / 12000 + exempt switcher.ts:55-95
唯一换址入口 replace(targetUrl, reason, origin);open() 在 :300 被 await switcher.ts:204-310
unhealthy() = state==='backoff' && (attempts ≥ minAttempts ∨ unhealthyForMs ≥ graceMs) switcher.ts:244
tick() 周期 = checkMs(2000);D6 现场 ⇒ noCandidateChecks + [relay-skip](节流) switcher.ts(tick 内)
waitUpOn 只轮询 state === 'up',直到 deadline 才 false(RC-1 真身) web/server.ts:499-506
open 的三处消费点 web/server.ts:524(C1)/worker/relay-tunnel.ts:104、:158(C2)/net/relay/main.ts:315(C3)
生产目录 3 条候选、前 2 条同机 47 —— 注释原文已承认 web/server.ts:517-519
gracefulBurstMs 默认 15_000、不可配(全仓只有 ?? 15_000 一处) client.ts:137/:567
burst 三个分支各自 attempts = 0(RC-2) client.ts:559-580
gracefulRetryMs 默认 300;reconnectMinMs 1000/reconnectMaxMs 30000;退避含 ±25% 抖动 client.ts:554-588
半开检测 halfOpen = max(3000, hbSec×1000×2.5) = 37.5 s;仅在 state==='up' 计时 client.ts:631-638
DEFAULT_HB_SEC = 15(运行真值;HB_SEC_DOC=20 是旧隧道时代文档口径,⚠️ 待更正) server.ts:60/参数表 §4
演练读数偏差:waitSwitch 每 DRILL_POLL_MS 轮询;t0 在 stopRelay 返回之后(比 BYE 晚 0.6–0.73 s) overlay-failover-drill.cjs(waitSwitch / runScene1)
本单两个逐行复核样本的 epoch ① 1789617466 / …7493.375 / …7496.196(unhealthyForMs=29376)② 1789616882 / …6910.195 / …6912.919(unhealthyForMs=30563)

9.1 本棒(规划棒)已做的只读取证(⛔ 执行棒不必重做)

项 读数 出处
工作区 HEAD 640813e;git status --short = 43 state.py / git status
参数表指纹 e6b669c257d8e8964273b3b400238351 现取
上两单前缀指纹 aa3a6ec0d66dd81f465cea2a3a08ad27 / 419abf308c00b7668e8898aaa91ba9e8 现取
overlay-probe 10/12(OBS-09 / OBS-11 红);exit=1;原始输出落 /tmp/seq9-probe-s0.txt 现取
ss -lntp | wc -l(47) 77(76 socket + 表头);连采两次同值;nft=72 现取
47 端口清单 9 固定口(22/80/443/888/3080/8765/15432/19100/20080)+ 拨号池 25000–25063(64) + 39463(w-106 落点)+ 58888(BT-Panel) 现取
47 活跃实例 无(20000 未监听;ps 只有控制面 / w-47 / relay) ⇒ OBS-09 红属环境态 现取
47 relay /status capacity.used=2;online[] = manager + w-106;dialers=["manager"] 现取
时延分解 检测 15.08 s / 白等 12.00 s / 建连 2.82 s(+ 相位 0.29 s) §1.2 样本①

§10 指纹

  • 本节口径(推荐核对用,可复现):整个 §10 不计入 ⇒ cd "E:/ProgramData/AI技能/aliyun-dsh-server" && sed '/^## §10 指纹/,$d' 交接单_检测时延与deadline_20260917.md | md5sum
  • ⚠️ 本单另有 §8 前缀口径(§8 及其后不计入)⇒ 见 §8 回报格式 内 §8.9 的取值处(收口时回填)。
  • 全文件 md5:请现取(⛔ 本行故意不内嵌数值 —— 包含本节自身,写进去即刻失效)。