fix(terminal): synchronize tmux size on workspace activation
This commit is contained in:
1 parent
7e82343da4
commit
e2a4ae9505
13 files changed
+1100
-8
No files matched your search
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user