fix(slice): report a finished slice once, not once per queued poll

setInterval does not await an async callback. Slicing a large project
    blocks the backend for seconds, so poll ticks piled up behind one stalled
    request, each holding a snapshot taken while the job was still active.
    They resolved together, and every one of them ran the completion path —
    one toast and two query invalidations each. A 20s stall against the 1.5s
    interval produced 13 "Sliced X" toasts from a single slice.

    Only one poll round is now in flight at a time, which also stops queueing
    requests against a backend that is already saturated. Completion is
    recorded once per job id, and a round still awaiting a response when the
    effect tears down now returns instead of acting.
This commit is contained in:
maziggy
2026-08-02 09:41:46 +02:00
9 changed files with 539 additions and 21 deletions
@@ -482,3 +482,188 @@ describe('SliceJobTrackerProvider — persistent progress toast', () => {
expect(screen.queryByText(/%/)).toBeNull();
});
});
describe('SliceJobTrackerProvider — one completion per job', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.clearAllMocks();
});
afterEach(() => {
vi.useRealTimers();
});
/** A QueryClient whose invalidateQueries is counted. completeJob calls it
* twice (library-files + archives), so the counter divided by two is the
* number of times the terminal state was handled — a direct count that
* doesn't depend on how long a transient toast happens to stay on screen. */
function countingWrapper(counter: { n: number }) {
return function Counting({ children }: { children: ReactNode }) {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const original = queryClient.invalidateQueries.bind(queryClient);
queryClient.invalidateQueries = ((...args: Parameters<typeof original>) => {
counter.n += 1;
return original(...args);
}) as typeof queryClient.invalidateQueries;
return (
<QueryClientProvider client={queryClient}>
<ToastProvider>
<SliceJobTrackerProvider>{children}</SliceJobTrackerProvider>
</ToastProvider>
</QueryClientProvider>
);
};
}
it('handles completion once when the backend stalls for many poll intervals', async () => {
// The reported symptom: a single slice produced a stream of "Sliced X"
// toasts, more than ten of them, arriving one poll interval apart.
//
// setInterval does not await an async callback. Slicing a large project
// blocks the backend for seconds (zip parsing and output assembly are
// synchronous), so poll ticks piled up behind one stalled request —
// each holding a snapshot taken while the job was still active. When
// the backend recovered they all resolved 'completed' at once and each
// one ran the completion path. A 20s stall against the 1.5s interval
// stacks ~14 of them, which is the observed magnitude.
const STALL_MS = 20_000;
let polls = 0;
mockApi.getSliceJob.mockImplementation(async () => {
polls += 1;
await new Promise((resolve) => setTimeout(resolve, STALL_MS));
return {
job_id: 30,
status: 'completed',
kind: 'library_file',
source_id: 300,
source_name: 'Stalled.3mf',
created_at: new Date().toISOString(),
started_at: new Date().toISOString(),
completed_at: new Date().toISOString(),
};
});
const counter = { n: 0 };
const Counting = countingWrapper(counter);
render(
<Counting>
<TrackTrigger id={30} name="Stalled.3mf" />
</Counting>,
);
act(() => {
screen.getByText('track-30').click();
});
// Well past the stall, so every tick that could have piled up has had
// its chance to resolve.
for (let i = 0; i < 200; i += 1) {
await act(async () => {
vi.advanceTimersByTime(200);
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}
expect(counter.n / 2).toBe(1);
// The in-flight guard also has to stop the pile-up itself, not just its
// visible consequence: a stalled backend must not be handed a fresh
// request every 1.5s. One poll starts, one more can start after it
// resolves and the job is already gone.
expect(polls).toBeLessThanOrEqual(2);
});
it('handles the slower of two jobs once when the first one restarts the poller', async () => {
// The in-flight guard alone does not cover this. Two jobs are tracked;
// the first completes while the second's request is still open. That
// completion changes the tracked count, so the polling effect tears
// down and starts a fresh interval — with its own in-flight flag —
// while the previous round is still parked on the second job's await.
// Both rounds then see 'completed' for it, and the job reports itself
// twice unless the abandoned round notices it was cancelled or the
// completion path refuses the repeat. Either guard alone closes this;
// both are kept, so removing one still passes and removing both fails.
mockApi.getSliceJob.mockImplementation(async (id: number) => {
const base = {
kind: 'library_file' as const,
created_at: new Date().toISOString(),
started_at: new Date().toISOString(),
completed_at: new Date().toISOString(),
};
if (id === 40) {
return { ...base, job_id: 40, status: 'completed', source_id: 400, source_name: 'Fast.3mf' };
}
await new Promise((resolve) => setTimeout(resolve, 6000));
return { ...base, job_id: 41, status: 'completed', source_id: 401, source_name: 'Slow.3mf' };
});
const counter = { n: 0 };
const Counting = countingWrapper(counter);
render(
<Counting>
<TrackTrigger id={40} name="Fast.3mf" />
<TrackTrigger id={41} name="Slow.3mf" />
</Counting>,
);
act(() => {
screen.getByText('track-40').click();
screen.getByText('track-41').click();
});
for (let i = 0; i < 100; i += 1) {
await act(async () => {
vi.advanceTimersByTime(200);
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}
// Exactly two completions: one per job, neither repeated.
expect(counter.n / 2).toBe(2);
});
it('still completes a job tracked again under the same id', async () => {
// The finished-id set must not turn into a permanent block list: a
// re-tracked id has to reach the completion path again.
mockApi.getSliceJob.mockResolvedValue({
job_id: 32,
status: 'completed',
kind: 'library_file',
source_id: 302,
source_name: 'Again.3mf',
created_at: new Date().toISOString(),
started_at: new Date().toISOString(),
completed_at: new Date().toISOString(),
});
const counter = { n: 0 };
const Counting = countingWrapper(counter);
render(
<Counting>
<TrackTrigger id={32} name="Again.3mf" />
</Counting>,
);
for (let round = 0; round < 2; round += 1) {
act(() => {
screen.getByText('track-32').click();
});
for (let i = 0; i < 3; i += 1) {
await act(async () => {
vi.advanceTimersByTime(1500);
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}
}
expect(counter.n / 2).toBe(2);
});
});