Skip to content

Fix cloud sync state consistency and provider-safe conflict handling#1504

Open
cyfung1031 wants to merge 104 commits into
scriptscat:mainfrom
cyfung1031:pr/sync-fix/100
Open

Fix cloud sync state consistency and provider-safe conflict handling#1504
cyfung1031 wants to merge 104 commits into
scriptscat:mainfrom
cyfung1031:pr/sync-fix/100

Conversation

@cyfung1031

@cyfung1031 cyfung1031 commented Jun 12, 2026

Copy link
Copy Markdown
Collaborator

背景

本 PR 在现有 main 的 per-file best-effort 同步语义上,修复云同步状态污染、错误吞掉、provider 错误分类不完整以及冲突/覆盖不可见的问题。

核心原则:

  1. 保持 per-file best-effort,单个文件失败不阻塞其他文件。
  2. 成功文件推进自己的 digest,失败文件保留旧 digest,下轮重试。
  3. 旧云端数据继续可读,无需手工迁移。
  4. provider 层只负责 typed error 和原生 digest,不承担同步冲突策略。
  5. 当前所有 provider 均使用普通覆盖写;不宣称、不伪造 atomic CAS/条件写删能力。

主要改动

同步状态与兼容

  • pullScript()、云端删除和 tombstone 写入的真实失败不再静默当作成功。
  • 失败文件不推进 file_digest;分片上传部分成功时只推进已成功文件。
  • scriptcat-sync.json 写回前重新读取并合并远端最新状态,兼容缺字段旧格式和旧 file_digest string map。
  • orphan .user.js without .meta.json 会跳过并保留远端状态。
  • 队列触发的安装/删除只更新当前 uuid 的 digest,避免盖章未对账文件。

方向判定与可见性

  • 使用 sync_content_md5 记录上次同步成功的本地内容基线,减少客户端毫秒时钟与服务端整秒 mtime 的跨时钟误判。
  • 云端已变且本地未变时 pull;两端内容一致时直接收敛基线;两端都变时停走并聚合通知,不自动覆盖任何一端。
  • 无内容基线的升级兼容路径使用整秒对齐的时间比较,并在真实写入成功后记录覆盖日志/通知。
  • 设置页展示同步中、失败、冲突和覆盖计数;“立即同步”构建文件系统失败时不再静默。

provider typed error 与 retry

  • WebDAV、S3、OneDrive、Google Drive、Dropbox、Baidu 的关键 notFound/conflict/auth/rate-limit/瞬时 5xx 转为 FileSystemError
  • Google Drive 兼容 403 rateLimitExceeded / userRateLimitExceeded
  • 只有 read-like 操作(verify/open/read/openDir/list/getDirUrl)会对 typed rate-limit/瞬时错误退避重试。
  • 普通 write/delete/create 不重试,避免非幂等操作重复执行。

生产兼容性与边界

  • .user.js / .meta.json、缺字段 scriptcat-sync.json 和旧 digest map 继续可读。
  • Google Drive / Dropbox / Baidu / WebDAV / S3 / OneDrive 都不宣称 atomic CAS。普通 push 仍存在 list → decision → write TOCTOU 窗口,最后写入者获胜。
  • 真实 provider 的 OAuth、限流、路径缓存和服务端兼容性仍需在有账号/夹具的环境持续验证。

验证

  • 单元测试覆盖同步状态、digest 收敛、冲突/覆盖可见性、provider typed error 与 retry 边界。
  • 云同步设计与当前实现详见 docs/cloud-sync.md

@CodFrm

CodFrm commented Jul 13, 2026

Copy link
Copy Markdown
Member

真实 provider 本地 e2e 验证结果 + 修复方案分析

在真实 provider(两台 WebDAV:坚果云 + 一台 LAN Alist 类 + 局域网 MinIO)上驱动生产代码栈 FileSystemFactory → LimiterFileSystem → provider 跑了 44+ 个端到端场景(外加完整性复查与跨服务端对比),专门覆盖 mock 同步层单测无法证明的条件写/CAS/跨设备往返部分。只验证、未改业务代码。

(本评论已更新:补入第二台真实 WebDAV 服务端的跨服务端对比,能力过度声明从单点观察变为铁证;并确认 L4 为同秒时序竞态。)

