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
+42
View File
@@ -307,6 +307,48 @@ rebuild whenever a guest patch changes. Built locally, not in CI: the host frame
checked continuation; a wedged write costs one expendable GCD thread, and process checked continuation; a wedged write costs one expendable GCD thread, and process
deletion closing the fd still unwedges it. Marked `[Nucleic vendored patch]`. deletion closing the fd still unwedges it. Marked `[Nucleic vendored patch]`.
19. **Monotonic boundary deadlines and generation-scoped exec admission (`DeadlinePolicy.swift`,
`Vminitd.swift`, `VZVirtualMachineInstance.swift`, `LinuxContainer.swift`, `LinuxProcess.swift`).**
A central, tunable operation-class policy now supplies absolute monotonic deadlines to Phase-1
generated vminitd gRPC calls: statistics (2s), create (10s), start (15s), process control (3s),
delete (30s), and agent graceful close (3s), with bounded filesystem/config calls and explicit
process-wait timeouts. RPC expiry cancels the call; create/start timeout races schedule bounded
deletion of any partially committed guest record. Agent close force-cancels `runConnections()`
after its grace period, closing the transport, and `LinuxProcess` teardown inherits that bound.
A nil-timeout `waitProcess` remains the one intentionally greppable generated-call exception:
renewable leases are deferred until guest `ManagedProcess.wait()` removes cancelled waiters;
renewing today would leak one checked continuation per deadline.
VZ vsock connects now have a three-second exactly-once callback/deadline arbiter. Timeout or task
cancellation settles the continuation once; a Virtualization connection delivered after that is
immediately closed. The same absolute deadline covers waiting for the existing VM `AsyncLock`;
a timed-out waiter is cancelled and checks cancellation before it can connect after eventually
acquiring the lock. The lock remains in place pending the lifecycle-gate migration. Container
statistics snapshots VM/ID/generation under `LinuxContainer.state` and
performs dial/RPC/close entirely unlocked. Exec now reserves `(exec ID, operation UUID,
generation)`, dials unlocked, and commits only if the reservation and public `generation` still
match; stale completions close their new agent and throw
`LinuxContainerOperationError.staleGeneration`. Generation values now come from a process-wide
atomic sequence rather than restarting at zero per object, and public `markGenerationDead()`
lets an independent retirement lane invalidate old reservations before normal state cleanup.
Stop invokes the same invalidation before boundary cleanup; reservations are removed on dial
failure, stale commit, pause, or state replacement.
20. **Independent emergency VM stop handle (`EmergencyVMHandle.swift`,
`VZVirtualMachineInstance.swift`).** A retained, generation-labelled handle captures the raw
`VZVirtualMachine` and its required dispatch queue before higher layers expose a live container.
Its once-only `requestStop()` enqueues Virtualization's hard stop directly, without entering
`LinuxContainer.state`, the VZ instance lifecycle lock, vminitd RPCs, statistics, or the normal
engine lifecycle actor. `waitUntilStopped(deadline:)` provides a separately bounded completion
signal by observing Virtualization state on the VM queue, so callers can defer rootfs/network
reuse until the old VM is stopped and make any deadline-expiry force path explicit.
`VZVirtualMachineInstance.dialFreshAgent(deadlinePolicy:)` similarly
supplies a newly opened, caller-deadlined vsock/gRPC channel for independent health checks; it
never adopts a pooled channel or enters the instance lifecycle lock. These are generic framework
concurrency/escape primitives only; replacement quorum, admission, notification, and rate-limit
policy remain in NucleicCore.
## Re-vendoring a newer upstream commit ## Re-vendoring a newer upstream commit
1. `git clone` upstream (or copy `.build/checkouts/containerization` after bumping the URL pin 1. `git clone` upstream (or copy `.build/checkouts/containerization` after bumping the URL pin
@@ -0,0 +1,110 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-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 ContainerizationError
import Foundation
import GRPCCore
/// Monotonic deadlines for operations which cross a process or transport boundary.
///
/// Keep generated gRPC calls behind ``performGRPC(operation:deadline:_:)``. Requiring both an
/// operation class and an absolute deadline makes a call which silently falls back to generated
/// default options conspicuous during review and straightforward to reject in CI.
public struct DeadlinePolicy: Sendable {
public typealias Deadline = ContinuousClock.Instant
public enum Operation: String, Sendable {
case dial
case statistics
case createProcess
case startProcess
case processControl
case deleteProcess
case agentClose
case waitProcess
case filesystem
}
public struct Bounds: Sendable {
public var dial: Duration = .seconds(3)
public var statistics: Duration = .seconds(2)
public var createProcess: Duration = .seconds(10)
public var startProcess: Duration = .seconds(15)
public var processControl: Duration = .seconds(3)
public var deleteProcess: Duration = .seconds(30)
public var agentClose: Duration = .seconds(3)
public var waitProcess: Duration = .seconds(30)
public var filesystem: Duration = .seconds(10)
public init() {}
}
public static let standard = DeadlinePolicy()
public var bounds: Bounds
public init(bounds: Bounds = Bounds()) {
self.bounds = bounds
}
public func bound(for operation: Operation) -> Duration {
switch operation {
case .dial: bounds.dial
case .statistics: bounds.statistics
case .createProcess: bounds.createProcess
case .startProcess: bounds.startProcess
case .processControl: bounds.processControl
case .deleteProcess: bounds.deleteProcess
case .agentClose: bounds.agentClose
case .waitProcess: bounds.waitProcess
case .filesystem: bounds.filesystem
}
}
public func deadline(for operation: Operation, clock: ContinuousClock = .init()) -> Deadline {
clock.now.advanced(by: bound(for: operation))
}
/// Invoke a generated gRPC call with the remaining part of an absolute monotonic deadline.
/// gRPC aborts the RPC when this timeout expires, so the server operation and client-side
/// continuation aren't left live after the caller receives a timeout.
public func performGRPC<Result: Sendable>(
operation: Operation,
deadline: Deadline,
_ call: @Sendable (GRPCCore.CallOptions) async throws -> Result
) async throws -> Result {
let remaining = ContinuousClock().now.duration(to: deadline)
guard remaining > .zero else {
throw timeoutError(for: operation)
}
var options = GRPCCore.CallOptions.defaults
options.timeout = remaining
do {
return try await call(options)
} catch let error as RPCError where error.code == .deadlineExceeded {
throw timeoutError(for: operation, cause: error)
}
}
public func timeoutError(for operation: Operation, cause: (any Error)? = nil) -> ContainerizationError {
ContainerizationError(
.timeout,
message: "\(operation.rawValue) exceeded its monotonic deadline",
cause: cause
)
}
}
@@ -0,0 +1,149 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-2026 Apple Inc. and the Containerization project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
#if os(macOS)
import Foundation
import Synchronization
@preconcurrency import Virtualization
/// A once-only escape hatch for stopping a VM when its normal lifecycle path is unavailable.
///
/// The handle deliberately retains only Virtualization's VM object, its required dispatch queue,
/// and caller-supplied generation metadata. Requesting a stop does not enter a container state
/// gate, an instance lifecycle lock, or a guest-agent/RPC path.
public final class EmergencyVMHandle: @unchecked Sendable {
private struct State {
enum Phase {
case ready
case requested
case stopped
}
var phase: Phase = .ready
var waiters: [UUID: CheckedContinuation<Bool, Never>] = [:]
}
/// Stable identity for this exact retained VM, independent of caller generation numbering.
public let id = UUID()
public let generation: UInt64
private nonisolated(unsafe) let virtualMachine: VZVirtualMachine
private let queue: DispatchQueue
private let state = Mutex(State())
init(virtualMachine: VZVirtualMachine, queue: DispatchQueue, generation: UInt64) {
self.virtualMachine = virtualMachine
self.queue = queue
self.generation = generation
}
/// Whether Virtualization has reported stop completion for this exact VM.
public var hasStopped: Bool {
state.withLock {
if case .stopped = $0.phase { return true }
return false
}
}
/// Enqueue a hard VM stop exactly once. Returns `true` only for the caller which requested it.
/// Completion is intentionally not awaited: this API is the independent request lane used when
/// normal lifecycle tasks may themselves be unable to make progress.
@discardableResult
public func requestStop() -> Bool {
let shouldRequest = state.withLock { state in
guard case .ready = state.phase else { return false }
state.phase = .requested
return true
}
guard shouldRequest else { return false }
queue.async { [self] in
if virtualMachine.state == .stopped {
finishStopped()
return
}
if virtualMachine.state == .stopping {
scheduleStopStateObservation()
return
}
virtualMachine.stop { [weak self] _ in
self?.scheduleStopStateObservation(immediate: true)
}
}
return true
}
/// Wait until Virtualization reports this exact VM stopped, but never beyond `deadline`.
/// `false` is an explicit force-retirement signal for the caller; the stop request itself stays
/// independent and lock-free even if the normal instance lifecycle lane is wedged.
public func waitUntilStopped(deadline: ContinuousClock.Instant) async -> Bool {
let waiterID = UUID()
return await withCheckedContinuation { continuation in
let alreadyStopped = state.withLock { state in
guard case .stopped = state.phase else {
state.waiters[waiterID] = continuation
return false
}
return true
}
if alreadyStopped {
continuation.resume(returning: true)
return
}
Task { [weak self] in
do {
try await ContinuousClock().sleep(until: deadline)
} catch {
// Cancellation is also a bounded failure for the waiting caller. The retained
// stop request continues independently on Virtualization's queue.
}
self?.finishWaiter(waiterID, stopped: false)
}
}
}
private func scheduleStopStateObservation(immediate: Bool = false) {
queue.asyncAfter(deadline: .now() + (immediate ? 0 : 0.05)) { [weak self] in
guard let self else { return }
guard case .requested = state.withLock({ $0.phase }) else { return }
if virtualMachine.state == .stopped {
finishStopped()
} else {
scheduleStopStateObservation()
}
}
}
private func finishStopped() {
let waiters = state.withLock { state -> [CheckedContinuation<Bool, Never>] in
guard case .stopped = state.phase else {
state.phase = .stopped
let waiters = Array(state.waiters.values)
state.waiters.removeAll()
return waiters
}
return []
}
for waiter in waiters { waiter.resume(returning: true) }
}
private func finishWaiter(_ id: UUID, stopped: Bool) {
let waiter = state.withLock { $0.waiters.removeValue(forKey: id) }
waiter?.resume(returning: stopped)
}
}
#endif
+121 -54
View File
@@ -26,11 +26,26 @@ import SystemPackage
import struct ContainerizationOS.Terminal 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 /// `LinuxContainer` is an easy to use type for launching and managing the
/// full lifecycle of a Linux container ran inside of a virtual machine. /// full lifecycle of a Linux container ran inside of a virtual machine.
public final class LinuxContainer: Container, Sendable { public final class LinuxContainer: Container, Sendable {
public static let maxIDLength = 64 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. /// The identifier of the container.
public let id: String public let id: String
@@ -135,6 +150,25 @@ public final class LinuxContainer: Container, Sendable {
private let state: AsyncMutex<State> 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 // Ports to be allocated from for stdio and for
// unix socket relays that are sharing a guest // unix socket relays that are sharing a guest
// uds to the host. // uds to the host.
@@ -147,6 +181,11 @@ public final class LinuxContainer: Container, Sendable {
private let copyQueue = DispatchQueue(label: "com.apple.containerization.copy") private let copyQueue = DispatchQueue(label: "com.apple.containerization.copy")
private enum State: Sendable { 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. /// The container class has been created but no live resources are running.
case initialized case initialized
/// The container's virtual machine has been setup and the runtime environment has been configured. /// 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 process: LinuxProcess
let relayManager: UnixSocketRelayManager let relayManager: UnixSocketRelayManager
var vendedProcesses: [String: LinuxProcess] var vendedProcesses: [String: LinuxProcess]
var execReservations: [String: ExecReservation]
let fileMountContext: FileMountContext let fileMountContext: FileMountContext
init(_ state: CreatedState, process: LinuxProcess) { init(_ state: CreatedState, process: LinuxProcess) {
@@ -178,6 +218,7 @@ public final class LinuxContainer: Container, Sendable {
self.relayManager = state.relayManager self.relayManager = state.relayManager
self.process = process self.process = process
self.vendedProcesses = [:] self.vendedProcesses = [:]
self.execReservations = [:]
self.fileMountContext = state.fileMountContext self.fileMountContext = state.fileMountContext
} }
@@ -186,6 +227,7 @@ public final class LinuxContainer: Container, Sendable {
self.relayManager = state.relayManager self.relayManager = state.relayManager
self.process = state.process self.process = state.process
self.vendedProcesses = state.vendedProcesses self.vendedProcesses = state.vendedProcesses
self.execReservations = state.execReservations
self.fileMountContext = state.fileMountContext self.fileMountContext = state.fileMountContext
} }
} }
@@ -195,6 +237,7 @@ public final class LinuxContainer: Container, Sendable {
let relayManager: UnixSocketRelayManager let relayManager: UnixSocketRelayManager
let process: LinuxProcess let process: LinuxProcess
var vendedProcesses: [String: LinuxProcess] var vendedProcesses: [String: LinuxProcess]
var execReservations: [String: ExecReservation]
let fileMountContext: FileMountContext let fileMountContext: FileMountContext
init(_ state: StartedState) { init(_ state: StartedState) {
@@ -202,6 +245,7 @@ public final class LinuxContainer: Container, Sendable {
self.relayManager = state.relayManager self.relayManager = state.relayManager
self.process = state.process self.process = state.process
self.vendedProcesses = state.vendedProcesses self.vendedProcesses = state.vendedProcesses
self.execReservations = state.execReservations
self.fileMountContext = state.fileMountContext self.fileMountContext = state.fileMountContext
} }
} }
@@ -361,6 +405,7 @@ public final class LinuxContainer: Container, Sendable {
self.logger = logger self.logger = logger
self.config = configuration self.config = configuration
self.state = AsyncMutex(.initialized) self.state = AsyncMutex(.initialized)
self._generation = Atomic(Self.allocateGeneration())
self.rootfs = rootfs self.rootfs = rootfs
self.writableLayer = writableLayer self.writableLayer = writableLayer
} }
@@ -779,6 +824,10 @@ extension LinuxContainer {
relayManager = createdState.relayManager 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? var firstError: Error?
do { do {
try await relayManager.stopAll() try await relayManager.stopAll()
@@ -904,67 +953,52 @@ extension LinuxContainer {
/// Execute a new process in the container. The process is not started after this call, and must be manually started /// Execute a new process in the container. The process is not started after this call, and must be manually started
/// via the `start` method. /// via the `start` method.
public func exec(_ id: String, configuration: @Sendable @escaping (inout LinuxProcessConfiguration) throws -> Void) async throws -> LinuxProcess { 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() var config = LinuxProcessConfiguration()
try configuration(&config) try configuration(&config)
spec.process = config.toOCI() return try await makeExec(id, configuration: config)
// [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
}
} }
/// Execute a new process in the container. The process is not started after this call, and must be manually started /// Execute a new process in the container. The process is not started after this call, and must be manually started
/// via the `start` method. /// via the `start` method.
public func exec(_ id: String, configuration: LinuxProcessConfiguration) async throws -> LinuxProcess { public func exec(_ id: String, configuration: LinuxProcessConfiguration) async throws -> LinuxProcess {
try await self.state.withLock { try await makeExec(id, configuration: configuration)
var state = try $0.startedState("exec") }
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() var spec = self.generateRuntimeSpec()
spec.process = configuration.toOCI() spec.process = configuration.toOCI()
// [Nucleic vendored patch] Per-exec memory ceiling → the exec's OCI resources (see above). // [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 { if let limit = configuration.memoryLimitInBytes {
spec.linux?.resources?.memory?.limit = Int64(limit) spec.linux?.resources?.memory?.limit = Int64(limit)
} }
let stdio = IOUtil.setup( let stdio = IOUtil.setup(
portAllocator: self.hostVsockPorts, portAllocator: self.hostVsockPorts,
stdin: configuration.stdin, stdin: configuration.stdin,
stdout: configuration.stdout, stdout: configuration.stdout,
stderr: configuration.stderr stderr: configuration.stderr
) )
let agent = try await state.vm.dialAgent()
let process = LinuxProcess( let process = LinuxProcess(
id, id,
containerID: self.id, containerID: self.id,
@@ -972,18 +1006,49 @@ extension LinuxContainer {
io: stdio, io: stdio,
ociRuntimePath: self.config.ociRuntimePath, ociRuntimePath: self.config.ociRuntimePath,
agent: agent, agent: agent,
vm: state.vm, vm: snapshot.vm,
logger: self.logger, logger: self.logger,
onDelete: { [weak self = self] in onDelete: { [weak self = self] in
await self?.removeProcess(id: id) await self?.removeProcess(id: id)
} }
) )
state.vendedProcesses[id] = process let committed = await self.state.withLock { state in
$0 = .started(state) 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 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
}
}
} }
/// Dial a vsock port in the container. /// Dial a vsock port in the container.
@@ -1030,22 +1095,24 @@ extension LinuxContainer {
/// Get statistics for the container. /// Get statistics for the container.
public func statistics(categories: StatCategory = .all) async throws -> ContainerStatistics { public func statistics(categories: StatCategory = .all) async throws -> ContainerStatistics {
try await self.state.withLock { let snapshot = try await self.state.withLock { state in
let state = try $0.startedState("statistics") 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 return try await snapshot.vm.withAgent { agent in
let allStats = try await agent.containerStatistics(containerIDs: [self.id], categories: categories) let allStats = try await agent.containerStatistics(
containerIDs: [snapshot.containerID],
categories: categories
)
guard let containerStats = allStats.first else { guard let containerStats = allStats.first else {
throw ContainerizationError( throw ContainerizationError(
.notFound, .notFound,
message: "statistics for container \(self.id) not found" message: "statistics for container \(snapshot.containerID) not found"
) )
} }
return containerStats return containerStats
} }
return stats
}
} }
// Perform filesystem operations in the container. // Perform filesystem operations in the container.
@@ -25,6 +25,191 @@ import NIOPosix
import Synchronization import Synchronization
@preconcurrency import Virtualization @preconcurrency import Virtualization
/// Exactly-once arbitration for Virtualization's callback-only vsock connect API. The API doesn't
/// expose a cancellation token, so a timeout settles the caller immediately and a connection which
/// arrives after that settlement is closed instead of being leaked or adopted.
private final class VZDialAttempt: @unchecked Sendable {
private enum State {
case idle
case pending(CheckedContinuation<VZVirtioSocketConnection, any Error>, Task<Void, Never>?)
case cancelled
case finished
}
private let state = Mutex<State>(.idle)
func install(_ continuation: CheckedContinuation<VZVirtioSocketConnection, any Error>) {
let wasCancelled = state.withLock { state in
switch state {
case .idle:
state = .pending(continuation, nil)
return false
case .cancelled:
state = .finished
return true
case .pending, .finished:
preconditionFailure("VZ dial continuation installed more than once")
}
}
if wasCancelled {
continuation.resume(throwing: CancellationError())
}
}
func setTimer(_ timer: Task<Void, Never>) {
let shouldCancel = state.withLock { state in
guard case .pending(let continuation, nil) = state else {
return true
}
state = .pending(continuation, timer)
return false
}
if shouldCancel {
timer.cancel()
}
}
var isPending: Bool {
state.withLock {
if case .pending = $0 { return true }
return false
}
}
func succeed(_ connection: VZVirtioSocketConnection) {
let completion = state.withLock { state -> (CheckedContinuation<VZVirtioSocketConnection, any Error>, Task<Void, Never>?)? in
guard case .pending(let continuation, let timer) = state else { return nil }
state = .finished
return (continuation, timer)
}
guard let completion else {
connection.close()
return
}
completion.1?.cancel()
// VZ's connection type isn't Sendable, but ownership transfers exactly once to the
// continuation here and this attempt never touches the successful connection again.
nonisolated(unsafe) let connection = connection
completion.0.resume(returning: connection)
}
func fail(_ error: any Error) {
let completion = state.withLock { state -> (CheckedContinuation<VZVirtioSocketConnection, any Error>, Task<Void, Never>?)? in
guard case .pending(let continuation, let timer) = state else { return nil }
state = .finished
return (continuation, timer)
}
guard let completion else { return }
completion.1?.cancel()
completion.0.resume(throwing: error)
}
func cancel() {
let completion = state.withLock { state -> (CheckedContinuation<VZVirtioSocketConnection, any Error>, Task<Void, Never>?)? in
switch state {
case .idle:
state = .cancelled
return nil
case .pending(let continuation, let timer):
state = .finished
return (continuation, timer)
case .cancelled, .finished:
return nil
}
}
guard let completion else { return }
completion.1?.cancel()
completion.0.resume(throwing: CancellationError())
}
}
/// Deadline arbiter for the whole locked dial operation. `AsyncLock` does not remove cancelled
/// waiters, so the caller is settled at the deadline while the cancelled waiter performs no connect
/// if it is eventually resumed and reaches the cancellation check inside the lock.
private final class VZLockedDialAttempt<Value: Sendable>: @unchecked Sendable {
private struct State {
var continuation: CheckedContinuation<Value, any Error>?
var operation: Task<Void, Never>?
var timer: Task<Void, Never>?
var finished = false
}
private let state = Mutex(State())
func install(_ continuation: CheckedContinuation<Value, any Error>) {
let wasCancelled = state.withLock { state in
guard !state.finished else { return true }
state.continuation = continuation
return false
}
if wasCancelled {
continuation.resume(throwing: CancellationError())
}
}
func setOperation(_ operation: Task<Void, Never>) {
let cancel = state.withLock { state in
guard !state.finished else { return true }
state.operation = operation
return false
}
if cancel { operation.cancel() }
}
func setTimer(_ timer: Task<Void, Never>) {
let cancel = state.withLock { state in
guard !state.finished else { return true }
state.timer = timer
return false
}
if cancel { timer.cancel() }
}
func succeed(_ value: Value) {
finish(.success(value), cancelOperation: false)
}
func fail(_ error: any Error, cancelOperation: Bool = false) {
finish(.failure(error), cancelOperation: cancelOperation)
}
func cancel() {
let completion = state.withLock { state -> (
CheckedContinuation<Value, any Error>?, Task<Void, Never>?, Task<Void, Never>?
)? in
guard !state.finished else { return nil }
state.finished = true
let completion = (state.continuation, state.operation, state.timer)
state.continuation = nil
state.operation = nil
state.timer = nil
return completion
}
guard let completion else { return }
completion.1?.cancel()
completion.2?.cancel()
completion.0?.resume(throwing: CancellationError())
}
private func finish(_ result: Result<Value, any Error>, cancelOperation: Bool) {
let completion = state.withLock { state -> (
CheckedContinuation<Value, any Error>, Task<Void, Never>?, Task<Void, Never>?
)? in
guard !state.finished, let continuation = state.continuation else { return nil }
state.finished = true
let completion = (continuation, state.operation, state.timer)
state.continuation = nil
state.operation = nil
state.timer = nil
return completion
}
guard let completion else { return }
completion.2?.cancel()
if cancelOperation { completion.1?.cancel() }
completion.0.resume(with: result)
}
}
public final class VZVirtualMachineInstance: Sendable { public final class VZVirtualMachineInstance: Sendable {
public typealias Agent = Vminitd public typealias Agent = Vminitd
@@ -40,6 +225,11 @@ public final class VZVirtualMachineInstance: Sendable {
/// The dispatch queue used for VZ operations. /// The dispatch queue used for VZ operations.
public var vmQueue: DispatchQueue { queue } public var vmQueue: DispatchQueue { queue }
/// Capture the independent, once-only stop lane for this exact VM generation.
public func emergencyStopHandle(generation: UInt64) -> EmergencyVMHandle {
EmergencyVMHandle(virtualMachine: vm, queue: queue, generation: generation)
}
/// Mutate the mount registry. /// Mutate the mount registry.
public func withMountRegistry<T: Sendable>(_ body: (inout sending [String: [AttachedFilesystem]]) throws -> sending T) rethrows -> T { public func withMountRegistry<T: Sendable>(_ body: (inout sending [String: [AttachedFilesystem]]) throws -> sending T) rethrows -> T {
try _mounts.withLock(body) try _mounts.withLock(body)
@@ -253,14 +443,15 @@ extension VZVirtualMachineInstance: VirtualMachineInstance {
} }
public func dialAgent() async throws -> Vminitd { public func dialAgent() async throws -> Vminitd {
try await lock.withLock { _ in let deadline = DeadlinePolicy.standard.deadline(for: .dial)
return try await withLockedDialDeadline(deadline: deadline) {
do { do {
let conn = try await self.vm.connect( return try await self.lock.withLock { _ in
queue: self.queue, try Task.checkCancellation()
port: Vminitd.port let conn = try await self.connect(port: Vminitd.port, deadline: deadline)
)
let handle = try conn.dupHandle() let handle = try conn.dupHandle()
return try await Vminitd(connection: handle, group: self.group) return try await Vminitd(connection: handle, group: self.group)
}
} catch { } catch {
if let err = error as? ContainerizationError { if let err = error as? ContainerizationError {
throw err throw err
@@ -274,14 +465,38 @@ extension VZVirtualMachineInstance: VirtualMachineInstance {
} }
} }
public func dial(_ port: UInt32) async throws -> FileHandle { /// Open a brand-new agent/vsock channel without entering the instance lifecycle lock.
try await lock.withLock { _ in /// Intended for independent health checks whose caller supplies a short deadline policy.
public func dialFreshAgent(deadlinePolicy: DeadlinePolicy) async throws -> Vminitd {
do { do {
let conn = try await self.vm.connect( let conn = try await self.connect(
queue: self.queue, port: Vminitd.port,
port: port deadline: deadlinePolicy.deadline(for: .dial),
deadlinePolicy: deadlinePolicy)
let handle = try conn.dupHandle()
return try await Vminitd(
connection: handle, group: self.group, deadlinePolicy: deadlinePolicy)
} catch {
if let err = error as? ContainerizationError {
throw err
}
throw ContainerizationError(
.internalError,
message: "failed to dial fresh agent channel",
cause: error
) )
}
}
public func dial(_ port: UInt32) async throws -> FileHandle {
let deadline = DeadlinePolicy.standard.deadline(for: .dial)
return try await withLockedDialDeadline(deadline: deadline) {
do {
return try await self.lock.withLock { _ in
try Task.checkCancellation()
let conn = try await self.connect(port: port, deadline: deadline)
return try conn.dupHandle() return try conn.dupHandle()
}
} catch { } catch {
if let err = error as? ContainerizationError { if let err = error as? ContainerizationError {
throw err throw err
@@ -295,6 +510,80 @@ extension VZVirtualMachineInstance: VirtualMachineInstance {
} }
} }
private func withLockedDialDeadline<Value: Sendable>(
deadline: DeadlinePolicy.Deadline,
operation: @escaping @Sendable () async throws -> Value
) async throws -> Value {
let attempt = VZLockedDialAttempt<Value>()
return try await withTaskCancellationHandler {
try await withCheckedThrowingContinuation { continuation in
attempt.install(continuation)
let operationTask = Task {
do {
attempt.succeed(try await operation())
} catch {
attempt.fail(error)
}
}
attempt.setOperation(operationTask)
let timer = Task {
do {
try await ContinuousClock().sleep(until: deadline)
} catch {
return
}
attempt.fail(
DeadlinePolicy.standard.timeoutError(for: .dial),
cancelOperation: true)
}
attempt.setTimer(timer)
}
} onCancel: {
attempt.cancel()
}
}
private func connect(
port: UInt32,
deadline: DeadlinePolicy.Deadline,
deadlinePolicy: DeadlinePolicy = .standard
) async throws -> VZVirtioSocketConnection {
let attempt = VZDialAttempt()
return try await withTaskCancellationHandler {
try await withCheckedThrowingContinuation { continuation in
attempt.install(continuation)
let timer = Task {
do {
try await ContinuousClock().sleep(until: deadline)
} catch {
return
}
attempt.fail(deadlinePolicy.timeoutError(for: .dial))
}
attempt.setTimer(timer)
self.queue.async {
guard attempt.isPending else { return }
guard let vsock = self.vm.socketDevices[0] as? VZVirtioSocketDevice else {
attempt.fail(ContainerizationError(.invalidArgument, message: "no vsock device"))
return
}
vsock.connect(toPort: port) { result in
switch result {
case .success(let connection):
attempt.succeed(connection)
case .failure(let error):
attempt.fail(error)
}
}
}
}
} onCancel: {
attempt.cancel()
}
}
public func listen(_ port: UInt32) throws -> VsockListener { public func listen(_ port: UInt32) throws -> VsockListener {
let stream = VsockListener(port: port, stopListen: self.stopListen) let stream = VsockListener(port: port, stopListen: self.stopListen)
let listener = VZVirtioSocketListener() let listener = VZVirtioSocketListener()
+223 -76
View File
@@ -33,8 +33,13 @@ public struct Vminitd: Sendable {
let client: Com_Apple_Containerization_Sandbox_V3_SandboxContext.Client<HTTP2ClientTransport.WrappedChannel> let client: Com_Apple_Containerization_Sandbox_V3_SandboxContext.Client<HTTP2ClientTransport.WrappedChannel>
public let grpcClient: GRPCClient<HTTP2ClientTransport.WrappedChannel> public let grpcClient: GRPCClient<HTTP2ClientTransport.WrappedChannel>
private let connectionTask: Task<Void, Error> private let connectionTask: Task<Void, Error>
private let deadlinePolicy: DeadlinePolicy
public init(connection: FileHandle, group: any EventLoopGroup) async throws { public init(
connection: FileHandle,
group: any EventLoopGroup,
deadlinePolicy: DeadlinePolicy = .standard
) async throws {
// Configure the gRPC pipeline from inside the channel initializer — before the channel // Configure the gRPC pipeline from inside the channel initializer — before the channel
// becomes active — so no early server frames (e.g. SETTINGS) are dropped. `configure` is // becomes active — so no early server frames (e.g. SETTINGS) are dropped. `configure` is
// supplied by `wrapping(config:serviceConfig:makeChannel:)` and must be called exactly once. // supplied by `wrapping(config:serviceConfig:makeChannel:)` and must be called exactly once.
@@ -54,6 +59,7 @@ public struct Vminitd: Sendable {
let grpcClient = GRPCClient(transport: transport) let grpcClient = GRPCClient(transport: transport)
self.grpcClient = grpcClient self.grpcClient = grpcClient
self.client = Com_Apple_Containerization_Sandbox_V3_SandboxContext.Client(wrapping: self.grpcClient) self.client = Com_Apple_Containerization_Sandbox_V3_SandboxContext.Client(wrapping: self.grpcClient)
self.deadlinePolicy = deadlinePolicy
// Not very structured concurrency friendly, but we'd need to expose a way on the protocol to "run" the // Not very structured concurrency friendly, but we'd need to expose a way on the protocol to "run" the
// agent otherwise, which some agents might not even need. // agent otherwise, which some agents might not even need.
self.connectionTask = Task { self.connectionTask = Task {
@@ -64,8 +70,42 @@ public struct Vminitd: Sendable {
/// Close the connection to the guest agent. /// Close the connection to the guest agent.
public func close() async throws { public func close() async throws {
self.grpcClient.beginGracefulShutdown() self.grpcClient.beginGracefulShutdown()
let deadline = deadlinePolicy.deadline(for: .agentClose)
do {
try await withThrowingTaskGroup(of: Void.self) { group in
group.addTask {
try await self.connectionTask.value try await self.connectionTask.value
} }
group.addTask {
try await ContinuousClock().sleep(until: deadline)
throw self.deadlinePolicy.timeoutError(for: .agentClose)
}
do {
_ = try await group.next()
group.cancelAll()
} catch {
// Cancelling the task executing runConnections() is GRPCCore's force-shutdown
// API. It cancels in-flight RPCs and closes the underlying transport channel.
self.connectionTask.cancel()
group.cancelAll()
throw error
}
}
} catch let error as ContainerizationError where error.code == .timeout {
// The transport has already been force-shut down above. A bounded close has fulfilled
// its cleanup contract even though graceful shutdown did not finish in time.
return
}
}
private func rpc<Result: Sendable>(
operation: DeadlinePolicy.Operation,
deadline: DeadlinePolicy.Deadline,
_ call: @Sendable (GRPCCore.CallOptions) async throws -> Result
) async throws -> Result {
try await deadlinePolicy.performGRPC(operation: operation, deadline: deadline, call)
}
} }
extension Vminitd: VirtualMachineAgent { extension Vminitd: VirtualMachineAgent {
@@ -87,8 +127,7 @@ extension Vminitd: VirtualMachineAgent {
} }
public func writeFile(path: String, data: Data, flags: WriteFileFlags, mode: UInt32) async throws { public func writeFile(path: String, data: Data, flags: WriteFileFlags, mode: UInt32) async throws {
_ = try await client.writeFile( let request = Com_Apple_Containerization_Sandbox_V3_WriteFileRequest.with {
.with {
$0.path = path $0.path = path
$0.mode = mode $0.mode = mode
$0.data = data $0.data = data
@@ -97,17 +136,24 @@ extension Vminitd: VirtualMachineAgent {
$0.createIfMissing = flags.create $0.createIfMissing = flags.create
$0.createParentDirs = flags.createParentDirectories $0.createParentDirs = flags.createParentDirectories
} }
}) }
let operation = DeadlinePolicy.Operation.filesystem
_ = try await rpc(operation: operation, deadline: deadlinePolicy.deadline(for: operation)) { options in
try await client.writeFile(request, options: options)
}
} }
/// Get statistics for containers. If `containerIDs` is empty returns stats for all containers /// Get statistics for containers. If `containerIDs` is empty returns stats for all containers
/// in the guest. If `categories` is empty, all categories are returned. /// in the guest. If `categories` is empty, all categories are returned.
public func containerStatistics(containerIDs: [String], categories: StatCategory) async throws -> [ContainerStatistics] { public func containerStatistics(containerIDs: [String], categories: StatCategory) async throws -> [ContainerStatistics] {
let response = try await client.containerStatistics( let request = Com_Apple_Containerization_Sandbox_V3_ContainerStatisticsRequest.with {
.with {
$0.containerIds = containerIDs $0.containerIds = containerIDs
$0.categories = categories.toProtoCategories() $0.categories = categories.toProtoCategories()
}) }
let operation = DeadlinePolicy.Operation.statistics
let response = try await rpc(operation: operation, deadline: deadlinePolicy.deadline(for: operation)) { options in
try await client.containerStatistics(request, options: options)
}
return response.containers.map { protoStats in return response.containers.map { protoStats in
ContainerStatistics( ContainerStatistics(
@@ -184,41 +230,56 @@ extension Vminitd: VirtualMachineAgent {
/// Mount a filesystem in the sandbox's environment. /// Mount a filesystem in the sandbox's environment.
public func mount(_ mount: ContainerizationOCI.Mount) async throws { public func mount(_ mount: ContainerizationOCI.Mount) async throws {
_ = try await client.mount( let request = Com_Apple_Containerization_Sandbox_V3_MountRequest.with {
.with {
$0.type = mount.type $0.type = mount.type
$0.source = mount.source $0.source = mount.source
$0.destination = mount.destination $0.destination = mount.destination
$0.options = mount.options $0.options = mount.options
}) }
let operation = DeadlinePolicy.Operation.filesystem
_ = try await rpc(operation: operation, deadline: deadlinePolicy.deadline(for: operation)) { options in
try await client.mount(request, options: options)
}
} }
/// Unmount a filesystem in the sandbox's environment. /// Unmount a filesystem in the sandbox's environment.
public func umount(path: String, flags: Int32) async throws { public func umount(path: String, flags: Int32) async throws {
_ = try await client.umount( let request = Com_Apple_Containerization_Sandbox_V3_UmountRequest.with {
.with {
$0.path = path $0.path = path
$0.flags = flags $0.flags = flags
}) }
let operation = DeadlinePolicy.Operation.filesystem
_ = try await rpc(operation: operation, deadline: deadlinePolicy.deadline(for: operation)) { options in
try await client.umount(request, options: options)
}
} }
/// Create a directory inside the sandbox's environment. /// Create a directory inside the sandbox's environment.
public func mkdir(path: String, all: Bool, perms: UInt32) async throws { public func mkdir(path: String, all: Bool, perms: UInt32) async throws {
_ = try await client.mkdir( let request = Com_Apple_Containerization_Sandbox_V3_MkdirRequest.with {
.with {
$0.path = path $0.path = path
$0.all = all $0.all = all
$0.perms = perms $0.perms = perms
}) }
let operation = DeadlinePolicy.Operation.filesystem
_ = try await rpc(operation: operation, deadline: deadlinePolicy.deadline(for: operation)) { options in
try await client.mkdir(request, options: options)
}
} }
/// Perform a filesystem operation on a path inside the sandbox's environment. /// Perform a filesystem operation on a path inside the sandbox's environment.
public func filesystemOperation(operation: FilesystemOperation, path: String) async throws { public func filesystemOperation(operation: FilesystemOperation, path: String) async throws {
_ = try await client.filesystemOperation( let request = Com_Apple_Containerization_Sandbox_V3_FilesystemOperationRequest.with {
.with {
$0.operation = operation.toProtoOperation() $0.operation = operation.toProtoOperation()
$0.path = path $0.path = path
}) }
let operationClass = DeadlinePolicy.Operation.filesystem
_ = try await rpc(
operation: operationClass,
deadline: deadlinePolicy.deadline(for: operationClass)
) { options in
try await client.filesystemOperation(request, options: options)
}
} }
public func createProcess( public func createProcess(
@@ -232,8 +293,7 @@ extension Vminitd: VirtualMachineAgent {
options: Data? options: Data?
) async throws { ) async throws {
let enc = JSONEncoder() let enc = JSONEncoder()
_ = try await client.createProcess( let request = try Com_Apple_Containerization_Sandbox_V3_CreateProcessRequest.with {
.with {
$0.id = id $0.id = id
if let stdinPort { if let stdinPort {
$0.stdin = stdinPort $0.stdin = stdinPort
@@ -251,7 +311,16 @@ extension Vminitd: VirtualMachineAgent {
$0.ociRuntimePath = ociRuntimePath $0.ociRuntimePath = ociRuntimePath
} }
$0.configuration = try enc.encode(configuration) $0.configuration = try enc.encode(configuration)
}) }
let operation = DeadlinePolicy.Operation.createProcess
do {
_ = try await rpc(operation: operation, deadline: deadlinePolicy.deadline(for: operation)) { options in
try await client.createProcess(request, options: options)
}
} catch let error as ContainerizationError where error.code == .timeout {
cleanupTimedOutProcess(id: id, containerID: containerID, signalFirst: false)
throw error
}
} }
@discardableResult @discardableResult
@@ -262,7 +331,16 @@ extension Vminitd: VirtualMachineAgent {
$0.containerID = containerID $0.containerID = containerID
} }
} }
let resp = try await client.startProcess(request) let operation = DeadlinePolicy.Operation.startProcess
let resp: Com_Apple_Containerization_Sandbox_V3_StartProcessResponse
do {
resp = try await rpc(operation: operation, deadline: deadlinePolicy.deadline(for: operation)) { options in
try await client.startProcess(request, options: options)
}
} catch let error as ContainerizationError where error.code == .timeout {
cleanupTimedOutProcess(id: id, containerID: containerID, signalFirst: true)
throw error
}
return resp.pid return resp.pid
} }
@@ -274,7 +352,10 @@ extension Vminitd: VirtualMachineAgent {
$0.containerID = containerID $0.containerID = containerID
} }
} }
_ = try await client.killProcess(request) let operation = DeadlinePolicy.Operation.processControl
_ = try await rpc(operation: operation, deadline: deadlinePolicy.deadline(for: operation)) { options in
try await client.killProcess(request, options: options)
}
} }
public func resizeProcess(id: String, containerID: String?, columns: UInt32, rows: UInt32) async throws { public func resizeProcess(id: String, containerID: String?, columns: UInt32, rows: UInt32) async throws {
@@ -286,7 +367,10 @@ extension Vminitd: VirtualMachineAgent {
$0.columns = columns $0.columns = columns
$0.rows = rows $0.rows = rows
} }
_ = try await client.resizeProcess(request) let operation = DeadlinePolicy.Operation.processControl
_ = try await rpc(operation: operation, deadline: deadlinePolicy.deadline(for: operation)) { options in
try await client.resizeProcess(request, options: options)
}
} }
public func waitProcess( public func waitProcess(
@@ -301,24 +385,29 @@ extension Vminitd: VirtualMachineAgent {
} }
} }
var callOpts = GRPCCore.CallOptions.defaults
if let timeoutInSeconds { if let timeoutInSeconds {
callOpts.timeout = .seconds(timeoutInSeconds) let operation = DeadlinePolicy.Operation.waitProcess
} let deadline = ContinuousClock().now.advanced(by: .seconds(timeoutInSeconds))
do { do {
let resp = try await client.waitProcess(request, options: callOpts) let resp = try await rpc(operation: operation, deadline: deadline) { options in
try await client.waitProcess(request, options: options)
}
return ExitStatus(exitCode: resp.exitCode, exitedAt: resp.exitedAt.date) return ExitStatus(exitCode: resp.exitCode, exitedAt: resp.exitedAt.date)
} catch { } catch let error as ContainerizationError where error.code == .timeout {
if let err = error as? RPCError, err.code == .deadlineExceeded {
throw ContainerizationError( throw ContainerizationError(
.timeout, .timeout,
message: "failed to wait for process exit within timeout of \(timeoutInSeconds!) seconds", message: "failed to wait for process exit within timeout of \(timeoutInSeconds) seconds",
cause: err cause: error.cause
) )
} }
throw error
} }
// Phase-2 exception, intentionally greppable: the guest's ManagedProcess.wait() does not
// remove a stored continuation when an RPC is cancelled. Renewing bounded RPCs here would
// leak one guest waiter per lease. Preserve the existing unbounded API until the guest gains
// cancellation-aware waiter removal; explicit caller timeouts above are deadline-routed.
let resp = try await client.waitProcess(request)
return ExitStatus(exitCode: resp.exitCode, exitedAt: resp.exitedAt.date)
} }
public func deleteProcess(id: String, containerID: String?) async throws { public func deleteProcess(id: String, containerID: String?) async throws {
@@ -328,16 +417,10 @@ extension Vminitd: VirtualMachineAgent {
$0.containerID = containerID $0.containerID = containerID
} }
} }
// [Nucleic vendored patch] Bound the teardown RPC so a wedged agent channel can't hang an let operation = DeadlinePolicy.Operation.deleteProcess
// exec's cleanup forever. Nucleic fires `LinuxProcess.delete()` after every turn to reclaim _ = try await rpc(operation: operation, deadline: deadlinePolicy.deadline(for: operation)) { options in
// the per-exec connection; if `deleteProcess` never returned, that reclaim task would leak try await client.deleteProcess(request, options: options)
// and the connection would stay open — reintroducing the very accumulation the delete exists }
// to prevent. Generous: a healthy delete returns in milliseconds, so this only trips a
// genuinely stuck channel, and `LinuxProcess.performDeletion` still closes the agent
// connection on the thrown deadline.
var callOpts = GRPCCore.CallOptions.defaults
callOpts.timeout = .seconds(30)
_ = try await client.deleteProcess(request, options: callOpts)
} }
public func closeProcessStdin(id: String, containerID: String?) async throws { public func closeProcessStdin(id: String, containerID: String?) async throws {
@@ -347,7 +430,23 @@ extension Vminitd: VirtualMachineAgent {
$0.containerID = containerID $0.containerID = containerID
} }
} }
_ = try await client.closeProcessStdin(request) let operation = DeadlinePolicy.Operation.processControl
_ = try await rpc(operation: operation, deadline: deadlinePolicy.deadline(for: operation)) { options in
try await client.closeProcessStdin(request, options: options)
}
}
/// The timed-out RPC has already been cancelled by gRPC. Reclaim any guest record which was
/// committed immediately before cancellation won the race. Cleanup has its own bounded RPCs so
/// the phase timeout can settle its caller without retaining an immortal cleanup task.
private func cleanupTimedOutProcess(id: String, containerID: String?, signalFirst: Bool) {
Task {
if signalFirst {
try? await self.signalProcess(id: id, containerID: containerID, signal: SIGKILL)
}
try? await self.deleteProcess(id: id, containerID: containerID)
try? await self.close()
}
} }
public func up(name: String, mtu: UInt32? = nil) async throws { public func up(name: String, mtu: UInt32? = nil) async throws {
@@ -356,7 +455,10 @@ extension Vminitd: VirtualMachineAgent {
$0.up = true $0.up = true
if let mtu { $0.mtu = mtu } if let mtu { $0.mtu = mtu }
} }
_ = try await client.ipLinkSet(request) let operation = DeadlinePolicy.Operation.filesystem
_ = try await rpc(operation: operation, deadline: deadlinePolicy.deadline(for: operation)) { options in
try await client.ipLinkSet(request, options: options)
}
} }
public func down(name: String) async throws { public func down(name: String) async throws {
@@ -364,25 +466,32 @@ extension Vminitd: VirtualMachineAgent {
$0.interface = name $0.interface = name
$0.up = false $0.up = false
} }
_ = try await client.ipLinkSet(request) let operation = DeadlinePolicy.Operation.filesystem
_ = try await rpc(operation: operation, deadline: deadlinePolicy.deadline(for: operation)) { options in
try await client.ipLinkSet(request, options: options)
}
} }
/// Get an environment variable from the sandbox's environment. /// Get an environment variable from the sandbox's environment.
public func getenv(key: String) async throws -> String { public func getenv(key: String) async throws -> String {
let response = try await client.getenv( let request = Com_Apple_Containerization_Sandbox_V3_GetenvRequest.with { $0.key = key }
.with { let operation = DeadlinePolicy.Operation.filesystem
$0.key = key let response = try await rpc(operation: operation, deadline: deadlinePolicy.deadline(for: operation)) { options in
}) try await client.getenv(request, options: options)
}
return response.value return response.value
} }
/// Set an environment variable in the sandbox's environment. /// Set an environment variable in the sandbox's environment.
public func setenv(key: String, value: String) async throws { public func setenv(key: String, value: String) async throws {
_ = try await client.setenv( let request = Com_Apple_Containerization_Sandbox_V3_SetenvRequest.with {
.with {
$0.key = key $0.key = key
$0.value = value $0.value = value
}) }
let operation = DeadlinePolicy.Operation.filesystem
_ = try await rpc(operation: operation, deadline: deadlinePolicy.deadline(for: operation)) { options in
try await client.setenv(request, options: options)
}
} }
} }
@@ -399,7 +508,10 @@ extension Vminitd {
$0.mask = configuration.mask $0.mask = configuration.mask
$0.flags = configuration.flags $0.flags = configuration.flags
} }
_ = try await client.setupEmulator(request) let operation = DeadlinePolicy.Operation.filesystem
_ = try await rpc(operation: operation, deadline: deadlinePolicy.deadline(for: operation)) { options in
try await client.setupEmulator(request, options: options)
}
} }
/// Sets the guest time. /// Sets the guest time.
@@ -408,7 +520,10 @@ extension Vminitd {
$0.sec = sec $0.sec = sec
$0.usec = usec $0.usec = usec
} }
_ = try await client.setTime(request) let operation = DeadlinePolicy.Operation.filesystem
_ = try await rpc(operation: operation, deadline: deadlinePolicy.deadline(for: operation)) { options in
try await client.setTime(request, options: options)
}
} }
/// Set the provided sysctls inside the Sandbox's environment. /// Set the provided sysctls inside the Sandbox's environment.
@@ -416,19 +531,25 @@ extension Vminitd {
let request = Com_Apple_Containerization_Sandbox_V3_SysctlRequest.with { let request = Com_Apple_Containerization_Sandbox_V3_SysctlRequest.with {
$0.settings = settings $0.settings = settings
} }
_ = try await client.sysctl(request) let operation = DeadlinePolicy.Operation.filesystem
_ = try await rpc(operation: operation, deadline: deadlinePolicy.deadline(for: operation)) { options in
try await client.sysctl(request, options: options)
}
} }
/// Add an IP address to the sandbox's network interfaces. /// Add an IP address to the sandbox's network interfaces.
public func addressAdd(name: String, address: InterfaceAddress) async throws { public func addressAdd(name: String, address: InterfaceAddress) async throws {
_ = try await client.ipAddrAdd( let request = Com_Apple_Containerization_Sandbox_V3_IpAddrAddRequest.with {
.with {
$0.interface = name $0.interface = name
$0.ipv4Address = address.ipv4Address.description $0.ipv4Address = address.ipv4Address.description
if let ipv6Address = address.ipv6Address { if let ipv6Address = address.ipv6Address {
$0.ipv6Address = ipv6Address.description $0.ipv6Address = ipv6Address.description
} }
}) }
let operation = DeadlinePolicy.Operation.filesystem
_ = try await rpc(operation: operation, deadline: deadlinePolicy.deadline(for: operation)) { options in
try await client.ipAddrAdd(request, options: options)
}
} }
/// Add a link-scoped route in the sandbox's environment, used to install an /// Add a link-scoped route in the sandbox's environment, used to install an
@@ -437,8 +558,7 @@ extension Vminitd {
/// `route.ipv4Destination`/`route.ipv6Destination` carry the /// `route.ipv4Destination`/`route.ipv6Destination` carry the
/// gateway address; the wire format is a CIDR string with the per-family host prefix appended. /// gateway address; the wire format is a CIDR string with the per-family host prefix appended.
public func routeAddLink(name: String, route: LinkRoute) async throws { public func routeAddLink(name: String, route: LinkRoute) async throws {
_ = try await client.ipRouteAddLink( let request = Com_Apple_Containerization_Sandbox_V3_IpRouteAddLinkRequest.with {
.with {
$0.interface = name $0.interface = name
if let ipv4Destination = route.ipv4Destination { if let ipv4Destination = route.ipv4Destination {
$0.dstIpv4Addr = "\(ipv4Destination.description)/32" $0.dstIpv4Addr = "\(ipv4Destination.description)/32"
@@ -452,26 +572,32 @@ extension Vminitd {
if let ipv6Source = route.ipv6Source { if let ipv6Source = route.ipv6Source {
$0.srcIpv6Addr = ipv6Source.description $0.srcIpv6Addr = ipv6Source.description
} }
}) }
let operation = DeadlinePolicy.Operation.filesystem
_ = try await rpc(operation: operation, deadline: deadlinePolicy.deadline(for: operation)) { options in
try await client.ipRouteAddLink(request, options: options)
}
} }
/// Set the default route in the sandbox's environment. /// Set the default route in the sandbox's environment.
public func routeAddDefault(name: String, route: DefaultRoute) async throws { public func routeAddDefault(name: String, route: DefaultRoute) async throws {
_ = try await client.ipRouteAddDefault( let request = Com_Apple_Containerization_Sandbox_V3_IpRouteAddDefaultRequest.with {
.with {
$0.interface = name $0.interface = name
$0.ipv4Gateway = route.ipv4Gateway?.description ?? "" $0.ipv4Gateway = route.ipv4Gateway?.description ?? ""
if let ipv6Gateway = route.ipv6Gateway { if let ipv6Gateway = route.ipv6Gateway {
$0.ipv6Gateway = ipv6Gateway.description $0.ipv6Gateway = ipv6Gateway.description
} }
}) }
let operation = DeadlinePolicy.Operation.filesystem
_ = try await rpc(operation: operation, deadline: deadlinePolicy.deadline(for: operation)) { options in
try await client.ipRouteAddDefault(request, options: options)
}
} }
/// Configure DNS within the sandbox's environment. /// Configure DNS within the sandbox's environment.
public func configureDNS(config: DNS, location: String) async throws { public func configureDNS(config: DNS, location: String) async throws {
try config.validate() try config.validate()
_ = try await client.configureDns( let request = Com_Apple_Containerization_Sandbox_V3_ConfigureDnsRequest.with {
.with {
$0.location = location $0.location = location
$0.nameservers = config.nameservers $0.nameservers = config.nameservers
if let domain = config.domain { if let domain = config.domain {
@@ -479,25 +605,39 @@ extension Vminitd {
} }
$0.searchDomains = config.searchDomains $0.searchDomains = config.searchDomains
$0.options = config.options $0.options = config.options
}) }
let operation = DeadlinePolicy.Operation.filesystem
_ = try await rpc(operation: operation, deadline: deadlinePolicy.deadline(for: operation)) { options in
try await client.configureDns(request, options: options)
}
} }
/// Configure /etc/hosts within the sandbox's environment. /// Configure /etc/hosts within the sandbox's environment.
public func configureHosts(config: Hosts, location: String) async throws { public func configureHosts(config: Hosts, location: String) async throws {
_ = try await client.configureHosts(config.toAgentHostsRequest(location: location)) let operation = DeadlinePolicy.Operation.filesystem
let request = config.toAgentHostsRequest(location: location)
_ = try await rpc(operation: operation, deadline: deadlinePolicy.deadline(for: operation)) { options in
try await client.configureHosts(request, options: options)
}
} }
/// Perform a sync call. /// Perform a sync call.
public func sync() async throws { public func sync() async throws {
_ = try await client.sync(.init()) let operation = DeadlinePolicy.Operation.filesystem
_ = try await rpc(operation: operation, deadline: deadlinePolicy.deadline(for: operation)) { options in
try await client.sync(.init(), options: options)
}
} }
public func kill(pid: Int32, signal: Int32) async throws -> Int32 { public func kill(pid: Int32, signal: Int32) async throws -> Int32 {
let response = try await client.kill( let request = Com_Apple_Containerization_Sandbox_V3_KillRequest.with {
.with {
$0.pid = pid $0.pid = pid
$0.signal = signal $0.signal = signal
}) }
let operation = DeadlinePolicy.Operation.processControl
let response = try await rpc(operation: operation, deadline: deadlinePolicy.deadline(for: operation)) { options in
try await client.kill(request, options: options)
}
return response.result return response.result
} }
@@ -519,7 +659,10 @@ extension Vminitd {
let response: Com_Apple_Containerization_Sandbox_V3_StatResponse let response: Com_Apple_Containerization_Sandbox_V3_StatResponse
do { do {
response = try await client.stat(request) let operation = DeadlinePolicy.Operation.filesystem
response = try await rpc(operation: operation, deadline: deadlinePolicy.deadline(for: operation)) { options in
try await client.stat(request, options: options)
}
} catch let error as RPCError where error.code == .notFound { } catch let error as RPCError where error.code == .notFound {
throw ContainerizationError(.notFound, message: "stat: path not found '\(path.path)'", cause: error) throw ContainerizationError(.notFound, message: "stat: path not found '\(path.path)'", cause: error)
} }
@@ -570,8 +713,11 @@ extension Vminitd {
$0.isArchive = isArchive $0.isArchive = isArchive
} }
let operation = DeadlinePolicy.Operation.filesystem
try await rpc(operation: operation, deadline: deadlinePolicy.deadline(for: operation)) { options in
try await client.copy( try await client.copy(
request, request,
options: options,
onResponse: { stream in onResponse: { stream in
for try await response in stream.messages { for try await response in stream.messages {
if !response.error.isEmpty { if !response.error.isEmpty {
@@ -589,6 +735,7 @@ extension Vminitd {
}) })
} }
} }
}
extension Hosts { extension Hosts {
func toAgentHostsRequest(location: String) -> Com_Apple_Containerization_Sandbox_V3_ConfigureHostsRequest { func toAgentHostsRequest(location: String) -> Com_Apple_Containerization_Sandbox_V3_ConfigureHostsRequest {