Files
dsh_shenxian/test/orchestrator-teardown.test.mjs
T
admin 09ce76f3af feat(overlay): 内容块级寻址 + 实例逐步拉起 + 骨干选路 + 组密钥加密(序24–㉛ 累积同步)
代码
- 内容分发块级寻址:新增 src/net/relay/content/{chunker,store,runtime,source,peer,crypto}.ts
- 组密钥(C 档)确定性加密:AES-256-GCM,块 id β′ = sha256(密文) 前 32 hex;双 epoch 过渡窗口
- 实例生命周期:三处 teardown() 不再杀实例(local/remote/leased-spawner);启动认领 + TCP 探活判孤儿
- 骨干选路:jitter 选路 + endpoint-target;relay client/server/wire/identity/directory/rendezvous/switcher 调整
- 工作台 src/web/server.ts、src/worker/relay-tunnel.ts 装配与候选链观测

脚本与测试
- scripts/overlay-{probe,keyring,jitter}.cjs 更新
- 探针新增 OBS-21(每连接候选数)/ OBS-22(teardown 静态守卫 + 认领面)/ OBS-23(组密钥加密)
- 新增 test/{orchestrator-teardown,orchestrator-rehydrate,overlay-content,overlay-jitter}.test.mjs;relay 两例更新

文档
- 新增交接单:覆盖网络-序24-内容分发块级寻址 / 序25-实例逐步拉起 / 序26-骨干稳定选路与加密
- INDEX.md、交接单/README.md、skills/dsh-auto-handoff-chain/SKILL.md 同步

验收(零回归,2026-09-18 08:0x 复核)
- npm test           201 tests / 200 pass / 0 fail / 1 skipped
- overlay-failover-drill --scene all --table   12 PASS / 0 SKIP / 0 FAIL
- overlay-probe --table                        23 PASS / 0 SKIP / 0 FAIL (rc=0)
2026-09-18 08:08:51 +08:00

239 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 覆盖网络线 序 ㉘ → 单 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 居然没命中')
})