feat(server,fs): W5-E production fsworker role, settings auto-investigate mode, replace old file to trash_item

- main.ts: `--role fsworker` dispatched before the rest of the module graph is
  loaded (other imports became dynamic); runs fsWorkerMain from the new
  `@umt/adapters/fsworker` export (~150 ms startup in the compiled binary)
- fsproxy: default worker command picked per runtime (deno CLI -> `deno run
  fsworker.ts`, compiled binary -> `<execPath> --role fsworker`); stats expose
  workerKind; UMT_FS_TEST_HANG_AFTER_BYTES acceptance switch for containers
- compile: --allow-run adds /app/umt-server (spawn self); start task allows deno
- organize: replaced old file moved into the file-manager trash layout
  (<root>/.umt-trash/<day>/<uuid>/<rel>) and registered in trash_item, ledger
  result follows; organize.transfer log carries fsproxy stats
- settings: autoInvestigateMode (investigate/recover) + max recover attempts
- deploy.md: container verification (subprocess self, TIMEOUT -> SIGKILL x3)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-27 23:50:10 +08:00
1 parent fc49c58359
commit 3641ab9573
12 files changed
+248 -48

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@umt/adapters",
"version": "0.1.0",
"exports": { ".": "./src/index.ts" },
"exports": { ".": "./src/index.ts", "./fsworker": "./src/fs/fsworker.ts" },
"tasks": {
"test": "deno run -A npm:vitest run",
"typecheck": "deno run -A npm:typescript/tsc --noEmit -p tsconfig.json"
+2 -1
View File
@@ -3,6 +3,7 @@
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts"
".": "./src/index.ts",
"./fsworker": "./src/fs/fsworker.ts"
}
}
+2 -1
View File
@@ -10,7 +10,8 @@
- 快操作按总时长 `quickTimeoutMs`(缺省 30 s)。`statSafe` 超时返回 `{kind:"error",code:"TIMEOUT"}`,`checkRoot` 超时返回 `offline(timeout)`——**挂死不等于文件缺失**。
- 卡在 D 状态的子进程被 `unref`,不拖住本进程;被 SIGKILL 的 partial 由下次写前清理收走。
- 子进程里 sha256 走流式(带心跳),不走 `sha256sum`(看不到进度);吞吐见 `docs/porting/moviepilot-rust-transfer.md` 基准记录。
- `mode: "auto"`:`Deno.execPath()` 是 deno CLI 才用子进程;`deno compile` 出的二进制回退 in-process(同样的看门狗,超时立即报 TIMEOUT 并中止,但卡死系统调用占住的线程无法回收)。要在编译二进制里用子进程,入口加 `--role fsworker` 调 `fsWorkerMain()`,并以 `workerCommand: { cmd: execPath, args: ["--role","fsworker"] }` 构造 FsProxy(待办)。
- `mode: "auto"`:`Deno.execPath()` 是 deno CLI → `deno run -A fsworker.ts`;否则视为 `deno compile` 出的 umt-server 二进制 → `<execPath> --role fsworker`(`apps/server/src/main.ts` 在加载其余模块前分派,启动约 150 ms)。编译产物的 `--allow-run` 必须含自身绝对路径(镜像内 `/app/umt-server`,见 `apps/server/deno.json`);起不来(NotCapable/ENOENT)自动回退 in-process(同样的看门狗,超时立即报 TIMEOUT 并中止,但卡死系统调用占住的线程无法回收),`stats().fallbackReason` 记原因。`stats().workerKind` = script / self / custom。
- 容器验收开关:worker 进程设 `UMT_FS_TEST_HANG_AFTER_BYTES=<n>` 后,每个数据类操作的 worker 读到 n 字节即卡死 JS 线程并屏蔽 SIGTERM,用来在生产二进制里验证 停滞超时 → SIGKILL → TIMEOUT;生产勿设。
- 测试开关 `_test: { hangAfterBytes, ignoreTerm }`:worker 在累计进度达到阈值后阻塞 JS 线程,模拟卡死的系统调用。
错误码处置类别见 `fsErrorClass`:IO/TIMEOUT/ROOT_OFFLINE/SOURCE_CHANGED 可退避重试;EACCES/ENOSPC/EXDEV 进待处理;EEXIST/VERIFY_FAILED/ENOENT 需人工。
+41 -1
View File
@@ -3,7 +3,15 @@ import { existsSync } from "node:fs";
import { readdir, readFile, writeFile } from "node:fs/promises";
import { createTempTree, type TempTree } from "@umt/testkit";
import { afterEach, describe, expect, it } from "vitest";
import { FsError, type FsProgress, FsProxy, fsErrorClass, sweepStalePartials, toFsError } from "./index.ts";
import {
defaultWorkerCommand,
FsError,
type FsProgress,
FsProxy,
fsErrorClass,
sweepStalePartials,
toFsError,
} from "./index.ts";
// fsproxy:单个文件操作在执行子进程里跑,按进度心跳判挂死 → SIGTERM → SIGKILL → FsError('TIMEOUT')。
@@ -122,6 +130,38 @@ describe("FsProxy(子进程)", () => {
}, 30_000);
});
describe("FsProxy 启动方式", () => {
it("deno CLI 下缺省 run fsworker.ts;显式 workerCommand 记为 custom", () => {
const auto = defaultWorkerCommand();
expect(auto?.kind).toBe("script");
expect(auto?.args.at(-1)).toMatch(/fsworker\.ts$/);
expect(new FsProxy().stats()).toMatchObject({ mode: "subprocess", workerKind: "script" });
const px = new FsProxy({ workerCommand: { cmd: "/app/umt-server", args: ["--role", "fsworker"] } });
expect(px.stats()).toMatchObject({ mode: "subprocess", workerKind: "custom" });
});
it("起不来的 worker 命令(auto)回退 in-process 并记原因", async () => {
const t = await tree({ "a.mkv": "x" });
const px = new FsProxy({ workerCommand: { cmd: "/nonexistent/umt-server", args: ["--role", "fsworker"] } });
expect(await px.statSafe(t.path("a.mkv"))).toMatchObject({ kind: "ok" });
expect(px.stats()).toMatchObject({ mode: "inprocess", fallbackReason: expect.stringMatching(/^spawn_failed/) });
});
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));
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);
expect(e.code).toBe("TIMEOUT");
expect(px.stats()).toMatchObject({ timeouts: 1, killed: 1 });
} finally {
Deno.env.delete("UMT_FS_TEST_HANG_AFTER_BYTES");
}
}, 30_000);
});
describe("FsProxy(in-process 回退)", () => {
it("正常复制有进度;FIFO 挂死 → TIMEOUT(看门狗),不等卡住的调用返回", async () => {
const t = await tree({ "src/a.mkv": 3 * MiB, "dst/": null });
+54 -21
View File
@@ -7,7 +7,9 @@
//
// 与 MP 的差异:MP 是常驻 worker + 一把锁串行;我们每个操作一个子进程(~50 ms 启动,入库按文件计可忽略),
// 天然并发、互不拖累,也没有"上一个请求挂死把 worker 占住"的问题。
// 无法起子进程时(deno compile 出的二进制:execPath 不是 deno;或缺 --allow-run)回退 in-process:
// 启动方式:execPath 是 deno CLI(开发/测试)→ `deno run -A fsworker.ts`;否则视为 deno compile 出的 umt-server
// 二进制 → `<execPath> --role fsworker`(入口 main.ts 在加载其余模块前分派)。编译产物的 --allow-run 须含自身绝对路径
// (镜像内 /app/umt-server,见 apps/server/deno.json);起不来(NotCapable 等)回退 in-process:
// 同样的心跳看门狗,超时立即以 TIMEOUT 失败并中止操作,但卡死的系统调用占住的线程无法回收(见 README)。
import { basename } from "node:path";
import { fileURLToPath } from "node:url";
@@ -51,6 +53,7 @@ export interface FsCallOptions {
export interface FsProxyStats {
mode: "subprocess" | "inprocess";
workerKind: FsProxy["workerKind"];
spawned: number;
timeouts: number;
killed: number;
@@ -75,13 +78,48 @@ const WORKER_PATH = (() => {
}
})();
function denoCliAvailable(): boolean {
/** 当前运行时能用的缺省 worker 启动命令:deno CLI → run 脚本;编译二进制 → 自身 --role fsworker;都不行 → null */
export function defaultWorkerCommand(): { cmd: string; args: string[]; kind: "script" | "self" } | null {
const D = (globalThis as { Deno?: { execPath?: () => string; Command?: unknown } }).Deno;
if (!D?.Command || !D.execPath || !WORKER_PATH) return false;
if (!D?.Command || !D.execPath) return null;
let exe: string;
try {
return /^deno(\.exe)?$/.test(basename(D.execPath()));
exe = D.execPath();
} catch {
return false;
return null;
}
if (/^deno(\.exe)?$/.test(basename(exe))) {
if (!WORKER_PATH) return null;
return {
cmd: exe,
args: [
"run",
"--no-config",
"--quiet",
"--no-prompt",
// Deno 对 /dev、/proc 下的路径(如 /dev/shm 上的库根)要求 --allow-all;worker 只执行本包固定的操作表
"--allow-all",
WORKER_PATH,
],
kind: "script",
};
}
return { cmd: exe, args: ["--role", "fsworker"], kind: "self" };
}
/**
* 仅容器/手工验收用:UMT_FS_TEST_HANG_AFTER_BYTES=<n> 让每个数据类操作的 worker 在读到 n 字节后卡死 JS 线程
* (不响应 SIGTERM),用于在生产二进制里验证 停滞超时 → SIGKILL → TIMEOUT。只在子进程模式生效。
*/
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 };
} catch {
return undefined;
}
}
@@ -109,6 +147,8 @@ function pathOf(req: FsOpRequest): string | undefined {
export class FsProxy {
readonly cfg: Required<Omit<FsProxyConfig, "workerCommand" | "mode">> & Pick<FsProxyConfig, "workerCommand">;
/** 子进程怎么起:script = deno run fsworker.ts;self = 编译二进制 --role fsworker;custom = 显式 workerCommand */
readonly workerKind: "script" | "self" | "custom" | null;
#mode: "subprocess" | "inprocess";
#forced: boolean;
#stats = { spawned: 0, timeouts: 0, killed: 0 };
@@ -120,12 +160,15 @@ export class FsProxy {
quickTimeoutMs: cfg.quickTimeoutMs ?? 30_000,
progressIntervalMs: cfg.progressIntervalMs ?? 1000,
killGraceMs: cfg.killGraceMs ?? 2000,
...(cfg.workerCommand ? { workerCommand: cfg.workerCommand } : {}),
};
const auto = cfg.workerCommand ? null : defaultWorkerCommand();
const wc = cfg.workerCommand ?? (auto ? { cmd: auto.cmd, args: auto.args } : undefined);
if (wc) this.cfg.workerCommand = wc;
this.workerKind = cfg.workerCommand ? "custom" : (auto?.kind ?? null);
const mode = cfg.mode ?? "auto";
this.#forced = mode === "subprocess";
if (mode === "inprocess") this.#mode = "inprocess";
else if (cfg.workerCommand || denoCliAvailable()) this.#mode = "subprocess";
else if (wc) this.#mode = "subprocess";
else {
if (mode === "subprocess") throw new Error("fsproxy: 当前运行时无法启动 deno 子进程,且未提供 workerCommand");
this.#mode = "inprocess";
@@ -138,7 +181,7 @@ export class FsProxy {
}
stats(): FsProxyStats {
return { mode: this.#mode, ...this.#stats, fallbackReason: this.#fallbackReason };
return { mode: this.#mode, workerKind: this.workerKind, ...this.#stats, fallbackReason: this.#fallbackReason };
}
// ── 业务操作 ──
@@ -260,18 +303,7 @@ export class FsProxy {
async #runSubprocess(req: FsOpRequest, call: FsCallOptions): Promise<unknown> {
const D = (globalThis as unknown as { Deno: DenoLike }).Deno;
const cmd = this.cfg.workerCommand ?? {
cmd: D.execPath(),
args: [
"run",
"--no-config",
"--quiet",
"--no-prompt",
// Deno 对 /dev、/proc 下的路径(如 /dev/shm 上的库根)要求 --allow-all;worker 只执行本包固定的操作表
"--allow-all",
WORKER_PATH!,
],
};
const cmd = this.cfg.workerCommand!;
let child: DenoChild;
try {
child = new D.Command(cmd.cmd, {
@@ -287,7 +319,8 @@ export class FsProxy {
this.#stats.spawned += 1;
const limit = this.#limitFor(req);
const w = child.stdin.getWriter();
await w.write(new TextEncoder().encode(JSON.stringify(call._test ? { ...req, _test: call._test } : req)));
const hook = call._test ?? (DATA_OPS.has(req.op) ? envTestHook() : undefined);
await w.write(new TextEncoder().encode(JSON.stringify(hook ? { ...req, _test: hook } : req)));
await w.close().catch(() => undefined);
let stderr = "";