diff --git a/Tests/OpenClawKitTests/GatewayChannelLifecycleTests.swift b/Tests/OpenClawKitTests/GatewayChannelLifecycleTests.swift index ba546f2..b711fae 100644 --- a/Tests/OpenClawKitTests/GatewayChannelLifecycleTests.swift +++ b/Tests/OpenClawKitTests/GatewayChannelLifecycleTests.swift @@ -496,15 +496,15 @@ struct GatewayChannelLifecycleTests { await channel.shutdown() } - @Test + /// The socket never pongs, so only the ping deadline can end the wait (with `URLError`); an unbounded + /// ping trips the time limit. No wall-clock bound: a saturated test pool can stall the run for seconds. + @Test(.timeLimit(.minutes(1))) func keepalivePingIsBoundedWhenNoPongArrives() async throws { let socket = GatewayCoreFakeSocket(script: GatewayCoreSocketScript(pingBehavior: .never)) let box = WebSocketTaskBox(task: socket) - let start = ContinuousClock.now await #expect(throws: URLError.self) { try await box.sendPing(timeout: .milliseconds(50)) } - #expect(ContinuousClock.now - start < .seconds(5)) let duplicate = WebSocketTaskBox(task: GatewayCoreFakeSocket(script: GatewayCoreSocketScript( pingBehavior: .duplicateSuccess))) @@ -545,14 +545,17 @@ struct GatewayChannelLifecycleTests { == .drop(.missingRecipientProfile)) } - @Test + /// The gateway never answers `connect`. Only the 100 ms option can time the handshake out within the + /// time limit: the fallback budget is an hour, so a channel that ignored the option would trip it. No + /// wall-clock bound: a saturated test pool can stall the run for seconds. + @Test(.timeLimit(.minutes(1))) func handshakeTimeoutOptionBoundsTheWholeHandshake() async throws { let session = GatewayCoreFakeSession(fixedScript: GatewayCoreSocketScript( connectReply: { _ in .none })) var options = gatewayCoreOptions() options.handshakeTimeoutMs = 100 let channel = try makeChannel(session: session, options: options) - let start = ContinuousClock.now + await channel._test_setConnectTimeoutSeconds(3600) do { try await channel.connect() Issue.record("expected a handshake timeout") @@ -560,7 +563,6 @@ struct GatewayChannelLifecycleTests { #expect((error as NSError).domain == NSURLErrorDomain) #expect((error as NSError).code == URLError.timedOut.rawValue) } - #expect(ContinuousClock.now - start < .seconds(5)) #expect(await channel.currentHandshakePhase() == .connectSent) #expect(session.latestSocket?.state != .running) await channel.shutdown() diff --git a/Tests/OpenClawKitTests/GatewayNetworkConnectionTransportTests.swift b/Tests/OpenClawKitTests/GatewayNetworkConnectionTransportTests.swift index 2a9ad51..8d4fc82 100644 --- a/Tests/OpenClawKitTests/GatewayNetworkConnectionTransportTests.swift +++ b/Tests/OpenClawKitTests/GatewayNetworkConnectionTransportTests.swift @@ -87,19 +87,28 @@ private final class NetworkConnectionLoopbackGateway: @unchecked Sendable { listener.newConnectionHandler = { [weak gateway] connection in gateway?.accept(connection) } + // Wait on the listener's own state updates rather than polling against a wall-clock deadline, + // which a saturated test pool can overrun even though the listener is long ready. The suite's + // time limit bounds a real hang: cancellation ends the stream iteration below. + let (states, stateContinuation) = AsyncStream.makeStream(of: NWListener.State.self) + defer { stateContinuation.finish() } + listener.stateUpdateHandler = { stateContinuation.yield($0) } listener.start(queue: gateway.queue) - let deadline = ContinuousClock.now.advanced(by: .seconds(5)) - while ContinuousClock.now < deadline { - if case .ready = listener.state, let port = listener.port, port.rawValue != 0 { + for await state in states { + switch state { + case .ready: return gateway - } - if case let .failed(error) = listener.state { + case let .failed(error): + listener.cancel() throw error + case .cancelled: + throw CancellationError() + default: + continue } - try await Task.sleep(for: .milliseconds(10)) } listener.cancel() - throw URLError(.timedOut) + throw CancellationError() } var url: URL { @@ -177,7 +186,7 @@ private final class NetworkConnectionLoopbackGateway: @unchecked Sendable { } } -@Suite("Network.framework gateway transport", .serialized, .gatewayTLSStoreIsolated) +@Suite("Network.framework gateway transport", .serialized, .gatewayTLSStoreIsolated, .timeLimit(.minutes(1))) struct GatewayNetworkConnectionTransportTests { @Test func handshakeAndRequestsRunOverNetworkConnection() async throws { diff --git a/Tests/OpenClawKitTests/OpenClawAppIntentsRunMatchingTests.swift b/Tests/OpenClawKitTests/OpenClawAppIntentsRunMatchingTests.swift index 01f4814..c64b29f 100644 --- a/Tests/OpenClawKitTests/OpenClawAppIntentsRunMatchingTests.swift +++ b/Tests/OpenClawKitTests/OpenClawAppIntentsRunMatchingTests.swift @@ -5,14 +5,20 @@ import OpenClawKit /// Gateway transport whose `chat.send` stays pending until the test acknowledges it. private actor HeldChatSendRequester: OpenClawIntentGatewayRequesting { - private(set) var sendParams: [String: AnyCodable]? + /// Params of each `chat.send`, yielded the moment the request arrives. + nonisolated let sends: AsyncStream<[String: AnyCodable]> + private let sendsContinuation: AsyncStream<[String: AnyCodable]>.Continuation private(set) var abortParams: [[String: AnyCodable]] = [] private var pendingSend: CheckedContinuation? + init() { + (self.sends, self.sendsContinuation) = AsyncStream.makeStream() + } + func request(method: String, params: [String: AnyCodable]?, timeoutMs: Double?) async throws -> Data { switch method { case "chat.send": - self.sendParams = params ?? [:] + self.sendsContinuation.yield(params ?? [:]) return try await withCheckedThrowingContinuation { self.pendingSend = $0 } case "chat.abort": self.abortParams.append(params ?? [:]) @@ -26,26 +32,25 @@ private actor HeldChatSendRequester: OpenClawIntentGatewayRequesting { self.pendingSend?.resume(returning: Data(json.utf8)) self.pendingSend = nil } - - var idempotencyKey: String? { - self.sendParams?["idempotencyKey"]?.stringValue - } } private func chatEvent(_ fields: [String: String]) -> EventFrame { EventFrame(type: "event", event: "chat", payload: AnyCodable(fields.mapValues { AnyCodable($0) })) } +/// Returns the idempotency key of the first `chat.send` once it reaches the requester. +/// +/// Event-driven rather than deadline-polled: under a saturated test pool the send can take +/// seconds to arrive, which must only slow the test down. The suite's time limit bounds a real hang. private func waitForSend(_ requester: HeldChatSendRequester) async throws -> String { - let deadline = ContinuousClock.now.advanced(by: .seconds(5)) - while ContinuousClock.now < deadline { - if let key = await requester.idempotencyKey { return key } - try await Task.sleep(for: .milliseconds(5)) + for await params in requester.sends { + return try #require(params["idempotencyKey"]?.stringValue) } + // The stream is never finished, so iteration only ends when the time limit cancels the test. throw CancellationError() } -@Suite("App Intents run matching") +@Suite("App Intents run matching", .timeLimit(.minutes(1))) struct OpenClawAppIntentsRunMatchingTests { @Test func anotherAgentsRunBeforeTheAckNeverHijacksTheIntent() async throws { diff --git a/Tests/OpenClawKitTests/WatchNodeClientTests.swift b/Tests/OpenClawKitTests/WatchNodeClientTests.swift index f97f3a3..0d8776d 100644 --- a/Tests/OpenClawKitTests/WatchNodeClientTests.swift +++ b/Tests/OpenClawKitTests/WatchNodeClientTests.swift @@ -196,7 +196,7 @@ private final class InMemoryWatchNodeConfigurationStore: OpenClawWatchNodeConfig } } -@Suite(.serialized) +@Suite(.serialized, .timeLimit(.minutes(1))) struct WatchNodeClientTests { private static let nowMs = Int64(1_800_000_000_000) @@ -223,12 +223,10 @@ struct WatchNodeClientTests { sentAtMs: sentAtMs) } - private static func eventually( - timeout: Duration = .seconds(5), - _ condition: @Sendable () async -> Bool) async -> Bool - { - let deadline = ContinuousClock.now + timeout - while ContinuousClock.now < deadline { + /// Polls until `condition` holds. There is no wall-clock deadline, so a saturated test pool only + /// slows the wait down; the suite's time limit cancels a real hang, which ends the wait with `false`. + private static func eventually(_ condition: @Sendable () async -> Bool) async -> Bool { + while !Task.isCancelled { if await condition() { return true } try? await Task.sleep(for: .milliseconds(5)) }