Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -219,12 +219,21 @@ public actor CloudWireGuardHub {
}
}

/// Schedules account-level preparation without making fleet discovery wait for enrollment.
/// Repeated refreshes and a first terminal join the same startup and keep one shared claim.
public func prepareForCloudUse() {
/// Schedules account-level preparation without making activation or fleet
/// discovery wait for enrollment. Repeated refreshes and a first terminal
/// join the same startup and keep one shared claim. The optional scope is
/// used by first-use activation so a background task cannot enroll a
/// different account if sign-out or team selection changes while it runs.
public func prepareForCloudUse(
allowWhenCloudDisabled: Bool = false,
expectedTeamScope: AuthenticatedTeamScope? = nil
) {
guard !Task.isCancelled, preparationTask == nil else { return }
preparationTask = Task { [weak self] in
_ = try? await self?.prewarm()
_ = try? await self?.prewarm(
allowWhenCloudDisabled: allowWhenCloudDisabled,
expectedTeamScope: expectedTeamScope
)
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

Expand Down
25 changes: 22 additions & 3 deletions Sources/Surfaces/CmuxTuiSurfaceProviderRegistry+Activation.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import CmuxAuthRuntime
import CmuxCloud
import CmuxSettings

Expand Down Expand Up @@ -34,9 +35,8 @@ extension CmuxTuiSurfaceProviderRegistry {
if AppDelegate.shared?.auth?.coordinator.authenticatedTeamScope != expectedTeamScope {
throw VMClientError.notSignedIn
}
guard let wireGuardHub else { throw VMClientError.cloudMachinesDisabled }
_ = try await wireGuardHub.prewarm(
allowWhenCloudDisabled: true,
await prepareActivationHub(
wireGuardHub: wireGuardHub,
expectedTeamScope: expectedTeamScope
)
try Task.checkCancellation()
Expand All @@ -48,6 +48,25 @@ extension CmuxTuiSurfaceProviderRegistry {
}
}

/// Runs activation's carrier preparation. Kept as a seam so activation can
/// be tested independently from the app's live auth and VM client graph.
func prepareActivationHub(
wireGuardHub: CloudWireGuardHub?,
expectedTeamScope: AuthenticatedTeamScope
) async {
// The bundled cmux-tui client is optional. Cloud activation still
// enables machine creation when this build cannot host the terminal
// carrier; restored links remain retryable when a present hub is
// temporarily unavailable. A present hub is also prepared in the
// background so a missing socket or stale enrollment cannot hold
// activation open.
guard let wireGuardHub else { return }
await wireGuardHub.prepareForCloudUse(
allowWhenCloudDisabled: true,
expectedTeamScope: expectedTeamScope
)
}

Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
/// Stops activation-only hub work after cancellation or a failed readiness
/// attempt. Persisted tunnel identity remains available for the next
/// retry; no Cloud operation is left running while the marker is off.
Expand Down
38 changes: 38 additions & 0 deletions cmuxTests/CloudActivationCoordinatorTests.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import CmuxAuthRuntime
import CmuxCloud
import CmuxSettings
import Foundation
Expand Down Expand Up @@ -111,6 +112,43 @@ struct CloudActivationCoordinatorTests {
#expect(!defaults.bool(forKey: CloudActivationCoordinator.activationKey))
}

@Test("An absent WireGuard hub does not roll back Cloud activation")
func absentWireGuardHubKeepsActivationEnabled() async throws {
let suite = "cmux.cloud.activation.absentHub.\(UUID().uuidString)"
let defaults = try #require(UserDefaults(suiteName: suite))
defer { defaults.removePersistentDomain(forName: suite) }
let registry = CmuxTuiSurfaceProviderRegistry(
links: CloudMachineLinkManager(clientURL: nil, hub: nil, hostThemeColors: { nil }),
wireGuardHub: nil,
allowsBackgroundWork: { true },
listPage: { nil },
notificationCenter: NotificationCenter()
)
let scope = AuthenticatedTeamScope(
session: AuthenticatedSessionIdentity(generation: 1, accountID: "account"),
teamID: "team",
generation: 1
)
let coordinator = CloudActivationCoordinator(
defaults: defaults,
notificationCenter: NotificationCenter(),
isAvailable: { true },
prepare: {
await registry.prepareActivationHub(
wireGuardHub: nil,
expectedTeamScope: scope
)
}
)

coordinator.enable()
await coordinator.activationTask?.value
await coordinator.cleanupTask?.value

#expect(coordinator.state == .enabled)
#expect(defaults.bool(forKey: CloudActivationCoordinator.activationKey))
}

@Test("Cancellation serializes cleanup before a replacement attempt")
func cancellationFencesReplacement() async throws {
let suite = "cmux.cloud.activation.cancelFence.\(UUID().uuidString)"
Expand Down
44 changes: 44 additions & 0 deletions cmuxTests/CloudWireGuardHubTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,50 @@ struct CloudWireGuardHubTests {
#expect(await h.hub.status().leases == 1)
}

@Test("A failed automatic preparation leaves restored-link demand retryable")
func automaticPreparationFailureDoesNotLoseRestoredLinkRetry() async throws {
let attempts = AttemptCounter()
let h = makeHarness(
enrollment: {
let attempt = await attempts.next()
if attempt <= 3 {
throw VMClientError.httpStatus(
503,
#"{"error":"vm_cloud_service_unavailable","retryable":true}"#
)
}
return CloudWireGuardHub.Enrollment(configPath: "/tmp/restored-link.conf", routes: ["10.0.0.0/8"])
},
backoff: [.seconds(1), .seconds(2)]
)

// Activation's background preparation is deliberately fire-and-forget.
// Exhaust that automatic sequence, as a restored provider would when
// its first graph refresh finds no usable hub.
await h.hub.prepareForCloudUse()
try await waitForPendingSleeps(h.gate, count: 1)
await h.gate.elapse()
try await waitForPendingSleeps(h.gate, count: 1)
await h.gate.elapse()
let exhausted = ContinuousClock.now + .seconds(10)
while ContinuousClock.now < exhausted, await attempts.value < 3 {
await Task.yield()
}
#expect(await attempts.value == 3)
#expect(!(await h.hub.status().running))
#expect(await h.hub.status().leases == 1, "The failed preparation retains its retry claim")

// A restored-link demand is explicit and must be able to retry the
// stopped hub immediately, even though automatic preparation failed.
let restoredLink = try await h.hub.acquire()
#expect(await attempts.value == 4)
#expect(restoredLink.ready.socketPath == h.socketPath)
#expect(await h.hub.status().leases == 2)
await h.hub.release(restoredLink.lease)
await h.hub.releasePrewarm()
await h.hub.stop()
}

@Test
func explicitOpenAndPrewarmShareEnrollmentRecovery() async throws {
let attempts = AttemptCounter()
Expand Down
Loading