diff --git a/Sources/Container-Compose/Commands/ComposeUp.swift b/Sources/Container-Compose/Commands/ComposeUp.swift index 3f59f6f..719ff21 100644 --- a/Sources/Container-Compose/Commands/ComposeUp.swift +++ b/Sources/Container-Compose/Commands/ComposeUp.swift @@ -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(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(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) 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 + /// `.` DNS convention, not just `-`. + 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 { + 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() + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000)) + var running = Set() + 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`. diff --git a/Tests/Container-Compose-StaticTests/WaitForeverCpuTests.swift b/Tests/Container-Compose-StaticTests/ForegroundWaitCpuTests.swift similarity index 70% rename from Tests/Container-Compose-StaticTests/WaitForeverCpuTests.swift rename to Tests/Container-Compose-StaticTests/ForegroundWaitCpuTests.swift index 3371c9c..d2e863a 100644 --- a/Tests/Container-Compose-StaticTests/WaitForeverCpuTests.swift +++ b/Tests/Container-Compose-StaticTests/ForegroundWaitCpuTests.swift @@ -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 { @@ -29,7 +29,7 @@ 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 @@ -37,30 +37,30 @@ struct WaitForeverCpuTests { /// 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(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(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 @@ -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 @@ -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)") } }