Skip to content

Commit 9985b32

Browse files
committed
Make XPCClient.send timeout actually cancel pending replies
The previous implementation raced `Task.sleep` against the XPC reply inside a `withThrowingTaskGroup`. When the timeout won, structured concurrency required the group to await the XPC child task before the group scope could return — but that child was suspended in a `withCheckedThrowingContinuation` that only resumes when the C `xpc_connection_send_message_with_reply` callback fires. Cancelling a Swift Task does not cancel the underlying C call. If the remote service was wedged (no reply, no connection invalidation), the child never resumed and the group never returned, regardless of the supplied `responseTimeout`. The `responseTimeout` parameter was therefore silently ineffective in exactly the failure mode it was meant to mitigate. This commit replaces the TaskGroup with a single-resume gate (`ResumptionState`) over a `CheckedContinuation` wrapped in a `withTaskCancellationHandler`. The continuation is resumed by whichever of the following completes first: 1. The XPC reply callback fires. 2. `responseTimeout` elapses. 3. The current Task is cancelled. Late completions from the other paths are dropped silently, so the underlying XPC connection remains valid for subsequent sends. This is required for callers that hold a long-lived `XPCClient` (`ContainerClient`, `NetworkClient`); a simpler design that called `xpc_connection_cancel` on timeout would brick those clients after a single timed-out send. Tradeoffs documented in docs/internal/help-freeze-analysis.md: - On timeout/cancel, the eventual late XPC reply is retained by XPC until the connection is released. For short-lived clients this is GC'd within milliseconds; for long-lived reusable clients the worst case is one orphaned `xpc_object_t` per timed-out send. - The unstructured `Task` that runs the timeout sleep is not cancelled when the parent task is cancelled; it wakes up later and becomes a no-op via `tryResume`. Reviewers: a unit test that injects a connection with a non-firing reply would meaningfully cover both the timeout path and the reusable-client guarantee. Happy to add it in this PR or as a follow-up — preference?
1 parent 2139733 commit 9985b32

1 file changed

Lines changed: 77 additions & 30 deletions

File tree

Sources/ContainerXPC/XPCClient.swift

Lines changed: 77 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -70,44 +70,59 @@ extension XPCClient {
7070
}
7171

7272
/// Send the provided message to the service.
73+
///
74+
/// The response is delivered by whichever of the following completes first:
75+
/// 1. The XPC reply callback fires.
76+
/// 2. `responseTimeout` elapses.
77+
/// 3. The current `Task` is cancelled.
78+
///
79+
/// Late completions from the other paths are dropped silently so the connection
80+
/// remains valid for subsequent sends — important for callers that hold a long-lived
81+
/// `XPCClient` (`ContainerClient`, `NetworkClient`). A previous implementation used
82+
/// a `withThrowingTaskGroup`, but structured-concurrency cleanup awaited the XPC
83+
/// child task, which could not actually be cancelled because
84+
/// `xpc_connection_send_message_with_reply` only resumes when its callback fires.
85+
/// That made `responseTimeout` ineffective whenever the remote service was wedged.
86+
/// See docs/internal/help-freeze-analysis.md.
7387
@discardableResult
7488
public func send(_ message: XPCMessage, responseTimeout: Duration? = nil) async throws -> XPCMessage {
75-
try await withThrowingTaskGroup(of: XPCMessage.self, returning: XPCMessage.self) { group in
76-
if let responseTimeout {
77-
group.addTask {
78-
try await Task.sleep(for: responseTimeout)
79-
let route = message.string(key: XPCMessage.routeKey) ?? "nil"
80-
throw ContainerizationError(
81-
.internalError,
82-
message: "XPC timeout for request to \(self.service)/\(route)"
83-
)
89+
let state = ResumptionState<XPCMessage>()
90+
return try await withTaskCancellationHandler {
91+
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<XPCMessage, Error>) in
92+
state.set(cont)
93+
94+
xpc_connection_send_message_with_reply(self.connection, message.underlying, nil) { reply in
95+
do {
96+
let parsed = try self.parseReply(reply)
97+
state.tryResume(returning: parsed)
98+
} catch {
99+
state.tryResume(throwing: error)
100+
}
84101
}
85-
}
86102

87-
group.addTask {
88-
try await withCheckedThrowingContinuation { cont in
89-
xpc_connection_send_message_with_reply(self.connection, message.underlying, nil) { reply in
90-
do {
91-
let message = try self.parseReply(reply)
92-
cont.resume(returning: message)
93-
} catch {
94-
cont.resume(throwing: error)
95-
}
103+
if let responseTimeout {
104+
let service = self.service
105+
let route = message.string(key: XPCMessage.routeKey) ?? "nil"
106+
Task { [state] in
107+
try? await Task.sleep(for: responseTimeout)
108+
state.tryResume(
109+
throwing: ContainerizationError(
110+
.timeout,
111+
message: "XPC timeout for request to \(service)/\(route)"
112+
)
113+
)
96114
}
97115
}
98-
}
99-
100-
let response = try await group.next()
101-
// once one task has finished, cancel the rest.
102-
group.cancelAll()
103-
// we don't really care about the second error here
104-
// as it's most likely a `CancellationError`.
105-
try? await group.waitForAll()
106116

107-
guard let response else {
108-
throw ContainerizationError(.invalidState, message: "failed to receive XPC response")
117+
// Close the race window: if cancellation arrived before `set(cont)`
118+
// ran, the cancellation handler resumed against an empty state. Resume
119+
// here so the continuation cannot be lost.
120+
if Task.isCancelled {
121+
state.tryResume(throwing: CancellationError())
122+
}
109123
}
110-
return response
124+
} onCancel: {
125+
state.tryResume(throwing: CancellationError())
111126
}
112127
}
113128

@@ -133,4 +148,36 @@ extension XPCClient {
133148
}
134149
}
135150

151+
/// Single-resume gate around a `CheckedContinuation`.
152+
///
153+
/// `XPCClient.send` races multiple completion sources (XPC reply callback, timeout
154+
/// sleep, parent-task cancellation) against a single continuation. Whichever wins
155+
/// resumes via `tryResume`; subsequent resumes are dropped silently.
156+
private final class ResumptionState<T: Sendable>: @unchecked Sendable {
157+
private let lock = NSLock()
158+
private var continuation: CheckedContinuation<T, Error>?
159+
160+
func set(_ continuation: CheckedContinuation<T, Error>) {
161+
lock.lock()
162+
defer { lock.unlock() }
163+
self.continuation = continuation
164+
}
165+
166+
func tryResume(returning value: T) {
167+
lock.lock()
168+
let c = continuation
169+
continuation = nil
170+
lock.unlock()
171+
c?.resume(returning: value)
172+
}
173+
174+
func tryResume(throwing error: Error) {
175+
lock.lock()
176+
let c = continuation
177+
continuation = nil
178+
lock.unlock()
179+
c?.resume(throwing: error)
180+
}
181+
}
182+
136183
#endif

0 commit comments

Comments
 (0)