|
|
|
@@ -628,6 +628,14 @@ class TerminalError extends Error {
|
|
|
|
*/
|
|
|
|
*/
|
|
|
|
class NetworkError 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
|
|
|
|
* 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
|
|
|
|
* 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). */
|
|
|
|
* actually queued (deduped, or the queue is full of un-evictable in-flight items). */
|
|
|
|
export type EnqueueResult = 'queued' | 'duplicate' | 'full';
|
|
|
|
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<Blob> {
|
|
|
|
|
|
|
|
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(
|
|
|
|
export async function addToQueue(
|
|
|
|
file: File,
|
|
|
|
file: File,
|
|
|
|
caption: string,
|
|
|
|
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
|
|
|
|
// 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.
|
|
|
|
// exactly ONCE per file here and reused by every retry — see uploadItem.
|
|
|
|
const id = uuid();
|
|
|
|
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 = {
|
|
|
|
const entry: QueueEntry = {
|
|
|
|
id,
|
|
|
|
id,
|
|
|
|
userId,
|
|
|
|
userId,
|
|
|
|
@@ -939,7 +991,7 @@ export async function addToQueue(
|
|
|
|
caption,
|
|
|
|
caption,
|
|
|
|
hashtags,
|
|
|
|
hashtags,
|
|
|
|
status: 'pending',
|
|
|
|
status: 'pending',
|
|
|
|
blob: file
|
|
|
|
blob
|
|
|
|
};
|
|
|
|
};
|
|
|
|
await storePut(entry);
|
|
|
|
await storePut(entry);
|
|
|
|
|
|
|
|
|
|
|
|
@@ -1112,6 +1164,11 @@ async function processQueue(): Promise<void> {
|
|
|
|
// NetworkError, which it extends.)
|
|
|
|
// NetworkError, which it extends.)
|
|
|
|
continue;
|
|
|
|
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) {
|
|
|
|
if (e instanceof NetworkError) {
|
|
|
|
// Connectivity dropped mid-flight. If offline the item is back to 'pending'
|
|
|
|
// Connectivity dropped mid-flight. If offline the item is back to 'pending'
|
|
|
|
// and the `online` listener resumes it; if the failure hit while nominally
|
|
|
|
// and the `online` listener resumes it; if the failure hit while nominally
|
|
|
|
@@ -1162,7 +1219,26 @@ async function uploadItem(id: string): Promise<void> {
|
|
|
|
// and charging the guest's quota twice. Both 200 (deduped) and 201 (created) are
|
|
|
|
// and charging the guest's quota twice. Both 200 (deduped) and 201 (created) are
|
|
|
|
// success; `classifyUploadStatus` already treats the whole 2xx range that way.
|
|
|
|
// success; `classifyUploadStatus` already treats the whole 2xx range that way.
|
|
|
|
formData.append('client_upload_id', entry.id);
|
|
|
|
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.caption) formData.append('caption', entry.caption);
|
|
|
|
if (entry.hashtags) formData.append('hashtags', entry.hashtags);
|
|
|
|
if (entry.hashtags) formData.append('hashtags', entry.hashtags);
|
|
|
|
|
|
|
|
|
|
|
|
@@ -1480,6 +1556,20 @@ async function uploadItem(id: string): Promise<void> {
|
|
|
|
}
|
|
|
|
}
|
|
|
|
throw e;
|
|
|
|
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) {
|
|
|
|
if (e instanceof TerminalError) {
|
|
|
|
// Permanent rejection — drop the blob (we'll never resend it) and mark blocked
|
|
|
|
// Permanent rejection — drop the blob (we'll never resend it) and mark blocked
|
|
|
|
// so the UI shows a clear reason and offers no retry.
|
|
|
|
// so the UI shows a clear reason and offers no retry.
|
|
|
|
|