Files
container/Tests/ContainerizationTests/ImageTests/HeaderScanTimingTests.swift
T
Anthony 35f97f225d Add progress handler for rootfs unpacking (#515)
I've added an optional progress handler for rootfs unpacking so that
consumers can show progress. The Ubuntu image takes about 8 seconds to
unpack on my machine, and I'm developing an app where it would be useful
to show progress in the UI.

Total size is determined in an optional first pass that scans archive
headers. Bytes written are then reported during unpacking. The optional
first pass adds 15 (Alpine) to 115 (Ubuntu) ms to unpacking duration on
my machine, depending on image size.
2026-04-02 17:33:55 -07:00

177 lines
7.1 KiB
Swift

//===----------------------------------------------------------------------===//
// Copyright © 2026 Apple Inc. and the Containerization project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
#if os(macOS)
import ContainerizationArchive
import ContainerizationEXT4
import ContainerizationExtras
import ContainerizationOCI
import Foundation
import SystemPackage
import Testing
@testable import Containerization
/// Measures header scan overhead vs. full unpack time using real container images
/// pulled from a registry.
///
/// Run with:
/// ENABLE_TIMING_TESTS=1 swift test --filter ImageHeaderScanTimingTest
@Suite
struct ImageHeaderScanTimingTest {
private static let isEnabled = ProcessInfo.processInfo.environment["ENABLE_TIMING_TESTS"] != nil
let store: ImageStore
let dir: URL
let contentStore: ContentStore
init() throws {
let dir = FileManager.default.uniqueTemporaryDirectory(create: true)
let cs = try LocalContentStore(path: dir)
let store = try ImageStore(path: dir, contentStore: cs)
self.dir = dir
self.store = store
self.contentStore = cs
}
@Test(.enabled(if: ImageHeaderScanTimingTest.isEnabled))
func measureAlpineAndUbuntu() async throws {
defer { try? FileManager.default.removeItem(at: dir) }
let images: [(reference: String, label: String)] = [
("ghcr.io/linuxcontainers/alpine:3.20", "Alpine 3.20"),
("docker.io/library/ubuntu:24.04", "Ubuntu 24.04"),
]
for image in images {
print("\n==============================")
print("Image: \(image.label)")
print("==============================")
let img = try await store.pull(reference: image.reference, platform: .current)
let manifest = try await img.manifest(for: .current)
for (i, layer) in manifest.layers.enumerated() {
let content = try await img.getContent(digest: layer.digest)
let compression = compressionFilter(for: layer.mediaType)
let compressedSize = try FileManager.default.attributesOfItem(atPath: content.path.path)[.size] as? Int64 ?? 0
let label = "\(image.label) layer \(i + 1)/\(manifest.layers.count) (\(layer.mediaType), \(formatBytes(compressedSize)) compressed)"
try await measureOverhead(url: content.path, compression: compression, label: label)
}
}
}
// MARK: - Helpers
private func compressionFilter(for mediaType: String) -> ContainerizationArchive.Filter {
switch mediaType {
case MediaTypes.imageLayerZstd, MediaTypes.dockerImageLayerZstd:
return .zstd
case MediaTypes.imageLayer, MediaTypes.dockerImageLayer:
return .none
default:
return .gzip
}
}
private func measureOverhead(url: URL, compression: ContainerizationArchive.Filter, label: String) async throws {
let clock = ContinuousClock()
print("\n--- \(label) ---\n")
// For zstd, pre-decompress once (matching the production code path in EXT4Unpacker).
let scanFile: URL
let scanFilter: ContainerizationArchive.Filter
var decompressedFile: URL?
if compression == .zstd {
var decompressed: URL = url
let decompressDuration = try clock.measure {
decompressed = try ArchiveReader.decompressZstd(url)
}
scanFile = decompressed
scanFilter = .none
decompressedFile = decompressed
print(" Zstd decompress: \(decompressDuration)")
} else {
scanFile = url
scanFilter = compression
}
defer {
if let decompressedFile {
ArchiveReader.cleanUpDecompressedZstd(decompressedFile)
}
}
// 1. Header scan only
var scannedTotals: (size: Int64, items: Int) = (0, 0)
let scanDuration = try clock.measure {
scannedTotals = try EXT4.Formatter.scanArchiveHeaders(
format: .paxRestricted, filter: scanFilter, file: scanFile)
}
print(" Scanned total size: \(formatBytes(scannedTotals.size)) (\(scannedTotals.items) items)")
print(" Header scan: \(scanDuration)")
// 2. Full unpack without progress
let tempDir1 = FileManager.default.uniqueTemporaryDirectory()
let fsPath1 = FilePath(tempDir1.appendingPathComponent("no-progress.ext4.img", isDirectory: false))
defer { try? FileManager.default.removeItem(at: tempDir1) }
let unpackOnlyDuration = try await clock.measure {
let formatter = try EXT4.Formatter(fsPath1)
try await formatter.unpack(source: url, compression: compression)
try formatter.close()
}
print(" Unpack (no progress): \(unpackOnlyDuration)")
// 3. Full unpack with progress (includes header scan pass)
let tempDir2 = FileManager.default.uniqueTemporaryDirectory()
let fsPath2 = FilePath(tempDir2.appendingPathComponent("with-progress.ext4.img", isDirectory: false))
defer { try? FileManager.default.removeItem(at: tempDir2) }
let noopProgress: ProgressHandler = { _ in }
let withProgressDuration = try await clock.measure {
let formatter = try EXT4.Formatter(fsPath2)
try await formatter.unpack(source: url, compression: compression, progress: noopProgress)
try formatter.close()
}
print(" Unpack (w/ progress): \(withProgressDuration)")
// Summary
let scanMs = toMs(scanDuration)
let unpackMs = toMs(unpackOnlyDuration)
let withProgressMs = toMs(withProgressDuration)
let overheadMs = withProgressMs - unpackMs
let overheadPct = unpackMs > 0 ? (overheadMs / unpackMs) * 100 : 0
print("\n Summary:")
print(" Header scan alone: \(String(format: "%.1f", scanMs)) ms")
print(" Unpack only: \(String(format: "%.1f", unpackMs)) ms")
print(" Unpack + progress: \(String(format: "%.1f", withProgressMs)) ms")
print(" Overhead: \(String(format: "%.1f", overheadMs)) ms (\(String(format: "%.1f", overheadPct))%)")
}
private func toMs(_ d: Duration) -> Double {
let c = d.components
return Double(c.seconds) * 1000.0 + Double(c.attoseconds) / 1e15
}
private func formatBytes(_ bytes: Int64) -> String {
let mb = Double(bytes) / 1_048_576.0
return "\(String(format: "%.1f", mb)) MB"
}
}
#endif