Skip to content

Preserve fractional memory sizes across MemorySize encoding#1904

Open
anxkhn wants to merge 1 commit into
apple:mainfrom
anxkhn:fix/memorysize-fractional-roundtrip
Open

Preserve fractional memory sizes across MemorySize encoding#1904
anxkhn wants to merge 1 commit into
apple:mainfrom
anxkhn:fix/memorysize-fractional-roundtrip

Conversation

@anxkhn

@anxkhn anxkhn commented Jul 5, 2026

Copy link
Copy Markdown
## Type of Change
- [x] Bug fix
- [ ] New feature
- [ ] Breaking change
- [ ] Documentation update

## Motivation and Context
`MemorySize` is the `Codable` persisted representation of the `memory` value for
containers and machines (for example `ContainerSystemConfig.memory` and
`MachineConfig.memory`). It parses fractional inputs such as `1.5gb` via
`Measurement.parse`, but its `formatted` property, which `encode(to:)` writes,
computed `Int64(measurement.value)`. That truncates the fractional part while
keeping the coarse unit, so `1.5gb` encoded to `1gb` and `0.5gb` to `0gb`.

As a result the configured value did not survive an encode/decode (save and
reload) cycle: for `1.5gb`, 0.5 GiB (536,870,912 bytes) was silently dropped.
This also left `formatted` inconsistent with `toUInt64`, which already rounds,
so the running VM and the persisted string disagreed for the same input.

## Testing
- [x] Tested locally
- [x] Added/updated tests
- [ ] Added/updated docs

`formatted` now emits the exact byte count when the stored value is fractional,
and is unchanged for whole-number values. Every supported unit is a binary
(power-of-two) multiple of a byte, so converting a fractional value to bytes is
lossless, which keeps the round-trip exact while leaving existing output such as
`1gb` and `2048mb` byte-identical.

Added `testRoundTripEncodingFractional` covering `1.5gb`, `0.5gb`, `2.25tb`, and
`1.5mb`; it asserts both `MemorySize` equality and equal byte counts after an
encode/decode round-trip. It fails before this change (for example `0.5gb`
decodes to 0 bytes) and passes after. The existing whole-number tests
(`testFormattedOutput`, `testRoundTripEncoding`) continue to pass.

Ran locally (with `DEVELOPER_DIR` pointing at Xcode so the Testing/XCTest
modules resolve):
- `swift test --filter ContainerAPIClientTests` -> 184 tests pass.
- `swift test --filter ContainerPersistenceTests` -> 91 tests pass.
- `swift format lint --strict` on both changed files -> clean.

Files changed

  • Sources/ContainerPersistence/MemorySize.swift (+10): guard in formatted;
    when measurement.value is fractional, emit the exact integer byte count
    (UInt64(measurement.converted(to: .bytes).value.rounded()) + b) instead of
    the truncated coarse unit. Whole-number path untouched.
  • Tests/ContainerAPIClientTests/MemorySizeTests.swift (+9): add
    testRoundTripEncodingFractional (parameterized over 4 fractional inputs).

The diff (for reference)

diff --git a/Sources/ContainerPersistence/MemorySize.swift b/Sources/ContainerPersistence/MemorySize.swift
index e0762b3..d5fc645 100644
--- a/Sources/ContainerPersistence/MemorySize.swift
+++ b/Sources/ContainerPersistence/MemorySize.swift
@@ -49,6 +49,16 @@ public struct MemorySize: Codable, Sendable, Equatable, CustomStringConvertible
     ]
 
     public var formatted: String {
+        // Only whole-number sizes can be represented in the stored unit without
+        // loss. A fractional value (e.g. "1.5gb") would truncate to "1gb" and
+        // silently drop memory when re-encoded, so emit the exact byte count
+        // instead. Binary units are integer multiples of a byte, so this keeps
+        // the encode/decode round-trip lossless while leaving whole-number
+        // output (e.g. "1gb", "2048mb") unchanged.
+        guard measurement.value == measurement.value.rounded() else {
+            let bytes = UInt64(measurement.converted(to: .bytes).value.rounded())
+            return "\(bytes)\(Self.unitLabels[.bytes] ?? "b")"
+        }
         let value = Int64(measurement.value)
         let label = Self.unitLabels[measurement.unit] ?? "unknown"
         return "\(value)\(label)"
diff --git a/Tests/ContainerAPIClientTests/MemorySizeTests.swift b/Tests/ContainerAPIClientTests/MemorySizeTests.swift
index 1d518db..6baa2be 100644
--- a/Tests/ContainerAPIClientTests/MemorySizeTests.swift
+++ b/Tests/ContainerAPIClientTests/MemorySizeTests.swift
@@ -61,6 +61,15 @@ struct MemorySizeTests {
         #expect(original == decoded)
     }
 
+    @Test(arguments: ["1.5gb", "0.5gb", "2.25tb", "1.5mb"])
+    func testRoundTripEncodingFractional(input: String) throws {
+        let original = try MemorySize(input)
+        let data = try JSONEncoder().encode(original)
+        let decoded = try JSONDecoder().decode(MemorySize.self, from: data)
+        #expect(original == decoded)
+        #expect(original.toUInt64(unit: .bytes) == decoded.toUInt64(unit: .bytes))
+    }
+
     @Test func testDecodingFromString() throws {
         let json = Data("\"512kb\"".utf8)
         let decoded = try JSONDecoder().decode(MemorySize.self, from: json)

MemorySize.formatted computed Int64(measurement.value), truncating the
fractional part while keeping the coarse unit, so a configured size such
as "1.5gb" encoded to "1gb" (and "0.5gb" to "0gb"). Because MemorySize is
the Codable persisted representation of container and machine memory
config, the configured value did not survive an encode/decode (save and
reload) cycle and memory was silently dropped.

Emit the exact byte count when the value is fractional; binary units are
integer multiples of a byte, so this keeps the round-trip lossless and
leaves whole-number output unchanged. Add a fractional round-trip test.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant