Files
nucleic/Sources/NucleicCore/Container/MemoryBalloon.swift
T
NucleicandClaude Opus 4.8 a69aa265a5 Container VMs: automatic memory reclamation via virtio balloon
Attach a virtio memory balloon to each container VM and drive its target from the
guest's live working set, so idle memory is returned to the host automatically
instead of being held until the VM is torn down — replacing the old "the VM never
returns freed memory; restart to reclaim" limitation.

- MemoryBalloon: a VZInstanceExtension that adds the balloon device (configureVZ),
  captures it (didCreate), and retargets it on the VM queue (setTarget, hysteresis).
- ContainerEngine: attach a balloon per container (unless Off) and run a single
  autoballoon loop that re-targets every live balloon each cadence; reclaimMemoryNow
  for an on-demand pass.
- MemoryManagementLevel (Off / Conservative / Balanced / Aggressive) → BalloonPolicy
  (headroom, floor, cadence, hysteresis). Single-word levels, not raw numbers; the
  target always leaves headroom over the working set (never inflates below resident
  memory) and never drops below the floor. Defaults to Balanced — automatic.
- Settings → Control: a "Memory management" picker + "Reclaim memory now" button;
  dropped the obsolete "VM doesn't return freed memory" note.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-21 20:22:35 -07:00

74 lines
3.6 KiB
Swift

import Containerization
import ContainerizationExtras
import Foundation
import Virtualization
/// Attaches a virtio **memory balloon** to a container's VM and lets the engine retarget it at
/// runtime, so unused guest memory is returned to the host instead of being held until teardown.
///
/// This is a `VZInstanceExtension`: `configureVZ` adds the balloon device while the VZ config is
/// built, and `didCreate` captures the live device once the VM exists. Upstream
/// `apple/containerization` already plumbs `VMConfiguration.extensions` into the VZ config, but
/// `LinuxContainer` — the only entry point the engine uses — didn't forward them; our vendored patch
/// adds `LinuxContainer.Configuration.vmExtensions` to bridge that gap (see
/// `third_party/containerization/PATCHES.md`).
///
/// `ContainerEngine`'s autoballoon loop drives `setTarget` from the guest's live working set; the
/// `MemoryManagementLevel` / ``BalloonPolicy`` picks how aggressively.
///
/// Thread-safety: `configureVZ`/`didCreate` run on the framework's VM-setup path while `setTarget`
/// is called from the engine actor's loop, so a lock guards the stored handles. The balloon device
/// is non-`Sendable` and must only be touched on the VM's own dispatch queue, so every mutation is
/// dispatched onto `queue`.
public final class MemoryBalloon: VZInstanceExtension, @unchecked Sendable {
private let lock = NSLock()
private var device: VZVirtioTraditionalMemoryBalloonDevice?
private var queue: DispatchQueue?
private var lastTarget: UInt64 = 0
public init() {}
/// Add a single traditional memory-balloon device to the VM configuration.
public func configureVZ(
_ config: inout VZVirtualMachineConfiguration,
allocator: any AddressAllocator<Character>,
storageDeviceCount: Int,
mountsByID: [String: [Mount]]
) throws {
config.memoryBalloonDevices = [VZVirtioTraditionalMemoryBalloonDeviceConfiguration()]
}
/// Capture the live balloon device + the VM's queue once the machine has been created.
public func didCreate(_ instance: VZVirtualMachineInstance) throws {
let vm = instance.vzVirtualMachine
let q = instance.vmQueue
// VZVirtualMachine properties must be read on its own queue.
let dev = q.sync { vm.memoryBalloonDevices.first as? VZVirtioTraditionalMemoryBalloonDevice }
lock.withLock {
self.device = dev
self.queue = q
}
}
/// Set the balloon's target guest-memory size (bytes). No-op until the device is captured, or
/// when the change is smaller than `hysteresis` (avoids churning the balloon on tiny deltas).
/// Returns the target actually applied, or `nil` if it was skipped.
@discardableResult
public func setTarget(_ bytes: UInt64, hysteresis: UInt64) -> UInt64? {
let (dev, q, apply): (VZVirtioTraditionalMemoryBalloonDevice?, DispatchQueue?, Bool) =
lock.withLock {
guard let dev = device, let q = queue else { return (nil, nil, false) }
let delta = bytes > lastTarget ? bytes - lastTarget : lastTarget - bytes
if lastTarget != 0 && delta < hysteresis { return (dev, q, false) }
lastTarget = bytes
return (dev, q, true)
}
guard apply, let dev, let q else { return nil }
// VZ requires queue-affinity, so the device is only ever touched inside this queue hop;
// `nonisolated(unsafe)` acknowledges that to the Sendable-capture check.
nonisolated(unsafe) let device = dev
q.async { device.targetVirtualMachineMemorySize = bytes }
return bytes
}
}