Files
UHD_Media_Tool/packages/adapters/src/fs
rootandClaude Opus 5.5 521c86d730 fix(storage): 本地写入口拒绝 Alist 虚拟路径,补字幕在 Alist 库根上明确失败
虚拟前缀 /@alist/… 读到本地是 ENOENT,但 mkdir -p、复制 / 移动目标、写文件、回收站落点会在本机真建出
/@alist 并写入文件。统一守卫:任何本地写类操作参数是虚拟路径即抛 REMOTE_PATH_ON_LOCAL_FS
(remote_path_on_local_fs),不落盘。

- adapters:fs/remote-guard.ts,接在 runFsOp、FsProxy.run(父进程拒绝不起子进程)、copyWithVerify、
  crossDeviceMove、moveSafe、commitNoClobber、sweepStalePartials、writeRootSentinel
- server:storage/local-fs.ts 带守卫的写函数,整理 / 替换 / 刮削 / 回收站 / 上传 / 字幕 / vfs 本地分支改用它
- 补字幕目标在 Alist 媒体库上返回失败原因,撤销同样拒绝

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
2026-10-07 00:35:41 +08:00
..

fs

本地文件操作: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 冒充"内容不同")。

挂死隔离: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 run -A fsworker.ts;否则视为 deno compile 出的 umt-server 二进制 → <execPath> --role fsworker(apps/server/src/main.ts 在加载其余模块前分派,启动约 150 ms)。编译产物的 --allow-run 必须含自身绝对路径(镜像内 /app/umt-server,见 apps/server/deno.json);起不来(NotCapable/ENOENT)自动回退 in-process(同样的看门狗,超时立即报 TIMEOUT 并中止,但卡死系统调用占住的线程无法回收),stats().fallbackReason 记原因。stats().workerKind = script / self / custom。
  • 容器验收开关:worker 进程设 UMT_FS_TEST_HANG_AFTER_BYTES=<n> 后,每个数据类操作的 worker 读到 n 字节即卡死 JS 线程并屏蔽 SIGTERM,用来在生产二进制里验证 停滞超时 → SIGKILL → TIMEOUT;生产勿设。
  • 测试开关 _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/网络挂载上验证。