From d8cb1daa78fa875d31389ced619128c42b5d7ac9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 12 Dec 2025 21:42:16 +0000 Subject: [PATCH] test(macos): cover gateway connection reuse --- .../GatewayChannelConfigureTests.swift | 32 ++++- .../GatewayChannelShutdownTests.swift | 109 ++++++++++++++++++ 2 files changed, 139 insertions(+), 2 deletions(-) create mode 100644 apps/macos/Tests/ClawdisIPCTests/GatewayChannelShutdownTests.swift diff --git a/apps/macos/Tests/ClawdisIPCTests/GatewayChannelConfigureTests.swift b/apps/macos/Tests/ClawdisIPCTests/GatewayChannelConfigureTests.swift index 86dbdece0..f62330b54 100644 --- a/apps/macos/Tests/ClawdisIPCTests/GatewayChannelConfigureTests.swift +++ b/apps/macos/Tests/ClawdisIPCTests/GatewayChannelConfigureTests.swift @@ -9,9 +9,14 @@ import Testing OSAllocatedUnfairLock<(@Sendable (Result) -> Void)?>(initialState: nil) private let cancelCount = OSAllocatedUnfairLock(initialState: 0) private let sendCount = OSAllocatedUnfairLock(initialState: 0) + private let helloDelayMs: Int var state: URLSessionTask.State = .suspended + init(helloDelayMs: Int = 0) { + self.helloDelayMs = helloDelayMs + } + func snapshotCancelCount() -> Int { self.cancelCount.withLock { $0 } } func resume() { @@ -53,7 +58,10 @@ import Testing } func receive() async throws -> URLSessionWebSocketTask.Message { - .data(Self.helloOkData()) + if self.helloDelayMs > 0 { + try await Task.sleep(nanoseconds: UInt64(self.helloDelayMs) * 1_000_000) + } + return .data(Self.helloOkData()) } func receive( @@ -97,6 +105,11 @@ import Testing private final class FakeWebSocketSession: WebSocketSessioning, @unchecked Sendable { private let makeCount = OSAllocatedUnfairLock(initialState: 0) private let tasks = OSAllocatedUnfairLock(initialState: [FakeWebSocketTask]()) + private let helloDelayMs: Int + + init(helloDelayMs: Int = 0) { + self.helloDelayMs = helloDelayMs + } func snapshotMakeCount() -> Int { self.makeCount.withLock { $0 } } func snapshotCancelCount() -> Int { @@ -108,7 +121,7 @@ import Testing func makeWebSocketTask(url: URL) -> WebSocketTaskBox { _ = url self.makeCount.withLock { $0 += 1 } - let task = FakeWebSocketTask() + let task = FakeWebSocketTask(helloDelayMs: self.helloDelayMs) self.tasks.withLock { $0.append(task) } return WebSocketTaskBox(task: task) } @@ -157,4 +170,19 @@ import Testing #expect(session.snapshotMakeCount() == 2) #expect(session.snapshotCancelCount() == 1) } + + @Test func concurrentRequestsStillUseSingleWebSocket() async throws { + let session = FakeWebSocketSession(helloDelayMs: 150) + let url = URL(string: "ws://example.invalid")! + let cfg = ConfigSource(token: nil) + let conn = GatewayConnection( + configProvider: { (url, cfg.snapshotToken()) }, + sessionBox: WebSocketSessionBox(session: session)) + + async let r1: Data = conn.request(method: "status", params: nil) + async let r2: Data = conn.request(method: "status", params: nil) + _ = try await (r1, r2) + + #expect(session.snapshotMakeCount() == 1) + } } diff --git a/apps/macos/Tests/ClawdisIPCTests/GatewayChannelShutdownTests.swift b/apps/macos/Tests/ClawdisIPCTests/GatewayChannelShutdownTests.swift new file mode 100644 index 000000000..f2a78fbfe --- /dev/null +++ b/apps/macos/Tests/ClawdisIPCTests/GatewayChannelShutdownTests.swift @@ -0,0 +1,109 @@ +import Foundation +import os +import Testing +@testable import Clawdis + +@Suite struct GatewayChannelShutdownTests { + private final class FakeWebSocketTask: WebSocketTasking, @unchecked Sendable { + private let pendingReceiveHandler = + OSAllocatedUnfairLock<(@Sendable (Result) -> Void)?>(initialState: nil) + private let cancelCount = OSAllocatedUnfairLock(initialState: 0) + + var state: URLSessionTask.State = .suspended + + func snapshotCancelCount() -> Int { self.cancelCount.withLock { $0 } } + + func resume() { + self.state = .running + } + + func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) { + _ = (closeCode, reason) + self.state = .canceling + self.cancelCount.withLock { $0 += 1 } + let handler = self.pendingReceiveHandler.withLock { handler in + defer { handler = nil } + return handler + } + handler?(Result.failure(URLError(.cancelled))) + } + + func send(_ message: URLSessionWebSocketTask.Message) async throws { + _ = message + } + + func receive() async throws -> URLSessionWebSocketTask.Message { + .data(Self.helloOkData()) + } + + func receive( + completionHandler: @escaping @Sendable (Result) -> Void) + { + self.pendingReceiveHandler.withLock { $0 = completionHandler } + } + + func triggerReceiveFailure() { + let handler = self.pendingReceiveHandler.withLock { $0 } + handler?(Result.failure(URLError(.networkConnectionLost))) + } + + private static func helloOkData() -> Data { + let json = """ + { + "type": "hello-ok", + "protocol": 1, + "server": { "version": "test", "connId": "test" }, + "features": { "methods": [], "events": [] }, + "snapshot": { + "presence": [ { "ts": 1 } ], + "health": {}, + "stateVersion": { "presence": 0, "health": 0 }, + "uptimeMs": 0 + }, + "policy": { "maxPayload": 1, "maxBufferedBytes": 1, "tickIntervalMs": 30000 } + } + """ + return Data(json.utf8) + } + } + + private final class FakeWebSocketSession: WebSocketSessioning, @unchecked Sendable { + private let makeCount = OSAllocatedUnfairLock(initialState: 0) + private let tasks = OSAllocatedUnfairLock(initialState: [FakeWebSocketTask]()) + + func snapshotMakeCount() -> Int { self.makeCount.withLock { $0 } } + func latestTask() -> FakeWebSocketTask? { self.tasks.withLock { $0.last } } + + func makeWebSocketTask(url: URL) -> WebSocketTaskBox { + _ = url + self.makeCount.withLock { $0 += 1 } + let task = FakeWebSocketTask() + self.tasks.withLock { $0.append(task) } + return WebSocketTaskBox(task: task) + } + } + + @Test func shutdownPreventsReconnectLoopFromReceiveFailure() async throws { + let session = FakeWebSocketSession() + let channel = GatewayChannelActor( + url: URL(string: "ws://example.invalid")!, + token: nil, + session: WebSocketSessionBox(session: session)) + + // Establish a connection so `listen()` is active. + try await channel.connect() + #expect(session.snapshotMakeCount() == 1) + + // Simulate a socket receive failure, which would normally schedule a reconnect. + session.latestTask()?.triggerReceiveFailure() + + // Shut down quickly, before backoff reconnect triggers. + await channel.shutdown() + + // Wait longer than the default reconnect backoff (500ms) to ensure no reconnect happens. + try? await Task.sleep(nanoseconds: 750 * 1_000_000) + + #expect(session.snapshotMakeCount() == 1) + } +} +