TL;DR

  • 同步层逻辑本身很稳:7 个生命周期 + 15 个矩阵场景,在两个真实 provider 上 44/44 里
    只有 1 个真失败。CAS 冲突保留 digest、tombstone、orphan、损坏/旧格式 sync.json 隔离、
    故障注入部分失败、原生 digest 等全部正确。
  • 🔴 头号真实 bug(L4):跨设备更新在同一秒时序竞态下收不到——本地 ms updatetime vs
    服务端整秒 mtime 让 syncOnce 误走 push 分支。命中时坚果云→412 卡住停滞、238→静默覆盖丢更新;
    错开一秒则正常(已在第二台服务端上因不同秒而通过,证明是同秒竞态)。
  • ⚠️ provider 能力过度声明(现有两台真实 WebDAV 服务端的铁证):WebDAV/S3/OneDrive 把三项条件能力
    硬编码为 true,但两台真实 WebDAV 都不兑现、且方式相反(坚果云拒绝一切 If-Match→412;
    另一台 Alist 类完全忽略 If-Match→照单全收无防覆盖)。S3 的 CAS 真生效,但 create-only 视版本、
    conditional-delete 因 API 无此语义而必不生效。
  • 🟡 矩阵作者标注的缺口(M4)仍在syncOnce 在云端 digest 未变时直接跳过,不补偿
    push 本地更新(依赖队列路径;队列 push 失败则无补偿)。
  • 🆕 N1:云同步不同步 GM 值和资源——syncOnce 只同步代码 + meta + enable/sort,
    GM_setValue 的值与 @resource/@require 不跨设备同步this.value/this.resource 只在
    backup() 路径用)。是否为有意取舍待确认。
  • 🆕 N2 + G-RACEmodifiedDate 对 push/pull 判定是死代码(两端都用服务端写入时间),
    且并发条件写在坚果云上全 412(CAS 完全不可用)→ 并发更新 last-writer-wins 静默丢更新;S3 的 CAS 真原子。
  • 📝 我早前口头说的"W1 让坚果云所有更新推不上去"不准确,已在下方更正。

🔴 L4:坚果云跨设备更新收不到(头号 bug)

复现(A 改脚本→更新上了云,但 B syncOnce 拉不到,抛两个 412):

provider 本地 updatetime(客户端 ms) 云端文件 mtime(服务端) Δ 分支 结果
坚果云 1783934916497 1783934916000整秒 +497ms push→CAS 412 ❌ B 停在旧版本
MinIO 1783934922693 1783934923222(含毫秒) −529ms pull ✅ B 收到新版

根因synchronize.ts:494
script.updatetime > file.script!.updatetime 决定 push/pull。左边是本地客户端时钟、毫秒精度
右边是服务端文件 mtime——WebDAV 的 mtime 源自 HTTP Last-Modified/getlastmodified,是
整秒精度...916000)。当对端更新落在与本地副本 updatetime 相同的一秒内,毫秒余数让本地
看起来更新 → 误走 push 分支(本应 pull)→ 坚果云 CAS 412 → B 收不到更新,且每轮都重复 412 不自愈
MinIO 的 LastModified 带毫秒精度,比较顺序正确,故不触发。整秒精度是 HTTP Last-Modified 的固有属性,
所有 WebDAV 服务端都有此风险。第二台 WebDAV 服务端(238)本轮因 B 安装秒 ≠ A 更新秒而正确 PULL 通过
反证这是"同一秒"时序竞态:触发窄,但命中时坚果云→412 卡住停滞、238→静默覆盖丢更新(数据丢失方向不同)。

修复方向(未实施,供决策)

  1. (推荐,治本)不要跨时钟域比较墙钟时间。push/pull 的判定应基于"变化发生在本地还是云端
    (相对上次同步点)",而非比较本地时钟 vs 服务端 mtime。可用已有的 digest 信号:若
    file_digest[name] !== 云端当前 digest(云端变了)且本地自上次同步未变 → pull;本地也变了 → 冲突。
    即引入/复用 per-uuid 的"上次同步 digest",用它判定方向。
  2. (最小改动,治标)比较前把两侧对齐到整秒Math.floor(local/1000)*1000 > cloudMtimeSec
    能消除"同一秒"误判,但仍无法解决客户端/服务端多秒时钟偏移。
  3. 二者可叠加:先用 digest 判"云端是否变化",再用带容差的时间比较兜底。

⚠️ provider 能力过度声明(capability over-claim)

WebDAV/S3/OneDrive 均把 supportsAtomicCompareAndSwap / supportsCreateOnly / supportsConditionalDelete 硬编码为 true(对所有该类服务端)。真实结果:

