diff --git a/Sources/Container-Compose/Codable Structs/Service.swift b/Sources/Container-Compose/Codable Structs/Service.swift index 2ed08c0..e678419 100644 --- a/Sources/Container-Compose/Codable Structs/Service.swift +++ b/Sources/Container-Compose/Codable Structs/Service.swift @@ -119,6 +119,13 @@ public struct Service: Codable, Hashable { /// machine's IP as seen from inside the container. public let extra_hosts: [String]? + /// Profile names that gate this service (per the Compose spec `profiles` key). + /// A service with no profiles (nil/empty) is always eligible. A service with + /// profiles is only eligible when at least one of them is active — unless the + /// service is named explicitly on the command line, or is a dependency of an + /// eligible service, both of which bypass the profile gate per the Compose spec. + public let profiles: [String]? + /// Other services that depend on this service public var dependedBy: [String] = [] @@ -126,7 +133,7 @@ public struct Service: Codable, Hashable { enum CodingKeys: String, CodingKey { case image, build, deploy, restart, healthcheck, volumes, environment, env_file, ports, command, depends_on, user, container_name, labels, networks, hostname, entrypoint, privileged, read_only, working_dir, configs, secrets, stdin_open, tty, platform, - mem_limit, extra_hosts + mem_limit, extra_hosts, profiles } /// Public memberwise initializer for testing @@ -160,6 +167,7 @@ public struct Service: Codable, Hashable { tty: Bool? = nil, mem_limit: String? = nil, extra_hosts: [String]? = nil, + profiles: [String]? = nil, dependedBy: [String] = [] ) { self.image = image @@ -191,6 +199,7 @@ public struct Service: Codable, Hashable { self.tty = tty self.mem_limit = mem_limit self.extra_hosts = extra_hosts + self.profiles = profiles self.dependedBy = dependedBy } @@ -332,6 +341,21 @@ public struct Service: Codable, Hashable { } else { extra_hosts = nil } + + // `profiles` is a plain list of strings per the Compose spec (no shorthand + // single-string form). + profiles = try container.decodeIfPresent([String].self, forKey: .profiles) + } + + /// True when this service should be included by default given the currently + /// active profiles. Per the Compose spec: a service with no `profiles` is + /// always eligible; a service with `profiles` is eligible only when at least + /// one of them is active. This gate is bypassed entirely for services named + /// explicitly on the command line and for dependencies of an eligible service + /// (see `selectServices(from:requestedServices:activeProfiles:)`). + public func isProfileEligible(activeProfiles: Set) -> Bool { + guard let profiles, !profiles.isEmpty else { return true } + return !Set(profiles).isDisjoint(with: activeProfiles) } /// Translates the list-form of `environment:` into the same `[String: String]` @@ -397,4 +421,50 @@ public struct Service: Codable, Hashable { return sorted } + + /// Selects the services `up`, `build`, and `down` should act on by default, + /// applying both explicit service-name filtering and Compose `profiles` gating. + /// + /// Per the Compose spec, `profiles` gating is bypassed in two cases: + /// - a service named explicitly in `requestedServices` + /// - a service reached only as a `depends_on` dependency of an eligible + /// service (its own `profiles` are ignored) + /// When `requestedServices` is empty, the seed set is every service that + /// is profile-eligible for `activeProfiles` (unprofiled, or one of its + /// profiles is active); dependencies of that seed are then pulled in + /// regardless of their own profile. + static func selectServices( + from services: [(serviceName: String, service: Service)], + requestedServices: [String], + activeProfiles: Set = [] + ) -> [(serviceName: String, service: Service)] { + let servicesByName = Dictionary(uniqueKeysWithValues: services.map { ($0.serviceName, $0.service) }) + + let seedNames: [String] + if !requestedServices.isEmpty { + seedNames = requestedServices + } else { + seedNames = services + .filter { $0.service.isProfileEligible(activeProfiles: activeProfiles) } + .map(\.serviceName) + } + + var selected = Set() + + func include(_ serviceName: String) { + guard let service = servicesByName[serviceName], selected.insert(serviceName).inserted else { + return + } + + for dependency in service.depends_on ?? [] { + include(dependency) + } + } + + for serviceName in seedNames { + include(serviceName) + } + + return services.filter { selected.contains($0.serviceName) } + } } diff --git a/Sources/Container-Compose/Commands/ComposeBuild.swift b/Sources/Container-Compose/Commands/ComposeBuild.swift index 5abde19..2d3e5de 100644 --- a/Sources/Container-Compose/Commands/ComposeBuild.swift +++ b/Sources/Container-Compose/Commands/ComposeBuild.swift @@ -101,13 +101,23 @@ public struct ComposeBuild: AsyncParsableCommand, @unchecked Sendable { projectName = deriveProjectName(cwd: cwd) } - var servicesToBuild: [(serviceName: String, service: Service)] = dockerCompose.services.compactMap { name, service in - guard let service, service.build != nil else { return nil } + // Route both the explicit-service-name and default cases through the same + // selection `up`/`down` use: an explicit name (or the default profile-eligible + // set) pulls in its `depends_on` graph regardless of that dependency's own + // build/profile status. Without this, a dependency only reachable via + // `depends_on` — whether profile-gated or just not named explicitly — would + // be started by `up` but never get built here. + let allServices: [(serviceName: String, service: Service)] = dockerCompose.services.compactMap { name, service in + guard let service else { return nil } return (name, service) } - - if !services.isEmpty { - servicesToBuild = servicesToBuild.filter { services.contains($0.serviceName) } + let selectedNames = Set( + Service.selectServices(from: allServices, requestedServices: services, activeProfiles: composeFileOptions.activeProfiles) + .map(\.serviceName) + ) + let servicesToBuild: [(serviceName: String, service: Service)] = dockerCompose.services.compactMap { name, service in + guard let service, service.build != nil, selectedNames.contains(name) else { return nil } + return (name, service) } if servicesToBuild.isEmpty { diff --git a/Sources/Container-Compose/Commands/ComposeDown.swift b/Sources/Container-Compose/Commands/ComposeDown.swift index b41a4e9..97647f0 100644 --- a/Sources/Container-Compose/Commands/ComposeDown.swift +++ b/Sources/Container-Compose/Commands/ComposeDown.swift @@ -107,11 +107,26 @@ public struct ComposeDown: AsyncParsableCommand { }) services = try Service.topoSortConfiguredServices(services) - // Filter for specified services + // Filter for specified services and active Compose profiles if !self.services.isEmpty { services = services.filter({ serviceName, service in self.services.contains(where: { $0 == serviceName }) || self.services.contains(where: { service.dependedBy.contains($0) }) }) + } else { + // Match `up`'s default selection: profile-eligible services plus their + // dependencies (which bypass the profile gate). Anything excluded here + // may still be running from a previous `up --profile ...` that isn't + // repeated on this `down` — warn instead of silently skipping it. + let selected = Service.selectServices(from: services, requestedServices: [], activeProfiles: composeFileOptions.activeProfiles) + let selectedNames = Set(selected.map(\.serviceName)) + let skipped = services.map(\.serviceName).filter { !selectedNames.contains($0) } + if !skipped.isEmpty { + print( + "Note: not stopping '\(skipped.sorted().joined(separator: "', '"))' — gated by an inactive Compose profile. " + + "Pass --profile (or name the service explicitly) to also stop them." + ) + } + services = selected } try await stopOldStuff(services, remove: false) diff --git a/Sources/Container-Compose/Commands/ComposeFileOptions.swift b/Sources/Container-Compose/Commands/ComposeFileOptions.swift index e27782a..51089a9 100644 --- a/Sources/Container-Compose/Commands/ComposeFileOptions.swift +++ b/Sources/Container-Compose/Commands/ComposeFileOptions.swift @@ -15,10 +15,30 @@ //===----------------------------------------------------------------------===// import ArgumentParser +import Foundation public struct ComposeFileOptions: ParsableArguments, Sendable { public init() {} - + @Option(name: [.customShort("f"), .customLong("file")], help: "The path to your Docker Compose file") public var composeFilename: String? + + @Option( + name: .long, + help: "Specify a profile to enable. Can be repeated. Services without a 'profiles' key are always enabled; profiled services are enabled only when one of their profiles is active." + ) + public var profile: [String] = [] + + /// Active profiles from repeated `--profile` flags merged with the + /// comma-separated `COMPOSE_PROFILES` environment variable (the Compose + /// spec's documented equivalent, e.g. `COMPOSE_PROFILES=debug,frontend`). + public var activeProfiles: Set { + var result = Set(profile) + if let envProfiles = ProcessInfo.processInfo.environment["COMPOSE_PROFILES"] { + result.formUnion( + envProfiles.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty } + ) + } + return result + } } diff --git a/Sources/Container-Compose/Commands/ComposeUp.swift b/Sources/Container-Compose/Commands/ComposeUp.swift index a17c719..3f59f6f 100644 --- a/Sources/Container-Compose/Commands/ComposeUp.swift +++ b/Sources/Container-Compose/Commands/ComposeUp.swift @@ -209,8 +209,12 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { }) services = try Service.topoSortConfiguredServices(services) - // Filter for specified services - services = Self.servicesSelectedForUp(services, requestedServices: self.services) + // Filter for specified services and active Compose profiles + services = Service.selectServices( + from: services, + requestedServices: self.services, + activeProfiles: composeFileOptions.activeProfiles + ) // Stop Services. Pass every name a previous run might have used (legacy // dashed, dotted DNS-mode, and explicit container_name) so the cleanup @@ -359,34 +363,6 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { ])) != nil } - static func servicesSelectedForUp( - _ services: [(serviceName: String, service: Service)], - requestedServices: [String] - ) -> [(serviceName: String, service: Service)] { - guard !requestedServices.isEmpty else { - return services - } - - let servicesByName = Dictionary(uniqueKeysWithValues: services.map { ($0.serviceName, $0.service) }) - var selected = Set() - - func include(_ serviceName: String) { - guard let service = servicesByName[serviceName], selected.insert(serviceName).inserted else { - return - } - - for dependency in service.depends_on ?? [] { - include(dependency) - } - } - - for serviceName in requestedServices { - include(serviceName) - } - - return services.filter { selected.contains($0.serviceName) } - } - static func validateStoppedServiceExitCode(_ exitCode: Int32, serviceName: String) throws { guard exitCode == 0 else { throw ComposeError.containerRunFailed(serviceName, exitCode) diff --git a/Tests/Container-Compose-DynamicTests/ComposeBuildTests.swift b/Tests/Container-Compose-DynamicTests/ComposeBuildTests.swift index 0fd3a4e..cb906d5 100644 --- a/Tests/Container-Compose-DynamicTests/ComposeBuildTests.swift +++ b/Tests/Container-Compose-DynamicTests/ComposeBuildTests.swift @@ -107,6 +107,39 @@ struct ComposeBuildTests { #expect(try await !imageExists(named: "excluded:latest")) } + @Test("Build with explicit service filter also builds its depends_on dependency") + func buildWithServiceFilterAlsoBuildsDependency() async throws { + let yaml = """ + services: + app: + depends_on: + - worker + build: + context: . + dockerfile: Dockerfile + worker: + build: + context: . + dockerfile: Dockerfile + unrelated: + build: + context: . + dockerfile: Dockerfile + """ + + let project = try writeBuildProject(yaml: yaml) + + var composeBuild = try ComposeBuild.parse([ + "--cwd", project.base.path(percentEncoded: false), + "app", + ]) + try await composeBuild.run() + + #expect(try await imageExists(named: "app:latest")) + #expect(try await imageExists(named: "worker:latest")) + #expect(try await !imageExists(named: "unrelated:latest")) + } + @Test("Build passes build args to Dockerfile") func buildPassesBuildArgsToDockerfile() async throws { let dockerfile = """ diff --git a/Tests/Container-Compose-DynamicTests/ComposeUpTests.swift b/Tests/Container-Compose-DynamicTests/ComposeUpTests.swift index 6de494b..cbef767 100644 --- a/Tests/Container-Compose-DynamicTests/ComposeUpTests.swift +++ b/Tests/Container-Compose-DynamicTests/ComposeUpTests.swift @@ -70,7 +70,7 @@ struct ComposeUpTests { #expect(wpEnv["WORDPRESS_DB_NAME"] == "wordpress") // Check Volume - #expect(wordpressContainer.configuration.mounts.map(\.destination) == ["/var/www/"]) + #expect(wordpressContainer.configuration.mounts.map(\.destination) == ["/var/www/html"]) // Assert correct db container information @@ -85,7 +85,7 @@ struct ComposeUpTests { #expect(dbEnv["MYSQL_PASSWORD"] == "wordpress") // Check Volume - #expect(dbContainer.configuration.mounts.map(\.destination) == ["/var/lib/"]) + #expect(dbContainer.configuration.mounts.map(\.destination) == ["/var/lib/mysql"]) print("") try? await stopInstance(location: tempLocation.deletingLastPathComponent()) @@ -426,9 +426,19 @@ struct ComposeUpTests { struct ContainerDependentTrait: TestScoping, TestTrait, SuiteTrait { func provideScope(for test: Test, testCase: Test.Case?, performing function: () async throws -> Void) async throws { - // Start Server - try await Application.SystemStart.parse(["--enable-kernel-install"]).run(); #warning("This is crashing") - + // `Application.SystemStart.run()` resolves the `container-apiserver` binary + // relative to `CommandLine.executablePath` — correct when invoked as the real + // `container` CLI, but when called in-process from this test binary that + // path resolves to the test runner itself, which has no `container-apiserver` + // sibling ("No such file or directory"). Shell out to the real installed + // `container` binary instead, the same way the rest of this codebase talks + // to `container`/`container-compose`. + let process = Process() + process.launchPath = "/usr/bin/env" + process.arguments = ["container", "system", "start", "--enable-kernel-install"] + try process.run() + process.waitUntilExit() + // Run Test try await function() } diff --git a/Tests/Container-Compose-StaticTests/ProfilesTests.swift b/Tests/Container-Compose-StaticTests/ProfilesTests.swift new file mode 100644 index 0000000..2f9ce3d --- /dev/null +++ b/Tests/Container-Compose-StaticTests/ProfilesTests.swift @@ -0,0 +1,200 @@ +//===----------------------------------------------------------------------===// +// 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 + +// .serialized: two tests below mutate the global COMPOSE_PROFILES process +// environment variable via setenv/unsetenv. Swift Testing parallelizes tests +// within a suite by default, so without this trait those tests can race each +// other (or any future test reading COMPOSE_PROFILES), causing intermittent +// failures. +@Suite("Compose profiles support", .serialized) +struct ProfilesTests { + + private func decodeService(_ serviceYaml: String) throws -> Service { + let yaml = """ + services: + svc: + \(serviceYaml.split(separator: "\n", omittingEmptySubsequences: false) + .map { " \($0)" }.joined(separator: "\n")) + """ + let compose = try YAMLDecoder().decode(DockerCompose.self, from: yaml) + guard let service = compose.services["svc"].flatMap({ $0 }) else { + Issue.record("service 'svc' missing or nil") + throw TestError.missingService + } + return service + } + + enum TestError: Error { case missingService } + + // MARK: - Decoding + + @Test("profiles list form parses") + func profilesListParses() throws { + let svc = try decodeService(""" + image: alpine + profiles: + - debug + - frontend + """) + #expect(svc.profiles == ["debug", "frontend"]) + } + + @Test("profiles absent when not set") + func profilesAbsentWhenNotSet() throws { + let svc = try decodeService(""" + image: alpine + """) + #expect(svc.profiles == nil) + } + + // MARK: - isProfileEligible + + @Test("service with no profiles is always eligible") + func noProfilesAlwaysEligible() { + let svc = Service(image: "alpine", profiles: nil) + #expect(svc.isProfileEligible(activeProfiles: [])) + #expect(svc.isProfileEligible(activeProfiles: ["debug"])) + } + + @Test("service with empty profiles list is always eligible") + func emptyProfilesAlwaysEligible() { + let svc = Service(image: "alpine", profiles: []) + #expect(svc.isProfileEligible(activeProfiles: [])) + } + + @Test("profiled service is ineligible when its profile is not active") + func profiledServiceIneligibleWhenInactive() { + let svc = Service(image: "alpine", profiles: ["debug"]) + #expect(!svc.isProfileEligible(activeProfiles: [])) + #expect(!svc.isProfileEligible(activeProfiles: ["frontend"])) + } + + @Test("profiled service is eligible when one of its profiles is active") + func profiledServiceEligibleWhenActive() { + let svc = Service(image: "alpine", profiles: ["debug", "frontend"]) + #expect(svc.isProfileEligible(activeProfiles: ["frontend"])) + } + + // MARK: - Service.selectServices — default (no requested services) + + @Test("default selection excludes profiled services when no profile is active") + func defaultSelectionExcludesProfiledServices() throws { + let web = Service(image: "nginx", profiles: nil) + let debugTools = Service(image: "busybox", profiles: ["debug"]) + let services: [(serviceName: String, service: Service)] = [ + ("web", web), + ("debug-tools", debugTools), + ] + + let selected = Service.selectServices(from: services, requestedServices: []) + + #expect(selected.map(\.serviceName) == ["web"]) + } + + @Test("default selection includes profiled services when their profile is active") + func defaultSelectionIncludesActiveProfiledServices() throws { + let web = Service(image: "nginx", profiles: nil) + let debugTools = Service(image: "busybox", profiles: ["debug"]) + let services: [(serviceName: String, service: Service)] = [ + ("web", web), + ("debug-tools", debugTools), + ] + + let selected = Service.selectServices(from: services, requestedServices: [], activeProfiles: ["debug"]) + + #expect(Set(selected.map(\.serviceName)) == ["web", "debug-tools"]) + } + + // MARK: - Service.selectServices — explicit request bypasses the gate + + @Test("explicitly requested service starts even if its profile is inactive") + func explicitRequestBypassesProfileGate() throws { + let debugTools = Service(image: "busybox", profiles: ["debug"]) + let services: [(serviceName: String, service: Service)] = [ + ("debug-tools", debugTools), + ] + + let selected = Service.selectServices(from: services, requestedServices: ["debug-tools"], activeProfiles: []) + + #expect(selected.map(\.serviceName) == ["debug-tools"]) + } + + // MARK: - Service.selectServices — dependency bypasses the gate + + @Test("dependency of an eligible service starts even if its own profile is inactive") + func dependencyBypassesProfileGate() throws { + let db = Service(image: "postgres", profiles: ["debug"]) + let api = Service(image: "myapi", depends_on: ["db"], profiles: nil) + let services: [(serviceName: String, service: Service)] = [ + ("db", db), + ("api", api), + ] + + let selected = Service.selectServices(from: services, requestedServices: [], activeProfiles: []) + + #expect(Set(selected.map(\.serviceName)) == ["db", "api"]) + } + + // MARK: - CLI parsing + + @Test("ComposeUp command accepts repeated --profile flags") + func composeUpCommandAcceptsRepeatedProfileFlags() throws { + let cmd = try ComposeUp.parse(["--profile", "debug", "--profile", "frontend"]) + #expect(cmd.composeFileOptions.profile == ["debug", "frontend"]) + } + + @Test("ComposeUp command defaults profile to empty") + func composeUpCommandDefaultsProfileToEmpty() throws { + let cmd = try ComposeUp.parse([]) + #expect(cmd.composeFileOptions.profile.isEmpty) + } + + @Test("ComposeBuild command accepts --profile flag") + func composeBuildCommandAcceptsProfileFlag() throws { + let cmd = try ComposeBuild.parse(["--profile", "debug"]) + #expect(cmd.composeFileOptions.profile == ["debug"]) + } + + @Test("ComposeDown command accepts --profile flag") + func composeDownCommandAcceptsProfileFlag() throws { + let cmd = try ComposeDown.parse(["--profile", "debug"]) + #expect(cmd.composeFileOptions.profile == ["debug"]) + } + + // MARK: - COMPOSE_PROFILES environment variable + + @Test("activeProfiles merges --profile flags with COMPOSE_PROFILES env var") + func activeProfilesMergesEnvVar() throws { + setenv("COMPOSE_PROFILES", "backend, debug", 1) + defer { unsetenv("COMPOSE_PROFILES") } + + let cmd = try ComposeUp.parse(["--profile", "frontend"]) + #expect(cmd.composeFileOptions.activeProfiles == ["frontend", "backend", "debug"]) + } + + @Test("activeProfiles is empty when neither --profile nor COMPOSE_PROFILES is set") + func activeProfilesEmptyWhenUnset() throws { + unsetenv("COMPOSE_PROFILES") + + let cmd = try ComposeUp.parse([]) + #expect(cmd.composeFileOptions.activeProfiles.isEmpty) + } +} diff --git a/Tests/Container-Compose-StaticTests/ServiceDependencyTests.swift b/Tests/Container-Compose-StaticTests/ServiceDependencyTests.swift index 9aabac8..9229a7d 100644 --- a/Tests/Container-Compose-StaticTests/ServiceDependencyTests.swift +++ b/Tests/Container-Compose-StaticTests/ServiceDependencyTests.swift @@ -82,7 +82,7 @@ struct ServiceDependencyTests { ("cache", cache), ] let sorted = try Service.topoSortConfiguredServices(services) - let selected = ComposeUp.servicesSelectedForUp(sorted, requestedServices: ["worker"]) + let selected = Service.selectServices(from: sorted, requestedServices: ["worker"]) #expect(selected.map(\.serviceName) == ["db", "api", "worker"]) } diff --git a/Tests/Container-Compose-StaticTests/WaitForeverCpuTests.swift b/Tests/Container-Compose-StaticTests/WaitForeverCpuTests.swift index ce99641..3371c9c 100644 --- a/Tests/Container-Compose-StaticTests/WaitForeverCpuTests.swift +++ b/Tests/Container-Compose-StaticTests/WaitForeverCpuTests.swift @@ -31,19 +31,25 @@ struct WaitForeverCpuTests { /// Regression for #27: `waitForever()` must suspend, not busy-loop. /// - /// Method: take a `getrusage(RUSAGE_SELF)` snapshot, spawn a child Task - /// that calls `waitForever()`, sleep for 200ms wall-clock, take a second - /// snapshot, and compare user-CPU consumed. + /// 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. + /// 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 user CPU consumed during the 200ms window - /// is around 200,000 µs (one full core). With the fix - /// (`withUnsafeContinuation { _ in }`), the child task suspends and - /// consumes essentially nothing. + /// 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. /// - /// Threshold of 50,000 µs gives ~4× headroom over a noisy CI baseline - /// (test-runner overhead, parallel tasks) while still reliably catching - /// a single core's worth of busy-loop work. + /// 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 @@ -52,6 +58,14 @@ struct WaitForeverCpuTests { @Test("waitForever does not pin a CPU core (regression for #27)") func waitForeverDoesNotPinCpu() 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 + // whatever else is concurrently running in this test process. + let baselineBefore = userCpuMicroseconds() + try await Task.sleep(nanoseconds: 200_000_000) // 200ms + let baselineConsumed = userCpuMicroseconds() - baselineBefore + let before = userCpuMicroseconds() // Detached so cancellation propagation from the test doesn't reach it @@ -65,8 +79,9 @@ struct WaitForeverCpuTests { let after = userCpuMicroseconds() let consumed = after - before + let incremental = consumed - baselineConsumed - #expect(consumed < 50_000, - "waitForever consumed \(consumed) µs of user CPU in 200ms — likely busy-looping (regression for #27)") + #expect(incremental < 50_000, + "waitForever added \(incremental) µs of user CPU over a \(baselineConsumed) µs baseline in 200ms — likely busy-looping (regression for #27)") } }