Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ let package = Package(
.testTarget(
name: "ContainerCommandsTests",
dependencies: [
"ContainerAPIClient",
"ContainerCommands",
"ContainerResource",
]
Expand Down Expand Up @@ -478,6 +479,13 @@ let package = Package(
"CAuditToken",
]
),
.testTarget(
name: "ContainerXPCTests",
dependencies: [
.product(name: "Logging", package: "swift-log"),
"ContainerXPC",
]
),
.target(
name: "ContainerOS",
dependencies: [
Expand Down
29 changes: 27 additions & 2 deletions Sources/ContainerCommands/Application.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ private nonisolated(unsafe) var bootstrapLogger = {
return log
}()

typealias APIServerHealthCheck = @Sendable (Duration) async throws -> SystemHealth

public struct Application: AsyncLoggableCommand {
@OptionGroup
public var logOptions: Flags.Logging
Expand Down Expand Up @@ -129,7 +131,7 @@ public struct Application: AsyncLoggableCommand {
// by ArgumentParser and lands here.
let containsHelp = fullArgs.contains("-h") || fullArgs.contains("--help")
if fullArgs.count <= 2 && containsHelp {
let pluginLoader = try? await createPluginLoader()
let pluginLoader = await pluginLoaderForHelp()
await Self.printModifiedHelpText(pluginLoader: pluginLoader)
return
}
Expand All @@ -144,6 +146,25 @@ public struct Application: AsyncLoggableCommand {
}

public static func createPluginLoader() async throws -> PluginLoader {
try await createPluginLoader(
healthTimeout: .seconds(10),
healthCheck: defaultAPIServerHealthCheck(timeout:)
)
}

static func pluginLoaderForHelp(
healthCheck: @escaping APIServerHealthCheck = defaultAPIServerHealthCheck(timeout:)
) async -> PluginLoader? {
try? await createPluginLoader(
healthTimeout: .seconds(1),
healthCheck: healthCheck
)
}

private static func createPluginLoader(
healthTimeout: Duration,
healthCheck: @escaping APIServerHealthCheck
) async throws -> PluginLoader {
let installRootPath = CommandLine.executablePath
.removingLastComponent()
.removingLastComponent()
Expand Down Expand Up @@ -175,7 +196,7 @@ public struct Application: AsyncLoggableCommand {
AppBundlePluginFactory(logger: bootstrapLogger),
]

guard let systemHealth = try? await ClientHealthCheck.ping(timeout: .seconds(10)) else {
guard let systemHealth = try? await healthCheck(healthTimeout) else {
throw ContainerizationError(.timeout, message: "unable to retrieve application data root from API server")
}
return try PluginLoader(
Expand All @@ -188,6 +209,10 @@ public struct Application: AsyncLoggableCommand {
)
}

private static func defaultAPIServerHealthCheck(timeout: Duration) async throws -> SystemHealth {
try await ClientHealthCheck.ping(timeout: timeout)
}

/// Load the system configuration using `appRoot` / `installRoot` reported by the
/// daemon. `container system start` MUST have previously been run to start the daemon.
public static func loadContainerSystemConfig() async throws -> ContainerSystemConfig {
Expand Down
6 changes: 4 additions & 2 deletions Sources/ContainerCommands/DefaultCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,15 @@ struct DefaultCommand: AsyncLoggableCommand {
var remaining: [String] = []

func run() async throws {
// See if we have a possible plugin command.
let pluginLoader = try? await Application.createPluginLoader()
guard let command = remaining.first else {
let pluginLoader = await Application.pluginLoaderForHelp()
await Application.printModifiedHelpText(pluginLoader: pluginLoader)
return
}

// See if we have a possible plugin command.
let pluginLoader = try? await Application.createPluginLoader()

// Check for edge cases and unknown options to match the behavior in the absence of plugins.
if command.isEmpty {
throw ValidationError("unknown argument '\(command)'")
Expand Down
2 changes: 1 addition & 1 deletion Sources/ContainerCommands/HelpCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ struct HelpCommand: AsyncLoggableCommand {

func run() async throws {
if subcommandPath.isEmpty {
let pluginLoader = try? await Application.createPluginLoader()
let pluginLoader = await Application.pluginLoaderForHelp()
await Application.printModifiedHelpText(pluginLoader: pluginLoader)
return
}
Expand Down
18 changes: 11 additions & 7 deletions Sources/ContainerXPC/XPCClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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() }
}
}

Expand Down
68 changes: 68 additions & 0 deletions Sources/ContainerXPC/XPCReplyBox.swift
Original file line number Diff line number Diff line change
@@ -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<XPCMessage, Error>?
private var resumed = false
private var pending: Result<XPCMessage, Error>?

/// Store the continuation. If a resume already raced ahead (cancellation
/// before the continuation was installed), honor it immediately.
func store(_ cont: CheckedContinuation<XPCMessage, Error>) {
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
10 changes: 10 additions & 0 deletions Sources/ContainerXPC/XPCServer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
)
}
}

Expand Down
65 changes: 65 additions & 0 deletions Tests/ContainerCommandsTests/ApplicationPluginDiscoveryTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
//===----------------------------------------------------------------------===//
// 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.
//===----------------------------------------------------------------------===//

import ContainerAPIClient
import Foundation
import Testing

@testable import ContainerCommands

struct ApplicationPluginDiscoveryTests {
@Test
func helpPluginDiscoveryUsesShortHealthDeadline() async throws {
let appRoot = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
try FileManager.default.createDirectory(at: appRoot, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: appRoot) }

let loader = await Application.pluginLoaderForHelp { timeout in
#expect(timeout == .seconds(1))
return try Self.makeSystemHealth(appRoot: appRoot)
}

#expect(loader != nil)
}

@Test
func helpPluginDiscoveryReturnsNilWhenHealthCheckFails() async {
let loader = await Application.pluginLoaderForHelp { timeout in
#expect(timeout == .seconds(1))
throw TestFailure.unavailable
}

#expect(loader == nil)
}

private static func makeSystemHealth(appRoot: URL) throws -> SystemHealth {
let json = """
{
"appRoot": "\(appRoot.absoluteString)",
"installRoot": "file:///tmp/container-test-install-root",
"apiServerVersion": "test-version",
"apiServerCommit": "test-commit",
"apiServerBuild": "debug",
"apiServerAppName": "container-apiserver"
}
"""
return try JSONDecoder().decode(SystemHealth.self, from: Data(json.utf8))
}
}

private enum TestFailure: Error {
case unavailable
}
Loading