Adds XPC sessions with disconnect handlers. (#1524)

- Part of #1318.
- Part of #1378.
- `RouteHandler` now accepts an additional `XPCServerSession` which
include a disconnect handler.
- `XPCServer` configures a session for each client connection and passes
it to every route handler.
- `XPCServer.route(fn:)` can be used to wrap existing route handlers
that don't care about sessions.
- `XPCClient` adds functions to support client-side session handling.
- Update all existing routes.
This commit is contained in:
J Logan
2026-05-11 15:49:05 -07:00
committed by GitHub
parent 509fa047dc
commit 1794afc449
8 changed files with 209 additions and 72 deletions
+32 -33
View File
@@ -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)
}
}
}
+26
View File
@@ -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 {
@@ -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
+13 -4
View File
@@ -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<xpc_object_t> { 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(
@@ -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
+18 -18
View File
@@ -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)
}
}
}
@@ -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
)
@@ -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
)