From 6fb2abfc6077663736f020d7cb4ca58daefb1b24 Mon Sep 17 00:00:00 2001 From: Linghong Date: Mon, 3 Aug 2026 11:34:04 +0000 Subject: [PATCH] =?UTF-8?q?=E5=A4=8D=E6=9F=A5=E4=BF=AE=E6=8E=89=E6=96=B0?= =?UTF-8?q?=E5=BC=95=E5=85=A5=E7=9A=84=E5=BF=99=E7=AD=89=E3=80=81=E6=97=A0?= =?UTF-8?q?=E7=95=8C=E9=A1=B9=E7=9B=AE=E8=AF=BB=E5=8F=96=E4=B8=8E=E8=BF=87?= =?UTF-8?q?=E7=B4=A7=E7=9A=84=E5=89=8D=E7=BD=AE=E9=A2=84=E7=AE=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 忙等(最严重,上一条新引入):归属过滤只加在 expiredIds,没加在 resolveNextInlineGenerationDialogExpiryAt。被本会话持有的超窗占位不进 expiredIds, 却仍被算出一个已过去的到期时刻,delayMs 塌成 50ms,触发→tick→重跑→再挂,变成 每 50 毫秒一次 setState 的忙等。改为先按归属过滤出 unownedDialogs 供两处共用。 loadEditorProject 同样没传 timeoutMs,而 composeAbortSignal 缺失时不设默认值。 它是对账路径上的读取,挂住会让 catch 迟迟不结束,连带把占位拖过窗口——正是上面 那个忙等的助推。补 60 秒。 前置预算 40 秒过紧:该阶段会真的直传一张画布图层,几 MB 在较差网络下要几十秒, 用「无界」换「过紧」同样是回归。改为 90 秒,窗口同步 180→240,维持 90 + 120 = 210 < 240 并留 30 秒余量。三个常量构成的跨文件不等式新增用例钉住。 Co-Authored-By: Claude Opus 5 --- .../shared-memory/decision-log.md | 11 +++++++ .../ImageCanvasEditorModel.test.ts | 31 ++++++++++++++++--- .../image-editor/ImageCanvasEditorModel.ts | 10 ++++-- .../useImageCanvasGenerationWorkflow.ts | 8 +++-- ...InlineGenerationPlaceholderExpiry.test.tsx | 8 ++++- .../useInlineGenerationPlaceholderExpiry.ts | 17 ++++++---- .../image-editor/editorProjectClient.ts | 4 +++ 7 files changed, 72 insertions(+), 17 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 888a5d48c..3a4188e1c 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -6152,3 +6152,14 @@ - 「窗口计时起点应改为 POST 发出时刻」未采纳:归属登记之后窗口不再需要覆盖本会话,只用于跨标签页;而对孤儿占位只有创建时刻这一个可用时间戳,改起点无从实现。整段预算已经让窗口的前提重新成立。 - 验证:新增两条用例——本会话持有期间超窗不清理、释放归属后同一超窗占位立即清理。去掉归属过滤后两条同时变红。`vitest src/components/image-editor` 923 通过 / 74 文件,typecheck、eslint、check:encoding 通过。 - 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + +## 2026-08-03 归属登记后的链路复查:一处新引入的忙等、两处无界读写、一处过紧预算 + +- 复查对象是上一条修复本身,找的是新引入的问题,结果三处新增、一处遗留。 +- 新引入(忙等,最严重):归属过滤只加在了 `expiredIds`,没加在 `resolveNextInlineGenerationDialogExpiryAt`。被本会话持有的超窗占位不进 `expiredIds`,却仍被算出一个**已经过去**的到期时刻,`delayMs` 塌成 50ms,定时器触发 → tick → effect 重跑 → 状态没变 → 再挂 50ms,变成每 50 毫秒一次 `setState` 的忙等,持续整个持有期。改为先按归属过滤出 `unownedDialogs`,两处判定共用。 +- 该忙等的可达性不是理论的:前置预算加 POST 之后,catch 里还要做对账 `loadEditorProject`,而归属要到 `finally` 才释放,这段完全可能越过存活窗口。 +- 遗留(无界读取):`loadEditorProject` 同样没传 `timeoutMs`,而 `composeAbortSignal` 在缺失时不设默认值。它正是对账路径上的读取,挂住会让 catch 迟迟不结束,连带把占位拖过窗口——即上一条忙等的直接助推。补 `60_000`。至此该文件里落在完美像素链路上的三个接口(POST、布局保存、项目读取)都有了显式上界。 +- 新引入(预算过紧):上一条把提交前置阶段封顶在 40 秒。该阶段在源图是 inline / 未登记时会真的直传一张画布图层,几 MB 的图在较差移动网络下要几十秒,40 秒会把原本能成功的操作改判为失败——**用「无界」换「过紧」同样是回归**。改为 90 秒,并把存活窗口从 180 秒同步提到 240 秒,维持 90 + 120 = 210 < 240 且留 30 秒余量。 +- 三个常量构成一条跨文件不等式(提交前置预算、客户端 POST 超时、占位存活窗口),任一处被单独调大都会破坏它,后果是跨标签页误删。新增用例把这条不等式连同 30 秒余量一起钉住。本会话自己的占位另有归属登记豁免、不依赖该窗口,所以窗口只需覆盖跨标签页那一侧——这一点也写进了常量注释。 +- 验证:忙等用例用 `vi.getTimerCount()` 直接断言「不该挂定时器」,把 `resolveNext...` 改回未过滤版本后该用例变红。`vitest src/components/image-editor` 924 通过 / 74 文件,typecheck、eslint、check:encoding 通过。 +- 关联文档:`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 8134d054c..b55ee9053 100644 --- a/src/components/image-editor/ImageCanvasEditorModel.test.ts +++ b/src/components/image-editor/ImageCanvasEditorModel.test.ts @@ -15,6 +15,7 @@ import { formatCanvasDisplayScalePercent, hydrateCanvasGenerationDialog, hydrateLayer, + INLINE_GENERATION_PLACEHOLDER_LIVE_WINDOW_MS, normalizeAssetLibrary, normalizeCanvasBackgroundHex, resolveNextInlineGenerationDialogExpiryAt, @@ -30,6 +31,7 @@ import type { CanvasLayer, EditorAsset, } from './ImageCanvasEditorTypes'; +import { PERFECT_PIXEL_PRE_POST_BUDGET_MS } from './useImageCanvasGenerationWorkflow'; describe('ImageCanvasEditorModel', () => { it('maps viewport scale to the user-facing canvas zoom scale', () => { @@ -1001,6 +1003,25 @@ describe('ImageCanvasEditorModel', () => { }); }); + it('keeps the placeholder live window above the whole client-side upper bound', () => { + // 中文注释:这条不等式散在三个常量里——提交前置预算、客户端 POST 超时、占位存活窗口—— + // 任何一处被单独调大都会破坏它,而破坏的后果是跨标签页误删:B 标签会清掉 A 标签仍在 + // 合法执行的占位,随后 A 的 POST 因占位不存在返回 409。本会话自己的占位另有归属登记 + // 豁免,不靠这个窗口,所以窗口只需覆盖跨标签页那一侧。 + const CLIENT_POST_TIMEOUT_MS = 120_000; + const clientUpperBoundMs = + PERFECT_PIXEL_PRE_POST_BUDGET_MS + CLIENT_POST_TIMEOUT_MS; + + expect(clientUpperBoundMs).toBe(210_000); + expect(INLINE_GENERATION_PLACEHOLDER_LIVE_WINDOW_MS).toBeGreaterThan( + clientUpperBoundMs, + ); + // 中文注释:余量不能只是「大于」。网络往返与标签页挂起后的时钟漂移都要吃掉一部分。 + expect( + INLINE_GENERATION_PLACEHOLDER_LIVE_WINDOW_MS - clientUpperBoundMs, + ).toBeGreaterThanOrEqual(30_000); + }); + describe('inline placeholder expiry helpers', () => { const dialog = ( overrides: Partial, @@ -1017,8 +1038,8 @@ describe('ImageCanvasEditorModel', () => { // 中文注释:与 dropDeadInlineGenerationPlaceholders 是同一条规则的两个作用面,边界 // 必须一致——正好到期不算过期,超过一毫秒才算。 const now = 1_700_000_000_000; - const live = [dialog({ requiresLiveSession: true, generationStartedAt: now - 180_000 })]; - const stale = [dialog({ requiresLiveSession: true, generationStartedAt: now - 180_001 })]; + const live = [dialog({ requiresLiveSession: true, generationStartedAt: now - 240_000 })]; + const stale = [dialog({ requiresLiveSession: true, generationStartedAt: now - 240_001 })]; expect(collectExpiredInlineGenerationDialogIds(live, now)).toEqual([]); expect(collectExpiredInlineGenerationDialogIds(stale, now)).toEqual([ @@ -1063,7 +1084,7 @@ describe('ImageCanvasEditorModel', () => { ]; expect(resolveNextInlineGenerationDialogExpiryAt(dialogs)).toBe( - now - 60_000 + 180_000, + now - 60_000 + 240_000, ); }); }); @@ -1132,7 +1153,7 @@ describe('ImageCanvasEditorModel', () => { buildDialogItem('boundary', { status: 'generating', requiresLiveSession: true, - generationStartedAt: now - 180_000, + generationStartedAt: now - 240_000, }), ]); @@ -1149,7 +1170,7 @@ describe('ImageCanvasEditorModel', () => { buildDialogItem('stale', { status: 'generating', requiresLiveSession: true, - generationStartedAt: now - 180_001, + generationStartedAt: now - 240_001, }), ]); diff --git a/src/components/image-editor/ImageCanvasEditorModel.ts b/src/components/image-editor/ImageCanvasEditorModel.ts index 08bbe1367..f9ca144f8 100644 --- a/src/components/image-editor/ImageCanvasEditorModel.ts +++ b/src/components/image-editor/ImageCanvasEditorModel.ts @@ -558,10 +558,14 @@ export function isUnresolvedCanvasGenerationDialogRecord( * * 上界由两侧共同封死:服务端最坏合法时长是处理预算 30 秒加持久化预算 60 秒(都由 * `timeout_at` 强制),客户端整个 POST 又被 `snapEditorImageToPixelArt` 的 120 秒超时封顶。 - * 所以 120 秒之后客户端必已 abort,并把占位改成 `failed` 或直接移除,服务端也早已越过 90 秒。 - * 取 180 秒 = 120 秒客户端上限 + 60 秒余量(网络往返、标签页被挂起后的时钟漂移)。 + * 客户端整条链的上界是提交前置预算 90 秒加 POST 120 秒 = 210 秒,之后必已 abort 并把占位改成 + * `failed` 或直接移除,服务端也早已越过自己的 90 秒。取 240 秒 = 210 秒客户端上界 + 30 秒余量 + * (网络往返、标签页被挂起后的时钟漂移)。 + * + * 本会话自己的占位另有归属登记豁免,不依赖这个窗口;窗口只用于跨标签页——B 标签不得清掉 + * A 标签仍在合法执行的占位,所以它必须大于 A 的客户端上界。 */ -const INLINE_GENERATION_PLACEHOLDER_LIVE_WINDOW_MS = 180_000; +export const INLINE_GENERATION_PLACEHOLDER_LIVE_WINDOW_MS = 240_000; function inlineGenerationPlaceholderExpiryAt( dialog: CanvasGenerationDialogState, diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts index 56adc07a9..c524f5b45 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts @@ -224,11 +224,15 @@ function createProjectLayerSnapshotFromLayer( // // 给整段一个预算而不是给每次请求加超时:上传最多三次尝试、布局保存最多四次,逐个加超时 // 的最坏总时长会累加到远超占位存活窗口,窗口的前提仍然不成立。整段封顶 40 秒之后, -// 客户端最坏 40 + 120 = 160 秒,落在 180 秒窗口内并留 20 秒余量。 +// 客户端最坏 90 + 120 = 210 秒,落在 240 秒窗口内并留 30 秒余量。 // // 超时抛的是裸 Error 而非 ApiClientError,会被归入未知结果走对账——上传可能已经完成、 // 素材可能已经落库,这正是对账要处理的情形。 -const PERFECT_PIXEL_PRE_POST_BUDGET_MS = 40_000; +// +// 取 90 秒而不是更紧:这段在源图是 inline/未登记时会真的直传一张画布图层,几 MB 的图在 +// 较差移动网络下就要几十秒,卡到 40 秒会把原本能成功的操作改判为失败——那是用「无界」换 +// 「过紧」,同样是回归。90 + 120 = 210 秒,落在 240 秒存活窗口内并留 30 秒余量。 +export const PERFECT_PIXEL_PRE_POST_BUDGET_MS = 90_000; async function withPerfectPixelPrePostBudget( work: Promise, diff --git a/src/components/image-editor/useInlineGenerationPlaceholderExpiry.test.tsx b/src/components/image-editor/useInlineGenerationPlaceholderExpiry.test.tsx index c95b2c92d..a8f0cd589 100644 --- a/src/components/image-editor/useInlineGenerationPlaceholderExpiry.test.tsx +++ b/src/components/image-editor/useInlineGenerationPlaceholderExpiry.test.tsx @@ -7,7 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { CanvasGenerationDialogState } from './ImageCanvasEditorTypes'; import { useInlineGenerationPlaceholderExpiry } from './useInlineGenerationPlaceholderExpiry'; -const WINDOW_MS = 180_000; +const WINDOW_MS = 240_000; function dialog( overrides: Partial, @@ -178,11 +178,17 @@ describe('useInlineGenerationPlaceholderExpiry', () => { />, ); + // 中文注释:不仅不能删,还不能给它挂定时器。归属过滤若只作用于「已到期」而不作用于 + // 「下一到期时刻」,这个超窗占位仍会被算出一个已经过去的到期时刻,delayMs 塌成 50ms, + // 触发 → tick → 重跑 → 再挂,变成每 50 毫秒一次 setState 的忙等。 + expect(vi.getTimerCount()).toBe(0); + act(() => { vi.advanceTimersByTime(WINDOW_MS * 2); }); expect(removeCanvasGenerationDialogById).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); }); it('expires the placeholder once the session releases ownership', () => { diff --git a/src/components/image-editor/useInlineGenerationPlaceholderExpiry.ts b/src/components/image-editor/useInlineGenerationPlaceholderExpiry.ts index 4bc710c05..14687eaa9 100644 --- a/src/components/image-editor/useInlineGenerationPlaceholderExpiry.ts +++ b/src/components/image-editor/useInlineGenerationPlaceholderExpiry.ts @@ -50,10 +50,16 @@ export function useInlineGenerationPlaceholderExpiry({ // // 这一条不能靠「到期重新判定」兜住:重新判定只能识别「已经收口的占位」,识别不出 // 「仍在合法运行的占位」——后者正处于要被删除的那个状态。归属必须显式登记。 + // 中文注释:归属过滤必须先于两处判定同时生效。只过滤 expiredIds 会留下一个死循环: + // 被本会话持有的超窗占位不进 expiredIds,却仍被 resolveNext... 算出一个**已经过去**的 + // 到期时刻,delayMs 塌成 50ms,定时器触发 → tick → 重跑 → 状态没变 → 再挂 50ms, + // 变成每 50 毫秒一次 setState 的忙等。可达路径是真实的:前置 90 秒 + POST 120 秒之后 + // catch 里还要做对账 GET,而归属要到 finally 才释放。 const activeDialogIds = activeInlineGenerationDialogIdsRef.current; - const expiredIds = collectExpiredInlineGenerationDialogIds( - canvasGenerationDialogs, - ).filter((dialogId) => !activeDialogIds?.has(dialogId)); + const unownedDialogs = canvasGenerationDialogs.filter( + (dialog) => !activeDialogIds?.has(dialog.id), + ); + const expiredIds = collectExpiredInlineGenerationDialogIds(unownedDialogs); if (expiredIds.length > 0) { for (const dialogId of expiredIds) { removeCanvasGenerationDialogById(dialogId); @@ -61,9 +67,8 @@ export function useInlineGenerationPlaceholderExpiry({ onPlaceholdersExpired(expiredIds.length); return undefined; } - const nextExpiryAt = resolveNextInlineGenerationDialogExpiryAt( - canvasGenerationDialogs, - ); + const nextExpiryAt = + resolveNextInlineGenerationDialogExpiryAt(unownedDialogs); if (nextExpiryAt === null) { return undefined; } diff --git a/src/services/image-editor/editorProjectClient.ts b/src/services/image-editor/editorProjectClient.ts index 51d348384..1fb8ff6df 100644 --- a/src/services/image-editor/editorProjectClient.ts +++ b/src/services/image-editor/editorProjectClient.ts @@ -785,6 +785,10 @@ export async function loadEditorProject(projectId: string) { `${EDITOR_PROJECT_API_BASE}/${encodeURIComponent(projectId)}`, { method: 'GET' }, '读取图片画布工程失败', + // 中文注释:本文件多数接口都显式写了超时,这里此前没有——而 composeAbortSignal 在 + // timeoutMs 缺失时不设任何默认值。它是未知结果对账路径上的读取,挂住会让 catch 迟迟 + // 不结束,本会话对占位的归属登记要到 finally 才释放,连带把占位拖过存活窗口。 + { timeoutMs: 60_000 }, ); return response.project; }