Fix Haenger beim Anlegen eines DHCP-Pools (bugs.md #11)

SSHTransport.connect()/run() hatten keinen Timeout - im Gegensatz zu
RestTransport (timeoutInterval=5). Jeder erste Schreibvorgang einer
Session loest ueber ensureSessionBackup() eine dedizierte SSH-
Verbindung aus; haengt die, blieb isApplying unbegrenzt aktiv (kein
Fehler, kein Recovery, nur Force-Quit). Live vom Nutzer bestaetigt
(Experte-Tab, dauerhaft haengend) bevor der Fix geschrieben wurde.

Neuer genererischer SSHTransport.withTimeout(_:operation:) (Task-
Group-Race gegen eine Deadline), angewendet auf connect() (10s) und
run() (30s). 3 neue Regressionstests fuer die Race-Logik isoliert.

Nebenbefund: xcodegen generate ueberschreibt Info.plist komplett aus
project.yml (kein Merge) - ein Regenerieren fuer die neue Testdatei
setzte die Version stillschweigend von 1.1.0 auf 1.0 zurueck. Version
jetzt explizit in project.yml verankert.

Build + alle 106 Unit-Tests gruen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Kay
2026-09-17 21:51:11 +02:00
co-authored by Claude Sonnet 5
parent e9e701a94d
commit cae59db39e
4 changed files with 141 additions and 35 deletions
@@ -16,19 +16,35 @@ final class SSHTransport: RouterOSTransport {
self.hostKeyTrust = hostKeyTrust
}
/// Unlike `RestTransport` (explicit `request.timeoutInterval = 5` on every request), neither
/// this nor `run(_:)` used to bound how long they'd wait at all `Citadel.SSHClient.connect`
/// has no built-in timeout. Live-confirmed as a real, reproducible bug (bugs.md #11,
/// 2026-09-17): creating an `/ip pool` entry in the Experte tab hung the app permanently (no
/// error, no recovery, force-quit needed) traced to this connect call, reached via
/// `ExpertViewModel.saveEditingItem()`'s mandatory `ensureSessionBackup()`, which opens a
/// fresh, dedicated SSH connection (`BackupService`) before every session's first write. A
/// connection attempt that stalls (transient network hiccup, or the hAP-lite test router
/// itself being slow under load MIPS 24Kc/650MHz/1 core, seen at 80% CPU this session) had
/// no way to ever resolve, so `isApplying` never cleared. `withTimeout` below races the real
/// operation against a deadline and cancels whichever loses.
private static let connectTimeout: Duration = .seconds(10)
private static let commandTimeout: Duration = .seconds(30)
func connect() async throws {
do {
client = try await SSHClient.connect(
host: credentials.host,
port: credentials.sshPort,
authenticationMethod: .passwordBased(username: credentials.username, password: credentials.password),
hostKeyValidator: .custom(self),
reconnect: .never,
// RouterOS' SSH server typically only offers legacy algorithms
// (diffie-hellman-group14-sha1 key exchange, RSA host keys) that
// Citadel's defaults don't include `.all` adds them.
algorithms: .all
)
client = try await Self.withTimeout(Self.connectTimeout) {
try await SSHClient.connect(
host: self.credentials.host,
port: self.credentials.sshPort,
authenticationMethod: .passwordBased(username: self.credentials.username, password: self.credentials.password),
hostKeyValidator: .custom(self),
reconnect: .never,
// RouterOS' SSH server typically only offers legacy algorithms
// (diffie-hellman-group14-sha1 key exchange, RSA host keys) that
// Citadel's defaults don't include `.all` adds them.
algorithms: .all
)
}
} catch let error as RouterOSError {
throw error
} catch {
@@ -39,6 +55,21 @@ final class SSHTransport: RouterOSTransport {
}
}
/// Races `operation` against `duration`, cancelling whichever loses the generic mechanism
/// behind both `connect()`'s and `run(_:)`'s timeouts (bugs.md #11). `operation` must be
/// `@Sendable`: it runs inside a detached task group child, not on the caller's isolation.
static func withTimeout<T: Sendable>(_ duration: Duration, operation: @escaping @Sendable () async throws -> T) async throws -> T {
try await withThrowingTaskGroup(of: T.self) { group in
group.addTask { try await operation() }
group.addTask {
try await Task.sleep(for: duration)
throw RouterOSError.transportUnavailable("Zeitüberschreitung (\(Int(duration.components.seconds))s) — Router antwortet nicht.")
}
defer { group.cancelAll() }
return try await group.next()!
}
}
func fetchDeviceInfo() async throws -> RouterDeviceInfo {
let output = try await run("/system resource print without-paging")
return RouterOSCliParser.parseDeviceInfo(output)
@@ -345,25 +376,33 @@ final class SSHTransport: RouterOSTransport {
/// `executeCommand` discards whatever output it already collected the moment the command
/// exits non-zero exactly the RouterOS error text we need. Collecting the stream ourselves
/// keeps that text available even when the command fails.
/// Wrapped in `withTimeout` for the same reason as `connect()` (bugs.md #11) a command that
/// never returns (router hangs mid-execution, connection drops without a clean error) used to
/// block forever with no recovery. The `CommandFailed` handling stays inside the timed
/// closure so `output` (partial text collected before the failure) is still in scope to build
/// the error detail the already-established domain error (`RouterOSError.invalidResponse`)
/// is what actually crosses the timeout race, not the raw Citadel type.
private func run(_ command: String) async throws -> String {
guard let client else { throw RouterOSError.notConnected }
var output = ""
do {
let stream = try await client.executeCommandStream(command)
for try await chunk in stream {
switch chunk {
case .stdout(let buffer), .stderr(let buffer):
output += String(buffer: buffer)
return try await Self.withTimeout(Self.commandTimeout) {
var output = ""
do {
let stream = try await client.executeCommandStream(command)
for try await chunk in stream {
switch chunk {
case .stdout(let buffer), .stderr(let buffer):
output += String(buffer: buffer)
}
}
return output
} catch let failure as SSHClient.CommandFailed {
let detail = output.trimmingCharacters(in: .whitespacesAndNewlines)
throw RouterOSError.invalidResponse(
"RouterOS meldete Fehler (Exit-Code \(failure.exitCode)) für \"\(command)\""
+ (detail.isEmpty ? "" : ": \(detail)")
)
}
return output
} catch let failure as SSHClient.CommandFailed {
let detail = output.trimmingCharacters(in: .whitespacesAndNewlines)
throw RouterOSError.invalidResponse(
"RouterOS meldete Fehler (Exit-Code \(failure.exitCode)) für \"\(command)\""
+ (detail.isEmpty ? "" : ": \(detail)")
)
}
}
}