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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions .github/swift-file-length-budget.tsv
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
# cmux-owned Swift file length budget.
# Format: max_lines<TAB>relative 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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions Sources/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
69 changes: 59 additions & 10 deletions Sources/PortScanner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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>) -> [UUID: Set<Int>])?

Expand All @@ -35,6 +35,9 @@ final class PortScanner: @unchecked Sendable {

/// Workspaces with active agent PID tracking that need background rescans.
private var trackedAgentWorkspaces: Set<UUID> = []
private var lastAgentPortsByWorkspace: [UUID: [Int]] = [:]
private var forceAgentResultWorkspaces: Set<UUID> = []
private var trackedAgentScanningPaused = false

/// Panels that requested a scan since the last coalesce snapshot.
private var pendingKicks: Set<PanelKey> = []
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -253,6 +264,7 @@ final class PortScanner: @unchecked Sendable {
trackedAgentWorkspaces.insert(workspaceId)
}
updateAgentScanTimerLocked()
forceAgentResultWorkspaces.insert(workspaceId)

scanAgentPorts(
workspaceIds: [workspaceId],
Expand All @@ -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
Expand Down Expand Up @@ -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()
}

Expand Down Expand Up @@ -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<UUID>
) 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()
}
}
}
Expand All @@ -419,16 +464,20 @@ final class PortScanner: @unchecked Sendable {
workspaceIds: Set<UUID>,
agentPortsByWorkspace: [UUID: Set<Int>],
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)
}
Expand Down
6 changes: 4 additions & 2 deletions Sources/TerminalController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 [:] }
Expand All @@ -787,6 +788,7 @@ class TerminalController {
}
return pidsByWorkspace
}
PortScanner.shared.setTrackedAgentScanningPaused(!NSApplication.shared.isActive)
}

nonisolated func socketListenerHealth(expectedSocketPath: String) -> SocketListenerHealth {
Expand Down
22 changes: 22 additions & 0 deletions cmuxTests/PaneMemoryGuardrailTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading