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 104 commits into
Conversation
真实 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)
|
|
@cyfung1031 按你提的「最好告知用户有冲突或者覆盖了」补了一轮覆盖/冲突可见性,已在本地实现并跑通 e2e 验证。都落在现有「脚本同步」设置卡片里,没有新增页面: 做了什么
关于「退而求其次告诉用户我什么时候覆盖了」: 本地 e2e(真实扩展)4 项全过:状态条四态 + 边界(未同步/未启用)+ 深链按 真实 provider「产生覆盖/冲突」的那一轮仍按本 PR 既定口径用单测(mock fs)覆盖:无基线判定 → overwrite 日志、聚合通知一轮一条去重、
|
|
更新到最新 HEAD 对评论里「有问题就显示出来,不要隐藏」的处理
覆盖文案也不再声称固定方向,只告知发生了可检测的覆盖,并让用户进日志确认具体脚本和时间。纯 TOCTOU 的 last-writer-wins 仍然无法由客户端可靠察觉,这个边界继续在文档中明确说明。 最新 HEAD 验证结果
真实扩展 e2e 重新覆盖了:
真实 provider 复验本轮使用当前生产 provider 代码,对四个真实服务重新执行了临时夹具往返:连接校验 → v1 写入 → list/digest → 读取 → v2 无条件覆盖 → 再次 list/read → digest/eTag 变化 → 删除 → 清理。
OneDrive 这次也用了真实 Graph App Root 和真实 结论边界这轮可以确认:移除条件写删后,四个真实 provider 的基础无条件读写往返正常; 但本轮没有用两个独立扩展 profile 重新跑完整 |
- 补偿 push 与无基线回退比较前两侧截断到整秒:同秒内毫秒余数不再误判"本地更新", 消除真实 provider 上每次编辑后的冗余覆盖 push;无基线回退同秒改判 pull - 无基线兜底的 overwrite 日志/通知在写入成功后才登记:失败轮不谎报覆盖, 去重键也不会把下一轮真覆盖静默掉 - classifySyncError 移除 auth 冗余分支与已无真实来源的 unsupported 分类 - 补 counts.failed / counts.conflict / 顶层 error 状态回归测试
SW 起始写只清 error 不清 counts,failed>0 判定需让位于 syncing; error 字符串仍最高优先(起始写已清 error,实际不会与 syncing 并存)
…iptscat#1504 - Google Drive 403 + reason=rateLimitExceeded/userRateLimitExceeded 转 typed rateLimit/retryable(官方建议指数退避,不能归为永久失败) - WebDAV 409 在 RFC 4918 中是父集合不存在等前置问题,不再判 conflict,仅保留 412
- 决策规则/回退规则补整秒对齐语义(同秒不判本地更新、回退同秒判 pull) - 覆盖日志说明改为写入成功后登记 - 删除「日志按 LogCleanCycle 自动清理」声明(LoggerDAO.deleteBefore 无调用点) - 错误分类表删除已无来源的 unsupported 行;Zip digest 表述改为空
结论复查最新 HEAD 已经修好的重点目前以下方向基本处理正确:
这些修复说明整体设计方向已经比较稳定。之前提出的覆盖误报、PR 描述过时和同秒时间误判问题不再作为阻塞项。 剩余风险主要集中在:
问题一:只判断
|


背景
本 PR 在现有
main的 per-file best-effort 同步语义上,修复云同步状态污染、错误吞掉、provider 错误分类不完整以及冲突/覆盖不可见的问题。核心原则:
主要改动
同步状态与兼容
pullScript()、云端删除和 tombstone 写入的真实失败不再静默当作成功。file_digest;分片上传部分成功时只推进已成功文件。scriptcat-sync.json写回前重新读取并合并远端最新状态,兼容缺字段旧格式和旧file_digeststring map。.user.jswithout.meta.json会跳过并保留远端状态。方向判定与可见性
sync_content_md5记录上次同步成功的本地内容基线,减少客户端毫秒时钟与服务端整秒 mtime 的跨时钟误判。provider typed error 与 retry
FileSystemError。rateLimitExceeded/userRateLimitExceeded。verify/open/read/openDir/list/getDirUrl)会对 typed rate-limit/瞬时错误退避重试。write/delete/create不重试,避免非幂等操作重复执行。生产兼容性与边界
.user.js/.meta.json、缺字段scriptcat-sync.json和旧 digest map 继续可读。list → decision → writeTOCTOU 窗口,最后写入者获胜。验证
docs/cloud-sync.md。