Skip to content
Open
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
106 changes: 106 additions & 0 deletions Sources/XcodeMCPProxy/ProxyServer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,24 @@ public final class ProxyServer {
private var isShuttingDown = false
private var sessionManager: (any RuntimeCoordinating)?
private var permissionDialogAutoApprover: (any ProxyServerPermissionDialogAutoApprover)?
private var xpcBroadcastTask: RepeatedTask?
private lazy var xpcServiceHost = ProxyXPCServiceHost(
statusProvider: { [weak self] in
self?.currentXPCStatus() ?? ProxyXPCStatusPayload(
endpointDisplay: "unavailable",
reachable: false,
version: ProxyBuildInfo.version,
xcodeHealth: "Unknown",
activeClientCount: 0,
activeCorrelatedRequestCount: 0,
clients: [],
fetchError: "Proxy server is unavailable."
)
},
shutdownHandler: { [weak self] in
_ = self?.shutdownGracefully()
}
)

public convenience init(config: ProxyConfig) {
self.init(config: config, dependencies: .live(config: config))
Expand Down Expand Up @@ -97,6 +115,8 @@ public final class ProxyServer {
}

public func start() throws -> Channel {
xpcServiceHost.start()
startXPCBroadcasting()
let logger = self.logger
Comment on lines 117 to 120
Copy link

Copilot AI Apr 1, 2026

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 a defer cleanup that stops/cancels on failure.

Copilot uses AI. Check for mistakes.
let bootstrap = ServerBootstrap(group: group)
.serverChannelOption(ChannelOptions.backlog, value: 256)
Expand Down Expand Up @@ -145,6 +165,9 @@ public final class ProxyServer {
}

public func shutdownGracefully() -> EventLoopFuture<Void> {
xpcBroadcastTask?.cancel()
xpcBroadcastTask = nil
xpcServiceHost.stop()
let promise = group.next().makePromise(of: Void.self)
let shutdownContext = beginShutdown()
shutdownContext.autoApprover?.stop()
Expand Down Expand Up @@ -440,6 +463,89 @@ public final class ProxyServer {
return true
}
}

private func currentXPCStatus() -> ProxyXPCStatusPayload {
let snapshotContext = runtimeLock.withLock {
(
isShuttingDown: isShuttingDown,
channels: channels,
sessionManager: sessionManager
)
}

let endpointDisplay = xpcEndpointDisplay(from: snapshotContext.channels)
let debugSnapshot = snapshotContext.sessionManager?.debugSnapshot()
let reachable = snapshotContext.isShuttingDown == false && snapshotContext.channels.isEmpty == false

return Self.makeXPCStatus(
endpointDisplay: endpointDisplay,
reachable: reachable,
version: ProxyBuildInfo.version,
debugSnapshot: debugSnapshot
)
}

package static func makeXPCStatus(
endpointDisplay: String,
reachable: Bool,
version: String,
debugSnapshot: ProxyDebugSnapshot?
) -> ProxyXPCStatusPayload {
let clients = (debugSnapshot?.sessions ?? [])
.map {
ProxyXPCClientStatus(
sessionID: $0.sessionID,
activeCorrelatedRequestCount: $0.activeCorrelatedRequestCount
)
}
.sorted {
if $0.activeCorrelatedRequestCount == $1.activeCorrelatedRequestCount {
return $0.sessionID < $1.sessionID
}
return $0.activeCorrelatedRequestCount > $1.activeCorrelatedRequestCount
}

let activeCorrelatedRequestCount = clients.reduce(into: 0) { partialResult, client in
partialResult += client.activeCorrelatedRequestCount
}

return ProxyXPCStatusPayload(
endpointDisplay: endpointDisplay,
reachable: reachable,
version: version,
xcodeHealth: debugSnapshot?.upstreams.first?.healthState ?? "Unknown",
activeClientCount: clients.count,
activeCorrelatedRequestCount: activeCorrelatedRequestCount,
clients: clients,
fetchError: reachable ? nil : "Proxy not reachable at \(endpointDisplay)."
)
}

private func xpcEndpointDisplay(from channels: [Channel]) -> String {
if let firstChannel = channels.first,
let address = firstChannel.localAddress,
let port = address.port {
let host: String
if config.listenHost == "localhost" {
host = "localhost"
} else {
host = address.ipAddress ?? config.listenHost
}
return "http://\(host):\(port)"
}

return "http://\(config.listenHost):\(config.listenPort)"
}

private func startXPCBroadcasting() {
xpcBroadcastTask?.cancel()
xpcBroadcastTask = group.next().scheduleRepeatedTask(
initialDelay: .seconds(0),
delay: .seconds(1)
) { [weak self] _ in
self?.xpcServiceHost.pushStatusIfChanged()
}
}
}

private enum ProxyServerError: Error {
Expand Down
58 changes: 58 additions & 0 deletions Sources/XcodeMCPProxy/ProxyXPCContracts.swift
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)
}
173 changes: 173 additions & 0 deletions Sources/XcodeMCPProxy/ProxyXPCServiceHost.swift
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()
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ProxyXPCServiceHost shares a single JSONEncoder instance across XPC callbacks and the periodic broadcast. JSONEncoder is not thread-safe, so concurrent fetchStatus/registerClient/pushStatusIfChanged calls can race and corrupt encoding or crash. Create a new encoder per call, or protect encoding behind a lock/serial executor.

Suggested change
private let encoder = JSONEncoder()
private var encoder: JSONEncoder { JSONEncoder() }

Copilot uses AI. Check for mistakes.
private let logger = ProxyLogging.make("xpc")
private let lastBroadcastPayload = NIOLockedValueBox<Data?>(nil)

private var isRunning = false
public init(statusProvider: @escaping StatusProvider, shutdownHandler: @escaping ShutdownHandler) {
self.statusProvider = statusProvider
self.shutdownHandler = shutdownHandler
super.init()
listener.delegate = self
}

public func start() {
guard !isRunning else { return }
isRunning = true
listener.resume()
Comment on lines +55 to +66
Copy link

Copilot AI Apr 1, 2026

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 uses AI. Check for mistakes.
logger.info("XPC listener started", metadata: ["service": "\(ProxyXPCServiceConfiguration.machServiceName)"])
}

public func stop() {
guard isRunning else { return }
isRunning = false
listener.invalidate()
logger.info("XPC listener stopped", metadata: ["service": "\(ProxyXPCServiceConfiguration.machServiceName)"])
}
Comment on lines +70 to +75
Copy link

Copilot AI Apr 1, 2026

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 uses AI. Check for mistakes.

public func listener(_ listener: NSXPCListener, shouldAcceptNewConnection newConnection: NSXPCConnection) -> Bool {
newConnection.exportedInterface = NSXPCInterface(with: ProxyXPCControlProtocol.self)
newConnection.exportedObject = self
newConnection.remoteObjectInterface = NSXPCInterface(with: ProxyXPCClientProtocol.self)

newConnection.invalidationHandler = { [weak self, weak newConnection] in
guard let self, let newConnection else { return }
self.registry.remove(newConnection)
}

registry.add(newConnection)
newConnection.resume()
return true
}
Comment on lines +77 to +90
Copy link

Copilot AI Apr 1, 2026

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.

Copilot uses AI. Check for mistakes.

public func ping(_ reply: @escaping (String) -> Void) {
reply("pong")
}

public func fetchStatus(_ reply: @escaping (Data) -> Void) {
reply(encodedStatus())
}

public func registerClient(_ reply: @escaping (Data) -> Void) {
guard let connection = NSXPCConnection.current() else {
reply(encodedStatus())
return
}
registry.markRegistered(connection, value: true)
let payload = encodedStatus()
lastBroadcastPayload.withLockedValue { $0 = payload }
if let client = connection.remoteObjectProxyWithErrorHandler({ [logger] (error: Error) in
logger.debug("XPC callback delivery failed", metadata: ["error": "\(error)"])
}) as? ProxyXPCClientProtocol {
client.statusDidUpdate(payload)
}
reply(payload)
}

public func unregisterClient(_ reply: @escaping () -> Void) {
if let connection = NSXPCConnection.current() {
registry.markRegistered(connection, value: false)
}
reply()
}

public func requestShutdown(_ reply: @escaping (Bool) -> Void) {
shutdownHandler()
reply(true)
}

public func pushStatusIfChanged() {
guard isRunning else { return }
let payload = encodedStatus()
let shouldBroadcast = lastBroadcastPayload.withLockedValue { last in
if last == payload {
return false
}
last = payload
return true
}

if shouldBroadcast {
broadcast(payload: payload)
}
}

private func broadcast(payload: Data) {
for connection in registry.registeredConnections() {
guard let client = connection.remoteObjectProxyWithErrorHandler({ [logger] (error: Error) in
logger.debug("XPC callback delivery failed", metadata: ["error": "\(error)"])
}) as? ProxyXPCClientProtocol else {
continue
}
client.statusDidUpdate(payload)
}
}

private func encodedStatus() -> Data {
do {
return try encoder.encode(statusProvider())
} catch {
logger.error("Failed to encode XPC status payload", metadata: ["error": "\(error)"])
let fallback = ProxyXPCStatusPayload(
endpointDisplay: "unavailable",
reachable: false,
version: "unknown",
xcodeHealth: "Unknown",
activeClientCount: 0,
activeCorrelatedRequestCount: 0,
clients: [],
fetchError: "Failed to encode status payload."
)
return (try? encoder.encode(fallback)) ?? Data()
}
}
}
Loading
Loading