Skip to content

fix(sync): 修复云同步多设备冲突下的静默覆盖与状态污染问题#1439

Open
cyfung1031 wants to merge 5 commits into
mainfrom
fix/sync/015c
Open

fix(sync): 修复云同步多设备冲突下的静默覆盖与状态污染问题#1439
cyfung1031 wants to merge 5 commits into
mainfrom
fix/sync/015c

Conversation

@cyfung1031
Copy link
Copy Markdown
Collaborator

@cyfung1031 cyfung1031 commented May 11, 2026

概要

本 PR 修复云同步在多设备并发写入、删除、拉取/推送失败时可能出现的静默覆盖、状态污染和错误推进本地同步状态问题。

核心目标是:云同步写入脚本、元数据和 scriptcat-sync.json 时,不再无条件基于过期远端状态覆盖;而是根据各 provider 能力使用 version / digest / rev / ETag 等信息建立写入前置检查或条件写入。若远端已被其他设备修改,当前设备会识别为冲突或失败,并停止继续更新 scriptcat-sync.json 与本地 file_digest,避免把错误状态写回本地或云端。

本 PR 同时补齐 provider 条件写入/删除能力、删除幂等性、请求错误类型化、Google Drive 重名保护、tombstone 删除收敛、同步失败通知、选中脚本导出失败处理、Service Worker alarm 错误处理以及相关测试。

主要改动

1. 扩展 filesystem 通用接口

  • FileInfo 新增 version?: string
    • 用于承载 provider-specific 写入/删除前置 token,例如 ETag、rev、version、opaque id/version token。
  • FileCreateOptions 新增:
    • expectedDigest
    • expectedVersion
    • createOnly
  • 新增 FileDeleteOptions
    • expectedDigest
    • expectedVersion
  • FileSystem.delete() 支持传入 FileDeleteOptions
  • FileSystemError 新增 unsupported
  • 新增错误构造 helper:
    • fileConflictError()
    • unsupportedConditionalWriteError()
  • 统一使用 conflict / unsupported 标识写入冲突和 provider 不支持的场景
  • LimiterFileSystem.delete() 会透传 FileDeleteOptions,确保 limiter 包装后仍保留条件删除语义

2. 条件 header 生成逻辑复用

  • 新增 buildConditionalHeaders(opts?: FileCreateOptions)
  • 新增 buildExpectedHeaders(opts?: FileDeleteOptions)
  • 统一生成:
    • If-None-Match: *:用于 createOnly
    • If-Match:用于 expectedVersion / expectedDigest
  • S3、OneDrive、WebDAV 复用该工具函数,减少重复条件 header 逻辑
  • WebDAV 会删除 If-None-Match,继续使用 webdav client 的 overwrite: false 处理 create-only 语义

3. Provider 写入/删除前置条件与冲突处理

S3

  • create() 透传完整 FileCreateOptions
  • delete() 支持 FileDeleteOptions
  • list() 暴露:
    • digest: 去除引号后的 ETag
    • version: provider 原始 ETag
  • PUT 写入支持:
    • If-None-Match: * 用于 createOnly
    • If-Match 用于 expectedVersion / expectedDigest
  • DELETE 支持 If-Match
  • 将 S3 409 / 412 统一转换为 fileConflictError("s3", ...)
  • delete() 继续保持不存在时幂等成功
  • list() 不再为每个对象额外发送 HEAD 读取 metadata createtime,避免目录列表产生额外请求;创建时间使用对象 LastModified

WebDAV

  • create() 透传 FileCreateOptions
  • delete() 支持 FileDeleteOptions
  • list() 将 ETag 暴露为 digest / version
  • 写入支持:
    • If-Match 条件更新
    • createOnly 创建保护
  • create-only 通过 webdav client 的 overwrite: false 实现
  • DELETE 支持 If-Match
  • 将 WebDAV 409 / 412 统一转换为 fileConflictError("webdav", ...)
  • delete() 对 404 保持幂等成功

Dropbox

  • create() 透传 FileCreateOptions
  • delete() 支持 FileDeleteOptions
  • list() 将 Dropbox rev 暴露为 version
  • 写入支持:
    • 普通写入直接使用 overwrite mode,不再先做 metadata exists() preflight
    • expectedVersion 时使用 Dropbox update mode
    • createOnly 时使用 add mode
  • expectedDigest 但没有 expectedVersion 时通过 unsupportedConditionalWriteError() 明确报 unsupported_conditional_write
  • 上传冲突、409、incorrect_offset 以及已类型化的 FileSystemError(conflict: true) 会统一转换 / 保持为 Dropbox 冲突错误
  • 删除支持基于 rev / content_hash 的 best-effort preflight
  • 删除不存在时保持幂等成功

Dropbox delete_v2 不支持原子条件删除,因此删除前置条件只能通过删除前读取 metadata 做 best-effort 校验,不能完全消除检查后到删除前的并发窗口。

Google Drive

  • create() 透传 FileCreateOptions
  • delete() 支持 FileDeleteOptions
  • list() 请求 version 字段,并暴露 opaque version token:
    • fileId:version
  • raw response 路径在非 2xx 时改为抛 typed request error
  • delete() 遇到 typed not-found 时保持幂等成功,并清理 stale path cache
  • 删除支持基于 version / md5Checksum 的 best-effort preflight
  • findFileInDirectory() 改为:
    • 基于 findFilesInDirectory()
    • 检测到同名重复文件时抛 fileConflictError("googledrive", ...)
  • 更新已有文件时:
    • expectedVersion 解析出 fileIdversion
    • 更新前显式读取当前 Google Drive 文件 version
    • 若当前 version 与期望 version 不一致,则抛 412 versionMismatch
    • 该校验是 best-effort preflight,用于尽早发现 stale local state;Google Drive writer 当前没有原子 compare-and-swap update 路径
  • 新建文件时:
    • 不再调用 generateIds
    • 不再设置 If-None-Match: *
    • createOnly 会在创建后再次检查同名文件
    • 若发现并发重名,best-effort 删除刚创建的文件并抛冲突

Google Drive 当前没有原子 compare-and-swap update/delete 路径,因此写入和删除前的 version / digest 校验是 best-effort preflight,只能降低 stale state 风险,不能完全消除并发窗口。

OneDrive

  • create() 透传 FileCreateOptions
  • delete() 支持 FileDeleteOptions
  • list() 将 eTag 暴露为:
    • digest
    • version
  • raw response 路径在非 2xx 时改为抛 typed request error
  • delete() 对 typed not-found 保持幂等成功
  • DELETE 支持 If-Match
  • simple upload 和 upload session 均支持条件写入:
    • If-None-Match: * 用于 createOnly
    • If-Match 用于 expectedVersion / expectedDigest
  • upload session 的 conflict behavior:
    • create-only 时使用 fail
    • 默认覆盖时继续使用 replace
  • 移除 writer 中未使用的 md5 helper

Baidu

  • expectedVersion 明确标记为不支持,并通过 unsupportedConditionalWriteError() 抛出 unsupported_conditional_write
  • expectedDigest 通过写入前 list() 做 best-effort preflight
    • 该检查只能在上传前发现本地状态过期
    • Baidu 不暴露原子 compare-and-swap upload 能力
  • createOnly
    • 写入前检查目标是否已存在
    • 上传参数使用 rtype=0,要求百度服务端拒绝覆盖
    • 服务端返回文件已存在类错误时转换为 fileConflictError("baidu", ...)
  • 普通 expectedDigest 通过 preflight 后仍使用默认覆盖语义 rtype=3
  • delete() 支持基于 expectedDigest 的 best-effort preflight
  • delete() 对文件不存在 errno 保持幂等成功

Baidu 不暴露原子 compare-and-swap upload/delete 能力,因此 digest 检查只能在操作前发现本地快照已过期,不能完全关闭 TOCTOU 窗口。

4. 云同步写入正确性改进

  • 新增 getWriteOptions(modifiedDate, remoteFile)
    • 远端文件不存在:使用 createOnly
    • 远端文件存在且有 version:使用 expectedVersion
    • version 但有 digest:使用 expectedDigest
  • 新增 getDeleteOptions(remoteFile)
    • 优先使用远端 version
    • version 时回退到 digest
  • pushScript() 现在接收远端脚本 / meta 文件信息,并分别带写入前置条件:
    • ${uuid}.user.js
    • ${uuid}.meta.json
  • 新建远端脚本文件时使用 createOnly
  • 更新已有远端脚本文件时使用远端 version / digest 作为写入前置条件
  • scriptcat-sync.json 写入也改为使用远端 version / digest 前置条件
  • install 事件触发的云端推送会先 list() 远端状态,再带前置条件写入
  • delete 事件触发的云端删除会先 list() 远端状态,再带前置条件删除 / 写 tombstone
  • 删除云端脚本失败时不再吞掉异常,会向调用方抛出,便于通知用户并阻止错误状态推进

5. 同步失败时避免污染本地状态

  • syncOnceInternal() 会检查 push / pull / status sync 的 rejected task
  • 只要有失败:
    • 不写入或继续推进 scriptcat-sync.json
    • 不更新本地 file_digest
    • 触发同步失败通知
  • scriptcat-sync.json 写入失败时会被捕获:
    • 冲突错误显示冲突通知
    • 普通失败显示同步失败通知
    • 不再继续更新 digest cache
  • pullScript() 失败后不再静默吞掉异常,而是继续抛出,让上层停止状态推进
  • status 同步失败时也会停止后续 digest 更新
  • install/delete 触发的云同步失败会通知用户

6. digest cache 与 tombstone digest 处理

  • 统一使用 FileDigestMap
  • updateFileDigest() 会先读取云端列表
  • 如果刚上传的文件暂时没有出现在 fs.list() 结果中,会再重试一次 list
  • 如果文件仍未出现在云端列表中,才使用本次 push 返回的 known digest 作为兜底
  • 如果 provider 已经返回该文件,则保留 provider 返回的云端 digest
  • 不再因为本地 md5 与 S3/WebDAV/OneDrive/Dropbox 等 provider 原生 digest 格式不同而覆盖云端 digest,避免下次同步误判
  • tombstone digest cache 会批量写入,避免旧记录较多时频繁 storage.set
  • tombstone digest cache 即使在后续同步任务失败时也允许写入
    • 它只是“某个 meta digest 已确认是 tombstone”的辅助事实
    • 不会推进 file_digestscriptcat-sync.json 的成功状态
    • 用于帮助下一轮继续收敛残留删除
  • 如果同名 meta 已确认不是 tombstone,会清理旧 tombstone digest 记录
  • 如果 list 暂时漏掉 tombstone meta,会先保留 tombstone digest cache,避免最终一致性/缓存导致下一轮丢失删除收敛信号

7. tombstone 删除收敛与 orphan 状态处理

  • pullScript() 现在会先读取 meta
    • 若 meta 是 tombstone,则优先执行删除流程
    • 只有确认不是 tombstone 后才读取 .user.js
  • 远端同时存在 .user.js 和 tombstone .meta.json 时,会优先处理 tombstone,避免残留脚本长期无法收敛
  • 远端只有 .user.js、没有 .meta.json 时跳过本轮处理,避免另一台设备半上传时被误删
  • 删除云端脚本时:
    • 如果调用方已有远端快照且快照中没有对应 script/meta,则不会做无条件删除
    • 避免 list 缓存或最终一致性问题导致误删
  • push 新脚本时,如果 script 写入成功但 meta 写入失败:
    • 仅在本次是新建 script 的情况下尝试 cleanup
    • cleanup 带 expectedDigest
    • cleanup 失败只记录 warn,继续抛出原始错误

8. 选中脚本备份导出失败处理

  • 当用户明确选择一组脚本 uuid 导出时,如果其中任意脚本缺失或导出失败,不再静默跳过
  • 会先收集并记录所有失败项,然后让本次导出整体失败
  • 避免生成不完整备份而用户无感
  • 未指定 uuid 的普通全量导出行为不受该逻辑影响

9. 同步失败通知与文案

  • 新增 notifySyncFailed(hasConflict: boolean, rejectedCount: number)
  • 新增 / 更新多语言通知文案:
    • notification.script_sync_failed
    • notification.script_sync_failed_desc
    • notification.script_sync_conflict_desc
  • 覆盖语言:
    • de-DE
    • en-US
    • ja-JP
    • ru-RU
    • vi-VN
    • zh-CN
    • zh-TW

10. Service Worker alarm 错误处理

  • cloudSync alarm 调用链增加 .catch()
  • 避免构建 filesystem 或执行同步过程中的异常变成未处理 promise rejection

测试覆盖

本 PR 补充了各 provider、filesystem utils、limiter、备份导出和同步流程的单元测试,覆盖:

  • provider version 暴露
  • 条件写入 header / mode / conflict behavior
  • buildConditionalHeaders() 行为:
    • createOnly 优先级高于 expected token
    • expectedVersion 优先于 expectedDigest
    • 仅有 expectedDigest 时生成 If-Match
    • 无条件时不生成 header
  • buildExpectedHeaders() 行为
  • create-only 写入保护
  • 条件写入冲突转换为 FileSystemError(conflict: true)
  • 不支持条件写入时返回 unsupported
  • 删除文件不存在时保持幂等
  • 条件删除参数透传
  • LimiterFileSystem.delete() 透传 FileDeleteOptions
  • Google Drive stale cache 清理
  • Google Drive 同名重复检测与 create-only best-effort 删除
  • Google Drive 更新前 best-effort version 校验
  • Google Drive 删除前 best-effort version / digest 校验
  • Google Drive create-only 不再生成 file id
  • S3 version 保留原始 ETag,digest 保留去引号 ETag
  • S3 条件 PUT / DELETE
  • S3 list 不为每个对象额外 HEAD 读取 metadata
  • WebDAV 条件 PUT / DELETE
  • WebDAV create-only 使用 overwrite: false
  • Dropbox 普通写入直接使用 overwrite mode,不再 metadata preflight
  • Dropbox expectedVersion 使用 update mode
  • Dropbox createOnly 使用 add mode
  • Dropbox expectedDigest without rev 返回 unsupported
  • Dropbox 已类型化 conflict 错误识别
  • Dropbox 删除前 best-effort rev 校验
  • OneDrive simple upload / upload session 条件写入
  • OneDrive 条件删除
  • Baidu createOnly 使用 rtype=0
  • Baidu best-effort expectedDigest 成功时仍使用 rtype=3
  • Baidu 写入/删除前 digest preflight
  • pushScript() 对新建文件使用 createOnly
  • pushScript() 对已有文件传递远端 expectedVersion / expectedDigest
  • 脚本/meta 写入失败时不继续推进状态和 digest
  • 新建 script 成功但 meta 写入失败时的 guarded cleanup
  • scriptcat-sync.json 使用 create-only 或 expectedVersion / expectedDigest 条件写入
  • scriptcat-sync.json 写入失败时通知并跳过 digest 更新
  • push / pull / status sync 失败时跳过 status 写入和 digest cache 更新
  • digest cache list retry 与 known digest 兜底逻辑
  • tombstone digest cache 的写入、保留和清理语义
  • tombstone 优先处理与残留 .user.js 删除收敛
  • orphan .user.js without meta 的跳过逻辑
  • 选中脚本导出时缺失脚本会导致导出失败
  • install/delete 触发的云同步失败通知路径
  • Service Worker cloudSync alarm 异常捕获

