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
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* 覆盖网络线 序 ㉕ · 「逐步拉起」单测 —— **纯函数面**(零 IO、零 systemd、零生产副作用)。
|
||||
*
|
||||
* ## 为什么只测纯函数
|
||||
* 被替换掉的是「启动即 `cleanAllStaleScopes()`」。它的输入是 **OS 层既有 scope**
|
||||
* (`systemctl list-units` + `systemctl show -p Description`),在 Windows 开发机上
|
||||
* 拿不到 ⇒ 真机部分由 §6/S6 在 106 上验收。
|
||||
* 但**最容易出错、也最致命**的那一段恰好是纯的 —— **把 `Description` 解析回实例三要素**:
|
||||
* 解析错 ⇒ 后续替换时定位到别人的实例(跨租户最坏情形)。故这一段必须穷举式钉住。
|
||||
*
|
||||
* 另加两条**源码级守卫**(照 序⑦ `T38` 先例):认领逻辑必须真的接在构造函数上、
|
||||
* 且⛔ 不许把「认领」写成「什么都不做」(那会让档案 30 的孤儿失控)。
|
||||
*
|
||||
* 运行:`node --test test/orchestrator-rehydrate.test.mjs`(⚠️ **刻意不进 `npm test`** ——
|
||||
* 本序要求零回归基线 `npm test` 176/175/0/1 **逐字不变**,加进去会改测试总数)。
|
||||
*
|
||||
* @module test/orchestrator-rehydrate
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { test } from 'node:test'
|
||||
import {
|
||||
decideScopeAction,
|
||||
parseScopeDescription,
|
||||
parseScopeUnitName,
|
||||
} from '../lib/supervisor/orchestrator.js'
|
||||
|
||||
/**
|
||||
* 夹具 = 106 上 **真实** 实例 `dsh-100002-ef8d1d12.scope` 的 `Description` 精简等价形态
|
||||
* (保留全部关键 flag 与其真实相对顺序,省掉无关的 `--ro-bind-try` 白名单项)。
|
||||
* ⚠️ 顺序刻意保留:`--chdir` 在 bwrap 段、`--profile/--port` 在 setpriv 之后。
|
||||
*/
|
||||
const REAL_DESC =
|
||||
'/usr/bin/bwrap --ro-bind /usr /usr --tmpfs /etc ' +
|
||||
'--bind /var/lib/dshs/users/4092b965-2f68-4977-9989-68b3966f7df0/tmp /tmp ' +
|
||||
'--bind /var/lib/dshs/users/4092b965-2f68-4977-9989-68b3966f7df0 /var/lib/dshs/users/4092b965-2f68-4977-9989-68b3966f7df0 ' +
|
||||
'--unshare-pid ' +
|
||||
'--chdir /var/lib/dshs/users/4092b965-2f68-4977-9989-68b3966f7df0/ws ' +
|
||||
'-- setpriv --reuid 100002 --regid 100002 --clear-groups ' +
|
||||
'/usr/bin/dsh --profile web --host 127.0.0.1 --port 21000'
|
||||
|
||||
const UID = 100002
|
||||
const USER = '4092b965-2f68-4977-9989-68b3966f7df0'
|
||||
const FOLDER = `/var/lib/dshs/users/${USER}/ws`
|
||||
|
||||
/* ── R1–R5:scope 名解析(⛔ 只认本平台自己的形态) ─────────────────────────── */
|
||||
|
||||
test('R1 真机形态:dsh-100002-ef8d1d12.scope ⇒ uid 100002', () => {
|
||||
assert.equal(parseScopeUnitName('dsh-100002-ef8d1d12.scope'), 100002)
|
||||
})
|
||||
|
||||
test('R2 ⛔ 大写 hex / 非 scope / 别的单元一律不认', () => {
|
||||
assert.equal(parseScopeUnitName('dsh-100002-EF8D1D12.scope'), undefined)
|
||||
assert.equal(parseScopeUnitName('dsh-100002-ef8d1d12.service'), undefined)
|
||||
assert.equal(parseScopeUnitName('dshs-relay.service'), undefined)
|
||||
assert.equal(parseScopeUnitName('dsh-100002-ef8d1d12.scope.bak'), undefined)
|
||||
assert.equal(parseScopeUnitName(''), undefined)
|
||||
})
|
||||
|
||||
test('R3 ⛔ uid=0 / 缺段 一律不认(防误伤 systemd 自身与门户进程)', () => {
|
||||
assert.equal(parseScopeUnitName('dsh-0-ef8d1d12.scope'), undefined)
|
||||
assert.equal(parseScopeUnitName('dsh--ef8d1d12.scope'), undefined)
|
||||
assert.equal(parseScopeUnitName('dsh-100002.scope'), undefined)
|
||||
})
|
||||
|
||||
/* ── R6–R11:Description 解析(真机夹具) ──────────────────────────────────── */
|
||||
|
||||
test('R6 真机夹具:三要素全部解出且 userId 取自 --chdir(⛔ 不是取自 --bind)', () => {
|
||||
const info = parseScopeDescription(REAL_DESC, UID)
|
||||
assert.deepEqual(info, { userId: USER, role: 'main', port: 21000, folder: FOLDER })
|
||||
})
|
||||
|
||||
test('R7 ⛔ uid 交叉校验不符 ⇒ 拒(scope 名与 argv 非同源 = 半截信息,必须停)', () => {
|
||||
assert.equal(parseScopeDescription(REAL_DESC, 100003), undefined)
|
||||
assert.equal(parseScopeDescription(REAL_DESC.replace('--reuid 100002', '--reuid 100009'), UID), undefined)
|
||||
})
|
||||
|
||||
test('R8 ⛔ 缺 --profile 或 profile 非 web/headless ⇒ 拒(role 猜不得)', () => {
|
||||
assert.equal(parseScopeDescription(REAL_DESC.replace('--profile web ', ''), UID), undefined)
|
||||
assert.equal(parseScopeDescription(REAL_DESC.replace('--profile web', '--profile weird'), UID), undefined)
|
||||
})
|
||||
|
||||
test('R9 ⛔ main 缺 --port / 端口非数字 / 越界 ⇒ 拒', () => {
|
||||
assert.equal(parseScopeDescription(REAL_DESC.replace('--port 21000', ''), UID), undefined)
|
||||
assert.equal(parseScopeDescription(REAL_DESC.replace('--port 21000', '--port abc'), UID), undefined)
|
||||
assert.equal(parseScopeDescription(REAL_DESC.replace('--port 21000', '--port 0'), UID), undefined)
|
||||
assert.equal(parseScopeDescription(REAL_DESC.replace('--port 21000', '--port 70000'), UID), undefined)
|
||||
})
|
||||
|
||||
test('R10 ⛔ 缺 --chdir / 相对路径 / 路径里没有 /users/ 段 ⇒ 拒(拿不到 userId)', () => {
|
||||
assert.equal(parseScopeDescription(REAL_DESC.replace('--chdir ' + FOLDER + ' ', ''), UID), undefined)
|
||||
assert.equal(parseScopeDescription(REAL_DESC.replace(FOLDER, 'ws'), UID), undefined)
|
||||
assert.equal(
|
||||
parseScopeDescription(REAL_DESC.replace(FOLDER, '/srv/ws').replace(/\/var\/lib\/dshs\/users\//g, '/srv/'), UID),
|
||||
undefined,
|
||||
)
|
||||
})
|
||||
|
||||
test('R11 watchdog:headless 且有端口 ⇒ 拒(不自洽);headless 无端口 ⇒ 解出但 role=watchdog', () => {
|
||||
const headlessWithPort = REAL_DESC.replace('--profile web', '--profile headless')
|
||||
assert.equal(parseScopeDescription(headlessWithPort, UID), undefined)
|
||||
const headless = headlessWithPort.replace(' --port 21000', '')
|
||||
const info = parseScopeDescription(headless, UID)
|
||||
assert.equal(info?.role, 'watchdog')
|
||||
assert.equal(info?.port, undefined)
|
||||
assert.equal(info?.userId, USER)
|
||||
})
|
||||
|
||||
/* ── R12–R15:处置判定(认领 vs 按旧行为停) ───────────────────────────────── */
|
||||
|
||||
test('R12 合法 main ⇒ adopt', () => {
|
||||
const info = parseScopeDescription(REAL_DESC, UID)
|
||||
assert.deepEqual(decideScopeAction(info, false), { kind: 'adopt' })
|
||||
})
|
||||
|
||||
test('R13 ⛔ 同 uid 多 scope ⇒ 全部停(档案 30 的风险本体:多实例共 profile)', () => {
|
||||
const info = parseScopeDescription(REAL_DESC, UID)
|
||||
assert.deepEqual(decideScopeAction(info, true), { kind: 'stop', reason: 'dup-uid' })
|
||||
})
|
||||
|
||||
test('R14 ⛔ 解析失败 ⇒ 停(宁可清掉,不留半截实例)', () => {
|
||||
assert.deepEqual(decideScopeAction(undefined, false), { kind: 'stop', reason: 'unparsable' })
|
||||
})
|
||||
|
||||
test('R15 ⛔ watchdog ⇒ 停(一次性 headless、无监听端口 ⇒ 无法确认健康,留着无收益)', () => {
|
||||
const info = parseScopeDescription(REAL_DESC.replace('--profile web', '--profile headless').replace(' --port 21000', ''), UID)
|
||||
assert.deepEqual(decideScopeAction(info, false), { kind: 'stop', reason: 'no-probe-target' })
|
||||
})
|
||||
|
||||
/* ── R16–R18:源码级接线守卫(照 序⑦ T38 先例) ────────────────────────────── */
|
||||
|
||||
const SRC = await readFile(new URL('../src/supervisor/orchestrator.ts', import.meta.url), 'utf8')
|
||||
|
||||
test('R16 接线:构造函数必须调 rehydrateAdoptedScopes(⛔ 不是裸 cleanAllStaleScopes)', () => {
|
||||
assert.ok(
|
||||
/this\.portGuard = createPortGuard\(config\.portGuard\)[\s\S]{0,400}this\.rehydrateAdoptedScopes\(\)/.test(SRC),
|
||||
'constructor 未接认领逻辑(仍是启动即清空)',
|
||||
)
|
||||
})
|
||||
|
||||
test('R17 ⛔ cleanAllStaleScopes 不许被删(回滚路径 + 非 account 回退都还在)', () => {
|
||||
assert.ok(SRC.includes('private cleanAllStaleScopes(): void'))
|
||||
assert.ok(
|
||||
/private rehydrateAdoptedScopes\(\): void \{[\s\S]{0,400}this\.cleanAllStaleScopes\(\)/.test(SRC),
|
||||
'非 account 回退丢了',
|
||||
)
|
||||
})
|
||||
|
||||
test('R18 ⛔ 认领不得写进 mains(写进去 ⇒ enter 复用分支拿不到 token ⇒ 503)', () => {
|
||||
const body = SRC.slice(SRC.indexOf('private adoptOne('), SRC.indexOf('private probeAdopted('))
|
||||
assert.ok(body.includes('this.adopted.set('), 'adoptOne 未登记到 adopted')
|
||||
assert.ok(!body.includes('this.mains.set('), '⛔ adoptOne 把实例写进了 mains ⇒ 用户会被 503 挡住')
|
||||
assert.ok(!body.includes('spawn('), '⛔ adoptOne 里出现了 spawn ⇒ 违反"不 spawn"')
|
||||
const afterAdopt = body.slice(body.indexOf('this.adopted.set('))
|
||||
assert.ok(!afterAdopt.includes('stopUnit('), '⛔ adopt 路径上还停了实例(认领应当只登记 + 探活)')
|
||||
})
|
||||
|
||||
test('R19 ⛔ 认领/回收路径里不得出现凭据落盘(R11:安全维度不许净变差)', () => {
|
||||
const body = SRC.slice(SRC.indexOf('private rehydrateAdoptedScopes('), SRC.indexOf('/** 档案 30:清掉指定 uid'))
|
||||
for (const banned of ['writeFileSync', 'appendFileSync', 'launchToken']) {
|
||||
assert.ok(!body.includes(banned), `rehydrate 路径里出现了 ${banned}`)
|
||||
}
|
||||
})
|
||||
|
||||
test('R20 ⛔ 节流:认领必须逐条经 setTimeout 排队(不得同步一次全跑)', () => {
|
||||
const body = SRC.slice(SRC.indexOf('private rehydrateAdoptedScopes('), SRC.indexOf('private scanExistingScopes('))
|
||||
assert.ok(body.includes('setTimeout('), '认领没有节流')
|
||||
assert.ok(SRC.includes('DSHS_REHYDRATE_STAGGER_MS'), '节流阈值不是可配的环境键')
|
||||
assert.ok(SRC.includes('DSHS_REHYDRATE_PROBE_MS'), '探活超时不是可配的环境键')
|
||||
})
|
||||
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* 覆盖网络线 序 ㉘ → 单 A(候选 `B`)· 「退出路径不杀实例」单测。
|
||||
*
|
||||
* ## 本序要钉住的那件事
|
||||
* 序 ㉕ 实测查明:「Manager 重启后逐步拉起既有实例」不成立的**真凶**是
|
||||
* `LocalSpawner.teardown()` 在 SIGTERM 退出路径上**逐个停掉在册实例**
|
||||
* (由 `src/worker/agent.ts` 的退出路径调用)⇒ 启动认领 `rehydrateAdoptedScopes()`
|
||||
* 永远扫不到存量。候选 `B` = 三处 `teardown()` 一起改:**退出进程不再停实例**。
|
||||
*
|
||||
* ## 为什么用"桩计数"而不是"真起进程"
|
||||
* 本序判的是 **"退出路径会不会去停实例"** 这一个布尔事实 —— 它与实例是不是真进程无关
|
||||
* (真进程那一半由真机夹具 + `OBS-22` 在 S1/S4/S6 验,见本单 §4)。
|
||||
* 用桩计数 ⇒ 单测**零 IO、零 systemd、零生产副作用**,在 Windows 开发机上可跑。
|
||||
*
|
||||
* ## 静态守卫 + 反向夹具自证
|
||||
* 除动态断言外,另加**源码级**守卫(照 序⑦ `T38` / 序㉕ `R16` 先例):三处 `teardown()`
|
||||
* 的函数体里⛔ 不许出现停实例 / 向远端下发停止的任何形态。
|
||||
* 🔴 关键:守卫本身必须**有判别力** —— 故 `T10` 用一段**故意写坏的合成函数体**跑同一个匹配器,
|
||||
* 要求它**必须命中**。否则整组静态断言可能只是"永远绿"的空断言(假绿)。
|
||||
*
|
||||
* ## ⚠️ 一条如实说明(⛔ 不掩饰)
|
||||
* 红腿(改前)只会有 **① 的动态断言 + ②/③ 的静态守卫标记** 变红。
|
||||
* **③ 的动态断言改前改后都是绿的** —— 因为它的 `inner` 是 `RemoteSpawner`,而该类的
|
||||
* `teardown()` **取证本来就是 no-op**(本单 §0.3-1,`git show HEAD:` 逐字相同)。
|
||||
* 即:候选 `B` 的**唯一语义变动处是 ①**,②/③ 是"语义固化 + 机器断言",不是"修 bug"。
|
||||
*
|
||||
* 运行:`node --test test/orchestrator-teardown.test.mjs`(rc=0)
|
||||
* ⚠️ **刻意不进 `npm test`** —— `package.json` 的 `test` 是**硬编码文件列表**,
|
||||
* 加进去会改测试总数(照 序 ㉕ 先例)。
|
||||
*
|
||||
* @module test/orchestrator-teardown
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { test } from 'node:test'
|
||||
import { LocalSpawner } from '../lib/supervisor/orchestrator.js'
|
||||
import { RemoteSpawner } from '../lib/supervisor/remote-spawner.js'
|
||||
import { LeasedSpawner } from '../lib/supervisor/leased-spawner.js'
|
||||
|
||||
/* ── 夹具 ─────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** 最小可用 `ServerConfig`:`portGuard:false` ⇒ 不装 iptables;三个 idle 值 0 ⇒ 不起 reapTimer。 */
|
||||
function makeLocalSpawner() {
|
||||
return new LocalSpawner(
|
||||
{
|
||||
portGuard: false,
|
||||
isolationMode: 'none',
|
||||
idleReapIntervalSeconds: 0,
|
||||
instanceIdleTtlSeconds: 0,
|
||||
maxIdleInstances: 0,
|
||||
dataRoot: 'E:/tmp/dshs-teardown-fixture',
|
||||
},
|
||||
async () => null,
|
||||
async () => 100002,
|
||||
)
|
||||
}
|
||||
|
||||
/** 计数用 DbAdapter 桩(`LeasedSpawner` 构造只把它透传给 `InstanceLease`,构造期零 IO)。 */
|
||||
function makeDbStub() {
|
||||
return {
|
||||
calls: 0,
|
||||
async upsertDshHost() {
|
||||
this.calls += 1
|
||||
},
|
||||
async renewInstanceLease() {
|
||||
this.calls += 1
|
||||
return undefined
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/* ── ① LocalSpawner(本序唯一的语义变动处) ──────────────────────────────── */
|
||||
|
||||
test('T1 ⛔ LocalSpawner.teardown() 不得停任何在册实例(stop 桩计数 = 0)', async () => {
|
||||
const spawner = makeLocalSpawner()
|
||||
// 造"在册实例":直接放进归属表即可 —— 本序判的是"退路径会不会去停它",与实例真伪无关。
|
||||
spawner.mains.set('u-1', {})
|
||||
spawner.watchdogs.set('u-1', {})
|
||||
assert.equal(spawner.mains.size, 1, '夹具没就位:mains 为空 ⇒ 本断言会假绿')
|
||||
|
||||
let stopCalls = 0
|
||||
spawner.stop = async () => {
|
||||
stopCalls += 1
|
||||
}
|
||||
await spawner.teardown()
|
||||
assert.equal(
|
||||
stopCalls,
|
||||
0,
|
||||
'⛔ teardown() 调了 stop() ⇒ 退出路径会杀掉在册实例(候选 B 被改回去了)⇒ 启动认领永远扫不到存量',
|
||||
)
|
||||
assert.equal(spawner.mains.size, 1, '⛔ teardown() 动了 mains(退出路径不该改归属表)')
|
||||
})
|
||||
|
||||
test('T2 teardown() 仍须停自己的 reapTimer(只停定时器,不停实例)', async () => {
|
||||
const spawner = makeLocalSpawner()
|
||||
let ticks = 0
|
||||
spawner.reapTimer = setInterval(() => {
|
||||
ticks += 1
|
||||
}, 5)
|
||||
try {
|
||||
await spawner.teardown()
|
||||
await new Promise((resolve) => setTimeout(resolve, 30))
|
||||
assert.equal(ticks, 0, '⛔ teardown() 没清 reapTimer ⇒ 进程要走了还在扫 idle')
|
||||
} finally {
|
||||
if (spawner.reapTimer !== undefined) clearInterval(spawner.reapTimer)
|
||||
}
|
||||
})
|
||||
|
||||
/* ── ② RemoteSpawner(取证已是 no-op ⇒ 只固化断言) ──────────────────────── */
|
||||
|
||||
test('T3 ⛔ RemoteSpawner.teardown() 不对远端下发任何停止(HTTP 桩计数 = 0)', async () => {
|
||||
const calls = []
|
||||
const spawner = new RemoteSpawner({
|
||||
agentUrl: 'http://127.0.0.1:9/agent',
|
||||
token: 'test-token',
|
||||
fetchImpl: async (url, init) => {
|
||||
calls.push(`${init?.method ?? 'GET'} ${String(url)}`)
|
||||
return new Response('{}', { status: 200 })
|
||||
},
|
||||
})
|
||||
await spawner.teardown()
|
||||
assert.equal(
|
||||
calls.length,
|
||||
0,
|
||||
`⛔ RemoteSpawner.teardown() 对远端发了 ${calls.length} 次请求:${calls.join(' , ')}`,
|
||||
)
|
||||
})
|
||||
|
||||
/* ── ③ LeasedSpawner(拆开"停心跳"与"停实例") ───────────────────────────── */
|
||||
|
||||
test('T4 ⛔ LeasedSpawner.teardown():只停心跳(心跳停 = 1),不停实例(inner.stop = 0)', async () => {
|
||||
const inner = {
|
||||
teardownCalls: 0,
|
||||
stopCalls: 0,
|
||||
async teardown() {
|
||||
this.teardownCalls += 1
|
||||
},
|
||||
async stop() {
|
||||
this.stopCalls += 1
|
||||
},
|
||||
}
|
||||
const spawner = new LeasedSpawner(inner, makeDbStub(), {
|
||||
hostId: 'w-test',
|
||||
agentUrl: 'http://127.0.0.1:9/agent',
|
||||
agentToken: 'test-token',
|
||||
registerSelf: false,
|
||||
manual: true,
|
||||
ttlMs: 30_000,
|
||||
renewMs: 5_000,
|
||||
})
|
||||
// 模拟 `start()` 已跑过 ⇒ 心跳定时器在跑
|
||||
spawner.timer = setInterval(() => {}, 5)
|
||||
|
||||
await spawner.teardown()
|
||||
|
||||
assert.equal(spawner.timer, undefined, '⛔ stopHeartbeat() 没生效 ⇒ 进程走了还在续租')
|
||||
assert.equal(inner.teardownCalls, 1, '⛔ 转发给 inner 的 teardown 丢了(inner = RemoteSpawner ⇒ 必须仍被调)')
|
||||
assert.equal(
|
||||
inner.stopCalls,
|
||||
0,
|
||||
'⛔ LeasedSpawner.teardown() 去停了实例 ⇒ 退出路径杀实例(候选 B 被改回去了)',
|
||||
)
|
||||
})
|
||||
|
||||
/* ── 静态守卫(三处 teardown 体)+ 反向夹具自证 ──────────────────────────── */
|
||||
|
||||
/** 与交接单 §5「对冲项 · 静态」**逐字同一条**:停实例 / 向远端下发停止的所有形态。 */
|
||||
const FORBIDDEN = /this\.stop\(|inner\.stop\(|killInstance\(|\/stop/
|
||||
|
||||
const TEARDOWN_DECL = 'async teardown('
|
||||
|
||||
/** 取 `teardown` 声明起 7 行(≡ `grep -A6 'async teardown'`)作为"函数体窗口"。 */
|
||||
function teardownWindow(src, file) {
|
||||
const lines = src.split('\n')
|
||||
const idx = lines.findIndex((line) => line.includes(TEARDOWN_DECL))
|
||||
assert.ok(idx >= 0, `${file}: 找不到 ${TEARDOWN_DECL} 声明`)
|
||||
return lines.slice(idx, idx + 7).join('\n')
|
||||
}
|
||||
|
||||
const FILES = {
|
||||
'orchestrator.ts': await readFile(new URL('../src/supervisor/orchestrator.ts', import.meta.url), 'utf8'),
|
||||
'remote-spawner.ts': await readFile(new URL('../src/supervisor/remote-spawner.ts', import.meta.url), 'utf8'),
|
||||
'leased-spawner.ts': await readFile(new URL('../src/supervisor/leased-spawner.ts', import.meta.url), 'utf8'),
|
||||
}
|
||||
|
||||
test('T5 ⛔ 三处 teardown() 体内均无停实例 / 下发停止(静态守卫,≡ 交接单 §5 对冲项)', () => {
|
||||
// ⚠️ 逐个收集再断言(⛔ 不在第一个文件就抛)—— 红腿要求**逐处点名**是哪一处。
|
||||
const offenders = []
|
||||
for (const [file, src] of Object.entries(FILES)) {
|
||||
const body = teardownWindow(src, file)
|
||||
const hit = body.split('\n').find((line) => FORBIDDEN.test(line))
|
||||
if (hit !== undefined) offenders.push(`${file}: ${hit.trim()}`)
|
||||
}
|
||||
assert.deepEqual(offenders, [], `teardown() 体内出现停实例调用(逐处点名)⇒\n${offenders.join('\n')}`)
|
||||
})
|
||||
|
||||
test('T6 守卫标记在位(三处都带 guard: teardown-must-not-stop-instances,防被静默改回去)', () => {
|
||||
const missing = []
|
||||
for (const [file, src] of Object.entries(FILES)) {
|
||||
if (!teardownWindow(src, file).includes('guard: teardown-must-not-stop-instances')) missing.push(file)
|
||||
}
|
||||
assert.deepEqual(missing, [], `以下文件的 teardown() 缺守卫标记(逐处点名)⇒ ${missing.join(' / ')}`)
|
||||
})
|
||||
|
||||
test('T7 ⛔ 回收链不许被本序删掉(认领 ≥ 2 处 + cleanStaleScopes 仍在)', () => {
|
||||
const src = FILES['orchestrator.ts']
|
||||
const adopt = src.split('rehydrateAdoptedScopes').length - 1
|
||||
assert.ok(adopt >= 2, `rehydrateAdoptedScopes 计数 = ${adopt}(期望 ≥ 2:定义 + 构造函数调用)`)
|
||||
assert.ok(src.includes('private cleanStaleScopes(uid: number): void'), '⛔ cleanStaleScopes 被删了(档案 30 本体)')
|
||||
assert.ok(src.includes('private cleanAllStaleScopes(): void'), '⛔ cleanAllStaleScopes 被删了(回滚路径还在)')
|
||||
})
|
||||
|
||||
test('T8 ⛔ 退出路径的调用方仍接在 agent 上(不是把整条退出路径改没了)', async () => {
|
||||
const agent = await readFile(new URL('../src/worker/agent.ts', import.meta.url), 'utf8')
|
||||
assert.ok(agent.includes('.teardown('), 'agent 退出路径不再调 teardown ⇒ 本序的改动对象消失了(判据失效)')
|
||||
})
|
||||
|
||||
/**
|
||||
* 🔴 反向夹具自证(照 单 B `F4` 先例):**故意写坏的合成函数体必须被同一匹配器命中**。
|
||||
* 没有这一条,`T5` 有可能只是"永远绿"的空断言。
|
||||
*/
|
||||
test('T9 🔴 反向夹具:判别器对"改回原语义"的合成体必须命中(防静态断言永远绿)', () => {
|
||||
const broken = [
|
||||
' async teardown(): Promise<void> {',
|
||||
' if (this.reapTimer !== undefined) clearInterval(this.reapTimer)',
|
||||
' for (const userId of [...this.mains.keys()]) await this.stop(userId)',
|
||||
' }',
|
||||
].join('\n')
|
||||
const hit = broken.split('\n').find((line) => FORBIDDEN.test(line))
|
||||
assert.ok(hit !== undefined, '🔴 判别器失效:写成原语义(逐个 stop)居然没命中 ⇒ T5 是假绿')
|
||||
|
||||
const brokenRemote = " await this.call(host, 'POST', '/stop', { userId }, randomUUID())"
|
||||
assert.ok(FORBIDDEN.test(brokenRemote), '🔴 判别器失效:远端 /stop 下发居然没命中')
|
||||
|
||||
const brokenLeased = ' await this.inner.stop(userId)'
|
||||
assert.ok(FORBIDDEN.test(brokenLeased), '🔴 判别器失效:inner.stop 居然没命中')
|
||||
})
|
||||
@@ -0,0 +1,844 @@
|
||||
/**
|
||||
* 覆盖网络线 · 序㉔「内容分发(块级内容寻址)」单测。
|
||||
*
|
||||
* ⚠️ 本文件**不在** `package.json` 的 `test` 脚本文件列表里(交接单 §3.1 禁止改那张列表)
|
||||
* ⇒ 单跑:`node --test test/overlay-content.test.mjs`
|
||||
* (`npm test` 的基线与"本文件新增用例数"分开报,见交接单 §8 的 ④)。
|
||||
*
|
||||
* 判据对应关系(交接单 §1):
|
||||
* - **E2** 只拿到一部分也能开始共享 ⇒ 「切分与部分持有」组
|
||||
* - **E3** 版本更新只传变化块 ⇒ 「局部性」组
|
||||
* - **E4** 客户端校验哈希 ⇒ 「校验」组
|
||||
* - **E5** 分组隔离 ⇒ 「分组」组
|
||||
* - **E6** 内容源优先级可观测 ⇒ 「优先级链」组
|
||||
*
|
||||
* 纪律(本线反复踩过的坑):
|
||||
* - ⛔ **只写日志不算计数** ⇒ 每个判别器断言的是**数字递增**,不是"调用没抛错";
|
||||
* - ⛔ **不许放宽判据凑绿** ⇒ 断言用**精确等值**(`deepStrictEqual` / `strictEqual`),
|
||||
* 不用 `>=` 这类可以让实现变差仍然通过的写法(除非判据本身就要求"至少")。
|
||||
*/
|
||||
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import {
|
||||
DEFAULT_BLOCK_SIZE,
|
||||
BLOCK_ID_HEX_LEN,
|
||||
blockIdOf,
|
||||
chunkify,
|
||||
contentIdOf,
|
||||
isBlockId,
|
||||
planOf,
|
||||
reassemble,
|
||||
} from '../lib/net/relay/content/chunker.js'
|
||||
import { ContentStore, DEFAULT_MAX_BYTES } from '../lib/net/relay/content/store.js'
|
||||
import {
|
||||
ContentSourceChain,
|
||||
SOURCE_TIERS,
|
||||
DEFAULT_TIER_ORDER,
|
||||
emptySourceCounters,
|
||||
} from '../lib/net/relay/content/source.js'
|
||||
import { ContentPeerGroup, groupKeyOf, sameGroup, PEER_COUNTER_KEYS } from '../lib/net/relay/content/peer.js'
|
||||
|
||||
/** 造一段可复现的伪随机内容(⛔ 不用 `Math.random` —— 用例必须可复现)。 */
|
||||
function makeBytes(size, seed = 1) {
|
||||
const b = Buffer.alloc(size)
|
||||
let x = seed >>> 0
|
||||
for (let i = 0; i < size; i += 1) {
|
||||
// xorshift32:确定性、够散、零依赖
|
||||
x ^= x << 13
|
||||
x >>>= 0
|
||||
x ^= x >>> 17
|
||||
x ^= x << 5
|
||||
x >>>= 0
|
||||
b[i] = x & 0xff
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 组 1:切分(S1 的核心不变量)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test('chunker: 同一份内容切两次 ⇒ 块 id 序列完全一致(确定性)', () => {
|
||||
const buf = makeBytes(3 * DEFAULT_BLOCK_SIZE + 12345)
|
||||
const a = chunkify(buf)
|
||||
const b = chunkify(buf)
|
||||
assert.deepStrictEqual(
|
||||
a.chunks.map((c) => c.id),
|
||||
b.chunks.map((c) => c.id),
|
||||
'同一份字节两次切分必须给出逐位相同的块 id 序列',
|
||||
)
|
||||
assert.strictEqual(a.contentId, b.contentId)
|
||||
assert.strictEqual(a.size, buf.length)
|
||||
})
|
||||
|
||||
test('chunker: 块 id 只由字节决定(改 1 字节 ⇒ 只有 1 块变化)', () => {
|
||||
const size = 3 * DEFAULT_BLOCK_SIZE
|
||||
const before = makeBytes(size)
|
||||
const after = Buffer.from(before)
|
||||
// 改第 2 块中间的一个字节(偏移显然落在第二块内)
|
||||
const flipAt = DEFAULT_BLOCK_SIZE + 100
|
||||
after[flipAt] = after[flipAt] ^ 0xff
|
||||
|
||||
const p0 = planOf(before)
|
||||
const p1 = planOf(after)
|
||||
assert.strictEqual(p0.ids.length, p1.ids.length, '块数不变')
|
||||
|
||||
const changed = []
|
||||
for (let i = 0; i < p0.ids.length; i += 1) {
|
||||
if (p0.ids[i] !== p1.ids[i]) changed.push(i)
|
||||
}
|
||||
assert.deepStrictEqual(changed, [1], `改 1 字节应只影响第 2 块(index=1),实际变了 ${JSON.stringify(changed)}`)
|
||||
// 其余块 id 必须逐位相同 ⇒ 对端持有的那几块**不用重传**(E3)
|
||||
assert.strictEqual(p0.ids[0], p1.ids[0])
|
||||
assert.strictEqual(p0.ids[2], p1.ids[2])
|
||||
})
|
||||
|
||||
test('chunker: 块 id 不掺序号/长度(同内容不同位置 ⇒ 同 id ⇒ 可去重)', () => {
|
||||
const block = makeBytes(1024, 42)
|
||||
const dup = Buffer.concat([block, makeBytes(1024, 43), block]) // 同一段内容出现两次
|
||||
const r = chunkify(dup, 1024)
|
||||
assert.strictEqual(r.chunks.length, 3)
|
||||
assert.strictEqual(r.chunks[0].id, r.chunks[2].id, '同内容必须同 id —— id 不得掺入序号')
|
||||
assert.deepStrictEqual(r.ids, [r.chunks[0].id, r.chunks[1].id], '去重后的 id 列表只保留首次出现序')
|
||||
})
|
||||
|
||||
test('chunker: id 形状与长度(小写 hex,恰 32 位)', () => {
|
||||
const id = blockIdOf(Buffer.from('hello'))
|
||||
assert.ok(isBlockId(id), `${id} 应为合法块 id`)
|
||||
assert.strictEqual(id.length, BLOCK_ID_HEX_LEN)
|
||||
assert.ok(!isBlockId(id.toUpperCase()), '大写 hex 不是合法 id(口径收紧 = 避免"看着像但实际上不同")')
|
||||
assert.ok(!isBlockId('zz'), '乱码不是合法 id')
|
||||
assert.strictEqual(contentIdOf(Buffer.from('hello')), createHash('sha256').update(Buffer.from('hello')).digest('hex').slice(0, BLOCK_ID_HEX_LEN))
|
||||
})
|
||||
|
||||
test('chunker: id 算法**逐字节钉死**(= sha256(内容) 前 32 hex,⛔ 不得掺任何东西)', () => {
|
||||
// 🔑 为什么必须有这条:id 算法一旦掺入"序号 / 长度 / 来源 / 时间",
|
||||
// 同内容会得到不同 id ⇒ **去重与共享同时静默失效**,而"两次切分一致"这类
|
||||
// 自洽性断言**照样全绿**(先红后绿实测:只改 id 算法时旧用例一条都不红 ⇒ 覆盖缺口)。
|
||||
// 故这里用**外部复算**(node:crypto 直接算)做绝对锚,而不依赖实现自洽。
|
||||
const cases = [
|
||||
Buffer.alloc(0),
|
||||
Buffer.from('hello'),
|
||||
Buffer.from([0x00]),
|
||||
Buffer.from([0xff, 0x00, 0xff]),
|
||||
makeBytes(1024, 1),
|
||||
makeBytes(1024, 2),
|
||||
makeBytes(2048, 1),
|
||||
]
|
||||
for (const buf of cases) {
|
||||
const expected = createHash('sha256').update(buf).digest('hex').slice(0, BLOCK_ID_HEX_LEN)
|
||||
assert.strictEqual(
|
||||
blockIdOf(buf),
|
||||
expected,
|
||||
`id 必须恰为 sha256(内容) 的前 ${BLOCK_ID_HEX_LEN} 位 hex(内容 ${buf.length}B)`,
|
||||
)
|
||||
}
|
||||
// 长度相同、内容不同 ⇒ id 必须不同(防"掺长度"这类退化)
|
||||
assert.notStrictEqual(blockIdOf(makeBytes(1024, 1)), blockIdOf(makeBytes(1024, 2)))
|
||||
// 长度不同但**前缀相同** ⇒ id 也必须不同(防"截断 / 掺长度"这类退化)
|
||||
const prefix = makeBytes(2048, 9)
|
||||
assert.notStrictEqual(blockIdOf(prefix), blockIdOf(prefix.subarray(0, 1024)))
|
||||
// 整份内容 id 同理钉死
|
||||
const whole = makeBytes(4096, 3)
|
||||
assert.strictEqual(
|
||||
contentIdOf(whole),
|
||||
createHash('sha256').update(whole).digest('hex').slice(0, BLOCK_ID_HEX_LEN),
|
||||
)
|
||||
})
|
||||
|
||||
test('chunker: 空内容与不足一块的边界', () => {
|
||||
const empty = chunkify(Buffer.alloc(0))
|
||||
assert.strictEqual(empty.chunks.length, 0)
|
||||
assert.strictEqual(empty.size, 0)
|
||||
|
||||
const small = chunkify(Buffer.from('abc'), 1024)
|
||||
assert.strictEqual(small.chunks.length, 1)
|
||||
assert.strictEqual(small.chunks[0].offset, 0)
|
||||
assert.strictEqual(small.chunks[0].bytes.length, 3)
|
||||
})
|
||||
|
||||
test('chunker: 非法块大小必须抛错(⛔ 不许静默取默认)', () => {
|
||||
assert.throws(() => chunkify(Buffer.from('x'), 0), /正整数/)
|
||||
assert.throws(() => chunkify(Buffer.from('x'), -1), /正整数/)
|
||||
assert.throws(() => chunkify(Buffer.from('x'), 1.5), /正整数/)
|
||||
})
|
||||
|
||||
test('chunker: subarray 视图陷阱 —— 块字节必须已复制(不随源 buffer 漂移)', () => {
|
||||
const src = makeBytes(2 * DEFAULT_BLOCK_SIZE)
|
||||
const r = chunkify(src)
|
||||
const idBefore = r.chunks[0].id
|
||||
const saved = Buffer.from(r.chunks[0].bytes)
|
||||
// 原地改源 buffer:已切出的块**不应**受影响
|
||||
src[0] = src[0] ^ 0xff
|
||||
assert.ok(r.chunks[0].bytes.equals(saved), '切出的块必须与源 buffer 脱钩')
|
||||
assert.strictEqual(blockIdOf(r.chunks[0].bytes), idBefore)
|
||||
})
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 组 2:校验(E4 —— 篡改块必须被丢弃,⛔ 不落盘)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test('E4: store.put 拒绝内容与声明 id 不符的块,并计数', () => {
|
||||
const store = new ContentStore({ maxBytes: 4 * 1024 * 1024 })
|
||||
const good = makeBytes(1000, 7)
|
||||
const id = blockIdOf(good)
|
||||
store.put(id, good)
|
||||
assert.strictEqual(store.get(id)?.equals(good), true)
|
||||
|
||||
const tampered = Buffer.from(good)
|
||||
tampered[10] = tampered[10] ^ 0x01
|
||||
const before = store.counters()
|
||||
assert.throws(() => store.put(id, tampered), /块校验失败|丢弃/)
|
||||
const after = store.counters()
|
||||
assert.strictEqual(after.putRejected, before.putRejected + 1, 'E4 写侧:被拒次数必须 +1(⛔ 只写日志不算)')
|
||||
assert.strictEqual(after.puts, before.puts, '被拒的块不得计入成功入库')
|
||||
// ⛔ 不落盘:仓库里那一份仍然是好的
|
||||
assert.strictEqual(store.get(id)?.equals(good), true, '原块必须完好(篡改块没覆盖它)')
|
||||
})
|
||||
|
||||
test('E4: store.get 读出损坏块 ⇒ 丢弃 + corruptReads 递增(不返回坏数据)', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dshs-content-'))
|
||||
try {
|
||||
const store = new ContentStore({ maxBytes: 4 * 1024 * 1024, dir })
|
||||
const bytes = makeBytes(2048, 11)
|
||||
const id = blockIdOf(bytes)
|
||||
store.put(id, bytes)
|
||||
|
||||
// 绕过 store 直接在盘上改坏(模拟"磁盘写坏 / 进程外改动")—— 内存里有副本,先建个空 store 走盘路径
|
||||
const store2 = new ContentStore({ maxBytes: 4 * 1024 * 1024, dir })
|
||||
const bad = Buffer.from(bytes)
|
||||
bad[3] = bad[3] ^ 0xff
|
||||
writeFileSync(join(dir, id), bad)
|
||||
|
||||
const c0 = store2.counters()
|
||||
const got = store2.get(id)
|
||||
const c1 = store2.counters()
|
||||
assert.strictEqual(got, undefined, '损坏块必须返回 undefined')
|
||||
assert.strictEqual(c1.corruptReads, c0.corruptReads + 1, 'E4 读侧:损坏读必须 +1(⛔ 不许静默当"没有")')
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('E4: reassemble 在缺块 / 篡改块时抛错并点名 index', () => {
|
||||
const buf = makeBytes(2 * 1024 + 10, 5)
|
||||
const r = chunkify(buf, 1024)
|
||||
const plan = r.ids
|
||||
const parts = new Map()
|
||||
for (const c of r.chunks) parts.set(c.id, c.bytes)
|
||||
assert.ok(reassemble(plan, parts).equals(buf), '完好块集必须能重组回原内容')
|
||||
|
||||
// 缺一块
|
||||
const missing = new Map(parts)
|
||||
missing.delete(plan[1])
|
||||
assert.throws(() => reassemble(plan, missing), /缺少块 index=1/)
|
||||
|
||||
// 篡改一块(id 对不上)
|
||||
const tampered = new Map(parts)
|
||||
const t = Buffer.from(parts.get(plan[1]))
|
||||
t[0] = t[0] ^ 0xff
|
||||
tampered.set(plan[1], t)
|
||||
assert.throws(() => reassemble(plan, tampered), /块校验失败 index=1/)
|
||||
})
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 组 3:内容寻址存储(S1 —— 去重 / LRU / 计数)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test('store: 同内容重复入库 ⇒ 去重(容量不重复计,命中仍计数)', () => {
|
||||
const store = new ContentStore({ maxBytes: 1024 * 1024 })
|
||||
const bytes = makeBytes(4096, 3)
|
||||
const id = blockIdOf(bytes)
|
||||
store.put(id, bytes)
|
||||
const usedAfterFirst = store.bytes
|
||||
store.put(id, bytes)
|
||||
store.put(id, Buffer.from(bytes))
|
||||
assert.strictEqual(store.size, 1, '同 id 只应有一份')
|
||||
assert.strictEqual(store.bytes, usedAfterFirst, '去重 ⇒ 容量不得重复累加')
|
||||
assert.strictEqual(store.counters().puts, 1, '只有第一次算新入库')
|
||||
})
|
||||
|
||||
test('store: 超上限 ⇒ LRU 淘汰,且 evicted 计数与容量约束都被断言', () => {
|
||||
const blockSize = 1024
|
||||
const store = new ContentStore({ maxBytes: 3 * blockSize })
|
||||
const ids = []
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
const b = makeBytes(blockSize, 100 + i)
|
||||
const id = blockIdOf(b)
|
||||
ids.push(id)
|
||||
store.put(id, b)
|
||||
}
|
||||
assert.ok(store.bytes <= 3 * blockSize, `容量必须被约束:${store.bytes} <= ${3 * blockSize}`)
|
||||
assert.ok(store.counters().evicted >= 2, `应发生 ≥2 次淘汰,实际 ${store.counters().evicted}`)
|
||||
assert.strictEqual(store.get(ids[4]) === undefined, false, '最近写入的块必须还在')
|
||||
assert.strictEqual(store.get(ids[0]), undefined, '最早的块应已被淘汰')
|
||||
})
|
||||
|
||||
test('store: 单块超上限 ⇒ 明确拒绝并计数(⛔ 不是静默丢)', () => {
|
||||
const store = new ContentStore({ maxBytes: 10_000, maxBlockBytes: 500 })
|
||||
const big = makeBytes(600, 1)
|
||||
const id = blockIdOf(big)
|
||||
const c0 = store.counters()
|
||||
assert.throws(() => store.put(id, big), /超过单块上限/)
|
||||
assert.strictEqual(store.counters().oversizeRejected, c0.oversizeRejected + 1)
|
||||
})
|
||||
|
||||
test('store: 命中/未命中计数可断言(E1 的直接来源)', () => {
|
||||
const store = new ContentStore({ maxBytes: 1024 * 1024 })
|
||||
const bytes = makeBytes(2048, 9)
|
||||
const id = blockIdOf(bytes)
|
||||
const c0 = store.counters()
|
||||
assert.strictEqual(store.get(id), undefined)
|
||||
assert.strictEqual(store.counters().misses, c0.misses + 1, '未命中必须 +1')
|
||||
store.put(id, bytes)
|
||||
const c1 = store.counters()
|
||||
assert.notStrictEqual(store.get(id), undefined)
|
||||
assert.strictEqual(store.counters().hits, c1.hits + 1, '命中必须 +1(这就是"省下一次回源"的机器判据)')
|
||||
})
|
||||
|
||||
test('store: 非法 id 与非法构造参数必须拒绝', () => {
|
||||
assert.throws(() => new ContentStore({ maxBytes: 0 }), /正数/)
|
||||
assert.throws(() => new ContentStore({ maxBytes: -5 }), /正数/)
|
||||
const store = new ContentStore({ maxBytes: 1024 })
|
||||
const c0 = store.counters()
|
||||
assert.throws(() => store.put('not-a-valid-id', Buffer.from('x')), /非法块 id/)
|
||||
assert.strictEqual(store.counters().putRejected, c0.putRejected + 1)
|
||||
assert.strictEqual(store.get('not-a-valid-id'), undefined)
|
||||
assert.strictEqual(store.has('not-a-valid-id'), false)
|
||||
})
|
||||
|
||||
test('store: 默认上限 = DEFAULT_MAX_BYTES 且 > 0(P5 预算的固化点)', () => {
|
||||
assert.strictEqual(DEFAULT_MAX_BYTES, 64 * 1024 * 1024)
|
||||
const store = new ContentStore()
|
||||
assert.strictEqual(store.size, 0)
|
||||
assert.strictEqual(store.bytes, 0)
|
||||
})
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 组 4:内容源优先级链(E6 —— 判别器必须落计数)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test('E6: 五档链路顺序固定,且每次取块点名来源档位(计数递增)', async () => {
|
||||
assert.deepStrictEqual(
|
||||
DEFAULT_TIER_ORDER,
|
||||
['local', 'peer', 'edge', 'region', 'origin'],
|
||||
'内容源优先级:本地 → 同局域网 peer → 同区域边缘缓存 → 区域分发点 → 公网源',
|
||||
)
|
||||
assert.deepStrictEqual(SOURCE_TIERS, DEFAULT_TIER_ORDER)
|
||||
|
||||
const hits = []
|
||||
const chain = new ContentSourceChain({
|
||||
// 四档都返回内容(夹具):链必须**按顺序**命中第一档可用者
|
||||
fetchers: {
|
||||
local: async () => undefined, // 本地没有
|
||||
peer: async () => ({ tier: 'peer', bytes: Buffer.from('from-peer') }),
|
||||
edge: async () => ({ tier: 'edge', bytes: Buffer.from('from-edge') }),
|
||||
region: async () => ({ tier: 'region', bytes: Buffer.from('from-region') }),
|
||||
origin: async () => ({ tier: 'origin', bytes: Buffer.from('from-origin') }),
|
||||
},
|
||||
onHit: (tier) => hits.push(tier),
|
||||
})
|
||||
|
||||
const r = await chain.fetch('a'.repeat(BLOCK_ID_HEX_LEN))
|
||||
assert.strictEqual(r?.bytes.toString(), 'from-peer', '本地空 ⇒ 命中 peer(顺序:local → peer)')
|
||||
assert.deepStrictEqual(hits, ['peer'], '命中档位必须被点名')
|
||||
|
||||
const c = chain.counters()
|
||||
assert.strictEqual(c.local, 0)
|
||||
assert.strictEqual(c.peer, 1, 'E6:peer 命中计数必须为 1(⛔ 只写日志 = 不合格)')
|
||||
assert.strictEqual(c.edge, 0, '⛔ 不许越过 peer 直接打 edge')
|
||||
})
|
||||
|
||||
test('E6: 逐档递减 —— 去掉某一档后必须落到下一档,且计数逐档递增', async () => {
|
||||
const make = (available) =>
|
||||
new ContentSourceChain({
|
||||
fetchers: Object.fromEntries(
|
||||
DEFAULT_TIER_ORDER.map((tier) => [
|
||||
tier,
|
||||
async () => (available.includes(tier) ? { tier, bytes: Buffer.from(`from-${tier}`) } : undefined),
|
||||
]),
|
||||
),
|
||||
})
|
||||
|
||||
// 只 origin 有
|
||||
const c1 = make(['origin'])
|
||||
const r1 = await c1.fetch('b'.repeat(BLOCK_ID_HEX_LEN))
|
||||
assert.strictEqual(r1?.tier, 'origin')
|
||||
assert.deepStrictEqual(
|
||||
DEFAULT_TIER_ORDER.map((t) => c1.counters()[t]),
|
||||
[0, 0, 0, 0, 1],
|
||||
'只有 origin 命中 ⇒ 计数必须是 [0,0,0,0,1]',
|
||||
)
|
||||
|
||||
// edge 也有 ⇒ 必须停在 edge(⛔ 不许越过更靠前的档)
|
||||
const c2 = make(['edge', 'origin'])
|
||||
const r2 = await c2.fetch('c'.repeat(BLOCK_ID_HEX_LEN))
|
||||
assert.strictEqual(r2?.tier, 'edge')
|
||||
assert.strictEqual(c2.counters().origin, 0, '⛔ 越过 edge 去打 origin = 优先级链失效')
|
||||
|
||||
// local 也有 ⇒ 必须停在 local(最短路径)
|
||||
const c3 = make(DEFAULT_TIER_ORDER)
|
||||
const r3 = await c3.fetch('d'.repeat(BLOCK_ID_HEX_LEN))
|
||||
assert.strictEqual(r3?.tier, 'local')
|
||||
assert.deepStrictEqual(
|
||||
DEFAULT_TIER_ORDER.map((t) => c3.counters()[t]),
|
||||
[1, 0, 0, 0, 0],
|
||||
'local 命中 ⇒ 后面四档计数必须全 0',
|
||||
)
|
||||
})
|
||||
|
||||
test('E6: 全档皆无 ⇒ 返回 undefined 且**每一档都留下未命中痕迹**(⛔ 不许静默返空)', async () => {
|
||||
const tried = []
|
||||
const chain = new ContentSourceChain({
|
||||
fetchers: Object.fromEntries(DEFAULT_TIER_ORDER.map((tier) => [tier, async () => undefined])),
|
||||
onMiss: (tier) => tried.push(tier),
|
||||
})
|
||||
const r = await chain.fetch('e'.repeat(BLOCK_ID_HEX_LEN))
|
||||
assert.strictEqual(r, undefined)
|
||||
assert.deepStrictEqual(tried, DEFAULT_TIER_ORDER, '"没有"必须留下**逐档**痕迹,否则就是本线反复踩的静默失效')
|
||||
const total = Object.values(chain.counters()).reduce((a, b) => a + b, 0)
|
||||
assert.strictEqual(total, 0, '未命中不得计入任何档位的命中计数')
|
||||
assert.strictEqual(chain.misses(), DEFAULT_TIER_ORDER.length, '未命中合计 = 档数')
|
||||
})
|
||||
|
||||
test('E6: fetcher 抛错必须计数并**继续往下一档**(⛔ 不许整体失败、不许静默吞)', async () => {
|
||||
const chain = new ContentSourceChain({
|
||||
fetchers: {
|
||||
local: async () => {
|
||||
throw new Error('local boom')
|
||||
},
|
||||
peer: async () => undefined,
|
||||
edge: async () => ({ tier: 'edge', bytes: Buffer.from('ok') }),
|
||||
region: async () => undefined,
|
||||
origin: async () => undefined,
|
||||
},
|
||||
})
|
||||
const r = await chain.fetch('f'.repeat(BLOCK_ID_HEX_LEN))
|
||||
assert.strictEqual(r?.bytes.toString(), 'ok', '前面的档抛错后必须继续尝试后面的档')
|
||||
assert.strictEqual(chain.errors().local, 1, '抛错必须单独计数(与"没有"可区分)')
|
||||
})
|
||||
|
||||
test('source: emptySourceCounters 覆盖五档且形状完整', () => {
|
||||
const c = emptySourceCounters()
|
||||
for (const tier of DEFAULT_TIER_ORDER) {
|
||||
assert.strictEqual(typeof c[tier], 'number', `档位 ${tier} 必须有计数`)
|
||||
assert.strictEqual(c[tier], 0)
|
||||
}
|
||||
})
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 组 5:分组隔离(E5 —— 跨组不穿透)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test('E5: 分组键由 (网络, 组) 决定;跨组/跨网一律不同组', () => {
|
||||
assert.strictEqual(groupKeyOf('u:1', 'lan-a'), 'u:1|lan-a')
|
||||
assert.notStrictEqual(groupKeyOf('u:1', 'lan-a'), groupKeyOf('u:1', 'lan-b'), '同网不同组必须不同')
|
||||
assert.notStrictEqual(groupKeyOf('u:1', 'lan-a'), groupKeyOf('u:2', 'lan-a'), '跨网不得同组')
|
||||
assert.strictEqual(sameGroup('u:1', 'lan-a', 'u:1', 'lan-a'), true)
|
||||
assert.strictEqual(sameGroup('u:1', 'lan-a', 'u:1', 'lan-b'), false)
|
||||
assert.strictEqual(sameGroup('u:1', 'lan-a', 'u:2', 'lan-a'), false)
|
||||
})
|
||||
|
||||
test('E5: 本地组内取块命中;跨组一律拒绝且计入 crossGroupDenied(⛔ 必须是显式拒绝)', () => {
|
||||
const group = new ContentPeerGroup({ network: 'u:1', group: 'lan-a', log: () => {} })
|
||||
const bytes = makeBytes(4096, 21)
|
||||
const id = blockIdOf(bytes)
|
||||
|
||||
// 同组 peer 供块
|
||||
group.addPeer({ name: 'u:1/p1', network: 'u:1', group: 'lan-a', holds: [id] })
|
||||
// 跨组 peer 也持有同一块(用来证明"不穿透")
|
||||
group.addPeer({ name: 'u:1/p2', network: 'u:1', group: 'lan-b', holds: [id] })
|
||||
group.addPeer({ name: 'u:2/p3', network: 'u:2', group: 'lan-a', holds: [id] })
|
||||
|
||||
const inGroup = group.candidates(id)
|
||||
assert.deepStrictEqual(
|
||||
inGroup.map((p) => p.name),
|
||||
['u:1/p1'],
|
||||
'E5:只有同 (网, 组) 的 peer 能成为候选',
|
||||
)
|
||||
|
||||
const c0 = group.counters()
|
||||
const denied = group.markDenied('u:1/p2', id)
|
||||
assert.strictEqual(denied, true, '跨组请求必须被显式拒绝')
|
||||
const c1 = group.counters()
|
||||
assert.strictEqual(c1.crossGroupDenied, c0.crossGroupDenied + 1, 'E5:跨组拒绝必须计数(⛔ 静默返空 = 假绿)')
|
||||
assert.strictEqual(group.markDenied('u:2/p3', id), true)
|
||||
assert.strictEqual(group.counters().crossGroupDenied, 2)
|
||||
|
||||
// 同组请求不被拒绝
|
||||
assert.strictEqual(group.markDenied('u:1/p1', id), false, '同组不得被拒')
|
||||
assert.strictEqual(group.counters().crossGroupDenied, 2, '同组不得计入跨组拒绝')
|
||||
})
|
||||
|
||||
test('E5: PEER_COUNTER_KEYS 齐全(判别器存在性可断言)', () => {
|
||||
const group = new ContentPeerGroup({ network: 'ops', group: 'lan-x', log: () => {} })
|
||||
const c = group.counters()
|
||||
for (const k of PEER_COUNTER_KEYS) {
|
||||
assert.strictEqual(typeof c[k], 'number', `peer 判别器 ${k} 必须是数字(OBS 会断言它)`)
|
||||
}
|
||||
assert.ok(PEER_COUNTER_KEYS.includes('crossGroupDenied'))
|
||||
assert.ok(PEER_COUNTER_KEYS.includes('peerHits'))
|
||||
assert.ok(PEER_COUNTER_KEYS.includes('peerMisses'))
|
||||
})
|
||||
|
||||
test('E5: 分组不影响同组内多 peer 的可用性(E2 的支撑)', () => {
|
||||
const group = new ContentPeerGroup({ network: 'u:1', group: 'lan-a', log: () => {} })
|
||||
const a = makeBytes(2048, 31)
|
||||
const b = makeBytes(2048, 32)
|
||||
const ida = blockIdOf(a)
|
||||
const idb = blockIdOf(b)
|
||||
group.addPeer({ name: 'u:1/pA', network: 'u:1', group: 'lan-a', holds: [ida] })
|
||||
group.addPeer({ name: 'u:1/pB', network: 'u:1', group: 'lan-a', holds: [idb] })
|
||||
// 只拿到一部分也能开始共享:两人各持一块,请求任一块都有人能供
|
||||
assert.deepStrictEqual(group.candidates(ida).map((p) => p.name), ['u:1/pA'])
|
||||
assert.deepStrictEqual(group.candidates(idb).map((p) => p.name), ['u:1/pB'])
|
||||
})
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 组 6:装配级不回归(本单的模块集合必须自洽)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test('端到端(进程内):切分 → 入库 → 计划重组 → 校验闭环', async () => {
|
||||
const store = new ContentStore({ maxBytes: 8 * 1024 * 1024 })
|
||||
const buf = makeBytes(4 * 1024 * 1024 + 777, 77)
|
||||
const r = chunkify(buf)
|
||||
for (const c of r.chunks) store.put(c.id, c.bytes)
|
||||
|
||||
// 只拿到 30% 也要能列出计划(E2)
|
||||
const plan = planOf(buf)
|
||||
assert.strictEqual(plan.ids.length, r.chunks.length)
|
||||
|
||||
// 从 store 取回并重组
|
||||
const parts = new Map()
|
||||
for (const id of plan.ids) {
|
||||
const got = store.get(id)
|
||||
assert.notStrictEqual(got, undefined)
|
||||
parts.set(id, got)
|
||||
}
|
||||
assert.ok(reassemble(plan.ids, parts).equals(buf), '重组必须逐字节等于原内容')
|
||||
assert.strictEqual(store.counters().hits, plan.ids.length, `命中数应等于块数 ${plan.ids.length}`)
|
||||
})
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 组 7:序㉘ · 单 B「组密钥加密」—— F1–F6(交接单_组密钥加密_20260918 §5)
|
||||
// ⚠️ 断言一律**精确等值**(⛔ 不用 `>=` 让实现变差还能过,除非判据本身就要求"至少")
|
||||
// ⚠️ 本组**刻意不进** `package.json` 的 test 列表(那张列表是硬编码的)⇒ 单跑本文件
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { ContentRuntime } from '../lib/net/relay/content/runtime.js'
|
||||
import { generateAuthorityKey, signPayloadWith } from '../lib/net/relay/identity.js'
|
||||
import {
|
||||
ContentCipher,
|
||||
CONTENT_CRYPTO_COUNTER_KEYS,
|
||||
CONTENT_CIPHER_VERSION,
|
||||
IV_LEN,
|
||||
KEY_LEN,
|
||||
TAG_LEN,
|
||||
groupKeyCredentialPayload,
|
||||
keyIdOf,
|
||||
loadGroupKeyFile,
|
||||
verifyGroupKeyCredential,
|
||||
} from '../lib/net/relay/content/crypto.js'
|
||||
|
||||
/** 造一个确定性的组密钥(⛔ 不用随机 —— 用例必须可复现)。 */
|
||||
function makeKey(seed = 9) {
|
||||
return makeBytes(KEY_LEN, seed)
|
||||
}
|
||||
|
||||
test('F1 确定性:同组 + 同明文两次 ⇒ 密文逐字节相同("按哈希共享块"的前提)', () => {
|
||||
const cipher = new ContentCipher({ groupKey: 'ops|relay', epoch: 1, key: makeKey(1) })
|
||||
const plain = Buffer.from('dshs-content-block-0123456789', 'utf8')
|
||||
const a = cipher.encryptBlock(plain)
|
||||
const b = cipher.encryptBlock(plain)
|
||||
assert.ok(a.equals(b), '同明文两次必须逐字节相同(否则块 id 每次都变 ⇒ 去重与 peer 命中全废)')
|
||||
assert.strictEqual(a.length, plain.length + IV_LEN + TAG_LEN, '密文 = iv(12) + tag(16) + 明文')
|
||||
assert.strictEqual(cipher.decodeBlock(a).toString('utf8'), 'dshs-content-block-0123456789')
|
||||
})
|
||||
|
||||
test('F1-b 换 epoch / 换密钥 ⇒ 密文必不同;且旧密文用新密钥解 ⇒ 认证失败', () => {
|
||||
const k = makeKey(1)
|
||||
const c1 = new ContentCipher({ groupKey: 'ops|relay', epoch: 1, key: k })
|
||||
const c2 = new ContentCipher({ groupKey: 'ops|relay', epoch: 2, key: k })
|
||||
const c3 = new ContentCipher({ groupKey: 'ops|relay', epoch: 1, key: makeKey(2) })
|
||||
const plain = Buffer.from('same-plaintext', 'utf8')
|
||||
const b1 = c1.encryptBlock(plain)
|
||||
assert.ok(!b1.equals(c2.encryptBlock(plain)), '换 epoch ⇒ AAD 与密钥双变 ⇒ 密文必须不同')
|
||||
assert.ok(!b1.equals(c3.encryptBlock(plain)), '换密钥 ⇒ 密文必须不同')
|
||||
assert.strictEqual(c2.decodeBlock(b1), undefined, '跨 epoch 必须**在认证阶段**被拒')
|
||||
assert.strictEqual(c3.decodeBlock(b1), undefined, '跨密钥必须被拒')
|
||||
assert.strictEqual(c2.counters().decryptRejected, 1)
|
||||
assert.strictEqual(c3.counters().decryptRejected, 1)
|
||||
})
|
||||
|
||||
test('F1-c 块 id 挂密文(β′):同组两次独立"切块+加密" ⇒ id 序列逐字一致;换组 ⇒ 全变', () => {
|
||||
const buf = makeBytes(3 * 1024 * 1024 + 13, 41)
|
||||
const A = new ContentCipher({ groupKey: 'ops|grpA', epoch: 1, key: makeKey(3) })
|
||||
const B = new ContentCipher({ groupKey: 'ops|grpB', epoch: 1, key: makeKey(4) })
|
||||
const encA = { encode: (p) => A.encryptBlock(p) }
|
||||
const r1 = chunkify(buf, 1024 * 1024, encA)
|
||||
const r2 = chunkify(buf, 1024 * 1024, encA)
|
||||
assert.deepStrictEqual(r1.chunks.map((c) => c.id), r2.chunks.map((c) => c.id), '组内 id 必须稳定')
|
||||
const rb = chunkify(buf, 1024 * 1024, { encode: (p) => B.encryptBlock(p) })
|
||||
assert.notDeepStrictEqual(
|
||||
r1.chunks.map((c) => c.id),
|
||||
rb.chunks.map((c) => c.id),
|
||||
'换组 ⇒ 全部 id 改变(= 轮换 ⇒ 全量回源 的直接证据)',
|
||||
)
|
||||
// 明文哈希(α)与密文哈希(β′)**必须不同**(否则等于没换口径)
|
||||
assert.notDeepStrictEqual(r1.chunks.map((c) => c.id), chunkify(buf, 1024 * 1024).chunks.map((c) => c.id))
|
||||
// 密文口径下"计划"与"切分"必须同源(否则先查 peer 会查错 id)
|
||||
assert.deepStrictEqual(planOf(buf, 1024 * 1024, encA).ids, r1.chunks.map((c) => c.id))
|
||||
})
|
||||
|
||||
test('F1-d 缺省(不传 transforms)⇒ 与序㉔ 逐字一致(零回归口径)', () => {
|
||||
const buf = makeBytes(1024 * 1024 + 5, 5)
|
||||
const a = chunkify(buf)
|
||||
const b = chunkify(buf, DEFAULT_BLOCK_SIZE, {})
|
||||
assert.deepStrictEqual(a.chunks.map((c) => c.id), b.chunks.map((c) => c.id))
|
||||
assert.strictEqual(a.contentId, b.contentId)
|
||||
assert.strictEqual(a.contentId, contentIdOf(buf), '缺省 contentId 仍是明文哈希')
|
||||
})
|
||||
|
||||
test('F2 共享不退化:put → 链上取回两次 ⇒ local 命中递增且解密成功', async () => {
|
||||
const cipher = new ContentCipher({ groupKey: 'ops|relay', epoch: 1, key: makeKey(6) })
|
||||
const rt = new ContentRuntime({ network: 'ops', group: 'relay', cipher, storeMaxBytes: 16 * 1024 * 1024 })
|
||||
const buf = makeBytes(2 * 1024 * 1024 + 9, 61)
|
||||
const put = rt.putContent(buf)
|
||||
assert.strictEqual(put.plan.length, 3, '2 MiB + 9 B ⇒ 3 块')
|
||||
const once = await rt.fetchContent(put.plan)
|
||||
const twice = await rt.fetchContent(put.plan)
|
||||
assert.ok(once.equals(buf), '第一次取回必须是原明文')
|
||||
assert.ok(twice.equals(buf), '第二次取回必须是原明文')
|
||||
const c = rt.snapshot()
|
||||
assert.strictEqual(c.source.local, 6, '两次取回 × 3 块 ⇒ local 命中 6(⛔ 不许退化成回源)')
|
||||
assert.strictEqual(c.source.origin, 0)
|
||||
assert.strictEqual(c.store.hits, 6)
|
||||
assert.strictEqual(c.crypto.decrypts, 6)
|
||||
assert.strictEqual(c.crypto.decryptRejected, 0)
|
||||
// ⚠️ 存储里放的是**密文**:库里的字节 ≠ 明文
|
||||
const firstId = put.plan[0]
|
||||
assert.ok(!rt.store.get(firstId).equals(buf.subarray(0, 1024 * 1024)), '库里的块必须是密文')
|
||||
// 16 字节的标记在密文里必须找不到
|
||||
assert.ok(rt.store.get(firstId).length > 1024 * 1024, '密文比明文长 iv+tag')
|
||||
})
|
||||
|
||||
test('F2-b 去重仍成立(E1 口径):同一份内容重复 put ⇒ cache 不翻倍', () => {
|
||||
const cipher = new ContentCipher({ groupKey: 'ops|relay', epoch: 1, key: makeKey(7) })
|
||||
const rt = new ContentRuntime({ network: 'ops', group: 'relay', cipher })
|
||||
const buf = makeBytes(1024 * 1024 * 2, 71)
|
||||
rt.putContent(buf)
|
||||
const blocksAfter1 = rt.snapshot().storeBlocks
|
||||
assert.strictEqual(blocksAfter1, 2)
|
||||
rt.putContent(buf)
|
||||
assert.strictEqual(rt.snapshot().storeBlocks, blocksAfter1, '同内容重复入库必须去重(否则 E1 直接崩)')
|
||||
assert.strictEqual(rt.snapshot().store.puts, blocksAfter1, '重复 put 不得重复计 puts')
|
||||
})
|
||||
|
||||
test('F3 跨组不可解:组 B 用自己的密钥解组 A 的密文 ⇒ 拒 + decryptRejected +1', () => {
|
||||
const A = new ContentCipher({ groupKey: 'ops|grpA', epoch: 1, key: makeKey(8) })
|
||||
const B = new ContentCipher({ groupKey: 'ops|grpB', epoch: 1, key: makeKey(9) })
|
||||
const blob = A.encryptBlock(Buffer.from('只有 A 组能看的内容', 'utf8'))
|
||||
assert.strictEqual(B.decodeBlock(blob), undefined)
|
||||
assert.strictEqual(B.counters().decryptRejected, 1)
|
||||
assert.strictEqual(A.decodeBlock(blob).toString('utf8'), '只有 A 组能看的内容')
|
||||
assert.strictEqual(A.counters().decryptRejected, 0)
|
||||
})
|
||||
|
||||
test('F3-b 跨组判定不被放松:未声明 epoch 的 peer 仍按"同组即可用"(序㉔ 语义不变)', () => {
|
||||
const g = new ContentPeerGroup({ network: 'ops', group: 'lan-a', epoch: 1 })
|
||||
g.addPeer({ name: 'ops/p1', network: 'ops', group: 'lan-a', holds: ['aa'] })
|
||||
g.addPeer({ name: 'ops/p2', network: 'ops', group: 'lan-b', holds: ['aa'] })
|
||||
assert.deepStrictEqual(g.candidates('aa').map((p) => p.name), ['ops/p1'])
|
||||
assert.strictEqual(g.counters().crossGroupDenied, 0, '跨组拒绝语义未被本单改动')
|
||||
})
|
||||
|
||||
test('F3-c epoch 不一致 ⇒ 不作候选 + 单独计数(⛔ 不混进 crossGroupDenied)', () => {
|
||||
const g = new ContentPeerGroup({ network: 'ops', group: 'lan-a', epoch: 2 })
|
||||
g.addPeer({ name: 'ops/p1', network: 'ops', group: 'lan-a', holds: ['aa'], epoch: 1 })
|
||||
g.addPeer({ name: 'ops/p2', network: 'ops', group: 'lan-a', holds: ['aa'], epoch: 2 })
|
||||
assert.deepStrictEqual(g.candidates('aa').map((p) => p.name), ['ops/p2'])
|
||||
assert.strictEqual(g.counters().epochMismatch, 1)
|
||||
assert.strictEqual(g.counters().crossGroupDenied, 0)
|
||||
})
|
||||
|
||||
test('F4 明文不出现(+ 反向证明):加密 ⇒ 扫不到;不加密 ⇒ 必须扫得到', () => {
|
||||
const marker = 'PLAINTEXT-MARKER-7f3a91'
|
||||
const cipher = new ContentCipher({ groupKey: 'ops|relay', epoch: 1, key: makeKey(10) })
|
||||
const rt = new ContentRuntime({ network: 'ops', group: 'relay', cipher })
|
||||
const put = rt.putContent(Buffer.from(`head-${marker}-tail`, 'utf8'))
|
||||
for (const id of put.plan) {
|
||||
assert.ok(!rt.store.get(id).includes(Buffer.from(marker, 'utf8')), '密文里不许出现明文标记')
|
||||
}
|
||||
// ⛔ 反向夹具:**关掉加密** ⇒ 同一份内容入库后**必须**扫得到标记(否则"没扫到"毫无意义)
|
||||
const plainRt = new ContentRuntime({ network: 'ops', group: 'relay' })
|
||||
const put2 = plainRt.putContent(Buffer.from(`head-${marker}-tail`, 'utf8'))
|
||||
assert.ok(
|
||||
plainRt.store.get(put2.plan[0]).includes(Buffer.from(marker, 'utf8')),
|
||||
'未启用加密时必须扫得到(= 判据有效性证明)',
|
||||
)
|
||||
})
|
||||
|
||||
test('F4-b 启动自证:detChecks/detMismatches/plainScans/plainLeaks 四键语义', async () => {
|
||||
const cipher = new ContentCipher({ groupKey: 'ops|relay', epoch: 1, key: makeKey(11) })
|
||||
const rt = new ContentRuntime({ network: 'ops', group: 'relay', cipher })
|
||||
const ok = await rt.selfProbe('self-probe-marker')
|
||||
assert.strictEqual(ok, true)
|
||||
const c = rt.snapshot().crypto
|
||||
assert.strictEqual(c.detChecks, 1)
|
||||
assert.strictEqual(c.detMismatches, 0)
|
||||
assert.strictEqual(c.plainScans, 1)
|
||||
assert.strictEqual(c.plainLeaks, 0)
|
||||
assert.strictEqual(c.decrypts, 2, '自证两次解密:crypto 自证一次 + 优先级链上一次')
|
||||
})
|
||||
|
||||
test('F4-c 不启用加密 ⇒ crypto 键整体缺席(⛔ 不是补零 ⇒ 探针才能记 SKIP)', async () => {
|
||||
const rt = new ContentRuntime({ network: 'ops', group: 'relay' })
|
||||
assert.strictEqual('crypto' in rt.snapshot(), false)
|
||||
assert.strictEqual(rt.cryptoEnabled, false)
|
||||
assert.strictEqual(rt.snapshot().peer.epochMismatch, 0)
|
||||
assert.strictEqual(await rt.selfProbe('x'), undefined, '未启用 ⇒ 自证直接短路(不打日志、不计数)')
|
||||
})
|
||||
|
||||
test('F6 失败关闭(五种情形各有具名原因,⛔ 绝不静默"以为加密了")', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'cfgx-'))
|
||||
const k = makeKey(12)
|
||||
const good = { version: 1, group: 'relay', epoch: 1, key: k.toString('base64') }
|
||||
// ① no-file
|
||||
assert.strictEqual(loadGroupKeyFile({ file: join(dir, 'nope.json'), group: 'relay' }).reason, 'no-file')
|
||||
// ② bad-perms(🔴 POSIX-only:靠 enforcePerms 显式开启 ⇒ 本机 Windows 也能证明"判据有牙")
|
||||
const p600 = join(dir, 'k600.json')
|
||||
writeFileSync(p600, JSON.stringify(good), { mode: 0o600 })
|
||||
assert.strictEqual(loadGroupKeyFile({ file: p600, group: 'relay', enforcePerms: true }).reason, 'bad-perms')
|
||||
// ③ group-mismatch
|
||||
assert.strictEqual(loadGroupKeyFile({ file: p600, group: 'local', enforcePerms: false }).reason, 'group-mismatch')
|
||||
// ③-b network 不配也要拦("同名不同网")
|
||||
const pNet = join(dir, 'knet.json')
|
||||
writeFileSync(pNet, JSON.stringify({ ...good, network: 'ops' }), { mode: 0o600 })
|
||||
assert.strictEqual(
|
||||
loadGroupKeyFile({ file: pNet, group: 'relay', network: 'u:1', enforcePerms: false }).reason,
|
||||
'group-mismatch',
|
||||
)
|
||||
// ④ bad-key
|
||||
const pBad = join(dir, 'kbad.json')
|
||||
writeFileSync(pBad, JSON.stringify({ ...good, key: Buffer.alloc(16).toString('base64') }), { mode: 0o600 })
|
||||
assert.strictEqual(loadGroupKeyFile({ file: pBad, group: 'relay', enforcePerms: false }).reason, 'bad-key')
|
||||
// ⑤ bad-epoch
|
||||
const pEpoch = join(dir, 'kepoch.json')
|
||||
writeFileSync(pEpoch, JSON.stringify({ ...good, epoch: 0 }), { mode: 0o600 })
|
||||
assert.strictEqual(loadGroupKeyFile({ file: pEpoch, group: 'relay', enforcePerms: false }).reason, 'bad-epoch')
|
||||
// ⑥ 解析错
|
||||
const pJunk = join(dir, 'kjunk.json')
|
||||
writeFileSync(pJunk, '{ not json', { mode: 0o600 })
|
||||
assert.strictEqual(loadGroupKeyFile({ file: pJunk, group: 'relay', enforcePerms: false }).reason, 'parse-error')
|
||||
// ⑦ 正例:装载成功且 keyId 与 key 一致;双 epoch 计数对
|
||||
const both = { ...good, epoch: 2, previous: [{ epoch: 1, key: makeKey(3).toString('base64') }] }
|
||||
writeFileSync(p600, JSON.stringify(both), { mode: 0o600 })
|
||||
const okv = loadGroupKeyFile({ file: p600, group: 'relay', network: 'ops', enforcePerms: false })
|
||||
assert.strictEqual(okv.ok, true)
|
||||
assert.strictEqual(okv.keyId, keyIdOf(k))
|
||||
assert.strictEqual(okv.epoch, 2)
|
||||
assert.strictEqual(okv.epochs, 2)
|
||||
assert.strictEqual(okv.permsChecked, false)
|
||||
// 🔴 权限判定的**平台语义必须分开写**:Windows 没有 POSIX 权限位(实测 mode 恒 666)
|
||||
// ⇒ 强制判定在 Windows 上**必然**报 `bad-perms`(这正是"判据有牙"的证明),
|
||||
// 在 POSIX 上 0600 的文件则必须通过并回报 `permsChecked=true`。
|
||||
const forced = loadGroupKeyFile({ file: p600, group: 'relay', enforcePerms: true })
|
||||
if (process.platform === 'win32') {
|
||||
assert.strictEqual(forced.reason, 'bad-perms', 'Windows:无权限位 ⇒ 强制判定必红(判据有牙)')
|
||||
} else {
|
||||
assert.strictEqual(forced.ok, true)
|
||||
assert.strictEqual(forced.permsChecked, true, 'POSIX:0600 ⇒ 通过且已判定')
|
||||
}
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test('F6-b 双 epoch 过渡:窗口内两种密文都能解;超窗口 ⇒ epochExpired + 拒', () => {
|
||||
const kOld = makeKey(13)
|
||||
const kNew = makeKey(14)
|
||||
const retired = new Date(Date.now() - 60_000).toISOString()
|
||||
const cipher = new ContentCipher({
|
||||
groupKey: 'ops|relay',
|
||||
epoch: 2,
|
||||
key: kNew,
|
||||
previous: [{ epoch: 1, key: kOld, retiredAt: retired }],
|
||||
graceMs: 10 * 60 * 1000,
|
||||
})
|
||||
const old1 = new ContentCipher({ groupKey: 'ops|relay', epoch: 1, key: kOld })
|
||||
const blob = old1.encryptBlock(Buffer.from('上一代的内容', 'utf8'))
|
||||
assert.strictEqual(cipher.decodeBlock(blob).toString('utf8'), '上一代的内容', '窗口内旧 epoch 必须能解')
|
||||
assert.strictEqual(cipher.counters().epochExpired, 0)
|
||||
// 超窗口(graceMs 小于已退休时长)
|
||||
const expired = new ContentCipher({
|
||||
groupKey: 'ops|relay',
|
||||
epoch: 2,
|
||||
key: kNew,
|
||||
previous: [{ epoch: 1, key: kOld, retiredAt: retired }],
|
||||
graceMs: 1000,
|
||||
})
|
||||
assert.strictEqual(expired.decodeBlock(blob), undefined)
|
||||
assert.strictEqual(expired.counters().epochExpired, 1)
|
||||
assert.strictEqual(expired.counters().decryptRejected, 1)
|
||||
// 写入一律用**新** epoch(旧 epoch 只解不写)
|
||||
assert.strictEqual(cipher.epoch, 2)
|
||||
assert.deepStrictEqual(cipher.epochs(), [2, 1])
|
||||
})
|
||||
|
||||
test('S1 组密钥凭据:签名者签发 ⇒ 验签通过;篡改 epoch ⇒ 失败关闭', () => {
|
||||
const signer = generateAuthorityKey()
|
||||
const k = makeKey(15)
|
||||
const doc = {
|
||||
version: CONTENT_CIPHER_VERSION,
|
||||
network: 'ops',
|
||||
group: 'relay',
|
||||
epoch: 1,
|
||||
keyId: keyIdOf(k),
|
||||
issuedAt: new Date(0).toISOString(),
|
||||
}
|
||||
const sig = signPayloadWith(signer.privateKeyPem, groupKeyCredentialPayload(doc))
|
||||
const okv = verifyGroupKeyCredential(doc, sig, [signer.publicKey])
|
||||
assert.strictEqual(okv.ok, true)
|
||||
assert.strictEqual(okv.doc.epoch, 1)
|
||||
// 篡改 epoch ⇒ 验签必失败(载荷覆盖全部字段)
|
||||
const bad = verifyGroupKeyCredential({ ...doc, epoch: 2 }, sig, [signer.publicKey])
|
||||
assert.strictEqual(bad.ok, false)
|
||||
assert.strictEqual(bad.reason, 'signature-mismatch')
|
||||
// ⛔ 不可验 = 不接受(受信签名者为空)
|
||||
assert.strictEqual(verifyGroupKeyCredential(doc, sig, []).reason, 'no-trusted-keys')
|
||||
// 🔴 载荷内**不含密钥本体**(本单红线:中继只广播三元组)
|
||||
const payload = groupKeyCredentialPayload(doc)
|
||||
assert.ok(!payload.includes(k.toString('base64')))
|
||||
assert.ok(payload.includes('keyId=' + keyIdOf(k)))
|
||||
})
|
||||
|
||||
test('口径守卫:探针 OBS-23 的键表必须与 crypto 模块**同源**(防"改了模块没改探针")', () => {
|
||||
const probeSrc = readFileSync(new URL('../scripts/overlay-probe.cjs', import.meta.url), 'utf8')
|
||||
const m = /const CRYPTO_NUM_KEYS = \[([\s\S]*?)\]/.exec(probeSrc)
|
||||
assert.notStrictEqual(m, null, '探针里必须能找到 CRYPTO_NUM_KEYS')
|
||||
const inProbe = [...m[1].matchAll(/'([a-zA-Z]+)'/g)].map((x) => x[1])
|
||||
assert.deepStrictEqual(inProbe, [...CONTENT_CRYPTO_COUNTER_KEYS], '探针键表与模块键表必须逐字一致')
|
||||
})
|
||||
|
||||
test('F5/E1 口径:加密前后"回源份数"不变(同组 N 次取用 ⇒ 全部 local 命中、零回源)', async () => {
|
||||
const cipher = new ContentCipher({ groupKey: 'ops|relay', epoch: 1, key: makeKey(16) })
|
||||
const rt = new ContentRuntime({ network: 'ops', group: 'relay', cipher, storeMaxBytes: 32 * 1024 * 1024 })
|
||||
const pack = makeBytes(1024 * 1024 * 2 + 11, 88)
|
||||
let missing = 0
|
||||
for (let i = 0; i < 4; i += 1) {
|
||||
const put = rt.putContent(pack)
|
||||
for (const id of put.plan) {
|
||||
const got = await rt.source.fetch(id)
|
||||
if (got === undefined) missing += 1
|
||||
}
|
||||
}
|
||||
assert.strictEqual(missing, 0)
|
||||
assert.strictEqual(rt.snapshot().source.local, 12, '4 轮 × 3 块,全部 local 命中(= E1 的"回源 1 份"口径)')
|
||||
assert.strictEqual(rt.snapshot().store.puts, 3, '同内容只入库 3 个块(去重)')
|
||||
assert.strictEqual(rt.snapshot().crypto.decryptRejected, 0)
|
||||
})
|
||||
@@ -0,0 +1,503 @@
|
||||
/**
|
||||
* 覆盖网络 · **序㉖ 骨干稳定选路(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,⛔ 不编数)')
|
||||
})
|
||||
@@ -36,6 +36,8 @@ import {
|
||||
signDirectory,
|
||||
waitUpOnStatus,
|
||||
} from '../lib/net/relay/index.js'
|
||||
/** 序㉗:候选链观测(`OBS-21` 的判据锚点 —— 行格式一变,探针就会**静默取不到值**)。 */
|
||||
import { CAND_OBS_PREFIX, RelayCandidateObservation, candidateObsMs } from '../lib/worker/relay-tunnel.js'
|
||||
|
||||
/* ─────────── 搭台工具 ─────────── */
|
||||
|
||||
@@ -842,3 +844,94 @@ test('F20 burst 窗口(计划内重启)内必须继续等,窗口过后才
|
||||
assert.equal(ok, true, 'burst 窗口内必须继续等 ⇒ 对端重启完就 up')
|
||||
assert.ok(ms >= 1_300, `⛔ 不许在窗口内就判死(旧坑:100ms 处就返回 false);实测 ${ms}ms`)
|
||||
})
|
||||
|
||||
/* ─────────── 序㉗:候选链观测(`OBS-21`「每连接候选数 ≥ 2」的判据锚点) ─────────── */
|
||||
|
||||
/**
|
||||
* O1 · **观测行的固定 key 序就是探针的判据锚点** ⇒ 逐字锁住。
|
||||
*
|
||||
* 🔴 为什么这条最要紧:探针 `OBS-21` 是按 `key=value` **按名取值**的 ⇒ 谁把键改名 / 把值里的
|
||||
* 空白留在行里 / 少写一个键,探针会**静默取不到**(本线最贵的一类失效:不是报错,是"看不见")。
|
||||
*/
|
||||
test('O1 观测行格式锁定:固定 key 序 + count 为条数 + hosts 按主机去重(丢 scheme)', () => {
|
||||
const lines = []
|
||||
const obs = new RelayCandidateObservation('manager', (l) => lines.push(l), 0)
|
||||
obs.record(
|
||||
[
|
||||
'wss://alotbuy.com/dshs-relay',
|
||||
'https://alotbuy.com/other',
|
||||
'wss://106.54.21.172/dshs-relay',
|
||||
],
|
||||
'cache',
|
||||
'/var/lib/dshs/overlay/directory.json',
|
||||
)
|
||||
assert.equal(lines.length, 1, '一次 record 写一行')
|
||||
const line = lines[0]
|
||||
assert.ok(line.startsWith(CAND_OBS_PREFIX), `必须以固定前缀开头:${line}`)
|
||||
const keys = [...line.matchAll(/(?:^|\s)([a-z]+)=/g)].map((m) => m[1])
|
||||
assert.deepEqual(
|
||||
keys,
|
||||
['scope', 'resolves', 'count', 'hosts', 'source', 'detail', 'urls'],
|
||||
'固定 key 序 = 契约(⛔ 改它 = 破坏探针判据)',
|
||||
)
|
||||
const snap = obs.snapshot()
|
||||
assert.equal(snap.count, 3, 'count = 候选**条数**(⛔ 不按主机去重)')
|
||||
assert.equal(snap.hosts, 2, 'hosts = 独立主机数(alotbuy.com 的两条算同一台 —— scheme 不参与)')
|
||||
assert.equal(snap.source, 'cache')
|
||||
assert.equal(snap.resolves, 1)
|
||||
assert.equal(snap.unresolved, false)
|
||||
})
|
||||
|
||||
/**
|
||||
* O2 · **稳态不刷屏**:`RELAY_FAILOVER_CHECK_MS` 是 2 s,同形状会被反复解析 ⇒ 变化才写。
|
||||
* ⚠️ 但解析次数必须**照实累计**(探针拿 `resolves` 判"这个进程到底解析过没有")。
|
||||
*/
|
||||
test('O2 同形状重复 record ⛔ 不重复写行(防刷屏),形状一变立刻写', () => {
|
||||
const lines = []
|
||||
const obs = new RelayCandidateObservation('worker', (l) => lines.push(l), 0)
|
||||
const urls = ['wss://a.example/dshs-relay']
|
||||
obs.record(urls, 'chain', '')
|
||||
obs.record(urls, 'chain', '')
|
||||
obs.record(urls, 'chain', '')
|
||||
assert.equal(lines.length, 1, '稳态巡检 ⛔ 不许刷屏')
|
||||
assert.equal(obs.snapshot().resolves, 3, '解析次数必须照实累计')
|
||||
obs.record(['wss://a.example/dshs-relay', 'wss://b.example/dshs-relay'], 'chain', '')
|
||||
assert.equal(lines.length, 2, '条数变了(候选集变化)⇒ 必须立刻写')
|
||||
assert.equal(obs.snapshot().count, 2)
|
||||
})
|
||||
|
||||
/**
|
||||
* O3 · 🔴 **「从未解析」与「解析出 0 条」必须可区分**(本线两处静默失效都栽在这一点),
|
||||
* 且**周期重发在零网络下也能写出行**(探针是**事后**读,没有它就可能读不到行)。
|
||||
*/
|
||||
test('O3 「从未解析」≠「解析出 0 条」+ 周期重发零网络写行 + stop 后不再写', async () => {
|
||||
const lines = []
|
||||
const obs = new RelayCandidateObservation('worker', (l) => lines.push(l), 5)
|
||||
const s0 = obs.snapshot()
|
||||
assert.equal(s0.unresolved, true, '没解析过 ⇒ unresolved')
|
||||
assert.equal(s0.source, 'unresolved')
|
||||
assert.equal(s0.resolves, 0)
|
||||
obs.start()
|
||||
await new Promise((r) => setTimeout(r, 40))
|
||||
obs.stop()
|
||||
assert.ok(lines.length >= 2, `周期重发必须写出行(实测 ${lines.length} 行)`)
|
||||
assert.ok(
|
||||
lines.every((l) => l.includes('source=unresolved')),
|
||||
'未解析时重发的行也必须**诚实**写 unresolved(⛔ 不许假装 0 条 = 已解析)',
|
||||
)
|
||||
const n = lines.length
|
||||
await new Promise((r) => setTimeout(r, 30))
|
||||
assert.equal(lines.length, n, 'stop() 后 ⛔ 不许再写')
|
||||
obs.record([], 'none', 'none')
|
||||
assert.equal(obs.snapshot().unresolved, false, '解析过就是解析过 —— 哪怕解析出 0 条')
|
||||
assert.equal(obs.snapshot().count, 0)
|
||||
assert.equal(obs.snapshot().hosts, 0)
|
||||
})
|
||||
|
||||
/** O4 · 重发周期取自 env(与 `switcher.ts#relayFailoverThresholds` 同纪律:值格必须纯数字)。 */
|
||||
test('O4 重发周期取自 env,`0` = 关闭,非纯数字 ⇒ 回退默认(不静默变成 NaN)', () => {
|
||||
assert.equal(candidateObsMs({}), 300_000, '缺省 300 s')
|
||||
assert.equal(candidateObsMs({ RELAY_CAND_OBS_MS: '0' }), 0, '0 = 关闭周期重发')
|
||||
assert.equal(candidateObsMs({ RELAY_CAND_OBS_MS: '60000' }), 60_000, '显式覆写生效')
|
||||
assert.equal(candidateObsMs({ RELAY_CAND_OBS_MS: '6e4' }), 300_000, '非纯数字 ⇒ 回退默认')
|
||||
})
|
||||
+694
-1
@@ -14,9 +14,10 @@
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import { createHmac, randomBytes } from 'node:crypto'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { createServer as createTcpServer, connect } from 'node:net'
|
||||
import { test } from 'node:test'
|
||||
import { MUX, OPS_NETWORK, RelayClient, RelayDialer, RelayServer, chooseNode, decodeMux, encodeJsonFrame, encodeMux, logicalName, parseKeysInline } from '../lib/net/relay/index.js'
|
||||
import { MUX, OPS_NETWORK, RelayClient, RelayDialer, RelayRendezvous, RelayServer, chooseNode, decodeMux, encodeJsonFrame, encodeMux, hostNameIndex, logicalName, parseKeysInline, relayEndpointTarget } from '../lib/net/relay/index.js'
|
||||
|
||||
const BASE = 45000
|
||||
const SPAN = 200
|
||||
@@ -1316,3 +1317,695 @@ test('T24 拨号池:未分配槽位被一条连接命中后 ⛔ 不得自毁
|
||||
assert.equal(dialer.status().pool, await liveCount(), '分配后池账仍须与实际在听恒等')
|
||||
assert.ok(await poolPortBusy(local), `已分配的落点口 ${local} 应在听`)
|
||||
})
|
||||
|
||||
/* ═══════════════════ 序⑲ presence(节点在线态)T25–T31 ═══════════════════ */
|
||||
|
||||
/**
|
||||
* presence 时序口径的**测试档**(生产默认 = grace 10 s / debounce 30 s / batch 1 s / TTL 45 s)。
|
||||
* 单测不可能真等 40 s ⇒ 全部注入。⚠️ 口径本身**不改**,只改"等多久"。
|
||||
*/
|
||||
const PT = { presenceGraceMs: 150, presenceOfflineDebounceMs: 250, presenceBatchMs: 40, presenceTtlMs: 3_000 }
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
||||
|
||||
/** 起一套"订阅方 + 若干 worker"的现场(⛔ `sub` 只是普通客户端,不需要拨号方身份)。 */
|
||||
async function presenceScene(t, workerIds, tune = {}) {
|
||||
const secret = randomBytes(32).toString('hex')
|
||||
const keys = new Map(['sub', ...workerIds].map((id) => [id, secret]))
|
||||
const logs = []
|
||||
const server = new RelayServer({
|
||||
port: 0,
|
||||
keys,
|
||||
instancePortBase: BASE,
|
||||
instancePortSpan: SPAN,
|
||||
...PT,
|
||||
...tune,
|
||||
log: (l) => logs.push(l),
|
||||
})
|
||||
await server.start()
|
||||
const url = `ws://127.0.0.1:${server.boundPort}${PATH}`
|
||||
const clients = []
|
||||
const mk = (hostId) => {
|
||||
/**
|
||||
* ⚠️ 两条硬约束(都是本轮实测踩到的,⛔ 别再踩):
|
||||
* ① 非拨号方客户端**必须至少声明一个端口**(`handleHello` 回 `no-ports` 且 `retryable=false` ⇒ 永不 `up`)。
|
||||
* 这里声明 `BASE` 只为满足该约束 —— relay 为它绑的是**动态回环口**(`listen(0)`),声明值只是转发目标,⛔ 不占本机端口。
|
||||
* ② **每个 hostId 都必须有 key**(否则 `AUTH DENY unknown-host`)⇒ 按需登记,
|
||||
* 这样 `presenceScene(t, [])` 之后仍可 `mk('w-xx')`(⛔ 不用把待造主机在入参里预先枚举一遍)。
|
||||
*/
|
||||
keys.set(hostId, secret)
|
||||
const c = new RelayClient({ url, hostId, secret, ports: [BASE], log: () => {} })
|
||||
clients.push(c)
|
||||
c.start()
|
||||
return c
|
||||
}
|
||||
t.after(async () => {
|
||||
for (const c of clients) c.stop()
|
||||
await server.stop()
|
||||
})
|
||||
const sub = mk('sub')
|
||||
const up = (c) => waitFor(() => c.status().state === 'up', 5_000)
|
||||
assert.ok(await up(sub), '订阅方未注册成功')
|
||||
return { server, sub, mk, up, logs }
|
||||
}
|
||||
|
||||
/** 读一次真 `/status`(顺便驱动 `statusHits` 判别器)。 */
|
||||
async function readStatus(server) {
|
||||
const res = await fetch(`http://127.0.0.1:${server.boundPort}/status`)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* 极简**原始** WS 客户端 —— 只为"未知帧号必须被显式拒绝(⛔ 不静默丢弃)"这一条判据。
|
||||
*
|
||||
* 故意不做认证:它要在**未认证**那一刻发帧 ⇒ 不需要 MAC(也就不能伪造合法注册)。
|
||||
* 返回服务端回的 WS close 码;`null` = 没等到 close(= 静默丢弃)。
|
||||
*/
|
||||
function rawWsProbe(port, muxType, ms = 3_000) {
|
||||
return new Promise((resolve) => {
|
||||
const sock = connect(port, '127.0.0.1')
|
||||
let buf = Buffer.alloc(0)
|
||||
let headerEnd = -1
|
||||
let sent = false
|
||||
let done = false
|
||||
let timer
|
||||
const finish = (code) => {
|
||||
if (done) return
|
||||
done = true
|
||||
clearTimeout(timer)
|
||||
// `end()` 而不是 `destroy()`:给上面那帧 "close 回应" 一个真的发出去的机会(见下)。
|
||||
sock.end()
|
||||
resolve(code)
|
||||
}
|
||||
timer = setTimeout(() => finish(null), ms)
|
||||
sock.on('error', () => finish(null))
|
||||
sock.on('data', (chunk) => {
|
||||
buf = Buffer.concat([buf, chunk])
|
||||
if (!sent) {
|
||||
headerEnd = buf.indexOf('\r\n\r\n')
|
||||
if (headerEnd < 0) return
|
||||
sent = true
|
||||
// 掩码的 binary mux 帧(客户端必须 mask,RFC 6455 §5.1)
|
||||
const body = encodeMux(muxType, 0, Buffer.alloc(0))
|
||||
const mask = randomBytes(4)
|
||||
const masked = Buffer.from(body)
|
||||
for (let i = 0; i < masked.length; i++) masked[i] ^= mask[i & 3]
|
||||
const head = Buffer.alloc(6)
|
||||
head[0] = 0x82
|
||||
head[1] = 0x80 | masked.length
|
||||
mask.copy(head, 2)
|
||||
sock.write(Buffer.concat([head, masked]))
|
||||
}
|
||||
// 在握手之后的数据里找服务端发的 close 帧(opcode 0x8;服务端→客户端**不掩码**)
|
||||
for (let i = headerEnd + 4; i + 3 < buf.length; ) {
|
||||
const opcode = buf[i] & 0x0f
|
||||
const len = buf[i + 1] & 0x7f
|
||||
if (opcode === 0x8) {
|
||||
const code = buf.readUInt16BE(i + 2)
|
||||
/**
|
||||
* 🔴 收到 close **必须回一个 close**(RFC 6455 §5.5.1),然后 `end()` 走优雅 TCP 收尾。
|
||||
* 实测(本轮踩到):回都不回就直接 `destroy()` ⇒ 服务端的 ws 会一直等对端 close 帧
|
||||
* (默认 30 s)⇒ 这条连接一直挂在 http server 上 ⇒ `server.stop()` 里的
|
||||
* `http.close(cb)` **永不回调** ⇒ 整个测试文件在 T26 之后被父级取消
|
||||
* (报 `Promise resolution is still pending but the event loop has already resolved`)。
|
||||
* 这是**测试夹具**的坑,不是产品缺陷 —— 生产停机有 `closeAllConnections()` 兜底。
|
||||
*/
|
||||
const mask = randomBytes(4)
|
||||
const payload = Buffer.alloc(2)
|
||||
payload.writeUInt16BE(code, 0)
|
||||
const masked = Buffer.from(payload)
|
||||
for (let k = 0; k < masked.length; k++) masked[k] ^= mask[k & 3]
|
||||
const head = Buffer.alloc(6)
|
||||
head[0] = 0x88
|
||||
head[1] = 0x80 | masked.length
|
||||
mask.copy(head, 2)
|
||||
try {
|
||||
sock.write(Buffer.concat([head, masked]))
|
||||
} catch {
|
||||
/* 对端已经走了 */
|
||||
}
|
||||
finish(code)
|
||||
return
|
||||
}
|
||||
i += 2 + len
|
||||
}
|
||||
})
|
||||
sock.write(
|
||||
`GET ${PATH} HTTP/1.1\r\nHost: 127.0.0.1\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n` +
|
||||
`Sec-WebSocket-Key: ${randomBytes(16).toString('base64')}\r\nSec-WebSocket-Version: 13\r\n\r\n`,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
test('T25 presence 生命周期:注册即在线 · 连接更替不闪烁 · 断连过 grace+debounce 才离线', async (t) => {
|
||||
const { server, sub, mk, up } = await presenceScene(t, [])
|
||||
const name = logicalName(OPS_NETWORK, 'w-p')
|
||||
|
||||
sub.subscribePresence()
|
||||
assert.ok(await waitFor(() => sub.presenceStatus().state === 'subscribed', 3_000), '订阅未生效')
|
||||
// E4 首帧即全量:`SNAP` **一帧拿全**,⛔ 不是逐 host 拉
|
||||
assert.equal(sub.presenceStatus().snapFrames, 1, '首帧必须是 SNAP')
|
||||
assert.equal(sub.presenceStatus().pushFrames, 0, '`SNAP` 之前 ⛔ 不得有增量帧')
|
||||
|
||||
// E1 稳态:没有任何状态变化 ⇒ ⛔ 一个帧都不推
|
||||
const idle = sub.presenceStatus().pushFrames
|
||||
await sleep(300)
|
||||
assert.equal(sub.presenceStatus().pushFrames, idle, '稳态必须 0 帧(变化驱动,无变化不推)')
|
||||
|
||||
const w = mk('w-p')
|
||||
assert.ok(await up(w), 'worker 未注册')
|
||||
assert.ok(await waitFor(() => sub.presenceStatus().pushFrames === idle + 1, 3_000), '上线应恰好推 1 帧')
|
||||
assert.equal(sub.presenceStatus().lastFrameEntries, 1, '该帧只应带这 1 条')
|
||||
assert.equal(sub.presenceOf(name)?.online, true, '上线事件未更新本地镜像')
|
||||
assert.equal(sub.presenceOf(name)?.devices, 1)
|
||||
|
||||
/**
|
||||
* E6(聚合口径的**可观测后果**):同 hostId 的第二条连接注册时,服务端会**先顶掉旧会话再接入新会话**
|
||||
* (`handleHello` 的 `superseded by new session`)⇒ 此刻"一条连接关了、另一条开了"。
|
||||
* 聚合口径要求:**这中间不许产生任何状态事件**(否则每次重连都会让上层看到一次闪烁 = 净退化)。
|
||||
*/
|
||||
const w2 = mk('w-p')
|
||||
assert.ok(await up(w2), '第二条连接未注册')
|
||||
await sleep(400)
|
||||
assert.equal(sub.presenceStatus().pushFrames, idle + 1, '连接更替(顶旧接新)⛔ 不得产生任何新帧')
|
||||
assert.equal(sub.presenceOf(name)?.online, true, '更替期间必须**始终**在线(⛔ 不得闪一下离线)')
|
||||
assert.equal(sub.presenceOf(name)?.devices, 1, '旧会话已被顶掉 ⇒ 活连接数仍是 1(devices 必须诚实)')
|
||||
|
||||
// E5:真正全断 ⇒ 先过 grace 仍在线,再过 debounce 才转离线
|
||||
w2.stop()
|
||||
await sleep(120)
|
||||
const during = sub.presenceOf(name)
|
||||
assert.equal(during?.online, true, `≤ grace(${PT.presenceGraceMs}ms) 必须仍在线(防抖动闪烁)`)
|
||||
/**
|
||||
* 倒计时(`offlineInMs`)**只认 `/status`(或 `SNAP`)上的值,⛔ 不从推送镜像里读**:
|
||||
* 它是"生成那一刻"的相对量,而推送是**变化驱动**的(无变化不推)⇒ 镜像里那个值一发出就过期。
|
||||
* 想让它实时更新就只能周期性推帧 —— 那正是 E1「稳态 0 帧」要干掉的东西。
|
||||
* 换句话说:**事实走推送,带时钟刻度的心跳量走兜底读取**(D5 的兜底就不只是"降级可用",而是分工)。
|
||||
*/
|
||||
const st = await readStatus(server)
|
||||
const mine = st.presence.find((p) => p.name === name)
|
||||
assert.equal(typeof mine?.offlineInMs, 'number', 'grace 窗口内 `/status` 必须给出"还有多久转离线"')
|
||||
assert.ok(
|
||||
mine.offlineInMs > 0 && mine.offlineInMs <= PT.presenceGraceMs + PT.presenceOfflineDebounceMs,
|
||||
`倒计时必须落在 (0, grace+debounce] 内,实测 ${mine?.offlineInMs}`,
|
||||
)
|
||||
assert.equal(sub.presenceStatus().pushFrames, idle + 1, 'grace 窗口内 ⛔ 一个帧都不许推(E2:一次变化 ≤1 帧)')
|
||||
assert.ok(await waitFor(() => sub.presenceOf(name)?.online === false, 4_000), '过 grace+debounce 必须转离线')
|
||||
assert.equal(sub.presenceStatus().pushFrames, idle + 2, '离线应恰好再推 1 帧')
|
||||
})
|
||||
|
||||
test('T26 线协议:SUB/UNSUB/PRESENCE/SNAP 帧号**末尾追加**且与既有集合不重叠', async (t) => {
|
||||
// 帧号:既有的 0x01–0x0f 语义一字未动,新帧全部 > 0x0f
|
||||
assert.equal(MUX.SUB, 0x10)
|
||||
assert.equal(MUX.UNSUB, 0x11)
|
||||
assert.equal(MUX.PRESENCE, 0x12)
|
||||
assert.equal(MUX.SNAP, 0x13)
|
||||
for (const t2 of [MUX.SUB, MUX.UNSUB, MUX.PRESENCE, MUX.SNAP]) {
|
||||
assert.ok(t2 > MUX.DIAL_ACK, `新帧号 ${t2} 必须**追加**在既有分配表末尾(⛔ 不改既有语义)`)
|
||||
}
|
||||
const codes = Object.values(MUX)
|
||||
assert.equal(new Set(codes).size, codes.length, '帧号必须两两不同')
|
||||
const back = decodeMux(encodeJsonFrame(MUX.SUB, 0, { all: true }))
|
||||
assert.equal(back.type, MUX.SUB, 'SUB 帧编解码往返失败')
|
||||
|
||||
// 🔴 "未知帧号 ⇒ 显式报错,⛔ 不静默丢弃"(本线头号教训):用一个服务端**不认识**的帧号打它
|
||||
const { server } = await presenceScene(t, [])
|
||||
const before = server.status().counters.authFailed
|
||||
const code = await rawWsProbe(server.boundPort, 0x99)
|
||||
assert.equal(code, 1008, `未知帧号必须被**显式拒绝**(期望 close 1008 policy-violation,实得 ${code})`)
|
||||
assert.equal(server.status().counters.authFailed, before + 1, '拒绝必须**有计数**(否则等于没记)')
|
||||
})
|
||||
|
||||
test('T27 批合并:同一 1 s 窗口内 N 次状态变化只推 1 帧(E2/E4)', async (t) => {
|
||||
const ids = Array.from({ length: 6 }, (_, i) => `w-b${i}`)
|
||||
// 窗口取 300ms:6 次回环握手远小于它 ⇒ 判据确定性足够(⛔ 不靠"碰巧合上")
|
||||
const { sub, mk, up } = await presenceScene(t, [], { presenceBatchMs: 300 })
|
||||
sub.subscribePresence()
|
||||
assert.ok(await waitFor(() => sub.presenceStatus().state === 'subscribed', 3_000), '订阅未生效')
|
||||
const base = sub.presenceStatus().pushFrames
|
||||
|
||||
const ws = ids.map((id) => mk(id))
|
||||
await Promise.all(ws.map((c) => up(c)))
|
||||
const name0 = logicalName(OPS_NETWORK, ids[0])
|
||||
assert.ok(await waitFor(() => sub.presenceOf(name0)?.online === true, 3_000), '上线事件未到达')
|
||||
await sleep(900) // 让所有可能的批窗口都过去
|
||||
assert.equal(sub.presenceStatus().pushFrames, base + 1, `${ids.length} 台同窗口上线 ⇒ 必须合并成**1 帧**`)
|
||||
assert.equal(sub.presenceStatus().lastFrameEntries, ids.length, '这一帧必须**带数组**(6 条),⛔ 不是逐个 host 一条')
|
||||
|
||||
// 反向:同窗口全部下线 ⇒ 同样只 1 帧(合并方向也要成立,⛔ 不能只测上线)
|
||||
for (const c of ws) c.stop()
|
||||
await waitFor(() => sub.presenceOf(name0)?.online === false, 5_000)
|
||||
await sleep(900)
|
||||
assert.equal(sub.presenceStatus().pushFrames, base + 2, `${ids.length} 台同窗口下线 ⇒ 必须合并成 1 帧`)
|
||||
assert.equal(sub.presenceStatus().lastFrameEntries, ids.length, '离线帧同样必须带全 6 条')
|
||||
})
|
||||
|
||||
test('T28 订阅可见性**只收窄**:跨网订阅必须显式拒绝 + 计数(⛔ 不静默返空,E8/D6)', async (t) => {
|
||||
const { server, sub } = await presenceScene(t, ['w-p'])
|
||||
sub.subscribePresence([logicalName('u:5', 'd1')])
|
||||
assert.ok(await waitFor(() => sub.presenceStatus().rejected === 1, 3_000), '跨网订阅必须被**显式拒绝**')
|
||||
const st = await readStatus(server)
|
||||
assert.equal(st.counters.rejected, 1, '服务端必须**有计数**(⛔ 静默返空 = 假绿)')
|
||||
assert.equal(sub.presenceOf('u:5/d1'), undefined, '被拒的订阅 ⛔ 不得留下任何条目')
|
||||
assert.equal(sub.presenceStatus().state, 'idle', '被拒后状态必须回到 idle(上层据此回退 /status)')
|
||||
assert.ok((await readStatus(server)).counters.subs === 0, '被拒的订阅 ⛔ 不得计入 subs')
|
||||
})
|
||||
|
||||
test('T29 TTL 安全网:漏掉 close 事件的"幽灵连接"超 TTL 后被摘掉并收口离线(E5 第三支)', async (t) => {
|
||||
const { server, sub } = await presenceScene(t, [], {
|
||||
presenceTtlMs: 400,
|
||||
presenceGraceMs: 100,
|
||||
presenceOfflineDebounceMs: 150,
|
||||
})
|
||||
sub.subscribePresence()
|
||||
assert.ok(await waitFor(() => sub.presenceStatus().state === 'subscribed', 3_000), '订阅未生效')
|
||||
const name = logicalName(OPS_NETWORK, 'w-ghost')
|
||||
|
||||
/**
|
||||
* **故障注入**(⛔ 不是模拟业务,而是模拟**漏掉了 close 事件**这一种故障):
|
||||
* 只往 presence 表里记一条"连接",既不建真连接、也就永远不会收到 close ⇒ 这正是 TTL 要兜的事。
|
||||
* 不这么做的话,"漏事件 ⇒ 永久假在线"这条路径**根本无法被触发**(正常路径总会 dropSession)。
|
||||
*/
|
||||
server.presenceTouch({ id: 'ghost-1', hostId: 'w-ghost', network: OPS_NETWORK, ports: new Set() })
|
||||
assert.ok(await waitFor(() => sub.presenceOf(name)?.online === true, 3_000), '幽灵连接应先被认定为在线')
|
||||
|
||||
assert.ok(
|
||||
await waitFor(() => sub.presenceOf(name)?.online === false, 5_000),
|
||||
'TTL 安全网未能自愈 ⇒ 漏掉 close 的事件会**永久**留在册',
|
||||
)
|
||||
})
|
||||
|
||||
test('T30 主路径=订阅 / 兜底=/status:订阅新鲜时 ⛔ 不读兜底;不可用时**必须**回退(D5/E7)', async (t) => {
|
||||
const calls = []
|
||||
const name = logicalName(OPS_NETWORK, 'w-1')
|
||||
const rv = (presence) =>
|
||||
new RelayRendezvous({
|
||||
dialTargetUrl: 'wss://example.invalid/dshs-relay',
|
||||
addressOf: () => '127.0.0.1:19100',
|
||||
online: (n) => {
|
||||
calls.push(`/status兜底:${n}`)
|
||||
return true
|
||||
},
|
||||
presence,
|
||||
})
|
||||
|
||||
// ① 订阅新鲜 ⇒ **以它为准**,兜底一次都不读
|
||||
assert.ok((await rv(() => true).resolve(name)) !== undefined, '订阅说在线 ⇒ 必须解析成功')
|
||||
assert.equal(calls.length, 0, '订阅新鲜时 ⛔ 不许读兜底(/status)')
|
||||
// ② 订阅说"不在" ⇒ 直接不认识(同样不读兜底)
|
||||
assert.equal(await rv(() => false).resolve(name), undefined, '订阅说离线 ⇒ 必须回 undefined')
|
||||
assert.equal(calls.length, 0, '订阅能给答案时 ⛔ 不许读兜底')
|
||||
// ③ 订阅"不知道"(undefined)⇒ **必须回退**,且照样拿到在线态
|
||||
assert.ok((await rv(() => undefined).resolve(name)) !== undefined, '订阅不可用时必须能回退且不瞎')
|
||||
assert.equal(calls.length, 1, '回退必须**真的调用**兜底(否则就是"订阅一断就全瞎")')
|
||||
|
||||
// E7 最终一致:在线态**没有任何**跨节点同步通道(两台 relay 各管各的 ⇒ 天然无脑裂源)
|
||||
const secret = randomBytes(32).toString('hex')
|
||||
const keys = new Map([['w-e7', secret]])
|
||||
const s1 = new RelayServer({ port: 0, keys, instancePortBase: BASE, instancePortSpan: SPAN, ...PT, log: () => {} })
|
||||
const s2 = new RelayServer({ port: 0, keys, instancePortBase: BASE, instancePortSpan: SPAN, ...PT, log: () => {} })
|
||||
await s1.start()
|
||||
await s2.start()
|
||||
const c = new RelayClient({
|
||||
url: `ws://127.0.0.1:${s1.boundPort}${PATH}`,
|
||||
hostId: 'w-e7',
|
||||
secret,
|
||||
ports: [BASE],
|
||||
log: () => {},
|
||||
})
|
||||
t.after(async () => {
|
||||
c.stop()
|
||||
await s1.stop()
|
||||
await s2.stop()
|
||||
})
|
||||
c.start()
|
||||
assert.ok(await waitFor(() => c.status().state === 'up', 5_000), 'client 未注册')
|
||||
assert.equal(s1.status().presence.length, 1, '第一台应有该 host')
|
||||
assert.equal(s2.status().presence.length, 0, '⛔ 另一台**不得**知道它(有同步才是缺陷:那是脑裂源)')
|
||||
})
|
||||
|
||||
test('T31 presence 判别器:subs / pushed / rejected / statusHits 都能被断言(⛔ 不许只写日志)', async (t) => {
|
||||
const { server, sub, mk, up } = await presenceScene(t, [])
|
||||
assert.equal((await readStatus(server)).counters.subs, 0, '没人订阅 ⇒ subs=0')
|
||||
sub.subscribePresence()
|
||||
assert.ok(await waitFor(() => sub.presenceStatus().state === 'subscribed', 3_000), '订阅未生效')
|
||||
assert.equal((await readStatus(server)).counters.subs, 1, '订阅生效 ⇒ subs=1(gauge)')
|
||||
|
||||
const pushed0 = (await readStatus(server)).counters.pushed
|
||||
const w = mk('w-p')
|
||||
assert.ok(await up(w), 'worker 未注册')
|
||||
assert.ok(await waitFor(() => sub.presenceStatus().pushFrames >= 1, 3_000), '未收到推送')
|
||||
await sleep(400)
|
||||
const pushed1 = (await readStatus(server)).counters.pushed
|
||||
assert.ok(pushed1 > pushed0, 'pushed 必须随真实推送增长(否则判别器是死的)')
|
||||
await sleep(400)
|
||||
assert.equal((await readStatus(server)).counters.pushed, pushed1, '稳态下 pushed 必须**停住不走**(E1)')
|
||||
|
||||
// statusHits:两次读数之差 = 1 ⇒ 期间**没有别人**在读 /status
|
||||
const a = (await readStatus(server)).counters.statusHits
|
||||
const b = (await readStatus(server)).counters.statusHits
|
||||
assert.equal(b - a, 1, `两次读数之差应为 1(期间只有本测试在读),实得 ${b - a}`)
|
||||
|
||||
/**
|
||||
* `snaps`(E4 的机器可读判据):有订阅者却 `snaps = 0` ⇒ 首帧走的不是 `SNAP`。
|
||||
* 同理把 `presenceTiming` 钉住 —— 探针(`OBS-13`)拿它与参数表 `PRESENCE_*` 对口径,
|
||||
* 对不上就是**口径漂移**(改了默认值却没改表 ⇒ 表在撒谎)。
|
||||
*/
|
||||
const st = await readStatus(server)
|
||||
assert.equal(st.counters.snaps, 1, '本轮只有 1 次订阅 ⇒ 必须恰好 1 帧 SNAP')
|
||||
assert.ok(st.counters.snaps <= st.counters.pushed, 'snaps 是 pushed 的子集(⛔ 不得大于)')
|
||||
assert.deepEqual(
|
||||
{
|
||||
graceMs: st.presenceTiming.graceMs,
|
||||
offlineDebounceMs: st.presenceTiming.offlineDebounceMs,
|
||||
batchMs: st.presenceTiming.batchMs,
|
||||
ttlMs: st.presenceTiming.ttlMs,
|
||||
subMax: st.presenceTiming.subMax,
|
||||
},
|
||||
{
|
||||
graceMs: PT.presenceGraceMs,
|
||||
offlineDebounceMs: PT.presenceOfflineDebounceMs,
|
||||
batchMs: PT.presenceBatchMs,
|
||||
ttlMs: PT.presenceTtlMs,
|
||||
subMax: 0,
|
||||
},
|
||||
'`presenceTiming` 必须把注入的时序口径如实下发(探针 `OBS-13` 靠它对口径)',
|
||||
)
|
||||
|
||||
sub.stop()
|
||||
await sleep(200)
|
||||
assert.equal((await readStatus(server)).counters.subs, 0, '连接断了订阅必须随之消失(→ 回到 0)')
|
||||
})
|
||||
|
||||
test('T32 落点不丢:注册即在线那一帧必须带**非 0** 落点,且端口变更会被推送(序⑲ 收口实测踩到的假死)', async (t) => {
|
||||
const { sub, mk, up } = await presenceScene(t, [])
|
||||
const name = logicalName(OPS_NETWORK, 'w-l')
|
||||
sub.subscribePresence()
|
||||
assert.ok(await waitFor(() => sub.presenceStatus().state === 'subscribed', 3_000), '订阅未生效')
|
||||
|
||||
/**
|
||||
* 🔴 这一条对应一个**实测踩到的假死**:`presenceTouch` 曾在 `ensureEndpoint` **之前**调用 ⇒
|
||||
* 首帧里 `localPorts[].localPort = 0`;而发布只在"在线态翻转"时发生 ⇒ 那个 0 永远修不回来 ⇒
|
||||
* 订阅方(Manager)`addressOf` 查不到落点 ⇒ 实例页**打不开但不报错**。
|
||||
* 判据必须卡在"**订阅之后**才上线的主机"上:老主机早就在册,落点已被别的路径补过。
|
||||
*/
|
||||
const w = mk('w-l')
|
||||
assert.ok(await up(w), 'worker 未注册')
|
||||
assert.ok(await waitFor(() => sub.presenceOf(name)?.online === true, 3_000), '上线事件未到达')
|
||||
const lp0 = sub.presenceOf(name)?.localPorts ?? []
|
||||
assert.equal(lp0.length, 1, '首帧必须带该 host 的落点条目')
|
||||
assert.equal(lp0[0].port, BASE, '落点条目的 port 必须与声明一致')
|
||||
assert.ok(lp0[0].localPort > 0, `落点口号必须**非 0**(实测 ${lp0[0].localPort} ⇒ 0 = addressOf 查不到 ⇒ 页面假死)`)
|
||||
|
||||
// 运行期加一个端口(`PORT_ADD`)⇒ 新落点也必须**推给订阅方**(否则同样查不到)
|
||||
const frames0 = sub.presenceStatus().pushFrames
|
||||
const NEW_PORT = BASE + 1
|
||||
assert.equal(await w.addPort(NEW_PORT), true, 'PORT_ADD 未被接受')
|
||||
assert.ok(await waitFor(() => (sub.presenceOf(name)?.ports ?? []).length === 2, 3_000), '端口变更未推送')
|
||||
await sleep(400)
|
||||
const lp1 = sub.presenceOf(name)?.localPorts ?? []
|
||||
assert.equal(lp1.length, 2, '两个端口都必须在落点表里')
|
||||
assert.ok(
|
||||
lp1.every((x) => x.localPort > 0),
|
||||
`新端口落点同样必须非 0:${JSON.stringify(lp1)}`,
|
||||
)
|
||||
assert.equal(sub.presenceStatus().pushFrames, frames0 + 1, '端口变更 = 一次状态变化 ⇒ 恰好 1 帧(E2 配额内)')
|
||||
})
|
||||
|
||||
/* ═══════════ 序㉑ P-1 修复(门的判据 = 订阅已建立 ∧ 链路活着)T33–T34 ═══════════ */
|
||||
|
||||
/**
|
||||
* 🔴 **P-1 回归**(在册缺陷,2026-09-17 序 ⑳ 实测)。
|
||||
*
|
||||
* 病根:门(`presenceFresh()`)原判据 = "最近一次 presence **载荷**距今 ≤ TTL"。而 presence 是
|
||||
* **变化驱动**的 —— 稳态下一帧都不推 ⇒ 45 s 后必然过期 ⇒ 门自己重开、`/status` 轮询照旧在跑
|
||||
* (真机实测降幅仅 **1.10×**,设计目标 ≥ 10×)。**"没有变化"被读成了"没有数据"**。
|
||||
*
|
||||
* 本用例把"稳态 + 超过 TTL"这个组合钉死:帧数必须仍是 0(E1 不破),门必须**仍然关着**。
|
||||
* ⚠️ 旧实现下必红(载荷年龄 > TTL ⇒ `presenceFresh()` 翻假)—— 这就是"先红后绿"的那条断言。
|
||||
*/
|
||||
test('T33 P-1:稳态零帧下门不得自己重开(判据 = 订阅已建立 ∧ 链路活着,⛔ 不是载荷年龄)', async (t) => {
|
||||
/**
|
||||
* ⚠️ `hbSec: 1` 是**夹具前提**,不是产品口径:测试档把 presence TTL 压到 3 s,而生产心跳是 15 s
|
||||
* ⇒ 不压心跳的话,relay 的 `presenceDevices`(`now − conns[ts] ≤ ttl`)会在 3 s 后把连接判死
|
||||
* ⇒ 自己制造出"离线→在线"的状态变化(**假帧**),把本用例的稳态前提破坏掉。
|
||||
* 生产里 `TTL(45 s) > 心跳(15 s)` ⇒ 不存在这个组合(这正是 TTL 因子取 3 的原因)。
|
||||
*/
|
||||
const { sub } = await presenceScene(t, [], { hbSec: 1 })
|
||||
sub.subscribePresence()
|
||||
assert.ok(await waitFor(() => sub.presenceStatus().state === 'subscribed', 3_000), '订阅未生效')
|
||||
|
||||
/**
|
||||
* ⚠️ 先**等静默下来**再取基线:`SNAP` 之后还有一次**由落点落地驱动**的强制推(序⑲ T32 的假死修复)
|
||||
* —— 它属于"上线那一件事"的收尾,⛔ 不是稳态帧。不先等它,基线就取在稳态之前。
|
||||
*/
|
||||
await sleep(1_000)
|
||||
const idle = sub.presenceStatus().pushFrames
|
||||
// 旧判据看的是"**最近一次载荷**"⇒ 前提要按它的口径算(`max(snap,push)` ⇒ 取**年龄最小**的那个)。
|
||||
const ageOf = (s) => Math.min(s.lastSnapAgoMs ?? 0, s.lastPushAgoMs ?? s.lastSnapAgoMs ?? 0)
|
||||
const before = sub.presenceStatus()
|
||||
|
||||
// 稳态(无任何状态变化)⇒ 一帧都不推;等到**超过 TTL**(测试档 3 s)再看门。
|
||||
await sleep(PT.presenceTtlMs + 1_200)
|
||||
const st = sub.presenceStatus()
|
||||
assert.equal(st.pushFrames, idle, '稳态必须仍然是 0 帧(E1:变化驱动,无变化不推)')
|
||||
assert.equal(st.state, 'subscribed', '链路上订阅应仍生效(服务端心跳在 ⇒ 半开巡检不会断它)')
|
||||
assert.ok(
|
||||
ageOf(st) >= PT.presenceTtlMs,
|
||||
`前提未成立:最近一次载荷年龄应已超过 TTL,实得 ${ageOf(st)}ms(旧=${ageOf(before)}ms;否则这条用例证不了 P-1)`,
|
||||
)
|
||||
assert.equal(
|
||||
st.fresh,
|
||||
true,
|
||||
`载荷 ${ageOf(st)}ms 没来、但链路活着(入站静默 ${st.lastInboundAgoMs}ms ≤ 上界 ${st.linkSilentMaxMs}ms)⇒ 门必须保持关闭`,
|
||||
)
|
||||
assert.equal(sub.presenceFresh(), true, 'P-1 回归:`presenceFresh()` ⛔ 不得因"没有变化"而翻假')
|
||||
})
|
||||
|
||||
/**
|
||||
* **反方向**(失败关闭):链路活着 ⛔ 不足以判"新鲜" —— 还必须**订阅真的生效**。
|
||||
* 少这一条,"永远返回 true"也能让 T33 绿 ⇒ 过修无法被发现。
|
||||
*/
|
||||
test('T34 P-1 反向:未订阅 / 已退订 / 被拒 ⇒ 一律不得判"新鲜"(失败关闭,回退 `/status`)', async (t) => {
|
||||
const { sub } = await presenceScene(t, [])
|
||||
// ① 从没订阅 ⇒ 不新鲜
|
||||
assert.equal(sub.presenceFresh(), false, '未订阅 ⇒ 必须回退 /status')
|
||||
assert.equal(sub.presenceStatus().fresh, false, '状态视图必须如实报门是开的')
|
||||
|
||||
// ② 订阅生效 ⇒ 新鲜;退订 ⇒ **立刻**回到不新鲜(链路还活着也不例外)
|
||||
sub.subscribePresence()
|
||||
assert.ok(await waitFor(() => sub.presenceStatus().state === 'subscribed', 3_000), '订阅未生效')
|
||||
assert.equal(sub.presenceFresh(), true, '订阅刚生效 ⇒ 应判新鲜(否则主路径永远用不上)')
|
||||
sub.unsubscribePresence()
|
||||
assert.equal(sub.presenceFresh(), false, '退订 ⇒ 必须立刻不新鲜(⛔ 不许靠"链路活着"继续给绿)')
|
||||
|
||||
// ③ 被**显式拒绝**的订阅(跨网)⇒ 同样不新鲜(⛔ 静默返空与"本网没人"同形,本线头号教训)
|
||||
sub.subscribePresence(['u:5/d1'])
|
||||
assert.ok(await waitFor(() => sub.presenceStatus().rejected === 1, 3_000), '跨网订阅必须被显式拒绝')
|
||||
assert.equal(sub.presenceFresh(), false, '被拒的订阅 ⇒ 必须回退 /status')
|
||||
|
||||
// ④ 链路断 ⇒ 订阅随之消失 ⇒ 不新鲜(`onPresenceDown` 的唯一职责)
|
||||
sub.subscribePresence()
|
||||
assert.ok(await waitFor(() => sub.presenceStatus().state === 'subscribed', 3_000), '重新订阅未生效')
|
||||
sub.stop()
|
||||
await sleep(200)
|
||||
assert.equal(sub.presenceFresh(), false, '链路断 ⇒ 必须不新鲜(否则会拿过期镜像当事实)')
|
||||
})
|
||||
|
||||
/* ═══════════ 序㉑ P-2 修复(键口径 ⇒ 抽纯函数)T35–T36 ═══════════ */
|
||||
|
||||
/**
|
||||
* 🔴 **P-2**(在册缺陷):`translateEndpoint` 的键口径 —— `hostVia` / `relayEndpoints` / 拨号池
|
||||
* 全按**逻辑名**建键,而调用方 `RemoteSpawner.translateEndpoint(host.hostId, …)` 只给得到
|
||||
* **裸 hostId** ⇒ `hostVia.get(hostId)` 恒 `undefined` ⇒ 早退原样透传 ⇒ **闭包整体是死分支**。
|
||||
*
|
||||
* 本用例钉住**判定本体**(抽成的纯函数);键口径那一半由 T36 钉。
|
||||
*/
|
||||
test('T35 P-2:`relayEndpointTarget` 四支判定(透传 / 拨号 / 快照 / 失败关闭)⛔ 不误触发拨号池', () => {
|
||||
let dialedCalls = 0
|
||||
const dial = (p) => () => {
|
||||
dialedCalls += 1
|
||||
return p
|
||||
}
|
||||
|
||||
// ① 未知 host(不在 `dsh_hosts`)⇒ 原样透传,且**不许**碰拨号池
|
||||
assert.deepEqual(
|
||||
relayEndpointTarget({ known: false, via: undefined, dialedPort: dial(25000), snapshotLocalPort: 41000 }),
|
||||
{ kind: 'passthrough', why: 'unknown-host' },
|
||||
'未知 host 必须保持老行为(单机 / 默认 host 不受影响)',
|
||||
)
|
||||
assert.equal(dialedCalls, 0, '未知 host ⛔ 不许查拨号池(`localPortFor` 会**按需绑池口**,是有副作用的调用)')
|
||||
|
||||
// ② `via` 不是 relay ⇒ 原样透传(隧道同号反向转发,不需要翻译);同样不碰池
|
||||
for (const via of ['local', 'manager-ssh']) {
|
||||
assert.deepEqual(
|
||||
relayEndpointTarget({ known: true, via, dialedPort: dial(25000) }),
|
||||
{ kind: 'passthrough', why: 'not-relay' },
|
||||
`via=${via} 两侧口号相同 ⇒ 翻译既不需要也不该做`,
|
||||
)
|
||||
}
|
||||
assert.equal(dialedCalls, 0, '非 relay ⛔ 不许查拨号池')
|
||||
|
||||
// ③ via=relay ⇒ ①拨号落点优先(R5:落点在 Manager 本机 ⇒ relay 换机器也成立)
|
||||
assert.deepEqual(
|
||||
relayEndpointTarget({ known: true, via: 'relay', dialedPort: dial(25000), snapshotLocalPort: 41000 }),
|
||||
{ kind: 'local', port: 25000, via: 'dialed' },
|
||||
)
|
||||
// ④ 拨号拿不到 ⇒ 回落 relay 快照
|
||||
assert.deepEqual(
|
||||
relayEndpointTarget({ known: true, via: 'relay', dialedPort: dial(undefined), snapshotLocalPort: 41000 }),
|
||||
{ kind: 'local', port: 41000, via: 'snapshot' },
|
||||
)
|
||||
// ⑤ 两条都没有 ⇒ **失败关闭**(⛔ 不是原样透传:那会拿 Worker 侧口号拨 Manager 本机)
|
||||
assert.deepEqual(
|
||||
relayEndpointTarget({ known: true, via: 'relay', dialedPort: dial(undefined) }),
|
||||
{ kind: 'unreachable', why: 'no-dialed-port' },
|
||||
)
|
||||
// ⑥ `0` 是"没有落点"的哨兵值(不是合法口号)⇒ 必须仍判失败关闭
|
||||
assert.equal(
|
||||
relayEndpointTarget({ known: true, via: 'relay', dialedPort: dial(0), snapshotLocalPort: 0 }).kind,
|
||||
'unreachable',
|
||||
'落点 0 ⇒ 无落点(与 relay `/status` 同口径)',
|
||||
)
|
||||
})
|
||||
|
||||
test('T36 P-2:键口径 —— 裸 `hostId` 必须经 `hostNameIndex` 换到逻辑名(⛔ 闭包不得再拿 hostId 当键)', async () => {
|
||||
const idx = hostNameIndex([
|
||||
{ id: 'w-47', networkId: 'ops' },
|
||||
{ id: 'w-106', networkId: '' }, // 空 ⇒ 归属网取兜底(与 DB 列默认值同口径)
|
||||
{ id: 'd1', networkId: 'u:5' },
|
||||
])
|
||||
assert.equal(idx.get('w-47'), 'ops/w-47')
|
||||
assert.equal(idx.get('w-106'), 'ops/w-106', '空 network_id 必须按兜底网补全(否则与 DB 行写的键不一致)')
|
||||
assert.equal(idx.get('d1'), 'u:5/d1', '跨网同 hostId 各算一台(P0-3)')
|
||||
assert.equal(idx.get('ops/w-106'), undefined, '索引的键是**裸 hostId** ⇒ 拿逻辑名查不到(两侧口径必须显式转换)')
|
||||
assert.equal(hostNameIndex([{ id: 'x', networkId: '' }], 'u:9').get('x'), 'u:9/x', '兜底网可注入(⛔ 不写死 ops)')
|
||||
|
||||
/**
|
||||
* 源码级守卫(这类"整个闭包静默失效"的缺陷只靠运行时断言抓不到 —— 无实例时分支根本不执行):
|
||||
* ⛔ 闭包不得再出现"拿裸 hostId 当控制面表的键"的写法。
|
||||
*/
|
||||
const src = await readFile(new URL('../src/web/server.ts', import.meta.url), 'utf8')
|
||||
// ⚠️ 只看**代码行**:注释里会引用反例("原实现直接 `hostVia.get(hostId)`…"),拿整文件匹配会自伤。
|
||||
const code = src
|
||||
.split('\n')
|
||||
.filter((l) => !/^\s*(\/\/|\*|\/\*)/.test(l))
|
||||
.join('\n')
|
||||
assert.equal(code.includes('hostVia.get(hostId)'), false, '⛔ 不得再用裸 hostId 查 `hostVia`(恒 undefined ⇒ 死分支)')
|
||||
assert.equal(code.includes('localPortFor(hostId'), false, '⛔ 不得再用裸 hostId 查拨号池(同上)')
|
||||
assert.equal(code.includes('relayEndpoints.get(`${hostId}'), false, '⛔ 快照回退键同样必须是逻辑名')
|
||||
assert.ok(code.includes('hostNameById.get(hostId)'), '闭包必须经 `hostNameById` 换到逻辑名')
|
||||
})
|
||||
|
||||
/* ═══════════ 序㉒ P-2b 修复(候选链补「订阅推送落点」一级)T37 ═══════════ */
|
||||
|
||||
/**
|
||||
* 🔴 **P-2b**(在册缺陷):`relayEndpointTarget` 只有「拨号落点 → relay `/status` 快照」**两级**,
|
||||
* 而地址解析链(`src/web/server.ts#RelayRendezvous.addressOf`)是**三级**:
|
||||
* ① 拨号落点 → ② **订阅推送落点**(`presenceLocalPort`)→ ③ relay 快照。
|
||||
*
|
||||
* 为什么在 P-1 修好之后这条变成**真缺陷**:P-1 把门判据改成「订阅已建立 ∧ 链路活着」之后,
|
||||
* 订阅新鲜期**长期成立** ⇒ 快照刷新(`relayEndpoints`)**趋冷**,而拨号池在"该 host 的槽位
|
||||
* 分不出来"(跨网被拒 / 池满 / 尚未绑口)时也给不出落点 ⇒ 本判定会落到"两级都没有"
|
||||
* ⇒ **判实例不可达(失败关闭)**,尽管**订阅推送里明明有落点**(同一时刻 `addressOf` 能答出来)。
|
||||
* ⇒ 修法 = 把订阅推送插成 **②' 级**,与 `addressOf` 的三级链**逐级对齐**。
|
||||
*
|
||||
* ⚠️ 本用例只钉**优先级与失败语义**;"闭包有没有把这一级传进来"由同文件 `T38` 的源码级守卫钉。
|
||||
*/
|
||||
test('T37 P-2b:候选链三级(拨号 → 订阅推送 → relay 快照)逐支可判,⛔ 不误触发拨号池', () => {
|
||||
let dialedCalls = 0
|
||||
const dial = (p) => () => {
|
||||
dialedCalls += 1
|
||||
return p
|
||||
}
|
||||
|
||||
// ① 三级全有 ⇒ **拨号落点优先**(R5:落点在 Manager 本机 ⇒ relay 换机器也成立)
|
||||
assert.deepEqual(
|
||||
relayEndpointTarget({
|
||||
known: true,
|
||||
via: 'relay',
|
||||
dialedPort: dial(25000),
|
||||
pushedLocalPort: 37057,
|
||||
snapshotLocalPort: 41000,
|
||||
}),
|
||||
{ kind: 'local', port: 25000, via: 'dialed' },
|
||||
'拨号落点是第一优先(它与订阅推送、快照三者必须逐支可分辨)',
|
||||
)
|
||||
|
||||
// ② 拨号分不出槽位,但**订阅推送里有落点** ⇒ 取推送(**P-2b 的实体**:修前这一支落到 ③/失败关闭)
|
||||
assert.deepEqual(
|
||||
relayEndpointTarget({
|
||||
known: true,
|
||||
via: 'relay',
|
||||
dialedPort: dial(undefined),
|
||||
pushedLocalPort: 37057,
|
||||
snapshotLocalPort: 41000,
|
||||
}),
|
||||
{ kind: 'local', port: 37057, via: 'pushed' },
|
||||
'P-2b:拨号给不出时,订阅推送的落点必须先于 relay 快照被采用(与 `addressOf` 对齐)',
|
||||
)
|
||||
|
||||
// ③ 订阅不新鲜 / 该 host 不在推送范围 ⇒ 回落到 relay 快照(③ 级语义**一行未改**)
|
||||
assert.deepEqual(
|
||||
relayEndpointTarget({
|
||||
known: true,
|
||||
via: 'relay',
|
||||
dialedPort: dial(undefined),
|
||||
pushedLocalPort: undefined,
|
||||
snapshotLocalPort: 41000,
|
||||
}),
|
||||
{ kind: 'local', port: 41000, via: 'snapshot' },
|
||||
'订阅给不出 ⇒ 必须仍能落到快照(⛔ 不是失败关闭)',
|
||||
)
|
||||
|
||||
// ④ 三级都没有 ⇒ **失败关闭**(⛔ 绝不原样透传:那会拿 Worker 侧口号拨 Manager 本机)
|
||||
assert.deepEqual(
|
||||
relayEndpointTarget({ known: true, via: 'relay', dialedPort: dial(undefined) }),
|
||||
{ kind: 'unreachable', why: 'no-dialed-port' },
|
||||
'三级全无 ⇒ 失败关闭',
|
||||
)
|
||||
|
||||
// ⑤ `0` 是"没有落点"的哨兵(与 relay `/status` 同口径)⇒ 订阅那级也必须按"没有"处理
|
||||
assert.deepEqual(
|
||||
relayEndpointTarget({
|
||||
known: true,
|
||||
via: 'relay',
|
||||
dialedPort: dial(0),
|
||||
pushedLocalPort: 0,
|
||||
snapshotLocalPort: 41000,
|
||||
}),
|
||||
{ kind: 'local', port: 41000, via: 'snapshot' },
|
||||
'落点 0 ⇒ 视为无落点,逐级下探(⛔ 不许把 0 当合法口号)',
|
||||
)
|
||||
|
||||
// ⑥ 未知 host / 非 relay ⇒ **原样透传**,且订阅那一级同样不许改写结果(⛔ 不是"新增一条翻译路径")
|
||||
const callsBefore6 = dialedCalls
|
||||
assert.deepEqual(
|
||||
relayEndpointTarget({ known: false, via: undefined, dialedPort: dial(25000), pushedLocalPort: 37057 }),
|
||||
{ kind: 'passthrough', why: 'unknown-host' },
|
||||
)
|
||||
assert.deepEqual(
|
||||
relayEndpointTarget({ known: true, via: 'local', dialedPort: dial(25000), pushedLocalPort: 37057 }),
|
||||
{ kind: 'passthrough', why: 'not-relay' },
|
||||
)
|
||||
assert.equal(
|
||||
dialedCalls - callsBefore6,
|
||||
0,
|
||||
'未知 host / 非 relay ⛔ 不许碰拨号池(`localPortFor` 会**按需绑池口**,是有副作用的调用)',
|
||||
)
|
||||
})
|
||||
|
||||
test('T38 P-2b:闭包必须把「订阅推送落点」传进判定(源码级守卫 —— 无实例时该分支不执行)', async () => {
|
||||
const src = await readFile(new URL('../src/web/server.ts', import.meta.url), 'utf8')
|
||||
const code = src
|
||||
.split('\n')
|
||||
.filter((l) => !/^\s*(\/\/|\*|\/\*)/.test(l))
|
||||
.join('\n')
|
||||
assert.ok(
|
||||
code.includes('presenceLocalPort(name, ep.port)'),
|
||||
'闭包必须把 `presenceLocalPort(name, ep.port)` 交给 `relayEndpointTarget`(否则三级链少一级 = P-2b 原样)',
|
||||
)
|
||||
assert.ok(
|
||||
code.includes('pushedLocalPort:'),
|
||||
'判定入参必须显式命名(⛔ 不许靠位置参数 / 事后补丁)',
|
||||
)
|
||||
})
|
||||
Reference in new issue
Block a user