Fix CampusNAS recovery after VPN loss
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user