From 7e82343da4cac3af3450ab7e54ad074bfd85e1c7 Mon Sep 17 00:00:00 2001 From: Kaede Akino Date: Fri, 16 Oct 2026 12:37:20 +0800 Subject: [PATCH] fix(terminal): restore and cache context-aware shell history --- .../RemoteShellHistoryLoaderTests.swift | 36 +++++ FrameworkTests/SessionHistoryTests.swift | 108 +++++++++++++ .../FeatureModels/CommandHistoryModel.swift | 63 ++++++++ .../Sessions/TerminalContext.swift | 9 ++ .../SSH/RemoteShellHistoryLoader.swift | 26 +++- .../SSH/SSHPTYChannel.swift | 82 ++++++++-- TFI/Features/Workspaces/MobileWorkspace.swift | 24 ++- ...MobileCommandGeneratorAndUploadTests.swift | 32 ++++ TFM/Features/Terminal/SSHSessionModel.swift | 24 ++- .../HostShellIntegrationSessionTests.swift | 33 ++++ TFMTests/TerminalContextRuntimeTests.swift | 143 +++++++++++++++++- 11 files changed, 553 insertions(+), 27 deletions(-) diff --git a/FrameworkTests/RemoteShellHistoryLoaderTests.swift b/FrameworkTests/RemoteShellHistoryLoaderTests.swift index fb46c403..c0c70fb9 100644 --- a/FrameworkTests/RemoteShellHistoryLoaderTests.swift +++ b/FrameworkTests/RemoteShellHistoryLoaderTests.swift @@ -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) } diff --git a/FrameworkTests/SessionHistoryTests.swift b/FrameworkTests/SessionHistoryTests.swift index 22ad0847..daa642c7 100644 --- a/FrameworkTests/SessionHistoryTests.swift +++ b/FrameworkTests/SessionHistoryTests.swift @@ -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() diff --git a/Frameworks/TermindApplication/FeatureModels/CommandHistoryModel.swift b/Frameworks/TermindApplication/FeatureModels/CommandHistoryModel.swift index 2052d888..2a9e6242 100644 --- a/Frameworks/TermindApplication/FeatureModels/CommandHistoryModel.swift +++ b/Frameworks/TermindApplication/FeatureModels/CommandHistoryModel.swift @@ -15,6 +15,22 @@ public final class CommandHistoryModel { private var isClosed = false private var loadTask: Task? 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 = [] + 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 } diff --git a/Frameworks/TermindDomain/Sessions/TerminalContext.swift b/Frameworks/TermindDomain/Sessions/TerminalContext.swift index a51f4967..1680bb0c 100644 --- a/Frameworks/TermindDomain/Sessions/TerminalContext.swift +++ b/Frameworks/TermindDomain/Sessions/TerminalContext.swift @@ -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 diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/RemoteShellHistoryLoader.swift b/Frameworks/TermindRemoteInfrastructure/SSH/RemoteShellHistoryLoader.swift index 7c9acf8e..076f6fe4 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/RemoteShellHistoryLoader.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/RemoteShellHistoryLoader.swift @@ -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 = diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/SSHPTYChannel.swift b/Frameworks/TermindRemoteInfrastructure/SSH/SSHPTYChannel.swift index de313745..99fd455a 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/SSHPTYChannel.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/SSHPTYChannel.swift @@ -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)) diff --git a/TFI/Features/Workspaces/MobileWorkspace.swift b/TFI/Features/Workspaces/MobileWorkspace.swift index d2cd2bfa..a9dfb4b0 100644 --- a/TFI/Features/Workspaces/MobileWorkspace.swift +++ b/TFI/Features/Workspaces/MobileWorkspace.swift @@ -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() diff --git a/TFITests/MobileCommandGeneratorAndUploadTests.swift b/TFITests/MobileCommandGeneratorAndUploadTests.swift index 3aa0e481..fe52ff85 100644 --- a/TFITests/MobileCommandGeneratorAndUploadTests.swift +++ b/TFITests/MobileCommandGeneratorAndUploadTests.swift @@ -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 diff --git a/TFM/Features/Terminal/SSHSessionModel.swift b/TFM/Features/Terminal/SSHSessionModel.swift index 22045a52..57f88c1b 100644 --- a/TFM/Features/Terminal/SSHSessionModel.swift +++ b/TFM/Features/Terminal/SSHSessionModel.swift @@ -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. diff --git a/TFMTests/HostShellIntegrationSessionTests.swift b/TFMTests/HostShellIntegrationSessionTests.swift index 7e969742..582a17dd 100644 --- a/TFMTests/HostShellIntegrationSessionTests.swift +++ b/TFMTests/HostShellIntegrationSessionTests.swift @@ -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) diff --git a/TFMTests/TerminalContextRuntimeTests.swift b/TFMTests/TerminalContextRuntimeTests.swift index 2403074a..2d4e1662 100644 --- a/TFMTests/TerminalContextRuntimeTests.swift +++ b/TFMTests/TerminalContextRuntimeTests.swift @@ -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) } }