From d88c4071088a0940210f30428a812056ac237956 Mon Sep 17 00:00:00 2001 From: Vishal Kale Date: Mon, 31 Aug 2026 19:32:20 +0530 Subject: [PATCH 1/4] T 1532290: back refreshClaimCoordinationEnabled with a lock for cross-thread runtime toggling --- .../OAuth2RequestPipelineMiddleware.swift | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/Sources/Conduit/Auth/OAuth2RequestPipelineMiddleware.swift b/Sources/Conduit/Auth/OAuth2RequestPipelineMiddleware.swift index 1fe6445..4609b9d 100644 --- a/Sources/Conduit/Auth/OAuth2RequestPipelineMiddleware.swift +++ b/Sources/Conduit/Auth/OAuth2RequestPipelineMiddleware.swift @@ -23,8 +23,6 @@ public struct OAuth2RequestPipelineMiddleware: RequestPipelineMiddleware { /// against lengthy session locks when terminating mid-flight. This will allow the host process to quickly pick /// up where the other process left off, if it needs to. Defaults to 30 seconds. public var tokenRefreshLockRelinquishInterval: TimeInterval = 30 - /// Kill switch for atomic in-process refresh serialization; static because instances are per-client value copies. Defaults to true. - public static var refreshClaimCoordinationEnabled = true let clientConfiguration: OAuth2ClientConfiguration let authorization: OAuth2Authorization let tokenStorage: OAuth2TokenStore @@ -315,3 +313,23 @@ public struct OAuth2RequestPipelineMiddleware: RequestPipelineMiddleware { } } + +private let refreshClaimCoordinationFlagLock = NSLock() +private var storedRefreshClaimCoordinationEnabled = true + +extension OAuth2RequestPipelineMiddleware { + /// Kill switch for atomic in-process refresh serialization; static because instances are per-client value copies. Defaults to true. + /// Lock-backed: hosts may flip it at runtime (e.g. from a remote-flag callback) while request pipelines read it. + public static var refreshClaimCoordinationEnabled: Bool { + get { + refreshClaimCoordinationFlagLock.lock() + defer { refreshClaimCoordinationFlagLock.unlock() } + return storedRefreshClaimCoordinationEnabled + } + set { + refreshClaimCoordinationFlagLock.lock() + defer { refreshClaimCoordinationFlagLock.unlock() } + storedRefreshClaimCoordinationEnabled = newValue + } + } +} From df6f58d4f6f77770de26067c9572f9c77c036669 Mon Sep 17 00:00:00 2001 From: Vishal Kale Date: Mon, 31 Aug 2026 19:39:30 +0530 Subject: [PATCH 2/4] Stub testBlockingTimeout with a stalled URLProtocol to remove live-network flakiness --- .../Networking/URLSessionClientTests.swift | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/Tests/ConduitTests/Networking/URLSessionClientTests.swift b/Tests/ConduitTests/Networking/URLSessionClientTests.swift index 4699657..65efee3 100644 --- a/Tests/ConduitTests/Networking/URLSessionClientTests.swift +++ b/Tests/ConduitTests/Networking/URLSessionClientTests.swift @@ -29,12 +29,14 @@ class URLSessionClientTests: XCTestCase { XCTAssertThrowsError(try client.begin(request: request)) } - /// Verify sesson client throws error for timeout + /// Verify sesson client throws error for timeout; a stalled protocol makes the timeout + /// deterministic instead of racing a live server against the interval. func testBlockingTimeout() throws { - let configuration = URLSessionConfiguration.default + let configuration = URLSessionConfiguration.ephemeral configuration.timeoutIntervalForRequest = 0.5 + configuration.protocolClasses = [StalledURLProtocol.self] let client = URLSessionClient(sessionConfiguration: configuration, delegateQueue: OperationQueue()) - let request = try URLRequest(url: URL(absoluteString: "https://httpbingo.org/delay/1")) + let request = try URLRequest(url: URL(absoluteString: "https://stalled.invalid/delay")) XCTAssertThrowsError(try client.begin(request: request), "Request did not timeout") { error in XCTAssertEqual(error as? URLSessionClientError, .requestTimeout) } @@ -358,3 +360,11 @@ private class TransformingResponseMiddleware: ResponsePipelineMiddleware { } } + +/// Accepts every request and never completes it, so the blocking-begin semaphore timeout always wins. +private class StalledURLProtocol: URLProtocol { + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + override func startLoading() {} + override func stopLoading() {} +} From b90529b64a2b57ea35ea9fe94e193d41a7bf9e48 Mon Sep 17 00:00:00 2001 From: Vishal Kale Date: Mon, 31 Aug 2026 19:44:01 +0530 Subject: [PATCH 3/4] Normalize URLError.timedOut to requestTimeout in blocking begin so the two timeout timers cannot race --- Sources/Conduit/Networking/URLSessionClient.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Sources/Conduit/Networking/URLSessionClient.swift b/Sources/Conduit/Networking/URLSessionClient.swift index ee0736a..3d87be4 100644 --- a/Sources/Conduit/Networking/URLSessionClient.swift +++ b/Sources/Conduit/Networking/URLSessionClient.swift @@ -87,6 +87,10 @@ public struct URLSessionClient: URLSessionClientType { throw URLSessionClientError.requestTimeout } if let error = result.error { + // The session's own request timeout races the semaphore above; report both as requestTimeout. + if (error as? URLError)?.code == .timedOut { + throw URLSessionClientError.requestTimeout + } throw error } guard let response = result.response else { From 1bd159c99b97692863716c4f909b1699e021ee39 Mon Sep 17 00:00:00 2001 From: Vishal Kale Date: Mon, 31 Aug 2026 19:58:14 +0530 Subject: [PATCH 4/4] Tighten code comments --- Sources/Conduit/Auth/OAuth2RequestPipelineMiddleware.swift | 2 +- Tests/ConduitTests/Networking/URLSessionClientTests.swift | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/Sources/Conduit/Auth/OAuth2RequestPipelineMiddleware.swift b/Sources/Conduit/Auth/OAuth2RequestPipelineMiddleware.swift index 4609b9d..0ad38f6 100644 --- a/Sources/Conduit/Auth/OAuth2RequestPipelineMiddleware.swift +++ b/Sources/Conduit/Auth/OAuth2RequestPipelineMiddleware.swift @@ -319,7 +319,7 @@ private var storedRefreshClaimCoordinationEnabled = true extension OAuth2RequestPipelineMiddleware { /// Kill switch for atomic in-process refresh serialization; static because instances are per-client value copies. Defaults to true. - /// Lock-backed: hosts may flip it at runtime (e.g. from a remote-flag callback) while request pipelines read it. + /// Lock-backed so it can be toggled at runtime while request pipelines read it. public static var refreshClaimCoordinationEnabled: Bool { get { refreshClaimCoordinationFlagLock.lock() diff --git a/Tests/ConduitTests/Networking/URLSessionClientTests.swift b/Tests/ConduitTests/Networking/URLSessionClientTests.swift index 65efee3..0ab1491 100644 --- a/Tests/ConduitTests/Networking/URLSessionClientTests.swift +++ b/Tests/ConduitTests/Networking/URLSessionClientTests.swift @@ -29,8 +29,7 @@ class URLSessionClientTests: XCTestCase { XCTAssertThrowsError(try client.begin(request: request)) } - /// Verify sesson client throws error for timeout; a stalled protocol makes the timeout - /// deterministic instead of racing a live server against the interval. + /// Verify sesson client throws error for timeout func testBlockingTimeout() throws { let configuration = URLSessionConfiguration.ephemeral configuration.timeoutIntervalForRequest = 0.5 @@ -361,7 +360,7 @@ private class TransformingResponseMiddleware: ResponsePipelineMiddleware { } -/// Accepts every request and never completes it, so the blocking-begin semaphore timeout always wins. +/// Never completes a request, forcing the timeout path deterministically. private class StalledURLProtocol: URLProtocol { override class func canInit(with request: URLRequest) -> Bool { true } override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }