Repository navigation
Uploads: stream to disk in resumable pieces, survive a hub restart, show progress and cancel, and say why a file is refused - #17
Merged
Conversation
…fter a dropped connection or a hub restart, show progress with a cancel, and a file that cannot go says why The hub writes each upload straight to <data>/uploads with a JSON record beside it, refuses one over the cap while it streams, and keeps uploads used in the last hour across a restart, sweeping everything else. The composer uploads each file as it is attached, in 2 MiB pieces over XHR, and lists refused files with the reason instead of dropping them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What changed
Hub (
packages/hub/src/uploads.ts,server.ts)<data>/uploads/<id>as they arrive, never whole into memory. A JSON record<id>.jsonsits beside each file:{ name, mime, size, at }. The folder stays 0700 and the files 0600.POST /api/uploads/start { name, mime, size }PUT /api/uploads/:id?offset=Ntakes at mostUPLOAD_CHUNK(2 MiB). A wrong offset gets 409 with{ received }.GET /api/uploads/:idandDELETE /api/uploads/:idreceivedis the file's size on disk. A piece cut off midway still leaves good bytes, and the next piece starts after them.POST /api/uploads(whole body in one request) still works, for older cached pages. It now streams to disk and is refused with 413 as soon as it passes the cap, even with no Content-Length. Nothing is kept when the upload is too large, empty, cut off, or shorter than its Content-Length.delivery.tsis notifications). They are in the app's IndexedDB outbox, with their files. Before this change the hub emptied the uploads folder at every start, so after a restart:Web (
upload.ts,ui/ComposerFiles.tsx,state.ts, composer edits inTimeline.tsx)Each file starts uploading as soon as it is attached, in pieces sent with XHR so progress can be shown. Each file chip has a progress bar and a cancel (×). Cancelling, or leaving the conversation, deletes what the hub has of that file.
After no answer or a 5xx, the app retries with backoff (1 s, 2 s, 4 s, 8 s, 15 s, 15 s), each time asking how much the hub has. After that it gives up as a
TypeError, and the outbox waits for the hub as it does for any send. The upload ids are saved with the send in IndexedDB as soon as the hub gives them, so a reload carries on where the upload stopped.Sending while files are still uploading queues the send (the existing outbox flow) and frees the composer. The pending copy shows "Uploading…" with each file's percentage.
No file is dropped silently any more. A refused file stays in the row with its reason and is never uploaded:
Over Discord’s 10 MB limit (this is 12 MB)Over the hub’s 25 MB limitEmpty fileCannot be read…Discord takes 10 files at a timeBefore this, files over the limit got a toast that didn't name the file, and files over the count were cut off by
.slice(0, limit.files)without a word.The attach button's tooltip names the network's limit.
Protocol: adds
UPLOAD_CHUNKandUploadState. Both are additions only;accounts-synccopies them over.Why resumable pieces rather than one streamed request
The case to plan for is a phone on a flaky connection. With pieces, a dropped connection loses at most the 2 MiB in flight, and the upload carries on from the hub's
received. With one stream it would start the whole 25 MB again. Pieces also let a hub restart in the middle of an upload carry on from where it stopped. The cost is one extra round trip per 2 MiB.Why MAX_UPLOAD stays at 25 MiB
The hub no longer holds files in memory, but the adapters still read each file whole to send it. Discord's adapter base64-encodes the file into the command it hands its client. Signal, WhatsApp, Telegram and Google Messages also take
MAX_UPLOADas their limit inATTACHMENT_LIMITS. Raising it safely needs each network's real limit checked and the adapters changed to stream (dither-accounts). The reasoning is indocs/uploads.md.How it was verified
Hub:
packages/hub/test/uploads.test.ts(14 tests) covers:server.test.ts"sending files" has 5 new tests over HTTP:A mutation check (not deleting partial files) fails 5 of these tests.
Web:
packages/web/test/upload.test.ts(9 tests) runs against a fake hub:scripts/verify-uploads.ts: all 21 checks pass in headless Chromium against its own demo hub on a fresh data dir. The script owns the hub, so restarting it ends nothing else.offset=819200, with no file uploaded again (nostartorPOSTafter the restart). The message goes out once, with both files, and the demo replies.verify-sends(17/17) andverify-reply(9/9) still pass, the first because the outbox send path changed.bun run typecheckpasses. Inbun run test, every package passes except 4 tests:inbox-plan"in a few milliseconds" andlearn"fits twenty thousand examples quickly". These are CPU timing thresholds; the machine's load average was about 27 on 16 cores, and neither test touches this code.The composer was checked by eye in screenshots at desktop and at 390 px.
What I could not verify
🤖 Generated with Claude Code