Skip to content

Commit 0654cd1

Browse files
committed
Encode mutating-safe contract in XPCClient.send
The Codex adversarial review of this branch flagged that `XPCClient.send(_:responseTimeout:)` can drop a late XPC reply after its timeout fires. For idempotent reads (`ClientHealthCheck.ping`, `list` operations) that is a deliberate tradeoff: the connection remains valid for subsequent sends and the next caller can re-issue the request. For mutating operations the same behavior is unsafe: the caller surfaces `.timeout`, the user retries, and the original operation may still commit on the server — duplicate or out-of-order container/network state under any slow-but-not-dead daemon. An independent audit of the call sites contradicted the operator note written when the freeze fix was first proposed. Four mutating call sites were already reaching the unsafe path: - ContainerClient.create (containerCreate, 60s default via xpcSend) - NetworkClient.create (networkCreate, 60s default via xpcSend) - NetworkClient.delete (networkDelete, 60s default via xpcSend) - SandboxClient.create (sandboxCreateEndpoint, 60s timeout: param) This commit removes that footgun at the API surface so future call sites cannot reach for it by accident: send(_:) -- mutating-safe; no timeout send(_:timeoutForIdempotentRequest:) -- explicit; late-reply drop acknowledged at call site The old `responseTimeout:` spelling is retained as `@available(*, unavailable, ...)` so any reintroduction in a future patch fails to build with a teaching error pointing at the two overloads. Cancellation contract: - send(_:) checks Task.isCancelled before dispatch via Task.checkCancellation(); after dispatch, cancellation is ignored and the call completes only when the daemon replies or the underlying connection is invalidated. Honoring cancellation after dispatch would re-introduce the same late-commit ambiguity as a timeout. - send(_:timeoutForIdempotentRequest:) keeps the existing reply/timeout/cancellation race semantics, with late replies dropped silently so reusable clients keep working. Call-site migrations: - ContainerClient gains an `xpcSendIdempotent(message:timeout:)` helper. `create` uses the no-timeout `xpcSend(message:)`; `list` uses the idempotent helper with its existing 10s bound. - NetworkClient (APIService) follows the same split: `create` and `delete` use the no-timeout helper; `list` keeps its 1s bound via the idempotent helper. - SandboxClient.create drops its `timeout:` parameter; the only caller (ContainersService) was already passing the default. - ClientHealthCheck.ping calls the idempotent overload with a non-optional Duration. All seven ping callers in ContainerCommands are unchanged at the call site. Tests: a new ContainerXPCTests target uses an in-process `xpc_endpoint_create`-based listener so the contract can be exercised without a live mach service. Six tests cover both overloads: - idempotentTimeoutReturnsWithinBound — verifies the .timeout error code (not .interrupted) and that elapsed time is within the expected window - reusableClientSurvivesIdempotentTimeout — same XPCClient instance survives a timeout and can complete a follow-up send - lateReplyAfterIdempotentTimeoutIsIgnoredCleanly — server replies after the client has timed out; subsequent send still works - plainSendCompletesWhenServerReplies — happy path - plainSendIgnoresCancellationAfterDispatch — Task.cancel() after dispatch must NOT short-circuit; the task waits for the reply - plainSendHonorsCancellationBeforeDispatch — pre-dispatch cancellation surfaces CancellationError What this commit does not address: - No idempotency token or recovery query (Codex's third suggestion). This commit prevents the unsafe combination at the API; it does not give callers a way to safely time out a mutating request and then ask the daemon "did it actually commit?". - Reusable ContainerClient/NetworkClient mutating calls now have no timeout (correctly so, per the new contract). Wedged-daemon scenarios will hang those callers indefinitely; the user-visible workaround (`launchctl bootout`) remains the only escape today. Both are reasonable follow-ups but out of scope for closing the freeze regression.
1 parent 9985b32 commit 0654cd1

7 files changed

Lines changed: 422 additions & 33 deletions

File tree

Package.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -428,6 +428,12 @@ let package = Package(
428428
"CAuditToken",
429429
]
430430
),
431+
.testTarget(
432+
name: "ContainerXPCTests",
433+
dependencies: [
434+
"ContainerXPC"
435+
]
436+
),
431437
.target(
432438
name: "ContainerOS",
433439
dependencies: [

Sources/ContainerXPC/XPCClient.swift

Lines changed: 79 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -69,23 +69,66 @@ extension XPCClient {
6969
xpc_connection_get_pid(self.connection)
7070
}
7171

72-
/// Send the provided message to the service.
72+
/// Send the provided message to the service and wait for the reply.
73+
///
74+
/// Use this overload for **mutating or unknown-safety** requests. It has no
75+
/// response timeout: once the message has been dispatched, this function
76+
/// blocks until the XPC service replies or the underlying connection is
77+
/// invalidated.
78+
///
79+
/// Cancellation is honored only **before dispatch**
80+
/// (`Task.checkCancellation()`). After the message is sent, `Task`
81+
/// cancellation does not resume the caller, because doing so would let the
82+
/// server commit a mutation after the caller had already given up — the
83+
/// classic late-reply race that creates duplicate or out-of-order state.
84+
///
85+
/// For read-only or idempotent requests where dropping a late reply is
86+
/// acceptable, use ``send(_:timeoutForIdempotentRequest:)`` instead.
87+
///
88+
/// See `docs/internal/codex-reviews.md` and
89+
/// `docs/internal/help-freeze-analysis.md` for the full design rationale.
90+
@discardableResult
91+
public func send(_ message: XPCMessage) async throws -> XPCMessage {
92+
try Task.checkCancellation()
93+
return try await withCheckedThrowingContinuation { (cont: CheckedContinuation<XPCMessage, Error>) in
94+
xpc_connection_send_message_with_reply(self.connection, message.underlying, nil) { reply in
95+
do {
96+
cont.resume(returning: try self.parseReply(reply))
97+
} catch {
98+
cont.resume(throwing: error)
99+
}
100+
}
101+
}
102+
}
103+
104+
/// Send the provided message to the service with an idempotency-safe
105+
/// timeout.
73106
///
74107
/// The response is delivered by whichever of the following completes first:
75108
/// 1. The XPC reply callback fires.
76109
/// 2. `responseTimeout` elapses.
77110
/// 3. The current `Task` is cancelled.
78111
///
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.
112+
/// **Use this overload only for read-only or idempotent requests.** When
113+
/// timeout or cancellation wins the race, the underlying XPC reply callback
114+
/// may still fire later and the server may still complete the operation.
115+
/// Late completions are dropped silently so the connection remains valid
116+
/// for subsequent sends — important for callers that hold a long-lived
117+
/// `XPCClient` (`ContainerClient`, `NetworkClient`). For mutating requests
118+
/// this would create duplicate or out-of-order server-side state; use
119+
/// ``send(_:)`` instead.
120+
///
121+
/// A previous implementation used a `withThrowingTaskGroup`, but
122+
/// structured-concurrency cleanup awaited the XPC child task, which could
123+
/// not actually be cancelled because
124+
/// `xpc_connection_send_message_with_reply` only resumes when its callback
125+
/// fires. That made the timeout ineffective whenever the remote service was
126+
/// wedged. See docs/internal/help-freeze-analysis.md.
87127
@discardableResult
88-
public func send(_ message: XPCMessage, responseTimeout: Duration? = nil) async throws -> XPCMessage {
128+
public func send(
129+
_ message: XPCMessage,
130+
timeoutForIdempotentRequest responseTimeout: Duration
131+
) async throws -> XPCMessage {
89132
let state = ResumptionState<XPCMessage>()
90133
return try await withTaskCancellationHandler {
91134
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<XPCMessage, Error>) in
@@ -100,18 +143,16 @@ extension XPCClient {
100143
}
101144
}
102145

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-
)
146+
let service = self.service
147+
let route = message.string(key: XPCMessage.routeKey) ?? "nil"
148+
Task { [state] in
149+
try? await Task.sleep(for: responseTimeout)
150+
state.tryResume(
151+
throwing: ContainerizationError(
152+
.timeout,
153+
message: "XPC timeout for request to \(service)/\(route)"
113154
)
114-
}
155+
)
115156
}
116157

117158
// Close the race window: if cancellation arrived before `set(cont)`
@@ -126,6 +167,23 @@ extension XPCClient {
126167
}
127168
}
128169

