[00140] Add use_stream and use_upload Hooks With Server Side Multipart Upload - #134
Merged
rorychatt merged 6 commits intoAug 10, 2026
Conversation
The Multipart extractor the upload endpoint needs is behind a feature flag. Workspace-wide because rusty-server and rusty-desktop share the dependency.
Slots are keyed by connection so an upload URL only resolves for the session that created it, and dropping the handle a view holds unregisters the slot. The cancel flag is shared with the caller so a view can cancel a body that is already arriving, before the handle exists.
Drives a futures::Stream into view state chunk by chunk on a spawned task, with an optional retry budget and a ring cap on how much is retained. restart() bumps a generation State so the effect re-registers, and both restart and the effect cleanup abort the previous task before anything new writes the state.
Registers a slot on mount and publishes its URL, so a view renders its picker only once the URL exists. Progress is what the server has received, capped at 99 until the bytes are in hand. use_upload_to hands each file to a sink instead of holding it in view state, and reports a sink error the same way a rejected MIME type is reported. Both hooks use the same slot layout so swapping one for the other does not shift any later hook.
Reads the file field chunk by chunk rather than through Field::bytes(), which is what makes progress reporting, mid-flight cancellation and rejecting an oversize body without buffering it possible. Every failure past a resolved slot reports itself through UploadEvent::Failed first, because the browser only sees the status code. The raised body limit is a layer on this route alone.
rorychatt
deleted the
tendril/00140-AddUsestreamAndUseuploadHooksWithServerSideMultipartUpload
branch
August 10, 2026 09:33
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.
Fixes #120
00140 — Add
use_streamanduse_uploadHooks With Server Side Multipart UploadPlan 00140 (job 00687, issue #120) is implemented on branch
tendril/00140-AddUsestreamAndUseuploadHooksWithServerSideMultipartUploadinsix commits. All nine verifications are resolved: seven Pass, two Skipped (the two
frontend-only ones — this plan changed no frontend file, by design).
What was built
A stream hook.
use_streamdrives afutures::Streaminto view state chunk bychunk on a spawned task;
use_stream_textis the LLM-token convenience thatconcatenates instead of collecting. Both carry a
StreamStatus(
Idle/Streaming/Done/Error), an optional retry budget with a delay, anoptional cap on retention,
auto_start: falsefor streams a user starts, andrestart()/stop()closures safe to call from an event handler.An upload hook and the server half it needs.
use_uploadregisters a slot onmount and publishes its URL;
use_upload_tohands each completed file to a sinkinstead of holding it in view state. Behind them,
UploadServiceis aper-connection registry with the same drop-to-unregister design as
DownloadService, andupload_handlerservesPOST /rusty/upload/{connection_id}/{upload_id}by reading the multipart body chunkby chunk — which is what makes progress reporting, mid-flight cancellation and
rejecting an oversize body without buffering it all possible.
No frontend change, deliberately. The client half already shipped:
uploadFileWithProgressPOSTs aFormDatawith one field namedfileand treats200..300as success. The endpoint was written to that contract exactly. The RustFileInputwidget that would carry anuploadUrlto the browser is issue #128 andout of scope here.
Commits
a6e686ff0988df9108d8113849c6590bafc23f8639Ordered so each commit builds on the last: the feature flag, then the registry, then
each hook with its own exports, then the endpoint (whose tests need the hook), then
the docs. The two hook commits each carry only their own lines of the three shared
export files (
hooks/mod.rs,lib.rs,hook_rules.rs), which needed a separatecargo fmtpass per intermediate state because a partialuselist rewrapsdifferently from the final one.
Verifications
55 net new tests (
check-test-inventory.sh: 741 → 796): 19 foruse_stream, 14 foruse_upload, 14 forUploadService, 7 endpoint tests over a real loopback socket,plus two extended
session.rstests. Every server-binding test uses127.0.0.1:0.Three deviations from the plan, and why
1.
UploadError::is_client_error()was implemented asstatus_code(). Theplan's name reads as a predicate, but its signature and mapping are a converter. A
bool-looking method that returnsStatusCodeis a trap at every call site. Themapping is exactly what the plan specifies:
TooLarge → 413,TooSmall/RejectedMimeType → 415,NoFile/Cancelled/Transport → 400.2. The early oversize rejection compares against
max_bytes + MULTIPART_ENVELOPE_ALLOWANCE(8 KiB), notmax_bytes. The plan's"
Content-Lengthvsmax_bytes→ 413 early" is unsound as written:Content-Lengthcovers the boundary lines, the part headers and the file name aswell as the bytes, so it is only ever an upper bound on the file's own size. A
bare
total > max_byteswould reject a file of exactlymax_byteswith a 413 —a limit that lies about itself. The allowance keeps the early rejection for the case
it exists for, a body far too big to be worth reading, while the exact limit is
still enforced chunk by chunk as the body arrives. The constant carries this
reasoning in its doc comment, and both paths have their own test.
3.
add_upload_with_cancelwas added alongsideadd_upload. A view must beable to cancel a body that is already arriving, but the handle only exists after the
mount effect has run — so the cancel flag has to be owned by the hook's
use_refand handed to the service, not allocated by it.
add_uploadis unchanged forcallers that do not need this. Both are tested.
Two smaller calls, recorded so they are not mistaken for accidents:
restart()also resets the status toIdle. The plan does not mention it, but arestarted stream that still reports
Doneis wrong for any consumer keying offstatus — and it was making two tests pass vacuously.
use_stream_text'smax_chunkscaps how many chunks are appended, rather thanring-buffering, because a concatenated
Stringhas no meaningful "drop the oldestchunk".
use_streamuses a true ring cap, as specified.The one thing worth knowing if you touch these tests
The plan suggested testing
retry_delaywithtokio::time::pause+advance. Thatworks only for a test that advances the clock itself. Under
start_paused, tokioauto-advances to the nearest timer, and a polling helper's own 5 ms sleeps are
always nearer than a multi-second retry delay — so two retry tests spun through
2 s of virtual time and timed out without the retry ever firing. Those two now run
on the real clock with a 10 ms delay, and a dedicated
start_pausedtest(
test_a_retry_waits_out_the_retry_delay) drives the clock explicitly to prove thedelay is honoured: no retry at 9 s of a 10 s delay, retry by 11 s.
Created using Ivy Tendril.