代码
- 内容分发块级寻址:新增 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)
504 lines
22 KiB
JavaScript
504 lines
22 KiB
JavaScript
/**
|
||
* 覆盖网络 · **序㉖ 骨干稳定选路(jitter 主序)** 单测。
|
||
*
|
||
* ## 这个文件要回答的三个问题
|
||
* 1. **选路到底看不看 jitter?** —— `E1`(**本序主判据**):两条候选里 **jitter 更低但 RTT 更高**
|
||
* 的那条**必须被选中**。改造前是"按目录发布序取第一个" ⇒ 这条断言**必红**。
|
||
* 2. **jitter 劣化会不会真的换路、有没有可断言的计数?** —— `E2`:超阈值 ⇒ 换 + `[relay-jitter]`
|
||
* 告警 + `jitterSwitches` 计数;⛔ 且**冷却语义一字不动**(jitter 换址**没有**豁免权)。
|
||
* 3. **利用率余量门在不在?** —— `E4`:`used/max ≥ RELAY_UTIL_MAX_PCT` ⇒ 拒新接入(⛔ 不打满),
|
||
* 且已在册节点重连**仍然优先**。
|
||
*
|
||
* ## 🔴 零回归的两条机器判据(本文件也在替它们守门)
|
||
* - **`D9`**:tracker **零样本** ⇒ `orderByJitter` 返回**同一个数组**(⛔ 不是"等值的新数组")
|
||
* ⇒ 改造前后**逐字一致**,`npm test` 的 176 条老用例才可能一条都不动。
|
||
* - **`D3` 护栏**:稳态换址的**文案与判据**逐字不变(jitter 只**新增**触发条件)。
|
||
*
|
||
* 运行:`npm run build && node --test test/overlay-jitter.test.mjs`(测 `lib/` 产物)。
|
||
* ⚠️ **本文件尚未挂进 `npm test` / `npm run verify`**(那两个入口在 `package.json` 里,
|
||
* 不在序㉖ 的在册文件集内 ⇒ 按 R7 不动它,已在回报里点名)。
|
||
*
|
||
* @module test/overlay-jitter
|
||
*/
|
||
|
||
import assert from 'node:assert/strict'
|
||
import { createHmac, randomBytes } from 'node:crypto'
|
||
import { test } from 'node:test'
|
||
import { MUX, RelayServer, decodeMux, encodeJsonFrame } from '../lib/net/relay/index.js'
|
||
import {
|
||
JitterTracker,
|
||
absDeltas,
|
||
histogram,
|
||
jitterThresholds,
|
||
orderByJitter,
|
||
percentile,
|
||
pickJitterTarget,
|
||
statsFromDeltas,
|
||
} from '../lib/net/relay/jitter.js'
|
||
import { RelayFailoverSupervisor } from '../lib/net/relay/switcher.js'
|
||
|
||
const BASE = 47000
|
||
const SPAN = 100
|
||
const PATH = '/dshs-relay'
|
||
|
||
/** 测试用阈值:`sampleGapMs = 0`(不节流,夹具要能"每 tick 都采样")。 */
|
||
const TH = {
|
||
enabled: true,
|
||
sampleMax: 32,
|
||
minSamples: 3,
|
||
switchMs: 20,
|
||
histMaxMs: 200,
|
||
histBuckets: 8,
|
||
sampleGapMs: 0,
|
||
}
|
||
|
||
const URL_A = 'wss://relay-a.example/dshs-relay'
|
||
const URL_B = 'wss://relay-b.example/dshs-relay'
|
||
const URL_C = 'wss://relay-c.example/dshs-relay'
|
||
|
||
/**
|
||
* `E1` 的夹具数据(⚠️ **数字就是判据本身**,⛔ 别改成"随便造两组"):
|
||
*
|
||
* | 候选 | RTT 序列 | 平均 RTT | `p95|ΔRTT|` |
|
||
* |---|---|---|---|
|
||
* | **A** | 20 / 35 / 20 / 35 | **27.5 ms** | **15 ms** |
|
||
* | **B** | 30 / 32 / 30 / 32 | **31.0 ms** | **2 ms** |
|
||
*
|
||
* ⇒ **A 的 RTT 更低**(改造前的判据会选它),**B 的 jitter 更低**(本序判据必须选它)。
|
||
*/
|
||
const SEQ_A = [20, 35, 20, 35]
|
||
const SEQ_B = [30, 32, 30, 32]
|
||
|
||
function trackerWith(url, seq, th = TH) {
|
||
const t = new JitterTracker(th)
|
||
for (const v of seq) t.record(url, v)
|
||
return t
|
||
}
|
||
|
||
const mean = (a) => a.reduce((x, y) => x + y, 0) / a.length
|
||
|
||
/* ─────────── 搭台工具(与 relay-failover.test.mjs 同风格:只测决策,不碰真 socket) ─────────── */
|
||
|
||
function fakeChannel(url, health = { state: 'up', attempts: 0, unhealthyForMs: 0 }) {
|
||
const ch = {
|
||
url,
|
||
closed: 0,
|
||
_h: { ...health },
|
||
health: () => ({ ...ch._h }),
|
||
setHealth(next) {
|
||
ch._h = { ...next }
|
||
},
|
||
close() {
|
||
ch.closed += 1
|
||
},
|
||
}
|
||
return ch
|
||
}
|
||
|
||
function collector() {
|
||
const lines = []
|
||
return {
|
||
lines,
|
||
log: (l) => lines.push(l),
|
||
switchLines: () => lines.filter((l) => l.startsWith('[relay-switch]')).length,
|
||
jitterLines: () => lines.filter((l) => l.startsWith('[relay-jitter]')).length,
|
||
}
|
||
}
|
||
|
||
/* ─────────── S1:jitter 采样 / 直方图(纯函数逐项可断言) ─────────── */
|
||
|
||
test('J1 absDeltas 取**相邻差分绝对值**(⛔ 不是标准差:单调漂移不算抖动)', () => {
|
||
assert.deepEqual(absDeltas([10, 12, 9, 30]), [2, 3, 21])
|
||
assert.deepEqual(absDeltas([5]), [])
|
||
assert.deepEqual(absDeltas([]), [])
|
||
})
|
||
|
||
test('J2 percentile 与 scripts/overlay-jitter.cjs **同口径**(索引 = floor(len·p),越界取末位)', () => {
|
||
assert.equal(percentile([], 0.95), 0)
|
||
assert.equal(percentile([7], 0.95), 7)
|
||
// 20 个 1..20 ⇒ floor(20×0.95)=19 ⇒ sorted[19]=20
|
||
assert.equal(percentile(Array.from({ length: 20 }, (_, i) => i + 1), 0.95), 20)
|
||
// 3 个 ⇒ floor(3×0.95)=2 ⇒ 末位
|
||
assert.equal(percentile([5, 1, 3], 0.95), 5)
|
||
})
|
||
|
||
test('J3 histogram 桶数恒等于 histBuckets,≥ histMaxMs 落末桶', () => {
|
||
const h = histogram([0, 24, 25, 26, 199, 200, 999], 200, 8)
|
||
assert.equal(h.length, 8)
|
||
// 每桶宽度 25ms:0→0 桶;24/25/26→0/1/1 桶;199→7 桶;200 与 999→末桶
|
||
assert.deepEqual(h, [2, 2, 0, 0, 0, 0, 0, 3])
|
||
assert.equal(histogram([], 200, 8).reduce((a, b) => a + b, 0), 0)
|
||
})
|
||
|
||
test('J4 statsFromDeltas:p95 / 均值 / 最大值 / 桶数(口径与探针一致)', () => {
|
||
const st = statsFromDeltas([1, 2, 3, 4], TH)
|
||
assert.equal(st.deltas, 4)
|
||
assert.equal(st.p95AbsDeltaMs, percentile([1, 2, 3, 4], 0.95))
|
||
assert.equal(st.meanAbsDeltaMs, 2.5)
|
||
assert.equal(st.maxAbsDeltaMs, 4)
|
||
assert.equal(st.hist.length, TH.histBuckets)
|
||
})
|
||
|
||
test('J5 JitterTracker:非法样本丢弃、环上限生效、样本不足 ⇒ **undefined(⛔ 不当 0)**', () => {
|
||
const t = new JitterTracker({ ...TH, sampleMax: 4 })
|
||
assert.equal(t.record(URL_A, Number.NaN), false)
|
||
assert.equal(t.record(URL_A, -1), false)
|
||
assert.equal(t.record('', 10), false)
|
||
// 只有一个样本 ⇒ 算不出差分 ⇒ 未知
|
||
assert.equal(t.record(URL_A, 10), true)
|
||
assert.equal(t.stats(URL_A), undefined)
|
||
assert.equal(t.jitterMs(URL_A), undefined)
|
||
// 差分数 < minSamples(3) ⇒ 仍然未知(⛔ 这是"样本不足不当 0"的机器判据)
|
||
t.record(URL_A, 12)
|
||
t.record(URL_A, 14)
|
||
assert.equal(t.stats(URL_A), undefined)
|
||
// 第 4 个样本 ⇒ 3 个差分 ⇒ 可判
|
||
t.record(URL_A, 16)
|
||
assert.equal(t.stats(URL_A)?.deltas, 3)
|
||
// 环上限 = 4 ⇒ 再喂两个只留最后 4 个([14,16,18,20])
|
||
t.record(URL_A, 18)
|
||
t.record(URL_A, 20)
|
||
assert.equal(t.snapshot()[0].samples, 4)
|
||
assert.equal(t.jitterMs(URL_A), 2)
|
||
})
|
||
|
||
test('J6 JITTER_ENABLE=0 ⇒ 采样与排序**全部失效**(回改造前行为)', () => {
|
||
const t = new JitterTracker({ ...TH, enabled: false })
|
||
assert.equal(t.record(URL_A, 10), false)
|
||
assert.equal(t.enabled, false)
|
||
})
|
||
|
||
test('J7 jitterThresholds:值格**必须纯数字**(带夹注 ⇒ 静默回退默认值)', () => {
|
||
assert.equal(jitterThresholds({ JITTER_LIMIT_MS: '35' }).switchMs, 35)
|
||
assert.equal(jitterThresholds({ JITTER_LIMIT_MS: '35(实测)' }).switchMs, 20, '夹注必须回退默认值')
|
||
assert.equal(jitterThresholds({ JITTER_ENABLE: '0' }).enabled, false)
|
||
assert.equal(jitterThresholds({ JITTER_ENABLE: '1(默认)' }).enabled, true, '非法值回退默认 1')
|
||
})
|
||
|
||
/* ─────────── S2:E1 主判据 —— jitter 更低(但 RTT 更高)者被选中 ─────────── */
|
||
|
||
test('E1-a 主判据:**jitter 更低的那条排在首位,即使它 RTT 更高**', () => {
|
||
const t = trackerWith(URL_A, SEQ_A)
|
||
for (const v of SEQ_B) t.record(URL_B, v)
|
||
// 先自证"两条的 RTT 关系确实与 jitter 关系相反"(⛔ 否则这条用例证明不了任何事)
|
||
assert.ok(mean(SEQ_B) > mean(SEQ_A), `夹具失效:B 的平均 RTT(${mean(SEQ_B)}) 必须高于 A(${mean(SEQ_A)})`)
|
||
assert.ok(
|
||
t.jitterMs(URL_B) < t.jitterMs(URL_A),
|
||
`夹具失效:B 的 jitter(${t.jitterMs(URL_B)}) 必须低于 A(${t.jitterMs(URL_A)})`,
|
||
)
|
||
const ordered = orderByJitter([URL_A, URL_B], t)
|
||
assert.deepEqual(ordered, [URL_B, URL_A], '必须选 B(jitter 2ms)—— 而不是 RTT 更低的 A')
|
||
})
|
||
|
||
test('E1-b 零回归(D9):**零样本 ⇒ 返回同一个数组**(⛔ 不是等值的新数组)', () => {
|
||
const empty = new JitterTracker(TH)
|
||
const urls = [URL_A, URL_B, URL_C]
|
||
assert.equal(orderByJitter(urls, empty), urls, '零样本必须原样返回(逐字一致)')
|
||
assert.equal(orderByJitter(urls, undefined), urls, '无 tracker 必须原样返回')
|
||
assert.deepEqual(orderByJitter([URL_A], trackerWith(URL_A, SEQ_A)), [URL_A], '单候选 ⇒ 不排序')
|
||
})
|
||
|
||
test('E1-c 未测样本的候选**保原序排在其后**(⛔ 不惩罚备用中继、也不给它虚位)', () => {
|
||
const t = trackerWith(URL_A, SEQ_A)
|
||
for (const v of SEQ_B) t.record(URL_B, v)
|
||
// C 无样本;目录原序 = [A, C, B]
|
||
assert.deepEqual(orderByJitter([URL_A, URL_C, URL_B], t), [URL_B, URL_A, URL_C])
|
||
})
|
||
|
||
test('E1-d 稳定排序:jitter 并列时**保原相对序**(⛔ 不许随机洗牌已有语义)', () => {
|
||
const t = trackerWith(URL_A, SEQ_A)
|
||
for (const v of SEQ_A) t.record(URL_B, v)
|
||
assert.deepEqual(orderByJitter([URL_A, URL_B, URL_C], t), [URL_A, URL_B, URL_C])
|
||
assert.deepEqual(orderByJitter([URL_B, URL_A, URL_C], t), [URL_B, URL_A, URL_C])
|
||
})
|
||
|
||
test('E1-e **端到端主判据**:当前通道不健康 ⇒ 换到 jitter 更低的那条(目录序里它排第二)', async () => {
|
||
const t = trackerWith(URL_A, SEQ_A)
|
||
for (const v of SEQ_B) t.record(URL_B, v)
|
||
const cur = fakeChannel(URL_C, { state: 'backoff', attempts: 5, unhealthyForMs: 60_000 })
|
||
const switched = []
|
||
const log = collector()
|
||
const sup = new RelayFailoverSupervisor({
|
||
open: async (url) => {
|
||
switched.push(url)
|
||
return fakeChannel(url)
|
||
},
|
||
candidates: async () => [URL_A, URL_B],
|
||
log: log.log,
|
||
thresholds: { minAttempts: 1, graceMs: 0, cooldownMs: 1000, checkMs: 10_000, upTimeoutMs: 10 },
|
||
jitterTracker: t,
|
||
})
|
||
sup.seed(cur)
|
||
await sup.tick()
|
||
|
||
assert.equal(sup.channel?.url, URL_B, '必须换到 B(jitter 更低),⛔ 不是目录序首位的 A')
|
||
assert.deepEqual(switched, [URL_B])
|
||
assert.equal(sup.stats().switches, 1)
|
||
assert.equal(log.switchLines(), 1, '判别器:`[relay-switch]` 行数必须等于 switches(D7)')
|
||
assert.ok(log.lines[0].startsWith('[relay-switch] #1'), `原文=${log.lines[0]}`)
|
||
})
|
||
|
||
/* ─────────── S3:E2 —— jitter 劣化即切(⛔ 冷却语义一字不动) ─────────── */
|
||
|
||
function supDeps({ urls, tracker, log, clock }) {
|
||
return {
|
||
open: async (url) => fakeChannel(url),
|
||
candidates: async () => urls,
|
||
log: log.log,
|
||
thresholds: { minAttempts: 1, graceMs: 0, cooldownMs: 1000, checkMs: 10_000, upTimeoutMs: 10 },
|
||
nowMs: () => clock.t,
|
||
jitterTracker: tracker,
|
||
}
|
||
}
|
||
|
||
test('E2-a 健康但**抖动超标** ⇒ 换到更稳的候选 + `[relay-jitter]` 告警 + 计数(= 0 增量判据)', async () => {
|
||
// 当前 A 的 jitter = 25ms ≥ 20ms(阈值),B = 2ms ⇒ 应切 B
|
||
const t = trackerWith(URL_A, [20, 45, 20, 45])
|
||
for (const v of SEQ_B) t.record(URL_B, v)
|
||
assert.equal(t.jitterMs(URL_A), 25)
|
||
const log = collector()
|
||
const clock = { t: 1_000_000 }
|
||
const sup = new RelayFailoverSupervisor(supDeps({ urls: [URL_A, URL_B], tracker: t, log, clock }))
|
||
sup.seed(fakeChannel(URL_A))
|
||
await sup.tick()
|
||
assert.equal(sup.channel?.url, URL_B, '抖动超标必须换到更稳的那条')
|
||
const st = sup.stats()
|
||
assert.equal(st.jitterSwitches, 1)
|
||
assert.equal(st.jitterAlerts, 1)
|
||
assert.equal(st.switches, 1)
|
||
assert.equal(log.switchLines(), 1)
|
||
assert.ok(log.lines.some((l) => l.startsWith('[relay-jitter] ⚠')), `缺告警行:${log.lines.join('|')}`)
|
||
assert.ok(log.lines.some((l) => l.includes('抖动量超标')), '切换原因必须点名 jitter(可被 grep 到)')
|
||
})
|
||
|
||
test('E2-b 抖动超标但**候选全在冷却** ⇒ 原地不动,⛔ 且**不得动用一跳豁免**(D3 护栏)', async () => {
|
||
const t = trackerWith(URL_A, [20, 45, 20, 45])
|
||
for (const v of SEQ_B) t.record(URL_B, v)
|
||
const log = collector()
|
||
const clock = { t: 1_000_000 }
|
||
const sup = new RelayFailoverSupervisor(supDeps({ urls: [URL_A, URL_B], tracker: t, log, clock }))
|
||
sup.seed(fakeChannel(URL_A))
|
||
// 先手工把 B 打进冷却(复现"B 刚被换掉 / 刚失败"的现场)
|
||
const opened = await sup.replace(URL_B, '夹具:先切到 B', 'health')
|
||
assert.equal(opened, true)
|
||
assert.equal(sup.channel?.url, URL_B)
|
||
// 再切回 A(夹具造"当前 A 抖动超标、B 在冷却")
|
||
await sup.replace(URL_A, '夹具:切回 A', 'health')
|
||
assert.ok(sup.stats().cooldown.some((c) => c.url === URL_B), 'B 必须已在冷却中')
|
||
/**
|
||
* ⚠️ 断言必须取**增量**:上面两次 `replace` 是**夹具自己**造的换址(其中"切回 A"走的就是
|
||
* 一跳豁免 —— 因为 A 刚被换掉、正在冷却)⇒ 拿绝对值判 "exemptSwitches === 0" 会把
|
||
* **夹具的动作**记到产品头上(本条第一版就是这么错的,已改)。
|
||
*/
|
||
const pre = sup.stats()
|
||
await sup.tick()
|
||
const after = sup.stats()
|
||
assert.equal(after.switches, pre.switches, '⛔ 不得切换(唯一候选在冷却中 ⇒ 原地不动)')
|
||
assert.equal(after.exemptSwitches, pre.exemptSwitches, '⛔ jitter 换址**没有**豁免权(豁免只属于"当前已经挂了")')
|
||
assert.equal(after.jitterSwitches, pre.jitterSwitches)
|
||
assert.ok(
|
||
log.lines.some((l) => l.startsWith('[relay-jitter] ⚠')),
|
||
'超标本身必须**照样告警**(⛔ 不换路 ≠ 不报警)',
|
||
)
|
||
})
|
||
|
||
test('E2-c 采样门限:同一份**缓存读数**只记一次(否则差分恒 0 ⇒ jitter 假绿)', async () => {
|
||
const t = new JitterTracker({ ...TH, sampleGapMs: 20_000 })
|
||
const log = collector()
|
||
const clock = { t: 1_000_000 }
|
||
const ch = fakeChannel(URL_A, { state: 'up', attempts: 0, unhealthyForMs: 0, rttMs: 40 })
|
||
const sup = new RelayFailoverSupervisor(supDeps({ urls: [URL_A], tracker: t, log, clock }))
|
||
sup.seed(ch)
|
||
for (let i = 0; i < 5; i++) {
|
||
clock.t += 1_000
|
||
await sup.tick()
|
||
}
|
||
assert.equal(sup.stats().jitterSamples, 1, '门限内(20s)同一份缓存值只能记 1 个样本')
|
||
clock.t += 25_000
|
||
await sup.tick()
|
||
assert.equal(sup.stats().jitterSamples, 2, '超过门限后允许再记一个(心跳周期已过)')
|
||
})
|
||
|
||
test('E2-d `jitterTracker: null` ⇒ **逐字回到序⑧**(不采样、不按 jitter 排序、不因抖动切换)', async () => {
|
||
const log = collector()
|
||
const clock = { t: 1_000_000 }
|
||
const ch = fakeChannel(URL_A, { state: 'up', attempts: 0, unhealthyForMs: 0, rttMs: 40 })
|
||
const sup = new RelayFailoverSupervisor({
|
||
...supDeps({ urls: [URL_A, URL_B], tracker: undefined, log, clock }),
|
||
jitterTracker: null,
|
||
})
|
||
sup.seed(ch)
|
||
await sup.tick()
|
||
assert.equal(sup.stats().jitterSamples, 0)
|
||
assert.equal(sup.stats().jitterSwitches, 0)
|
||
assert.equal(sup.stats().switches, 0)
|
||
assert.equal(log.switchLines(), 0)
|
||
})
|
||
|
||
test('E2-e 缺省(不传 jitterTracker)⇒ 用**进程级共享 tracker**(装配点零改动即生效)', async () => {
|
||
// ⛔ 这条专门守"静默失效":装配点不在在册文件集里 ⇒ 若默认不是共享单例,生产上永远不会生效。
|
||
const { sharedJitterTracker } = await import('../lib/net/relay/jitter.js')
|
||
const shared = sharedJitterTracker()
|
||
shared.reset()
|
||
for (const v of [20, 45, 20, 45]) shared.record(URL_A, v)
|
||
for (const v of SEQ_B) shared.record(URL_B, v)
|
||
const log = collector()
|
||
const clock = { t: 1_000_000 }
|
||
const sup = new RelayFailoverSupervisor({
|
||
open: async (url) => fakeChannel(url),
|
||
candidates: async () => [URL_A, URL_B],
|
||
log: log.log,
|
||
thresholds: { minAttempts: 1, graceMs: 0, cooldownMs: 1000, checkMs: 10_000, upTimeoutMs: 10 },
|
||
nowMs: () => clock.t,
|
||
})
|
||
sup.seed(fakeChannel(URL_A))
|
||
await sup.tick()
|
||
assert.equal(sup.channel?.url, URL_B, '缺省必须走共享 tracker(否则本序在生产上是静默失效)')
|
||
shared.reset()
|
||
})
|
||
|
||
test('E2-f pickJitterTarget 单点判据:没劣化 / 候选不更稳 / 候选在冷却 ⇒ 一律 undefined', () => {
|
||
const t = trackerWith(URL_A, SEQ_A) // 15ms
|
||
for (const v of SEQ_B) t.record(URL_B, v) // 2ms
|
||
const base = { urls: [URL_A, URL_B], tracker: t, curUrl: URL_A, switchMs: 20, minSamples: 3 }
|
||
assert.equal(pickJitterTarget({ ...base, curJitterMs: 15 }), undefined, '未达阈值 ⇒ 不动')
|
||
assert.equal(pickJitterTarget({ ...base, curJitterMs: 25 })?.url, URL_B, '超标且 B 更稳 ⇒ 选 B')
|
||
assert.equal(
|
||
pickJitterTarget({ ...base, curJitterMs: 25, blocked: new Set([URL_B]) }),
|
||
undefined,
|
||
'⛔ 冷却中的候选不许被 jitter 换址挑中(无豁免权)',
|
||
)
|
||
assert.equal(pickJitterTarget({ ...base, curJitterMs: 25, urls: [URL_A, URL_A] }), undefined, '⛔ 不许切到自己')
|
||
})
|
||
|
||
/* ─────────── S4:E4 —— 利用率软门(真起 relay,真握手) ─────────── */
|
||
|
||
async function rawHello(wsUrl, hostId, secret, portsCsv) {
|
||
const ws = new WebSocket(wsUrl)
|
||
ws.binaryType = 'arraybuffer'
|
||
await new Promise((resolve, reject) => {
|
||
ws.addEventListener('open', resolve, { once: true })
|
||
ws.addEventListener('error', () => reject(new Error('ws open failed')), { once: true })
|
||
})
|
||
const ts = Date.now()
|
||
const nonce = randomBytes(16).toString('hex')
|
||
const mac = createHmac('sha256', Buffer.from(secret, 'hex')).update(`${hostId}|${ts}|${nonce}|${portsCsv}`).digest('hex')
|
||
ws.send(encodeJsonFrame(MUX.HELLO, 0, { v: 1, hostId, ts, nonce, portsCsv, mac }))
|
||
const frame = await new Promise((resolve) => {
|
||
const timer = setTimeout(() => resolve(undefined), 3000)
|
||
ws.addEventListener('message', (ev) => {
|
||
clearTimeout(timer)
|
||
resolve(decodeMux(Buffer.from(ev.data)))
|
||
})
|
||
ws.addEventListener('close', () => {
|
||
clearTimeout(timer)
|
||
resolve(undefined)
|
||
})
|
||
})
|
||
return { ws, frame }
|
||
}
|
||
|
||
test('E4 利用率软门:`used/max ≥ RELAY_UTIL_MAX_PCT` ⇒ 拒新接入(⛔ 不打满),**已在册重连仍优先**', async (t) => {
|
||
const secrets = { a: randomBytes(32).toString('hex'), b: randomBytes(32).toString('hex'), d: randomBytes(32).toString('hex') }
|
||
const server = new RelayServer({
|
||
port: 0,
|
||
keys: new Map([
|
||
['w-a', secrets.a],
|
||
['w-b', secrets.b],
|
||
['w-d', secrets.d],
|
||
]),
|
||
instancePortBase: BASE,
|
||
instancePortSpan: SPAN,
|
||
maxHosts: 10,
|
||
utilMaxPct: 20,
|
||
log: () => {},
|
||
})
|
||
await server.start()
|
||
t.after(() => server.stop())
|
||
const url = `ws://127.0.0.1:${server.boundPort}${PATH}`
|
||
|
||
const a = await rawHello(url, 'w-a', secrets.a, String(BASE + 40))
|
||
t.after(() => a.ws.close())
|
||
assert.equal(a.frame?.type, MUX.HELLO_ACK, '第 1 台必须准入(used=0 ⇒ 0% < 20%)')
|
||
const b = await rawHello(url, 'w-b', secrets.b, String(BASE + 41))
|
||
t.after(() => b.ws.close())
|
||
assert.equal(b.frame?.type, MUX.HELLO_ACK, '第 2 台必须准入(used=1 ⇒ 10% < 20%)')
|
||
|
||
assert.equal(server.status().capacity.utilPct, 20, 'used=2 / max=10 ⇒ utilPct 必须 = 20')
|
||
assert.equal(server.status().capacity.utilMaxPct, 20)
|
||
|
||
// 第 3 个**新面孔** ⇒ 必须被软门拦下(⛔ 而不是等到 10 台才拦)
|
||
const d = await rawHello(url, 'w-d', secrets.d, String(BASE + 42))
|
||
t.after(() => d.ws.close())
|
||
assert.equal(d.frame?.type, MUX.HELLO_ERR, '利用率达软门 ⇒ 新节点必须被拒')
|
||
const msg = JSON.parse(Buffer.from(d.frame.payload).toString('utf8'))
|
||
assert.equal(msg.reason, 'at-util-limit')
|
||
assert.equal(msg.retryable, true, '这是**临时**状态 ⇒ 对端应排队重试')
|
||
assert.equal(msg.capacity.utilPct, 20)
|
||
assert.equal(msg.capacity.utilMaxPct, 20)
|
||
assert.equal(server.status().counters.utilRefused, 1, '⛔ 软门拒绝必须与硬门 `refused` 分开计数')
|
||
assert.equal(server.status().counters.refused, 0)
|
||
assert.ok(!server.isOnline('w-d'))
|
||
|
||
// **已在册**的 hostId 重连**永远优先**(它占的位子本来就是它的)
|
||
const a2 = await rawHello(url, 'w-a', secrets.a, String(BASE + 40))
|
||
t.after(() => a2.ws.close())
|
||
assert.equal(a2.frame?.type, MUX.HELLO_ACK, '已在册节点重连不受软门影响')
|
||
})
|
||
|
||
test('E4-b `utilMaxPct = 0` ⇒ 软门关闭(逐字回到改造前:只有硬容量门)', async (t) => {
|
||
const secrets = { a: randomBytes(32).toString('hex'), b: randomBytes(32).toString('hex'), c: randomBytes(32).toString('hex') }
|
||
const server = new RelayServer({
|
||
port: 0,
|
||
keys: new Map([
|
||
['w-a', secrets.a],
|
||
['w-b', secrets.b],
|
||
['w-c', secrets.c],
|
||
]),
|
||
instancePortBase: BASE + SPAN,
|
||
instancePortSpan: SPAN,
|
||
maxHosts: 10,
|
||
utilMaxPct: 0,
|
||
log: () => {},
|
||
})
|
||
await server.start()
|
||
t.after(() => server.stop())
|
||
const url = `ws://127.0.0.1:${server.boundPort}${PATH}`
|
||
for (const [i, [id, s]] of [['w-a', secrets.a], ['w-b', secrets.b], ['w-c', secrets.c]].entries()) {
|
||
const r = await rawHello(url, id, s, String(BASE + SPAN + 40 + i))
|
||
t.after(() => r.ws.close())
|
||
assert.equal(r.frame?.type, MUX.HELLO_ACK, `${id} 必须准入(软门已关)`)
|
||
}
|
||
assert.equal(server.status().counters.utilRefused, 0)
|
||
})
|
||
|
||
test('E4-c `/status` 结构:`capacity.utilPct` / `jitter` 块**恒在**(⛔ 不因"没样本"缺键)', async (t) => {
|
||
const s = randomBytes(32).toString('hex')
|
||
const server = new RelayServer({
|
||
port: 0,
|
||
keys: new Map([['w-a', s]]),
|
||
instancePortBase: BASE + 2 * SPAN,
|
||
instancePortSpan: SPAN,
|
||
maxHosts: 7515,
|
||
log: () => {},
|
||
})
|
||
await server.start()
|
||
t.after(() => server.stop())
|
||
const url = `ws://127.0.0.1:${server.boundPort}${PATH}`
|
||
const a = await rawHello(url, 'w-a', s, String(BASE + 2 * SPAN + 40))
|
||
t.after(() => a.ws.close())
|
||
|
||
const st = server.status()
|
||
assert.equal(st.capacity.max, 7515, '⛔ 容量值一字不动(本序不许改 RELAY_MAX_HOSTS)')
|
||
assert.equal(typeof st.capacity.utilPct, 'number')
|
||
assert.equal(st.capacity.utilMaxPct, 70, '缺省软门 = 70%(留 30%+ 余量)')
|
||
assert.equal(st.counters.utilRefused, 0)
|
||
const j = st.jitter
|
||
for (const k of ['samples', 'deltas', 'p95AbsDeltaMs', 'meanAbsDeltaMs', 'maxAbsDeltaMs', 'thresholdMs', 'overThreshold', 'alerts', 'sessions']) {
|
||
assert.ok(k in j, `jitter.${k} 缺失(探针 OBS-19 的判别器面)`)
|
||
}
|
||
assert.equal(j.hist.length, TH.histBuckets, '直方图桶数必须 = 参数表值')
|
||
assert.equal(j.thresholdMs, TH.switchMs, '阈值必须 = 参数表 JITTER_LIMIT_MS')
|
||
assert.equal(j.sessions, 0, 'rawHello 不回应 PING ⇒ 没有 RTT 差分 ⇒ sessions=0(诚实报 0,⛔ 不编数)')
|
||
})
|