diff --git a/Sources/tart/Commands/Run.swift b/Sources/tart/Commands/Run.swift index 744214b..f57c66f 100644 --- a/Sources/tart/Commands/Run.swift +++ b/Sources/tart/Commands/Run.swift @@ -108,6 +108,24 @@ struct Run: AsyncParsableCommand { } }() + // Lock the VM + // + // More specifically, lock the "config.json", because we can't lock + // directories with fcntl(2)-based locking and we better not interfere + // with the VM's disk and NVRAM, because they are opened (and even seem + // to be locked) directly by the Virtualization.Framework's process. + // + // Note that due to "completely stupid semantics"[1] of the fcntl-based + // file locking, we need to acquire the lock after we read the VM's + // configuration file, otherwise we will loose the lock. + // + // [1]: https://man.openbsd.org/fcntl + let lock = try PIDLock(lockURL: vmDir.configURL) + if try !lock.trylock() { + print("Virtual machine \"\(name)\" is already running!") + Foundation.exit(2) + } + let task = Task { do { if let vncImpl = vncImpl { @@ -129,11 +147,6 @@ struct Run: AsyncParsableCommand { Foundation.exit(0) } catch { - if error.localizedDescription.contains("Failed to lock auxiliary storage.") { - print("Virtual machine \"\(name)\" is already running!") - Foundation.exit(2) - } - print(error) Foundation.exit(1) } diff --git a/Sources/tart/Commands/Stop.swift b/Sources/tart/Commands/Stop.swift new file mode 100644 index 0000000..a93fca7 --- /dev/null +++ b/Sources/tart/Commands/Stop.swift @@ -0,0 +1,72 @@ +import ArgumentParser +import Foundation +import System +import SwiftDate + +struct Stop: AsyncParsableCommand { + static var configuration = CommandConfiguration(commandName: "stop", abstract: "Stop a VM") + + @Argument(help: "VM name") + var name: String + + @Option(name: [.short, .long], help: "Seconds to wait for graceful termination before forcefully terminating the VM") + var timeout: UInt64 = 30 + + func run() async throws { + do { + let vmDir = try VMStorageLocal().open(name) + let lock = try PIDLock(lockURL: vmDir.configURL) + + // Find the VM's PID + var pid = try lock.pid() + if pid == 0 { + print("VM \(name) is not running") + + Foundation.exit(1) + } + + // Try to gracefully terminate the VM + // + // Note that we don't check the return code here + // to provide a clean exit from "tart stop" in cases + // when the VM is already shutting down and we hit + // a race condition. + // + // We check the return code in the kill(2) below, though, + // because it's a less common scenario and it would be + // nice to know for the user that we've tried all methods + // and failed to shutdown the VM. + kill(pid, SIGINT) + + // Ensure that the VM has terminated + var gracefulWaitDuration = Measurement(value: Double(timeout), unit: UnitDuration.seconds) + let gracefulTickDuration = Measurement(value: Double(100), unit: UnitDuration.milliseconds) + + while gracefulWaitDuration.value > 0 { + pid = try lock.pid() + if pid == 0 { + Foundation.exit(0) + } + + try await Task.sleep(nanoseconds: UInt64(gracefulTickDuration.converted(to: .nanoseconds).value)) + gracefulWaitDuration = gracefulWaitDuration - gracefulTickDuration + } + + // Seems that VM is still running, proceed with forceful termination + let ret = kill(pid, SIGKILL) + if ret != 0 { + let details = Errno(rawValue: CInt(errno)) + + print("failed to forcefully terminate the VM \(name): \(details)") + + Foundation.exit(1) + } + + Foundation.exit(0) + } catch { + print(error) + + Foundation.exit(1) + } + } +} diff --git a/Sources/tart/PIDLock.swift b/Sources/tart/PIDLock.swift new file mode 100644 index 0000000..7800059 --- /dev/null +++ b/Sources/tart/PIDLock.swift @@ -0,0 +1,52 @@ +import Foundation +import System + +class PIDLock { + let url: URL + let fd: Int32 + + init(lockURL: URL) throws { + url = lockURL + fd = open(lockURL.path, O_RDWR) + } + + deinit { + close(fd) + } + + func trylock() throws -> Bool { + let (locked, _) = try lockWrapper(F_SETLK, F_WRLCK, "failed to lock \(url)") + return locked + } + + func lock() throws { + _ = try lockWrapper(F_SETLKW, F_WRLCK, "failed to lock \(url)") + } + + func unlock() throws { + _ = try lockWrapper(F_SETLK, F_UNLCK, "failed to unlock \(url)") + } + + func pid() throws -> pid_t { + let (_, result) = try lockWrapper(F_GETLK, F_RDLCK, "failed to get lock \(url) status") + + return result.l_pid + } + + func lockWrapper(_ operation: Int32, _ type: Int32, _ message: String) throws -> (Bool, flock) { + var result = flock(l_start: 0, l_len: 0, l_pid: 0, l_type: Int16(type), l_whence: Int16(SEEK_SET)) + + let ret = fcntl(fd, operation, &result) + if ret != 0 { + if operation == F_SETLK && errno == EAGAIN { + return (false, result) + } + + let details = Errno(rawValue: CInt(errno)) + + throw RuntimeError("\(message): \(details)") + } + + return (true, result) + } +} diff --git a/Sources/tart/Root.swift b/Sources/tart/Root.swift index 86845fd..8d5c372 100644 --- a/Sources/tart/Root.swift +++ b/Sources/tart/Root.swift @@ -28,6 +28,7 @@ struct Root: AsyncParsableCommand { Push.self, Prune.self, Rename.self, + Stop.self, Delete.self, ])