diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+TransportTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+TransportTests.swift index 6595fd31f8..47d5b503c7 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+TransportTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+TransportTests.swift @@ -3,6 +3,120 @@ import Network #if AGENT_DEVICE_RUNNER_UNIT_TESTS extension RunnerTests { + func testUptimeHttpRemainsResponsiveWhileCommandQueueIsOccupied() throws { + let listener = try NWListener(using: .tcp, on: .any) + let ready = expectation(description: "HTTP listener ready") + listener.stateUpdateHandler = { state in + if case .ready = state { ready.fulfill() } + } + listener.newConnectionHandler = { connection in + connection.start(queue: self.transportQueue) + self.handle(connection: connection) + } + listener.start(queue: transportQueue) + defer { listener.cancel() } + wait(for: [ready], timeout: 2) + let port = try XCTUnwrap(listener.port) + let configuration = URLSessionConfiguration.ephemeral + configuration.timeoutIntervalForRequest = 3 + configuration.timeoutIntervalForResource = 3 + let client = URLSession(configuration: configuration) + defer { client.invalidateAndCancel() } + + let occupied = expectation(description: "command queue occupied") + let drained = DispatchSemaphore(value: 0) + let releaseWork = DispatchSemaphore(value: 0) + commandExecutionQueue.async { + occupied.fulfill() + releaseWork.wait() + drained.signal() + } + defer { + releaseWork.signal() + XCTAssertEqual(drained.wait(timeout: .now() + 2), .success) + } + wait(for: [occupied], timeout: 2) + + let started = ProcessInfo.processInfo.systemUptime + let uptime = try transportHttpResponse( + client: client, + port: port, + payload: #"{"command":"uptime","commandId":"occupied-preflight"}"# + ) + let elapsed = ProcessInfo.processInfo.systemUptime - started + XCTAssertTrue(uptime.ok) + XCTAssertNotNil(uptime.data?.currentUptimeMs) + XCTAssertLessThan(elapsed, 1) + XCTAssertEqual( + commandJournal.status(normalizedCommandId: "occupied-preflight").lifecycleState, + RunnerCommandLifecycleState.notAccepted.rawValue + ) + + let queued = transportHttpRequest( + client: client, + port: port, + payload: #"{"command":"appState","commandId":"after-preflight"}"# + ) + let acceptedDeadline = Date().addingTimeInterval(1) + var state: String? + repeat { + state = try transportHttpResponse( + client: client, + port: port, + payload: #"{"command":"status","statusCommandId":"after-preflight"}"# + ).data?.lifecycleState + } while state == RunnerCommandLifecycleState.notAccepted.rawValue && Date() < acceptedDeadline + XCTAssertEqual(state, RunnerCommandLifecycleState.accepted.rawValue) + releaseWork.signal() + wait(for: [queued.finished], timeout: 2) + let response = try queued.response() + XCTAssertEqual(response.error?.message, "appState requires appBundleId") + XCTAssertEqual( + commandJournal.status(normalizedCommandId: "after-preflight").lifecycleState, + RunnerCommandLifecycleState.failed.rawValue + ) + NSLog("AGENT_DEVICE_RUNNER_OCCUPIED_UPTIME_MS=%.1f", elapsed * 1000) + } + + private final class TransportHttpReply { + let finished = XCTestExpectation(description: "HTTP response received") + var data: Data? + var error: Error? + + func response() throws -> Response { + if let error { throw error } + return try JSONDecoder().decode(Response.self, from: XCTUnwrap(data)) + } + } + + private func transportHttpRequest( + client: URLSession, + port: NWEndpoint.Port, + payload: String + ) -> TransportHttpReply { + let reply = TransportHttpReply() + var request = URLRequest(url: URL(string: "http://127.0.0.1:\(port.rawValue)/command")!) + request.httpMethod = "POST" + request.httpBody = Data(payload.utf8) + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + client.dataTask(with: request) { data, _, error in + reply.data = data + reply.error = error + reply.finished.fulfill() + }.resume() + return reply + } + + private func transportHttpResponse( + client: URLSession, + port: NWEndpoint.Port, + payload: String + ) throws -> Response { + let reply = transportHttpRequest(client: client, port: port, payload: payload) + wait(for: [reply.finished], timeout: 2) + return try reply.response() + } + func testDuplicateCommandIdCoalescesOntoInFlightExecution() throws { let command = try JSONDecoder().decode( Command.self, diff --git a/src/daemon/__tests__/wait-runtime.test.ts b/src/daemon/__tests__/wait-runtime.test.ts index 6fab968aba..4916c05ee9 100644 --- a/src/daemon/__tests__/wait-runtime.test.ts +++ b/src/daemon/__tests__/wait-runtime.test.ts @@ -778,7 +778,7 @@ test.for(['runner-start', 'target-discovery'] as const)( retriable: true, readableCaptures: 0, captures: 1, - polls: [{ startedMs: 0, outcome: 'readiness' }], + polls: [{ startedMs: expect.any(Number), outcome: 'readiness' }], }); expect(response.error.details?.captureStalled).toBeUndefined(); },