iOS chunked-upload engine - #34
Merged
Merged
Conversation
This was referenced Aug 24, 2026
dmurphy5
force-pushed
the
dylan/v9-6-ios
branch
from
August 25, 2026 17:36
00fc39c to
445dfdc
Compare
dmurphy5
force-pushed
the
dylan/v9-6-ios
branch
from
August 26, 2026 20:26
445dfdc to
982d227
Compare
dmurphy5
force-pushed
the
dylan/v9-6-ios
branch
from
August 27, 2026 19:46
982d227 to
5a0c08c
Compare
dmurphy5
marked this pull request as ready for review
August 27, 2026 19:51
Per docs/design/chunked-uploads.md, the counterpart to the Android slice with
identical JS-visible semantics. startChunkedUpload renames the source into a
library-owned directory (Application Support/RNFileUploaderChunked/<id>/blob)
and persists a durable Codable manifest before any task is enqueued; re-calls
reconcile — same parts resume (stored headers/expiresAt replaced, accepted
parts skipped), a different parts array recreates per the design's rule. The
manifest carries a persisted `stalled` flag instead of Android's
it-just-isn't-scheduled, because iOS reconciles every upload on relaunch and
must not auto-resume one that journaled a terminal; it also stores no absolute
source path (the app container moves between launches — the blob location is
derived from the id).
The coordinator keeps a sliding window of 3 part tasks enqueued with the
daemon per upload (the design's liveness runway for a dead app), never two for
one part index: inFlight maps each part to the task key that owns it, every
transition runs on one serial queue, only refill creates tasks, and a
reconcile token discards stale daemon snapshots so overlapping reconciles
can't double-enqueue. Background sessions only upload from files, so each
enqueued part gets a temp file of exactly its byte range (FileHandle range
copy, tmp+rename), deleted when the part finishes and rebuilt on retry —
transient disk stays window x partSize. Part completions evaluate the accept
rules against the manifest, persist the flag, emit byte-weighted aggregate
progress, and refill; transient failures (network, 5xx, system cancels) back
off exponentially with expiresAt as the only cap; a non-accepted response out
of its 3 per-part retries journals an 'error' with the part detail and stalls.
Relaunch reconciliation runs whenever the sessions are recreated (module init
and the AppDelegate background-wake hook both land in `shared`'s init):
enumerate the daemon's tasks, match them to manifests via taskDescription with
the TaskMap entry as durable fallback, rebuild the window, sweep orphaned temp
files. The existing setBackgroundSessionCompletionHandler flow is untouched.
Bytes are deleted only when a 'completed' event is acked; cancelUpload
journals one user 'cancelled' and keeps manifest+bytes; removeUpload releases
both with no event (its cancellations are suppressed for simple uploads too,
matching Android). Accept rules also replace the dead acceptStatus parsing for
simple uploads. httpMaximumConnectionsPerHost stays 1 — the library-wide cap
of 4 belongs to the hardening slice.
Review fixes:
- Simple-upload idempotency had a check-then-act race: the existence check
enumerates session tasks asynchronously, so two concurrent same-id calls
could both pass it and enqueue duplicates. check-and-create is now
serialized per id — the first caller claims the id synchronously before
dispatching enumeration; concurrent callers park their resolve and are
answered with the id once the task lands.
- The manifest now carries an incarnation token, persisted and encoded into
each part task's identity (taskDescription and TaskMap). A late delegate
callback whose token doesn't match the stored manifest's is dropped, so a
removed-then-recreated upload can never have an old incarnation's part
completion written into the new manifest; relaunch reconciliation cancels
rather than adopts mismatched tasks.
- Recreate semantics implemented to match Android's ChunkedManifest.reconcile:
a different parts array is accepted only while the upload is not running
(stalled or expired) — keep the blob, replace parts/headers/accept/expiresAt,
reset every part to unsent, require the new ranges to tile [0, blob size),
reject while running; path stays ignored whenever a manifest exists. The
incarnation rotates on recreate.
- Part temp files are no longer trusted by size alone on relaunch: the
filename encodes the incarnation and byte range that produced the file, so
a stale copy from a prior plan is rebuilt instead of reused, and stale
siblings are swept when a part is next materialized.
- A blob shorter than a part's range now stalls with a terminal 'file' error
on the first refill instead of surfacing only when the window reached the
short part (Android is aligned to the same rule in its slice).
- startChunkedUpload documents its byte-safety ordering: every rejection-type
validation precedes the source move, and the one post-move failure (the
manifest save) leaves the blob adoptable by a retry with the same id.
Final review fixes:
- Relaunch refill no longer races the background completion handler: replayed
part completions run unowned and never refill, so reconcileAll's async chain
was the only refill — and urlSessionDidFinishEvents could hand the system its
completion handler (and the app its suspension) before a single new part task
was enqueued, likeliest exactly when every enqueued part finished while the
app was dead (zero daemon tasks, no future wake, silent stall). The
coordinator now claims the pending completion handlers when reconciliation
begins and releases them only after every upload's post-reconcile refill has
resumed its tasks; a didFinishEvents in between parks its session id and is
drained at release.
- The per-part non-transient HTTP retry budget is persisted in the manifest
instead of in-memory (where every process death reset it, letting a
deterministic 4xx re-upload its part once per wake until expiresAt with no
terminal ever journaled), and an unowned non-transient HTTP failure with no
live replacement task now counts toward it, so the budget can trip across
wakes. Resume and recreate still grant a fresh budget — both rebuild parts
from the incoming call.
- clearState prunes expiryArmed (a removed-then-recreated id could not arm its
possibly-earlier deadline until the stale timer fired) and the coordinator's
lastProgressAt entry (unbounded growth).
- TaskMap.Meta decodes the legacy acceptStatus:[Int] field and maps it to
{status} accept rules, mirroring Android's Upload.normalized(), so a task
persisted by an earlier build keeps its accept rules when it completes under
this one.
- First-call (create) startChunkedUpload validates that the parts tile
[0, blob size) exactly, same as recreate and as Android's validatedForCreate,
rejecting after the source move but before the manifest is saved or anything
is enqueued — the moved blob stays adoptable by a corrected retry.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
dmurphy5
force-pushed
the
dylan/v9-6-ios
branch
from
August 28, 2026 16:49
5a0c08c to
6523157
Compare
elliottkember
approved these changes
Aug 31, 2026
This was referenced Sep 3, 2026
dmurphy5
added a commit
that referenced
this pull request
Sep 3, 2026
This PR moves the merged chunked-upload work onto master. It replaces #39, which conflicted. The stacked PRs #32–#35 were reviewed and merged, but their stack was rooted on `dylan/v9-3-configure`, so their four squash commits landed on that branch and not on master. This branch carries those same four commits, cherry-picked onto the current master. The tree at the tip is byte-identical to the merged stack. There are no new changes, and there are no conflicts: the branch is a fast-forward of master. - Chunked JS surface — types, codegen spec, chunkPlan (#32) - Android chunked-upload engine (#33) - iOS chunked-upload engine (#34) - Global cap, example app, docs, v9.0.0 (#35) Prefer "Rebase and merge", so the four commits keep their identities on master. After the merge, master is v9.0.0 and gets the `v9.0.0` tag. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 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.
This PR adds the iOS engine for chunked uploads. The JS-visible behavior is the same as Android. The transport is a background
URLSession.A background session can send only files. Thus the engine writes one temporary file for each part in flight, and deletes it when the part completes. The transient disk usage stays near 60 MB. There is no second full copy of the source file.
The engine keeps a sliding window of 3 part tasks enqueued in the session. The session daemon continues the tasks when the app is suspended or terminated. When iOS relaunches the app, the engine reconciles the manifest with the daemon's task list, and then fills the window again. The background completion-handler flow is kept.
Review fixes in this slice:
startUploadraces are serialized natively.🤖 Generated with Claude Code