fix(terminal): synchronize tmux size on workspace activation

This commit is contained in:
Kaede Akino committed 2026-10-16 13:42:39 +08:00
1 parent 7e82343da4
commit e2a4ae9505
13 files changed
+1100 -8

No files matched your search

+149
View File
@@ -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"))
}
}
@@ -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
}
}
@@ -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
@@ -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
}
}
@@ -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<Void, Never>)?
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<Int32, TermindSSHError>) {
@@ -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
@@ -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()
+9 -1
View File
@@ -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 "
@@ -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
@@ -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.
+3
View File
@@ -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)
@@ -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<ActivationDeck>
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()
}
}
+388
View File
@@ -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()
}
}
+51
View File
@@ -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))
}
}