feat(fs): W4-E4 fsproxy hang isolation, partial sweep, replace-with-verify, TIMEOUT retry

- adapters/fs: FsProxy + fsworker run each file op in a killable subprocess;
  progress-stall watchdog (FS_HANG_TIMEOUT_MS) for copy/hash, total timeout for
  quick ops; SIGTERM -> SIGKILL -> FsError('TIMEOUT'); stat/checkRoot timeouts
  never read as missing
- copyWithVerify: onExisting conflict/skip/replace (hardlink backup + atomic
  rename, old file to .umt-trash or deleted), sweep stale partials before write,
  document why permissions/owner are not copied
- filesMatch/filesMatchSha256 throw FsError on read failure instead of
  match=false; crossDeviceMove skips redundant re-hash after sha256 copy
- FsErrorCode TIMEOUT + fsErrorClass; organize executeRecord routes fs ops
  through fsproxy, retries TIMEOUT/IO up to FS_TRANSFER_MAX_ATTEMPTS then opens
  a decision without deleting the record; reconcile errors no longer reported
  as EEXIST
- domain: subtitle .default marker (ORGANIZE_DEFAULT_SUBTITLE_LANG) and
  findOtherVersions (same season/episode/part, video only) as upgrade
  disposal candidates
- scripts/bench/fs-bench.ts and benchmark record in porting doc

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-27 23:15:05 +08:00
1 parent 36dc048e89
commit 2e2d56184c
21 files changed
+1970 -179

No files matched your search

+80 -1
View File
@@ -1,5 +1,6 @@
import { execFileSync } from "node:child_process";
import { chmod, readFile, rm, stat, symlink, unlink, writeFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import { chmod, readdir, readFile, rm, stat, symlink, unlink, utimes, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { createTempTree, findCrossDeviceBase, type TempTree } from "@umt/testkit";
import { afterEach, describe, expect, it } from "vitest";
@@ -303,3 +304,81 @@ describe("ffprobe", () => {
}
});
});
describe("copyWithVerify:onExisting / 写前清 partial / 比对异常不冒充 EEXIST", () => {
it("replace:新文件写临时→校验→原子替换,旧文件移入回收目录(不裸覆盖)", async () => {
const t = await tree({ "src/a.mkv": "NEW-CONTENT", "lib/a.mkv": "OLD" });
const r = await copyWithVerify(t.path("src/a.mkv"), t.path("lib/a.mkv"), {
hash: "sha256",
onExisting: "replace",
replaceOld: { mode: "trash", trashDir: t.path("lib/.umt-trash") },
});
expect(r.outcome).toBe("committed");
expect(await readFile(t.path("lib/a.mkv"), "utf8")).toBe("NEW-CONTENT");
expect(r.replaced?.disposition).toBe("trash");
expect(r.replaced?.size).toBe(3);
expect(await readFile(r.replaced!.path!, "utf8")).toBe("OLD");
expect(r.replaced!.path!.startsWith(t.path("lib/.umt-trash/replaced-"))).toBe(true);
// 目标目录只剩新文件与回收目录,没有留底/临时残留
expect((await readdir(t.path("lib"))).sort()).toEqual([".umt-trash", "a.mkv"]);
});
it("replace + delete:旧文件删除;replace 不替换目录/软链;skip 不写;conflict 仍 EEXIST", async () => {
const t = await tree({ "src/a.mkv": "NEW", "lib/a.mkv": "OLD", "lib/d.mkv/": null, "lib/b.mkv": "KEEP" });
const r = await copyWithVerify(t.path("src/a.mkv"), t.path("lib/a.mkv"), {
onExisting: "replace",
replaceOld: { mode: "delete" },
});
expect(r.replaced).toMatchObject({ disposition: "deleted", size: 3 });
expect(await readFile(t.path("lib/a.mkv"), "utf8")).toBe("NEW");
expect((await readdir(t.path("lib"))).sort()).toEqual(["a.mkv", "b.mkv", "d.mkv"]);
const dir = await copyWithVerify(t.path("src/a.mkv"), t.path("lib/d.mkv"), { onExisting: "replace" }).catch(
(e) => e,
);
expect(dir.code).toBe("EEXIST");
await symlink(t.path("nowhere"), t.path("lib/s.mkv"));
const sl = await copyWithVerify(t.path("src/a.mkv"), t.path("lib/s.mkv"), { onExisting: "replace" }).catch(
(e) => e,
);
expect(sl.code).toBe("EEXIST");
const sk = await copyWithVerify(t.path("src/a.mkv"), t.path("lib/b.mkv"), { onExisting: "skip" });
expect(sk.outcome).toBe("skipped_existing");
expect(await readFile(t.path("lib/b.mkv"), "utf8")).toBe("KEEP");
const cf = await copyWithVerify(t.path("src/a.mkv"), t.path("lib/b.mkv")).catch((e) => e);
expect(cf.code).toBe("EEXIST");
});
it("写前清理本目录 >24h 的残留 partial,新鲜的保留;可关闭", async () => {
const t = await tree({ "src/a.mkv": 1024, "lib/": null });
const stale = await t.write("lib/.x.mkv.1.abc.uhd-partial", "half");
const fresh = await t.write("lib/.y.mkv.1.def.uhd-partial", "half");
const old = new Date(Date.now() - 25 * 3600_000);
await utimes(stale, old, old);
const r = await copyWithVerify(t.path("src/a.mkv"), t.path("lib/a.mkv"));
expect(r.sweptPartials).toEqual([stale]);
expect(existsSync(stale)).toBe(false);
expect(existsSync(fresh)).toBe(true);
await utimes(fresh, old, old);
const r2 = await copyWithVerify(t.path("src/a.mkv"), t.path("lib/b.mkv"), { sweepPartialsOlderThanMs: null });
expect(r2.sweptPartials).toBeUndefined();
expect(existsSync(fresh)).toBe(true);
});
it("filesMatchSha256 读失败抛 FsError(不返回 match=false,调用方不会误报 EEXIST)", async () => {
const t = await tree({ "a.mkv": 4096 });
const e = await filesMatchSha256(t.path("a.mkv"), t.path("gone.mkv")).catch((x) => x);
expect(e).toBeInstanceOf(FsError);
expect(e.code).toBe("ENOENT");
});
it.skipIf(isRoot)("filesMatchSha256 无读权限 → EACCES/IO,不是 EEXIST", async () => {
const t = await tree({ "a.mkv": 4096, "b.mkv": 4096 });
await chmod(t.path("b.mkv"), 0o000);
const e = await filesMatchSha256(t.path("a.mkv"), t.path("b.mkv")).catch((x) => x);
await chmod(t.path("b.mkv"), 0o644);
expect(e).toBeInstanceOf(FsError);
expect(["EACCES", "IO"]).toContain(e.code);
});
});