复查修掉新引入的忙等、无界项目读取与过紧的前置预算
忙等(最严重,上一条新引入):归属过滤只加在 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:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user