From c2c6f8f8765a41c0980c8cf5022581d6c4b4f1be Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 29 Sep 2026 16:03:31 -0700 Subject: [PATCH 01/17] test: reproduce cross-client Cloud display membership gap --- .../CloudVMDisplayMembership.swift | 67 ++++++++ ...talogSnapshot+CloudDisplayMembership.swift | 29 ++++ .../SurfaceCatalogSnapshot.swift | 11 +- cmux.xcodeproj/project.pbxproj | 24 +++ ...loudDisplayMembershipProjectionTests.swift | 143 ++++++++++++++++++ 5 files changed, 269 insertions(+), 5 deletions(-) create mode 100644 Packages/macOS/CmuxSurfaceCatalogModel/Sources/CmuxSurfaceCatalogModel/CloudVMDisplayMembership.swift create mode 100644 Packages/macOS/CmuxSurfaceCatalogModel/Sources/CmuxSurfaceCatalogModel/SurfaceCatalogSnapshot+CloudDisplayMembership.swift create mode 100644 cmuxTests/CloudDisplayMembershipProjectionTests.swift diff --git a/Packages/macOS/CmuxSurfaceCatalogModel/Sources/CmuxSurfaceCatalogModel/CloudVMDisplayMembership.swift b/Packages/macOS/CmuxSurfaceCatalogModel/Sources/CmuxSurfaceCatalogModel/CloudVMDisplayMembership.swift new file mode 100644 index 000000000000..7c2744ff551e --- /dev/null +++ b/Packages/macOS/CmuxSurfaceCatalogModel/Sources/CmuxSurfaceCatalogModel/CloudVMDisplayMembership.swift @@ -0,0 +1,67 @@ +import Foundation + +/// One durable display view attached to a Cloud workspace. +/// +/// The daemon stores these values in a frontend projection because a VNC display +/// is a machine resource rather than a cmux-tui tab. `machine`, `workspaceID`, +/// and `displayID` are the ownership identity; `clientID` and `viewID` let +/// independent clients remove only the view they created. +public struct CloudVMDisplayMembership: Hashable, Codable, Sendable { + public let machine: SurfaceMachineID + public let workspaceID: String + public let displayID: String + public let clientID: String + public let viewID: String + + public init( + machine: SurfaceMachineID, + workspaceID: String, + displayID: String, + clientID: String, + viewID: String + ) { + self.machine = machine + self.workspaceID = workspaceID + self.displayID = displayID + self.clientID = clientID + self.viewID = viewID + } +} + +extension CloudVMState { + /// Reads only validated display memberships from the accepted frontend + /// projection rows. Unknown machines, workspaces, displays, and malformed + /// view tokens are ignored so stale or foreign provenance cannot enter the + /// catalog projection. + public var displayMemberships: [CloudVMDisplayMembership] { + let workspaceIDs = Set(workspaces.map(\.id)) + var result = Set() + for row in document.objects(forCollectionKey: "frontend_projections") ?? [] { + guard let projection = row["projection"] as? [String: Any], + projection["schema"] as? String == "cmux.cloud.workspace-displays.v1", + projection["machine_id"] as? String == machine.rawValue, + let workspaceID = projection["workspace_id"] as? String, + workspaceIDs.contains(workspaceID), + let memberships = projection["memberships"] as? [[String: Any]] else { continue } + for membership in memberships { + guard let displayID = membership["display_id"] as? String, + displayID.hasPrefix("display:"), + let clientID = membership["client_id"] as? String, + !clientID.isEmpty, + let viewID = membership["view_id"] as? String, + !viewID.isEmpty else { continue } + result.insert(CloudVMDisplayMembership( + machine: machine, + workspaceID: workspaceID, + displayID: displayID, + clientID: clientID, + viewID: viewID + )) + } + } + return result.sorted { + ($0.workspaceID, $0.displayID, $0.clientID, $0.viewID) + < ($1.workspaceID, $1.displayID, $1.clientID, $1.viewID) + } + } +} diff --git a/Packages/macOS/CmuxSurfaceCatalogModel/Sources/CmuxSurfaceCatalogModel/SurfaceCatalogSnapshot+CloudDisplayMembership.swift b/Packages/macOS/CmuxSurfaceCatalogModel/Sources/CmuxSurfaceCatalogModel/SurfaceCatalogSnapshot+CloudDisplayMembership.swift new file mode 100644 index 000000000000..58954db483fe --- /dev/null +++ b/Packages/macOS/CmuxSurfaceCatalogModel/Sources/CmuxSurfaceCatalogModel/SurfaceCatalogSnapshot+CloudDisplayMembership.swift @@ -0,0 +1,29 @@ +import Foundation + +extension SurfaceCatalogSnapshot { + /// Returns the machine pool plus one presentation copy for every accepted + /// display/workspace membership. The copies retain the same resource id and + /// are used only by workspace rows and groups; the pool remains one row per + /// discovered display resource. + public func cloudWorkspaceResources(on machine: SurfaceMachineID) -> [SurfaceResource] { + let base = resources(on: machine) + let byID = Dictionary(base.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) + let workspaces = Dictionary( + (machines.first { $0.id == machine }?.remoteWorkspaces ?? []).map { ($0.id, $0) }, + uniquingKeysWith: { first, _ in first } + ) + var result = base + var seen = Set() + for membership in cloudDisplayMemberships where membership.machine == machine { + let id = SurfaceResourceID(machine: machine, kind: .display, key: membership.displayID) + guard let baseResource = byID[id], let workspace = workspaces[membership.workspaceID], + !baseResource.remoteWorkspaces.contains(where: { $0.id == workspace.id }), + seen.insert("\(id.rawValue)\u{0}\(workspace.id)").inserted else { continue } + var placed = baseResource + placed.remoteViews = nil + placed.remoteWorkspace = workspace + result.append(placed) + } + return result + } +} diff --git a/Packages/macOS/CmuxSurfaceCatalogModel/Sources/CmuxSurfaceCatalogModel/SurfaceCatalogSnapshot.swift b/Packages/macOS/CmuxSurfaceCatalogModel/Sources/CmuxSurfaceCatalogModel/SurfaceCatalogSnapshot.swift index 38a5616d917f..350082d79dad 100644 --- a/Packages/macOS/CmuxSurfaceCatalogModel/Sources/CmuxSurfaceCatalogModel/SurfaceCatalogSnapshot.swift +++ b/Packages/macOS/CmuxSurfaceCatalogModel/Sources/CmuxSurfaceCatalogModel/SurfaceCatalogSnapshot.swift @@ -3,9 +3,6 @@ import Foundation /// The catalog as one value: what the sidebar renders, what `surface.catalog` and /// `cmux vm tree --json` print. Machines are ordered local first, then by name. public struct SurfaceCatalogSnapshot: Hashable, Codable, Sendable { - /// Workspaces admitted for deletion but not yet confirmed by the daemon, - /// per machine. Nil when nothing is pending, so socket readers on older - /// builds keep decoding the same document. /// Pending native workspace identities, keyed by machine and daemon workspace. public var pendingWorkspaceCreations: [SurfaceMachineID: [String: UUID]]? = nil public var pendingWorkspaceDeletions: [SurfaceMachineID: Set]? = nil @@ -14,6 +11,7 @@ public struct SurfaceCatalogSnapshot: Hashable, Codable, Sendable { public var projections: [SurfaceProjection] public var staleMachineIDs: Set = [] public var displayCreationMachines: Set? = nil + public var cloudDisplayMemberships: [CloudVMDisplayMembership] = [] public static let empty = SurfaceCatalogSnapshot(machines: [], resources: [], projections: []) @@ -37,7 +35,8 @@ public struct SurfaceCatalogSnapshot: Hashable, Codable, Sendable { resources: [SurfaceResource], projections: [SurfaceProjection], staleMachineIDs: Set = [], - displayCreationMachines: Set? = nil + displayCreationMachines: Set? = nil, + cloudDisplayMemberships: [CloudVMDisplayMembership] = [] ) { self.pendingWorkspaceCreations = pendingWorkspaceCreations self.pendingWorkspaceDeletions = pendingWorkspaceDeletions @@ -46,12 +45,13 @@ public struct SurfaceCatalogSnapshot: Hashable, Codable, Sendable { self.projections = projections self.staleMachineIDs = staleMachineIDs self.displayCreationMachines = displayCreationMachines + self.cloudDisplayMemberships = cloudDisplayMemberships } } extension SurfaceCatalogSnapshot { private enum CodingKeys: String, CodingKey { - case pendingWorkspaceCreations, pendingWorkspaceDeletions, machines, resources, projections, staleMachineIDs, displayCreationMachines + case pendingWorkspaceCreations, pendingWorkspaceDeletions, machines, resources, projections, staleMachineIDs, displayCreationMachines, cloudDisplayMemberships } public init(from decoder: Decoder) throws { @@ -63,5 +63,6 @@ extension SurfaceCatalogSnapshot { projections = try values.decode([SurfaceProjection].self, forKey: .projections) staleMachineIDs = try values.decodeIfPresent(Set.self, forKey: .staleMachineIDs) ?? [] displayCreationMachines = try values.decodeIfPresent(Set.self, forKey: .displayCreationMachines) + cloudDisplayMemberships = try values.decodeIfPresent([CloudVMDisplayMembership].self, forKey: .cloudDisplayMemberships) ?? [] } } diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 2f6302cf0e49..496ac77a110b 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -708,6 +708,8 @@ 5910F6B0C9704B8DB8607318 /* CloudDirectoryLifecycleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EFB28A7B5DF48ED8EF9CFB6 /* CloudDirectoryLifecycleTests.swift */; }; F3FAB983EE90414CAA90F970 /* CloudDirectoryTestFixture.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFC7BA3C7C834D20B3173D36 /* CloudDirectoryTestFixture.swift */; }; 33E9D640ED2646A38673776D /* CloudDisplayCatalogTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B7DF8AE123341DEBA066731 /* CloudDisplayCatalogTests.swift */; }; + 27BBEA55BF5579F93E5C55C3 /* CloudDisplayMembershipProjectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 635AF226CFD6879AC9ACFD09 /* CloudDisplayMembershipProjectionTests.swift */; }; + A2482459B25F92BBE1020EEB /* CloudDisplayMembershipSyncing.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5E23BA1DDF8DA4B0BB766CA /* CloudDisplayMembershipSyncing.swift */; }; C10D0A200000000000000001 /* CloudDomainsCLIIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10D0A200000000000000002 /* CloudDomainsCLIIntegrationTests.swift */; }; 71FE9F238B1717D43FC01E63 /* CloudEqualCursorConflictRecoveryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D4316FCA6BA8B1260D8FFB6 /* CloudEqualCursorConflictRecoveryTests.swift */; }; 5EDE6BFD28BE4BD58EF84889 /* CloudFeatureAvailabilityObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 668F7B9BC77445B793C0298A /* CloudFeatureAvailabilityObserver.swift */; }; @@ -766,6 +768,7 @@ A12480000000000000000005 /* CloudPaneCreationFailureStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A12480000000000000000006 /* CloudPaneCreationFailureStore.swift */; }; A12480000000000000000003 /* CloudPaneCreationFailureView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A12480000000000000000004 /* CloudPaneCreationFailureView.swift */; }; C12537000000000000000215 /* CloudPaneCreationRetryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12537000000000000000216 /* CloudPaneCreationRetryTests.swift */; }; + 3D0803F8A42471FC25115802 /* CloudPlacementCoordinator+CloudDisplayMembership.swift in Sources */ = {isa = PBXBuildFile; fileRef = 981BED9BD8FFEE7B0CBFCD4B /* CloudPlacementCoordinator+CloudDisplayMembership.swift */; }; 118E574AA58D92C0A3FB893E /* CloudPlacementCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A22E92B91E0FFF066AE10237 /* CloudPlacementCoordinator.swift */; }; CB32AB7B7AB792F21B5C5B36 /* CloudPlacementCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0FD4E01EAF1B2F306A96F6F /* CloudPlacementCoordinatorTests.swift */; }; C5FB49903F3E8EA7192AB445 /* CloudPlacementTestProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAADF4DC604A997BD8DA561E /* CloudPlacementTestProvider.swift */; }; @@ -1420,6 +1423,7 @@ 81A1621623F83B61D4C5FFA9 /* CmuxTuiSurfaceProvider+AttachmentRecovery.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D8BE7D3234A1F44E2A1C52B /* CmuxTuiSurfaceProvider+AttachmentRecovery.swift */; }; 5633045E23E44075B22C08CA /* CmuxTuiSurfaceProvider+CloseTerminal.swift in Sources */ = {isa = PBXBuildFile; fileRef = E655249E82FC412B9EC2CD21 /* CmuxTuiSurfaceProvider+CloseTerminal.swift */; }; C10DC1000000000000000015 /* CmuxTuiSurfaceProvider+CloudDiagnosticFailure.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10DC1000000000000000016 /* CmuxTuiSurfaceProvider+CloudDiagnosticFailure.swift */; }; + C462743B6AB963C09DE86933 /* CmuxTuiSurfaceProvider+CloudDisplayMembership.swift in Sources */ = {isa = PBXBuildFile; fileRef = 905C49B3C3B96FE5AE1D9D0F /* CmuxTuiSurfaceProvider+CloudDisplayMembership.swift */; }; FB8AB3FDE68E4ABC9C72E6CB /* CmuxTuiSurfaceProvider+Displays.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED5ABA995260445394F81C79 /* CmuxTuiSurfaceProvider+Displays.swift */; }; AA92C99C529DB9A24064AF48 /* CmuxTuiSurfaceProvider+Environment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BE30231700141C6F370FA63 /* CmuxTuiSurfaceProvider+Environment.swift */; }; A12093000000000000000002 /* CmuxTuiSurfaceProvider+Error.swift in Sources */ = {isa = PBXBuildFile; fileRef = A12093000000000000000001 /* CmuxTuiSurfaceProvider+Error.swift */; }; @@ -3283,6 +3287,8 @@ CFSF00000000000000000013 /* SurfaceCatalog+ChangeNotifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFSR00000000000000000013 /* SurfaceCatalog+ChangeNotifications.swift */; }; F11347000000000000000007 /* SurfaceCatalog+CloudBinding.swift in Sources */ = {isa = PBXBuildFile; fileRef = F11347000000000000000008 /* SurfaceCatalog+CloudBinding.swift */; }; 78F8D449BA0D4860AE1162C7 /* SurfaceCatalog+CloudDirectoryMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B2248ED1422420F90E0D71D /* SurfaceCatalog+CloudDirectoryMetadata.swift */; }; + 11B3CE31BC9CCFE671CFC273 /* SurfaceCatalog+CloudDisplayMembership.swift in Sources */ = {isa = PBXBuildFile; fileRef = 316418AB818C4081081DA326 /* SurfaceCatalog+CloudDisplayMembership.swift */; }; + 968E42E7CCB67098FF50BB84 /* SurfaceCatalog+CloudDisplayProjection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56434762F98EC43868C86D56 /* SurfaceCatalog+CloudDisplayProjection.swift */; }; F11347000000000000000001 /* SurfaceCatalog+CloudPorts.swift in Sources */ = {isa = PBXBuildFile; fileRef = F11347000000000000000002 /* SurfaceCatalog+CloudPorts.swift */; }; E71A3B8404534D5699AA2E9B /* SurfaceCatalog+CloudRenameSubmission.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A4A80857B8A4530BD5BC498 /* SurfaceCatalog+CloudRenameSubmission.swift */; }; D4E2A030E899483F96AF0C9E /* SurfaceCatalog+CloudTerminalNavigation.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9B841AA21EA4B58B34D228B /* SurfaceCatalog+CloudTerminalNavigation.swift */; }; @@ -4953,6 +4959,8 @@ 9EFB28A7B5DF48ED8EF9CFB6 /* CloudDirectoryLifecycleTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CloudDirectoryLifecycleTests.swift; sourceTree = ""; }; FFC7BA3C7C834D20B3173D36 /* CloudDirectoryTestFixture.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CloudDirectoryTestFixture.swift; sourceTree = ""; }; 6B7DF8AE123341DEBA066731 /* CloudDisplayCatalogTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudDisplayCatalogTests.swift"; sourceTree = ""; }; + 635AF226CFD6879AC9ACFD09 /* CloudDisplayMembershipProjectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudDisplayMembershipProjectionTests.swift"; sourceTree = ""; }; + D5E23BA1DDF8DA4B0BB766CA /* CloudDisplayMembershipSyncing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudDisplayMembershipSyncing.swift; sourceTree = ""; }; C10D0A200000000000000002 /* CloudDomainsCLIIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudDomainsCLIIntegrationTests.swift; sourceTree = ""; }; 2D4316FCA6BA8B1260D8FFB6 /* CloudEqualCursorConflictRecoveryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudEqualCursorConflictRecoveryTests.swift"; sourceTree = ""; }; 668F7B9BC77445B793C0298A /* CloudFeatureAvailabilityObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudFeatureAvailabilityObserver.swift"; sourceTree = ""; }; @@ -5011,6 +5019,7 @@ A12480000000000000000006 /* CloudPaneCreationFailureStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudPaneCreationFailureStore.swift"; sourceTree = ""; }; A12480000000000000000004 /* CloudPaneCreationFailureView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/CloudPaneCreationFailureView.swift; sourceTree = ""; }; C12537000000000000000216 /* CloudPaneCreationRetryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudPaneCreationRetryTests.swift; sourceTree = ""; }; + 981BED9BD8FFEE7B0CBFCD4B /* CloudPlacementCoordinator+CloudDisplayMembership.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudPlacementCoordinator+CloudDisplayMembership.swift"; sourceTree = ""; }; A22E92B91E0FFF066AE10237 /* CloudPlacementCoordinator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CloudPlacementCoordinator.swift; sourceTree = ""; }; F0FD4E01EAF1B2F306A96F6F /* CloudPlacementCoordinatorTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CloudPlacementCoordinatorTests.swift; sourceTree = ""; }; EAADF4DC604A997BD8DA561E /* CloudPlacementTestProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudPlacementTestProvider.swift"; sourceTree = ""; }; @@ -5552,6 +5561,7 @@ 4D8BE7D3234A1F44E2A1C52B /* CmuxTuiSurfaceProvider+AttachmentRecovery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CmuxTuiSurfaceProvider+AttachmentRecovery.swift"; sourceTree = ""; }; E655249E82FC412B9EC2CD21 /* CmuxTuiSurfaceProvider+CloseTerminal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CmuxTuiSurfaceProvider+CloseTerminal.swift"; sourceTree = ""; }; C10DC1000000000000000016 /* CmuxTuiSurfaceProvider+CloudDiagnosticFailure.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CmuxTuiSurfaceProvider+CloudDiagnosticFailure.swift"; sourceTree = ""; }; + 905C49B3C3B96FE5AE1D9D0F /* CmuxTuiSurfaceProvider+CloudDisplayMembership.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CmuxTuiSurfaceProvider+CloudDisplayMembership.swift"; sourceTree = ""; }; ED5ABA995260445394F81C79 /* CmuxTuiSurfaceProvider+Displays.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CmuxTuiSurfaceProvider+Displays.swift"; sourceTree = ""; }; 4BE30231700141C6F370FA63 /* CmuxTuiSurfaceProvider+Environment.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "CmuxTuiSurfaceProvider+Environment.swift"; sourceTree = ""; }; A12093000000000000000001 /* CmuxTuiSurfaceProvider+Error.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CmuxTuiSurfaceProvider+Error.swift"; sourceTree = ""; }; @@ -7379,6 +7389,8 @@ CFSR00000000000000000013 /* SurfaceCatalog+ChangeNotifications.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SurfaceCatalog+ChangeNotifications.swift"; sourceTree = ""; }; F11347000000000000000008 /* SurfaceCatalog+CloudBinding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SurfaceCatalog+CloudBinding.swift"; sourceTree = ""; }; 7B2248ED1422420F90E0D71D /* SurfaceCatalog+CloudDirectoryMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SurfaceCatalog+CloudDirectoryMetadata.swift"; sourceTree = ""; }; + 316418AB818C4081081DA326 /* SurfaceCatalog+CloudDisplayMembership.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SurfaceCatalog+CloudDisplayMembership.swift"; sourceTree = ""; }; + 56434762F98EC43868C86D56 /* SurfaceCatalog+CloudDisplayProjection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SurfaceCatalog+CloudDisplayProjection.swift"; sourceTree = ""; }; F11347000000000000000002 /* SurfaceCatalog+CloudPorts.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "SurfaceCatalog+CloudPorts.swift"; sourceTree = ""; }; 4A4A80857B8A4530BD5BC498 /* SurfaceCatalog+CloudRenameSubmission.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SurfaceCatalog+CloudRenameSubmission.swift"; sourceTree = ""; }; A9B841AA21EA4B58B34D228B /* SurfaceCatalog+CloudTerminalNavigation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SurfaceCatalog+CloudTerminalNavigation.swift"; sourceTree = ""; }; @@ -8855,6 +8867,11 @@ F1BE71D511FF27AB7D0279A8 /* TerminalController+CloudMachinePayload.swift */, E52470C2BDCDB44AA3AF4B48 /* CloudWorkspaceCreationReveals.swift */, 16F81541F06BB9C88654913B /* SurfaceCatalog+MachineDeletion.swift */, + D5E23BA1DDF8DA4B0BB766CA /* CloudDisplayMembershipSyncing.swift */, + 981BED9BD8FFEE7B0CBFCD4B /* CloudPlacementCoordinator+CloudDisplayMembership.swift */, + 905C49B3C3B96FE5AE1D9D0F /* CmuxTuiSurfaceProvider+CloudDisplayMembership.swift */, + 316418AB818C4081081DA326 /* SurfaceCatalog+CloudDisplayMembership.swift */, + 56434762F98EC43868C86D56 /* SurfaceCatalog+CloudDisplayProjection.swift */, ); name = Surfaces; path = Surfaces; @@ -12539,6 +12556,7 @@ A43345B5B3C60929B9F661CB /* ExitingProcessCensusReader.swift */, 09DC7DE176A93C580A263ADD /* PaneDropTargetIdentityTests.swift */, 08202CA6A24B08873B5CC6FD /* GhosttyDialogThemeTests.swift */, + 635AF226CFD6879AC9ACFD09 /* CloudDisplayMembershipProjectionTests.swift */, ); path = cmuxTests; sourceTree = ""; @@ -13699,6 +13717,7 @@ 69AA6941BCDDF67E7B44CA26 /* CloudBrowserAccessState.swift in Sources */, 57C7774B1620767AED6F13B7 /* CloudBrowserAccessView.swift in Sources */, 32E508E45F3E7B0832FB99EA /* CloudDiagnosticsView.swift in Sources */, + A2482459B25F92BBE1020EEB /* CloudDisplayMembershipSyncing.swift in Sources */, 5EDE6BFD28BE4BD58EF84889 /* CloudFeatureAvailabilityObserver.swift in Sources */, CFSF00000000000000000001 /* CloudFileExplorerCommandRunning.swift in Sources */, F434C13ADFB45B7DD70B2191 /* CloudFileExplorerSearchQueue.swift in Sources */, @@ -13720,6 +13739,7 @@ 7C98953CC5230654F2A4D628 /* CloudOperationDetailsView.swift in Sources */, A12480000000000000000005 /* CloudPaneCreationFailureStore.swift in Sources */, A12480000000000000000003 /* CloudPaneCreationFailureView.swift in Sources */, + 3D0803F8A42471FC25115802 /* CloudPlacementCoordinator+CloudDisplayMembership.swift in Sources */, 118E574AA58D92C0A3FB893E /* CloudPlacementCoordinator.swift in Sources */, 462AE599482B727CBF10308F /* CloudPortAccessModel.swift in Sources */, 1EA5411EB2583A92811329BD /* CloudPortAccessStore.swift in Sources */, @@ -13930,6 +13950,7 @@ 81A1621623F83B61D4C5FFA9 /* CmuxTuiSurfaceProvider+AttachmentRecovery.swift in Sources */, 5633045E23E44075B22C08CA /* CmuxTuiSurfaceProvider+CloseTerminal.swift in Sources */, C10DC1000000000000000015 /* CmuxTuiSurfaceProvider+CloudDiagnosticFailure.swift in Sources */, + C462743B6AB963C09DE86933 /* CmuxTuiSurfaceProvider+CloudDisplayMembership.swift in Sources */, FB8AB3FDE68E4ABC9C72E6CB /* CmuxTuiSurfaceProvider+Displays.swift in Sources */, AA92C99C529DB9A24064AF48 /* CmuxTuiSurfaceProvider+Environment.swift in Sources */, A12093000000000000000002 /* CmuxTuiSurfaceProvider+Error.swift in Sources */, @@ -15115,6 +15136,8 @@ CFSF00000000000000000013 /* SurfaceCatalog+ChangeNotifications.swift in Sources */, F11347000000000000000007 /* SurfaceCatalog+CloudBinding.swift in Sources */, 78F8D449BA0D4860AE1162C7 /* SurfaceCatalog+CloudDirectoryMetadata.swift in Sources */, + 11B3CE31BC9CCFE671CFC273 /* SurfaceCatalog+CloudDisplayMembership.swift in Sources */, + 968E42E7CCB67098FF50BB84 /* SurfaceCatalog+CloudDisplayProjection.swift in Sources */, F11347000000000000000001 /* SurfaceCatalog+CloudPorts.swift in Sources */, E71A3B8404534D5699AA2E9B /* SurfaceCatalog+CloudRenameSubmission.swift in Sources */, D4E2A030E899483F96AF0C9E /* SurfaceCatalog+CloudTerminalNavigation.swift in Sources */, @@ -16365,6 +16388,7 @@ 5910F6B0C9704B8DB8607318 /* CloudDirectoryLifecycleTests.swift in Sources */, F3FAB983EE90414CAA90F970 /* CloudDirectoryTestFixture.swift in Sources */, 33E9D640ED2646A38673776D /* CloudDisplayCatalogTests.swift in Sources */, + 27BBEA55BF5579F93E5C55C3 /* CloudDisplayMembershipProjectionTests.swift in Sources */, C10D0A200000000000000001 /* CloudDomainsCLIIntegrationTests.swift in Sources */, 71FE9F238B1717D43FC01E63 /* CloudEqualCursorConflictRecoveryTests.swift in Sources */, C3CD2FF9F6F1489C8EDD0C7B /* CloudFeatureFlagTests.swift in Sources */, diff --git a/cmuxTests/CloudDisplayMembershipProjectionTests.swift b/cmuxTests/CloudDisplayMembershipProjectionTests.swift new file mode 100644 index 000000000000..b7c7137e0fa6 --- /dev/null +++ b/cmuxTests/CloudDisplayMembershipProjectionTests.swift @@ -0,0 +1,143 @@ +import CmuxSurfaceCatalogModel +import Foundation +import Testing +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@MainActor +@Suite("Cloud display membership projection") +struct CloudDisplayMembershipProjectionTests { + private let machine = SurfaceMachineID.cloud("display-membership-vm") + private let workspaceID = "ws_cloud" + private let displayID = "display:1" + + private func document( + revision: Int, + memberships: [[String: Any]] = [], + display: String = "display:1", + generation: String = "membership" + ) -> [String: Any] { + [ + "cursor": ["generation": generation, "revision": String(revision)], + "workspaces": [["id": workspaceID, "name": "Cloud", "index": 0, "focused": true]], + "screens": [["id": "screen_cloud", "workspace_id": workspaceID]], + "panes": [["id": "pane_cloud", "screen_id": "screen_cloud"]], + "tabs": [["id": "tab_terminal", "pane_id": "pane_cloud", "index": 0, + "focused": true, "content_kind": "terminal", "content_id": "term_cloud"]], + "terminals": [["id": "term_cloud", "tab_id": "tab_terminal", "title": "terminal", "lifecycle": "running"]], + "browsers": [], + "agents": [], + "frontend_projections": [[ + "id": "projection_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "session_id": "session_cloud", + "frontend_id": "cmux-macos-cloud-layout-v1", + "window_id": "cloud-workspace", + "generation": "cmux-cloud-display-layout-v1", + "projection_revision": "\(revision)", + "projection": [ + "schema": "cmux.cloud.workspace-displays.v1", + "machine_id": machine.rawValue, + "workspace_id": workspaceID, + "memberships": memberships, + ], + ]], + "display_hint": display, + ] + } + + private func state( + revision: Int = 1, + memberships: [[String: Any]] = [["display_id": "display:1", "client_id": "mac-a", "view_id": "panel-a"]], + generation: String = "membership" + ) throws -> CloudVMState { + try #require(CmuxTuiSnapshotParser.state( + fromSnapshot: document(revision: revision, memberships: memberships, generation: generation), + machine: machine + )) + } + + private func info(_ state: CloudVMState) -> SurfaceMachineInfo { + SurfaceMachineInfo( + id: machine, name: "Display VM", status: "running", image: nil, hasDesktop: true, + memoryMb: nil, diskMb: nil, linkState: .connected, linkError: nil, + cpuPercent: nil, memoryUsedMb: nil, diskUsedMb: nil, + remoteWorkspaces: state.workspaces.map { + SurfaceRemoteWorkspace(id: $0.id, name: $0.name, index: $0.index, focused: $0.focused) + } + ) + } + + private func resources(_ state: CloudVMState) -> [SurfaceResource] { + [CmuxTuiSnapshotParser.display(machine: machine), + CmuxTuiSnapshotParser.resources(from: state).first { $0.kind == .terminal }].compactMap { $0 } + } + + @Test("A frontend projection gives every client the same workspace display row") + func sameAcceptedSnapshotProjectsOnTwoClients() throws { + let state = try state() + let resources = resources(state) + let first = SurfaceCatalog() + let second = SurfaceCatalog() + first.replaceCloudState(state, resources: resources, info: info(state)) + second.replaceCloudState(state, resources: resources, info: info(state)) + for catalog in [first, second] { + let tree = CloudTreeNodeBuilder.flattened(CloudTreeNodeBuilder.nodes( + machines: [MachineSnapshot(id: machine.rawValue, provider: "test", image: "test", isDesktop: true, activity: .ready, createdAt: nil, label: "Display VM")], + snapshot: catalog.snapshot, localWorkspaces: [], includeLocalMachine: false + )) + let workspace = try #require(tree.first { $0.id == CloudTreeNodeBuilder.nodeID(workspace: workspaceID, machine: machine) }) + #expect(workspace.children.contains { node in + if case .display(let resource, _, _) = node.kind { return resource.id.key == displayID } + return false + }) + let pool = try #require(tree.first { $0.id == CloudTreeNodeBuilder.nodeID(displaysPool: machine) }) + #expect(pool.children.count == 1) + } + } + + @Test("Foreign and unknown display provenance stays out of workspace membership") + func ownershipIsCheckedAtProjectionBoundary() throws { + let state = try state(memberships: [ + ["display_id": "display:1", "client_id": "mac-a", "view_id": "panel-a"], + ["display_id": "display:99", "client_id": "foreign-vm", "view_id": "panel-b"], + ]) + let snapshot = SurfaceCatalogSnapshot( + machines: [info(state)], resources: resources(state), projections: [], + cloudDisplayMemberships: state.displayMemberships + ) + let workspaceResources = snapshot.cloudWorkspaceResources(on: machine) + #expect(workspaceResources.filter { $0.id.key == displayID }.count == 2) + #expect(!workspaceResources.contains { $0.id.key == "display:99" }) + } + + @Test("Revision updates replace display membership and stale deltas cannot win") + func revisionOrderingPreservesAcceptedProjection() throws { + let initial = try state() + let nextDocument = document(revision: 2, memberships: [], generation: "membership") + let next = try #require(CmuxTuiSnapshotParser.state(fromSnapshot: nextDocument, machine: machine)) + #expect(next.displayMemberships.isEmpty) + #expect(CloudVMStateSyncDecision.forDelta( + generation: "membership", previousRevision: 1, revision: 2, current: initial.cursor + ) == .installSnapshot) + #expect(CloudVMStateSyncDecision.forDelta( + generation: "membership", previousRevision: 0, revision: 1, current: next.cursor + ) == .ignoreStale) + #expect(CloudVMStateSyncDecision.forSnapshot(incoming: initial.cursor, current: next.cursor) == .ignoreStale) + } + + @Test("Reconnect keeps the durable membership while client view tokens change") + func reconnectRetainsWorkspacePlacement() throws { + let first = try state(memberships: [["display_id": displayID, "client_id": "mac-a", "view_id": "panel-a"]]) + let reconnected = try state( + revision: 1, + memberships: [["display_id": displayID, "client_id": "mac-b", "view_id": "panel-b"]], + generation: "reconnected" + ) + #expect(first.displayMemberships.first?.displayID == reconnected.displayMemberships.first?.displayID) + #expect(first.displayMemberships.first?.workspaceID == reconnected.displayMemberships.first?.workspaceID) + #expect(first.displayMemberships.first?.viewID != reconnected.displayMemberships.first?.viewID) + } +} From 971f372e8cf02cd1c619aefae04f6149a476d0f2 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 29 Sep 2026 16:04:00 -0700 Subject: [PATCH 02/17] fix: persist Cloud display workspace membership --- .../CloudTuiFrontendProjectionRequests.swift | 26 +++++ Sources/Cloud/CloudTreeNode.swift | 2 +- Sources/Cloud/CloudTreeRemoteWorkspaces.swift | 2 +- .../CloudDisplayMembershipSyncing.swift | 12 +++ ...ntCoordinator+CloudDisplayMembership.swift | 76 +++++++++++++++ .../Surfaces/CloudPlacementCoordinator.swift | 8 +- .../CloudWorkspaceProjectionCoordinator.swift | 3 + ...rfaceProvider+CloudDisplayMembership.swift | 96 +++++++++++++++++++ .../Surfaces/CmuxTuiSurfaceProviders.swift | 2 +- ...urfaceCatalog+CloudDisplayMembership.swift | 16 ++++ ...urfaceCatalog+CloudDisplayProjection.swift | 14 +++ Sources/Surfaces/SurfaceCatalog+Groups.swift | 2 +- .../Surfaces/SurfaceCatalog+Snapshot.swift | 2 +- .../SurfaceCatalog+WorkspaceMembership.swift | 2 +- Sources/Surfaces/SurfaceCatalog.swift | 8 +- 15 files changed, 260 insertions(+), 11 deletions(-) create mode 100644 Packages/macOS/CmuxCloudTui/Sources/CmuxCloudTui/CloudTuiFrontendProjectionRequests.swift create mode 100644 Sources/Surfaces/CloudDisplayMembershipSyncing.swift create mode 100644 Sources/Surfaces/CloudPlacementCoordinator+CloudDisplayMembership.swift create mode 100644 Sources/Surfaces/CmuxTuiSurfaceProvider+CloudDisplayMembership.swift create mode 100644 Sources/Surfaces/SurfaceCatalog+CloudDisplayMembership.swift create mode 100644 Sources/Surfaces/SurfaceCatalog+CloudDisplayProjection.swift diff --git a/Packages/macOS/CmuxCloudTui/Sources/CmuxCloudTui/CloudTuiFrontendProjectionRequests.swift b/Packages/macOS/CmuxCloudTui/Sources/CmuxCloudTui/CloudTuiFrontendProjectionRequests.swift new file mode 100644 index 000000000000..4367e54acb44 --- /dev/null +++ b/Packages/macOS/CmuxCloudTui/Sources/CmuxCloudTui/CloudTuiFrontendProjectionRequests.swift @@ -0,0 +1,26 @@ +import Foundation + +public extension CloudTuiRequests { + /// Updates one durable Cloud display-membership projection. + static func putCloudDisplayMembershipProjection( + projectionID: String, + frontendID: String, + windowID: String, + generation: String, + projection: [String: Any], + expectedProjectionRevision: UInt64?, + idempotencyKey: String + ) -> CloudTuiRequest { + var fields: [String: Any] = [ + "frontend_projection": projectionID, + "frontend_id": frontendID, + "window_id": windowID, + "generation": generation, + "projection": projection, + ] + if let expectedProjectionRevision { + fields["expected_projection_revision"] = String(expectedProjectionRevision) + } + return CloudTuiRequest("frontend_projection.put", fields, mutation: true, key: idempotencyKey) + } +} diff --git a/Sources/Cloud/CloudTreeNode.swift b/Sources/Cloud/CloudTreeNode.swift index afdefa6d9009..06f3b2421263 100644 --- a/Sources/Cloud/CloudTreeNode.swift +++ b/Sources/Cloud/CloudTreeNode.swift @@ -876,7 +876,7 @@ enum CloudTreeNodeBuilder { for workspace in info.remoteWorkspaces ?? [] { byWorkspace[workspace.id] = RemoteWorkspaceRows(workspace: workspace) } - for resource in resources { + for resource in snapshot.cloudWorkspaceResources(on: machine) { for placement in remotePlacements(of: resource) { var rows = byWorkspace[placement.workspace.id] ?? RemoteWorkspaceRows(workspace: placement.workspace) switch resource.kind { diff --git a/Sources/Cloud/CloudTreeRemoteWorkspaces.swift b/Sources/Cloud/CloudTreeRemoteWorkspaces.swift index 71a760416792..434c663f31ce 100644 --- a/Sources/Cloud/CloudTreeRemoteWorkspaces.swift +++ b/Sources/Cloud/CloudTreeRemoteWorkspaces.swift @@ -118,7 +118,7 @@ extension CloudTreeNodeBuilder { static func lookupRemoteWorkspace(_ selector: String, on machine: SurfaceMachineID, snapshot: SurfaceCatalogSnapshot) -> CloudTreeRemoteWorkspaceLookup { let trimmed = selector.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return .notFound } - let resources = snapshot.resources(on: machine) + let resources = snapshot.cloudWorkspaceResources(on: machine) let workspaces = remoteWorkspaces(info: snapshot.machines.first { $0.id == machine }, resources: resources) if let byID = workspaces.first(where: { $0.id == trimmed }) { return .found(byID, remoteWorkspaceMembers(workspaceID: byID.id, resources: resources, projections: snapshot.projections)) diff --git a/Sources/Surfaces/CloudDisplayMembershipSyncing.swift b/Sources/Surfaces/CloudDisplayMembershipSyncing.swift new file mode 100644 index 000000000000..901aaa22c8bc --- /dev/null +++ b/Sources/Surfaces/CloudDisplayMembershipSyncing.swift @@ -0,0 +1,12 @@ +import Foundation + +/// Persists a local VNC view in the VM's revisioned Cloud workspace projection. +@MainActor +protocol CloudDisplayMembershipSyncing: AnyObject { + func syncCloudDisplayMembership( + displayID: String, + workspaceID: String, + panelID: UUID, + attached: Bool + ) async throws +} diff --git a/Sources/Surfaces/CloudPlacementCoordinator+CloudDisplayMembership.swift b/Sources/Surfaces/CloudPlacementCoordinator+CloudDisplayMembership.swift new file mode 100644 index 000000000000..0da84021b5a0 --- /dev/null +++ b/Sources/Surfaces/CloudPlacementCoordinator+CloudDisplayMembership.swift @@ -0,0 +1,76 @@ +import CmuxSurfaceCatalogModel +import Foundation + +extension CloudPlacementCoordinator { + /// Writes the durable membership after the local pane has been admitted. + /// The lane serializes a move's detach and attach so a revision update cannot + /// publish a half-moved display view. + func syncCloudDisplayMembership( + projection: SurfaceProjection, + current: SurfaceProjection, + catalog: SurfaceCatalog + ) { + guard projection.resource.kind == .display, + let provider = catalog.provider(for: projection.resource.machine) as? any CloudDisplayMembershipSyncing + else { return } + let previous = localDisplayMemberships[projection.panelID] + let target = current.remoteWorkspaceID + guard previous != target || (previous == nil && target != nil) else { return } + enqueue(projection, catalog: catalog, presentFailure: false) { + guard let latest = catalog.projection(forPanel: projection.panelID), + latest.resource == projection.resource else { return false } + let old = self.localDisplayMemberships[projection.panelID] + let next = latest.remoteWorkspaceID + if let old, old != next { + try await provider.syncCloudDisplayMembership( + displayID: projection.resource.key, + workspaceID: old, + panelID: projection.panelID, + attached: false + ) + } + if let next { + try await provider.syncCloudDisplayMembership( + displayID: projection.resource.key, + workspaceID: next, + panelID: projection.panelID, + attached: true + ) + self.localDisplayMemberships[projection.panelID] = next + } else { + self.localDisplayMemberships[projection.panelID] = nil + } + return true + } + } + + /// Removes only this local view's durable token. Other clients and other + /// views retain their membership in the shared projection. + func syncCloudDisplayMembershipEnd( + projection: SurfaceProjection, + reason: SurfaceProjectionEndReason, + catalog: SurfaceCatalog + ) { + guard reason == .paneClosed, + projection.resource.kind == .display, + let workspaceID = localDisplayMemberships[projection.panelID] + ?? projection.remoteWorkspaceID, + let provider = catalog.provider(for: projection.resource.machine) as? any CloudDisplayMembershipSyncing, + !catalog.projections.contains(where: { + $0.panelID != projection.panelID + && $0.resource == projection.resource + && $0.remoteWorkspaceID == workspaceID + && $0.isLocalWorkspaceView + }) else { return } + enqueue(projection, catalog: catalog, presentFailure: false) { + try await provider.syncCloudDisplayMembership( + displayID: projection.resource.key, + workspaceID: workspaceID, + panelID: projection.panelID, + attached: false + ) + self.localDisplayMemberships[projection.panelID] = nil + return true + } + } +} diff --git a/Sources/Surfaces/CloudPlacementCoordinator.swift b/Sources/Surfaces/CloudPlacementCoordinator.swift index c2d19179d7f8..2f82286893e8 100644 --- a/Sources/Surfaces/CloudPlacementCoordinator.swift +++ b/Sources/Surfaces/CloudPlacementCoordinator.swift @@ -25,6 +25,7 @@ final class CloudPlacementCoordinator { private var movedTabs: [SurfaceMachineID: [String: String]] = [:] private var closedTabs: [SurfaceMachineID: [String: String]] = [:] private var confirmationCursors: [SurfaceMachineID: [String: CloudVMCursor]] = [:] + var localDisplayMemberships: [UUID: String] = [:] private(set) var failures: [SurfaceResourceID: String] = [:] init( @@ -121,6 +122,7 @@ final class CloudPlacementCoordinator { // when the pane moves into an unbound viewer workspace. let current = projectionInCurrentWorkspace(projection) catalog.setRemotePlacement(for: projection, workspaceID: current.remoteWorkspaceID, tabID: nil) + syncCloudDisplayMembership(projection: projection, current: current, catalog: catalog) return } guard let target = boundRemoteWorkspaceID(forLocalWorkspace: projection.workspaceID, on: projection.resource.machine), @@ -211,6 +213,10 @@ final class CloudPlacementCoordinator { } func projectionDidEnd(_ projection: SurfaceProjection, reason: SurfaceProjectionEndReason, catalog: SurfaceCatalog) { + if projection.isLocalWorkspaceView { + syncCloudDisplayMembershipEnd(projection: projection, reason: reason, catalog: catalog) + return + } guard reason == .paneClosed, let bound = boundRemoteWorkspaceID(forLocalWorkspace: projection.workspaceID, on: projection.resource.machine), let provider = catalog.provider(for: projection.resource.machine) as? any SurfacePlacementSyncing else { return } @@ -298,7 +304,7 @@ final class CloudPlacementCoordinator { } @discardableResult - private func enqueue( + func enqueue( _ projection: SurfaceProjection, catalog: SurfaceCatalog, presentFailure: Bool = true, diff --git a/Sources/Surfaces/CloudWorkspaceProjectionCoordinator.swift b/Sources/Surfaces/CloudWorkspaceProjectionCoordinator.swift index f73582d528a9..ba681a6c5aa4 100644 --- a/Sources/Surfaces/CloudWorkspaceProjectionCoordinator.swift +++ b/Sources/Surfaces/CloudWorkspaceProjectionCoordinator.swift @@ -63,6 +63,9 @@ final class CloudWorkspaceProjectionCoordinator { guard let binding = environment.bindings()[projection.workspaceID], binding.vmID == state.machine.rawValue, let workspaceID = binding.remoteWorkspaceID else { return true } let tabs = state.lookupIndex.tabs(contentKind: projection.resource.kind.rawValue, contentID: projection.resource.key) + if projection.resource.kind == .display && projection.remoteTabID == nil { + return state.displayMemberships.contains { $0.workspaceID == workspaceID && $0.displayID == projection.resource.key } + } return tabs.contains { tab in guard projection.remoteTabID == nil || projection.remoteTabID == tab.id, let pane = state.lookupIndex.pane(id: tab.paneID), diff --git a/Sources/Surfaces/CmuxTuiSurfaceProvider+CloudDisplayMembership.swift b/Sources/Surfaces/CmuxTuiSurfaceProvider+CloudDisplayMembership.swift new file mode 100644 index 000000000000..609c48a7ab07 --- /dev/null +++ b/Sources/Surfaces/CmuxTuiSurfaceProvider+CloudDisplayMembership.swift @@ -0,0 +1,96 @@ +import CmuxCloud +import CmuxCloudTui +import CmuxSurfaceCatalogModel +import CryptoKit +import Foundation + +@MainActor +extension CmuxTuiSurfaceProvider: CloudDisplayMembershipSyncing { + private static let displayMembershipSchema = "cmux.cloud.workspace-displays.v1" + private static let displayMembershipFrontend = "cmux-macos-cloud-layout-v1" + private static let displayMembershipGeneration = "cmux-cloud-display-layout-v1" + + func syncCloudDisplayMembership( + displayID: String, + workspaceID: String, + panelID: UUID, + attached: Bool + ) async throws { + let resourceID = SurfaceResourceID(machine: machine, kind: .display, key: displayID) + guard catalog.resources[resourceID]?.kind == .display else { + throw SurfaceCatalogError.unknownResource(resourceID) + } + guard let connected = try? await links.connected(machineID: machineID), + let link = await links.link(machineID: machineID) else { + throw ProviderError.machineAsleep(machineID) + } + let clientID = CloudTuiClientPaths().notificationClientID() + let viewID = panelID.uuidString.lowercased() + let projectionID = Self.displayMembershipProjectionID(machine: machine, workspaceID: workspaceID) + let windowID = "cloud-workspace:\(machine.rawValue):\(workspaceID)" + let idempotencyKey = "cmux-cloud-display-membership-\(UUID().uuidString.lowercased())" + var lastError: Error? + for _ in 0..<4 { + try Task.checkCancellation() + let data = try await link.run(arguments: CloudTuiRequests.snapshotArguments(socketPath: connected.socketPath)) + guard let object = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let state = CmuxTuiSnapshotParser.state(fromSnapshot: object, machine: machine), + state.document.containsCollection("frontend_projections") else { + throw SurfaceCatalogError.unsupported(CloudGuestDisplaySnapshot.unavailableMessage) + } + guard state.workspaceIDs.contains(workspaceID) else { + throw SurfaceCatalogError.destinationNotFound("workspace \(workspaceID) on \(machine.rawValue)") + } + var memberships = Set(state.displayMemberships.filter { $0.workspaceID == workspaceID }) + let token = CloudVMDisplayMembership( + machine: machine, + workspaceID: workspaceID, + displayID: displayID, + clientID: clientID, + viewID: viewID + ) + if attached { memberships.insert(token) } else { memberships.remove(token) } + let rows = (object["frontend_projections"] as? [[String: Any]]) ?? [] + let row = rows.first { ($0["id"] as? String) == projectionID } + let previousMemberships = Set(state.displayMemberships.filter { $0.workspaceID == workspaceID }) + if row != nil, memberships == previousMemberships { return } + let projection: [String: Any] = [ + "schema": Self.displayMembershipSchema, + "machine_id": machine.rawValue, + "workspace_id": workspaceID, + "memberships": memberships.sorted { + ($0.displayID, $0.clientID, $0.viewID) < ($1.displayID, $1.clientID, $1.viewID) + }.map { [ + "display_id": $0.displayID, + "client_id": $0.clientID, + "view_id": $0.viewID, + ] }, + ] + let expected = row.flatMap { CloudWireNumber.unsigned($0["projection_revision"]) } + let request = CloudTuiRequests.putCloudDisplayMembershipProjection( + projectionID: projectionID, + frontendID: Self.displayMembershipFrontend, + windowID: windowID, + generation: Self.displayMembershipGeneration, + projection: projection, + expectedProjectionRevision: expected, + idempotencyKey: idempotencyKey + ) + do { + _ = try await link.run(arguments: request) + scheduleRefresh() + return + } catch { + lastError = error + guard Self.isRevisionConflict(error) else { throw error } + } + } + throw lastError ?? SurfaceCatalogError.unsupported(CloudGuestDisplaySnapshot.unavailableMessage) + } + + private static func displayMembershipProjectionID(machine: SurfaceMachineID, workspaceID: String) -> String { + let input = Data("\(machine.rawValue)/\(workspaceID)/\(displayMembershipSchema)".utf8) + let digest = SHA256.hash(data: input) + return "projection_" + digest.prefix(16).map { String(format: "%02x", $0) }.joined() + } +} diff --git a/Sources/Surfaces/CmuxTuiSurfaceProviders.swift b/Sources/Surfaces/CmuxTuiSurfaceProviders.swift index 3f9d13f10053..48909e7549b7 100644 --- a/Sources/Surfaces/CmuxTuiSurfaceProviders.swift +++ b/Sources/Surfaces/CmuxTuiSurfaceProviders.swift @@ -82,7 +82,6 @@ final class CmuxTuiSurfaceProvider: SurfaceProvider { var portDiscovery = CloudPortDiscovery() private(set) var summaryGeneration: UInt64 = 0 let loadPortSummary: @MainActor (String) async throws -> VMSummary - func publishPortDiscovery() { guard isRegisteredInCatalog() else { return } info.portDiscoveryState = portDiscovery.state @@ -787,6 +786,7 @@ final class CmuxTuiSurfaceProvider: SurfaceProvider { info: info, observation: acceptedObservation ) + catalog.reconcileCloudRemoteState(machine: machine, state: state, observation: acceptedObservation) if reconcileTitles { catalog.cloudWorkspaceRenameService.reconcileRemoteState( machine: machine, diff --git a/Sources/Surfaces/SurfaceCatalog+CloudDisplayMembership.swift b/Sources/Surfaces/SurfaceCatalog+CloudDisplayMembership.swift new file mode 100644 index 000000000000..87c35452ea70 --- /dev/null +++ b/Sources/Surfaces/SurfaceCatalog+CloudDisplayMembership.swift @@ -0,0 +1,16 @@ +import CmuxSurfaceCatalogModel +import Foundation + +extension SurfaceCatalog { + /// The accepted daemon projection rows that describe VNC membership. This is + /// derived at snapshot time so the tree never reads a local pane as remote + /// authority. + func cloudDisplayMemberships() -> [CloudVMDisplayMembership] { + cloudStates.values + .flatMap(\.displayMemberships) + .sorted { + ($0.machine.rawValue, $0.workspaceID, $0.displayID, $0.clientID, $0.viewID) + < ($1.machine.rawValue, $1.workspaceID, $1.displayID, $1.clientID, $1.viewID) + } + } +} diff --git a/Sources/Surfaces/SurfaceCatalog+CloudDisplayProjection.swift b/Sources/Surfaces/SurfaceCatalog+CloudDisplayProjection.swift new file mode 100644 index 000000000000..8b57a8572019 --- /dev/null +++ b/Sources/Surfaces/SurfaceCatalog+CloudDisplayProjection.swift @@ -0,0 +1,14 @@ +import CmuxSurfaceCatalogModel +import Foundation + +extension SurfaceCatalog { + /// Registers a Cloud pane and admits its local display membership through + /// the same placement coordinator used by pane moves and closes. + func recordCloudProjection(_ projection: SurfaceProjection) { + let resolved = projection.remoteWorkspaceID == nil + ? cloudPlacementCoordinator.projectionInCurrentWorkspace(projection) + : projection + insertSupersedingLocalPlaceholder(resolved) + cloudPlacementCoordinator.projectionDidMove(resolved, catalog: self) + } +} diff --git a/Sources/Surfaces/SurfaceCatalog+Groups.swift b/Sources/Surfaces/SurfaceCatalog+Groups.swift index 794d52cb4dfc..78b6ca0040f7 100644 --- a/Sources/Surfaces/SurfaceCatalog+Groups.swift +++ b/Sources/Surfaces/SurfaceCatalog+Groups.swift @@ -205,9 +205,9 @@ extension SurfaceCatalog { }) { return nil } + if resources[member.resource]?.kind == .display && resources[member.resource]?.remoteViews == nil { return nil } return try remoteView(for: member.resource, workspaceID: workspaceID) } - /// How a group becomes a new local workspace: the machinery a caller injects so the /// layout can be checked without AppKit. struct NewWorkspaceHost { diff --git a/Sources/Surfaces/SurfaceCatalog+Snapshot.swift b/Sources/Surfaces/SurfaceCatalog+Snapshot.swift index 914904ed0b3c..a4a3d3d90b1e 100644 --- a/Sources/Surfaces/SurfaceCatalog+Snapshot.swift +++ b/Sources/Surfaces/SurfaceCatalog+Snapshot.swift @@ -35,7 +35,7 @@ extension SurfaceCatalog { return $0.name.localizedStandardCompare($1.name) == .orderedAscending }, resources: resources.values.map(resourceForPresentation).sorted { $0.catalogPrecedes($1) }, - projections: projections.sorted { $0.panelID.uuidString < $1.panelID.uuidString }, + projections: projections.sorted { $0.panelID.uuidString < $1.panelID.uuidString }, cloudDisplayMemberships: cloudDisplayMemberships(), staleMachineIDs: Set(cloudStateObservations.filter { $0.value.freshness != .current }.keys), displayCreationMachines: displayCreationMachines.isEmpty ? nil : displayCreationMachines ) diff --git a/Sources/Surfaces/SurfaceCatalog+WorkspaceMembership.swift b/Sources/Surfaces/SurfaceCatalog+WorkspaceMembership.swift index 479b421952fc..0c2e675ca72e 100644 --- a/Sources/Surfaces/SurfaceCatalog+WorkspaceMembership.swift +++ b/Sources/Surfaces/SurfaceCatalog+WorkspaceMembership.swift @@ -16,7 +16,7 @@ extension SurfaceCatalog { let machineSnapshot = snapshot let machineInfo = machineSnapshot.machines.first { $0.id == machine } var workspace = machineInfo?.remoteWorkspaces?.first { $0.id == workspaceID } - let resources = machineSnapshot.resources(on: machine) + let resources = machineSnapshot.cloudWorkspaceResources(on: machine) struct Candidate { let placement: SurfaceResourcePlacement diff --git a/Sources/Surfaces/SurfaceCatalog.swift b/Sources/Surfaces/SurfaceCatalog.swift index 9210d356cfa9..0dd0cf886213 100644 --- a/Sources/Surfaces/SurfaceCatalog.swift +++ b/Sources/Surfaces/SurfaceCatalog.swift @@ -1107,7 +1107,7 @@ final class SurfaceCatalog { /// Records a materialized pane and reconciles it with the installed graph. func record(_ projection: SurfaceProjection) { consumePendingProjectionIfMaterialized(projection) - insertSupersedingLocalPlaceholder(cloudPlacementCoordinator.projectionInCurrentWorkspace(projection)) + recordCloudProjection(projection) reconcileCloudWorkspaceBinding(localWorkspaceID: projection.workspaceID) reconcileCloudProjection(projection) syncCloudTerminalTabIcon(projection) @@ -1163,7 +1163,7 @@ final class SurfaceCatalog { /// local provider already registered as a plain local terminal (the pane is created /// first, then attached), the local placeholder yields: its projection ends and the /// local resource disappears, so the pane counts once, as the remote terminal. - private func insertSupersedingLocalPlaceholder(_ projection: SurfaceProjection) { + func insertSupersedingLocalPlaceholder(_ projection: SurfaceProjection) { if !projection.resource.machine.isLocal { for existing in projections where existing.panelID == projection.panelID && existing.resource.machine.isLocal { projections.remove(existing) @@ -1346,7 +1346,7 @@ final class SurfaceCatalog { if resources[record.resource] != nil { wokenMachines.insert(record.resource.machine) pendingRestoredProjections.remove(panelID: record.panelID) - insertSupersedingLocalPlaceholder(cloudPlacementCoordinator.restoredProjection(record, workspaceID: workspaceID)) + recordCloudProjection(cloudPlacementCoordinator.restoredProjection(record, workspaceID: workspaceID)) } else { pendingRestoredProjections.stage(record, workspaceID: workspaceID) } @@ -1406,7 +1406,7 @@ final class SurfaceCatalog { isAllowed: canRestoreProjection ) for projection in resolved { - insertSupersedingLocalPlaceholder(cloudPlacementCoordinator.resolvingLocalPreviewMembership(projection)) + recordCloudProjection(cloudPlacementCoordinator.resolvingLocalPreviewMembership(projection)) resolvedWorkspaceIDs.insert(projection.workspaceID) } for workspaceID in resolvedWorkspaceIDs { From a75f0e1c22b499c345a45e949bfe8e88bd980c91 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 29 Sep 2026 16:15:07 -0700 Subject: [PATCH 03/17] fix: retain Cloud terminal replay before pane binding --- ...TuiManualMirrorSession+PendingReplay.swift | 19 +++++++++++++++ .../Cloud/CloudTuiManualMirrorSession.swift | 11 ++++----- cmux.xcodeproj/project.pbxproj | 8 +++++++ ...oudManualMirrorStartupRenderingTests.swift | 24 +++++++++++++++++++ cmuxTests/CloudRestoreReplayFixture.swift | 22 +++++++++++++---- 5 files changed, 74 insertions(+), 10 deletions(-) create mode 100644 Sources/Cloud/CloudTuiManualMirrorSession+PendingReplay.swift create mode 100644 cmuxTests/CloudManualMirrorStartupRenderingTests.swift diff --git a/Sources/Cloud/CloudTuiManualMirrorSession+PendingReplay.swift b/Sources/Cloud/CloudTuiManualMirrorSession+PendingReplay.swift new file mode 100644 index 000000000000..1c7982af9a05 --- /dev/null +++ b/Sources/Cloud/CloudTuiManualMirrorSession+PendingReplay.swift @@ -0,0 +1,19 @@ +import CmuxTerminal +import Foundation + +extension CloudTuiManualMirrorSession { + /// Applies an attach replay that arrived before the native pane was bound. + /// The replay is already reset and color-composed, so it must enter Ghostty + /// exactly once after binding. + func flushPendingReplay() { + guard let pendingReplay, let surface else { return } + self.pendingReplay = nil + let token = replayFidelity.replayQueued(remote: lastRemoteGrid, local: settledGrid()) + surface.processRemoteReplay(pendingReplay) { [weak self, weak surface] in + surface?.forceRefresh(reason: "cloud.replay.applied") + self?.replayApplied(token: token) + } onDiscarded: { [weak self] in + self?.replayDiscarded(token: token) + } + } +} diff --git a/Sources/Cloud/CloudTuiManualMirrorSession.swift b/Sources/Cloud/CloudTuiManualMirrorSession.swift index a6b47039e1f1..72debbf64e27 100644 --- a/Sources/Cloud/CloudTuiManualMirrorSession.swift +++ b/Sources/Cloud/CloudTuiManualMirrorSession.swift @@ -41,6 +41,7 @@ final class CloudTuiManualMirrorSession { private(set) var socketPath: String? private var nextRequestID: UInt64 = 1 private var pendingRequests: [UInt64: CloudTuiManualMirrorRequestKind] = [:] + var pendingReplay: Data? /// Capabilities belong to the current control connection. They must not /// survive a daemon restart because an older generation may not implement /// lease-fenced sizing or initial attach dimensions. @@ -196,10 +197,7 @@ final class CloudTuiManualMirrorSession { self.surface = surface surface.hostedView.cloudTerminalOverlay.session = self manualMirrorLogger.info("bind terminal=\(self.terminalID, privacy: .private(mask: .hash)) surface=\(self.remoteSurfaceID)") - // A color sidecar that arrived before any surface existed reaches this - // one now. The stored sidecar is the remote truth, and the next - // identical sidecar would produce an empty delta and leave the pane on - // the local theme. + // A color sidecar that arrived before any surface existed reaches this one now. let pendingColors = appliedRemoteColors.oscBytes if !pendingColors.isEmpty { surface.processRemoteOutput(pendingColors) @@ -216,6 +214,7 @@ final class CloudTuiManualMirrorSession { surface.onManualVisibilityChanged = { [weak self] visible in self?.visibilityChanged(visible) } + flushPendingReplay() surface.flushPendingManualSizeReportIfAttached() runtimeReady() } @@ -307,7 +306,7 @@ final class CloudTuiManualMirrorSession { remoteLease = nil serverCapabilities.removeAll(keepingCapacity: true) resizeScheduler.resetForReconnect() - lastRemoteGrid = nil + lastRemoteGrid = nil; pendingReplay = nil diagnosticReplayReceived = false } /// Samples the grid after Ghostty has created its runtime surface. Runtime @@ -634,8 +633,8 @@ final class CloudTuiManualMirrorSession { replay.append(bytes) replay.append(replayColors.oscBytes) appliedRemoteColors = replayColors + guard let surface else { pendingReplay = replay; return } let token = replayFidelity.replayQueued(remote: remote, local: settledGrid()) - guard let surface else { return } surface.processRemoteReplay(replay) { [weak self, weak surface] in surface?.forceRefresh(reason: "cloud.replay.applied") self?.replayApplied(token: token) diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 496ac77a110b..78128285def7 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -752,6 +752,7 @@ C11324810000000000000001 /* CloudManualMirrorMaterialization.swift in Sources */ = {isa = PBXBuildFile; fileRef = C11324820000000000000001 /* CloudManualMirrorMaterialization.swift */; }; C12376010000000000000001 /* CloudManualMirrorPresentationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12376020000000000000001 /* CloudManualMirrorPresentationTests.swift */; }; 95E93CB008517B3E2ABDC326 /* CloudManualMirrorSocketFixture.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF70B0862D206C62CA5ACF65 /* CloudManualMirrorSocketFixture.swift */; }; + 865554BEE843813A69525FC0 /* CloudManualMirrorStartupRenderingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDD2286462BDC54589245BFF /* CloudManualMirrorStartupRenderingTests.swift */; }; C11322A10000000000000001 /* CloudManualMirrorTransportTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C11322A20000000000000001 /* CloudManualMirrorTransportTests.swift */; }; AF4D952CE6254D3D8D886EBC /* CloudNameAuthorityFixture.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9A7787BF0744F3981CFD375 /* CloudNameAuthorityFixture.swift */; }; EF3CE50369C1428F8F611B2F /* CloudNameAuthorityTestProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20C6CAE6E212495C917D9568 /* CloudNameAuthorityTestProvider.swift */; }; @@ -937,6 +938,7 @@ A12367000000000000000002 /* CloudTreeWorkspaceTitleLayoutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A12367000000000000000001 /* CloudTreeWorkspaceTitleLayoutTests.swift */; }; A838AA211BDA47268C683501 /* CloudTuiManualIOConnectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A838AA211BDA47268C683502 /* CloudTuiManualIOConnectionTests.swift */; }; 124760000000000000000006 /* CloudTuiManualMirrorSession+Capabilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = 124770000000000000000006 /* CloudTuiManualMirrorSession+Capabilities.swift */; }; + 79F5ACE2406FEBF9C900F333 /* CloudTuiManualMirrorSession+PendingReplay.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB12D123CA2A96F1DDBA3405 /* CloudTuiManualMirrorSession+PendingReplay.swift */; }; F181020CA0F7042CBC03B870 /* CloudTuiManualMirrorSession+ReplayFidelity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D8DA9E8FCC6A6A56E0B6435 /* CloudTuiManualMirrorSession+ReplayFidelity.swift */; }; C11322F10000000000000001 /* CloudTuiManualMirrorSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = C11322F20000000000000001 /* CloudTuiManualMirrorSession.swift */; }; 0F2A151D749994FC57D403CA /* CloudTunnelActivationObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E9D51274EC32D701633288E /* CloudTunnelActivationObserver.swift */; }; @@ -5003,6 +5005,7 @@ C11324820000000000000001 /* CloudManualMirrorMaterialization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudManualMirrorMaterialization.swift; sourceTree = ""; }; C12376020000000000000001 /* CloudManualMirrorPresentationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudManualMirrorPresentationTests.swift; sourceTree = ""; }; EF70B0862D206C62CA5ACF65 /* CloudManualMirrorSocketFixture.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CloudManualMirrorSocketFixture.swift; sourceTree = ""; }; + FDD2286462BDC54589245BFF /* CloudManualMirrorStartupRenderingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudManualMirrorStartupRenderingTests.swift"; sourceTree = ""; }; C11322A20000000000000001 /* CloudManualMirrorTransportTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudManualMirrorTransportTests.swift; sourceTree = ""; }; F9A7787BF0744F3981CFD375 /* CloudNameAuthorityFixture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudNameAuthorityFixture.swift"; sourceTree = ""; }; 20C6CAE6E212495C917D9568 /* CloudNameAuthorityTestProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudNameAuthorityTestProvider.swift"; sourceTree = ""; }; @@ -5188,6 +5191,7 @@ A12367000000000000000001 /* CloudTreeWorkspaceTitleLayoutTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CloudTreeWorkspaceTitleLayoutTests.swift; sourceTree = ""; }; A838AA211BDA47268C683502 /* CloudTuiManualIOConnectionTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CloudTuiManualIOConnectionTests.swift; sourceTree = ""; }; 124770000000000000000006 /* CloudTuiManualMirrorSession+Capabilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Sources/Cloud/CloudTuiManualMirrorSession+Capabilities.swift"; sourceTree = SOURCE_ROOT; }; + FB12D123CA2A96F1DDBA3405 /* CloudTuiManualMirrorSession+PendingReplay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudTuiManualMirrorSession+PendingReplay.swift"; sourceTree = ""; }; 1D8DA9E8FCC6A6A56E0B6435 /* CloudTuiManualMirrorSession+ReplayFidelity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudTuiManualMirrorSession+ReplayFidelity.swift"; sourceTree = ""; }; C11322F20000000000000001 /* CloudTuiManualMirrorSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudTuiManualMirrorSession.swift; sourceTree = ""; }; 8E9D51274EC32D701633288E /* CloudTunnelActivationObserver.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CloudTunnelActivationObserver.swift; sourceTree = ""; }; @@ -9032,6 +9036,7 @@ ED2C0C087F0298473CB156E7 /* MachinesPanelViewModel+MachineDeletion.swift */, E3AF26EAE918E646929A4B58 /* CloudTreeOutlineView+RestoreState.swift */, 276B8742C711D7E718A5DC5A /* CloudTreePortPresentation.swift */, + FB12D123CA2A96F1DDBA3405 /* CloudTuiManualMirrorSession+PendingReplay.swift */, ); name = Cloud; path = Cloud; @@ -12557,6 +12562,7 @@ 09DC7DE176A93C580A263ADD /* PaneDropTargetIdentityTests.swift */, 08202CA6A24B08873B5CC6FD /* GhosttyDialogThemeTests.swift */, 635AF226CFD6879AC9ACFD09 /* CloudDisplayMembershipProjectionTests.swift */, + FDD2286462BDC54589245BFF /* CloudManualMirrorStartupRenderingTests.swift */, ); path = cmuxTests; sourceTree = ""; @@ -13832,6 +13838,7 @@ A12357000000000000000002 /* CloudTreeTerminalNavigationCoordinator.swift in Sources */, 12D5531F8CC84B0197E44AD6 /* CloudTreeTerminalRow+Directory.swift in Sources */, 124760000000000000000006 /* CloudTuiManualMirrorSession+Capabilities.swift in Sources */, + 79F5ACE2406FEBF9C900F333 /* CloudTuiManualMirrorSession+PendingReplay.swift in Sources */, F181020CA0F7042CBC03B870 /* CloudTuiManualMirrorSession+ReplayFidelity.swift in Sources */, C11322F10000000000000001 /* CloudTuiManualMirrorSession.swift in Sources */, 0F2A151D749994FC57D403CA /* CloudTunnelActivationObserver.swift in Sources */, @@ -16415,6 +16422,7 @@ FA0000000000000000000001 /* CloudManualInputOptionBackspaceTests.swift in Sources */, C12376010000000000000001 /* CloudManualMirrorPresentationTests.swift in Sources */, 95E93CB008517B3E2ABDC326 /* CloudManualMirrorSocketFixture.swift in Sources */, + 865554BEE843813A69525FC0 /* CloudManualMirrorStartupRenderingTests.swift in Sources */, C11322A10000000000000001 /* CloudManualMirrorTransportTests.swift in Sources */, AF4D952CE6254D3D8D886EBC /* CloudNameAuthorityFixture.swift in Sources */, EF3CE50369C1428F8F611B2F /* CloudNameAuthorityTestProvider.swift in Sources */, diff --git a/cmuxTests/CloudManualMirrorStartupRenderingTests.swift b/cmuxTests/CloudManualMirrorStartupRenderingTests.swift new file mode 100644 index 000000000000..f017a48b0741 --- /dev/null +++ b/cmuxTests/CloudManualMirrorStartupRenderingTests.swift @@ -0,0 +1,24 @@ +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +/// Cloud attach replay must survive the short interval before a native pane +/// binds its ``TerminalSurface``. The prompt intentionally has no newline, the +/// same shape as the partial-line report in #3079. +@Suite("Cloud manual mirror startup rendering") +struct CloudManualMirrorStartupRenderingTests { + @Test @MainActor + func partialPromptReceivedBeforeSurfaceBindingIsRendered() async throws { + let fixture = try CloudRestoreReplayFixture(bindSurface: false) + defer { fixture.close() } + + try await fixture.attachBeforeSurfaceBinding(replay: Data("prompt$ ".utf8)) + fixture.bindSurface() + try await fixture.waitForText("prompt$ ") + } +} diff --git a/cmuxTests/CloudRestoreReplayFixture.swift b/cmuxTests/CloudRestoreReplayFixture.swift index 61f01ce62741..3482a5e756f0 100644 --- a/cmuxTests/CloudRestoreReplayFixture.swift +++ b/cmuxTests/CloudRestoreReplayFixture.swift @@ -19,7 +19,7 @@ final class CloudRestoreReplayFixture { let socket: CloudManualMirrorSocketFixture private let session: CloudTuiManualMirrorSession - init(initiallyClaimsGeometry: Bool = true) throws { + init(initiallyClaimsGeometry: Bool = true, bindSurface: Bool = true) throws { _ = NSApplication.shared socket = try CloudManualMirrorSocketFixture() session = CloudTuiManualMirrorSession( @@ -44,7 +44,7 @@ final class CloudRestoreReplayFixture { content.layoutSubtreeIfNeeded() hosted.setVisibleInUI(false) hosted.setActive(false) - session.bind(surface: surface) + if bindSurface { session.bind(surface: surface) } } func setGrid(columns: Int, rows: Int) async throws { @@ -70,6 +70,20 @@ final class CloudRestoreReplayFixture { try await waitUntil { self.session.phase == .attached } } + func attachBeforeSurfaceBinding(replay: Data, columns: Int = 80, rows: Int = 24) async throws { + session.reconnect(socketPath: socket.socketPath) + let attach = try await answerHandshake() + socket.send(["id": attach.id, "ok": true, "data": [:]]) + try await deliver(replay, event: "vt-state", marker: "prompt", columns: columns, rows: rows, waitForSurface: false) + try await waitUntil { self.session.lastRemoteGrid != nil } + } + + func bindSurface() { session.bind(surface: surface) } + + func waitForText(_ text: String) async throws { + try await waitUntil { self.surface.readText(region: .screen)?.contains(text) == true } + } + /// Answers identify and client registration on the session's newest /// connection and returns its unanswered attach-surface request. func answerHandshake() async throws -> CloudManualMirrorFixtureCommand { @@ -122,7 +136,7 @@ final class CloudRestoreReplayFixture { func deliver( _ bytes: Data, event: String, marker: String, colors: [String: Any]? = nil, - columns: Int = 80, rows: Int = 24 + columns: Int = 80, rows: Int = 24, waitForSurface: Bool = true ) async throws { var payload: [String: Any] = [ "event": event, "surface": 17, "cols": columns, "rows": rows, @@ -130,7 +144,7 @@ final class CloudRestoreReplayFixture { ] if let colors { payload["colors"] = colors } socket.send(payload) - try await waitUntil { self.surface.readText(region: .screen)?.contains(marker) == true } + if waitForSurface { try await waitUntil { self.surface.readText(region: .screen)?.contains(marker) == true } } } func close() { From 78664ad26a942e269d9abe78da602a3cdd18f9ea Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 29 Sep 2026 16:23:13 -0700 Subject: [PATCH 04/17] fix: import Cloud projection end reason --- .../CloudPlacementCoordinator+CloudDisplayMembership.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/Surfaces/CloudPlacementCoordinator+CloudDisplayMembership.swift b/Sources/Surfaces/CloudPlacementCoordinator+CloudDisplayMembership.swift index 0da84021b5a0..def818b5c404 100644 --- a/Sources/Surfaces/CloudPlacementCoordinator+CloudDisplayMembership.swift +++ b/Sources/Surfaces/CloudPlacementCoordinator+CloudDisplayMembership.swift @@ -1,3 +1,4 @@ +import CmuxCloud import CmuxSurfaceCatalogModel import Foundation From 167227b696ed395b400dd361628d634e9eef5df4 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 29 Sep 2026 16:42:27 -0700 Subject: [PATCH 05/17] fix: preserve Cloud display placement identities --- ...talogSnapshot+CloudDisplayMembership.swift | 27 +++++++++++++------ ...aceRemoteView+CloudDisplayMembership.swift | 11 ++++++++ .../SurfaceResourcePlacement.swift | 2 +- Sources/Cloud/CloudTreeNode.swift | 4 +-- Sources/Surfaces/SurfaceCatalog+Groups.swift | 4 +-- Sources/Surfaces/SurfaceCatalog.swift | 4 +-- ...loudDisplayMembershipProjectionTests.swift | 1 + 7 files changed, 38 insertions(+), 15 deletions(-) create mode 100644 Packages/macOS/CmuxSurfaceCatalogModel/Sources/CmuxSurfaceCatalogModel/SurfaceRemoteView+CloudDisplayMembership.swift diff --git a/Packages/macOS/CmuxSurfaceCatalogModel/Sources/CmuxSurfaceCatalogModel/SurfaceCatalogSnapshot+CloudDisplayMembership.swift b/Packages/macOS/CmuxSurfaceCatalogModel/Sources/CmuxSurfaceCatalogModel/SurfaceCatalogSnapshot+CloudDisplayMembership.swift index 58954db483fe..aff9c5616e88 100644 --- a/Packages/macOS/CmuxSurfaceCatalogModel/Sources/CmuxSurfaceCatalogModel/SurfaceCatalogSnapshot+CloudDisplayMembership.swift +++ b/Packages/macOS/CmuxSurfaceCatalogModel/Sources/CmuxSurfaceCatalogModel/SurfaceCatalogSnapshot+CloudDisplayMembership.swift @@ -13,15 +13,26 @@ extension SurfaceCatalogSnapshot { uniquingKeysWith: { first, _ in first } ) var result = base - var seen = Set() - for membership in cloudDisplayMemberships where membership.machine == machine { - let id = SurfaceResourceID(machine: machine, kind: .display, key: membership.displayID) - guard let baseResource = byID[id], let workspace = workspaces[membership.workspaceID], - !baseResource.remoteWorkspaces.contains(where: { $0.id == workspace.id }), - seen.insert("\(id.rawValue)\u{0}\(workspace.id)").inserted else { continue } + for displayID in Set(cloudDisplayMemberships.filter { $0.machine == machine }.map(\.displayID)) { + let id = SurfaceResourceID(machine: machine, kind: .display, key: displayID) + guard let baseResource = byID[id] else { continue } var placed = baseResource - placed.remoteViews = nil - placed.remoteWorkspace = workspace + let memberships = cloudDisplayMemberships + .filter { $0.machine == machine && $0.displayID == displayID } + .sorted { ($0.workspaceID, $0.clientID, $0.viewID) < ($1.workspaceID, $1.clientID, $1.viewID) } + let views = memberships.compactMap { membership -> SurfaceRemoteView? in + guard let workspace = workspaces[membership.workspaceID] else { return nil } + return SurfaceRemoteView( + tabID: SurfaceRemoteView.cloudDisplayMembershipViewPrefix + membership.viewID, + workspace: workspace, + name: nil, + index: memberships.firstIndex(of: membership), + focused: false + ) + } + guard !views.isEmpty else { continue } + placed.remoteViews = (baseResource.remoteViews ?? []) + views + placed.remoteWorkspace = placed.remoteViews?.first?.workspace result.append(placed) } return result diff --git a/Packages/macOS/CmuxSurfaceCatalogModel/Sources/CmuxSurfaceCatalogModel/SurfaceRemoteView+CloudDisplayMembership.swift b/Packages/macOS/CmuxSurfaceCatalogModel/Sources/CmuxSurfaceCatalogModel/SurfaceRemoteView+CloudDisplayMembership.swift new file mode 100644 index 000000000000..6d5afa11927b --- /dev/null +++ b/Packages/macOS/CmuxSurfaceCatalogModel/Sources/CmuxSurfaceCatalogModel/SurfaceRemoteView+CloudDisplayMembership.swift @@ -0,0 +1,11 @@ +import Foundation + +extension SurfaceRemoteView { + /// Synthetic tab identity used only to order a display view persisted by + /// the Cloud frontend projection. It is never sent to a daemon tab API. + public static let cloudDisplayMembershipViewPrefix = "cloud-display-view:" + + public var isCloudDisplayMembershipView: Bool { + tabID.hasPrefix(Self.cloudDisplayMembershipViewPrefix) + } +} diff --git a/Packages/macOS/CmuxSurfaceCatalogModel/Sources/CmuxSurfaceCatalogModel/SurfaceResourcePlacement.swift b/Packages/macOS/CmuxSurfaceCatalogModel/Sources/CmuxSurfaceCatalogModel/SurfaceResourcePlacement.swift index f15c77e811f5..16119e9c0e08 100644 --- a/Packages/macOS/CmuxSurfaceCatalogModel/Sources/CmuxSurfaceCatalogModel/SurfaceResourcePlacement.swift +++ b/Packages/macOS/CmuxSurfaceCatalogModel/Sources/CmuxSurfaceCatalogModel/SurfaceResourcePlacement.swift @@ -17,6 +17,6 @@ public struct SurfaceResourcePlacement: Hashable, Codable, Sendable { ) { self.resource = resource self.remoteWorkspaceID = remoteView?.workspace.id ?? remoteWorkspaceID - self.remoteTabID = remoteView?.tabID ?? remoteTabID + self.remoteTabID = remoteView?.isCloudDisplayMembershipView == true ? nil : (remoteView?.tabID ?? remoteTabID) } } diff --git a/Sources/Cloud/CloudTreeNode.swift b/Sources/Cloud/CloudTreeNode.swift index 06f3b2421263..f2d2b74003ae 100644 --- a/Sources/Cloud/CloudTreeNode.swift +++ b/Sources/Cloud/CloudTreeNode.swift @@ -82,7 +82,6 @@ final class CloudTreeNode: NSObject { var resourceSection: CloudTreeMachineResourceSection? /// For workspace rows: everything the workspace holds, in the order it opens. private var explicitDragGroup: SurfaceResourceGroup? - init(id: String, kind: Kind, children: [CloudTreeNode] = [], dragGroup: SurfaceResourceGroup? = nil, isPinned: Bool = false) { self.id = id self.kind = kind @@ -435,7 +434,8 @@ enum CloudTreeNodeBuilder { ) openPlacements.insert(identity) exact[identity, default: []].append(projection.workspaceID) - } else if let views = resourceByID[projection.resource]?.remoteViews { + } else if let views = resourceByID[projection.resource]?.remoteViews, + !views.allSatisfy(\.isCloudDisplayMembershipView) { // Intermediate builds persisted the workspace id before // they persisted tab ids. Recover the tab only when the // current graph has one unambiguous view in that diff --git a/Sources/Surfaces/SurfaceCatalog+Groups.swift b/Sources/Surfaces/SurfaceCatalog+Groups.swift index 78b6ca0040f7..6c534ad6ae49 100644 --- a/Sources/Surfaces/SurfaceCatalog+Groups.swift +++ b/Sources/Surfaces/SurfaceCatalog+Groups.swift @@ -2,7 +2,6 @@ import CmuxCloud import CmuxCore import CmuxSurfaceCatalogModel import Foundation - /// A collection of resources that travels as one drag or one "open all": a cmux-tui /// workspace on a machine, or a local workspace (the panes it projects). The canonical /// payload is typed placements. `resources` and the group workspace id remain as derived @@ -205,7 +204,8 @@ extension SurfaceCatalog { }) { return nil } - if resources[member.resource]?.kind == .display && resources[member.resource]?.remoteViews == nil { return nil } + if resources[member.resource]?.kind == .display, + resources[member.resource]?.remoteViews?.allSatisfy(\.isCloudDisplayMembershipView) == true { return nil } return try remoteView(for: member.resource, workspaceID: workspaceID) } /// How a group becomes a new local workspace: the machinery a caller injects so the diff --git a/Sources/Surfaces/SurfaceCatalog.swift b/Sources/Surfaces/SurfaceCatalog.swift index 0dd0cf886213..198ba73d59b7 100644 --- a/Sources/Surfaces/SurfaceCatalog.swift +++ b/Sources/Surfaces/SurfaceCatalog.swift @@ -81,7 +81,6 @@ final class SurfaceCatalog { var pendingRestoredProjections = SurfaceProjectionRestoreStore() { didSet { cloudProjectionIndex = nil } } /// Focus/select behavior the app uses to bring an existing projection forward. var focusProjection: ((SurfaceProjection) -> Void)? - init( abandonedMaterializationTimeout: Duration = SurfaceCatalog.defaultAbandonedMaterializationTimeout, retiredMaterializationRetention: Duration = SurfaceCatalog.defaultRetiredMaterializationRetention, @@ -1287,11 +1286,12 @@ final class SurfaceCatalog { return view } guard let workspaceID else { return nil } - let matches = views.filter { $0.workspace.id == workspaceID } + let matches = views.filter { !$0.isCloudDisplayMembershipView && $0.workspace.id == workspaceID } guard matches.count <= 1 else { throw SurfaceCatalogError.ambiguousRemotePlacement(id, workspaceID: workspaceID) } guard let view = matches.first else { + if resource.kind == .display, views.contains(where: \.isCloudDisplayMembershipView) { return nil } throw SurfaceCatalogError.unavailable(id, reason: "remote workspace \(workspaceID) has no view of this resource") } return view diff --git a/cmuxTests/CloudDisplayMembershipProjectionTests.swift b/cmuxTests/CloudDisplayMembershipProjectionTests.swift index b7c7137e0fa6..bbff29a0d461 100644 --- a/cmuxTests/CloudDisplayMembershipProjectionTests.swift +++ b/cmuxTests/CloudDisplayMembershipProjectionTests.swift @@ -110,6 +110,7 @@ struct CloudDisplayMembershipProjectionTests { ) let workspaceResources = snapshot.cloudWorkspaceResources(on: machine) #expect(workspaceResources.filter { $0.id.key == displayID }.count == 2) + #expect(workspaceResources.last?.remoteViews?.first?.isCloudDisplayMembershipView == true) #expect(!workspaceResources.contains { $0.id.key == "display:99" }) } From 4e36b68903073ea007f2c3a43a8434d5aa0abb4a Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 29 Sep 2026 16:42:55 -0700 Subject: [PATCH 06/17] style: keep Cloud snapshot arguments ordered --- Sources/Surfaces/SurfaceCatalog+Snapshot.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Sources/Surfaces/SurfaceCatalog+Snapshot.swift b/Sources/Surfaces/SurfaceCatalog+Snapshot.swift index a4a3d3d90b1e..d2d174de6382 100644 --- a/Sources/Surfaces/SurfaceCatalog+Snapshot.swift +++ b/Sources/Surfaces/SurfaceCatalog+Snapshot.swift @@ -35,9 +35,10 @@ extension SurfaceCatalog { return $0.name.localizedStandardCompare($1.name) == .orderedAscending }, resources: resources.values.map(resourceForPresentation).sorted { $0.catalogPrecedes($1) }, - projections: projections.sorted { $0.panelID.uuidString < $1.panelID.uuidString }, cloudDisplayMemberships: cloudDisplayMemberships(), + projections: projections.sorted { $0.panelID.uuidString < $1.panelID.uuidString }, staleMachineIDs: Set(cloudStateObservations.filter { $0.value.freshness != .current }.keys), - displayCreationMachines: displayCreationMachines.isEmpty ? nil : displayCreationMachines + displayCreationMachines: displayCreationMachines.isEmpty ? nil : displayCreationMachines, + cloudDisplayMemberships: cloudDisplayMemberships() ) } From 80146da2654e0cbb970256627ac9a8cfbb234830 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 29 Sep 2026 17:09:15 -0700 Subject: [PATCH 07/17] fix: materialize Cloud display memberships across clients --- .../CloudWorkspaceProjectionPlan.swift | 11 +- .../CloudWorkspaceProjectionPlanTests.swift | 33 ++++ .../CloudVMDisplayMembership.swift | 20 ++- ...talogSnapshot+CloudDisplayMembership.swift | 7 +- ...aceRemoteView+CloudDisplayMembership.swift | 10 ++ .../SurfaceResourcePlacement.swift | 7 +- Sources/Cloud/CloudTreeNode.swift | 6 +- Sources/Cloud/CloudTreeRemoteWorkspaces.swift | 13 ++ ...ntCoordinator+CloudDisplayMembership.swift | 40 ++++- .../Surfaces/CloudPlacementCoordinator.swift | 4 +- .../CloudWorkspaceProjectionCoordinator.swift | 24 ++- ...rfaceProvider+CloudDisplayMembership.swift | 23 +-- .../CmuxTuiSurfaceProvider+ManualMirror.swift | 2 +- .../Surfaces/CmuxTuiSurfaceProviders.swift | 4 +- ...urfaceCatalog+CloudDisplayMembership.swift | 167 ++++++++++++++++++ Sources/Surfaces/SurfaceCatalog+Groups.swift | 28 --- Sources/Surfaces/SurfaceCatalog.swift | 99 ++++------- ...ceProvider+MaterializationValidation.swift | 3 +- ...loudDisplayMembershipProjectionTests.swift | 57 +++++- 19 files changed, 421 insertions(+), 137 deletions(-) diff --git a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Surfaces/CloudWorkspaceProjectionPlan.swift b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Surfaces/CloudWorkspaceProjectionPlan.swift index 77938c0d29cf..d4f5198ac417 100644 --- a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Surfaces/CloudWorkspaceProjectionPlan.swift +++ b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Surfaces/CloudWorkspaceProjectionPlan.swift @@ -10,6 +10,7 @@ public struct CloudWorkspaceProjectionPlan { public init(desired: [SurfaceResourcePlacement], existing: [SurfaceProjection]) { let wanted = Set(desired) var seen = Set() + var satisfied = Set() var obsolete: [SurfaceProjection] = [] for projection in existing.sorted(by: { $0.panelID.uuidString < $1.panelID.uuidString }) { let placement = SurfaceResourcePlacement( @@ -22,13 +23,19 @@ public struct CloudWorkspaceProjectionPlan { // coordinates were cleared by an authoritative remote deletion has // neither coordinate and must still be retired. if projection.isLocalWorkspaceView && projection.remoteWorkspaceID != nil { - seen.insert(placement) + for matching in desired where + matching.cloudDisplayMembershipViewID != nil + && matching.resource == projection.resource + && matching.remoteWorkspaceID == projection.remoteWorkspaceID { + satisfied.insert(matching) + seen.insert(matching) + } continue } if !wanted.contains(placement) || !seen.insert(placement).inserted { obsolete.append(projection) } } var missingSeen = Set() - missing = desired.filter { !seen.contains($0) && missingSeen.insert($0).inserted } + missing = desired.filter { !satisfied.contains($0) && missingSeen.insert($0).inserted } self.obsolete = obsolete } } diff --git a/Packages/macOS/CmuxCloud/Tests/CmuxCloudTests/CloudWorkspaceProjectionPlanTests.swift b/Packages/macOS/CmuxCloud/Tests/CmuxCloudTests/CloudWorkspaceProjectionPlanTests.swift index a220f4087a14..61bc646204b1 100644 --- a/Packages/macOS/CmuxCloud/Tests/CmuxCloudTests/CloudWorkspaceProjectionPlanTests.swift +++ b/Packages/macOS/CmuxCloud/Tests/CmuxCloudTests/CloudWorkspaceProjectionPlanTests.swift @@ -29,6 +29,39 @@ struct CloudWorkspaceProjectionPlanTests { #expect(plan.missing == [desired]) } + @Test("A local Desktop preview satisfies an exact frontend membership view") + func localDisplayPreviewSatisfiesMembership() { + let display = SurfaceResourceID(machine: machine, kind: .display, key: "display:1") + let preview = SurfaceProjection( + resource: display, + workspaceID: UUID(), + panelID: UUID(), + remoteWorkspaceID: "remote-workspace" + ) + let remoteWorkspace = SurfaceRemoteWorkspace( + id: "remote-workspace", name: "Cloud", index: 0, focused: true + ) + let desired = SurfaceResourcePlacement( + resource: display, + remoteView: SurfaceRemoteView( + tabID: SurfaceRemoteView.cloudDisplayMembershipViewPrefix + "view-a", + workspace: remoteWorkspace + ) + ) + let secondView = SurfaceResourcePlacement( + resource: display, + remoteView: SurfaceRemoteView( + tabID: SurfaceRemoteView.cloudDisplayMembershipViewPrefix + "view-b", + workspace: remoteWorkspace + ) + ) + + let plan = CloudWorkspaceProjectionPlan(desired: [desired, secondView], existing: [preview]) + + #expect(plan.obsolete.isEmpty) + #expect(plan.missing.isEmpty) + } + @Test("A preview whose remote placement was deleted is retired") func deletedRemotePlacementIsNotMistakenForPreview() { let display = SurfaceResourceID(machine: machine, kind: .display, key: "display:1") diff --git a/Packages/macOS/CmuxSurfaceCatalogModel/Sources/CmuxSurfaceCatalogModel/CloudVMDisplayMembership.swift b/Packages/macOS/CmuxSurfaceCatalogModel/Sources/CmuxSurfaceCatalogModel/CloudVMDisplayMembership.swift index 7c2744ff551e..41205150de53 100644 --- a/Packages/macOS/CmuxSurfaceCatalogModel/Sources/CmuxSurfaceCatalogModel/CloudVMDisplayMembership.swift +++ b/Packages/macOS/CmuxSurfaceCatalogModel/Sources/CmuxSurfaceCatalogModel/CloudVMDisplayMembership.swift @@ -7,6 +7,18 @@ import Foundation /// and `displayID` are the ownership identity; `clientID` and `viewID` let /// independent clients remove only the view they created. public struct CloudVMDisplayMembership: Hashable, Codable, Sendable { + /// Schema stored in the daemon's frontend projection record. + public static let projectionSchema = "cmux.cloud.workspace-displays.v1" + /// Frontend identity authorized to author this projection. + public static let projectionFrontendID = "cmux-macos-cloud-layout-v1" + /// Projection generation used to reject stale frontend implementations. + public static let projectionGeneration = "cmux-cloud-display-layout-v1" + + /// Stable window subject used to scope one machine workspace projection. + public static func projectionWindowID(machine: SurfaceMachineID, workspaceID: String) -> String { + "cloud-workspace:\(machine.rawValue):\(workspaceID)" + } + public let machine: SurfaceMachineID public let workspaceID: String public let displayID: String @@ -36,16 +48,22 @@ extension CloudVMState { public var displayMemberships: [CloudVMDisplayMembership] { let workspaceIDs = Set(workspaces.map(\.id)) var result = Set() + let expectedSessionID = (document.value(forKey: "session") as? [String: Any])?["id"] as? String for row in document.objects(forCollectionKey: "frontend_projections") ?? [] { + guard row["frontend_id"] as? String == CloudVMDisplayMembership.projectionFrontendID, + row["generation"] as? String == CloudVMDisplayMembership.projectionGeneration else { continue } + if let expectedSessionID, row["session_id"] as? String != expectedSessionID { continue } guard let projection = row["projection"] as? [String: Any], - projection["schema"] as? String == "cmux.cloud.workspace-displays.v1", + projection["schema"] as? String == CloudVMDisplayMembership.projectionSchema, projection["machine_id"] as? String == machine.rawValue, let workspaceID = projection["workspace_id"] as? String, workspaceIDs.contains(workspaceID), + row["window_id"] as? String == CloudVMDisplayMembership.projectionWindowID(machine: machine, workspaceID: workspaceID), let memberships = projection["memberships"] as? [[String: Any]] else { continue } for membership in memberships { guard let displayID = membership["display_id"] as? String, displayID.hasPrefix("display:"), + displayID.dropFirst("display:".count).isEmpty == false, let clientID = membership["client_id"] as? String, !clientID.isEmpty, let viewID = membership["view_id"] as? String, diff --git a/Packages/macOS/CmuxSurfaceCatalogModel/Sources/CmuxSurfaceCatalogModel/SurfaceCatalogSnapshot+CloudDisplayMembership.swift b/Packages/macOS/CmuxSurfaceCatalogModel/Sources/CmuxSurfaceCatalogModel/SurfaceCatalogSnapshot+CloudDisplayMembership.swift index aff9c5616e88..82a41b3d8699 100644 --- a/Packages/macOS/CmuxSurfaceCatalogModel/Sources/CmuxSurfaceCatalogModel/SurfaceCatalogSnapshot+CloudDisplayMembership.swift +++ b/Packages/macOS/CmuxSurfaceCatalogModel/Sources/CmuxSurfaceCatalogModel/SurfaceCatalogSnapshot+CloudDisplayMembership.swift @@ -4,7 +4,8 @@ extension SurfaceCatalogSnapshot { /// Returns the machine pool plus one presentation copy for every accepted /// display/workspace membership. The copies retain the same resource id and /// are used only by workspace rows and groups; the pool remains one row per - /// discovered display resource. + /// discovered display resource. A copy contains only its frontend views, so + /// an existing daemon display tab is not emitted a second time. public func cloudWorkspaceResources(on machine: SurfaceMachineID) -> [SurfaceResource] { let base = resources(on: machine) let byID = Dictionary(base.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) @@ -31,8 +32,8 @@ extension SurfaceCatalogSnapshot { ) } guard !views.isEmpty else { continue } - placed.remoteViews = (baseResource.remoteViews ?? []) + views - placed.remoteWorkspace = placed.remoteViews?.first?.workspace + placed.remoteViews = views + placed.remoteWorkspace = views.first?.workspace result.append(placed) } return result diff --git a/Packages/macOS/CmuxSurfaceCatalogModel/Sources/CmuxSurfaceCatalogModel/SurfaceRemoteView+CloudDisplayMembership.swift b/Packages/macOS/CmuxSurfaceCatalogModel/Sources/CmuxSurfaceCatalogModel/SurfaceRemoteView+CloudDisplayMembership.swift index 6d5afa11927b..56f041eb16d4 100644 --- a/Packages/macOS/CmuxSurfaceCatalogModel/Sources/CmuxSurfaceCatalogModel/SurfaceRemoteView+CloudDisplayMembership.swift +++ b/Packages/macOS/CmuxSurfaceCatalogModel/Sources/CmuxSurfaceCatalogModel/SurfaceRemoteView+CloudDisplayMembership.swift @@ -8,4 +8,14 @@ extension SurfaceRemoteView { public var isCloudDisplayMembershipView: Bool { tabID.hasPrefix(Self.cloudDisplayMembershipViewPrefix) } + + /// The durable frontend view token carried by a synthetic display view. + /// It is intentionally separate from daemon tab ids: a display membership + /// is persisted outside the daemon layout, but still needs an exact identity + /// when two clients attach the same display to one workspace. + public var cloudDisplayMembershipViewID: String? { + guard isCloudDisplayMembershipView else { return nil } + let value = String(tabID.dropFirst(Self.cloudDisplayMembershipViewPrefix.count)) + return value.isEmpty ? nil : value + } } diff --git a/Packages/macOS/CmuxSurfaceCatalogModel/Sources/CmuxSurfaceCatalogModel/SurfaceResourcePlacement.swift b/Packages/macOS/CmuxSurfaceCatalogModel/Sources/CmuxSurfaceCatalogModel/SurfaceResourcePlacement.swift index 16119e9c0e08..363ccbc786e9 100644 --- a/Packages/macOS/CmuxSurfaceCatalogModel/Sources/CmuxSurfaceCatalogModel/SurfaceResourcePlacement.swift +++ b/Packages/macOS/CmuxSurfaceCatalogModel/Sources/CmuxSurfaceCatalogModel/SurfaceResourcePlacement.swift @@ -8,15 +8,20 @@ public struct SurfaceResourcePlacement: Hashable, Codable, Sendable { public let resource: SurfaceResourceID public let remoteWorkspaceID: String? public let remoteTabID: String? + /// Exact frontend display membership identity, when this placement came + /// from the Cloud workspace projection rather than a daemon tab. + public let cloudDisplayMembershipViewID: String? public init( resource: SurfaceResourceID, remoteView: SurfaceRemoteView? = nil, remoteWorkspaceID: String? = nil, - remoteTabID: String? = nil + remoteTabID: String? = nil, + cloudDisplayMembershipViewID: String? = nil ) { self.resource = resource self.remoteWorkspaceID = remoteView?.workspace.id ?? remoteWorkspaceID self.remoteTabID = remoteView?.isCloudDisplayMembershipView == true ? nil : (remoteView?.tabID ?? remoteTabID) + self.cloudDisplayMembershipViewID = remoteView?.cloudDisplayMembershipViewID ?? cloudDisplayMembershipViewID } } diff --git a/Sources/Cloud/CloudTreeNode.swift b/Sources/Cloud/CloudTreeNode.swift index f2d2b74003ae..5c7f5385e1ad 100644 --- a/Sources/Cloud/CloudTreeNode.swift +++ b/Sources/Cloud/CloudTreeNode.swift @@ -421,12 +421,13 @@ enum CloudTreeNodeBuilder { for resource in snapshot.resources where resource.remoteViews?.count == 1 { singleViewResources.insert(resource.id) } - for projection in snapshot.projections { openResources.insert(projection.resource) workspaceCountsByResource[projection.resource, default: [:]][projection.workspaceID, default: 0] += 1 if let remoteWorkspaceID = projection.remoteWorkspaceID { - if let remoteTabID = projection.remoteTabID, !remoteTabID.isEmpty { + if Self.hasCloudDisplayMembershipProjection(projection, snapshot: snapshot, workspaceID: remoteWorkspaceID) { + workspaceOnly[RemoteWorkspaceIdentity(resource: projection.resource, workspaceID: remoteWorkspaceID), default: []].append(projection.workspaceID) + } else if let remoteTabID = projection.remoteTabID, !remoteTabID.isEmpty { let identity = RemotePlacementIdentity( resource: projection.resource, workspaceID: remoteWorkspaceID, @@ -486,7 +487,6 @@ enum CloudTreeNodeBuilder { } } } - func isOpen(_ resource: SurfaceResourceID, remoteView: SurfaceRemoteView?) -> Bool { guard let remoteView else { return openResources.contains(resource) } let identity = RemotePlacementIdentity( diff --git a/Sources/Cloud/CloudTreeRemoteWorkspaces.swift b/Sources/Cloud/CloudTreeRemoteWorkspaces.swift index 434c663f31ce..99c8dfb2263f 100644 --- a/Sources/Cloud/CloudTreeRemoteWorkspaces.swift +++ b/Sources/Cloud/CloudTreeRemoteWorkspaces.swift @@ -35,6 +35,19 @@ enum CloudTreeRemoteWorkspaceLookup: Equatable { } extension CloudTreeNodeBuilder { + static func hasCloudDisplayMembershipProjection( + _ projection: SurfaceProjection, + snapshot: SurfaceCatalogSnapshot, + workspaceID: String + ) -> Bool { + projection.resource.kind == .display && projection.remoteTabID == nil + && snapshot.cloudDisplayMemberships.contains { + $0.machine == projection.resource.machine + && $0.displayID == projection.resource.key + && $0.workspaceID == workspaceID + } + } + /// Every cmux-tui workspace on a machine, in the daemon's order: the ones the /// machine itself reports (including empty workspaces needed by lookup and /// persistence) plus any that a resource's views name before the machine list diff --git a/Sources/Surfaces/CloudPlacementCoordinator+CloudDisplayMembership.swift b/Sources/Surfaces/CloudPlacementCoordinator+CloudDisplayMembership.swift index def818b5c404..d2b9572473d4 100644 --- a/Sources/Surfaces/CloudPlacementCoordinator+CloudDisplayMembership.swift +++ b/Sources/Surfaces/CloudPlacementCoordinator+CloudDisplayMembership.swift @@ -22,15 +22,41 @@ extension CloudPlacementCoordinator { latest.resource == projection.resource else { return false } let old = self.localDisplayMemberships[projection.panelID] let next = latest.remoteWorkspaceID + var attachedNext = false if let old, old != next { - try await provider.syncCloudDisplayMembership( - displayID: projection.resource.key, - workspaceID: old, - panelID: projection.panelID, - attached: false - ) + // Each workspace has its own projection row, so a move cannot + // be one backend transaction. Attach first to keep the old + // placement live if the new write fails; compensate on a + // failed detach so a transient move never loses membership. + if let next { + try await provider.syncCloudDisplayMembership( + displayID: projection.resource.key, + workspaceID: next, + panelID: projection.panelID, + attached: true + ) + attachedNext = true + } + do { + try await provider.syncCloudDisplayMembership( + displayID: projection.resource.key, + workspaceID: old, + panelID: projection.panelID, + attached: false + ) + } catch { + if let next { + try? await provider.syncCloudDisplayMembership( + displayID: projection.resource.key, + workspaceID: next, + panelID: projection.panelID, + attached: false + ) + } + throw error + } } - if let next { + if let next, !attachedNext { try await provider.syncCloudDisplayMembership( displayID: projection.resource.key, workspaceID: next, diff --git a/Sources/Surfaces/CloudPlacementCoordinator.swift b/Sources/Surfaces/CloudPlacementCoordinator.swift index 2f82286893e8..c6f73cb0f430 100644 --- a/Sources/Surfaces/CloudPlacementCoordinator.swift +++ b/Sources/Surfaces/CloudPlacementCoordinator.swift @@ -251,7 +251,9 @@ final class CloudPlacementCoordinator { let current = catalog.projections.filter { $0.resource == resourceID } guard !current.isEmpty else { return false } if let state = catalog.cloudStates[resourceID.machine] { - guard current.contains(where: { catalog.cloudWorkspaceProjectionCoordinator.retainsProjection($0, in: state) }) else { return false } + guard current.contains(where: { + catalog.cloudWorkspaceProjectionCoordinator.retainsProjection($0, in: state, catalog: catalog) + }) else { return false } } let targets = Set(current.compactMap { self.boundRemoteWorkspaceID(forLocalWorkspace: $0.workspaceID, on: resourceID.machine) diff --git a/Sources/Surfaces/CloudWorkspaceProjectionCoordinator.swift b/Sources/Surfaces/CloudWorkspaceProjectionCoordinator.swift index ba681a6c5aa4..7b80ceb29185 100644 --- a/Sources/Surfaces/CloudWorkspaceProjectionCoordinator.swift +++ b/Sources/Surfaces/CloudWorkspaceProjectionCoordinator.swift @@ -74,6 +74,24 @@ final class CloudWorkspaceProjectionCoordinator { } } + /// Applies the catalog's known display inventory to the graph check. A + /// frontend row can carry a syntactically valid `display:*` value that is + /// no longer an exposed display after reconnect; that row must not retain + /// or recreate a local pane. + func retainsProjection( + _ projection: SurfaceProjection, + in state: CloudVMState, + catalog: SurfaceCatalog + ) -> Bool { + guard retainsProjection(projection, in: state) else { return false } + guard projection.resource.kind == .display, projection.remoteTabID == nil else { return true } + return catalog.cloudDisplayMemberships().contains { + $0.machine == projection.resource.machine + && $0.displayID == projection.resource.key + && $0.workspaceID == projection.remoteWorkspaceID + } + } + func cancel(machine: SurfaceMachineID) { tasks.removeValue(forKey: machine)?.task.cancel() requested.remove(machine) @@ -118,8 +136,10 @@ final class CloudWorkspaceProjectionCoordinator { if !Task.isCancelled { requested.insert(machine) } return } - let view = try catalog.remoteView(for: placement.resource, tabID: placement.remoteTabID, - workspaceID: placement.remoteTabID == nil ? nil : remoteID) + let view = try catalog.remoteView( + for: placement, + fallbackWorkspaceID: remoteID + ) _ = try await catalog.project(placement.resource, into: .workspace(id: workspaceID, placement: .tab), focus: false, reuseExisting: true, reuseInWorkspace: workspaceID, remoteView: view) } diff --git a/Sources/Surfaces/CmuxTuiSurfaceProvider+CloudDisplayMembership.swift b/Sources/Surfaces/CmuxTuiSurfaceProvider+CloudDisplayMembership.swift index 609c48a7ab07..cb97442b9bd1 100644 --- a/Sources/Surfaces/CmuxTuiSurfaceProvider+CloudDisplayMembership.swift +++ b/Sources/Surfaces/CmuxTuiSurfaceProvider+CloudDisplayMembership.swift @@ -6,10 +6,6 @@ import Foundation @MainActor extension CmuxTuiSurfaceProvider: CloudDisplayMembershipSyncing { - private static let displayMembershipSchema = "cmux.cloud.workspace-displays.v1" - private static let displayMembershipFrontend = "cmux-macos-cloud-layout-v1" - private static let displayMembershipGeneration = "cmux-cloud-display-layout-v1" - func syncCloudDisplayMembership( displayID: String, workspaceID: String, @@ -27,7 +23,7 @@ extension CmuxTuiSurfaceProvider: CloudDisplayMembershipSyncing { let clientID = CloudTuiClientPaths().notificationClientID() let viewID = panelID.uuidString.lowercased() let projectionID = Self.displayMembershipProjectionID(machine: machine, workspaceID: workspaceID) - let windowID = "cloud-workspace:\(machine.rawValue):\(workspaceID)" + let windowID = CloudVMDisplayMembership.projectionWindowID(machine: machine, workspaceID: workspaceID) let idempotencyKey = "cmux-cloud-display-membership-\(UUID().uuidString.lowercased())" var lastError: Error? for _ in 0..<4 { @@ -41,7 +37,10 @@ extension CmuxTuiSurfaceProvider: CloudDisplayMembershipSyncing { guard state.workspaceIDs.contains(workspaceID) else { throw SurfaceCatalogError.destinationNotFound("workspace \(workspaceID) on \(machine.rawValue)") } - var memberships = Set(state.displayMemberships.filter { $0.workspaceID == workspaceID }) + let knownDisplayIDs = Set(catalog.resources.keys.filter { $0.machine == machine && $0.kind == .display }.map(\.key)) + var memberships = Set(state.displayMemberships.filter { + $0.workspaceID == workspaceID && knownDisplayIDs.contains($0.displayID) + }) let token = CloudVMDisplayMembership( machine: machine, workspaceID: workspaceID, @@ -52,10 +51,12 @@ extension CmuxTuiSurfaceProvider: CloudDisplayMembershipSyncing { if attached { memberships.insert(token) } else { memberships.remove(token) } let rows = (object["frontend_projections"] as? [[String: Any]]) ?? [] let row = rows.first { ($0["id"] as? String) == projectionID } - let previousMemberships = Set(state.displayMemberships.filter { $0.workspaceID == workspaceID }) + let previousMemberships = Set(state.displayMemberships.filter { + $0.workspaceID == workspaceID && knownDisplayIDs.contains($0.displayID) + }) if row != nil, memberships == previousMemberships { return } let projection: [String: Any] = [ - "schema": Self.displayMembershipSchema, + "schema": CloudVMDisplayMembership.projectionSchema, "machine_id": machine.rawValue, "workspace_id": workspaceID, "memberships": memberships.sorted { @@ -69,9 +70,9 @@ extension CmuxTuiSurfaceProvider: CloudDisplayMembershipSyncing { let expected = row.flatMap { CloudWireNumber.unsigned($0["projection_revision"]) } let request = CloudTuiRequests.putCloudDisplayMembershipProjection( projectionID: projectionID, - frontendID: Self.displayMembershipFrontend, + frontendID: CloudVMDisplayMembership.projectionFrontendID, windowID: windowID, - generation: Self.displayMembershipGeneration, + generation: CloudVMDisplayMembership.projectionGeneration, projection: projection, expectedProjectionRevision: expected, idempotencyKey: idempotencyKey @@ -89,7 +90,7 @@ extension CmuxTuiSurfaceProvider: CloudDisplayMembershipSyncing { } private static func displayMembershipProjectionID(machine: SurfaceMachineID, workspaceID: String) -> String { - let input = Data("\(machine.rawValue)/\(workspaceID)/\(displayMembershipSchema)".utf8) + let input = Data("\(machine.rawValue)/\(workspaceID)/\(CloudVMDisplayMembership.projectionSchema)".utf8) let digest = SHA256.hash(data: input) return "projection_" + digest.prefix(16).map { String(format: "%02x", $0) }.joined() } diff --git a/Sources/Surfaces/CmuxTuiSurfaceProvider+ManualMirror.swift b/Sources/Surfaces/CmuxTuiSurfaceProvider+ManualMirror.swift index 0c02575b6d5a..ceaa2093b49a 100644 --- a/Sources/Surfaces/CmuxTuiSurfaceProvider+ManualMirror.swift +++ b/Sources/Surfaces/CmuxTuiSurfaceProvider+ManualMirror.swift @@ -263,7 +263,7 @@ extension CmuxTuiSurfaceProvider { if let state = cloudState { let resourceID = SurfaceResourceID(machine: machine, kind: .terminal, key: terminalID) guard catalog.projections(of: resourceID).contains(where: { - catalog.cloudWorkspaceProjectionCoordinator.retainsProjection($0, in: state) + catalog.cloudWorkspaceProjectionCoordinator.retainsProjection($0, in: state, catalog: catalog) }) else { continue } } for session in sessionsByTerminal[terminalID] ?? [] { diff --git a/Sources/Surfaces/CmuxTuiSurfaceProviders.swift b/Sources/Surfaces/CmuxTuiSurfaceProviders.swift index 48909e7549b7..ab05a915f9e0 100644 --- a/Sources/Surfaces/CmuxTuiSurfaceProviders.swift +++ b/Sources/Surfaces/CmuxTuiSurfaceProviders.swift @@ -956,7 +956,7 @@ final class CmuxTuiSurfaceProvider: SurfaceProvider { workspaceID: created.workspaceID, panelID: created.panelID, remoteWorkspaceID: createdPlacement?.workspaceID ?? selectedView?.workspace.id, - remoteTabID: createdPlacement?.tabID ?? selectedView?.tabID + remoteTabID: createdPlacement?.tabID ?? (selectedView?.isCloudDisplayMembershipView == true ? nil : selectedView?.tabID) ) } @@ -1700,7 +1700,7 @@ final class CmuxTuiSurfaceProvider: SurfaceProvider { let terminals = catalog.authoritativeSnapshot.resources(on: machine).filter { $0.kind == .terminal } for terminal in terminals { for projection in catalog.projections(of: terminal.id) where !materializedPanels.contains(projection.panelID) { - guard cloudState.map({ catalog.cloudWorkspaceProjectionCoordinator.retainsProjection(projection, in: $0) }) != false, + guard cloudState.map({ catalog.cloudWorkspaceProjectionCoordinator.retainsProjection(projection, in: $0, catalog: catalog) }) != false, let workspace = AppDelegate.shared?.workspace(containingSurfaceID: projection.panelID), let paneID = SurfacePaneFactory.paneID(ofPanel: projection.panelID, in: projection.workspaceID) else { continue diff --git a/Sources/Surfaces/SurfaceCatalog+CloudDisplayMembership.swift b/Sources/Surfaces/SurfaceCatalog+CloudDisplayMembership.swift index 87c35452ea70..e7053f550e5f 100644 --- a/Sources/Surfaces/SurfaceCatalog+CloudDisplayMembership.swift +++ b/Sources/Surfaces/SurfaceCatalog+CloudDisplayMembership.swift @@ -6,11 +6,178 @@ extension SurfaceCatalog { /// derived at snapshot time so the tree never reads a local pane as remote /// authority. func cloudDisplayMemberships() -> [CloudVMDisplayMembership] { + let knownDisplayIDs = Set(resources.keys.filter { $0.kind == .display }) cloudStates.values .flatMap(\.displayMemberships) + .filter { + knownDisplayIDs.contains(SurfaceResourceID( + machine: $0.machine, kind: .display, key: $0.displayID + )) + } .sorted { ($0.machine.rawValue, $0.workspaceID, $0.displayID, $0.clientID, $0.viewID) < ($1.machine.rawValue, $1.workspaceID, $1.displayID, $1.clientID, $1.viewID) } } + + /// Reconstructs one accepted frontend display view from the catalog's + /// authoritative projection. This is the materialization boundary for a + /// workspace row on a client that has no corresponding daemon display tab. + func cloudDisplayMembershipView( + for resource: SurfaceResourceID, + workspaceID: String, + viewID: String? = nil + ) -> SurfaceRemoteView? { + guard resource.kind == .display else { return nil } + let matches = cloudDisplayMemberships().filter { + $0.machine == resource.machine && $0.displayID == resource.key && $0.workspaceID == workspaceID + && (viewID == nil || $0.viewID == viewID) + } + guard matches.count == 1, let membership = matches.first, + let workspace = machines[resource.machine]?.remoteWorkspaces?.first(where: { $0.id == workspaceID }) + ?? cloudStates[resource.machine]?.workspaces.first(where: { $0.id == workspaceID }).map({ + SurfaceRemoteWorkspace(id: $0.id, name: $0.name, index: $0.index, focused: $0.focused) + }) else { return nil } + return SurfaceRemoteView( + tabID: SurfaceRemoteView.cloudDisplayMembershipViewPrefix + membership.viewID, + workspace: workspace, + name: nil, + index: nil, + focused: false + ) + } + + /// Resolves the exact placement token carried by a workspace group. A + /// frontend display view has no daemon tab id, so its marker is resolved + /// against the accepted projection instead of the base display resource. + func remoteView( + for placement: SurfaceResourcePlacement, + fallbackWorkspaceID: String? = nil + ) throws -> SurfaceRemoteView? { + let workspaceID = placement.remoteWorkspaceID ?? fallbackWorkspaceID + if let membershipViewID = placement.cloudDisplayMembershipViewID { + guard let workspaceID, + let view = cloudDisplayMembershipView( + for: placement.resource, + workspaceID: workspaceID, + viewID: membershipViewID + ) else { + throw SurfaceCatalogError.unavailable( + placement.resource, + reason: "Cloud display membership is no longer present" + ) + } + return view + } + if resources[placement.resource]?.kind == .display, + resources[placement.resource]?.remoteViews == nil, + let workspaceID, + let view = cloudDisplayMembershipView(for: placement.resource, workspaceID: workspaceID) { + return view + } + return try remoteView( + for: placement.resource, + tabID: placement.remoteTabID, + workspaceID: workspaceID + ) + } + + /// Resolves a group placement without allowing a missing display tab to + /// fall back to an unrelated resource view. + func resolveRemoteView( + for member: SurfaceResourcePlacement, + fallbackWorkspaceID: String? + ) throws -> SurfaceRemoteView? { + guard resources[member.resource] != nil else { return nil } + if member.cloudDisplayMembershipViewID == nil, member.remoteTabID == nil, + let workspaceID = member.remoteWorkspaceID ?? fallbackWorkspaceID, + projections.contains(where: { + $0.resource == member.resource && $0.isLocalWorkspaceView && $0.remoteWorkspaceID == workspaceID + }) { + return nil + } + return try remoteView(for: member, fallbackWorkspaceID: fallbackWorkspaceID) + } + + /// Resolves an agent-provided remote placement against the latest accepted + /// graph. A workspace id alone is valid only when it identifies one view; + /// callers that need a particular tab must provide `tabID`. + func remoteView( + for id: SurfaceResourceID, + tabID: String? = nil, + workspaceID: String? = nil + ) throws -> SurfaceRemoteView? { + guard let resource = resources[id] else { throw SurfaceCatalogError.unknownResource(id) } + if let tabID, tabID.hasPrefix(SurfaceRemoteView.cloudDisplayMembershipViewPrefix) { + let membershipViewID = String(tabID.dropFirst(SurfaceRemoteView.cloudDisplayMembershipViewPrefix.count)) + guard resource.kind == .display, + !membershipViewID.isEmpty, + let workspaceID, + let view = cloudDisplayMembershipView( + for: id, + workspaceID: workspaceID, + viewID: membershipViewID + ) else { + throw SurfaceCatalogError.unavailable(id, reason: "Cloud display membership is no longer present") + } + return view + } + guard let views = resource.remoteViews else { + if tabID != nil || workspaceID != nil { + throw SurfaceCatalogError.unavailable(id, reason: "remote placement data is unavailable") + } + return nil + } + if let tabID { + let matches = views.filter { $0.tabID == tabID } + guard matches.count == 1, let view = matches.first else { + if matches.count > 1 { + throw SurfaceCatalogError.unavailable(id, reason: "remote tab \(tabID) has ambiguous placement") + } + throw SurfaceCatalogError.unavailable(id, reason: "remote tab \(tabID) is no longer present") + } + if let workspaceID, view.workspace.id != workspaceID { + throw SurfaceCatalogError.unavailable(id, reason: "remote tab \(tabID) is not in workspace \(workspaceID)") + } + return view + } + guard let workspaceID else { return nil } + let matches = views.filter { !$0.isCloudDisplayMembershipView && $0.workspace.id == workspaceID } + guard matches.count <= 1 else { + throw SurfaceCatalogError.ambiguousRemotePlacement(id, workspaceID: workspaceID) + } + guard let view = matches.first else { + if resource.kind == .display, views.contains(where: \.isCloudDisplayMembershipView) { return nil } + throw SurfaceCatalogError.unavailable(id, reason: "remote workspace \(workspaceID) has no view of this resource") + } + return view + } + + /// Fills a legacy projection's missing remote coordinates, or replaces a + /// stale coordinate only when the caller explicitly supplied the same tab. + /// The set remains the single owner of projection identity. + @discardableResult + func attachRemoteView(_ view: SurfaceRemoteView?, to projection: SurfaceProjection) -> SurfaceProjection { + guard let view else { return projection } + if view.isCloudDisplayMembershipView { + guard projection.remoteTabID == nil else { return projection } + projections.remove(projection) + var updated = projection + updated.remoteWorkspaceID = view.workspace.id + updated.remoteTabID = nil + projections.insert(updated) + reconcileCloudWorkspaceBinding(localWorkspaceID: updated.workspaceID) + notifyChange(for: updated.resource.machine) + return updated + } + guard projection.remoteTabID == nil || projection.remoteTabID == view.tabID else { return projection } + projections.remove(projection) + var updated = projection + updated.remoteWorkspaceID = view.workspace.id + updated.remoteTabID = view.tabID + projections.insert(updated) + reconcileCloudWorkspaceBinding(localWorkspaceID: updated.workspaceID) + notifyChange(for: updated.resource.machine) + return updated + } } diff --git a/Sources/Surfaces/SurfaceCatalog+Groups.swift b/Sources/Surfaces/SurfaceCatalog+Groups.swift index 6c534ad6ae49..ff1eee0574ac 100644 --- a/Sources/Surfaces/SurfaceCatalog+Groups.swift +++ b/Sources/Surfaces/SurfaceCatalog+Groups.swift @@ -180,34 +180,6 @@ extension SurfaceCatalog { return projected } - private func resolveRemoteView( - for member: SurfaceResourcePlacement, - fallbackWorkspaceID: String? - ) throws -> SurfaceRemoteView? { - // Unknown resources are skipped by the group projector. Once a resource exists, - // delegate placement validation to the catalog's single resolver so explicit IDs - // cannot silently fall back when remote view metadata is absent. - guard resources[member.resource] != nil else { - return nil - } - if let tabID = member.remoteTabID { - return try remoteView( - for: member.resource, - tabID: tabID, - workspaceID: member.remoteWorkspaceID ?? fallbackWorkspaceID - ) - } - let workspaceID = member.remoteWorkspaceID ?? fallbackWorkspaceID - guard let workspaceID else { return nil } - if projections.contains(where: { - $0.resource == member.resource && $0.isLocalWorkspaceView && $0.remoteWorkspaceID == workspaceID - }) { - return nil - } - if resources[member.resource]?.kind == .display, - resources[member.resource]?.remoteViews?.allSatisfy(\.isCloudDisplayMembershipView) == true { return nil } - return try remoteView(for: member.resource, workspaceID: workspaceID) - } /// How a group becomes a new local workspace: the machinery a caller injects so the /// layout can be checked without AppKit. struct NewWorkspaceHost { diff --git a/Sources/Surfaces/SurfaceCatalog.swift b/Sources/Surfaces/SurfaceCatalog.swift index 198ba73d59b7..7bee0f22ba5a 100644 --- a/Sources/Surfaces/SurfaceCatalog.swift +++ b/Sources/Surfaces/SurfaceCatalog.swift @@ -617,19 +617,34 @@ final class SurfaceCatalog { // different placement after a concurrent daemon update. let resolvedRemoteView: SurfaceRemoteView? if let remoteView { - guard let current = resource.remoteViews?.first(where: { $0.tabID == remoteView.tabID }) else { - throw SurfaceCatalogError.unavailable( - id, - reason: "remote tab \(remoteView.tabID) is no longer present" - ) - } - guard current.workspace.id == remoteView.workspace.id else { - throw SurfaceCatalogError.unavailable( - id, - reason: "remote tab \(remoteView.tabID) moved to workspace \(current.workspace.id)" - ) + if let membershipViewID = remoteView.cloudDisplayMembershipViewID { + guard resource.kind == .display, + let current = cloudDisplayMembershipView( + for: id, + workspaceID: remoteView.workspace.id, + viewID: membershipViewID + ) else { + throw SurfaceCatalogError.unavailable( + id, + reason: "Cloud display membership is no longer present" + ) + } + resolvedRemoteView = current + } else { + guard let current = resource.remoteViews?.first(where: { $0.tabID == remoteView.tabID }) else { + throw SurfaceCatalogError.unavailable( + id, + reason: "remote tab \(remoteView.tabID) is no longer present" + ) + } + guard current.workspace.id == remoteView.workspace.id else { + throw SurfaceCatalogError.unavailable( + id, + reason: "remote tab \(remoteView.tabID) moved to workspace \(current.workspace.id)" + ) + } + resolvedRemoteView = current } - resolvedRemoteView = current } else { resolvedRemoteView = nil } @@ -643,6 +658,9 @@ final class SurfaceCatalog { // An explicit placement must match an explicit projection. A legacy // projection with no tab id is not safe to reuse because it may be // showing another tab of the same terminal. + if let resolvedRemoteView, resolvedRemoteView.isCloudDisplayMembershipView { + return $0.remoteTabID == nil && $0.remoteWorkspaceID == resolvedRemoteView.workspace.id + } return resolvedRemoteView == nil || $0.remoteTabID == resolvedRemoteView?.tabID }) { try claimCompletedMaterializationIfNeeded(materializationKey, projection: existing) @@ -1141,23 +1159,6 @@ final class SurfaceCatalog { )) } - /// Fills a legacy projection's missing remote coordinates, or replaces a - /// stale coordinate only when the caller explicitly supplied the same tab. - /// The set remains the single owner of projection identity. - @discardableResult - private func attachRemoteView(_ view: SurfaceRemoteView?, to projection: SurfaceProjection) -> SurfaceProjection { - guard let view, - projection.remoteTabID == nil || projection.remoteTabID == view.tabID else { return projection } - projections.remove(projection) - var updated = projection - updated.remoteWorkspaceID = view.workspace.id - updated.remoteTabID = view.tabID - projections.insert(updated) - reconcileCloudWorkspaceBinding(localWorkspaceID: updated.workspaceID) - notifyChange(for: updated.resource.machine) - return updated - } - /// A pane can show one resource. When a remote resource is projected into a pane the /// local provider already registered as a plain local terminal (the pane is created /// first, then attached), the local placeholder yields: its projection ends and the @@ -1257,46 +1258,6 @@ final class SurfaceCatalog { notifyChange(for: source.resource.machine) } - /// Resolves an agent-provided remote placement against the latest accepted - /// graph. A workspace id alone is valid only when it identifies one view; - /// callers that need a particular tab must provide `tabID`. - func remoteView( - for id: SurfaceResourceID, - tabID: String? = nil, - workspaceID: String? = nil - ) throws -> SurfaceRemoteView? { - guard let resource = resources[id] else { throw SurfaceCatalogError.unknownResource(id) } - guard let views = resource.remoteViews else { - if tabID != nil || workspaceID != nil { - throw SurfaceCatalogError.unavailable(id, reason: "remote placement data is unavailable") - } - return nil - } - if let tabID { - let matches = views.filter { $0.tabID == tabID } - guard matches.count == 1, let view = matches.first else { - if matches.count > 1 { - throw SurfaceCatalogError.unavailable(id, reason: "remote tab \(tabID) has ambiguous placement") - } - throw SurfaceCatalogError.unavailable(id, reason: "remote tab \(tabID) is no longer present") - } - if let workspaceID, view.workspace.id != workspaceID { - throw SurfaceCatalogError.unavailable(id, reason: "remote tab \(tabID) is not in workspace \(workspaceID)") - } - return view - } - guard let workspaceID else { return nil } - let matches = views.filter { !$0.isCloudDisplayMembershipView && $0.workspace.id == workspaceID } - guard matches.count <= 1 else { - throw SurfaceCatalogError.ambiguousRemotePlacement(id, workspaceID: workspaceID) - } - guard let view = matches.first else { - if resource.kind == .display, views.contains(where: \.isCloudDisplayMembershipView) { return nil } - throw SurfaceCatalogError.unavailable(id, reason: "remote workspace \(workspaceID) has no view of this resource") - } - return view - } - /// Returns whether the panel is backed by a non-local resource projection. func hasCloudProjection(panelID: UUID, workspaceID: UUID) -> Bool { // Cache hits must observe the same authoritative inputs as cache misses. diff --git a/Sources/Surfaces/SurfaceProvider+MaterializationValidation.swift b/Sources/Surfaces/SurfaceProvider+MaterializationValidation.swift index 5c10d999cb3a..9464582a8aca 100644 --- a/Sources/Surfaces/SurfaceProvider+MaterializationValidation.swift +++ b/Sources/Surfaces/SurfaceProvider+MaterializationValidation.swift @@ -33,7 +33,8 @@ extension SurfaceProvider { } guard projection.resource == resource.id, projection.workspaceID == destination.workspaceID, - reservation == nil || remoteView == nil || projection.remoteTabID == remoteView?.tabID, + reservation == nil || remoteView == nil || remoteView?.isCloudDisplayMembershipView == true + || projection.remoteTabID == remoteView?.tabID, expectedWorkspace == nil || projection.remoteWorkspaceID == expectedWorkspace else { if let reservation, projection.panelID == reservation.panelID { // Stop an adopted transport but retain the manual pane for its diff --git a/cmuxTests/CloudDisplayMembershipProjectionTests.swift b/cmuxTests/CloudDisplayMembershipProjectionTests.swift index bbff29a0d461..27164c9b31d5 100644 --- a/cmuxTests/CloudDisplayMembershipProjectionTests.swift +++ b/cmuxTests/CloudDisplayMembershipProjectionTests.swift @@ -18,9 +18,13 @@ struct CloudDisplayMembershipProjectionTests { revision: Int, memberships: [[String: Any]] = [], display: String = "display:1", - generation: String = "membership" + generation: String = "membership", + frontendID: String = CloudVMDisplayMembership.projectionFrontendID, + projectionGeneration: String = CloudVMDisplayMembership.projectionGeneration, + windowID: String? = nil, + sessionID: String? = nil ) -> [String: Any] { - [ + var snapshot: [String: Any] = [ "cursor": ["generation": generation, "revision": String(revision)], "workspaces": [["id": workspaceID, "name": "Cloud", "index": 0, "focused": true]], "screens": [["id": "screen_cloud", "workspace_id": workspaceID]], @@ -33,9 +37,9 @@ struct CloudDisplayMembershipProjectionTests { "frontend_projections": [[ "id": "projection_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "session_id": "session_cloud", - "frontend_id": "cmux-macos-cloud-layout-v1", - "window_id": "cloud-workspace", - "generation": "cmux-cloud-display-layout-v1", + "frontend_id": frontendID, + "window_id": windowID ?? CloudVMDisplayMembership.projectionWindowID(machine: machine, workspaceID: workspaceID), + "generation": projectionGeneration, "projection_revision": "\(revision)", "projection": [ "schema": "cmux.cloud.workspace-displays.v1", @@ -46,6 +50,10 @@ struct CloudDisplayMembershipProjectionTests { ]], "display_hint": display, ] + if let sessionID { + snapshot["session"] = ["id": sessionID] + } + return snapshot } private func state( @@ -98,6 +106,32 @@ struct CloudDisplayMembershipProjectionTests { } } + @Test("Workspace open retains the exact synthetic display membership identity") + func workspaceGroupResolvesMembershipView() throws { + let accepted = try state() + let catalog = SurfaceCatalog() + catalog.replaceCloudState(accepted, resources: resources(accepted), info: info(accepted)) + let group = try catalog.remoteWorkspaceGroup(machine: machine, workspaceID: workspaceID) + let display = try #require(group.placements.first { $0.resource.kind == .display }) + #expect(display.cloudDisplayMembershipViewID == "panel-a") + let view = try #require(catalog.remoteView(for: display, fallbackWorkspaceID: workspaceID)) + #expect(view.isCloudDisplayMembershipView) + #expect(view.cloudDisplayMembershipViewID == "panel-a") + } + + @Test("Installing a newer snapshot replaces membership rows without touching the display pool") + func catalogSnapshotRefreshReconcilesMembership() throws { + let initial = try state() + let catalog = SurfaceCatalog() + catalog.replaceCloudState(initial, resources: resources(initial), info: info(initial)) + #expect(catalog.snapshot.cloudDisplayMemberships.count == 1) + let next = try state(revision: 2, memberships: []) + catalog.replaceCloudState(next, resources: resources(next), info: info(next)) + #expect(catalog.snapshot.cloudDisplayMemberships.isEmpty) + #expect(catalog.snapshot.resources(on: machine).filter { $0.kind == .display }.count == 1) + #expect(catalog.snapshot.cloudWorkspaceResources(on: machine).filter { $0.id.key == displayID }.count == 1) + } + @Test("Foreign and unknown display provenance stays out of workspace membership") func ownershipIsCheckedAtProjectionBoundary() throws { let state = try state(memberships: [ @@ -114,6 +148,19 @@ struct CloudDisplayMembershipProjectionTests { #expect(!workspaceResources.contains { $0.id.key == "display:99" }) } + @Test("Frontend provenance fences reject rows from another client implementation") + func rejectsForeignProjectionProvenance() throws { + for mutation in [ + ("frontend", document(revision: 1, memberships: [["display_id": displayID, "client_id": "mac-a", "view_id": "panel-a"]], frontendID: "other-frontend")), + ("generation", document(revision: 1, memberships: [["display_id": displayID, "client_id": "mac-a", "view_id": "panel-a"]], projectionGeneration: "old-generation")), + ("window", document(revision: 1, memberships: [["display_id": displayID, "client_id": "mac-a", "view_id": "panel-a"]], windowID: "cloud-workspace:other-vm:ws_cloud")), + ("session", document(revision: 1, memberships: [["display_id": displayID, "client_id": "mac-a", "view_id": "panel-a"]], sessionID: "other-session")), + ] { + let parsed = try #require(CmuxTuiSnapshotParser.state(fromSnapshot: mutation.1, machine: machine)) + #expect(parsed.displayMemberships.isEmpty, "\(mutation.0) provenance must not enter the accepted state") + } + } + @Test("Revision updates replace display membership and stale deltas cannot win") func revisionOrderingPreservesAcceptedProjection() throws { let initial = try state() From 3b5a7c7fee14b275df6bc78532da353e67b64ebd Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 29 Sep 2026 17:51:44 -0700 Subject: [PATCH 08/17] fix: reconcile Cloud display membership from fresh snapshots --- ...talogSnapshot+CloudDisplayMembership.swift | 10 +++--- Sources/Cloud/CloudTreeNode.swift | 2 +- .../Cloud/CloudTuiManualMirrorSession.swift | 3 +- .../CloudDisplayMembershipSyncing.swift | 5 +++ ...ntCoordinator+CloudDisplayMembership.swift | 34 ++++++++----------- .../Surfaces/CloudPlacementCoordinator.swift | 3 +- ...rfaceProvider+CloudDisplayMembership.swift | 18 ++++++++++ ...oudManualMirrorStartupRenderingTests.swift | 5 ++- 8 files changed, 51 insertions(+), 29 deletions(-) diff --git a/Packages/macOS/CmuxSurfaceCatalogModel/Sources/CmuxSurfaceCatalogModel/SurfaceCatalogSnapshot+CloudDisplayMembership.swift b/Packages/macOS/CmuxSurfaceCatalogModel/Sources/CmuxSurfaceCatalogModel/SurfaceCatalogSnapshot+CloudDisplayMembership.swift index 82a41b3d8699..0d75537017b9 100644 --- a/Packages/macOS/CmuxSurfaceCatalogModel/Sources/CmuxSurfaceCatalogModel/SurfaceCatalogSnapshot+CloudDisplayMembership.swift +++ b/Packages/macOS/CmuxSurfaceCatalogModel/Sources/CmuxSurfaceCatalogModel/SurfaceCatalogSnapshot+CloudDisplayMembership.swift @@ -14,20 +14,20 @@ extension SurfaceCatalogSnapshot { uniquingKeysWith: { first, _ in first } ) var result = base - for displayID in Set(cloudDisplayMemberships.filter { $0.machine == machine }.map(\.displayID)) { + let membershipsByDisplayID = Dictionary(grouping: cloudDisplayMemberships.filter { $0.machine == machine }, by: \.displayID) + for displayID in membershipsByDisplayID.keys.sorted() { let id = SurfaceResourceID(machine: machine, kind: .display, key: displayID) guard let baseResource = byID[id] else { continue } var placed = baseResource - let memberships = cloudDisplayMemberships - .filter { $0.machine == machine && $0.displayID == displayID } + let memberships = (membershipsByDisplayID[displayID] ?? []) .sorted { ($0.workspaceID, $0.clientID, $0.viewID) < ($1.workspaceID, $1.clientID, $1.viewID) } - let views = memberships.compactMap { membership -> SurfaceRemoteView? in + let views = memberships.enumerated().compactMap { index, membership -> SurfaceRemoteView? in guard let workspace = workspaces[membership.workspaceID] else { return nil } return SurfaceRemoteView( tabID: SurfaceRemoteView.cloudDisplayMembershipViewPrefix + membership.viewID, workspace: workspace, name: nil, - index: memberships.firstIndex(of: membership), + index: index, focused: false ) } diff --git a/Sources/Cloud/CloudTreeNode.swift b/Sources/Cloud/CloudTreeNode.swift index 5c7f5385e1ad..af9b94b7ad32 100644 --- a/Sources/Cloud/CloudTreeNode.swift +++ b/Sources/Cloud/CloudTreeNode.swift @@ -425,7 +425,7 @@ enum CloudTreeNodeBuilder { openResources.insert(projection.resource) workspaceCountsByResource[projection.resource, default: [:]][projection.workspaceID, default: 0] += 1 if let remoteWorkspaceID = projection.remoteWorkspaceID { - if Self.hasCloudDisplayMembershipProjection(projection, snapshot: snapshot, workspaceID: remoteWorkspaceID) { + if CloudTreeNodeBuilder.hasCloudDisplayMembershipProjection(projection, snapshot: snapshot, workspaceID: remoteWorkspaceID) { workspaceOnly[RemoteWorkspaceIdentity(resource: projection.resource, workspaceID: remoteWorkspaceID), default: []].append(projection.workspaceID) } else if let remoteTabID = projection.remoteTabID, !remoteTabID.isEmpty { let identity = RemotePlacementIdentity( diff --git a/Sources/Cloud/CloudTuiManualMirrorSession.swift b/Sources/Cloud/CloudTuiManualMirrorSession.swift index 72debbf64e27..67fd57a35f0e 100644 --- a/Sources/Cloud/CloudTuiManualMirrorSession.swift +++ b/Sources/Cloud/CloudTuiManualMirrorSession.swift @@ -580,6 +580,7 @@ final class CloudTuiManualMirrorSession { case let .output(surfaceID, bytes, colors): guard surfaceID == remoteSurfaceID else { return } surface?.processRemoteOutput(bytes) + if surface == nil { pendingReplay = (pendingReplay ?? Data()) + bytes } applyColors(colors) case let .resized(surfaceID, columns, rows, bytes, colors): guard surfaceID == remoteSurfaceID else { return } @@ -642,7 +643,6 @@ final class CloudTuiManualMirrorSession { self?.replayDiscarded(token: token) } } - /// The replay is theme-portable: it carries no palette or default-color /// OSC state, so the local Ghostty theme stands for every color the /// remote PTY did not author. The sidecar restores the authored ones and @@ -655,6 +655,7 @@ final class CloudTuiManualMirrorSession { appliedRemoteColors = colors guard !delta.isEmpty else { return } surface?.processRemoteOutput(delta) + if surface == nil { pendingReplay = (pendingReplay ?? Data()) + delta } } private func transitionToDisconnected(reason: CloudTerminalAttachmentInterruption) { diff --git a/Sources/Surfaces/CloudDisplayMembershipSyncing.swift b/Sources/Surfaces/CloudDisplayMembershipSyncing.swift index 901aaa22c8bc..8fa04d434988 100644 --- a/Sources/Surfaces/CloudDisplayMembershipSyncing.swift +++ b/Sources/Surfaces/CloudDisplayMembershipSyncing.swift @@ -3,6 +3,11 @@ import Foundation /// Persists a local VNC view in the VM's revisioned Cloud workspace projection. @MainActor protocol CloudDisplayMembershipSyncing: AnyObject { + func cloudDisplayMembershipWorkspace( + displayID: String, + panelID: UUID + ) async throws -> String? + func syncCloudDisplayMembership( displayID: String, workspaceID: String, diff --git a/Sources/Surfaces/CloudPlacementCoordinator+CloudDisplayMembership.swift b/Sources/Surfaces/CloudPlacementCoordinator+CloudDisplayMembership.swift index d2b9572473d4..ad591ddad53a 100644 --- a/Sources/Surfaces/CloudPlacementCoordinator+CloudDisplayMembership.swift +++ b/Sources/Surfaces/CloudPlacementCoordinator+CloudDisplayMembership.swift @@ -8,19 +8,18 @@ extension CloudPlacementCoordinator { /// publish a half-moved display view. func syncCloudDisplayMembership( projection: SurfaceProjection, - current: SurfaceProjection, catalog: SurfaceCatalog ) { guard projection.resource.kind == .display, let provider = catalog.provider(for: projection.resource.machine) as? any CloudDisplayMembershipSyncing else { return } - let previous = localDisplayMemberships[projection.panelID] - let target = current.remoteWorkspaceID - guard previous != target || (previous == nil && target != nil) else { return } enqueue(projection, catalog: catalog, presentFailure: false) { guard let latest = catalog.projection(forPanel: projection.panelID), latest.resource == projection.resource else { return false } - let old = self.localDisplayMemberships[projection.panelID] + let old = try await provider.cloudDisplayMembershipWorkspace( + displayID: projection.resource.key, + panelID: projection.panelID + ) let next = latest.remoteWorkspaceID var attachedNext = false if let old, old != next { @@ -63,11 +62,8 @@ extension CloudPlacementCoordinator { panelID: projection.panelID, attached: true ) - self.localDisplayMemberships[projection.panelID] = next - } else { - self.localDisplayMemberships[projection.panelID] = nil } - return true + return old != next || attachedNext } } @@ -80,23 +76,23 @@ extension CloudPlacementCoordinator { ) { guard reason == .paneClosed, projection.resource.kind == .display, - let workspaceID = localDisplayMemberships[projection.panelID] - ?? projection.remoteWorkspaceID, - let provider = catalog.provider(for: projection.resource.machine) as? any CloudDisplayMembershipSyncing, - !catalog.projections.contains(where: { - $0.panelID != projection.panelID - && $0.resource == projection.resource - && $0.remoteWorkspaceID == workspaceID - && $0.isLocalWorkspaceView - }) else { return } + let provider = catalog.provider(for: projection.resource.machine) as? any CloudDisplayMembershipSyncing else { return } enqueue(projection, catalog: catalog, presentFailure: false) { + guard let workspaceID = try await provider.cloudDisplayMembershipWorkspace( + displayID: projection.resource.key, + panelID: projection.panelID + ), !catalog.projections.contains(where: { + $0.panelID != projection.panelID + && $0.resource == projection.resource + && $0.remoteWorkspaceID == workspaceID + && $0.isLocalWorkspaceView + }) else { return false } try await provider.syncCloudDisplayMembership( displayID: projection.resource.key, workspaceID: workspaceID, panelID: projection.panelID, attached: false ) - self.localDisplayMemberships[projection.panelID] = nil return true } } diff --git a/Sources/Surfaces/CloudPlacementCoordinator.swift b/Sources/Surfaces/CloudPlacementCoordinator.swift index c6f73cb0f430..a3818e9d1d58 100644 --- a/Sources/Surfaces/CloudPlacementCoordinator.swift +++ b/Sources/Surfaces/CloudPlacementCoordinator.swift @@ -25,7 +25,6 @@ final class CloudPlacementCoordinator { private var movedTabs: [SurfaceMachineID: [String: String]] = [:] private var closedTabs: [SurfaceMachineID: [String: String]] = [:] private var confirmationCursors: [SurfaceMachineID: [String: CloudVMCursor]] = [:] - var localDisplayMemberships: [UUID: String] = [:] private(set) var failures: [SurfaceResourceID: String] = [:] init( @@ -122,7 +121,7 @@ final class CloudPlacementCoordinator { // when the pane moves into an unbound viewer workspace. let current = projectionInCurrentWorkspace(projection) catalog.setRemotePlacement(for: projection, workspaceID: current.remoteWorkspaceID, tabID: nil) - syncCloudDisplayMembership(projection: projection, current: current, catalog: catalog) + syncCloudDisplayMembership(projection: projection, catalog: catalog) return } guard let target = boundRemoteWorkspaceID(forLocalWorkspace: projection.workspaceID, on: projection.resource.machine), diff --git a/Sources/Surfaces/CmuxTuiSurfaceProvider+CloudDisplayMembership.swift b/Sources/Surfaces/CmuxTuiSurfaceProvider+CloudDisplayMembership.swift index cb97442b9bd1..3568e25de6b9 100644 --- a/Sources/Surfaces/CmuxTuiSurfaceProvider+CloudDisplayMembership.swift +++ b/Sources/Surfaces/CmuxTuiSurfaceProvider+CloudDisplayMembership.swift @@ -6,6 +6,24 @@ import Foundation @MainActor extension CmuxTuiSurfaceProvider: CloudDisplayMembershipSyncing { + func cloudDisplayMembershipWorkspace(displayID: String, panelID: UUID) async throws -> String? { + guard let connected = try? await links.connected(machineID: machineID), + let link = await links.link(machineID: machineID) else { + throw ProviderError.machineAsleep(machineID) + } + let data = try await link.run(arguments: CloudTuiRequests.snapshotArguments(socketPath: connected.socketPath)) + guard let object = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let state = CmuxTuiSnapshotParser.state(fromSnapshot: object, machine: machine), + state.document.containsCollection("frontend_projections") else { + throw SurfaceCatalogError.unsupported(CloudGuestDisplaySnapshot.unavailableMessage) + } + let clientID = CloudTuiClientPaths().notificationClientID() + let viewID = panelID.uuidString.lowercased() + return state.displayMemberships.first { + $0.displayID == displayID && $0.clientID == clientID && $0.viewID == viewID + }?.workspaceID + } + func syncCloudDisplayMembership( displayID: String, workspaceID: String, diff --git a/cmuxTests/CloudManualMirrorStartupRenderingTests.swift b/cmuxTests/CloudManualMirrorStartupRenderingTests.swift index f017a48b0741..f7b012884ac2 100644 --- a/cmuxTests/CloudManualMirrorStartupRenderingTests.swift +++ b/cmuxTests/CloudManualMirrorStartupRenderingTests.swift @@ -18,7 +18,10 @@ struct CloudManualMirrorStartupRenderingTests { defer { fixture.close() } try await fixture.attachBeforeSurfaceBinding(replay: Data("prompt$ ".utf8)) + try await fixture.deliver( + Data("ready".utf8), event: "output", marker: "ready", waitForSurface: false + ) fixture.bindSurface() - try await fixture.waitForText("prompt$ ") + try await fixture.waitForText("prompt$ ready") } } From 1c2792081c23e9451ff8378c7c9e518d7c0a8918 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 29 Sep 2026 18:03:03 -0700 Subject: [PATCH 09/17] fix: keep catalog projection mutation within owner file --- ...urfaceCatalog+CloudDisplayMembership.swift | 29 +------------------ Sources/Surfaces/SurfaceCatalog.swift | 28 ++++++++++++++++++ 2 files changed, 29 insertions(+), 28 deletions(-) diff --git a/Sources/Surfaces/SurfaceCatalog+CloudDisplayMembership.swift b/Sources/Surfaces/SurfaceCatalog+CloudDisplayMembership.swift index e7053f550e5f..4a75ae139429 100644 --- a/Sources/Surfaces/SurfaceCatalog+CloudDisplayMembership.swift +++ b/Sources/Surfaces/SurfaceCatalog+CloudDisplayMembership.swift @@ -7,7 +7,7 @@ extension SurfaceCatalog { /// authority. func cloudDisplayMemberships() -> [CloudVMDisplayMembership] { let knownDisplayIDs = Set(resources.keys.filter { $0.kind == .display }) - cloudStates.values + return cloudStates.values .flatMap(\.displayMemberships) .filter { knownDisplayIDs.contains(SurfaceResourceID( @@ -153,31 +153,4 @@ extension SurfaceCatalog { return view } - /// Fills a legacy projection's missing remote coordinates, or replaces a - /// stale coordinate only when the caller explicitly supplied the same tab. - /// The set remains the single owner of projection identity. - @discardableResult - func attachRemoteView(_ view: SurfaceRemoteView?, to projection: SurfaceProjection) -> SurfaceProjection { - guard let view else { return projection } - if view.isCloudDisplayMembershipView { - guard projection.remoteTabID == nil else { return projection } - projections.remove(projection) - var updated = projection - updated.remoteWorkspaceID = view.workspace.id - updated.remoteTabID = nil - projections.insert(updated) - reconcileCloudWorkspaceBinding(localWorkspaceID: updated.workspaceID) - notifyChange(for: updated.resource.machine) - return updated - } - guard projection.remoteTabID == nil || projection.remoteTabID == view.tabID else { return projection } - projections.remove(projection) - var updated = projection - updated.remoteWorkspaceID = view.workspace.id - updated.remoteTabID = view.tabID - projections.insert(updated) - reconcileCloudWorkspaceBinding(localWorkspaceID: updated.workspaceID) - notifyChange(for: updated.resource.machine) - return updated - } } diff --git a/Sources/Surfaces/SurfaceCatalog.swift b/Sources/Surfaces/SurfaceCatalog.swift index 7bee0f22ba5a..b2c6efcb2baf 100644 --- a/Sources/Surfaces/SurfaceCatalog.swift +++ b/Sources/Surfaces/SurfaceCatalog.swift @@ -1258,6 +1258,34 @@ final class SurfaceCatalog { notifyChange(for: source.resource.machine) } + /// Fills a legacy projection's missing remote coordinates, or replaces a + /// stale coordinate only when the caller explicitly supplied the same tab. + /// The set remains the single owner of projection identity. + @discardableResult + private func attachRemoteView(_ view: SurfaceRemoteView?, to projection: SurfaceProjection) -> SurfaceProjection { + guard let view else { return projection } + if view.isCloudDisplayMembershipView { + guard projection.remoteTabID == nil else { return projection } + projections.remove(projection) + var updated = projection + updated.remoteWorkspaceID = view.workspace.id + updated.remoteTabID = nil + projections.insert(updated) + reconcileCloudWorkspaceBinding(localWorkspaceID: updated.workspaceID) + notifyChange(for: updated.resource.machine) + return updated + } + guard projection.remoteTabID == nil || projection.remoteTabID == view.tabID else { return projection } + projections.remove(projection) + var updated = projection + updated.remoteWorkspaceID = view.workspace.id + updated.remoteTabID = view.tabID + projections.insert(updated) + reconcileCloudWorkspaceBinding(localWorkspaceID: updated.workspaceID) + notifyChange(for: updated.resource.machine) + return updated + } + /// Returns whether the panel is backed by a non-local resource projection. func hasCloudProjection(panelID: UUID, workspaceID: UUID) -> Bool { // Cache hits must observe the same authoritative inputs as cache misses. From 85392bf765da0cd8440ac5c6cd98e0ae2d99c7a1 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 29 Sep 2026 18:21:45 -0700 Subject: [PATCH 10/17] test: wire Cloud display projection fixtures --- cmuxTests/CloudDisplayMembershipProjectionTests.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cmuxTests/CloudDisplayMembershipProjectionTests.swift b/cmuxTests/CloudDisplayMembershipProjectionTests.swift index 27164c9b31d5..9e1a661f64d5 100644 --- a/cmuxTests/CloudDisplayMembershipProjectionTests.swift +++ b/cmuxTests/CloudDisplayMembershipProjectionTests.swift @@ -1,3 +1,4 @@ +import CmuxCloud import CmuxSurfaceCatalogModel import Foundation import Testing @@ -114,7 +115,7 @@ struct CloudDisplayMembershipProjectionTests { let group = try catalog.remoteWorkspaceGroup(machine: machine, workspaceID: workspaceID) let display = try #require(group.placements.first { $0.resource.kind == .display }) #expect(display.cloudDisplayMembershipViewID == "panel-a") - let view = try #require(catalog.remoteView(for: display, fallbackWorkspaceID: workspaceID)) + let view = try #require(try catalog.remoteView(for: display, fallbackWorkspaceID: workspaceID)) #expect(view.isCloudDisplayMembershipView) #expect(view.cloudDisplayMembershipViewID == "panel-a") } From e7ffb8f91db701ce739df7eec342a9ebade957e0 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 29 Sep 2026 18:49:57 -0700 Subject: [PATCH 11/17] fix: deduplicate Cloud display presentation resources --- .../Surfaces/SurfaceProjection+WorkspaceMembership.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Surfaces/SurfaceProjection+WorkspaceMembership.swift b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Surfaces/SurfaceProjection+WorkspaceMembership.swift index 9ab0e1e29ed3..43e213d3d4af 100644 --- a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Surfaces/SurfaceProjection+WorkspaceMembership.swift +++ b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Surfaces/SurfaceProjection+WorkspaceMembership.swift @@ -10,7 +10,10 @@ extension SurfaceProjection { /// their bound workspace; availability in the machine resource pool never does. /// A daemon placement of the same resource already supplies that workspace row. public static func localWorkspaceMembers(resources: [SurfaceResource], projections: [SurfaceProjection]) -> [(resource: SurfaceResource, workspaceID: String)] { - let previews = Dictionary(uniqueKeysWithValues: resources.filter { $0.kind == .display || $0.id.isForwardedPort }.map { ($0.id, $0) }) + let previews = Dictionary( + resources.filter { $0.kind == .display || $0.id.isForwardedPort }.map { ($0.id, $0) }, + uniquingKeysWith: { first, _ in first } + ) var seen: [SurfaceResourceID: Set] = [:] return projections.compactMap { projection in guard projection.isLocalWorkspaceView, From d9059bc4e60ff7db91c453f979ec3772bb5ee707 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 29 Sep 2026 22:47:14 -0700 Subject: [PATCH 12/17] fix: avoid duplicate Cloud display placement sync --- Sources/Surfaces/SurfaceCatalog.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Sources/Surfaces/SurfaceCatalog.swift b/Sources/Surfaces/SurfaceCatalog.swift index b2c6efcb2baf..67735a4e976a 100644 --- a/Sources/Surfaces/SurfaceCatalog.swift +++ b/Sources/Surfaces/SurfaceCatalog.swift @@ -719,7 +719,6 @@ final class SurfaceCatalog { throw CancellationError() } record(projection) - cloudPlacementCoordinator.projectionDidMove(projection, catalog: self) return (projection, false) } From 8926fa672375efc25c93251763ea3f888e3f0706 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 29 Sep 2026 23:15:43 -0700 Subject: [PATCH 13/17] fix: preserve every Cloud display membership token --- ...loudPlacementCoordinator+CloudDisplayMembership.swift | 7 +------ .../CmuxTuiSurfaceProvider+CloudDisplayMembership.swift | 9 ++------- 2 files changed, 3 insertions(+), 13 deletions(-) diff --git a/Sources/Surfaces/CloudPlacementCoordinator+CloudDisplayMembership.swift b/Sources/Surfaces/CloudPlacementCoordinator+CloudDisplayMembership.swift index ad591ddad53a..da6d196b1e78 100644 --- a/Sources/Surfaces/CloudPlacementCoordinator+CloudDisplayMembership.swift +++ b/Sources/Surfaces/CloudPlacementCoordinator+CloudDisplayMembership.swift @@ -81,12 +81,7 @@ extension CloudPlacementCoordinator { guard let workspaceID = try await provider.cloudDisplayMembershipWorkspace( displayID: projection.resource.key, panelID: projection.panelID - ), !catalog.projections.contains(where: { - $0.panelID != projection.panelID - && $0.resource == projection.resource - && $0.remoteWorkspaceID == workspaceID - && $0.isLocalWorkspaceView - }) else { return false } + ) else { return false } try await provider.syncCloudDisplayMembership( displayID: projection.resource.key, workspaceID: workspaceID, diff --git a/Sources/Surfaces/CmuxTuiSurfaceProvider+CloudDisplayMembership.swift b/Sources/Surfaces/CmuxTuiSurfaceProvider+CloudDisplayMembership.swift index 3568e25de6b9..e7a405a9a86c 100644 --- a/Sources/Surfaces/CmuxTuiSurfaceProvider+CloudDisplayMembership.swift +++ b/Sources/Surfaces/CmuxTuiSurfaceProvider+CloudDisplayMembership.swift @@ -55,10 +55,8 @@ extension CmuxTuiSurfaceProvider: CloudDisplayMembershipSyncing { guard state.workspaceIDs.contains(workspaceID) else { throw SurfaceCatalogError.destinationNotFound("workspace \(workspaceID) on \(machine.rawValue)") } - let knownDisplayIDs = Set(catalog.resources.keys.filter { $0.machine == machine && $0.kind == .display }.map(\.key)) - var memberships = Set(state.displayMemberships.filter { - $0.workspaceID == workspaceID && knownDisplayIDs.contains($0.displayID) - }) + let previousMemberships = Set(state.displayMemberships.filter { $0.workspaceID == workspaceID }) + var memberships = previousMemberships let token = CloudVMDisplayMembership( machine: machine, workspaceID: workspaceID, @@ -69,9 +67,6 @@ extension CmuxTuiSurfaceProvider: CloudDisplayMembershipSyncing { if attached { memberships.insert(token) } else { memberships.remove(token) } let rows = (object["frontend_projections"] as? [[String: Any]]) ?? [] let row = rows.first { ($0["id"] as? String) == projectionID } - let previousMemberships = Set(state.displayMemberships.filter { - $0.workspaceID == workspaceID && knownDisplayIDs.contains($0.displayID) - }) if row != nil, memberships == previousMemberships { return } let projection: [String: Any] = [ "schema": CloudVMDisplayMembership.projectionSchema, From 819b1323311b4387d3842f60eec7b53e3113d8a1 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 29 Sep 2026 23:51:53 -0700 Subject: [PATCH 14/17] fix: avoid replaying Cloud placement after materialization --- Sources/Surfaces/SurfaceCatalog.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Sources/Surfaces/SurfaceCatalog.swift b/Sources/Surfaces/SurfaceCatalog.swift index 67735a4e976a..e2192775e884 100644 --- a/Sources/Surfaces/SurfaceCatalog.swift +++ b/Sources/Surfaces/SurfaceCatalog.swift @@ -895,7 +895,6 @@ final class SurfaceCatalog { throw SurfaceCatalogError.unavailable(id, reason: "projection closed while opening") } acknowledgeMaterialization(key, waiterID: waiterID) - if !result.reused { cloudPlacementCoordinator.projectionDidMove(projection, catalog: self) } if result.reused, focus { focusProjection?(projection) } return (projection, result.reused) } From b5f4226e2065710308c88ec50c8cafbef2b7af00 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 30 Sep 2026 01:25:26 -0700 Subject: [PATCH 15/17] fix: restore shared sizing request tracking access --- Sources/Cloud/CloudTuiManualMirrorSession.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Sources/Cloud/CloudTuiManualMirrorSession.swift b/Sources/Cloud/CloudTuiManualMirrorSession.swift index a3cf48e707cd..6a3cfbd8b743 100644 --- a/Sources/Cloud/CloudTuiManualMirrorSession.swift +++ b/Sources/Cloud/CloudTuiManualMirrorSession.swift @@ -51,7 +51,9 @@ final class CloudTuiManualMirrorSession { private var runtimeSampleTask: Task? private(set) var socketPath: String? private var nextRequestID: UInt64 = 1 - private var pendingRequests: [UInt64: CloudTuiManualMirrorRequestKind] = [:] + // Shared-sizing relay commands are recorded by the feature extension in + // CloudTuiManualMirrorSession+SharedSizing.swift before they are sent. + var pendingRequests: [UInt64: CloudTuiManualMirrorRequestKind] = [:] var pendingReplay: Data? /// Capabilities belong to the current control connection. They must not /// survive a daemon restart because an older generation may not implement From fe408bbeb20f0474e13a8984ecd2e21000e4a633 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 30 Sep 2026 02:15:58 -0700 Subject: [PATCH 16/17] style: keep Cloud mirror session within file budget --- Sources/Cloud/CloudTuiManualMirrorSession.swift | 2 -- 1 file changed, 2 deletions(-) diff --git a/Sources/Cloud/CloudTuiManualMirrorSession.swift b/Sources/Cloud/CloudTuiManualMirrorSession.swift index f526d1e65408..038fc9e6ff13 100644 --- a/Sources/Cloud/CloudTuiManualMirrorSession.swift +++ b/Sources/Cloud/CloudTuiManualMirrorSession.swift @@ -51,8 +51,6 @@ final class CloudTuiManualMirrorSession { private var runtimeSampleTask: Task? private(set) var socketPath: String? private var nextRequestID: UInt64 = 1 - // Shared-sizing relay commands are recorded by the feature extension in - // CloudTuiManualMirrorSession+SharedSizing.swift before they are sent. var pendingRequests: [UInt64: CloudTuiManualMirrorRequestKind] = [:] var pendingReplay: Data? /// Capabilities belong to the current control connection. They must not From 16973ddb17166ded436acb7b17184888d9af116f Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 30 Sep 2026 02:32:05 -0700 Subject: [PATCH 17/17] fix: model Cloud display memberships in desktop fixture --- cmuxTests/CloudDesktopOpenTestProvider.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/cmuxTests/CloudDesktopOpenTestProvider.swift b/cmuxTests/CloudDesktopOpenTestProvider.swift index 28913b383e45..02acbdc9acc7 100644 --- a/cmuxTests/CloudDesktopOpenTestProvider.swift +++ b/cmuxTests/CloudDesktopOpenTestProvider.swift @@ -37,8 +37,13 @@ final class CloudDesktopOpenTestProvider: SurfaceProvider { destinations.append(destination) try await beforeMaterialization?() let pane = try SurfacePaneFactory.makeBrowserPane(url: nil, at: destination, focus: focus) + // A Cloud display membership is a frontend workspace view, not a + // daemon tab. Mirror the real provider's materialization contract so + // the projection reconciler does not treat this view as an untracked + // remote placement and recreate it on every refresh. + let remoteTabID = remoteView?.isCloudDisplayMembershipView == true ? nil : remoteView?.tabID return SurfaceProjection(resource: resource.id, workspaceID: pane.workspaceID, panelID: pane.panelID, - remoteWorkspaceID: remoteView?.workspace.id, remoteTabID: remoteView?.tabID) + remoteWorkspaceID: remoteView?.workspace.id, remoteTabID: remoteTabID) } func createTerminal(command: [String]?, cwd: String?, name: String?,