修复任务列表重试请求泄漏

在任一 bootstrap 分支失败时中止当前轮全部请求。

补充失败分支悬挂、重试后卸载的 AbortSignal 回归测试。
This commit is contained in:
2026-08-06 14:52:16 +08:00
parent 31df362fe4
commit fe63c28690
2 changed files with 78 additions and 5 deletions
@@ -625,7 +625,9 @@ describe('ImageCanvasTaskSidebarView', () => {
});
let activeRequestCount = 0;
listExternalGenerationTasksMock.mockImplementation(
(options: Parameters<typeof listExternalGenerationTasks>[0] = {}) => {
(
options: Parameters<typeof listExternalGenerationTasks>[0] = {},
): ReturnType<typeof listExternalGenerationTasks> => {
if (options.statuses?.includes('running')) {
activeRequestCount += 1;
if (activeRequestCount === 1) {
@@ -713,6 +715,67 @@ describe('ImageCanvasTaskSidebarView', () => {
}
});
it('aborts every bootstrap attempt when one branch fails and after retry unmounts', async () => {
vi.useFakeTimers();
try {
const requestSignals: AbortSignal[] = [];
let activeRequestCount = 0;
listExternalGenerationTasksMock.mockImplementation(
(options: Parameters<typeof listExternalGenerationTasks>[0] = {}) => {
const signal = options.signal;
if (!signal) {
return Promise.reject(new Error('missing abort signal'));
}
requestSignals.push(signal);
if (options.statuses?.includes('running')) {
activeRequestCount += 1;
if (activeRequestCount === 1) {
return Promise.reject(new Error('active bootstrap failed'));
}
}
return new Promise<
Awaited<ReturnType<typeof listExternalGenerationTasks>>
>((_, reject) => {
signal.addEventListener(
'abort',
() => reject(new DOMException('Aborted', 'AbortError')),
{ once: true },
);
});
},
);
const { unmount } = render(
<ImageCanvasTaskSidebarView
open
onToggleOpen={vi.fn()}
onFocusExternalTask={vi.fn()}
/>,
);
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(requestSignals).toHaveLength(2);
expect(requestSignals[0]).toBe(requestSignals[1]);
expect(requestSignals[0]!.aborted).toBe(true);
await act(async () => {
await vi.advanceTimersByTimeAsync(1000);
await Promise.resolve();
});
expect(requestSignals).toHaveLength(4);
expect(requestSignals[2]).toBe(requestSignals[3]);
expect(requestSignals[2]!.aborted).toBe(false);
unmount();
expect(requestSignals[2]!.aborted).toBe(true);
} finally {
vi.useRealTimers();
}
});
it('loads more completed tasks while scrolling and keeps the request capped', async () => {
const completedTasks = Array.from({ length: 120 }, (_, index) => {
const completedAt = new Date(Date.now() - index * 1000).toISOString();
@@ -362,23 +362,24 @@ export function ImageCanvasTaskSidebarView({
let retryTimerId: number | null = null;
let retryIndex = 0;
const loadTaskLists = () => {
controller = new AbortController();
const attemptController = new AbortController();
controller = attemptController;
Promise.all([
listExternalGenerationTasks({
limit: ACTIVE_TASK_LIST_LIMIT,
includeAcknowledgedTerminal: false,
statuses: ['running', 'queued'],
signal: controller.signal,
signal: attemptController.signal,
}),
listExternalGenerationTasks({
limit: completedListLimit,
includeAcknowledgedTerminal: true,
statuses: ['completed', 'failed'],
signal: controller.signal,
signal: attemptController.signal,
}),
])
.then(([activeResponse, completedResponse]) => {
if (disposed) {
if (disposed || attemptController.signal.aborted) {
return;
}
const activeTasks = activeResponse.tasks.filter(isActiveExternalTask);
@@ -408,6 +409,10 @@ export function ImageCanvasTaskSidebarView({
);
})
.catch(() => {
attemptController.abort();
if (controller === attemptController) {
controller = null;
}
if (
disposed ||
retryIndex >= TASK_LIST_BOOTSTRAP_RETRY_DELAYS_MS.length
@@ -417,6 +422,11 @@ export function ImageCanvasTaskSidebarView({
const retryDelayMs = TASK_LIST_BOOTSTRAP_RETRY_DELAYS_MS[retryIndex];
retryIndex += 1;
retryTimerId = window.setTimeout(loadTaskLists, retryDelayMs);
})
.finally(() => {
if (controller === attemptController) {
controller = null;
}
});
};