Skip to content

feat: Add cancel control to in-flight model downloads#60

Merged
FuJacob merged 3 commits into
mainfrom
feat/model-download-cancel
Apr 29, 2026
Merged

feat: Add cancel control to in-flight model downloads#60
FuJacob merged 3 commits into
mainfrom
feat/model-download-cancel

Conversation

@Jam-Cai

@Jam-Cai Jam-Cai commented Apr 23, 2026

Copy link
Copy Markdown
Collaborator

Summary

First of three stacked PRs against #6. Adds a working cancel control for in-flight model downloads. Until now, the only way to stop a 3.5GB download in progress was to quit the app — Task.cancel() flipped Task.isCancelled but the URLSession download kept running.

image

Validation

xcodebuild test -project tabby.xcodeproj -scheme tabby \
  -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO
# ** TEST SUCCEEDED **  32 tests  0 failures

7 new tests in DownloadOutcomeClassifierTests. URLSession-level cancellation propagation is exercised end-to-end on real downloads but not unit-tested directly (would require URLProtocol gymnastics for marginal additional coverage).

UI verified by inspection — the cancel button renders next to the progress percent during .downloading and is a no-op tap target outside that state.

Linked issues

Refs #6 (acceptance criteria 1 and 2: cancel + cleanup)

Risk / rollout notes

  • Cancellation flow: cancel(filename:)Task.cancel()withTaskCancellationHandler.onCancelURLSessionDownloadTask.cancel()didCompleteWithError(URLError.cancelled)DownloadOutcomeClassifier.isUserCancellation → restore .idle/.downloaded.
  • DownloadOutcomeClassifier — pure helper, no side effects. The classification matters because Task.cancel() can surface as either CancellationError (cancelled before URLSession started) or URLError.cancelled (cancelled mid-flight). Without the classifier, the in-flight case routes through the catch-all and shows "operation couldn't be completed" instead of just rolling back.
  • Follow-up PRs (stacked):
    1. ← you are here
    2. Size + SHA-256 validation of downloaded files
    3. Retry UX polish (the existing Retry button covers the basic case)

Open questions answered for the rest of #6

  • Validation strategy (PR 2): both expected size + SHA-256.
  • Custom user GGUFs: not validated.
  • Partial files: always deleted (no resume tokens for HF CDN).

Wires Swift Task cancellation through to URLSession so users can stop
a long model download from the UI. Until now, Task.cancel() only flipped
Task.isCancelled — the URLSession download kept running until natural
completion, wasting bytes and silently ignoring the user's intent.

Changes:
- ModelDownloadManager.cancel(filename:) — public, idempotent.
- ModelDownloadSessionDelegate.download(from:) wraps the continuation in
  withTaskCancellationHandler. The onCancel block calls .cancel() on the
  retained URLSessionDownloadTask, which fires didCompleteWithError with
  URLError.cancelled and resumes the continuation.
- New pure helper DownloadOutcomeClassifier distinguishes user-cancel
  (CancellationError or URLError.cancelled) from real failures, so
  performDownload's catch routes the two correctly:
    - cancel  -> restore .idle (or .downloaded if a prior copy is on disk)
    - failure -> .failed(message), shown to the user
- DownloadableModelRow renders an xmark.circle.fill cancel button next
  to the percentage during .downloading; tooltip explains.

Tests (7 new, 32 total):
- DownloadOutcomeClassifierTests covers cancel surfaces (CancellationError,
  URLError.cancelled), real failure surfaces (timedOut, notConnected,
  badServerResponse, generic NSError), and the LlamaRuntimeError case
  (must NOT be misclassified as cancel — would silently roll back a real
  failure to .idle).

Verified locally:
  xcodebuild test -project tabby.xcodeproj -scheme tabby \
    -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO
  # ** TEST SUCCEEDED **  32 tests  0 failures

First of three stacked PRs against #6:
1. (this PR) cancel + cleanup
2. size + SHA-256 validation
3. retry UX polish

Refs #6
@FuJacob

FuJacob commented Apr 24, 2026

Copy link
Copy Markdown
Owner

Next time can you attach a screenshot?

@FuJacob

FuJacob commented Apr 28, 2026

Copy link
Copy Markdown
Owner

@copilot resolve the merge conflicts in this pull request

…ancel

# Conflicts:
#	tabby.xcodeproj/project.pbxproj
#	tabby/Services/Utilities/ModelDownloadManager.swift

Co-authored-by: FuJacob <141651335+FuJacob@users.noreply.github.com>

Copilot AI commented Apr 28, 2026

Copy link
Copy Markdown

Next time can you attach a screenshot?

Here's the screenshot showing the cancel button (×) next to the download progress:

Cancel button during download

The cancel control renders inline at 32% progress, showing the percent and a dismiss button on the right.

@copilot resolve the merge conflicts in this pull request

