Files
Termind/TFMTests/SSHPTYChannelLifecycleWireTests.swift

101 lines
4.5 KiB
Swift

// SPDX-FileCopyrightText: 2026 Kaede Akino
// SPDX-License-Identifier: GPL-3.0-only
import Foundation
import TermindApplication
import TermindDomain
import Testing
@testable import TermindRemoteInfrastructure
/// Run against Scripts/ssh-pty-lifecycle-fixture.py. The peer deliberately never
/// acknowledges exec CLOSE; disposing the connection must not wait for that reply.
@Suite(.enabled(if: ProcessInfo.processInfo.environment["TERMIND_PTY_LIFECYCLE_FIXTURE"] != nil), .serialized)
@MainActor
struct SSHPTYChannelLifecycleWireTests {
private struct Fixture: Decodable {
let port: Int
let hostFingerprint: String
}
@Test func cancelledHistoryCannotKeepAnUnownedTransportAlive() async throws {
var channel: SSHPTYChannel? = try await connect()
weak let released = channel
let token = try await cancelHistoryWhileNativeCloseIsPending(on: channel!)
await channel!.disconnect()
await channel!.disconnect()
// Explicit disconnect must retire the transport even while its public
// owner is still retained, independently of the deinit safety net.
try await waitForEvent("transport-closed \(token)")
channel = nil
#expect(released == nil)
}
@Test func droppingAConnectedPTYWithoutDisconnectRetiresItsSources() async throws {
var channel: SSHPTYChannel? = try await connect()
weak let released = channel
let token = try await cancelHistoryWhileNativeCloseIsPending(on: channel!)
channel = nil
let deadline = ContinuousClock.now.advanced(by: .seconds(5))
while released != nil, ContinuousClock.now < deadline {
try await Task.sleep(for: .milliseconds(10))
}
#expect(released == nil)
try await waitForEvent("transport-closed \(token)")
}
@Test func sftpSiblingSurvivesPTYReleaseAndLastCloseDisposesCancelledExec() async throws {
var channel: SSHPTYChannel? = try await connect()
var client: (any SFTPChannelProtocol)? = try await channel!.openSFTPChannel()
let sibling = try await client!.openSiblingChannel()
let token = try await cancelHistoryWhileNativeCloseIsPending(on: channel!)
await channel!.disconnect()
channel = nil
#expect(try await client!.attributes(at: "/fixture.txt")?.size == 8)
await client!.disconnect()
client = nil
#expect(try await sibling.readFile(at: "/fixture.txt", maximumBytes: 100) == Data("fixture\n".utf8))
await sibling.disconnect()
try await waitForEvent("transport-closed \(token)")
}
private func cancelHistoryWhileNativeCloseIsPending(on channel: SSHPTYChannel) async throws -> String {
let token = UUID().uuidString
let read = Task {
_ = try? await channel.executeHistoryRead("fixture-stalled-close \(token)", outputLimit: 1024)
}
defer { read.cancel() }
try await waitForEvent("exec \(token)")
read.cancel()
await read.value
// The Swift caller has returned, but the native channel is demonstrably
// still closing. This is the build 69 crash's lifetime gap.
try await waitForEvent("close \(token)")
return token
}
private func connect() async throws -> SSHPTYChannel {
let path = try #require(ProcessInfo.processInfo.environment["TERMIND_PTY_LIFECYCLE_FIXTURE"])
let fixture = try JSONDecoder().decode(Fixture.self, from: Data(contentsOf: URL(fileURLWithPath: path)))
let channel = SSHPTYChannel(output: { _ in }, exit: { _ in })
try await channel.connect(
using: SSHConnectionRequest(
configuration: .init(
hostID: UUID(), host: "127.0.0.1", port: fixture.port, username: "none",
credentials: [], expectedHostKeys: [.init(algorithm: .rsa, fingerprint: fixture.hostFingerprint)])),
shellIntegrationEnabled: false, columns: 80, rows: 24)
return channel
}
private func waitForEvent(_ event: String) async throws {
let path = try #require(ProcessInfo.processInfo.environment["TERMIND_PTY_LIFECYCLE_FIXTURE"])
let deadline = ContinuousClock.now.advanced(by: .seconds(5))
while true {
let events = try String(contentsOfFile: path + ".events", encoding: .utf8).split(separator: "\n")
if events.contains(Substring(event)) { return }
try #require(ContinuousClock.now < deadline, "SSH fixture did not report \(event)")
try await Task.sleep(for: .milliseconds(10))
}
}
}