Skip to content

-#1434

Closed
cyfung1031 wants to merge 29 commits intomainfrom
fix/sync/015
Closed

-#1434
cyfung1031 wants to merge 29 commits intomainfrom
fix/sync/015

Conversation

@cyfung1031
Copy link
Copy Markdown
Collaborator

@cyfung1031 cyfung1031 commented May 10, 2026

概要

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

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

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

主要改动

1. 扩展 filesystem 通用接口

  • FileInfo 新增 version?: string
    • 用于承载 provider-specific 写入前置 token,例如 ETag、rev、version、opaque id/version token。
  • FileCreateOptions 新增:
    • expectedDigest
    • expectedVersion
    • createOnly
  • 移除 FileCreateOptions.overwrite
  • FileSystemError 新增 unsupported
  • 新增错误构造 helper:
    • fileConflictError()
    • unsupportedConditionalWriteError()
  • 移除未使用的 isUnsupportedError()
  • 统一使用 conflict / unsupported 标识写入冲突和 provider 不支持的场景

2. 条件写入 header 生成逻辑复用

  • 新增 buildConditionalHeaders(opts?: FileCreateOptions)
  • 统一生成:
    • If-None-Match: *:用于 createOnly
    • If-Match:用于 expectedVersion / expectedDigest
  • S3、OneDrive、WebDAV 复用该工具函数,减少重复条件 header 逻辑
  • WebDAV 会删除 If-None-Match,继续使用 webdav client 的 overwrite: false 处理 create-only 语义
  • 新增 buildConditionalHeaders() 单元测试,覆盖:
    • createOnly 优先级高于 expected token
    • expectedVersion 优先于 expectedDigest
    • 仅有 expectedDigest 时生成 If-Match
    • 无条件时不生成 header

3. 认证流程并发去重

  • AuthVerify() 新增 authTokenPromises
  • 当 token 不存在或缺少 access token 时,同一网盘类型的并发认证请求会复用同一个 promise
  • 首次授权场景下:
    • 只打开一次授权页
    • 只请求一次 token
    • 只保存一次 token
  • 避免多个同步任务同时触发重复授权 / token 获取请求

4. Provider 写入前置条件与冲突处理

S3

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

WebDAV

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

Dropbox

  • create() 透传 FileCreateOptions
  • 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 冲突错误

Google Drive

  • create() 透传 FileCreateOptions
  • list() 请求 version 字段,并暴露 opaque version token:
    • fileId:version
  • raw response 路径在非 2xx 时改为抛 typed request error
  • delete() 遇到 typed not-found 时保持幂等成功,并清理 stale path cache
  • 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 删除刚创建的文件并抛冲突

OneDrive

  • create() 透传 FileCreateOptions
  • list() 将 eTag 暴露为:
    • digest
    • version
  • raw response 路径在非 2xx 时改为抛 typed request error
  • delete() 对 typed not-found 保持幂等成功
  • 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() 对文件不存在 errno 保持幂等成功

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

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

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

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

7. digest cache 与 tombstone digest 处理

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

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、备份导出和同步流程的单元测试,覆盖:

  • provider version 暴露
  • 条件写入 header / mode / conflict behavior
  • buildConditionalHeaders() 行为
  • create-only 写入保护
  • 条件写入冲突转换为 FileSystemError(conflict: true)
  • 不支持条件写入时返回 unsupported
  • 删除文件不存在时保持幂等
  • 初始授权并发验证只打开一次授权页、请求一次 token、保存一次 token
  • token refresh 并发复用
  • Google Drive stale cache 清理
  • Google Drive 同名重复检测与 create-only best-effort 删除
  • Google Drive 更新前 best-effort version 校验
  • Google Drive create-only 不再生成 file id
  • S3 version 保留原始 ETag,digest 保留去引号 ETag
  • S3 list 不为每个对象额外 HEAD 读取 metadata
  • Dropbox 普通写入直接使用 overwrite mode,不再 metadata preflight
  • Dropbox 已类型化 conflict 错误识别
  • Baidu best-effort expectedDigest 成功时仍使用 rtype=3
  • pushScript() 对新建文件使用 createOnly
  • pushScript() 对已有文件传递远端 expectedVersion
  • 脚本/meta 写入失败时不继续推进状态和 digest
  • scriptcat-sync.json 使用 create-only 或 expectedVersion 条件写入
  • scriptcat-sync.json 写入失败时通知并跳过 digest 更新
  • push / pull / status sync 失败时跳过 status 写入和 digest cache 更新
  • digest cache list retry 与 known digest 兜底逻辑
  • tombstone digest cache 的写入语义
  • 选中脚本导出时缺失脚本会导致导出失败
  • install/delete 触发的云同步失败通知路径

