复查修掉新引入的忙等、无界项目读取与过紧的前置预算
Project CI / Repository checks (pull_request) Successful in 48s
Project CI / Frontend tests (pull_request) Failing after 2m11s
Project CI / Backend tests (pull_request) Successful in 4m23s
Project CI / Native shell tests (pull_request) Successful in 12m38s

忙等(最严重,上一条新引入):归属过滤只加在 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 <noreply@anthropic.com>
This commit is contained in:
2026-08-03 11:34:04 +00:00
parent aa8ea401a9
commit 6fb2abfc60
7 changed files with 72 additions and 17 deletions
@@ -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`
@@ -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<CanvasGenerationDialogState>,
@@ -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,
}),
]);
@@ -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,
@@ -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<T>(
work: Promise<T>,
@@ -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<CanvasGenerationDialogState>,
@@ -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', () => {
@@ -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;
}
@@ -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;
}