Support suspending and resuming Linux VMs

Persist a VZGenericMachineIdentifier in Linux VM configurations and
reuse it when building VZGenericPlatformConfiguration. Saved machine
state is tied to this identifier, so generating a new one prevents
restoration.

Legacy configurations remain loadable but cannot be suspended.
When cloning a stopped legacy Linux VM, initialize the missing identifier
in the clone. Existing identifiers and suspended state remain unchanged.

Linux now conforms to PlatformSuspendable and retains USB keyboard
support in suspendable mode, but omits
VZUSBScreenCoordinatePointingDeviceConfiguration because it passes
save/restore validation but causes restoration to fail.
This commit is contained in:
Randolph Voorhies 2026-07-28 13:35:48 -07:00
parent cbc160a592
commit 01d9359fbd
6 changed files with 141 additions and 4 deletions

View File

@ -66,10 +66,19 @@ struct Clone: AsyncParsableCommand {
let lock = try FileLock(lockURL: Config().tartHomeDir)
try lock.lock()
let sourceState = try sourceVM.state()
let sourceConfig = try VMConfig(fromURL: sourceVM.configURL)
let generateMAC = try localStorage.hasVMsWithMACAddress(macAddress: sourceVM.macAddress())
&& sourceVM.state() != .Suspended
&& sourceState != .Suspended
try sourceVM.clone(to: tmpVMDir, generateMAC: generateMAC)
if sourceState != .Suspended,
let linux = sourceConfig.platform as? Linux,
linux.machineIdentifier == nil {
try tmpVMDir.initializeLinuxMachineIdentifier()
}
try localStorage.move(newName, from: tmpVMDir)
try lock.unlock()

View File

@ -271,7 +271,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
@ -359,7 +361,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 {

View File

@ -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()
}
func pointingDevicesSuspendable() -> [VZPointingDeviceConfiguration] {
// VZUSBScreenCoordinatePointingDeviceConfiguration passes save/restore
// validation, but causes restoring a Linux VM to fail with "invalid argument".
[]
}
func keyboardsSuspendable() -> [VZKeyboardConfiguration] {
keyboards()
}
}

View File

@ -30,6 +30,9 @@ enum CodingKeys: String, CodingKey {
// macOS-specific keys
case ecid
case hardwareModel
// Linux-specific keys
case machineIdentifier
}
struct VMDisplayConfig: Codable, Equatable {

View File

@ -142,6 +142,23 @@ struct VMDirectory: Prunable {
try vmConfig.save(toURL: configURL)
}
func initializeLinuxMachineIdentifier() throws {
var vmConfig = try VMConfig(fromURL: configURL)
guard var vmLinux = vmConfig.platform as? Linux else {
throw RuntimeError.VMConfigurationError("cannot initialize a Linux machine identifier on a non-Linux VM")
}
guard vmLinux.machineIdentifier == nil else {
throw RuntimeError.VMConfigurationError(
"cannot initialize a Linux machine identifier when one already exists"
)
}
vmLinux.machineIdentifier = VZGenericMachineIdentifier()
vmConfig.platform = vmLinux
try vmConfig.save(toURL: configURL)
}
func resizeDisk(_ sizeGB: UInt16, format: DiskImageFormat = .raw) throws {
let diskExists = FileManager.default.fileExists(atPath: diskURL.path)

View File

@ -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")
}
}