diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 119ef2f4e..eb3d11b3b 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -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`。 diff --git a/src/components/image-editor/ImageCanvasEditorModel.test.ts b/src/components/image-editor/ImageCanvasEditorModel.test.ts index c6d4078a3..1d98db6ff 100644 --- a/src/components/image-editor/ImageCanvasEditorModel.test.ts +++ b/src/components/image-editor/ImageCanvasEditorModel.test.ts @@ -1036,6 +1036,83 @@ describe('ImageCanvasEditorModel', () => { expect(result.project.layers).toEqual([]); }); + it('keeps a live-session placeholder that is still inside the live window', () => { + // 中文注释:这是多标签页的核心场景。B 标签打开同一项目时会 hydrate 到 A 标签正在用的 + // 活占位——原先的结构性判据(「从服务端读回来的必然属于已死会话」)在这里是假的,会被 + // 当成孤儿剥离,再由 B 下一次布局保存以当前 revision 合法写回,把 A 的占位删掉。 + const now = 1_700_000_000_000; + const project = buildProject([ + buildDialogItem('live', { + status: 'generating', + requiresLiveSession: true, + generationStartedAt: now - 30_000, + }), + ]); + + const result = dropDeadInlineGenerationPlaceholders(project, now); + + expect(result.droppedCount).toBe(0); + expect(result.project).toBe(project); + }); + + it('keeps a live-session placeholder right up to the window boundary', () => { + // 中文注释:窗口是「服务端最坏 90 秒 + 客户端 120 秒上限 + 余量」推出来的,边界必须是 + // 包含式:正好 180 秒时操作仍可能刚刚收口,不能剥。 + const now = 1_700_000_000_000; + const project = buildProject([ + buildDialogItem('boundary', { + status: 'generating', + requiresLiveSession: true, + generationStartedAt: now - 180_000, + }), + ]); + + expect(dropDeadInlineGenerationPlaceholders(project, now).droppedCount).toBe( + 0, + ); + }); + + it('drops a live-session placeholder once the window has elapsed', () => { + // 中文注释:超过窗口意味着客户端早已 abort 并把占位改成 failed 或移除,服务端也越过了 + // 90 秒硬上限——此时还停在 generating 就确定是孤儿。 + const now = 1_700_000_000_000; + const project = buildProject([ + buildDialogItem('stale', { + status: 'generating', + requiresLiveSession: true, + generationStartedAt: now - 180_001, + }), + ]); + + const result = dropDeadInlineGenerationPlaceholders(project, now); + + expect(result.droppedCount).toBe(1); + expect(result.project.layers).toEqual([]); + }); + + it('drops a live-session placeholder that carries no usable timestamp', () => { + // 中文注释:兜底方向必须是剥离。按「保留」会让这类占位永久留在画布上;按「剥离」最坏 + // 只是退回引入时间窗之前的行为。 + const now = 1_700_000_000_000; + for (const generationStartedAt of [ + undefined, + Number.NaN, + 'not-a-number', + ]) { + const project = buildProject([ + buildDialogItem('no-timestamp', { + status: 'generating', + requiresLiveSession: true, + ...(generationStartedAt === undefined ? {} : { generationStartedAt }), + }), + ]); + + expect( + dropDeadInlineGenerationPlaceholders(project, now).droppedCount, + ).toBe(1); + } + }); + it('keeps generating placeholders backed by a durable job', () => { // 中文注释:去除背景恒队列、图片生成默认队列,它们的 job 在服务端继续跑,worker 会 // 替换占位。刷新后必须原样恢复,误清会让用户以为操作没发生而重复提交。 diff --git a/src/components/image-editor/ImageCanvasEditorModel.ts b/src/components/image-editor/ImageCanvasEditorModel.ts index e755e8226..2ba543955 100644 --- a/src/components/image-editor/ImageCanvasEditorModel.ts +++ b/src/components/image-editor/ImageCanvasEditorModel.ts @@ -501,18 +501,37 @@ function isCanvasSettingsLayoutItem( return item.itemType === 'canvas-settings'; } +/** + * 中文注释:从占位创建起算的存活窗口。超过它还停在 `generating` 的 inline 占位,确定是孤儿。 + * + * 上界由两侧共同封死:服务端最坏合法时长是处理预算 30 秒加持久化预算 60 秒(都由 + * `timeout_at` 强制),客户端整个 POST 又被 `snapEditorImageToPixelArt` 的 120 秒超时封顶。 + * 所以 120 秒之后客户端必已 abort,并把占位改成 `failed` 或直接移除,服务端也早已越过 90 秒。 + * 取 180 秒 = 120 秒客户端上限 + 60 秒余量(网络往返、标签页被挂起后的时钟漂移)。 + */ +const INLINE_GENERATION_PLACEHOLDER_LIVE_WINDOW_MS = 180_000; + /** * 中文注释:剥离「只能由已死会话收口」的 generating 占位。 * - * 判据是一条结构性不变量,不是时间阈值:置位了 requiresLiveSession 的占位,其收口只能由 - * 创建它的页面会话完成;而活着的那一份始终在内存里,永远不经过快照 hydrate。因此凡是从 - * 服务端快照里读回来的这类 generating 占位,创建它的会话必然已经不在了。 + * 原先的判据是一条结构性不变量——活着的那份始终在内存里、永远不经过 hydrate,所以从服务端 + * 读回来的必然属于已死会话。**这条在多标签页下是假的**:B 标签打开同一项目时,会 hydrate 到 + * A 标签正在用的活占位,据此剥离并在自己下一次布局保存里把它写没。CAS 挡不住——B 是以当前 + * revision 写入一份合法布局。(反方向倒是被 CAS 挡住的:A 完成后 B 再写,B 的 revision 已陈旧。) + * + * 改为有界时间窗:只有超过 `INLINE_GENERATION_PLACEHOLDER_LIVE_WINDOW_MS` 才判定为孤儿。 + * 不如结构性判据优雅,但那条判据在多标签页下本来就不成立。时间戳用 `generationStartedAt`, + * 它由 `withGenerationTimestamps` 在占位进入 `generating` 时自动打戳,无需新增字段。 + * + * 时钟取自读取方的 `Date.now()`。同机多标签共享时钟,正是要修的场景,判定精确;跨设备有偏移 + * 风险,但此前是无条件剥离,任何时间窗都不会比原行为更差。 * * 只能用在项目首次加载。会话内 applyQueuedEditorGenerationProject 会重新 GET 项目并套用, * 那时候占位对应的操作正在进行,套用本函数会把自己的活占位清掉。 */ export function dropDeadInlineGenerationPlaceholders( project: EditorProjectSnapshot, + now: number = Date.now(), ): { project: EditorProjectSnapshot; droppedCount: number } { const nextLayers = project.layers.filter((item) => { if (item.itemType !== 'generation-dialog') { @@ -523,9 +542,20 @@ export function dropDeadInlineGenerationPlaceholders( typeof (item as { dialog?: unknown }).dialog === 'object' ? ((item as { dialog?: unknown }).dialog as Record) : null; - return !( - dialog?.requiresLiveSession === true && dialog?.status === 'generating' - ); + if ( + dialog?.requiresLiveSession !== true || + dialog?.status !== 'generating' + ) { + return true; + } + const startedAt = dialog.generationStartedAt; + // 中文注释:缺时间戳按「可剥离」处理。实践中不会出现——`requiresLiveSession` 与 + // `generationStartedAt` 在同一次创建里一起写——但兜底方向必须选对:按「保留」会让 + // 这类占位永久留在画布上,而按「剥离」最坏只是退回本次改动之前的行为。 + if (typeof startedAt !== 'number' || !Number.isFinite(startedAt)) { + return false; + } + return now - startedAt <= INLINE_GENERATION_PLACEHOLDER_LIVE_WINDOW_MS; }); const droppedCount = project.layers.length - nextLayers.length; if (droppedCount === 0) {