Fix cloud sync state consistency and provider-safe conflict handling#1504
Fix cloud sync state consistency and provider-safe conflict handling#1504cyfung1031 wants to merge 91 commits into
Conversation
解决 docs 冲突:main 的文档重构(大写文件名→小写 + 拆出 references/*)与本 PR 新增的云同步文档在同一批索引表/校验脚本上冲突。采用 main 的重构结果,并把 docs/CLOUD-SYNC.md 改名为 docs/cloud-sync.md 以符合新命名约定,重新登记进 docs/README.md、docs/DOC-MAINTENANCE.md 的责任表与链接校验脚本。
- #2 scriptInstall 编辑已同步脚本改用 CAS 覆盖而非 create-only: buildPushCreateOptions 仅在"本轮未列到且本地无 digest 记录"(真正首次上传) 才用 create-only;已同步过的脚本走 CAS,避免 create-only 撞已存在云端文件必然 412。 - #3 分片上传(.user.js 成功/.meta.json 失败)不再永久 CAS 冲突: pushScript 抛 PushScriptPartialError 带出已成功文件;syncOnce 与 scriptInstall 只保留失败文件旧 digest、推进成功文件的云端最新 digest,成功文件 digest 不再永久变旧。 - scriptscat#1 digest 相等时仍按本地更新时间/.meta.json 齐全判定: 早退仅当 .meta.json 齐全、digest 一致且本地不比云端新时才跳过;否则本地编辑需上传、 云端缺 meta 需补传。修复本地编辑永久不上云,并避免推进成功 digest 后漏补 meta 成孤儿。 补 5 个测试:#2 CAS 覆盖、scriptscat#1 本地编辑推送、pushScript 带出成功文件、新脚本 meta 失败下一轮补传、 scriptInstall 分片失败后 syncOnce 自愈不永久 412。
#2(main 既有)scriptInstall/scriptsDelete 改用 updateFileDigestForUuids 只更新本 uuid 文件;updateFileDigest 全量盖章仅保留给已逐文件对账的 syncOnce。避免本机改动 任一脚本时,把他端已更新但尚未 pull 的文件也标成已同步而下轮 syncOnce 早退漏 pull。 scriptscat#1 push 成功内容 md5 先于易失的 fs.list 落库(push_content_md5);pushScript 遇 CAS 冲突时读云端内容比对,识别为「本机上次成功写入」(digest 记账失败导致 If-Match 基准 过期)则以云端当前原生 digest 重挂基准重推一次收敛,打破自我 412 死循环;内容不符即 真·他端并发编辑,维持停在冲突不覆盖他端。 均 TDD(先失败测试后修复),新增 3 个用例,全量 3182 单测通过。
fix: - updateFileDigest 全量对账后清理 file_digest 之外的 push_content_md5 - updateFileDigestForUuids 仅清理本次确认已删除的目标文件(队列未全量对账,不能全局清理误删他端窗口内记录) TDD 补 2 个测试:全量路径清理云端已删文件、队列路径 scoped 清理不误删无关条目。 docs(cloud-sync): - 补齐 b851542 引入的「自我 412 收敛」「两条 digest 更新路径」「push_content_md5」语义 - 并发冲突表述从「仅日志可见」细化为自我 412 自愈 vs 真并发编辑停走 - 修正 Baidu filemetas notFound 措辞(仅空列表强制 notFound)
真实 provider 本地 e2e 验证结果 + 修复方案分析在真实 provider(两台 WebDAV:坚果云 + 一台 LAN Alist 类 + 局域网 MinIO)上驱动生产代码栈
TL;DR
🔴 L4:坚果云跨设备更新收不到(头号 bug)复现(A 改脚本→更新上了云,但 B syncOnce 拉不到,抛两个 412):
根因: 修复方向(未实施,供决策):
|
| 条件操作 | 坚果云 WebDAV | MinIO S3 |
|---|---|---|
| CAS 写(If-Match) | ❌ 恒 412(quoteETag 加引号,坚果云只认不带引号;见下) |
✅ 生效 |
| create-only(If-None-Match) | ❌ 忽略→覆盖 | ❌ 忽略→覆盖(此 MinIO 版本 2023-07) |
| 条件删除(If-Match on DELETE) | ❌ 忽略→照删 | ❌ 照删(S3 DeleteObject 本就无 If-Match 语义) |
W1(quoteETag):packages/filesystem/webdav/rw.ts:85 用
If-Match: quoteETag(expectedDigest) 强制加引号。坚果云的 getetag 是不带引号的不透明串,
只接受不带引号的 If-Match(我用 curl 逐一验证:带引号→412、不带引号→204)。
- S3 的 ETag 是带引号 md5,
quoteETag对 S3 正确;WebDAV 侧则相反。 - 修复方向:WebDAV 的
If-Match应回传服务端list()给出的 etag 原样(不 force-quote),
因为webdav库的item.etag即服务端认可的 token。需在坚果云 和一个 RFC 规范服务端
(如 Nextcloud)上都验证后再定,避免"修了坚果云、坏了 Nextcloud"。
W2/W3/S2/S3d(能力过度声明)修复方向:
- S3
supportsConditionalDelete应为false(S3 API 无条件删除语义,恒不生效)。 - S3
supportsCreateOnly依赖服务端版本(If-None-Match:*支持);WebDAV 三项均因服务端而异。
→ 要么保守默认 false、要么连接时探测能力,避免同步层依赖服务端并不兑现的"保护"。 - 注意:能力声明 false 时,写入回退为无条件 PUT(≈ PR 之前的行为),这恰恰是坚果云上能工作的路径。
🧪 跨两台真实 WebDAV 服务端对比(能力过度声明的铁证)
同一份 WebDAV provider 代码,对着两台不同真实 WebDAV 服务端跑,都不兑现声明的条件能力,
且失败方式相反——证明"WebDAV capabilities=true"不是单一服务端怪癖,而是普遍不成立:
| 行为 | 坚果云 dav.jianguoyun.com |
LAN 192.168.8.238:5005(Alist 类) |
S3/MinIO(对照) |
|---|---|---|---|
| ETag 响应头 | 无(仅 PROPFIND getetag,不带引号) | 有 ETag: "..."(带引号) |
有 md5 |
| If-Match(当前 etag)写 | ❌ 412(拒带引号) | ✅ 201 接受 | ✅ |
| If-Match(过期 etag)写 | 412(拒一切) | ✅ 201 照写 | ✅ 412 |
| CAS 实际效果 | 不可写(正常更新也 412) | 无保护(stale 也接受→静默覆盖) | 真原子 |
| 并发条件写(G-RACE) | 0 个成功 | 2 个都成功(无防覆盖) | 恰好 1 |
| M5(CAS 冲突保留 digest) | ✅(因恒 412→"冲突") | ❌ 失败(照写→覆盖、digest 被推进) | ✅ |
| L4(跨设备更新) | ❌ 同秒→push→412 卡住 | ✅ 本次不同秒→PULL 成功 | ✅ |
推论:
- 坚果云过度严格(拒绝一切 If-Match)→ CAS 写路径不可用;238/Alist 完全忽略 If-Match → CAS 是静默 no-op。
- 没有任何客户端 quoting 改动能同时救两者(238 根本不看 If-Match)。所以 W1 的正确修法不是只改
quoteETag,而是 WebDAV 不该硬声明能力,应连接时探测或保守默认 false——只有 S3 真正拿到 PR 承诺的原子性。 - M5 的"保护"在 238 上直接落空(照写覆盖),说明 WebDAV 类在真实世界拿不到防覆盖保证。
🟡 M4:矩阵作者本人标注的缺口仍未补
矩阵行"双端都有、云端 digest 未变但本地更新时间更晚 → 不能直接跳过;应补偿 push"。
当前 synchronize.ts:489 在 file_digest[name] === 云端 digest 时直接 return 跳过 →
本地新内容不会经 syncOnce 上云。正常本地编辑走队列 scriptInstall 推送;但若队列 push 失败
(transient),file_digest 不推进、云端 digest 不变,下轮 syncOnce 仍会跳过 → 本地编辑卡住不补偿。
建议按矩阵所写:本地较新时用旧 digest 做条件写补偿 push。
📝 对早前 W1 影响判断的更正(诚实)
我早前说"W1 会让坚果云所有更新推不上去"是不准确的。经两台设备真实流验证:
- 本地编辑走队列
scriptInstall(synchronize.ts:940)用的是 createOnly(非 CAS);坚果云忽略
createOnly → 覆盖成功 → 更新能上云。 syncOnce仅在 push 分支用 CAS,且那里的expectedDigest必为 stale(相等就在:489skip 了),
412 属真冲突分支。- 所以 W1 是 provider 层的确凿事实,但对同步流的直接危害有限;真正咬人的是 L4。
完整性复查(第二轮)—— 对照 docs/cloud-sync.md 补验的盲区
代码层新发现(读码即证):
- N1 —— 云同步不同步 GM 值与资源:
this.value/this.resource只在generateScriptBackupData()
(backup()导出路径)用到,syncOnce/pushScript/pullScript完全不碰。即跨设备只同步脚本代码 +
meta + enable/sort,不同步GM_setValue的值和@resource/@require资源。Tampermonkey 会同步值,
用户可能有落差——设计取舍还是缺口?待确认。 - N2 ——
modifiedDate对 push/pull 判定是死代码:WebDAV writer 完全不引用它;S3 写
x-amz-meta-createtime但list()用LastModified(服务端写入时间)当updatetime(s3.ts:256)。
故syncOnce:494比较的file.updatetime两端都是服务端写入时间,与脚本编辑时间脱钩——这让 L4 更本质:
push/pull 判定本就是"客户端编辑时刻 vs 服务端写入时刻"两种时钟/事件的比较。
盲区 e2e(synchronize-gaps.e2e.test.ts,两 provider):
| 项 | 验证内容 | S3 | 坚果云 |
|---|---|---|---|
| G-ENC | 中文名 + emoji + 日文内容跨设备往返 | ✅ 逐字节一致 | ✅ 逐字节一致(无编码 bug) |
| G-RACE | 真实并发条件写原子性 | ✅ 恰好 1 个成功(CAS 原子、防覆盖有效) | ❌ 0 个成功(quoted If-Match 恒 412,CAS 完全不可用) |
| G-404 | 删除不存在文件幂等成功 | ✅ | ✅ |
| G-MTIME | 云端 mtime 是否=脚本 updatetime | ✅ 实为服务端写入时间 | ✅ 服务端写入时间(整秒精度) |
G-RACE 补充:坚果云 CAS 写两个并发都 412 → 无任何原子防覆盖;叠加 create-only 被忽略(W2)→
坚果云上并发更新 last-writer-wins 静默丢更新。S3 的 CAS 在真实并发下确是原子的(PR 核心价值在 S3 成立)。
仍无法本地验证(需凭据/夹具):OneDrive/Dropbox/Google Drive/Baidu 的真实条件写删与各自坑
(path-cache stale / TOCTOU / filemetas,PR 自己也列为待验证);真实 429 限流的 typed transient 与 Limiter 退避;
S3 list() 分页(>1000 对象)。
已验证"稳"的范围(44 场景,仅 L4 失败)
生命周期 L1–L7(上传/拉取/更新/删除 tombstone/orphan/status)+ 矩阵 M3/M5/M8/M9/M10/M16/M17/
M18/M21/M24 + 故障注入 M12/M13/M14/M23,在坚果云与 MinIO 上均通过。非原子 provider
(Baidu/GoogleDrive/Dropbox)诚实声明能力=false,已有 45 个单测覆盖。
复现方式
# 凭据放仓库外 ~/.config/scriptcat/e2e-sync.env(E2E_WEBDAV_* / E2E_OSS_*)
scripts/e2e-sync.sh webdav # provider 契约(坚果云)
scripts/e2e-sync.sh s3 # provider 契约(MinIO)
scripts/e2e-sync.sh sync # 两台设备生命周期
E2E_ONLY=webdav scripts/e2e-sync.sh matrix # 覆盖矩阵(可 E2E_ONLY 限定单 provider)无 E2E_SYNC_LIVE=1/无凭据时全部自动 skip,pnpm test/CI 不触网。
|
webdav建议不要太严格了,很多webdav的实际实现并不一致,会出现很多问题 |
做最穩的就好 |
不是误删问题(当然这是基础的),可能会导致不能用,已经收到很多次webdav相关的同步问题了 |
有找到是哪裡的問題嗎?可以退回或修改呀 |
|
@cyfung1031 找到了、复现了、也按「最終一致 + 做最穩」的方向在本地改好并验证了。根因不是某处逻辑写错,而是 provider 能力声明与真实服务端不符—— 两类「不能用」(都已本地端到端复现)
处理方案(已实现 + 验证):能力声明退回到「实测可靠」不再假设服务端支持条件写,改成保守默认,让同步回落到无条件读写、以 last-writer-wins 保证最终一致(而不是卡死)。只改三处能力声明,
验证(真实服务端 + 合规参考实现)
除 WebDAV 外还需处理的平台
一个仍需单独跟进的点(非本次「不能用」根因,且涉及冲突策略取舍)
根治要动到冲突判定策略:现有单测「push 已有云端文件时应当用旧 digest 作为 expectedDigest」明确期望「本地较新 → push」,而同秒竞态正是这条规则的误报。要区分「真的较新」与「秒级截断导致的假较新」,得引入基于内容 digest 的本地基线(而非秒级 mtime)——这属于你 PR 的核心判定逻辑,策略由你定,我可以按你选的方向补上(含失败测试 → 修复)。 |
@cyfung1031 这么一看下来,好像没有什么厂商明确支持:"AtomicCAS、createOnly、condDelete",和你的结论有一些出入 做了一轮测试,onedrive支持,AWS S3不清楚但是minio不完全支持,收益不大,能不能直接移除掉 补充下实测数据(本地驱动生产代码栈
所以结论跟你一致:只有 OneDrive 全支持、S3 半支持、WebDAV 完全不支持,收益太小、还带来 WebDAV 卡死 + 合规厂商编辑失败。倾向把整套「能力 / 条件写」直接移除,全线回落无条件读写 + last-writer-wins 最终一致。
|
|
@CodFrm 如果可以的話,最好是告知用戶有衝突,或者覆蓋了 之類的信息 不然用戶不清楚怎麼更新了 如果沒辦法避免衝突覆蓋就退而求其次跟用戶說:我什麼時候覆蓋了,你自己確認一下吧 |
确实应该如此,增加一些同步日志/状态,我一起处理一下 |
真实服务端实测:坚果云对正确 etag 的 If-Match 也恒 412(跨设备更新卡死), Alist 类完全忽略条件头(静默 no-op 无防覆盖),MinIO 忽略 If-None-Match:*, S3 DeleteObject 本无条件删除语义。capability 不能按协议类型硬编码, 探测机制落地前全部声明 false;provider 侧条件头映射代码与单测保留供后续回开。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
本地毫秒 updatetime 与服务端整秒 mtime 属于两个时钟域,对端更新落在同一秒内时 会误走 push 覆盖较新的云端内容(L4)。云端 digest 已变时改由 sync_content_md5 (上次同步成功的本地内容基线,push/pull 均记录)判定方向:本地未变 → pull; 双方都变且内容一致 → 收敛基线;真冲突 → 停走保留旧 digest,聚合通知用户 (一轮一条、同一批冲突不重复),绝不自动覆盖任何一端。无基线时退回时间比较(升级兼容)。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
按 @CodFrm 的真实服务端实测结论逐项复核并落地修复,已推到本分支(HEAD 本次修复1. 能力过度声明(W1/W2/W3/S2 根因)——WebDAV / S3 / OneDrive 三家 capabilities 全线改为
2. L4 同秒竞态(头号 bug)——方向判定不再跨时钟域比较 云端 digest 已变时,不再比较「本地毫秒 updatetime vs 服务端整秒 mtime」,改用本地内容基线
M4(队列 push 失败后不补偿)此前已由「digest 相等但本地更新时间更晚 → 补偿 push」路径覆盖(43e3aea8 有端到端测试),基线机制不影响该路径。 3. 冲突告知(前面讨论的「要让用户知道」) 真冲突一轮同步聚合一条系统通知,列出所有冲突脚本名;同一批冲突后续轮次不重复轰炸,集合变化后重新通知。8 个 locale 文案已补。 4. 文档(docs/cloud-sync.md) 能力关闭的实测依据、内容基线方向判定、冲突停走 + 通知语义、TOCTOU 窗口的诚实声明(无 CAS 时 此前已在分支修复(本次复核确认)
验证
后续项(不阻塞本 PR)
|
背景
本 PR 对 ScriptCat 云同步做一轮生产兼容方向的修复。目标不是把 PR #1439 原样搬进来,而是在现有
main同步语义上修复状态污染、错误吞掉、provider 能力不清晰和条件写入缺失的问题。核心原则:
file_digest,失败文件保留旧 digest,下轮重试。.user.js、旧.meta.json、旧file_digeststring map、缺字段scriptcat-sync.json。总体改动
本 PR 改动集中在 5 类:
scriptcat-sync.json合并写和旧格式兼容涉及文件:
src/app/service/service_worker/synchronize.tssrc/app/service/service_worker/synchronize.test.tspackages/filesystem/**docs/README.md关键行为变化
同步层
pullScript()真实失败不再被静默吞掉。deleteCloudScript()删除失败、tombstone 写失败不再被当作成功。syncOnceInternal()保持 per-file best-effort,单个文件失败不会阻塞其他文件。file_digest。scriptcat-sync.json写回前重新读取远端最新状态并合并,降低覆盖其他设备状态的风险。.user.jswithout.meta.json会跳过,并保留远端 status。scriptInstall/scriptsDelete队列路径的 typed failure 能被分类记录,不污染 digest。filesystem 能力
新增最小 provider capabilities:
同步层只在 provider 显式声明能力时传条件参数:
这样 WebDAV / S3 / OneDrive 可以使用原生条件写删,Google Drive / Dropbox / Baidu 继续保持非 atomic 行为,不伪造 CAS。
provider typed error
已补关键 provider 错误分类:
PreconditionFailed/ 429 / 5xxResponse404 / 409 / 412 / 429Response404 / 409 / 412 / 429,reader path lookup miss typed notFoundpath_lookup/pathnot_found / conflict,raw read 429,保留content_hashopaque digestretry 策略
LimiterFileSystem只重试:verify/open/read/openDir/list/getDirUrlexpectedDigest/createOnlyexpectedDigest普通
write/delete/create不重试,避免非幂等写重复创建或覆盖。Commit 说明
docs(sync): document cloud sync correctness designtest(sync): cover failed task digest preservationfix(sync): preserve failed task digeststest(sync): cover status merge before writescriptcat-sync.jsonfix(sync): merge latest status before writescriptcat-sync.json写回前重新读取并合并test(sync): cover status sync best efforttest(sync): cover legacy sync status filescriptcat-sync.json兼容测试fix(sync): tolerate legacy sync status filetest(sync): cover status write failure isolation193a5ac7 fix(sync): isolate status file write failuresscriptcat-sync.json写失败test(sync): cover corrupt status file isolationfix(sync): isolate unreadable status filetest(fs): cover dropbox typed errorsfix(fs): use typed dropbox errorstest(fs): cover baidu errno classificationfix(fs): classify baidu errno preciselytest(fs): cover raw response typed errorsnothen=true路径fix(fs): type raw provider response errorstest(fs): cover typed transient retriesfix(fs): retry typed transient errorstest(sync): cover delete notification throttlingfix(sync): throttle delete notificationstest(fs): cover filesystem capabilitiesfix(fs): expose filesystem capabilitiestest(fs): cover dropbox raw read errorsfix(fs): type dropbox raw read errorstest(fs): cover s3 transient errorsfix(fs): type s3 transient errorstest(fs): cover webdav transient errorsfix(fs): type webdav transient errorstest(fs): cover s3 conditional operationsfix(fs): support s3 conditional operationstest(fs): cover webdav conditional operationsfix(fs): support webdav conditional operationstest(fs): cover onedrive conditional operationsfix(fs): support onedrive conditional operationstest(sync): cover conditional push optionsfix(sync): use provider conditional writes when safetest(sync): cover conditional cloud deletefix(sync): use provider conditional delete when safetest(fs): cover webdav create-only conflictfix(fs): type webdav create-only conflicttest(sync): cover status write after file failurefix(sync): preserve failed script status while syncing otherstest(fs): cover s3 conditional conflictsdocs(sync): update implemented provider statustest(sync): cover conflict error classificationfix(sync): classify per-file sync failurestest(fs): cover conditional write retryfix(fs): retry protected conditional writesdocs(sync): reflect protected write retrytest(sync): cover sync error kind mappingtest(sync): cover install push transient failurefix(sync): classify queued sync failuresdocs(sync): record queued failure classification auditdocs(sync): refine remaining rollout plantest(sync): cover queued delete typed failurestest(fs): cover dropbox opaque digestdocs(sync): document manual verification pathfix(fs): classify dropbox structured not founderror_summary的 Dropbox 响应test(fs): cover dropbox structured conflictdocs(sync): keep rollout checklist currenttest(fs): cover non-atomic provider capabilitiesdocs(sync): record non-atomic provider coveragefix(sync/fs): address remaining provider verification gapsdocs(sync): record provider gap closuredocs(sync): record manual verification resultdocs(sync): record real provider verificationfix(sync/fs): address real provider verification findingsdocs(sync): finalize rollout status生产兼容性
本 PR 不要求用户手动迁移云端数据。
兼容旧数据:
.user.js/.meta.json可继续读取。file_digeststring map 可继续读取。scriptcat-sync.json缺字段时不会崩溃。.user.jswithout.meta.json会跳过并保留 status。风险和暂不实现项
仍需真实 provider 验证:
filemetas缺失、errno 分类和覆盖写行为。本轮暂不实现:
tombstone_digest。