Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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 @@ -254,25 +254,117 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable {
}

if !detach {
await waitForever()
await runForegroundUntilStopped(serviceNames: services.map({ $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.
func runForegroundUntilStopped(serviceNames: [String]) async -> Never {
let containerNames = projectName.map { project in
serviceNames.map { "\(project)-\($0)" }
} ?? []

// 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
75 changes: 75 additions & 0 deletions Tests/Container-Compose-StaticTests/ForegroundWaitCpuTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
//===----------------------------------------------------------------------===//
// 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
import Darwin
@testable import ContainerComposeCore

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

/// Returns user-mode CPU time consumed by this process, in microseconds.
private func userCpuMicroseconds() -> Int64 {
var usage = rusage()
getrusage(RUSAGE_SELF, &usage)
return Int64(usage.ru_utime.tv_sec) * 1_000_000 + Int64(usage.ru_utime.tv_usec)
}

/// Regression for #27: the foreground `up` wait must suspend, not busy-loop.
///
/// Method: take a `getrusage(RUSAGE_SELF)` snapshot, spawn a child Task that
/// calls `runForegroundUntilStopped` (with no services, so it just awaits the
/// signal stream and starts no container monitor), sleep for a 1s wall-clock
/// window, take a second snapshot, and compare user-CPU consumed.
///
/// On the original bug (`for await _ in AsyncStream<Void>(unfolding: {})`),
/// the child task pinned one core, so over a 1s window it consumes ~1,000,000
/// µs (one full core). The wait now suspends on a `DispatchSource` signal
/// stream and consumes essentially nothing.
///
/// `getrusage(RUSAGE_SELF)` is process-wide, so the other (fast, parallel)
/// static suites add CPU noise — but they finish within the first few hundred
/// ms, whereas a busy-loop runs the entire second. The 1s window plus a
/// 400,000 µs threshold (≈0.4 core-seconds) sits well above that transient
/// noise yet well below a full core's worth of spinning, so the test is
/// reliable in the full parallel suite, not just in isolation.
///
/// Side effect: this test leaks one suspended task per invocation
/// (`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()
let before = userCpuMicroseconds()

// Detached so cancellation propagation from the test doesn't reach it
// (it wouldn't matter — the function ignores cancellation by contract —
// but this makes the leak explicit rather than incidental).
Task.detached {
await composeUp.runForegroundUntilStopped(serviceNames: [])
}

try await Task.sleep(nanoseconds: 1_000_000_000) // 1s

let after = userCpuMicroseconds()
let consumed = after - before

#expect(consumed < 400_000,
"foreground wait consumed \(consumed) µs of user CPU in 1s — likely busy-looping (regression for #27)")
}
}
72 changes: 0 additions & 72 deletions Tests/Container-Compose-StaticTests/WaitForeverCpuTests.swift

This file was deleted.

Loading