条件操作 坚果云 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(quoteETagpackages/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:489file_digest[name] === 云端 digest 时直接 return 跳过 →
本地新内容不会经 syncOnce 上云。正常本地编辑走队列 scriptInstall 推送;但若队列 push 失败
(transient),file_digest 不推进、云端 digest 不变,下轮 syncOnce 仍会跳过 → 本地编辑卡住不补偿
建议按矩阵所写:本地较新时用旧 digest 做条件写补偿 push。


📝 对早前 W1 影响判断的更正(诚实)

我早前说"W1 会让坚果云所有更新推不上去"是不准确的。经两台设备真实流验证:

  • 本地编辑走队列 scriptInstallsynchronize.ts:940)用的是 createOnly(非 CAS);坚果云忽略
    createOnly → 覆盖成功 → 更新能上云
  • syncOnce 仅在 push 分支用 CAS,且那里的 expectedDigest 必为 stale(相等就在 :489 skip 了),
    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-createtimelist()LastModified(服务端写入时间)当 updatetimes3.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 不触网。

@CodFrm

CodFrm commented Jul 13, 2026

Copy link
Copy Markdown
Member

webdav建议不要太严格了,很多webdav的实际实现并不一致,会出现很多问题

@cyfung1031

Copy link
Copy Markdown
Collaborator Author

webdav建议不要太严格了,很多webdav的实际实现并不一致,会出现很多问题

做最穩的就好
不要誤刪
有問題就顯示出來不要隱藏
讓用戶自己處理
不需要代碼自以為是

@CodFrm

CodFrm commented Jul 13, 2026

Copy link
Copy Markdown
Member

webdav建议不要太严格了,很多webdav的实际实现并不一致,会出现很多问题

做最穩的就好

不要誤刪

有問題就顯示出來不要隱藏

讓用戶自己處理

不需要代碼自以為是

不是误删问题(当然这是基础的),可能会导致不能用,已经收到很多次webdav相关的同步问题了

@cyfung1031

Copy link
Copy Markdown
Collaborator Author

webdav建议不要太严格了,很多webdav的实际实现并不一致,会出现很多问题

做最穩的就好
不要誤刪
有問題就顯示出來不要隱藏
讓用戶自己處理
不需要代碼自以為是

不是误删问题(当然这是基础的),可能会导致不能用,已经收到很多次webdav相关的同步问题了

有找到是哪裡的問題嗎?可以退回或修改呀

@CodFrm

CodFrm commented Jul 13, 2026

Copy link
Copy Markdown
Member

@cyfung1031 找到了、复现了、也按「最終一致 + 做最穩」的方向在本地改好并验证了。根因不是某处逻辑写错,而是 provider 能力声明与真实服务端不符——webdav / s3 / onedrive 三家都无条件声明支持「原子 CAS / createOnly / 条件删除」,但真实服务端对这些条件头的支持极不一致,于是同一份代码在不同服务器上朝相反方向坏

