Files
dsh_shenxian/src/net/relay/switcher.ts
T
admin 09ce76f3af feat(overlay): 内容块级寻址 + 实例逐步拉起 + 骨干选路 + 组密钥加密(序24–㉛ 累积同步)
代码
- 内容分发块级寻址:新增 src/net/relay/content/{chunker,store,runtime,source,peer,crypto}.ts
- 组密钥(C 档)确定性加密:AES-256-GCM,块 id β′ = sha256(密文) 前 32 hex;双 epoch 过渡窗口
- 实例生命周期:三处 teardown() 不再杀实例(local/remote/leased-spawner);启动认领 + TCP 探活判孤儿
- 骨干选路:jitter 选路 + endpoint-target;relay client/server/wire/identity/directory/rendezvous/switcher 调整
- 工作台 src/web/server.ts、src/worker/relay-tunnel.ts 装配与候选链观测

脚本与测试
- scripts/overlay-{probe,keyring,jitter}.cjs 更新
- 探针新增 OBS-21(每连接候选数)/ OBS-22(teardown 静态守卫 + 认领面)/ OBS-23(组密钥加密)
- 新增 test/{orchestrator-teardown,orchestrator-rehydrate,overlay-content,overlay-jitter}.test.mjs;relay 两例更新

文档
- 新增交接单:覆盖网络-序24-内容分发块级寻址 / 序25-实例逐步拉起 / 序26-骨干稳定选路与加密
- INDEX.md、交接单/README.md、skills/dsh-auto-handoff-chain/SKILL.md 同步

验收(零回归,2026-09-18 08:0x 复核)
- npm test           201 tests / 200 pass / 0 fail / 1 skipped
- overlay-failover-drill --scene all --table   12 PASS / 0 SKIP / 0 FAIL
- overlay-probe --table                        23 PASS / 0 SKIP / 0 FAIL (rc=0)
2026-09-18 08:08:51 +08:00

