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
This commit is contained in:
1 parent
640813e84e
commit
146c3d25ef
53 files changed
+16187
-63
No files matched your search
@@ -0,0 +1,519 @@
|
||||
/**
|
||||
* 中继失败切流 · **唯一一份实现**(覆盖网络 · 序⑦)
|
||||
*
|
||||
* ## 为什么只能是"唯一一份"
|
||||
* 本线已经吃过两次**同类**教训:`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` ⇒ 逐字回到序⑦ 行为)= 第二层回滚。
|
||||
*/
|
||||
|
||||
/** 阈值(全部来自参数表 / 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 }
|
||||
/** 关掉这条通道。**幂等**、不抛。 */
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* **冷却原因**(序⑧ / 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
|
||||
/** 最近一次成功切换的时刻(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
|
||||
|
||||
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 lastSwitchAtMs: number | undefined
|
||||
private lastSkipLogAtMs: 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 ?? {}) }
|
||||
}
|
||||
|
||||
/** 当前通道(启动时由装配点灌入第一条)。 */
|
||||
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,
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* 一次巡检:**当前通道不健康 ⇒ 换到链里的下一条**(排除当前 + 冷却中的)。
|
||||
*
|
||||
* ⛔ 不健康但无候选 ⇒ 序⑧ 之前是**什么都不做**(D6:原地退避,⛔ 不切到空 / 不静默回退默认机);
|
||||
* 序⑧ 起:**先试一次"一跳豁免"**(D1/D4/D5),拿不到豁免对象才回到原地退避。
|
||||
*/
|
||||
async tick(): Promise<void> {
|
||||
this.checks += 1
|
||||
const cur = this.current
|
||||
if (cur === undefined) return
|
||||
const h = cur.health()
|
||||
if (!this.unhealthy(h)) 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})`
|
||||
const target = urls.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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user