- 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]>
98 lines
3.4 KiB
TypeScript
98 lines
3.4 KiB
TypeScript
// 本项目新增(REQ §8.2 挂载根哨兵);MP 无对应实现。
|
|
import { lstat, stat, writeFile } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
import { errnoOf } from "./errors.ts";
|
|
|
|
export const DEFAULT_ROOT_SENTINEL = ".uhd-root";
|
|
|
|
export interface FileFacts {
|
|
size: number;
|
|
mtimeMs: number;
|
|
ino: number;
|
|
dev: number;
|
|
nlink: number;
|
|
isFile: boolean;
|
|
isDirectory: boolean;
|
|
isSymbolicLink: boolean;
|
|
}
|
|
|
|
export type StatResult =
|
|
| { kind: "ok"; facts: FileFacts }
|
|
| { kind: "missing" }
|
|
| { kind: "permission_denied" }
|
|
/** 挂载根哨兵不存在/不可读:不能据此判定文件缺失 */
|
|
| { kind: "root_offline"; root: string; reason: string }
|
|
| { kind: "error"; code: string; message: string };
|
|
|
|
export type RootHealth =
|
|
| { kind: "online"; dev: number }
|
|
| {
|
|
kind: "offline";
|
|
reason: "sentinel_missing" | "root_missing" | "permission_denied" | "timeout" | "error";
|
|
code?: string;
|
|
};
|
|
|
|
/** 检查库根/缓存根是否在线:根目录存在且哨兵文件存在。 */
|
|
export async function checkRoot(root: string, sentinel: string = DEFAULT_ROOT_SENTINEL): Promise<RootHealth> {
|
|
let dev: number;
|
|
try {
|
|
const st = await stat(root);
|
|
dev = st.dev;
|
|
} catch (e) {
|
|
const code = errnoOf(e);
|
|
if (code === "ENOENT") return { kind: "offline", reason: "root_missing" };
|
|
if (code === "EACCES" || code === "EPERM") return { kind: "offline", reason: "permission_denied", code };
|
|
return { kind: "offline", reason: "error", code: code ?? "UNKNOWN" };
|
|
}
|
|
try {
|
|
await stat(join(root, sentinel));
|
|
return { kind: "online", dev };
|
|
} catch (e) {
|
|
const code = errnoOf(e);
|
|
if (code === "ENOENT") return { kind: "offline", reason: "sentinel_missing" };
|
|
if (code === "EACCES" || code === "EPERM") return { kind: "offline", reason: "permission_denied", code };
|
|
return { kind: "offline", reason: "error", code: code ?? "UNKNOWN" };
|
|
}
|
|
}
|
|
|
|
/** 在根目录写入哨兵文件(首次登记库根时调用)。 */
|
|
export async function writeRootSentinel(root: string, sentinel: string = DEFAULT_ROOT_SENTINEL): Promise<void> {
|
|
await writeFile(join(root, sentinel), `uhd-media-tool root sentinel\ncreated=${new Date().toISOString()}\n`, {
|
|
flag: "wx",
|
|
}).catch((e) => {
|
|
if (errnoOf(e) !== "EEXIST") throw e;
|
|
});
|
|
}
|
|
|
|
function facts(st: Awaited<ReturnType<typeof lstat>>): FileFacts {
|
|
return {
|
|
size: Number(st.size),
|
|
mtimeMs: Number(st.mtimeMs),
|
|
ino: Number(st.ino),
|
|
dev: Number(st.dev),
|
|
nlink: Number(st.nlink),
|
|
isFile: st.isFile(),
|
|
isDirectory: st.isDirectory(),
|
|
isSymbolicLink: st.isSymbolicLink(),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 区分 ENOENT / EACCES / 挂载根离线的 stat。给出 root 时先检查哨兵,
|
|
* 根离线返回 root_offline 而不是 missing(REQ §8.2)。不抛异常。
|
|
*/
|
|
export async function statSafe(path: string, opts: { root?: string; sentinel?: string } = {}): Promise<StatResult> {
|
|
if (opts.root) {
|
|
const h = await checkRoot(opts.root, opts.sentinel);
|
|
if (h.kind === "offline") return { kind: "root_offline", root: opts.root, reason: h.reason };
|
|
}
|
|
try {
|
|
return { kind: "ok", facts: facts(await lstat(path)) };
|
|
} catch (e) {
|
|
const code = errnoOf(e);
|
|
if (code === "ENOENT" || code === "ENOTDIR") return { kind: "missing" };
|
|
if (code === "EACCES" || code === "EPERM") return { kind: "permission_denied" };
|
|
return { kind: "error", code: code ?? "UNKNOWN", message: (e as Error)?.message ?? String(e) };
|
|
}
|
|
}
|