Skip to content

Commit ccf956b

Browse files
committed
vm(apple): implement snapshot save/restore for macOS 14
Resolves #5376
1 parent 5967988 commit ccf956b

File tree

5 files changed

+126
-6
lines changed

5 files changed

+126
-6
lines changed

Configuration/QEMUConstant.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -390,6 +390,7 @@ enum QEMUPackageFileName: String {
390390
case debugLog = "debug.log"
391391
case efiVariables = "efi_vars.fd"
392392
case tpmData = "tpmdata"
393+
case vmState = "vmstate"
393394
}
394395

395396
// MARK: Supported features

Configuration/UTMAppleConfigurationBoot.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ struct UTMAppleConfigurationBoot: Codable {
3939
var linuxCommandLine: String?
4040
var linuxInitialRamdiskURL: URL?
4141
var efiVariableStorageURL: URL?
42+
var vmSavedStateURL: URL?
4243
var hasUefiBoot: Bool = false
4344

4445
/// IPSW for installing macOS. Not saved.
@@ -78,6 +79,7 @@ struct UTMAppleConfigurationBoot: Codable {
7879
if let efiVariableStoragePath = try container.decodeIfPresent(String.self, forKey: .efiVariableStoragePath) {
7980
efiVariableStorageURL = dataURL.appendingPathComponent(efiVariableStoragePath)
8081
}
82+
vmSavedStateURL = dataURL.appendingPathComponent(QEMUPackageFileName.vmState.rawValue)
8183
}
8284

8385
init(for operatingSystem: OperatingSystem, linuxKernelURL: URL? = nil) throws {
@@ -189,6 +191,9 @@ extension UTMAppleConfigurationBoot {
189191
self.efiVariableStorageURL = efiVariableStorageURL
190192
urls.append(efiVariableStorageURL)
191193
}
194+
let vmSavedStateURL = dataURL.appendingPathComponent(QEMUPackageFileName.vmState.rawValue)
195+
self.vmSavedStateURL = vmSavedStateURL
196+
urls.append(vmSavedStateURL)
192197
return urls
193198
}
194199
}

Services/UTMAppleVirtualMachine.swift

