feat: refine terminal resize

This commit is contained in:
Kaede Akino committed 2026-10-18 21:39:14 +08:00
1 parent ed7ed758db
commit 4fa7fcc428
9 files changed
+680 -27

No files matched your search

@@ -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<Void, Never>?
@@ -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")
@@ -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<Void, Never>] = []
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() }
@@ -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<Void, Never>)?
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
@@ -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()
}
}
@@ -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)
}
+35 -1
View File
@@ -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)
@@ -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))
+168
View File
@@ -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)
}
}
+140
View File
@@ -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()
}
}