From 3935801c0ecb86cf415f7acf268cf67b6f9e8866 Mon Sep 17 00:00:00 2001 From: MarcoDotIO Date: Wed, 30 Sep 2026 10:14:21 -0400 Subject: [PATCH 01/11] test: wait for events instead of a 5 s wall-clock deadline anotherAgentsRunBeforeTheAckNeverHijacksTheIntent and preAckEventsOfTheAckedRunAreReplayed failed on main (CI run 36644330107, xcode-27 runner) with "Caught error: CancellationError()" after ~8 s; the same tree passed on PR #20. waitForSend polled the requester every 5 ms and threw once a 5 s ContinuousClock deadline passed. With ~3,150 Swift Testing tests saturating the cooperative pool, the host.send task (and the poller itself) did not run for more than 5 s, so the deadline expired although nothing in GatewayOpenClawIntentHost.send is slow. Blocking every cooperative thread for 6 s reproduces the failure locally. - OpenClawAppIntentsRunMatchingTests: HeldChatSendRequester yields each chat.send's params to an AsyncStream the moment the request arrives, and waitForSend awaits that stream. Assertions are unchanged. - GatewayNetworkConnectionTransportTests: the loopback NWListener start waits on stateUpdateHandler (ready/failed/cancelled) instead of polling listener.state against the same 5 s deadline, which also had no final re-check after a late wake-up. - WatchNodeClientTests: eventually() drops its 5 s deadline. Its conditions read production client state that has no change hook, so it still polls, but slowness no longer fails it. Each suite gains .timeLimit(.minutes(1)) for hang protection. Every wait ends on cancellation, so a time-limit overrun reports "Time limit was exceeded" instead of hanging. Co-Authored-By: Claude Opus 5.5 --- ...tewayNetworkConnectionTransportTests.swift | 25 +++++++++++------ .../OpenClawAppIntentsRunMatchingTests.swift | 27 +++++++++++-------- .../WatchNodeClientTests.swift | 12 ++++----- 3 files changed, 38 insertions(+), 26 deletions(-) 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)) } From f8eb5fb9193150e61a5f0cedbf7b1e3367896285 Mon Sep 17 00:00:00 2001 From: MarcoDotIO Date: Wed, 30 Sep 2026 11:09:15 -0400 Subject: [PATCH 02/11] test: bound lifecycle timeouts with a time limit, not a 5 s wall clock keepalivePingIsBoundedWhenNoPongArrives and handshakeTimeoutOptionBoundsTheWholeHandshake failed on PR #22's macOS job (Xcode 27) with `ContinuousClock.now - start < .seconds(5)` at ~5.7 s. A trivial test in the same window took 5.3 s, so the runner stalled; the timeouts under test were 50 ms and 100 ms. - keepalive ping: the socket never pongs, so the thrown URLError already proves the ping deadline ended the wait; a time limit catches a hang. - handshake option: the fallback budget is raised to an hour through _test_setConnectTimeoutSeconds, so a channel that ignored the 100 ms option would trip the time limit instead of finishing at the 30 s default. Checked with a scratch test that drops the option: it fails with "Time limit was exceeded" and does not hang. Both tests take .timeLimit(.minutes(1)) and pass while every cooperative thread is blocked for several seconds. Co-Authored-By: Claude Opus 5.5 --- .../GatewayChannelLifecycleTests.swift | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) 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() From 79c50c2c37c5a01d54f06f166d6e4b8bd09f4aab Mon Sep 17 00:00:00 2001 From: MarcoDotIO Date: Wed, 30 Sep 2026 11:40:37 -0400 Subject: [PATCH 03/11] test: show the device-auth write leaves the actor free by ordering, not elapsed time issuedTokenPersistenceNeverBlocksTheChannelActor asserted the actor answered within 3 s while a token write waited on another connection's SQLite lock. A saturated test pool can stall the run for longer than that, and a time limit alone would miss the regression it guards (a write on the actor holds it for SQLite's 30 s busy timeout, less than the one-minute limit). The test now relies on ordering. It releases the lock only after the actor answers, so the token can reach disk only if the actor answered while the write was pending. A write on the actor fails with SQLITE_BUSY first, the token never lands, and the final wait trips the new suite time limit. The 200 ms sleep that let hello-ok reach the write is replaced by a DEBUG hook, _test_setDeviceTokenPersistenceStartedHandler, that fires on the actor just before the persistence hop. Once the hop starts, shutdown cannot stop it. Co-Authored-By: Claude Opus 5.5 --- .../OpenClawKit/GatewayChannel+Testing.swift | 5 +++++ Sources/OpenClawKit/GatewayChannel.swift | 4 ++++ .../GatewayDeviceAuthOffActorTests.swift | 19 +++++++++++++------ 3 files changed, 22 insertions(+), 6 deletions(-) diff --git a/Sources/OpenClawKit/GatewayChannel+Testing.swift b/Sources/OpenClawKit/GatewayChannel+Testing.swift index 3e5c9f3..6d7f3ef 100644 --- a/Sources/OpenClawKit/GatewayChannel+Testing.swift +++ b/Sources/OpenClawKit/GatewayChannel+Testing.swift @@ -22,6 +22,11 @@ extension GatewayChannelActor { func _test_setRequestResumedHandler(_ handler: (@Sendable () async -> Void)?) { self.testRequestResumedHandler = handler } + + /// Called on the actor just before hello-ok's issued tokens are handed to the persistence hop. + func _test_setDeviceTokenPersistenceStartedHandler(_ handler: (@Sendable () -> Void)?) { + self.testDeviceTokenPersistenceStartedHandler = handler + } #endif func _test_pendingRequestCount() -> Int { diff --git a/Sources/OpenClawKit/GatewayChannel.swift b/Sources/OpenClawKit/GatewayChannel.swift index 283afad..ca38b24 100644 --- a/Sources/OpenClawKit/GatewayChannel.swift +++ b/Sources/OpenClawKit/GatewayChannel.swift @@ -94,6 +94,7 @@ public actor GatewayChannelActor { var testConnectRunFinishedHandler: (@Sendable () -> Void)? var testConnectFailureBackoffWaitHandler: (@Sendable () async throws -> Void)? var testRequestResumedHandler: (@Sendable () async -> Void)? + var testDeviceTokenPersistenceStartedHandler: (@Sendable () -> Void)? #endif private let connectChallengeTimeoutSeconds: Double = 6.0 // Some networks will silently drop idle TCP/TLS flows around ~30s. The gateway tick is server->client, @@ -1349,6 +1350,9 @@ extension GatewayChannelActor { } var persistedRoles = Set() if let identity, !writes.isEmpty { + #if DEBUG + self.testDeviceTokenPersistenceStartedHandler?() + #endif persistedRoles = await Self.persistDeviceTokens( writes, deviceId: identity.deviceId, diff --git a/Tests/OpenClawKitTests/GatewayDeviceAuthOffActorTests.swift b/Tests/OpenClawKitTests/GatewayDeviceAuthOffActorTests.swift index 47887c9..782a52a 100644 --- a/Tests/OpenClawKitTests/GatewayDeviceAuthOffActorTests.swift +++ b/Tests/OpenClawKitTests/GatewayDeviceAuthOffActorTests.swift @@ -35,8 +35,13 @@ private final class StateDatabaseWriteLock: @unchecked Sendable { } } -@Suite("Gateway device auth off the channel actor", .serialized) +@Suite("Gateway device auth off the channel actor", .serialized, .timeLimit(.minutes(1))) struct GatewayDeviceAuthOffActorTests { + /// Ordering, not elapsed time, proves the actor stayed responsive: the lock is released only after + /// the actor answers, so the token can reach disk only if the actor answered while the write was + /// still pending. A write on the actor would hold every call until SQLite's 30 s busy timeout fails + /// it; the token never lands and the final wait trips the time limit. The only timing left is that + /// the healthy write must see the release within the same 30 s busy timeout. @Test func issuedTokenPersistenceNeverBlocksTheChannelActor() async throws { let directory = try gatewayCoreTemporaryStateDirectory() @@ -59,15 +64,17 @@ struct GatewayDeviceAuthOffActorTests { token: "shared", session: WebSocketSessionBox(session: session), connectOptions: gatewayCoreOptions(includeDeviceIdentity: true)) + let (persistenceStarts, persistenceStarted) = AsyncStream.makeStream(of: Void.self) + await channel._test_setDeviceTokenPersistenceStartedHandler { persistenceStarted.yield() } let connect = Task { try await channel.connect() } - try await gatewayCoreWaitUntil("write lock held") { writeLock.isHeld } - try await Task.sleep(for: .milliseconds(200)) + // Wait until hello-ok's token is handed to the persistence hop, which shutdown cannot stop. + var starts = persistenceStarts.makeAsyncIterator() + guard await starts.next() != nil else { throw CancellationError() } + try #require(writeLock.isHeld) - // The token write waits on SQLite's 30 s busy timeout; the actor must stay responsive. - let started = ContinuousClock.now + // The write cannot finish while the lock is held; the actor must still answer. #expect(await channel.currentConnectionGeneration() == nil) await channel.shutdown() - #expect(ContinuousClock.now - started < .seconds(3)) writeLock.release() _ = try? await connect.value From bccaaacd1a0f209e862e9cd3abdb2b2441f5ba05 Mon Sep 17 00:00:00 2001 From: MarcoDotIO Date: Wed, 30 Sep 2026 11:40:38 -0400 Subject: [PATCH 04/11] test: bound the FIFO, AsyncTimeout and link-preview tests with a time limit The three tests asserted wall-clock bounds (2 s, 5 s, 1 s) that a saturated test pool can overrun. Each now has a one-minute time limit instead, and each makes sure a regression ends on the limit's cancellation instead of hanging the run: - file fetch refuses a FIFO: a blocking open(2) never returns. The cancellation handler opens the FIFO's write end once to release it. - operationThatIgnoresCancellationStillTimesOut: the parked operation is a gate the test opens on cancellation (and afterwards), not a never-resumed continuation that a loser-joining race would wait on forever. - total deadline can fire before the session starts: URLSession's own timeouts move past the limit, so only the fetcher's zero-second deadline can end the fetch. The fetch is awaited through a no-deadline AsyncTimeout race, because a deadline lost before start would leave it unresumed even on cancellation. Co-Authored-By: Claude Opus 5.5 --- .../AsyncTimeoutRaceTests.swift | 57 +++++++++++++++---- .../ChatLinkPreviewTests.swift | 19 +++++-- .../FileTransferNodeCommandsTests.swift | 16 ++++-- 3 files changed, 72 insertions(+), 20 deletions(-) diff --git a/Tests/OpenClawKitTests/AsyncTimeoutRaceTests.swift b/Tests/OpenClawKitTests/AsyncTimeoutRaceTests.swift index d8808e7..d090c77 100644 --- a/Tests/OpenClawKitTests/AsyncTimeoutRaceTests.swift +++ b/Tests/OpenClawKitTests/AsyncTimeoutRaceTests.swift @@ -21,21 +21,56 @@ private final class RaceCounter: @unchecked Sendable { } } +/// An operation that ignores cancellation entirely (the keepalive wedge): it parks on a checked +/// continuation until the test itself calls `open()`. +private final class RaceGate: @unchecked Sendable { + private let lock = NSLock() + private var isOpen = false + private var waiter: CheckedContinuation? + + func wait() async { + await withCheckedContinuation { continuation in + self.lock.lock() + guard !self.isOpen else { + self.lock.unlock() + continuation.resume() + return + } + self.waiter = continuation + self.lock.unlock() + } + } + + func open() { + self.lock.lock() + self.isOpen = true + let waiter = self.waiter + self.waiter = nil + self.lock.unlock() + waiter?.resume() + } +} + @Suite("AsyncTimeout race") struct AsyncTimeoutRaceTests { - @Test + /// Only the 50 ms deadline can end the race: the operation stays parked until the test opens its + /// gate. A race that joined its loser would hang and trip the time limit, whose cancellation opens + /// the gate so the test body can end. No wall-clock bound: a saturated test pool can stall the run + /// for seconds. + @Test(.timeLimit(.minutes(1))) func operationThatIgnoresCancellationStillTimesOut() async { - let start = ContinuousClock.now - await #expect(throws: RaceTimeoutError.self) { - try await AsyncTimeout.withTimeout( - seconds: 0.05, - onTimeout: { RaceTimeoutError() }, - operation: { - // A never-resumed continuation ignores cancellation entirely (the keepalive wedge). - await withCheckedContinuation { (_: CheckedContinuation) in } - }) + let gate = RaceGate() + defer { gate.open() } + await withTaskCancellationHandler { + await #expect(throws: RaceTimeoutError.self) { + try await AsyncTimeout.withTimeout( + seconds: 0.05, + onTimeout: { RaceTimeoutError() }, + operation: { await gate.wait() }) + } + } onCancel: { + gate.open() } - #expect(ContinuousClock.now - start < .seconds(5)) } @Test diff --git a/Tests/OpenClawKitTests/ChatLinkPreviewTests.swift b/Tests/OpenClawKitTests/ChatLinkPreviewTests.swift index fae006b..cbf04ad 100644 --- a/Tests/OpenClawKitTests/ChatLinkPreviewTests.swift +++ b/Tests/OpenClawKitTests/ChatLinkPreviewTests.swift @@ -1,5 +1,6 @@ import Foundation import ImageIO +import OpenClawKit import Testing import UniformTypeIdentifiers @testable import OpenClawChatUI @@ -212,9 +213,17 @@ struct ChatLinkPreviewNetworkTests { #expect(ChatLinkPreviewStubURLProtocol.lastAcceptHeader == "text/html") } - @Test func `total deadline can fire before the session starts`() async throws { + /// The protocol never answers and URLSession's own timeouts sit past the time limit, so only the + /// fetcher's zero-second deadline can end the fetch in time. A deadline lost before `start` could + /// leave the fetch unresumed even on cancellation, so it is awaited through a no-deadline + /// `AsyncTimeout` race, which the time limit's cancellation always ends. No wall-clock bound: a + /// saturated test pool can stall the run for seconds. + @Test(.timeLimit(.minutes(1))) + func `total deadline can fire before the session starts`() async throws { let configuration = URLSessionConfiguration.ephemeral configuration.protocolClasses = [ChatLinkPreviewHangingURLProtocol.self] + configuration.timeoutIntervalForRequest = 3600 + configuration.timeoutIntervalForResource = 3600 let fetcher = ChatLinkPreviewFetcher( configuration: configuration, timeout: 0, @@ -222,11 +231,11 @@ struct ChatLinkPreviewNetworkTests { resolutionPolicy: { _ in true }, connectionPolicy: { _ in true }) let url = try #require(URL(string: "https://preview.test/slow")) - let clock = ContinuousClock() - let start = clock.now - #expect(await fetcher.fetch(url) == .failed) - #expect(start.duration(to: clock.now) < .seconds(1)) + let result = try await AsyncTimeout.withTimeout(seconds: 0, onTimeout: { CancellationError() }) { + await fetcher.fetch(url) + } + #expect(result == .failed) } @Test func `image fetch accepts only images and enforces its body cap`() async throws { diff --git a/Tests/OpenClawKitTests/FileTransferNodeCommandsTests.swift b/Tests/OpenClawKitTests/FileTransferNodeCommandsTests.swift index d33dc18..e84a465 100644 --- a/Tests/OpenClawKitTests/FileTransferNodeCommandsTests.swift +++ b/Tests/OpenClawKitTests/FileTransferNodeCommandsTests.swift @@ -217,15 +217,23 @@ struct FileTransferNodeCommandsTests { // MARK: - Drift between preflight and the final call - @Test func `file fetch refuses a FIFO promptly`() async throws { + /// A fetch that opened the FIFO without `O_NONBLOCK` would wait forever for a writer and trip the + /// time limit. That hang is a syscall, not an await, so the time limit's cancellation opens the write + /// end once to release the reader and let the test body end. No wall-clock bound: a saturated test + /// pool can stall the run for seconds. + @Test(.timeLimit(.minutes(1))) + func `file fetch refuses a FIFO promptly`() async throws { let sandbox = try FileTransferSandbox() defer { sandbox.cleanUp() } let fifo = sandbox.root + "/pipe" try #require(mkfifo(fifo, 0o600) == 0) let commands = sandbox.commands - let started = ContinuousClock.now - let result = try await self.invoke(commands, "file.fetch", ["path": fifo]) - #expect(ContinuousClock.now - started < .seconds(2)) + let result = try await withTaskCancellationHandler { + try await self.invoke(commands, "file.fetch", ["path": fifo]) + } onCancel: { + let writer = open(fifo, O_WRONLY | O_NONBLOCK) + if writer >= 0 { close(writer) } + } #expect(result["ok"] as? Bool == false) #expect(result["code"] as? String == "IS_DIRECTORY") } From ffeef5c3bf2a72529daa9af46a8bd80b83d21234 Mon Sep 17 00:00:00 2001 From: MarcoDotIO Date: Wed, 30 Sep 2026 11:40:38 -0400 Subject: [PATCH 05/11] test: drop wall-clock deadlines from the gateway and session-action waits gatewayCoreWaitUntil gave up after 10 s (15 s at two call sites) and the ChatViewModelSessionActionTests helpers after 15 s. A pool stall adds to those waits, so they could time out even though the condition was about to hold. Both now wait until the condition holds or the test is cancelled. Every suite that uses them has a one-minute time limit. gatewayCoreWaitUntil records its GatewayCoreWaitTimeout as an issue before throwing, because Swift Testing drops errors thrown after a time-limit cancellation and the label says which wait hung. waitForForkStart awaits the gate's stream directly instead of racing it against a sleep. Co-Authored-By: Claude Opus 5.5 --- .../ChatGatewaySessionTransportTests.swift | 6 +- .../ChatViewModelSessionActionTests.swift | 84 +++++-------------- .../GatewayChannelLifecycleTests.swift | 2 +- .../GatewayConnectRecoveryTests.swift | 4 +- .../GatewayCoreTestSupport.swift | 18 ++-- .../GatewayNodeSessionRouteTests.swift | 2 +- .../GatewayRequestBudgetTests.swift | 2 +- .../GatewayStateReportingWiringTests.swift | 2 +- .../GatewayTLSPinRotationRecoveryTests.swift | 2 +- 9 files changed, 43 insertions(+), 79 deletions(-) diff --git a/Tests/OpenClawKitTests/ChatGatewaySessionTransportTests.swift b/Tests/OpenClawKitTests/ChatGatewaySessionTransportTests.swift index 3249a1f..6b357b9 100644 --- a/Tests/OpenClawKitTests/ChatGatewaySessionTransportTests.swift +++ b/Tests/OpenClawKitTests/ChatGatewaySessionTransportTests.swift @@ -62,7 +62,7 @@ private func sentParams(_ socket: GatewayCoreFakeSocket, method: String) -> [[St socket.sentFrames(method: method).compactMap { $0["params"] as? [String: Any] } } -@Suite("Gateway session chat transport", .serialized) +@Suite("Gateway session chat transport", .serialized, .timeLimit(.minutes(1))) struct ChatGatewaySessionTransportTests { @Test func `init normalizes the agent and keeps exact gateway id bytes`() { let transport = OpenClawGatewaySessionChatTransport( @@ -384,7 +384,7 @@ struct ChatGatewaySessionTransportTests { // Same connection context, new socket: seqGap plus a fresh per-socket subscription. socket.emitReceiveFailure() - try await gatewayCoreWaitUntil("reconnect seqGap", timeoutSeconds: 15) { + try await gatewayCoreWaitUntil("reconnect seqGap") { recorder.values.contains("seqGap") } let reconnected = try #require(session.latestSocket) @@ -396,7 +396,7 @@ struct ChatGatewaySessionTransportTests { // A different endpoint is a different connection context: routeChanged. let replacement = transportTestSession(capabilities: []) try await gateway.connectForChatTransportTest("ws://replacement.example.invalid", session: replacement) - try await gatewayCoreWaitUntil("route change reported", timeoutSeconds: 15) { + try await gatewayCoreWaitUntil("route change reported") { recorder.values.contains("routeChanged") } let replacementSocket = try #require(replacement.latestSocket) diff --git a/Tests/OpenClawKitTests/ChatViewModelSessionActionTests.swift b/Tests/OpenClawKitTests/ChatViewModelSessionActionTests.swift index d0bcf9c..adbda1e 100644 --- a/Tests/OpenClawKitTests/ChatViewModelSessionActionTests.swift +++ b/Tests/OpenClawKitTests/ChatViewModelSessionActionTests.swift @@ -511,6 +511,7 @@ private struct BatchTestError: LocalizedError { } @MainActor +@Suite(.timeLimit(.minutes(1))) struct ChatViewModelSessionActionTests { @Test func `batch mutations continue after per-row failure with bounded fan-out`() async { let probe = BatchMutationProbe() @@ -1402,81 +1403,38 @@ struct ChatViewModelSessionActionTests { #expect(await transport.forkedParentKeys() == ["main"]) } - private func waitForForkStart( - _ gate: SessionActionCompletionGate, - timeout: Duration = .seconds(15)) async -> Bool - { - // The stream controls ordering; this deadline only bounds a broken fake or call path. - await withTaskGroup(of: Bool.self) { group in - group.addTask { await gate.waitUntilStarted() } - group.addTask { - try? await Task.sleep(for: timeout) - return false - } - let started = await group.next() ?? false - group.cancelAll() - return started - } + private func waitForForkStart(_ gate: SessionActionCompletionGate) async -> Bool { + // The stream controls ordering; the suite's time limit bounds a broken fake or call path, + // and its cancellation ends the stream wait with `false`. + await gate.waitUntilStarted() } - private func waitForBranchSwitchActivityToClear( - _ viewModel: OpenClawChatViewModel, - timeout: Duration = .seconds(15)) async -> Bool - { - let clock = ContinuousClock() - let deadline = clock.now + timeout - while clock.now < deadline { - if viewModel.hasBlockingRunActivity == false { - return true - } - await Task.yield() - } - return false + private func waitForBranchSwitchActivityToClear(_ viewModel: OpenClawChatViewModel) async -> Bool { + await self.eventually { viewModel.hasBlockingRunActivity == false } } - private func waitForOutboxRestore( - _ viewModel: OpenClawChatViewModel, - timeout: Duration = .seconds(15)) async -> Bool - { - let clock = ContinuousClock() - let deadline = clock.now + timeout - while clock.now < deadline { - if viewModel.hasRestoredOutboxMessages, - viewModel.hasPendingOutboxCommandsForCurrentSession - { - return true - } - await Task.yield() + private func waitForOutboxRestore(_ viewModel: OpenClawChatViewModel) async -> Bool { + await self.eventually { + viewModel.hasRestoredOutboxMessages && viewModel.hasPendingOutboxCommandsForCurrentSession } - return false } - private func waitForSend( - _ transport: SessionActionTransport, - timeout: Duration = .seconds(15)) async -> Bool - { - let clock = ContinuousClock() - let deadline = clock.now + timeout - while clock.now < deadline { - if await transport.sentSessionKeys().isEmpty == false { - return true - } - await Task.yield() - } - return false + private func waitForSend(_ transport: SessionActionTransport) async -> Bool { + await self.eventually { await transport.sentSessionKeys().isEmpty == false } } private func waitForBranchReload( _ viewModel: OpenClawChatViewModel, - branches: [OpenClawChatSessionBranch], - timeout: Duration = .seconds(15)) async -> Bool + branches: [OpenClawChatSessionBranch]) async -> Bool { - let clock = ContinuousClock() - let deadline = clock.now + timeout - while clock.now < deadline { - if viewModel.sessionBranches == branches, !viewModel.isLoading { - return true - } + await self.eventually { viewModel.sessionBranches == branches && !viewModel.isLoading } + } + + /// Yields 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 func eventually(_ condition: () async -> Bool) async -> Bool { + while !Task.isCancelled { + if await condition() { return true } await Task.yield() } return false diff --git a/Tests/OpenClawKitTests/GatewayChannelLifecycleTests.swift b/Tests/OpenClawKitTests/GatewayChannelLifecycleTests.swift index b711fae..2e05193 100644 --- a/Tests/OpenClawKitTests/GatewayChannelLifecycleTests.swift +++ b/Tests/OpenClawKitTests/GatewayChannelLifecycleTests.swift @@ -37,7 +37,7 @@ private func makeChannel( extraHeadersProvider: extraHeadersProvider) } -@Suite("Gateway channel lifecycle") +@Suite("Gateway channel lifecycle", .timeLimit(.minutes(1))) struct GatewayChannelLifecycleTests { @Test func operatorConnectOffersProtocolFourWithPlatformDefaults() async throws { diff --git a/Tests/OpenClawKitTests/GatewayConnectRecoveryTests.swift b/Tests/OpenClawKitTests/GatewayConnectRecoveryTests.swift index ba7f8aa..7cc763a 100644 --- a/Tests/OpenClawKitTests/GatewayConnectRecoveryTests.swift +++ b/Tests/OpenClawKitTests/GatewayConnectRecoveryTests.swift @@ -37,7 +37,7 @@ private let pinMismatch = GatewayTLSValidationFailure( systemTrustOk: true, port: 443) -@Suite("Gateway connect recovery", .serialized) +@Suite("Gateway connect recovery", .serialized, .timeLimit(.minutes(1))) struct GatewayConnectRecoveryTests { // MARK: TLS pin mismatch @@ -232,7 +232,7 @@ struct GatewayConnectRecoveryTests { await channel.reconnectPauseReason() == .authFailure } #expect(session.makeCount == 2) - try await gatewayCoreWaitUntil("resumed after retryAfterMs", timeoutSeconds: 10) { + try await gatewayCoreWaitUntil("resumed after retryAfterMs") { guard session.makeCount == 3 else { return false } return await channel.currentConnectionGeneration() != nil } diff --git a/Tests/OpenClawKitTests/GatewayCoreTestSupport.swift b/Tests/OpenClawKitTests/GatewayCoreTestSupport.swift index e05807a..3c5c243 100644 --- a/Tests/OpenClawKitTests/GatewayCoreTestSupport.swift +++ b/Tests/OpenClawKitTests/GatewayCoreTestSupport.swift @@ -1,5 +1,6 @@ import Foundation import OpenClawProtocol +import Testing @testable import OpenClawKit // In-memory WebSocket transport for gateway channel and node session tests. It scripts @@ -392,18 +393,23 @@ struct GatewayCoreWaitTimeout: Error, CustomStringConvertible { } } +/// Polls `condition` until it holds. +/// +/// There is no wall-clock deadline: a saturated test pool must only slow the wait down. Every suite +/// that calls this carries a `.timeLimit`, whose cancellation ends the wait with +/// ``GatewayCoreWaitTimeout``. func gatewayCoreWaitUntil( _ label: String, - timeoutSeconds: Double = 10, _ condition: @escaping @Sendable () async -> Bool) async throws { - let deadline = ContinuousClock.now.advanced(by: .milliseconds(Int64(timeoutSeconds * 1000))) - while ContinuousClock.now < deadline { + while !Task.isCancelled { if await condition() { return } - try await Task.sleep(for: .milliseconds(5)) + try? await Task.sleep(for: .milliseconds(5)) } - if await condition() { return } - throw GatewayCoreWaitTimeout(label: label) + // Swift Testing drops errors thrown after a time-limit cancellation, so record which wait hung. + let timeout = GatewayCoreWaitTimeout(label: label) + Issue.record(timeout) + throw timeout } func gatewayCoreTemporaryStateDirectory() throws -> URL { diff --git a/Tests/OpenClawKitTests/GatewayNodeSessionRouteTests.swift b/Tests/OpenClawKitTests/GatewayNodeSessionRouteTests.swift index 8a36913..3603f8b 100644 --- a/Tests/OpenClawKitTests/GatewayNodeSessionRouteTests.swift +++ b/Tests/OpenClawKitTests/GatewayNodeSessionRouteTests.swift @@ -148,7 +148,7 @@ private func respondToSurfaceRefresh( reply: .ok(["surface": "canvas", "pluginSurfaceUrls": ["canvas": url]])) } -@Suite("Gateway node session routes", .serialized) +@Suite("Gateway node session routes", .serialized, .timeLimit(.minutes(1))) struct GatewayNodeSessionRouteTests { @Test func invokeMetadataReachesTheHandlerAndResultCarriesStructuredPayload() async throws { diff --git a/Tests/OpenClawKitTests/GatewayRequestBudgetTests.swift b/Tests/OpenClawKitTests/GatewayRequestBudgetTests.swift index d915160..6313210 100644 --- a/Tests/OpenClawKitTests/GatewayRequestBudgetTests.swift +++ b/Tests/OpenClawKitTests/GatewayRequestBudgetTests.swift @@ -10,7 +10,7 @@ private func budgetChannel(session: GatewayCoreFakeSession) throws -> GatewayCha connectOptions: gatewayCoreOptions()) } -@Suite("Gateway request budget", .serialized) +@Suite("Gateway request budget", .serialized, .timeLimit(.minutes(1))) struct GatewayRequestBudgetTests { @Test func slowConnectDoesNotConsumeTheRequestBudget() async throws { diff --git a/Tests/OpenClawKitTests/GatewayStateReportingWiringTests.swift b/Tests/OpenClawKitTests/GatewayStateReportingWiringTests.swift index dbca099..33f2394 100644 --- a/Tests/OpenClawKitTests/GatewayStateReportingWiringTests.swift +++ b/Tests/OpenClawKitTests/GatewayStateReportingWiringTests.swift @@ -21,7 +21,7 @@ private func gatewayLabels(_ reporter: RecordingStateReporter) -> [String?] { reporter.transitions.filter { $0.domain == .gateway }.map(\.label) } -@Suite("Gateway state reporting wiring", .serialized) +@Suite("Gateway state reporting wiring", .serialized, .timeLimit(.minutes(1))) struct GatewayStateReportingWiringTests { @Test func connectReportsTheHandshakeLifecycleWithStableContext() async throws { diff --git a/Tests/OpenClawKitTests/GatewayTLSPinRotationRecoveryTests.swift b/Tests/OpenClawKitTests/GatewayTLSPinRotationRecoveryTests.swift index 85dc577..36c6a3c 100644 --- a/Tests/OpenClawKitTests/GatewayTLSPinRotationRecoveryTests.swift +++ b/Tests/OpenClawKitTests/GatewayTLSPinRotationRecoveryTests.swift @@ -16,7 +16,7 @@ private func mismatch(storeKey: String) -> GatewayTLSValidationFailure { port: 443) } -@Suite("Gateway TLS pin rotation recovery", .serialized, .gatewayTLSStoreIsolated) +@Suite("Gateway TLS pin rotation recovery", .serialized, .gatewayTLSStoreIsolated, .timeLimit(.minutes(1))) struct GatewayTLSPinRotationRecoveryTests { @Test func pinningSessionAcceptsAReviewedRotationInPlace() throws { From f5bee2adeb995569245984b2b2081cbd67da3377 Mon Sep 17 00:00:00 2001 From: MarcoDotIO Date: Wed, 30 Sep 2026 14:23:59 -0400 Subject: [PATCH 06/11] test: drop the wall-clock deadline from waitUntil waitUntil gave up after 15 s (7, 10 or 30 s at five call sites). The macOS CI job runs about 3,150 tests in parallel and the cooperative pool stalls for 5 to 8 s at a time, so a wait could time out even though the condition was about to hold. waitUntil now polls until the condition holds or the test is cancelled, and the timeoutSeconds and now: parameters are gone (nothing injected a clock, and with no deadline there is nothing to measure). Every suite whose tests reach waitUntil, directly or through a file-local helper, has a one-minute time limit; ChatViewModelSessionActionTests already had one. On cancellation the helper records AsyncWaitTimeoutError as an issue before throwing it, because Swift Testing drops errors thrown after a time-limit cancellation and the label says which wait hung. No call site relied on the timeout: nothing expects AsyncWaitTimeoutError, no wait runs in a child task that the test cancels, and the one try? wait is cleanup in a catch block that rethrows. Co-Authored-By: Claude Opus 5.5 --- .../ChannelDeliveryHardeningTests.swift | 2 +- .../ChannelErrorRedactionTests.swift | 2 +- .../ChannelPairingStoreTests.swift | 2 +- .../ChatComposerParityTests.swift | 1 + .../ChatComposerShellTests.swift | 1 + .../ChatCoreCompatibilityTests.swift | 2 +- Tests/OpenClawKitTests/ChatHapticsTests.swift | 1 + .../ChatSessionSidebarPreviewsTests.swift | 1 + .../ChatStreamReplayTests.swift | 1 + .../ChatViewModelAgentNavigationTests.swift | 1 + .../ChatViewModelAttachmentTests.swift | 1 + .../ChatViewModelOutboxSettingsTests.swift | 1 + .../ChatViewModelOutboxTests.swift | 6 ++--- .../ChatViewModelSessionDeletionTests.swift | 1 + .../OpenClawKitTests/ChatViewModelTests.swift | 7 +++--- .../ChatViewModelTranscriptCacheTests.swift | 1 + .../ChatViewModelUnreadTests.swift | 2 +- .../DiscordChannelAdapterTests.swift | 2 +- .../DiscordGatewayAdapterTests.swift | 4 ++-- .../IMAPMailboxWatcherTests.swift | 2 +- .../IMsgRPCTransportTests.swift | 4 ++-- .../SMSChannelAdapterTests.swift | 2 +- .../SignalAdapterRefreshTests.swift | 2 +- .../SignalChannelAdapterTests.swift | 2 +- .../SlackAdapterRefreshTests.swift | 2 +- .../SlackChannelAdapterTests.swift | 2 +- .../TelegramAdapterRefreshTests.swift | 2 +- .../TelegramChannelAdapterTests.swift | 2 +- Tests/OpenClawKitTests/TestAsyncHelpers.swift | 23 ++++++++++--------- .../VoiceNoteRecorderTests.swift | 1 + 30 files changed, 48 insertions(+), 35 deletions(-) diff --git a/Tests/OpenClawKitTests/ChannelDeliveryHardeningTests.swift b/Tests/OpenClawKitTests/ChannelDeliveryHardeningTests.swift index 0eae1a2..7f3b9c7 100644 --- a/Tests/OpenClawKitTests/ChannelDeliveryHardeningTests.swift +++ b/Tests/OpenClawKitTests/ChannelDeliveryHardeningTests.swift @@ -9,7 +9,7 @@ import Testing /// 2026.3.0 FX6 regressions: partial multi-part delivery, ambiguous 5xx, remote numeric input, /// iMessage post-write failures and media budgets. -@Suite("Channel delivery hardening") +@Suite("Channel delivery hardening", .timeLimit(.minutes(1))) struct ChannelDeliveryHardeningTests { actor OffsetStore: TelegramUpdateOffsetStore { func readLastUpdateID() async -> Int64? { nil } diff --git a/Tests/OpenClawKitTests/ChannelErrorRedactionTests.swift b/Tests/OpenClawKitTests/ChannelErrorRedactionTests.swift index f6f7a33..4e8afdc 100644 --- a/Tests/OpenClawKitTests/ChannelErrorRedactionTests.swift +++ b/Tests/OpenClawKitTests/ChannelErrorRedactionTests.swift @@ -7,7 +7,7 @@ import OpenClawCore import Testing /// Credential redaction for channel health, `channels.status`, diagnostics and delivery failures. -@Suite("Channel error redaction") +@Suite("Channel error redaction", .timeLimit(.minutes(1))) struct ChannelErrorRedactionTests { actor OffsetStore: TelegramUpdateOffsetStore { func readLastUpdateID() async -> Int64? { nil } diff --git a/Tests/OpenClawKitTests/ChannelPairingStoreTests.swift b/Tests/OpenClawKitTests/ChannelPairingStoreTests.swift index 0832180..aa3f38d 100644 --- a/Tests/OpenClawKitTests/ChannelPairingStoreTests.swift +++ b/Tests/OpenClawKitTests/ChannelPairingStoreTests.swift @@ -2,7 +2,7 @@ import Foundation import Testing @testable import OpenClawChannels -@Suite("Channel DM pairing store") +@Suite("Channel DM pairing store", .timeLimit(.minutes(1))) struct ChannelPairingStoreTests { final class Clock: @unchecked Sendable { private let lock = NSLock() diff --git a/Tests/OpenClawKitTests/ChatComposerParityTests.swift b/Tests/OpenClawKitTests/ChatComposerParityTests.swift index 93a9c1e..e27f1b1 100644 --- a/Tests/OpenClawKitTests/ChatComposerParityTests.swift +++ b/Tests/OpenClawKitTests/ChatComposerParityTests.swift @@ -80,6 +80,7 @@ struct ChatReplyQuoteTests { } @MainActor +@Suite(.timeLimit(.minutes(1))) struct ChatComposerStateTests { @Test func `model selection target describes only gateway owned values`() { let viewModel = OpenClawChatViewModel(sessionKey: "main", transport: ComposerParityTransport()) diff --git a/Tests/OpenClawKitTests/ChatComposerShellTests.swift b/Tests/OpenClawKitTests/ChatComposerShellTests.swift index bd6dae8..3d27a8f 100644 --- a/Tests/OpenClawKitTests/ChatComposerShellTests.swift +++ b/Tests/OpenClawKitTests/ChatComposerShellTests.swift @@ -264,6 +264,7 @@ struct ChatPrivateCloudQuotaNoticeTests { } @MainActor +@Suite(.timeLimit(.minutes(1))) struct ChatSessionPagingTests { @Test func `paging status summarizes truncated lists only`() { #expect(ChatSessionPagingStatus(loadedCount: 10, totalCount: 10, isTruncated: false) == nil) diff --git a/Tests/OpenClawKitTests/ChatCoreCompatibilityTests.swift b/Tests/OpenClawKitTests/ChatCoreCompatibilityTests.swift index f983944..def0d69 100644 --- a/Tests/OpenClawKitTests/ChatCoreCompatibilityTests.swift +++ b/Tests/OpenClawKitTests/ChatCoreCompatibilityTests.swift @@ -140,7 +140,7 @@ private func bootstrappedViewModel(_ transport: ScriptedEventTransport) async th return viewModel } -@Suite("Chat core 2026.3.0 compatibility") +@Suite("Chat core 2026.3.0 compatibility", .timeLimit(.minutes(1))) struct ChatCoreCompatibilityTests { // MARK: Legacy transport bridge diff --git a/Tests/OpenClawKitTests/ChatHapticsTests.swift b/Tests/OpenClawKitTests/ChatHapticsTests.swift index e855b67..2141011 100644 --- a/Tests/OpenClawKitTests/ChatHapticsTests.swift +++ b/Tests/OpenClawKitTests/ChatHapticsTests.swift @@ -92,6 +92,7 @@ private func sendHapticsTestMessage(_ viewModel: OpenClawChatViewModel) async { } } +@Suite(.timeLimit(.minutes(1))) struct ChatHapticsTests { @Test func `send acceptance fires message sent exactly once`() async throws { let (_, viewModel, recorder) = await makeHapticsViewModel(status: "started") diff --git a/Tests/OpenClawKitTests/ChatSessionSidebarPreviewsTests.swift b/Tests/OpenClawKitTests/ChatSessionSidebarPreviewsTests.swift index e41e7ce..8df8da2 100644 --- a/Tests/OpenClawKitTests/ChatSessionSidebarPreviewsTests.swift +++ b/Tests/OpenClawKitTests/ChatSessionSidebarPreviewsTests.swift @@ -63,6 +63,7 @@ private actor SidebarPreviewCache: OpenClawChatTranscriptCache { } @MainActor +@Suite(.timeLimit(.minutes(1))) struct ChatSessionSidebarPreviewsTests { @Test(arguments: [false, true]) func `changing Gateway owners never reuses an identical session preview`(oldLoadPending: Bool) async throws { diff --git a/Tests/OpenClawKitTests/ChatStreamReplayTests.swift b/Tests/OpenClawKitTests/ChatStreamReplayTests.swift index 76f302c..fda8bc5 100644 --- a/Tests/OpenClawKitTests/ChatStreamReplayTests.swift +++ b/Tests/OpenClawKitTests/ChatStreamReplayTests.swift @@ -372,6 +372,7 @@ Closing paragraph with unicode — dashes, émojis 🦀🚀, and a trailing line /// Covers streaming accumulation, provisional-final reconciliation against durable /// `session.message` rows, duplicate delivery, out-of-order arrival, and reconnect /// convergence. Tracking: #100196. +@Suite(.timeLimit(.minutes(1))) struct ChatStreamReplayTests { @Test func `live session message marker produces a visible transcript row`() async throws { let harness = try await StreamReplayHarness.bootstrapped() diff --git a/Tests/OpenClawKitTests/ChatViewModelAgentNavigationTests.swift b/Tests/OpenClawKitTests/ChatViewModelAgentNavigationTests.swift index 9855899..1cf40b0 100644 --- a/Tests/OpenClawKitTests/ChatViewModelAgentNavigationTests.swift +++ b/Tests/OpenClawKitTests/ChatViewModelAgentNavigationTests.swift @@ -250,6 +250,7 @@ private final class AgentNavigationFixture { } @MainActor +@Suite(.timeLimit(.minutes(1))) struct ChatViewModelAgentNavigationTests { private func globalSession(owner: String) -> OpenClawChatSessionEntry { var entry = OpenClawChatSessionEntry.placeholder(key: "global") diff --git a/Tests/OpenClawKitTests/ChatViewModelAttachmentTests.swift b/Tests/OpenClawKitTests/ChatViewModelAttachmentTests.swift index fb3666a..634172e 100644 --- a/Tests/OpenClawKitTests/ChatViewModelAttachmentTests.swift +++ b/Tests/OpenClawKitTests/ChatViewModelAttachmentTests.swift @@ -261,6 +261,7 @@ private func chatAttachmentDimensions(for data: Data) -> (width: Int, height: In return (width.intValue, height.intValue) } +@Suite(.timeLimit(.minutes(1))) struct ChatViewModelAttachmentTests { @Test func imageAttachmentsAreProcessedBeforeStaging() async throws { let imageData = try makeChatAttachmentJPEG(width: 3000, height: 4000) diff --git a/Tests/OpenClawKitTests/ChatViewModelOutboxSettingsTests.swift b/Tests/OpenClawKitTests/ChatViewModelOutboxSettingsTests.swift index ed6294d..97ebd96 100644 --- a/Tests/OpenClawKitTests/ChatViewModelOutboxSettingsTests.swift +++ b/Tests/OpenClawKitTests/ChatViewModelOutboxSettingsTests.swift @@ -16,6 +16,7 @@ private actor SettingsPatchCounter { } } +@Suite(.timeLimit(.minutes(1))) struct ChatViewModelOutboxSettingsTests { @Test func `background replay uses its command owned session settings`() async throws { let (store, _, databaseDirectory) = try makeOutboxStore() diff --git a/Tests/OpenClawKitTests/ChatViewModelOutboxTests.swift b/Tests/OpenClawKitTests/ChatViewModelOutboxTests.swift index 99d9453..956c40a 100644 --- a/Tests/OpenClawKitTests/ChatViewModelOutboxTests.swift +++ b/Tests/OpenClawKitTests/ChatViewModelOutboxTests.swift @@ -832,7 +832,7 @@ actor ScriptedOutbox: OpenClawChatCommandOutbox { } // Serialized: every case opens a real SQLite outbox (see ChatTranscriptCacheStoreTests). -@Suite(.serialized) +@Suite(.serialized, .timeLimit(.minutes(1))) struct ChatViewModelOutboxTests { @Test func `offline send queues durably and renders queued row`() async throws { let (store, _, databaseDirectory) = try makeOutboxStore() @@ -1714,7 +1714,7 @@ struct ChatViewModelOutboxTests { vm.messages.first { vm.outboxState(for: $0.id)?.isFailed == true }?.id }) await MainActor.run { vm.retryOutboxMessage(failedMessageID) } - try await waitUntil("retried command drained", timeoutSeconds: 30) { + try await waitUntil("retried command drained") { await store.loadCommands().isEmpty } #expect(await transport.state.sentIdempotencyKeys.count == 1) @@ -1856,7 +1856,7 @@ struct ChatViewModelOutboxTests { let messageID = try #require(await MainActor.run { vm.messages.last?.id }) await MainActor.run { vm.retryOutboxMessage(messageID) } - try await waitUntil("explicit retry drained", timeoutSeconds: 10) { + try await waitUntil("explicit retry drained") { await store.loadCommands().isEmpty } #expect(await transport.state.sentIdempotencyKeys == [preserved.id]) diff --git a/Tests/OpenClawKitTests/ChatViewModelSessionDeletionTests.swift b/Tests/OpenClawKitTests/ChatViewModelSessionDeletionTests.swift index cf78d4f..e27de06 100644 --- a/Tests/OpenClawKitTests/ChatViewModelSessionDeletionTests.swift +++ b/Tests/OpenClawKitTests/ChatViewModelSessionDeletionTests.swift @@ -54,6 +54,7 @@ private final class DeleteSessionTestTransport: @unchecked Sendable, OpenClawCha } @MainActor +@Suite(.timeLimit(.minutes(1))) struct ChatViewModelSessionDeletionTests { @Test func `deleting the active main session re-bootstraps in place`() async throws { let transport = DeleteSessionTestTransport() diff --git a/Tests/OpenClawKitTests/ChatViewModelTests.swift b/Tests/OpenClawKitTests/ChatViewModelTests.swift index 0894844..1a227ab 100644 --- a/Tests/OpenClawKitTests/ChatViewModelTests.swift +++ b/Tests/OpenClawKitTests/ChatViewModelTests.swift @@ -1667,6 +1667,7 @@ private actor SwarmCapabilityScript { } } +@Suite(.timeLimit(.minutes(1))) struct ChatViewModelTests { @Test func `legacy plan renders only when progress card store is unavailable`() async throws { let (_, vm) = await makeViewModel( @@ -3853,7 +3854,7 @@ struct ChatViewModelTests { await historyCalls.current() >= 2 } #expect(await MainActor.run { vm.pendingRunCount == 1 }) - try await waitUntil("post-send fallback keeps known run ownership", timeoutSeconds: 7.0) { + try await waitUntil("post-send fallback keeps known run ownership") { let historyCount = await historyCalls.current() let pendingRunCount = await MainActor.run { vm.pendingRunCount } return historyCount >= 3 && pendingRunCount == 1 @@ -10833,7 +10834,7 @@ struct ChatViewModelTests { await staleFallbackReleasedCount.current() == 1 } - try await waitUntil("later fallback still runs", timeoutSeconds: 7.0) { + try await waitUntil("later fallback still runs") { await mainHistoryCount.current() >= 5 } try await waitUntil("later fallback applies assistant reply") { @@ -14032,7 +14033,7 @@ struct ChatViewModelTests { } } -@Suite(.serialized) +@Suite(.serialized, .timeLimit(.minutes(1))) struct ChatViewModelSessionManagementTests { @Test @MainActor func `session list organizer orders pinned first with key tiebreak`() { let organized = OpenClawChatSessionListOrganizer.organize([ diff --git a/Tests/OpenClawKitTests/ChatViewModelTranscriptCacheTests.swift b/Tests/OpenClawKitTests/ChatViewModelTranscriptCacheTests.swift index 3c02aee..c3ec4b1 100644 --- a/Tests/OpenClawKitTests/ChatViewModelTranscriptCacheTests.swift +++ b/Tests/OpenClawKitTests/ChatViewModelTranscriptCacheTests.swift @@ -177,6 +177,7 @@ private func makeViewModel( return vm } +@Suite(.timeLimit(.minutes(1))) struct ChatViewModelTranscriptCacheTests { @Test func `cold open paints cached transcript then live history replaces it`() async throws { let cache = TestTranscriptCache( diff --git a/Tests/OpenClawKitTests/ChatViewModelUnreadTests.swift b/Tests/OpenClawKitTests/ChatViewModelUnreadTests.swift index 444fb9d..a60baea 100644 --- a/Tests/OpenClawKitTests/ChatViewModelUnreadTests.swift +++ b/Tests/OpenClawKitTests/ChatViewModelUnreadTests.swift @@ -204,7 +204,7 @@ extension UnreadTestTransportState { } } -@Suite(.serialized) +@Suite(.serialized, .timeLimit(.minutes(1))) @MainActor struct ChatViewModelUnreadTests { @Test func `successful activation clears unread once`() async throws { diff --git a/Tests/OpenClawKitTests/DiscordChannelAdapterTests.swift b/Tests/OpenClawKitTests/DiscordChannelAdapterTests.swift index 38b6ccc..5eff39e 100644 --- a/Tests/OpenClawKitTests/DiscordChannelAdapterTests.swift +++ b/Tests/OpenClawKitTests/DiscordChannelAdapterTests.swift @@ -5,7 +5,7 @@ import FoundationNetworking import Testing @testable import OpenClawKit -@Suite("Discord channel adapter") +@Suite("Discord channel adapter", .timeLimit(.minutes(1))) struct DiscordChannelAdapterTests { actor InboundCollector { private(set) var messages: [InboundMessage] = [] diff --git a/Tests/OpenClawKitTests/DiscordGatewayAdapterTests.swift b/Tests/OpenClawKitTests/DiscordGatewayAdapterTests.swift index 5e6692c..00d996e 100644 --- a/Tests/OpenClawKitTests/DiscordGatewayAdapterTests.swift +++ b/Tests/OpenClawKitTests/DiscordGatewayAdapterTests.swift @@ -7,7 +7,7 @@ import OpenClawCore import OpenClawProtocol import Testing -@Suite("Discord gateway adapter") +@Suite("Discord gateway adapter", .timeLimit(.minutes(1))) struct DiscordGatewayAdapterTests { static let hello = #"{"op":10,"d":{"heartbeat_interval":45000}}"# static let ready = #"{"op":0,"s":1,"t":"READY","d":{"session_id":"sess-1","resume_gateway_url":"wss://resume.example","user":{"id":"bot-id"}}}"# @@ -175,7 +175,7 @@ struct DiscordGatewayAdapterTests { try await waitUntil("other channel delivered while the slow turn runs") { await collector.messages.contains { $0.peerID == "fast" } } - try await waitUntil("at least two heartbeats acknowledged", timeoutSeconds: 10) { + try await waitUntil("at least two heartbeats acknowledged") { await socket.sentFrames().filter { (jsonObject($0)["op"] as? Int) == 1 }.count >= 2 } // Give a missed ACK time to trip the zombie check (next beat after the first). diff --git a/Tests/OpenClawKitTests/IMAPMailboxWatcherTests.swift b/Tests/OpenClawKitTests/IMAPMailboxWatcherTests.swift index b960237..4de5612 100644 --- a/Tests/OpenClawKitTests/IMAPMailboxWatcherTests.swift +++ b/Tests/OpenClawKitTests/IMAPMailboxWatcherTests.swift @@ -122,7 +122,7 @@ actor FakeIMAPServer: IMAPTransport { } } -@Suite("IMAP mailbox watcher") +@Suite("IMAP mailbox watcher", .timeLimit(.minutes(1))) struct IMAPMailboxWatcherTests { actor Turns { private(set) var turns: [IMAPHookTurn] = [] diff --git a/Tests/OpenClawKitTests/IMsgRPCTransportTests.swift b/Tests/OpenClawKitTests/IMsgRPCTransportTests.swift index bd778c9..7c821cf 100644 --- a/Tests/OpenClawKitTests/IMsgRPCTransportTests.swift +++ b/Tests/OpenClawKitTests/IMsgRPCTransportTests.swift @@ -64,7 +64,7 @@ struct RPCParams: @unchecked Sendable { } } -@Suite("imsg JSON-RPC transport") +@Suite("imsg JSON-RPC transport", .timeLimit(.minutes(1))) struct IMsgRPCTransportTests { static func standardResponder(_ method: String, _: [String: Any]) -> String? { switch method { @@ -231,7 +231,7 @@ struct IMsgRPCTransportTests { } } -@Suite("iMessage private-API actions and catch-up") +@Suite("iMessage private-API actions and catch-up", .timeLimit(.minutes(1))) struct IMessagePrivateActionsTests { @Test func actionsUseChatGUIDFromInboundAndTypingDisablesAfterFailure() async throws { diff --git a/Tests/OpenClawKitTests/SMSChannelAdapterTests.swift b/Tests/OpenClawKitTests/SMSChannelAdapterTests.swift index eb96eef..78dfa65 100644 --- a/Tests/OpenClawKitTests/SMSChannelAdapterTests.swift +++ b/Tests/OpenClawKitTests/SMSChannelAdapterTests.swift @@ -7,7 +7,7 @@ import OpenClawCore import OpenClawProtocol import Testing -@Suite("SMS (Twilio) channel adapter") +@Suite("SMS (Twilio) channel adapter", .timeLimit(.minutes(1))) struct SMSChannelAdapterTests { actor DiagnosticsCollector { var events: [RuntimeDiagnosticEvent] = [] diff --git a/Tests/OpenClawKitTests/SignalAdapterRefreshTests.swift b/Tests/OpenClawKitTests/SignalAdapterRefreshTests.swift index 451acbe..43e12e4 100644 --- a/Tests/OpenClawKitTests/SignalAdapterRefreshTests.swift +++ b/Tests/OpenClawKitTests/SignalAdapterRefreshTests.swift @@ -7,7 +7,7 @@ import OpenClawCore import OpenClawProtocol import Testing -@Suite("Signal adapter 2026.9.6 refresh") +@Suite("Signal adapter 2026.9.6 refresh", .timeLimit(.minutes(1))) struct SignalAdapterRefreshTests { struct ScriptedLines: ChannelLineStreaming { let lines: [String] diff --git a/Tests/OpenClawKitTests/SignalChannelAdapterTests.swift b/Tests/OpenClawKitTests/SignalChannelAdapterTests.swift index 60d8a4d..2d3767d 100644 --- a/Tests/OpenClawKitTests/SignalChannelAdapterTests.swift +++ b/Tests/OpenClawKitTests/SignalChannelAdapterTests.swift @@ -5,7 +5,7 @@ import FoundationNetworking import Testing @testable import OpenClawKit -@Suite("Signal channel adapter") +@Suite("Signal channel adapter", .timeLimit(.minutes(1))) struct SignalChannelAdapterTests { /// Container WebSocket upgrades fail, so these tests exercise the GET /v1/receive fallback. struct NoWebSocketConnector: ChannelWebSocketConnecting { diff --git a/Tests/OpenClawKitTests/SlackAdapterRefreshTests.swift b/Tests/OpenClawKitTests/SlackAdapterRefreshTests.swift index 0b76c96..aa52451 100644 --- a/Tests/OpenClawKitTests/SlackAdapterRefreshTests.swift +++ b/Tests/OpenClawKitTests/SlackAdapterRefreshTests.swift @@ -6,7 +6,7 @@ import FoundationNetworking import OpenClawCore import Testing -@Suite("Slack adapter 2026.9.6 refresh") +@Suite("Slack adapter 2026.9.6 refresh", .timeLimit(.minutes(1))) struct SlackAdapterRefreshTests { static let auth = #"{"ok":true,"user_id":"UBOT","bot_id":"B1","user":"claw","team":"acme"}"# diff --git a/Tests/OpenClawKitTests/SlackChannelAdapterTests.swift b/Tests/OpenClawKitTests/SlackChannelAdapterTests.swift index 1ac7934..2d35841 100644 --- a/Tests/OpenClawKitTests/SlackChannelAdapterTests.swift +++ b/Tests/OpenClawKitTests/SlackChannelAdapterTests.swift @@ -5,7 +5,7 @@ import FoundationNetworking import Testing @testable import OpenClawKit -@Suite("Slack channel adapter") +@Suite("Slack channel adapter", .timeLimit(.minutes(1))) struct SlackChannelAdapterTests { actor InboundCollector { private(set) var messages: [InboundMessage] = [] diff --git a/Tests/OpenClawKitTests/TelegramAdapterRefreshTests.swift b/Tests/OpenClawKitTests/TelegramAdapterRefreshTests.swift index c5a377f..fcc443b 100644 --- a/Tests/OpenClawKitTests/TelegramAdapterRefreshTests.swift +++ b/Tests/OpenClawKitTests/TelegramAdapterRefreshTests.swift @@ -7,7 +7,7 @@ import OpenClawCore import OpenClawProtocol import Testing -@Suite("Telegram adapter 2026.9.6 refresh") +@Suite("Telegram adapter 2026.9.6 refresh", .timeLimit(.minutes(1))) struct TelegramAdapterRefreshTests { actor MemoryOffsetStore: TelegramUpdateOffsetStore { var value: Int64? diff --git a/Tests/OpenClawKitTests/TelegramChannelAdapterTests.swift b/Tests/OpenClawKitTests/TelegramChannelAdapterTests.swift index 81e5e58..67c4c29 100644 --- a/Tests/OpenClawKitTests/TelegramChannelAdapterTests.swift +++ b/Tests/OpenClawKitTests/TelegramChannelAdapterTests.swift @@ -5,7 +5,7 @@ import FoundationNetworking import Testing @testable import OpenClawKit -@Suite("Telegram channel adapter") +@Suite("Telegram channel adapter", .timeLimit(.minutes(1))) struct TelegramChannelAdapterTests { actor InboundCollector { private(set) var messages: [InboundMessage] = [] diff --git a/Tests/OpenClawKitTests/TestAsyncHelpers.swift b/Tests/OpenClawKitTests/TestAsyncHelpers.swift index ac74423..32cedcb 100644 --- a/Tests/OpenClawKitTests/TestAsyncHelpers.swift +++ b/Tests/OpenClawKitTests/TestAsyncHelpers.swift @@ -1,4 +1,4 @@ -import Foundation +import Testing struct AsyncWaitTimeoutError: Error, CustomStringConvertible { let label: String @@ -7,21 +7,22 @@ struct AsyncWaitTimeoutError: Error, CustomStringConvertible { } } +/// Polls `condition` until it holds. +/// +/// There is no wall-clock deadline: a saturated test pool must only slow the wait down. Every suite +/// that calls this carries a `.timeLimit`, whose cancellation ends the wait with +/// ``AsyncWaitTimeoutError``. func waitUntil( _ label: String, - // Polling returns as soon as the condition holds; the generous ceiling - // only matters under full-suite parallel load, where 3s flaked on CI. - timeoutSeconds: Double = 15.0, pollMs: UInt64 = 10, - now: @Sendable () -> Date = { Date() }, _ condition: @escaping @Sendable () async -> Bool) async throws { - let deadline = now().addingTimeInterval(timeoutSeconds) - while now() < deadline { + while !Task.isCancelled { if await condition() { return } - try await Task.sleep(nanoseconds: pollMs * 1_000_000) + try? await Task.sleep(nanoseconds: pollMs * 1_000_000) } - // Completion can arrive during the final suspension, before this waiter resumes. - if await condition() { return } - throw AsyncWaitTimeoutError(label: label) + // Swift Testing drops errors thrown after a time-limit cancellation, so record which wait hung. + let timeout = AsyncWaitTimeoutError(label: label) + Issue.record(timeout) + throw timeout } diff --git a/Tests/OpenClawKitTests/VoiceNoteRecorderTests.swift b/Tests/OpenClawKitTests/VoiceNoteRecorderTests.swift index bd4055f..0e164ab 100644 --- a/Tests/OpenClawKitTests/VoiceNoteRecorderTests.swift +++ b/Tests/OpenClawKitTests/VoiceNoteRecorderTests.swift @@ -48,6 +48,7 @@ private final class FakeVoiceNoteAudioCapture: VoiceNoteAudioCapture { } } +@Suite(.timeLimit(.minutes(1))) struct VoiceNoteRecorderTests { @MainActor @Test func startAndFinishProduceRecordingWithDuration() async throws { From d5620d18782ebdd48d4cdff335e50331bcaa88cb Mon Sep 17 00:00:00 2001 From: MarcoDotIO Date: Wed, 30 Sep 2026 14:23:59 -0400 Subject: [PATCH 07/11] test(e2e): wait without a wall-clock deadline in the channel and reconnect tests ChannelAdaptersE2ETests.waitFor gave up after 15 s and only recorded an unlabelled expectation failure. reconnectFailureSchedulesAnotherAttempt polled against a 10 s deadline. A pool stall can outlast either one. waitFor now takes a label, polls until the condition holds or the test is cancelled, and records a WaitTimeout issue before throwing it. The reconnect loop polls until cancelled. The suite and the reconnect test each have a one-minute time limit. Co-Authored-By: Claude Opus 5.5 --- .../ChannelAdaptersE2ETests.swift | 28 +++++++++++++------ .../GatewayTransportE2ETests.swift | 9 +++--- 2 files changed, 25 insertions(+), 12 deletions(-) diff --git a/Tests/OpenClawKitE2ETests/ChannelAdaptersE2ETests.swift b/Tests/OpenClawKitE2ETests/ChannelAdaptersE2ETests.swift index b3ac75c..bd6a400 100644 --- a/Tests/OpenClawKitE2ETests/ChannelAdaptersE2ETests.swift +++ b/Tests/OpenClawKitE2ETests/ChannelAdaptersE2ETests.swift @@ -5,7 +5,7 @@ import FoundationNetworking import Testing @testable import OpenClawKit -@Suite("Channel adapters E2E") +@Suite("Channel adapters E2E", .timeLimit(.minutes(1))) struct ChannelAdaptersE2ETests { actor TelegramInboundCollector { private(set) var messages: [InboundMessage] = [] @@ -115,14 +115,26 @@ struct ChannelAdaptersE2ETests { #expect(sent.first?.text == "pong") } + struct WaitTimeout: Error, CustomStringConvertible { + let label: String + var description: String { + "Timeout waiting for: \(self.label)" + } + } + /// Polls a condition instead of sleeping a fixed interval (fixed sleeps flaked under load). - static func waitFor(timeoutSeconds: Double = 15, _ condition: @escaping @Sendable () async -> Bool) async throws { - let deadline = Date().addingTimeInterval(timeoutSeconds) - while Date() < deadline { + /// + /// There is no wall-clock deadline either: a saturated test pool must only slow the wait down. The + /// suite's `.timeLimit` cancels a real hang, which ends the wait with ``WaitTimeout``. + static func waitFor(_ label: String, _ condition: @escaping @Sendable () async -> Bool) async throws { + while !Task.isCancelled { if await condition() { return } - try await Task.sleep(nanoseconds: 10_000_000) + try? await Task.sleep(nanoseconds: 10_000_000) } - #expect(await condition(), "timed out waiting for condition") + // Swift Testing drops errors thrown after a time-limit cancellation, so record which wait hung. + let timeout = WaitTimeout(label: label) + Issue.record(timeout) + throw timeout } @Test @@ -150,7 +162,7 @@ struct ChannelAdaptersE2ETests { await collector1.append(inbound) } try await adapter1.start() - try await Self.waitFor { await !collector1.snapshot().isEmpty } + try await Self.waitFor("first run delivered") { await !collector1.snapshot().isEmpty } await adapter1.stop() let collector2 = TelegramInboundCollector() @@ -164,7 +176,7 @@ struct ChannelAdaptersE2ETests { await collector2.append(inbound) } try await adapter2.start() - try await Self.waitFor { await !collector2.snapshot().isEmpty } + try await Self.waitFor("second run delivered") { await !collector2.snapshot().isEmpty } await adapter2.stop() let firstRun = await collector1.snapshot() diff --git a/Tests/OpenClawKitE2ETests/GatewayTransportE2ETests.swift b/Tests/OpenClawKitE2ETests/GatewayTransportE2ETests.swift index fa2e70b..649aabc 100644 --- a/Tests/OpenClawKitE2ETests/GatewayTransportE2ETests.swift +++ b/Tests/OpenClawKitE2ETests/GatewayTransportE2ETests.swift @@ -250,7 +250,9 @@ struct GatewayTransportE2ETests { #expect(counter.get() == baseline) } - @Test + /// No wall-clock bound on the reconnect wait: a saturated test pool can stall the run for seconds. The + /// time limit ends the wait if the client stops reconnecting. + @Test(.timeLimit(.minutes(1))) func reconnectFailureSchedulesAnotherAttempt() async throws { let counter = Counter() let client = GatewayClient( @@ -272,9 +274,8 @@ struct GatewayTransportE2ETests { try await client.connect(to: GatewayEndpoint(url: URL(string: "ws://127.0.0.1:18789")!)) // Wait for the third socket (stale tick -> reconnect -> connect failure -> another attempt) // instead of a fixed 220 ms, which slower CI runners can overrun. - let deadline = Date().addingTimeInterval(10) - while counter.get() <= 2, Date() < deadline { - try await Task.sleep(nanoseconds: 10_000_000) + while counter.get() <= 2, !Task.isCancelled { + try? await Task.sleep(nanoseconds: 10_000_000) } #expect(counter.get() > 2) await client.disconnect() From 45849ebe33d9c7eacbfd52483e94e96f1c2f7315 Mon Sep 17 00:00:00 2001 From: MarcoDotIO Date: Wed, 30 Sep 2026 15:14:51 -0400 Subject: [PATCH 08/11] test: drop the 1 s deadline from the chat UI view-model waits OpenClawChatUITests had its own waitUntil that gave up after 1 s and returned false. On the macOS CI job for this PR, a pool stall of about 10 s ran past it: both view-model tests failed after about 12 s, and the bootstrap expectations that followed failed with them. The tests now call the shared deadline-free waitUntil with a label for each wait, and the suite has a one-minute time limit. The private helper is removed. Co-Authored-By: Claude Opus 5.5 --- .../OpenClawChatUITests.swift | 35 +++++-------------- 1 file changed, 8 insertions(+), 27 deletions(-) diff --git a/Tests/OpenClawKitTests/OpenClawChatUITests.swift b/Tests/OpenClawKitTests/OpenClawChatUITests.swift index 114fba9..26ffadb 100644 --- a/Tests/OpenClawKitTests/OpenClawChatUITests.swift +++ b/Tests/OpenClawKitTests/OpenClawChatUITests.swift @@ -2,7 +2,7 @@ import Foundation import Testing @testable import OpenClawChatUI -@Suite("OpenClaw chat UI") +@Suite("OpenClaw chat UI", .timeLimit(.minutes(1))) struct OpenClawChatUITests { @Test func chatPayloadDecodingSupportsLegacyStringContentAndUsageFallback() throws { @@ -77,7 +77,7 @@ struct OpenClawChatUITests { } @Test - func chatViewModelBootstrapLoadsHistoryModelsAndSessions() async { + func chatViewModelBootstrapLoadsHistoryModelsAndSessions() async throws { let transport = MockChatTransport( historyBySession: [ "main": OpenClawChatHistoryPayload( @@ -152,7 +152,7 @@ struct OpenClawChatUITests { viewModel.load() } - let loaded = await Self.waitUntil { + try await waitUntil("bootstrap loaded history, models and sessions") { await MainActor.run { !viewModel.isLoading && viewModel.healthOK && @@ -161,7 +161,6 @@ struct OpenClawChatUITests { } } - #expect(loaded) await MainActor.run { #expect(viewModel.thinkingLevel == "high") #expect(viewModel.messages.first?.content.first?.text == "Please help.") @@ -175,7 +174,7 @@ struct OpenClawChatUITests { } @Test - func chatViewModelAppliesAgentStreamAndPendingToolEvents() async { + func chatViewModelAppliesAgentStreamAndPendingToolEvents() async throws { let transport = MockChatTransport( historyBySession: [ "main": OpenClawChatHistoryPayload( @@ -226,10 +225,9 @@ struct OpenClawChatUITests { viewModel.load() } - let bootstrapped = await Self.waitUntil { + try await waitUntil("bootstrap") { await MainActor.run { !viewModel.isLoading && viewModel.healthOK } } - #expect(bootstrapped) transport.emit( .agent( @@ -243,10 +241,9 @@ struct OpenClawChatUITests { ) ) - let sawStreamingText = await Self.waitUntil { + try await waitUntil("streaming text applied") { await MainActor.run { viewModel.streamingAssistantText == "streaming reply" } } - #expect(sawStreamingText) transport.emit( .agent( @@ -265,10 +262,9 @@ struct OpenClawChatUITests { ) ) - let sawPendingTool = await Self.waitUntil { + try await waitUntil("pending tool call applied") { await MainActor.run { viewModel.pendingToolCalls.count == 1 } } - #expect(sawPendingTool) await MainActor.run { #expect(viewModel.pendingToolCalls.first?.name == "browser") #expect(viewModel.pendingToolCalls.first?.args?.dictionaryValue?["url"] == AnyCodable("https://docs.openclaw.ai")) @@ -290,24 +286,9 @@ struct OpenClawChatUITests { ) ) - let clearedTool = await Self.waitUntil { + try await waitUntil("pending tool call cleared") { await MainActor.run { viewModel.pendingToolCalls.isEmpty } } - #expect(clearedTool) - } - - private static func waitUntil( - timeoutMs: Int = 1_000, - condition: @escaping @Sendable () async -> Bool - ) async -> Bool { - let deadline = Date().addingTimeInterval(Double(timeoutMs) / 1000) - while Date() < deadline { - if await condition() { - return true - } - try? await Task.sleep(nanoseconds: 10_000_000) - } - return await condition() } } From 76615995a3e6ca3a7ed7a8e9d0fc8e8bf999226e Mon Sep 17 00:00:00 2001 From: MarcoDotIO Date: Wed, 30 Sep 2026 15:17:06 -0400 Subject: [PATCH 09/11] test: drop the wall-clock waits left in the provider and realtime relay tests - ProviderStreamingCancellationTests polls with the shared deadline-free waitUntil under a one-minute suite time limit. Both request timeouts are now an hour, so a cancellation that never reaches the request fails as a hang instead of passing once the 60 s request timeout fires. - ModelRouterStreamingFallbackTests waits for the stream termination (and the two stream/generate starts) with waitUntil, under a suite time limit. - RealtimeTalkRelaySession._test_waitForStartupCancelled() drops its timeoutSeconds parameter. A closed session answers before any timer is armed; a zero timeout makes one that is not closed fail at once rather than after a 1 s wall-clock wait. Co-Authored-By: Claude Opus 5.5 --- .../RealtimeTalkRelaySession.swift | 8 ++++--- .../ModelRouterStreamingFallbackTests.swift | 15 ++++--------- .../ProviderStreamingCancellationTests.swift | 22 ++++++------------- .../RealtimeTalkRelaySessionTests.swift | 4 ++-- 4 files changed, 18 insertions(+), 31 deletions(-) diff --git a/Sources/OpenClawKit/RealtimeTalkRelaySession.swift b/Sources/OpenClawKit/RealtimeTalkRelaySession.swift index 3d07bba..a4eefc5 100644 --- a/Sources/OpenClawKit/RealtimeTalkRelaySession.swift +++ b/Sources/OpenClawKit/RealtimeTalkRelaySession.swift @@ -1812,10 +1812,12 @@ extension RealtimeTalkRelaySession { await self.handleEventStreamEnded(lifecycleGeneration: self.lifecycleGeneration) } - // Package tests observe startup cancellation without waiting out the timeout. - func _test_waitForStartupCancelled(timeoutSeconds: Int) async -> Bool { + // Package tests observe startup cancellation. A closed session answers before any timer is + // armed; the zero timeout makes one that is not closed report `.failed` at once rather than + // after a wall-clock wait. + func _test_waitForStartupCancelled() async -> Bool { if case .cancelled = await self.waitForStartupResult( - timeoutSeconds: timeoutSeconds, + timeoutSeconds: 0, lifecycleGeneration: self.lifecycleGeneration) { return true diff --git a/Tests/OpenClawKitTests/ModelRouterStreamingFallbackTests.swift b/Tests/OpenClawKitTests/ModelRouterStreamingFallbackTests.swift index 299632b..608cc49 100644 --- a/Tests/OpenClawKitTests/ModelRouterStreamingFallbackTests.swift +++ b/Tests/OpenClawKitTests/ModelRouterStreamingFallbackTests.swift @@ -96,7 +96,7 @@ private actor DiagnosticRecorder { } } -@Suite("Model router streaming fallback and cancellation") +@Suite("Model router streaming fallback and cancellation", .timeLimit(.minutes(1))) struct ModelRouterStreamingFallbackTests { private static func collect(_ stream: AsyncThrowingStream) async throws -> [ModelStreamChunk] { var chunks: [ModelStreamChunk] = [] @@ -246,9 +246,7 @@ struct ModelRouterStreamingFallbackTests { ) ) } - while await primary.generateCalls == 0 { - try await Task.sleep(nanoseconds: 5_000_000) - } + try await waitUntil("primary generate started") { await primary.generateCalls > 0 } task.cancel() await #expect(throws: CancellationError.self) { _ = try await task.value @@ -285,15 +283,10 @@ struct ModelRouterStreamingFallbackTests { let consumer = Task { for try await _ in stream {} } - while await primary.streamCalls == 0 { - try await Task.sleep(nanoseconds: 5_000_000) - } + try await waitUntil("primary stream started") { await primary.streamCalls > 0 } consumer.cancel() _ = await consumer.result - let deadline = Date().addingTimeInterval(5) - while await primary.streamTerminations == 0, Date() < deadline { - try await Task.sleep(nanoseconds: 5_000_000) - } + try await waitUntil("primary stream terminated") { await primary.streamTerminations > 0 } #expect(await primary.streamTerminations == 1) #expect(await fallback.streamCalls == 0) #expect(await store.snapshot().usageStats["primary:a"]?.cooldownUntil == nil) diff --git a/Tests/OpenClawKitTests/ProviderStreamingCancellationTests.swift b/Tests/OpenClawKitTests/ProviderStreamingCancellationTests.swift index d5f14c1..ff4c068 100644 --- a/Tests/OpenClawKitTests/ProviderStreamingCancellationTests.swift +++ b/Tests/OpenClawKitTests/ProviderStreamingCancellationTests.swift @@ -40,23 +40,15 @@ private final class HangingHeadURLProtocol: URLProtocol, @unchecked Sendable { /// Cancelling a streaming consumer before the response head arrives must cancel the HTTP request /// promptly, not after the request timeout. -@Suite("Provider streaming cancellation", .serialized) +@Suite("Provider streaming cancellation", .serialized, .timeLimit(.minutes(1))) struct ProviderStreamingCancellationTests { - private static func waitUntil(seconds: Double, _ condition: () -> Bool) async throws -> Bool { - let deadline = Date().addingTimeInterval(seconds) - while Date() < deadline { - if condition() { - return true - } - try await Task.sleep(nanoseconds: 20_000_000) - } - return condition() - } - @Test func cancellingBeforeResponseHeadCancelsTheRequest() async throws { let configuration = URLSessionConfiguration.ephemeral configuration.protocolClasses = [HangingHeadURLProtocol.self] + // This timeout and the policy's below are past the suite's time limit, so a cancellation that + // never reaches the request fails as a hang instead of passing once the request times out. + configuration.timeoutIntervalForRequest = 3_600 let session = URLSession(configuration: configuration) let provider = ProviderServiceOpenAIModelProvider( id: "hanging", @@ -66,14 +58,14 @@ struct ProviderStreamingCancellationTests { let startedBefore = HangingHeadURLProtocol.started let stoppedBefore = HangingHeadURLProtocol.stopped let stream = await provider.generateStream( - ModelGenerationRequest(sessionKey: "s", prompt: "hi", policy: ModelGenerationPolicy(requestTimeoutMs: 60_000)) + ModelGenerationRequest(sessionKey: "s", prompt: "hi", policy: ModelGenerationPolicy(requestTimeoutMs: 3_600_000)) ) let consumer = Task { for try await _ in stream {} } - #expect(try await Self.waitUntil(seconds: 5) { HangingHeadURLProtocol.started > startedBefore }) + try await waitUntil("request started") { HangingHeadURLProtocol.started > startedBefore } consumer.cancel() - #expect(try await Self.waitUntil(seconds: 5) { HangingHeadURLProtocol.stopped > stoppedBefore }) + try await waitUntil("request stopped after cancellation") { HangingHeadURLProtocol.stopped > stoppedBefore } session.invalidateAndCancel() } } diff --git a/Tests/OpenClawKitTests/RealtimeTalkRelaySessionTests.swift b/Tests/OpenClawKitTests/RealtimeTalkRelaySessionTests.swift index c55f655..d8969a3 100644 --- a/Tests/OpenClawKitTests/RealtimeTalkRelaySessionTests.swift +++ b/Tests/OpenClawKitTests/RealtimeTalkRelaySessionTests.swift @@ -454,7 +454,7 @@ struct RealtimeTalkRelaySessionTests { #expect(audioCapture.stopCount == 1) } - @Test("closed relay does not wait for startup ready") + @Test("closed relay does not wait for startup ready", .timeLimit(.minutes(1))) func closedRelayDoesNotWaitForReady() async { let session = RealtimeTalkRelaySession( transport: unusedRealtimeRelayTransport(), @@ -466,7 +466,7 @@ struct RealtimeTalkRelaySessionTests { session.stop() - #expect(await session._test_waitForStartupCancelled(timeoutSeconds: 1)) + #expect(await session._test_waitForStartupCancelled()) } @Test("stop during event subscription prevents relay creation") From 3b3466ec62ea029bf766072f809cc90d5f26536f Mon Sep 17 00:00:00 2001 From: MarcoDotIO Date: Wed, 30 Sep 2026 15:17:07 -0400 Subject: [PATCH 10/11] test(linux): wait without a wall-clock deadline in the runtime tests - A shared TestAsyncHelpers.swift replaces AgentLoopHardeningTests' waitUntil (500 x 10 ms, recorded an unlabelled issue and returned). It polls until the test is cancelled, then records and throws AsyncWaitTimeoutError(label:). - GatewayServerTestHarness.collect (5 s) and Recorder.waitFor (5 s) take a label and wait until the time limit. The one deliberate negative wait now uses frames(_:arrivingWithinMs:), which returns what arrived; the old helper returned [] whenever its timer won, so that assertion could never fail. - ChannelAdaptersLinuxSmokeTests.poll (15 s), the SIWC loopback-page wait (10 s), the automation run waits (10 s) and the MCP list_changed waits (3 s + 2 s) use waitUntil. - addingAJobWakesTheSleepingLoop pushes the scheduler's idle cap to an hour through the new DEBUG hook CronScheduler._test_setMaximumSleepSeconds, so a missed wake hangs instead of racing the 60 s cap against the time limit. - The MCP list_changed test uses an hour request timeout and drops its "< 1.5 s" elapsed assertion: a stall behind another request now hangs. - .timeLimit(.minutes(1)) is on the nine suites that reach these waits and had no limit. Co-Authored-By: Claude Opus 5.5 --- Sources/OpenClawCore/CronScheduler.swift | 16 +++++- .../AgentLoopHardeningTests.swift | 12 +--- .../AutomationHardeningTests.swift | 25 ++++---- .../ChannelAdaptersLinuxSmokeTests.swift | 13 +---- .../GatewayApprovalEventShapeTests.swift | 4 +- .../GatewayEventStreamTests.swift | 20 ++++--- .../GatewayNodePresenceTests.swift | 6 +- .../GatewayScopeAuthorizationTests.swift | 6 +- .../GatewayServerTestHarness.swift | 57 +++++++++++-------- .../GatewaySessionOrganizationTests.swift | 4 +- .../GatewayWireShapeTests.swift | 4 +- .../MCPHardeningTests.swift | 19 ++----- .../SessionBranchGatewayMethodsTests.swift | 4 +- .../SignInWithChatGPTSessionTests.swift | 7 +-- .../SubagentRuntimeHardeningTests.swift | 2 +- .../TestAsyncHelpers.swift | 33 +++++++++++ 16 files changed, 129 insertions(+), 103 deletions(-) create mode 100644 Tests/OpenClawLinuxRuntimeTests/TestAsyncHelpers.swift diff --git a/Sources/OpenClawCore/CronScheduler.swift b/Sources/OpenClawCore/CronScheduler.swift index c0a8d11..742363c 100644 --- a/Sources/OpenClawCore/CronScheduler.swift +++ b/Sources/OpenClawCore/CronScheduler.swift @@ -241,6 +241,8 @@ public actor CronScheduler { private var changeHandlers: [@Sendable (CronChangedHookEvent) async -> Void] = [] private var loop: Task? private var sleeper: Task? + /// Longest sleep between checks when no job is due sooner. + private var maximumSleepSeconds: Double = 60 private var running: Set = [] /// Creates an empty in-memory scheduler. @@ -556,7 +558,7 @@ public actor CronScheduler { // MARK: - Internals private func secondsUntilNextWake() -> Double { - guard let next = self.nextWakeDate else { return 60 } + guard let next = self.nextWakeDate else { return self.maximumSleepSeconds } return next.timeIntervalSince(self.now()) } @@ -564,7 +566,7 @@ public actor CronScheduler { /// wake the loop early. The delay is computed and the sleeper installed in one actor turn, so a job /// change that lands in between can never be missed. private func sleepUntilNextWake() async { - let nanoseconds = UInt64(max(0.05, min(60, self.secondsUntilNextWake())) * 1_000_000_000) + let nanoseconds = UInt64(max(0.05, min(self.maximumSleepSeconds, self.secondsUntilNextWake())) * 1_000_000_000) let sleeper = Task { try? await Task.sleep(nanoseconds: nanoseconds) } self.sleeper = sleeper await sleeper.value @@ -704,3 +706,13 @@ public actor CronScheduler { } } } + +#if DEBUG +extension CronScheduler { + // Package tests push the idle cap past their time limit, so a job change that fails to wake the + // sleeping loop shows up as a hang instead of a run one cap later. + func _test_setMaximumSleepSeconds(_ seconds: Double) { + self.maximumSleepSeconds = seconds + } +} +#endif diff --git a/Tests/OpenClawLinuxRuntimeTests/AgentLoopHardeningTests.swift b/Tests/OpenClawLinuxRuntimeTests/AgentLoopHardeningTests.swift index 01f4342..8f04ae0 100644 --- a/Tests/OpenClawLinuxRuntimeTests/AgentLoopHardeningTests.swift +++ b/Tests/OpenClawLinuxRuntimeTests/AgentLoopHardeningTests.swift @@ -156,14 +156,6 @@ actor ScriptedStreamProvider: ModelProvider { } } -func waitUntil(_ condition: @Sendable () async -> Bool) async throws { - for _ in 0..<500 { - if await condition() { return } - try await Task.sleep(nanoseconds: 10_000_000) - } - Issue.record("condition not met in time") -} - func temporarySessionStore(_ label: String) -> SessionStore { SessionStore(fileURL: FileManager.default.temporaryDirectory.appendingPathComponent("\(label)-\(UUID().uuidString)/sessions.json")) } @@ -203,7 +195,7 @@ struct AgentLoopHardeningTests { transcriptStore: InMemorySessionTranscriptStore() ) let runID = await runtime.start(AgentRunRequest(runID: "pair-1", sessionKey: "pair", prompt: "go"), streaming: false) - try await waitUntil { await log.contains("slow-started") } + try await waitUntil("slow tool started") { await log.contains("slow-started") } #expect(await runtime.abort(runID: runID)) #expect(await runtime.wait(runID: runID)?.status == "error") @@ -315,7 +307,7 @@ struct AgentLoopHardeningTests { transcriptStore: InMemorySessionTranscriptStore() ) let runID = await runtime.start(AgentRunRequest(sessionKey: "stream-abort", prompt: "look"), streaming: true) - try await waitUntil { await log.contains("slow-started") } + try await waitUntil("slow tool started") { await log.contains("slow-started") } await runtime.abort(runID: runID) _ = await runtime.wait(runID: runID) let history = try await runtime.history(sessionKey: "stream-abort") diff --git a/Tests/OpenClawLinuxRuntimeTests/AutomationHardeningTests.swift b/Tests/OpenClawLinuxRuntimeTests/AutomationHardeningTests.swift index 935331b..76bad36 100644 --- a/Tests/OpenClawLinuxRuntimeTests/AutomationHardeningTests.swift +++ b/Tests/OpenClawLinuxRuntimeTests/AutomationHardeningTests.swift @@ -1,7 +1,7 @@ import Foundation import Testing import OpenClawAgents -import OpenClawCore +@testable import OpenClawCore import OpenClawProtocol @Suite("Automation scheduler and hook gate hardening", .timeLimit(.minutes(1))) @@ -30,12 +30,8 @@ struct AutomationHardeningTests { let at = ISO8601DateFormatter().string(from: Date()) let job = try await scheduler.addJob(AutomationJob(name: "check-in", schedule: .at(at), payload: Self.event)) await scheduler.start() - let deadline = Date().addingTimeInterval(10) - var records = await scheduler.runs(jobID: job.id) - while records.isEmpty, Date() < deadline { - try await Task.sleep(nanoseconds: 20_000_000) - records = await scheduler.runs(jobID: job.id) - } + try await waitUntil("check-in run recorded") { await !scheduler.runs(jobID: job.id).isEmpty } + let records = await scheduler.runs(jobID: job.id) await scheduler.stop() #expect(records.count == 1) #expect(records.first?.status == .ok, "the in-flight run was not cancelled: \(records.first?.error ?? "")") @@ -47,19 +43,18 @@ struct AutomationHardeningTests { func addingAJobWakesTheSleepingLoop() async throws { let scheduler = CronScheduler() await scheduler.setExecutor { _ in AutomationRunOutcome(status: .ok) } - // No jobs: the loop sleeps for its one-minute cap until a job change wakes it. + // No jobs: the loop sleeps for its idle cap until a job change wakes it. The cap is past the + // suite's time limit, so a loop that is not woken fails as a hang instead of running the job + // late. + await scheduler._test_setMaximumSleepSeconds(3_600) await scheduler.start() try await Task.sleep(nanoseconds: 100_000_000) let at = ISO8601DateFormatter().string(from: Date().addingTimeInterval(1)) let job = try await scheduler.addJob(AutomationJob(name: "soon", schedule: .at(at), payload: Self.event)) - let deadline = Date().addingTimeInterval(10) - var records = await scheduler.runs(jobID: job.id) - while records.isEmpty, Date() < deadline { - try await Task.sleep(nanoseconds: 20_000_000) - records = await scheduler.runs(jobID: job.id) - } + try await waitUntil("job run after the wake") { await !scheduler.runs(jobID: job.id).isEmpty } + let records = await scheduler.runs(jobID: job.id) await scheduler.stop() - #expect(records.first?.status == .ok, "the job ran well before the loop's one-minute cap") + #expect(records.first?.status == .ok) } @Test diff --git a/Tests/OpenClawLinuxRuntimeTests/ChannelAdaptersLinuxSmokeTests.swift b/Tests/OpenClawLinuxRuntimeTests/ChannelAdaptersLinuxSmokeTests.swift index 9604d81..53831f4 100644 --- a/Tests/OpenClawLinuxRuntimeTests/ChannelAdaptersLinuxSmokeTests.swift +++ b/Tests/OpenClawLinuxRuntimeTests/ChannelAdaptersLinuxSmokeTests.swift @@ -8,7 +8,7 @@ import OpenClawProtocol import Testing /// Cross-platform smoke tests for the 2026.3.0 native adapters (run on Linux CI with swift-crypto). -@Suite("Channel adapters (Linux smoke)") +@Suite("Channel adapters (Linux smoke)", .timeLimit(.minutes(1))) struct ChannelAdaptersLinuxSmokeTests { actor StubHTTP: ChannelHTTPTransport { private(set) var bodies: [String] = [] @@ -34,15 +34,6 @@ struct ChannelAdaptersLinuxSmokeTests { } } - private func poll(_ condition: @escaping @Sendable () async -> Bool) async throws { - let deadline = Date().addingTimeInterval(15) - while Date() < deadline { - if await condition() { return } - try await Task.sleep(nanoseconds: 10_000_000) - } - #expect(await condition(), "timed out") - } - @Test func webhookSignatureVectorsMatchPlatformDocumentation() { let twilio = ChannelWebhookSignature.twilioSignature( @@ -71,7 +62,7 @@ struct ChannelAdaptersLinuxSmokeTests { body: body ) #expect(response.status == 200) - try await self.poll { await inbox.messages.count == 1 } + try await waitUntil("SMS webhook delivered to the inbox") { await inbox.messages.count == 1 } #expect(await inbox.messages.first?.peerID == "+15550002222") await adapter.stop() } diff --git a/Tests/OpenClawLinuxRuntimeTests/GatewayApprovalEventShapeTests.swift b/Tests/OpenClawLinuxRuntimeTests/GatewayApprovalEventShapeTests.swift index f6b3f2e..cf59365 100644 --- a/Tests/OpenClawLinuxRuntimeTests/GatewayApprovalEventShapeTests.swift +++ b/Tests/OpenClawLinuxRuntimeTests/GatewayApprovalEventShapeTests.swift @@ -7,7 +7,7 @@ import OpenClawProtocol @testable import OpenClawAgents /// Approval event wire shapes and ordering (2026.3.0 FX1 review fix). -@Suite("Gateway approval event shapes") +@Suite("Gateway approval event shapes", .timeLimit(.minutes(1))) struct GatewayApprovalEventShapeTests { typealias Harness = GatewayServerTestHarness @@ -26,7 +26,7 @@ struct GatewayApprovalEventShapeTests { _ = await Harness.call(stack.server, "exec.approval.resolve", [ "id": AnyCodable(id), "decision": AnyCodable("allow-once"), "reviewer": AnyCodable(["channel": AnyCodable("slack"), "senderId": AnyCodable("u1")]), ]) - let frames = await Harness.collect(events) { frames in frames.contains { $0.event == "exec.approval.resolved" } } + let frames = try await Harness.collect(events, "exec.approval.resolved") { frames in frames.contains { $0.event == "exec.approval.resolved" } } #expect(frames.map(\.event) == ["exec.approval.requested", "exec.approval.resolved"]) let requestedEvent = try #require(frames.first { $0.event == "exec.approval.requested" }?.payload?.dictionaryValue) diff --git a/Tests/OpenClawLinuxRuntimeTests/GatewayEventStreamTests.swift b/Tests/OpenClawLinuxRuntimeTests/GatewayEventStreamTests.swift index 62ba8bb..dfd8c38 100644 --- a/Tests/OpenClawLinuxRuntimeTests/GatewayEventStreamTests.swift +++ b/Tests/OpenClawLinuxRuntimeTests/GatewayEventStreamTests.swift @@ -8,7 +8,7 @@ import OpenClawProtocol /// Server event emission: runtime → `agent`/`chat`/`session.*`/`sessions.changed`, per-connection /// session subscriptions, filters, and startup gating. -@Suite("Gateway event stream") +@Suite("Gateway event stream", .timeLimit(.minutes(1))) struct GatewayEventStreamTests { private typealias Harness = GatewayServerTestHarness @@ -31,7 +31,7 @@ struct GatewayEventStreamTests { #expect(sent["runId"] == AnyCodable("client-run-1")) #expect(sent["status"] == AnyCodable("in_flight")) - let frames = await Harness.collect(events) { frames in + let frames = try await Harness.collect(events, "final chat and lifecycle end") { frames in frames.contains { $0.event == "chat" && ["final", "error", "aborted"].contains($0.payload?.dictionaryValue?["state"]?.stringValue ?? "") } && frames.contains { $0.event == "sessions.changed" && $0.payload?.dictionaryValue?["phase"] == AnyCodable("end") } } @@ -104,7 +104,7 @@ struct GatewayEventStreamTests { let sent = try await subscribed.send(method: "sessions.send", params: ["key": AnyCodable("agent:main:main"), "message": AnyCodable("hello")]) let runID = try #require(sent.payload?.dictionaryValue?["runId"]?.stringValue) - let received = await subscribedRecorder.waitFor { frames in + let received = try await subscribedRecorder.waitFor("subscribed final chat and four session messages") { frames in frames.contains { $0.event == "chat" && $0.payload?.dictionaryValue?["state"] == AnyCodable("final") } && frames.filter { $0.event == "session.message" }.count >= 4 } @@ -114,7 +114,7 @@ struct GatewayEventStreamTests { #expect(messages.allSatisfy { $0["messageId"]?.stringValue != nil }) #expect(received.contains { $0.event == "session.tool" && $0.payload?.dictionaryValue?["runId"] == AnyCodable(runID) }) - let otherFrames = await otherRecorder.waitFor { frames in + let otherFrames = try await otherRecorder.waitFor("other connection final chat") { frames in frames.contains { $0.event == "chat" && $0.payload?.dictionaryValue?["state"] == AnyCodable("final") } } #expect(otherFrames.contains { $0.event == "chat" }) @@ -138,7 +138,7 @@ struct GatewayEventStreamTests { _ = try Harness.payload(await Harness.call(stack.server, "sessions.send", [ "key": AnyCodable("agent:main:main"), "message": AnyCodable("one"), ])) - _ = await Harness.collect(chat) { frames in frames.contains { $0.payload?.dictionaryValue?["state"] == AnyCodable("final") } } + _ = try await Harness.collect(chat, "first run final") { frames in frames.contains { $0.payload?.dictionaryValue?["state"] == AnyCodable("final") } } // Keep the second run's start strictly after the first run's rows (millisecond timestamps). try await Task.sleep(nanoseconds: 20_000_000) @@ -152,7 +152,7 @@ struct GatewayEventStreamTests { _ = try Harness.payload(await Harness.call( stack.server, "sessions.send", ["key": AnyCodable("agent:main:main"), "message": AnyCodable("two")], connection: connection )) - let frames = await Harness.collect(bound) { frames in + let frames = try await Harness.collect(bound, "second run messages and final") { frames in frames.filter { $0.event == "session.message" }.count >= 2 && frames.contains { $0.event == "chat" && $0.payload?.dictionaryValue?["state"] == AnyCodable("final") } } @@ -183,7 +183,9 @@ struct GatewayEventStreamTests { #expect(aborted["aborted"] == AnyCodable(true)) #expect(aborted["runIds"] == AnyCodable([AnyCodable("abort-me")])) - let frames = await Harness.collect(chatOnly) { $0.contains { $0.payload?.dictionaryValue?["state"] == AnyCodable("aborted") } } + let frames = try await Harness.collect(chatOnly, "aborted chat event") { frames in + frames.contains { $0.payload?.dictionaryValue?["state"] == AnyCodable("aborted") } + } #expect(frames.allSatisfy { $0.event == "chat" }) guard case .aborted(let event)? = Harness.chatFrames(frames, runID: "abort-me").last else { Issue.record("expected an aborted chat event") @@ -206,9 +208,9 @@ struct GatewayEventStreamTests { let observer = await server.events(filter: .only(.sessionMessage)) #expect(await server.wantsSessionEvents(sessionKey: "s") == true) await server.broadcast(event: "session.message", payload: AnyCodable(["sessionKey": AnyCodable("s")])) - let observed = await Harness.collect(observer) { !$0.isEmpty } + let observed = try await Harness.collect(observer, "observed session.message") { !$0.isEmpty } #expect(observed.first?.event == "session.message") - let seen = await Harness.collect(all) { $0.count >= 2 } + let seen = try await Harness.collect(all, "note and session.message") { $0.count >= 2 } #expect(seen.map(\.event) == ["sdk.note", "session.message"]) } diff --git a/Tests/OpenClawLinuxRuntimeTests/GatewayNodePresenceTests.swift b/Tests/OpenClawLinuxRuntimeTests/GatewayNodePresenceTests.swift index 7a4f867..2158b2f 100644 --- a/Tests/OpenClawLinuxRuntimeTests/GatewayNodePresenceTests.swift +++ b/Tests/OpenClawLinuxRuntimeTests/GatewayNodePresenceTests.swift @@ -6,7 +6,7 @@ import OpenClawProtocol /// Presence (`system-presence`, `presence` events), node pairing (`node.pair.*`, `node.list`, /// `node.rename`) and `mcp.authLogin` on the in-process server. -@Suite("Gateway node pairing and presence") +@Suite("Gateway node pairing and presence", .timeLimit(.minutes(1))) struct GatewayNodePresenceTests { private typealias Harness = GatewayServerTestHarness @@ -37,7 +37,7 @@ struct GatewayNodePresenceTests { #expect(entry["onlineSince"]?.int64Value != nil) await client.disconnect() - let frames = await Harness.collect(events) { $0.count >= 2 } + let frames = try await Harness.collect(events, "two presence frames") { $0.count >= 2 } #expect(frames.first?.payload?.dictionaryValue?["presence"]?.arrayValue?.count == 1) #expect(frames.last?.payload?.dictionaryValue?["presence"]?.arrayValue?.isEmpty == true) #expect(await server.presenceEntries().isEmpty) @@ -72,7 +72,7 @@ struct GatewayNodePresenceTests { #expect(rejected["nodeId"] == AnyCodable("node-2")) #expect(await Harness.call(server, "node.pair.approve", ["requestId": AnyCodable("missing")]).error?.message == "unknown requestId") - let frames = await Harness.collect(events) { $0.count >= 3 } + let frames = try await Harness.collect(events, "three pairing frames") { $0.count >= 3 } #expect(frames.compactMap { $0.payload?.dictionaryValue?["decision"]?.stringValue } == ["approved", "removed", "rejected"]) // Pairing scope is enforced before dispatch. diff --git a/Tests/OpenClawLinuxRuntimeTests/GatewayScopeAuthorizationTests.swift b/Tests/OpenClawLinuxRuntimeTests/GatewayScopeAuthorizationTests.swift index 2cd7041..a3fa74c 100644 --- a/Tests/OpenClawLinuxRuntimeTests/GatewayScopeAuthorizationTests.swift +++ b/Tests/OpenClawLinuxRuntimeTests/GatewayScopeAuthorizationTests.swift @@ -8,7 +8,7 @@ import OpenClawProtocol /// Per-request scope policy of dynamic methods, unclassified methods, node pairing approval and /// connection-bound event delivery (2026.3.0 FX1 review fixes). -@Suite("Gateway scope authorization") +@Suite("Gateway scope authorization", .timeLimit(.minutes(1))) struct GatewayScopeAuthorizationTests { typealias Harness = GatewayServerTestHarness @@ -212,7 +212,7 @@ struct GatewayScopeAuthorizationTests { func received(_ id: String) async throws -> [String] { let stream = try #require(streams[id]) - return await Harness.collect(stream) { frames in frames.contains { $0.event == "tick" } }.map(\.event) + return try await Harness.collect(stream, "tick on \(id)") { frames in frames.contains { $0.event == "tick" } }.map(\.event) } let nodeEvents = try await received("ev-node") let pairerEvents = try await received("ev-pairer") @@ -226,7 +226,7 @@ struct GatewayScopeAuthorizationTests { #expect(adminEvents.filter { $0 != "presence" } == [ "chat", "agent", "exec.approval.requested", "node.pair.resolved", "sessions.changed", "sdk.custom", "plugin.demo", "tick", ]) - #expect(await Harness.collect(unregistered, timeoutMs: 200) { _ in false }.isEmpty) + #expect(await Harness.frames(unregistered, arrivingWithinMs: 200).isEmpty) #expect(GatewayEventFilter.hasEventScope(GatewayConnectionContext(scopes: ["operator.write"]), event: "plugin.demo")) #expect(GatewayEventFilter.hasEventScope(GatewayConnectionContext(scopes: ["operator.approvals"]), event: "openclaw.approval.requested")) diff --git a/Tests/OpenClawLinuxRuntimeTests/GatewayServerTestHarness.swift b/Tests/OpenClawLinuxRuntimeTests/GatewayServerTestHarness.swift index 5bf12d3..c00f579 100644 --- a/Tests/OpenClawLinuxRuntimeTests/GatewayServerTestHarness.swift +++ b/Tests/OpenClawLinuxRuntimeTests/GatewayServerTestHarness.swift @@ -84,31 +84,41 @@ enum GatewayServerTestHarness { try #require(try GatewayPayloadCodec.encode(value).dictionaryValue) } - /// Collects frames until `predicate` holds for the collected list or `timeoutMs` elapses. + /// Collects frames until `predicate` holds for the collected list, or the stream ends. + /// + /// There is no wall-clock deadline: every suite that calls this carries a `.timeLimit`, whose + /// cancellation ends the stream and the wait with ``AsyncWaitTimeoutError``. static func collect( _ stream: AsyncStream, - timeoutMs: UInt64 = 5_000, - until predicate: @escaping @Sendable ([EventFrame]) -> Bool - ) async -> [EventFrame] { - await withTaskGroup(of: [EventFrame]?.self) { group in - group.addTask { - var frames: [EventFrame] = [] - for await frame in stream { - frames.append(frame) - if predicate(frames) { - return frames - } - } + _ label: String, + until predicate: @Sendable ([EventFrame]) -> Bool + ) async throws -> [EventFrame] { + var frames: [EventFrame] = [] + for await frame in stream { + frames.append(frame) + if predicate(frames) { return frames } - group.addTask { - try? await Task.sleep(nanoseconds: timeoutMs * 1_000_000) - return nil + } + if Task.isCancelled { + throw recordedWaitTimeout(label) + } + return frames + } + + /// Frames that arrive within `milliseconds`, for asserting that nothing arrives. The window is + /// deliberately wall-clock: a slow pool can only hide a stray frame, never invent one. + static func frames(_ stream: AsyncStream, arrivingWithinMs milliseconds: UInt64) async -> [EventFrame] { + let consumer = Task { + var frames: [EventFrame] = [] + for await frame in stream { + frames.append(frame) } - let first = await group.next() ?? nil - group.cancelAll() - return first ?? [] + return frames } + try? await Task.sleep(nanoseconds: milliseconds * 1_000_000) + consumer.cancel() + return await consumer.value } /// Chat frames of a run, decoded through ``ChatEventFrame``. @@ -124,13 +134,14 @@ enum GatewayServerTestHarness { self.frames.append(frame) } - func waitFor(timeoutMs: UInt64 = 5_000, _ predicate: @Sendable ([EventFrame]) -> Bool) async -> [EventFrame] { - let deadline = Date().addingTimeInterval(Double(timeoutMs) / 1000) - while Date() < deadline { + /// Polls the recorded frames until `predicate` holds, with no wall-clock deadline (see + /// ``GatewayServerTestHarness/collect(_:_:until:)``). + func waitFor(_ label: String, _ predicate: @Sendable ([EventFrame]) -> Bool) async throws -> [EventFrame] { + while !Task.isCancelled { if predicate(self.frames) { return self.frames } try? await Task.sleep(nanoseconds: 10_000_000) } - return self.frames + throw recordedWaitTimeout(label) } } } diff --git a/Tests/OpenClawLinuxRuntimeTests/GatewaySessionOrganizationTests.swift b/Tests/OpenClawLinuxRuntimeTests/GatewaySessionOrganizationTests.swift index 91606c5..048fe87 100644 --- a/Tests/OpenClawLinuxRuntimeTests/GatewaySessionOrganizationTests.swift +++ b/Tests/OpenClawLinuxRuntimeTests/GatewaySessionOrganizationTests.swift @@ -7,7 +7,7 @@ import OpenClawProtocol /// Session organization over the in-process server: pin/archive/rename/unread/color through /// `sessions.patch`, `sessions.groups.*`, agent-scoped listing. -@Suite("Gateway session organization") +@Suite("Gateway session organization", .timeLimit(.minutes(1))) struct GatewaySessionOrganizationTests { private typealias Harness = GatewayServerTestHarness @@ -114,7 +114,7 @@ struct GatewaySessionOrganizationTests { let events = await stack.server.events(filter: .only(.sessionsChanged)) _ = await Harness.call(stack.server, "sessions.patch", ["key": AnyCodable("agent:main:x"), "category": AnyCodable("Research")]) #expect(await stack.server.sessionGroups.contains("Research")) - let frames = await Harness.collect(events) { !$0.isEmpty } + let frames = try await Harness.collect(events, "sessions.changed after patch") { !$0.isEmpty } let payload = try #require(frames.first?.payload?.dictionaryValue) #expect(payload["reason"] == AnyCodable("create")) #expect(payload["session"]?.dictionaryValue?["category"] == AnyCodable("Research")) diff --git a/Tests/OpenClawLinuxRuntimeTests/GatewayWireShapeTests.swift b/Tests/OpenClawLinuxRuntimeTests/GatewayWireShapeTests.swift index 22f07fa..b19f7ba 100644 --- a/Tests/OpenClawLinuxRuntimeTests/GatewayWireShapeTests.swift +++ b/Tests/OpenClawLinuxRuntimeTests/GatewayWireShapeTests.swift @@ -7,7 +7,7 @@ import OpenClawProtocol /// Legacy + upstream wire shapes of the in-process gateway (agent/agent.wait, session rows, /// session mutations, chat events) and the built-in session lifecycle handlers. -@Suite("Gateway wire shapes") +@Suite("Gateway wire shapes", .timeLimit(.minutes(1))) struct GatewayWireShapeTests { private typealias Harness = GatewayServerTestHarness @@ -320,7 +320,7 @@ struct GatewayWireShapeTests { #expect(deleted["archived"] == AnyCodable([AnyCodable]())) #expect(await store.recordForKey("agent:main:work") == nil) - let frames = await Harness.collect(events) { $0.count >= 3 } + let frames = try await Harness.collect(events, "three sessions.changed frames") { $0.count >= 3 } let reasons = frames.compactMap { $0.payload?.dictionaryValue?["reason"]?.stringValue } #expect(reasons == ["create", "reset", "delete"]) #expect(frames.first?.payload?.dictionaryValue?["session"]?.dictionaryValue?["pinned"] == AnyCodable(true)) diff --git a/Tests/OpenClawLinuxRuntimeTests/MCPHardeningTests.swift b/Tests/OpenClawLinuxRuntimeTests/MCPHardeningTests.swift index 739172e..d092169 100644 --- a/Tests/OpenClawLinuxRuntimeTests/MCPHardeningTests.swift +++ b/Tests/OpenClawLinuxRuntimeTests/MCPHardeningTests.swift @@ -426,8 +426,10 @@ struct MCPHardeningTests { return FakeMCPHTTP.Reply(status: 202, headers: [:], chunks: []) } } + // The request timeout is past the suite's time limit, so a call or refresh that stalls behind + // another request fails as a hang instead of recovering when that request times out. let config = MCPConfig(servers: [ - (name: "tracker", config: MCPServerConfig(url: "https://mcp.example.com/mcp", transport: "streamable-http", requestTimeoutMs: 500)), + (name: "tracker", config: MCPServerConfig(url: "https://mcp.example.com/mcp", transport: "streamable-http", requestTimeoutMs: 3_600_000)), ]) let manager = MCPClientManager(config: config, transportFactory: { _, server, _ in MCPStreamableHTTPTransport(url: URL(string: server.url!)!, http: http) @@ -435,10 +437,7 @@ struct MCPHardeningTests { #expect(await manager.tools().map(\.name) == ["tracker__create_issue"]) // The GET stream opens after the 202 to notifications/initialized. - let deadline = Date().addingTimeInterval(3) - while !http.requests.contains(where: { $0.httpMethod == "GET" }), Date() < deadline { - try await Task.sleep(nanoseconds: 20_000_000) - } + try await waitUntil("server stream GET opened") { http.requests.contains { $0.httpMethod == "GET" } } let get = try #require(http.requests.first { $0.httpMethod == "GET" }) #expect(get.value(forHTTPHeaderField: "Accept") == "text/event-stream") #expect(get.value(forHTTPHeaderField: "Mcp-Session-Id") == "s-1") @@ -446,16 +445,10 @@ struct MCPHardeningTests { _ = changed.insert("yes") serverFeed.yield(Data("event: message\ndata: {\"jsonrpc\":\"2.0\",\"method\":\"notifications/tools/list_changed\"}\n\n".utf8)) - let started = Date() // A call right after the notification must not stall behind the refresh. _ = try await manager.call(server: "tracker", tool: "create_issue", arguments: [:]) - var names = await manager.tools().map(\.name) - while names.count < 2, Date() < deadline.addingTimeInterval(2) { - try await Task.sleep(nanoseconds: 20_000_000) - names = await manager.tools().map(\.name) - } - #expect(names == ["tracker__create_issue", "tracker__close_issue"]) - #expect(Date().timeIntervalSince(started) < 0.5 * 3, "no request-timeout stall") + try await waitUntil("catalog refreshed after list_changed") { await manager.tools().count >= 2 } + #expect(await manager.tools().map(\.name) == ["tracker__create_issue", "tracker__close_issue"]) serverFeed.finish() await manager.shutdown() } diff --git a/Tests/OpenClawLinuxRuntimeTests/SessionBranchGatewayMethodsTests.swift b/Tests/OpenClawLinuxRuntimeTests/SessionBranchGatewayMethodsTests.swift index 9902229..e588086 100644 --- a/Tests/OpenClawLinuxRuntimeTests/SessionBranchGatewayMethodsTests.swift +++ b/Tests/OpenClawLinuxRuntimeTests/SessionBranchGatewayMethodsTests.swift @@ -7,7 +7,7 @@ import OpenClawProtocol @testable import OpenClawAgents /// `sessions.rewind`, `sessions.fork`, `sessions.branches.list|switch` and `sessions.search`. -@Suite("Session branch gateway methods") +@Suite("Session branch gateway methods", .timeLimit(.minutes(1))) struct SessionBranchGatewayMethodsTests { private typealias Harness = GatewayServerTestHarness private static let key = "agent:main:main" @@ -85,7 +85,7 @@ struct SessionBranchGatewayMethodsTests { // Lifecycle changes of the earlier runs may still be in flight; keep the DAG reasons only. let dagReasons: Set = ["rewind", "branch-switch"] - let frames = await Harness.collect(events) { frames in + let frames = try await Harness.collect(events, "rewind and branch-switch frames") { frames in frames.filter { dagReasons.contains($0.payload?.dictionaryValue?["reason"]?.stringValue ?? "") }.count >= 2 } let reasons = frames.compactMap { $0.payload?.dictionaryValue?["reason"]?.stringValue }.filter(dagReasons.contains) diff --git a/Tests/OpenClawLinuxRuntimeTests/SignInWithChatGPTSessionTests.swift b/Tests/OpenClawLinuxRuntimeTests/SignInWithChatGPTSessionTests.swift index 5697a2a..a43aaf0 100644 --- a/Tests/OpenClawLinuxRuntimeTests/SignInWithChatGPTSessionTests.swift +++ b/Tests/OpenClawLinuxRuntimeTests/SignInWithChatGPTSessionTests.swift @@ -195,7 +195,7 @@ final class SIWCCounter: @unchecked Sendable { } } -@Suite("Sign in with ChatGPT session") +@Suite("Sign in with ChatGPT session", .timeLimit(.minutes(1))) struct SignInWithChatGPTSessionTests { @Test("A first sign-in registers the account and persists the host id first") func registration() async throws { @@ -396,10 +396,7 @@ struct SignInWithChatGPTSessionTests { #expect(result.account.subject == "user-abc") #expect(browser.presentedURL.map { SIWCTest.queryItems($0)["client_id"] } == "dynamic_agent_client") #expect(browser.dismissCount >= 1) - let deadline = Date().addingTimeInterval(10) - while browser.responseBody == nil, Date() < deadline { - try await Task.sleep(nanoseconds: 10_000_000) - } + try await waitUntil("loopback success page returned to the browser") { browser.responseBody != nil } #expect(browser.responseStatuses == [400, 200]) let page = try #require(browser.responseBody) #expect(page.contains("Signed in with ChatGPT")) diff --git a/Tests/OpenClawLinuxRuntimeTests/SubagentRuntimeHardeningTests.swift b/Tests/OpenClawLinuxRuntimeTests/SubagentRuntimeHardeningTests.swift index 0b7cbb0..1a855f1 100644 --- a/Tests/OpenClawLinuxRuntimeTests/SubagentRuntimeHardeningTests.swift +++ b/Tests/OpenClawLinuxRuntimeTests/SubagentRuntimeHardeningTests.swift @@ -186,7 +186,7 @@ struct SubagentRuntimeHardeningTests { modelRouter: ModelRouter(defaultProviderID: provider.id, providers: [provider]) ) await runtime.start(AgentRunRequest(runID: "dup", sessionKey: "one", prompt: "a"), streaming: false) - try await waitUntil { await provider.count() == 1 } + try await waitUntil("first run reached the provider") { await provider.count() == 1 } await runtime.start(AgentRunRequest(runID: "dup", sessionKey: "two", prompt: "b"), streaming: false) #expect(await runtime.activeRunIDs() == ["dup"]) await provider.release() diff --git a/Tests/OpenClawLinuxRuntimeTests/TestAsyncHelpers.swift b/Tests/OpenClawLinuxRuntimeTests/TestAsyncHelpers.swift new file mode 100644 index 0000000..ac54535 --- /dev/null +++ b/Tests/OpenClawLinuxRuntimeTests/TestAsyncHelpers.swift @@ -0,0 +1,33 @@ +import Testing + +struct AsyncWaitTimeoutError: Error, CustomStringConvertible { + let label: String + var description: String { + "Timeout waiting for: \(self.label)" + } +} + +/// Polls `condition` until it holds. +/// +/// There is no wall-clock deadline: a saturated test pool must only slow the wait down. Every suite +/// or test that calls this carries a `.timeLimit`, whose cancellation ends the wait with +/// ``AsyncWaitTimeoutError``. +func waitUntil( + _ label: String, + pollMs: UInt64 = 10, + _ condition: @Sendable () async -> Bool) async throws +{ + while !Task.isCancelled { + if await condition() { return } + try? await Task.sleep(nanoseconds: pollMs * 1_000_000) + } + throw recordedWaitTimeout(label) +} + +/// Records which wait hung and returns the error to throw. Swift Testing drops errors thrown after a +/// time-limit cancellation, so the recorded issue is what names the wait. +func recordedWaitTimeout(_ label: String) -> AsyncWaitTimeoutError { + let timeout = AsyncWaitTimeoutError(label: label) + Issue.record(timeout) + return timeout +} From ba41908d35b7acd44b8728ebef550059ee4c883d Mon Sep 17 00:00:00 2001 From: MarcoDotIO Date: Wed, 30 Sep 2026 15:54:39 -0400 Subject: [PATCH 11/11] test: drop the bounded polls, elapsed bounds and positive-path timeouts #26 left three kinds of wall-clock bounds in the test waits. A stalled cooperative pool (5-8 s on the macOS CI job) could fail any of them. - Iteration-count sleep polls now use waitUntil, under a one-minute time limit. The Task.yield loops before negative checks, the sampler repetitions and the media teardown grace are kept. - Elapsed "< N s" asserts are replaced by what they stood for: error payloads that name the short deadline, kill checks, and a stalled server or run that outlives the time limit. Lower bounds and the synchronous cron search are kept. The throttle drop and delay checks use an hour-long window, so a stall between the two requests cannot let the window pass. - Positive-path product timeouts move past the time limit where the wait ends on cancellation (SIWC signIn, runtime.run). Waits that park on a continuation cancellation never resumes (runtime.wait, agent.wait, the brokers, A2ATaskStore, imsg) go through the new awaitCancellable(_:) with no timeout. - The stale-timer test retries until its first run beats its 200 ms timer. The run-id collision test holds its run on a gate instead of a 3 s sleep. The coalescing reporter test uses a frozen clock. Co-Authored-By: Claude Opus 5.5 --- .../CoreAIModelRuntimeTests.swift | 6 +- .../GatewayServerChatUICompatTests.swift | 11 +-- .../OpenClawKitTests/MediaPipelineTests.swift | 26 +++--- .../OpenClawKitTests/ModelRoutingTests.swift | 49 +++++++---- .../SystemStateReportingTests.swift | 11 +-- .../VoiceNoteRecorderTests.swift | 5 +- .../AgentRuntimeExtensionsTests.swift | 40 +++------ .../ApprovalQuestionBrokerTests.swift | 30 ++----- .../ChannelAdaptersLinuxSmokeTests.swift | 15 +++- .../ExecApprovalHardeningTests.swift | 9 +- .../GatewayRunLifecycleTests.swift | 37 +++++--- .../MCPHardeningTests.swift | 3 +- .../MCPStdioTransportTests.swift | 19 ++-- .../RuntimeIntegrationWiringTests.swift | 6 +- .../SignInWithChatGPTSessionTests.swift | 9 +- .../SpotlightMemoryTests.swift | 88 ++++++++++++++----- .../SubagentRuntimeHardeningTests.swift | 35 +++++--- .../TestAsyncHelpers.swift | 24 +++++ .../ToolsGatewayMethodsTests.swift | 11 +-- 19 files changed, 259 insertions(+), 175 deletions(-) diff --git a/Tests/OpenClawKitTests/CoreAIModelRuntimeTests.swift b/Tests/OpenClawKitTests/CoreAIModelRuntimeTests.swift index ddcf386..f98005f 100644 --- a/Tests/OpenClawKitTests/CoreAIModelRuntimeTests.swift +++ b/Tests/OpenClawKitTests/CoreAIModelRuntimeTests.swift @@ -296,7 +296,7 @@ struct CoreAIModelRuntimeTests { #expect(texts == ["aaaaa", "bbbbb"]) } - @Test + @Test(.timeLimit(.minutes(1))) func cancelStopsOnlyTheRunningGeneration() async throws { let executor = SharedCacheExecutor(limit: 40) let engine = CoreAILocalModelEngine(tokenizer: ScalarTokenizer(), executorFactory: { _, _ in executor }) @@ -304,9 +304,7 @@ struct CoreAIModelRuntimeTests { configuration.temperature = 0 try await engine.loadModel(path: "/m", configuration: configuration) let running = Task { try await engine.generate(prompt: "a", systemPrompt: nil, configuration: configuration, onToken: nil) } - for _ in 0..<500 where await executor.runs == 0 { - try await Task.sleep(nanoseconds: 1_000_000) - } + try await waitUntil("first generation running") { await executor.runs > 0 } let queued = Task { try await engine.generate(prompt: "b", systemPrompt: nil, configuration: configuration, onToken: nil) } await engine.cancelGeneration(token: nil) await #expect(throws: CoreAIRuntimeError.cancelled) { diff --git a/Tests/OpenClawKitTests/GatewayServerChatUICompatTests.swift b/Tests/OpenClawKitTests/GatewayServerChatUICompatTests.swift index 6d0186c..3191832 100644 --- a/Tests/OpenClawKitTests/GatewayServerChatUICompatTests.swift +++ b/Tests/OpenClawKitTests/GatewayServerChatUICompatTests.swift @@ -5,7 +5,7 @@ import Testing /// The in-process server's session rows, `sessions.changed`, `session.message` and protocol-v4 /// `chat` payloads decode through the ChatUI transport models. -@Suite("Gateway server ChatUI compatibility") +@Suite("Gateway server ChatUI compatibility", .timeLimit(.minutes(1))) struct GatewayServerChatUICompatTests { private func makeStack(_ name: String, turns: [String]) async throws -> (GatewayServer, EmbeddedAgentRuntime, URL) { let root = FileManager.default.temporaryDirectory.appendingPathComponent("\(name)-\(UUID().uuidString)") @@ -43,11 +43,8 @@ struct GatewayServerChatUICompatTests { private(set) var frames: [EventFrame] = [] func record(_ frame: EventFrame) { self.frames.append(frame) } - func wait(_ predicate: @Sendable ([EventFrame]) -> Bool) async -> [EventFrame] { - for _ in 0..<500 { - if predicate(self.frames) { return self.frames } - try? await Task.sleep(nanoseconds: 10_000_000) - } + func wait(_ label: String, _ predicate: @escaping @Sendable ([EventFrame]) -> Bool) async throws -> [EventFrame] { + try await waitUntil(label) { predicate(await self.frames) } return self.frames } } @@ -101,7 +98,7 @@ struct GatewayServerChatUICompatTests { let send = try GatewayPayloadCodec.decode(OpenClawChatSendResponse.self, from: ack.payload) #expect(send.runId == "ui-run-1") - let frames = await recorder.wait { frames in + let frames = try await recorder.wait("final chat, both session messages and the end change") { frames in frames.contains { $0.event == "chat" && $0.payload?.dictionaryValue?["state"] == AnyCodable("final") } && frames.filter { $0.event == "session.message" }.count >= 2 && frames.contains { $0.event == "sessions.changed" && $0.payload?.dictionaryValue?["phase"] == AnyCodable("end") } diff --git a/Tests/OpenClawKitTests/MediaPipelineTests.swift b/Tests/OpenClawKitTests/MediaPipelineTests.swift index 7e6f64d..315d30c 100644 --- a/Tests/OpenClawKitTests/MediaPipelineTests.swift +++ b/Tests/OpenClawKitTests/MediaPipelineTests.swift @@ -7,7 +7,7 @@ import Glibc import Darwin #endif -@Suite("Media pipeline", .serialized) +@Suite("Media pipeline", .serialized, .timeLimit(.minutes(1))) struct MediaPipelineTests { @Test func normalizeRejectsEmptyMimeTypeAndOversizedPayloads() async throws { @@ -415,22 +415,26 @@ struct MediaPipelineTests { } let baseURL = URL(string: "http://127.0.0.1:\(port)/")! - try await self.waitForHTTPServer(baseURL: baseURL) + try await self.waitForHTTPServer(baseURL: baseURL, process: process) try await body(baseURL) } - private func waitForHTTPServer(baseURL: URL) async throws { - var lastError: (any Error)? - for _ in 0..<20 { - do { - _ = try Data(contentsOf: baseURL) + /// Polls until the server answers. There is no deadline: a loaded runner only delays Python's + /// startup, and the suite's time limit bounds a server that never answers. + private func waitForHTTPServer(baseURL: URL, process: Process) async throws { + while !Task.isCancelled { + if (try? Data(contentsOf: baseURL)) != nil { return - } catch { - lastError = error - try await Task.sleep(nanoseconds: 100_000_000) } + guard process.isRunning else { + throw OpenClawCoreError.unavailable("Local HTTP server exited with status \(process.terminationStatus)") + } + try? await Task.sleep(nanoseconds: 100_000_000) } - throw lastError ?? OpenClawCoreError.unavailable("Local HTTP server did not start") + // Swift Testing drops errors thrown after a time-limit cancellation, so record which wait hung. + let timeout = AsyncWaitTimeoutError(label: "local HTTP server answering") + Issue.record(timeout) + throw timeout } private func reserveTCPPort() throws -> Int { diff --git a/Tests/OpenClawKitTests/ModelRoutingTests.swift b/Tests/OpenClawKitTests/ModelRoutingTests.swift index 6b22ef8..c5bb624 100644 --- a/Tests/OpenClawKitTests/ModelRoutingTests.swift +++ b/Tests/OpenClawKitTests/ModelRoutingTests.swift @@ -332,9 +332,11 @@ struct ModelRoutingTests { StaticProvider(id: "primary", text: "primary-output"), StaticProvider(id: "secondary", text: "secondary-output"), ], + // An hour-long window: the second request is inside it however slow the runner is, and a + // drop never waits for the window to pass. throttlePolicy: ModelProviderThrottlePolicy( maxRequestsPerWindow: 1, - windowMs: 1_000, + windowMs: 3_600_000, strategy: .drop ), diagnosticsSink: await pipeline.sink() @@ -355,9 +357,9 @@ struct ModelRoutingTests { #expect(events.contains(where: { $0.name == "model.request.retry" })) } - @Test + @Test(.timeLimit(.minutes(1))) func routerThrottleDelayAppliesCooldownAndEmitsDiagnostics() async throws { - let pipeline = RuntimeDiagnosticsPipeline(eventLimit: 50) + let request = ModelGenerationRequest(sessionKey: "main", prompt: "hello", providerID: "primary") let router = ModelRouter( defaultProviderID: "primary", providers: [StaticProvider(id: "primary", text: "primary-output")], @@ -365,22 +367,39 @@ struct ModelRoutingTests { maxRequestsPerWindow: 1, windowMs: 70, strategy: .delay - ), - diagnosticsSink: await pipeline.sink() + ) ) + // The second request proceeds once the window has passed. A lower bound: a slow runner only + // adds to it (and may let the window pass before the second request arrives). let startedAt = Date() - _ = try await router.generate( - ModelGenerationRequest(sessionKey: "main", prompt: "hello", providerID: "primary") - ) - _ = try await router.generate( - ModelGenerationRequest(sessionKey: "main", prompt: "hello", providerID: "primary") - ) - let elapsed = Date().timeIntervalSince(startedAt) + _ = try await router.generate(request) + _ = try await router.generate(request) + #expect(Date().timeIntervalSince(startedAt) >= 0.06) - #expect(elapsed >= 0.06) - let events = await pipeline.recentEvents(limit: 50) - #expect(events.contains(where: { $0.name == "model.throttle.delay" })) + // With an hour-long window the second request always lands inside it and waits. + let pipeline = RuntimeDiagnosticsPipeline(eventLimit: 50) + let slowRouter = ModelRouter( + defaultProviderID: "primary", + providers: [StaticProvider(id: "primary", text: "primary-output")], + throttlePolicy: ModelProviderThrottlePolicy( + maxRequestsPerWindow: 1, + windowMs: 3_600_000, + strategy: .delay + ), + diagnosticsSink: await pipeline.sink() + ) + _ = try await slowRouter.generate(request) + let delayed = Task { try await slowRouter.generate(request) } + try await waitUntil("model.throttle.delay emitted") { + await pipeline.recentEvents(limit: 50).contains { $0.name == "model.throttle.delay" } + } + let delay = try #require(await pipeline.recentEvents(limit: 50).first { $0.name == "model.throttle.delay" }) + #expect((Int(delay.metadata["delayMs"] ?? "") ?? 0) > 3_000_000) + delayed.cancel() + await #expect(throws: CancellationError.self) { + _ = try await delayed.value + } } @Test diff --git a/Tests/OpenClawKitTests/SystemStateReportingTests.swift b/Tests/OpenClawKitTests/SystemStateReportingTests.swift index a2f88f9..00f681b 100644 --- a/Tests/OpenClawKitTests/SystemStateReportingTests.swift +++ b/Tests/OpenClawKitTests/SystemStateReportingTests.swift @@ -191,16 +191,17 @@ struct SystemStateReportingTests { #expect(recorder.reports.last == .volatile(.gateway, ["backoffMs": 4000])) } - @Test + @Test(.timeLimit(.minutes(1))) func coalescingReporterSchedulesTrailingFlush() async throws { let recorder = RecordingStateReporter() - let reporter = CoalescingSystemStateReporter(wrapping: recorder, minimumInterval: 0.05) + // The clock stands still, so the second update always lands inside the interval; only the + // trailing flush can forward it. + let clock = TestClock() + let reporter = CoalescingSystemStateReporter(wrapping: recorder, minimumInterval: 0.05, now: { clock.now }) reporter.reportVolatileUpdate(.talk, ["level": 1]) reporter.reportVolatileUpdate(.talk, ["level": 2]) #expect(recorder.reports == [.volatile(.talk, ["level": 1])]) - for _ in 0..<50 where recorder.reports.count < 2 { - try await Task.sleep(nanoseconds: 20_000_000) - } + try await waitUntil("trailing flush forwarded the held update") { recorder.reports.count >= 2 } #expect(recorder.reports == [.volatile(.talk, ["level": 1]), .volatile(.talk, ["level": 2])]) } diff --git a/Tests/OpenClawKitTests/VoiceNoteRecorderTests.swift b/Tests/OpenClawKitTests/VoiceNoteRecorderTests.swift index 0e164ab..3cd7405 100644 --- a/Tests/OpenClawKitTests/VoiceNoteRecorderTests.swift +++ b/Tests/OpenClawKitTests/VoiceNoteRecorderTests.swift @@ -359,10 +359,9 @@ struct VoiceNoteRecorderTests { #expect(started) #expect(recorder.level == 0) - for _ in 0..<200 where recorder.level == 0 { - try await Task.sleep(nanoseconds: 3_000_000) + try await waitUntil("capture level published") { + await MainActor.run { recorder.level > 0 } } - #expect(recorder.level > 0) _ = try #require(recorder.finish()) #expect(recorder.level == 0) diff --git a/Tests/OpenClawLinuxRuntimeTests/AgentRuntimeExtensionsTests.swift b/Tests/OpenClawLinuxRuntimeTests/AgentRuntimeExtensionsTests.swift index abec741..571aba6 100644 --- a/Tests/OpenClawLinuxRuntimeTests/AgentRuntimeExtensionsTests.swift +++ b/Tests/OpenClawLinuxRuntimeTests/AgentRuntimeExtensionsTests.swift @@ -38,7 +38,7 @@ actor SessionRoutingProvider: ModelProvider { } } -@Suite("Agent runtime extensions") +@Suite("Agent runtime extensions", .timeLimit(.minutes(1))) struct AgentRuntimeExtensionsTests { // MARK: - Exec gate @@ -92,13 +92,8 @@ struct AgentRuntimeExtensionsTests { #expect(await broker.pending().isEmpty) // The fourth command escalates to a human. let escalated = Task { await gate.evaluate(command: "danger 4", permissionMode: .workspace, sessionKey: "w") } - var pending: [AgentApproval] = [] - for _ in 0..<100 { - pending = await broker.pending() - if !pending.isEmpty { break } - try await Task.sleep(nanoseconds: 10_000_000) - } - let approval = try #require(pending.first) + try await waitUntil("escalated approval pending") { await !broker.pending().isEmpty } + let approval = try #require(await broker.pending().first) #expect(approval.presentation.warningText?.contains("Escalated") == true) _ = try await broker.resolve(id: approval.id, decision: .allowOnce) #expect(await escalated.value == .allow(source: .human)) @@ -108,9 +103,7 @@ struct AgentRuntimeExtensionsTests { throw ReviewDown() }) let asking = Task { await failing.evaluate(command: "anything", permissionMode: .workspace, sessionKey: "f") } - for _ in 0..<100 where await broker.pending().isEmpty { - try await Task.sleep(nanoseconds: 10_000_000) - } + try await waitUntil("failed-review approval pending") { await !broker.pending().isEmpty } let failed = try #require(await broker.pending().first) #expect(failed.presentation.warningText?.contains("Automatic review failed") == true) await broker.cancel(runID: "none") @@ -132,8 +125,7 @@ struct AgentRuntimeExtensionsTests { // MARK: - Sub-agents and ledger - // Bounded so a lost wake-up fails fast instead of hanging the suite. - @Test(.timeLimit(.minutes(1))) + @Test func spawnAnnouncesCompletionAndWakesTheYieldedParent() async throws { let provider = SessionRoutingProvider() let store = SessionStore(fileURL: FileManager.default.temporaryDirectory.appendingPathComponent("sub-\(UUID().uuidString)/sessions.json")) @@ -148,7 +140,8 @@ struct AgentRuntimeExtensionsTests { let manager = SubagentManager(runtime: runtime, ledger: ledger) await manager.registerTools() - let result = try await runtime.run(AgentRunRequest(sessionKey: "agent:main:main", prompt: "delegate"), timeoutMs: 10_000) + // The run ends on cancellation, so the hour-long timeout leaves the time limit as the only bound. + let result = try await runtime.run(AgentRunRequest(sessionKey: "agent:main:main", prompt: "delegate"), timeoutMs: 3_600_000) #expect(result.toolResults.map(\.name) == ["sessions_spawn", "sessions_yield"]) let spawnDetails = try #require(result.toolResults.first?.output.details?.dictionaryValue) #expect(spawnDetails["status"] == AnyCodable("accepted")) @@ -157,20 +150,15 @@ struct AgentRuntimeExtensionsTests { #expect(await store.recordForKey(childKey)?.spawnedBy == "agent:main:main") #expect(await store.recordForKey(childKey)?.spawnDepth == 1) - var prompts: [String] = [] - for _ in 0..<300 { - prompts = await provider.prompts() - if prompts.contains(where: { $0.contains("child result") }) { break } - try await Task.sleep(nanoseconds: 10_000_000) + try await waitUntil("parent woke with the child result") { + await provider.prompts().contains { $0.contains("child result") } } - #expect(prompts.contains { $0.contains("[Subagent completion]") && $0.contains("child result") }) + #expect(await provider.prompts().contains { $0.contains("[Subagent completion]") && $0.contains("child result") }) let tasks = await ledger.list().tasks #expect(tasks.count == 1) #expect(tasks.first?.kind == .subagent) - for _ in 0..<100 where await ledger.list().tasks.first?.status.isTerminal != true { - try await Task.sleep(nanoseconds: 10_000_000) - } + try await waitUntil("sub-agent task terminal") { await ledger.list().tasks.first?.status.isTerminal == true } #expect(await ledger.list().tasks.first?.status == .completed) #expect(await ledger.list(statuses: [.running]).tasks.isEmpty) let children = await manager.children(of: "agent:main:main") @@ -185,7 +173,7 @@ struct AgentRuntimeExtensionsTests { } } - @Test(.timeLimit(.minutes(1))) + @Test func spawnParamsRejectUnsupportedOptionsAndKillWorks() async throws { #expect(throws: SubagentError.self) { try SubagentSpawnParams.parse(["task": AnyCodable("x"), "runtime": AnyCodable("acp")]) } #expect(throws: SubagentError.self) { try SubagentSpawnParams.parse(["task": AnyCodable("x"), "visible": AnyCodable(true)]) } @@ -212,7 +200,7 @@ struct AgentRuntimeExtensionsTests { #expect(await manager.resolve(target: "1", parentSessionKey: "p").count == 1) let killed = try await manager.kill(target: "last", parentSessionKey: "p") #expect(killed.map(\.status) == ["killed"]) - #expect(await runtime.wait(runID: record.runID, timeoutMs: 2_000)?.status == "error") + #expect(try await awaitCancellable("killed run finished") { await runtime.wait(runID: record.runID) }?.status == "error") } @Test @@ -344,7 +332,7 @@ struct AgentRuntimeExtensionsTests { ) ) let runID = try #require(refresh.payload?.dictionaryValue?["runId"]?.stringValue) - #expect(await runtime.wait(runID: runID, timeoutMs: 5_000)?.status == "ok") + #expect(try await awaitCancellable("refresh run finished") { await runtime.wait(runID: runID) }?.status == "ok") // The refresh instruction is hidden from chat history. #expect(try await runtime.history(sessionKey: "pc").map(\.role) == ["assistant"]) } diff --git a/Tests/OpenClawLinuxRuntimeTests/ApprovalQuestionBrokerTests.swift b/Tests/OpenClawLinuxRuntimeTests/ApprovalQuestionBrokerTests.swift index 348b43b..89b3974 100644 --- a/Tests/OpenClawLinuxRuntimeTests/ApprovalQuestionBrokerTests.swift +++ b/Tests/OpenClawLinuxRuntimeTests/ApprovalQuestionBrokerTests.swift @@ -5,7 +5,7 @@ import OpenClawModels import OpenClawProtocol @testable import OpenClawAgents -@Suite("Approval and question brokers") +@Suite("Approval and question brokers", .timeLimit(.minutes(1))) struct ApprovalQuestionBrokerTests { // MARK: - Approvals @@ -56,7 +56,7 @@ struct ApprovalQuestionBrokerTests { func expiryFailsClosed() async throws { let broker = ApprovalBroker() let pending = await broker.request(presentation: .exec(commandText: "rm -rf build"), timeoutMs: 30) - let result = try await broker.waitDecision(id: pending.id, timeoutMs: 2_000) + let result = try await awaitCancellable("approval expired") { try await broker.waitDecision(id: pending.id) } #expect(result.state == .expired) #expect(result.reason == .timeout) #expect(result.isAllowed == false) @@ -76,13 +76,8 @@ struct ApprovalQuestionBrokerTests { let waiter = Task { await broker.requestAndWait(presentation: .exec(commandText: "git status"), agentID: "main", grantKey: key) } - var pendingID: String? - for _ in 0..<50 { - pendingID = await broker.pending().first?.id - if pendingID != nil { break } - try await Task.sleep(nanoseconds: 10_000_000) - } - let id = try #require(pendingID) + try await waitUntil("grant approval pending") { await !broker.pending().isEmpty } + let id = try #require(await broker.pending().first?.id) _ = try await broker.resolve(id: id, decision: .allowAlways, grantExpiresInDays: 7) #expect(await waiter.value.isAllowed) @@ -135,13 +130,11 @@ struct ApprovalQuestionBrokerTests { hooks: AgentLoopHooks(beforeToolCall: { _ in .requireApproval(AgentToolApprovalRequest(title: "Echo", description: "Allow?")) }) ) let runID = await runtime.start(AgentRunRequest(runID: "wait-approval", sessionKey: "w", prompt: "go"), streaming: false) - for _ in 0..<100 where await runtime.approvals.pending().isEmpty { - try await Task.sleep(nanoseconds: 10_000_000) - } + try await waitUntil("tool approval pending") { await !runtime.approvals.pending().isEmpty } let pending = try #require(await runtime.approvals.pending().first) #expect(pending.runID == runID) await runtime.abort(runID: runID) - let result = try #require(await runtime.wait(runID: runID, timeoutMs: 2_000)) + let result = try #require(await awaitCancellable("aborted run finished") { await runtime.wait(runID: runID) }) #expect(result.status == "error") #expect(await runtime.approvals.get(id: pending.id)?.state == .cancelled) } @@ -208,13 +201,8 @@ struct ApprovalQuestionBrokerTests { let asking = Task { try await tool.invoke(AgentToolInvocation(arguments: arguments, context: AgentToolInvocationContext(runID: "r", sessionKey: "chat")), update: nil) } - var questionID: String? - for _ in 0..<100 { - questionID = await broker.pendingQuestion(sessionKey: "chat")?.id - if questionID != nil { break } - try await Task.sleep(nanoseconds: 10_000_000) - } - let id = try #require(questionID) + try await waitUntil("question pending") { await broker.pendingQuestion(sessionKey: "chat") != nil } + let id = try #require(await broker.pendingQuestion(sessionKey: "chat")?.id) // One pending question per session. let second = try await tool.invoke(AgentToolInvocation(arguments: arguments, context: AgentToolInvocationContext(sessionKey: "chat")), update: nil) @@ -243,7 +231,7 @@ struct ApprovalQuestionBrokerTests { options: [AgentQuestionOption(label: "Yes"), AgentQuestionOption(label: "No")] ) let expiring = try await broker.request(questions: [prompt], timeoutMs: 30) - #expect(try await broker.waitAnswer(id: expiring.id, timeoutMs: 2_000) == .expired) + #expect(try await awaitCancellable("question expired") { try await broker.waitAnswer(id: expiring.id) } == .expired) let cancelled = try await broker.request(questions: [prompt], runID: "run-9") #expect(try await broker.waitAnswer(id: cancelled.id, timeoutMs: 20) == .pending) diff --git a/Tests/OpenClawLinuxRuntimeTests/ChannelAdaptersLinuxSmokeTests.swift b/Tests/OpenClawLinuxRuntimeTests/ChannelAdaptersLinuxSmokeTests.swift index 53831f4..46f8ba8 100644 --- a/Tests/OpenClawLinuxRuntimeTests/ChannelAdaptersLinuxSmokeTests.swift +++ b/Tests/OpenClawLinuxRuntimeTests/ChannelAdaptersLinuxSmokeTests.swift @@ -69,14 +69,22 @@ struct ChannelAdaptersLinuxSmokeTests { @Test func a2aRoundTripCompletesTaskWithReply() async throws { - let config = A2AChannelConfig(enabled: true, replyTimeoutMs: 5_000, peers: ["peer": A2APeerConfig(token: "secret")]) + // The longest reply timeout (10 min) outlasts the time limit; the task-store wait ignores + // cancellation, so the request is awaited through awaitCancellable. + let config = A2AChannelConfig( + enabled: true, + replyTimeoutMs: A2AChannelConfig.replyTimeoutRangeMs.upperBound, + peers: ["peer": A2APeerConfig(token: "secret")] + ) let adapter = A2AChannelAdapter(config: config) await adapter.setInboundHandler { message in try? await adapter.send(OutboundMessage(channel: .a2a, peerID: message.peerID, text: "done")) } try await adapter.start() let request = #"{"jsonrpc":"2.0","id":1,"method":"SendMessage","params":{"message":{"role":"ROLE_USER","contextId":"c1","parts":[{"text":"task"}]}}}"# - let response = await adapter.handleHTTP(method: "POST", path: "/a2a/v1", headers: ["Authorization": "Bearer secret"], body: Data(request.utf8)) + let response = try await awaitCancellable("A2A task completed") { + await adapter.handleHTTP(method: "POST", path: "/a2a/v1", headers: ["Authorization": "Bearer secret"], body: Data(request.utf8)) + } let decoded = try JSONDecoder().decode(A2AJSONRPCResponse.self, from: response.body) #expect(decoded.result?.task?.status.state == .completed) #expect(decoded.result?.task?.replyText == "done") @@ -106,7 +114,8 @@ struct ChannelAdaptersLinuxSmokeTests { try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: script.path) let client = IMsgRPCClient(pipe: IMsgProcessPipe(cliPath: script.path)) try await client.start() - let result = try await client.request("ping", timeoutMs: 10_000) + // No request timeout (0); the reply wait ignores cancellation, so it goes through awaitCancellable. + let result = try await awaitCancellable("imsg ping answered") { try await client.request("ping", timeoutMs: 0) } #expect(result.dictionaryValue?["ok"]?.boolValue == true) await client.stop() } diff --git a/Tests/OpenClawLinuxRuntimeTests/ExecApprovalHardeningTests.swift b/Tests/OpenClawLinuxRuntimeTests/ExecApprovalHardeningTests.swift index 733bcac..726ea7a 100644 --- a/Tests/OpenClawLinuxRuntimeTests/ExecApprovalHardeningTests.swift +++ b/Tests/OpenClawLinuxRuntimeTests/ExecApprovalHardeningTests.swift @@ -5,15 +5,10 @@ import OpenClawProtocol @testable import OpenClawAgents /// Exec approval gate and grant-key hardening (allow-always scope, raw allowlist text, closure rules). -@Suite("Exec approval hardening") +@Suite("Exec approval hardening", .timeLimit(.minutes(1))) struct ExecApprovalHardeningTests { private func firstPending(_ broker: ApprovalBroker) async throws -> AgentApproval { - for _ in 0..<300 { - if let approval = await broker.pending().first { - return approval - } - try await Task.sleep(nanoseconds: 10_000_000) - } + try await waitUntil("exec approval pending") { await !broker.pending().isEmpty } return try #require(await broker.pending().first) } diff --git a/Tests/OpenClawLinuxRuntimeTests/GatewayRunLifecycleTests.swift b/Tests/OpenClawLinuxRuntimeTests/GatewayRunLifecycleTests.swift index 6828211..cff332e 100644 --- a/Tests/OpenClawLinuxRuntimeTests/GatewayRunLifecycleTests.swift +++ b/Tests/OpenClawLinuxRuntimeTests/GatewayRunLifecycleTests.swift @@ -8,7 +8,7 @@ import OpenClawProtocol /// Built-in run tracking, client timeouts, pagination cursors, run-id collisions and /// `progressCard.put` validation (2026.3.0 FX1 review fixes). -@Suite("Gateway run lifecycle") +@Suite("Gateway run lifecycle", .timeLimit(.minutes(1))) struct GatewayRunLifecycleTests { typealias Harness = GatewayServerTestHarness @@ -33,22 +33,24 @@ struct GatewayRunLifecycleTests { @Test func builtinAgentWaitTimesOutWithoutWaitingForTheRun() async throws { + // The run only ends when aborted, so a wait that ignored its timeout would hang until the time limit. let server = Self.bareServer("lifecycle-wait-timeout") { request in - try await Task.sleep(nanoseconds: 10_000_000_000) + try await Task.sleep(nanoseconds: 3_600_000_000_000) return Self.ok(request) } let accepted = try Harness.payload(await Harness.call(server, "agent", ["message": AnyCodable("slow"), "idempotencyKey": AnyCodable("slow-1")])) #expect(accepted["runId"] == AnyCodable("slow-1")) - let started = Date() - let waited = try Harness.payload(await Harness.call(server, "agent.wait", ["runId": AnyCodable("slow-1"), "timeoutMs": AnyCodable(50)])) + let waited = try Harness.payload(try await awaitCancellable("timed-out agent.wait returned") { + await Harness.call(server, "agent.wait", ["runId": AnyCodable("slow-1"), "timeoutMs": AnyCodable(50)]) + }) #expect(waited["status"] == AnyCodable("timeout")) - // Well before the 10 s run ends (generous margin for loaded CI machines). - #expect(Date().timeIntervalSince(started) < 5) // The run keeps being tracked after a timed-out wait. #expect(await server.trackedRuns["slow-1"] != nil) let aborted = try Harness.payload(await Harness.call(server, "sessions.abort", ["runId": AnyCodable("slow-1")])) #expect(aborted["status"] == AnyCodable("aborted")) - let final = try Harness.payload(await Harness.call(server, "agent.wait", ["runId": AnyCodable("slow-1"), "timeoutMs": AnyCodable(5_000)])) + let final = try Harness.payload(try await awaitCancellable("aborted run reported") { + await Harness.call(server, "agent.wait", ["runId": AnyCodable("slow-1")]) + }) #expect(final["status"] == AnyCodable("error")) #expect(final["error"] == AnyCodable("aborted")) } @@ -62,7 +64,9 @@ struct GatewayRunLifecycleTests { return Self.ok(request) } _ = await Harness.call(server, "agent", ["message": AnyCodable("explode"), "idempotencyKey": AnyCodable("boom-1")]) - let failed = try Harness.payload(await Harness.call(server, "agent.wait", ["runId": AnyCodable("boom-1"), "timeoutMs": AnyCodable(5_000)])) + let failed = try Harness.payload(try await awaitCancellable("failed run reported") { + await Harness.call(server, "agent.wait", ["runId": AnyCodable("boom-1")]) + }) #expect(failed["status"] == AnyCodable("error")) #expect(failed["error"] == AnyCodable("exploded")) #expect(failed["endedAt"] != nil) @@ -71,9 +75,7 @@ struct GatewayRunLifecycleTests { _ = await Harness.call(server, "sessions.send", [ "key": AnyCodable("agent:main:quick"), "message": AnyCodable("quick"), "idempotencyKey": AnyCodable("quick-1"), ]) - for _ in 0..<200 where await server.completedRuns["quick-1"] == nil { - try await Task.sleep(nanoseconds: 5_000_000) - } + try await waitUntil("unwatched run completed") { await server.completedRuns["quick-1"] != nil } #expect(await server.agentRuns.isEmpty) #expect(await server.trackedRuns.isEmpty) let abortByKey = try Harness.payload(await Harness.call(server, "sessions.abort", ["key": AnyCodable("agent:main:quick")])) @@ -169,7 +171,9 @@ struct GatewayRunLifecycleTests { "key": AnyCodable("agent:main:main"), "message": AnyCodable("hi"), ])) let runID = try #require(sent["runId"]?.stringValue) - _ = await Harness.call(stack.server, "agent.wait", ["runId": AnyCodable(runID), "timeoutMs": AnyCodable(5_000)]) + _ = try await awaitCancellable("sessions.send run finished") { + await Harness.call(stack.server, "agent.wait", ["runId": AnyCodable(runID)]) + } let task = await ledger.create(kind: .tool, sessionKey: "agent:main:main") for method in ["tasks.list", "approval.history"] { @@ -205,9 +209,11 @@ struct GatewayRunLifecycleTests { @Test func reusedIdempotencyKeysDoNotStartASecondRunUnderTheSameID() async throws { + // The first run stays in flight until the duplicate sends are refused. + let inFlight = AsyncGate() let stack = await Harness.runtimeStack("lifecycle-run-ids", turns: [ { _ in - try await Task.sleep(nanoseconds: 3_000_000_000) + await inFlight.wait() return ModelGenerationResponse(text: "first", providerID: "scripted") }, ], fallback: ScriptedToolProvider.text("later")) @@ -225,7 +231,10 @@ struct GatewayRunLifecycleTests { ])) #expect(agent["status"] == AnyCodable("in_flight")) #expect(await stack.runtime.activeRunIDs() == ["msg-1"]) - let waited = try Harness.payload(await Harness.call(stack.server, "agent.wait", ["runId": AnyCodable("msg-1"), "timeoutMs": AnyCodable(5_000)])) + await inFlight.open() + let waited = try Harness.payload(try await awaitCancellable("first run finished") { + await Harness.call(stack.server, "agent.wait", ["runId": AnyCodable("msg-1")]) + }) #expect(waited["output"] == AnyCodable("first")) } diff --git a/Tests/OpenClawLinuxRuntimeTests/MCPHardeningTests.swift b/Tests/OpenClawLinuxRuntimeTests/MCPHardeningTests.swift index d092169..9fa9949 100644 --- a/Tests/OpenClawLinuxRuntimeTests/MCPHardeningTests.swift +++ b/Tests/OpenClawLinuxRuntimeTests/MCPHardeningTests.swift @@ -370,11 +370,10 @@ struct MCPHardeningTests { feed.yield(Data(": ping\n\n".utf8)) let transport = MCPLegacySSETransport(url: URL(string: "https://mcp.example.com/mcp")!, http: http) let client = MCPClient(serverName: "legacy", transport: transport, connectionTimeoutMs: 100) - let started = Date() + // The error names the 100 ms deadline; without one the stream never ends and the time limit fails the test. await #expect(throws: MCPTransportError.timeout(method: "connect", milliseconds: 100)) { try await client.connect() } - #expect(Date().timeIntervalSince(started) < 2) await #expect(throws: MCPTransportError.closed("transport closed")) { try await transport.send(.notification(method: "ping", params: nil)) } diff --git a/Tests/OpenClawLinuxRuntimeTests/MCPStdioTransportTests.swift b/Tests/OpenClawLinuxRuntimeTests/MCPStdioTransportTests.swift index 47132b5..57a86a2 100644 --- a/Tests/OpenClawLinuxRuntimeTests/MCPStdioTransportTests.swift +++ b/Tests/OpenClawLinuxRuntimeTests/MCPStdioTransportTests.swift @@ -89,9 +89,8 @@ struct MCPStdioTransportTests { ) try await transport.start() let pid = try #require(await transport.processIdentifier) - let startedAt = Date() await transport.close() - #expect(Date().timeIntervalSince(startedAt) < 5) + // The server traps TERM, so it is gone only if close escalated to KILL. #expect(kill(pid, 0) != 0) } @@ -111,20 +110,26 @@ struct MCPStdioTransportTests { @Test func closeDoesNotWaitForAWriteBlockedOnAFullPipe() async throws { - // The server never reads stdin, so a 200 KB message fills the pipe and the write blocks. + // The server never reads stdin, so a 200 KB message fills the pipe and the write blocks. The + // server outlives the time limit: a close that waited for the write would hang until the + // cancellation handler below kills the server. let transport = try MCPStdioTransport( serverName: "deaf", - config: self.config("exec sleep 5"), + config: self.config("exec sleep 3600"), allowlist: ExecCommandAllowlist(patterns: ["/bin/*", "/usr/bin/*"]), shutdownGraceSeconds: 0.2 ) try await transport.start() + let pid = try #require(await transport.processIdentifier) let big = MCPJSONRPCMessage.request(id: .int(1), method: "tools/call", params: AnyCodable(["blob": AnyCodable(String(repeating: "x", count: 200_000))])) let pending = Task { try await transport.send(big) } try await Task.sleep(nanoseconds: 200_000_000) - let startedAt = Date() - await transport.close() - #expect(Date().timeIntervalSince(startedAt) < 3, "close escalates without waiting for the blocked write") + await withTaskCancellationHandler { + await transport.close() + } onCancel: { + _ = kill(pid, SIGKILL) + } + #expect(kill(pid, 0) != 0, "close escalates without waiting for the blocked write") // Once the child is gone the blocked write fails with EPIPE (no SIGPIPE crash). await #expect(throws: MCPTransportError.self) { try await pending.value diff --git a/Tests/OpenClawLinuxRuntimeTests/RuntimeIntegrationWiringTests.swift b/Tests/OpenClawLinuxRuntimeTests/RuntimeIntegrationWiringTests.swift index 5892f24..d2f1b4f 100644 --- a/Tests/OpenClawLinuxRuntimeTests/RuntimeIntegrationWiringTests.swift +++ b/Tests/OpenClawLinuxRuntimeTests/RuntimeIntegrationWiringTests.swift @@ -8,7 +8,7 @@ import OpenClawProtocol import OpenClawSkills @testable import OpenClawAgents -@Suite("Runtime integration wiring") +@Suite("Runtime integration wiring", .timeLimit(.minutes(1))) struct RuntimeIntegrationWiringTests { private static func document(_ json: String) throws -> OpenClawConfigDocument { try OpenClawConfigDocument.decode(Data(json.utf8)) @@ -433,9 +433,7 @@ struct RuntimeIntegrationWiringTests { ) let manager = SubagentManager(runtime: runtime) let record = try await manager.spawn(SubagentSpawnParams(task: "summarize", label: "Summary"), parentSessionKey: "agent:main:main") - for _ in 0..<300 where await !log.names.contains("subagent_ended") { - try await Task.sleep(nanoseconds: 10_000_000) - } + try await waitUntil("subagent_ended hook ran") { await log.names.contains("subagent_ended") } #expect(await log.names == ["subagent_spawned", "subagent_ended"]) let spawned = try #require(await log.first(.subagentSpawned)) #expect(spawned["childSessionKey"]?.stringValue == record.childSessionKey) diff --git a/Tests/OpenClawLinuxRuntimeTests/SignInWithChatGPTSessionTests.swift b/Tests/OpenClawLinuxRuntimeTests/SignInWithChatGPTSessionTests.swift index a43aaf0..5091d87 100644 --- a/Tests/OpenClawLinuxRuntimeTests/SignInWithChatGPTSessionTests.swift +++ b/Tests/OpenClawLinuxRuntimeTests/SignInWithChatGPTSessionTests.swift @@ -392,7 +392,8 @@ struct SignInWithChatGPTSessionTests { let server = SIWCFakeServer() let session = SIWCTest.makeSession(server: server, clock: SIWCTestClock(), callbackPort: 0) let browser = LoopbackCallingBrowser() - let result = try await session.signIn(using: browser, reauthenticating: nil, consent: .automatic, timeout: 20, secrets: SIWCTest.secrets) + // Sign-in ends on cancellation, so an hour-long timeout leaves the suite's time limit as the bound. + let result = try await session.signIn(using: browser, reauthenticating: nil, consent: .automatic, timeout: 3_600, secrets: SIWCTest.secrets) #expect(result.account.subject == "user-abc") #expect(browser.presentedURL.map { SIWCTest.queryItems($0)["client_id"] } == "dynamic_agent_client") #expect(browser.dismissCount >= 1) @@ -408,7 +409,7 @@ struct SignInWithChatGPTSessionTests { let server = SIWCFakeServer() let session = SIWCTest.makeSession(server: server, clock: SIWCTestClock(), callbackPort: 0) let browser = LoopbackCallingBrowser(closesAfterCallback: true) - let result = try await session.signIn(using: browser, reauthenticating: nil, consent: .automatic, timeout: 20, secrets: SIWCTest.secrets) + let result = try await session.signIn(using: browser, reauthenticating: nil, consent: .automatic, timeout: 3_600, secrets: SIWCTest.secrets) #expect(result.account.subject == "user-abc") } @@ -417,13 +418,13 @@ struct SignInWithChatGPTSessionTests { let server = SIWCFakeServer() let session = SIWCTest.makeSession(server: server, clock: SIWCTestClock(), callbackPort: 0) await #expect(throws: SignInWithChatGPTError.cancelled) { - try await session.signIn(using: CancellingBrowser(), timeout: 20) + try await session.signIn(using: CancellingBrowser(), timeout: 3_600) } await #expect(throws: SignInWithChatGPTError.timedOut) { try await session.signIn(using: SignInWithChatGPTExternalBrowser { _ in true }, timeout: 1) } await #expect(throws: OpenClawCoreError.self) { - try await session.signIn(using: SignInWithChatGPTExternalBrowser { _ in false }, timeout: 20) + try await session.signIn(using: SignInWithChatGPTExternalBrowser { _ in false }, timeout: 3_600) } #expect(try await session.accounts().isEmpty) } diff --git a/Tests/OpenClawLinuxRuntimeTests/SpotlightMemoryTests.swift b/Tests/OpenClawLinuxRuntimeTests/SpotlightMemoryTests.swift index 64f8c86..b247c9e 100644 --- a/Tests/OpenClawLinuxRuntimeTests/SpotlightMemoryTests.swift +++ b/Tests/OpenClawLinuxRuntimeTests/SpotlightMemoryTests.swift @@ -10,12 +10,31 @@ import OpenClawProtocol final class FakeSpotlightStore: SpotlightItemIndexing, @unchecked Sendable { private let lock = NSLock() private var items: [String: String] = [:] - private let hangs: Bool + private var hangs: Bool + private var stalled: [@Sendable ((any Error)?) -> Void] = [] init(hangs: Bool = false) { self.hangs = hangs } + /// Answers the calls a hanging store left pending, and every later call. + func stopHanging() { + let stalled = self.lock.withLock { + self.hangs = false + defer { self.stalled.removeAll() } + return self.stalled + } + for completion in stalled { completion(nil) } + } + + /// Keeps `completion` pending when the store hangs. + private func stalls(_ completion: @escaping @Sendable ((any Error)?) -> Void) -> Bool { + self.lock.withLock { + if self.hangs { self.stalled.append(completion) } + return self.hangs + } + } + var isAvailable: Bool { true } @@ -28,7 +47,7 @@ final class FakeSpotlightStore: SpotlightItemIndexing, @unchecked Sendable { } func indexItems(_ items: [CSSearchableItem], completion: @escaping @Sendable ((any Error)?) -> Void) { - guard !self.hangs else { return } + guard !self.stalls(completion) else { return } self.lock.lock() for item in items { self.items[item.uniqueIdentifier] = item.domainIdentifier ?? "" } self.lock.unlock() @@ -36,7 +55,7 @@ final class FakeSpotlightStore: SpotlightItemIndexing, @unchecked Sendable { } func deleteItems(identifiers: [String], completion: @escaping @Sendable ((any Error)?) -> Void) { - guard !self.hangs else { return } + guard !self.stalls(completion) else { return } self.lock.lock() for id in identifiers { self.items.removeValue(forKey: id) } self.lock.unlock() @@ -44,7 +63,7 @@ final class FakeSpotlightStore: SpotlightItemIndexing, @unchecked Sendable { } func deleteItems(domains: [String], completion: @escaping @Sendable ((any Error)?) -> Void) { - guard !self.hangs else { return } + guard !self.stalls(completion) else { return } self.lock.lock() self.items = self.items.filter { _, domain in !domains.contains { domain == $0 || domain.hasPrefix($0 + ".") } @@ -54,6 +73,20 @@ final class FakeSpotlightStore: SpotlightItemIndexing, @unchecked Sendable { } } +/// Tells a stalled test operation to stop. +final class StallFlag: @unchecked Sendable { + private let lock = NSLock() + private var ended = false + + var isEnded: Bool { + self.lock.withLock { self.ended } + } + + func end() { + self.lock.withLock { self.ended = true } + } +} + @Suite("Spotlight memory backends") struct SpotlightMemoryTests { static var live: Bool { @@ -83,17 +116,22 @@ struct SpotlightMemoryTests { #expect(SpotlightTimeoutRace.deadlineNanoseconds(-5) == 50_000_000) } - @Test + @Test(.timeLimit(.minutes(1))) func indexWritesAreBoundedWhenSpotlightNeverAnswers() async throws { - let index = Self.index(store: FakeSpotlightStore(hangs: true), writeTimeoutSeconds: 0.2) - let started = Date() - await #expect(throws: SpotlightTimeoutError.self) { - try await index.upsert([MemoryDocument(id: "a", source: .systemNote, text: "stalled write")], sessionKey: nil) - } - await #expect(throws: SpotlightTimeoutError.self) { - try await index.deleteAll() + let store = FakeSpotlightStore(hangs: true) + let index = Self.index(store: store, writeTimeoutSeconds: 0.2) + // The errors name the 0.2 s deadline. A write without one would hang until the time limit, + // whose cancellation answers the stalled calls so the test can end. + await withTaskCancellationHandler { + await #expect(throws: SpotlightTimeoutError(operation: "indexSearchableItems", seconds: 0.2)) { + try await index.upsert([MemoryDocument(id: "a", source: .systemNote, text: "stalled write")], sessionKey: nil) + } + await #expect(throws: SpotlightTimeoutError(operation: "deleteSearchableItems(withDomainIdentifiers:)", seconds: 0.2)) { + try await index.deleteAll() + } + } onCancel: { + store.stopHanging() } - #expect(Date().timeIntervalSince(started) < 3) } @Test @@ -229,18 +267,26 @@ struct SpotlightMemoryTests { feed.finish() } - @Test - func timeoutRaceReturnsWithoutJoiningAStalledOperation() async { - let started = Date() + @Test(.timeLimit(.minutes(1))) + func timeoutRaceReturnsWithoutJoiningAStalledOperation() async throws { // The operation ignores cancellation and never finishes on its own, like a stalled CSUserQuery. - let value: Int? = await SpotlightTimeoutRace.first(timeoutSeconds: 0.2) { - while true { - try? await Task.sleep(nanoseconds: 50_000_000) + // It stops once the test ends, or when the time limit cancels a race that waits for it. + let stall = StallFlag() + defer { stall.end() } + let value: Int? = await withTaskCancellationHandler { + await SpotlightTimeoutRace.first(timeoutSeconds: 0.2) { + while !stall.isEnded { + try? await Task.sleep(nanoseconds: 50_000_000) + } + return 0 } + } onCancel: { + stall.end() } #expect(value == nil) - #expect(Date().timeIntervalSince(started) < 2) - let fast: Int? = await SpotlightTimeoutRace.first(timeoutSeconds: 5) { 42 } + let fast: Int? = try await awaitCancellable("fast operation won the race") { + await SpotlightTimeoutRace.first(timeoutSeconds: 3_600) { 42 } + } #expect(fast == 42) } diff --git a/Tests/OpenClawLinuxRuntimeTests/SubagentRuntimeHardeningTests.swift b/Tests/OpenClawLinuxRuntimeTests/SubagentRuntimeHardeningTests.swift index 1a855f1..189fded 100644 --- a/Tests/OpenClawLinuxRuntimeTests/SubagentRuntimeHardeningTests.swift +++ b/Tests/OpenClawLinuxRuntimeTests/SubagentRuntimeHardeningTests.swift @@ -108,7 +108,7 @@ struct SubagentRuntimeHardeningTests { ) let manager = SubagentManager(runtime: runtime) let child = try await manager.spawn(SubagentSpawnParams(task: "run rm -rf build and write X"), parentSessionKey: parent) - #expect(await runtime.wait(runID: child.runID, timeoutMs: 10_000)?.status == "ok") + #expect(try await awaitCancellable("child run finished") { await runtime.wait(runID: child.runID) }?.status == "ok") let childRecord = try #require(await store.recordForKey(child.childSessionKey)) #expect(childRecord.permissionMode == .readOnly) @@ -143,11 +143,11 @@ struct SubagentRuntimeHardeningTests { await manager.registerTools() let result = try await runtime.run( AgentRunRequest(sessionKey: "agent:main:main", prompt: "delegate", toolPolicy: ToolPolicy(deny: ["exec"])), - timeoutMs: 10_000 + timeoutMs: 3_600_000 ) #expect(result.output == "parent done") let child = try #require(await manager.children(of: "agent:main:main").first) - #expect(await runtime.wait(runID: child.runID, timeoutMs: 10_000)?.status == "ok") + #expect(try await awaitCancellable("child run finished") { await runtime.wait(runID: child.runID) }?.status == "ok") #expect(await log.count("exec") == 0) let results = toolResults(try await runtime.history(sessionKey: child.childSessionKey)) #expect(results.first?.text == "Tool exec is not allowed by the current tool policy") @@ -190,20 +190,28 @@ struct SubagentRuntimeHardeningTests { await runtime.start(AgentRunRequest(runID: "dup", sessionKey: "two", prompt: "b"), streaming: false) #expect(await runtime.activeRunIDs() == ["dup"]) await provider.release() - #expect(await runtime.wait(runID: "dup", timeoutMs: 10_000)?.output == "first") + #expect(try await awaitCancellable("joined run finished") { await runtime.wait(runID: "dup") }?.output == "first") #expect(await provider.count() == 1) } @Test(.timeLimit(.minutes(1))) func aStaleTimerNeverCancelsANewRunWithTheSameID() async throws { - let provider = GatedTextProvider(replies: ["old", "new"]) - await provider.release() - let runtime = EmbeddedAgentRuntime( - toolRegistry: AgentToolRegistry(), - modelRouter: ModelRouter(defaultProviderID: provider.id, providers: [provider]) - ) - await runtime.start(AgentRunRequest(runID: "reuse", sessionKey: "s", prompt: "a"), timeoutMs: 200, streaming: false) - #expect(await runtime.wait(runID: "reuse", timeoutMs: 10_000)?.output == "old") + // The first run has to finish before its 200 ms timer so the timer is stale during the second + // run. A stalled pool can let the timer win, so start over until the first run finishes first. + var provider: GatedTextProvider + var runtime: EmbeddedAgentRuntime + var first: AgentRunWaitResult? + repeat { + provider = GatedTextProvider(replies: ["old", "new"]) + await provider.release() + runtime = EmbeddedAgentRuntime( + toolRegistry: AgentToolRegistry(), + modelRouter: ModelRouter(defaultProviderID: provider.id, providers: [provider]) + ) + await runtime.start(AgentRunRequest(runID: "reuse", sessionKey: "s", prompt: "a"), timeoutMs: 200, streaming: false) + first = try await awaitCancellable("first run finished") { [runtime] in await runtime.wait(runID: "reuse") } + } while first?.status == "timeout" + #expect(first?.output == "old") await provider.block() await runtime.start(AgentRunRequest(runID: "reuse", sessionKey: "s", prompt: "b"), streaming: false) @@ -213,7 +221,8 @@ struct SubagentRuntimeHardeningTests { #expect(await runtime.activeRunIDs() == ["reuse"]) await provider.release() // The waiter sees the new run, not the previous run's retained result. - #expect(await runtime.wait(runID: "reuse", timeoutMs: 10_000)?.output == "new") + let second = try await awaitCancellable("second run finished") { [runtime] in await runtime.wait(runID: "reuse") } + #expect(second?.output == "new") _ = await runtime.approvals.cancel(id: approval.id) } diff --git a/Tests/OpenClawLinuxRuntimeTests/TestAsyncHelpers.swift b/Tests/OpenClawLinuxRuntimeTests/TestAsyncHelpers.swift index ac54535..27cb5d2 100644 --- a/Tests/OpenClawLinuxRuntimeTests/TestAsyncHelpers.swift +++ b/Tests/OpenClawLinuxRuntimeTests/TestAsyncHelpers.swift @@ -31,3 +31,27 @@ func recordedWaitTimeout(_ label: String) -> AsyncWaitTimeoutError { Issue.record(timeout) return timeout } + +/// Awaits `operation` in its own task, so a time-limit cancellation ends the wait even when the +/// operation ignores cancellation. +/// +/// Product waits such as `EmbeddedAgentRuntime.wait(runID:)`, the gateway's `agent.wait` and the +/// approval and question brokers park on a continuation that only their own timer or the awaited +/// event resumes. Wrapping them here lets a test wait without a timeout: a regression shows up as a +/// hang, and the time limit turns it into an ``AsyncWaitTimeoutError`` naming `label`. +func awaitCancellable(_ label: String, _ operation: @escaping @Sendable () async throws -> T) async throws -> T { + let (results, continuation) = AsyncStream>.makeStream(bufferingPolicy: .bufferingNewest(1)) + let task = Task { + do { + continuation.yield(.success(try await operation())) + } catch { + continuation.yield(.failure(error)) + } + continuation.finish() + } + defer { task.cancel() } + for await result in results { + return try result.get() + } + throw recordedWaitTimeout(label) +} diff --git a/Tests/OpenClawLinuxRuntimeTests/ToolsGatewayMethodsTests.swift b/Tests/OpenClawLinuxRuntimeTests/ToolsGatewayMethodsTests.swift index ab2c7c8..057bd5a 100644 --- a/Tests/OpenClawLinuxRuntimeTests/ToolsGatewayMethodsTests.swift +++ b/Tests/OpenClawLinuxRuntimeTests/ToolsGatewayMethodsTests.swift @@ -7,7 +7,7 @@ import OpenClawProtocol @testable import OpenClawAgents /// `tools.catalog`, `tools.effective` and `tools.invoke` over the runtime tool registry and policy. -@Suite("Tools gateway methods") +@Suite("Tools gateway methods", .timeLimit(.minutes(1))) struct ToolsGatewayMethodsTests { private typealias Harness = GatewayServerTestHarness @@ -164,13 +164,8 @@ struct ToolsGatewayMethodsTests { "name": AnyCodable("lookup"), "args": AnyCodable(["text": AnyCodable("w")]), "confirm": AnyCodable(true), ])) } - var pendingID: String? - for _ in 0..<200 { - pendingID = await stack.runtime.approvals.pending().first?.id - if pendingID != nil { break } - try await Task.sleep(nanoseconds: 10_000_000) - } - let waitingID = try #require(pendingID) + try await waitUntil("confirmation approval pending") { await !stack.runtime.approvals.pending().isEmpty } + let waitingID = try #require(await stack.runtime.approvals.pending().first?.id) _ = await Harness.call(stack.server, "approval.resolve", ["id": AnyCodable(waitingID), "decision": AnyCodable("deny")]) let denied = try await waiting.value #expect(denied["ok"] == AnyCodable(false))