Readest 字体同步下载 “Unknown error“ 排查实录:缺失目录根因、错误折叠缺陷与 PR 5700 修复方案
2026/9/20 16:49:49 网站建设 项目流程

Readest 字体同步下载 "Unknown error" 排查实录:缺失目录根因、错误折叠缺陷与 PR #5700 修复方案

【免费下载链接】readestReadest is a modern, feature-rich ebook reader designed for avid readers offering seamless cross-platform access, powerful tools, and an intuitive interface to elevate your reading experience.项目地址: https://gitcode.com/gh_mirrors/re/readest

本篇技术指南完整复盘 Readest 仓库中 Issue #5675 的排查与修复过程:Android 用户 Transfer Queue 中大量字体下载显示Unknown error,根因并非网络或鉴权,而是原生下载器File::create不创建父目录导致os error 2,同时 TransferManager 将非Error类型的原生拒绝折叠为无信息文案。读者将掌握一条完整的"排除法定位 → 源码级归因 → 修复落地 → 测试验证"的实战链路,以及这些修复在当前仓库中的确切代码位置。

问题现场:Issue #5675 的现象

Issue #5675 报告于 Android 0.12.1(俄语用户、终身许可)。现象非常明确:Transfer Queue 面板显示Completed: 0, Failed: 16,16 个字体下载全部失败,错误标签为Unknown error(俄语环境显示Неизвестная ошибка),同时弹出 toastFailed to download file: <font>

这批失败并非随机网络抖动——失败数量与待下载字体数量完全一致,且全部落在同一个错误文案上。这意味着问题大概率出在某一个共性的系统环节,而不是逐文件独立的网络错误。

排除法定位:先证明"不该失败的地方都没失败"

排查的第一步是把失败责任逐层剥离,文档作者给出了三条已证实而非猜测的证据链:

  1. 元数据与 manifest 确实到达了接收设备。fontAdapter.unpackRowrow.manifest_jsonb缺失时返回null(见 adapters 助手 中的singleFileFilenameFromManifest),而 replicaPullAndApply.ts 对空 manifest 直接提前返回。因此队列里出现了一个下载任务,本身就证明发布端已完成uploadReplicaFile(manifest 只在replicaTransferIntegration.handleReplicaUpload上传完成之后才提交)。
  2. /api/storage/download接口调用成功。所有 JS 侧的失败都会抛出带真实消息的ErrorfetchWithAuth(utils/fetch.ts)会重新抛出服务端返回的error字符串;getUserID为 null 时抛Error('Not authenticated');缺少 URL 时抛Error('No download URL available')webDownload失败抛Error(...);缺失files行 404 时抛Error('File not found')。这些文案一个都没有出现
  3. 结论:拒绝必然来自invoke('download_file')原生调用。src-tauri/src/transfer_file.rs 中impl Serialize for Error将错误序列化为纯 JS 字符串serializer.serialize_str(self.to_string())),而 transferManager.ts 第 502 行的错误折叠逻辑error instanceof Error ? error.message : _('Unknown error')会把任何非Error类型的拒绝值统一映射为字面量Unknown error,随后在第 551 行存入 transfer 状态。

仓库中的 transfer-manager.test.ts 用一次临时的 vitest 验证印证了该推断:downloadReplicaFile以字符串拒绝时transfer.error === 'Unknown error';以new Error('File not found')拒绝时消息被完整保留。

根因:File::create不创建父目录 + mkdir 与 id minting 被"熔合"

Rust 侧:原生下载器只写文件,不建目录

transfer_file.rs 单线程路径与 第 268 行 多线程分段路径都直接调用File::create(file_path)File::create的语义是"打开或创建指定文件",它不会递归创建父目录——当Fonts/<bundleDir>/不存在时,立即以No such file or directory (os error 2)失败。

JS 侧:mkdir 被"熔合"进了 id minting

真正不寻常的部分在于:目录为什么会在设备上凭空消失?答案在 useReplicaPull.ts 的createBundleDir

