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,18 @@
|
||||
# fs
|
||||
|
||||
本地文件操作:`copyWithVerify`(同目录隐藏临时文件 → fsync → size/sha256/抽样校验 → no-clobber 原子提交 → fsync 目录,保留 mtime,默认不建父目录)、`moveSafe`(同设备 no-clobber 移动,EXDEV 返回 `exdev` 不降级)、`crossDeviceMove`(copy → verify → delete_source,返回步骤日志,可重入,删源失败为 `attention`)、`statSafe`/`checkRoot`(先查库根哨兵 `.uhd-root`,区分 missing / permission_denied / root_offline)、`scanIncremental`(名称排序深度优先、每批 lstat 上限、游标续扫、new/changed/unchanged 标注)、`ffprobe`(无 ffprobe 或失败返回 `unknown`,不抛)、`diskUsage`(statfs,失败返回 `unknown`)、`sweepStalePartials`。
|
||||
本地文件操作:`copyWithVerify`(同目录隐藏临时文件 → fsync → size/sha256/抽样校验 → 提交 → fsync 目录,保留 mtime,**不复制权限/属主**,默认不建父目录;目标已存在按 `onExisting`:`conflict` 抛 EEXIST / `skip` 不写 / `replace` 旧文件硬链接留底 → rename 原子替换 → 留底进回收目录或删除,不裸覆盖;写前顺带清本目录 >24h 的 `.uhd-partial`)、`moveSafe`(同设备 no-clobber 移动,EXDEV 返回 `exdev` 不降级)、`crossDeviceMove`(copy → verify → delete_source,返回步骤日志,可重入,删源失败为 `attention`;copy 已做 sha256 时 verify 不再重复全量哈希)、`statSafe`/`checkRoot`(先查库根哨兵 `.uhd-root`,区分 missing / permission_denied / root_offline)、`scanIncremental`、`ffprobe`、`diskUsage`、`sweepStalePartials`、`filesMatchSha256`(读失败/超时抛 FsError,不返回 match=false 冒充"内容不同")。
|
||||
|
||||
在本机临时目录与 /dev/shm(跨设备)上做了集成测试;权限拒绝类用例在 root 下跳过。未在真实 NAS/FUSE/网络挂载上验证。
|
||||
## 挂死隔离:`FsProxy`(对标 MP fsproxy)
|
||||
|
||||
`new FsProxy({ hangTimeoutMs, quickTimeoutMs, progressIntervalMs, killGraceMs, mode })` 把单个文件操作(copy / crossMove / move / match / sha256 / stat / checkRoot / sweep / unlink)放进一次性执行子进程 `fsworker.ts`(`deno run --no-config -A`,约 50 ms 启动),stdin 一条 JSON 请求,stdout 逐行心跳 + 结果。
|
||||
|
||||
- 数据类操作(复制、跨卷搬运、哈希)按**进度停滞**判挂死:`hangTimeoutMs`(缺省 120 s)内字节数与阶段都没前进 → SIGTERM(worker 中止并清掉自己的 partial)→ `killGraceMs` 后 SIGKILL → `FsError('TIMEOUT')`。大文件复制几小时不会误杀。
|
||||
- 快操作按总时长 `quickTimeoutMs`(缺省 30 s)。`statSafe` 超时返回 `{kind:"error",code:"TIMEOUT"}`,`checkRoot` 超时返回 `offline(timeout)`——**挂死不等于文件缺失**。
|
||||
- 卡在 D 状态的子进程被 `unref`,不拖住本进程;被 SIGKILL 的 partial 由下次写前清理收走。
|
||||
- 子进程里 sha256 走流式(带心跳),不走 `sha256sum`(看不到进度);吞吐见 `docs/porting/moviepilot-rust-transfer.md` 基准记录。
|
||||
- `mode: "auto"`:`Deno.execPath()` 是 deno CLI 才用子进程;`deno compile` 出的二进制回退 in-process(同样的看门狗,超时立即报 TIMEOUT 并中止,但卡死系统调用占住的线程无法回收)。要在编译二进制里用子进程,入口加 `--role fsworker` 调 `fsWorkerMain()`,并以 `workerCommand: { cmd: execPath, args: ["--role","fsworker"] }` 构造 FsProxy(待办)。
|
||||
- 测试开关 `_test: { hangAfterBytes, ignoreTerm }`:worker 在累计进度达到阈值后阻塞 JS 线程,模拟卡死的系统调用。
|
||||
|
||||
错误码处置类别见 `fsErrorClass`:IO/TIMEOUT/ROOT_OFFLINE/SOURCE_CHANGED 可退避重试;EACCES/ENOSPC/EXDEV 进待处理;EEXIST/VERIFY_FAILED/ENOENT 需人工。
|
||||
|
||||
在本机临时目录与 /dev/shm(跨设备)上做了集成测试(含 FIFO 真实阻塞);权限拒绝类用例在 root 下跳过。未在真实 NAS/FUSE/网络挂载上验证。
|
||||
@@ -10,6 +10,27 @@ import { errnoOf, FsError, toFsError } from "./errors.ts";
|
||||
|
||||
export type VerifyMethod = "size" | "sha256" | "sample";
|
||||
|
||||
/** 字节级进度回调(复制 / 校验回读 / 哈希共用) */
|
||||
export type ProgressFn = (bytesDone: number, totalBytes: number) => void;
|
||||
|
||||
/**
|
||||
* 目标已存在时的执行端策略(对应域层 ExistingPolicy,由计划冻结后传入):
|
||||
* - conflict(缺省):抛 EEXIST,绝不覆盖
|
||||
* - skip:不写,回执 outcome=skipped_existing
|
||||
* - replace:新文件先写临时名 → 校验 → 旧文件硬链接留底 → rename 原子替换 → 留底按 replaceOld 处置;不裸覆盖
|
||||
*/
|
||||
export type OnExisting = "conflict" | "skip" | "replace";
|
||||
|
||||
/** replace 时被替换旧文件的去向:trash = 移到 trashDir(同卷 rename);delete = 删除 */
|
||||
export interface ReplaceOldPolicy {
|
||||
mode: "trash" | "delete";
|
||||
/** mode=trash 时的回收目录(应与目标同卷);缺省 <目标目录>/.umt-trash */
|
||||
trashDir?: string;
|
||||
}
|
||||
|
||||
/** 写前顺带清理本目录残留 partial 的缺省年龄(对照 MP _cleanup_stale_partials:24h) */
|
||||
export const DEFAULT_PARTIAL_MAX_AGE_MS = 24 * 3600_000;
|
||||
|
||||
export interface CopyOptions {
|
||||
/** 临时文件后缀,默认 .uhd-partial;临时文件与目标同目录、以 . 开头 */
|
||||
tmpSuffix?: string;
|
||||
@@ -21,7 +42,26 @@ export interface CopyOptions {
|
||||
/** 是否创建目标父目录;默认 false(防止库根离线时在挂载点下写入) */
|
||||
createParents?: boolean;
|
||||
signal?: AbortSignal;
|
||||
onProgress?: (bytesCopied: number, totalBytes: number) => void;
|
||||
onProgress?: ProgressFn;
|
||||
/** 校验回读(sha256/sample)的进度;给出时 sha256 走进程内流式以便上报心跳 */
|
||||
onVerifyProgress?: ProgressFn;
|
||||
/** 目标已存在时的策略,缺省 conflict */
|
||||
onExisting?: OnExisting;
|
||||
/** onExisting=replace 时旧文件去向,缺省 trash */
|
||||
replaceOld?: ReplaceOldPolicy;
|
||||
/** 写入前清理目标目录中超过该年龄的残留临时文件;缺省 24h,null 关闭。失败吞掉 */
|
||||
sweepPartialsOlderThanMs?: number | null;
|
||||
}
|
||||
|
||||
export interface ReplacedOld {
|
||||
/** 旧文件留底后去向:trash=已移到回收目录;deleted=已删除;left=处置失败,留底文件保留在原目录 */
|
||||
disposition: "trash" | "deleted" | "left";
|
||||
/** 旧文件的大小与 mtime(证据) */
|
||||
size: number;
|
||||
mtimeMs: number;
|
||||
/** trash / left 时旧文件当前所在路径 */
|
||||
path?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface CopyReceipt {
|
||||
@@ -35,6 +75,12 @@ export interface CopyReceipt {
|
||||
sampleDigest?: string;
|
||||
partialPath: string;
|
||||
durationMs: number;
|
||||
/** committed = 已提交到目标;skipped_existing = onExisting=skip 且目标已存在,未写 */
|
||||
outcome: "committed" | "skipped_existing";
|
||||
/** onExisting=replace 且确实替换了旧文件 */
|
||||
replaced?: ReplacedOld;
|
||||
/** 本次写前清走的残留 partial */
|
||||
sweptPartials?: string[];
|
||||
}
|
||||
|
||||
async function fsyncPath(p: string, flags = "r"): Promise<void> {
|
||||
@@ -53,13 +99,17 @@ async function fsyncPath(p: string, flags = "r"): Promise<void> {
|
||||
/** 复制/哈希的流块大小:1 MiB(缺省 64 KiB 在大文件上每 GB 要多上万次回调) */
|
||||
export const IO_CHUNK_BYTES = 1024 * 1024;
|
||||
|
||||
/** 进程内流式 sha256(每块之间让出事件循环) */
|
||||
export async function sha256File(p: string, signal?: AbortSignal): Promise<string> {
|
||||
/** 进程内流式 sha256(每块之间让出事件循环);onProgress 每块回调一次(fsproxy 心跳用) */
|
||||
export async function sha256File(p: string, signal?: AbortSignal, onProgress?: ProgressFn): Promise<string> {
|
||||
const h = createHash("sha256");
|
||||
let done = 0;
|
||||
const total = onProgress ? Number((await stat(p)).size) : 0;
|
||||
await pipeline(createReadStream(p, { highWaterMark: IO_CHUNK_BYTES }), async (src: AsyncIterable<Buffer>) => {
|
||||
for await (const c of src) {
|
||||
if (signal?.aborted) throw new FsError("ABORTED", "aborted", { path: p });
|
||||
h.update(c);
|
||||
done += c.length;
|
||||
onProgress?.(done, total);
|
||||
}
|
||||
});
|
||||
return h.digest("hex");
|
||||
@@ -126,15 +176,17 @@ async function sha256Subprocess(p: string, signal?: AbortSignal): Promise<string
|
||||
* 全量 sha256:优先外部 `sha256sum` 子进程(读盘 + 哈希完全不占本进程事件循环),
|
||||
* 不可用时回退进程内流式哈希。返回所用方式,调用方记进操作结果。
|
||||
*/
|
||||
export async function sha256Full(p: string, signal?: AbortSignal): Promise<Sha256Result> {
|
||||
if (!sha256sumUnavailable) {
|
||||
export async function sha256Full(p: string, signal?: AbortSignal, onProgress?: ProgressFn): Promise<Sha256Result> {
|
||||
// 需要进度(fsproxy 子进程内按心跳判挂死)时走流式:sha256sum 子进程看不到进度;
|
||||
// 在执行子进程里哈希本就不占主进程事件循环,实测两者吞吐相当(见 docs/porting/moviepilot-rust-transfer.md 基准)
|
||||
if (!sha256sumUnavailable && !onProgress) {
|
||||
const digest = await sha256Subprocess(p, signal);
|
||||
if (digest) {
|
||||
hashCounters.sha256sum += 1;
|
||||
return { digest, via: "sha256sum" };
|
||||
}
|
||||
}
|
||||
const digest = await sha256File(p, signal);
|
||||
const digest = await sha256File(p, signal, onProgress);
|
||||
hashCounters.stream += 1;
|
||||
return { digest, via: "stream" };
|
||||
}
|
||||
@@ -145,7 +197,12 @@ export function resetSha256Probe(): void {
|
||||
}
|
||||
|
||||
/** 抽样摘要:首/尾/等距 N 块 + 文件大小。抽样一致不等于完整一致性证明(REQ §8.2)。 */
|
||||
export async function sampleDigest(p: string, chunks = 8, chunkBytes = 1024 * 1024): Promise<string> {
|
||||
export async function sampleDigest(
|
||||
p: string,
|
||||
chunks = 8,
|
||||
chunkBytes = 1024 * 1024,
|
||||
onProgress?: ProgressFn,
|
||||
): Promise<string> {
|
||||
const fh = await open(p, "r");
|
||||
try {
|
||||
const { size } = await fh.stat();
|
||||
@@ -158,6 +215,7 @@ export async function sampleDigest(p: string, chunks = 8, chunkBytes = 1024 * 10
|
||||
for (const off of [...offsets].sort((a, b) => a - b)) {
|
||||
const { bytesRead } = await fh.read(buf, 0, chunkBytes, off);
|
||||
h.update(buf.subarray(0, bytesRead));
|
||||
onProgress?.(off + bytesRead, size);
|
||||
}
|
||||
return h.digest("hex");
|
||||
} finally {
|
||||
@@ -169,6 +227,8 @@ export function partialPathFor(dst: string, suffix = ".uhd-partial"): string {
|
||||
return join(dirname(dst), `.${basename(dst)}.${process.pid}.${randomBytes(3).toString("hex")}${suffix}`);
|
||||
}
|
||||
|
||||
const LINK_UNSUPPORTED = ["EPERM", "ENOTSUP", "EOPNOTSUPP", "ENOSYS", "EMLINK"];
|
||||
|
||||
/** no-clobber 提交:link(tmp,dst) 在目标存在时原子失败;文件系统不支持硬链接时退回 lstat+rename。 */
|
||||
export async function commitNoClobber(tmp: string, dst: string): Promise<void> {
|
||||
try {
|
||||
@@ -178,7 +238,7 @@ export async function commitNoClobber(tmp: string, dst: string): Promise<void> {
|
||||
} catch (e) {
|
||||
const code = errnoOf(e);
|
||||
if (code === "EEXIST") throw new FsError("EEXIST", `target exists: ${dst}`, { path: dst, cause: e });
|
||||
if (!["EPERM", "ENOTSUP", "EOPNOTSUPP", "ENOSYS", "EMLINK"].includes(code ?? "")) throw toFsError(e, dst);
|
||||
if (!LINK_UNSUPPORTED.includes(code ?? "")) throw toFsError(e, dst);
|
||||
}
|
||||
try {
|
||||
await lstat(dst);
|
||||
@@ -190,13 +250,93 @@ export async function commitNoClobber(tmp: string, dst: string): Promise<void> {
|
||||
await rename(tmp, dst);
|
||||
}
|
||||
|
||||
async function lstatOrNull(p: string): Promise<Awaited<ReturnType<typeof lstat>> | null> {
|
||||
try {
|
||||
return await lstat(p);
|
||||
} catch (e) {
|
||||
if (errnoOf(e) === "ENOENT") return null;
|
||||
// 失败 ≠ 不存在(REQ §2.1 原则 4):非 ENOENT 一律按错误上抛
|
||||
throw toFsError(e, p);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 复制并校验:写目标同目录临时文件 → fsync → 校验 → no-clobber 原子提交 → fsync 目录。
|
||||
* 任何失败都会清理临时文件并抛出 FsError;目标已存在抛 EEXIST,绝不覆盖。
|
||||
* 替换提交:旧目标先硬链接留底(同目录隐藏名,原子)→ 确认留底的就是计划时看到的旧文件 → rename(tmp,dst) 原子替换
|
||||
* (读者看不到空窗)→ fsync 目录 → 留底按策略移到回收目录或删除。文件系统不支持硬链接时退回
|
||||
* rename(dst,留底) + no-clobber 提交,提交失败把旧文件改回。处置失败不删旧文件,标 left 交给调用方记待处理。
|
||||
*/
|
||||
async function replaceCommit(
|
||||
tmp: string,
|
||||
dst: string,
|
||||
old: { ino: number | bigint; dev: number | bigint; size: number | bigint; mtimeMs: number | bigint },
|
||||
policy: ReplaceOldPolicy = { mode: "trash" },
|
||||
): Promise<ReplacedOld> {
|
||||
const keep = join(dirname(dst), `.${basename(dst)}.${process.pid}.${randomBytes(3).toString("hex")}.uhd-replaced`);
|
||||
const sameInode = (st: { ino: number | bigint; dev: number | bigint }) =>
|
||||
Number(st.ino) === Number(old.ino) && Number(st.dev) === Number(old.dev);
|
||||
let linked = true;
|
||||
try {
|
||||
await link(dst, keep);
|
||||
} catch (e) {
|
||||
if (!LINK_UNSUPPORTED.includes(errnoOf(e) ?? "")) throw toFsError(e, dst);
|
||||
linked = false;
|
||||
}
|
||||
if (linked) {
|
||||
try {
|
||||
if (!sameInode(await lstat(keep)))
|
||||
throw new FsError("EEXIST", `target changed before replace: ${dst}`, { path: dst });
|
||||
await rename(tmp, dst);
|
||||
} catch (e) {
|
||||
await unlink(keep).catch(() => undefined);
|
||||
throw toFsError(e, dst);
|
||||
}
|
||||
} else {
|
||||
const cur = await lstatOrNull(dst);
|
||||
if (!cur || !sameInode(cur)) throw new FsError("EEXIST", `target changed before replace: ${dst}`, { path: dst });
|
||||
await rename(dst, keep).catch((e) => {
|
||||
throw toFsError(e, dst);
|
||||
});
|
||||
try {
|
||||
await commitNoClobber(tmp, dst);
|
||||
} catch (e) {
|
||||
await rename(keep, dst).catch(() => undefined);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
await fsyncPath(dirname(dst));
|
||||
|
||||
const facts = { size: Number(old.size), mtimeMs: Number(old.mtimeMs) };
|
||||
try {
|
||||
if (policy.mode === "delete") {
|
||||
await unlink(keep);
|
||||
return { disposition: "deleted", ...facts };
|
||||
}
|
||||
const bin = join(
|
||||
policy.trashDir ?? join(dirname(dst), ".umt-trash"),
|
||||
`replaced-${new Date().toISOString().replace(/[:.]/g, "-")}-${randomBytes(3).toString("hex")}`,
|
||||
);
|
||||
await mkdir(bin, { recursive: true });
|
||||
const to = join(bin, basename(dst));
|
||||
await rename(keep, to);
|
||||
return { disposition: "trash", path: to, ...facts };
|
||||
} catch (e) {
|
||||
return { disposition: "left", path: keep, error: (e as Error)?.message ?? String(e), ...facts };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 复制并校验:写目标同目录临时文件 → fsync → 校验 → 提交 → fsync 目录。
|
||||
* 目标已存在按 onExisting:conflict(缺省)抛 EEXIST 绝不覆盖;skip 不写;replace 走 replaceCommit(不裸覆盖)。
|
||||
* 写前顺带清理本目录超过 24h 的残留 partial(对照 MP,只清本目录、不全库扫,失败吞掉)。
|
||||
* 任何失败都会清理临时文件并抛出 FsError。
|
||||
*
|
||||
* 故意只保留 atime/mtime,**不复制权限与属主**:目标目录的默认权限/继承 ACL 是媒体库的访问策略,
|
||||
* 用源文件权限覆盖会清掉继承 ACL(与 MP _copy_with_target_permissions 同理)。不要补 chmod/chown。
|
||||
*/
|
||||
export async function copyWithVerify(src: string, dst: string, opts: CopyOptions = {}): Promise<CopyReceipt> {
|
||||
const started = Date.now();
|
||||
const method = opts.hash ?? "size";
|
||||
const onExisting = opts.onExisting ?? "conflict";
|
||||
let srcStat: Awaited<ReturnType<typeof stat>>;
|
||||
try {
|
||||
srcStat = await stat(src);
|
||||
@@ -204,14 +344,31 @@ export async function copyWithVerify(src: string, dst: string, opts: CopyOptions
|
||||
throw toFsError(e, src);
|
||||
}
|
||||
if (!srcStat.isFile()) throw new FsError("IO", `source is not a regular file: ${src}`, { path: src });
|
||||
try {
|
||||
await lstat(dst);
|
||||
throw new FsError("EEXIST", `target exists: ${dst}`, { path: dst });
|
||||
} catch (e) {
|
||||
if (e instanceof FsError) throw e;
|
||||
if (errnoOf(e) !== "ENOENT") throw toFsError(e, dst);
|
||||
const existing = await lstatOrNull(dst);
|
||||
if (existing) {
|
||||
if (onExisting === "skip")
|
||||
return {
|
||||
src,
|
||||
dst,
|
||||
bytes: 0,
|
||||
verifiedBy: method,
|
||||
partialPath: "",
|
||||
durationMs: Date.now() - started,
|
||||
outcome: "skipped_existing",
|
||||
};
|
||||
// 只替换普通文件;目录/软链(含失效软链)一律按冲突
|
||||
if (onExisting !== "replace" || !existing.isFile())
|
||||
throw new FsError("EEXIST", `target exists: ${dst}`, { path: dst });
|
||||
}
|
||||
if (opts.createParents) await mkdir(dirname(dst), { recursive: true });
|
||||
const sweptPartials =
|
||||
opts.sweepPartialsOlderThanMs === null
|
||||
? []
|
||||
: await sweepStalePartials(
|
||||
dirname(dst),
|
||||
opts.sweepPartialsOlderThanMs ?? DEFAULT_PARTIAL_MAX_AGE_MS,
|
||||
opts.tmpSuffix,
|
||||
).catch(() => [] as string[]);
|
||||
|
||||
const tmp = partialPathFor(dst, opts.tmpSuffix);
|
||||
const hasher = method === "sha256" ? createHash("sha256") : null;
|
||||
@@ -248,22 +405,26 @@ export async function copyWithVerify(src: string, dst: string, opts: CopyOptions
|
||||
verifiedBy: method,
|
||||
partialPath: tmp,
|
||||
durationMs: 0,
|
||||
outcome: "committed",
|
||||
...(sweptPartials.length ? { sweptPartials } : {}),
|
||||
};
|
||||
if (hasher) {
|
||||
const expected = hasher.digest("hex");
|
||||
// 回读临时文件做全量比对:走子进程,不占本进程事件循环
|
||||
const actual = await sha256Full(tmp, opts.signal);
|
||||
// 回读临时文件做全量比对:主进程里走 sha256sum 子进程;fsproxy 子进程里走流式并上报心跳
|
||||
const actual = await sha256Full(tmp, opts.signal, opts.onVerifyProgress);
|
||||
if (actual.digest !== expected) throw new FsError("VERIFY_FAILED", "sha256 mismatch", { path: dst });
|
||||
receipt.sha256 = actual.digest;
|
||||
receipt.sha256Via = actual.via;
|
||||
} else if (method === "sample") {
|
||||
const a = await sampleDigest(src, opts.sampleChunks, opts.sampleChunkBytes);
|
||||
const b = await sampleDigest(tmp, opts.sampleChunks, opts.sampleChunkBytes);
|
||||
const a = await sampleDigest(src, opts.sampleChunks, opts.sampleChunkBytes, opts.onVerifyProgress);
|
||||
const b = await sampleDigest(tmp, opts.sampleChunks, opts.sampleChunkBytes, opts.onVerifyProgress);
|
||||
if (a !== b) throw new FsError("VERIFY_FAILED", "sample digest mismatch", { path: dst });
|
||||
receipt.sampleDigest = b;
|
||||
}
|
||||
opts.signal?.throwIfAborted();
|
||||
await utimes(tmp, srcStat.atime, srcStat.mtime);
|
||||
await commitNoClobber(tmp, dst);
|
||||
if (existing) receipt.replaced = await replaceCommit(tmp, dst, existing, opts.replaceOld);
|
||||
else await commitNoClobber(tmp, dst);
|
||||
await fsyncPath(dirname(dst));
|
||||
receipt.durationMs = Date.now() - started;
|
||||
return receipt;
|
||||
@@ -274,25 +435,70 @@ export async function copyWithVerify(src: string, dst: string, opts: CopyOptions
|
||||
}
|
||||
}
|
||||
|
||||
/** 两个文件全量 sha256 比对(大小先比);两边并行,各走 sha256Full。返回是否一致与所用方式 */
|
||||
/** 比对/哈希的 IO 选项:中止信号与进度(两文件的累计字节) */
|
||||
export interface MatchIo {
|
||||
signal?: AbortSignal;
|
||||
onProgress?: ProgressFn;
|
||||
}
|
||||
|
||||
const asIo = (x: AbortSignal | MatchIo | undefined): MatchIo =>
|
||||
x === undefined ? {} : x instanceof AbortSignal ? { signal: x } : x;
|
||||
|
||||
/**
|
||||
* 两个文件全量 sha256 比对(大小先比);两边并行,各走 sha256Full。返回是否一致与所用方式。
|
||||
* 读失败/超时**抛 FsError**(IO/TIMEOUT/ENOENT…),调用方不得把异常当成"内容不同"(EEXIST 误报)。
|
||||
*/
|
||||
export async function filesMatchSha256(
|
||||
a: string,
|
||||
b: string,
|
||||
signal?: AbortSignal,
|
||||
io?: AbortSignal | MatchIo,
|
||||
): Promise<{ match: boolean; via: Sha256Via | null }> {
|
||||
const [sa, sb] = await Promise.all([stat(a), stat(b)]);
|
||||
const { signal, onProgress } = asIo(io);
|
||||
let sa: Awaited<ReturnType<typeof stat>>;
|
||||
let sb: Awaited<ReturnType<typeof stat>>;
|
||||
try {
|
||||
[sa, sb] = await Promise.all([stat(a), stat(b)]);
|
||||
} catch (e) {
|
||||
throw toFsError(e);
|
||||
}
|
||||
if (sa.size !== sb.size) return { match: false, via: null };
|
||||
const [ha, hb] = await Promise.all([sha256Full(a, signal), sha256Full(b, signal)]);
|
||||
return { match: ha.digest === hb.digest, via: ha.via === hb.via ? ha.via : "stream" };
|
||||
const total = Number(sa.size) * 2;
|
||||
const done = [0, 0];
|
||||
const prog = (i: 0 | 1) =>
|
||||
onProgress
|
||||
? (n: number) => {
|
||||
done[i] = n;
|
||||
onProgress(done[0]! + done[1]!, total);
|
||||
}
|
||||
: undefined;
|
||||
try {
|
||||
const [ha, hb] = await Promise.all([sha256Full(a, signal, prog(0)), sha256Full(b, signal, prog(1))]);
|
||||
return { match: ha.digest === hb.digest, via: ha.via === hb.via ? ha.via : "stream" };
|
||||
} catch (e) {
|
||||
throw toFsError(e);
|
||||
}
|
||||
}
|
||||
|
||||
/** 按指定方式比对两个文件是否一致(size 恒比对)。 */
|
||||
export async function filesMatch(a: string, b: string, method: VerifyMethod = "size"): Promise<boolean> {
|
||||
if (method === "sha256") return (await filesMatchSha256(a, b)).match;
|
||||
const [sa, sb] = await Promise.all([stat(a), stat(b)]);
|
||||
if (sa.size !== sb.size) return false;
|
||||
if (method === "sample") return (await sampleDigest(a)) === (await sampleDigest(b));
|
||||
return true;
|
||||
/** 按指定方式比对两个文件是否一致(size 恒比对)。读失败抛 FsError。 */
|
||||
export async function filesMatch(
|
||||
a: string,
|
||||
b: string,
|
||||
method: VerifyMethod = "size",
|
||||
io: MatchIo = {},
|
||||
): Promise<boolean> {
|
||||
if (method === "sha256") return (await filesMatchSha256(a, b, io)).match;
|
||||
try {
|
||||
const [sa, sb] = await Promise.all([stat(a), stat(b)]);
|
||||
if (sa.size !== sb.size) return false;
|
||||
if (method === "sample")
|
||||
return (
|
||||
(await sampleDigest(a, undefined, undefined, io.onProgress)) ===
|
||||
(await sampleDigest(b, undefined, undefined, io.onProgress))
|
||||
);
|
||||
return true;
|
||||
} catch (e) {
|
||||
throw toFsError(e);
|
||||
}
|
||||
}
|
||||
|
||||
/** 清理目录下超过 olderThanMs 的残留临时文件,返回被删除的路径。 */
|
||||
@@ -304,8 +510,13 @@ export async function sweepStalePartials(dir: string, olderThanMs: number, suffi
|
||||
const p = join(dir, name);
|
||||
const st = await lstat(p).catch(() => null);
|
||||
if (st?.isFile() && now - st.mtimeMs > olderThanMs) {
|
||||
await unlink(p).catch(() => undefined);
|
||||
removed.push(p);
|
||||
if (
|
||||
await unlink(p).then(
|
||||
() => true,
|
||||
() => false,
|
||||
)
|
||||
)
|
||||
removed.push(p);
|
||||
}
|
||||
}
|
||||
return removed;
|
||||
|
||||
@@ -8,8 +8,37 @@ export type FsErrorCode =
|
||||
| "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(
|
||||
@@ -33,9 +62,11 @@ export function toFsError(e: unknown, path?: string): FsError {
|
||||
? code
|
||||
: code === "EACCES" || code === "EPERM"
|
||||
? "EACCES"
|
||||
: (e as Error)?.name === "AbortError"
|
||||
? "ABORTED"
|
||||
: "IO";
|
||||
: 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! } : {}),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { readdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { createTempTree, type TempTree } from "@umt/testkit";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { FsError, type FsProgress, FsProxy, fsErrorClass, sweepStalePartials, toFsError } from "./index.ts";
|
||||
|
||||
// fsproxy:单个文件操作在执行子进程里跑,按进度心跳判挂死 → SIGTERM → SIGKILL → FsError('TIMEOUT')。
|
||||
|
||||
const trees: TempTree[] = [];
|
||||
async function tree(...a: Parameters<typeof createTempTree>) {
|
||||
const t = await createTempTree(...a);
|
||||
trees.push(t);
|
||||
return t;
|
||||
}
|
||||
afterEach(async () => {
|
||||
await Promise.all(trees.splice(0).map((t) => t.cleanup()));
|
||||
});
|
||||
|
||||
const MiB = 1024 * 1024;
|
||||
const fast = { hangTimeoutMs: 800, quickTimeoutMs: 800, progressIntervalMs: 20, killGraceMs: 300 };
|
||||
|
||||
describe("FsProxy(子进程)", () => {
|
||||
it("正常复制:结果与进程内一致,进度回调有值(复制 + 校验两阶段)", async () => {
|
||||
const t = await tree({ "src/a.mkv": 6 * MiB + 5, "dst/": null });
|
||||
const px = new FsProxy({ mode: "subprocess", progressIntervalMs: 1 });
|
||||
const seen: FsProgress[] = [];
|
||||
const r = await px.copy(
|
||||
t.path("src/a.mkv"),
|
||||
t.path("dst/a.mkv"),
|
||||
{ hash: "sha256" },
|
||||
{ onProgress: (p) => seen.push(p) },
|
||||
);
|
||||
expect(px.mode).toBe("subprocess");
|
||||
expect(r.outcome).toBe("committed");
|
||||
expect(r.bytes).toBe(6 * MiB + 5);
|
||||
expect(r.sha256).toMatch(/^[0-9a-f]{64}$/);
|
||||
expect((await readFile(t.path("dst/a.mkv"))).equals(await readFile(t.path("src/a.mkv")))).toBe(true);
|
||||
const copy = seen.filter((p) => p.phase === "copy");
|
||||
expect(copy.length).toBeGreaterThan(0);
|
||||
expect(copy.at(-1)!.bytes).toBe(6 * MiB + 5);
|
||||
expect(seen.some((p) => p.phase === "verify" && p.bytes > 0)).toBe(true);
|
||||
expect(px.stats().spawned).toBe(1);
|
||||
}, 30_000);
|
||||
|
||||
it("worker 报的 FsError 原样还原(EEXIST / ENOENT)", async () => {
|
||||
const t = await tree({ "a.mkv": "new", "b.mkv": "old" });
|
||||
const px = new FsProxy({ mode: "subprocess" });
|
||||
const e = await px.copy(t.path("a.mkv"), t.path("b.mkv")).catch((x) => x);
|
||||
expect(e).toBeInstanceOf(FsError);
|
||||
expect(e.code).toBe("EEXIST");
|
||||
expect((await px.moveSafe(t.path("nope"), t.path("c")).catch((x) => x)).code).toBe("ENOENT");
|
||||
expect(await px.statSafe(t.path("nope"))).toEqual({ kind: "missing" });
|
||||
}, 30_000);
|
||||
|
||||
it("复制中途挂死(屏蔽 SIGTERM)→ 进度停滞超时 → SIGKILL → TIMEOUT;不留最终名半成品,partial 可被清理", async () => {
|
||||
const t = await tree({ "src/a.mkv": 8 * MiB, "dst/": null });
|
||||
const px = new FsProxy({ mode: "subprocess", ...fast });
|
||||
const seen: number[] = [];
|
||||
const t0 = Date.now();
|
||||
const e = await px
|
||||
.copy(
|
||||
t.path("src/a.mkv"),
|
||||
t.path("dst/a.mkv"),
|
||||
{},
|
||||
{
|
||||
onProgress: (p) => seen.push(p.bytes),
|
||||
_test: { hangAfterBytes: 2 * MiB, ignoreTerm: true },
|
||||
},
|
||||
)
|
||||
.catch((x) => x);
|
||||
expect(e).toBeInstanceOf(FsError);
|
||||
expect(e.code).toBe("TIMEOUT");
|
||||
expect(fsErrorClass(e.code)).toBe("retry");
|
||||
expect(Date.now() - t0).toBeGreaterThanOrEqual(800);
|
||||
expect(seen.some((b) => b > 0)).toBe(true);
|
||||
expect(px.stats()).toMatchObject({ timeouts: 1, killed: 1 });
|
||||
expect(existsSync(t.path("dst/a.mkv"))).toBe(false);
|
||||
// 被 SIGKILL 的 worker 来不及清 partial:留给下次写前清理
|
||||
const left = (await readdir(t.path("dst"))).filter((n) => n.endsWith(".uhd-partial"));
|
||||
expect(left.length).toBe(1);
|
||||
expect(await sweepStalePartials(t.path("dst"), -1)).toHaveLength(1);
|
||||
}, 30_000);
|
||||
|
||||
it("真实挂死的系统调用:哈希 FIFO(open 阻塞)→ TIMEOUT,SIGTERM 即可回收", async () => {
|
||||
const t = await tree({});
|
||||
const fifo = t.path("hang.fifo");
|
||||
execFileSync("mkfifo", [fifo]);
|
||||
const px = new FsProxy({ mode: "subprocess", ...fast });
|
||||
const e = await px.sha256(fifo).catch((x) => x);
|
||||
expect(e.code).toBe("TIMEOUT");
|
||||
expect(px.stats().timeouts).toBe(1);
|
||||
}, 30_000);
|
||||
|
||||
it("快操作挂死:stat → error(TIMEOUT) 而非 missing;checkRoot → offline(timeout)", async () => {
|
||||
const t = await tree({ "a.mkv": "x" }, { sentinel: true });
|
||||
const px = new FsProxy({ mode: "subprocess", ...fast });
|
||||
const st = await px.statSafe(t.path("a.mkv"), {}, { _test: { hangAfterBytes: 0 } });
|
||||
expect(st).toMatchObject({ kind: "error", code: "TIMEOUT" });
|
||||
const h = await px.checkRoot(t.root, undefined, { _test: { hangAfterBytes: 0, ignoreTerm: true } });
|
||||
expect(h).toEqual({ kind: "offline", reason: "timeout", code: "TIMEOUT" });
|
||||
expect(await px.checkRoot(t.root)).toMatchObject({ kind: "online" });
|
||||
}, 30_000);
|
||||
|
||||
it("取消:AbortSignal → 终止 worker,ABORTED", async () => {
|
||||
const t = await tree({ "src/a.mkv": 4 * MiB, "dst/": null });
|
||||
const px = new FsProxy({ mode: "subprocess", ...fast, hangTimeoutMs: 60_000 });
|
||||
const ac = new AbortController();
|
||||
const p = px.copy(
|
||||
t.path("src/a.mkv"),
|
||||
t.path("dst/a.mkv"),
|
||||
{},
|
||||
{
|
||||
signal: ac.signal,
|
||||
_test: { hangAfterBytes: MiB },
|
||||
onProgress: (x) => x.bytes > 0 && ac.abort(),
|
||||
},
|
||||
);
|
||||
const e = await p.catch((x) => x);
|
||||
expect(e.code).toBe("ABORTED");
|
||||
expect(existsSync(t.path("dst/a.mkv"))).toBe(false);
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
describe("FsProxy(in-process 回退)", () => {
|
||||
it("正常复制有进度;FIFO 挂死 → TIMEOUT(看门狗),不等卡住的调用返回", async () => {
|
||||
const t = await tree({ "src/a.mkv": 3 * MiB, "dst/": null });
|
||||
const px = new FsProxy({ mode: "inprocess", ...fast });
|
||||
const seen: FsProgress[] = [];
|
||||
const r = await px.copy(t.path("src/a.mkv"), t.path("dst/a.mkv"), {}, { onProgress: (p) => seen.push(p) });
|
||||
expect(r.outcome).toBe("committed");
|
||||
expect(seen.at(-1)?.bytes).toBe(3 * MiB);
|
||||
|
||||
const fifo = t.path("hang.fifo");
|
||||
execFileSync("mkfifo", [fifo]);
|
||||
const e = await px.sha256(fifo).catch((x) => x);
|
||||
expect(e.code).toBe("TIMEOUT");
|
||||
// 解开卡住的 open,别让测试进程留着阻塞线程
|
||||
await writeFile(fifo, "x");
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
describe("错误分类", () => {
|
||||
it("TIMEOUT 进 FsErrorCode 与处置类别;ETIMEDOUT 映射为 TIMEOUT", () => {
|
||||
const e = Object.assign(new Error("timed out"), { code: "ETIMEDOUT" });
|
||||
expect(toFsError(e).code).toBe("TIMEOUT");
|
||||
expect(fsErrorClass("TIMEOUT")).toBe("retry");
|
||||
expect(fsErrorClass("IO")).toBe("retry");
|
||||
expect(fsErrorClass("ENOSPC")).toBe("attention");
|
||||
expect(fsErrorClass("EACCES")).toBe("attention");
|
||||
expect(fsErrorClass("EEXIST")).toBe("manual");
|
||||
expect(fsErrorClass("VERIFY_FAILED")).toBe("manual");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,441 @@
|
||||
// 挂死隔离(对标 MoviePilot app/adapters/system/fsproxy.py,e6820a8,只借语义):
|
||||
// 单个文件操作放进一次性执行子进程(fsworker.ts),父进程按进度心跳判挂死——数据类操作(复制/跨卷搬运/哈希)
|
||||
// 在 hangTimeoutMs 内没有任何前进(字节或阶段)即判挂死,快操作(stat/checkRoot/同卷 move/清 partial)按总时长
|
||||
// quickTimeoutMs 判超时;超时先 SIGTERM(worker 中止操作并清掉自己的 partial),killGraceMs 后 SIGKILL,
|
||||
// 统一转成 FsError('TIMEOUT')——普通可重试 IO 错误,交给任务层退避重试。卡在 D 状态的子进程被 unref,不拖住本进程。
|
||||
// stat/checkRoot 超时返回 error/offline(timeout),**不**当成文件缺失(REQ §2.1 原则 4)。
|
||||
//
|
||||
// 与 MP 的差异:MP 是常驻 worker + 一把锁串行;我们每个操作一个子进程(~50 ms 启动,入库按文件计可忽略),
|
||||
// 天然并发、互不拖累,也没有"上一个请求挂死把 worker 占住"的问题。
|
||||
// 无法起子进程时(deno compile 出的二进制:execPath 不是 deno;或缺 --allow-run)回退 in-process:
|
||||
// 同样的心跳看门狗,超时立即以 TIMEOUT 失败并中止操作,但卡死的系统调用占住的线程无法回收(见 README)。
|
||||
import { basename } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { CopyReceipt, Sha256Result } from "./copy.ts";
|
||||
import { FsError, type FsErrorCode } from "./errors.ts";
|
||||
import {
|
||||
DATA_OPS,
|
||||
type FsOpRequest,
|
||||
type FsProgress,
|
||||
type FsTestHook,
|
||||
runFsOp,
|
||||
type WireCopyOptions,
|
||||
type WireCrossMoveOptions,
|
||||
} from "./fsops.ts";
|
||||
import type { CrossMoveResult, MoveResult } from "./move.ts";
|
||||
import type { RootHealth, StatResult } from "./stat.ts";
|
||||
|
||||
export type { FsProgress } from "./fsops.ts";
|
||||
|
||||
export interface FsProxyConfig {
|
||||
/** auto:能起 deno 子进程就用子进程,否则 in-process;subprocess 强制子进程(起不来报错) */
|
||||
mode?: "auto" | "subprocess" | "inprocess";
|
||||
/** 数据类操作:进度停滞多久判挂死(缺省 120 s,同 MP FS_PROXY_STALL_TIMEOUT) */
|
||||
hangTimeoutMs?: number;
|
||||
/** 快操作总超时(缺省 30 s,同 MP) */
|
||||
quickTimeoutMs?: number;
|
||||
/** worker 心跳上报间隔(缺省 1 s) */
|
||||
progressIntervalMs?: number;
|
||||
/** SIGTERM 到 SIGKILL 的宽限(缺省 2 s) */
|
||||
killGraceMs?: number;
|
||||
/** 自定义 worker 启动命令(编译二进制:[execPath, "--role", "fsworker"]);请求走 stdin */
|
||||
workerCommand?: { cmd: string; args: string[] };
|
||||
}
|
||||
|
||||
export interface FsCallOptions {
|
||||
signal?: AbortSignal;
|
||||
onProgress?: (p: FsProgress) => void;
|
||||
/** 仅测试:见 FsTestHook(只在子进程模式生效) */
|
||||
_test?: FsTestHook;
|
||||
}
|
||||
|
||||
export interface FsProxyStats {
|
||||
mode: "subprocess" | "inprocess";
|
||||
spawned: number;
|
||||
timeouts: number;
|
||||
killed: number;
|
||||
fallbackReason: string | null;
|
||||
}
|
||||
|
||||
type DenoChild = {
|
||||
stdin: WritableStream<Uint8Array>;
|
||||
stdout: ReadableStream<Uint8Array>;
|
||||
stderr: ReadableStream<Uint8Array>;
|
||||
status: Promise<{ success: boolean; code: number; signal: string | null }>;
|
||||
kill(sig?: string): void;
|
||||
unref(): void;
|
||||
};
|
||||
|
||||
const WORKER_PATH = (() => {
|
||||
try {
|
||||
const u = new URL("./fsworker.ts", import.meta.url);
|
||||
return u.protocol === "file:" ? fileURLToPath(u) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
function denoCliAvailable(): boolean {
|
||||
const D = (globalThis as { Deno?: { execPath?: () => string; Command?: unknown } }).Deno;
|
||||
if (!D?.Command || !D.execPath || !WORKER_PATH) return false;
|
||||
try {
|
||||
return /^deno(\.exe)?$/.test(basename(D.execPath()));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const timeoutError = (op: string, why: string, path?: string) =>
|
||||
new FsError("TIMEOUT", `fs ${op} 挂死:${why}`, path ? { path } : {});
|
||||
|
||||
function pathOf(req: FsOpRequest): string | undefined {
|
||||
switch (req.op) {
|
||||
case "copy":
|
||||
case "crossMove":
|
||||
case "move":
|
||||
return req.dst;
|
||||
case "match":
|
||||
return req.b;
|
||||
case "sha256":
|
||||
case "stat":
|
||||
case "unlink":
|
||||
return req.path;
|
||||
case "checkRoot":
|
||||
return req.root;
|
||||
case "sweep":
|
||||
return req.dir;
|
||||
}
|
||||
}
|
||||
|
||||
export class FsProxy {
|
||||
readonly cfg: Required<Omit<FsProxyConfig, "workerCommand" | "mode">> & Pick<FsProxyConfig, "workerCommand">;
|
||||
#mode: "subprocess" | "inprocess";
|
||||
#forced: boolean;
|
||||
#stats = { spawned: 0, timeouts: 0, killed: 0 };
|
||||
#fallbackReason: string | null = null;
|
||||
|
||||
constructor(cfg: FsProxyConfig = {}) {
|
||||
this.cfg = {
|
||||
hangTimeoutMs: cfg.hangTimeoutMs ?? 120_000,
|
||||
quickTimeoutMs: cfg.quickTimeoutMs ?? 30_000,
|
||||
progressIntervalMs: cfg.progressIntervalMs ?? 1000,
|
||||
killGraceMs: cfg.killGraceMs ?? 2000,
|
||||
...(cfg.workerCommand ? { workerCommand: cfg.workerCommand } : {}),
|
||||
};
|
||||
const mode = cfg.mode ?? "auto";
|
||||
this.#forced = mode === "subprocess";
|
||||
if (mode === "inprocess") this.#mode = "inprocess";
|
||||
else if (cfg.workerCommand || denoCliAvailable()) this.#mode = "subprocess";
|
||||
else {
|
||||
if (mode === "subprocess") throw new Error("fsproxy: 当前运行时无法启动 deno 子进程,且未提供 workerCommand");
|
||||
this.#mode = "inprocess";
|
||||
this.#fallbackReason = "no_deno_cli";
|
||||
}
|
||||
}
|
||||
|
||||
get mode() {
|
||||
return this.#mode;
|
||||
}
|
||||
|
||||
stats(): FsProxyStats {
|
||||
return { mode: this.#mode, ...this.#stats, fallbackReason: this.#fallbackReason };
|
||||
}
|
||||
|
||||
// ── 业务操作 ──
|
||||
|
||||
copy(src: string, dst: string, opts: WireCopyOptions = {}, call: FsCallOptions = {}): Promise<CopyReceipt> {
|
||||
return this.run({ op: "copy", src, dst, opts }, call) as Promise<CopyReceipt>;
|
||||
}
|
||||
crossDeviceMove(
|
||||
src: string,
|
||||
dst: string,
|
||||
opts: WireCrossMoveOptions = {},
|
||||
call: FsCallOptions = {},
|
||||
): Promise<CrossMoveResult> {
|
||||
return this.run({ op: "crossMove", src, dst, opts }, call) as Promise<CrossMoveResult>;
|
||||
}
|
||||
moveSafe(src: string, dst: string, call: FsCallOptions = {}): Promise<MoveResult> {
|
||||
return this.run({ op: "move", src, dst }, call) as Promise<MoveResult>;
|
||||
}
|
||||
/** 读失败/超时抛 FsError(IO/TIMEOUT),不会返回 match=false 冒充"内容不同" */
|
||||
filesMatchSha256(a: string, b: string, call: FsCallOptions = {}): Promise<{ match: boolean; via: string | null }> {
|
||||
return this.run({ op: "match", a, b }, call) as Promise<{ match: boolean; via: string | null }>;
|
||||
}
|
||||
sha256(path: string, call: FsCallOptions = {}): Promise<Sha256Result> {
|
||||
return this.run({ op: "sha256", path }, call) as Promise<Sha256Result>;
|
||||
}
|
||||
sweepStalePartials(dir: string, olderThanMs: number, suffix?: string, call: FsCallOptions = {}): Promise<string[]> {
|
||||
return this.run({ op: "sweep", dir, olderThanMs, ...(suffix ? { suffix } : {}) }, call) as Promise<string[]>;
|
||||
}
|
||||
/** 超时 → { kind: "error", code: "TIMEOUT" },不当成 missing */
|
||||
async statSafe(
|
||||
path: string,
|
||||
o: { root?: string; sentinel?: string } = {},
|
||||
call: FsCallOptions = {},
|
||||
): Promise<StatResult> {
|
||||
try {
|
||||
return (await this.run({ op: "stat", path, ...o }, call)) as StatResult;
|
||||
} catch (e) {
|
||||
if (e instanceof FsError && e.code !== "ABORTED") return { kind: "error", code: e.code, message: e.message };
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
/** 超时 → offline(timeout):挂载抖动/挂死按根离线处理,绝不据此判文件缺失 */
|
||||
async checkRoot(root: string, sentinel?: string, call: FsCallOptions = {}): Promise<RootHealth> {
|
||||
try {
|
||||
return (await this.run({ op: "checkRoot", root, ...(sentinel ? { sentinel } : {}) }, call)) as RootHealth;
|
||||
} catch (e) {
|
||||
if (e instanceof FsError && e.code === "TIMEOUT") return { kind: "offline", reason: "timeout", code: "TIMEOUT" };
|
||||
if (e instanceof FsError && e.code !== "ABORTED") return { kind: "offline", reason: "error", code: e.code };
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 执行 ──
|
||||
|
||||
async run(req: FsOpRequest, call: FsCallOptions = {}): Promise<unknown> {
|
||||
call.signal?.throwIfAborted();
|
||||
if (this.#mode === "subprocess") {
|
||||
try {
|
||||
return await this.#runSubprocess(req, call);
|
||||
} catch (e) {
|
||||
if (!(e instanceof SpawnError) || this.#forced) throw e instanceof SpawnError ? e.cause : e;
|
||||
this.#mode = "inprocess";
|
||||
this.#fallbackReason = e.message;
|
||||
}
|
||||
}
|
||||
return this.#runInProcess(req, call);
|
||||
}
|
||||
|
||||
#limitFor(req: FsOpRequest) {
|
||||
return DATA_OPS.has(req.op)
|
||||
? { ms: this.cfg.hangTimeoutMs, stall: true, why: `进度停滞超过 ${this.cfg.hangTimeoutMs} ms` }
|
||||
: { ms: this.cfg.quickTimeoutMs, stall: false, why: `超过 ${this.cfg.quickTimeoutMs} ms 未完成` };
|
||||
}
|
||||
|
||||
async #runInProcess(req: FsOpRequest, call: FsCallOptions): Promise<unknown> {
|
||||
const { _test: _t, ...clean } = req;
|
||||
const limit = this.#limitFor(req);
|
||||
const ac = new AbortController();
|
||||
const onAbort = () => ac.abort();
|
||||
call.signal?.addEventListener("abort", onAbort, { once: true });
|
||||
let last = Date.now();
|
||||
let lastKey = "";
|
||||
let timer: ReturnType<typeof setInterval> | undefined;
|
||||
const stalled = new Promise<never>((_, reject) => {
|
||||
timer = setInterval(
|
||||
() => {
|
||||
if (Date.now() - last > limit.ms) {
|
||||
this.#stats.timeouts += 1;
|
||||
ac.abort();
|
||||
reject(timeoutError(req.op, limit.why, pathOf(req)));
|
||||
}
|
||||
},
|
||||
Math.max(10, Math.min(1000, limit.ms / 4)),
|
||||
);
|
||||
});
|
||||
const work = runFsOp(clean as FsOpRequest, {
|
||||
signal: ac.signal,
|
||||
progress: (p) => {
|
||||
const key = `${p.phase}:${p.bytes}`;
|
||||
if (limit.stall && key !== lastKey) {
|
||||
lastKey = key;
|
||||
last = Date.now();
|
||||
}
|
||||
call.onProgress?.(p);
|
||||
},
|
||||
});
|
||||
work.catch(() => undefined);
|
||||
try {
|
||||
return await Promise.race([work, stalled]);
|
||||
} catch (e) {
|
||||
if (call.signal?.aborted && !(e instanceof FsError && e.code === "TIMEOUT"))
|
||||
throw new FsError("ABORTED", "aborted", { cause: e });
|
||||
throw e;
|
||||
} finally {
|
||||
clearInterval(timer);
|
||||
call.signal?.removeEventListener("abort", onAbort);
|
||||
}
|
||||
}
|
||||
|
||||
async #runSubprocess(req: FsOpRequest, call: FsCallOptions): Promise<unknown> {
|
||||
const D = (globalThis as unknown as { Deno: DenoLike }).Deno;
|
||||
const cmd = this.cfg.workerCommand ?? {
|
||||
cmd: D.execPath(),
|
||||
args: [
|
||||
"run",
|
||||
"--no-config",
|
||||
"--quiet",
|
||||
"--no-prompt",
|
||||
// Deno 对 /dev、/proc 下的路径(如 /dev/shm 上的库根)要求 --allow-all;worker 只执行本包固定的操作表
|
||||
"--allow-all",
|
||||
WORKER_PATH!,
|
||||
],
|
||||
};
|
||||
let child: DenoChild;
|
||||
try {
|
||||
child = new D.Command(cmd.cmd, {
|
||||
args: cmd.args,
|
||||
stdin: "piped",
|
||||
stdout: "piped",
|
||||
stderr: "piped",
|
||||
env: { UMT_FS_PROGRESS_INTERVAL_MS: String(this.cfg.progressIntervalMs) },
|
||||
}).spawn();
|
||||
} catch (e) {
|
||||
throw new SpawnError(`spawn_failed:${(e as Error)?.name ?? "Error"}`, e);
|
||||
}
|
||||
this.#stats.spawned += 1;
|
||||
const limit = this.#limitFor(req);
|
||||
const w = child.stdin.getWriter();
|
||||
await w.write(new TextEncoder().encode(JSON.stringify(call._test ? { ...req, _test: call._test } : req)));
|
||||
await w.close().catch(() => undefined);
|
||||
|
||||
let stderr = "";
|
||||
const errPump = (async () => {
|
||||
const dec = new TextDecoder();
|
||||
try {
|
||||
for await (const c of child.stderr) stderr = (stderr + dec.decode(c, { stream: true })).slice(-2000);
|
||||
} catch {
|
||||
// 被 kill 时流会出错
|
||||
}
|
||||
})();
|
||||
|
||||
return await new Promise<unknown>((resolve, reject) => {
|
||||
let settled = false;
|
||||
let last = Date.now();
|
||||
let lastKey = "";
|
||||
let killTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
/** 已判超时/已取消:之后 worker 自己报的结果(如 SIGTERM 后的 ABORTED)一律以此为准 */
|
||||
let terminating: FsError | null = null;
|
||||
const finish = (fn: () => void) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearInterval(watch);
|
||||
call.signal?.removeEventListener("abort", onAbort);
|
||||
fn();
|
||||
};
|
||||
const terminate = (err: FsError) => {
|
||||
if (settled || terminating) return;
|
||||
terminating = err;
|
||||
try {
|
||||
child.kill("SIGTERM");
|
||||
} catch {
|
||||
// 已退出
|
||||
}
|
||||
clearInterval(watch);
|
||||
killTimer = setTimeout(() => {
|
||||
try {
|
||||
child.kill("SIGKILL");
|
||||
this.#stats.killed += 1;
|
||||
} catch {
|
||||
// SIGTERM 后已退出
|
||||
}
|
||||
// D 状态的子进程可能连 SIGKILL 都要等系统调用返回:不等它,unref 后直接报错
|
||||
child.unref();
|
||||
finish(() => reject(err));
|
||||
}, this.cfg.killGraceMs);
|
||||
};
|
||||
const onAbort = () =>
|
||||
terminate(new FsError("ABORTED", "aborted", { ...(pathOf(req) ? { path: pathOf(req) } : {}) }));
|
||||
call.signal?.addEventListener("abort", onAbort, { once: true });
|
||||
const watch = setInterval(
|
||||
() => {
|
||||
if (Date.now() - last > limit.ms) {
|
||||
this.#stats.timeouts += 1;
|
||||
terminate(timeoutError(req.op, limit.why, pathOf(req)));
|
||||
}
|
||||
},
|
||||
Math.max(10, Math.min(1000, limit.ms / 4)),
|
||||
);
|
||||
|
||||
(async () => {
|
||||
const dec = new TextDecoder();
|
||||
let buf = "";
|
||||
let final: WorkerFinal | null = null;
|
||||
try {
|
||||
for await (const c of child.stdout) {
|
||||
buf += dec.decode(c, { stream: true });
|
||||
let i = buf.indexOf("\n");
|
||||
while (i >= 0) {
|
||||
const line = buf.slice(0, i);
|
||||
buf = buf.slice(i + 1);
|
||||
i = buf.indexOf("\n");
|
||||
if (!line.trim()) continue;
|
||||
const msg = JSON.parse(line) as WorkerFinal & FsProgress;
|
||||
if (msg.t === "hb") {
|
||||
const key = `${msg.phase}:${msg.bytes}`;
|
||||
if (!limit.stall || key !== lastKey) {
|
||||
lastKey = key;
|
||||
if (limit.stall) last = Date.now();
|
||||
}
|
||||
call.onProgress?.({ phase: msg.phase, bytes: msg.bytes, total: msg.total });
|
||||
} else final = msg;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// kill 时 stdout 断开
|
||||
}
|
||||
const status = await child.status;
|
||||
await errPump;
|
||||
if (terminating) {
|
||||
// SIGTERM 生效(worker 清理后退出)就不必等到宽限期满
|
||||
clearTimeout(killTimer);
|
||||
const err = terminating;
|
||||
return finish(() => reject(err));
|
||||
}
|
||||
if (final?.t === "ok") return finish(() => resolve(final!.result));
|
||||
if (final?.t === "err")
|
||||
return finish(() =>
|
||||
reject(
|
||||
new FsError(final!.code ?? "IO", final!.message ?? "fs worker error", {
|
||||
...(final!.path ? { path: final!.path } : {}),
|
||||
}),
|
||||
),
|
||||
);
|
||||
finish(() =>
|
||||
reject(
|
||||
new FsError("IO", `fs worker 异常退出(code=${status.code} signal=${status.signal}):${stderr.trim()}`, {
|
||||
...(pathOf(req) ? { path: pathOf(req) } : {}),
|
||||
}),
|
||||
),
|
||||
);
|
||||
})();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
type WorkerFinal = { t: string; result?: unknown; code?: FsErrorCode; message?: string; path?: string };
|
||||
|
||||
class SpawnError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
override readonly cause: unknown,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
type DenoLike = {
|
||||
execPath(): string;
|
||||
Command: new (
|
||||
cmd: string,
|
||||
opts: {
|
||||
args: string[];
|
||||
stdin: "piped";
|
||||
stdout: "piped";
|
||||
stderr: "piped";
|
||||
env?: Record<string, string>;
|
||||
},
|
||||
) => { spawn(): DenoChild };
|
||||
};
|
||||
|
||||
let shared: FsProxy | null = null;
|
||||
/** 进程级缺省代理(配置由 server 启动时 configureFsProxy 注入;未配置时用缺省阈值) */
|
||||
export function fsProxy(): FsProxy {
|
||||
shared ??= new FsProxy();
|
||||
return shared;
|
||||
}
|
||||
export function configureFsProxy(cfg: FsProxyConfig): FsProxy {
|
||||
shared = new FsProxy(cfg);
|
||||
return shared;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// fsproxy 执行子进程:从 stdin 读一条 JSON 请求,执行单个文件操作,stdout 逐行 JSON 上报
|
||||
// {t:"hb",phase,bytes,total}(按 progressIntervalMs 节流,阶段切换立即报)/ {t:"ok",result} / {t:"err",code,message}。
|
||||
// SIGTERM → 中止当前操作(copy 会清掉自己的 partial)后退出;挂死在系统调用里时由父进程 SIGKILL 回收。
|
||||
// 只依赖 node 内置模块,`deno run --no-config` 毫秒级启动。编译二进制可在入口按 --role fsworker 调 fsWorkerMain()。
|
||||
import { toFsError } from "./errors.ts";
|
||||
import { type FsOpRequest, type FsProgress, runFsOp } from "./fsops.ts";
|
||||
|
||||
const enc = new TextEncoder();
|
||||
|
||||
function send(obj: unknown): void {
|
||||
try {
|
||||
Deno.stdout.writeSync(enc.encode(`${JSON.stringify(obj)}\n`));
|
||||
} catch {
|
||||
// 父进程已退出:无处可报
|
||||
}
|
||||
}
|
||||
|
||||
/** 阻塞 JS 线程(测试用,模拟卡死的系统调用:信号监听器也不会运行) */
|
||||
function blockForever(): never {
|
||||
const a = new Int32Array(new SharedArrayBuffer(4));
|
||||
for (;;) Atomics.wait(a, 0, 0);
|
||||
}
|
||||
|
||||
export async function fsWorkerMain(progressIntervalMs = Number(Deno.env.get("UMT_FS_PROGRESS_INTERVAL_MS") ?? 1000)) {
|
||||
const req = JSON.parse(await new Response(Deno.stdin.readable).text()) as FsOpRequest;
|
||||
const ac = new AbortController();
|
||||
Deno.addSignalListener("SIGTERM", () => {
|
||||
if (req._test?.ignoreTerm) return;
|
||||
ac.abort();
|
||||
// 操作卡在系统调用里时 abort 解不开:给清理一点时间后直接退出
|
||||
setTimeout(() => Deno.exit(143), 500);
|
||||
});
|
||||
let lastAt = 0;
|
||||
let lastPhase = "";
|
||||
const progress = (p: FsProgress) => {
|
||||
const hang = req._test?.hangAfterBytes;
|
||||
const now = Date.now();
|
||||
if (p.phase !== lastPhase || now - lastAt >= progressIntervalMs || (hang !== undefined && p.bytes >= hang)) {
|
||||
lastAt = now;
|
||||
lastPhase = p.phase;
|
||||
send({ t: "hb", ...p });
|
||||
}
|
||||
if (hang !== undefined && p.bytes >= hang) blockForever();
|
||||
};
|
||||
if (req._test?.hangAfterBytes === 0) {
|
||||
send({ t: "hb", phase: "start", bytes: 0, total: 0 });
|
||||
blockForever();
|
||||
}
|
||||
try {
|
||||
const result = await runFsOp(req, { signal: ac.signal, progress });
|
||||
send({ t: "ok", result });
|
||||
} catch (e) {
|
||||
const fe = toFsError(e);
|
||||
send({
|
||||
t: "err",
|
||||
code: ac.signal.aborted ? "ABORTED" : fe.code,
|
||||
message: fe.message,
|
||||
...(fe.detail.path ? { path: fe.detail.path } : {}),
|
||||
...(fe.detail.syscall ? { syscall: fe.detail.syscall } : {}),
|
||||
});
|
||||
}
|
||||
Deno.exit(0);
|
||||
}
|
||||
|
||||
if (import.meta.main) await fsWorkerMain();
|
||||
@@ -1,6 +1,8 @@
|
||||
export * from "./copy.ts";
|
||||
export * from "./errors.ts";
|
||||
export * from "./ffprobe.ts";
|
||||
export * from "./fsops.ts";
|
||||
export * from "./fsproxy.ts";
|
||||
export * from "./move.ts";
|
||||
export * from "./scan.ts";
|
||||
export * from "./stat.ts";
|
||||
@@ -79,7 +79,7 @@ export interface CrossMoveResult {
|
||||
steps: CrossMoveStepLog[];
|
||||
}
|
||||
|
||||
export interface CrossMoveOptions extends Omit<CopyOptions, "hash"> {
|
||||
export interface CrossMoveOptions extends Omit<CopyOptions, "hash" | "onExisting" | "replaceOld"> {
|
||||
hash?: VerifyMethod;
|
||||
/** 上次运行的步骤日志,用于中断续跑(只作参考,实际仍以文件系统观察为准) */
|
||||
previous?: CrossMoveStepLog[];
|
||||
@@ -103,6 +103,10 @@ async function exists(p: string): Promise<boolean> {
|
||||
*/
|
||||
export async function crossDeviceMove(src: string, dst: string, opts: CrossMoveOptions = {}): Promise<CrossMoveResult> {
|
||||
const method = opts.hash ?? "size";
|
||||
const io = {
|
||||
...(opts.signal ? { signal: opts.signal } : {}),
|
||||
...(opts.onVerifyProgress ? { onProgress: opts.onVerifyProgress } : {}),
|
||||
};
|
||||
const steps: CrossMoveStepLog[] = [];
|
||||
const log = (step: CrossMoveStep, status: CrossMoveStepLog["status"], detail?: string, err?: unknown) => {
|
||||
const e = err === undefined ? undefined : toFsError(err);
|
||||
@@ -136,11 +140,14 @@ export async function crossDeviceMove(src: string, dst: string, opts: CrossMoveO
|
||||
return { status: dstExists ? "attention" : "failed", steps };
|
||||
}
|
||||
|
||||
let fullyVerified = false;
|
||||
if (dstExists) {
|
||||
try {
|
||||
// 目标不是本次写出的:只比大小/抽样会把同尺寸的不同文件当成一致并删源,这里一律用 sha256
|
||||
if (await filesMatch(src, dst, "sha256")) log("copy", "skipped", "target already matches source");
|
||||
else {
|
||||
if (await filesMatch(src, dst, "sha256", io)) {
|
||||
log("copy", "skipped", "target already matches source");
|
||||
fullyVerified = true;
|
||||
} else {
|
||||
log("copy", "failed", "target exists and differs", new FsError("EEXIST", `target exists: ${dst}`));
|
||||
return { status: "failed", steps };
|
||||
}
|
||||
@@ -153,6 +160,8 @@ export async function crossDeviceMove(src: string, dst: string, opts: CrossMoveO
|
||||
const { previous: _p, ...copyOpts } = opts;
|
||||
const r = await copyWithVerify(src, dst, { ...copyOpts, hash: method });
|
||||
log("copy", "done", `${r.bytes} bytes`);
|
||||
// copy 已用流式哈希 + 回读临时文件全量比对,并确认源 size/mtime 未变:verify 不再重复全量哈希两边
|
||||
if (r.sha256) fullyVerified = true;
|
||||
} catch (e) {
|
||||
log("copy", "failed", undefined, e);
|
||||
return { status: "failed", steps };
|
||||
@@ -160,11 +169,11 @@ export async function crossDeviceMove(src: string, dst: string, opts: CrossMoveO
|
||||
}
|
||||
|
||||
try {
|
||||
if (!(await filesMatch(src, dst, method))) {
|
||||
if (!(await filesMatch(src, dst, fullyVerified ? "size" : method, io))) {
|
||||
log("verify", "failed", undefined, new FsError("VERIFY_FAILED", "target differs from source"));
|
||||
return { status: "failed", steps };
|
||||
}
|
||||
log("verify", "done", method);
|
||||
log("verify", "done", fullyVerified ? `${method} (sha256 verified in copy step)` : method);
|
||||
} catch (e) {
|
||||
log("verify", "failed", undefined, e);
|
||||
return { status: "failed", steps };
|
||||
|
||||
@@ -26,7 +26,11 @@ export type StatResult =
|
||||
|
||||
export type RootHealth =
|
||||
| { kind: "online"; dev: number }
|
||||
| { kind: "offline"; reason: "sentinel_missing" | "root_missing" | "permission_denied" | "error"; code?: string };
|
||||
| {
|
||||
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> {
|
||||
|
||||
Reference in new issue
Block a user