From e2a4ae950541a4fae47b83b8f413834284197a31 Mon Sep 17 00:00:00 2001 From: Kaede Akino Date: Fri, 16 Oct 2026 13:42:39 +0800 Subject: [PATCH] fix(terminal): synchronize tmux size on workspace activation --- FrameworkTests/SSHPTYActivationTests.swift | 149 +++++++ .../SSH/RemoteTmuxClientIdentity.swift | 48 +++ .../SSH/RemoteTmuxRuntimeAdapter.swift | 37 +- .../SSH/SSHPTYActivationState.swift | 63 +++ .../SSH/SSHPTYChannel.swift | 70 +++- .../SSH/SSHPTYResizeState.swift | 4 + Scripts/ssh-tmux-session-fixture.py | 10 +- .../PersistentTerminalSurfaceView.swift | 4 + TFM/Features/Terminal/SSHSessionModel.swift | 30 ++ TFMTests/RemoteSessionWireTests.swift | 3 + TFMTests/TerminalActivationWindowTests.swift | 251 +++++++++++ TFMTests/TerminalActivationWireTests.swift | 388 ++++++++++++++++++ TFMTests/TerminalActivityTests.swift | 51 +++ 13 files changed, 1100 insertions(+), 8 deletions(-) create mode 100644 FrameworkTests/SSHPTYActivationTests.swift create mode 100644 Frameworks/TermindRemoteInfrastructure/SSH/RemoteTmuxClientIdentity.swift create mode 100644 Frameworks/TermindRemoteInfrastructure/SSH/SSHPTYActivationState.swift create mode 100644 TFMTests/TerminalActivationWindowTests.swift create mode 100644 TFMTests/TerminalActivationWireTests.swift create mode 100644 TFMTests/TerminalActivityTests.swift diff --git a/FrameworkTests/SSHPTYActivationTests.swift b/FrameworkTests/SSHPTYActivationTests.swift new file mode 100644 index 00000000..7b31d11f --- /dev/null +++ b/FrameworkTests/SSHPTYActivationTests.swift @@ -0,0 +1,149 @@ +import CSSH2 +import Foundation +import TermindDomain +import Testing + +@testable import TermindRemoteInfrastructure + +@Suite +struct SSHPTYActivationTests { + @Test func activationWaitsForDetectionAndOnlyClaimsEachClientOnce() throws { + var state = SSHPTYActivationState() + state.setActive(true) + #expect(state.takeRequest(resizeIsSynchronized: true) == nil) + let client = try identity() + state.updateClient(client) + let request = try takeRequest(&state) + #expect(state.isCurrent(request)) + state.finish(request) + state.updateClient(client) + state.setActive(true) + #expect(state.takeRequest(resizeIsSynchronized: true) == nil) + state.setActive(false) + state.setActive(true) + let next = try takeRequest(&state) + #expect(next != request) + #expect(!state.isCurrent(request)) + } + + @Test func viewportChangesWaitForTheLastBlockedResizeBeforeActivation() throws { + var resize = SSHPTYResizeState() + var state = SSHPTYActivationState() + state.updateClient(try identity()) + state.setActive(true) + #expect(!resize.isSynchronized) + _ = resize.beginConnection(columns: 80, rows: 24) + resize.didCreatePTY(size: SSHPTYSize(columns: 80, rows: 24)) + resize.request(SSHPTYSize(columns: 100, rows: 30)) + #expect(resize.flush { _ in LIBSSH2_ERROR_EAGAIN } == LIBSSH2_ERROR_EAGAIN) + #expect(state.takeRequest(resizeIsSynchronized: resize.isSynchronized) == nil) + resize.request(SSHPTYSize(columns: 120, rows: 40)) + state.viewportChanged() + var delivered: [SSHPTYSize] = [] + #expect( + resize.flush { + delivered.append($0) + return 0 + } == 0) + #expect(delivered == [SSHPTYSize(columns: 100, rows: 30), SSHPTYSize(columns: 120, rows: 40)]) + let request = try takeRequest(&state, synchronized: resize.isSynchronized) + state.viewportChanged() + #expect(!state.isCurrent(request)) + let latest = try takeRequest(&state, synchronized: resize.isSynchronized) + state.finish(request) + #expect(state.isCurrent(latest)) + state.finish(latest) + // Ordinary live resizing already sends SIGWINCH; do not add an exec for every grid change. + state.viewportChanged() + #expect(state.takeRequest(resizeIsSynchronized: true) == nil) + } + + @Test func failedResizeCannotActivateUsingAnUnappliedViewport() throws { + var resize = SSHPTYResizeState() + _ = resize.beginConnection(columns: 80, rows: 24) + resize.didCreatePTY(size: SSHPTYSize(columns: 80, rows: 24)) + resize.request(SSHPTYSize(columns: 120, rows: 40)) + _ = resize.flush { _ in LIBSSH2_ERROR_SOCKET_SEND } + resize.discardFailedRequest() + var state = SSHPTYActivationState() + state.setActive(true) + state.updateClient(try identity()) + #expect(state.takeRequest(resizeIsSynchronized: resize.isSynchronized) == nil) + resize.request(SSHPTYSize(columns: 120, rows: 40)) + _ = resize.flush { _ in 0 } + #expect(state.takeRequest(resizeIsSynchronized: resize.isSynchronized) != nil) + } + + @Test func blurInputDetachAndConnectionReplacementInvalidateOldRequests() throws { + for reason in ["blur", "input", "detach", "reset"] { + var state = SSHPTYActivationState() + state.setActive(true) + state.updateClient(try identity()) + let request = try takeRequest(&state) + switch reason { + case "blur": state.setActive(false) + case "input": state.noteInput() + case "detach": state.updateClient(nil) + default: state.reset() + } + #expect(!state.isCurrent(request)) + #expect(state.takeRequest(resizeIsSynchronized: true) == nil) + state.finish(request) + state.reset() + state.setActive(true) + state.updateClient(try identity()) + let next = try takeRequest(&state) + #expect(!state.isCurrent(request)) + #expect(state.isCurrent(next)) + } + } + + @Test func aNewClientCanBeActivatedWithoutAnotherLocalFocusTransition() throws { + var state = SSHPTYActivationState() + state.setActive(true) + state.updateClient(try identity()) + let old = try takeRequest(&state) + state.updateClient(try identity(process: "556")) + let next = try takeRequest(&state) + #expect(!state.isCurrent(old)) + state.finish(old) + #expect(state.isCurrent(next)) + state.updateClient(nil) + state.updateClient(try identity()) + #expect(state.takeRequest(resizeIsSynchronized: true) != nil) + } + + @Test func signalIdentityRejectsGroupsServerPIDsAndMalformedFields() throws { + for process in ["0", "-1", "-555", "+555", "2147483648", "777", "0777", "555;echo bad"] { + #expect(RemoteTmuxClientIdentity(update: update(process: process), terminal: "/dev/pts/1") == nil) + } + var value = update() + value.status = .ordinary + #expect(RemoteTmuxClientIdentity(update: value, terminal: "/dev/pts/1") == nil) + #expect(RemoteTmuxClientIdentity(update: update(), terminal: "/dev/pts/2") == nil) + value = update() + value.clientIdentity = "/dev/pts/1:555:100:extra" + #expect(RemoteTmuxClientIdentity(update: value, terminal: "/dev/pts/1") == nil) + value = update() + value.active = .init(backend: "tmux", server: "777", session: "$0;bad:99", pane: "%1", shellInstance: "") + #expect(RemoteTmuxClientIdentity(update: value, terminal: "/dev/pts/1") == nil) + #expect(try identity().process == "555") + } + + private func takeRequest( + _ state: inout SSHPTYActivationState, synchronized: Bool = true + ) throws -> SSHPTYActivationState.Request { + let request = state.takeRequest(resizeIsSynchronized: synchronized) + return try #require(request) + } + + private func identity(process: String = "555") throws -> RemoteTmuxClientIdentity { + try #require(RemoteTmuxClientIdentity(update: update(process: process), terminal: "/dev/pts/1")) + } + + private func update(process: String = "555") -> TerminalRuntimeUpdate { + .init( + status: .multiplexer, clientIdentity: "/dev/pts/1:\(process):100", + active: .init(backend: "tmux", server: "777", session: "$0:99", pane: "%1", shellInstance: "unintegrated")) + } +} diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/RemoteTmuxClientIdentity.swift b/Frameworks/TermindRemoteInfrastructure/SSH/RemoteTmuxClientIdentity.swift new file mode 100644 index 00000000..745c92b3 --- /dev/null +++ b/Frameworks/TermindRemoteInfrastructure/SSH/RemoteTmuxClientIdentity.swift @@ -0,0 +1,48 @@ +import Foundation +import TermindDomain + +/// A terminal client, not a pane process or a session ownership credential. +nonisolated struct RemoteTmuxClientIdentity: Equatable, Sendable { + let terminal: String + let process: String + let created: String + let server: String + let session: String + let sessionCreated: String + + init?(update: TerminalRuntimeUpdate, terminal: String) { + guard update.status == .multiplexer, let context = update.active, context.backend == "tmux", + Self.validTerminal(terminal) + else { return nil } + let client = + update.clientIdentity?.split(separator: ":", omittingEmptySubsequences: false).map(String.init) ?? [] + let session = context.session.split(separator: ":", omittingEmptySubsequences: false).map(String.init) + guard client.count == 3, client[0] == terminal, session.count == 2, + Self.validProcess(client[1]), Self.validProcess(context.server), Int32(client[1]) != Int32(context.server), + Self.validNumber(client[2]), Self.validNumber(session[1]), + session[0].hasPrefix("$"), Self.validNumber(String(session[0].dropFirst())) + else { return nil } + self.terminal = terminal + process = client[1] + created = client[2] + server = context.server + self.session = session[0] + sessionCreated = session[1] + } + + static func validTerminal(_ terminal: String) -> Bool { + terminal.hasPrefix("/dev/") + && terminal.utf8.allSatisfy { + (48...57).contains($0) || (65...90).contains($0) || (97...122).contains($0) + || [47, 46, 95, 45].contains($0) + } + } + + private static func validNumber(_ value: String) -> Bool { + !value.isEmpty && value.utf8.allSatisfy { (48...57).contains($0) } && UInt64(value) != nil + } + + private static func validProcess(_ value: String) -> Bool { + validNumber(value) && Int32(value).map { $0 > 0 } == true + } +} diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/RemoteTmuxRuntimeAdapter.swift b/Frameworks/TermindRemoteInfrastructure/SSH/RemoteTmuxRuntimeAdapter.swift index e05cac7e..6af877ed 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/RemoteTmuxRuntimeAdapter.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/RemoteTmuxRuntimeAdapter.swift @@ -16,13 +16,38 @@ public nonisolated struct RemoteTmuxRuntimeAdapter: TerminalRuntimeAdapter { return try Self.parse(result.stdout, terminal: terminal, charset: charset) } + func refreshSize(of client: RemoteTmuxClientIdentity) async throws { + let result = try await execute(Self.resizeCommand(client: client)) + guard result.exitCode == 0, !result.truncated, result.stdout == "TERMIND_RESIZED\n" else { + throw TerminalRuntimeError.staleContext + } + } + + static func resizeCommand(client: RemoteTmuxClientIdentity) throws -> String { + let identity = [ + client.terminal, client.process, client.created, client.server, client.session, client.sessionCreated, + ].joined(separator: "|") + // Revalidate on the server immediately before signalling. A detached client's TTY + // may already contain a shell, so this operation must never write terminal input. + return """ + export LC_ALL=C + client=$(tmux list-clients -F '#{client_tty}|#{client_pid}|#{client_created}|#{pid}|#{session_id}|#{session_created}|#{client_flags}' 2>/dev/null | \ + while IFS='|' read -r tty cp cc sp sid sc flags; do + if [ "$tty" = \(try RemoteTmuxCommands.quote(client.terminal)) ]; then + case ",$flags," in + *,read-only,*|*,ignore-size,*|*,active-pane,*|*,control-mode,*) continue ;; + esac + printf '%s|%s|%s|%s|%s|%s\\n' "$tty" "$cp" "$cc" "$sp" "$sid" "$sc" + fi + done) + [ "$client" = \(try RemoteTmuxCommands.quote(identity)) ] || exit 79 + kill -WINCH \(client.process) || exit 79 + printf 'TERMIND_RESIZED\\n' + """ + } + static func command(terminal: String) throws -> String { - guard terminal.hasPrefix("/dev/"), - terminal.utf8.allSatisfy({ - (48...57).contains($0) || (65...90).contains($0) || (97...122).contains($0) - || [47, 46, 95, 45].contains($0) - }) - else { throw TerminalRuntimeError.invalidResponse } + guard RemoteTmuxClientIdentity.validTerminal(terminal) else { throw TerminalRuntimeError.invalidResponse } return """ export LC_ALL=C command -v tmux >/dev/null 2>&1 || exit 77 diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/SSHPTYActivationState.swift b/Frameworks/TermindRemoteInfrastructure/SSH/SSHPTYActivationState.swift new file mode 100644 index 00000000..0930c132 --- /dev/null +++ b/Frameworks/TermindRemoteInfrastructure/SSH/SSHPTYActivationState.swift @@ -0,0 +1,63 @@ +import Foundation + +/// Queue-confined activation intent. Runtime polling must not repeatedly claim the window. +nonisolated struct SSHPTYActivationState { + struct Request: Equatable, Sendable { + let revision: UUID + let client: RemoteTmuxClientIdentity + } + + private(set) var isActive = false + private(set) var client: RemoteTmuxClientIdentity? + private var revision = UUID() + private var pending = false + private var inFlight: Request? + + mutating func setActive(_ active: Bool) { + guard active != isActive else { return } + isActive = active + invalidate(retry: active) + } + + mutating func updateClient(_ client: RemoteTmuxClientIdentity?) { + guard self.client != client else { return } + self.client = client + invalidate(retry: isActive) + } + + mutating func viewportChanged() { + guard pending || inFlight != nil else { return } + invalidate(retry: isActive) + } + + mutating func noteInput() { + // Real input already activates tmux; never let an older control operation follow it. + invalidate(retry: false) + } + + mutating func takeRequest(resizeIsSynchronized: Bool) -> Request? { + guard pending, isActive, let client, resizeIsSynchronized else { return nil } + let request = Request(revision: revision, client: client) + pending = false + inFlight = request + return request + } + + func isCurrent(_ request: Request) -> Bool { + isActive && request.revision == revision && request.client == client && inFlight == request + } + + mutating func finish(_ request: Request) { + if inFlight == request { inFlight = nil } + } + + mutating func reset() { + self = Self() + } + + private mutating func invalidate(retry: Bool) { + revision = UUID() + pending = retry + inFlight = nil + } +} diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/SSHPTYChannel.swift b/Frameworks/TermindRemoteInfrastructure/SSH/SSHPTYChannel.swift index 99fd455a..6430b5be 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/SSHPTYChannel.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/SSHPTYChannel.swift @@ -853,6 +853,11 @@ public actor SSHPTYChannel { try await worker.sessionCommandExecutor(runtime: true) } + public func setTerminalActive(_ active: Bool) { + guard transportKind == .ssh else { return } + worker.setTerminalActive(active) + } + public func contextDraft(_ context: TerminalShellContext, replacing command: String? = nil) async throws -> String { try await worker.contextDraft(context, replacing: command) } @@ -954,6 +959,8 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { private var socketEvents: SSHSocketEvents? private let pendingWrites = SSHByteQueue(chunkSize: 32_700) private var ptyResize = SSHPTYResizeState() + private var terminalActivation = SSHPTYActivationState() + private var activationTask: (request: SSHPTYActivationState.Request, task: Task)? private var shellIntegration: RemoteShellIntegrationBootstrap? private var shellIntegrationLifecycleLogger: RemoteShellIntegrationLifecycleLogger private var commandEventFilter: RemoteCommandEventFilter? @@ -1243,8 +1250,10 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { // but never carry another connection's pending request forward. var nextResize = ptyResize let generation = nextResize.beginConnection(columns: columns, rows: rows) + let wasActive = terminalActivation.isActive cleanup() ptyResize = nextResize + terminalActivation.setActive(wasActive) return generation } let encodedEnvironment = try environmentVariables.map { variable in @@ -1369,6 +1378,8 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { queue.async { [self] in runtimeRevision &+= 1 runtimeInputRevision &+= 1 + terminalActivation.noteInput() + cancelStaleActivation() runtimeDraftBaseline = nil // Input expires in-flight operations, but does not itself prove a context switch. // Keep the last observation until inspection replaces it; operations revalidate it. @@ -1385,9 +1396,22 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { public func resize(columns: UInt16, rows: UInt16, widthPixels: UInt32, heightPixels: UInt32) { guard columns > 0, rows > 0 else { return } queue.async { [self] in + let previous = ptyResize.requestedSize ptyResize.request( SSHPTYSize(columns: columns, rows: rows, widthPixels: widthPixels, heightPixels: heightPixels) ) + if ptyResize.requestedSize != previous { + terminalActivation.viewportChanged() + cancelStaleActivation() + } + pump() + } + } + + func setTerminalActive(_ active: Bool) { + queue.async { [self] in + terminalActivation.setActive(active) + cancelStaleActivation() pump() } } @@ -1395,7 +1419,8 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { public func disconnect() async { await queue.perform { self.closeTerminalSession() } } func sessionCommandExecutor( - runtime: Bool = false, expectedInput: UInt64? = nil + runtime: Bool = false, expectedInput: UInt64? = nil, + activation: SSHPTYActivationState.Request? = nil ) async throws -> RemoteSessionCommandExecutor { let generation = try await queue.perform { [self] in guard connection != nil else { throw TermindSSHError.disconnected } @@ -1423,6 +1448,9 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { validateContext: { [self] in ptyResize.generation == generation && (expectedInput == nil || runtimeInputRevision == expectedInput) + && (activation.map { + terminalActivation.isCurrent($0) && ptyResize.isSynchronized && ptyChannel != nil + } ?? true) }) } catch { try Task.checkCancellation() @@ -2062,6 +2090,39 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { socketEvents?.update( read: !buffer.isBackpressured, write: buffer.wantsWrite || directions & LIBSSH2_SESSION_BLOCK_OUTBOUND != 0) + scheduleTerminalActivation() + } + + private func cancelStaleActivation() { + guard let activationTask, !terminalActivation.isCurrent(activationTask.request) else { return } + activationTask.task.cancel() + self.activationTask = nil + } + + private func scheduleTerminalActivation() { + guard activationTask == nil, ptyChannel != nil, runtimeEnabled, !runtimeControlUnavailable, + channelBudget?.permitsConcurrentControl == true, + let request = terminalActivation.takeRequest(resizeIsSynchronized: ptyResize.isSynchronized) + else { return } + let generation = ptyResize.generation + let inputRevision = runtimeInputRevision + let task = Task { [weak self] in + guard let self else { return } + do { + try Task.checkCancellation() + let execute = try await sessionCommandExecutor( + runtime: true, expectedInput: inputRevision, activation: request) + try await RemoteTmuxRuntimeAdapter(execute: execute).refreshSize(of: request.client) + } catch { + // Optional sizing assistance must not disconnect a usable terminal or inject a fallback key. + } + await queue.perform { [self] in + guard ptyResize.generation == generation, activationTask?.request == request else { return } + terminalActivation.finish(request) + activationTask = nil + } + } + activationTask = (request, task) } private func reportExit(_ result: Result) { @@ -2202,6 +2263,10 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { } } latestRuntimeUpdate = update + terminalActivation.updateClient( + runtimeTerminal.flatMap { RemoteTmuxClientIdentity(update: update, terminal: $0) }) + cancelStaleActivation() + scheduleTerminalActivation() detectedHistorySource = update.status == .ordinary || update.status == .multiplexer ? update.contexts.first(where: { $0.identity == update.active })?.historySource : nil @@ -2421,6 +2486,9 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { } private func stopRuntimeObservation() { + activationTask?.task.cancel() + activationTask = nil + terminalActivation.reset() runtimeTask?.cancel() runtimeTask = nil runtimeTerminal = nil diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/SSHPTYResizeState.swift b/Frameworks/TermindRemoteInfrastructure/SSH/SSHPTYResizeState.swift index c03457a3..8de2b8b0 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/SSHPTYResizeState.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/SSHPTYResizeState.swift @@ -18,6 +18,10 @@ nonisolated struct SSHPTYResizeState { private var inFlightSize: SSHPTYSize? private var acceptsRequests = true + var isSynchronized: Bool { + appliedSize != nil && inFlightSize == nil && requestedSize == appliedSize + } + mutating func beginConnection(columns: UInt16, rows: UInt16) -> UUID { let initial = generation == nil && acceptsRequests ? requestedSize : nil reset() diff --git a/Scripts/ssh-tmux-session-fixture.py b/Scripts/ssh-tmux-session-fixture.py index 47df0e7f..00b098a5 100644 --- a/Scripts/ssh-tmux-session-fixture.py +++ b/Scripts/ssh-tmux-session-fixture.py @@ -14,6 +14,11 @@ TEST_RUNNER_TERMIND_TMUX_FIXTURE=/tmp/termind-tmux.json xcodebuild \ -only-testing:TFMTests/RemoteSessionWireTests \ -only-testing:TFMTests/TerminalContextRuntimeTests \ -parallel-testing-enabled NO test + +For TerminalActivationWireTests, use --max-sessions 8 and run both +--debian-release bookworm (tmux 3.3a) and trixie (tmux 3.5a). +TerminalActivationWindowTests additionally requires TEST_RUNNER_TERMIND_WINDOW_TESTS=1; +it opens native windows and records viewport, Ghostty, PTY and tmux sizes. """ import argparse @@ -34,6 +39,9 @@ def main(): parser.add_argument("--lima", default="default") parser.add_argument("--port", type=int, default=0) parser.add_argument("--max-sessions", type=int, default=1) + parser.add_argument( + "--debian-release", choices=["bookworm", "trixie"], default="trixie", + help="bookworm supplies tmux 3.3a; trixie supplies tmux 3.5a") args = parser.parse_args() metadata = Path(args.metadata).resolve() if metadata.exists(): @@ -65,7 +73,7 @@ def main(): try: nerdctl( "run", "--detach", "--name", name, "--publish", f"127.0.0.1:{port}:22", - "debian:trixie-slim", "sleep", "infinity", stdout=subprocess.DEVNULL) + f"debian:{args.debian_release}-slim", "sleep", "infinity", stdout=subprocess.DEVNULL) print("Installing OpenSSH, tmux and shells in an isolated Lima container...", flush=True) execute( "apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends " diff --git a/TFM/Features/Terminal/PersistentTerminalSurfaceView.swift b/TFM/Features/Terminal/PersistentTerminalSurfaceView.swift index 6988372a..203e6ab0 100644 --- a/TFM/Features/Terminal/PersistentTerminalSurfaceView.swift +++ b/TFM/Features/Terminal/PersistentTerminalSurfaceView.swift @@ -569,6 +569,10 @@ synchronizeFocus(requested: true, force: true) } + var hasActiveKeyboardFocus: Bool { + terminalView.window?.isKeyWindow == true && terminalView.window?.firstResponder === terminalView + } + @discardableResult func performBindingAction(_ action: String) -> Bool { // Ghostty may make its surface first responder while dispatching a diff --git a/TFM/Features/Terminal/SSHSessionModel.swift b/TFM/Features/Terminal/SSHSessionModel.swift index 57f88c1b..562b7d55 100644 --- a/TFM/Features/Terminal/SSHSessionModel.swift +++ b/TFM/Features/Terminal/SSHSessionModel.swift @@ -1,3 +1,4 @@ +import AppKit import Combine import Foundation import GhosttyTerminal @@ -141,6 +142,8 @@ final class SSHSessionModel: RemotePathDisplaying, Identifiable { @ObservationIgnored lazy var terminalUpload = TerminalUploadModel(session: self) private var reconnectController: SessionReconnectController { connection.reconnect } private var workingDirectoryObservation: AnyCancellable? + private var terminalActivityObservation: AnyCancellable? + private(set) var isTerminalActive = false /// The live terminal's frozen route and credential IDs. Password, Key, and /// passphrase material are resolved only for each attempted transfer /// connection and never retained here. Cleared with the SSH connection. @@ -270,6 +273,18 @@ final class SSHSessionModel: RemotePathDisplaying, Identifiable { relay.owner = self commandObservation = TerminalCommandObservation(state: terminalSurface.state, monitor: commandMonitor) observeWorkingDirectory(from: surface.state, through: relay) + terminalActivityObservation = surface.state.$isFocused + .combineLatest(surface.state.$isSurfaceVisible) + .map { $0 && $1 } + .removeDuplicates() + .map { _ in () } + .merge( + with: NotificationCenter.default.publisher(for: NSWindow.didBecomeKeyNotification).map { _ in () }, + NotificationCenter.default.publisher(for: NSWindow.didResignKeyNotification).map { _ in () } + ) + .sink { [weak relay] _ in + Task { @MainActor in relay?.owner?.synchronizeTerminalActivity() } + } sessionPersistence.didChangePrompt = { [weak self] pending in if pending { self?.connectionJourney.waitForSessionSelection() } else { self?.connectionJourney.resume() } } @@ -944,6 +959,7 @@ final class SSHSessionModel: RemotePathDisplaying, Identifiable { environment: host.environmentVariables) } } + await actor.setTerminalActive(isTerminalActive) try await actor.connect( using: SSHConnectionRequest( configuration: configuration, @@ -1295,6 +1311,20 @@ final class SSHSessionModel: RemotePathDisplaying, Identifiable { } } + private func synchronizeTerminalActivity() { + // First responder can be retained in a non-key window. Window events must + // recheck native focus even when Ghostty's last published value is unchanged. + let active = + terminalState.isFocused && terminalState.isSurfaceVisible && terminalViewOwner.hasActiveKeyboardFocus + guard active != isTerminalActive else { return } + isTerminalActive = active + guard let ssh else { return } + Task { [weak self] in + guard let self, self.ssh === ssh, self.isTerminalActive == active else { return } + await ssh.setTerminalActive(active) + } + } + /// Ghostty parses OSC 7 and publishes the decoded remote path on its view /// state. Bridge that dependency-owned state into the sshSession so every Termind /// surface reads one observable source of truth. diff --git a/TFMTests/RemoteSessionWireTests.swift b/TFMTests/RemoteSessionWireTests.swift index cb1f3ffc..a48a34e5 100644 --- a/TFMTests/RemoteSessionWireTests.swift +++ b/TFMTests/RemoteSessionWireTests.swift @@ -68,6 +68,9 @@ struct RemoteSessionWireTests { let target = try RemoteTmuxCommands.quote(reference.session.id) let size = try await execute("tmux list-clients -t \(target) -F '#{client_width} #{client_height}'") #expect(size.stdout == "103 37\n") + let innerSize = try await execute( + "tmux display-message -p -t \(target) '#{window_width} #{window_height} #{pane_width} #{pane_height}'") + #expect(innerSize.stdout == "103 36 103 36\n") return SSHPTYStartupPlan(command: try backend.launch(reference, created: false).command) } try await connect(second, fixture: fixture, hostID: hostID) diff --git a/TFMTests/TerminalActivationWindowTests.swift b/TFMTests/TerminalActivationWindowTests.swift new file mode 100644 index 00000000..02de31a9 --- /dev/null +++ b/TFMTests/TerminalActivationWindowTests.swift @@ -0,0 +1,251 @@ +import AppKit +import GhosttyTerminal +import SwiftUI +import TermindApplication +import TermindDomain +import TermindRemoteInfrastructure +import Testing + +@testable import Termind + +@Suite( + .requiresVisibleWindow, .serialized, + .enabled(if: ProcessInfo.processInfo.environment["TERMIND_TMUX_FIXTURE"] != nil)) +@MainActor +struct TerminalActivationWindowTests { + @Test(.timeLimit(.minutes(2))) + func equallySizedWorkspacesRecoverFromParkedSizeWithoutKeyboardInput() async throws { + let remote = try await TmuxActivationFixture.make() + let deck = try ActivationWindowFixture(remote: remote) + do { + let first = deck.model.sessions[0] + let second = deck.model.sessions[1] + try await TmuxActivationFixture.wait { + // Test-host launch can temporarily take focus after the window is shown. + NSRunningApplication.current.activate(options: [.activateAllWindows]) + NSApp.activate(ignoringOtherApps: true) + deck.window.orderFrontRegardless() + deck.window.makeMain() + deck.window.makeKey() + return NSApp.isActive && deck.window.isKeyWindow + } + try await TmuxActivationFixture.wait { first.terminalState.surfaceSize != nil } + try deck.focus(first) + try await deck.show(0) + first.connect(using: deck.library) + try await deck.waitForRuntime(first) + try await deck.expectLayers(first, label: "first connection") + let parked = try #require(second.terminalState.surfaceSize) + + deck.window.setContentSize(NSSize(width: 1000, height: 680)) + try await TmuxActivationFixture.wait { + first.terminalState.surfaceSize != parked + } + #expect(second.terminalState.surfaceSize == parked) + try await deck.expectLayers(first, label: "visible enlarged, hidden parked") + + // Connect the hidden workspace at its cached small viewport. The active + // terminal keeps the correct PTY size, but tmux now chooses the hidden client. + second.connect(using: deck.library) + try await deck.waitForRuntime(second) + try await remote.expectSize(columns: parked.columns, rows: parked.rows) + #expect(!second.isTerminalActive) + #expect(deck.window.makeFirstResponder(deck.search)) + try await TmuxActivationFixture.wait { !first.isTerminalActive } + try await Task.sleep(for: .milliseconds(250)) + try await remote.expectSize(columns: parked.columns, rows: parked.rows) + #expect(deck.window.firstResponder === deck.search.currentEditor()) + try deck.focus(first) + try await deck.expectLayers(first, label: "click restores unchanged PTY") + + try await deck.show(1) + try await deck.expectLayers(second, label: "second workspace at same visible size") + #expect(first.terminalState.surfaceSize == second.terminalState.surfaceSize) + for index in [0, 1, 0, 1, 0] { try await deck.show(index) } + try await deck.expectLayers(first, label: "rapid switching") + + // An actual native key-window change, not a SwiftUI focus request. + let other = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 240, height: 100), + styleMask: [.titled], backing: .buffered, defer: false) + other.isReleasedWhenClosed = false + defer { other.close() } + other.makeKeyAndOrderFront(nil) + try await TmuxActivationFixture.wait { !first.isTerminalActive } + _ = try await remote.attach(columns: 40, rows: 22, integrated: false) + try await remote.expectSize(columns: 40, rows: 22) + deck.window.makeKeyAndOrderFront(nil) + try await deck.expectLayers(first, label: "window reactivation") + + first.disconnect() + first.connect(using: deck.library) + try await deck.waitForRuntime(first) + try await deck.expectLayers(first, label: "reconnected focused terminal") + deck.close() + await remote.close() + } catch { + let diagnostic = deck.model.sessions.map { + "status=\($0.status), runtime=\($0.terminalContext.status), active=\($0.isTerminalActive), " + + "focus=\($0.terminalState.isFocused), visible=\($0.terminalState.isSurfaceVisible), " + + "grid=\(String(describing: $0.terminalState.surfaceSize))" + }.joined(separator: "\n") + Attachment.record( + Data( + ("appActive=\(NSApp.isActive), key=\(deck.window.isKeyWindow), " + + "frontmost=\(NSWorkspace.shared.frontmostApplication?.localizedName ?? "none"), " + + "responder=\(String(describing: deck.window.firstResponder))\n\(diagnostic)") + .utf8), + named: "activation-failure.txt") + deck.close() + await remote.close() + throw error + } + } +} + +@MainActor @Observable +private final class ActivationDeckModel { + let sessions: [SSHSessionModel] + var selected = 0 + init(sessions: [SSHSessionModel]) { self.sessions = sessions } +} + +private struct ActivationDeck: View { + let model: ActivationDeckModel + + var body: some View { + PersistentWorkspaceDeckLayout { + ForEach(Array(model.sessions.enumerated()), id: \.element.id) { index, session in + let active = model.selected == index + PersistentTerminalSurfaceView( + state: session.terminalState, owner: session.terminalViewOwner, + isVisible: active, requestsFocus: active + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .opacity(active ? 1 : 0) + .allowsHitTesting(active) + .disabled(!active) + .zIndex(active ? 1 : 0) + .persistentWorkspaceDeckValue(id: session.id, isActive: active) + } + } + } +} + +@MainActor +private final class ActivationWindowFixture { + let remote: TmuxActivationFixture + let library: HostLibraryModel + let model: ActivationDeckModel + let window: NSWindow + let search = NSTextField() + private let hosting: NSHostingView + + init(remote: TmuxActivationFixture) throws { + self.remote = remote + let key = SSHKeyRef( + label: "Activation fixture", publicKey: "", credentialRef: UUID().uuidString, storage: .thisDevice) + let identity = Identity(name: "Fixture", username: "root", keyID: key.id) + var host = TermindDomain.Host( + name: "Activation fixture", host: "127.0.0.1", port: remote.configuration.port, identityID: identity.id) + host.shellIntegrationMode = .enabled + library = HostLibraryModel( + hosts: [host], identities: [identity], + knownHosts: [ + .init( + hostname: host.host, port: host.sshPort, algorithm: .ed25519, + fingerprint: remote.configuration.hostFingerprint) + ]) + try library.upsert( + key, + credential: .software( + .init( + privateKey: String(contentsOfFile: remote.configuration.privateKeyPath, encoding: .utf8)))) + model = ActivationDeckModel(sessions: [SSHSessionModel(host: host), SSHSessionModel(host: host)]) + for session in model.sessions { + session.sessionPersistence.attachment = remote.attachment(hostID: host.id) + session.terminalState.setTerminalConfiguration( + TerminalConfiguration { builder in + builder.withWindowPaddingX(0) + builder.withWindowPaddingY(0) + }) + } + hosting = NSHostingView(rootView: ActivationDeck(model: model)) + let content = NSView(frame: NSRect(x: 0, y: 0, width: 480, height: 330)) + window = NSWindow( + contentRect: content.frame, styleMask: [.titled, .resizable], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.animationBehavior = .none + window.title = "tmux activation regression" + window.contentView = content + for view in [hosting, search] { + view.translatesAutoresizingMaskIntoConstraints = false + content.addSubview(view) + } + NSLayoutConstraint.activate([ + search.leadingAnchor.constraint(equalTo: content.leadingAnchor), + search.trailingAnchor.constraint(equalTo: content.trailingAnchor), + search.topAnchor.constraint(equalTo: content.topAnchor), + search.heightAnchor.constraint(equalToConstant: 30), + hosting.leadingAnchor.constraint(equalTo: content.leadingAnchor), + hosting.trailingAnchor.constraint(equalTo: content.trailingAnchor), + hosting.topAnchor.constraint(equalTo: search.bottomAnchor), + hosting.bottomAnchor.constraint(equalTo: content.bottomAnchor), + ]) + window.makeKeyAndOrderFront(nil) + content.layoutSubtreeIfNeeded() + } + + func show(_ index: Int) async throws { + model.selected = index + try await TmuxActivationFixture.wait { + let session = self.model.sessions[index] + return session.isTerminalActive && session.terminalState.surfaceSize != nil + } + } + + func waitForRuntime(_ session: SSHSessionModel) async throws { + try await TmuxActivationFixture.wait { + session.status == .connected && session.terminalContext.status == .multiplexer + } + } + + func focus(_ session: SSHSessionModel) throws { + let view = try terminalView(session) + #expect(window.makeFirstResponder(view)) + } + + func expectLayers(_ session: SSHSessionModel, label: String) async throws { + try await TmuxActivationFixture.wait { session.isTerminalActive } + let grid = try #require(session.terminalState.surfaceSize) + let tty = try #require(session.terminalContext.clientIdentity?.split(separator: ":").first.map(String.init)) + try await remote.expectSize(columns: grid.columns, rows: grid.rows) + let pty = try await remote.command("stty size -F \(RemoteTmuxCommands.quote(tty))") + let client = try await remote.clientSize(tty) + let inner = try await remote.windowSize() + let view = try terminalView(session) + let layers = + "\(label): visible=\(hosting.bounds.size), native=\(view.bounds.size), " + + "grid=\(grid.columns)x\(grid.rows), PTY=\(pty), client=\(client), window/pane=\(inner)" + Attachment.record(Data(layers.utf8), named: "\(label).txt") + #expect(view.bounds.size == hosting.bounds.size, Comment(rawValue: layers)) + #expect(pty == "\(grid.rows) \(grid.columns)", Comment(rawValue: layers)) + #expect(client == "\(grid.columns) \(grid.rows)", Comment(rawValue: layers)) + } + + private func terminalView(_ session: SSHSessionModel) throws -> TermindTerminalView { + func find(in view: NSView) -> TermindTerminalView? { + if let mount = view as? TerminalMountView, mount.owner === session.terminalViewOwner { + return mount.subviews.first as? TermindTerminalView + } + return view.subviews.lazy.compactMap { find(in: $0) }.first + } + return try #require(find(in: hosting)) + } + + func close() { + for session in model.sessions { session.disconnect() } + window.contentView = nil + window.close() + } +} diff --git a/TFMTests/TerminalActivationWireTests.swift b/TFMTests/TerminalActivationWireTests.swift new file mode 100644 index 00000000..1247ebae --- /dev/null +++ b/TFMTests/TerminalActivationWireTests.swift @@ -0,0 +1,388 @@ +import Foundation +import TermindApplication +import TermindDomain +import Testing + +@testable import TermindRemoteInfrastructure + +@Suite(.enabled(if: ProcessInfo.processInfo.environment["TERMIND_TMUX_FIXTURE"] != nil), .serialized) +@MainActor +struct TerminalActivationWireTests { + @Test(.timeLimit(.minutes(2))) + func activationRestoresWindowAndPaneWithoutResizingTheClientOrTyping() async throws { + let fixture = try await TmuxActivationFixture.make() + do { + Attachment.record(Data(try await fixture.command("tmux -V").utf8), named: "tmux-version.txt") + let first = try await fixture.attach(columns: 120, rows: 40) + let identity = try first.identity() + await first.channel.setTerminalActive(true) + try await fixture.expectSize(columns: 120, rows: 40) + let second = try await fixture.attach(columns: 40, rows: 22, integrated: false) + try await fixture.expectSize(columns: 40, rows: 22) + // A runtime poll or duplicate focus publication must not steal size from another client. + await first.channel.setTerminalActive(true) + try await Task.sleep(for: .seconds(1)) + try await fixture.expectSize(columns: 40, rows: 22) + await first.channel.setTerminalActive(false) + await first.channel.setTerminalActive(true) + try await fixture.expectSize(columns: 120, rows: 40) + #expect(try await fixture.clientSize(identity.terminal) == "120 40") + #expect( + try await fixture.command("tmux list-clients -t \(fixture.target) -F '#{client_pid}'") + .split(separator: "\n").count == 2) + + // The final engine viewport must win even if activation overlaps queued window changes. + await first.channel.setTerminalActive(false) + await first.channel.resize(columns: 130, rows: 42, widthPixels: 0, heightPixels: 0) + await first.channel.setTerminalActive(true) + await first.channel.resize(columns: 140, rows: 45, widthPixels: 0, heightPixels: 0) + try await fixture.expectSize(columns: 140, rows: 45) + #expect(try await fixture.clientSize(identity.terminal) == "140 45") + await second.channel.resize(columns: 140, rows: 45, widthPixels: 0, heightPixels: 0) + try await fixture.expectSize(columns: 140, rows: 45) + await first.channel.setTerminalActive(false) + await first.channel.setTerminalActive(true) + try await fixture.expectSize(columns: 140, rows: 45) + + _ = try await fixture.command("tmux resize-window -t \(fixture.target) -x 55 -y 17") + await first.channel.setTerminalActive(false) + await first.channel.setTerminalActive(true) + try await Task.sleep(for: .milliseconds(300)) + #expect(try await fixture.windowSize() == "55 17 55 17") + #expect(try await fixture.command("tmux show-option -w -v -t \(fixture.target) window-size") == "manual") + await fixture.close() + } catch { + await fixture.close() + throw error + } + } + + @Test(.timeLimit(.minutes(2))) + func identityRevalidationAndSameSizeActivationPreserveSplitLayout() async throws { + let fixture = try await TmuxActivationFixture.make() + do { + let first = try await fixture.attach(columns: 120, rows: 40) + let identity = try first.identity() + _ = try await fixture.attach(columns: 40, rows: 22, integrated: false) + try await fixture.expectSize(columns: 40, rows: 22) + let adapter = RemoteTmuxRuntimeAdapter(execute: fixture.execute) + for field in ["terminal", "process", "created", "server", "session", "sessionCreated"] { + let terminal = field == "terminal" ? "/dev/pts/99999" : identity.terminal + var update = first.update + let process = field == "process" ? "\(Int(identity.process)! + 1)" : identity.process + let created = field == "created" ? "\(Int(identity.created)! + 1)" : identity.created + update.clientIdentity = "\(terminal):\(process):\(created)" + let context = try #require(update.active) + update.active = .init( + backend: "tmux", server: field == "server" ? "\(Int(identity.server)! + 1)" : identity.server, + session: (field == "session" ? "$99999" : identity.session) + ":" + + (field == "sessionCreated" + ? "\(Int(identity.sessionCreated)! + 1)" : identity.sessionCreated), + pane: context.pane, shellInstance: context.shellInstance) + let stale = try #require(RemoteTmuxClientIdentity(update: update, terminal: terminal)) + await #expect(throws: TerminalRuntimeError.self) { try await adapter.refreshSize(of: stale) } + #expect(try await fixture.windowSize() == "40 21 40 21") + } + await first.channel.setTerminalActive(true) + try await fixture.expectSize(columns: 120, rows: 40) + _ = try await fixture.command("tmux split-window -h -t \(fixture.target)") + let layoutCommand = "tmux display-message -p -t \(try fixture.target) '#{window_layout}'" + let processesCommand = "tmux list-panes -t \(try fixture.target) -F '#{pane_id} #{pane_pid}'" + let layout = try await fixture.command(layoutCommand) + let processes = try await fixture.command(processesCommand) + _ = try await fixture.attach(columns: 120, rows: 40, integrated: false) + await first.channel.setTerminalActive(false) + await first.channel.setTerminalActive(true) + try await Task.sleep(for: .milliseconds(250)) + #expect(try await fixture.command(layoutCommand) == layout) + #expect(try await fixture.command(processesCommand) == processes) + await fixture.close() + } catch { + await fixture.close() + throw error + } + } + + @Test(.timeLimit(.minutes(2)), arguments: ["bash", "zsh", "fish"]) + func manualAttachActivationPreservesDraftPrefixCopyModeAndRejectsDetachedClients(shell: String) async throws { + let fixture = try await TmuxActivationFixture.make(shell: shell) + do { + let first = try await fixture.attach(columns: 120, rows: 40, manualShell: shell) + let identity = try first.identity() + try await TmuxActivationFixture.wait { first.context?.promptReady == true } + await first.channel.write(Data("echo draft-not-executed".utf8)) + #expect(try await first.draft() == "echo draft-not-executed") + _ = try await fixture.attach(columns: 40, rows: 22, integrated: false) + try await fixture.expectSize(columns: 40, rows: 22) + let tty = try RemoteTmuxCommands.quote(identity.terminal) + _ = try await fixture.command("tmux switch-client -c \(tty) -T prefix") + await first.channel.setTerminalActive(true) + try await fixture.expectSize(columns: 120, rows: 40) + #expect(try await fixture.clientField(identity.terminal, field: "client_key_table") == "prefix") + _ = try await fixture.command("tmux switch-client -c \(tty) -T root; tmux copy-mode -t \(fixture.target)") + await first.channel.setTerminalActive(false) + await first.channel.setTerminalActive(true) + try await Task.sleep(for: .milliseconds(250)) + #expect(try await fixture.command("tmux display-message -p -t \(fixture.target) '#{pane_in_mode}'") == "1") + _ = try await fixture.command("tmux send-keys -t \(fixture.target) -X cancel") + try await TmuxActivationFixture.wait { first.context?.capabilities.contains(.readDraft) == true } + #expect(try await first.draft() == "echo draft-not-executed") + + let adapter = RemoteTmuxRuntimeAdapter(execute: fixture.execute) + for flag in ["ignore-size", "read-only", "active-pane"] { + _ = try await fixture.command("tmux refresh-client -t \(tty) -f \(flag)") + await #expect(throws: TerminalRuntimeError.self) { try await adapter.refreshSize(of: identity) } + if flag == "read-only" { + _ = try await fixture.command("tmux switch-client -c \(tty) -r") + } else { + _ = try await fixture.command("tmux refresh-client -t \(tty) -f '!\(flag)'") + } + } + _ = try await fixture.command("tmux detach-client -t \(tty)") + try await TmuxActivationFixture.wait { first.update.status == .ordinary } + await #expect(throws: TerminalRuntimeError.self) { try await adapter.refreshSize(of: identity) } + first.output.removeAll() + await first.channel.setTerminalActive(false) + await first.channel.setTerminalActive(true) + await first.channel.write(Data("printf 'OUTER-%s\\n' SHELL-OK\n".utf8)) + try await TmuxActivationFixture.wait { + String(decoding: first.output, as: UTF8.self).contains("OUTER-SHELL-OK") + } + await fixture.close() + } catch { + await fixture.close() + throw error + } + } + + @Test(.timeLimit(.minutes(2))) + func activationDoesNotInterruptVimOrChangeItsPaneProcess() async throws { + let fixture = try await TmuxActivationFixture.make() + let path = "/tmp/termind-vim-activation-\(UUID().uuidString)" + do { + let first = try await fixture.attach(columns: 120, rows: 40) + let launch = try RemoteTmuxCommands.quote( + "vim -n -u NONE -i NONE -c 'call setline(1, \"activation-buffer\")' \(path)") + _ = try await fixture.command("tmux send-keys -t \(fixture.target) \(launch) Enter") + try await TmuxActivationFixture.wait { + try await fixture.command("tmux capture-pane -p -t \(fixture.target)").hasPrefix("activation-buffer\n") + } + let pid = try await fixture.command("tmux display-message -p -t \(fixture.target) '#{pane_pid}'") + _ = try await fixture.attach(columns: 40, rows: 22, integrated: false) + try await fixture.expectSize(columns: 40, rows: 22) + await first.channel.setTerminalActive(true) + try await fixture.expectSize(columns: 120, rows: 40) + #expect(try await fixture.command("tmux display-message -p -t \(fixture.target) '#{pane_pid}'") == pid) + #expect( + try await fixture.command("tmux display-message -p -t \(fixture.target) '#{pane_current_command}'") + == "vim") + _ = try await fixture.command("tmux send-keys -t \(fixture.target) ':w' Enter") + try await TmuxActivationFixture.wait { + try await fixture.execute("test -f \(path)").exitCode == 0 + } + #expect(try await fixture.command("cat \(path)") == "activation-buffer") + _ = try await fixture.command("rm \(path)") + await fixture.close() + } catch { + _ = try? await fixture.command("rm -f \(path)") + await fixture.close() + throw error + } + } + + @Test(.timeLimit(.minutes(2))) + func missingIntegrationAndSingleChannelCapacityLeaveInputUsable() async throws { + let fixture = try await TmuxActivationFixture.make() + do { + for (integrated, maximumChannels) in [(false, 8), (true, 1)] { + let first = try await fixture.attach( + columns: 120, rows: 40, integrated: integrated, maximumChannels: maximumChannels) + let second = try await fixture.attach(columns: 40, rows: 22, integrated: false) + try await fixture.expectSize(columns: 40, rows: 22) + await first.channel.setTerminalActive(true) + try await Task.sleep(for: .seconds(1)) + try await fixture.expectSize(columns: 40, rows: 22) + #expect(first.update.status != .multiplexer) + await first.channel.write(Data("printf 'INPUT-%s\\n' STILL-WORKS\n".utf8)) + try await fixture.expectSize(columns: 120, rows: 40) + try await TmuxActivationFixture.wait { + String(decoding: first.output, as: UTF8.self).contains("INPUT-STILL-WORKS") + } + await first.channel.disconnect() + await second.channel.disconnect() + } + await fixture.close() + } catch { + await fixture.close() + throw error + } + } +} + +@MainActor +final class TmuxActivationFixture { + struct Configuration: Decodable { + let port: Int + let hostFingerprint: String + let privateKeyPath: String + } + + final class Client { + var output = Data() + var update = TerminalRuntimeUpdate(status: .unknown) + private let maximumChannels: Int + lazy var channel = SSHPTYChannel( + output: { [weak self] data in + Task { @MainActor in + self?.output.append(data) + } + }, exit: { _ in }, + shellIntegration: { [weak self] event in + Task { @MainActor in + if case .runtime(let update) = event { self?.update = update } + } + }, maxSessionsPolicy: .init(preferences: .init(maxSessions: maximumChannels))) + + init(maximumChannels: Int) { self.maximumChannels = maximumChannels } + + func identity() throws -> RemoteTmuxClientIdentity { + let terminal = try #require(update.clientIdentity?.split(separator: ":").first.map(String.init)) + return try #require(RemoteTmuxClientIdentity(update: update, terminal: terminal)) + } + + var context: TerminalShellContext? { + update.contexts.first { $0.identity == update.active } + } + + func draft() async throws -> String { + // Prefix/copy-mode changes reach the runtime snapshot asynchronously. + for _ in 0..<100 { + if let context, context.capabilities.contains(.readDraft) { + do { return try await channel.contextDraft(context) } catch TerminalRuntimeError.staleContext { + } catch TerminalRuntimeError.capacityUnavailable {} + } + try await Task.sleep(for: .milliseconds(50)) + } + throw TerminalRuntimeError.staleContext + } + } + + let configuration: Configuration + let control = SSHPTYChannel(output: { _ in }, exit: { _ in }) + private(set) var execute: RemoteSessionCommandExecutor! + private var reference: RemoteSessionReference! + private var clients: [Client] = [] + var target: String { get throws { try RemoteTmuxCommands.quote(reference.session.id) } } + + private init(configuration: Configuration) { self.configuration = configuration } + + func attachment(hostID: UUID) -> RemoteSessionReference { + .init( + backend: .tmux, session: reference.session, hostID: hostID, + endpoint: "127.0.0.1:\(configuration.port)", username: "root", ownershipToken: reference.ownershipToken) + } + + static func make(shell: String = "bash") async throws -> TmuxActivationFixture { + let path = try #require(ProcessInfo.processInfo.environment["TERMIND_TMUX_FIXTURE"]) + let configuration = try JSONDecoder().decode( + Configuration.self, from: Data(contentsOf: URL(fileURLWithPath: path))) + let fixture = TmuxActivationFixture(configuration: configuration) + do { + try await fixture.connect(fixture.control, columns: 80, rows: 24, integrated: false) + try await fixture.control.releaseTerminalForControl() + fixture.execute = try await fixture.control.sessionCommandExecutor() + let token = UUID() + let session = try await RemoteTmuxService(execute: fixture.execute).create( + token: token, startupCommand: nil, shellIntegration: true, + environment: [.init(variable: "SHELL", value: "/bin/\(shell)")]) + fixture.reference = .init( + backend: .tmux, session: session, hostID: UUID(), endpoint: "fixture", username: "root", + ownershipToken: token) + return fixture + } catch { + await fixture.close() + throw error + } + } + + func attach( + columns: UInt16, rows: UInt16, manualShell: String? = nil, integrated: Bool = true, maximumChannels: Int = 8 + ) async throws -> Client { + let client = Client(maximumChannels: maximumChannels) + clients.append(client) + if let manualShell { + await client.channel.configureEnvironment([ + .init(variable: "SHELL", value: "/bin/\(manualShell)"), + .init(variable: "TERMIND_TMUX_SHELL", value: "/bin/\(manualShell)"), + ]) + } else { + let command = try RemoteTmuxCommands.attach(reference) + await client.channel.configureStartup { _ in .init(command: command) } + } + try await connect(client.channel, columns: columns, rows: rows, integrated: integrated) + if manualShell != nil { + try await Self.wait { client.update.status == .ordinary } + await client.channel.write(Data("tmux attach-session -t \(try target)\n".utf8)) + } + if integrated, maximumChannels > 1 { + try await Self.wait { client.update.status == .multiplexer } + } + return client + } + + func connect(_ channel: SSHPTYChannel, columns: UInt16, rows: UInt16, integrated: Bool) async throws { + let key = try String(contentsOfFile: configuration.privateKeyPath, encoding: .utf8) + try await channel.connect( + using: SSHConnectionRequest( + configuration: .init( + hostID: UUID(), host: "127.0.0.1", port: configuration.port, username: "root", + credentials: [.privateKey(privateKey: SSHSecret(key), publicKey: nil, passphrase: nil)], + expectedHostKeys: [.init(algorithm: .ed25519, fingerprint: configuration.hostFingerprint)])), + shellIntegrationEnabled: integrated, columns: columns, rows: rows) + } + + func command(_ command: String) async throws -> String { + let result = try await execute(command) + try #require(result.exitCode == 0 && !result.truncated, "Fixture command failed: \(result.stderr)") + return result.stdout.trimmingCharacters(in: .newlines) + } + + func windowSize() async throws -> String { + try await command( + "tmux display-message -p -t \(target) '#{window_width} #{window_height} #{pane_width} #{pane_height}'") + } + + func clientField(_ terminal: String, field: String) async throws -> String { + try await command( + "tmux list-clients -F '#{client_tty}|#{\(field)}' | awk -F '|' -v tty=\(RemoteTmuxCommands.quote(terminal)) '$1 == tty {print $2}'" + ) + } + + func clientSize(_ terminal: String) async throws -> String { + try await command( + "tmux list-clients -F '#{client_tty}|#{client_width} #{client_height}' | awk -F '|' -v tty=\(RemoteTmuxCommands.quote(terminal)) '$1 == tty {print $2}'" + ) + } + + func expectSize(columns: UInt16, rows: UInt16) async throws { + let expected = "\(columns) \(rows - 1) \(columns) \(rows - 1)" + try await Self.wait { try await self.windowSize() == expected } + #expect(try await windowSize() == expected) + } + + static func wait( + sourceLocation: SourceLocation = #_sourceLocation, _ predicate: () async throws -> Bool + ) async throws { + for _ in 0..<200 { + if try await predicate() { return } + try await Task.sleep(for: .milliseconds(25)) + } + try #require(try await predicate(), "Timed out waiting for the fixture", sourceLocation: sourceLocation) + } + + func close() async { + for client in clients { await client.channel.disconnect() } + if let reference, let execute { try? await RemoteTmuxService(execute: execute).terminate(reference) } + await control.disconnect() + } +} diff --git a/TFMTests/TerminalActivityTests.swift b/TFMTests/TerminalActivityTests.swift new file mode 100644 index 00000000..dd17033c --- /dev/null +++ b/TFMTests/TerminalActivityTests.swift @@ -0,0 +1,51 @@ +import Foundation +import TermindDomain +import Testing + +@testable import Termind + +@Suite +@MainActor +struct TerminalActivityTests { + @Test func unmountedGhosttyFocusCannotActivateATerminal() async throws { + let session = SSHSessionModel(host: Host(name: "Activity test", host: "test.invalid")) + let state = session.terminalState + #expect(!session.isTerminalActive) + state.isSurfaceVisible = false + state.terminalDidChangeFocus(true) + try await settle() + #expect(!session.isTerminalActive) + state.isSurfaceVisible = true + try await settle() + #expect(!session.isTerminalActive) + state.terminalDidChangeFocus(false) + try await settle() + #expect(!session.isTerminalActive) + state.terminalDidChangeFocus(true) + try await settle() + #expect(!session.isTerminalActive) + state.isSurfaceVisible = false + try await settle() + #expect(!session.isTerminalActive) + } + + @Test func rapidVisibilityChangesDoNotReplayStaleActivation() async throws { + let session = SSHSessionModel(host: Host(name: "Activity test", host: "test.invalid")) + let state = session.terminalState + state.terminalDidChangeFocus(true) + try await settle() + #expect(!session.isTerminalActive) + for _ in 0..<10 { + state.isSurfaceVisible = false + state.isSurfaceVisible = true + } + state.isSurfaceVisible = false + state.terminalDidChangeFocus(false) + try await settle() + #expect(!session.isTerminalActive) + } + + private func settle() async throws { + try await Task.sleep(for: .milliseconds(30)) + } +}