- 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]>
76 lines
2.2 KiB
TypeScript
76 lines
2.2 KiB
TypeScript
export type FsErrorCode =
|
|
| "EXDEV" // 跨设备,不会自动降级
|
|
| "EEXIST" // 目标已存在(no-clobber)
|
|
| "ENOENT"
|
|
| "EACCES"
|
|
| "ENOSPC"
|
|
| "VERIFY_FAILED"
|
|
| "SOURCE_CHANGED"
|
|
| "ROOT_OFFLINE"
|
|
| "ABORTED"
|
|
/** 文件操作挂死(进度停滞/快操作超时),执行子进程已被回收;与 IO 同属可退避重试,不代表文件缺失 */
|
|
| "TIMEOUT"
|
|
| "IO";
|
|
|
|
/**
|
|
* 错误码 → 处置类别(对照 MP:所有 IO 故障归 OSError 复用重试;我们更细):
|
|
* - retry:可退避重试(IO / TIMEOUT / ROOT_OFFLINE / SOURCE_CHANGED),超限后进待处理
|
|
* - attention:直接进待处理、不重试(EACCES / ENOSPC / EXDEV)
|
|
* - manual:需人工判断(EEXIST / VERIFY_FAILED / ENOENT)
|
|
* - aborted:被取消
|
|
*/
|
|
export type FsErrorClass = "retry" | "attention" | "manual" | "aborted";
|
|
|
|
export function fsErrorClass(code: string): FsErrorClass {
|
|
switch (code) {
|
|
case "IO":
|
|
case "TIMEOUT":
|
|
case "ROOT_OFFLINE":
|
|
case "SOURCE_CHANGED":
|
|
return "retry";
|
|
case "EACCES":
|
|
case "ENOSPC":
|
|
case "EXDEV":
|
|
return "attention";
|
|
case "ABORTED":
|
|
return "aborted";
|
|
default:
|
|
return "manual";
|
|
}
|
|
}
|
|
|
|
export class FsError extends Error {
|
|
override readonly name = "FsError";
|
|
constructor(
|
|
readonly code: FsErrorCode,
|
|
message: string,
|
|
readonly detail: { path?: string; syscall?: string; cause?: unknown } = {},
|
|
) {
|
|
super(message);
|
|
}
|
|
}
|
|
|
|
export function errnoOf(e: unknown): string | undefined {
|
|
return (e as NodeJS.ErrnoException | undefined)?.code;
|
|
}
|
|
|
|
export function toFsError(e: unknown, path?: string): FsError {
|
|
if (e instanceof FsError) return e;
|
|
const code = errnoOf(e);
|
|
const mapped: FsErrorCode =
|
|
code === "EXDEV" || code === "EEXIST" || code === "ENOENT" || code === "ENOSPC"
|
|
? code
|
|
: code === "EACCES" || code === "EPERM"
|
|
? "EACCES"
|
|
: code === "ETIMEDOUT"
|
|
? "TIMEOUT"
|
|
: (e as Error)?.name === "AbortError"
|
|
? "ABORTED"
|
|
: "IO";
|
|
return new FsError(mapped, (e as Error)?.message ?? String(e), {
|
|
...(path !== undefined ? { path } : {}),
|
|
...((e as NodeJS.ErrnoException)?.syscall ? { syscall: (e as NodeJS.ErrnoException).syscall! } : {}),
|
|
cause: e,
|
|
});
|
|
}
|