diff --git a/Package.swift b/Package.swift index a9d7e90d2..ec69ba91e 100644 --- a/Package.swift +++ b/Package.swift @@ -322,6 +322,7 @@ let package = Package( .product(name: "ContainerizationOS", package: "containerization"), .product(name: "ArgumentParser", package: "swift-argument-parser"), "ContainerAPIClient", + "ContainerNetworkServiceClient", "ContainerOS", "ContainerPersistence", "ContainerResource", diff --git a/Sources/ContainerResource/Network/AllocatedAttachment.swift b/Sources/ContainerResource/Network/AllocatedAttachment.swift deleted file mode 100644 index dfc68471e..000000000 --- a/Sources/ContainerResource/Network/AllocatedAttachment.swift +++ /dev/null @@ -1,32 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2026 Apple Inc. and the container 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 ContainerXPC - -/// AllocatedAttachment represents a network attachment that has been allocated for use -/// by a container and any additional relevant data needed for a sandbox to properly -/// configure networking on container bootstrap. -public struct AllocatedAttachment: Sendable { - public let attachment: Attachment - public let additionalData: XPCMessage? - public let pluginInfo: NetworkPluginInfo - - public init(attachment: Attachment, additionalData: XPCMessage?, pluginInfo: NetworkPluginInfo) { - self.attachment = attachment - self.additionalData = additionalData - self.pluginInfo = pluginInfo - } -} diff --git a/Sources/ContainerXPC/XPCClient.swift b/Sources/ContainerXPC/XPCClient.swift index fee917f9a..6beb79436 100644 --- a/Sources/ContainerXPC/XPCClient.swift +++ b/Sources/ContainerXPC/XPCClient.swift @@ -62,6 +62,17 @@ extension XPCClient { xpc_connection_cancel(connection) } + /// Set a handler that is called when the remote end of the connection closes or crashes. + /// This replaces the no-op event handler installed at init time. Call before any ``send(_:)`` + /// to avoid a narrow race between activation and handler registration. + public func setDisconnectHandler(_ handler: @Sendable @escaping () -> Void) { + xpc_connection_set_event_handler(connection) { event in + if xpc_get_type(event) == XPC_TYPE_ERROR { + handler() + } + } + } + /// Returns the pid of process to which we have a connection. /// Note: `xpc_connection_get_pid` returns 0 if no activity /// has taken place on the connection prior to it being called. diff --git a/Sources/ContainerXPC/XPCServer.swift b/Sources/ContainerXPC/XPCServer.swift index 1a5856bf6..0a67fe243 100644 --- a/Sources/ContainerXPC/XPCServer.swift +++ b/Sources/ContainerXPC/XPCServer.swift @@ -24,15 +24,26 @@ import Synchronization public struct XPCServer: Sendable { public typealias RouteHandler = @Sendable (XPCMessage) async throws -> XPCMessage + /// A persistent route handler returns a reply and an optional cleanup closure that is + /// invoked when the peer connection closes (normally or due to peer exit/crash). + /// The connection is kept alive until the peer disconnects, making connection lifetime + /// equivalent to the resource lifetime of whatever was allocated in the handler. + public typealias PersistentRouteHandler = @Sendable (XPCMessage) async throws -> (XPCMessage, (@Sendable () async -> Void)?) private let routes: [String: RouteHandler] + private let persistentRoutes: [String: PersistentRouteHandler] // Access to `connection` is protected by a lock. private nonisolated(unsafe) let connection: xpc_connection_t private let lock = NSLock() let log: Logging.Logger - public init(identifier: String, routes: [String: RouteHandler], log: Logging.Logger) { + public init( + identifier: String, + routes: [String: RouteHandler], + persistentRoutes: [String: PersistentRouteHandler] = [:], + log: Logging.Logger + ) { let connection = xpc_connection_create_mach_service( identifier, nil, @@ -40,12 +51,19 @@ public struct XPCServer: Sendable { ) self.routes = routes + self.persistentRoutes = persistentRoutes self.connection = connection self.log = log } - public init(connection: xpc_connection_t, routes: [String: RouteHandler], log: Logging.Logger) { + public init( + connection: xpc_connection_t, + routes: [String: RouteHandler], + persistentRoutes: [String: PersistentRouteHandler] = [:], + log: Logging.Logger + ) { self.routes = routes + self.persistentRoutes = persistentRoutes self.connection = connection self.log = log } @@ -100,6 +118,7 @@ public struct XPCServer: Sendable { func handleClientConnection(connection: xpc_connection_t) async throws { let replySent = Mutex(false) + let onClose = Mutex<(@Sendable () async -> Void)?>(nil) let objects = AsyncStream { cont in xpc_connection_set_event_handler(connection) { object in @@ -140,7 +159,7 @@ public struct XPCServer: Sendable { // `object` isn't used concurrently. nonisolated(unsafe) let object = object let added = group.addTaskUnlessCancelled { @Sendable in - try await self.handleMessage(connection: connection, object: object) + try await self.handleMessage(connection: connection, object: object, onClose: onClose) replySent.withLock { $0 = true } } if !added { @@ -149,9 +168,14 @@ public struct XPCServer: Sendable { } group.cancelAll() } + + // Connection has closed — run any cleanup registered by a persistent route handler. + if let cleanup = onClose.withLock({ $0 }) { + await cleanup() + } } - func handleMessage(connection: xpc_connection_t, object: xpc_object_t) async throws { + func handleMessage(connection: xpc_connection_t, object: xpc_object_t, onClose: borrowing Mutex<(@Sendable () async -> Void)?>) async throws { // All requests are dictionary-valued. guard xpc_get_type(object) == XPC_TYPE_DICTIONARY else { log.error("invalid request - not a dictionary") @@ -193,7 +217,36 @@ public struct XPCServer: Sendable { return } - if let handler = routes[route] { + if let handler = persistentRoutes[route] { + do { + let message = XPCMessage(object: object) + let (response, cleanup) = try await handler(message) + xpc_connection_send_message(connection, response.underlying) + if let cleanup { + onClose.withLock { $0 = cleanup } + } + } catch let error as ContainerizationError { + log.error( + "persistent route handler threw an error", + metadata: [ + "route": "\(route)", + "error": "\(error)", + ]) + Self.replyWithError(connection: connection, object: object, err: error) + } catch { + log.error( + "persistent route handler threw an error", + metadata: [ + "route": "\(route)", + "error": "\(error)", + ]) + let message = XPCMessage(object: object) + let reply = message.reply() + let err = ContainerizationError(.unknown, message: String(describing: error)) + reply.set(error: err) + xpc_connection_send_message(connection, reply.underlying) + } + } else if let handler = routes[route] { do { let message = XPCMessage(object: object) let response = try await handler(message) diff --git a/Sources/Plugins/NetworkVmnet/NetworkVmnetHelper+Start.swift b/Sources/Plugins/NetworkVmnet/NetworkVmnetHelper+Start.swift index e090b8419..220eabb74 100644 --- a/Sources/Plugins/NetworkVmnet/NetworkVmnetHelper+Start.swift +++ b/Sources/Plugins/NetworkVmnet/NetworkVmnetHelper+Start.swift @@ -104,11 +104,12 @@ extension NetworkVmnetHelper { identifier: serviceIdentifier, routes: [ NetworkRoutes.state.rawValue: server.state, - NetworkRoutes.allocate.rawValue: server.allocate, - NetworkRoutes.deallocate.rawValue: server.deallocate, NetworkRoutes.lookup.rawValue: server.lookup, NetworkRoutes.disableAllocator.rawValue: server.disableAllocator, ], + persistentRoutes: [ + NetworkRoutes.allocate.rawValue: server.allocate + ], log: log ) diff --git a/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift b/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift index b1ea7981f..70903edd7 100644 --- a/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift +++ b/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift @@ -34,7 +34,6 @@ public actor ContainersService { struct ContainerState { var snapshot: ContainerSnapshot var client: SandboxClient? - var allocatedAttachments: [AllocatedAttachment] func getClient() throws -> SandboxClient { guard let client else { @@ -96,7 +95,7 @@ public actor ContainersService { } let runtimePlugins = loader.findPlugins().filter { $0.hasType(.runtime) } - var results = [String: ContainerState]() + let results = [String: ContainerState]() for dir in directories { do { let (config, options) = try Self.getContainerConfiguration(at: dir) @@ -121,16 +120,6 @@ public actor ContainersService { } } - let state = ContainerState( - snapshot: .init( - configuration: config, - status: .stopped, - networks: [], - startedDate: nil - ), - allocatedAttachments: [] - ) - results[config.id] = state guard runtimePlugins.first(where: { $0.name == config.runtimeHandler }) != nil else { throw ContainerizationError( .internalError, @@ -390,7 +379,7 @@ public actor ContainersService { networks: [], startedDate: nil ) - await self.setContainerState(configuration.id, ContainerState(snapshot: snapshot, allocatedAttachments: []), context: context) + await self.setContainerState(configuration.id, ContainerState(snapshot: snapshot), context: context) } catch { throw error } @@ -429,35 +418,14 @@ public actor ContainersService { let path = self.containerRoot.appendingPathComponent(id) let (config, _) = try Self.getContainerConfiguration(at: path) - var allocatedAttachments = [AllocatedAttachment]() + var networkPluginInfos = [NetworkPluginInfo]() do { for n in config.networks { - let allocatedAttach = try await self.networksService?.allocate( - id: n.network, - hostname: n.options.hostname, - macAddress: n.options.macAddress - ) - guard var allocatedAttach = allocatedAttach else { - throw ContainerizationError(.internalError, message: "failed to allocate a network") - } - - if let mtu = n.options.mtu { - let a = allocatedAttach.attachment - allocatedAttach = AllocatedAttachment( - attachment: Attachment( - network: a.network, - hostname: a.hostname, - ipv4Address: a.ipv4Address, - ipv4Gateway: a.ipv4Gateway, - ipv6Address: a.ipv6Address, - macAddress: a.macAddress, - mtu: mtu - ), - additionalData: allocatedAttach.additionalData, - pluginInfo: allocatedAttach.pluginInfo - ) + let pluginInfo = try await self.networksService?.pluginInfo(for: n.network) + guard let pluginInfo else { + throw ContainerizationError(.internalError, message: "failed to get plugin info for network \(n.network)") } - allocatedAttachments.append(allocatedAttach) + networkPluginInfos.append(pluginInfo) } try Self.registerService( @@ -473,7 +441,7 @@ public actor ContainersService { id: id, runtime: runtime ) - try await sandboxClient.bootstrap(stdio: stdio, allocatedAttachments: allocatedAttachments) + try await sandboxClient.bootstrap(stdio: stdio, networkPluginInfos: networkPluginInfos) try await self.exitMonitor.registerProcess( id: id, @@ -481,23 +449,8 @@ public actor ContainersService { ) state.client = sandboxClient - state.allocatedAttachments = allocatedAttachments await self.setContainerState(id, state, context: context) } catch { - for allocatedAttach in allocatedAttachments { - do { - try await self.networksService?.deallocate(attachment: allocatedAttach.attachment) - } catch { - self.log.error( - "failed to deallocate network attachment", - metadata: [ - "id": "\(id)", - "network": "\(allocatedAttach.attachment.network)", - "error": "\(error)", - ]) - } - } - let label = Self.fullLaunchdServiceLabel( runtimeName: config.runtimeHandler, instanceId: id @@ -992,27 +945,9 @@ public actor ContainersService { ]) } - // Best effort deallocate network attachments for the container. Don't throw on - // failure so we can continue with state cleanup. - self.log.info("deallocating network attachments", metadata: ["id": "\(id)"]) - for allocatedAttach in state.allocatedAttachments { - do { - try await self.networksService?.deallocate(attachment: allocatedAttach.attachment) - } catch { - self.log.error( - "failed to deallocate network attachment", - metadata: [ - "id": "\(id)", - "network": "\(allocatedAttach.attachment.network)", - "error": "\(error)", - ]) - } - } - state.snapshot.status = .stopped state.snapshot.networks = [] state.client = nil - state.allocatedAttachments = [] await self.setContainerState(id, state, context: context) let options = try getContainerCreationOptions(id: id) diff --git a/Sources/Services/ContainerAPIService/Server/Networks/NetworksService.swift b/Sources/Services/ContainerAPIService/Server/Networks/NetworksService.swift index afd5c5302..00eb2f6ec 100644 --- a/Sources/Services/ContainerAPIService/Server/Networks/NetworksService.swift +++ b/Sources/Services/ContainerAPIService/Server/Networks/NetworksService.swift @@ -347,26 +347,14 @@ public actor NetworksService { } } - public func allocate(id: String, hostname: String, macAddress: MACAddress?) async throws -> AllocatedAttachment { + public func pluginInfo(for id: String) async throws -> NetworkPluginInfo { guard let serviceState = serviceStates[id] else { throw ContainerizationError(.notFound, message: "no network for id \(id)") } guard let pluginInfo = serviceState.networkState.pluginInfo else { throw ContainerizationError(.internalError, message: "network \(id) missing plugin information") } - let (attach, additionalData) = try await serviceState.client.allocate(hostname: hostname, macAddress: macAddress) - return AllocatedAttachment( - attachment: attach, - additionalData: additionalData, - pluginInfo: pluginInfo - ) - } - - public func deallocate(attachment: Attachment) async throws { - guard let serviceState = serviceStates[attachment.network] else { - throw ContainerizationError(.notFound, message: "no network for id \(attachment.network)") - } - return try await serviceState.client.deallocate(hostname: attachment.hostname) + return pluginInfo } private static func getClient(configuration: NetworkConfiguration) throws -> NetworkClient { diff --git a/Sources/Services/ContainerNetworkService/Client/NetworkClient.swift b/Sources/Services/ContainerNetworkService/Client/NetworkClient.swift index 0d5aac9e2..d26ee480b 100644 --- a/Sources/Services/ContainerNetworkService/Client/NetworkClient.swift +++ b/Sources/Services/ContainerNetworkService/Client/NetworkClient.swift @@ -53,10 +53,17 @@ extension NetworkClient { return state } + /// Allocates network attachment parameters for this sandbox and returns both the + /// attachment data and a persistent ``XPCClient`` that the caller must hold for the + /// duration of the allocation. The network plugin releases the allocation automatically + /// when the connection closes (i.e. when the caller drops the returned client or the + /// caller process exits). ``onDisconnect`` is called if the network service itself + /// disconnects unexpectedly. public func allocate( hostname: String, - macAddress: MACAddress? = nil - ) async throws -> (attachment: Attachment, additionalData: XPCMessage?) { + macAddress: MACAddress? = nil, + onDisconnect: @Sendable @escaping () async -> Void = {} + ) async throws -> (attachment: Attachment, additionalData: XPCMessage?, connection: XPCClient) { let request = XPCMessage(route: NetworkRoutes.allocate.rawValue) request.set(key: NetworkKeys.hostname.rawValue, value: hostname) if let macAddress = macAddress { @@ -64,19 +71,12 @@ extension NetworkClient { } let client = createClient() + client.setDisconnectHandler { Task { await onDisconnect() } } let response = try await client.send(request) let attachment = try response.attachment() let additionalData = response.additionalData() - return (attachment, additionalData) - } - - public func deallocate(hostname: String) async throws { - let request = XPCMessage(route: NetworkRoutes.deallocate.rawValue) - request.set(key: NetworkKeys.hostname.rawValue, value: hostname) - - let client = createClient() - try await client.send(request) + return (attachment, additionalData, client) } public func lookup(hostname: String) async throws -> Attachment? { diff --git a/Sources/Services/ContainerNetworkService/Client/NetworkRoutes.swift b/Sources/Services/ContainerNetworkService/Client/NetworkRoutes.swift index edfbec4aa..0a48a2f4c 100644 --- a/Sources/Services/ContainerNetworkService/Client/NetworkRoutes.swift +++ b/Sources/Services/ContainerNetworkService/Client/NetworkRoutes.swift @@ -18,9 +18,9 @@ public enum NetworkRoutes: String { /// Return the current state of the network. case state = "com.apple.container.network/state" /// Allocates parameters for attaching a sandbox to the network. + /// Uses a persistent connection — deallocation happens automatically when the + /// peer connection closes (sandbox exits or crashes). case allocate = "com.apple.container.network/allocate" - /// Deallocates parameters for attaching a sandbox to the network. - case deallocate = "com.apple.container.network/deallocate" /// Disables the allocator if no sandboxes are attached. case disableAllocator = "com.apple.container.network/disableAllocator" /// Retrieves the allocation for a hostname. diff --git a/Sources/Services/ContainerNetworkService/Server/NetworkService.swift b/Sources/Services/ContainerNetworkService/Server/NetworkService.swift index d0cc20f79..255bd893f 100644 --- a/Sources/Services/ContainerNetworkService/Server/NetworkService.swift +++ b/Sources/Services/ContainerNetworkService/Server/NetworkService.swift @@ -56,7 +56,7 @@ public actor NetworkService: Sendable { } @Sendable - public func allocate(_ message: XPCMessage) async throws -> XPCMessage { + public func allocate(_ message: XPCMessage) async throws -> (XPCMessage, (@Sendable () async -> Void)?) { log.debug("enter", metadata: ["func": "\(#function)"]) defer { log.debug("exit", metadata: ["func": "\(#function)"]) } @@ -99,20 +99,22 @@ public actor NetworkService: Sendable { } } macAddresses[index] = macAddress - return reply - } - @Sendable - public func deallocate(_ message: XPCMessage) async throws -> XPCMessage { - log.debug("enter", metadata: ["func": "\(#function)"]) - defer { log.debug("exit", metadata: ["func": "\(#function)"]) } + // Return a cleanup closure that releases this allocation when the peer connection closes. + let onClose: @Sendable () async -> Void = { [weak self, hostname, index] in + await self?.releaseAllocation(hostname: hostname, index: index) + } + return (reply, onClose) + } - let hostname = try message.hostname() - if let index = try await allocator.deallocate(hostname: hostname) { - macAddresses.removeValue(forKey: index) + private func releaseAllocation(hostname: String, index: UInt32) async { + macAddresses.removeValue(forKey: index) + do { + try await allocator.deallocate(hostname: hostname) + } catch { + log.error("failed to release allocation on connection close", metadata: ["hostname": "\(hostname)", "error": "\(error)"]) } - log.info("released attachments", metadata: ["hostname": "\(hostname)"]) - return message.reply() + log.info("released attachment on connection close", metadata: ["hostname": "\(hostname)"]) } @Sendable diff --git a/Sources/Services/ContainerSandboxService/Client/SandboxClient.swift b/Sources/Services/ContainerSandboxService/Client/SandboxClient.swift index 6723c6ae3..106ad41d6 100644 --- a/Sources/Services/ContainerSandboxService/Client/SandboxClient.swift +++ b/Sources/Services/ContainerSandboxService/Client/SandboxClient.swift @@ -77,7 +77,7 @@ public struct SandboxClient: Sendable { // Runtime Methods extension SandboxClient { - public func bootstrap(stdio: [FileHandle?], allocatedAttachments: [AllocatedAttachment]) async throws { + public func bootstrap(stdio: [FileHandle?], networkPluginInfos: [NetworkPluginInfo]) async throws { let request = XPCMessage(route: SandboxRoutes.bootstrap.rawValue) for (i, h) in stdio.enumerated() { @@ -97,7 +97,7 @@ extension SandboxClient { } do { - try request.setAllocatedAttachments(allocatedAttachments) + try request.setNetworkPluginInfos(networkPluginInfos) try await self.client.send(request) } catch { throw ContainerizationError( @@ -324,25 +324,8 @@ extension XPCMessage { return try JSONDecoder().decode(SandboxSnapshot.self, from: data) } - func setAllocatedAttachments(_ allocatedAttachments: [AllocatedAttachment]) throws { - let encoder = JSONEncoder() - let allocatedAttachmentsArray = xpc_array_create_empty() - for allocatedAttach in allocatedAttachments { - let xpcObject: xpc_object_t = xpc_dictionary_create_empty() - let networkXPC = XPCMessage(object: xpcObject) - - let attachmentEncoded = try encoder.encode(allocatedAttach.attachment) - networkXPC.set(key: SandboxKeys.networkAttachment.rawValue, value: attachmentEncoded) - - let pluginInfoEncoded = try encoder.encode(allocatedAttach.pluginInfo) - networkXPC.set(key: SandboxKeys.networkPluginInfo.rawValue, value: pluginInfoEncoded) - - if let additionalData = allocatedAttach.additionalData { - xpc_dictionary_set_value(networkXPC.underlying, SandboxKeys.networkAdditionalData.rawValue, additionalData.underlying) - } - - xpc_array_append_value(allocatedAttachmentsArray, networkXPC.underlying) - } - self.set(key: SandboxKeys.allocatedAttachments.rawValue, value: allocatedAttachmentsArray) + func setNetworkPluginInfos(_ networkPluginInfos: [NetworkPluginInfo]) throws { + let encoded = try JSONEncoder().encode(networkPluginInfos) + self.set(key: SandboxKeys.networkPluginInfos.rawValue, value: encoded) } } diff --git a/Sources/Services/ContainerSandboxService/Client/SandboxKeys.swift b/Sources/Services/ContainerSandboxService/Client/SandboxKeys.swift index e207cb049..b884f9653 100644 --- a/Sources/Services/ContainerSandboxService/Client/SandboxKeys.swift +++ b/Sources/Services/ContainerSandboxService/Client/SandboxKeys.swift @@ -44,8 +44,8 @@ public enum SandboxKeys: String { case statistics /// Network resource keys. - case allocatedAttachments - case networkAdditionalData - case networkAttachment - case networkPluginInfo + /// Parallel array of JSON-encoded NetworkPluginInfo, one per entry in the container's + /// AttachmentConfiguration array. The sandbox uses these to connect directly to each + /// network plugin and call allocate(). + case networkPluginInfos } diff --git a/Sources/Services/ContainerSandboxService/Server/SandboxService.swift b/Sources/Services/ContainerSandboxService/Server/SandboxService.swift index db8acd03c..69d8e6428 100644 --- a/Sources/Services/ContainerSandboxService/Server/SandboxService.swift +++ b/Sources/Services/ContainerSandboxService/Server/SandboxService.swift @@ -15,6 +15,7 @@ //===----------------------------------------------------------------------===// import ContainerAPIClient +import ContainerNetworkServiceClient import ContainerOS import ContainerPersistence import ContainerResource @@ -48,6 +49,9 @@ public actor SandboxService { private let lock: AsyncLock = AsyncLock() private let log: Logging.Logger private var state: State = .created + /// Persistent XPC connections to each network plugin, one per network attachment. + /// Held for the container lifetime; deallocation happens automatically when these are released. + private var networkConnections: [XPCClient] = [] private var processes: [String: ProcessInfo] = [:] private var socketForwarders: [SocketForwarderResult] = [] @@ -155,11 +159,18 @@ public actor SandboxService { logger: self.log ) - let allocatedAttachments = try message.getAllocatedAttachments() + let networkPluginInfos = try message.getNetworkPluginInfos() + + guard networkPluginInfos.count == config.networks.count else { + throw ContainerizationError( + .invalidArgument, + message: "network plugin info count (\(networkPluginInfos.count)) does not match attachment config count (\(config.networks.count))" + ) + } // Dynamically configure the DNS nameserver from a network if no explicit configuration if let dns = config.dns, dns.nameservers.isEmpty { - let defaultNameservers = try await self.getDefaultNameservers(allocatedAttachments: allocatedAttachments) + let defaultNameservers = try await self.getDefaultNameservers(networkConfigs: config.networks) if !defaultNameservers.isEmpty { config.dns = ContainerConfiguration.DNSConfiguration( nameservers: defaultNameservers, @@ -172,21 +183,49 @@ public actor SandboxService { var attachments: [Attachment] = [] var interfaces: [Interface] = [] - for index in 0.. [String] { - for allocatedAttach in allocatedAttachments { - let state = try await ClientNetwork.get(id: allocatedAttach.attachment.network) + private func handleNetworkServiceDisconnect(networkId: String) async { + self.log.error( + "network service disconnected, stopping sandbox", + metadata: ["network": "\(networkId)"] + ) + // Attempt graceful container cleanup under the lock before exiting. + let shouldCancel = await self.lock.withLock { _ -> Bool in + guard let ctrInfo = await self.container else { return false } + switch await self.state { + case .running, .booted: + await self.setState(.stopping) + do { + try await self.cleanUpContainer(containerInfo: ctrInfo) + } catch { + self.log.error( + "cleanup failed on network disconnect", + metadata: ["error": "\(error)"] + ) + } + await self.setState(.stopped) + return true + default: + return false + } + } + // Cancel the main XPC connection. This causes the XPC server's listen() to return, + // unwinding the task group in RuntimeLinuxHelper and exiting the process. + if shouldCancel { + xpc_connection_cancel(self.connection) + } + } + + private func getDefaultNameservers(networkConfigs: [AttachmentConfiguration]) async throws -> [String] { + for n in networkConfigs { + let state = try await ClientNetwork.get(id: n.network) guard case .running(_, let status) = state else { continue } @@ -1071,6 +1144,9 @@ public actor SandboxService { await self.stopSocketForwarders() + // Release persistent network connections — the network plugins will auto-deallocate. + self.networkConnections = [] + let status = exitStatus ?? ExitStatus(exitCode: 255) self.releaseWaiters(for: id, status: status) } @@ -1118,48 +1194,11 @@ extension XPCMessage { return try JSONDecoder().decode(ProcessConfiguration.self, from: data) } - fileprivate func getAllocatedAttachments() throws -> [AllocatedAttachment] { - guard let attachmentArray = xpc_dictionary_get_value(self.underlying, SandboxKeys.allocatedAttachments.rawValue) else { - throw ContainerizationError(.invalidArgument, message: "missing allocatedAttachments array in message") - } - - var results = [AllocatedAttachment]() - let decoder = JSONDecoder() - - let arrayCount = xpc_array_get_count(attachmentArray) - - for i in 0.. [NetworkPluginInfo] { + guard let data = self.dataNoCopy(key: SandboxKeys.networkPluginInfos.rawValue) else { + throw ContainerizationError(.invalidArgument, message: "missing networkPluginInfos in message") } - return results + return try JSONDecoder().decode([NetworkPluginInfo].self, from: data) } } @@ -1339,6 +1378,10 @@ extension SandboxService { self.container = info } + private func setNetworkConnections(_ connections: [XPCClient]) { + self.networkConnections = connections + } + private func addNewProcess(_ id: String, _ config: ProcessConfiguration, _ io: [FileHandle?]) throws { guard self.processes[id] == nil else { throw ContainerizationError(.invalidArgument, message: "process \(id) already exists")