Skip to content

Commit 6d1b9d1

Browse files
authored
fix(commands): Fix indefinite hang on container --help / help / no-args (#8)
* Document the container --help freeze investigation Adds docs/internal/help-freeze-analysis.md describing the two defects that combine to produce an indefinite hang on `container --help` when `com.apple.container.apiserver` is dead, wedged, or stale-registered in launchd: A. The help path requires the daemon to be reachable, because `Application.main` calls `createPluginLoader()` (which pings the API server) before printing help. B. `XPCClient.send`'s timeout cannot actually unblock the function: the structured TaskGroup must await the XPC child task, which is suspended in a `withCheckedThrowingContinuation` that only resumes when the C callback fires. The document is intended to be reviewed alongside the two follow-up commits that implement the fixes. * Skip API server ping for help and no-args paths Help rendering must not depend on `com.apple.container.apiserver` being reachable. When the API server is dead, wedged, or stale-registered in launchd, the previous behavior was an indefinite hang on: container --help container help container All three paths called `Application.createPluginLoader()` (which pings the API server to fetch `appRoot`/`installRoot`/`logRoot`) just to enrich the help text with plugin commands. The ping is structurally unnecessary for help: `PluginLoader.alterCLIHelpText` only reads `pluginDirectories` and `pluginFactories`. This commit removes the call from each help path and extends `printModifiedHelpText` with an optional `unavailableMessage:` so that contexts which deliberately skipped plugin loading do not print the misleading 'PLUGINS: not available, run `container system start`' notice. `DefaultCommand` is reordered so the API server is contacted only when there is a plugin command to dispatch. Plugin enrichment in help output is removed by this commit. A follow-up can restore it by extracting filesystem-only plugin discovery from `PluginLoader.findPlugins` (see docs/internal/help-freeze-analysis.md for the proposed shape). Verified by running each path with no apiserver running on macOS 26: all three return immediately with exit 0 and the original `OVERVIEW: A container platform for macOS` block. * 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? * 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. * Document codex review and mutating-safe send follow-up Adds docs/internal/codex-reviews.md capturing the verbatim output of the two `codex` plugin reviews run against this branch: - /codex:review — pass - /codex:adversarial-review — needs-attention The adversarial pass surfaced a high-severity design concern about late XPC replies for mutating requests under a slow-but-not-dead daemon. The doc preserves the Codex output for the record and adds a follow-up section that: - Corrects the operator note's claim that no mutating call site used `responseTimeout` today. Independent verification surfaced four live mutating-with-timeout call sites (ContainerClient.create, NetworkClient.create, NetworkClient.delete, SandboxClient.create). - Records the decision to implement Codex's first suggestion (restrict `responseTimeout` to idempotent operations) at the XPCClient.send API surface, with the design rationale. - Documents the new API shape, the cancellation contract for both overloads, and the call-site migrations. - Lists the test coverage added in the new ContainerXPCTests target. - Notes the two issues the follow-up still does not address (idempotency tokens; wedged-daemon hang protection for reusable clients) and explains why they are out of scope for closing the freeze regression. The implementation referenced in this doc landed in the prior commit on this branch.
1 parent e9891b3 commit 6d1b9d1

12 files changed

Lines changed: 837 additions & 51 deletions

File tree

Package.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -425,6 +425,12 @@ let package = Package(
425425
"CAuditToken",
426426
]
427427
),
428+
.testTarget(
429+
name: "ContainerXPCTests",
430+
dependencies: [
431+
"ContainerXPC"
432+
]
433+
),
428434
.target(
429435
name: "ContainerOS",
430436
dependencies: [

Sources/ContainerCommands/Application.swift

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -118,10 +118,13 @@ public struct Application: AsyncLoggableCommand {
118118
} catch {
119119
// --help/-h on the root command (e.g. `container --help`) is intercepted
120120
// by ArgumentParser and lands here.
121+
//
122+
// Help rendering must not depend on the API server being reachable, otherwise
123+
// a wedged or unregistered `com.apple.container.apiserver` causes an
124+
// indefinite hang. See docs/internal/help-freeze-analysis.md.
121125
let containsHelp = fullArgs.contains("-h") || fullArgs.contains("--help")
122126
if fullArgs.count <= 2 && containsHelp {
123-
let pluginLoader = try? await createPluginLoader()
124-
await Self.printModifiedHelpText(pluginLoader: pluginLoader)
127+
await Self.printModifiedHelpText(pluginLoader: nil, unavailableMessage: nil)
125128
return
126129
}
127130
let errorAsString: String = String(describing: error)
@@ -234,11 +237,21 @@ public struct Application: AsyncLoggableCommand {
234237
extension Application {
235238
// Because we support plugins, we need to modify the help text to display
236239
// any if we found some.
237-
static func printModifiedHelpText(pluginLoader: PluginLoader?) async {
240+
//
241+
// Pass `unavailableMessage: nil` from contexts that already deliberately skipped
242+
// plugin loading (e.g. the `--help` / `help` / no-args paths that must not depend
243+
// on the API server). The default preserves prior behavior for paths that *did*
244+
// attempt to load plugins but couldn't reach the server.
245+
static func printModifiedHelpText(
246+
pluginLoader: PluginLoader?,
247+
unavailableMessage: String? = "PLUGINS: not available, run `container system start`"
248+
) async {
238249
let original = Application.helpMessage(for: Application.self)
239250
guard let pluginLoader else {
240251
print(original)
241-
print("PLUGINS: not available, run `container system start`")
252+
if let unavailableMessage {
253+
print(unavailableMessage)
254+
}
242255
return
243256
}
244257
let altered = pluginLoader.alterCLIHelpText(original: original)

Sources/ContainerCommands/DefaultCommand.swift

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,13 +33,17 @@ struct DefaultCommand: AsyncLoggableCommand {
3333
var remaining: [String] = []
3434

3535
func run() async throws {
36-
// See if we have a possible plugin command.
37-
let pluginLoader = try? await Application.createPluginLoader()
36+
// No-args invocation prints help and must not depend on the API server.
37+
// See docs/internal/help-freeze-analysis.md.
3838
guard let command = remaining.first else {
39-
await Application.printModifiedHelpText(pluginLoader: pluginLoader)
39+
await Application.printModifiedHelpText(pluginLoader: nil, unavailableMessage: nil)
4040
return
4141
}
4242

43+
// We have a candidate plugin command; load plugins (which contacts the
44+
// API server) only on this path.
45+
let pluginLoader = try? await Application.createPluginLoader()
46+
4347
// Check for edge cases and unknown options to match the behavior in the absence of plugins.
4448
if command.isEmpty {
4549
throw ValidationError("unknown argument '\(command)'")

Sources/ContainerCommands/HelpCommand.swift

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@ struct HelpCommand: AsyncLoggableCommand {
2727
public var logOptions: Flags.Logging
2828

2929
func run() async throws {
30-
let pluginLoader = try? await Application.createPluginLoader()
31-
await Application.printModifiedHelpText(pluginLoader: pluginLoader)
30+
// The `help` subcommand must not depend on the API server being reachable.
31+
// See docs/internal/help-freeze-analysis.md.
32+
await Application.printModifiedHelpText(pluginLoader: nil, unavailableMessage: nil)
3233
}
3334
}

Sources/ContainerXPC/XPCClient.swift

Lines changed: 135 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -69,48 +69,121 @@ 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.
7390
@discardableResult
74-
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-
)
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)
8499
}
85100
}
101+
}
102+
}
86103

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-
}
104+
/// Send the provided message to the service with an idempotency-safe
105+
/// timeout.
106+
///
107+
/// The response is delivered by whichever of the following completes first:
108+
/// 1. The XPC reply callback fires.
109+
/// 2. `responseTimeout` elapses.
110+
/// 3. The current `Task` is cancelled.
111+
///
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.
127+
@discardableResult
128+
public func send(
129+
_ message: XPCMessage,
130+
timeoutForIdempotentRequest responseTimeout: Duration
131+
) async throws -> XPCMessage {
132+
let state = ResumptionState<XPCMessage>()
133+
return try await withTaskCancellationHandler {
134+
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<XPCMessage, Error>) in
135+
state.set(cont)
136+
137+
xpc_connection_send_message_with_reply(self.connection, message.underlying, nil) { reply in
138+
do {
139+
let parsed = try self.parseReply(reply)
140+
state.tryResume(returning: parsed)
141+
} catch {
142+
state.tryResume(throwing: error)
96143
}
97144
}
98-
}
99145

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()
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)"
154+
)
155+
)
156+
}
106157

107-
guard let response else {
108-
throw ContainerizationError(.invalidState, message: "failed to receive XPC response")
158+
// Close the race window: if cancellation arrived before `set(cont)`
159+
// ran, the cancellation handler resumed against an empty state. Resume
160+
// here so the continuation cannot be lost.
161+
if Task.isCancelled {
162+
state.tryResume(throwing: CancellationError())
163+
}
109164
}
110-
return response
165+
} onCancel: {
166+
state.tryResume(throwing: CancellationError())
111167
}
112168
}
113169

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+
114187
private func parseReply(_ reply: xpc_object_t) throws -> XPCMessage {
115188
switch xpc_get_type(reply) {
116189
case XPC_TYPE_ERROR:
@@ -133,4 +206,36 @@ extension XPCClient {
133206
}
134207
}
135208

209+
/// Single-resume gate around a `CheckedContinuation`.
210+
///
211+
/// `XPCClient.send` races multiple completion sources (XPC reply callback, timeout
212+
/// sleep, parent-task cancellation) against a single continuation. Whichever wins
213+
/// resumes via `tryResume`; subsequent resumes are dropped silently.
214+
private final class ResumptionState<T: Sendable>: @unchecked Sendable {
215+
private let lock = NSLock()
216+
private var continuation: CheckedContinuation<T, Error>?
217+
218+
func set(_ continuation: CheckedContinuation<T, Error>) {
219+
lock.lock()
220+
defer { lock.unlock() }
221+
self.continuation = continuation
222+
}
223+
224+
func tryResume(returning value: T) {
225+
lock.lock()
226+
let c = continuation
227+
continuation = nil
228+
lock.unlock()
229+
c?.resume(returning: value)
230+
}
231+
232+
func tryResume(throwing error: Error) {
233+
lock.lock()
234+
let c = continuation
235+
continuation = nil
236+
lock.unlock()
237+
c?.resume(throwing: error)
238+
}
239+
}
240+
136241
#endif

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)