两类「不能用」(都已本地端到端复现)

  1. scriptInstall 对已存在文件用 createOnly(If-None-Match:* — 每次编辑/自动更新已同步脚本都走这里(synchronize.ts:940)。服务器若遵守 If-None-Match:*(RFC 合规实现,含 OneDrive/Graph 的 conflictBehavior:fail)→ 412 → 编辑写不上去 → 随后 syncOnce 因 digest 未变而跳过 → 改动永远同步不出去。最高频、最静默。

    用一台 RFC 合规参考 WebDAV 复现:首传成功,从第二次编辑起 create-only write conflict 412

  2. syncOnce 推送走 If-Match CAS(:499 → :840 — 服务器若拒绝 If-Match(坚果云对正确 etag 也恒 412)→ 跨设备更新既推不上也拉不下,卡死。

处理方案(已实现 + 验证):能力声明退回到「实测可靠」

不再假设服务端支持条件写,改成保守默认,让同步回落到无条件读写、以 last-writer-wins 保证最终一致(而不是卡死)。只改三处能力声明,buildPushCreateOptions 会据此自动停发条件头,同步主逻辑一行没动

provider AtomicCAS createOnly condDelete 依据
WebDAV 坚果云拒 If-Match、各家忽略另两项;无一可靠
S3 MinIO 实测 If-Match CAS 真原子;createOnly/条件删被忽略
OneDrive 未在真实 Graph 验证;且 Graph 越合规越会触发 createOnly 412
Dropbox / GoogleDrive / 百度 / Zip — 本就默认全 false,无此问题

验证(真实服务端 + 合规参考实现)

场景 修复前 修复后
合规 WebDAV L3(编辑推送) ❌ createOnly 412,改动丢 ✅ 无条件覆盖,同步成功
坚果云 L1–L7 生命周期 L4 恒 412 卡死 7/7 通过
MinIO S3 L1–L7 + CAS 契约 ✅(CAS 有效) ✅ 7/7,CAS 仍原子
单元测试 packages/filesystem + synchronize 229 passed

除 WebDAV 外还需处理的平台

  • S3:同样过度声明。已改为仅保留 CAS(实测可靠且有价值),关掉 createOnly/条件删除(被忽略、无保护)。
  • OneDrive:同样过度声明,且因为 Graph 更合规,反而更可能命中 createOnly 412 卡死。手上无真实凭据验证,先保守全关;拿到测试账号再逐项验证回开。
  • Dropbox / GoogleDrive / 百度网盘 / 本地 Zip:本就默认全 false,无此问题。

一个仍需单独跟进的点(非本次「不能用」根因,且涉及冲突策略取舍)

synchronize.ts:494 的 push/pull 判定用「本地毫秒 updatetime vs 服务端整秒 mtime」,同一秒会误判成 push。能力关闭后它不再 412 卡死,但同秒并发下会退化成「无条件覆盖 → 设备 B 拉不到较新版本」。

根治要动到冲突判定策略:现有单测「push 已有云端文件时应当用旧 digest 作为 expectedDigest」明确期望「本地较新 → push」,而同秒竞态正是这条规则的误报。要区分「真的较新」与「秒级截断导致的假较新」,得引入基于内容 digest 的本地基线(而非秒级 mtime)——这属于你 PR 的核心判定逻辑,策略由你定,我可以按你选的方向补上(含失败测试 → 修复)。

@CodFrm

CodFrm commented Jul 13, 2026

Copy link
Copy Markdown
Member

这样 WebDAV / S3 / OneDrive 可以使用原生条件写删,Google Drive / Dropbox / Baidu 继续保持非 atomic 行为,不伪造 CAS。

@cyfung1031 这么一看下来,好像没有什么厂商明确支持:"AtomicCAS、createOnly、condDelete",和你的结论有一些出入


做了一轮测试,onedrive支持,AWS S3不清楚但是minio不完全支持,收益不大,能不能直接移除掉


补充下实测数据(本地驱动生产代码栈 FileSystemFactory → LimiterFileSystem → provider 打真实服务端,非 mock):

条件原语 坚果云 Alist 类 MinIO AWS S3 OneDrive
AtomicCAS(If-Match 写) ❌ 恒 412(正确 etag 也拒) ❌ 忽略→覆盖 ⚠️ 文档称支持、未实测
createOnly(If-None-Match:* ❌ 忽略→覆盖 ❌ 忽略→覆盖 ❌ 忽略→覆盖 ⚠️ 文档(2024 新增)
condDelete(If-Match 删) ❌ 忽略→照删 ❌ 忽略→照删 ❌ 忽略→照删 ❌ 基本不支持
  • WebDAV:两台真机(坚果云 + 一台 Alist 类)三项全不兑现,方向还相反(一个恒拒、一个忽略)。坚果云 If-Match 恒 412 → 跨设备更新卡死,这就是「不能用」的根。
  • S3:MinIO 只有 CAS 真原子,createOnly / 条件删除都被忽略。
  • OneDrive:三项都真生效(412/409,内容与文件都正确保留)。但这反而暴露另一个坑——scriptInstall 对已存在文件误用 createOnly,在 OneDrive(及任何合规服务端)上会 409 → 每次编辑推不上去。也就是说 createOnly「越被支持越坏事」。

所以结论跟你一致:只有 OneDrive 全支持、S3 半支持、WebDAV 完全不支持,收益太小、还带来 WebDAV 卡死 + 合规厂商编辑失败。倾向把整套「能力 / 条件写」直接移除,全线回落无条件读写 + last-writer-wins 最终一致。

  • 保留:file_digest 变更检测、tombstone、scriptcat-sync.json 状态合并、best-effort 部分失败保留旧 digest(这些是状态一致性,和条件写无关)。
  • 代价:S3/OneDrive 上真能用的 CAS、OneDrive 的条件删除一起没了 → 只有「多设备同一秒改同一脚本」这种罕见场景退化为后写覆盖;WebDAV 用户零损失、反而不再卡死。

@cyfung1031

Copy link
Copy Markdown
Collaborator Author

@CodFrm 如果可以的話,最好是告知用戶有衝突,或者覆蓋了 之類的信息

不然用戶不清楚怎麼更新了
我對同步處理不太熟悉

如果沒辦法避免衝突覆蓋就退而求其次跟用戶說:我什麼時候覆蓋了,你自己確認一下吧

@CodFrm

CodFrm commented Jul 14, 2026

Copy link
Copy Markdown
Member

@CodFrm 如果可以的話,最好是告知用戶有衝突,或者覆蓋了 之類的信息

不然用戶不清楚怎麼更新了 我對同步處理不太熟悉

如果沒辦法避免衝突覆蓋就退而求其次跟用戶說:我什麼時候覆蓋了,你自己確認一下吧

确实应该如此,增加一些同步日志/状态,我一起处理一下

cyfung1031 and others added 3 commits July 14, 2026 23:03
真实服务端实测:坚果云对正确 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>
@cyfung1031

Copy link
Copy Markdown
Collaborator Author

@CodFrm 的真实服务端实测结论逐项复核并落地修复,已推到本分支(HEAD c75ea8bc)。

本次修复

1. 能力过度声明(W1/W2/W3/S2 根因)——WebDAV / S3 / OneDrive 三家 capabilities 全线改为 false

  • 依据跨服务端实测:坚果云对正确 etagIf-Match 也恒 412、Alist 类完全忽略条件头(静默 no-op)、MinIO 忽略 If-None-Match: *、S3 DeleteObject 本无条件删除语义、OneDrive 全支持反而暴露 createOnly 误用会 409 卡死编辑。capability 按协议类型硬编码的假设整体不成立。
  • 同步层据此自动停发全部条件头,回落无条件读写 + last-writer-wins 最终一致——坚果云跨设备卡死、合规服务端编辑 412 两类「不能用」一并解除。
  • provider 侧条件头映射代码与单测保留(inert),供后续「按 endpoint 实测探测能力」方案回开;若倾向整层直接移除,我可以再提一个纯删除的 commit。

2. L4 同秒竞态(头号 bug)——方向判定不再跨时钟域比较

云端 digest 已变时,不再比较「本地毫秒 updatetime vs 服务端整秒 mtime」,改用本地内容基线 sync_content_md5(上次同步成功时的本地内容 md5,push / pull 均记录):

本地(相对基线) 行为
未变 pull(同秒竞态下不再误 push 覆盖较新云端)
已变、但与云端内容一致 收敛基线,不产生写操作
已变、与云端不一致(真冲突) 停走:保留旧 digest 与云端 status,不覆盖任何一端,通知用户
无基线(升级前旧数据) 退回旧时间比较规则,平滑迁移

M4(队列 push 失败后不补偿)此前已由「digest 相等但本地更新时间更晚 → 补偿 push」路径覆盖(43e3aea8 有端到端测试),基线机制不影响该路径。

3. 冲突告知(前面讨论的「要让用户知道」)

真冲突一轮同步聚合一条系统通知,列出所有冲突脚本名;同一批冲突后续轮次不重复轰炸,集合变化后重新通知。8 个 locale 文案已补。

4. 文档(docs/cloud-sync.md)

能力关闭的实测依据、内容基线方向判定、冲突停走 + 通知语义、TOCTOU 窗口的诚实声明(无 CAS 时 list → write 之间 last-writer-wins,同步层无法察觉),以及功能范围声明:GM storage 值与 @require/@resource 资源不参与云同步(N1 列为有意取舍,另案设计)。

此前已在分支修复(本次复核确认)

  • scriptInstall 更新误当新增:队列路径已传 fileDigestMap,已同步过的脚本不再走 createOnly(ad8a4939,含测试)——即合规服务端「每次编辑 409/412」的语义错误已修,能力关闭是在此之上的第二道保险。
  • pull 后本地 updatetime 采用云端文件时间,消除补偿 push 振荡(4819c76d)。
  • 自我 412 收敛、部分失败 digest 不污染、Baidu 2xx 非 JSON / Dropbox 409 收窄 / 501 不重试等错误分类修正见分支提交历史。

验证

  • 全量 pnpm test:290 文件 / 3190 用例全部通过;prettier + tsc + eslint 干净。
  • 新增 6 个回归测试:同秒竞态必须 pull、真冲突不覆盖 + 保留 digest + 通知一次、冲突多轮去重、内容一致自动收敛、无基线退回时间规则、pull 记录基线;另有 3 个 provider capability 断言改为全 false。
  • 局限:以上是 mock / 单测层验证。麻烦 @CodFrm 有空用你本地的 e2e-sync 流程对坚果云 / MinIO / 合规 WebDAV 再压一轮——能力关闭 + 基线判向后,L1–L7 与 L4 同秒场景预期应全通过,真冲突场景应看到停走 + 一条聚合通知。

后续项(不阻塞本 PR)

  • 按账号/endpoint 实测探测条件能力后逐项回开(S3 CAS、OneDrive 三项都有真实价值)。
  • GM values / resources 同步另开 feature PR(隐私、容量、二进制、多端 merge 需单独设计)。
  • 你提到的同步日志/状态面板可以在冲突通知的基础上继续做。

@CodFrm

CodFrm commented Jul 16, 2026

Copy link
Copy Markdown
Member

@cyfung1031 按你提的「最好告知用户有冲突或者覆盖了」补了一轮覆盖/冲突可见性,已在本地实现并跑通 e2e 验证。都落在现有「脚本同步」设置卡片里,没有新增页面:

做了什么

  1. 同步状态条(卡片顶部):正常 / 同步中 / 有覆盖或冲突(琥珀警示)/ 失败 四态;数据来自每轮同步写入的 cloud_sync_state(设备本地,chrome.storage + onChanged 实时刷新)。
  2. 覆盖单独标记:方向判定的「无内容基线兜底」分支(可能覆盖未知改动)单独打 warn 日志 action:overwrite,并聚合一条通知(仿冲突的一轮一条去重)。
  3. 查看日志深链:状态条 /通知点击 → #/logs?query=… 预过滤 service=synchronize(覆盖态再加 action=overwrite),直接看到「什么时候覆盖了哪个脚本」,自己确认。
  4. 立即同步 / 保存 按钮。

关于「退而求其次告诉用户我什么时候覆盖了」overwrite 只在可检测的无基线兜底场景标记;纯 TOCTOU last-writer-wins 客户端无法察觉,doc 里写明了边界。真冲突仍是停走 + 聚合通知(已有)。

本地 e2e(真实扩展)4 项全过:状态条四态 + 边界(未同步/未启用)+ 深链按 service+action 过滤 + cloudSyncOnce 端点。验证中还发现并修掉一个缺陷——立即同步遇到不可达服务端会静默失败(未捕获异常、状态条无反应),已改为写入 error 状态 + toast 提示,复验通过。

真实 provider「产生覆盖/冲突」的那一轮仍按本 PR 既定口径用单测(mock fs)覆盖:无基线判定 → overwrite 日志、聚合通知一轮一条去重、cloud_sync_state 计数写入;需账号/夹具才能做真云端 e2e。

deeplink-filtered-logs state-warning

@CodFrm

CodFrm commented Jul 16, 2026

Copy link
Copy Markdown
Member

更新到最新 HEAD fbad7394 后重新核对了本 PR 的讨论点,并把本地可验证范围全部重跑了一遍。

对评论里「有问题就显示出来,不要隐藏」的处理

fbad7394 补齐了两个容易误导用户的状态:

  1. 单个文件同步失败时,即使本轮没有顶层 error,只要 cloud_sync_state.counts.failed > 0,设置页状态必须显示 失败,不能显示成同步正常。
  2. 日志深链只有在“纯覆盖”时才附加 action=overwrite;只要本轮含 conflict 或 failed,就只过滤 service=synchronize,避免把真正需要用户处理的冲突/失败日志隐藏掉。

覆盖文案也不再声称固定方向,只告知发生了可检测的覆盖,并让用户进日志确认具体脚本和时间。纯 TOCTOU 的 last-writer-wins 仍然无法由客户端可靠察觉,这个边界继续在文档中明确说明。

最新 HEAD 验证结果

  • pnpm run typecheck:通过。
  • 全量 Vitest:304 files / 3346 tests 全部通过
  • Rspack 开发构建:通过(只有 Monaco 动态 require 的既有 warning)。
  • 真实扩展 scratch e2e:4/4 通过

真实扩展 e2e 重新覆盖了:

  • idle / syncing / warning / error 四态与未启用边界;
  • 纯覆盖:warning,日志深链含 service=synchronize + action=overwrite
  • 含冲突:warning,但深链不带 overwrite 过滤;
  • 文件级失败:error,即使顶层 error 为空,深链也不带 overwrite 过滤;
  • 真实 SW logger → LoggerDAO → 日志页的 service/action 深链过滤;
  • cloudSyncOnce 未启用时安全 no-op;
  • “立即同步”遇到不可达 WebDAV 时写入 error、恢复 syncing:false、toast/状态条可见,页面无未捕获异常。

真实 provider 复验

本轮使用当前生产 provider 代码,对四个真实服务重新执行了临时夹具往返:连接校验 → v1 写入 → list/digest → 读取 → v2 无条件覆盖 → 再次 list/read → digest/eTag 变化 → 删除 → 清理。

服务 创建/读取 无条件覆盖 digest/eTag 更新 删除与清理 结果
坚果云 WebDAV 通过
第二台 WebDAV(LAN) 通过
S3 / MinIO(LAN) 通过
OneDrive / Microsoft Graph 通过

OneDrive 这次也用了真实 Graph App Root 和真实 eTag,不是 mock。四路测试均使用唯一临时目录/对象,结束后已清理,没有碰现有同步数据。

结论边界

这轮可以确认:移除条件写删后,四个真实 provider 的基础无条件读写往返正常;fbad7394 的失败/冲突/覆盖可见性在真实扩展中正常。

但本轮没有用两个独立扩展 profile 重新跑完整 SynchronizeService 跨设备 L1–L7,所以不会把这次 provider 往返扩大描述成同秒竞态、tombstone、status merge、多文件 best-effort 等完整真实云矩阵都已复验。相关业务状态机本轮由全量单测覆盖;如合并前仍要求最新 HEAD 的双端 L1–L7,我可以再按两个 profile 单独跑一轮并回报。

CodFrm added 4 commits July 17, 2026 10:33
- 补偿 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 表述改为空
@cyfung1031

cyfung1031 commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator Author

结论

复查最新 HEAD 38ded05 后,大部分讨论中提出的问题已经修复,但仍有两个可能导致同步状态被错误确认、失败操作无法自动恢复的关键缺口。建议在合并前继续修改。

已经修好的重点

目前以下方向基本处理正确:

  • 已移除不可靠的 provider 条件写、条件删除和 capability 声明。WebDAV、S3、OneDrive 等 provider 统一使用普通覆盖写,避免因不同服务端对 If-MatchIf-None-Match 支持不一致而导致同步不可用。

  • 已使用 sync_content_md5 判断本地内容是否真的发生修改,避免仅通过本地毫秒时间和云端秒级时间判断同步方向。

  • 本地时间和云端时间现在会统一截断到整秒后比较。同一秒内的毫秒差不再被误判为本地更新,避免刚上传完成后又触发一次无意义的补偿 push;无内容基线时,同秒也会优先 pull。

  • 本地和云端都修改时不再自动覆盖,而是保留旧 digest、暂停该脚本同步并提示冲突。

  • 单个文件失败不会阻塞其他文件;失败文件保留旧 digest,成功文件可以推进。

  • scriptcat-sync.json 的旧格式、损坏文件、远端并发更新、远端删除状态被错误复活等问题已有处理。

  • 已增加同步状态、失败提示、冲突通知、覆盖提醒和日志入口,不再把部分失败显示为“同步正常”。

  • 覆盖提醒的登记时机已经修复。现在只有在 push 或 pull 实际成功后,才会记录 overwrite 日志、增加覆盖计数并发送通知。写入失败时不会再误报“已经发生覆盖”,下一轮成功重试后仍会正常提示。

  • PR 描述已经更新,明确当前实现使用普通覆盖写,不宣称支持 atomic CAS 或条件写删,并承认 list → decision → write 之间仍存在 TOCTOU 窗口。

  • Provider 错误分类继续完善:

    • Google Drive 已识别 403 中的 rateLimitExceededuserRateLimitExceeded
    • WebDAV 只对真正的瞬时 5xx 进行重试;
    • 501、505、507 等永久错误不会再重复退避。
  • 当前测试工作流通过。

这些修复说明整体设计方向已经比较稳定。之前提出的覆盖误报、PR 描述过时和同秒时间误判问题不再作为阻塞项。

剩余风险主要集中在:

  1. .user.js.meta.json 文件组的一致性;
  2. 删除操作部分成功后的持久化重试。

问题一:只判断 .user.js,可能把未处理的 .meta.json 标记为已同步

当前主流程判断脚本是否变化时,稳定路径仍然主要检查:

fileDigestMap[file.script.name] === file.script.digest

也就是只比较 <uuid>.user.js 的 digest。

如果 .user.js 没有变化,并且本地时间不比云端源码时间新,就会直接跳过整个 UUID,没有检查 <uuid>.meta.json 的 digest。

因此可能出现以下情况:

  1. .user.js 上传成功,但 .meta.json 上传失败。
  2. 成功的 .user.js digest 被推进,失败的 .meta.json digest 保留旧值。
  3. 下一轮 .user.js digest 与本地记录一致。
  4. 本地时间不比云端 .user.js 时间新。
  5. 同步流程直接跳过整个 UUID。
  6. .meta.json 不再获得重试机会。

这意味着当前实现虽然做到了“失败文件保留旧 digest”,但没有保证“下一轮一定会根据旧 digest 生成重试任务”。

生产路径会使这个问题更容易出现

生产代码通过 publishInstallScript() 发布安装或更新消息时,没有传递 updatetimecreatetime

因此 pushScript() 通常会使用:

script.updatetime || script.createtime || Date.now()

也就是使用队列执行时的 Date.now() 作为云端文件修改时间。

.user.js 成功、.meta.json 失败后,下一轮本地脚本的实际更新时间可能:

  • 早于云端 .user.js 的写入时间;
  • 或者与云端写入时间处于同一秒。

新的整秒比较会将这种情况判断为“本地不比云端新”,从而直接跳过,导致 .meta.json 永久不再重试。

现有相关测试人为设置了更晚的 DAO updatetime,确保第二轮一定再次 push,但没有覆盖实际生产消息不包含时间字段的调用方式。

metadata-only 变化也可能被静默确认

如果其他设备或人工操作只修改了 .meta.json,而 .user.js 没有变化,也可能发生:

  1. .user.js digest 与本地记录一致;
  2. 当前流程直接跳过该 UUID;
  3. .meta.json 没有被读取、应用、合并或重新上传;
  4. 最后的 updateFileDigest() 重新列出云端文件;
  5. 新的 .meta.json digest 被写入本地;
  6. 这份实际上没有处理过的 metadata 被标记为“已同步”。

这会导致后续同步不再处理该变化。

建议修改

应将 .user.js.meta.json 作为同一个同步文件组进行对账:

  • 两个 digest 都没有变化时,才能直接跳过。
  • .meta.json digest 发生变化时,必须明确读取、采用、合并或重新上传。
  • 只有实际读取、写入或明确采用成功的文件才能推进 digest。
  • 或者持久化记录具体失败文件,让下一轮直接重试失败分片,而不是重新依赖 .user.js 的方向判断。

至少应增加以下测试:

  1. .user.js 上传成功,.meta.json 上传失败。
  2. 安装消息不携带 updatetime,与真实 publishInstallScript() 路径一致。
  3. 本地时间早于云端写入时间,或两者处于同一秒。
  4. 第二轮 provider 恢复后,仍必须重新上传 .meta.json
  5. 源码未变但 metadata digest 变化时,不能直接推进 digest 后跳过。

问题二:删除操作部分成功后,没有可靠的后续重试机制

删除云端脚本实际上是多步操作。

syncDelete=true 时:

  1. 删除 <uuid>.user.js
  2. 写入 tombstone <uuid>.meta.json

syncDelete=false 时:

  1. 删除 <uuid>.user.js
  2. 删除 <uuid>.meta.json

如果第一步成功、第二步失败,当前代码会记录本轮失败并保留旧 digest,但没有持久化“这个 UUID 仍有未完成删除操作”的状态。

下一轮不会自动恢复剩余操作

第一轮部分失败后,下一轮可能看到:

  • 本地脚本已经不存在;
  • 云端 <uuid>.user.js 已经成功删除;
  • 云端只剩 <uuid>.meta.json

但当前“本地无脚本”的处理路径只会在云端存在 .user.js 时生成 pull 任务。

对于“本地无脚本、云端只有 .meta.json”的状态,不会生成新的删除或 tombstone 重试任务。

因此可能出现:

  • tombstone 写入失败后永久不再重试;
  • 其他设备无法看到删除标记;
  • 其他设备可能根据残留状态重新上传脚本;
  • syncDelete=false 时,残留的 .meta.json 长期无法清理;
  • 后续全量更新 digest 后,之前保留的失败上下文彻底丢失。

也就是说,file_digest 只能表示文件快照,不能可靠充当“未完成操作队列”。

建议修改

应增加持久化的 pending operation,例如:

pending_sync_operations[uuid] = {
  type: "delete" | "write_tombstone",
  remainingFiles: [...]
}

每轮同步开始时优先处理这些未完成操作,只有全部步骤成功后才清除记录。

至少应增加以下两轮恢复测试:

  1. 第一轮删除 .user.js 成功。
  2. 写 tombstone 或删除 .meta.json 失败。
  3. 本地脚本已经不存在。
  4. Service Worker 重启或进入下一轮同步。
  5. Provider 恢复正常。
  6. 同步必须继续完成剩余的 tombstone 写入或 meta 删除。
  7. 全部成功后才清除 pending 状态和旧 digest。

合并建议

新增 commits 已经解决:

  • 覆盖失败时的误报问题;
  • 同秒时间比较问题;
  • PR 描述与最终实现不一致的问题;
  • 部分 provider 错误分类问题。

这些内容不再需要作为阻塞项提出。

但以下两个核心问题仍然存在:

  1. .user.js.meta.json 没有联合对账,metadata 失败或单独变化可能被永久跳过,并被错误标记为已同步。
  2. 删除操作部分成功后没有持久化重试,未完成的删除或 tombstone 意图可能永久丢失。

它们都会导致:

失败文件虽然保留了旧 digest,但下一轮实际上不一定会产生重试操作。

这会直接影响“失败后能否自动恢复”和“digest 是否真实代表已经完成同步”,属于同步正确性的核心问题。

因此结论仍然是:

建议 Request changes,修复上述两个状态恢复问题后再合并。

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

Labels

CloudSync Related to CloudSync P1 🔥 重要但是不紧急的内容

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants