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
4 changes: 4 additions & 0 deletions GhosttyTabs.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -770,6 +771,7 @@
1D301919B10F22B8708E8883 /* WorkspaceManualUnreadTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceManualUnreadTests.swift; sourceTree = "<group>"; };
EE0171AF1F49F7547191CEE5 /* SidebarWidthPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWidthPolicyTests.swift; sourceTree = "<group>"; };
491751CE2321474474F27DCF /* TerminalControllerSocketSecurityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalControllerSocketSecurityTests.swift; sourceTree = "<group>"; };
PRTS000001 /* PortScannerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PortScannerTests.swift; sourceTree = "<group>"; };
C1A2B3C4D5E6F70800000003 /* TerminalControllerShellStateDedupTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalControllerShellStateDedupTests.swift; sourceTree = "<group>"; };
10D684CFFB8CDEF89CE2D9E1 /* TabManagerSessionSnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabManagerSessionSnapshotTests.swift; sourceTree = "<group>"; };
7C135SB00000000000000003 /* GhosttyScrollPreciseBoostTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyScrollPreciseBoostTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down
101 changes: 90 additions & 11 deletions Sources/PortScanner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ final class PortScanner: @unchecked Sendable {
) -> [UUID: Set<Int>]
typealias AgentResultsValidatedHook = @Sendable (_ results: [(UUID, [Int])]) async -> Void
typealias AgentResultsApplyCompletedHook = @Sendable (_ results: [(UUID, [Int])]) -> Void
typealias LsofChunkOverride = @Sendable (_ pidsCsv: String) -> [Int: Set<Int>]

/// Callback delivers `(workspaceId, panelId, ports)` on the main actor.
var onPortsUpdated: (@MainActor (_ workspaceId: UUID, _ panelId: UUID, _ ports: [Int]) -> Void)?
Expand Down Expand Up @@ -50,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?

Expand All @@ -67,6 +73,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
Expand All @@ -82,11 +89,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()
}
Expand All @@ -112,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()
}
}
}

Expand Down Expand Up @@ -161,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.
Expand All @@ -177,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
Expand All @@ -204,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
Expand All @@ -218,6 +248,7 @@ final class PortScanner: @unchecked Sendable {
}
self.queue.async { [weak self] in
self?.finishScan(
generation: generation,
panelSnapshot: panelSnapshot,
agentPIDsByWorkspace: agentPIDsByWorkspace,
agentRevisions: agentRevisions
Expand All @@ -227,6 +258,7 @@ final class PortScanner: @unchecked Sendable {
}

private func finishScan(
generation: UInt64,
panelSnapshot: [PanelKey: String],
agentPIDsByWorkspace: [UUID: Set<Int>],
agentRevisions: [UUID: UInt64]
Expand All @@ -249,7 +281,8 @@ final class PortScanner: @unchecked Sendable {
panelResults,
workspaceIds: workspaceIds,
agentPortsByWorkspace: [:],
agentRevisions: agentRevisions
agentRevisions: agentRevisions,
applyPanelResults: generation == burstGeneration
)
return
}
Expand Down Expand Up @@ -284,7 +317,8 @@ final class PortScanner: @unchecked Sendable {
results,
workspaceIds: workspaceIds,
agentPortsByWorkspace: agentPortsByWorkspace,
agentRevisions: agentRevisions
agentRevisions: agentRevisions,
applyPanelResults: generation == burstGeneration
)
}

Expand Down Expand Up @@ -475,9 +509,10 @@ final class PortScanner: @unchecked Sendable {
_ panelResults: [(PanelKey, [Int])],
workspaceIds: Set<UUID>,
agentPortsByWorkspace: [UUID: Set<Int>],
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 {
Expand Down Expand Up @@ -670,6 +705,50 @@ final class PortScanner: @unchecked Sendable {
}

private func runLsof(pidsCsv: String) -> [Int: Set<Int>] {
let pids = pidsCsv.split(separator: ",").compactMap { Int($0) }
guard pids.count > Self.lsofMaximumPIDsPerInvocation else {
return runLsofChunk(pidsCsv: pidsCsv)
}

var result: [Int: Set<Int>] = [:]
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<Int>] {
if let lsofChunkOverride { return lsofChunkOverride(pidsCsv) }
// `lsof -nP -a -p <pids> -iTCP -sTCP:LISTEN -F pn`
let process = Process()
let pipe = Pipe()
Expand Down
Loading
Loading