Fix CampusNAS recovery after VPN loss

This commit is contained in:
2026-08-13 01:15:14 +06:00
parent cd53b6a1a1
commit b08f0e8cf1
3 changed files with 53 additions and 5 deletions
@@ -12,6 +12,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
private var hosting: NSHostingController<ContentView>?
private var settingsWindow: NSWindow?
private var keyMonitor: Any?
private var wakeObserver: NSObjectProtocol?
private var cancellables = Set<AnyCancellable>()
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.
+24 -2
View File
@@ -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
+16 -3
View File
@@ -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) }
}
}