diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 98c8c898d5db..4bd3eb1331bb 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -1,17 +1,17 @@ # cmux-owned Swift file length budget. # Format: max_linesrelative path # Reduce counts as files shrink. CI fails if tracked files exceed this budget. -34336 CLI/cmux.swift -17717 Sources/AppDelegate.swift +34289 CLI/cmux.swift +17719 Sources/AppDelegate.swift 16112 Sources/ContentView.swift -13950 Sources/TerminalController.swift +13952 Sources/TerminalController.swift 12772 Sources/Workspace.swift 12144 cmuxTests/AppDelegateShortcutRoutingTests.swift 11924 Sources/Panels/BrowserPanel.swift 11867 Sources/GhosttyTerminalView.swift 9335 cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift 8017 CLI/cmux_open.swift -7988 Sources/Panels/BrowserPanelView.swift +7986 Sources/Panels/BrowserPanelView.swift 7354 cmuxTests/WorkspaceUnitTests.swift 7218 cmuxTests/WorkspaceRemoteConnectionTests.swift 6317 cmuxTests/SessionPersistenceTests.swift @@ -146,6 +146,7 @@ 668 cmuxTests/FeedCoordinatorTests.swift 668 cmuxTests/SettingsWindowPresenterTests.swift 664 Sources/CmuxTopSnapshot.swift +663 Sources/PortScanner.swift 663 cmuxTests/SessionIndexViewTests.swift 655 Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Session/RemoteSessionCoordinator.swift 655 Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeLifecycle.swift @@ -159,7 +160,6 @@ 620 cmuxTests/FinderFileDropRegressionTests.swift 620 cmuxTests/TerminalNotificationQueueTests.swift 615 cmuxTests/RemoteTmuxControlParserTests.swift -614 Sources/PortScanner.swift 612 cmuxUITests/FeedSidebarUITests.swift 608 Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Coordinators/WorkspaceGroupCoordinator.swift 606 Sources/SettingsNavigation.swift diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index d4cead29d9b6..e8bbf0711541 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -1787,6 +1787,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent func applicationWillBecomeActive(_ notification: Notification) { if !hasVisibleMainTerminalWindow() { _ = mainWindowVisibilityController.orderFrontApplicationWindowsBeforeActivation(windows: mainWindowsForVisibilityController(), reason: .applicationWillBecomeActive) } } func applicationDidBecomeActive(_ notification: Notification) { + PortScanner.shared.setTrackedAgentScanningPaused(false) let activationWindows = mainWindowsForVisibilityController() if mainWindowVisibilityController.finishPendingApplicationActivationRestore(windows: activationWindows, reason: .applicationDidBecomeActive) == nil, !hasVisibleMainTerminalWindow() { _ = mainWindowVisibilityController.restoreApplicationWindowsAfterActivation(windows: activationWindows, reason: .applicationDidBecomeActive) @@ -1999,6 +2000,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent func applicationWillResignActive(_ notification: Notification) { guard !isTerminatingApp else { return } + PortScanner.shared.setTrackedAgentScanningPaused(true) clearConfiguredShortcutChordState() if Self.shouldSaveSessionSnapshotOnApplicationResign(isTerminatingApp: isTerminatingApp) { saveSessionSnapshotAfterLoadingProcessDetectedIndexes(includeScrollback: false) diff --git a/Sources/PortScanner.swift b/Sources/PortScanner.swift index 8494be9ea4f6..ba746a16c0a0 100644 --- a/Sources/PortScanner.swift +++ b/Sources/PortScanner.swift @@ -19,7 +19,7 @@ final class PortScanner: @unchecked Sendable { /// Callback delivers `(workspaceId, panelId, ports)` on the main actor. var onPortsUpdated: (@MainActor (_ workspaceId: UUID, _ panelId: UUID, _ ports: [Int]) -> Void)? /// Callback delivers workspace-scoped ports owned by tracked agents. - var onAgentPortsUpdated: (@MainActor (_ workspaceId: UUID, _ ports: [Int]) -> Void)? + var onAgentPortsUpdated: (@MainActor (_ workspaceId: UUID, _ ports: [Int]) -> Bool)? /// Provider returns tracked agent root PIDs for the given workspaces. var agentPIDsProvider: (@MainActor (_ workspaceIds: Set) -> [UUID: Set])? @@ -35,6 +35,9 @@ final class PortScanner: @unchecked Sendable { /// Workspaces with active agent PID tracking that need background rescans. private var trackedAgentWorkspaces: Set = [] + private var lastAgentPortsByWorkspace: [UUID: [Int]] = [:] + private var forceAgentResultWorkspaces: Set = [] + private var trackedAgentScanningPaused = false /// Panels that requested a scan since the last coalesce snapshot. private var pendingKicks: Set = [] @@ -96,6 +99,14 @@ final class PortScanner: @unchecked Sendable { } } + func setTrackedAgentScanningPaused(_ paused: Bool) { + queue.async { [self] in + guard trackedAgentScanningPaused != paused else { return } + trackedAgentScanningPaused = paused + updateAgentScanTimerLocked() + } + } + // MARK: - Coalesce + Burst private func startCoalesce() { @@ -253,6 +264,7 @@ final class PortScanner: @unchecked Sendable { trackedAgentWorkspaces.insert(workspaceId) } updateAgentScanTimerLocked() + forceAgentResultWorkspaces.insert(workspaceId) scanAgentPorts( workspaceIds: [workspaceId], @@ -262,7 +274,7 @@ final class PortScanner: @unchecked Sendable { } private func updateAgentScanTimerLocked() { - guard !trackedAgentWorkspaces.isEmpty else { + guard !trackedAgentScanningPaused, !trackedAgentWorkspaces.isEmpty else { agentScanTimer?.cancel() agentScanTimer = nil return @@ -328,6 +340,7 @@ final class PortScanner: @unchecked Sendable { let inactiveWorkspaceIds = workspaceIds.subtracting(normalizedPIDsByWorkspace.keys) if !inactiveWorkspaceIds.isEmpty { trackedAgentWorkspaces.subtract(inactiveWorkspaceIds) + forceAgentResultWorkspaces.formUnion(inactiveWorkspaceIds) updateAgentScanTimerLocked() } @@ -407,10 +420,42 @@ final class PortScanner: @unchecked Sendable { agentRevisions: agentRevisions ) guard !validatedResults.isEmpty else { return } - await MainActor.run { - for (workspaceId, ports) in validatedResults { - agentCallback(workspaceId, ports) + let appliedResults = await MainActor.run { + validatedResults.filter { result in + agentCallback(result.workspaceId, result.ports) + } + } + await self.acknowledgeAgentResults( + validatedResults, + appliedWorkspaceIds: Set(appliedResults.map(\.workspaceId)) + ) + } + } + + private func acknowledgeAgentResults( + _ results: [(workspaceId: UUID, ports: [Int], revision: UInt64)], + appliedWorkspaceIds: Set + ) async { + guard !results.isEmpty else { return } + await withCheckedContinuation { continuation in + queue.async { [self] in + for (workspaceId, ports, revision) in results { + guard agentRevisionByWorkspace[workspaceId, default: 0] == revision else { continue } + guard appliedWorkspaceIds.contains(workspaceId) else { + if !trackedAgentWorkspaces.contains(workspaceId) { + forceAgentResultWorkspaces.remove(workspaceId) + lastAgentPortsByWorkspace.removeValue(forKey: workspaceId) + } + continue + } + forceAgentResultWorkspaces.remove(workspaceId) + if ports.isEmpty, !trackedAgentWorkspaces.contains(workspaceId) { + lastAgentPortsByWorkspace.removeValue(forKey: workspaceId) + } else { + lastAgentPortsByWorkspace[workspaceId] = ports + } } + continuation.resume() } } } @@ -419,16 +464,20 @@ final class PortScanner: @unchecked Sendable { workspaceIds: Set, agentPortsByWorkspace: [UUID: Set], agentRevisions: [UUID: UInt64] - ) async -> [(UUID, [Int])] { + ) async -> [(workspaceId: UUID, ports: [Int], revision: UInt64)] { await withCheckedContinuation { continuation in queue.async { [self] in - var results: [(UUID, [Int])] = [] + var results: [(workspaceId: UUID, ports: [Int], revision: UInt64)] = [] for workspaceId in workspaceIds.sorted(by: { $0.uuidString < $1.uuidString }) { - let currentRevision = agentRevisionByWorkspace[workspaceId, default: 0] let expectedRevision = agentRevisions[workspaceId, default: 0] - guard currentRevision == expectedRevision else { continue } + guard agentRevisionByWorkspace[workspaceId, default: 0] == expectedRevision else { continue } let ports = Array(agentPortsByWorkspace[workspaceId] ?? []).sorted() - results.append((workspaceId, ports)) + let previousPorts = lastAgentPortsByWorkspace[workspaceId] + if !forceAgentResultWorkspaces.contains(workspaceId) { + guard previousPorts != ports else { continue } + guard previousPorts != nil || !ports.isEmpty else { continue } + } + results.append((workspaceId: workspaceId, ports: ports, revision: expectedRevision)) } continuation.resume(returning: results) } diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 25539c3d197e..55ef5d7c040a 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -768,12 +768,13 @@ class TerminalController { workspace.recomputeListeningPorts() } PortScanner.shared.onAgentPortsUpdated = { [weak self] workspaceId, ports in - guard let self, let tabManager = self.tabManager else { return } - guard let workspace = tabManager.tabs.first(where: { $0.id == workspaceId }) else { return } + guard let self, let tabManager = self.tabManager else { return false } + guard let workspace = tabManager.tabs.first(where: { $0.id == workspaceId }) else { return false } if workspace.agentListeningPorts != ports { workspace.agentListeningPorts = ports workspace.recomputeListeningPorts() } + return true } PortScanner.shared.agentPIDsProvider = { [weak self] workspaceIds in guard let self, let tabManager = self.tabManager else { return [:] } @@ -787,6 +788,7 @@ class TerminalController { } return pidsByWorkspace } + PortScanner.shared.setTrackedAgentScanningPaused(!NSApplication.shared.isActive) } nonisolated func socketListenerHealth(expectedSocketPath: String) -> SocketListenerHealth { diff --git a/cmuxTests/PaneMemoryGuardrailTests.swift b/cmuxTests/PaneMemoryGuardrailTests.swift index 2fff25741a11..871fe1e07681 100644 --- a/cmuxTests/PaneMemoryGuardrailTests.swift +++ b/cmuxTests/PaneMemoryGuardrailTests.swift @@ -400,6 +400,28 @@ struct PaneMemoryGuardrailTests { #expect(workspaceIds.contains(secondWindowWorkspace.id)) } + @MainActor + @Test + func appDelegateGuardrailDescriptorsKeepBackgroundWorkspacesLive() throws { + let app = AppDelegate() + let manager = TabManager() + let windowId = app.registerMainWindowContextForTesting(tabManager: manager) + defer { app.unregisterMainWindowContextForTesting(windowId: windowId) } + + let firstWorkspace = try #require(manager.selectedWorkspace) + let backgroundWorkspace = manager.addWorkspace(title: "Background", select: false) + + let initialWorkspaceIds = Set(app.paneMemoryGuardrailDescriptors().map(\.workspaceId)) + #expect(initialWorkspaceIds.contains(firstWorkspace.id)) + #expect(initialWorkspaceIds.contains(backgroundWorkspace.id)) + + manager.selectWorkspace(backgroundWorkspace) + + let selectedWorkspaceIds = Set(app.paneMemoryGuardrailDescriptors().map(\.workspaceId)) + #expect(selectedWorkspaceIds.contains(firstWorkspace.id)) + #expect(selectedWorkspaceIds.contains(backgroundWorkspace.id)) + } + @MainActor @Test func appDelegateGuardrailCloseRoutesThroughOwningWindowManager() throws {