block / block/buzz

fix(desktop): queue batch uploads and retry media rate-limit failures (patch included)

Open
#7,631 0 comments 0 reactions 0 assignees View on GitHub
Dominant language
Rust
Stars
32.7k
Forks
4.3k
Avg merge
1d 13h
Merged PRs (30d)
253

Description

## Problem

Selecting seven files together in Buzz Desktop 0.5.23 can fail with:

`Upload failed: Error: relay rate-limited: quota exceeded`

The user should be able to select a batch once; manually sending one file at a time is not a workable recovery path.

## Source diagnosis

Reviewed source: `813bbd14121edacc6cb4733301a3af12131aa10e` (desktop 0.5.23).

- `desktop/src/features/messages/lib/useMediaUpload.ts` starts each foreground file upload concurrently.
- The relay defaults to two concurrent media uploads per pubkey, eight globally, and 30 uploads per minute.
- `UploadConcurrencyLimitReached` and `UploadRateLimitExceeded` both become HTTP 429; the desktop formatter displays the generic quota message when no retry hint is present.
- The native multi-file picker path returns descriptors only after the whole operation; a later failure can prevent previously successful descriptors from reaching the composer.

The seven-file failure was user-reported. These code paths and defaults were inspected; the effective live container overrides and exact rejection counter were not available, so concurrency is a well-supported explanation, not a claimed live counter-confirmed diagnosis. This is not evidence of a full storage bucket.

## Proposed desktop fix (complete patch below)

- Shared renderer upload queue: two active requests and at most 100 waiting file references.
- Read raw file bytes only after admission.
- Retry explicit `relay rate-limited:` failures with a shared cooldown and at most five attempts. Use a full media-rate window when no hint exists; clamp hints and prevent zero-delay loops.
- Never retry successful files or ambiguous network/permission failures.
- Preserve selection order even when completions arrive out of order.
- Cancel queued/retrying work on file removal, draft replacement, unmount, and community/identity reset. Dispatched work retains its slot until settlement; stale results are discarded.
- Route paperclip selection through the existing file-input hook to retain per-file results.
- Byte-based editor uploads share the queue.

No relay limits, authentication, archive data, or native identities are changed.

## Validation

Against the pinned source above:
- **60 targeted tests passed**: 9 queue tests, 4 actual React composer-hook tests with mocked native IPC, and adjacent slot/rate-limit/IPC tests.
- Composer cases cover paperclip, drop, paste, queued cancellation, seven files and out-of-order completion.
- Full desktop TypeScript check passed.
- Vite E2E-mode frontend build passed. This is not a packaged native-app build.
- Patch reverse-apply check and source/patch SHA-256 checks passed.

Not run: repository-wide CI, Rust/native builds, live-relay upload acceptance, or mobile tests. Hermit bootstrap was unavailable in the test environment; validation used Node 24.19.0 and frozen dependency versions, with dependency lifecycle scripts disabled.

## Scope / review notes

This is a proposed patch for maintainer review, not a deployed or release-ready claim. The queue is in-memory, not persistent across restarting the app. Terminal failures remain visible and may require reselecting failed files; successful files are retained. Native-only callers such as the avatar image picker are outside this queue. The existing installed official app was not modified.

**iPhone follow-up remains open:** the Flutter upload path needs its own equivalent implementation, tests and official release. This desktop patch does not claim to fix mobile.

Related, but distinct: #7411 (shared WebSocket subscription/send admission quota), #7019 (archive backfill rate limiting), and #6636 (deferred upload error preservation). Open issues/PRs were searched; no matching foreground batch-concurrency fix was found.

## Apply / reproduce tests

Save the patch below as `desktop-batch-queue.patch` and apply to the pinned commit in a clean review checkout:

```sh
git apply --check desktop-batch-queue.patch
git apply desktop-batch-queue.patch
cd desktop
node --import ./test-loader.mjs --experimental-strip-types --test \
src/shared/api/mediaUploadQueue.test.mjs \
src/features/messages/lib/batchUpload.integration.test.mjs \
src/features/messages/lib/imetaSlots.test.mjs \
src/shared/api/relayRateLimitGate.test.mjs \
src/shared/api/tauri.test.mjs
pnpm typecheck
```