677 lines
33 KiB
TypeScript
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.
/**
* 中继失败切流 · **唯一一份实现**(覆盖网络 · 序⑦)
*
* ## 为什么只能是"唯一一份"
* 本线已经吃过两次**同类**教训:`translateEndpoint` 漏赋值、`target()` 静默回退
* —— 都是"同一件事在多处各写一遍,其中一处悄悄漏了"。所以切换逻辑**只写在这里**,
* 三个装配点(Manager 拨号通道 / worker 实例面 / 独立 `relay --client`)复用同一个类。
*
* ## 它解决的是"候选集退化成单点"
* 改造前 `RelayClient` 退避**永不放弃**地重试**同一个 url**,而取址层 `pickFromDoc`
* **取到第一个候选就 `return`** ⇒ 重解析一百次拿回来的还是同一个字符串。
* ⇒ 本模块负责"**发现当前这条不健康 ⇒ 换到链里的下一条**"。
*
* ## 判据全部复用现成的(D2:⛔ 不新造心跳 / 不新增探测帧)
* - 不健康 = `state === 'backoff'` 且(`attempts ≥ MIN_ATTEMPTS` ∨ `unhealthyForMs ≥ GRACE`);
* - 候选链 = {@link listOverlayRelayCandidates}(**同一份引导链**,⛔ 不另写取址);
* - "先建新、成功再关旧" = 由调用方的 `open()` 保证(见 {@link RelayFailoverDeps.open})。
*
* ## 硬纪律(每条都有对应验收)
* - **D4**:新通道**建不起来 ⇒ 保持原通道**(把"本来能用"的通路打掉才是真事故);
* - **D5**:被换掉的 url 进**冷却表**,冷却期内不回跳(否则两台互相抢 = 抖动风暴);
* - **D6**:链里除已排除项外**没有候选 ⇒ 不切换**(原地退避);⛔ 绝不切到空、**绝不静默回退默认机**;
* - **D7**:**每次切换**输出一行 `[relay-switch] #N …` + 累加计数 `switches`
* ⇒ `grep -c '^\[relay-switch\]'` 与 `switches` **必然相等**(判别器可断言)。
* ⚠️ 所以"**没切**"的行必须用**另一个前缀** `[relay-skip]` —— 否则计数对不上(E9 会红)。
*
* ## 序⑧(2026-09-17)新增:**冷却语义的不对称拆分**(解决"候选池被自己耗干")
* - **D1**:**「当前通道已不可用」有权打破自己刚设下的冷却;「目录说该换回首位」没有**。
* ⇒ `replace()` 新增 `origin: 'health' | 'directory'`,闸门**只对 directory 收口**。
* - **D2**:冷却键**仍是 url**;新增 `kind`(`switched-away` / `open-failed`)**只**决定豁免优先级。
* ⛔ 原因做键 ⇒ 同一 url 会同时存在多条 ⇒ 从头抖回来 = 净退化。
* - **D4**:豁免**有界** —— 每 url **每冷却周期一次**(`exemptedAtMs`);豁免后 `open()` 再失败
* ⇒ 重置该 url 冷却**且本周期不再豁免**(⛔ 否则每 2 s 试一次 = 重试风暴)。
* - **D5**:豁免优先级 = `switched-away` 优先,同类按 `untilMs` 升序。
* - **D3 护栏**:豁免**只在 D6 现场**启用;**有干净候选时行为逐字不变**(单测 F15 锁住)。
* - **D6 开关**:`RELAY_FAILOVER_EXEMPT`(默认 `1`;置 `0` ⇒ 逐字回到序⑦ 行为)= 第二层回滚。
*/
/**
* 序㉖:`jitter` 主序与"劣化即切"的**唯一实现**都在 `jitter.ts` ⇒ 本文件只做三件事:
* ① 采样(把当前通道的 `rttMs` 喂进 tracker)② 判定(调 `pickJitterTarget`)③ 记数 + 告警。
* ⛔ 不许在本文件里再写一份 p95/排序 —— 那正是"另一份实现 = 另一处静默失效"的复发点。
*/
import {
orderByJitter,
pickJitterTarget,
sharedJitterTracker,
type JitterTracker,
} from './jitter.js'
/** 阈值(全部来自参数表 / env;脚本与实现**零数字字面量**)。 */
export interface RelayFailoverThresholds {
/** 连续失败次数达到这个数即视为不健康。 */
minAttempts: number
/** 或:持续不健康时间达到这个数(ms)即视为不健康。 */
graceMs: number
/** 被换掉的 url 的冷却时长(ms);冷却期内不参与候选。 */
cooldownMs: number
/** 从"判定不健康"到"切换完成"的允许上限(ms)——**验收判据**,实现只用它做日志。 */
deadlineMs: number
/** 巡检周期(ms)。 */
checkMs: number
/**
* **换址时"新通道算不算建起来了"的等待上限(ms)**。
*
* ⚠️ 只作用于**换址**,⛔ **不作用于启动** —— 启动那条通道必须"不依赖网络"就走完
* (P0-2 的设计前提:控制面自己也是客户端,启动那一刻自己的门户还没 listen)。
* 不给这个上限,`open()` 就会把"口池绑好了"当成"通了" ⇒ 会切到一条**同样连不上**的中继上。
*/
upTimeoutMs: number
/**
* **序⑧ 新增:一跳豁免总开关**(`RELAY_FAILOVER_EXEMPT`,默认开)。
*
* 语义 = "**当前这条已经挂了**"有权打破自己刚设下的冷却(见 {@link RelayFailoverSupervisor.pickExemptTarget})。
* 置 `0` ⇒ 逐字回到序⑦ 行为(D6 现场只写 `[relay-skip]`、不切换)⇒ **第二层回滚**。
* ⚠️ 与 `minAttempts <= 0`(把整个监管器关掉)**不是同一层级**,⛔ 不许合并(D6 口径)。
*/
exempt: boolean
}
/**
* 从 env 读阈值(**值格必须纯数字**;非纯数字一律回退默认值并在日志里说清)。
*
* 键名与本线参数表 `参数表_覆盖网络_20260917.md` 的 `RELAY_FAILOVER_*` 一一对应。
*/
export function relayFailoverThresholds(
env: Record<string, string | undefined> = process.env,
): RelayFailoverThresholds {
const num = (key: string, dflt: number): number => {
const raw = (env[key] ?? '').trim()
if (raw === '') return dflt
return /^\d+$/.test(raw) ? Number(raw) : dflt
}
return {
minAttempts: num('RELAY_FAILOVER_MIN_ATTEMPTS', 3),
graceMs: num('RELAY_FAILOVER_GRACE_MS', 15_000),
cooldownMs: num('RELAY_FAILOVER_COOLDOWN_MS', 300_000),
deadlineMs: num('RELAY_FAILOVER_DEADLINE_MS', 30_000),
checkMs: num('RELAY_FAILOVER_CHECK_MS', 2_000),
upTimeoutMs: num('RELAY_FAILOVER_UP_TIMEOUT_MS', 12_000),
/**
* 值格仍走 `num()`(`/^\d+$/`)⇒ ⛔ 参数表里的值格**必须纯数字**(写 `1` / `0`,
* ⛔ 不许写成 `true` 或带夹注的 `1(默认)` —— 后者解析失败会**静默回退默认值**)。
*/
exempt: num('RELAY_FAILOVER_EXEMPT', 1) !== 0,
}
}
/** 一条 relay 通道(把"通道"和它的健康快照绑在一起,避免调用方各记一份)。 */
export interface RelayChannelHandle {
/** 这条通道连的地址(`ws://` / `wss://`)。 */
readonly url: string
/** 该通道自己的健康快照(实现里通常就是 `RelayClient.status()` 的投影)。 */
health(): { state: string; attempts: number; unhealthyForMs: number; rttMs?: number }
/** 关掉这条通道。**幂等**、不抛。 */
close(): void
}
export interface RelayFailoverDeps {
/**
* 建一条新通道,**成功才返回句柄**(失败返回 `undefined`)。
*
* ⛔ 顺序由本函数负责:**先把新通道建起来并确认可用,本模块之后才去关旧的**。
* 反着做(先关后建)会在切换失败时把"本来能用"的通路打掉 ⇒ 违反 R11。
*/
open: (url: string) => Promise<RelayChannelHandle | undefined>
/** **候选链**(有序)。生产上就是 `listOverlayRelayCandidates()` 的投影。 */
candidates: () => Promise<readonly string[]>
log: (line: string) => void
thresholds?: Partial<RelayFailoverThresholds>
/** 注入点(单测用);默认 `Date.now`。 */
nowMs?: () => number
/** 注入点(单测用);默认 `setTimeout` 自链。 */
setTimerImpl?: (fn: () => void, ms: number) => unknown
clearTimerImpl?: (handle: unknown) => void
/**
* **序㉖:抖动采样表**。
*
* - 缺省(`undefined`)⇒ 用**进程级共享 tracker**({@link sharedJitterTracker})——
* 装配点(`src/web/server.ts` / `src/worker/relay-tunnel.ts` / `src/net/relay/main.ts`)
* 都**不在序㉖ 的在册文件集**里,做成"必须注入"= 生产上永远不会被注入 = **静默失效**。
* - 显式给 `null` ⇒ **本监管器不参与 jitter 排序与劣化切换**(逐字回到序⑧ 行为,夹具用)。
* - 集成/单测可传自己的实例(⛔ 别用共享单例做断言 —— 会与别的用例串味)。
*/
jitterTracker?: JitterTracker | null
}
/**
* **冷却原因**(序⑧ / D2):⛔ **不作冷却表的键**,只决定**豁免优先级**(D5)。
*
* - `switched-away` —— "**我们主动离开了它**",它**曾可用**({@link RelayFailoverSupervisor.replace} 换址成功那条)⇒ 优先豁免。
* - `open-failed` —— "**刚证明它建不起来**"(`open()` 失败那条)⇒ 次选。
*
* 🔴 **为什么原因不能做键**(本线已有同类教训:`translateEndpoint` 漏赋值):同一 url 会**先后**因不同
* 原因进冷却 ⇒ 原因做键会让同一 url 同时存在多条条目 ⇒ 它会通过"另一条原因键"被再次尝试 ⇒
* 抖动抑制失效 = **净退化**。
*/
export type RelayCooldownKind = 'switched-away' | 'open-failed'
/** 冷却表条目(序⑧ / D2 + D4)。 */
export interface RelayCooldownEntry {
/** 解除时刻(epoch ms)。 */
untilMs: number
/** **本冷却周期的起点**(epoch ms)—— 判定"本周期的豁免是否已用掉"的锚点(D4)。 */
sinceMs: number
/** 进冷却的原因;只喂给豁免优先级(D5)。 */
kind: RelayCooldownKind
/**
* **本周期内上一次用掉豁免**的时刻(D4 的有界性)。
* 判据 = `exemptedAtMs >= sinceMs` ⇒ 本周期不再豁免它(否则每 2 s 巡检都试一次 = **重试风暴**)。
*/
exemptedAtMs?: number
}
/** 切换统计(D7:**可读计数**,与日志行数必须一致)。 */
export interface RelayFailoverStats {
/** 成功切换次数(= `[relay-switch]` 日志行数)。 */
switches: number
/** 巡检次数。 */
checks: number
/** "判定不健康但无候选可切"的次数(D6 的现场证据)。 */
noCandidateChecks: number
/** "新通道建不起来 ⇒ 保持原通道"的次数(D4 的现场证据)。 */
openFailed: number
/** **序⑧ 新增**:走"一跳豁免"完成的切换次数(⊆ `switches`;这些行都带 `|豁免`)。 */
exemptSwitches: number
/**
* **序㉖ 新增**:因 **jitter 劣化**触发的换址次数(⊆ `switches`;这些行都带 `|jitter`)。
*
* 🔑 为什么必须有这个数:本序的判据是"**超阈值自动切路径并告警**"——如果只写日志不留计数,
* 脚本就无法断言"它到底切过没有"(本线已有两次同类教训:只写日志的实现让判据形同虚设)。
*/
jitterSwitches: number
/** **序㉖ 新增**:成功记入 tracker 的 RTT 采样次数(= 0 ⇒ 采样链断了,必须能看出来)。 */
jitterSamples: number
/** **序㉖ 新增**:当前通道抖动量超标(`p95|ΔRTT| ≥ JITTER_LIMIT_MS`)的巡检次数。 */
jitterAlerts: number
/** 最近一次成功切换的时刻(epoch ms)。 */
lastSwitchAtMs?: number
/** 冷却表中的地址与解除时刻(⚠️ 保持 `{url, untilMs}` 外形;`kind` 为序⑧ 追加的只读字段)。 */
cooldown: { url: string; untilMs: number; kind: RelayCooldownKind }[]
}
export class RelayFailoverSupervisor {
private readonly deps: RelayFailoverDeps
private readonly th: RelayFailoverThresholds
private readonly log: (line: string) => void
private readonly now: () => number
private readonly setTimer: (fn: () => void, ms: number) => unknown
private readonly clearTimer: (handle: unknown) => void
/** 序㉖:抖动采样表(`undefined` = 本序能力关闭 ⇒ 一切逐字回到序⑧ 行为)。 */
private readonly jitter: JitterTracker | undefined
private current: RelayChannelHandle | undefined
/** 冷却表:**键 = url**(单一事实:冷却期内该地址不可用);值是 {@link RelayCooldownEntry}(序⑧ 结构化)。 */
private readonly cooling = new Map<string, RelayCooldownEntry>()
private switches = 0
private checks = 0
private noCandidateChecks = 0
private openFailed = 0
private exemptSwitches = 0
private jitterSwitches = 0
private jitterSamples = 0
private jitterAlerts = 0
private lastJitterLogAtMs: number | undefined
private lastSwitchAtMs: number | undefined
private lastSkipLogAtMs: number | undefined
/** 序㉖:上一次记入 tracker 的样本(用于"同一份缓存读数只记一次"的门限)。 */
private lastJitterSample: { url: string; rttMs: number; atMs: number } | undefined
private timer: unknown
private running = false
private ticking = false
constructor(deps: RelayFailoverDeps) {
this.deps = deps
this.log = deps.log
this.now = deps.nowMs ?? ((): number => Date.now())
this.setTimer =
deps.setTimerImpl ??
((fn, ms): unknown => {
/**
* **默认 unref**:巡检是**后台**活动,⛔ 不许因为它把进程钉在事件循环上
* (`relay --client` / worker 这类进程本来就可能随时退出;一个 pending timer
* 会让"该退出的进程退不掉"—— 那是净退化)。
*/
const h = setTimeout(fn, ms)
;(h as { unref?: () => void }).unref?.()
return h
})
this.clearTimer = deps.clearTimerImpl ?? ((h): void => clearTimeout(h as ReturnType<typeof setTimeout>))
this.th = { ...relayFailoverThresholds({}), ...(deps.thresholds ?? {}) }
/**
* 序㉖:`null` ⇒ 关闭(逐字回到序⑧);`undefined` ⇒ 共享单例(默认,装配点零改动)。
* ⚠️ `?? ` 会把 `null` 也当成"没给",所以必须**先显式判 `null`** —— 这是本行唯一的坑。
*/
this.jitter = deps.jitterTracker === null ? undefined : deps.jitterTracker ?? sharedJitterTracker()
}
/** 当前通道(启动时由装配点灌入第一条)。 */
get channel(): RelayChannelHandle | undefined {
return this.current
}
get thresholds(): RelayFailoverThresholds {
return { ...this.th }
}
/** 灌入起始通道(启动那条,**不计数、不写 `[relay-switch]`** —— 它不是"切换")。 */
seed(handle: RelayChannelHandle | undefined): void {
this.current = handle
}
stats(): RelayFailoverStats {
const now = this.now()
return {
switches: this.switches,
checks: this.checks,
noCandidateChecks: this.noCandidateChecks,
openFailed: this.openFailed,
exemptSwitches: this.exemptSwitches,
jitterSwitches: this.jitterSwitches,
jitterSamples: this.jitterSamples,
jitterAlerts: this.jitterAlerts,
lastSwitchAtMs: this.lastSwitchAtMs,
cooldown: [...this.cooling.entries()]
.filter(([, e]) => e.untilMs > now)
.map(([url, e]) => ({ url, untilMs: e.untilMs, kind: e.kind }))
.sort((a, b) => a.untilMs - b.untilMs),
}
}
/** 是否满足"不健康"判据(**口径**:只有 `backoff` 累计才算;`handshaking` 不算)。 */
private unhealthy(h: { state: string; attempts: number; unhealthyForMs: number }): boolean {
/**
* 🔑 **`RELAY_FAILOVER_MIN_ATTEMPTS=0` = 总开关关闭**(§7 回滚第 1 层)。
* 语义必须这样定:置 0 后监管器**永不触发** ⇒ 不改代码就能回到"原地退避重试"的现状。
* ⛔ 不能理解成"`attempts >= 0` 恒真"—— 那会变成"一进 backoff 就切",是**反向**效果。
*/
if (this.th.minAttempts <= 0) return false
if (h.state !== 'backoff') return false
return h.attempts >= this.th.minAttempts || h.unhealthyForMs >= this.th.graceMs
}
/**
* **唯一的"换址"动作**(三处触发路径共用:健康巡检 / 健康巡检的**一跳豁免** / 目录地址变了)。
*
* 成功 → 关旧、记冷却、`switches += 1`、写一行 `[relay-switch]`;失败 → 原通道**原样保留**。
*
* ## 序⑧:`origin` = 「这条换址**有没有**打破冷却的权力」(D1)
* - `'directory'` ⇒ ⛔ **没有**。它的触发条件("目录里的地址变了")与"旧通道是否可用"**无关**;
* 给它豁免权 ⇒ "当前站在 106、目录首位是 47"的每一轮巡检都想把刚冷却的 47 换回来 ⇒
* **两位互相抢 = D5 想防的那个抖动风暴**(真机 11:43:26 已实测踩到)。
* - `'health'` ⇒ 有。`tick()` 稳态只用它传**已过滤掉冷却**的候选;唯一会传"冷却中目标"的场合是
* **D6 现场的一跳豁免**(见 {@link tick})—— ⚠️ 豁免**挑谁**由 `tick()` 单点判定(D4/D5),
* 本函数只做一件与豁免有关的事:**认领**它(写 `exemptedAtMs`)+ 按 D4 处理"豁免也失败"。
*/
async replace(
targetUrl: string,
reason: string,
origin: 'health' | 'directory',
): Promise<boolean> {
const old = this.current
if (old !== undefined && old.url === targetUrl) {
this.log(`[relay-skip] 目标地址与当前通道相同(${targetUrl})⇒ 不动`)
return false
}
/**
* 🔴 **冷却闸门放在这里(不是只放在 `tick()` 里)** —— 真机实测逼出来的修正:
*
* 两条触发路径共用本函数:**健康巡检**(`tick()` 已按冷却过滤候选)与
* **「目录地址变了」**(`refreshOverlay` 直接调 `replace`,**它不看冷却**)。
* 首轮真机实测(11:43:26):`wss://106… -> wss://alotbuy.com…` —— 而 `alotbuy.com` 十几分钟前
* **刚被冷却**,只是 `refreshOverlay` 的周期到了、按"地址变了"又把它换回来
* ⇒ **抖动抑制形同不存在**(D5 的意图被另一条路径绕开)。
* ⇒ 统一在这一处把关:**directory 路径**上,冷却期内的目标**一律不换**。
*
* 序⑧(D1):闸门**只对 `'directory'` 收口** —— `'health'` 传进来的"冷却中目标"就是一跳豁免本身。
*/
const now = this.now()
const entry = this.cooling.get(targetUrl)
const inCooling = entry !== undefined && entry.untilMs > now
if (inCooling && origin === 'directory') {
this.log(
`[relay-skip] 目标 ${targetUrl} 仍在冷却(剩 ${entry.untilMs - now}ms / 共 ${this.th.cooldownMs}ms)` +
`⇒ 不换(D5 防抖动;目录路径**无豁免权** D1);原因本为:${reason}`,
)
return false
}
const opened = await this.deps.open(targetUrl)
if (opened === undefined) {
this.openFailed += 1
const failAt = this.now()
/**
* 🔴 **失败的候选也必须进冷却** —— 这是真机上想清楚才补上的一条(不是理论洁癖):
*
* 生产目录的 `relays[]` = `[alotbuy.com(47), relay-direct.alotbuy.com(47), 106]`
* ——**前两条落在同一台机器上**。杀 47 时,若只排除"当前 url"、不排除"刚试失败的候选",
* 那么每次巡检都会**卡在候选②上反复失败**,**永远推进不到候选③(106)** ⇒
* 链虽然"不再退化成单点",却依然**换不过去**。
* ⇒ 语义 = 「排除 **当前** + 排除 **试过且失败的** ⇒ 取下一个」,失败项冷却 `cooldownMs` 后自动回归。
*/
if (inCooling) {
/**
* **D4 后半句**:豁免尝试也失败 ⇒ ① **重置该 url 冷却** ② **本周期不再豁免它**。
* 实现 = `exemptedAtMs = sinceMs = failAt` ⇒ `exemptedAtMs >= sinceMs` 恒真 ⇒ 本周期额度用尽。
* ⛔ 不这样写就是"每 2 s 豁免一次、每次都失败" = **重试风暴**(比不切更糟,R11)。
*/
this.cooling.set(targetUrl, {
untilMs: failAt + this.th.cooldownMs,
sinceMs: failAt,
kind: 'open-failed',
exemptedAtMs: failAt,
})
this.log(
`[relay-skip] ⛔ 豁免尝试也起不来(${targetUrl})⇒ **保持原通道**;` +
`重置该候选冷却 ${this.th.cooldownMs}ms 且**本周期不再豁免**(D4 防重试风暴)`,
)
return false
}
this.cooling.set(targetUrl, {
untilMs: failAt + this.th.cooldownMs,
sinceMs: failAt,
kind: 'open-failed',
})
this.log(
`[relay-skip] ⛔ 新通道起不来(${targetUrl})⇒ **保持原通道**(不做半途替换);` +
`该候选进冷却 ${this.th.cooldownMs}ms(否则它会把链堵死)`,
)
return false
}
this.current = opened
if (old !== undefined) {
try {
old.close()
} catch {
/* 已关 */
}
/**
* D5:**被换掉的那条进冷却**。不冷却的话,它一恢复就会被立刻换回来,
* 两台互相抢 = 抖动风暴("A 挂 → 切 B → A 恢复 → 切回 A → 再挂…")。
*/
const awayAt = this.now()
this.cooling.set(old.url, {
untilMs: awayAt + this.th.cooldownMs,
sinceMs: awayAt,
kind: 'switched-away',
})
}
/** 认领本次豁免(D4):本周期内**不再**把它当豁免对象。 */
const wasExempt = inCooling && entry !== undefined
if (wasExempt) {
this.cooling.set(targetUrl, { ...entry, exemptedAtMs: this.now() })
this.exemptSwitches += 1
}
this.switches += 1
this.lastSwitchAtMs = this.now()
this.log(
`[relay-switch] #${this.switches} ${old === undefined ? '(无)' : old.url} -> ${opened.url}` +
`(原因:${reason};冷却 ${old === undefined ? '-' : old.url} 至 +${this.th.cooldownMs}ms)` +
(wasExempt ? `|豁免 kind=${entry.kind} 剩 ${entry.untilMs - now}ms` : ''),
)
return true
}
/**
* 序㉖:从**当前通道**读一次 RTT 样本并记入 tracker(返回当前通道的 jitter,未知 ⇒ `undefined`)。
*
* 两级取值:
* ① `health().rttMs` —— 接口位(实现方愿意投影就投影);
* ② **鸭子类型兜底** `(handle).client.status().rttMs` —— 真实装配点(`src/web/server.ts` 的
* `toHandle` 与 `src/worker/relay-tunnel.ts` 的 `healthOf`)**只投影了三个字段**,而它们
* **不在序㉖ 的在册文件集**里 ⇒ 兜底读 `client.status()` 是**唯一**能让真机采到样本的路径。
* ⚠️ 代价:耦合"句柄身上挂着 client"这个装配事实 ⇒ 用**全可选 + 拿不到就返回 `undefined`**
* 兜住:拿不到只是"没样本",⛔ **不抛、不影响换址**。
*
* 🔴 **缓存门限**:`status().rttMs` 是上次心跳的结果(周期 15 s),而巡检是 2 s 一次 ⇒
* 不设门限同一个值会被反复记录、差分恒 0 ⇒ jitter 假绿。门限见 `JITTER_SAMPLE_GAP_MS`。
*/
private sampleCurrent(cur: RelayChannelHandle): number | undefined {
const j = this.jitter
if (j === undefined || !j.enabled) return undefined
let rtt = cur.health().rttMs
if (typeof rtt !== 'number' || !Number.isFinite(rtt)) {
const duck = (cur as { client?: { status?: () => { rttMs?: number } } }).client
const st = typeof duck?.status === 'function' ? duck.status() : undefined
rtt = st?.rttMs
}
if (typeof rtt !== 'number' || !Number.isFinite(rtt)) return j.jitterMs(cur.url)
const now = this.now()
const last = this.lastJitterSample
const fresh =
last === undefined ||
last.url !== cur.url ||
last.rttMs !== rtt ||
now - last.atMs >= j.thresholds().sampleGapMs
if (fresh) {
if (j.record(cur.url, rtt)) {
this.jitterSamples += 1
this.lastJitterSample = { url: cur.url, rttMs: rtt, atMs: now }
}
}
return j.jitterMs(cur.url)
}
/**
* 序㉖:**"jitter 劣化即切"**(`E2`)—— 通道**健康但抖得厉害**时,换到更稳的候选。
*
* ⛔ 三条边界(都是本线已有判据,⛔ 不许动):
* 1. **不碰冷却语义**:候选池仍要过 `blocked`(当前 + 冷却中的)⇒ jitter 换址**没有**打破
* 冷却的权力(豁免权只属于"当前这条已经挂了"那条语义)。
* 2. **没有更稳的候选 ⇒ 原地不动**(不切空、不静默回退默认机)。
* 3. 换址**仍走唯一的 {@link replace}** ⇒ `[relay-switch]` 行数与 `switches` 的相等关系
* (D7 判别器)不受影响。
*/
private async considerJitterSwitch(cur: RelayChannelHandle, curJitterMs: number | undefined): Promise<void> {
const j = this.jitter
if (j === undefined || curJitterMs === undefined) return
const jth = j.thresholds()
if (curJitterMs < jth.switchMs) return
this.jitterAlerts += 1
const now = this.now()
/** 告警按 `graceMs` 节流(否则每次巡检一行 = 日志被刷满,本线吃过这个亏)。 */
if (this.lastJitterLogAtMs === undefined || now - this.lastJitterLogAtMs >= this.th.graceMs) {
this.lastJitterLogAtMs = now
this.log(
`[relay-jitter] ⚠ 当前通道抖动量超标(p95|ΔRTT|=${curJitterMs}ms ≥ 阈值 ${jth.switchMs}ms,` +
`样本 ${this.jitterSamples} 个)⇒ 尝试换到更稳的候选(url=${cur.url})`,
)
}
let urls: readonly string[]
try {
urls = await this.deps.candidates()
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
this.log(`[relay-jitter] ⚠ 候选链解析失败(${msg})⇒ 本次不换址(原地不动)`)
return
}
const blocked = new Set<string>([cur.url])
for (const [url, e] of this.cooling) if (e.untilMs > now) blocked.add(url)
const best = pickJitterTarget({
urls,
tracker: j,
curUrl: cur.url,
curJitterMs,
switchMs: jth.switchMs,
blocked,
})
if (best === undefined) {
this.log(
`[relay-jitter] ⤵ 无更稳的候选(候选 ${urls.length} 条,可用 ${urls.filter((u) => !blocked.has(u)).length} 条)⇒ 保持当前通道`,
)
return
}
const ok = await this.replace(
best.url,
`当前通道抖动量超标(p95|ΔRTT|=${curJitterMs}ms ≥ 阈值 ${jth.switchMs}ms)且 ${best.url} 更稳(${best.jitterMs}ms)`,
'health',
)
if (ok) this.jitterSwitches += 1
}
/**
* 一次巡检:**当前通道不健康 ⇒ 换到链里的下一条**(排除当前 + 冷却中的)。
*
* ⛔ 不健康但无候选 ⇒ 序⑧ 之前是**什么都不做**(D6:原地退避,⛔ 不切到空 / 不静默回退默认机);
* 序⑧ 起:**先试一次"一跳豁免"**(D1/D4/D5),拿不到豁免对象才回到原地退避。
*
* 序㉖:**每次巡检都先采一个 RTT 样本**(不论健康与否 —— 直方图没数据就判不出"劣化"),
* 健康时额外判一次"**抖动劣化即切**"({@link considerJitterSwitch})。
*/
async tick(): Promise<void> {
this.checks += 1
const cur = this.current
if (cur === undefined) return
const h = cur.health()
const curJitterMs = this.sampleCurrent(cur)
if (!this.unhealthy(h)) {
await this.considerJitterSwitch(cur, curJitterMs)
return
}
const now = this.now()
let urls: readonly string[]
try {
urls = await this.deps.candidates()
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
this.log(`[relay-skip] ⚠ 候选链解析失败(${msg})⇒ 本次不换址(保持原地退避)`)
return
}
const blocked = new Set<string>([cur.url])
for (const [url, e] of this.cooling) if (e.untilMs > now) blocked.add(url)
/**
* ⚠️ **这段文案是 D3 的护栏本身**:稳态(有干净候选)路径上必须**逐字不变** ——
* 否则序⑦ 的 E1–E11 结论会被本单"自己推翻自己"。
*/
const reason =
`当前通道不健康(state=${h.state} attempts=${h.attempts} unhealthyForMs=${h.unhealthyForMs}` +
` ≥ 阈值 minAttempts=${this.th.minAttempts}/graceMs=${this.th.graceMs})`
/**
* 序㉖:**候选顺序 = jitter 为主序**(`E1`)。⚠️ tracker 无样本时 `orderByJitter` 返回
* **原数组本身** ⇒ 与改造前逐字一致(`D3` 的护栏因此仍然成立)。
*/
const target = orderByJitter(urls, this.jitter).find((u) => !blocked.has(u))
if (target !== undefined) {
await this.replace(target, reason, 'health')
return
}
/**
* ═══ **D6 现场**:链里除"当前 + 冷却中的"之外**没有候选** ═══
*
* 序⑧ 的立项依据正是这里:生产目录 3 条候选里有 **2 条同机**,一次 47 故障就把它们
* **同时**耗进冷却 ⇒ 杀另一台时"唯一可能的出路"被自己设的冷却挡住 ⇒
* 真机读数 `仍在冷却(剩 59201ms / 共 300000ms)` ⇒ **最长 ~300 s 不切流**。
*
* ⇒ 语义拆分(D1):**"当前这条已经挂了"有权打破自己刚设下的冷却**(一跳豁免),
* 而"目录说该换回首位"没有这个权力(见 {@link replace} 的 `origin`)。
*/
const exempt = this.pickExemptTarget(urls, cur.url, now)
if (exempt !== undefined) {
await this.replace(
exempt,
`${reason};D6 现场:**候选池已被冷却耗干** ⇒ 动用一跳豁免(每 url 每冷却周期一次,D4)`,
'health',
)
return
}
this.noCandidateChecks += 1
/**
* D6 现场证据。⚠️ 这句话**必然**会被反复写(每次巡检一次)⇒ 按 grace 节流,
* 否则日志被它刷满(本线的日志纪律:`attempts` 那种每秒一行的噪音已经吃过一次)。
*/
if (this.lastSkipLogAtMs === undefined || now - this.lastSkipLogAtMs >= this.th.graceMs) {
this.lastSkipLogAtMs = now
this.log(
`[relay-skip] ⚠ 当前通道不健康(state=${h.state} attempts=${h.attempts} ` +
`unhealthyForMs=${h.unhealthyForMs})但**链里无其他候选**(候选 ${urls.length} 条,` +
`排除 ${blocked.size} 条)⇒ 保持原地退避(⛔ 不切到空、不静默回退默认机)`,
)
}
}
/**
* **一跳豁免的"挑人"单点判据**(D1 / D4 / D5)。
*
* ⛔ **只有 D6 现场才允许调用它**(D3)—— 有干净候选时**根本不该走到这里**。
* 这条护栏由单测 **F15** 锁住:豁免一旦泄漏进正常路径,就会变成"每轮巡检都想回跳"(新抖动源)。
*
* 候选池 = `urls`(**链里真正存在**,即已签名目录给出的候选)∩ 冷却中 - 当前 url - 本周期已豁免过的。
* ⚠️ 池子**严格限定在候选链内** ⇒ ⛔ 绝不放宽成"任意 url"(那是 R5:扩大信任面 = 任意重定向)。
*
* 排序(D5):`switched-away` 优先("我们主动离开了一件**曾可用**的东西"),
* 同类按 `untilMs` **升序**(越早解除 ⇒ 越可能已经恢复)。
*/
private pickExemptTarget(
urls: readonly string[],
curUrl: string,
now: number,
): string | undefined {
if (!this.th.exempt) return undefined
const pool: { url: string; entry: RelayCooldownEntry }[] = []
for (const u of urls) {
if (u === curUrl) continue
const entry = this.cooling.get(u)
if (entry === undefined || entry.untilMs <= now) continue
/** D4:本周期已用掉豁免 ⇒ 不再挑它(否则每 2 s 一次 = 重试风暴)。 */
if (entry.exemptedAtMs !== undefined && entry.exemptedAtMs >= entry.sinceMs) continue
pool.push({ url: u, entry })
}
if (pool.length === 0) return undefined
pool.sort((a, b) => {
const ka = a.entry.kind === 'switched-away' ? 0 : 1
const kb = b.entry.kind === 'switched-away' ? 0 : 1
if (ka !== kb) return ka - kb
return a.entry.untilMs - b.entry.untilMs
})
return pool[0].url
}
/** 启动巡检(**幂等**)。周期 = `checkMs`;自链自推,不依赖调用方的定时器。 */
start(): void {
if (this.running) return
this.running = true
const loop = (): void => {
if (!this.running) return
void this.runOnce().finally(() => {
if (!this.running) return
this.timer = this.setTimer(loop, this.th.checkMs)
})
}
this.timer = this.setTimer(loop, this.th.checkMs)
}
/** 停巡检(**幂等**)。⛔ 不关通道 —— 通道归装配点管。 */
stop(): void {
this.running = false
if (this.timer !== undefined) {
this.clearTimer(this.timer)
this.timer = undefined
}
}
/** 单次巡检的重入保护(`tick()` 里会 await 网络 ⇒ 两次巡检可能交叠)。 */
private async runOnce(): Promise<void> {
if (this.ticking) return
this.ticking = true
try {
await this.tick()
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
this.log(`[relay-skip] ⚠ 巡检异常(${msg})⇒ 忽略本轮(下轮继续)`)
} finally {
this.ticking = false
}
}
}