fix(terminal): restore and cache context-aware shell history

This commit is contained in:
Kaede Akino committed 2026-10-16 12:37:20 +08:00
1 parent 7725e7c1ae
commit 7e82343da4
11 files changed
+553 -27

No files matched your search

@@ -8,6 +8,42 @@
@Suite
struct RemoteShellHistoryLoaderTests {
@Test func explicitlyDisabledHistoryDoesNotOpenAControlChannel() async throws {
let loader = RemoteShellHistoryLoader(
charset: .utf8, resolveSource: { .init(shell: .bash, path: "") },
execute: { _, _ in
Issue.record("Disabled history must not read a default file")
throw TerminalRuntimeError.capacityUnavailable
})
#expect(try await loader.load(source: nil).isEmpty)
}
@Test(arguments: [false, true])
func channelCapacityRetriesAreBoundedAndCanRecover(permanentlyUnavailable: Bool) async throws {
let attempts = ReadAttempts()
let loader = RemoteShellHistoryLoader(
charset: .utf8, resolveSource: { .init(shell: .bash, path: "/nonexistent-\(UUID())") },
execute: { command, _ in
let attempt = await attempts.next()
if permanentlyUnavailable || attempt < 3 { throw TerminalRuntimeError.capacityUnavailable }
return try Self.execute(command)
})
if permanentlyUnavailable {
await #expect(throws: TerminalRuntimeError.self) { try await loader.load(source: nil) }
} else {
#expect(try await loader.load(source: nil).isEmpty)
}
#expect(await attempts.count == 3)
}
private actor ReadAttempts {
private(set) var count = 0
func next() -> Int {
count += 1
return count
}
}
@Test func realZshHistoryPreservesBackslashesBeforeLogicalNewlines() async throws {
let file = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at: file) }
+108
View File
@@ -10,6 +10,114 @@ import Testing
@Suite @MainActor
struct SessionHistoryTests {
@Test func returningToAPaneRestoresStableRowsWithoutAnotherRead() async {
let model = CommandHistoryModel()
let generation = UUID()
let first = historyContext(pane: "a", path: "/a/history")
let second = historyContext(pane: "b", path: "/b/history")
let firstRead = HistoryReadGate()
let secondRead = HistoryReadGate()
model.load(using: firstRead, source: model.selectContext(first, generation: generation))
await firstRead.waitForRead()
await firstRead.finish([.init(command: "first file")])
#expect(await settles { model.hasLoaded })
let firstIDs = model.entries().map(\.id)
let sharedFile = historyContext(pane: "shared", path: "/a/history", instance: "another-shell")
model.load(using: firstRead, source: model.selectContext(sharedFile, generation: generation))
#expect(model.hasLoaded)
#expect(!model.isLoading)
#expect(model.entries().map(\.id) == firstIDs)
#expect(await firstRead.readCount == 1)
model.load(using: secondRead, source: model.selectContext(second, generation: generation))
await secondRead.waitForRead()
await secondRead.finish([])
#expect(await settles { model.hasLoaded })
model.load(using: firstRead, source: model.selectContext(first, generation: generation))
#expect(model.hasLoaded)
#expect(!model.isLoading)
#expect(model.entries().map(\.id) == firstIDs)
#expect(await firstRead.readCount == 1)
model.selectContext(nil, generation: generation)
#expect(model.entries().isEmpty)
model.selectContext(first, generation: generation)
#expect(model.entries().map(\.id) == firstIDs)
model.load(using: secondRead, source: model.selectContext(second, generation: generation))
#expect(model.hasLoaded)
#expect(model.entries().isEmpty)
#expect(await secondRead.readCount == 1)
model.selectContext(first, generation: UUID())
#expect(!model.hasLoaded)
#expect(model.entries().isEmpty)
}
@Test func switchingPanesRejectsOldFileReadsAndKeepsObservedCommands() async throws {
let model = CommandHistoryModel()
let generation = UUID()
let first = historyContext(pane: "a", path: "/a/history")
let second = historyContext(pane: "b", path: "/b/history")
let stale = HistoryReadGate()
let current = HistoryReadGate()
model.record("observed live command")
model.load(using: stale, source: model.selectContext(first, generation: generation))
await stale.waitForRead()
model.load(using: current, source: model.selectContext(second, generation: generation))
await current.waitForRead()
await current.finish([.init(command: "pane b history")])
#expect(await settles { model.hasLoaded })
await stale.finish([.init(command: "pane a history")])
for _ in 0..<20 { await Task.yield() }
#expect(model.entries().map(\.command) == ["observed live command", "pane b history"])
model.selectContext(nil, generation: generation)
#expect(model.entries().map(\.command) == ["observed live command"])
#expect(!model.hasLoaded)
}
@Test func historyFileCacheExpiresOnNewConnectionOrFileButNotPromptSequence() async {
let model = CommandHistoryModel()
var generation = UUID()
var context = historyContext(pane: "a", path: "/a/history")
for change in 0..<3 {
let gate = HistoryReadGate()
model.load(using: gate, source: model.selectContext(context, generation: generation))
await gate.waitForRead()
await gate.finish([.init(command: "snapshot \(change)")])
#expect(await settles { model.hasLoaded })
var typing = context
typing.sequence += 1
typing.promptReady = false
model.selectContext(typing, generation: generation)
model.load(using: gate, source: typing.historySource)
#expect(await gate.readCount == 1)
#expect(model.entries().map(\.command) == ["snapshot \(change)"])
switch change {
case 0: context.historyPath = "/a/new history"
case 1: generation = UUID()
default: break
}
}
}
@Test func unverifiedHistoryNeverFallsBackToAnOuterShellFile() {
var context = historyContext(pane: "a", path: "/a/history")
context.historyPath = nil
#expect(context.historySource == nil)
context.historyPath = "relative"
#expect(context.historySource == nil)
context.historyPath = ""
#expect(context.historySource == .init(shell: .bash, path: ""))
context.shell = "unknown"
#expect(context.historySource == nil)
}
private func historyContext(pane: String, path: String, instance: String = "shell") -> TerminalShellContext {
.init(
identity: .init(backend: "test", server: "server", session: "session", pane: pane, shellInstance: instance),
shell: "bash", directory: "/", home: "/home/test", promptReady: true, sequence: 1,
capabilities: [.commandLifecycle], historyPath: path)
}
@Test func snapshotDoesNotReplaceLiveCommandsAndLoadsOnlyOnce() async throws {
let gate = HistoryReadGate()
let model = CommandHistoryModel()
@@ -15,6 +15,22 @@ public final class CommandHistoryModel {
private var isClosed = false
private var loadTask: Task<Void, Never>?
private var loadID: UUID?
private enum SnapshotScope: Equatable {
case legacy
case context(UUID, TerminalContextIdentity, ShellHistorySource)
case unavailable(UUID)
func sharesFile(with other: Self) -> Bool {
guard case .context(let generation, _, let source) = self,
case .context(let otherGeneration, _, let otherSource) = other
else { return false }
return generation == otherGeneration && source == otherSource
}
}
private var snapshotScope: SnapshotScope = .legacy
private var importedIDs: Set<CommandHistoryEntry.ID> = []
private var snapshotGeneration: UUID?
private var snapshots: [(scope: SnapshotScope, entries: [CommandHistoryEntry])] = []
public init(sessionID: UUID = UUID(), entries: [CommandHistoryEntry] = []) {
self.sessionID = sessionID
@@ -65,6 +81,7 @@ public final class CommandHistoryModel {
guard let self, self.loadID == token, !self.isClosed, !Task.isCancelled else { return }
self.merge(snapshot)
self.hasLoaded = true
self.cacheSnapshot()
self.finishLoading()
} catch {
guard let self, self.loadID == token, !self.isClosed, !Task.isCancelled else { return }
@@ -74,6 +91,41 @@ public final class CommandHistoryModel {
}
}
/// Only the selected shell's file snapshot is visible. Observed live commands remain terminal-wide.
@discardableResult
public func selectContext(_ context: TerminalShellContext?, generation: UUID) -> ShellHistorySource? {
guard !isClosed else { return nil }
if snapshotGeneration != generation {
snapshots.removeAll()
snapshotGeneration = generation
}
let source = context?.historySource
let next: SnapshotScope
if let context, let source {
next = .context(generation, context.identity, source)
} else {
next = .unavailable(generation)
}
if snapshotScope != next {
cancelLoading()
values.removeAll { importedIDs.contains($0.id) }
importedIDs.removeAll()
hasLoaded = false
loadError = nil
snapshotScope = next
if let index = snapshots.firstIndex(where: { $0.scope.sharesFile(with: next) }) {
let cached = snapshots.remove(at: index)
snapshots.append(cached)
values.append(contentsOf: cached.entries)
importedIDs = Set(cached.entries.map(\.id))
trim()
hasLoaded = true
}
revision &+= 1
}
return source
}
/// Disconnect cancels only the current read; reconnect retains a completed snapshot.
public func cancelLoading() {
loadID = nil
@@ -86,6 +138,8 @@ public final class CommandHistoryModel {
isClosed = true
cancelLoading()
values.removeAll()
importedIDs.removeAll()
snapshots.removeAll()
loadError = nil
revision &+= 1
}
@@ -95,6 +149,14 @@ public final class CommandHistoryModel {
isLoading = false
}
private func cacheSnapshot() {
guard case .context = snapshotScope else { return }
snapshots.removeAll { $0.scope.sharesFile(with: snapshotScope) }
snapshots.append((snapshotScope, values.filter { importedIDs.contains($0.id) }))
// Keep recent pane snapshots bounded and memory-only, including successful empty reads.
if snapshots.count > 16 { snapshots.removeFirst(snapshots.count - 16) }
}
private func merge(_ snapshot: [ShellHistoryRecord]) {
// Consume matches one at a time: repeated executions must not collapse into a set.
var unmatched = values
@@ -118,6 +180,7 @@ public final class CommandHistoryModel {
sessionID: sessionID, command: command, executedAt: record.executedAt, duration: record.duration)
}
values.append(contentsOf: imported)
importedIDs.formUnion(imported.map(\.id))
trim()
revision &+= 1
}
@@ -36,6 +36,15 @@ public nonisolated struct TerminalShellContext: Sendable, Equatable {
public var sequence: UInt64
public var capabilities: TerminalContextCapabilities
/// Nil means unverified; an empty path explicitly disables file-backed history.
public var historySource: ShellHistorySource? {
guard let shell = ShellHistorySource.Shell(rawValue: shell),
let historyPath, historyPath.isEmpty || historyPath.hasPrefix("/"),
!historyPath.contains("\0")
else { return nil }
return .init(shell: shell, path: historyPath)
}
public init(
identity: TerminalContextIdentity, shell: String, directory: String?, home: String?,
promptReady: Bool, sequence: UInt64, capabilities: TerminalContextCapabilities, historyPath: String? = nil
@@ -16,6 +16,16 @@ public nonisolated struct RemoteShellHistoryLoader: ShellHistoryLoading {
execute: { try await channel.executeHistoryRead($0, outputLimit: $1) })
}
public init(channel: SSHPTYChannel, charset: RemoteCharset, context: TerminalShellContext) {
self.init(
charset: charset,
resolveSource: {
guard let source = context.historySource else { throw TerminalRuntimeError.staleContext }
return source
},
execute: { try await channel.executeHistoryRead($0, outputLimit: $1, context: context) })
}
init(
charset: RemoteCharset,
resolveSource: @escaping @Sendable () async throws -> ShellHistorySource?,
@@ -41,8 +51,9 @@ public nonisolated struct RemoteShellHistoryLoader: ShellHistoryLoading {
private func read(source: ShellHistorySource?) async throws -> [ShellHistoryRecord] {
let resolved: ShellHistorySource?
if let source { resolved = source } else { resolved = try await resolveSource() }
if resolved?.path == "" { return [] }
let token = UUID().uuidString.replacingOccurrences(of: "-", with: "")
let result = try await execute(Self.command(source: resolved, token: token), 3 * 1024 * 1024)
let result = try await executeRead(Self.command(source: resolved, token: token))
guard result.exitCode == 0, !result.stdoutTruncated else {
throw TermindSSHError.library("History file could not be read.")
}
@@ -67,6 +78,19 @@ public nonisolated struct RemoteShellHistoryLoader: ShellHistoryLoading {
.suffix(HistoryLimits.retainedEntries))
}
private func executeRead(_ command: String) async throws -> SSHExecResult {
// A runtime poll may briefly own the last free channel. Never queue behind the user's PTY.
for attempt in 0..<3 {
try Task.checkCancellation()
do {
return try await execute(command, 3 * 1024 * 1024)
} catch TerminalRuntimeError.capacityUnavailable where attempt < 2 {
try await Task.sleep(for: .milliseconds(100 * (attempt + 1)))
}
}
throw TerminalRuntimeError.capacityUnavailable
}
static func command(source: ShellHistorySource?, token: String) -> String {
let shell = source.map { quote($0.shell.rawValue) } ?? "\"${SHELL##*/}\""
let path =
@@ -891,10 +891,13 @@ public actor SSHPTYChannel {
try await worker.openSFTPChannel()
}
public func executeHistoryRead(_ command: String, outputLimit: Int) async throws -> SSHExecResult {
public func executeHistoryRead(
_ command: String, outputLimit: Int, context: TerminalShellContext? = nil
) async throws -> SSHExecResult {
let cancellation = TransferCancellationToken()
return try await withTaskCancellationHandler {
try await worker.executeHistoryRead(command, outputLimit: outputLimit, cancellation: cancellation)
try await worker.executeHistoryRead(
command, outputLimit: outputLimit, cancellation: cancellation, context: context)
} onCancel: {
cancellation.cancel()
}
@@ -973,6 +976,7 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
private var sftpChannelOwner: SSHMultiplexedSFTPChannelOwner!
private var execChannelOwner: SSHMultiplexedExecChannelOwner!
private var detectedHistorySource: ShellHistorySource?
private var allowsDefaultHistorySource = true
init(
output: @escaping SSHPTYChannel.OutputHandler,
@@ -1525,6 +1529,7 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
try await queue.perform { [self] in
guard ptyResize.generation == generation else { throw CancellationError() }
runtimeEnabled = runtimeIsEnabled
allowsDefaultHistorySource = launch == nil && !runtimeIsEnabled
runtimeWantsObservation = runtimeIsEnabled && launch != nil
if runtimeIsEnabled {
contextFilter = .init(token: runtimeToken.uuidString, charset: charset)
@@ -2197,12 +2202,9 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
}
}
latestRuntimeUpdate = update
if update.status == .ordinary,
let context = update.contexts.first(where: { $0.identity == update.active }),
let path = context.historyPath, let shell = ShellHistorySource.Shell(rawValue: context.shell)
{
detectedHistorySource = .init(shell: shell, path: path)
}
detectedHistorySource =
update.status == .ordinary || update.status == .multiplexer
? update.contexts.first(where: { $0.identity == update.active })?.historySource : nil
shellIntegrationHandler(.runtime(update))
}
@@ -2423,6 +2425,7 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
runtimeTask = nil
runtimeTerminal = nil
runtimeEnabled = false
allowsDefaultHistorySource = true
runtimeWantsObservation = false
runtimeControlUnavailable = false
runtimeRevision &+= 1
@@ -2587,11 +2590,60 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
nonisolated extension SSHPTYChannelWorker {
fileprivate func executeHistoryRead(
_ command: String, outputLimit: Int, cancellation: TransferCancellationToken
_ command: String, outputLimit: Int, cancellation: TransferCancellationToken,
context expected: TerminalShellContext?
) async throws -> SSHExecResult {
try await execChannelOwner.execute(
let generation = try await queue.perform { [self] in
guard connection != nil else { throw TermindSSHError.disconnected }
return ptyResize.generation
}
if let expected { try await confirmHistoryContext(expected) }
let result = try await execChannelOwner.execute(
command, workingDirectory: nil, stdin: nil, timeout: 10,
outputLimit: outputLimit, cancellation: cancellation, onOutput: nil, usesLoginShell: false)
outputLimit: outputLimit, cancellation: cancellation, onOutput: nil, usesLoginShell: false,
retriesChannelOpen: false, requiresImmediateCapacity: true,
validateContext: { [self] in
ptyResize.generation == generation
&& (expected.map(matchesHistoryContext) ?? allowsDefaultHistorySource)
})
if let expected { try await confirmHistoryContext(expected) }
try Task.checkCancellation()
try await queue.perform { [self] in
guard connection != nil, ptyResize.generation == generation else { throw CancellationError() }
}
return result
}
private func matchesHistoryContext(_ expected: TerminalShellContext) -> Bool {
guard let update = latestRuntimeUpdate,
update.status == .ordinary || update.status == .multiplexer,
update.active == expected.identity, let source = expected.historySource
else { return false }
return update.contexts.first(where: { $0.identity == expected.identity })?.historySource == source
}
private func confirmHistoryContext(_ expected: TerminalShellContext) async throws {
let state = try await queue.perform { [self] in
guard matchesHistoryContext(expected), let terminal = runtimeTerminal else {
throw TerminalRuntimeError.staleContext
}
return (terminal, ptyResize.generation, charset)
}
if expected.identity.backend != "shell" {
let execute = try await sessionCommandExecutor(runtime: true)
let update = try await RemoteTmuxRuntimeAdapter(execute: execute, charset: state.2)
.inspect(terminal: state.0)
guard update.status == .multiplexer, update.active == expected.identity,
update.contexts.first(where: { $0.identity == expected.identity })?.historySource
== expected.historySource
else { throw TerminalRuntimeError.staleContext }
}
try Task.checkCancellation()
try await queue.perform { [self] in
guard ptyResize.generation == state.1, matchesHistoryContext(expected) else {
throw TerminalRuntimeError.staleContext
}
}
}
fileprivate func historySource() async throws -> ShellHistorySource? {
@@ -2600,11 +2652,9 @@ nonisolated extension SSHPTYChannelWorker {
try Task.checkCancellation()
let (source, pending) = try await queue.perform { [self] in
guard connection != nil else { throw TermindSSHError.disconnected }
return (
detectedHistorySource,
shellIntegration != nil
|| (runtimeEnabled && runtimeHelper != nil && latestRuntimeUpdate?.active == nil)
)
// Runtime shells must use a context-bound read, even before the UI receives their update.
guard allowsDefaultHistorySource else { throw TerminalRuntimeError.staleContext }
return (detectedHistorySource, shellIntegration != nil)
}
if !pending || source != nil || ContinuousClock.now >= deadline { return source }
try await Task.sleep(for: .milliseconds(50))
+21 -3
View File
@@ -134,6 +134,7 @@ final class MobileWorkspace: Identifiable {
private var activeHistoryID: UUID?
let terminalContext = TerminalContextCoordinator()
private var hasRuntimeContext = false
private var historyCharset: RemoteCharset = .utf8
private var contextHistory: [TerminalContextIdentity: UUID] = [:]
/// Resolved once at the beginning of each connection attempt.
private(set) var isShellIntegrationEnabled = true
@@ -649,6 +650,7 @@ final class MobileWorkspace: Identifiable {
private func resetCommandGeneratorShellState() {
terminalContext.reset()
if hasRuntimeContext { commandHistory.selectContext(nil, generation: terminalContext.generation) }
hasRuntimeContext = false
contextHistory.removeAll()
cancelCommandGeneration()
@@ -742,6 +744,7 @@ final class MobileWorkspace: Identifiable {
hasRuntimeContext = true
let previous = terminalContext.revision
let events = terminalContext.receive(update, generation: terminalContext.generation)
loadContextHistory()
commandMonitor.receiveRuntimeEvents(events)
if previous != terminalContext.revision {
commandDraft.cancel()
@@ -775,6 +778,16 @@ final class MobileWorkspace: Identifiable {
}
}
private func loadContextHistory() {
let context = terminalContext.active
let source = commandHistory.selectContext(context, generation: terminalContext.generation)
guard descriptor.kind == .ssh, let context, let source, let ssh, commandHistory.loadError == nil else {
return
}
commandHistory.load(
using: RemoteShellHistoryLoader(channel: ssh, charset: historyCharset, context: context), source: source)
}
private func restoreTransport() {
guard !restoringTransport else { return }
restoringTransport = true
@@ -1023,6 +1036,7 @@ final class MobileWorkspace: Identifiable {
handlers.exit,
handlers.shellIntegration)
ssh = session
historyCharset = host.charset
if transport.isResumable {
recoveryRecord = TerminalRecoveryRecord(
id: recoveryID, host: host, username: config.username, transport: transport)
@@ -1190,9 +1204,13 @@ final class MobileWorkspace: Identifiable {
proxySubmissions.removeAll()
let wasReconnecting = connection.markConnected()
connected = true
if descriptor.kind == .ssh, let ssh, sessionPersistence.attachment == nil {
commandHistory.load(
using: RemoteShellHistoryLoader(channel: ssh, charset: host.charset), source: nil)
if descriptor.kind == .ssh, let ssh {
if hasRuntimeContext, terminalContext.active?.historySource != nil {
loadContextHistory()
} else {
commandHistory.load(
using: RemoteShellHistoryLoader(channel: ssh, charset: host.charset), source: nil)
}
}
status = String(localized: AppStrings.Connections.connected)
journey.succeed()
@@ -94,6 +94,38 @@ private final class MobileSSHCommandConnectionBox {
@Suite @MainActor
struct MobileCommandGeneratorTests {
@Test(arguments: [false, true])
func paneHistoryInvalidationDoesNotDependOnSessionPersistence(managed: Bool) async throws {
let (host, library) = try commandFixture()
let workspace = MobileWorkspace(
descriptor: .init(hostID: host.id, title: host.name, kind: .ssh), hosts: library)
defer { workspace.disconnect() }
if managed {
workspace.sessionPersistence.attachment = .init(
backend: .tmux, session: .init(id: "test", name: "Test", identity: Data()),
hostID: host.id, endpoint: "test:22", username: "user", ownershipToken: UUID())
}
let context = TerminalShellContext(
identity: .init(backend: "test", server: "s", session: "s", pane: "a", shellInstance: "i"),
shell: "bash", directory: "/", home: "/home/test", promptReady: true, sequence: 1,
capabilities: [.commandLifecycle], historyPath: "/home/test/history")
workspace.receiveShellEvent(
.runtime(.init(status: .multiplexer, active: context.identity, contexts: [context])))
workspace.commandHistory.load(using: HistoryLoader(), source: context.historySource)
#expect(await eventually { workspace.commandHistory.hasLoaded })
#expect(workspace.commandHistory.entries().map(\.command) == ["imported"])
workspace.receiveShellEvent(
.runtime(.init(status: .multiplexer, active: context.identity, contexts: [context])))
#expect(workspace.commandHistory.hasLoaded)
workspace.receiveShellEvent(.runtime(.init(status: .unknown)))
#expect(workspace.commandHistory.entries().isEmpty)
#expect(!workspace.commandHistory.hasLoaded)
}
private struct HistoryLoader: ShellHistoryLoading {
func load(source: ShellHistorySource?) async throws -> [ShellHistoryRecord] { [.init(command: "imported")] }
}
@Test func runtimePaneInvalidationClearsDirectoryAndIgnoresUnscopedEvents() async throws {
var (host, library) = try commandFixture()
host.shellIntegrationMode = .enabled
+21 -3
View File
@@ -97,6 +97,7 @@ final class SSHSessionModel: RemotePathDisplaying, Identifiable {
private var activeShellCommand: ActiveShellCommand?
let terminalContext = TerminalContextCoordinator()
private var hasRuntimeContext = false
private var historyCharset: RemoteCharset = .utf8
private var contextHistory: [TerminalContextIdentity: UUID] = [:]
private(set) var commandGeneratorRevision: UInt64 = 0
private let commandDraft = TerminalCommandDraftController()
@@ -411,6 +412,7 @@ final class SSHSessionModel: RemotePathDisplaying, Identifiable {
private func resetCommandGeneratorShellState() {
terminalContext.reset()
if hasRuntimeContext { commandHistory.selectContext(nil, generation: terminalContext.generation) }
hasRuntimeContext = false
contextHistory.removeAll()
integratedShell = nil
@@ -877,6 +879,7 @@ final class SSHSessionModel: RemotePathDisplaying, Identifiable {
}
)
ssh = actor
historyCharset = host.charset
do {
if transport.isResumable {
let snapshot = store.additionalConnectionSnapshot(from: configuration)
@@ -970,9 +973,15 @@ final class SSHSessionModel: RemotePathDisplaying, Identifiable {
temporaryHostKeys.removeAll()
_ = connection.markConnected()
status = .connected
if transport == .ssh && sessionPersistence.attachment == nil {
commandHistory.load(using: RemoteShellHistoryLoader(channel: actor, charset: host.charset), source: nil)
} else if sessionPersistence.attachment != nil {
if transport == .ssh {
if hasRuntimeContext, terminalContext.active?.historySource != nil {
loadContextHistory()
} else {
commandHistory.load(
using: RemoteShellHistoryLoader(channel: actor, charset: host.charset), source: nil)
}
}
if sessionPersistence.attachment != nil {
pendingInitialCommand = nil
pendingConnectionStartupCommand = nil
homeResolution = .unavailable
@@ -1358,6 +1367,7 @@ final class SSHSessionModel: RemotePathDisplaying, Identifiable {
hasRuntimeContext = true
let previous = terminalContext.revision
let events = terminalContext.receive(update, generation: terminalContext.generation)
loadContextHistory()
commandMonitor.receiveRuntimeEvents(events)
if previous != terminalContext.revision {
commandDraft.cancel()
@@ -1392,6 +1402,14 @@ final class SSHSessionModel: RemotePathDisplaying, Identifiable {
}
}
private func loadContextHistory() {
let context = terminalContext.active
let source = commandHistory.selectContext(context, generation: terminalContext.generation)
guard transport == .ssh, let context, let source, let ssh, commandHistory.loadError == nil else { return }
commandHistory.load(
using: RemoteShellHistoryLoader(channel: ssh, charset: historyCharset, context: context), source: source)
}
/// Submits an explicit one-shot launch command, or this connection's freshly
/// resolved Host startup command. Keeping the History exclusion next to the
/// write ensures it is armed only when the command actually runs.
@@ -9,6 +9,39 @@ import struct TermindDomain.Host
@Suite(.serialized) @MainActor
struct HostShellIntegrationSessionTests {
@Test(arguments: [false, true])
func paneHistoryInvalidationDoesNotDependOnSessionPersistence(managed: Bool) async {
let session = SSHSessionModel(hostID: UUID(), title: "History")
defer { session.disconnect() }
if managed {
session.sessionPersistence.attachment = .init(
backend: .tmux, session: .init(id: "test", name: "Test", identity: Data()),
hostID: session.hostID, endpoint: "test:22", username: "user", ownershipToken: UUID())
}
let context = TerminalShellContext(
identity: .init(backend: "test", server: "s", session: "s", pane: "a", shellInstance: "i"),
shell: "bash", directory: "/", home: "/home/test", promptReady: true, sequence: 1,
capabilities: [.commandLifecycle], historyPath: "/home/test/history")
session.receiveShellIntegrationEvent(
.runtime(.init(status: .multiplexer, active: context.identity, contexts: [context])))
session.commandHistory.load(using: HistoryLoader(), source: context.historySource)
for _ in 0..<1000 {
if session.commandHistory.hasLoaded { break }
await Task.yield()
}
#expect(session.commandHistory.entries().map(\.command) == ["imported"])
session.receiveShellIntegrationEvent(
.runtime(.init(status: .multiplexer, active: context.identity, contexts: [context])))
#expect(session.commandHistory.hasLoaded)
session.receiveShellIntegrationEvent(.runtime(.init(status: .unknown)))
#expect(session.commandHistory.entries().isEmpty)
#expect(!session.commandHistory.hasLoaded)
}
private struct HistoryLoader: ShellHistoryLoading {
func load(source: ShellHistorySource?) async throws -> [ShellHistoryRecord] { [.init(command: "imported")] }
}
@Test func runtimeContextGatesEnhancementsWithoutPersistence() throws {
let host = Host(name: "Server", host: "server.invalid", shellIntegrationMode: .enabled)
let session = SSHSessionModel(hostID: host.id, title: host.name)
+139 -4
View File
@@ -31,6 +31,68 @@ struct TerminalContextRuntimeTests {
}
}
@Test(.timeLimit(.minutes(2)), arguments: ["bash", "zsh", "fish"])
func managedHistoryReadsTheConfirmedPaneFileWithoutTerminalInput(shell: String) async throws {
let fixture = try loadFixture()
guard (fixture.maxSessions ?? 1) > 1 else { return }
let state = State()
let channel = makeChannel(state)
let root = "/tmp/termind-history-\(UUID().uuidString)"
let firstPath = shell == "fish" ? root + "/fish/pane-a_history" : root + "/a ' history"
let secondPath = shell == "fish" ? root + "/fish/pane-b_history" : root + "/b ' history"
let setup = """
mkdir -p \(try RemoteTmuxCommands.quote(root + "/fish"))
printf %s \(try RemoteTmuxCommands.quote(historyContents("echo from-pane-a", shell: shell))) > \(try RemoteTmuxCommands.quote(firstPath))
printf %s \(try RemoteTmuxCommands.quote(historyContents("echo from-pane-b", shell: shell))) > \(try RemoteTmuxCommands.quote(secondPath))
"""
await channel.configureStartup { execute in
_ = try await execute(setup)
let backend = RemoteTmuxService(execute: execute)
let owner = UUID()
let session = try await backend.create(
token: owner, startupCommand: nil, shellIntegration: true,
environment: [
.init(variable: "SHELL", value: "/bin/\(shell)"),
.init(variable: "HISTFILE", value: firstPath),
.init(variable: "XDG_DATA_HOME", value: root),
.init(variable: "fish_history", value: "pane-a"),
])
let reference = RemoteSessionReference(
backend: .tmux, session: session, hostID: UUID(), endpoint: "fixture", username: "root",
ownershipToken: owner)
await MainActor.run { state.reference = reference }
return try SSHPTYStartupPlan(command: backend.launch(reference, created: true).command)
}
do {
try await connect(channel, fixture)
try await wait { state.coordinator.active?.promptReady == true }
let first = try #require(state.coordinator.active)
#expect(first.historySource?.path == firstPath)
let firstLoader = RemoteShellHistoryLoader(channel: channel, charset: .utf8, context: first)
#expect(try await firstLoader.load(source: nil).map(\.command) == ["echo from-pane-a"])
let execute = try await channel.runtimeCommandExecutor()
_ = try await execute(
"tmux split-window -h -t \(try RemoteTmuxCommands.quote(first.identity.pane))"
+ " -e \(try RemoteTmuxCommands.quote("HISTFILE=" + secondPath)) -e fish_history=pane-b")
try await wait {
state.coordinator.active?.identity != first.identity && state.coordinator.active?.promptReady == true
}
let second = try #require(state.coordinator.active)
#expect(second.historySource?.path == secondPath)
await #expect(throws: TerminalRuntimeError.self) { try await firstLoader.load(source: nil) }
let secondLoader = RemoteShellHistoryLoader(channel: channel, charset: .utf8, context: second)
#expect(try await secondLoader.load(source: nil).map(\.command) == ["echo from-pane-b"])
_ = try await execute("rm -rf \(try RemoteTmuxCommands.quote(root))")
try await cleanup(channel, state)
} catch {
if let execute = try? await channel.sessionCommandExecutor() {
_ = try? await execute("rm -rf \(try RemoteTmuxCommands.quote(root))")
}
try? await cleanup(channel, state)
throw error
}
}
@Test(.timeLimit(.minutes(2)), arguments: ["bash", "zsh", "fish"])
func managedContextsDraftsAndInvisibleCompletions(shell: String) async throws {
let fixture = try loadFixture()
@@ -201,38 +263,76 @@ struct TerminalContextRuntimeTests {
guard (fixture.maxSessions ?? 1) > 1 else { return }
let state = State()
let channel = makeChannel(state)
let root = "/tmp/termind-manual-history-\(UUID().uuidString)"
let path = shell == "fish" ? root + "/fish/manual_history" : root + "/history"
var stage = "ordinary draft"
await channel.configureEnvironment([
.init(variable: "TERMIND_TMUX_SHELL", value: "/bin/\(shell)"), .init(variable: "LC_ALL", value: "C.UTF-8"),
])
do {
try await connect(channel, fixture)
try await wait { state.coordinator.status == .ordinary }
try await wait { state.coordinator.status == .ordinary && state.coordinator.active?.promptReady == true }
let outer = try #require(state.coordinator.active)
#expect(outer.shell == shell)
await channel.write(Data("echo scratch".utf8))
#expect(try await channel.contextDraft(outer) == "echo scratch")
await channel.write(Data([0x15]))
stage = "ordinary history"
let outerHistory = RemoteShellHistoryLoader(channel: channel, charset: .utf8, context: outer)
_ = try await outerHistory.load(source: nil)
await #expect(throws: TerminalRuntimeError.self) {
try await RemoteShellHistoryLoader(channel: channel, charset: .utf8).load(source: nil)
}
let execute = try await channel.runtimeCommandExecutor()
_ = try await execute(
"mkdir -p \(try RemoteTmuxCommands.quote(root + "/fish")); printf %s "
+ "\(try RemoteTmuxCommands.quote(historyContents("echo manual-pane", shell: shell)))"
+ " > \(try RemoteTmuxCommands.quote(path))")
let backend = RemoteTmuxService(execute: execute)
let owner = UUID()
let session = try await backend.create(token: owner, startupCommand: "cd /tmp", shellIntegration: true)
let session = try await backend.create(
token: owner, startupCommand: "cd /tmp", shellIntegration: true,
environment: [
.init(variable: "SHELL", value: "/bin/\(shell)"),
.init(variable: "HISTFILE", value: path),
.init(variable: "XDG_DATA_HOME", value: root),
.init(variable: "fish_history", value: "manual"),
])
let reference = RemoteSessionReference(
backend: .tmux, session: session, hostID: UUID(), endpoint: "fixture", username: "root",
ownershipToken: owner)
state.reference = reference
stage = "manual attach history"
await channel.write(Data("tmux attach-session -t '\(session.id)'\n".utf8))
try await wait { state.coordinator.status == .multiplexer && state.coordinator.active?.promptReady == true }
#expect(state.coordinator.directory == "/tmp")
let pane = try #require(state.coordinator.active)
#expect(pane.historySource?.path == path)
let paneHistory = RemoteShellHistoryLoader(channel: channel, charset: .utf8, context: pane)
#expect(try await paneHistory.load(source: nil).map(\.command) == ["echo manual-pane"])
if outer.historySource?.path == "" {
#expect(try await outerHistory.load(source: nil).isEmpty)
} else {
await #expect(throws: TerminalRuntimeError.self) { try await outerHistory.load(source: nil) }
}
await channel.write(Data([0x02, 0x64]))
stage = "detach history"
try await wait { state.coordinator.status == .ordinary }
#expect(state.coordinator.active?.identity == outer.identity)
_ = try await outerHistory.load(source: nil)
await #expect(throws: TerminalRuntimeError.self) { try await paneHistory.load(source: nil) }
#expect(
state.events.contains {
if case .commandFinished = $0.payload { return $0.context == outer.identity }
return false
})
_ = try await execute("rm -rf \(try RemoteTmuxCommands.quote(root))")
try await cleanup(channel, state)
} catch {
Issue.record("Manual history acceptance failed at \(stage) for \(shell): \(error)")
if let execute = try? await channel.sessionCommandExecutor() {
_ = try? await execute("rm -rf \(try RemoteTmuxCommands.quote(root))")
}
try? await cleanup(channel, state)
throw error
}
@@ -254,6 +354,11 @@ struct TerminalContextRuntimeTests {
_ = try await channel.contextDraft(context)
Issue.record("The occupied channel budget must refuse a draft exec")
} catch {}
do {
_ = try await RemoteShellHistoryLoader(channel: channel, charset: .utf8, context: context)
.load(source: nil)
Issue.record("The occupied channel budget must refuse a history exec")
} catch {}
await channel.write(Data("printf 'TERMINAL_STILL_WORKS\\n'\n".utf8))
try await wait { String(decoding: state.output, as: UTF8.self).contains("TERMINAL_STILL_WORKS\r\n") }
await channel.disconnect()
@@ -284,6 +389,9 @@ struct TerminalContextRuntimeTests {
try await wait { state.coordinator.status == .unavailable }
#expect(state.coordinator.directory == nil)
#expect(state.coordinator.ticket(requiring: .replaceDraft) == nil)
await #expect(throws: TerminalRuntimeError.self) {
try await RemoteShellHistoryLoader(channel: channel, charset: .utf8).load(source: nil)
}
await channel.write(Data("printf 'SERVER_LIMIT_%s\\n' TERMINAL_OK\n".utf8))
try await wait { String(decoding: state.output, as: UTF8.self).contains("SERVER_LIMIT_TERMINAL_OK") }
try await cleanup(channel, state)
@@ -293,6 +401,23 @@ struct TerminalContextRuntimeTests {
}
}
@Test(.timeLimit(.minutes(1)), arguments: [false, true])
func plainShellHistoryStillLoadsWithoutIntegration(prepared: Bool) async throws {
let fixture = try loadFixture()
guard (fixture.maxSessions ?? 1) > 1 else { return }
let state = State()
let channel = makeChannel(state)
if prepared { await channel.configureStartup { _ in nil } }
do {
try await connect(channel, fixture, shellIntegrationEnabled: false)
_ = try await RemoteShellHistoryLoader(channel: channel, charset: .utf8).load(source: nil)
await channel.disconnect()
} catch {
await channel.disconnect()
throw error
}
}
@Test(.timeLimit(.minutes(1)))
func loginScriptAutoAttachNeverReceivesBootstrapInput() async throws {
let fixture = try loadFixture()
@@ -353,6 +478,14 @@ struct TerminalContextRuntimeTests {
}
}
private func historyContents(_ command: String, shell: String) -> String {
switch shell {
case "zsh": ": 123:0;\(command)\n"
case "fish": "- cmd: \(command)\n when: 123\n"
default: "#123\n\(command)\n"
}
}
private func makeChannel(_ state: State) -> SSHPTYChannel {
SSHPTYChannel(
output: { data in Task { @MainActor in state.output.append(data) } }, exit: { _ in },
@@ -381,7 +514,9 @@ struct TerminalContextRuntimeTests {
throw TerminalRuntimeError.invalidResponse
}
private func connect(_ channel: SSHPTYChannel, _ fixture: Fixture) async throws {
private func connect(
_ channel: SSHPTYChannel, _ fixture: Fixture, shellIntegrationEnabled: Bool = true
) async throws {
let key = try String(contentsOfFile: fixture.privateKeyPath, encoding: .utf8)
try await channel.connect(
using: SSHConnectionRequest(
@@ -389,6 +524,6 @@ struct TerminalContextRuntimeTests {
hostID: UUID(), 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: true, columns: 100, rows: 30)
shellIntegrationEnabled: shellIntegrationEnabled, columns: 100, rows: 30)
}
}