Lines changed: 118 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,8 @@ final class UTMAppleVirtualMachine: UTMVirtualMachine {
100100
/// This variable MUST be synchronized by `vmQueue`
101101
private(set) var apple: VZVirtualMachine?
102102

103+
private var saveSnapshotError: Error?
104+
103105
private var installProgress: Progress?
104106

105107
private var progressObserver: NSKeyValueObservation?
@@ -138,7 +140,6 @@ final class UTMAppleVirtualMachine: UTMVirtualMachine {
138140
}
139141

140142
private func _start(options: UTMVirtualMachineStartOptions) async throws {
141-
try await createAppleVM()
142143
let boot = await config.system.boot
143144
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) -> Void in
144145
vmQueue.async {
@@ -173,8 +174,14 @@ final class UTMAppleVirtualMachine: UTMVirtualMachine {
173174
}
174175
state = .starting
175176
do {
177+
let isSuspended = await registryEntry.isSuspended
176178
try await beginAccessingResources()
177-
try await _start(options: options)
179+
try await createAppleVM()
180+
if isSuspended && !options.contains(.bootRecovery) {
181+
try await restoreSnapshot()
182+
} else {
183+
try await _start(options: options)
184+
}
178185
if #available(macOS 12, *) {
179186
Task { @MainActor in
180187
sharedDirectoriesChanged = config.sharedDirectoriesPublisher.sink { [weak self] newShares in
@@ -194,6 +201,7 @@ final class UTMAppleVirtualMachine: UTMVirtualMachine {
194201
} catch {
195202
await stopAccesingResources()
196203
state = .stopped
204+
try? await deleteSnapshot()
197205
throw error
198206
}
199207
}
@@ -328,16 +336,109 @@ final class UTMAppleVirtualMachine: UTMVirtualMachine {
328336
}
329337
}
330338

339+
#if arch(arm64)
340+
@available(macOS 14, *)
341+
private func _saveSnapshot(url: URL) async throws {
342+
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
343+
vmQueue.async {
344+
guard let apple = self.apple else {
345+
continuation.resume(throwing: UTMAppleVirtualMachineError.operationNotAvailable)
346+
return
347+
}
348+
apple.saveMachineStateTo(url: url) { error in
349+
if let error = error {
350+
continuation.resume(throwing: error)
351+
} else {
352+
continuation.resume()
353+
}
354+
}
355+
}
356+
}
357+
}
358+
#endif
359+
331360
func saveSnapshot(name: String? = nil) async throws {
332-
// FIXME: implement this
361+
guard #available(macOS 14, *) else {
362+
return
363+
}
364+
#if arch(arm64)
365+
guard let vmSavedStateURL = await config.system.boot.vmSavedStateURL else {
366+
return
367+
}
368+
if let saveSnapshotError = saveSnapshotError {
369+
throw saveSnapshotError
370+
}
371+
if state == .started {
372+
try await pause()
373+
}
374+
guard state == .paused else {
375+
return
376+
}
377+
state = .saving
378+
defer {
379+
state = .paused
380+
}
381+
try await _saveSnapshot(url: vmSavedStateURL)
382+
await registryEntry.setIsSuspended(true)
383+
#endif
333384
}
334385

335386
func deleteSnapshot(name: String? = nil) async throws {
336-
// FIXME: implement this
387+
guard let vmSavedStateURL = await config.system.boot.vmSavedStateURL else {
388+
return
389+
}
390+
await registryEntry.setIsSuspended(false)
391+
try FileManager.default.removeItem(at: vmSavedStateURL)
337392
}
338393

394+
#if arch(arm64)
395+
@available(macOS 14, *)
396+
private func _restoreSnapshot(url: URL) async throws {
397+
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
398+
vmQueue.async {
399+
guard let apple = self.apple else {
400+
continuation.resume(throwing: UTMAppleVirtualMachineError.operationNotAvailable)
401+
return
402+
}
403+
apple.restoreMachineStateFrom(url: url) { error in
404+
if let error = error {
405+
continuation.resume(throwing: error)
406+
} else {
407+
continuation.resume()
408+
}
409+
}
410+
}
411+
}
412+
}
413+
#endif
414+
339415
func restoreSnapshot(name: String? = nil) async throws {
340-
// FIXME: implement this
416+
guard #available(macOS 14, *) else {
417+
throw UTMAppleVirtualMachineError.operationNotAvailable
418+
}
419+
#if arch(arm64)
420+
guard let vmSavedStateURL = await config.system.boot.vmSavedStateURL else {
421+
throw UTMAppleVirtualMachineError.operationNotAvailable
422+
}
423+
if state == .started {
424+
try await stop(usingMethod: .force)
425+
}
426+
guard state == .stopped || state == .starting else {
427+
throw UTMAppleVirtualMachineError.operationNotAvailable
428+
}
429+
state = .restoring
430+
do {
431+
try await _restoreSnapshot(url: vmSavedStateURL)
432+
try await _resume()
433+
} catch {
434+
state = .stopped
435+
throw error
436+
}
437+
state = .started
438+
try await deleteSnapshot(name: name)
439+
#else
440+
throw UTMAppleVirtualMachineError.operationNotAvailable
441+
#endif
341442
}
342443

343444
private func _resume() async throws {
@@ -388,6 +489,17 @@ final class UTMAppleVirtualMachine: UTMVirtualMachine {
388489
vmQueue.async { [self] in
389490
apple = VZVirtualMachine(configuration: vzConfig, queue: vmQueue)
390491
apple!.delegate = self
492+
saveSnapshotError = nil
493+
#if arch(arm64)
494+
if #available(macOS 14, *) {
495+
do {
496+
try vzConfig.validateSaveRestoreSupport()
497+
} catch {
498+
// save this for later when we want to use snapshots
499+
saveSnapshotError = error
500+
}
501+
}
502+
#endif
391503
}
392504
}
393505

@@ -521,6 +633,7 @@ extension UTMAppleVirtualMachine: VZVirtualMachineDelegate {
521633
func guestDidStop(_ virtualMachine: VZVirtualMachine) {
522634
vmQueue.async { [self] in
523635
apple = nil
636+
saveSnapshotError = nil
524637
}
525638
sharedDirectoriesChanged = nil
526639
Task { @MainActor in

Services/UTMQemuVirtualMachine.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -465,13 +465,13 @@ extension UTMQemuVirtualMachine {
465465
}
466466

467467
private func _deleteSnapshot(name: String) async throws {
468+
await registryEntry.setIsSuspended(false)
468469
if let monitor = await monitor { // if QEMU is running
469470
let result = try await monitor.qemuDeleteSnapshot(name)
470471
if result.localizedCaseInsensitiveContains("Error") {
471472
throw UTMQemuVirtualMachineError.qemuError(result)
472473
}
473474
}
474-
await registryEntry.setIsSuspended(false)
475475
}
476476

477477
func deleteSnapshot(name: String? = nil) async throws {

Services/UTMVirtualMachine.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -498,6 +498,7 @@ extension UTMVirtualMachine {
498498
Task {
499499
do {
500500
try await resume()
501+
try? await deleteSnapshot(name: nil)
501502
} catch {
502503
delegate?.virtualMachine(self, didErrorWithMessage: error.localizedDescription)
503504
}

0 commit comments

Comments
 (0)