From 433e57e68dfe76f946249b2c810046573121ca9d Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Mon, 19 Oct 2026 22:31:52 +0200 Subject: [PATCH] Recover downloads from an invalidated URLSession (#975) Toggling cellular downloads recreated the background session with the same identifier before the old one finished invalidating, and nothing ever replaced a dead session, so every download threw "Task created in a session that has been invalidated" until relaunch. Wait for the old session's invalidation before creating the new one, rebuild the session when the delegate reports the live one invalid, and recreate it once when task creation hits the invalidated exception. --- .../Downloads/DownloadManager+Execution.swift | 43 ++++++++++++----- .../DownloadManager+URLSession.swift | 6 +++ .../Services/Downloads/DownloadManager.swift | 46 +++++++++++++++++-- 3 files changed, 80 insertions(+), 15 deletions(-) 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.