diff --git a/.github/workflows/cloud-command-deadlines.yml b/.github/workflows/cloud-command-deadlines.yml new file mode 100644 index 000000000000..788abc0f9d56 --- /dev/null +++ b/.github/workflows/cloud-command-deadlines.yml @@ -0,0 +1,51 @@ +name: Cloud command deadlines +on: + pull_request: + paths: + - Sources/Cloud/CloudTuiPersistent*.swift + - Sources/Cloud/CloudTuiManualIO*.swift + - Sources/Cloud/CloudTuiTerminalProjectionTarget.swift + - Sources/Cloud/CloudTuiRemoteColors.swift + - cmuxTests/CloudCommand*.swift + - cmuxTests/CloudTuiManualIOConnectionTests.swift + - tests/fixtures/cloud-command-deadlines/** + - tests/run_cloud_command_deadline_tests.sh + - Sources/Mobile/MobileHostIdentity.swift + - Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/CmxDeviceIDCanonicalization.swift + - Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/Concurrency/AtomicBooleanGate.swift + - Packages/macOS/CmuxFoundation/Sources/CmuxFoundationAtomicsC/** + - tests/fixtures/mobile-host-identity-cold-start/** + - tests/run_mobile_host_identity_cold_start_tests.sh + - tests/test_mobile_host_identity_cold_start.py + - .github/workflows/cloud-command-deadlines.yml + workflow_dispatch: + inputs: + source_ref: + description: Source commit to exercise (empty uses the dispatch ref) + default: "" + type: string + runner: + description: Hosted macOS runner + default: blacksmith-6vcpu-macos-15 + type: choice + options: + - blacksmith-6vcpu-macos-15 + - blacksmith-6vcpu-macos-26 +permissions: + contents: read +concurrency: + group: cloud-command-deadlines-${{ github.ref }}-${{ inputs.source_ref }}-${{ inputs.runner }} + cancel-in-progress: true +jobs: + command-regressions: + runs-on: ${{ inputs.runner || vars.MACOS_RUNNER_15 || 'blacksmith-6vcpu-macos-15' }} + timeout-minutes: 5 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + persist-credentials: false + ref: ${{ inputs.source_ref || github.ref }} + - name: Exercise the persistent Cloud command transport in isolation + run: tests/run_cloud_command_deadline_tests.sh "$RUNNER_TEMP/issue-11008-${{ github.run_id }}" + - name: Prove cold identity publication before defaults notification + run: tests/run_mobile_host_identity_cold_start_tests.sh "$RUNNER_TEMP/issue-11008-identity-${{ github.run_id }}" diff --git a/Sources/Cloud/CloudTuiPersistentResourceConnection.swift b/Sources/Cloud/CloudTuiPersistentResourceConnection.swift index abf219284a55..9cbee9792c17 100644 --- a/Sources/Cloud/CloudTuiPersistentResourceConnection.swift +++ b/Sources/Cloud/CloudTuiPersistentResourceConnection.swift @@ -9,6 +9,7 @@ actor CloudTuiPersistentResourceConnection { let continuation: CheckedContinuation let request: CloudTuiRequest let deadline: Task + let isExpired: @Sendable () -> Bool } private struct Subscription { let continuation: AsyncStream.Continuation @@ -75,7 +76,16 @@ actor CloudTuiPersistentResourceConnection { } func request(_ request: CloudTuiRequest, timeout: Duration = .seconds(30)) async throws -> Data { + // Explicit `self.` is required: the compiler declines to open the + // existential when the argument is an implicit-self stored property. + try await performRequest(request, timeout: timeout, clock: self.clock) + } + + private func performRequest( + _ request: CloudTuiRequest, timeout: Duration, clock: RequestClock + ) async throws -> Data where RequestClock.Duration == Duration { try Task.checkCancellation() + guard timeout > .zero else { throw CloudMachineLink.LinkError.timedOut } try await start() try Task.checkCancellation() guard !closed else { throw Self.protocolFailure } @@ -85,20 +95,27 @@ actor CloudTuiPersistentResourceConnection { let id = nextID() let encoded = try request.envelope(id: id) guard encoded.count <= 256 * 1024 - 1 else { throw CloudMachineLink.LinkError.inputTooLarge } - return try await withTaskCancellationHandler(operation: { + let expiresAt = clock.now.advanced(by: timeout) + let result: Data = try await withTaskCancellationHandler(operation: { try Task.checkCancellation() return try await withCheckedThrowingContinuation { continuation in - let clock = self.clock + // A genuine request deadline, owned and cancelled with its pending entry. let deadline = Task { [weak self, clock] in - do { try await clock.sleep(for: timeout) } catch { return } + do { try await clock.sleep(until: expiresAt, tolerance: nil) } catch { return } await self?.retire(id, error: CloudMachineLink.LinkError.timedOut) } - pending[id] = Pending(continuation: continuation, request: request, deadline: deadline) + pending[id] = Pending( + continuation: continuation, request: request, deadline: deadline, + isExpired: { clock.now >= expiresAt } + ) connection.send(line: encoded + Data([0x0A])) } }, onCancel: { [weak self] in Task { await self?.retire(id, error: CancellationError()) } }) + // The cancellation handler's actor hop can arrive after a successful response. + try Task.checkCancellation() + return result } private func retire(_ id: String, error: Error) { @@ -159,12 +176,18 @@ actor CloudTuiPersistentResourceConnection { } guard let id = root["id"] as? String, let ok = root["ok"] as? NSNumber, CFGetTypeID(ok) == CFBooleanGetTypeID() else { close(); return } - guard let entry = pending.removeValue(forKey: id) else { + guard let entry = pending[id] else { // Responses to cancellation and retired requests are harmless. guard id.hasPrefix("request-\(namespace)-"), let suffix = id.split(separator: "-").last, let issued = UInt64(suffix), issued > 0, issued <= sequence else { close(); return } return } + // After suspend/resume, the socket reader may run before the deadline task. + guard !entry.isExpired() else { + retire(id, error: CloudMachineLink.LinkError.timedOut) + return + } + pending.removeValue(forKey: id) entry.deadline.cancel() if !entry.request.raw && (root["protocol"] as? String != "cmux.protocol/2" || root["type"] as? String != "response") { entry.continuation.resume(throwing: Self.protocolFailure); close(); return diff --git a/Sources/Surfaces/SurfaceCatalog+Snapshot.swift b/Sources/Surfaces/SurfaceCatalog+Snapshot.swift index b3c64414f6cb..fc2bdff60675 100644 --- a/Sources/Surfaces/SurfaceCatalog+Snapshot.swift +++ b/Sources/Surfaces/SurfaceCatalog+Snapshot.swift @@ -1,12 +1,33 @@ import Foundation extension SurfaceCatalog { + /// Reconciles machine-summary workspace names with the accepted daemon graph. + /// Resource rows may carry optimistic creation or rename overlays, but the + /// authoritative snapshot must expose the last accepted name for identities + /// already present in `cloudStates`. + private func authoritativeMachineInfo(_ info: SurfaceMachineInfo) -> SurfaceMachineInfo { + guard case .cloud = info.id, + let state = cloudStates[info.id], + let workspaces = info.remoteWorkspaces else { return info } + let accepted = Dictionary(uniqueKeysWithValues: state.workspaces.map { workspace in + (workspace.id, SurfaceRemoteWorkspace( + id: workspace.id, + name: workspace.name, + index: workspace.index, + focused: workspace.focused + )) + }) + var adjusted = info + adjusted.remoteWorkspaces = workspaces.map { accepted[$0.id] ?? $0 } + return adjusted + } + /// The provider rows without any deletion or rename intent applied, used to /// enumerate destructive operations. Presentation still withholds a stale /// graph's cwd, and stale machines are flagged, exactly as `snapshot` does. var authoritativeSnapshot: SurfaceCatalogSnapshot { SurfaceCatalogSnapshot( - machines: machines.values.sorted { + machines: machines.values.map(authoritativeMachineInfo).sorted { if $0.id.isLocal != $1.id.isLocal { return $0.id.isLocal } return $0.name.localizedStandardCompare($1.name) == .orderedAscending }, diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 92071dfa8ca8..2cd477771590 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -729,6 +729,9 @@ EA00C01A1F5287765D8117C5 /* CloudBrowserRouting.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEF04D7E65B3CF1EDF513AFE /* CloudBrowserRouting.swift */; }; D01100800000000000000006 /* CloudCatalogQueryTestProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = D01100800000000000000005 /* CloudCatalogQueryTestProvider.swift */; }; 954865E90FA14C5790614136 /* CloudClosedPanelRestoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1889B99217E645E7A477621A /* CloudClosedPanelRestoreTests.swift */; }; + E1724349548A4068A8377242 /* CloudCommandDeadlineClock.swift in Sources */ = {isa = PBXBuildFile; fileRef = A54945EFADC4466F8B520CA0 /* CloudCommandDeadlineClock.swift */; }; + 2626F8CD8D00447CA73AF900 /* CloudCommandDeadlineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 791992D7B1A443AF960CC85A /* CloudCommandDeadlineTests.swift */; }; + EA2B013FB51E4424834575A5 /* CloudCommandOperationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AAC366FD05843F5BC94E64D /* CloudCommandOperationTests.swift */; }; 9A1261600000000000000004 /* CloudDesktopAccessTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A1261600000000000000003 /* CloudDesktopAccessTests.swift */; }; 9A126160000000000000000A /* CloudDesktopConnectionObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A1261600000000000000009 /* CloudDesktopConnectionObserver.swift */; }; 9A1261600000000000000006 /* CloudDesktopWebSocketTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A1261600000000000000005 /* CloudDesktopWebSocketTests.swift */; }; @@ -4757,6 +4760,9 @@ CEF04D7E65B3CF1EDF513AFE /* CloudBrowserRouting.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CloudBrowserRouting.swift; sourceTree = ""; }; D01100800000000000000005 /* CloudCatalogQueryTestProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudCatalogQueryTestProvider.swift; sourceTree = ""; }; 1889B99217E645E7A477621A /* CloudClosedPanelRestoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudClosedPanelRestoreTests.swift; sourceTree = ""; }; + A54945EFADC4466F8B520CA0 /* CloudCommandDeadlineClock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudCommandDeadlineClock.swift"; sourceTree = ""; }; + 791992D7B1A443AF960CC85A /* CloudCommandDeadlineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudCommandDeadlineTests.swift"; sourceTree = ""; }; + 3AAC366FD05843F5BC94E64D /* CloudCommandOperationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudCommandOperationTests.swift"; sourceTree = ""; }; 9A1261600000000000000003 /* CloudDesktopAccessTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudDesktopAccessTests.swift"; sourceTree = ""; }; 9A1261600000000000000009 /* CloudDesktopConnectionObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudDesktopConnectionObserver.swift"; sourceTree = ""; }; 9A1261600000000000000005 /* CloudDesktopWebSocketTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudDesktopWebSocketTests.swift"; sourceTree = ""; }; @@ -11841,6 +11847,9 @@ 7A0CE100000000000000072D /* CloudTunnelStatusBlockerTests.swift */, 7A0CE100000000000000072F /* CloudTunnelBannerTests.swift */, 8C629AFC3F73DD0690975630 /* CmuxTuiSurfaceProviderTests+LinkCancellation.swift */, + 791992D7B1A443AF960CC85A /* CloudCommandDeadlineTests.swift */, + 3AAC366FD05843F5BC94E64D /* CloudCommandOperationTests.swift */, + A54945EFADC4466F8B520CA0 /* CloudCommandDeadlineClock.swift */, C12093000000000000000001 /* CloudVPNExplicitActivationTests.swift */, 7A0CE1000000000000000731 /* CloudLoopbackPortForwardTests.swift */, A6D3896BB28B4549B3D33A82 /* CloudPortForwardAddressReuseTests.swift */, @@ -15611,6 +15620,9 @@ BD85C5CFA3F5C1D251113A3B /* CloudBrowserProxyIntegrationTests.swift in Sources */, D01100800000000000000006 /* CloudCatalogQueryTestProvider.swift in Sources */, 954865E90FA14C5790614136 /* CloudClosedPanelRestoreTests.swift in Sources */, + E1724349548A4068A8377242 /* CloudCommandDeadlineClock.swift in Sources */, + 2626F8CD8D00447CA73AF900 /* CloudCommandDeadlineTests.swift in Sources */, + EA2B013FB51E4424834575A5 /* CloudCommandOperationTests.swift in Sources */, 9A1261600000000000000004 /* CloudDesktopAccessTests.swift in Sources */, 9A1261600000000000000006 /* CloudDesktopWebSocketTests.swift in Sources */, 5910F6B0C9704B8DB8607318 /* CloudDirectoryLifecycleTests.swift in Sources */, diff --git a/cmuxTests/CloudCommandDeadlineClock.swift b/cmuxTests/CloudCommandDeadlineClock.swift new file mode 100644 index 000000000000..bf7be431201f --- /dev/null +++ b/cmuxTests/CloudCommandDeadlineClock.swift @@ -0,0 +1,49 @@ +import Foundation + +/// A virtual command clock that can hold timer delivery while time advances. +/// Safety: Clock.now is synchronous; the lock protects only the test clock's instant and sleepers. +final class CloudCommandDeadlineClock: Clock, @unchecked Sendable { + typealias Instant = ContinuousClock.Instant + private let lock = NSLock() + private var instant = ContinuousClock.now + private var sleepers: [UUID: (Instant, AsyncStream.Continuation)] = [:] + private let registrations = AsyncStream.makeStream() + + var now: Instant { lock.withLock { instant } } + var minimumResolution: Duration { .nanoseconds(1) } + + func advance(by duration: Duration, wakingTimers: Bool = true) { + let ready = lock.withLock { + instant = instant.advanced(by: duration) + return wakingTimers ? sleepers.values.filter { $0.0 <= instant }.map { $0.1 } : [] + } + for continuation in ready { continuation.finish() } + } + + func waitUntilSleeping() async { + var iterator = registrations.stream.makeAsyncIterator() + _ = await iterator.next() + } + + func sleep(until deadline: Instant, tolerance: Duration?) async throws { + let id = UUID() + let wakeup = AsyncStream.makeStream() + register(id, deadline: deadline, continuation: wakeup.continuation) + defer { remove(id) } + registrations.continuation.yield(()) + // AsyncStream ends on cancellation, releasing even a deliberately held timer. + for await _ in wakeup.stream {} + try Task.checkCancellation() + } + + private func register(_ id: UUID, deadline: Instant, continuation: AsyncStream.Continuation) { + lock.withLock { + if deadline <= instant { continuation.finish() } + else { sleepers[id] = (deadline, continuation) } + } + } + + private func remove(_ id: UUID) { + _ = lock.withLock { sleepers.removeValue(forKey: id) } + } +} diff --git a/cmuxTests/CloudCommandDeadlineTests.swift b/cmuxTests/CloudCommandDeadlineTests.swift new file mode 100644 index 000000000000..feedc6325136 --- /dev/null +++ b/cmuxTests/CloudCommandDeadlineTests.swift @@ -0,0 +1,72 @@ +import Foundation +import Testing +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#elseif canImport(CloudCommandFixture) +@testable import CloudCommandFixture +#endif + +@Suite(.timeLimit(.minutes(1))) struct CloudCommandDeadlineTests { + private typealias Socket = CloudTuiManualIOConnectionTests + + @Test(arguments: [false, true]) + func responseAfterDeadlineFailsEvenWhenTimerHasNotResumed(raw: Bool) async throws { + let clock = CloudCommandDeadlineClock() + try await Socket.withResourceConnection(clock: clock) { channel, peer in + let command = Task { try await channel.request(CloudTuiRequest("session.ping", raw: raw), timeout: .seconds(30)) } + defer { command.cancel() } + let bytes = try await Socket.blocking { try Socket.readLine(peer) } + let request = try Socket.object(bytes) + let requestID = try #require(request["id"] as? String) + await clock.waitUntilSleeping() + clock.advance(by: .seconds(600), wakingTimers: false) + try Socket.write(peer, Socket.response(request, result: ["expired": true], raw: raw)) + do { + _ = try await command.value + Issue.record("a response delivered after resume cannot turn an expired request into success") + } catch CloudMachineLink.LinkError.timedOut {} + + // An expired request never closes the machine-owned socket or replays work. + async let sibling = channel.request(CloudTuiRequest("session.snapshot")) + try await Socket.blocking { + var next = try Socket.object(Socket.readLine(peer)) + if next["operation"] as? String == "request.cancel" { + #expect(!raw) + #expect((next["params"] as? [String: Any])?["request_id"] as? String == requestID) + next = try Socket.object(Socket.readLine(peer)) + } + #expect(next["operation"] as? String == "session.snapshot") + try Socket.write(peer, Socket.response(next, result: ["alive": true])) + } + let siblingResult = try await sibling + #expect(try Socket.object(siblingResult)["alive"] as? Bool == true) + #expect(!(await channel.isClosed)) + } + } + + @Test(arguments: [false, true]) + func responseBeforeDeadlinePreservesOutput(raw: Bool) async throws { + let clock = CloudCommandDeadlineClock() + try await Socket.withResourceConnection(clock: clock) { channel, peer in + async let command = channel.request(CloudTuiRequest("session.ping", raw: raw), timeout: .seconds(30)) + let bytes = try await Socket.blocking { try Socket.readLine(peer) } + await clock.waitUntilSleeping() + clock.advance(by: .seconds(29), wakingTimers: false) + try Socket.write(peer, Socket.response(Socket.object(bytes), result: ["value": "unchanged"], raw: raw)) + let commandResult = try await command + #expect(try Socket.object(commandResult)["value"] as? String == "unchanged") + } + } + + @Test(arguments: [Duration.zero, .seconds(-1)]) + func expiredBudgetIsRejectedBeforeOpeningSocket(timeout: Duration) async throws { + let channel = CloudTuiPersistentResourceConnection(socketPath: "/does/not/exist") + do { + _ = try await channel.request(CloudTuiRequest("session.ping"), timeout: timeout) + Issue.record("an expired request must be rejected") + } catch CloudMachineLink.LinkError.timedOut {} + await channel.close() + } +} diff --git a/cmuxTests/CloudCommandOperationTests.swift b/cmuxTests/CloudCommandOperationTests.swift new file mode 100644 index 000000000000..1a8aa7c20073 --- /dev/null +++ b/cmuxTests/CloudCommandOperationTests.swift @@ -0,0 +1,48 @@ +import Foundation +import Testing +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@MainActor +@Suite struct CloudCommandOperationTests { + @Test func commandTimeoutLeavesRecoverableOperationVisibleAfterLaterSuccess() async throws { + let recorder = CloudOperationRecorder() + let clock = CloudCommandDeadlineClock() + try await CloudTuiManualIOConnectionTests.withResourceConnection(clock: clock) { channel, peer in + let command = Task { + try await recorder.perform(.workspace, foreground: true) { + try await CloudOperationContext.phase(.process) { + try await channel.request(CloudTuiRequest("session.ping"), timeout: .seconds(30)) + } + } + } + defer { command.cancel() } + _ = try await CloudTuiManualIOConnectionTests.blocking { + try CloudTuiManualIOConnectionTests.readLine(peer) + } + await clock.waitUntilSleeping() + clock.advance(by: .seconds(30)) + do { + _ = try await command.value + Issue.record("a command deadline must fail the operation") + } catch CloudMachineLink.LinkError.timedOut {} + } + let failed = try #require(recorder.operations.first) + #expect(!failed.isRunning) + #expect(failed.outcome == .timeout) + #expect(failed.failure == .timeout) + #expect(failed.steps.first?.failure == .timeout) + #expect(failed.isVisibleInMachinesPanel) + #expect(CloudTuiDaemonAnswer(error: CloudMachineLink.LinkError.timedOut).isRetryable) + + let success = recorder.begin(.workspace) + await recorder.finish(success) + #expect(recorder.operations.first?.id == failed.id) + #expect(recorder.operations.first?.isVisibleInMachinesPanel == true) + recorder.dismiss(failed.id) + #expect(recorder.operations.allSatisfy { !$0.isVisibleInMachinesPanel }) + } +} diff --git a/cmuxTests/CloudTuiManualIOConnectionTests.swift b/cmuxTests/CloudTuiManualIOConnectionTests.swift index e0f078c574f7..536662a60332 100644 --- a/cmuxTests/CloudTuiManualIOConnectionTests.swift +++ b/cmuxTests/CloudTuiManualIOConnectionTests.swift @@ -5,6 +5,8 @@ import Testing @testable import cmux_DEV #elseif canImport(cmux) @testable import cmux +#elseif canImport(CloudCommandFixture) +@testable import CloudCommandFixture #endif @Suite struct CloudTuiManualIOConnectionTests { @@ -166,7 +168,7 @@ import Testing } @Test func persistentDeadlineCompletesWithoutAReplyOrClosingSiblings() async throws { - let clock = SidebarTestManualClock() + let clock = CloudCommandDeadlineClock() try await Self.withResourceConnection(clock: clock) { channel, peer in let expired = Task { try await channel.request(CloudTuiRequest("session.ping"), timeout: .milliseconds(100)) } _ = try await Self.blocking { try Self.readLine(peer) } @@ -225,8 +227,12 @@ import Testing @Test func persistentWirePreservesPayloadAndMutationKey() async throws { try await Self.withResourceConnection { channel, peer in let bytes = Data("--name secret\n\0payload".utf8) - var write = CloudTuiRequests.writeBytes(terminalID: "term_test", data: bytes) - write.idempotencyKey = "same-logical-input" + // Immutable so the `async let` capture is Sendable-clean under Swift 6 diagnostics. + let write: CloudTuiRequest = { + var request = CloudTuiRequests.writeBytes(terminalID: "term_test", data: bytes) + request.idempotencyKey = "same-logical-input" + return request + }() async let result = channel.request(write) let captured = try await Self.blocking { try Self.readLine(peer) } let request = try Self.object(captured) @@ -238,18 +244,18 @@ import Testing } } - private static func object(_ data: Data) throws -> [String: Any] { + static func object(_ data: Data) throws -> [String: Any] { guard let object = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { throw socketError() } return object } - private static func response(_ request: [String: Any], result: [String: Any], raw: Bool = false) throws -> Data { + static func response(_ request: [String: Any], result: [String: Any], raw: Bool = false) throws -> Data { var response: [String: Any] = ["id": request["id"]!, "ok": true, raw ? "data" : "result": result] if !raw { response["protocol"] = "cmux.protocol/2"; response["type"] = "response" } return try JSONSerialization.data(withJSONObject: response) + Data([10]) } - private static func withResourceConnection( + static func withResourceConnection( clock: any Clock = ContinuousClock(), _ body: (CloudTuiPersistentResourceConnection, Int32) async throws -> Void ) async throws { @@ -316,7 +322,7 @@ import Testing try await body(connection, peer) } - private static func write(_ descriptor: Int32, _ data: Data) throws { + static func write(_ descriptor: Int32, _ data: Data) throws { try data.withUnsafeBytes { raw in var offset = 0 while offset < raw.count { @@ -328,7 +334,7 @@ import Testing } } - private static func readLine(_ descriptor: Int32) throws -> Data { + static func readLine(_ descriptor: Int32) throws -> Data { var result = Data() var byte: UInt8 = 0 while true { @@ -343,7 +349,7 @@ import Testing /// Blocking peer I/O stays off Swift's cooperative executor and the client's /// dispatch queue. Each test owns its descriptors until these jobs finish. - private static func blocking(_ operation: @escaping @Sendable () throws -> T) async throws -> T { + static func blocking(_ operation: @escaping @Sendable () throws -> T) async throws -> T { try await withCheckedThrowingContinuation { continuation in DispatchQueue.global().async { continuation.resume(with: Result { try operation() }) } } diff --git a/tests/fixtures/cloud-command-deadlines/StandaloneDependencies.swift b/tests/fixtures/cloud-command-deadlines/StandaloneDependencies.swift new file mode 100644 index 000000000000..62e03e29a1fa --- /dev/null +++ b/tests/fixtures/cloud-command-deadlines/StandaloneDependencies.swift @@ -0,0 +1,15 @@ +import Foundation + +// The resource actor, socket I/O, framing and request builders compile unchanged. +// Only app-level error and cursor declarations are stubbed for this isolated runner. +enum CloudMachineLink { + enum LinkError: Error { + case timedOut + case inputTooLarge + case exited(status: Int32, output: String) + } +} +struct CloudVMCursor: Sendable { + let generation: String + let revision: UInt64 +} diff --git a/tests/fixtures/mobile-host-identity-cold-start/DisplayMetadataDependencies.swift b/tests/fixtures/mobile-host-identity-cold-start/DisplayMetadataDependencies.swift index c27a150b403b..cbef9e43dfd6 100644 --- a/tests/fixtures/mobile-host-identity-cold-start/DisplayMetadataDependencies.swift +++ b/tests/fixtures/mobile-host-identity-cold-start/DisplayMetadataDependencies.swift @@ -20,6 +20,7 @@ public enum SocketControlSettings { public enum SocketPathMarkerFiles { public enum Variant { case stable + case rc(String?) case nightly(String?) case staging(String?) case dev(String?) diff --git a/tests/run_cloud_command_deadline_tests.sh b/tests/run_cloud_command_deadline_tests.sh new file mode 100755 index 000000000000..77062874065c --- /dev/null +++ b/tests/run_cloud_command_deadline_tests.sh @@ -0,0 +1,30 @@ +#!/bin/bash +# Run only on a disposable/leased Mac or hosted CI, never the user's shared Mac. +# This compiles the persistent command transport and tests with app-level value dependencies stubbed. +set -euo pipefail +ROOT=$(cd "$(dirname "$0")/.." && pwd) +DEST=${1:?pass an isolated scratch directory} +mkdir -p "$DEST/Sources/CloudCommandFixture" "$DEST/Tests/CloudCommandFixtureTests" +cat > "$DEST/Package.swift" <<'SWIFT' +// swift-tools-version: 6.0 +import PackageDescription +let package = Package( + name: "CloudCommandFixture", + platforms: [.macOS(.v14)], + targets: [ + .target(name: "CloudCommandFixture"), + .testTarget(name: "CloudCommandFixtureTests", dependencies: ["CloudCommandFixture"]) + ], + swiftLanguageModes: [.v5] +) +SWIFT +for name in CloudTuiPersistentResourceConnection CloudTuiPersistentRequestBuilder CloudTuiTerminalProjectionTarget \ + CloudTuiManualIOConnection CloudTuiManualIODescriptorLease CloudTuiManualIOCommand \ + CloudTuiManualIOFrame CloudTuiManualIOFrameDecoder CloudTuiRemoteColors; do + cp "$ROOT/Sources/Cloud/$name.swift" "$DEST/Sources/CloudCommandFixture/" +done +cp "$ROOT/tests/fixtures/cloud-command-deadlines/StandaloneDependencies.swift" "$DEST/Sources/CloudCommandFixture/" +for name in CloudCommandDeadlineClock CloudCommandDeadlineTests CloudTuiManualIOConnectionTests; do + cp "$ROOT/cmuxTests/$name.swift" "$DEST/Tests/CloudCommandFixtureTests/" +done +swift test --package-path "$DEST" -Xswiftc -warnings-as-errors