Files
Termind/TFMTests/RemoteSessionWireTests.swift

370 lines
20 KiB
Swift

// SPDX-FileCopyrightText: 2026 Kaede Akino
// SPDX-License-Identifier: GPL-3.0-only
import Foundation
import TermindApplication
import TermindDomain
import TermindRemoteInfrastructure
import Testing
/// Opt-in: Scripts/ssh-tmux-session-fixture.py runs isolated OpenSSH with MaxSessions=1 and tmux >= 3.3.
/// The JSON fixture holds only paths and a pinned test-server fingerprint.
@Suite(.enabled(if: ProcessInfo.processInfo.environment["TERMIND_TMUX_FIXTURE"] != nil), .serialized)
@MainActor
struct RemoteSessionWireTests {
private struct Fixture: Decodable {
let port: Int
let hostFingerprint: String
let privateKeyPath: String
}
@Test(.timeLimit(.minutes(1)))
func preparationMultiClientDetachRestoreAndOwnedTermination() async throws {
let fixture = try loadFixture()
let hostID = UUID()
let coordinator = RemoteSessionCoordinator()
let selectionMonitor = Task {
while !Task.isCancelled {
if let prompt = coordinator.prompt {
switch prompt.failure {
case .commandFailed(let message):
let redacted = message.replacingOccurrences(
of: #"[0-9A-Fa-f]{8}(?:-[0-9A-Fa-f]{4}){3}-[0-9A-Fa-f]{12}"#,
with: "<redacted>", options: .regularExpression)
Issue.record("Unexpected remote command failure during create/restore: \(redacted)")
case .invalidResponse:
Issue.record("Unexpected remote response during create/restore")
case .ownershipMismatch:
Issue.record("Unexpected ownership mismatch during create/restore")
default:
Issue.record("Unexpected session selection during automatic create/restore")
}
coordinator.cancel()
return
}
try? await Task.sleep(for: .milliseconds(10))
}
}
defer { selectionMonitor.cancel() }
let marker = "/tmp/termind-once-\(UUID().uuidString)"
let channel = SSHPTYChannel(
output: { _ in }, exit: { _ in }, maxSessionsPolicy: .init(preferences: .init(maxSessions: 1)))
await channel.configureStartup { execute in
try await coordinator.prepare(
preferences: .init(enabled: true, onConnect: .createManaged), registry: .live,
execute: execute, hostID: hostID, endpoint: "localhost:\(fixture.port)", username: "root",
startupCommand: "printf once >> '\(marker)'", requiresNewSession: true, shellIntegration: false)
}
try await connect(channel, fixture: fixture, hostID: hostID)
let reference = try #require(coordinator.attachment)
#expect(reference.isManaged)
let owner = try #require(reference.ownershipToken)
#expect(!reference.session.name.lowercased().contains(owner.uuidString.lowercased()))
await channel.resize(columns: 103, rows: 37, widthPixels: 0, heightPixels: 0)
try await Task.sleep(for: .milliseconds(250))
let second = SSHPTYChannel(output: { _ in }, exit: { _ in })
await second.configureStartup { execute in
let backend = try RemoteSessionBackendRegistry.live.backend(.tmux, execute: execute)
let before = try await backend.sessions()
#expect(before.contains { backend.matches(reference, session: $0) && $0.clients == 1 })
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)
let observer = SSHPTYChannel(output: { _ in }, exit: { _ in })
await observer.configureStartup { execute in
let backend = try RemoteSessionBackendRegistry.live.backend(.tmux, execute: execute)
let sessions = try await backend.sessions()
#expect(sessions.contains { backend.matches(reference, session: $0) && $0.clients == 2 })
return nil
}
try await connect(observer, fixture: fixture, hostID: hostID)
await observer.disconnect()
// Release the first terminal before executing control commands on its MaxSessions=1 transport.
try await channel.releaseTerminalForControl()
let execute = try await channel.sessionCommandExecutor()
let backend = try RemoteSessionBackendRegistry.live.backend(.tmux, execute: execute)
let listed = try await backend.sessions()
#expect(listed.contains { backend.matches(reference, session: $0) && $0.clients == 1 })
#expect(try await execute("cat '\(marker)'").stdout == "once")
await second.disconnect()
#expect(try await backend.sessions().contains { backend.matches(reference, session: $0) })
let third = SSHPTYChannel(output: { _ in }, exit: { _ in })
await third.configureStartup { execute in
try await coordinator.prepare(
preferences: .init(enabled: true, onConnect: .createManaged), registry: .live,
execute: execute, hostID: hostID, endpoint: "localhost:\(fixture.port)", username: "root",
startupCommand: "printf REPLAY >> '\(marker)'", requiresNewSession: false, shellIntegration: false)
}
try await connect(third, fixture: fixture, hostID: hostID)
#expect(try await execute("cat '\(marker)'").stdout == "once")
let external = RemoteSessionReference(
backend: reference.backend, session: reference.session,
hostID: reference.hostID, endpoint: reference.endpoint, username: reference.username, ownershipToken: nil)
do {
try await backend.terminate(external)
Issue.record("External attachment cannot terminate")
} catch RemoteSessionFailure.ownershipMismatch {}
try await backend.terminate(reference)
#expect(try await backend.sessions().allSatisfy { !backend.matches(reference, session: $0) })
_ = try await execute("rm -f '\(marker)'")
await third.disconnect()
await channel.disconnect()
}
@Test(.timeLimit(.minutes(1)), arguments: ["bash", "zsh", "fish"])
func managedShellLoadsUserConfigurationAndDoesNotReplayStartup(shell: String) async throws {
let fixture = try loadFixture()
let hostID = UUID()
let directory = "/tmp/termind-shell-test-\(UUID().uuidString)"
let channel = SSHPTYChannel(output: { _ in }, exit: { _ in })
await channel.configureStartup { execute in
let quotedDirectory = try RemoteTmuxCommands.quote(directory)
let config = shell == "fish" ? "set -gx TEST_SESSION_CONFIG loaded" : "export TEST_SESSION_CONFIG=loaded"
let contents = try RemoteTmuxCommands.quote(
config + "\nprintf 'config\\n' >> '\(directory)/config-count'\n")
let setup = try await execute(
"""
umask 077
mkdir -p \(quotedDirectory)/.config/fish
printf '%s\\n' \(contents) > \(quotedDirectory)/.bash_profile
printf '%s\\n' \(contents) > \(quotedDirectory)/.zshrc
printf '%s\\n' \(contents) > \(quotedDirectory)/.config/fish/config.fish
""")
#expect(setup.exitCode == 0)
let backend = try RemoteSessionBackendRegistry.live.backend(.tmux, execute: execute)
let token = UUID()
let session = try await backend.create(
token: token,
startupCommand:
"cd /tmp; printf '%s' \"$TEST_SESSION_CONFIG\" > '\(directory)/config-value'; printf once >> '\(directory)/once'",
shellIntegration: true,
environment: [
.init(variable: "SHELL", value: "/bin/\(shell)"),
.init(variable: "HOME", value: directory),
.init(variable: "XDG_CONFIG_HOME", value: "\(directory)/.config"),
])
let reference = RemoteSessionReference(
backend: .tmux, session: session, hostID: hostID, endpoint: "localhost:\(fixture.port)",
username: "root", ownershipToken: token)
let target = try RemoteTmuxCommands.quote(session.id)
var initialized = false
for _ in 0..<100 {
if try await execute("test -f \(quotedDirectory)/once").exitCode == 0 {
initialized = true
break
}
try await Task.sleep(for: .milliseconds(10))
}
#expect(initialized)
let configuredValue = try await execute("cat \(quotedDirectory)/config-value").stdout
#expect(configuredValue == "loaded")
let actualShell = try await execute("tmux display-message -p -t \(target) '#{pane_current_command}'").stdout
#expect(actualShell == "\(shell)\n")
#expect(try await execute("tmux display-message -p -t \(target) '#{pane_current_path}'").stdout == "/tmp\n")
#expect(try await execute("tmux new-window -t \(target)").exitCode == 0)
var configCount = ""
for _ in 0..<100 {
configCount = try await execute("cat \(quotedDirectory)/config-count").stdout
if configCount == "config\nconfig\n" { break }
try await Task.sleep(for: .milliseconds(10))
}
#expect(configCount == "config\nconfig\n")
let shellPassthrough = try await execute("tmux show-option -A -w -v -t \(target) allow-passthrough")
#expect(shellPassthrough.stdout == "off\n")
let journals = try await execute(
"""
helper=$(tmux show-environment -t \(target) TERMIND_TMUX_HELPERS)
helper=${helper#TERMIND_TMUX_HELPERS=}
for file in "$helper"/ctx.*/journal; do head -n 2 "$file"; done
""")
#expect(journals.stdout.components(separatedBy: "\n").filter { $0.hasPrefix("M1|") }.count == 2)
#expect(journals.stdout.contains("|\(shell)|"))
let programWindow = try await execute("tmux new-window -P -F '#{window_id}' -t \(target) 'sleep 120'")
#expect(programWindow.exitCode == 0)
let programTarget = try RemoteTmuxCommands.quote(
programWindow.stdout.trimmingCharacters(in: .whitespacesAndNewlines))
let passthrough = try await execute("tmux show-option -A -w -v -t \(programTarget) allow-passthrough")
#expect(passthrough.stdout == "off\n")
#expect(try await execute("cat \(quotedDirectory)/once").stdout == "once")
try await backend.terminate(reference)
#expect(try await backend.sessions().allSatisfy { !backend.matches(reference, session: $0) })
_ = try await execute("rm -rf \(quotedDirectory)")
return nil
}
try await connect(channel, fixture: fixture, hostID: hostID)
await channel.disconnect()
}
@Test(.timeLimit(.minutes(1)))
func abruptTransportLossPreservesTheSessionAndDoesNotReplayStartup() async throws {
let fixture = try loadFixture()
let hostID = UUID()
let coordinator = RemoteSessionCoordinator()
let state = ConnectionState()
let marker = "/tmp/termind-network-test-\(UUID().uuidString)"
let channel = SSHPTYChannel(
output: { _ in },
exit: { result in
Task { @MainActor in state.exit = result }
})
await channel.configureStartup { execute in
let parent = try await execute("printf '%s' \"$PPID\"").stdout
await MainActor.run { state.serverPID = UInt64(parent) }
return try await coordinator.prepare(
preferences: .init(enabled: true, onConnect: .createManaged), registry: .live,
execute: execute, hostID: hostID, endpoint: "localhost:\(fixture.port)", username: "root",
startupCommand: "printf once >> '\(marker)'", requiresNewSession: false, shellIntegration: false)
}
try await connect(channel, fixture: fixture, hostID: hostID)
let reference = try #require(coordinator.attachment)
let serverPID = try #require(state.serverPID)
let control = SSHPTYChannel(output: { _ in }, exit: { _ in })
await control.configureStartup { _ in nil }
try await connect(control, fixture: fixture, hostID: hostID)
try await control.releaseTerminalForControl()
let execute = try await control.sessionCommandExecutor()
// Kill only this fixture connection's sshd process, not the tmux server or listener.
let process = try await execute("ps -o comm= -p \(serverPID)").stdout
try #require(process.contains("sshd"))
#expect(try await execute("kill -KILL \(serverPID)").exitCode == 0)
for _ in 0..<200 {
if state.exit != nil { break }
try await Task.sleep(for: .milliseconds(10))
}
guard case .failure = state.exit else {
Issue.record("Abrupt transport loss must report a connection failure, not a detach")
await channel.disconnect()
await control.disconnect()
return
}
let backend = try RemoteSessionBackendRegistry.live.backend(.tmux, execute: execute)
#expect(try await backend.sessions().contains { backend.matches(reference, session: $0) })
try await connect(channel, fixture: fixture, hostID: hostID)
#expect(coordinator.attachment?.hasSameIdentity(as: reference) == true)
#expect(try await execute("cat '\(marker)'").stdout == "once")
try await backend.terminate(reference)
_ = try await execute("rm -f '\(marker)'")
await channel.disconnect()
await control.disconnect()
}
@Test(.timeLimit(.minutes(1)))
func reattachmentLeavesVimAndRunningProcessesUntouched() async throws {
let fixture = try loadFixture()
let hostID = UUID()
let coordinator = RemoteSessionCoordinator()
let control = SSHPTYChannel(output: { _ in }, exit: { _ in })
await control.configureStartup { execute in
let backend = try RemoteSessionBackendRegistry.live.backend(.tmux, execute: execute)
let token = UUID()
let session = try await backend.create(
token: token, startupCommand: nil, shellIntegration: true, environment: [])
let reference = RemoteSessionReference(
backend: .tmux, session: session, hostID: hostID, endpoint: "localhost:\(fixture.port)",
username: "root", ownershipToken: token)
await MainActor.run { coordinator.attachment = reference }
return try SSHPTYStartupPlan(command: backend.launch(reference, created: true).command)
}
try await connect(control, fixture: fixture, hostID: hostID)
let reference = try #require(coordinator.attachment)
try await control.releaseTerminalForControl()
let execute = try await control.sessionCommandExecutor()
let target = try RemoteTmuxCommands.quote(reference.session.id)
_ = try await execute("tmux send-keys -t \(target) 'vim -n -u NONE -i NONE' Enter")
var running = ""
for _ in 0..<100 {
running = try await execute("tmux display-message -p -t \(target) '#{pane_current_command}'").stdout
if running.contains("vim") { break }
try await Task.sleep(for: .milliseconds(10))
}
try #require(running.contains("vim"))
let vimBefore = try await execute("tmux capture-pane -p -t \(target)").stdout
let client = SSHPTYChannel(output: { _ in }, exit: { _ in })
await client.configureStartup { execute in
try await coordinator.prepare(
preferences: .init(enabled: true, onConnect: .createManaged), registry: .live, execute: execute,
hostID: hostID, endpoint: "localhost:\(fixture.port)", username: "root",
startupCommand: "echo MUST_NOT_REPLAY", requiresNewSession: false, shellIntegration: true)
}
try await connect(client, fixture: fixture, hostID: hostID)
let vimAfter = try await execute("tmux capture-pane -p -t \(target)").stdout
#expect(vimAfter == vimBefore)
await client.write(Data([0x02, 0x64]))
let backend = try RemoteSessionBackendRegistry.live.backend(.tmux, execute: execute)
var detached = false
for _ in 0..<100 {
detached = try await backend.sessions().contains { $0.id == reference.session.id && $0.clients == 0 }
if detached { break }
try await Task.sleep(for: .milliseconds(10))
}
#expect(detached)
try await connect(client, fixture: fixture, hostID: hostID)
let vimReattached = try await execute("tmux capture-pane -p -t \(target)").stdout
#expect(vimReattached == vimBefore)
_ = try await execute("tmux send-keys -t \(target) Escape ':q!' Enter")
for _ in 0..<100 {
running = try await execute("tmux display-message -p -t \(target) '#{pane_current_command}'").stdout
if !running.contains("vim") { break }
try await Task.sleep(for: .milliseconds(10))
}
_ = try await execute("tmux send-keys -t \(target) 'sleep 120' Enter")
for _ in 0..<100 {
running = try await execute("tmux display-message -p -t \(target) '#{pane_current_command}'").stdout
if running == "sleep\n" { break }
try await Task.sleep(for: .milliseconds(10))
}
#expect(running == "sleep\n")
let panePID = try await execute("tmux display-message -p -t \(target) '#{pane_pid}'").stdout
.trimmingCharacters(in: .whitespacesAndNewlines)
let pid = try #require(UInt64(panePID))
let child = try await execute("pgrep -P \(pid) -x sleep").stdout.trimmingCharacters(in: .whitespacesAndNewlines)
let jobPID = try #require(UInt64(child))
await client.disconnect()
try await connect(client, fixture: fixture, hostID: hostID)
running = try await execute("tmux display-message -p -t \(target) '#{pane_current_command}'").stdout
#expect(running == "sleep\n")
try await backend.terminate(reference)
var alive = true
for _ in 0..<100 {
let states = try await execute("ps -o stat= -p \(pid),\(jobPID)").stdout
// A minimal container's PID 1 may retain zombies. Neither the shell nor
// its foreground job may remain executable after owned termination.
alive = states.split(whereSeparator: \.isWhitespace).contains { !$0.hasPrefix("Z") && !$0.hasPrefix("X") }
if !alive { break }
try await Task.sleep(for: .milliseconds(10))
}
#expect(!alive)
await client.disconnect()
await control.disconnect()
}
private func loadFixture() throws -> Fixture {
let path = try #require(ProcessInfo.processInfo.environment["TERMIND_TMUX_FIXTURE"])
return try JSONDecoder().decode(Fixture.self, from: Data(contentsOf: URL(fileURLWithPath: path)))
}
private final class ConnectionState {
var serverPID: UInt64?
var exit: Result<Int32, TermindSSHError>?
}
private func connect(_ channel: SSHPTYChannel, fixture: Fixture, hostID: UUID) async throws {
let key = try String(contentsOfFile: fixture.privateKeyPath, encoding: .utf8)
try await channel.connect(
using: SSHConnectionRequest(
configuration: .init(
hostID: hostID, host: "127.0.0.1", port: fixture.port, username: "root",
credentials: [.privateKey(privateKey: SSHSecret(key), publicKey: nil, passphrase: nil)],
expectedHostKeys: [.init(algorithm: .ed25519, fingerprint: fixture.hostFingerprint)])),
shellIntegrationEnabled: false, columns: 80, rows: 24)
}
}