Merge nucleic/fuzzy-yarn-otter-2pji into dev

This commit is contained in:
2026-08-05 18:19:16 -07:00
parent 17e84c9573
commit 37c33d305b
6 changed files with 1002 additions and 198 deletions
+149 -82
View File
@@ -26,11 +26,26 @@ import SystemPackage
import struct ContainerizationOS.Terminal
/// Errors produced while committing generation-scoped container operations.
public enum LinuxContainerOperationError: Error, Sendable, Equatable {
/// External work completed after the container generation which reserved it was invalidated.
case staleGeneration(expected: UInt64, actual: UInt64)
}
/// `LinuxContainer` is an easy to use type for launching and managing the
/// full lifecycle of a Linux container ran inside of a virtual machine.
public final class LinuxContainer: Container, Sendable {
public static let maxIDLength = 64
/// Process-wide source for generation identities. Allocating from one sequence, rather than
/// restarting at zero for each `LinuxContainer` object, keeps late lifecycle evidence from an
/// old object from aliasing a replacement object's first generation.
private static let generationSequence = Atomic<UInt64>(0)
private static func allocateGeneration() -> UInt64 {
generationSequence.wrappingAdd(1, ordering: .relaxed).oldValue
}
/// The identifier of the container.
public let id: String
@@ -135,6 +150,25 @@ public final class LinuxContainer: Container, Sendable {
private let state: AsyncMutex<State>
private let _generation: Atomic<UInt64>
/// Process-wide unique identifier for the current container generation. It changes when a
/// running generation is retired, allowing callers to reject results and evidence produced by
/// an older VM instance even when its replacement uses a newly allocated container object.
public var generation: UInt64 {
_generation.load(ordering: .acquiring)
}
/// Atomically invalidate every reservation made against the current generation. The returned
/// identity is the newly installed dead-generation sentinel; no container object can later use
/// the same value for a live generation.
@discardableResult
public func markGenerationDead() -> UInt64 {
let deadGeneration = Self.allocateGeneration()
_generation.store(deadGeneration, ordering: .releasing)
return deadGeneration
}
// Ports to be allocated from for stdio and for
// unix socket relays that are sharing a guest
// uds to the host.
@@ -147,6 +181,11 @@ public final class LinuxContainer: Container, Sendable {
private let copyQueue = DispatchQueue(label: "com.apple.containerization.copy")
private enum State: Sendable {
struct ExecReservation: Sendable, Equatable {
let operationID: UUID
let generation: UInt64
}
/// The container class has been created but no live resources are running.
case initialized
/// The container's virtual machine has been setup and the runtime environment has been configured.
@@ -171,6 +210,7 @@ public final class LinuxContainer: Container, Sendable {
let process: LinuxProcess
let relayManager: UnixSocketRelayManager
var vendedProcesses: [String: LinuxProcess]
var execReservations: [String: ExecReservation]
let fileMountContext: FileMountContext
init(_ state: CreatedState, process: LinuxProcess) {
@@ -178,6 +218,7 @@ public final class LinuxContainer: Container, Sendable {
self.relayManager = state.relayManager
self.process = process
self.vendedProcesses = [:]
self.execReservations = [:]
self.fileMountContext = state.fileMountContext
}
@@ -186,6 +227,7 @@ public final class LinuxContainer: Container, Sendable {
self.relayManager = state.relayManager
self.process = state.process
self.vendedProcesses = state.vendedProcesses
self.execReservations = state.execReservations
self.fileMountContext = state.fileMountContext
}
}
@@ -195,6 +237,7 @@ public final class LinuxContainer: Container, Sendable {
let relayManager: UnixSocketRelayManager
let process: LinuxProcess
var vendedProcesses: [String: LinuxProcess]
var execReservations: [String: ExecReservation]
let fileMountContext: FileMountContext
init(_ state: StartedState) {
@@ -202,6 +245,7 @@ public final class LinuxContainer: Container, Sendable {
self.relayManager = state.relayManager
self.process = state.process
self.vendedProcesses = state.vendedProcesses
self.execReservations = state.execReservations
self.fileMountContext = state.fileMountContext
}
}
@@ -361,6 +405,7 @@ public final class LinuxContainer: Container, Sendable {
self.logger = logger
self.config = configuration
self.state = AsyncMutex(.initialized)
self._generation = Atomic(Self.allocateGeneration())
self.rootfs = rootfs
self.writableLayer = writableLayer
}
@@ -779,6 +824,10 @@ extension LinuxContainer {
relayManager = createdState.relayManager
}
// Invalidate unlocked operations as soon as stop has validated a live state. A failed
// stop is still the end of this usable generation: late dial completions must not commit.
self.markGenerationDead()
var firstError: Error?
do {
try await relayManager.stopAll()
@@ -904,85 +953,101 @@ extension LinuxContainer {
/// Execute a new process in the container. The process is not started after this call, and must be manually started
/// via the `start` method.
public func exec(_ id: String, configuration: @Sendable @escaping (inout LinuxProcessConfiguration) throws -> Void) async throws -> LinuxProcess {
try await self.state.withLock { state in
var startedState = try state.startedState("exec")
var spec = self.generateRuntimeSpec()
var config = LinuxProcessConfiguration()
try configuration(&config)
spec.process = config.toOCI()
// [Nucleic vendored patch] Per-exec memory ceiling → the exec's OCI resources, which the
// guest applies as memory.max on this exec's own cgroup (patch #9).
if let limit = config.memoryLimitInBytes {
spec.linux?.resources?.memory?.limit = Int64(limit)
}
let stdio = IOUtil.setup(
portAllocator: self.hostVsockPorts,
stdin: config.stdin,
stdout: config.stdout,
stderr: config.stderr
)
let agent = try await startedState.vm.dialAgent()
let process = LinuxProcess(
id,
containerID: self.id,
spec: spec,
io: stdio,
ociRuntimePath: self.config.ociRuntimePath,
agent: agent,
vm: startedState.vm,
logger: self.logger,
onDelete: { [weak self = self] in
await self?.removeProcess(id: id)
}
)
startedState.vendedProcesses[id] = process
state = .started(startedState)
return process
}
var config = LinuxProcessConfiguration()
try configuration(&config)
return try await makeExec(id, configuration: config)
}
/// Execute a new process in the container. The process is not started after this call, and must be manually started
/// via the `start` method.
public func exec(_ id: String, configuration: LinuxProcessConfiguration) async throws -> LinuxProcess {
try await self.state.withLock {
var state = try $0.startedState("exec")
try await makeExec(id, configuration: configuration)
}
var spec = self.generateRuntimeSpec()
spec.process = configuration.toOCI()
// [Nucleic vendored patch] Per-exec memory ceiling → the exec's OCI resources (see above).
if let limit = configuration.memoryLimitInBytes {
spec.linux?.resources?.memory?.limit = Int64(limit)
private struct ExecSnapshot: Sendable {
let vm: any VirtualMachineInstance
let reservation: State.ExecReservation
}
private func makeExec(_ id: String, configuration: LinuxProcessConfiguration) async throws -> LinuxProcess {
let snapshot = try await self.state.withLock { state in
var startedState = try state.startedState("exec")
let reservation = State.ExecReservation(operationID: UUID(), generation: self.generation)
startedState.execReservations[id] = reservation
state = .started(startedState)
return ExecSnapshot(vm: startedState.vm, reservation: reservation)
}
let agent: any VirtualMachineAgent
do {
agent = try await snapshot.vm.dialAgent()
} catch {
await releaseExecReservation(id: id, reservation: snapshot.reservation)
throw error
}
var spec = self.generateRuntimeSpec()
spec.process = configuration.toOCI()
// [Nucleic vendored patch] Per-exec memory ceiling → the exec's OCI resources, which the
// guest applies as memory.max on this exec's own cgroup (patch #9).
if let limit = configuration.memoryLimitInBytes {
spec.linux?.resources?.memory?.limit = Int64(limit)
}
let stdio = IOUtil.setup(
portAllocator: self.hostVsockPorts,
stdin: configuration.stdin,
stdout: configuration.stdout,
stderr: configuration.stderr
)
let process = LinuxProcess(
id,
containerID: self.id,
spec: spec,
io: stdio,
ociRuntimePath: self.config.ociRuntimePath,
agent: agent,
vm: snapshot.vm,
logger: self.logger,
onDelete: { [weak self = self] in
await self?.removeProcess(id: id)
}
)
let stdio = IOUtil.setup(
portAllocator: self.hostVsockPorts,
stdin: configuration.stdin,
stdout: configuration.stdout,
stderr: configuration.stderr
)
let agent = try await state.vm.dialAgent()
let process = LinuxProcess(
id,
containerID: self.id,
spec: spec,
io: stdio,
ociRuntimePath: self.config.ociRuntimePath,
agent: agent,
vm: state.vm,
logger: self.logger,
onDelete: { [weak self = self] in
await self?.removeProcess(id: id)
}
let committed = await self.state.withLock { state in
guard case .started(var startedState) = state,
self.generation == snapshot.reservation.generation,
startedState.execReservations[id] == snapshot.reservation
else {
return false
}
startedState.execReservations.removeValue(forKey: id)
startedState.vendedProcesses[id] = process
state = .started(startedState)
return true
}
guard committed else {
await releaseExecReservation(id: id, reservation: snapshot.reservation)
try? await agent.close()
throw LinuxContainerOperationError.staleGeneration(
expected: snapshot.reservation.generation,
actual: self.generation
)
}
return process
}
state.vendedProcesses[id] = process
$0 = .started(state)
return process
private func releaseExecReservation(id: String, reservation: State.ExecReservation) async {
await self.state.withLock { state in
switch state {
case .started(var startedState) where startedState.execReservations[id] == reservation:
startedState.execReservations.removeValue(forKey: id)
state = .started(startedState)
case .paused(var pausedState) where pausedState.execReservations[id] == reservation:
pausedState.execReservations.removeValue(forKey: id)
state = .paused(pausedState)
default:
break
}
}
}
@@ -1030,21 +1095,23 @@ extension LinuxContainer {
/// Get statistics for the container.
public func statistics(categories: StatCategory = .all) async throws -> ContainerStatistics {
try await self.state.withLock {
let state = try $0.startedState("statistics")
let snapshot = try await self.state.withLock { state in
let startedState = try state.startedState("statistics")
return (vm: startedState.vm, containerID: self.id, generation: self.generation)
}
let stats = try await state.vm.withAgent { agent in
let allStats = try await agent.containerStatistics(containerIDs: [self.id], categories: categories)
guard let containerStats = allStats.first else {
throw ContainerizationError(
.notFound,
message: "statistics for container \(self.id) not found"
)
}
return containerStats
return try await snapshot.vm.withAgent { agent in
let allStats = try await agent.containerStatistics(
containerIDs: [snapshot.containerID],
categories: categories
)
guard let containerStats = allStats.first else {
throw ContainerizationError(
.notFound,
message: "statistics for container \(snapshot.containerID) not found"
)
}
return stats
return containerStats
}
}