完美像素孤儿占位判据由结构不变式改为有界时间窗
Project CI / Repository checks (pull_request) Failing after 11s
Project CI / Backend tests (pull_request) Failing after 10s
Project CI / Frontend tests (pull_request) Failing after 1m54s
Project CI / Native shell tests (pull_request) Failing after 2m58s

原判据依赖「活着的占位始终在内存里、永不经过 hydrate」,在单标签页下成立,
多标签页下是假的:B 标签会 hydrate 到 A 标签正在用的活占位,据此剥离,再由
B 下一次布局保存以当前 revision 合法写回把它删掉——CAS 挡不住,因为 B 不陈旧。

改为只有超过 180 秒才判定为孤儿。窗口由两侧封死:服务端最坏 30 + 60 = 90 秒
(timeout_at 强制),客户端整个 POST 被 120 秒封顶,之后必已 abort 并把占位
改成 failed 或移除。取 120 + 60 余量。

这个方案在持久化预算落地之前不成立——那时服务端最坏时长无界,任何时间窗都是
拍脑袋。时间戳复用 generationStartedAt,已自动打戳、已序列化、已 hydrate。

同时撤回此前多次记录的「加会话归属标识」方案:B 拿到不同的会话 id 推不出 A
是死是活,只能识别「不是我的」,不能识别「已经没人要了」。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-03 07:11:48 +00:00
parent cf397644c7
commit 62c02f22ab
3 changed files with 127 additions and 6 deletions
@@ -6038,3 +6038,17 @@
- 验证:新增 `pixel_art_server_worst_case_fits_inside_the_client_timeout` 钉住跨端不变式,把两侧数值和 30 秒余量都写死;顺序守卫新增「预算在前、写入在后」与超时文案 + 标记两项,任何把 persist 挪到 `timeout_at` 之前的改动都会失败。把持久化预算临时调到 120 秒可确认该测试变红。api-server 677 通过 / 3 失败(`wallet_refund_outbox` 本机环境失败,与基线一致),`cargo fmt --check` 通过。
- 未覆盖:跨端不变式靠常量断言维系,客户端那侧的 120 秒仍是 `editorProjectClient.ts` 里的字面量,改动它不会让 Rust 测试失败。真正的双向钉定需要共享契约常量,本次未做。
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
## 2026-08-03 完美像素孤儿占位判据由结构不变式改为有界时间窗
- 缺陷:`dropDeadInlineGenerationPlaceholders` 原先依赖一条结构性不变式——置位 `requiresLiveSession` 的占位其收口只能由创建它的会话完成,而活着的那份始终在内存里、永不经过 hydrate,所以从服务端快照读回来的必然属于已死会话。这条在单标签页下成立,多标签页下是假的:B 标签打开同一项目会 hydrate 到 A 标签正在用的活占位,据此剥离,再由 B 下一次布局保存以**当前** revision 合法写回,把 A 的占位删掉。
- CAS 的作用要说准:它挡的是基于陈旧 revision 的覆盖写。B 在 A 完成**前**写入时 revision 是当前的,CAS 放行——这是有害的那一半;B 在 A 完成**后**写入时 revision 已陈旧,CAS 拒绝——所以「B 把 A 的成品图层写没」这种更严重的情况本来就不会发生。此前 decision-log 笼统写「CAS 兜底」是错的,纠正后也不应反过来说 CAS 完全无用。
- 决策:判据改为有界时间窗,只有超过 180 秒才判定为孤儿。窗口上界由两侧共同封死——服务端最坏合法时长是处理 30 秒加持久化 60 秒(都由 `timeout_at` 强制,见同日持久化预算条目),客户端整个 POST 又被 120 秒超时封顶;120 秒之后客户端必已 abort 并把占位改成 `failed` 或移除。取 180 = 120 客户端上限 + 60 余量(网络往返、标签页挂起后的时钟漂移)。
- 关键依赖:这个方案在持久化预算落地**之前**不成立。那时服务端最坏时长无界,任何时间窗都是拍脑袋;把最坏时长收进 90 秒之后,时间窗才有硬依据。
- 复用既有字段:时间戳用 `generationStartedAt`,由 `withGenerationTimestamps` 在占位进入 `generating` 时自动打戳,已序列化、已 hydrate,无需新增字段。
- 撤回先前方案:此前多次记录「真正的修法是给占位加会话归属标识、只允许创建者剥离」。该方案不成立——B 拿到一个不同的会话 id,推不出 A 是死是活,照样只能猜。会话 id 只能识别「不是我的」,不能识别「已经没人要了」。
- 兜底方向:缺 `generationStartedAt` 时按可剥离处理。实践中不会出现(两个字段同一次创建一起写),但按「保留」会让这类占位永久留在画布上,按「剥离」最坏只是退回引入时间窗之前的行为。
- 时钟:取读取方的 `Date.now()`。同机多标签共享时钟,正是要修的场景,判定精确;跨设备有偏移风险,但此前是无条件剥离,任何时间窗都不会比原行为更差。
- 已知残留:A 真死了而用户在 180 秒内重新加载时,占位会继续转到窗口过后的下一次加载才清掉。可以加客户端定时器在剩余时间后自行收口,但要多一套定时器生命周期管理,先不加,观察实际是否困扰。
- 验证:新增四条用例覆盖窗口内保留、边界包含式、超窗剥离、缺时间戳兜底;去掉时间窗判断后其中两条变红。`vitest src/components/image-editor` 899 通过 / 72 文件,typecheck、eslint 通过。
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。