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
18 changes: 11 additions & 7 deletions Sources/ContainerXPC/XPCClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -111,15 +111,19 @@ extension XPCClient {
}

group.addTask {
try await withCheckedThrowingContinuation { cont in
xpc_connection_send_message_with_reply(self.connection, message.underlying, nil) { reply in
do {
let message = try self.parseReply(reply)
cont.resume(returning: message)
} catch {
cont.resume(throwing: error)
let box = XPCReplyBox()
return try await withTaskCancellationHandler {
try await withCheckedThrowingContinuation { cont in
box.store(cont)
xpc_connection_send_message_with_reply(self.connection, message.underlying, nil) { reply in
box.resume { try self.parseReply(reply) }
}
}
} onCancel: {
// On timeout the group cancels this task. The XPC reply
// handler is not cancellation-aware, so resume the
// continuation here; a late reply becomes a no-op.
box.resume { throw CancellationError() }
}
}

Expand Down
68 changes: 68 additions & 0 deletions Sources/ContainerXPC/XPCReplyBox.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
//===----------------------------------------------------------------------===//
// 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 Foundation

/// Resume-once bridge between a `CheckedContinuation` and the XPC reply handler.
///
/// The XPC reply handler is not cancellation-aware, so a request can outlive a
/// client-side timeout. This box lets either the reply handler or the task's
/// cancellation handler resume the continuation, whichever fires first, while
/// guaranteeing it is resumed exactly once. A continuation resumed twice traps.
final class XPCReplyBox: @unchecked Sendable {
private let lock = NSLock()
private var cont: CheckedContinuation<XPCMessage, Error>?
private var resumed = false
private var pending: Result<XPCMessage, Error>?

/// Store the continuation. If a resume already raced ahead (cancellation
/// before the continuation was installed), honor it immediately.
func store(_ cont: CheckedContinuation<XPCMessage, Error>) {
lock.lock()
if let pending, !resumed {
resumed = true
lock.unlock()
cont.resume(with: pending)
return
}
self.cont = cont
lock.unlock()
}

/// Resume the continuation with the result of `body`, at most once. A later
/// call is a no-op, so the reply handler and the cancellation handler can
/// both call it safely.
func resume(_ body: () throws -> XPCMessage) {
let result = Result { try body() }
lock.lock()
guard !resumed else {
lock.unlock()
return
}
guard let cont else {
pending = result
lock.unlock()
return
}
resumed = true
self.cont = nil
lock.unlock()
cont.resume(with: result)
}
}

#endif
10 changes: 10 additions & 0 deletions Sources/ContainerXPC/XPCServer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,16 @@ public struct XPCServer: Sendable {
}
xpc_connection_send_message(connection, reply.underlying)
}
} else {
// No handler for this route: reply with an error instead of dropping
// the message, otherwise the client blocks until its timeout (or
// forever, if it sent none).
log.error("no handler registered for route", metadata: ["route": "\(route)"])
Self.replyWithError(
connection: connection,
object: object,
err: ContainerizationError(.invalidArgument, message: "unknown route: \(route)")
)
}
}

Expand Down