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
+250 -103
View File
@@ -33,8 +33,13 @@ public struct Vminitd: Sendable {
let client: Com_Apple_Containerization_Sandbox_V3_SandboxContext.Client<HTTP2ClientTransport.WrappedChannel>
public let grpcClient: GRPCClient<HTTP2ClientTransport.WrappedChannel>
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
// 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.
@@ -54,6 +59,7 @@ public struct Vminitd: Sendable {
let grpcClient = GRPCClient(transport: transport)
self.grpcClient = 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
// agent otherwise, which some agents might not even need.
self.connectionTask = Task {
@@ -64,7 +70,41 @@ public struct Vminitd: Sendable {
/// Close the connection to the guest agent.
public func close() async throws {
self.grpcClient.beginGracefulShutdown()
try await self.connectionTask.value
let deadline = deadlinePolicy.deadline(for: .agentClose)
do {
try await withThrowingTaskGroup(of: Void.self) { group in
group.addTask {
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)
}
}
@@ -87,8 +127,7 @@ extension Vminitd: VirtualMachineAgent {
}
public func writeFile(path: String, data: Data, flags: WriteFileFlags, mode: UInt32) async throws {
_ = try await client.writeFile(
.with {
let request = Com_Apple_Containerization_Sandbox_V3_WriteFileRequest.with {
$0.path = path
$0.mode = mode
$0.data = data
@@ -97,17 +136,24 @@ extension Vminitd: VirtualMachineAgent {
$0.createIfMissing = flags.create
$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
/// in the guest. If `categories` is empty, all categories are returned.
public func containerStatistics(containerIDs: [String], categories: StatCategory) async throws -> [ContainerStatistics] {
let response = try await client.containerStatistics(
.with {
let request = Com_Apple_Containerization_Sandbox_V3_ContainerStatisticsRequest.with {
$0.containerIds = containerIDs
$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
ContainerStatistics(
@@ -184,41 +230,56 @@ extension Vminitd: VirtualMachineAgent {
/// Mount a filesystem in the sandbox's environment.
public func mount(_ mount: ContainerizationOCI.Mount) async throws {
_ = try await client.mount(
.with {
let request = Com_Apple_Containerization_Sandbox_V3_MountRequest.with {
$0.type = mount.type
$0.source = mount.source
$0.destination = mount.destination
$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.
public func umount(path: String, flags: Int32) async throws {
_ = try await client.umount(
.with {
let request = Com_Apple_Containerization_Sandbox_V3_UmountRequest.with {
$0.path = path
$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.
public func mkdir(path: String, all: Bool, perms: UInt32) async throws {
_ = try await client.mkdir(
.with {
let request = Com_Apple_Containerization_Sandbox_V3_MkdirRequest.with {
$0.path = path
$0.all = all
$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.
public func filesystemOperation(operation: FilesystemOperation, path: String) async throws {
_ = try await client.filesystemOperation(
.with {
let request = Com_Apple_Containerization_Sandbox_V3_FilesystemOperationRequest.with {
$0.operation = operation.toProtoOperation()
$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(
@@ -232,26 +293,34 @@ extension Vminitd: VirtualMachineAgent {
options: Data?
) async throws {
let enc = JSONEncoder()
_ = try await client.createProcess(
.with {
$0.id = id
if let stdinPort {
$0.stdin = stdinPort
}
if let stdoutPort {
$0.stdout = stdoutPort
}
if let stderrPort {
$0.stderr = stderrPort
}
if let containerID {
$0.containerID = containerID
}
if let ociRuntimePath {
$0.ociRuntimePath = ociRuntimePath
}
$0.configuration = try enc.encode(configuration)
})
let request = try Com_Apple_Containerization_Sandbox_V3_CreateProcessRequest.with {
$0.id = id
if let stdinPort {
$0.stdin = stdinPort
}
if let stdoutPort {
$0.stdout = stdoutPort
}
if let stderrPort {
$0.stderr = stderrPort
}
if let containerID {
$0.containerID = containerID
}
if let ociRuntimePath {
$0.ociRuntimePath = ociRuntimePath
}
$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
@@ -262,7 +331,16 @@ extension Vminitd: VirtualMachineAgent {
$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
}
@@ -274,7 +352,10 @@ extension Vminitd: VirtualMachineAgent {
$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 {
@@ -286,7 +367,10 @@ extension Vminitd: VirtualMachineAgent {
$0.columns = columns
$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(
@@ -301,24 +385,29 @@ extension Vminitd: VirtualMachineAgent {
}
}
var callOpts = GRPCCore.CallOptions.defaults
if let timeoutInSeconds {
callOpts.timeout = .seconds(timeoutInSeconds)
}
do {
let resp = try await client.waitProcess(request, options: callOpts)
return ExitStatus(exitCode: resp.exitCode, exitedAt: resp.exitedAt.date)
} catch {
if let err = error as? RPCError, err.code == .deadlineExceeded {
let operation = DeadlinePolicy.Operation.waitProcess
let deadline = ContinuousClock().now.advanced(by: .seconds(timeoutInSeconds))
do {
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)
} catch let error as ContainerizationError where error.code == .timeout {
throw ContainerizationError(
.timeout,
message: "failed to wait for process exit within timeout of \(timeoutInSeconds!) seconds",
cause: err
message: "failed to wait for process exit within timeout of \(timeoutInSeconds) seconds",
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 {
@@ -328,16 +417,10 @@ extension Vminitd: VirtualMachineAgent {
$0.containerID = containerID
}
}
// [Nucleic vendored patch] Bound the teardown RPC so a wedged agent channel can't hang an
// exec's cleanup forever. Nucleic fires `LinuxProcess.delete()` after every turn to reclaim
// the per-exec connection; if `deleteProcess` never returned, that reclaim task would leak
// 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)
let operation = DeadlinePolicy.Operation.deleteProcess
_ = try await rpc(operation: operation, deadline: deadlinePolicy.deadline(for: operation)) { options in
try await client.deleteProcess(request, options: options)
}
}
public func closeProcessStdin(id: String, containerID: String?) async throws {
@@ -347,7 +430,23 @@ extension Vminitd: VirtualMachineAgent {
$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 {
@@ -356,7 +455,10 @@ extension Vminitd: VirtualMachineAgent {
$0.up = true
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 {
@@ -364,25 +466,32 @@ extension Vminitd: VirtualMachineAgent {
$0.interface = name
$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.
public func getenv(key: String) async throws -> String {
let response = try await client.getenv(
.with {
$0.key = key
})
let request = Com_Apple_Containerization_Sandbox_V3_GetenvRequest.with { $0.key = key }
let operation = DeadlinePolicy.Operation.filesystem
let response = try await rpc(operation: operation, deadline: deadlinePolicy.deadline(for: operation)) { options in
try await client.getenv(request, options: options)
}
return response.value
}
/// Set an environment variable in the sandbox's environment.
public func setenv(key: String, value: String) async throws {
_ = try await client.setenv(
.with {
$0.key = key
$0.value = value
})
let request = Com_Apple_Containerization_Sandbox_V3_SetenvRequest.with {
$0.key = key
$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.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.
@@ -408,7 +520,10 @@ extension Vminitd {
$0.sec = sec
$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.
@@ -416,19 +531,25 @@ extension Vminitd {
let request = Com_Apple_Containerization_Sandbox_V3_SysctlRequest.with {
$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.
public func addressAdd(name: String, address: InterfaceAddress) async throws {
_ = try await client.ipAddrAdd(
.with {
let request = Com_Apple_Containerization_Sandbox_V3_IpAddrAddRequest.with {
$0.interface = name
$0.ipv4Address = address.ipv4Address.description
if let ipv6Address = address.ipv6Address {
$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
@@ -437,8 +558,7 @@ extension Vminitd {
/// `route.ipv4Destination`/`route.ipv6Destination` carry the
/// 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 {
_ = try await client.ipRouteAddLink(
.with {
let request = Com_Apple_Containerization_Sandbox_V3_IpRouteAddLinkRequest.with {
$0.interface = name
if let ipv4Destination = route.ipv4Destination {
$0.dstIpv4Addr = "\(ipv4Destination.description)/32"
@@ -452,26 +572,32 @@ extension Vminitd {
if let ipv6Source = route.ipv6Source {
$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.
public func routeAddDefault(name: String, route: DefaultRoute) async throws {
_ = try await client.ipRouteAddDefault(
.with {
let request = Com_Apple_Containerization_Sandbox_V3_IpRouteAddDefaultRequest.with {
$0.interface = name
$0.ipv4Gateway = route.ipv4Gateway?.description ?? ""
if let ipv6Gateway = route.ipv6Gateway {
$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.
public func configureDNS(config: DNS, location: String) async throws {
try config.validate()
_ = try await client.configureDns(
.with {
let request = Com_Apple_Containerization_Sandbox_V3_ConfigureDnsRequest.with {
$0.location = location
$0.nameservers = config.nameservers
if let domain = config.domain {
@@ -479,25 +605,39 @@ extension Vminitd {
}
$0.searchDomains = config.searchDomains
$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.
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.
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 {
let response = try await client.kill(
.with {
$0.pid = pid
$0.signal = signal
})
let request = Com_Apple_Containerization_Sandbox_V3_KillRequest.with {
$0.pid = pid
$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
}
@@ -519,7 +659,10 @@ extension Vminitd {
let response: Com_Apple_Containerization_Sandbox_V3_StatResponse
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 {
throw ContainerizationError(.notFound, message: "stat: path not found '\(path.path)'", cause: error)
}
@@ -570,9 +713,12 @@ extension Vminitd {
$0.isArchive = isArchive
}
try await client.copy(
request,
onResponse: { stream in
let operation = DeadlinePolicy.Operation.filesystem
try await rpc(operation: operation, deadline: deadlinePolicy.deadline(for: operation)) { options in
try await client.copy(
request,
options: options,
onResponse: { stream in
for try await response in stream.messages {
if !response.error.isEmpty {
throw ContainerizationError(.internalError, message: "copy: \(response.error)")
@@ -587,6 +733,7 @@ extension Vminitd {
}
}
})
}
}
}