You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
message:"XPC timeout for request to \(service)/\(route)"
154
+
)
155
+
)
156
+
}
106
157
107
-
guardlet response else{
108
-
throwContainerizationError(.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
+
ifTask.isCancelled {
162
+
state.tryResume(throwing:CancellationError())
163
+
}
109
164
}
110
-
return response
165
+
} onCancel:{
166
+
state.tryResume(throwing:CancellationError())
111
167
}
112
168
}
113
169
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.")
0 commit comments