Skip to content

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

Open
cyfung1031 wants to merge 91 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 91 commits into
scriptscat:mainfrom
cyfung1031:pr/sync-fix/100

Conversation

@cyfung1031

@cyfung1031 cyfung1031 commented Jun 12, 2026

Copy link
Copy Markdown
Collaborator

背景

本 PR 对 ScriptCat 云同步做一轮生产兼容方向的修复。目标不是把 PR #1439 原样搬进来,而是在现有 main 同步语义上修复状态污染、错误吞掉、provider 能力不清晰和条件写入缺失的问题。

核心原则:

  1. 保持 per-file best-effort,不把整轮同步改成 all-or-nothing。
  2. 成功文件可以推进自己的 file_digest,失败文件保留旧 digest,下轮重试。
  3. 旧云端数据继续可读,包括旧 .user.js、旧 .meta.json、旧 file_digest string map、缺字段 scriptcat-sync.json
  4. provider 层只暴露能力和 typed error,不承担同步业务冲突策略。
  5. Google Drive / Baidu / Dropbox 等非原子 provider 不伪装成 atomic CAS。
  6. 真实 provider 验证需要账号和云端夹具,当前本地已记录未执行原因。

总体改动

本 PR 改动集中在 5 类:

  1. 同步层状态污染修复
  2. scriptcat-sync.json 合并写和旧格式兼容
  3. filesystem capabilities、条件写入、条件删除
  4. provider typed error 和 transient retry
  5. 研究文档、验证矩阵和 rollout 状态记录

