From 3207aa6e47b68f72ae7d1bbad7e08b76aca27f2d Mon Sep 17 00:00:00 2001 From: Randolph Voorhies Date: Thu, 15 Oct 2026 15:46:51 -0700 Subject: [PATCH] Support suspending and resuming Linux VMs (#1297) Restoring saved state requires the VZGenericMachineIdentifier that the VM was saved with, but Linux VMs were getting a new one every time they started (including when trying to restore from saved state). The identifier is now persisted in the VM config and reused on every start. Apple's SDK header for VZGenericPlatformConfiguration.machineIdentifier says: > Running two virtual machines concurrently with the same identifier > results in undefined behavior in the guest operating system. When > restoring a virtual machine from saved state, this `machineIdentifier` > must match the `machineIdentifier` of the saved virtual machine. So identifiers for Linux guests are now treated like MAC addresses: - Cloning a non-suspended VM generates a new id for the clone. - Suspended clones keep their id because their saved state requires it. - Running a VM whose id matches an already running VM generates a new id. - If the colliding VM being run was suspended, its saved state will be discarded. Configurations without an identifier will still load, but can't be suspended. Suspendable mode for Linux VMs omits the pointing device because VZUSBScreenCoordinatePointingDeviceConfiguration makes restores fail. --- Sources/tart/Commands/Clone.swift | 6 +++ Sources/tart/Commands/Run.swift | 32 +++++++++++++-- Sources/tart/Platform/Linux.swift | 52 +++++++++++++++++++++++- Sources/tart/VMConfig.swift | 3 ++ Sources/tart/VMDirectory.swift | 15 +++++++ Tests/TartTests/VMConfigTests.swift | 52 ++++++++++++++++++++++++ Tests/TartTests/VMDirectoryTests.swift | 56 ++++++++++++++++++++++++++ 7 files changed, 211 insertions(+), 5 deletions(-) create mode 100644 Tests/TartTests/VMDirectoryTests.swift diff --git a/Sources/tart/Commands/Clone.swift b/Sources/tart/Commands/Clone.swift index f58c364..f993c18 100644 --- a/Sources/tart/Commands/Clone.swift +++ b/Sources/tart/Commands/Clone.swift @@ -106,6 +106,8 @@ struct Clone: AsyncParsableCommand { try rejectExistingDestination(localStorage) let sourceState = try sourceVM.state() + let sourceConfig = try VMConfig(fromURL: sourceVM.configURL) + let generateMAC = try localStorage.hasVMsWithMACAddress(macAddress: sourceVM.macAddress()) && sourceState != .Suspended @@ -128,6 +130,10 @@ struct Clone: AsyncParsableCommand { try sourceVM.clone(to: tmpVMDir, generateMAC: generateMAC) } + if sourceState != .Suspended, sourceConfig.platform is Linux { + try tmpVMDir.regenerateLinuxMachineIdentifier() + } + try localStorage.move(newName, from: tmpVMDir) try lock.unlock() diff --git a/Sources/tart/Commands/Run.swift b/Sources/tart/Commands/Run.swift index 1c965e1..9435062 100644 --- a/Sources/tart/Commands/Run.swift +++ b/Sources/tart/Commands/Run.swift @@ -285,7 +285,9 @@ struct Run: AsyncParsableCommand { var rootDiskOpts: String = "" #if arch(arm64) - @Flag(help: ArgumentHelp("Disables audio and entropy devices and switches to only Mac-specific input devices.", discussion: "Useful for running a VM that can be suspended via \"tart suspend\".")) + @Flag( + help: ArgumentHelp("Disables or replaces devices that do not support VM suspension, such as audio, entropy and some input devices.", + discussion: "Useful for running a VM that can be suspended via \"tart suspend\".")) #endif var suspendable: Bool = false @@ -374,7 +376,11 @@ struct Run: AsyncParsableCommand { if suspendable { let config = try VMConfig.init(fromURL: vmDir.configURL) if !(config.platform is PlatformSuspendable) { - throw ValidationError("You can only suspend macOS VMs") + throw ValidationError("This platform is not suspendable") + } + + if let linux = config.platform as? Linux, linux.machineIdentifier == nil { + throw ValidationError("Linux VMs without a machine identifier cannot be suspended or resumed") } if noTrackpad { @@ -421,14 +427,15 @@ struct Run: AsyncParsableCommand { let localStorage = try VMStorageLocal() let vmDir = try localStorage.open(name) + let storageLock = try FileLock(lockURL: Config().tartHomeDir) + try storageLock.lock() + // Validate disk format support let vmConfig = try VMConfig(fromURL: vmDir.configURL) if !vmConfig.diskFormat.isSupported { throw ValidationError("Disk format '\(vmConfig.diskFormat.rawValue)' is not supported on this system.") } - let storageLock = try FileLock(lockURL: Config().tartHomeDir) - try storageLock.lock() // check if there is a running VM with the same MAC address let hasRunningMACCollision = try localStorage.list().contains { // check if there is a running VM with the same MAC but different name @@ -440,6 +447,23 @@ struct Run: AsyncParsableCommand { try vmDir.regenerateMACAddress() } + // check if there is a running VM with the same Linux machine identifier but different name + if let linuxMachineIdentifier = (vmConfig.platform as? Linux)?.machineIdentifier { + let hasRunningLinuxMachineIdentifierCollision = try localStorage.list().contains { + if try !$1.running() || $1.name == vmDir.name { + return false + } + let otherConfig = try VMConfig(fromURL: $1.configURL) + return (otherConfig.platform as? Linux)?.machineIdentifier == linuxMachineIdentifier + } + + if hasRunningLinuxMachineIdentifierCollision { + print("There is already a running VM with the same Linux machine identifier!") + print("Resetting VM to assign a new Linux machine identifier...") + try vmDir.regenerateLinuxMachineIdentifier() + } + } + if netSoftnet && isInteractiveSession() { try Softnet.configureSUIDBitIfNeeded() } diff --git a/Sources/tart/Platform/Linux.swift b/Sources/tart/Platform/Linux.swift index 10772c9..3f0b353 100644 --- a/Sources/tart/Platform/Linux.swift +++ b/Sources/tart/Platform/Linux.swift @@ -1,7 +1,42 @@ import Virtualization @available(macOS 13, *) -struct Linux: Platform { +struct Linux: PlatformSuspendable { + + var machineIdentifier: VZGenericMachineIdentifier? + + init(machineIdentifier: VZGenericMachineIdentifier = VZGenericMachineIdentifier()) { + self.machineIdentifier = machineIdentifier + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + guard let encodedMachineIdentifier = try container.decodeIfPresent( + String.self, forKey: .machineIdentifier + ) else { + self.machineIdentifier = nil + return + } + guard let data = Data.init(base64Encoded: encodedMachineIdentifier) else { + throw DecodingError.dataCorruptedError(forKey: .machineIdentifier, + in: container, + debugDescription: "failed to initialize Data using the provided value") + } + guard let machineIdentifier = VZGenericMachineIdentifier.init(dataRepresentation: data) else { + throw DecodingError.dataCorruptedError(forKey: .machineIdentifier, + in: container, + debugDescription: "failed to initialize VZGenericMachineIdentifier using the provided value") + } + self.machineIdentifier = machineIdentifier + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + try container.encodeIfPresent(machineIdentifier?.dataRepresentation.base64EncodedString(), forKey: .machineIdentifier) + } + func os() -> OS { .linux } @@ -16,6 +51,11 @@ struct Linux: Platform { func platform(nvramURL: URL, needsNestedVirtualization: Bool) throws -> VZPlatformConfiguration { let config = VZGenericPlatformConfiguration() + + if let machineIdentifier { + config.machineIdentifier = machineIdentifier + } + if #available(macOS 15, *) { config.isNestedVirtualizationEnabled = needsNestedVirtualization } @@ -47,4 +87,14 @@ struct Linux: Platform { // Linux doesn't support trackpad, so just return the regular pointing devices return pointingDevices(noUSB: noUSB) } + + func pointingDevicesSuspendable(noUSB: Bool) -> [VZPointingDeviceConfiguration] { + // VZUSBScreenCoordinatePointingDeviceConfiguration passes save/restore + // validation, but causes restoring a Linux VM to fail with "invalid argument". + [] + } + + func keyboardsSuspendable(noUSB: Bool) -> [VZKeyboardConfiguration] { + keyboards(noUSB: noUSB) + } } diff --git a/Sources/tart/VMConfig.swift b/Sources/tart/VMConfig.swift index 7fd58d9..e9630eb 100644 --- a/Sources/tart/VMConfig.swift +++ b/Sources/tart/VMConfig.swift @@ -30,6 +30,9 @@ enum CodingKeys: String, CodingKey { // macOS-specific keys case ecid case hardwareModel + + // Linux-specific keys + case machineIdentifier } struct VMDisplayConfig: Codable, Equatable { diff --git a/Sources/tart/VMDirectory.swift b/Sources/tart/VMDirectory.swift index 7f5ad83..92bb03d 100644 --- a/Sources/tart/VMDirectory.swift +++ b/Sources/tart/VMDirectory.swift @@ -230,6 +230,21 @@ struct VMDirectory: Prunable { try vmConfig.save(toURL: configURL) } + func regenerateLinuxMachineIdentifier() throws { + var vmConfig = try VMConfig(fromURL: configURL) + guard var vmLinux = vmConfig.platform as? Linux else { + throw RuntimeError.VMConfigurationError("cannot regenerate a Linux machine identifier on a non-Linux VM") + } + + vmLinux.machineIdentifier = VZGenericMachineIdentifier() + vmConfig.platform = vmLinux + + // cleanup state if any + try? FileManager.default.removeItem(at: stateURL) + + try vmConfig.save(toURL: configURL) + } + func resizeDisk( _ sizeGB: UInt16, format: DiskImageFormat = .raw, diff --git a/Tests/TartTests/VMConfigTests.swift b/Tests/TartTests/VMConfigTests.swift index 34fb901..5684670 100644 --- a/Tests/TartTests/VMConfigTests.swift +++ b/Tests/TartTests/VMConfigTests.swift @@ -1,6 +1,9 @@ import XCTest @testable import tart +import Foundation +import Virtualization + final class VMConfigTests: XCTestCase { func testVMDisplayConfig() throws { // Defaults units (points) @@ -15,4 +18,53 @@ final class VMConfigTests: XCTestCase { vmDisplayConfig = VMDisplayConfig.init(argument: "1234x5678px") XCTAssertEqual(VMDisplayConfig(width: 1234, height: 5678, unit: .pixel), vmDisplayConfig) } + + func testLinuxMachineIdentifierSerialization() throws { + let originalIdentifier = VZGenericMachineIdentifier() + let originalConfig = VMConfig( + platform: Linux(machineIdentifier: originalIdentifier), cpuCountMin: 2, memorySizeMin: 1024 * 1024 * 1024 + ) + let encodedConfigData = try originalConfig.toJSON() + + let decodedConfig = try VMConfig(fromJSON: encodedConfigData) + let decodedLinux = try XCTUnwrap(decodedConfig.platform as? Linux) + let decodedMachineIdentifier = try XCTUnwrap(decodedLinux.machineIdentifier) + + XCTAssertEqual( + decodedMachineIdentifier.dataRepresentation, + originalIdentifier.dataRepresentation, + "decoded machine identifier should match original identifier" + ) + + let platformConfiguration = try decodedLinux.platform( + nvramURL: URL(fileURLWithPath: "/dev/null"), + needsNestedVirtualization: false + ) + let decodedPlatformConfiguration = try XCTUnwrap( + platformConfiguration as? VZGenericPlatformConfiguration + ) + + XCTAssertEqual( + decodedPlatformConfiguration.machineIdentifier.dataRepresentation, + originalIdentifier.dataRepresentation, + "platform configuration should reuse decoded machine identifier" + ) + } + + func testLegacyLinuxConfigWithoutMachineIdentifier() throws { + let originalIdentifier = VZGenericMachineIdentifier() + let originalConfig = VMConfig( + platform: Linux(machineIdentifier: originalIdentifier), cpuCountMin: 2, memorySizeMin: 1024 * 1024 * 1024 + ) + let encodedConfigData = try originalConfig.toJSON() + + var configJSONObject = try XCTUnwrap(JSONSerialization.jsonObject(with: encodedConfigData) as? [String: Any]) + configJSONObject.removeValue(forKey: "machineIdentifier") + let legacyConfigData = try JSONSerialization.data(withJSONObject: configJSONObject) + + let decodedConfig = try VMConfig(fromJSON: legacyConfigData) + let decodedLinux = try XCTUnwrap(decodedConfig.platform as? Linux) + + XCTAssertNil(decodedLinux.machineIdentifier, "missing machineIdentifier should be decoded as nil") + } } diff --git a/Tests/TartTests/VMDirectoryTests.swift b/Tests/TartTests/VMDirectoryTests.swift new file mode 100644 index 0000000..3fbf62c --- /dev/null +++ b/Tests/TartTests/VMDirectoryTests.swift @@ -0,0 +1,56 @@ +import Foundation +import Virtualization +import XCTest +@testable import tart + +final class VMDirectoryTests: XCTestCase { + + func testRegenerateLinuxMachineIdentifierReplacesIdentifierAndDeletesState() throws { + let vmDir = try temporaryVMDirectory() + let originalIdentifier = VZGenericMachineIdentifier() + let originalConfig = try writeLinuxConfig(machineIdentifier: originalIdentifier, to: vmDir) + try touch(vmDir.stateURL) + + try vmDir.regenerateLinuxMachineIdentifier() + + let config = try VMConfig(fromURL: vmDir.configURL) + let linux = try XCTUnwrap(config.platform as? Linux) + let newIdentifier = try XCTUnwrap(linux.machineIdentifier) + XCTAssertNotEqual(newIdentifier.dataRepresentation, originalIdentifier.dataRepresentation) + XCTAssertFalse(FileManager.default.fileExists(atPath: vmDir.stateURL.path)) + XCTAssertEqual(config.macAddress.string, originalConfig.macAddress.string) + } + + func testRegenerateLinuxMachineIdentifierAssignsMissingIdentifier() throws { + let vmDir = try temporaryVMDirectory() + try writeLinuxConfig(machineIdentifier: nil, to: vmDir) + + try vmDir.regenerateLinuxMachineIdentifier() + + let linux = try XCTUnwrap(VMConfig(fromURL: vmDir.configURL).platform as? Linux) + XCTAssertNotNil(linux.machineIdentifier) + } + + @discardableResult + private func writeLinuxConfig(machineIdentifier: VZGenericMachineIdentifier?, to vmDir: VMDirectory) throws -> VMConfig { + var linux = Linux() + linux.machineIdentifier = machineIdentifier + let config = VMConfig(platform: linux, cpuCountMin: 2, memorySizeMin: 1024 * 1024 * 1024) + try config.save(toURL: vmDir.configURL) + return config + } + + private func temporaryVMDirectory() throws -> VMDirectory { + let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: false) + addTeardownBlock { + try? FileManager.default.removeItem(at: url) + } + + return VMDirectory(baseURL: url) + } + + private func touch(_ url: URL) throws { + XCTAssertTrue(FileManager.default.createFile(atPath: url.path, contents: Data())) + } +}