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
@@ -0,0 +1,91 @@
|
||||
// fsproxy 的操作表:主进程(in-process 回退)与执行子进程(fsworker.ts)共用同一份实现。
|
||||
// 迁移来源:MoviePilot app/adapters/system/fsworker.py 的请求/响应模型(e6820a8),只借语义不借代码。
|
||||
|
||||
import { unlink } from "node:fs/promises";
|
||||
import { type CopyOptions, copyWithVerify, filesMatchSha256, sha256Full, sweepStalePartials } from "./copy.ts";
|
||||
import { FsError, toFsError } from "./errors.ts";
|
||||
import { type CrossMoveOptions, crossDeviceMove, moveSafe } from "./move.ts";
|
||||
import { checkRoot, statSafe } from "./stat.ts";
|
||||
|
||||
/** 可序列化的复制选项(去掉信号与回调) */
|
||||
export type WireCopyOptions = Omit<CopyOptions, "signal" | "onProgress" | "onVerifyProgress">;
|
||||
export type WireCrossMoveOptions = Omit<CrossMoveOptions, "signal" | "onProgress" | "onVerifyProgress">;
|
||||
|
||||
/** 测试开关:执行子进程在累计进度达到 hangAfterBytes 后阻塞 JS 线程(模拟卡死的系统调用);ignoreTerm 屏蔽 SIGTERM */
|
||||
export interface FsTestHook {
|
||||
hangAfterBytes?: number;
|
||||
ignoreTerm?: boolean;
|
||||
}
|
||||
|
||||
export type FsOpRequest = (
|
||||
| { op: "copy"; src: string; dst: string; opts?: WireCopyOptions }
|
||||
| { op: "crossMove"; src: string; dst: string; opts?: WireCrossMoveOptions }
|
||||
| { op: "move"; src: string; dst: string }
|
||||
| { op: "match"; a: string; b: string }
|
||||
| { op: "sha256"; path: string }
|
||||
| { op: "stat"; path: string; root?: string; sentinel?: string }
|
||||
| { op: "checkRoot"; root: string; sentinel?: string }
|
||||
| { op: "sweep"; dir: string; olderThanMs: number; suffix?: string }
|
||||
| { op: "unlink"; path: string }
|
||||
) & { _test?: FsTestHook };
|
||||
|
||||
export type FsOpName = FsOpRequest["op"];
|
||||
|
||||
/** 按"进度停滞"判挂死的数据类操作;其余是快操作,按总时长判超时 */
|
||||
export const DATA_OPS: ReadonlySet<FsOpName> = new Set(["copy", "crossMove", "match", "sha256"]);
|
||||
|
||||
export interface FsProgress {
|
||||
phase: string;
|
||||
bytes: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface FsOpContext {
|
||||
signal: AbortSignal;
|
||||
progress: (p: FsProgress) => void;
|
||||
}
|
||||
|
||||
export async function runFsOp(req: FsOpRequest, ctx: FsOpContext): Promise<unknown> {
|
||||
const { signal, progress } = ctx;
|
||||
const at = (phase: string) => (bytes: number, total: number) => progress({ phase, bytes, total });
|
||||
switch (req.op) {
|
||||
case "copy":
|
||||
return copyWithVerify(req.src, req.dst, {
|
||||
...req.opts,
|
||||
signal,
|
||||
onProgress: at("copy"),
|
||||
onVerifyProgress: at("verify"),
|
||||
});
|
||||
case "crossMove":
|
||||
return crossDeviceMove(req.src, req.dst, {
|
||||
...req.opts,
|
||||
signal,
|
||||
onProgress: at("copy"),
|
||||
onVerifyProgress: at("verify"),
|
||||
});
|
||||
case "move":
|
||||
return moveSafe(req.src, req.dst);
|
||||
case "match":
|
||||
return filesMatchSha256(req.a, req.b, { signal, onProgress: at("hash") });
|
||||
case "sha256":
|
||||
return sha256Full(req.path, signal, at("hash"));
|
||||
case "stat":
|
||||
return statSafe(req.path, {
|
||||
...(req.root ? { root: req.root } : {}),
|
||||
...(req.sentinel ? { sentinel: req.sentinel } : {}),
|
||||
});
|
||||
case "checkRoot":
|
||||
return checkRoot(req.root, req.sentinel);
|
||||
case "sweep":
|
||||
return sweepStalePartials(req.dir, req.olderThanMs, req.suffix);
|
||||
case "unlink":
|
||||
try {
|
||||
await unlink(req.path);
|
||||
return null;
|
||||
} catch (e) {
|
||||
throw toFsError(e, req.path);
|
||||
}
|
||||
default:
|
||||
throw new FsError("IO", `unknown fs op: ${(req as { op: string }).op}`);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user