diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index b974081b..2dcd5c89 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -2590,6 +2590,11 @@ struct ContentView: View { case completed(reason: String) } + private enum BackgroundWorkspacePrimePolicy { + static let timeoutSeconds: TimeInterval = 2.0 + static let pollIntervalNanoseconds: UInt64 = 50_000_000 + } + private func primeBackgroundWorkspaceIfNeeded(workspaceId: UUID) async { let shouldPrime = await MainActor.run { tabManager.pendingBackgroundWorkspaceLoadIds.contains(workspaceId) @@ -2601,7 +2606,7 @@ struct ContentView: View { dlog("workspace.backgroundPrime.start workspace=\(workspaceId.uuidString.prefix(5))") #endif - let timeout = Date().addingTimeInterval(2.0) + let timeout = Date().addingTimeInterval(BackgroundWorkspacePrimePolicy.timeoutSeconds) while !Task.isCancelled { let state = await MainActor.run { stepBackgroundWorkspacePrime(workspaceId: workspaceId) @@ -2609,7 +2614,7 @@ struct ContentView: View { switch state { case .pending: if Date() < timeout { - try? await Task.sleep(nanoseconds: 50_000_000) + try? await Task.sleep(nanoseconds: BackgroundWorkspacePrimePolicy.pollIntervalNanoseconds) continue } await MainActor.run { diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 37b07947..64863659 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -635,6 +635,7 @@ class TabManager: ObservableObject { qos: .utility ) private var initialWorkspaceGitProbeGenerationByWorkspace: [UUID: UUID] = [:] + private var initialWorkspaceGitProbeTimersByWorkspace: [UUID: [DispatchSourceTimer]] = [:] // Recent tab history for back/forward navigation (like browser history) private var tabHistory: [UUID] = [] @@ -870,6 +871,7 @@ class TabManager: ObservableObject { ) { let normalizedDirectory = normalizeDirectory(directory) let generation = UUID() + cancelInitialWorkspaceGitProbeTimers(workspaceId: workspaceId) initialWorkspaceGitProbeGenerationByWorkspace[workspaceId] = generation #if DEBUG @@ -880,9 +882,12 @@ class TabManager: ObservableObject { #endif let delays = Self.initialWorkspaceGitProbeDelays + var timers: [DispatchSourceTimer] = [] for (index, delay) in delays.enumerated() { let isLastAttempt = index == delays.count - 1 - initialWorkspaceGitProbeQueue.asyncAfter(deadline: .now() + delay) { + let timer = DispatchSource.makeTimerSource(queue: initialWorkspaceGitProbeQueue) + timer.schedule(deadline: .now() + delay, repeating: .never) + timer.setEventHandler { [weak self] in let snapshot = Self.initialWorkspaceGitMetadataSnapshot(for: normalizedDirectory) Task { @MainActor [weak self] in self?.applyInitialWorkspaceGitMetadataSnapshot( @@ -895,7 +900,25 @@ class TabManager: ObservableObject { ) } } + timers.append(timer) + timer.resume() } + initialWorkspaceGitProbeTimersByWorkspace[workspaceId] = timers + } + + private func cancelInitialWorkspaceGitProbeTimers(workspaceId: UUID) { + guard let timers = initialWorkspaceGitProbeTimersByWorkspace.removeValue(forKey: workspaceId) else { + return + } + for timer in timers { + timer.setEventHandler {} + timer.cancel() + } + } + + private func clearInitialWorkspaceGitProbe(workspaceId: UUID) { + initialWorkspaceGitProbeGenerationByWorkspace.removeValue(forKey: workspaceId) + cancelInitialWorkspaceGitProbeTimers(workspaceId: workspaceId) } private func applyInitialWorkspaceGitMetadataSnapshot( @@ -909,17 +932,17 @@ class TabManager: ObservableObject { defer { if isLastAttempt, initialWorkspaceGitProbeGenerationByWorkspace[workspaceId] == generation { - initialWorkspaceGitProbeGenerationByWorkspace.removeValue(forKey: workspaceId) + clearInitialWorkspaceGitProbe(workspaceId: workspaceId) } } guard initialWorkspaceGitProbeGenerationByWorkspace[workspaceId] == generation else { return } guard let workspace = tabs.first(where: { $0.id == workspaceId }) else { - initialWorkspaceGitProbeGenerationByWorkspace.removeValue(forKey: workspaceId) + clearInitialWorkspaceGitProbe(workspaceId: workspaceId) return } guard workspace.panels[panelId] != nil else { - initialWorkspaceGitProbeGenerationByWorkspace.removeValue(forKey: workspaceId) + clearInitialWorkspaceGitProbe(workspaceId: workspaceId) return } @@ -927,7 +950,7 @@ class TabManager: ObservableObject { workspace.panelDirectories[panelId] ?? workspace.currentDirectory ) if let currentDirectory, currentDirectory != expectedDirectory { - initialWorkspaceGitProbeGenerationByWorkspace.removeValue(forKey: workspaceId) + clearInitialWorkspaceGitProbe(workspaceId: workspaceId) #if DEBUG dlog( "workspace.gitProbe.skip workspace=\(workspaceId.uuidString.prefix(5)) " + @@ -980,7 +1003,7 @@ class TabManager: ObservableObject { process.executableURL = URL(fileURLWithPath: "/usr/bin/env") process.arguments = ["git", "-C", directory] + arguments process.standardOutput = stdout - process.standardError = Pipe() + process.standardError = FileHandle.nullDevice do { try process.run() @@ -988,12 +1011,13 @@ class TabManager: ObservableObject { return nil } + // Drain stdout while the subprocess is active so large repos cannot fill the pipe buffer. + let data = stdout.fileHandleForReading.readDataToEndOfFile() process.waitUntilExit() guard process.terminationStatus == 0 else { return nil } - let data = stdout.fileHandleForReading.readDataToEndOfFile() return String(data: data, encoding: .utf8) } @@ -1196,7 +1220,7 @@ class TabManager: ObservableObject { guard tabs.count > 1 else { return } guard let index = tabs.firstIndex(where: { $0.id == workspace.id }) else { return } sentryBreadcrumb("workspace.close", data: ["tabCount": tabs.count - 1]) - initialWorkspaceGitProbeGenerationByWorkspace.removeValue(forKey: workspace.id) + clearInitialWorkspaceGitProbe(workspaceId: workspace.id) AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: workspace.id) unwireClosedBrowserTracking(for: workspace) @@ -1218,7 +1242,7 @@ class TabManager: ObservableObject { @discardableResult func detachWorkspace(tabId: UUID) -> Workspace? { guard let index = tabs.firstIndex(where: { $0.id == tabId }) else { return nil } - initialWorkspaceGitProbeGenerationByWorkspace.removeValue(forKey: tabId) + clearInitialWorkspaceGitProbe(workspaceId: tabId) let removed = tabs.remove(at: index) unwireClosedBrowserTracking(for: removed) diff --git a/tests_v2/test_cli_new_workspace_external_git_branch_refresh.py b/tests_v2/test_cli_new_workspace_external_git_branch_refresh.py index 87625710..9e83ee0f 100644 --- a/tests_v2/test_cli_new_workspace_external_git_branch_refresh.py +++ b/tests_v2/test_cli_new_workspace_external_git_branch_refresh.py @@ -5,6 +5,7 @@ from __future__ import annotations import glob import os +import re import shutil import subprocess import sys @@ -16,7 +17,16 @@ sys.path.insert(0, str(Path(__file__).parent)) from cmux import cmux, cmuxError -SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") +def _resolve_socket_path() -> str: + socket_path = os.environ.get("CMUX_SOCKET", "").strip() + if not socket_path: + raise cmuxError("CMUX_SOCKET is required (expected /tmp/cmux-debug-.sock)") + if not re.fullmatch(r"/tmp/cmux-debug-[^/]+\.sock", socket_path): + raise cmuxError(f"CMUX_SOCKET must be a tagged debug socket, got: {socket_path!r}") + return socket_path + + +SOCKET_PATH = _resolve_socket_path() def _must(cond: bool, msg: str) -> None: