Files
dsh_shenxian/test/remote-spawner.test.mjs
T
admin 5ec99ce227
build / build-and-scan (push) Canceled after 0s
chore: 文档库全量重新入库(2026-10-20)
2026-10-20 15:05:55 +08:00

178 lines
8.4 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.
/**
* Manager 侧 `RemoteSpawner.endpointFor` 的**端点翻译接线**单测(覆盖网络 R4)。
*
* ## 为什么单独立一个文件(2026-09-16 实测代价)
* `translateEndpoint` 是 R4 的**唯一**翻译点:`via='relay'` 的 host,其实例在 Worker 上监听
* `127.0.0.1:<实例端口>`,而 Manager 必须拨 relay 为那个端口开的**动态回环口号**。
*
* 首版实现里 `RemoteSpawner` 的构造函数**漏了 `this.translateEndpoint = options.translateEndpoint`**
* —— 于是整个翻译**静默失效**:`endpointFor` 原样返回 Worker 侧口号 ⇒ Manager 往**自己本机**
* 拨 `127.0.0.1:21000` ⇒ 连接被拒两次 ⇒ 代理 `reply.raw.destroy()`。
* 现场表现只有两条:浏览器/curl 看到 **`Empty reply from server`**;平台日志**一行错误都没有**。
* (定位靠"在 47 上临时监听 21000,请求被这个探针接走"——即**用判别器测,而不是读代码猜**。)
*
* ⇒ 本文件把"翻译必须真的生效"钉成断言:**构造时就验**,不依赖集群环境、不依赖 relay 在跑。
*
* 运行:`node --test test/remote-spawner.test.mjs`(已登记进 `npm test` / `npm run verify`)。
*
* @module test/remote-spawner
*/
import assert from 'node:assert/strict'
import { test } from 'node:test'
import { RemoteSpawner, PROBE_TIMEOUT_MS, AGENT_ERROR_TEXT_MAX } from '../lib/supervisor/remote-spawner.js'
/* ─────────── 小工具 ─────────── */
/** Worker 侧 agent 的回包(`GET /endpoint/:userId` 的形状)。 */
function fakeFetch(payload) {
const calls = []
const impl = async (url, init) => {
calls.push(`${init?.method ?? 'GET'} ${url}`)
return { ok: true, status: 200, json: async () => payload, text: async () => JSON.stringify(payload) }
}
impl.calls = calls
return impl
}
/** `via='relay'` 的一台 worker:agent 口号 19000,relay 已把它映射到本机 38253。 */
const W106 = {
hostId: 'w-2',
agentUrl: 'http://127.0.0.1:19000',
reachability: { hostId: 'w-2', via: 'relay', address: '127.0.0.1:38253', scheme: 'http' },
token: 'tok-106',
}
function make(opts = {}) {
return new RemoteSpawner({
agentUrl: 'http://127.0.0.1:19100',
token: 'tok-local',
defaultHostId: 'w-1',
hostIdFor: async () => 'w-2',
hostsProvider: async () => [W106],
fetchImpl: fakeFetch({ running: true, host: '127.0.0.1', port: 21000 }),
...opts,
})
}
/* ─────────── T1–T4 ─────────── */
test('T1 翻译器被真的接上:传入的 hostId/endpoint 与返回值都要生效', async () => {
const seen = []
const s = make({
translateEndpoint: (hostId, ep) => {
seen.push([hostId, ep])
return { host: '127.0.0.1', port: 34241 }
},
})
assert.deepEqual(await s.endpointFor('u1'), { host: '127.0.0.1', port: 34241 })
// ★ 这一条就是首版漏赋值时唯一会红的断言:漏了 ⇒ seen 为空、返回 {21000}
assert.deepEqual(seen, [['w-2', { host: '127.0.0.1', port: 21000 }]])
})
test('T2 未给翻译器 ⇒ 原样透传(local / manager-ssh 的同号语义,行为零变化)', async () => {
const s = make()
assert.deepEqual(await s.endpointFor('u1'), { host: '127.0.0.1', port: 21000 })
})
test('T3 翻译器回 undefined ⇒ endpointFor 也回 undefined(失败关闭,不回退 Worker 口号)', async () => {
const s = make({ translateEndpoint: () => undefined })
assert.equal(await s.endpointFor('u1'), undefined)
})
test('T4 实例未运行 ⇒ undefined,且**不该**调翻译器(没有端口可翻)', async () => {
let called = false
const s = make({
fetchImpl: fakeFetch({ running: false }),
translateEndpoint: () => {
called = true
return undefined
},
})
assert.equal(await s.endpointFor('u1'), undefined)
assert.equal(called, false)
})
/* ─────────── T5:跨机探活的客户端预算(2026-09-25 第 17 棒补)─────────── */
test('T5 /restart-probe 走独立预算:⛔ 不许吃 10s 默认值(否则跨机"启用插件"永久失败)', async () => {
// 真机实证(47 · 第 17 棒):这个口吃默认 10s,而对端要**真重启实例**(冷启动 10–14 s)
// + 2.5 s 稳定窗 ⇒ 每次必超时;`call()` 还会再重试 3 次(每次都再重启一次实例,实测
// 一条任务连打 8 次 /restart-probe、约 100 s 后以 "The operation was aborted due to timeout" 收场)。
const seen = []
const s = make({
fetchImpl: async (url, init) => {
seen.push({ url: String(url), hasSignal: init?.signal !== undefined })
return { ok: true, status: 200, json: async () => ({ ok: true, reason: '' }), text: async () => '{}' }
},
})
const r = await s.restartAndProbe('u1')
assert.equal(r.ok, true)
assert.equal(seen.length, 1, '一次成功就不该重试(重试 = 再重启一次实例)')
assert.ok(seen[0].url.endsWith('/restart-probe/u1'), '必须打对端的 /restart-probe')
assert.ok(seen[0].hasSignal, '必须带超时信号(⛔ 不许变成"永不超时")')
assert.ok(
PROBE_TIMEOUT_MS >= 60_000,
`客户端预算 ${PROBE_TIMEOUT_MS}ms 必须 ≥ 对端探活预算 60000ms(旧值 10s ⇒ 跨机启用插件永久失败)`,
)
})
/* ─────────── T6–T8:跨机错误消息保留长度(2026-09-25 第 23 棒 · §5-12)─────────── */
/** Worker 侧 agent 回一个**非 2xx**,正文为 `body` —— 用来把 `call()` 的错误分支逼出来。 */
function failingFetch(status, body) {
const calls = []
const impl = async (url, init) => {
calls.push(`${init?.method ?? 'GET'} ${url} @${status}`)
return { ok: false, status, json: async () => ({}), text: async () => body }
}
impl.calls = calls
return impl
}
/** 从 reject 出来的错误里取 `message`(并断言它确实是 Error)。 */
async function caughtMessage(promise) {
try {
await promise
} catch (err) {
assert.ok(err instanceof Error, '必须是 Error(⛔ 别把非 Error 直接抛出)')
return err.message
}
assert.fail('本该抛错,却成功返回了')
}
test('T6 4xx 的真实原因不再被截到 200 字符(证据完整落进 Manager 侧 message)', async () => {
// 第 17 棒现场:Manager 侧任务 detail 只看到半截,真根因(pnpm 原文)只能从 worker 的
// journald 补 —— 跨机形态下这条取证路径对运维不可用。故本断言钉住"整段可读"。
const body = `pnpm failed: ${'EPERM-chmod-'.repeat(80)}` // 973 字符
assert.ok(body.length > 200 && body.length <= AGENT_ERROR_TEXT_MAX, `夹具长度须落在 (200, ${AGENT_ERROR_TEXT_MAX}]`)
const s = make({ fetchImpl: failingFetch(400, body) })
const msg = await caughtMessage(s.applyPlugins('u1', []))
assert.ok(!msg.includes('slice'), '错误正文里不该出现实现细节')
assert.equal(msg.endsWith(body), true, '4xx 正文必须**逐字完整**保留(旧口径只留前 200)')
assert.ok(msg.length > 200 + 'agent POST /plugins/apply → 400: '.length, '整条 message 必须明显长于旧口径')
})
test('T7 边界:恰好上限全留;多 1 个字符即被截到上限', async () => {
const exact = 'A'.repeat(AGENT_ERROR_TEXT_MAX)
const over = `${exact}B`
const s1 = make({ fetchImpl: failingFetch(400, exact) })
const msg1 = await caughtMessage(s1.applyPlugins('u1', []))
assert.ok(msg1.endsWith(exact), `恰好 ${AGENT_ERROR_TEXT_MAX} 字符必须**一个不少**`)
const s2 = make({ fetchImpl: failingFetch(400, over) })
const msg2 = await caughtMessage(s2.applyPlugins('u1', []))
assert.equal(msg2.endsWith(exact), true, `超过 ${AGENT_ERROR_TEXT_MAX} 时截到上限`)
assert.equal(msg2.endsWith(over), false, '⛔ 超过上限不许全留(上限还在,不是"关了限制")')
})
test('T8 5xx 分支走同一上限,且既有重试语义未被改动(仍重试 4 次)', async () => {
// ⛔ 本单只放宽"保留长度",⛔ 不动重试次数 —— 5xx 仍要跑满 4 次尝试(退避 200/1000/3000 ms)。
const body = 'B'.repeat(AGENT_ERROR_TEXT_MAX + 50)
const f = failingFetch(503, body)
const s = make({ fetchImpl: f })
const msg = await caughtMessage(s.applyPlugins('u1', []))
assert.equal(msg.endsWith('B'.repeat(AGENT_ERROR_TEXT_MAX)), true, '5xx 分支同样保留到上限')
assert.equal(f.calls.length, 4, '5xx 仍重试 4 次(本单不改重试语义)')
})