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.
This commit is contained in:
Randolph Voorhies
2026-10-15 15:46:51 -07:00
committed by GitHub
parent e8e97444af
commit 3207aa6e47
7 changed files with 211 additions and 5 deletions
+6
View File
@@ -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()
+28 -4
View File
@@ -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()
}
+51 -1
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(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)
}
}
+3
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 {
+15
View File
@@ -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,
+52
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")
}
}
+56
View File
@@ -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()))
}
}