代码
- 内容分发块级寻址:新增 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)
239 lines
11 KiB
JavaScript
239 lines
11 KiB
JavaScript
/**
|
||
* 覆盖网络线 序 ㉘ → 单 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 居然没命中')
|
||
})
|