解决的问题

  • 避免多设备同步时 last-write-wins 静默覆盖其他设备的新改动
  • 避免远端冲突后仍写入或推进 scriptcat-sync.json
  • 避免冲突、pull 失败、status sync 失败或 sync status 写入失败后错误更新本地 digest cache
  • 避免脚本文件与 meta 文件部分写入失败后继续推进本地同步状态
  • 避免删除同步半提交后残留 .user.js 长期无法收敛
  • 避免 Google Drive 同名文件导致错误更新或状态错乱
  • 减少 S3 list 的额外 HEAD 请求开销
  • 减少 Dropbox 普通写入前不必要的 metadata 查询
  • 避免用户选择导出指定脚本时,因为部分 uuid 缺失而静默生成不完整备份
  • 避免 cloudSync alarm 出现未处理 promise rejection
  • 明确 tombstone digest cache 与成功同步状态的边界,帮助后续同步继续收敛残留删除
  • 收口 conflict / unsupported 条件写入错误构造,减少 provider 实现重复代码
  • 补齐 Discussion Sync-related Coding Issue #1237 中剩余云同步正确性问题:
    • 条件写入 / 前置检查防止静默覆盖
    • delete 幂等性补齐
    • OneDrive / Google Drive raw response 错误类型化
    • sync 冲突时不推进本地 digest cache
    • install 触发的云推送也使用远端状态作为写入前置条件

@cyfung1031 cyfung1031 mentioned this pull request May 11, 2026
Closed
@cyfung1031 cyfung1031 added the CloudSync Related to CloudSync label May 11, 2026
@CodFrm
Copy link
Copy Markdown
Member

CodFrm commented May 11, 2026

我总感觉要被你改炸了。。。。。

filesystem这个包应该只专注于文件读写,冲突之类不要由这个包去处理,违背单一职责了

@cyfung1031
Copy link
Copy Markdown
Collaborator Author

cyfung1031 commented May 11, 2026

我总感觉要被你改炸了。。。。。

filesystem这个包应该只专注于文件读写,冲突之类不要由这个包去处理,违背单一职责了

每个 Provider 的冲突处理不一样呀
没有一个通用原则
Provider 的filesystem 写入/删除就是要包括各自的冲突处理

也是 fs.write(...) fs.delete(...) 有冲突的话要报错

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

本 PR 聚焦修复云同步在多设备并发与失败场景下的“静默覆盖”和“本地状态污染”问题:通过为各云端 provider 引入/统一条件写入与条件删除(version/digest 预期值),在检测到远端变化或写入失败时停止推进 scriptcat-sync.json 与本地 digest 状态,并补充冲突/失败通知与测试覆盖。

Changes:

  • 扩展 filesystem 抽象:FileInfo.versionFileCreateOptions/FileDeleteOptions 条件参数、FileSystemError.unsupported 及冲突/不支持的错误构造 helper;新增条件 Header 构造工具函数并复用。
  • 多 provider(S3/WebDAV/OneDrive/Dropbox/Google Drive/Baidu)补齐条件写入/删除或 best-effort preflight 校验,并统一冲突错误类型化。
  • 云同步流程增强:写入/删除带前置条件;失败/冲突时不推进 scriptcat-sync.json 与 digest;tombstone 收敛与导出失败处理;新增通知文案与大量单测。

Reviewed changes

