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.
This commit is contained in:
Yoshimasa Niwa
2026-09-25 17:08:34 -07:00
committed by GitHub
parent 65aea029ab
commit bb4acb2468
6 changed files with 136 additions and 10 deletions
+2 -1
View File
@@ -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)
+3 -2
View File
@@ -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(),
@@ -1,15 +1,19 @@
import Foundation
struct HumanReadableByteCount: Encodable, CustomStringConvertible {
private let byteCount: Int
private let byteCount: Int?
private let jsonValue: any Encodable
init<JSONValue: Encodable>(_ byteCount: Int, encodedAs: (Int) -> JSONValue) {
init<JSONValue: Encodable>(_ 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
-4
View File
@@ -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)
}
+117
View File
@@ -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<Command: AsyncParsableCommand>(
_ 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"]
@@ -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" }