Files
Termind/Frameworks/TermindRemoteInfrastructure/SSH/RemoteMultiplexerRuntimeAdapter.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

340 lines
16 KiB
Swift

import Foundation
import TermindApplication
import TermindDomain
nonisolated struct RemoteMultiplexerDraftTarget {
let root: String
let trigger: String
}
public nonisolated struct RemoteMultiplexerRuntimeAdapter: TerminalRuntimeAdapter {
private let attachment: RemoteSessionReference?
private let execute: RemoteSessionCommandExecutor
private let charset: RemoteCharset
public init(
attachment: RemoteSessionReference? = nil, execute: @escaping RemoteSessionCommandExecutor,
charset: RemoteCharset = .utf8
) {
self.attachment = attachment
self.execute = execute
self.charset = charset
}
private var dialect: any RemoteMultiplexerDialect {
get throws {
guard let dialect = RemoteMultiplexerRegistry.dialect(attachment?.backend ?? .tmux) else {
throw TerminalRuntimeError.unsupported
}
return dialect
}
}
public func inspect(terminal: String) async throws -> TerminalRuntimeUpdate {
// Preserve the native tmux TTY lookup and integrated journal path. Other backends
// can still provide a read-only directory when no Termind helper was installed.
if let attachment {
_ = try dialect
do {
let update = try await dialect.runtime(reference: attachment, execute: execute, charset: charset)
.inspect(terminal: terminal)
if update.active != nil { return update }
} catch is CancellationError {
throw CancellationError()
} catch {}
} else {
do {
let update = try await RemoteTmuxRuntimeDialect(execute: execute, charset: charset)
.inspect(terminal: terminal)
if update.status == .multiplexer || update.backend != nil { return update }
} catch is CancellationError {
throw CancellationError()
} catch {}
}
guard let client = try await RemoteTerminalProcessProbe(execute: execute).inspect(terminal: terminal) else {
return .init(status: .unknown)
}
do {
if let attachment, client.backend == attachment.backend,
let identity = try? RemoteMultiplexerIdentity.decode(
attachment.session.identity, kind: attachment.backend),
identity.helper != nil,
client.endpoint
== (client.backend == .herdr ? identity.root + "/herdr-client.sock" : identity.socket)
{
do {
let update = try await RemoteSocketMultiplexerRuntime(
reference: attachment, execute: execute, charset: charset, boundClient: client
)
.inspect(terminal: terminal)
if update.active != nil,
try await RemoteTerminalProcessProbe(execute: execute).inspect(terminal: terminal) == client
{
return update
}
} catch is CancellationError {
throw CancellationError()
} catch {}
}
return try await RemoteNativeMultiplexerRuntime(client: client, execute: execute).inspect(
terminal: terminal)
} catch is CancellationError {
throw CancellationError()
} catch {
return .init(
status: .multiplexer, clientIdentity: client.identity, backend: client.backend,
issue: .inspectionUnavailable)
}
}
static func helper(for attachment: RemoteSessionReference?) throws -> String? {
guard let attachment else { return nil }
guard let dialect = RemoteMultiplexerRegistry.dialect(attachment.backend) else {
throw TerminalRuntimeError.unsupported
}
return try dialect.helper(attachment)
}
func draftTarget(
context: TerminalShellContext, terminal: String, replacing: Bool
) async throws -> RemoteMultiplexerDraftTarget {
try await dialect.runtime(reference: attachment, execute: execute, charset: charset)
.draftTarget(context: context, terminal: terminal, replacing: replacing)
}
func refreshSize(of client: RemoteTmuxClientIdentity) async throws {
try await RemoteTmuxRuntimeDialect(execute: execute, charset: charset).refreshSize(of: client)
}
}
nonisolated struct RemoteSocketMultiplexerRuntime: RemoteMultiplexerRuntime {
let reference: RemoteSessionReference
let execute: RemoteSessionCommandExecutor
var charset: RemoteCharset = .utf8
var boundClient: RemoteTerminalProcessProbe.Client? = nil
func inspect(terminal: String) async throws -> TerminalRuntimeUpdate {
try await snapshot(terminal: terminal).update
}
private struct Snapshot {
let update: TerminalRuntimeUpdate
let native: String
let focus: String
}
private var dialect: any RemoteSocketMultiplexerDialect {
get throws {
guard let dialect = RemoteMultiplexerRegistry.socketDialect(reference.backend) else {
throw TerminalRuntimeError.unsupported
}
return dialect
}
}
private var identity: RemoteMultiplexerIdentity {
get throws {
try RemoteMultiplexerIdentity.decode(reference.session.identity, kind: dialect.id)
}
}
private func nativeCommands() throws -> (String, String) {
try dialect.runtimeCommands(name: RemoteMultiplexerCommands.quote(reference.session.name))
}
private func clientGuard(terminal: String) throws -> String {
guard RemoteTmuxClientIdentity.validTerminal(terminal), let helper = try identity.helper else {
throw TerminalRuntimeError.unsupported
}
let kind = try identity.kind.rawValue
if let boundClient {
return """
helper=\(try RemoteMultiplexerCommands.quote(helper))
client='\(boundClient.process)'
\(try RemoteTerminalProcessProbe.guardClient(boundClient, terminal: terminal))
"""
}
return """
helper=\(try RemoteMultiplexerCommands.quote(helper))
\(RemoteMultiplexerCommands.stateRoot(try identity.kind, create: false))
meta="$state/\(reference.session.name)"
private_state || exit 79
[ "$(cat "$meta/helper")" = "$helper" ] || exit 79
[ "$(cat "$meta/identity")" = \(try RemoteMultiplexerCommands.quote(identity.fingerprint)) ] || exit 79
key=$(printf '%s' \(try RemoteMultiplexerCommands.quote(terminal)) | od -An -v -tx1 | tr -d ' \\n')
[ ! -L "$helper" ] && [ ! -L "$helper/client-$key" ] || exit 79
IFS='|' read -r client started < "$helper/client-$key" || exit 79
case "$client" in ''|*[!0-9]*) exit 79 ;; esac
[ -n "$started" ] && [ "$(process_start "$client")" = "$started" ] || exit 79
client_tty=$(ps -p "$client" -o tty= | tr -d ' ')
[ "/dev/$client_tty" = \(try RemoteMultiplexerCommands.quote(terminal)) ] || exit 79
# Without a native TTY-to-client API, any other interactive client makes focus ambiguous.
clients=$(ps -u "$(id -u)" -o pid= -o tty= -o comm= | \
awk '$2 != "?" && $2 != "??" {n=split($3,a,"/"); if(a[n]=="\(kind)") print $1}')
[ "$clients" = "$client" ] || exit 79
"""
}
private var shellCheck: String {
get throws {
"""
shell_check() {
case "$1" in ''|*[!0-9]*) return 1 ;; esac
case "$2" in bash|zsh|fish) ;; *) return 1 ;; esac
[ -d "$3" ] && [ ! -L "$3" ] && [ ! -L "$3/process-start" ] || return 1
started=$(cat "$3/process-start") || return 1
[ -n "$started" ] && [ "$(process_start "$1")" = "$started" ] || return 1
parent=$(ps -p "$1" -o ppid= | tr -d ' ') || return 1
\(try identity.process.map { "[ \"$1\" = '\($0)' ] && [ \"$parent\" = '\(try identity.daemonProcess!)' ] || return 1" } ?? "")
parent_name=$(ps -p "$parent" -o comm=)
[ "${parent_name##*/}" = '\(try identity.kind.rawValue)' ] || return 1
name=$(ps -p "$1" -o comm=)
[ "${name##*/}" = "$2" ] || [ "${name##*/}" = "-$2" ] || return 1
}
foreground_shell() {
shell_check "$1" "$2" "$3" || return 1
groups=$(ps -p "$1" -o pgid= -o tpgid=) || return 1
set -- $groups
[ "$#" = 2 ] && [ "$1" = "$2" ]
}
"""
}
}
private func snapshot(terminal: String) async throws -> Snapshot {
let (nativeCommand, focusCommand) = try nativeCommands()
let command = """
\(try RemoteMultiplexerCommands.guardIdentity(reference))
\(try clientGuard(terminal: terminal))
\(try shellCheck)
native=$(\(nativeCommand)) || exit 79
focus=$(\(focusCommand)) || exit 79
printf 'C|%s\\nN|%s\\nA|%s\\n' "$client" "$(hex "$native")" "$(hex "$focus")"
\(Self.journalCommand(backend: try identity.kind))
"""
let result = try await execute(command)
guard result.exitCode == 0, !result.truncated else { throw TerminalRuntimeError.unsupported }
return try Self.parseSnapshot(
result.stdout, reference: reference, charset: charset, clientBound: boundClient != nil)
}
static func journalCommand(backend: SessionPersistenceBackendID) -> String {
"""
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" || exit 78
[ "$tag" = M2 ] && [ "$backend" = '\(backend.rawValue)' ] || continue
[ "$token" = "$(cat "$helper/event-token")" ] || continue
dir=${file%/journal}
[ "${dir##*/}" = "$instance" ] || continue
shell_check "$pid" "$shell" "$dir" || continue
count=$((count + 1)); [ "$count" -le 128 ] || exit 78
foreground=0
if foreground_shell "$pid" "$shell" "$dir"; then foreground=1; fi
printf 'BEGIN|%s\\n' "$foreground"
cat "$file" || exit 78
printf 'END\\n'
done
printf 'DONE\\n'
"""
}
static func parse(
_ text: String, reference: RemoteSessionReference, charset: RemoteCharset = .utf8, clientBound: Bool = false
) throws -> TerminalRuntimeUpdate {
try parseSnapshot(text, reference: reference, charset: charset, clientBound: clientBound).update
}
private static func parseSnapshot(
_ text: String, reference: RemoteSessionReference, charset: RemoteCharset, clientBound: Bool = false
) throws -> Snapshot {
guard text.utf8.count <= 1024 * 1024,
let dialect = RemoteMultiplexerRegistry.socketDialect(reference.backend)
else { throw TerminalRuntimeError.invalidResponse }
let kind = dialect.id
let identity = try RemoteMultiplexerIdentity.decode(reference.session.identity, kind: kind)
let lines = text.components(separatedBy: "\n")
guard lines.count >= 5, lines[0].hasPrefix("C|"), UInt64(lines[0].dropFirst(2)) != nil,
lines[1].hasPrefix("N|"), lines[2].hasPrefix("A|"), lines.suffix(2) == ["DONE", ""]
else { throw TerminalRuntimeError.invalidResponse }
let native = try RemoteMultiplexerCommands.unhex(String(lines[1].dropFirst(2)), limit: 512 * 1024)
let focus = try RemoteMultiplexerCommands.unhex(String(lines[2].dropFirst(2)), limit: 512 * 1024)
let activePane = try dialect.activePane(
native: native, focus: focus, reference: reference, clientBound: clientBound)
var contexts: [TerminalShellContext] = []
var events: [TerminalContextEvent] = []
var active: TerminalContextIdentity?
var index = 3
while index < lines.count - 2 {
guard lines[index] == "BEGIN|0" || lines[index] == "BEGIN|1" else {
throw TerminalRuntimeError.invalidResponse
}
let foreground = lines[index] == "BEGIN|1"
index += 1
let start = index
while index < lines.count - 2, lines[index] != "END" { index += 1 }
guard index < lines.count - 2 else { throw TerminalRuntimeError.invalidResponse }
let record = try RemoteShellContextRecord(lines[start..<index].joined(separator: "\n"), charset: charset)
index += 1
guard record.backend == kind.rawValue else { throw TerminalRuntimeError.invalidResponse }
var context = record.context(
server: identity.fingerprint, session: reference.session.id, backend: kind.rawValue)
if !foreground { context.capabilities.remove(.directory) }
if record.pane == activePane {
guard active == nil else { throw TerminalRuntimeError.invalidResponse }
active = context.identity
if foreground, record.ready, !clientBound {
context.capabilities.formUnion([.readDraft, .replaceDraft])
}
}
contexts.append(context)
events.append(contentsOf: record.events(for: context))
}
guard contexts.count <= 128 else { throw TerminalRuntimeError.invalidResponse }
return Snapshot(
update: .init(
status: .multiplexer, clientIdentity: String(lines[0].dropFirst(2)),
active: active, contexts: contexts, events: events, backend: kind,
issue: active == nil ? .paneAmbiguous : nil),
native: native, focus: focus)
}
func draftTarget(
context: TerminalShellContext, terminal: String, replacing: Bool
) async throws -> RemoteMultiplexerDraftTarget {
let snapshot = try await snapshot(terminal: terminal)
guard snapshot.update.active == context.identity,
snapshot.update.contexts.first(where: { $0.identity == context.identity }) == context,
context.capabilities.contains(replacing ? .replaceDraft : .readDraft),
let helper = try identity.helper
else { throw TerminalRuntimeError.staleContext }
let (nativeCommand, focusCommand) = try nativeCommands()
let guardCommand = """
\(try RemoteMultiplexerCommands.guardIdentity(reference))
\(try clientGuard(terminal: terminal))
[ "$(\(nativeCommand))" = \(try RemoteMultiplexerCommands.quote(snapshot.native)) ] || exit 79
[ "$(\(focusCommand))" = \(try RemoteMultiplexerCommands.quote(snapshot.focus)) ] || exit 79
"""
let key = try RemoteMultiplexerCommands.quote(replacing ? "\u{1B}[777;2~" : "\u{1B}[777;1~")
let name = try RemoteMultiplexerCommands.quote(reference.session.name)
let pane = try RemoteMultiplexerCommands.quote(context.identity.pane)
let write = try dialect.writeDraft(name: name, pane: pane, key: key)
return .init(
root: guardCommand + "\nhelper=\(try RemoteMultiplexerCommands.quote(helper))",
trigger: """
(
\(guardCommand)
\(try shellCheck)
IFS='|' read -r tag token pane instance shell home pid history backend < "$dir/meta" || exit 79
[ "$backend" = '\(context.identity.backend)' ] && [ "$pane" = \(pane) ] \
&& [ "$instance" = '\(context.identity.shellInstance)' ] || exit 79
foreground_shell "$pid" "$shell" "$dir" || exit 79
IFS='|' read -r tag seq ready cwd < "$dir/state" || exit 79
[ "$seq" = '\(context.sequence)' ] && [ "$ready" = 1 ] || exit 79
\(write) >/dev/null || exit 79
) || { rm -f "$dir/request"; exit 79; }
""")
}
}