createBundleDir: async () => { const id = uniqueId(); // 铸造一个新的 bundleDir id await service.createDir(id, config.baseDir!, true); // 同时创建磁盘目录 return id; },

它把"生成唯一 id"和"创建目录"两个职责融合在同一个函数里。而 replicaPullAndApply.ts 的applyRowlocal分支(本地已存在该 contentId 的记录)中刻意不调用createBundleDir——原因在注释里写得很清楚:如果为一条已有记录铸造新 id,会让旧二进制文件变成孤儿,并且每次 pull 都会触发全量重下载。于是,作为"附带损伤",mkdir 也被一并跳过了

结果就是:local分支的下载路径(包括从 localStorage 重放的持久化失败队列、Retry All 重试、以及目录丢失后的local记录)全部在"目录可能不存在"的前提下写入文件。没有任何代码在下载前重新建立"目录必须存在"这个不变量。

为什么只有"记录存活、目录死亡"的设备中招:路径解析分裂

要理解这个问题为何只在部分设备上爆发,需要看清记录(record)与文件(file)住在不同的地方

  • 字体记录本身(settings.json)保存在AppConfig(应用内部目录)中;
  • 字体二进制文件保存在Fonts/目录下,而Fonts/跟随用户自定义的customRootDir
  • getPathResolver 对Fonts/Books/Images/Dictionaries都应用 custom root,唯独不作用于 Settings

因此,只要用户修改了 customRootDir,或清除了外部存储,就会出现文档中描述的极端不对称:每一条记录都存活(记录在内部存储里安然无恙),每一个 bundle 目录却全部消失(目录跟着旧 root 一起没了)。这批"记录比目录长寿"的设备,正是Unknown error的重灾区。全新设备反而完全正常——因为新设备的记录和目录是同时创建的。

修复方案:PR #5700(fix/replica-download-bundle-dir

PR #5700 共 2 个 commit,修复策略是在下载时兜底建目录,而不是在applyRow里补 mkdir:

Commit 1:下载前确保 bundle 目录存在

修复点位于 appService.ts 的downloadReplicaFile,在把绝对路径交给原生下载器之前先建目录:

async downloadReplicaFile(kind, replicaId, filename, lfp, base, onProgress?) { // The native downloader writes with `File::create`, which does not create // parent directories, so a missing bundle dir fails as an opaque // "No such file or directory (os error 2)" (issue #5675). const bundleDir = getDirPath(lfp); if (bundleDir) { await this.fs.createDir(bundleDir, base, true); } // Resolve the relative `<bundleDir>/<filename>` lfp against the // replica's base dir before downloading. const dst = await this.resolveFilePath(lfp, base); return CloudSvc.downloadReplicaFileFromCloud(this, { kind, replicaId, filename, dst, onProgress, }); }

关键设计考量:

  • getDirPath(utils/path.ts)从 lfp 中剥离出<bundleDir>部分createDir是递归的,目录已存在时是 no-op,无额外开销。
  • 放在下载时而非 applyRow,因此持久化队列重放(persisted-queue replay)和 Retry All 也被一并覆盖——这正是文档强调"at the DOWNLOAD, not in applyRow"的原因。
  • 这与既有防御保持了一致:cloudService.downloadBookdownloadBookCovers本就先createDir再下载,此次修复补齐了 replica 下载路径上缺失的同款护栏。

Commit 2:replica 传输默认后台化,失败路径也静默

第二个 commit 修复了一个配套缺陷:replica 传输现在默认isBackground: true,并且失败路径同样遵守该标记。此前只有成功路径遵守isBackground,失败路径未遵守——这正是 Issue #5675 中"16 个字体 16 条Failed to download filetoast"刷屏的来源。修复后(见 transferManager.ts),后台 replica 的失败不再逐文件弹 toast,但失败状态仍会写入 transfer,Transfer Queue 面板依旧可以读到错误信息。

测试验证:目录先于下载创建

仓库为这次修复补充了完整的单测,位于 app-service.test.ts 的downloadReplicaFiledescribe 块,覆盖五个场景:

测试用例断言
下载前创建 bundle 目录createDir('v04c1uy', 'Fonts', true)被调用
创建发生在下载之前createDir的调用序 <downloadReplicaFileFromCloud的调用序
嵌套路径创建完整父链('bundle-1/assets', 'Dictionaries', true)
无 bundle 目录的扁平旧路径不建目录createDir未被调用
仍下载到解析后的绝对路径dst包含v04c1uy/Georgia.ttf

此外 transfer-manager.test.ts 验证了后台化语义:replica 传输不弹 info toast;当downloadReplicaFile以原生字符串'No such file or directory (os error 2)'拒绝(正是真实 os-error-2 的形态)时,重试耗尽后不再派发错误 toast。

设备 A/B 证明:同一台设备,目录有无决定成败

文档记录了在一台 Xiaomi 设备(customRootDir=/storage/emulated/0/Books)上的 A/B 对照验证:

  • 目录缺失failed→ 重试 3 次 →Unknown error,console 输出os error 2
  • 目录存在completed,下载字节数 379588 完全一致,Georgia:loaded@font-face注入成功。

同时完整跑通了全链路:导入(import)→ 上传(upload)→ manifest 提交 → pull → 下载(download)→ 字体挂载(mount)。这组证据将根因从"推断"升级为"实锤"。

尚未修复的次生缺陷:Unknown error文案折叠

PR #5700 明确标注了一个仍未修复的 follow-up:原生错误在 UI 中被折叠成无信息的Unknown error。问题位于 transferManager.ts:

const errorMessage = error instanceof Error ? error.message : _('Unknown error');

由于 Rust 侧将错误序列化为纯字符串(transfer_file.rs 的serialize_str),而此处只识别Error实例,于是四类原生失败模式在 UI 中完全不可区分且不被记录:

  • Forbidden:fs-scope 越权(相关历史见ensure_path_allowed与 in-place-delete 的 fs-scope 演进);
  • Request:网络 / TLS 层失败;
  • HttpErrorCode403/404:R2 云端返回;
  • Io:os-error-2(目录缺失)/ os-error-28(磁盘满)。

文档给出的修复方向是:在executeTransfer中先接受字符串拒绝,即typeof error === 'string' ? error : ...,让原生错误文案直达 UI。同时指出downloadFile目前只console.error,使得这类报告完全无法诊断——这是比目录缺失更需要优先解决的真实缺陷。

Fleet 佐证与更多暴露出来的未处理路径

Sentry(readest org)数据将问题从个例升级为平台级现象:

  • 搜索message:*Readest/Fonts*可看到大量failed to get metadata of path: .../Readest/Fonts/<bundleDir>/<file> ... No such file or directory (os error 2),覆盖Android、iOS、Windows三个平台,版本跨度 0.11.18 → 0.12.1,且非特定用户——占位记录存在而二进制从未落盘。
  • 另有failed to create directory ... No space left on device (os error 28),来自importFont,属于未处理路径。
  • 还发现一个泄漏的 Android SAF doc-id 被当作字体文件名(primary%3AFonts%2FTaiwanPearl-Regular.ttf)——importFont未对fileobj.name执行makeSafeFilename

仍待确定与附带发现

  • 四种原生错误中究竟是哪一种击中了本次报告者,仍无法确定。Sentry 中一条 Android 0.12.1 路径为/storage/emulated/0/Books/Readest/Fonts/...(custom root 位于外部存储),对那一子集而言,File::create的 EACCES(权限拒绝)同样是可疑来源。文档指出,需要错误透出修复上线 + 用户重新上报,或设备端复现才能定论。
  • 附带发现(与本次问题无关但真实存在):useCustomFontStore 的findByContentId不过滤deletedAt,而持久化 fallbackfindFontByContentId会过滤——被软删除的本地字体会遮蔽存活的远端行,导致设备 A 上"删除后重新导入"的字体现在设备 B 上无法复活。
  • 设备探测记录:用于验证的 Xiaomi fuxi(368b0948)运行的是 RELEASE 0.12.1——webview_devtools_remote_<pid>socket 存在但不提供 CDP,run-as被拒(包不可调试)、无 root,/sdcard/Android/data/com.bilingify.readest/files为空。要在设备上做深层检查,需要pnpm dev-androiddevtools 构建。

总结

Issue #5675 是一次教科书级的"跨语言边界 bug":JS 侧的错误折叠把 Rust 原生的字符串拒绝吞成Unknown error,而真正的根因——File::create不建父目录、mkdir 与 id minting 熔合导致的local分支目录缺失、以及 customRootDir 让记录与文件分裂存储——环环相扣。PR #5700 在下载入口处补齐了createDir(getDirPath(lfp), base, true)护栏,并将 replica 传输彻底后台化,配合五组单测锁死行为。对于仍困扰用户的"错误不可诊断"问题,仓库已给出明确的下一步修复方向(在executeTransfer接受字符串拒绝),值得任何关注该仓库的开发者跟进。

【免费下载链接】readestReadest is a modern, feature-rich ebook reader designed for avid readers offering seamless cross-platform access, powerful tools, and an intuitive interface to elevate your reading experience.项目地址: https://gitcode.com/gh_mirrors/re/readest

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询