涉及文件:

  • src/app/service/service_worker/synchronize.ts
  • src/app/service/service_worker/synchronize.test.ts
  • packages/filesystem/**
  • docs/README.md

关键行为变化

同步层

  • pullScript() 真实失败不再被静默吞掉。
  • deleteCloudScript() 删除失败、tombstone 写失败不再被当作成功。
  • syncOnceInternal() 保持 per-file best-effort,单个文件失败不会阻塞其他文件。
  • 失败文件不会推进 file_digest
  • scriptcat-sync.json 写回前重新读取远端最新状态并合并,降低覆盖其他设备状态的风险。
  • orphan .user.js without .meta.json 会跳过,并保留远端 status。
  • scriptInstall / scriptsDelete 队列路径的 typed failure 能被分类记录,不污染 digest。
  • 多个远端 tombstone 删除通知会聚合,不逐文件弹。

filesystem 能力

新增最小 provider capabilities:

supportsAtomicCompareAndSwap
supportsCreateOnly
supportsConditionalDelete

同步层只在 provider 显式声明能力时传条件参数:

FileCreateOptions.expectedDigest
FileCreateOptions.createOnly
FileDeleteOptions.expectedDigest

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

provider typed error

已补关键 provider 错误分类:

  • WebDAV: 429 / 5xx / create-only conflict / conditional delete conflict
  • S3: 412 / PreconditionFailed / 429 / 5xx
  • OneDrive: raw Response 404 / 409 / 412 / 429
  • Google Drive: raw Response 404 / 409 / 412 / 429,reader path lookup miss typed notFound
  • Dropbox: structured path_lookup / path not_found / conflict,raw read 429,保留 content_hash opaque digest
  • Baidu: 精确 errno conflict,reader filemetas miss typed notFound,HTTP 429/5xx typed rateLimit/retryable

retry 策略

LimiterFileSystem 只重试:

  • read-like 操作:verify/open/read/openDir/list/getDirUrl
  • 有保护条件的写入:expectedDigest / createOnly
  • 有保护条件的删除:expectedDigest

普通 write/delete/create 不重试,避免非幂等写重复创建或覆盖。

Commit 说明

Commit 改动 原因
docs(sync): document cloud sync correctness design 新增云同步正确性研究文档和文档索引 先把架构、风险、PR #1439 review 观点和生产兼容原则写清楚,避免直接改代码
test(sync): cover failed task digest preservation 增加失败任务不推进 digest 的测试 复现状态污染问题,保证失败文件保留旧 digest
fix(sync): preserve failed task digests 修复失败任务 digest 推进问题 成功文件可推进,失败文件不能被错误标记为同步成功
test(sync): cover status merge before write 增加写回前合并远端 status 测试 防止覆盖其他设备刚写入的 scriptcat-sync.json
fix(sync): merge latest status before write scriptcat-sync.json 写回前重新读取并合并 降低多设备 status 覆盖风险
test(sync): cover status sync best effort 增加 status sync best-effort 测试 单个状态更新失败不能卡死整轮同步
test(sync): cover legacy sync status file 增加旧 scriptcat-sync.json 兼容测试 旧文件可能缺字段,不能要求用户迁移
fix(sync): tolerate legacy sync status file 容忍旧 status 文件结构 保持生产旧数据可读
test(sync): cover status write failure isolation 增加 status 写失败隔离测试 status 文件写失败不应阻断成功脚本 digest 推进
193a5ac7 fix(sync): isolate status file write failures 隔离 scriptcat-sync.json 写失败 避免状态文件失败扩大成整轮失败
test(sync): cover corrupt status file isolation 增加损坏 status 文件测试 损坏 JSON 不应阻塞脚本同步,也不应覆盖坏文件
fix(sync): isolate unreadable status file 读取 status 文件失败时跳过安全写回 避免在无法读取远端状态时覆盖其他设备数据
test(fs): cover dropbox typed errors 增加 Dropbox typed error 测试 替代脆弱字符串错误判断
fix(fs): use typed dropbox errors Dropbox request 层转换 typed error 让同步层能区分 notFound/conflict/rateLimit/auth
test(fs): cover baidu errno classification 增加 Baidu errno 分类测试 防止所有非 0 errno 都被误判 conflict
fix(fs): classify baidu errno precisely Baidu 只把明确 file-exists errno 判 conflict 保留普通错误语义,避免误报冲突
test(fs): cover raw response typed errors 增加 OneDrive/Google raw response typed error 测试 覆盖 nothen=true 路径
fix(fs): type raw provider response errors OneDrive/Google raw response 转 typed error raw Response 路径不再漏分类 404/409/412/429
test(fs): cover typed transient retries 增加 typed transient retry 测试 验证 limiter 按 typed retryable/rateLimit 工作
fix(fs): retry typed transient errors Limiter 支持 typed transient retry 429/5xx 可有限重试
test(sync): cover delete notification throttling 增加删除通知节流测试 多个 tombstone 不应逐个通知用户
fix(sync): throttle delete notifications 聚合远端删除通知 降低通知噪声
test(fs): cover filesystem capabilities 增加 capabilities 测试 锁定 provider 能力默认值和包装行为
fix(fs): expose filesystem capabilities FileSystem 暴露能力描述 同步层按能力决定是否使用条件操作
test(fs): cover dropbox raw read errors 增加 Dropbox raw read 429 测试 读取内容接口也要 typed rateLimit
fix(fs): type dropbox raw read errors Dropbox reader raw response 转 typed error 避免 read 路径漏分类
test(fs): cover s3 transient errors 增加 S3 transient 测试 覆盖 S3 429/5xx 分类
fix(fs): type s3 transient errors S3 error 转 typed retryable/rateLimit 让 limiter/sync 正确处理 S3 transient
test(fs): cover webdav transient errors 增加 WebDAV transient 测试 覆盖 WebDAV 429/5xx
fix(fs): type webdav transient errors WebDAV error 转 typed retryable/rateLimit 让 transient 行为一致
test(fs): cover s3 conditional operations 增加 S3 条件写删测试 验证 If-Match / If-None-Match / conditional delete
fix(fs): support s3 conditional operations S3 支持条件写、create-only、条件删除 使用原生 S3 条件请求降低覆盖风险
test(fs): cover webdav conditional operations 增加 WebDAV 条件操作测试 验证 ETag If-Match 和 overwrite=false
fix(fs): support webdav conditional operations WebDAV 支持条件写删和 create-only 使用 WebDAV 原生能力
test(fs): cover onedrive conditional operations 增加 OneDrive 条件操作测试 验证 eTag / If-Match / If-None-Match
fix(fs): support onedrive conditional operations OneDrive 支持条件写删 使用 Graph API 条件头
test(sync): cover conditional push options 增加 sync push 条件参数测试 同步层只在安全时传 expectedDigest/createOnly
fix(sync): use provider conditional writes when safe push 使用 provider 条件写能力 已有远端 digest 用 CAS,新文件用 createOnly
test(sync): cover conditional cloud delete 增加条件删除测试 删除时可带 expectedDigest
fix(sync): use provider conditional delete when safe deleteCloudScript 按能力使用 expectedDigest 防止删除被并发修改的云端文件
test(fs): cover webdav create-only conflict 增加 WebDAV create-only false 测试 putFileContents false 应转 typed conflict
fix(fs): type webdav create-only conflict WebDAV create-only false 转 typed conflict 让 sync 识别 create-only 冲突
test(sync): cover status write after file failure 增加文件失败后 status 写回测试 失败 uuid 的远端状态应保留
fix(sync): preserve failed script status while syncing others 文件失败时保留失败脚本 status 成功脚本仍可同步,失败脚本不污染状态
test(fs): cover s3 conditional conflicts 增加 S3 条件冲突测试 覆盖 412 / PreconditionFailed
docs(sync): update implemented provider status 更新 provider 实现状态文档 记录 WebDAV/S3/OneDrive 条件能力已落地
test(sync): cover conflict error classification 增加 sync conflict 分类测试 typed conflict 应标记为 conflict
fix(sync): classify per-file sync failures 同步层增加 per-file 错误分类 区分 conflict/stale_snapshot/transient/unsupported/fatal
test(fs): cover conditional write retry 增加受保护写 retry 测试 只有有条件保护的写才可 retry
fix(fs): retry protected conditional writes Limiter 只重试 protected conditional write/delete 避免非幂等写重复执行
docs(sync): reflect protected write retry 文档记录 protected retry 范围 明确普通 write/delete 不重试
test(sync): cover sync error kind mapping 增加错误类型映射测试 确认 FileSystemError 映射到 SyncErrorKind
test(sync): cover install push transient failure 增加 install 触发 push transient 失败测试 安装成功后云同步失败不能污染 digest
fix(sync): classify queued sync failures 队列触发的 install/delete 同步失败分类 记录 typed failure,保持后续定时同步可补偿
docs(sync): record queued failure classification audit 文档记录队列失败分类审计 说明 install/delete 路径处理方式
docs(sync): refine remaining rollout plan 整理剩余 rollout 计划 明确哪些继续做,哪些不进入本轮
test(sync): cover queued delete typed failures 增加 queued delete typed failure 测试 删除路径失败不能污染 digest
test(fs): cover dropbox opaque digest 增加 Dropbox content_hash opaque digest 测试 不把 provider digest 改成本地 md5
docs(sync): document manual verification path 文档记录手工验证路径 说明真实扩展和真实 provider 验证方式
fix(fs): classify dropbox structured not found Dropbox structured not_found 转 typed notFound 覆盖无 error_summary 的 Dropbox 响应
test(fs): cover dropbox structured conflict 增加 Dropbox structured conflict 测试 覆盖 structured path conflict
docs(sync): keep rollout checklist current 更新 rollout checklist 同步当前完成状态
test(fs): cover non-atomic provider capabilities 增加 Google Drive/Dropbox/Baidu 非 atomic capability 测试 锁定这些 provider 不声明 CAS 能力
docs(sync): record non-atomic provider coverage 文档记录非 atomic provider 覆盖 说明 Google Drive/Dropbox/Baidu 的能力边界
fix(sync/fs): address remaining provider verification gaps Google Drive reader miss、Baidu filemetas miss 转 typed notFound 避免 stale/missing cloud file 被误判 fatal
docs(sync): record provider gap closure 文档记录 provider gap 已关闭 同步 上一个commit 状态
docs(sync): record manual verification result 记录本地验证结果和真实 provider 未验证原因 避免把 mock/unit/build 结果宣称为真实云验证
docs(sync): record real provider verification 记录真实 provider 验证阻塞条件和恢复条件 明确需要 OAuth/账号/夹具后才能继续真实云验证
fix(sync/fs): address real provider verification findings Baidu HTTP 429/5xx 转 typed rateLimit/retryable 真实 HTTP 层限流/服务错误不能被当作普通成功 JSON
docs(sync): finalize rollout status 最终整理 rollout 状态 说明本地可验证范围完成,真实 provider 仍待凭据环境

生产兼容性

本 PR 不要求用户手动迁移云端数据。

兼容旧数据:

  • .user.js / .meta.json 可继续读取。
  • file_digest string map 可继续读取。
  • scriptcat-sync.json 缺字段时不会崩溃。
  • orphan .user.js without .meta.json 会跳过并保留 status。
  • 新 capabilities 是 optional,未声明能力的 provider 保持旧写入语义。
  • Google Drive / Dropbox / Baidu 不声明 atomic CAS,避免把 best-effort 行为误导成强一致。

风险和暂不实现项

仍需真实 provider 验证:

  • WebDAV/S3/OneDrive 真实 ETag / If-Match mismatch。
  • Dropbox 真实 structured conflict / rate-limit。
  • Google Drive path cache stale、同名文件和 best-effort race。
  • Baidu 真实 filemetas 缺失、errno 分类和覆盖写行为。
  • OAuth refresh 路径在真实账号中的表现。

本轮暂不实现:

  • Dropbox rev CAS。
  • Google Drive / Baidu atomic CAS。
  • tombstone_digest
  • 通知聚合 UI。
  • 既有 React hooks warnings。

@cyfung1031 cyfung1031 added the P1 🔥 重要但是不紧急的内容 label Jul 11, 2026
CodFrm added 12 commits July 13, 2026 10:23
解决 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)
@CodFrm CodFrm force-pushed the pr/sync-fix/100 branch from 980c069 to 7a80222 Compare July 13, 2026 08:17
@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 需单独设计)。
  • 你提到的同步日志/状态面板可以在冲突通知的基础上继续做。

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