diff --git a/README.md b/README.md index ef2ef70c6..893d08c6d 100644 --- a/README.md +++ b/README.md @@ -656,6 +656,9 @@ local repository. Installing a newer Try Omarchy app therefore does not apply all of that app's factory-image changes to an existing VM, and an in-guest update should not be assumed to reproduce them. A confirmed reset is the deliberate, destructive way to start again from the newest bundled factory. +Reset deletes the saved VM; the next launch prepares its replacement. Launch +shows image preparation and verification progress when a new disk is needed, +then startup progress. ### Updating integrations in an existing VM diff --git a/docs/architecture.md b/docs/architecture.md index b85e942a7..02b4551c5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -254,6 +254,13 @@ creates the account on first boot. (Bopomofo), the fontconfig rule prefers Traditional Chinese Han variants for `zh-TW` text, and Chromium receives the Wayland IME flag needed for fcitx5. +When no VM exists, launch decompresses the compressed factory directly into its +unpublished writable disk, verifies its expanded size and digest, and publishes it +atomically. No expanded factory-image cache is retained. Development launches +through `make run` use the same single-workspace policy as normal app launches; +confirmed reset deletes the workspace, and the next launch selects the current +factory. Ephemeral mode also selects the current factory for testing. + Resources can set an optional maximum virtual disk capacity. On the next normal launch, an existing disk is sparsely extended under the workspace lock, after validating its metadata and boot kit. The native helper binds the change @@ -262,7 +269,7 @@ allocates blocks as guest writes arrive; the configured capacity does not reserve host space. New launcher settings default to 64 GiB, raised to the existing capacity when larger. The editor always displays a capacity; older settings without one display the existing or factory capacity. -New VMs use the selected capacity when their factory clone is prepared. +New VMs use the selected capacity when their writable disk is prepared. Nothing is overwritten while the app runs. The app bundle and packaged factory disk remain unchanged. Normal user launches use one private writable disk under @@ -275,12 +282,13 @@ launch the existing VM without decompressing, cloning, expanding, or charging free space for its new factory disk. The current bundled factory applies only when no persistent VM exists, after an -explicitly confirmed reset, or in ephemeral mode. New and reset VMs atomically -stage the current factory's boot kit with the new writable disk. A compatible +explicitly confirmed reset, or in ephemeral mode. Reset only deletes the VM; +launch atomically stages the current factory's boot kit with a new writable +disk when none exists. A compatible legacy identity-keyed disk can be migrated into the single workspace without discarding its contents. If several recognized legacy disks exist, normal -launch stops at the start menu; confirmed reset safely removes them before -publishing one fresh workspace. Unrecognized host files are always left +launch stops at the start menu; confirmed reset safely removes them. The next +launch publishes one fresh workspace. Unrecognized host files are always left untouched. Older schema-2 VMs predate saved boot kits. Their first preserving launch uses @@ -305,10 +313,9 @@ launcher receives that choice as `OMARCHY_QEMU_GPU_STATE_ROOT`. The chosen folder is used as-is: it is never restructured with a folder created inside it, so it must already be empty (or already be a workspace Omarchy has used) — a populated folder or a drive's top level is refused with an explanation -instead. The volume must -be APFS: the storage library clones the factory image with `cp -c` and expands -the working disk sparsely, and it serializes launches with a `lockf` advisory -lock. On exFAT the same expansion allocates the full working size immediately, +instead. The volume must be APFS: the storage library expands the working +disk sparsely and uses `cp -c` for uncompressed development sources. It +serializes launches with a `lockf` advisory lock. On exFAT the same expansion allocates the full working size immediately, and on a network share the lock is unreliable. Both layers check independently, the app when the folder is chosen and the shell library again at launch, because the volume can change in between. A location change never moves the existing VM; diff --git a/macos/README.md b/macos/README.md index e5cbb98c2..b8dd3e367 100644 --- a/macos/README.md +++ b/macos/README.md @@ -89,12 +89,14 @@ Runtime caches are private to `macos/.build/`; user-facing output always goes to `dist/`. The generated app lives inside `dist/app.noindex/`, which keeps a development build from appearing beside an installed copy in Command-Space. -Normal app launches maintain one stable user VM disk under -`~/Library/Application Support/Try Omarchy/VM/v1`. Storage integration tests -and specialized development runs can opt into identity-keyed parallel disks by -setting `OMARCHY_QEMU_GPU_DEVELOPMENT_MULTI_DISK=1`; release behavior leaves it -unset. Each persistent disk keeps the identity of the factory that created it -and is paired with a private, validated boot kit containing that factory's +Normal app launches and `make run` maintain one stable user VM disk under +`~/Library/Application Support/Try Omarchy/VM/v1`, reused across factory builds. +Use `make reset` to replace it with the current factory or `make run-ephemeral` +to test that factory without retaining its disk. Storage integration tests and +specialized direct-script runs can opt into identity-keyed parallel disks by +setting `OMARCHY_QEMU_GPU_DEVELOPMENT_MULTI_DISK=1`; the development app wrapper +explicitly disables this mode. Each persistent disk keeps the identity of the +factory that created it and is paired with a private, validated boot kit containing that factory's kernel, initramfs, and base command line. App updates reuse the disk and its boot kit; the current bundled factory is selected only for a new, reset, or ephemeral VM. This keeps an older root filesystem on its matching kernel-module @@ -114,7 +116,11 @@ the normal launch. Unsupported storage or boot ABIs, and ambiguous multiple legacy disks, still use the user-facing, confirmed Reset Omarchy flow. That destructive flow keeps **Reset** disabled until the user types `Try Omarchy` exactly in a native sheet. Cancelling or dismissing the sheet -returns control without invoking the storage reset. +returns control without invoking the storage reset. During reset, the disabled +button names the current phase: checking the VM, deleting it, then finishing +the reset. The next launch prepares a new disk if needed, with its button +showing checking, image preparation, verification, setup, and startup phases. +Phase changes come from the launcher process and are also announced to VoiceOver. The start menu can move that workspace to any APFS folder the user picks; the folder is used exactly as chosen, never with a folder created inside it — a @@ -125,10 +131,15 @@ variable still wins, so the development and test override keeps working unchanged. Reset composes its environment exactly as a launch does, so it always erases the workspace the user is actually running. -Reset reuses the verified, identity-keyed factory cache and APFS cloning. The -validated native helper streams SHA-256 through CryptoKit, including the full -expanded factory digest on a cache miss. Each storage transaction flushes its -written files before its staging directory, then flushes the parent after the +Reset deletes the VM without creating a replacement or requiring creation +headroom. On the next launch, a missing VM decompresses the bundled factory +directly into the staged writable disk; no expanded factory-image cache is +retained. The validated native helper +streams SHA-256 through CryptoKit, including the full expanded factory digest. +Existing VMs launch without decompression. Confirmed reset also removes safely +recognized disks left by older development launches; normal launch preserves +those disks and requests reset if there are several. Each storage transaction +flushes its written files before its staging directory, then flushes the parent after the atomic rename. This avoids repeated system-wide `sync` calls without dropping checksums, workspace locks, or interrupted-transaction recovery. A newly created workspace still uses one global sync to persist its marker and directory diff --git a/macos/Sources/OmarchyVMHelper/QEMUGPULauncher.swift b/macos/Sources/OmarchyVMHelper/QEMUGPULauncher.swift index 650139189..b19e6a2e9 100644 --- a/macos/Sources/OmarchyVMHelper/QEMUGPULauncher.swift +++ b/macos/Sources/OmarchyVMHelper/QEMUGPULauncher.swift @@ -772,6 +772,8 @@ enum CameraPreflight { final class QEMUGPUProcessSupervisor: @unchecked Sendable { enum LaunchEvent: Equatable { case virtualMachineReady(qmpSocketPath: String?) + case resetProgress(VMResetPhase) + case launchProgress(VMLaunchPhase) } struct StandardErrorDrain { @@ -792,6 +794,8 @@ final class QEMUGPUProcessSupervisor: @unchecked Sendable { private var errorPipe: Pipe? private var errorBuffer = "" private var didReportVirtualMachineStart = false + private var resetProgressStream: VMProgressStream? + private var launchProgressStream: VMProgressStream? func start( executableURL: URL, @@ -859,6 +863,9 @@ final class QEMUGPUProcessSupervisor: @unchecked Sendable { errorPipe = pipe errorBuffer = "" didReportVirtualMachineStart = false + let isReset = arguments.first == QEMUGPUStorageOption.resetStorageOnly.rawValue + resetProgressStream = isReset ? VMProgressStream(operation: .reset) : nil + launchProgressStream = isReset ? nil : VMProgressStream(operation: .launch) lock.unlock() do { @@ -998,7 +1005,8 @@ final class QEMUGPUProcessSupervisor: @unchecked Sendable { lock.lock() defer { lock.unlock() } errorBuffer += String(decoding: data, as: UTF8.self) - var launchEvents: [LaunchEvent] = [] + var launchEvents = (resetProgressStream?.append(data) ?? []).map(LaunchEvent.resetProgress) + launchEvents += (launchProgressStream?.append(data) ?? []).map(LaunchEvent.launchProgress) if !didReportVirtualMachineStart, let event = Self.virtualMachineReadyEvent(in: errorBuffer) { didReportVirtualMachineStart = true diff --git a/macos/Sources/OmarchyVMHelper/StartMenuWindow.swift b/macos/Sources/OmarchyVMHelper/StartMenuWindow.swift index 526c79c06..ed698644f 100644 --- a/macos/Sources/OmarchyVMHelper/StartMenuWindow.swift +++ b/macos/Sources/OmarchyVMHelper/StartMenuWindow.swift @@ -195,7 +195,11 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { private var microphoneRequestInFlight = false private var cameraRequestInFlight = false private var resetInProgress = false + private var resetPhase = VMResetPhase.checking + private weak var resetActionButton: OmarchyActionButton? private var launchInProgress = false + private var launchPhase = VMLaunchPhase.checking + private weak var launchActionButton: OmarchyActionButton? private var virtualMachineRunning = false private var closeRunningSettings: (() -> Void)? private var shutdownInProgress = false @@ -487,6 +491,36 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { window.orderOut(nil) } + func launchDidProgress(to phase: VMLaunchPhase) { + guard launchInProgress, launchPhase != phase else { return } + launchPhase = phase + launchActionButton?.updateTitle(phase.buttonTitle) + launchActionButton?.invalidateIntrinsicContentSize() + NSAccessibility.post( + element: window, + notification: .announcementRequested, + userInfo: [ + .announcement: phase.buttonTitle, + .priority: NSAccessibilityPriorityLevel.medium.rawValue, + ] + ) + } + + func resetDidProgress(to phase: VMResetPhase) { + guard resetInProgress, resetPhase != phase else { return } + resetPhase = phase + resetActionButton?.updateTitle(phase.buttonTitle) + resetActionButton?.invalidateIntrinsicContentSize() + NSAccessibility.post( + element: window, + notification: .announcementRequested, + userInfo: [ + .announcement: phase.buttonTitle, + .priority: NSAccessibilityPriorityLevel.medium.rawValue, + ] + ) + } + func resetDidFinish(errorMessage: String?) { guard resetInProgress else { return } resetInProgress = false @@ -499,11 +533,11 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { alert.informativeText = errorMessage } else { alert.alertStyle = .informational - alert.messageText = "Omarchy has been reset" + alert.messageText = "Try Omarchy has been reset" if let estimate = pendingResetSpaceEstimate { - alert.informativeText = "The VM is back to factory settings. Up to \(estimate) of disk space was reclaimed. You can launch whenever you’re ready." + alert.informativeText = "The VM has been deleted. Up to \(estimate) of disk space was reclaimed. A fresh VM will be prepared on the next launch." } else { - alert.informativeText = "The VM is back to factory settings. You can launch whenever you’re ready." + alert.informativeText = "The VM has been deleted. A fresh VM will be prepared on the next launch." } } pendingResetSpaceEstimate = nil @@ -522,7 +556,7 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { /// Clears the resetting state when the controller refused to start the /// reset at all. Deliberately silent, and deliberately not /// `resetDidFinish(errorMessage: nil)` — nothing was erased, so claiming - /// "Omarchy has been reset" would be a lie about a destructive action. + /// "Try Omarchy has been reset" would be a lie about a destructive action. func resetDidAbort() { guard resetInProgress else { return } resetInProgress = false @@ -854,11 +888,12 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { let integrationHeading = sectionHeading("INTEGRATIONS") let reset = OmarchyActionButton( - title: resetInProgress ? "Resetting Omarchy…" : "Reset Omarchy", + title: resetInProgress ? resetPhase.buttonTitle : "Reset Omarchy", style: .danger, target: self, action: #selector(resetOmarchy) ) + resetActionButton = reset reset.identifier = NSUserInterfaceItemIdentifier("reset-button") reset.isEnabled = canResetStorage && !prelaunchControlsLocked @@ -876,13 +911,14 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { manage.isEnabled = !controlsBusy let resetAction = virtualMachineRunning && canResetStorage ? manage : reset - let launchButtonTitle = virtualMachineRunning ? "Done" : (launchInProgress ? "Launching Omarchy…" : "Launch Omarchy") + let launchButtonTitle = virtualMachineRunning ? "Done" : (launchInProgress ? launchPhase.buttonTitle : "Launch Omarchy") let launchButton = OmarchyActionButton( title: launchButtonTitle, style: .primary, target: self, action: virtualMachineRunning ? #selector(closeSettings) : #selector(launchOmarchy) ) + launchActionButton = launchButton launchButton.keyEquivalent = launchInProgress ? "" : "\r" launchButton.isEnabled = virtualMachineRunning || (!launchInProgress && !resetInProgress) @@ -1034,6 +1070,9 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { ) ) scrollView.reflectScrolledClipView(scrollView.contentView) + if resetInProgress { + reset.scrollToVisible(reset.bounds) + } updatePermissionRequestControls() } @@ -1714,6 +1753,7 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { // controller, which owns the preference and can explain and offer // to switch, rather than asking the user to confirm erasing a // workspace we would only be guessing the identity of. + resetPhase = .checking resetInProgress = true render() resetStorage() @@ -1735,6 +1775,7 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { resetConfirmationPrompt = nil guard confirmed else { return } pendingResetSpaceEstimate = estimate + resetPhase = .checking resetInProgress = true render() resetStorage() @@ -1773,6 +1814,7 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { !resetInProgress, !microphoneRequestInFlight, !cameraRequestInFlight else { return } + launchPhase = .checking launchInProgress = true render() launch() diff --git a/macos/Sources/OmarchyVMHelper/StorageLocation.swift b/macos/Sources/OmarchyVMHelper/StorageLocation.swift index 3a340e74b..dd9031691 100644 --- a/macos/Sources/OmarchyVMHelper/StorageLocation.swift +++ b/macos/Sources/OmarchyVMHelper/StorageLocation.swift @@ -66,31 +66,23 @@ struct BundledGuestMetrics: Equatable { /// How much room the workspace needs on the chosen volume. /// -/// The factory source is a real multi-gigabyte file, written once per guest -/// build. The working disk is an APFS clone of it that is then expanded -/// sparsely, so it costs almost nothing at creation and grows only as the -/// guest writes. That is why the floor and the comfort target differ so much. +/// A new disk is decompressed directly into the workspace, then expanded +/// sparsely. Its initial bytes and boot headroom must fit; the selected virtual +/// capacity is a longer-term space target rather than an immediate allocation. struct StorageSpaceRequirement: Equatable { var sourceBytes: Int64 var workingBytes: Int64 - /// True when this volume already holds the materialized factory source for - /// the current guest build, which is therefore already paid for. - var sourceAlreadyPresent: Bool static let headroomBytes: Int64 = 1_073_741_824 - private var unpaidSourceBytes: Int64 { - sourceAlreadyPresent ? 0 : sourceBytes - } - /// Below this the workspace cannot even be created. var floorBytes: Int64 { - unpaidSourceBytes + Self.headroomBytes + sourceBytes + Self.headroomBytes } /// Below this the VM starts but the guest can run out of room later. var comfortBytes: Int64 { - unpaidSourceBytes + workingBytes + workingBytes + Self.headroomBytes } } @@ -321,12 +313,7 @@ enum StorageLocationPolicy { if let metrics, !hasRecordedPersistentDisk { let requirement = StorageSpaceRequirement( sourceBytes: metrics.sourceDiskBytes, - workingBytes: metrics.workingDiskBytes, - sourceAlreadyPresent: hasMaterializedSource( - stateRoot: root, - identity: metrics.identity, - fileManager: fileManager - ) + workingBytes: metrics.workingDiskBytes ) guard capabilities.availableBytes >= requirement.floorBytes else { throw StorageLocationPolicyError.insufficientSpace( @@ -348,19 +335,6 @@ enum StorageLocationPolicy { ) } - /// True when the factory source for this guest build is already written to - /// the workspace, so its bytes must not be demanded a second time. - static func hasMaterializedSource( - stateRoot: String, - identity: String, - fileManager: FileManager = .default - ) -> Bool { - let source = URL(fileURLWithPath: stateRoot, isDirectory: true) - .appendingPathComponent("images", isDirectory: true) - .appendingPathComponent("\(identity).ext4", isDirectory: false) - return fileManager.fileExists(atPath: source.path) - } - static func displayPath(_ path: String, homeDirectory: String) -> String { if path == homeDirectory { return "~" diff --git a/macos/Sources/OmarchyVMHelper/VMApplicationController.swift b/macos/Sources/OmarchyVMHelper/VMApplicationController.swift index 94a65c8ca..bac51d4cc 100644 --- a/macos/Sources/OmarchyVMHelper/VMApplicationController.swift +++ b/macos/Sources/OmarchyVMHelper/VMApplicationController.swift @@ -409,12 +409,12 @@ final class VMApplicationController: NSObject, NSApplicationDelegate { // Switching the setting is allowed; erasing on that same click is not, // so the reset is abandoned and they get an accurate confirmation the // next time they ask for one. - guard resolveStorageLocationAvailability() == .available else { + guard resolveStorageLocationAvailability(forReset: true) == .available else { startMenuWindow?.resetDidAbort() return } do { - let context = childLaunchContext() + let context = childLaunchContext(forReset: true) guard context.storageUnavailableReason == nil else { startMenuWindow?.resetDidFinish( errorMessage: context.storageUnavailableReason @@ -425,7 +425,12 @@ final class VMApplicationController: NSObject, NSApplicationDelegate { try supervisor.start( executableURL: launcherURL, arguments: resetArguments(), - environment: QEMUGPURuntimeEnvironment.sanitizedForReset(context.environment) + environment: QEMUGPURuntimeEnvironment.sanitizedForReset(context.environment), + launchEvent: { [weak self] event in + if case .resetProgress(let phase) = event { + self?.startMenuWindow?.resetDidProgress(to: phase) + } + } ) { [weak self] status in self?.resetDidExit(status: status) } @@ -538,7 +543,7 @@ final class VMApplicationController: NSObject, NSApplicationDelegate { /// context is shared with Reset, and a reset that only wipes the VM disk /// should never fail because an unrelated port mapping is unavailable. /// `launch()` validates the composed mappings itself, after calling this. - private func childLaunchContext() -> ChildLaunchContext { + private func childLaunchContext(forReset: Bool = false) -> ChildLaunchContext { let audio = AudioLaunchConfiguration.make( baseEnvironment: QEMUGPURuntimeEnvironment.sanitizedForLaunch(baseEnvironment), preferences: preferenceStore.load(), @@ -578,7 +583,7 @@ final class VMApplicationController: NSObject, NSApplicationDelegate { let storage = StorageLocationLaunchConfiguration.make( baseEnvironment: storageEnvironment, preference: storageLocationStore.load(), - metrics: bundledMetrics, + metrics: forReset ? nil : bundledMetrics, probe: volumeProbe, volumeRootDetector: volumeRootDetector ) @@ -618,6 +623,10 @@ final class VMApplicationController: NSObject, NSApplicationDelegate { switch event { case .virtualMachineReady(let qmpSocketPath): self?.virtualMachineDidStart(qmpSocketPath: qmpSocketPath) + case .launchProgress(let phase): + self?.startMenuWindow?.launchDidProgress(to: phase) + case .resetProgress: + break } } ) { [weak self] status in @@ -852,7 +861,7 @@ final class VMApplicationController: NSObject, NSApplicationDelegate { return (configured?.isEmpty == false) ? configured : nil } - private func resolveStorageLocationAvailability() -> StorageAvailability { + private func resolveStorageLocationAvailability(forReset: Bool = false) -> StorageAvailability { // An override wins over the preference on the way to the launcher, so // the preference's reachability says nothing about this run. The // launcher validates the override itself and fails loudly. @@ -862,7 +871,7 @@ final class VMApplicationController: NSObject, NSApplicationDelegate { do { _ = try StorageLocationPolicy.validate( container, - metrics: bundledMetrics, + metrics: forReset ? nil : bundledMetrics, probe: volumeProbe, volumeRootDetector: volumeRootDetector ) diff --git a/macos/Sources/OmarchyVMHelper/VMProgress.swift b/macos/Sources/OmarchyVMHelper/VMProgress.swift new file mode 100644 index 000000000..089f681a5 --- /dev/null +++ b/macos/Sources/OmarchyVMHelper/VMProgress.swift @@ -0,0 +1,83 @@ +import Foundation + +enum VMResetPhase: String, CaseIterable, Sendable { + case checking + case deleting + case finishing + + var buttonTitle: String { + switch self { + case .checking: "Checking VM…" + case .deleting: "Deleting VM…" + case .finishing: "Finishing reset…" + } + } +} + +enum VMLaunchPhase: String, CaseIterable, Sendable { + case checking + case preparing + case verifying + case finishing + case starting + + var buttonTitle: String { + switch self { + case .checking: "Checking VM…" + case .preparing: "Preparing image…" + case .verifying: "Verifying image…" + case .finishing: "Finishing setup…" + case .starting: "Starting Omarchy…" + } + } +} + +enum VMProgressOperation: String { + case reset = "Reset" + case launch = "Launch" +} + +/// Pipe reads can split a phase marker anywhere. Accept only complete, exact +/// launcher-owned lines, and discard oversized log lines without growing state. +struct VMProgressStream where Phase.RawValue == String { + private let prefix: String + private let knownPhases = Array(Phase.allCases) + + init(operation: VMProgressOperation) { + prefix = "[qemu-gpu] \(operation.rawValue) phase: " + } + + private var line = Data() + private var discardingLine = false + private(set) var phase: Phase? + + mutating func append(_ data: Data) -> [Phase] { + var phases: [Phase] = [] + for byte in data { + if byte == 10 { + if !discardingLine { + if line.last == 13 { line.removeLast() } + let text = String(decoding: line, as: UTF8.self) + let previousIndex = phase.flatMap { knownPhases.firstIndex(of: $0) } ?? -1 + if text.hasPrefix(prefix), + let next = Phase(rawValue: String(text.dropFirst(prefix.count))), + let nextIndex = knownPhases.firstIndex(of: next), + nextIndex > previousIndex { + phase = next + phases.append(next) + } + } + line.removeAll(keepingCapacity: true) + discardingLine = false + } else if !discardingLine { + if line.count < 256 { + line.append(byte) + } else { + line.removeAll(keepingCapacity: true) + discardingLine = true + } + } + } + return phases + } +} diff --git a/macos/Sources/OmarchyVMHelper/VolumeCapabilities.swift b/macos/Sources/OmarchyVMHelper/VolumeCapabilities.swift index aa9bbddff..bc28e15ff 100644 --- a/macos/Sources/OmarchyVMHelper/VolumeCapabilities.swift +++ b/macos/Sources/OmarchyVMHelper/VolumeCapabilities.swift @@ -85,7 +85,7 @@ struct URLVolumeProbe: VolumeProbing { /// Whether a path is the root directory of its own mounted volume — the one /// case a chosen storage folder is refused even when empty, since writing -/// `disks/`, `images/`, and `locks/` directly onto a drive's top level would +/// `boot/`, `disks/`, and `locks/` directly onto a drive's top level would /// mean chmod-ing (or otherwise restructuring) the volume's mount point /// itself rather than a folder the user chose to dedicate to Omarchy. protocol VolumeRootDetecting { diff --git a/macos/Tests/OmarchyVMHelperTests/StorageLocationTests.swift b/macos/Tests/OmarchyVMHelperTests/StorageLocationTests.swift index f35abe282..f3be20ca9 100644 --- a/macos/Tests/OmarchyVMHelperTests/StorageLocationTests.swift +++ b/macos/Tests/OmarchyVMHelperTests/StorageLocationTests.swift @@ -404,10 +404,6 @@ struct StorageLocationPolicyTests { identity: previousIdentity ) - #expect(!StorageLocationPolicy.hasMaterializedSource( - stateRoot: container.path, - identity: metrics.identity - )) let resolution = try StorageLocationPolicy.validate( container.path, metrics: metrics, @@ -507,8 +503,7 @@ struct StorageLocationPolicyTests { let requirement = StorageSpaceRequirement( sourceBytes: sourceBytes, - workingBytes: workingBytes, - sourceAlreadyPresent: false + workingBytes: workingBytes ) let available = (requirement.floorBytes + requirement.comfortBytes) / 2 let resolution = try StorageLocationPolicy.validate( @@ -519,8 +514,8 @@ struct StorageLocationPolicyTests { #expect(resolution.spaceWarning != nil) } - @Test("an already-materialized factory image is not charged for twice") - func materializedSourceLowersTheFloor() throws { + @Test("old factory caches do not bypass space needed for a new disk") + func legacyFactoryCacheDoesNotLowerTheFloor() throws { let container = try temporaryDirectory() defer { try? FileManager.default.removeItem(at: container) } @@ -534,20 +529,19 @@ struct StorageLocationPolicyTests { ) } - // The marker is always written before `images/` gains content — see - // `_qps_prepare_state_root` in qemu-persistent-storage.sh — so a - // realistic "already materialized" fixture carries both. + // Old versions may have left a cache, but new disks no longer use it. try writeValidRootMarker(in: container) let images = container.appendingPathComponent("images", isDirectory: true) try FileManager.default.createDirectory(at: images, withIntermediateDirectories: true) try Data().write(to: images.appendingPathComponent("\(metrics.identity).ext4")) - let resolution = try StorageLocationPolicy.validate( - container.path, - metrics: metrics, - probe: FakeVolumeProbe(result: volume(available: tight)) - ) - #expect(resolution.stateRoot == container.path) + #expect(throws: StorageLocationPolicyError.self) { + try StorageLocationPolicy.validate( + container.path, + metrics: metrics, + probe: FakeVolumeProbe(result: volume(available: tight)) + ) + } } @Test("skips the space check when the bundle metrics are unavailable") @@ -666,22 +660,10 @@ struct StorageSpaceRequirementTests { func floorCoversTheFactoryImage() { let requirement = StorageSpaceRequirement( sourceBytes: sourceBytes, - workingBytes: workingBytes, - sourceAlreadyPresent: false + workingBytes: workingBytes ) #expect(requirement.floorBytes == sourceBytes + StorageSpaceRequirement.headroomBytes) - #expect(requirement.comfortBytes == sourceBytes + workingBytes) - } - - @Test("a materialized image leaves only the headroom to find") - func materializedImageLeavesHeadroom() { - let requirement = StorageSpaceRequirement( - sourceBytes: sourceBytes, - workingBytes: workingBytes, - sourceAlreadyPresent: true - ) - #expect(requirement.floorBytes == StorageSpaceRequirement.headroomBytes) - #expect(requirement.comfortBytes == workingBytes) + #expect(requirement.comfortBytes == workingBytes + StorageSpaceRequirement.headroomBytes) } } diff --git a/macos/Tests/OmarchyVMHelperTests/VMProgressTests.swift b/macos/Tests/OmarchyVMHelperTests/VMProgressTests.swift new file mode 100644 index 000000000..3a8fde0c9 --- /dev/null +++ b/macos/Tests/OmarchyVMHelperTests/VMProgressTests.swift @@ -0,0 +1,69 @@ +import Foundation +import Testing +@testable import OmarchyVMHelper + +@Suite("VM operation progress") +struct VMProgressTests { + @Test("phase markers survive arbitrary pipe-read boundaries") + func fragmentedPhases() { + let output = "ordinary log\n[qemu-gpu] Launch phase: checking\n" + + "[qemu-gpu] Launch phase: preparing\r\n" + + "[qemu-gpu] Launch phase: verifying\n[qemu-gpu] Launch phase: finishing\n" + for boundary in 0...output.utf8.count { + let data = Data(output.utf8) + var stream = VMProgressStream(operation: .launch) + let phases = stream.append(data.prefix(boundary)) + stream.append(data.dropFirst(boundary)) + #expect(phases == [.checking, .preparing, .verifying, .finishing]) + } + } + + @Test("only complete exact markers change the button phase") + func rejectsLogsAndIncompleteMarkers() { + var stream = VMProgressStream(operation: .launch) + #expect(stream.append(Data("log [qemu-gpu] Launch phase: checking\n".utf8)).isEmpty) + #expect(stream.append(Data("[qemu-gpu] Launch phase: unknown\n".utf8)).isEmpty) + #expect(stream.append(Data("[qemu-gpu] Launch phase: preparing ".utf8)).isEmpty) + #expect(stream.append(Data("\n[qemu-gpu] Launch phase: preparing".utf8)).isEmpty) + #expect(stream.append(Data("\n".utf8)) == [.preparing]) + } + + @Test("duplicate and delayed earlier phases cannot move progress backwards") + func phasesAdvanceOnce() { + var stream = VMProgressStream(operation: .launch) + let output = "[qemu-gpu] Launch phase: verifying\n" + + "[qemu-gpu] Launch phase: verifying\n[qemu-gpu] Launch phase: checking\n" + + "[qemu-gpu] Launch phase: finishing\n" + #expect(stream.append(Data(output.utf8)) == [.verifying, .finishing]) + var nextLaunch = VMProgressStream(operation: .launch) + #expect(nextLaunch.append(Data("[qemu-gpu] Launch phase: checking\n".utf8)) == [.checking]) + } + + @Test("large log lines are discarded without accepting their trailing marker") + func oversizedLog() { + var stream = VMProgressStream(operation: .launch) + let output = String(repeating: "x", count: 100_000) + + "[qemu-gpu] Launch phase: checking\n[qemu-gpu] Launch phase: preparing\n" + #expect(stream.append(Data(output.utf8)) == [.preparing]) + } + + @Test("the button names the work currently happening") + func buttonFeedback() { + #expect(VMResetPhase.checking.buttonTitle == "Checking VM…") + #expect(VMResetPhase.deleting.buttonTitle == "Deleting VM…") + #expect(VMLaunchPhase.preparing.buttonTitle == "Preparing image…") + #expect(VMLaunchPhase.verifying.buttonTitle == "Verifying image…") + #expect(VMLaunchPhase.starting.buttonTitle == "Starting Omarchy…") + #expect(VMResetPhase.finishing.buttonTitle == "Finishing reset…") + } + + @Test("reset and launch accept only their own phases") + func operationIsolation() { + var reset = VMProgressStream(operation: .reset) + let output = "[qemu-gpu] Launch phase: preparing\n" + + "[qemu-gpu] Reset phase: preparing\n" + + "[qemu-gpu] Reset phase: deleting\n[qemu-gpu] Reset phase: finishing\n" + #expect(reset.append(Data(output.utf8)) == [.deleting, .finishing]) + var launch = VMProgressStream(operation: .launch) + #expect(launch.append(Data(output.utf8)) == [.preparing]) + } +} diff --git a/macos/Tests/qemu-memory-contract.test.sh b/macos/Tests/qemu-memory-contract.test.sh index 275b51da6..ba64efa7d 100755 --- a/macos/Tests/qemu-memory-contract.test.sh +++ b/macos/Tests/qemu-memory-contract.test.sh @@ -241,13 +241,14 @@ _qps_permissions() { /usr/bin/stat -f '%Lp' "$1"; } _qps_lstat_kind() { /usr/bin/stat -f '%HT' "$1"; } _qps_size() { /usr/bin/stat -f '%z' "$1"; } qemu_persistent_storage_release_lock() { :; } +qemu_persistent_storage_reset() { + printf 'clear\n' >>"$FAKE_STORAGE_LOG" + /bin/rm -f "$FAKE_PERSISTENT_ROOT/rootfs.ext4" + /bin/rm -rf "$FAKE_PERSISTENT_ROOT/boot" +} qemu_persistent_storage_grow_selected() { printf 'grow:%s\n' "$1" >>"$FAKE_STORAGE_LOG" } -qemu_persistent_storage_materialize_source() { - printf 'materialize\n' >>"$FAKE_STORAGE_LOG" - return 1 -} qemu_persistent_storage_select_existing() { printf 'select-existing\n' >>"$FAKE_STORAGE_LOG" QEMU_SELECTED_DISK="$FAKE_PERSISTENT_ROOT/rootfs.ext4" diff --git a/macos/Tests/qemu-persistent-storage.test.sh b/macos/Tests/qemu-persistent-storage.test.sh index 2c92385e7..10703efb4 100755 --- a/macos/Tests/qemu-persistent-storage.test.sh +++ b/macos/Tests/qemu-persistent-storage.test.sh @@ -18,6 +18,14 @@ grep -Fq '/bin/rm -rf -x "$qps_discarded"' \ exit 1 } +# Development app launches must share the stable single-disk policy, even if +# the shell environment previously enabled identity-keyed test workspaces. +grep -Fxq ' --env OMARCHY_QEMU_GPU_DEVELOPMENT_MULTI_DISK=0 \' \ + "$native_dir/open-qemu-gpu.sh" || { + printf 'qemu-persistent-storage.test: development app must use one workspace\n' >&2 + exit 1 + } + fail() { printf 'qemu-persistent-storage.test: %s\n' "$*" >&2 exit 1 @@ -268,8 +276,8 @@ assert_fails _qps_assert_boot_source_file \ export OMARCHY_QEMU_GPU_STATE_ROOT="$test_root/state" export OMARCHY_QEMU_GPU_DEVELOPMENT_MULTI_DISK=1 -# A compressed app payload is expanded once into the private immutable-image -# cache, verified against the raw manifest digest, and reused thereafter. +# Compressed factories are expanded straight into the selected writable disk. +# Reset re-expands and verifies the payload without retaining an image cache. compressed_disk="$test_root/source.ext4.zst" zstd_test="$test_root/zstd" cp "$source_disk" "$compressed_disk" @@ -281,15 +289,88 @@ set -euo pipefail EOF chmod 700 "$zstd_test" compressed_bytes=$(/usr/bin/stat -f '%z' "$compressed_disk") -qemu_persistent_storage_materialize_source \ - "$identity_compressed" "$compressed_disk" "$compressed_bytes" \ - "$source_sha" "$source_bytes" "$zstd_test" -materialized_source=$QEMU_IMMUTABLE_SOURCE_DISK -assert cmp -s "$materialized_source" "$source_disk" -qemu_persistent_storage_materialize_source \ - "$identity_compressed" "$compressed_disk" "$compressed_bytes" \ - "$source_sha" "$source_bytes" "$zstd_test" -assert_eq "$QEMU_IMMUTABLE_SOURCE_DISK" "$materialized_source" +compressed_state="$test_root/compressed-state" +( + export OMARCHY_QEMU_GPU_STATE_ROOT="$compressed_state" + export OMARCHY_QEMU_GPU_DEVELOPMENT_MULTI_DISK=0 + for mode in persistent reset reset; do + qemu_persistent_storage_select \ + "$mode" "$identity_compressed" "$compressed_disk" "$source_sha" "$source_bytes" '' \ + "$source_bytes" "$kernel_a" "$initramfs_a" "$kernel_command_line_a" "$zstd_test" \ + 2>"$test_root/reset-phases.log" + sed -n -e 's/^\[qemu-gpu\] Reset phase: /reset:/p' \ + -e 's/^\[qemu-gpu\] Launch phase: /launch:/p' \ + "$test_root/reset-phases.log" >"$test_root/actual-phases" + if [[ $mode == reset ]]; then + printf '%s\n' reset:deleting launch:preparing launch:verifying launch:finishing >"$test_root/expected-phases" + assert cmp -s "$test_root/actual-phases" "$test_root/expected-phases" + else + printf '%s\n' launch:preparing launch:verifying launch:finishing >"$test_root/expected-phases" + assert cmp -s "$test_root/actual-phases" "$test_root/expected-phases" + fi + assert cmp -s "$QEMU_SELECTED_DISK" "$source_disk" + assert test ! -e "$compressed_state/images" + assert_eq "$(find "$compressed_state/disks" -name rootfs.ext4 | wc -l | tr -d '[:space:]')" 1 + qemu_persistent_storage_release_lock + done + # A bad expanded digest must never publish a disk, even when reset is requested. + assert_fails qemu_persistent_storage_select \ + reset "$identity_compressed" "$compressed_disk" "$source_sha_b" "$source_bytes" '' \ + "$source_bytes" "$kernel_a" "$initramfs_a" "$kernel_command_line_a" "$zstd_test" + assert test ! -e "$compressed_state/disks/current" + qemu_persistent_storage_release_lock + ephemeral_compressed="$test_root/ephemeral-compressed" + mkdir -m 700 "$ephemeral_compressed" + qemu_persistent_storage_select \ + ephemeral "$identity_compressed" "$compressed_disk" "$source_sha" "$source_bytes" \ + "$ephemeral_compressed" "$source_bytes" '' '' '' "$zstd_test" + assert cmp -s "$QEMU_SELECTED_DISK" "$source_disk" +) + +# UI reset clears the VM without a factory source or free-space headroom, +# releases the lock, and leaves creation to the next normal launch. +( + export OMARCHY_QEMU_GPU_STATE_ROOT="$test_root/clear-only-state" + export OMARCHY_QEMU_GPU_DEVELOPMENT_MULTI_DISK=0 + qemu_persistent_storage_select \ + persistent "$identity_a" "$source_disk" "$source_sha" "$source_bytes" '' \ + "$source_bytes" "$kernel_a" "$initramfs_a" "$kernel_command_line_a" + assert_fails /bin/bash -c \ + 'source "$1"; qemu_persistent_storage_reset "$2"' \ + qps-reset-lock-test "$native_dir/qemu-persistent-storage.sh" "$identity_a" 9>&- + assert test -f "$QEMU_SELECTED_DISK" + qemu_persistent_storage_release_lock + export OMARCHY_QEMU_GPU_DEVELOPMENT_MULTI_DISK=1 + qemu_persistent_storage_select \ + persistent "$identity_b" "$source_disk_b" "$source_sha_b" "$source_bytes_b" '' \ + "$source_bytes_b" "$kernel_b" "$initramfs_b" "$kernel_command_line_b" + qemu_persistent_storage_release_lock + export OMARCHY_QEMU_GPU_DEVELOPMENT_MULTI_DISK=0 + printf 'keep me\n' >"$OMARCHY_QEMU_GPU_STATE_ROOT/notes.txt" + export OMARCHY_QEMU_GPU_TEST_FREE_BYTES=0 + qemu_persistent_storage_reset "$identity_b" 2>"$test_root/clear-phases.log" + assert test ! -e "$OMARCHY_QEMU_GPU_STATE_ROOT/disks/current" + assert test ! -e "$OMARCHY_QEMU_GPU_STATE_ROOT/disks/$identity_b" + assert test ! -e "$OMARCHY_QEMU_GPU_STATE_ROOT/boot/$identity_a" + assert test ! -e "$OMARCHY_QEMU_GPU_STATE_ROOT/boot/$identity_b" + assert test -f "$OMARCHY_QEMU_GPU_STATE_ROOT/notes.txt" + assert_fails _qps_lock_fd_is_open + sed -n 's/^\[qemu-gpu\] Reset phase: //p' "$test_root/clear-phases.log" >"$test_root/actual-phases" + printf '%s\n' deleting finishing >"$test_root/expected-phases" + assert cmp -s "$test_root/actual-phases" "$test_root/expected-phases" + assert_status "$QEMU_PERSISTENT_STORAGE_MISSING_STATUS" \ + qemu_persistent_storage_select_existing "$identity_b" "$kernel_b" "$initramfs_b" "$kernel_command_line_b" + unset OMARCHY_QEMU_GPU_TEST_FREE_BYTES + qemu_persistent_storage_select \ + persistent "$identity_b" "$compressed_disk" "$source_sha" "$source_bytes" '' \ + "$source_bytes" "$kernel_b" "$initramfs_b" "$kernel_command_line_b" "$zstd_test" \ + 2>"$test_root/launch-phases.log" + assert cmp -s "$QEMU_SELECTED_DISK" "$source_disk" + qemu_persistent_storage_release_lock + sed -n 's/^\[qemu-gpu\] Launch phase: //p' "$test_root/launch-phases.log" >"$test_root/actual-phases" + printf '%s\n' preparing verifying finishing >"$test_root/expected-phases" + assert cmp -s "$test_root/actual-phases" "$test_root/expected-phases" +) # The factory workspace grows sparsely while its immutable source stays at the # transport size. Relaunch validates and reuses the expanded workspace. @@ -849,9 +930,9 @@ export OMARCHY_QEMU_GPU_STATE_ROOT=$unsupported_root export OMARCHY_QEMU_GPU_TEST_FS_TYPE=exfat assert_fails qemu_persistent_storage_select \ persistent "$identity_a" "$source_disk" "$source_sha" "$source_bytes" '' -assert_fails qemu_persistent_storage_materialize_source \ - "$identity_compressed" "$compressed_disk" "$compressed_bytes" \ - "$source_sha" "$source_bytes" "$zstd_test" +assert_fails qemu_persistent_storage_select \ + persistent "$identity_compressed" "$compressed_disk" "$source_sha" "$source_bytes" '' \ + "$source_bytes" '' '' '' "$zstd_test" unset OMARCHY_QEMU_GPU_TEST_FS_TYPE assert test ! -e "$unsupported_root/disks" assert test ! -e "$unsupported_root/images" @@ -862,14 +943,14 @@ export OMARCHY_QEMU_GPU_STATE_ROOT=$saved_state_root cramped_root="$test_root/cramped-state" export OMARCHY_QEMU_GPU_STATE_ROOT=$cramped_root export OMARCHY_QEMU_GPU_TEST_FREE_BYTES=1024 -assert_fails qemu_persistent_storage_materialize_source \ - "$identity_compressed" "$compressed_disk" "$compressed_bytes" \ - "$source_sha" "$source_bytes" "$zstd_test" +assert_fails qemu_persistent_storage_select \ + persistent "$identity_compressed" "$compressed_disk" "$source_sha" "$source_bytes" '' \ + "$source_bytes" '' '' '' "$zstd_test" assert_fails qemu_persistent_storage_select \ persistent "$identity_a" "$source_disk" "$source_sha" "$source_bytes" '' unset OMARCHY_QEMU_GPU_TEST_FREE_BYTES assert test ! -e "$cramped_root/disks/current" -assert_eq "$(find "$cramped_root/images" -type f | wc -l | tr -d '[:space:]')" 0 +assert test ! -e "$cramped_root/images" export OMARCHY_QEMU_GPU_STATE_ROOT=$saved_state_root # The same volume succeeds once the room is there, proving the guard is what diff --git a/macos/Tests/run-qemu-ssh-contract.test.sh b/macos/Tests/run-qemu-ssh-contract.test.sh index 1fa0c2b13..3c3e3f7e8 100755 --- a/macos/Tests/run-qemu-ssh-contract.test.sh +++ b/macos/Tests/run-qemu-ssh-contract.test.sh @@ -311,9 +311,10 @@ _qps_permissions() { /usr/bin/stat -f '%Lp' "$1"; } _qps_lstat_kind() { /usr/bin/stat -f '%HT' "$1"; } _qps_size() { /usr/bin/stat -f '%z' "$1"; } qemu_persistent_storage_release_lock() { :; } -qemu_persistent_storage_materialize_source() { - printf 'materialize\n' >>"$FAKE_STORAGE_LOG" - return 1 +qemu_persistent_storage_reset() { + printf 'clear\n' >>"$FAKE_STORAGE_LOG" + /bin/rm -f "$FAKE_PERSISTENT_ROOT/rootfs.ext4" + /bin/rm -rf "$FAKE_PERSISTENT_ROOT/boot" } qemu_persistent_storage_select_existing() { printf 'select-existing\n' >>"$FAKE_STORAGE_LOG" @@ -1117,12 +1118,11 @@ assert_contains "$(<"$test_root/malformed/stderr")" 'canonical decimal' run_scenario reset-only 0 --reset-storage-only \ OMARCHY_QEMU_GPU_PORT_FORWARDS=tcp:2225:22 \ FAKE_HOST_KEYBOARD_FAIL=1 -assert_contains "$(<"$test_root/reset-only/storage.log")" 'select reset' +assert_contains "$(<"$test_root/reset-only/storage.log")" 'clear' [[ ! -e $test_root/reset-only/qemu.log ]] || fail 'reset-only launch started QEMU' +[[ ! -e $persistent_root/rootfs.ext4 ]] || fail 'reset-only kept or created a disk' assert_not_contains "$(<"$test_root/reset-only/stderr")" tryomarchy.ssh_access -assert_contains "$(<"$persistent_root/boot/kernel")" new-kernel -assert_contains "$(<"$persistent_root/boot/initramfs")" new-initramfs -assert_contains "$(<"$persistent_root/boot/command-line")" loglevel=5 +[[ ! -e $persistent_root/boot ]] || fail 'reset-only kept or created a boot kit' # Dry runs must construct a bridge without requesting administrator access. run_scenario bridge-preview 0 --ephemeral OMARCHY_QEMU_GPU_DRY_RUN=1 \ diff --git a/macos/open-qemu-gpu.sh b/macos/open-qemu-gpu.sh index 41193c3cc..2aea3a710 100755 --- a/macos/open-qemu-gpu.sh +++ b/macos/open-qemu-gpu.sh @@ -88,7 +88,7 @@ fi exec /usr/bin/open \ -n \ -W \ - --env OMARCHY_QEMU_GPU_DEVELOPMENT_MULTI_DISK=1 \ + --env OMARCHY_QEMU_GPU_DEVELOPMENT_MULTI_DISK=0 \ ${usb_environment[@]+"${usb_environment[@]}"} \ --stdin /dev/null \ --stdout /dev/null \ diff --git a/macos/qemu-persistent-storage.sh b/macos/qemu-persistent-storage.sh index 1451d1d15..6cb6c1707 100755 --- a/macos/qemu-persistent-storage.sh +++ b/macos/qemu-persistent-storage.sh @@ -27,7 +27,6 @@ QEMU_PERSISTENT_STORAGE_DIRECTORY='' QEMU_PERSISTENT_STORAGE_IDENTITY='' QEMU_PERSISTENT_STORAGE_LOCK_PATH='' QEMU_PERSISTENT_STORAGE_WORKING_BYTES='' -QEMU_IMMUTABLE_SOURCE_DISK='' # Set by the launcher only after validating the bundled native helper. Direct # library callers retain the system-tool fallback; never inherit this path. QEMU_PERSISTENT_STORAGE_HELPER='' @@ -54,6 +53,18 @@ _qps_error() { printf 'qemu-persistent-storage: %s\n' "$*" >&2 } +# Bash locals in the reset selector scope this reporting to an actual reset; +# ordinary launches and direct helper calls never emit reset progress. +_qps_reset_progress() { + [[ ${qps_reset_in_progress:-0} == 1 ]] || return 0 + printf '[qemu-gpu] Reset phase: %s\n' "$1" >&2 +} + +_qps_launch_progress() { + [[ ${qps_launch_in_progress:-0} == 1 ]] || return 0 + printf '[qemu-gpu] Launch phase: %s\n' "$1" >&2 +} + _qps_fail() { _qps_error "$*" return 1 @@ -205,12 +216,11 @@ _qps_volume_filesystem() { ' } -# The working disk is an APFS clone of the factory image that is then expanded -# sparsely to its full size. Sparse files are the part that cannot be given up: -# on exFAT a `truncate` to the working size allocates every byte immediately, so -# a 24 GiB disk would cost 24 GiB the moment it is created. Cloning matters less -# — a full copy works, it just costs another 6 GiB and takes far longer. -# Refuse anything but APFS, naming what was actually found. +# The factory is decompressed (or cloned from a raw development source), then +# expanded sparsely to the working capacity. Sparse files cannot be given up: +# on exFAT a `truncate` allocates every byte immediately, so a 64 GiB disk would +# cost 64 GiB the moment it is created. Refuse anything but APFS, naming what +# was actually found. _qps_assert_volume_supported() { local qps_root=$1 local qps_filesystem='' @@ -344,7 +354,7 @@ _qps_prepare_state_root() { fi _qps_validate_root_marker "$qps_marker" || return 1 - for qps_child in boot disks images locks; do + for qps_child in boot disks locks; do if [[ ! -e $qps_root/$qps_child && ! -L $qps_root/$qps_child ]]; then if mkdir "$qps_root/$qps_child" 2>/dev/null; then chmod 700 "$qps_root/$qps_child" || return 1 @@ -367,7 +377,6 @@ _qps_prepare_state_root() { QEMU_PERSISTENT_STORAGE_ROOT=$qps_root QEMU_PERSISTENT_STORAGE_BOOT_ROOT="$qps_root/boot" QEMU_PERSISTENT_STORAGE_DISKS_ROOT="$qps_root/disks" - QEMU_PERSISTENT_STORAGE_IMAGES_ROOT="$qps_root/images" QEMU_PERSISTENT_STORAGE_LOCKS_ROOT="$qps_root/locks" } @@ -1119,149 +1128,54 @@ _qps_expand_disk() { _qps_error "expanded the sparse working disk to $((qps_working_bytes / 1024 / 1024)) MiB" } -_qps_validate_immutable_source() { +_qps_validate_expanded_disk() { local qps_source=$1 local qps_expected_bytes=$2 local qps_magic='' - _qps_assert_private_regular_file "$qps_source" 'materialized immutable root disk' || return 1 + _qps_assert_private_regular_file "$qps_source" 'expanded factory disk' || return 1 [[ $(_qps_size "$qps_source") == "$qps_expected_bytes" ]] || { - _qps_fail 'materialized immutable root disk has the wrong size' + _qps_fail 'expanded factory disk has the wrong size' return 1 } qps_magic=$(/usr/bin/od -An -tx1 -j 1080 -N 2 "$qps_source" | tr -d '[:space:]') || { - _qps_fail 'cannot inspect the materialized ext4 superblock' + _qps_fail 'cannot inspect the expanded ext4 superblock' return 1 } [[ $qps_magic == 53ef ]] || { - _qps_fail 'materialized immutable root disk has no ext4 superblock' + _qps_fail 'expanded factory disk has no ext4 superblock' return 1 } } -# Expand a signed, manifest-verified Zstandard artifact into an identity-keyed -# immutable APFS source exactly once. The persistent workspace is then cloned -# from this source, so the 6 GiB base blocks are not physically duplicated. -qemu_persistent_storage_materialize_source() { - local qps_identity=${1:-} - local qps_compressed=${2:-} - local qps_compressed_bytes=${3:-} - local qps_source_sha=${4:-} - local qps_source_bytes=${5:-} - local qps_zstd=${6:-} - local qps_final='' - local qps_staging='' - local qps_actual_sha='' - local qps_lock_path='' - - QEMU_IMMUTABLE_SOURCE_DISK='' - _qps_is_identity "$qps_identity" || { - _qps_fail 'bundle identity must be exactly 64 lowercase hexadecimal characters' - return 1 - } - _qps_is_identity "$qps_source_sha" || { - _qps_fail 'source rootfs digest must be exactly 64 lowercase hexadecimal characters' - return 1 - } - _qps_is_positive_integer "$qps_compressed_bytes" || return 1 - _qps_is_positive_integer "$qps_source_bytes" || return 1 - [[ -f $qps_compressed && ! -L $qps_compressed ]] || { - _qps_fail 'compressed root disk is missing or unsafe' - return 1 - } - [[ $(_qps_size "$qps_compressed") == "$qps_compressed_bytes" ]] || { - _qps_fail 'compressed root disk has the wrong size' - return 1 - } - [[ -f $qps_zstd && ! -L $qps_zstd && -x $qps_zstd ]] || { - _qps_fail 'bundled Zstandard decoder is missing or unsafe' - return 1 - } - - _qps_prepare_state_root || return 1 - qps_final="$QEMU_PERSISTENT_STORAGE_IMAGES_ROOT/$qps_identity.ext4" - qps_lock_path="$QEMU_PERSISTENT_STORAGE_LOCKS_ROOT/$qps_identity.image.lock" - exec 8>>"$qps_lock_path" || return 1 - chmod 600 "$qps_lock_path" || { exec 8>&-; return 1; } - _qps_assert_private_regular_file "$qps_lock_path" 'base-image lock' || { - exec 8>&- - return 1 - } - if ! /usr/bin/lockf -s 8; then - exec 8>&- - _qps_fail 'cannot lock base-image materialization' - return 1 - fi - - for qps_staging in \ - "$QEMU_PERSISTENT_STORAGE_IMAGES_ROOT"/."$qps_identity".initializing.??????; do - [[ -f $qps_staging && ! -L $qps_staging ]] || continue - [[ $(_qps_owner "$qps_staging") == $(id -u) ]] || continue - case "$qps_staging" in - "$QEMU_PERSISTENT_STORAGE_IMAGES_ROOT/.${qps_identity}.initializing."??????) - /bin/rm -f -- "$qps_staging" || { - exec 8>&- - _qps_fail 'cannot reclaim an interrupted base-image expansion' - return 1 - } - ;; - esac - done - - if [[ -e $qps_final || -L $qps_final ]]; then - if ! _qps_validate_immutable_source "$qps_final" "$qps_source_bytes"; then - exec 8>&- - return 1 - fi - QEMU_IMMUTABLE_SOURCE_DISK=$qps_final - exec 8>&- - return 0 - fi +# Write the factory straight into the unpublished writable disk. Compressed +# bundles never retain an expanded source image in the workspace. +_qps_prepare_disk() { + local qps_source=$1 + local qps_destination=$2 + local qps_source_bytes=$3 + local qps_source_sha=$4 + local qps_zstd=${5:-} - if ! _qps_assert_free_space "$QEMU_PERSISTENT_STORAGE_IMAGES_ROOT" \ - "$((qps_source_bytes + QEMU_PERSISTENT_STORAGE_HEADROOM_BYTES))"; then - exec 8>&- - return 1 + _qps_launch_progress preparing + if [[ -z $qps_zstd ]]; then + _qps_clone_disk "$qps_source" "$qps_destination" "$qps_source_bytes" + return $? fi - - qps_staging=$(mktemp "$QEMU_PERSISTENT_STORAGE_IMAGES_ROOT/.${qps_identity}.initializing.XXXXXX") || { - exec 8>&- - return 1 - } - chmod 600 "$qps_staging" || return 1 - if ! "$qps_zstd" -d -f "$qps_compressed" -o "$qps_staging" >&2; then - /bin/rm -f -- "$qps_staging" - exec 8>&- + if ! "$qps_zstd" -d -f "$qps_source" -o "$qps_destination" >&2; then + /bin/rm -f -- "$qps_destination" _qps_fail 'cannot expand the bundled root disk' return 1 fi - chmod 600 "$qps_staging" || { - /bin/rm -f -- "$qps_staging" - exec 8>&- - return 1 - } - if ! _qps_validate_immutable_source "$qps_staging" "$qps_source_bytes"; then - /bin/rm -f -- "$qps_staging" - exec 8>&- - return 1 - fi - qps_actual_sha=$(_qps_sha256 "$qps_staging") || { - /bin/rm -f -- "$qps_staging" - exec 8>&- - return 1 - } - if [[ $qps_actual_sha != "$qps_source_sha" ]]; then - /bin/rm -f -- "$qps_staging" - exec 8>&- - _qps_fail 'expanded root disk does not match its signed manifest digest' + chmod 600 "$qps_destination" || return 1 + _qps_launch_progress verifying + if ! _qps_validate_expanded_disk "$qps_destination" "$qps_source_bytes" || + [[ $(_qps_sha256 "$qps_destination") != "$qps_source_sha" ]]; then + /bin/rm -f -- "$qps_destination" + _qps_fail 'expanded root disk does not match its signed manifest' return 1 fi - _qps_fsync "$qps_staging" || return 1 - /bin/mv "$qps_staging" "$qps_final" || return 1 - _qps_fsync "$QEMU_PERSISTENT_STORAGE_IMAGES_ROOT" || return 1 - QEMU_IMMUTABLE_SOURCE_DISK=$qps_final - exec 8>&- - _qps_error "materialized immutable base image ${qps_identity:0:12}" + _qps_error 'expanded the bundled factory directly into the working disk' } _qps_remove_recognized_directory() { @@ -1359,6 +1273,7 @@ _qps_initialize_persistent_disk() { local qps_source_sha=$4 local qps_source_bytes=$5 local qps_working_bytes=$6 + local qps_zstd=${7:-} local qps_final="$QEMU_PERSISTENT_STORAGE_DISKS_ROOT/$qps_storage_key" local qps_staging='' @@ -1378,7 +1293,8 @@ _qps_initialize_persistent_disk() { _qps_fail 'cannot write persistent-disk metadata' return 1 fi - if ! _qps_clone_disk "$qps_source" "$qps_staging/rootfs.ext4" "$qps_source_bytes"; then + if ! _qps_prepare_disk "$qps_source" "$qps_staging/rootfs.ext4" \ + "$qps_source_bytes" "$qps_source_sha" "$qps_zstd"; then _qps_remove_recognized_directory \ "$qps_staging" "$qps_identity" "$qps_source_sha" "$qps_source_bytes" \ "$qps_working_bytes" 1 || true @@ -1390,6 +1306,7 @@ _qps_initialize_persistent_disk() { "$qps_working_bytes" 1 || true return 1 fi + _qps_launch_progress finishing _qps_validate_store_directory \ "$qps_staging" "$qps_identity" "$qps_source_sha" "$qps_source_bytes" \ "$qps_working_bytes" || return 1 @@ -1771,6 +1688,74 @@ _qps_publish_recorded_selection() { _qps_set_selected_boot_kit "$QPS_METADATA_IDENTITY" } +_qps_clear_workspace_locked() { + local qps_identity=$1 + local qps_storage_key=$2 + # Validate an orphan current-identity boot entry before deleting any disk, + # then re-check it immediately before removal below. + if ! _qps_validate_boot_kit_reset_target "$qps_identity"; then + return 1 + fi + _qps_reset_progress deleting + if ! _qps_reset_persistent_disk "$qps_storage_key"; then + return 1 + fi + if [[ $qps_storage_key == current ]] && \ + ! _qps_reset_remaining_legacy_workspaces; then + return 1 + fi + # A prior interrupted reset can leave the current app identity's boot kit + # after its disk has already gone. Reset is explicitly destructive, so + # discard that exact app-owned entry too. + if ! _qps_reset_boot_kit "$qps_identity"; then + return 1 + fi +} + +# A confirmed UI reset only removes the recorded workspace and boot kit. It +# requires neither a factory source disk nor enough room to create a new one. +qemu_persistent_storage_reset() { + local qps_identity=${1:-} + local qps_storage_key=current + local qps_reset_in_progress=1 + local qps_status=0 + + _qps_is_identity "$qps_identity" || { + _qps_fail 'bundle identity must be exactly 64 lowercase hexadecimal characters' + return 1 + } + _qps_prepare_state_root || return 1 + case "${OMARCHY_QEMU_GPU_DEVELOPMENT_MULTI_DISK:-0}" in + 0) ;; + 1) qps_storage_key=$qps_identity ;; + *) _qps_fail 'OMARCHY_QEMU_GPU_DEVELOPMENT_MULTI_DISK must be 0 or 1'; return 1 ;; + esac + _qps_acquire_lock "$qps_storage_key" || return 1 + if [[ $qps_storage_key == current ]]; then + if ! _qps_migrate_legacy_single_workspace reset "$qps_identity"; then + qemu_persistent_storage_release_lock + return 1 + fi + fi + _qps_reap_interrupted_work "$qps_storage_key" + if _qps_clear_workspace_locked "$qps_identity" "$qps_storage_key"; then + _qps_reset_progress finishing + QEMU_SELECTED_DISK='' + QEMU_SELECTED_STORAGE_MODE='' + QEMU_PERSISTENT_STORAGE_WORKING_BYTES='' + QEMU_PERSISTENT_STORAGE_NEEDS_BOOT_RECOVERY=0 + QEMU_SELECTED_KERNEL='' + QEMU_SELECTED_INITRAMFS='' + QEMU_SELECTED_KERNEL_COMMAND_LINE='' + QEMU_PERSISTENT_STORAGE_DIRECTORY='' + QEMU_PERSISTENT_STORAGE_IDENTITY='' + else + qps_status=$? + fi + qemu_persistent_storage_release_lock + return "$qps_status" +} + _qps_select_persistent_disk() { local qps_mode=$1 local qps_identity=$2 @@ -1781,9 +1766,13 @@ _qps_select_persistent_disk() { local qps_kernel=${7:-} local qps_initramfs=${8:-} local qps_command_line=${9:-} + local qps_zstd=${10:-} local qps_final='' local qps_status=0 local qps_storage_key='current' + local qps_launch_in_progress=1 + local qps_reset_in_progress=0 + [[ $qps_mode != reset ]] || qps_reset_in_progress=1 _qps_prepare_state_root || return 1 case "${OMARCHY_QEMU_GPU_DEVELOPMENT_MULTI_DISK:-0}" in @@ -1811,25 +1800,7 @@ _qps_select_persistent_disk() { _qps_reap_interrupted_work "$qps_storage_key" if [[ $qps_mode == reset ]]; then - # Validate an orphan current-identity boot entry before deleting any disk, - # then re-check it immediately before removal below. - if ! _qps_validate_boot_kit_reset_target "$qps_identity"; then - qemu_persistent_storage_release_lock - return 1 - fi - if ! _qps_reset_persistent_disk "$qps_storage_key"; then - qemu_persistent_storage_release_lock - return 1 - fi - if [[ $qps_storage_key == current ]] && \ - ! _qps_reset_remaining_legacy_workspaces; then - qemu_persistent_storage_release_lock - return 1 - fi - # A prior interrupted reset can leave the current app identity's boot kit - # after its disk has already gone. Reset is explicitly destructive, so - # discard that exact app-owned entry before creating the fresh VM too. - if ! _qps_reset_boot_kit "$qps_identity"; then + if ! _qps_clear_workspace_locked "$qps_identity" "$qps_storage_key"; then qemu_persistent_storage_release_lock return 1 fi @@ -1845,16 +1816,16 @@ _qps_select_persistent_disk() { fi fi if [[ ! -e $qps_final && ! -L $qps_final ]]; then - # The clone itself is nearly free on APFS and the expansion is sparse, so - # what matters here is that the guest has room to boot and write. + local qps_required_bytes=$QEMU_PERSISTENT_STORAGE_HEADROOM_BYTES + [[ -z $qps_zstd ]] || qps_required_bytes=$((qps_required_bytes + qps_source_bytes)) if ! _qps_assert_free_space "$QEMU_PERSISTENT_STORAGE_DISKS_ROOT" \ - "$QEMU_PERSISTENT_STORAGE_HEADROOM_BYTES"; then + "$qps_required_bytes"; then qemu_persistent_storage_release_lock return 1 fi if ! _qps_initialize_persistent_disk \ "$qps_identity" "$qps_storage_key" "$qps_source" "$qps_source_sha" \ - "$qps_source_bytes" "$qps_working_bytes"; then + "$qps_source_bytes" "$qps_working_bytes" "$qps_zstd"; then qemu_persistent_storage_release_lock return 1 fi @@ -1871,10 +1842,13 @@ _qps_select_persistent_disk() { } _qps_select_ephemeral_disk() { + local qps_launch_in_progress=1 local qps_source=$1 local qps_source_bytes=$2 local qps_work_directory=$3 local qps_working_bytes=${4:-$qps_source_bytes} + local qps_source_sha=${5:-} + local qps_zstd=${6:-} local qps_final="$qps_work_directory/rootfs.ext4" local qps_staging="$qps_work_directory/.rootfs.ext4.initializing.$$.$RANDOM$RANDOM" @@ -1883,7 +1857,12 @@ _qps_select_ephemeral_disk() { _qps_fail "ephemeral root disk already exists: $qps_final" return 1 } - if ! _qps_clone_disk "$qps_source" "$qps_staging" "$qps_source_bytes"; then + if [[ -n $qps_zstd ]]; then + _qps_assert_free_space "$qps_work_directory" \ + "$((qps_source_bytes + QEMU_PERSISTENT_STORAGE_HEADROOM_BYTES))" || return 1 + fi + if ! _qps_prepare_disk "$qps_source" "$qps_staging" \ + "$qps_source_bytes" "$qps_source_sha" "$qps_zstd"; then [[ ! -e $qps_staging && ! -L $qps_staging ]] || /bin/rm -f "$qps_staging" return 1 fi @@ -1891,6 +1870,7 @@ _qps_select_ephemeral_disk() { [[ ! -e $qps_staging && ! -L $qps_staging ]] || /bin/rm -f "$qps_staging" return 1 fi + _qps_launch_progress finishing _qps_fsync "$qps_staging" || return 1 /bin/mv "$qps_staging" "$qps_final" || { _qps_fail 'cannot publish ephemeral root disk' @@ -1987,7 +1967,7 @@ qemu_persistent_storage_select_existing() { # Arguments: # 1. mode: persistent (default lifecycle), reset, or ephemeral # 2. exact 64-character lowercase guest-manifest SHA-256 -# 3. validated immutable source rootfs path +# 3. validated factory rootfs path (raw, or compressed with argument 11) # 4. validated source-rootfs SHA-256 from the manifest # 5. source-rootfs byte count from the manifest # 6. private run directory (required only for ephemeral mode) @@ -1995,6 +1975,7 @@ qemu_persistent_storage_select_existing() { # 8. validated bundled kernel (optional only for storage-library tests) # 9. validated bundled initramfs (required with argument 8) # 10. validated base kernel command line (required with argument 8) +# 11. validated Zstandard decoder (only for compressed source paths) # # On success, QEMU_SELECTED_DISK and QEMU_SELECTED_STORAGE_MODE are populated. # Persistent/reset mode also holds FD 9 until the caller exits or explicitly @@ -2010,6 +1991,7 @@ qemu_persistent_storage_select() { local qps_kernel=${8:-} local qps_initramfs=${9:-} local qps_command_line=${10:-} + local qps_zstd=${11:-} QEMU_SELECTED_DISK='' QEMU_SELECTED_STORAGE_MODE='' @@ -2049,7 +2031,18 @@ qemu_persistent_storage_select() { _qps_fail 'working rootfs byte count cannot be smaller than the source' return 1 } - _qps_assert_source_disk "$qps_source" "$qps_source_bytes" || return 1 + if [[ -n $qps_zstd ]]; then + [[ -f $qps_source && ! -L $qps_source ]] || { + _qps_fail 'compressed root disk is missing or unsafe' + return 1 + } + [[ -f $qps_zstd && ! -L $qps_zstd && -x $qps_zstd ]] || { + _qps_fail 'bundled Zstandard decoder is missing or unsafe' + return 1 + } + else + _qps_assert_source_disk "$qps_source" "$qps_source_bytes" || return 1 + fi if [[ -n $qps_kernel || -n $qps_initramfs || -n $qps_command_line ]]; then [[ -n $qps_kernel && -n $qps_initramfs && -n $qps_command_line ]] || { _qps_fail 'kernel, initramfs, and command line must be supplied together' @@ -2066,7 +2059,8 @@ qemu_persistent_storage_select() { if [[ $qps_mode == ephemeral ]]; then if _qps_select_ephemeral_disk \ - "$qps_source" "$qps_source_bytes" "$qps_work_directory" "$qps_working_bytes"; then + "$qps_source" "$qps_source_bytes" "$qps_work_directory" "$qps_working_bytes" \ + "$qps_source_sha" "$qps_zstd"; then QEMU_SELECTED_KERNEL=$qps_kernel QEMU_SELECTED_INITRAMFS=$qps_initramfs QEMU_SELECTED_KERNEL_COMMAND_LINE=$qps_command_line @@ -2077,6 +2071,6 @@ qemu_persistent_storage_select() { _qps_select_persistent_disk \ "$qps_mode" "$qps_identity" "$qps_source" "$qps_source_sha" \ "$qps_source_bytes" "$qps_working_bytes" \ - "$qps_kernel" "$qps_initramfs" "$qps_command_line" + "$qps_kernel" "$qps_initramfs" "$qps_command_line" "$qps_zstd" fi } diff --git a/macos/run-qemu-gpu.sh b/macos/run-qemu-gpu.sh index dbc7bd2f7..75574ddfb 100755 --- a/macos/run-qemu-gpu.sh +++ b/macos/run-qemu-gpu.sh @@ -43,6 +43,10 @@ case ${1:-} in esac (( $# <= 1 )) || usage +if ((reset_only == 0)); then + echo '[qemu-gpu] Launch phase: checking' >&2 +fi + script_dir=$(cd "$(dirname "$0")" && pwd -P) resources_dir=$(cd "$script_dir/.." && pwd -P) contents_dir=$(cd "$resources_dir/.." && pwd -P) @@ -1476,6 +1480,12 @@ recover_persistent_boot_kit() { echo '[qemu-gpu] Saved VM boot files recovered; continuing normal launch.' >&2 } +if ((reset_only)); then + qemu_persistent_storage_reset "$bundle_identity" || fail 'could not clear the VM disk' + echo '[qemu-gpu] Reset complete.' >&2 + exit 0 +fi + umask 077 work_dir=$(mktemp -d '/private/tmp/omarchy-qemu-gpu.XXXXXX') || { fail "could not create a private temporary directory" @@ -1531,15 +1541,10 @@ if (( selected_existing == 0 )); then expanded_disk_bytes=$disk_capacity_bytes fi source_disk="$guest_dir/rootfs.ext4" + source_decoder='' if [[ ! -e $source_disk && ! -L $source_disk ]]; then - qemu_persistent_storage_materialize_source \ - "$bundle_identity" \ - "$guest_dir/rootfs.ext4.zst" \ - "$compressed_disk_bytes" \ - "$source_disk_sha" \ - "$source_disk_bytes" \ - "$resources_dir/runtime/bin/zstd" || fail "could not materialize the bundled root disk" - source_disk=$QEMU_IMMUTABLE_SOURCE_DISK + source_disk="$guest_dir/rootfs.ext4.zst" + source_decoder="$resources_dir/runtime/bin/zstd" fi if qemu_persistent_storage_select \ "$storage_mode" \ @@ -1551,7 +1556,8 @@ if (( selected_existing == 0 )); then "$expanded_disk_bytes" \ "$bundled_kernel" \ "$bundled_initramfs" \ - "$kernel_command_line"; then + "$kernel_command_line" \ + "$source_decoder"; then : else storage_status=$? @@ -1590,12 +1596,6 @@ case " $launch_kernel_command_line " in ;; esac -if ((reset_only)); then - qemu_persistent_storage_release_lock - echo "[qemu-gpu] Reset complete." >&2 - exit 0 -fi - if [[ -n $disk_capacity_bytes && $storage_mode == persistent && ${OMARCHY_QEMU_GPU_DRY_RUN:-0} == 0 ]]; then qemu_persistent_storage_grow_selected "$disk_capacity_bytes" "$native_bridge" || \ fail 'could not apply maximum disk size' @@ -1865,6 +1865,7 @@ if [[ -n $shared_folder ]]; then echo "[qemu-gpu] Shared folder: $shared_folder (guest ~/$shared_folder_name)" >&2 fi echo "[qemu-gpu] Port forwarding: $port_forwarding_summary" >&2 +echo '[qemu-gpu] Launch phase: starting' >&2 "$qemu_bin" "${qemu_args[@]}" & qemu_pid=$! printf '%s\n' "$qemu_pid" >"$work_dir/.qemu.pid"