到期清理补归属登记,并给提交前置阶段加预算
Project CI / Repository checks (pull_request) Successful in 49s
Project CI / Backend tests (pull_request) Successful in 4m6s
Project CI / Frontend tests (pull_request) Failing after 2m8s
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
@@ -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 秒就 abort180 秒时不可能还是 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`
@@ -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 },
);
}