Skip to content

fix: preserve model cache when first load is cancelled#749

Open
ComicBit wants to merge 4 commits into
FluidInference:mainfrom
ComicBit:fix/preserve-cache-on-cancelled-load
Open

fix: preserve model cache when first load is cancelled#749
ComicBit wants to merge 4 commits into
FluidInference:mainfrom
ComicBit:fix/preserve-cache-on-cancelled-load

Conversation

@ComicBit

@ComicBit ComicBit commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Problem

DownloadUtils.loadModels treats any first-load failure as cache corruption: it deletes the whole repo cache folder and re-downloads. But cancellation is not corruption — if the load is cancelled (app teardown, user abort, Task cancellation propagating as CancellationError or NSURLErrorCancelled -999), the fallback wipes a perfectly valid, fully-downloaded model cache. We hit this in production: cancelling a Sortformer load during app stop deleted a 246 MB downloaded model, forcing a full re-download on next launch.

Fix

Add an isCancellationError classifier and check it before the delete-and-redownload fallback. Cancelled loads rethrow without touching the cache. Detected shapes:

  • Swift CancellationError
  • NSURLErrorDomain code NSURLErrorCancelled (-999)
  • NSCocoaErrorDomain code NSUserCancelledError
  • any of the above anywhere in the NSUnderlyingErrorKey chain (depth-capped)

Offline-mode behaviour (enforceOffline) is unchanged — it already skipped the wipe.

Tests

Tests/FluidAudioTests/Shared/DownloadUtilsCancellationTests.swift — 10 cases covering all detected cancellation shapes plus negatives (timeout, corrupt-file error, -999 in a non-URL domain, chain termination).

swift build + swift test --filter DownloadUtilsCancellationTests green on macOS.

🤖 Generated with Claude Code

DownloadUtils.loadModels treats any first-load failure as cache
corruption and deletes the repo folder before re-downloading. A
cancelled load (CancellationError, NSURLErrorCancelled -999, or
NSUserCancelledError, including in the underlying-error chain) is not
corruption: cancelling mid-load during app teardown wiped fully
downloaded multi-hundred-MB model repos.

Classify cancellation via isCancellationError and rethrow without
touching the cache. Offline mode behaviour is unchanged.
Copilot AI review requested due to automatic review settings July 2, 2026 14:50

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR prevents DownloadUtils.loadModels from treating cancellations as cache corruption, avoiding deletion of a valid on-disk model repository when the initial load is cancelled (e.g., app teardown / task cancellation).

Changes:

  • Add DownloadUtils.isCancellationError(_:) to classify common cancellation shapes (Swift CancellationError, NSURLErrorCancelled -999, NSUserCancelledError, plus underlying-error chains).
  • Gate the delete-and-redownload fallback in loadModels so cancellations rethrow without wiping the cache.
  • Add a focused XCTest suite covering positive/negative cancellation classification cases.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

File Description
Sources/FluidAudio/DownloadUtils.swift Adds cancellation classification and skips cache deletion on cancelled first loads.
Tests/FluidAudioTests/Shared/DownloadUtilsCancellationTests.swift Adds unit tests to validate cancellation detection behavior and non-cancellation negatives.

Comment thread Sources/FluidAudio/DownloadUtils.swift Outdated
Comment on lines +270 to +273
/// `true` when `error` represents cancellation rather than a corrupted
/// cache: Swift `CancellationError`, `NSURLErrorCancelled` (-999), or
/// `NSUserCancelledError` — including anywhere in the underlying-error
/// chain (`NSUnderlyingErrorKey`).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 7c4d206 — doc now states the 8-link depth cap and the rationale (cycle protection; real-world wrapping is 1–2 levels deep).

Comment on lines +71 to +79
func testUnderlyingChainCycleTerminates() {
// Self-referential underlying chain must not loop forever; the
// walker is depth-capped.
let inner = NSError(domain: "com.example.a", code: 1)
let outer = NSError(
domain: "com.example.b", code: 2,
userInfo: [NSUnderlyingErrorKey: inner])
XCTAssertFalse(DownloadUtils.isCancellationError(outer))
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 7c4d206 — renamed to testNonCancellationUnderlyingChainNotClassified (it never built a cycle), and added testDepthCapTerminatesLongChain which actually exercises the cap on a 12-link chain.

- isCancellationError doc now states the 8-link depth cap and why
  (cycle protection; real wrapping is 1-2 levels).
- Rename testUnderlyingChainCycleTerminates to
  testNonCancellationUnderlyingChainNotClassified (it never built a
  cycle) and add testDepthCapTerminatesLongChain covering the cap on a
  12-link chain.
Comment thread Sources/FluidAudio/DownloadUtils.swift Outdated
}
}

/// `true` when `error` represents cancellation rather than a corrupted

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the comment is abit too verbose , if users or agents want more context they will use commit history as context reference.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Trimmed in 243a591 — doc comment is now three lines; the depth-cap rationale moved onto the constant itself.

Comment thread Sources/FluidAudio/DownloadUtils.swift Outdated

var current: NSError? = error as NSError
var depth = 0
while let nsError = current, depth < 8 {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why specifically 8 , also ideally this should be a constant somewhere

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 243a591 — extracted maxUnderlyingErrorDepth = 8 with a one-line rationale (cycle guard; real-world wrapping is 1–2 levels deep; 8 leaves generous headroom).

Comment thread Sources/FluidAudio/DownloadUtils.swift Outdated
/// Used by ``loadModels(_:modelNames:directory:computeUnits:variant:progressHandler:)``
/// to skip the delete-cache-and-redownload fallback for cancelled loads.
/// Guards the `NSUnderlyingErrorKey` walk against pathological or cyclic
/// error chains; real-world wrapping is 1–2 levels deep.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so why do we need 8 then ? why not just 2

@ComicBit ComicBit Jul 4, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The depth limit was pretty arbitrary. In reality, NSError chains are usually only 1–2 levels deep and a plain NSError can't even form a cycle because userInfo is immutable

…tionError

The NSUnderlyingErrorKey walk was depth-capped at an arbitrary 8 links.
Neither 8 nor any other constant is principled: real chains are 1-2 deep,
and a plain NSError cannot even form a cycle (userInfo is fixed at init).

Track visited errors by identity instead. This removes the magic number,
terminates on any genuine cycle, and walks legit-but-deep acyclic chains
to the end -- so a cancellation buried at any depth is still detected
(the safe direction: never wipe a good cache).

Tests: real self-referential cycle via NSError subclass (plain NSError
can't cycle); deep-buried-cancellation now asserts detected.
ComicBit added a commit to ComicBit/FluidAudio that referenced this pull request Jul 4, 2026
…tionError

Mirror of PR FluidInference#749 fix (fork commit e44eff7) onto the integration branch.
The NSUnderlyingErrorKey walk was depth-capped at an arbitrary 8 links;
neither 8 nor any constant is principled (real chains are 1-2 deep, and a
plain NSError can't even form a cycle). Track visited errors by identity
instead: no magic number, terminates on any genuine cycle, and a
cancellation buried at any depth is still detected (safe direction).
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