From 7ecc2ccfb0289400f42c68d2a07b33bdeb45fe8f Mon Sep 17 00:00:00 2001 From: Austin Wang <38676809+austinywang@users.noreply.github.com> Date: Tue, 20 Oct 2026 18:48:05 -0700 Subject: [PATCH 1/9] test: unrelated defaults writes must not rebuild window chrome Sparkle writes SULastCheckTime after each background check. The hang reports for issue 22703 show that write entering SwiftUI's UserDefaultObserver and driving a graph update. This test hosts the main window, writes keys no view reads, and counts ContentView, WorkspaceContentView and VerticalTabsSidebar body evaluations. Co-Authored-By: Claude Opus 5.5 --- .../WorkspaceContentViewVisibilityTests.swift | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/cmuxTests/WorkspaceContentViewVisibilityTests.swift b/cmuxTests/WorkspaceContentViewVisibilityTests.swift index be7959d72c34..946ec7d25c08 100644 --- a/cmuxTests/WorkspaceContentViewVisibilityTests.swift +++ b/cmuxTests/WorkspaceContentViewVisibilityTests.swift @@ -499,6 +499,97 @@ final class WorkspaceContentViewVisibilityTests { ) } + /// Sparkle records its last-check date in the app's defaults after every + /// background check, and other subsystems write keys no view reads. Each + /// write posts `UserDefaults.didChangeNotification`. A write to a key that + /// no view displays must not rebuild the window's chrome or pane content: + /// with many panes each rebuild runs every pane's `updateNSView` and portal + /// reconciliation, which is the 0.65.x idle hang (issue 22703). + @Test(.timeLimit(.minutes(1))) + @MainActor + func testUnrelatedDefaultsWriteDoesNotReevaluateChromeBodies() async throws { + _ = NSApplication.shared + + let suiteName = "WorkspaceContentViewDefaultsWriteTests.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defaults.removePersistentDomain(forName: suiteName) + defer { + defaults.removePersistentDomain(forName: suiteName) + } + + // Loading-card workspaces, as in the tests above, so no shell or git + // probe publishes during the measured window. + let tabManager = TabManager(autoWelcomeIfNeeded: false, createInitialWorkspace: false) + for index in 0..<3 { + tabManager.addWorkspace( + initialSurface: .cloudVMLoading, + select: index == 0, + autoWelcomeIfNeeded: false + ) + } + let counts = MinimalModeBodyProbeCounts() + let root = ContentView(updateViewModel: UpdateStateModel(), windowId: UUID()) + .environmentObject(tabManager) + .environmentObject(TerminalNotificationStore.shared) + .environmentObject(SidebarState()) + .environmentObject(SidebarSelectionState()) + .environmentObject(FileExplorerState(defaults: defaults)) + .environmentObject(CmuxConfigStore()) + .environment( + \.minimalModeInvalidationProbe, + MinimalModeInvalidationProbe( + shouldTraceBodyChanges: { counts.isMeasuringInvalidations }, + contentViewBody: { counts.contentViewBody += 1 }, + workspaceContentBody: { counts.workspaceContentBody += 1 }, + verticalTabsSidebarBody: { counts.verticalTabsSidebarBody += 1 } + ) + ) + .defaultAppStorage(defaults) + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 900, height: 640), + styleMask: [.titled, .closable, .resizable], + backing: .buffered, + defer: false + ) + window.isReleasedWhenClosed = false + window.contentView = MainWindowHostingView(rootView: root) + defer { + window.contentView = nil + tabManager.finalizeAllWorkspacesForWindowClose() + window.close() + } + + await Self.drainMainRunLoop(for: window) + #expect(counts.contentViewBody > 0) + #expect(counts.workspaceContentBody > 0) + let settled = await Self.waitForQuietChromeBodies(counts: counts, window: window) + try #require(settled, "The window must stop re-evaluating chrome bodies while idle") + + counts.reset() + counts.isMeasuringInvalidations = true + defer { counts.isMeasuringInvalidations = false } + // The key Sparkle's SPUUpdater writes after each scheduled check, plus + // a key no cmux code reads at all. + defaults.set(Date(), forKey: "SULastCheckTime") + defaults.set(UUID().uuidString, forKey: "cmux.tests.unrelatedDefaultsWrite") + await Self.drainMainRunLoop(for: window) + counts.isMeasuringInvalidations = false + + #expect( + counts.contentViewBody == 0, + "An unrelated defaults write must not re-evaluate ContentView (\(counts.contentViewBody) evaluations)." + ) + #expect( + counts.workspaceContentBody == 0, + "An unrelated defaults write must not rebuild pane content (\(counts.workspaceContentBody) evaluations)." + ) + #expect( + counts.verticalTabsSidebarBody == 0, + "An unrelated defaults write must not rebuild the sidebar (\(counts.verticalTabsSidebarBody) evaluations)." + ) + } + @Test @MainActor func testUnreadApplyPublishesOneAtomicSnapshot() { From 1b1db50b37f35afcadf5ca3598f38e606340d0c7 Mon Sep 17 00:00:00 2001 From: Austin Wang <38676809+austinywang@users.noreply.github.com> Date: Tue, 20 Oct 2026 19:07:20 -0700 Subject: [PATCH 2/9] test: a shared agent-index reload must not rebuild pane content SharedLiveAgentIndex posts .sharedLiveAgentIndexDidChange without userInfo after each full reload, at most every 5 s while agent hooks write the hook store. Each Workspace answers with objectWillChange, which re-runs WorkspaceContentView and every pane's updateNSView and portal reconciliation. Replaces the unrelated-defaults-write probe: SwiftUI does not invalidate @AppStorage views for unrelated keys. Co-Authored-By: Claude Opus 5.5 --- .../WorkspaceContentViewVisibilityTests.swift | 40 +++++++++++-------- 1 file changed, 23 insertions(+), 17 deletions(-) diff --git a/cmuxTests/WorkspaceContentViewVisibilityTests.swift b/cmuxTests/WorkspaceContentViewVisibilityTests.swift index 946ec7d25c08..46de1d5204fc 100644 --- a/cmuxTests/WorkspaceContentViewVisibilityTests.swift +++ b/cmuxTests/WorkspaceContentViewVisibilityTests.swift @@ -499,18 +499,18 @@ final class WorkspaceContentViewVisibilityTests { ) } - /// Sparkle records its last-check date in the app's defaults after every - /// background check, and other subsystems write keys no view reads. Each - /// write posts `UserDefaults.didChangeNotification`. A write to a key that - /// no view displays must not rebuild the window's chrome or pane content: - /// with many panes each rebuild runs every pane's `updateNSView` and portal - /// reconciliation, which is the 0.65.x idle hang (issue 22703). + /// `SharedLiveAgentIndex` reloads at most every 5 s while agent hooks write + /// the hook store, and each full reload posts `.sharedLiveAgentIndexDidChange` + /// without `userInfo`. Every workspace used to answer with + /// `objectWillChange`, which re-ran `WorkspaceContentView` and every pane's + /// `updateNSView` and portal reconciliation while the user was idle: the + /// burst that precedes the 0.65.x idle hangs (issue 22703). @Test(.timeLimit(.minutes(1))) @MainActor - func testUnrelatedDefaultsWriteDoesNotReevaluateChromeBodies() async throws { + func testSharedAgentIndexReloadDoesNotReevaluatePaneContent() async throws { _ = NSApplication.shared - let suiteName = "WorkspaceContentViewDefaultsWriteTests.\(UUID().uuidString)" + let suiteName = "WorkspaceContentViewAgentIndexReloadTests.\(UUID().uuidString)" let defaults = try #require(UserDefaults(suiteName: suiteName)) defaults.removePersistentDomain(forName: suiteName) defer { @@ -569,24 +569,30 @@ final class WorkspaceContentViewVisibilityTests { counts.reset() counts.isMeasuringInvalidations = true defer { counts.isMeasuringInvalidations = false } - // The key Sparkle's SPUUpdater writes after each scheduled check, plus - // a key no cmux code reads at all. - defaults.set(Date(), forKey: "SULastCheckTime") - defaults.set(UUID().uuidString, forKey: "cmux.tests.unrelatedDefaultsWrite") + var workspacePublishes = 0 + let publishes = tabManager.tabs.map { workspace in + workspace.objectWillChange.sink { _ in workspacePublishes += 1 } + } + defer { publishes.forEach { $0.cancel() } } + // The post a full reload makes (SharedLiveAgentIndex.startReload). + NotificationCenter.default.post( + name: .sharedLiveAgentIndexDidChange, + object: SharedLiveAgentIndex.shared + ) await Self.drainMainRunLoop(for: window) counts.isMeasuringInvalidations = false #expect( - counts.contentViewBody == 0, - "An unrelated defaults write must not re-evaluate ContentView (\(counts.contentViewBody) evaluations)." + workspacePublishes == 0, + "An agent-index reload must not publish any workspace (\(workspacePublishes) publishes)." ) #expect( counts.workspaceContentBody == 0, - "An unrelated defaults write must not rebuild pane content (\(counts.workspaceContentBody) evaluations)." + "An agent-index reload must not rebuild pane content (\(counts.workspaceContentBody) evaluations)." ) #expect( - counts.verticalTabsSidebarBody == 0, - "An unrelated defaults write must not rebuild the sidebar (\(counts.verticalTabsSidebarBody) evaluations)." + counts.contentViewBody == 0, + "An agent-index reload must not re-evaluate ContentView (\(counts.contentViewBody) evaluations)." ) } From 6b88664f9fee3d7c5a9a8db50a5f64c1addd51af Mon Sep 17 00:00:00 2001 From: Austin Wang <38676809+austinywang@users.noreply.github.com> Date: Tue, 20 Oct 2026 19:34:32 -0700 Subject: [PATCH 3/9] fix: shared agent-index reloads no longer re-render every pane SharedLiveAgentIndex reloads at most every 5 s while agent hooks write the hook store and posts .sharedLiveAgentIndexDidChange without userInfo. Every Workspace answered with objectWillChange, so WorkspaceContentView re-ran every pane's updateNSView and portal reconciliation while the user was idle. Sentry breadcrumbs show that burst before 82 of 146 multi-pane no-app-frame hangs (issue 22703). The publish existed so Bonsplit's tab bar re-read Fork Conversation availability. The tab context menu now reads it through its provider when it opens, and restored-agent reconciliation mutates an @Observable coordinator, so no view needs the workspace publish. The retirement test counted that publish as its probe; it now counts handled notifications through a DEBUG seam. Co-Authored-By: Claude Opus 5.5 --- Sources/Workspace.swift | 16 +++++++++++++--- cmuxTests/ClosedMainWindowRoutingTests.swift | 4 ++-- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 610082f193bb..a4141b18ad20 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -4624,8 +4624,12 @@ final class Workspace: Identifiable, ObservableObject, FilePreviewTabMetadataHos tmuxLayoutSnapshot = bonsplitController.layoutSnapshot() scheduleExtensionSidebarProjectRootRefresh(for: currentDirectory) - // Forward shared agent-index refreshes so the bonsplit tab-bar re-evaluates - // Fork Conversation availability when a background refresh lands. + // Reconcile restored agents that a shared agent-index refresh reports as + // finished. The Fork Conversation menu reads availability from its + // provider when it opens, so a refresh must not publish this workspace: + // a full reload posts without userInfo at most every 5 s while agent + // hooks write the hook store, and each publish re-rendered every pane + // (issue 22703). sharedLiveAgentIndexObserver = NotificationCenter.default.addObserver( forName: .sharedLiveAgentIndexDidChange, object: SharedLiveAgentIndex.shared, @@ -4661,7 +4665,9 @@ final class Workspace: Identifiable, ObservableObject, FilePreviewTabMetadataHos self.reconcileCompletedRestoredAgent(panelId: panelId, observation: observation) } } - self.objectWillChange.send() + #if DEBUG + self.sharedLiveAgentIndexChangeHandledForTesting?() + #endif } } featureFlagsObserver = NotificationCenter.default.addObserver( @@ -4691,6 +4697,10 @@ final class Workspace: Identifiable, ObservableObject, FilePreviewTabMetadataHos } private var sharedLiveAgentIndexObserver: NSObjectProtocol? + #if DEBUG + /// Test seam: runs after this workspace handles a shared agent-index change. + var sharedLiveAgentIndexChangeHandledForTesting: (() -> Void)? + #endif deinit { for registrations in pendingTerminalInputObserversByPanelId.values { diff --git a/cmuxTests/ClosedMainWindowRoutingTests.swift b/cmuxTests/ClosedMainWindowRoutingTests.swift index 900e68d8ea8a..56239f457cdf 100644 --- a/cmuxTests/ClosedMainWindowRoutingTests.swift +++ b/cmuxTests/ClosedMainWindowRoutingTests.swift @@ -3005,10 +3005,10 @@ struct WorkspaceProcessWideObserverRetirementTests { await drainMainActorQueue() var publicationCount = 0 - let publication = workspace.objectWillChange.sink { + workspace.sharedLiveAgentIndexChangeHandledForTesting = { publicationCount += 1 } - defer { publication.cancel() } + defer { workspace.sharedLiveAgentIndexChangeHandledForTesting = nil } NotificationCenter.default.post( name: .sharedLiveAgentIndexDidChange, From 65811211884fbb8d9606d42dcd6656e6b6f97127 Mon Sep 17 00:00:00 2001 From: Austin Wang <38676809+austinywang@users.noreply.github.com> Date: Mon, 26 Oct 2026 12:59:29 -0700 Subject: [PATCH 4/9] test: require every workspace to handle the agent-index reload Review: the zero-count assertions also passed if the observers never ran. Count handled notifications through the DEBUG seam first. Co-Authored-By: Claude Opus 5.5 --- .../WorkspaceContentViewVisibilityTests.swift | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/cmuxTests/WorkspaceContentViewVisibilityTests.swift b/cmuxTests/WorkspaceContentViewVisibilityTests.swift index 46de1d5204fc..57ead92dee66 100644 --- a/cmuxTests/WorkspaceContentViewVisibilityTests.swift +++ b/cmuxTests/WorkspaceContentViewVisibilityTests.swift @@ -574,6 +574,17 @@ final class WorkspaceContentViewVisibilityTests { workspace.objectWillChange.sink { _ in workspacePublishes += 1 } } defer { publishes.forEach { $0.cancel() } } + // Count handled notifications so the zero counts below can't pass + // because the observers never ran. + var handledChanges = 0 + for workspace in tabManager.tabs { + workspace.sharedLiveAgentIndexChangeHandledForTesting = { handledChanges += 1 } + } + defer { + for workspace in tabManager.tabs { + workspace.sharedLiveAgentIndexChangeHandledForTesting = nil + } + } // The post a full reload makes (SharedLiveAgentIndex.startReload). NotificationCenter.default.post( name: .sharedLiveAgentIndexDidChange, @@ -582,6 +593,11 @@ final class WorkspaceContentViewVisibilityTests { await Self.drainMainRunLoop(for: window) counts.isMeasuringInvalidations = false + try #require( + handledChanges == tabManager.tabs.count, + "Every workspace must handle the reload (\(handledChanges) of \(tabManager.tabs.count))." + ) + #expect( workspacePublishes == 0, "An agent-index reload must not publish any workspace (\(workspacePublishes) publishes)." From 0f7073f03a28044011f731a4c533d066db51bb54 Mon Sep 17 00:00:00 2001 From: Austin Wang <38676809+austinywang@users.noreply.github.com> Date: Mon, 26 Oct 2026 13:48:57 -0700 Subject: [PATCH 5/9] refactor: handle shared agent-index changes in an internal method, no test seam Review (CodeRabbit): the DEBUG closure in Workspace.swift was a test seam in production source. The observer now calls handleSharedLiveAgentIndexChange, which the pane test calls synchronously on every workspace instead of draining the run loop for queued tasks. The retirement test checks the observer is removed and that a queued change publishes nothing. Co-Authored-By: Claude Opus 5.5 --- Sources/Workspace.swift | 83 +++++++++---------- cmuxTests/ClosedMainWindowRoutingTests.swift | 29 +++---- .../WorkspaceContentViewVisibilityTests.swift | 22 +---- 3 files changed, 54 insertions(+), 80 deletions(-) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index a4141b18ad20..31c704bcb1c0 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -4624,50 +4624,17 @@ final class Workspace: Identifiable, ObservableObject, FilePreviewTabMetadataHos tmuxLayoutSnapshot = bonsplitController.layoutSnapshot() scheduleExtensionSidebarProjectRootRefresh(for: currentDirectory) - // Reconcile restored agents that a shared agent-index refresh reports as - // finished. The Fork Conversation menu reads availability from its - // provider when it opens, so a refresh must not publish this workspace: - // a full reload posts without userInfo at most every 5 s while agent - // hooks write the hook store, and each publish re-rendered every pane - // (issue 22703). + // The Fork Conversation menu reads availability from its provider when + // it opens, so a refresh must not publish this workspace: a full reload + // posts without userInfo at most every 5 s while agent hooks write the + // hook store, and each publish re-rendered every pane (issue 22703). sharedLiveAgentIndexObserver = NotificationCenter.default.addObserver( forName: .sharedLiveAgentIndexDidChange, object: SharedLiveAgentIndex.shared, queue: nil ) { [weak self] notification in Task { @MainActor [weak self] in - guard let self, !self.isRetiredFromOwningTabManager else { return } - if let index = SharedLiveAgentIndex.shared.index { - let completedPanelIds: [UUID] - if let panelIdsByWorkspaceId = notification.userInfo?["panelIdsByWorkspaceId"] as? [UUID: Set] { - guard let changedPanelIds = panelIdsByWorkspaceId[self.id] else { - return - } - completedPanelIds = changedPanelIds.filter { panelId in - self.restoredAgentResumeStatesByPanelId[panelId] == .completedAgentExit - } - } else if let changedWorkspaceId = notification.userInfo?["workspaceId"] as? UUID, - changedWorkspaceId != self.id { - return - } else if let changedPanelId = notification.userInfo?["panelId"] as? UUID { - completedPanelIds = self.restoredAgentResumeStatesByPanelId[changedPanelId] == .completedAgentExit - ? [changedPanelId] - : [] - } else { - completedPanelIds = self.restoredAgentResumeStatesByPanelId.compactMap { panelId, state in - state == .completedAgentExit ? panelId : nil - } - } - for panelId in completedPanelIds { - guard let observation = index.entry(workspaceId: self.id, panelId: panelId) else { - continue - } - self.reconcileCompletedRestoredAgent(panelId: panelId, observation: observation) - } - } - #if DEBUG - self.sharedLiveAgentIndexChangeHandledForTesting?() - #endif + self?.handleSharedLiveAgentIndexChange(userInfo: notification.userInfo) } } featureFlagsObserver = NotificationCenter.default.addObserver( @@ -4696,11 +4663,41 @@ final class Workspace: Identifiable, ObservableObject, FilePreviewTabMetadataHos } } - private var sharedLiveAgentIndexObserver: NSObjectProtocol? - #if DEBUG - /// Test seam: runs after this workspace handles a shared agent-index change. - var sharedLiveAgentIndexChangeHandledForTesting: (() -> Void)? - #endif + private(set) var sharedLiveAgentIndexObserver: NSObjectProtocol? + + /// Reconciles restored agents that a shared agent-index change reports as + /// finished. It never publishes this workspace. + func handleSharedLiveAgentIndexChange(userInfo: [AnyHashable: Any]?) { + guard !isRetiredFromOwningTabManager else { return } + if let index = SharedLiveAgentIndex.shared.index { + let completedPanelIds: [UUID] + if let panelIdsByWorkspaceId = userInfo?["panelIdsByWorkspaceId"] as? [UUID: Set] { + guard let changedPanelIds = panelIdsByWorkspaceId[self.id] else { + return + } + completedPanelIds = changedPanelIds.filter { panelId in + self.restoredAgentResumeStatesByPanelId[panelId] == .completedAgentExit + } + } else if let changedWorkspaceId = userInfo?["workspaceId"] as? UUID, + changedWorkspaceId != self.id { + return + } else if let changedPanelId = userInfo?["panelId"] as? UUID { + completedPanelIds = self.restoredAgentResumeStatesByPanelId[changedPanelId] == .completedAgentExit + ? [changedPanelId] + : [] + } else { + completedPanelIds = self.restoredAgentResumeStatesByPanelId.compactMap { panelId, state in + state == .completedAgentExit ? panelId : nil + } + } + for panelId in completedPanelIds { + guard let observation = index.entry(workspaceId: self.id, panelId: panelId) else { + continue + } + self.reconcileCompletedRestoredAgent(panelId: panelId, observation: observation) + } + } + } deinit { for registrations in pendingTerminalInputObserversByPanelId.values { diff --git a/cmuxTests/ClosedMainWindowRoutingTests.swift b/cmuxTests/ClosedMainWindowRoutingTests.swift index 56239f457cdf..846329633b1f 100644 --- a/cmuxTests/ClosedMainWindowRoutingTests.swift +++ b/cmuxTests/ClosedMainWindowRoutingTests.swift @@ -3004,32 +3004,25 @@ struct WorkspaceProcessWideObserverRetirementTests { } await drainMainActorQueue() - var publicationCount = 0 - workspace.sharedLiveAgentIndexChangeHandledForTesting = { - publicationCount += 1 - } - defer { workspace.sharedLiveAgentIndexChangeHandledForTesting = nil } - - NotificationCenter.default.post( - name: .sharedLiveAgentIndexDidChange, - object: SharedLiveAgentIndex.shared, - userInfo: ["workspaceId": workspace.id] - ) - await drainMainActorQueue() - #expect(publicationCount > 0) + #expect(workspace.sharedLiveAgentIndexObserver != nil) // Queue one final matching publication, then retire before its - // MainActor task can mutate the workspace. + // MainActor task runs. NotificationCenter.default.post( name: .sharedLiveAgentIndexDidChange, object: SharedLiveAgentIndex.shared, userInfo: ["workspaceId": workspace.id] ) workspace.retireFromOwningTabManager() - let countAfterRetirement = publicationCount - await drainMainActorQueue() - #expect(publicationCount == countAfterRetirement) + // Future publications reach no observer. + #expect(workspace.sharedLiveAgentIndexObserver == nil) + var publishesAfterRetirement = 0 + let publication = workspace.objectWillChange.sink { _ in + publishesAfterRetirement += 1 + } + defer { publication.cancel() } + await drainMainActorQueue() for _ in 0..<3 { NotificationCenter.default.post( name: .sharedLiveAgentIndexDidChange, @@ -3038,7 +3031,7 @@ struct WorkspaceProcessWideObserverRetirementTests { ) } await drainMainActorQueue() - #expect(publicationCount == countAfterRetirement) + #expect(publishesAfterRetirement == 0) } @Test("Retirement rejects queued and future feature flag publications") diff --git a/cmuxTests/WorkspaceContentViewVisibilityTests.swift b/cmuxTests/WorkspaceContentViewVisibilityTests.swift index 57ead92dee66..48c00a6d738b 100644 --- a/cmuxTests/WorkspaceContentViewVisibilityTests.swift +++ b/cmuxTests/WorkspaceContentViewVisibilityTests.swift @@ -574,30 +574,14 @@ final class WorkspaceContentViewVisibilityTests { workspace.objectWillChange.sink { _ in workspacePublishes += 1 } } defer { publishes.forEach { $0.cancel() } } - // Count handled notifications so the zero counts below can't pass - // because the observers never ran. - var handledChanges = 0 + // Handle the change a full reload posts (SharedLiveAgentIndex.startReload, + // no userInfo) on every workspace, as each one's observer does. for workspace in tabManager.tabs { - workspace.sharedLiveAgentIndexChangeHandledForTesting = { handledChanges += 1 } + workspace.handleSharedLiveAgentIndexChange(userInfo: nil) } - defer { - for workspace in tabManager.tabs { - workspace.sharedLiveAgentIndexChangeHandledForTesting = nil - } - } - // The post a full reload makes (SharedLiveAgentIndex.startReload). - NotificationCenter.default.post( - name: .sharedLiveAgentIndexDidChange, - object: SharedLiveAgentIndex.shared - ) await Self.drainMainRunLoop(for: window) counts.isMeasuringInvalidations = false - try #require( - handledChanges == tabManager.tabs.count, - "Every workspace must handle the reload (\(handledChanges) of \(tabManager.tabs.count))." - ) - #expect( workspacePublishes == 0, "An agent-index reload must not publish any workspace (\(workspacePublishes) publishes)." From afe461bcbfd5b0e2803313e0eef67a03719a1c8c Mon Sep 17 00:00:00 2001 From: Austin Wang <38676809+austinywang@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:50:08 -0700 Subject: [PATCH 6/9] test: post the real agent-index reload and await project-root discovery Review (CodeRabbit): the reload test called the handler directly, so it would miss an observer that published, and it could measure while project-root discovery was still pending. The observer now delivers on the main queue and handles the change before post returns (the index is MainActor and posts on main), so the test posts the full-reload notification and asserts with no queued work to wait for. Workspaces use a controlled project root and the test awaits each root first. Co-Authored-By: Claude Opus 5.5 --- Sources/Workspace.swift | 9 +++-- cmuxTests/ClosedMainWindowRoutingTests.swift | 3 +- .../WorkspaceContentViewVisibilityTests.swift | 33 +++++++++++++++---- 3 files changed, 33 insertions(+), 12 deletions(-) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 31c704bcb1c0..8576d9548677 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -4628,13 +4628,16 @@ final class Workspace: Identifiable, ObservableObject, FilePreviewTabMetadataHos // it opens, so a refresh must not publish this workspace: a full reload // posts without userInfo at most every 5 s while agent hooks write the // hook store, and each publish re-rendered every pane (issue 22703). + // SharedLiveAgentIndex is MainActor, so it posts on the main thread and + // the change is handled before post returns. sharedLiveAgentIndexObserver = NotificationCenter.default.addObserver( forName: .sharedLiveAgentIndexDidChange, object: SharedLiveAgentIndex.shared, - queue: nil + queue: .main ) { [weak self] notification in - Task { @MainActor [weak self] in - self?.handleSharedLiveAgentIndexChange(userInfo: notification.userInfo) + let userInfo = notification.userInfo + MainActor.assumeIsolated { + self?.handleSharedLiveAgentIndexChange(userInfo: userInfo) } } featureFlagsObserver = NotificationCenter.default.addObserver( diff --git a/cmuxTests/ClosedMainWindowRoutingTests.swift b/cmuxTests/ClosedMainWindowRoutingTests.swift index 846329633b1f..440836f08b96 100644 --- a/cmuxTests/ClosedMainWindowRoutingTests.swift +++ b/cmuxTests/ClosedMainWindowRoutingTests.swift @@ -3006,8 +3006,7 @@ struct WorkspaceProcessWideObserverRetirementTests { await drainMainActorQueue() #expect(workspace.sharedLiveAgentIndexObserver != nil) - // Queue one final matching publication, then retire before its - // MainActor task runs. + // A matching publication while live is handled without publishing. NotificationCenter.default.post( name: .sharedLiveAgentIndexDidChange, object: SharedLiveAgentIndex.shared, diff --git a/cmuxTests/WorkspaceContentViewVisibilityTests.swift b/cmuxTests/WorkspaceContentViewVisibilityTests.swift index 48c00a6d738b..133c2f0bfc3f 100644 --- a/cmuxTests/WorkspaceContentViewVisibilityTests.swift +++ b/cmuxTests/WorkspaceContentViewVisibilityTests.swift @@ -517,16 +517,32 @@ final class WorkspaceContentViewVisibilityTests { defaults.removePersistentDomain(forName: suiteName) } - // Loading-card workspaces, as in the tests above, so no shell or git - // probe publishes during the measured window. + // Loading-card workspaces in a controlled project root, as in the tests + // above, so no shell, git or project-root discovery publishes during + // the measured window. Await each root rather than counting idle turns. + let projectRoot = FileManager.default.temporaryDirectory + .appendingPathComponent(suiteName, isDirectory: true) + .resolvingSymlinksInPath() + try FileManager.default.createDirectory( + at: projectRoot.appendingPathComponent(".git", isDirectory: true), + withIntermediateDirectories: true + ) + defer { try? FileManager.default.removeItem(at: projectRoot) } let tabManager = TabManager(autoWelcomeIfNeeded: false, createInitialWorkspace: false) for index in 0..<3 { tabManager.addWorkspace( + workingDirectory: projectRoot.path, initialSurface: .cloudVMLoading, select: index == 0, autoWelcomeIfNeeded: false ) } + for workspace in tabManager.tabs { + for await root in workspace.$extensionSidebarProjectRootPath.values { + if root == projectRoot.path { break } + } + try #require(workspace.extensionSidebarProjectRootPath == projectRoot.path) + } let counts = MinimalModeBodyProbeCounts() let root = ContentView(updateViewModel: UpdateStateModel(), windowId: UUID()) .environmentObject(tabManager) @@ -574,11 +590,14 @@ final class WorkspaceContentViewVisibilityTests { workspace.objectWillChange.sink { _ in workspacePublishes += 1 } } defer { publishes.forEach { $0.cancel() } } - // Handle the change a full reload posts (SharedLiveAgentIndex.startReload, - // no userInfo) on every workspace, as each one's observer does. - for workspace in tabManager.tabs { - workspace.handleSharedLiveAgentIndexChange(userInfo: nil) - } + // The post a full reload makes (SharedLiveAgentIndex.startReload). Each + // workspace's observer handles it before post returns; the drain lets + // any SwiftUI update it scheduled run before the counts are read. + try #require(tabManager.tabs.allSatisfy { $0.sharedLiveAgentIndexObserver != nil }) + NotificationCenter.default.post( + name: .sharedLiveAgentIndexDidChange, + object: SharedLiveAgentIndex.shared + ) await Self.drainMainRunLoop(for: window) counts.isMeasuringInvalidations = false From d64486faecdc20f0974e6c1be25d12df63172ffc Mon Sep 17 00:00:00 2001 From: Austin Wang <38676809+austinywang@users.noreply.github.com> Date: Mon, 26 Oct 2026 15:07:19 -0700 Subject: [PATCH 7/9] test: name the retirement test for what it checks Review: with the change handled synchronously and never publishing, the queued and future publication counts could not fail. The test now checks the one thing retirement guarantees here: the observer is gone. Co-Authored-By: Claude Opus 5.5 --- cmuxTests/ClosedMainWindowRoutingTests.swift | 28 +++----------------- 1 file changed, 3 insertions(+), 25 deletions(-) diff --git a/cmuxTests/ClosedMainWindowRoutingTests.swift b/cmuxTests/ClosedMainWindowRoutingTests.swift index 440836f08b96..139e21ec6144 100644 --- a/cmuxTests/ClosedMainWindowRoutingTests.swift +++ b/cmuxTests/ClosedMainWindowRoutingTests.swift @@ -2991,8 +2991,8 @@ struct MainWindowKeyObservationOwnershipTests { @MainActor @Suite("Workspace process-wide observer retirement", .serialized) struct WorkspaceProcessWideObserverRetirementTests { - @Test("Retirement rejects queued and future shared agent index publications") - func retirementRejectsQueuedAndFutureSharedAgentIndexPublications() async throws { + @Test("Retirement removes the shared agent index observer") + func retirementRemovesSharedAgentIndexObserver() async throws { let manager = TabManager() let workspace = try #require(manager.selectedWorkspace) defer { @@ -3006,31 +3006,9 @@ struct WorkspaceProcessWideObserverRetirementTests { await drainMainActorQueue() #expect(workspace.sharedLiveAgentIndexObserver != nil) - // A matching publication while live is handled without publishing. - NotificationCenter.default.post( - name: .sharedLiveAgentIndexDidChange, - object: SharedLiveAgentIndex.shared, - userInfo: ["workspaceId": workspace.id] - ) workspace.retireFromOwningTabManager() - // Future publications reach no observer. + // A shared agent-index change no longer reaches a retired workspace. #expect(workspace.sharedLiveAgentIndexObserver == nil) - - var publishesAfterRetirement = 0 - let publication = workspace.objectWillChange.sink { _ in - publishesAfterRetirement += 1 - } - defer { publication.cancel() } - await drainMainActorQueue() - for _ in 0..<3 { - NotificationCenter.default.post( - name: .sharedLiveAgentIndexDidChange, - object: SharedLiveAgentIndex.shared, - userInfo: ["workspaceId": workspace.id] - ) - } - await drainMainActorQueue() - #expect(publishesAfterRetirement == 0) } @Test("Retirement rejects queued and future feature flag publications") From ef4d231607e78ea54d3013b772515e2c3dad701b Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Mon, 26 Oct 2026 16:19:37 -0700 Subject: [PATCH 8/9] test: explicitly display agent index visibility window --- cmuxTests/WorkspaceContentViewVisibilityTests.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cmuxTests/WorkspaceContentViewVisibilityTests.swift b/cmuxTests/WorkspaceContentViewVisibilityTests.swift index 133c2f0bfc3f..e2e5327dfc6f 100644 --- a/cmuxTests/WorkspaceContentViewVisibilityTests.swift +++ b/cmuxTests/WorkspaceContentViewVisibilityTests.swift @@ -570,6 +570,8 @@ final class WorkspaceContentViewVisibilityTests { ) window.isReleasedWhenClosed = false window.contentView = MainWindowHostingView(rootView: root) + window.makeKeyAndOrderFront(nil) + window.displayIfNeeded() defer { window.contentView = nil tabManager.finalizeAllWorkspacesForWindowClose() @@ -590,7 +592,7 @@ final class WorkspaceContentViewVisibilityTests { workspace.objectWillChange.sink { _ in workspacePublishes += 1 } } defer { publishes.forEach { $0.cancel() } } - // The post a full reload makes (SharedLiveAgentIndex.startReload). Each + // The post below makes a full reload (SharedLiveAgentIndex.startReload). Each // workspace's observer handles it before post returns; the drain lets // any SwiftUI update it scheduled run before the counts are read. try #require(tabManager.tabs.allSatisfy { $0.sharedLiveAgentIndexObserver != nil }) From d319d3666397a91b7c850150c04b237eb405fb21 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Tue, 27 Oct 2026 23:40:44 -0700 Subject: [PATCH 9/9] refactor: isolate agent index notification handling and regression --- Sources/Workspace+SharedLiveAgentIndex.swift | 37 +++++ Sources/Workspace.swift | 34 ----- cmux.xcodeproj/project.pbxproj | 8 + ...ontentViewVisibilityTests+AgentIndex.swift | 138 ++++++++++++++++++ .../WorkspaceContentViewVisibilityTests.swift | 124 +--------------- 5 files changed, 186 insertions(+), 155 deletions(-) create mode 100644 Sources/Workspace+SharedLiveAgentIndex.swift create mode 100644 cmuxTests/WorkspaceContentViewVisibilityTests+AgentIndex.swift diff --git a/Sources/Workspace+SharedLiveAgentIndex.swift b/Sources/Workspace+SharedLiveAgentIndex.swift new file mode 100644 index 000000000000..1a85f3214fb3 --- /dev/null +++ b/Sources/Workspace+SharedLiveAgentIndex.swift @@ -0,0 +1,37 @@ +import Foundation + +extension Workspace { + /// Reconciles previously completed restored agents against the fresh index. + /// Only their lifecycle state changes; this never publishes the workspace. + func handleSharedLiveAgentIndexChange(userInfo: [AnyHashable: Any]?) { + guard !isRetiredFromOwningTabManager else { return } + if let index = SharedLiveAgentIndex.shared.index { + let completedPanelIds: [UUID] + if let panelIdsByWorkspaceId = userInfo?["panelIdsByWorkspaceId"] as? [UUID: Set] { + guard let changedPanelIds = panelIdsByWorkspaceId[self.id] else { + return + } + completedPanelIds = changedPanelIds.filter { panelId in + self.restoredAgentResumeStatesByPanelId[panelId] == .completedAgentExit + } + } else if let changedWorkspaceId = userInfo?["workspaceId"] as? UUID, + changedWorkspaceId != self.id { + return + } else if let changedPanelId = userInfo?["panelId"] as? UUID { + completedPanelIds = self.restoredAgentResumeStatesByPanelId[changedPanelId] == .completedAgentExit + ? [changedPanelId] + : [] + } else { + completedPanelIds = self.restoredAgentResumeStatesByPanelId.compactMap { panelId, state in + state == .completedAgentExit ? panelId : nil + } + } + for panelId in completedPanelIds { + guard let observation = index.entry(workspaceId: self.id, panelId: panelId) else { + continue + } + self.reconcileCompletedRestoredAgent(panelId: panelId, observation: observation) + } + } + } +} diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 8576d9548677..7cdd887c9d20 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -4668,40 +4668,6 @@ final class Workspace: Identifiable, ObservableObject, FilePreviewTabMetadataHos private(set) var sharedLiveAgentIndexObserver: NSObjectProtocol? - /// Reconciles restored agents that a shared agent-index change reports as - /// finished. It never publishes this workspace. - func handleSharedLiveAgentIndexChange(userInfo: [AnyHashable: Any]?) { - guard !isRetiredFromOwningTabManager else { return } - if let index = SharedLiveAgentIndex.shared.index { - let completedPanelIds: [UUID] - if let panelIdsByWorkspaceId = userInfo?["panelIdsByWorkspaceId"] as? [UUID: Set] { - guard let changedPanelIds = panelIdsByWorkspaceId[self.id] else { - return - } - completedPanelIds = changedPanelIds.filter { panelId in - self.restoredAgentResumeStatesByPanelId[panelId] == .completedAgentExit - } - } else if let changedWorkspaceId = userInfo?["workspaceId"] as? UUID, - changedWorkspaceId != self.id { - return - } else if let changedPanelId = userInfo?["panelId"] as? UUID { - completedPanelIds = self.restoredAgentResumeStatesByPanelId[changedPanelId] == .completedAgentExit - ? [changedPanelId] - : [] - } else { - completedPanelIds = self.restoredAgentResumeStatesByPanelId.compactMap { panelId, state in - state == .completedAgentExit ? panelId : nil - } - } - for panelId in completedPanelIds { - guard let observation = index.entry(workspaceId: self.id, panelId: panelId) else { - continue - } - self.reconcileCompletedRestoredAgent(panelId: panelId, observation: observation) - } - } - } - deinit { for registrations in pendingTerminalInputObserversByPanelId.values { for registration in registrations { diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index a05bb89a3fd9..1138f547bd15 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -4558,6 +4558,7 @@ 9200A0019200A0019200A001 /* Workspace+RestoredWorkingDirectoryGuard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9200A0019200A0019200A002 /* Workspace+RestoredWorkingDirectoryGuard.swift */; }; 381B026EFFAB40449AF805E8 /* Workspace+RightSidebarToolDrop.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52ACFA4197424088998A76C0 /* Workspace+RightSidebarToolDrop.swift */; }; C548600A000000000000001 /* Workspace+SessionRestoreIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = C548600A000000000000002 /* Workspace+SessionRestoreIdentity.swift */; }; + A45B02D25FC8C5AF22850E39 /* Workspace+SharedLiveAgentIndex.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EEA4A95E38F9F819B101793 /* Workspace+SharedLiveAgentIndex.swift */; }; C7268002000000000000001 /* Workspace+SidebarDirectories.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7268002000000000000002 /* Workspace+SidebarDirectories.swift */; }; C3744E100000000000000001 /* Workspace+SidebarStatusVisibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3744E100000000000000002 /* Workspace+SidebarStatusVisibility.swift */; }; C51A710000000000000000C1 /* Workspace+SimulatorPane.swift in Sources */ = {isa = PBXBuildFile; fileRef = C51A710000000000000000C2 /* Workspace+SimulatorPane.swift */; }; @@ -4606,6 +4607,7 @@ 5732A0045732A0045732A004 /* WorkspaceContentMinimalModeSafeAreaModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5732B0045732B0045732B004 /* WorkspaceContentMinimalModeSafeAreaModifier.swift */; }; DFAAE6D5B2C449C4BC3AC238 /* WorkspaceContentView+PanelVisibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = 043F767DF75A4170B35210E7 /* WorkspaceContentView+PanelVisibility.swift */; }; A5001407 /* WorkspaceContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001417 /* WorkspaceContentView.swift */; }; + 95FDF35378E51CD79C9D008F /* WorkspaceContentViewVisibilityTests+AgentIndex.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6FA6CA3BD8C6FB44634D416 /* WorkspaceContentViewVisibilityTests+AgentIndex.swift */; }; F7000000A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */; }; F7962E5DDA94319A98612BDC /* WorkspaceCreateIdempotencyIsolationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D745F48BE9C06D2ED4C2E3DC /* WorkspaceCreateIdempotencyIsolationTests.swift */; }; D1A700000000000000000008 /* WorkspaceCreateIdempotencyRecoveryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A700000000000000000007 /* WorkspaceCreateIdempotencyRecoveryTests.swift */; }; @@ -9208,6 +9210,7 @@ 9200A0019200A0019200A002 /* Workspace+RestoredWorkingDirectoryGuard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+RestoredWorkingDirectoryGuard.swift"; sourceTree = ""; }; 52ACFA4197424088998A76C0 /* Workspace+RightSidebarToolDrop.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+RightSidebarToolDrop.swift"; sourceTree = ""; }; C548600A000000000000002 /* Workspace+SessionRestoreIdentity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+SessionRestoreIdentity.swift"; sourceTree = ""; }; + 9EEA4A95E38F9F819B101793 /* Workspace+SharedLiveAgentIndex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+SharedLiveAgentIndex.swift"; sourceTree = ""; }; C7268002000000000000002 /* Workspace+SidebarDirectories.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+SidebarDirectories.swift"; sourceTree = ""; }; C3744E100000000000000002 /* Workspace+SidebarStatusVisibility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+SidebarStatusVisibility.swift"; sourceTree = ""; }; C51A710000000000000000C2 /* Workspace+SimulatorPane.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+SimulatorPane.swift"; sourceTree = ""; }; @@ -9256,6 +9259,7 @@ 5732B0045732B0045732B004 /* WorkspaceContentMinimalModeSafeAreaModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceContentMinimalModeSafeAreaModifier.swift; sourceTree = ""; }; 043F767DF75A4170B35210E7 /* WorkspaceContentView+PanelVisibility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WorkspaceContentView+PanelVisibility.swift"; sourceTree = ""; }; A5001417 /* WorkspaceContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceContentView.swift; sourceTree = ""; }; + A6FA6CA3BD8C6FB44634D416 /* WorkspaceContentViewVisibilityTests+AgentIndex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WorkspaceContentViewVisibilityTests+AgentIndex.swift"; sourceTree = ""; }; F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceContentViewVisibilityTests.swift; sourceTree = ""; }; D745F48BE9C06D2ED4C2E3DC /* WorkspaceCreateIdempotencyIsolationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WorkspaceCreateIdempotencyIsolationTests.swift"; sourceTree = ""; }; D1A700000000000000000007 /* WorkspaceCreateIdempotencyRecoveryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceCreateIdempotencyRecoveryTests.swift; sourceTree = ""; }; @@ -12641,6 +12645,7 @@ F1C6800D38EC5F238A2994FC /* DockSplitStore+ChiefPane.swift */, 7947118FF892144AE54C82D2 /* ChiefPaneDragPayload.swift */, 0D10F73113F55349CE0E475D /* ChiefHomeConversationRows.swift */, + 9EEA4A95E38F9F819B101793 /* Workspace+SharedLiveAgentIndex.swift */, ); path = Sources; sourceTree = ""; @@ -14281,6 +14286,7 @@ FBFA082D961AFAB3FFBFF6CC /* RawOSCMachinePayloadTests.swift */, E5E915CF2237052FDE02EE05 /* WorkspaceTitleRecoveryTests.swift */, AD2084D340B702301EBB1356 /* SurfaceSocketCommandTests+NewDisplay.swift */, + A6FA6CA3BD8C6FB44634D416 /* WorkspaceContentViewVisibilityTests+AgentIndex.swift */, ); path = cmuxTests; sourceTree = ""; @@ -17774,6 +17780,7 @@ 9200A0019200A0019200A001 /* Workspace+RestoredWorkingDirectoryGuard.swift in Sources */, 381B026EFFAB40449AF805E8 /* Workspace+RightSidebarToolDrop.swift in Sources */, C548600A000000000000001 /* Workspace+SessionRestoreIdentity.swift in Sources */, + A45B02D25FC8C5AF22850E39 /* Workspace+SharedLiveAgentIndex.swift in Sources */, C7268002000000000000001 /* Workspace+SidebarDirectories.swift in Sources */, C3744E100000000000000001 /* Workspace+SidebarStatusVisibility.swift in Sources */, C51A710000000000000000C1 /* Workspace+SimulatorPane.swift in Sources */, @@ -19593,6 +19600,7 @@ C12524000000000000000001 /* WorkspaceClosePanelFallbackTests.swift in Sources */, D7AB3605C10DEF0000000001 /* WorkspaceCloseTabsContextMenuTests.swift in Sources */, 970900030000000000000001 /* WorkspaceColorMenuTests.swift in Sources */, + 95FDF35378E51CD79C9D008F /* WorkspaceContentViewVisibilityTests+AgentIndex.swift in Sources */, F7000000A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift in Sources */, F7962E5DDA94319A98612BDC /* WorkspaceCreateIdempotencyIsolationTests.swift in Sources */, D1A700000000000000000008 /* WorkspaceCreateIdempotencyRecoveryTests.swift in Sources */, diff --git a/cmuxTests/WorkspaceContentViewVisibilityTests+AgentIndex.swift b/cmuxTests/WorkspaceContentViewVisibilityTests+AgentIndex.swift new file mode 100644 index 000000000000..7f1beedaa55e --- /dev/null +++ b/cmuxTests/WorkspaceContentViewVisibilityTests+AgentIndex.swift @@ -0,0 +1,138 @@ +import Testing +import AppKit +import Combine +import CmuxNotifications +import CmuxUpdater +import CoreGraphics +import Observation +import SwiftUI +@testable import Bonsplit + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +extension WorkspaceContentViewVisibilityTests { + /// `SharedLiveAgentIndex` reloads at most every 5 s while agent hooks write + /// the hook store, and each full reload posts `.sharedLiveAgentIndexDidChange` + /// without `userInfo`. Every workspace used to answer with + /// `objectWillChange`, which re-ran `WorkspaceContentView` and every pane's + /// `updateNSView` and portal reconciliation while the user was idle: the + /// burst that precedes the 0.65.x idle hangs (issue 22703). + @Test(.timeLimit(.minutes(1))) + @MainActor + func testSharedAgentIndexReloadDoesNotReevaluatePaneContent() async throws { + _ = NSApplication.shared + + let suiteName = "WorkspaceContentViewAgentIndexReloadTests.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defaults.removePersistentDomain(forName: suiteName) + defer { + defaults.removePersistentDomain(forName: suiteName) + } + + // Loading-card workspaces in a controlled project root, as in the tests + // above, so no shell, git or project-root discovery publishes during + // the measured window. Await each root rather than counting idle turns. + let projectRoot = FileManager.default.temporaryDirectory + .appendingPathComponent(suiteName, isDirectory: true) + .resolvingSymlinksInPath() + try FileManager.default.createDirectory( + at: projectRoot.appendingPathComponent(".git", isDirectory: true), + withIntermediateDirectories: true + ) + defer { try? FileManager.default.removeItem(at: projectRoot) } + let tabManager = TabManager(autoWelcomeIfNeeded: false, createInitialWorkspace: false) + defer { tabManager.finalizeAllWorkspacesForWindowClose() } + for index in 0..<3 { + tabManager.addWorkspace( + workingDirectory: projectRoot.path, + initialSurface: .cloudVMLoading, + select: index == 0, + autoWelcomeIfNeeded: false + ) + } + for workspace in tabManager.tabs { + for await root in workspace.$extensionSidebarProjectRootPath.values { + if root == projectRoot.path { break } + } + try #require(workspace.extensionSidebarProjectRootPath == projectRoot.path) + } + let counts = MinimalModeBodyProbeCounts() + let root = ContentView(updateViewModel: UpdateStateModel(), windowId: UUID()) + .environmentObject(tabManager) + .environmentObject(TerminalNotificationStore.shared) + .environmentObject(SidebarState()) + .environmentObject(SidebarSelectionState()) + .environmentObject(FileExplorerState(defaults: defaults)) + .environmentObject(CmuxConfigStore()) + .environment( + \.minimalModeInvalidationProbe, + MinimalModeInvalidationProbe( + shouldTraceBodyChanges: { counts.isMeasuringInvalidations }, + contentViewBody: { counts.contentViewBody += 1 }, + workspaceContentBody: { counts.workspaceContentBody += 1 }, + verticalTabsSidebarBody: { counts.verticalTabsSidebarBody += 1 } + ) + ) + .defaultAppStorage(defaults) + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 900, height: 640), + styleMask: [.titled, .closable, .resizable], + backing: .buffered, + defer: false + ) + window.isReleasedWhenClosed = false + window.contentView = MainWindowHostingView(rootView: root) + window.makeKeyAndOrderFront(nil) + window.displayIfNeeded() + defer { + window.contentView = nil + window.close() + } + + await Self.drainMainRunLoop(for: window) + #expect(counts.contentViewBody > 0) + #expect(counts.workspaceContentBody > 0) + let settled = await Self.waitForQuietChromeBodies(counts: counts, window: window) + try #require(settled, "The window must stop re-evaluating chrome bodies while idle") + + counts.reset() + counts.isMeasuringInvalidations = true + defer { counts.isMeasuringInvalidations = false } + var workspacePublishes = 0 + // Observe the real classic Workspace publisher: the regression is an + // unconditional send without a property mutation. Observation tracking + // on a mock cannot detect it, including for workspaces not on screen. + let publishes = tabManager.tabs.map { workspace in + workspace.objectWillChange.sink { _ in workspacePublishes += 1 } + } + defer { publishes.forEach { $0.cancel() } } + // Post the notification emitted by SharedLiveAgentIndex.startReload. Each + // workspace's observer handles it before post returns; the drain lets + // any SwiftUI update it scheduled run before the counts are read. + try #require(tabManager.tabs.allSatisfy { $0.sharedLiveAgentIndexObserver != nil }) + NotificationCenter.default.post( + name: .sharedLiveAgentIndexDidChange, + object: SharedLiveAgentIndex.shared + ) + await Self.drainMainRunLoop(for: window) + counts.isMeasuringInvalidations = false + + #expect( + workspacePublishes == 0, + "An agent-index reload must not publish any workspace (\(workspacePublishes) publishes)." + ) + #expect( + counts.workspaceContentBody == 0, + "An agent-index reload must not rebuild pane content (\(counts.workspaceContentBody) evaluations)." + ) + #expect( + counts.contentViewBody == 0, + "An agent-index reload must not re-evaluate ContentView (\(counts.contentViewBody) evaluations)." + ) + } +} diff --git a/cmuxTests/WorkspaceContentViewVisibilityTests.swift b/cmuxTests/WorkspaceContentViewVisibilityTests.swift index e2e5327dfc6f..5dc76f6e35cd 100644 --- a/cmuxTests/WorkspaceContentViewVisibilityTests.swift +++ b/cmuxTests/WorkspaceContentViewVisibilityTests.swift @@ -16,7 +16,7 @@ import SwiftUI @Suite(.serialized) final class WorkspaceContentViewVisibilityTests { - private final class MinimalModeBodyProbeCounts { + final class MinimalModeBodyProbeCounts { var isMeasuringInvalidations = false var contentViewBody = 0 var workspaceContentBody = 0 @@ -499,124 +499,6 @@ final class WorkspaceContentViewVisibilityTests { ) } - /// `SharedLiveAgentIndex` reloads at most every 5 s while agent hooks write - /// the hook store, and each full reload posts `.sharedLiveAgentIndexDidChange` - /// without `userInfo`. Every workspace used to answer with - /// `objectWillChange`, which re-ran `WorkspaceContentView` and every pane's - /// `updateNSView` and portal reconciliation while the user was idle: the - /// burst that precedes the 0.65.x idle hangs (issue 22703). - @Test(.timeLimit(.minutes(1))) - @MainActor - func testSharedAgentIndexReloadDoesNotReevaluatePaneContent() async throws { - _ = NSApplication.shared - - let suiteName = "WorkspaceContentViewAgentIndexReloadTests.\(UUID().uuidString)" - let defaults = try #require(UserDefaults(suiteName: suiteName)) - defaults.removePersistentDomain(forName: suiteName) - defer { - defaults.removePersistentDomain(forName: suiteName) - } - - // Loading-card workspaces in a controlled project root, as in the tests - // above, so no shell, git or project-root discovery publishes during - // the measured window. Await each root rather than counting idle turns. - let projectRoot = FileManager.default.temporaryDirectory - .appendingPathComponent(suiteName, isDirectory: true) - .resolvingSymlinksInPath() - try FileManager.default.createDirectory( - at: projectRoot.appendingPathComponent(".git", isDirectory: true), - withIntermediateDirectories: true - ) - defer { try? FileManager.default.removeItem(at: projectRoot) } - let tabManager = TabManager(autoWelcomeIfNeeded: false, createInitialWorkspace: false) - for index in 0..<3 { - tabManager.addWorkspace( - workingDirectory: projectRoot.path, - initialSurface: .cloudVMLoading, - select: index == 0, - autoWelcomeIfNeeded: false - ) - } - for workspace in tabManager.tabs { - for await root in workspace.$extensionSidebarProjectRootPath.values { - if root == projectRoot.path { break } - } - try #require(workspace.extensionSidebarProjectRootPath == projectRoot.path) - } - let counts = MinimalModeBodyProbeCounts() - let root = ContentView(updateViewModel: UpdateStateModel(), windowId: UUID()) - .environmentObject(tabManager) - .environmentObject(TerminalNotificationStore.shared) - .environmentObject(SidebarState()) - .environmentObject(SidebarSelectionState()) - .environmentObject(FileExplorerState(defaults: defaults)) - .environmentObject(CmuxConfigStore()) - .environment( - \.minimalModeInvalidationProbe, - MinimalModeInvalidationProbe( - shouldTraceBodyChanges: { counts.isMeasuringInvalidations }, - contentViewBody: { counts.contentViewBody += 1 }, - workspaceContentBody: { counts.workspaceContentBody += 1 }, - verticalTabsSidebarBody: { counts.verticalTabsSidebarBody += 1 } - ) - ) - .defaultAppStorage(defaults) - - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 900, height: 640), - styleMask: [.titled, .closable, .resizable], - backing: .buffered, - defer: false - ) - window.isReleasedWhenClosed = false - window.contentView = MainWindowHostingView(rootView: root) - window.makeKeyAndOrderFront(nil) - window.displayIfNeeded() - defer { - window.contentView = nil - tabManager.finalizeAllWorkspacesForWindowClose() - window.close() - } - - await Self.drainMainRunLoop(for: window) - #expect(counts.contentViewBody > 0) - #expect(counts.workspaceContentBody > 0) - let settled = await Self.waitForQuietChromeBodies(counts: counts, window: window) - try #require(settled, "The window must stop re-evaluating chrome bodies while idle") - - counts.reset() - counts.isMeasuringInvalidations = true - defer { counts.isMeasuringInvalidations = false } - var workspacePublishes = 0 - let publishes = tabManager.tabs.map { workspace in - workspace.objectWillChange.sink { _ in workspacePublishes += 1 } - } - defer { publishes.forEach { $0.cancel() } } - // The post below makes a full reload (SharedLiveAgentIndex.startReload). Each - // workspace's observer handles it before post returns; the drain lets - // any SwiftUI update it scheduled run before the counts are read. - try #require(tabManager.tabs.allSatisfy { $0.sharedLiveAgentIndexObserver != nil }) - NotificationCenter.default.post( - name: .sharedLiveAgentIndexDidChange, - object: SharedLiveAgentIndex.shared - ) - await Self.drainMainRunLoop(for: window) - counts.isMeasuringInvalidations = false - - #expect( - workspacePublishes == 0, - "An agent-index reload must not publish any workspace (\(workspacePublishes) publishes)." - ) - #expect( - counts.workspaceContentBody == 0, - "An agent-index reload must not rebuild pane content (\(counts.workspaceContentBody) evaluations)." - ) - #expect( - counts.contentViewBody == 0, - "An agent-index reload must not re-evaluate ContentView (\(counts.contentViewBody) evaluations)." - ) - } - @Test @MainActor func testUnreadApplyPublishesOneAtomicSnapshot() { @@ -760,7 +642,7 @@ final class WorkspaceContentViewVisibilityTests { /// Drains until three consecutive drains re-evaluate no chrome body, so a /// measurement only counts what the change under test invalidates. @MainActor - private static func waitForQuietChromeBodies( + static func waitForQuietChromeBodies( counts: MinimalModeBodyProbeCounts, window: NSWindow ) async -> Bool { @@ -777,7 +659,7 @@ final class WorkspaceContentViewVisibilityTests { } @MainActor - private static func drainMainRunLoop(for window: NSWindow, iterations: Int = 20) async { + static func drainMainRunLoop(for window: NSWindow, iterations: Int = 20) async { for _ in 0..