-
Notifications
You must be signed in to change notification settings - Fork 4
feat(xpc): implement XPC service host and status management #57
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,58 @@ | ||
| import Foundation | ||
|
|
||
| public enum ProxyXPCServiceConfiguration { | ||
| public static let machServiceName = "com.xcodemcproxy.server" | ||
| } | ||
|
|
||
| public struct ProxyXPCClientStatus: Codable, Sendable, Equatable { | ||
| public let sessionID: String | ||
| public let activeCorrelatedRequestCount: Int | ||
|
|
||
| public init(sessionID: String, activeCorrelatedRequestCount: Int) { | ||
| self.sessionID = sessionID | ||
| self.activeCorrelatedRequestCount = activeCorrelatedRequestCount | ||
| } | ||
| } | ||
|
|
||
| public struct ProxyXPCStatusPayload: Codable, Sendable, Equatable { | ||
| public let endpointDisplay: String | ||
| public let reachable: Bool | ||
| public let version: String | ||
| public let xcodeHealth: String | ||
| public let activeClientCount: Int | ||
| public let activeCorrelatedRequestCount: Int | ||
| public let clients: [ProxyXPCClientStatus] | ||
| public let fetchError: String? | ||
|
|
||
| public init( | ||
| endpointDisplay: String, | ||
| reachable: Bool, | ||
| version: String, | ||
| xcodeHealth: String, | ||
| activeClientCount: Int, | ||
| activeCorrelatedRequestCount: Int, | ||
| clients: [ProxyXPCClientStatus], | ||
| fetchError: String? | ||
| ) { | ||
| self.endpointDisplay = endpointDisplay | ||
| self.reachable = reachable | ||
| self.version = version | ||
| self.xcodeHealth = xcodeHealth | ||
| self.activeClientCount = activeClientCount | ||
| self.activeCorrelatedRequestCount = activeCorrelatedRequestCount | ||
| self.clients = clients | ||
| self.fetchError = fetchError | ||
| } | ||
| } | ||
|
|
||
| @objc public protocol ProxyXPCClientProtocol { | ||
| func statusDidUpdate(_ payload: Data) | ||
| } | ||
|
|
||
| @objc public protocol ProxyXPCControlProtocol { | ||
| func ping(_ reply: @escaping (String) -> Void) | ||
| func fetchStatus(_ reply: @escaping (Data) -> Void) | ||
| func registerClient(_ reply: @escaping (Data) -> Void) | ||
| func unregisterClient(_ reply: @escaping () -> Void) | ||
| func requestShutdown(_ reply: @escaping (Bool) -> Void) | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,173 @@ | ||||||
| import Foundation | ||||||
| import Logging | ||||||
| import NIOConcurrencyHelpers | ||||||
|
|
||||||
| private final class ProxyXPCClientRegistry { | ||||||
| private struct ClientEntry { | ||||||
| let connection: NSXPCConnection | ||||||
| var isRegistered: Bool | ||||||
| } | ||||||
|
|
||||||
| private let state = NIOLockedValueBox<[ObjectIdentifier: ClientEntry]>([:]) | ||||||
|
|
||||||
| func add(_ connection: NSXPCConnection) { | ||||||
| state.withLockedValue { state in | ||||||
| state[ObjectIdentifier(connection)] = ClientEntry(connection: connection, isRegistered: false) | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| func remove(_ connection: NSXPCConnection) { | ||||||
| state.withLockedValue { state in | ||||||
| state.removeValue(forKey: ObjectIdentifier(connection)) | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| func markRegistered(_ connection: NSXPCConnection, value: Bool) { | ||||||
| state.withLockedValue { state in | ||||||
| let key = ObjectIdentifier(connection) | ||||||
| guard var entry = state[key] else { return } | ||||||
| entry.isRegistered = value | ||||||
| state[key] = entry | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| func registeredConnections() -> [NSXPCConnection] { | ||||||
| state.withLockedValue { state in | ||||||
| state.values.compactMap { entry in | ||||||
| entry.isRegistered ? entry.connection : nil | ||||||
| } | ||||||
| } | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| public final class ProxyXPCServiceHost: NSObject, NSXPCListenerDelegate, ProxyXPCControlProtocol { | ||||||
| public typealias StatusProvider = () -> ProxyXPCStatusPayload | ||||||
| public typealias ShutdownHandler = () -> Void | ||||||
|
|
||||||
| private let listener = NSXPCListener(machServiceName: ProxyXPCServiceConfiguration.machServiceName) | ||||||
| private let registry = ProxyXPCClientRegistry() | ||||||
| private let statusProvider: StatusProvider | ||||||
| private let shutdownHandler: ShutdownHandler | ||||||
| private let encoder = JSONEncoder() | ||||||
|
||||||
| private let encoder = JSONEncoder() | |
| private var encoder: JSONEncoder { JSONEncoder() } |
Copilot
AI
Apr 1, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
isRunning is read/written from different execution contexts (XPC listener threads and the NIO event loop via pushStatusIfChanged) without synchronization. This is a data race under Swift concurrency rules. Guard it with a lock/NIOLockedValueBox, or make ProxyXPCServiceHost an actor / otherwise serialize access.
Copilot
AI
Apr 1, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
stop() calls listener.invalidate(), which permanently invalidates an NSXPCListener (it cannot be resumed again). Since start() can be called again after stop() (it only checks isRunning), this can lead to a silent failure to restart the listener. Either document that the host is single-use, or recreate the listener on start / avoid invalidation if restart is intended.
Copilot
AI
Apr 1, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The listener accepts every incoming mach service connection without validating the connecting process. As written, any local process that can connect to the service can call requestShutdown (and also receive status updates), which is a straightforward DoS / information disclosure vector. Consider verifying the client via audit token / code-signing identity, restricting to the same user/session, or removing/guarding requestShutdown behind an authorization check.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
start()begins the XPC listener and schedules periodic broadcasting before the HTTP server bind succeeds. If binding throws (e.g., port in use), the method exits with an error but the XPC service remains active and the repeated task continues running. Start XPC/broadcasting only after a successful bind/install, or add adefercleanup that stops/cancels on failure.