170+
/// Compile-time guard against the previous footgun spelling.
171+
///
172+
/// The previous API allowed any caller to pass `responseTimeout:` regardless
173+
/// of whether the request mutated server-side state. When the timeout fired
174+
/// against a mutating request, the server could still commit the operation
175+
/// while the caller had already given up — the late-reply race documented in
176+
/// `docs/internal/codex-reviews.md`.
177+
///
178+
/// This unavailable shim keeps the old call shape compiling-as-error so
179+
/// callers are forced to choose either ``send(_:)`` for mutating requests
180+
/// or ``send(_:timeoutForIdempotentRequest:)`` for idempotent ones.
181+
@available(*, unavailable, message: "responseTimeout may drop late replies. Use send(_:) for mutating requests, or send(_:timeoutForIdempotentRequest:) only for idempotent/read-only requests.")
182+
@discardableResult
183+
public func send(_ message: XPCMessage, responseTimeout: Duration?) async throws -> XPCMessage {
184+
fatalError("unavailable")
185+
}
186+
129187
private func parseReply(_ reply: xpc_object_t) throws -> XPCMessage {
130188
switch xpc_get_type(reply) {
131189
case XPC_TYPE_ERROR:

Sources/Services/ContainerAPIService/Client/ClientHealthCheck.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,10 @@ extension ClientHealthCheck {
2828
XPCClient(service: serviceIdentifier)
2929
}
3030

31-
public static func ping(timeout: Duration? = XPCClient.xpcRegistrationTimeout) async throws -> SystemHealth {
31+
public static func ping(timeout: Duration = XPCClient.xpcRegistrationTimeout) async throws -> SystemHealth {
3232
let client = Self.newClient()
3333
let request = XPCMessage(route: .ping)
34-
let reply = try await client.send(request, responseTimeout: timeout)
34+
let reply = try await client.send(request, timeoutForIdempotentRequest: timeout)
3535
guard let appRootValue = reply.string(key: .appRoot), let appRoot = URL(string: appRootValue) else {
3636
throw ContainerizationError(.internalError, message: "failed to decode appRoot in health check")
3737
}

Sources/Services/ContainerAPIService/Client/ContainerClient.swift

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,11 +37,16 @@ public struct ContainerClient: Sendable {
3737
}
3838

3939
@discardableResult
40-
private func xpcSend(
40+
private func xpcSend(message: XPCMessage) async throws -> XPCMessage {
41+
try await xpcClient.send(message)
42+
}
43+
44+
@discardableResult
45+
private func xpcSendIdempotent(
4146
message: XPCMessage,
42-
timeout: Duration? = XPCClient.xpcRegistrationTimeout
47+
timeout: Duration
4348
) async throws -> XPCMessage {
44-
try await xpcClient.send(message, responseTimeout: timeout)
49+
try await xpcClient.send(message, timeoutForIdempotentRequest: timeout)
4550
}
4651

4752
/// Create a new container with the given configuration.
@@ -82,7 +87,7 @@ public struct ContainerClient: Sendable {
8287
let filterData = try JSONEncoder().encode(filters)
8388
request.set(key: .listFilters, value: filterData)
8489

85-
let response = try await xpcSend(
90+
let response = try await xpcSendIdempotent(
8691
message: request,
8792
timeout: .seconds(10)
8893
)

Sources/Services/ContainerAPIService/Client/NetworkClient.swift

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -57,11 +57,16 @@ public struct NetworkClient: Sendable {
5757
}
5858

5959
@discardableResult
60-
private func xpcSend(
60+
private func xpcSend(message: XPCMessage) async throws -> XPCMessage {
61+
try await xpcClient.send(message)
62+
}
63+
64+
@discardableResult
65+
private func xpcSendIdempotent(
6166
message: XPCMessage,
62-
timeout: Duration? = XPCClient.xpcRegistrationTimeout
67+
timeout: Duration
6368
) async throws -> XPCMessage {
64-
try await xpcClient.send(message, responseTimeout: timeout)
69+
try await xpcClient.send(message, timeoutForIdempotentRequest: timeout)
6570
}
6671

6772
/// Creates a new network with the given configuration.
@@ -97,7 +102,7 @@ public struct NetworkClient: Sendable {
97102
public func list() async throws -> [NetworkState] {
98103
let request = XPCMessage(route: .networkList)
99104

100-
let response = try await xpcSend(message: request, timeout: .seconds(1))
105+
let response = try await xpcSendIdempotent(message: request, timeout: .seconds(1))
101106
let responseData = response.dataNoCopy(key: .networkStates)
102107
guard let responseData else {
103108
return []

Sources/Services/ContainerSandboxService/Client/SandboxClient.swift

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,14 +47,19 @@ public struct SandboxClient: Sendable {
4747

4848
/// Create a SandboxClient by ID and runtime string. The returned client is ready to be used
4949
/// without additional steps.
50-
public static func create(id: String, runtime: String, timeout: Duration = XPCClient.xpcRegistrationTimeout) async throws -> SandboxClient {
50+
///
51+
/// `createEndpoint` is mutating server-side: a successful reply hands back an XPC endpoint
52+
/// that the sandbox process owns. Racing the call with a response timeout would risk
53+
/// orphaning that endpoint when a slow sandbox replies after the caller has given up.
54+
/// The send therefore blocks until the sandbox replies or its connection is invalidated.
55+
public static func create(id: String, runtime: String) async throws -> SandboxClient {
5156
let label = Self.machServiceLabel(runtime: runtime, id: id)
5257
let client = XPCClient(service: label)
5358
let request = XPCMessage(route: SandboxRoutes.createEndpoint.rawValue)
5459

5560
let response: XPCMessage
5661
do {
57-
response = try await client.send(request, responseTimeout: timeout)
62+
response = try await client.send(request)
5863
} catch {
5964
throw ContainerizationError(
6065
.internalError,

0 commit comments

Comments
 (0)