到期清理补归属登记,并给提交前置阶段加预算
Project CI / Repository checks (pull_request) Successful in 49s
Project CI / Frontend tests (pull_request) Failing after 2m8s
Project CI / Backend tests (pull_request) Successful in 4m6s
Project CI / Native shell tests (pull_request) Successful in 12m28s

到期清理按状态删除超窗占位,区分不出它属于已死会话还是本会话仍在执行。占位创建
后还要走源图解析/直传和 flush 才轮到 POST,而 120 秒只从最终 POST 开始计——前置
阶段慢起来时,定时器会删掉自己正在用的占位,随后 POST 返回 409。

我写在 hook 注释里的两条安全性论证都是错的:120 秒不覆盖前置阶段;「到期重新
判定兜得住」也不成立,重新判定只能识别已收口的占位,识别不出仍在合法运行的。

补 activeInlineGenerationDialogIdsRef 显式登记归属,创建后紧挨着注册、finally
释放,到期清理跳过。

前置阶段此前完全无界:直传是裸 fetch 无 signal,saveEditorProjectLayout 没传
timeoutMs 而 composeAbortSignal 缺失时不设默认值。给整段 40 秒预算而不是逐请求
加超时——后者最坏会因重试累加到远超窗口。客户端最坏 40 + 120 = 160 秒。
另给 saveEditorProjectLayout 补 60 秒超时,那是独立缺陷。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-03 11:24:22 +00:00
parent 170806124b
commit aa8ea401a9
6 changed files with 166 additions and 14 deletions
@@ -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') {
@@ -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<T>(
work: Promise<T>,
deadlineAt: number,
timeoutMessage: string,
): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
work,
new Promise<never>((_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<Set<string>>(() => new Set());
const perfectPixelLayerIdsRef = useRef(new Set<string>());
// 中文注释:本会话仍在执行的 inline 占位 id。到期清理只该针对**别人**留下的孤儿,
// 而它无法从 dialog 状态区分「已死会话留下的」和「本会话正在跑的」——两者都是
// requiresLiveSession + generating。占位创建后还要走源图解析/直传和 flush 才轮到
// 受超时保护的 POST,这段慢起来会越过存活窗口,届时定时器会删掉自己正在用的占位。
const activeInlineGenerationDialogIdsRef = useRef(new Set<string>());
const [perfectPixelLayerIds, setPerfectPixelLayerIds] = useState<Set<string>>(
() => 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,
@@ -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(
<Harness
dialogs={[
dialog({
requiresLiveSession: true,
generationStartedAt: Date.now() - WINDOW_MS - 60_000,
}),
]}
activeDialogIds={['dialog-1']}
removeCanvasGenerationDialogById={removeCanvasGenerationDialogById}
onPlaceholdersExpired={vi.fn()}
/>,
);
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(
<Harness
dialogs={[expired]}
activeDialogIds={['dialog-1']}
removeCanvasGenerationDialogById={removeCanvasGenerationDialogById}
onPlaceholdersExpired={vi.fn()}
/>,
);
expect(removeCanvasGenerationDialogById).not.toHaveBeenCalled();
rerender(
<Harness
dialogs={[expired]}
activeDialogIds={[]}
removeCanvasGenerationDialogById={removeCanvasGenerationDialogById}
onPlaceholdersExpired={vi.fn()}
/>,
);
expect(removeCanvasGenerationDialogById).toHaveBeenCalledWith('dialog-1');
});
it('never arms a timer for queue-backed placeholders', () => {
// 中文注释:队列型占位的 job 在服务端继续跑,worker 会替换占位。自动清理会让用户以为
// 操作没发生而重复提交。
@@ -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<Set<string>>;
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,
@@ -819,6 +819,10 @@ export async function saveEditorProjectLayout(
expectedRevision: input.expectedRevision,
}),
'保存图片画布工程失败',
// 中文注释:本文件其余接口都显式写了超时,唯独这里没有——而 composeAbortSignal 在
// timeoutMs 缺失时不设任何默认值,于是布局保存可以无限期挂住。它被 flushProjectPersistence
// 同步等待在生成提交路径上,挂住会连带把占位拖过存活窗口。
{ timeoutMs: 60_000 },
);
}