修复内联生成占位归属清理

引入可观察的占位归属注册表,让归属释放可靠触发到期清理。

为完美像素首次提交和人工重试路径配对登记与释放,并统一恢复查询。

补充固定输入与 StrictMode 覆盖,移除依赖 rerender 的假触发。

同步图片画布技术方案与项目决策记录。
This commit is contained in:
2026-08-04 05:33:52 +00:00
parent 193c17d6ed
commit f6628ead7e
6 changed files with 192 additions and 84 deletions
@@ -6213,3 +6213,11 @@
- 文档收口:后端数据契约、连接池 Drop 说明、图片画布方案、decision log 与 pitfalls 同步撤回“相对断言可消除并行竞态”的错误保证。连接池 lease 的 Drop 只保证本地 slot / permit 可回收,不表示 handler timeout/drop 能取消或回滚已经发出的远端 procedure。
- 验证结果:`cargo test --manifest-path server-rs/Cargo.toml -p api-server pixel_art_snap` 为 17 通过 / 0 失败;`npm run check:rustfmt``npm run check:encoding`5153 个文件)与 `git diff --check` 通过。
- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md``docs/【后端架构】SpacetimeDB连接池租约Drop兜底与取消安全-2026-06-11.md``docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`
## 2026-08-04 inline 占位 ownership 释放改为可观察信号
- 覆盖缺口:2026-08-03 的“释放归属后同一超窗占位立即清理”测试在 `rerender` 时同时创建了新的 dialogs 数组和 callbackeffect 实际由这些依赖变化唤醒;它没有证明 `finally` 里单独执行 `Set.delete()` 会重新判定。生产实现把稳定 ref 对象放进依赖,但 React 不观察 `.current` 内容变化,因此旧测试与旧实现之间存在同一个盲区。
- 决策:Set 继续作为首个 await 前同步可见的 ownership 真值,但封装进 `useInlineGenerationPlaceholderOwnership`,不再向 View 暴露可变 ref。`claim / release` 只有在 membership 真变化时才推进 version;到期 effect 同时依赖稳定 `has` 和 version。首次提交与人工 exact replay 通过同一份 ownership 登记 / 释放,hydrate GET-only 恢复只查询这份 ownership 来避开本页 live Promise,不能在 View 创建第二份 registry。
- 时序边界:`claim` 仍紧挨占位创建且早于任何 await;`release` 仍位于 `finally`。version 只负责 React 通知,不替代同步 Set,也不清理 observed recovery key;否则可能在 live Promise 尚未退出时启动第二条 GET。重复 claim / release 为幂等 no-op,不额外触发 effect。
- 验证:hook 定向测试使用生产 ownership hook、固定 dialogs 数组和固定 callbacks,并包在 StrictMode 中;单次 render 后先 claim 取消到期 timer,推进到超窗仍不清理,再仅 release 唤醒 effect并清理一次。重复 claim / release 分别保持 version `1 / 2`,删除与通知均只发生一次。hook 定向测试 10 条、generation workflow 定向测试 81 条通过;`npm run typecheck`、全仓 `npm run lint:eslint``npm run check:encoding``git diff --check` 通过。未追加其它测试或全量测试套件。
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`
@@ -177,6 +177,7 @@
- 完美像素前端第二批以 durable operation 为提交边界:生成占位必须持久化 `perfectPixelOperation = { version: 1, kind: "perfect-pixel", operationId, taskId, request, submittedAt, reconcileUntil }`,其中 `operationId` 等于规范化 dialog id、`taskId` 固定为 `pixel-art-snap-{operationId}``request` 是稳定源引用解析完成后的完整 `EditorPixelArtSnapInput``submittedAt / reconcileUntil` 构成从快照写入起算、不得被 POST 回包续期的 75 秒整链绝对窗口。完美像素 dialog id 使用跨标签随机 identity,不能复用每个标签页都会从 1 开始的局部计数器。inline 源图以该 identity 作为稳定 upload ID,只执行 object-only 上传,不等待 signed URL;快照不得包含 Data URL、Blob URL 或 signed URL。POST 前必须取得包含该 dialog 与 operation 快照的布局保存成功确认,保存冲突、鉴权失败、重试耗尽或无法确认时 POST 必须为零。人工重试只能原样重放该快照与同一 operation,不得重新 placement、上传、读取当前图层字段或暗中换 identity;快照缺失、损坏或与 dialog / project / task / completion 不匹配时失败关闭。首次提交或人工重试在途期间若 owner、project 或组件生命周期已经变化,旧响应的素材写入、项目应用、提示与对账副作用必须全部忽略,不能把前一账号的结果写入当前账号状态。
- 完美像素 unknown-result 的 verdict 只来自项目 GET,POST 响应体不得直接判成功:找到唯一稳定 task resource 且 dialog 已收口、结果层精确指向该 resource 时为 `Applied`resource 存在且 dialog 不存在时为 `DialogMissing`,结果只在素材库;dialog 仍 generating(包括匹配 resource 已先可见)或 dialog 不存在且无匹配 resource 时继续有界轮询;resource 与 dialog / layer 出现原子事务不可能产生的错配时保持待确认并提示冲突,禁止自动 POST。首个 GET 立即执行,此后退避不超过 5 秒;即使绝对窗口已过期也必须读取一次。`refreshAssetLibrary` 只在终态后 best-effort 触发,不进入轮询 deadline、`Promise.all` 或成功判断,同步 throw、异步 reject 和永久挂起均不得阻塞。轮询到期或 GET 失败后 dialog 转 `pending-confirmation`,保留 operation 与请求快照并释放页面 busy,不得伪装成普通失败或声称素材已保存。
- 完美像素恢复只对账、不重新执行:项目 hydrate 后识别带有效 operation 快照的 `generating` / `pending-confirmation` dialog,只按稳定 task/resource 做 GET-only 轮询,绝不 POST、重新上传、重新准备来源或为了恢复而先写布局;owner/project 切换、卸载或更高 revision 到来时旧轮询结果不得生效。新写入的 v1 operation 固定使用 75 秒跨度;为兼容第一批和滚动升级中的旧标签页,hydrate 仍接受跨度及未来时钟偏差不超过 240 秒的旧 v1 journal。若旧 `submittedAt` 位于可接受的未来区间,先把它规范化到当前时间,再把 `reconcileUntil` 压到 `min(持久截止, 规范化 submittedAt + 75 秒, 当前时间 + 75 秒)`;写回形状必须继续满足 `reconcileUntil >= submittedAt`,确保下次 hydrate 仍保留同一 identity。新 durable operation 无论在哪个标签页、是否超过 legacy TTL 都不得被 `requiresLiveSession` 或普通删除路径清理;TTL 只兼容完全没有 operation journal 字段的历史 inline 孤儿,字段存在但内容损坏时必须保留并失败关闭,清理 legacy 孤儿时必须同时更新 `project.layers``project.canvas.layers`。恢复到期仍持久保持 `pending-confirmation`,只有用户明确点击重试才进入 exact replay。
- legacy inline 占位的本会话归属必须由同一份封装 ownership 管理:同步 Set 在首个 await 前完成 `claim`,保证到期判定即时可见;`claim / release` 仅在 membership 真变化时推进 React 可观察的 version`release` 即使发生时 dialogs 与 callbacks identity 都不变,也必须立即唤醒到期 effect 重新判定。禁止重新暴露可变 Set ref 或直接修改 `.current`,React 不会因为 ref 内容变化而重跑 effect。
- 重绘生成资源后,右侧出现新生成结果图层,并自动 fit 原图 + 新图,且重绘面板保持打开。
- 快速编辑 / 重绘站内 public 示例图、历史 generated 图或 OSS generated 图时,优先复用当前图层已有 `objectKey` / `resourceId` / `sourceAssetId`;尚未登记且没有稳定引用的浏览器本地图片或普通 public 图片路径都必须先上传并取得 objectKey。前端不得再把正式对象下载成 `data:image/*;base64,...` 后提交,也不得把 Data URL / Blob URL 写入外部生成持久任务 JSON;后端收到引用后统一做 owner 归属校验并签名读取。
- 快速编辑不保留额外参考图入口;点击修改时只把原图或红框序号标注图作为 `/api/editor/images/edits``sourceImageSrc` 提交给后端。
@@ -1633,7 +1633,7 @@ export function ImageCanvasEditorView({
openCropExpandPanel,
removeSelectedLayerBackground,
snapSelectedLayerToPerfectPixels,
activeInlineGenerationDialogIdsRef,
activeInlineGenerationDialogOwnership,
perfectPixelLayerIds,
pendingPerfectPixelLayerIds,
splitSelectedIconSpritesheet,
@@ -1964,7 +1964,7 @@ export function ImageCanvasEditorView({
// 否则它会一直转到用户下一次加载。两条路径共用同一条文案,用户感知一致。
useInlineGenerationPlaceholderExpiry({
canvasGenerationDialogs,
activeInlineGenerationDialogIdsRef,
activeInlineGenerationDialogOwnership,
removeCanvasGenerationDialogById,
onPlaceholdersExpired: handleInlinePlaceholdersExpired,
});
@@ -122,6 +122,7 @@ import {
resolveEditorGenerationMediaReference,
useImageCanvasGenerationSubmissionWorkflow,
} from './useImageCanvasGenerationSubmissionWorkflow';
import { useInlineGenerationPlaceholderOwnership } from './useInlineGenerationPlaceholderExpiry';
type CanvasSize = { width: number; height: number };
@@ -988,7 +989,16 @@ export function useImageCanvasGenerationWorkflow({
// 而它无法从 dialog 状态区分「已死会话留下的」和「本会话正在跑的」——两者都是
// requiresLiveSession + generating。占位创建后还要走源图解析/直传和 flush 才轮到
// 受超时保护的 POST,这段慢起来会越过存活窗口,届时定时器会删掉自己正在用的占位。
const activeInlineGenerationDialogIdsRef = useRef(new Set<string>());
//
// ownership 内部保留同步 Set,并通过 version 把 claim / release 通知给 React。调用方不得
// 直接改 Set,否则 finally 释放后到期 effect 不会重新判定。
const activeInlineGenerationDialogOwnership =
useInlineGenerationPlaceholderOwnership();
const {
claim: claimActiveInlineGenerationDialog,
release: releaseActiveInlineGenerationDialog,
has: hasActiveInlineGenerationDialog,
} = activeInlineGenerationDialogOwnership;
const perfectPixelRecoveryControllersRef = useRef(
new Map<string, AbortController>(),
);
@@ -2257,7 +2267,7 @@ export function useImageCanvasGenerationWorkflow({
perfectPixelDialogId = placement.dialogId;
// 中文注释:紧挨着创建注册,中间不能有 await——否则会留出一个「占位已存在但尚未
// 登记归属」的窗口,到期清理正好可以在那里把它删掉。
activeInlineGenerationDialogIdsRef.current.add(perfectPixelDialogId);
claimActiveInlineGenerationDialog(perfectPixelDialogId);
if (!placement.placeholder) {
throw new Error('无法创建完美像素处理占位');
}
@@ -2468,9 +2478,7 @@ export function useImageCanvasGenerationWorkflow({
}
} finally {
if (perfectPixelDialogId) {
activeInlineGenerationDialogIdsRef.current.delete(
perfectPixelDialogId,
);
releaseActiveInlineGenerationDialog(perfectPixelDialogId);
}
perfectPixelLayerIdsRef.current.delete(sourceLayer.id);
setPerfectPixelLayerIds((currentLayerIds) => {
@@ -2494,6 +2502,8 @@ export function useImageCanvasGenerationWorkflow({
isPerfectPixelAuthorityCurrent,
openPlacedCanvasGenerationDialog,
projectId,
claimActiveInlineGenerationDialog,
releaseActiveInlineGenerationDialog,
setCharacterAnimationPanel,
setCropExpandPanel,
setImageContextMenu,
@@ -2542,26 +2552,26 @@ export function useImageCanvasGenerationWorkflow({
if (perfectPixelLayerIdsRef.current.has(lockKey)) {
return;
}
perfectPixelLayerIdsRef.current.add(lockKey);
activeInlineGenerationDialogIdsRef.current.add(normalizedDialogId);
if (dialog.sourceLayerId) {
setPerfectPixelLayerIds((currentLayerIds) => {
const nextLayerIds = new Set(currentLayerIds);
nextLayerIds.add(dialog.sourceLayerId!);
return nextLayerIds;
});
}
const retriedOperation =
createPerfectPixelReconciliationOperation(operation);
observedPerfectPixelRecoveryKeysRef.current.add(
perfectPixelRecoveryKey(
currentUserId,
normalizedProjectId,
retriedOperation,
),
);
let postAttempted = false;
perfectPixelLayerIdsRef.current.add(lockKey);
claimActiveInlineGenerationDialog(normalizedDialogId);
try {
if (dialog.sourceLayerId) {
setPerfectPixelLayerIds((currentLayerIds) => {
const nextLayerIds = new Set(currentLayerIds);
nextLayerIds.add(dialog.sourceLayerId!);
return nextLayerIds;
});
}
observedPerfectPixelRecoveryKeysRef.current.add(
perfectPixelRecoveryKey(
currentUserId,
normalizedProjectId,
retriedOperation,
),
);
updateCanvasGenerationDialogById(normalizedDialogId, (current) => ({
...current,
status: 'generating',
@@ -2657,7 +2667,7 @@ export function useImageCanvasGenerationWorkflow({
errorMessage,
}));
} finally {
activeInlineGenerationDialogIdsRef.current.delete(normalizedDialogId);
releaseActiveInlineGenerationDialog(normalizedDialogId);
perfectPixelLayerIdsRef.current.delete(lockKey);
if (dialog.sourceLayerId) {
setPerfectPixelLayerIds((currentLayerIds) => {
@@ -2674,10 +2684,12 @@ export function useImageCanvasGenerationWorkflow({
[
applyProjectSnapshot,
canvasGenerationDialogs,
claimActiveInlineGenerationDialog,
currentUserId,
flushProjectPersistence,
isPerfectPixelAuthorityCurrent,
projectId,
releaseActiveInlineGenerationDialog,
showGenerationWarning,
settleLivePerfectPixelVerdict,
updateCanvasGenerationDialogById,
@@ -2739,7 +2751,7 @@ export function useImageCanvasGenerationWorkflow({
);
// 中文注释:本页首次 POST / 人工重试已有自己的 Promise 收口。effect 会在 operation
// 写入后重跑,必须把该 key 记为本会话已观察并跳过,否则会并发启动第二条 GET 轮询。
if (activeInlineGenerationDialogIdsRef.current.has(dialog.id)) {
if (hasActiveInlineGenerationDialog(dialog.id)) {
observedPerfectPixelRecoveryKeysRef.current.add(recoveryKey);
continue;
}
@@ -2877,6 +2889,7 @@ export function useImageCanvasGenerationWorkflow({
canvasGenerationDialogs,
currentUserId,
flushProjectPersistence,
hasActiveInlineGenerationDialog,
hasCanvasGenerationDialogById,
projectId,
refreshPerfectPixelAssetLibrary,
@@ -3740,7 +3753,7 @@ export function useImageCanvasGenerationWorkflow({
removeSelectedLayerBackground,
snapSelectedLayerToPerfectPixels,
retryPerfectPixelOperation,
activeInlineGenerationDialogIdsRef,
activeInlineGenerationDialogOwnership,
perfectPixelLayerIds,
pendingPerfectPixelLayerIds,
splitSelectedIconSpritesheet,
@@ -3820,6 +3833,7 @@ export function useImageCanvasGenerationWorkflow({
isPickingPublicationReferenceFromCanvas,
generationWarning,
generationWarningVersion,
activeInlineGenerationDialogOwnership,
openBackgroundMusicGenerationDialog,
openCharacterAnimationPanel,
openCharacterGenerationDialog,
@@ -1,14 +1,17 @@
/* @vitest-environment jsdom */
import { act, render } from '@testing-library/react';
import { useRef } from 'react';
import { act, render, renderHook } from '@testing-library/react';
import { StrictMode } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type {
CanvasGenerationDialogState,
PerfectPixelOperationSnapshot,
} from './ImageCanvasEditorTypes';
import { useInlineGenerationPlaceholderExpiry } from './useInlineGenerationPlaceholderExpiry';
import {
useInlineGenerationPlaceholderExpiry,
useInlineGenerationPlaceholderOwnership,
} from './useInlineGenerationPlaceholderExpiry';
const WINDOW_MS = 240_000;
@@ -60,18 +63,16 @@ 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);
const activeInlineGenerationDialogOwnership =
useInlineGenerationPlaceholderOwnership();
useInlineGenerationPlaceholderExpiry({
canvasGenerationDialogs: dialogs,
activeInlineGenerationDialogIdsRef,
activeInlineGenerationDialogOwnership,
removeCanvasGenerationDialogById,
onPlaceholdersExpired,
});
@@ -106,9 +107,7 @@ describe('useInlineGenerationPlaceholderExpiry', () => {
perfectPixelOperation: perfectPixelOperation(dialogId),
}),
]}
removeCanvasGenerationDialogById={
removeCanvasGenerationDialogById
}
removeCanvasGenerationDialogById={removeCanvasGenerationDialogById}
onPlaceholdersExpired={onPlaceholdersExpired}
/>,
);
@@ -193,7 +192,9 @@ describe('useInlineGenerationPlaceholderExpiry', () => {
const startedAt = Date.now() - WINDOW_MS + 1_000;
const { rerender } = render(
<Harness
dialogs={[dialog({ requiresLiveSession: true, generationStartedAt: startedAt })]}
dialogs={[
dialog({ requiresLiveSession: true, generationStartedAt: startedAt }),
]}
removeCanvasGenerationDialogById={removeCanvasGenerationDialogById}
onPlaceholdersExpired={onPlaceholdersExpired}
/>,
@@ -267,23 +268,35 @@ describe('useInlineGenerationPlaceholderExpiry', () => {
// 在状态上完全一致——只能靠显式登记归属区分。删掉自己正在用的占位会让随后的 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()}
/>,
const onPlaceholdersExpired = vi.fn();
const dialogs = [
dialog({
requiresLiveSession: true,
generationStartedAt: Date.now() - WINDOW_MS + 1_000,
}),
];
const { result } = renderHook(
() => {
const ownership = useInlineGenerationPlaceholderOwnership();
useInlineGenerationPlaceholderExpiry({
canvasGenerationDialogs: dialogs,
activeInlineGenerationDialogOwnership: ownership,
removeCanvasGenerationDialogById,
onPlaceholdersExpired,
});
return ownership;
},
{ wrapper: StrictMode },
);
// 中文注释:不仅不能删,还不能给它挂定时器。归属过滤若只作用于「已到期」而不作用于
// 「下一到期时刻」,这个超窗占位仍会被算出一个已经过去的到期时刻,delayMs 塌成 50ms
// 触发 → tick → 重跑 → 再挂,变成每 50 毫秒一次 setState 的忙等。
expect(vi.getTimerCount()).toBe(1);
act(() => {
expect(result.current.claim('dialog-1')).toBe(true);
});
expect(result.current.version).toBe(1);
// 中文注释:claim 只改变 ownership 的私有 Set 与 versiondialogs 和两个 callback
// identity 始终不变。归属过滤必须同时取消下一到期 timer,不能在超窗后进入 50ms 忙等。
expect(vi.getTimerCount()).toBe(0);
act(() => {
@@ -291,37 +304,60 @@ describe('useInlineGenerationPlaceholderExpiry', () => {
});
expect(removeCanvasGenerationDialogById).not.toHaveBeenCalled();
expect(onPlaceholdersExpired).not.toHaveBeenCalled();
expect(vi.getTimerCount()).toBe(0);
});
it('expires the placeholder once the session releases ownership', () => {
// 中文注释:归属在 finally 里释放。释放之后同一个超窗占位必须能被清掉,否则失败退出的
// 会话会留下永久转圈的占位
// 中文注释:归属在 finally 里释放。此用例不 rerender,也不更换 dialogs / callbacks
// 唯一唤醒 effect 的必须是生产 ownership hook 在 release 时推进的 version
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()}
/>,
const onPlaceholdersExpired = vi.fn();
const dialogs = [
dialog({
requiresLiveSession: true,
generationStartedAt: Date.now() - WINDOW_MS + 1_000,
}),
];
const { result } = renderHook(
() => {
const ownership = useInlineGenerationPlaceholderOwnership();
useInlineGenerationPlaceholderExpiry({
canvasGenerationDialogs: dialogs,
activeInlineGenerationDialogOwnership: ownership,
removeCanvasGenerationDialogById,
onPlaceholdersExpired,
});
return ownership;
},
{ wrapper: StrictMode },
);
act(() => {
expect(result.current.claim('dialog-1')).toBe(true);
expect(result.current.claim('dialog-1')).toBe(false);
});
expect(result.current.version).toBe(1);
act(() => {
vi.advanceTimersByTime(2_000);
});
expect(removeCanvasGenerationDialogById).not.toHaveBeenCalled();
rerender(
<Harness
dialogs={[expired]}
activeDialogIds={[]}
removeCanvasGenerationDialogById={removeCanvasGenerationDialogById}
onPlaceholdersExpired={vi.fn()}
/>,
);
act(() => {
expect(result.current.release('dialog-1')).toBe(true);
});
expect(result.current.version).toBe(2);
expect(removeCanvasGenerationDialogById).toHaveBeenCalledWith('dialog-1');
expect(removeCanvasGenerationDialogById).toHaveBeenCalledOnce();
expect(onPlaceholdersExpired).toHaveBeenCalledWith(1);
expect(onPlaceholdersExpired).toHaveBeenCalledOnce();
act(() => {
expect(result.current.release('dialog-1')).toBe(false);
});
expect(result.current.version).toBe(2);
expect(removeCanvasGenerationDialogById).toHaveBeenCalledOnce();
expect(onPlaceholdersExpired).toHaveBeenCalledOnce();
});
it('never arms a timer for queue-backed placeholders', () => {
@@ -1,4 +1,4 @@
import { type RefObject, useEffect, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
collectExpiredInlineGenerationDialogIds,
@@ -6,9 +6,54 @@ import {
} from './ImageCanvasEditorModel';
import type { CanvasGenerationDialogState } from './ImageCanvasEditorTypes';
export type InlineGenerationPlaceholderOwnership = Readonly<{
version: number;
claim: (dialogId: string) => boolean;
release: (dialogId: string) => boolean;
has: (dialogId: string) => boolean;
}>;
/**
* 中文注释:同步 Set 是本会话 ownership 的即时真值;version 只负责把 membership 变化
* 通知给 React。所有写入都必须经过 claim / release,避免直接修改 ref 后 effect 无法观察。
*/
export function useInlineGenerationPlaceholderOwnership(): InlineGenerationPlaceholderOwnership {
const activeDialogIdsRef = useRef(new Set<string>());
const [version, setVersion] = useState(0);
const claim = useCallback((dialogId: string) => {
if (activeDialogIdsRef.current.has(dialogId)) {
return false;
}
activeDialogIdsRef.current.add(dialogId);
setVersion((currentVersion) => currentVersion + 1);
return true;
}, []);
const release = useCallback((dialogId: string) => {
if (!activeDialogIdsRef.current.delete(dialogId)) {
return false;
}
setVersion((currentVersion) => currentVersion + 1);
return true;
}, []);
const has = useCallback(
(dialogId: string) => activeDialogIdsRef.current.has(dialogId),
[],
);
return useMemo(
() => ({
version,
claim,
release,
has,
}),
[claim, has, release, version],
);
}
type InlineGenerationPlaceholderExpiryOptions = {
canvasGenerationDialogs: CanvasGenerationDialogState[];
activeInlineGenerationDialogIdsRef: RefObject<Set<string>>;
activeInlineGenerationDialogOwnership: InlineGenerationPlaceholderOwnership;
removeCanvasGenerationDialogById: (dialogId: string) => void;
onPlaceholdersExpired: (expiredCount: number) => void;
};
@@ -31,17 +76,21 @@ type InlineGenerationPlaceholderExpiryOptions = {
* `delete-generation-result` 历史、清空选中、切回选择工具。自动清理记用户没做过的历史、
* 抢走用户当前的选中态和工具,都是错的;加载期剥离同样不做这些。
*
* 三、本会话自己在途的占位不会被误清。客户端 120 秒就会 abortcatch 会把它推离
* `generating`180 秒时它不可能还处于 `generating`。但这里不依赖该推理——第一点的
* 「到期重新判定」本身就兜住了:条件不成立就什么都不做
* 三、本会话自己在途的占位不会被误清。ownership 的同步 Set 负责在首个 await 前立即
* 挡住清理;claim / release 同时推进可观察 version,让 membership 变化必然触发重新判定。
* 不能把可变 ref 对象本身放进依赖后直接修改 `.current`,React 不会观察这种变化
*/
export function useInlineGenerationPlaceholderExpiry({
canvasGenerationDialogs,
activeInlineGenerationDialogIdsRef,
activeInlineGenerationDialogOwnership,
removeCanvasGenerationDialogById,
onPlaceholdersExpired,
}: InlineGenerationPlaceholderExpiryOptions) {
const [expiryTick, setExpiryTick] = useState(0);
const {
has: isDialogOwnedByCurrentSession,
version: activeInlineGenerationOwnershipVersion,
} = activeInlineGenerationDialogOwnership;
useEffect(() => {
// 中文注释:本会话仍在执行的占位一律跳过。到期清理只针对已死会话留下的孤儿,而
// dialog 状态区分不出这两者——本会话在源图直传或布局保存阶段慢起来时,它的占位同样
@@ -55,9 +104,8 @@ export function useInlineGenerationPlaceholderExpiry({
// 到期时刻,delayMs 塌成 50ms,定时器触发 → tick → 重跑 → 状态没变 → 再挂 50ms,
// 变成每 50 毫秒一次 setState 的忙等。可达路径是真实的:前置 90 秒 + POST 120 秒之后
// catch 里还要做对账 GET,而归属要到 finally 才释放。
const activeDialogIds = activeInlineGenerationDialogIdsRef.current;
const unownedDialogs = canvasGenerationDialogs.filter(
(dialog) => !activeDialogIds?.has(dialog.id),
(dialog) => !isDialogOwnedByCurrentSession(dialog.id),
);
const expiredIds = collectExpiredInlineGenerationDialogIds(unownedDialogs);
if (expiredIds.length > 0) {
@@ -82,9 +130,10 @@ export function useInlineGenerationPlaceholderExpiry({
window.clearTimeout(timer);
};
}, [
activeInlineGenerationDialogIdsRef,
activeInlineGenerationOwnershipVersion,
canvasGenerationDialogs,
expiryTick,
isDialogOwnedByCurrentSession,
onPlaceholdersExpired,
removeCanvasGenerationDialogById,
]);