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

@@ -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()
}
}