From dd439844ab232a86494f8cf5e4058f5ab438a549 Mon Sep 17 00:00:00 2001 From: Michael-128 Date: Mon, 6 Jul 2026 17:59:34 +0200 Subject: [PATCH 1/2] feat(network): implement 15s global request timeout and 3s launch splash cut-off --- qBitControl/Classes/NetworkClient.swift | 15 +++++++++++--- qBitControl/Classes/ServersHelper.swift | 26 +++++++++++++++++++++---- 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/qBitControl/Classes/NetworkClient.swift b/qBitControl/Classes/NetworkClient.swift index 1826168..8878cc9 100644 --- a/qBitControl/Classes/NetworkClient.swift +++ b/qBitControl/Classes/NetworkClient.swift @@ -11,6 +11,7 @@ enum NetworkError: Error, Equatable { case unauthorized case invalidResponse case httpError(statusCode: Int) + case timeout } /// A stateless actor responsible for executing generic asynchronous HTTP requests. @@ -23,11 +24,19 @@ actor NetworkClient { /// - Parameters: /// - baseURL: The base server URL. /// - basicAuth: Optional basic authentication credentials. - /// - session: The `URLSession` instance to use (supports Dependency Injection for testability). - init(baseURL: String, basicAuth: Server.BasicAuth?, session: URLSession = .shared) { + /// - session: Optional custom `URLSession` (defaults to 15s timeout session). + init(baseURL: String, basicAuth: Server.BasicAuth?, session: URLSession? = nil) { self.baseURL = baseURL self.basicAuth = basicAuth - self.session = session + + if let session = session { + self.session = session + } else { + let config = URLSessionConfiguration.default + config.timeoutIntervalForRequest = 15.0 + config.timeoutIntervalForResource = 15.0 + self.session = URLSession(configuration: config) + } } /// Sends an HTTP request and decodes the response to a generic `Decodable` type. diff --git a/qBitControl/Classes/ServersHelper.swift b/qBitControl/Classes/ServersHelper.swift index fea9a03..2aa3e83 100644 --- a/qBitControl/Classes/ServersHelper.swift +++ b/qBitControl/Classes/ServersHelper.swift @@ -165,12 +165,30 @@ class ServersHelper: ObservableObject { let networkClient = NetworkClient(baseURL: server.url, basicAuth: server.basicAuth) let newClient = qBittorrentClient(networkClient: networkClient) do { - try await newClient.login(username: server.username, password: server.password) + let loggedInClient = try await withThrowingTaskGroup(of: qBittorrentClient.self) { group in + group.addTask { + try await newClient.login(username: server.username, password: server.password) + return newClient + } + + group.addTask { + try await Task.sleep(nanoseconds: 3_000_000_000) // 3 seconds + throw NetworkError.timeout + } + + let firstResult = try await group.next() + group.cancelAll() + + guard let client = firstResult else { + throw NetworkError.invalidResponse + } + return client + } - self.client = newClient + // Mutating and invoking actor-isolated methods safely on the MainActor + self.client = loggedInClient self.setActiveServer(id: server.id) - - await fetchMetadata() + await self.fetchMetadata() self.isLoggedIn = true } catch { From 6a41fccd52c6a36d59da34efc7685f242f6dc633 Mon Sep 17 00:00:00 2001 From: Michael-128 Date: Mon, 6 Jul 2026 18:06:45 +0200 Subject: [PATCH 2/2] test(network): add unit tests for request timeout and task group timeout race --- qBitControlTests/NetworkClientTests.swift | 48 +++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/qBitControlTests/NetworkClientTests.swift b/qBitControlTests/NetworkClientTests.swift index 68ecf33..254f21b 100644 --- a/qBitControlTests/NetworkClientTests.swift +++ b/qBitControlTests/NetworkClientTests.swift @@ -271,4 +271,52 @@ final class NetworkClientTests: XCTestCase { // Then XCTAssertEqual(result, rawVersionString) } + + func testSendRequest_Timeout() async { + // Given + MockURLProtocol.requestHandler = { request in + Thread.sleep(forTimeInterval: 0.3) + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (response, nil) + } + + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [MockURLProtocol.self] + configuration.timeoutIntervalForRequest = 0.1 + configuration.timeoutIntervalForResource = 0.1 + let mockTimeoutSession = URLSession(configuration: configuration) + + let client = NetworkClient(baseURL: baseURL, basicAuth: nil, session: mockTimeoutSession) + + // When/Then + do { + let _: MockDecodable = try await client.sendRequest(path: "/test", queryItems: [], cookie: nil) + XCTFail("Expected request to timeout, but it succeeded.") + } catch { + XCTAssertNotNil(error) + } + } + + func testTaskGroup_TimeoutRace() async { + // Given/When/Then + do { + let _: Bool = try await withThrowingTaskGroup(of: Bool.self) { group in + group.addTask { + try await Task.sleep(nanoseconds: 300_000_000) // 0.3 seconds + return true + } + group.addTask { + try await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds (wins timeout) + throw NetworkError.timeout + } + + let first = try await group.next() + group.cancelAll() + return first ?? false + } + XCTFail("Expected task group to fail with timeout error") + } catch { + XCTAssertEqual(error as? NetworkError, NetworkError.timeout) + } + } }