I am trying to send an anonymous XPC listener endpoint to my daemon from user context in order to be able to do some bidirectional XPC.
I was trying to use the new XPCListener
and XPCSession
objects and the easiest method I figured was using the Codable
version of the send()
methods, in which I wanted to send the XPCEndpoint
object - alongside the name of the anonymous endpoint (because I want to have more XPCEndpoint
s sent over, so I want to be able to identify them.
However, trying to manually encode XPCEndpoint throws an exception:
ERROR: Missing CodingUserInfoKey CodingUserInfoKey(rawValue: "_XPCCodable")
Here is a simple command-line tool reproducing the issue:
import Foundation
import XPC
let listener = try XPCListener(service: "mach-service.xxx.yyy", incomingSessionHandler: {
$0.accept(incomingMessageHandler: { (msg: XPCReceivedMessage) in
return nil
})
})
var endpoint = listener.endpoint
do {
let endpointData = try JSONEncoder().encode(endpoint)
print("EndpointData object: \(endpointData.count) bytes")
} catch let error {
print("ERROR: \(error)")
}
Wrapping my object into an XPCDictionary
, then adding multiple keys alongside an "endpoint"
key with the XPCEndpoint
as value works, but XPCDictionaries are less ideal - they don't even support vanilla Data
objects, only ones converted to an xpc_object_t
with xpc_data_*
functions
Is this expected behavior? I shouldn't encode an XPCEndpoint
myself? I am using the latest Xcode 26.0 beta, with deployment target of macOS 15.1, running on macOS 15.5.
(Btw it's also incorrect that this XPCEndpoint
API is available from macOS 15.0 - it cannot be found in Xcode 15.4 under macOS 15.5. At the very best it's backDeployed but this isn't mentioned in its public declaration.)
Here is a simple command-line tool reproducing the issue:
I’m not surprised that code fails. You’re trying to encode the endpoint using a ‘vanilla’ JSONEncoder
, and that has nowhere to store the Mach port rights.
I’ve always envisaged implementing this using Codable
structs. Pasted in at the end of this reply is an example of what I mean. When I built this with Xcode 26.0b7 and ran it on macOS 15.6.1, it printed this:
will start anonymous listener
did start anonymous listener
will send check-in request, message: Hello Cruel World! 2025-09-02 09:09:13 +0000
did send check-in request
will accept session request
will accept check-in request, message: Hello Cruel World! 2025-09-02 09:09:13 +0000
did accept check-in request, clientID: 21BB21C9-40FE-4F85-B1AC-40E3ED0EA5B1
did receive check-in reply, id: 21BB21C9-40FE-4F85-B1AC-40E3ED0EA5B1
indicating that the reply made it back to the client.
it's also incorrect that this XPCEndpoint API is available from macOS 15.0
I happened to have a macOS 15.0 VM lying about, so I copied my built tool to that VM and ran it there. It works just fine. Yay!
At the very best it's backDeployed
That’s not what’s going on here. Rather, XPCEndpoint
is implemented as documented on macOS 15.0 and later, it just was SPI rather than API. When we decided to make it API, we just changed the availability in the macOS 26 SDK that’s part of Xcode 26.0 beta.
This is unusal, but it’s not that unusal. For an extreme example, check in <sys/fileport.h>
, introduced in the macOS 15.4 SDK but supported all the way back to macOS 10.7!
Folks generally appreciate us doing this because it lets them adopt the API sooner. And, yes, it’s a inconvenient, and potentially confusing, that XPCEndpoint
is only available in the macOS 26 beta SDK, but in a few months that pain will have faded (-:
Share and Enjoy
—
Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"
import Foundation
struct CheckInRequest: Codable {
var message: String
}
struct CheckInReply: Codable {
var clientID: UUID
var endpoint: XPCEndpoint
}
func makeClientListener() -> XPCListener {
XPCListener(targetQueue: .main) { request in
// In a real app this would accept new session requests from this client
// to its anonymous listener.
return request.reject(reason: "NYI")
}
}
var listenersByClientID: [UUID: XPCListener] = [:]
func main() throws {
// Start a loopback listener. I’m testing this stuff using the modern equivalent of the technique described in
// TN3113 “Testing and debugging XPC code with an anonymous listener”.
//
// <[TN3113: Testing and debugging XPC code with an anonymous listener | Apple Developer Documentation](https://developer.apple.com/documentation/technotes/tn3113-testing-xpc-code-with-an-anonymous-listener)>
print("will start anonymous listener")
let listener = XPCListener(targetQueue: .main, options: [.inactive]) { request in
print("will accept session request")
return request.accept { (checkIn: CheckInRequest) in
print("will accept check-in request, message: \(checkIn.message)")
let clientID = UUID()
let listener = makeClientListener()
listenersByClientID[clientID] = listener
let reply = CheckInReply(
clientID: clientID,
endpoint: listener.endpoint
)
print("did accept check-in request, clientID: \(clientID)")
return reply
}
}
try listener.activate()
print("did start anonymous listener")
// Create a session to that listener.
let session = try XPCSession(endpoint: listener.endpoint, targetQueue: .main, options: [], cancellationHandler: nil)
// And start a timer that checks in every 5 seconds.
let source = DispatchSource.makeTimerSource(queue: .main)
source.setEventHandler {
do {
let message = "Hello Cruel World! \(Date.now)"
print("will send check-in request, message: \(message)")
try session.send(CheckInRequest(message: message)) { (result: Result<CheckInReply, Error>) in
switch result {
case .failure(let error):
print("did not receive check-in reply, error: \(error)")
case .success(let something):
print("did receive check-in reply, id: \(something.clientID)")
}
}
print("did send check-in request")
} catch {
print("did not send check-in request, error: \(error)")
}
}
source.schedule(deadline: .now() + 1, repeating: 5.0)
source.activate()
// ‘Park’ the main thread in `dispatchMain()`.
withExtendedLifetime( (listener, session) ) {
dispatchMain()
}
}
try main()