Skip to content
Merged
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
124 changes: 108 additions & 16 deletions Sources/Container-Compose/Commands/ComposeUp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -258,25 +258,117 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable {
}

if !detach {
await waitForever()
await runForegroundUntilStopped(containerNames: services.map({ containerName(for: $0.serviceName) }))
}
}

func waitForever() async -> Never {
// `AsyncStream<Void>(unfolding: () async -> Void?)` ends only when the
// closure returns `nil`. An empty closure returns `()`, which Swift
// auto-wraps as `.some(())` — never `nil` — so the previous
// `for await _ in AsyncStream<Void>(unfolding: {})` produced an
// infinite stream of `Void` values with no `await` between them and
// pinned a CPU core at 100% (issue #27).
//
// Suspending on a continuation that is never resumed parks the task
// indefinitely with zero CPU. `withUnsafeContinuation` (rather than
// `withCheckedContinuation`) avoids the runtime's "continuation leaked"
// diagnostic — leaking is the intent here, since the contract is to
// wait until the process is killed.
await withUnsafeContinuation { (_: UnsafeContinuation<Void, Never>) in }
fatalError("unreachable")
/// Foreground (`up` without `--detach`) behavior, matching `docker compose up`:
/// - Ctrl-C (SIGINT) / `kill` (SIGTERM) gracefully stops the project's
/// containers, then exits. A second signal forces an immediate exit.
/// - If the containers stop on their own — or via `container compose down`
/// from another shell — `up` returns instead of hanging forever.
///
/// Takes resolved container names (not service names): a service's container
/// may be named via explicit `container_name` or the dotted
/// `<service>.<dnsDomain>` DNS convention, not just `<project>-<service>`.
func runForegroundUntilStopped(containerNames: [String]) async -> Never {
// Exit once the containers stop by themselves or are stopped externally.
if !containerNames.isEmpty {
Task {
await Self.waitUntilAllContainersStopped(containerNames)
print("\nAll containers have stopped.")
Foundation.exit(0)
}
}

// Bridge SIGINT/SIGTERM into an async stream. The `ContainerCommands`
// invoked during `up` leave these signals neutered (SIG_IGN) via
// ContainerAPIService's async signal machinery, so a foreground `up`
// previously ignored Ctrl-C. A `DispatchSource` signal source observes
// them regardless of disposition.
let signals = Self.makeSignalStream([SIGINT, SIGTERM])
var stopping = false
for await _ in signals {
if !stopping {
stopping = true
print("\nGracefully stopping... (press Ctrl+C again to force)")
Task {
await Self.stopContainers(containerNames)
Foundation.exit(0)
}
} else {
print("\nForcing stop.")
Task {
await Self.killContainers(containerNames)
Foundation.exit(130)
}
}
}
Foundation.exit(0)
}

/// An `AsyncStream` of the given signals, delivered via `DispatchSource` so
/// they're received even after the disposition has been set to `SIG_IGN`.
private static func makeSignalStream(_ signals: [Int32]) -> AsyncStream<Int32> {
AsyncStream { continuation in
let queue = DispatchQueue(label: "container-compose.signals")
let sources: [DispatchSourceSignal] = signals.map { sig in
// Ignore the default action so the DispatchSource alone handles it.
signal(sig, SIG_IGN)
let source = DispatchSource.makeSignalSource(signal: sig, queue: queue)
source.setEventHandler { continuation.yield(sig) }
source.resume()
return source
}
continuation.onTermination = { _ in sources.forEach { $0.cancel() } }
}
}

/// Gracefully stops (without removing) the named containers — the
/// `docker compose up` Ctrl-C contract leaves stopped containers in place.
private static func stopContainers(_ containerNames: [String]) async {
let client = ContainerClient()
for name in containerNames {
guard let container = try? await client.get(id: name) else { continue }
print("Stopping container: \(name)")
do {
try await client.stop(id: container.id)
} catch {
print("Error stopping container \(name): \(error)")
}
}
}

/// Force-stops the named containers with SIGKILL — the second-Ctrl-C
/// contract, matching `docker compose up`'s "press Ctrl+C again to force".
private static func killContainers(_ containerNames: [String]) async {
let client = ContainerClient()
for name in containerNames {
guard let container = try? await client.get(id: name) else { continue }
print("Killing container: \(name)")
try? await client.kill(id: container.id, signal: "SIGKILL")
}
}

/// Polls until every named container has been observed running at least once
/// and then none remain running (stopped naturally or via `down`). Requiring
/// "seen running first" avoids returning before the containers have started.
private static func waitUntilAllContainersStopped(_ containerNames: [String], interval: TimeInterval = 1.0) async {
let client = ContainerClient()
var seenRunning = Set<String>()
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000))
var running = Set<String>()
for name in containerNames {
if let container = try? await client.get(id: name), container.status == .running {
running.insert(name)
}
}
seenRunning.formUnion(running)
if seenRunning.count == containerNames.count && running.isEmpty {
return
}
}
}

/// Translates Compose's `entrypoint` + `command` into args for `container run`.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ import Foundation
import Darwin
@testable import ContainerComposeCore

@Suite("waitForever CPU usage", .serialized)
struct WaitForeverCpuTests {
@Suite("Foreground wait CPU usage", .serialized)
struct ForegroundWaitCpuTests {

/// Returns user-mode CPU time consumed by this process, in microseconds.
private func userCpuMicroseconds() -> Int64 {
Expand All @@ -29,38 +29,38 @@ struct WaitForeverCpuTests {
return Int64(usage.ru_utime.tv_sec) * 1_000_000 + Int64(usage.ru_utime.tv_usec)
}

/// Regression for #27: `waitForever()` must suspend, not busy-loop.
/// Regression for #27: the foreground `up` wait must suspend, not busy-loop.
///
/// Method: `getrusage(RUSAGE_SELF)` is process-wide, not per-task — it counts
/// every thread in the process, including whatever other tests Swift Testing
/// is running concurrently in this same process (this suite is `.serialized`
/// internally, but that doesn't stop *other* suites from overlapping it). So
/// rather than an absolute threshold, take a baseline measurement over an
/// idle 200ms window immediately before the real one, then assert on the
/// *incremental* cost the `waitForever` task adds on top of that baseline.
/// *incremental* cost the foreground-wait task adds on top of that baseline.
/// This cancels out ambient noise from concurrent tests instead of assuming
/// it's near-zero, which stopped holding once the suite grew large enough
/// that something is almost always running during any given 200ms window.
///
/// On the bug (`for await _ in AsyncStream<Void>(unfolding: {})`), the
/// child task pins one core, so it adds roughly 200,000 µs of user CPU on
/// top of baseline during the 200ms window. With the fix
/// (`withUnsafeContinuation { _ in }`), the child task suspends and adds
/// essentially nothing.
/// On the original bug (`for await _ in AsyncStream<Void>(unfolding: {})`),
/// the child task pins one core, so it adds roughly 200,000 µs of user CPU
/// on top of baseline during the 200ms window. The wait now suspends on a
/// `DispatchSource` signal stream (with no containers, so it starts no
/// container monitor) and adds essentially nothing.
///
/// Threshold of 50,000 µs of *incremental* cost gives ~4× headroom while
/// still reliably catching a single core's worth of busy-loop work.
///
/// Side effect: this test leaks one suspended task per invocation
/// (`waitForever` is `-> Never` and the suspended task can't be cancelled
/// from outside). The leak is bounded — each leaked task holds only its
/// stack — and is cleaned up when the test process exits.
@Test("waitForever does not pin a CPU core (regression for #27)")
func waitForeverDoesNotPinCpu() async throws {
/// (`runForegroundUntilStopped` is `-> Never` and the suspended task can't be
/// cancelled from outside). The leak is bounded — each leaked task holds only
/// its stack — and is cleaned up when the test process exits.
@Test("foreground wait does not pin a CPU core (regression for #27)")
func foregroundWaitDoesNotPinCpu() async throws {
let composeUp = ComposeUp()

// Baseline: ambient CPU consumed by the whole process over an idle
// 200ms window (no waitForever task running), to calibrate against
// 200ms window (no foreground-wait task running), to calibrate against
// whatever else is concurrently running in this test process.
let baselineBefore = userCpuMicroseconds()
try await Task.sleep(nanoseconds: 200_000_000) // 200ms
Expand All @@ -72,7 +72,7 @@ struct WaitForeverCpuTests {
// (it wouldn't matter — the function ignores cancellation by contract —
// but this makes the leak explicit rather than incidental).
Task.detached {
await composeUp.waitForever()
await composeUp.runForegroundUntilStopped(containerNames: [])
}

try await Task.sleep(nanoseconds: 200_000_000) // 200ms
Expand All @@ -82,6 +82,6 @@ struct WaitForeverCpuTests {
let incremental = consumed - baselineConsumed

#expect(incremental < 50_000,
"waitForever added \(incremental) µs of user CPU over a \(baselineConsumed) µs baseline in 200ms — likely busy-looping (regression for #27)")
"foreground wait added \(incremental) µs of user CPU over a \(baselineConsumed) µs baseline in 200ms — likely busy-looping (regression for #27)")
}
}
Loading