snapshots: manage Apple VM snapshots

Apple virtualization reported snapshots as unsupported. It has no notion of
them, but the pieces are there: the disks can be layered and the saved
state and firmware storage are ordinary files.

Add a backend that freezes a layer per drive and keeps copies of the saved
state, the EFI variables and the macOS auxiliary storage beside them. The
layers under a disk that is attached cannot be changed, so a running VM may
only delete; everything else waits until it stops.

A layer sits beside the image it belongs to and is named after it, and the saved
state and firmware of a snapshot are copies named the same way. A bundle only
keeps the files its configuration names, so saving the configuration would take
all of them with it. Work out what the snapshots still need instead — the layer
being written to, the layers of the snapshots in the bundle, and whatever lies
underneath those — and keep exactly that, so anything left over is still cleaned
up. When that cannot be worked out, because the manifest or a layer cannot be
read, everything named after the file is kept instead.

Assisted-by: Claude:claude-fable-5-1
Assisted-by: Claude:claude-opus-5
This commit is contained in:
osy
2026-09-22 10:57:30 -05:00
parent 0086417b97
commit f4a3c5ac7c
6 changed files with 346 additions and 1 deletions
@@ -384,6 +384,9 @@ private extension String {
existingDataURLs += try await _drives[i].saveData(to: dataURL)
}
// what the snapshots hold sits beside the files above and is named after them
existingDataURLs += UTMAppleSnapshotFiles.preservedDataURLs(for: existingDataURLs, in: dataURL.deletingLastPathComponent())
return existingDataURLs
}
}
+27
View File
@@ -337,6 +337,33 @@ extension UTMAppleDiskImage {
try removeUnusedLayers(of: imageURL)
}
/// Layers of an image that are still needed, given the snapshots that are still wanted.
///
/// A layer is needed when it is the one being written to, when it belongs to one of `names`,
/// or when it lies under a layer that is needed. Anything else is left over and may go.
/// - Parameters:
/// - imageURL: Location of the image
/// - names: Names of the snapshots to keep
static func preservedLayerURLs(of imageURL: URL, for names: Set<String>) throws -> [URL] {
let fileURLs = layerFileURLs(of: imageURL)
let layers = try layers(of: imageURL)
// a layer that could not be read, such as the one a running VM writes to, may be needed
// by any of the others and is kept along with all of them
guard layers.count == fileURLs.count else {
return fileURLs
}
var preserved = Set<URL>()
for top in layers where top.isCurrent || names.contains(top.name) {
guard let chain = try? chain(endingAt: top, in: layers) else {
return fileURLs
}
for layer in chain {
preserved.insert(layer.url)
}
}
return Array(preserved)
}
/// Delete the layers of deleted snapshots that no other layer needs anymore
private static func removeUnusedLayers(of imageURL: URL) throws {
var layers = try layers(of: imageURL)
+256
View File
@@ -0,0 +1,256 @@
//
// Copyright © 2026 Turing Software, LLC. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
extension UTMAppleVirtualMachine {
/// Images that take part in a snapshot
@MainActor var snapshotImageURLs: [URL] {
config.drives.compactMap { drive in
drive.isReadOnly || drive.isExternal ? nil : drive.imageURL
}
}
/// Small files the guest writes to that are not disks, such as the variables of the firmware
@MainActor var snapshotAuxiliaryURLs: [URL] {
[config.system.boot.efiVariableStorageURL, config.system.macPlatform?.auxiliaryStorageURL].compactMap { url in
url.flatMap { FileManager.default.fileExists(atPath: $0.path) ? $0 : nil }
}
}
}
/// Which files a bundle has to keep for the snapshots of an Apple VM.
///
/// A bundle only keeps the files its configuration names, and what a snapshot holds is named
/// after the file it belongs to rather than being named by the configuration.
enum UTMAppleSnapshotFiles {
/// Files beside `baseURLs` that the snapshots recorded in the bundle still need.
///
/// Everything else beside them is left over — from a snapshot that was deleted, or from a
/// tool that is no longer around — and is left out so that it is cleaned up with the rest.
/// - Parameters:
/// - baseURLs: Files the configuration keeps
/// - bundleURL: Bundle of the VM
static func preservedDataURLs(for baseURLs: [URL], in bundleURL: URL) -> [URL] {
// without the manifest nothing can be ruled out, and snapshots it does not list yet are
// adopted from these files the next time the snapshots are read
guard let manifest = (try? UTMSnapshotManifest.load(from: bundleURL)) ?? nil else {
return baseURLs.flatMap { namedAfter($0) }
}
var identifiers = Set(manifest.snapshots.map { $0.backendIdentifier })
if let suspendIdentifier = manifest.suspendIdentifier {
identifiers.insert(suspendIdentifier)
}
let fileManager = FileManager.default
var preserved = [URL]()
for baseURL in baseURLs {
// the saved state and the firmware of a snapshot are copies named after the original
for identifier in identifiers {
let url = baseURL.appendingPathExtension(identifier)
if fileManager.fileExists(atPath: url.path) {
preserved.append(url)
}
}
if #available(macOS 27, *), let layerURLs = try? UTMAppleDiskImage.preservedLayerURLs(of: baseURL, for: identifiers) {
preserved += layerURLs
} else {
// the layers cannot be read, so keep all of them rather than lose one
preserved += namedAfter(baseURL).filter { $0.pathExtension == "asif" }
}
}
return preserved
}
/// Files beside `baseURL` whose names start with its own, which is how snapshots name theirs
private static func namedAfter(_ baseURL: URL) -> [URL] {
let prefix = baseURL.lastPathComponent + "."
let siblings = (try? FileManager.default.contentsOfDirectory(at: baseURL.deletingLastPathComponent(), includingPropertiesForKeys: nil)) ?? []
return siblings.filter { $0.lastPathComponent.hasPrefix(prefix) }
}
}
/// Snapshots of a VM using Apple Virtualization.
///
/// A snapshot spans every drive. The disks are kept as a layer of each image (see
/// `UTMAppleDiskImage`), while the running state and the files in `auxiliaryURLs` are small
/// enough to be kept as copies named after the snapshot. Virtualization cannot change the layers of a
/// disk that is attached, so the current state can only be saved or replaced while the VM is
/// not running. Everything else works either way.
@available(macOS 27, *)
struct UTMAppleSnapshotBackend: UTMSnapshotBackend {
let imageURLs: [URL]
let isInventoryExact = true
/// Files that are not disks but still part of the state of the guest
let auxiliaryURLs: [URL]
/// File the VM is suspended to
let savedStateURL: URL?
let isRunning: Bool
/// Kept from starting while its files change
var vm: UTMAppleVirtualMachine? = nil
/// There is only one file to suspend to which is never a snapshot by itself
let defaultSuspendIdentifier = "suspend"
func supports(_ operation: UTMSnapshotOperation, hasState: Bool) -> Bool {
// the layers below a disk that is attached have to stay the same
!isRunning || operation == .delete
}
private var fileManager: FileManager {
FileManager.default
}
private func snapshotStateURL(for identifier: String) -> URL? {
savedStateURL?.appendingPathExtension(identifier)
}
/// Copy beside `url` for `replaceItemAt`, which consumes what it puts in place
private func copyToTemporary(_ url: URL) throws -> URL {
let temporaryURL = url.appendingPathExtension(UUID().uuidString)
try fileManager.copyItem(at: url, to: temporaryURL)
return temporaryURL
}
private func size(of url: URL) -> Int64 {
let values = try? url.resourceValues(forKeys: [.totalFileAllocatedSizeKey])
return Int64(values?.totalFileAllocatedSize ?? 0)
}
func entries() async throws -> [UTMSnapshotBackendEntry] {
var entries = [String: UTMSnapshotBackendEntry]()
var imageCounts = [String: Int]()
for imageURL in imageURLs {
let layers = try UTMAppleDiskImage.layers(of: imageURL)
for layer in layers where !layer.isCurrent && !layer.isUnlinked {
// skip the layers of deleted snapshots to find the one this was taken from
var parent = layers.first { $0.uuid == layer.parentUUID }
while let unlinked = parent, unlinked.isUnlinked {
parent = layers.first { $0.uuid == unlinked.parentUUID }
}
let date = (try? layer.url.resourceValues(forKeys: [.contentModificationDateKey]))?.contentModificationDate ?? Date()
let previous = entries[layer.name]
entries[layer.name] = UTMSnapshotBackendEntry(identifier: layer.name,
date: max(date, previous?.date ?? .distantPast),
size: size(of: layer.url) + (previous?.size ?? 0),
hasState: false,
parentIdentifier: previous?.parentIdentifier ?? parent?.name)
imageCounts[layer.name, default: 0] += 1
}
}
return entries.values.map { entry in
var entry = entry
entry.isComplete = imageCounts[entry.identifier] == imageURLs.count
guard let stateURL = snapshotStateURL(for: entry.identifier), fileManager.fileExists(atPath: stateURL.path) else {
return entry
}
return UTMSnapshotBackendEntry(identifier: entry.identifier,
date: entry.date,
size: entry.size + size(of: stateURL),
hasState: true,
isComplete: entry.isComplete,
parentIdentifier: entry.parentIdentifier)
}
}
func create(suspendIdentifier: String?) async throws -> UTMSnapshotBackendEntry {
let identifier = UUID().uuidString
try await changingFiles(as: .saving) {
do {
for imageURL in imageURLs {
try UTMAppleDiskImage.createSnapshot(of: imageURL, name: identifier)
}
for auxiliaryURL in auxiliaryURLs {
try fileManager.copyItem(at: auxiliaryURL, to: auxiliaryURL.appendingPathExtension(identifier))
}
if suspendIdentifier != nil, let savedStateURL = savedStateURL, let stateURL = snapshotStateURL(for: identifier) {
// the VM still resumes from its own copy
try fileManager.copyItem(at: savedStateURL, to: stateURL)
}
} catch {
try? await delete(identifier: identifier)
throw error
}
}
guard let entry = try await entries().first(where: { $0.identifier == identifier }) else {
throw UTMSnapshotError.notFound(identifier)
}
return entry
}
func restore(_ entry: UTMSnapshotBackendEntry) async throws -> Bool {
guard entry.isComplete else {
throw UTMSnapshotError.incomplete(entry.identifier)
}
try await changingFiles(as: .restoring) {
// every drive is restored or none is
for imageURL in imageURLs {
try UTMAppleDiskImage.validateSnapshot(of: imageURL, name: entry.identifier)
}
for imageURL in imageURLs {
try UTMAppleDiskImage.restoreSnapshot(of: imageURL, name: entry.identifier)
// what the VM was suspended to stops matching the drives once one is restored
if let savedStateURL = savedStateURL, fileManager.fileExists(atPath: savedStateURL.path) {
try? fileManager.removeItem(at: savedStateURL)
}
}
for auxiliaryURL in auxiliaryURLs {
let snapshotURL = auxiliaryURL.appendingPathExtension(entry.identifier)
if fileManager.fileExists(atPath: snapshotURL.path) {
// the original stays until the copy is in place so a failure cannot lose it
_ = try fileManager.replaceItemAt(auxiliaryURL, withItemAt: try copyToTemporary(snapshotURL))
}
}
if let savedStateURL = savedStateURL, entry.hasState, let stateURL = snapshotStateURL(for: entry.identifier) {
try fileManager.copyItem(at: stateURL, to: savedStateURL)
}
}
return savedStateURL != nil && entry.hasState
}
private func changingFiles(as state: UTMVirtualMachineState, _ body: () async throws -> Void) async throws {
if let vm = vm {
try await vm.changingFiles(as: state, body)
} else {
try await body()
}
}
func delete(identifier: String) async throws {
var isFound = false
for imageURL in imageURLs {
do {
try UTMAppleDiskImage.deleteSnapshot(of: imageURL, name: identifier)
isFound = true
} catch UTMAppleDiskImageError.snapshotNotFound(_) {
// a drive added after the snapshot was created
}
}
for auxiliaryURL in auxiliaryURLs {
try? fileManager.removeItem(at: auxiliaryURL.appendingPathExtension(identifier))
}
if let stateURL = snapshotStateURL(for: identifier) {
try? fileManager.removeItem(at: stateURL)
}
guard isFound else {
throw UTMSnapshotError.notFound(identifier)
}
}
}
+42 -1
View File
@@ -287,6 +287,8 @@ final class UTMAppleVirtualMachine: UTMVirtualMachine {
state = .stopped
if !isDisposible {
try? await deleteSnapshot()
// the layers of a snapshot below the disks may be gone
try? await UTMSnapshotService.identifyOrphanedSnapshots(on: self)
}
throw error
}
@@ -553,9 +555,48 @@ final class UTMAppleVirtualMachine: UTMVirtualMachine {
}
await registryEntry.setIsSuspended(false)
await registryEntry.setConnectedUsbDevices([])
try FileManager.default.removeItem(at: vmSavedStateURL)
// a state that is gone already is still let go of below
if FileManager.default.fileExists(atPath: vmSavedStateURL.path) {
try FileManager.default.removeItem(at: vmSavedStateURL)
}
if #available(macOS 27, *) {
try? await releaseSuspendSnapshot(savedStateURL: vmSavedStateURL)
}
try? updateLastModified()
}
/// Keep the VM from starting while the files of its snapshots change, which it would otherwise
/// attach halfway through
@available(macOS 27, *)
func changingFiles(as state: UTMVirtualMachineState, _ body: () async throws -> Void) async throws {
guard self.state == .stopped else {
throw UTMAppleVirtualMachineError.operationNotAvailable
}
self.state = state
defer {
self.state = .stopped
}
try await body()
}
/// The saved state may have been a copy of a snapshot that was only kept to resume from
@available(macOS 27, *)
private func releaseSuspendSnapshot(savedStateURL: URL) async throws {
let staleIdentifiers = try UTMSnapshotManifest.releaseSuspendState(in: pathUrl, default: nil)
guard !staleIdentifiers.isEmpty else {
return
}
let backend = UTMAppleSnapshotBackend(imageURLs: await snapshotImageURLs, auxiliaryURLs: await snapshotAuxiliaryURLs, savedStateURL: savedStateURL, isRunning: state != .stopped)
// one that cannot go now stays listed and goes the next time
for identifier in staleIdentifiers {
do {
try await backend.delete(identifier: identifier)
} catch UTMSnapshotError.notFound(_) {
// already gone
}
try UTMSnapshotManifest.forgetState(identifier, in: pathUrl)
}
}
#if arch(arm64)
@available(macOS 14, *)
+14
View File
@@ -43,6 +43,8 @@ enum UTMSnapshotService {
#if os(macOS)
if vm is UTMQemuVirtualMachine {
return true
} else if #available(macOS 27, *), vm is UTMAppleVirtualMachine {
return true
}
#endif
// qemu-img is needed for a VM that is not running and is only part of the macOS app
@@ -379,6 +381,18 @@ enum UTMSnapshotService {
default: throw UTMSnapshotError.invalidVmState
}
}
#if os(macOS)
if #available(macOS 27, *), let apple = vm as? UTMAppleVirtualMachine {
guard [.started, .paused, .stopped].contains(vm.state) else {
throw UTMSnapshotError.invalidVmState
}
return UTMAppleSnapshotBackend(imageURLs: apple.snapshotImageURLs,
auxiliaryURLs: apple.snapshotAuxiliaryURLs,
savedStateURL: apple.config.system.boot.vmSavedStateURL,
isRunning: vm.state != .stopped,
vm: apple)
}
#endif
throw UTMSnapshotError.notSupported
}
+4
View File
@@ -687,6 +687,7 @@
CE7A1C042EFA0A01001671E9 /* UTMAppleUSBManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE7A1C032EFA0A01001671E9 /* UTMAppleUSBManager.swift */; };
CE7A1C062EFA0A01001671E9 /* AccessoryAccess.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CE7A1C052EFA0A01001671E9 /* AccessoryAccess.framework */; settings = {ATTRIBUTES = (Weak, ); }; };
CE1D15AB2F6B100100D15C01 /* UTMAppleDiskImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE1D15AA2F6B100100D15C01 /* UTMAppleDiskImage.swift */; };
1C5910D866A20395225768B9 /* UTMAppleSnapshotBackend.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CBB52BFA0D55FDEC6497878 /* UTMAppleSnapshotBackend.swift */; };
CE1D15AD2F6B100100D15C01 /* DiskImageKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CE1D15AC2F6B100100D15C01 /* DiskImageKit.framework */; settings = {ATTRIBUTES = (Weak, ); }; };
CE6804852E4E5D84001671E9 /* UTMScriptingInputImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE6804842E4E5D84001671E9 /* UTMScriptingInputImpl.swift */; };
CE68E5442E3912E0006B3645 /* VMKeyboardShortcutsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE68E5422E3912E0006B3645 /* VMKeyboardShortcutsView.swift */; };
@@ -2060,6 +2061,7 @@
CE7A1C032EFA0A01001671E9 /* UTMAppleUSBManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMAppleUSBManager.swift; sourceTree = "<group>"; };
CE7A1C052EFA0A01001671E9 /* AccessoryAccess.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AccessoryAccess.framework; path = System/Library/Frameworks/AccessoryAccess.framework; sourceTree = SDKROOT; };
CE1D15AA2F6B100100D15C01 /* UTMAppleDiskImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMAppleDiskImage.swift; sourceTree = "<group>"; };
0CBB52BFA0D55FDEC6497878 /* UTMAppleSnapshotBackend.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMAppleSnapshotBackend.swift; sourceTree = "<group>"; };
CE1D15AC2F6B100100D15C01 /* DiskImageKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = DiskImageKit.framework; path = System/Library/Frameworks/DiskImageKit.framework; sourceTree = SDKROOT; };
CE6804842E4E5D84001671E9 /* UTMScriptingInputImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMScriptingInputImpl.swift; sourceTree = "<group>"; };
CE68E5422E3912E0006B3645 /* VMKeyboardShortcutsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMKeyboardShortcutsView.swift; sourceTree = "<group>"; };
@@ -3023,6 +3025,7 @@
CE7A1C012EFA0A01001671E9 /* UTMAppleUSBDevice.swift */,
CE7A1C032EFA0A01001671E9 /* UTMAppleUSBManager.swift */,
CE1D15AA2F6B100100D15C01 /* UTMAppleDiskImage.swift */,
0CBB52BFA0D55FDEC6497878 /* UTMAppleSnapshotBackend.swift */,
CE020BB524B14F8400B44AB6 /* UTMVirtualMachine.swift */,
CE928C2926ABE6690099F293 /* UTMAppleVirtualMachine.swift */,
4F9F544E438D4BE8A5CA9360 /* UTMAppleVmnetNetworkManager.swift */,
@@ -4207,6 +4210,7 @@
CE7A1C022EFA0A01001671E9 /* UTMAppleUSBDevice.swift in Sources */,
CE7A1C042EFA0A01001671E9 /* UTMAppleUSBManager.swift in Sources */,
CE1D15AB2F6B100100D15C01 /* UTMAppleDiskImage.swift in Sources */,
1C5910D866A20395225768B9 /* UTMAppleSnapshotBackend.swift in Sources */,
CEF0306F26A2AFDF00667B63 /* VMWizardOSView.swift in Sources */,
83034C0926AB630F006B4BAF /* UTMPendingVMView.swift in Sources */,
84909A8F27CACD5C005605F1 /* UTMPlaceholderVMView.swift in Sources */,