mirror of
https://github.com/apple/container.git
synced 2026-08-24 10:05:43 -05:00
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user