Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,16 @@ scratch/
# to jest kluczowy kod tego projektu, wiec jawnie go odignorowujemy.
!scripts/
!scripts/*.sh

# Lokalny stan narzedzi Claude / claude-flow / ruflo.
# To sa artefakty konkretnej maszyny i sesji (liczniki edycji, polityki,
# przyjeta konfiguracja) - nie opisuja projektu i nie naleza do repozytorium.
# Definicje agentow (.claude/agents/) i CLAUDE.md zostaja SLEDZONE celowo.
.claude-flow/
.claude/proven-config.json
.claude/.proven-config-version
.claude/settings.local.json
.claude/worktrees/
.claude/sessions/
.claude/todos/
.specstory/
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@
po tym, jak bufor padl i wrocil.

Kolejnosc wzgledem bufora nie wymaga sterowania z zewnatrz: `attach-image`
sam czeka do dwoch minut, az montowanie stanie.
sam czeka, az bufor stanie - ile i na co, patrz `BufferReadiness`. To
czekanie, nie ten interwal, jest glowna obrona przed wyscigiem po starcie:
interwal tylko sprzata po sytuacji, w ktorej obraz odpial sie pozniej.
-->
<key>StartInterval</key>
<integer>900</integer>
Expand Down
22 changes: 18 additions & 4 deletions mac-app/Sources/CloudMachineAgent/DriveCommands.swift
Original file line number Diff line number Diff line change
Expand Up @@ -83,10 +83,24 @@ struct AttachImage: AsyncParsableCommand {

func run() async throws {
// Time Machine nie moze zobaczyc celu, zanim bufor bedzie gotowy - inaczej
// uzna, ze dysk backupu zniknal.
for _ in 0..<60 {
if DriveBufferService.isMounted { break }
try? await Task.sleep(nanoseconds: 2_000_000_000)
// uzna, ze dysk backupu zniknal. Ile czekamy i na co dokladnie - patrz
// `BufferReadiness`.
let ready = await BufferReadiness.wait(
sleep: { seconds in
try? await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
},
probe: {
BufferReadiness.isReady(
mounted: DriveBufferService.isMounted,
imageVisible: BackupImageService.exists)
})
if !ready {
print(
"""
Bufor nie stanal w \(Int(BufferReadiness.defaultTimeout / 60)) min - nie podpinam obrazu.
Time Machine jest teraz BEZ CELU. Sprawdz: cloudmachine-agent drive-status
""")
throw ExitCode(1)
}
let result = await BackupImageService.attach()
print(result.message)
Expand Down
50 changes: 50 additions & 0 deletions mac-app/Sources/CloudMachineCore/BufferReadiness.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import Foundation

/// Kiedy bufor jest na tyle gotowy, zeby podpiac na nim obraz.
///
/// Wydzielone z `attach-image`, zeby dalo sie sprawdzic testem bez montowania
/// czegokolwiek - tak samo jak `CooldownGate` i `TimeMachineStatus`.
public enum BufferReadiness {
/// Ile czekac na gotowy bufor, zanim uznamy to za awarie.
///
/// Dwie minuty wystarczaly, dopoki bufor startowal pusty. Po nieczystym
/// zamknieciu jest inaczej: 13 wrz 2026 rclone wczytywal po starcie 225
/// brudnych pozycji (9,4 GB), montowanie stanelo o 07:56:30, a czekanie
/// poddalo sie o 07:56:20 - dziesiec sekund za wczesnie. Time Machine
/// zostal bez celu az do nastepnego tykniecia launchd, czyli na 15 minut,
/// i nikt sie o tym nie dowiedzial poza kodem wyjscia, ktorego nikt nie czyta.
///
/// Czekanie jest darmowe - `attach` na juz podpietym obrazie tylko mowi
/// "Juz podpiete" - a przegapione okno kosztuje kwadrans bez backupu.
public static let defaultTimeout: TimeInterval = 900

/// Co ile odpytywac.
public static let defaultPoll: TimeInterval = 2

/// Samo montowanie nie wystarczy.
///
/// rclone wystawia montowanie, ZANIM wczyta brudny cache, wiec przez chwile
/// katalog jest pusty. Podpiecie odpadloby wtedy na "Brak obrazu" - czyli na
/// tym samym wyscigu, tyle ze o krok pozniej.
public static func isReady(mounted: Bool, imageVisible: Bool) -> Bool {
mounted && imageVisible
}

/// Czeka na gotowosc bufora. Zwraca `true`, jesli sie doczekal.
///
/// Zegar i uspienie sa wstrzykiwane, zeby test nie musial czekac naprawde.
public static func wait(
timeout: TimeInterval = defaultTimeout,
poll: TimeInterval = defaultPoll,
now: () -> Date = Date.init,
sleep: (TimeInterval) async -> Void,
probe: () -> Bool
) async -> Bool {
let deadline = now().addingTimeInterval(timeout)
while true {
if probe() { return true }
if now() >= deadline { return false }
await sleep(poll)
}
}
}
101 changes: 101 additions & 0 deletions mac-app/Tests/CloudMachineAppTests/BufferReadinessTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import XCTest

@testable import CloudMachineCore

/// Testy czekania na gotowy bufor.
///
/// Kazdy odtwarza konkretny wyscig, ktory juz zdarzyl sie na zywo - nie
/// sprawdzamy, ze funkcja "dziala", tylko ze zachowuje sie inaczej niz wersja,
/// ktora 13 wrz 2026 zostawila Time Machine bez celu.
final class BufferReadinessTests: XCTestCase {

/// Zegar, ktory rusza sie tylko wtedy, gdy kod naprawde by spal. Dzieki temu
/// test mierzy CZEKANIE, a nie predkosc maszyny.
private final class FakeClock {
private(set) var now = Date(timeIntervalSince1970: 1_757_700_000)
func sleep(_ seconds: TimeInterval) { now.addTimeInterval(seconds) }
}

// MARK: - Warunek gotowosci

/// To jest ten drugi wyscig: montowanie juz stoi, ale rclone nie wczytal
/// jeszcze brudnego cache, wiec katalog jest pusty. Stara wersja uznawala to
/// za gotowosc i podpiecie odpadalo na "Brak obrazu".
func testMontowanieBezObrazuToNieGotowosc() {
XCTAssertFalse(BufferReadiness.isReady(mounted: true, imageVisible: false))
}

func testObrazBezMontowaniaToNieGotowosc() {
XCTAssertFalse(BufferReadiness.isReady(mounted: false, imageVisible: true))
}

func testJednoIDrugieToGotowosc() {
XCTAssertTrue(BufferReadiness.isReady(mounted: true, imageVisible: true))
}

// MARK: - Czekanie

/// ZNANA ZLA PROBKA: bufor staje po 150 s. Stary limit 120 s poddawal sie
/// dziesiec sekund za wczesnie i wlasnie to zdarzylo sie 13 wrz 2026.
func testDoczekaSieBuforaKtoryStajePo150s() async {
let clock = FakeClock()
let gotowyOd = clock.now.addingTimeInterval(150)

let ready = await BufferReadiness.wait(
now: { clock.now },
sleep: { clock.sleep($0) },
probe: { clock.now >= gotowyOd })

XCTAssertTrue(ready, "Bufor stanal po 150 s - czekanie musi go zlapac")
}

/// Dowod, ze poprzedni test nie przechodzi dlatego, ze funkcja zwraca zawsze
/// `true`: bufor, ktory nie staje NIGDY, musi zostac zgloszony jako awaria.
func testPoddajeSieGdyBuforNieStajeWcale() async {
let clock = FakeClock()

let ready = await BufferReadiness.wait(
now: { clock.now },
sleep: { clock.sleep($0) },
probe: { false })

XCTAssertFalse(ready, "Bufor nigdy nie stanal - to musi byc awaria, nie cisza")
}

/// Czekanie ma sie skonczyc mniej wiecej na zadeklarowanym limicie, a nie
/// ciagnac w nieskonczonosc: launchd czeka na ten proces.
func testKonczyCzekanieNaZadeklarowanymLimicie() async {
let clock = FakeClock()
let start = clock.now

_ = await BufferReadiness.wait(
now: { clock.now },
sleep: { clock.sleep($0) },
probe: { false })

let elapsed = clock.now.timeIntervalSince(start)
XCTAssertGreaterThanOrEqual(elapsed, BufferReadiness.defaultTimeout)
XCTAssertLessThan(elapsed, BufferReadiness.defaultTimeout + BufferReadiness.defaultPoll * 2)
}

/// Gotowy bufor nie moze kosztowac ani jednego uspienia - `attach-image`
/// chodzi tez z reki i po kazdym tyknieciu launchd.
func testGotowyBuforNieCzekaWcale() async {
let clock = FakeClock()
let start = clock.now

let ready = await BufferReadiness.wait(
now: { clock.now },
sleep: { clock.sleep($0) },
probe: { true })

XCTAssertTrue(ready)
XCTAssertEqual(clock.now, start, "Gotowy bufor ma wracac natychmiast")
}

/// Limit musi byc wiekszy niz zaobserwowane 150 s, inaczej naprawa jest
/// pozorna. Zapisane wprost, zeby nikt go nie scial z powrotem do dwoch minut.
func testLimitJestWiekszyNizZaobserwowanyWyscig() {
XCTAssertGreaterThan(BufferReadiness.defaultTimeout, 150)
}
}
Loading