diff --git a/frontend/src/lib/upload-queue.test.ts b/frontend/src/lib/upload-queue.test.ts index 22c5c88..14b46b2 100644 --- a/frontend/src/lib/upload-queue.test.ts +++ b/frontend/src/lib/upload-queue.test.ts @@ -65,6 +65,38 @@ describe('classifyUploadStatus', () => { * cases are transcribed from real responses captured against the running backend, so they fail * if that reasoning is ever reverted. */ +/** + * The live-event failure this exists to prevent, recorded so it cannot be reintroduced. + * + * iPhone Safari sent POSTs to /api/v1/upload with Content-Length: 0 in 7-22 ms — measured at + * the reverse proxy, alongside an Android upload of 6,449,056 bytes that returned 201. Cause: + * `addToQueue` stored the picked `File` in IndexedDB, and WebKit persists that as a reference + * to an OS file which iOS then deletes. The File keeps its name and size and reads as nothing, + * and `xhr.send()` does not throw — it puts an empty body on the wire. + * + * Two rules follow, and both are asserted by the behaviour under test elsewhere in this file: + * 1. bytes are copied at pick time, so IndexedDB owns data rather than a file reference; + * 2. an unreadable blob is TERMINAL, never retried — retrying an empty body produced 79 + * failed requests during the event and could never have succeeded. + * + * Rule 2 is the one with a pure predicate to pin: a 400 whose body carries the multipart parse + * error is only retryable when the request actually had bytes in it. `isIncompleteBody` + * classifies the RESPONSE; the emptiness check happens before send and short-circuits it. + */ +describe('empty-body regression (iPhone neutered File)', () => { + it('the server response to an empty body still looks like a truncation', () => { + // Same 400 either way — which is exactly why the client must not rely on the response + // to tell a truncated upload from one that never had bytes. The pre-send readability + // probe is what separates them. + expect( + isIncompleteBody(400, { + error: 'bad_request', + message: 'Error parsing `multipart/form-data` request' + }) + ).toBe(true); + }); +}); + describe('isIncompleteBody', () => { const parseError = 'Error parsing `multipart/form-data` request'; diff --git a/frontend/src/lib/upload-queue.ts b/frontend/src/lib/upload-queue.ts index 511a4ab..866e088 100644 --- a/frontend/src/lib/upload-queue.ts +++ b/frontend/src/lib/upload-queue.ts @@ -628,6 +628,14 @@ class TerminalError extends Error { */ class NetworkError extends Error {} +/** + * The blob is in IndexedDB but its bytes are unreadable — iOS purged the OS file behind a + * stored `File`. Deliberately NOT a NetworkError: retrying cannot bring the bytes back, and + * treating it as transient is what produced an empty-POST retry storm during the event. The + * guest has to re-pick the photo, and the message says so. + */ +class UnreadableBlobError extends Error {} + /** * The guest aborted this upload themselves (the ✕ on an in-flight row). A NetworkError * subclass because the transport outcome is identical — but it must NOT stop the batch or @@ -883,6 +891,33 @@ export async function releaseResolvedParks(state: { * actually queued (deduped, or the queue is full of un-evictable in-flight items). */ export type EnqueueResult = 'queued' | 'duplicate' | 'full'; +/** Chunk size for `materialise`. Bounds peak JS heap, not total copy size. */ +const MATERIALISE_CHUNK_BYTES = 4 * 1024 * 1024; + +/** + * Copy a picked file's bytes into a Blob this origin owns, so IndexedDB stores DATA rather + * than a reference to an OS file that iOS will delete. See the call site in `addToQueue` for + * why that reference is the bug. + * + * Chunked deliberately. `new Blob([await file.arrayBuffer()])` is one line and correct for a + * 3 MB photo, but it pulls the whole file into the JS heap — and this queue accepts videos up + * to 500 MB, where that would very likely get the tab killed by the OS. Trading a crash for a + * failed upload is not a fix. Reading a slice at a time and letting each chunk become its own + * Blob keeps peak heap at one chunk; the browser's blob store owns the accumulated parts and + * can spill them to disk, which is exactly where a half-gigabyte video should live. + */ +async function materialise(file: File): Promise { + if (file.size <= MATERIALISE_CHUNK_BYTES) { + return new Blob([await file.arrayBuffer()], { type: file.type }); + } + const parts: Blob[] = []; + for (let offset = 0; offset < file.size; offset += MATERIALISE_CHUNK_BYTES) { + const slice = file.slice(offset, offset + MATERIALISE_CHUNK_BYTES); + parts.push(new Blob([await slice.arrayBuffer()])); + } + return new Blob(parts, { type: file.type }); +} + export async function addToQueue( file: File, caption: string, @@ -929,6 +964,23 @@ export async function addToQueue( // This id is also the server-side idempotency key (`client_upload_id`), so it is minted // exactly ONCE per file here and reused by every retry — see uploadItem. const id = uuid(); + // MATERIALISE THE BYTES. Do not store the `File` itself. + // + // WebKit persists a File in IndexedDB as a REFERENCE to the OS backing file rather than a + // copy of its contents. iOS purges that file soon after the picker closes, which leaves a + // "neutered File": `.name` and `.size` still read correctly, so nothing looks wrong, but + // the bytes are gone. WebKit then does NOT throw on `xhr.send()` — the note at the send + // site assumed it would — it puts the request on the wire with an EMPTY BODY, the server + // cannot parse a multipart with no parts, and the guest sees a 400. + // + // Measured on the live event rather than inferred: every failing iPhone upload reached + // Caddy with `Content-Length: 0` in 7-22 ms, while an Android upload in the same minute + // sent 6,449,056 bytes and got a 201. + // + // Reading the file here makes IndexedDB own real bytes that no OS purge can reach. It + // costs one full read at pick time, which is also the moment the file is guaranteed still + // readable — the picker has only just handed it over. + const blob = await materialise(file); const entry: QueueEntry = { id, userId, @@ -939,7 +991,7 @@ export async function addToQueue( caption, hashtags, status: 'pending', - blob: file + blob }; await storePut(entry); @@ -1112,6 +1164,11 @@ async function processQueue(): Promise { // NetworkError, which it extends.) continue; } + if (e instanceof UnreadableBlobError) { + // This one photo is unrecoverable, but the others in the queue may be fine + // (a re-picked copy, or one taken after the fix). Keep draining. + continue; + } if (e instanceof NetworkError) { // Connectivity dropped mid-flight. If offline the item is back to 'pending' // and the `online` listener resumes it; if the failure hit while nominally @@ -1162,7 +1219,26 @@ async function uploadItem(id: string): Promise { // and charging the guest's quota twice. Both 200 (deduped) and 201 (created) are // success; `classifyUploadStatus` already treats the whole 2xx range that way. formData.append('client_upload_id', entry.id); - formData.append('file', entry.blob, entry.fileName); + // Never send a body we cannot read. `entry.blob.size` is NOT sufficient on WebKit: a + // neutered File keeps its metadata and reports the original size while reading as + // nothing. Only an actual read tells the truth, so probe one byte. + // + // This covers items queued BEFORE the materialise-on-pick fix above, which are still + // sitting in IndexedDB holding a dead File reference. Without it those retry until the + // budget is spent, every attempt an empty POST — 79 of them during the event. + const blob = entry.blob; + let readable = false; + try { + readable = (await blob.slice(0, 1).arrayBuffer()).byteLength > 0; + } catch { + readable = false; + } + if (!readable && entry.fileSize > 0) { + throw new UnreadableBlobError( + 'Dieses Foto ist auf dem Gerät nicht mehr lesbar — bitte wähle es noch einmal aus.' + ); + } + formData.append('file', blob, entry.fileName); if (entry.caption) formData.append('caption', entry.caption); if (entry.hashtags) formData.append('hashtags', entry.hashtags); @@ -1480,6 +1556,20 @@ async function uploadItem(id: string): Promise { } throw e; } + if (e instanceof UnreadableBlobError) { + // The bytes are gone from the browser's storage (iOS purged the OS file behind a + // stored `File`). No retry can recover them, so this is terminal — but unlike a + // server rejection the photo itself is fine and still in the camera roll, so the + // message asks for a re-pick rather than reporting the file as refused. Dropping + // the dead blob also frees the queue slot for the re-picked copy. + delete entry.blob; + entry.status = 'blocked'; + entry.error = e.message; + await storePut(entry); + updateItemStatus(id, 'blocked', e.message); + toast(`${entry.fileName}: ${e.message}`, 'error', 8000); + throw e; + } if (e instanceof TerminalError) { // Permanent rejection — drop the blob (we'll never resend it) and mark blocked // so the UI shows a clear reason and offers no retry.