From 2ddaf7bfa699167e5d5ac0a50a0e3f6c55453033 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 28 Sep 2026 00:48:45 +0800 Subject: [PATCH] fix(fs): S-10 hang test hook requires UMT_FS_TEST_HOOKS=1; preflight warns when set Co-Authored-By: Claude Opus 5.5 --- apps/server/src/main.ts | 6 ++++++ docs/runbook/deploy.md | 4 ++-- packages/adapters/src/fs/fsproxy.test.ts | 14 ++++++++++++++ packages/adapters/src/fs/fsproxy.ts | 15 ++++++++++----- 4 files changed, 32 insertions(+), 7 deletions(-) diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index b03f43a7..0cab982e 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -127,6 +127,12 @@ async function runOneShot(r: OneShotRole): Promise { logger.warn({ kind: w.kind, id: w.id, path: w.path }, `挂载路径不可用: ${w.name} ${w.reason}`); } await auditStorageWarnings(db, storage.warnings).catch((err) => logger.warn({ err }, "存储告警写审计失败")); + // 验收开关误设提示(验收 07 S-10):只有 UMT_FS_TEST_HOOKS=1 时才真正生效 + if (Deno.env.get("UMT_FS_TEST_HANG_AFTER_BYTES")) + logger.warn( + { active: Deno.env.get("UMT_FS_TEST_HOOKS") === "1" }, + "设置了 UMT_FS_TEST_HANG_AFTER_BYTES(fsproxy 挂死验收开关);生产环境不要设置,生效时所有数据类文件操作都会卡死到被 SIGKILL", + ); for (const p of problems) logger.error({ check: p.check }, p.message); if (problems.length) return 1; logger.info( diff --git a/docs/runbook/deploy.md b/docs/runbook/deploy.md index fb91afd8..0ca701df 100644 --- a/docs/runbook/deploy.md +++ b/docs/runbook/deploy.md @@ -181,8 +181,8 @@ docker compose ps && docker compose logs --tail=50 app | `docker build --build-arg APT_MIRROR=mirrors.ustc.edu.cn` | 成功(约 2 分 30 秒,缓存命中依赖层) | | setup → login → admin API Key → `POST /library-roots {localPath:/media}` → 改 `managed`;SQL 插 3 个 movie 作品 | 成功 | | 手动整理 `POST /organization-records` dryRun → 不带 dryRun 执行(Copy,1.5 GB 稀疏文件 `/media/_incoming/Check.Movie.2021…mkv`) | 记录 succeeded,1.8 s;日志 `organize.transfer` 带 `fsproxy:{mode:"subprocess",workerKind:"self",spawned:5,timeouts:0,fallbackReason:null}` | -| 以 `UMT_FS_TEST_HANG_AFTER_BYTES=1048576` 重建 app 后整理 60 MB 文件 | 容器内 `/proc` 看到 `/app/umt-server --role fsworker`(uid 10001,父进程为 `--role worker`)存活约 3–4 s 后消失,无残留子进程 | +| 以 `UMT_FS_TEST_HANG_AFTER_BYTES=1048576 UMT_FS_TEST_HOOKS=1` 重建 app 后整理 60 MB 文件 | 容器内 `/proc` 看到 `/app/umt-server --role fsworker`(uid 10001,父进程为 `--role worker`)存活约 3–4 s 后消失,无残留子进程 | | 超时链路 | 每次尝试:停滞 3 s → SIGTERM(worker 屏蔽)→ SIGKILL → `TIMEOUT`;共 3 次(`FS_TRANSFER_MAX_ATTEMPTS`)后账本 `{"code":"TIMEOUT","message":"fs copy 挂死:进度停滞超过 3000 ms"}`,`fsproxy.timeouts=3 killed=3`,待处理「文件操作挂死,重试已达上限」;目标目录只留被 KILL 的 `.uhd-partial`(下次写前按年龄清理),无最终名半成品 | | 清理 | `compose down -v`、删除测试镜像与临时目录 | -`UMT_FS_TEST_HANG_AFTER_BYTES` 只用于这类验收(让数据类操作的 worker 读到 n 字节后卡死 JS 线程并屏蔽 SIGTERM),生产不要设置。 +`UMT_FS_TEST_HANG_AFTER_BYTES` 只用于这类验收(让数据类操作的 worker 读到 n 字节后卡死 JS 线程并屏蔽 SIGTERM),必须同时设置 `UMT_FS_TEST_HOOKS=1` 才生效;单独设置不生效,但 `preflight` 会打 warn。生产不要设置。 diff --git a/packages/adapters/src/fs/fsproxy.test.ts b/packages/adapters/src/fs/fsproxy.test.ts index 5ae05b61..ea053d7a 100644 --- a/packages/adapters/src/fs/fsproxy.test.ts +++ b/packages/adapters/src/fs/fsproxy.test.ts @@ -9,6 +9,7 @@ import { type FsProgress, FsProxy, fsErrorClass, + fsTestHookFromEnv, sweepStalePartials, toFsError, } from "./index.ts"; @@ -147,10 +148,22 @@ describe("FsProxy 启动方式", () => { expect(px.stats()).toMatchObject({ mode: "inprocess", fallbackReason: expect.stringMatching(/^spawn_failed/) }); }); + it("验收开关只在 UMT_FS_TEST_HOOKS=1 时生效(单独误设 HANG_AFTER_BYTES 无效)", () => { + const env = (m: Record) => (k: string) => m[k]; + expect(fsTestHookFromEnv(env({ UMT_FS_TEST_HANG_AFTER_BYTES: "1024" }))).toBeUndefined(); + expect(fsTestHookFromEnv(env({ UMT_FS_TEST_HANG_AFTER_BYTES: "1024", UMT_FS_TEST_HOOKS: "0" }))).toBeUndefined(); + expect(fsTestHookFromEnv(env({ UMT_FS_TEST_HANG_AFTER_BYTES: "x", UMT_FS_TEST_HOOKS: "1" }))).toBeUndefined(); + expect(fsTestHookFromEnv(env({ UMT_FS_TEST_HANG_AFTER_BYTES: "1024", UMT_FS_TEST_HOOKS: "1" }))).toEqual({ + hangAfterBytes: 1024, + ignoreTerm: true, + }); + }); + it("UMT_FS_TEST_HANG_AFTER_BYTES(容器验收开关)→ 数据类操作卡死 → SIGKILL → TIMEOUT;快操作不受影响", async () => { const t = await tree({ "src/a.mkv": 3 * MiB, "dst/": null }); const px = new FsProxy({ mode: "subprocess", ...fast }); Deno.env.set("UMT_FS_TEST_HANG_AFTER_BYTES", String(MiB)); + Deno.env.set("UMT_FS_TEST_HOOKS", "1"); try { expect(await px.statSafe(t.path("src/a.mkv"))).toMatchObject({ kind: "ok" }); const e = await px.copy(t.path("src/a.mkv"), t.path("dst/a.mkv")).catch((x) => x); @@ -158,6 +171,7 @@ describe("FsProxy 启动方式", () => { expect(px.stats()).toMatchObject({ timeouts: 1, killed: 1 }); } finally { Deno.env.delete("UMT_FS_TEST_HANG_AFTER_BYTES"); + Deno.env.delete("UMT_FS_TEST_HOOKS"); } }, 30_000); }); diff --git a/packages/adapters/src/fs/fsproxy.ts b/packages/adapters/src/fs/fsproxy.ts index caeac213..6b428371 100644 --- a/packages/adapters/src/fs/fsproxy.ts +++ b/packages/adapters/src/fs/fsproxy.ts @@ -110,14 +110,19 @@ export function defaultWorkerCommand(): { cmd: string; args: string[]; kind: "sc /** * 仅容器/手工验收用:UMT_FS_TEST_HANG_AFTER_BYTES= 让每个数据类操作的 worker 在读到 n 字节后卡死 JS 线程 * (不响应 SIGTERM),用于在生产二进制里验证 停滞超时 → SIGKILL → TIMEOUT。只在子进程模式生效。 + * 必须同时设置 UMT_FS_TEST_HOOKS=1 才生效(验收 07 S-10):单独误设这一个变量不会让所有文件操作卡死。 */ +export function fsTestHookFromEnv(get: (k: string) => string | undefined): FsTestHook | undefined { + const v = get("UMT_FS_TEST_HANG_AFTER_BYTES"); + if (v === undefined || v === "" || !/^\d+$/.test(v)) return undefined; + if (get("UMT_FS_TEST_HOOKS") !== "1") return undefined; + return { hangAfterBytes: Number(v), ignoreTerm: true }; +} + function envTestHook(): FsTestHook | undefined { try { - const v = (globalThis as { Deno?: { env?: { get(k: string): string | undefined } } }).Deno?.env?.get( - "UMT_FS_TEST_HANG_AFTER_BYTES", - ); - if (v === undefined || v === "" || !/^\d+$/.test(v)) return undefined; - return { hangAfterBytes: Number(v), ignoreTerm: true }; + const env = (globalThis as { Deno?: { env?: { get(k: string): string | undefined } } }).Deno?.env; + return env ? fsTestHookFromEnv((k) => env.get(k)) : undefined; } catch { return undefined; }