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:
@@ -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)")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user