From bb4acb24688568cffc5642125b05f9f5f62ed2fa Mon Sep 17 00:00:00 2001 From: Yoshimasa Niwa Date: Fri, 25 Sep 2026 17:08:34 -0700 Subject: [PATCH] Fix listing VMs when disk capacity is unavailable (#1349) * Allow HumanReadableByteCount to represent an unknown byte count Some byte counts, such as the capacity of an ASIF disk image, can't always be determined. Accept an optional byte count and render an unknown value as "-" in text output and as null in JSON output. * Fix listing and getting VMs when disk capacity is unavailable For ASIF disk images, the disk capacity is read with "diskutil image info". The command fails with "Resource temporarily unavailable" while a running VM holds the disk image open. As a result, "tart list" and "tart get" fail entirely when any such VM exists. Treat the disk capacity as unknown when it can't be determined, so that both commands still show the remaining information. Fixes #1344 * Remove unused VMDirectory.diskSizeGB() The method was added together with diskSizeBytes() but has never been used. "tart list" and "tart get" use diskSizeBytes() directly. --- Sources/tart/Commands/Get.swift | 3 +- Sources/tart/Commands/List.swift | 5 +- .../Formatter/HumanReadableByteCount.swift | 10 +- Sources/tart/VMDirectory.swift | 4 - Tests/TartTests/CommandBehaviorTests.swift | 117 ++++++++++++++++++ .../HumanReadableByteCountTests.swift | 7 ++ 6 files changed, 136 insertions(+), 10 deletions(-) diff --git a/Sources/tart/Commands/Get.swift b/Sources/tart/Commands/Get.swift index 594f0a4..91812ed 100644 --- a/Sources/tart/Commands/Get.swift +++ b/Sources/tart/Commands/Get.swift @@ -31,7 +31,8 @@ struct Get: AsyncParsableCommand { OS: vmConfig.os, CPU: vmConfig.cpuCount, Memory: memorySizeInMb, - Disk: HumanReadableByteCount(try vmDir.diskSizeBytes()) { $0 / 1000 / 1000 / 1000 }, + // ASIF capacity lookup can fail while a running VM holds the disk open. + Disk: HumanReadableByteCount(try? vmDir.diskSizeBytes()) { $0 / 1000 / 1000 / 1000 }, DiskFormat: vmConfig.diskFormat.rawValue, Size: HumanReadableByteCount(try vmDir.allocatedSizeBytes()) { String(format: "%.3f", Float($0) / 1000 / 1000 / 1000) diff --git a/Sources/tart/Commands/List.swift b/Sources/tart/Commands/List.swift index 28e67bf..7a0ac23 100644 --- a/Sources/tart/Commands/List.swift +++ b/Sources/tart/Commands/List.swift @@ -42,7 +42,8 @@ struct List: AsyncParsableCommand { try VMInfo( Source: "local", Name: name, - Disk: HumanReadableByteCount(try vmDir.diskSizeBytes()) { $0 / 1000 / 1000 / 1000 }, + // ASIF capacity lookup can fail while a running VM holds the disk open. + Disk: HumanReadableByteCount(try? vmDir.diskSizeBytes()) { $0 / 1000 / 1000 / 1000 }, Size: HumanReadableByteCount(try vmDir.allocatedSizeBytes()) { $0 / 1000 / 1000 / 1000 }, Accessed: formatAccessDate(try vmDir.accessDate()), Running: vmDir.running(), @@ -56,7 +57,7 @@ struct List: AsyncParsableCommand { try VMInfo( Source: "OCI", Name: name, - Disk: HumanReadableByteCount(try vmDir.diskSizeBytes()) { $0 / 1000 / 1000 / 1000 }, + Disk: HumanReadableByteCount(try? vmDir.diskSizeBytes()) { $0 / 1000 / 1000 / 1000 }, Size: HumanReadableByteCount(try vmDir.allocatedSizeBytes()) { $0 / 1000 / 1000 / 1000 }, Accessed: formatAccessDate(try vmDir.accessDate()), Running: vmDir.running(), diff --git a/Sources/tart/Formatter/HumanReadableByteCount.swift b/Sources/tart/Formatter/HumanReadableByteCount.swift index d287b76..fcc5b0d 100644 --- a/Sources/tart/Formatter/HumanReadableByteCount.swift +++ b/Sources/tart/Formatter/HumanReadableByteCount.swift @@ -1,15 +1,19 @@ import Foundation struct HumanReadableByteCount: Encodable, CustomStringConvertible { - private let byteCount: Int + private let byteCount: Int? private let jsonValue: any Encodable - init(_ byteCount: Int, encodedAs: (Int) -> JSONValue) { + init(_ byteCount: Int?, encodedAs: (Int) -> JSONValue) { self.byteCount = byteCount - self.jsonValue = encodedAs(byteCount) + self.jsonValue = byteCount.map(encodedAs) } var description: String { + guard let byteCount else { + return "-" + } + let formatter = MeasurementFormatter() formatter.unitOptions = .naturalScale formatter.unitStyle = .medium diff --git a/Sources/tart/VMDirectory.swift b/Sources/tart/VMDirectory.swift index 48e3faf..7f5ad83 100644 --- a/Sources/tart/VMDirectory.swift +++ b/Sources/tart/VMDirectory.swift @@ -469,10 +469,6 @@ struct VMDirectory: Prunable { } } - func diskSizeGB() throws -> Int { - try diskSizeBytes() / 1000 / 1000 / 1000 - } - func markExplicitlyPulled() { FileManager.default.createFile(atPath: explicitlyPulledMark.path, contents: nil) } diff --git a/Tests/TartTests/CommandBehaviorTests.swift b/Tests/TartTests/CommandBehaviorTests.swift index da2ce98..c579b0b 100644 --- a/Tests/TartTests/CommandBehaviorTests.swift +++ b/Tests/TartTests/CommandBehaviorTests.swift @@ -4,6 +4,85 @@ import XCTest @testable import tart final class CommandBehaviorTests: XCTestCase { + func testListSurvivesUnavailableDiskCapacity() async throws { + try await withTemporaryTartHome { + let previousPath = try installUnavailableDiskutil() + defer { restoreEnvironment("PATH", to: previousPath) } + + let local = try VMStorageLocal() + let oci = try VMStorageOCI() + for (name, diskFormat) in [("unavailable", DiskImageFormat.asif), ("healthy", .raw)] { + let remoteName = try RemoteName("example.com/org/\(name):latest") + for vmDir in [try local.create(name), try oci.create(remoteName)] { + var vmConfig = config() + vmConfig.diskFormat = diskFormat + try vmConfig.save(toURL: vmDir.configURL) + XCTAssertTrue(FileManager.default.createFile(atPath: vmDir.nvramURL.path, contents: Data())) + // The diskutil stub simulates a locked ASIF disk without needing a running VM. + XCTAssertTrue(FileManager.default.createFile( + atPath: vmDir.diskURL.path, + contents: Data(repeating: 0, count: 4096) + )) + if diskFormat == .asif { + XCTAssertThrowsError(try vmDir.diskSizeBytes()) + } + } + } + + for sourceArguments in [[], ["--source", "local"], ["--source", "oci"]] { + let json = try await commandOutput(List.self, sourceArguments + ["--format", "json"]) + let rows = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(json.utf8)) as? [[String: Any]]) + XCTAssertEqual(rows.count, sourceArguments.isEmpty ? 4 : 2) + for row in rows { + let name = try XCTUnwrap(row["Name"] as? String) + if name.contains("unavailable") { + XCTAssertTrue(row["Disk"] is NSNull) + } else { + XCTAssertEqual(row["Disk"] as? Int, 0) + } + XCTAssertEqual(row["State"] as? String, "stopped") + XCTAssertEqual(row["Running"] as? Bool, false) + } + + let text = try await commandOutput(List.self, sourceArguments) + XCTAssertTrue(text.contains("unavailable")) + XCTAssertTrue(text.contains("healthy")) + XCTAssertTrue(text.contains("-")) + + let quiet = try await commandOutput(List.self, sourceArguments + ["--quiet"]) + XCTAssertEqual(quiet.split(separator: "\n").map(String.init), rows.compactMap { $0["Name"] as? String }) + } + } + } + + func testGetSurvivesUnavailableDiskCapacity() async throws { + try await withTemporaryTartHome { + let previousPath = try installUnavailableDiskutil() + defer { restoreEnvironment("PATH", to: previousPath) } + + let vmDir = try VMStorageLocal().create("unavailable") + var vmConfig = config() + vmConfig.diskFormat = .asif + try vmConfig.save(toURL: vmDir.configURL) + XCTAssertTrue(FileManager.default.createFile(atPath: vmDir.nvramURL.path, contents: Data())) + XCTAssertTrue(FileManager.default.createFile( + atPath: vmDir.diskURL.path, + contents: Data(repeating: 0, count: 4096) + )) + XCTAssertThrowsError(try vmDir.diskSizeBytes()) + + let json = try await commandOutput(Get.self, ["unavailable", "--format", "json"]) + let info = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(json.utf8)) as? [String: Any]) + XCTAssertTrue(info["Disk"] is NSNull) + XCTAssertEqual(info["DiskFormat"] as? String, "asif") + XCTAssertEqual(info["State"] as? String, "stopped") + + let text = try await commandOutput(Get.self, ["unavailable"]) + XCTAssertTrue(text.contains("asif")) + XCTAssertTrue(text.contains("-")) + } + } + func testNoUSBAccessoriesDoesNotEnableSuspendable() throws { try withTemporaryTartHome { let vmDir = try VMStorageLocal().create("no-usb-accessories") @@ -130,6 +209,44 @@ final class CommandBehaviorTests: XCTestCase { ) } + private func installUnavailableDiskutil() throws -> String? { + let binDirectory = try temporaryDirectory() + let diskutilURL = binDirectory.appendingPathComponent("diskutil") + let script = """ + #!/bin/sh + echo 'Resource temporarily unavailable' >&2 + exit 1 + """ + try script.write(to: diskutilURL, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: diskutilURL.path) + let previousPath = ProcessInfo.processInfo.environment["PATH"] + setenv("PATH", binDirectory.path, 1) + return previousPath + } + + private func commandOutput( + _ commandType: Command.Type, + _ arguments: [String] + ) async throws -> String { + let outputURL = try temporaryDirectory().appendingPathComponent("stdout") + XCTAssertTrue(FileManager.default.createFile(atPath: outputURL.path, contents: nil)) + let output = try FileHandle(forWritingTo: outputURL) + defer { try? output.close() } + + fflush(stdout) + let savedStdout = dup(STDOUT_FILENO) + defer { + fflush(stdout) + dup2(savedStdout, STDOUT_FILENO) + close(savedStdout) + } + dup2(output.fileDescriptor, STDOUT_FILENO) + var command = try Command.parseAsRoot(arguments) as! Command + try await command.run() + fflush(stdout) + return try String(contentsOf: outputURL, encoding: .utf8) + } + private func withTemporaryTartHome(_ body: () throws -> Void) throws { let home = try temporaryDirectory() let previousHome = ProcessInfo.processInfo.environment["TART_HOME"] diff --git a/Tests/TartTests/HumanReadableByteCountTests.swift b/Tests/TartTests/HumanReadableByteCountTests.swift index 398158e..3e30a61 100644 --- a/Tests/TartTests/HumanReadableByteCountTests.swift +++ b/Tests/TartTests/HumanReadableByteCountTests.swift @@ -3,6 +3,13 @@ import XCTest @testable import tart final class HumanReadableByteCountTests: XCTestCase { + func testUnknownByteCount() throws { + let unknown = HumanReadableByteCount(nil) { $0 / 1000 / 1000 / 1000 } + + XCTAssertEqual(unknown.description, "-") + XCTAssertEqual(String(data: try JSONEncoder().encode(unknown), encoding: .utf8), "null") + } + func testTextAndJSONRepresentations() throws { let integer = HumanReadableByteCount(51_400_000_000) { _ in 51 } let string = HumanReadableByteCount(17_234_000_000) { _ in "17.234" }