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:
1 parent
36dc048e89
commit
2e2d56184c
21 files changed
+1970
-179
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user