From 1dd43df70d9dbf3f927c856b291d10fbee1e26d0 Mon Sep 17 00:00:00 2001 From: Marcin Beczynski Date: Wed, 4 Nov 2026 00:05:36 +0100 Subject: [PATCH] fix(health): read through the share and ask Samba before saying "Ready" Every check of the SMB layout asked whether units are `active`, whether rclone's rc answers and where tmutil points - all over limactl's own network. A hung or dead punchfs/rclone FUSE chain, a cut vzNAT, a silent smbd and an inactive tm-bands-bind after vm-prepare-bundle all showed green "Ready" for up to 3 h. - ShareReadProbe: the status script reads 1 byte (read-only) of the image's band 0 through the share's path; detached reader, 10 s wait, single-flight lock, survives sudo use_pty's hangup, dead locks taken over. dead / failed / no answer / readable / no image yet. - SMBProbe: SMB2 NEGOTIATE from the Mac to :445 with a deadline; no route is "unknown" (Local Network privacy), answers reused for 60 s. - backup-health, vm-status, the menu bar and the machine card: "unknown" and "dead" are never green. - servicesHealthy takes bundlePrepared: an inactive bind is fine only before vm-prepare-bundle. - Status script limit 60 -> 90 s; NSLocalNetworkUsageDescription. No self-repair (separate decision); nothing in vm/. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AU1Mhxpy65J75YYTkmt7NF --- mac-app/Resources/Info.plist | 2 + .../CloudMachineApp/Models/AppStatus.swift | 64 ++- .../Services/CloudMachineController.swift | 7 + .../CloudMachineApp/Views/DashboardView.swift | 21 +- .../CloudMachineCore/BackupHealth.swift | 26 +- .../CloudMachineCore/L10nPolish+VM.swift | 30 ++ .../L10nTranslations+ar.swift | 30 ++ .../L10nTranslations+da.swift | 30 ++ .../L10nTranslations+de.swift | 30 ++ .../L10nTranslations+es.swift | 30 ++ .../L10nTranslations+fi.swift | 30 ++ .../L10nTranslations+fr.swift | 30 ++ .../L10nTranslations+he.swift | 30 ++ .../L10nTranslations+it.swift | 30 ++ .../L10nTranslations+ja.swift | 30 ++ .../L10nTranslations+ko.swift | 30 ++ .../L10nTranslations+ms.swift | 30 ++ .../L10nTranslations+nb.swift | 30 ++ .../L10nTranslations+nl.swift | 30 ++ .../L10nTranslations+pt-BR.swift | 30 ++ .../L10nTranslations+ru.swift | 30 ++ .../L10nTranslations+sv.swift | 30 ++ .../L10nTranslations+th.swift | 30 ++ .../L10nTranslations+tr.swift | 30 ++ .../L10nTranslations+zh-Hans.swift | 30 ++ .../L10nTranslations+zh-Hant.swift | 30 ++ .../Sources/CloudMachineCore/SMBProbe.swift | 236 ++++++++++ .../CloudMachineCore/ShareHealth.swift | 150 ++++++ .../CloudMachineCore/ShareReadProbe.swift | 195 ++++++++ .../Sources/CloudMachineCore/VMStatus.swift | 43 +- .../Sources/CloudMachineCore/VMUpdate.swift | 2 +- .../AppStatusFixtures.swift | 2 + .../AppStatusHealthTests.swift | 3 + .../CloudMachineAppTests/SMBProbeTests.swift | 223 +++++++++ .../ShareHealthTests.swift | 269 +++++++++++ .../ShareReadProbeTests.swift | 442 ++++++++++++++++++ .../CloudMachineAppTests/VMLayoutTests.swift | 28 +- 37 files changed, 2309 insertions(+), 34 deletions(-) create mode 100644 mac-app/Sources/CloudMachineCore/SMBProbe.swift create mode 100644 mac-app/Sources/CloudMachineCore/ShareHealth.swift create mode 100644 mac-app/Sources/CloudMachineCore/ShareReadProbe.swift create mode 100644 mac-app/Tests/CloudMachineAppTests/SMBProbeTests.swift create mode 100644 mac-app/Tests/CloudMachineAppTests/ShareHealthTests.swift create mode 100644 mac-app/Tests/CloudMachineAppTests/ShareReadProbeTests.swift diff --git a/mac-app/Resources/Info.plist b/mac-app/Resources/Info.plist index 2811ef5..13452ae 100644 --- a/mac-app/Resources/Info.plist +++ b/mac-app/Resources/Info.plist @@ -73,5 +73,7 @@ AppIcon CFBundleIconName AppIcon + NSLocalNetworkUsageDescription + CloudMachine checks that Samba in its own machine (a virtual machine on this Mac) answers at the address Time Machine backs up to. diff --git a/mac-app/Sources/CloudMachineApp/Models/AppStatus.swift b/mac-app/Sources/CloudMachineApp/Models/AppStatus.swift index 4fb1fcd..8379c6e 100644 --- a/mac-app/Sources/CloudMachineApp/Models/AppStatus.swift +++ b/mac-app/Sources/CloudMachineApp/Models/AppStatus.swift @@ -75,13 +75,64 @@ struct MachineStatus: Equatable { var punchfsFileLimit: Int? var bundlePrepared: Bool? var imageCount: Int? + /// One byte read through the share's path in the machine; `nil` = not read. + var shareRead: ShareReadProbe.Verdict? + /// Samba's answer to this Mac over the address Time Machine uses; `nil` = not asked. + var smb: SMBProbe.Verdict? var isRunning: Bool { state == .running } + /// Both probes confirmed the path Time Machine uses. Not read is not confirmed. + var shareConfirmed: Bool { + shareRead?.isConfirmed == true && smb?.answers == true + } + /// `nil` = not known (no answer from the machine). var servicesHealthy: Bool? { guard isRunning, !services.isEmpty else { return nil } - return VMStatus.servicesHealthy(services) + return VMStatus.servicesHealthy(services, bundlePrepared: bundlePrepared) + } + + /// Units that are not up, by the same rule as `servicesHealthy`: an + /// inactive bind counts once the image is prepared. + var servicesDown: [String] { + VMLayout.services.filter { + let state = services[$0] + if state == "active" { return false } + if $0 == "tm-bands-bind" && state == "inactive" { return bundlePrepared != false } + return true + } + } + + /// The "Services" row of the machine card. All services up is green only + /// with the share confirmed by both probes - otherwise the row says what + /// the probes found. + var servicesText: String { + switch servicesHealthy { + case .some(true): + if let summary = shareProblemSummary { return summary } + return L10n.tr("All running") + case .some(false): + return L10n.tr("Not running: %@", servicesDown.joined(separator: ", ")) + case .none: return L10n.tr("not read") + } + } + + var servicesTone: StatusTone { + switch servicesHealthy { + case .some(true): + if shareRead?.isFailure == true || smb?.isFailure == true { return .danger } + return shareConfirmed ? .success : .warning + case .some(false): return .danger + case .none: return isRunning ? .warning : .neutral + } + } + + /// What the probes found, first the read, then Samba; `nil` = both + /// confirmed. Not read counts as unknown. + var shareProblemSummary: String? { + (shareRead ?? .noAnswer(hungSeconds: nil)).problemSummary + ?? (smb ?? .noAnswer(seconds: 0)).problemSummary } /// Whether the counters below come from a reading at all. A fresh @@ -260,6 +311,13 @@ final class AppStatus: ObservableObject { if machine.servicesHealthy == false { return L10n.tr("A service in the CloudMachine machine is not running") } + // Every service "active" says nothing about whether the path through + // them works - and a probe that did not answer is not a path that works. + if machine.isRunning, machine.servicesHealthy == true, + let summary = machine.shareProblemSummary + { + return summary + } if case .notRegistered = timeMachineState { return L10n.tr("Time Machine does not point to CloudMachine") } @@ -294,7 +352,8 @@ final class AppStatus: ObservableObject { /// safe", and that follows solely from a backup having BEEN MADE. var healthy: Bool { guard case .ready = dependencyState, remoteConfigured, machine.isRunning, - machine.servicesHealthy == true, case .registered = timeMachineState, + machine.servicesHealthy == true, machine.shareConfirmed, + case .registered = timeMachineState, machine.uploadState.isNominal, backupCycle.isFresh(), !remoteControlOpen else { return false } return true @@ -330,6 +389,7 @@ extension AppStatus { case .unknown, .running: break } if machine.servicesHealthy == false { return .danger } + if machine.shareRead?.isFailure == true || machine.smb?.isFailure == true { return .danger } if case .notRegistered = timeMachineState { return .danger } if machine.uploadState.needsAttention { return .danger } if remoteControlOpen { return .danger } diff --git a/mac-app/Sources/CloudMachineApp/Services/CloudMachineController.swift b/mac-app/Sources/CloudMachineApp/Services/CloudMachineController.swift index 69d3149..574dd59 100644 --- a/mac-app/Sources/CloudMachineApp/Services/CloudMachineController.swift +++ b/mac-app/Sources/CloudMachineApp/Services/CloudMachineController.swift @@ -14,6 +14,8 @@ final class CloudMachineController: ObservableObject { hasClientID: false, hasClientSecret: false) private var refreshTask: Task? + /// Samba is asked again after an answer only once a minute, not at every 10 s refresh. + private var smbCache = SMBProbe.Cache() private var lastBytesDone: Double? private var lastBytesSampledAt: Date? @@ -159,7 +161,12 @@ final class CloudMachineController: ObservableObject { machine.punchfsFileLimit = report.punchfsFileLimit machine.bundlePrepared = report.bundlePrepared machine.imageCount = report.imageCount + machine.shareRead = report.shareRead machine.address = await VMLayout.currentAddress() + // Off the main actor inside, bounded by its own limit. + var cache = smbCache + machine.smb = await cache.probe(address: machine.address) + smbCache = cache } status.machine = machine status.remoteControlOpen = VMStatus.remoteControlOpenOnMac() diff --git a/mac-app/Sources/CloudMachineApp/Views/DashboardView.swift b/mac-app/Sources/CloudMachineApp/Views/DashboardView.swift index 3de4fea..8967526 100644 --- a/mac-app/Sources/CloudMachineApp/Views/DashboardView.swift +++ b/mac-app/Sources/CloudMachineApp/Views/DashboardView.swift @@ -480,26 +480,9 @@ private struct DashboardContent: View { } } - private var servicesTone: StatusTone { - switch status.machine.servicesHealthy { - case .some(true): return .success - case .some(false): return .danger - case .none: return status.machine.isRunning ? .warning : .neutral - } - } + private var servicesTone: StatusTone { status.machine.servicesTone } - private var servicesText: String { - switch status.machine.servicesHealthy { - case .some(true): return L10n.tr("All running") - case .some(false): - let down = VMLayout.services.filter { - let state = status.machine.services[$0] - return state != "active" && !($0 == "tm-bands-bind" && state == "inactive") - } - return L10n.tr("Not running: %@", down.joined(separator: ", ")) - case .none: return L10n.tr("not read") - } - } + private var servicesText: String { status.machine.servicesText } private var disksTone: StatusTone { machineTone.worst(servicesTone == .neutral ? .warning : servicesTone) diff --git a/mac-app/Sources/CloudMachineCore/BackupHealth.swift b/mac-app/Sources/CloudMachineCore/BackupHealth.swift index 0460579..1f9b3ed 100644 --- a/mac-app/Sources/CloudMachineCore/BackupHealth.swift +++ b/mac-app/Sources/CloudMachineCore/BackupHealth.swift @@ -191,7 +191,12 @@ public enum BackupHealth { // Whether the startup grace period is in progress - see `startupGraceMinutes`. withinStartupGrace: Bool = false, // Whether Time Machine is RIGHT NOW performing a run (`tmutil status`). - backupRunning: Bool? = nil + backupRunning: Bool? = nil, + // The read through the share's path in the machine and Samba's answer to + // this Mac (`ShareReadProbe`, `SMBProbe`); `nil` = not asked. + shareRead: ShareReadProbe.Verdict? = nil, + smb: SMBProbe.Verdict? = nil, + smbAddress: String? = nil ) -> Report { var problems: [Problem] = [] var deferred: [Problem] = [] @@ -349,6 +354,15 @@ public enum BackupHealth { ), code: "rclone-rc-no-answer")) } + // The services say "active"; these two say whether the path Time Machine + // uses works. Only for a machine whose services all answered, like the rc + // message above. "No answer" alarms at once and is never folded into fine; + // only Samba refusing (smbd still starting) waits out the startup grace. + if machineRunning == true && servicesHealthy == true { + let share = shareProblems(read: shareRead, smb: smb, address: smbAddress) + problems.append(contentsOf: share.problems) + share.notReadyYet.forEach(notReadyYet) + } if punchfsNearFileLimit { problems.append( Problem( @@ -585,7 +599,9 @@ public enum BackupHealth { } let report = machineRunning == true ? await VMStatus.currentReport() : nil let reading = report?.reading - let servicesHealthy = report.flatMap { VMStatus.servicesHealthy($0.services) } + let servicesHealthy = report.flatMap { + VMStatus.servicesHealthy($0.services, bundlePrepared: $0.bundlePrepared) + } let address = machineRunning == true ? await VMLayout.currentAddress() : nil let registered = await VMStatus.timeMachineTargetsMachine(address: address) @@ -626,7 +642,11 @@ public enum BackupHealth { // uptime computed from it would come out negative, i.e. "startup in // progress". withinStartupGrace: (systemUptime() ?? .infinity) < startupGraceMinutes * 60, - backupRunning: await TimeMachineStatus.runningState()) + backupRunning: await TimeMachineStatus.runningState(), + // `report == nil` (no answer) is already "machine does not answer". + shareRead: report?.shareRead, + smb: machineRunning == true ? await SMBProbe.probe(address: address) : nil, + smbAddress: address) health.problems.append(contentsOf: unmeasuredLocalDiskProblems(localFreeGB: localFree)) health.problems.append(contentsOf: await MachineBudget.currentProblems()) diff --git a/mac-app/Sources/CloudMachineCore/L10nPolish+VM.swift b/mac-app/Sources/CloudMachineCore/L10nPolish+VM.swift index bcd1b0c..27d973d 100644 --- a/mac-app/Sources/CloudMachineCore/L10nPolish+VM.swift +++ b/mac-app/Sources/CloudMachineCore/L10nPolish+VM.swift @@ -226,5 +226,35 @@ extension L10nPolish { "rclone ma pasma w trakcie wysyłki, ale od %@ żadne nie zostało wysłane - zwykle to zerwane połączenie sieciowe, którego rclone nie zgłasza jako błędu. Kopie od tego czasu są tylko w buforze maszyny. Gdy sieć wróci, wysyłka wznowi się sama (28.10.2026 w 80 s); jeśli nie, restart maszyny (prepare-shutdown, limactl start) zrywa martwe połączenia.", "tmutil did not answer - not restarting the machine while its state is unknown.": "tmutil nie odpowiedział - nie restartuję maszyny, póki jej stan jest nieznany.", + "The machine's share cannot be read": + "Nie da się czytać z udziału maszyny", + "Unknown whether the machine's share can be read": + "Nie wiadomo, czy z udziału maszyny da się czytać", + "Samba in the machine does not answer this Mac": + "Samba w maszynie nie odpowiada temu Macowi", + "Unknown whether Samba in the machine answers this Mac": + "Nie wiadomo, czy Samba w maszynie odpowiada temu Macowi", + "Reading 1 byte of %@ in the machine failed: %@. Every service says it is active, yet Time Machine cannot use the image there. Nothing restarts it by itself. Check: cloudmachine-agent vm-status, then in the machine: journalctl -u punchfs -u tm-bands-bind -u rclone-tm": + "Odczyt 1 bajtu z %@ w maszynie nie powiódł się: %@. Każda usługa twierdzi, że działa, a Time Machine nie może tam korzystać z obrazu. Nic samo tego nie naprawi. Sprawdź: cloudmachine-agent vm-status, potem w maszynie: journalctl -u punchfs -u tm-bands-bind -u rclone-tm", + "Reading 1 byte of %@ in the machine has not returned for %@ s. That is how a hung punchfs or rclone mount behaves. Nothing is restarted while the state is unknown - a restart under a write still going could lose it. Check in the machine: journalctl -u punchfs -u rclone-tm": + "Odczyt 1 bajtu z %@ w maszynie nie wrócił od %@ s. Tak zachowuje się zawieszony punchfs albo montowanie rclone. Póki stan jest nieznany, nic nie jest restartowane - restart pod trwającym zapisem mógłby go zgubić. Sprawdź w maszynie: journalctl -u punchfs -u rclone-tm", + "An SMB connection to %@ got no answer within %@ s. Time Machine reaches the machine at this address. Check the machine's network and smbd: cloudmachine-agent vm-status": + "Połączenie SMB z %@ nie dostało odpowiedzi w %@ s. Pod tym adresem Time Machine łączy się z maszyną. Sprawdź sieć maszyny i smbd: cloudmachine-agent vm-status", + "The machine did not report its address, so nobody could try an SMB connection. Check: cloudmachine-agent vm-status": + "Maszyna nie podała swojego adresu, więc nikt nie mógł spróbować połączenia SMB. Sprawdź: cloudmachine-agent vm-status", + "An SMB connection to %@ failed (%@). Time Machine reaches the machine at this address, so no backup is made. Check the machine's network and smbd: cloudmachine-agent vm-status": + "Połączenie SMB z %@ nie powiodło się (%@). Pod tym adresem Time Machine łączy się z maszyną, więc kopia nie powstaje. Sprawdź sieć maszyny i smbd: cloudmachine-agent vm-status", + "readable": + "czytelny", + "no image yet": + "jeszcze bez obrazu", + "answers": + "odpowiada", + "Share read: %@": + "Odczyt udziału: %@", + "Samba from Mac: %@": + "Samba z Maca: %@", + "This Mac found no route to %@ (%@). Either the route to the machine is gone, or macOS Local Network privacy has not allowed CloudMachine - then allow it in System Settings > Privacy & Security > Local Network. Check: cloudmachine-agent vm-status": + "Ten Mac nie znalazł trasy do %@ (%@). Albo trasa do maszyny zniknęła, albo prywatność „Sieć lokalna” w macOS nie pozwoliła CloudMachine - wtedy zezwól w Ustawieniach systemowych > Prywatność i ochrona > Sieć lokalna. Sprawdź: cloudmachine-agent vm-status", ] } diff --git a/mac-app/Sources/CloudMachineCore/L10nTranslations+ar.swift b/mac-app/Sources/CloudMachineCore/L10nTranslations+ar.swift index ca75cd4..8dd951f 100644 --- a/mac-app/Sources/CloudMachineCore/L10nTranslations+ar.swift +++ b/mac-app/Sources/CloudMachineCore/L10nTranslations+ar.swift @@ -947,5 +947,35 @@ extension L10nTranslations { "لدى rclone أجزاء قيد التنفيذ، لكن لم يُرفع أي منها منذ %@ - عادةً بسبب انقطاع اتصال الشبكة، ولا يبلّغ rclone عنه كخطأ. النسخ الاحتياطية منذ ذلك الحين موجودة في المخزن المؤقت للجهاز الافتراضي فقط. حين تعود الشبكة يستأنف الرفع تلقائيًا (في 28.10.2026 خلال 80 s)؛ وإن لم يحدث، فإعادة تشغيل الجهاز الافتراضي (prepare-shutdown ثم limactl start) تُسقط الاتصالات الميتة.", "tmutil did not answer - not restarting the machine while its state is unknown.": "لم يستجب tmutil - لن يُعاد تشغيل الجهاز الافتراضي ما دامت حالته غير معروفة.", + "The machine's share cannot be read": + "تعذّرت قراءة مشاركة الجهاز", + "Unknown whether the machine's share can be read": + "غير معروف ما إذا كانت مشاركة الجهاز قابلة للقراءة", + "Samba in the machine does not answer this Mac": + "Samba في الجهاز لا يستجيب لهذا الـ Mac", + "Unknown whether Samba in the machine answers this Mac": + "غير معروف ما إذا كان Samba في الجهاز يستجيب لهذا الـ Mac", + "Reading 1 byte of %@ in the machine failed: %@. Every service says it is active, yet Time Machine cannot use the image there. Nothing restarts it by itself. Check: cloudmachine-agent vm-status, then in the machine: journalctl -u punchfs -u tm-bands-bind -u rclone-tm": + "فشلت قراءة بايت واحد من %@ في الجهاز: %@. كل خدمة تقول إنها نشطة، ومع ذلك لا يستطيع Time Machine استخدام الصورة هناك. لا شيء يعيد تشغيله تلقائيًا. تحقّق: cloudmachine-agent vm-status، ثم داخل الجهاز: journalctl -u punchfs -u tm-bands-bind -u rclone-tm", + "Reading 1 byte of %@ in the machine has not returned for %@ s. That is how a hung punchfs or rclone mount behaves. Nothing is restarted while the state is unknown - a restart under a write still going could lose it. Check in the machine: journalctl -u punchfs -u rclone-tm": + "لم تعد قراءة بايت واحد من %@ في الجهاز منذ %@ ث. هكذا يتصرف تركيب punchfs أو rclone المعلّق. لا يُعاد تشغيل أي شيء ما دامت الحالة غير معروفة - إعادة التشغيل أثناء كتابة جارية قد تُضيّعها. تحقّق داخل الجهاز: journalctl -u punchfs -u rclone-tm", + "An SMB connection to %@ got no answer within %@ s. Time Machine reaches the machine at this address. Check the machine's network and smbd: cloudmachine-agent vm-status": + "لم يتلقَّ اتصال SMB بـ %@ أي رد خلال %@ ث. يصل Time Machine إلى الجهاز عبر هذا العنوان. تحقّق من شبكة الجهاز وsmbd: cloudmachine-agent vm-status", + "The machine did not report its address, so nobody could try an SMB connection. Check: cloudmachine-agent vm-status": + "لم يُبلغ الجهاز عن عنوانه، لذا لم يتمكن أحد من تجربة اتصال SMB. تحقّق: cloudmachine-agent vm-status", + "An SMB connection to %@ failed (%@). Time Machine reaches the machine at this address, so no backup is made. Check the machine's network and smbd: cloudmachine-agent vm-status": + "فشل اتصال SMB بـ %@ (%@). يصل Time Machine إلى الجهاز عبر هذا العنوان، لذا لا تُنشأ أي نسخة احتياطية. تحقّق من شبكة الجهاز وsmbd: cloudmachine-agent vm-status", + "readable": + "قابلة للقراءة", + "no image yet": + "لا توجد صورة بعد", + "answers": + "يستجيب", + "Share read: %@": + "قراءة المشاركة: %@", + "Samba from Mac: %@": + "Samba من الـ Mac: %@", + "This Mac found no route to %@ (%@). Either the route to the machine is gone, or macOS Local Network privacy has not allowed CloudMachine - then allow it in System Settings > Privacy & Security > Local Network. Check: cloudmachine-agent vm-status": + "لم يجد هذا الـ Mac مسارًا إلى %@ (%@). إما أن المسار إلى الجهاز اختفى، أو أن خصوصية «الشبكة المحلية» في macOS لم تسمح لـ CloudMachine - عندها اسمح بذلك من إعدادات النظام > الخصوصية والأمن > الشبكة المحلية. تحقّق: cloudmachine-agent vm-status", ] } diff --git a/mac-app/Sources/CloudMachineCore/L10nTranslations+da.swift b/mac-app/Sources/CloudMachineCore/L10nTranslations+da.swift index 67db8d2..cffe848 100644 --- a/mac-app/Sources/CloudMachineCore/L10nTranslations+da.swift +++ b/mac-app/Sources/CloudMachineCore/L10nTranslations+da.swift @@ -947,5 +947,35 @@ extension L10nTranslations { "rclone har bånd i gang, men ingen er blevet uploadet i %@ - som regel en afbrudt netværksforbindelse, som rclone ikke melder som en fejl. Backups siden da ligger kun i maskinens buffer. Når netværket vender tilbage, genoptages upload af sig selv (den 28.10.2026 inden for 80 s); hvis det ikke sker, fjerner en genstart af maskinen (prepare-shutdown, limactl start) de døde forbindelser.", "tmutil did not answer - not restarting the machine while its state is unknown.": "tmutil svarede ikke - genstarter ikke maskinen, mens dens tilstand er ukendt.", + "The machine's share cannot be read": + "Maskinens share kan ikke læses", + "Unknown whether the machine's share can be read": + "Ukendt, om maskinens share kan læses", + "Samba in the machine does not answer this Mac": + "Samba i maskinen svarer ikke denne Mac", + "Unknown whether Samba in the machine answers this Mac": + "Ukendt, om Samba i maskinen svarer denne Mac", + "Reading 1 byte of %@ in the machine failed: %@. Every service says it is active, yet Time Machine cannot use the image there. Nothing restarts it by itself. Check: cloudmachine-agent vm-status, then in the machine: journalctl -u punchfs -u tm-bands-bind -u rclone-tm": + "Læsning af 1 byte fra %@ i maskinen mislykkedes: %@. Alle tjenester siger, at de er aktive, men Time Machine kan ikke bruge imaget der. Intet genstarter det af sig selv. Tjek: cloudmachine-agent vm-status, derefter i maskinen: journalctl -u punchfs -u tm-bands-bind -u rclone-tm", + "Reading 1 byte of %@ in the machine has not returned for %@ s. That is how a hung punchfs or rclone mount behaves. Nothing is restarted while the state is unknown - a restart under a write still going could lose it. Check in the machine: journalctl -u punchfs -u rclone-tm": + "Læsning af 1 byte fra %@ i maskinen er ikke vendt tilbage i %@ s. Sådan opfører en hængende punchfs- eller rclone-montering sig. Intet genstartes, så længe tilstanden er ukendt - en genstart under en igangværende skrivning kan miste den. Tjek i maskinen: journalctl -u punchfs -u rclone-tm", + "An SMB connection to %@ got no answer within %@ s. Time Machine reaches the machine at this address. Check the machine's network and smbd: cloudmachine-agent vm-status": + "En SMB-forbindelse til %@ fik intet svar inden for %@ s. Time Machine når maskinen på denne adresse. Tjek maskinens netværk og smbd: cloudmachine-agent vm-status", + "The machine did not report its address, so nobody could try an SMB connection. Check: cloudmachine-agent vm-status": + "Maskinen oplyste ikke sin adresse, så ingen kunne forsøge en SMB-forbindelse. Tjek: cloudmachine-agent vm-status", + "An SMB connection to %@ failed (%@). Time Machine reaches the machine at this address, so no backup is made. Check the machine's network and smbd: cloudmachine-agent vm-status": + "En SMB-forbindelse til %@ mislykkedes (%@). Time Machine når maskinen på denne adresse, så der laves ingen sikkerhedskopi. Tjek maskinens netværk og smbd: cloudmachine-agent vm-status", + "readable": + "læsbar", + "no image yet": + "intet image endnu", + "answers": + "svarer", + "Share read: %@": + "Læsning af share: %@", + "Samba from Mac: %@": + "Samba fra Mac: %@", + "This Mac found no route to %@ (%@). Either the route to the machine is gone, or macOS Local Network privacy has not allowed CloudMachine - then allow it in System Settings > Privacy & Security > Local Network. Check: cloudmachine-agent vm-status": + "Denne Mac fandt ingen rute til %@ (%@). Enten er ruten til maskinen væk, eller macOS' anonymitet for \"Lokalt netværk\" har ikke tilladt CloudMachine - tillad det så i Systemindstillinger > Anonymitet & sikkerhed > Lokalt netværk. Tjek: cloudmachine-agent vm-status", ] } diff --git a/mac-app/Sources/CloudMachineCore/L10nTranslations+de.swift b/mac-app/Sources/CloudMachineCore/L10nTranslations+de.swift index ea676f6..01ec0a4 100644 --- a/mac-app/Sources/CloudMachineCore/L10nTranslations+de.swift +++ b/mac-app/Sources/CloudMachineCore/L10nTranslations+de.swift @@ -947,5 +947,35 @@ extension L10nTranslations { "rclone hat Bänder in Bearbeitung, aber seit %@ wurde keines hochgeladen – meist eine unterbrochene Netzwerkverbindung, die rclone nicht als Fehler meldet. Backups seitdem liegen nur im Puffer der Maschine. Sobald das Netzwerk wieder da ist, macht der Upload von selbst weiter (am 28.10.2026 innerhalb von 80 s); falls nicht, verwirft ein Neustart der Maschine (prepare-shutdown, limactl start) die toten Verbindungen.", "tmutil did not answer - not restarting the machine while its state is unknown.": "tmutil hat nicht geantwortet – die Maschine wird nicht neu gestartet, solange ihr Zustand unbekannt ist.", + "The machine's share cannot be read": + "Die Freigabe der Maschine lässt sich nicht lesen", + "Unknown whether the machine's share can be read": + "Unbekannt, ob sich die Freigabe der Maschine lesen lässt", + "Samba in the machine does not answer this Mac": + "Samba in der Maschine antwortet diesem Mac nicht", + "Unknown whether Samba in the machine answers this Mac": + "Unbekannt, ob Samba in der Maschine diesem Mac antwortet", + "Reading 1 byte of %@ in the machine failed: %@. Every service says it is active, yet Time Machine cannot use the image there. Nothing restarts it by itself. Check: cloudmachine-agent vm-status, then in the machine: journalctl -u punchfs -u tm-bands-bind -u rclone-tm": + "Das Lesen von 1 Byte aus %@ in der Maschine ist fehlgeschlagen: %@. Jeder Dienst meldet sich als aktiv, doch Time Machine kann das Image dort nicht nutzen. Nichts startet ihn von selbst neu. Prüfen: cloudmachine-agent vm-status, dann in der Maschine: journalctl -u punchfs -u tm-bands-bind -u rclone-tm", + "Reading 1 byte of %@ in the machine has not returned for %@ s. That is how a hung punchfs or rclone mount behaves. Nothing is restarted while the state is unknown - a restart under a write still going could lose it. Check in the machine: journalctl -u punchfs -u rclone-tm": + "Das Lesen von 1 Byte aus %@ in der Maschine ist seit %@ s nicht zurückgekehrt. So verhält sich ein hängendes punchfs- oder rclone-Mount. Solange der Zustand unbekannt ist, wird nichts neu gestartet – ein Neustart unter einem laufenden Schreibvorgang könnte ihn verlieren. In der Maschine prüfen: journalctl -u punchfs -u rclone-tm", + "An SMB connection to %@ got no answer within %@ s. Time Machine reaches the machine at this address. Check the machine's network and smbd: cloudmachine-agent vm-status": + "Eine SMB-Verbindung zu %@ bekam innerhalb von %@ s keine Antwort. Unter dieser Adresse erreicht Time Machine die Maschine. Netzwerk der Maschine und smbd prüfen: cloudmachine-agent vm-status", + "The machine did not report its address, so nobody could try an SMB connection. Check: cloudmachine-agent vm-status": + "Die Maschine hat ihre Adresse nicht gemeldet, daher konnte niemand eine SMB-Verbindung versuchen. Prüfen: cloudmachine-agent vm-status", + "An SMB connection to %@ failed (%@). Time Machine reaches the machine at this address, so no backup is made. Check the machine's network and smbd: cloudmachine-agent vm-status": + "Eine SMB-Verbindung zu %@ ist fehlgeschlagen (%@). Unter dieser Adresse erreicht Time Machine die Maschine, daher entsteht kein Backup. Netzwerk der Maschine und smbd prüfen: cloudmachine-agent vm-status", + "readable": + "lesbar", + "no image yet": + "noch kein Image", + "answers": + "antwortet", + "Share read: %@": + "Freigabe lesen: %@", + "Samba from Mac: %@": + "Samba vom Mac: %@", + "This Mac found no route to %@ (%@). Either the route to the machine is gone, or macOS Local Network privacy has not allowed CloudMachine - then allow it in System Settings > Privacy & Security > Local Network. Check: cloudmachine-agent vm-status": + "Dieser Mac hat keine Route zu %@ gefunden (%@). Entweder ist die Route zur Maschine weg, oder der macOS-Datenschutz „Lokales Netzwerk“ hat CloudMachine nicht erlaubt – dann unter Systemeinstellungen > Datenschutz & Sicherheit > Lokales Netzwerk erlauben. Prüfen: cloudmachine-agent vm-status", ] } diff --git a/mac-app/Sources/CloudMachineCore/L10nTranslations+es.swift b/mac-app/Sources/CloudMachineCore/L10nTranslations+es.swift index 9bf6b3e..02dc636 100644 --- a/mac-app/Sources/CloudMachineCore/L10nTranslations+es.swift +++ b/mac-app/Sources/CloudMachineCore/L10nTranslations+es.swift @@ -947,5 +947,35 @@ extension L10nTranslations { "rclone tiene bandas en curso, pero ninguna se ha subido desde hace %@ - normalmente una conexión de red rota, que rclone no notifica como error. Las copias desde entonces están solo en el búfer de la máquina. Cuando vuelva la red, la subida se reanuda sola (el 28.10.2026, en 80 s); si no, reiniciar la máquina (prepare-shutdown, limactl start) descarta las conexiones muertas.", "tmutil did not answer - not restarting the machine while its state is unknown.": "tmutil no respondió - no se reinicia la máquina mientras se desconozca su estado.", + "The machine's share cannot be read": + "No se puede leer el recurso compartido de la máquina", + "Unknown whether the machine's share can be read": + "Se desconoce si el recurso compartido de la máquina se puede leer", + "Samba in the machine does not answer this Mac": + "Samba en la máquina no responde a este Mac", + "Unknown whether Samba in the machine answers this Mac": + "Se desconoce si Samba en la máquina responde a este Mac", + "Reading 1 byte of %@ in the machine failed: %@. Every service says it is active, yet Time Machine cannot use the image there. Nothing restarts it by itself. Check: cloudmachine-agent vm-status, then in the machine: journalctl -u punchfs -u tm-bands-bind -u rclone-tm": + "La lectura de 1 byte de %@ en la máquina falló: %@. Todos los servicios dicen estar activos, pero Time Machine no puede usar la imagen allí. Nada lo reinicia por sí solo. Comprueba: cloudmachine-agent vm-status y luego, en la máquina: journalctl -u punchfs -u tm-bands-bind -u rclone-tm", + "Reading 1 byte of %@ in the machine has not returned for %@ s. That is how a hung punchfs or rclone mount behaves. Nothing is restarted while the state is unknown - a restart under a write still going could lose it. Check in the machine: journalctl -u punchfs -u rclone-tm": + "La lectura de 1 byte de %@ en la máquina no ha vuelto desde hace %@ s. Así se comporta un montaje de punchfs o rclone colgado. No se reinicia nada mientras el estado sea desconocido: un reinicio bajo una escritura en curso podría perderla. Comprueba en la máquina: journalctl -u punchfs -u rclone-tm", + "An SMB connection to %@ got no answer within %@ s. Time Machine reaches the machine at this address. Check the machine's network and smbd: cloudmachine-agent vm-status": + "Una conexión SMB a %@ no obtuvo respuesta en %@ s. Time Machine llega a la máquina en esta dirección. Comprueba la red de la máquina y smbd: cloudmachine-agent vm-status", + "The machine did not report its address, so nobody could try an SMB connection. Check: cloudmachine-agent vm-status": + "La máquina no indicó su dirección, así que nadie pudo intentar una conexión SMB. Comprueba: cloudmachine-agent vm-status", + "An SMB connection to %@ failed (%@). Time Machine reaches the machine at this address, so no backup is made. Check the machine's network and smbd: cloudmachine-agent vm-status": + "Una conexión SMB a %@ falló (%@). Time Machine llega a la máquina en esta dirección, así que no se hace ninguna copia. Comprueba la red de la máquina y smbd: cloudmachine-agent vm-status", + "readable": + "legible", + "no image yet": + "aún sin imagen", + "answers": + "responde", + "Share read: %@": + "Lectura recurso: %@", + "Samba from Mac: %@": + "Samba desde Mac: %@", + "This Mac found no route to %@ (%@). Either the route to the machine is gone, or macOS Local Network privacy has not allowed CloudMachine - then allow it in System Settings > Privacy & Security > Local Network. Check: cloudmachine-agent vm-status": + "Este Mac no encontró ninguna ruta a %@ (%@). O la ruta a la máquina ha desaparecido, o la privacidad de «Red local» de macOS no ha permitido CloudMachine; en ese caso, permítelo en Ajustes del Sistema > Privacidad y seguridad > Red local. Comprueba: cloudmachine-agent vm-status", ] } diff --git a/mac-app/Sources/CloudMachineCore/L10nTranslations+fi.swift b/mac-app/Sources/CloudMachineCore/L10nTranslations+fi.swift index 5bf6873..6ddafd3 100644 --- a/mac-app/Sources/CloudMachineCore/L10nTranslations+fi.swift +++ b/mac-app/Sources/CloudMachineCore/L10nTranslations+fi.swift @@ -947,5 +947,35 @@ extension L10nTranslations { "rclonella on paloja käsittelyssä, mutta mitään ei ole lähetetty %@ aikana - yleensä katkennut verkkoyhteys, jota rclone ei ilmoita virheenä. Sen jälkeiset varmuuskopiot ovat vain virtuaalikoneen puskurissa. Kun verkko palaa, lähetys jatkuu itsestään (28.10.2026 80 s:ssa); jos ei, virtuaalikoneen uudelleenkäynnistys (prepare-shutdown, limactl start) katkaisee kuolleet yhteydet.", "tmutil did not answer - not restarting the machine while its state is unknown.": "tmutil ei vastannut - virtuaalikonetta ei käynnistetä uudelleen, kun sen tila on tuntematon.", + "The machine's share cannot be read": + "Koneen jakoa ei voi lukea", + "Unknown whether the machine's share can be read": + "Ei tiedetä, voiko koneen jakoa lukea", + "Samba in the machine does not answer this Mac": + "Koneen Samba ei vastaa tälle Macille", + "Unknown whether Samba in the machine answers this Mac": + "Ei tiedetä, vastaako koneen Samba tälle Macille", + "Reading 1 byte of %@ in the machine failed: %@. Every service says it is active, yet Time Machine cannot use the image there. Nothing restarts it by itself. Check: cloudmachine-agent vm-status, then in the machine: journalctl -u punchfs -u tm-bands-bind -u rclone-tm": + "1 tavun lukeminen kohteesta %@ koneessa epäonnistui: %@. Jokainen palvelu ilmoittaa olevansa aktiivinen, mutta Time Machine ei voi käyttää levykuvaa siellä. Mikään ei käynnistä sitä itsestään uudelleen. Tarkista: cloudmachine-agent vm-status, sitten koneessa: journalctl -u punchfs -u tm-bands-bind -u rclone-tm", + "Reading 1 byte of %@ in the machine has not returned for %@ s. That is how a hung punchfs or rclone mount behaves. Nothing is restarted while the state is unknown - a restart under a write still going could lose it. Check in the machine: journalctl -u punchfs -u rclone-tm": + "1 tavun lukeminen kohteesta %@ koneessa ei ole palannut %@ s:iin. Näin käyttäytyy jumittunut punchfs- tai rclone-liitos. Mitään ei käynnistetä uudelleen, kun tila on tuntematon - uudelleenkäynnistys kesken kirjoituksen voisi hukata sen. Tarkista koneessa: journalctl -u punchfs -u rclone-tm", + "An SMB connection to %@ got no answer within %@ s. Time Machine reaches the machine at this address. Check the machine's network and smbd: cloudmachine-agent vm-status": + "SMB-yhteys osoitteeseen %@ ei saanut vastausta %@ s:ssa. Time Machine tavoittaa koneen tästä osoitteesta. Tarkista koneen verkko ja smbd: cloudmachine-agent vm-status", + "The machine did not report its address, so nobody could try an SMB connection. Check: cloudmachine-agent vm-status": + "Kone ei ilmoittanut osoitettaan, joten kukaan ei voinut yrittää SMB-yhteyttä. Tarkista: cloudmachine-agent vm-status", + "An SMB connection to %@ failed (%@). Time Machine reaches the machine at this address, so no backup is made. Check the machine's network and smbd: cloudmachine-agent vm-status": + "SMB-yhteys osoitteeseen %@ epäonnistui (%@). Time Machine tavoittaa koneen tästä osoitteesta, joten varmuuskopiota ei tehdä. Tarkista koneen verkko ja smbd: cloudmachine-agent vm-status", + "readable": + "luettavissa", + "no image yet": + "ei vielä levykuvaa", + "answers": + "vastaa", + "Share read: %@": + "Jaon luku: %@", + "Samba from Mac: %@": + "Samba Macista: %@", + "This Mac found no route to %@ (%@). Either the route to the machine is gone, or macOS Local Network privacy has not allowed CloudMachine - then allow it in System Settings > Privacy & Security > Local Network. Check: cloudmachine-agent vm-status": + "Tämä Mac ei löytänyt reittiä kohteeseen %@ (%@). Joko reitti koneeseen on poissa, tai macOS:n Paikallisverkko-tietosuoja ei ole sallinut CloudMachinea - salli se silloin kohdassa Järjestelmäasetukset > Tietosuoja ja turvallisuus > Paikallisverkko. Tarkista: cloudmachine-agent vm-status", ] } diff --git a/mac-app/Sources/CloudMachineCore/L10nTranslations+fr.swift b/mac-app/Sources/CloudMachineCore/L10nTranslations+fr.swift index e8e74f7..e09b443 100644 --- a/mac-app/Sources/CloudMachineCore/L10nTranslations+fr.swift +++ b/mac-app/Sources/CloudMachineCore/L10nTranslations+fr.swift @@ -947,5 +947,35 @@ extension L10nTranslations { "rclone a des bandes en cours, mais aucune n'a été envoyée depuis %@ - généralement une connexion réseau coupée, que rclone ne signale pas comme une erreur. Les sauvegardes depuis ce moment ne sont que dans le tampon de la machine. Quand le réseau revient, l'envoi reprend tout seul (le 28.10.2026 en 80 s) ; sinon, redémarrer la machine (prepare-shutdown, limactl start) coupe les connexions mortes.", "tmutil did not answer - not restarting the machine while its state is unknown.": "tmutil n'a pas répondu - la machine n'est pas redémarrée tant que son état est inconnu.", + "The machine's share cannot be read": + "Impossible de lire le partage de la machine", + "Unknown whether the machine's share can be read": + "On ne sait pas si le partage de la machine est lisible", + "Samba in the machine does not answer this Mac": + "Samba dans la machine ne répond pas à ce Mac", + "Unknown whether Samba in the machine answers this Mac": + "On ne sait pas si Samba dans la machine répond à ce Mac", + "Reading 1 byte of %@ in the machine failed: %@. Every service says it is active, yet Time Machine cannot use the image there. Nothing restarts it by itself. Check: cloudmachine-agent vm-status, then in the machine: journalctl -u punchfs -u tm-bands-bind -u rclone-tm": + "La lecture de 1 octet de %@ dans la machine a échoué : %@. Chaque service se dit actif, mais Time Machine ne peut pas utiliser l'image à cet endroit. Rien ne le redémarre automatiquement. Vérifier : cloudmachine-agent vm-status, puis dans la machine : journalctl -u punchfs -u tm-bands-bind -u rclone-tm", + "Reading 1 byte of %@ in the machine has not returned for %@ s. That is how a hung punchfs or rclone mount behaves. Nothing is restarted while the state is unknown - a restart under a write still going could lose it. Check in the machine: journalctl -u punchfs -u rclone-tm": + "La lecture de 1 octet de %@ dans la machine n'est pas revenue depuis %@ s. C'est ainsi que se comporte un montage punchfs ou rclone bloqué. Rien n'est redémarré tant que l'état est inconnu - un redémarrage pendant une écriture en cours pourrait la perdre. Vérifier dans la machine : journalctl -u punchfs -u rclone-tm", + "An SMB connection to %@ got no answer within %@ s. Time Machine reaches the machine at this address. Check the machine's network and smbd: cloudmachine-agent vm-status": + "Une connexion SMB à %@ n'a reçu aucune réponse en %@ s. Time Machine joint la machine à cette adresse. Vérifiez le réseau de la machine et smbd : cloudmachine-agent vm-status", + "The machine did not report its address, so nobody could try an SMB connection. Check: cloudmachine-agent vm-status": + "La machine n'a pas indiqué son adresse, personne n'a donc pu tenter de connexion SMB. Vérifier : cloudmachine-agent vm-status", + "An SMB connection to %@ failed (%@). Time Machine reaches the machine at this address, so no backup is made. Check the machine's network and smbd: cloudmachine-agent vm-status": + "Une connexion SMB à %@ a échoué (%@). Time Machine joint la machine à cette adresse, donc aucune sauvegarde n'est faite. Vérifiez le réseau de la machine et smbd : cloudmachine-agent vm-status", + "readable": + "lisible", + "no image yet": + "pas encore d'image", + "answers": + "répond", + "Share read: %@": + "Lecture partage : %@", + "Samba from Mac: %@": + "Samba (Mac) : %@", + "This Mac found no route to %@ (%@). Either the route to the machine is gone, or macOS Local Network privacy has not allowed CloudMachine - then allow it in System Settings > Privacy & Security > Local Network. Check: cloudmachine-agent vm-status": + "Ce Mac n'a trouvé aucune route vers %@ (%@). Soit la route vers la machine a disparu, soit la confidentialité « Réseau local » de macOS n'a pas autorisé CloudMachine - autorisez-le alors dans Réglages Système > Confidentialité et sécurité > Réseau local. Vérifier : cloudmachine-agent vm-status", ] } diff --git a/mac-app/Sources/CloudMachineCore/L10nTranslations+he.swift b/mac-app/Sources/CloudMachineCore/L10nTranslations+he.swift index bd5e940..daf8a3a 100644 --- a/mac-app/Sources/CloudMachineCore/L10nTranslations+he.swift +++ b/mac-app/Sources/CloudMachineCore/L10nTranslations+he.swift @@ -947,5 +947,35 @@ extension L10nTranslations { "ל-rclone יש חלקים בעיבוד, אבל אף אחד לא הועלה במשך %@ - לרוב בגלל חיבור רשת שנותק, ש-rclone אינו מדווח עליו כשגיאה. גיבויים מאז נמצאים רק במאגר הביניים של המכונה. כשהרשת חוזרת ההעלאה ממשיכה מעצמה (ב-28.10.2026 תוך 80 s); אם לא, הפעלה מחדש של המכונה (prepare-shutdown, limactl start) מנתקת את החיבורים המתים.", "tmutil did not answer - not restarting the machine while its state is unknown.": "tmutil לא ענה - לא מפעילים מחדש את המכונה כל עוד מצבה לא ידוע.", + "The machine's share cannot be read": + "לא ניתן לקרוא את השיתוף של המכונה", + "Unknown whether the machine's share can be read": + "לא ידוע אם ניתן לקרוא את השיתוף של המכונה", + "Samba in the machine does not answer this Mac": + "Samba במכונה לא עונה ל-Mac הזה", + "Unknown whether Samba in the machine answers this Mac": + "לא ידוע אם Samba במכונה עונה ל-Mac הזה", + "Reading 1 byte of %@ in the machine failed: %@. Every service says it is active, yet Time Machine cannot use the image there. Nothing restarts it by itself. Check: cloudmachine-agent vm-status, then in the machine: journalctl -u punchfs -u tm-bands-bind -u rclone-tm": + "קריאת בית אחד מ-%@ במכונה נכשלה: %@. כל שירות מדווח שהוא פעיל, ובכל זאת Time Machine לא יכול להשתמש בתמונה שם. שום דבר לא מפעיל אותו מחדש מעצמו. בדיקה: cloudmachine-agent vm-status, ואז במכונה: journalctl -u punchfs -u tm-bands-bind -u rclone-tm", + "Reading 1 byte of %@ in the machine has not returned for %@ s. That is how a hung punchfs or rclone mount behaves. Nothing is restarted while the state is unknown - a restart under a write still going could lose it. Check in the machine: journalctl -u punchfs -u rclone-tm": + "קריאת בית אחד מ-%@ במכונה לא חזרה כבר %@ שנ'. כך מתנהג עיגון תקוע של punchfs או rclone. שום דבר לא מופעל מחדש כל עוד המצב לא ידוע - הפעלה מחדש באמצע כתיבה שעדיין מתבצעת עלולה לאבד אותה. בדיקה במכונה: journalctl -u punchfs -u rclone-tm", + "An SMB connection to %@ got no answer within %@ s. Time Machine reaches the machine at this address. Check the machine's network and smbd: cloudmachine-agent vm-status": + "חיבור SMB אל %@ לא קיבל תשובה בתוך %@ שנ'. Time Machine מגיע למכונה בכתובת הזו. בדקו את הרשת של המכונה ואת smbd: cloudmachine-agent vm-status", + "The machine did not report its address, so nobody could try an SMB connection. Check: cloudmachine-agent vm-status": + "המכונה לא דיווחה על הכתובת שלה, ולכן איש לא יכול היה לנסות חיבור SMB. בדיקה: cloudmachine-agent vm-status", + "An SMB connection to %@ failed (%@). Time Machine reaches the machine at this address, so no backup is made. Check the machine's network and smbd: cloudmachine-agent vm-status": + "חיבור SMB אל %@ נכשל (%@). Time Machine מגיע למכונה בכתובת הזו, ולכן לא נוצר גיבוי. בדקו את הרשת של המכונה ואת smbd: cloudmachine-agent vm-status", + "readable": + "קריא", + "no image yet": + "עדיין אין תמונה", + "answers": + "עונה", + "Share read: %@": + "קריאת השיתוף: %@", + "Samba from Mac: %@": + "Samba מה-Mac: %@", + "This Mac found no route to %@ (%@). Either the route to the machine is gone, or macOS Local Network privacy has not allowed CloudMachine - then allow it in System Settings > Privacy & Security > Local Network. Check: cloudmachine-agent vm-status": + "ה-Mac הזה לא מצא נתיב אל %@ (%@). או שהנתיב למכונה נעלם, או שפרטיות \"רשת מקומית\" של macOS לא התירה ל-CloudMachine - במקרה כזה התירו זאת בהגדרות המערכת > פרטיות ואבטחה > רשת מקומית. בדיקה: cloudmachine-agent vm-status", ] } diff --git a/mac-app/Sources/CloudMachineCore/L10nTranslations+it.swift b/mac-app/Sources/CloudMachineCore/L10nTranslations+it.swift index fcc9af1..128d9bb 100644 --- a/mac-app/Sources/CloudMachineCore/L10nTranslations+it.swift +++ b/mac-app/Sources/CloudMachineCore/L10nTranslations+it.swift @@ -947,5 +947,35 @@ extension L10nTranslations { "rclone ha bande in corso, ma nessuna è stata inviata da %@ - di solito una connessione di rete interrotta, che rclone non segnala come errore. I backup da allora sono solo nel buffer della macchina. Quando la rete torna, l'invio riprende da solo (il 28.10.2026 entro 80 s); se non succede, riavviare la macchina (prepare-shutdown, limactl start) chiude le connessioni morte.", "tmutil did not answer - not restarting the machine while its state is unknown.": "tmutil non ha risposto - non riavvio la macchina finché il suo stato è sconosciuto.", + "The machine's share cannot be read": + "Impossibile leggere la condivisione della macchina", + "Unknown whether the machine's share can be read": + "Non si sa se la condivisione della macchina sia leggibile", + "Samba in the machine does not answer this Mac": + "Samba nella macchina non risponde a questo Mac", + "Unknown whether Samba in the machine answers this Mac": + "Non si sa se Samba nella macchina risponda a questo Mac", + "Reading 1 byte of %@ in the machine failed: %@. Every service says it is active, yet Time Machine cannot use the image there. Nothing restarts it by itself. Check: cloudmachine-agent vm-status, then in the machine: journalctl -u punchfs -u tm-bands-bind -u rclone-tm": + "La lettura di 1 byte di %@ nella macchina non è riuscita: %@. Ogni servizio risulta attivo, eppure Time Machine non può usare l'immagine lì. Nulla lo riavvia da solo. Controlla: cloudmachine-agent vm-status, poi nella macchina: journalctl -u punchfs -u tm-bands-bind -u rclone-tm", + "Reading 1 byte of %@ in the machine has not returned for %@ s. That is how a hung punchfs or rclone mount behaves. Nothing is restarted while the state is unknown - a restart under a write still going could lose it. Check in the machine: journalctl -u punchfs -u rclone-tm": + "La lettura di 1 byte di %@ nella macchina non è tornata da %@ s. Così si comporta un mount punchfs o rclone bloccato. Finché lo stato è sconosciuto non si riavvia nulla: un riavvio durante una scrittura in corso potrebbe perderla. Controlla nella macchina: journalctl -u punchfs -u rclone-tm", + "An SMB connection to %@ got no answer within %@ s. Time Machine reaches the machine at this address. Check the machine's network and smbd: cloudmachine-agent vm-status": + "Una connessione SMB a %@ non ha ricevuto risposta entro %@ s. Time Machine raggiunge la macchina a questo indirizzo. Controlla la rete della macchina e smbd: cloudmachine-agent vm-status", + "The machine did not report its address, so nobody could try an SMB connection. Check: cloudmachine-agent vm-status": + "La macchina non ha comunicato il suo indirizzo, quindi nessuno ha potuto tentare una connessione SMB. Controlla: cloudmachine-agent vm-status", + "An SMB connection to %@ failed (%@). Time Machine reaches the machine at this address, so no backup is made. Check the machine's network and smbd: cloudmachine-agent vm-status": + "Una connessione SMB a %@ non è riuscita (%@). Time Machine raggiunge la macchina a questo indirizzo, quindi non viene fatto alcun backup. Controlla la rete della macchina e smbd: cloudmachine-agent vm-status", + "readable": + "leggibile", + "no image yet": + "ancora nessuna immagine", + "answers": + "risponde", + "Share read: %@": + "Lettura condiv.: %@", + "Samba from Mac: %@": + "Samba dal Mac: %@", + "This Mac found no route to %@ (%@). Either the route to the machine is gone, or macOS Local Network privacy has not allowed CloudMachine - then allow it in System Settings > Privacy & Security > Local Network. Check: cloudmachine-agent vm-status": + "Questo Mac non ha trovato alcun percorso verso %@ (%@). O il percorso verso la macchina non c'è più, oppure la privacy «Rete locale» di macOS non ha consentito CloudMachine: in tal caso consentilo in Impostazioni di Sistema > Privacy e sicurezza > Rete locale. Controlla: cloudmachine-agent vm-status", ] } diff --git a/mac-app/Sources/CloudMachineCore/L10nTranslations+ja.swift b/mac-app/Sources/CloudMachineCore/L10nTranslations+ja.swift index e918b98..12fe232 100644 --- a/mac-app/Sources/CloudMachineCore/L10nTranslations+ja.swift +++ b/mac-app/Sources/CloudMachineCore/L10nTranslations+ja.swift @@ -947,5 +947,35 @@ extension L10nTranslations { "rcloneには処理中のバンドがありますが、%1$@の間、1つもアップロードされていません - 通常はネットワーク接続の切断が原因で、rcloneはこれをエラーとして報告しません。それ以降のバックアップは、マシンのバッファにのみあります。ネットワークが復旧すると、アップロードは自動的に再開します(2026年10月28日には80秒以内)。再開しない場合は、マシンを再起動する(prepare-shutdown、limactl start)と、切れた接続が破棄されます。", "tmutil did not answer - not restarting the machine while its state is unknown.": "tmutilから応答がありませんでした - マシンの状態が不明な間は、マシンを再起動しません。", + "The machine's share cannot be read": + "マシンの共有を読み取れません", + "Unknown whether the machine's share can be read": + "マシンの共有を読み取れるか不明です", + "Samba in the machine does not answer this Mac": + "マシンの Samba がこの Mac に応答しません", + "Unknown whether Samba in the machine answers this Mac": + "マシンの Samba がこの Mac に応答するか不明です", + "Reading 1 byte of %@ in the machine failed: %@. Every service says it is active, yet Time Machine cannot use the image there. Nothing restarts it by itself. Check: cloudmachine-agent vm-status, then in the machine: journalctl -u punchfs -u tm-bands-bind -u rclone-tm": + "マシン内の %@ から 1 バイトを読み取れませんでした: %@。すべてのサービスはアクティブと報告していますが、Time Machine はそこでイメージを使えません。自動では再起動されません。確認: cloudmachine-agent vm-status、続いてマシン内で: journalctl -u punchfs -u tm-bands-bind -u rclone-tm", + "Reading 1 byte of %@ in the machine has not returned for %@ s. That is how a hung punchfs or rclone mount behaves. Nothing is restarted while the state is unknown - a restart under a write still going could lose it. Check in the machine: journalctl -u punchfs -u rclone-tm": + "マシン内の %@ からの 1 バイトの読み取りが %@ 秒間戻っていません。punchfs または rclone のマウントが停止しているときの挙動です。状態が不明な間は何も再起動しません - 書き込み中に再起動するとその書き込みが失われるおそれがあります。マシン内で確認: journalctl -u punchfs -u rclone-tm", + "An SMB connection to %@ got no answer within %@ s. Time Machine reaches the machine at this address. Check the machine's network and smbd: cloudmachine-agent vm-status": + "%@ への SMB 接続は %@ 秒以内に応答がありませんでした。Time Machine はこのアドレスでマシンに接続します。マシンのネットワークと smbd を確認してください: cloudmachine-agent vm-status", + "The machine did not report its address, so nobody could try an SMB connection. Check: cloudmachine-agent vm-status": + "マシンがアドレスを報告しなかったため、SMB 接続を試せませんでした。確認: cloudmachine-agent vm-status", + "An SMB connection to %@ failed (%@). Time Machine reaches the machine at this address, so no backup is made. Check the machine's network and smbd: cloudmachine-agent vm-status": + "%@ への SMB 接続に失敗しました(%@)。Time Machine はこのアドレスでマシンに接続するため、バックアップは作成されません。マシンのネットワークと smbd を確認してください: cloudmachine-agent vm-status", + "readable": + "読み取り可", + "no image yet": + "イメージはまだありません", + "answers": + "応答あり", + "Share read: %@": + "共有の読み取り: %@", + "Samba from Mac: %@": + "Mac からの Samba: %@", + "This Mac found no route to %@ (%@). Either the route to the machine is gone, or macOS Local Network privacy has not allowed CloudMachine - then allow it in System Settings > Privacy & Security > Local Network. Check: cloudmachine-agent vm-status": + "この Mac は %@ への経路を見つけられませんでした(%@)。マシンへの経路がなくなったか、macOS の「ローカルネットワーク」プライバシーが CloudMachine を許可していません - その場合は システム設定 > プライバシーとセキュリティ > ローカルネットワーク で許可してください。確認: cloudmachine-agent vm-status", ] } diff --git a/mac-app/Sources/CloudMachineCore/L10nTranslations+ko.swift b/mac-app/Sources/CloudMachineCore/L10nTranslations+ko.swift index 88978c4..44e79fb 100644 --- a/mac-app/Sources/CloudMachineCore/L10nTranslations+ko.swift +++ b/mac-app/Sources/CloudMachineCore/L10nTranslations+ko.swift @@ -947,5 +947,35 @@ extension L10nTranslations { "rclone에 진행 중인 밴드가 있지만 %1$@ 동안 업로드된 것이 없습니다 - 대개 네트워크 연결이 끊긴 경우이며, rclone은 이를 오류로 알리지 않습니다. 그 이후의 백업은 머신의 버퍼에만 있습니다. 네트워크가 복구되면 업로드가 저절로 재개됩니다(2026년 10월 28일에는 80초 이내). 재개되지 않으면 머신을 다시 시작하면(prepare-shutdown, limactl start) 끊어진 연결이 정리됩니다.", "tmutil did not answer - not restarting the machine while its state is unknown.": "tmutil이 응답하지 않았습니다 - 머신의 상태를 모르는 동안에는 머신을 다시 시작하지 않습니다.", + "The machine's share cannot be read": + "머신의 공유를 읽을 수 없습니다", + "Unknown whether the machine's share can be read": + "머신의 공유를 읽을 수 있는지 알 수 없습니다", + "Samba in the machine does not answer this Mac": + "머신의 Samba가 이 Mac에 응답하지 않습니다", + "Unknown whether Samba in the machine answers this Mac": + "머신의 Samba가 이 Mac에 응답하는지 알 수 없습니다", + "Reading 1 byte of %@ in the machine failed: %@. Every service says it is active, yet Time Machine cannot use the image there. Nothing restarts it by itself. Check: cloudmachine-agent vm-status, then in the machine: journalctl -u punchfs -u tm-bands-bind -u rclone-tm": + "머신의 %@에서 1바이트를 읽지 못했습니다: %@. 모든 서비스가 활성 상태라고 하지만 Time Machine은 그곳의 이미지를 사용할 수 없습니다. 스스로 재시작되지 않습니다. 확인: cloudmachine-agent vm-status, 그다음 머신에서: journalctl -u punchfs -u tm-bands-bind -u rclone-tm", + "Reading 1 byte of %@ in the machine has not returned for %@ s. That is how a hung punchfs or rclone mount behaves. Nothing is restarted while the state is unknown - a restart under a write still going could lose it. Check in the machine: journalctl -u punchfs -u rclone-tm": + "머신의 %@에서 1바이트 읽기가 %@초째 돌아오지 않습니다. punchfs 또는 rclone 마운트가 멈췄을 때의 동작입니다. 상태를 알 수 없는 동안에는 아무것도 재시작하지 않습니다 - 진행 중인 쓰기 도중 재시작하면 그 쓰기를 잃을 수 있습니다. 머신에서 확인: journalctl -u punchfs -u rclone-tm", + "An SMB connection to %@ got no answer within %@ s. Time Machine reaches the machine at this address. Check the machine's network and smbd: cloudmachine-agent vm-status": + "%@(으)로의 SMB 연결이 %@초 안에 응답을 받지 못했습니다. Time Machine은 이 주소로 머신에 접속합니다. 머신의 네트워크와 smbd를 확인하세요: cloudmachine-agent vm-status", + "The machine did not report its address, so nobody could try an SMB connection. Check: cloudmachine-agent vm-status": + "머신이 주소를 알려 주지 않아 아무도 SMB 연결을 시도할 수 없었습니다. 확인: cloudmachine-agent vm-status", + "An SMB connection to %@ failed (%@). Time Machine reaches the machine at this address, so no backup is made. Check the machine's network and smbd: cloudmachine-agent vm-status": + "%@(으)로의 SMB 연결에 실패했습니다(%@). Time Machine은 이 주소로 머신에 접속하므로 백업이 만들어지지 않습니다. 머신의 네트워크와 smbd를 확인하세요: cloudmachine-agent vm-status", + "readable": + "읽기 가능", + "no image yet": + "아직 이미지 없음", + "answers": + "응답함", + "Share read: %@": + "공유 읽기: %@", + "Samba from Mac: %@": + "Mac에서 Samba: %@", + "This Mac found no route to %@ (%@). Either the route to the machine is gone, or macOS Local Network privacy has not allowed CloudMachine - then allow it in System Settings > Privacy & Security > Local Network. Check: cloudmachine-agent vm-status": + "이 Mac이 %@(으)로 가는 경로를 찾지 못했습니다(%@). 머신으로 가는 경로가 사라졌거나 macOS의 '로컬 네트워크' 개인정보 보호가 CloudMachine을 허용하지 않았습니다 - 그렇다면 시스템 설정 > 개인정보 보호 및 보안 > 로컬 네트워크에서 허용하세요. 확인: cloudmachine-agent vm-status", ] } diff --git a/mac-app/Sources/CloudMachineCore/L10nTranslations+ms.swift b/mac-app/Sources/CloudMachineCore/L10nTranslations+ms.swift index a68b746..567a0ae 100644 --- a/mac-app/Sources/CloudMachineCore/L10nTranslations+ms.swift +++ b/mac-app/Sources/CloudMachineCore/L10nTranslations+ms.swift @@ -947,5 +947,35 @@ extension L10nTranslations { "rclone mempunyai jalur sedang berjalan, tetapi tiada yang dimuat naik selama %@ - biasanya sambungan rangkaian terputus, yang tidak dilaporkan oleh rclone sebagai ralat. Sandaran sejak itu hanya ada dalam penimbal mesin. Apabila rangkaian kembali, muat naik menyambung sendiri (pada 28.10.2026 dalam 80 s); jika tidak, memulakan semula mesin (prepare-shutdown, limactl start) menutup sambungan yang mati.", "tmutil did not answer - not restarting the machine while its state is unknown.": "tmutil tidak menjawab - mesin tidak dimulakan semula selagi keadaannya tidak diketahui.", + "The machine's share cannot be read": + "Perkongsian mesin tidak boleh dibaca", + "Unknown whether the machine's share can be read": + "Tidak diketahui sama ada perkongsian mesin boleh dibaca", + "Samba in the machine does not answer this Mac": + "Samba dalam mesin tidak menjawab Mac ini", + "Unknown whether Samba in the machine answers this Mac": + "Tidak diketahui sama ada Samba dalam mesin menjawab Mac ini", + "Reading 1 byte of %@ in the machine failed: %@. Every service says it is active, yet Time Machine cannot use the image there. Nothing restarts it by itself. Check: cloudmachine-agent vm-status, then in the machine: journalctl -u punchfs -u tm-bands-bind -u rclone-tm": + "Membaca 1 bait daripada %@ dalam mesin gagal: %@. Setiap perkhidmatan mengatakan ia aktif, tetapi Time Machine tidak dapat menggunakan imej di situ. Tiada apa yang memulakannya semula dengan sendiri. Semak: cloudmachine-agent vm-status, kemudian dalam mesin: journalctl -u punchfs -u tm-bands-bind -u rclone-tm", + "Reading 1 byte of %@ in the machine has not returned for %@ s. That is how a hung punchfs or rclone mount behaves. Nothing is restarted while the state is unknown - a restart under a write still going could lose it. Check in the machine: journalctl -u punchfs -u rclone-tm": + "Membaca 1 bait daripada %@ dalam mesin belum kembali selama %@ s. Begitulah kelakuan lekapan punchfs atau rclone yang tergantung. Tiada apa yang dimulakan semula selagi keadaan tidak diketahui - mula semula di bawah penulisan yang masih berjalan boleh menghilangkannya. Semak dalam mesin: journalctl -u punchfs -u rclone-tm", + "An SMB connection to %@ got no answer within %@ s. Time Machine reaches the machine at this address. Check the machine's network and smbd: cloudmachine-agent vm-status": + "Sambungan SMB ke %@ tidak mendapat jawapan dalam %@ s. Time Machine mencapai mesin pada alamat ini. Semak rangkaian mesin dan smbd: cloudmachine-agent vm-status", + "The machine did not report its address, so nobody could try an SMB connection. Check: cloudmachine-agent vm-status": + "Mesin tidak melaporkan alamatnya, jadi tiada sesiapa dapat mencuba sambungan SMB. Semak: cloudmachine-agent vm-status", + "An SMB connection to %@ failed (%@). Time Machine reaches the machine at this address, so no backup is made. Check the machine's network and smbd: cloudmachine-agent vm-status": + "Sambungan SMB ke %@ gagal (%@). Time Machine mencapai mesin pada alamat ini, jadi tiada sandaran dibuat. Semak rangkaian mesin dan smbd: cloudmachine-agent vm-status", + "readable": + "boleh dibaca", + "no image yet": + "belum ada imej", + "answers": + "menjawab", + "Share read: %@": + "Bacaan kongsi: %@", + "Samba from Mac: %@": + "Samba dari Mac: %@", + "This Mac found no route to %@ (%@). Either the route to the machine is gone, or macOS Local Network privacy has not allowed CloudMachine - then allow it in System Settings > Privacy & Security > Local Network. Check: cloudmachine-agent vm-status": + "Mac ini tidak menemui laluan ke %@ (%@). Sama ada laluan ke mesin sudah hilang, atau privasi Rangkaian Setempat macOS belum membenarkan CloudMachine - jika begitu, benarkannya dalam Seting Sistem > Privasi & Keselamatan > Rangkaian Setempat. Semak: cloudmachine-agent vm-status", ] } diff --git a/mac-app/Sources/CloudMachineCore/L10nTranslations+nb.swift b/mac-app/Sources/CloudMachineCore/L10nTranslations+nb.swift index c485bea..9a369e0 100644 --- a/mac-app/Sources/CloudMachineCore/L10nTranslations+nb.swift +++ b/mac-app/Sources/CloudMachineCore/L10nTranslations+nb.swift @@ -947,5 +947,35 @@ extension L10nTranslations { "rclone har bånd under behandling, men ingen er lastet opp på %@ - vanligvis en brutt nettverkstilkobling, som rclone ikke melder som en feil. Sikkerhetskopier siden da ligger bare i maskinens buffer. Når nettverket kommer tilbake, fortsetter opplastingen av seg selv (den 28.10.2026 innen 80 s); hvis ikke, fjerner en omstart av maskinen (prepare-shutdown, limactl start) de døde tilkoblingene.", "tmutil did not answer - not restarting the machine while its state is unknown.": "tmutil svarte ikke - starter ikke maskinen på nytt mens tilstanden er ukjent.", + "The machine's share cannot be read": + "Maskinens deling kan ikke leses", + "Unknown whether the machine's share can be read": + "Ukjent om maskinens deling kan leses", + "Samba in the machine does not answer this Mac": + "Samba i maskinen svarer ikke denne Macen", + "Unknown whether Samba in the machine answers this Mac": + "Ukjent om Samba i maskinen svarer denne Macen", + "Reading 1 byte of %@ in the machine failed: %@. Every service says it is active, yet Time Machine cannot use the image there. Nothing restarts it by itself. Check: cloudmachine-agent vm-status, then in the machine: journalctl -u punchfs -u tm-bands-bind -u rclone-tm": + "Lesing av 1 byte fra %@ i maskinen mislyktes: %@. Alle tjenester sier at de er aktive, men Time Machine kan ikke bruke bildet der. Ingenting starter det på nytt av seg selv. Sjekk: cloudmachine-agent vm-status, deretter i maskinen: journalctl -u punchfs -u tm-bands-bind -u rclone-tm", + "Reading 1 byte of %@ in the machine has not returned for %@ s. That is how a hung punchfs or rclone mount behaves. Nothing is restarted while the state is unknown - a restart under a write still going could lose it. Check in the machine: journalctl -u punchfs -u rclone-tm": + "Lesing av 1 byte fra %@ i maskinen har ikke kommet tilbake på %@ s. Slik oppfører en hengende punchfs- eller rclone-montering seg. Ingenting startes på nytt så lenge tilstanden er ukjent - en omstart under en pågående skriving kan miste den. Sjekk i maskinen: journalctl -u punchfs -u rclone-tm", + "An SMB connection to %@ got no answer within %@ s. Time Machine reaches the machine at this address. Check the machine's network and smbd: cloudmachine-agent vm-status": + "En SMB-tilkobling til %@ fikk ikke svar innen %@ s. Time Machine når maskinen på denne adressen. Sjekk maskinens nettverk og smbd: cloudmachine-agent vm-status", + "The machine did not report its address, so nobody could try an SMB connection. Check: cloudmachine-agent vm-status": + "Maskinen oppga ikke adressen sin, så ingen kunne prøve en SMB-tilkobling. Sjekk: cloudmachine-agent vm-status", + "An SMB connection to %@ failed (%@). Time Machine reaches the machine at this address, so no backup is made. Check the machine's network and smbd: cloudmachine-agent vm-status": + "En SMB-tilkobling til %@ mislyktes (%@). Time Machine når maskinen på denne adressen, så ingen sikkerhetskopi blir laget. Sjekk maskinens nettverk og smbd: cloudmachine-agent vm-status", + "readable": + "lesbar", + "no image yet": + "ikke noe bilde ennå", + "answers": + "svarer", + "Share read: %@": + "Lesing av deling: %@", + "Samba from Mac: %@": + "Samba fra Mac: %@", + "This Mac found no route to %@ (%@). Either the route to the machine is gone, or macOS Local Network privacy has not allowed CloudMachine - then allow it in System Settings > Privacy & Security > Local Network. Check: cloudmachine-agent vm-status": + "Denne Macen fant ingen rute til %@ (%@). Enten er ruten til maskinen borte, eller personvernet for «Lokalt nettverk» i macOS har ikke tillatt CloudMachine - tillat det da i Systeminnstillinger > Personvern og sikkerhet > Lokalt nettverk. Sjekk: cloudmachine-agent vm-status", ] } diff --git a/mac-app/Sources/CloudMachineCore/L10nTranslations+nl.swift b/mac-app/Sources/CloudMachineCore/L10nTranslations+nl.swift index 3e23a14..772919d 100644 --- a/mac-app/Sources/CloudMachineCore/L10nTranslations+nl.swift +++ b/mac-app/Sources/CloudMachineCore/L10nTranslations+nl.swift @@ -947,5 +947,35 @@ extension L10nTranslations { "rclone heeft bands in behandeling, maar al %@ is er geen geüpload - meestal een verbroken netwerkverbinding, die rclone niet als fout meldt. Back-ups sindsdien staan alleen in de buffer van de machine. Zodra het netwerk terug is, gaat de upload vanzelf verder (op 28.10.2026 binnen 80 s); zo niet, dan verwijdert een herstart van de machine (prepare-shutdown, limactl start) de dode verbindingen.", "tmutil did not answer - not restarting the machine while its state is unknown.": "tmutil heeft niet geantwoord - de machine wordt niet opnieuw gestart zolang de status onbekend is.", + "The machine's share cannot be read": + "De share van de machine kan niet worden gelezen", + "Unknown whether the machine's share can be read": + "Onbekend of de share van de machine leesbaar is", + "Samba in the machine does not answer this Mac": + "Samba in de machine antwoordt deze Mac niet", + "Unknown whether Samba in the machine answers this Mac": + "Onbekend of Samba in de machine deze Mac antwoordt", + "Reading 1 byte of %@ in the machine failed: %@. Every service says it is active, yet Time Machine cannot use the image there. Nothing restarts it by itself. Check: cloudmachine-agent vm-status, then in the machine: journalctl -u punchfs -u tm-bands-bind -u rclone-tm": + "Het lezen van 1 byte van %@ in de machine is mislukt: %@. Elke dienst zegt actief te zijn, maar Time Machine kan de image daar niet gebruiken. Niets herstart het vanzelf. Controleer: cloudmachine-agent vm-status, daarna in de machine: journalctl -u punchfs -u tm-bands-bind -u rclone-tm", + "Reading 1 byte of %@ in the machine has not returned for %@ s. That is how a hung punchfs or rclone mount behaves. Nothing is restarted while the state is unknown - a restart under a write still going could lose it. Check in the machine: journalctl -u punchfs -u rclone-tm": + "Het lezen van 1 byte van %@ in de machine is al %@ s niet teruggekomen. Zo gedraagt een vastgelopen punchfs- of rclone-mount zich. Zolang de toestand onbekend is, wordt niets herstart - een herstart onder een lopende schrijfactie kan die verliezen. Controleer in de machine: journalctl -u punchfs -u rclone-tm", + "An SMB connection to %@ got no answer within %@ s. Time Machine reaches the machine at this address. Check the machine's network and smbd: cloudmachine-agent vm-status": + "Een SMB-verbinding met %@ kreeg binnen %@ s geen antwoord. Time Machine bereikt de machine op dit adres. Controleer het netwerk van de machine en smbd: cloudmachine-agent vm-status", + "The machine did not report its address, so nobody could try an SMB connection. Check: cloudmachine-agent vm-status": + "De machine heeft haar adres niet gemeld, dus niemand kon een SMB-verbinding proberen. Controleer: cloudmachine-agent vm-status", + "An SMB connection to %@ failed (%@). Time Machine reaches the machine at this address, so no backup is made. Check the machine's network and smbd: cloudmachine-agent vm-status": + "Een SMB-verbinding met %@ is mislukt (%@). Time Machine bereikt de machine op dit adres, dus er wordt geen back-up gemaakt. Controleer het netwerk van de machine en smbd: cloudmachine-agent vm-status", + "readable": + "leesbaar", + "no image yet": + "nog geen image", + "answers": + "antwoordt", + "Share read: %@": + "Share lezen: %@", + "Samba from Mac: %@": + "Samba vanaf Mac: %@", + "This Mac found no route to %@ (%@). Either the route to the machine is gone, or macOS Local Network privacy has not allowed CloudMachine - then allow it in System Settings > Privacy & Security > Local Network. Check: cloudmachine-agent vm-status": + "Deze Mac vond geen route naar %@ (%@). Of de route naar de machine is weg, of de macOS-privacy 'Lokaal netwerk' heeft CloudMachine niet toegestaan - sta het dan toe in Systeeminstellingen > Privacy en beveiliging > Lokaal netwerk. Controleer: cloudmachine-agent vm-status", ] } diff --git a/mac-app/Sources/CloudMachineCore/L10nTranslations+pt-BR.swift b/mac-app/Sources/CloudMachineCore/L10nTranslations+pt-BR.swift index 7c2e2c8..ebc9dc3 100644 --- a/mac-app/Sources/CloudMachineCore/L10nTranslations+pt-BR.swift +++ b/mac-app/Sources/CloudMachineCore/L10nTranslations+pt-BR.swift @@ -947,5 +947,35 @@ extension L10nTranslations { "O rclone tem bandas em andamento, mas nenhuma foi enviada há %@ - normalmente uma conexão de rede interrompida, que o rclone não relata como erro. Os backups desde então estão apenas no buffer da máquina. Quando a rede voltar, o envio retoma sozinho (em 28.10.2026, em 80 s); se não retomar, reiniciar a máquina (prepare-shutdown, limactl start) descarta as conexões mortas.", "tmutil did not answer - not restarting the machine while its state is unknown.": "O tmutil não respondeu - a máquina não será reiniciada enquanto o estado for desconhecido.", + "The machine's share cannot be read": + "Não é possível ler o compartilhamento da máquina", + "Unknown whether the machine's share can be read": + "Não se sabe se o compartilhamento da máquina pode ser lido", + "Samba in the machine does not answer this Mac": + "O Samba na máquina não responde a este Mac", + "Unknown whether Samba in the machine answers this Mac": + "Não se sabe se o Samba na máquina responde a este Mac", + "Reading 1 byte of %@ in the machine failed: %@. Every service says it is active, yet Time Machine cannot use the image there. Nothing restarts it by itself. Check: cloudmachine-agent vm-status, then in the machine: journalctl -u punchfs -u tm-bands-bind -u rclone-tm": + "A leitura de 1 byte de %@ na máquina falhou: %@. Todos os serviços dizem estar ativos, mas o Time Machine não consegue usar a imagem ali. Nada o reinicia sozinho. Verifique: cloudmachine-agent vm-status e depois, na máquina: journalctl -u punchfs -u tm-bands-bind -u rclone-tm", + "Reading 1 byte of %@ in the machine has not returned for %@ s. That is how a hung punchfs or rclone mount behaves. Nothing is restarted while the state is unknown - a restart under a write still going could lose it. Check in the machine: journalctl -u punchfs -u rclone-tm": + "A leitura de 1 byte de %@ na máquina não retornou há %@ s. É assim que se comporta uma montagem do punchfs ou do rclone travada. Nada é reiniciado enquanto o estado for desconhecido - um reinício durante uma gravação em andamento poderia perdê-la. Verifique na máquina: journalctl -u punchfs -u rclone-tm", + "An SMB connection to %@ got no answer within %@ s. Time Machine reaches the machine at this address. Check the machine's network and smbd: cloudmachine-agent vm-status": + "Uma conexão SMB com %@ não obteve resposta em %@ s. O Time Machine acessa a máquina neste endereço. Verifique a rede da máquina e o smbd: cloudmachine-agent vm-status", + "The machine did not report its address, so nobody could try an SMB connection. Check: cloudmachine-agent vm-status": + "A máquina não informou seu endereço, então ninguém pôde tentar uma conexão SMB. Verifique: cloudmachine-agent vm-status", + "An SMB connection to %@ failed (%@). Time Machine reaches the machine at this address, so no backup is made. Check the machine's network and smbd: cloudmachine-agent vm-status": + "Uma conexão SMB com %@ falhou (%@). O Time Machine acessa a máquina neste endereço, então nenhum backup é feito. Verifique a rede da máquina e o smbd: cloudmachine-agent vm-status", + "readable": + "legível", + "no image yet": + "ainda sem imagem", + "answers": + "responde", + "Share read: %@": + "Leitura compart.: %@", + "Samba from Mac: %@": + "Samba do Mac: %@", + "This Mac found no route to %@ (%@). Either the route to the machine is gone, or macOS Local Network privacy has not allowed CloudMachine - then allow it in System Settings > Privacy & Security > Local Network. Check: cloudmachine-agent vm-status": + "Este Mac não encontrou rota para %@ (%@). Ou a rota até a máquina sumiu, ou a privacidade de \"Rede Local\" do macOS não permitiu o CloudMachine - nesse caso, permita em Ajustes do Sistema > Privacidade e Segurança > Rede Local. Verifique: cloudmachine-agent vm-status", ] } diff --git a/mac-app/Sources/CloudMachineCore/L10nTranslations+ru.swift b/mac-app/Sources/CloudMachineCore/L10nTranslations+ru.swift index 5fd47db..fb972df 100644 --- a/mac-app/Sources/CloudMachineCore/L10nTranslations+ru.swift +++ b/mac-app/Sources/CloudMachineCore/L10nTranslations+ru.swift @@ -947,5 +947,35 @@ extension L10nTranslations { "У rclone есть полосы в процессе отправки, но ни одна не отправлена уже %@ - обычно это обрыв сетевого соединения, о котором rclone не сообщает как об ошибке. Копии с тех пор находятся только в буфере машины. Когда сеть вернётся, отправка возобновится сама (28.10.2026 - за 80 s); если нет, перезапуск машины (prepare-shutdown, limactl start) разорвёт мёртвые соединения.", "tmutil did not answer - not restarting the machine while its state is unknown.": "tmutil не ответил - не перезапускаю машину, пока её состояние неизвестно.", + "The machine's share cannot be read": + "Общий ресурс машины не читается", + "Unknown whether the machine's share can be read": + "Неизвестно, читается ли общий ресурс машины", + "Samba in the machine does not answer this Mac": + "Samba в машине не отвечает этому Mac", + "Unknown whether Samba in the machine answers this Mac": + "Неизвестно, отвечает ли Samba в машине этому Mac", + "Reading 1 byte of %@ in the machine failed: %@. Every service says it is active, yet Time Machine cannot use the image there. Nothing restarts it by itself. Check: cloudmachine-agent vm-status, then in the machine: journalctl -u punchfs -u tm-bands-bind -u rclone-tm": + "Чтение 1 байта из %@ в машине не удалось: %@. Все службы сообщают, что активны, но Time Machine не может использовать там образ. Ничто не перезапускает это само. Проверьте: cloudmachine-agent vm-status, затем в машине: journalctl -u punchfs -u tm-bands-bind -u rclone-tm", + "Reading 1 byte of %@ in the machine has not returned for %@ s. That is how a hung punchfs or rclone mount behaves. Nothing is restarted while the state is unknown - a restart under a write still going could lose it. Check in the machine: journalctl -u punchfs -u rclone-tm": + "Чтение 1 байта из %@ в машине не возвращается уже %@ с. Так ведёт себя зависшее монтирование punchfs или rclone. Пока состояние неизвестно, ничего не перезапускается - перезапуск во время идущей записи может её потерять. Проверьте в машине: journalctl -u punchfs -u rclone-tm", + "An SMB connection to %@ got no answer within %@ s. Time Machine reaches the machine at this address. Check the machine's network and smbd: cloudmachine-agent vm-status": + "SMB-подключение к %@ не получило ответа за %@ с. По этому адресу Time Machine обращается к машине. Проверьте сеть машины и smbd: cloudmachine-agent vm-status", + "The machine did not report its address, so nobody could try an SMB connection. Check: cloudmachine-agent vm-status": + "Машина не сообщила свой адрес, поэтому никто не смог попробовать SMB-подключение. Проверьте: cloudmachine-agent vm-status", + "An SMB connection to %@ failed (%@). Time Machine reaches the machine at this address, so no backup is made. Check the machine's network and smbd: cloudmachine-agent vm-status": + "SMB-подключение к %@ не удалось (%@). По этому адресу Time Machine обращается к машине, поэтому резервная копия не создаётся. Проверьте сеть машины и smbd: cloudmachine-agent vm-status", + "readable": + "читается", + "no image yet": + "образа пока нет", + "answers": + "отвечает", + "Share read: %@": + "Чтение ресурса: %@", + "Samba from Mac: %@": + "Samba с Mac: %@", + "This Mac found no route to %@ (%@). Either the route to the machine is gone, or macOS Local Network privacy has not allowed CloudMachine - then allow it in System Settings > Privacy & Security > Local Network. Check: cloudmachine-agent vm-status": + "Этот Mac не нашёл маршрута к %@ (%@). Либо маршрут к машине пропал, либо конфиденциальность «Локальная сеть» в macOS не разрешила CloudMachine - тогда разрешите это в Системных настройках > Конфиденциальность и безопасность > Локальная сеть. Проверьте: cloudmachine-agent vm-status", ] } diff --git a/mac-app/Sources/CloudMachineCore/L10nTranslations+sv.swift b/mac-app/Sources/CloudMachineCore/L10nTranslations+sv.swift index 1c12a4b..395227b 100644 --- a/mac-app/Sources/CloudMachineCore/L10nTranslations+sv.swift +++ b/mac-app/Sources/CloudMachineCore/L10nTranslations+sv.swift @@ -947,5 +947,35 @@ extension L10nTranslations { "rclone har band som pågår, men inget har laddats upp under %@ - oftast en bruten nätverksanslutning, som rclone inte rapporterar som ett fel. Säkerhetskopior sedan dess finns bara i maskinens buffert. När nätverket kommer tillbaka återupptas uppladdningen av sig själv (28.10.2026 inom 80 s); om inte, släpper en omstart av maskinen (prepare-shutdown, limactl start) de döda anslutningarna.", "tmutil did not answer - not restarting the machine while its state is unknown.": "tmutil svarade inte - startar inte om maskinen medan dess tillstånd är okänt.", + "The machine's share cannot be read": + "Maskinens delning går inte att läsa", + "Unknown whether the machine's share can be read": + "Okänt om maskinens delning går att läsa", + "Samba in the machine does not answer this Mac": + "Samba i maskinen svarar inte den här Macen", + "Unknown whether Samba in the machine answers this Mac": + "Okänt om Samba i maskinen svarar den här Macen", + "Reading 1 byte of %@ in the machine failed: %@. Every service says it is active, yet Time Machine cannot use the image there. Nothing restarts it by itself. Check: cloudmachine-agent vm-status, then in the machine: journalctl -u punchfs -u tm-bands-bind -u rclone-tm": + "Läsning av 1 byte från %@ i maskinen misslyckades: %@. Alla tjänster säger att de är aktiva, men Time Machine kan inte använda avbilden där. Inget startar om det av sig självt. Kontrollera: cloudmachine-agent vm-status, sedan i maskinen: journalctl -u punchfs -u tm-bands-bind -u rclone-tm", + "Reading 1 byte of %@ in the machine has not returned for %@ s. That is how a hung punchfs or rclone mount behaves. Nothing is restarted while the state is unknown - a restart under a write still going could lose it. Check in the machine: journalctl -u punchfs -u rclone-tm": + "Läsning av 1 byte från %@ i maskinen har inte kommit tillbaka på %@ s. Så beter sig en hängd punchfs- eller rclone-montering. Ingenting startas om så länge tillståndet är okänt - en omstart under en pågående skrivning kan förlora den. Kontrollera i maskinen: journalctl -u punchfs -u rclone-tm", + "An SMB connection to %@ got no answer within %@ s. Time Machine reaches the machine at this address. Check the machine's network and smbd: cloudmachine-agent vm-status": + "En SMB-anslutning till %@ fick inget svar inom %@ s. Time Machine når maskinen på den här adressen. Kontrollera maskinens nätverk och smbd: cloudmachine-agent vm-status", + "The machine did not report its address, so nobody could try an SMB connection. Check: cloudmachine-agent vm-status": + "Maskinen angav inte sin adress, så ingen kunde försöka med en SMB-anslutning. Kontrollera: cloudmachine-agent vm-status", + "An SMB connection to %@ failed (%@). Time Machine reaches the machine at this address, so no backup is made. Check the machine's network and smbd: cloudmachine-agent vm-status": + "En SMB-anslutning till %@ misslyckades (%@). Time Machine når maskinen på den här adressen, så ingen säkerhetskopia görs. Kontrollera maskinens nätverk och smbd: cloudmachine-agent vm-status", + "readable": + "läsbar", + "no image yet": + "ingen avbild än", + "answers": + "svarar", + "Share read: %@": + "Läsning delning: %@", + "Samba from Mac: %@": + "Samba från Mac: %@", + "This Mac found no route to %@ (%@). Either the route to the machine is gone, or macOS Local Network privacy has not allowed CloudMachine - then allow it in System Settings > Privacy & Security > Local Network. Check: cloudmachine-agent vm-status": + "Den här Macen hittade ingen väg till %@ (%@). Antingen är vägen till maskinen borta, eller så har integritetsskyddet för ”Lokalt nätverk” i macOS inte tillåtit CloudMachine – tillåt det då i Systeminställningar > Integritet och säkerhet > Lokalt nätverk. Kontrollera: cloudmachine-agent vm-status", ] } diff --git a/mac-app/Sources/CloudMachineCore/L10nTranslations+th.swift b/mac-app/Sources/CloudMachineCore/L10nTranslations+th.swift index ec28197..b4a4d10 100644 --- a/mac-app/Sources/CloudMachineCore/L10nTranslations+th.swift +++ b/mac-app/Sources/CloudMachineCore/L10nTranslations+th.swift @@ -947,5 +947,35 @@ extension L10nTranslations { "rclone มีแบนด์กำลังดำเนินการ แต่ไม่มีการอัปโหลดเป็นเวลา %@ - มักเกิดจากการเชื่อมต่อเครือข่ายขาด ซึ่ง rclone ไม่รายงานเป็นข้อผิดพลาด ข้อมูลสำรองตั้งแต่นั้นมีอยู่ในบัฟเฟอร์ของเครื่องเสมือนเท่านั้น เมื่อเครือข่ายกลับมา การอัปโหลดจะทำต่อเอง (เมื่อ 28.10.2026 ภายใน 80 s) หากไม่ทำต่อ การรีสตาร์ทเครื่องเสมือน (prepare-shutdown, limactl start) จะตัดการเชื่อมต่อที่ตายแล้ว", "tmutil did not answer - not restarting the machine while its state is unknown.": "tmutil ไม่ตอบสนอง - ไม่รีสตาร์ทเครื่องเสมือนขณะที่ไม่ทราบสถานะ", + "The machine's share cannot be read": + "อ่านการแชร์ของเครื่องไม่ได้", + "Unknown whether the machine's share can be read": + "ไม่ทราบว่าอ่านการแชร์ของเครื่องได้หรือไม่", + "Samba in the machine does not answer this Mac": + "Samba ในเครื่องไม่ตอบ Mac เครื่องนี้", + "Unknown whether Samba in the machine answers this Mac": + "ไม่ทราบว่า Samba ในเครื่องตอบ Mac เครื่องนี้หรือไม่", + "Reading 1 byte of %@ in the machine failed: %@. Every service says it is active, yet Time Machine cannot use the image there. Nothing restarts it by itself. Check: cloudmachine-agent vm-status, then in the machine: journalctl -u punchfs -u tm-bands-bind -u rclone-tm": + "การอ่าน 1 ไบต์จาก %@ ในเครื่องล้มเหลว: %@ ทุกบริการแจ้งว่าทำงานอยู่ แต่ Time Machine ใช้อิมเมจที่นั่นไม่ได้ ไม่มีสิ่งใดรีสตาร์ทให้เอง ตรวจสอบ: cloudmachine-agent vm-status แล้วในเครื่อง: journalctl -u punchfs -u tm-bands-bind -u rclone-tm", + "Reading 1 byte of %@ in the machine has not returned for %@ s. That is how a hung punchfs or rclone mount behaves. Nothing is restarted while the state is unknown - a restart under a write still going could lose it. Check in the machine: journalctl -u punchfs -u rclone-tm": + "การอ่าน 1 ไบต์จาก %@ ในเครื่องยังไม่กลับมาเป็นเวลา %@ วินาที นี่คือลักษณะของการเมานต์ punchfs หรือ rclone ที่ค้าง ระหว่างที่ไม่ทราบสถานะจะไม่มีการรีสตาร์ทใดๆ - การรีสตาร์ทระหว่างการเขียนที่ยังดำเนินอยู่อาจทำให้ข้อมูลนั้นหาย ตรวจสอบในเครื่อง: journalctl -u punchfs -u rclone-tm", + "An SMB connection to %@ got no answer within %@ s. Time Machine reaches the machine at this address. Check the machine's network and smbd: cloudmachine-agent vm-status": + "การเชื่อมต่อ SMB ไปยัง %@ ไม่ได้รับคำตอบภายใน %@ วินาที Time Machine เข้าถึงเครื่องที่ที่อยู่นี้ ตรวจสอบเครือข่ายของเครื่องและ smbd: cloudmachine-agent vm-status", + "The machine did not report its address, so nobody could try an SMB connection. Check: cloudmachine-agent vm-status": + "เครื่องไม่ได้แจ้งที่อยู่ของตน จึงไม่มีใครลองเชื่อมต่อ SMB ได้ ตรวจสอบ: cloudmachine-agent vm-status", + "An SMB connection to %@ failed (%@). Time Machine reaches the machine at this address, so no backup is made. Check the machine's network and smbd: cloudmachine-agent vm-status": + "การเชื่อมต่อ SMB ไปยัง %@ ล้มเหลว (%@) Time Machine เข้าถึงเครื่องที่ที่อยู่นี้ จึงไม่มีการสำรองข้อมูล ตรวจสอบเครือข่ายของเครื่องและ smbd: cloudmachine-agent vm-status", + "readable": + "อ่านได้", + "no image yet": + "ยังไม่มีอิมเมจ", + "answers": + "ตอบ", + "Share read: %@": + "การอ่านการแชร์: %@", + "Samba from Mac: %@": + "Samba จาก Mac: %@", + "This Mac found no route to %@ (%@). Either the route to the machine is gone, or macOS Local Network privacy has not allowed CloudMachine - then allow it in System Settings > Privacy & Security > Local Network. Check: cloudmachine-agent vm-status": + "Mac เครื่องนี้หาเส้นทางไปยัง %@ ไม่พบ (%@) อาจเป็นเพราะเส้นทางไปยังเครื่องหายไป หรือความเป็นส่วนตัว \"เครือข่ายเฉพาะที่\" ของ macOS ยังไม่อนุญาต CloudMachine - หากเป็นเช่นนั้นให้อนุญาตใน การตั้งค่าระบบ > ความเป็นส่วนตัวและความปลอดภัย > เครือข่ายเฉพาะที่ ตรวจสอบ: cloudmachine-agent vm-status", ] } diff --git a/mac-app/Sources/CloudMachineCore/L10nTranslations+tr.swift b/mac-app/Sources/CloudMachineCore/L10nTranslations+tr.swift index 20b16c5..8dbcff5 100644 --- a/mac-app/Sources/CloudMachineCore/L10nTranslations+tr.swift +++ b/mac-app/Sources/CloudMachineCore/L10nTranslations+tr.swift @@ -947,5 +947,35 @@ extension L10nTranslations { "rclone'da süren bantlar var ama %@ süredir hiçbiri yüklenmedi - genellikle rclone'un hata olarak bildirmediği kopuk bir ağ bağlantısıdır. O zamandan beri alınan yedekler yalnızca makinenin arabelleğinde. Ağ geri gelince yükleme kendiliğinden sürer (28.10.2026'da 80 s içinde); sürmezse makineyi yeniden başlatmak (prepare-shutdown, limactl start) ölü bağlantıları koparır.", "tmutil did not answer - not restarting the machine while its state is unknown.": "tmutil yanıt vermedi - durumu bilinmeyen makine yeniden başlatılmıyor.", + "The machine's share cannot be read": + "Makinenin paylaşımı okunamıyor", + "Unknown whether the machine's share can be read": + "Makinenin paylaşımının okunup okunamadığı bilinmiyor", + "Samba in the machine does not answer this Mac": + "Makinedeki Samba bu Mac'e yanıt vermiyor", + "Unknown whether Samba in the machine answers this Mac": + "Makinedeki Samba'nın bu Mac'e yanıt verip vermediği bilinmiyor", + "Reading 1 byte of %@ in the machine failed: %@. Every service says it is active, yet Time Machine cannot use the image there. Nothing restarts it by itself. Check: cloudmachine-agent vm-status, then in the machine: journalctl -u punchfs -u tm-bands-bind -u rclone-tm": + "Makinede %@ dosyasından 1 bayt okunamadı: %@. Her hizmet etkin olduğunu söylüyor, ancak Time Machine oradaki imajı kullanamıyor. Hiçbir şey bunu kendiliğinden yeniden başlatmaz. Kontrol edin: cloudmachine-agent vm-status, ardından makinede: journalctl -u punchfs -u tm-bands-bind -u rclone-tm", + "Reading 1 byte of %@ in the machine has not returned for %@ s. That is how a hung punchfs or rclone mount behaves. Nothing is restarted while the state is unknown - a restart under a write still going could lose it. Check in the machine: journalctl -u punchfs -u rclone-tm": + "Makinede %@ dosyasından 1 bayt okuma %@ sn'dir dönmedi. Takılmış bir punchfs veya rclone bağlaması böyle davranır. Durum bilinmezken hiçbir şey yeniden başlatılmaz - süren bir yazma sırasında yeniden başlatmak onu kaybettirebilir. Makinede kontrol edin: journalctl -u punchfs -u rclone-tm", + "An SMB connection to %@ got no answer within %@ s. Time Machine reaches the machine at this address. Check the machine's network and smbd: cloudmachine-agent vm-status": + "%@ adresine SMB bağlantısı %@ sn içinde yanıt almadı. Time Machine makineye bu adresten ulaşır. Makinenin ağını ve smbd'yi kontrol edin: cloudmachine-agent vm-status", + "The machine did not report its address, so nobody could try an SMB connection. Check: cloudmachine-agent vm-status": + "Makine adresini bildirmedi, bu yüzden kimse SMB bağlantısı deneyemedi. Kontrol edin: cloudmachine-agent vm-status", + "An SMB connection to %@ failed (%@). Time Machine reaches the machine at this address, so no backup is made. Check the machine's network and smbd: cloudmachine-agent vm-status": + "%@ adresine SMB bağlantısı başarısız oldu (%@). Time Machine makineye bu adresten ulaşır, bu yüzden yedek alınmıyor. Makinenin ağını ve smbd'yi kontrol edin: cloudmachine-agent vm-status", + "readable": + "okunabilir", + "no image yet": + "henüz imaj yok", + "answers": + "yanıt veriyor", + "Share read: %@": + "Paylaşım okuma: %@", + "Samba from Mac: %@": + "Mac'ten Samba: %@", + "This Mac found no route to %@ (%@). Either the route to the machine is gone, or macOS Local Network privacy has not allowed CloudMachine - then allow it in System Settings > Privacy & Security > Local Network. Check: cloudmachine-agent vm-status": + "Bu Mac %@ için yol bulamadı (%@). Ya makineye giden yol yok oldu ya da macOS'in Yerel Ağ gizliliği CloudMachine'e izin vermedi - bu durumda Sistem Ayarları > Gizlilik ve Güvenlik > Yerel Ağ bölümünden izin verin. Kontrol edin: cloudmachine-agent vm-status", ] } diff --git a/mac-app/Sources/CloudMachineCore/L10nTranslations+zh-Hans.swift b/mac-app/Sources/CloudMachineCore/L10nTranslations+zh-Hans.swift index ec881b7..37fc713 100644 --- a/mac-app/Sources/CloudMachineCore/L10nTranslations+zh-Hans.swift +++ b/mac-app/Sources/CloudMachineCore/L10nTranslations+zh-Hans.swift @@ -947,5 +947,35 @@ extension L10nTranslations { "rclone 有分块正在处理,但已有 %@ 没有任何分块上传完成,这通常是网络连接中断造成的,rclone 不会把它报告为错误。此后的备份只存在于虚拟机的缓冲区中。网络恢复后,上传会自动继续(2026 年 10 月 28 日那次在 80 秒内恢复);如果没有恢复,请重启虚拟机(prepare-shutdown,limactl start)以丢弃失效的连接。", "tmutil did not answer - not restarting the machine while its state is unknown.": "tmutil 没有响应,在状态未知时不会重启虚拟机。", + "The machine's share cannot be read": + "无法读取机器的共享", + "Unknown whether the machine's share can be read": + "不确定机器的共享能否读取", + "Samba in the machine does not answer this Mac": + "机器中的 Samba 未响应此 Mac", + "Unknown whether Samba in the machine answers this Mac": + "不确定机器中的 Samba 是否响应此 Mac", + "Reading 1 byte of %@ in the machine failed: %@. Every service says it is active, yet Time Machine cannot use the image there. Nothing restarts it by itself. Check: cloudmachine-agent vm-status, then in the machine: journalctl -u punchfs -u tm-bands-bind -u rclone-tm": + "从机器中的 %@ 读取 1 字节失败:%@。所有服务都报告为活动状态,但 Time Machine 无法使用那里的映像。不会自动重启。检查:cloudmachine-agent vm-status,然后在机器中:journalctl -u punchfs -u tm-bands-bind -u rclone-tm", + "Reading 1 byte of %@ in the machine has not returned for %@ s. That is how a hung punchfs or rclone mount behaves. Nothing is restarted while the state is unknown - a restart under a write still going could lose it. Check in the machine: journalctl -u punchfs -u rclone-tm": + "从机器中的 %@ 读取 1 字节已有 %@ 秒未返回。这是 punchfs 或 rclone 挂载卡住时的表现。状态未知时不会重启任何东西——在写入进行中重启可能会丢失该写入。在机器中检查:journalctl -u punchfs -u rclone-tm", + "An SMB connection to %@ got no answer within %@ s. Time Machine reaches the machine at this address. Check the machine's network and smbd: cloudmachine-agent vm-status": + "到 %@ 的 SMB 连接在 %@ 秒内没有响应。Time Machine 通过此地址访问机器。请检查机器的网络和 smbd:cloudmachine-agent vm-status", + "The machine did not report its address, so nobody could try an SMB connection. Check: cloudmachine-agent vm-status": + "机器未报告其地址,因此无法尝试 SMB 连接。检查:cloudmachine-agent vm-status", + "An SMB connection to %@ failed (%@). Time Machine reaches the machine at this address, so no backup is made. Check the machine's network and smbd: cloudmachine-agent vm-status": + "到 %@ 的 SMB 连接失败(%@)。Time Machine 通过此地址访问机器,因此不会进行备份。请检查机器的网络和 smbd:cloudmachine-agent vm-status", + "readable": + "可读取", + "no image yet": + "尚无映像", + "answers": + "有响应", + "Share read: %@": + "共享读取: %@", + "Samba from Mac: %@": + "从 Mac 到 Samba: %@", + "This Mac found no route to %@ (%@). Either the route to the machine is gone, or macOS Local Network privacy has not allowed CloudMachine - then allow it in System Settings > Privacy & Security > Local Network. Check: cloudmachine-agent vm-status": + "此 Mac 找不到到 %@ 的路由(%@)。要么到机器的路由已消失,要么 macOS 的“本地网络”隐私设置未允许 CloudMachine——此时请在 系统设置 > 隐私与安全性 > 本地网络 中允许。检查:cloudmachine-agent vm-status", ] } diff --git a/mac-app/Sources/CloudMachineCore/L10nTranslations+zh-Hant.swift b/mac-app/Sources/CloudMachineCore/L10nTranslations+zh-Hant.swift index 79d534b..c6a106c 100644 --- a/mac-app/Sources/CloudMachineCore/L10nTranslations+zh-Hant.swift +++ b/mac-app/Sources/CloudMachineCore/L10nTranslations+zh-Hant.swift @@ -947,5 +947,35 @@ extension L10nTranslations { "rclone 有分塊正在處理,但已有 %@ 沒有任何分塊上傳完成,這通常是網路連線中斷造成的,rclone 不會把它回報為錯誤。此後的備份只存在於虛擬機器的緩衝區中。網路恢復後,上傳會自動繼續(2026 年 10 月 28 日那次在 80 秒內恢復);如果沒有恢復,請重新啟動虛擬機器(prepare-shutdown,limactl start)以丟棄失效的連線。", "tmutil did not answer - not restarting the machine while its state is unknown.": "tmutil 沒有回應,在狀態未知時不會重新啟動虛擬機器。", + "The machine's share cannot be read": + "無法讀取機器的共享", + "Unknown whether the machine's share can be read": + "不確定機器的共享能否讀取", + "Samba in the machine does not answer this Mac": + "機器中的 Samba 未回應此 Mac", + "Unknown whether Samba in the machine answers this Mac": + "不確定機器中的 Samba 是否回應此 Mac", + "Reading 1 byte of %@ in the machine failed: %@. Every service says it is active, yet Time Machine cannot use the image there. Nothing restarts it by itself. Check: cloudmachine-agent vm-status, then in the machine: journalctl -u punchfs -u tm-bands-bind -u rclone-tm": + "從機器中的 %@ 讀取 1 位元組失敗:%@。所有服務都回報為作用中,但 Time Machine 無法使用那裡的映像檔。不會自動重新啟動。檢查:cloudmachine-agent vm-status,然後在機器中:journalctl -u punchfs -u tm-bands-bind -u rclone-tm", + "Reading 1 byte of %@ in the machine has not returned for %@ s. That is how a hung punchfs or rclone mount behaves. Nothing is restarted while the state is unknown - a restart under a write still going could lose it. Check in the machine: journalctl -u punchfs -u rclone-tm": + "從機器中的 %@ 讀取 1 位元組已有 %@ 秒未返回。這是 punchfs 或 rclone 掛載卡住時的表現。狀態未知時不會重新啟動任何東西——在寫入進行中重新啟動可能會遺失該寫入。在機器中檢查:journalctl -u punchfs -u rclone-tm", + "An SMB connection to %@ got no answer within %@ s. Time Machine reaches the machine at this address. Check the machine's network and smbd: cloudmachine-agent vm-status": + "連到 %@ 的 SMB 連線在 %@ 秒內沒有回應。Time Machine 透過此位址存取機器。請檢查機器的網路和 smbd:cloudmachine-agent vm-status", + "The machine did not report its address, so nobody could try an SMB connection. Check: cloudmachine-agent vm-status": + "機器未回報其位址,因此無法嘗試 SMB 連線。檢查:cloudmachine-agent vm-status", + "An SMB connection to %@ failed (%@). Time Machine reaches the machine at this address, so no backup is made. Check the machine's network and smbd: cloudmachine-agent vm-status": + "連到 %@ 的 SMB 連線失敗(%@)。Time Machine 透過此位址存取機器,因此不會進行備份。請檢查機器的網路和 smbd:cloudmachine-agent vm-status", + "readable": + "可讀取", + "no image yet": + "尚無映像檔", + "answers": + "有回應", + "Share read: %@": + "共享讀取: %@", + "Samba from Mac: %@": + "從 Mac 到 Samba: %@", + "This Mac found no route to %@ (%@). Either the route to the machine is gone, or macOS Local Network privacy has not allowed CloudMachine - then allow it in System Settings > Privacy & Security > Local Network. Check: cloudmachine-agent vm-status": + "此 Mac 找不到通往 %@ 的路由(%@)。可能是通往機器的路由已消失,或 macOS 的「區域網路」隱私權未允許 CloudMachine——此時請在 系統設定 > 隱私權與安全性 > 區域網路 中允許。檢查:cloudmachine-agent vm-status", ] } diff --git a/mac-app/Sources/CloudMachineCore/SMBProbe.swift b/mac-app/Sources/CloudMachineCore/SMBProbe.swift new file mode 100644 index 0000000..fbdfd16 --- /dev/null +++ b/mac-app/Sources/CloudMachineCore/SMBProbe.swift @@ -0,0 +1,236 @@ +import Foundation + +/// Asks Samba in the machine to negotiate, from THIS Mac, over the address +/// Time Machine uses (vzNAT). +/// +/// Every other question to the machine goes through `limactl shell` - ssh +/// over Lima's own user network, not over vzNAT. So a cut vzNAT, or an smbd +/// that is `active` but does not answer, left every check green while Time +/// Machine could not reach its destination. +/// +/// A TCP connect alone would not do: the kernel completes the handshake for +/// a listening socket whether or not smbd ever reads from it. The probe sends +/// an SMB2 NEGOTIATE and waits for an SMB2 header back - the first thing +/// Time Machine itself asks for. It never logs in; the share's password is +/// not involved. +/// +/// Sockets do not sit in uninterruptible sleep, so the deadline (non-blocking +/// connect, `poll`) really bounds it; it still runs off the caller's thread, +/// because the panel calls it from the main actor. +public enum SMBProbe { + + public enum Verdict: Equatable, Sendable { + case answers + /// The connection failed (`reason`: errno name) or smbd hung up unanswered. + case refused(reason: String) + /// Something answered on the port, but not SMB2. + case notSMB + /// This Mac had no route (EHOSTUNREACH/ENETUNREACH, at once): the route + /// to the machine is gone, OR macOS Local Network privacy has not allowed + /// this program - the same probe from Terminal then answers. Not knowing + /// which, it is "unknown", not a refusal. + case unreachable(reason: String) + /// Nothing came back within the limit - unknown, never fine. + case noAnswer(seconds: Int) + /// The machine did not say its address, so nobody could try. + case noAddress + + public var answers: Bool { self == .answers } + } + + /// What happened on the wire - the input of the pure `verdict`. + public enum Exchange: Equatable, Sendable { + case connectFailed(Int32) + case timedOut + case closed + case received([UInt8]) + } + + public typealias Transport = + @Sendable (_ host: String, _ port: UInt16, _ request: [UInt8], _ timeout: TimeInterval) -> + Exchange + + /// A healthy smbd answers a negotiate in milliseconds; 5 s leaves room for + /// a machine busy with a backup without holding up the panel. + public static let timeoutSeconds: TimeInterval = 5 + + /// NetBIOS session frame + SMB2 header + NEGOTIATE with dialects 2.0.2, + /// 2.1, 3.0, 3.0.2 - none of which needs negotiate contexts (3.1.1 does, + /// and the machine's minimum is SMB2_10 anyway). Little-endian fields. + static let negotiateRequest: [UInt8] = { + var header = [UInt8](repeating: 0, count: 64) + header[0...3] = [0xFE, 0x53, 0x4D, 0x42] // protocol id + header[4] = 64 // structure size + // command 0 = NEGOTIATE (bytes 12-13), credit request 1 (bytes 14-15) + header[14] = 1 + var body = [UInt8](repeating: 0, count: 36) + body[0] = 36 // structure size + body[2] = 4 // dialect count + body[4] = 1 // security mode: signing enabled + // client GUID (bytes 12-27): any fixed value + for index in 12..<28 { body[index] = UInt8(index) } + let dialects: [UInt8] = [0x02, 0x02, 0x10, 0x02, 0x00, 0x03, 0x02, 0x03] + let message = header + body + dialects + let length = UInt32(message.count) + return [0, UInt8((length >> 16) & 0xFF), UInt8((length >> 8) & 0xFF), UInt8(length & 0xFF)] + + message + }() + + /// Pure, so every outcome has a test. + static func verdict(_ exchange: Exchange, timeout: Int) -> Verdict { + switch exchange { + case .connectFailed(let code) where code == EHOSTUNREACH || code == ENETUNREACH: + return .unreachable(reason: errnoName(code)) + case .connectFailed(let code): return .refused(reason: errnoName(code)) + case .timedOut: return .noAnswer(seconds: timeout) + case .closed: return .refused(reason: "closed without an answer") + case .received(let bytes): + // After the 4-byte NetBIOS frame: 0xFE 'S' 'M' 'B'. + guard bytes.count >= 8, Array(bytes[4..<8]) == [0xFE, 0x53, 0x4D, 0x42] else { + return .notSMB + } + return .answers + } + } + + static func errnoName(_ code: Int32) -> String { + switch code { + case ECONNREFUSED: return "ECONNREFUSED" + case EHOSTUNREACH: return "EHOSTUNREACH" + case ENETUNREACH: return "ENETUNREACH" + case EHOSTDOWN: return "EHOSTDOWN" + case ECONNRESET: return "ECONNRESET" + case ETIMEDOUT: return "ETIMEDOUT" + case EADDRNOTAVAIL: return "EADDRNOTAVAIL" + case EINVAL: return "EINVAL" + default: return "errno \(code)" + } + } + + /// `nil` address = `.noAddress` without touching the network. + public static func probe( + address: String?, port: UInt16 = 445, timeout: TimeInterval = timeoutSeconds, + transport: @escaping Transport = liveExchange + ) async -> Verdict { + guard let address, !address.isEmpty else { return .noAddress } + let request = negotiateRequest + let exchange = await withCheckedContinuation { continuation in + DispatchQueue.global(qos: .utility).async { + continuation.resume(returning: transport(address, port, request, timeout)) + } + } + return verdict(exchange, timeout: Int(timeout.rounded())) + } + + /// `poll` that sits out signal interruptions until the deadline. + private static func pollUntil( + _ fd: Int32, events: Int32, milliseconds: () -> Int32 + ) -> Int32 { + while true { + var pollFD = pollfd(fd: fd, events: Int16(events), revents: 0) + let ready = poll(&pollFD, 1, milliseconds()) + if ready < 0 && errno == EINTR { continue } + return ready + } + } + + /// One connection: connect, send, read the first 8 bytes - all within `timeout`. + public static func liveExchange( + host: String, port: UInt16, request: [UInt8], timeout: TimeInterval + ) -> Exchange { + let deadline = Date().addingTimeInterval(timeout) + func remainingMilliseconds() -> Int32 { + Int32(max(0, min(Double(Int32.max), deadline.timeIntervalSinceNow * 1000))) + } + + var address = sockaddr_in() + address.sin_len = UInt8(MemoryLayout.size) + address.sin_family = sa_family_t(AF_INET) + address.sin_port = port.bigEndian + guard inet_pton(AF_INET, host, &address.sin_addr) == 1 else { + return .connectFailed(EINVAL) + } + let fd = socket(AF_INET, SOCK_STREAM, 0) + guard fd >= 0 else { return .connectFailed(errno) } + defer { close(fd) } + var one: Int32 = 1 + setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &one, socklen_t(MemoryLayout.size)) + _ = fcntl(fd, F_SETFL, fcntl(fd, F_GETFL) | O_NONBLOCK) + + let connected = withUnsafePointer(to: &address) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + connect(fd, $0, socklen_t(MemoryLayout.size)) + } + } + if connected != 0 { + guard errno == EINPROGRESS || errno == EINTR else { return .connectFailed(errno) } + let ready = pollUntil(fd, events: POLLOUT, milliseconds: remainingMilliseconds) + if ready == 0 { return .timedOut } + if ready < 0 { return .connectFailed(errno) } + var failure: Int32 = 0 + var length = socklen_t(MemoryLayout.size) + getsockopt(fd, SOL_SOCKET, SO_ERROR, &failure, &length) + if failure != 0 { return .connectFailed(failure) } + } + + var sent = 0 + while sent < request.count { + let written = request[sent...].withUnsafeBytes { write(fd, $0.baseAddress, $0.count) } + if written > 0 { + sent += written + continue + } + guard written < 0, errno == EAGAIN || errno == EINTR else { return .connectFailed(errno) } + if pollUntil(fd, events: POLLOUT, milliseconds: remainingMilliseconds) <= 0 { + return .timedOut + } + } + + var received: [UInt8] = [] + var buffer = [UInt8](repeating: 0, count: 64) + while received.count < 8 { + let ready = pollUntil(fd, events: POLLIN, milliseconds: remainingMilliseconds) + if ready == 0 { return .timedOut } + if ready < 0 { return .connectFailed(errno) } + let count = read(fd, &buffer, buffer.count) + if count == 0 { return received.isEmpty ? .closed : .received(received) } + if count < 0 { + if errno == EAGAIN || errno == EINTR { continue } + return .connectFailed(errno) + } + received += buffer[0.. Verdict? { + guard let address, let last, last.address == address, + now.timeIntervalSince(last.at) < interval + else { return nil } + return .answers + } + + public mutating func record(_ verdict: Verdict, address: String?, at date: Date) { + last = verdict == .answers ? address.map { ($0, date) } : nil + } + + public mutating func probe( + address: String?, now: Date = Date(), transport: @escaping Transport = liveExchange + ) async -> Verdict { + if let reused = reusable(address: address, now: now) { return reused } + let verdict = await SMBProbe.probe(address: address, transport: transport) + record(verdict, address: address, at: now) + return verdict + } + } +} diff --git a/mac-app/Sources/CloudMachineCore/ShareHealth.swift b/mac-app/Sources/CloudMachineCore/ShareHealth.swift new file mode 100644 index 0000000..cfef82c --- /dev/null +++ b/mac-app/Sources/CloudMachineCore/ShareHealth.swift @@ -0,0 +1,150 @@ +import Foundation + +// The words for the two share probes, shared by `backup-health`, `vm-status` +// and the panel, so the three never describe the same state differently. + +extension ShareReadProbe.Verdict { + /// One sentence for a state that is not fine; `nil` for a confirmed one. + public var problemSummary: String? { + switch self { + case .readable, .noImage: return nil + case .dead, .failed: return L10n.tr("The machine's share cannot be read") + case .noAnswer: return L10n.tr("Unknown whether the machine's share can be read") + } + } + + /// Red (needs a person) or amber (unknown) in the panel. + public var isFailure: Bool { + switch self { + case .dead, .failed: return true + case .readable, .noImage, .noAnswer: return false + } + } +} + +extension SMBProbe.Verdict { + public var problemSummary: String? { + switch self { + case .answers: return nil + case .refused, .notSMB: return L10n.tr("Samba in the machine does not answer this Mac") + case .noAnswer, .noAddress, .unreachable: + return L10n.tr("Unknown whether Samba in the machine answers this Mac") + } + } + + public var isFailure: Bool { + switch self { + case .refused, .notSMB: return true + case .answers, .noAnswer, .noAddress, .unreachable: return false + } + } +} + +extension BackupHealth { + /// The probes' problems. Pure; `nil` = not asked. + static func shareProblems( + read: ShareReadProbe.Verdict?, smb: SMBProbe.Verdict?, address: String? + ) -> (problems: [Problem], notReadyYet: [Problem]) { + var problems: [Problem] = [] + var notReadyYet: [Problem] = [] + let band = "\(VMLayout.Guest.localRoot)//bands/0" + + switch read { + case .dead(let reason)?, .failed(let reason)?: + problems.append( + Problem( + summary: L10n.tr("The machine's share cannot be read"), + detail: L10n.tr( + "Reading 1 byte of %@ in the machine failed: %@. Every service says it is active, yet Time Machine cannot use the image there. Nothing restarts it by itself. Check: cloudmachine-agent vm-status, then in the machine: journalctl -u punchfs -u tm-bands-bind -u rclone-tm", + band, reason), + code: "share-unreadable")) + case .noAnswer(let hung)?: + problems.append( + Problem( + summary: L10n.tr("Unknown whether the machine's share can be read"), + detail: L10n.tr( + "Reading 1 byte of %@ in the machine has not returned for %@ s. That is how a hung punchfs or rclone mount behaves. Nothing is restarted while the state is unknown - a restart under a write still going could lose it. Check in the machine: journalctl -u punchfs -u rclone-tm", + band, hung.map { "\($0)" } ?? "?"), + code: "share-read-no-answer")) + case .readable?, .noImage?, nil: + break + } + + let where445 = "\(address ?? "?"):445" + switch smb { + case .refused(let reason)?: + notReadyYet.append(smbRefused(where445, reason)) + case .notSMB?: + notReadyYet.append(smbRefused(where445, "not SMB2")) + case .noAnswer(let seconds)?: + problems.append( + Problem( + summary: L10n.tr("Unknown whether Samba in the machine answers this Mac"), + detail: L10n.tr( + "An SMB connection to %@ got no answer within %@ s. Time Machine reaches the machine at this address. Check the machine's network and smbd: cloudmachine-agent vm-status", + where445, "\(seconds)"), + code: "smb-no-answer")) + case .unreachable(let reason)?: + problems.append( + Problem( + summary: L10n.tr("Unknown whether Samba in the machine answers this Mac"), + detail: L10n.tr( + "This Mac found no route to %@ (%@). Either the route to the machine is gone, or macOS Local Network privacy has not allowed CloudMachine - then allow it in System Settings > Privacy & Security > Local Network. Check: cloudmachine-agent vm-status", + where445, reason), + code: "smb-unreachable")) + case .noAddress?: + problems.append( + Problem( + summary: L10n.tr("Unknown whether Samba in the machine answers this Mac"), + detail: L10n.tr( + "The machine did not report its address, so nobody could try an SMB connection. Check: cloudmachine-agent vm-status" + ), + code: "smb-no-address")) + case .answers?, nil: + break + } + return (problems, notReadyYet) + } + + private static func smbRefused(_ where445: String, _ reason: String) -> Problem { + Problem( + summary: L10n.tr("Samba in the machine does not answer this Mac"), + detail: L10n.tr( + "An SMB connection to %@ failed (%@). Time Machine reaches the machine at this address, so no backup is made. Check the machine's network and smbd: cloudmachine-agent vm-status", + where445, reason), + code: "smb-no-connection") + } +} + +extension VMStatus { + /// The two `vm-status` lines for the probes. Pure. + public static func shareLines( + read: ShareReadProbe.Verdict, smb: SMBProbe.Verdict, address: String? + ) -> (lines: [String], healthy: Bool) { + let readText: String + switch read { + case .readable: readText = L10n.tr("readable") + case .noImage: readText = L10n.tr("no image yet") + case .dead(let reason), .failed(let reason): + readText = "\(read.problemSummary ?? "") (\(reason))" + case .noAnswer(let hung): + readText = "\(read.problemSummary ?? "") (\(hung.map { "\($0) s" } ?? "?"))" + } + let smbText: String + switch smb { + case .answers: smbText = L10n.tr("answers") + case .refused(let reason), .unreachable(let reason): + smbText = "\(smb.problemSummary ?? "") (\(reason))" + case .notSMB: smbText = "\(smb.problemSummary ?? "") (not SMB2)" + case .noAnswer(let seconds): smbText = "\(smb.problemSummary ?? "") (\(seconds) s)" + case .noAddress: smbText = smb.problemSummary ?? "" + } + return ( + [ + L10n.tr("Share read: %@", readText), + L10n.tr("Samba from Mac: %@", "\(address ?? "?"):445 - \(smbText)"), + ], + read.isConfirmed && smb.answers + ) + } +} diff --git a/mac-app/Sources/CloudMachineCore/ShareReadProbe.swift b/mac-app/Sources/CloudMachineCore/ShareReadProbe.swift new file mode 100644 index 0000000..dc692d8 --- /dev/null +++ b/mac-app/Sources/CloudMachineCore/ShareReadProbe.swift @@ -0,0 +1,195 @@ +import Foundation + +/// Reads one byte through the share's own path in the machine: +/// `/srv/tm-local//bands/0`, which after `vm-prepare-bundle` is the +/// bind of punchfs over the rclone mount - the chain every band Time Machine +/// writes goes through. +/// +/// Why a read and not the services: every link of that chain can be +/// `systemctl is-active` while a read through it fails or never returns. A +/// hung FUSE daemon is still a live process; punchfs keeps the DEAD mount of +/// an rclone that systemd restarted under it (`Restart=always` restarts +/// rclone alone); after `vm-prepare-bundle` an inactive `tm-bands-bind` +/// passes as fine, and the bands land on the machine's local disk. In the +/// disk-image layout the same blind spot (the mount table still listing a +/// dead device) let the panel say "everything uploaded" while Time Machine +/// got ENXIO (22.09.2026). `stat` is not a read either: it is answered from +/// the attribute cache (`--attr-timeout 5m`), only reading data proves the +/// path. +/// +/// Band `0` holds the start of the image (the partition map, the APFS +/// container superblock): it exists in every image Time Machine has +/// formatted, and it is read on every mount, so rclone has it in its cache - +/// the probe costs a local read, not a download from Drive. +/// +/// HOW IT STAYS BOUNDED: a read on a hung FUSE mount sits in uninterruptible +/// sleep (`D`); neither `timeout` nor SIGKILL brings it back, and a script +/// that waits for it hangs the whole `limactl shell` call - and with it the +/// panel, `vm-status` and the watchdog. So the read runs DETACHED, the script +/// polls for its result for at most `timeoutSeconds` and then reports "hung" +/// with the age of the read. A lock directory lets only one read exist: a +/// stuck one is reported at once by every later run, instead of leaving one +/// more stuck process per panel refresh. +/// +/// The reader ignores SIGHUP/SIGINT/SIGTERM: `sudo` with `use_pty` (Ubuntu's +/// default) runs the script in its own process group on a pty, and that group +/// is hung up when bash exits or when the Mac kills `limactl` - a reader dying +/// of it would leave its lock behind. It removes the lock itself; a lock whose +/// reader is gone (SIGKILL, no pid and older than `staleLockSeconds`) is +/// taken over. `/run` is a tmpfs, so a reboot clears it too. +/// +/// It only reads: `head -c1` opens the band read-only, nothing is written to +/// the image. +public enum ShareReadProbe { + + public enum Verdict: Equatable, Sendable { + /// One byte read through the share's path. + case readable + /// No image in the machine yet - Time Machine has not created it. + case noImage + /// The read failed with a device-level error (EIO, ENOTCONN, ENXIO, + /// ENODEV, ESTALE) or the first band is missing (ENOENT). Restarting the + /// share's stack (punchfs, the bind, smbd) is the likely fix - by a + /// person for now; doing it by itself is a separate decision. + case dead(reason: String) + /// Another read error (EACCES...) - not fixed by a restart, not fine either. + case failed(reason: String) + /// The read did not return within the limit (`hungSeconds` = how long the + /// read has been running), or the machine printed no verdict at all + /// (`nil`). NOT a failure to act on - a hung read can be a write still + /// going - and never "fine". + case noAnswer(hungSeconds: Int?) + + /// Only these two let the panel show green. + public var isConfirmed: Bool { + switch self { + case .readable, .noImage: return true + case .dead, .failed, .noAnswer: return false + } + } + } + + /// How long one run waits for the read. A cached band reads in + /// milliseconds; an uncached one is a ranged download, a few seconds over a + /// slow network. Counted into `VMStatus.reportTimeout`. + public static let timeoutSeconds = 10 + + /// A lock without its reader's pid older than this is a reader that died + /// before writing it; no read waits this long for its pid. + static let staleLockSeconds = 60 + + /// Where the lock and the last result live in the machine. + static let stateDir = "/run/cloudmachine-share-probe" + + /// Sets `f` to the first band of the image the share serves; empty `f` = no + /// image (yet). + /// + /// A prepared image is named in `bundle.env`, and its band is read + /// unconditionally: its bands are the FUSE chain, and a missing band 0 is + /// exactly what the probe must report. Before `vm-prepare-bundle` the image + /// is on the machine's own disk, and Time Machine creating it writes the + /// directory a moment before band 0 - there `[ -e ]` is a safe local check, + /// and an image without its band yet is "no image yet", not a dead share. + static func locateFirstBand(bundleEnv: String, localRoot: String) -> String { + let env = VMLayout.shellQuoted(bundleEnv) + let root = VMLayout.shellQuoted(localRoot) + return "B=$(sed -n 's/^CM_BUNDLE=//p' \(env) 2>/dev/null | tr -d '\"'); f=; " + + "if [ -n \"$B\" ]; then f=\(root)/\"$B\"/bands/0; else " + + "B=$(cd \(root) 2>/dev/null && ls -d *.sparsebundle 2>/dev/null | head -1); " + + "if [ -n \"$B\" ] && [ -e \(root)/\"$B\"/bands/0 ]; then f=\(root)/\"$B\"/bands/0; fi; " + + "fi" + } + + /// The part of the status script that prints the `PROBE` line. + public static let guestScript = script( + locate: locateFirstBand( + bundleEnv: VMLayout.Guest.bundleEnv, localRoot: VMLayout.Guest.localRoot), + stateDir: stateDir, timeout: timeoutSeconds) + + /// The probe as a bash script. `locate` assigns the file to read to `f`. + /// Separate from `guestScript` so a test can run it on a real file, a + /// missing one and a FIFO (which blocks like a hung mount). + /// + /// Output: `PROBE ok`, `PROBE none`, `PROBE error ` or + /// `PROBE hung `. + static func script(locate: String, stateDir: String, timeout: Int) -> String { + let d = VMLayout.shellQuoted(stateDir) + return "{ " + + "\(locate); " + + "if [ -z \"$f\" ]; then echo PROBE none; else " + + "mkdir -p \(d); " + // A lock whose reader is gone is taken over. Two runs doing it at once + // can start two readers; both still write one result, so that costs one + // extra read, never a wrong verdict. + + "if [ -d \(d)/lock ]; then p=$(cat \(d)/lock/pid 2>/dev/null); s=$(cat \(d)/lock/start 2>/dev/null); " + + "if [ -n \"$p\" ]; then kill -0 \"$p\" 2>/dev/null || rm -rf \(d)/lock; " + + "elif [ -n \"$s\" ] && [ $(( $(date +%s) - s )) -gt \(staleLockSeconds) ]; then rm -rf \(d)/lock; fi; fi; " + // The one who creates the lock starts the read. The reader writes the + // result BEFORE it removes the lock, so whoever sees the lock gone sees + // that result. `sh -c 'echo $PPID'` is the reader's own pid (bash 3.2, + // which the tests run, has no BASHPID). + + "if mkdir \(d)/lock 2>/dev/null; then " + + "date +%s > \(d)/lock/start; " + + "( trap '' HUP INT TERM; sh -c 'echo $PPID' > \(d)/lock/pid; " + + "LC_ALL=C head -c1 -- \"$f\" >/dev/null 2>\(d)/err.tmp; echo $? > \(d)/rc.tmp; " + + "mv -f \(d)/err.tmp \(d)/err; mv -f \(d)/rc.tmp \(d)/rc; rm -rf \(d)/lock ) " + + "/dev/null 2>&1 & " + + "fi; " + // Wait while the read runs and is younger than the limit; the counter + // caps the wait even if `start` is missing. + + "i=0; while [ -d \(d)/lock ] && [ $i -le \(timeout) ]; do " + + "s=$(cat \(d)/lock/start 2>/dev/null); " + + "if [ -n \"$s\" ]; then [ $(( $(date +%s) - s )) -lt \(timeout) ] || break; fi; " + + "sleep 1; i=$((i+1)); done; " + + "if [ -d \(d)/lock ]; then s=$(cat \(d)/lock/start 2>/dev/null); " + + "echo PROBE hung $(( $(date +%s) - ${s:-$(date +%s)} )); " + + "elif [ \"$(cat \(d)/rc 2>/dev/null)\" = 0 ]; then echo PROBE ok; " + + "else echo \"PROBE error $(head -1 \(d)/err 2>/dev/null)\"; fi; " + + "fi; }" + } + + // MARK: - Reading the verdict (pure) + + /// The verdict from the status script's output. No `PROBE` line, or one + /// that is not understood, is `.noAnswer(nil)` - never `.readable`. + public static func verdict(scriptOutput: String) -> Verdict { + for line in scriptOutput.split(separator: "\n") where line.hasPrefix("PROBE ") { + let rest = String(line.dropFirst("PROBE ".count)) + if rest == "ok" { return .readable } + if rest == "none" { return .noImage } + if rest.hasPrefix("hung "), let seconds = Int(rest.dropFirst("hung ".count)) { + return .noAnswer(hungSeconds: max(0, seconds)) + } + if rest == "error" || rest.hasPrefix("error ") { + return verdict(errorText: String(rest.dropFirst("error".count))) + } + } + return .noAnswer(hungSeconds: nil) + } + + /// Error messages (C locale; GNU coreutils in the machine, BSD in the + /// tests) and the errno they stand for. Longer first: "No such device" + /// is a prefix of "No such device or address". + static let deviceErrors: [(text: String, name: String)] = [ + ("No such device or address", "ENXIO"), + ("Input/output error", "EIO"), + ("Transport endpoint is not connected", "ENOTCONN"), + ("Socket is not connected", "ENOTCONN"), + ("Stale NFS file handle", "ESTALE"), + ("Stale file handle", "ESTALE"), + ("No such device", "ENODEV"), + ("Operation not supported by device", "ENODEV"), + ("No such file or directory", "ENOENT"), + ] + + static func verdict(errorText: String) -> Verdict { + // `head: : ` or `head: cannot open '' ...: `. + let message = + errorText.components(separatedBy: ": ").last? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + for known in deviceErrors where message.hasPrefix(known.text) { + return .dead(reason: known.name) + } + return .failed(reason: message.isEmpty ? "no message" : message) + } +} diff --git a/mac-app/Sources/CloudMachineCore/VMStatus.swift b/mac-app/Sources/CloudMachineCore/VMStatus.swift index 2455508..2695937 100644 --- a/mac-app/Sources/CloudMachineCore/VMStatus.swift +++ b/mac-app/Sources/CloudMachineCore/VMStatus.swift @@ -25,6 +25,9 @@ public enum VMStatus { /// Seconds since the last upload that went through, by the machine's /// clock; `.infinity` = none in rclone's log; `nil` = not read. public var lastUploadAge: TimeInterval? + /// One byte read through the share's path (`ShareReadProbe`). A report + /// without the probe's line says "no answer", not "readable". + public var shareRead: ShareReadProbe.Verdict = .noAnswer(hungSeconds: nil) public init() {} } @@ -78,6 +81,7 @@ public enum VMStatus { + "l=$(tac \(VMLayout.Guest.rcloneLog) 2>/dev/null | grep -m1 'vfs cache: upload succeeded'); " + "if [ -n \"$l\" ]; then echo LASTUPLOAD $(date -d \"$(echo \"$l\" | cut -c1-19 | tr / -)\" +%s) $(date +%s); " + "else echo LASTUPLOAD 0 $(date +%s); fi; " + + ShareReadProbe.guestScript + "; " + VMLayout.readingScript /// Google's daily upload limit (750 GB) is not an error rclone gives up @@ -122,6 +126,7 @@ public enum VMStatus { } } report.reading = VMLayout.reading(scriptOutput: scriptOutput) + report.shareRead = ShareReadProbe.verdict(scriptOutput: scriptOutput) return report } @@ -130,7 +135,15 @@ public enum VMStatus { /// `vm-prepare-bundle` (its condition skips it). `failed` is never fine: a /// bind that failed after the image was prepared sends the bands to the /// machine's local disk instead of Drive. `nil` = no answer for some unit. - public static func servicesHealthy(_ services: [String: String]) -> Bool? { + /// + /// `inactive` is fine for the bind ONLY before `vm-prepare-bundle` + /// (`bundlePrepared == false`). After it the bind is what puts the bands on + /// Drive; until 03.11.2026 an inactive one passed as fine here, and Time + /// Machine wrote the bands to the machine's own disk with every light green. + /// Not knowing whether the image is prepared makes an inactive bind unknown. + public static func servicesHealthy(_ services: [String: String], bundlePrepared: Bool?) + -> Bool? + { var unknown = false for service in VMLayout.services { guard let state = services[service] else { @@ -138,17 +151,33 @@ public enum VMStatus { continue } if state == "active" { continue } - if service == "tm-bands-bind" && state == "inactive" { continue } + if service == "tm-bands-bind" && state == "inactive" { + switch bundlePrepared { + case .some(false): continue + case .some(true): return false + case .none: + unknown = true + continue + } + } return false } return unknown ? nil : true } + /// The status script's limit. Its worst case, with rclone hung: the share + /// probe's wait (`ShareReadProbe.timeoutSeconds` + 1), two `curl -m 20` to + /// rclone's rc, plus `limactl shell`'s own start. It was 60 s before the + /// probe; with it, a hung rclone would have pushed the script past the + /// limit and turned the whole report into "no answer". + public static let reportTimeout: TimeInterval = 90 + /// One run of the status script in the machine. `nil` = no answer (machine /// down, limactl stuck); a report whose `reading` is `nil` means rclone in /// the machine did not answer. public static func currentReport() async -> Report? { - guard let result = try? await VMLayout.rootShell(script, timeout: 60), result.succeeded + guard let result = try? await VMLayout.rootShell(script, timeout: reportTimeout), + result.succeeded else { return nil } return report(scriptOutput: result.stdout) } @@ -240,7 +269,13 @@ public enum VMStatus { L10n.tr("Service %@: %@", service.padding(toLength: 18, withPad: " ", startingAt: 0), state) ) } - if servicesHealthy(report.services) != true { healthy = false } + if servicesHealthy(report.services, bundlePrepared: report.bundlePrepared) != true { + healthy = false + } + let share = shareLines( + read: report.shareRead, smb: await SMBProbe.probe(address: address), address: address) + out.append(contentsOf: share.lines) + if !share.healthy { healthy = false } if let line = metaSyncLine(report) { out.append(line) } if let stalled = uploadStalledFor(report) { out.append( diff --git a/mac-app/Sources/CloudMachineCore/VMUpdate.swift b/mac-app/Sources/CloudMachineCore/VMUpdate.swift index 5a516b5..4158181 100644 --- a/mac-app/Sources/CloudMachineCore/VMUpdate.swift +++ b/mac-app/Sources/CloudMachineCore/VMUpdate.swift @@ -155,7 +155,7 @@ public enum VMUpdate { // 6. The share must be back on the Drive layers. guard let report = await VMStatus.currentReport(), - VMStatus.servicesHealthy(report.services) == true + VMStatus.servicesHealthy(report.services, bundlePrepared: report.bundlePrepared) == true else { return fail( L10n.tr( diff --git a/mac-app/Tests/CloudMachineAppTests/AppStatusFixtures.swift b/mac-app/Tests/CloudMachineAppTests/AppStatusFixtures.swift index 8932f51..91befd3 100644 --- a/mac-app/Tests/CloudMachineAppTests/AppStatusFixtures.swift +++ b/mac-app/Tests/CloudMachineAppTests/AppStatusFixtures.swift @@ -21,6 +21,8 @@ extension MachineStatus { freeBytes: 120 * VMGuard.gib) machine.bundlePrepared = true machine.imageCount = 1 + machine.shareRead = .readable + machine.smb = .answers return machine } } diff --git a/mac-app/Tests/CloudMachineAppTests/AppStatusHealthTests.swift b/mac-app/Tests/CloudMachineAppTests/AppStatusHealthTests.swift index 2beedbd..4c73309 100644 --- a/mac-app/Tests/CloudMachineAppTests/AppStatusHealthTests.swift +++ b/mac-app/Tests/CloudMachineAppTests/AppStatusHealthTests.swift @@ -153,6 +153,9 @@ final class AppStatusHealthTests: XCTestCase { /// `tm-bands-bind` is skipped before `vm-prepare-bundle`; that is not a failure. func testSkippedBandBindIsNotAFailure() { let status = healthyStatus() + // Before `vm-prepare-bundle` - the state the sentence above is about. + // After it an inactive bind is a failure (ShareHealthTests). + status.machine.bundlePrepared = false status.machine.services["tm-bands-bind"] = "inactive" XCTAssertTrue(status.healthy) status.machine.services["tm-bands-bind"] = "failed" diff --git a/mac-app/Tests/CloudMachineAppTests/SMBProbeTests.swift b/mac-app/Tests/CloudMachineAppTests/SMBProbeTests.swift new file mode 100644 index 0000000..7593121 --- /dev/null +++ b/mac-app/Tests/CloudMachineAppTests/SMBProbeTests.swift @@ -0,0 +1,223 @@ +import Foundation +import XCTest + +@testable import CloudMachineCore + +/// Whether Samba in the machine answers THIS Mac, over the address Time +/// Machine uses. +/// +/// Everything else asks the machine through `limactl shell`, which goes over +/// Lima's own user network - not over vzNAT, which carries SMB. A cut vzNAT or +/// an smbd that no longer answers was invisible: every service `active`, the +/// panel "Ready". +final class SMBProbeTests: XCTestCase { + + // MARK: - The request + + /// An SMB2 NEGOTIATE in a NetBIOS session frame, offering only dialects + /// that need no negotiate contexts (the machine's minimum is SMB2_10). + func testNegotiateRequestShape() { + let request = SMBProbe.negotiateRequest + XCTAssertEqual(request.count, 4 + 64 + 36 + 2 * 4) + XCTAssertEqual(Array(request[0..<4]), [0x00, 0x00, 0x00, UInt8(request.count - 4)]) + XCTAssertEqual(Array(request[4..<8]), [0xFE, 0x53, 0x4D, 0x42], "SMB2 protocol id") + XCTAssertEqual(Array(request[8..<10]), [64, 0], "header structure size") + XCTAssertEqual(Array(request[16..<18]), [0, 0], "command NEGOTIATE") + XCTAssertEqual(Array(request[68..<70]), [36, 0], "negotiate structure size") + XCTAssertEqual(Array(request[70..<72]), [4, 0], "dialect count") + XCTAssertEqual( + Array(request[104..<112]), [0x02, 0x02, 0x10, 0x02, 0x00, 0x03, 0x02, 0x03]) + } + + // MARK: - Verdicts + + private let smb2Answer: [UInt8] = [0x00, 0x00, 0x00, 0x41, 0xFE, 0x53, 0x4D, 0x42, 64, 0] + + func testSMB2AnswerAnswers() { + XCTAssertEqual(SMBProbe.verdict(.received(smb2Answer), timeout: 5), .answers) + } + + func testRefusedConnectionIsNamed() { + XCTAssertEqual( + SMBProbe.verdict(.connectFailed(ECONNREFUSED), timeout: 5), + .refused(reason: "ECONNREFUSED")) + } + + /// REVIEW 03.11: "no route" comes back at once both when the route to the + /// machine is gone AND when macOS Local Network privacy has not allowed + /// this program (a launchd agent or the app, while the same probe from + /// Terminal answers). Not knowing which, it is "unknown", not a refusal. + func testNoRouteIsUnreachableNotRefused() { + XCTAssertEqual( + SMBProbe.verdict(.connectFailed(EHOSTUNREACH), timeout: 5), + .unreachable(reason: "EHOSTUNREACH")) + XCTAssertEqual( + SMBProbe.verdict(.connectFailed(ENETUNREACH), timeout: 5), + .unreachable(reason: "ENETUNREACH")) + XCTAssertFalse(SMBProbe.Verdict.unreachable(reason: "x").answers) + } + + // MARK: - Asking less often + + /// The panel refreshes every 10 s; an answering smbd is asked again only + /// after `interval`, a failing one at every refresh (so the panel turns + /// green as soon as it answers again). + func testOnlyAnAnswerIsReusedAndOnlyForTheSameAddress() { + var cache = SMBProbe.Cache(interval: 60) + let start = Date(timeIntervalSince1970: 1_800_000_000) + XCTAssertNil(cache.reusable(address: "a", now: start)) + cache.record(.answers, address: "a", at: start) + XCTAssertEqual(cache.reusable(address: "a", now: start.addingTimeInterval(59)), .answers) + XCTAssertNil(cache.reusable(address: "a", now: start.addingTimeInterval(60))) + XCTAssertNil(cache.reusable(address: "b", now: start.addingTimeInterval(1))) + XCTAssertNil(cache.reusable(address: nil, now: start.addingTimeInterval(1))) + cache.record(.noAnswer(seconds: 5), address: "a", at: start) + XCTAssertNil(cache.reusable(address: "a", now: start.addingTimeInterval(1))) + } + + func testCachedProbeAsksOnceAWhile() async { + final class Count: @unchecked Sendable { var n = 0 } + let count = Count() + var cache = SMBProbe.Cache(interval: 60) + let start = Date(timeIntervalSince1970: 1_800_000_000) + let answer = smb2Answer + let transport: SMBProbe.Transport = { _, _, _, _ in + count.n += 1 + return .received(answer) + } + for second in [0, 10, 20, 61] as [TimeInterval] { + let verdict = await cache.probe( + address: "a", now: start.addingTimeInterval(second), transport: transport) + XCTAssertEqual(verdict, .answers) + } + XCTAssertEqual(count.n, 2) + } + + /// smbd accepted and hung up before answering: it does not serve. + func testClosedWithoutAnswerIsRefused() { + XCTAssertEqual( + SMBProbe.verdict(.closed, timeout: 5), .refused(reason: "closed without an answer")) + } + + func testSomethingElseOnThePortIsNotSMB() { + XCTAssertEqual( + SMBProbe.verdict(.received(Array("HTTP/1.1 400".utf8)), timeout: 5), .notSMB) + } + + /// The case that must never read as "fine": no answer within the limit. + func testTimeoutIsNoAnswer() { + XCTAssertEqual(SMBProbe.verdict(.timedOut, timeout: 5), .noAnswer(seconds: 5)) + XCTAssertFalse(SMBProbe.Verdict.noAnswer(seconds: 5).answers) + XCTAssertFalse(SMBProbe.Verdict.noAddress.answers) + XCTAssertTrue(SMBProbe.Verdict.answers.answers) + } + + func testWithoutAnAddressNobodyTried() async { + let verdict = await SMBProbe.probe(address: nil) { _, _, _, _ in + XCTFail("no address - nothing to connect to") + return .timedOut + } + XCTAssertEqual(verdict, .noAddress) + } + + func testProbeSendsTheRequestToPort445() async { + final class Seen: @unchecked Sendable { + var host = "" + var port: UInt16 = 0 + var bytes = 0 + } + let seen = Seen() + let verdict = await SMBProbe.probe(address: "192.168.64.10") { host, port, request, _ in + seen.host = host + seen.port = port + seen.bytes = request.count + return .received(self.smb2Answer) + } + XCTAssertEqual(verdict, .answers) + XCTAssertEqual(seen.host, "192.168.64.10") + XCTAssertEqual(seen.port, 445) + XCTAssertEqual(seen.bytes, SMBProbe.negotiateRequest.count) + } + + // MARK: - Canary: real sockets on 127.0.0.1 + + /// A listener that accepts and then does `behaviour` with the connection. + private func listener(_ behaviour: @escaping @Sendable (Int32) -> Void) throws -> UInt16 { + let fd = socket(AF_INET, SOCK_STREAM, 0) + XCTAssertGreaterThanOrEqual(fd, 0) + var address = sockaddr_in() + address.sin_len = UInt8(MemoryLayout.size) + address.sin_family = sa_family_t(AF_INET) + address.sin_port = 0 + address.sin_addr.s_addr = inet_addr("127.0.0.1") + let bound = withUnsafePointer(to: &address) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + Darwin.bind(fd, $0, socklen_t(MemoryLayout.size)) + } + } + guard bound == 0, listen(fd, 4) == 0 else { + close(fd) + throw XCTSkip("cannot listen on 127.0.0.1 here (errno \(errno))") + } + var length = socklen_t(MemoryLayout.size) + _ = withUnsafeMutablePointer(to: &address) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { getsockname(fd, $0, &length) } + } + Thread.detachNewThread { + let client = accept(fd, nil, nil) + if client >= 0 { behaviour(client) } + close(fd) + } + return UInt16(bigEndian: address.sin_port) + } + + func testCanaryAnsweringServerAnswers() async throws { + let answer = smb2Answer + let port = try listener { client in + var buffer = [UInt8](repeating: 0, count: 256) + _ = read(client, &buffer, buffer.count) + _ = answer.withUnsafeBytes { write(client, $0.baseAddress, answer.count) } + close(client) + } + let verdict = await SMBProbe.probe(address: "127.0.0.1", port: port, timeout: 3) + XCTAssertEqual(verdict, .answers) + } + + /// The smbd-hangs case: the kernel accepts, nobody answers. + func testCanarySilentServerIsNoAnswerWithinTheLimit() async throws { + let port = try listener { client in + Thread.sleep(forTimeInterval: 4) + close(client) + } + let started = Date() + let verdict = await SMBProbe.probe(address: "127.0.0.1", port: port, timeout: 1) + XCTAssertEqual(verdict, .noAnswer(seconds: 1)) + XCTAssertLessThan(Date().timeIntervalSince(started), 3) + } + + func testCanaryNothingListeningIsRefused() async throws { + // Bind a port, then close it: nothing listens there any more. + let fd = socket(AF_INET, SOCK_STREAM, 0) + var address = sockaddr_in() + address.sin_len = UInt8(MemoryLayout.size) + address.sin_family = sa_family_t(AF_INET) + address.sin_addr.s_addr = inet_addr("127.0.0.1") + let bound = withUnsafePointer(to: &address) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + Darwin.bind(fd, $0, socklen_t(MemoryLayout.size)) + } + } + guard bound == 0 else { + close(fd) + throw XCTSkip("cannot bind on 127.0.0.1 here") + } + var length = socklen_t(MemoryLayout.size) + _ = withUnsafeMutablePointer(to: &address) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { getsockname(fd, $0, &length) } + } + close(fd) + let verdict = await SMBProbe.probe( + address: "127.0.0.1", port: UInt16(bigEndian: address.sin_port), timeout: 2) + XCTAssertEqual(verdict, .refused(reason: "ECONNREFUSED")) + } +} diff --git a/mac-app/Tests/CloudMachineAppTests/ShareHealthTests.swift b/mac-app/Tests/CloudMachineAppTests/ShareHealthTests.swift new file mode 100644 index 0000000..9186f7f --- /dev/null +++ b/mac-app/Tests/CloudMachineAppTests/ShareHealthTests.swift @@ -0,0 +1,269 @@ +import CloudMachineCore +import XCTest + +@testable import CloudMachineApp + +/// What the watchdog, `vm-status` and the panel say about the share - the +/// readers of the two probes. The rule they share: only a CONFIRMED readable +/// share and an SMB answer are fine; "no answer" is never shown as OK. +final class ShareHealthTests: XCTestCase { + + private let now = Date(timeIntervalSince1970: 1_757_700_000) + + private func evaluate( + shareRead: ShareReadProbe.Verdict?, smb: SMBProbe.Verdict?, + services: Bool? = true, grace: Bool = false + ) -> BackupHealth.Report { + BackupHealth.evaluate( + lastSuccess: now.addingTimeInterval(-1800), lastAttempt: now.addingTimeInterval(-1800), + result: 0, now: now, machineRunning: true, servicesHealthy: services, + destinationRegistered: true, erroredFiles: 0, outOfSpace: false, queueReadable: true, + withinStartupGrace: grace, shareRead: shareRead, smb: smb, smbAddress: "192.168.64.10") + } + + private func codes(_ report: BackupHealth.Report) -> [String] { report.problems.map(\.code) } + + // MARK: - backup-health + + func testReadableShareThatAnswersIsHealthy() { + XCTAssertTrue(evaluate(shareRead: .readable, smb: .answers).healthy) + XCTAssertTrue(evaluate(shareRead: .noImage, smb: .answers).healthy) + } + + /// Not asked (older callers, tests) stays silent; the live report always asks. + func testNotAskedIsSilent() { + XCTAssertTrue(evaluate(shareRead: nil, smb: nil).healthy) + } + + func testDeadShareAlarms() { + let report = evaluate(shareRead: .dead(reason: "ENXIO"), smb: .answers) + XCTAssertEqual(codes(report), ["share-unreadable"]) + XCTAssertTrue(report.problems[0].detail.contains("ENXIO"), report.problems[0].detail) + } + + func testOtherReadErrorAlarmsTheSameWay() { + XCTAssertEqual( + codes(evaluate(shareRead: .failed(reason: "Permission denied"), smb: .answers)), + ["share-unreadable"]) + } + + /// The case the old layout got wrong: "I do not know" read as "fine". + func testHungReadAlarmsAsUnknownNotAsFine() { + let report = evaluate(shareRead: .noAnswer(hungSeconds: 120), smb: .answers) + XCTAssertEqual(codes(report), ["share-read-no-answer"]) + XCTAssertTrue(report.problems[0].detail.contains("120"), report.problems[0].detail) + XCTAssertEqual( + codes(evaluate(shareRead: .noAnswer(hungSeconds: nil), smb: .answers)), + ["share-read-no-answer"]) + } + + /// Startup grace defers "not up yet" states only - an unknown read and a + /// dead one alarm from the first second, like every other "unknown". + func testGraceDoesNotSilenceTheShare() { + XCTAssertEqual( + codes(evaluate(shareRead: .noAnswer(hungSeconds: 30), smb: .answers, grace: true)), + ["share-read-no-answer"]) + XCTAssertEqual( + codes(evaluate(shareRead: .dead(reason: "EIO"), smb: .answers, grace: true)), + ["share-unreadable"]) + } + + func testSambaRefusingAlarms() { + let report = evaluate(shareRead: .readable, smb: .refused(reason: "EHOSTUNREACH")) + XCTAssertEqual(codes(report), ["smb-no-connection"]) + XCTAssertTrue(report.problems[0].detail.contains("192.168.64.10")) + XCTAssertTrue(report.problems[0].detail.contains("EHOSTUNREACH")) + XCTAssertEqual(codes(evaluate(shareRead: .readable, smb: .notSMB)), ["smb-no-connection"]) + } + + /// Samba refusing right after startup is "not up yet" - deferred, not lost. + func testSambaRefusingRightAfterStartupIsDeferred() { + let report = evaluate(shareRead: .readable, smb: .refused(reason: "ECONNREFUSED"), grace: true) + XCTAssertTrue(report.healthy) + XCTAssertEqual(report.deferred.map(\.code), ["smb-no-connection"]) + } + + /// No route: the route is gone or Local Network privacy blocks this program. + func testSambaUnreachableIsUnknownAndNamesLocalNetworkPrivacy() { + let report = evaluate(shareRead: .readable, smb: .unreachable(reason: "EHOSTUNREACH")) + XCTAssertEqual(codes(report), ["smb-unreachable"]) + XCTAssertEqual( + report.problems[0].summary, "Unknown whether Samba in the machine answers this Mac") + XCTAssertTrue(report.problems[0].detail.contains("Local Network"), report.problems[0].detail) + XCTAssertTrue(report.problems[0].detail.contains("EHOSTUNREACH")) + } + + func testSambaSilentIsUnknown() { + XCTAssertEqual( + codes(evaluate(shareRead: .readable, smb: .noAnswer(seconds: 5))), ["smb-no-answer"]) + XCTAssertEqual(codes(evaluate(shareRead: .readable, smb: .noAddress)), ["smb-no-address"]) + } + + /// With a service down a harder message already speaks; a second one about + /// the consequence only dilutes it (same rule as the rc message). + func testServiceDownSpeaksAlone() { + let report = evaluate( + shareRead: .dead(reason: "ENOENT"), smb: .refused(reason: "ECONNREFUSED"), services: false) + XCTAssertEqual(codes(report), ["machine-service-down"]) + } + + func testCodesAreOwnAndDistinct() { + let reports = [ + evaluate(shareRead: .dead(reason: "EIO"), smb: .refused(reason: "X")), + evaluate(shareRead: .noAnswer(hungSeconds: 1), smb: .noAnswer(seconds: 5)), + evaluate(shareRead: .readable, smb: .noAddress), + ] + for report in reports { + XCTAssertEqual(Set(codes(report)).count, report.problems.count) + for problem in report.problems { XCTAssertNotEqual(problem.code, problem.summary) } + } + } + + // MARK: - vm-status + + func testStatusLines() { + let fine = VMStatus.shareLines(read: .readable, smb: .answers, address: "192.168.64.10") + XCTAssertTrue(fine.healthy) + XCTAssertEqual(fine.lines.count, 2) + + let noImage = VMStatus.shareLines(read: .noImage, smb: .answers, address: "a") + XCTAssertTrue(noImage.healthy) + + let cases: [(ShareReadProbe.Verdict, SMBProbe.Verdict, String)] = [ + (.dead(reason: "EIO"), .answers, "EIO"), + (.noAnswer(hungSeconds: 42), .answers, "42"), + (.readable, .refused(reason: "ECONNREFUSED"), "ECONNREFUSED"), + (.readable, .noAnswer(seconds: 5), "5"), + (.readable, .noAddress, "Samba"), + ] + for (read, smb, mention) in cases { + let result = VMStatus.shareLines(read: read, smb: smb, address: "192.168.64.10") + XCTAssertFalse(result.healthy, "\(read) \(smb)") + XCTAssertTrue(result.lines.joined().contains(mention), "\(result.lines)") + } + } + + // MARK: - The panel + + @MainActor + func testPanelSambaUnreachableIsAmber() { + let status = AppStatus.healthyForTests() + status.machine.smb = .unreachable(reason: "EHOSTUNREACH") + XCTAssertFalse(status.healthy) + XCTAssertEqual(status.headline, "Unknown whether Samba in the machine answers this Mac") + XCTAssertEqual(status.overallTone, .warning) + } + + // MARK: - The machine card in the window + + /// REVIEW 03.11: the card said "All running" in green with the share dead, + /// and "Not running: " with an empty list for an inactive bind after + /// `vm-prepare-bundle`. + func testMachineCardNamesTheInactiveBind() { + var machine = MachineStatus.healthyForTests + machine.services["tm-bands-bind"] = "inactive" + machine.bundlePrepared = true + XCTAssertEqual(machine.servicesDown, ["tm-bands-bind"]) + XCTAssertEqual(machine.servicesText, "Not running: tm-bands-bind") + XCTAssertEqual(machine.servicesTone, .danger) + machine.bundlePrepared = false + XCTAssertEqual(machine.servicesDown, []) + } + + func testMachineCardIsGreenOnlyWithTheShareConfirmed() { + var machine = MachineStatus.healthyForTests + XCTAssertEqual(machine.servicesText, "All running") + XCTAssertEqual(machine.servicesTone, .success) + + machine.shareRead = .dead(reason: "EIO") + XCTAssertEqual(machine.servicesText, "The machine's share cannot be read") + XCTAssertEqual(machine.servicesTone, .danger) + + machine.shareRead = .noAnswer(hungSeconds: 40) + XCTAssertEqual(machine.servicesText, "Unknown whether the machine's share can be read") + XCTAssertEqual(machine.servicesTone, .warning) + + machine.shareRead = .readable + machine.smb = .refused(reason: "ECONNREFUSED") + XCTAssertEqual(machine.servicesText, "Samba in the machine does not answer this Mac") + XCTAssertEqual(machine.servicesTone, .danger) + + machine.smb = nil + XCTAssertEqual(machine.servicesTone, .warning, "not asked is not green") + } + + /// The panel reads the bind's verdict with the image's state from the same + /// report: inactive after `vm-prepare-bundle` is a service problem. + func testPanelSeesTheInactiveBindAfterPrepare() { + var machine = MachineStatus.healthyForTests + XCTAssertEqual(machine.servicesHealthy, true) + machine.services["tm-bands-bind"] = "inactive" + machine.bundlePrepared = true + XCTAssertEqual(machine.servicesHealthy, false) + machine.bundlePrepared = false + XCTAssertEqual(machine.servicesHealthy, true) + } + + @MainActor + func testPanelIsGreenOnlyForAConfirmedShare() { + let status = AppStatus.healthyForTests() + XCTAssertTrue(status.healthy) + XCTAssertEqual(status.headline, "Ready") + } + + @MainActor + func testPanelDeadShareIsRed() { + let status = AppStatus.healthyForTests() + status.machine.shareRead = .dead(reason: "ENXIO") + XCTAssertFalse(status.healthy) + XCTAssertEqual(status.headline, "The machine's share cannot be read") + XCTAssertEqual(status.overallTone, .danger) + } + + @MainActor + func testPanelUnknownShareIsNeverGreenNorReady() { + for verdict in [ShareReadProbe.Verdict.noAnswer(hungSeconds: 30), .noAnswer(hungSeconds: nil)] { + let status = AppStatus.healthyForTests() + status.machine.shareRead = verdict + XCTAssertFalse(status.healthy) + XCTAssertEqual(status.headline, "Unknown whether the machine's share can be read") + XCTAssertEqual(status.overallTone, .warning) + } + let unread = AppStatus.healthyForTests() + unread.machine.shareRead = nil + XCTAssertFalse(unread.healthy, "unread is not readable") + XCTAssertNotEqual(unread.headline, "Ready") + } + + @MainActor + func testPanelSambaRefusingIsRed() { + let status = AppStatus.healthyForTests() + status.machine.smb = .refused(reason: "EHOSTUNREACH") + XCTAssertFalse(status.healthy) + XCTAssertEqual(status.headline, "Samba in the machine does not answer this Mac") + XCTAssertEqual(status.overallTone, .danger) + } + + @MainActor + func testPanelSambaUnknownIsAmber() { + for verdict in [SMBProbe.Verdict.noAnswer(seconds: 5), .noAddress] { + let status = AppStatus.healthyForTests() + status.machine.smb = verdict + XCTAssertFalse(status.healthy) + XCTAssertEqual(status.headline, "Unknown whether Samba in the machine answers this Mac") + XCTAssertEqual(status.overallTone, .warning) + } + let unread = AppStatus.healthyForTests() + unread.machine.smb = nil + XCTAssertFalse(unread.healthy) + } + + /// The button that starts a backup still follows the device checks, not + /// the probes: an unknown read must not grey out the way to find out. + @MainActor + func testUnknownShareStillAllowsBackingUp() { + let status = AppStatus.healthyForTests() + status.machine.shareRead = .noAnswer(hungSeconds: 30) + XCTAssertTrue(status.canStartBackup) + } +} diff --git a/mac-app/Tests/CloudMachineAppTests/ShareReadProbeTests.swift b/mac-app/Tests/CloudMachineAppTests/ShareReadProbeTests.swift new file mode 100644 index 0000000..c2ec39c --- /dev/null +++ b/mac-app/Tests/CloudMachineAppTests/ShareReadProbeTests.swift @@ -0,0 +1,442 @@ +import Foundation +import XCTest + +@testable import CloudMachineCore + +/// The read through the share's own path in the machine +/// (smbd -> tm-bands-bind -> punchfs -> rclone). +/// +/// Every service in that chain can be `active` while a read through it fails +/// or never returns: a hung FUSE daemon is still a live process, and punchfs +/// keeps the dead mount of an rclone that systemd restarted under it. Until +/// this probe nothing read through the chain, so the panel said "Ready" for up +/// to 3 h - until the age of the last backup crossed the threshold. +final class ShareReadProbeTests: XCTestCase { + + // MARK: - Parsing what the machine printed + + func testReadableByteIsReadable() { + XCTAssertEqual(ShareReadProbe.verdict(scriptOutput: "SVC smbd active\nPROBE ok\n"), .readable) + } + + func testNoImageIsNotAFailure() { + XCTAssertEqual(ShareReadProbe.verdict(scriptOutput: "PROBE none"), .noImage) + } + + /// The 22.09 incident class: the device is gone, the mount table still lists it. + func testDeviceErrorsAreDead() { + let cases: [(String, String)] = [ + ("head: error reading '/srv/tm-local/x.sparsebundle/bands/0': Input/output error", "EIO"), + ("head: cannot open 'f' for reading: Transport endpoint is not connected", "ENOTCONN"), + ("head: f: Socket is not connected", "ENOTCONN"), + ("head: cannot open 'f' for reading: No such device or address", "ENXIO"), + ("head: cannot open 'f' for reading: No such device", "ENODEV"), + ("head: error reading 'f': Stale file handle", "ESTALE"), + ("head: f: Stale NFS file handle", "ESTALE"), + ] + for (text, name) in cases { + XCTAssertEqual( + ShareReadProbe.verdict(scriptOutput: "PROBE error \(text)"), .dead(reason: name), text) + } + } + + /// With an image in the machine, its first band missing means the bind is + /// not there: bands would go to the machine's local disk, not to Drive. + func testMissingBandIsDead() { + XCTAssertEqual( + ShareReadProbe.verdict( + scriptOutput: "PROBE error head: cannot open 'f' for reading: No such file or directory"), + .dead(reason: "ENOENT")) + } + + /// Not a device failure - restarting the stack would not fix it, but it + /// is not "readable" either. + func testOtherErrorsFailWithoutBeingDead() { + XCTAssertEqual( + ShareReadProbe.verdict( + scriptOutput: "PROBE error head: cannot open 'f' for reading: Permission denied"), + .failed(reason: "Permission denied")) + XCTAssertEqual( + ShareReadProbe.verdict(scriptOutput: "PROBE error "), .failed(reason: "no message")) + } + + func testHungReadIsNoAnswerWithItsAge() { + XCTAssertEqual( + ShareReadProbe.verdict(scriptOutput: "PROBE hung 734"), .noAnswer(hungSeconds: 734)) + } + + /// "I do not know" must not become "fine": a report without the line is + /// not a readable share. + func testMissingLineIsNoAnswer() { + XCTAssertEqual( + ShareReadProbe.verdict(scriptOutput: "SVC smbd active\n"), .noAnswer(hungSeconds: nil)) + XCTAssertEqual( + ShareReadProbe.verdict(scriptOutput: "PROBE garbage"), .noAnswer(hungSeconds: nil)) + } + + func testOnlyReadableAndNoImageAreConfirmed() { + XCTAssertTrue(ShareReadProbe.Verdict.readable.isConfirmed) + XCTAssertTrue(ShareReadProbe.Verdict.noImage.isConfirmed) + XCTAssertFalse(ShareReadProbe.Verdict.dead(reason: "EIO").isConfirmed) + XCTAssertFalse(ShareReadProbe.Verdict.failed(reason: "x").isConfirmed) + XCTAssertFalse(ShareReadProbe.Verdict.noAnswer(hungSeconds: 3).isConfirmed) + XCTAssertFalse(ShareReadProbe.Verdict.noAnswer(hungSeconds: nil).isConfirmed) + } + + /// The status script carries the probe, so every reader of the machine + /// (panel, vm-status, backup-health, vm-guard) gets the verdict. + func testStatusReportCarriesTheVerdict() { + XCTAssertTrue(VMStatus.script.contains(ShareReadProbe.guestScript)) + let report = VMStatus.report(scriptOutput: "SVC smbd active\nPROBE error x: Input/output error") + XCTAssertEqual(report.shareRead, .dead(reason: "EIO")) + XCTAssertEqual( + VMStatus.report(scriptOutput: "SVC smbd active").shareRead, .noAnswer(hungSeconds: nil)) + } + + /// REVIEW 03.11: the status script's worst case - the probe's wait, both + /// `curl -m 20` to rclone's rc, the log reads - must fit its time limit, + /// with room for `limactl shell` itself. Without that a hung rclone made + /// the whole report "no answer" and killed the probe's own reader. + func testStatusScriptWorstCaseFitsItsTimeLimit() { + let curls = VMLayout.readingScript.components(separatedBy: "curl -s -m 20").count - 1 + XCTAssertEqual(curls, 2) + let worstCase = Double(ShareReadProbe.timeoutSeconds + 1 + curls * 20) + XCTAssertGreaterThanOrEqual(VMStatus.reportTimeout, worstCase + 30) + } + + /// The guest script is the tested pieces with the machine's own paths. + func testGuestScriptIsTheTestedScriptWithTheMachinesPaths() { + XCTAssertEqual( + ShareReadProbe.guestScript, + ShareReadProbe.script( + locate: ShareReadProbe.locateFirstBand( + bundleEnv: VMLayout.Guest.bundleEnv, localRoot: VMLayout.Guest.localRoot), + stateDir: ShareReadProbe.stateDir, timeout: ShareReadProbe.timeoutSeconds)) + XCTAssertTrue(ShareReadProbe.stateDir.hasPrefix("/run/"), "a tmpfs: a reboot clears a lock") + } + + // MARK: - Canary: which file the machine reads + + /// What `locateFirstBand` sets `f` to, for a scratch bundle.env and share root. + private func located(env: String?, images: [String: Bool]) throws -> String { + let dir = try scratch() + let root = dir.appendingPathComponent("tm-local") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + for (image, hasFirstBand) in images { + let bands = root.appendingPathComponent(image).appendingPathComponent("bands") + try FileManager.default.createDirectory(at: bands, withIntermediateDirectories: true) + if hasFirstBand { try Data([1]).write(to: bands.appendingPathComponent("0")) } + } + let envFile = dir.appendingPathComponent("bundle.env") + if let env { try env.write(to: envFile, atomically: true, encoding: .utf8) } + let locate = ShareReadProbe.locateFirstBand(bundleEnv: envFile.path, localRoot: root.path) + let (output, _) = try bash("\(locate); echo \"F=$f\"") + return output.trimmingCharacters(in: .whitespacesAndNewlines) + .replacingOccurrences(of: root.path + "/", with: "") + } + + func testLocatesNothingWithoutAnImage() throws { + XCTAssertEqual(try located(env: nil, images: [:]), "F=") + } + + /// A prepared image is named in bundle.env, and its band is read whatever + /// it looks like from here: after `vm-prepare-bundle` the bands are the + /// FUSE chain, and a missing band 0 is exactly what the probe must report. + func testPreparedImageIsReadEvenWithoutItsBand() throws { + XCTAssertEqual( + try located(env: "CM_BUNDLE=\"Mac.sparsebundle\"\n", images: ["Mac.sparsebundle": false]), + "F=Mac.sparsebundle/bands/0") + } + + /// REVIEW 03.11: Time Machine creating a NEW image writes the directory + /// first and band 0 a moment later; on the machine's own disk (not + /// prepared yet) that gap is not a dead share. + func testUnpreparedImageWithoutItsBandIsNoImageYet() throws { + XCTAssertEqual(try located(env: nil, images: ["Mac.sparsebundle": false]), "F=") + XCTAssertEqual( + try located(env: nil, images: ["Mac.sparsebundle": true]), "F=Mac.sparsebundle/bands/0") + } + + // MARK: - Canary: the real script, in bash, on a real file system + + /// Runs `script` in bash; returns stdout and how long it took. + private func bash(_ script: String) throws -> (String, TimeInterval) { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/bash") + process.arguments = ["-c", script] + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = FileHandle.nullDevice + let started = Date() + try process.run() + let output = String(decoding: pipe.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self) + process.waitUntilExit() + return (output, Date().timeIntervalSince(started)) + } + + private func probeScript(target: String, state: URL, timeout: Int = 2) -> String { + ShareReadProbe.script( + locate: "f=\(VMLayout.shellQuoted(target))", stateDir: state.path, timeout: timeout) + } + + /// Runs the probe in its OWN process group, the way `sudo` with `use_pty` + /// runs it in the machine, and returns that group's id with the output. + private func runInOwnGroup(target: String, state: URL, output: URL) throws -> pid_t { + var attributes: posix_spawnattr_t? + posix_spawnattr_init(&attributes) + defer { posix_spawnattr_destroy(&attributes) } + posix_spawnattr_setflags(&attributes, Int16(POSIX_SPAWN_SETPGROUP)) + posix_spawnattr_setpgroup(&attributes, 0) + var actions: posix_spawn_file_actions_t? + posix_spawn_file_actions_init(&actions) + defer { posix_spawn_file_actions_destroy(&actions) } + posix_spawn_file_actions_addopen(&actions, 1, output.path, O_WRONLY | O_CREAT | O_TRUNC, 0o600) + posix_spawn_file_actions_addopen(&actions, 2, "/dev/null", O_WRONLY, 0) + let arguments = ["/bin/bash", "-c", probeScript(target: target, state: state)] + var argv: [UnsafeMutablePointer?] = arguments.map { strdup($0) } + [nil] + defer { + for pointer in argv { free(pointer) } + } + var pid: pid_t = 0 + let spawned = posix_spawn(&pid, "/bin/bash", &actions, &attributes, &argv, environ) + XCTAssertEqual(spawned, 0) + var status: Int32 = 0 + waitpid(pid, &status, 0) + return pid + } + + /// Waits up to `seconds` for `condition`. + private func eventually(_ seconds: TimeInterval, _ condition: () -> Bool) -> Bool { + let deadline = Date().addingTimeInterval(seconds) + while Date() < deadline { + if condition() { return true } + Thread.sleep(forTimeInterval: 0.1) + } + return condition() + } + + /// REVIEW 03.11 (sudo `use_pty`): the script's process group gets SIGHUP + /// when bash exits or limactl is killed. A reader that dies of it leaves + /// its lock behind, and every later run says "hung" on a healthy share. + /// The reader must outlive the hangup and clean up after itself. + func testCanaryReaderSurvivesTheHangupAndCleansUp() throws { + let dir = try scratch() + let fifo = dir.appendingPathComponent("fifo") + XCTAssertEqual(mkfifo(fifo.path, 0o600), 0) + let state = dir.appendingPathComponent("state") + let output = dir.appendingPathComponent("out") + addTeardownBlock { self.release(fifo) } + + let group = try runInOwnGroup(target: fifo.path, state: state, output: output) + let first = try String(contentsOf: output, encoding: .utf8) + guard case .noAnswer(_?) = ShareReadProbe.verdict(scriptOutput: first) else { + return XCTFail("expected a hung verdict, got: \(first)") + } + _ = kill(-group, SIGHUP) + Thread.sleep(forTimeInterval: 0.3) + XCTAssertEqual(readers(of: fifo.path), 1, "the hangup must not kill the reader") + + release(fifo) + let lock = state.appendingPathComponent("lock").path + XCTAssertTrue( + eventually(5) { !FileManager.default.fileExists(atPath: lock) }, + "the reader removes its own lock when the read returns") + let (next, _) = try bash( + probeScript(target: dir.appendingPathComponent("absent").path, state: state)) + XCTAssertEqual(ShareReadProbe.verdict(scriptOutput: next), .dead(reason: "ENOENT"), next) + } + + /// A lock whose reader is gone (killed outright, SIGKILL) is taken over, + /// not reported as "hung" forever. + func testCanaryDeadReadersLockIsReclaimed() throws { + let dir = try scratch() + let state = dir.appendingPathComponent("state") + let lock = state.appendingPathComponent("lock") + try FileManager.default.createDirectory(at: lock, withIntermediateDirectories: true) + let gone = Process() + gone.executableURL = URL(fileURLWithPath: "/usr/bin/true") + try gone.run() + gone.waitUntilExit() + try "\(gone.processIdentifier)\n".write( + to: lock.appendingPathComponent("pid"), atomically: true, encoding: .utf8) + try "\(Int(Date().timeIntervalSince1970) - 30)\n".write( + to: lock.appendingPathComponent("start"), atomically: true, encoding: .utf8) + let file = dir.appendingPathComponent("0") + try Data([7]).write(to: file) + + let (output, _) = try bash(probeScript(target: file.path, state: state)) + XCTAssertEqual(ShareReadProbe.verdict(scriptOutput: output), .readable, output) + } + + /// A lock that never got its reader's pid (the reader died at once) is + /// taken over once it is clearly older than any read. + func testCanaryOldLockWithoutAPidIsReclaimed() throws { + let dir = try scratch() + let state = dir.appendingPathComponent("state") + let lock = state.appendingPathComponent("lock") + try FileManager.default.createDirectory(at: lock, withIntermediateDirectories: true) + try "\(Int(Date().timeIntervalSince1970) - 600)\n".write( + to: lock.appendingPathComponent("start"), atomically: true, encoding: .utf8) + let file = dir.appendingPathComponent("0") + try Data([7]).write(to: file) + + let (output, _) = try bash(probeScript(target: file.path, state: state)) + XCTAssertEqual(ShareReadProbe.verdict(scriptOutput: output), .readable, output) + } + + /// The panel, backup-health and vm-status can ask at the same moment: one + /// reader between them, and all of them get a verdict. + func testCanaryParallelRunsShareOneReader() throws { + let dir = try scratch() + let fifo = dir.appendingPathComponent("fifo") + XCTAssertEqual(mkfifo(fifo.path, 0o600), 0) + let state = dir.appendingPathComponent("state") + addTeardownBlock { self.release(fifo) } + + let script = probeScript(target: fifo.path, state: state) + final class Outputs: @unchecked Sendable { + let lock = NSLock() + var values: [String] = [] + } + let outputs = Outputs() + let group = DispatchGroup() + for _ in 0..<3 { + group.enter() + DispatchQueue.global().async { + let output = (try? self.bash(script).0) ?? "" + outputs.lock.lock() + outputs.values.append(output) + outputs.lock.unlock() + group.leave() + } + } + XCTAssertEqual(group.wait(timeout: .now() + 15), .success) + XCTAssertEqual(outputs.values.count, 3) + for output in outputs.values { + guard case .noAnswer(_?) = ShareReadProbe.verdict(scriptOutput: output) else { + return XCTFail("expected a hung verdict, got: \(output)") + } + } + XCTAssertEqual(readers(of: fifo.path), 1, "one reader for all three") + } + + /// Opens the FIFO for writing on a thread, which lets a reader stuck in its + /// `open` go; the teardown must not hang if there is none. + private func release(_ fifo: URL) { + let path = fifo.path + Thread.detachNewThread { + let fd = open(path, O_WRONLY) + if fd >= 0 { close(fd) } + } + Thread.sleep(forTimeInterval: 0.3) + } + + /// Runs the probe script with a fixed target in a scratch directory. + private func run(target: String, state: URL, timeout: Int = 2) throws -> (String, TimeInterval) { + let script = ShareReadProbe.script( + locate: "f=\(VMLayout.shellQuoted(target))", stateDir: state.path, timeout: timeout) + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/bash") + process.arguments = ["-c", script] + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = FileHandle.nullDevice + let started = Date() + try process.run() + process.waitUntilExit() + let output = String(decoding: pipe.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self) + return (output, Date().timeIntervalSince(started)) + } + + private func scratch() throws -> URL { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("cm-sonda-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + addTeardownBlock { try? FileManager.default.removeItem(at: dir) } + return dir + } + + func testCanaryReadsARealFile() throws { + let dir = try scratch() + let file = dir.appendingPathComponent("0") + try Data([0x42]).write(to: file) + let (output, _) = try run(target: file.path, state: dir.appendingPathComponent("state")) + XCTAssertEqual(ShareReadProbe.verdict(scriptOutput: output), .readable, output) + } + + func testCanaryMissingFileIsENOENT() throws { + let dir = try scratch() + let (output, _) = try run( + target: dir.appendingPathComponent("nope").path, state: dir.appendingPathComponent("state")) + XCTAssertEqual(ShareReadProbe.verdict(scriptOutput: output), .dead(reason: "ENOENT"), output) + } + + func testCanaryEmptyTargetIsNoImage() throws { + let dir = try scratch() + let script = ShareReadProbe.script( + locate: "f=", stateDir: dir.appendingPathComponent("state").path, timeout: 2) + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/bash") + process.arguments = ["-c", script] + let pipe = Pipe() + process.standardOutput = pipe + try process.run() + process.waitUntilExit() + let output = String(decoding: pipe.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self) + XCTAssertEqual(ShareReadProbe.verdict(scriptOutput: output), .noImage, output) + } + + /// A FIFO with no writer blocks `open` the way a hung FUSE mount blocks a + /// read: the script must come back within its limit, say "hung", and NOT + /// start a second reader on the next run - one stuck process, not one per + /// panel refresh. + func testCanaryHungReadComesBackAndDoesNotPileUp() throws { + let dir = try scratch() + let fifo = dir.appendingPathComponent("fifo") + XCTAssertEqual(mkfifo(fifo.path, 0o600), 0) + let state = dir.appendingPathComponent("state") + // Releases the stuck reader whatever the assertions do. A blocking open: + // the reader is still inside its own `open`, and a non-blocking writer + // gets ENXIO then. On a thread, so the teardown itself cannot hang. + addTeardownBlock { + let path = fifo.path + Thread.detachNewThread { + let fd = open(path, O_WRONLY) + if fd >= 0 { close(fd) } + } + Thread.sleep(forTimeInterval: 0.3) + } + + let (first, firstTook) = try run(target: fifo.path, state: state, timeout: 2) + guard case .noAnswer(let hung?) = ShareReadProbe.verdict(scriptOutput: first) else { + return XCTFail("expected a hung verdict, got: \(first)") + } + XCTAssertGreaterThanOrEqual(hung, 1) + XCTAssertLessThan(firstTook, 8, "the probe must not wait on the stuck read") + + // The second run finds the stuck one and reports it at once. + let (second, secondTook) = try run(target: fifo.path, state: state, timeout: 2) + guard case .noAnswer(let again?) = ShareReadProbe.verdict(scriptOutput: second) else { + return XCTFail("expected a hung verdict, got: \(second)") + } + XCTAssertGreaterThanOrEqual(again, hung) + XCTAssertLessThan(secondTook, 2, "an already stuck probe is reported without waiting again") + XCTAssertEqual(readers(of: fifo.path), 1, "one stuck reader, not one per run") + } + + /// `head` processes reading `path` (the stuck probes). + private func readers(of path: String) -> Int { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/ps") + process.arguments = ["-axo", "command="] + process.environment = ["LC_ALL": "C"] + let pipe = Pipe() + process.standardOutput = pipe + try? process.run() + // Read BEFORE waiting: the process table does not fit in a pipe buffer, + // and `ps` blocked on a full pipe never exits. + let text = String(decoding: pipe.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self) + process.waitUntilExit() + return text.split(separator: "\n").filter { $0.hasPrefix("head -c1 -- \(path)") }.count + } +} diff --git a/mac-app/Tests/CloudMachineAppTests/VMLayoutTests.swift b/mac-app/Tests/CloudMachineAppTests/VMLayoutTests.swift index 4bc86fe..3391630 100644 --- a/mac-app/Tests/CloudMachineAppTests/VMLayoutTests.swift +++ b/mac-app/Tests/CloudMachineAppTests/VMLayoutTests.swift @@ -124,17 +124,35 @@ final class VMLayoutTests: XCTestCase { "tm-bands-bind": "inactive", "smbd": "active", "tm-meta-sync.timer": "active", ] - XCTAssertEqual(VMStatus.servicesHealthy(services), true) + XCTAssertEqual(VMStatus.servicesHealthy(services, bundlePrepared: false), true) services["tm-meta-sync.timer"] = "inactive" - XCTAssertEqual(VMStatus.servicesHealthy(services), false) + XCTAssertEqual(VMStatus.servicesHealthy(services, bundlePrepared: false), false) services["tm-meta-sync.timer"] = "active" services["tm-bands-bind"] = "failed" - XCTAssertEqual(VMStatus.servicesHealthy(services), false) + XCTAssertEqual(VMStatus.servicesHealthy(services, bundlePrepared: false), false) services["tm-bands-bind"] = "active" services["smbd"] = "inactive" - XCTAssertEqual(VMStatus.servicesHealthy(services), false) + XCTAssertEqual(VMStatus.servicesHealthy(services, bundlePrepared: false), false) services["smbd"] = nil - XCTAssertNil(VMStatus.servicesHealthy(services)) + XCTAssertNil(VMStatus.servicesHealthy(services, bundlePrepared: false)) + } + + /// After `vm-prepare-bundle` an inactive bind is NOT "skipped by design": + /// the bands then go to the machine's local disk instead of Drive, with + /// every other service green. Whether the image is prepared not read = + /// unknown, not fine. + func testInactiveBindAfterPrepareIsNotHealthy() { + let services = [ + "smb-throttle": "active", "rclone-tm": "active", "punchfs": "active", + "tm-bands-bind": "inactive", + "smbd": "active", "tm-meta-sync.timer": "active", + ] + XCTAssertEqual(VMStatus.servicesHealthy(services, bundlePrepared: true), false) + XCTAssertNil(VMStatus.servicesHealthy(services, bundlePrepared: nil)) + var bound = services + bound["tm-bands-bind"] = "active" + XCTAssertEqual(VMStatus.servicesHealthy(bound, bundlePrepared: true), true) + XCTAssertEqual(VMStatus.servicesHealthy(bound, bundlePrepared: nil), true) } /// An unknown backlog after `prepare-shutdown` must not read as "all uploaded".