From 0919932c64fed4be540d370f8de2232def4ca299 Mon Sep 17 00:00:00 2001 From: Matthieu Veinhard Date: Sun, 12 Jul 2026 13:31:24 +0200 Subject: [PATCH 1/2] fix: make URLSession async compat shims cancellation-aware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The data(from:)/data(for:) continuation wrappers ignored Swift task cancellation entirely — cancelling a Task running an extraction let the underlying dataTask download to completion anyway. Wrap both in withTaskCancellationHandler and cancel the URLSessionTask on task cancellation (guarding the register/cancel race with a locked box), so cancelled extractions abort their network work immediately with URLError(.cancelled). --- .../Extensions/AsyncCompatibility.swift | 82 ++++++++++++++----- 1 file changed, 62 insertions(+), 20 deletions(-) diff --git a/Sources/YouTubeKit/Extensions/AsyncCompatibility.swift b/Sources/YouTubeKit/Extensions/AsyncCompatibility.swift index b152f15..e9b74e9 100644 --- a/Sources/YouTubeKit/Extensions/AsyncCompatibility.swift +++ b/Sources/YouTubeKit/Extensions/AsyncCompatibility.swift @@ -7,36 +7,78 @@ import Foundation -/// Backward compatibility of iOS 15 URLSession async function for older versions +/// Thread-safe holder tying an in-flight `URLSessionTask` to Swift task cancellation. +/// If the surrounding Swift task is cancelled before the URLSessionTask is registered, +/// the URLSessionTask is cancelled immediately upon registration. +private final class URLSessionTaskCancellationBox: @unchecked Sendable { + private let lock = NSLock() + private var task: URLSessionTask? + private var isCancelled = false + + func register(_ task: URLSessionTask) { + lock.lock() + self.task = task + let cancelled = isCancelled + lock.unlock() + if cancelled { + task.cancel() + } + } + + func cancel() { + lock.lock() + isCancelled = true + let task = self.task + lock.unlock() + task?.cancel() + } +} + +/// Backward compatibility of iOS 15 URLSession async function for older versions. +/// Cancellation-aware: cancelling the surrounding Swift task cancels the underlying +/// `URLSessionTask` (the request fails with `URLError.cancelled`) instead of letting +/// the download run to completion. @available(iOS 13.0, watchOS 6.0, tvOS 13.0, macOS 10.15, *) extension URLSession { - + func data(from url: URL) async throws -> (Data, URLResponse) { - try await withCheckedThrowingContinuation { continuation in - dataTask(with: url) { data, response, error in - guard let data = data, let response = response else { - let error = error ?? URLError(.unknown) - return continuation.resume(throwing: error) + let box = URLSessionTaskCancellationBox() + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + let task = dataTask(with: url) { data, response, error in + guard let data = data, let response = response else { + let error = error ?? URLError(.unknown) + return continuation.resume(throwing: error) + } + + continuation.resume(returning: (data, response)) } - - continuation.resume(returning: (data, response)) + box.register(task) + task.resume() } - .resume() + } onCancel: { + box.cancel() } } - + func data(for request: URLRequest) async throws -> (Data, URLResponse) { - try await withCheckedThrowingContinuation { continuation in - dataTask(with: request) { data, response, error in - guard let data = data, let response = response else { - let error = error ?? URLError(.unknown) - return continuation.resume(throwing: error) - } + let box = URLSessionTaskCancellationBox() + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + let task = dataTask(with: request) { data, response, error in + guard let data = data, let response = response else { + let error = error ?? URLError(.unknown) + return continuation.resume(throwing: error) + } - continuation.resume(returning: (data, response)) + continuation.resume(returning: (data, response)) + } + box.register(task) + task.resume() } - .resume() + } onCancel: { + box.cancel() } } - + } From 2ddeb81dd19c1032816050dc63857ad183691a29 Mon Sep 17 00:00:00 2001 From: Matthieu Veinhard Date: Tue, 14 Jul 2026 11:26:48 +0200 Subject: [PATCH 2/2] Extract shared cancellable data-task helper (DRY) --- .../Extensions/AsyncCompatibility.swift | 28 +++++++------------ 1 file changed, 10 insertions(+), 18 deletions(-) diff --git a/Sources/YouTubeKit/Extensions/AsyncCompatibility.swift b/Sources/YouTubeKit/Extensions/AsyncCompatibility.swift index e9b74e9..8bfbb32 100644 --- a/Sources/YouTubeKit/Extensions/AsyncCompatibility.swift +++ b/Sources/YouTubeKit/Extensions/AsyncCompatibility.swift @@ -42,30 +42,22 @@ private final class URLSessionTaskCancellationBox: @unchecked Sendable { extension URLSession { func data(from url: URL) async throws -> (Data, URLResponse) { - let box = URLSessionTaskCancellationBox() - return try await withTaskCancellationHandler { - try await withCheckedThrowingContinuation { continuation in - let task = dataTask(with: url) { data, response, error in - guard let data = data, let response = response else { - let error = error ?? URLError(.unknown) - return continuation.resume(throwing: error) - } - - continuation.resume(returning: (data, response)) - } - box.register(task) - task.resume() - } - } onCancel: { - box.cancel() - } + try await performCancellableDataTask { dataTask(with: url, completionHandler: $0) } } func data(for request: URLRequest) async throws -> (Data, URLResponse) { + try await performCancellableDataTask { dataTask(with: request, completionHandler: $0) } + } + + /// Shared body for the async shims: bridge a completion-handler `dataTask` to + /// async, cancelling the underlying `URLSessionTask` when the Swift task is cancelled. + private func performCancellableDataTask( + _ makeTask: (@escaping (Data?, URLResponse?, Error?) -> Void) -> URLSessionDataTask + ) async throws -> (Data, URLResponse) { let box = URLSessionTaskCancellationBox() return try await withTaskCancellationHandler { try await withCheckedThrowingContinuation { continuation in - let task = dataTask(with: request) { data, response, error in + let task = makeTask { data, response, error in guard let data = data, let response = response else { let error = error ?? URLError(.unknown) return continuation.resume(throwing: error)