From 2dfd41e792dabd6621fc56dbf214182370671f48 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Wed, 26 Nov 2025 22:16:01 +1300 Subject: [PATCH 1/3] Add `URLSessionTaskDelegate` parameter to WordPressAPI --- Package.resolved | 2 +- Package.swift | 2 +- .../wordpress-api/SafeRequestExecutor.swift | 243 ++++++++++++++++-- .../Sources/wordpress-api/WordPressAPI.swift | 12 +- .../Tests/integration-tests/Helpers.swift | 3 +- .../integration-tests/TaskDelegateTests.swift | 62 +++++ 6 files changed, 295 insertions(+), 29 deletions(-) create mode 100644 native/swift/Tests/integration-tests/TaskDelegateTests.swift diff --git a/Package.resolved b/Package.resolved index bda6bd37c..6a09abfcf 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "6791ea51c1f6a770231d8e16e3b9310037ce94ea7a924534fab9039ed08061cd", + "originHash" : "283d6745467d75f5921b090c61c117a36db0062bf3ddc329c97354553842b877", "pins" : [ { "identity" : "collectionconcurrencykit", diff --git a/Package.swift b/Package.swift index 21e87dd66..b6c42910b 100644 --- a/Package.swift +++ b/Package.swift @@ -18,7 +18,7 @@ var package = Package( name: "WordPressAPI", platforms: [ .iOS(.v16), - .macOS(.v12), + .macOS(.v13), .tvOS(.v16), .watchOS(.v9) ], diff --git a/native/swift/Sources/wordpress-api/SafeRequestExecutor.swift b/native/swift/Sources/wordpress-api/SafeRequestExecutor.swift index df2a0715d..50f762c0e 100644 --- a/native/swift/Sources/wordpress-api/SafeRequestExecutor.swift +++ b/native/swift/Sources/wordpress-api/SafeRequestExecutor.swift @@ -43,10 +43,11 @@ public final class WpRequestExecutor: SafeRequestExecutor { public init( urlSession: URLSession, additionalHttpHeadersForAllRequests: [String: String] = [:], - userAgent: String = defaultUserAgent(clientSpecificPostfix: UserAgent.postfix) + userAgent: String = defaultUserAgent(clientSpecificPostfix: UserAgent.postfix), + notifyingDelegate: URLSessionTaskDelegate? = nil ) { self.session = urlSession - self.executorDelegate = RequestExecutorDelegate() + self.executorDelegate = RequestExecutorDelegate(delegate: notifyingDelegate) var headers = additionalHttpHeadersForAllRequests if !headers.contains(where: { $0.key.caseInsensitiveCompare("User-Agent") == .orderedSame }) { @@ -266,14 +267,17 @@ public final class WpRequestExecutor: SafeRequestExecutor { } } -private final class RequestExecutorDelegate: NSObject, URLSessionTaskDelegate, @unchecked Sendable { +private final class RequestExecutorDelegate: + NSObject, URLSessionTaskDelegate, URLSessionDataDelegate, @unchecked Sendable { static let didCreateTaskNotification = Notification.Name("RequestExecutorDelegate.didCreateTaskNotification") private let lock = NSLock() private var redirects: [String: [WpRedirect]] = [:] + let delegate: URLSessionTaskDelegate? - init(redirects: [String: [WpRedirect]] = [:]) { + init(delegate: URLSessionTaskDelegate?, redirects: [String: [WpRedirect]] = [:]) { + self.delegate = delegate self.redirects = redirects } @@ -283,9 +287,13 @@ private final class RequestExecutorDelegate: NSObject, URLSessionTaskDelegate, @ } } + #if !os(Linux) func urlSession(_ session: URLSession, didCreateTask task: URLSessionTask) { NotificationCenter.default.post(name: RequestExecutorDelegate.didCreateTaskNotification, object: task) + + delegate?.urlSession?(session, didCreateTask: task) } + #endif func urlSession( _ session: URLSession, @@ -315,6 +323,95 @@ private final class RequestExecutorDelegate: NSObject, URLSessionTaskDelegate, @ return request } + + func urlSession(_ session: URLSession, taskIsWaitingForConnectivity task: URLSessionTask) { + #if !os(Linux) + delegate?.urlSession?(session, taskIsWaitingForConnectivity: task) + #endif + } + + func urlSession( + _ session: URLSession, + task: URLSessionTask, + didSendBodyData bytesSent: Int64, + totalBytesSent: Int64, + totalBytesExpectedToSend: Int64 + ) { + #if os(Linux) + delegate?.urlSession( + session, + task: task, + didSendBodyData: bytesSent, + totalBytesSent: totalBytesSent, + totalBytesExpectedToSend: totalBytesExpectedToSend + ) + #else + delegate?.urlSession?( + session, + task: task, + didSendBodyData: bytesSent, + totalBytesSent: totalBytesSent, + totalBytesExpectedToSend: totalBytesExpectedToSend + ) + #endif + } + + func urlSession( + _ session: URLSession, + task: URLSessionTask, + didReceiveInformationalResponse response: HTTPURLResponse + ) { + #if os(macOS) + if #available(macOS 14.0, *) { + delegate?.urlSession?(session, task: task, didReceiveInformationalResponse: response) + } + #elseif os(iOS) + if #available(iOS 17.0, *) { + delegate?.urlSession?(session, task: task, didReceiveInformationalResponse: response) + } + #endif + } + + func urlSession(_ session: URLSession, task: URLSessionTask, didFinishCollecting metrics: URLSessionTaskMetrics) { + #if os(Linux) + delegate?.urlSession(session, task: task, didFinishCollecting: metrics) + #else + delegate?.urlSession?(session, task: task, didFinishCollecting: metrics) + #endif + } + + func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: (any Error)?) { + #if os(Linux) + delegate?.urlSession(session, task: task, didCompleteWithError: error) + #else + delegate?.urlSession?(session, task: task, didCompleteWithError: error) + #endif + } + + func urlSession( + _ session: URLSession, + dataTask: URLSessionDataTask, + didReceive response: URLResponse, + completionHandler: @escaping @Sendable (URLSession.ResponseDisposition) -> Void + ) { + #if os(Linux) + (delegate as? URLSessionDataDelegate)?.urlSession( + session, dataTask: dataTask, didReceive: response, completionHandler: { _ in }) + #else + (delegate as? URLSessionDataDelegate)?.urlSession?( + session, dataTask: dataTask, didReceive: response, completionHandler: { _ in }) + #endif + + completionHandler(.allow) + } + + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { + #if os(Linux) + (delegate as? URLSessionDataDelegate)?.urlSession(session, dataTask: dataTask, didReceive: data) + #else + (delegate as? URLSessionDataDelegate)?.urlSession?(session, dataTask: dataTask, didReceive: data) + #endif + } } private let requestIdHeaderName = "X-REQUEST-ID" @@ -368,11 +465,28 @@ extension WpNetworkRequest: NetworkRequestContent { delegate: URLSessionTaskDelegate? ) async throws -> (Data, URLResponse) { let request = try buildURLRequest(additionalHeaders: headers) - #if os(Linux) - return try await session.data(for: request) - #else - return try await session.data(for: request, delegate: delegate) - #endif + + let cancellation = TaskCancellation() + return try await withTaskCancellationHandler { + let result: Result<(Data, URLResponse), Error> = await withCheckedContinuation { continuation in + let task = session.dataTask(with: request, completionHandler: completionHandler(continuation)) + cancellation.task = task + task.delegate = delegate + task.resume() + + #if !os(Linux) + delegate?.urlSession?(session, didCreateTask: task) + #endif + } + + if let task = cancellation.task { + notifyTaskResult(delegate: delegate, session: session, task: task, result: result) + } + + return try result.get() + } onCancel: { + cancellation.cancel() + } } } @@ -423,22 +537,107 @@ extension WpMultipartFormRequest: NetworkRequestContent { let boundery = String(format: "wordpressrs.%08x", Int.random(in: Int.min.. (Data, URLResponse) { + let cancellation = TaskCancellation() + return try await withTaskCancellationHandler { + let result: Result<(Data, URLResponse), Error> = await withCheckedContinuation { continuation in + let completion = completionHandler(continuation) + let task = switch body { + case let .inMemory(data): + session.uploadTask(with: request, from: data, completionHandler: completion) + case let .onDisk(file): + session.uploadTask(with: request, fromFile: file, completionHandler: completion) + } + cancellation.task = task + task.delegate = delegate + task.resume() + + #if !os(Linux) + delegate?.urlSession?(session, didCreateTask: task) + #endif + } + + if let task = cancellation.task { + notifyTaskResult(delegate: delegate, session: session, task: task, result: result) + } + + return try result.get() + } onCancel: { + cancellation.cancel() } - #else - switch body { - case let .inMemory(data): - return try await session.upload(for: request, from: data, delegate: delegate) - case let .onDisk(file): - return try await session.upload(for: request, fromFile: file, delegate: delegate) + } +} + +private class TaskCancellation: @unchecked Sendable { + private let lock = NSLock() + private var _task: URLSessionTask? + + var task: URLSessionTask? { + get { + lock.withLock { _task } + } + set { + lock.withLock { _task = newValue } } - #endif } + func cancel() { + lock.withLock { + _task?.cancel() + _task = nil + } + } +} + +private func completionHandler( + _ continuation: CheckedContinuation, Never> +) -> @Sendable (Data?, URLResponse?, Error?) -> Void { + { (data, response, error) in + if let error { + continuation.resume(returning: .failure(error)) + } else { + // It's okay to force-unwrap here. + // swiftlint:disable:next line_length + // https://github.com/swiftlang/swift-corelibs-foundation/blob/swift-6.2.1-RELEASE/Sources/FoundationNetworking/URLSession/URLSession.swift#L743 + continuation.resume(returning: .success((data!, response!))) + } + } +} + +private func notifyTaskResult( + delegate: URLSessionTaskDelegate?, + session: URLSession, + task: URLSessionTask, + result: Result<(Data, URLResponse), any Error> +) { + if let task = task as? URLSessionDataTask, let delegate = delegate as? URLSessionDataDelegate { + if case let .success((data, response)) = result { + #if os(Linux) + delegate.urlSession(session, dataTask: task, didReceive: response, completionHandler: { _ in }) + delegate.urlSession(session, dataTask: task, didReceive: data) + #else + delegate.urlSession?(session, dataTask: task, didReceive: response, completionHandler: { _ in }) + delegate.urlSession?(session, dataTask: task, didReceive: data) + #endif + } + } + + let error: Error? = if case let .failure(error) = result { + error + } else { + nil + } + #if os(Linux) + delegate?.urlSession(session, task: task, didCompleteWithError: error) + #else + delegate?.urlSession?(session, task: task, didCompleteWithError: error) + #endif } diff --git a/native/swift/Sources/wordpress-api/WordPressAPI.swift b/native/swift/Sources/wordpress-api/WordPressAPI.swift index 4a5efd3f0..63f1214af 100644 --- a/native/swift/Sources/wordpress-api/WordPressAPI.swift +++ b/native/swift/Sources/wordpress-api/WordPressAPI.swift @@ -22,6 +22,7 @@ public actor WordPressAPI { public init( urlSession: URLSession, + notifyingDelegate: URLSessionTaskDelegate? = nil, apiRootUrl: ParsedUrl, authentication: WpAuthentication, middlewarePipeline: MiddlewarePipeline = .default, @@ -30,7 +31,7 @@ public actor WordPressAPI { self.init( apiUrlResolver: WpOrgSiteApiUrlResolver(apiRootUrl: apiRootUrl), authenticationProvider: .staticWithAuth(auth: authentication), - executor: WpRequestExecutor(urlSession: urlSession), + executor: WpRequestExecutor(urlSession: urlSession, notifyingDelegate: notifyingDelegate), middlewarePipeline: middlewarePipeline, appNotifier: appNotifier ) @@ -54,6 +55,7 @@ public actor WordPressAPI { public init( urlSession: URLSession, + notifyingDelegate: URLSessionTaskDelegate? = nil, apiUrlResolver: ApiUrlResolver, authenticationProvider: WpAuthenticationProvider, middlewarePipeline: MiddlewarePipeline = .default, @@ -62,7 +64,7 @@ public actor WordPressAPI { self.init( apiUrlResolver: apiUrlResolver, authenticationProvider: authenticationProvider, - executor: WpRequestExecutor(urlSession: urlSession), + executor: WpRequestExecutor(urlSession: urlSession, notifyingDelegate: notifyingDelegate), middlewarePipeline: middlewarePipeline, appNotifier: appNotifier ) @@ -70,6 +72,7 @@ public actor WordPressAPI { public init( urlSession: URLSession, + notifyingDelegate: URLSessionTaskDelegate? = nil, siteUrl: String, apiRootUrl: ParsedUrl, username: String, @@ -77,7 +80,7 @@ public actor WordPressAPI { middlewarePipeline: MiddlewarePipeline = .default, appNotifier: WpAppNotifier? = nil ) { - let executor = WpRequestExecutor(urlSession: urlSession) + let executor = WpRequestExecutor(urlSession: urlSession, notifyingDelegate: notifyingDelegate) let provider = CookiesNonceAuthenticationProvider.withSiteUrl( url: siteUrl, username: username, @@ -95,13 +98,14 @@ public actor WordPressAPI { public init( urlSession: URLSession, + notifyingDelegate: URLSessionTaskDelegate? = nil, details: AutoDiscoveryAttemptSuccess, username: String, password: String, middlewarePipeline: MiddlewarePipeline = .default, appNotifier: WpAppNotifier? = nil ) { - let executor = WpRequestExecutor(urlSession: urlSession) + let executor = WpRequestExecutor(urlSession: urlSession, notifyingDelegate: notifyingDelegate) let provider = CookiesNonceAuthenticationProvider( username: username, password: password, diff --git a/native/swift/Tests/integration-tests/Helpers.swift b/native/swift/Tests/integration-tests/Helpers.swift index 103d93d36..7dbdcb0ac 100644 --- a/native/swift/Tests/integration-tests/Helpers.swift +++ b/native/swift/Tests/integration-tests/Helpers.swift @@ -22,10 +22,11 @@ extension TestCredentials { } extension WordPressAPI { - static func admin() -> WordPressAPI { + static func admin(notifyingDelegate: URLSessionTaskDelegate? = nil) -> WordPressAPI { let credentials = TestCredentials.instance() return WordPressAPI( urlSession: .init(configuration: .ephemeral), + notifyingDelegate: notifyingDelegate, apiRootUrl: credentials.apiRootURL, authentication: credentials.adminAuthentication ) diff --git a/native/swift/Tests/integration-tests/TaskDelegateTests.swift b/native/swift/Tests/integration-tests/TaskDelegateTests.swift new file mode 100644 index 000000000..3f63a7e4b --- /dev/null +++ b/native/swift/Tests/integration-tests/TaskDelegateTests.swift @@ -0,0 +1,62 @@ +import Foundation +import Testing + +@testable import WordPressAPI +@testable import WordPressAPIInternal + +@Suite(.serialized) +struct TaskDelegateTests { + + @Test + func success() async throws { + let delegate = Delegate() + let api = WordPressAPI.admin(notifyingDelegate: delegate) + _ = try await api.users.retrieveMeWithEditContext() + + let invocations = delegate.invocations + #expect(invocations.contains("urlSession(_:dataTask:didReceive:completionHandler:)")) + #expect(invocations.contains("urlSession(_:dataTask:didReceive:)")) + #expect(invocations.contains("urlSession(_:task:didCompleteWithError:)")) + #expect(invocations.contains("urlSession(_:task:didFinishCollecting:)")) + } + +} + +private final class Delegate: NSObject, URLSessionTaskDelegate, URLSessionDataDelegate, @unchecked Sendable { + private let lock = NSLock() + private var _invocations: [String] = [] + var invocations: [String] { + lock.withLock { + _invocations + } + } + + func urlSession( + _ session: URLSession, + dataTask: URLSessionDataTask, + didReceive response: URLResponse, + completionHandler: @escaping (URLSession.ResponseDisposition) -> Void + ) { + lock.withLock { + _invocations.append(#function) + } + } + + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { + lock.withLock { + _invocations.append(#function) + } + } + + func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: (any Error)?) { + lock.withLock { + _invocations.append(#function) + } + } + + func urlSession(_ session: URLSession, task: URLSessionTask, didFinishCollecting metrics: URLSessionTaskMetrics) { + lock.withLock { + _invocations.append(#function) + } + } +} From ec36414ad2d6bbdf936f38494de68cad4a18adf1 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Wed, 26 Nov 2025 23:01:12 +1300 Subject: [PATCH 2/3] Fix compilation issues on Linux --- .../swift/Tests/integration-tests/TaskDelegateTests.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/native/swift/Tests/integration-tests/TaskDelegateTests.swift b/native/swift/Tests/integration-tests/TaskDelegateTests.swift index 3f63a7e4b..c78a6a905 100644 --- a/native/swift/Tests/integration-tests/TaskDelegateTests.swift +++ b/native/swift/Tests/integration-tests/TaskDelegateTests.swift @@ -1,6 +1,10 @@ import Foundation import Testing +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + @testable import WordPressAPI @testable import WordPressAPIInternal @@ -17,7 +21,10 @@ struct TaskDelegateTests { #expect(invocations.contains("urlSession(_:dataTask:didReceive:completionHandler:)")) #expect(invocations.contains("urlSession(_:dataTask:didReceive:)")) #expect(invocations.contains("urlSession(_:task:didCompleteWithError:)")) + + #if !os(Linux) #expect(invocations.contains("urlSession(_:task:didFinishCollecting:)")) + #endif } } From 1d6a163b7e58c13ec4d694ade536fbe16572a5de Mon Sep 17 00:00:00 2001 From: Tony Li Date: Thu, 27 Nov 2025 13:34:27 +1300 Subject: [PATCH 3/3] Do not set URLSessionTask.delegate on Linux --- Makefile | 2 +- .../Sources/wordpress-api/SafeRequestExecutor.swift | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 9e34e5014..1a3287f19 100644 --- a/Makefile +++ b/Makefile @@ -169,7 +169,7 @@ test-swift: $(MAKE) test-swift-$(uname) test-swift-linux: - docker exec -w /app -i wordpress make test-swift-linux-in-docker + docker exec -w /app -it wordpress make test-swift-linux-in-docker test-swift-linux-in-docker: swift-linux-library swift test -Xlinker -Ltarget/release/libwordpressFFI-linux -Xlinker -lwp_mobile --no-parallel diff --git a/native/swift/Sources/wordpress-api/SafeRequestExecutor.swift b/native/swift/Sources/wordpress-api/SafeRequestExecutor.swift index 50f762c0e..b370b0044 100644 --- a/native/swift/Sources/wordpress-api/SafeRequestExecutor.swift +++ b/native/swift/Sources/wordpress-api/SafeRequestExecutor.swift @@ -471,7 +471,12 @@ extension WpNetworkRequest: NetworkRequestContent { let result: Result<(Data, URLResponse), Error> = await withCheckedContinuation { continuation in let task = session.dataTask(with: request, completionHandler: completionHandler(continuation)) cancellation.task = task + + // See https://github.com/Automattic/wordpress-rs/pull/1046 + #if !os(Linux) task.delegate = delegate + #endif + task.resume() #if !os(Linux) @@ -557,7 +562,12 @@ extension WpMultipartFormRequest: NetworkRequestContent { session.uploadTask(with: request, fromFile: file, completionHandler: completion) } cancellation.task = task + + // See https://github.com/Automattic/wordpress-rs/pull/1046 + #if !os(Linux) task.delegate = delegate + #endif + task.resume() #if !os(Linux)