解决的问题

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

@cyfung1031 cyfung1031 added the CloudSync Related to CloudSync label May 10, 2026
@cyfung1031 cyfung1031 marked this pull request as draft May 10, 2026 07:37
@cyfung1031

This comment was marked as outdated.

@cyfung1031 cyfung1031 marked this pull request as ready for review May 10, 2026 07:41
@cyfung1031 cyfung1031 marked this pull request as draft May 10, 2026 07:45
@cyfung1031

This comment was marked as outdated.

@cyfung1031

This comment was marked as outdated.

@cyfung1031

This comment was marked as outdated.

@CodFrm
Copy link
Copy Markdown
Member

CodFrm commented May 10, 2026

改动了好多,只是大概的看了下代码逻辑,感觉没问题就合了,等全部弄完后,得手动测试一下

@cyfung1031
Copy link
Copy Markdown
Collaborator Author

cyfung1031 commented May 10, 2026

感谢。
晚点我再让 AI 处理一下。 #1434 (comment)_
之后都不提交这块的PR了。等用户回馈

@cyfung1031

This comment was marked as outdated.

@cyfung1031 cyfung1031 changed the title fix(sync): (Codex) 修复云同步冲突安全性并接入 provider 条件写入 fix(sync): (Codex) 修复云同步多设备冲突下的静默覆盖与状态污染问题 May 10, 2026
@cyfung1031

This comment was marked as outdated.

@cyfung1031

This comment was marked as outdated.

@cyfung1031

This comment was marked as outdated.

@cyfung1031

This comment was marked as outdated.

@cyfung1031
Copy link
Copy Markdown
Collaborator Author

cyfung1031 commented May 10, 2026

d574ba5

  • FileSystem.delete() 增加了 FileDeleteOptions,支持 expectedVersion / expectedDigest 条件删除:filesystem.ts
  • S3 / WebDAV / OneDrive 使用原生 If-Match;Dropbox / Google Drive / Baidu 做 provider 内的受限 preflight,冲突时转成 typed conflict error。
  • 同步删除路径现在会先读取远端文件版本,并把版本/digest 传给 deleteCloudScript();删除 tombstone 的 meta 写入也使用远端 meta 的条件写入:synchronize.ts
  • pushScript() 没引入重型两阶段提交,只在“本次新建 script 成功,但 meta 写入失败”时做一次带 expectedDigest 的 best-effort 清理;已有远端 script 的失败路径不恢复、不覆盖旧内容:synchronize.ts

补了对应单测,覆盖条件删除 header/preflight、冲突转换、tombstone 条件写入、meta 失败后的 guarded cleanup、Limiter 透传 delete options。

9313d79

deleteCloudScript() 现在如果调用方已经传入远端快照,但快照里没看到对应文件,就不会再退化成无条件删除。也就是:

  • 快照里有 .user.js:按 version/digest 条件删除。
  • 快照里没有 .user.js:不删它,避免 list 漏文件时误删第三方更新。
  • syncDelete=false 删除 .meta.json 同理,不会在 meta 快照缺失时无条件删除。
  • 没有传快照的旧调用保持原行为,避免扩大影响面。

6032f6e 13804b9

  • Add or Revise comments

ff61d4d

避免 tombstone meta 已经写入,但 .user.js 还在:之前代码可能因为 .user.js digest 没变而直接跳过,或者在本地没有脚本时把它重新安装回来。这确实可能让删除半提交状态反复存在,表现成后续同步不收敛。

补了自愈逻辑:

  • 如果 .meta.json 变成 isDeleted: true,即使 .user.js 还存在,也优先按 tombstone 处理。
  • 本地已有脚本时:删除本地脚本,并用远端版本守卫清理残留 .user.js
  • 本地没有脚本时:不会把 tombstone 对应的 .user.js 重新安装回来,会尝试清理远端残留 .user.js
  • 原本 .user.js digest 没变就跳过的逻辑,现在不会遮蔽 tombstone。

这能避免“删除冲突/半提交后,下一次、下下次一直无法正常同步”的一类实际风险。普通非冲突路径仍然主要靠 script digest 快速跳过;只有 meta digest 变化时才额外读取 meta 判断 tombstone。

补了两条测试覆盖:

  • cloud script 仍存在且 script digest 未变时,也会执行 tombstone 删除。
  • 本地无脚本但云端存在 script + tombstone 时,不会重新安装已删除脚本。

