235 lines
8.4 KiB
Swift
235 lines
8.4 KiB
Swift
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
|
|
}
|
|
}
|
|
|
|
/// `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
|
|
/// 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 }
|
|
stop(process)
|
|
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 }
|
|
stop(process)
|
|
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 }
|
|
stop(process)
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
}
|