diff --git a/Packages/macOS/CmuxNext/Package.swift b/Packages/macOS/CmuxNext/Package.swift index 8c66bf77b92c..ed25179fb87c 100644 --- a/Packages/macOS/CmuxNext/Package.swift +++ b/Packages/macOS/CmuxNext/Package.swift @@ -1219,6 +1219,11 @@ let packageTargets: [Target] = uiLayerPreviewTargets + [ ], swiftSettings: daemonSwiftSettings ), + .testTarget( + name: "CmuxNextCloudTests", + dependencies: ["CmuxNextCloud"], + swiftSettings: daemonSwiftSettings + ), .target( name: "CmuxNextCloudUI", dependencies: ["CmuxNextCloud", "CmuxNextDesign"], diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartChat.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartChat.swift index 7334407aae9a..76e2c33f279e 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartChat.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartChat.swift @@ -139,6 +139,10 @@ struct CloudStartChat { /// this Mac's tab: the chat is the machine's now. private func open(_ start: AgentPaneCloudStart, tab: TabModel, on machine: CloudMachineSession, cwd: String?, tabKey: String) async throws -> String { + await CloudOutdatedMachine.updateBeforeUsingCapability( + holds: { services.agentTabs.holdsTabs(machine.daemon) }, + link: { CloudMachineUpdates.handshake(machine, machines: services.machines) }, + update: { await CloudMachineUpdates.updateNow(machine.machineID, services: services) }) let context = AppActionContext(services: services) let anchor = try await CloudHandlers.terminalAnchor(on: machine, context) let pending = try services.agentTabs.open(in: anchor.pane.handle, of: machine.daemon, seed: AgentPaneSeedSource(start.seed(cwd: cwd)), diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextCloud/Move/CloudOutdatedMachine.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextCloud/Move/CloudOutdatedMachine.swift index e17c682d8d65..bd3b30e832f2 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextCloud/Move/CloudOutdatedMachine.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextCloud/Move/CloudOutdatedMachine.swift @@ -16,6 +16,25 @@ public struct CloudOutdatedMachine { !ok && errorCode == code && side != "source" } + /// Updates a machine before using a capability that its current daemon does not advertise. + /// The update restarts the daemon, so the capability must be read from the new handshake rather + /// than from the old connection. If the update cannot run or the new link does not arrive before + /// `relink`, this returns and the caller's existing capability refusal remains the final result. + @MainActor + public static func updateBeforeUsingCapability( + holds: @escaping @MainActor @Sendable () -> Bool, + link: @escaping @MainActor @Sendable () -> String?, + relink: Duration = .seconds(90), + update: @escaping @MainActor @Sendable () async -> Bool + ) async { + guard !holds() else { return } + let before = link() + guard await update() else { return } + _ = await CloudWait.holds(within: relink) { + link().map { $0 != before } ?? false + } + } + /// Runs `copy` on the machine's link socket (`socket`, nil when not connected: `notConnected` is thrown); when /// its answer (read by `answer`) is the machine's older daemon refusing it (``updatesBeforeCopyingAgain(ok:errorCode:side:)``) /// and `update` updated the machine (true), runs it once more. Otherwise the first answer stands: nothing else updates it. diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextCloudTests/CloudOutdatedMachineTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextCloudTests/CloudOutdatedMachineTests.swift new file mode 100644 index 000000000000..4f99297473b0 --- /dev/null +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextCloudTests/CloudOutdatedMachineTests.swift @@ -0,0 +1,60 @@ +import CmuxNextCloud +import Testing + +@Suite +@MainActor +struct CloudOutdatedMachineTests { + @Test func anOutdatedMachineUpdatesBeforeItsCapabilityIsUsed() async { + var handshake = "old" + var holdsTabs = false + var updates = 0 + + await CloudOutdatedMachine.updateBeforeUsingCapability( + holds: { holdsTabs }, + link: { handshake }, + relink: .seconds(1), + update: { + updates += 1 + await Task.yield() + handshake = "new" + holdsTabs = true + return true + }) + + #expect(updates == 1) + #expect(handshake == "new") + #expect(holdsTabs) + } + + @Test func anUpdateThatDoesNotRelinkReturnsWithinTheBoundedWait() async { + var holdsTabs = false + var updates = 0 + + await CloudOutdatedMachine.updateBeforeUsingCapability( + holds: { holdsTabs }, + link: { "old" }, + relink: .milliseconds(10), + update: { + updates += 1 + return true + }) + + #expect(updates == 1) + #expect(!holdsTabs) + } + + @Test func anAlreadyCurrentMachineDoesNotUpdate() async { + var updates = 0 + + await CloudOutdatedMachine.updateBeforeUsingCapability( + holds: { true }, + link: { "current" }, + relink: .milliseconds(10), + update: { + updates += 1 + return true + }) + + #expect(updates == 0) + } +}