Copilot reviewed 34 out of 34 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
src/locales/zh-TW/translation.json 新增同步失败/冲突通知文案(繁中)
src/locales/zh-CN/translation.json 新增同步失败/冲突通知文案(简中)
src/locales/vi-VN/translation.json 新增同步失败/冲突通知文案(越南语)
src/locales/ru-RU/translation.json 新增同步失败/冲突通知文案(俄语)
src/locales/ja-JP/translation.json 新增同步失败/冲突通知文案(日语)
src/locales/en-US/translation.json 新增同步失败/冲突通知文案(英语)
src/locales/de-DE/translation.json 新增同步失败/冲突通知文案(德语)
src/app/service/service_worker/synchronize.ts 云同步核心逻辑:条件写入/删除、失败不推进状态、tombstone 收敛、导出失败处理、通知
src/app/service/service_worker/synchronize.test.ts 覆盖同步失败不推进、tombstone 优先、条件写入、清理策略等单测
src/app/service/service_worker/index.ts cloudSync alarm 链路增加 catch,避免未处理 rejection
packages/filesystem/filesystem.ts 扩展 filesystem 通用接口(version、create/delete options、delete 签名)
packages/filesystem/error.ts 增加 unsupported 标识与冲突/不支持条件写入的错误 helper
packages/filesystem/utils.ts 新增 buildConditionalHeaders/buildExpectedHeaders 统一生成条件请求头
packages/filesystem/utils.test.ts 新增 filesystem utils 条件 header 行为测试
packages/filesystem/limiter.ts limiter 透传 FileDeleteOptions,保留条件删除语义
packages/filesystem/limiter.test.ts 覆盖 limiter.delete 透传 options 的测试
packages/filesystem/s3/s3.ts S3:list 暴露 version;PUT/DELETE 支持条件头;冲突类型化;删除幂等
packages/filesystem/s3/rw.ts S3 writer:复用条件 header 并将 409/412 转冲突错误
packages/filesystem/s3/s3.test.ts 覆盖 S3 条件 PUT/DELETE、list 行为与无额外 HEAD 请求
packages/filesystem/webdav/webdav.ts WebDAV:delete 支持 If-Match;list 暴露 version;冲突类型化;幂等删除
packages/filesystem/webdav/rw.ts WebDAV writer:复用条件 header + overwrite=false 实现 createOnly;冲突类型化
packages/filesystem/webdav/webdav.test.ts 覆盖 WebDAV 条件写入/删除与冲突转换测试
packages/filesystem/onedrive/onedrive.ts OneDrive:delete 支持 If-Match;非 2xx 抛 typed error;list 暴露 version
packages/filesystem/onedrive/rw.ts OneDrive writer:simple/upload session 支持条件写入与 createOnly 行为
packages/filesystem/onedrive/onedrive.test.ts 覆盖 OneDrive 条件删除、条件写入、createDir 前缀处理、list version
packages/filesystem/dropbox/dropbox.ts Dropbox:delete 支持条件 preflight 校验;list 暴露 rev 为 version
packages/filesystem/dropbox/rw.ts Dropbox writer:createOnly/add、expectedVersion/update、冲突类型化与不支持场景
packages/filesystem/dropbox/dropbox.test.ts 覆盖 Dropbox 条件删除 preflight、writer mode、unsupported conditional write
packages/filesystem/googledrive/googledrive.ts Google Drive:list 暴露 opaque version;delete/update best-effort version/digest 校验;重名冲突检测
packages/filesystem/googledrive/rw.ts Google Drive writer:createOnly 冲突处理与重复名回滚;更新前 version preflight
packages/filesystem/googledrive/googledrive.test.ts 覆盖 Google Drive 版本 token 暴露、删除/写入 preflight、重名检测与回滚
packages/filesystem/baidu/baidu.ts Baidu:expectedVersion 明确 unsupported;expectedDigest/delete preflight;删除幂等化
packages/filesystem/baidu/rw.ts Baidu writer:createOnly(rtype=0)与 expectedDigest preflight;冲突类型化
packages/filesystem/baidu/baidu.test.ts 覆盖 Baidu createOnly/expectedDigest/delete 幂等与 unsupported 行为

Comment on lines 53 to 58
create(path: string, opts?: FileCreateOptions): Promise<FileWriter>;
// 创建目录
createDir(dir: string, opts?: FileCreateOptions): Promise<void>;
// 删除文件
delete(path: string): Promise<void>;
delete(path: string, opts?: FileDeleteOptions): Promise<void>;
// 文件列表
Comment on lines +137 to +146
private throwCreateOnlyConflict(data: any): void {
if (!this.opts?.createOnly) {
return;
}
throw fileConflictError("baidu", `File already exists or createOnly write was rejected: ${this.path}`, {
status: 409,
code: String(data.errno),
raw: data,
});
}
@CodFrm
Copy link
Copy Markdown
Member

CodFrm commented May 15, 2026

Code review

Found 1 issue:

  1. Baidu throwCreateOnlyConflict reclassifies any non-zero data.errno from precreate/upload/create as a 409 file-conflict whenever createOnly is set. Real "file exists" cases (e.g. errno -8 / 31061) are mixed with unrelated failures (network/signature/quota/server errors), so users hit the misleading script_sync_conflict_desc notification for non-conflict failures and the underlying error is lost to the caller. A correct CAS should match the specific "file exists" errno; everything else should propagate as-is. (CLAUDE.md says "Fix root causes, not symptoms. No ... defensive skips to make errors disappear.")

body: urlencoded,
}
);
if (data.errno) {
this.throwCreateOnlyConflict(data);
throw new Error(JSON.stringify(data));
}
}
private throwCreateOnlyConflict(data: any): void {
if (!this.opts?.createOnly) {
return;
}
throw fileConflictError("baidu", `File already exists or createOnly write was rejected: ${this.path}`, {
status: 409,
code: String(data.errno),
raw: data,
});
}

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

Copy link
Copy Markdown
Member

@CodFrm CodFrm left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

从设计角度的 review,最近这些云同步的处理,都只看重了错误处理,却没有考虑消化错误,容错率低,我都有点不敢发布了


方向

修复云同步多设备并发下的静默覆盖,整体方向正确。FileCreateOptions.{expectedDigest, expectedVersion, createOnly} + FileDeleteOptions + FileSystemError.{conflict, unsupported} 是干净的领域模型,tombstone 优先、失败不推进 file_digest / scriptcat-sync.json 也是正确性的核心改进。

