mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-16 15:11:39 +00:00
* fix(crestodian): keep onboarding RPCs restart-safe * fix(profiles): isolate approval state migrations * fix(crestodian): bypass configured gateway setup * test(crestodian): type onboarding mocks * fix(onboarding): require inference before Crestodian * fix(onboarding): enforce verified inference handoff * fix(macos): reset setup on gateway endpoint edits * chore(i18n): refresh native source inventory * fix(gateway): keep socket on request cancellation * test(packaging): require workspace templates * fix(onboarding): bind setup to verified inference * fix(onboarding): align inference gate contracts * fix(crestodian): classify concurrent policy rejection * test(crestodian): expect registry restoration * fix(onboarding): bind setup to configured gateways * fix(codex): preserve startup phase deadlines * test(crestodian): match fail-closed policy ordering * test(onboarding): assert bound gateway handoff * fix(codex): bind runtime resolution to spawn cwd * test(crestodian): assert policy rejection order * fix(cli): preserve gateway routing across restarts * fix(macos): fail closed during gateway edits * test(macos): cover gateway route generation races * chore: keep release notes out of onboarding PR * fix(ci): refresh onboarding generated checks * style(swift): align gateway channel formatting * fix(ci): refresh plugin SDK surface budgets * fix(ci): resync native string inventory * refactor(swift): split gateway channel support * test(doctor): isolate plugin compatibility registry * test(macos): isolate gateway onboarding fixtures * test(macos): assert gateway lease health ordering * fix(codex): reconcile computer-use startup changes
355 lines
14 KiB
Swift
355 lines
14 KiB
Swift
import Foundation
|
|
import Testing
|
|
@testable import OpenClaw
|
|
@testable import OpenClawKit
|
|
|
|
private struct GatewayRequestCancellationTimeout: Error {}
|
|
|
|
private actor GatewayRequestProbe {
|
|
private var value: String?
|
|
private var waiter: CheckedContinuation<String, Never>?
|
|
|
|
func record(_ value: String) {
|
|
self.value = value
|
|
self.waiter?.resume(returning: value)
|
|
self.waiter = nil
|
|
}
|
|
|
|
func wait() async -> String {
|
|
if let value {
|
|
return value
|
|
}
|
|
return await withCheckedContinuation { self.waiter = $0 }
|
|
}
|
|
}
|
|
|
|
private actor GatewayRequestStartGate {
|
|
private var entered = false
|
|
private var enteredWaiter: CheckedContinuation<Void, Never>?
|
|
private var releaseWaiter: CheckedContinuation<Void, Never>?
|
|
|
|
func wait() async {
|
|
self.entered = true
|
|
self.enteredWaiter?.resume()
|
|
self.enteredWaiter = nil
|
|
await withCheckedContinuation { self.releaseWaiter = $0 }
|
|
}
|
|
|
|
func waitUntilEntered() async {
|
|
if self.entered {
|
|
return
|
|
}
|
|
await withCheckedContinuation { self.enteredWaiter = $0 }
|
|
}
|
|
|
|
func release() {
|
|
self.releaseWaiter?.resume()
|
|
self.releaseWaiter = nil
|
|
}
|
|
}
|
|
|
|
struct GatewayChannelRequestTests {
|
|
private func makeSession(requestSendDelayMs: Int) -> GatewayTestWebSocketSession {
|
|
GatewayTestWebSocketSession(
|
|
taskFactory: {
|
|
GatewayTestWebSocketTask(
|
|
sendHook: { _, _, sendIndex in
|
|
guard sendIndex == 1 else { return }
|
|
try await Task.sleep(nanoseconds: UInt64(requestSendDelayMs) * 1_000_000)
|
|
throw URLError(.cannotConnectToHost)
|
|
})
|
|
})
|
|
}
|
|
|
|
@Test func `request timeout then send failure does not double resume`() async throws {
|
|
let session = self.makeSession(requestSendDelayMs: 100)
|
|
let channel = try GatewayChannelActor(
|
|
url: #require(URL(string: "ws://example.invalid")),
|
|
token: nil,
|
|
session: WebSocketSessionBox(session: session))
|
|
|
|
do {
|
|
_ = try await channel.request(method: "test", params: nil, timeoutMs: 10)
|
|
Issue.record("Expected request to time out")
|
|
} catch {
|
|
let ns = error as NSError
|
|
#expect(ns.domain == "Gateway")
|
|
#expect(ns.code == 5)
|
|
}
|
|
|
|
// Give the delayed send failure task time to run; this used to crash due to a double-resume.
|
|
try? await Task.sleep(nanoseconds: 250 * 1_000_000)
|
|
}
|
|
|
|
@Test func `cancelled request send keeps the shared socket reusable`() async throws {
|
|
let session = GatewayTestWebSocketSession(
|
|
taskFactory: {
|
|
GatewayTestWebSocketTask(sendHook: { task, message, sendIndex in
|
|
if sendIndex == 1 {
|
|
throw CancellationError()
|
|
}
|
|
guard sendIndex == 2,
|
|
let requestID = GatewayWebSocketTestSupport.requestID(from: message)
|
|
else { return }
|
|
task.emitReceiveSuccessOnce(.data(GatewayWebSocketTestSupport.okResponseData(id: requestID)))
|
|
})
|
|
})
|
|
let channel = try GatewayChannelActor(
|
|
url: #require(URL(string: "ws://example.invalid")),
|
|
token: nil,
|
|
session: WebSocketSessionBox(session: session))
|
|
|
|
await #expect(throws: CancellationError.self) {
|
|
try await channel.request(method: "cancelled-send", params: nil, timeoutMs: 5000)
|
|
}
|
|
let response = try await channel.request(method: "retry", params: nil, timeoutMs: 5000)
|
|
|
|
#expect(!response.isEmpty)
|
|
#expect(session.snapshotMakeCount() == 1)
|
|
#expect(session.latestTask()?.snapshotSendCount() == 3)
|
|
}
|
|
|
|
@Test func `request cancellation removes pending waiter and ignores late response`() async throws {
|
|
let probe = GatewayRequestProbe()
|
|
let session = GatewayTestWebSocketSession(
|
|
taskFactory: {
|
|
GatewayTestWebSocketTask(sendHook: { _, message, sendIndex in
|
|
guard sendIndex == 1,
|
|
let requestID = GatewayWebSocketTestSupport.requestID(from: message)
|
|
else { return }
|
|
await probe.record(requestID)
|
|
})
|
|
})
|
|
let channel = try GatewayChannelActor(
|
|
url: #require(URL(string: "ws://example.invalid")),
|
|
token: nil,
|
|
session: WebSocketSessionBox(session: session))
|
|
let request = Task {
|
|
try await channel.request(method: "cancel-me", params: nil, timeoutMs: 5000)
|
|
}
|
|
let requestID = await probe.wait()
|
|
#expect(await channel._test_pendingRequestCount() == 1)
|
|
|
|
request.cancel()
|
|
|
|
await #expect(throws: CancellationError.self) {
|
|
try await AsyncTimeout.withTimeout(
|
|
seconds: 1,
|
|
onTimeout: { GatewayRequestCancellationTimeout() },
|
|
operation: { try await request.value })
|
|
}
|
|
#expect(await channel._test_pendingRequestCount() == 0)
|
|
|
|
let socket = try #require(session.latestTask())
|
|
socket.emitReceiveSuccessOnce(.data(GatewayWebSocketTestSupport.okResponseData(id: requestID)))
|
|
await Task.yield()
|
|
#expect(await channel._test_pendingRequestCount() == 0)
|
|
}
|
|
|
|
@Test func `request cancellation wins after response resumes`() async throws {
|
|
let probe = GatewayRequestProbe()
|
|
let session = GatewayTestWebSocketSession(
|
|
taskFactory: {
|
|
GatewayTestWebSocketTask(sendHook: { _, message, sendIndex in
|
|
guard sendIndex == 1,
|
|
let requestID = GatewayWebSocketTestSupport.requestID(from: message)
|
|
else { return }
|
|
await probe.record(requestID)
|
|
})
|
|
})
|
|
let channel = try GatewayChannelActor(
|
|
url: #require(URL(string: "ws://example.invalid")),
|
|
token: nil,
|
|
session: WebSocketSessionBox(session: session))
|
|
let resumedGate = GatewayRequestStartGate()
|
|
await channel._test_setRequestResumedHandler { await resumedGate.wait() }
|
|
let request = Task {
|
|
try await channel.request(method: "response-cancel-race", params: nil, timeoutMs: 5000)
|
|
}
|
|
let requestID = await probe.wait()
|
|
let socket = try #require(session.latestTask())
|
|
|
|
socket.emitReceiveSuccessOnce(.data(GatewayWebSocketTestSupport.okResponseData(id: requestID)))
|
|
await resumedGate.waitUntilEntered()
|
|
request.cancel()
|
|
await resumedGate.release()
|
|
|
|
await #expect(throws: CancellationError.self) {
|
|
try await request.value
|
|
}
|
|
#expect(await channel._test_pendingRequestCount() == 0)
|
|
}
|
|
|
|
@Test func `request cancellation wins after disconnect resumes an error`() async throws {
|
|
let probe = GatewayRequestProbe()
|
|
let session = GatewayTestWebSocketSession(
|
|
taskFactory: {
|
|
GatewayTestWebSocketTask(sendHook: { _, message, sendIndex in
|
|
guard sendIndex == 1,
|
|
let requestID = GatewayWebSocketTestSupport.requestID(from: message)
|
|
else { return }
|
|
await probe.record(requestID)
|
|
})
|
|
})
|
|
let channel = try GatewayChannelActor(
|
|
url: #require(URL(string: "ws://example.invalid")),
|
|
token: nil,
|
|
session: WebSocketSessionBox(session: session))
|
|
let resumedGate = GatewayRequestStartGate()
|
|
await channel._test_setRequestResumedHandler { await resumedGate.wait() }
|
|
let request = Task {
|
|
try await channel.request(method: "disconnect-cancel-race", params: nil, timeoutMs: 5000)
|
|
}
|
|
_ = await probe.wait()
|
|
let socket = try #require(session.latestTask())
|
|
|
|
socket.emitReceiveFailure()
|
|
await resumedGate.waitUntilEntered()
|
|
request.cancel()
|
|
await resumedGate.release()
|
|
|
|
await #expect(throws: CancellationError.self) {
|
|
try await request.value
|
|
}
|
|
#expect(await channel._test_pendingRequestCount() == 0)
|
|
}
|
|
|
|
@Test func `pre-cancelled request never dispatches`() async throws {
|
|
let session = GatewayTestWebSocketSession()
|
|
let channel = try GatewayChannelActor(
|
|
url: #require(URL(string: "ws://example.invalid")),
|
|
token: nil,
|
|
session: WebSocketSessionBox(session: session))
|
|
try await channel.connect()
|
|
let socket = try #require(session.latestTask())
|
|
#expect(socket.snapshotSendCount() == 1)
|
|
let gate = GatewayRequestStartGate()
|
|
let request = Task {
|
|
await gate.wait()
|
|
return try await channel.request(method: "never-send", params: nil, timeoutMs: 100)
|
|
}
|
|
await gate.waitUntilEntered()
|
|
|
|
request.cancel()
|
|
await gate.release()
|
|
|
|
await #expect(throws: CancellationError.self) {
|
|
try await request.value
|
|
}
|
|
await Task.yield()
|
|
#expect(socket.snapshotSendCount() == 1)
|
|
#expect(await channel._test_pendingRequestCount() == 0)
|
|
}
|
|
|
|
@Test func `pre-cancelled send never dispatches`() async throws {
|
|
let session = GatewayTestWebSocketSession()
|
|
let channel = try GatewayChannelActor(
|
|
url: #require(URL(string: "ws://example.invalid")),
|
|
token: nil,
|
|
session: WebSocketSessionBox(session: session))
|
|
try await channel.connect()
|
|
let socket = try #require(session.latestTask())
|
|
#expect(socket.snapshotSendCount() == 1)
|
|
let gate = GatewayRequestStartGate()
|
|
let send = Task {
|
|
await gate.wait()
|
|
try await channel.send(method: "never-send", params: nil)
|
|
}
|
|
await gate.waitUntilEntered()
|
|
|
|
send.cancel()
|
|
await gate.release()
|
|
|
|
await #expect(throws: CancellationError.self) {
|
|
try await send.value
|
|
}
|
|
await Task.yield()
|
|
#expect(socket.snapshotSendCount() == 1)
|
|
}
|
|
|
|
@Test func `request cancellation leaves a shared connect promptly`() async throws {
|
|
let connectGate = GatewayRequestStartGate()
|
|
let session = GatewayTestWebSocketSession(
|
|
taskFactory: {
|
|
GatewayTestWebSocketTask(sendHook: { _, _, sendIndex in
|
|
guard sendIndex == 0 else { return }
|
|
await connectGate.wait()
|
|
})
|
|
})
|
|
let channel = try GatewayChannelActor(
|
|
url: #require(URL(string: "ws://example.invalid")),
|
|
token: nil,
|
|
session: WebSocketSessionBox(session: session))
|
|
let connecting = Task { try await channel.connect() }
|
|
await connectGate.waitUntilEntered()
|
|
let request = Task {
|
|
try await channel.request(method: "cancel-during-connect", params: nil, timeoutMs: 5000)
|
|
}
|
|
for _ in 0..<1000 {
|
|
if await channel._test_connectWaiterCount() == 2 {
|
|
break
|
|
}
|
|
try? await Task.sleep(nanoseconds: 1_000_000)
|
|
}
|
|
#expect(await channel._test_connectWaiterCount() == 2)
|
|
|
|
request.cancel()
|
|
|
|
await #expect(throws: CancellationError.self) {
|
|
try await AsyncTimeout.withTimeout(
|
|
seconds: 1,
|
|
onTimeout: { GatewayRequestCancellationTimeout() },
|
|
operation: { try await request.value })
|
|
}
|
|
#expect(await channel._test_connectWaiterCount() == 1)
|
|
#expect(await channel._test_pendingRequestCount() == 0)
|
|
let socket = try #require(session.latestTask())
|
|
#expect(socket.snapshotSendCount() == 1)
|
|
|
|
await connectGate.release()
|
|
try await connecting.value
|
|
}
|
|
|
|
@Test func `cancelling the initiating connect leaves the shared attempt alive`() async throws {
|
|
let connectGate = GatewayRequestStartGate()
|
|
let session = GatewayTestWebSocketSession(
|
|
taskFactory: {
|
|
GatewayTestWebSocketTask(sendHook: { _, _, sendIndex in
|
|
guard sendIndex == 0 else { return }
|
|
await connectGate.wait()
|
|
})
|
|
})
|
|
let channel = try GatewayChannelActor(
|
|
url: #require(URL(string: "ws://example.invalid")),
|
|
token: nil,
|
|
session: WebSocketSessionBox(session: session))
|
|
let initiator = Task { try await channel.connect() }
|
|
await connectGate.waitUntilEntered()
|
|
let peer = Task { try await channel.connect() }
|
|
for _ in 0..<1000 {
|
|
if await channel._test_connectWaiterCount() == 2 {
|
|
break
|
|
}
|
|
try? await Task.sleep(nanoseconds: 1_000_000)
|
|
}
|
|
#expect(await channel._test_connectWaiterCount() == 2)
|
|
|
|
initiator.cancel()
|
|
|
|
await #expect(throws: CancellationError.self) {
|
|
try await AsyncTimeout.withTimeout(
|
|
seconds: 1,
|
|
onTimeout: { GatewayRequestCancellationTimeout() },
|
|
operation: { try await initiator.value })
|
|
}
|
|
#expect(await channel._test_connectWaiterCount() == 1)
|
|
let socket = try #require(session.latestTask())
|
|
#expect(socket.snapshotSendCount() == 1)
|
|
|
|
await connectGate.release()
|
|
try await peer.value
|
|
#expect(await channel._test_connectWaiterCount() == 0)
|
|
#expect(socket.snapshotSendCount() == 1)
|
|
}
|
|
}
|