Skip to content

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

Open
cyfung1031 wants to merge 1 commit into
mainfrom
fix/sync/015c
Open

fix(sync): 修复云同步多设备冲突下的静默覆盖与状态污染问题#1439
cyfung1031 wants to merge 1 commit 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(...) 有冲突的话要报错

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.

2 participants