必须修复

  1. OneDrive / Google Drive request(nothen=true) 行为变化未充分覆盖
    两处都把 nothen=true 路径从"返回 Response 让调用方判状态"改成了"!resp.ok 直接 throw typed error"。这是底层行为变更,但 PR 只针对 delete() 调整了调用点。请补测覆盖所有 nothen=true 的调用方,避免静默回归。

  2. Dropbox 冲突识别用字符串匹配过于脆弱

    message.includes("409") || message.includes("conflict") || message.includes("incorrect_offset")

    将来 Dropbox SDK 文案变更会让冲突识别静默失效。建议在 fs.request 层就把 4xx 转成 typed FileSystemError,上层只判 error.conflict

  3. getScriptBackupData(uuids) 写法
    Promise.allSettledfilter/map 三次,可以简化成 Promise.all + try/catch 收集失败,或一次 reduce 完成分类。

设计层建议

  1. expectedVersion 语义在不同 provider 间不齐

    • S3 / OneDrive / WebDAV:服务端原子 If-Match(强 CAS)
    • Dropbox / Google Drive / Baidu:preflight,仍存在 TOCTOU 窗口

    接口上看不出区别,调用方拿到 expectedVersion 容易误以为是强一致。注释里只在 provider 内部点了。建议:

    • FileSystem.capabilities() 之类的能力查询里暴露 atomicCompareAndSwap: boolean
    • 或在 FileSystemError 上把 conflict(服务端 CAS 命中)与 staleSnapshot(preflight 命中)分开,让上层做差异化处理(best-effort provider 可以加自动重试)
  2. Google Drive 的 version 字段被编码成 fileId:version

    version: item.version ? `${item.id}:${item.version}` : item.id,

    list/delete/writer 各自实现 parseGoogleDriveVersion 再 split。这让 version 在不同 provider 间含义不同(其他都是 etag/rev/hash),违反"opaque token 跨 provider 透明"的契约。建议在 FileInfo 上加 providerToken?: unknown,或 Google Drive 内部维护独立 path→id 缓存。

  3. tombstone_digest 第二存储引入了独立生命周期

    • 隐式 invariant:仅当 file_digest[name] 也匹配时才有效,但类型不强制
    • "其他 task 失败时仍允许写"是个例外口子,与整体"失败不推进状态"原则相反
    • 永远不为完全删除的脚本做 GC(只在同名 meta 再次出现时清理),长期累积

    建议合并进 file_digest 的值({digest, isTombstone}),或加 TTL/round 计数清理。

  4. updateFileDigest 的 list-retry 把 provider 一致性问题暴露给上层
    更干净的做法是 FileWriter.write() 返回该文件最终的 FileInfo(含远端 digest/version),避免上传后再 list-retry。

  5. SCRIPTCAT_SYNC_FILENAME 写入用第一轮 list 的快照
    整个 syncOnce 过程可能持续数秒到分钟,期间另一台设备可能已经写过 sync.json。CAS provider 没问题,best-effort provider 仍是 TOCTOU。值得在 pushScript 进入 finalize 前补一次 list。

  6. OneDriveFileWriter.createConditionalHeaders 返回逻辑

    return base || Object.keys(conditionalHeaders).length > 0 ? headers : undefined;

    优先级容易看错,建议改成显式 if。

副作用清单(用户可感)

行为变化 风险
任一文件失败即停止 file_digest 推进 单次 5xx/限流会让整轮 sync 状态不前进,PR 没加自动重试
新增"同步失败/冲突"通知 之前静默吞掉的失败现在弹窗,预期用户投诉增加,文案让用户"检查日志"对最终用户不友好
S3 list 不再 per-object HEAD 失去 x-amz-meta-createtime,回退到 LastModified;性能大幅改善
deleteCloudScript / pullScript 不再吞异常 install/delete/pull 失败会触发用户通知,量上升
scriptcat-sync.json 写入失败会通知 多设备首轮并发用户会频繁看到冲突通知,直到一方让步
Google Drive createOnly 是 post-create 检测 两台设备同时新建同名时可能双方都先写成功,再各自 reject 删除自己的,极端竞态有数据丢失风险
OneDrive delete 200/202 现在也视为成功 旧代码只接受 204,新代码任意 resp.ok,行为变化
tombstone_digest 长期累积无 GC storage IO 慢慢变重

建议补充

  • 自动重试:transient 失败(429/5xx)按指数退避重试,不直接通知用户
  • 通知节流:同一错误 5 分钟内只弹一次
  • 失败文案优化(不要让最终用户"检查日志")

整体评价:方向对、修复了真实问题,但抽象语义未对齐、用户感知通知可能从"静默"变"嘈杂"。建议优先补 typed error / 自动重试 / 通知节流。

Copy link
Copy Markdown
Member

@CodFrm CodFrm left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

补一轮 inline review,重点指出 synchronize.ts 在错误处理上只考虑了「报错」,没考虑「消化」——容错颗粒度从 per-file best-effort 变成了 all-or-nothing 事务,对大批量同步是显著退化。

