diff --git a/Yattee/Services/Downloads/DownloadManager+Execution.swift b/Yattee/Services/Downloads/DownloadManager+Execution.swift index d908b2f7..e312809e 100644 --- a/Yattee/Services/Downloads/DownloadManager+Execution.swift +++ b/Yattee/Services/Downloads/DownloadManager+Execution.swift @@ -91,6 +91,33 @@ extension DownloadManager { } } + /// Creates a download task, catching CFNetwork's Objective-C exceptions. If the + /// session turns out to be invalidated, builds a fresh one and tries once more, + /// so a dead session doesn't fail every download until relaunch (#975). + private func makeDownloadTask( + _ make: (URLSession) -> URLSessionDownloadTask + ) -> (URLSessionDownloadTask?, NSException?) { + func attempt() -> (URLSessionDownloadTask?, NSException?) { + var caughtException: NSException? + var task: URLSessionDownloadTask? + let success = tryCatchObjCException({ + task = make(self.urlSession) + }, &caughtException) + return (success ? task : nil, caughtException) + } + + let first = attempt() + guard first.0 == nil, + first.1?.reason?.localizedCaseInsensitiveContains("invalidated") == true else { + return first + } + + LoggingService.shared.logDownload("[Downloads] Session was invalidated, recreating before retry") + urlSession = nil + setupSession() + return attempt() + } + /// Start a download task for a specific stream phase func startStreamDownload( downloadID: UUID, @@ -115,13 +142,9 @@ extension DownloadManager { let task: URLSessionDownloadTask if let resumeData { - var caughtException: NSException? - var resumeTask: URLSessionDownloadTask? - let success = tryCatchObjCException({ - resumeTask = self.urlSession.downloadTask(withResumeData: resumeData) - }, &caughtException) + let (resumeTask, caughtException) = makeDownloadTask { $0.downloadTask(withResumeData: resumeData) } - guard success, let resumeTask else { + guard let resumeTask else { LoggingService.shared.logDownloadError( "[Downloads] NSException creating resume task (\(phase))", error: DownloadError.downloadFailed("CFNetwork exception: \(caughtException?.reason ?? "unknown")") @@ -160,13 +183,9 @@ extension DownloadManager { } } - var caughtException: NSException? - var newTask: URLSessionDownloadTask? - let success = tryCatchObjCException({ - newTask = self.urlSession.downloadTask(with: request) - }, &caughtException) + let (newTask, caughtException) = makeDownloadTask { $0.downloadTask(with: request) } - guard success, let newTask else { + guard let newTask else { LoggingService.shared.logDownloadError( "[Downloads] NSException creating download task (\(phase))", error: DownloadError.downloadFailed("CFNetwork exception: \(caughtException?.reason ?? "unknown")") diff --git a/Yattee/Services/Downloads/DownloadManager+URLSession.swift b/Yattee/Services/Downloads/DownloadManager+URLSession.swift index 81e40c8c..93dde781 100644 --- a/Yattee/Services/Downloads/DownloadManager+URLSession.swift +++ b/Yattee/Services/Downloads/DownloadManager+URLSession.swift @@ -206,6 +206,12 @@ extension DownloadManager: URLSessionDownloadDelegate { } } + nonisolated func urlSession(_ session: URLSession, didBecomeInvalidWithError error: Error?) { + Task { @MainActor in + self.handleSessionInvalidated(session, error: error) + } + } + nonisolated func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) { // Called when all background tasks are complete // Could be used to update UI or show notification diff --git a/Yattee/Services/Downloads/DownloadManager.swift b/Yattee/Services/Downloads/DownloadManager.swift index b7278933..f9875dc2 100644 --- a/Yattee/Services/Downloads/DownloadManager.swift +++ b/Yattee/Services/Downloads/DownloadManager.swift @@ -63,6 +63,10 @@ final class DownloadManager: NSObject { let minimumValidFileSize: Int64 = 1024 // 1 KB var urlSession: URLSession! + /// Callers waiting for a replaced background session to finish invalidating, + /// keyed by session. A new session with the same identifier must not be + /// created while the old one is still alive. + var sessionInvalidationWaiters: [ObjectIdentifier: CheckedContinuation] = [:] /// Tracks video download tasks by download ID var videoTasks: [UUID: URLSessionDownloadTask] = [:] /// Tracks audio download tasks by download ID @@ -110,7 +114,7 @@ final class DownloadManager: NSObject { // to ensure we have the correct cellular access setting before creating the session. } - private func setupSession() { + func setupSession() { let config = URLSessionConfiguration.background( withIdentifier: AppIdentifiers.downloadSession ) @@ -193,8 +197,14 @@ final class DownloadManager: NSObject { } // 3. Invalidate the old session (use finishTasksAndInvalidate since downloads are already paused) - urlSession?.finishTasksAndInvalidate() - urlSession = nil + // and wait for it to go away: a background session created with the same identifier + // while the old one is still invalidating hands out tasks that throw "Task created in + // a session that has been invalidated" (#975). + if let oldSession = urlSession { + urlSession = nil + oldSession.finishTasksAndInvalidate() + await waitForInvalidation(of: oldSession) + } // 4. Create new session with updated cellular config setupSession() @@ -218,6 +228,36 @@ final class DownloadManager: NSObject { } #endif + /// Suspends until the delegate reports `session` invalidated, or a timeout passes. + private func waitForInvalidation(of session: URLSession, timeout: TimeInterval = 5) async { + let key = ObjectIdentifier(session) + await withCheckedContinuation { continuation in + sessionInvalidationWaiters[key] = continuation + Task { @MainActor [weak self] in + try? await Task.sleep(for: .seconds(timeout)) + guard let waiter = self?.sessionInvalidationWaiters.removeValue(forKey: key) else { return } + LoggingService.shared.logDownload("[Downloads] Session invalidation timed out, continuing") + waiter.resume() + } + } + } + + /// Called from the URLSession delegate when a session becomes invalid. + func handleSessionInvalidated(_ session: URLSession, error: Error?) { + LoggingService.shared.logDownload( + "[Downloads] Session invalidated", + details: "current: \(session === urlSession), error: \(error?.localizedDescription ?? "none")" + ) + sessionInvalidationWaiters.removeValue(forKey: ObjectIdentifier(session))?.resume() + + // The live session died without us replacing it; build a fresh one so + // new downloads don't keep failing until the app is relaunched. + if session === urlSession { + urlSession = nil + setupSession() + } + } + // MARK: - Auto Download /// Automatically enqueue a video for download using the preferred quality setting.