From 752590162e8016b773d646f8bb2dbf2bbea1242d Mon Sep 17 00:00:00 2001 From: ejc3 Date: Wed, 22 Jul 2026 23:32:00 -0700 Subject: [PATCH 1/3] sidebar-git: give the PR refresh run-loop test something real to observe testPullRequestRefreshRepositoryDiscoveryDoesNotBlockMainRunLoop counted calls to a stubbed `git remote -v` subprocess as its proxy for "repository discovery ran". The refresh stopped spawning that process in #2797, which replaced it with in-process config parsing, so the counter sat at zero and the assertion failed. The checks after it were worse than failing: with no discovery observed, they held whether or not anything happened at all. Repository discovery is the blocking filesystem work the refresh does before it reaches the network, so that is what the test should watch. This adds GitRepositoryDiscovering for the two calls PullRequestProbeService makes while resolving candidate seeds, and lets a host inject it. GitMetadataService conforms and stays the only implementation the app installs, so behavior is unchanged; PullRequestPollService and the probe service accept the protocol instead of the concrete type, which every existing call site already satisfies. The test injects a discovery that counts and sleeps. It resolves no slugs, which keeps the refresh off the GitHub transport and away from `gh auth token`. The test now makes two claims rather than three. The invocation count is the one that can fail for a product reason, and it is the one that was broken. The run-loop tick gap stays as a coarse guard against a seconds-long stall. The old "discovery did not run on the main thread" check is gone, along with the observation box that fed it. `repositorySlugs` is a nonisolated async requirement, so SE-0338 runs it off the caller's actor however the refresh schedules it: the check passed no matter what the product did, including if discovery were rewritten to be awaited inline. A test that cannot fail is not evidence, and keeping it would have implied coverage the test does not have. The run-loop tick gap stays as a coarse guard, and its comment now says why it is loose: 45 seeds times 30ms of injected blocking is 1.35s against a 2.0s ceiling, so this test's own work cannot trip it. It fires only if the product adds a multi-second main-thread stall on top. The counter is renamed to RepositoryDiscoveryInvocationCounter, since it counts discovery calls rather than command-runner calls, and the new TabManager parameter carries a note that it overrides discovery for the pull-request refresh only. --- .../CmuxGit/GitRepositoryDiscovering.swift | 21 ++++ .../CmuxGit/PullRequestProbeService.swift | 2 +- .../Service/PullRequestPollService.swift | 4 +- Sources/TabManager.swift | 6 +- .../WorkspacePullRequestSidebarTests.swift | 99 ++++++++----------- 5 files changed, 72 insertions(+), 60 deletions(-) create mode 100644 Packages/macOS/CmuxGit/Sources/CmuxGit/GitRepositoryDiscovering.swift diff --git a/Packages/macOS/CmuxGit/Sources/CmuxGit/GitRepositoryDiscovering.swift b/Packages/macOS/CmuxGit/Sources/CmuxGit/GitRepositoryDiscovering.swift new file mode 100644 index 000000000000..55278e1684dc --- /dev/null +++ b/Packages/macOS/CmuxGit/Sources/CmuxGit/GitRepositoryDiscovering.swift @@ -0,0 +1,21 @@ +import Foundation + +/// The repository discovery a pull-request refresh does before it talks to +/// GitHub: resolving a directory to its GitHub slugs and its checked-out branch. +/// +/// Both are blocking filesystem work — they walk up for a `.git` directory and +/// read config files — so a refresh must keep them off the main thread. The +/// protocol exists so a host can observe or stand in for that work; +/// ``GitMetadataService`` is the production implementation and the only one the +/// app installs. +public protocol GitRepositoryDiscovering: Sendable { + /// Ordered, de-duplicated GitHub slugs for the repository containing + /// `directory`; empty when there is no repository or no GitHub remote. + func repositorySlugs(forDirectory directory: String) async -> [String] + + /// The ``GitCheckedOutBranch`` for the repository containing `directory`, or + /// ``GitCheckedOutBranch/notARepository`` when there is none. + func checkedOutBranch(forDirectory directory: String) async -> GitCheckedOutBranch +} + +extension GitMetadataService: GitRepositoryDiscovering {} diff --git a/Packages/macOS/CmuxGit/Sources/CmuxGit/PullRequestProbeService.swift b/Packages/macOS/CmuxGit/Sources/CmuxGit/PullRequestProbeService.swift index 8afece47d188..d81a3f414bdc 100644 --- a/Packages/macOS/CmuxGit/Sources/CmuxGit/PullRequestProbeService.swift +++ b/Packages/macOS/CmuxGit/Sources/CmuxGit/PullRequestProbeService.swift @@ -92,7 +92,7 @@ public struct PullRequestProbeService: Sendable { /// - Returns: The candidates plus repo-keyed indexes for the fetch stage. public nonisolated func resolveCandidateSeeds( _ seeds: [WorkspacePullRequestCandidateSeed], - gitMetadata: GitMetadataService + gitMetadata: any GitRepositoryDiscovering ) async -> WorkspacePullRequestCandidateResolution { var candidates: [WorkspacePullRequestCandidate] = [] candidates.reserveCapacity(seeds.count) diff --git a/Packages/macOS/CmuxSidebarGit/Sources/CmuxSidebarGit/Service/PullRequestPollService.swift b/Packages/macOS/CmuxSidebarGit/Sources/CmuxSidebarGit/Service/PullRequestPollService.swift index 8e0214cfb8de..f84bddf649a3 100644 --- a/Packages/macOS/CmuxSidebarGit/Sources/CmuxSidebarGit/Service/PullRequestPollService.swift +++ b/Packages/macOS/CmuxSidebarGit/Sources/CmuxSidebarGit/Service/PullRequestPollService.swift @@ -33,7 +33,7 @@ public final class PullRequestPollService: PullRequestProbing { // MARK: Dependencies // Resolves slugs for candidate seeds (stateless CmuxGit reader). - let gitMetadataService: GitMetadataService + let gitMetadataService: any GitRepositoryDiscovering // Fetches and matches GitHub PRs (stateless CmuxGit pipeline). let probeService: PullRequestProbeService // Drives the poll deadline and mobile-host deferral sleeps. @@ -67,7 +67,7 @@ public final class PullRequestPollService: PullRequestProbing { /// - mobileHostDeferral: Mobile-host deferral intervals. /// - debugLog: Diagnostics sink; defaults to a no-op. public init( - gitMetadataService: GitMetadataService, + gitMetadataService: any GitRepositoryDiscovering, probeService: PullRequestProbeService, clock: any GitPollClock = SystemGitPollClock(), mobileHostDeferral: MobileHostDeferralPolicy = .standard, diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index a56b1c078e88..47999c668ee3 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -470,6 +470,10 @@ class TabManager: ObservableObject { autoWelcomeIfNeeded: Bool = true, commandRunner: any CommandRunning = CommandRunner(), gitMetadataService: GitMetadataService = GitMetadataService(), + // Overrides repository discovery for the pull-request refresh only. The sidebar + // metadata service keeps using gitMetadataService directly, so injecting here + // does not change what the sidebar reads. + pullRequestRepositoryDiscovery: (any GitRepositoryDiscovering)? = nil, pullRequestProbeService: PullRequestProbeService? = nil, workspaceGitMetadataReader: (any WorkspaceGitMetadataReading)? = nil, gitPollClock: any GitPollClock = SystemGitPollClock(), @@ -513,7 +517,7 @@ class TabManager: ObservableObject { ) self.pullRequestProbeService = pullRequestProbeService let pullRequestPollService = PullRequestPollService( - gitMetadataService: gitMetadataService, + gitMetadataService: pullRequestRepositoryDiscovery ?? gitMetadataService, probeService: pullRequestProbeService, clock: gitPollClock, debugLog: sidebarGitDebugLog diff --git a/cmuxTests/WorkspacePullRequestSidebarTests.swift b/cmuxTests/WorkspacePullRequestSidebarTests.swift index d2a3236d2052..7af9fccec6fc 100644 --- a/cmuxTests/WorkspacePullRequestSidebarTests.swift +++ b/cmuxTests/WorkspacePullRequestSidebarTests.swift @@ -1,6 +1,7 @@ import XCTest import Darwin import CmuxFoundation +import CmuxGit import CmuxSidebar @@ -24,7 +25,7 @@ private struct StubCommandRunner: CommandRunning { } } -private final class CommandRunnerInvocationCounter: @unchecked Sendable { +private final class RepositoryDiscoveryInvocationCounter: @unchecked Sendable { private let lock = NSLock() private var storedValue = 0 @@ -41,29 +42,6 @@ private final class CommandRunnerInvocationCounter: @unchecked Sendable { } } -/// Records whether any observation happened on the main thread. Used to assert -/// that off-main work (e.g. PR-refresh git commands) never executes on the main -/// thread, a deterministic signal that does not depend on wall-clock timing. -private final class MainThreadObservationBox: @unchecked Sendable { - private let lock = NSLock() - private var storedObservedOnMainThread = false - - func recordCurrentThread() { - let onMain = Thread.isMainThread - lock.lock() - if onMain { - storedObservedOnMainThread = true - } - lock.unlock() - } - - var observedOnMainThread: Bool { - lock.lock() - defer { lock.unlock() } - return storedObservedOnMainThread - } -} - private final class IndexLockObserver: @unchecked Sendable { private let path: String private let queue = DispatchQueue(label: "com.cmux.tests.index-lock-observer", qos: .utility) @@ -540,32 +518,35 @@ final class WorkspacePullRequestSidebarTests: XCTestCase { } func testPullRequestRefreshRepositoryDiscoveryDoesNotBlockMainRunLoop() throws { - let invocationCounter = CommandRunnerInvocationCounter() - let gitThreadObservation = MainThreadObservationBox() - let commandDelay: TimeInterval = 0.03 - let commandRunner = StubCommandRunner { _, executable, arguments, _ in - if executable == "git", arguments == ["remote", "-v"] { - invocationCounter.increment() - gitThreadObservation.recordCurrentThread() - Thread.sleep(forTimeInterval: commandDelay) - return CommandResult( - stdout: "origin\tssh://example.invalid/not-github.git (fetch)\n", - stderr: "", - exitStatus: 0, - timedOut: false, - executionError: nil - ) + let invocationCounter = RepositoryDiscoveryInvocationCounter() + let discoveryDelay: TimeInterval = 0.03 + // Repository discovery reads git config in process; it has not shelled out + // to `git remote -v` since #2797, so stubbing a command runner observes + // nothing and leaves every assertion below vacuous. Stand in for the + // discovery seam instead. Resolving no slugs also keeps the refresh away + // from the GitHub transport and from `gh auth token`, because the fetch + // returns before it asks for an auth header when the slug map is empty. + struct BlockingRepositoryDiscovery: GitRepositoryDiscovering { + let counter: RepositoryDiscoveryInvocationCounter + let delay: TimeInterval + + func repositorySlugs(forDirectory directory: String) async -> [String] { + counter.increment() + Thread.sleep(forTimeInterval: delay) + return [] + } + + func checkedOutBranch(forDirectory directory: String) async -> GitCheckedOutBranch { + .notARepository } - return CommandResult( - stdout: "", - stderr: "", - exitStatus: 0, - timedOut: false, - executionError: nil - ) } - let manager = TabManager(commandRunner: commandRunner) + let manager = TabManager( + pullRequestRepositoryDiscovery: BlockingRepositoryDiscovery( + counter: invocationCounter, + delay: discoveryDelay + ) + ) var seededPanels: [(workspaceId: UUID, panelId: UUID)] = [] let workspaceCount = 45 var workspaces = manager.tabs @@ -591,8 +572,8 @@ final class WorkspacePullRequestSidebarTests: XCTestCase { // Generous bound far above macOS CI scheduling noise (GC, unrelated test // work, run-loop jitter can stall the main thread well past a few hundred // ms on a loaded shared runner). This catches gross main-thread blocking - // without failing on routine host jitter; the deterministic non-main-thread - // assertion below is the real regression signal. + // without failing on routine host jitter; the invocation count below is the + // assertion that can fail for a product reason. let allowedMainThreadGap: TimeInterval = 2.0 let finishedMonitoring = expectation(description: "main run loop remained responsive") let monitorStartedAt = Date() @@ -618,14 +599,20 @@ final class WorkspacePullRequestSidebarTests: XCTestCase { let result = XCTWaiter().wait(for: [finishedMonitoring], timeout: monitorDuration + 1.5) timer.invalidate() XCTAssertEqual(result, .completed) - XCTAssertGreaterThan(invocationCounter.value, 0) - // Deterministic regression signal: the blocking git work must have run off - // the main thread. This does not depend on wall-clock timing, so it cannot - // flake from host scheduling noise. - XCTAssertFalse( - gitThreadObservation.observedOnMainThread, - "Pull request refresh ran its blocking git command on the main thread" + // The load-bearing assertion. Before the discovery seam existed this test + // watched a `git remote -v` subprocess that the refresh stopped spawning in + // #2797, so the counter sat at zero and the checks below it held whether or + // not anything ran at all. + XCTAssertGreaterThan( + invocationCounter.value, + 0, + "Pull request refresh never resolved repository slugs for any seeded panel" ) + // Coarse guard only, and deliberately loose: 45 seeds x 30ms of injected + // blocking is 1.35s, under the 2.0s ceiling, so this test's own work cannot + // trip it. It fires only if the product adds a multi-second main-thread stall + // on top, which is the gross regression worth catching here. The invocation + // count above is the assertion that fails for an ordinary product change. XCTAssertLessThan( maxTickGap, allowedMainThreadGap, From ee61af8b4e95141fe0d44e562af9a05be6bb6c93 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 4 Aug 2026 02:35:02 -0700 Subject: [PATCH 2/3] chore: prepare PR 8724 origin transfer From baff71cfc78fbfb1eb01b3f9835f6a8110f2ef9b Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 4 Aug 2026 02:35:02 -0700 Subject: [PATCH 3/3] test: transfer deterministic PR refresh coverage --- .../CmuxGit/GitRepositoryDiscovering.swift | 8 +- Sources/TabManager.swift | 6 +- .../WorkspacePullRequestSidebarTests.swift | 226 ++++++++++-------- 3 files changed, 136 insertions(+), 104 deletions(-) diff --git a/Packages/macOS/CmuxGit/Sources/CmuxGit/GitRepositoryDiscovering.swift b/Packages/macOS/CmuxGit/Sources/CmuxGit/GitRepositoryDiscovering.swift index 55278e1684dc..c6211c8b9e46 100644 --- a/Packages/macOS/CmuxGit/Sources/CmuxGit/GitRepositoryDiscovering.swift +++ b/Packages/macOS/CmuxGit/Sources/CmuxGit/GitRepositoryDiscovering.swift @@ -4,10 +4,10 @@ import Foundation /// GitHub: resolving a directory to its GitHub slugs and its checked-out branch. /// /// Both are blocking filesystem work — they walk up for a `.git` directory and -/// read config files — so a refresh must keep them off the main thread. The -/// protocol exists so a host can observe or stand in for that work; -/// ``GitMetadataService`` is the production implementation and the only one the -/// app installs. +/// read config files — so a refresh must keep them off the main thread. Keeping +/// this boundary separate from GitHub transport lets the pull-request pipeline +/// resolve local candidates before it performs any authenticated network work. +/// ``GitMetadataService`` is the production implementation. public protocol GitRepositoryDiscovering: Sendable { /// Ordered, de-duplicated GitHub slugs for the repository containing /// `directory`; empty when there is no repository or no GitHub remote. diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 6271d9fd37d8..0f28410fd84f 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -474,10 +474,6 @@ class TabManager: ObservableObject { autoWelcomeIfNeeded: Bool = true, commandRunner: any CommandRunning = CommandRunner(), gitMetadataService: GitMetadataService = GitMetadataService(), - // Overrides repository discovery for the pull-request refresh only. The sidebar - // metadata service keeps using gitMetadataService directly, so injecting here - // does not change what the sidebar reads. - pullRequestRepositoryDiscovery: (any GitRepositoryDiscovering)? = nil, pullRequestProbeService: PullRequestProbeService? = nil, workspaceGitMetadataReader: (any WorkspaceGitMetadataReading)? = nil, gitPollClock: any GitPollClock = SystemGitPollClock(), @@ -532,7 +528,7 @@ class TabManager: ObservableObject { ) self.pullRequestProbeService = pullRequestProbeService let pullRequestPollService = PullRequestPollService( - gitMetadataService: pullRequestRepositoryDiscovery ?? gitMetadataService, + gitMetadataService: gitMetadataService, probeService: pullRequestProbeService, clock: gitPollClock, debugLog: sidebarGitDebugLog diff --git a/cmuxTests/WorkspacePullRequestSidebarTests.swift b/cmuxTests/WorkspacePullRequestSidebarTests.swift index 2f9f30559d14..589707bd0b8f 100644 --- a/cmuxTests/WorkspacePullRequestSidebarTests.swift +++ b/cmuxTests/WorkspacePullRequestSidebarTests.swift @@ -2,6 +2,8 @@ import XCTest import Darwin import CmuxFoundation import CmuxGit +import CmuxSettings +import CmuxSidebarGit import CmuxSidebar @@ -25,20 +27,70 @@ private struct StubCommandRunner: CommandRunning { } } -private final class RepositoryDiscoveryInvocationCounter: @unchecked Sendable { +private final class BlockingRepositoryDiscovery: GitRepositoryDiscovering, @unchecked Sendable { private let lock = NSLock() - private var storedValue = 0 + private let releaseGate = DispatchSemaphore(value: 0) + private let startedExpectation: XCTestExpectation + private let finishedExpectation: XCTestExpectation + private var storedInvocationCount = 0 + private var storedReachedCleanupDeadline = false + private var storedReleased = false + + init( + startedExpectation: XCTestExpectation, + finishedExpectation: XCTestExpectation + ) { + self.startedExpectation = startedExpectation + self.finishedExpectation = finishedExpectation + } + + func repositorySlugs(forDirectory directory: String) async -> [String] { + recordInvocationAndBlockUntilReleased() + return [] + } - func increment() { + func checkedOutBranch(forDirectory directory: String) async -> GitCheckedOutBranch { + .notARepository + } + + private func recordInvocationAndBlockUntilReleased() { lock.lock() - storedValue += 1 + storedInvocationCount += 1 lock.unlock() + + startedExpectation.fulfill() + // This deadline is only deadlock cleanup. The test releases the gate + // after it observes a queued main-run-loop turn; reaching the deadline + // is itself a failure signal. + if releaseGate.wait(timeout: .now() + 5) == .timedOut { + lock.lock() + storedReachedCleanupDeadline = true + storedReleased = true + lock.unlock() + } + finishedExpectation.fulfill() + } + + func release() { + lock.lock() + let shouldSignal = !storedReleased + storedReleased = true + lock.unlock() + if shouldSignal { + releaseGate.signal() + } + } + + var invocationCount: Int { + lock.lock() + defer { lock.unlock() } + return storedInvocationCount } - var value: Int { + var reachedCleanupDeadline: Bool { lock.lock() defer { lock.unlock() } - return storedValue + return storedReachedCleanupDeadline } } @@ -528,105 +580,89 @@ final class WorkspacePullRequestSidebarTests: XCTestCase { } func testPullRequestRefreshRepositoryDiscoveryDoesNotBlockMainRunLoop() throws { - let invocationCounter = RepositoryDiscoveryInvocationCounter() - let discoveryDelay: TimeInterval = 0.03 - // Repository discovery reads git config in process; it has not shelled out - // to `git remote -v` since #2797, so stubbing a command runner observes - // nothing and leaves every assertion below vacuous. Stand in for the - // discovery seam instead. Resolving no slugs also keeps the refresh away - // from the GitHub transport and from `gh auth token`, because the fetch - // returns before it asks for an auth header when the slug map is empty. - struct BlockingRepositoryDiscovery: GitRepositoryDiscovering { - let counter: RepositoryDiscoveryInvocationCounter - let delay: TimeInterval - - func repositorySlugs(forDirectory directory: String) async -> [String] { - counter.increment() - Thread.sleep(forTimeInterval: delay) - return [] - } - - func checkedOutBranch(forDirectory directory: String) async -> GitCheckedOutBranch { - .notARepository - } + let defaults = UserDefaults.standard + let sidebarSettings = SidebarCatalogSection() + let hideAllDetailsKey = sidebarSettings.hideAllDetails.userDefaultsKey + let previousWatchGitStatus = defaults.object(forKey: SidebarWorkspaceDetailDefaults.watchGitStatusKey) + let previousShowPullRequests = defaults.object(forKey: SidebarWorkspaceDetailDefaults.showPullRequestsKey) + let previousHideAllDetails = defaults.object(forKey: hideAllDetailsKey) + defer { + restoreUserDefault(previousWatchGitStatus, key: SidebarWorkspaceDetailDefaults.watchGitStatusKey) + restoreUserDefault(previousShowPullRequests, key: SidebarWorkspaceDetailDefaults.showPullRequestsKey) + restoreUserDefault(previousHideAllDetails, key: hideAllDetailsKey) } + defaults.set(true, forKey: SidebarWorkspaceDetailDefaults.watchGitStatusKey) + defaults.set(true, forKey: SidebarWorkspaceDetailDefaults.showPullRequestsKey) + defaults.set(false, forKey: hideAllDetailsKey) - let manager = TabManager( - pullRequestRepositoryDiscovery: BlockingRepositoryDiscovery( - counter: invocationCounter, - delay: discoveryDelay - ) + let discoveryStarted = expectation(description: "repository discovery started") + discoveryStarted.assertForOverFulfill = true + let discoveryFinished = expectation(description: "repository discovery finished") + discoveryFinished.assertForOverFulfill = true + let discovery = BlockingRepositoryDiscovery( + startedExpectation: discoveryStarted, + finishedExpectation: discoveryFinished ) - var seededPanels: [(workspaceId: UUID, panelId: UUID)] = [] - let workspaceCount = 45 - var workspaces = manager.tabs - while workspaces.count < workspaceCount { - workspaces.append(manager.addWorkspace(select: false, eagerLoadTerminal: false)) + defer { + discovery.release() } - for (index, workspace) in workspaces.enumerated() { - let panelId = try XCTUnwrap(workspace.focusedPanelId) - workspace.updatePanelDirectory( - panelId: panelId, - directory: "/tmp/cmux-pr-refresh-main-thread-\(index)" - ) - workspace.updatePanelGitBranch( - panelId: panelId, - branch: "issue-3033-\(index)", - isDirty: false - ) - seededPanels.append((workspace.id, panelId)) - } + let manager = TabManager() + let workspace = try XCTUnwrap(manager.selectedWorkspace) + let panelId = try XCTUnwrap(workspace.focusedPanelId) + workspace.updatePanelDirectory( + panelId: panelId, + directory: "/tmp/cmux-pr-refresh-main-run-loop" + ) + workspace.updatePanelGitBranch( + panelId: panelId, + branch: "issue-3033-main-run-loop", + isDirty: false + ) - let monitorDuration: TimeInterval = 0.7 - // Generous bound far above macOS CI scheduling noise (GC, unrelated test - // work, run-loop jitter can stall the main thread well past a few hundred - // ms on a loaded shared runner). This catches gross main-thread blocking - // without failing on routine host jitter; the invocation count below is the - // assertion that can fail for a product reason. - let allowedMainThreadGap: TimeInterval = 2.0 - let finishedMonitoring = expectation(description: "main run loop remained responsive") - let monitorStartedAt = Date() - var lastTickAt = monitorStartedAt - var maxTickGap: TimeInterval = 0 - let timer = Timer.scheduledTimer(withTimeInterval: 0.01, repeats: true) { timer in - let now = Date() - maxTickGap = max(maxTickGap, now.timeIntervalSince(lastTickAt)) - lastTickAt = now - if now.timeIntervalSince(monitorStartedAt) >= monitorDuration { - timer.invalidate() - finishedMonitoring.fulfill() - } + let pollService = PullRequestPollService( + gitMetadataService: discovery, + probeService: manager.pullRequestProbeService + ) + pollService.attach(host: manager) + + pollService.scheduleWorkspacePullRequestRefresh( + workspaceId: workspace.id, + panelId: panelId, + reason: "testMainRunLoopResponsiveness" + ) + + // The test releases discovery only after this queued main-run-loop turn + // executes. If discovery occupies the main thread, its cleanup deadline + // opens the gate instead and the assertions below fail. + let mainRunLoopTurnCompleted = expectation(description: "main run loop completed a queued turn") + DispatchQueue.main.async { + mainRunLoopTurnCompleted.fulfill() } - let triggerPanel = try XCTUnwrap(seededPanels.first) - manager.updateSurfaceShellActivity( - tabId: triggerPanel.workspaceId, - surfaceId: triggerPanel.panelId, - state: .promptIdle + let responsivenessResult = XCTWaiter().wait( + for: [discoveryStarted, mainRunLoopTurnCompleted], + timeout: 5 + ) + guard responsivenessResult == .completed else { + XCTFail("Repository discovery did not start while the main run loop remained responsive") + return + } + XCTAssertEqual( + discovery.invocationCount, + 1, + "Pull request refresh should resolve repository slugs once for the tracked directory" + ) + XCTAssertFalse( + discovery.reachedCleanupDeadline, + "Pull request repository discovery blocked the main run loop" ) - let result = XCTWaiter().wait(for: [finishedMonitoring], timeout: monitorDuration + 1.5) - timer.invalidate() - XCTAssertEqual(result, .completed) - // The load-bearing assertion. Before the discovery seam existed this test - // watched a `git remote -v` subprocess that the refresh stopped spawning in - // #2797, so the counter sat at zero and the checks below it held whether or - // not anything ran at all. - XCTAssertGreaterThan( - invocationCounter.value, - 0, - "Pull request refresh never resolved repository slugs for any seeded panel" - ) - // Coarse guard only, and deliberately loose: 45 seeds x 30ms of injected - // blocking is 1.35s, under the 2.0s ceiling, so this test's own work cannot - // trip it. It fires only if the product adds a multi-second main-thread stall - // on top, which is the gross regression worth catching here. The invocation - // count above is the assertion that fails for an ordinary product change. - XCTAssertLessThan( - maxTickGap, - allowedMainThreadGap, - "Pull request refresh blocked the main run loop for \(maxTickGap) seconds" + discovery.release() + XCTAssertEqual( + XCTWaiter().wait(for: [discoveryFinished], timeout: 5), + .completed, + "Repository discovery did not finish after the test released it" ) }