Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
a2272c2
fix: announce private VM addresses before Cloud connections
austinywang Sep 10, 2026
830324e
test: remove obsolete IPv4-only desktop listener assertion
austinywang Sep 10, 2026
dc66b80
test: preserve VM readiness when one private family is pending
austinywang Sep 10, 2026
db67a30
test: exercise unavailable IPv4 and IPv6 announcement sockets
austinywang Sep 10, 2026
2a64eaf
fix: keep Cloud VMs usable when one private family is unavailable
austinywang Sep 10, 2026
5c20070
refactor: isolate Cloud provider refresh entrypoints
austinywang Sep 10, 2026
4d1fe36
test: reproduce competing Cloud snapshot refreshes and early catalog …
austinywang Sep 10, 2026
ec0502e
fix: serialize Cloud snapshots and reconnect restored desktop tabs
austinywang Sep 10, 2026
c8f4ebc
refactor: isolate same-revision Cloud snapshot comparison
austinywang Sep 10, 2026
9af510d
test: distinguish connection telemetry from Cloud graph conflicts
austinywang Sep 10, 2026
bd208c8
fix: keep volatile client diagnostics out of Cloud revision checks
austinywang Sep 10, 2026
44dc5c2
test: reproduce stale session revision after a Cloud delta
austinywang Sep 10, 2026
23f9ff3
test: evaluate cursor mutations before assertion macros
austinywang Sep 10, 2026
2adf75d
fix: advance the mirrored session revision with Cloud deltas
austinywang Sep 10, 2026
192789c
test: preserve Cloud snapshot validity across live terminal resizes
austinywang Sep 10, 2026
35cfc28
fix: separate live terminal geometry from Cloud revision checks
austinywang Sep 10, 2026
b5baacb
test: distinguish live PTY titles from revisioned terminal identity
austinywang Sep 10, 2026
ecdaed2
fix: treat PTY titles as live Cloud snapshot observations
austinywang Sep 10, 2026
1dd7ee9
test: reject Cloud readiness without any usable address
austinywang Sep 10, 2026
00358ad
test: pass empty address arrays as complete fixtures
austinywang Sep 10, 2026
d79f584
test: cover create and restore rollback without private addresses
austinywang Sep 10, 2026
345ffd0
test: assert network diagnostics at the readiness boundary
austinywang Sep 10, 2026
dfff157
fix: reject unusable Cloud networks and clear restored browser routes
austinywang Sep 10, 2026
8a67adb
test: cover a Cloud wake whose payload omits its private address
austinywang Sep 10, 2026
2d212f7
fix: report a Cloud wake's network setup without failing the wake
austinywang Sep 10, 2026
a5e0dc5
Merge remote-tracking branch 'origin/main' into fix-cloud-network-ann…
austinywang Sep 10, 2026
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
55 changes: 55 additions & 0 deletions Sources/Surfaces/CloudProviderRefreshCoordinator.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import Foundation

/// Serializes graph publication for one provider. A forced reader waits for a
/// pass started after its request; ordinary readers share the active pass.
@MainActor
final class CloudProviderRefreshCoordinator {
private struct Entry {
let request: UInt64
let forced: Bool
let task: Task<Bool, Never>
}

private var inFlight: Entry?
private var latestRequest: UInt64 = 0
private var invalidation: UInt64 = 0
private var lifetime: UInt64 = 0

func refresh(force: Bool, operation: @escaping @MainActor (Bool) async -> Bool) async -> Bool {
latestRequest &+= 1
let request = latestRequest
let epoch = lifetime
while !Task.isCancelled, epoch == lifetime {
if let entry = inFlight {
let result = await entry.task.value
if inFlight?.task == entry.task { inFlight = nil }
guard !Task.isCancelled, epoch == lifetime else { return false }
if !force || (entry.forced && entry.request >= request) { return result }
continue
}
let task = Task { @MainActor [weak self] in
while let self, !Task.isCancelled, epoch == self.lifetime {
let revision = self.invalidation
let result = await operation(force)
guard !Task.isCancelled, epoch == self.lifetime else { return false }
// Metadata superseded this pass. Readers stay attached to
// the owner until a pass over the current metadata finishes.
if revision == self.invalidation { return result }
}
return false
}
// Covers all forced readers already waiting, so a burst shares
// one trailing pass instead of issuing one snapshot per waiter.
inFlight = Entry(request: latestRequest, forced: force, task: task)
}
return false
}

func invalidate() { invalidation &+= 1 }

func cancel() {
lifetime &+= 1
inFlight?.task.cancel()
inFlight = nil
}
}
62 changes: 62 additions & 0 deletions Sources/Surfaces/CloudVMState+SnapshotComparison.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import Foundation

extension CloudVMState {
static func == (lhs: CloudVMState, rhs: CloudVMState) -> Bool {
lhs.hasSameModeledContent(as: rhs) && lhs.document == rhs.document
}

/// Clients and live terminal titles and dimensions are observations, not
/// revisioned resources (client.list and public_terminal_snapshot). Keep
/// them in exports without treating inspection or resize as a conflict.
func hasSameRevisionedContent(as other: CloudVMState) -> Bool {
hasSameModeledContent(as: other, includingLiveTerminalMetadata: false)
&& document.values.filter { $0.key != "clients" } == other.document.values.filter { $0.key != "clients" }
&& document.collections.filter { $0.key != "clients" && $0.key != "terminals" }
== other.document.collections.filter { $0.key != "clients" && $0.key != "terminals" }
&& hasSameTerminalDocument(as: other)
}

private var revisionedTerminals: [CloudVMTerminalState] {
terminals.map {
var terminal = $0
terminal.title = ""
terminal.cols = nil
terminal.rows = nil
return terminal
}
}

private func hasSameModeledContent(as other: CloudVMState, includingLiveTerminalMetadata: Bool = true) -> Bool {
let left = includingLiveTerminalMetadata ? terminals : revisionedTerminals
let right = includingLiveTerminalMetadata ? other.terminals : other.revisionedTerminals
return machine == other.machine
&& cursor == other.cursor
&& workspaces == other.workspaces
&& screens == other.screens
&& panes == other.panes
&& tabs == other.tabs
&& left == right
&& browsers == other.browsers
&& agents == other.agents
}

/// Identity, launch fields, and unknown fields remain strict. Only the PTY
/// title and dimensions are live; unchanged rows use their byte cache.
private func hasSameTerminalDocument(as other: CloudVMState) -> Bool {
guard let left = document.collections["terminals"] else {
return other.document.collections["terminals"] == nil
}
guard let right = other.document.collections["terminals"], left.order == right.order else { return false }
for id in left.order {
guard let a = left.rows[id], let b = right.rows[id] else { return false }
if a == b { continue }
guard var lhs = try? JSONSerialization.jsonObject(with: a) as? [String: Any],
var rhs = try? JSONSerialization.jsonObject(with: b) as? [String: Any] else { return false }
for key in ["title", "cols", "rows"] { lhs[key] = nil; rhs[key] = nil }
guard let lhsData = try? JSONSerialization.data(withJSONObject: lhs, options: [.sortedKeys]),
let rhsData = try? JSONSerialization.data(withJSONObject: rhs, options: [.sortedKeys]),
lhsData == rhsData else { return false }
}
return true
}
}
50 changes: 45 additions & 5 deletions Sources/Surfaces/CmuxTuiSurfaceProvider+PortForward.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ extension CmuxTuiSurfaceProvider {
func materializeBrowserPane(
_ resource: SurfaceResource,
at destination: SurfaceDestination,
focus: Bool
focus: Bool,
reusing existingPane: (workspaceID: UUID, panelID: UUID)? = nil
) async throws -> (workspaceID: UUID, panelID: UUID) {
let generation = currentLifecycleGeneration
try Task.checkCancellation()
Expand All @@ -33,7 +34,7 @@ extension CmuxTuiSurfaceProvider {
try Task.checkCancellation()
guard isCurrentLifecycleGeneration(generation), isRegisteredInCatalog() else { throw CancellationError() }
let label = Self.paneLabel(machineID: machineID, port: target.port, desktop: desktop)
let pane = try Self.makeConnectingPane(label: label, at: destination, focus: focus)
let pane = try Self.makeConnectingPane(label: label, at: destination, focus: focus, reusing: existingPane)
let machineWasAwake = isAwake
// A provider that is stopped or replaced while this runs must not
// touch the pane its successor now owns.
Expand Down Expand Up @@ -66,7 +67,7 @@ extension CmuxTuiSurfaceProvider {
case .controlPlanePreview(let port):
guard isRegisteredInCatalog() else { throw CancellationError() }
let label = Self.paneLabel(machineID: machineID, port: port, desktop: desktop)
let pane = try Self.makeConnectingPane(label: label, at: destination, focus: focus)
let pane = try Self.makeConnectingPane(label: label, at: destination, focus: focus, reusing: existingPane)
browserPaneTasks[pane.panelID] = Task { @MainActor [weak self] in
guard let self else { return }
defer { self.browserPaneTasks[pane.panelID] = nil }
Expand All @@ -86,6 +87,44 @@ extension CmuxTuiSurfaceProvider {
}
}

/// Restored browser tabs retain their identity, but their saved loopback
/// ports belong to the previous process. Reuse the normal route preparation
/// path to create a new forward and navigate the existing tab in place.
func reprojectRestoredBrowserPanes(generation: UInt64) {
for resource in catalog.snapshot.resources(on: machine) where resource.kind != .terminal {
for projection in catalog.projections(of: resource.id)
where !materializedPanels.contains(projection.panelID) {
guard SurfacePaneFactory.browserPanel(panelID: projection.panelID, in: projection.workspaceID) != nil,
let paneID = SurfacePaneFactory.paneID(ofPanel: projection.panelID, in: projection.workspaceID) else { continue }
materializedPanels.insert(projection.panelID)
let pane = (workspaceID: projection.workspaceID, panelID: projection.panelID)
// The old process no longer owns this URL. Retire it before
// any route setup can suspend, then reuse the normal preparer.
SurfacePaneFactory.navigate(panelID: pane.panelID, in: pane.workspaceID, to: SurfacePaneFactory.blankURL)
SurfacePaneFactory.showPlaceholder(SurfaceBrowserPlaceholder.connecting(resource.title), panelID: pane.panelID, in: pane.workspaceID)
// This task owns forward creation; materializeBrowserPane hands
// the same slot to its navigation task after the forward binds.
browserPaneTasks[pane.panelID] = Task { @MainActor [weak self] in
guard let self else { return }
do {
try Task.checkCancellation()
guard self.isCurrentLifecycleGeneration(generation), self.isRegisteredInCatalog() else { return }
_ = try await self.materializeBrowserPane(
resource,
at: .tab(workspaceID: pane.workspaceID, paneID: paneID, index: nil),
focus: false,
reusing: pane
)
} catch {
self.browserPaneTasks[pane.panelID] = nil
guard !Task.isCancelled, self.isCurrentLifecycleGeneration(generation) else { return }
Self.showFailure(label: resource.title, error: error, pane: pane)
}
}
}
}
}
Comment thread
cursor[bot] marked this conversation as resolved.

/// The link `port` opens as, shared by the pane, Copy Link, and
/// `vm.port_open`: the loopback forward when the machine has a private
/// address, else the control plane's tokened preview URL. Throws when the
Expand Down Expand Up @@ -138,9 +177,10 @@ extension CmuxTuiSurfaceProvider {
private static func makeConnectingPane(
label: String,
at destination: SurfaceDestination,
focus: Bool
focus: Bool,
reusing existingPane: (workspaceID: UUID, panelID: UUID)? = nil
) throws -> (workspaceID: UUID, panelID: UUID) {
let pane = try SurfacePaneFactory.makeBrowserPane(url: SurfacePaneFactory.blankURL, at: destination, focus: focus)
let pane = try existingPane ?? SurfacePaneFactory.makeBrowserPane(url: SurfacePaneFactory.blankURL, at: destination, focus: focus)
SurfacePaneFactory.showPlaceholder(SurfaceBrowserPlaceholder.connecting(label), panelID: pane.panelID, in: pane.workspaceID)
return pane
}
Expand Down
23 changes: 23 additions & 0 deletions Sources/Surfaces/CmuxTuiSurfaceProvider+Refresh.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import Foundation

extension CmuxTuiSurfaceProvider {
func refresh() async {
await refreshCurrentGraph(force: false)
}

// Matches the protocol's Void return type so existential catalog reads
// preserve force instead of falling through to its legacy default.
func refresh(force: Bool) async {
await refreshCurrentGraph(force: force)
}

/// Re-syncs the graph and reports whether the result is authoritative enough
/// for mutations. Concurrent reads share the provider's refresh owner.
@discardableResult
func refreshCurrentGraph(force: Bool) async -> Bool {
await refreshCoordinator.refresh(force: force) { [weak self] force in
guard let self else { return false }
return await self.performRefresh(force: force)
}
}
}
Loading
Loading