From dad62fd252ea065e18a41d88b7a68e7551089fd9 Mon Sep 17 00:00:00 2001 From: arzafran Date: Mon, 31 Aug 2026 14:27:39 -0300 Subject: [PATCH 1/3] test: cover PortScanner burst lifecycle --- GhosttyTabs.xcodeproj/project.pbxproj | 4 + programaTests/PortScannerTests.swift | 143 ++++++++++++++++++++++++++ 2 files changed, 147 insertions(+) create mode 100644 programaTests/PortScannerTests.swift diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 92dfbffd..b323803a 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -347,6 +347,7 @@ 0F2C25F9170130F8DC09DD1B /* WorkspaceManualUnreadTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D301919B10F22B8708E8883 /* WorkspaceManualUnreadTests.swift */; }; CA39C0304FE351A21C372429 /* SidebarWidthPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE0171AF1F49F7547191CEE5 /* SidebarWidthPolicyTests.swift */; }; 8C4BBF2DEF6DF93F395A9EE7 /* TerminalControllerSocketSecurityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 491751CE2321474474F27DCF /* TerminalControllerSocketSecurityTests.swift */; }; + PRTS000002 /* PortScannerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = PRTS000001 /* PortScannerTests.swift */; }; C1A2B3C4D5E6F70800000004 /* TerminalControllerShellStateDedupTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1A2B3C4D5E6F70800000003 /* TerminalControllerShellStateDedupTests.swift */; }; 2BB56A710BB1FC50367E5BCF /* TabManagerSessionSnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10D684CFFB8CDEF89CE2D9E1 /* TabManagerSessionSnapshotTests.swift */; }; 7C135SB00000000000000004 /* GhosttyScrollPreciseBoostTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C135SB00000000000000003 /* GhosttyScrollPreciseBoostTests.swift */; }; @@ -770,6 +771,7 @@ 1D301919B10F22B8708E8883 /* WorkspaceManualUnreadTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceManualUnreadTests.swift; sourceTree = ""; }; EE0171AF1F49F7547191CEE5 /* SidebarWidthPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWidthPolicyTests.swift; sourceTree = ""; }; 491751CE2321474474F27DCF /* TerminalControllerSocketSecurityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalControllerSocketSecurityTests.swift; sourceTree = ""; }; + PRTS000001 /* PortScannerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PortScannerTests.swift; sourceTree = ""; }; C1A2B3C4D5E6F70800000003 /* TerminalControllerShellStateDedupTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalControllerShellStateDedupTests.swift; sourceTree = ""; }; 10D684CFFB8CDEF89CE2D9E1 /* TabManagerSessionSnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabManagerSessionSnapshotTests.swift; sourceTree = ""; }; 7C135SB00000000000000003 /* GhosttyScrollPreciseBoostTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyScrollPreciseBoostTests.swift; sourceTree = ""; }; @@ -1302,6 +1304,7 @@ 1D301919B10F22B8708E8883 /* WorkspaceManualUnreadTests.swift */, EE0171AF1F49F7547191CEE5 /* SidebarWidthPolicyTests.swift */, 491751CE2321474474F27DCF /* TerminalControllerSocketSecurityTests.swift */, + PRTS000001 /* PortScannerTests.swift */, C1A2B3C4D5E6F70800000003 /* TerminalControllerShellStateDedupTests.swift */, 10D684CFFB8CDEF89CE2D9E1 /* TabManagerSessionSnapshotTests.swift */, 7C135SB00000000000000003 /* GhosttyScrollPreciseBoostTests.swift */, @@ -1790,6 +1793,7 @@ 0F2C25F9170130F8DC09DD1B /* WorkspaceManualUnreadTests.swift in Sources */, CA39C0304FE351A21C372429 /* SidebarWidthPolicyTests.swift in Sources */, 8C4BBF2DEF6DF93F395A9EE7 /* TerminalControllerSocketSecurityTests.swift in Sources */, + PRTS000002 /* PortScannerTests.swift in Sources */, C1A2B3C4D5E6F70800000004 /* TerminalControllerShellStateDedupTests.swift in Sources */, 2BB56A710BB1FC50367E5BCF /* TabManagerSessionSnapshotTests.swift in Sources */, 7C135SB00000000000000004 /* GhosttyScrollPreciseBoostTests.swift in Sources */, diff --git a/programaTests/PortScannerTests.swift b/programaTests/PortScannerTests.swift new file mode 100644 index 00000000..08e3787d --- /dev/null +++ b/programaTests/PortScannerTests.swift @@ -0,0 +1,143 @@ +import Foundation +import XCTest + +#if canImport(Programa_DEV) +@testable import Programa_DEV +#elseif canImport(Programa) +@testable import Programa +#endif + +@MainActor +final class PortScannerBurstLifecycleTests: XCTestCase { + func testUnregisteringFinalPanelCancelsQueuedBurstBeforeReplacementPanelAppears() async { + let scanner = PortScanner(observesAppVisibility: false) + let workspaceID = UUID() + let panelID = UUID() + let firstPublication = expectation(description: "initial panel scan publishes") + let staleBurstPublication = expectation( + description: "cancelled burst does not scan a replacement panel without a kick" + ) + staleBurstPublication.isInverted = true + var receivedFirstPublication = false + scanner.onPortsUpdated = { callbackWorkspaceID, callbackPanelID, _ in + guard callbackWorkspaceID == workspaceID, callbackPanelID == panelID else { return } + if receivedFirstPublication { + staleBurstPublication.fulfill() + } else { + receivedFirstPublication = true + firstPublication.fulfill() + } + } + defer { scanner.onPortsUpdated = nil } + + scanner.registerTTY(workspaceId: workspaceID, panelId: panelID, ttyName: "programa-test-old") + scanner.kick(workspaceId: workspaceID, panelId: panelID) + await fulfillment(of: [firstPublication], timeout: 3) + + scanner.unregisterPanel(workspaceId: workspaceID, panelId: panelID) + scanner.registerTTY(workspaceId: workspaceID, panelId: panelID, ttyName: "programa-test-new") + + await fulfillment(of: [staleBurstPublication], timeout: 2) + } + + func testUnregisteringOnePanelPreservesBurstForRetainedPanel() async { + let scanner = PortScanner(observesAppVisibility: false) + let workspaceID = UUID() + let removedPanelID = UUID() + let retainedPanelID = UUID() + let removedInitialPublication = expectation(description: "removed panel initially publishes") + let retainedInitialPublication = expectation(description: "retained panel initially publishes") + let retainedFollowupPublication = expectation(description: "retained panel receives next burst scan") + let removedFollowupPublication = expectation(description: "removed panel receives no later result") + removedFollowupPublication.isInverted = true + var initialPanels: Set = [] + var observingFollowup = false + scanner.onPortsUpdated = { callbackWorkspaceID, callbackPanelID, _ in + guard callbackWorkspaceID == workspaceID else { return } + if observingFollowup { + if callbackPanelID == retainedPanelID { + retainedFollowupPublication.fulfill() + } else if callbackPanelID == removedPanelID { + removedFollowupPublication.fulfill() + } + return + } + guard initialPanels.insert(callbackPanelID).inserted else { return } + if callbackPanelID == removedPanelID { + removedInitialPublication.fulfill() + } else if callbackPanelID == retainedPanelID { + retainedInitialPublication.fulfill() + } + } + defer { scanner.onPortsUpdated = nil } + + scanner.registerTTY( + workspaceId: workspaceID, + panelId: removedPanelID, + ttyName: "programa-test-removed" + ) + scanner.registerTTY( + workspaceId: workspaceID, + panelId: retainedPanelID, + ttyName: "programa-test-retained" + ) + scanner.kick(workspaceId: workspaceID, panelId: removedPanelID) + scanner.kick(workspaceId: workspaceID, panelId: retainedPanelID) + await fulfillment( + of: [removedInitialPublication, retainedInitialPublication], + timeout: 3 + ) + + observingFollowup = true + scanner.unregisterPanel(workspaceId: workspaceID, panelId: removedPanelID) + + await fulfillment( + of: [retainedFollowupPublication, removedFollowupPublication], + timeout: 2 + ) + } + + func testStalePanelCompletionIsDroppedWhileCurrentAgentResultSurvives() async { + let scanner = PortScanner(observesAppVisibility: false) + let workspaceID = UUID() + let panelID = UUID() + let agentPublication = expectation(description: "agent result survives panel generation change") + let stalePanelPublication = expectation(description: "stale panel snapshot is not published") + stalePanelPublication.isInverted = true + var receivedAgentPublication = false + scanner.onPortsUpdated = { callbackWorkspaceID, callbackPanelID, _ in + guard callbackWorkspaceID == workspaceID, callbackPanelID == panelID else { return } + stalePanelPublication.fulfill() + } + scanner.onAgentPortsUpdated = { callbackWorkspaceID, ports in + guard callbackWorkspaceID == workspaceID, !receivedAgentPublication else { return } + receivedAgentPublication = true + XCTAssertTrue(ports.isEmpty) + agentPublication.fulfill() + } + scanner.agentPIDsProvider = { callbackWorkspaceIDs in + XCTAssertEqual(callbackWorkspaceIDs, [workspaceID]) + scanner.unregisterPanel(workspaceId: workspaceID, panelId: panelID) + scanner.registerTTY( + workspaceId: workspaceID, + panelId: panelID, + ttyName: "programa-test-replacement" + ) + return [:] + } + defer { + scanner.onPortsUpdated = nil + scanner.onAgentPortsUpdated = nil + scanner.agentPIDsProvider = nil + } + + scanner.registerTTY( + workspaceId: workspaceID, + panelId: panelID, + ttyName: "programa-test-snapshot" + ) + scanner.kick(workspaceId: workspaceID, panelId: panelID) + + await fulfillment(of: [agentPublication, stalePanelPublication], timeout: 2) + } +} From 904c6a7e8c0f5516d4517a61676c6d767550d8c6 Mon Sep 17 00:00:00 2001 From: arzafran Date: Mon, 31 Aug 2026 14:29:55 -0300 Subject: [PATCH 2/3] test: expose PortScanner lsof batching --- Sources/PortScanner.swift | 11 ++++++- programaTests/PortScannerTests.swift | 43 ++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/Sources/PortScanner.swift b/Sources/PortScanner.swift index 061eba84..bfff3302 100644 --- a/Sources/PortScanner.swift +++ b/Sources/PortScanner.swift @@ -23,6 +23,7 @@ final class PortScanner: @unchecked Sendable { ) -> [UUID: Set] typealias AgentResultsValidatedHook = @Sendable (_ results: [(UUID, [Int])]) async -> Void typealias AgentResultsApplyCompletedHook = @Sendable (_ results: [(UUID, [Int])]) -> Void + typealias LsofChunkOverride = @Sendable (_ pidsCsv: String) -> [Int: Set] /// Callback delivers `(workspaceId, panelId, ports)` on the main actor. var onPortsUpdated: (@MainActor (_ workspaceId: UUID, _ panelId: UUID, _ ports: [Int]) -> Void)? @@ -67,6 +68,7 @@ final class PortScanner: @unchecked Sendable { private let agentScanOverride: AgentScanOverride? private let agentResultsValidatedHook: AgentResultsValidatedHook? private let agentResultsApplyCompletedHook: AgentResultsApplyCompletedHook? + private let lsofChunkOverride: LsofChunkOverride? /// Burst scan offsets in seconds from the start of the burst. /// Each scan fires at this absolute offset; the recursive scheduler @@ -82,11 +84,13 @@ final class PortScanner: @unchecked Sendable { observesAppVisibility: Bool = true, agentScanOverride: AgentScanOverride? = nil, agentResultsValidatedHook: AgentResultsValidatedHook? = nil, - agentResultsApplyCompletedHook: AgentResultsApplyCompletedHook? = nil + agentResultsApplyCompletedHook: AgentResultsApplyCompletedHook? = nil, + lsofChunkOverride: LsofChunkOverride? = nil ) { self.agentScanOverride = agentScanOverride self.agentResultsValidatedHook = agentResultsValidatedHook self.agentResultsApplyCompletedHook = agentResultsApplyCompletedHook + self.lsofChunkOverride = lsofChunkOverride if observesAppVisibility { registerOcclusionObserver() } @@ -670,6 +674,11 @@ final class PortScanner: @unchecked Sendable { } private func runLsof(pidsCsv: String) -> [Int: Set] { + runLsofChunk(pidsCsv: pidsCsv) + } + + private func runLsofChunk(pidsCsv: String) -> [Int: Set] { + if let lsofChunkOverride { return lsofChunkOverride(pidsCsv) } // `lsof -nP -a -p -iTCP -sTCP:LISTEN -F pn` let process = Process() let pipe = Pipe() diff --git a/programaTests/PortScannerTests.swift b/programaTests/PortScannerTests.swift index 08e3787d..bd59be33 100644 --- a/programaTests/PortScannerTests.swift +++ b/programaTests/PortScannerTests.swift @@ -1,5 +1,6 @@ import Foundation import XCTest +import os #if canImport(Programa_DEV) @testable import Programa_DEV @@ -9,6 +10,48 @@ import XCTest @MainActor final class PortScannerBurstLifecycleTests: XCTestCase { + func testLargeAgentPIDScanBatchesAndUnionsEveryChunkResult() async { + let workspaceID = UUID() + let inputPIDs = Array(1_000_000..<1_020_000) + let expectedPorts = Array(10_000..<30_000) + let recordedChunks = OSAllocatedUnfairLock(initialState: [[Int]]()) + let scanner = PortScanner( + observesAppVisibility: false, + lsofChunkOverride: { pidsCSV in + let pids = pidsCSV.split(separator: ",").compactMap { Int($0) } + recordedChunks.withLock { $0.append(pids) } + return Dictionary(uniqueKeysWithValues: pids.map { pid in + (pid, Set([pid - 990_000])) + }) + } + ) + let publication = expectation(description: "all batched lsof results publish together") + var publishedPorts: [Int]? + scanner.onAgentPortsUpdated = { callbackWorkspaceID, ports in + guard callbackWorkspaceID == workspaceID, publishedPorts == nil else { return } + publishedPorts = ports + publication.fulfill() + } + defer { scanner.onAgentPortsUpdated = nil } + + scanner.refreshAgentPorts(workspaceId: workspaceID, agentPIDs: Set(inputPIDs)) + await fulfillment(of: [publication], timeout: 10) + + let chunks = recordedChunks.withLock { $0 } + XCTAssertGreaterThan( + chunks.count, + 1, + "a single lsof argv cannot safely carry a 20,000-process agent tree" + ) + for chunk in chunks { + XCTAssertLessThanOrEqual(chunk.count, 256) + let csvBytes = chunk.map(String.init).joined(separator: ",").utf8.count + XCTAssertLessThanOrEqual(csvBytes + 256, 32 * 1024) + } + XCTAssertEqual(chunks.flatMap { $0 }, inputPIDs) + XCTAssertEqual(publishedPorts, expectedPorts) + } + func testUnregisteringFinalPanelCancelsQueuedBurstBeforeReplacementPanelAppears() async { let scanner = PortScanner(observesAppVisibility: false) let workspaceID = UUID() From 51171e897010f84d4ca701d939fca31e9776c5a9 Mon Sep 17 00:00:00 2001 From: arzafran Date: Mon, 31 Aug 2026 14:56:10 -0300 Subject: [PATCH 3/3] fix: bound PortScanner burst work --- Sources/PortScanner.swift | 92 ++++++++++++++++++++++++++++++++++----- 1 file changed, 81 insertions(+), 11 deletions(-) diff --git a/Sources/PortScanner.swift b/Sources/PortScanner.swift index bfff3302..0dea74ad 100644 --- a/Sources/PortScanner.swift +++ b/Sources/PortScanner.swift @@ -51,6 +51,11 @@ final class PortScanner: @unchecked Sendable { /// Whether a burst sequence is currently running. private var burstActive = false + /// Invalidates burst callbacks and panel results queued before the final + /// registered panel was removed. This state is owned by `queue`. + private var burstGeneration: UInt64 = 0 + private var scheduledBurstTimers: [UUID: DispatchSourceTimer] = [:] + /// Coalesce timer (200ms after first kick). private var coalesceTimer: DispatchSourceTimer? @@ -116,6 +121,16 @@ final class PortScanner: @unchecked Sendable { let key = PanelKey(workspaceId: workspaceId, panelId: panelId) ttyNames.removeValue(forKey: key) pendingKicks.remove(key) + if ttyNames.isEmpty { + burstGeneration &+= 1 + scheduledBurstTimers.values.forEach { $0.cancel() } + scheduledBurstTimers.removeAll() + burstActive = false + coalesceTimer?.cancel() + coalesceTimer = nil + } else if !pendingKicks.isEmpty, !burstActive { + startCoalesce() + } } } @@ -165,11 +180,12 @@ final class PortScanner: @unchecked Sendable { guard !pendingKicks.isEmpty else { return } burstActive = true - runBurst(index: 0) + runBurst(index: 0, generation: burstGeneration) } - private func runBurst(index: Int, burstStart: DispatchTime? = nil) { + private func runBurst(index: Int, burstStart: DispatchTime? = nil, generation: UInt64) { // Already on `queue`. + guard generation == burstGeneration else { return } guard index < Self.burstOffsets.count else { burstActive = false // If new kicks arrived during the burst, start a new coalesce cycle. @@ -181,17 +197,26 @@ final class PortScanner: @unchecked Sendable { let start = burstStart ?? .now() let deadline = start + Self.burstOffsets[index] - queue.asyncAfter(deadline: deadline) { [weak self] in + let timerID = UUID() + let timer = DispatchSource.makeTimerSource(queue: queue) + timer.schedule(deadline: deadline) + timer.setEventHandler { [weak self, weak timer] in guard let self else { return } - self.runScan() - self.runBurst(index: index + 1, burstStart: start) + guard generation == self.burstGeneration else { return } + self.scheduledBurstTimers.removeValue(forKey: timerID) + timer?.cancel() + self.runScan(generation: generation) + self.runBurst(index: index + 1, burstStart: start, generation: generation) } + scheduledBurstTimers[timerID] = timer + timer.resume() } // MARK: - Scan - private func runScan() { + private func runScan(generation requestedGeneration: UInt64? = nil) { // Already on `queue`. Snapshot which panels to scan and their TTYs. + let generation = requestedGeneration ?? burstGeneration // We scan all registered panels, not just pending ones, since ports can // appear/disappear on any panel. let panelSnapshot = ttyNames @@ -208,6 +233,7 @@ final class PortScanner: @unchecked Sendable { let agentRevisions = agentRevisionSnapshot(for: workspaceIds) guard let agentPIDsProvider, !workspaceIds.isEmpty else { finishScan( + generation: generation, panelSnapshot: panelSnapshot, agentPIDsByWorkspace: [:], agentRevisions: agentRevisions @@ -222,6 +248,7 @@ final class PortScanner: @unchecked Sendable { } self.queue.async { [weak self] in self?.finishScan( + generation: generation, panelSnapshot: panelSnapshot, agentPIDsByWorkspace: agentPIDsByWorkspace, agentRevisions: agentRevisions @@ -231,6 +258,7 @@ final class PortScanner: @unchecked Sendable { } private func finishScan( + generation: UInt64, panelSnapshot: [PanelKey: String], agentPIDsByWorkspace: [UUID: Set], agentRevisions: [UUID: UInt64] @@ -253,7 +281,8 @@ final class PortScanner: @unchecked Sendable { panelResults, workspaceIds: workspaceIds, agentPortsByWorkspace: [:], - agentRevisions: agentRevisions + agentRevisions: agentRevisions, + applyPanelResults: generation == burstGeneration ) return } @@ -288,7 +317,8 @@ final class PortScanner: @unchecked Sendable { results, workspaceIds: workspaceIds, agentPortsByWorkspace: agentPortsByWorkspace, - agentRevisions: agentRevisions + agentRevisions: agentRevisions, + applyPanelResults: generation == burstGeneration ) } @@ -479,9 +509,10 @@ final class PortScanner: @unchecked Sendable { _ panelResults: [(PanelKey, [Int])], workspaceIds: Set, agentPortsByWorkspace: [UUID: Set], - agentRevisions: [UUID: UInt64] + agentRevisions: [UUID: UInt64], + applyPanelResults: Bool ) { - let panelCallback = onPortsUpdated + let panelCallback = applyPanelResults ? onPortsUpdated : nil if let panelCallback { Task { @MainActor in for (key, ports) in panelResults { @@ -674,7 +705,46 @@ final class PortScanner: @unchecked Sendable { } private func runLsof(pidsCsv: String) -> [Int: Set] { - runLsofChunk(pidsCsv: pidsCsv) + let pids = pidsCsv.split(separator: ",").compactMap { Int($0) } + guard pids.count > Self.lsofMaximumPIDsPerInvocation else { + return runLsofChunk(pidsCsv: pidsCsv) + } + + var result: [Int: Set] = [:] + for chunk in Self.lsofPIDChunks(pids) { + let csv = chunk.map(String.init).joined(separator: ",") + for (pid, ports) in runLsofChunk(pidsCsv: csv) { + result[pid, default: []].formUnion(ports) + } + } + return result + } + + private static let lsofMaximumPIDsPerInvocation = 256 + private static let lsofArgumentByteBudget = 32 * 1024 + private static let lsofArgumentOverhead = 256 + + private static func lsofPIDChunks(_ pids: [Int]) -> [[Int]] { + var chunks: [[Int]] = [] + var chunk: [Int] = [] + var chunkBytes = 0 + + for pid in pids { + let pidBytes = String(pid).utf8.count + let additionalBytes = pidBytes + (chunk.isEmpty ? 0 : 1) + if !chunk.isEmpty, + chunk.count >= lsofMaximumPIDsPerInvocation + || chunkBytes + additionalBytes + lsofArgumentOverhead > lsofArgumentByteBudget + { + chunks.append(chunk) + chunk = [] + chunkBytes = 0 + } + chunkBytes += pidBytes + (chunk.isEmpty ? 0 : 1) + chunk.append(pid) + } + if !chunk.isEmpty { chunks.append(chunk) } + return chunks } private func runLsofChunk(pidsCsv: String) -> [Int: Set] {