From a8030e26a09b770a40bed16976f142ebbc261505 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 27 Sep 2026 20:04:11 -0700 Subject: [PATCH 01/11] test: cover stalled SSH paste target detection --- cmuxTests/CloudImagePasteRoutingTests.swift | 35 +++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/cmuxTests/CloudImagePasteRoutingTests.swift b/cmuxTests/CloudImagePasteRoutingTests.swift index fcbedcd8cd75..3a9545791e19 100644 --- a/cmuxTests/CloudImagePasteRoutingTests.swift +++ b/cmuxTests/CloudImagePasteRoutingTests.swift @@ -121,4 +121,39 @@ struct CloudImagePasteRoutingTests { #expect(TerminalImageTransferPlanner.plan(preparedContent: .insertText("ordinary clipboard text"), target: .cloud) == .insertText("ordinary clipboard text")) } + + @Test @MainActor + func stalledAdHocSSHDetectionFallsBackToLocalPaste() async throws { + let workspace = Workspace() + let panelID = try #require(workspace.focusedPanelId) + let panel = try #require(workspace.terminalPanel(for: panelID)) + workspace.surfaceTTYNames[panelID] = "/dev/ttys15073" + let url = URL(fileURLWithPath: "/tmp/cmux-image-15073.png") + let startedAt = Date() + + let target = await panel.surface.resolvedImageTransferTargetAsync( + in: workspace, + detector: { _ in + Thread.sleep(forTimeInterval: 1) + return DetectedSSHSession( + destination: "slow-host", port: nil, identityFile: nil, + configFile: nil, jumpHost: nil, controlPath: nil, + useIPv4: false, useIPv6: false, + forwardAgent: false, compressionEnabled: false, + sshOptions: [] + ) + } + ) + + #expect(Date().timeIntervalSince(startedAt) < 0.75) + #expect(target == .local) + #expect( + TerminalImageTransferPlanner.plan( + fileURLs: [url], + target: target + ) == .insertText( + TerminalImageTransferPlanner.escapeForShell(url.path) + ) + ) + } } From fd47d6af4bdbb1abdd2b72130012faa477dae45f Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 27 Sep 2026 20:11:15 -0700 Subject: [PATCH 02/11] fix: keep SSH paste detection off the main actor --- Sources/GhosttyApp+RuntimeClipboardRead.swift | 4 +- .../GhosttyNSView+PreparedImageTransfer.swift | 83 ++++++++++++++++--- Sources/GhosttyTerminalView.swift | 2 - ...rminalSSHSessionDetectionTimeoutGate.swift | 41 +++++++++ .../TerminalSSHSessionDetector+Async.swift | 47 +++++++++++ ...rminalSSHSessionDetector+ProcessInfo.swift | 64 ++++++++++++++ Sources/TerminalSSHSessionDetector.swift | 53 +----------- .../TerminalSurface+ImageTransferTarget.swift | 38 ++++++++- ...extBoxInputContainer+CloudImagePaste.swift | 30 +++++-- Sources/TextBoxInputContainer+Paste.swift | 41 +++++++-- cmux.xcodeproj/project.pbxproj | 12 +++ 11 files changed, 329 insertions(+), 86 deletions(-) create mode 100644 Sources/TerminalSSHSessionDetectionTimeoutGate.swift create mode 100644 Sources/TerminalSSHSessionDetector+Async.swift create mode 100644 Sources/TerminalSSHSessionDetector+ProcessInfo.swift diff --git a/Sources/GhosttyApp+RuntimeClipboardRead.swift b/Sources/GhosttyApp+RuntimeClipboardRead.swift index c4c9f3586c99..cab90817f563 100644 --- a/Sources/GhosttyApp+RuntimeClipboardRead.swift +++ b/Sources/GhosttyApp+RuntimeClipboardRead.swift @@ -185,8 +185,8 @@ extension GhosttyApp { case .insertText(let text): completeClipboardRequest(with: text) case .fileURLs(let fileURLs): - let target = requestTerminalSurface - .resolvedImageTransferTarget() + let target = await requestTerminalSurface + .resolvedImageTransferTargetAsync() let plan = TerminalImageTransferPlanner.plan( fileURLs: fileURLs, target: target diff --git a/Sources/GhosttyNSView+PreparedImageTransfer.swift b/Sources/GhosttyNSView+PreparedImageTransfer.swift index ef32975dc012..eda3fbf0dff1 100644 --- a/Sources/GhosttyNSView+PreparedImageTransfer.swift +++ b/Sources/GhosttyNSView+PreparedImageTransfer.swift @@ -7,16 +7,78 @@ extension GhosttyNSView { _ preparedContent: TerminalImageTransferPreparedContent, mode: TerminalImageTransferMode = .drop, onCancel: @escaping () -> Void + ) -> Bool { + switch preparedContent { + case .reject, .rejectOversizedImage: + return false + case .insertText(let text): + return terminalSurface?.sendText(text) ?? false + case .fileURLs(let fileURLs): + guard let terminalSurface else { + preparedContent.cleanupTransferredTemporaryFiles( + using: GhosttyApp.terminalPasteboard + ) + return false + } + let knownTarget = terminalSurface.resolvedImageTransferTarget(mode: mode) + guard terminalSurface.imageTransferDetectionTTY(mode: mode) != nil else { + return executePreparedImageTransfer( + preparedContent, + mode: mode, + target: knownTarget, + onCancel: onCancel + ) + } + let task = Task { @MainActor [weak self, weak terminalSurface] in + guard let self, let terminalSurface else { + preparedContent.cleanupTransferredTemporaryFiles( + using: GhosttyApp.terminalPasteboard + ) + return + } + let target = await terminalSurface + .resolvedImageTransferTargetAsync(mode: mode) + _ = self.executePreparedImageTransfer( + preparedContent, + mode: mode, + target: target, + onCancel: onCancel + ) + } + // The AppKit drop/paste callbacks are synchronous. Returning true + // acknowledges ownership while the target lookup runs off-main. + _ = task + return true + } + } + + @MainActor + @discardableResult + private func executePreparedImageTransfer( + _ preparedContent: TerminalImageTransferPreparedContent, + mode: TerminalImageTransferMode, + target: TerminalImageTransferTarget, + onCancel: @escaping () -> Void ) -> Bool { if mode == .paste, case .fileURLs(let urls) = preparedContent, - resolvedImageTransferTarget(mode: mode) == .cloud, - deferRuntimeInputDuringClipboardRead(estimatedBytes: urls.reduce(0) { $0 + $1.path.utf8.count + 256 }, replay: { [weak self] in - if let self { - _ = self.executePreparedImageTransfer(preparedContent, mode: mode, onCancel: onCancel) - } else { - preparedContent.cleanupTransferredTemporaryFiles(using: GhosttyApp.terminalPasteboard) + target == .cloud, + deferRuntimeInputDuringClipboardRead( + estimatedBytes: urls.reduce(0) { $0 + $1.path.utf8.count + 256 }, + replay: { [weak self] in + if let self { + _ = self.executePreparedImageTransfer( + preparedContent, + mode: mode, + target: target, + onCancel: onCancel + ) + } else { + preparedContent.cleanupTransferredTemporaryFiles( + using: GhosttyApp.terminalPasteboard + ) + } } - }) { + ) { return true } switch preparedContent { @@ -27,7 +89,7 @@ extension GhosttyNSView { case .fileURLs(let fileURLs): let plan = TerminalImageTransferPlanner.plan( fileURLs: fileURLs, - target: resolvedImageTransferTarget(mode: mode), + target: target, mode: mode ) guard plan != .reject else { @@ -36,10 +98,7 @@ extension GhosttyNSView { ) return false } - return executeImageTransferPlan( - plan, - onCancel: onCancel - ) + return executeImageTransferPlan(plan, onCancel: onCancel) } } diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index de0524fcdfad..4772d44c0dae 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -9966,8 +9966,6 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { } } - - #if DEBUG fileprivate enum DebugDropPayloadKind { case fileURLs diff --git a/Sources/TerminalSSHSessionDetectionTimeoutGate.swift b/Sources/TerminalSSHSessionDetectionTimeoutGate.swift new file mode 100644 index 000000000000..13d419e10e55 --- /dev/null +++ b/Sources/TerminalSSHSessionDetectionTimeoutGate.swift @@ -0,0 +1,41 @@ +import Foundation + +/// Owns the one-shot result of a bounded SSH process lookup. +actor TerminalSSHSessionDetectionTimeoutGate { + private var completed = false + private var pendingResult: DetectedSSHSession?? + private var continuation: CheckedContinuation? + private var worker: Task? + private var timeoutTask: Task? + + func install( + continuation: CheckedContinuation, + worker: Task, + timeoutTask: Task + ) { + guard !completed else { + worker.cancel() + timeoutTask.cancel() + continuation.resume(returning: pendingResult ?? nil) + return + } + self.continuation = continuation + self.worker = worker + self.timeoutTask = timeoutTask + } + + func finish(_ result: DetectedSSHSession?) { + guard !completed else { return } + completed = true + worker?.cancel() + timeoutTask?.cancel() + if let continuation { + continuation.resume(returning: result) + } else { + pendingResult = result + } + self.continuation = nil + worker = nil + timeoutTask = nil + } +} diff --git a/Sources/TerminalSSHSessionDetector+Async.swift b/Sources/TerminalSSHSessionDetector+Async.swift new file mode 100644 index 000000000000..1a7173468386 --- /dev/null +++ b/Sources/TerminalSSHSessionDetector+Async.swift @@ -0,0 +1,47 @@ +import Foundation + +extension TerminalSSHSessionDetector { + static let defaultDetectionTimeout: TimeInterval = 0.25 + + /// Resolves an ad-hoc SSH session without making the caller wait for a + /// process that may be stuck reading another process's memory. + /// + /// The synchronous detector runs on a detached worker. The deadline task + /// completes the request independently; a worker that is wedged in + /// `KERN_PROCARGS2` is intentionally abandoned and its late result is + /// ignored. + static func detectAsync( + forTTY ttyName: String, + timeout: TimeInterval = defaultDetectionTimeout, + detector: @escaping @Sendable (String) -> DetectedSSHSession? = { tty in + detect(forTTY: tty) + } + ) async -> DetectedSSHSession? { + let gate = TerminalSSHSessionDetectionTimeoutGate() + return await withTaskCancellationHandler { + await withCheckedContinuation { continuation in + let worker = Task.detached(priority: .utility) { + await gate.finish(detector(ttyName)) + } + let timeoutTask = Task.detached(priority: .utility) { + let nanoseconds = UInt64(max(0, timeout) * 1_000_000_000) + do { + try await Task.sleep(nanoseconds: nanoseconds) + } catch { + return + } + await gate.finish(nil) + } + Task { + await gate.install( + continuation: continuation, + worker: worker, + timeoutTask: timeoutTask + ) + } + } + } onCancel: { + Task { await gate.finish(nil) } + } + } +} diff --git a/Sources/TerminalSSHSessionDetector+ProcessInfo.swift b/Sources/TerminalSSHSessionDetector+ProcessInfo.swift new file mode 100644 index 000000000000..bad521aabddb --- /dev/null +++ b/Sources/TerminalSSHSessionDetector+ProcessInfo.swift @@ -0,0 +1,64 @@ +import Darwin + +extension TerminalSSHSessionDetector { + static func processSnapshots(forTTY ttyName: String) -> [ProcessSnapshot] { + guard let ttyDevice = CmuxTopProcessSnapshot.deviceIdentifier(forTTYName: ttyName), + ttyDevice <= Int64(UInt32.max) else { + return [] + } + + var capacity = 64 + while capacity <= 4096 { + var pids = [pid_t](repeating: 0, count: capacity) + let byteCount = pids.withUnsafeMutableBytes { rawBuffer in + proc_listpids( + UInt32(PROC_TTY_ONLY), + UInt32(ttyDevice), + rawBuffer.baseAddress, + Int32(rawBuffer.count) + ) + } + guard byteCount > 0 else { return [] } + let count = min(Int(byteCount) / MemoryLayout.stride, pids.count) + let snapshots = pids.prefix(count).compactMap { pid in + processSnapshot(for: pid, ttyName: ttyName) + } + if byteCount < Int32(pids.count * MemoryLayout.stride) { + return snapshots + } + capacity *= 2 + } + return [] + } + + private static func processSnapshot( + for pid: pid_t, + ttyName: String + ) -> ProcessSnapshot? { + guard pid > 0 else { return nil } + var info = proc_bsdinfo() + let expectedSize = MemoryLayout.stride + let size = proc_pidinfo( + pid, + PROC_PIDTBSDINFO, + 0, + &info, + Int32(expectedSize) + ) + guard size == expectedSize, + info.e_tdev > 0, + info.e_tpgid > 0, + info.pbi_pgid > 0 else { + return nil + } + return ProcessSnapshot( + pid: pid, + pgid: Int32(info.pbi_pgid), + tpgid: Int32(info.e_tpgid), + tty: ttyName, + executableName: CmuxTopProcessSnapshot + .fixedString(info.pbi_comm) + .lowercased() + ) + } +} diff --git a/Sources/TerminalSSHSessionDetector.swift b/Sources/TerminalSSHSessionDetector.swift index a8c85fd8ced6..49af3c2af0b4 100644 --- a/Sources/TerminalSSHSessionDetector.swift +++ b/Sources/TerminalSSHSessionDetector.swift @@ -5,7 +5,7 @@ import CmuxRemoteSession import Foundation import Darwin -struct DetectedSSHSession: Equatable { +struct DetectedSSHSession: Equatable, Sendable { let destination: String let port: Int? let identityFile: String? @@ -535,11 +535,6 @@ enum TerminalSSHSessionDetector { ) } - private static let psPath = "/bin/ps" - private static let noArgumentFlags = Set("46AaCfGgKkMNnqsTtVvXxYy") - private static let nonInteractiveFlags = Set("nTGV") - private static let valueArgumentFlags = Set("BbcDEeFIiJLlmOopQRSWw") - private static func normalizeTTYName(_ ttyName: String) -> String { let trimmed = ttyName.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return "" } @@ -641,52 +636,6 @@ enum TerminalSSHSessionDetector { return key == "sessiontype" && value == "none" } - private static func processSnapshots(forTTY ttyName: String) -> [ProcessSnapshot] { - let process = Process() - let pipe = Pipe() - process.executableURL = URL(fileURLWithPath: psPath) - process.arguments = ["-ww", "-t", ttyName, "-o", "pid=,pgid=,tpgid=,tty=,ucomm="] - process.standardInput = FileHandle.nullDevice - process.standardOutput = pipe - process.standardError = FileHandle.nullDevice - - do { - try process.run() - } catch { - return [] - } - - let data = pipe.fileHandleForReading.readDataToEndOfFileOrEmpty() - process.waitUntilExit() - - guard process.terminationStatus == 0, - let output = String(data: data, encoding: .utf8) else { - return [] - } - - return output - .split(separator: "\n") - .compactMap(parseProcessSnapshot) - } - - private static func parseProcessSnapshot(_ line: Substring) -> ProcessSnapshot? { - let parts = line.split(maxSplits: 4, whereSeparator: \.isWhitespace) - guard parts.count == 5, - let pid = Int32(parts[0]), - let pgid = Int32(parts[1]), - let tpgid = Int32(parts[2]) else { - return nil - } - - return ProcessSnapshot( - pid: pid, - pgid: pgid, - tpgid: tpgid, - tty: String(parts[3]), - executableName: String(parts[4]).trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - ) - } - static func commandLineArguments(forPID pid: Int32) -> [String]? { var mib = [CTL_KERN, KERN_PROCARGS2, pid] var size: size_t = 0 diff --git a/Sources/TerminalSurface+ImageTransferTarget.swift b/Sources/TerminalSurface+ImageTransferTarget.swift index 8e7a04a6672f..8bc6d1c3c658 100644 --- a/Sources/TerminalSurface+ImageTransferTarget.swift +++ b/Sources/TerminalSurface+ImageTransferTarget.swift @@ -25,13 +25,43 @@ extension TerminalSurface { if let target = AppDelegate.shared?.remoteTmuxController.remoteUploadTarget(forSurfaceId: id) { return .remote(target) } - if let ttyName = workspace.surfaceTTYNames[id], - let session = TerminalSSHSessionDetector.detect(forTTY: ttyName) { - return .remote(.detectedSSH(session)) - } return .local } + @MainActor + func resolvedImageTransferTargetAsync( + mode: TerminalImageTransferMode = .paste, + in workspace: Workspace? = nil, + detector: @escaping @Sendable (String) -> DetectedSSHSession? = { tty in + TerminalSSHSessionDetector.detect(forTTY: tty) + } + ) async -> TerminalImageTransferTarget { + let workspace = workspace ?? owningWorkspace() + let knownTarget = resolvedImageTransferTarget(mode: mode, in: workspace) + guard let ttyName = imageTransferDetectionTTY(mode: mode, in: workspace), + let session = await TerminalSSHSessionDetector.detectAsync( + forTTY: ttyName, + detector: detector + ) else { + return knownTarget + } + return .remote(.detectedSSH(session)) + } + + @MainActor + func imageTransferDetectionTTY( + mode: TerminalImageTransferMode = .paste, + in workspace: Workspace? = nil + ) -> String? { + let workspace = workspace ?? owningWorkspace() + guard resolvedImageTransferTarget(mode: mode, in: workspace) == .local, + let ttyName = workspace?.surfaceTTYNames[id], + !ttyName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return nil + } + return ttyName + } + @MainActor private func isManagedCloudImageTarget(in workspace: Workspace?) -> Bool { if hostedView.cloudTerminalOverlay.session != nil { return true } diff --git a/Sources/TextBoxInputContainer+CloudImagePaste.swift b/Sources/TextBoxInputContainer+CloudImagePaste.swift index 622b3585f9cc..ec22c713c3d0 100644 --- a/Sources/TextBoxInputContainer+CloudImagePaste.swift +++ b/Sources/TextBoxInputContainer+CloudImagePaste.swift @@ -17,16 +17,35 @@ extension TextBoxInputContainer { .map(\.standardizedFileURL) guard !standardizedURLs.isEmpty else { return false } + Task { @MainActor [weak self, weak textView] in + guard let self, let textView else { return } + let target = await self.surface.resolvedImageTransferTargetAsync() + self.attachFileURLs( + standardizedURLs, + into: textView, + target: target + ) + } + return true + } + + @MainActor + private func attachFileURLs( + _ fileURLs: [URL], + into textView: TextBoxInputTextView, + target: TerminalImageTransferTarget + ) { + let plan = TerminalImageTransferPlanner.plan( - fileURLs: standardizedURLs, - target: surface.resolvedImageTransferTarget(), + fileURLs: fileURLs, + target: target, mode: .paste ) switch plan { case .insertText, .insertTextSegments: textView.insertAttachments( - standardizedURLs.map { + fileURLs.map { TextBoxAttachment( localURL: $0, submissionText: TextBoxAttachment.submissionText(forLocalFileURL: $0), @@ -42,10 +61,9 @@ extension TextBoxInputContainer { return true case .pasteCloudImages: refuseCloudComposerImage() - GhosttyApp.terminalPasteboard.cleanupTransferredTemporaryImageFiles(standardizedURLs) - return true + GhosttyApp.terminalPasteboard.cleanupTransferredTemporaryImageFiles(fileURLs) case .reject: - return false + break } } diff --git a/Sources/TextBoxInputContainer+Paste.swift b/Sources/TextBoxInputContainer+Paste.swift index bc13df82a182..035a20a77f5f 100644 --- a/Sources/TextBoxInputContainer+Paste.swift +++ b/Sources/TextBoxInputContainer+Paste.swift @@ -61,13 +61,37 @@ extension TextBoxInputContainer { } publishComposerContent(from: textView) case .attachments(let preparedAttachments): - attachPreparedPasteAttachments( - preparedAttachments, - to: textView, - placeholderID: placeholderID, - validationToken: validationToken, - preparationService: preparationService - ) + Task { @MainActor [weak self] in + guard let self else { + preparationService.cleanupTransferredTemporaryFiles( + preparedContent + ) + return + } + guard self.ownsTextView(textView), + textView.canAcceptPendingAttachmentUpload( + validationToken: validationToken + ) else { + _ = textView.rollbackPendingPasteReservation( + id: placeholderID, + notifyingTextChange: false + ) + preparationService.cleanupTransferredTemporaryFiles( + preparedContent + ) + return + } + let target = await self.surface + .resolvedImageTransferTargetAsync() + self.attachPreparedPasteAttachments( + preparedAttachments, + to: textView, + placeholderID: placeholderID, + validationToken: validationToken, + target: target, + preparationService: preparationService + ) + } case .reject: if textView.removePendingAttachmentUploadPlaceholder( id: placeholderID @@ -82,6 +106,7 @@ extension TextBoxInputContainer { to textView: TextBoxInputTextView, placeholderID: UUID, validationToken: UInt64, + target: TerminalImageTransferTarget, preparationService: TerminalImageTransferPreparationService ) { guard !preparedAttachments.isEmpty else { @@ -95,7 +120,7 @@ extension TextBoxInputContainer { let fileURLs = preparedAttachments.map(\.fileURL) let plan = TerminalImageTransferPlanner.plan( fileURLs: fileURLs, - target: surface.resolvedImageTransferTarget(), + target: target, mode: .paste ) diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index fab7412d009c..8fdae75f0194 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -3616,6 +3616,9 @@ C75100020000000000000001 /* TerminalSelectionTranslation.swift in Sources */ = {isa = PBXBuildFile; fileRef = C75100020000000000000002 /* TerminalSelectionTranslation.swift */; }; C75100030000000000000001 /* TerminalSelectionTranslationAnchorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C75100030000000000000002 /* TerminalSelectionTranslationAnchorView.swift */; }; 85510003A1B2C3D4E5F60001 /* TerminalShellResolver+CurrentUser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85510004A1B2C3D4E5F60001 /* TerminalShellResolver+CurrentUser.swift */; }; + 91E525AB0D9ABD5F965A5FD7 /* TerminalSSHSessionDetectionTimeoutGate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA313712597F71E6015ABBC /* TerminalSSHSessionDetectionTimeoutGate.swift */; }; + 19C3B3B9103CA947118B64FD /* TerminalSSHSessionDetector+Async.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7611B3AEEB664825A7DD8733 /* TerminalSSHSessionDetector+Async.swift */; }; + 09B7D763834218583E039E4D /* TerminalSSHSessionDetector+ProcessInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61BF07E91B28C5740AD10DD9 /* TerminalSSHSessionDetector+ProcessInfo.swift */; }; A5001543 /* TerminalSSHSessionDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001545 /* TerminalSSHSessionDetector.swift */; }; D4A9923A0000000000000001 /* TerminalStartupRestoreCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4A9923A0000000000000002 /* TerminalStartupRestoreCoordinator.swift */; }; 99230001992300019923000E /* TerminalStartupRestoreFailureTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99230001992300019923000D /* TerminalStartupRestoreFailureTests.swift */; }; @@ -7626,6 +7629,9 @@ C75100020000000000000002 /* TerminalSelectionTranslation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalSelectionTranslation.swift; sourceTree = ""; }; C75100030000000000000002 /* TerminalSelectionTranslationAnchorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalSelectionTranslationAnchorView.swift; sourceTree = ""; }; 85510004A1B2C3D4E5F60001 /* TerminalShellResolver+CurrentUser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalShellResolver+CurrentUser.swift"; sourceTree = ""; }; + 2DA313712597F71E6015ABBC /* TerminalSSHSessionDetectionTimeoutGate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalSSHSessionDetectionTimeoutGate.swift; sourceTree = ""; }; + 7611B3AEEB664825A7DD8733 /* TerminalSSHSessionDetector+Async.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalSSHSessionDetector+Async.swift"; sourceTree = ""; }; + 61BF07E91B28C5740AD10DD9 /* TerminalSSHSessionDetector+ProcessInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalSSHSessionDetector+ProcessInfo.swift"; sourceTree = ""; }; A5001545 /* TerminalSSHSessionDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalSSHSessionDetector.swift; sourceTree = ""; }; D4A9923A0000000000000002 /* TerminalStartupRestoreCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalStartupRestoreCoordinator.swift; sourceTree = ""; }; 99230001992300019923000D /* TerminalStartupRestoreFailureTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalStartupRestoreFailureTests.swift; sourceTree = ""; }; @@ -10904,6 +10910,9 @@ 28B357957683C6E50B1C4263 /* DeviceIrxClient+Discovery.swift */, 4681424F883829B8E87F5B0B /* DevicesSidebarControls.swift */, 93F4913B51A207BA657F0241 /* DevicesPreferencesModel.swift */, + 2DA313712597F71E6015ABBC /* TerminalSSHSessionDetectionTimeoutGate.swift */, + 7611B3AEEB664825A7DD8733 /* TerminalSSHSessionDetector+Async.swift */, + 61BF07E91B28C5740AD10DD9 /* TerminalSSHSessionDetector+ProcessInfo.swift */, ); path = Sources; sourceTree = ""; @@ -15147,6 +15156,9 @@ C75100020000000000000001 /* TerminalSelectionTranslation.swift in Sources */, C75100030000000000000001 /* TerminalSelectionTranslationAnchorView.swift in Sources */, 85510003A1B2C3D4E5F60001 /* TerminalShellResolver+CurrentUser.swift in Sources */, + 91E525AB0D9ABD5F965A5FD7 /* TerminalSSHSessionDetectionTimeoutGate.swift in Sources */, + 19C3B3B9103CA947118B64FD /* TerminalSSHSessionDetector+Async.swift in Sources */, + 09B7D763834218583E039E4D /* TerminalSSHSessionDetector+ProcessInfo.swift in Sources */, A5001543 /* TerminalSSHSessionDetector.swift in Sources */, D4A9923A0000000000000001 /* TerminalStartupRestoreCoordinator.swift in Sources */, 124760000000000000000009 /* TerminalSurface+CloudImagePaste.swift in Sources */, From d839b3dd166ba3ae6ccc18de27c0503e1bfb4486 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 27 Sep 2026 20:34:14 -0700 Subject: [PATCH 03/11] test: make stalled detector regression deterministic --- Sources/TerminalSSHSessionDetector+Async.swift | 16 ++++++++++------ .../TerminalSurface+ImageTransferTarget.swift | 8 +++++++- cmuxTests/CloudImagePasteRoutingTests.swift | 9 +++++---- 3 files changed, 22 insertions(+), 11 deletions(-) diff --git a/Sources/TerminalSSHSessionDetector+Async.swift b/Sources/TerminalSSHSessionDetector+Async.swift index 1a7173468386..aff07616df9d 100644 --- a/Sources/TerminalSSHSessionDetector+Async.swift +++ b/Sources/TerminalSSHSessionDetector+Async.swift @@ -3,6 +3,11 @@ import Foundation extension TerminalSSHSessionDetector { static let defaultDetectionTimeout: TimeInterval = 0.25 + static func defaultDetectionTimeoutSleep(_ timeout: TimeInterval) async { + let nanoseconds = UInt64(max(0, timeout) * 1_000_000_000) + try? await Task.sleep(nanoseconds: nanoseconds) + } + /// Resolves an ad-hoc SSH session without making the caller wait for a /// process that may be stuck reading another process's memory. /// @@ -15,6 +20,10 @@ extension TerminalSSHSessionDetector { timeout: TimeInterval = defaultDetectionTimeout, detector: @escaping @Sendable (String) -> DetectedSSHSession? = { tty in detect(forTTY: tty) + }, + timeoutSleep: @escaping @Sendable (TimeInterval) async -> Void = { + timeout in + await defaultDetectionTimeoutSleep(timeout) } ) async -> DetectedSSHSession? { let gate = TerminalSSHSessionDetectionTimeoutGate() @@ -24,12 +33,7 @@ extension TerminalSSHSessionDetector { await gate.finish(detector(ttyName)) } let timeoutTask = Task.detached(priority: .utility) { - let nanoseconds = UInt64(max(0, timeout) * 1_000_000_000) - do { - try await Task.sleep(nanoseconds: nanoseconds) - } catch { - return - } + await timeoutSleep(timeout) await gate.finish(nil) } Task { diff --git a/Sources/TerminalSurface+ImageTransferTarget.swift b/Sources/TerminalSurface+ImageTransferTarget.swift index 8bc6d1c3c658..f3b2139ed837 100644 --- a/Sources/TerminalSurface+ImageTransferTarget.swift +++ b/Sources/TerminalSurface+ImageTransferTarget.swift @@ -34,6 +34,11 @@ extension TerminalSurface { in workspace: Workspace? = nil, detector: @escaping @Sendable (String) -> DetectedSSHSession? = { tty in TerminalSSHSessionDetector.detect(forTTY: tty) + }, + timeoutSleep: @escaping @Sendable (TimeInterval) async -> Void = { + timeout in + await TerminalSSHSessionDetector + .defaultDetectionTimeoutSleep(timeout) } ) async -> TerminalImageTransferTarget { let workspace = workspace ?? owningWorkspace() @@ -41,7 +46,8 @@ extension TerminalSurface { guard let ttyName = imageTransferDetectionTTY(mode: mode, in: workspace), let session = await TerminalSSHSessionDetector.detectAsync( forTTY: ttyName, - detector: detector + detector: detector, + timeoutSleep: timeoutSleep ) else { return knownTarget } diff --git a/cmuxTests/CloudImagePasteRoutingTests.swift b/cmuxTests/CloudImagePasteRoutingTests.swift index 3a9545791e19..6bcb150990f8 100644 --- a/cmuxTests/CloudImagePasteRoutingTests.swift +++ b/cmuxTests/CloudImagePasteRoutingTests.swift @@ -129,12 +129,12 @@ struct CloudImagePasteRoutingTests { let panel = try #require(workspace.terminalPanel(for: panelID)) workspace.surfaceTTYNames[panelID] = "/dev/ttys15073" let url = URL(fileURLWithPath: "/tmp/cmux-image-15073.png") - let startedAt = Date() + let releaseDetector = DispatchSemaphore(value: 0) let target = await panel.surface.resolvedImageTransferTargetAsync( in: workspace, detector: { _ in - Thread.sleep(forTimeInterval: 1) + releaseDetector.wait() return DetectedSSHSession( destination: "slow-host", port: nil, identityFile: nil, configFile: nil, jumpHost: nil, controlPath: nil, @@ -142,10 +142,11 @@ struct CloudImagePasteRoutingTests { forwardAgent: false, compressionEnabled: false, sshOptions: [] ) - } + }, + timeoutSleep: { _ in } ) + releaseDetector.signal() - #expect(Date().timeIntervalSince(startedAt) < 0.75) #expect(target == .local) #expect( TerminalImageTransferPlanner.plan( From 7e61675b87544bce426fb485dd8ff22d01185af0 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 27 Sep 2026 20:44:00 -0700 Subject: [PATCH 04/11] fix: restore composer attachment result --- Sources/TextBoxInputContainer+CloudImagePaste.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Sources/TextBoxInputContainer+CloudImagePaste.swift b/Sources/TextBoxInputContainer+CloudImagePaste.swift index ec22c713c3d0..498731f03a4e 100644 --- a/Sources/TextBoxInputContainer+CloudImagePaste.swift +++ b/Sources/TextBoxInputContainer+CloudImagePaste.swift @@ -34,7 +34,7 @@ extension TextBoxInputContainer { _ fileURLs: [URL], into textView: TextBoxInputTextView, target: TerminalImageTransferTarget - ) { + ) -> Bool { let plan = TerminalImageTransferPlanner.plan( fileURLs: fileURLs, @@ -62,8 +62,9 @@ extension TextBoxInputContainer { case .pasteCloudImages: refuseCloudComposerImage() GhosttyApp.terminalPasteboard.cleanupTransferredTemporaryImageFiles(fileURLs) + return true case .reject: - break + return false } } From 7fc5e47e095be51f7986f672182024a72cc831be Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 27 Sep 2026 20:50:32 -0700 Subject: [PATCH 05/11] fix: revalidate paste surface after detection --- Sources/GhosttyApp+RuntimeClipboardRead.swift | 7 +++++++ Sources/GhosttyNSView+PreparedImageTransfer.swift | 6 ++++++ 2 files changed, 13 insertions(+) diff --git a/Sources/GhosttyApp+RuntimeClipboardRead.swift b/Sources/GhosttyApp+RuntimeClipboardRead.swift index cab90817f563..29a071a63ae6 100644 --- a/Sources/GhosttyApp+RuntimeClipboardRead.swift +++ b/Sources/GhosttyApp+RuntimeClipboardRead.swift @@ -187,6 +187,13 @@ extension GhosttyApp { case .fileURLs(let fileURLs): let target = await requestTerminalSurface .resolvedImageTransferTargetAsync() + guard requestSurfaceIdentity.matches(requestTerminalSurface) else { + preparedContent.cleanupTransferredTemporaryFiles( + using: terminalPasteboard + ) + completeClipboardRequest(with: "") + return + } let plan = TerminalImageTransferPlanner.plan( fileURLs: fileURLs, target: target diff --git a/Sources/GhosttyNSView+PreparedImageTransfer.swift b/Sources/GhosttyNSView+PreparedImageTransfer.swift index eda3fbf0dff1..7f8ec0003eac 100644 --- a/Sources/GhosttyNSView+PreparedImageTransfer.swift +++ b/Sources/GhosttyNSView+PreparedImageTransfer.swift @@ -38,6 +38,12 @@ extension GhosttyNSView { } let target = await terminalSurface .resolvedImageTransferTargetAsync(mode: mode) + guard self.terminalSurface === terminalSurface else { + preparedContent.cleanupTransferredTemporaryFiles( + using: GhosttyApp.terminalPasteboard + ) + return + } _ = self.executePreparedImageTransfer( preparedContent, mode: mode, From 3438d7f05ea71aa5ff982e1dd344864d7772ca56 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 27 Sep 2026 20:56:00 -0700 Subject: [PATCH 06/11] fix: keep paste task captures valid for Swift --- Sources/TerminalSSHSessionDetectionTimeoutGate.swift | 8 ++++++-- Sources/TextBoxInputContainer+CloudImagePaste.swift | 4 ++-- Sources/TextBoxInputContainer+Paste.swift | 8 +------- 3 files changed, 9 insertions(+), 11 deletions(-) diff --git a/Sources/TerminalSSHSessionDetectionTimeoutGate.swift b/Sources/TerminalSSHSessionDetectionTimeoutGate.swift index 13d419e10e55..52bba4315603 100644 --- a/Sources/TerminalSSHSessionDetectionTimeoutGate.swift +++ b/Sources/TerminalSSHSessionDetectionTimeoutGate.swift @@ -3,7 +3,8 @@ import Foundation /// Owns the one-shot result of a bounded SSH process lookup. actor TerminalSSHSessionDetectionTimeoutGate { private var completed = false - private var pendingResult: DetectedSSHSession?? + private var hasPendingResult = false + private var pendingResult: DetectedSSHSession? private var continuation: CheckedContinuation? private var worker: Task? private var timeoutTask: Task? @@ -16,7 +17,9 @@ actor TerminalSSHSessionDetectionTimeoutGate { guard !completed else { worker.cancel() timeoutTask.cancel() - continuation.resume(returning: pendingResult ?? nil) + continuation.resume( + returning: hasPendingResult ? pendingResult : nil + ) return } self.continuation = continuation @@ -32,6 +35,7 @@ actor TerminalSSHSessionDetectionTimeoutGate { if let continuation { continuation.resume(returning: result) } else { + hasPendingResult = true pendingResult = result } self.continuation = nil diff --git a/Sources/TextBoxInputContainer+CloudImagePaste.swift b/Sources/TextBoxInputContainer+CloudImagePaste.swift index 498731f03a4e..1de79463a0d8 100644 --- a/Sources/TextBoxInputContainer+CloudImagePaste.swift +++ b/Sources/TextBoxInputContainer+CloudImagePaste.swift @@ -17,8 +17,8 @@ extension TextBoxInputContainer { .map(\.standardizedFileURL) guard !standardizedURLs.isEmpty else { return false } - Task { @MainActor [weak self, weak textView] in - guard let self, let textView else { return } + Task { @MainActor [self, weak textView] in + guard let textView else { return } let target = await self.surface.resolvedImageTransferTargetAsync() self.attachFileURLs( standardizedURLs, diff --git a/Sources/TextBoxInputContainer+Paste.swift b/Sources/TextBoxInputContainer+Paste.swift index 035a20a77f5f..5ee55e6d7a9c 100644 --- a/Sources/TextBoxInputContainer+Paste.swift +++ b/Sources/TextBoxInputContainer+Paste.swift @@ -61,13 +61,7 @@ extension TextBoxInputContainer { } publishComposerContent(from: textView) case .attachments(let preparedAttachments): - Task { @MainActor [weak self] in - guard let self else { - preparationService.cleanupTransferredTemporaryFiles( - preparedContent - ) - return - } + Task { @MainActor in guard self.ownsTextView(textView), textView.canAcceptPendingAttachmentUpload( validationToken: validationToken From 00377d4885ed9af9a8f262cf6e9662b43ceea0f6 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 27 Sep 2026 21:04:03 -0700 Subject: [PATCH 07/11] fix: bound concurrent SSH target lookups --- .../TerminalSSHSessionDetectionTimeoutGate.swift | 14 ++++++++++++++ Sources/TerminalSSHSessionDetector+Async.swift | 5 ++++- Sources/TerminalSSHSessionDetector.swift | 2 ++ .../TextBoxInputContainer+CloudImagePaste.swift | 1 + Sources/TextBoxInputContainer+Paste.swift | 13 +++++++++++++ cmuxTests/CloudImagePasteRoutingTests.swift | 16 +++++++++++++++- 6 files changed, 49 insertions(+), 2 deletions(-) diff --git a/Sources/TerminalSSHSessionDetectionTimeoutGate.swift b/Sources/TerminalSSHSessionDetectionTimeoutGate.swift index 52bba4315603..6d21f1d430af 100644 --- a/Sources/TerminalSSHSessionDetectionTimeoutGate.swift +++ b/Sources/TerminalSSHSessionDetectionTimeoutGate.swift @@ -1,5 +1,19 @@ import Foundation +actor TerminalSSHSessionDetectionLimiter { + private var isActive = false + + func start() -> Bool { + guard !isActive else { return false } + isActive = true + return true + } + + func finish() { + isActive = false + } +} + /// Owns the one-shot result of a bounded SSH process lookup. actor TerminalSSHSessionDetectionTimeoutGate { private var completed = false diff --git a/Sources/TerminalSSHSessionDetector+Async.swift b/Sources/TerminalSSHSessionDetector+Async.swift index aff07616df9d..bd18cf4e961f 100644 --- a/Sources/TerminalSSHSessionDetector+Async.swift +++ b/Sources/TerminalSSHSessionDetector+Async.swift @@ -26,11 +26,14 @@ extension TerminalSSHSessionDetector { await defaultDetectionTimeoutSleep(timeout) } ) async -> DetectedSSHSession? { + guard await detectionLimiter.start() else { return nil } let gate = TerminalSSHSessionDetectionTimeoutGate() return await withTaskCancellationHandler { await withCheckedContinuation { continuation in let worker = Task.detached(priority: .utility) { - await gate.finish(detector(ttyName)) + let result = detector(ttyName) + await detectionLimiter.finish() + await gate.finish(result) } let timeoutTask = Task.detached(priority: .utility) { await timeoutSleep(timeout) diff --git a/Sources/TerminalSSHSessionDetector.swift b/Sources/TerminalSSHSessionDetector.swift index 49af3c2af0b4..45aee62411af 100644 --- a/Sources/TerminalSSHSessionDetector.swift +++ b/Sources/TerminalSSHSessionDetector.swift @@ -417,6 +417,8 @@ struct DetectedSSHSession: Equatable, Sendable { } enum TerminalSSHSessionDetector { + static let detectionLimiter = TerminalSSHSessionDetectionLimiter() + struct ProcessSnapshot: Equatable { let pid: Int32 let pgid: Int32 diff --git a/Sources/TextBoxInputContainer+CloudImagePaste.swift b/Sources/TextBoxInputContainer+CloudImagePaste.swift index 1de79463a0d8..32858ced7d2c 100644 --- a/Sources/TextBoxInputContainer+CloudImagePaste.swift +++ b/Sources/TextBoxInputContainer+CloudImagePaste.swift @@ -20,6 +20,7 @@ extension TextBoxInputContainer { Task { @MainActor [self, weak textView] in guard let textView else { return } let target = await self.surface.resolvedImageTransferTargetAsync() + guard self.ownsTextView(textView) else { return } self.attachFileURLs( standardizedURLs, into: textView, diff --git a/Sources/TextBoxInputContainer+Paste.swift b/Sources/TextBoxInputContainer+Paste.swift index 5ee55e6d7a9c..347cf7dde126 100644 --- a/Sources/TextBoxInputContainer+Paste.swift +++ b/Sources/TextBoxInputContainer+Paste.swift @@ -77,6 +77,19 @@ extension TextBoxInputContainer { } let target = await self.surface .resolvedImageTransferTargetAsync() + guard self.ownsTextView(textView), + textView.canAcceptPendingAttachmentUpload( + validationToken: validationToken + ) else { + _ = textView.rollbackPendingPasteReservation( + id: placeholderID, + notifyingTextChange: false + ) + preparationService.cleanupTransferredTemporaryFiles( + preparedContent + ) + return + } self.attachPreparedPasteAttachments( preparedAttachments, to: textView, diff --git a/cmuxTests/CloudImagePasteRoutingTests.swift b/cmuxTests/CloudImagePasteRoutingTests.swift index 6bcb150990f8..812e763aabc9 100644 --- a/cmuxTests/CloudImagePasteRoutingTests.swift +++ b/cmuxTests/CloudImagePasteRoutingTests.swift @@ -12,6 +12,20 @@ import Testing @Suite("Cloud clipboard image routing") struct CloudImagePasteRoutingTests { + /// Test-only gate shared with the detached detector closure. The semaphore + /// is immutable and provides the only cross-task mutation. + private final class DetectionBlocker: @unchecked Sendable { + private let semaphore = DispatchSemaphore(value: 0) + + func wait() { + semaphore.wait() + } + + func signal() { + semaphore.signal() + } + } + @Test @MainActor func legacyCloudSSHWorkspaceKeepsItsExistingUploadRoute() throws { let workspace = Workspace() @@ -129,7 +143,7 @@ struct CloudImagePasteRoutingTests { let panel = try #require(workspace.terminalPanel(for: panelID)) workspace.surfaceTTYNames[panelID] = "/dev/ttys15073" let url = URL(fileURLWithPath: "/tmp/cmux-image-15073.png") - let releaseDetector = DispatchSemaphore(value: 0) + let releaseDetector = DetectionBlocker() let target = await panel.surface.resolvedImageTransferTargetAsync( in: workspace, From 8d01fb1edd8a534ab13c575020c8e54e1e478619 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 27 Sep 2026 21:15:38 -0700 Subject: [PATCH 08/11] fix: validate paste target generations --- Sources/GhosttyNSView+PreparedImageTransfer.swift | 4 +++- .../TerminalSSHSessionDetectionTimeoutGate.swift | 14 -------------- Sources/TerminalSSHSessionDetector+Async.swift | 5 +---- .../TerminalSSHSessionDetector+ProcessInfo.swift | 11 ++++++++--- Sources/TerminalSSHSessionDetector.swift | 4 +++- .../TextBoxInputContainer+CloudImagePaste.swift | 4 +++- Sources/TextBoxInputContainer+Paste.swift | 4 +++- 7 files changed, 21 insertions(+), 25 deletions(-) diff --git a/Sources/GhosttyNSView+PreparedImageTransfer.swift b/Sources/GhosttyNSView+PreparedImageTransfer.swift index 7f8ec0003eac..56512df8fbdb 100644 --- a/Sources/GhosttyNSView+PreparedImageTransfer.swift +++ b/Sources/GhosttyNSView+PreparedImageTransfer.swift @@ -29,6 +29,7 @@ extension GhosttyNSView { onCancel: onCancel ) } + let runtimeGeneration = terminalSurface.runtimeSurfaceGeneration let task = Task { @MainActor [weak self, weak terminalSurface] in guard let self, let terminalSurface else { preparedContent.cleanupTransferredTemporaryFiles( @@ -38,7 +39,8 @@ extension GhosttyNSView { } let target = await terminalSurface .resolvedImageTransferTargetAsync(mode: mode) - guard self.terminalSurface === terminalSurface else { + guard self.terminalSurface === terminalSurface, + terminalSurface.runtimeSurfaceGeneration == runtimeGeneration else { preparedContent.cleanupTransferredTemporaryFiles( using: GhosttyApp.terminalPasteboard ) diff --git a/Sources/TerminalSSHSessionDetectionTimeoutGate.swift b/Sources/TerminalSSHSessionDetectionTimeoutGate.swift index 6d21f1d430af..52bba4315603 100644 --- a/Sources/TerminalSSHSessionDetectionTimeoutGate.swift +++ b/Sources/TerminalSSHSessionDetectionTimeoutGate.swift @@ -1,19 +1,5 @@ import Foundation -actor TerminalSSHSessionDetectionLimiter { - private var isActive = false - - func start() -> Bool { - guard !isActive else { return false } - isActive = true - return true - } - - func finish() { - isActive = false - } -} - /// Owns the one-shot result of a bounded SSH process lookup. actor TerminalSSHSessionDetectionTimeoutGate { private var completed = false diff --git a/Sources/TerminalSSHSessionDetector+Async.swift b/Sources/TerminalSSHSessionDetector+Async.swift index bd18cf4e961f..aff07616df9d 100644 --- a/Sources/TerminalSSHSessionDetector+Async.swift +++ b/Sources/TerminalSSHSessionDetector+Async.swift @@ -26,14 +26,11 @@ extension TerminalSSHSessionDetector { await defaultDetectionTimeoutSleep(timeout) } ) async -> DetectedSSHSession? { - guard await detectionLimiter.start() else { return nil } let gate = TerminalSSHSessionDetectionTimeoutGate() return await withTaskCancellationHandler { await withCheckedContinuation { continuation in let worker = Task.detached(priority: .utility) { - let result = detector(ttyName) - await detectionLimiter.finish() - await gate.finish(result) + await gate.finish(detector(ttyName)) } let timeoutTask = Task.detached(priority: .utility) { await timeoutSleep(timeout) diff --git a/Sources/TerminalSSHSessionDetector+ProcessInfo.swift b/Sources/TerminalSSHSessionDetector+ProcessInfo.swift index bad521aabddb..65febfba7b18 100644 --- a/Sources/TerminalSSHSessionDetector+ProcessInfo.swift +++ b/Sources/TerminalSSHSessionDetector+ProcessInfo.swift @@ -21,7 +21,11 @@ extension TerminalSSHSessionDetector { guard byteCount > 0 else { return [] } let count = min(Int(byteCount) / MemoryLayout.stride, pids.count) let snapshots = pids.prefix(count).compactMap { pid in - processSnapshot(for: pid, ttyName: ttyName) + processSnapshot( + for: pid, + ttyName: ttyName, + ttyDevice: UInt32(ttyDevice) + ) } if byteCount < Int32(pids.count * MemoryLayout.stride) { return snapshots @@ -33,7 +37,8 @@ extension TerminalSSHSessionDetector { private static func processSnapshot( for pid: pid_t, - ttyName: String + ttyName: String, + ttyDevice: UInt32 ) -> ProcessSnapshot? { guard pid > 0 else { return nil } var info = proc_bsdinfo() @@ -46,7 +51,7 @@ extension TerminalSSHSessionDetector { Int32(expectedSize) ) guard size == expectedSize, - info.e_tdev > 0, + info.e_tdev == ttyDevice, info.e_tpgid > 0, info.pbi_pgid > 0 else { return nil diff --git a/Sources/TerminalSSHSessionDetector.swift b/Sources/TerminalSSHSessionDetector.swift index 45aee62411af..aa64db2141a5 100644 --- a/Sources/TerminalSSHSessionDetector.swift +++ b/Sources/TerminalSSHSessionDetector.swift @@ -417,7 +417,9 @@ struct DetectedSSHSession: Equatable, Sendable { } enum TerminalSSHSessionDetector { - static let detectionLimiter = TerminalSSHSessionDetectionLimiter() + private static let noArgumentFlags = Set("46AaCfGgKkMNnqsTtVvXxYy") + private static let nonInteractiveFlags = Set("nTGV") + private static let valueArgumentFlags = Set("BbcDEeFIiJLlmOopQRSWw") struct ProcessSnapshot: Equatable { let pid: Int32 diff --git a/Sources/TextBoxInputContainer+CloudImagePaste.swift b/Sources/TextBoxInputContainer+CloudImagePaste.swift index 32858ced7d2c..87f6ab180d9f 100644 --- a/Sources/TextBoxInputContainer+CloudImagePaste.swift +++ b/Sources/TextBoxInputContainer+CloudImagePaste.swift @@ -19,8 +19,10 @@ extension TextBoxInputContainer { Task { @MainActor [self, weak textView] in guard let textView else { return } + let runtimeGeneration = self.surface.runtimeSurfaceGeneration let target = await self.surface.resolvedImageTransferTargetAsync() - guard self.ownsTextView(textView) else { return } + guard self.surface.runtimeSurfaceGeneration == runtimeGeneration, + self.ownsTextView(textView) else { return } self.attachFileURLs( standardizedURLs, into: textView, diff --git a/Sources/TextBoxInputContainer+Paste.swift b/Sources/TextBoxInputContainer+Paste.swift index 347cf7dde126..c8a11a4a7f6a 100644 --- a/Sources/TextBoxInputContainer+Paste.swift +++ b/Sources/TextBoxInputContainer+Paste.swift @@ -75,9 +75,11 @@ extension TextBoxInputContainer { ) return } + let runtimeGeneration = self.surface.runtimeSurfaceGeneration let target = await self.surface .resolvedImageTransferTargetAsync() - guard self.ownsTextView(textView), + guard self.surface.runtimeSurfaceGeneration == runtimeGeneration, + self.ownsTextView(textView), textView.canAcceptPendingAttachmentUpload( validationToken: validationToken ) else { From 247170d6709b7cbf76b8fe12c6ef53377b308228 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 27 Sep 2026 21:53:42 -0700 Subject: [PATCH 09/11] fix: import Foundation for detection timeout --- Sources/TerminalSSHSessionDetector+Async.swift | 3 ++- Sources/TerminalSurface+ImageTransferTarget.swift | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/Sources/TerminalSSHSessionDetector+Async.swift b/Sources/TerminalSSHSessionDetector+Async.swift index aff07616df9d..00b36a153b14 100644 --- a/Sources/TerminalSSHSessionDetector+Async.swift +++ b/Sources/TerminalSSHSessionDetector+Async.swift @@ -23,7 +23,8 @@ extension TerminalSSHSessionDetector { }, timeoutSleep: @escaping @Sendable (TimeInterval) async -> Void = { timeout in - await defaultDetectionTimeoutSleep(timeout) + await TerminalSSHSessionDetector + .defaultDetectionTimeoutSleep(timeout) } ) async -> DetectedSSHSession? { let gate = TerminalSSHSessionDetectionTimeoutGate() diff --git a/Sources/TerminalSurface+ImageTransferTarget.swift b/Sources/TerminalSurface+ImageTransferTarget.swift index f3b2139ed837..dd176c52cdb0 100644 --- a/Sources/TerminalSurface+ImageTransferTarget.swift +++ b/Sources/TerminalSurface+ImageTransferTarget.swift @@ -1,4 +1,5 @@ import CmuxTerminal +import Foundation extension TerminalSurface { @MainActor From 3f6001632fcca2e6d861537094251c9ab22be33d Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 27 Sep 2026 22:28:27 -0700 Subject: [PATCH 10/11] fix: clear Swift warning budget findings --- Sources/GhosttyNSView+PreparedImageTransfer.swift | 2 +- Sources/TextBoxInputContainer+CloudImagePaste.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/GhosttyNSView+PreparedImageTransfer.swift b/Sources/GhosttyNSView+PreparedImageTransfer.swift index 56512df8fbdb..77a83c36b9c2 100644 --- a/Sources/GhosttyNSView+PreparedImageTransfer.swift +++ b/Sources/GhosttyNSView+PreparedImageTransfer.swift @@ -13,7 +13,7 @@ extension GhosttyNSView { return false case .insertText(let text): return terminalSurface?.sendText(text) ?? false - case .fileURLs(let fileURLs): + case .fileURLs: guard let terminalSurface else { preparedContent.cleanupTransferredTemporaryFiles( using: GhosttyApp.terminalPasteboard diff --git a/Sources/TextBoxInputContainer+CloudImagePaste.swift b/Sources/TextBoxInputContainer+CloudImagePaste.swift index 87f6ab180d9f..14955018462d 100644 --- a/Sources/TextBoxInputContainer+CloudImagePaste.swift +++ b/Sources/TextBoxInputContainer+CloudImagePaste.swift @@ -23,7 +23,7 @@ extension TextBoxInputContainer { let target = await self.surface.resolvedImageTransferTargetAsync() guard self.surface.runtimeSurfaceGeneration == runtimeGeneration, self.ownsTextView(textView) else { return } - self.attachFileURLs( + _ = self.attachFileURLs( standardizedURLs, into: textView, target: target From 34b95f550cc67a667b9b9926a58b474a03257883 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 27 Sep 2026 22:52:38 -0700 Subject: [PATCH 11/11] fix: address async detection review findings --- Sources/GhosttyApp+RuntimeClipboardRead.swift | 3 ++- Sources/GhosttyTerminalView.swift | 2 ++ .../TerminalSSHSessionDetector+Async.swift | 11 +++++++-- ...rminalSSHSessionDetector+ProcessInfo.swift | 24 ++++++++++++------- 4 files changed, 29 insertions(+), 11 deletions(-) diff --git a/Sources/GhosttyApp+RuntimeClipboardRead.swift b/Sources/GhosttyApp+RuntimeClipboardRead.swift index 29a071a63ae6..7cb88b0107eb 100644 --- a/Sources/GhosttyApp+RuntimeClipboardRead.swift +++ b/Sources/GhosttyApp+RuntimeClipboardRead.swift @@ -187,7 +187,8 @@ extension GhosttyApp { case .fileURLs(let fileURLs): let target = await requestTerminalSurface .resolvedImageTransferTargetAsync() - guard requestSurfaceIdentity.matches(requestTerminalSurface) else { + guard !operation.isCancelled, + requestSurfaceIdentity.matches(requestTerminalSurface) else { preparedContent.cleanupTransferredTemporaryFiles( using: terminalPasteboard ) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 4772d44c0dae..de0524fcdfad 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -9966,6 +9966,8 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { } } + + #if DEBUG fileprivate enum DebugDropPayloadKind { case fileURLs diff --git a/Sources/TerminalSSHSessionDetector+Async.swift b/Sources/TerminalSSHSessionDetector+Async.swift index 00b36a153b14..4d4272d73358 100644 --- a/Sources/TerminalSSHSessionDetector+Async.swift +++ b/Sources/TerminalSSHSessionDetector+Async.swift @@ -4,8 +4,10 @@ extension TerminalSSHSessionDetector { static let defaultDetectionTimeout: TimeInterval = 0.25 static func defaultDetectionTimeoutSleep(_ timeout: TimeInterval) async { - let nanoseconds = UInt64(max(0, timeout) * 1_000_000_000) - try? await Task.sleep(nanoseconds: nanoseconds) + let milliseconds = Int64(max(0, timeout) * 1_000) + try? await ContinuousClock().sleep( + for: .milliseconds(milliseconds) + ) } /// Resolves an ad-hoc SSH session without making the caller wait for a @@ -15,6 +17,11 @@ extension TerminalSSHSessionDetector { /// completes the request independently; a worker that is wedged in /// `KERN_PROCARGS2` is intentionally abandoned and its late result is /// ignored. +#if compiler(>=6.2) + @concurrent +#else + @Sendable +#endif static func detectAsync( forTTY ttyName: String, timeout: TimeInterval = defaultDetectionTimeout, diff --git a/Sources/TerminalSSHSessionDetector+ProcessInfo.swift b/Sources/TerminalSSHSessionDetector+ProcessInfo.swift index 65febfba7b18..12f5aca9194f 100644 --- a/Sources/TerminalSSHSessionDetector+ProcessInfo.swift +++ b/Sources/TerminalSSHSessionDetector+ProcessInfo.swift @@ -20,15 +20,23 @@ extension TerminalSSHSessionDetector { } guard byteCount > 0 else { return [] } let count = min(Int(byteCount) / MemoryLayout.stride, pids.count) - let snapshots = pids.prefix(count).compactMap { pid in - processSnapshot( - for: pid, - ttyName: ttyName, - ttyDevice: UInt32(ttyDevice) - ) - } if byteCount < Int32(pids.count * MemoryLayout.stride) { - return snapshots + return pids.prefix(count).compactMap { pid in + processSnapshot( + for: pid, + ttyName: ttyName, + ttyDevice: UInt32(ttyDevice) + ) + } + } + if capacity == 4096 { + return pids.compactMap { pid in + processSnapshot( + for: pid, + ttyName: ttyName, + ttyDevice: UInt32(ttyDevice) + ) + } } capacity *= 2 }