From 9ebb37d8483f1b6c61166e70897a49f7a81e3a36 Mon Sep 17 00:00:00 2001 From: Marcin Beczynski Date: Wed, 23 Sep 2026 12:58:28 +0200 Subject: [PATCH 1/3] Say "I don't know" when the upload queue cannot be read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The queue read goes through `rclone rc vfs/stats`. When it timed out the caller got nil, substituted zeros for every counter, and `UploadState.from` concluded `.upToDate` — so both `drive-status` and the app card announced "Wszystko wysłane na Google Drive" while 386 bands were still waiting. It happened twice on 23 September, and `drive-status` printed the contradiction in plain sight: "Kolejka wysylki: (interfejs rc nieosiagalny)" two lines above the all-clear. Missing numbers are not zeros. `UploadState` gains `.queueUnknown`, which is not nominal, so the menu bar cannot go green on it; `from(...)` now takes a `queueKnown:` argument with no default, which forces every call site to answer the question it used to skip. `BufferStatus` defaults it to false, so an unpopulated status reads as unmeasured rather than empty. The three-state verdict no longer fits two booleans, so the badge moves onto `UploadState` itself, and the views stop claiming "Brak zaległości" from counters nobody read. Also raises the read timeout from 30 s to 60 s: the same call was measured at 36.7 s under a saturated buffer, and at 0.03 s a second later. That alone would not have fixed anything — a slower answer is still no answer — but it makes the question worth asking. Verified on a live bad sample: with the mount up and rclone refusing to answer, the new binary prints "Nie wiadomo, co czeka w kolejce". Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 8 +++ .../CloudMachineAgent/DriveCommands.swift | 1 + .../CloudMachineApp/Models/AppStatus.swift | 8 +++ .../Services/CloudMachineController.swift | 3 + .../CloudMachineApp/Views/DashboardView.swift | 41 ++++++++------ .../Views/MenuBarContentView.swift | 8 ++- .../CloudMachineCore/DriveBufferService.swift | 15 ++++- .../CloudMachineCore/UploadState.swift | 52 +++++++++++++++++- .../AppStatusHealthTests.swift | 14 +++++ .../UploadStateTests.swift | 55 +++++++++++++++++-- 10 files changed, 178 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 4fe6aae..11cc7b4 100644 --- a/README.md +++ b/README.md @@ -161,6 +161,14 @@ The `Wysylka:` line is the same verdict the app window shows, computed in one place so the two can never disagree. When it is not nominal it prints a second line saying why, and whether it clears on its own. +It has three kinds of answer, not two. Besides "fine" and "broken" there is +**"unknown"** — printed when rclone does not answer the question about its +queue. That third state exists because of a specific lie: the queue read used +to time out, the caller substituted zeros for the missing numbers, and both the +CLI and the app then announced *Wszystko wysłane na Google Drive* while 386 +bands sat unsent. A verdict computed from numbers nobody measured is worse than +no verdict, so now it says so. + Five launchd agents keep it alive, all running code from inside the app: | Agent | Job | diff --git a/mac-app/Sources/CloudMachineAgent/DriveCommands.swift b/mac-app/Sources/CloudMachineAgent/DriveCommands.swift index 4fb8438..60e8885 100644 --- a/mac-app/Sources/CloudMachineAgent/DriveCommands.swift +++ b/mac-app/Sources/CloudMachineAgent/DriveCommands.swift @@ -297,6 +297,7 @@ struct DriveStatus: AsyncParsableCommand { // i GUI nie mogly twierdzic czegos innego o tym samym stanie. let upload = UploadState.from( mounted: DriveBufferService.isMounted, + queueKnown: queueStats != nil, queued: queueStats?.uploadsQueued ?? 0, inProgress: queueStats?.uploadsInProgress ?? 0, failedFiles: queueStats?.erroredFiles ?? 0, diff --git a/mac-app/Sources/CloudMachineApp/Models/AppStatus.swift b/mac-app/Sources/CloudMachineApp/Models/AppStatus.swift index 24b6ca6..21ff035 100644 --- a/mac-app/Sources/CloudMachineApp/Models/AppStatus.swift +++ b/mac-app/Sources/CloudMachineApp/Models/AppStatus.swift @@ -28,6 +28,13 @@ struct BufferStatus: Equatable { /// nadaza za zapisem. var uploadsQueued: Int = 0 var uploadsInProgress: Int = 0 + /// Czy powyzsze liczniki w ogole pochodza z odczytu. + /// + /// Domyslnie `false` i to jest wazniejsze niz wyglada: swiezo utworzony + /// `BufferStatus` ma same zera, ktore nie sa pomiarem. Domyslne `true` + /// znaczyloby "pusta kolejka" i pasek menu swiecilby na zielono, zanim + /// cokolwiek zostalo sprawdzone. + var queueKnown: Bool = false var erroredFiles: Int = 0 /// Na Google Drive nie ma miejsca. NIE minie samo. var driveFull: Bool = false @@ -46,6 +53,7 @@ struct BufferStatus: Equatable { var uploadState: UploadState { UploadState.from( mounted: mounted, + queueKnown: queueKnown, queued: uploadsQueued, inProgress: uploadsInProgress, failedFiles: erroredFiles, diff --git a/mac-app/Sources/CloudMachineApp/Services/CloudMachineController.swift b/mac-app/Sources/CloudMachineApp/Services/CloudMachineController.swift index 5199619..36a5164 100644 --- a/mac-app/Sources/CloudMachineApp/Services/CloudMachineController.swift +++ b/mac-app/Sources/CloudMachineApp/Services/CloudMachineController.swift @@ -94,6 +94,9 @@ final class CloudMachineController: ObservableObject { // naprawic, limit dobowy mija sam. buffer.driveFull = DriveBufferService.hitStorageQuota() buffer.dailyQuotaExhausted = DriveBufferService.uploadStalled() + // Rozroznienie "odczytano" od "wyszlo zero" - bez tego brak odpowiedzi od + // rclone wygladal na pusta kolejke. + buffer.queueKnown = stats != nil if let stats { buffer.uploadsQueued = stats.uploadsQueued buffer.uploadsInProgress = stats.uploadsInProgress diff --git a/mac-app/Sources/CloudMachineApp/Views/DashboardView.swift b/mac-app/Sources/CloudMachineApp/Views/DashboardView.swift index 3c54f0d..84684f8 100644 --- a/mac-app/Sources/CloudMachineApp/Views/DashboardView.swift +++ b/mac-app/Sources/CloudMachineApp/Views/DashboardView.swift @@ -207,14 +207,22 @@ struct DashboardView: View { StatCard( title: "Kolejka Wysyłki", - value: controller.status.buffer.draining - ? "\(controller.status.buffer.uploadsQueued) w kolejce" : "Brak zaległości", - subtitle: controller.status.buffer.draining - ? "\(controller.status.buffer.uploadsInProgress) transferów w toku" - : "Wszystko w chmurze", + // Bez odczytu kolejki ta karta nie ma prawa powiedziec "Brak + // zaleglosci" - zera sa wtedy brakiem pomiaru, nie wynikiem. + value: !controller.status.buffer.queueKnown + ? "—" + : (controller.status.buffer.draining + ? "\(controller.status.buffer.uploadsQueued) w kolejce" : "Brak zaległości"), + subtitle: !controller.status.buffer.queueKnown + ? "rclone nie odpowiedział" + : (controller.status.buffer.draining + ? "\(controller.status.buffer.uploadsInProgress) transferów w toku" + : "Wszystko w chmurze"), systemImage: "icloud.and.arrow.up.fill", - iconColor: controller.status.buffer.draining - ? RenaCodeTheme.colorWarning : RenaCodeTheme.colorSuccess + iconColor: !controller.status.buffer.queueKnown + ? RenaCodeTheme.colorWarning + : (controller.status.buffer.draining + ? RenaCodeTheme.colorWarning : RenaCodeTheme.colorSuccess) ) StatCard( @@ -447,10 +455,12 @@ struct DashboardView: View { row( "Kolejka synchronizacji z chmurą", - controller.status.buffer.draining - ? "\(controller.status.buffer.uploadsInProgress) w toku, \(controller.status.buffer.uploadsQueued) w kolejce" - : "Wszystko wysłane", - ok: controller.status.buffer.erroredFiles == 0 + !controller.status.buffer.queueKnown + ? "nie odczytano" + : (controller.status.buffer.draining + ? "\(controller.status.buffer.uploadsInProgress) w toku, \(controller.status.buffer.uploadsQueued) w kolejce" + : "Wszystko wysłane"), + ok: controller.status.buffer.queueKnown && controller.status.buffer.erroredFiles == 0 ) if controller.status.buffer.erroredFiles > 0 { @@ -693,11 +703,9 @@ struct DashboardView: View { return RenaCodeTheme.colorSuccess } - private func uploadBadge(_ state: UploadState) -> String { - if state.needsAttention { return "WYMAGA REAKCJI" } - if !state.isNominal { return "MINIE SAMO — NIC NIE RÓB" } - return "W PORZĄDKU" - } + /// Etykieta idzie prosto z `UploadState`. Skladanie jej tutaj z dwoch bool-i + /// ograniczalo interfejs do trzech wariantow, a stanow jest wiecej. + private func uploadBadge(_ state: UploadState) -> String { state.badge } private func uploadIcon(_ state: UploadState) -> String { switch state { @@ -708,6 +716,7 @@ struct DashboardView: View { case .dailyQuotaExhausted: return "hourglass" case .flowing: return "arrow.up.circle.fill" case .upToDate: return "checkmark.icloud.fill" + case .queueUnknown: return "questionmark.circle.fill" } } diff --git a/mac-app/Sources/CloudMachineApp/Views/MenuBarContentView.swift b/mac-app/Sources/CloudMachineApp/Views/MenuBarContentView.swift index af743fc..5fd5411 100644 --- a/mac-app/Sources/CloudMachineApp/Views/MenuBarContentView.swift +++ b/mac-app/Sources/CloudMachineApp/Views/MenuBarContentView.swift @@ -100,12 +100,14 @@ struct MenuBarContentView: View { .foregroundStyle(RenaCodeTheme.textMuted) Spacer() Text( - controller.status.buffer.draining - ? "\(controller.status.buffer.uploadsQueued) plików" : "nic" + !controller.status.buffer.queueKnown + ? "?" + : (controller.status.buffer.draining + ? "\(controller.status.buffer.uploadsQueued) plików" : "nic") ) .font(.system(size: 12, weight: .semibold, design: .monospaced)) .foregroundStyle( - controller.status.buffer.draining + !controller.status.buffer.queueKnown || controller.status.buffer.draining ? RenaCodeTheme.colorWarning : RenaCodeTheme.textMain) } diff --git a/mac-app/Sources/CloudMachineCore/DriveBufferService.swift b/mac-app/Sources/CloudMachineCore/DriveBufferService.swift index 5d5ecc1..9880ca2 100644 --- a/mac-app/Sources/CloudMachineCore/DriveBufferService.swift +++ b/mac-app/Sources/CloudMachineCore/DriveBufferService.swift @@ -167,10 +167,23 @@ public enum DriveBufferService { /// UWAGA: `--rc-no-auth` to flaga SERWERA. Klient `rclone rc` jej nie /// przyjmuje i konczy sie bledem "unknown flag" - kosztowalo to juz jedno /// ciche zepsucie podgladu stanu. + /// + /// Limit czasu 60 s, a nie 30 s: 23.09.2026 to samo wywolanie trwalo + /// **36,7 s** przy zapchanym buforze (kolejne 0,03 s - wiec sporadycznie, pod + /// obciazeniem). Przy 30 s konczylo sie `nil`, a `nil` szedl dalej jako + /// komplet zer i interfejs oglaszal "Wszystko wyslane" przy 386 pasmach w + /// kolejce. Samo podniesienie limitu tego nie naprawia - od tego jest + /// `UploadState.queueUnknown` - ale sprawia, ze pytanie zwykle dostaje + /// odpowiedz. + /// + /// Wyzej nie warto. Petla odswiezania interfejsu chodzi co 10 s i czeka na + /// ten odczyt, a `drive-status` pyta dwa razy (drugi raz przez + /// `safeToRebootNow`). Przy martwym rclone kazda sekunda limitu to sekunda + /// zamrozonego okna, a odpowiedz i tak nie przyjdzie. public static func queueStats() async -> QueueStats? { guard let result = try? await CMTooling.runRclone( - ["rc", "--url", rcAddress, "vfs/stats"], timeout: 30), + ["rc", "--url", rcAddress, "vfs/stats"], timeout: 60), result.succeeded, let data = result.stdout.data(using: .utf8), let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] diff --git a/mac-app/Sources/CloudMachineCore/UploadState.swift b/mac-app/Sources/CloudMachineCore/UploadState.swift index 580f125..7326d68 100644 --- a/mac-app/Sources/CloudMachineCore/UploadState.swift +++ b/mac-app/Sources/CloudMachineCore/UploadState.swift @@ -27,13 +27,24 @@ public enum UploadState: Equatable, Sendable { case flowing(queued: Int) /// Nic nie czeka - wszystko jest na Dysku. case upToDate + /// Nie udalo sie odczytac kolejki - stan wysylki jest NIEZNANY. + /// + /// Trzeci stan obok "dobrze" i "zle", i musi istniec osobno. Wczesniej brak + /// odpowiedzi od `rclone rc` konczyl sie podstawieniem zer, z czego wychodzil + /// `.upToDate`: przy 386 pasmach w kolejce interfejs pisal "Wszystko wyslane + /// na Google Drive". Falszywy spokoj jest gorszy od braku odpowiedzi, bo + /// gasi czujke dokladnie wtedy, gdy nikt nie wie, co sie dzieje. + case queueUnknown /// Czy stan wymaga reakcji czlowieka. `false` znaczy "samo sie ulozy", /// a nie "wszystko dobrze" - patrz `dailyQuotaExhausted`. public var needsAttention: Bool { switch self { case .mountDown, .driveFull, .failedFiles, .bufferFull: return true - case .dailyQuotaExhausted, .flowing, .upToDate: return false + // Nieznany stan NIE wola o czlowieka: pojedyncze przekroczenie limitu + // czasu zdarza sie przy obciazonym rclone i mija samo. Gdy nie mija, + // alarmuje `backup-health` - od trwalosci jest on, nie kolor karty. + case .dailyQuotaExhausted, .flowing, .upToDate, .queueUnknown: return false } } @@ -47,7 +58,8 @@ public enum UploadState: Equatable, Sendable { public var isNominal: Bool { switch self { case .flowing, .upToDate: return true - case .mountDown, .driveFull, .failedFiles, .bufferFull, .dailyQuotaExhausted: return false + case .mountDown, .driveFull, .failedFiles, .bufferFull, .dailyQuotaExhausted, .queueUnknown: + return false } } @@ -57,6 +69,22 @@ public enum UploadState: Equatable, Sendable { return false } + /// Etykieta nad naglowkiem karty: co uzytkownik ma z tym zrobic. + /// + /// Wczesniej interfejs skladal ja z dwoch bool-i (`needsAttention`, + /// `isNominal`), wiec umial wyrazic tylko trzy warianty i kazdy nowy stan + /// musial sie do ktoregos wcisnac. `queueUnknown` nie pasuje do zadnego: + /// nie jest awaria, nie jest porzadkiem i nie jest tez "minie samo", bo nikt + /// nie wie, czy jest co przeczekiwac. + public var badge: String { + switch self { + case .mountDown, .driveFull, .failedFiles, .bufferFull: return "WYMAGA REAKCJI" + case .dailyQuotaExhausted: return "MINIE SAMO — NIC NIE RÓB" + case .queueUnknown: return "NIE WIADOMO — SPRAWDŹ ZA CHWILĘ" + case .flowing, .upToDate: return "W PORZĄDKU" + } + } + /// Jedno zdanie do paska i naglowka karty. public var headline: String { switch self { @@ -67,6 +95,7 @@ public enum UploadState: Equatable, Sendable { case .dailyQuotaExhausted: return "Wysyłka wstrzymana — dobowy limit Google" case .flowing(let queued): return "Wysyłanie na Google Drive — \(queued) w kolejce" case .upToDate: return "Wszystko wysłane na Google Drive" + case .queueUnknown: return "Nie wiadomo, co czeka w kolejce" } } @@ -108,6 +137,13 @@ public enum UploadState: Equatable, Sendable { return "\(queued) fragmentów kopii czeka w kolejce i leci na Dysk." case .upToDate: return "Nic nie czeka w kolejce — kopia na Google Drive jest kompletna." + case .queueUnknown: + return """ + rclone nie odpowiedział na pytanie o kolejkę, więc nie wiadomo, ile kopii \ + czeka jeszcze na wysłanie. To nie znaczy, że coś się zepsuło — pod obciążeniem \ + odpowiedź potrafi się spóźnić. Znaczy tylko tyle, że w tej chwili nikt tego \ + nie wie. Jeśli utrzymuje się dłużej, zgłosi to kontrola cyklu backupu. + """ } } @@ -116,8 +152,15 @@ public enum UploadState: Equatable, Sendable { /// Kolejnosc NIE jest dowolna - od najtwardszego faktu do najmiekszego. /// `failedFiles` wyprzedza limit dobowy, bo "rclone odpuscil" znaczy, ze /// kopia jest niekompletna TERAZ, a limit znaczy tylko, ze poczeka. + /// `queueKnown` NIE ma wartosci domyslnej i to jest celowe. Wszystkie + /// liczniki ponizej pochodza z `vfs/stats`; gdy rclone nie odpowie, wolajacy + /// ma pod reka same zera i zadne z nich nie znaczy "zero". Wymuszony + /// argument zmusza kazde miejsce w kodzie do odpowiedzi na pytanie, ktore + /// wczesniej przemilczano - stad brala sie plansza "Wszystko wyslane" przy + /// pelnej kolejce. public static func from( mounted: Bool, + queueKnown: Bool, queued: Int, inProgress: Int, failedFiles: Int, @@ -127,6 +170,11 @@ public enum UploadState: Equatable, Sendable { ) -> UploadState { if !mounted { return .mountDown } if driveFull { return .driveFull } + // Przed kazdym stanem liczonym z licznikow, bo bez odczytu kolejki nie da + // sie odroznic "nic nie czeka" od "nie wiem, co czeka". Limit dobowy tez + // tu przepada, i slusznie: skoro nie wiadomo, czy rclone czegos nie + // porzucil, to "poczekaj, minie samo" nie jest uczciwa odpowiedzia. + if !queueKnown { return .queueUnknown } if failedFiles > 0 { return .failedFiles(failedFiles) } if bufferOutOfSpace { return .bufferFull } if dailyQuotaExhausted { return .dailyQuotaExhausted } diff --git a/mac-app/Tests/CloudMachineAppTests/AppStatusHealthTests.swift b/mac-app/Tests/CloudMachineAppTests/AppStatusHealthTests.swift index 66da537..8f7eea8 100644 --- a/mac-app/Tests/CloudMachineAppTests/AppStatusHealthTests.swift +++ b/mac-app/Tests/CloudMachineAppTests/AppStatusHealthTests.swift @@ -21,10 +21,24 @@ final class AppStatusHealthTests: XCTestCase { var buffer = BufferStatus() buffer.mounted = true buffer.imageAttached = true + // Musi byc jawne: `BufferStatus` zaczyna od "kolejki nie odczytano", zeby + // swiezy, niesprawdzony stan nie uchodzil za pusta kolejke. + buffer.queueKnown = true status.buffer = buffer return status } + /// REGRESJA 23.09.2026: `rclone rc` nie odpowiedzial w limicie czasu, + /// wolajacy podstawil zera i pasek menu pokazal "Gotowe" przy 386 pasmach + /// czekajacych w kolejce. + func testNieodczytanaKolejkaOdbieraZielonyZnaczek() { + let status = zdrowy() + status.buffer.queueKnown = false + XCTAssertFalse(status.healthy, "Nie wiadomo = nie zielono.") + XCTAssertNotEqual(status.headline, "Gotowe") + XCTAssertEqual(status.buffer.uploadState, .queueUnknown) + } + func testZdrowyStanJestZdrowy() { let status = zdrowy() XCTAssertTrue(status.healthy) diff --git a/mac-app/Tests/CloudMachineAppTests/UploadStateTests.swift b/mac-app/Tests/CloudMachineAppTests/UploadStateTests.swift index 1f740b8..68d9bc7 100644 --- a/mac-app/Tests/CloudMachineAppTests/UploadStateTests.swift +++ b/mac-app/Tests/CloudMachineAppTests/UploadStateTests.swift @@ -10,12 +10,13 @@ import XCTest final class UploadStateTests: XCTestCase { private func state( - mounted: Bool = true, queued: Int = 0, inProgress: Int = 0, failedFiles: Int = 0, - bufferOutOfSpace: Bool = false, driveFull: Bool = false, dailyQuotaExhausted: Bool = false + mounted: Bool = true, queueKnown: Bool = true, queued: Int = 0, inProgress: Int = 0, + failedFiles: Int = 0, bufferOutOfSpace: Bool = false, driveFull: Bool = false, + dailyQuotaExhausted: Bool = false ) -> UploadState { UploadState.from( - mounted: mounted, queued: queued, inProgress: inProgress, failedFiles: failedFiles, - bufferOutOfSpace: bufferOutOfSpace, driveFull: driveFull, + mounted: mounted, queueKnown: queueKnown, queued: queued, inProgress: inProgress, + failedFiles: failedFiles, bufferOutOfSpace: bufferOutOfSpace, driveFull: driveFull, dailyQuotaExhausted: dailyQuotaExhausted) } @@ -65,12 +66,56 @@ final class UploadStateTests: XCTestCase { XCTAssertFalse(s.isMovingData) } + /// REGRESJA 23.09.2026. `rclone rc vfs/stats` przekroczyl limit czasu, wiec + /// `queueStats()` oddal `nil`, a wolajacy podstawil zera - i przy 386 pasmach + /// w kolejce `drive-status` oraz karta w interfejsie oglosily "Wszystko + /// wyslane na Google Drive". Brak odpowiedzi ma wygladac jak brak odpowiedzi. + func testBrakOdczytuKolejkiNieUdajePustejKolejki() { + let s = state(queueKnown: false) + XCTAssertEqual(s, .queueUnknown) + XCTAssertNotEqual(s, .upToDate) + XCTAssertFalse(s.isNominal, "nieznany stan nie ma prawa swiecic na zielono") + XCTAssertFalse(s.isMovingData) + XCTAssertFalse(s.needsAttention, "od trwalosci problemu jest backup-health, nie kolor karty") + XCTAssertFalse( + s.headline.contains("Wszystko wysłane"), "to zdanie wlasnie bylo klamstwem") + } + + /// Twarde fakty, ktore nie pochodza z kolejki, wyprzedzaja niewiedze o niej: + /// brak montowania i brak miejsca na Dysku wiadomo bez `vfs/stats`. + func testFaktySpozaKolejkiWyprzedzajaNiewiedze() { + XCTAssertEqual(state(mounted: false, queueKnown: false), .mountDown) + XCTAssertEqual(state(queueKnown: false, driveFull: true), .driveFull) + } + + /// Limit dobowy przepada za to celowo: skoro nie wiadomo, czy rclone czegos + /// nie porzucil, "poczekaj, minie samo" nie jest uczciwa odpowiedzia. + func testLimitDobowyNiePrzykrywaNiewiedzyOKolejce() { + XCTAssertEqual(state(queueKnown: false, dailyQuotaExhausted: true), .queueUnknown) + } + + func testKazdyStanMaEtykieteDlaCzlowieka() { + let wszystkie: [UploadState] = [ + .mountDown, .driveFull, .failedFiles(2), .bufferFull, .dailyQuotaExhausted, + .flowing(queued: 5), .upToDate, .queueUnknown, + ] + for s in wszystkie { + XCTAssertFalse(s.badge.isEmpty, "brak etykiety dla \(s)") + } + XCTAssertNotEqual( + UploadState.queueUnknown.badge, UploadState.upToDate.badge, + "niewiedza i porzadek nie moga wygladac tak samo") + XCTAssertNotEqual( + UploadState.queueUnknown.badge, UploadState.dailyQuotaExhausted.badge, + "niewiedza to nie jest 'minie samo'") + } + /// Kazdy stan musi umiec sie wytlumaczyc. Pusty tekst na karcie to dokladnie /// ten rodzaj cichej awarii, ktory ten projekt juz raz mial. func testKazdyStanMaTrescDlaCzlowieka() { let wszystkie: [UploadState] = [ .mountDown, .driveFull, .failedFiles(2), .bufferFull, .dailyQuotaExhausted, - .flowing(queued: 5), .upToDate, + .flowing(queued: 5), .upToDate, .queueUnknown, ] for s in wszystkie { XCTAssertFalse(s.headline.isEmpty, "brak naglowka dla \(s)") From e13e1cfc0b3425652758d46e5734f5b9ba87660a Mon Sep 17 00:00:00 2001 From: Marcin Beczynski Date: Wed, 23 Sep 2026 16:51:03 +0200 Subject: [PATCH 2/3] fix(obraz): blokada zaczyna istniec, a "nie wiem" przestaje znaczyc "wyslane" `CMLock` bylo MARTWYM KODEM - 241 linii mechanizmu blokad, ktorego nie wolalo ani jedno miejsce w repozytorium, takze zaden test. `attach()`, `detach()`, `verify()` i `create()` nie mialy wiec zadnego wzajemnego wykluczenia, a `detach()` ma okno ~10,5 min (sleep 35 s + drenaz do 600 s), w ktore tik `gdrive-attach` (StartInterval 900) trafia z duzym prawdopodobienstwem: widzi `.detached`, podpina obraz z powrotem, Time Machine rusza z zapisem, a `prepare-shutdown` konczy drenaz i wypisuje "Mozna restartowac". Teraz te cztery operacje ida przez `withCMLock`, a nieudane zdobycie blokady zwraca `busyResult`, nie milczacy sukces. `acquire()` weryfikuje tez zapis PID-a na sciezce "katalog utworzony" - dotad robila to tylko sciezka przejecia, wiec proces przerwany miedzy `createDirectory` a `writePid()` byl uznawany za osierocony i blokada trafiala do DWOCH wlascicieli naraz. "Nie wiem" czytane jako dobra odpowiedz, cztery razy: - `isQuiet` ignorowalo `erroredFiles`. Pasma porzucone przez rclone wypadaja z kolejki, wiec `detach()` meldowal "wszystko wyslane na Google Drive", a `safeToRebootNow()` mowil TAK - przy danych istniejacych tylko lokalnie. Czekanie chodzi teraz po `isIdle`, werdykt po `isQuiet`: gdyby czekac na `isQuiet`, kazde podpiecie placiloby pelne 600 s, bo porzucone pasma zostaja w liczniku do konca zycia procesu rclone. - `vfs/stats` bez sekcji `diskCache` dawalo komplet ZER zamiast `nil`, bo fallback `?? json` plus `number()` zwracajace 0 dla brakujacego klucza przechodzily walidacje. `queueKnown` bylo wtedy prawda i znow padalo "Wszystko wyslane". To ten sam wzorzec, ktory naprawiono wyzej przez `queueUnknown`, tylko przesuniety do parsowania - ktore siedzialo w funkcji async i nie dalo sie przetestowac. Wydzielone i pokryte testem. - `ImageProbe` polykalo bledy LISTOWANIA (`try? regularFiles()`), wiec ENXIO na samym katalogu dawalo `.nothingToProbe`, a `attachment` mapowalo to na `.attached`. Martwy obraz uchodzil za zywy - awaria z 22.09 wejsciem, ktorego tamta poprawka nie objela. - `mountPoints()` i `mountTable()` szly przez `/sbin/mount` BEZ limitu czasu, czyli pytaly o wolumen, ktory bywa martwy, tym sposobem, ktory na nim wisi. Zamiast ograniczac ten tryb awarii - usuniety: `getmntinfo(MNT_NOWAIT)` czyta tablice z pamieci jadra, bez procesu i bez dotykania systemu plikow. `MNT_WAIT` wolalby `VFS_STATFS` na kazdym montowaniu i wieszalby sie tak samo. `create()` sprawdzalo istnienie obrazu przez `fileExists` na cache FUSE i - w przeciwienstwie do `attach-image` - nie czekalo na `BufferReadiness`. W oknie, gdy rclone wystawil montowanie przed wczytaniem katalogu, straznik "obraz juz istnieje" przepuszczal, a `hdiutil create` szedl na sciezke istniejacego backupu, z piecioma ponowieniami. Teraz nieobecnosc potwierdza `rclone lsf` na zdalnym, a BRAK ODPOWIEDZI PRZERYWA - operacja nieodwracalna nie moze isc naprzod bez pewnosci. `verify()` meldowalo "Obraz NIESPOJNY", gdy `fsck_apfs` padl, bo tik agenta wyrwal mu urzadzenie - falszywy alarm o utracie backupu. `attach-image` rozroznia teraz sukces / pominiecie (kod 0) / awarie (kod 1) po typie `CMActionResult.Disposition`, a nie po tekscie: "zajete przez inna operacje" nie jest awaria i nie ma prawa zapisywac sie jako awaria w logu bledow. Co-Authored-By: Claude Opus 5 (1M context) --- .../CloudMachineAgent/DriveCommands.swift | 47 ++- .../CloudMachineCore/BackupImageService.swift | 376 ++++++++++++++++-- .../CloudMachineCore/CMActionResult.swift | 37 +- mac-app/Sources/CloudMachineCore/CMLock.swift | 63 ++- .../CloudMachineCore/DriveBufferService.swift | 227 ++++++++--- .../Sources/CloudMachineCore/ImageProbe.swift | 51 ++- .../CloudMachineCore/StatusLines.swift | 63 +++ .../BackupImageServiceTests.swift | 219 ++++++++++ .../CloudMachineAppTests/CMLockTests.swift | 79 ++++ .../ImageProbeTests.swift | 41 +- .../QueueStatsParsingTests.swift | 133 +++++++ .../StatusLinesTests.swift | 80 ++++ 12 files changed, 1308 insertions(+), 108 deletions(-) create mode 100644 mac-app/Sources/CloudMachineCore/StatusLines.swift create mode 100644 mac-app/Tests/CloudMachineAppTests/BackupImageServiceTests.swift create mode 100644 mac-app/Tests/CloudMachineAppTests/CMLockTests.swift create mode 100644 mac-app/Tests/CloudMachineAppTests/QueueStatsParsingTests.swift create mode 100644 mac-app/Tests/CloudMachineAppTests/StatusLinesTests.swift diff --git a/mac-app/Sources/CloudMachineAgent/DriveCommands.swift b/mac-app/Sources/CloudMachineAgent/DriveCommands.swift index 60e8885..2fb8a9b 100644 --- a/mac-app/Sources/CloudMachineAgent/DriveCommands.swift +++ b/mac-app/Sources/CloudMachineAgent/DriveCommands.swift @@ -104,7 +104,27 @@ struct AttachImage: AsyncParsableCommand { } let result = await BackupImageService.attach() print(result.message) - if !result.succeeded { throw ExitCode(1) } + + // Trzy przypadki, nie dwa. To polecenie chodzi pod launchd co 900 s, wiec + // jego kod wyjscia jest zapisem w `launchd-gdrive-attach.err.log` - tam, + // gdzie czlowiek patrzy, pytajac "czy backup dziala". "Obraz zajety przez + // odpinanie, ktore wlasnie trwa" NIE JEST awaria: nastepny tik za 15 minut + // zastanie juz wolny obraz i podepnie. Zapisywanie tego jako bledu to ten + // sam wzorzec, ktory ten kod tepi w druga strone - stan normalny czytany + // jako awaria, zamiast braku odpowiedzi czytanego jako odpowiedz. + // + // Rozstrzyga TYP wyniku (`CMActionResult.Disposition`), nie tresc + // komunikatu - dopasowanie do tekstu psuje sie przy pierwszej zmianie + // zdania i nikt tego nie zauwaza. `switch` jest wyczerpujacy, wiec nowy + // przypadek nie przejdzie tedy po cichu. + switch result.disposition { + case .ok: + break + case .skipped: + print("Nie jest to blad - nastepny przebieg agenta sprobuje ponownie.") + case .failed: + throw ExitCode(1) + } } } @@ -275,10 +295,15 @@ struct DriveStatus: AsyncParsableCommand { print( "Narzedzia: \(readiness.ready ? "OK" : "brakuje: " + readiness.missing.joined(separator: ", "))" ) - print("Montowanie Drive: \(DriveBufferService.isMounted ? "OK" : "BRAK")") + let mounted = DriveBufferService.mountedState() + print("Montowanie Drive: \(StatusLines.mounted(mounted))") print("Obraz podpiety: \(BackupImageService.describe(BackupImageService.attachment))") print("Bufor: \(BufferGuardService.bufferGB()) GB z \(DriveBufferService.cacheSize)") - print("Wolne na dysku: \(BufferGuardService.freeGB()) GB") + // NIE `\(BufferGuardService.freeGB()) GB` - to zwraca `Int?`, odkad brak + // pomiaru przestal udawac zero, a interpolacja opcjonalnej wartosci + // wypisywala `Wolne na dysku: Optional(427) GB`. Kompilator mowil o tym + // tylko ostrzezeniem, wiec nie zatrzymalo to ani builda, ani testow. + print("Wolne na dysku: \(StatusLines.freeDisk(BufferGuardService.freeGB()))") let queueStats = await DriveBufferService.queueStats() if let stats = queueStats { @@ -296,7 +321,12 @@ struct DriveStatus: AsyncParsableCommand { // Ta sama odpowiedz, co na karcie w interfejsie - jedno zrodlo, zeby CLI // i GUI nie mogly twierdzic czegos innego o tym samym stanie. let upload = UploadState.from( - mounted: DriveBufferService.isMounted, + // `UploadState` nie ma stanu "nie wiadomo, czy zamontowane", a dolozenie + // go dotknelo by plikow poza moim zakresem. `?? false` daje wtedy + // `.mountDown` ("Wysylka nie dziala") - czyli ostrzega, zamiast + // uspokajac, a wiersz "Montowanie Drive" wyzej mowi juz wprost + // "NIE WIADOMO". Falszywy alarm jest tu wlasciwym kierunkiem pomylki. + mounted: mounted ?? false, queueKnown: queueStats != nil, queued: queueStats?.uploadsQueued ?? 0, inProgress: queueStats?.uploadsInProgress ?? 0, @@ -322,6 +352,15 @@ struct DriveStatus: AsyncParsableCommand { } else { print("Backup: nie trwa") } + + // Na samym koncu i bez wyrownania do kolumny - to nie jest kolejny wiersz + // stanu, tylko cos, co ma zaklocic czytanie. `HealthAlert` od niedawna nie + // zamyka sprawy znacznikiem, dopoki powiadomienie nie doszlo, wiec + // niedoreczony alarm nie ginie juz na 12 h - ale bez tego bloku nikt by sie + // o nim nie dowiedzial, bo powiadomienia systemowego z definicji nie widac. + for line in StatusLines.undeliveredAlert(HealthAlert.lastDeliveryFailure()) { + print(line) + } } } diff --git a/mac-app/Sources/CloudMachineCore/BackupImageService.swift b/mac-app/Sources/CloudMachineCore/BackupImageService.swift index 9049313..153c858 100644 --- a/mac-app/Sources/CloudMachineCore/BackupImageService.swift +++ b/mac-app/Sources/CloudMachineCore/BackupImageService.swift @@ -48,6 +48,42 @@ public enum BackupImageService { private static let fsckPath = "/System/Library/Filesystems/apfs.fs/Contents/Resources/fsck_apfs" + // MARK: - Wzajemne wykluczenie + + /// Nazwa blokady, pod ktora chodza WSZYSTKIE operacje zmieniajace stan + /// obrazu: `create`, `attach`, `detach`, `verify`. + /// + /// Do 23 wrzesnia 2026 nie wykluczaly sie nawzajem niczym - `CMLock` istnial, + /// ale `withCMLock` nie bylo wolane z ani jednego miejsca w repo. Realny + /// przebieg, ktory to ujawnil: `detach` czeka na drenaz (35 s przerwy na + /// zapelnienie kolejki + do 600 s na cisze, czyli okno do 10,5 minuty), + /// a agent `gdrive-attach` tyka co 900 s. Agent regularnie wchodzil w to + /// okno, widzial obraz jako odpiety - bo `hdiutil detach` juz przeszedl - + /// i podpinal go z powrotem w srodku cudzego odpinania. + /// + /// Drugi wariant tego samego wyscigu: `purgeStaleDevices()` z tiku agenta + /// robi `hdiutil detach -force` na urzadzeniu, na ktorym akurat chodzi + /// `fsck_apfs` z `verify` - i `verify` meldowal "Obraz NIESPOJNY" o calym + /// backupie (patrz komentarz przy `verify`). + public static let lockName = "image" + + /// Wynik operacji, ktora sie NIE WYDARZYLA, bo obraz zajmuje inna operacja. + /// + /// `withCMLock` oddaje wtedy `nil` i to `nil` nie moze przejsc jako sukces: + /// `attach`, ktorego nie bylo, zameldowalby "Podpiete", a `detach`, ktorego + /// nie bylo - "wszystko wyslane na Google Drive". + private static func busyResult(_ what: String) -> CMActionResult { + CMLogger.log("\(what): blokade '\(lockName)' trzyma inna operacja na obrazie - nie robie nic") + return CMActionResult( + succeeded: false, + message: """ + \(what): inna operacja na obrazie jest w toku (tworzenie, podpinanie, \ + odpinanie albo sprawdzanie) - NIE zrobiono nic. Sprobuj za chwile. + """, + // Nie awaria, tylko "nie teraz" - patrz `CMActionResult.Disposition`. + didNotRun: true) + } + // MARK: - Stan public static var exists: Bool { @@ -62,9 +98,18 @@ public enum BackupImageService { /// oddaje dane. Martwe urzadzenie (patrz `ImageProbe`) siedzi w tej tablicy /// tak samo jak zywe. Do pytania "czy Time Machine ma gdzie pisac" sluzy /// `attachment`; `isAttached` zostaje tam, gdzie chodzi o samo odpiecie. - public static var isAttached: Bool { - guard let out = try? mountTable() else { return false } - return out.contains(" on \(targetPath.path) ") + public static var isAttached: Bool { attachedState() ?? false } + + /// Jak `isAttached`, ale `nil` = tablicy montowan NIE UDALO SIE odczytac. + /// + /// Ta sama zmiana, co w `DriveBufferService.mountPoints()` i z tego samego + /// powodu: dotad szlo to przez `/sbin/mount` bez limitu czasu, a pyta o + /// wolumen, ktory bywa MARTWY - czyli dokladnie o ten, na ktorym taki odczyt + /// potrafi zawisnac. Teraz idzie przez tablice jadra, bez procesu i bez + /// dotykania systemu plikow. + public static func attachedState() -> Bool? { + guard let points = DriveBufferService.mountPoints() else { return nil } + return points.contains(targetPath.path) } public enum Attachment: Equatable { @@ -74,16 +119,33 @@ public enum BackupImageService { /// widzi ten stan jako "dysk odlaczony" i nie zrobi ani jednej kopii, /// dopoki obraz nie zostanie odpiety i podpiety na nowo. case dead(errno: Int32) - + /// Tablicy montowan NIE UDALO SIE odczytac, wiec o stanie obrazu nie + /// wiadomo nic. To nie jest `.detached`: `.detached` to twierdzenie + /// ("sprawdzilem, nie ma"), a tu nie bylo czego sprawdzic. + /// + /// Po przejsciu na `getmntinfo(MNT_NOWAIT)` ten stan jest skrajnie malo + /// prawdopodobny - to odczyt z pamieci jadra, ktory nie ma jak zawisnac + /// ani pojsc do sieci. Istnieje mimo to, bo `attachment` jest typem, na + /// ktorym wolajacy opieraja decyzje, a typ nie powinien zmuszac do + /// zmyslania odpowiedzi. + case unknown + + /// Czy Time Machine ma gdzie pisac. `.unknown` swiadomie daje `false` - + /// to jest pytanie "czy MOGE na tym polegac", a na niewiadomej polegac + /// nie mozna. public var isUsable: Bool { self == .attached } } /// Stan podpiecia z uwzglednieniem tego, czy urzadzenie ZYJE. public static var attachment: Attachment { - guard isAttached else { return .detached } - switch ImageProbe.probe(volume: targetPath) { - case .dead(let errno): return .dead(errno: errno) - case .readable, .nothingToProbe: return .attached + switch attachedState() { + case .none: return .unknown + case .some(false): return .detached + case .some(true): + switch ImageProbe.probe(volume: targetPath) { + case .dead(let errno): return .dead(errno: errno) + case .readable, .nothingToProbe: return .attached + } } } @@ -94,18 +156,19 @@ public enum BackupImageService { case .dead(let errno): return "MARTWY - w tablicy montowan, ale odczyt pada (errno \(errno)); attach-image podpina na nowo" + case .unknown: + return "NIE WIADOMO - nie udalo sie odczytac tablicy montowan" } } - private static func mountTable() throws -> String { - let process = Process() - process.executableURL = URL(fileURLWithPath: "/sbin/mount") - let pipe = Pipe() - process.standardOutput = pipe - try process.run() - let data = pipe.fileHandleForReading.readDataToEndOfFile() - process.waitUntilExit() - return String(data: data, encoding: .utf8) ?? "" + /// Punkty montowania przegladanych migawek backupu. + /// + /// Czysta wersja, zeby dalo sie ja sprawdzic testem bez montowania + /// czegokolwiek - wczesniej to samo wychodzilo z parsowania wydruku + /// `/sbin/mount` (`" on "` ... `" ("`), wiec nie bylo do czego podstawic + /// probki. + static func browsedSnapshotMounts(_ points: [String]) -> [String] { + points.filter { $0.hasPrefix("/Volumes/.timemachine/") } } // MARK: - Zawieszone urzadzenia @@ -172,17 +235,95 @@ public enum BackupImageService { /// pozniej nie otwiera ("CBSDBackingStore::newProbe stat() failed"), mimo ze /// wszystkie pliki i pasma sa na swoim miejscu i daja sie czytac. public static func create(sizeGB: Int) async -> CMActionResult { - guard DriveBufferService.isMounted else { + await withCMLock(lockName) { await createLocked(sizeGB: sizeGB) } + ?? busyResult("Tworzenie obrazu") + } + + private static func createLocked(sizeGB: Int) async -> CMActionResult { + switch DriveBufferService.mountedState() { + case .some(true): + break + case .some(false): return CMActionResult( succeeded: false, message: "Drive nie jest zamontowany - najpierw uruchom bufor.") + case .none: + // Nie `isMounted`: tworzenie obrazu jest NIEODWRACALNE, wiec "nie wiem" + // nie moze tu przejsc jako "nie zamontowany" ani tym bardziej dalej. + return CMActionResult( + succeeded: false, + message: """ + Nie udalo sie odczytac tablicy montowan - NIE WIADOMO, czy bufor jest \ + zamontowany. NIE tworze obrazu. + """) } + + // Straznik "obraz juz istnieje" czyta cache FUSE, a rclone wystawia + // montowanie ZANIM wczyta z Dysku zawartosc katalogu - w tym oknie + // `exists` mowi "nie ma obrazu" o obrazie, ktory jest. `attach-image` + // czeka tu na `BufferReadiness.wait` od 13 wrzesnia 2026, `create` nie + // czekalo wcale. Dla `attach` przegapienie okna kosztuje jedno nieudane + // podpiecie; dla `create` - `hdiutil create` idzie na sciezke istniejacego + // backupu, i to z pieciokrotnym ponawianiem. + // + // Czekamy na UDANE listowanie punktu montowania, a nie na samo + // `isMounted`: na to drugie odpowiedzial juz straznik wyzej, wiec probka + // przechodzilaby natychmiast i czekanie nie robiloby NIC. Listowanie + // korzenia przy zimnym `--dir-cache-time` idzie po dane do Google, wiec + // jego powodzenie znaczy "rclone faktycznie obsluguje ten katalog"; + // dopoki FUSE nie zaczelo serwowac, konczy sie bledem urzadzenia. + // + // UWAGA co do zasiegu: to czekanie usuwa okno "FUSE jeszcze nie odpowiada", + // ale NIE dowodzi nieobecnosci obrazu - puste listowanie wyglada tak samo + // przy pustym koncie i przy niewczytanym katalogu. Dowodem jest dopiero + // `remoteImagePresence()` nizej i to on, a nie to czekanie, wstrzymuje + // operacje nieodwracalna. + let ready = await BufferReadiness.wait( + sleep: { seconds in + try? await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) + }, + probe: { + DriveBufferService.isMounted + && (try? FileManager.default.contentsOfDirectory( + atPath: DriveBufferService.mountPoint.path)) != nil + }) + guard ready else { + return CMActionResult( + succeeded: false, + message: + "Bufor nie stanal w \(Int(BufferReadiness.defaultTimeout / 60)) min - NIE tworze obrazu.") + } + guard !exists else { return CMActionResult( succeeded: false, message: "Obraz juz istnieje. Usuniecie go kasuje caly backup - zrob to swiadomie.") } - await DriveBufferService.waitUntilQuiet(timeout: 180) + // Cache FUSE juz raz sklamal, wiec pytamy jeszcze raz ZDALNEGO, z + // pominieciem montowania. To jest operacja NIEODWRACALNA: brak pewnosci + // musi ja PRZERWAC, a nie tylko wypisac ostrzezenie, ktore i tak nikt nie + // czyta przed zatwierdzeniem. + switch await remoteImagePresence() { + case .absent: + break + case .present: + return CMActionResult( + succeeded: false, + message: """ + Obraz juz istnieje na Google Drive (cache montowania go nie pokazywal, \ + ale zdalny go ma). Usuniecie go kasuje caly backup - zrob to swiadomie. + """) + case .unknown(let why): + return CMActionResult( + succeeded: false, + message: """ + Nie udalo sie potwierdzic na Google Drive, ze obrazu tam jeszcze nie ma \ + (\(why)) - PRZERYWAM. Tworzenie obrazu na istniejacym backupie jest \ + nieodwracalne, wiec bez tej odpowiedzi nie zaczynam. + """) + } + + await DriveBufferService.waitUntilIdle(timeout: 180) let args = [ "create", "-type", "SPARSEBUNDLE", @@ -207,11 +348,81 @@ public enum BackupImageService { message: "Utworzono obraz \(sizeGB) GB, pasmo \(bandSectors * 512 / 1024 / 1024) MB.") } + // MARK: - Obraz na zdalnym + + /// Czy obraz lezy na Google Drive - pytane BEZ posrednictwa montowania. + public enum RemotePresence: Equatable { + case present + case absent + /// Nie wiadomo. `why` idzie do komunikatu, zeby uzytkownik wiedzial, + /// czego dokladnie zabraklo. + case unknown(String) + } + + /// Pyta `rclone lsf` prosto o zdalny katalog backupu. + /// + /// Sens jest w tym, ze omija cache FUSE - a to wlasnie cache FUSE mowi + /// "nie ma obrazu" przez pierwsze sekundy po wystawieniu montowania. + public static func remoteImagePresence() async -> RemotePresence { + let remote = "\(DriveBufferService.remoteName):\(DriveBufferService.remotePath)" + guard + let result = try? await CMTooling.runRclone(["lsf", "--dirs-only", remote], timeout: 120) + else { + return .unknown("rclone nie odpowiedzial") + } + return classifyRemoteListing( + succeeded: result.succeeded, stdout: result.stdout, stderr: result.stderr) + } + + /// Czysta wersja - zeby dalo sie ja sprawdzic testem bez sieci i bez konta. + /// + /// Nieudane `lsf` z komunikatem "directory not found" NIE jest brakiem + /// odpowiedzi, tylko odpowiedzia "nie ma tam niczego": tak wyglada pierwsze + /// uruchomienie, zanim cokolwiek zostalo na Dysk wyslane. Gdybysmy zaliczyli + /// to do `.unknown`, `create` nie dalby sie wykonac ANI RAZU - straznik + /// blokowalby dokladnie ten przypadek, dla ktorego istnieje. + static func classifyRemoteListing(succeeded: Bool, stdout: String, stderr: String) + -> RemotePresence + { + if succeeded { + return listingContainsImage(stdout) ? .present : .absent + } + if stderr.lowercased().contains("directory not found") { return .absent } + let reason = stderr.trimmingCharacters(in: .whitespacesAndNewlines) + return .unknown(reason.isEmpty ? "rclone lsf zakonczylo sie bledem" : String(reason.suffix(200))) + } + + /// `rclone lsf --dirs-only` konczy nazwy katalogow ukosnikiem, ale nie + /// polegamy na tym - przyjmujemy obie postacie. + static func listingContainsImage(_ listing: String) -> Bool { + let wanted = "\(imageName).sparsebundle" + return listing.components(separatedBy: .newlines) + .map { $0.trimmingCharacters(in: .whitespaces) } + .contains { $0 == wanted || $0 == wanted + "/" } + } + // MARK: - Podpinanie public static func attach() async -> CMActionResult { - guard DriveBufferService.isMounted else { + await withCMLock(lockName) { await attachLocked() } ?? busyResult("Podpinanie obrazu") + } + + private static func attachLocked() async -> CMActionResult { + switch DriveBufferService.mountedState() { + case .some(true): + break + case .some(false): return CMActionResult(succeeded: false, message: "Drive nie jest zamontowany.") + case .none: + // Podpiecie obrazu na NIEZAMONTOWANYM buforze konczy sie obrazem + // wiszacym na pustym katalogu, wiec "nie wiem" ma tu wstrzymac, a nie + // przepuscic. Tik agenta sprobuje znowu za 900 s. + return CMActionResult( + succeeded: false, + message: """ + Nie udalo sie odczytac tablicy montowan - NIE WIADOMO, czy bufor jest \ + zamontowany. NIE podpinam obrazu. + """) } guard exists else { return CMActionResult(succeeded: false, message: "Brak obrazu - najpierw go utworz.") @@ -219,6 +430,17 @@ public enum BackupImageService { switch attachment { case .attached: return CMActionResult(succeeded: true, message: "Juz podpiete: \(targetPath.path)") + case .unknown: + // Nie `.detached`, bo nastepnym krokiem bylby `hdiutil attach` na + // obrazie, ktory moze byc juz podpiety - a wczesniej jeszcze + // `purgeStaleDevices()`, czyli `detach -force` na cudzym, zywym + // urzadzeniu. "Nie wiem" nie moze uruchamiac ani jednego, ani drugiego. + return CMActionResult( + succeeded: false, + message: """ + Nie udalo sie odczytac tablicy montowan - NIE WIADOMO, czy obraz jest \ + podpiety. NIE podpinam. + """) case .dead(let errno): // Obraz jest w tablicy montowan, ale nie oddaje danych. Do 22 wrz 2026 // ta funkcja mowila wtedy "Juz podpiete" i wychodzila - agent podpinajacy @@ -227,7 +449,10 @@ public enum BackupImageService { // martwym urzadzeniu) i podpiecie od nowa. Czekanie na wysylke zostaje: // to, co zdazylo trafic do bufora rclone, nadal ma doleciec na Dysk. CMLogger.log("Obraz martwy (errno \(errno)) - odpinam na sile i podpinam od nowa") - let detached = await detach(force: true) + // `detachLocked`, nie `detach`: blokade 'image' trzymamy juz my, + // a `CMLock` nie jest wznawialna - wejscie przez publiczna `detach` + // zobaczyloby wlasna, zywa blokade i odmowilo samo sobie. + let detached = await detachLocked(force: true) CMLogger.log("Odpiecie martwego obrazu: \(detached.message)") guard !isAttached else { return CMActionResult( @@ -263,7 +488,7 @@ public enum BackupImageService { // w ponizszym limicie czasu, wiec najpierw wymuszamy wysylke - inaczej // podpiecie po kazdym starcie bylo by loteria. await DriveBufferService.expireQueuedUploads() - await DriveBufferService.waitUntilQuiet(timeout: 120) + await DriveBufferService.waitUntilIdle(timeout: 120) let result = await retryingFlakyMount(attempts: 5) { try? await ProcessRunner.run( @@ -292,6 +517,13 @@ public enum BackupImageService { /// Dlatego kazda sciezka wygaszania - odpiecie, zatrzymanie bufora, /// wylaczenie Maca - musi przepuscic drenaz do konca. public static func detach(force: Bool = false, waitForUpload: Bool = true) async -> CMActionResult + { + await withCMLock(lockName) { await detachLocked(force: force, waitForUpload: waitForUpload) } + ?? busyResult("Odpinanie obrazu") + } + + private static func detachLocked(force: Bool = false, waitForUpload: Bool = true) async + -> CMActionResult { let stillMounted = await unmountBrowsedSnapshots() @@ -335,16 +567,46 @@ public enum BackupImageService { // Bez tego drenaz trwalby tyle, co `writeBackSeconds` (dziesiec minut), // czyli dluzej niz ponizszy limit czasu - i odpiecie zglaszaloby // niepowodzenie za kazdym razem. - let forced = await DriveBufferService.expireQueuedUploads() - if forced > 0 { + switch await DriveBufferService.expireQueuedUploads() { + case .none: + // Brak odpowiedzi to NIE pusta kolejka - patrz `expireQueuedUploads`. + CMLogger.log( + "Odpiecie: rclone nie odpowiedzial na pytanie o kolejke - terminow wysylki NIE" + + " przesunieto, drenaz moze trwac do \(DriveBufferService.writeBackSeconds / 60) min") + case .some(0): + CMLogger.log("Odpiecie: kolejka pusta - nie bylo czego przyspieszac") + case .some(let forced): CMLogger.log("Odpiecie: wymuszono wysylke \(forced) pozycji z kolejki") } - let drained = await DriveBufferService.waitUntilQuiet(timeout: 600) - return CMActionResult( - succeeded: drained, - message: drained - ? "Odpiete, wszystko wyslane na Google Drive." - : "Odpiete, ale wysylka NIE zakonczyla sie w czasie - nie kasuj bufora.") + return detachVerdict(settled: await DriveBufferService.statsWhenIdle(timeout: 600)) + } + + /// Czysta wersja werdyktu o odpieciu - `settled` to odczyt kolejki z chwili, + /// w ktorej ucichla (`nil` = nie ucichla w czasie albo rclone nie odpowiedzial). + /// + /// Pusta kolejka to jeszcze nie komplet danych na Dysku. Pasma, ktore rclone + /// PORZUCIL, wypadaja z kolejki dokladnie tak samo jak wyslane i zostaja + /// wylacznie w `erroredFiles`. Do 23 wrzesnia 2026 odpiecie patrzylo tylko + /// na `uploadsInProgress`/`uploadsQueued`, wiec meldowalo "Odpiete, wszystko + /// wyslane na Google Drive" przy danych istniejacych TYLKO na tym Macu - + /// a `UploadState` z tych samych licznikow wyprowadzal juz wtedy + /// `.failedFiles(...)` z etykieta "WYMAGA REAKCJI". CLI i GUI mowily o tej + /// samej chwili dwie rozne rzeczy. + static func detachVerdict(settled: DriveBufferService.QueueStats?) -> CMActionResult { + guard let settled else { + return CMActionResult( + succeeded: false, + message: "Odpiete, ale wysylka NIE zakonczyla sie w czasie - nie kasuj bufora.") + } + guard settled.erroredFiles == 0 else { + return CMActionResult( + succeeded: false, + message: """ + Odpiete, ale rclone PORZUCIL \(settled.erroredFiles) fragmentow kopii - istnieja \ + wylacznie na tym Macu i na Google Drive ich nie ma. Nie kasuj bufora. + """) + } + return CMActionResult(succeeded: true, message: "Odpiete, wszystko wyslane na Google Drive.") } /// Odmontowuje migawki backupu podpiete pod `/Volumes/.timemachine/`. @@ -365,14 +627,9 @@ public enum BackupImageService { /// potem odmawial bez zwiazku ze soba widocznego w logu. @discardableResult public static func unmountBrowsedSnapshots() async -> [String] { - guard let table = try? mountTable() else { return [] } + guard let points = DriveBufferService.mountPoints() else { return [] } var failed: [String] = [] - for line in table.components(separatedBy: .newlines) { - guard line.contains("/Volumes/.timemachine/"), - let range = line.range(of: " on "), - let end = line.range(of: " (", range: range.upperBound.. CMActionResult { + await withCMLock(lockName) { await verifyLocked() } ?? busyResult("Sprawdzanie obrazu") + } + + private static func verifyLocked() async -> CMActionResult { guard exists else { return CMActionResult(succeeded: false, message: "Brak obrazu.") } @@ -423,6 +684,17 @@ public enum BackupImageService { // alarm o utracie backupu jest tu grozniejszy niz dlugie czekanie. let fsck = try? await ProcessRunner.run(fsckPath, ["-n", String(device)]) + // Czy urzadzenie bylo jeszcze nasze, gdy `fsck` konczyl? + // + // `fsck_apfs` zwraca niezerowy kod tak samo, gdy znalazl uszkodzenie, jak + // i wtedy, gdy ktos wyrwal mu urzadzenie spod nog - a wyrwac je potrafi + // `purgeStaleDevices()` (`hdiutil detach -force`) przy tiku agenta + // `gdrive-attach` co 900 s. Bez tego sprawdzenia `verify` meldowal wtedy + // "Obraz NIESPOJNY", czyli falszywy alarm o utracie calego backupu. + // Blokada 'image' zamyka juz to okno, ale komunikat ma byc uczciwy + // takze wtedy, gdy urzadzenie znika z innego powodu. + let deviceSurvived = await devicesForImage().contains(parentDevice(of: String(device))) + // Odpinamy Z CZEKANIEM, nie przez `defer { Task { ... } }`. Tamta wersja // wracala z funkcji, zanim odpiecie sie wydarzylo - a wolajacy zwykle od // razu podpina obraz z powrotem, wiec podpiecie scigalo sie z zaleglym @@ -439,12 +711,34 @@ public enum BackupImageService { succeeded: false, message: "Nie udalo sie uruchomic \(fsckPath) - spojnosc obrazu POZOSTAJE NIESPRAWDZONA.") } + if !fsck.succeeded && !deviceSurvived { + return CMActionResult( + succeeded: false, + message: """ + Sprawdzenie PRZERWANE - urzadzenie \(device) zniklo w trakcie (ktos odpial \ + obraz na sile). To nie jest wynik o stanie backupu: spojnosc obrazu \ + POZOSTAJE NIESPRAWDZONA. Powtorz sprawdzenie. + """) + } return CMActionResult( succeeded: fsck.succeeded, message: fsck.succeeded ? "Obraz spojny." : "Obraz NIESPOJNY: \(fsck.stdout.suffix(500))") } + /// `/dev/disk7s1` -> `/dev/disk7`. + /// + /// `fsck_apfs` dostaje partycje APFS, a `hdiutil info` - i wiec + /// `devicesForImage()` - wypisuje urzadzenie NADRZEDNE. Porownanie ich + /// wprost nigdy by sie nie zgodzilo, wiec sprawdzenie "czy urzadzenie + /// przezylo" cicho odpowiadaloby "nie" za kazdym razem. + static func parentDevice(of device: String) -> String { + let prefix = "/dev/disk" + guard device.hasPrefix(prefix) else { return device } + let digits = device.dropFirst(prefix.count).prefix(while: \.isNumber) + return digits.isEmpty ? device : prefix + digits + } + // MARK: - Gotowosc do restartu /// Czy mozna bezpiecznie wylaczyc Maca bez `prepare-shutdown`. @@ -464,6 +758,12 @@ public enum BackupImageService { // bezpiecznie - a przy takim pytaniu milczenie musi znaczyc "nie". return false } + // `isQuiet`, NIE `isIdle`: pusta kolejka nie wystarczy, bo pasma porzucone + // przez rclone (`erroredFiles`) wypadaja z kolejki tak samo jak wyslane. + // Restart przy takim stanie nie niszczy niczego dodatkowo, ale odpowiedz + // "TAK - kolejka pusta" czytalo sie jako "kopia na Dysku jest kompletna", + // a nie byla - i to samo zdanie padalo w `drive-status` obok + // `UploadState.failedFiles` z etykieta "WYMAGA REAKCJI". return stats.isQuiet } @@ -487,7 +787,7 @@ public enum BackupImageService { guard attempt < attempts else { break } CMLogger.log("hdiutil: proba \(attempt) nieudana, ponawiam") try? await Task.sleep(nanoseconds: 5_000_000_000) - await DriveBufferService.waitUntilQuiet(timeout: 60) + await DriveBufferService.waitUntilIdle(timeout: 60) } return last } diff --git a/mac-app/Sources/CloudMachineCore/CMActionResult.swift b/mac-app/Sources/CloudMachineCore/CMActionResult.swift index e425a71..86e5453 100644 --- a/mac-app/Sources/CloudMachineCore/CMActionResult.swift +++ b/mac-app/Sources/CloudMachineCore/CMActionResult.swift @@ -13,9 +13,44 @@ public struct CMActionResult { /// prawdziwego bledu, zamiast zwracac uzytkownikowi martwy koniec. public var isSudoAuthFailure: Bool - public init(succeeded: Bool, message: String, isSudoAuthFailure: Bool = false) { + /// `true`, jesli operacja w ogole sie NIE ZACZELA - nie dlatego, ze cos + /// poszlo zle, tylko dlatego, ze zasob byl zajety przez inna operacje + /// (patrz `BackupImageService.busyResult`). + /// + /// Osobne pole, a nie rozpoznawanie po tresci `message`: dopasowanie do + /// tekstu psuje sie przy kazdej zmianie komunikatu, a cicho - to znaczy + /// tak, ze nikt tego nie zauwaza az do awarii. + public var didNotRun: Bool + + public init( + succeeded: Bool, message: String, isSudoAuthFailure: Bool = false, didNotRun: Bool = false + ) { self.succeeded = succeeded self.message = message self.isSudoAuthFailure = isSudoAuthFailure + self.didNotRun = didNotRun + } + + /// Co wolajacy ma z tym wynikiem zrobic - w szczegolnosci z jakim kodem + /// wyjscia ma sie skonczyc polecenie CLI chodzace pod launchd. + /// + /// Istnieje, bo "zajete przez inna operacje" NIE JEST awaria, a przez kod + /// wyjscia 1 ladowalo w `launchd-gdrive-attach.err.log` - czyli dokladnie + /// tam, gdzie czlowiek patrzy, pytajac "czy backup dziala". To ten sam blad, + /// ktory ten kod tepi w druga strone ("brak odpowiedzi czytany jako + /// odpowiedz"), tylko odwrocony: stan normalny czytany jako awaria. + public enum Disposition: Equatable { + /// Udalo sie. + case ok + /// Nic sie nie wydarzylo i nic sie nie zepsulo. Cykliczny tik ma po + /// prostu sprobowac przy nastepnym przebiegu. + case skipped + /// Prawdziwa porazka - ta ma byc widoczna. + case failed + } + + public var disposition: Disposition { + if succeeded { return .ok } + return didNotRun ? .skipped : .failed } } diff --git a/mac-app/Sources/CloudMachineCore/CMLock.swift b/mac-app/Sources/CloudMachineCore/CMLock.swift index c52b7b0..1079ee3 100644 --- a/mac-app/Sources/CloudMachineCore/CMLock.swift +++ b/mac-app/Sources/CloudMachineCore/CMLock.swift @@ -14,12 +14,32 @@ public final class CMLock { lockDir = CMPaths.logDir.appendingPathComponent("\(name).lock.d") } + /// Wylacznie dla testu: blokada w katalogu, ktory test sam sprzata. Bez tego + /// kazdy test blokady zostawialby katalogi w prawdziwym `~/Library/Logs/ + /// CloudMachine`, czyli tam, gdzie dzialajaca instalacja trzyma blokady + /// produkcyjne - test umialby zablokowac agenta. + init(directory: URL) { + lockDir = directory + } + /// Probuje przejac blokade. Zwraca `false`, jesli inny zywy proces juz ja trzyma. public func acquire() -> Bool { if (try? FileManager.default.createDirectory(at: lockDir, withIntermediateDirectories: false)) != nil { - writePid() + // Weryfikacja odczytem jest tu potrzebna z DOKLADNIE tego samego powodu, + // co na sciezce przejecia nizej - do 23 wrzesnia 2026 byla tylko tam. + // `writePid()` polyka blad zapisu (`try?`), a proces ubity miedzy + // `createDirectory` a zapisem zostawia katalog blokady BEZ pliku `pid`. + // Konkurent czyta wtedy pusty katalog, nie znajduje PID-a, uznaje + // blokade za osierocona i ja przejmuje - podczas gdy my juz zwrocilismy + // `true` i dzialamy w przekonaniu o wylacznosci. Dwoch wlascicieli tej + // samej blokady "image" to rownolegly `detach` i `attach` na tym samym + // obrazie, czyli dokladnie to, przed czym blokada ma chronic. + guard writeAndVerifyPid() else { + try? FileManager.default.removeItem(at: lockDir) + return false + } acquired = true return true } @@ -40,25 +60,34 @@ public final class CMLock { else { return false } + guard writeAndVerifyPid() else { return false } + acquired = true + return true + } + + /// Zapisuje wlasny PID i POTWIERDZA go odczytem. `false` znaczy "nie umiem + /// udowodnic, ze blokada jest moja" - a to musi konczyc sie rezygnacja, + /// nie optymizmem. + /// + /// WAZNE: `removeItem` + `createDirectory` na sciezce przejecia NIE jest + /// atomowe - dwa procesy przejmujace ta sama osierocona blokade w + /// nakladajacym sie oknie moga obie odczytac "martwy PID", obie usunac + /// i utworzyc katalog na nowo, obie zapisac swoj PID - i obie zwrocic + /// `true`. Odczytujemy wlasnie zapisany plik z powrotem: jesli inny proces + /// zdazyl go nadpisac swoim PID-em pomiedzy zapisem a tym odczytem, wiemy, + /// ze przegralismy wyscig, i wycofujemy sie zamiast dzialac w falszywym + /// przekonaniu o wylacznosci. Nie eliminuje to calkowicie okna (obie strony + /// moga jeszcze przejsciowo "wygrac" tuz przed ta weryfikacja), ale + /// gwarantuje, ze co najmniej jedna z nich to wykryje i cofnie. + private func writeAndVerifyPid() -> Bool { writePid() - // WAZNE: `removeItem` + `createDirectory` powyzej NIE jest atomowe - - // dwa procesy przejmujace ta sama osierocona blokade w nakladajacym - // sie oknie moga obie odczytac "martwy PID", obie usunac i utworzyc - // katalog na nowo, obie zapisac swoj PID - i obie zwrocic `true`. - // Odczytujemy wlasnie zapisany plik z powrotem: jesli inny proces - // zdazyl go nadpisac swoim PID-em pomiedzy naszym `writePid()` a tym - // odczytem, wiemy, ze przegralismy wyscig, i wycofujemy sie zamiast - // dzialac w falszywym przekonaniu o wylacznosci. Nie eliminuje to - // calkowicie okna (obie strony moga jeszcze przejsciowo "wygrac" tuz - // przed ta weryfikacja), ale gwarantuje, ze co najmniej jedna z nich - // to wykryje i cofnie. guard - let verifyContent = try? String(contentsOf: pidFile, encoding: .utf8), + let verifyContent = try? String( + contentsOf: lockDir.appendingPathComponent("pid"), encoding: .utf8), Self.parsePid(from: verifyContent) == getpid() else { return false } - acquired = true return true } @@ -224,6 +253,12 @@ public final class CMLock { /// Wykonuje `body` pod blokada `name`, zwalniajac ja automatycznie na wyjsciu /// (rowniez przy rzuconym bledzie) - odpowiednik `cm_acquire_lock` + `trap EXIT`. /// Zwraca `nil` bez wywolania `body`, jesli inna zywa instancja juz trzyma blokade. +/// +/// `nil` znaczy **"nic sie nie wydarzylo"** i wolajacy MUSI to odroznic od +/// wyniku `body`. Zapis w rodzaju `(await withCMLock("image") { ... }) ?? true` +/// albo `!= nil ? ... : ...` zamieniajacy brak wykonania w sukces jest bledem: +/// `attach`, ktore nie doszlo do skutku, zameldowaloby "Podpiete", a +/// `detach`, ktore sie nie wykonalo - "wszystko wyslane". @discardableResult public func withCMLock(_ name: String, _ body: () throws -> T) rethrows -> T? { let lock = CMLock(name: name) diff --git a/mac-app/Sources/CloudMachineCore/DriveBufferService.swift b/mac-app/Sources/CloudMachineCore/DriveBufferService.swift index 9880ca2..1e7b5ad 100644 --- a/mac-app/Sources/CloudMachineCore/DriveBufferService.swift +++ b/mac-app/Sources/CloudMachineCore/DriveBufferService.swift @@ -62,24 +62,60 @@ public enum DriveBufferService { // MARK: - Stan - public static var isMounted: Bool { - // `mount` jest zrodlem prawdy - samo istnienie katalogu nic nie znaczy, - // bo punkt montowania zostaje na dysku po odmontowaniu. - guard let out = try? shellMountTable() else { return false } - return out.contains(" on \(mountPoint.path) ") + /// Punkty montowania prosto z tablicy jadra. `nil` = tablicy NIE UDALO SIE + /// odczytac, co jest czyms innym niz "nic nie jest zamontowane". + /// + /// DLACZEGO NIE `/sbin/mount` + /// + /// Do 23 wrzesnia 2026 bylo tu uruchomienie `/sbin/mount` z + /// `readDataToEndOfFile()` + `waitUntilExit()` BEZ limitu czasu. Przy martwym + /// montowaniu FUSE-T (incydent ENXIO z 22.09) taki odczyt potrafi wejsc w + /// nieprzerywalne I/O i nie wrocic - a czyta stad `isMounted`, czyli czujka + /// `backup-health` ORAZ petla odswiezania GUI chodzaca co 10 sekund. + /// Zawieszenie wieszalo wiec i podglad, i nadzor, na tej samej awarii, + /// ktora oba maja wykryc. + /// + /// Nalozenie limitu czasu (jak w `TimeMachineStatus.commandTimeout`) + /// usuneloby zawieszenie, ale kazdy taki limit jest tu czystym kosztem: + /// przy odswiezaniu co 10 s wywolania zaczelyby sie nakladac, a odpowiedz + /// i tak by nie przyszla. `getmntinfo(MNT_NOWAIT)` usuwa problem u zrodla - + /// czyta tablice montowan z pamieci jadra i NIE odpytuje zadnego systemu + /// plikow (od tego jest `MNT_WAIT`, ktore wlasnie umialoby zawisnac). + /// Nie ma tu procesu, potoku ani wejscia/wyjscia, wiec nie ma czego + /// ograniczac limitem. Zmierzone na tej maszynie: 19 montowan w 0,0002 s. + /// + /// Przy okazji znika parsowanie tekstu: `f_mntonname` to sciezka wprost, + /// zamiast szukania `" on "` w wydruku. + public static func mountPoints() -> [String]? { + var raw: UnsafeMutablePointer? + let count = getmntinfo(&raw, MNT_NOWAIT) + guard count > 0, let raw else { return nil } + return (0.. String { - let process = Process() - process.executableURL = URL(fileURLWithPath: "/sbin/mount") - let pipe = Pipe() - process.standardOutput = pipe - try process.run() - let data = pipe.fileHandleForReading.readDataToEndOfFile() - process.waitUntilExit() - return String(data: data, encoding: .utf8) ?? "" + /// Czy bufor jest zamontowany. `nil` = NIE WIADOMO. + /// + /// Rozroznienie jest tu istotne, bo na tej odpowiedzi stoi decyzja o + /// podpieciu i o utworzeniu obrazu - a "nie wiem" udajace "nie zamontowane" + /// to ten sam rodzaj cichej awarii, ktory w tym pliku zamyka juz + /// `UploadState.queueUnknown` po stronie kolejki. + public static func mountedState() -> Bool? { + guard let points = mountPoints() else { return nil } + // Tablica montowan jest zrodlem prawdy - samo istnienie katalogu nic nie + // znaczy, bo punkt montowania zostaje na dysku po odmontowaniu. + return points.contains(mountPoint.path) } + /// Skrot dla miejsc, w ktorych brak odczytu i "nie zamontowane" znacza to + /// samo - czyli tam, gdzie i tak czekamy na montowanie albo tylko je + /// wypisujemy. Wszedzie, gdzie z odpowiedzi wynika DECYZJA, uzywaj + /// `mountedState()`. + public static var isMounted: Bool { mountedState() ?? false } + // MARK: - Uruchomienie /// Argumenty `rclone mount`. Wydzielone, zeby dalo sie je sprawdzic testem @@ -159,7 +195,23 @@ public enum DriveBufferService { /// trzyma, wiec nie ma czego usunac. Mocniejszy sygnal niz jakikolwiek /// prog, bo pochodzi od tego, kto naprawde wie. public var outOfSpace: Bool - public var isQuiet: Bool { uploadsInProgress == 0 && uploadsQueued == 0 } + + /// rclone nic TERAZ nie robi. To warunek STABILNOSCI `hdiutil` na + /// montowaniu FUSE-T (patrz `retryingFlakyMount`) i nic wiecej - w + /// szczegolnosci NIE jest dowodem, ze kopia doleciala na Dysk. + public var isIdle: Bool { uploadsInProgress == 0 && uploadsQueued == 0 } + + /// Nic nie czeka I nic nie zostalo po drodze porzucone. + /// + /// Do 23 wrzesnia 2026 to pytanie mialo tylko jedna odpowiedz - te, ktora + /// dzis nazywa sie `isIdle` - i to ona szla do komunikatu odpiecia oraz do + /// `safeToRebootNow()`. Pasmo, ktore rclone porzucil, wypada z kolejki + /// dokladnie tak samo jak pasmo wyslane: `uploadsQueued` wraca do zera, + /// a slad zostaje wylacznie w `erroredFiles`. Skutek: "Odpiete, wszystko + /// wyslane na Google Drive" i "Restart bez pytania: TAK" przy danych + /// istniejacych tylko lokalnie - podczas gdy `UploadState` z tych samych + /// licznikow wyprowadzal juz `.failedFiles(...)` i "WYMAGA REAKCJI". + public var isQuiet: Bool { isIdle && erroredFiles == 0 } } /// Odczytuje stan kolejki przez interfejs sterujacy rclone. @@ -184,25 +236,61 @@ public enum DriveBufferService { guard let result = try? await CMTooling.runRclone( ["rc", "--url", rcAddress, "vfs/stats"], timeout: 60), - result.succeeded, - let data = result.stdout.data(using: .utf8), - let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + result.succeeded + else { return nil } + return parseQueueStats(result.stdout) + } + + /// Czysta wersja parsowania odpowiedzi `vfs/stats`. `nil` znaczy "nie wiem", + /// nigdy "same zera". + /// + /// Wydzielone, zeby dalo sie to sprawdzic testem - dotad parsowanie siedzialo + /// w funkcji async wolajacej rclone i nie bylo do niego dostepu z zadnej + /// strony poza uruchomieniem calego bufora. + /// + /// Dwie rzeczy, ktore tu byly i musialy zniknac: + /// + /// 1. `(json["diskCache"] as? [String: Any]) ?? json` - odpowiedz BEZ sekcji + /// `diskCache` (rclone zbudowane bez cache dysku, inna wersja interfejsu, + /// obcieta odpowiedz) wpadala na `json`, gdzie zadnego z licznikow nie ma. + /// 2. `number(_:in:)` oddajace 0 dla brakujacego klucza. + /// + /// Razem dawaly `QueueStats` z samymi zerami zamiast `nil`, czyli + /// `queueKnown == true` i znow plansza "Wszystko wyslane na Google Drive". + /// To ten sam wzorzec, ktory naprawiono wyzej przez `UploadState.queueUnknown`, + /// tyle ze przesuniety o jeden krok - do parsowania. + static func parseQueueStats(_ raw: String) -> QueueStats? { + guard + let data = raw.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + // vfs/stats zwraca liczniki zagniezdzone w sekcji "diskCache". Jej brak + // to brak odpowiedzi na zadane pytanie, a nie odpowiedz "zero". + let disk = json["diskCache"] as? [String: Any] else { return nil } - func number(_ key: String, in dict: [String: Any]) -> Int { - if let v = dict[key] as? Int { return v } - if let v = dict[key] as? NSNumber { return v.intValue } - return 0 + func number(_ key: String) -> Int? { + if let v = disk[key] as? Int { return v } + if let v = disk[key] as? NSNumber { return v.intValue } + return nil } - // vfs/stats zwraca liczniki zagniezdzone w sekcji "diskCache". - let disk = (json["diskCache"] as? [String: Any]) ?? json + guard + let inProgress = number("uploadsInProgress"), + let queued = number("uploadsQueued"), + let files = number("files"), + let errored = number("erroredFiles"), + let bytesUsed = number("bytesUsed") + else { return nil } + return QueueStats( - uploadsInProgress: number("uploadsInProgress", in: disk), - uploadsQueued: number("uploadsQueued", in: disk), - files: number("files", in: disk), - erroredFiles: number("erroredFiles", in: disk), - bytesUsed: UInt64(max(0, number("bytesUsed", in: disk))), + uploadsInProgress: inProgress, + uploadsQueued: queued, + files: files, + erroredFiles: errored, + bytesUsed: UInt64(max(0, bytesUsed)), + // Jedyne pole, ktorego brak wolno nadrobic domyslna wartoscia: to flaga, + // a nie licznik - starsze rclone jej nie wystawia, a jej brak nie da sie + // pomylic z "bufor pelny". outOfSpace: (disk["outOfSpace"] as? Bool) ?? false ) } @@ -236,16 +324,33 @@ public enum DriveBufferService { return (used, total, free) } - /// Czeka, az wysylka ucichnie. Operacje `hdiutil` na montowaniu FUSE-T sa - /// stabilne tylko przy pustej kolejce - patrz `BackupImageService`. + /// Czeka, az rclone przestanie cokolwiek wysylac. Operacje `hdiutil` na + /// montowaniu FUSE-T sa stabilne tylko przy pustej kolejce - patrz + /// `BackupImageService.retryingFlakyMount`. + /// + /// Warunkiem jest `isIdle`, a NIE `isQuiet`: pasma porzucone przez rclone + /// zostaja w `erroredFiles` do konca zycia procesu, wiec czekanie na + /// `isQuiet` nigdy by sie nie doczekalo i kazde podpiecie placilo by pelny + /// limit czasu za nic. + /// + /// Zwraca odczyt kolejki z chwili uciszenia - `nil`, gdy nie ucichla w czasie + /// albo gdy rclone nie odpowiedzial. Wolajacy dostaje go po to, zeby moc + /// sprawdzic `erroredFiles` bez zadawania rclone tego samego pytania drugi + /// raz (kosztuje do 60 s - patrz `queueStats`). @discardableResult - public static func waitUntilQuiet(timeout: TimeInterval = 180) async -> Bool { + public static func statsWhenIdle(timeout: TimeInterval = 180) async -> QueueStats? { let deadline = Date().addingTimeInterval(timeout) while Date() < deadline { - if let stats = await queueStats(), stats.isQuiet { return true } + if let stats = await queueStats(), stats.isIdle { return stats } try? await Task.sleep(nanoseconds: 2_000_000_000) } - return false + return nil + } + + /// Jak `statsWhenIdle`, gdy wolajacego interesuje wylacznie "doczekalem sie". + @discardableResult + public static func waitUntilIdle(timeout: TimeInterval = 180) async -> Bool { + await statsWhenIdle(timeout: timeout) != nil } /// Przesuwa termin wysylki wszystkich czekajacych pozycji na "teraz". @@ -261,24 +366,36 @@ public enum DriveBufferService { /// Wolac PO tym, jak zapisy z odpiecia zdazyly trafic do kolejki - pozycje /// dolozone pozniej nie zostana ruszone. /// - /// Zwraca liczbe pozycji, ktorym przesunieto termin. + /// Zwraca liczbe pozycji, ktorym przesunieto termin, albo `nil`, gdy rclone + /// nie odpowiedzial na pytanie o kolejke. + /// + /// `nil` i `0` to DWIE ROZNE RZECZY i dlatego typ jest opcjonalny. Do 23 + /// wrzesnia 2026 obie sytuacje - "kolejka byla pusta" i "nie dostalismy + /// odpowiedzi" - wychodzily stad jako `0`, wiec `detach` milczal w logu + /// dokladnie w tym przypadku, w ktorym terminow NIE przesunieto i drenaz + /// mogl potrwac cale `writeBackSeconds` (dziesiec minut) zamiast chwili. + /// + /// Limit na samo listowanie kolejki podniesiony z 30 s do 60 s: ten sam plik + /// dokumentuje pomiar **36,7 s** dla LZEJSZEGO `vfs/stats` przy zapchanym + /// buforze (patrz `queueStats`), a `vfs/queue` wypisuje wtedy setki pozycji. + /// Przy 30 s odpowiedz nie zdazala przyjsc dokladnie wtedy, gdy przesuniecie + /// terminow bylo najbardziej potrzebne. + /// + /// Limit pojedynczego `queue-set-expiry` zostaje na 30 s CELOWO: tamto jedno + /// wywolanie decyduje o calej funkcji, a to jest jedno z setek i jego strata + /// kosztuje jedna pozycje. Przy kilkuset pozycjach sufit 60 s na sztuke + /// zamienilby odpiecie w operacje bez gornego ograniczenia czasu. @discardableResult - public static func expireQueuedUploads() async -> Int { + public static func expireQueuedUploads() async -> Int? { guard let result = try? await CMTooling.runRclone( - ["rc", "--url", rcAddress, "vfs/queue"], timeout: 30), + ["rc", "--url", rcAddress, "vfs/queue"], timeout: 60), result.succeeded, - let data = result.stdout.data(using: .utf8), - let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let queue = json["queue"] as? [[String: Any]] - else { return 0 } + let ids = parseQueueIDs(result.stdout) + else { return nil } var moved = 0 - for item in queue { - // Pozycji juz wysylanej nie da sie przyspieszyc - rclone to ignoruje, - // wiec nie marnujemy na nia wywolania. - if (item["uploading"] as? Bool) == true { continue } - guard let id = (item["id"] as? NSNumber)?.intValue else { continue } + for id in ids { // Duza liczba ujemna zamiast zera - tak opisuje to samo rclone. let response = try? await CMTooling.runRclone( ["rc", "--url", rcAddress, "vfs/queue-set-expiry", "id=\(id)", "expiry=-1000000000"], @@ -288,6 +405,24 @@ public enum DriveBufferService { return moved } + /// Czysta wersja: numery pozycji z odpowiedzi `vfs/queue`, ktorym da sie + /// przesunac termin. `nil` = odpowiedzi nie da sie odczytac, `[]` = kolejka + /// jest pusta. Wydzielone, zeby to rozroznienie dalo sie sprawdzic testem. + static func parseQueueIDs(_ raw: String) -> [Int]? { + guard + let data = raw.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let queue = json["queue"] as? [[String: Any]] + else { return nil } + + return queue.compactMap { item in + // Pozycji juz wysylanej nie da sie przyspieszyc - rclone to ignoruje, + // wiec nie marnujemy na nia wywolania. + if (item["uploading"] as? Bool) == true { return nil } + return (item["id"] as? NSNumber)?.intValue + } + } + /// Rozmiar bufora w bajtach - z obchodu katalogu. /// /// Uzywane TYLKO awaryjnie, gdy interfejs sterujacy rclone nie odpowiada. diff --git a/mac-app/Sources/CloudMachineCore/ImageProbe.swift b/mac-app/Sources/CloudMachineCore/ImageProbe.swift index 556868f..6677565 100644 --- a/mac-app/Sources/CloudMachineCore/ImageProbe.swift +++ b/mac-app/Sources/CloudMachineCore/ImageProbe.swift @@ -50,7 +50,27 @@ public enum ImageProbe { regularFiles: () throws -> [URL], readFirstByte: (URL) -> Int32? ) -> Verdict { - guard let files = try? regularFiles(), !files.isEmpty else { return .nothingToProbe } + let files: [URL] + do { + files = try regularFiles() + } catch { + // Listowanie tez potrafi pasc na martwym urzadzeniu - i to jest ten + // przypadek, ktory `try?` polykal. `--dir-cache-time` wynosi 5 minut: + // dopoki cache jest swiezy, `contentsOfDirectory` chodzi z pamieci jadra + // i dziala nawet po ENXIO (stad zdanie wyzej, ze listowanie "to nie jest + // test"). Po wygasnieciu cache to samo listowanie idzie po dane do + // rclone i pada tym samym ENXIO, co odczyt. Do 23 wrzesnia 2026 sonda + // mowila wtedy `.nothingToProbe`, `BackupImageService.attachment` + // mapowalo to na `.attached`, a agent `gdrive-attach` co 15 minut + // meldowal "Juz podpiete" - czyli dokladnie ta awaria, dla ktorej ta + // sonda powstala, wracala tylnymi drzwiami po piatej minucie. + if let code = deviceErrno(of: error), deviceErrors.contains(code) { + return .dead(errno: code) + } + // Blad bez rozpoznanego errno urzadzenia nie dowodzi niczego o wolumenie. + return .nothingToProbe + } + guard !files.isEmpty else { return .nothingToProbe } for file in files { guard let errno = readFirstByte(file) else { return .readable } if deviceErrors.contains(errno) { return .dead(errno: errno) } @@ -59,6 +79,26 @@ public enum ImageProbe { return .nothingToProbe } + /// Wyciaga surowe `errno` z bledu rzuconego przez listowanie katalogu. + /// + /// Foundation nie oddaje go wprost: `contentsOfDirectory` opakowuje blad + /// POSIX-a w `NSCocoaErrorDomain` (np. 256 `NSFileReadUnknownError`), + /// a oryginalne `errno` chowa pod `NSUnderlyingErrorKey` jako + /// `NSPOSIXErrorDomain`. Sprawdzamy trzy postacie, bo kazda z nich wychodzi + /// z innej warstwy: `POSIXError` z kodu wolajacego libc wprost, + /// `NSPOSIXErrorDomain` z cienkiego opakowania, i dopiero potem zagniezdzenie. + static func deviceErrno(of error: Error) -> Int32? { + if let posix = error as? POSIXError { return posix.code.rawValue } + let ns = error as NSError + if ns.domain == NSPOSIXErrorDomain { return Int32(ns.code) } + if let underlying = ns.userInfo[NSUnderlyingErrorKey] as? NSError, + underlying.domain == NSPOSIXErrorDomain + { + return Int32(underlying.code) + } + return nil + } + /// Sonda na zywym wolumenie. public static func probe(volume: URL) -> Verdict { probe( @@ -66,9 +106,12 @@ public enum ImageProbe { readFirstByte: { readFirstByteErrno(of: $0) }) } - /// Zwykle pliki w katalogu glownym. Listowanie chodzi z cache jadra, wiec - /// dziala takze na martwym urzadzeniu - to nie jest test, tylko lista - /// kandydatow do testu. + /// Zwykle pliki w katalogu glownym. Dopoki cache katalogu jest swiezy + /// (`--dir-cache-time 5m`), listowanie chodzi z pamieci i dziala takze na + /// martwym urzadzeniu - dlatego samo powodzenie listowania NIE jest dowodem + /// zycia, tylko lista kandydatow do testu. Po wygasnieciu cache to samo + /// listowanie pada ENXIO i wtedy jest juz dowodem smierci - obsluguje to + /// `probe`, nie ta funkcja. static func regularFiles(in volume: URL) throws -> [URL] { try FileManager.default.contentsOfDirectory( at: volume, includingPropertiesForKeys: [.isRegularFileKey], diff --git a/mac-app/Sources/CloudMachineCore/StatusLines.swift b/mac-app/Sources/CloudMachineCore/StatusLines.swift new file mode 100644 index 0000000..028799a --- /dev/null +++ b/mac-app/Sources/CloudMachineCore/StatusLines.swift @@ -0,0 +1,63 @@ +import Foundation + +/// Skladanie wierszy `drive-status`. Czyste funkcje, bo wiersz statusu jest +/// tym, co czlowiek CZYTA, pytajac "czy backup dziala" - a dotad nie dalo sie +/// go sprawdzic testem, bo powstawal w `print` wewnatrz polecenia CLI. +/// +/// Te wiersze lamaly sie juz dwa razy w ten sam sposob: przez zamiane "nie +/// wiem" na jakas wartosc. Raz przez podstawienie zer za brak odpowiedzi +/// rclone (stad `UploadState.queueUnknown`), raz przez `Optional(427)` po tym, +/// jak `BufferGuardService.freeGB()` slusznie przestal udawac, ze brak pomiaru +/// to zero. Dlatego kazda z ponizszych funkcji ma jawna galaz dla braku danych. +public enum StatusLines { + + /// Wiersz "Montowanie Drive". + /// + /// Trzeci stan jest osobny z tego samego powodu, co przy kolejce: `BRAK` + /// znaczy "sprawdzilem i nie ma", a to jest wniosek, ktorego przy nieudanym + /// odczycie tablicy montowan nikt nie ma prawa wyciagnac. + public static func mounted(_ state: Bool?) -> String { + switch state { + case .some(true): return "OK" + case .some(false): return "BRAK" + case .none: return "NIE WIADOMO - nie udalo sie odczytac tablicy montowan" + } + } + + /// Wiersz "Wolne na dysku". + /// + /// `nil` MUSI byc nazwany. Nie `Optional(427)` (bo to wyglada na usterke + /// programu, a nie na informacje) i nie podstawione zero (bo zero jest + /// KONKRETNA liczba, na ktorej dozorca bufora wstrzymuje Time Machine - + /// dokladnie ten blad naprawial drugi agent, zmieniajac typ na `Int?`). + /// Brak pomiaru znaczy, ze dozorca nie chroni juz dysku przed zapelnieniem, + /// wiec wiersz ma to powiedziec wprost. + public static func freeDisk(_ gb: Int?) -> String { + guard let gb else { + return "NIE ZMIERZONO - dozorca bufora nie wstrzyma Time Machine przed zapelnieniem dysku" + } + return "\(gb) GB" + } + + /// Wiersze o powiadomieniu, ktorego NIE udalo sie doreczyc. + /// + /// `HealthAlert.notify` zwraca od niedawna `Bool`, a `HealthAlert.report` + /// nie zamyka sprawy znacznikiem, dopoki powiadomienie nie doszlo - dzieki + /// temu alarm nie ginie juz po cichu na 12 godzin. Ale samo to nie wystarczy: + /// dopoki nikt tego nie WYPISUJE, czlowiek dowiaduje sie o niedoreczonym + /// alarmie tylko wtedy, gdy sam zajrzy do pliku stanu. Odmowa uprawnien do + /// powiadomien jest typowa dla procesu launchd, wiec to nie jest przypadek + /// teoretyczny. + /// + /// Pusta tablica = nie ma czego zglaszac. + public static func undeliveredAlert(_ failure: (at: Date, summary: String, reason: String)?) + -> [String] + { + guard let failure else { return [] } + return [ + "NIEDORECZONY ALARM: \(failure.summary)", + " z \(BackupHealth.stamp(failure.at)), powod: \(failure.reason)", + " Powiadomienie systemowe nie doszlo - ten alarm zobaczysz TYLKO tutaj.", + ] + } +} diff --git a/mac-app/Tests/CloudMachineAppTests/BackupImageServiceTests.swift b/mac-app/Tests/CloudMachineAppTests/BackupImageServiceTests.swift new file mode 100644 index 0000000..46177e6 --- /dev/null +++ b/mac-app/Tests/CloudMachineAppTests/BackupImageServiceTests.swift @@ -0,0 +1,219 @@ +import XCTest + +@testable import CloudMachineCore + +/// Operacje na obrazie backupu: wzajemne wykluczenie i te werdykty, ktore +/// wczesniej klamaly - "wszystko wyslane" przy porzuconych pasmach i "obraz +/// NIESPOJNY" po wyrwaniu urzadzenia spod `fsck`. +final class BackupImageServiceTests: XCTestCase { + + // MARK: - Wzajemne wykluczenie + + /// Blokada nazwana `BackupImageService.lockName` lezy w prawdziwym katalogu + /// logow, bo to tej samej blokady uzywaja `attach`/`detach`/`verify`/`create`. + /// Trzymamy ja przez ulamek sekundy i tylko po to, zeby sprawdzic, ze + /// operacje jej PRZESTRZEGAJA - zadna z nich nie dochodzi wtedy do `hdiutil`. + private func withHeldImageLock(_ body: () async -> Void) async throws { + let lock = CMLock(name: BackupImageService.lockName) + try XCTSkipUnless( + lock.acquire(), + "blokade '\(BackupImageService.lockName)' trzyma cos innego na tej maszynie") + defer { lock.release() } + await body() + } + + /// Sedno poprawki: do 23 wrzesnia 2026 `withCMLock` nie bylo wolane z ani + /// jednego miejsca w repo, wiec kazda z tych czterech operacji szla przy + /// trzymanej blokadzie tak samo jak bez niej. Test sprawdza nie tylko + /// `succeeded == false` (to akurat wychodzilo juz wczesniej, bo bufor nie + /// jest zamontowany), ale takze tresc i - co wazniejsze - `disposition`: + /// operacja ma rozpoznac zajetosc, a nie odbic sie od czegos innego po drodze. + func testOperacjeNaObrazieNieWchodzaSobieWDroge() async throws { + try await withHeldImageLock { + for (nazwa, wynik) in [ + ("create", await BackupImageService.create(sizeGB: 100)), + ("attach", await BackupImageService.attach()), + ("detach", await BackupImageService.detach()), + ("verify", await BackupImageService.verify()), + ] { + XCTAssertFalse(wynik.succeeded, "\(nazwa) przy zajetym obrazie nie moze meldowac sukcesu") + XCTAssertTrue( + wynik.message.contains("inna operacja na obrazie"), + "\(nazwa) ma powiedziec, ze NIE zrobilo nic - dostalem: \(wynik.message)") + XCTAssertEqual( + wynik.disposition, .skipped, + "\(nazwa): zajetosc ma byc rozpoznawalna po TYPIE wyniku, nie po tresci komunikatu") + } + } + } + + /// `attach-image` chodzi pod launchd co 900 s i jego kod wyjscia laduje w + /// `launchd-gdrive-attach.err.log`. Zajetosc obrazu nie moze sie tam + /// zapisywac jako awaria - ale prawdziwa porazka MUSI, inaczej schowalibysmy + /// realny blad za kodem 0. + func testZajeteToNieAwariaAlePorazkaNadalJestPorazka() { + XCTAssertEqual( + CMActionResult(succeeded: true, message: "Podpiete").disposition, .ok) + XCTAssertEqual( + CMActionResult(succeeded: false, message: "zajete", didNotRun: true).disposition, + .skipped) + XCTAssertEqual( + CMActionResult(succeeded: false, message: "Nie udalo sie podpiac obrazu").disposition, + .failed, + "brak `didNotRun` ma znaczyc realna porazke - domyslna wartosc nie moze uciszac bledow") + } + + // MARK: - Werdykt o odpieciu + + private func stats(queued: Int = 0, inProgress: Int = 0, errored: Int = 0) + -> DriveBufferService.QueueStats + { + DriveBufferService.QueueStats( + uploadsInProgress: inProgress, uploadsQueued: queued, files: 10, + erroredFiles: errored, bytesUsed: 1024, outOfSpace: false) + } + + func testPustaKolejkaBezBledowToWszystkoWyslane() { + let wynik = BackupImageService.detachVerdict(settled: stats()) + XCTAssertTrue(wynik.succeeded) + XCTAssertTrue(wynik.message.contains("wszystko wyslane")) + } + + /// Pasma porzucone przez rclone wypadaja z kolejki tak samo jak wyslane, + /// wiec sama pusta kolejka meldowala "Odpiete, wszystko wyslane na Google + /// Drive" przy danych istniejacych TYLKO na tym Macu. + func testPorzuconePasmaNieSaWyslane() { + let wynik = BackupImageService.detachVerdict(settled: stats(errored: 7)) + XCTAssertFalse(wynik.succeeded) + XCTAssertFalse(wynik.message.contains("wszystko wyslane")) + XCTAssertTrue(wynik.message.contains("7")) + } + + /// Brak odczytu to nie sukces - patrz `UploadState.queueUnknown`. + func testBrakOdczytuKolejkiToNieSukces() { + let wynik = BackupImageService.detachVerdict(settled: nil) + XCTAssertFalse(wynik.succeeded) + } + + // MARK: - Tablica montowan + + /// Dotad ta lista powstawala z parsowania wydruku `/sbin/mount` (`" on "` … + /// `" ("`) wewnatrz `unmountBrowsedSnapshots()`, wiec nie bylo do czego + /// podstawic probki. Migawka backupu montuje sie pod + /// `/Volumes/.timemachine//.backup/` i trzyma + /// urzadzenie obrazu zajete, przez co `hdiutil detach` odmawia. + func testWybieraTylkoPrzegladaneMigawkiBackupu() { + let punkty = [ + "/", + "/Volumes/CloudMachine", + "/Users/mbeczynski/.cloudmachine/drive", + "/Volumes/.timemachine/mac-studio/2026-09-23-101500.backup/CloudMachine", + "/Volumes/.timemachine/mac-studio/2026-09-22-231500.backup/CloudMachine", + // Pulapka: podobna nazwa, ale NIE pod katalogiem migawek. + "/Volumes/timemachine-kopia", + ] + XCTAssertEqual( + BackupImageService.browsedSnapshotMounts(punkty), + [ + "/Volumes/.timemachine/mac-studio/2026-09-23-101500.backup/CloudMachine", + "/Volumes/.timemachine/mac-studio/2026-09-22-231500.backup/CloudMachine", + ]) + } + + func testBrakMigawekToPustaLista() { + XCTAssertEqual(BackupImageService.browsedSnapshotMounts(["/", "/Volumes/CloudMachine"]), []) + } + + /// Tablica montowan czytana z jadra, nie z `/sbin/mount`. Sprawdzamy na + /// zywo, bo cala poprawka polega na tym, ze ten odczyt NIE uruchamia procesu + /// i NIE dotyka systemu plikow - czego atrapa by nie pokazala. + func testTablicaMontowanJestCzytelnaIZawieraKorzen() throws { + let punkty = try XCTUnwrap( + DriveBufferService.mountPoints(), "getmntinfo nie oddal tablicy montowan") + XCTAssertTrue(punkty.contains("/"), "kazdy system ma zamontowany korzen - dostalem: \(punkty)") + } + + // MARK: - Stan podpiecia + + /// `.unknown` to NIE `.detached`. `.detached` jest twierdzeniem + /// („sprawdzilem, nie ma"), a przy nieodczytanej tablicy montowan nie bylo + /// czego sprawdzic. Rozroznienie ma znaczenie, bo `attach` na podstawie + /// `.detached` robi `purgeStaleDevices()`, czyli `detach -force` na + /// urzadzeniu, ktore moze byc w tym czasie zywe. + func testNieznanyStanToNiePodpietyIleczNieodpiety() { + let nieznany = BackupImageService.Attachment.unknown + XCTAssertNotEqual(nieznany, .detached) + XCTAssertNotEqual(nieznany, .attached) + XCTAssertFalse( + nieznany.isUsable, + "na niewiadomej nie wolno polegac - Time Machine nie ma tu gwarancji celu") + } + + /// Kazdy stan ma dawac inne zdanie. Wspolny opis dla `.detached` + /// i `.unknown` przywrocilby zlanie, ktore ta poprawka usuwa - tyle ze + /// w warstwie, ktora czyta czlowiek. + func testKazdyStanPodpieciaMaWlasnyOpis() { + let opisy = [ + BackupImageService.describe(.attached), + BackupImageService.describe(.detached), + BackupImageService.describe(.dead(errno: ENXIO)), + BackupImageService.describe(.unknown), + ] + XCTAssertEqual(Set(opisy).count, opisy.count, "opisy sie powtarzaja: \(opisy)") + XCTAssertTrue(BackupImageService.describe(.unknown).contains("NIE WIADOMO")) + } + + // MARK: - Urzadzenie nadrzedne + + /// `fsck_apfs` dostaje partycje, `hdiutil info` wypisuje urzadzenie + /// nadrzedne - bez tego przeliczenia sprawdzenie "czy urzadzenie przezylo" + /// odpowiadaloby "nie" zawsze. + func testUrzadzenieNadrzedneZPartycji() { + XCTAssertEqual(BackupImageService.parentDevice(of: "/dev/disk7s1"), "/dev/disk7") + XCTAssertEqual(BackupImageService.parentDevice(of: "/dev/disk12s3"), "/dev/disk12") + XCTAssertEqual(BackupImageService.parentDevice(of: "/dev/disk7"), "/dev/disk7") + XCTAssertEqual(BackupImageService.parentDevice(of: "cos-innego"), "cos-innego") + } + + // MARK: - Obraz na zdalnym + + private let listing = """ + inne-dane/ + mac-studio.sparsebundle/ + """ + + func testWypisZdalnegoZObrazem() { + XCTAssertEqual( + BackupImageService.classifyRemoteListing(succeeded: true, stdout: listing, stderr: ""), + .present) + } + + func testPustyWypisZdalnegoToBrakObrazu() { + XCTAssertEqual( + BackupImageService.classifyRemoteListing(succeeded: true, stdout: "", stderr: ""), + .absent) + } + + /// Pierwsze uruchomienie: zdalnego katalogu jeszcze nie ma. To jest + /// ODPOWIEDZ ("nie ma tam nic"), a nie jej brak - inaczej straznik + /// blokowalby `create` dokladnie w tym jedynym przypadku, dla ktorego + /// `create` istnieje. + func testBrakKataloguNaZdalnymToBrakObrazu() { + XCTAssertEqual( + BackupImageService.classifyRemoteListing( + succeeded: false, stdout: "", + stderr: "2026/09/23 10:00:00 ERROR : : error listing: directory not found"), + .absent) + } + + /// Zerwane lacze to NIE dowod nieobecnosci obrazu. Tworzenie obrazu jest + /// nieodwracalne, wiec brak pewnosci musi je przerwac. + func testBrakLaczaToNieDowodNieobecnosci() { + let wynik = BackupImageService.classifyRemoteListing( + succeeded: false, stdout: "", + stderr: "Failed to lsf with 2 errors: couldn't connect to Google Drive") + guard case .unknown = wynik else { + return XCTFail("brak odpowiedzi ma byc .unknown, dostalem \(wynik)") + } + } +} diff --git a/mac-app/Tests/CloudMachineAppTests/CMLockTests.swift b/mac-app/Tests/CloudMachineAppTests/CMLockTests.swift new file mode 100644 index 0000000..1670c55 --- /dev/null +++ b/mac-app/Tests/CloudMachineAppTests/CMLockTests.swift @@ -0,0 +1,79 @@ +import XCTest + +@testable import CloudMachineCore + +/// Blokada wzajemnego wykluczenia. Testy chodza po PRAWDZIWYM katalogu - +/// atrapa systemu plikow nie sprawdzilaby tu niczego, bo caly mechanizm to +/// atomowosc `mkdir` i to, czy zapisany PID da sie odczytac z powrotem. +/// Katalog jest wlasny i tymczasowy, zeby test nie dotykal blokad +/// produkcyjnych w `~/Library/Logs/CloudMachine`. +final class CMLockTests: XCTestCase { + + private var dir: URL! + + override func setUpWithError() throws { + dir = FileManager.default.temporaryDirectory + .appendingPathComponent("CMLockTests-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + } + + override func tearDownWithError() throws { + try? FileManager.default.removeItem(at: dir) + } + + private var lockPath: URL { dir.appendingPathComponent("image.lock.d") } + + func testDrugaInstancjaNieDostajeTrzymanejBlokady() { + let first = CMLock(directory: lockPath) + XCTAssertTrue(first.acquire()) + defer { first.release() } + + let second = CMLock(directory: lockPath) + XCTAssertFalse( + second.acquire(), + "blokade trzyma zywy proces (ten test) - druga instancja nie ma prawa jej dostac") + } + + func testPoZwolnieniuBlokadaJestZnowDoWziecia() { + let first = CMLock(directory: lockPath) + XCTAssertTrue(first.acquire()) + first.release() + XCTAssertFalse( + FileManager.default.fileExists(atPath: lockPath.path), + "zwolnienie ma usunac katalog blokady, nie tylko zapomniec o nim") + + let second = CMLock(directory: lockPath) + XCTAssertTrue(second.acquire()) + second.release() + } + + /// Dokladnie ten stan, ktory zostawia proces ubity miedzy `createDirectory` + /// a zapisem PID-a: katalog blokady jest, pliku `pid` nie ma. Konkurent ma + /// prawo go przejac - bo nikt zywy sie do niego nie przyznaje. + func testKatalogBezPidJestOsieroconyIDaSiePrzejac() throws { + try FileManager.default.createDirectory(at: lockPath, withIntermediateDirectories: false) + + let lock = CMLock(directory: lockPath) + XCTAssertTrue(lock.acquire()) + defer { lock.release() } + + let pid = try String( + contentsOf: lockPath.appendingPathComponent("pid"), encoding: .utf8) + XCTAssertEqual( + pid.split(separator: "\n").first.map(String.init), "\(getpid())", + "po przejeciu w pliku ma stac NASZ PID - inaczej nastepny konkurent uzna blokade za wolna") + } + + /// Blokada po martwym procesie nie moze zostac na zawsze - watchdog + /// przerwany SIGKILL-em nie zdazy wywolac `release()`. + func testBlokadaPoMartwymPidzieJestPrzejmowana() throws { + try FileManager.default.createDirectory(at: lockPath, withIntermediateDirectories: false) + // PID, ktorego na pewno nie ma: `kill(pid, 0)` odmawia z ESRCH. + try "999999\n".write( + to: lockPath.appendingPathComponent("pid"), atomically: true, encoding: .utf8) + + let lock = CMLock(directory: lockPath) + XCTAssertTrue(lock.acquire()) + lock.release() + } +} diff --git a/mac-app/Tests/CloudMachineAppTests/ImageProbeTests.swift b/mac-app/Tests/CloudMachineAppTests/ImageProbeTests.swift index 3bf8247..1973a7a 100644 --- a/mac-app/Tests/CloudMachineAppTests/ImageProbeTests.swift +++ b/mac-app/Tests/CloudMachineAppTests/ImageProbeTests.swift @@ -46,12 +46,51 @@ final class ImageProbeTests: XCTestCase { XCTAssertEqual(verdict, .nothingToProbe) } - func testNieczytelneListowanieToBrakProbki() { + // MARK: - Listowanie tez umie pasc + + /// ZMIANA wzgledem poprzedniej wersji tego testu, ktora nazywala sie + /// `testNieczytelneListowanieToBrakProbki` i sprawdzala, ze KAZDY blad + /// listowania daje `.nothingToProbe`. Kodowala stan, ktory okazal sie + /// dziura: `BackupImageService.attachment` mapuje `.nothingToProbe` na + /// `.attached`, wiec martwy obraz uchodzil za zywy, a agent `gdrive-attach` + /// nie podpinal go przez godziny. Rozstrzyga teraz ZRODLO bledu, nie sam + /// fakt bledu: blad bez rozpoznanego errno urzadzenia nadal nie dowodzi + /// niczego o wolumenie i zostaje `.nothingToProbe`. + func testListowanieZBledemBezErrnoUrzadzeniaToBrakProbki() { struct Boom: Error {} let verdict = ImageProbe.probe(regularFiles: { throw Boom() }, readFirstByte: { _ in nil }) XCTAssertEqual(verdict, .nothingToProbe) } + /// Po wygasnieciu `--dir-cache-time 5m` listowanie przestaje chodzic z cache + /// jadra i pada tym samym ENXIO, co odczyt. Wtedy jest juz dowodem smierci. + func testENXIONaListowaniuZnaczyMartwy() { + let verdict = ImageProbe.probe( + regularFiles: { throw POSIXError(.ENXIO) }, readFirstByte: { _ in nil }) + XCTAssertEqual(verdict, .dead(errno: ENXIO)) + } + + /// Tak wyglada ten sam blad, gdy rzuca go Foundation: `contentsOfDirectory` + /// opakowuje errno w `NSCocoaErrorDomain` i chowa oryginal pod + /// `NSUnderlyingErrorKey`. Sonda musi rozpoznac obie postacie, bo zywa + /// sciezka (`regularFiles(in:)`) chodzi wlasnie przez Foundation. + func testENXIOOpakowaneDoNSErrorTezZnaczyMartwy() { + let underlying = NSError(domain: NSPOSIXErrorDomain, code: Int(ENXIO)) + let cocoa = NSError( + domain: NSCocoaErrorDomain, code: 256, + userInfo: [NSUnderlyingErrorKey: underlying]) + let verdict = ImageProbe.probe( + regularFiles: { throw cocoa }, readFirstByte: { _ in nil }) + XCTAssertEqual(verdict, .dead(errno: ENXIO)) + } + + /// Brak uprawnien do katalogu to wlasciwosc katalogu, nie awaria wolumenu. + func testEACCESNaListowaniuToBrakProbki() { + let verdict = ImageProbe.probe( + regularFiles: { throw POSIXError(.EACCES) }, readFirstByte: { _ in nil }) + XCTAssertEqual(verdict, .nothingToProbe) + } + // MARK: - Zywa sciezka na prawdziwym katalogu func testPrawdziwyOdczytNaKataloguTymczasowym() throws { diff --git a/mac-app/Tests/CloudMachineAppTests/QueueStatsParsingTests.swift b/mac-app/Tests/CloudMachineAppTests/QueueStatsParsingTests.swift new file mode 100644 index 0000000..44f6a1f --- /dev/null +++ b/mac-app/Tests/CloudMachineAppTests/QueueStatsParsingTests.swift @@ -0,0 +1,133 @@ +import XCTest + +@testable import CloudMachineCore + +/// Parsowanie odpowiedzi interfejsu sterujacego rclone. Kazdy przypadek tutaj +/// to inny sposob, na jaki "nie wiem" zamienialo sie w "zero" - a "zero" +/// czytalo sie na ekranie jako "Wszystko wyslane na Google Drive". +final class QueueStatsParsingTests: XCTestCase { + + // MARK: - vfs/stats + + private let pelnaOdpowiedz = """ + { + "diskCache": { + "bytesUsed": 12345678, + "erroredFiles": 3, + "files": 40, + "outOfSpace": false, + "uploadsInProgress": 2, + "uploadsQueued": 386 + }, + "metadataCache": { "dirs": 1, "files": 40 } + } + """ + + func testPelnaOdpowiedzDajeLiczniki() throws { + let stats = try XCTUnwrap(DriveBufferService.parseQueueStats(pelnaOdpowiedz)) + XCTAssertEqual(stats.uploadsInProgress, 2) + XCTAssertEqual(stats.uploadsQueued, 386) + XCTAssertEqual(stats.files, 40) + XCTAssertEqual(stats.erroredFiles, 3) + XCTAssertEqual(stats.bytesUsed, 12_345_678) + XCTAssertFalse(stats.outOfSpace) + } + + /// Sedno poprawki. Odpowiedz bez sekcji `diskCache` wpadala na `?? json`, + /// gdzie zadnego z licznikow nie ma, a brakujacy klucz dawal 0 - wychodzil + /// z tego komplet zer, czyli `queueKnown == true` i "Wszystko wyslane". + func testOdpowiedzBezDiskCacheToBrakWiedzy() { + let bezSekcji = """ + { "metadataCache": { "dirs": 1, "files": 40 } } + """ + XCTAssertNil(DriveBufferService.parseQueueStats(bezSekcji)) + } + + /// Ten sam blad o jeden poziom nizej: sekcja jest, ale licznika w niej nie ma. + func testBrakujacyLicznikToBrakWiedzy() { + let bezBledow = """ + { + "diskCache": { + "bytesUsed": 1, "files": 40, + "uploadsInProgress": 0, "uploadsQueued": 0 + } + } + """ + XCTAssertNil( + DriveBufferService.parseQueueStats(bezBledow), + "brak erroredFiles nie znaczy 'zero bledow'") + } + + func testPustaOdpowiedzToBrakWiedzy() { + XCTAssertNil(DriveBufferService.parseQueueStats("")) + XCTAssertNil(DriveBufferService.parseQueueStats("connection refused")) + } + + /// `outOfSpace` to jedyne pole, ktorego brak wolno nadrobic domyslna + /// wartoscia - to flaga, a nie licznik. + func testBrakFlagiOutOfSpaceNiePsujeOdczytu() throws { + let bezFlagi = """ + { + "diskCache": { + "bytesUsed": 1, "erroredFiles": 0, "files": 2, + "uploadsInProgress": 0, "uploadsQueued": 0 + } + } + """ + let stats = try XCTUnwrap(DriveBufferService.parseQueueStats(bezFlagi)) + XCTAssertFalse(stats.outOfSpace) + } + + // MARK: - Cisza kontra bezczynnosc + + func testPustaKolejkaZPorzuconymiPasmamiNieJestCisza() { + let stats = DriveBufferService.QueueStats( + uploadsInProgress: 0, uploadsQueued: 0, files: 40, + erroredFiles: 5, bytesUsed: 1024, outOfSpace: false) + XCTAssertTrue(stats.isIdle, "rclone faktycznie nic nie robi - hdiutil moze dzialac") + XCTAssertFalse( + stats.isQuiet, + "ale 5 pasm nie dolecialo na Dysk, wiec 'wszystko wyslane' byloby klamstwem") + } + + func testPustaKolejkaBezBledowJestCisza() { + let stats = DriveBufferService.QueueStats( + uploadsInProgress: 0, uploadsQueued: 0, files: 40, + erroredFiles: 0, bytesUsed: 1024, outOfSpace: false) + XCTAssertTrue(stats.isIdle) + XCTAssertTrue(stats.isQuiet) + } + + func testTrwajacaWysylkaToAniCiszaAniBezczynnosc() { + let stats = DriveBufferService.QueueStats( + uploadsInProgress: 1, uploadsQueued: 12, files: 40, + erroredFiles: 0, bytesUsed: 1024, outOfSpace: false) + XCTAssertFalse(stats.isIdle) + XCTAssertFalse(stats.isQuiet) + } + + // MARK: - vfs/queue + + func testKolejkaPomijaPozycjeJuzWysylane() throws { + let odpowiedz = """ + { + "queue": [ + { "id": 1, "name": "bands/0001", "uploading": false, "expiry": 480.2 }, + { "id": 2, "name": "bands/0002", "uploading": true, "expiry": -1 }, + { "id": 3, "name": "bands/0003", "uploading": false, "expiry": 512.9 } + ] + } + """ + let ids = try XCTUnwrap(DriveBufferService.parseQueueIDs(odpowiedz)) + XCTAssertEqual(ids, [1, 3], "pozycji juz wysylanej rclone i tak nie przyspieszy") + } + + /// Pusta kolejka i brak odpowiedzi to DWIE ROZNE RZECZY - obie wychodzily + /// wczesniej z `expireQueuedUploads()` jako `0`, wiec log milczal dokladnie + /// wtedy, gdy terminow NIE przesunieto i drenaz mogl potrwac 10 minut. + func testPustaKolejkaToNieToSamoCoBrakOdpowiedzi() { + XCTAssertEqual(DriveBufferService.parseQueueIDs(#"{ "queue": [] }"#), []) + XCTAssertNil(DriveBufferService.parseQueueIDs("")) + XCTAssertNil(DriveBufferService.parseQueueIDs("{}")) + } +} diff --git a/mac-app/Tests/CloudMachineAppTests/StatusLinesTests.swift b/mac-app/Tests/CloudMachineAppTests/StatusLinesTests.swift new file mode 100644 index 0000000..5594360 --- /dev/null +++ b/mac-app/Tests/CloudMachineAppTests/StatusLinesTests.swift @@ -0,0 +1,80 @@ +import XCTest + +@testable import CloudMachineCore + +/// Wiersze `drive-status`. To jest tekst, ktory czlowiek CZYTA, pytajac "czy +/// backup dziala" - a psul sie dotad dokladnie tam, gdzie brak danych +/// zamieniano na jakas wartosc. +final class StatusLinesTests: XCTestCase { + + // MARK: - Montowanie + + func testZamontowaneINiezamontowane() { + XCTAssertEqual(StatusLines.mounted(true), "OK") + XCTAssertEqual(StatusLines.mounted(false), "BRAK") + } + + /// „BRAK" znaczy „sprawdzilem i nie ma". Przy nieudanym odczycie tablicy + /// montowan nikt nie ma prawa wyciagnac tego wniosku - a na tej odpowiedzi + /// stoi decyzja o podpieciu obrazu. + func testNieodczytanaTablicaToNieBrakMontowania() { + let linia = StatusLines.mounted(nil) + XCTAssertNotEqual(linia, "BRAK") + XCTAssertNotEqual(linia, "OK") + XCTAssertTrue(linia.contains("NIE WIADOMO"), "dostalem: \(linia)") + } + + // MARK: - Wolne miejsce + + func testZmierzoneWolneMiejsceJestLiczba() { + XCTAssertEqual(StatusLines.freeDisk(427), "427 GB") + } + + /// Regresja z 23 wrzesnia 2026: po zmianie `BufferGuardService.freeGB()` na + /// `Int?` wiersz wypisywal `Wolne na dysku: Optional(427) GB`. Kompilator + /// zglaszal to ostrzezeniem, nie bledem, wiec ani build, ani testy tego nie + /// zatrzymaly. + func testBrakPomiaruNieWypisujeOptional() { + let linia = StatusLines.freeDisk(nil) + XCTAssertFalse(linia.contains("Optional"), "dostalem: \(linia)") + XCTAssertFalse(linia.contains("nil"), "dostalem: \(linia)") + } + + /// Zero to KONKRETNA liczba, na ktorej dozorca wstrzymuje Time Machine - + /// podstawienie go za brak pomiaru bylo pierwotnym bledem, ktory drugi agent + /// naprawial zmiana typu. Wiersz nie moze go przywrocic tylnymi drzwiami. + func testBrakPomiaruToNieZero() { + XCTAssertNotEqual(StatusLines.freeDisk(nil), StatusLines.freeDisk(0)) + XCTAssertEqual(StatusLines.freeDisk(0), "0 GB") + } + + func testBrakPomiaruJestNAZWANY() { + let linia = StatusLines.freeDisk(nil) + XCTAssertTrue(linia.contains("NIE ZMIERZONO"), "dostalem: \(linia)") + XCTAssertTrue( + linia.contains("dozorca"), + "wiersz ma powiedziec, CO z tego wynika - ze dysk nie jest chroniony. Dostalem: \(linia)") + } + + // MARK: - Niedoreczony alarm + + func testBrakNiedoreczonegoAlarmuNicNieWypisuje() { + XCTAssertEqual(StatusLines.undeliveredAlert(nil), []) + } + + /// Sens poprawki w `HealthAlert`: nieudane powiadomienie ma dac sie ZOBACZYC. + /// Dopoki `drive-status` o tym milczal, alarm istnial tylko w pliku stanu. + func testNiedoreczonyAlarmJestWidoczny() { + let kiedy = Date(timeIntervalSince1970: 1_790_000_000) + let linie = StatusLines.undeliveredAlert( + (at: kiedy, summary: "Backup nie powstal od 30 h", reason: "osascript kod 1: brak uprawnien")) + + let tekst = linie.joined(separator: "\n") + XCTAssertTrue(tekst.contains("NIEDORECZONY ALARM"), tekst) + XCTAssertTrue(tekst.contains("Backup nie powstal od 30 h"), "tresc alarmu ma byc widoczna") + XCTAssertTrue(tekst.contains("brak uprawnien"), "powod niedoreczenia ma byc widoczny") + XCTAssertTrue( + tekst.contains(BackupHealth.stamp(kiedy)), + "bez daty nie wiadomo, czy alarm jest swiezy, czy sprzed tygodnia") + } +} From 744b695b7adc4621630014591d0b07cf3075ca03 Mon Sep 17 00:00:00 2001 From: Marcin Beczynski Date: Wed, 23 Sep 2026 16:51:35 +0200 Subject: [PATCH 3/3] fix(czujka): nadzor przestaje wieszac sie i milczec na awarii, ktora ma wykryc `ProcessRunner.run` ma `timeout` domyslnie `nil`, a `TimeMachineStatus` wolalo `tmutil` BEZ limitu we wszystkich siedmiu miejscach. Przy martwym montowaniu FUSE-T (incydent ENXIO z 22.09) `tmutil destinationinfo` blokuje sie w nieprzerywalnym I/O, `BackupHealth.currentReport` nigdy nie wraca, a launchd ze `StartInterval` NIE odpala drugiej instancji, dopoki zyje pierwsza - czujka milkla NA STALE. Naglowek `BackupHealth.swift` deklarowal dokladnie odwrotnosc tego stanu i zostal poprawiony. Limit 90 s na wspolnym wejsciu, dobrany NIE pod "ile zwykle trwa" (ulamek sekundy), tylko z zapasem dwoch rzedow wielkosci - bo 120 s dobrane pod czysty start juz raz zabraklo o 10 s po awarii. Realne maksimum czekania to 100 s (SIGTERM -> SIGKILL -> poddanie sie) przy oknie czujki 1800 s. Brak odpowiedzi nie jest juz `false`: doszly `runningState() -> Bool?` i `destinationReading()`, a `nil` daje OSOBNY problem "tmutil nie odpowiada - nie wiadomo, gdzie idzie backup", a nie "Time Machine nie wskazuje na CloudMachine" (ten drugi wysylalby czlowieka do przestawiania celu, ktory jest ustawiony dobrze). Dozorca uznawal pauze za wykonana, nie patrzac na wynik `tmutil stopbackup` (`_ = try? await ...`, a przejscie stanu BEZWARUNKOWE). Gdy polecenie padlo - brak uprawnien albo przekroczony limit - stan szedl na `.pausedForBuffer`, a `stopBackup()` wola sie tylko przy ZMIANIE stanu, wiec nigdy nie ponawial. Time Machine pisal dalej, dozorca czekal na drenaz, dysk zapelnial sie do konca, a w logu stalo "PAUZA ... czekam na wysylke". `.pausedForQuota` (brak miejsca na Google Drive) dzielila galaz wznowienia z `.pausedForBuffer`, a warunek patrzyl na bufor i wolne miejsce LOKALNEGO dysku - o miejscu na Dysku nie wiedzial nic. `hitStorageQuota()` czyta tylko 30 minut logu rclone, wiec po zatrzymaniu Time Machine wpisy sie starzaly i pauza spadala sama. Wyjscie wymaga teraz POZYTYWNEGO dowodu z `remoteQuota()`; brak odpowiedzi PODTRZYMUJE pauze. `freeGB()` zwracalo 0 przy nieudanym `statfs`. Warunek pauzy byl wtedy spelniony natychmiast, a warunek wznowienia NIGDY - dozorca wstrzymywal Time Machine i nie wznawial go juz nigdy. Teraz `Int?`; osobny test pilnuje, ze ZMIERZONE zero nadal pauzuje, zeby ta poprawka nie przeszla w druga skrajnosc. Panel nie pytal ANI RAZU o date ostatniej UDANEJ kopii - `grep BackupHealth` po `CloudMachineApp/` nie dawal zadnego trafienia. Montowanie stoi, obraz podpiety, kolejka pusta, Time Machine od dwoch dni nie dokonczyl kopii - i swiecilo "Sprawny / Gotowe". To jest awaria opisana w naglowku `BackupHealth.swift` jako najgrozniejsza w tym systemie. Nieudane `osascript` bylo wyrzucane przez `_ = try?`, a `report()` i tak zapisywalo `lastSummary` i `lastAlertAt` - alarm ginal po cichu na 12 h. Przypomnienie i tak nie dzialalo, bo `summary` zawiera wiek awarii ("od 3 h"), wiec zmienialo sie CO GODZINE i powiadomienie wracalo przy kazdym przebiegu. `connect` chodzilo na rclone z Homebrew, ktorego wg README na tej maszynie nie ma, podczas gdy `isConfigured` w tym samym pliku idzie przez wlasna binarke. Po dolozeniu `Attachment.unknown` wyrazenie `attached: attachment != .detached` zaczelo dawac `true` - czujka MILCZALABY o stanie, ktorego nie zna. `mounted` i `attached` sa teraz trojstanowe, a brak odczytu daje wlasny problem. ZNANY OSAD, rozstrzygniety swiadomie: `CloudMachineController.swift:124` nadal czyta `isMounted`, wiec panel przy nieodczytanej tablicy montowan pokaze "Bufor nie dziala". Po przebudowie na `getmntinfo` ten odczyt idzie z pamieci jadra i praktycznie nie ma jak zawiesc, skutkiem jest falszywy ALARM (nie cisza), a panel odswieza sie co 10 s. Trojstanowosc wymagalaby zmiany `UploadState.from(mounted:)` i dwunastu testow kodujacych dzisiejszy kontrakt - nie warte tego dla galezi, do ktorej realnie nie da sie dojsc. Co-Authored-By: Claude Opus 5 (1M context) --- .../CloudMachineApp/Models/AppStatus.swift | 71 +++- .../Services/CloudMachineController.swift | 39 ++ .../CloudMachineApp/Views/DashboardView.swift | 25 +- .../CloudMachineCore/BackupHealth.swift | 172 +++++++-- .../CloudMachineCore/BufferGuardService.swift | 330 +++++++++++++++-- .../CloudMachineCore/HealthAlert.swift | 138 ++++++- .../CloudMachineCore/RemoteConfigurer.swift | 31 +- .../CloudMachineCore/TimeMachineStatus.swift | 146 ++++++-- .../AppStatusHealthTests.swift | 60 +++ .../BufferGuardDecisionTests.swift | 343 ++++++++++++++++++ .../HealthAlertTests.swift | 189 ++++++++++ .../TimeMachineTimeoutTests.swift | 167 +++++++++ 12 files changed, 1594 insertions(+), 117 deletions(-) create mode 100644 mac-app/Tests/CloudMachineAppTests/BufferGuardDecisionTests.swift create mode 100644 mac-app/Tests/CloudMachineAppTests/HealthAlertTests.swift create mode 100644 mac-app/Tests/CloudMachineAppTests/TimeMachineTimeoutTests.swift diff --git a/mac-app/Sources/CloudMachineApp/Models/AppStatus.swift b/mac-app/Sources/CloudMachineApp/Models/AppStatus.swift index 21ff035..9100dc6 100644 --- a/mac-app/Sources/CloudMachineApp/Models/AppStatus.swift +++ b/mac-app/Sources/CloudMachineApp/Models/AppStatus.swift @@ -22,7 +22,10 @@ struct BufferStatus: Equatable { var mounted: Bool = false var imageAttached: Bool = false var sizeGB: Int = 0 - var freeDiskGB: Int = 0 + /// `nil` = pomiaru NIE BYLO (statfs zawiodl), a nie "zero gigabajtow" - + /// patrz `BufferGuardService.freeGB()`. To samo rozroznienie, co + /// `queueKnown` nizej. + var freeDiskGB: Int? /// Ile plikow czeka na wyslanie. Ta liczba jest wazniejsza od rozmiaru /// bufora: jesli rosnie i nie wraca do zera miedzy backupami, wysylka nie /// nadaza za zapisem. @@ -63,6 +66,52 @@ struct BufferStatus: Equatable { } } +/// Czy cykl backupu NADAL dziala - mierzone data ostatniej UDANEJ kopii. +/// +/// Do 23.09.2026 interfejs nie zadawal tego pytania ani razu: `grep -rn +/// "BackupHealth" Sources/CloudMachineApp/` nie dawal ani jednego trafienia. +/// Panel liczyl zdrowie wylacznie ze stanu URZADZEN - montowanie, obraz, cel +/// Time Machine, kolejka - czyli ze stanu CHWILOWEGO. Awaria opisana +/// w naglowku `BackupHealth` jako najgrozniejsza wyglada dokladnie odwrotnie: +/// wszystko zamontowane, obraz podpiety, kolejka pusta, a Time Machine od +/// dwoch dni nie dokonczyl kopii. Panel swiecil wtedy "Sprawny / Gotowe". +struct BackupCycleStatus: Equatable { + /// Czy udalo sie w ogole odczytac preferencje Time Machine. + /// + /// Domyslnie `false` i to jest wazniejsze, niz wyglada - tak samo jak przy + /// `queueKnown`: swiezo utworzony stan nie jest pomiarem, a brak Pelnego + /// dostepu do dysku (najczestsza przyczyna nieczytelnego pliku preferencji) + /// nie moze uchodzic za brak problemu. + var known: Bool = false + /// Data ostatniej ZAKONCZONEJ kopii. `nil` = nie ma ani jednej. + var lastSuccess: Date? + /// Gotowe zdania z `BackupHealth.Report` - do pokazania bez tlumaczenia. + var problems: [String] = [] + /// Kiedy ostatnio pytalismy (czujka chodzi rzadziej niz odswiezanie panelu). + var checkedAt: Date? + + func age(now: Date = Date()) -> TimeInterval? { + lastSuccess.map { now.timeIntervalSince($0) } + } + + /// Czy ostatnia UDANA kopia jest dostatecznie swieza. + /// + /// Brak odczytu i brak kopii daja `false` - jedno i drugie znaczy, ze nikt + /// nie potwierdzil, ze backup dziala, a zielony znaczek jest wlasnie takim + /// potwierdzeniem. + func isFresh(now: Date = Date(), maxAgeHours: Double = BackupHealth.maxAgeHours) -> Bool { + guard known, let age = age(now: now) else { return false } + return age <= maxAgeHours * 3600 + } + + /// Wiek slowami, do wiersza w panelu. + func ageText(now: Date = Date()) -> String { + guard known else { return "nie sprawdzono" } + guard let age = age(now: now) else { return "ani jednej" } + return "\(BackupHealth.formatAge(age)) temu" + } +} + struct LastRunResult: Equatable { var succeeded: Bool var message: String @@ -88,6 +137,9 @@ final class AppStatus: ObservableObject { @Published var dependencyState: DependencyState = .unknown @Published var remoteConfigured: Bool = false @Published var buffer = BufferStatus() + /// Odpowiedz na pytanie "kiedy ostatnio powstala KOPIA" - jedyna miara, + /// ktora rosnie wylacznie przy sukcesie. + @Published var backupCycle = BackupCycleStatus() @Published var timeMachineState: TimeMachineState = .unknown @Published var backupProgress: BackupProgressInfo? @Published var lastAction: LastRunResult? @@ -116,6 +168,14 @@ final class AppStatus: ObservableObject { let upload = buffer.uploadState if !upload.isNominal { return upload.headline } if backupProgress != nil { return "Backup w toku" } + // Stan urzadzen moze byc nienaganny, a kopii moze nie byc od dwoch dni. + // To zdanie musi paść PRZED "Gotowe", bo inaczej naglowek zaprzecza + // znaczkowi obok (healthy = false, a napis "Gotowe"). + if !backupCycle.isFresh() { + guard backupCycle.known else { return "Nie wiadomo, kiedy powstała ostatnia kopia" } + guard let age = backupCycle.age() else { return "Nie ma ani jednej ukończonej kopii" } + return "Brak ukończonej kopii od \(BackupHealth.formatAge(age))" + } if upload.isMovingData { return upload.headline } return "Gotowe" } @@ -126,9 +186,16 @@ final class AppStatus: ObservableObject { /// pokazywal zielony znaczek i "Gotowe", podczas gdy czesc pasm obrazu nigdy /// nie doleciala na Dysk - a taka kopia moze sie nie otworzyc. Zepsute /// wygladalo dokladnie tak samo jak sprawne. + /// + /// UWAGA DRUGA, z 23.09.2026: `backupCycle` MUSI tu byc z tego samego + /// powodu. Wszystkie pozostale warunki opisuja stan URZADZEN w tej chwili + /// i kazdy z nich moze byc spelniony, gdy od dwoch dni nie powstala zadna + /// kopia. Zielony znaczek ma znaczyc "dane sa bezpieczne", a to wynika + /// wylacznie z tego, ze kopia POWSTALA - nie z tego, ze dysk jest podpiety. var healthy: Bool { guard case .ready = dependencyState, remoteConfigured, buffer.mounted, buffer.imageAttached, - case .registered = timeMachineState, buffer.uploadState.isNominal + case .registered = timeMachineState, buffer.uploadState.isNominal, + backupCycle.isFresh() else { return false } return true } diff --git a/mac-app/Sources/CloudMachineApp/Services/CloudMachineController.swift b/mac-app/Sources/CloudMachineApp/Services/CloudMachineController.swift index 36a5164..5c9b127 100644 --- a/mac-app/Sources/CloudMachineApp/Services/CloudMachineController.swift +++ b/mac-app/Sources/CloudMachineApp/Services/CloudMachineController.swift @@ -17,6 +17,16 @@ final class CloudMachineController: ObservableObject { private var lastBytesDone: Double? private var lastBytesSampledAt: Date? + /// Co ile odswiezamy odpowiedz na pytanie "kiedy ostatnio powstala kopia". + /// + /// Rzadziej niz reszta panelu (10 s) i celowo: `BackupHealth.currentReport()` + /// czyta plik preferencji Time Machine, pyta rclone o pojemnosc Dysku + /// i tmutil o cel backupu - to sekundy pracy, nie mikrosekundy. Cykl + /// backupu jest godzinowy, wiec odpowiedz sprzed pieciu minut jest tak samo + /// dobra jak sprzed pieciu sekund. + private static let backupCycleInterval: TimeInterval = 300 + private var backupCycleCheckedAt: Date? + // MARK: - Cykl odswiezania func startAutoRefresh(interval: TimeInterval = 10) { @@ -40,9 +50,38 @@ final class CloudMachineController: ObservableObject { await refreshBuffer() await refreshTimeMachine() await refreshProgress() + await refreshBackupCycle() status.lastRefresh = Date() } + /// Wiek ostatniej UDANEJ kopii. + /// + /// Panel nie zadawal tego pytania ani razu (patrz `BackupCycleStatus`), + /// wiec awaria "wszystko podpiete, a kopii nie ma od dwoch dni" wygladala + /// w nim dokladnie tak samo jak sprawny system. Zrodlo jest to samo, z + /// ktorego korzysta czujka `backup-health` - jedno zrodlo prawdy, zeby + /// panel i czujka nie mogly twierdzic czegos innego o tym samym. + private func refreshBackupCycle(force: Bool = false) async { + if !force, let at = backupCycleCheckedAt, + Date().timeIntervalSince(at) < Self.backupCycleInterval + { + return + } + backupCycleCheckedAt = Date() + + let report = await BackupHealth.currentReport() + var cycle = BackupCycleStatus() + // "Odczytano" znaczy tu: plik preferencji dalo sie przeczytac. Gdy sie + // nie da (najczesciej brak Pelnego dostepu do dysku), `currentReport` + // zglasza to jako problem i NIE podaje zadnej daty - wtedy `known` + // zostaje `false`, a nie udaje, ze kopii po prostu nie ma. + cycle.known = report.preferencesReadable + cycle.lastSuccess = report.lastSuccess + cycle.problems = report.problems.map(\.summary) + cycle.checkedAt = Date() + status.backupCycle = cycle + } + // MARK: - Poszczegolne odczyty private func refreshDependencies() async { diff --git a/mac-app/Sources/CloudMachineApp/Views/DashboardView.swift b/mac-app/Sources/CloudMachineApp/Views/DashboardView.swift index 84684f8..8526e95 100644 --- a/mac-app/Sources/CloudMachineApp/Views/DashboardView.swift +++ b/mac-app/Sources/CloudMachineApp/Views/DashboardView.swift @@ -200,7 +200,12 @@ struct DashboardView: View { StatCard( title: "Rozmiar Bufora", value: "\(controller.status.buffer.sizeGB) GB", - subtitle: "Wolne na dysku: \(controller.status.buffer.freeDiskGB) GB", + // Brak pomiaru ma wygladac inaczej niz liczba - patrz + // `BufferGuardService.freeGB()`. Samo wstawienie opcjonalnej wartosci + // do tekstu dalo "Wolne na dysku: Optional(427) GB" i kompilator + // zglaszal to TYLKO jako ostrzezenie, wiec zaden test by tego nie zlapal. + subtitle: + "Wolne na dysku: \(controller.status.buffer.freeDiskGB.map { "\($0) GB" } ?? "nie zmierzono")", systemImage: "internaldrive.fill", iconColor: RenaCodeTheme.colorCyan ) @@ -445,10 +450,24 @@ struct DashboardView: View { Divider().background(RenaCodeTheme.borderGlass) + // Brak pomiaru MUSI wygladac inaczej niz "0 GB" - patrz + // `BufferGuardService.freeGB()`. Nieudany statfs to awaria dozorcy + // bufora, a nie informacja o pustym dysku. row( "Wolne miejsce na lokalnym wolumenie", - "\(controller.status.buffer.freeDiskGB) GB", - ok: controller.status.buffer.freeDiskGB > 80 + controller.status.buffer.freeDiskGB.map { "\($0) GB" } ?? "nie zmierzono", + ok: (controller.status.buffer.freeDiskGB ?? 0) > 80 + ) + + Divider().background(RenaCodeTheme.borderGlass) + + // Jedyny wiersz, ktory odpowiada na pytanie "czy kopia POWSTALA". + // Wszystkie pozostale opisuja stan urzadzen i moga byc zielone, gdy + // Time Machine od dwoch dni nie dokonczyl backupu. + row( + "Ostatnia ukończona kopia", + controller.status.backupCycle.ageText(), + ok: controller.status.backupCycle.isFresh() ) Divider().background(RenaCodeTheme.borderGlass) diff --git a/mac-app/Sources/CloudMachineCore/BackupHealth.swift b/mac-app/Sources/CloudMachineCore/BackupHealth.swift index 5dc5267..3a476ba 100644 --- a/mac-app/Sources/CloudMachineCore/BackupHealth.swift +++ b/mac-app/Sources/CloudMachineCore/BackupHealth.swift @@ -19,6 +19,14 @@ import Foundation /// `tmutil latestbackup` montuje migawke na wolumenie lezacym na Google Drive /// i przy chorym montowaniu potrafi wisiec w nieprzerywalnym I/O. Czujka, ktora /// zawiesza sie dokladnie wtedy, gdy ma zaalarmowac, jest gorsza niz jej brak. +/// +/// To zdanie bylo do 23.09.2026 deklaracja, a nie faktem: `currentReport()` +/// wola `tmutil destinationinfo` (po cel Time Machine), a ten odczyt siega +/// na montowanie i BEZ LIMITU CZASU wisial w nieprzerywalnym I/O dokladnie +/// tak, jak `latestbackup`, przed ktorym ten komentarz ostrzega. Od tej daty +/// kazde wywolanie tmutil ma twardy limit (`TimeMachineStatus.commandTimeout`), +/// a brak odpowiedzi jest zglaszany jako AWARIA - nie jako "cel przestawiony" +/// i nie jako cisza. public enum BackupHealth { public static let preferencesPath = "/Library/Preferences/com.apple.TimeMachine.plist" @@ -47,12 +55,23 @@ public enum BackupHealth { public var problems: [Problem] public var lastSuccess: Date? public var lastAttempt: Date? + /// Czy licznik udanych kopii w ogole dalo sie ODCZYTAC. + /// + /// Bez tego pola `lastSuccess == nil` znaczylo dwie zupelnie rozne rzeczy: + /// "Time Machine nie zrobil ani jednej kopii" i "nie mamy dostepu do + /// pliku, wiec nic nie wiemy". Kto czyta ten raport (np. panel GUI), musi + /// je rozroznic, zeby nie pokazac braku wiedzy jako faktu. + public var preferencesReadable: Bool public var healthy: Bool { problems.isEmpty } - public init(problems: [Problem], lastSuccess: Date?, lastAttempt: Date?) { + public init( + problems: [Problem], lastSuccess: Date?, lastAttempt: Date?, + preferencesReadable: Bool = true + ) { self.problems = problems self.lastSuccess = lastSuccess self.lastAttempt = lastAttempt + self.preferencesReadable = preferencesReadable } } @@ -92,9 +111,9 @@ public enum BackupHealth { lastAttempt: Date?, result: Int?, now: Date, - mounted: Bool, - attached: Bool, - destinationRegistered: Bool, + mounted: Bool?, + attached: Bool?, + destinationRegistered: Bool?, erroredFiles: Int, outOfSpace: Bool, queueReadable: Bool, @@ -107,32 +126,82 @@ public enum BackupHealth { // Kolejnosc od przyczyny do skutku: jesli montowanie lezy, wiek kopii // i tak bedzie rosl, ale to montowanie trzeba naprawic. - if !mounted { + // + // `mounted` i `attached` sa TROJSTANOWE z tego samego powodu, co + // `destinationRegistered` nizej: odczyt tablicy montowan moze sie nie + // udac, a wtedy nie wiemy ani ze jest, ani ze nie ma. Zlanie tego + // w `Bool` konczylo sie dwojako i oba sposoby byly zle - `?? false` + // dawalo alarm o odmontowanym Dysku, ktory moze byc zamontowany, + // a `!= .detached` dawalo CISZE o obrazie, o ktorym nie wiemy nic. + switch mounted { + case .some(true): + break + case .some(false): problems.append( Problem( summary: "Montowanie Google Drive nie dziala", detail: "Bez niego obraz backupu jest nieosiagalny i Time Machine nie ma gdzie pisac.")) + case .none: + problems.append( + Problem( + summary: "Nie wiadomo, czy montowanie Google Drive dziala", + detail: + "Nie udalo sie odczytac tablicy montowan. To nie znaczy, ze Dysk jest odmontowany - znaczy, ze nikt tego nie sprawdzil. Bez tej odpowiedzi nie da sie stwierdzic, czy kopie maja gdzie powstawac." + )) } - if !attached { + + switch attached { + case .some(true): + if let errno = imageDeadErrno { + // Podpiety, ale martwy - stan, ktory do 22 wrz 2026 nie istnial dla + // zadnego czujnika i przez to trwal 15 godzin. Patrz `ImageProbe`. + problems.append( + Problem( + summary: "Obraz backupu jest podpiety, ale MARTWY (errno \(errno))", + detail: + "Urzadzenie obrazu przestalo oddawac dane - Time Machine widzi to jako odlaczony dysk. " + + "Naprawa: cloudmachine-agent attach-image (odpina na sile i podpina na nowo).")) + } + case .some(false): problems.append( Problem( summary: "Obraz backupu nie jest podpiety", detail: "Time Machine nie widzi celu \(BackupImageService.targetPath.path).")) - } else if let errno = imageDeadErrno { - // Podpiety, ale martwy - stan, ktory do 22 wrz 2026 nie istnial dla - // zadnego czujnika i przez to trwal 15 godzin. Patrz `ImageProbe`. + case .none: + // TA cisza. Do 23.09.2026 wolajacy przekazywal tu `attachment != + // .detached`, wiec nowy przypadek `.unknown` ("tablicy montowan nie + // udalo sie odczytac") wpadal na `true` - czyli "podpiety". Czujka, + // ktorej JEDYNYM zadaniem jest nie twierdzic rzeczy, ktorych nie wie, + // milczala o stanie, ktorego nie znala. Komunikat musi byc INNY niz + // przy realnym odpieciu: "nie jest podpiety" wysyla czlowieka do + // podpinania obrazu, ktory moze byc podpiety poprawnie. problems.append( Problem( - summary: "Obraz backupu jest podpiety, ale MARTWY (errno \(errno))", + summary: "Nie wiadomo, czy obraz backupu jest podpiety", detail: - "Urzadzenie obrazu przestalo oddawac dane - Time Machine widzi to jako odlaczony dysk. " - + "Naprawa: cloudmachine-agent attach-image (odpina na sile i podpina na nowo).")) + "Nie udalo sie odczytac tablicy montowan, wiec stan obrazu \(BackupImageService.targetPath.path) jest NIEZNANY. Nie podpinaj go na oslepe - najpierw sprawdz, czy `mount` w ogole odpowiada (przy martwym montowaniu FUSE-T potrafi wisiec)." + )) } - if !destinationRegistered { + // `nil` to NIE to samo co `false`. Od 23.09.2026 `tmutil` ma limit czasu + // (patrz `TimeMachineStatus.commandTimeout`), wiec przy martwym montowaniu + // czujka wraca z brakiem odpowiedzi zamiast wisiec. Brak odpowiedzi jest + // AWARIA - ale inna niz przestawiony cel, i musi brzmiec inaczej, zeby nie + // wyslac czlowieka do przestawiania czegos, co jest ustawione dobrze. + switch destinationRegistered { + case .some(true): + break + case .some(false): problems.append( Problem( summary: "Time Machine nie wskazuje na CloudMachine", detail: "Cel backupu zostal przestawiony albo wyrejestrowany - kopie nie powstaja.")) + case .none: + problems.append( + Problem( + summary: "tmutil nie odpowiada - nie wiadomo, gdzie idzie backup", + detail: + "Odczyt celu Time Machine nie wrocil w \(Int(TimeMachineStatus.commandTimeout)) s. Tak zachowuje sie tmutil zablokowany na martwym montowaniu Google Drive. Naprawa: cloudmachine-agent attach-image, a gdy to nie pomoze - restart agenta gdrive-buffer." + )) } // TO jest licznik, ktory rosnie wylacznie przy sukcesie. @@ -190,7 +259,10 @@ public enum BackupHealth { summary: "Bufor pelny samymi niewyslanymi danymi", detail: "rclone nie ma juz czego usunac z bufora - wysylka nie nadaza albo stoi.")) } - if !queueReadable && mounted { + // `mounted == true`, nie `mounted != false`: gdy montowania nie ma ALBO + // nie wiadomo, czy jest, mowia o tym juz twardsze komunikaty wyzej, a + // drugi komunikat o tym samym tylko rozmywa ten pierwszy. + if !queueReadable && mounted == true { problems.append( Problem( summary: "Interfejs sterujacy rclone nie odpowiada", @@ -259,7 +331,7 @@ public enum BackupHealth { detail: "\(preferencesFile) jest nieczytelny - najczesciej brak Pelnego dostepu do dysku. Bez tego pliku NIE WIADOMO, kiedy ostatnio powstala kopia, wiec traktujemy to jak awarie, a nie jak brak problemu." ) - ], lastSuccess: nil, lastAttempt: nil) + ], lastSuccess: nil, lastAttempt: nil, preferencesReadable: false) } let (lastSuccess, lastAttempt, result) = dates( @@ -269,16 +341,44 @@ public enum BackupHealth { let attachment = BackupImageService.attachment var deadErrno: Int32? if case .dead(let errno) = attachment { deadErrno = errno } - let registered = - await TimeMachineStatus.currentDestinationMountPoint() == BackupImageService.targetPath.path - return evaluate( + // Trzy stany, tak samo jak przy celu Time Machine nizej. + // + // Wyliczamy je z `attachment`, a nie drugim wywolaniem + // `BackupImageService.attachedState()` - ten sam odczyt tablicy montowan + // dal juz `deadErrno` powyzej, a dwa osobne odczyty moglyby sie + // rozjechac i dac raport opisujacy dwie rozne chwile. + let attached: Bool? + switch attachment { + // `.dead` to nadal PODPIETY obraz - tylko martwy, i to osobny problem + // zglaszany przez `imageDeadErrno`. + case .attached, .dead: attached = true + case .detached: attached = false + case .unknown: attached = nil + } + // Trzy stany, nie dwa: `noAnswer` (zawieszony tmutil) nie ma prawa + // udawac "cel przestawiony" - patrz `evaluate`. + let registered: Bool? + switch await TimeMachineStatus.destinationReading() { + case .mountPoint(let path): registered = (path == BackupImageService.targetPath.path) + case .none: registered = false + case .noAnswer: registered = nil + } + + // Pomiar wolnego miejsca moze sie NIE UDAC (statfs zwraca blad) i wtedy + // `freeGB()` oddaje `nil`, a nie zmyslone zero - patrz komentarz przy niej. + let localFree = BufferGuardService.freeGB() + + var report = evaluate( lastSuccess: lastSuccess, lastAttempt: lastAttempt, result: result, now: now, - mounted: DriveBufferService.isMounted, - attached: attachment != .detached, + // `mountedState()`, a NIE `isMounted` - to drugie jest + // `mountedState() ?? false`, czyli zamienia "nie wiem" w "nie dziala" + // i kaze czlowiekowi naprawiac montowanie, ktore moze byc sprawne. + mounted: DriveBufferService.mountedState(), + attached: attached, destinationRegistered: registered, erroredFiles: stats?.erroredFiles ?? 0, outOfSpace: stats?.outOfSpace ?? false, @@ -287,16 +387,44 @@ public enum BackupHealth { // nie odpowiada, mowia o tym juz twardsze sygnaly powyzej, a drugi // komunikat o tym samym tylko rozmywa ten pierwszy. driveFreeBytes: (await DriveBufferService.remoteQuota())?.free, - localFreeGB: BufferGuardService.freeGB(), + localFreeGB: localFree, imageDeadErrno: deadErrno, maxAgeHours: maxAgeHours) + + report.problems.append(contentsOf: unmeasuredLocalDiskProblems(localFreeGB: localFree)) + return report + } + + /// Problem zglaszany, gdy pomiaru wolnego miejsca NIE DA SIE wykonac. + /// + /// `evaluate` traktuje `localFreeGB: nil` jako "nie pytano" (taki jest jego + /// kontrakt od poczatku i opiera sie na nim kilkanascie testow), ale + /// `currentReport` WIE, ze pytalo i nie wyszlo. To osobna awaria: dozorca + /// bufora podejmuje decyzje o wstrzymaniu Time Machine wlasnie na tej + /// liczbie, wiec gdy jej nie ma, nie chroni juz dysku przed zapelnieniem. + /// + /// Wydzielone z `currentReport()` WYLACZNIE po to, zeby dalo sie sprawdzic + /// testem: `currentReport()` dotyka rclone, tmutil i hdiutil, wiec ta galaz + /// bylaby inaczej niesprawdzalna - a galaz "nie wiem", ktorej nikt nie + /// sprawdzil, to dokladnie ten rodzaj martwego kodu, o ktory pytal przeglad + /// (kompilator ostrzegal wczesniej, ze `Int` porownywany do `nil` zawsze + /// daje falsz, czyli ze galaz jest martwa). + static func unmeasuredLocalDiskProblems(localFreeGB: Int?) -> [Problem] { + guard localFreeGB == nil else { return [] } + return [ + Problem( + summary: "Nie da sie zmierzyc wolnego miejsca na dysku Maca", + detail: + "statfs('/System/Volumes/Data') zwrocil blad. Dozorca bufora nie wstrzyma wtedy Time Machine przed zapelnieniem dysku, bo nie zna liczby, na ktorej opiera ta decyzje." + ) + ] } // MARK: - Formatowanie /// Wiek slowami. Minuty ponizej dwoch godzin - inaczej przy niskim progu /// komunikat brzmi "Brak udanej kopii od 0 h", co nie znaczy nic. - static func formatAge(_ seconds: TimeInterval) -> String { + public static func formatAge(_ seconds: TimeInterval) -> String { let hours = Int(seconds / 3600) if hours < 2 { return "\(Int(seconds / 60)) min" } if hours < 48 { return "\(hours) h" } diff --git a/mac-app/Sources/CloudMachineCore/BufferGuardService.swift b/mac-app/Sources/CloudMachineCore/BufferGuardService.swift index b2f89d2..76ea53c 100644 --- a/mac-app/Sources/CloudMachineCore/BufferGuardService.swift +++ b/mac-app/Sources/CloudMachineCore/BufferGuardService.swift @@ -23,6 +23,15 @@ public actor BufferGuardService { public var lowGB: Int /// Ponizej tylu GB wolnych na dysku wstrzymujemy niezaleznie od bufora. public var minFreeGB: Int + /// Ponizej tylu GB wolnych NA DYSKU GOOGLE nie wolno zdjac pauzy + /// zalozonej z powodu braku miejsca na Dysku. + /// + /// Ta sama liczba, ktora `BackupHealth` uwaza za prog ostrzegawczy dla + /// Dysku - jedno zrodlo prawdy. Przy przyroscie rzedu 600 MB na cykl + /// godzinowy 30 GB to okolo dwoch tygodni zapasu, czyli tyle, zeby + /// wznowiony backup mial gdzie sie zmiescic, a nie wrocil pod sciane + /// w kolejnej godzinie. + public var minDriveFreeGB: Int /// Progi wyliczane z rozmiaru bufora, nie wpisane z palca. /// @@ -37,11 +46,13 @@ public actor BufferGuardService { public init( highGB: Int = DriveBufferService.cacheSizeGB * 3 / 2, lowGB: Int = DriveBufferService.cacheSizeGB * 2 / 5, - minFreeGB: Int = 80 + minFreeGB: Int = 80, + minDriveFreeGB: Int = BackupHealth.driveFreeWarningGB ) { self.highGB = highGB self.lowGB = lowGB self.minFreeGB = minFreeGB + self.minDriveFreeGB = minDriveFreeGB } } @@ -62,19 +73,100 @@ public actor BufferGuardService { public struct Snapshot: Sendable { public var state: State public var bufferGB: Int - public var freeGB: Int - public var backupRunning: Bool + /// `nil` = pomiaru NIE BYLO (statfs zawiodl), a nie "zero gigabajtow". + public var freeGB: Int? + /// `nil` = tmutil nie odpowiedzial, czyli nie wiadomo. + public var backupRunning: Bool? public var percent: Double } + /// Zrodla pomiarow i sterowania. + /// + /// Domyslne (`live`) czytaja prawdziwy system. Test podstawia wlasne i dzieki + /// temu przechodzi CALA sciezke decyzji dozorcy - pauze, zmiane stanu, + /// wznowienie - bez tmutil, rclone i prawdziwego backupu. Ten sam wzorzec, + /// co `preferencesFile` w `BackupHealth.currentReport`: nie da sie inaczej + /// wstrzyknac ZNANEJ ZLEJ probki, a wlasnie w decyzjach dozorcy (a nie + /// w parsowaniu) siedzialy tu ciche awarie. + public struct Probes: Sendable { + public var queueStats: @Sendable () async -> DriveBufferService.QueueStats? + /// `nil` = nie zmierzono. + public var freeGB: @Sendable () -> Int? + /// `nil` = tmutil nie odpowiedzial. + public var backupRunning: @Sendable () async -> Bool? + public var progressPercent: @Sendable () async -> Double + public var hitStorageQuota: @Sendable () -> Bool + public var uploadStalled: @Sendable () -> Bool + /// Wolne bajty na Dysku Google. `nil` = NIE WIADOMO (rclone nie + /// odpowiedzial) - i to nie jest zgoda na wznowienie. + public var driveFreeBytes: @Sendable () async -> UInt64? + /// `true` TYLKO gdy tmutil potwierdzil wykonanie polecenia. + public var stopBackup: @Sendable () async -> Bool + public var startBackup: @Sendable () async -> Bool + public var reportStall: @Sendable (Bool) async -> Void + /// Wydzielone, zeby test nie dopisywal swoich zmyslonych "PAUZA (prog)" + /// do produkcyjnego `cloudmachine.log` - ten log sluzy do diagnozy + /// prawdziwych awarii i nie moze zawierac zdarzen, ktore sie nie zdarzyly. + public var log: @Sendable (String) -> Void + + public init( + queueStats: @escaping @Sendable () async -> DriveBufferService.QueueStats?, + freeGB: @escaping @Sendable () -> Int?, + backupRunning: @escaping @Sendable () async -> Bool?, + progressPercent: @escaping @Sendable () async -> Double, + hitStorageQuota: @escaping @Sendable () -> Bool, + uploadStalled: @escaping @Sendable () -> Bool, + driveFreeBytes: @escaping @Sendable () async -> UInt64?, + stopBackup: @escaping @Sendable () async -> Bool, + startBackup: @escaping @Sendable () async -> Bool, + reportStall: @escaping @Sendable (Bool) async -> Void, + log: @escaping @Sendable (String) -> Void + ) { + self.queueStats = queueStats + self.freeGB = freeGB + self.backupRunning = backupRunning + self.progressPercent = progressPercent + self.hitStorageQuota = hitStorageQuota + self.uploadStalled = uploadStalled + self.driveFreeBytes = driveFreeBytes + self.stopBackup = stopBackup + self.startBackup = startBackup + self.reportStall = reportStall + self.log = log + } + + public static let live = Probes( + queueStats: { await DriveBufferService.queueStats() }, + freeGB: { BufferGuardService.freeGB() }, + backupRunning: { await TimeMachineStatus.runningState() }, + progressPercent: { (await TimeMachineStatus.currentProgress())?.percent ?? 0 }, + hitStorageQuota: { DriveBufferService.hitStorageQuota() }, + uploadStalled: { DriveBufferService.uploadStalled() }, + driveFreeBytes: { await DriveBufferService.remoteQuota()?.free }, + stopBackup: { await BufferGuardService.tmutil("stopbackup") }, + startBackup: { await BufferGuardService.tmutil("startbackup") }, + reportStall: { await BufferGuardService.reportUploadStall($0) }, + log: { CMLogger.log($0) }) + } + private let thresholds: Thresholds + private let probes: Probes private var state: State = .idle /// Czy od ostatniego przejscia w czuwanie widzielismy dzialajacy backup - /// zeby zameldowac zakonczenie raz, a nie przy kazdym tyknieciu. private var sawBackupRunning = false + /// Czy poprzedni krok juz zglosil nieudane wstrzymanie - zeby przy awarii + /// trwajacej godzinami log nie urosl o linie co 30 sekund, ale zeby samo + /// zdarzenie NIE zniknelo (patrz `EdgeTriggeredLog`, ten sam powod). + private var reportedStopFailure = false + /// To samo dla braku pomiaru wolnego miejsca. + private var reportedFreeUnknown = false + /// To samo dla pauzy z powodu braku miejsca na Dysku trzymanej bez dowodu. + private var reportedQuotaHold = false - public init(thresholds: Thresholds = Thresholds()) { + public init(thresholds: Thresholds = Thresholds(), probes: Probes = .live) { self.thresholds = thresholds + self.probes = probes } // MARK: - Pomiary @@ -93,33 +185,70 @@ public actor BufferGuardService { /// system dopiero MOGLBY zwolnic. Na tej maszynie pokazywala 1202 GB, gdy /// `df` mowilo 427 GB. Dozorca ma wstrzymywac backup, zanim dysk sie zapelni, /// wiec musi patrzec na miejsce faktycznie dostepne teraz, a nie na obietnice. - public static func freeGB() -> Int { + /// + /// `nil` znaczy "NIE ZMIERZONO", i to nie jest kosmetyka. Wczesniej nieudany + /// `statfs` zwracal `0`, czyli liczbe - a wtedy warunek pauzy + /// (`free <= minFreeGB`) byl spelniony natychmiast, warunek wznowienia + /// (`free > minFreeGB`) NIGDY, i dozorca wstrzymywal Time Machine na zawsze. + /// Rownolegle czujka meldowala "Konczy sie miejsce na dysku Maca (0 GB)" - + /// alarm o stanie, ktorego nikt nie zmierzyl. To samo rozroznienie, ktore + /// `BufferStatus.queueKnown` wprowadzil juz dla kolejki wysylki. + public static func freeGB() -> Int? { var stats = statfs() - guard statfs("/System/Volumes/Data", &stats) == 0 else { return 0 } + guard statfs("/System/Volumes/Data", &stats) == 0 else { return nil } let available = UInt64(stats.f_bavail) * UInt64(stats.f_bsize) return Int(available / 1_073_741_824) } + /// Czy wolno wznowic Time Machine, patrzac WYLACZNIE na pomiary lokalne. + /// + /// Czysta funkcja - decyzja da sie sprawdzic testem bez dysku i bez tmutil. + /// `free == nil` nie wznawia: brak pomiaru to nie jest dowod, ze miejsce + /// jest. Wznowienie sprawdza wolne miejsce TAK SAMO jak pauza, bo pauza + /// chroniaca dysk nie moze byc odwolywana przez warunek, ktory o dysku nic + /// nie wie. + static func canResumeLocally(buffer: Int, free: Int?, thresholds: Thresholds) -> Bool { + guard let free else { return false } + return buffer <= thresholds.lowGB && free > thresholds.minFreeGB + } + + /// Czy na Dysku Google jest DOWIEDZIONE miejsce na dalsza prace. + /// + /// `nil` (rclone nie odpowiedzial) to NIE jest zgoda - patrz `step()`. + static func driveHasRoom(freeBytes: UInt64?, minGB: Int) -> Bool { + guard let freeBytes else { return false } + return freeBytes / 1_073_741_824 >= UInt64(max(0, minGB)) + } + // MARK: - Jeden krok /// Wykonuje jeden krok nadzoru i zwraca stan. Wydzielone z petli, zeby dalo /// sie sprawdzic decyzje testem bez czekania w czasie rzeczywistym. @discardableResult public func step() async -> Snapshot { - let stats = await DriveBufferService.queueStats() + let stats = await probes.queueStats() let buffer = Self.bufferGB(stats: stats) - let free = Self.freeGB() - let running = await TimeMachineStatus.isRunning() - let percent = (await TimeMachineStatus.currentProgress())?.percent ?? 0 + let free = probes.freeGB() + let running = await probes.backupRunning() + let percent = await probes.progressPercent() + + // Nieudany pomiar wolnego miejsca NIE moze przejsc po cichu: od tej liczby + // zalezy jedyna ochrona dysku przed zapelnieniem, a bez niej dozorca nie + // wstrzyma Time Machine (i slusznie - nie zgaduje). Czlowiek musi o tym + // wiedziec z logu, a nie z pelnego dysku. + if free == nil, !reportedFreeUnknown { + probes.log( + "UWAGA: nie da sie zmierzyc wolnego miejsca na dysku (statfs zawiodl) - dozorca nie wstrzyma Time Machine z powodu dysku, bo nie ma na czym oprzec decyzji." + ) + } + reportedFreeUnknown = (free == nil) // Brak MIEJSCA na Dysku ma pierwszenstwo i nie minie sam: dopoki // uzytkownik czegos nie skasuje, wysylka nie ruszy, a dalsza praca // Time Machine tylko pompuje bufor. - if DriveBufferService.hitStorageQuota() { + if probes.hitStorageQuota() { if state != .pausedForQuota { - CMLogger.log("Brak miejsca na Google Drive - wstrzymuje Time Machine") - await stopBackup() - state = .pausedForQuota + await pause(reason: "Brak miejsca na Google Drive", into: .pausedForQuota) } return Snapshot( state: state, bufferGB: buffer, freeGB: free, backupRunning: running, percent: percent) @@ -137,12 +266,14 @@ public actor BufferGuardService { // Zglaszamy natomiast ZAWSZE, bo zator z 12 wrzesnia przeszedl zupelnie // niezauwazony - trzy godziny bez wysylki i ani jednego sladu poza // surowym logiem rclone. - await reportUploadStall(DriveBufferService.uploadStalled()) + await probes.reportStall(probes.uploadStalled()) switch state { case .idle: - if running { - CMLogger.log("Backup ruszyl - nadzoruje bufor") + // Tylko JAWNE "tak". `nil` (tmutil nie odpowiedzial) zostawia stan bez + // zmiany - nie zaczynamy nadzoru nad czyms, o czym nic nie wiemy. + if running == true { + probes.log("Backup ruszyl - nadzoruje bufor") sawBackupRunning = true state = .running } @@ -150,33 +281,64 @@ public actor BufferGuardService { case .running: // outOfSpace pochodzi od rclone i znaczy "nie mam juz gdzie odlozyc // danych" - to twardszy fakt niz jakikolwiek nasz prog. - if stats?.outOfSpace == true || buffer >= thresholds.highGB || free <= thresholds.minFreeGB { + // + // Brak pomiaru wolnego miejsca (`free == nil`) NIE wstrzymuje backupu. + // Wczesniej nieudany `statfs` dawal zero, zero spelnialo warunek pauzy + // i dozorca wstrzymywal Time Machine na podstawie liczby, ktorej nigdy + // nie zmierzyl - a potem nie umial go wznowic, bo warunek wznowienia + // przy zerze nie zachodzi nigdy. + let lowDisk = free.map { $0 <= thresholds.minFreeGB } ?? false + if stats?.outOfSpace == true || buffer >= thresholds.highGB || lowDisk { let why = stats?.outOfSpace == true ? "rclone zglasza brak miejsca w buforze" : "prog" - CMLogger.log("PAUZA (\(why)): bufor \(buffer) GB, wolne \(free) GB - czekam na wysylke") - await stopBackup() - state = .pausedForBuffer - } else if !running { + await pause( + reason: "PAUZA (\(why)): bufor \(buffer) GB, wolne \(describe(free)) - czekam na wysylke", + into: .pausedForBuffer) + } else if running == false { if sawBackupRunning { - CMLogger.log("Time Machine zakonczyl. Bufor \(buffer) GB") + probes.log("Time Machine zakonczyl. Bufor \(buffer) GB") sawBackupRunning = false } state = .idle } - case .pausedForBuffer, .pausedForQuota: + case .pausedForBuffer: // Wznawiamy dopiero, gdy wysylka faktycznie nadgonila - inaczej // wpadlibysmy w oscylacje start/stop przy progu. + if Self.canResumeLocally(buffer: buffer, free: free, thresholds: thresholds) { + await resume(bufferGB: buffer, freeGB: free) + } + + case .pausedForQuota: + // Pauza z powodu BRAKU MIEJSCA na Dysku wymaga do zdjecia POZYTYWNEGO + // dowodu, ze miejsce jest. To nie jest ostroznosc na zapas: // - // Wolne miejsce sprawdzamy TAK SAMO jak przy pauzie. Wczesniej warunek - // wznowienia patrzyl wylacznie na bufor: dozorca, ktory wstrzymal backup - // z powodu konczacego sie dysku, wznawial go, gdy tylko bufor zszedl - // ponizej progu - czyli przy dysku nadal pelnym. Pauza chroniaca dysk - // nie moze byc odwolywana przez warunek, ktory o dysku nic nie wie. - if buffer <= thresholds.lowGB && free > thresholds.minFreeGB { - CMLogger.log("WZNOWIENIE: bufor \(buffer) GB, wolne \(free) GB") - await startBackup() - state = .running + // `hitStorageQuota()` czyta wpisy z ostatnich 30 minut logu rclone. + // Po wstrzymaniu Time Machine nowe pasma przestaja powstawac, rclone + // przestaje probowac wysylac, wpisy sie starzeja i funkcja zaczyna + // zwracac `false` - mimo ze na Dysku jak nie bylo miejsca, tak nie ma. + // Przy spokojnym buforze (a po pauzie bufor sie wlasnie oprozni) + // wspolny warunek wznowienia byl wtedy spelniony natychmiast: dozorca + // puszczal Time Machine, ten pisal kolejne pasma, ktorych nie ma jak + // wyslac, i cala pauza konczyla sie po kilkudziesieciu minutach bez + // zmiany czegokolwiek po stronie Dysku. `UploadState` mowi wprost, ze + // ten stan NIE mija sam. + guard Self.canResumeLocally(buffer: buffer, free: free, thresholds: thresholds) else { break } + let driveFree = await probes.driveFreeBytes() + guard Self.driveHasRoom(freeBytes: driveFree, minGB: thresholds.minDriveFreeGB) else { + // Brak odpowiedzi od rclone PODTRZYMUJE pauze - "nie wiem" nigdy nie + // jest zgoda na wznowienie czegos, co zapelnia dysk. + if !reportedQuotaHold { + let ile = + driveFree.map { "\($0 / 1_073_741_824) GB" } ?? "nie wiadomo (rclone nie odpowiedzial)" + probes.log( + "PAUZA (brak miejsca na Dysku) utrzymana: wolne na Google Drive \(ile), wymagane co najmniej \(thresholds.minDriveFreeGB) GB." + ) + reportedQuotaHold = true + } + break } + reportedQuotaHold = false + await resume(bufferGB: buffer, freeGB: free) } return Snapshot( @@ -185,34 +347,122 @@ public actor BufferGuardService { public func currentState() -> State { state } + /// Wolne miejsce do logu. Brak pomiaru MUSI wygladac inaczej niz zero, + /// inaczej linia w logu klamie tak samo, jak klamala sama liczba. + private func describe(_ freeGB: Int?) -> String { + freeGB.map { "\($0) GB" } ?? "nie zmierzono" + } + // MARK: - Sterowanie Time Machine + /// Wstrzymuje Time Machine i przechodzi w `into` TYLKO gdy sie udalo. + /// + /// TO jest ta poprawka. Wczesniej wynik `tmutil stopbackup` byl wyrzucany + /// (`_ = try? await ...`), a stan zmienial sie BEZWARUNKOWO. Gdy polecenie + /// padalo - brak uprawnien, przekroczony limit czasu - dozorca uznawal pauze + /// za wykonana, a poniewaz `stopBackup()` wola sie wylacznie przy ZMIANIE + /// stanu, nie ponawial jej nigdy. Time Machine pisal dalej, dozorca czekal + /// na drenaz, dysk zapelnial sie do konca, a w logu stalo "PAUZA ... czekam + /// na wysylke". + /// + /// Przy niepowodzeniu stan zostaje na `.running`, wiec warunek pauzy + /// (nadal spelniony) wyzwoli kolejna probe przy nastepnym tyknieciu - czyli + /// za 30 sekund, bez zadnego dodatkowego mechanizmu ponawiania. + private func pause(reason: String, into paused: State) async { + probes.log(reason) + if await probes.stopBackup() { + state = paused + reportedStopFailure = false + return + } + if !reportedStopFailure { + probes.log( + "NIE UDALO SIE wstrzymac Time Machine (tmutil stopbackup). Stan zostaje na '\(state.rawValue)', ponawiam przy kazdym kolejnym sprawdzeniu. Time Machine PISZE DALEJ - dysk moze sie zapelnic." + ) + reportedStopFailure = true + } + } + + /// Wznawia Time Machine. + /// + /// Asymetria wzgledem `pause` jest celowa. Nieudane `stopbackup` grozi + /// zapelnieniem dysku, wiec nie wolno udawac, ze pauza zaszla. Nieudane + /// `startbackup` nie grozi niczym: Time Machine i tak ruszy sam w swoim + /// cyklu godzinowym, a `startbackup` jest tylko przyspieszeniem tego. + /// Gdybysmy przy jego niepowodzeniu zostawali w pauzie, dozorca tkwilby + /// w stanie, z ktorego jedyne wyjscie wlasnie nie dziala. + private func resume(bufferGB: Int, freeGB: Int?) async { + probes.log("WZNOWIENIE: bufor \(bufferGB) GB, wolne \(describe(freeGB))") + if await probes.startBackup() == false { + probes.log( + "tmutil startbackup nie powiodlo sie - Time Machine ruszy sam w swoim cyklu godzinowym.") + } + state = .running + } + /// Zglasza poczatek i koniec zatoru wysylki - raz na zmiane stanu. /// /// Stan trzymamy w pliku, a nie w polu, bo `buffer-guard` chodzi pod /// launchd z `KeepAlive`: po kazdym wskrzeszeniu procesu pole zaczynaloby /// od zera i ten sam zator zglaszalby sie od nowa co 30 sekund. - private func reportUploadStall(_ stalled: Bool) async { + static func reportUploadStall(_ stalled: Bool) async { let marker = CMPaths.appSupportDir.appendingPathComponent(".upload-stalled") let reported = FileManager.default.fileExists(atPath: marker.path) guard stalled != reported else { return } if stalled { - FileManager.default.createFile(atPath: marker.path, contents: nil) let message = "Wysylka na Google Drive stoi - wyczerpany limit dobowy." CMLogger.log("\(message) Kopie ida dalej, zator mija sam w kilka godzin.") - await HealthAlert.notify(title: "CloudMachine: wysylka na Dysk stoi", message: message) + // Znacznik zakladamy DOPIERO po doreczeniu powiadomienia. Zalozony + // wczesniej zamykal sprawe takze wtedy, gdy powiadomienie nie doszlo - + // czyli gasil zgloszenie dokladnie w przypadku, dla ktorego istnieje + // (ten sam blad, co w `HealthAlert.report`, patrz tamtejszy komentarz). + if await HealthAlert.notify(title: "CloudMachine: wysylka na Dysk stoi", message: message) { + FileManager.default.createFile(atPath: marker.path, contents: nil) + } else { + CMLogger.log( + "Powiadomienie o zatorze wysylki NIE zostalo doreczone - sprobuje ponownie przy nastepnym sprawdzeniu." + ) + } } else { try? FileManager.default.removeItem(at: marker) CMLogger.log("Wysylka na Google Drive ruszyla z powrotem.") } } - private func stopBackup() async { - _ = try? await ProcessRunner.run("/usr/bin/tmutil", ["stopbackup"], timeout: 120) + /// Wola `tmutil ` i mowi, czy NAPRAWDE sie udalo. + /// + /// Najpierw przez `sudo -n`: `tmutil stopbackup` i `startbackup` wymagaja + /// uprawnien roota, a dozorca chodzi pod launchd w sesji uzytkownika. + /// Istniejacy `runTmutilUnattended` byl tu nieuzyty, mimo ze powstal + /// dokladnie do tego. Regula NOPASSWD w `/etc/sudoers.d/cloudmachine` nie + /// jest przez nic w tym repo zakladana (sprawdzone: zaden instalator jej + /// nie pisze), wiec `sudo -n` dzis odmawia natychmiast - i wlasnie dlatego + /// przy odmowie AUTORYZACJI probujemy jeszcze bez sudo, zamiast uznawac + /// sprawe za przegrana. `isSudoAuthFailure` odroznia "sudo nas nie wpuscilo" + /// od "polecenie sie wykonalo i zwrocilo blad". + static func tmutil(_ command: String) async -> Bool { + if let viaSudo = try? await ProcessRunner.runTmutilUnattended([command], timeout: 120) { + if viaSudo.succeeded { return true } + if !viaSudo.isSudoAuthFailure { + CMLogger.log( + "sudo tmutil \(command): kod \(viaSudo.exitCode) \(shortError(viaSudo))") + return false + } + } + guard let direct = try? await ProcessRunner.run("/usr/bin/tmutil", [command], timeout: 120) + else { + CMLogger.log("tmutil \(command): BRAK ODPOWIEDZI w limicie czasu.") + return false + } + if !direct.succeeded { + CMLogger.log("tmutil \(command): kod \(direct.exitCode) \(shortError(direct))") + } + return direct.succeeded } - private func startBackup() async { - _ = try? await ProcessRunner.run("/usr/bin/tmutil", ["startbackup"], timeout: 120) + private static func shortError(_ result: ProcessResult) -> String { + let text = (result.stderr + " " + result.stdout).trimmingCharacters(in: .whitespacesAndNewlines) + return text.isEmpty ? "(bez komunikatu)" : text.replacingOccurrences(of: "\n", with: " ") } } diff --git a/mac-app/Sources/CloudMachineCore/HealthAlert.swift b/mac-app/Sources/CloudMachineCore/HealthAlert.swift index 92f6625..0e16492 100644 --- a/mac-app/Sources/CloudMachineCore/HealthAlert.swift +++ b/mac-app/Sources/CloudMachineCore/HealthAlert.swift @@ -16,13 +16,26 @@ public enum HealthAlert { /// Plik ze stanem ostatniego zgloszenia. Trzymany OBOK bufora i obrazu, /// w katalogu, ktory zyje niezaleznie od nich - czujka nie moze dzielic losu /// tego, co nadzoruje. - static var stateFile: URL { + public static var stateFile: URL { CMPaths.appSupportDir.appendingPathComponent("health-alert.json") } struct AlertState: Codable { + /// Ostatnio POKAZANY tekst - trzymany dla czlowieka (`drive-status`, + /// diagnoza z pliku), nie do porownywania. var lastSummary: String var lastAlertAt: Date + /// Tozsamosc problemow, czyli to, po czym poznajemy, ze chodzi o TE SAMA + /// awarie - patrz `identity(of:)`. Opcjonalne, zeby plik zapisany przed + /// 23.09.2026 nadal sie czytal; przy `nil` porownujemy po tekscie jak + /// dawniej (najwyzej jedno powiadomienie wiecej, raz). + var lastIdentity: String? + /// Czy powiadomienie FAKTYCZNIE doszlo. `nil` = plik w starym formacie, + /// czyli sprzed czasow, gdy ktokolwiek to sprawdzal - traktujemy jak + /// doreczone, bo inaczej po aktualizacji posypalyby sie ponowienia. + var delivered: Bool? + /// Dlaczego nie doszlo - do pokazania czlowiekowi. + var deliveryError: String? } /// Po tylu godzinach przypominamy o TYM SAMYM problemie jeszcze raz. @@ -33,9 +46,20 @@ public enum HealthAlert { static let reminderHours = 12.0 /// Zglasza problemy, jesli sa NOWE albo jesli minal czas przypomnienia. - /// Zwraca `true`, gdy faktycznie cos zgloszono. + /// Zwraca `true`, gdy faktycznie cos zgloszono I DORECZONO. + /// + /// `stateFile` i `deliver` sa podmienialne, zeby dalo sie sprawdzic testem + /// CALA sciezke - z odmowa doreczenia wlacznie - bez pisania do prawdziwego + /// katalogu uzytkownika i bez wyswietlania komukolwiek powiadomien. @discardableResult - public static func report(_ report: BackupHealth.Report, now: Date = Date()) async -> Bool { + public static func report( + _ report: BackupHealth.Report, + now: Date = Date(), + stateFile: URL = HealthAlert.stateFile, + deliver: @Sendable (String, String) async -> Bool = { + await notify(title: $0, message: $1) + } + ) async -> Bool { guard let first = report.problems.first else { // Wyzdrowienie kasuje stan, zeby nastepna awaria zglosila sie od razu, // a nie czekala na okno przypomnienia. @@ -44,36 +68,122 @@ public enum HealthAlert { } let summary = report.problems.map(\.summary).joined(separator: " | ") - if !shouldAlert(summary: summary, now: now) { return false } + let identity = self.identity(of: report.problems) + if !shouldAlert(identity: identity, now: now, stateFile: stateFile) { return false } let body = report.problems.map { "\($0.summary): \($0.detail)" }.joined(separator: "\n") CMLogger.log("AWARIA BACKUPU: \(body)") - await notify(title: "CloudMachine: backup nie dziala", message: first.summary) + let delivered = await deliver("CloudMachine: backup nie dziala", first.summary) - let state = AlertState(lastSummary: summary, lastAlertAt: now) + // Stan zapisujemy ZAWSZE, ale z informacja, czy powiadomienie doszlo. + // + // Wczesniej zapisywalo sie bezwarunkowo jako sukces, wiec nieudane + // powiadomienie (odmowa uprawnien dla procesu launchd, brak sesji Aqua, + // przekroczony limit czasu osascript) zamykalo okno ciszy na 12 godzin. + // Alarm ginal po cichu - czyli nadzor ginal razem z nadzorowanym, przed + // czym ostrzega naglowek tego pliku. + if !delivered { + CMLogger.log( + "NIE UDALO SIE pokazac powiadomienia o awarii backupu. Tresc poszla do logu powyzej; sprobuje ponownie przy nastepnym sprawdzeniu." + ) + } + let state = AlertState( + lastSummary: summary, lastAlertAt: now, lastIdentity: identity, + delivered: delivered, + deliveryError: delivered + ? nil : "osascript nie pokazal powiadomienia (uprawnienia albo brak sesji graficznej)") if let data = try? JSONEncoder().encode(state) { try? data.write(to: stateFile, options: .atomic) } - return true + return delivered } - static func shouldAlert(summary: String, now: Date) -> Bool { - guard let data = try? Data(contentsOf: stateFile), - let state = try? JSONDecoder().decode(AlertState.self, from: data) - else { return true } - if state.lastSummary != summary { return true } + static func shouldAlert(identity: String, now: Date, stateFile: URL = HealthAlert.stateFile) + -> Bool + { + guard let state = loadState(stateFile) else { return true } + // Nieudane doreczenie NIE zamyka okna ciszy - inaczej pierwsza nieudana + // proba uciszalaby alarm na 12 godzin. + if state.delivered == false { return true } + if (state.lastIdentity ?? state.lastSummary) != identity { return true } return now.timeIntervalSince(state.lastAlertAt) > reminderHours * 3600 } + /// Tozsamosc zestawu problemow: te same summary z wycietymi LICZBAMI. + /// + /// Porownywanie gotowego tekstu dla uzytkownika nie dziala, bo ten tekst + /// zawiera zmienne: "Brak udanej kopii od 3 h" zmienia sie w "... od 4 h" + /// po godzinie. Warunek "inny tekst = nowy problem" byl wiec spelniony przy + /// KAZDYM przebiegu czujki i powiadomienie wracalo co godzine zamiast raz na + /// dwanascie - a alarm bez odstepu zamienia sie w szum i przestaje cokolwiek + /// znaczyc (patrz `reminderHours`). Ta sama awaria musi miec te sama + /// tozsamosc niezaleznie od tego, jak dlugo trwa. + /// + /// Ciag cyfr zastepujemy jednym `#`, zeby "od 9 h" i "od 12 h" dawaly ten + /// sam odcisk. NOWY problem dokladany do listy zmienia odcisk i alarmuje od + /// razu - i tak ma byc. + static func identity(of problems: [BackupHealth.Problem]) -> String { + problems.map { fingerprint($0.summary) }.joined(separator: " | ") + } + + static func fingerprint(_ text: String) -> String { + var out = "" + var inNumber = false + for character in text { + if character.isNumber { + if !inNumber { + out.append("#") + inNumber = true + } + } else { + out.append(character) + inNumber = false + } + } + return out + } + + static func loadState(_ file: URL = HealthAlert.stateFile) -> AlertState? { + guard let data = try? Data(contentsOf: file) else { return nil } + return try? JSONDecoder().decode(AlertState.self, from: data) + } + + /// Ostatnie zgloszenie, ktorego NIE udalo sie doreczyc - do pokazania + /// w `drive-status`. `nil`, gdy ostatnie zgloszenie doszlo albo gdy nie bylo + /// zadnego. Cichy alarm musi byc widoczny gdzies, gdzie czlowiek zaglada + /// sam, bo z definicji nie przyjdzie do niego po powiadomieniu. + public static func lastDeliveryFailure(stateFile: URL = HealthAlert.stateFile) -> ( + at: Date, summary: String, reason: String + )? { + guard let state = loadState(stateFile), state.delivered == false else { return nil } + return (state.lastAlertAt, state.lastSummary, state.deliveryError ?? "nieznany powod") + } + /// Powiadomienie systemowe przez `osascript`. Sam tekst wstawiamy jako /// literal AppleScript z ucieknietymi cudzyslowami - inaczej komunikat /// zawierajacy `"` (a komunikaty rclone je zawieraja) rozwalilby skrypt /// i alarm zginalby po cichu, czyli dokladnie tak, jak awaria, ktora ma /// zglaszac. - static func notify(title: String, message: String) async { + /// + /// Zwraca `true` tylko wtedy, gdy `osascript` FAKTYCZNIE zakonczyl sie + /// powodzeniem. Wynik byl wczesniej wyrzucany przez `_ = try?`, wiec odmowa + /// uprawnien do powiadomien (typowa dla procesu launchd), brak sesji Aqua + /// albo przekroczony limit czasu wygladaly dokladnie tak samo, jak + /// pokazane powiadomienie. + @discardableResult + public static func notify(title: String, message: String) async -> Bool { let script = "display notification \(appleScriptLiteral(message)) with title \(appleScriptLiteral(title))" - _ = try? await ProcessRunner.run("/usr/bin/osascript", ["-e", script], timeout: 30) + guard let result = try? await ProcessRunner.run("/usr/bin/osascript", ["-e", script], timeout: 30) + else { + CMLogger.log("osascript nie odpowiedzial w limicie czasu - powiadomienie nie poszlo.") + return false + } + if !result.succeeded { + let text = (result.stderr + result.stdout).trimmingCharacters(in: .whitespacesAndNewlines) + CMLogger.log("osascript zwrocil kod \(result.exitCode): \(text.isEmpty ? "(bez komunikatu)" : text)") + } + return result.succeeded } static func appleScriptLiteral(_ text: String) -> String { diff --git a/mac-app/Sources/CloudMachineCore/RemoteConfigurer.swift b/mac-app/Sources/CloudMachineCore/RemoteConfigurer.swift index 7e01f14..ceca2fa 100644 --- a/mac-app/Sources/CloudMachineCore/RemoteConfigurer.swift +++ b/mac-app/Sources/CloudMachineCore/RemoteConfigurer.swift @@ -121,13 +121,32 @@ public enum RemoteConfigurer { // potrzebuje katalog z pasmami jednego obrazu, tym bardziej ze montowanie // chodzi z `--drive-use-trash=false`, wiec kasowanie nie ma kosza, z // ktorego dalo by sie cokolwiek cofnac. + // + // Binarka: TA SAMA, ktorej uzywa reszta systemu (`~/.cloudmachine/bin/ + // rclone`), a nie `/usr/bin/env rclone`. Do 23.09.2026 `connect` szlo + // przez `env`, czyli w rclone z Homebrew - podczas gdy `isConfigured` + // w tym samym pliku i cale montowanie ida przez `CMTooling.runRclone`. + // Konsekwencje byly dwie i obie ciche: bez Homebrew udokumentowana + // sciezka instalacji (`install-rclone`, potem `configure-remote`) konczyla + // sie kodem 127 i komunikatem bez przyczyny, a Z Homebrew konfiguracje + // zapisywala INNA binarka niz ta, ktorej system potem uzywa. + // + // `authorize` woly przez `ProcessRunner.run` wprost na sciezce + // zarzadzanej binarki, bo `CMTooling.runRclone` nie przyjmuje `env`, + // a klucze OAuth MUSZA isc srodowiskiem (patrz komentarz wyzej) i + // `CMTooling.swift` nie nalezy do zakresu tej poprawki. guard let authResult = try? await ProcessRunner.run( - "/usr/bin/env", ["rclone", "authorize", "drive", "--drive-scope", "drive.file"], + CMTooling.managedRclonePath.path, + ["authorize", "drive", "--drive-scope", "drive.file"], env: authEnv, timeout: 300), authResult.succeeded else { - return CMActionResult(succeeded: false, message: "rclone authorize nie powiodlo sie.") + return CMActionResult( + succeeded: false, + message: + "rclone authorize nie powiodlo sie (\(CMTooling.managedRclonePath.path)). Jesli tej binarki nie ma, zacznij od: cloudmachine-agent install-rclone." + ) } guard let token = extractToken(from: authResult.stdout) else { return CMActionResult( @@ -141,11 +160,15 @@ public enum RemoteConfigurer { createArgs += ["client_id=\(clientID)", "client_secret=\(clientSecret)"] } createArgs.append("token=\(token)") - let createResult = try? await ProcessRunner.runRclone(createArgs) + // Znowu ta sama binarka co montowanie: gdyby `config create` poszlo przez + // Homebrew, zapisalby wpis w konfiguracji, ktorej moze nie czytac binarka + // uzywana przez system - a wtedy `isConfigured` mowi "nie ma remote'a" + // zaraz po udanym "polaczono". + let createResult = try? await CMTooling.runRclone(createArgs, timeout: 60) guard createResult?.succeeded == true else { return CMActionResult(succeeded: false, message: "rclone config create nie powiodlo sie.") } - let mkdirResult = try? await ProcessRunner.runRclone(["mkdir", remotePath]) + let mkdirResult = try? await CMTooling.runRclone(["mkdir", remotePath], timeout: 120) guard mkdirResult?.succeeded == true else { // ZWRACAMY BLAD, nie "sukces": remote istnieje, ale bez tego folderu nie // mamy potwierdzenia, ze zapis na to konto faktycznie dziala - a kolejny diff --git a/mac-app/Sources/CloudMachineCore/TimeMachineStatus.swift b/mac-app/Sources/CloudMachineCore/TimeMachineStatus.swift index cf5025d..5e9a942 100644 --- a/mac-app/Sources/CloudMachineCore/TimeMachineStatus.swift +++ b/mac-app/Sources/CloudMachineCore/TimeMachineStatus.swift @@ -18,11 +18,69 @@ public struct TimeMachineProgress: Equatable { } public enum TimeMachineStatus { - public static func isRunning() async -> Bool { - guard let result = try? await ProcessRunner.run("/usr/bin/tmutil", ["status"]) else { - return false + + /// Limit czasu dla KAZDEGO wywolania `tmutil` w tym pliku. + /// + /// Do 23.09.2026 nie bylo tu zadnego limitu i to byla awaria czekajaca na + /// swoj dzien. `tmutil destinationinfo` siega do celu backupu, czyli na + /// montowanie FUSE-T lezace na Google Drive. Przy martwym montowaniu + /// (incydent ENXIO z 22.09) odczyt wchodzi w nieprzerywalne I/O i nie wraca + /// NIGDY. Czujka `backup-health` wisi wtedy na `currentReport()`, a launchd + /// ze `StartInterval` nie uruchamia drugiej instancji, dopoki zyje pierwsza + /// - czyli czujka milknie NA STALE, dokladnie w chwili, w ktorej ma mowic. + /// + /// Dobor liczby, a nie "jakis limit z palca": + /// - na zdrowym systemie `tmutil status` i `destinationinfo` odpowiadaja + /// grubo ponizej sekundy (mierzone recznie na tej maszynie), + /// - na montowaniu, ktore jeszcze zyje, ale odpowiada wolno, ten sam + /// odczyt potrafi trwac dziesiatki sekund, bo idzie przez siec, + /// - projekt ma juz jedna wpadke z limitem dobranym dla CZYSTEGO startu: + /// 120 s wystarczalo po restarcie, a po awarii zabraklo 10 s. Dlatego + /// 90 s to nie jest "tyle, ile zwykle trwa", tylko dwa rzedy wielkosci + /// zapasu nad przypadkiem zdrowym i spory zapas nad wolnym. + /// + /// Gorna granica CZEKANIA jest wyzsza niz ta liczba: `ProcessRunner` przy + /// `timeout` wysyla SIGTERM, po +5 s SIGKILL, a po +10 s poddaje sie + /// i zwraca blad (SIGKILL nie dziala na proces w stanie "U"). Realne + /// maksimum to wiec 100 s na jedno wywolanie. `backup-health` robi ich na + /// przebieg jedno, przy `StartInterval` 1800 s - zapas 18-krotny, wiec + /// limit nie moze zjesc okna uruchomienia. + public static let commandTimeout: TimeInterval = 90 + + /// Surowe wyjscie `tmutil`. `nil` znaczy DOKLADNIE jedno: tmutil NIE + /// ODPOWIEDZIAL (limit czasu albo nie dalo sie go uruchomic) - a nie + /// "odpowiedzial, ze nie". Kazdy wolajacy musi te dwie rzeczy rozroznic + /// sam, bo zlanie ich w `false`/`nil` to wlasnie ten rodzaj cichej awarii, + /// przed ktorym ostrzega naglowek `BackupHealth`. + private static func output(_ arguments: [String]) async -> String? { + do { + let result = try await ProcessRunner.run( + "/usr/bin/tmutil", arguments, timeout: commandTimeout) + return result.stdout + } catch { + CMLogger.log( + "tmutil \(arguments.joined(separator: " ")): BRAK ODPOWIEDZI - \(error.localizedDescription)" + ) + return nil } - return isRunning(statusOutput: result.stdout) + } + + /// Czy backup trwa. `nil` = tmutil nie odpowiedzial, czyli NIE WIADOMO. + /// + /// Rozroznienie jest tu istotne dla dozorcy bufora: "nie trwa" kaze mu + /// przejsc w czuwanie i zapomniec, ze nadzorowal backup, a "nie wiadomo" + /// musi zostawic stan bez zmiany. + public static func runningState() async -> Bool? { + guard let out = await output(["status"]) else { return nil } + return isRunning(statusOutput: out) + } + + /// Skrot dla miejsc CZYSTO INFORMACYJNYCH (wydruk stanu, podglad w GUI), + /// gdzie brak odpowiedzi i "nie trwa" wygladaja tak samo i nic z tego nie + /// wynika. Wszedzie, gdzie z odpowiedzi wynika DECYZJA, uzywaj + /// `runningState()` i obsluz `nil` osobno. + public static func isRunning() async -> Bool { + await runningState() ?? false } /// Czysta funkcja parsujaca - wydzielona z `isRunning()`, zeby dalo sie ja @@ -44,11 +102,14 @@ public enum TimeMachineStatus { /// klucze wewnatrz bloku `Progress` (`bytes`, `files`, `TimeRemaining`...) /// sa unikalne w calym wyjsciu, wiec nie trzeba osobno sledzic zagniezdzenia /// nawiasow klamrowych. Zwraca `nil`, jesli aktualnie nic nie kopiuje. + /// + /// `nil` znaczy tu takze "tmutil nie odpowiedzial" i to jedyne miejsce + /// w tym pliku, gdzie zlanie tych dwoch przypadkow jest w porzadku: postep + /// sluzy WYLACZNIE do pokazania paska w interfejsie i zadna decyzja z niego + /// nie wynika. Kto pyta o postep, i tak wczesniej pyta `runningState()`. public static func currentProgress() async -> TimeMachineProgress? { - guard let result = try? await ProcessRunner.run("/usr/bin/tmutil", ["status"]) else { - return nil - } - return currentProgress(statusOutput: result.stdout) + guard let out = await output(["status"]) else { return nil } + return currentProgress(statusOutput: out) } static func currentProgress(statusOutput: String) -> TimeMachineProgress? { @@ -81,10 +142,8 @@ public enum TimeMachineStatus { /// Odpowiednik `tmutil destinationinfo | awk ... -v mp="$SP_MOUNT"` - szuka /// bloku, ktorego "Mount Point" zawiera `mountPoint`, i zwraca jego ID. public static func destinationID(forMountPointContaining mountPoint: String) async -> String? { - guard let result = try? await ProcessRunner.run("/usr/bin/tmutil", ["destinationinfo"]) else { - return nil - } - return destinationID(forMountPointContaining: mountPoint, destinationInfoOutput: result.stdout) + guard let out = await output(["destinationinfo"]) else { return nil } + return destinationID(forMountPointContaining: mountPoint, destinationInfoOutput: out) } static func destinationID( @@ -114,12 +173,8 @@ public enum TimeMachineStatus { public static func destinationQuotaGB(forMountPointContaining mountPoint: String) async -> Double? { - guard let result = try? await ProcessRunner.run("/usr/bin/tmutil", ["destinationinfo"]) - else { - return nil - } - return destinationQuotaGB( - forMountPointContaining: mountPoint, destinationInfoOutput: result.stdout) + guard let out = await output(["destinationinfo"]) else { return nil } + return destinationQuotaGB(forMountPointContaining: mountPoint, destinationInfoOutput: out) } static func destinationQuotaGB( @@ -152,12 +207,36 @@ public enum TimeMachineStatus { /// bugiem: po recznej zmianie nazwy wolumenu caly status GUI/watchdogow /// pokazywal "brak lokalnego woluminu" / "TimeMachine niezarejestrowany", /// mimo poprawnie dzialajacego, zarejestrowanego celu. + /// + /// Zwraca `nil` zarowno przy braku celu, jak i przy braku odpowiedzi od + /// tmutil - kto musi te dwie rzeczy rozroznic (czujka `backup-health`: + /// jedno znaczy "ktos przestawil cel", drugie "nie wiemy nic"), pyta + /// `destinationReading()`. public static func currentDestinationMountPoint() async -> String? { - guard let result = try? await ProcessRunner.run("/usr/bin/tmutil", ["destinationinfo"]) - else { - return nil - } - return currentDestinationMountPoint(destinationInfoOutput: result.stdout) + if case .mountPoint(let path) = await destinationReading() { return path } + return nil + } + + /// Odpowiedz `tmutil destinationinfo` z jawnym, trzecim stanem: BRAK + /// ODPOWIEDZI. + /// + /// Trzeci stan musi istniec osobno z tego samego powodu, co `queueUnknown` + /// w `UploadState`: bez niego zawieszony tmutil wygladal dokladnie tak samo + /// jak wyrejestrowany cel i czujka zglaszalaby "Time Machine nie wskazuje + /// na CloudMachine" - zdanie prawdziwie brzmiace i falszywe, ktore wysyla + /// czlowieka w zla strone. + public enum DestinationReading: Equatable, Sendable { + case mountPoint(String) + /// tmutil odpowiedzial, ale zadnego celu nie ma. + case none + /// tmutil nie odpowiedzial w limicie czasu. + case noAnswer + } + + public static func destinationReading() async -> DestinationReading { + guard let out = await output(["destinationinfo"]) else { return .noAnswer } + guard let path = currentDestinationMountPoint(destinationInfoOutput: out) else { return .none } + return .mountPoint(path) } static func currentDestinationMountPoint(destinationInfoOutput: String) -> String? { @@ -175,11 +254,14 @@ public enum TimeMachineStatus { /// `LocalBackupService.setAsDestination` do usuniecia poprzednich celow /// przed zarejestrowaniem nowego (ta architektura utrzymuje dokladnie /// jeden aktywny lokalny cel, w przeciwienstwie do legacy podejscia). + /// + /// Pusta lista przy braku odpowiedzi jest tu BEZPIECZNA i tylko dlatego + /// zostaje: jedyny wolajacy kasuje po kolei zwrocone cele przed + /// zarejestrowaniem nowego, wiec "nie wiem" konczy sie nieusunieciem + /// czegos, a nie usunieciem czegos nie tego. public static func allDestinationIDs() async -> [String] { - guard let result = try? await ProcessRunner.run("/usr/bin/tmutil", ["destinationinfo"]) else { - return [] - } - return allDestinationIDs(destinationInfoOutput: result.stdout) + guard let out = await output(["destinationinfo"]) else { return [] } + return allDestinationIDs(destinationInfoOutput: out) } static func allDestinationIDs(destinationInfoOutput: String) -> [String] { @@ -194,10 +276,10 @@ public enum TimeMachineStatus { return ids } - public static func destinationInfoContains(_ needle: String) async -> Bool { - guard let result = try? await ProcessRunner.run("/usr/bin/tmutil", ["destinationinfo"]) else { - return false - } - return result.stdout.contains(needle) + /// `nil` = tmutil nie odpowiedzial. Celowo NIE `false`: "w konfiguracji nie + /// ma tego napisu" i "nie udalo sie zapytac" to dwie rozne odpowiedzi. + public static func destinationInfoContains(_ needle: String) async -> Bool? { + guard let out = await output(["destinationinfo"]) else { return nil } + return out.contains(needle) } } diff --git a/mac-app/Tests/CloudMachineAppTests/AppStatusHealthTests.swift b/mac-app/Tests/CloudMachineAppTests/AppStatusHealthTests.swift index 8f7eea8..751a6d1 100644 --- a/mac-app/Tests/CloudMachineAppTests/AppStatusHealthTests.swift +++ b/mac-app/Tests/CloudMachineAppTests/AppStatusHealthTests.swift @@ -1,3 +1,4 @@ +import CloudMachineCore import XCTest @testable import CloudMachineApp @@ -24,7 +25,18 @@ final class AppStatusHealthTests: XCTestCase { // Musi byc jawne: `BufferStatus` zaczyna od "kolejki nie odczytano", zeby // swiezy, niesprawdzony stan nie uchodzil za pusta kolejke. buffer.queueKnown = true + buffer.freeDiskGB = 400 status.buffer = buffer + // ZMIANA 23.09.2026: "wszystko podpiete" przestalo wystarczac do zielonego + // znaczka. Punkt odniesienia musi teraz zawierac takze fakt, ze kopia + // FAKTYCZNIE powstala - bo dokladnie tego brakowalo w awarii, dla ktorej + // `BackupHealth` w ogole powstal. Wczesniej ten helper opisywal stan + // urzadzen i milczal o tym, czy backup sie udal; test "zdrowy stan jest + // zdrowy" przechodzil wiec takze dla Maca, ktory nie zrobil kopii od + // dwoch dni. + status.backupCycle = BackupCycleStatus( + known: true, lastSuccess: Date().addingTimeInterval(-1800), problems: [], + checkedAt: Date()) return status } @@ -117,4 +129,52 @@ final class AppStatusHealthTests: XCTestCase { XCTAssertTrue(status.healthy) XCTAssertEqual(status.headline, "Wysyłanie na Google Drive — 12 w kolejce") } + + // MARK: - Wiek ostatniej UDANEJ kopii + + /// TA awaria. Montowanie stoi, obraz podpiety, kolejka pusta, cel Time + /// Machine ustawiony - a ostatnia ZAKONCZONA kopia ma dwa dni. Panel + /// pokazywal wtedy "Sprawny / Gotowe", bo nie pytal o to ani razu: + /// `grep -rn "BackupHealth" Sources/CloudMachineApp/` nie dawal trafien. + func testStaraKopiaOdbieraZielonyZnaczek() { + let status = zdrowy() + status.backupCycle.lastSuccess = Date().addingTimeInterval(-48 * 3600) + XCTAssertFalse( + status.healthy, + "Wszystkie urzadzenia moga byc sprawne, a kopii moze nie byc od dwoch dni.") + XCTAssertEqual(status.headline, "Brak ukończonej kopii od 2 dni") + } + + /// Granica progu. Tuz pod nia jest jeszcze dobrze, tuz nad nia juz nie - + /// bez tego testu "naprawa" ustawiajaca prog na 100 lat przeszlaby cicho. + func testProgWiekuKopiiDzialaWObieStrony() { + let tuzPrzed = zdrowy() + tuzPrzed.backupCycle.lastSuccess = Date().addingTimeInterval( + -(BackupHealth.maxAgeHours * 3600 - 60)) + XCTAssertTrue(tuzPrzed.healthy) + + let tuzPo = zdrowy() + tuzPo.backupCycle.lastSuccess = Date().addingTimeInterval( + -(BackupHealth.maxAgeHours * 3600 + 60)) + XCTAssertFalse(tuzPo.healthy) + } + + /// Nieodczytany licznik kopii to NIE to samo, co kopia sprzed chwili. + /// Domyslny `BackupCycleStatus` ma `known == false` wlasnie po to, zeby + /// panel nie swiecil na zielono, zanim ktokolwiek o cokolwiek zapytal. + func testNieodczytanyLicznikKopiiOdbieraZielonyZnaczek() { + let status = zdrowy() + status.backupCycle = BackupCycleStatus() + XCTAssertFalse(status.healthy) + XCTAssertEqual(status.headline, "Nie wiadomo, kiedy powstała ostatnia kopia") + } + + /// Odczytano preferencje i nie ma w nich ANI JEDNEJ udanej kopii - co jest + /// czyms innym niz "nie udalo sie odczytac" i musi brzmiec inaczej. + func testBrakJakiejkolwiekKopiiOdbieraZielonyZnaczek() { + let status = zdrowy() + status.backupCycle = BackupCycleStatus(known: true, lastSuccess: nil, checkedAt: Date()) + XCTAssertFalse(status.healthy) + XCTAssertEqual(status.headline, "Nie ma ani jednej ukończonej kopii") + } } diff --git a/mac-app/Tests/CloudMachineAppTests/BufferGuardDecisionTests.swift b/mac-app/Tests/CloudMachineAppTests/BufferGuardDecisionTests.swift new file mode 100644 index 0000000..bf83b75 --- /dev/null +++ b/mac-app/Tests/CloudMachineAppTests/BufferGuardDecisionTests.swift @@ -0,0 +1,343 @@ +import XCTest + +@testable import CloudMachineCore + +/// Decyzje dozorcy bufora przechodzone CALA sciezka - przez `step()`, ze +/// zmiana stanu wlacznie - a nie tylko przez czyste funkcje pomocnicze. +/// +/// Powod: wszystkie trzy awarie naprawione 23.09.2026 (wyrzucany wynik +/// `stopbackup`, wspolna galaz wznowienia dla braku miejsca na Dysku, +/// zmyslone zero z nieudanego `statfs`) siedzialy w SEKWENCJI krokow, a nie +/// w pojedynczym wyrazeniu. Test czystego predykatu przeszedlby dla kazdej +/// z nich. Dlatego `BufferGuardService` ma teraz wstrzykiwalne `Probes` - +/// ten sam zabieg, co `preferencesFile` w `BackupHealth.currentReport`. +final class BufferGuardDecisionTests: XCTestCase { + + /// Zapisuje, co dozorca zrobil, i pozwala sterowac tym, co "widzi". + /// Klasa, nie struktura, bo te same wartosci czyta i zmienia kilka domkniec. + private final class Atrapa: @unchecked Sendable { + private let lock = NSLock() + + private var _bufferGB = 0 + private var _freeGB: Int? = 500 + private var _running: Bool? = true + private var _quotaHit = false + private var _driveFreeBytes: UInt64? = 1_000 * 1_073_741_824 + private var _stopSucceeds = true + private var _stopCalls = 0 + private var _startCalls = 0 + private var _log: [String] = [] + + private func read(_ body: () -> T) -> T { + lock.lock() + defer { lock.unlock() } + return body() + } + private func write(_ body: () -> Void) { + lock.lock() + defer { lock.unlock() } + body() + } + + var bufferGB: Int { + get { read { _bufferGB } } + set { write { _bufferGB = newValue } } + } + var freeGB: Int? { + get { read { _freeGB } } + set { write { _freeGB = newValue } } + } + var running: Bool? { + get { read { _running } } + set { write { _running = newValue } } + } + var quotaHit: Bool { + get { read { _quotaHit } } + set { write { _quotaHit = newValue } } + } + var driveFreeBytes: UInt64? { + get { read { _driveFreeBytes } } + set { write { _driveFreeBytes = newValue } } + } + var stopSucceeds: Bool { + get { read { _stopSucceeds } } + set { write { _stopSucceeds = newValue } } + } + var stopCalls: Int { read { _stopCalls } } + var startCalls: Int { read { _startCalls } } + var log: [String] { read { _log } } + + func probes() -> BufferGuardService.Probes { + BufferGuardService.Probes( + // Rozmiar bufora dozorca liczy z `bytesUsed`; podajemy go wprost. + queueStats: { [self] in + DriveBufferService.QueueStats( + uploadsInProgress: 0, uploadsQueued: 0, files: 0, erroredFiles: 0, + bytesUsed: UInt64(max(0, bufferGB)) * 1_073_741_824, outOfSpace: false) + }, + freeGB: { [self] in freeGB }, + backupRunning: { [self] in running }, + progressPercent: { 0 }, + hitStorageQuota: { [self] in quotaHit }, + uploadStalled: { false }, + driveFreeBytes: { [self] in driveFreeBytes }, + stopBackup: { [self] in + write { _stopCalls += 1 } + return stopSucceeds + }, + startBackup: { [self] in + write { _startCalls += 1 } + return true + }, + // Zgloszenie zatoru dotyka pliku znacznika w katalogu uzytkownika + // i pokazuje powiadomienie - w tescie nie ma tam czego szukac. + reportStall: { _ in }, + log: { [self] line in write { _log.append(line) } }) + } + } + + private let progi = BufferGuardService.Thresholds( + highGB: 150, lowGB: 40, minFreeGB: 80, minDriveFreeGB: 30) + + /// Doprowadza dozorce do stanu `.running` - punkt wyjscia dla reszty. + private func nadzorujacy(_ atrapa: Atrapa) async -> BufferGuardService { + let dozorca = BufferGuardService(thresholds: progi, probes: atrapa.probes()) + atrapa.bufferGB = 10 + atrapa.running = true + await dozorca.step() + let stan = await dozorca.currentState() + XCTAssertEqual(stan, .running, "Punkt wyjscia: dozorca ma nadzorowac trwajacy backup.") + return dozorca + } + + // MARK: - Punkt 2: nieudane wstrzymanie NIE jest pauza + + /// TA awaria. `tmutil stopbackup` pada (brak uprawnien albo limit czasu), + /// a dozorca i tak przechodzil w `.pausedForBuffer`. Poniewaz wstrzymanie + /// wola sie wylacznie przy ZMIANIE stanu, nie ponawial go juz nigdy: + /// Time Machine pisal dalej, dozorca czekal na drenaz, dysk zapelnial sie + /// do konca, a w logu stalo "PAUZA ... czekam na wysylke". + func testNieudaneWstrzymanieNieZmieniaStanuIJestPonawiane() async { + let atrapa = Atrapa() + let dozorca = await nadzorujacy(atrapa) + + atrapa.stopSucceeds = false + atrapa.bufferGB = 200 // powyzej progu pauzy + await dozorca.step() + + var stan = await dozorca.currentState() + XCTAssertEqual( + stan, .running, + "Nieudane 'tmutil stopbackup' NIE jest pauza - Time Machine nadal pisze.") + XCTAssertEqual(atrapa.stopCalls, 1) + XCTAssertTrue( + atrapa.log.contains { $0.contains("NIE UDALO SIE wstrzymac") }, + "Cicha porazka jest gorsza od glosnej - musi byc slad w logu.") + + // Kolejny krok MUSI sprobowac jeszcze raz - bez tego jedna nieudana proba + // zostawiala backup bez nadzoru az do restartu agenta. + await dozorca.step() + XCTAssertEqual(atrapa.stopCalls, 2, "Dozorca ma ponawiac wstrzymanie przy kazdym kroku.") + stan = await dozorca.currentState() + XCTAssertEqual(stan, .running) + + // Gdy wreszcie sie uda - dopiero wtedy stan sie zmienia. + atrapa.stopSucceeds = true + await dozorca.step() + stan = await dozorca.currentState() + XCTAssertEqual(stan, .pausedForBuffer) + XCTAssertEqual(atrapa.stopCalls, 3) + } + + // MARK: - Punkt 3: pauza za brak miejsca na Dysku wymaga DOWODU + + /// TA awaria. `hitStorageQuota()` patrzy na wpisy z ostatnich 30 minut logu + /// rclone. Po wstrzymaniu Time Machine nowe pasma nie powstaja, rclone + /// przestaje probowac, wpisy sie starzeja - i funkcja zaczyna zwracac + /// `false`, mimo ze na Dysku jak nie bylo miejsca, tak nie ma. Wspolna + /// galaz wznowienia patrzyla wtedy wylacznie na bufor i wolne miejsce + /// LOKALNE, czyli na dwie liczby, ktore o Dysku Google nie wiedza nic, + /// i zdejmowala pauze natychmiast. + func testPauzaZaBrakMiejscaNaDyskuNieMijaSama() async { + let atrapa = Atrapa() + let dozorca = await nadzorujacy(atrapa) + + atrapa.quotaHit = true + await dozorca.step() + var stan = await dozorca.currentState() + XCTAssertEqual(stan, .pausedForQuota) + + // Wpisy w logu sie zestarzaly, bufor sie oprozail, dysk lokalny pusty - + // czyli DOKLADNIE sytuacja, w ktorej stara wersja wznawiala backup. + atrapa.quotaHit = false + atrapa.bufferGB = 5 + atrapa.freeGB = 900 + + // 1. rclone nie odpowiada: "nie wiem" NIE jest zgoda na wznowienie. + atrapa.driveFreeBytes = nil + await dozorca.step() + stan = await dozorca.currentState() + XCTAssertEqual( + stan, .pausedForQuota, + "Brak odpowiedzi o pojemnosci Dysku ma PODTRZYMAC pauze, nie ja zniesc.") + XCTAssertEqual(atrapa.startCalls, 0) + + // 2. rclone odpowiada, ale miejsca nadal praktycznie nie ma. + atrapa.driveFreeBytes = 2 * 1_073_741_824 + await dozorca.step() + stan = await dozorca.currentState() + XCTAssertEqual(stan, .pausedForQuota, "2 GB to nie jest miejsce na dalsze kopie.") + XCTAssertEqual(atrapa.startCalls, 0) + + // 3. Miejsce faktycznie sie znalazlo - dopiero to jest dowod. + atrapa.driveFreeBytes = 500 * 1_073_741_824 + await dozorca.step() + stan = await dozorca.currentState() + XCTAssertEqual(stan, .running) + XCTAssertEqual(atrapa.startCalls, 1) + } + + /// Pauza z powodu BUFORA nie potrzebuje niczego od Dysku Google - inaczej + /// nieosiagalny rclone blokowalby kazde wznowienie w systemie. + func testPauzaZaBuforWznawiaSieBezPytaniaODysk() async { + let atrapa = Atrapa() + let dozorca = await nadzorujacy(atrapa) + + atrapa.bufferGB = 200 + await dozorca.step() + var stan = await dozorca.currentState() + XCTAssertEqual(stan, .pausedForBuffer) + + atrapa.bufferGB = 10 + atrapa.driveFreeBytes = nil // rclone milczy, ale to nie ta pauza + await dozorca.step() + stan = await dozorca.currentState() + XCTAssertEqual(stan, .running) + } + + // MARK: - Punkt 4: nieudany pomiar wolnego miejsca + + /// TA awaria. `freeGB()` zwracalo `0`, gdy `statfs` zawiodl. Zero spelnialo + /// warunek pauzy (`free <= minFreeGB`) natychmiast i NIGDY nie spelnialo + /// warunku wznowienia (`free > minFreeGB`) - dozorca wstrzymywal Time + /// Machine na podstawie liczby, ktorej nie zmierzyl, i nie wznawial go juz + /// nigdy. + func testNieudanyPomiarWolnegoMiejscaNieWstrzymujeBackupu() async { + let atrapa = Atrapa() + let dozorca = await nadzorujacy(atrapa) + + atrapa.freeGB = nil + atrapa.bufferGB = 10 // bufor w porzadku, wiec jedyny powod pauzy to dysk + await dozorca.step() + + let stan = await dozorca.currentState() + XCTAssertEqual( + stan, .running, + "Brak pomiaru to nie jest pomiar zerowy - nie wolno na nim wstrzymywac backupu.") + XCTAssertEqual(atrapa.stopCalls, 0) + XCTAssertTrue( + atrapa.log.contains { $0.contains("nie da sie zmierzyc wolnego miejsca") }, + "...ale nie wolno tez o tym milczec: to awaria samej ochrony dysku.") + } + + /// Zmierzone zero to co INNEGO niz brak pomiaru - i musi pauzowac. + /// Bez tego testu "naprawa" polegajaca na zignorowaniu wolnego miejsca + /// w ogole przeszlaby niezauwazona. + func testZmierzoneZeroNadalWstrzymujeBackup() async { + let atrapa = Atrapa() + let dozorca = await nadzorujacy(atrapa) + + atrapa.freeGB = 0 + await dozorca.step() + + let stan = await dozorca.currentState() + XCTAssertEqual(stan, .pausedForBuffer) + XCTAssertEqual(atrapa.stopCalls, 1) + } + + /// Brak pomiaru nie moze tez UDAWAC zgody na wznowienie. + func testNieudanyPomiarNieWznawiaBackupu() async { + let atrapa = Atrapa() + let dozorca = await nadzorujacy(atrapa) + + atrapa.bufferGB = 200 + await dozorca.step() + var stan = await dozorca.currentState() + XCTAssertEqual(stan, .pausedForBuffer) + + atrapa.bufferGB = 5 + atrapa.freeGB = nil + await dozorca.step() + stan = await dozorca.currentState() + XCTAssertEqual( + stan, .pausedForBuffer, + "Wznowienie wymaga dowodu, ze miejsce JEST - brak pomiaru dowodem nie jest.") + XCTAssertEqual(atrapa.startCalls, 0) + } + + /// `freeGB()` na prawdziwym systemie ma oddawac to samo, co `df`, i ma to + /// byc wartosc OPCJONALNA. Sciezka udana - odpowiednik dawnego + /// `testFreeSpaceMatchesStatfs`, ktory jako jedyny testowal te funkcje. + func testPomiarWolnegoMiejscaZgadzaSieZeStatfs() { + var stats = statfs() + XCTAssertEqual(statfs("/System/Volumes/Data", &stats), 0) + let oczekiwane = Int(UInt64(stats.f_bavail) * UInt64(stats.f_bsize) / 1_073_741_824) + XCTAssertEqual(BufferGuardService.freeGB(), oczekiwane) + } + + // MARK: - Czyste predykaty + + func testWznowienieWymagaObuWarunkowIPomiaru() { + // Bufor nadgonil i miejsce jest - jedyny przypadek, ktory wznawia. + XCTAssertTrue( + BufferGuardService.canResumeLocally(buffer: 10, free: 500, thresholds: progi)) + // Bufor nadgonil, ale dysk nadal pelny. + XCTAssertFalse( + BufferGuardService.canResumeLocally(buffer: 10, free: 10, thresholds: progi)) + // Dysk pusty, ale bufor jeszcze nie zszedl. + XCTAssertFalse( + BufferGuardService.canResumeLocally(buffer: 100, free: 500, thresholds: progi)) + // Brak pomiaru - nie wiadomo, wiec nie wznawiamy. + XCTAssertFalse( + BufferGuardService.canResumeLocally(buffer: 10, free: nil, thresholds: progi)) + } + + func testMiejsceNaDyskuLiczySieTylkoGdyJestZmierzone() { + XCTAssertFalse(BufferGuardService.driveHasRoom(freeBytes: nil, minGB: 30)) + XCTAssertFalse(BufferGuardService.driveHasRoom(freeBytes: 0, minGB: 30)) + XCTAssertFalse( + BufferGuardService.driveHasRoom(freeBytes: 29 * 1_073_741_824, minGB: 30)) + XCTAssertTrue( + BufferGuardService.driveHasRoom(freeBytes: 30 * 1_073_741_824, minGB: 30)) + } + + /// Galaz "nie wiem" w czujce MUSI byc zywa. + /// + /// Przeglad zlapal moment, w ktorym `BackupHealth` porownywal do `nil` + /// wartosc nieopcjonalna - takie porownanie zawsze daje falsz, wiec galaz + /// byla martwa, a kod i tak sie kompilowal i testy przechodzily. Ten test + /// sprawdza SAMA galaz, nie typ: przy braku pomiaru ma powstac problem, + /// przy pomiarze - nie. + func testBrakPomiaruDyskuJestZglaszanyPrzezCzujke() { + let brak = BackupHealth.unmeasuredLocalDiskProblems(localFreeGB: nil) + XCTAssertEqual(brak.count, 1, "Nieudany statfs to awaria ochrony dysku, nie cisza.") + // `first`, nie `[0]`: przy porazce tej asercji indeks przerwalby CALY + // przebieg fatal errorem zamiast zglosic jeden nieudany test. + XCTAssertEqual(brak.first?.summary, "Nie da sie zmierzyc wolnego miejsca na dysku Maca") + + XCTAssertTrue( + BackupHealth.unmeasuredLocalDiskProblems(localFreeGB: 400).isEmpty, + "Udany pomiar nie ma prawa niczego zglaszac.") + // Zmierzone zero to WYNIK, a nie brak wyniku - o niskim stanie mowi + // osobny prog w `evaluate`, nie ta funkcja. + XCTAssertTrue(BackupHealth.unmeasuredLocalDiskProblems(localFreeGB: 0).isEmpty) + } + + /// Prog wolnego miejsca na Dysku jest ten sam, ktory `BackupHealth` uznaje + /// za ostrzegawczy - jedno zrodlo prawdy, zeby czujka i dozorca nie mogly + /// twierdzic czegos innego o tej samej liczbie. + func testProgMiejscaNaDyskuZgadzaSieZCzujka() { + XCTAssertEqual( + BufferGuardService.Thresholds().minDriveFreeGB, BackupHealth.driveFreeWarningGB) + } +} diff --git a/mac-app/Tests/CloudMachineAppTests/HealthAlertTests.swift b/mac-app/Tests/CloudMachineAppTests/HealthAlertTests.swift new file mode 100644 index 0000000..9802adf --- /dev/null +++ b/mac-app/Tests/CloudMachineAppTests/HealthAlertTests.swift @@ -0,0 +1,189 @@ +import XCTest + +@testable import CloudMachineCore + +/// Testy samego ALARMU, a nie czujki. +/// +/// Do 23.09.2026 `HealthAlert` nie mial ani jednego testu, mimo ze to on +/// decyduje, czy ktokolwiek dowie sie o awarii backupu. Obie naprawione tu +/// usterki sa tego samego rodzaju: alarm uznawal sie za zglosony, choc nikt +/// go nie zobaczyl. +final class HealthAlertTests: XCTestCase { + + private var katalog: URL! + private var plikStanu: URL! + + override func setUpWithError() throws { + katalog = FileManager.default.temporaryDirectory + .appendingPathComponent("cm-health-alert-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: katalog, withIntermediateDirectories: true) + plikStanu = katalog.appendingPathComponent("health-alert.json") + } + + override func tearDownWithError() throws { + try? FileManager.default.removeItem(at: katalog) + } + + private func raport(_ summaries: [String], lastSuccess: Date? = nil) -> BackupHealth.Report { + BackupHealth.Report( + problems: summaries.map { BackupHealth.Problem(summary: $0, detail: "szczegoly") }, + lastSuccess: lastSuccess, lastAttempt: nil) + } + + // MARK: - Punkt 6: nieudane powiadomienie nie jest zgloszeniem + + /// TA awaria. `osascript` pada (odmowa uprawnien dla procesu launchd, brak + /// sesji Aqua, limit czasu), a `report()` i tak zapisywalo `lastSummary` + /// i `lastAlertAt` oraz zwracalo `true`. Od tej chwili `shouldAlert` + /// blokowalo kolejne proby na 12 godzin - alarm ginal po cichu, czyli + /// nadzor ginal razem z nadzorowanym. + func testNieudanePowiadomienieNieUciszaAlarmu() async { + let problemy = raport(["Brak udanej kopii od 5 h"]) + + let pierwsze = await HealthAlert.report( + problemy, now: Date(), stateFile: plikStanu, deliver: { _, _ in false }) + XCTAssertFalse(pierwsze, "Zgloszenie, ktorego nikt nie zobaczyl, nie jest zgloszeniem.") + + // Piec minut pozniej, ten sam problem: MUSI sprobowac jeszcze raz, + // a nie czekac 12 godzin. + let sprobowanoPonownie = LicznikProb() + let drugie = await HealthAlert.report( + problemy, now: Date().addingTimeInterval(300), stateFile: plikStanu, + deliver: { _, _ in + sprobowanoPonownie.zwieksz() + return true + }) + XCTAssertEqual(sprobowanoPonownie.ile, 1, "Po nieudanej probie alarm ma wrocic.") + XCTAssertTrue(drugie) + } + + /// Nieudane doreczenie ma byc WIDOCZNE - alarmu, ktory nie doszedl, nikt + /// nie zauwazy z definicji, wiec musi dac sie go zobaczyc tam, gdzie + /// czlowiek zaglada sam (`drive-status`). + func testNieudaneDoreczenieDaSieOdczytac() async { + let kiedy = Date(timeIntervalSince1970: 1_758_000_000) + await HealthAlert.report( + raport(["Obraz backupu nie jest podpiety"]), now: kiedy, stateFile: plikStanu, + deliver: { _, _ in false }) + + let awaria = HealthAlert.lastDeliveryFailure(stateFile: plikStanu) + XCTAssertNotNil(awaria) + XCTAssertEqual(awaria?.at, kiedy) + XCTAssertEqual(awaria?.summary, "Obraz backupu nie jest podpiety") + + // Po udanym doreczeniu slad znika - inaczej wisialby tam na zawsze. + await HealthAlert.report( + raport(["Obraz backupu nie jest podpiety"]), now: kiedy.addingTimeInterval(3600), + stateFile: plikStanu, deliver: { _, _ in true }) + XCTAssertNil(HealthAlert.lastDeliveryFailure(stateFile: plikStanu)) + } + + // MARK: - Punkt 8: odstep miedzy przypomnieniami + + /// TA usterka. Tekst problemu zawiera wiek awarii ("Brak udanej kopii od + /// 3 h"), wiec przy trwajacej awarii zmienial sie CO GODZINE. Warunek + /// "inny tekst = nowy problem" byl wtedy spelniony przy kazdym przebiegu + /// i powiadomienie wracalo co godzine zamiast raz na dwanascie - a alarm + /// bez odstepu zamienia sie w szum i przestaje cokolwiek znaczyc. + func testRosnacyWiekAwariiNieJestNowymProblemem() async { + let start = Date(timeIntervalSince1970: 1_758_000_000) + let doreczone = await HealthAlert.report( + raport(["Brak udanej kopii od 3 h"]), now: start, stateFile: plikStanu, + deliver: { _, _ in true }) + XCTAssertTrue(doreczone) + + // Godzine pozniej ta sama awaria opisuje sie innym tekstem. + let licznik = LicznikProb() + let znowu = await HealthAlert.report( + raport(["Brak udanej kopii od 4 h"]), now: start.addingTimeInterval(3600), + stateFile: plikStanu, + deliver: { _, _ in + licznik.zwieksz() + return true + }) + XCTAssertEqual(licznik.ile, 0, "To ta sama awaria, tylko starsza - nie alarmujemy od nowa.") + XCTAssertFalse(znowu) + } + + /// Po okresie przypomnienia ta sama awaria ma sie odezwac ponownie - + /// inaczej alarm zapala sie raz i gasnie na zawsze. + func testPoOkresiePrzypomnieniaTaSamaAwariaWraca() { + let start = Date(timeIntervalSince1970: 1_758_000_000) + zapisz(identity: HealthAlert.identity(of: raport(["Brak udanej kopii od 3 h"]).problems), at: start) + + let odcisk = HealthAlert.identity(of: raport(["Brak udanej kopii od 15 h"]).problems) + XCTAssertFalse( + HealthAlert.shouldAlert( + identity: odcisk, now: start.addingTimeInterval(11 * 3600), stateFile: plikStanu), + "Przed uplywem 12 h milczymy.") + XCTAssertTrue( + HealthAlert.shouldAlert( + identity: odcisk, now: start.addingTimeInterval(13 * 3600), stateFile: plikStanu), + "Po 12 h przypominamy - awaria trwa, dopoki ktos jej nie naprawi.") + } + + /// NOWY problem dolozony do listy musi zaalarmowac od razu, bez czekania na + /// okno przypomnienia. Bez tego testu "naprawa" uciszajaca wszystko na + /// 12 godzin przeszlaby niezauwazona. + func testNowyProblemAlarmujeOdRazu() { + let start = Date(timeIntervalSince1970: 1_758_000_000) + zapisz(identity: HealthAlert.identity(of: raport(["Brak udanej kopii od 3 h"]).problems), at: start) + + let dwaProblemy = HealthAlert.identity( + of: raport(["Brak udanej kopii od 4 h", "Obraz backupu nie jest podpiety"]).problems) + XCTAssertTrue( + HealthAlert.shouldAlert( + identity: dwaProblemy, now: start.addingTimeInterval(600), stateFile: plikStanu)) + } + + /// Sam odcisk: liczby znikaja, tresc zostaje. + func testOdciskWycinaLiczbyAleNieTresc() { + XCTAssertEqual( + HealthAlert.fingerprint("Brak udanej kopii od 3 h"), + HealthAlert.fingerprint("Brak udanej kopii od 27 h")) + XCTAssertNotEqual( + HealthAlert.fingerprint("Brak udanej kopii od 3 h"), + HealthAlert.fingerprint("Obraz backupu nie jest podpiety")) + // Dwa RONE problemy roznia sie tylko liczba w nawiasie - to nadal ten + // sam rodzaj awarii i nie ma powodu alarmowac od nowa przy kazdym GB. + XCTAssertEqual( + HealthAlert.fingerprint("Konczy sie miejsce na Google Drive (28 GB)"), + HealthAlert.fingerprint("Konczy sie miejsce na Google Drive (12 GB)")) + } + + /// Wyzdrowienie kasuje stan, zeby nastepna awaria zglosila sie od razu. + func testWyzdrowienieKasujeStan() async { + await HealthAlert.report( + raport(["Brak udanej kopii od 3 h"]), stateFile: plikStanu, deliver: { _, _ in true }) + XCTAssertTrue(FileManager.default.fileExists(atPath: plikStanu.path)) + + await HealthAlert.report(raport([]), stateFile: plikStanu, deliver: { _, _ in true }) + XCTAssertFalse(FileManager.default.fileExists(atPath: plikStanu.path)) + } + + // MARK: - Pomocnicze + + private func zapisz(identity: String, at date: Date) { + let stan = HealthAlert.AlertState( + lastSummary: "nieistotne", lastAlertAt: date, lastIdentity: identity, delivered: true, + deliveryError: nil) + let dane = try! JSONEncoder().encode(stan) + try! dane.write(to: plikStanu) + } + + /// Licznik prob doreczenia. Klasa, bo domkniecie `deliver` jest `@Sendable`. + private final class LicznikProb: @unchecked Sendable { + private let lock = NSLock() + private var licznik = 0 + func zwieksz() { + lock.lock() + licznik += 1 + lock.unlock() + } + var ile: Int { + lock.lock() + defer { lock.unlock() } + return licznik + } + } +} diff --git a/mac-app/Tests/CloudMachineAppTests/TimeMachineTimeoutTests.swift b/mac-app/Tests/CloudMachineAppTests/TimeMachineTimeoutTests.swift new file mode 100644 index 0000000..1880cb7 --- /dev/null +++ b/mac-app/Tests/CloudMachineAppTests/TimeMachineTimeoutTests.swift @@ -0,0 +1,167 @@ +import XCTest + +@testable import CloudMachineCore + +/// Co sie dzieje, gdy `tmutil` NIE ODPOWIADA. +/// +/// Do 23.09.2026 odpowiedz brzmiala "nic": wywolania `tmutil` nie mialy +/// limitu czasu, wiec przy martwym montowaniu FUSE-T (incydent ENXIO z 22.09) +/// `destinationinfo` wchodzil w nieprzerywalne I/O i `BackupHealth. +/// currentReport()` nie wracal nigdy. launchd ze `StartInterval` nie +/// uruchamia drugiej instancji, dopoki zyje pierwsza - czujka milkla NA STALE, +/// a naglowek `BackupHealth` deklarowal dokladnie odwrotnie. +final class TimeMachineTimeoutTests: XCTestCase { + + /// Limit MUSI istniec i MUSI miescic sie w oknie uruchomienia czujki. + /// + /// `ProcessRunner` poddaje sie dopiero `timeout + 10` s (SIGTERM, SIGKILL, + /// a na koniec porzucenie procesu, bo SIGKILL nie dziala na proces + /// zawieszony w jadrze). To ta liczba, nie sam `timeout`, jest realnym + /// czasem czekania i to ona musi zmiescic sie w `StartInterval` czujki + /// (1800 s), inaczej limit tylko przesuwalby zawieszenie, zamiast je + /// przerywac. + func testLimitCzasuTmutilIstniejeIMiesciSieWOknieCzujki() { + XCTAssertGreaterThan(TimeMachineStatus.commandTimeout, 0, "Brak limitu to ta awaria.") + let najdluzszeCzekanie = TimeMachineStatus.commandTimeout + 10 + XCTAssertLessThan( + najdluzszeCzekanie, 1800, + "Czekanie dluzsze niz StartInterval czujki (1800 s) zjada jej wlasne okno uruchomienia.") + // Zapas nad przypadkiem zdrowym (ulamek sekundy) ma byc duzy, bo projekt + // ma juz jedna wpadke z limitem dobranym dla CZYSTEGO startu: 120 s + // wystarczalo po restarcie, a po awarii zabraklo 10 s. + XCTAssertGreaterThanOrEqual( + TimeMachineStatus.commandTimeout, 60, + "Limit dobrany 'na zdrowy system' padnie przy pierwszej powaznej awarii.") + } + + /// Brak odpowiedzi od tmutil jest AWARIA, a nie "cel przestawiony". + /// + /// To rozroznienie jest calym sensem limitu czasu. Gdyby zawieszony tmutil + /// zglaszal sie jako `destinationRegistered: false`, czujka wysylalaby + /// czlowieka do przestawiania celu, ktory jest ustawiony poprawnie - a + /// prawdziwa przyczyna (martwe montowanie) zostalaby nietknieta. + func testBrakOdpowiedziTmutilJestOsobnaAwaria() { + let teraz = Date(timeIntervalSince1970: 1_758_000_000) + + let nieWiadomo = BackupHealth.evaluate( + lastSuccess: teraz.addingTimeInterval(-1800), lastAttempt: teraz.addingTimeInterval(-1800), + result: 0, now: teraz, mounted: true, attached: true, destinationRegistered: nil, + erroredFiles: 0, outOfSpace: false, queueReadable: true) + + XCTAssertFalse(nieWiadomo.healthy, "Nie wiadomo = nie zdrowo.") + XCTAssertTrue( + nieWiadomo.problems.contains { $0.summary.contains("tmutil nie odpowiada") }, + "Zawieszony tmutil musi byc nazwany po imieniu.") + XCTAssertFalse( + nieWiadomo.problems.contains { $0.summary == "Time Machine nie wskazuje na CloudMachine" }, + "To NIE jest przestawiony cel - taki komunikat wysyla czlowieka w zla strone.") + } + + /// Odpowiedz "cel jest przestawiony" nadal ma brzmiec jak dawniej - + /// bez tego testu naprawa mogla zamienic jeden komunikat na drugi. + func testPrzestawionyCelNadalJestZglaszanyOsobno() { + let teraz = Date(timeIntervalSince1970: 1_758_000_000) + + let przestawiony = BackupHealth.evaluate( + lastSuccess: teraz.addingTimeInterval(-1800), lastAttempt: teraz.addingTimeInterval(-1800), + result: 0, now: teraz, mounted: true, attached: true, destinationRegistered: false, + erroredFiles: 0, outOfSpace: false, queueReadable: true) + + XCTAssertTrue( + przestawiony.problems.contains { $0.summary == "Time Machine nie wskazuje na CloudMachine" }) + XCTAssertFalse(przestawiony.problems.contains { $0.summary.contains("tmutil nie odpowiada") }) + } + + /// Poprawnie ustawiony cel nie zglasza zadnego z tych dwoch problemow. + func testUstawionyCelNieZglaszaNiczego() { + let teraz = Date(timeIntervalSince1970: 1_758_000_000) + + let dobrze = BackupHealth.evaluate( + lastSuccess: teraz.addingTimeInterval(-1800), lastAttempt: teraz.addingTimeInterval(-1800), + result: 0, now: teraz, mounted: true, attached: true, destinationRegistered: true, + erroredFiles: 0, outOfSpace: false, queueReadable: true) + + XCTAssertTrue(dobrze.healthy) + } + + // MARK: - Nieodczytana tablica montowan + + /// Punkt odniesienia dla tej grupy: wszystko sprawne. + private func zdrowa( + mounted: Bool? = true, attached: Bool? = true, imageDeadErrno: Int32? = nil + ) -> BackupHealth.Report { + let teraz = Date(timeIntervalSince1970: 1_758_000_000) + return BackupHealth.evaluate( + lastSuccess: teraz.addingTimeInterval(-1800), lastAttempt: teraz.addingTimeInterval(-1800), + result: 0, now: teraz, mounted: mounted, attached: attached, destinationRegistered: true, + erroredFiles: 0, outOfSpace: false, queueReadable: true, imageDeadErrno: imageDeadErrno) + } + + /// TA cisza. `BackupImageService.Attachment` dostal czwarty przypadek + /// `.unknown` ("tablicy montowan nie udalo sie odczytac"), a wolajacy + /// przekazywal do czujki `attachment != .detached` - czyli `.unknown` + /// wchodzilo jako `true`, "podpiety". Czujka, ktorej JEDYNYM zadaniem jest + /// nie twierdzic rzeczy, ktorych nie wie, milczala o stanie, ktorego nie + /// znala. To jedyne miejsce w tej turze, gdzie "nie wiem" szlo w strone + /// ciszy, a nie alarmu. + func testNieodczytanyStanObrazuNieJestCisza() { + let nieWiadomo = zdrowa(attached: nil) + + XCTAssertFalse(nieWiadomo.healthy, "Nie wiadomo = nie zdrowo. Cisza tu nie wolno.") + XCTAssertTrue( + nieWiadomo.problems.contains { $0.summary == "Nie wiadomo, czy obraz backupu jest podpiety" }) + } + + /// ...i nie jest tym samym, co realne odpiecie. Komunikat "nie jest + /// podpiety" wyslalby czlowieka do podpinania obrazu, ktory moze byc + /// podpiety poprawnie - ta sama zasada, co przy przestawionym celu. + func testNieodczytanyStanObrazuBrzmiInaczejNizOdpiecie() { + let nieWiadomo = zdrowa(attached: nil) + let odpiety = zdrowa(attached: false) + + XCTAssertFalse( + nieWiadomo.problems.contains { $0.summary == "Obraz backupu nie jest podpiety" }, + "Brak odczytu NIE jest odpieciem.") + XCTAssertTrue(odpiety.problems.contains { $0.summary == "Obraz backupu nie jest podpiety" }) + XCTAssertFalse( + odpiety.problems.contains { $0.summary.hasPrefix("Nie wiadomo, czy obraz") }, + "Realne odpiecie jest FAKTEM, a nie niewiadoma.") + } + + /// To samo dla montowania. `isMounted` to `mountedState() ?? false`, wiec + /// nieodczytana tablica montowan zglaszala sie jako "montowanie nie dziala" + /// - alarm o stanie, ktorego nikt nie zmierzyl, wysylajacy czlowieka do + /// naprawiania czegos, co moze byc sprawne. + func testNieodczytaneMontowanieBrzmiInaczejNizBrakMontowania() { + let nieWiadomo = zdrowa(mounted: nil) + let brak = zdrowa(mounted: false) + + XCTAssertFalse(nieWiadomo.healthy) + XCTAssertTrue( + nieWiadomo.problems.contains { + $0.summary == "Nie wiadomo, czy montowanie Google Drive dziala" + }) + XCTAssertFalse( + nieWiadomo.problems.contains { $0.summary == "Montowanie Google Drive nie dziala" }) + XCTAssertTrue(brak.problems.contains { $0.summary == "Montowanie Google Drive nie dziala" }) + } + + /// Podpiety, ale MARTWY obraz musi nadal byc zglaszany - przebudowa galezi + /// `attached` na trojstanowa nie moze zgubic tego przypadku, bo kosztowal + /// juz 15 godzin bez kopii. + func testMartwyObrazNadalJestZglaszany() { + let martwy = zdrowa(attached: true, imageDeadErrno: 6) + XCTAssertTrue(martwy.problems.contains { $0.summary.contains("MARTWY (errno 6)") }) + // Przy nieznanym stanie NIE zgadujemy, ze obraz zyje. + XCTAssertFalse(zdrowa(attached: nil).problems.contains { $0.summary.contains("MARTWY") }) + } + + /// `runningState()` istnieje po to, zeby "nie trwa" i "nie wiadomo" dalo + /// sie rozroznic. `isRunning()` zostaje jako skrot dla miejsc czysto + /// informacyjnych i tam wolno mu zlewac te dwa przypadki. + func testParsowanieStatusuNieZmieniloSie() { + XCTAssertTrue(TimeMachineStatus.isRunning(statusOutput: "Running = 1;")) + XCTAssertFalse(TimeMachineStatus.isRunning(statusOutput: "Running = 0;")) + XCTAssertFalse(TimeMachineStatus.isRunning(statusOutput: "")) + } +}