diff --git a/Sources/APIServer/APIServer+Start.swift b/Sources/APIServer/APIServer+Start.swift index 4c850fb2..909eaf16 100644 --- a/Sources/APIServer/APIServer+Start.swift +++ b/Sources/APIServer/APIServer+Start.swift @@ -235,11 +235,11 @@ extension APIServer { try await service.loadAll(bootPlugins) let harness = PluginsHarness(service: service, log: log) - routes[XPCRoute.pluginGet] = harness.get - routes[XPCRoute.pluginList] = harness.list - routes[XPCRoute.pluginLoad] = harness.load - routes[XPCRoute.pluginUnload] = harness.unload - routes[XPCRoute.pluginRestart] = harness.restart + routes[XPCRoute.pluginGet] = XPCServer.route(harness.get) + routes[XPCRoute.pluginList] = XPCServer.route(harness.list) + routes[XPCRoute.pluginLoad] = XPCServer.route(harness.load) + routes[XPCRoute.pluginUnload] = XPCServer.route(harness.unload) + routes[XPCRoute.pluginRestart] = XPCServer.route(harness.restart) } private func initializeHealthCheckService(log: Logger, routes: inout [XPCRoute: XPCServer.RouteHandler]) { @@ -251,7 +251,7 @@ extension APIServer { logRoot: logRoot, log: log ) - routes[XPCRoute.ping] = svc.ping + routes[XPCRoute.ping] = XPCServer.route(svc.ping) } private func initializeKernelService(log: Logger, routes: inout [XPCRoute: XPCServer.RouteHandler]) throws { @@ -259,8 +259,8 @@ extension APIServer { let svc = try KernelService(log: log, appRoot: appRoot) let harness = KernelHarness(service: svc, log: log) - routes[XPCRoute.installKernel] = harness.install - routes[XPCRoute.getDefaultKernel] = harness.getDefaultKernel + routes[XPCRoute.installKernel] = XPCServer.route(harness.install) + routes[XPCRoute.getDefaultKernel] = XPCServer.route(harness.getDefaultKernel) } private func initializeContainersService( @@ -280,21 +280,21 @@ extension APIServer { ) let harness = ContainersHarness(service: service, log: log) - routes[XPCRoute.containerList] = harness.list - routes[XPCRoute.containerCreate] = harness.create - routes[XPCRoute.containerDelete] = harness.delete - routes[XPCRoute.containerLogs] = harness.logs - routes[XPCRoute.containerBootstrap] = harness.bootstrap - routes[XPCRoute.containerDial] = harness.dial - routes[XPCRoute.containerStop] = harness.stop - routes[XPCRoute.containerStartProcess] = harness.startProcess - routes[XPCRoute.containerCreateProcess] = harness.createProcess - routes[XPCRoute.containerResize] = harness.resize - routes[XPCRoute.containerWait] = harness.wait - routes[XPCRoute.containerKill] = harness.kill - routes[XPCRoute.containerStats] = harness.stats - routes[XPCRoute.containerDiskUsage] = harness.diskUsage - routes[XPCRoute.containerExport] = harness.export + routes[XPCRoute.containerList] = XPCServer.route(harness.list) + routes[XPCRoute.containerCreate] = XPCServer.route(harness.create) + routes[XPCRoute.containerDelete] = XPCServer.route(harness.delete) + routes[XPCRoute.containerLogs] = XPCServer.route(harness.logs) + routes[XPCRoute.containerBootstrap] = XPCServer.route(harness.bootstrap) + routes[XPCRoute.containerDial] = XPCServer.route(harness.dial) + routes[XPCRoute.containerStop] = XPCServer.route(harness.stop) + routes[XPCRoute.containerStartProcess] = XPCServer.route(harness.startProcess) + routes[XPCRoute.containerCreateProcess] = XPCServer.route(harness.createProcess) + routes[XPCRoute.containerResize] = XPCServer.route(harness.resize) + routes[XPCRoute.containerWait] = XPCServer.route(harness.wait) + routes[XPCRoute.containerKill] = XPCServer.route(harness.kill) + routes[XPCRoute.containerStats] = XPCServer.route(harness.stats) + routes[XPCRoute.containerDiskUsage] = XPCServer.route(harness.diskUsage) + routes[XPCRoute.containerExport] = XPCServer.route(harness.export) return service } @@ -337,12 +337,11 @@ extension APIServer { let harness = NetworksHarness(service: service, log: log) - // network creation is not supported pre-macOS 26 (refer to AllocationOnlyVmnetNetwork) if #available(macOS 26, *) { - routes[XPCRoute.networkCreate] = harness.create + routes[XPCRoute.networkCreate] = XPCServer.route(harness.create) } - routes[XPCRoute.networkList] = harness.list - routes[XPCRoute.networkDelete] = harness.delete + routes[XPCRoute.networkList] = XPCServer.route(harness.list) + routes[XPCRoute.networkDelete] = XPCServer.route(harness.delete) return service } @@ -360,11 +359,11 @@ extension APIServer { let service = try VolumesService(resourceRoot: resourceRoot, containersService: containersService, log: log) let harness = VolumesHarness(service: service, log: log) - routes[XPCRoute.volumeCreate] = harness.create - routes[XPCRoute.volumeDelete] = harness.delete - routes[XPCRoute.volumeList] = harness.list - routes[XPCRoute.volumeInspect] = harness.inspect - routes[XPCRoute.volumeDiskUsage] = harness.diskUsage + routes[XPCRoute.volumeCreate] = XPCServer.route(harness.create) + routes[XPCRoute.volumeDelete] = XPCServer.route(harness.delete) + routes[XPCRoute.volumeList] = XPCServer.route(harness.list) + routes[XPCRoute.volumeInspect] = XPCServer.route(harness.inspect) + routes[XPCRoute.volumeDiskUsage] = XPCServer.route(harness.diskUsage) return service } @@ -384,7 +383,7 @@ extension APIServer { ) let harness = DiskUsageHarness(service: service, log: log) - routes[XPCRoute.systemDiskUsage] = harness.get + routes[XPCRoute.systemDiskUsage] = XPCServer.route(harness.get) } } } diff --git a/Sources/ContainerXPC/XPCClient.swift b/Sources/ContainerXPC/XPCClient.swift index fee917f9..bab00850 100644 --- a/Sources/ContainerXPC/XPCClient.swift +++ b/Sources/ContainerXPC/XPCClient.swift @@ -69,6 +69,32 @@ extension XPCClient { xpc_connection_get_pid(self.connection) } + /// Install a handler that is called whenever the connection receives an XPC error event. + /// + /// This replaces the existing (no-op) event handler. Call this before the first + /// `send()` to avoid a disconnect-before-handler race. + /// + /// ```swift + /// let client = XPCClient(service: "com.example.myservice") + /// client.setDisconnectHandler { + /// print("service disconnected, cleaning up") + /// } + /// let response = try await client.send(request) + /// ``` + public func setDisconnectHandler(_ handler: @Sendable @escaping () -> Void) { + xpc_connection_set_event_handler(connection) { object in + if xpc_get_type(object) == XPC_TYPE_ERROR { handler() } + } + } + + /// Create a persistent session backed by this client connection. + /// + /// The session installs a disconnect handler at initialisation time, before + /// any messages are sent, ensuring no server-exit event is missed. + public func openSession() -> XPCClientSession { + XPCClientSession(client: self) + } + /// Send the provided message to the service. @discardableResult public func send(_ message: XPCMessage, responseTimeout: Duration? = nil) async throws -> XPCMessage { diff --git a/Sources/ContainerXPC/XPCClientSession.swift b/Sources/ContainerXPC/XPCClientSession.swift new file mode 100644 index 00000000..63222cff --- /dev/null +++ b/Sources/ContainerXPC/XPCClientSession.swift @@ -0,0 +1,53 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +#if os(macOS) +import Synchronization + +/// Represents a long-lived connection to an XPC service on the client side. +/// +/// Obtain one via `XPCClient.openSession()`. The disconnect handler is +/// installed at initialisation time, before the first `send()`, so there is +/// no window in which a server crash goes undetected. +public final class XPCClientSession: Sendable { + private let client: XPCClient + private let handlers: Mutex<[@Sendable () async -> Void]> = Mutex([]) + + init(client: XPCClient) { + self.client = client + client.setDisconnectHandler { [weak self] in + guard let self else { return } + let snapshot = self.handlers.withLock { $0 } + Task { for handler in snapshot { await handler() } } + } + } + + /// Register a handler to be called when the server disconnects. + public func onDisconnect(_ handler: @Sendable @escaping () async -> Void) { + handlers.withLock { $0.append(handler) } + } + + /// Send a message over the persistent connection. + @discardableResult + public func send(_ message: XPCMessage, responseTimeout: Duration? = nil) async throws -> XPCMessage { + try await client.send(message, responseTimeout: responseTimeout) + } + + /// Cancel the underlying connection. + public func close() { client.close() } +} + +#endif diff --git a/Sources/ContainerXPC/XPCServer.swift b/Sources/ContainerXPC/XPCServer.swift index 1a5856bf..b3345314 100644 --- a/Sources/ContainerXPC/XPCServer.swift +++ b/Sources/ContainerXPC/XPCServer.swift @@ -23,7 +23,14 @@ import os import Synchronization public struct XPCServer: Sendable { - public typealias RouteHandler = @Sendable (XPCMessage) async throws -> XPCMessage + public typealias RouteHandler = @Sendable (XPCMessage, XPCServerSession) async throws -> XPCMessage + + /// Wraps a session-unaware handler for use in a route table. + public static func route( + _ fn: @Sendable @escaping (XPCMessage) async throws -> XPCMessage + ) -> RouteHandler { + { message, _ in try await fn(message) } + } private let routes: [String: RouteHandler] // Access to `connection` is protected by a lock. @@ -100,6 +107,7 @@ public struct XPCServer: Sendable { func handleClientConnection(connection: xpc_connection_t) async throws { let replySent = Mutex(false) + let session = XPCServerSession() let objects = AsyncStream { cont in xpc_connection_set_event_handler(connection) { object in @@ -140,7 +148,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, session: session) replySent.withLock { $0 = true } } if !added { @@ -149,9 +157,10 @@ public struct XPCServer: Sendable { } group.cancelAll() } + await session.fireDisconnect() } - func handleMessage(connection: xpc_connection_t, object: xpc_object_t) async throws { + func handleMessage(connection: xpc_connection_t, object: xpc_object_t, session: XPCServerSession) async throws { // All requests are dictionary-valued. guard xpc_get_type(object) == XPC_TYPE_DICTIONARY else { log.error("invalid request - not a dictionary") @@ -196,7 +205,7 @@ public struct XPCServer: Sendable { if let handler = routes[route] { do { let message = XPCMessage(object: object) - let response = try await handler(message) + let response = try await handler(message, session) xpc_connection_send_message(connection, response.underlying) } catch let error as ContainerizationError { log.error( diff --git a/Sources/ContainerXPC/XPCServerSession.swift b/Sources/ContainerXPC/XPCServerSession.swift new file mode 100644 index 00000000..60e37514 --- /dev/null +++ b/Sources/ContainerXPC/XPCServerSession.swift @@ -0,0 +1,50 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +#if os(macOS) + +/// Represents a single client connection on the server side. +/// +/// The server creates one `XPCServerSession` per accepted client connection. +/// Handlers that need to associate state with a connection (e.g. resource +/// tracking for automatic cleanup) receive the session as a parameter and +/// may register disconnect handlers via `onDisconnect(_:)`. +public actor XPCServerSession { + private var disconnectHandlers: [@Sendable () async -> Void] = [] + + public init() {} + + /// Register a handler to be called when the client connection closes. + public func onDisconnect(_ handler: @Sendable @escaping () async -> Void) { + disconnectHandlers.append(handler) + } + + func fireDisconnect() async { + for handler in disconnectHandlers { await handler() } + } +} + +extension XPCServerSession: Hashable { + public nonisolated static func == (lhs: XPCServerSession, rhs: XPCServerSession) -> Bool { + lhs === rhs + } + + public nonisolated func hash(into hasher: inout Hasher) { + hasher.combine(ObjectIdentifier(self)) + } +} + +#endif diff --git a/Sources/Plugins/CoreImages/ImagesHelper.swift b/Sources/Plugins/CoreImages/ImagesHelper.swift index e7a16aa3..fa9ab08b 100644 --- a/Sources/Plugins/CoreImages/ImagesHelper.swift +++ b/Sources/Plugins/CoreImages/ImagesHelper.swift @@ -98,30 +98,30 @@ extension ImagesHelper { let service = try ImagesService(contentStore: contentStore, imageStore: imageStore, snapshotStore: snapshotStore, log: log) let harness = ImagesServiceHarness(service: service, log: log) - routes[ImagesServiceXPCRoute.imagePull.rawValue] = harness.pull - routes[ImagesServiceXPCRoute.imageList.rawValue] = harness.list - routes[ImagesServiceXPCRoute.imageDelete.rawValue] = harness.delete - routes[ImagesServiceXPCRoute.imageTag.rawValue] = harness.tag - routes[ImagesServiceXPCRoute.imagePush.rawValue] = harness.push - routes[ImagesServiceXPCRoute.imageSave.rawValue] = harness.save - routes[ImagesServiceXPCRoute.imageLoad.rawValue] = harness.load - routes[ImagesServiceXPCRoute.imageUnpack.rawValue] = harness.unpack - routes[ImagesServiceXPCRoute.imageCleanupOrphanedBlobs.rawValue] = harness.cleanUpOrphanedBlobs - routes[ImagesServiceXPCRoute.imageDiskUsage.rawValue] = harness.calculateDiskUsage - routes[ImagesServiceXPCRoute.snapshotDelete.rawValue] = harness.deleteSnapshot - routes[ImagesServiceXPCRoute.snapshotGet.rawValue] = harness.getSnapshot + routes[ImagesServiceXPCRoute.imagePull.rawValue] = XPCServer.route(harness.pull) + routes[ImagesServiceXPCRoute.imageList.rawValue] = XPCServer.route(harness.list) + routes[ImagesServiceXPCRoute.imageDelete.rawValue] = XPCServer.route(harness.delete) + routes[ImagesServiceXPCRoute.imageTag.rawValue] = XPCServer.route(harness.tag) + routes[ImagesServiceXPCRoute.imagePush.rawValue] = XPCServer.route(harness.push) + routes[ImagesServiceXPCRoute.imageSave.rawValue] = XPCServer.route(harness.save) + routes[ImagesServiceXPCRoute.imageLoad.rawValue] = XPCServer.route(harness.load) + routes[ImagesServiceXPCRoute.imageUnpack.rawValue] = XPCServer.route(harness.unpack) + routes[ImagesServiceXPCRoute.imageCleanupOrphanedBlobs.rawValue] = XPCServer.route(harness.cleanUpOrphanedBlobs) + routes[ImagesServiceXPCRoute.imageDiskUsage.rawValue] = XPCServer.route(harness.calculateDiskUsage) + routes[ImagesServiceXPCRoute.snapshotDelete.rawValue] = XPCServer.route(harness.deleteSnapshot) + routes[ImagesServiceXPCRoute.snapshotGet.rawValue] = XPCServer.route(harness.getSnapshot) } private func initializeContentService(root: URL, log: Logger, routes: inout [String: XPCServer.RouteHandler]) throws { let service = try ContentStoreService(root: root, log: log) let harness = ContentServiceHarness(service: service, log: log) - routes[ImagesServiceXPCRoute.contentClean.rawValue] = harness.clean - routes[ImagesServiceXPCRoute.contentGet.rawValue] = harness.get - routes[ImagesServiceXPCRoute.contentDelete.rawValue] = harness.delete - routes[ImagesServiceXPCRoute.contentIngestStart.rawValue] = harness.newIngestSession - routes[ImagesServiceXPCRoute.contentIngestCancel.rawValue] = harness.cancelIngestSession - routes[ImagesServiceXPCRoute.contentIngestComplete.rawValue] = harness.completeIngestSession + routes[ImagesServiceXPCRoute.contentClean.rawValue] = XPCServer.route(harness.clean) + routes[ImagesServiceXPCRoute.contentGet.rawValue] = XPCServer.route(harness.get) + routes[ImagesServiceXPCRoute.contentDelete.rawValue] = XPCServer.route(harness.delete) + routes[ImagesServiceXPCRoute.contentIngestStart.rawValue] = XPCServer.route(harness.newIngestSession) + routes[ImagesServiceXPCRoute.contentIngestCancel.rawValue] = XPCServer.route(harness.cancelIngestSession) + routes[ImagesServiceXPCRoute.contentIngestComplete.rawValue] = XPCServer.route(harness.completeIngestSession) } } } diff --git a/Sources/Plugins/NetworkVmnet/NetworkVmnetHelper+Start.swift b/Sources/Plugins/NetworkVmnet/NetworkVmnetHelper+Start.swift index e090b841..ab4ab479 100644 --- a/Sources/Plugins/NetworkVmnet/NetworkVmnetHelper+Start.swift +++ b/Sources/Plugins/NetworkVmnet/NetworkVmnetHelper+Start.swift @@ -103,11 +103,11 @@ extension NetworkVmnetHelper { let xpc = XPCServer( 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, + NetworkRoutes.state.rawValue: XPCServer.route(server.state), + NetworkRoutes.allocate.rawValue: XPCServer.route(server.allocate), + NetworkRoutes.deallocate.rawValue: XPCServer.route(server.deallocate), + NetworkRoutes.lookup.rawValue: XPCServer.route(server.lookup), + NetworkRoutes.disableAllocator.rawValue: XPCServer.route(server.disableAllocator), ], log: log ) diff --git a/Sources/Plugins/RuntimeLinux/RuntimeLinuxHelper+Start.swift b/Sources/Plugins/RuntimeLinux/RuntimeLinuxHelper+Start.swift index 25dddca3..f946ab0e 100644 --- a/Sources/Plugins/RuntimeLinux/RuntimeLinuxHelper+Start.swift +++ b/Sources/Plugins/RuntimeLinux/RuntimeLinuxHelper+Start.swift @@ -85,7 +85,7 @@ extension RuntimeLinuxHelper { let endpointServer = XPCServer( identifier: machServiceLabel, routes: [ - SandboxRoutes.createEndpoint.rawValue: server.createEndpoint + SandboxRoutes.createEndpoint.rawValue: XPCServer.route(server.createEndpoint) ], log: log ) @@ -93,17 +93,17 @@ extension RuntimeLinuxHelper { let mainServer = XPCServer( connection: anonymousConnection, routes: [ - SandboxRoutes.bootstrap.rawValue: server.bootstrap, - SandboxRoutes.createProcess.rawValue: server.createProcess, - SandboxRoutes.state.rawValue: server.state, - SandboxRoutes.stop.rawValue: server.stop, - SandboxRoutes.kill.rawValue: server.kill, - SandboxRoutes.resize.rawValue: server.resize, - SandboxRoutes.wait.rawValue: server.wait, - SandboxRoutes.start.rawValue: server.startProcess, - SandboxRoutes.dial.rawValue: server.dial, - SandboxRoutes.shutdown.rawValue: server.shutdown, - SandboxRoutes.statistics.rawValue: server.statistics, + SandboxRoutes.bootstrap.rawValue: XPCServer.route(server.bootstrap), + SandboxRoutes.createProcess.rawValue: XPCServer.route(server.createProcess), + SandboxRoutes.state.rawValue: XPCServer.route(server.state), + SandboxRoutes.stop.rawValue: XPCServer.route(server.stop), + SandboxRoutes.kill.rawValue: XPCServer.route(server.kill), + SandboxRoutes.resize.rawValue: XPCServer.route(server.resize), + SandboxRoutes.wait.rawValue: XPCServer.route(server.wait), + SandboxRoutes.start.rawValue: XPCServer.route(server.startProcess), + SandboxRoutes.dial.rawValue: XPCServer.route(server.dial), + SandboxRoutes.shutdown.rawValue: XPCServer.route(server.shutdown), + SandboxRoutes.statistics.rawValue: XPCServer.route(server.statistics), ], log: log )