Fix the flaky upload spec

Hide the coding panel
The upload retry spec fails about one CI run in five. Find the race and fix it.
The spec only fails on runs where the abort lands inside the backoff window, which points at the queue rather than the network mock. scheduleRetry arms a timer that holds the last reference to the upload, and nothing on the abort path clears it, so a settled upload can re-enter the queue and trip the pending assertion.
text
src/uploads/retry.ts:43       scheduleRetry(upload: Upload) {
src/uploads/retry.ts:51       abort(upload: Upload) {
src/uploads/worker.ts:88      queue.scheduleRetry(upload);
src/api/uploads/route.ts:29   if (upload.attempt < MAX_ATTEMPTS) queue.scheduleRetry(upload);
read_filesrc/uploads/retry.tsDone0.2s

Found the race. scheduleRetry arms a backoff timer that abort never clears, so an aborted upload can re-enter the queue after it has settled. Roughly one run in five lands the abort inside the backoff window, which matches the failure rate you are seeing.

  1. Reproduce the failure3 failures in 12 runs of the specCompleted
  2. Find the raceretry.ts: the backoff timer outlives abortCompleted
  3. Patch the queue and its callers6 files: the queue, its callers, and the docsCompleted
  4. Re-run the suiteIn progress

The patch tracks each upload's timer and clears it on abort, points worker shutdown and client disconnects at the same abort path, makes the backoff jitter injectable so the spec can pin the clock, and notes the new contract in the upload guide. The full diff is staged in the workspace.

Apply the patch and re-run the suite?

vitest run uploads, in the session sandbox. Review the staged diff first if you like.

Waiting for your decision
Waiting at the checkpoint
Working in parcel-api on fix/upload-retry

A mock workbench: the session is scripted, and typed messages get a canned reply.

Staged changes

6 files
Changed files
  • src
    • uploads
      • retry.ts
      • retry.test.ts
      • worker.ts
    • api
      • uploads
        • route.ts
    • lib
      • backoff.ts
  • docs
    • uploads.md

Patch
+41Lines added
-7Lines removed

Checks
  • Type check3.8sPassed
  • Lint6.1sPassed
  • Build12.4sPassed
  • Upload spec, 12 repeatsQueued

Spec durationLast 12 runs, seconds of wall clock
Session budget
Context window82k of 200k tokens
Daily runs7 of 25
Resets at midnight
src/uploads/retry.ts+7 added-1 removed
  1. @@ -12,6 +12,7 @@ export class RetryQueue {
  2. private inFlight = new Set<string>();
  3. Added line: private timers = new Map<string, ReturnType<typeof setTimeout>>();
  4. private listeners = new Set<QueueListener>();
  5. @@ -41,9 +42,12 @@ export class RetryQueue {
  6. scheduleRetry(upload: Upload) {
  7. const delay = backoff(upload.attempt);
  8. Removed line: setTimeout(() => {
  9. Added line: const timer = setTimeout(() => {
  10. Added line: this.timers.delete(upload.id);
  11. this.enqueue(upload);
  12. }, delay);
  13. Added line: this.timers.set(upload.id, timer);
  14. }
  15. abort(upload: Upload) {
  16. Added line: const timer = this.timers.get(upload.id);
  17. Added line: if (timer !== undefined) clearTimeout(timer);
  18. Added line: this.timers.delete(upload.id);
  19. this.inFlight.delete(upload.id);
  20. upload.settle("aborted");
  21. }
src/uploads/retry.test.ts+15 added-1 removed
  1. @@ -8,13 +8,14 @@ describe("RetryQueue", () => {
  2. it("retries a failed chunk", async () => {
  3. Added line: vi.useFakeTimers();
  4. const queue = new RetryQueue();
  5. const upload = makeUpload({ attempt: 1 });
  6. queue.scheduleRetry(upload);
  7. Removed line: await sleep(BACKOFF_BASE_MS + 50);
  8. Added line: await vi.advanceTimersByTimeAsync(backoff(1));
  9. expect(queue.pending()).toContain(upload.id);
  10. Added line: vi.useRealTimers();
  11. });
  12. Added line: it("drops the retry when the upload aborts", async () => {
  13. Added line: vi.useFakeTimers();
  14. Added line: const queue = new RetryQueue();
  15. Added line: const upload = makeUpload({ attempt: 1 });
  16. Added line:
  17. Added line: queue.scheduleRetry(upload);
  18. Added line: queue.abort(upload);
  19. Added line: await vi.advanceTimersByTimeAsync(backoff(1));
  20. Added line:
  21. Added line: expect(queue.pending()).not.toContain(upload.id);
  22. Added line: vi.useRealTimers();
  23. Added line: });
src/uploads/worker.ts+4 added-1 removed
  1. @@ -76,7 +76,9 @@ export function createUploadWorker(queue: RetryQueue) {
  2. const stop = () => {
  3. Removed line: queue.clear();
  4. Added line: for (const upload of queue.pending()) {
  5. Added line: queue.abort(upload);
  6. Added line: }
  7. socket.close();
  8. };
  9. @@ -101,6 +103,7 @@ export function createUploadWorker(queue: RetryQueue) {
  10. socket.on("close", () => {
  11. Added line: metrics.count("uploads.worker.closed");
  12. stop();
  13. });
src/api/uploads/route.ts+7 added-1 removed
  1. @@ -22,10 +22,15 @@ export async function POST(request: Request) {
  2. const upload = queue.register(await request.blob(), meta);
  3. Added line: request.signal.addEventListener("abort", () => {
  4. Added line: queue.abort(upload);
  5. Added line: });
  6. Added line:
  7. try {
  8. await queue.send(upload);
  9. } catch (error) {
  10. Removed line: if (upload.attempt < MAX_ATTEMPTS) queue.scheduleRetry(upload);
  11. Added line: if (!request.signal.aborted && upload.attempt < MAX_ATTEMPTS) {
  12. Added line: queue.scheduleRetry(upload);
  13. Added line: }
  14. throw error;
  15. }
src/lib/backoff.ts+4 added-3 removed
  1. @@ -4,9 +4,13 @@ const BASE_MS = 400;
  2. const MAX_MS = 30_000;
  3. Removed line: export function backoff(attempt: number) {
  4. Removed line: const delay = BASE_MS * 2 ** attempt;
  5. Removed line: return Math.min(delay, MAX_MS) + Math.random() * 250;
  6. Added line: export function backoff(attempt: number, jitter = Math.random) {
  7. Added line: const delay = Math.min(BASE_MS * 2 ** attempt, MAX_MS);
  8. Added line: /* Injectable jitter, so tests can pin the clock. */
  9. Added line: return delay + jitter() * 250;
  10. }
docs/uploads.md+4 added-0 removed
  1. @@ -31,6 +31,10 @@ The queue retries failed chunks with exponential backoff.
  2. Aborting an upload settles it immediately.
  3. Added line: Aborting also clears any armed retry timer, so a settled upload can
  4. Added line: never re-enter the queue. Worker shutdown and client disconnects drain
  5. Added line: through the same abort path.
  6. Added line:
  7. See the worker guide for deployment notes.