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)
This commit is contained in:
1 parent
04776af4b1
commit
09ce76f3af
38 files changed
+9133
-211
No files matched your search
+371
-2
@@ -46,7 +46,7 @@ import { connect } from 'node:net'
|
||||
import { networkInterfaces } from 'node:os'
|
||||
import type { Duplex } from 'node:stream'
|
||||
import { MUX, decodeMux, encodeJsonFrame, encodeMux, parseJsonPayload, type MuxFrame } from './wire.js'
|
||||
import { OPS_NETWORK, assertNetworkId } from './network.js'
|
||||
import { OPS_NETWORK, NAME_SEP, assertNetworkId, logicalName } from './network.js'
|
||||
import { signProof, publicKeyOfPrivate, type NodeGrant } from './identity.js'
|
||||
import { MuxDuplex } from './duplex.js'
|
||||
// 序④(443/TCP 兜底 · L1):地址覆盖(只依赖 node 内建,**无循环依赖**)。
|
||||
@@ -223,6 +223,83 @@ export interface RelayClientStatus {
|
||||
* ⛔ **只读观测量**:由既有状态机被动产生,**不驱动任何行为**(不改重试 / 不改退避 / 不新增定时器)。
|
||||
*/
|
||||
inGracefulBurstWindow: boolean
|
||||
/**
|
||||
* **presence 订阅视图**(序⑲)—— 订阅侧的判别器。
|
||||
*
|
||||
* 🔑 存在的理由:控制面必须能回答「**我现在到底还在不在推送上**」—— 否则"订阅静默失效"
|
||||
* 与"这张网里确实没人"完全同形(本线头号教训)。判据 = `state==='subscribed'` +
|
||||
* `lastSnapAgoMs/lastPushAgoMs` 的**新鲜度**。
|
||||
*/
|
||||
presence: RelayPresenceStatus
|
||||
}
|
||||
|
||||
/** presence 订阅的**连接期状态**(⛔ 不是持久订阅:连接一断,`state` 回 `idle`)。 */
|
||||
export type RelayPresenceState =
|
||||
/** 未订阅(默认)。 */
|
||||
| 'idle'
|
||||
/** 已发 `SUB`、等首帧 `SNAP`(这一段是"可能静默"的唯一窗口 ⇒ 必须可观测)。 */
|
||||
| 'pending'
|
||||
/** 已拿到 `SNAP`:推送上。 */
|
||||
| 'subscribed'
|
||||
/**
|
||||
* 对端**不支持** SUB(老 relay:未知帧号 ⇒ 直接关连接)⇒ **已停止再试**。
|
||||
* 存在的意义:滚动升级期间新客户端遇到旧 relay 时,⛔ 不许把连接反复踢死。
|
||||
*/
|
||||
| 'unsupported'
|
||||
|
||||
/** 一条在线态记录(与 `server.ts` 的 `PresenceEntry` 同形,这里只做结构性约束)。 */
|
||||
export interface RelayPresenceEntry {
|
||||
name: string
|
||||
hostId: string
|
||||
network: string
|
||||
online: boolean
|
||||
devices: number
|
||||
ports: number[]
|
||||
/** 每个声明端口在 **relay 本机**的回环落点(`0` = 无)—— 让订阅路径也能供地址(见服务端注释)。 */
|
||||
localPorts: { port: number; localPort: number }[]
|
||||
lastSeenAgoMs: number
|
||||
offlineInMs?: number
|
||||
changedAt: number
|
||||
}
|
||||
|
||||
export interface RelayPresenceStatus {
|
||||
state: RelayPresenceState
|
||||
/** 订阅范围:`all` = 本网全部;数字 = 点名订阅的个数(`idle`/`unsupported` ⇒ `0`)。 */
|
||||
scope: 'all' | number
|
||||
/** 最近一次收到 `SNAP` 距今多久(ms);从未收到 ⇒ `undefined`(⛔ 别用 0 冒充"刚收到")。 */
|
||||
lastSnapAgoMs?: number
|
||||
/** 最近一次收到 `PRESENCE` 增量距今多久(ms);从未收到 ⇒ `undefined`。 */
|
||||
lastPushAgoMs?: number
|
||||
/**
|
||||
* **最近一次入站帧**(**任何**帧,含心跳 `PING`/`PONG`)距今多久(ms)—— `undefined` = 还没收到过。
|
||||
*
|
||||
* 🔑 门的判据看**它**(链路活没活),⛔ 不看上面两个"载荷年龄"(见 `presenceFresh()` 的 P-1 说明)。
|
||||
*/
|
||||
lastInboundAgoMs?: number
|
||||
/** 门的**入站静默上界**(ms):`max(服务端下发 TTL, 半开阈值)` —— 超过它才允许判"不新鲜"。 */
|
||||
linkSilentMaxMs: number
|
||||
/** **门此刻的判定结果**(= `presenceFresh()`):让"门为什么开着/关着"一眼可判,⛔ 不静默。 */
|
||||
fresh: boolean
|
||||
/** 当前缓存里的在线态条数(**本地镜像**:订阅方读它,⛔ 不自己推导)。 */
|
||||
entries: number
|
||||
/** 当前缓存中 `online === true` 的条数(一眼看出"推送有没有真的更新过")。 */
|
||||
onlineEntries: number
|
||||
/** 收到的 `SNAP` 帧数。 */
|
||||
snapFrames: number
|
||||
/** 收到的 `PRESENCE` 增量帧数(**稳态必须停住不走** —— E1 的机器可读判据)。 */
|
||||
pushFrames: number
|
||||
/**
|
||||
* **最近一帧带了几条记录**。
|
||||
*
|
||||
* 🔑 存在的理由:`pushFrames` 只回答"推了几帧",答不了"一帧里装了几条" —— 而"批合并真的生效"
|
||||
* 恰恰是后半句(一帧带 host 数组,⛔ 不是逐个 host 一条)。没有它,"6 台同时上线合并成 1 帧"
|
||||
* 与"6 台各推 1 帧"在计数上无法区分。
|
||||
*/
|
||||
lastFrameEntries: number
|
||||
/** 被服务端**显式拒绝**的订阅次数(跨网 / 越界;⛔ 静默返空不算)。 */
|
||||
rejected: number
|
||||
/** `SUB` 发出后"连接掉了都没等到 `SNAP`"的次数(老 relay 的指纹)⇒ 达阈值判 `unsupported`。 */
|
||||
subFailures: number
|
||||
}
|
||||
|
||||
interface LocalStream {
|
||||
@@ -254,6 +331,12 @@ interface DialStream {
|
||||
const DEFAULT_QUEUE_MAX = 1 << 20
|
||||
const DEFAULT_HIGH_WATER = 256 * 1024
|
||||
const FLUSH_INTERVAL_MS = 20
|
||||
/**
|
||||
* presence TTL 的**保守默认**(ms)—— 只在"还没收到过 `SNAP`(因此没拿到服务端下发的 TTL)"
|
||||
* 时用。取值 = 服务端默认 `HB_SEC(15s) × 3`(`server.ts#DEFAULT_PRESENCE_TTL_FACTOR`)。
|
||||
* ⚠️ 它只影响"何时回退 `/status`",⛔ 不参与任何在线态判定 ⇒ 宁可短(早回退)也不许长。
|
||||
*/
|
||||
const DEFAULT_PRESENCE_TTL_MS = 45_000
|
||||
|
||||
export class RelayClient {
|
||||
private readonly opts: RelayClientOptions
|
||||
@@ -346,6 +429,23 @@ export class RelayClient {
|
||||
private bytesIn = 0
|
||||
private bytesOut = 0
|
||||
|
||||
/* ── presence(序⑲):订阅 + 本地镜像 ── */
|
||||
/** 订阅范围(`undefined` = 未订阅)。`'all'` = 本网全部;数组 = 点名的逻辑名。 */
|
||||
private presenceSub: 'all' | string[] | undefined
|
||||
/** 本地镜像:**订阅方唯一该读的在线态来源**(键 = 逻辑名)。 */
|
||||
private readonly presenceMap = new Map<string, RelayPresenceEntry>()
|
||||
private presenceState: RelayPresenceState = 'idle'
|
||||
/** 发过 `SUB` 但还没拿到 `SNAP` 的时刻;`0` = 没有在途订阅请求。 */
|
||||
private subSentAt = 0
|
||||
/** 服务端下发的 presence TTL(ms);未收到过 `SNAP` ⇒ 用默认值(保守)。 */
|
||||
private presenceTtlMs = DEFAULT_PRESENCE_TTL_MS
|
||||
private snapFrames = 0
|
||||
private pushFrames = 0
|
||||
private lastFrameEntries = 0
|
||||
private presenceRejected = 0
|
||||
private subFailures = 0
|
||||
private lastSnapAt = 0
|
||||
private lastPushAt = 0
|
||||
constructor(opts: RelayClientOptions) {
|
||||
this.opts = opts
|
||||
this.allow = new Set(opts.ports)
|
||||
@@ -428,9 +528,229 @@ export class RelayClient {
|
||||
unhealthySinceMs: this.unhealthySince,
|
||||
unhealthyForMs: this.unhealthySince === undefined ? 0 : Math.max(0, now - this.unhealthySince),
|
||||
inGracefulBurstWindow: this.state === 'backoff' && this.burstUntil !== undefined && now < this.burstUntil,
|
||||
presence: this.presenceStatus(),
|
||||
}
|
||||
}
|
||||
|
||||
/* ═══════════ presence 订阅(序⑲)═══════════ */
|
||||
|
||||
/**
|
||||
* **订阅在线态**(`瓶颈落地方案 §1` 第 3 条:订阅式扇出,只推给"正在看的人")。
|
||||
*
|
||||
* `hosts` 省略 ⇒ 订阅**本网全部**(`all`)。订阅**只活在连接期间**(第 3 条)⇒
|
||||
* 断线重连后由客户端自己重发,⛔ 服务端不保存任何持久订阅。
|
||||
*
|
||||
* ⚠️ **可重复调用**:调用即"以最后一次为准"(改范围会先发 `UNSUB` 再发 `SUB`),
|
||||
* 这比"调用两次报错"更符合控制面"周期对账"的用法。
|
||||
*/
|
||||
subscribePresence(hosts?: readonly string[]): void {
|
||||
if (this.presenceState === 'unsupported') return
|
||||
this.presenceSub = hosts === undefined ? 'all' : hosts.map((h) => this.qualify(h))
|
||||
if (this.state !== 'up') {
|
||||
// 还没连上 ⇒ 记下范围,`onHelloAck` 起来时会自动发(⛔ 不在握手前发,那会被当未认证帧)。
|
||||
this.presenceState = 'pending'
|
||||
return
|
||||
}
|
||||
this.sendSubscribe()
|
||||
}
|
||||
|
||||
/** **退订**(幂等)。控制面在"不再需要推送"时调用它 ⇒ `/status` 轮询会自动恢复(兜底路径)。 */
|
||||
unsubscribePresence(): void {
|
||||
const had = this.presenceSub !== undefined
|
||||
this.presenceSub = undefined
|
||||
this.presenceState = 'idle'
|
||||
this.subSentAt = 0
|
||||
this.presenceMap.clear()
|
||||
if (had && this.state === 'up') this.send(encodeJsonFrame(MUX.UNSUB, 0, { all: true }))
|
||||
}
|
||||
|
||||
/**
|
||||
* presence 视图 —— 控制面读它(⛔ **不要自己另建一份在线态**,否则就是双权威)。
|
||||
*
|
||||
* `entries` 只在订阅生效期间有值;`state !== 'subscribed'` ⇒ 调用方**必须**回退 `/status`
|
||||
* (D5:`/status` 是兜底路径,不是废弃路径)。
|
||||
*/
|
||||
presenceStatus(): RelayPresenceStatus {
|
||||
const now = this.now()
|
||||
let onlineEntries = 0
|
||||
for (const e of this.presenceMap.values()) if (e.online) onlineEntries += 1
|
||||
return {
|
||||
state: this.presenceState,
|
||||
scope: this.presenceSub === undefined ? 0 : this.presenceSub === 'all' ? 'all' : this.presenceSub.length,
|
||||
lastSnapAgoMs: this.lastSnapAt === 0 ? undefined : now - this.lastSnapAt,
|
||||
lastPushAgoMs: this.lastPushAt === 0 ? undefined : now - this.lastPushAt,
|
||||
// ⚠️ `lastFrameAt` 只在 `up` 期间被刷新;`idle` 时它可能是上一轮的残值 ⇒ 只在 `up` 时报。
|
||||
lastInboundAgoMs: this.state === 'up' ? now - this.lastFrameAt : undefined,
|
||||
linkSilentMaxMs: this.presenceLinkSilentMaxMs(),
|
||||
fresh: this.presenceFresh(),
|
||||
entries: this.presenceMap.size,
|
||||
onlineEntries,
|
||||
snapFrames: this.snapFrames,
|
||||
pushFrames: this.pushFrames,
|
||||
lastFrameEntries: this.lastFrameEntries,
|
||||
rejected: this.presenceRejected,
|
||||
subFailures: this.subFailures,
|
||||
}
|
||||
}
|
||||
|
||||
/** 本地在线态镜像(键 = 逻辑名);未订阅 ⇒ 空数组(调用方据此回退 `/status`)。 */
|
||||
presenceEntries(): RelayPresenceEntry[] {
|
||||
return [...this.presenceMap.values()]
|
||||
}
|
||||
|
||||
/** 单条查询:`undefined` = **不知道**(⛔ 与"离线"必须能分开 —— 否则会把未知当死)。 */
|
||||
presenceOf(name: string): RelayPresenceEntry | undefined {
|
||||
return this.presenceMap.get(name)
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅**可用**(= 允许拿这份镜像替代 `/status` 兜底)—— D5「主路径 / 兜底」的**唯一开关**。
|
||||
*
|
||||
* 🔑 判据 = 「**订阅已建立 ∧ 链路活着**」,⛔ **不是**「presence 载荷新鲜度」。
|
||||
*
|
||||
* 为什么必须这样定(在册缺陷 **P-1**,2026-09-17 序 ⑳ 实测):presence 是**变化驱动**的
|
||||
* —— 稳态(无状态变化)下一帧都不推(`pushed/snaps` 自 relay 起就恒为 1,这是**设计属性**、
|
||||
* ⛔ 不是故障)。若把门的判据定成"最近一次 presence 载荷距今 ≤ TTL",那"**没有变化**"就会被
|
||||
* 读成"**没有数据**" ⇒ 45 s 后门必然重开、`/status` 轮询照旧在跑 ⇒ 核心收益归零
|
||||
* (实测降幅仅 **1.10×**,而设计目标 ≥ 10×;门开关占空比 5.0% ⇒ 理论降幅 1.05×,吻合)。
|
||||
*
|
||||
* 镜像的**有效性**本来就不靠"载荷多久没来",靠两件事:
|
||||
* ① **订阅已建立**(`state === 'subscribed'`,即拿到过首帧全量 `SNAP`);
|
||||
* ② **通道有序可靠**(WS over TCP)⇒ relay 侧每一次变化**必然**以 `PRESENCE` 帧按序到达,
|
||||
* 不会漏、不会乱序 ⇒ 载荷的**年龄与内容正确性无关**。
|
||||
* 只有**链路死**才会让镜像失效,而链路死由半开巡检兜(`2.5 × hbSec` 内**没有任何**入站帧
|
||||
* ⇒ 主动断链重连);断链时 `presenceState` 立刻离开 `subscribed` ⇒ 本函数随即回 `false`
|
||||
* ⇒ 上层必然回退 `/status`(D5 的兜底路径,⛔ 一行都没删)。
|
||||
*
|
||||
* `ttlMs`(服务端下发,生产 45 s)在此退化为**入站静默上界**的一员:与半开阈值取大
|
||||
* (`presenceLinkSilentMaxMs`)—— 兜"半开巡检的 tick 还没到"的那一个极窄窗口,
|
||||
* ⛔ 不再当"载荷新鲜度"用。
|
||||
*
|
||||
* ⚠️ **已知残余**(写进交接单,不在此处兜):relay 侧**静默**清掉订阅而 socket 仍活 ⇒ 本判据
|
||||
* 察觉不到。当前代码里这条路径**不可达**(唯一清空 `session.subs` 的是显式 `UNSUB`;relay 重启 /
|
||||
* 会话回收都会断 socket ⇒ 走 ①/② 的路径被发现)。一旦真出现,正解 = 心跳帧携带订阅态断言,
|
||||
* 或周期性 `SNAP` 复核(⛔ 不靠缩短 TTL)。
|
||||
*/
|
||||
presenceFresh(ttlMs?: number): boolean {
|
||||
if (this.presenceState !== 'subscribed') return false
|
||||
// 没拿到过首帧全量 ⇒ 镜像没有权威来源(空表 ≠ "这张网里没人")。
|
||||
if (this.lastSnapAt === 0) return false
|
||||
return this.now() - this.lastFrameAt <= this.presenceLinkSilentMaxMs(ttlMs)
|
||||
}
|
||||
|
||||
/**
|
||||
* 门的**入站静默上界**(ms)。
|
||||
*
|
||||
* 🔴 取 `max(服务端 TTL, 半开阈值)` 的理由:上界**不得比链路巡检更紧** —— 否则门会比
|
||||
* "链路真的死了"更早打开(那就是把 P-1 换个方向重犯:拿一个与镜像有效性无关的时钟当判据)。
|
||||
* ⚠️ 测试档把 TTL 压到 3 s 而心跳仍是 15 s ⇒ 若只用 TTL,测试里门会无谓地开合(假红源)。
|
||||
*/
|
||||
private presenceLinkSilentMaxMs(ttlMs?: number): number {
|
||||
const bound = ttlMs ?? this.presenceTtlMs ?? DEFAULT_PRESENCE_TTL_MS
|
||||
return Math.max(bound, this.halfOpenMs())
|
||||
}
|
||||
|
||||
/** **半开阈值**(ms)—— 唯一来源:半开巡检与门判据共用,⛔ 不引入第二个时间口径。 */
|
||||
private halfOpenMs(): number {
|
||||
return this.opts.halfOpenMs ?? Math.max(3_000, Math.round(this.hbSec * 1_000 * 2.5))
|
||||
}
|
||||
|
||||
private qualify(h: string): string {
|
||||
// 裸 `hostId` 按本网补全;给了完整逻辑名就**原样保留**(跨网与否由服务端判,这里不猜)。
|
||||
return h.includes(NAME_SEP) ? h : logicalName(this.network, h)
|
||||
}
|
||||
|
||||
private sendSubscribe(): void {
|
||||
if (this.presenceSub === undefined) return
|
||||
const payload = this.presenceSub === 'all' ? { all: true } : { hosts: this.presenceSub }
|
||||
this.subSentAt = this.now()
|
||||
if (this.presenceState !== 'subscribed') this.presenceState = 'pending'
|
||||
this.send(encodeJsonFrame(MUX.SUB, 0, payload))
|
||||
}
|
||||
|
||||
/** 重连后重订阅(第 6 条:**重连后必须重新拉一次全量** —— 首帧 `SNAP` 就是那个全量)。 */
|
||||
private resendPresenceSub(): void {
|
||||
if (this.presenceSub === undefined || this.presenceState === 'unsupported') return
|
||||
this.presenceMap.clear()
|
||||
this.sendSubscribe()
|
||||
}
|
||||
|
||||
/**
|
||||
* `SNAP` —— **首帧即全量**(第 6 条,⛔ 无 N+1):整表替换而不是增量合并。
|
||||
*
|
||||
* 为什么必须整表替换:relay 重启后回环口号会全部重分配,增量合并会把过期条目永远留下
|
||||
* (这正是 ssh 版"静默打到别人实例"的同族病,`web/server.ts#refreshRelay` 已为轮询路径
|
||||
* 踩过一次 ⇒ 订阅路径不许复现)。
|
||||
*/
|
||||
private onSnapFrame(frame: MuxFrame): void {
|
||||
const msg = parseJsonPayload(frame.payload)
|
||||
if (msg === null || msg.ok !== true) {
|
||||
this.log('[relay-client] ⛔ SNAP 负载非法 ⇒ 忽略(不回退状态机,等下一个帧)')
|
||||
return
|
||||
}
|
||||
this.presenceMap.clear()
|
||||
for (const e of toPresenceEntries(msg.entries)) this.presenceMap.set(e.name, e)
|
||||
// TTL 口径由**服务端**下发(⛔ 客户端不写死数字:写死就会在服务端调参后悄悄不一致)。
|
||||
if (typeof msg.ttlMs === 'number' && msg.ttlMs > 0) this.presenceTtlMs = msg.ttlMs
|
||||
this.snapFrames += 1
|
||||
this.lastSnapAt = this.now()
|
||||
this.subSentAt = 0
|
||||
this.presenceState = 'subscribed'
|
||||
this.lastFrameEntries = this.presenceMap.size
|
||||
this.log(`[relay-client] presence SNAP ${this.presenceMap.size} 条(scope=${this.presenceSub === 'all' ? 'all' : (this.presenceSub?.length ?? 0)})`)
|
||||
}
|
||||
|
||||
/** `PRESENCE` 增量(或**显式拒绝**)。一帧带数组 ⇒ 就地合并。 */
|
||||
private onPresenceFrame(frame: MuxFrame): void {
|
||||
const msg = parseJsonPayload(frame.payload)
|
||||
if (msg === null) {
|
||||
this.log('[relay-client] ⛔ PRESENCE 负载非法 ⇒ 忽略')
|
||||
return
|
||||
}
|
||||
if (msg.ok !== true) {
|
||||
// 🔴 **显式拒绝**必须计数 + 响亮:静默返空与"本网没人"完全同形(本线头号教训)。
|
||||
this.presenceRejected += 1
|
||||
const why = typeof msg.error === 'string' ? msg.error : 'unknown'
|
||||
this.log(`[relay-client] presence ⛔ 订阅被拒:${why}(rejected=${this.presenceRejected})`)
|
||||
this.presenceState = 'idle'
|
||||
this.presenceSub = undefined
|
||||
return
|
||||
}
|
||||
const entries = toPresenceEntries(msg.entries)
|
||||
for (const e of entries) this.presenceMap.set(e.name, e)
|
||||
this.pushFrames += 1
|
||||
this.lastFrameEntries = entries.length
|
||||
this.lastPushAt = this.now()
|
||||
if (this.presenceState !== 'subscribed') {
|
||||
this.presenceState = 'subscribed'
|
||||
this.subSentAt = 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 连接掉了 ⇒ presence 订阅**随之消失**(第 3 条),必须让上层能看见这件事。
|
||||
*
|
||||
* 🔴 **老 relay 的指纹**:发了 `SUB` 却**没等到 `SNAP` 连接就掉了** —— 未知帧号会直接关连接。
|
||||
* 达阈值 ⇒ 判 `unsupported` 并**停止再试**:滚动升级期间"新客户端 + 旧 relay"绝不能变成
|
||||
* "连接被反复踢死"(那会把升级顺序依赖变成生产事故)。
|
||||
*/
|
||||
private onPresenceDown(): void {
|
||||
if (this.subSentAt !== 0) {
|
||||
this.subFailures += 1
|
||||
this.subSentAt = 0
|
||||
if (this.subFailures >= 2) {
|
||||
this.presenceState = 'unsupported'
|
||||
this.log(
|
||||
`[relay-client] presence ⛔ 对端不认 SUB(${this.subFailures} 次"发了 SUB 没等到 SNAP 就断")⇒ 停止订阅,改由 /status 兜底`,
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
if (this.presenceSub !== undefined) this.presenceState = 'pending'
|
||||
// ⚠️ **保留**上一次的 entries 但把新鲜度打掉:`presenceFresh()` 会因为 state!=='subscribed'
|
||||
// 直接回 false ⇒ 上层必然回退 `/status`,而不会拿着过期数据当事实。
|
||||
}
|
||||
|
||||
/* ═══════════ 连接生命周期 ═══════════ */
|
||||
|
||||
private now(): number {
|
||||
@@ -556,6 +876,8 @@ export class RelayClient {
|
||||
}
|
||||
}
|
||||
this.setState('backoff')
|
||||
// presence:订阅随连接消失(第 3 条)⇒ 必须让上层看得见"现在已经不在推送上了"。
|
||||
this.onPresenceDown()
|
||||
if (this.stopped) return
|
||||
this.scheduleRetry(wasUp ? `${why} (was up)` : why, graceful, fixedDelayMs)
|
||||
}
|
||||
@@ -640,7 +962,7 @@ export class RelayClient {
|
||||
*/
|
||||
private startHealthWatch(): void {
|
||||
clearInterval(this.healthTimer)
|
||||
const halfOpen = this.opts.halfOpenMs ?? Math.max(3_000, Math.round(this.hbSec * 1_000 * 2.5))
|
||||
const halfOpen = this.halfOpenMs()
|
||||
const tick = Math.max(500, Math.round(halfOpen / 4))
|
||||
const timer = setInterval(() => {
|
||||
if (this.stopped || this.state !== 'up') return
|
||||
@@ -753,6 +1075,12 @@ export class RelayClient {
|
||||
this.pingSentAt = undefined
|
||||
}
|
||||
return
|
||||
case MUX.SNAP:
|
||||
this.onSnapFrame(frame)
|
||||
return
|
||||
case MUX.PRESENCE:
|
||||
this.onPresenceFrame(frame)
|
||||
return
|
||||
default:
|
||||
this.log(`[relay-client] unknown mux type=${frame.type} ⇒ reconnect`)
|
||||
this.onDown('unknown frame type')
|
||||
@@ -811,6 +1139,8 @@ export class RelayClient {
|
||||
for (const p of this.opts.ports) this.allow.add(p)
|
||||
for (const p of this.dynamicPorts) this.allow.add(p)
|
||||
if (this.dynamicPorts.size > 0) void this.replayDynamicPorts()
|
||||
// presence(第 6 条):**重连必须重新拉一次全量** —— 见 `resendPresenceSub`。
|
||||
this.resendPresenceSub()
|
||||
}
|
||||
|
||||
/** 把运行期端口重新声明一遍(重连后调用;逐条独立,单条失败不影响其余)。 */
|
||||
@@ -1314,6 +1644,45 @@ function errText(err: unknown): string {
|
||||
return err instanceof Error ? err.message : String(err)
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 `SNAP` / `PRESENCE` 负载里的 `entries` 数组**逐条校验**后再采用(序⑲)。
|
||||
*
|
||||
* 为什么逐条校验而不是 `as RelayPresenceEntry[]`:这是**跨进程**来的数据(WS 帧),
|
||||
* 一条字段缺失的条目如果被直接当成事实,症状是"某台机器永远显示离线" ——
|
||||
* 与本线反复踩的"静默失败"同族(宁可**丢这一条**并留着上一条,也不采用半截数据)。
|
||||
*/
|
||||
function toPresenceEntries(raw: unknown): RelayPresenceEntry[] {
|
||||
if (!Array.isArray(raw)) return []
|
||||
const out: RelayPresenceEntry[] = []
|
||||
for (const item of raw) {
|
||||
if (item === null || typeof item !== 'object') continue
|
||||
const e = item as Record<string, unknown>
|
||||
if (typeof e.name !== 'string' || e.name === '') continue
|
||||
if (typeof e.hostId !== 'string' || typeof e.network !== 'string') continue
|
||||
if (typeof e.online !== 'boolean') continue
|
||||
out.push({
|
||||
name: e.name,
|
||||
hostId: e.hostId,
|
||||
network: e.network,
|
||||
online: e.online,
|
||||
devices: typeof e.devices === 'number' ? e.devices : 0,
|
||||
ports: Array.isArray(e.ports) ? e.ports.filter((p): p is number => typeof p === 'number') : [],
|
||||
localPorts: Array.isArray(e.localPorts)
|
||||
? (e.localPorts as unknown[]).flatMap((raw) => {
|
||||
if (raw === null || typeof raw !== 'object') return []
|
||||
const lp = raw as { port?: unknown; localPort?: unknown }
|
||||
if (typeof lp.port !== 'number' || typeof lp.localPort !== 'number') return []
|
||||
return [{ port: lp.port, localPort: lp.localPort }]
|
||||
})
|
||||
: [],
|
||||
lastSeenAgoMs: typeof e.lastSeenAgoMs === 'number' ? e.lastSeenAgoMs : 0,
|
||||
offlineInMs: typeof e.offlineInMs === 'number' ? e.offlineInMs : undefined,
|
||||
changedAt: typeof e.changedAt === 'number' ? e.changedAt : 0,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** 便于诊断:把 client 当前状态压成一行(`systemctl status` / 日志里直接可读)。 */
|
||||
export function describeClientStatus(s: RelayClientStatus): string {
|
||||
const retry = s.nextRetryMs === undefined ? '' : ` nextRetryIn=${Math.round(s.nextRetryMs)}ms`
|
||||
|
||||
Reference in new issue
Block a user