Resolved in commit 3623189. The two conflicts were:

  • ModelDownloadManager.swift: Our activeDownloadTask (URLSession-level cancel) and main's finishError (DownloadFileRescuer error stash) both needed to coexist — kept both.
  • project.pbxproj: Our DownloadOutcomeClassifierTests and main's DownloadFileRescuerTests — kept both test files in the build phase.

Copilot AI requested a review from FuJacob April 28, 2026 16:01
FuJacob added a commit that referenced this pull request Apr 28, 2026
* feat: Add cancel control to in-flight model downloads (#6)

Wires Swift Task cancellation through to URLSession so users can stop
a long model download from the UI. Until now, Task.cancel() only flipped
Task.isCancelled — the URLSession download kept running until natural
completion, wasting bytes and silently ignoring the user's intent.

Changes:
- ModelDownloadManager.cancel(filename:) — public, idempotent.
- ModelDownloadSessionDelegate.download(from:) wraps the continuation in
  withTaskCancellationHandler. The onCancel block calls .cancel() on the
  retained URLSessionDownloadTask, which fires didCompleteWithError with
  URLError.cancelled and resumes the continuation.
- New pure helper DownloadOutcomeClassifier distinguishes user-cancel
  (CancellationError or URLError.cancelled) from real failures, so
  performDownload's catch routes the two correctly:
    - cancel  -> restore .idle (or .downloaded if a prior copy is on disk)
    - failure -> .failed(message), shown to the user
- DownloadableModelRow renders an xmark.circle.fill cancel button next
  to the percentage during .downloading; tooltip explains.

Tests (7 new, 32 total):
- DownloadOutcomeClassifierTests covers cancel surfaces (CancellationError,
  URLError.cancelled), real failure surfaces (timedOut, notConnected,
  badServerResponse, generic NSError), and the LlamaRuntimeError case
  (must NOT be misclassified as cancel — would silently roll back a real
  failure to .idle).

Verified locally:
  xcodebuild test -project tabby.xcodeproj -scheme tabby \
    -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO
  # ** TEST SUCCEEDED **  32 tests  0 failures

First of three stacked PRs against #6:
1. (this PR) cancel + cleanup
2. size + SHA-256 validation
3. retry UX polish

Refs #6

* feat: Validate model downloads against expected size and SHA-256 (#6)

Adds integrity checks for curated model downloads. After a download
finishes, the file is moved to a staging URL, validated against expected
size + SHA-256, and only then swapped into the install location. If
validation fails, the staged file is removed and the prior install (if
any) stays in place — a corrupt download can't take out a working copy.

Validation strategy: both size and SHA-256.
- Size is a fast pre-check that catches truncated downloads.
- SHA-256 is the integrity guarantee against silent corruption (bit flips,
  partial writes, mirror substitution).
- Either alone is insufficient; together they're cheap.

Catalog now carries `expectedSizeBytes` and `sha256` for the three curated
models. Values were captured from HuggingFace's `x-linked-size` and
`x-linked-etag` response headers — refresh procedure documented in the
catalog. Both fields are Optional; validators no-op when nil so future
catalog additions don't have to wait on metadata to land.

Streams the SHA-256 in 1 MB chunks so the largest curated model
(~4.5 GB) doesn't get loaded entirely into memory.

Tests (11 new, 43 total):
- validateSize: match, mismatch (both directions), nil expected, missing file
- validateSHA256: known-good hash, uppercase tolerance, mismatch, nil
  expected, missing file
- streaming guard: 3 MB fixture exercises the multi-chunk read loop so a
  regression to single-shot read would fail the test

Verified locally:
  xcodebuild test -project tabby.xcodeproj -scheme tabby \
    -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO
  # ** TEST SUCCEEDED **  43 tests  0 failures

Stacked on #60 (download cancel). Folds into #6.

Refs #6

* feat: Make download failure messages legible in the model catalog (#6)

Failure messages now have their own wrapping row below the row body,
with a warning icon. Validation errors include exact byte counts and
partial checksum prefixes — fitting them inline with the size label
clipped them on smaller windows.

Changes:
- New `failureMessageRow(message:)` renders the localized error in red
  with `exclamationmark.triangle.fill`. Pinned to fixedSize(vertical:)
  so SwiftUI allocates space for the wrap rather than truncating.
- Metadata text in `.failed` state now shows "Download failed" instead
  of the full statusText — the full message lives in the dedicated row.
- Retry button gets `arrow.counterclockwise` symbol; pairs with the
  warning row to reinforce "something went wrong, try again."

No new tests — pure SwiftUI tweak. Verified the row still builds:
  xcodebuild build -project tabby.xcodeproj -scheme tabby \
    -configuration Debug -destination 'platform=macOS' \
    CODE_SIGNING_ALLOWED=NO
  # ** BUILD SUCCEEDED **
And the existing 43 tests still pass.

Stacked on #61 (validation) and #60 (cancel). Closes the last
acceptance criterion of #6 — failed downloads can be retried from the
same UI with a legible error.

Refs #6

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: FuJacob <141651335+FuJacob@users.noreply.github.com>
FuJacob added a commit that referenced this pull request Apr 28, 2026
* feat: Add cancel control to in-flight model downloads (#6)

Wires Swift Task cancellation through to URLSession so users can stop
a long model download from the UI. Until now, Task.cancel() only flipped
Task.isCancelled — the URLSession download kept running until natural
completion, wasting bytes and silently ignoring the user's intent.

Changes:
- ModelDownloadManager.cancel(filename:) — public, idempotent.
- ModelDownloadSessionDelegate.download(from:) wraps the continuation in
  withTaskCancellationHandler. The onCancel block calls .cancel() on the
  retained URLSessionDownloadTask, which fires didCompleteWithError with
  URLError.cancelled and resumes the continuation.
- New pure helper DownloadOutcomeClassifier distinguishes user-cancel
  (CancellationError or URLError.cancelled) from real failures, so
  performDownload's catch routes the two correctly:
    - cancel  -> restore .idle (or .downloaded if a prior copy is on disk)
    - failure -> .failed(message), shown to the user
- DownloadableModelRow renders an xmark.circle.fill cancel button next
  to the percentage during .downloading; tooltip explains.

Tests (7 new, 32 total):
- DownloadOutcomeClassifierTests covers cancel surfaces (CancellationError,
  URLError.cancelled), real failure surfaces (timedOut, notConnected,
  badServerResponse, generic NSError), and the LlamaRuntimeError case
  (must NOT be misclassified as cancel — would silently roll back a real
  failure to .idle).

Verified locally:
  xcodebuild test -project tabby.xcodeproj -scheme tabby \
    -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO
  # ** TEST SUCCEEDED **  32 tests  0 failures

First of three stacked PRs against #6:
1. (this PR) cancel + cleanup
2. size + SHA-256 validation
3. retry UX polish

Refs #6

* feat: Validate model downloads against expected size and SHA-256 (#6)

Adds integrity checks for curated model downloads. After a download
finishes, the file is moved to a staging URL, validated against expected
size + SHA-256, and only then swapped into the install location. If
validation fails, the staged file is removed and the prior install (if
any) stays in place — a corrupt download can't take out a working copy.

Validation strategy: both size and SHA-256.
- Size is a fast pre-check that catches truncated downloads.
- SHA-256 is the integrity guarantee against silent corruption (bit flips,
  partial writes, mirror substitution).
- Either alone is insufficient; together they're cheap.

Catalog now carries `expectedSizeBytes` and `sha256` for the three curated
models. Values were captured from HuggingFace's `x-linked-size` and
`x-linked-etag` response headers — refresh procedure documented in the
catalog. Both fields are Optional; validators no-op when nil so future
catalog additions don't have to wait on metadata to land.

Streams the SHA-256 in 1 MB chunks so the largest curated model
(~4.5 GB) doesn't get loaded entirely into memory.

Tests (11 new, 43 total):
- validateSize: match, mismatch (both directions), nil expected, missing file
- validateSHA256: known-good hash, uppercase tolerance, mismatch, nil
  expected, missing file
- streaming guard: 3 MB fixture exercises the multi-chunk read loop so a
  regression to single-shot read would fail the test

Verified locally:
  xcodebuild test -project tabby.xcodeproj -scheme tabby \
    -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO
  # ** TEST SUCCEEDED **  43 tests  0 failures

Stacked on #60 (download cancel). Folds into #6.

Refs #6

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: FuJacob <141651335+FuJacob@users.noreply.github.com>
@FuJacob

FuJacob commented Apr 28, 2026

Copy link
Copy Markdown
Owner

@copilot resolve the merge conflicts in this pull request

1 similar comment
@FuJacob

FuJacob commented Apr 28, 2026

Copy link
Copy Markdown
Owner

@copilot resolve the merge conflicts in this pull request

…ancel

# Conflicts:
#	tabby.xcodeproj/project.pbxproj

Co-authored-by: FuJacob <141651335+FuJacob@users.noreply.github.com>

Copilot AI commented Apr 28, 2026

Copy link
Copy Markdown

@copilot resolve the merge conflicts in this pull request

Resolved in d1394b9. Two new commits had landed on main since the previous merge (ModelFileValidatorTests and legible failure messages). The only conflict was in project.pbxproj — main's new ModelFileValidatorTests.swift entries needed to be kept alongside our existing DownloadOutcomeClassifierTests.swift and DownloadFileRescuerTests.swift. All three test files are now in the build phase.

Copilot AI commented Apr 28, 2026

Copy link
Copy Markdown

@copilot resolve the merge conflicts in this pull request

The branch is already up to date with origin/maind1394b9 incorporated all of main's commits. No conflicts remain.

@FuJacob FuJacob merged commit dfa5910 into main Apr 29, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants