/** * 覆盖网络线 序 ㉘ → 单 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 {', ' 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 居然没命中') })