Skip to content

feat(storage): support pause and resume for downloads#990

Open
grdsdev wants to merge 6 commits into
claude/vibrant-carson-674f4afrom
feat/storage-resumable-downloads
Open

feat(storage): support pause and resume for downloads#990
grdsdev wants to merge 6 commits into
claude/vibrant-carson-674f4afrom
feat/storage-resumable-downloads

Conversation

@grdsdev
Copy link
Copy Markdown
Contributor

@grdsdev grdsdev commented May 6, 2026

Summary

  • Adds DownloadEngine — an actor-based state machine (idle → starting → downloading → paused → completed/failed/cancelled) that owns the URLSessionDownloadTask lifecycle
  • pause() calls cancel(byProducingResumeData:) and awaits the callback so resume data is captured synchronously before returning; resume() uses downloadTask(withResumeData:) when available, otherwise restarts from byte 0
  • DownloadSessionDelegate refactored from continuation-holding to callback-based routing (DownloadTaskCallbacks); temp-file move now happens synchronously inside the delegate method before URLSession reclaims the file
  • StorageTransferTask.pause() / resume() docs updated to describe download behaviour alongside TUS uploads

Design notes

Why pause() is async: cancel(byProducingResumeData:) delivers resume data via a callback. Making pause() async lets us await withCheckedContinuation so the data is stored on the actor before the method returns — no extra Task {} or intermediate state needed.

Temp-file safety: URLSession deletes the temp file passed to didFinishDownloadingTo when that delegate method returns. The old design moved the file inside a continuation yield (safe). The new callback design moves it synchronously inside the delegate method before invoking onFinished(Result<URL, StorageError>), so the engine only ever sees the already-moved URL.

didCompleteWithError filtering: A URLError.cancelled arriving while state is .paused or .starting is intentional (from cancel(byProducingResumeData:) or a stale cancel after resume()) and is silently dropped. Only .downloading state treats an unexpected .cancelled as a real failure.

Test plan

  • DownloadEngineTests.downloadCompletesSuccessfully — basic happy path
  • DownloadEngineTests.pauseAndResumeCompletesDownload — pause a hanging download, resume, verify completion
  • DownloadEngineTests.cancelYieldsCancelledError — cancel mid-download, verify .failed(.cancelled) event
  • DownloadEngineTests.cancelThrowsFromValue — cancel mid-download, verify task.value throws StorageError.cancelled
  • DownloadEngineTests.networkErrorYieldsFailedEvent — network error surfaces as .failed
  • Existing DownloadSessionDelegateTests — delegate routing still correct after refactor
  • Existing StorageDownloadTaskTests — end-to-end download API unchanged

🤖 Generated with Claude Code

@grdsdev grdsdev requested a review from a team as a code owner May 6, 2026 20:01
@grdsdev
Copy link
Copy Markdown
Contributor Author

grdsdev commented May 6, 2026

Code review

No issues found. Checked for bugs and CLAUDE.md compliance.

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

Comment thread Tests/IntegrationTests/StorageDownloadIntegrationTests.swift Outdated
@grdsdev grdsdev force-pushed the feat/storage-resumable-downloads branch from ffd5ec5 to a6f3159 Compare May 7, 2026 08:39
grdsdev and others added 6 commits May 7, 2026 05:41
Adds `DownloadEngine` — an actor-based state machine that manages
URLSessionDownloadTask lifecycle with pause/resume/cancel support.

When the server returns `Accept-Ranges: bytes`, pausing captures resume
data via `cancel(byProducingResumeData:)` so the next `resume()` call
restarts from the last received byte. If the server does not support
range requests the download restarts from byte 0.

Changes:
- New `DownloadEngine` actor with states: idle, starting, downloading,
  paused, completed, failed, cancelled
- `DownloadSessionDelegate` refactored to callback-based routing
  (`DownloadTaskCallbacks`) instead of holding continuations directly;
  temp-file move is now done synchronously inside the delegate method so
  URLSession cannot reclaim the file before the move completes
- `StorageFileAPI.download()` now wires through `DownloadEngine.makeTask`
  and documents pause/resume behavior
- `StorageTransferTask.pause()` / `resume()` docs updated to cover
  downloads alongside TUS uploads
- New `DownloadEngineTests` (Darwin-only) covering basic download,
  pause-then-resume, cancel-yields-cancelled-error, and network-error

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…lude downloads

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Covers happy path, pause/resume cycles, cancel semantics, error
handling, and concurrent download scenarios using a live Supabase
instance via the existing withUploadedFile helper pattern.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1. Task-identity race in DownloadEngine: after pause() cancels the active
   URLSessionDownloadTask (ID=X) and resume() starts a new one (ID=Y),
   the stale URLError.cancelled from X could arrive while state is
   .downloading(Y) and incorrectly call cancel(). Fix: capture the task
   identifier at callback-registration time and guard every engine callback
   against a mismatch with the current active task.

2. HTTP error responses delivered as completed downloads: URLSession calls
   didFinishDownloadingTo for 4xx/5xx responses too, saving the error JSON
   as the "downloaded" file. Fix: check the HTTP status code in the delegate
   and parse the Supabase error envelope into a StorageError, delivering
   .failed instead of .success.

Also adds StorageError.from(httpResponse:data:) as an internal factory
shared by the delegate, and removes LockIsolated (unavailable in
IntegrationTests target) from the multi-pause-cycle test in favour of
a local actor.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@grdsdev grdsdev force-pushed the feat/storage-resumable-downloads branch from a6f3159 to 6b62a19 Compare May 7, 2026 08:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant