diff --git a/Sources/GhosttyApp+RuntimeClipboardRead.swift b/Sources/GhosttyApp+RuntimeClipboardRead.swift index c4c9f3586c99..7cb88b0107eb 100644 --- a/Sources/GhosttyApp+RuntimeClipboardRead.swift +++ b/Sources/GhosttyApp+RuntimeClipboardRead.swift @@ -185,8 +185,16 @@ extension GhosttyApp { case .insertText(let text): completeClipboardRequest(with: text) case .fileURLs(let fileURLs): - let target = requestTerminalSurface - .resolvedImageTransferTarget() + let target = await requestTerminalSurface + .resolvedImageTransferTargetAsync() + guard !operation.isCancelled, + 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 ef32975dc012..77a83c36b9c2 100644 --- a/Sources/GhosttyNSView+PreparedImageTransfer.swift +++ b/Sources/GhosttyNSView+PreparedImageTransfer.swift @@ -7,16 +7,86 @@ 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: + 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 runtimeGeneration = terminalSurface.runtimeSurfaceGeneration + 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) + guard self.terminalSurface === terminalSurface, + terminalSurface.runtimeSurfaceGeneration == runtimeGeneration else { + preparedContent.cleanupTransferredTemporaryFiles( + using: GhosttyApp.terminalPasteboard + ) + return + } + _ = 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 +97,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 +106,7 @@ extension GhosttyNSView { ) return false } - return executeImageTransferPlan( - plan, - onCancel: onCancel - ) + return executeImageTransferPlan(plan, onCancel: onCancel) } } diff --git a/Sources/TerminalSSHSessionDetectionTimeoutGate.swift b/Sources/TerminalSSHSessionDetectionTimeoutGate.swift new file mode 100644 index 000000000000..52bba4315603 --- /dev/null +++ b/Sources/TerminalSSHSessionDetectionTimeoutGate.swift @@ -0,0 +1,45 @@ +import Foundation + +/// Owns the one-shot result of a bounded SSH process lookup. +actor TerminalSSHSessionDetectionTimeoutGate { + private var completed = false + private var hasPendingResult = 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: hasPendingResult ? 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 { + hasPendingResult = true + 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..4d4272d73358 --- /dev/null +++ b/Sources/TerminalSSHSessionDetector+Async.swift @@ -0,0 +1,59 @@ +import Foundation + +extension TerminalSSHSessionDetector { + static let defaultDetectionTimeout: TimeInterval = 0.25 + + static func defaultDetectionTimeoutSleep(_ timeout: TimeInterval) async { + 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 + /// 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. +#if compiler(>=6.2) + @concurrent +#else + @Sendable +#endif + static func detectAsync( + forTTY ttyName: String, + timeout: TimeInterval = defaultDetectionTimeout, + detector: @escaping @Sendable (String) -> DetectedSSHSession? = { tty in + detect(forTTY: tty) + }, + timeoutSleep: @escaping @Sendable (TimeInterval) async -> Void = { + timeout in + await TerminalSSHSessionDetector + .defaultDetectionTimeoutSleep(timeout) + } + ) 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) { + await timeoutSleep(timeout) + 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..12f5aca9194f --- /dev/null +++ b/Sources/TerminalSSHSessionDetector+ProcessInfo.swift @@ -0,0 +1,77 @@ +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) + if byteCount < Int32(pids.count * MemoryLayout.stride) { + 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 + } + return [] + } + + private static func processSnapshot( + for pid: pid_t, + ttyName: String, + ttyDevice: UInt32 + ) -> 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 == ttyDevice, + 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..aa64db2141a5 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? @@ -417,6 +417,10 @@ struct DetectedSSHSession: Equatable { } enum TerminalSSHSessionDetector { + private static let noArgumentFlags = Set("46AaCfGgKkMNnqsTtVvXxYy") + private static let nonInteractiveFlags = Set("nTGV") + private static let valueArgumentFlags = Set("BbcDEeFIiJLlmOopQRSWw") + struct ProcessSnapshot: Equatable { let pid: Int32 let pgid: Int32 @@ -535,11 +539,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 +640,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..dd176c52cdb0 100644 --- a/Sources/TerminalSurface+ImageTransferTarget.swift +++ b/Sources/TerminalSurface+ImageTransferTarget.swift @@ -1,4 +1,5 @@ import CmuxTerminal +import Foundation extension TerminalSurface { @MainActor @@ -25,13 +26,49 @@ 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) + }, + timeoutSleep: @escaping @Sendable (TimeInterval) async -> Void = { + timeout in + await TerminalSSHSessionDetector + .defaultDetectionTimeoutSleep(timeout) + } + ) 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, + timeoutSleep: timeoutSleep + ) 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..14955018462d 100644 --- a/Sources/TextBoxInputContainer+CloudImagePaste.swift +++ b/Sources/TextBoxInputContainer+CloudImagePaste.swift @@ -17,16 +17,38 @@ extension TextBoxInputContainer { .map(\.standardizedFileURL) guard !standardizedURLs.isEmpty else { return false } + Task { @MainActor [self, weak textView] in + guard let textView else { return } + let runtimeGeneration = self.surface.runtimeSurfaceGeneration + let target = await self.surface.resolvedImageTransferTargetAsync() + guard self.surface.runtimeSurfaceGeneration == runtimeGeneration, + self.ownsTextView(textView) else { return } + _ = self.attachFileURLs( + standardizedURLs, + into: textView, + target: target + ) + } + return true + } + + @MainActor + private func attachFileURLs( + _ fileURLs: [URL], + into textView: TextBoxInputTextView, + target: TerminalImageTransferTarget + ) -> Bool { + 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,7 +64,7 @@ extension TextBoxInputContainer { return true case .pasteCloudImages: refuseCloudComposerImage() - GhosttyApp.terminalPasteboard.cleanupTransferredTemporaryImageFiles(standardizedURLs) + GhosttyApp.terminalPasteboard.cleanupTransferredTemporaryImageFiles(fileURLs) return true case .reject: return false diff --git a/Sources/TextBoxInputContainer+Paste.swift b/Sources/TextBoxInputContainer+Paste.swift index bc13df82a182..c8a11a4a7f6a 100644 --- a/Sources/TextBoxInputContainer+Paste.swift +++ b/Sources/TextBoxInputContainer+Paste.swift @@ -61,13 +61,46 @@ extension TextBoxInputContainer { } publishComposerContent(from: textView) case .attachments(let preparedAttachments): - attachPreparedPasteAttachments( - preparedAttachments, - to: textView, - placeholderID: placeholderID, - validationToken: validationToken, - preparationService: preparationService - ) + Task { @MainActor in + guard self.ownsTextView(textView), + textView.canAcceptPendingAttachmentUpload( + validationToken: validationToken + ) else { + _ = textView.rollbackPendingPasteReservation( + id: placeholderID, + notifyingTextChange: false + ) + preparationService.cleanupTransferredTemporaryFiles( + preparedContent + ) + return + } + let runtimeGeneration = self.surface.runtimeSurfaceGeneration + let target = await self.surface + .resolvedImageTransferTargetAsync() + guard self.surface.runtimeSurfaceGeneration == runtimeGeneration, + self.ownsTextView(textView), + textView.canAcceptPendingAttachmentUpload( + validationToken: validationToken + ) else { + _ = textView.rollbackPendingPasteReservation( + id: placeholderID, + notifyingTextChange: false + ) + preparationService.cleanupTransferredTemporaryFiles( + preparedContent + ) + return + } + self.attachPreparedPasteAttachments( + preparedAttachments, + to: textView, + placeholderID: placeholderID, + validationToken: validationToken, + target: target, + preparationService: preparationService + ) + } case .reject: if textView.removePendingAttachmentUploadPlaceholder( id: placeholderID @@ -82,6 +115,7 @@ extension TextBoxInputContainer { to textView: TextBoxInputTextView, placeholderID: UUID, validationToken: UInt64, + target: TerminalImageTransferTarget, preparationService: TerminalImageTransferPreparationService ) { guard !preparedAttachments.isEmpty else { @@ -95,7 +129,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 */, diff --git a/cmuxTests/CloudImagePasteRoutingTests.swift b/cmuxTests/CloudImagePasteRoutingTests.swift index fcbedcd8cd75..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() @@ -121,4 +135,40 @@ 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 releaseDetector = DetectionBlocker() + + let target = await panel.surface.resolvedImageTransferTargetAsync( + in: workspace, + detector: { _ in + releaseDetector.wait() + return DetectedSSHSession( + destination: "slow-host", port: nil, identityFile: nil, + configFile: nil, jumpHost: nil, controlPath: nil, + useIPv4: false, useIPv6: false, + forwardAgent: false, compressionEnabled: false, + sshOptions: [] + ) + }, + timeoutSleep: { _ in } + ) + releaseDetector.signal() + + #expect(target == .local) + #expect( + TerminalImageTransferPlanner.plan( + fileURLs: [url], + target: target + ) == .insertText( + TerminalImageTransferPlanner.escapeForShell(url.path) + ) + ) + } }