import Foundation import Network /// One-shot guard so a continuation can never be resumed twice, which would trap. private final class ResumeOnce: @unchecked Sendable { private let lock = NSLock() private var fired = false func claim() -> Bool { lock.lock() defer { lock.unlock() } if fired { return false } fired = true return true } } 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 /// a dead network otherwise. static func commandSucceeds( _ launchPath: String, args: [String], timeout: TimeInterval ) async -> Bool { guard FileManager.default.isExecutableFile(atPath: launchPath) else { NSLog("campusnas-monitor: not executable: %@", launchPath) return false } let process = Process() process.executableURL = URL(fileURLWithPath: launchPath) process.arguments = args process.standardOutput = FileHandle.nullDevice process.standardError = FileHandle.nullDevice let once = ResumeOnce() return await withCheckedContinuation { continuation in process.terminationHandler = { proc in guard once.claim() else { return } continuation.resume(returning: proc.terminationStatus == 0) } do { try process.run() } catch { NSLog("campusnas-monitor: failed to run %@: %@", launchPath, "\(error)") if once.claim() { continuation.resume(returning: false) } return } DispatchQueue.global().asyncAfter(deadline: .now() + timeout) { guard process.isRunning else { return } process.terminate() if once.claim() { continuation.resume(returning: false) } } } } struct RunResult: Sendable { let ok: Bool let output: String let error: String } /// Like `commandOutput`, but also reports stderr and never discards output /// on a non-zero exit — needed to diagnose privileged steps. static func commandRun( _ launchPath: String, args: [String], timeout: TimeInterval ) async -> RunResult { guard FileManager.default.isExecutableFile(atPath: launchPath) else { return RunResult(ok: false, output: "", error: "not executable: \(launchPath)") } let process = Process() process.executableURL = URL(fileURLWithPath: launchPath) process.arguments = args let outPipe = Pipe() let errPipe = Pipe() process.standardOutput = outPipe process.standardError = errPipe let once = ResumeOnce() return await withCheckedContinuation { continuation in do { try process.run() } catch { if once.claim() { continuation.resume(returning: RunResult( ok: false, output: "", error: "\(error)")) } return } DispatchQueue.global().async { // Drain stderr on its own thread: both pipes filling at once // would deadlock a sequential read. var errorText = "" let errorQueue = DispatchQueue(label: "campusnas.stderr") let group = DispatchGroup() group.enter() errorQueue.async { let data = errPipe.fileHandleForReading.readDataToEndOfFile() errorText = String(data: data, encoding: .utf8) ?? "" group.leave() } let outData = outPipe.fileHandleForReading.readDataToEndOfFile() group.wait() process.waitUntilExit() guard once.claim() else { return } continuation.resume(returning: RunResult( ok: process.terminationStatus == 0, output: String(data: outData, encoding: .utf8) ?? "", error: errorText)) } DispatchQueue.global().asyncAfter(deadline: .now() + timeout) { guard process.isRunning else { return } process.terminate() if once.claim() { continuation.resume(returning: RunResult( ok: false, output: "", error: "timeout after \(timeout)s")) } } } } /// Runs a command and returns its stdout, or nil on failure/timeout. /// /// stdout is drained on a background queue rather than after exit: a process /// that fills the pipe buffer would otherwise block forever waiting for a /// reader that never comes. static func commandOutput( _ launchPath: String, args: [String], timeout: TimeInterval ) async -> String? { guard FileManager.default.isExecutableFile(atPath: launchPath) else { Log.write("not executable: \(launchPath)") return nil } let process = Process() process.executableURL = URL(fileURLWithPath: launchPath) process.arguments = args let pipe = Pipe() process.standardOutput = pipe process.standardError = FileHandle.nullDevice let once = ResumeOnce() return await withCheckedContinuation { continuation in do { try process.run() } catch { Log.write("failed to run \(launchPath): \(error)") if once.claim() { continuation.resume(returning: nil) } return } DispatchQueue.global().async { let data = pipe.fileHandleForReading.readDataToEndOfFile() process.waitUntilExit() guard once.claim() else { return } let ok = process.terminationStatus == 0 continuation.resume( returning: ok ? String(data: data, encoding: .utf8) : nil) } DispatchQueue.global().asyncAfter(deadline: .now() + timeout) { guard process.isRunning else { return } process.terminate() if once.claim() { continuation.resume(returning: nil) } } } } /// Opens a TCP connection and closes it immediately. Deliberately does not /// touch the autofs path: an `ls` there would trigger a mount on every poll /// and defeat the whole point of lazy mounting. static func tcpReachable( host: String, port: UInt16, timeout: TimeInterval ) async -> Bool { guard let nwPort = NWEndpoint.Port(rawValue: port) else { return false } let connection = NWConnection( host: NWEndpoint.Host(host), port: nwPort, using: .tcp) let once = ResumeOnce() return await withCheckedContinuation { continuation in @Sendable func finish(_ reachable: Bool) { guard once.claim() else { return } connection.cancel() continuation.resume(returning: reachable) } connection.stateUpdateHandler = { state in switch state { case .ready: finish(true) case .failed, .cancelled: finish(false) case .waiting: // Host unreachable / connection refused — no point waiting out // the full timeout for a retry that will not happen. finish(false) default: break } } connection.start(queue: .global()) DispatchQueue.global().asyncAfter(deadline: .now() + timeout) { finish(false) } } } }