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:
admin committed 2026-09-18 08:08:51 +08:00
1 parent 04776af4b1
commit 09ce76f3af
38 files changed
+9133 -211

No files matched your search

+335
View File
@@ -0,0 +1,335 @@
/**
* 覆盖网络 · **链路抖动(jitter)采样 · 直方图 · 选路主序**(序 ㉖ · 骨干稳定选路与加密)。
*
* ## 为什么需要它(用户口径 → 机器判据)
*
* 用户 2026-09-17 20:2x 的口径是「**按照连接稳定高效的方式 数据安全可加密传输**」。翻译成
* 可验证的工程质量判据就是 **"选路看 jitter、⛔ 不看 RTT"**:
*
* - 改造前:`directory.ts` 的候选序 = **签名目录发布序**(同源优先插首位),`switcher.ts` 的换址
* = **排除当前 + 排除冷却中的 ⇒ 取链里下一个** ⇒ 两处**都没有**"哪条更稳"这个维度。
* - 后果:一条 RTT 低但**抖得厉害**的路径会长期霸占首位 —— 而"稳定"恰恰是交互式会话的第一诉求
* (RTT 高只是慢,jitter 高是**卡顿/超时/断连**)。
*
* ## 三条口径(⛔ 改这三条等于改判据,必须同步改参数表)
*
* 1. **量 = `|ΔRTT|` 的 p95**(相邻两次心跳往返之差的绝对值),与 `scripts/overlay-jitter.cjs`
* 序⑥ 实测所用的**同一个量**(`p95AbsDelta`)⇒ 历史读数(`p95 = 3 ms`)与本模块**同口径可比**。
* 2. **序 = jitter 升序**,且 **只对"已测出样本"的候选生效**;**无样本者保原序排在其后**
* (⛔ 不惩罚"还没测过的备用中继",也不凭空给它排位 —— 见 {@link orderByJitter})。
* 3. **零样本 ⇒ 逐字返回原数组**(⛔ 这是零回归的机器判据 `D9`:观测器没喂过数,
* 行为必须与改造前**逐字一致**)。
*
* ## 为什么只有这一份算法
*
* relay 侧(`server.ts` 的每会话 RTT)与平台侧(`switcher.ts` 的选路)**共用**本模块的
* `absDeltas` / `percentile` / `histogram` —— 本线的教训是「**另一份实现 = 另一处静默失效**」
* (取址链踩过两次)。⛔ 不许在 `scripts/**` 或别的模块里再写一份 p95/直方图。
*
* ## 阈值来源(⛔ 全部来自参数表,模块内零魔数)
*
* `JITTER_ENABLE` / `JITTER_SAMPLE_MAX` / `JITTER_MIN_SAMPLES` / **`JITTER_LIMIT_MS`** /
* `JITTER_HIST_MAX_MS` / `JITTER_HIST_BUCKETS` / `JITTER_SAMPLE_GAP_MS`
* (口径见 `参数表_覆盖网络_20260917.md §3.7`)。
*
* 🔴 **劣化阈值复用 `JITTER_LIMIT_MS`(⛔ 不新造 `JITTER_SWITCH_MS`)**:该键在序⑥ 就已登记
* (值 `20 ms`,语义 = `p95(|ΔRTT|)` 的**达标限值**)—— "超标"与"劣化到该换路"是**同一件事**
* ⇒ 新造一个同值键只会变成"同一事实两处写"(本线的知识碎片化教训)。
*
* @module src/net/relay/jitter
*/
/** 本模块的阈值(**全部**来自参数表;见模块头)。 */
export interface JitterThresholds {
/** 总开关(`JITTER_ENABLE`,默认 `1`)。置 `0` ⇒ 采样与排序**全部失效**回到改造前行为。 */
enabled: boolean
/** 每个 url 保留的 RTT 样本上限(环状,老的丢弃)。 */
sampleMax: number
/** 参与排序 / 劣化判定所需的**最小 |ΔRTT| 样本数**(不足 ⇒ 该 url 视为"未知")。 */
minSamples: number
/**
* **劣化阈值**:`p95(|ΔRTT|) ≥ 它` ⇒ 这条路径被判"不稳",允许换到更稳的候选。
*
* ⚠️ 来源 = 参数表的 **`JITTER_LIMIT_MS`**(序⑥ 就有的"达标限值";⛔ 不是新键)。
*/
switchMs: number
/** 直方图上界(`≥ 它` 的样本落进末桶)。 */
histMaxMs: number
/** 直方图桶数(**固定值**;探针拿它校验"口径一致",⛔ 不是实现细节)。 */
histBuckets: number
/**
* **同一份缓存读数的最小采样间隔(ms)**。
*
* 🔴 为什么必须有这个键:`RelayClient.status().rttMs` 是**上一次心跳的结果**,而心跳周期
* (`HB_SEC = 15 s`)远大于巡检周期(`checkMs = 2 s`)⇒ 不做门限的话**同一个 RTT 值会被
* 反复记录** ⇒ 差分恒为 0 ⇒ **jitter 被系统性低估到 0**(判据假绿)。
* ⇒ 门限必须 **> 心跳周期**:同一份缓存值最多只贡献一个样本。
*
* ⚠️ relay 侧(`server.ts`)**不用**这个键 —— 它在 `PONG` 到达那一刻采样,本来就是新测量。
*/
sampleGapMs: number
}
/**
* 从 env 读阈值(**值格必须纯数字**;非纯数字一律回退默认值)。
*
* ⚠️ 与 `relayFailoverThresholds` 同款纪律:参数表里写 `1` / `0`,⛔ **不许**写 `true` 或
* 带夹注的 `1(默认)` —— 后者解析失败会**静默回退默认值**(本线已踩过一次)。
*/
export function jitterThresholds(env: Record<string, string | undefined> = process.env): JitterThresholds {
const num = (key: string, dflt: number): number => {
const raw = (env[key] ?? '').trim()
if (raw === '') return dflt
return /^\d+$/.test(raw) ? Number(raw) : dflt
}
return {
enabled: num('JITTER_ENABLE', 1) !== 0,
sampleMax: num('JITTER_SAMPLE_MAX', 32),
minSamples: num('JITTER_MIN_SAMPLES', 3),
switchMs: num('JITTER_LIMIT_MS', 20),
histMaxMs: num('JITTER_HIST_MAX_MS', 200),
histBuckets: num('JITTER_HIST_BUCKETS', 8),
sampleGapMs: num('JITTER_SAMPLE_GAP_MS', 20_000),
}
}
/**
* 相邻样本的一阶差分绝对值 = **抖动量**(⛔ 不是标准差)。
*
* 🔴 为什么不用标准差:标准差会把"单调漂移"(排队时延缓慢变化)算成抖动,而交互式会话真正
* 怕的是**相邻两拍之间的突变**(卡一下)。序⑥ 的实测口径同样是相邻差分 ⇒ 保持一致。
*/
export function absDeltas(samples: readonly number[]): number[] {
const out: number[] = []
for (let i = 1; i < samples.length; i++) {
const d = Math.abs(samples[i]! - samples[i - 1]!)
if (Number.isFinite(d)) out.push(d)
}
return out
}
/**
* 百分位(**与 `scripts/overlay-jitter.cjs` 逐字同口径**:`sorted[min(len-1, floor(len·p))]`)。
*
* ⚠️ 口径必须与脚本一致,否则"实时选路看到的 p95"与"运维点测的 p95"会给出**两个数**。
*/
export function percentile(values: readonly number[], p: number): number {
if (values.length === 0) return 0
const s = [...values].sort((a, b) => a - b)
return s[Math.min(s.length - 1, Math.floor(s.length * p))]!
}
/** 直方图(等宽桶;`≥ histMaxMs` 落末桶)。返回值长度**恒等于** `buckets`。 */
export function histogram(deltas: readonly number[], histMaxMs: number, buckets: number): number[] {
const out = new Array<number>(Math.max(1, buckets)).fill(0)
if (histMaxMs <= 0) return out
const n = out.length
for (const d of deltas) {
const idx = Math.min(n - 1, Math.max(0, Math.floor((d / histMaxMs) * n)))
out[idx] = (out[idx] ?? 0) + 1
}
return out
}
/** 一组样本的抖动画像。 */
export interface JitterStats {
/** 已采到的 RTT 样本数。 */
samples: number
/** `|ΔRTT|` 样本数(= `samples - 1`,样本不足 2 时为 0)。 */
deltas: number
p95AbsDeltaMs: number
meanAbsDeltaMs: number
maxAbsDeltaMs: number
/** 直方图(长度 = `histBuckets`)。 */
hist: number[]
}
/** 由**已在别处算好的差分序列**构造画像(relay 侧多会话合并时用;⛔ 不重复实现统计)。 */
export function statsFromDeltas(deltas: readonly number[], th: JitterThresholds): JitterStats {
const sum = deltas.reduce((a, b) => a + b, 0)
return {
samples: deltas.length + 1,
deltas: deltas.length,
p95AbsDeltaMs: round2(percentile(deltas, 0.95)),
meanAbsDeltaMs: deltas.length === 0 ? 0 : round2(sum / deltas.length),
maxAbsDeltaMs: deltas.length === 0 ? 0 : round2(Math.max(...deltas)),
hist: histogram(deltas, th.histMaxMs, th.histBuckets),
}
}
function round2(v: number): number {
return Math.round(v * 100) / 100
}
/**
* **每个 url 一份的 RTT 样本环 + 抖动画像**(进程内单例,见 {@link sharedJitterTracker})。
*
* ⛔ 它**不做 I/O**、不定时、不联网 —— 采样由调用方喂(relay 侧 = PONG 回来的那一刻;
* 平台侧 = 当前通道的 `status().rttMs`)。这样本模块可以在单测里被**逐项断言**。
*/
export class JitterTracker {
private readonly th: JitterThresholds
private readonly rings = new Map<string, number[]>()
constructor(th: Partial<JitterThresholds> = {}) {
this.th = { ...jitterThresholds(), ...th }
}
/** 阈值快照(调用方据此做判定,⛔ 不许各自再读一遍 env)。 */
thresholds(): JitterThresholds {
return this.th
}
/** 是否开启(`JITTER_ENABLE=0` ⇒ 采样与排序全部失效)。 */
get enabled(): boolean {
return this.th.enabled
}
/**
* 记一次 RTT 样本。
*
* 非法输入(非有限数 / 负数)**静默丢弃并返回 `false`** —— 这里故意不抛:
* 采样点在生产路径上(每 15 s 一次心跳),抛异常会把**选路**带崩,
* 而"少一个样本"只影响排序精度。⛔ 但**不静默吞掉"整条通道读不到 RTT"**:那是
* `switcher.ts` 的 `jitterAlerts` / 探针 `OBS-19` 负责点名的分工。
*/
record(url: string, rttMs: number): boolean {
if (!this.th.enabled) return false
if (url === '' || !Number.isFinite(rttMs) || rttMs < 0) return false
const ring = this.rings.get(url) ?? []
ring.push(rttMs)
const max = Math.max(2, this.th.sampleMax)
if (ring.length > max) ring.splice(0, ring.length - max)
this.rings.set(url, ring)
return true
}
/** 该 url 的画像;**样本不足 `minSamples` 个差分 ⇒ `undefined`(= 未知,⛔ 不当 0 用)**。 */
stats(url: string): JitterStats | undefined {
const ring = this.rings.get(url)
if (ring === undefined || ring.length < 2) return undefined
const st = statsFromDeltas(absDeltas(ring), this.th)
if (st.deltas < this.th.minSamples) return undefined
return st
}
/** 该 url 的 jitter(`undefined` = 未知)。 */
jitterMs(url: string): number | undefined {
return this.stats(url)?.p95AbsDeltaMs
}
/** 已采过样的 url(信息输出 / 观测用)。 */
urls(): string[] {
return [...this.rings.keys()].sort()
}
/** 全量快照(**只读**副本;给 `/status` 与探针用)。 */
snapshot(): { url: string; samples: number; jitterMs?: number }[] {
return this.urls().map((url) => {
const st = this.stats(url)
return {
url,
samples: this.rings.get(url)?.length ?? 0,
...(st === undefined ? {} : { jitterMs: st.p95AbsDeltaMs }),
}
})
}
/** 清空(测试与"配置热更"用;⛔ 生产路径不调它)。 */
reset(): void {
this.rings.clear()
}
}
/**
* **候选排序:jitter 为主序**(`E1` 的实现本体)。
*
* 语义(⛔ 三条都要照做,改一条就等于改判据):
* 1. **已测出样本**(差分 ≥ `minSamples`)的候选按 `jitter` **升序**在前 —— 并列时**保原相对序**;
* 2. **未测出样本**的候选按**原相对序**排在其后 —— ⛔ 不把"没测过"当成"很差"(那会让**备用中继
* 永远排最后 ⇒ 永远不被使用 ⇒ 永远测不出来**,形成死角),也⛔ 不把它当成"很好"(那会让
* 推荐序失去意义);
* 3. **一个都没测出来 ⇒ 返回原数组本身**(`D9`:零回归的机器判据)。
*
* ⚠️ 排序必须 **stable**(同 key 保原序)—— 否则同源优先、目录发布序这些**已有语义**会被
* 一次抖动采样随机洗牌 ⇒ 那是净退化(R11)。
*/
export function orderByJitter(
urls: readonly string[],
tracker: JitterTracker | undefined,
minSamples?: number,
): readonly string[] {
if (tracker === undefined || !tracker.enabled || urls.length < 2) return urls
const th = tracker.thresholds()
const need = minSamples ?? th.minSamples
const known: { url: string; jitterMs: number; idx: number }[] = []
for (let i = 0; i < urls.length; i++) {
const url = urls[i]!
const st = tracker.stats(url)
if (st === undefined || st.deltas < need) continue
known.push({ url, jitterMs: st.p95AbsDeltaMs, idx: i })
}
/** ③ 零已知 ⇒ 原数组(⛔ 连新数组都不建:`D9` 要的是"逐字一致")。 */
if (known.length === 0) return urls
known.sort((a, b) => (a.jitterMs === b.jitterMs ? a.idx - b.idx : a.jitterMs - b.jitterMs))
const pinned = new Set(known.map((k) => k.url))
return [...known.map((k) => k.url), ...urls.filter((u) => !pinned.has(u))]
}
/**
* **"jitter 劣化即切"的挑人单点判据**(`E2` 的实现本体)。
*
* 规则(⛔ 只有这一处实现;`switcher.ts` 只负责调用 + 记数 + 告警):
* - 当前通道 jitter `< switchMs` ⇒ `undefined`(**没劣化,一步都不许动**);
* - 否则在候选里找 **① 不是当前 ② 不在 `blocked` 里**(`blocked` = 当前 + 冷却中的,
* ⛔ **jitter 换址没有打破冷却的权力** —— 豁免权只属于"当前这条已经挂了"这条语义,见 `switcher.ts`)
* 且 **jitter 已知且严格更小** 的最小者;
* - 找不到 ⇒ `undefined`(**原地不动**,⛔ 不切空、⛔ 不静默回退默认机)。
*/
export function pickJitterTarget(opts: {
urls: readonly string[]
tracker: JitterTracker | undefined
curUrl: string
curJitterMs: number
switchMs: number
minSamples?: number
blocked?: ReadonlySet<string>
}): { url: string; jitterMs: number } | undefined {
const { urls, tracker, curUrl, curJitterMs, switchMs, blocked } = opts
if (tracker === undefined || !tracker.enabled) return undefined
if (!Number.isFinite(curJitterMs) || curJitterMs < switchMs) return undefined
const need = opts.minSamples ?? tracker.thresholds().minSamples
let best: { url: string; jitterMs: number } | undefined
for (const url of urls) {
if (url === curUrl) continue
if (blocked !== undefined && blocked.has(url)) continue
const st = tracker.stats(url)
if (st === undefined || st.deltas < need) continue
const j = st.p95AbsDeltaMs
if (j >= curJitterMs) continue
if (best === undefined || j < best.jitterMs) best = { url, jitterMs: j }
}
return best
}
/**
* **进程级共享 tracker**(`switcher.ts` 采样 / `directory.ts` 排序 / relay `/status` 观测
* 都用它 ⇒ 装配点**零改动**)。
*
* 🔴 为什么必须是单例:装配点(`src/web/server.ts` / `src/worker/relay-tunnel.ts` /
* `src/net/relay/main.ts`)**不在序㉖ 的在册文件集**内 ⇒ 若把 tracker 做成"构造时注入",
* 生产上**永远不会被注入** ⇒ 本序所有判据都变成**静默失效**(装了但一次都没生效)。
* 单例把"接线"这件事**从装配点挪进模块内部**,代价是"测试要能换掉它" ⇒ 见
* {@link setSharedJitterTracker}。
*/
let shared: JitterTracker | undefined
export function sharedJitterTracker(): JitterTracker {
if (shared === undefined) shared = new JitterTracker()
return shared
}
/** 注入/清空共享 tracker(**只有单测用**)。 */
export function setSharedJitterTracker(t: JitterTracker | undefined): void {
shared = t
}