到期清理补归属登记,并给提交前置阶段加预算
到期清理按状态删除超窗占位,区分不出它属于已死会话还是本会话仍在执行。占位创建 后还要走源图解析/直传和 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:
@@ -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`。
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user