08e0844

tombstone 已经被缓存、但远端 .user.js 仍残留的异常状态。两处收敛性改进:

  1. pullScript() 改为先读 meta,发现 isDeleted tombstone 就直接删除本地脚本并清理远端 .user.js,不再先读取 .user.js。这样远端脚本已被删、不可读或很大时,不会阻塞 tombstone 删除路径。
    synchronize.ts

  2. 主同步流程补了一个窄条件恢复:当 .user.js.meta.json 都存在、digest cache 已经命中、但 meta 更新时间晚于 script 时,会额外读一次 meta;如果是 tombstone,就继续执行删除清理。这个覆盖旧版本/异常中断可能留下的“cache 已记录 tombstone,但 script 还在”的状态,避免下一次、下下次都因为 digest 未变而跳过。
    synchronize.ts

补了对应测试:

  • cached tombstone digest + 残留 .user.js 仍会收敛删除
  • tombstone pull 不再读取/安装 .user.js

88033bd

  • remoteMeta.updatetime > remoteScript.updatetime 只是启发式,不能覆盖 mtime 相等或 provider 时间精度差的情况。
  • 新增本地 tombstone_digest 记录。第一次确认某个 .meta.json 是 tombstone 后,会把它的 digest 记下来;后续即使 .user.js.meta.json 的 digest 都已在 cache 里、mtime 也相等,只要 meta digest 命中这个 tombstone 记录,仍会读取 meta 并继续清理残留 .user.js。mtime 判断保留为旧版本/异常状态的兜底,但注释里明确它不是严格协议。

  • 测试也补强了:cached tombstone 场景现在用 meta.updatetime === script.updatetime,确认不再依赖 > 才能收敛,并检查首次读到 tombstone 会写入本地标记。

f86d788

tombstone_digest 只增不清的问题

  • 当同步流程读到某个 meta,确认它不是 tombstone 时,会删除对应的 tombstone cache,避免后续每轮都额外读 meta。
  • updateFileDigest() 现在会根据最新 list 剪枝 tombstone_digest:远端 meta 不存在,或同名 meta 的 digest 已变化,旧 tombstone 标记都会被清掉。
  • 保留 updatetime > 作为旧异常状态的兜底,并在注释里明确它只是启发式,不是严格协议。

8db7c9b

  1. tombstone_digest 不再因为一次 fs.list() 没看到 meta 就被清掉。现在只有“同名 meta 仍在,但 digest 已经变化”才清理旧 tombstone 标记。这样弱一致 provider 临时漏文件时,不会丢掉后续清理残留 .user.js 的信号。

  2. 同步主流程里 rememberTombstoneDigest() / forgetTombstoneDigest() 不再每次立即 storage.set(),改为本轮内批量修改,Promise.allSettled() 后统一写一次。多脚本场景下减少本地 storage I/O,不改变同步语义。

7da6dbf

  • scriptcat-sync.json 写回时保留云端未知字段,只重建 status.scripts,避免未来 manifest 扩展字段被同步覆盖。
  • pullScript() 支持复用 tombstone 预检查时已经读到的 meta,避免同一轮同步里重复读取同一个 .meta.json
  • 抽了文件名常量和 groupFilesByUuid(),减少硬编码和主流程噪音。
  • 失败的 sync task 现在逐个记录 reason,排查冲突/网络失败更直接。
  • getScriptBackupData(uuids) 改为跳过已不存在的选中脚本并记录 warn,避免一个 uuid 缺失导致整次导出失败。
  • 命名上把 scriptlistfile 这类易混变量收紧了。

补了对应测试:

  • 选中导出时跳过缺失脚本
  • 保留 scriptcat-sync.json 未知字段
  • tombstone 预读 meta 后进入 pull 不重复读 meta

d59f7d4

用户明确选择脚本备份时,如果某个 uuid 不存在,现在会直接失败并记录日志,不再静默跳过。这样不会影响正常存在脚本的导出,但能避免“备份成功但内容不完整且用户无感”。

@cyfung1031
Copy link
Copy Markdown
Collaborator Author

移至 #1439#1437
避免 AI 难以 review

@cyfung1031 cyfung1031 closed this May 11, 2026
@cyfung1031 cyfung1031 changed the title fix(sync): (Codex) 修复云同步多设备冲突下的静默覆盖与状态污染问题 - May 11, 2026
@CodFrm CodFrm deleted the fix/sync/015 branch May 11, 2026 01:53
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