feat(pro): gate multi-connection acceleration without changing saved settings

This commit is contained in:
Kaede Akino committed 2026-10-02 15:32:35 +08:00
1 parent 46895e39c6
commit 75a10fbbd0
25 files changed
+678 -63

No files matched your search

@@ -0,0 +1,75 @@
import Foundation
import SwiftUI
import TermindApplication
import TermindDomain
import TermindInfrastructure
import TermindPresentation
import Testing
@Suite @MainActor
struct MultiConnectionSettingsTests {
@Test(arguments: SFTPConcurrencyStrategy.allCases)
func freeDisplayAndOtherEditsPreserveTheSavedStrategy(_ strategy: SFTPConcurrencyStrategy) {
let persistence = SettingsStore(.init(strategy: strategy))
let settings = SFTPSettingsModel(persistence: persistence)
let pro = ProAccessController.testing(status: .free(trialAvailable: true))
let access = MultiConnectionSettingsAccess(isAllowed: false) { pro.require(.sftpMultiConnection) }
let selection = access.strategy(for: settings)
#expect(selection.wrappedValue == .channelsOnly)
#expect(persistence.saves == 0)
selection.wrappedValue = .channelsOnly
#expect(persistence.saves == 0)
selection.wrappedValue = .smart
selection.wrappedValue = .connectionsPreferred
#expect(pro.isPaywallPresented)
#expect(persistence.saves == 0)
#expect(settings.set(12, for: \.globalLimit))
#expect(persistence.value.strategy == strategy)
#expect(persistence.saves == 1)
let upgraded = MultiConnectionSettingsAccess(isAllowed: true, requestAccess: {})
#expect(upgraded.strategy(for: settings).wrappedValue == strategy)
}
@Test func freeHostEditorSavesOtherFieldsWithoutReplacingItsConnectionCount() {
var host = Host(name: "Before", host: "example.invalid", portForwardingMaxConnections: 8)
var prompts = 0
let saved = Binding(get: { host.portForwardingMaxConnections }, set: { host.portForwardingMaxConnections = $0 })
let free = MultiConnectionSettingsAccess(isAllowed: false) { prompts += 1 }.forwardingConnections(saved)
#expect(free.wrappedValue == 1)
free.wrappedValue = 1
free.wrappedValue = 2
host.name = "After"
#expect(host.portForwardingMaxConnections == 8)
#expect(prompts == 1)
let paid = MultiConnectionSettingsAccess(isAllowed: true, requestAccess: {}).forwardingConnections(saved)
#expect(paid.wrappedValue == 8)
paid.wrappedValue = 4
#expect(host.portForwardingMaxConnections == 4)
}
@Test(arguments: SFTPConcurrencyStrategy.allCases)
func runtimeProjectionPreservesSavedPreferencesWithoutOpeningPaywall(_ strategy: SFTPConcurrencyStrategy) {
let saved = SFTPPreferences(globalLimit: 80, perHostLimit: 40, strategy: strategy)
let free = ProAccessController.testing(status: .verificationFailed)
let effective = free.effectiveSFTPPreferences(saved)
#expect(effective == SFTPPreferences(globalLimit: 80, perHostLimit: 40, strategy: .channelsOnly))
#expect(free.effectiveForwardingConnections(8) == 1)
#expect(!free.isPaywallPresented)
let paid = ProAccessController.testing(status: .purchased(.familyShared))
#expect(paid.effectiveSFTPPreferences(saved) == saved)
#expect(paid.effectiveForwardingConnections(8) == 8)
#expect(!paid.isPaywallPresented)
}
}
@MainActor
private final class SettingsStore: SFTPPreferencesPersisting {
var value: SFTPPreferences
var saves = 0
init(_ value: SFTPPreferences) { self.value = value }
func loadSFTP() throws -> SFTPPreferences { value }
func saveSFTP(_ value: SFTPPreferences) throws {
self.value = value
saves += 1
}
}
@@ -116,6 +116,23 @@ struct RevenueCatCommerceTests {
#expect(!controller.hasProAccess)
}
@Test func accessChangesNotifyRuntimeWithoutAVisibleViewOrPaywall() async {
let service = FakeService()
let controller = ProAccessController(purchaseService: service, observeUpdates: false)
var observed: [Bool] = []
controller.accessDidChange = { observed.append(controller.hasProAccess) }
defer { controller.accessDidChange = nil }
await controller.refresh()
service.status = .purchased(.purchased)
await controller.refresh()
service.status = .purchased(.familyShared)
await controller.refresh()
service.status = .free(trialAvailable: false)
await controller.refresh()
#expect(observed == [true, false])
#expect(!controller.isPaywallPresented)
}
@Test
func newerCustomerInfoWinsOverAnOlderRefreshResponse() async {
let service = FakeService()
@@ -7223,6 +7223,23 @@
}
}
},
"Connections Preferred (Pro)" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "优先连接(Pro)"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "優先連線(Pro)"
}
}
}
},
"Contacting the App Store…" : {
"extractionState" : "manual",
"localizations" : {
@@ -15919,6 +15936,23 @@
}
}
},
"Max Connections (Pro)" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "最大连接数(Pro)"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "連線數上限(Pro)"
}
}
}
},
"Max Output" : {
"extractionState" : "manual",
"localizations" : {
@@ -26097,6 +26131,23 @@
}
}
},
"Smart (Pro)" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "智能(Pro)"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "智慧(Pro)"
}
}
}
},
"Smart uses channels for files up to 64 MB and prefers additional connections for larger files. Transfers with an unknown size use channels." : {
"extractionState" : "manual",
"localizations" : {
@@ -6,6 +6,8 @@ public nonisolated enum ProFeature: String, CaseIterable, Hashable, Sendable {
case mcpServer
case vaultSync
case unlimitedMetrics
case sftpMultiConnection
case portForwardingMultiConnection
}
public nonisolated enum ProOwnership: Equatable, Sendable {
@@ -15,6 +15,8 @@ public final class ProAccessController {
public private(set) var status: EntitlementStatus
public private(set) var isProcessing = false
public var isPaywallPresented = false
/// App-owned runtime reconciliation; does not require a visible SwiftUI surface.
public var accessDidChange: (() -> Void)?
public var usesRevenueCat: Bool { purchaseService != nil }
public var hasProAccess: Bool { status.hasProAccess }
@@ -62,6 +64,17 @@ public final class ProAccessController {
public func canUse(_: ProFeature) -> Bool { hasProAccess }
/// Resolve access without changing the user's persisted choice or presenting commerce UI.
public func effectiveSFTPPreferences(_ saved: SFTPPreferences) -> SFTPPreferences {
var effective = saved
if !canUse(.sftpMultiConnection) { effective.strategy = .channelsOnly }
return effective
}
public func effectiveForwardingConnections(_ saved: Int) -> Int {
canUse(.portForwardingMultiConnection) ? Host.normalizedPortForwardingMaxConnections(saved) : 1
}
@discardableResult
public func require(_ feature: ProFeature) -> Bool {
guard !canUse(feature) else { return true }
@@ -118,7 +131,9 @@ public final class ProAccessController {
}
private func apply(_ next: EntitlementStatus) {
let changed = status.hasProAccess != next.hasProAccess
status = next
if changed { accessDidChange?() }
entitlementExpirationTask?.cancel()
if case .subscription(let subscription) = next,
subscription.phase.hasProAccess, let expiresAt = subscription.expiresAt, expiresAt > now()
@@ -0,0 +1,38 @@
import SwiftUI
import TermindApplication
import TermindDomain
/// Editing-only projections. Reading a free value never overwrites the saved choice.
public struct MultiConnectionSettingsAccess {
public var isAllowed: Bool
private let requestAccess: () -> Void
public init(isAllowed: Bool, requestAccess: @escaping () -> Void) {
self.isAllowed = isAllowed
self.requestAccess = requestAccess
}
public func strategy(for model: SFTPSettingsModel) -> Binding<SFTPConcurrencyStrategy> {
Binding(
get: { isAllowed ? model.preferences.strategy : .channelsOnly },
set: { value in
guard isAllowed else {
if value != .channelsOnly { requestAccess() }
return
}
model.setStrategy(value)
})
}
public func forwardingConnections(_ saved: Binding<Int>) -> Binding<Int> {
Binding(
get: { isAllowed ? saved.wrappedValue : 1 },
set: { value in
guard isAllowed else {
if value > 1 { requestAccess() }
return
}
saved.wrappedValue = Host.normalizedPortForwardingMaxConnections(value)
})
}
}
@@ -4,31 +4,35 @@ import TermindDomain
public struct SFTPSettingsFields: View {
let model: SFTPSettingsModel
let access: MultiConnectionSettingsAccess
public init(model: SFTPSettingsModel) { self.model = model }
public init(
model: SFTPSettingsModel,
access: MultiConnectionSettingsAccess = .init(isAllowed: true, requestAccess: {})
) {
self.model = model
self.access = access
}
public var body: some View {
Picker("Concurrent Strategy", selection: strategyBinding) {
Text("Smart").tag(SFTPConcurrencyStrategy.smart)
Text("Channels Only").tag(SFTPConcurrencyStrategy.channelsOnly)
Text("Connections Preferred").tag(SFTPConcurrencyStrategy.connectionsPreferred)
Picker(AppStrings.SFTP.concurrentStrategy, selection: access.strategy(for: model)) {
Text(access.isAllowed ? AppStrings.SFTP.smart : AppStrings.Pro.smartStrategy)
.tag(SFTPConcurrencyStrategy.smart)
Text(AppStrings.SFTP.channelsOnly).tag(SFTPConcurrencyStrategy.channelsOnly)
Text(access.isAllowed ? AppStrings.SFTP.connectionsPreferred : AppStrings.Pro.connectionsPreferredStrategy)
.tag(SFTPConcurrencyStrategy.connectionsPreferred)
}
if model.preferences.strategy == .smart {
Text(
"Smart uses channels for files up to 64 MB and prefers additional connections for larger files. Transfers with an unknown size use channels."
)
.font(.caption).foregroundStyle(.secondary)
if access.strategy(for: model).wrappedValue == .smart {
Text(AppStrings.SFTP.smartStrategyExplanation)
.font(.caption).foregroundStyle(.secondary)
}
Stepper(value: binding(\.globalLimit), in: SFTPPreferences.globalLimitRange) {
label("Concurrent Transfers", value: model.preferences.globalLimit)
label(AppStrings.SFTP.concurrentTransfers, value: model.preferences.globalLimit)
}
Stepper(value: binding(\.perHostLimit), in: model.preferences.availablePerHostRange) {
label("Per-Host Transfers", value: model.preferences.perHostLimit)
label(AppStrings.SFTP.perHostTransfers, value: model.preferences.perHostLimit)
}
Text(
"Changes apply to queued transfers; active transfers continue. If new connections would prompt for authentication, transfers use parallel channels on the existing connection."
)
.font(.caption).foregroundStyle(.secondary)
Text(AppStrings.SFTP.concurrencyChangesExplanation)
.font(.caption).foregroundStyle(.secondary)
if let error = model.storageError { Text(error).foregroundStyle(.red) }
}
@@ -51,7 +55,4 @@ public struct SFTPSettingsFields: View {
Binding(get: { model.preferences[keyPath: keyPath] }, set: { model.set($0, for: keyPath) })
}
private var strategyBinding: Binding<SFTPConcurrencyStrategy> {
Binding(get: { model.preferences.strategy }, set: { model.setStrategy($0) })
}
}
@@ -2,6 +2,10 @@ import Foundation
extension AppStrings {
public nonisolated enum Pro {
public static var smartStrategy: LocalizedStringResource { "Smart (Pro)" }
public static var connectionsPreferredStrategy: LocalizedStringResource { "Connections Preferred (Pro)" }
public static var maxConnections: LocalizedStringResource { "Max Connections (Pro)" }
public static var contactingTheAppStoreProgress: LocalizedStringResource {
"Contacting the App Store…"
}
@@ -2,6 +2,18 @@ import Foundation
extension AppStrings {
public nonisolated enum SFTP {
public static var concurrentStrategy: LocalizedStringResource { "Concurrent Strategy" }
public static var smart: LocalizedStringResource { "Smart" }
public static var channelsOnly: LocalizedStringResource { "Channels Only" }
public static var connectionsPreferred: LocalizedStringResource { "Connections Preferred" }
public static var concurrentTransfers: LocalizedStringResource { "Concurrent Transfers" }
public static var perHostTransfers: LocalizedStringResource { "Per-Host Transfers" }
public static var smartStrategyExplanation: LocalizedStringResource {
"Smart uses channels for files up to 64 MB and prefers additional connections for larger files. Transfers with an unknown size use channels."
}
public static var concurrencyChangesExplanation: LocalizedStringResource {
"Changes apply to queued transfers; active transfers continue. If new connections would prompt for authentication, transfers use parallel channels on the existing connection."
}
public static var appStoreProgress: LocalizedStringResource {
"App Store…"
@@ -80,7 +80,7 @@ public final class PortForwardingConnection {
fileprivate var silentAttempt = false
fileprivate var additionalSnapshot: SSHAdditionalConnectionSnapshot?
fileprivate var connectionCounts: [PortForwardingRule.ID: Int] = [:]
fileprivate let maximumConnections: Int
fileprivate var maximumConnections: Int
fileprivate init(
host: Host, route: [Host], maximumConnections: Int, reconnect: SessionReconnectController
@@ -100,14 +100,6 @@ public final class PortForwardingController {
public nonisolated enum PoolPolicy: Sendable {
case singleConnection
case hostConfigured
fileprivate func maximumConnections(for host: Host) -> Int {
switch self {
case .singleConnection: 1
case .hostConfigured:
Host.normalizedPortForwardingMaxConnections(host.portForwardingMaxConnections)
}
}
}
public typealias ConnectionFactory =
@@ -122,6 +114,8 @@ public final class PortForwardingController {
@escaping SSHForwardingConnection.FailureHandler
) -> any SSHForwardingConnectionProtocol
private let poolPolicy: PoolPolicy
private var connectionLimit: Int
private var limitUpdateTask: Task<Void, Never>?
private let logger: Logger
private let makeReconnectController: @MainActor () -> SessionReconnectController
public private(set) var states: [PortForwardingRule.ID: PortForwardingRuntimeState] = [:]
@@ -135,6 +129,7 @@ public final class PortForwardingController {
public init(
hosts: HostLibraryModel,
poolPolicy: PoolPolicy = .singleConnection,
connectionLimit: Int = Host.portForwardingMaxConnectionsRange.upperBound,
logger: Logger = ApplicationLogger.disabled("termind.port-forwarding"),
makeConnection: ConnectionFactory? = nil,
makeReconnectController: @escaping @MainActor () -> SessionReconnectController = {
@@ -144,6 +139,7 @@ public final class PortForwardingController {
self.hosts = hosts
self.logger = logger
self.poolPolicy = poolPolicy
self.connectionLimit = Host.normalizedPortForwardingMaxConnections(connectionLimit)
self.makeReconnectController = makeReconnectController
if let makeConnection {
self.makeConnection = { _, connectionHandler, failureHandler in
@@ -151,7 +147,7 @@ public final class PortForwardingController {
}
} else {
self.makeConnection = { maximumConnections, connectionHandler, failureHandler in
if maximumConnections > 1 {
if poolPolicy == .hostConfigured {
SSHForwardingConnectionPool(
maximumConnections: maximumConnections,
connectionHandler: connectionHandler, failureHandler: failureHandler, logger: logger)
@@ -166,6 +162,30 @@ public final class PortForwardingController {
public func state(for id: PortForwardingRule.ID) -> PortForwardingRuntimeState { states[id] ?? .stopped }
public func connection(for id: PortForwardingRule.ID) -> PortForwardingConnection? { presentations[id] }
private func maximumConnections(for host: Host) -> Int {
switch poolPolicy {
case .singleConnection: 1
case .hostConfigured:
min(connectionLimit, Host.normalizedPortForwardingMaxConnections(host.portForwardingMaxConnections))
}
}
/// The app supplies final connection limits; the transport has no commerce dependency.
public func updateConnectionLimit(_ maximum: Int) {
connectionLimit = Host.normalizedPortForwardingMaxConnections(maximum)
for context in connections.values {
if let host = hosts.host(context.hostID) { context.maximumConnections = maximumConnections(for: host) }
}
let previous = limitUpdateTask
limitUpdateTask = Task { [weak self] in
await previous?.value
guard let self else { return }
for context in Array(connections.values) {
await context.forwardingConnection?.updateMaximumConnections(context.maximumConnections)
}
}
}
public func start(_ id: PortForwardingRule.ID) {
guard !state(for: id).isActive, let rule = hosts.portForwarding(id), rule.isValid,
let host = hosts.host(rule.hostID), host.sshEnabled
@@ -183,7 +203,7 @@ public final class PortForwardingController {
} else {
context = PortForwardingConnection(
host: host, route: hosts.route(for: host),
maximumConnections: poolPolicy.maximumConnections(for: host),
maximumConnections: maximumConnections(for: host),
reconnect: makeReconnectController())
connections[host.id] = context
}
@@ -396,6 +416,7 @@ public final class PortForwardingController {
}
let attemptSubmissions = silently ? [:] : context.submissions.takeAuthenticationSecrets()
let attemptProxySubmissions = silently ? [:] : context.proxySubmissions.takeProxyAuthenticationSecrets()
context.maximumConnections = maximumConnections(for: host)
let forwardingConnection = makeConnection(
context.maximumConnections,
{ [weak self, weak context] id, generation, count in
@@ -476,6 +497,7 @@ public final class PortForwardingController {
context.additionalSnapshot = hosts.additionalConnectionSnapshot(from: configuration)
}
if let snapshot = context.additionalSnapshot {
await forwardingConnection.updateMaximumConnections(context.maximumConnections)
await forwardingConnection.configureAdditionalConnections { [weak self] in
guard let self else { throw CancellationError() }
return try self.hosts.additionalConnectionRequest(from: snapshot)
@@ -29,6 +29,7 @@ public nonisolated struct SSHForwardingPoolStatus: Hashable, Sendable {
public protocol SSHForwardingConnectionProtocol: Actor {
func connect(_ request: SSHConnectionRequest, progress: SSHRouteProgressHandler?) async throws
func configureAdditionalConnections(_ resolver: @escaping SSHAdditionalConnectionRequestResolver) async
func updateMaximumConnections(_ maximum: Int) async
func connectionPoolStatus() async -> SSHForwardingPoolStatus
func start(_ rule: PortForwardingRule, generation: UUID) async throws
func stop(_ id: PortForwardingRule.ID) async
@@ -37,6 +38,7 @@ public protocol SSHForwardingConnectionProtocol: Actor {
extension SSHForwardingConnectionProtocol {
public func configureAdditionalConnections(_ resolver: @escaping SSHAdditionalConnectionRequestResolver) async {}
public func updateMaximumConnections(_ maximum: Int) async {}
public func connectionPoolStatus() async -> SSHForwardingPoolStatus { .single }
}
@@ -77,6 +79,9 @@ public actor SSHForwardingConnection: SSHForwardingConnectionProtocol {
await worker.acceptShared(descriptor, ruleID: ruleID, generation: generation)
}
/// Queried only after the pool has stopped assigning flows to this connection.
func hasActiveFlows() async -> Bool { await worker.hasActiveFlows() }
public func stop(_ id: PortForwardingRule.ID) async {
await worker.stop(id)
}
@@ -333,6 +338,12 @@ private nonisolated final class SSHForwardingWorker: @unchecked Sendable {
}
}
func hasActiveFlows() async -> Bool {
await withCheckedContinuation { continuation in
queue.async { [self] in continuation.resume(returning: !flows.isEmpty) }
}
}
func stop(_ id: PortForwardingRule.ID) async {
await withCheckedContinuation { continuation in
queue.async { [self] in
@@ -22,7 +22,7 @@ actor SSHForwardingConnectionPool: SSHForwardingConnectionProtocol {
let generation: UUID
}
private let maximumConnections: Int
private var maximumConnections: Int
private let connectionHandler: SSHForwardingConnection.ConnectionHandler
private let failureHandler: SSHForwardingConnection.FailureHandler
private let logger: Logger
@@ -34,6 +34,11 @@ actor SSHForwardingConnectionPool: SSHForwardingConnectionProtocol {
private var additionalRequest: SSHAdditionalConnectionRequestResolver?
private var opening: [UUID: Slot] = [:]
private var installing: [UUID: Slot] = [:]
private var openingTasks: [UUID: Task<Void, Never>] = [:]
private var draining: Set<UUID> = []
private var assignments: [UUID: Int] = [:]
private var checkingIdle: Set<UUID> = []
private var recheckIdle: Set<UUID> = []
private var retryTask: Task<Void, Never>?
private var stabilityTasks: [UUID: Task<Void, Never>] = [:]
private var retryAttempt = 0
@@ -46,7 +51,7 @@ actor SSHForwardingConnectionPool: SSHForwardingConnectionProtocol {
failureHandler: @escaping SSHForwardingConnection.FailureHandler,
logger: Logger = ApplicationLogger.disabled("termind.port-forwarding-pool")
) {
self.maximumConnections = min(max(maximumConnections, 2), Host.portForwardingMaxConnectionsRange.upperBound)
self.maximumConnections = Host.normalizedPortForwardingMaxConnections(maximumConnections)
self.connectionHandler = connectionHandler
self.failureHandler = failureHandler
self.logger = logger
@@ -87,6 +92,28 @@ actor SSHForwardingConnectionPool: SSHForwardingConnectionProtocol {
SSHForwardingPoolStatus(connected: slots.count, target: desiredConnections)
}
func updateMaximumConnections(_ maximum: Int) async {
maximumConnections = Host.normalizedPortForwardingMaxConnections(maximum)
rebalanceSlots()
// Pending auxiliaries have no accepted flows and can be cancelled immediately.
let pending: [Slot]
if slots.count + opening.count + installing.count > desiredConnections {
pending = Array(opening.values) + Array(installing.values)
opening.removeAll()
installing.removeAll()
for task in openingTasks.values { task.cancel() }
openingTasks.removeAll()
} else {
pending = []
}
retryTask?.cancel()
retryTask = nil
publishPoolCounts()
for slot in pending { await slot.connection.disconnect() }
for id in Array(draining) { await retireIfIdle(id) }
fillPool()
}
func flowDistribution(for ruleID: PortForwardingRule.ID) async -> [Int] {
slots.keys.sorted { $0.uuidString < $1.uuidString }.map { counts[$0]?[ruleID] ?? 0 }
}
@@ -99,6 +126,7 @@ actor SSHForwardingConnectionPool: SSHForwardingConnectionProtocol {
try await primary.connection.start(rule, generation: generation)
primary.rules.insert(rule.id)
} else {
// Draining slots may be retained again if the limit increases before their flows finish.
for slot in slots.values {
try await slot.connection.registerShared(rule, generation: generation)
slot.rules.insert(rule.id)
@@ -133,6 +161,12 @@ actor SSHForwardingConnectionPool: SSHForwardingConnectionProtocol {
retryTask = nil
for task in stabilityTasks.values { task.cancel() }
stabilityTasks.removeAll()
for task in openingTasks.values { task.cancel() }
openingTasks.removeAll()
draining.removeAll()
assignments.removeAll()
checkingIdle.removeAll()
recheckIdle.removeAll()
let pending = Array(opening.values) + Array(installing.values)
opening.removeAll()
installing.removeAll()
@@ -152,6 +186,40 @@ actor SSHForwardingConnectionPool: SSHForwardingConnectionProtocol {
private var needsPool: Bool { registrations.values.contains { $0.rule.kind != .remote } }
private var desiredConnections: Int { needsPool ? maximumConnections : 1 }
private func rebalanceSlots() {
let auxiliaries = slots.keys.filter { $0 != primaryID }.sorted { $0.uuidString < $1.uuidString }
let retained = Set(auxiliaries.prefix(max(0, desiredConnections - 1)))
draining = Set(auxiliaries).subtracting(retained)
}
private func retireIfIdle(_ id: UUID) async {
guard draining.contains(id), id != primaryID, assignments[id, default: 0] == 0,
let slot = slots[id]
else { return }
guard checkingIdle.insert(id).inserted else {
recheckIdle.insert(id)
return
}
let busy = await slot.connection.hasActiveFlows()
checkingIdle.remove(id)
let needsRecheck = recheckIdle.remove(id) != nil
if busy {
if needsRecheck { await retireIfIdle(id) }
return
}
// The worker query is authoritative: count callbacks may still be in flight.
guard draining.contains(id), id != primaryID, slots[id] === slot,
assignments[id, default: 0] == 0
else { return }
slots[id] = nil
counts[id] = nil
draining.remove(id)
stabilityTasks.removeValue(forKey: id)?.cancel()
publishPoolCounts()
await slot.connection.disconnect()
fillPool()
}
private func makeSlot(id: UUID) -> Slot {
let connection = SSHForwardingConnection(
connectionHandler: { [weak self] ruleID, generation, count in
@@ -173,10 +241,12 @@ actor SSHForwardingConnectionPool: SSHForwardingConnectionProtocol {
let slot = makeSlot(id: id)
opening[id] = slot
let resolver = additionalRequest!
Task { [weak self] in
openingTasks[id] = Task { [weak self] in
do {
let request = try await resolver()
try Task.checkCancellation()
try await slot.connection.connect(request, progress: nil)
try Task.checkCancellation()
await self?.opened(slot)
} catch {
await self?.openFailed(id: id, error: error)
@@ -187,6 +257,7 @@ actor SSHForwardingConnectionPool: SSHForwardingConnectionProtocol {
}
private func opened(_ slot: Slot) async {
defer { openingTasks[slot.id] = nil }
guard opening.removeValue(forKey: slot.id) != nil, !disconnecting,
slots.count + installing.count < desiredConnections
else {
@@ -221,12 +292,15 @@ actor SSHForwardingConnectionPool: SSHForwardingConnectionProtocol {
}
private func openFailed(id: UUID, error: any Error) {
openingTasks[id] = nil
guard opening.removeValue(forKey: id) != nil, !disconnecting else { return }
scheduleRetry(for: error)
}
private func scheduleRetry(for error: any Error) {
guard !Self.requiresManualRetry(error), retryTask == nil, needsPool else { return }
guard !Self.requiresManualRetry(error), retryTask == nil, needsPool,
!disconnecting, slots.count + opening.count + installing.count < desiredConnections
else { return }
let delays = [1, 2, 5, 10, 30]
let delay = delays[min(retryAttempt, delays.count - 1)]
retryAttempt += 1
@@ -258,15 +332,18 @@ actor SSHForwardingConnectionPool: SSHForwardingConnectionProtocol {
stabilityTasks.removeValue(forKey: slotID)?.cancel()
counts[slotID] = nil
slots[slotID] = nil
draining.remove(slotID)
publishPoolCounts()
if wasPrimary, let promoted = slots.values.first {
primaryID = promoted.id
rebalanceSlots()
do {
for registration in registrations.values where registration.rule.kind == .remote {
try await promoted.connection.start(registration.rule, generation: registration.generation)
promoted.rules.insert(registration.rule.id)
}
fillPool()
for id in Array(draining) { await retireIfIdle(id) }
} catch {
failureHandler(error.localizedDescription)
}
@@ -281,6 +358,8 @@ actor SSHForwardingConnectionPool: SSHForwardingConnectionProtocol {
private func retireAuxiliaries() async {
retryTask?.cancel()
retryTask = nil
for task in openingTasks.values { task.cancel() }
openingTasks.removeAll()
let pending = Array(opening.values) + Array(installing.values)
opening.removeAll()
installing.removeAll()
@@ -289,6 +368,7 @@ actor SSHForwardingConnectionPool: SSHForwardingConnectionProtocol {
for slot in auxiliaries {
slots[slot.id] = nil
counts[slot.id] = nil
draining.remove(slot.id)
stabilityTasks.removeValue(forKey: slot.id)?.cancel()
}
await withTaskGroup(of: Void.self) { group in
@@ -302,7 +382,7 @@ actor SSHForwardingConnectionPool: SSHForwardingConnectionProtocol {
Darwin.close(descriptor)
return
}
let eligible = slots.values.filter { $0.rules.contains(ruleID) }
let eligible = slots.values.filter { $0.rules.contains(ruleID) && !draining.contains($0.id) }
guard !eligible.isEmpty else {
Darwin.close(descriptor)
return
@@ -313,16 +393,21 @@ actor SSHForwardingConnectionPool: SSHForwardingConnectionProtocol {
}
let slot = candidates[roundRobin % candidates.count]
roundRobin &+= 1
guard await slot.connection.acceptShared(descriptor, ruleID: ruleID, generation: generation) else {
assignments[slot.id, default: 0] += 1
let accepted = await slot.connection.acceptShared(descriptor, ruleID: ruleID, generation: generation)
assignments[slot.id, default: 1] -= 1
if assignments[slot.id] == 0 { assignments[slot.id] = nil }
if !accepted {
Darwin.close(descriptor)
return
}
await retireIfIdle(slot.id)
}
private func receivedCount(_ count: Int, ruleID: UUID, generation: UUID, slotID: UUID) {
private func receivedCount(_ count: Int, ruleID: UUID, generation: UUID, slotID: UUID) async {
guard registrations[ruleID]?.generation == generation, slots[slotID] != nil else { return }
counts[slotID, default: [:]][ruleID] = count
report(ruleID, generation: generation)
if count == 0 { await retireIfIdle(slotID) }
}
private func report(_ ruleID: UUID, generation: UUID?) {
@@ -159,8 +159,11 @@ public final class SFTPTransferCoordinator {
globalLimit = limits.globalLimit
perHostLimit = limits.perHostLimit
strategy = limits.strategy
preferencesUpdateTask?.cancel()
preferencesUpdateTask = Task { await concurrencyScheduler.updatePreferences(limits) }
let previous = preferencesUpdateTask
preferencesUpdateTask = Task {
await previous?.value
await concurrencyScheduler.updatePreferences(limits)
}
schedule()
}
+1
View File
@@ -13,6 +13,7 @@ struct MobileAppDependencies {
var workspaceConnections: MobileWorkspaceConnectionServices
var metrics: MobileMetricsServices
var appReview: AppReviewController
var proAccess: ProAccessController?
var diagnosis = DiagnosisController()
var logging: any ApplicationLogManaging = DisabledApplicationLogManager.shared
var connectionCoordinator: RemoteConnectionCoordinator = RemoteConnectionCoordinator()
+13 -7
View File
@@ -217,7 +217,7 @@ final class MobileAppModel {
self.defaults = inMemory || hosts != nil ? nil : defaults
snippetTimestampFallback = MobileSnippetTimestamp.fallback(in: self.defaults)
liveActivities = dependencies.liveActivities ?? MobileLiveActivityController(enabled: !inMemory && hosts == nil)
pro = inMemory || hosts != nil ? .unrestrictedForTesting() : .live()
pro = dependencies.proAccess ?? (inMemory || hosts != nil ? .unrestrictedForTesting() : .live())
let persistence = HostPersistence(context: vaultContext)
_ = try persistence.load()
let secrets: any SecretStoring =
@@ -316,7 +316,8 @@ final class MobileAppModel {
service: AppleSpeechTranscriptionService(audioInput: speechAudio)
)
speechAudio.onInterruption = { [weak speech] in speech?.cancelAll() }
let sftpConcurrencyScheduler = SFTPConcurrencyScheduler(preferences: sftpSettings.preferences)
let sftpPreferences = pro.effectiveSFTPPreferences(sftpSettings.preferences)
let sftpConcurrencyScheduler = SFTPConcurrencyScheduler(preferences: sftpPreferences)
assistant = MobileAssistantModel(
configuration: ai,
repository: MemoryIndexedAgentRepository(
@@ -342,9 +343,9 @@ final class MobileAppModel {
forwardings = dependencies.makeForwardings(library)
transfers = SFTPTransferCoordinator(
store: library, clientFactory: { connectionCoordinator.makeSFTPClient() },
globalLimit: sftpSettings.preferences.globalLimit,
perHostLimit: sftpSettings.preferences.perHostLimit,
strategy: sftpSettings.preferences.strategy,
globalLimit: sftpPreferences.globalLimit,
perHostLimit: sftpPreferences.perHostLimit,
strategy: sftpPreferences.strategy,
concurrencyScheduler: sftpConcurrencyScheduler)
sftpEdits = SFTPEditService(transfers: transfers, host: { [weak library] in library?.host($0) })
library.willDeleteHost = { [weak sftpEdits, weak forwardings] hostID, _ in
@@ -391,8 +392,13 @@ final class MobileAppModel {
connectionHistorySync?.didApplyRemote = { [weak self] _ in self?.connectionHistory.reload() }
deviceSync?.didApplyRemote = { [weak self] _ in self?.devices.reload() }
vaultSync?.didApplyRemote = { [weak self] in self?.applyRemoteVault($0, keys: $1) }
sftpSettings.didChange = { [weak transfers] preferences in
transfers?.updatePreferences(preferences)
sftpSettings.didChange = { [weak transfers, weak pro] preferences in
guard let pro else { return }
transfers?.updatePreferences(pro.effectiveSFTPPreferences(preferences))
}
pro.accessDidChange = { [weak self] in
guard let self else { return }
transfers.updatePreferences(pro.effectiveSFTPPreferences(sftpSettings.preferences))
}
sshSettings.didChange = { preferences in
connectionCoordinator.updateSSHPreferences(preferences)
@@ -5,11 +5,12 @@ import TermindPresentation
struct MobileConnectionSettingsView: View {
let ssh: SSHSettingsModel
let sftp: SFTPSettingsModel
var sftpAccess = MultiConnectionSettingsAccess(isAllowed: true, requestAccess: {})
var body: some View {
Form {
Section(AppStrings.Vault.ssh.swiftUIKey) { SSHSettingsFields(model: ssh) }
Section(AppStrings.SFTP.sftp.swiftUIKey) { SFTPSettingsFields(model: sftp) }
Section(AppStrings.SFTP.sftp.swiftUIKey) { SFTPSettingsFields(model: sftp, access: sftpAccess) }
}
.navigationTitle(AppStrings.Settings.connections.swiftUIKey)
}
@@ -41,7 +41,11 @@ struct MobileSettingsView: View {
Label(AppStrings.Settings.terminal.swiftUIKey, systemImage: "terminal")
}
NavigationLink {
MobileConnectionSettingsView(ssh: app.sshSettings, sftp: app.sftpSettings)
MobileConnectionSettingsView(
ssh: app.sshSettings, sftp: app.sftpSettings,
sftpAccess: .init(isAllowed: app.pro.canUse(.sftpMultiConnection)) {
app.pro.require(.sftpMultiConnection)
})
} label: {
Label(AppStrings.Settings.connections.swiftUIKey, systemImage: "network")
}
@@ -0,0 +1,37 @@
import TermindDomain
import Testing
@testable import TermindIOS
@testable import TermindInfrastructure
@Suite @MainActor
struct MobileMultiConnectionAccessTests {
@Test(arguments: SFTPConcurrencyStrategy.allCases)
func entitlementChangesPreserveSavedSettingsWithoutAnyScene(_ strategy: SFTPConcurrencyStrategy) async throws {
let service = MobileConnectionPurchaseService()
let pro = ProAccessController(purchaseService: service, observeUpdates: false)
var dependencies = MobileAppDependencies.testing()
dependencies.proAccess = pro
let app = try MobileAppModel(dependencies: dependencies)
app.sftpSettings.setStrategy(strategy)
#expect(app.transfers.strategy == .channelsOnly)
service.status = .purchased(.purchased)
await pro.refresh()
#expect(app.transfers.strategy == strategy)
service.status = .free(trialAvailable: false)
await pro.refresh()
#expect(app.transfers.strategy == .channelsOnly)
#expect(app.sftpSettings.preferences.strategy == strategy)
app.sftpSettings.set(60, for: \.globalLimit)
#expect(app.transfers.globalLimit == 60)
#expect(app.transfers.strategy == .channelsOnly)
#expect(!pro.isPaywallPresented)
}
}
@MainActor
private final class MobileConnectionPurchaseService: ProPurchaseService {
let updates = AsyncStream<EntitlementStatus> { $0.finish() }
var status = EntitlementStatus.free(trialAvailable: false)
func currentStatus() async throws -> EntitlementStatus { status }
}
+16 -6
View File
@@ -244,7 +244,8 @@ final class AppModel {
store: dependencies.assistantModelSelectionStore ?? InMemoryAssistantModelSelectionStore())
assistantChatCollaborationStore =
dependencies.assistantChatCollaborationStore ?? InMemoryAssistantChatCollaborationStore()
let sftpConcurrencyScheduler = SFTPConcurrencyScheduler(preferences: sftpSettings.preferences)
let sftpPreferences = proAccess.effectiveSFTPPreferences(sftpSettings.preferences)
let sftpConcurrencyScheduler = SFTPConcurrencyScheduler(preferences: sftpPreferences)
let agentHostServices = AgentHostServices.live(
store: hosts,
coordinator: connectionCoordinator,
@@ -261,13 +262,15 @@ final class AppModel {
metricsSync = dependencies.metricsSync
forwarding = PortForwardingController(
hosts: hosts, poolPolicy: .hostConfigured,
connectionLimit: proAccess.effectiveForwardingConnections(
Host.portForwardingMaxConnectionsRange.upperBound),
logger: dependencies.logging.makeLogger(label: "termind.port-forwarding"))
sftpTransfers = SFTPTransferCoordinator(
store: hosts,
clientFactory: { connectionCoordinator.makeSFTPClient() },
globalLimit: sftpSettings.preferences.globalLimit,
perHostLimit: sftpSettings.preferences.perHostLimit,
strategy: sftpSettings.preferences.strategy,
globalLimit: sftpPreferences.globalLimit,
perHostLimit: sftpPreferences.perHostLimit,
strategy: sftpPreferences.strategy,
concurrencyScheduler: sftpConcurrencyScheduler
)
sftpEdits = SFTPEditService(transfers: sftpTransfers, host: { [weak hosts] in hosts?.host($0) })
@@ -374,8 +377,15 @@ final class AppModel {
vaultSync?.didApplyRemote = { [weak self] snapshot, keys in
self?.applyRemoteVaultSnapshot(snapshot, changedKeys: keys)
}
sftpSettings.didChange = { [weak sftpTransfers] preferences in
sftpTransfers?.updatePreferences(preferences)
sftpSettings.didChange = { [weak sftpTransfers, weak proAccess] preferences in
guard let proAccess else { return }
sftpTransfers?.updatePreferences(proAccess.effectiveSFTPPreferences(preferences))
}
proAccess.accessDidChange = { [weak self] in
guard let self else { return }
self.sftpTransfers.updatePreferences(self.proAccess.effectiveSFTPPreferences(self.sftpSettings.preferences))
self.forwarding.updateConnectionLimit(
self.proAccess.effectiveForwardingConnections(Host.portForwardingMaxConnectionsRange.upperBound))
}
sshSettings.didChange = { preferences in
connectionCoordinator.updateSSHPreferences(preferences)
+11 -3
View File
@@ -295,13 +295,15 @@ struct HostEditorView: View {
Section(AppStrings.Navigation.portForwarding.swiftUIKey) {
Stepper(
value: $draft.portForwardingMaxConnections,
value: forwardingConnections,
in: Host.portForwardingMaxConnectionsRange
) {
HStack {
Text(AppStrings.Vault.maxConnections)
Text(
app.proAccess.canUse(.portForwardingMultiConnection)
? AppStrings.Vault.maxConnections : AppStrings.Pro.maxConnections)
Spacer()
Text(draft.portForwardingMaxConnections, format: .number).monospacedDigit()
Text(forwardingConnections.wrappedValue, format: .number).monospacedDigit()
}
}
Text(AppStrings.Vault.forwardingPoolExplanation)
@@ -515,6 +517,12 @@ struct HostEditorView: View {
// MARK: - Picking, and making
private var forwardingConnections: Binding<Int> {
MultiConnectionSettingsAccess(isAllowed: app.proAccess.canUse(.portForwardingMultiConnection)) {
app.proAccess.require(.portForwardingMultiConnection)
}.forwardingConnections($draft.portForwardingMaxConnections)
}
/// What a picker row means: nothing, one of the saved records, or the row
/// that makes a new one.
///
+7 -1
View File
@@ -583,7 +583,13 @@ private struct ConnectionSettings: View {
var body: some View {
Section(AppStrings.Vault.ssh.swiftUIKey) { SSHSettingsFields(model: app.sshSettings) }
Section(AppStrings.SFTP.sftp.swiftUIKey) { SFTPSettingsFields(model: app.sftpSettings) }
Section(AppStrings.SFTP.sftp.swiftUIKey) {
SFTPSettingsFields(
model: app.sftpSettings,
access: .init(isAllowed: app.proAccess.canUse(.sftpMultiConnection)) {
app.proAccess.require(.sftpMultiConnection)
})
}
}
}
+32 -3
View File
@@ -8,6 +8,23 @@ import struct TermindDomain.Host
@Suite @MainActor
struct PortForwardingReconnectTests {
@Test func numericLimitsUpdateLiveConnectionsAndSurviveRecoveryWithoutChangingHosts() async throws {
let fixture = ReconnectFixture(poolPolicy: .hostConfigured, connectionLimit: 1, hostConnections: 8)
defer { fixture.stop() }
try await fixture.start([0, 1, 2])
#expect(await fixture.sessions[0].maximumConnections == 1)
fixture.controller.updateConnectionLimit(64)
try await eventually { await fixture.sessions[0].maximumConnections == 8 }
fixture.controller.updateConnectionLimit(1)
try await eventually { await fixture.sessions[0].maximumConnections == 1 }
#expect(fixture.library.host(fixture.host.id)?.portForwardingMaxConnections == 8)
await fixture.sessions[0].fail()
try await fixture.retry()
try await fixture.waitForRunning([0, 1, 2])
#expect(await fixture.sessions[1].maximumConnections == 1)
#expect(!fixture.context!.journey.isPresented)
}
@Test func compactLabelsHideConnectionCountsAndFailureDetails() {
let running = PortForwardingRuntimeState.running(
activeConnections: 37, sshConnections: 3, targetSSHConnections: 8)
@@ -376,6 +393,8 @@ private final class ReconnectFixture {
var sessions: [ReconnectSession] = []
var nextError: TermindSSHError?
let modes: [ReconnectSession.Mode]
let poolPolicy: PortForwardingController.PoolPolicy
let connectionLimit: Int
lazy var controller = makeController()
private func makeController() -> PortForwardingController {
@@ -387,17 +406,25 @@ private final class ReconnectFixture {
return session
}
return PortForwardingController(
hosts: library, makeConnection: makeConnection,
hosts: library, poolPolicy: poolPolicy, connectionLimit: connectionLimit, makeConnection: makeConnection,
makeReconnectController: { [gate] in
SessionReconnectController(policy: .sessionDefault, sleep: { await gate.sleep($0) }, now: Date.init)
})
}
var context: PortForwardingConnection? { controller.connection(for: rules[0].id) }
init(modes: [ReconnectSession.Mode] = [], usesJump: Bool = false, trusted: Bool = true) {
init(
modes: [ReconnectSession.Mode] = [], usesJump: Bool = false, trusted: Bool = true,
poolPolicy: PortForwardingController.PoolPolicy = .singleConnection, connectionLimit: Int = 64,
hostConnections: Int = 1
) {
self.modes = modes
self.poolPolicy = poolPolicy
self.connectionLimit = connectionLimit
jump = Host(name: "Jump", host: "jump.invalid", identityID: identity.id)
host = Host(name: "Host", host: "host.invalid", identityID: identity.id, jumpHostID: usesJump ? jump.id : nil)
host = Host(
name: "Host", host: "host.invalid", identityID: identity.id, jumpHostID: usesJump ? jump.id : nil,
portForwardingMaxConnections: hostConnections)
rules = [
PortForwardingRule(hostID: host.id, kind: .local, bindPort: 8000),
PortForwardingRule(hostID: host.id, kind: .remote, bindPort: 9000),
@@ -482,6 +509,8 @@ private actor ReconnectSession: SSHForwardingConnectionProtocol {
var generations: [UUID: UUID] = [:]
var hasProgress = false
var disconnects = 0
var maximumConnections = 1
func updateMaximumConnections(_ maximum: Int) async { maximumConnections = maximum }
private var connectGate: CheckedContinuation<Void, Never>?
private var disconnectGate: CheckedContinuation<Void, Never>?
private var pendingStarts: [UUID: CheckedContinuation<Void, any Error>] = [:]
@@ -5,6 +5,30 @@ import Testing
@Suite
struct SFTPConcurrencySchedulerTests {
@Test func finalPreferencesAffectQueuedWorkWithoutChangingGrantedPermits() async throws {
let scheduler = SFTPConcurrencyScheduler(
preferences: .init(globalLimit: 1, perHostLimit: 1, strategy: .connectionsPreferred))
let host = UUID()
let first = try await scheduler.acquire(
anchorHostID: host, hostIDs: [host], source: .workspace(UUID()), byteCount: 80_000_000)
let queued = Task {
try await scheduler.acquire(
anchorHostID: host, hostIDs: [host], source: .agentRun(UUID()), byteCount: 80_000_000)
}
try await eventually { await scheduler.queuedCount == 1 }
await scheduler.updatePreferences(.init(globalLimit: 1, perHostLimit: 1, strategy: .channelsOnly))
#expect(first.schedulingMode == .connectionsPreferred)
await first.release()
let next = try await queued.value
#expect(next.schedulingMode == .channelsOnly)
await next.release()
await scheduler.updatePreferences(.init(strategy: .smart))
let upgraded = try await scheduler.acquire(
anchorHostID: host, hostIDs: [host], source: .workspace(UUID()), byteCount: 80_000_000)
#expect(upgraded.schedulingMode == .connectionsPreferred)
await upgraded.release()
}
@Test func smartResolvesTheSixtyFourMBBoundaryAndUnknownSizeToSessions() async throws {
let scheduler = SFTPConcurrencyScheduler(
preferences: SFTPPreferences(globalLimit: 3, perHostLimit: 3, strategy: .smart))
@@ -2,12 +2,88 @@ import AppKit
import SwiftUI
import TermindApplication
import TermindDomain
import TermindInfrastructure
import TermindPresentation
import Testing
@testable import Termind
@testable import TermindInfrastructure
@Suite(.serialized) @MainActor
struct SFTPSettingsIntegrationTests {
@Test(arguments: ["en", "zh-Hans", "zh-Hant"])
func freeSettingsRenderOffscreenWithoutMutatingTheSavedChoice(language: String) async throws {
let settings = SFTPSettingsModel()
settings.setStrategy(.connectionsPreferred)
let view = Form {
SFTPSettingsFields(model: settings, access: .init(isAllowed: false, requestAccess: {}))
}
.formStyle(.grouped)
.environment(\.locale, Locale(identifier: language))
.frame(width: 620, height: 340)
let host = NSHostingView(rootView: view)
host.frame = NSRect(x: 0, y: 0, width: 620, height: 340)
host.appearance = NSAppearance(named: .aqua)
host.layoutSubtreeIfNeeded()
try await Task.sleep(for: .milliseconds(100))
let bitmap = try #require(host.bitmapImageRepForCachingDisplay(in: host.bounds))
host.cacheDisplay(in: host.bounds, to: bitmap)
let data = try #require(bitmap.representation(using: .png, properties: [:]))
Attachment.record(data, named: "free-sftp-settings-\(language).png")
#expect(data.count > 2_000)
#expect(settings.preferences.strategy == .connectionsPreferred)
}
@Test(arguments: SFTPConcurrencyStrategy.allCases)
func purchaseRestoresSavedStrategyWithoutOpeningSettings(_ strategy: SFTPConcurrencyStrategy) async {
let service = ConnectionPurchaseService()
let pro = ProAccessController(purchaseService: service, observeUpdates: false)
let settings = SFTPSettingsModel()
settings.setStrategy(strategy)
let app = AppModel(hosts: HostLibraryModel(), sftpSettings: settings, proAccess: pro)
defer { app.shutdownForTermination() }
#expect(app.sftpTransfers.strategy == .channelsOnly)
settings.set(60, for: \.globalLimit)
service.status = .purchased(.purchased)
await pro.refresh()
#expect(app.sftpTransfers.strategy == strategy)
#expect(app.sftpTransfers.globalLimit == 60)
#expect(settings.preferences.strategy == strategy)
#expect(!pro.isPaywallPresented)
}
@Test(arguments: SFTPConcurrencyStrategy.allCases)
func losingAccessChangesRuntimeWithoutRewritingPreferencesOrOpeningPaywall(_ strategy: SFTPConcurrencyStrategy)
async
{
let settings = SFTPSettingsModel()
settings.setStrategy(strategy)
let pro = ProAccessController.testing(status: .purchased(.purchased))
let app = AppModel(hosts: HostLibraryModel(), sftpSettings: settings, proAccess: pro)
defer { app.shutdownForTermination() }
#expect(app.sftpTransfers.strategy == strategy)
await pro.refresh()
#expect(app.sftpTransfers.strategy == .channelsOnly)
#expect(settings.preferences.strategy == strategy)
#expect(!pro.isPaywallPresented)
settings.set(50, for: \.globalLimit)
#expect(app.sftpTransfers.strategy == .channelsOnly)
#expect(app.sftpTransfers.globalLimit == 50)
#expect(settings.preferences.strategy == strategy)
}
@Test func freeStartupAndSyncedPreferencesUseOnlyTheEffectiveStrategy() {
let settings = SFTPSettingsModel()
let pro = ProAccessController.testing(status: .free(trialAvailable: true))
let app = AppModel(hosts: HostLibraryModel(), sftpSettings: settings, proAccess: pro)
defer { app.shutdownForTermination() }
#expect(settings.preferences.strategy == .smart)
#expect(app.sftpTransfers.strategy == .channelsOnly)
settings.applyRemoteSnapshot(.init(strategy: .connectionsPreferred))
#expect(settings.preferences.strategy == .connectionsPreferred)
#expect(app.sftpTransfers.strategy == .channelsOnly)
#expect(!pro.isPaywallPresented)
}
@Test func localAndRemoteChangesReachTheQueueWithoutOpeningSettings() {
let settings = SFTPSettingsModel()
let app = AppModel(hosts: HostLibraryModel(), sftpSettings: settings)
@@ -60,3 +136,10 @@ struct SFTPSettingsIntegrationTests {
}
}
}
@MainActor
private final class ConnectionPurchaseService: ProPurchaseService {
let updates = AsyncStream<EntitlementStatus> { $0.finish() }
var status = EntitlementStatus.free(trialAvailable: false)
func currentStatus() async throws -> EntitlementStatus { status }
}
+70
View File
@@ -9,6 +9,76 @@ import Testing
/// Uses the loopback Paramiko or forwarding-only OpenSSH fixture in Scripts/.
@Suite(.enabled(if: ProcessInfo.processInfo.environment["TERMIND_SSH_FIXTURE"] != nil), .serialized)
nonisolated struct SSHForwardingWireTests {
@Test func poolShrinksWithoutClosingExistingFlowsAndCanExpandAgain() async throws {
let fixture = try fixture()
let pool = SSHForwardingConnectionPool(
maximumConnections: 1, connectionHandler: { _, _, _ in },
failureHandler: { Issue.record(Comment(rawValue: $0)) })
try await pool.connect(request(fixture, jumps: 0), progress: nil)
await pool.configureAdditionalConnections { request(fixture, jumps: 0).enforcingInteractionPolicy() }
let local = try rule(.local, fixture: fixture)
try await pool.start(local, generation: UUID())
defer { Task { await pool.disconnect() } }
#expect(await pool.connectionPoolStatus() == .single)
await pool.updateMaximumConnections(3)
try await waitForPool(pool, connected: 3, target: 3)
var clients: [Int32] = []
defer { for client in clients { Darwin.close(client) } }
for _ in 0..<12 { clients.append(try SSHSocketConnector.open(host: "127.0.0.1", port: local.bindPort)) }
for _ in 0..<1_000 {
if await pool.flowDistribution(for: local.id).reduce(0, +) == clients.count { break }
try await Task.sleep(for: .milliseconds(5))
}
#expect(await pool.flowDistribution(for: local.id).filter { $0 > 0 }.count > 1)
await pool.updateMaximumConnections(1)
#expect(await pool.connectionPoolStatus().target == 1)
// Every established TCP stream must still pass data after the downgrade.
for client in clients {
var sent: UInt8 = 42
#expect(Darwin.write(client, &sent, 1) == 1)
var timeout = timeval(tv_sec: 5, tv_usec: 0)
setsockopt(client, SOL_SOCKET, SO_RCVTIMEO, &timeout, socklen_t(MemoryLayout<timeval>.size))
var received: UInt8 = 0
#expect(Darwin.read(client, &received, 1) == 1)
#expect(received == sent)
}
// A new stream still works on the retained connection while auxiliaries drain.
try await exchange(port: local.bindPort, target: nil, seed: 72, rounds: 2)
let addedWhileDraining = try rule(.local, fixture: fixture)
try await pool.start(addedWhileDraining, generation: UUID())
await pool.updateMaximumConnections(3)
try await waitForPool(pool, connected: 3, target: 3)
var addedClients: [Int32] = []
defer { for client in addedClients { Darwin.close(client) } }
for _ in 0..<12 {
addedClients.append(try SSHSocketConnector.open(host: "127.0.0.1", port: addedWhileDraining.bindPort))
}
for _ in 0..<1_000 {
if await pool.flowDistribution(for: addedWhileDraining.id).filter({ $0 > 0 }).count > 1 { break }
try await Task.sleep(for: .milliseconds(5))
}
#expect(await pool.flowDistribution(for: addedWhileDraining.id).filter { $0 > 0 }.count > 1)
for client in addedClients { Darwin.close(client) }
addedClients.removeAll()
await pool.updateMaximumConnections(1)
for client in clients { Darwin.close(client) }
clients.removeAll()
try await waitForPool(pool, connected: 1, target: 1)
await pool.updateMaximumConnections(3)
try await waitForPool(pool, connected: 3, target: 3)
await pool.updateMaximumConnections(1)
try await waitForPool(pool, connected: 1, target: 1)
}
private func waitForPool(_ pool: SSHForwardingConnectionPool, connected: Int, target: Int) async throws {
let expected = SSHForwardingPoolStatus(connected: connected, target: target)
for _ in 0..<1_000 {
if await pool.connectionPoolStatus() == expected { return }
try await Task.sleep(for: .milliseconds(5))
}
#expect(await pool.connectionPoolStatus() == expected)
}
@Test(arguments: [PortForwardingRule.Kind.local, .dynamic])
func disconnectReleasesThePortBeforeReturningEvenWithEstablishedStreams(_ kind: PortForwardingRule.Kind)
async throws