容错率对比(同 synchronize.ts

场景 旧代码 新代码 容错变化
单 pushScript 429 / 网络抖动 其他脚本继续,成功的 digest 推进,下轮只重试失败那个 整轮停摆,所有 digest 不推进,下轮 100 个全部重做 显著降低
单 pullScript 失败(JSON 损坏等) 静默 log,其他脚本继续 抛错→整轮停摆,单条坏数据卡死整账号 显著降低
批量删除中单条失败 循环继续,其他 uuid 仍被处理 抛错→循环中断,后续 uuid 完全跳过 ❌ 降低
status sync 单条失败 Promise.allSettled 忽略 rejected,继续写 sync.json 任一失败即 return,sync.json 不写 ❌ 降低
99 成功 + 1 失败 99 推进 digest,1 待重试 100 全不推进,且下轮 push 因为远端 version 已变更容易再冲突 严重降低
install 触发的 push 瞬时失败 静默 log,下次 syncOnce 自然补上 弹「同步失败」通知 ❌ 用户感知噪声
delete 触发的循环失败 静默 log 弹通知但未说明后续未删 ❌ 信息不全
整体 sync 失败 静默 log 弹通知 + 不推进任何状态 ❌ 嘈杂 + 脆弱

核心问题(一句话)

旧代码是「整体 best-effort」(最多就是一轮 sync 啥也没做)
新代码是「整体事务」(任一失败 = 整轮回滚)
应该的颗粒度是「per-file」——成功的 file 推进自己的 digest,失败的保留旧 digest 待下轮自动重试

旧代码确实也有问题

公平地说,旧代码的「容错」部分是用 silent data loss 换的——pullScript 静默吞错会让本地版本和云端不一致而没有任何提示。PR 修对了这部分。问题不是「该不该报错」,而是「报错的颗粒度过粗」

建议

  • 把错误按 transient(429/5xx/网络) / conflict / fatal 分类
  • transient 走 backoff 重试(LimiterFileSystem 已有,但只覆盖 read 类操作,建议扩展到 write/delete)
  • conflict 才真正中止该文件,但保留其他成功文件的 digest
  • 通知做节流,并区分「重试中」「最终失败」两种状态

下面是 synchronize.ts 具体行号的 inline 评论。

this.logger.warn(`sync task #${idx} failed`, Logger.E(ret.reason));
});
this.notifySyncFailed(hasConflict, rejected.length);
return;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

容错率下降的核心点

旧代码这里是:

const syncResults = await Promise.allSettled(result);
syncResults.forEach((ret) => {
  if (ret.status === "fulfilled" && ret.value) {
    Object.assign(pushedFileDigestMap, ret.value);
  }
});
// 继续往下走 status sync / sync.json / updateFileDigest

新代码任一 rejected 即 return99 个成功 push 的 digest 全部不记录

后果:

  • 下一轮 sync 重新 list,发现 99 个文件 digest 与本地缓存不一致 → 又触发对比 → push 重做
  • 重做时远端 version 已经变了(自己刚写过)→ 触发 conflict
  • 用户感知是「同步反复失败」

建议:保留旧的 pushedFileDigestMap 累积逻辑,把「成功 file 的 digest 推进」和「整轮 status / sync.json 写入」解耦——前者无脑推进,后者根据 rejected 数量决定是否跳过。这样 transient 失败下,至少成功的 99 个文件不会下轮重做。

const rejectedStatus = statusResults.filter((ret) => ret.status === "rejected");
if (rejectedStatus.length) {
this.notifySyncFailed(false, rejectedStatus.length);
return;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

status sync 也一刀切退出。

旧代码这里是 await Promise.allSettled(...)不检查 rejected,继续写 scriptcat-sync.json。这是有意为之——status sync 是 best-effort,单条 enable/sort 失败不应阻止全局状态写回。

新代码改成「任一失败即 return」,会让单条 enableScript 失败(例如某个脚本被禁用过程中触发了边界错误)阻止 sync.json 写入,进而下轮所有脚本都要重做 status 同步。

建议:保持 best-effort 语义,或只在多数失败时才中止。

logger.info("delete success");
} catch (e) {
logger.error("delete file error", Logger.E(e));
throw e;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

deleteCloudScript 从「吞错」改为「抛错」。

旧代码:

} catch (e) {
  logger.error("delete file error", Logger.E(e));
  // 没有 throw
}

配合 scriptsDelete 里的 for (const { uuid } of items) 循环——旧代码单条删除失败循环继续,新代码一条失败立刻中断后续所有 uuid 的删除。

问题:用户一次性删除 10 个脚本,第 3 个网络抖动 → 后 7 个都不会被推到云端,但本地已经删了,下次也不会重试(因为 mq 事件已经消费)。数据持久错配

建议:循环内单条用 try/catch 收集失败,循环结束后统一上报;或者把删除事件持久化到队列里支持重试。

logger.info("pull script success");
} catch (e) {
logger.error("pull script error", Logger.E(e));
throw e;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pullScript 从「吞错」改为「抛错」。

旧代码这里是静默 log,新代码 throw 后冒泡到上面 syncOnceInternalrejected.length 检查 → 整轮 sync 退出。

实际场景:云端某个脚本 meta JSON 被外部工具改坏了 → JSON.parse 抛错 → 整个账号的同步永远停在那条坏数据上,直到用户人工去云盘修复。

建议:区分错误类型。JSON.parse 失败、prepareScriptByCode 失败属于「该文件本身坏了」,应该跳过该文件而不是阻止整轮;只有 fs 层的 conflict / auth / network 才该冒泡。

logger.warn("cleanup newly created script after meta write failure failed", Logger.E(cleanupError));
});
}
throw e;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

