Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ public final class SSHTransport: TerminalTransport, @unchecked Sendable {
nonisolated(unsafe) private var childChannel: Channel?

nonisolated(unsafe) private var keepaliveTask: Task<Void, Never>?
nonisolated(unsafe) private var isDisconnecting = false
nonisolated(unsafe) private var didTerminateConnection = false

// Track the last known terminal size for use in PTY allocation.
nonisolated(unsafe) private var currentColumns: Int = 80
Expand Down Expand Up @@ -115,6 +117,8 @@ public final class SSHTransport: TerminalTransport, @unchecked Sendable {
guard parentChannel == nil else {
throw SSHTransportError.alreadyConnected
}
isDisconnecting = false
didTerminateConnection = false

stateContinuation.yield(.connecting)

Expand Down Expand Up @@ -176,10 +180,14 @@ public final class SSHTransport: TerminalTransport, @unchecked Sendable {
}

public func disconnect() async throws {
isDisconnecting = true
defer { isDisconnecting = false }

keepaliveTask?.cancel()
keepaliveTask = nil

guard let parent = parentChannel else {
didTerminateConnection = true
return // Already disconnected; no-op.
}

Expand All @@ -190,6 +198,7 @@ public final class SSHTransport: TerminalTransport, @unchecked Sendable {

try await parent.close()
self.parentChannel = nil
didTerminateConnection = true

if ownsEventLoopGroup {
try await eventLoopGroup.shutdownGracefully()
Expand Down Expand Up @@ -242,11 +251,31 @@ public final class SSHTransport: TerminalTransport, @unchecked Sendable {
// MARK: - Private

private func handleConnectionDeath() {
guard !isDisconnecting else { return }
terminateConnection(state: .disconnected, closeParent: false)
}

private func handleSessionChannelClosed() {
guard !isDisconnecting else { return }
terminateConnection(state: .sessionEnded, closeParent: true)
}

private func terminateConnection(state: TransportState, closeParent: Bool) {
guard !didTerminateConnection else { return }
didTerminateConnection = true

keepaliveTask?.cancel()
keepaliveTask = nil

let parent = parentChannel
parentChannel = nil
childChannel = nil
stateContinuation.yield(.disconnected)

if closeParent, let parent, parent.isActive {
parent.close(promise: nil)
}

stateContinuation.yield(state)
dataContinuation.finish()
}

Expand Down Expand Up @@ -308,6 +337,9 @@ public final class SSHTransport: TerminalTransport, @unchecked Sendable {
}.get()

self.childChannel = childChannel
childChannel.closeFuture.whenComplete { [weak self] _ in
self?.handleSessionChannelClosed()
}

// Request PTY allocation using the current terminal size.
let ptyRequest = SSHChannelRequestEvent.PseudoTerminalRequest(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import Foundation
/// Represents the current state of a terminal transport connection.
public enum TransportState: Sendable {
case disconnected
case sessionEnded
case connecting
case connected
case reconnecting
Expand All @@ -13,6 +14,7 @@ extension TransportState: CustomStringConvertible {
public var description: String {
switch self {
case .disconnected: return "disconnected"
case .sessionEnded: return "sessionEnded"
case .connecting: return "connecting"
case .connected: return "connected"
case .reconnecting: return "reconnecting"
Expand Down Expand Up @@ -46,7 +48,7 @@ public protocol TerminalTransport: AnyObject, Sendable {
func resize(columns: Int, rows: Int) async throws

/// Check whether the connection is still alive.
/// If the connection is dead, the transport should yield `.disconnected` or `.failed`.
/// If the connection is dead, the transport should yield `.disconnected`, `.sessionEnded`, or `.failed`.
func checkConnection() async
}

Expand Down
26 changes: 16 additions & 10 deletions Spectty/Models/TerminalSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ final class TerminalSession: Identifiable {
nonisolated(unsafe) private var autoReconnectTask: Task<Void, Never>?
@ObservationIgnored
private var outboundSendTail: Task<Void, Never>?
@ObservationIgnored
var onSessionEnded: ((TerminalSession) -> Void)?

init(id: UUID = UUID(), connectionName: String, transport: any TerminalTransport, transportFactory: (@Sendable () -> any TerminalTransport)? = nil, startupCommand: String? = nil, columns: Int = 80, rows: Int = 24, scrollbackCapacity: Int = 10_000) {
self.id = id
Expand Down Expand Up @@ -54,11 +56,7 @@ final class TerminalSession: Identifiable {
stateTask = Task { [weak self] in
for await state in stateStream {
guard let self else { break }
if case .disconnected = state, self.transportFactory != nil {
self.attemptAutoReconnect()
} else {
self.transportState = state
}
self.handleTransportState(state)
}
}

Expand Down Expand Up @@ -139,11 +137,7 @@ final class TerminalSession: Identifiable {
stateTask = Task { [weak self] in
for await state in newStateStream {
guard let self else { break }
if case .disconnected = state, self.transportFactory != nil {
self.attemptAutoReconnect()
} else {
self.transportState = state
}
self.handleTransportState(state)
}
}

Expand Down Expand Up @@ -182,6 +176,18 @@ final class TerminalSession: Identifiable {
}
}

private func handleTransportState(_ state: TransportState) {
switch state {
case .disconnected where transportFactory != nil:
attemptAutoReconnect()
case .sessionEnded:
transportState = .sessionEnded
onSessionEnded?(self)
default:
transportState = state
}
}

private func sendStartupCommand() {
guard let cmd = startupCommand, !cmd.isEmpty else { return }
enqueueOutboundSend(Data((cmd + "\n").utf8))
Expand Down
8 changes: 8 additions & 0 deletions Spectty/ViewModels/SessionManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ final class SessionManager {
startupCommand: connection.startupCommand,
scrollbackCapacity: scrollbackLines > 0 ? scrollbackLines : 10_000
)
attachSessionLifecycle(session)

sessions.append(session)
sessionConnectionIDs[session.id] = connection.id.uuidString
Expand Down Expand Up @@ -229,6 +230,7 @@ final class SessionManager {
transport: transport,
scrollbackCapacity: scrollbackLines > 0 ? scrollbackLines : 10_000
)
attachSessionLifecycle(session)

sessions.append(session)
sessionConnectionIDs[session.id] = savedState.connectionID
Expand Down Expand Up @@ -324,4 +326,10 @@ final class SessionManager {
ipResolution: ipResolution
)
}

private func attachSessionLifecycle(_ session: TerminalSession) {
session.onSessionEnded = { [weak self] endedSession in
self?.disconnect(endedSession)
}
}
}
2 changes: 2 additions & 0 deletions Spectty/Views/TerminalSessionView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ struct TerminalSessionView: View {
showSpinner: false
)
}
case .sessionEnded:
EmptyView()
case .connected:
EmptyView()
}
Expand Down