2026-09-17 17:03:27 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* `RemoteUserFs` 的**按用户路由**单测(覆盖网络线缺陷 A1)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* ## 被钉死的缺陷(2026-09-16 现场实测)
|
|
|
|
|
|
* `RemoteUserFs.target()` 旧实现对三种失败**一律静默回退默认机**:查库抛错 / 没给 `agentFor` /
|
|
|
|
|
|
* `agentFor` 查不到。而默认机 = **Manager 自己那台**,它对*别的机*的用户只有两种回答:
|
|
|
|
|
|
* ① 那台 agent 上没有这个用户 ⇒ `{error:"not_found"}` —— 与「**文件夹不存在**」**完全同形**,
|
|
|
|
|
|
* 用户读成"我的文件丢了",真因却是"请求根本没出这台机";
|
|
|
|
|
|
* ② 本地恰好有同名目录 ⇒ 文件被写进一份**没人在看的副本**(更糟的静默写坏)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 触发窗口是**真实存在的、且每次重启必现**:`server.ts` 的 `hostDirectory` 是惰性 Map,
|
|
|
|
|
|
* 唯一写入者 `hostsProvider()` 此前只被 `RemoteSpawner.ensureHosts()` 调用 ⇒ Manager 重启后
|
|
|
|
|
|
* 若用户先碰文件面,表里只有本机。实测:连发 3 次 launch **全 404,relay 零 `DIAL`、
|
|
|
|
|
|
* 拨号池零落点**(判别器 = relay 有没有 `DIAL`)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* ## 修法与断言
|
|
|
|
|
|
* ① **治本**:新增可选 `ensureHost(hostId)` —— 未命中时先**按需补齐**目录再判(U1),
|
|
|
|
|
|
* 所以正常冷启动请求不会被下面的失败关闭波及;
|
|
|
|
|
|
* ② **治安全**:补齐后仍取不到 ⇒ `UserFsError('host_unresolved')` → **503**,且
|
|
|
|
|
|
* **一个字节都不许发往默认机**(U2/U3/U4/U8 的 `fetch` 断言)。
|
|
|
|
|
|
* ③ **不退化**:"确实还没有归属"(`hostIdFor` 正常返回 `undefined`)与单机形态仍是默认机(U5/U6)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 运行:`node --test test/remote-user-fs.test.mjs`(已登记进 `npm test` / `npm run verify`)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* @module test/remote-user-fs
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
import assert from 'node:assert/strict'
|
|
|
|
|
|
import { test } from 'node:test'
|
|
|
|
|
|
import { RemoteUserFs } from '../lib/fs/remote-user-fs.js'
|
|
|
|
|
|
|
|
|
|
|
|
/* ─────────── 小工具 ─────────── */
|
|
|
|
|
|
|
|
|
|
|
|
/** Manager 自己那台(= 默认 / 回退 agent)—— 任何"打到这里"都是路由失败。 */
|
|
|
|
|
|
const DEFAULT_AGENT = 'http://127.0.0.1:19100'
|
2026-09-19 15:12:19 +08:00
|
|
|
|
/** 归属机:w-2 的 agent。 */
|
2026-09-17 17:03:27 +08:00
|
|
|
|
const W106_AGENT = 'http://127.0.0.1:19000'
|
|
|
|
|
|
|
|
|
|
|
|
/** 记账用 fetch:记下每一发请求,永不真的出网。 */
|
|
|
|
|
|
function spyFetch() {
|
|
|
|
|
|
const calls = []
|
|
|
|
|
|
const impl = async (url) => {
|
|
|
|
|
|
calls.push(String(url))
|
|
|
|
|
|
return { ok: true, status: 200, text: async () => JSON.stringify([]) }
|
|
|
|
|
|
}
|
|
|
|
|
|
impl.calls = calls
|
|
|
|
|
|
return impl
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function mk(opts) {
|
|
|
|
|
|
const fetchImpl = spyFetch()
|
|
|
|
|
|
const fs = new RemoteUserFs({
|
|
|
|
|
|
agentUrl: DEFAULT_AGENT,
|
|
|
|
|
|
token: 'tok-default',
|
|
|
|
|
|
workerDataRoot: '/var/lib/dsh/data',
|
|
|
|
|
|
hostIdFor: opts.hostIdFor,
|
|
|
|
|
|
agentFor: opts.agentFor,
|
|
|
|
|
|
ensureHost: opts.ensureHost,
|
|
|
|
|
|
fetchImpl,
|
|
|
|
|
|
})
|
|
|
|
|
|
return { fs, fetchImpl }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 断言"抛出的一定是 host_unresolved/503"。 */
|
|
|
|
|
|
function isHostUnresolved(err) {
|
|
|
|
|
|
assert.equal(err.code, 'host_unresolved')
|
|
|
|
|
|
assert.equal(err.status, 503)
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* ─────────── ① 治本:未命中先补齐 ─────────── */
|
|
|
|
|
|
|
|
|
|
|
|
test('U1 目录未命中:ensureHost 补齐后打到**归属机器**(不再回退默认机)', async () => {
|
|
|
|
|
|
const dir = new Map() // 模拟 hostsProvider() 尚未填过的空目录
|
|
|
|
|
|
let ensured = 0
|
|
|
|
|
|
const { fs, fetchImpl } = mk({
|
2026-09-19 15:12:19 +08:00
|
|
|
|
hostIdFor: async () => 'w-2',
|
2026-09-17 17:03:27 +08:00
|
|
|
|
agentFor: (h) => dir.get(h),
|
|
|
|
|
|
ensureHost: async (h) => {
|
|
|
|
|
|
ensured += 1
|
|
|
|
|
|
dir.set(h, { agentUrl: W106_AGENT, token: 'tok-106' })
|
|
|
|
|
|
},
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
await fs.listDir('u1', '')
|
|
|
|
|
|
|
|
|
|
|
|
assert.equal(ensured, 1, '未命中必须触发一次补齐')
|
2026-09-19 15:12:19 +08:00
|
|
|
|
assert.deepEqual(fetchImpl.calls, [`${W106_AGENT}/fs/list`], '必须打到 w-2,不许打默认机')
|
2026-09-17 17:03:27 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
test('U7 命中时**不**做补齐(正常路径零开销:不查库)', async () => {
|
|
|
|
|
|
let ensured = 0
|
|
|
|
|
|
const { fs, fetchImpl } = mk({
|
2026-09-19 15:12:19 +08:00
|
|
|
|
hostIdFor: async () => 'w-2',
|
2026-09-17 17:03:27 +08:00
|
|
|
|
agentFor: () => ({ agentUrl: W106_AGENT, token: 'tok-106' }),
|
|
|
|
|
|
ensureHost: async () => {
|
|
|
|
|
|
ensured += 1
|
|
|
|
|
|
},
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
await fs.listDir('u1', '')
|
|
|
|
|
|
|
|
|
|
|
|
assert.equal(ensured, 0, '命中后再查库 = 纯浪费')
|
|
|
|
|
|
assert.deepEqual(fetchImpl.calls, [`${W106_AGENT}/fs/list`])
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
/* ─────────── ② 治安全:取不到 ⇒ 失败关闭 ─────────── */
|
|
|
|
|
|
|
|
|
|
|
|
test('U2 补齐后仍取不到地址:503 host_unresolved,且**零请求发往默认机**(写操作也拦住)', async () => {
|
|
|
|
|
|
let ensured = 0
|
|
|
|
|
|
const { fs, fetchImpl } = mk({
|
2026-09-19 15:12:19 +08:00
|
|
|
|
hostIdFor: async () => 'w-2',
|
|
|
|
|
|
agentFor: () => undefined, // 目录里始终没有 w-2
|
2026-09-17 17:03:27 +08:00
|
|
|
|
ensureHost: async () => {
|
|
|
|
|
|
ensured += 1
|
|
|
|
|
|
},
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// 用 mkdir 而不是读:这正是"把目录建到错机上"的那类操作
|
|
|
|
|
|
await assert.rejects(() => fs.mkdir('u1', 'MCN短视频创作'), isHostUnresolved)
|
|
|
|
|
|
|
|
|
|
|
|
assert.equal(ensured, 1, '失败前仍应尝试过补齐')
|
|
|
|
|
|
assert.deepEqual(fetchImpl.calls, [], '⛔ 一个字节都不许发往默认机')
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
test('U3 未提供 ensureHost(老调用点):未命中同样失败关闭,**不退化**为静默回退', async () => {
|
|
|
|
|
|
const { fs, fetchImpl } = mk({
|
2026-09-19 15:12:19 +08:00
|
|
|
|
hostIdFor: async () => 'w-2',
|
2026-09-17 17:03:27 +08:00
|
|
|
|
agentFor: () => undefined,
|
|
|
|
|
|
// ensureHost 故意不传
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
await assert.rejects(() => fs.listDir('u1', ''), isHostUnresolved)
|
|
|
|
|
|
assert.deepEqual(fetchImpl.calls, [])
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
test('U8 ensureHost 自己抛错(如查库失败):仍失败关闭,不吞成"默认机"', async () => {
|
|
|
|
|
|
const { fs, fetchImpl } = mk({
|
2026-09-19 15:12:19 +08:00
|
|
|
|
hostIdFor: async () => 'w-2',
|
2026-09-17 17:03:27 +08:00
|
|
|
|
agentFor: () => undefined,
|
|
|
|
|
|
ensureHost: async () => {
|
|
|
|
|
|
throw new Error('pg is down')
|
|
|
|
|
|
},
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
await assert.rejects(() => fs.listDir('u1', ''), isHostUnresolved)
|
|
|
|
|
|
assert.deepEqual(fetchImpl.calls, [])
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
test('U4 hostIdFor 抛错(查库失败):失败关闭,不静默回退默认机', async () => {
|
|
|
|
|
|
const { fs, fetchImpl } = mk({
|
|
|
|
|
|
hostIdFor: async () => {
|
|
|
|
|
|
throw new Error('pg is down')
|
|
|
|
|
|
},
|
|
|
|
|
|
agentFor: () => ({ agentUrl: W106_AGENT, token: 'tok-106' }),
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
await assert.rejects(() => fs.readFile('u1', 'a.txt'), isHostUnresolved)
|
|
|
|
|
|
assert.deepEqual(fetchImpl.calls, [], '归属都查不出来时,任何一台都不该被打')
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
/* ─────────── ③ 不退化:设计内的默认机路径 ─────────── */
|
|
|
|
|
|
|
|
|
|
|
|
test('U5 hostIdFor 返回 undefined(确实还没有归属):仍用默认 agent', async () => {
|
|
|
|
|
|
const { fs, fetchImpl } = mk({
|
|
|
|
|
|
hostIdFor: async () => undefined,
|
|
|
|
|
|
agentFor: () => ({ agentUrl: W106_AGENT, token: 'tok-106' }),
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
await fs.initUserRoot('u1', 1001)
|
|
|
|
|
|
|
|
|
|
|
|
assert.deepEqual(fetchImpl.calls, [`${DEFAULT_AGENT}/fs/init`], '首触达/无归属走默认机是设计内契约')
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
test('U6 未提供 hostIdFor(单机形态):默认 agent,且不触发任何路由表', async () => {
|
|
|
|
|
|
let probed = 0
|
|
|
|
|
|
const { fs, fetchImpl } = mk({
|
|
|
|
|
|
agentFor: () => {
|
|
|
|
|
|
probed += 1
|
|
|
|
|
|
return undefined
|
|
|
|
|
|
},
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
await fs.listDir('u1', '')
|
|
|
|
|
|
|
|
|
|
|
|
assert.equal(probed, 0)
|
|
|
|
|
|
assert.deepEqual(fetchImpl.calls, [`${DEFAULT_AGENT}/fs/list`])
|
|
|
|
|
|
})
|