mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-22 03:49:18 +02:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user