Fix 4 Bugs aus systematischem Tester-Durchgang (bugs.md)

Race Condition bei LAN-Port-Konflikt-Prüfung (Generation-Zähler), Firewall-Titel
widersprach sich im Einfach-Modus, 2 fehlende EN-Übersetzungen, Health-Check-
Herzschlag ignorierte laufende Wizard-/Experte-Schreibvorgänge. Außerdem M7/M9
im README/HANDOFF auf fertig aktualisiert (Modusschalter+Experte-Zweig liefen
bereits über die Multi-LAN/WLAN-Live-Tests).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Kay
2026-09-17 17:53:58 +02:00
co-authored by Claude Sonnet 5
parent 123e46973a
commit 3b84d31ec6
9 changed files with 197 additions and 15 deletions
@@ -15,6 +15,9 @@ enum L10n {
}
private static let translations: [String: String] = [
"Netzwerk-Test fehlgeschlagen": "Network test failed",
"Fokus-Ansicht schließen": "Close focus view",
"Firewall": "Firewall",
"Verbinden": "Connect",
"Verbindung unterbrochen — versuche automatisch, erneut zu verbinden…":
"Connection lost — trying to reconnect automatically…",
@@ -42,6 +42,22 @@ final class ConnectionService: ObservableObject {
private var healthMonitorTask: Task<Void, Never>?
private static let healthCheckInterval: Duration = .seconds(10)
private static let reconnectRetryInterval: Duration = .seconds(5)
/// How many callers currently consider themselves "mid-write" (Setup-Wizard apply, Experte-Tab
/// save/remove) while non-zero, the health-check heartbeat below skips its round so a slow
/// but otherwise-succeeding apply on weak hardware doesn't get mistaken for a dropped
/// connection and trigger a spurious "Wiederverbinden" banner (bugs.md #4, 2026-09-17).
private var activeWriteCount = 0
/// Call before a multi-step write (Setup-Wizard apply, Experte-Tab save/remove) starts, paired
/// with `endWrite()` once it finishes (success or failure) see `activeWriteCount`'s doc
/// comment.
func beginWrite() {
activeWriteCount += 1
}
func endWrite() {
activeWriteCount = max(0, activeWriteCount - 1)
}
private let certificateTrust: CertificateTrustStore
private let sshHostKeyTrust: SSHHostKeyTrustStore
@@ -271,7 +287,7 @@ final class ConnectionService: ObservableObject {
}
private func checkConnectionHealthAndReconnectIfNeeded() async {
guard case .connected = state, !isReconnecting,
guard case .connected = state, !isReconnecting, activeWriteCount == 0,
let activeTransport, let credentials else { return }
do {
_ = try await activeTransport.fetchMenuItems(menuPath: "/system identity", restPath: "system/identity")
@@ -217,6 +217,8 @@ final class ExpertViewModel: ObservableObject {
guard let command = pendingCommand else { return }
isApplying = true
applyError = nil
connectionService.beginWrite()
defer { connectionService.endWrite() }
do {
try await ensureSessionBackup()
if selectedSchema?.writesRequireSSH == true {
@@ -239,6 +241,8 @@ final class ExpertViewModel: ObservableObject {
guard let schema = selectedSchema else { return }
isApplying = true
applyError = nil
connectionService.beginWrite()
defer { connectionService.endWrite() }
do {
let command = RouterOSCommand.remove(
menuPath: schema.menuPath, restPath: schema.restPath,
@@ -60,7 +60,7 @@ struct FirewallStepView: View {
}
}
.formStyle(.grouped)
.navigationTitle(LocalizedStringKey(L10n.t("Firewall (optional)", appLanguage)))
.navigationTitle(LocalizedStringKey(L10n.t(viewModel.mode == .expert ? "Firewall (optional)" : "Firewall", appLanguage)))
.toolbar { ToolbarItem { ManualHelpButton(anchor: ManualAnchor.stepFirewall) } }
.onAppear {
if viewModel.firewallSectionEnabled {
@@ -134,8 +134,15 @@ final class SetupViewModel: ObservableObject {
lanPortConflicts[id] = nil
acknowledgedPortConflicts.remove(id)
isCheckingPortConflict.remove(id)
portConflictRequestGeneration[id] = nil
}
/// Bumped on every `checkPortConflict(for:)` call for a given config, so a slower, older
/// in-flight check can recognize it's been superseded and discard its own result instead of
/// racing the newer one see that function's doc comment for the concrete failure this
/// prevents (bugs.md #1, 2026-09-17).
private var portConflictRequestGeneration: [LanDhcpConfig.ID: Int] = [:]
/// Live-checks whether the port currently picked for this LAN config already carries other
/// configuration (bridge membership, an existing address, WAN dial-up) called when the LAN
/// step appears and whenever its interface Picker selection changes. Any prior acknowledgement
@@ -143,14 +150,24 @@ final class SetupViewModel: ObservableObject {
/// port says nothing about the newly picked one. Best-effort a failed check (e.g. transient
/// connection hiccup) must not block the wizard; the user simply doesn't get the extra warning
/// for that attempt, same as before this feature existed.
///
/// Two call sites (`.onAppear` and `.onChange(of: interfaceName)`) can fire in quick
/// succession for the same `configID` while a previous check is still in flight without the
/// generation guard below, a slower-but-older response could land after a faster-but-newer one
/// and overwrite it with a stale port's result (bugs.md #1).
func checkPortConflict(for configID: LanDhcpConfig.ID) {
guard let config = lanConfigs.first(where: { $0.id == configID }) else { return }
let interfaceName = config.interfaceName
acknowledgedPortConflicts.remove(configID)
lanPortConflicts[configID] = nil
isCheckingPortConflict.insert(configID)
let generation = (portConflictRequestGeneration[configID] ?? 0) + 1
portConflictRequestGeneration[configID] = generation
Task {
defer { isCheckingPortConflict.remove(configID) }
lanPortConflicts[configID] = try? await connectionService.checkPortConflict(interfaceName: config.interfaceName)
let result = try? await connectionService.checkPortConflict(interfaceName: interfaceName)
guard portConflictRequestGeneration[configID] == generation else { return }
lanPortConflicts[configID] = result
isCheckingPortConflict.remove(configID)
}
}
@@ -282,6 +299,7 @@ final class SetupViewModel: ObservableObject {
lanPortConflicts = [:]
isCheckingPortConflict = []
acknowledgedPortConflicts = []
portConflictRequestGeneration = [:]
applyLog = []
applyError = nil
didApplySuccessfully = false
@@ -316,6 +334,8 @@ final class SetupViewModel: ObservableObject {
didApplySuccessfully = false
Task {
connectionService.beginWrite()
defer { connectionService.endWrite() }
do {
applyLog.append("Sichere aktuelle Konfiguration…")
_ = try await backupService.createBackup(for: credentials)