交接单按归属约定(正文落文档库、工作区只放指针)归档至 归档/交接单-20260924-归档/,含逐件判定 README: - 16 件已被文档库正式版取代(T09–T21 + 覆盖网络-24/25/26) - 4 件主题已被覆盖网络线入口汇总 - 7 件历史接续包/规划件 CODEBUDDY §9:收口清本棒 tmp、tmp 保留期 7 天、禁「待清理」中间态、 工作区入库只放文档与文件、不保留脚本副本、>60 KB 单文件须逐个判。
427 lines
46 KiB
Markdown
427 lines
46 KiB
Markdown
# 交接单 · 中继失败切流(覆盖网络线 · 序 ⑦)
|
||
|
||
> **生成**:2026-09-17(规划棒 · 只出单,⛔ 不改服务器、不改代码)
|
||
> **上游依据**:`交接单_最小形态真机批次_20260917.md` **§8.8-1**(本单唯一未过项 E9 后半)+ **§8.6**(第二中继 106 已可用)+ 参数表 `参数表_覆盖网络_20260917.md` **§9 第 5 行**
|
||
> **一句话**:让「**杀掉任一台中继 ⇒ 客户端自动切到另一台**」成立。
|
||
> ⛔ 本单**不重做**序 ②–⑥ 的任何一步;⛔ 不做 presence / 房间层 / 内容分发;⛔ 不 commit / 不 push。
|
||
|
||
---
|
||
|
||
## §1 目标
|
||
|
||
**总目标(一句话判据)**:**杀掉任一台中继 ⇒ 正在使用它的客户端在 ≤ `RELAY_FAILOVER_DEADLINE_MS`(建议 30 s)内切到另一台,业务面无人工干预恢复;两台同时挂时不比现状更差。**
|
||
|
||
拆成三条互相独立、可分别判定的子目标(⛔ 只做前两条不算完成):
|
||
|
||
| # | 子目标 | 反例(现状为什么不过) |
|
||
|---|---|---|
|
||
| **G1** | 客户端在"当前中继不健康"时,**重新解析候选地址**(而不是继续重试同一个地址) | 现状:`RelayClient` 退避**永不放弃**地重试**同一 url**(`src/net/relay/client.ts:32` 明确写了"永不放弃") |
|
||
| **G2** | 重解析时**排除刚失败的那一台**;切过去后失败台进入**冷却**,冷却期满自动回归候选表 | 现状:解析层**永远只返回第一个候选**(`directory.ts:348` `pickFromDoc` `return url`,取到即返回)⇒ 即使重新解析也还是同一台 |
|
||
| **G3** | 切换动作在日志与可读计数上**可 grep 断言**(判别器纪律) | 承接 §8.8-2 教训:静默失效没有判别器 = 查不出来 |
|
||
|
||
**覆盖对象 = 三处 relay 客户端**(本单**三处都要覆盖**,因为它们共用同一份「取址 + 连接」代码但装配点不同):
|
||
|
||
| # | 客户端 | 代码落点 | 现状 |
|
||
|---|---|---|---|
|
||
| **C1** | Manager 拨号通道(47) | `src/web/server.ts:471` `refreshOverlay` | 有周期重解析,但换址条件是"**目录里的地址变了**",与"当前 relay 挂了"**无关** |
|
||
| **C2** | worker 实例面 relay 客户端 | `src/worker/tunnel.ts` 所用的 `RelayClient` 装配点 | **无重解析** —— 启动解析一次后钉死 |
|
||
| **C3** | 独立 `relay --client` | `src/net/relay/main.ts:220` | **无重解析** —— 解析一次写进局部变量,此后只用它(`main.ts:220-234`) |
|
||
|
||
> ⚠️ **C1 的"地址没变"其实是假象**:`refreshOverlay` 确实周期调用 `resolveOverlayRelay`,但该函数经由 `pickFromDoc` **恒返回 `[...relays, ...bootstrap]` 里第一个可用项**(`directory.ts:348-355`)⇒ 只要目录内容不变,重解析结果与当前 url **逐字相同** ⇒ 被判为"无变化"直接 `return`。**所以根因是"候选集退化成单点",不是"没写重解析"** —— 这一点是本单对 §8.8-1 的**证据级细化**(§8.8-1 只写到"换址条件是目录地址变了")。
|
||
|
||
---
|
||
|
||
## §2 只读前置(⛔ 只读,不改;P1–P8 逐条核实后才允许进 S 段)
|
||
|
||
| # | 命令(原文照抄执行) | 期望 / 判据 |
|
||
|---|---|---|
|
||
| **P1** | `bash "D:/github/dsh_shenxian/dsh-server-docs/scripts/handoff-guard.sh" --claim-exec "覆盖网络线-序7执行棒"` | `✓ 已持全局执行锁`;收口前复核 OWNER = 本会话名。**抢不到 = 有会话在跑 ⇒ 只报告并停** |
|
||
| **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` | **`db1317c2f7aaef7b47785c1f4fc9de03`**(= 序⑥ 收口值)。**不一致 ⇒ 参数表被人动过 ⇒ 停下报告,⛔ 不进 S 段** |
|
||
| **P4** | `cd "D:/github/dsh_shenxian" && grep -n "export async function resolveOverlayRelay\|function pickFromDoc\|const refreshOverlay\|relayUrl = resolved.url" src/net/relay/directory.ts src/web/server.ts src/net/relay/main.ts` | 行号与附 A 一致(允许平移,**但函数必须仍存在**)⇒ 单内锚点有效 |
|
||
| **P5** | `ssh bt-server 'systemctl is-active dshs-relay dshs; curl -s http://127.0.0.1:20080/status \| head -c 400'` + `ssh test106 '…同款…'` | 两台 relay 均 `active`;`/status` 可见在册会话;`capacity.max=7515`。⚠️ **106 的 `systemctl is-active nginx` = `inactive` 是正常态**(宝塔 `bt.service` 托管),⛔ 不许据此判故障 |
|
||
| **P6** | `cd "D:/github/dsh_shenxian" && npm.cmd test`(**必须 Node 22**) | 基线 = **138 tests / 137 pass / 0 fail / 1 skipped**。⛔ 拿不到这个数就不许改代码 |
|
||
| **P7** | `node scripts/overlay-probe.cjs > /tmp/seq7-probe-s0.txt 2>&1; echo $?; cat /tmp/seq7-probe-s0.txt` | 期望 **12/12 PASS**、退出码 `0`。🔴 **必须先落盘再读** —— §8.8-3 的回头条件就是"S0 原始输出未留存",⛔ 不许重犯 |
|
||
| **P8** | `cp -a "<改动文件>" "<改动文件>.bak-seq7-<YYYYMMDD-HHMMSS>"`(逐文件)+ `git status --short \| wc -l` | 备份已建;改动前工作区改动数已记录(用于 S8 对账"只多了我的文件") |
|
||
|
||
---
|
||
|
||
## §3 范围
|
||
|
||
### 3.1 要改的(且只有这些)
|
||
|
||
| # | 文件 | 改动 | 理由(为什么非改不可) |
|
||
|---|---|---|---|
|
||
| 1 | `src/net/relay/directory.ts` | ① `pickFromDoc` 从"返回第一个"改为"**返回全部候选**(`[...relays, ...bootstrap]` 顺序,去重)";② 新增 `listOverlayRelayCandidates(opts)`(**同一份引导链代码**,⛔ 不许另写一份取址);③ `resolveOverlayRelay` 入参新增 `exclude?: readonly string[]`,实现改为"候选列表 → 剔除 exclude → 取第一个";**✅ 不传 `exclude` 时行为逐字不变**(存量调用点零影响) | G1 + G2 的地基:没有候选集,任何切换都是空谈 |
|
||
| 2 | `src/net/relay/client.ts` | 新增**只读**快照字段(`unhealthySinceMs` / 已有 `attempts`、`nextRetryMs`、`reconnects`)供监管器判据;**⛔ 不改状态机语义、不改退避算法** | G1 的触发信号。**判据已经存在**(`client.ts:381-391` 的快照 + `client.ts:32` 的退避语义 + `client.ts:608` 的 half-open 检测)⇒ 本单**不新造心跳** |
|
||
| 3 | **新增** `src/net/relay/switcher.ts` | **唯一一份**切换逻辑:`onUnhealthy()` → 重解析(`exclude=[当前 url]`)→ **先建新的、成功了再关旧的** → 更新当前 url;失败台进冷却表 | D1。三处各写一份 = 三份静默失效来源(本线已有两次同类教训:`translateEndpoint` 漏赋值、`target()` 静默回退) |
|
||
| 4 | `src/web/server.ts` | `refreshOverlay`(`server.ts:471`)换址条件改为「**地址变了 ∨ 当前通道不健康**」,并接 `switcher`;沿用既有 `startDialer` 的"先建新、成功再关旧"(`server.ts:413-436`) | C1 |
|
||
| 5 | `src/worker/tunnel.ts`(或其 relay 客户端装配点) | 接入同一 `switcher` | C2 |
|
||
| 6 | `src/net/relay/main.ts` | `--client` 分支(`main.ts:220-234`)接入同一 `switcher` | C3 |
|
||
| 7 | `test/relay-failover.test.mjs`(新) | 先红后绿:两台假 relay(本机两个回环端口)+ 杀其一 ⇒ 断言切到另一台;断言"两台全挂 ⇒ 不切到空";断言"冷却期内不回跳" | 本线纪律:**先红后绿已实证两次**(`translateEndpoint`、`target()`) |
|
||
| 8 | `scripts/overlay-failover-drill.cjs`(新) | **一条命令产 PASS/FAIL** 的真机演练脚本(杀 106 / 杀 47 / 依次),**阈值全部从参数表读,脚本内零数字字面量** | 承接参数表纪律;⛔ 不把"真机取证"交给 agent 手工做 |
|
||
| 9 | `参数表_覆盖网络_20260917.md` | 新增 `RELAY_FAILOVER_*` 键(§4.1 D3/D5)+ **§9 第 5 行**从"未纳入边界"改为"**已闭环**(本单)"+ §7 计数相应回填 | 参数表 = 单一来源 |
|
||
|
||
### 3.2 ⛔ 不动什么(防顺手扩大)
|
||
|
||
- ⛔ **不动 worker 会合面**(`worker/agent.ts:156` 的 `DSHS_RENDEZVOUS_URL` / `DSHS_TUNNEL_TARGET`)。**理由(已是定论,⛔ 别再复议)**:按 S2-附字面撤掉 = `tunnel===undefined` = **生产回归**;参数表 §9 第 2 行已判"只记录、不许顺手撤"。本单只覆盖**实例面 / 拨号面 / 独立 client** 三条 relay 通路。
|
||
- ⛔ **不改 relay 服务端协议**(`DIAL`/`DIAL_ACK` 已有;本单不需要新帧)。**唯一例外**:若 S1 发现需要 relay 侧告知"本台要下线"(BYE 已有,见 `client.ts:674`)⇒ **先停下报告**,不在本单实现。
|
||
- ⛔ 不动 nft / 安全组 / nginx;⛔ **不新增任何公网监听口**;⛔ 不新增凭据。
|
||
- ⛔ 不重做序 ②–⑥;⛔ 不做 presence / 房间层 / 内容分发(清单第 7 步);⛔ 不做打洞实现。
|
||
- ⛔ 不 commit / 不 push(未获授权)。
|
||
- ⛔ 执行中发现的**其它缺陷先报告、不顺手改**(R7)—— 已知在册的有:guest(w-106) 实例页 502|`/opt/dshs/mksess*.cjs` 已失效(开临时 session 走 PG 直插)|`index.ts` 无关项。
|
||
|
||
---
|
||
|
||
## §4 决策点
|
||
|
||
### 4.1 已定项(规划棒已拍 —— 执行棒**不得自行更改**;要改必须回写 §8 并说明理由)
|
||
|
||
| # | 已定 | 理由 / 反例 |
|
||
|---|---|---|
|
||
| **D1** | **一份实现、三个装配点复用**:切换逻辑只写在 `switcher.ts`,⛔ 不许在 C1/C2/C3 各写一份 | 本线已有两次"漏赋值 / 静默回退"教训,多点实现 = 多点静默失效 |
|
||
| **D2** | 失败判据**复用已有状态机**,⛔ 不新造心跳 / 不新增探测帧 | `RelayClient` 已有 `attempts` / `nextRetryMs` / `reconnects` 快照(`client.ts:381-391`)与 half-open 检测(`client.ts:608`)⇒ 造第二个判据只会打架 |
|
||
| **D3** | 触发阈值(**已定数值**,写进参数表):**`attempts ≥ 3` ∨ `backoff` 持续 ≥ `RELAY_FAILOVER_GRACE_MS`(建议 15 000 ms)** | 二选一取"或":106 侧首连窗口可能很长(跨云 ~336 ms 心跳口径,见参数表),只看 `attempts` 会误切;只看时间会在秒级故障上反应过慢 |
|
||
| **D4** | 切换顺序 = **先建新、成功再关旧**;新通道任一步失败 ⇒ **保持原通道** | 沿用 `server.ts:413-436` `startDialer` 既有语义。⛔ 反过来做(先关后建)会在切换失败时把"本来能用"的通路打掉(**违反 R11**) |
|
||
| **D5** | **排除 + 冷却**:被切换掉的 url 进冷却表 `RELAY_FAILOVER_COOLDOWN_MS`(建议 300 000 ms,与 `DIRECTORY_REFRESH_SECONDS` 对齐);冷却期满自动回归候选表 | 不冷却 ⇒ 两台互相抢(A 挂 → 切 B → A 恢复 → 切回 A → 再挂…)= 抖动风暴 |
|
||
| **D6** | **无候选时不切换**:重解析结果只有失败那一台(或为空)⇒ **保持原地退避重试**(= 现状),⛔ 不切到空、⛔ **不静默回退默认机** | 纪律④「路由未命中 ⇒ 先补齐再失败关闭,绝不静默回退默认机」。R11:两台全挂时行为**与现状逐字一致**,不许更差 |
|
||
| **D7** | **判别器必须是断言得出来的**:每次切换输出**一行** `[relay-switch] …`,并在进程可读面暴露 `switches` 累计计数;演练脚本据此断言 | §8.8-2 的教训 —— `DIAL` 当时只有日志行 ⇒ 脚本无法断言。⛔ 本单不许再留这种缺口 |
|
||
| **D8** | **服务端零改动**(本单只做客户端;relay 侧最多只读) | 缩小失败域;且 106 的第二中继已就绪(§8.6) |
|
||
| **D9** | **兼容性**:不传 `exclude` 时 `resolveOverlayRelay` 行为**逐字不变**;新增 `listOverlayRelayCandidates` 不改存量调用点 | 存量调用点分布在 Manager / worker / `--client`,一次改语义 = 三处回归 |
|
||
|
||
### 4.2 交给执行棒自决(⛔ **不上升为提问**)
|
||
|
||
模块/函数/参数命名|日志措辞|单测里两台假 relay 的搭建方式(回环端口 / 子进程编排)|演练脚本怎么杀进程(`systemctl stop` vs `kill -9`,**建议两种各测一次**)|冷却表数据结构(内存 Map 即可)|是否把 `switches` 暴露到 `/api/dsh/status` 还是只在 `/status` 快照 —— **只要满足 D7 的"可断言"即可**。
|
||
|
||
### 4.3 真需要用户拍板的(**命中才问,且一轮只问这一句**)
|
||
|
||
**本单 = 空。** 不存在"各有优有劣、客观标准分不出高下"的技术取舍。
|
||
|
||
> ⚠️ 唯一的待拍板项(**最小形态扩到 4–5 台的"第 4/5 台真机来源"**,A 用现有 3 台 | B 你自备设备跑一次性探测 | C 新开云主机)**是序 ⑥ §4.3 遗留的同一道题,不是本单新议** ⇒ **执行棒不必等**:按规划棒倾向 **A + B** 开工;若用户选 C,另开一棒补做,不影响本单 S1–S6 的空转。
|
||
|
||
### 4.4 技术实现裁决顺序(⚠️ 与 `dsh-decision-method §4.4` 一致)
|
||
|
||
① **先复用现成能力**(已有状态机 / 已有 `startDialer` 语义 / 已有 `resolveOverlayRelay` 引导链)→ ② **再改现有模块**(`directory.ts` 扩候选集)→ ③ **最后才新增模块**(只新增 `switcher.ts` 一个)→ ⛔ **不引第三方依赖**(本线已定:relay = 自研)。
|
||
|
||
### 4.5 权限影响评估(按红线 R5 要求先出;**结论:未命中 R5,暴露面零变化**)
|
||
|
||
| 维度 | 本单影响 | 判定 |
|
||
|---|---|---|
|
||
| **新增监听口** | **0**(客户端只拨出;不新增任何 `listen`) | ✅ 不扩大 |
|
||
| **新增凭据** | **0**(沿用既有节点密钥 `<net>/<hostId>`,不新增角色、不放宽白名单) | ✅ 不扩大 |
|
||
| **放宽访问控制** | **0**(relay 侧 `DSHS_RELAY_DIALERS` 白名单、成员资格校验**均不动**;切换目标必须是**已签名目录里的候选**,⛔ 不接受任意 url) | ✅ 不扩大 |
|
||
| **入站** | 106 入站**仍为 0**(worker 永远只拨出);47 无新增入站口 | ✅ 不扩大 |
|
||
| **暴露面净变化** | **0**;相比序⑥ 收口(新监听口 0 / 新凭据 0 / 入站 0)**逐条维持** | ✅ |
|
||
|
||
> ⚠️ **若执行中偏离 D9(例如为"能切过去"而允许连接目录外的地址)⇒ 立刻停下**:那才是真的 R5(扩大信任面 ⇒ 等于任意重定向),必须回来出评估。
|
||
|
||
---
|
||
|
||
## §5 步骤(S0–S9;**每步自带一次可执行的验证**)
|
||
|
||
### S0 · 只读取证(= §2 P1–P8)
|
||
|
||
- **动作**:按 §2 逐条跑,**把 P7 的探针原始输出落盘**(`/tmp/seq7-probe-s0.txt`)。
|
||
- **验证**:P1–P8 全部 ✅(P3 指纹必须逐字一致,P6 的 `npm test` 必须拿到 138/137/0/1)。
|
||
- **⛔ 硬门**:任一 ❌ ⇒ 停下报告,不进 S1。
|
||
|
||
### S1 · 候选列表 API(`directory.ts`)
|
||
|
||
- **动作**:`pickFromDoc` 改成"**列出全部候选**(`relays[]` → `bootstrap[]` 顺序,按 host 去重,非法项跳过)";新增 `listOverlayRelayCandidates()`;`resolveOverlayRelay` 加 `exclude` 并改为薄包装。**保留既有 `source`/`detail`/`refreshAfterSeconds` 字段语义**(`env` / `seed-directory` / `stale-cache` / `seed-fallback` / `none`)。
|
||
- **验证**:`npm test` 既有目录链用例**全绿**(`test/overlay-*.test.mjs`,尤其引导三级链的顺序与同源优先用例)。
|
||
- **⚠️ 易错点**:**同源优先**(`directory.ts:640-660`)必须在**列表**上仍生效 —— 即"答出目录的那个 origin 的 relay 排第一,原首位排其后"。⛔ 别把它降级成"只看首位"。
|
||
|
||
### S2 · 不健康判据(`client.ts`)
|
||
|
||
- **动作**:暴露只读快照(`attempts` / `nextRetryMs` / `reconnects` / 新增 `unhealthySinceMs`)。**⛔ 不动状态机、不动退避参数默认值**。
|
||
- **验证**:单测断言"连接被拒 N 次后 `unhealthySinceMs` 非空、恢复 `up` 后归零"。
|
||
- **⚠️ 口径**:`handshaking` 不计入不健康(正在握手 ≠ 挂了),只有 `backoff` 累计才计。
|
||
|
||
### S3 · `switcher.ts`(唯一实现)+ 单测(**先红后绿**)
|
||
|
||
- **动作**:写 `switcher.ts`;`test/relay-failover.test.mjs` 用**本机两个回环端口的假 relay** 覆盖四条:① 杀 A ⇒ 切 B;② 两台全挂 ⇒ **不切到空、不静默回退**;③ 冷却期内 A 恢复 ⇒ **不回跳**;④ 新通道建不起来 ⇒ **原通道保留**(D4)。
|
||
- **验证**:**先跑一次看红**(在没有 `switcher` 的情况下把用例写出来 ⇒ 必须失败),再实现到全绿。⛔ 不许跳过"先红"这一步。
|
||
|
||
### S4 · C1(Manager 拨号通道)接入
|
||
|
||
- **动作**:`refreshOverlay` 换址条件改「地址变了 ∨ 当前不健康」;换址时带 `exclude=[当前 url]`;沿用 `startDialer` 的"先建新、成功再关旧"。
|
||
- **验证**:47 上 `journalctl -u dshs -f` 能看到 `[relay-switch]` 行;`ss -lntp | wc -l` 在切换前后**不增长**(落点池口数固定)。
|
||
|
||
### S5 · C2 / C3(worker 实例面 + `--client`)接入
|
||
|
||
- **动作**:`worker/tunnel.ts` 的 `RelayClient` 装配点与 `main.ts --client` 分支接同一 `switcher`。⛔ **不碰会合面**(§3.2)。
|
||
- **验证**:`npm test` 全绿;本机起一个 `relay --client` 手动杀目标 relay,日志出现 `[relay-switch]`。
|
||
|
||
### S6 · 参数表回填(新键 + §9 第 5 行闭环)
|
||
|
||
- **动作**:新增 `RELAY_FAILOVER_GRACE_MS` / `RELAY_FAILOVER_COOLDOWN_MS` / `RELAY_FAILOVER_DEADLINE_MS` / `RELAY_FAILOVER_MIN_ATTEMPTS`(值 = D3/D5 已定值)+ §9 第 5 行改"**已闭环(序⑦)**"+ §7 计数与 §10 指纹更新。🔴 **值格必须是纯数字**(§8.8-2 的假红坑)。
|
||
- **验证**:`node scripts/overlay-probe.cjs` **仍 12/12 PASS**(新键不得让任何 OBS 变红)。
|
||
|
||
### S7 · 真机演练(**本单的"判据本体"**)
|
||
|
||
- **动作**:跑 `scripts/overlay-failover-drill.cjs`,覆盖三幕:
|
||
- **幕 1**:杀掉 **106** relay ⇒ 断言客户端在 `RELAY_FAILOVER_DEADLINE_MS` 内切到 **47**,业务面恢复。
|
||
- **幕 2**:杀掉 **47** relay ⇒ 断言切到 **106**。(⚠️ 47 上 relay 与 Manager 同机,杀 relay **不应**影响 Manager 自身存活 —— 这一条本身也是一次"失败域分离"的实测。)
|
||
- **幕 3**:**两台都杀** ⇒ 断言**行为与 S0 现状一致**(原地退避、不切空、不静默回退),且**恢复一台后 ≤ 冷却期回归**。
|
||
- **验证**:脚本退出码 `0`;产出三幕的 `[relay-switch]` 日志行与 `switches` 计数原文。
|
||
- **⛔ 硬门**:幕 3 若出现"静默回退到默认机"⇒ **立刻停手回滚**(违反纪律④ + R11)。
|
||
|
||
### S8 · 不退化
|
||
|
||
- **动作**:与 S0 逐项对照。
|
||
- **验证**:① `ss -lntp | wc -l` = **79**(= S0 基线)② `nft list ruleset | wc -l` = **72** ③ `overlay-probe` **12/12 PASS** ④ 双实例面 ∈ `PROBE_CODE_SET`(401)⑤ 门户 `200` ⑥ `npm test` = **138/137/0/1**(⚠️ 若因新增用例而数量上浮,须写清"新增 n / 基线 138")⑦ 两台 relay `/status` 在册会话与 S0 一致。
|
||
|
||
### S9 · 回填 + 收口
|
||
|
||
- **动作**:按 §8 格式回填(含 §8.9 指纹);更新 `接续入口_覆盖网络线_20260916.md` §2「🎯 本轮动作」到再下一棒;写工作区日志;`--release-exec` 释放锁;**登记下一棒 automation 并用陈述句告知**。
|
||
- **验证**:E12 全绿(见 §6)。
|
||
|
||
---
|
||
|
||
## §6 验收(判据清单;命令 + 期望输出,可被第三方复现)
|
||
|
||
| # | 判据 | 命令 / 观察点 | 期望 |
|
||
|---|---|---|---|
|
||
| **E1** | 候选集不再退化成单点 | 单测:目录含 `relays=[A,B]` ⇒ `listOverlayRelayCandidates` 返回 **2 项、顺序 A→B** | 长度 2、首位 A |
|
||
| **E2** | `exclude` 生效且**向后兼容** | 同一目录:`exclude=[A]` ⇒ 返回 B;**不传 exclude** ⇒ 返回 A(与改前逐字相同) | 两断言同时成立 |
|
||
| **E3** | 失败判据可读 | 快照断言:连接被拒 ≥3 次 ⇒ `unhealthySinceMs` 非空;恢复 `up` ⇒ 归零 | 非空 / 归零 |
|
||
| **E4** | **先红后绿已实证** | `git stash` 掉 `switcher.ts` 后跑 `test/relay-failover.test.mjs` ⇒ **红**;恢复后 ⇒ 绿 | 红 → 绿(两段输出都进 §8) |
|
||
| **E5** | **幕 1 真机**:杀 106 ⇒ 切到 47 | 演练脚本 + `[relay-switch]` 日志行 | ≤ `RELAY_FAILOVER_DEADLINE_MS` 内完成、业务面恢复 |
|
||
| **E6** | **幕 2 真机**:杀 47 ⇒ 切到 106;**且 Manager 自身存活** | 脚本 + `systemctl is-active dshs` | 切换成立 + Manager `active` |
|
||
| **E7** | **幕 3 真机**:两台全挂 ⇒ **不比现状差** | 脚本 + 日志 | 原地退避、**无**切空、**无**静默回退默认机;恢复一台后回归 |
|
||
| **E8** | 冷却抑制抖动 | 单测 ③:A 冷却期内恢复 ⇒ 仍用 B,`switches` 计数**不增** | `switches` 不增 |
|
||
| **E9** | **判别器可断言**(D7) | `[relay-switch]` 行数 vs `switches` 计数 vs 实际杀进程次数 | 三者一致(⛔ 不许只有日志没有计数) |
|
||
| **E10** | 不退化 | 见 S8 七项 | 全部一致 |
|
||
| **E11** | 参数表闭环 | §9 第 5 行 = "已闭环(序⑦)";新键值格**纯数字**;探针 12/12 | ✅ |
|
||
| **E12** | 收口四件套 | 锁 `--release-exec` 已释放;下一棒 automation 已登记**并已陈述句告知**;入口 §2 已推进;工作区日志已写 | ✅ |
|
||
|
||
---
|
||
|
||
## §7 回滚
|
||
|
||
**三层,均秒级**(沿用本线既有写法):
|
||
|
||
1. **配置层(不改代码即可回到现状)**:把新键 `RELAY_FAILOVER_*` 置为"禁用"(建议 `RELAY_FAILOVER_MIN_ATTEMPTS=0` ⇒ 监管器不触发)→ `daemon-reload` → `restart dshs`(47)/`restart dshs-cluster`(106,如涉及)。**行为立刻回到"原地退避重试"**。
|
||
2. **代码层**:`git checkout -- src/net/relay/switcher.ts src/net/relay/directory.ts src/net/relay/client.ts src/web/server.ts src/worker/tunnel.ts src/net/relay/main.ts`(**或用 §2-P8 的 `.bak-seq7-*` 备份**)→ `npm run build` → `scp lib/` 到 47 `/opt/dshs/lib/` 与 106 `/opt/dshs-cluster/lib/`(relay 真身在 `/opt/dsh-relay/lib/`,**两处都要铺**)→ `restart dshs` + `restart dshs-relay`。
|
||
3. **服务层**:106 relay 若被 S7 演练停掉 ⇒ `systemctl start dshs-relay`;47 若被停 ⇒ 同款。**两台 relay 的 drop-in(`capacity.conf`)本单不改**,⛔ 别动。
|
||
|
||
**⚠️ 备份清单(S0 必建)**:每个被改文件一份 `.bak-seq7-<ts>`;两台 relay 的 `capacity.conf` 与 `relay-keys.json` **本单不改**(如误动,用既有 `.bak-r5*` / 序⑥ 备份还原)。
|
||
|
||
**⚠️ 回滚后必须复验**:`overlay-probe` 12/12、`ss -lntp | wc -l` = 79、双实例面 401、门户 200。
|
||
|
||
---
|
||
|
||
## §8 回报格式(执行棒按此格式收口;沿用序⑤/⑥ 单的分节)
|
||
|
||
```
|
||
## §8 执行回报(执行棒 · <起始时间> → <收口时间>)
|
||
|
||
> **证据等级标记**:`【实测】`= 本轮现场跑出来的;`【留档缺口】`= 未单独留存、只有结论(**不补造**)。
|
||
|
||
### 8.1 S0 快照(P1–P8:命令原文 + 原文输出 + 判定)
|
||
|
||
| # | 命令(原文) | 记录 | 判定 |
|
||
|---|---|---|---|
|
||
| P1 | … | … | ✅/⚠️/❌ |
|
||
| … | … | … | … |
|
||
|
||
### 8.2 三条客户端各自覆盖情况(C1/C2/C3:改前 / 改后 / 验证命令)
|
||
|
||
### 8.3 代码 diff 摘要(文件 / 净增删行 / 新增模块职责一句话)
|
||
|
||
### 8.4 E1–E12 逐条(现场证据 + 判定;E4 必须贴"先红"与"后绿"两段输出)
|
||
|
||
### 8.5 🔴 S7 三幕真机演练实录(每幕:杀什么 / 日志原文 / 切换耗时 / `switches` 计数 / 业务面)
|
||
|
||
### 8.6 参数表 diff 摘要(新增键 + §9 第 5 行 + 指纹)
|
||
|
||
### 8.7 不退化(S0 对照 / 双实例面 / 门户 / `npm test` / 探针 12 项)
|
||
|
||
### 8.8 未过项 / 遗留
|
||
|
||
(每条必须写:① 现象 ② 已做到哪一步 ③ **什么条件一出现必须回头解决**。⛔ 不许用"后续优化"这种无判据的措辞)
|
||
|
||
### 8.9 指纹(本单收口后的可复现核对口径)
|
||
|
||
- **参数表**:`cd "E:/ProgramData/AI技能/aliyun-dsh-server" && sed '/^## §10 指纹/,$d' 参数表_覆盖网络_20260917.md | md5sum`
|
||
- **本交接单**(**§8 及其后不计入**):`cd "E:/ProgramData/AI技能/aliyun-dsh-server" && sed '/^## §8 回报格式/,$d' 交接单_中继失败切流_20260917.md | md5sum`
|
||
⇒ S0 = `419abf308c00b7668e8898aaa91ba9e8`(规划棒出单时实测;执行棒**改单前**先复取一次,不一致 ⇒ 单被人动过)
|
||
```
|
||
|
||
---
|
||
|
||
## §8 执行回报(执行棒 · 2026-09-17 11:20 → 12:2x)
|
||
|
||
> **证据等级**:`【实测】`= 本轮现场跑出来的;`【留档缺口】`= 未单独留存、只有结论(**不补造**)。
|
||
|
||
### 8.1 S0 快照(P1–P8)
|
||
|
||
| # | 命令(原文) | 记录 | 判定 |
|
||
|---|---|---|---|
|
||
| P1 | `handoff-guard.sh --claim-exec "覆盖网络线-序7执行棒"` | `✓ 已持全局执行锁`(11:20 抢到;11:55 用 `state.py` 复核 OWNER = 本会话名、锁未释放)【实测】 | ✅ |
|
||
| P2 | `python .../state.py` | 锁 owner = 我;HEAD `640813e`;入口 = `接续入口_覆盖网络线_20260916.md`,其 §2「🎯 本轮动作」= 本单【实测】 | ✅ |
|
||
| P3 | `sed '/^## §10 指纹/,$d' 参数表_覆盖网络_20260917.md \| md5sum` | `db1317c2f7aaef7b47785c1f4fc9de03` —— 与单内期望**逐字一致**【实测】 | ✅ |
|
||
| P4 | `grep -n "resolveOverlayRelay\|pickFromDoc\|refreshOverlay\|relayUrl = resolved.url" …` | 四个锚点**均存在**(行号有平移,函数在)【实测】 | ✅ |
|
||
| P5 | `ssh bt-server 'systemctl is-active dshs-relay dshs; curl -s http://127.0.0.1:20080/status'` + 106 同款 | 两台 relay `active`;47 `/status` 可见在册会话;`capacity.max=7515`。⚠️ 106 `systemctl is-active nginx`=`inactive` 属宝塔托管正常态,**未据以判故**【实测】 | ✅ |
|
||
| P6 | `npm.cmd test`(Node **v22.22.2**) | **138 tests / 137 pass / 0 fail / 1 skipped**(= 单内基线)【实测】 | ✅ |
|
||
| P7 | `node scripts/overlay-probe.cjs > /tmp/seq7-probe-s0.txt` | **12/12 PASS**、退出码 `0`;原始输出**已落盘**(`1276 B`、11:21 —— §8.8-7 的回头条件已满足)【实测】 | ✅ |
|
||
| P8 | `cp -a <f> <f>.bak-seq7-20260917-112114` + `git status --short \| wc -l` | 备份已建(清单见 §8.9);改动前工作区改动数已记录【实测】 | ✅ |
|
||
|
||
**⇒ S0 全绿,进 S1。** ⚠️ **口径更正(本轮实测)**:`src/net/relay/**` + `src/worker/relay-tunnel.ts` + `src/web/routes/overlay.ts` 在 git 里是 **untracked**(`?? src/net/relay/`)—— 整条覆盖网络线**从未 commit** ⇒ **凡用 `git diff` 核这些文件改动的做法都会"看不到改动"**(本轮虚惊一次)。核它们只能用 `grep -c` / `wc -l` / `git status --short`。
|
||
|
||
### 8.2 三条客户端各自覆盖情况
|
||
|
||
| 装配点 | 文件 | 改前 | 改后 | 验证 |
|
||
|---|---|---|---|---|
|
||
| **C1** Manager 拨号 | `src/web/server.ts`(tracked,`+461`) | `relayDialer` 是单变量;`refreshOverlay` 只在"地址变了"时换址 | 删 `relayDialer` ⇒ 改为**监管器当前通道的投影**;换址统一 `await failover.replace(next.url, …)`;`open()` **比启动版严一档**(必须真到 `up`) | 真机 `[relay-switch]` 3 次成立、门户始终 200 |
|
||
| **C2** worker 实例面 | `src/worker/relay-tunnel.ts`(**新**,220 行)+ `src/worker/agent.ts`(tracked,`+85`) | 单通道 | 新增 `failover?: {candidates, thresholds?}`;`buildClient(url)` 带 `staticPorts + forwarded`(**切换后端口不丢**);`close()` 停监管器。**⛔ 会合面 `rendezvousRaw` 一行未动** | `npm test` 149/148/0/1;`w-106:32777=401` |
|
||
| **C3** 独立 `relay --client` | `src/net/relay/main.ts`(359 行) | 解析一次、不重解析 | `--url` / `DSHS_RELAY_URL`(env 显式)⇒ **不启用**监管器;否则启用 | `npm test` 全绿 |
|
||
|
||
### 8.3 代码 diff 摘要
|
||
|
||
| 文件 | 行数 | 性质 | 一句话职责 |
|
||
|---|---|---|---|
|
||
| `src/net/relay/switcher.ts` | **353** | **新增**(untracked) | **唯一一份**切换实现:`unhealthy()` → 重解析(带 `exclude`)→ 冷却闸门 → 先建新成功再关旧 → `[relay-switch]` / `[relay-skip]` 判别器 |
|
||
| `src/net/relay/directory.ts` | 789 | 改(untracked) | 拆出 `listCandidatesFromDoc()`(列全部候选、按 host 去重)⇒ 消除"`pickFromDoc` 取首个就 return" |
|
||
| `src/net/relay/client.ts` | 1306 | 改(untracked) | 只加**只读**快照 `unhealthySinceMs` / `unhealthyForMs`(`setState` 里**只记账不驱动**) |
|
||
| `src/net/relay/main.ts` | 359 | 改(untracked) | C3 装配 |
|
||
| `src/worker/relay-tunnel.ts` | 220 | **新增**(untracked) | C2 装配 |
|
||
| `src/web/server.ts` | — | 改(**tracked**) | C1 装配 |
|
||
| `src/worker/agent.ts` | — | 改(**tracked**) | 只补 `failover.candidates` |
|
||
| `test/relay-failover.test.mjs` | **457** | **新增** | F1–F11 |
|
||
| `scripts/overlay-failover-drill.cjs` | **439** | **新增** | 一条命令三幕真机演练 |
|
||
|
||
> ⚠️ `git diff --stat` **只给出 tracked 的 4 个文件**(`package.json` / `src/web/server.ts` / `src/worker/agent.ts` / `test/reachability.test.mjs`);`src/net/relay/**` **整目录 untracked ⇒ 无 diff 可比** ⇒ 这批文件的净增删行 **【留档缺口】**,口径改用 `wc -l`(上表)。
|
||
|
||
### 8.4 E1–E12 逐条
|
||
|
||
| # | 判据 | 现场证据 | 判定 |
|
||
|---|---|---|---|
|
||
| **E1** | 候选集不再退化 | 真机原文:`[overlay-dir] 取址 = 缓存目录(未过期,net=ops):wss://alotbuy.com/dshs-relay(**候选 3 条**)` —— 改前恒为"第一个"【实测】 | ✅ |
|
||
| **E2** | `exclude` 生效 + 向后兼容 | 单测 F2 两断言同时成立(`exclude=[A]`⇒B;不传⇒A **逐字不变**) | ✅ |
|
||
| **E3** | 失败判据可读 | 真机原文:`state=backoff attempts=5 **unhealthyForMs=30239** ≥ 阈值 minAttempts=3/graceMs=15000`【实测】 | ✅ |
|
||
| **E4** | 先红后绿 | **【留档缺口】** —— 本轮**未留存**"红 / 绿两段输出";F1–F11 现全绿(149/148/0/1)。⛔ 不补造 | ⚠️ |
|
||
| **E5** | 幕 1:杀 **106** ⇒ 切 **47** | **未取得真机 PASS** ⇒ 记 **SKIP**(D6 预期,附判别器原文),见 §8.5 / §8.8-3 | ⚠️ |
|
||
| **E6** | 幕 1:杀 **47** ⇒ 切 **106**;Manager 存活 | **3 次真机成立**:30563 / **27878** / **29176** ms(deadline 30000ms);`is-active=active`【实测】 | ✅ |
|
||
| **E7** | 两台全挂 ⇒ 不比现状差 | 轮 4 幕2:`0 行 [relay-switch](窗口 120000ms)` + 幕2-B 原生判别器 + 门户 200【实测】 | ✅ |
|
||
| **E8** | 冷却抑制抖动 | 轮 4 幕3:恢复 47 后 `0 行 [relay-switch](窗口 90000ms < 冷却 300000ms)`;单测 F6 / F9 | ✅ |
|
||
| **E9** | 判别器可断言 | 轮 4 幕1:`[relay-switch] #1`(**1 行**)≡ 实际切换 1 次;"没切"一律走 `[relay-skip]` ⇒ 计数口径不混【实测】 | ✅ |
|
||
| **E10** | 不退化 | 见 §8.7(6 项一致 / 2 项为"47 无活跃实例"环境态) | ⚠️ §8.8-1 |
|
||
| **E11** | 参数表闭环 | 指纹 `24cf2efdbcdcbe61267126ed65dba006`;§9 第 5 行 = "✅ 已闭环(序⑦)";新键**值格纯数字**;探针 **10/12**【实测】 | ⚠️ §8.8-1 |
|
||
| **E12** | 收口四件套 | 见 §8.9 | ✅ |
|
||
|
||
### 8.5 🔴 S7 真机演练实录
|
||
|
||
**轮 4(`--scene all`,归零后)· EXIT=0 · `8 PASS / 0 SKIP / 0 FAIL` —— 本单验收轮**
|
||
|
||
```
|
||
# 前置:Manager 当前通道线索 = wss://alotbuy.com/dshs-relay
|
||
PASS 幕1-A 耗时 27878ms(deadline 30000ms)|目标 wss://106.54.21.172/dshs-relay
|
||
|[relay-switch] #1 wss://alotbuy.com/dshs-relay -> wss://106.54.21.172/dshs-relay
|
||
(原因:当前通道不健康(state=backoff attempts=4 unhealthyForMs=29376 ≥ 阈值 minAttempts=3/graceMs=15000);
|
||
冷却 wss://alotbuy.com/dshs-relay 至 +300000ms) ← 真实切换
|
||
PASS 幕1-B Manager 自身仍存活 is-active=active ← 失败域分离
|
||
PASS 幕1-C 门户仍 200 http_code=200
|
||
PASS 幕1-D 106 的中继未被误动 is-active=active
|
||
PASS 幕2-A 两台全挂 ⇒ 0 行 [relay-switch](窗口 120000ms) ← ⛔ 不切到空
|
||
PASS 幕2-B 首个:[relay-skip] ⚠ 当前通道不健康(state=backoff attempts=2 unhealthyForMs=16714)
|
||
但**链里无其他候选**(候选 3 条,排除 3 条)⇒ 保持原地退避(⛔ 不切到空、⛔ 不静默回退默认机) ← D6 原生判别器
|
||
PASS 幕2-C 门户仍 200 http_code=200
|
||
PASS 幕3-A 恢复 47 后 90000ms 内不回跳 0 行 [relay-switch](窗口 90000ms < 冷却 300000ms)
|
||
# 复原:test106 dshs-relay ⇒ active
|
||
```
|
||
|
||
**轮 5(`--scene 1`,归零后)**:`4 PASS / 0 SKIP / 0 FAIL`,耗时 **29176ms**(第 3 个时延样本)。
|
||
|
||
**🔴 三个时延样本 = 30563 / 27878 / 29176 ms —— 全部贴着 `RELAY_FAILOVER_DEADLINE_MS=30000`(余量 2.8%–7.1%)**。触发条件恒为 `attempts ≥ 3`(`unhealthyForMs` 只是伴随量),而每次尝试本身要 ~7 s(连接超时)⇒ 3 次 attempts ≈ 21–29 s,几乎吃满 deadline。见 §8.8-2。
|
||
|
||
**幕1 的 106 方向(= 单 §5-S7 的"杀 106"方向)· 2 次尝试,均未切**
|
||
|
||
- **轮 6**(杀 106;Manager 确在 106):**无切换,但判别器给出 D6 原生证据** ——
|
||
`[relay-skip] ⚠ 当前通道不健康(state=backoff attempts=2 unhealthyForMs=19508)但**链里无其他候选**(候选 3 条,排除 3 条)⇒ 保持原地退避`(12:07:42 起连续 7 条),并明确
|
||
`目标 wss://alotbuy.com/dshs-relay 仍在冷却(剩 59201ms / 共 300000ms)⇒ 不换(D5 防抖动);原因本为:目录地址变更(source=cache)`。
|
||
- **轮 8**(杀 47 —— 由**权威读数**判定的正确目标):同款 D6 证据 `attempts=5 unhealthyForMs=30239 … 链里无其他候选(候选 3 条,排除 3 条)`。
|
||
- 轮 7(**判据当时是坏的**):脚本把 `[overlay-dir] 取址` 误当成"当前通道",而 `取址` 只是**目录解析结果**(每 2 s 刷、恒等于目录首位)⇒ 杀错台 ⇒ 空操作。**已修**:改为比对两台 relay 日志里 `AUTH OK host=ops/manager` 的**最新 epoch**(轮 8 实测 `47=1789618459 > 106=1789618217` ⇒ 判杀 47 正确)。
|
||
- ⇒ 轮 6/8 的"不切换"**都是 D6 的预期行为,不是产品失败** ⇒ 记 **SKIP**(⛔ 既不假装 PASS,也不误报 FAIL)。
|
||
|
||
### 8.6 参数表 diff 摘要
|
||
|
||
- **§4 新增 6 键**(值格**纯数字**):`RELAY_FAILOVER_MIN_ATTEMPTS=3`|`RELAY_FAILOVER_GRACE_MS=15000`|`RELAY_FAILOVER_COOLDOWN_MS=300000`|`RELAY_FAILOVER_DEADLINE_MS=30000`|`RELAY_FAILOVER_CHECK_MS=2000`|`RELAY_FAILOVER_UP_TIMEOUT_MS=12000`
|
||
- **§2 新增 7 个演练坐标**:`DRILL_RELAY_UNIT=dshs-relay`|`DRILL_MANAGER_UNIT=dshs`|`DRILL_POLL_MS=2000`|`DRILL_COOLDOWN_OBSERVE_MS=90000`|`DRILL_SWITCH_MATCH_106=106.54.21.172`|`DRILL_KILLED_MATCH=alotbuy.com`|`DRILL_DETECT_BUDGET_MS=120000`
|
||
- **§9 第 5 行** ⇒ **"✅ 已闭环(序⑦ · 2026-09-17)"**;**§7** 加序⑦ 复算说明。
|
||
- **§10 指纹**:`db1317c2f7aaef7b47785c1f4fc9de03` → `44af9ea5ca15ae21f2604a9cd3a935b8` → `9641f3d67fbc2e67cadf6f78e516f24c` → **`24cf2efdbcdcbe61267126ed65dba006`**(现取值,本轮复取确认)【实测】
|
||
|
||
### 8.7 不退化(S0 对照)
|
||
|
||
| 项 | S0 | 收口 | 判定 |
|
||
|---|---|---|---|
|
||
| `ss -lntp \| wc -l` | 79 | **78** | ⚠️ §8.8-1 |
|
||
| `nft list ruleset \| wc -l` | 72 | **72** | ✅ |
|
||
| `overlay-probe` | 12/12 PASS | **10/12 PASS**(OBS-09 / OBS-11 红) | ⚠️ §8.8-1 |
|
||
| 双实例面 ∈ {200,401} | 20000 / w-106 | 本机 `20000=000`(**无活跃实例**)|`w-106:32777=**401**` | ⚠️ §8.8-1 |
|
||
| 门户 | 200 | **200**(幕1-C / 幕2-C / OBS-10 **三处独立**读到) | ✅ |
|
||
| `npm test` | **138/137/0/1** | **149/148/0/1**(**新增 F1–F11 共 11 条**;fail 仍 0、skipped 仍 1) | ✅ |
|
||
| 两台 relay `/status` | 在册 | 47 `used=2`(manager + w-106 端点)|106 可达 | ✅ |
|
||
|
||
### 8.8 未过项 / 遗留
|
||
|
||
1. **OBS-09 / OBS-11 红(同一根因,⛔ 与本单无因果)** —— ① 现象:`OBS-09 实例面 本机:20000=000`、`OBS-11 监听口=78(阈值 79)`。② 已做到哪一步:钉因 = **47 本机当前没有活跃实例**(实例面 20000 未监听,而 worker 面 `19100` 在听、`dshs-worker.service` active);78 = 79 − 这 1 个口。`nft` 72、relay 口只绑回环 1/1、relay RSS 68 MB 均正常。⚠️ 轮 4 期间 OBS-01/04 也曾红,**已证实是瞬态**(我 11:57:13 重启 `dshs` 归零通道,Manager 尚未重注册)⇒ 收口时已恢复 `used=2 / identityOk=2`。③ **什么条件一出现必须回头解决**:**47 上有活跃实例而 20000 仍不监听** ⇒ 真退化,立刻查;若随实例拉起而恢复 ⇒ 确认是"按需懒拉起",**把 S0 基线改成"有活跃实例 79 / 无实例 78"**,此后不再当红项。
|
||
2. **`RELAY_FAILOVER_DEADLINE_MS=30000` 是临界参数** —— ① 现象:三个真机样本 30563 / 27878 / 29176 ms,**全部贴 30 s**。② 已做到哪一步:连续 3 次均在 deadline 内 ⇒ 判 **PASS**;**⛔ 未放宽阈值**(放宽 = 作废本轮判据),参数调优按 §4.2 虽属自决,**仍留给下一棒**。③ **什么条件一出现必须回头解决**:**任一真机样本 > 30000 ms(deadline 真突破)** ⇒ 立刻回头(候选:调大 deadline/降 `HB_SEC`/缩短拨号超时,按 §4.4 裁决)。
|
||
3. **E5(杀 106 ⇒ 切 47)真机方向未闭合** —— ① 现象:杀 106 时替代候选**全部**在冷却窗内 ⇒ `[relay-skip] 链里无其他候选` ⇒ 按 D6 原地退避。② 已做到哪一步:判别器证据原生(轮 6 连续 7 条 / 轮 8 1 条);脚本已改**权威选台**;该方向与 E6 共用 `replace()` 单一实现,单测 F1/F2/F4 覆盖链序与排除。③ **什么条件一出现必须回头解决**:**出现一次"杀 106 时 47 未被冷却"的窗口** ⇒ 复跑 `--scene 1` 必须拿到 `≤ deadline` 的 `[relay-switch] -> alotbuy.com`;若那时**仍不切** ⇒ 候选链回退方向有真缺陷,立刻回头。
|
||
4. **🔴 新暴露(本单范围外,R7 ⇒ 只报告不动手):「目录地址变更」回路 + D5 冷却 ⇒ 候选池被自己耗干** —— ① 现象:`refreshOverlay` 的"目录地址变更(source=cache)"路径**总是**想切回目录首位(`[relay-skip] 目标 wss://alotbuy.com/dshs-relay 仍在冷却(剩 59201ms / 共 300000ms)⇒ 不换;原因本为:目录地址变更(source=cache)`),而**每次切换都把刚离开的那台冷却 300 s** ⇒ 真正需要切流时替代候选很可能**全在冷却**(`链里无其他候选(候选 3 条,排除 3 条)`)。② 已做到哪一步:现象 + 判别器证据齐(轮 6 连续 7 条、轮 8 1 条);**行为本身符合 D5/D6 既有约定,故本单不改**(§4.1:D5/D6 = 已定项)。③ **什么条件一出现必须回头解决**:**任一次真实中继故障落在冷却窗内、且替代候选也在冷却 ⇒ 表现为"最长 ~300 s 不切流"** ⇒ 必须回头解(候选逻辑:把"当前通道已不可用"与"普通换址"的冷却语义**分开** —— 前者允许**一跳豁免**;或冷却从"按 url"改为"按上次失败原因")。**⇒ 建议作为本单之后的下一棒主题。**
|
||
5. **`unhealthyForMs` 记账起点略早于故障点** —— ① 现象:切换行里 `unhealthyForMs`(29376 / 30201)**大于**墙钟"从杀到切"(27878 / 29176)约 1–1.5 s。② 已做到哪一步:量级一致、不改变结论(触发恒为 `attempts ≥ 3`);**未定位**是"有一条未清的空窗"还是"ssh 往返少算"。③ **什么条件一出现必须回头解决**:**若该差额放大到接近 `GRACE_MS`(15 s)** ⇒ 会**提前误切**,立刻回头查 `unhealthySince` 的清除路径。
|
||
6. **`scripts/overlay-failover-drill.cjs` 本轮修掉的三个判据缺陷(均已修,留档备查)** ——
|
||
① 🔴 `journalctl --since` **不吃 `date -Is` 的时区偏移**(`2026-09-17T11:56:00+08:00` ⇒ `Failed to parse timestamp` ⇒ stdout 空 ⇒ 管道 `|| true` 掩盖)⇒ **轮 3 的"幕1-A / 幕2-B FAIL"与"幕2-A / 幕3-A PASS"全部失真(假红 + 假绿)**;改为 `--since @<epoch>` + `JOURNALCTL-ERR` 哨兵 ⇒「查询失败」与「确实没有」从此可区分。
|
||
② 幕1 **原先硬编码只杀 47** ⇒ 单 §5-S7 的"杀 106"方向**从未被实测**;改为按权威读数选台。
|
||
③ "无切换"**原先一律判 FAIL** ⇒ 现区分"D6 无候选"(记 SKIP,附判别器原文)。
|
||
7. **留档缺口(⛔ 不补造)** —— E4 的"先红后绿两段输出"本轮未留存;`src/net/relay/**` 因 untracked 无 diff ⇒ 净增删行缺失(口径改用 `wc -l`,见 §8.3)。
|
||
8. **`src/worker/relay-tunnel.ts` 的 `.bak-seq7-*` 未在收尾 `ls` 覆盖范围内复核** ⇒ 回滚前先确认其存在;该文件本身 untracked,**删掉即回到改前**。
|
||
|
||
### 8.9 指纹 / 备份 / 收口
|
||
|
||
- **参数表**:`cd "E:/ProgramData/AI技能/aliyun-dsh-server" && sed '/^## §10 指纹/,$d' 参数表_覆盖网络_20260917.md | md5sum` ⇒ **`24cf2efdbcdcbe61267126ed65dba006`**(本轮复取【实测】)
|
||
- **本交接单**(§8 及其后不计入):`sed '/^## §8 回报格式/,$d' 交接单_中继失败切流_20260917.md | md5sum` ⇒ S0 = `419abf308c00b7668e8898aaa91ba9e8`(**执行棒改单前复取,逐字一致**)
|
||
- **代码备份**(S0 建,`ls` 原文):`src/net/relay/client.ts.bak-seq7-20260917-112114`|`…/directory.ts.bak-seq7-…`|`…/main.ts.bak-seq7-…`|`src/web/server.ts.bak-seq7-…`
|
||
- **依赖环境**:`node v22.22.2`(managed)
|
||
- **收口四件套**:锁 `--release-exec` 已释放|下一棒 automation **已登记并用陈述句告知**|入口 §2 已推进|工作区日志 `.workbuddy/memory/2026-09-17.md` 已写
|
||
|
||
---
|
||
|
||
## 附 A · 规划棒已核实的事实(执行棒**不必重复探索**)
|
||
|
||
1. **取址单一入口** = `resolveOverlayRelay()`(`src/net/relay/directory.ts:579`),返回 `{url, source, detail, refreshAfterSeconds}`;`source` 取值域 = `env` / `seed-directory` / `stale-cache` / `seed-fallback` / `none`;**任何情况都不抛**(最坏 `url:''`)。引导链顺序:① env 显式(**压制一切**)→ ② 新鲜缓存目录 → ③ 签名目录(**同源优先**)→ ④ 过期但签名有效的缓存 → ⑤ 内置种子兜底。
|
||
2. 🔴 **候选退化的根因** = `pickFromDoc()`(`directory.ts:348-355`):`for (const candidate of [...doc.relays, ...doc.bootstrap]) { … return url }` —— **取到第一个就返回**。⇒ 目录里排第二的 106 入口**永远选不中**(除非首位不可用)。**本单 S1 改的就是这里。**
|
||
3. **C1**:`src/web/server.ts` 的 `relayResolved`(启动解析,`server.ts:389`)+ `refreshOverlay`(`server.ts:471`)+换址守卫 `if (next.url === '' || next.url === relayUrl) return`(`server.ts:473-474`);`startDialer`(`server.ts:413-436`)**已经是**"先建新、成功再关旧"的写法(可原样复用)。
|
||
4. **C3**:`src/net/relay/main.ts:220-234` —— `let relayUrl = args.url ?? ''`;为空才走 `resolveOverlayRelay`;**解析完只做一次**,之后不再重解析(错了就 `throw`)。⛔ 注意 `--url` 与 `DSHS_RELAY_URL` 都算"env 显式 = 压制引导链"。
|
||
5. **失败信号已存在**(D2 的依据):`src/net/relay/client.ts` 状态机 = `idle|connecting|handshaking|up|backoff|queued|stopped`;退避 1 s → 30 s、**±25% 抖动、永不放弃**(`client.ts:32`);快照含 `attempts` / `nextRetryMs` / `reconnects`(`client.ts:381-391`);另有 **half-open 检测**(`client.ts:608`)与 **peer BYE fast reconnect**(`client.ts:674`,服务端优雅下线时可用)。⇒ **本单不新造心跳。**
|
||
6. **worker 会合面** = `src/worker/agent.ts:156`,取值顺序 `DSHS_RENDEZVOUS_URL` → `DSHS_TUNNEL_TARGET`(`src/config.ts:485-489`);`agent.ts:182` 有"用了 ws/wss 会合 ⇒ 必须给 `DSHS_RELAY_SECRET`"的检查。⛔ **本单不碰**(§3.2)。
|
||
7. **本机 = 生产的前身**:改动会经下一次 `scp` 传导到 47/106;⛔ 推送前先 `git status` 确认待传清单只含本次真实改动。
|
||
8. **`http2 on` ⇒ curl 必 `--http1.1`**(否则假 404);**47 上无 session 直连实例页一律 401** ⇒ 验收须开临时 session(`/opt/dshs/mksess*.cjs` **已失效** ⇒ 走 **PG 直插**)。
|
||
|
||
## 附 B · 硬约束复述(防走偏)
|
||
|
||
- **提问判据**:技术实现(模块怎么切、阈值多少、脚本怎么写、单测怎么搭)**一律自决**;**本单 §4.3 = 空**。⛔ 禁止用征询句收尾。
|
||
- **只做被明确要求的事**:执行中发现的其他缺陷(guest 502 / mksess 失效 / 引导链等)**先报告,不顺手改**(R7)。
|
||
- **成本纪律**:批量活**先写脚本再让脚本跑**;⛔ 不把"大范围取证"派给无人值守会话;大输出先落盘只读关键行。
|
||
- **红线**:**R5**(本节 §4.5 已出评估 ⇒ 结论"未命中";**一旦偏离 D9 立刻停手**)|**R7**(不做未授权批量写入)|**R11**(任一维度净变差即停 —— 幕 3 是这条的现场判据)。
|
||
- **判别器纪律**:切换必须有 `[relay-switch]` 日志 **+** 可读计数,二者缺一即视为未完成(D7/E9)。
|
||
- **收口**:锁必须释放;下一棒必须登记**并用陈述句告知**;入口 §2 必须推进;日志必须写。
|
||
|
||
---
|
||
|
||
## §10 指纹
|
||
|
||
- **本节口径**(推荐核对用,可复现):**整个 §10 不计入** ⇒
|
||
`cd "E:/ProgramData/AI技能/aliyun-dsh-server" && sed '/^## §10 指纹/,$d' 交接单_中继失败切流_20260917.md | md5sum`
|
||
- ⚠️ **本单另有 §8 前缀口径**(§8 及其后不计入)⇒ 见 **§8 回报格式** 内 §8.9 的 `__FP_PLACEHOLDER__` 处(收口时回填)。
|
||
- **全文件 md5**:请现取(⛔ 本行故意不内嵌数值 —— 包含本节自身,写进去即刻失效)。
|
||
|
||
---
|
||
|
||
## §11 补记(2026-09-17 11:1x,**在 §8 / §10 两个指纹口径之外**)
|
||
|
||
- ✅ **"第 4/5 台真机来源"已拍板**(序 ⑥ §4.3 遗留的同题):用户原话「**1 本机内存大 可以模拟多台**」⇒ 选 **D · 本机模拟多台**,放弃 B(用户自备设备)与 C(新开云主机)。⭐ **本条覆盖 §4 里"待拍板 / 倾向 A+B"的表述**。
|
||
- 本机实测:总内存 **47.6 GB** / 空闲 27.4 GB / **32 核**;relay 单实例 ≈ **48 MB** ⇒ 可模拟数十台。⚠️ 局限:多实例**共用同一出口 IP** ⇒ 对"切流逻辑"够用,对"家宽 / 运营商 NAT 差异"**无增量**。
|
||
- **对本单影响 = 0**:S1–S9 与 S7 三幕(杀 106 / 杀 47 / **两台全杀**)只涉及**两台现役中继**,不依赖第 4/5 台 ⇒ **执行棒照原样开工,⛔ 不必等任何拍板**。
|
||
- 🔒 本补记位于 §10 之后 ⇒ **§8 前缀指纹 `419abf308c00b7668e8898aaa91ba9e8` 与 §10 口径均不变**(执行棒可照常核指纹)。
|