Files
Termind/Frameworks/TermindRemoteInfrastructure/SSH/RemoteTmuxRuntimeDialect.swift
T
Kaede Akino e457e9af98 fix(multiplexer): preserve session ownership and safe shell startup
Detach managed sessions when native mobile windows are discarded, retain
live reopen references, and fence ordinary startup commands by input and
session generation. Handle noexec helpers, isolate tmux discovery locale,
and count only verified live contexts against the journal limit.

Move actual shell execution coverage into the hostless framework suite.
Validation: 76 focused macOS tests passed; iOS multiplexer regressions
passed with the inherited hosted-test artwork failure still present.
2026-10-17 09:50:37 +08:00

242 lines
14 KiB
Swift

import Foundation
import TermindApplication
import TermindDomain
nonisolated struct RemoteTmuxRuntimeDialect: RemoteMultiplexerRuntime {
private let execute: RemoteSessionCommandExecutor
private let charset: RemoteCharset
init(execute: @escaping RemoteSessionCommandExecutor, charset: RemoteCharset = .utf8) {
self.execute = execute
self.charset = charset
}
func inspect(terminal: String) async throws -> TerminalRuntimeUpdate {
let result = try await execute(Self.command(terminal: terminal))
guard result.exitCode == 0, !result.truncated else { throw TerminalRuntimeError.unsupported }
return try Self.parse(result.stdout, terminal: terminal, charset: charset)
}
func draftTarget(
context: TerminalShellContext, terminal: String, replacing: Bool
) async throws -> RemoteMultiplexerDraftTarget {
let update = try await inspect(terminal: terminal)
guard update.active == context.identity,
update.contexts.first(where: { $0.identity == context.identity }) == context
else { throw TerminalRuntimeError.staleContext }
return try Self.draftTarget(
context: context, terminal: terminal, clientIdentity: update.clientIdentity, replacing: replacing)
}
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 """
\(RemoteTmuxCommands.executionPreamble)
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 RemoteTmuxClientIdentity.validTerminal(terminal) else { throw TerminalRuntimeError.invalidResponse }
return """
\(RemoteTmuxCommands.executionPreamble)
tmux -V || exit 77
client=$(tmux list-clients -F 'C|#{client_tty}|#{client_pid}|#{client_created}|#{pid}|#{session_id}|#{session_created}|#{pane_id}|#{client_flags}|#{client_key_table}' 2>/dev/null | \
while IFS='|' read -r tag tty cp cc sp sid sc pane flags table; do
if [ "$tty" = \(try RemoteTmuxCommands.quote(terminal)) ]; then
printf 'C|%s|%s|%s|%s|%s|%s|%s|%s|%s\\n' "$tty" "$cp" "$cc" "$sp" "$sid" "$sc" "$pane" "$flags" "$table"
fi
done)
if [ -z "$client" ]; then printf 'NONE\\n'; exit 0; fi
printf '%s\\n' "$client"
sid=$(printf '%s' "$client" | cut -d '|' -f6)
panes=$(tmux list-panes -s -t "$sid" -F 'P|#{pane_id}|#{pane_pid}|#{pane_in_mode}|#{synchronize-panes}|#{?#{==:#{pane_current_command},bash},bash,#{?#{==:#{pane_current_command},zsh},zsh,#{?#{==:#{pane_current_command},fish},fish,unknown}}}') || exit 78
printf '%s\\n' "$panes"
pane=$(printf '%s' "$client" | cut -d '|' -f8)
cwd=$(tmux display-message -p -t "$pane" '#{pane_current_path}') || exit 78
foreground=$(tmux display-message -p -t "$pane" '#{pane_current_command}') || exit 78
printf 'D|%s|%s|%s\\n' "$pane" \
"$(printf '%s' "$cwd" | od -An -v -tx1 | tr -d ' \\n')" \
"$(printf '%s' "$foreground" | od -An -v -tx1 | tr -d ' \\n')"
helper=$(tmux show-environment -t "$sid" TERMIND_TMUX_HELPERS 2>/dev/null)
helper=${helper#TERMIND_TMUX_HELPERS=}
case "$helper" in /tmp/termind-shell.*) ;; *) printf 'DONE\\n'; exit 0 ;; esac
suffix=${helper#/tmp/termind-shell.}
case "$suffix" in ''|*[!a-zA-Z0-9]*) printf 'DONE\\n'; exit 0 ;; esac
count=0
for file in "$helper"/ctx.*/journal; do
[ -f "$file" ] && [ ! -L "$file" ] || continue
IFS='|' read -r tag token pane instance shell home pid history backend < "$file" || continue
case "$tag:$backend" in M1:|M2:tmux) ;; *) continue ;; esac
[ "$token" = "$(cat "$helper/event-token")" ] || continue
dir=${file%/journal}
[ ! -L "$dir" ] && [ "${dir##*/}" = "$instance" ] || continue
[ "$(printf '%s\\n' "$panes" | awk -F '|' -v pane="$pane" '$2 == pane {print $3}')" = "$pid" ] || continue
count=$((count + 1)); [ "$count" -le 128 ] || exit 78
printf 'BEGIN\\n'
cat "$file" || exit 78
printf 'END\\n'
done
printf 'DONE\\n'
"""
}
static func parse(_ text: String, terminal: String, charset: RemoteCharset = .utf8) throws -> TerminalRuntimeUpdate
{
var lines = text.components(separatedBy: "\n")
guard !lines.isEmpty else { throw TerminalRuntimeError.invalidResponse }
try RemoteTmuxCommands.validateVersion(lines.removeFirst())
if lines == ["NONE", ""] { return .init(status: .unknown) }
guard let first = lines.first else { throw TerminalRuntimeError.invalidResponse }
let client = first.components(separatedBy: "|")
guard client.count == 10, client[0] == "C", client[1] == terminal,
[2, 3, 4, 6].allSatisfy({ UInt64(client[$0]) != nil }),
client[5].hasPrefix("$"), UInt64(client[5].dropFirst()) != nil,
RemoteShellContextRecord.validPane(client[7])
else { throw TerminalRuntimeError.invalidResponse }
let flags = Set(client[8].split(separator: ",").map(String.init))
guard !flags.contains("active-pane"), !flags.contains("read-only") else {
return .init(status: .unavailable, backend: .tmux, issue: .paneAmbiguous)
}
let server = client[4]
let session = client[5] + ":" + client[6]
let clientIdentity = terminal + ":" + client[2] + ":" + client[3]
var panes: [String: (process: String, interactive: Bool, foregroundShell: String)] = [:]
var records: [RemoteShellContextRecord] = []
var nativeDirectory: String?
var nativeCommand: String?
var hasNativeDirectory = false
var index = 1
while index < lines.count {
let line = lines[index]
index += 1
if line == "DONE" {
guard index == lines.count - 1, lines.last == "" else {
throw TerminalRuntimeError.invalidResponse
}
break
}
if line.hasPrefix("D|") {
let fields = line.components(separatedBy: "|")
guard !hasNativeDirectory, fields.count == 4, fields[1] == client[7] else {
throw TerminalRuntimeError.invalidResponse
}
hasNativeDirectory = true
let directory = try RemoteMultiplexerCommands.unhex(fields[2])
nativeDirectory = RemoteMultiplexerCommands.validPath(directory) ? directory : nil
nativeCommand = try RemoteMultiplexerCommands.unhex(fields[3])
} else if line == "BEGIN" {
let start = index
while index < lines.count, lines[index] != "END" { index += 1 }
guard index < lines.count else { throw TerminalRuntimeError.invalidResponse }
records.append(
try RemoteShellContextRecord(lines[start..<index].joined(separator: "\n"), charset: charset))
index += 1
} else {
let fields = line.components(separatedBy: "|")
guard fields.count == 6, fields[0] == "P", RemoteShellContextRecord.validPane(fields[1]),
UInt64(fields[2]) != nil, ["0", "1"].contains(fields[3]), ["0", "1"].contains(fields[4]),
["bash", "zsh", "fish", "unknown"].contains(fields[5]),
panes[fields[1]] == nil
else { throw TerminalRuntimeError.invalidResponse }
panes[fields[1]] = (fields[2], fields[3] == "0" && fields[4] == "0", fields[5])
}
}
guard lines.dropLast().last == "DONE", panes[client[7]] != nil else {
throw TerminalRuntimeError.invalidResponse
}
var contexts: [TerminalShellContext] = []
var events: [TerminalContextEvent] = []
var active: TerminalContextIdentity?
let nested = RemoteNativeMultiplexerRuntime.isNested(nativeCommand)
for record in records {
guard let pane = panes[record.pane], pane.process == record.process else { continue }
var context = record.context(server: server, session: session, backend: "tmux")
if pane.interactive, pane.foregroundShell == record.shell, context.promptReady {
// A prefix key table changes input routing, not the verified pane's directory.
if client[9] == "root" {
context.capabilities.formUnion([.readDraft, .replaceDraft])
}
} else {
context.capabilities.remove(.directory)
}
contexts.append(context)
events.append(contentsOf: record.events(for: context))
if record.pane == client[7], !nested {
guard active == nil else { throw TerminalRuntimeError.invalidResponse }
active = context.identity
}
}
if active == nil, !nested {
let identity = TerminalContextIdentity(
backend: "tmux", server: server, session: session, pane: client[7],
shellInstance: "unintegrated:" + panes[client[7]]!.process)
contexts.append(
.init(
identity: identity, shell: "", directory: nativeDirectory, home: nil, promptReady: false,
sequence: 0, capabilities: [], directorySource: .nativePane))
active = identity
}
return .init(
status: .multiplexer, clientIdentity: clientIdentity, active: active, contexts: contexts, events: events,
backend: .tmux,
issue: nested
? .nestedMultiplexer
: (active?.shellInstance.hasPrefix("unintegrated:") == true ? .shellIntegrationUnavailable : nil))
}
}
extension RemoteTmuxRuntimeDialect {
static func draftTarget(context: TerminalShellContext, terminal: String, clientIdentity: String?, replacing: Bool)
throws -> RemoteMultiplexerDraftTarget
{
let identity = context.identity
let key = replacing ? "\u{1B}[777;2~" : "\u{1B}[777;1~"
let session = identity.session.split(separator: ":").map(String.init)
let client = clientIdentity?.split(separator: ":").map(String.init) ?? []
guard session.count == 2, session[0].hasPrefix("$"), UInt64(session[0].dropFirst()) != nil,
UInt64(session[1]) != nil, UInt64(identity.server) != nil,
RemoteShellContextRecord.validPane(identity.pane), client.count == 3, client[0] == terminal,
UInt64(client[1]) != nil, UInt64(client[2]) != nil
else { throw TerminalRuntimeError.staleContext }
let target = try RemoteTmuxCommands.quote(session[0])
let root = """
\(RemoteTmuxCommands.executionPreamble)
[ "$(tmux display-message -p -t \(target) '#{pid}:#{session_created}')" = '\(identity.server):\(session[1])' ] || exit 79
[ "$(tmux list-clients -F '#{client_tty}|#{client_pid}|#{client_created}|#{pane_id}|#{client_flags}|#{client_key_table}|#{session_id}|#{session_created}' | \
awk -F '|' -v tty=\(try RemoteTmuxCommands.quote(terminal)) '$1 == tty {print $2 ":" $3 ":" $4 ":" $6 ":" $7 ":" $8; if ($5 ~ /active-pane|read-only/) print "UNSUPPORTED"}')" \
= '\(client[1]):\(client[2]):\(identity.pane):root:\(identity.session)' ] || exit 79
helper=$(tmux show-environment -t \(target) TERMIND_TMUX_HELPERS) || exit 79
helper=${helper#TERMIND_TMUX_HELPERS=}
"""
let guardFormat =
"#{&&:#{==:#{pane_in_mode},0},#{&&:#{==:#{synchronize-panes},0},#{&&:#{==:#{pane_dead},0},#{==:#{pane_current_command},\(context.shell)}}}}"
let trigger = """
tmux if-shell -t \(try RemoteTmuxCommands.quote(identity.pane)) -F \(try RemoteTmuxCommands.quote(guardFormat)) \
\(try RemoteTmuxCommands.quote("send-keys -t \(identity.pane) -l \(try RemoteTmuxCommands.quote(key))")) || exit 79
"""
return .init(root: root, trigger: trigger)
}
}