diff --git a/Sources/ContainerXPC/XPCClient.swift b/Sources/ContainerXPC/XPCClient.swift index bab008509..ea4edb7e5 100644 --- a/Sources/ContainerXPC/XPCClient.swift +++ b/Sources/ContainerXPC/XPCClient.swift @@ -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() } } } diff --git a/Sources/ContainerXPC/XPCReplyBox.swift b/Sources/ContainerXPC/XPCReplyBox.swift new file mode 100644 index 000000000..24a9a1c07 --- /dev/null +++ b/Sources/ContainerXPC/XPCReplyBox.swift @@ -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? + private var resumed = false + private var pending: Result? + + /// Store the continuation. If a resume already raced ahead (cancellation + /// before the continuation was installed), honor it immediately. + func store(_ cont: CheckedContinuation) { + 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 diff --git a/Sources/ContainerXPC/XPCServer.swift b/Sources/ContainerXPC/XPCServer.swift index b33453142..a46dc0873 100644 --- a/Sources/ContainerXPC/XPCServer.swift +++ b/Sources/ContainerXPC/XPCServer.swift @@ -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)") + ) } }