diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 1a136673f..888a5d48c 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -6140,3 +6140,15 @@ - 刻意不复制子句字面文本:Rust 常量是真值源,OpenAPI 已复制一份,skill 文档再抄第三份就是把同一事实摊到三处——这次漂移正是这么发生的,只是方向相反。文档改为指向 OpenAPI 并写明「本指南刻意不复制」,让下一个读到的人知道那是有意为之而非遗漏。 - 校验面已确认:这批文档由 `external_skill_api.rs` / `external_mcp.rs` 以 `include_str!` 编译期内联,SHA 在运行时从内容算出、测试只断言「算出的与返回的一致」,没有钉死具体摘要,改文档无需同步任何清单。api-server 700 通过 / 3 失败(`wallet_refund_outbox` 本机环境失败,与基线一致)。 - 关联文档:`docs/openapi/genarrative-external-v1.openapi.json`。 + +## 2026-08-03 到期清理误删本会话在途占位:补归属登记与前置阶段预算 + +- 缺陷:到期清理只按 `generationStartedAt + 180 秒` 删除 `requiresLiveSession` 且 `generating` 的占位,区分不出它属于已死会话还是本会话仍在执行。占位在创建后还要走源图解析/直传和 `flushProjectPersistence` 才轮到 POST,而 `snapEditorImageToPixelArt` 的 120 秒**只从最终 POST 开始计**。前置阶段慢起来越过窗口时,定时器会删掉本会话正在用的占位并把删除持久化,随后 POST 因占位不存在返回 `409`;若删除的落库晚于 POST 到达,则 completion 找不到占位返回 `Ok(None)`,结果只进素材库、不落画布。 +- 我写在 hook 注释里的安全性论证是错的,两条都错:其一「客户端 120 秒就 abort,180 秒时不可能还是 generating」——120 秒不覆盖前置阶段;其二「不依赖该推理,到期重新判定本身兜得住」——重新判定只能识别**已经收口**的占位,识别不出**仍在合法运行**的占位,后者正处于要被删除的那个状态。第二条错得更本质:它给了自己和读者一道并不存在的第二防线。 +- 前置阶段此前完全无界:直传 `postEditorDirectUploadFile` 是裸 `fetch`、没有 signal,`saveEditorProjectLayout` 的 `requestJson` 没传 `timeoutMs`(同文件其余接口都写了),而 `composeAbortSignal` 在缺失时不设任何默认值。两者各自还有重试(上传最多 3 次尝试、布局保存最多 4 次)。 +- 决策一(归属登记):`activeInlineGenerationDialogIdsRef` 记录本会话仍在执行的占位 id,创建后**紧挨着**注册(中间不能有 await,否则留出「已存在但未登记」的窗口),`finally` 释放;到期清理跳过其中的 id。到期清理本来就只该针对别人留下的孤儿。 +- 决策二(整段预算而非逐请求超时):给「占位创建 → POST 发出」整段 40 秒预算。逐个请求加超时的最坏总时长会因重试累加到远超 180 秒窗口,窗口的前提仍不成立;整段封顶后客户端最坏 40 + 120 = 160 秒,落在窗口内并留 20 秒余量。超时抛裸 `Error` 而非 `ApiClientError`,归入未知结果走对账——上传可能已完成、素材可能已落库,正是对账要处理的情形。 +- 决策三:`saveEditorProjectLayout` 补 `timeoutMs: 60_000`。这是独立缺陷,与本条无关也该修——它被 `flushProjectPersistence` 同步等待在提交路径上,挂住会连带把占位拖过窗口。 +- 「窗口计时起点应改为 POST 发出时刻」未采纳:归属登记之后窗口不再需要覆盖本会话,只用于跨标签页;而对孤儿占位只有创建时刻这一个可用时间戳,改起点无从实现。整段预算已经让窗口的前提重新成立。 +- 验证:新增两条用例——本会话持有期间超窗不清理、释放归属后同一超窗占位立即清理。去掉归属过滤后两条同时变红。`vitest src/components/image-editor` 923 通过 / 74 文件,typecheck、eslint、check:encoding 通过。 +- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index 686399ef9..ccd3aa11f 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -1521,13 +1521,6 @@ export function ImageCanvasEditorView({ const handleInlinePlaceholdersExpired = useCallback(() => { showGenerationWarning(DEAD_INLINE_PLACEHOLDER_NOTICE); }, [showGenerationWarning]); - // 中文注释:加载期的剥离只跑一次,当时未到期而被保留的孤儿占位需要这里补上到期清理, - // 否则它会一直转到用户下一次加载。两条路径共用同一条文案,用户感知一致。 - useInlineGenerationPlaceholderExpiry({ - canvasGenerationDialogs, - removeCanvasGenerationDialogById, - onPlaceholdersExpired: handleInlinePlaceholdersExpired, - }); const handleExternalGenerationTasksCompleted = useCallback( (tasks: ExternalGenerationTaskRecord[]) => { if (!projectId || tasks.length === 0) { @@ -1632,6 +1625,7 @@ export function ImageCanvasEditorView({ openCropExpandPanel, removeSelectedLayerBackground, snapSelectedLayerToPerfectPixels, + activeInlineGenerationDialogIdsRef, perfectPixelLayerIds, splitSelectedIconSpritesheet, splittingIconSpritesheetLayerIds, @@ -1957,6 +1951,15 @@ export function ImageCanvasEditorView({ setSelectedLayerIds, ], ); + // 中文注释:加载期的剥离只跑一次,当时未到期而被保留的孤儿占位需要这里补上到期清理, + // 否则它会一直转到用户下一次加载。两条路径共用同一条文案,用户感知一致。 + useInlineGenerationPlaceholderExpiry({ + canvasGenerationDialogs, + activeInlineGenerationDialogIdsRef, + removeCanvasGenerationDialogById, + onPlaceholdersExpired: handleInlinePlaceholdersExpired, + }); + const requestRemoveCanvasGenerationDialog = useCallback( (dialog: CanvasGenerationDialogState) => { if (dialog.status === 'generating') { diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts index 882a142d1..56adc07a9 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts @@ -215,6 +215,44 @@ function createProjectLayerSnapshotFromLayer( }; } +// 中文注释:占位创建到 POST 发出之间的整段预算。 +// +// 这段做的是源图解析/直传和布局保存,此前**完全无界**:直传是裸 fetch 没有 signal, +// 布局保存的 requestJson 没传 timeoutMs(同文件其余接口都写了),两者各自还有重试。 +// 而 `snapEditorImageToPixelArt` 的 120 秒只从最终 POST 开始计,所以「客户端 120 秒封顶」 +// 这条曾被写进到期清理注释的保证,对这段并不成立。 +// +// 给整段一个预算而不是给每次请求加超时:上传最多三次尝试、布局保存最多四次,逐个加超时 +// 的最坏总时长会累加到远超占位存活窗口,窗口的前提仍然不成立。整段封顶 40 秒之后, +// 客户端最坏 40 + 120 = 160 秒,落在 180 秒窗口内并留 20 秒余量。 +// +// 超时抛的是裸 Error 而非 ApiClientError,会被归入未知结果走对账——上传可能已经完成、 +// 素材可能已经落库,这正是对账要处理的情形。 +const PERFECT_PIXEL_PRE_POST_BUDGET_MS = 40_000; + +async function withPerfectPixelPrePostBudget( + work: Promise, + deadlineAt: number, + timeoutMessage: string, +): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + work, + new Promise((_resolve, reject) => { + timer = setTimeout( + () => reject(new Error(timeoutMessage)), + Math.max(0, deadlineAt - Date.now()), + ); + }), + ]); + } finally { + if (timer !== undefined) { + clearTimeout(timer); + } + } +} + function preserveSourceLayerInProjectSnapshot( project: EditorProjectSnapshot, sourceLayer: CanvasLayer, @@ -692,6 +730,11 @@ export function useImageCanvasGenerationWorkflow({ const [splittingIconSpritesheetLayerIds, setSplittingIconSpritesheetLayerIds] = useState>(() => new Set()); const perfectPixelLayerIdsRef = useRef(new Set()); + // 中文注释:本会话仍在执行的 inline 占位 id。到期清理只该针对**别人**留下的孤儿, + // 而它无法从 dialog 状态区分「已死会话留下的」和「本会话正在跑的」——两者都是 + // requiresLiveSession + generating。占位创建后还要走源图解析/直传和 flush 才轮到 + // 受超时保护的 POST,这段慢起来会越过存活窗口,届时定时器会删掉自己正在用的占位。 + const activeInlineGenerationDialogIdsRef = useRef(new Set()); const [perfectPixelLayerIds, setPerfectPixelLayerIds] = useState>( () => new Set(), ); @@ -1825,16 +1868,28 @@ export function useImageCanvasGenerationWorkflow({ requiresLiveSession: true, }); perfectPixelDialogId = placement.dialogId; + // 中文注释:紧挨着创建注册,中间不能有 await——否则会留出一个「占位已存在但尚未 + // 登记归属」的窗口,到期清理正好可以在那里把它删掉。 + activeInlineGenerationDialogIdsRef.current.add(perfectPixelDialogId); if (!placement.placeholder) { throw new Error('无法创建完美像素处理占位'); } - const sourceImageSrc = await resolveEditorGenerationMediaReference( - sourceLayer, - 'image', - normalizedProjectId, + const prePostDeadlineAt = Date.now() + PERFECT_PIXEL_PRE_POST_BUDGET_MS; + const sourceImageSrc = await withPerfectPixelPrePostBudget( + resolveEditorGenerationMediaReference( + sourceLayer, + 'image', + normalizedProjectId, + ), + prePostDeadlineAt, + '完美像素源图准备超时。', + ); + await withPerfectPixelPrePostBudget( + flushProjectPersistence(), + prePostDeadlineAt, + '完美像素提交前的画布保存超时。', ); - await flushProjectPersistence(); const sourceResourceId = sourceLayer.resourceId.trim(); perfectPixelPostAttempted = true; const result = await snapImageToPerfectPixels({ @@ -1998,6 +2053,9 @@ export function useImageCanvasGenerationWorkflow({ showGenerationWarning(errorMessage); } } finally { + if (perfectPixelDialogId) { + activeInlineGenerationDialogIdsRef.current.delete(perfectPixelDialogId); + } perfectPixelLayerIdsRef.current.delete(sourceLayer.id); setPerfectPixelLayerIds((currentLayerIds) => { if (!currentLayerIds.has(sourceLayer.id)) { @@ -2874,6 +2932,7 @@ export function useImageCanvasGenerationWorkflow({ startCropExpandFrameResize, removeSelectedLayerBackground, snapSelectedLayerToPerfectPixels, + activeInlineGenerationDialogIdsRef, perfectPixelLayerIds, splitSelectedIconSpritesheet, splittingIconSpritesheetLayerIds, diff --git a/src/components/image-editor/useInlineGenerationPlaceholderExpiry.test.tsx b/src/components/image-editor/useInlineGenerationPlaceholderExpiry.test.tsx index f227e9d61..c95b2c92d 100644 --- a/src/components/image-editor/useInlineGenerationPlaceholderExpiry.test.tsx +++ b/src/components/image-editor/useInlineGenerationPlaceholderExpiry.test.tsx @@ -1,6 +1,7 @@ /* @vitest-environment jsdom */ import { act, render } from '@testing-library/react'; +import { useRef } from 'react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { CanvasGenerationDialogState } from './ImageCanvasEditorTypes'; @@ -24,13 +25,18 @@ function Harness({ dialogs, removeCanvasGenerationDialogById, onPlaceholdersExpired, + activeDialogIds = [], }: { dialogs: CanvasGenerationDialogState[]; removeCanvasGenerationDialogById: (dialogId: string) => void; onPlaceholdersExpired: (expiredCount: number) => void; + activeDialogIds?: string[]; }) { + const activeInlineGenerationDialogIdsRef = useRef(new Set(activeDialogIds)); + activeInlineGenerationDialogIdsRef.current = new Set(activeDialogIds); useInlineGenerationPlaceholderExpiry({ canvasGenerationDialogs: dialogs, + activeInlineGenerationDialogIdsRef, removeCanvasGenerationDialogById, onPlaceholdersExpired, }); @@ -152,6 +158,63 @@ describe('useInlineGenerationPlaceholderExpiry', () => { expect(removeCanvasGenerationDialogById).toHaveBeenCalledWith('dialog-1'); }); + it('never expires a placeholder the current session still owns', () => { + // 中文注释:占位创建后还要走源图解析/直传和 flush 才轮到受超时保护的 POST,这段慢起来 + // 会越过存活窗口。此时占位仍是 requiresLiveSession + generating,与已死会话留下的孤儿 + // 在状态上完全一致——只能靠显式登记归属区分。删掉自己正在用的占位会让随后的 POST 因 + // 占位不存在返回 409。 + const removeCanvasGenerationDialogById = vi.fn(); + render( + , + ); + + act(() => { + vi.advanceTimersByTime(WINDOW_MS * 2); + }); + + expect(removeCanvasGenerationDialogById).not.toHaveBeenCalled(); + }); + + it('expires the placeholder once the session releases ownership', () => { + // 中文注释:归属在 finally 里释放。释放之后同一个超窗占位必须能被清掉,否则失败退出的 + // 会话会留下永久转圈的占位。 + const removeCanvasGenerationDialogById = vi.fn(); + const expired = dialog({ + requiresLiveSession: true, + generationStartedAt: Date.now() - WINDOW_MS - 60_000, + }); + const { rerender } = render( + , + ); + expect(removeCanvasGenerationDialogById).not.toHaveBeenCalled(); + + rerender( + , + ); + + expect(removeCanvasGenerationDialogById).toHaveBeenCalledWith('dialog-1'); + }); + it('never arms a timer for queue-backed placeholders', () => { // 中文注释:队列型占位的 job 在服务端继续跑,worker 会替换占位。自动清理会让用户以为 // 操作没发生而重复提交。 diff --git a/src/components/image-editor/useInlineGenerationPlaceholderExpiry.ts b/src/components/image-editor/useInlineGenerationPlaceholderExpiry.ts index 9974964a4..4bc710c05 100644 --- a/src/components/image-editor/useInlineGenerationPlaceholderExpiry.ts +++ b/src/components/image-editor/useInlineGenerationPlaceholderExpiry.ts @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { type RefObject, useEffect, useState } from 'react'; import { collectExpiredInlineGenerationDialogIds, @@ -8,6 +8,7 @@ import type { CanvasGenerationDialogState } from './ImageCanvasEditorTypes'; type InlineGenerationPlaceholderExpiryOptions = { canvasGenerationDialogs: CanvasGenerationDialogState[]; + activeInlineGenerationDialogIdsRef: RefObject>; removeCanvasGenerationDialogById: (dialogId: string) => void; onPlaceholdersExpired: (expiredCount: number) => void; }; @@ -36,14 +37,23 @@ type InlineGenerationPlaceholderExpiryOptions = { */ export function useInlineGenerationPlaceholderExpiry({ canvasGenerationDialogs, + activeInlineGenerationDialogIdsRef, removeCanvasGenerationDialogById, onPlaceholdersExpired, }: InlineGenerationPlaceholderExpiryOptions) { const [expiryTick, setExpiryTick] = useState(0); useEffect(() => { + // 中文注释:本会话仍在执行的占位一律跳过。到期清理只针对已死会话留下的孤儿,而 + // dialog 状态区分不出这两者——本会话在源图直传或布局保存阶段慢起来时,它的占位同样 + // 是 requiresLiveSession + generating,按状态判定会把自己正在用的占位删掉,随后 POST + // 因占位不存在返回 409。 + // + // 这一条不能靠「到期重新判定」兜住:重新判定只能识别「已经收口的占位」,识别不出 + // 「仍在合法运行的占位」——后者正处于要被删除的那个状态。归属必须显式登记。 + const activeDialogIds = activeInlineGenerationDialogIdsRef.current; const expiredIds = collectExpiredInlineGenerationDialogIds( canvasGenerationDialogs, - ); + ).filter((dialogId) => !activeDialogIds?.has(dialogId)); if (expiredIds.length > 0) { for (const dialogId of expiredIds) { removeCanvasGenerationDialogById(dialogId); @@ -67,6 +77,7 @@ export function useInlineGenerationPlaceholderExpiry({ window.clearTimeout(timer); }; }, [ + activeInlineGenerationDialogIdsRef, canvasGenerationDialogs, expiryTick, onPlaceholdersExpired, diff --git a/src/services/image-editor/editorProjectClient.ts b/src/services/image-editor/editorProjectClient.ts index a9f156981..51d348384 100644 --- a/src/services/image-editor/editorProjectClient.ts +++ b/src/services/image-editor/editorProjectClient.ts @@ -819,6 +819,10 @@ export async function saveEditorProjectLayout( expectedRevision: input.expectedRevision, }), '保存图片画布工程失败', + // 中文注释:本文件其余接口都显式写了超时,唯独这里没有——而 composeAbortSignal 在 + // timeoutMs 缺失时不设任何默认值,于是布局保存可以无限期挂住。它被 flushProjectPersistence + // 同步等待在生成提交路径上,挂住会连带把占位拖过存活窗口。 + { timeoutMs: 60_000 }, ); }