push 失败时清理孤儿 .user.jsexpectedDigest: scriptDigest(本地 MD5),但部分 provider(OneDrive/WebDAV/S3 = etag, Dropbox = content_hash)远端 digest 不是 MD5。

注释已经承认「清理可能失败,只会留下 orphan」,但 orphan 的代价被低估了:

  • 下一轮 sync 看到 orphan .user.js(无 meta)会触发 skip orphan 分支
  • 这台设备的本地 script 还在 → scriptMap 命中 → 走 push 分支
  • push 时 remoteFiles.script 命中 orphan → 用 orphan 的 digest 作为 expectedDigest

这条路径最终能收敛,但中间会让其他设备误判为「另一台正在半上传」,导致其他设备的 sync.json 写回时保留旧 status。

建议:cleanup 这里改成无 digest 守卫的窄场景删除(仅在「本次新建且无远端快照」时),或者 writer 写完后返回远端真实 digest。

await this.updateFileDigest(fs, pushedFileDigestMap);
}).catch((e) => {
this.logger.error("push script on install error", Logger.E(e));
this.notifySyncFailed(isConflictError(e), 1);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

install 触发的云推送失败 → 弹通知。

旧代码是 this.logger.error(...) 静默。

场景:用户装一个脚本,本地装好了,云端 push 因为 429 / 网络抖动失败 → 用户看到「同步失败」通知。但用户的本地操作完全成功,下次 syncOnce 会自动补上 push。这种情况通知就是噪声。

建议:transient 失败这里加 1-2 次重试,仍失败再降级通知;或者干脆不通知,让定时 syncOnce 自然兜底。

await this.updateFileDigest(fs);
}).catch((e) => {
this.logger.error("delete cloud script error", Logger.E(e));
this.notifySyncFailed(isConflictError(e), 1);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

同上,delete 触发的云推送失败也弹通知。

这条路径比 install 更敏感:用户删 10 个脚本,第 3 个失败 → 后 7 个完全没被处理(因为 deleteCloudScript 现在 throw,for 循环中断)→ 但只弹一条「同步失败」通知,没说后面 7 个没删。

建议:循环内逐条 catch,最后聚合失败列表通知;或把 delete 任务写到持久队列做重试。

async updateFileDigest(fs: FileSystem, knownFileDigestMap: FileDigestMap = {}) {
const newList = await fs.list();
const newFileDigestMap: FileDigestMap = {};
let newList = await fs.list();
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updateFileDigest list-retry 一次的启发式补丁。

这是把 provider 的最终一致性问题向上抛给同步层处理,而不是在 provider 层解决。更干净的设计是:FileWriter.write() 返回该文件最终的 FileInfo(含远端 digestversion),调用方直接用,不必上传后再 round-trip list。

现状代价:每轮 sync 都做这个判断(some + 嵌套 some,O(n²)),脚本多时是无谓开销。

if (rejected.length) {
const hasConflict = rejected.some((ret) => isConflictError(ret.reason));
rejected.forEach((ret, idx) => {
this.logger.warn(`sync task #${idx} failed`, Logger.E(ret.reason));
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logger.warn 逐条记每个失败,但用户只看到一条聚合通知 notifySyncFailed。失败原因(429? auth? conflict?)只在 log 里——而扩展用户基本不会去看 service worker console。

建议:通知里至少带 task 数 + 首个错误类型(conflict/network/auth),让用户能粗略判断是不是该立刻处理。

@cyfung1031
Copy link
Copy Markdown
Collaborator Author

0308d03

  • 修复 Baidu createOnly 错误分类:只把 errno = -8 / 31061 视为文件已存在冲突,quota/auth/server 等错误不再误报为 conflict。
  • 调整 syncOnceInternal:部分同步失败时,已成功 push 的文件 digest 会先推进,避免下轮全部重做;失败时仍跳过 scriptcat-sync.json
  • status 同步改为 best-effort:单个 enableScript / sort 更新失败只记录 warning 和通知,不再阻止写入 scriptcat-sync.json 与更新 digest。
  • scriptsDelete 改为逐条删除、逐条 catch:一条删除失败不会中断后续删除,最后聚合失败并通知。
  • pullScript 增加坏远端文件容错:坏 .meta.json 或坏 userscript 会跳过该文件,不再卡死整个账号同步。
  • 收窄 pullScript 错误吞掉范围:只吞解析类坏远端脚本错误,内部 DAO/安装等错误继续上抛。
  • partial sync 中成功 digest 推进失败时,不再吞掉原始同步失败通知;只额外 warn。

补充测试:

  • Baidu createOnly:已存在 errno、重名 errno、非冲突 errno。
  • partial sync:成功 digest 先推进、digest 推进失败仍通知。
  • status sync:失败后仍写 scriptcat-sync.json 和 digest。
  • pullScript:坏 meta、坏 script 跳过。
  • scriptsDelete:单条失败后继续删除并聚合通知。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CloudSync Related to CloudSync

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants