对账整段设界,并把提交前置预算变成真正的取消
对账用 Promise.all 同时等项目快照与 refreshAssetLibrary,而 loadEditorAssetLibrary 没有 timeoutMs。catch 也接不住永不 settle:await 不返回则 finally 永远不执行, 占位归属登记与源图层锁都释放不掉,而到期清理又豁免已登记的占位,页面永久停在 generating。这个洞是上一条留下的——给 loadEditorProject 加界时只覆盖了同一个 Promise.all 里两个 await 中的一个。 改为给整段对账设 75 秒上界,往里加新的 await 自动受约束。超时解析为 null 而不 拒绝:这段在 catch 内,抛出会穿出 async 函数变成未处理 rejection;null 则落进 既有的「快照读取失败」分支。 前置预算原先只是 Promise.race,超时后上传继续跑到 confirm 并注册对象。把同一个 AbortSignal 贯穿凭证、直传与 confirm 三步,由预算到期时 abort——只停其中一步会 留下半成品。未做重试等待与 flush 的贯穿:收益是少产生不可见的存储孤儿,而 flush 已有 60 秒上界。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -6171,3 +6171,15 @@
|
||||
- 真正的问题是验证方式而非测试:改的是 `src/services/image-editor/editorProjectClient.ts`,验证却只跑了 `vitest src/components/image-editor`,改动面与验证面完全对不上。这个盲区在本次会话中期分析另一份 CI 日志时已由我自己指出过,却没有改掉习惯,于是同一个盲区再次漏出——而且这次不是难复现的跨文件竞态,是本地一跑就红的确定性失败。
|
||||
- 约定:这条链路横跨 `src/components/image-editor/` 与 `src/services/image-editor/`,往后验证至少同时覆盖两处。不跑全量套件——本机有九条稳定的环境失败(符号链接、`0600` 权限模式、缺客户端 AppData 配置),噪音大于收益。
|
||||
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
|
||||
|
||||
## 2026-08-03 对账整段设界,并把提交前置预算变成真正的取消
|
||||
|
||||
- 缺陷一(对账可被素材库读取永久挂住):对账用 `Promise.all` 同时等项目快照与 `refreshAssetLibrary`,而 `loadEditorAssetLibrary` 至今没有 `timeoutMs`(`composeAbortSignal` 缺失时不设默认值)。`.catch()` 只接住拒绝、接不住永不 settle;catch 体内的 await 不返回,`finally` 就永远不执行——占位归属登记与源图层锁都释放不掉,而到期清理又豁免已登记的占位,页面永久停在 `generating`,同一源图也无法再次操作,刷新前无解。
|
||||
- 这个洞是上一条修复留下的:给 `loadEditorProject` 加界时写的注释已经把机制说对了(「挂住会让 catch 迟迟不结束」),却只给同一个 `Promise.all` 里两个 await 中的一个加了界。逐个接口补超时这条路已经漏过一次。
|
||||
- 决策一:给**整段对账**设 75 秒上界,而不是继续逐个接口补。往对账里加任何新的 await 都自动受约束。超时必须**解析为 null 而不是拒绝**——这段代码本身位于 catch 内,抛出会穿出整个 async 函数,而调用方是 `void snapSelectedLayerToPerfectPixels(...)`,结果是未处理的 rejection;解析为 null 则落进既有的「权威项目快照读取失败」分支,语义正好一致。
|
||||
- 缺陷二(预算只停止等待、不取消):`withPerfectPixelPrePostBudget` 原先只是 `Promise.race`,超时后底层继续跑。直传 `fetch` 没有 signal,被放弃的上传会一路走到 confirm 并注册对象,用户重试再产生一份。
|
||||
- 决策二:把同一个 `AbortSignal` 贯穿凭证请求、直传 POST 与 confirm 三步,由前置预算到期时 `abort`。只中止直传会留下未 confirm 的 OSS 对象,只中止 confirm 又会让实体已写入却无记录——要停就整条链一起停。`requestJson` 从 `init.signal` 取信号并与自身超时合成,所以凭证与 confirm 只需在 init 里传入。
|
||||
- 未采纳评审建议的全量贯穿(再覆盖重试等待与 flush/save):那要再动两个模块,而收益只是少产生一些用户不可见的存储孤儿;`flushProjectPersistence` 现已有 60 秒上界,最多多挂 60 秒后自行结束。改动面从四个模块降到两个,绝大部分收益保留。
|
||||
- 后果分级要说清:缺陷一是永久性的 UI 卡死,缺陷二只是存储层孤儿对象(confirm 注册的是 asset object,不是素材库条目,用户基本不可见)。两者同为 P2 但不同量级。
|
||||
- 验证:新增用例让项目快照读取永不 settle,断言 120 秒后完美像素状态回到「空闲」——即 `finally` 确实执行。去掉对账 deadline 后该用例变红。`vitest src/components/image-editor src/services/image-editor` 974 通过,typecheck、eslint、check:encoding 通过。
|
||||
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
|
||||
|
||||
@@ -114,6 +114,9 @@ type EditorGenerationMediaReference = {
|
||||
type EditorGenerationMediaReferenceOptions = {
|
||||
allowRegisteredIds?: boolean;
|
||||
requireImageObjectReference?: boolean;
|
||||
// 中文注释:由调用方的阶段预算驱动。仅在最外层停止 await 不会取消已经启动的上传,
|
||||
// 被放弃的那次会继续跑完并注册对象,用户重试再产生一份。
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
let editorGenerationUploadFallbackCounter = 0;
|
||||
@@ -191,6 +194,7 @@ async function uploadEditorGenerationInlineMediaSource(
|
||||
source: string,
|
||||
mediaType: EditorMediaAssetUploadType,
|
||||
projectId?: string | null,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
const normalizedProjectId = projectId?.trim() || 'unscoped';
|
||||
const uploadId = createEditorGenerationMediaUploadId();
|
||||
@@ -206,6 +210,7 @@ async function uploadEditorGenerationInlineMediaSource(
|
||||
uploadId,
|
||||
],
|
||||
entityId: normalizedProjectId,
|
||||
signal,
|
||||
...(projectId?.trim()
|
||||
? { metadata: { editor_project_id: projectId.trim() } }
|
||||
: {}),
|
||||
@@ -247,6 +252,7 @@ export async function resolveEditorGenerationMediaReference(
|
||||
uploadSource,
|
||||
mediaType,
|
||||
projectId,
|
||||
options.signal,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2680,6 +2680,52 @@ describe('useImageCanvasGenerationWorkflow', () => {
|
||||
expect(applyProjectSnapshot).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('releases the perfect-pixel locks when reconciliation reads never settle', async () => {
|
||||
// 中文注释:对账用 Promise.all 同时等项目快照与素材库刷新,而 loadEditorAssetLibrary
|
||||
// 至今没有超时。任一读取永不 settle,catch 体内的 await 就不返回,finally 永远不执行——
|
||||
// 占位归属登记与源图层锁都释放不掉,到期清理又豁免已登记的占位,页面会永久停在
|
||||
// generating。整段对账因此必须有上界,且超时要解析为 null 落进「快照读取失败」分支,
|
||||
// 不能抛——这段本身在 catch 内,抛出会变成未处理的 rejection。
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const applyProjectSnapshot = vi.fn();
|
||||
snapImageToPerfectPixelsMock.mockRejectedValueOnce(new Error('网络中断'));
|
||||
// 中文注释:永不 settle 的素材库读取。
|
||||
loadEditorProjectMock.mockImplementationOnce(() => new Promise(() => {}));
|
||||
render(
|
||||
<GenerationWorkflowHarness
|
||||
projectId="project-1"
|
||||
applyProjectSnapshot={applyProjectSnapshot}
|
||||
flushProjectPersistence={vi.fn().mockResolvedValue(undefined)}
|
||||
initialLayers={[
|
||||
createLayer({
|
||||
objectKey: 'generated-images/editor/source.png',
|
||||
src: '/generated-images/editor/source.png',
|
||||
}),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '完美像素' }));
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
});
|
||||
expect(
|
||||
screen.getByRole('status', { name: '完美像素状态' }).textContent,
|
||||
).toBe('处理中');
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(120_000);
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByRole('status', { name: '完美像素状态' }).textContent,
|
||||
).toBe('空闲');
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('treats a gateway-synthesized timeout as an unknown outcome and reconciles', async () => {
|
||||
// 中文注释:网关自己合成的 504 只有 code / message,没有 details,所以既不是 transport
|
||||
// 异常也拿不到 resultPersistenceStarted。按「有响应即结果已知」会跳过对账,而此时
|
||||
|
||||
@@ -234,20 +234,57 @@ function createProjectLayerSnapshotFromLayer(
|
||||
// 「过紧」,同样是回归。90 + 120 = 210 秒,落在 240 秒存活窗口内并留 30 秒余量。
|
||||
export const PERFECT_PIXEL_PRE_POST_BUDGET_MS = 90_000;
|
||||
|
||||
// 中文注释:未知结果对账整段的上界。
|
||||
//
|
||||
// 逐个接口补超时已经漏过一次:给 loadEditorProject 加界时,同一个 Promise.all 里的
|
||||
// refreshAssetLibrary 分支被漏掉,而 loadEditorAssetLibrary 至今没有 timeout。任一读取
|
||||
// 永不 settle 时,catch 体内的 await 就不会返回,`finally` 永远不执行——占位归属登记和
|
||||
// 源图层锁都释放不掉,到期清理又会豁免已登记的占位,页面会永久停在 generating。
|
||||
// 所以这里给整段设界:往对账里加任何新的 await 都自动受约束。
|
||||
//
|
||||
// 超时必须**解析为 null 而不是拒绝**。这段代码本身位于 catch 内,抛出会穿出整个 async
|
||||
// 函数,而调用方是 `void snapSelectedLayerToPerfectPixels(...)`,结果是未处理的 rejection。
|
||||
// 解析为 null 则落进既有的「权威项目快照读取失败」分支,语义正好一致。
|
||||
const PERFECT_PIXEL_RECONCILIATION_BUDGET_MS = 75_000;
|
||||
|
||||
async function withPerfectPixelReconciliationDeadline<T>(
|
||||
work: Promise<T>,
|
||||
): Promise<T | null> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
work,
|
||||
new Promise<null>((resolve) => {
|
||||
timer = setTimeout(
|
||||
() => resolve(null),
|
||||
PERFECT_PIXEL_RECONCILIATION_BUDGET_MS,
|
||||
);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timer !== undefined) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function withPerfectPixelPrePostBudget<T>(
|
||||
work: Promise<T>,
|
||||
deadlineAt: number,
|
||||
timeoutMessage: string,
|
||||
// 中文注释:到期时必须真的 abort,而不只是停止 await。这一段会往 OSS 写实体,
|
||||
// 被放弃的上传若继续跑完并 confirm,就留下一份不可见的孤儿对象,而用户重试还会再写一份。
|
||||
abortOnTimeout?: AbortController,
|
||||
): 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()),
|
||||
);
|
||||
timer = setTimeout(() => {
|
||||
abortOnTimeout?.abort(new Error(timeoutMessage));
|
||||
reject(new Error(timeoutMessage));
|
||||
}, Math.max(0, deadlineAt - Date.now()));
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
@@ -1880,14 +1917,17 @@ export function useImageCanvasGenerationWorkflow({
|
||||
}
|
||||
|
||||
const prePostDeadlineAt = Date.now() + PERFECT_PIXEL_PRE_POST_BUDGET_MS;
|
||||
const prePostAbort = new AbortController();
|
||||
const sourceImageSrc = await withPerfectPixelPrePostBudget(
|
||||
resolveEditorGenerationMediaReference(
|
||||
sourceLayer,
|
||||
'image',
|
||||
normalizedProjectId,
|
||||
{ signal: prePostAbort.signal },
|
||||
),
|
||||
prePostDeadlineAt,
|
||||
'完美像素源图准备超时。',
|
||||
prePostAbort,
|
||||
);
|
||||
await withPerfectPixelPrePostBudget(
|
||||
flushProjectPersistence(),
|
||||
@@ -1975,10 +2015,12 @@ export function useImageCanvasGenerationWorkflow({
|
||||
// 弹登录框、其余一律吞掉,返回 `Promise<void>` 不带成败信号;它本身还是可选
|
||||
// prop。刷新成功时用户白赚一份新列表,失败时文案必须仍然成立,所以下面只给
|
||||
// 「请确认」的指令,不给「已刷新」的事实断言。
|
||||
const [reconciled] = await Promise.all([
|
||||
loadEditorProject(normalizedProjectId).catch(() => null),
|
||||
Promise.resolve(refreshAssetLibrary?.()).catch(() => undefined),
|
||||
]);
|
||||
const reconciled = await withPerfectPixelReconciliationDeadline(
|
||||
Promise.all([
|
||||
loadEditorProject(normalizedProjectId).catch(() => null),
|
||||
Promise.resolve(refreshAssetLibrary?.()).catch(() => undefined),
|
||||
]).then(([snapshot]) => snapshot),
|
||||
);
|
||||
if (reconciled) {
|
||||
// 中文注释:判据必须看 dialog 的 status / generatedLayerId,**不能**看它在不在。
|
||||
// 服务端成功回填时保留该 dialog 并就地改写(置 status: "idle"、写 generatedLayerId),
|
||||
|
||||
@@ -42,6 +42,9 @@ export type EditorMediaAssetUploadOptions = {
|
||||
pathSegments?: string[];
|
||||
entityId?: string;
|
||||
metadata?: Record<string, string>;
|
||||
// 中文注释:贯穿凭证、直传与 confirm 三步。只中止直传会留下未 confirm 的 OSS 对象,
|
||||
// 只中止 confirm 又会让实体已写入却无记录——要停就整条链一起停。
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
const EDITOR_MEDIA_READ_EXPIRE_SECONDS = 60 * 60;
|
||||
@@ -86,6 +89,7 @@ export async function uploadEditorMediaAssetObjectFile(
|
||||
const ticket = await requestJson<DirectUploadTicketResponse>(
|
||||
'/api/assets/direct-upload-tickets',
|
||||
{
|
||||
signal: options.signal,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
@@ -112,11 +116,17 @@ export async function uploadEditorMediaAssetObjectFile(
|
||||
{ retry: EDITOR_REQUEST_RETRY_OPTIONS },
|
||||
);
|
||||
|
||||
await postEditorDirectUploadFile(ticket.upload, file, '上传素材失败');
|
||||
await postEditorDirectUploadFile(
|
||||
ticket.upload,
|
||||
file,
|
||||
'上传素材失败',
|
||||
options.signal,
|
||||
);
|
||||
|
||||
const confirmed = await requestJson<ConfirmAssetObjectResponse>(
|
||||
'/api/assets/objects/confirm',
|
||||
{
|
||||
signal: options.signal,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
|
||||
@@ -53,11 +53,15 @@ export async function postEditorDirectUploadFile(
|
||||
upload: EditorDirectUploadTarget,
|
||||
file: File,
|
||||
errorMessage: string,
|
||||
// 中文注释:调用方超时后仅停止 await 是不够的——这一步是唯一往 OSS 写实体的动作,
|
||||
// 不取消的话被放弃的上传会继续跑完并注册对象,用户重试再产生一份,留下不可见的孤儿。
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
for (let attempt = 0; ; attempt += 1) {
|
||||
const response = await fetch(upload.host, {
|
||||
method: 'POST',
|
||||
body: buildDirectUploadFormData(upload, file),
|
||||
signal,
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
|
||||
Reference in New Issue
Block a user