Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions tabby.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@
objects = {

/* Begin PBXBuildFile section */
8B6282F0C1CCA0746D96B914 /* DownloadOutcomeClassifierTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 562F89255AF340C15A0554BE /* DownloadOutcomeClassifierTests.swift */; };
A1C3E0012F90000100AAA001 /* LlamaSwift in Frameworks */ = {isa = PBXBuildFile; productRef = A1C3E0002F90000100AAA001 /* LlamaSwift */; };
A1C3E0112F90000100AAA001 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = A1C3E0102F90000100AAA001 /* Sparkle */; };
A404828463CADB2ECDAE7AF3 /* LlamaPromptRendererTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F29623C5C0A67B992D383A3C /* LlamaPromptRendererTests.swift */; };
ADEFEE12C197DB6C990E3812 /* SuggestionRequestFactoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B1121FFDAD30C7F62E15289 /* SuggestionRequestFactoryTests.swift */; };
AF0F4C853CCA8B86BB5E28CD /* ModelFileValidatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9D35DB9E86506B9FAE1CFE9 /* ModelFileValidatorTests.swift */; };
B053492719F6106E48290C32 /* SuggestionAvailabilityEvaluatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A39EC1A44E9160E13E2AE77 /* SuggestionAvailabilityEvaluatorTests.swift */; };
B5788B37B93AFEC10EFD3108 /* DownloadFileRescuerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAAEE25772008D75883F2655 /* DownloadFileRescuerTests.swift */; };
/* End PBXBuildFile section */
Expand All @@ -28,10 +30,12 @@
/* Begin PBXFileReference section */
193741492F81DE7000BEC04F /* tabby.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = tabby.app; sourceTree = BUILT_PRODUCTS_DIR; };
3FBFA92FA44AA317135426FB /* tabbyTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = tabbyTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
562F89255AF340C15A0554BE /* DownloadOutcomeClassifierTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DownloadOutcomeClassifierTests.swift; sourceTree = "<group>"; };
5A39EC1A44E9160E13E2AE77 /* SuggestionAvailabilityEvaluatorTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SuggestionAvailabilityEvaluatorTests.swift; sourceTree = "<group>"; };
8B1121FFDAD30C7F62E15289 /* SuggestionRequestFactoryTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SuggestionRequestFactoryTests.swift; sourceTree = "<group>"; };
BAAEE25772008D75883F2655 /* DownloadFileRescuerTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DownloadFileRescuerTests.swift; sourceTree = "<group>"; };
F29623C5C0A67B992D383A3C /* LlamaPromptRendererTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LlamaPromptRendererTests.swift; sourceTree = "<group>"; };
F9D35DB9E86506B9FAE1CFE9 /* ModelFileValidatorTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ModelFileValidatorTests.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */

/* Begin PBXFileSystemSynchronizedRootGroup section */
Expand Down Expand Up @@ -88,6 +92,8 @@
F29623C5C0A67B992D383A3C /* LlamaPromptRendererTests.swift */,
5A39EC1A44E9160E13E2AE77 /* SuggestionAvailabilityEvaluatorTests.swift */,
8B1121FFDAD30C7F62E15289 /* SuggestionRequestFactoryTests.swift */,
562F89255AF340C15A0554BE /* DownloadOutcomeClassifierTests.swift */,
F9D35DB9E86506B9FAE1CFE9 /* ModelFileValidatorTests.swift */,
BAAEE25772008D75883F2655 /* DownloadFileRescuerTests.swift */,
);
path = tabbyTests;
Expand Down Expand Up @@ -209,6 +215,8 @@
A404828463CADB2ECDAE7AF3 /* LlamaPromptRendererTests.swift in Sources */,
B053492719F6106E48290C32 /* SuggestionAvailabilityEvaluatorTests.swift in Sources */,
ADEFEE12C197DB6C990E3812 /* SuggestionRequestFactoryTests.swift in Sources */,
8B6282F0C1CCA0746D96B914 /* DownloadOutcomeClassifierTests.swift in Sources */,
AF0F4C853CCA8B86BB5E28CD /* ModelFileValidatorTests.swift in Sources */,
B5788B37B93AFEC10EFD3108 /* DownloadFileRescuerTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
Expand Down
31 changes: 28 additions & 3 deletions tabby/Models/LlamaRuntimeModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,15 @@
let displayName: String
let downloadURL: URL
let approximateSizeInGigabytes: Double
/// Exact byte count of the served file. Optional so future catalog entries
/// can land while metadata is still being filled in. When non-nil, the
/// download manager runs `ModelFileValidator.validateSize` against it
/// before promoting the staged file into the install location.
let expectedSizeBytes: Int64?
/// Lowercase SHA-256 hex string for the served file. Same nullability
/// rationale as `expectedSizeBytes`. HuggingFace exposes this as the
/// `x-linked-etag` response header on its CDN URLs.
let sha256: String?
let alternateFilenames: [String]

var id: String { filename }
Expand All @@ -59,12 +68,16 @@
displayName: String,
downloadURL: URL,
approximateSizeInGigabytes: Double,
expectedSizeBytes: Int64? = nil,
sha256: String? = nil,
alternateFilenames: [String] = []
) {
self.filename = filename
self.displayName = displayName
self.downloadURL = downloadURL
self.approximateSizeInGigabytes = approximateSizeInGigabytes
self.expectedSizeBytes = expectedSizeBytes
self.sha256 = sha256
self.alternateFilenames = alternateFilenames
}
}
Expand All @@ -84,6 +97,12 @@
}

/// Canonical downloadable model list shown in Welcome and menu UI.
///
/// `expectedSizeBytes` and `sha256` were captured from HuggingFace's CDN
/// response headers (`x-linked-size` and `x-linked-etag` respectively).
/// To refresh after a model is updated upstream:
///
/// curl -sIL "<URL>" | grep -iE "^(x-linked-size|x-linked-etag):"
static let downloadableModels: [DownloadableRuntimeModel] = [
DownloadableRuntimeModel(
filename: "gemma-3-1b-it-Q4_K_M.gguf",
Expand All @@ -92,7 +111,9 @@
string:
"https://huggingface.co/unsloth/gemma-3-1b-it-GGUF/resolve/main/gemma-3-1b-it-Q4_K_M.gguf?download=true"
)!,
approximateSizeInGigabytes: 0.8
approximateSizeInGigabytes: 0.8,
expectedSizeBytes: 806_058_272,
sha256: "8270790f3ab69fdfe860b7b64008d9a19986d8df7e407bb018184caa08798ebd"
),
DownloadableRuntimeModel(
filename: "Qwen3-0.6B-Q4_K_M.gguf",
Expand All @@ -101,7 +122,9 @@
string:
"https://huggingface.co/unsloth/Qwen3-0.6B-GGUF/resolve/main/Qwen3-0.6B-Q4_K_M.gguf?download=true"
)!,
approximateSizeInGigabytes: 0.4
approximateSizeInGigabytes: 0.4,
expectedSizeBytes: 396_705_472,
sha256: "ac2d97712095a558e31573f62f466a3f9d93990898b0ec79d7c974c1780d524a"
),
DownloadableRuntimeModel(
filename: "gemma-3n-E4B-it-Q4_K_M.gguf",
Expand All @@ -110,8 +133,10 @@
string:
"https://huggingface.co/unsloth/gemma-3n-E4B-it-GGUF/resolve/main/gemma-3n-E4B-it-Q4_K_M.gguf?download=true"
)!,
approximateSizeInGigabytes: 3.5
approximateSizeInGigabytes: 3.5,
expectedSizeBytes: 4_539_054_208,
sha256: "43b489bb77a81bda85180e7c490d40ad7f1d5c2ce654c9b05e15e104bd3c777e"
),

Check warning on line 139 in tabby/Models/LlamaRuntimeModels.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Collection literals should not have trailing commas (trailing_comma)
]
}

Expand All @@ -130,7 +155,7 @@
preferredModelNames: [
"gemma-3-1b-it-Q4_K_M.gguf",
"Qwen3-0.6B-Q4_K_M.gguf",
"gemma-3n-E4B-it-Q4_K_M.gguf",

Check warning on line 158 in tabby/Models/LlamaRuntimeModels.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Collection literals should not have trailing commas (trailing_comma)
],
contextWindowTokens: 2048,
batchSize: 512,
Expand Down
32 changes: 32 additions & 0 deletions tabby/Services/Utilities/DownloadOutcomeClassifier.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import Foundation

/// File overview:
/// Classifies download errors into "user pressed cancel" vs "something genuinely
/// went wrong." Used by `ModelDownloadManager` to decide whether a failed
/// download should restore the prior state (cancel) or surface as `.failed`.
///
/// Why this is its own type:
/// Swift's `Task.cancel()` triggers two distinct error shapes by the time we
/// catch them downstream:
///
/// 1. `CancellationError` — when `Task.checkCancellation()` runs *before* the
/// URLSession download even starts.
/// 2. `URLError(.cancelled)` — when the URLSession download is in flight and
/// our `withTaskCancellationHandler` aborts it via
/// `URLSessionDownloadTask.cancel()`.
///
/// Without this classification, case (2) would route through the catch-all
/// failure path and the user would see "The operation couldn't be completed"
/// despite having pressed Cancel themselves. This helper makes the
/// distinction testable in isolation, with no URLSession or Task setup.
enum DownloadOutcomeClassifier {
static func isUserCancellation(_ error: Error) -> Bool {
if error is CancellationError {
return true
}
if let urlError = error as? URLError, urlError.code == .cancelled {
return true
}
return false
}
}
98 changes: 86 additions & 12 deletions tabby/Services/Utilities/ModelDownloadManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,27 @@ final class ModelDownloadManager: ObservableObject {
downloadTasks[model.filename] = task
}

/// User-initiated cancel of an in-flight model download. Idempotent —
/// calling it on a filename that isn't downloading is a safe no-op.
///
/// Cancellation flow:
/// 1. `Task.cancel()` flips `Task.isCancelled` and triggers the
/// `withTaskCancellationHandler` block in the delegate.
/// 2. That block calls `URLSessionDownloadTask.cancel()`, which aborts
/// the in-flight download.
/// 3. The delegate receives `didCompleteWithError(URLError.cancelled)`
/// and resumes the continuation throwing.
/// 4. `performDownload`'s catch routes the error through
/// `DownloadOutcomeClassifier`, sees a user cancel, and restores
/// `.idle` (or `.downloaded` if a prior copy is on disk) — never
/// `.failed`, since the user pressed Cancel deliberately.
func cancel(filename: String) {
guard let task = downloadTasks[filename] else {
return
}
task.cancel()
}

func openModelsDirectory() {
do {
try ensureRuntimeDirectoryExists()
Expand Down Expand Up @@ -183,22 +204,55 @@ final class ModelDownloadManager: ObservableObject {
try validate(response: downloadResult.response)

let fileManager = FileManager.default

// Stage-validate-swap so a corrupt download can't take out a
// working previous install. If validation throws, the staged
// file is removed and the existing destinationURL (if any)
// stays untouched.
let stagingURL = runtimeDirectoryURL.appendingPathComponent(
"\(model.filename).staging-\(UUID().uuidString)",
isDirectory: false
)
try fileManager.moveItem(at: downloadResult.temporaryURL, to: stagingURL)

do {
try ModelFileValidator.validateSize(
of: stagingURL,
expectedBytes: model.expectedSizeBytes
)
try ModelFileValidator.validateSHA256(
of: stagingURL,
expectedSHA256: model.sha256
)
} catch {
// Don't leave a partial or corrupt file in the runtime
// directory where the locator might pick it up later.
try? fileManager.removeItem(at: stagingURL)
throw error
}

// Validation passed — atomically swap the new file in. The
// existing copy is removed only at this point, so any failure
// before here leaves the prior install intact.
if fileManager.fileExists(atPath: destinationURL.path) {
try fileManager.removeItem(at: destinationURL)
}

try fileManager.moveItem(at: downloadResult.temporaryURL, to: destinationURL)
try fileManager.moveItem(at: stagingURL, to: destinationURL)

modelStates[model.filename] = .downloaded
onModelDirectoryChanged?()
} catch is CancellationError {
if isInstalled(model: model) {
modelStates[model.filename] = .downloaded
} catch {
// A user-initiated cancel surfaces here as either CancellationError
// (cancelled before URLSession ran) or URLError.cancelled
// (cancelled while in flight). Both should restore the prior
// visible state, not show a failure — the user already knows what
// they did. DownloadOutcomeClassifier owns the discrimination so
// the rule is unit-tested in isolation.
if DownloadOutcomeClassifier.isUserCancellation(error) {
modelStates[model.filename] = isInstalled(model: model) ? .downloaded : .idle
} else {
modelStates[model.filename] = .idle
modelStates[model.filename] = .failed(error.localizedDescription)
}
} catch {
modelStates[model.filename] = .failed(error.localizedDescription)
}
}

Expand Down Expand Up @@ -250,6 +304,11 @@ private final class ModelDownloadSessionDelegate: NSObject, URLSessionDownloadDe
private var downloadedFileURL: URL?
private var response: URLResponse?
private var hasCompleted = false
// Held so `withTaskCancellationHandler` can call .cancel() on it when the
// surrounding Swift Task is cancelled. Without this, Task.cancel() would
// only flip Task.isCancelled — the URLSession download would keep running
// until natural completion, wasting bytes and ignoring the user's intent.
private var activeDownloadTask: URLSessionDownloadTask?
// Any error thrown while rescuing the temp file in `didFinishDownloadingTo`.
// We can't throw from the delegate callback, so we stash it and re-raise from
// `didCompleteWithError`, which is the single funnel that resumes the continuation.
Expand All @@ -264,10 +323,25 @@ private final class ModelDownloadSessionDelegate: NSObject, URLSessionDownloadDe
configuration.requestCachePolicy = .reloadIgnoringLocalCacheData
let session = URLSession(configuration: configuration, delegate: self, delegateQueue: nil)

return try await withCheckedThrowingContinuation { continuation in
self.continuation = continuation
let task = session.downloadTask(with: url)
task.resume()
// withTaskCancellationHandler bridges Swift Task cancellation into the
// URLSession world. When `Task.cancel()` runs upstream (e.g., from
// ModelDownloadManager.cancel(filename:)), the onCancel block fires and
// aborts the URLSession download task. The delegate then receives
// didCompleteWithError(URLError.cancelled), which resumes the
// continuation throwing — and the catch in performDownload routes it
// through DownloadOutcomeClassifier as a user cancel rather than a
// hard failure.
return try await withTaskCancellationHandler {
try await withCheckedThrowingContinuation { continuation in
self.continuation = continuation
let task = session.downloadTask(with: url)
self.activeDownloadTask = task
task.resume()
}
} onCancel: { [weak self] in
// URLSessionDownloadTask.cancel() is thread-safe by Apple's docs,
// so calling it from arbitrary cancellation contexts is fine.
self?.activeDownloadTask?.cancel()
}
}

Expand Down
Loading
Loading