From e7cf1b0f84b0a9ed54f56e31db96325c2eff7762 Mon Sep 17 00:00:00 2001 From: OrtegaMatias <212189024+OrtegaMatias@users.noreply.github.com> Date: Tue, 28 Jul 2026 03:58:23 -0400 Subject: [PATCH 1/5] feat: launch independent services concurrently in `up` (#128) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `up` launched every service strictly one at a time, so N independent services paid N× the per-service start + readiness cost (the issue reports ~23s for 11 independent services). It now launches each dependency WAVE (from `Service.dependencyLevels`) concurrently: services with no pending dependency start together, while a dependent still waits for its dependencies to reach their required condition. All cross-service launch state — container names, IPs, start states, health, console colors, and the IP-substituted environment — moves into a new `LaunchState` actor so concurrent launches are data-race-free. Those dictionaries were previously stored on the value-type command and mutated from a serial loop, and `@unchecked Sendable` hid that hazard. A barrier between waves guarantees a service's dependencies are fully started before it begins, so `depends_on` ordering and conditions (service_started / service_healthy / service_completed_successfully) are preserved. The new `--sequential` flag restores the original one-at-a-time behavior. Verified on the Apple `container` daemon: 8 independent services took 11.6s sequential vs 6.1s parallel (all reaching running); a web->app->db chain still comes up strictly in dependency order. --- .../Commands/ComposeUp.swift | 224 +++++++++++++----- 1 file changed, 163 insertions(+), 61 deletions(-) diff --git a/Sources/Container-Compose/Commands/ComposeUp.swift b/Sources/Container-Compose/Commands/ComposeUp.swift index d42efd7..08a77d3 100644 --- a/Sources/Container-Compose/Commands/ComposeUp.swift +++ b/Sources/Container-Compose/Commands/ComposeUp.swift @@ -72,6 +72,71 @@ private actor ForegroundRunHandle { func exitCodeIfCompleted() -> Int32? { exitCode } } +/// Owns every piece of cross-service mutable state touched while launching +/// services. `up` used to keep these as `ComposeUp` (a value-type command) +/// dictionaries and mutate them from a strictly serial loop; launching a whole +/// dependency wave concurrently (issue #128) would race those dictionaries, and +/// `@unchecked Sendable` hides that from the compiler. Routing all reads/writes +/// through this actor makes concurrent launches data-race-free. Within a wave +/// services are independent (they only depend on earlier, already-completed +/// waves), so this actor is the only synchronization needed. +private actor LaunchState { + var environmentVariables: [String: String] + private var containerIps: [String: String] = [:] + private var serviceStartStates: [String: ServiceStartState] = [:] + private var serviceHealth: [String: Bool] = [:] + private var serviceContainerNames: [String: String] = [:] + private var usedColors: Set = [] + private let palette: [NamedColor] + + init(environmentVariables: [String: String], palette: [NamedColor]) { + self.environmentVariables = environmentVariables + self.palette = palette + } + + // Container names + func recordContainerName(_ name: String, for service: String) { serviceContainerNames[service] = name } + func allContainerNames() -> [String: String] { serviceContainerNames } + + // Start state + func recordStartState(_ state: ServiceStartState, for service: String) { serviceStartStates[service] = state } + func startState(of service: String) -> ServiceStartState? { serviceStartStates[service] } + + // Health + func recordHealthy(_ service: String) { serviceHealth[service] = true } + func isHealthy(_ service: String) -> Bool { serviceHealth[service] == true } + + // IPs + env (IP substitution mirrors the old updateEnvironmentWithServiceIP) + func recordIP(_ ip: String?, for service: String) { + containerIps[service] = ip + for (key, value) in environmentVariables where value == service { + environmentVariables[key] = ip ?? value + } + } + func allContainerIps() -> [String: String] { containerIps } + func environmentSnapshot() -> [String: String] { environmentVariables } + + /// Atomically picks a console color, preferring one not yet used while the + /// palette isn't exhausted (was `ComposeUp`'s racy read-modify-write of + /// `containerConsoleColors`). + func pickColor(for service: String) -> NamedColor { + var color = palette.randomElement()! + if usedColors.count < palette.count { + while usedColors.contains(color) { color = palette.randomElement()! } + } + usedColors.insert(color) + return color + } +} + +/// Carries the immutable compose model (a value type) into a concurrent launch +/// task. Each task only reads it, so sending it across the isolation boundary is +/// safe — the same reason the command structs are `@unchecked Sendable`. +private struct UncheckedSendable: @unchecked Sendable { + let value: T + init(_ value: T) { self.value = value } +} + public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { public init() {} @@ -103,6 +168,11 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { @Flag(name: .long, help: "Do not use cache") var noCache: Bool = false + @Flag( + name: .customLong("sequential"), + help: "Launch services one at a time in dependency order (the pre-parallel behavior). By default, services with no pending dependency start concurrently.") + var sequential: Bool = false + @OptionGroup var logging: Flags.Logging @@ -119,15 +189,9 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { /// queries from inside containers. When true, services get a dotted `--name` /// + `--dns-domain` and the /etc/hosts cross-patcher is skipped. private var dnsAvailable: Bool = false + /// Seed environment loaded from the `.env` file. Copied into `LaunchState` + /// (which owns the live, IP-substituted copy) once launching begins. private var environmentVariables: [String: String] = [:] - private var containerIps: [String: String] = [:] - private var serviceStartStates: [String: ServiceStartState] = [:] - private var serviceHealth: [String: Bool] = [:] - /// Resolved container ID (i.e. the name on disk) per service. - /// Equal to `service.container_name` when set, otherwise either - /// `.` (DNS path) or `-` (legacy). - private var serviceContainerNames: [String: String] = [:] - private var containerConsoleColors: [String: NamedColor] = [:] private static let availableContainerConsoleColors: Set = [ .blue, .cyan, .magenta, .lightBlack, .lightBlue, .lightCyan, .lightYellow, .yellow, .lightGreen, .green, @@ -214,14 +278,60 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { // Process each service defined in the docker-compose.yml print("\n--- Processing Services ---") - print(project.services.map(\.serviceName)) - for target in project.services { - try await configService(target.service, serviceName: target.serviceName, from: dockerCompose) + + // All cross-service launch state lives in this actor so a wave of + // services can be launched concurrently without racing. + let launchState = LaunchState( + environmentVariables: environmentVariables, + palette: Array(Self.availableContainerConsoleColors)) + + // `run()` is `mutating` (it set projectName/dnsDomain/... above), so an + // escaping task closure can't capture `self`. Snapshot the now-immutable + // command config into a `let` the concurrent tasks can share. + let launcher = self + + if sequential { + // Explicit opt-out (`--sequential`): the original one-at-a-time + // behavior, in topological order. + for target in project.services { + try await configService( + target.service, serviceName: target.serviceName, + from: dockerCompose, launchState: launchState) + } + } else { + // Launch each dependency WAVE concurrently. `dependencyLevels` groups + // services so every service in a wave has all of its in-set + // dependencies in an earlier wave; a dependency cycle throws here, + // before anything is launched. The barrier between waves means a + // service's dependencies are always fully started before it begins. + let servicesByName = Dictionary( + uniqueKeysWithValues: project.services.map { ($0.serviceName, $0.service) }) + let waves = try Service.dependencyLevels( + project.services.map { ($0.serviceName, $0.service) }) + let composeBox = UncheckedSendable(dockerCompose) + for wave in waves { + try await withThrowingTaskGroup(of: Void.self) { group in + for serviceName in wave { + guard let service = servicesByName[serviceName] else { continue } + let inputs = UncheckedSendable((service: service, name: serviceName)) + group.addTask { + try await launcher.configService( + inputs.value.service, serviceName: inputs.value.name, + from: composeBox.value, launchState: launchState) + } + } + try await group.waitForAll() + } + } } if !detach { - await runForegroundUntilStopped(containerNames: project.services.map({ containerName(for: $0.serviceName) })) + let names = await launchState.allContainerNames() + let containerNames = project.services.map { + names[$0.serviceName] ?? "\(projectName)-\($0.serviceName)" + } + await runForegroundUntilStopped(containerNames: containerNames) } } @@ -492,9 +602,6 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { } } - private func containerName(for serviceName: String) -> String { - serviceContainerNames[serviceName] ?? "\(projectName)-\(serviceName)" - } /// Pure parser for `container system dns list` output. Output looks like: /// DOMAIN @@ -527,9 +634,7 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { return Self.dnsListContainsDomain(text, domain: domain) } - private func getIPForRunningService(_ serviceName: String) async throws -> String? { - let name = containerName(for: serviceName) - + private func getIPForRunningService(containerName name: String) async throws -> String? { let client = ContainerClient() let container = try await client.get(id: name) // Use the container's own address, not the network gateway — every @@ -567,13 +672,13 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { /// - interval: How often to poll (in seconds). private func waitUntilServiceStarted( _ serviceName: String, + containerName: String, activity: ActivityClock, foregroundRun: ForegroundRunHandle? = nil, idleTimeout: TimeInterval = 30, maxWait: TimeInterval = 300, interval: TimeInterval = 0.5 ) async throws -> ServiceStartState { - let containerName = containerName(for: serviceName) let client = ContainerClient() let start = Date() @@ -678,14 +783,11 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { // MARK: Compose Top Level Functions - private mutating func updateEnvironmentWithServiceIP(_ serviceName: String) async throws { - let ip = try await getIPForRunningService(serviceName) - self.containerIps[serviceName] = ip - for (key, value) in environmentVariables.map({ ($0, $1) }) where value == serviceName { - self.environmentVariables[key] = ip ?? value - } + private func updateEnvironmentWithServiceIP(_ serviceName: String, containerName: String, launchState: LaunchState) async throws { + let ip = try await getIPForRunningService(containerName: containerName) + await launchState.recordIP(ip, for: serviceName) if !dnsAvailable { - await crossPatchHostsForService(serviceName) + await crossPatchHostsForService(serviceName, launchState: launchState) } } @@ -694,12 +796,14 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { /// already-running peer to add ` `, and also add all the /// previously-known peers into the new container. This is best-effort — services /// that need DNS at startup time should still wait/retry. - private func crossPatchHostsForService(_ newServiceName: String) async { - guard let newIP = containerIps[newServiceName] else { return } - let newContainerID = containerName(for: newServiceName) + private func crossPatchHostsForService(_ newServiceName: String, launchState: LaunchState) async { + let ips = await launchState.allContainerIps() + let names = await launchState.allContainerNames() + guard let newIP = ips[newServiceName] else { return } + let newContainerID = names[newServiceName] ?? "\(projectName)-\(newServiceName)" // Add the new entry in every previously-running peer. - for (peerName, peerIP) in containerIps where peerName != newServiceName { - let peerContainerID = containerName(for: peerName) + for (peerName, peerIP) in ips where peerName != newServiceName { + let peerContainerID = names[peerName] ?? "\(projectName)-\(peerName)" await appendHostsEntry(in: peerContainerID, name: newServiceName, ip: newIP) // Also make the new container aware of this peer, in case it queries it later. await appendHostsEntry(in: newContainerID, name: peerName, ip: peerIP) @@ -810,8 +914,8 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { } // MARK: Compose Service Level Functions - private mutating func configService(_ service: Service, serviceName: String, from dockerCompose: DockerCompose) async throws { - try waitForDependencyConditions(serviceName: serviceName, service: service) + private func configService(_ service: Service, serviceName: String, from dockerCompose: DockerCompose, launchState: LaunchState) async throws { + try await waitForDependencyConditions(serviceName: serviceName, service: service, launchState: launchState) var imageToRun: String @@ -862,7 +966,7 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { // Default container name based on project and service name containerName = "\(projectName)-\(serviceName)" } - serviceContainerNames[serviceName] = containerName + await launchState.recordContainerName(containerName, for: serviceName) runCommandArgs.append("--name") runCommandArgs.append(containerName) @@ -907,8 +1011,11 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { } } - // Combine environment variables from .env files and service environment - var combinedEnv: [String: String] = environmentVariables + // Combine environment variables from .env files and service environment. + // The base env is snapshotted from the actor so it includes the IP + // substitutions recorded by services in earlier (already-completed) waves. + let envSnapshot = await launchState.environmentSnapshot() + var combinedEnv: [String: String] = envSnapshot if let envFiles = service.env_file { for envFile in envFiles { @@ -934,9 +1041,10 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { return combinedEnv[variableName] ?? value }) - // Fill in IPs + // Fill in IPs (peer container addresses recorded by earlier waves). + let ipSnapshot = await launchState.allContainerIps() combinedEnv = combinedEnv.mapValues({ value in - containerIps[value] ?? value + ipSnapshot[value] ?? value }) // MARK: Spinning Spot @@ -948,7 +1056,7 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { if let ports = service.ports { for port in ports { - let resolvedPort = resolveVariable(port, with: environmentVariables) + let resolvedPort = resolveVariable(port, with: envSnapshot) runCommandArgs.append("-p") runCommandArgs.append(composePortToRunArg(resolvedPort)) } @@ -957,7 +1065,7 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { // Connect to specified networks if let serviceNetworks = service.networks { for network in serviceNetworks { - let resolvedNetwork = resolveVariable(network, with: environmentVariables) + let resolvedNetwork = resolveVariable(network, with: envSnapshot) // Use the explicit network name from top-level definition if available, otherwise resolved name let networkToConnect = dockerCompose.networks?[network]??.name ?? resolvedNetwork runCommandArgs.append("--network") @@ -1135,16 +1243,7 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { runCommandArgs.append(imageToRun) runCommandArgs.append(contentsOf: argv.positional) - var serviceColor: NamedColor = Self.availableContainerConsoleColors.randomElement()! - - if Array(Set(containerConsoleColors.values)).sorted(by: { $0.rawValue < $1.rawValue }) != Self.availableContainerConsoleColors.sorted(by: { $0.rawValue < $1.rawValue }) { - while containerConsoleColors.values.contains(serviceColor) { - serviceColor = Self.availableContainerConsoleColors.randomElement()! - } - } - - let selectedColor = serviceColor - self.containerConsoleColors[serviceName] = selectedColor + let selectedColor = await launchState.pickColor(for: serviceName) // Tracks output from the run subprocess so the readiness wait below can // tell an in-progress image pull from a stuck container. @@ -1205,15 +1304,15 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { } } - let startState = try await waitUntilServiceStarted(serviceName, activity: activity, foregroundRun: foregroundRunHandle) - serviceStartStates[serviceName] = startState + let startState = try await waitUntilServiceStarted(serviceName, containerName: containerName, activity: activity, foregroundRun: foregroundRunHandle) + await launchState.recordStartState(startState, for: serviceName) switch startState { case .running: - try await updateEnvironmentWithServiceIP(serviceName) + try await updateEnvironmentWithServiceIP(serviceName, containerName: containerName, launchState: launchState) if let healthcheck = service.healthcheck, !healthcheck.isDisabled { - try await waitUntilServiceIsHealthy(serviceName: serviceName, healthcheck: healthcheck) - serviceHealth[serviceName] = true + try await waitUntilServiceIsHealthy(serviceName: serviceName, containerName: containerName, healthcheck: healthcheck) + await launchState.recordHealthy(serviceName) } case .completed: if let healthcheck = service.healthcheck, !healthcheck.isDisabled { @@ -1222,20 +1321,24 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { } } - private func waitForDependencyConditions(serviceName: String, service: Service) throws { + /// Asserts that each of `service`'s dependencies has already reached its + /// required condition. Under both the serial path and the wave-parallel + /// path a service's dependencies are in an earlier wave that has fully + /// completed before this runs, so these are satisfied checks, not polls. + private func waitForDependencyConditions(serviceName: String, service: Service, launchState: LaunchState) async throws { for dependencyName in service.depends_on ?? [] { let dependency = service.dependencyConditions?[dependencyName] ?? ServiceDependency() switch dependency.effectiveCondition { case ServiceDependency.serviceStarted: - guard serviceStartStates[dependencyName] != nil else { + guard await launchState.startState(of: dependencyName) != nil else { throw ComposeError.dependencyNotStarted(serviceName, dependencyName) } case ServiceDependency.serviceHealthy: - guard serviceHealth[dependencyName] == true else { + guard await launchState.isHealthy(dependencyName) else { throw ComposeError.dependencyNotHealthy(serviceName, dependencyName) } case ServiceDependency.serviceCompletedSuccessfully: - guard serviceStartStates[dependencyName] == .completed else { + guard await launchState.startState(of: dependencyName) == .completed else { throw ComposeError.dependencyNotCompleted(serviceName, dependencyName) } default: @@ -1244,12 +1347,11 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { } } - private func waitUntilServiceIsHealthy(serviceName: String, healthcheck: Healthcheck) async throws { + private func waitUntilServiceIsHealthy(serviceName: String, containerName: String, healthcheck: Healthcheck) async throws { guard let execArguments = healthcheck.execArguments else { return } - let containerName = containerName(for: serviceName) let retries = max(healthcheck.retries ?? 3, 1) let interval = Healthcheck.parseDuration(healthcheck.interval, default: 30) let startPeriod = Healthcheck.parseDuration(healthcheck.start_period, default: 0) From 66e74b935ff72b1e02afd68785f71eda47968e6f Mon Sep 17 00:00:00 2001 From: Mikimoto Date: Tue, 8 Sep 2026 17:40:39 +0000 Subject: [PATCH 2/5] fix(up): wait out a one-shot instead of recording it as running MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `waitUntilServiceStarted` returned `.running` the moment it observed that status. A one-shot only passes *through* `.running` on its way to `.stopped`, so an init container still working at the first 0.5s poll was recorded as running, and every dependent declaring `service_completed_successfully` was then refused: Service 'etcd2' depends on 'etcd-init' with condition 'service_completed_successfully', but 'etcd-init' has not completed successfully. The bug predates concurrent launch — launching one at a time simply lost the race almost never, because a `chown -R` init had usually already exited by the first poll. With a whole wave of VMs booting at once it became reliable. Whether a service must be waited out is not readable from the service itself; it lives on whoever depends on it. `servicesRequiredToComplete` collects those names, and `configService` passes `awaitCompletion` down so the readiness wait keeps polling for `.stopped`. `maxWait` still backstops a compose file that declares the condition on something which never exits. The collection is pure and tested. The wiring from it into the readiness wait is not: that path needs a running daemon, as does `waitUntilServiceStarted` itself, which has no test today either. It is covered only by an end-to-end run. --- .../Codable Structs/Service.swift | 23 +++ .../Commands/ComposeUp.swift | 32 +++- .../RequiredToCompleteTests.swift | 147 ++++++++++++++++++ 3 files changed, 197 insertions(+), 5 deletions(-) create mode 100644 Tests/Container-Compose-StaticTests/RequiredToCompleteTests.swift diff --git a/Sources/Container-Compose/Codable Structs/Service.swift b/Sources/Container-Compose/Codable Structs/Service.swift index b0950ef..5921109 100644 --- a/Sources/Container-Compose/Codable Structs/Service.swift +++ b/Sources/Container-Compose/Codable Structs/Service.swift @@ -472,6 +472,29 @@ public struct Service: Codable, Hashable { return waves } + /// Names of services that some other service declares + /// `condition: service_completed_successfully` on. + /// + /// A one-shot only passes *through* `.running` on its way to `.stopped`, so + /// a readiness wait that returns on the first `.running` it observes records + /// the wrong terminal state and the dependent is refused. Which services must + /// be waited out is not visible on the service itself — it lives on whoever + /// depends on it, which is what this collects. + static func servicesRequiredToComplete( + _ services: [(serviceName: String, service: Service)] + ) -> Set { + var required: Set = [] + for (_, service) in services { + for dependencyName in service.depends_on ?? [] { + let condition = service.dependencyConditions?[dependencyName] ?? ServiceDependency() + if condition.effectiveCondition == ServiceDependency.serviceCompletedSuccessfully { + required.insert(dependencyName) + } + } + } + return required + } + /// Validates that every explicitly requested service name exists among the /// services defined in the compose file. Mirrors `docker compose up `, /// which fails with "no such service: " rather than silently selecting diff --git a/Sources/Container-Compose/Commands/ComposeUp.swift b/Sources/Container-Compose/Commands/ComposeUp.swift index 08a77d3..8c1e7d7 100644 --- a/Sources/Container-Compose/Commands/ComposeUp.swift +++ b/Sources/Container-Compose/Commands/ComposeUp.swift @@ -291,13 +291,20 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { // command config into a `let` the concurrent tasks can share. let launcher = self + // Which services a dependent waits out rather than merely starts. Needed + // by both paths: the sequential one has the same one-shot race, it just + // loses it far less often. + let mustComplete = Service.servicesRequiredToComplete( + project.services.map { ($0.serviceName, $0.service) }) + if sequential { // Explicit opt-out (`--sequential`): the original one-at-a-time // behavior, in topological order. for target in project.services { try await configService( target.service, serviceName: target.serviceName, - from: dockerCompose, launchState: launchState) + from: dockerCompose, launchState: launchState, + awaitCompletion: mustComplete.contains(target.serviceName)) } } else { // Launch each dependency WAVE concurrently. `dependencyLevels` groups @@ -315,10 +322,12 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { for serviceName in wave { guard let service = servicesByName[serviceName] else { continue } let inputs = UncheckedSendable((service: service, name: serviceName)) + let awaitCompletion = mustComplete.contains(serviceName) group.addTask { try await launcher.configService( inputs.value.service, serviceName: inputs.value.name, - from: composeBox.value, launchState: launchState) + from: composeBox.value, launchState: launchState, + awaitCompletion: awaitCompletion) } } try await group.waitForAll() @@ -675,6 +684,7 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { containerName: String, activity: ActivityClock, foregroundRun: ForegroundRunHandle? = nil, + awaitCompletion: Bool = false, idleTimeout: TimeInterval = 30, maxWait: TimeInterval = 300, interval: TimeInterval = 0.5 @@ -685,7 +695,14 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { while true { try await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000)) let container = try? await client.get(id: containerName) - if container?.status == .running { + // A one-shot passes through `.running`; returning here would record + // that as its terminal state and refuse every dependent waiting for + // `service_completed_successfully`. Keep polling for `.stopped` + // instead. Launching a whole wave concurrently made this near + // certain — with six VMs booting at once a `chown -R` init is still + // running at the first 0.5s poll, where launched one at a time it + // had almost always already exited. + if container?.status == .running, !awaitCompletion { return .running } if container?.status == .stopped { @@ -914,7 +931,10 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { } // MARK: Compose Service Level Functions - private func configService(_ service: Service, serviceName: String, from dockerCompose: DockerCompose, launchState: LaunchState) async throws { + private func configService( + _ service: Service, serviceName: String, from dockerCompose: DockerCompose, + launchState: LaunchState, awaitCompletion: Bool = false + ) async throws { try await waitForDependencyConditions(serviceName: serviceName, service: service, launchState: launchState) var imageToRun: String @@ -1304,7 +1324,9 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { } } - let startState = try await waitUntilServiceStarted(serviceName, containerName: containerName, activity: activity, foregroundRun: foregroundRunHandle) + let startState = try await waitUntilServiceStarted( + serviceName, containerName: containerName, activity: activity, + foregroundRun: foregroundRunHandle, awaitCompletion: awaitCompletion) await launchState.recordStartState(startState, for: serviceName) switch startState { diff --git a/Tests/Container-Compose-StaticTests/RequiredToCompleteTests.swift b/Tests/Container-Compose-StaticTests/RequiredToCompleteTests.swift new file mode 100644 index 0000000..7cbc55e --- /dev/null +++ b/Tests/Container-Compose-StaticTests/RequiredToCompleteTests.swift @@ -0,0 +1,147 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Morris Richman and the Container-Compose project authors. All rights reserved. +// +// 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 Testing +import Foundation +@testable import Yams +@testable import ContainerComposeCore + +/// A one-shot service only passes *through* `.running`. The readiness wait +/// returned on the first `.running` it saw, so a fast init that had not quite +/// exited yet was recorded as running, and every dependent declaring +/// `service_completed_successfully` was refused: +/// +/// Service 'etcd2' depends on 'etcd-init' with condition +/// 'service_completed_successfully', but 'etcd-init' has not completed +/// successfully. +/// +/// Whether a service must be waited out is not readable from the service — it +/// lives on whoever depends on it. +@Suite("Services Required To Complete") +struct RequiredToCompleteTests { + + private func targets(_ yaml: String) throws -> [(serviceName: String, service: Service)] { + let doc = try YAMLDecoder().decode(DockerCompose.self, from: yaml) + return doc.services.compactMap { name, service in + service.map { (serviceName: name, service: $0) } + } + } + + @Test("a service depended on for completion is collected") + func completionDependencyCollected() throws { + let t = try targets(""" + services: + init: + image: alpine + app: + image: nginx + depends_on: + init: + condition: service_completed_successfully + """) + #expect(Service.servicesRequiredToComplete(t) == ["init"]) + } + + @Test("service_started and service_healthy do not require completion") + func otherConditionsExcluded() throws { + let t = try targets(""" + services: + db: + image: postgres + cache: + image: redis + app: + image: nginx + depends_on: + db: + condition: service_healthy + cache: + condition: service_started + """) + #expect(Service.servicesRequiredToComplete(t).isEmpty) + } + + /// `depends_on` in list form carries no condition, and the default is + /// `service_started` — so it must not be collected. + @Test("the list form of depends_on defaults to service_started") + func listFormDefaultsToStarted() throws { + let t = try targets(""" + services: + db: + image: postgres + app: + image: nginx + depends_on: + - db + """) + #expect(Service.servicesRequiredToComplete(t).isEmpty) + } + + /// dcf-local-env's shape: three peers each waiting on the same init. + @Test("one init depended on by several services is collected once") + func sharedInitCollectedOnce() throws { + let t = try targets(""" + services: + etcd-init: + image: alpine + etcd1: + image: etcd + depends_on: + etcd-init: + condition: service_completed_successfully + etcd2: + image: etcd + depends_on: + etcd-init: + condition: service_completed_successfully + etcd3: + image: etcd + depends_on: + etcd-init: + condition: service_completed_successfully + """) + #expect(Service.servicesRequiredToComplete(t) == ["etcd-init"]) + } + + @Test("a service nobody depends on requires nothing") + func noDependenciesAtAll() throws { + let t = try targets(""" + services: + solo: + image: nginx + """) + #expect(Service.servicesRequiredToComplete(t).isEmpty) + } + + @Test("two distinct one-shots are both collected") + func twoOneShots() throws { + let t = try targets(""" + services: + a-init: + image: alpine + b-init: + image: alpine + app: + image: nginx + depends_on: + a-init: + condition: service_completed_successfully + b-init: + condition: service_completed_successfully + """) + #expect(Service.servicesRequiredToComplete(t) == ["a-init", "b-init"]) + } +} From 553affe3baeb2534d98f3cb7ff1b89e3c259fdc9 Mon Sep 17 00:00:00 2001 From: Mikimoto Date: Wed, 9 Sep 2026 02:28:09 +0000 Subject: [PATCH 3/5] fix(up): make the wave barrier wait for DNS, not just for running MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A container reaching `.running` does not mean its name resolves yet. Apple Container publishes the DNS record a moment after the container is up; Docker's embedded DNS updates as the container joins the network, so `depends_on` there implies resolvability and compose files rely on it. haproxy resolves every `server` line while parsing its config and refuses to start if one fails, so a proxy in the next wave died on [ALERT] 'server postgres_replicas/patroni2' : could not resolve address 'patroni2'. with patroni2 running and healthy — the same name resolved from another container seconds later. After recording a service's IP, poll the host resolver for the name the daemon registers (`.`, which the host can answer via /etc/resolver) until it appears. The wave barrier then also gates on DNS, which is what the next wave actually needs. Advisory rather than fatal: on timeout it warns and proceeds, since a compose file may name a container the daemon never registers. Only the name derivation is unit-tested; the poll needs a live daemon, as does everything else on this path. Rejected first: giving haproxy a `resolvers` section with `init-addr last,libc,none`. haproxy's own resolver does not expand search domains, so it queried the bare `patroni2` and cached NXDOMAIN, parking every backend in maintenance permanently — strictly worse than the race it was meant to fix. The fix belongs where the asymmetry is. --- .../Commands/ComposeUp.swift | 46 +++++++++++++++ .../DNSRegistrationNameTests.swift | 57 +++++++++++++++++++ 2 files changed, 103 insertions(+) create mode 100644 Tests/Container-Compose-StaticTests/DNSRegistrationNameTests.swift diff --git a/Sources/Container-Compose/Commands/ComposeUp.swift b/Sources/Container-Compose/Commands/ComposeUp.swift index 8c1e7d7..ec8b57f 100644 --- a/Sources/Container-Compose/Commands/ComposeUp.swift +++ b/Sources/Container-Compose/Commands/ComposeUp.swift @@ -930,6 +930,46 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { } } + /// The name the daemon registers a container under when a DNS domain is + /// configured: the container name with the domain appended, unless it + /// already carries it (the no-`container_name` path builds a dotted name + /// itself). Matches the `hostname` `container inspect` reports. + static func dnsRegistrationName(containerName: String, dnsDomain: String) -> String { + containerName.hasSuffix(".\(dnsDomain)") ? containerName : "\(containerName).\(dnsDomain)" + } + + /// Blocks until the daemon has published a DNS record for `name`, or the + /// budget runs out. + /// + /// A container reaching `.running` does not mean its name resolves yet: + /// Apple Container publishes the record a moment afterwards. Docker's + /// embedded DNS updates as the container joins the network, so `depends_on` + /// there implies resolvability and every compose file relies on it — + /// haproxy resolves all of its `server` lines while parsing its config and + /// aborts if one fails, which is how a proxy in the next wave died on + /// "could not resolve address 'patroni2'" with patroni2 up and healthy. + /// + /// Only advisory: on timeout it warns and proceeds, because a compose file + /// may legitimately name a container the daemon never registers. + private func waitUntilNameResolves( + _ name: String, timeout: TimeInterval = 20, interval: TimeInterval = 0.25 + ) async { + let start = Date() + while Date().timeIntervalSince(start) < timeout { + var hints = addrinfo() + hints.ai_family = AF_UNSPEC + hints.ai_socktype = SOCK_STREAM + var result: UnsafeMutablePointer? + let rc = getaddrinfo(name, nil, &hints, &result) + if let result { freeaddrinfo(result) } + if rc == 0 { return } + try? await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000)) + } + print( + "Warning: '\(name)' still does not resolve after \(Int(timeout))s; " + + "a service that resolves its peers at startup may fail.") + } + // MARK: Compose Service Level Functions private func configService( _ service: Service, serviceName: String, from dockerCompose: DockerCompose, @@ -1332,6 +1372,12 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { switch startState { case .running: try await updateEnvironmentWithServiceIP(serviceName, containerName: containerName, launchState: launchState) + // Make the wave barrier a DNS barrier too: the next wave's services + // resolve this one's name while parsing their own config. + if dnsAvailable, let dnsDomain { + await waitUntilNameResolves( + Self.dnsRegistrationName(containerName: containerName, dnsDomain: dnsDomain)) + } if let healthcheck = service.healthcheck, !healthcheck.isDisabled { try await waitUntilServiceIsHealthy(serviceName: serviceName, containerName: containerName, healthcheck: healthcheck) await launchState.recordHealthy(serviceName) diff --git a/Tests/Container-Compose-StaticTests/DNSRegistrationNameTests.swift b/Tests/Container-Compose-StaticTests/DNSRegistrationNameTests.swift new file mode 100644 index 0000000..19fd5b8 --- /dev/null +++ b/Tests/Container-Compose-StaticTests/DNSRegistrationNameTests.swift @@ -0,0 +1,57 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Morris Richman and the Container-Compose project authors. All rights reserved. +// +// 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 Testing +import Foundation +@testable import ContainerComposeCore + +/// A container reaching `.running` does not mean its name resolves yet — Apple +/// Container publishes the DNS record a moment afterwards, where Docker's +/// embedded DNS updates as the container joins the network. `up` therefore waits +/// for the record before releasing the wave, and this is the name it waits on. +@Suite("DNS Registration Name") +struct DNSRegistrationNameTests { + + @Test("the domain is appended to a plain container name") + func plainNameGetsDomain() { + #expect( + ComposeUp.dnsRegistrationName(containerName: "patroni2", dnsDomain: "dcf") + == "patroni2.dcf") + } + + /// Without `container_name`, `up` already builds a dotted name itself; + /// appending again would wait on `patroni2.dcf.dcf`, which never resolves. + @Test("an already-qualified name is left alone") + func qualifiedNameUnchanged() { + #expect( + ComposeUp.dnsRegistrationName(containerName: "patroni2.dcf", dnsDomain: "dcf") + == "patroni2.dcf") + } + + @Test("a name ending in the domain's letters but not the domain is still qualified") + func similarSuffixStillQualified() { + #expect( + ComposeUp.dnsRegistrationName(containerName: "mydcf", dnsDomain: "dcf") + == "mydcf.dcf") + } + + @Test("a multi-label domain works the same way") + func multiLabelDomain() { + #expect( + ComposeUp.dnsRegistrationName(containerName: "db", dnsDomain: "test.local") + == "db.test.local") + } +} From e53165c0f33910c986865e36f6b7fd6105b0201f Mon Sep 17 00:00:00 2001 From: Mikimoto Date: Tue, 15 Sep 2026 06:46:31 +0000 Subject: [PATCH 4/5] fix(up): separate waiting for a one-shot to finish from waiting for it to start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two different questions shared one loop and one budget, and a one-shot that does its work quietly failed both of them. **The idle timeout judged a container that had already started.** It exists for a container that is stuck coming up: an image pull streams progress, so silence with nothing running means genuinely stuck rather than slow. Once the container has been seen running that reasoning no longer holds — a `chown -R` over a populated volume prints nothing for minutes, which is the normal case, not a hang. `waitVerdict` now applies the idle budget only before the container starts; `maxWait` still bounds the whole wait, because unbounded is the worse failure. The message for the post-start case no longer says the container timed out "waiting to be running", which it demonstrably was. **`containerWait`'s response timeout was a ten-second cap on the one-shot's runtime.** `containerWait` answers when the container exits, so that response timeout is not a request budget — it is how long the init is allowed to take. Any init doing real work failed with `XPC timeout for request to com.apple.container.apiserver/containerWait`, naming neither the service nor what was being waited for. It now matches the wait loop's ceiling, so one number bounds "how long may a one-shot take". Found the second one by fixing the first: with the idle timeout out of the way, the 45-second regression below still failed, at ten seconds, on the XPC call. Seven static tests on `waitVerdict` cover the split, including that silence before starting still trips the idle budget and that both thresholds are exclusive. The dynamic regression runs a one-shot that sleeps 45 seconds in silence with a dependent gated on `service_completed_successfully`, and asserts `up` both succeeds and actually waited — it takes ~52s, where before it failed at 30 and then at 10. --- .../Commands/ComposeUp.swift | 83 +++++++++++++++--- .../SilentOneShotTests.swift | 85 +++++++++++++++++++ .../WaitVerdictTests.swift | 81 ++++++++++++++++++ 3 files changed, 236 insertions(+), 13 deletions(-) create mode 100644 Tests/Container-Compose-DynamicTests/SilentOneShotTests.swift create mode 100644 Tests/Container-Compose-StaticTests/WaitVerdictTests.swift diff --git a/Sources/Container-Compose/Commands/ComposeUp.swift b/Sources/Container-Compose/Commands/ComposeUp.swift index ec8b57f..7a0d7e6 100644 --- a/Sources/Container-Compose/Commands/ComposeUp.swift +++ b/Sources/Container-Compose/Commands/ComposeUp.swift @@ -679,6 +679,37 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { /// - idleTimeout: Max seconds of no output (while not running) before failing. /// - maxWait: Absolute ceiling on the wait, regardless of ongoing output. /// - interval: How often to poll (in seconds). + /// What the wait loop should do next. + enum WaitVerdict: Equatable { + case keepWaiting + case idleTimeout + case totalTimeout + } + + /// Separates the two waits that share one loop. + /// + /// Before the container is running, silence is the signal that matters: an + /// image pull streams progress, so a gap with nothing coming out and nothing + /// running means genuinely stuck rather than merely slow. That is what + /// `idleTimeout` is for. + /// + /// Once it has been seen running, startup is over and the loop is waiting for + /// a one-shot's work to finish. Silence there is the normal case — a `chown -R` + /// over a large volume prints nothing for minutes — so the idle timeout no + /// longer applies. `maxWait` still bounds the whole thing, because an + /// unbounded wait is the worse failure. + static func waitVerdict( + hasStarted: Bool, + silentFor: TimeInterval, + elapsed: TimeInterval, + idleTimeout: TimeInterval, + maxWait: TimeInterval + ) -> WaitVerdict { + if !hasStarted, silentFor > idleTimeout { return .idleTimeout } + if elapsed > maxWait { return .totalTimeout } + return .keepWaiting + } + private func waitUntilServiceStarted( _ serviceName: String, containerName: String, @@ -691,6 +722,7 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { ) async throws -> ServiceStartState { let client = ContainerClient() let start = Date() + var hasStarted = false while true { try await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000)) @@ -702,8 +734,14 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { // certain — with six VMs booting at once a `chown -R` init is still // running at the first 0.5s poll, where launched one at a time it // had almost always already exited. - if container?.status == .running, !awaitCompletion { - return .running + if container?.status == .running { + if !awaitCompletion { + return .running + } + // Startup is over. What follows is a different wait — for the + // one-shot's work to finish — and it must not be judged by the + // startup budget. + hasStarted = true } if container?.status == .stopped { if let foregroundRun { @@ -734,23 +772,29 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { } } let now = Date() - // An active pull keeps refreshing `activity`, pushing the idle - // deadline out, so slow downloads never trip this — only genuine - // silence does. - if now.timeIntervalSince(activity.lastActivity) > idleTimeout { + switch Self.waitVerdict( + hasStarted: hasStarted, + silentFor: now.timeIntervalSince(activity.lastActivity), + elapsed: now.timeIntervalSince(start), + idleTimeout: idleTimeout, + maxWait: maxWait + ) { + case .keepWaiting: + break + case .idleTimeout: throw NSError( domain: "ContainerWait", code: 1, userInfo: [ NSLocalizedDescriptionKey: "Timed out waiting for container '\(containerName)' to be running." ]) - } - // Absolute backstop: even with continuous output, never wait past - // `maxWait` for the container to come up. - if now.timeIntervalSince(start) > maxWait { + case .totalTimeout: + let what = hasStarted + ? "to finish (it started, then ran for over \(Int(maxWait))s)" + : "to be running (exceeded \(Int(maxWait))s)" throw NSError( domain: "ContainerWait", code: 1, userInfo: [ - NSLocalizedDescriptionKey: "Timed out waiting for container '\(containerName)' to be running (exceeded \(Int(maxWait))s)." + NSLocalizedDescriptionKey: "Timed out waiting for container '\(containerName)' \(what)." ]) } } @@ -763,13 +807,26 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { ]) } - private static func waitForInitExitCode(containerName: String) async throws -> Int32 { + /// How long a one-shot is allowed to run before its completion wait gives up. + /// + /// `containerWait` answers when the container exits, so this response timeout + /// is the one-shot's whole runtime budget, not a request budget. At ten + /// seconds any init doing real work — a `chown -R` over a populated volume, + /// a schema load — failed with an XPC timeout that named neither the service + /// nor what it had been waiting for. It matches the wait loop's `maxWait` so + /// there is one number bounding "how long may a one-shot take". + static let oneShotCompletionTimeout: TimeInterval = 300 + + private static func waitForInitExitCode( + containerName: String, + timeout: TimeInterval = ComposeUp.oneShotCompletionTimeout + ) async throws -> Int32 { let request = XPCMessage(route: .containerWait) request.set(key: .id, value: containerName) request.set(key: .processIdentifier, value: containerName) let client = XPCClient(service: "com.apple.container.apiserver") - let response = try await client.send(request, responseTimeout: .seconds(10)) + let response = try await client.send(request, responseTimeout: .seconds(Int(timeout))) return Int32(response.int64(key: .exitCode)) } diff --git a/Tests/Container-Compose-DynamicTests/SilentOneShotTests.swift b/Tests/Container-Compose-DynamicTests/SilentOneShotTests.swift new file mode 100644 index 0000000..b51bd5d --- /dev/null +++ b/Tests/Container-Compose-DynamicTests/SilentOneShotTests.swift @@ -0,0 +1,85 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Morris Richman and the Container-Compose project authors. All rights reserved. +// +// 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 Testing +import Foundation +@testable import ContainerComposeCore + +/// A one-shot that does its work without printing anything. +/// +/// Waiting for a container to start and waiting for a one-shot to finish share a +/// loop, and the idle timeout only answers the first question. A `chown -R` over a +/// large volume prints nothing for minutes; judged by the startup budget it was +/// failed as "timed out waiting for it to be running" after it had already been +/// seen running. +@Suite("Silent One-Shot", .containerDependent, .serialized) +struct SilentOneShotTests { + + /// Comfortably past the idle timeout the wait uses while starting, and + /// comfortably inside the total budget. + private static let silentSeconds = 45 + + @Test("a one-shot that runs silently past the idle timeout still completes") + func silentOneShotCompletes() async throws { + let project = "silent-oneshot-\(UUID().uuidString.prefix(8).lowercased())" + let dir = URL.temporaryDirectory.appending(path: project) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + let yaml = """ + name: \(project) + services: + quiet: + image: alpine:latest + restart: "no" + command: ["sleep", "\(Self.silentSeconds)"] + after: + image: alpine:latest + command: ["sleep", "5"] + depends_on: + quiet: + condition: service_completed_successfully + """ + try yaml.write(to: dir.appending(path: "docker-compose.yaml"), atomically: false, encoding: .utf8) + + func tearDown() async { + var down = try? ComposeDown.parse(["--cwd", dir.path(percentEncoded: false)]) + try? await down?.run() + for name in ["\(project)-quiet", "\(project)-after"] { + _ = try? await ComposeUp.parse(["-d", "--cwd", dir.path(percentEncoded: false)]) + .streamCommand("container", args: ["delete", "-f", name], + onStdout: { _ in }, onStderr: { _ in }) + } + try? FileManager.default.removeItem(at: dir) + } + + var composeUp = try ComposeUp.parse(["-d", "--cwd", dir.path(percentEncoded: false)]) + let start = ContinuousClock.now + do { + // Before the fix this threw at ~30s of silence, with a message saying + // the container had timed out waiting to be running — by which point + // it had been running for most of that time. + try await composeUp.run() + } catch { + await tearDown() + throw error + } + let elapsed = ContinuousClock.now - start + await tearDown() + + // It had to outlast the idle timeout to prove anything. + #expect(elapsed > .seconds(Self.silentSeconds - 10), + "up returned in \(elapsed), too fast to have waited out the one-shot") + } +} diff --git a/Tests/Container-Compose-StaticTests/WaitVerdictTests.swift b/Tests/Container-Compose-StaticTests/WaitVerdictTests.swift new file mode 100644 index 0000000..e13db42 --- /dev/null +++ b/Tests/Container-Compose-StaticTests/WaitVerdictTests.swift @@ -0,0 +1,81 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Morris Richman and the Container-Compose project authors. All rights reserved. +// +// 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 Testing +import Foundation +@testable import ContainerComposeCore + +/// Waiting for a container to start and waiting for a one-shot to finish are two +/// different questions sharing one loop, and the idle timeout only answers the +/// first. A one-shot that works quietly — `chown -R` over a large volume prints +/// nothing — used to be failed by the startup budget after it had already been +/// seen running, and told the operator it had timed out "waiting for it to be +/// running". +@Suite("Wait Verdict") +struct WaitVerdictTests { + + private func verdict( + started: Bool, silentFor: TimeInterval, elapsed: TimeInterval, + idle: TimeInterval = 30, max: TimeInterval = 300 + ) -> ComposeUp.WaitVerdict { + ComposeUp.waitVerdict( + hasStarted: started, silentFor: silentFor, elapsed: elapsed, + idleTimeout: idle, maxWait: max) + } + + /// The regression: silent well past the idle timeout, but already running. + @Test("a silent one-shot that has started is not failed by the idle timeout") + func silentAfterStartingKeepsWaiting() { + #expect(verdict(started: true, silentFor: 120, elapsed: 130) == .keepWaiting) + } + + /// The behaviour the idle timeout exists for, unchanged. + @Test("silence before starting still trips the idle timeout") + func silentBeforeStartingTimesOut() { + #expect(verdict(started: false, silentFor: 31, elapsed: 31) == .idleTimeout) + } + + /// An active pull keeps refreshing activity, so a slow download is not silence. + @Test("a noisy pull that has not started yet keeps waiting") + func noisyBeforeStartingKeepsWaiting() { + #expect(verdict(started: false, silentFor: 2, elapsed: 200) == .keepWaiting) + } + + /// The backstop still applies once running: unbounded is the worse failure. + @Test("the total budget still bounds a one-shot that has started") + func totalBudgetStillApplies() { + #expect(verdict(started: true, silentFor: 400, elapsed: 301) == .totalTimeout) + } + + @Test("the total budget bounds a noisy container that never starts") + func totalBudgetBoundsNoisyStartup() { + #expect(verdict(started: false, silentFor: 1, elapsed: 301) == .totalTimeout) + } + + /// Exactly at each threshold is still waiting; the comparisons are strict. + @Test("the thresholds are exclusive") + func thresholdsAreExclusive() { + #expect(verdict(started: false, silentFor: 30, elapsed: 30) == .keepWaiting) + #expect(verdict(started: true, silentFor: 500, elapsed: 300) == .keepWaiting) + } + + /// Idle is reported ahead of total when both are past, because it is the more + /// specific diagnosis: nothing came out and nothing started. + @Test("idle wins over total before the container starts") + func idlePreferredBeforeStart() { + #expect(verdict(started: false, silentFor: 400, elapsed: 400) == .idleTimeout) + } +} From dc89bd80d7d131c83f4a390e880ecf4763de64bb Mon Sep 17 00:00:00 2001 From: Mikimoto Date: Tue, 15 Sep 2026 07:39:35 +0000 Subject: [PATCH 5/5] test(up): assert a dependent resolves its dependency from inside the container MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wave barrier resolves the name with `getaddrinfo` in the compose process, which goes through the host's resolver. A dependent resolves through its own `/etc/resolv.conf` against the daemon's DNS from inside the container network. Those are different paths, so the host lookup the barrier performs does not establish the property the barrier exists to provide. This starts a real dependency pair and asks the dependent: its `resolv.conf` carries the domain, and `getent hosts` resolves the dependency by both its short service name and its registered dotted name. The project is named after a domain discovered from `container system dns list` rather than a hardcoded one, because `up` derives its DNS domain from the project name — under any other name the DNS path is never taken and there is nothing to assert. With no domain registered the test says so and returns. It carries an anti-vacuity guard, because writing it without one produced a convincing false positive: an exec against a container that does not exist also returns nothing, and on the DNS path containers are named `.` rather than `-`. The first version used the wrong shape, execed into nothing, and read the empty output as a resolution failure. The guard now proves the target is reachable before any miss is interpreted; pointing it at the wrong name again fails with "the assertions below would be vacuous" instead of reporting a defect that is not there. --- .../DependentResolutionTests.swift | 145 ++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 Tests/Container-Compose-DynamicTests/DependentResolutionTests.swift diff --git a/Tests/Container-Compose-DynamicTests/DependentResolutionTests.swift b/Tests/Container-Compose-DynamicTests/DependentResolutionTests.swift new file mode 100644 index 0000000..0216f02 --- /dev/null +++ b/Tests/Container-Compose-DynamicTests/DependentResolutionTests.swift @@ -0,0 +1,145 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Morris Richman and the Container-Compose project authors. All rights reserved. +// +// 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 Testing +import Foundation +@testable import ContainerComposeCore + +/// What the wave barrier exists to guarantee, asserted where it matters. +/// +/// The barrier resolves the name with `getaddrinfo` in the compose process, which +/// goes through the host's resolver. A dependent resolves through its own +/// `/etc/resolv.conf` against the daemon's DNS from inside the container network — +/// a different path, so a host lookup succeeding does not establish that the +/// dependent can resolve the name. This starts a real dependency pair and asks the +/// dependent. +/// +/// The project is named after a domain that is actually registered, discovered +/// rather than hardcoded, because `up` derives its DNS domain from the project +/// name: with a name that is not a registered domain the DNS path is never taken +/// and there is nothing to assert. +@Suite("Dependent Resolution", .containerDependent, .serialized) +struct DependentResolutionTests { + + /// First domain from `container system dns list`, or nil when none is + /// registered — in which case the DNS path cannot engage at all. + private static func registeredDomain() -> String? { + let process = Process() + process.launchPath = "/usr/bin/env" + process.arguments = ["container", "system", "dns", "list"] + let stdout = Pipe() + process.standardOutput = stdout + process.standardError = Pipe() + guard (try? process.run()) != nil else { return nil } + process.waitUntilExit() + guard process.terminationStatus == 0 else { return nil } + let text = String( + data: stdout.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + return text.split(separator: "\n") + .map { $0.trimmingCharacters(in: .whitespaces) } + .first { !$0.isEmpty && $0 != "DOMAIN" } + } + + private final class Output: @unchecked Sendable { + private let lock = NSLock() + private var text = "" + func append(_ s: String) { lock.lock(); text += s; lock.unlock() } + var value: String { lock.lock(); defer { lock.unlock() }; return text } + } + + private func exec(_ container: String, _ args: [String]) async throws -> String { + let output = Output() + _ = try await ComposeUp.parse(["-d", "--cwd", NSTemporaryDirectory()]) + .streamCommand( + "container", args: ["exec", container] + args, + onStdout: { output.append($0) }, onStderr: { _ in }) + return output.value + } + + @Test("a dependent resolves its dependency's name from inside the container") + func dependentResolvesDependency() async throws { + guard let domain = Self.registeredDomain() else { + print(""" + Skipping: no DNS domain is registered, so `up` never takes the DNS path. + Register one with: sudo container system dns create + """) + return + } + + let dir = URL.temporaryDirectory.appending(path: "depres-\(UUID().uuidString.prefix(8))") + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + let yaml = """ + name: \(domain) + services: + upstream: + image: alpine:latest + command: ["sleep", "120"] + dependent: + image: alpine:latest + command: ["sleep", "120"] + depends_on: [upstream] + """ + try yaml.write(to: dir.appending(path: "docker-compose.yaml"), atomically: false, encoding: .utf8) + // On the DNS path containers are named `.`, not + // `-`. Getting this wrong makes every exec target a + // container that does not exist, and `getent` returning nothing then looks + // exactly like a resolution failure — which is how this test first "failed". + let upstream = "upstream.\(domain)" + let dependent = "dependent.\(domain)" + + func tearDown() async { + var down = try? ComposeDown.parse(["--cwd", dir.path(percentEncoded: false)]) + try? await down?.run() + for name in [upstream, dependent] { + _ = try? await ComposeUp.parse(["-d", "--cwd", dir.path(percentEncoded: false)]) + .streamCommand("container", args: ["delete", "-f", name], + onStdout: { _ in }, onStderr: { _ in }) + } + try? FileManager.default.removeItem(at: dir) + } + + var composeUp = try ComposeUp.parse(["-d", "--cwd", dir.path(percentEncoded: false)]) + do { + try await composeUp.run() + + // Anti-vacuity: an exec against a container that does not exist also + // returns nothing, so prove the target is there before reading a miss + // as a resolution failure. + let selfCheck = try await exec(dependent, ["echo", "alive"]) + #expect(selfCheck.contains("alive"), + "could not exec into '\(dependent)' — the assertions below would be vacuous") + + // Resolution goes through the container's own resolver, which is a + // different path from the `getaddrinfo` the barrier runs on the host. + let resolv = try await exec(dependent, ["cat", "/etc/resolv.conf"]) + #expect(resolv.contains("domain \(domain)"), + "dependent's resolv.conf does not carry the domain: \(resolv)") + + // The claim the barrier exists to make: by the time the dependent is + // up, it can resolve the service it depends on. + let short = try await exec(dependent, ["getent", "hosts", "upstream"]) + let dotted = try await exec(dependent, ["getent", "hosts", upstream]) + #expect(short.contains("upstream"), + "dependent could not resolve short name 'upstream' (got: \(short))") + #expect(dotted.contains("upstream"), + "dependent could not resolve '\(upstream)' (got: \(dotted))") + } catch { + await tearDown() + throw error + } + await tearDown() + } +}