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,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();
|
||||
Reference in new issue
Block a user