From f10eaca613f6eef28add5d69f4fbf573ca6ed156 Mon Sep 17 00:00:00 2001 From: Aaron Paterson Date: Sat, 1 Aug 2026 18:48:34 +0000 Subject: [PATCH 01/12] Resolve test directory paths before reaping per-test files The reaper that runs at a concurrency of one deletes everything in the test directory except the unpacked rootfs it means to preserve. It held that rootfs path as FileManager reports it, under /var, and compared it against the entries of contentsOfDirectory, which reports them under /private/var, so the preserved path never matched any entry and the rootfs was deleted along with the per-test files. The unpack coordinator still held it as unpacked, so the next test opened a rootfs that was no longer there and failed with a missing file error. Resolve both sides with resolvingSymlinksInPathWithPrivate, which exists for this difference between the two views of the same directory. --- Sources/Integration/Suite.swift | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/Sources/Integration/Suite.swift b/Sources/Integration/Suite.swift index 9dae8435..e43cf372 100644 --- a/Sources/Integration/Suite.swift +++ b/Sources/Integration/Suite.swift @@ -259,12 +259,16 @@ struct IntegrationSuite: AsyncParsableCommand { // a ~2GB rootfs and a ~512MB initfs, so without reaping the dev // container fills its CoW layer in ~10 tests. if self.maxConcurrency == 1 { - let preserve = fsPath.absolutePath() + // contentsOfDirectory reports paths under /private, while testDir is + // built from FileManager's /var view of the same directory, so both + // sides are resolved before comparing. + let preserve = fsPath.resolvingSymlinksInPathWithPrivate().absolutePath() if let entries = try? FileManager.default.contentsOfDirectory( at: Self.testDir, includingPropertiesForKeys: nil ) { - for url in entries where url.absolutePath() != preserve { + for url in entries + where url.resolvingSymlinksInPathWithPrivate().absolutePath() != preserve { try? FileManager.default.removeItem(at: url) } } From 6499f7dfa7fff64c903642db690094d7e6c64ec8 Mon Sep 17 00:00:00 2001 From: Aaron Paterson Date: Sun, 2 Aug 2026 00:51:10 +0000 Subject: [PATCH 02/12] Say how to run the tests that macOS skips Tests guarded for Linux are compiled out on macOS, so `make test` reports success without having run them, and nothing says so. The target that does run them is not mentioned anywhere outside the makefile. --- README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/README.md b/README.md index f658b08d..372a872a 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,16 @@ After building, run basic and integration tests: make test integration ``` +Tests that only apply to Linux are compiled out on macOS, so `make test` passes +without running them. Run those with: + +```bash +make linux-test +``` + +which runs the same tests inside the Linux dev container, as the Linux build +workflow does. + A kernel is required to run integration tests. If you do not have a kernel locally, a default kernel can be fetched using the `make fetch-default-kernel` target. From bf4d7dcf8bbe64c745e9ef344a8c173d2a42a3af Mon Sep 17 00:00:00 2001 From: Aaron Paterson Date: Sun, 2 Aug 2026 01:20:29 +0000 Subject: [PATCH 03/12] Test against the kernel release the shipped tool installs The default kernel the tests fetch had drifted from the one users run. container installs kata 3.28.0 and its 6.18.15 kernel, while these tests fetched 3.17.0, so the suite exercised a guest with a different feature set to the one it is meant to represent. Nested runtimes are the visible case: 3.17.0 was built without nf_tables, so a docker daemon inside a container fails there and works on what ships. Kata moved from xz to zstd between those releases, so the archive is no longer named for its compression and tar is left to recognise it rather than being told, which also holds if the format changes again. --- Makefile | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Makefile b/Makefile index c79d6995..3d01b93a 100644 --- a/Makefile +++ b/Makefile @@ -64,7 +64,7 @@ LIBARCHIVE_UPSTREAM_REPO := https://github.com/libarchive/libarchive LIBARCHIVE_UPSTREAM_VERSION := v3.7.7 LIBARCHIVE_LOCAL_DIR := workdir/libarchive -KATA_BINARY_PACKAGE := https://github.com/kata-containers/kata-containers/releases/download/3.17.0/kata-static-3.17.0-arm64.tar.xz +KATA_BINARY_PACKAGE := https://github.com/kata-containers/kata-containers/releases/download/3.28.0/kata-static-3.28.0-arm64.tar.zst CLOUD_HYPERVISOR_URL := https://github.com/cloud-hypervisor/cloud-hypervisor/releases/download/v52.0/cloud-hypervisor-static-aarch64 # SHA256 of the v52.0 aarch64 static binary (verified locally from the # upstream release artifact). Bump alongside CLOUD_HYPERVISOR_URL. @@ -410,11 +410,11 @@ integration: .PHONY: fetch-default-kernel fetch-default-kernel: @mkdir -p .local/ bin/ -ifeq (,$(wildcard .local/kata.tar.gz)) - @curl -SsL -o .local/kata.tar.gz ${KATA_BINARY_PACKAGE} +ifeq (,$(wildcard .local/kata.tar)) + @curl -SsL -o .local/kata.tar ${KATA_BINARY_PACKAGE} endif ifeq (,$(wildcard .local/vmlinux-$(KERNEL_ARCH))) - @tar -zxf .local/kata.tar.gz -C .local/ --strip-components=1 + @tar -xf .local/kata.tar -C .local/ --strip-components=1 @cp -L .local/opt/kata/share/kata-containers/vmlinux.container .local/vmlinux-$(KERNEL_ARCH) endif ifeq (,$(wildcard bin/vmlinux-$(KERNEL_ARCH))) From 21f1f16eaaacc051bbc5be6566814cb606de5698 Mon Sep 17 00:00:00 2001 From: Aaron Paterson Date: Wed, 12 Aug 2026 05:25:08 +0000 Subject: [PATCH 04/12] Run each integration test in its own scratch directory A test's rootfs clones, init block, and writable layers land in a directory the runner creates for it and deletes when it finishes, so a test holds disk only while it runs at any concurrency. The run-shared directory holds the unpacked images every bootstrap clones from, and is removed when the run ends. --- Sources/Integration/PodTests.swift | 7 +++-- Sources/Integration/Suite.swift | 43 +++++++++++++----------------- 2 files changed, 21 insertions(+), 29 deletions(-) diff --git a/Sources/Integration/PodTests.swift b/Sources/Integration/PodTests.swift index ae1caec8..7dff20fa 100644 --- a/Sources/Integration/PodTests.swift +++ b/Sources/Integration/PodTests.swift @@ -2228,10 +2228,9 @@ extension IntegrationSuite { /// (directory-share) rootfs. Assumes a single-layer image (the alpine /// image used by the suite) so no OCI whiteout processing is required. /// - /// The extracted dir lives under `Self.testDir`; do NOT `defer`-remove it - /// here — virtiofsd shares it for the whole test. It is swept by - /// `bootstrap`'s `maxConcurrency == 1` reaper on the next test and by the - /// suite-end `removeItem(at: Self.testDir)`. + /// The extracted dir lives under the test's scratch directory; do NOT + /// `defer`-remove it here — virtiofsd shares it for the whole test. The + /// runner removes the scratch directory when the test finishes. private func unpackRootfsDirectory(_ image: Containerization.Image, testID: String) async throws -> Containerization.Mount { let dir = Self.testDir.appending(component: "\(testID)-rootfs-dir") try? FileManager.default.removeItem(at: dir) diff --git a/Sources/Integration/Suite.swift b/Sources/Integration/Suite.swift index e43cf372..84580dba 100644 --- a/Sources/Integration/Suite.swift +++ b/Sources/Integration/Suite.swift @@ -135,7 +135,19 @@ struct IntegrationSuite: AsyncParsableCommand { FileManager.default.uniqueTemporaryDirectory(create: true) }() + /// The scratch directory of the test running on this task. The runner + /// creates one per test and deletes it when the test finishes, so a + /// test's clones and layers hold disk only while it runs. + @TaskLocal static var currentTestDir: URL? + static var testDir: URL { + currentTestDir ?? _testDir + } + + /// The run-shared directory holding unpacked images, which every test's + /// bootstrap clones from. It lives for the whole run and is removed at + /// the end. + static var imageCacheDir: URL { _testDir } @@ -233,7 +245,7 @@ struct IntegrationSuite: AsyncParsableCommand { let platform = Platform(arch: "arm64", os: "linux", variant: "v8") // Unpack to shared location with coordination to prevent concurrent unpacks - let fsPath = Self.testDir.appending(component: image.digest) + let fsPath = Self.imageCacheDir.appending(component: image.digest) let fs = try await Self.unpackCoordinator.unpack(key: fsPath.absolutePath()) { do { let unpacker = EXT4Unpacker(capacityInBytes: 2.gib()) @@ -251,29 +263,6 @@ struct IntegrationSuite: AsyncParsableCommand { } } - // Reap any per-test artifacts left over from prior tests. With - // `--max-concurrency 1` (linux-integration default) this runs after - // the previous test has fully completed, so it's race-free; on - // macOS where tests can run in parallel we just keep all files — - // disk usage isn't a concern there. Each per-test bootstrap clones - // a ~2GB rootfs and a ~512MB initfs, so without reaping the dev - // container fills its CoW layer in ~10 tests. - if self.maxConcurrency == 1 { - // contentsOfDirectory reports paths under /private, while testDir is - // built from FileManager's /var view of the same directory, so both - // sides are resolved before comparing. - let preserve = fsPath.resolvingSymlinksInPathWithPrivate().absolutePath() - if let entries = try? FileManager.default.contentsOfDirectory( - at: Self.testDir, - includingPropertiesForKeys: nil - ) { - for url in entries - where url.resolvingSymlinksInPathWithPrivate().absolutePath() != preserve { - try? FileManager.default.removeItem(at: url) - } - } - } - // Clone to test-specific path let clPath = Self.testDir.appending(component: "\(testID).ext4").absolutePath() try? FileManager.default.removeItem(atPath: clPath) @@ -658,11 +647,14 @@ struct IntegrationSuite: AsyncParsableCommand { for _ in 0.. Date: Wed, 12 Aug 2026 15:00:44 +0000 Subject: [PATCH 05/12] Give the machine's storage the shape its containers have A container's storage is its rootfs, an optional writable layer, and its mounts; a machine's is its containers' plus the volumes they share. One generic shape describes both the Mount values a machine is configured with and the AttachedFilesystem values it reports once attached, so converting between the two is a map over the structure and the roles cannot drift between configuration and attachment. Device addresses are allocated walking the same sorted order the devices are created in, on both backends. Consumers read roles instead of list positions: the spec builders take a container's mounts without prefix arithmetic, pod volumes mount by name, and a cloud-hypervisor volume's virtiofsd is held by a machine-lifetime reference alongside its containers' reference counts. --- .../Containerization/CHHotplugProvider.swift | 79 ++++++----- .../CHVirtualMachineInstance.swift | 127 ++++++++++-------- .../CHVirtualMachineManager.swift | 2 +- .../Containerization/ContainerStorage.swift | 107 +++++++++++++++ .../Containerization/HotplugProvider.swift | 2 +- Sources/Containerization/LinuxContainer.swift | 51 +++---- Sources/Containerization/LinuxPod.swift | 98 +++++++------- .../Containerization/VMConfiguration.swift | 10 +- .../VZVirtualMachineInstance.swift | 100 +++++++------- .../VZVirtualMachineManager.swift | 2 +- .../VirtualMachineInstance.swift | 5 +- 11 files changed, 351 insertions(+), 232 deletions(-) create mode 100644 Sources/Containerization/ContainerStorage.swift diff --git a/Sources/Containerization/CHHotplugProvider.swift b/Sources/Containerization/CHHotplugProvider.swift index 870fb1ff..380cba76 100644 --- a/Sources/Containerization/CHHotplugProvider.swift +++ b/Sources/Containerization/CHHotplugProvider.swift @@ -27,8 +27,8 @@ import Synchronization /// /// Handles both block (`vm.add-disk`) and virtiofs (`vm.add-fs`, with one /// `virtiofsd` per unique source-hash tag) hotplug, plus the matching -/// `vm.remove-device` teardown. Owns the per-VM mount registry so -/// `CHVirtualMachineInstance.mounts` can forward to it. +/// `vm.remove-device` teardown. Owns the machine's storage so +/// `CHVirtualMachineInstance.storage` can forward to it. final class CHHotplugProvider: HotplugProvider { struct HotplugRecord: Sendable { let chDeviceId: String @@ -50,7 +50,7 @@ final class CHHotplugProvider: HotplugProvider { private let workDir: URL private let virtiofsdBinaryOverride: URL? private let allocator: any AddressAllocator - private let _mounts: Mutex<[String: [AttachedFilesystem]]> + private let _storage: Mutex private let _records: Mutex<[String: [HotplugRecord]]> private let _tags: Mutex<[String: VirtiofsdTagState]> /// Serializes per-tag virtiofsd spawn so a concurrent hotplug for the @@ -65,14 +65,14 @@ final class CHHotplugProvider: HotplugProvider { workDir: URL, virtiofsdBinary: URL?, allocator: any AddressAllocator, - initialMounts: [String: [AttachedFilesystem]], + initialStorage: MachineAttachments, logger: Logger? ) { self.client = client self.workDir = workDir self.virtiofsdBinaryOverride = virtiofsdBinary self.allocator = allocator - self._mounts = Mutex(initialMounts) + self._storage = Mutex(initialStorage) self._records = Mutex([:]) self._tags = Mutex([:]) self.spawnLock = AsyncLock() @@ -81,14 +81,14 @@ final class CHHotplugProvider: HotplugProvider { // MARK: - Read accessors - var mounts: [String: [AttachedFilesystem]] { - _mounts.withLock { $0 } + var storage: MachineAttachments { + _storage.withLock { $0 } } - func withMountRegistry( - _ body: (inout sending [String: [AttachedFilesystem]]) throws -> sending T + func withStorage( + _ body: (inout sending MachineAttachments) throws -> sending T ) rethrows -> T { - try _mounts.withLock(body) + try _storage.withLock(body) } // MARK: - HotplugProvider conformance @@ -151,12 +151,13 @@ final class CHHotplugProvider: HotplugProvider { } func registerMounts(id: String, rootfs: AttachedFilesystem, additionalMounts: [Mount]) throws { - var attached: [AttachedFilesystem] = [rootfs] + var mounts: [AttachedFilesystem] = [] for mount in additionalMounts { - attached.append(try AttachedFilesystem(mount: mount, allocator: allocator)) + mounts.append(try AttachedFilesystem(mount: mount, allocator: allocator)) } - _mounts.withLock { - $0[id, default: []].append(contentsOf: attached) + let container = ContainerAttachments(rootfs: rootfs, mounts: mounts) + _storage.withLock { + $0.containers[id] = container } } @@ -190,19 +191,10 @@ final class CHHotplugProvider: HotplugProvider { } } - // Drop block-derived AttachedFilesystem entries for `id`. Block entries - // are the ones whose source was rewritten to "/dev/vd" by - // `hotplug(_:)` (or by AttachedFilesystem(mount:allocator:) for an - // additionalMount of type virtio-blk). - _mounts.withLock { state in - guard var perID = state[id] else { return } - perID.removeAll { $0.source.hasPrefix("/dev/vd") } - if perID.isEmpty { - state.removeValue(forKey: id) - } else { - state[id] = perID - } - } + // The container's devices are gone, so the container leaves the + // registry with them; its shares are released separately and their + // processes reference-counted through `_tags`. + _ = _storage.withLock { $0.containers.removeValue(forKey: id) } } func hotplugVirtioFS(_ mounts: [Mount], id: String) async throws { @@ -226,7 +218,7 @@ final class CHHotplugProvider: HotplugProvider { let chDeviceId = try await ensureVirtiofsDevice(tag: tag, source: source, readonly: readonly) // Record once per tag for this container. The AttachedFilesystem // entries for these mounts are written by registerMounts (the sole - // _mounts writer), so we do NOT touch _mounts here. + // registry writer), so we do NOT touch the storage here. _records.withLock { $0[id, default: []].append(HotplugRecord(chDeviceId: chDeviceId, kind: .virtiofs(tag: tag))) } @@ -338,16 +330,17 @@ final class CHHotplugProvider: HotplugProvider { try? FileManager.default.removeItem(at: socket) } - // Drop virtiofs AttachedFilesystem entries for `id`. AttachedFilesystem - // sets `type = mount.type` which for a `.virtiofs` mount is "virtiofs". - _mounts.withLock { state in - guard var perID = state[id] else { return } - perID.removeAll { $0.type == "virtiofs" } - if perID.isEmpty { - state.removeValue(forKey: id) - } else { - state[id] = perID + // Drop the container's virtiofs entries. A container whose rootfs is + // itself a share leaves the registry whole; one that keeps block + // devices keeps its entry with the share entries dropped. + _storage.withLock { state in + guard var container = state.containers[id] else { return } + if container.rootfs.type == "virtiofs" { + state.containers.removeValue(forKey: id) + return } + container.mounts.removeAll { $0.type == "virtiofs" } + state.containers[id] = container } } @@ -358,15 +351,21 @@ final class CHHotplugProvider: HotplugProvider { /// is the user-supplied `FsConfig.id` (which `vm.remove-device` keys on). /// `ownerIds` are the container ids that count toward this tag's refcount; /// each gets a `HotplugRecord` so `releaseVirtioFS(id:)` walks them - /// uniformly. + /// uniformly. `machineHeld` adds one reference nothing releases, for a + /// share the machine itself owns (a volume). func recordBootTimeVirtiofs( tag: String, process: VirtiofsdProcess, chDeviceId: String, - ownerIds: [String] + ownerIds: [String], + machineHeld: Bool ) { _tags.withLock { - $0[tag] = VirtiofsdTagState(process: process, refcount: ownerIds.count, chDeviceId: chDeviceId) + $0[tag] = VirtiofsdTagState( + process: process, + refcount: ownerIds.count + (machineHeld ? 1 : 0), + chDeviceId: chDeviceId + ) } _records.withLock { records in for id in ownerIds { diff --git a/Sources/Containerization/CHVirtualMachineInstance.swift b/Sources/Containerization/CHVirtualMachineInstance.swift index 122f592a..e363d5ca 100644 --- a/Sources/Containerization/CHVirtualMachineInstance.swift +++ b/Sources/Containerization/CHVirtualMachineInstance.swift @@ -46,7 +46,7 @@ public final class CHVirtualMachineInstance: Sendable { public struct Configuration: Sendable { public var cpus: Int public var memoryInBytes: UInt64 - public var mountsByID: [String: [Mount]] + public var storage: MachineMounts public var interfaces: [any Interface] public var kernel: Kernel? public var initialFilesystem: Mount? @@ -56,7 +56,7 @@ public final class CHVirtualMachineInstance: Sendable { public init() { self.cpus = 4 self.memoryInBytes = 1024 * 1024 * 1024 - self.mountsByID = [:] + self.storage = MachineMounts() self.interfaces = [] } } @@ -65,8 +65,8 @@ public final class CHVirtualMachineInstance: Sendable { /// `start()`'s `VmConfig.disks` ordering matches the allocator letters. struct BootDisk: Sendable { let mount: Mount - let containerId: String? // nil for rootfs - let letter: Character + let chId: String + let readonly: Bool } // MARK: - State @@ -76,8 +76,8 @@ public final class CHVirtualMachineInstance: Sendable { _state.withLock { $0 } } - public var mounts: [String: [AttachedFilesystem]] { - hotplug.mounts + public var storage: MachineAttachments { + hotplug.storage } /// Cloud-hypervisor exposes one virtio-fs device per source-hash tag, so @@ -167,9 +167,9 @@ public final class CHVirtualMachineInstance: Sendable { ) self.workDir = workDir - // 2. Block allocator + boot inventory. Walks rootfs first, then - // mountsByID sorted by container id, allocating disk letters in - // that order. The same allocator is later handed to the hotplug + // 2. Block allocator + boot inventory. Walks rootfs first, then the + // machine's storage in its device order, allocating disk letters + // as it goes. The same allocator is later handed to the hotplug // provider so runtime add-disk picks up where boot wiring left off. let allocator = Character.blockDeviceTagAllocator() let inventory = try config.bootInventory(allocator: allocator) @@ -201,14 +201,14 @@ public final class CHVirtualMachineInstance: Sendable { logger: logger ?? Logger(label: "CloudHypervisor.Client") ) - // 5. Hotplug provider — owns the mount registry, seeded with the + // 5. Hotplug provider — owns the machine's storage, seeded with the // boot inventory so registerMounts can append to it. self.hotplug = CHHotplugProvider( client: self.client, workDir: workDir, virtiofsdBinary: virtiofsdBinary, allocator: allocator, - initialMounts: inventory.attachments, + initialStorage: inventory.attachments, logger: logger ) @@ -222,11 +222,11 @@ public final class CHVirtualMachineInstance: Sendable { self._stdioPool = Mutex([:]) } - /// Mutate the mount registry. Forwards to the hotplug provider, which + /// Mutate the storage registry. Forwards to the hotplug provider, which /// owns the registry. Kept on the instance for parity with the macOS - /// path's `withMountRegistry` API. - func withMountRegistry(_ body: (inout sending [String: [AttachedFilesystem]]) throws -> sending T) rethrows -> T { - try hotplug.withMountRegistry(body) + /// path's `withStorage` API. + func withStorage(_ body: (inout sending MachineAttachments) throws -> sending T) rethrows -> T { + try hotplug.withStorage(body) } } @@ -510,34 +510,46 @@ extension CHVirtualMachineInstance { throw ContainerizationError(.invalidArgument, message: "initialFilesystem is required for cloud-hypervisor backend") } - // Disks: rootfs forced read-only at the device level; container disks + // Disks: rootfs forced read-only at the device level; other disks // honor their `ro` option through chDiskConfig. var disks: [CloudHypervisor.DiskConfig] = [] for bd in bootDisks { - let chId = bd.containerId.map { "blk-\($0)-\(bd.letter)" } ?? "rootfs" - if var disk = bd.mount.chDiskConfig(id: chId) { - if bd.containerId == nil { + if var disk = bd.mount.chDiskConfig(id: bd.chId) { + if bd.readonly { disk.readonly = true } disks.append(disk) } } - // Virtiofs: group all .virtiofs mounts in mountsByID by source-hash - // tag, spawn one virtiofsd per tag, build matching FsConfigs. - var byTag: [String: (mounts: [Mount], owners: [String])] = [:] - for cid in config.mountsByID.keys.sorted() { - guard let mounts = config.mountsByID[cid] else { continue } - for mount in mounts { - guard case .virtiofs = mount.runtimeOptions else { continue } - let tag = try hashFilePath(path: mount.source) - var entry = byTag[tag] ?? (mounts: [], owners: []) - entry.mounts.append(mount) - if !entry.owners.contains(cid) { - entry.owners.append(cid) + // Virtiofs: group all virtiofs mounts in the machine's storage by + // source-hash tag, spawn one virtiofsd per tag, build matching + // FsConfigs. A volume's tag is the machine's own, held for its + // lifetime. + var byTag: [String: (mounts: [Mount], owners: [String], machineHeld: Bool)] = [:] + func groupVirtiofs(_ mount: Mount, owner: String?) throws { + guard case .virtiofs = mount.runtimeOptions else { return } + let tag = try hashFilePath(path: mount.source) + var entry = byTag[tag] ?? (mounts: [], owners: [], machineHeld: false) + entry.mounts.append(mount) + if let owner { + if !entry.owners.contains(owner) { + entry.owners.append(owner) } - byTag[tag] = entry + } else { + entry.machineHeld = true } + byTag[tag] = entry + } + for cid in config.storage.containers.keys.sorted() { + guard let container = config.storage.containers[cid] else { continue } + for mount in container.all { + try groupVirtiofs(mount, owner: cid) + } + } + for name in config.storage.volumes.keys.sorted() { + guard let mount = config.storage.volumes[name] else { continue } + try groupVirtiofs(mount, owner: nil) } var fsConfigs: [CloudHypervisor.FsConfig] = [] @@ -569,7 +581,8 @@ extension CHVirtualMachineInstance { tag: tag, process: process, chDeviceId: chDeviceId, - ownerIds: entry.owners + ownerIds: entry.owners, + machineHeld: entry.machineHeld ) fsConfigs.append( @@ -714,40 +727,46 @@ extension CHVirtualMachineInstance { // MARK: - Boot inventory extension CHVirtualMachineInstance.Configuration { - /// Walks boot-time mounts in deterministic order (rootfs first, then - /// `mountsByID` sorted by container id, then each container's mounts in - /// input order), allocating disk letters for virtio-blk mounts and seeding - /// the per-container `AttachedFilesystem` registry. + /// Walks boot-time storage in the machine's device order (rootfs first, + /// then containers sorted by id, each in role order, then volumes sorted + /// by name), allocating disk letters for virtio-blk mounts and seeding + /// the machine's `AttachedFilesystem` registry. /// /// The allocator is shared with the runtime hotplug provider, so block /// hotplug picks up at the next free letter after boot. func bootInventory( allocator: any AddressAllocator - ) throws -> (attachments: [String: [AttachedFilesystem]], bootDisks: [CHVirtualMachineInstance.BootDisk]) { + ) throws -> (attachments: MachineAttachments, bootDisks: [CHVirtualMachineInstance.BootDisk]) { var bootDisks: [CHVirtualMachineInstance.BootDisk] = [] - var attachments: [String: [AttachedFilesystem]] = [:] - // Rootfs is not part of mountsByID. If it's a block device, it claims - // the first letter (vda) so the kernel cmdline `root=/dev/vda` is right. + // The rootfs is the machine's own, not part of its containers' + // storage. If it's a block device, it claims the first letter (vda) + // so the kernel cmdline `root=/dev/vda` is right. if let rootfs = self.initialFilesystem, rootfs.isBlock { - let letter = try allocator.allocate() - bootDisks.append(.init(mount: rootfs, containerId: nil, letter: letter)) + _ = try allocator.allocate() + bootDisks.append(.init(mount: rootfs, chId: "rootfs", readonly: true)) } - for cid in self.mountsByID.keys.sorted() { - guard let mounts = self.mountsByID[cid] else { continue } - var perContainer: [AttachedFilesystem] = [] - for mount in mounts { - let attached = try AttachedFilesystem(mount: mount, allocator: allocator) - if mount.isBlock, let letter = attached.source.last { - bootDisks.append(.init(mount: mount, containerId: cid, letter: letter)) - } - perContainer.append(attached) + func attach(_ mount: Mount, chId: (Character) -> String) throws -> AttachedFilesystem { + let attached = try AttachedFilesystem(mount: mount, allocator: allocator) + if mount.isBlock, let letter = attached.source.last { + bootDisks.append(.init(mount: mount, chId: chId(letter), readonly: false)) } - attachments[cid] = perContainer + return attached + } + + var containers: [String: ContainerAttachments] = [:] + for cid in self.storage.containers.keys.sorted() { + guard let container = self.storage.containers[cid] else { continue } + containers[cid] = try container.map { try attach($0, chId: { "blk-\(cid)-\($0)" }) } + } + var volumes: [String: AttachedFilesystem] = [:] + for name in self.storage.volumes.keys.sorted() { + guard let mount = self.storage.volumes[name] else { continue } + volumes[name] = try attach(mount, chId: { "vol-\(name)-\($0)" }) } - return (attachments, bootDisks) + return (MachineAttachments(containers: containers, volumes: volumes), bootDisks) } } #endif diff --git a/Sources/Containerization/CHVirtualMachineManager.swift b/Sources/Containerization/CHVirtualMachineManager.swift index ba20b5d1..c444620d 100644 --- a/Sources/Containerization/CHVirtualMachineManager.swift +++ b/Sources/Containerization/CHVirtualMachineManager.swift @@ -99,7 +99,7 @@ public struct CHVirtualMachineManager: VirtualMachineManager { instanceConfig.cpus = vmConfig.cpus instanceConfig.memoryInBytes = vmConfig.memoryInBytes instanceConfig.interfaces = vmConfig.interfaces - instanceConfig.mountsByID = vmConfig.mountsByID + instanceConfig.storage = vmConfig.storage instanceConfig.bootLog = vmConfig.bootLog instanceConfig.extensions = vmConfig.extensions instanceConfig.kernel = kernel diff --git a/Sources/Containerization/ContainerStorage.swift b/Sources/Containerization/ContainerStorage.swift new file mode 100644 index 00000000..4d8de2ed --- /dev/null +++ b/Sources/Containerization/ContainerStorage.swift @@ -0,0 +1,107 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +/// A container's storage, by role. +/// +/// The same shape describes storage at both stages of its life: as the +/// `Mount` values a container is configured with, and as the +/// `AttachedFilesystem` values the machine reports once they are attached. +/// Converting between the two is a `map` over the structure, so the roles +/// cannot drift apart between configuration and attachment. +public struct ContainerStorage: Sendable { + /// The container's root filesystem. + public var rootfs: Value + + /// The writable layer mounted over the rootfs as the upper side of an + /// overlay, when the container has one. + public var writableLayer: Value? + + /// The container's remaining mounts, in configuration order. + public var mounts: [Value] + + public init(rootfs: Value, writableLayer: Value? = nil, mounts: [Value] = []) { + self.rootfs = rootfs + self.writableLayer = writableLayer + self.mounts = mounts + } + + /// Every value in the structure: the rootfs, the writable layer when + /// present, then the mounts, in that order. + public var all: [Value] { + var values = [rootfs] + if let writableLayer { + values.append(writableLayer) + } + values.append(contentsOf: mounts) + return values + } + + /// The storage with `transform` applied to every value, each keeping + /// its role. + public func map(_ transform: (Value) throws -> U) rethrows -> ContainerStorage { + ContainerStorage( + rootfs: try transform(rootfs), + writableLayer: try writableLayer.map(transform), + mounts: try mounts.map(transform) + ) + } +} + +/// A container's storage as configured, before its machine exists. +public typealias ContainerMounts = ContainerStorage + +/// A container's storage as attached to its machine. +public typealias ContainerAttachments = ContainerStorage + +/// The storage a machine carries: each container's, and the resources its +/// containers share. +public struct MachineStorage: Sendable { + /// Each container's storage, by container ID. + public var containers: [String: ContainerStorage] + + /// Volumes shared by the machine's containers, by volume name. + public var volumes: [String: Value] + + public init(containers: [String: ContainerStorage] = [:], volumes: [String: Value] = [:]) { + self.containers = containers + self.volumes = volumes + } + + /// Every value the machine carries: containers sorted by ID, each in + /// role order, then volumes sorted by name. Walks that allocate device + /// addresses and walks that create the devices use this one order, so an + /// address always names the device it was handed out for. + public var ordered: [Value] { + containers.keys.sorted().flatMap { containers[$0]?.all ?? [] } + + volumes.keys.sorted().compactMap { volumes[$0] } + } + + /// The storage with `transform` applied to every value, each keeping its + /// place. The transform runs in no particular order, so an allocating + /// conversion walks the fields sorted itself. + public func map(_ transform: (Value) throws -> U) rethrows -> MachineStorage { + MachineStorage( + containers: try containers.mapValues { try $0.map(transform) }, + volumes: try volumes.mapValues(transform) + ) + } +} + +/// A machine's storage as configured, before it exists. +public typealias MachineMounts = MachineStorage + +/// A machine's storage as attached. +public typealias MachineAttachments = MachineStorage diff --git a/Sources/Containerization/HotplugProvider.swift b/Sources/Containerization/HotplugProvider.swift index f535ce3f..0835077f 100644 --- a/Sources/Containerization/HotplugProvider.swift +++ b/Sources/Containerization/HotplugProvider.swift @@ -26,7 +26,7 @@ public protocol HotplugProvider: Sendable { /// - Returns: The attached filesystem with the device path in the guest func hotplug(_ block: Mount, id: String) async throws -> AttachedFilesystem - /// Register mounts for a container in the VM's mount registry. + /// Register mounts for a container in the machine's storage. /// - Parameters: /// - id: The container ID /// - rootfs: The rootfs attachment from hotplug diff --git a/Sources/Containerization/LinuxContainer.swift b/Sources/Containerization/LinuxContainer.swift index 34964fcd..55bbb39a 100644 --- a/Sources/Containerization/LinuxContainer.swift +++ b/Sources/Containerization/LinuxContainer.swift @@ -537,24 +537,14 @@ extension LinuxContainer { } private func mountRootfs( - attachments: [AttachedFilesystem], + attached: ContainerAttachments, rootfsPath: String, agent: VirtualMachineAgent ) async throws { - guard let rootfsAttachment = attachments.first else { - throw ContainerizationError(.notFound, message: "rootfs mount not found") - } + let rootfsAttachment = attached.rootfs - if self.writableLayer != nil { + if let writableAttachment = attached.writableLayer { // Set up overlayfs with image as lower layer and writable layer as upper. - guard attachments.count >= 2 else { - throw ContainerizationError( - .notFound, - message: "writable layer mount not found" - ) - } - let writableAttachment = attachments[1] - let lowerPath = "/run/container/\(self.id)/lower" let upperMountPath = "/run/container/\(self.id)/upper" let upperPath = "/run/container/\(self.id)/upper/diff" @@ -623,17 +613,18 @@ extension LinuxContainer { // This is dumb, but alas. let fileMountContextHolder = Mutex(fileMountContext) - // Build the list of mounts to attach to the VM. - var containerMounts = [modifiedRootfs] + fileMountContext.transformedMounts - if let writableLayer = self.writableLayer { - containerMounts.insert(writableLayer, at: 1) - } + // Build the container's storage to attach to the VM. + let containerStorage = ContainerMounts( + rootfs: modifiedRootfs, + writableLayer: self.writableLayer, + mounts: fileMountContext.transformedMounts + ) let vmConfig = VMConfiguration( cpus: vmCpus, memoryInBytes: vmMemory, interfaces: self.interfaces, - mountsByID: [self.id: containerMounts], + storage: MachineMounts(containers: [self.id: containerStorage]), bootLog: self.config.bootLog, nestedVirtualization: self.config.virtualization ) @@ -643,7 +634,7 @@ extension LinuxContainer { do { try await vm.start() - let mountsForAgent = containerMounts + let storageForAgent = containerStorage try await vm.withAgent { agent in try await agent.standardSetup() @@ -656,7 +647,7 @@ extension LinuxContainer { // with zero shares), but the cloud-hypervisor backend // only spawns virtiofsd when shares exist; mounting an // unbacked tag fails with EINVAL. - let hasVirtiofsMount = mountsForAgent.contains { mount in + let hasVirtiofsMount = storageForAgent.all.contains { mount in if case .virtiofs = mount.runtimeOptions { return true } return false } @@ -671,7 +662,7 @@ extension LinuxContainer { // gets populated. if vm.virtiofsLayout == .perTag { try await agent.mkdir(path: "/run/virtiofs", all: true, perms: 0o755) - let virtiofsAttachments = (vm.mounts[self.id] ?? []).filter { $0.type == "virtiofs" } + let virtiofsAttachments = (vm.storage.containers[self.id]?.all ?? []).filter { $0.type == "virtiofs" } let uniqueTags = Set(virtiofsAttachments.map(\.source)) for tag in uniqueTags { let dest = "/run/virtiofs/\(tag)" @@ -695,18 +686,17 @@ extension LinuxContainer { } } - guard let attachments = vm.mounts[self.id] else { + guard let attached = vm.storage.containers[self.id] else { throw ContainerizationError(.notFound, message: "rootfs mount not found") } let rootfsPath = Self.guestRootfsPath(self.id) - try await self.mountRootfs(attachments: attachments, rootfsPath: rootfsPath, agent: agent) + try await self.mountRootfs(attached: attached, rootfsPath: rootfsPath, agent: agent) // Mount file mount holding directories under /run. if fileMountContext.hasFileMounts { - let containerMounts = vm.mounts[self.id] ?? [] var ctx = fileMountContextHolder.withLock { $0 } try await ctx.mountHoldingDirectories( - vmMounts: containerMounts, + vmMounts: attached.mounts, agent: agent ) fileMountContextHolder.withLock { $0 = ctx } @@ -764,15 +754,12 @@ extension LinuxContainer { let agent = try await createdState.vm.dialAgent() do { var spec = self.generateRuntimeSpec() - // We don't need the rootfs (or writable layer), nor do OCI runtimes want it included. - // Also filter out file mount holding directories. We'll mount those separately under /run. + // Filter out file mount holding directories. We'll mount those separately under /run. // Transform virtiofs mounts to bind mounts from /run/virtiofs/{tag} - let containerMounts = createdState.vm.mounts[self.id] ?? [] + let containerMounts = createdState.vm.storage.containers[self.id]?.mounts ?? [] let holdingTags = createdState.fileMountContext.holdingDirectoryTags - // Drop rootfs, and writable layer if present. - let mountsToSkip = self.writableLayer != nil ? 2 : 1 var mounts: [ContainerizationOCI.Mount] = - containerMounts.dropFirst(mountsToSkip) + containerMounts .filter { !holdingTags.contains($0.source) } .map { attached -> ContainerizationOCI.Mount in if attached.type == "virtiofs" { diff --git a/Sources/Containerization/LinuxPod.swift b/Sources/Containerization/LinuxPod.swift index 6275a4d4..fa4d1785 100644 --- a/Sources/Containerization/LinuxPod.swift +++ b/Sources/Containerization/LinuxPod.swift @@ -446,24 +446,28 @@ extension LinuxPod { // the container's bind mounts from /run/virtiofs/ fail // with ENOENT. // - // Derive the tags from the additional mounts directly rather - // than from vm.mounts[id], so this is independent of the - // rootfs (which may be virtiofs or virtio-blk) and of mount - // ordering. The rootfs is mounted at /run/container//rootfs - // and is never consumed from /run/virtiofs. + // Derive the new tags from the additional mounts being added; + // the machine's storage names what is already shared. let newVirtiofsTags = try virtioFSMounts.map { try hashFilePath(path: $0.source) } if !newVirtiofsTags.isEmpty { try await agent.mkdir(path: "/run/virtiofs", all: true, perms: 0o755) if vm.virtiofsLayout == .perTag { // Tags already mounted in the guest at boot or by a - // prior hotplug (i.e. present on another container). - let alreadyMounted = Set( - vm.mounts - .filter { $0.key != id } - .values.flatMap { $0 } - .filter { $0.type == "virtiofs" } - .map { $0.source } - ) + // prior hotplug (i.e. present on another container or + // a machine volume). + let alreadyMounted: Set = { + var mounted = Set( + vm.storage.containers + .filter { $0.key != id } + .values.flatMap { $0.all } + .filter { $0.type == "virtiofs" } + .map { $0.source }) + mounted.formUnion( + vm.storage.volumes.values + .filter { $0.type == "virtiofs" } + .map { $0.source }) + return mounted + }() var seen: Set = [] for tag in newVirtiofsTags where !alreadyMounted.contains(tag) && seen.insert(tag).inserted { @@ -493,7 +497,7 @@ extension LinuxPod { } if fileMountContext.hasFileMounts { - let containerMounts = vm.mounts[id] ?? [] + let containerMounts = vm.storage.containers[id]?.mounts ?? [] try await updatedFileMountContext.mountHoldingDirectories( vmMounts: containerMounts, agent: agent @@ -557,20 +561,23 @@ extension LinuxPod { try await self.state.withLock { state in try state.phase.validateForCreate() - // Build mountsByID for all containers. + // Build the machine's storage from its containers. // Strip "ro" from rootfs options - we handle readonly via the OCI spec's // root.readonly field and remount in vmexec after setup is complete. // Use transformedMounts from fileMountContext (file mounts become directory shares). - var mountsByID: [String: [Mount]] = [:] + var machineStorage = MachineMounts() for (id, container) in state.containers { var modifiedRootfs = container.rootfs modifiedRootfs.options.removeAll(where: { $0 == "ro" }) - // Filter out shared mounts — those are handled separately as pod volume bind mounts. + // Shared mounts are handled separately as pod volume bind mounts. let containerMounts = container.fileMountContext.transformedMounts.filter { if case .shared = $0.runtimeOptions { return false } return true } - mountsByID[id] = [modifiedRootfs] + containerMounts + machineStorage.containers[id] = ContainerMounts( + rootfs: modifiedRootfs, + mounts: containerMounts + ) } // Validate pod volume names are unique. @@ -597,9 +604,8 @@ extension LinuxPod { } } } - let podVolumeMounts = self.config.volumes.map { $0.toMount() } - if !podVolumeMounts.isEmpty { - mountsByID[self.id] = podVolumeMounts + for volume in self.config.volumes { + machineStorage.volumes[volume.name] = volume.toMount() } // Capture into an immutable `let` so the value is safely usable @@ -608,18 +614,16 @@ extension LinuxPod { // only attaches a virtiofs device when shares are configured, // so mounting an unbacked /run/virtiofs would fail with EINVAL // on the CH backend. - let hasVirtiofsMount = mountsByID.values.contains { mounts in - mounts.contains { mount in - if case .virtiofs = mount.runtimeOptions { return true } - return false - } + let hasVirtiofsMount = machineStorage.ordered.contains { mount in + if case .virtiofs = mount.runtimeOptions { return true } + return false } var vmConfig = VMConfiguration( cpus: self.config.cpus, memoryInBytes: self.config.memoryInBytes, interfaces: self.config.interfaces, - mountsByID: mountsByID, + storage: machineStorage, bootLog: self.config.bootLog, nestedVirtualization: self.config.virtualization ) @@ -649,19 +653,17 @@ extension LinuxPod { // /run/virtiofs/. See LinuxContainer for the // VZ vs. CH model split. var seenTags: Set = [] - for (_, attached) in vm.mounts { - for entry in attached where entry.type == "virtiofs" { - guard seenTags.insert(entry.source).inserted else { continue } - let dest = "/run/virtiofs/\(entry.source)" - try await agent.mkdir(path: dest, all: true, perms: 0o755) - try await agent.mount( - ContainerizationOCI.Mount( - type: "virtiofs", - source: entry.source, - destination: dest, - options: [] - )) - } + for entry in vm.storage.ordered where entry.type == "virtiofs" { + guard seenTags.insert(entry.source).inserted else { continue } + let dest = "/run/virtiofs/\(entry.source)" + try await agent.mkdir(path: dest, all: true, perms: 0o755) + try await agent.mount( + ContainerizationOCI.Mount( + type: "virtiofs", + source: entry.source, + destination: dest, + options: [] + )) } } else { try await agent.mount( @@ -728,10 +730,10 @@ extension LinuxPod { // Mount all container rootfs for (_, container) in containers { - guard let attachments = vm.mounts[container.id], let rootfsAttachment = attachments.first else { + guard let attached = vm.storage.containers[container.id] else { throw ContainerizationError(.notFound, message: "rootfs mount not found for container \(container.id)") } - var rootfs = rootfsAttachment.to + var rootfs = attached.rootfs.to rootfs.destination = Self.guestRootfsPath(container.id) try await agent.mount(rootfs) } @@ -740,7 +742,7 @@ extension LinuxPod { for (id, container) in containers { if container.fileMountContext.hasFileMounts { var ctx = container.fileMountContext - let containerMounts = vm.mounts[id] ?? [] + let containerMounts = vm.storage.containers[id]?.mounts ?? [] try await ctx.mountHoldingDirectories( vmMounts: containerMounts, agent: agent @@ -750,15 +752,13 @@ extension LinuxPod { } // Mount pod-level volumes. - let podVolumeAttachments = vm.mounts[self.id] ?? [] - for (index, volume) in self.config.volumes.enumerated() { - guard index < podVolumeAttachments.count else { + for volume in self.config.volumes { + guard let attachment = vm.storage.volumes[volume.name] else { throw ContainerizationError( .notFound, message: "attached filesystem not found for pod volume \"\(volume.name)\"" ) } - let attachment = podVolumeAttachments[index] let guestPath = Self.guestVolumePath(volume.name) try await agent.mount( ContainerizationOCI.Mount( @@ -864,10 +864,10 @@ extension LinuxPod { // We don't need the rootfs, nor do OCI runtimes want it included. // Also filter out file mount holding directories - we mount those separately under /run. // Transform virtiofs mounts to bind mounts from /run/virtiofs/{tag} - let containerMounts = createdState.vm.mounts[containerID] ?? [] + let containerMounts = createdState.vm.storage.containers[containerID]?.mounts ?? [] let holdingTags = container.fileMountContext.holdingDirectoryTags var mounts: [ContainerizationOCI.Mount] = - containerMounts.dropFirst() + containerMounts .filter { !holdingTags.contains($0.source) } .map { attached -> ContainerizationOCI.Mount in if attached.type == "virtiofs" { diff --git a/Sources/Containerization/VMConfiguration.swift b/Sources/Containerization/VMConfiguration.swift index 30faebc4..7a5e8e2c 100644 --- a/Sources/Containerization/VMConfiguration.swift +++ b/Sources/Containerization/VMConfiguration.swift @@ -72,9 +72,9 @@ public struct VMConfiguration: Sendable { public var memoryInBytes: UInt64 /// The network interfaces to attach. public var interfaces: [any Interface] - /// Mounts organized by metadata ID (e.g. container ID). - /// Each ID maps to an array of mounts for that workload. - public var mountsByID: [String: [Mount]] + /// The storage the machine carries: each container's mounts by role, + /// and the volumes its containers share. + public var storage: MachineMounts /// Optional destination for serial boot logs. public var bootLog: BootLog? /// Enable nested virtualization support. If the VirtualMachineManager @@ -89,14 +89,14 @@ public struct VMConfiguration: Sendable { cpus: Int = 4, memoryInBytes: UInt64 = 1024 * 1024 * 1024, interfaces: [any Interface] = [], - mountsByID: [String: [Mount]] = [:], + storage: MachineMounts = MachineMounts(), bootLog: BootLog? = nil, nestedVirtualization: Bool = false ) { self.cpus = cpus self.memoryInBytes = memoryInBytes self.interfaces = interfaces - self.mountsByID = mountsByID + self.storage = storage self.bootLog = bootLog self.nestedVirtualization = nestedVirtualization } diff --git a/Sources/Containerization/VZVirtualMachineInstance.swift b/Sources/Containerization/VZVirtualMachineInstance.swift index 160c5026..670ab512 100644 --- a/Sources/Containerization/VZVirtualMachineInstance.swift +++ b/Sources/Containerization/VZVirtualMachineInstance.swift @@ -28,10 +28,10 @@ import Synchronization public final class VZVirtualMachineInstance: Sendable { public typealias Agent = Vminitd - /// Attached mounts on the virtual machine, organized by metadata ID. - private let _mounts: Mutex<[String: [AttachedFilesystem]]> - public var mounts: [String: [AttachedFilesystem]] { - _mounts.withLock { $0 } + /// The machine's attached storage. + private let _storage: Mutex + public var storage: MachineAttachments { + _storage.withLock { $0 } } /// The underlying Virtualization framework virtual machine. @@ -40,9 +40,9 @@ public final class VZVirtualMachineInstance: Sendable { /// The dispatch queue used for VZ operations. public var vmQueue: DispatchQueue { queue } - /// Mutate the mount registry. - public func withMountRegistry(_ body: (inout sending [String: [AttachedFilesystem]]) throws -> sending T) rethrows -> T { - try _mounts.withLock(body) + /// Mutate the storage registry. + public func withStorage(_ body: (inout sending MachineAttachments) throws -> sending T) rethrows -> T { + try _storage.withLock(body) } /// Serialize VM operations with the instance lock. @@ -73,8 +73,9 @@ public final class VZVirtualMachineInstance: Sendable { public var rosetta: Bool /// Toggle nested virtualization support. public var nestedVirtualization: Bool - /// Mount attachments organized by metadata ID. - public var mountsByID: [String: [Mount]] + /// The machine's storage: each container's mounts by role, and the + /// volumes its containers share. + public var storage: MachineMounts /// Network interface attachments. public var interfaces: [any Interface] /// Kernel image. @@ -91,7 +92,7 @@ public final class VZVirtualMachineInstance: Sendable { self.memoryInBytes = 1024.mib() self.rosetta = false self.nestedVirtualization = false - self.mountsByID = [:] + self.storage = MachineMounts() self.interfaces = [] } } @@ -132,7 +133,7 @@ public final class VZVirtualMachineInstance: Sendable { let allocator = Character.blockDeviceTagAllocator() let (mountAttachments, _) = try config.mountAttachments(allocator: allocator) - self._mounts = Mutex(mountAttachments) + self._storage = Mutex(mountAttachments) self.vm = VZVirtualMachine( configuration: try config.toVZ(allocator: allocator), @@ -153,7 +154,7 @@ public protocol VZInstanceExtension: Sendable { _ config: inout VZVirtualMachineConfiguration, allocator: any AddressAllocator, storageDeviceCount: Int, - mountsByID: [String: [Mount]] + storage: MachineMounts ) throws /// Called after the VZVirtualMachine is created but before start. @@ -168,7 +169,7 @@ extension VZInstanceExtension { _ config: inout VZVirtualMachineConfiguration, allocator: any AddressAllocator, storageDeviceCount: Int, - mountsByID: [String: [Mount]] + storage: MachineMounts ) throws {} public func didCreate(_ instance: VZVirtualMachineInstance) throws {} @@ -477,35 +478,33 @@ extension VZVirtualMachineInstance.Configuration { // Track used virtiofs tags to avoid creating duplicate VZ devices. // The same source directory mounted to multiple destinations shares one device. + // The walk is the machine's device order, matching the addresses + // `mountAttachments` hands out walking the same way. var usedVirtioFSTags: Set = [] - for (_, mounts) in self.mountsByID { - for mount in mounts { - if case .virtiofs = mount.runtimeOptions { - let tag = try hashFilePath(path: mount.source) - if usedVirtioFSTags.contains(tag) { - continue - } - usedVirtioFSTags.insert(tag) + for mount in self.storage.ordered { + if case .virtiofs = mount.runtimeOptions { + let tag = try hashFilePath(path: mount.source) + if usedVirtioFSTags.contains(tag) { + continue } - try mount.configure(config: &config) + usedVirtioFSTags.insert(tag) } + try mount.configure(config: &config) } // Create the unified virtiofs device with VZMultipleDirectoryShare // This device hosts all virtiofs shares and supports runtime updates var directories: [String: VZSharedDirectory] = [:] - for (_, mounts) in self.mountsByID { - for mount in mounts { - guard case .virtiofs(_) = mount.runtimeOptions else { continue } - guard FileManager.default.fileExists(atPath: mount.source) else { - throw ContainerizationError(.notFound, message: "directory \(mount.source) does not exist") - } - let name = try hashFilePath(path: mount.source) - directories[name] = VZSharedDirectory( - url: URL(fileURLWithPath: mount.source), - readOnly: mount.options.contains("ro") - ) + for mount in self.storage.ordered { + guard case .virtiofs(_) = mount.runtimeOptions else { continue } + guard FileManager.default.fileExists(atPath: mount.source) else { + throw ContainerizationError(.notFound, message: "directory \(mount.source) does not exist") } + let name = try hashFilePath(path: mount.source) + directories[name] = VZSharedDirectory( + url: URL(fileURLWithPath: mount.source), + readOnly: mount.options.contains("ro") + ) } let multiShare = VZMultipleDirectoryShare(directories: directories) let virtiofsDevice = VZVirtioFileSystemDeviceConfiguration(tag: "virtiofs") @@ -527,7 +526,7 @@ extension VZVirtualMachineInstance.Configuration { config.platform = platform for ext in self.extensions.compactMap({ $0 as? any VZInstanceExtension }) { - try ext.configureVZ(&config, allocator: allocator, storageDeviceCount: storageDeviceCount, mountsByID: self.mountsByID) + try ext.configureVZ(&config, allocator: allocator, storageDeviceCount: storageDeviceCount, storage: self.storage) } try config.validate() @@ -535,7 +534,7 @@ extension VZVirtualMachineInstance.Configuration { } func mountAttachments(allocator: any AddressAllocator) throws -> ( - attachments: [String: [AttachedFilesystem]], storageDeviceCount: Int + attachments: MachineAttachments, storageDeviceCount: Int ) { var storageDeviceCount = 0 @@ -548,21 +547,28 @@ extension VZVirtualMachineInstance.Configuration { } } - var attachmentsByID: [String: [AttachedFilesystem]] = [:] - - for (id, mounts) in self.mountsByID { - var attachments: [AttachedFilesystem] = [] - for mount in mounts { - let attached = try AttachedFilesystem(mount: mount, allocator: allocator) - attachments.append(attached) - if mount.isBlock { - storageDeviceCount += 1 - } + // The machine's device order: addresses are handed out in the same + // walk `makeConfiguration` creates the devices in. + func attach(_ mount: Mount) throws -> AttachedFilesystem { + let attached = try AttachedFilesystem(mount: mount, allocator: allocator) + if mount.isBlock { + storageDeviceCount += 1 } - attachmentsByID[id] = attachments + return attached + } + + var containers: [String: ContainerAttachments] = [:] + for id in self.storage.containers.keys.sorted() { + guard let container = self.storage.containers[id] else { continue } + containers[id] = try container.map(attach) + } + var volumes: [String: AttachedFilesystem] = [:] + for name in self.storage.volumes.keys.sorted() { + guard let mount = self.storage.volumes[name] else { continue } + volumes[name] = try attach(mount) } - return (attachmentsByID, storageDeviceCount) + return (MachineAttachments(containers: containers, volumes: volumes), storageDeviceCount) } } diff --git a/Sources/Containerization/VZVirtualMachineManager.swift b/Sources/Containerization/VZVirtualMachineManager.swift index 4959bee4..d0888447 100644 --- a/Sources/Containerization/VZVirtualMachineManager.swift +++ b/Sources/Containerization/VZVirtualMachineManager.swift @@ -76,7 +76,7 @@ public struct VZVirtualMachineManager: VirtualMachineManager { instanceConfig.rosetta = self.rosetta instanceConfig.nestedVirtualization = useNestedVirtualization - instanceConfig.mountsByID = vmConfig.mountsByID + instanceConfig.storage = vmConfig.storage instanceConfig.extensions = vmConfig.extensions }) } diff --git a/Sources/Containerization/VirtualMachineInstance.swift b/Sources/Containerization/VirtualMachineInstance.swift index 302e97ae..b9340612 100644 --- a/Sources/Containerization/VirtualMachineInstance.swift +++ b/Sources/Containerization/VirtualMachineInstance.swift @@ -44,7 +44,8 @@ public protocol VirtualMachineInstance: Sendable { // The state of the virtual machine. var state: VirtualMachineInstanceState { get } - var mounts: [String: [AttachedFilesystem]] { get } + /// The machine's attached storage. + var storage: MachineAttachments { get } /// How this VMM exposes virtiofs devices to the guest. Defaults to /// `.unified` (the VZ-shaped behavior); CH overrides to `.perTag`. @@ -73,7 +74,7 @@ public protocol VirtualMachineInstance: Sendable { func hotplug(_ block: Mount, id: String) async throws -> AttachedFilesystem /// Register mounts for a container after hotplug. - /// This is used to add the rootfs and additional mounts to the VM's mount registry + /// This is used to add the rootfs and additional mounts to the machine's storage /// so they can be found when building the container's OCI spec. /// - Parameter id: The container ID /// - Parameter rootfs: The rootfs attachment from hotplug From b3d72088b9deaeda3c02e4dcf377c2831c1473a0 Mon Sep 17 00:00:00 2001 From: Aaron Paterson Date: Sat, 1 Aug 2026 19:06:18 +0000 Subject: [PATCH 06/12] Add guest swap support A container can be given a swap area, so a workload whose memory exceeds its limit is reclaimed rather than killed. The area is a raw file on the host attached as a block device, which is how a guest gets swap it can write to when its own root is read only, and vminitd formats and enables it while mounting the container's filesystems. The area is enabled with discard, and the device backing it is marked non rotational first. virtio block devices are rotational by default, and the kernel only tracks a swap area in clusters when its device is non rotational, so without that the discard flags are accepted and no discard is ever issued. Together they let the sparse file that backs the area release the blocks the guest stops using, rather than holding the area's high water mark for as long as the container runs. --- Sources/CShim/include/swap.h | 36 ++++ Sources/CShim/swap.c | 23 +++ .../Containerization/ContainerManager.swift | 57 +++++- .../Containerization/ContainerStorage.swift | 14 +- Sources/Containerization/LinuxContainer.swift | 40 +++- Sources/ContainerizationOS/Linux/Swap.swift | 176 +++++++++++++++++ Sources/Integration/ContainerTests.swift | 178 ++++++++++++++++++ Sources/Integration/Suite.swift | 26 +++ Sources/cctl/RunCommand.swift | 4 + Tests/ContainerizationTests/SwapTests.swift | 73 +++++++ vminitd/Sources/VminitdCore/Server+GRPC.swift | 15 ++ 11 files changed, 635 insertions(+), 7 deletions(-) create mode 100644 Sources/CShim/include/swap.h create mode 100644 Sources/CShim/swap.c create mode 100644 Sources/ContainerizationOS/Linux/Swap.swift create mode 100644 Tests/ContainerizationTests/SwapTests.swift diff --git a/Sources/CShim/include/swap.h b/Sources/CShim/include/swap.h new file mode 100644 index 00000000..f16000ee --- /dev/null +++ b/Sources/CShim/include/swap.h @@ -0,0 +1,36 @@ +/* + * 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. + */ + +#ifndef __SWAP_H +#define __SWAP_H + +#if defined(__linux__) + +// swapon(2) lives in , which Swift's glibc modulemap does not +// carry, so it is reachable from Swift only through a wrapper. + +// Discard the whole area when it is enabled, and each page as it is freed. +// These come from the same header, and SWAP_FLAG_DISCARD_PAGES has no UAPI +// header of its own at all, so every consumer declares it; see util-linux +// sys-utils/swapon.c. +#define CZ_SWAP_DISCARD 0x10000 +#define CZ_SWAP_DISCARD_PAGES 0x40000 + +int CZ_swapon(const char *path, int flags); + +#endif + +#endif diff --git a/Sources/CShim/swap.c b/Sources/CShim/swap.c new file mode 100644 index 00000000..2a66461d --- /dev/null +++ b/Sources/CShim/swap.c @@ -0,0 +1,23 @@ +/* + * 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. + */ + +#ifdef __linux__ +#include + +#include "swap.h" + +int CZ_swapon(const char *path, int flags) { return swapon(path, flags); } +#endif diff --git a/Sources/Containerization/ContainerManager.swift b/Sources/Containerization/ContainerManager.swift index 27e9fbe4..a1b082e1 100644 --- a/Sources/Containerization/ContainerManager.swift +++ b/Sources/Containerization/ContainerManager.swift @@ -195,6 +195,9 @@ public struct ContainerManager: Sendable { /// - writableLayerSizeInBytes: Optional size for a separate writable layer. When provided, /// the rootfs becomes read-only and an overlayfs is used with a separate writable layer of this size. /// - readOnly: Whether to mount the root filesystem as read-only. + /// - swapSizeInBytes: Optional size for a swap area. When provided, a raw block + /// device of this size is created and the guest enables it as swap, so a + /// workload exceeding its memory limit reclaims rather than being killed. /// - networking: Whether to create a network interface for this container. Defaults to `true`. /// When `false`, no network resources are allocated and `releaseNetwork`/`delete` remain safe to call. /// - progress: Optional handler for tracking rootfs unpacking progress. @@ -203,6 +206,7 @@ public struct ContainerManager: Sendable { reference: String, rootfsSizeInBytes: UInt64 = 8.gib(), writableLayerSizeInBytes: UInt64? = nil, + swapSizeInBytes: UInt64? = nil, readOnly: Bool = false, networking: Bool = true, progress: ProgressHandler? = nil, @@ -214,6 +218,7 @@ public struct ContainerManager: Sendable { image: image, rootfsSizeInBytes: rootfsSizeInBytes, writableLayerSizeInBytes: writableLayerSizeInBytes, + swapSizeInBytes: swapSizeInBytes, readOnly: readOnly, networking: networking, progress: progress, @@ -229,6 +234,9 @@ public struct ContainerManager: Sendable { /// - writableLayerSizeInBytes: Optional size for a separate writable layer. When provided, /// the rootfs becomes read-only and an overlayfs is used with a separate writable layer of this size. /// - readOnly: Whether to mount the root filesystem as read-only. + /// - swapSizeInBytes: Optional size for a swap area. When provided, a raw block + /// device of this size is created and the guest enables it as swap, so a + /// workload exceeding its memory limit reclaims rather than being killed. /// - networking: Whether to create a network interface for this container. Defaults to `true`. /// When `false`, no network resources are allocated and `releaseNetwork`/`delete` remain safe to call. /// - progress: Optional handler for tracking rootfs unpacking progress. @@ -237,6 +245,7 @@ public struct ContainerManager: Sendable { image: Image, rootfsSizeInBytes: UInt64 = 8.gib(), writableLayerSizeInBytes: UInt64? = nil, + swapSizeInBytes: UInt64? = nil, readOnly: Bool = false, networking: Bool = true, progress: ProgressHandler? = nil, @@ -263,14 +272,25 @@ public struct ContainerManager: Sendable { ) } + // Create the swap device if a size is specified. + var swapLayer: Mount? = nil + if let swapSize = swapSizeInBytes, swapSize > 0 { + swapLayer = try createSwapDevice( + at: path.appendingPathComponent("swap.raw"), + size: swapSize + ) + } + return try await create( id, image: image, rootfs: rootfs, writableLayer: writableLayer, - networking: networking, - configuration: configuration - ) + networking: networking + ) { config in + config.swapLayer = swapLayer + try configuration(&config) + } } /// Returns a new container from the provided image and root filesystem mount. @@ -357,6 +377,37 @@ public struct ContainerManager: Sendable { } } + /// Create a raw block file to back a container's swap area. + /// + /// It carries no filesystem: the agent writes the swap header to the device + /// and enables it. The file is sparse, so it costs the host only the pages + /// the guest has actually swapped out, and gives them back on discard. A + /// swap area held in a file has to be free of holes, since the kernel walks + /// its extents; the guest reaches this one as a block device, which the + /// kernel takes as a single extent without consulting the host's layout. + /// https://github.com/torvalds/linux/blob/master/mm/swapfile.c + private func createSwapDevice(at destination: URL, size: UInt64) throws -> Mount { + let path = destination.absolutePath() + guard !FileManager.default.fileExists(atPath: path) else { + throw ContainerizationError(.exists, message: "swap device already exists at \(path)") + } + guard FileManager.default.createFile(atPath: path, contents: nil) else { + throw ContainerizationError(.internalError, message: "failed to create swap device at \(path)") + } + let handle = try FileHandle(forWritingTo: destination) + defer { try? handle.close() } + try handle.truncate(atOffset: size) + // A swap area holds nothing that outlives the container, so the host + // has no reason to synchronize it to permanent storage. + return .block( + format: Swap.mountType, + source: path, + destination: "", + options: [], + runtimeOptions: ["vzDiskImageSynchronizationMode=none"] + ) + } + private func createEmptyFilesystem(at destination: URL, size: UInt64) throws -> Mount { let path = destination.absolutePath() guard !FileManager.default.fileExists(atPath: path) else { diff --git a/Sources/Containerization/ContainerStorage.swift b/Sources/Containerization/ContainerStorage.swift index 4d8de2ed..49fae6d4 100644 --- a/Sources/Containerization/ContainerStorage.swift +++ b/Sources/Containerization/ContainerStorage.swift @@ -29,22 +29,29 @@ public struct ContainerStorage: Sendable { /// overlay, when the container has one. public var writableLayer: Value? + /// The swap area enabled for the container, when it has one. + public var swap: Value? + /// The container's remaining mounts, in configuration order. public var mounts: [Value] - public init(rootfs: Value, writableLayer: Value? = nil, mounts: [Value] = []) { + public init(rootfs: Value, writableLayer: Value? = nil, swap: Value? = nil, mounts: [Value] = []) { self.rootfs = rootfs self.writableLayer = writableLayer + self.swap = swap self.mounts = mounts } - /// Every value in the structure: the rootfs, the writable layer when - /// present, then the mounts, in that order. + /// Every value in the structure: the rootfs, the writable layer and swap + /// when present, then the mounts, in that order. public var all: [Value] { var values = [rootfs] if let writableLayer { values.append(writableLayer) } + if let swap { + values.append(swap) + } values.append(contentsOf: mounts) return values } @@ -55,6 +62,7 @@ public struct ContainerStorage: Sendable { ContainerStorage( rootfs: try transform(rootfs), writableLayer: try writableLayer.map(transform), + swap: try swap.map(transform), mounts: try mounts.map(transform) ) } diff --git a/Sources/Containerization/LinuxContainer.swift b/Sources/Containerization/LinuxContainer.swift index 55bbb39a..4d5289a7 100644 --- a/Sources/Containerization/LinuxContainer.swift +++ b/Sources/Containerization/LinuxContainer.swift @@ -99,6 +99,16 @@ public final class LinuxContainer: Container, Sendable { /// The total is aligned to a 1 MiB boundary. public var memoryOverhead: UInt64 = 128.mib() + /// Optional swap area for the container, as a block device mount. + /// + /// Swap lets a workload whose resident set exceeds `memoryInBytes` + /// reclaim rather than meet the out of memory killer. The area is a + /// block device rather than a file in the guest, because no filesystem + /// the agent can write to exists when the sandbox starts: its root is + /// mounted read only. The `destination` field is ignored, as the area + /// is enabled rather than mounted. + public var swapLayer: Mount? = nil + public init() {} public init( @@ -536,6 +546,30 @@ extension LinuxContainer { config.interfaces } + /// Enable the container's swap area, if it has one. + /// + /// The device is attached with the container's other block devices, so the + /// agent is told the guest path the VMM allocated it. It is enabled rather + /// than mounted, which is why it travels as a mount of type `swap`. + private func enableSwap( + attached: ContainerAttachments, + agent: VirtualMachineAgent + ) async throws { + guard self.config.swapLayer != nil else { + return + } + guard let swap = attached.swap else { + throw ContainerizationError(.notFound, message: "swap mount not found") + } + try await agent.mount( + ContainerizationOCI.Mount( + type: Swap.mountType, + source: swap.source, + destination: "", + options: swap.options + )) + } + private func mountRootfs( attached: ContainerAttachments, rootfsPath: String, @@ -613,10 +647,13 @@ extension LinuxContainer { // This is dumb, but alas. let fileMountContextHolder = Mutex(fileMountContext) - // Build the container's storage to attach to the VM. + // Build the container's storage to attach to the VM. The swap + // device is attached with the container's other block devices so + // the guest is told the /dev path the VMM allocates it. let containerStorage = ContainerMounts( rootfs: modifiedRootfs, writableLayer: self.writableLayer, + swap: self.config.swapLayer, mounts: fileMountContext.transformedMounts ) @@ -691,6 +728,7 @@ extension LinuxContainer { } let rootfsPath = Self.guestRootfsPath(self.id) try await self.mountRootfs(attached: attached, rootfsPath: rootfsPath, agent: agent) + try await self.enableSwap(attached: attached, agent: agent) // Mount file mount holding directories under /run. if fileMountContext.hasFileMounts { diff --git a/Sources/ContainerizationOS/Linux/Swap.swift b/Sources/ContainerizationOS/Linux/Swap.swift new file mode 100644 index 00000000..82006738 --- /dev/null +++ b/Sources/ContainerizationOS/Linux/Swap.swift @@ -0,0 +1,176 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +import CShim +import ContainerizationError + +#if canImport(FoundationEssentials) +import FoundationEssentials +#else +import Foundation +#endif + +#if canImport(Musl) +import Musl +#elseif canImport(Glibc) +import Glibc +#endif + +/// `Swap` is a utility type that contains static helpers for creating a swap +/// file and enabling it as swap space for the guest. +/// +/// The swap area header layout is the kernel's on-disk format, version 1: +/// a page of header whose last 10 bytes are the magic `SWAPSPACE2`, with the +/// version, the last usable page and the page count at fixed offsets from the +/// start of the second kilobyte. +/// https://docs.kernel.org/admin-guide/mm/concepts.html +/// https://github.com/torvalds/linux/blob/master/include/linux/swap.h +public struct Swap: Sendable { + /// Offset of the version field within the swap header, and of the last + /// usable page field that follows it. + private static let versionOffset = 1024 + private static let lastPageOffset = 1028 + /// The magic that marks the last bytes of the first page of a swap area. + private static let magic = "SWAPSPACE2" + /// The queue attributes the kernel exposes for each block device. + public static let blockPath = "/sys/block" + + /// Mount type that marks a block device as a container's swap area, which + /// the agent enables rather than mounts. It travels in the `type` field of + /// the mount the host sends, so both sides read it from here. + /// + /// Kata gives its guest swap the same shape, the host attaching a raw file + /// as a block device and the agent calling `swapon` on it, and carries the + /// request on an RPC of its own (`AddSwap`). Here it travels as a mount so + /// the device path the VMM allocates reaches the guest the way every other + /// attached device's does. + /// https://github.com/kata-containers/kata-containers/blob/main/src/agent/src/rpc.rs + public static let mountType = "swap" + + #if os(Linux) + /// Format the swap area at `path` and enable it. + /// + /// `path` may be a block device or a file that already has its final size, + /// which is what `size` describes; `create` makes such a file. + public static func enable(path: String, size: UInt64, pageSize: Int = 4096) throws { + try format(path: path, size: size, pageSize: pageSize) + try on(path: path) + } + + /// Write the swap area header the kernel expects to an existing block + /// device or fully allocated file. + /// + /// This is what `mkswap` writes, and kata has its host run `mkswap` before + /// attaching the device. That is not open to a host which is not Linux, so + /// the header is written here instead, from the guest that is about to + /// enable it. + /// https://github.com/kata-containers/kata-containers/blob/main/src/runtime-rs/crates/resource/src/cpu_mem/swap.rs + public static func format(path: String, size: UInt64, pageSize: Int = 4096) throws { + let pages = size / UInt64(pageSize) + guard pages > 1 else { + throw ContainerizationError( + .invalidArgument, + message: "swap size \(size) is smaller than the two pages a swap area needs" + ) + } + // The header carries the last page number in 32 bits. + guard let lastPage = UInt32(exactly: pages - 1) else { + throw ContainerizationError( + .invalidArgument, + message: "swap size \(size) exceeds the \(UInt64(UInt32.max) + 1) pages a swap header can carry" + ) + } + + let fd = open(path, O_WRONLY) + guard fd >= 0 else { + throw POSIXError.fromErrno() + } + defer { close(fd) } + + var header = [UInt8](repeating: 0, count: pageSize) + header.replaceSubrange(versionOffset..<(versionOffset + 4), with: littleEndianBytes(1)) + header.replaceSubrange( + lastPageOffset..<(lastPageOffset + 4), + with: littleEndianBytes(lastPage) + ) + // The magic occupies the last bytes of the header page. + header.replaceSubrange((pageSize - magic.count).. UInt64 { + let fd = open(path, O_RDONLY) + guard fd >= 0 else { + throw POSIXError.fromErrno() + } + defer { close(fd) } + let end = lseek(fd, 0, SEEK_END) + guard end > 0 else { + throw POSIXError.fromErrno() + } + return UInt64(end) + } + + /// Enable the swap area at `path`, asking the kernel to discard the blocks + /// it stops using so the file backing the area does not keep them. + /// + /// Ours: kata enables its swap with no flags, which suits an area on a + /// host disk that was sized once and stays that size. + /// https://github.com/kata-containers/kata-containers/blob/main/src/agent/src/rpc.rs + public static func on(path: String) throws { + try markSolidState(devicePath: path) + guard CZ_swapon(path, CZ_SWAP_DISCARD | CZ_SWAP_DISCARD_PAGES) == 0 else { + throw POSIXError.fromErrno() + } + } + + /// Mark the block device backing the swap area as non rotational. + /// + /// Ours: no other runtime does this, because no other runtime needs the + /// area to give its blocks back. Kata calls `swapon` with no flags at all. + /// + /// The kernel only tracks a swap area in clusters when its device is non + /// rotational, and freeing a cluster is the only thing that schedules a + /// discard. A virtio block device reports as rotational, so without this + /// the area is scanned rather than clustered and the discard flags above + /// never take effect, leaving the file that backs the area holding every + /// block the guest has ever swapped to. + /// https://github.com/torvalds/linux/blob/master/mm/swapfile.c + static func markSolidState(devicePath: String) throws { + let device = URL(fileURLWithPath: devicePath).lastPathComponent + try "0".write( + to: URL(fileURLWithPath: Self.blockPath) + .appendingPathComponent(device) + .appendingPathComponent("queue") + .appendingPathComponent("rotational"), + atomically: false, + encoding: .ascii + ) + } + + private static func littleEndianBytes(_ value: UInt32) -> [UInt8] { + withUnsafeBytes(of: value.littleEndian) { Array($0) } + } + + #endif // os(Linux) +} diff --git a/Sources/Integration/ContainerTests.swift b/Sources/Integration/ContainerTests.swift index dd2a9165..9980cf21 100644 --- a/Sources/Integration/ContainerTests.swift +++ b/Sources/Integration/ContainerTests.swift @@ -137,6 +137,184 @@ extension IntegrationSuite { } } + func testContainerSwap() async throws { + let id = "test-container-swap" + let bs = try await bootstrap(id) + + let swapPath = Self.binPath(name: "\(id)-swap.raw") + let swap = try Self.makeSwapDevice(at: swapPath, size: 64.mib()) + defer { try? FileManager.default.removeItem(at: swapPath) } + + let buffer = BufferWriter() + let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in + config.process.arguments = ["/bin/cat", "/proc/swaps"] + config.process.stdout = buffer + config.swapLayer = swap + config.bootLog = bs.bootLog + } + + do { + try await container.create() + try await container.start() + + let status = try await container.wait() + try await container.stop() + + guard status.exitCode == 0 else { + throw IntegrationError.assert(msg: "process status \(status) != 0") + } + + // The guest lists the area it enabled, so the device is present and + // the kernel accepted the header the agent wrote to it. + let swaps = String(data: buffer.data, encoding: .utf8) ?? "" + guard swaps.contains("partition") else { + throw IntegrationError.assert(msg: "swap not enabled in guest: '\(swaps)'") + } + } catch { + try? await container.stop() + throw error + } + } + + /// The point of swap: a workload whose pages exceed the memory limit + /// reclaims instead of meeting the out of memory killer. The tmpfs is + /// sized explicitly because its default is half of RAM, which cannot + /// exceed the limit and so would never drive a page out. + func testContainerSwapUnderPressure() async throws { + let id = "test-container-swap-pressure" + let bs = try await bootstrap(id) + + let swapPath = Self.binPath(name: "\(id)-swap.raw") + let swap = try Self.makeSwapDevice(at: swapPath, size: 512.mib()) + defer { try? FileManager.default.removeItem(at: swapPath) } + + let buffer = BufferWriter() + let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in + // A shell variable is anonymous memory, which is what the kernel + // reclaims to a swap area, and asking the container's cgroup to + // reclaim puts it there rather than leaving it to the pressure the + // allocation happens to produce. Reading the value back afterwards + // is what shows it made the round trip intact, so the length is + // checked after the area has been measured. + config.process.arguments = [ + "/bin/sh", "-c", + "fill=$(head -c 340000000 /dev/zero | tr '\\0' 'a'); " + + "echo 340M > /sys/fs/cgroup/memory.reclaim; " + + "awk '/SwapTotal|SwapFree/ { print $1, $2 }' /proc/meminfo; " + + "test ${#fill} -eq 340000000", + ] + config.process.stdout = buffer + config.memoryInBytes = 256.mib() + config.swapLayer = swap + config.bootLog = bs.bootLog + } + + do { + try await container.create() + try await container.start() + + let status = try await container.wait() + try await container.stop() + + // Survival is the first half of the claim: without swap this + // workload is killed rather than reclaimed. + guard status.exitCode == 0 else { + throw IntegrationError.assert(msg: "workload did not survive memory pressure: \(status)") + } + + // And the second half: pages actually reached the swap device. + let out = String(data: buffer.data, encoding: .utf8) ?? "" + let values = out.split(separator: "\n").reduce(into: [String: Int]()) { acc, line in + let parts = line.split(separator: " ") + if parts.count == 2 { acc[String(parts[0])] = Int(parts[1]) } + } + guard let total = values["SwapTotal:"], let free = values["SwapFree:"], total > 0 else { + throw IntegrationError.assert(msg: "guest reported no swap: '\(out)'") + } + guard UInt64(total - free) * 1024 > 64.mib() else { + throw IntegrationError.assert(msg: "little or nothing was swapped out: '\(out)'") + } + } catch { + try? await container.stop() + throw error + } + } + + func testContainerSwapReclaimsFreedBlocks() async throws { + let id = "test-container-swap-reclaim" + let bs = try await bootstrap(id) + + let swapPath = Self.binPath(name: "\(id)-swap.raw") + let swap = try Self.makeSwapDevice(at: swapPath, size: 512.mib()) + defer { try? FileManager.default.removeItem(at: swapPath) } + + let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in + // The allocation is held by a child that exits, because the swap + // slots are only freed once the pages themselves are, and a shell + // that drops a variable keeps the pages in its own allocator. + config.process.arguments = [ + "/bin/sh", "-c", + "sh -c 'fill=$(head -c 200000000 /dev/zero | tr \"\\0\" a); " + + "echo 200M > /sys/fs/cgroup/memory.reclaim; " + + "test ${#fill} -eq 200000000' && sleep 30", + ] + config.memoryInBytes = 256.mib() + config.swapLayer = swap + config.bootLog = bs.bootLog + } + + // The blocks the file holds, rather than the size it reports, because a + // sparse file only ever reports the whole area. URL resource values + // cache after their first read, which a sampler cannot use. + func allocatedBytes() -> UInt64 { + var info = stat() + guard stat(swapPath.absolutePath(), &info) == 0 else { + return 0 + } + return UInt64(info.st_blocks) * 512 + } + + do { + try await container.create() + try await container.start() + + // Watching the file while the workload runs is what separates an + // area that released its blocks from one that never held any. + let peak = Task { + var high: UInt64 = 0 + while !Task.isCancelled { + high = max(high, allocatedBytes()) + try? await Task.sleep(nanoseconds: 200_000_000) + } + return high + } + + let status = try await container.wait() + peak.cancel() + let highWater = await peak.value + try await container.stop() + + guard status.exitCode == 0 else { + throw IntegrationError.assert(msg: "workload did not complete: \(status)") + } + guard highWater > 100.mib() else { + throw IntegrationError.assert( + msg: "workload never filled the swap area, it held \(highWater) bytes") + } + + // Having been filled, the area releases what the guest stopped + // using, so the file backing it does not hold its high water mark. + let settled = allocatedBytes() + guard settled < highWater / 4 else { + throw IntegrationError.assert( + msg: "swap area kept \(settled) of \(highWater) bytes after the guest freed it") + } + } catch { + try? await container.stop() + throw error + } + } + func testProcessEchoHi() async throws { let id = "test-process-echo-hi" let bs = try await bootstrap(id) diff --git a/Sources/Integration/Suite.swift b/Sources/Integration/Suite.swift index 84580dba..6087aafe 100644 --- a/Sources/Integration/Suite.swift +++ b/Sources/Integration/Suite.swift @@ -207,6 +207,27 @@ struct IntegrationSuite: AsyncParsableCommand { static let eventLoop = MultiThreadedEventLoopGroup(numberOfThreads: System.coreCount) + /// The swap area a test hands to a container. A suite that builds its own + /// containers owns the file behind them, the way `ContainerManager` owns + /// the ones it makes for callers that do not. + static func makeSwapDevice(at path: URL, size: UInt64) throws -> Containerization.Mount { + guard FileManager.default.createFile(atPath: path.absolutePath(), contents: nil) else { + throw IntegrationError.assert(msg: "failed to create swap device at \(path.absolutePath())") + } + let handle = try FileHandle(forWritingTo: path) + defer { try? handle.close() } + try handle.truncate(atOffset: size) + // A swap area holds nothing that outlives the container, so the host + // has no reason to synchronize it to permanent storage. + return .block( + format: Swap.mountType, + source: path.absolutePath(), + destination: "", + options: [], + runtimeOptions: ["vzDiskImageSynchronizationMode=none"] + ) + } + func bootstrap(_ testID: String) async throws -> (rootfs: Containerization.Mount, vmm: VirtualMachineManager, image: Containerization.Image, bootLog: BootLog) { let reference = "ghcr.io/linuxcontainers/alpine:3.20" let store = Self.imageStore @@ -614,6 +635,11 @@ struct IntegrationSuite: AsyncParsableCommand { Test("pod filesystem operation", testPodFilesystemOperation), Test("pod shared disk image volume", testPodSharedDiskImageVolume), Test("pod shared tmpfs volume", testPodSharedTmpfsVolume), + + // Swap + Test("container swap", testContainerSwap), + Test("container swap under pressure", testContainerSwapUnderPressure), + Test("container swap reclaims freed blocks", testContainerSwapReclaimsFreedBlocks), ] + macOS26Tests() let tests: [Test] = crossPlatformTests + macOSOnlyTests #else diff --git a/Sources/cctl/RunCommand.swift b/Sources/cctl/RunCommand.swift index fceff172..21f13a3e 100644 --- a/Sources/cctl/RunCommand.swift +++ b/Sources/cctl/RunCommand.swift @@ -48,6 +48,9 @@ extension Application { @Flag(name: .customLong("rosetta"), help: "Enable rosetta x64 emulation") var rosetta = false + @Option(name: .customLong("swap"), help: "Amount of swap in megabytes to create in the guest") + var swap: UInt64 = 0 + @Option(name: .customLong("mount"), help: "Directory to share into the container (Example: /foo:/bar)") var mounts: [String] = [] @@ -106,6 +109,7 @@ extension Application { id, reference: imageReference, rootfsSizeInBytes: fsSizeInMB.mib(), + swapSizeInBytes: swap.mib(), readOnly: readOnly, networking: true ) { config in diff --git a/Tests/ContainerizationTests/SwapTests.swift b/Tests/ContainerizationTests/SwapTests.swift new file mode 100644 index 00000000..1b8aa8a9 --- /dev/null +++ b/Tests/ContainerizationTests/SwapTests.swift @@ -0,0 +1,73 @@ +//===----------------------------------------------------------------------===// +// 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(Linux) +import ContainerizationOS +import Foundation +import Testing + +struct SwapTests { + private func makeArea(pages: UInt64, pageSize: Int) throws -> String { + let path = FileManager.default.temporaryDirectory + .appendingPathComponent("swap-\(UUID().uuidString)").path + try #require( + FileManager.default.createFile( + atPath: path, + contents: Data(count: Int(pages) * pageSize) + ) + ) + return path + } + + /// The header the kernel reads back is the one `mkswap` writes: version 1 + /// and the last usable page at the documented offsets, and the magic in the + /// final bytes of the first page. + @Test func formatWritesTheSwapAreaHeader() throws { + let pageSize = 4096 + let pages: UInt64 = 32 + let path = try makeArea(pages: pages, pageSize: pageSize) + defer { try? FileManager.default.removeItem(atPath: path) } + + try Swap.format(path: path, size: pages * UInt64(pageSize), pageSize: pageSize) + + let header = try Data(contentsOf: URL(fileURLWithPath: path))[0.. Date: Sun, 2 Aug 2026 21:23:05 +0000 Subject: [PATCH 07/12] Let an NBD export keep its blocks somewhere other than a file The server answers reads, writes and flushes against whatever holds the export's blocks, so where they are kept is a choice made when the server is built rather than the one thing it can do. A file is one such place. Memory is another, and it differs in where the blocks end up when the host runs short: memory a process holds is pageable, so those blocks go to the host's own swap and share the pool the rest of the system draws on, while a file takes space of its own. Only the chunks written are held, so an export costs nothing until something is stored in it. Every connection to an export now serves the one store behind it, which is what lets a client read back what another wrote, so a client going away no longer closes it. --- Sources/Integration/NBDBackingStore.swift | 198 ++++++++++++++++++++++ Sources/Integration/NBDServer.swift | 72 ++++---- 2 files changed, 238 insertions(+), 32 deletions(-) create mode 100644 Sources/Integration/NBDBackingStore.swift diff --git a/Sources/Integration/NBDBackingStore.swift b/Sources/Integration/NBDBackingStore.swift new file mode 100644 index 00000000..0bf35a14 --- /dev/null +++ b/Sources/Integration/NBDBackingStore.swift @@ -0,0 +1,198 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +import Foundation +import Synchronization + +#if os(macOS) + +/// Where an NBD export keeps the blocks it serves. +/// +/// A server holds one of these and every connection to that export shares it, +/// so what one connection writes another reads back. +protocol NBDBackingStore: Sendable { + /// The size of the export, which the server reports during the handshake. + var size: UInt64 { get } + + /// Read `length` bytes from `offset`. Returns nil when the read fails. + func read(offset: UInt64, length: Int) -> [UInt8]? + + /// Write `data` at `offset`. Returns false when the write fails. + func write(offset: UInt64, data: [UInt8]) -> Bool + + /// Put anything held back where it belongs before replying to a flush. + func flush() + + /// Release whatever the store holds. + func close() +} + +/// A store that keeps its blocks in a file. +final class NBDFileStore: NBDBackingStore { + private let fd: Mutex + let size: UInt64 + + init?(path: String) { + let descriptor = open(path, O_RDWR) + guard descriptor >= 0 else { + return nil + } + var st = stat() + guard fstat(descriptor, &st) == 0 else { + _ = Foundation.close(descriptor) + return nil + } + self.fd = Mutex(descriptor) + self.size = UInt64(st.st_size) + } + + func read(offset: UInt64, length: Int) -> [UInt8]? { + var buffer = [UInt8](repeating: 0, count: length) + let read = self.fd.withLock { descriptor in + buffer.withUnsafeMutableBytes { + pread(descriptor, $0.baseAddress, length, off_t(offset)) + } + } + guard read == length else { + return nil + } + return buffer + } + + func write(offset: UInt64, data: [UInt8]) -> Bool { + self.fd.withLock { descriptor in + data.withUnsafeBytes { + pwrite(descriptor, $0.baseAddress, data.count, off_t(offset)) == data.count + } + } + } + + func flush() { + self.fd.withLock { _ = fsync($0) } + } + + func close() { + self.fd.withLock { descriptor in + if descriptor >= 0 { + _ = Foundation.close(descriptor) + } + } + } +} + +/// A store that keeps its blocks in the memory of the process serving them. +/// +/// The point of holding them here rather than in a file is where they end up +/// under pressure. Memory a host process holds is pageable, so the host decides +/// when these blocks go to its own swap, and they share the one pool the rest +/// of the system draws on. Blocks in a file take space of their own instead. +/// +/// Only the chunks actually written are held, so an export costs nothing until +/// something is stored in it, the way a sparse file costs nothing until it is +/// written to. +final class NBDMemoryStore: NBDBackingStore { + /// A chunk is a page, because that is the unit a guest swaps in and out. + /// Anything larger rounds every scattered page write up to its size, which + /// costs both the memory the rounding wastes and the copying of the part + /// that was not written. + static let chunkSize = 4096 + + private let chunks: Mutex<[UInt64: [UInt8]]> = Mutex([:]) + let size: UInt64 + + init(size: UInt64) { + self.size = size + } + + /// The bytes actually held, which is what the export costs the host. + var allocatedBytes: Int { + self.chunks.withLock { $0.count * Self.chunkSize } + } + + /// The most the export has ever held. + /// + /// What it holds right now says nothing about what passed through it, since + /// a client that discards what it has finished with leaves an export as + /// empty as it started. This is what a reader wanting to know whether + /// anything was ever stored should look at. + var peakAllocatedBytes: Int { + self.peakChunks.withLock { $0 * Self.chunkSize } + } + + private let peakChunks: Mutex = Mutex(0) + + func read(offset: UInt64, length: Int) -> [UInt8]? { + guard offset + UInt64(length) <= self.size else { + return nil + } + var out = [UInt8](repeating: 0, count: length) + self.chunks.withLock { chunks in + self.forEachSpan(offset: offset, length: length) { index, inChunk, inSpan, span in + // A chunk never written reads back as the zeroes it started as. + guard let chunk = chunks[index] else { + return + } + out.replaceSubrange(inSpan..<(inSpan + span), with: chunk[inChunk..<(inChunk + span)]) + } + } + return out + } + + func write(offset: UInt64, data: [UInt8]) -> Bool { + guard offset + UInt64(data.count) <= self.size else { + return false + } + let held = self.chunks.withLock { chunks -> Int in + self.forEachSpan(offset: offset, length: data.count) { index, inChunk, inSpan, span in + var chunk = chunks[index] ?? [UInt8](repeating: 0, count: Self.chunkSize) + chunk.replaceSubrange(inChunk..<(inChunk + span), with: data[inSpan..<(inSpan + span)]) + chunks[index] = chunk + } + return chunks.count + } + self.peakChunks.withLock { $0 = max($0, held) } + return true + } + + /// Nothing is held anywhere else, so a flush has nothing to do. + func flush() {} + + func close() { + self.chunks.withLock { $0.removeAll() } + } + + /// Walk the chunks a request covers, handing each the range it owns. + private func forEachSpan( + offset: UInt64, + length: Int, + _ body: (_ index: UInt64, _ inChunk: Int, _ inSpan: Int, _ span: Int) -> Void + ) { + var remaining = length + var at = offset + var taken = 0 + while remaining > 0 { + let index = at / UInt64(Self.chunkSize) + let inChunk = Int(at % UInt64(Self.chunkSize)) + let span = min(Self.chunkSize - inChunk, remaining) + body(index, inChunk, taken, span) + remaining -= span + taken += span + at += UInt64(span) + } + } +} + +#endif diff --git a/Sources/Integration/NBDServer.swift b/Sources/Integration/NBDServer.swift index 982c8752..1c680f75 100644 --- a/Sources/Integration/NBDServer.swift +++ b/Sources/Integration/NBDServer.swift @@ -31,23 +31,27 @@ final class NBDServer: Sendable { private let group: EventLoopGroup let url: String - init(filePath: String, socketPath: String, logger: Logger? = nil) throws { + private let store: NBDBackingStore + + init(store: NBDBackingStore, socketPath: String, logger: Logger? = nil) throws { self.socketPath = socketPath + self.store = store self.group = MultiThreadedEventLoopGroup(numberOfThreads: 1) try? FileManager.default.removeItem(atPath: socketPath) - self.channel = try Self.bootstrap(group: self.group, filePath: filePath, logger: logger) + self.channel = try Self.bootstrap(group: self.group, store: store, logger: logger) .bind(unixDomainSocketPath: socketPath) .wait() self.url = "nbd+unix:///?socket=\(socketPath)" } - init(filePath: String, port: Int, logger: Logger? = nil) throws { + init(store: NBDBackingStore, port: Int, logger: Logger? = nil) throws { self.socketPath = nil + self.store = store self.group = MultiThreadedEventLoopGroup(numberOfThreads: 1) - self.channel = try Self.bootstrap(group: self.group, filePath: filePath, logger: logger) + self.channel = try Self.bootstrap(group: self.group, store: store, logger: logger) .bind(host: "127.0.0.1", port: port) .wait() @@ -57,21 +61,37 @@ final class NBDServer: Sendable { self.url = "nbd://127.0.0.1:\(boundPort)" } + convenience init(filePath: String, socketPath: String, logger: Logger? = nil) throws { + try self.init(store: Self.fileStore(filePath), socketPath: socketPath, logger: logger) + } + + convenience init(filePath: String, port: Int, logger: Logger? = nil) throws { + try self.init(store: Self.fileStore(filePath), port: port, logger: logger) + } + + private static func fileStore(_ path: String) throws -> NBDBackingStore { + guard let store = NBDFileStore(path: path) else { + throw ContainerizationError(.internalError, message: "NBD server failed to open \(path)") + } + return store + } + func stop() { try? channel.close().wait() try? group.syncShutdownGracefully() + self.store.close() if let socketPath { try? FileManager.default.removeItem(atPath: socketPath) } } - private static func bootstrap(group: EventLoopGroup, filePath: String, logger: Logger?) -> ServerBootstrap { + private static func bootstrap(group: EventLoopGroup, store: NBDBackingStore, logger: Logger?) -> ServerBootstrap { ServerBootstrap(group: group) .serverChannelOption(.socketOption(.so_reuseaddr), value: 1) .childChannelInitializer { channel in channel.eventLoop.makeCompletedFuture { try channel.pipeline.syncOperations.addHandler( - NBDConnectionHandler(filePath: filePath, logger: logger) + NBDConnectionHandler(store: store, logger: logger) ) } } @@ -118,7 +138,7 @@ private final class NBDConnectionHandler: ChannelInboundHandler { static let errIO: UInt32 = 5 static let errNotsup: UInt32 = 95 - private let fileFD: Int32 + private let store: NBDBackingStore private let fileSize: UInt64 private let logger: Logger? private var buffer: ByteBuffer = ByteBuffer() @@ -130,24 +150,14 @@ private final class NBDConnectionHandler: ChannelInboundHandler { case transmission } - init(filePath: String, logger: Logger?) { - self.fileFD = open(filePath, O_RDWR) + init(store: NBDBackingStore, logger: Logger?) { + self.store = store + self.fileSize = store.size self.logger = logger - guard fileFD >= 0 else { - self.fileSize = 0 - logger?.error("NBD server: failed to open \(filePath), errno=\(errno)") - return - } - var st = stat() - if fstat(self.fileFD, &st) == 0 { - self.fileSize = UInt64(st.st_size) - } else { - self.fileSize = 0 - } } func channelActive(context: ChannelHandlerContext) { - guard fileFD >= 0 else { + guard fileSize > 0 else { context.close(promise: nil) return } @@ -160,9 +170,8 @@ private final class NBDConnectionHandler: ChannelInboundHandler { } func channelInactive(context: ChannelHandlerContext) { - if fileFD >= 0 { - close(fileFD) - } + // Every connection to an export serves the one store behind it, so a + // client going away is not what ends it. The server closes it instead. } func channelRead(context: ChannelHandlerContext, data: NIOAny) { @@ -329,19 +338,18 @@ private final class NBDConnectionHandler: ChannelInboundHandler { } return Int(length) } - let n = pwrite(fileFD, &writeData, Int(length), off_t(offset)) + let stored = store.write(offset: offset, data: writeData) var reply = context.channel.allocator.buffer(capacity: 16) - writeSimpleReply(&reply, cookie: cookie, error: n < 0 ? Self.errIO : Self.errOK) + writeSimpleReply(&reply, cookie: cookie, error: stored ? Self.errOK : Self.errIO) context.writeAndFlush(wrapOutboundOut(reply), promise: nil) case Self.cmdRead: buffer.moveReaderIndex(forwardBy: 28) - var readBuf = [UInt8](repeating: 0, count: Int(length)) - let n = pread(fileFD, &readBuf, Int(length), off_t(offset)) + let readBuf = store.read(offset: offset, length: Int(length)) var reply = context.channel.allocator.buffer(capacity: 16 + Int(length)) - writeSimpleReply(&reply, cookie: cookie, error: n < 0 ? Self.errIO : Self.errOK) - if n >= 0 { - reply.writeBytes(readBuf[0.. Date: Sun, 2 Aug 2026 21:25:38 +0000 Subject: [PATCH 08/12] Swap a container to an area held in host memory A swap area named by an export's URL rather than a path reaches the guest the same way, so what it pushes out is held by a host process instead of a file. That is where the two differ: memory a process holds is pageable, so those pages reach the host's own swap and share the pool the rest of the system draws on, rather than taking storage of their own. Enabling an area writes a header to it, so the test asks for more than a guest that swapped nothing could have put there. --- Sources/Integration/ContainerTests.swift | 69 ++++++++++++++++++++++++ Sources/Integration/Suite.swift | 1 + 2 files changed, 70 insertions(+) diff --git a/Sources/Integration/ContainerTests.swift b/Sources/Integration/ContainerTests.swift index 9980cf21..0073485d 100644 --- a/Sources/Integration/ContainerTests.swift +++ b/Sources/Integration/ContainerTests.swift @@ -315,6 +315,75 @@ extension IntegrationSuite { } } + #if os(macOS) + /// A swap area does not have to be a file the host sets aside. Pointed at + /// an export whose blocks are held in memory, what the guest swaps out + /// lands in a host process instead, where it is pageable and reaches the + /// host's own swap rather than a store of its own. + func testContainerSwapOnMemoryBackedNBD() async throws { + let id = "test-container-swap-memory-nbd" + let bs = try await bootstrap(id) + + let store = NBDMemoryStore(size: 512.mib()) + let socketPath = "/tmp/nbd-swap-\(UUID().uuidString.prefix(8)).sock" + let server = try NBDServer(store: store, socketPath: socketPath) + defer { server.stop() } + + let buffer = BufferWriter() + let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in + // The area is reached over the export's URL rather than a path, so + // the same mount that names a file names this instead. + config.swapLayer = .block( + format: Swap.mountType, + source: server.url, + destination: "", + options: [] + ) + config.process.arguments = [ + "/bin/sh", "-c", + "awk '/SwapTotal/ { print $2 }' /proc/meminfo; " + + "sh -c 'fill=$(head -c 200000000 /dev/zero | tr \"\\0\" a); " + + "echo 200M > /sys/fs/cgroup/memory.reclaim; " + + "test ${#fill} -eq 200000000'", + ] + config.process.stdout = buffer + config.memoryInBytes = 256.mib() + config.bootLog = bs.bootLog + } + + do { + try await container.create() + try await container.start() + let status = try await container.wait() + try await container.stop() + guard status.exitCode == 0 else { + throw IntegrationError.assert(msg: "workload did not complete: \(status)") + } + + let out = String(data: buffer.data, encoding: .utf8) ?? "" + guard let total = UInt64(out.trimmingCharacters(in: .whitespacesAndNewlines)), total > 0 else { + throw IntegrationError.assert(msg: "guest enabled no swap area: '\(out)'") + } + // What the guest pushed out was held by the server, which is the + // whole point of keeping the blocks in memory rather than a file. + // The high water mark is what to ask for: a guest gives its swap + // back as it goes away, so what the export holds by now says + // nothing about what passed through it. Enabling an area writes a + // header to it, so the bar is set well above one, or a guest that + // swapped nothing would clear it. + let held = store.peakAllocatedBytes + guard held > 16.mib() else { + throw IntegrationError.assert( + msg: "guest reported \(total) kB of swap but the export never held" + + " more than \(held) bytes") + } + } catch { + try? await container.stop() + throw error + } + } + #endif + func testProcessEchoHi() async throws { let id = "test-process-echo-hi" let bs = try await bootstrap(id) diff --git a/Sources/Integration/Suite.swift b/Sources/Integration/Suite.swift index 6087aafe..8a5e05c1 100644 --- a/Sources/Integration/Suite.swift +++ b/Sources/Integration/Suite.swift @@ -640,6 +640,7 @@ struct IntegrationSuite: AsyncParsableCommand { Test("container swap", testContainerSwap), Test("container swap under pressure", testContainerSwapUnderPressure), Test("container swap reclaims freed blocks", testContainerSwapReclaimsFreedBlocks), + Test("container swap on memory backed NBD", testContainerSwapOnMemoryBackedNBD), ] + macOS26Tests() let tests: [Test] = crossPlatformTests + macOSOnlyTests #else From 727bf4476def2c2a2a59422d97a7bf4d9f50364f Mon Sep 17 00:00:00 2001 From: Aaron Paterson Date: Sun, 2 Aug 2026 23:19:51 +0000 Subject: [PATCH 09/12] Let a client tell an export what it no longer needs An export that never says it takes trims is never sent one, because the protocol forbids a client from asking otherwise, so a store grew with every block ever written to it and gave nothing back however much the guest had finished with. A swap area is rewritten constantly and never shrinks on its own, so what a guest has done with is most of what an area holds. Saying so and acting on it lets a file punch the hole out and a store in memory drop the chunk, both of which read back afterwards as the zeroes the protocol calls for. https://github.com/NetworkBlockDevice/nbd/blob/master/doc/proto.md --- Sources/Integration/NBDBackingStore.swift | 47 +++++++++++++++++++++++ Sources/Integration/NBDServer.swift | 17 +++++++- 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/Sources/Integration/NBDBackingStore.swift b/Sources/Integration/NBDBackingStore.swift index 0bf35a14..cee3c359 100644 --- a/Sources/Integration/NBDBackingStore.swift +++ b/Sources/Integration/NBDBackingStore.swift @@ -33,6 +33,17 @@ protocol NBDBackingStore: Sendable { /// Write `data` at `offset`. Returns false when the write fails. func write(offset: UInt64, data: [UInt8]) -> Bool + /// Let go of `length` bytes at `offset`, which the client has said it no + /// longer needs. Returns false only when the range is outside the export. + /// A store may release less than was asked, or nothing; what a released + /// range reads back as is the store's own affair. + /// + /// The protocol allows a server to do nothing here, but a swap area is + /// rewritten constantly and never shrinks on its own, so a store that + /// ignores this grows until the export is closed. + /// https://github.com/NetworkBlockDevice/nbd/blob/master/doc/proto.md + func discard(offset: UInt64, length: Int) -> Bool + /// Put anything held back where it belongs before replying to a flush. func flush() @@ -80,6 +91,22 @@ final class NBDFileStore: NBDBackingStore { } } + func discard(offset: UInt64, length: Int) -> Bool { + guard offset + UInt64(length) <= self.size else { + return false + } + // Punching a hole is how a file gives blocks back without changing the + // length the export reports. The punch is best effort: a range the + // filesystem will not punch, such as one it cannot align, keeps its + // blocks, which the protocol permits. + self.fd.withLock { descriptor in + var punch = fpunchhole_t( + fp_flags: 0, reserved: 0, fp_offset: off_t(offset), fp_length: off_t(length)) + _ = fcntl(descriptor, F_PUNCHHOLE, &punch) + } + return true + } + func flush() { self.fd.withLock { _ = fsync($0) } } @@ -167,6 +194,26 @@ final class NBDMemoryStore: NBDBackingStore { return true } + func discard(offset: UInt64, length: Int) -> Bool { + guard offset + UInt64(length) <= self.size else { + return false + } + self.chunks.withLock { chunks in + self.forEachSpan(offset: offset, length: length) { index, inChunk, _, span in + // Only a whole chunk can go; a chunk the client still wants part + // of keeps its place, with the discarded part zeroed. + if inChunk == 0 && span == Self.chunkSize { + chunks.removeValue(forKey: index) + } else if var chunk = chunks[index] { + chunk.replaceSubrange( + inChunk..<(inChunk + span), with: [UInt8](repeating: 0, count: span)) + chunks[index] = chunk + } + } + } + return true + } + /// Nothing is held anywhere else, so a flush has nothing to do. func flush() {} diff --git a/Sources/Integration/NBDServer.swift b/Sources/Integration/NBDServer.swift index 1c680f75..ddcf6894 100644 --- a/Sources/Integration/NBDServer.swift +++ b/Sources/Integration/NBDServer.swift @@ -118,6 +118,7 @@ private final class NBDConnectionHandler: ChannelInboundHandler { static let cmdWrite: UInt16 = 1 static let cmdDisc: UInt16 = 2 static let cmdFlush: UInt16 = 3 + static let cmdTrim: UInt16 = 4 static let flagFixedNewstyle: UInt16 = 0x1 static let flagNoZeroes: UInt16 = 0x2 @@ -126,6 +127,11 @@ private final class NBDConnectionHandler: ChannelInboundHandler { static let transmitHasFlags: UInt16 = 0x1 static let transmitSendFlush: UInt16 = 0x4 static let transmitSendFUA: UInt16 = 0x8 + /// A client is not allowed to send a trim without being told the server + /// takes them, so an export that never sets this is never asked to let + /// anything go, however much the guest has finished with. + /// https://github.com/NetworkBlockDevice/nbd/blob/master/doc/proto.md + static let transmitSendTrim: UInt16 = 0x20 static let repACK: UInt32 = 1 static let repInfo: UInt32 = 3 @@ -222,7 +228,9 @@ private final class NBDConnectionHandler: ChannelInboundHandler { return } - let transmitFlags = Self.transmitHasFlags | Self.transmitSendFlush | Self.transmitSendFUA + let transmitFlags = + Self.transmitHasFlags | Self.transmitSendFlush | Self.transmitSendFUA + | Self.transmitSendTrim switch optType { case Self.optExportName: @@ -365,6 +373,13 @@ private final class NBDConnectionHandler: ChannelInboundHandler { writeSimpleReply(&reply, cookie: cookie, error: Self.errOK) context.writeAndFlush(wrapOutboundOut(reply), promise: nil) + case Self.cmdTrim: + buffer.moveReaderIndex(forwardBy: 28) + let released = store.discard(offset: offset, length: Int(length)) + var reply = context.channel.allocator.buffer(capacity: 16) + writeSimpleReply(&reply, cookie: cookie, error: released ? Self.errOK : Self.errIO) + context.writeAndFlush(wrapOutboundOut(reply), promise: nil) + default: buffer.moveReaderIndex(forwardBy: 28) var reply = context.channel.allocator.buffer(capacity: 16) From 16230ceb9ffb55ae538a6bd848fd32d482181f6c Mon Sep 17 00:00:00 2001 From: Aaron Paterson Date: Sun, 2 Aug 2026 23:32:57 +0000 Subject: [PATCH 10/12] Hold an export to what it tells a client it does The flags a client sends with a command sit in the two bytes after the request magic, which were read past, so an export that said it took force unit access replied to one before what it wrote was durable. They are read now, and a command carrying that flag waits. An export that only reads says so and turns writes away, which the protocol requires of it and which the pod volume API already offers to ask for. A request reaching past the end is refused as invalid rather than left to fail somewhere in the store, and the errors the protocol names are all spelled out rather than the three that were in use. Writing zeroes and caching are what a client reaches for beside them: zeroes are punched out unless the client asks for the range to stay written through, and a cache hint has nothing to prepare here, so it is accepted rather than refused. https://github.com/NetworkBlockDevice/nbd/blob/master/doc/proto.md --- Sources/Integration/NBDBackingStore.swift | 21 +++++ Sources/Integration/NBDServer.swift | 102 ++++++++++++++++++++-- 2 files changed, 115 insertions(+), 8 deletions(-) diff --git a/Sources/Integration/NBDBackingStore.swift b/Sources/Integration/NBDBackingStore.swift index cee3c359..4f010782 100644 --- a/Sources/Integration/NBDBackingStore.swift +++ b/Sources/Integration/NBDBackingStore.swift @@ -27,6 +27,10 @@ protocol NBDBackingStore: Sendable { /// The size of the export, which the server reports during the handshake. var size: UInt64 { get } + /// Whether the export only ever reads. A server says so during the + /// handshake and turns away everything that would write. + var isReadOnly: Bool { get } + /// Read `length` bytes from `offset`. Returns nil when the read fails. func read(offset: UInt64, length: Int) -> [UInt8]? @@ -51,6 +55,23 @@ protocol NBDBackingStore: Sendable { func close() } +extension NBDBackingStore { + /// Most stores are written to, so saying nothing means so. + var isReadOnly: Bool { false } + + /// Whether a request of `length` bytes at `offset` lies inside the export. + /// The protocol asks a server to turn away one that does not rather than + /// let it reach the store. + /// https://github.com/NetworkBlockDevice/nbd/blob/master/doc/proto.md + func covers(offset: UInt64, length: Int) -> Bool { + guard length >= 0 else { + return false + } + let (end, overflowed) = offset.addingReportingOverflow(UInt64(length)) + return !overflowed && end <= self.size + } +} + /// A store that keeps its blocks in a file. final class NBDFileStore: NBDBackingStore { private let fd: Mutex diff --git a/Sources/Integration/NBDServer.swift b/Sources/Integration/NBDServer.swift index ddcf6894..5e61cb15 100644 --- a/Sources/Integration/NBDServer.swift +++ b/Sources/Integration/NBDServer.swift @@ -119,6 +119,13 @@ private final class NBDConnectionHandler: ChannelInboundHandler { static let cmdDisc: UInt16 = 2 static let cmdFlush: UInt16 = 3 static let cmdTrim: UInt16 = 4 + static let cmdCache: UInt16 = 5 + static let cmdWriteZeroes: UInt16 = 6 + + /// Command flags travel in the two bytes after the request magic. + /// https://github.com/NetworkBlockDevice/nbd/blob/master/doc/proto.md + static let cmdFlagFUA: UInt16 = 0x1 + static let cmdFlagNoHole: UInt16 = 0x2 static let flagFixedNewstyle: UInt16 = 0x1 static let flagNoZeroes: UInt16 = 0x2 @@ -132,6 +139,9 @@ private final class NBDConnectionHandler: ChannelInboundHandler { /// anything go, however much the guest has finished with. /// https://github.com/NetworkBlockDevice/nbd/blob/master/doc/proto.md static let transmitSendTrim: UInt16 = 0x20 + static let transmitReadOnly: UInt16 = 0x2 + static let transmitSendWriteZeroes: UInt16 = 0x40 + static let transmitSendCache: UInt16 = 0x400 static let repACK: UInt32 = 1 static let repInfo: UInt32 = 3 @@ -141,8 +151,14 @@ private final class NBDConnectionHandler: ChannelInboundHandler { // NBD error codes static let errOK: UInt32 = 0 + static let errPerm: UInt32 = 1 static let errIO: UInt32 = 5 + static let errNoMem: UInt32 = 12 + static let errInval: UInt32 = 22 + static let errNoSpc: UInt32 = 28 + static let errOverflow: UInt32 = 75 static let errNotsup: UInt32 = 95 + static let errShutdown: UInt32 = 108 private let store: NBDBackingStore private let fileSize: UInt64 @@ -228,9 +244,12 @@ private final class NBDConnectionHandler: ChannelInboundHandler { return } - let transmitFlags = + var transmitFlags = Self.transmitHasFlags | Self.transmitSendFlush | Self.transmitSendFUA - | Self.transmitSendTrim + | Self.transmitSendTrim | Self.transmitSendWriteZeroes | Self.transmitSendCache + if store.isReadOnly { + transmitFlags |= Self.transmitReadOnly + } switch optType { case Self.optExportName: @@ -316,6 +335,7 @@ private final class NBDConnectionHandler: ChannelInboundHandler { } let readerIndex = buffer.readerIndex guard let magic = buffer.getInteger(at: readerIndex, as: UInt32.self), + let cmdFlags = buffer.getInteger(at: readerIndex + 4, as: UInt16.self), let cmdType = buffer.getInteger(at: readerIndex + 6, as: UInt16.self), let cookie = buffer.getInteger(at: readerIndex + 8, as: UInt64.self), let offset = buffer.getInteger(at: readerIndex + 16, as: UInt64.self), @@ -329,6 +349,55 @@ private final class NBDConnectionHandler: ChannelInboundHandler { return } + /// A command that has been dealt with, but whose reply the + /// protocol holds back until what it wrote is durable when the + /// client asked for that. + func replyHonouringFUA(_ error: UInt32) { + if cmdFlags & Self.cmdFlagFUA != 0 && error == Self.errOK { + store.flush() + } + var reply = context.channel.allocator.buffer(capacity: 16) + writeSimpleReply(&reply, cookie: cookie, error: error) + context.writeAndFlush(wrapOutboundOut(reply), promise: nil) + } + + // An export that only reads turns away everything that writes, + // and a request reaching past the end of one is refused rather + // than passed to the store to fail on. + let writes = + cmdType == Self.cmdWrite || cmdType == Self.cmdTrim + || cmdType == Self.cmdWriteZeroes + let addressed = + cmdType == Self.cmdRead || cmdType == Self.cmdWrite || cmdType == Self.cmdTrim + || cmdType == Self.cmdWriteZeroes || cmdType == Self.cmdCache + if writes && store.isReadOnly { + if cmdType == Self.cmdWrite { + // A refused write is consumed whole, so its payload is + // never read back as the next request's header; the + // refusal waits alongside the acceptance for all of it. + guard buffer.readableBytes >= 28 + Int(length) else { + return + } + buffer.moveReaderIndex(forwardBy: 28 + Int(length)) + } else { + buffer.moveReaderIndex(forwardBy: 28) + } + replyHonouringFUA(Self.errPerm) + continue + } + if addressed && !store.covers(offset: offset, length: Int(length)) { + if cmdType == Self.cmdWrite { + guard buffer.readableBytes >= 28 + Int(length) else { + return + } + buffer.moveReaderIndex(forwardBy: 28 + Int(length)) + } else { + buffer.moveReaderIndex(forwardBy: 28) + } + replyHonouringFUA(Self.errInval) + continue + } + switch cmdType { case Self.cmdWrite: // Need the full write payload before processing. @@ -347,9 +416,7 @@ private final class NBDConnectionHandler: ChannelInboundHandler { return Int(length) } let stored = store.write(offset: offset, data: writeData) - var reply = context.channel.allocator.buffer(capacity: 16) - writeSimpleReply(&reply, cookie: cookie, error: stored ? Self.errOK : Self.errIO) - context.writeAndFlush(wrapOutboundOut(reply), promise: nil) + replyHonouringFUA(stored ? Self.errOK : Self.errIO) case Self.cmdRead: buffer.moveReaderIndex(forwardBy: 28) @@ -376,9 +443,28 @@ private final class NBDConnectionHandler: ChannelInboundHandler { case Self.cmdTrim: buffer.moveReaderIndex(forwardBy: 28) let released = store.discard(offset: offset, length: Int(length)) - var reply = context.channel.allocator.buffer(capacity: 16) - writeSimpleReply(&reply, cookie: cookie, error: released ? Self.errOK : Self.errIO) - context.writeAndFlush(wrapOutboundOut(reply), promise: nil) + replyHonouringFUA(released ? Self.errOK : Self.errIO) + + case Self.cmdWriteZeroes: + buffer.moveReaderIndex(forwardBy: 28) + // Discarding leaves zeroes behind and costs nothing, so it + // serves unless the client has said it wants the range to + // stay written through, which is what the flag is for. + let zeroed: Bool + if cmdFlags & Self.cmdFlagNoHole != 0 { + zeroed = store.write( + offset: offset, data: [UInt8](repeating: 0, count: Int(length))) + } else { + zeroed = store.discard(offset: offset, length: Int(length)) + } + replyHonouringFUA(zeroed ? Self.errOK : Self.errIO) + + case Self.cmdCache: + // A hint that a range is wanted soon. Every store here + // answers a read in the same time whether it was told or + // not, so there is nothing to prepare. + buffer.moveReaderIndex(forwardBy: 28) + replyHonouringFUA(Self.errOK) default: buffer.moveReaderIndex(forwardBy: 28) From 93ecaea92970a760c9a9c3f75b428f232ed67bd5 Mon Sep 17 00:00:00 2001 From: Aaron Paterson Date: Sun, 2 Aug 2026 23:45:04 +0000 Subject: [PATCH 11/12] Answer for the rest of what a client may ask an export A client that asks for replies carrying their own framing gets them, and a read answered that way names the offset it covers and carries a message with any error rather than a bare number. That framing is what lets an export be asked how it is laid out, so the context describing that is offered and answered: a store in memory knows which chunks it holds and a file is asked through the same seeks a sparse copy uses, so the holes a client is told about are the ones really there. Listing the exports names the one on offer. Every connection serves the one store behind the export, so a flush on any of them covers what was written on the others, which is the condition for saying that a client may spread its work across several. https://github.com/NetworkBlockDevice/nbd/blob/master/doc/proto.md --- Sources/Integration/NBDBackingStore.swift | 77 ++++++++++ Sources/Integration/NBDServer.swift | 172 +++++++++++++++++++++- 2 files changed, 245 insertions(+), 4 deletions(-) diff --git a/Sources/Integration/NBDBackingStore.swift b/Sources/Integration/NBDBackingStore.swift index 4f010782..5f12edaa 100644 --- a/Sources/Integration/NBDBackingStore.swift +++ b/Sources/Integration/NBDBackingStore.swift @@ -55,10 +55,30 @@ protocol NBDBackingStore: Sendable { func close() } +/// A run of the export that shares one allocation state, which is what a client +/// asking about block status is told. +struct NBDExtent: Sendable { + /// The extent is not allocated in the store behind the export. + static let stateHole: UInt32 = 0x1 + /// The extent reads back as zeroes. + static let stateZero: UInt32 = 0x2 + + var length: UInt32 + var flags: UInt32 +} + extension NBDBackingStore { /// Most stores are written to, so saying nothing means so. var isReadOnly: Bool { false } + /// How the export is laid out over `length` bytes from `offset`. + /// + /// A store that cannot tell says the whole range is allocated, which is + /// true of any store and costs a client only the chance to skip a hole. + func extents(offset: UInt64, length: Int) -> [NBDExtent] { + [NBDExtent(length: UInt32(length), flags: 0)] + } + /// Whether a request of `length` bytes at `offset` lies inside the export. /// The protocol asks a server to turn away one that does not rather than /// let it reach the store. @@ -128,6 +148,44 @@ final class NBDFileStore: NBDBackingStore { return true } + /// A file says where its holes are through the same seeks a sparse copy + /// uses, so a client is told what the filesystem already knows. + func extents(offset: UInt64, length: Int) -> [NBDExtent] { + self.fd.withLock { descriptor in + var runs: [NBDExtent] = [] + var at = off_t(offset) + let end = off_t(offset) + off_t(length) + while at < end { + let nextData = lseek(descriptor, at, SEEK_DATA) + if nextData < 0, errno != ENXIO { + // The seek itself failed, so nothing is known about the + // layout; say the range is allocated, which is true of any + // range and costs a client only the chance to skip a hole. + return [NBDExtent(length: UInt32(length), flags: 0)] + } + if nextData < 0 || nextData >= end { + // Nothing written between here and the end of the range. + runs.append( + NBDExtent( + length: UInt32(end - at), + flags: NBDExtent.stateHole | NBDExtent.stateZero)) + break + } + if nextData > at { + runs.append( + NBDExtent( + length: UInt32(nextData - at), + flags: NBDExtent.stateHole | NBDExtent.stateZero)) + } + let nextHole = lseek(descriptor, nextData, SEEK_HOLE) + let dataEnd = (nextHole < 0 || nextHole > end) ? end : nextHole + runs.append(NBDExtent(length: UInt32(dataEnd - nextData), flags: 0)) + at = dataEnd + } + return runs.isEmpty ? [NBDExtent(length: UInt32(length), flags: 0)] : runs + } + } + func flush() { self.fd.withLock { _ = fsync($0) } } @@ -235,6 +293,25 @@ final class NBDMemoryStore: NBDBackingStore { return true } + /// A store in memory knows exactly which chunks it holds, so it can say + /// where the holes are rather than claiming the whole range is written. + func extents(offset: UInt64, length: Int) -> [NBDExtent] { + var runs: [NBDExtent] = [] + self.chunks.withLock { chunks in + self.forEachSpan(offset: offset, length: length) { index, _, _, span in + let flags: UInt32 = + chunks[index] == nil ? (NBDExtent.stateHole | NBDExtent.stateZero) : 0 + if var last = runs.last, last.flags == flags { + last.length += UInt32(span) + runs[runs.count - 1] = last + } else { + runs.append(NBDExtent(length: UInt32(span), flags: flags)) + } + } + } + return runs + } + /// Nothing is held anywhere else, so a flush has nothing to do. func flush() {} diff --git a/Sources/Integration/NBDServer.swift b/Sources/Integration/NBDServer.swift index 5e61cb15..34d572b6 100644 --- a/Sources/Integration/NBDServer.swift +++ b/Sources/Integration/NBDServer.swift @@ -111,8 +111,18 @@ private final class NBDConnectionHandler: ChannelInboundHandler { static let optExportName: UInt32 = 1 static let optAbort: UInt32 = 2 + static let optList: UInt32 = 3 static let optInfo: UInt32 = 6 static let optGo: UInt32 = 7 + static let optStructuredReply: UInt32 = 8 + static let optListMetaContext: UInt32 = 9 + static let optSetMetaContext: UInt32 = 10 + + /// The one export a server here serves, which has no name of its own. + static let exportName = "" + /// The layout context a client asks about, and the only one answered. + static let metaContextAllocation = "base:allocation" + static let metaContextID: UInt32 = 1 static let cmdRead: UInt16 = 0 static let cmdWrite: UInt16 = 1 @@ -126,6 +136,9 @@ private final class NBDConnectionHandler: ChannelInboundHandler { /// https://github.com/NetworkBlockDevice/nbd/blob/master/doc/proto.md static let cmdFlagFUA: UInt16 = 0x1 static let cmdFlagNoHole: UInt16 = 0x2 + static let cmdFlagDF: UInt16 = 0x4 + static let cmdFlagReqOne: UInt16 = 0x8 + static let cmdBlockStatus: UInt16 = 7 static let flagFixedNewstyle: UInt16 = 0x1 static let flagNoZeroes: UInt16 = 0x2 @@ -142,10 +155,28 @@ private final class NBDConnectionHandler: ChannelInboundHandler { static let transmitReadOnly: UInt16 = 0x2 static let transmitSendWriteZeroes: UInt16 = 0x40 static let transmitSendCache: UInt16 = 0x400 + /// Every connection to an export here serves the one store behind it, so a + /// flush on any of them covers what was written on the others, which is + /// what lets a client spread its work across several. + static let transmitCanMultiConn: UInt16 = 0x100 + static let transmitSendDF: UInt16 = 0x80 static let repACK: UInt32 = 1 + static let repServer: UInt32 = 2 static let repInfo: UInt32 = 3 + static let repMetaContext: UInt32 = 4 static let repErrUnsup: UInt32 = 0x8000_0001 + + /// Structured replies carry their own framing, so that a read can name the + /// offset it answers and a hole can be sent without its zeroes. + static let structuredReplyMagic: UInt32 = 0x668e_33ef + static let replyFlagDone: UInt16 = 0x1 + static let replyTypeNone: UInt16 = 0 + static let replyTypeOffsetData: UInt16 = 1 + static let replyTypeOffsetHole: UInt16 = 2 + static let replyTypeBlockStatus: UInt16 = 5 + static let replyTypeError: UInt16 = 32769 + static let replyTypeErrorOffset: UInt16 = 32770 static let infoExport: UInt16 = 0 static let infoBlockSize: UInt16 = 3 @@ -165,6 +196,10 @@ private final class NBDConnectionHandler: ChannelInboundHandler { private let logger: Logger? private var buffer: ByteBuffer = ByteBuffer() private var state: ConnectionState = .handshake + /// Whether the client asked for replies that carry their own framing. + private var structuredReplies = false + /// Whether the client asked to be told about the export's layout. + private var metaContextSelected = false private enum ConnectionState { case handshake @@ -247,6 +282,7 @@ private final class NBDConnectionHandler: ChannelInboundHandler { var transmitFlags = Self.transmitHasFlags | Self.transmitSendFlush | Self.transmitSendFUA | Self.transmitSendTrim | Self.transmitSendWriteZeroes | Self.transmitSendCache + | Self.transmitCanMultiConn | Self.transmitSendDF if store.isReadOnly { transmitFlags |= Self.transmitReadOnly } @@ -319,6 +355,49 @@ private final class NBDConnectionHandler: ChannelInboundHandler { context.close(promise: nil) return + case Self.optList: + // One export, and it goes by no name. + buffer.moveReaderIndex(forwardBy: Int(dataLen)) + let name = Self.exportName + var listing = context.channel.allocator.buffer(capacity: 32) + writeOptReply( + &listing, optType: optType, replyType: Self.repServer, + dataLen: UInt32(4 + name.utf8.count)) + listing.writeInteger(UInt32(name.utf8.count)) + listing.writeString(name) + writeOptReply(&listing, optType: optType, replyType: Self.repACK, dataLen: 0) + context.writeAndFlush(wrapOutboundOut(listing), promise: nil) + + case Self.optStructuredReply: + buffer.moveReaderIndex(forwardBy: Int(dataLen)) + structuredReplies = true + var reply = context.channel.allocator.buffer(capacity: 20) + writeOptReply(&reply, optType: optType, replyType: Self.repACK, dataLen: 0) + context.writeAndFlush(wrapOutboundOut(reply), promise: nil) + + case Self.optSetMetaContext, Self.optListMetaContext: + // The queries name what a client wants to ask about later. + // Only the layout of the export is on offer, so a query + // that asks for it, or for everything, is answered and the + // rest are passed over. + let payload = buffer.getSlice(at: buffer.readerIndex, length: Int(dataLen)) + buffer.moveReaderIndex(forwardBy: Int(dataLen)) + let wanted = Self.queriedContexts(payload) + var reply = context.channel.allocator.buffer(capacity: 64) + if wanted { + let name = Self.metaContextAllocation + writeOptReply( + &reply, optType: optType, replyType: Self.repMetaContext, + dataLen: UInt32(4 + name.utf8.count)) + reply.writeInteger(Self.metaContextID) + reply.writeString(name) + if optType == Self.optSetMetaContext { + metaContextSelected = true + } + } + writeOptReply(&reply, optType: optType, replyType: Self.repACK, dataLen: 0) + context.writeAndFlush(wrapOutboundOut(reply), promise: nil) + default: if dataLen > 0 { buffer.moveReaderIndex(forwardBy: Int(dataLen)) @@ -370,6 +449,7 @@ private final class NBDConnectionHandler: ChannelInboundHandler { let addressed = cmdType == Self.cmdRead || cmdType == Self.cmdWrite || cmdType == Self.cmdTrim || cmdType == Self.cmdWriteZeroes || cmdType == Self.cmdCache + || cmdType == Self.cmdBlockStatus if writes && store.isReadOnly { if cmdType == Self.cmdWrite { // A refused write is consumed whole, so its payload is @@ -421,10 +501,55 @@ private final class NBDConnectionHandler: ChannelInboundHandler { case Self.cmdRead: buffer.moveReaderIndex(forwardBy: 28) let readBuf = store.read(offset: offset, length: Int(length)) - var reply = context.channel.allocator.buffer(capacity: 16 + Int(length)) - writeSimpleReply(&reply, cookie: cookie, error: readBuf == nil ? Self.errIO : Self.errOK) - if let readBuf { - reply.writeBytes(readBuf) + var reply = context.channel.allocator.buffer(capacity: 32 + Int(length)) + if structuredReplies { + // A structured read names the offset it answers, and an + // error carries a message rather than a bare number. + if let readBuf { + writeStructuredHeader( + &reply, cookie: cookie, type: Self.replyTypeOffsetData, + payload: UInt32(8 + readBuf.count), done: true) + reply.writeInteger(offset) + reply.writeBytes(readBuf) + } else { + let message = "read failed" + writeStructuredHeader( + &reply, cookie: cookie, type: Self.replyTypeError, + payload: UInt32(6 + message.utf8.count), done: true) + reply.writeInteger(Self.errIO) + reply.writeInteger(UInt16(message.utf8.count)) + reply.writeString(message) + } + } else { + writeSimpleReply( + &reply, cookie: cookie, error: readBuf == nil ? Self.errIO : Self.errOK) + if let readBuf { + reply.writeBytes(readBuf) + } + } + context.writeAndFlush(wrapOutboundOut(reply), promise: nil) + + case Self.cmdBlockStatus: + buffer.moveReaderIndex(forwardBy: 28) + var reply = context.channel.allocator.buffer(capacity: 64) + guard structuredReplies, metaContextSelected else { + // Layout can only be described in a structured reply, + // and only about a context the client asked for. + writeSimpleReply(&reply, cookie: cookie, error: Self.errInval) + context.writeAndFlush(wrapOutboundOut(reply), promise: nil) + continue + } + var runs = store.extents(offset: offset, length: Int(length)) + if cmdFlags & Self.cmdFlagReqOne != 0, let first = runs.first { + runs = [first] + } + writeStructuredHeader( + &reply, cookie: cookie, type: Self.replyTypeBlockStatus, + payload: UInt32(4 + runs.count * 8), done: true) + reply.writeInteger(Self.metaContextID) + for run in runs { + reply.writeInteger(run.length) + reply.writeInteger(run.flags) } context.writeAndFlush(wrapOutboundOut(reply), promise: nil) @@ -488,5 +613,44 @@ private final class NBDConnectionHandler: ChannelInboundHandler { buf.writeInteger(error) buf.writeInteger(cookie) } + + private func writeStructuredHeader( + _ buf: inout ByteBuffer, cookie: UInt64, type: UInt16, payload: UInt32, done: Bool + ) { + buf.writeInteger(Self.structuredReplyMagic) + buf.writeInteger(done ? Self.replyFlagDone : 0) + buf.writeInteger(type) + buf.writeInteger(cookie) + buf.writeInteger(payload) + } + + /// Whether the queries a client sent ask about the export's layout, either + /// by name or by asking for everything the server has. A request carrying + /// no queries at all is asking for the lot. + private static func queriedContexts(_ payload: ByteBuffer?) -> Bool { + guard var payload else { + return false + } + guard let nameLen = payload.readInteger(as: UInt32.self), + payload.readSlice(length: Int(nameLen)) != nil, + let queryCount = payload.readInteger(as: UInt32.self) + else { + return false + } + if queryCount == 0 { + return true + } + for _ in 0.. Date: Thu, 6 Aug 2026 23:33:54 +0000 Subject: [PATCH 12/12] Assert the export gives back what the guest frees The memory backed test asserts the release as well as the high water mark: the filler exits while the guest is still up, freeing its swap slots, and a freed cluster is discarded, so the export drops to little more than the swap header. The discards trail the exit, so the test watches the export while the machine is still up rather than sampling it once. --- Sources/Integration/ContainerTests.swift | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/Sources/Integration/ContainerTests.swift b/Sources/Integration/ContainerTests.swift index 0073485d..1b8d1dd2 100644 --- a/Sources/Integration/ContainerTests.swift +++ b/Sources/Integration/ContainerTests.swift @@ -355,7 +355,6 @@ extension IntegrationSuite { try await container.create() try await container.start() let status = try await container.wait() - try await container.stop() guard status.exitCode == 0 else { throw IntegrationError.assert(msg: "workload did not complete: \(status)") } @@ -377,6 +376,23 @@ extension IntegrationSuite { msg: "guest reported \(total) kB of swap but the export never held" + " more than \(held) bytes") } + // What grew must come back: the filler exited while the guest was + // still up, freeing its swap slots, and a freed cluster is + // discarded. The discards trail the exit, so the export is watched + // while the machine is still up rather than sampled once, until it + // holds little more than the swap header. + var residual = store.allocatedBytes + let deadline = ContinuousClock.now.advanced(by: .seconds(10)) + while residual > 32.mib(), ContinuousClock.now < deadline { + try await Task.sleep(nanoseconds: 200_000_000) + residual = store.allocatedBytes + } + try await container.stop() + guard residual <= 32.mib() else { + throw IntegrationError.assert( + msg: "export still holds \(residual) bytes after the workload" + + " freed its swap (peak \(held))") + } } catch { try? await container.stop() throw error