Files
dsh_shenxian/test/overlay-jitter.test.mjs
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

504 lines
22 KiB
JavaScript
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.
/**
* 覆盖网络 · **序㉖ 骨干稳定选路(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,⛔ 不编数)')
})