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.
This commit is contained in:
Arkadiusz Fal
2026-10-19 22:31:52 +02:00
parent 4c7ccb83a6
commit 433e57e68d
3 changed files with 80 additions and 15 deletions

View File

@@ -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")")

View File

@@ -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

View File

@@ -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<Void, Never>] = [:]
/// 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.