From b08f0e8cf12217677866ceb6d135e401432f79f2 Mon Sep 17 00:00:00 2001 From: Roman Varlakov Date: Thu, 13 Aug 2026 01:15:14 +0600 Subject: [PATCH] Fix CampusNAS recovery after VPN loss --- Sources/CampusNASMonitor/AppDelegate.swift | 13 +++++++++++ Sources/CampusNASMonitor/AppModel.swift | 26 ++++++++++++++++++++-- Sources/CampusNASMonitor/Probe.swift | 19 +++++++++++++--- 3 files changed, 53 insertions(+), 5 deletions(-) diff --git a/Sources/CampusNASMonitor/AppDelegate.swift b/Sources/CampusNASMonitor/AppDelegate.swift index 32fa883..93f0bed 100644 --- a/Sources/CampusNASMonitor/AppDelegate.swift +++ b/Sources/CampusNASMonitor/AppDelegate.swift @@ -12,6 +12,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { private var hosting: NSHostingController? private var settingsWindow: NSWindow? private var keyMonitor: Any? + private var wakeObserver: NSObjectProtocol? private var cancellables = Set() func applicationDidFinishLaunching(_ notification: Notification) { @@ -39,6 +40,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { .store(in: &cancellables) installKeyMonitor() + observeWake() model.start() render() @@ -51,6 +53,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { } } + private func observeWake() { + wakeObserver = NSWorkspace.shared.notificationCenter.addObserver( + forName: NSWorkspace.didWakeNotification, object: nil, queue: .main + ) { [weak self] _ in + // NetworkExtension and Wi-Fi routes settle just after didWake. + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + Task { @MainActor in self?.model.wake() } + } + } + } + /// Launching the app again while it is already running (Finder, Spotlight, /// `open`) brings the window up instead of doing nothing, which is what a /// second launch is normally asking for. diff --git a/Sources/CampusNASMonitor/AppModel.swift b/Sources/CampusNASMonitor/AppModel.swift index baf0580..6fb1c40 100644 --- a/Sources/CampusNASMonitor/AppModel.swift +++ b/Sources/CampusNASMonitor/AppModel.swift @@ -50,6 +50,8 @@ final class AppModel: ObservableObject { private(set) var config: Config private var timer: Timer? private var checking = false + private var checkStartedAt: Date? + private var checkGeneration = 0 /// Tracked separately from `state` so "just became reachable" can be told /// apart from "still reachable" when deciding to auto-mount. private var previousState: LinkState = .unknown @@ -114,12 +116,24 @@ final class AppModel: ObservableObject { // MARK: - Probing - func checkNow() { - guard !checking else { return } + func checkNow(force: Bool = false) { + // A network framework call is supposed to be bounded, but defensive + // recovery matters here: one stuck check must not freeze the displayed + // state for days. Normal overlapping timer ticks are still ignored. + if checking && !force { + let age = checkStartedAt.map { Date().timeIntervalSince($0) } ?? 0 + guard age > max(config.probeTimeoutSeconds * 3, 15) else { return } + Log.write("probe watchdog: replacing check stuck for \(Int(age)) s") + } + + checkGeneration += 1 + let generation = checkGeneration checking = true + checkStartedAt = Date() Task { @MainActor in let next = await probe() + guard generation == self.checkGeneration else { return } self.mounted = Mounter.mountedShares(host: config.nasHost) let wasUnknown = self.previousState == .unknown @@ -131,6 +145,7 @@ final class AppModel: ObservableObject { self.previousState = next self.state = next self.checking = false + self.checkStartedAt = nil if notable { self.onNotableChange?(next) } @@ -145,6 +160,13 @@ final class AppModel: ObservableObject { } } + /// Sleep invalidates tunnels and may pause run-loop timers. Re-probe as + /// soon as macOS wakes, superseding a check that started before sleep. + func wake() { + Log.write("system wake: refreshing VPN and NAS state") + checkNow(force: true) + } + /// Explicit refresh always re-enumerates, unlike the polling path. func refreshAll() { Task { @MainActor in diff --git a/Sources/CampusNASMonitor/Probe.swift b/Sources/CampusNASMonitor/Probe.swift index c47e7fe..81143db 100644 --- a/Sources/CampusNASMonitor/Probe.swift +++ b/Sources/CampusNASMonitor/Probe.swift @@ -15,6 +15,19 @@ private final class ResumeOnce: @unchecked Sendable { } } +/// `Process.terminate()` only sends SIGTERM. A wedged network client (notably +/// `smbutil` after a tunnel disappears) may ignore it and keep the pipe open, +/// leaving the reader thread in `readDataToEndOfFile()` forever. Escalate to +/// SIGKILL after a short grace period so every probe is actually bounded. +private func stop(_ process: Process) { + guard process.isRunning else { return } + process.terminate() + let pid = process.processIdentifier + DispatchQueue.global().asyncAfter(deadline: .now() + 1) { + if process.isRunning { Darwin.kill(pid, SIGKILL) } + } +} + enum Probe { /// Runs a command and reports whether it exited 0. A process that outlives /// `timeout` is killed and treated as failure — the VPN wrapper can block on @@ -51,7 +64,7 @@ enum Probe { DispatchQueue.global().asyncAfter(deadline: .now() + timeout) { guard process.isRunning else { return } - process.terminate() + stop(process) if once.claim() { continuation.resume(returning: false) } } } @@ -119,7 +132,7 @@ enum Probe { DispatchQueue.global().asyncAfter(deadline: .now() + timeout) { guard process.isRunning else { return } - process.terminate() + stop(process) if once.claim() { continuation.resume(returning: RunResult( ok: false, output: "", error: "timeout after \(timeout)s")) @@ -170,7 +183,7 @@ enum Probe { DispatchQueue.global().asyncAfter(deadline: .now() + timeout) { guard process.isRunning else { return } - process.terminate() + stop(process) if once.claim() { continuation.resume(returning: nil) } } }