Patch SHA-256 (UTF-8, LF, trailing newline):
`d4652edcc9d7edd8b264d590851722770fe21d4f42182cae3aabe094bf6cac44`

Complete proposed patch (7 files)

```diff
diff --git a/desktop/src/features/communities/useCommunityInit.ts b/desktop/src/features/communities/useCommunityInit.ts
index 4a1ddf9..ff4eefc 100644
--- a/desktop/src/features/communities/useCommunityInit.ts
+++ b/desktop/src/features/communities/useCommunityInit.ts
@@ -4,6 +4,7 @@ import { isMacPlatform } from "@/shared/lib/platform";

import { relayClient } from "@/shared/api/relayClient";
import { resetRateLimitGate } from "@/shared/api/relayRateLimitGate";
+import { resetMediaUploadQueue } from "@/shared/api/mediaUploadQueue";
import {
autoConnectDefaultRelayEnabled,
getDefaultRelayUrl,
@@ -62,6 +63,7 @@ async function resetCommunityState({
resetAvatarState: boolean;
}): Promise {
relayClient.disconnect();
+ resetMediaUploadQueue();
await resetNavigationDeepLinkDrain();
resetRateLimitGate();
clearAllDrafts();
diff --git a/desktop/src/features/messages/lib/useMediaUpload.ts b/desktop/src/features/messages/lib/useMediaUpload.ts
index c1bcd6b..8268ea1 100644
--- a/desktop/src/features/messages/lib/useMediaUpload.ts
+++ b/desktop/src/features/messages/lib/useMediaUpload.ts
@@ -1,10 +1,6 @@
import * as React from "react";

-import {
- type BlobDescriptor,
- pickAndUploadMedia,
- uploadMediaBytes,
-} from "@/shared/api/tauri";
+import { type BlobDescriptor, uploadMediaBytes } from "@/shared/api/tauri";
import { uploadMediaFile } from "@/shared/api/tauriMedia";
import type { QueuedMediaAttachment } from "./backgroundMediaUploadStore";
import { applyImetaUpdate, compactImetaSlots } from "./imetaSlots";
@@ -226,6 +222,15 @@ export function useMediaUpload({
};
}, []);
const activeUploadingPreviewIdsRef = React.useRef(new Set());
+ const uploadControllersRef = React.useRef(new Map());
+ React.useEffect(
+ () => () => {
+ for (const controller of uploadControllersRef.current.values())
+ controller.abort();
+ uploadControllersRef.current.clear();
+ },
+ [],
+ );
const canceledUploadingPreviewIdsRef = React.useRef(new Set());
/**
* Incremented whenever the composer's attachment set is replaced wholesale
@@ -411,6 +416,7 @@ export function useMediaUpload({
const id = nextUploadingPreviewIdRef.current;
nextUploadingPreviewIdRef.current += 1;
activeUploadingPreviewIdsRef.current.add(id);
+ uploadControllersRef.current.set(id, new AbortController());

setUploadingPreviews((prev) => [
...prev,
@@ -444,6 +450,7 @@ export function useMediaUpload({
const finishUpload = React.useCallback(
(previewId?: number) => {
if (previewId !== undefined) {
+ uploadControllersRef.current.delete(previewId);
if (!activeUploadingPreviewIdsRef.current.delete(previewId)) return;
removeUploadingPreview(previewId);
}
@@ -481,6 +488,8 @@ export function useMediaUpload({
activeIds.clear();
for (const id of retiredIds) {
canceledUploadingPreviewIdsRef.current.add(id);
+ uploadControllersRef.current.get(id)?.abort();
+ uploadControllersRef.current.delete(id);
}
setUploadingPreviews((prev) =>
prev.filter((preview) => !retiredIds.has(preview.id)),
@@ -491,6 +500,7 @@ export function useMediaUpload({
const cancelUpload = React.useCallback(
(previewId: number) => {
canceledUploadingPreviewIdsRef.current.add(previewId);
+ uploadControllersRef.current.get(previewId)?.abort();
const preview = uploadingPreviewsRef.current.find(
(candidate) => candidate.id === previewId,
);
@@ -562,25 +572,6 @@ export function useMediaUpload({
[finishUpload, isUploadCanceled, isUploadStale],
);

- /** Append a single descriptor (no pre-reserved slot). */
- const onUploaded = React.useCallback(
- (
- descriptor: BlobDescriptor,
- previewId?: number,
- epoch = uploadEpochRef.current,
- ) => {
- if (isUploadCanceled(previewId)) return;
- if (isUploadStale(epoch)) {
- finishUpload(previewId);
- return;
- }
- nextSlotRef.current += 1;
- setImetaSlots((prev) => [...prev, descriptor]);
- finishUpload(previewId);
- },
- [finishUpload, isUploadCanceled, isUploadStale],
- );
-
const onUploadError = React.useCallback(
(err: unknown, previewId?: number) => {
if (isUploadCanceled(previewId)) return;
@@ -610,10 +601,11 @@ export function useMediaUpload({
const descriptor = await uploadMediaFile(
file,
uploadProgressId(previewId),
+ uploadControllersRef.current.get(previewId)?.signal,
);
fillSlot(slotIndex, descriptor, previewId, epoch);
} catch (err) {
- onUploadError(err, previewId);
+ onUploadError(new Error(`${file.name}: ${String(err)}`), previewId);
}
})();
}
@@ -624,46 +616,13 @@ export function useMediaUpload({
const openFilePicker = useFilePicker();

const handlePaperclip = React.useCallback(async () => {
- if (queueUntilSend) {
- openFilePicker({ multiple: true }, (files) => {
- queueFiles(files.filter(shouldQueueFile));
- uploadFiles(files.filter((file) => !shouldQueueFile(file)));
- });
- return;
- }
-
- // Hold a single pending tick while the native picker is open + uploads
- // run in Rust. We don't know the file count until the dialog returns,
- // and uploads are already complete by then, so we just append each
- // descriptor when we get them back.
- const previewId = reserveUploadingPreview();
- setUploadingCount((c) => c + 1);
- const epoch = uploadEpochRef.current;
- try {
- const descriptors = await pickAndUploadMedia(uploadProgressId(previewId));
- if (isUploadCanceled(previewId)) return;
- finishUpload(previewId);
- if (isUploadStale(epoch)) return;
- for (const descriptor of descriptors) {
- nextSlotRef.current += 1;
- setImetaSlots((prev) => [...prev, descriptor]);
- }
- } catch (err) {
- if (isUploadCanceled(previewId)) return;
- onUploadError(err, previewId);
- }
- }, [
- queueUntilSend,
- finishUpload,
- isUploadCanceled,
- isUploadStale,
- onUploadError,
- openFilePicker,
- queueFiles,
- reserveUploadingPreview,
- shouldQueueFile,
- uploadFiles,
- ]);
+ // Use the same per-file queue as drop/paste. A failed item must not
+ // discard descriptors for earlier successes in the native picker batch.
+ openFilePicker({ multiple: true }, (files) => {
+ queueFiles(files.filter(shouldQueueFile));
+ uploadFiles(files.filter((file) => !shouldQueueFile(file)));
+ });
+ }, [openFilePicker, queueFiles, shouldQueueFile, uploadFiles]);

const handleDrop = React.useCallback(
async (event: React.DragEvent) => {
@@ -761,23 +720,26 @@ export function useMediaUpload({
queueFiles([file]);
return;
}
- const previewId = reserveUploadingPreview(file);
+ const slotIndex = reserveSlots(1);
+ const previewId = reserveUploadingPreview(file, slotIndex);
setUploadingCount((c) => c + 1);
const epoch = uploadEpochRef.current;
try {
const descriptor = await uploadMediaFile(
file,
uploadProgressId(previewId),
+ uploadControllersRef.current.get(previewId)?.signal,
);
- onUploaded(descriptor, previewId, epoch);
+ fillSlot(slotIndex, descriptor, previewId, epoch);
} catch (err) {
onUploadError(err, previewId);
}
},
[
- onUploaded,
+ fillSlot,
onUploadError,
queueFiles,
+ reserveSlots,
reserveUploadingPreview,
shouldQueueFile,
],
diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts
index 984b9d1..3484c86 100644
--- a/desktop/src/shared/api/tauri.ts
+++ b/desktop/src/shared/api/tauri.ts
@@ -1,4 +1,5 @@
import { invoke as tauriInvoke } from "@tauri-apps/api/core";
+import { mediaUploadQueue } from "./mediaUploadQueue";
import {
activateRateLimit,
parseRateLimitHint,
@@ -562,11 +563,13 @@ export async function uploadMediaBytes(
/** Correlation id for `media-upload-progress` events from the Rust side. */
progressId?: string,
): Promise {
- return invokeTauri("upload_media_bytes", {
- data,
- filename,
- progressId,
- });
+ return mediaUploadQueue.run(() =>
+ invokeTauri("upload_media_bytes", {
+ data,
+ filename,
+ progressId,
+ }),
+ );
}

export { editMessage } from "@/shared/api/editMessage";
diff --git a/desktop/src/shared/api/tauriMedia.ts b/desktop/src/shared/api/tauriMedia.ts
index 01ef53d..81128e6 100644
--- a/desktop/src/shared/api/tauriMedia.ts
+++ b/desktop/src/shared/api/tauriMedia.ts
@@ -1,5 +1,6 @@
import { invoke as invokeTauriRaw, isTauri } from "@tauri-apps/api/core";
import { type BlobDescriptor, invokeTauri } from "./tauri";
+import { mediaUploadQueue } from "./mediaUploadQueue";

function encodeRawIpcHeader(value: string): string {
const bytes = new TextEncoder().encode(value);
@@ -18,6 +19,22 @@ export async function uploadMediaFile(
progressId?: string,
signal?: AbortSignal,
onDispatch?: () => void,
+): Promise {
+ // Read file bytes only after admission, not once for every selected file.
+ let bytes: Uint8Array | undefined;
+ return mediaUploadQueue.run(async (queuedSignal) => {
+ if (queuedSignal.aborted) throw new Error("upload cancelled");
+ bytes ??= new Uint8Array(await file.arrayBuffer());
+ if (queuedSignal.aborted) throw new Error("upload cancelled");
+ return dispatchMediaFile(file, bytes, progressId, onDispatch);
+ }, signal);
+}
+
+async function dispatchMediaFile(
+ file: File,
+ bytes: Uint8Array,
+ progressId?: string,
+ onDispatch?: () => void,
): Promise {
const headers: Record = {
"x-buzz-filename": encodeRawIpcHeader(file.name),
@@ -26,9 +43,6 @@ export async function uploadMediaFile(
headers["x-buzz-progress-id"] = encodeRawIpcHeader(progressId);
}

- if (signal?.aborted) throw new Error("upload cancelled");
- const bytes = new Uint8Array(await file.arrayBuffer());
- if (signal?.aborted) throw new Error("upload cancelled");
onDispatch?.();
try {
return await invokeTauriRaw(
diff --git a/desktop/src/shared/api/mediaUploadQueue.ts b/desktop/src/shared/api/mediaUploadQueue.ts
new file mode 100644
--- /dev/null
+++ b/desktop/src/shared/api/mediaUploadQueue.ts
@@ -0,0 +1,133 @@
+/** One bounded queue for upload callers in this renderer, reset at sign-in/community boundaries. */
+export class MediaUploadQueue {
+ private active = 0;
+ private pending: Array<() => void> = [];
+ private controllers = new Set();
+ private cooldownUntil = 0;
+
+ /** Two native requests at a time; retain at most 100 waiting file references. */
+ run(
+ operation: (signal: AbortSignal) => Promise,
+ signal?: AbortSignal,
+ ): Promise {
+ if (signal?.aborted) return Promise.reject(new Error("upload cancelled"));
+ if (this.pending.length >= 100) {
+ return Promise.reject(
+ new Error("Upload queue full; wait for the current batch to finish."),
+ );
+ }
+ const controller = new AbortController();
+ this.controllers.add(controller);
+ return new Promise((resolve, reject) => {
+ let started = false;
+ const cleanup = () => {
+ signal?.removeEventListener("abort", cancel);
+ controller.signal.removeEventListener("abort", cancelled);
+ this.controllers.delete(controller);
+ };
+ const cancelled = () => {
+ reject(new Error("upload cancelled"));
+ if (!started) {
+ this.pending = this.pending.filter((entry) => entry !== start);
+ cleanup();
+ }
+ };
+ const cancel = () => controller.abort();
+ const start = () => {
+ started = true;
+ this.active += 1;
+ void this.execute(operation, controller.signal)
+ .then(resolve, reject)
+ .finally(() => {
+ cleanup();
+ this.active -= 1;
+ this.drain();
+ });
+ };
+ controller.signal.addEventListener("abort", cancelled, { once: true });
+ signal?.addEventListener("abort", cancel, { once: true });
+ this.pending.push(start);
+ this.drain();
+ });
+ }
+
+ /** Cancel waiting/retrying work. Already dispatched native requests retain their slots until settled. */
+ reset(): void {
+ for (const controller of this.controllers) controller.abort();
+ this.cooldownUntil = 0;
+ }
+
+ private drain(): void {
+ while (this.active < 2 && this.pending.length > 0) this.pending.shift()?.();
+ }
+
+ private async execute(
+ operation: (signal: AbortSignal) => Promise,
+ signal: AbortSignal,
+ ): Promise {
+ // Retry only explicit quota rejection, never ambiguous network/timeout errors
+ // that might have committed an upload. Each call creates fresh native auth.
+ for (let attempt = 0; ; attempt += 1) {
+ while (this.cooldownUntil > Date.now()) {
+ await wait(this.cooldownUntil - Date.now(), signal);
+ }
+ if (signal.aborted) throw new Error("upload cancelled");
+ try {
+ const result = await operation(signal);
+ if (signal.aborted) throw new Error("upload cancelled");
+ return result;
+ } catch (error) {
+ if (signal.aborted) throw new Error("upload cancelled");
+ const delay = retryDelay(error, attempt);
+ if (delay === null) throw error;
+ // Share cooldown across files, including after this file exhausts retries.
+ this.cooldownUntil = Math.max(this.cooldownUntil, Date.now() + delay);
+ if (attempt >= 4) {
+ throw new Error(
+ "Upload still rate-limited after automatic retries. Retry only the failed file; completed uploads were retained.",
+ );
+ }
+ }
+ }
+ }
+}
+
+function retryDelay(error: unknown, attempt: number): number | null {
+ const message =
+ error instanceof Error
+ ? error.message
+ : typeof error === "string"
+ ? error
+ : "";
+ if (!message.startsWith("relay rate-limited:")) return null;
+ const hint = /retry in (\d+)s/i.exec(message);
+ // Honor bounded server hints. No hint: wait a whole 60-second media window.
+ const seconds = hint ? Math.max(1, Math.min(300, Number(hint[1]))) : 60;
+ return Math.max(seconds * 1000, Math.min(30_000, 1000 * 2 ** attempt)) + 250;
+}
+
+function wait(milliseconds: number, signal: AbortSignal): Promise {
+ return new Promise((resolve, reject) => {
+ if (signal.aborted) {
+ reject(new Error("upload cancelled"));
+ return;
+ }
+ const cancel = () => {
+ clearTimeout(timer);
+ reject(new Error("upload cancelled"));
+ };
+ const timer = setTimeout(() => {
+ signal.removeEventListener("abort", cancel);
+ resolve();
+ }, milliseconds);
+ signal.addEventListener("abort", cancel, { once: true });
+ });
+}
+
+/** Shared by drag/drop, paste, the picker and byte-based upload entry points. */
+export const mediaUploadQueue = new MediaUploadQueue();
+
+/** Prevent a queued upload from being signed with the next community's identity. */
+export function resetMediaUploadQueue(): void {
+ mediaUploadQueue.reset();
+}
diff --git a/desktop/src/shared/api/mediaUploadQueue.test.mjs b/desktop/src/shared/api/mediaUploadQueue.test.mjs
new file mode 100644
--- /dev/null
+++ b/desktop/src/shared/api/mediaUploadQueue.test.mjs
@@ -0,0 +1,206 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { MediaUploadQueue } from "./mediaUploadQueue.ts";
+
+const flush = async () => {
+ for (let i = 0; i < 20; i++) await Promise.resolve();
+};
+const deferred = () => {
+ let resolve;
+ const promise = new Promise((r) => {
+ resolve = r;
+ });
+ return { promise, resolve };
+};
+
+test("seven files complete once each with at most two simultaneous requests", async () => {
+ const queue = new MediaUploadQueue();
+ const gates = Array.from({ length: 7 }, deferred);
+ const calls = [];
+ let active = 0;
+ let maximum = 0;
+ const batch = gates.map((gate, index) =>
+ queue.run(async () => {
+ calls.push(index);
+ active++;
+ maximum = Math.max(maximum, active);
+ await gate.promise;
+ active--;
+ return index;
+ }),
+ );
+ assert.deepEqual(calls, [0, 1]);
+ for (let i = 0; i < 7; i++) {
+ gates[i].resolve();
+ await flush();
+ }
+ assert.deepEqual(await Promise.all(batch), [0, 1, 2, 3, 4, 5, 6]);
+ assert.equal(maximum, 2);
+ assert.deepEqual(calls, [0, 1, 2, 3, 4, 5, 6]);
+});
+
+test("quota rejection retries only the rejected file after shared cooldown", async (t) => {
+ t.mock.timers.enable({ apis: ["Date", "setTimeout"], now: 0 });
+ const queue = new MediaUploadQueue();
+ const calls = [];
+ const a = queue.run(async () => {
+ calls.push("a");
+ return "a";
+ });
+ let attempts = 0;
+ const b = queue.run(async () => {
+ calls.push("b");
+ if (++attempts === 1) throw new Error("relay rate-limited: quota exceeded");
+ return "b";
+ });
+ const c = queue.run(async () => {
+ calls.push("c");
+ return "c";
+ });
+ await flush();
+ assert.deepEqual(calls, ["a", "b"]);
+ t.mock.timers.tick(60_249);
+ await flush();
+ assert.deepEqual(calls, ["a", "b"]);
+ t.mock.timers.tick(1);
+ await flush();
+ assert.deepEqual(await Promise.all([a, b, c]), ["a", "b", "c"]);
+ assert.equal(calls.filter((x) => x === "a").length, 1);
+ assert.equal(attempts, 2);
+});
+
+test("server retry hints are honored without zero-delay loops", async (t) => {
+ t.mock.timers.enable({ apis: ["Date", "setTimeout"], now: 0 });
+ for (const [hint, delay] of [
+ ["0", 1250],
+ ["4", 4250],
+ ["999999999999999999999", 300250],
+ ]) {
+ const queue = new MediaUploadQueue();
+ let calls = 0;
+ const pending = queue.run(async () => {
+ if (++calls === 1)
+ throw new Error(`relay rate-limited: retry in ${hint}s`);
+ return true;
+ });
+ await flush();
+ t.mock.timers.tick(delay - 1);
+ await flush();
+ assert.equal(calls, 1);
+ t.mock.timers.tick(1);
+ await flush();
+ assert.equal(await pending, true);
+ }
+});
+
+test("persistent rejection has a finite five-attempt terminal failure", async (t) => {
+ t.mock.timers.enable({ apis: ["Date", "setTimeout"], now: 0 });
+ const queue = new MediaUploadQueue();
+ let calls = 0;
+ const result = queue.run(async () => {
+ calls++;
+ throw new Error("relay rate-limited: quota exceeded");
+ });
+ const check = assert.rejects(result, /after automatic retries/);
+ await flush();
+ for (let i = 0; i < 4; i++) {
+ t.mock.timers.tick(60250);
+ await flush();
+ }
+ await check;
+ assert.equal(calls, 5);
+});
+
+test("permission and ambiguous network failures are not retried; other files survive", async () => {
+ const queue = new MediaUploadQueue();
+ let calls = 0;
+ const bad = queue.run(async () => {
+ calls++;
+ throw new Error("relay unreachable: timeout");
+ });
+ const denied = queue.run(async () => {
+ calls++;
+ throw new Error("unauthorized");
+ });
+ const good = queue.run(async () => "stored");
+ const results = await Promise.allSettled([bad, denied, good]);
+ assert.deepEqual(
+ results.map((r) => r.status),
+ ["rejected", "rejected", "fulfilled"],
+ );
+ assert.equal(results[2].value, "stored");
+ assert.equal(calls, 2);
+});
+
+test("queued cancellation never dispatches and frees queue capacity", async () => {
+ const queue = new MediaUploadQueue();
+ const gate = deferred();
+ const signal = new AbortController();
+ const active = [queue.run(() => gate.promise), queue.run(() => gate.promise)];
+ let calls = 0;
+ const cancelled = queue.run(async () => {
+ calls++;
+ }, signal.signal);
+ const check = assert.rejects(cancelled, /cancelled/);
+ signal.abort();
+ await check;
+ gate.resolve();
+ await Promise.all(active);
+ await flush();
+ assert.equal(calls, 0);
+});
+
+test("reset cancels backoff and old-community queued work, never retries it", async (t) => {
+ t.mock.timers.enable({ apis: ["Date", "setTimeout"], now: 0 });
+ const queue = new MediaUploadQueue();
+ let calls = 0;
+ const old = Array.from({ length: 7 }, () =>
+ queue.run(async () => {
+ calls++;
+ throw new Error("relay rate-limited: quota exceeded");
+ }),
+ );
+ const results = Promise.allSettled(old);
+ await flush();
+ queue.reset();
+ await results;
+ await flush();
+ t.mock.timers.tick(600000);
+ await flush();
+ assert.equal(calls, 2);
+ assert.equal(await queue.run(async () => "new-community"), "new-community");
+});
+
+test("cancelled active work retains its native slot until it settles", async () => {
+ const queue = new MediaUploadQueue();
+ const gate = deferred();
+ let calls = 0;
+ const old = [queue.run(() => gate.promise), queue.run(() => gate.promise)];
+ const results = Promise.allSettled(old);
+ queue.reset();
+ await results;
+ const next = queue.run(async () => {
+ calls++;
+ return true;
+ });
+ assert.equal(calls, 0);
+ gate.resolve();
+ await flush();
+ assert.equal(await next, true);
+});
+
+test("oversized batches fail visibly instead of keeping unlimited file references", async () => {
+ const queue = new MediaUploadQueue();
+ const gate = deferred();
+ const pending = Array.from({ length: 102 }, () =>
+ queue.run(() => gate.promise),
+ );
+ await assert.rejects(
+ queue.run(async () => true),
+ /queue full/,
+ );
+ const results = Promise.allSettled(pending);
+ queue.reset();
+ gate.resolve();
+ await results;
+});
diff --git a/desktop/src/features/messages/lib/batchUpload.integration.test.mjs b/desktop/src/features/messages/lib/batchUpload.integration.test.mjs
new file mode 100644
--- /dev/null
+++ b/desktop/src/features/messages/lib/batchUpload.integration.test.mjs
@@ -0,0 +1,106 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { JSDOM } from "jsdom";
+import React from "react";
+import { createRoot } from "react-dom/client";
+import { act } from "react";
+import { useMediaUpload } from "./useMediaUpload.ts";
+import { resetMediaUploadQueue } from "@/shared/api/mediaUploadQueue";
+
+for (const mode of ["picker", "drop", "paste", "cancel"])
+ test(`real composer ${mode}: bounded batch, ordered results`, async () => {
+ const dom = new JSDOM('

', {
+ url: "https://test.invalid",
+ });
+ globalThis.window = dom.window;
+ globalThis.document = dom.window.document;
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true;
+ window.__TAURI_EVENT_PLUGIN_INTERNALS__ = { unregisterListener() {} };
+ let state;
+ const requests = [];
+ let active = 0;
+ let maxActive = 0;
+ window.__TAURI_INTERNALS__ = {
+ transformCallback: () => 1,
+ unregisterCallback: () => {},
+ invoke: async (command, body, options) => {
+ if (command !== "upload_media_bytes_raw") return 1;
+ active++;
+ maxActive = Math.max(maxActive, active);
+ const filename = Buffer.from(
+ options.headers["x-buzz-filename"],
+ "base64url",
+ ).toString();
+ return new Promise((resolve) =>
+ requests.push({
+ filename,
+ finish() {
+ active--;
+ resolve({
+ url: `https://test.invalid/media/${filename}`,
+ sha256: filename,
+ filename,
+ });
+ },
+ }),
+ );
+ },
+ };
+ function Harness() {
+ state = useMediaUpload();
+ return null;
+ }
+ const root = createRoot(document.getElementById("root"));
+ try {
+ await act(async () => root.render(React.createElement(Harness)));
+ const files = Array.from(
+ { length: 7 },
+ (_, index) => new File([`file ${index}`], `file-${index}.md`),
+ );
+ if (mode === "picker") {
+ await act(async () => state.handlePaperclip());
+ const input = document.querySelector('input[type="file"]');
+ assert.ok(input.multiple);
+ Object.defineProperty(input, "files", {
+ configurable: true,
+ value: files,
+ });
+ await act(async () => {
+ input.dispatchEvent(new window.Event("change"));
+ });
+ } else if (mode === "paste") {
+ await act(async () => {
+ for (const file of files) void state.uploadFile(file);
+ });
+ } else {
+ await act(async () =>
+ state.handleDrop({ preventDefault() {}, dataTransfer: { files } }),
+ );
+ }
+ assert.equal(requests.length, 2);
+ if (mode === "cancel") {
+ await act(async () =>
+ state.cancelUpload(state.uploadingPreviews[6].id),
+ );
+ }
+ const expected = mode === "cancel" ? files.slice(0, 6) : files;
+ // Complete the second file first: output order must not be completion order.
+ for (const i of [
+ 1,
+ 0,
+ ...Array.from({ length: expected.length - 2 }, (_, n) => n + 2),
+ ]) {
+ await act(async () => requests[i].finish());
+ }
+ assert.equal(maxActive, 2);
+ assert.deepEqual(
+ state.pendingImeta.map((item) => item.filename),
+ expected.map((file) => file.name),
+ );
+ assert.equal(state.isUploading, false);
+ } finally {
+ await act(async () => root.unmount());
+ resetMediaUploadQueue();
+ dom.window.close();
+ }
+ });
```

Contributor guide

Open the contributing guide

Research direction

Start with desktop/src/features/messages/lib/useMediaUpload.ts and the shared media upload queue entry point, then run the listed queue, batch integration, slot, rate-limit, and Tauri tests from desktop. Done means batch uploads are limited and retried as specified, successful descriptors remain ordered, cancellation/reset behavior is covered, and the TypeScript check and Vite E2E-mode build pass.

Written by the indexing model from the issue text.

Assessment

Tech stack
react, tauri, typescript, vite
Domain
desktop
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
32/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.