From 4fa7fcc4280eaf749eb0e951eb483c4243400a4f Mon Sep 17 00:00:00 2001 From: Kaede Akino Date: Sun, 18 Oct 2026 21:39:14 +0800 Subject: [PATCH] feat: refine terminal resize --- .../ResumableTerminalEngineTests.swift | 127 ++++++++++++- .../SSH/ResumableTerminalClient.swift | 53 ++++-- .../SSH/SSHPTYChannel.swift | 35 ++-- .../SSH/SSHPTYStartupSizing.swift | 31 ++++ .../PersistentTerminalSurfaceView.swift | 10 ++ TFM/Features/Terminal/SSHSessionModel.swift | 36 +++- .../HostShellIntegrationSessionTests.swift | 107 +++++++++++ TFMTests/SSHPTYStartupSizingTests.swift | 168 ++++++++++++++++++ TFMTests/TerminalResizeThrottleTests.swift | 140 +++++++++++++++ 9 files changed, 680 insertions(+), 27 deletions(-) create mode 100644 Frameworks/TermindRemoteInfrastructure/SSH/SSHPTYStartupSizing.swift create mode 100644 TFMTests/SSHPTYStartupSizingTests.swift create mode 100644 TFMTests/TerminalResizeThrottleTests.swift diff --git a/FrameworkTests/ResumableTerminalEngineTests.swift b/FrameworkTests/ResumableTerminalEngineTests.swift index 4128fd45..cb7bb476 100644 --- a/FrameworkTests/ResumableTerminalEngineTests.swift +++ b/FrameworkTests/ResumableTerminalEngineTests.swift @@ -7,6 +7,114 @@ import Testing @Suite struct ResumableTerminalEngineTests { + @Test func restoreStartsWithTheLatestViewportAcceptedDuringPreparationWithoutRedundantResize() async throws { + let events = EngineEvents() + let engine = TestEngine(events: events, gates: ["prepare"]) + let client = client(engine, events: events) + let connecting = Task { try await connect(client) } + await events.wait(for: "gated:prepare") + try await client.resize(columns: 100, rows: 30) + try await client.resize(columns: 132, rows: 43) + try await client.resize(columns: 132, rows: 43) + #expect(!events.values.contains(where: { $0.hasPrefix("resize:") || $0.hasPrefix("start:") })) + await engine.release("prepare") + try await connecting.value + #expect(await engine.preparedPayload == checkpoint.payload) + #expect(events.values.filter { $0.hasPrefix("start:") } == ["start:132x43"]) + #expect(!events.values.contains(where: { $0.hasPrefix("resize:") })) + #expect(await engine.appliedGrid == "132x43") + #expect(events.values.last == "state:connected") + await client.close() + } + + @Test func startupPreservesTerminalProtocolRepliesBeforePublishingConnected() async throws { + let events = EngineEvents() + let engine = TestEngine(events: events, gates: ["start"]) + let client = client(engine, events: events) + let connecting = Task { try await connect(client) } + await events.wait(for: "gated:start") + await events.emit(.connected) + let reply = "\u{1B}[1;1R" + try await client.write(Data(reply.utf8)) + #expect(events.values.contains("write:\(reply)")) + #expect(!events.values.contains("state:connected")) + await engine.release("start") + try await connecting.value + #expect(events.values.last == "state:connected") + await client.close() + } + + @Test func startupDrainsResizeChangesAcrossStartAndResizeAwaitsBeforeConnected() async throws { + let events = EngineEvents() + let engine = TestEngine(events: events, gates: ["start", "resize"]) + let client = client(engine, events: events) + let connecting = Task { try await connect(client) } + await events.wait(for: "gated:start") + try await client.resize(columns: 100, rows: 30) + try await client.resize(columns: 132, rows: 43) + await events.emit(.connected) + #expect(!events.values.contains("state:connected")) + #expect(!events.values.contains(where: { $0.hasPrefix("resize:") })) + await engine.release("start") + await events.wait(for: "gated:resize") + #expect(await engine.appliedGrid == "80x24") + try await client.resize(columns: 150, rows: 50) + try await client.resize(columns: 160, rows: 60) + try await client.resize(columns: 160, rows: 60) + await events.emit(.activity) + #expect(events.values.filter { $0.hasPrefix("resize:") } == ["resize:132x43"]) + #expect(!events.values.contains("state:connected")) + await engine.release("resize") + try await connecting.value + #expect(events.values.filter { $0.hasPrefix("resize:") } == ["resize:132x43", "resize:160x60"]) + #expect(await engine.appliedGrid == "160x60") + #expect(events.values.last == "state:connected") + try await client.write(Data("after".utf8)) + #expect(events.values.last == "write:after") + await client.close() + } + + @Test func startupRechecksViewportAfterWritingRestoredIntegrationBeforePublishingConnected() async throws { + let events = EngineEvents() + let engine = TestEngine(events: events, gates: ["start", "write"], restoresIntegration: true) + let client = client(engine, events: events) + let connecting = Task { try await connect(client) } + await events.wait(for: "gated:start") + await events.emit(.output(Data("__TRMD_RESTORE_SHELL__/bin/bash__\r\n".utf8))) + #expect(!events.values.contains(where: { $0.hasPrefix("write:") })) + await engine.release("start") + await events.wait(for: "gated:write") + try await client.resize(columns: 140, rows: 50) + #expect(!events.values.contains("state:connected")) + #expect(!events.values.contains(where: { $0.hasPrefix("resize:") })) + await engine.release("write") + try await connecting.value + #expect(events.values.filter { $0.hasPrefix("write:") && $0.contains("TermindCommand=RESTORE") }.count == 1) + #expect(events.values.filter { $0.hasPrefix("resize:") } == ["resize:140x50"]) + #expect(await engine.appliedGrid == "140x50") + #expect(events.values.last == "state:connected") + await client.close() + } + + @Test func cancellingStartupDuringResizeDrainClosesWithoutAdmittingConnectedOrInput() async throws { + let events = EngineEvents() + let engine = TestEngine(events: events, gates: ["start", "resize"]) + let client = client(engine, events: events) + let connecting = Task { try await connect(client) } + await events.wait(for: "gated:start") + try await client.resize(columns: 132, rows: 43) + await engine.release("start") + await events.wait(for: "gated:resize") + connecting.cancel() + try await client.resize(columns: 160, rows: 60) + await engine.release("resize") + await #expect(throws: CancellationError.self) { try await connecting.value } + #expect(!events.values.contains("state:connected")) + #expect(events.values.filter { $0.hasPrefix("resize:") } == ["resize:132x43"]) + #expect(Array(events.values.suffix(3)) == ["rollback", "close", "state:closed"]) + await #expect(throws: CancellationError.self) { try await client.write(Data("blocked".utf8)) } + } + @Test func routeFactsPassWhileRecoveryIsFrozenWithoutConfirmingProtocolReadiness() async throws { let events = EngineEvents() let engine = TestEngine(events: events, gates: ["retry"]) @@ -295,18 +403,28 @@ struct ResumableTerminalEngineTests { await client.close() } - @Test func checkpointQueuedDuringInitialStartUsesTheConnectedEventAfterDrainingConnect() async throws { + @Test func checkpointQueuedDuringInitialStartWaitsForTheLatestViewportToDrain() async throws { let events = EngineEvents() - let engine = TestEngine(events: events, gates: ["start"]) + let engine = TestEngine(events: events, gates: ["start", "resize"]) let client = client(engine, events: events) let connecting = Task { try await connect(client) } await events.wait(for: "gated:start") let suspending = Task { try await client.suspend() } await events.wait(for: "lifecycle:queued") + try await client.resize(columns: 132, rows: 43) #expect(!events.values.contains("checkpoint")) await engine.release("start") + await events.wait(for: "gated:resize") + try await client.resize(columns: 160, rows: 60) + #expect(!events.values.contains("checkpoint")) + #expect(!events.values.contains("state:connected")) + await engine.release("resize") try await connecting.value let saved = try await suspending.value + #expect(await engine.appliedGrid == "160x60") + let resizeIndex = try #require(events.values.firstIndex(of: "resize:160x60")) + let checkpointIndex = try #require(events.values.firstIndex(of: "checkpoint")) + #expect(resizeIndex < checkpointIndex) #expect(saved.transport == .mosh) #expect(events.values.last == "state:suspended") #expect(!events.values.contains("state:failed")) @@ -840,6 +958,7 @@ private actor TestEngine: ResumableTerminalEngine { let restoresIntegration: Bool let reportsConnectedOnStart: Bool private(set) var preparedPayload: Data? + private(set) var appliedGrid: String? private(set) var permitsAgentForwarding = true private(set) var capturedShell: ResumableTerminalShellState? private var writing: CheckedContinuation? @@ -864,6 +983,7 @@ private actor TestEngine: ResumableTerminalEngine { preparedPayload = context.checkpoint permitsAgentForwarding = context.request.permitsAgentForwarding events.record("prepare") + await waitAtGate("prepare") return .init( shell: .init( integration: restoresIntegration ? RemoteShellIntegrationBootstrap(token: "RESTORE") : nil, @@ -873,11 +993,13 @@ private actor TestEngine: ResumableTerminalEngine { events.record("start:\(columns)x\(rows)") if failsStart { throw TerminalTransportError.unreachable } await waitAtGate("start") + appliedGrid = "\(columns)x\(rows)" if reportsConnectedOnStart { await events.emit(.connected) } await events.emit(.output(Data("ready".utf8))) } func write(_ data: Data) async throws { events.record("write:\(String(decoding: data, as: UTF8.self))") + await waitAtGate("write") if data == Data("hold".utf8) { await withCheckedContinuation { writing = $0 } } @@ -890,6 +1012,7 @@ private actor TestEngine: ResumableTerminalEngine { events.record("resize:\(columns)x\(rows)") await waitAtGate("resize") try failIfRequested("resize") + appliedGrid = "\(columns)x\(rows)" } func checkpoint(shell: @Sendable () async -> ResumableTerminalShellState) async throws -> Data { events.record("checkpoint") diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/ResumableTerminalClient.swift b/Frameworks/TermindRemoteInfrastructure/SSH/ResumableTerminalClient.swift index cd8c22ef..be125b3e 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/ResumableTerminalClient.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/ResumableTerminalClient.swift @@ -21,6 +21,7 @@ public actor ResumableTerminalClient: ResumableTerminalSession { private var hasObservedNetworkPath = false private var closed = false private var protocolSuspended = false + private var startupNeedsCompletion = false private var recoveryNeedsCompletion = false private var suspendedCheckpoint: TerminalTransportCheckpoint? private var lifecycleOperations: [LifecycleOperation] = [] @@ -36,10 +37,14 @@ public actor ResumableTerminalClient: ResumableTerminalSession { private var sendWaiters: [CheckedContinuation] = [] private var viewport: (columns: UInt16, rows: UInt16) = (80, 24) - private var frozen: Bool { + private var lifecycleFrozen: Bool { !lifecycleOperations.isEmpty || protocolSuspended || recoveryNeedsCompletion } + private var frozen: Bool { + startupNeedsCompletion || lifecycleFrozen + } + private enum LifecycleAction { case suspend, resume, retry } @@ -114,6 +119,7 @@ public actor ResumableTerminalClient: ResumableTerminalSession { await self?.receiveEngineEvent(event) } self.engine = engine + startupNeedsCompletion = true sendsInFlight += 1 defer { finishSend() } let bootstrap = SSHConnection() @@ -149,17 +155,38 @@ public actor ResumableTerminalClient: ResumableTerminalSession { ) } } - try await engine.start(columns: columns, rows: rows) - try checkOpen() - if let input = preparation.startupInput { try await engine.write(input) } - if pendingCheckpoint == nil, let integration { try await engine.write(integration.startCommand) } - if let command = pendingIntegrationCommand { - pendingIntegrationCommand = nil - try await engine.write(command) + var appliedViewport = viewport + try await engine.start(columns: appliedViewport.columns, rows: appliedViewport.rows) + var startupInput = preparation.startupInput + var integrationStartCommand = pendingCheckpoint == nil ? integration?.startCommand : nil + var bootstrapDisconnected = false + // Keep viewport delivery and connected publication ordered across every await. + // No concurrent resize may complete before a stale start overwrites it. + while true { + try checkOpen() + if appliedViewport.columns != viewport.columns || appliedViewport.rows != viewport.rows { + let latest = viewport + try await engine.resize(columns: latest.columns, rows: latest.rows) + appliedViewport = latest + } else if let input = startupInput { + startupInput = nil + try await engine.write(input) + } else if let command = integrationStartCommand { + integrationStartCommand = nil + try await engine.write(command) + } else if let command = pendingIntegrationCommand { + pendingIntegrationCommand = nil + try await engine.write(command) + } else if !bootstrapDisconnected { + await bootstrap.disconnect() + bootstrapDisconnected = true + } else { + pendingCheckpoint = nil + startupNeedsCompletion = false + if !frozen { publish(engineState) } + break + } } - await bootstrap.disconnect() - pendingCheckpoint = nil - guard !closed else { return } startMaintenance() startNetworkMonitoring() } catch { @@ -176,7 +203,9 @@ public actor ResumableTerminalClient: ResumableTerminalSession { public func write(_ data: Data) async throws { try checkOpen() - guard !frozen else { throw TerminalTransportError.suspended } + // A starting surface can already answer terminal protocol queries. The host + // gates interactive input on connected state; startup must not drop these replies. + guard !lifecycleFrozen else { throw TerminalTransportError.suspended } integration?.noteUserInput() sendsInFlight += 1 defer { finishSend() } diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/SSHPTYChannel.swift b/Frameworks/TermindRemoteInfrastructure/SSH/SSHPTYChannel.swift index 8a415649..d8823c6a 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/SSHPTYChannel.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/SSHPTYChannel.swift @@ -992,6 +992,7 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { private var socketEvents: SSHSocketEvents? private let pendingWrites = SSHByteQueue(chunkSize: 32_700) private var ptyResize = SSHPTYResizeState() + private var ptyStartupSizing = SSHPTYStartupSizing() private var terminalActivation = SSHPTYActivationState() private var activationTask: (request: SSHPTYActivationState.Request, task: Task)? private var shellIntegration: RemoteShellIntegrationBootstrap? @@ -1626,7 +1627,6 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { } } let variables = environment.map { ($0.0, SSHPTYStartupBytes($0.1), SSHPTYStartupBytes($0.2)) } - let initialSize = try await queue.perform { self.ptyResize.requestedSize } guard let budget = try await queue.perform({ self.channelBudget }) else { throw TermindSSHError.disconnected } let lease = try await budget.acquire() try await queue.perform { [self] in @@ -1648,14 +1648,12 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { guard let channel = openingPTY else { return LIBSSH2_ERROR_CHANNEL_CLOSED } return request.agentForwarding == nil ? 0 : libssh2_channel_request_auth_agent(channel) case 2: - guard let channel = openingPTY, let size = initialSize else { - return LIBSSH2_ERROR_CHANNEL_CLOSED + guard let channel = openingPTY else { return LIBSSH2_ERROR_CHANNEL_CLOSED } + return ptyStartupSizing.requestPTY(resize: &ptyResize) { size in + libssh2_channel_request_pty_ex( + channel, "xterm-256color", 14, nil, 0, Int32(size.columns), Int32(size.rows), + Int32(clamping: size.widthPixels), Int32(clamping: size.heightPixels)) } - let result = libssh2_channel_request_pty_ex( - channel, "xterm-256color", 14, nil, 0, Int32(size.columns), Int32(size.rows), - Int32(clamping: size.widthPixels), Int32(clamping: size.heightPixels)) - if result == 0 { ptyResize.didCreatePTY(size: size) } - return result case 3..<(3 + environment.count): let variable = variables[operation - 3] let result = libssh2_channel_setenv_ex( @@ -1666,10 +1664,21 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { } return result default: - if let command { - return libssh2_channel_process_startup(openingPTY, "exec", 4, command.pointer, command.count) - } - return libssh2_channel_process_startup(openingPTY, "shell", 5, nil, 0) + guard let channel = openingPTY else { return LIBSSH2_ERROR_CHANNEL_CLOSED } + return ptyStartupSizing.startProcess( + resize: &ptyResize, + sendResize: { size in + libssh2_channel_request_pty_size_ex( + channel, Int32(size.columns), Int32(size.rows), + Int32(clamping: size.widthPixels), Int32(clamping: size.heightPixels)) + }, + start: { + if let command { + return libssh2_channel_process_startup( + channel, "exec", 4, command.pointer, command.count) + } + return libssh2_channel_process_startup(channel, "shell", 5, nil, 0) + }) } } } @@ -2519,6 +2528,7 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { finishCharsetOutput() pendingWrites.removeAll() ptyResize.reset() + ptyStartupSizing = SSHPTYStartupSizing() if let hostID { shellIntegrationLifecycleLogger.sessionEnded(hostID: hostID) } shellIntegration = nil commandEventFilter = nil @@ -2553,6 +2563,7 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { self.connection = nil pendingWrites.removeAll() ptyResize.reset() + ptyStartupSizing = SSHPTYStartupSizing() if let hostID { shellIntegrationLifecycleLogger.sessionEnded(hostID: hostID) } shellIntegration = nil commandEventFilter = nil diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/SSHPTYStartupSizing.swift b/Frameworks/TermindRemoteInfrastructure/SSH/SSHPTYStartupSizing.swift new file mode 100644 index 00000000..827e2017 --- /dev/null +++ b/Frameworks/TermindRemoteInfrastructure/SSH/SSHPTYStartupSizing.swift @@ -0,0 +1,31 @@ +import CSSH2 + +/// Queue-confined startup sizing. A PTY request and a process request each retain +/// their own arguments across EAGAIN; newer geometry is flushed between them. +nonisolated struct SSHPTYStartupSizing { + private var requestedSize: SSHPTYSize? + private var processRequestStarted = false + + mutating func requestPTY( + resize: inout SSHPTYResizeState, send: (SSHPTYSize) -> Int32 + ) -> Int32 { + if requestedSize == nil { requestedSize = resize.requestedSize } + guard let size = requestedSize else { return LIBSSH2_ERROR_CHANNEL_CLOSED } + let result = send(size) + if result == 0 { resize.didCreatePTY(size: size) } + return result + } + + mutating func startProcess( + resize: inout SSHPTYResizeState, + sendResize: (SSHPTYSize) -> Int32, + start: () -> Int32 + ) -> Int32 { + if !processRequestStarted { + let result = resize.flush(send: sendResize) + guard result == 0 else { return result } + processRequestStarted = true + } + return start() + } +} diff --git a/TFM/Features/Terminal/PersistentTerminalSurfaceView.swift b/TFM/Features/Terminal/PersistentTerminalSurfaceView.swift index 203e6ab0..7a906b9c 100644 --- a/TFM/Features/Terminal/PersistentTerminalSurfaceView.swift +++ b/TFM/Features/Terminal/PersistentTerminalSurfaceView.swift @@ -371,6 +371,7 @@ private var appliedVisibility: Bool? private var wantsFocus = false private var nextMountOrdinal: UInt64 = 0 + private(set) var resizeThrottleMilliseconds: Double = 35 private var uploadBindingID: UUID? private var sftpBindingID: UUID? @@ -468,6 +469,15 @@ terminalView = terminal } + /// Changes only the live coalescing policy; configuration and surface identity stay intact. + @discardableResult + func setResizeThrottle(milliseconds: Double) -> Bool { + guard resizeThrottleMilliseconds != milliseconds else { return false } + resizeThrottleMilliseconds = milliseconds + terminalView.setResizeThrottle(milliseconds: milliseconds) + return true + } + nonisolated func receive(_ data: Data) { backend.receive(data) } diff --git a/TFM/Features/Terminal/SSHSessionModel.swift b/TFM/Features/Terminal/SSHSessionModel.swift index d3aef20f..7eb3840e 100644 --- a/TFM/Features/Terminal/SSHSessionModel.swift +++ b/TFM/Features/Terminal/SSHSessionModel.swift @@ -43,6 +43,7 @@ final class SSHSessionModel: RemotePathDisplaying, Identifiable { didSet { if !status.isLive, status != .connecting { commandMonitor.endConnection() } statusStore?.setConnectionStatus(status, connectionID: id, hostID: hostID) + synchronizeResizeThrottle() } } var workingDirectory: String? { @@ -360,6 +361,22 @@ final class SSHSessionModel: RemotePathDisplaying, Identifiable { writeInputDirectly(Data((text + (submit ? "\r" : "")).utf8)) } + /// Runtime prompt snapshots can outlive foreground ownership or tmux copy/prefix mode. + /// A read-draft ticket confirms that the prompt is still interactive. + private func synchronizeResizeThrottle() { + let milliseconds: Double + if !status.isLive || !isShellIntegrationEnabled || !shellIntegrationAvailable { + milliseconds = 35 + } else if isShellPromptReady, + !hasRuntimeContext || terminalContext.ticket(requiring: .readDraft) != nil + { + milliseconds = 0 + } else { + milliseconds = 128 + } + terminalSurface.setResizeThrottle(milliseconds: milliseconds) + } + var canGenerateFromCurrentDraft: Bool { status.isLive && isShellIntegrationEnabled && shellIntegrationAvailable && isShellPromptReady && TerminalCommandDraftController.supportedShells.contains(integratedShell ?? "") @@ -473,6 +490,7 @@ final class SSHSessionModel: RemotePathDisplaying, Identifiable { activeShellCommand = nil commandDraft.cancel() invalidateCommandGeneration() + synchronizeResizeThrottle() } func connectIfNeeded(using store: any RemoteSessionHostStore) { @@ -1009,6 +1027,16 @@ final class SSHSessionModel: RemotePathDisplaying, Identifiable { } } await actor.setTerminalActive(isTerminalActive) + guard connectionAttemptID == attemptID, ssh === actor else { throw CancellationError() } + if transport == .ssh { + // A retained surface may not emit another resize for this new channel. + // Prime all dimensions before connect consumes the worker's initial viewport. + let viewport = latestViewport + await actor.resize( + columns: viewport.columns, rows: viewport.rows, + widthPixels: viewport.widthPixels, heightPixels: viewport.heightPixels) + } + guard connectionAttemptID == attemptID, ssh === actor else { throw CancellationError() } try await actor.connect( using: SSHConnectionRequest( configuration: configuration, @@ -1377,9 +1405,13 @@ final class SSHSessionModel: RemotePathDisplaying, Identifiable { } private func resizeSSH(_ viewport: InMemoryTerminalViewport) { + guard viewport.columns > 0, viewport.rows > 0 else { return } latestViewport = viewport guard let ssh else { return } - Task { + let attemptID = connectionAttemptID + Task { [weak self] in + guard let self, self.ssh === ssh, self.connectionAttemptID == attemptID else { return } + let viewport = self.latestViewport await ssh.resize( columns: viewport.columns, rows: viewport.rows, widthPixels: viewport.widthPixels, heightPixels: viewport.heightPixels @@ -1438,6 +1470,7 @@ final class SSHSessionModel: RemotePathDisplaying, Identifiable { return } guard !hasRuntimeContext else { return } + defer { synchronizeResizeThrottle() } commandMonitor.receiveShellEvent(event) switch event { case .runtime, .ordinaryStartupUnavailable: break @@ -1483,6 +1516,7 @@ final class SSHSessionModel: RemotePathDisplaying, Identifiable { } private func receiveRuntimeContext(_ update: TerminalRuntimeUpdate) { + defer { synchronizeResizeThrottle() } hasRuntimeContext = true let previous = terminalContext.revision let events = terminalContext.receive(update, generation: terminalContext.generation) diff --git a/TFMTests/HostShellIntegrationSessionTests.swift b/TFMTests/HostShellIntegrationSessionTests.swift index 662c2fa8..ef5a849d 100644 --- a/TFMTests/HostShellIntegrationSessionTests.swift +++ b/TFMTests/HostShellIntegrationSessionTests.swift @@ -9,6 +9,111 @@ import struct TermindDomain.Host @Suite(.serialized) @MainActor struct HostShellIntegrationSessionTests { + @Test func resizeThrottleFollowsLegacyLifecycleRatherThanCommandCompletion() { + let session = SSHSessionModel(hostID: UUID(), title: "Resize lifecycle") + defer { session.disconnect() } + let owner = session.terminalViewOwner + #expect(owner.resizeThrottleMilliseconds == 35) + session.receiveShellIntegrationEvent(.available(shell: "bash", systemInfo: nil, homeDirectory: nil)) + #expect(owner.resizeThrottleMilliseconds == 35) + session.status = .connected + #expect(owner.resizeThrottleMilliseconds == 128) + session.receiveShellIntegrationEvent(.promptReady) + #expect(owner.resizeThrottleMilliseconds == 0) + session.receiveShellIntegrationEvent(.commandStarted("top")) + #expect(owner.resizeThrottleMilliseconds == 128) + session.receiveShellIntegrationEvent(.commandFinished(0, duration: 1)) + #expect(owner.resizeThrottleMilliseconds == 128) + session.receiveShellIntegrationEvent(.promptReady) + #expect(owner.resizeThrottleMilliseconds == 0) + session.receiveShellIntegrationEvent(.available(shell: "bash", systemInfo: nil, homeDirectory: nil)) + #expect(owner.resizeThrottleMilliseconds == 128) + session.status = .reconnecting + #expect(owner.resizeThrottleMilliseconds == 35) + session.receiveShellIntegrationEvent(.promptReady) + #expect(owner.resizeThrottleMilliseconds == 35) + session.status = .connected + #expect(owner.resizeThrottleMilliseconds == 0) + session.status = .failed("Disconnected") + #expect(owner.resizeThrottleMilliseconds == 35) + } + + @Test(arguments: [TerminalRuntimeUpdate.Status.ordinary, .multiplexer]) + func runtimeResizeThrottleRequiresLifecycleAndInteractivePrompt(status: TerminalRuntimeUpdate.Status) { + let session = SSHSessionModel(hostID: UUID(), title: "Runtime resize") + defer { session.disconnect() } + session.status = .connected + let owner = session.terminalViewOwner + var context = TerminalShellContext( + identity: .init(backend: "tmux", server: "s", session: "s", pane: "a", shellInstance: "i"), + shell: "bash", directory: nil, home: nil, promptReady: false, sequence: 1, + capabilities: [.commandLifecycle]) + func receive() { + session.receiveShellIntegrationEvent( + .runtime(.init(status: status, active: context.identity, contexts: [context]))) + context.sequence += 1 + } + receive() + #expect(owner.resizeThrottleMilliseconds == 128) + // A prompt snapshot without readDraft also represents tmux copy/prefix + // mode or a foreground process that is no longer the shell. + context.promptReady = true + receive() + #expect(owner.resizeThrottleMilliseconds == 128) + context.capabilities.insert(.readDraft) + receive() + #expect(owner.resizeThrottleMilliseconds == 0) + #expect(!session.canGenerateFromCurrentDraft) // Resize does not require replaceDraft. + context.capabilities.remove(.readDraft) + receive() + #expect(owner.resizeThrottleMilliseconds == 128) + session.receiveShellIntegrationEvent(.promptReady) + #expect(owner.resizeThrottleMilliseconds == 128) + context.capabilities = [.readDraft] + receive() + #expect(owner.resizeThrottleMilliseconds == 35) + context.capabilities.insert(.commandLifecycle) + receive() + #expect(owner.resizeThrottleMilliseconds == 0) + context.promptReady = false + receive() + #expect(owner.resizeThrottleMilliseconds == 128) + for unavailable in [TerminalRuntimeUpdate.Status.unknown, .unavailable] { + session.receiveShellIntegrationEvent(.runtime(.init(status: unavailable))) + #expect(owner.resizeThrottleMilliseconds == 35) + session.receiveShellIntegrationEvent(.available(shell: "bash", systemInfo: nil, homeDirectory: nil)) + session.receiveShellIntegrationEvent(.promptReady) + #expect(owner.resizeThrottleMilliseconds == 35) + } + } + + @Test func reconnectResetsResizeThrottleUntilFreshShellStateArrives() { + let host = Host(name: "Server", host: "server.invalid", shellIntegrationMode: .enabled) + let library = HostLibraryModel(hosts: [host]) + let session = SSHSessionModel(hostID: host.id, title: host.name) + defer { session.disconnect() } + // Missing authentication keeps setup local; no socket is opened. + session.connect(using: library) + session.status = .connected + let context = TerminalShellContext( + identity: .init(backend: "shell", server: "s", session: "s", pane: "a", shellInstance: "i"), + shell: "bash", directory: nil, home: nil, promptReady: true, sequence: 1, + capabilities: [.commandLifecycle, .readDraft]) + session.receiveShellIntegrationEvent( + .runtime(.init(status: .ordinary, active: context.identity, contexts: [context]))) + #expect(session.terminalViewOwner.resizeThrottleMilliseconds == 0) + session.disconnect() + #expect(session.terminalViewOwner.resizeThrottleMilliseconds == 35) + session.connect(using: library) + session.status = .connected + #expect(session.terminalViewOwner.resizeThrottleMilliseconds == 35) + session.receiveShellIntegrationEvent(.runtime(.init(status: .unknown))) + #expect(session.terminalViewOwner.resizeThrottleMilliseconds == 35) + session.receiveShellIntegrationEvent( + .runtime(.init(status: .ordinary, active: context.identity, contexts: [context]))) + #expect(session.terminalViewOwner.resizeThrottleMilliseconds == 0) + } + @Test(arguments: [false, true]) func ordinaryStartupFallbackSubmitsOnceButNeverIntoManagedAttachments(managed: Bool) { let host = Host(name: "Server", host: "server.invalid", shellIntegrationMode: .enabled) @@ -169,6 +274,7 @@ struct HostShellIntegrationSessionTests { let pane = Pane(.sshSession(session)) #expect(pane.remoteSystemInfo == (expected ? info : nil)) #expect(session.canGenerateFromCurrentDraft == expected) + #expect(session.terminalViewOwner.resizeThrottleMilliseconds == (expected ? 0 : 35)) defaults.set(!global, forKey: SettingsKey.shellIntegration) host.shellIntegrationMode = expected ? .disabled : .enabled @@ -176,6 +282,7 @@ struct HostShellIntegrationSessionTests { #expect(session.isShellIntegrationEnabled == expected) #expect(pane.remoteSystemInfo == (expected ? info : nil)) #expect(session.canGenerateFromCurrentDraft == expected) + #expect(session.terminalViewOwner.resizeThrottleMilliseconds == (expected ? 0 : 35)) session.receiveShellIntegrationEvent(.commandStarted("pwd")) session.receiveShellIntegrationEvent(.commandFinished(0, duration: 0.1)) #expect(history.entries().count == (expected ? 1 : 0)) diff --git a/TFMTests/SSHPTYStartupSizingTests.swift b/TFMTests/SSHPTYStartupSizingTests.swift new file mode 100644 index 00000000..3d97a0db --- /dev/null +++ b/TFMTests/SSHPTYStartupSizingTests.swift @@ -0,0 +1,168 @@ +import CSSH2 +import Testing + +@testable import TermindRemoteInfrastructure + +@Suite +struct SSHPTYStartupSizingTests { + @Test func initialRequestUsesTheViewportAfterChannelAdmission() { + var resize = SSHPTYResizeState() + _ = resize.beginConnection(columns: 80, rows: 24) + var startup = SSHPTYStartupSizing() + let latest = SSHPTYSize(columns: 132, rows: 43, widthPixels: 2112, heightPixels: 1376) + resize.request(latest) + var requests: [SSHPTYSize] = [] + #expect( + startup.requestPTY(resize: &resize) { + requests.append($0) + return 0 + } == 0) + var starts = 0 + #expect( + startup.startProcess( + resize: &resize, + sendResize: { _ in + Issue.record("The initial viewport was sent twice") + return 0 + }, + start: { + starts += 1 + return 0 + }) == 0) + #expect(requests == [latest]) + #expect(starts == 1) + #expect(resize.isSynchronized) + } + + @Test func aBlockedPTYRequestKeepsItsSizeThenFlushesTheLatestBeforeStarting() { + var resize = SSHPTYResizeState() + _ = resize.beginConnection(columns: 80, rows: 24) + let initial = SSHPTYSize(columns: 80, rows: 24) + let latest = SSHPTYSize(columns: 140, rows: 50, widthPixels: 2240, heightPixels: 1600) + var startup = SSHPTYStartupSizing() + var requests: [SSHPTYSize] = [] + #expect( + startup.requestPTY(resize: &resize) { + requests.append($0) + return LIBSSH2_ERROR_EAGAIN + } == LIBSSH2_ERROR_EAGAIN) + resize.request(latest) + #expect( + startup.requestPTY(resize: &resize) { + requests.append($0) + return 0 + } == 0) + var events: [String] = [] + var delivered: [SSHPTYSize] = [] + #expect( + startup.startProcess( + resize: &resize, + sendResize: { + delivered.append($0) + events.append("resize") + return 0 + }, + start: { + events.append("start") + return 0 + }) == 0) + #expect(requests == [initial, initial]) + #expect(delivered == [latest]) + #expect(events == ["resize", "start"]) + #expect(resize.isSynchronized) + } + + @Test func windowChangeRetriesFinishBeforeTheProcessRequestBegins() { + var resize = SSHPTYResizeState() + _ = resize.beginConnection(columns: 80, rows: 24) + var startup = SSHPTYStartupSizing() + #expect(startup.requestPTY(resize: &resize) { _ in 0 } == 0) + let blocked = SSHPTYSize(columns: 100, rows: 30) + let latest = SSHPTYSize(columns: 140, rows: 50) + resize.request(blocked) + var attempts: [SSHPTYSize] = [] + var starts = 0 + #expect( + startup.startProcess( + resize: &resize, + sendResize: { + attempts.append($0) + return LIBSSH2_ERROR_EAGAIN + }, + start: { + starts += 1 + return 0 + }) == LIBSSH2_ERROR_EAGAIN) + #expect(starts == 0) + resize.request(latest) + #expect( + startup.startProcess( + resize: &resize, + sendResize: { + attempts.append($0) + return 0 + }, + start: { + starts += 1 + return 0 + }) == 0) + #expect(attempts == [blocked, blocked, latest]) + #expect(starts == 1) + } + + @Test func aBlockedProcessRequestIsNotInterruptedByAnotherWindowChange() { + var resize = SSHPTYResizeState() + _ = resize.beginConnection(columns: 80, rows: 24) + var startup = SSHPTYStartupSizing() + #expect(startup.requestPTY(resize: &resize) { _ in 0 } == 0) + var starts = 0 + #expect( + startup.startProcess( + resize: &resize, + sendResize: { _ in + Issue.record("Unchanged viewport sent") + return 0 + }, + start: { + starts += 1 + return LIBSSH2_ERROR_EAGAIN + }) == LIBSSH2_ERROR_EAGAIN) + let latest = SSHPTYSize(columns: 120, rows: 40) + resize.request(latest) + #expect( + startup.startProcess( + resize: &resize, + sendResize: { _ in + Issue.record("Interrupted a pending process request") + return 0 + }, + start: { + starts += 1 + return 0 + }) == 0) + var delivered: [SSHPTYSize] = [] + #expect( + resize.flush { + delivered.append($0) + return 0 + } == 0) + #expect(starts == 2) + #expect(delivered == [latest]) + } + + @Test func aFailedWindowChangeDoesNotStartTheProcess() { + var resize = SSHPTYResizeState() + _ = resize.beginConnection(columns: 80, rows: 24) + var startup = SSHPTYStartupSizing() + #expect(startup.requestPTY(resize: &resize) { _ in 0 } == 0) + resize.request(.init(columns: 120, rows: 40)) + #expect( + startup.startProcess( + resize: &resize, + sendResize: { _ in LIBSSH2_ERROR_SOCKET_SEND }, + start: { + Issue.record("Started after a failed window change") + return 0 + }) == LIBSSH2_ERROR_SOCKET_SEND) + } +} diff --git a/TFMTests/TerminalResizeThrottleTests.swift b/TFMTests/TerminalResizeThrottleTests.swift new file mode 100644 index 00000000..483e9ccb --- /dev/null +++ b/TFMTests/TerminalResizeThrottleTests.swift @@ -0,0 +1,140 @@ +import AppKit +import GhosttyTerminal +import Testing + +@testable import Termind + +/// These windows are never ordered front or made key; layout and surface lifetime +/// regressions remain covered by the default suite without stealing focus. +@Suite(.serialized) @MainActor +struct TerminalResizeThrottleTests { + @Test func liveThrottleChangesAreIdempotentAndSurviveRemountWithoutReplacingTheSurface() async throws { + let fixture = TerminalResizeFixture() + defer { fixture.close() } + try await fixture.waitForSurface() + let owner = fixture.owner + let state = owner.state + let surface = try #require(state.surface) + let controller = state.controller + let view = try #require(fixture.mount.subviews.first as? TermindTerminalView) + guard case .inMemory(let backend) = state.configuration.backend else { + Issue.record("Expected the persistent in-memory backend") + return + } + backend.receive(Data("preserved resize transcript".utf8)) + backend.waitForPendingOutput() + #expect(!owner.setResizeThrottle(milliseconds: 35)) + for milliseconds in [128.0, 0.0, 35.0, 128.0] { + #expect(owner.setResizeThrottle(milliseconds: milliseconds)) + #expect(!owner.setResizeThrottle(milliseconds: milliseconds)) + #expect(state.surface === surface) + #expect(state.controller === controller) + #expect(state.configuration.resizeThrottleMilliseconds == 35) + #expect(view.configuration.resizeThrottleMilliseconds == 35) + } + let target = TerminalMountView(frame: fixture.mount.frame) + fixture.window.contentView?.addSubview(target) + defer { owner.unmount(from: target) } + owner.mount(in: target, state: state, isVisible: true, requestsFocus: false) + target.layoutSubtreeIfNeeded() + owner.unmount(from: fixture.mount) + #expect(target.subviews.first === view) + #expect(state.surface === surface) + #expect(state.controller === controller) + #expect(view.controller === controller) + #expect(owner.resizeThrottleMilliseconds == 128) + #expect(!owner.setResizeThrottle(milliseconds: 128)) + if case .inMemory(let currentBackend) = view.configuration.backend { + #expect(currentBackend === backend) + } else { + Issue.record("Remount replaced the in-memory backend") + } + #expect(backend.readViewportText()?.contains("preserved resize transcript") == true) + #expect(!fixture.window.isVisible) + } + + @Test(arguments: [0.0, 35.0, 128.0]) + func rapidResizeBurstConvergesWithoutFocusOrFurtherInput(milliseconds: Double) async throws { + let fixture = TerminalResizeFixture() + defer { fixture.close() } + try await fixture.waitForSurface() + let owner = fixture.owner + let surface = try #require(owner.state.surface) + let before = try #require(fixture.resizes.latest) + owner.setResizeThrottle(milliseconds: milliseconds) + for width in stride(from: 680, through: 1040, by: 40) { + fixture.mount.setFrameSize(NSSize(width: CGFloat(width), height: 400)) + fixture.mount.layoutSubtreeIfNeeded() + } + let view = try #require(fixture.mount.subviews.first as? TermindTerminalView) + let finalWidth = UInt32((view.bounds.width * fixture.window.backingScaleFactor).rounded(.down)) + for _ in 0..<200 where fixture.resizes.latest?.widthPixels != finalWidth { + try await Task.sleep(for: .milliseconds(10)) + } + let after = try #require(fixture.resizes.latest) + #expect(after.widthPixels == finalWidth) + #expect(after.columns > before.columns) + let count = fixture.resizes.count + for _ in 0..<10 { + fixture.mount.setFrameSize(fixture.mount.frame.size) + fixture.mount.needsLayout = true + fixture.mount.layoutSubtreeIfNeeded() + } + // Let both the 128 ms trailing edge and its final rearm settle. + try await Task.sleep(for: .milliseconds(300)) + #expect(fixture.resizes.count == count) + #expect(owner.state.surface === surface) + #expect(!fixture.window.isVisible) + } +} + +@MainActor +private final class TerminalResizeFixture { + nonisolated final class Resizes: @unchecked Sendable { + private let lock = NSLock() + private var viewports: [InMemoryTerminalViewport] = [] + + func receive(_ viewport: InMemoryTerminalViewport) { + lock.withLock { viewports.append(viewport) } + } + + var latest: InMemoryTerminalViewport? { lock.withLock { viewports.last } } + var count: Int { lock.withLock { viewports.count } } + } + + let resizes = Resizes() + let owner: PersistentTerminalViewOwner + let mount = TerminalMountView(frame: NSRect(x: 0, y: 0, width: 640, height: 400)) + let window: NSWindow + + init() { + let resizes = resizes + owner = PersistentTerminalViewOwner(write: { _, _ in }, resize: { resizes.receive($0) }) + owner.state.setTerminalConfiguration( + TerminalConfiguration { builder in + builder.withWindowPaddingX(0) + builder.withWindowPaddingY(0) + }) + window = NSWindow(contentRect: mount.frame, styleMask: [.titled], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + let root = NSView(frame: mount.frame) + window.contentView = root + root.addSubview(mount) + owner.mount(in: mount, state: owner.state, isVisible: true, requestsFocus: false) + mount.layoutSubtreeIfNeeded() + } + + func waitForSurface() async throws { + for _ in 0..<100 where owner.state.surface == nil || resizes.latest == nil { + try await Task.sleep(for: .milliseconds(10)) + } + _ = try #require(owner.state.surface) + _ = try #require(resizes.latest) + } + + func close() { + owner.unmount(from: mount) + window.contentView = nil + window.close() + } +}