94 lines
3.3 KiB
Swift
94 lines
3.3 KiB
Swift
// SPDX-FileCopyrightText: 2026 Kaede Akino
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
import Foundation
|
|
import TermindApplication
|
|
|
|
/// Controls one non-PTY exec channel, independently of the pooled connection.
|
|
nonisolated public final class SSHExecSession: @unchecked Sendable {
|
|
public typealias Writer = @Sendable (SSHCommandInput?) async throws -> Int
|
|
public let keepsStdinOpen: Bool
|
|
public let cancellation = TransferCancellationToken()
|
|
private let lock = NSLock()
|
|
private var startup: Result<Void, any Error>?
|
|
private var waiter: CheckedContinuation<Void, any Error>?
|
|
private var writer: Writer?
|
|
private var closed: Bool
|
|
private var didStart = false
|
|
|
|
public init(keepsStdinOpen: Bool) {
|
|
self.keepsStdinOpen = keepsStdinOpen
|
|
closed = !keepsStdinOpen
|
|
}
|
|
|
|
public var started: Bool { lock.withLock { didStart } }
|
|
public var stdinClosed: Bool { lock.withLock { closed } }
|
|
|
|
public func waitUntilStarted() async throws {
|
|
try await withCheckedThrowingContinuation { continuation in
|
|
let result: Result<Void, any Error>? = lock.withLock {
|
|
if let startup { return startup }
|
|
waiter = continuation
|
|
return nil
|
|
}
|
|
if let result { continuation.resume(with: result) }
|
|
}
|
|
}
|
|
|
|
public func start(writer: @escaping Writer) {
|
|
resolveStartup(.success(()), writer: writer)
|
|
}
|
|
|
|
public func finish(error: (any Error)? = nil) {
|
|
resolveStartup(.failure(error ?? CancellationError()), writer: nil)
|
|
lock.withLock {
|
|
writer = nil
|
|
closed = true
|
|
}
|
|
}
|
|
|
|
private func resolveStartup(_ result: Result<Void, any Error>, writer: Writer?) {
|
|
let continuation = lock.withLock {
|
|
guard startup == nil else { return Optional<CheckedContinuation<Void, any Error>>.none }
|
|
startup = result
|
|
self.writer = writer
|
|
if case .success = result { didStart = true }
|
|
defer { waiter = nil }
|
|
return waiter
|
|
}
|
|
continuation?.resume(with: result)
|
|
}
|
|
|
|
public func write(_ input: SSHCommandInput?) async throws -> Int {
|
|
let writer = try lock.withLock {
|
|
if closed {
|
|
if input == nil { return Optional<Writer>.none }
|
|
throw TermindSSHError.library("Command input is closed.")
|
|
}
|
|
guard let writer else { throw TermindSSHError.library("Command input is unavailable.") }
|
|
return writer
|
|
}
|
|
guard let writer else { return 0 }
|
|
let count = try await withTaskCancellationHandler {
|
|
try Task.checkCancellation()
|
|
return try await writer(input)
|
|
} onCancel: {
|
|
self.cancellation.cancel()
|
|
}
|
|
if input == nil { lock.withLock { closed = true } }
|
|
return count
|
|
}
|
|
}
|
|
|
|
/// Byte-bounded UTF-8 tail. Drop an incomplete leading scalar, never split a valid one.
|
|
nonisolated enum SSHOutputTail {
|
|
@discardableResult
|
|
static func append(_ chunk: Data, to data: inout Data, limit: Int) -> Bool {
|
|
data.append(chunk)
|
|
guard data.count > max(0, limit) else { return false }
|
|
data = Data(data.suffix(max(0, limit)))
|
|
while let first = data.first, first & 0xC0 == 0x80 { data.removeFirst() }
|
|
return true
|
|
}
|
|
}
|