Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 32 additions & 33 deletions Sources/APIServer/APIServer+Start.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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]) {
Expand All @@ -251,16 +251,16 @@ 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 {
log.info("initializing kernel service")

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(
Expand All @@ -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
}
Expand Down Expand Up @@ -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
}
Expand All @@ -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
}
Expand All @@ -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 changes: 26 additions & 0 deletions Sources/ContainerXPC/XPCClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
53 changes: 53 additions & 0 deletions Sources/ContainerXPC/XPCClientSession.swift
Original file line number Diff line number Diff line change
@@ -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
17 changes: 13 additions & 4 deletions Sources/ContainerXPC/XPCServer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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")
Expand Down Expand Up @@ -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(
Expand Down
50 changes: 50 additions & 0 deletions Sources/ContainerXPC/XPCServerSession.swift
Original file line number Diff line number Diff line change
@@ -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
36 changes: 18 additions & 18 deletions Sources/Plugins/CoreImages/ImagesHelper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
10 changes: 5 additions & 5 deletions Sources/Plugins/NetworkVmnet/NetworkVmnetHelper+Start.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down
Loading