feat: refine terminal resize
This commit is contained in:
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()
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user