From 246e7c7a2296acf3f29e4c6b43f08692deb861f6 Mon Sep 17 00:00:00 2001 From: Jack Adamson Date: Sat, 29 Aug 2026 22:09:54 -0500 Subject: [PATCH] feat: add/support stop_grace_period --- Package.swift | 4 + .../Codable Structs/Service.swift | 14 +- .../Codable Structs/StopGracePeriod.swift | 124 ++++++++++++++++++ .../Commands/ComposeDown.swift | 5 +- .../Commands/ComposeUp.swift | 80 ++++++----- .../ComposeStopOptions.swift | 34 +++++ .../ComposeDownTests.swift | 46 +++++++ .../ForegroundWaitCpuTests.swift | 2 +- .../StopGracePeriodTests.swift | 87 ++++++++++++ 9 files changed, 363 insertions(+), 33 deletions(-) create mode 100644 Sources/Container-Compose/Codable Structs/StopGracePeriod.swift create mode 100644 Sources/Container-Compose/ComposeStopOptions.swift create mode 100644 Tests/Container-Compose-StaticTests/StopGracePeriodTests.swift diff --git a/Package.swift b/Package.swift index 75f4adf..85491da 100644 --- a/Package.swift +++ b/Package.swift @@ -28,6 +28,10 @@ let package = Package( name: "ContainerXPC", package: "container" ), + .product( + name: "ContainerResource", + package: "container" + ), .product( name: "ArgumentParser", package: "swift-argument-parser" diff --git a/Sources/Container-Compose/Codable Structs/Service.swift b/Sources/Container-Compose/Codable Structs/Service.swift index 78fcd73..a69b60d 100644 --- a/Sources/Container-Compose/Codable Structs/Service.swift +++ b/Sources/Container-Compose/Codable Structs/Service.swift @@ -38,6 +38,9 @@ public struct Service: Codable, Hashable { /// Restart policy (e.g., 'unless-stopped', 'always') public let restart: String? + /// Time Compose waits for the container to stop before sending SIGKILL. + public let stop_grace_period: StopGracePeriod? + /// Healthcheck configuration public let healthcheck: Healthcheck? @@ -131,7 +134,7 @@ public struct Service: Codable, Hashable { // Defines custom coding keys to map YAML keys to Swift properties enum CodingKeys: String, CodingKey { - case image, build, deploy, restart, healthcheck, volumes, environment, env_file, ports, command, depends_on, user, + case image, build, deploy, restart, stop_grace_period, 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, profiles } @@ -142,6 +145,7 @@ public struct Service: Codable, Hashable { build: Build? = nil, deploy: Deploy? = nil, restart: String? = nil, + stop_grace_period: StopGracePeriod? = nil, healthcheck: Healthcheck? = nil, volumes: [String]? = nil, environment: [String: String]? = nil, @@ -174,6 +178,7 @@ public struct Service: Codable, Hashable { self.build = build self.deploy = deploy self.restart = restart + self.stop_grace_period = stop_grace_period self.healthcheck = healthcheck self.volumes = volumes self.environment = environment @@ -216,6 +221,7 @@ public struct Service: Codable, Hashable { } restart = try container.decodeIfPresent(String.self, forKey: .restart) + stop_grace_period = try container.decodeIfPresent(StopGracePeriod.self, forKey: .stop_grace_period) healthcheck = try container.decodeIfPresent(Healthcheck.self, forKey: .healthcheck) volumes = try container.decodeIfPresent([String].self, forKey: .volumes) @@ -357,6 +363,12 @@ public struct Service: Codable, Hashable { guard let profiles, !profiles.isEmpty else { return true } return !Set(profiles).isDisjoint(with: activeProfiles) } + + /// The stop timeout passed to Apple Container, using Compose's 10-second + /// default when the service does not specify `stop_grace_period`. + public var stopTimeoutInSeconds: Int32 { + stop_grace_period?.timeoutInSeconds ?? StopGracePeriod.defaultTimeoutInSeconds + } /// Translates the list-form of `environment:` into the same `[String: String]` /// shape produced by the map form. Handles two cases: diff --git a/Sources/Container-Compose/Codable Structs/StopGracePeriod.swift b/Sources/Container-Compose/Codable Structs/StopGracePeriod.swift new file mode 100644 index 0000000..414d220 --- /dev/null +++ b/Sources/Container-Compose/Codable Structs/StopGracePeriod.swift @@ -0,0 +1,124 @@ +//===----------------------------------------------------------------------===// +// 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 Foundation + +/// A Compose duration used for the time allowed for a container to stop. +public struct StopGracePeriod: Codable, Hashable, Sendable { + /// Docker Compose's default when `stop_grace_period` is omitted. + public static let defaultTimeoutInSeconds: Int32 = 10 + + /// The duration as written in the Compose file. + public let value: String + + /// The whole seconds accepted by Apple Container's stop API. + /// + /// Docker Compose also passes the duration to a whole-second runtime API, + /// so fractional seconds are truncated rather than rounded up. + public let timeoutInSeconds: Int32 + + public init(_ value: String) throws { + let seconds = try Self.parseSeconds(value) + self.value = value + self.timeoutInSeconds = Int32(seconds) + } + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let value = try container.decode(String.self) + try self.init(value) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(value) + } + + private static func parseSeconds(_ value: String) throws -> Double { + guard !value.isEmpty else { throw InvalidDurationError(value: value) } + + var index = value.startIndex + var total: Double = 0 + var foundComponent = false + + while index < value.endIndex { + let numberStart = index + while index < value.endIndex, value[index].isNumber { + index = value.index(after: index) + } + guard index > numberStart else { throw InvalidDurationError(value: value) } + + if index < value.endIndex, value[index] == "." { + index = value.index(after: index) + let fractionStart = index + while index < value.endIndex, value[index].isNumber { + index = value.index(after: index) + } + guard index > fractionStart else { throw InvalidDurationError(value: value) } + } + + let number = String(value[numberStart..= 0, total <= Double(Int32.max) else { + throw InvalidDurationError(value: value) + } + return total.rounded(.towardZero) + } + + private struct InvalidDurationError: LocalizedError { + let value: String + + var errorDescription: String? { + "Invalid stop_grace_period duration: '\(value)'" + } + } +} diff --git a/Sources/Container-Compose/Commands/ComposeDown.swift b/Sources/Container-Compose/Commands/ComposeDown.swift index de25845..fd9edc2 100644 --- a/Sources/Container-Compose/Commands/ComposeDown.swift +++ b/Sources/Container-Compose/Commands/ComposeDown.swift @@ -84,7 +84,10 @@ public struct ComposeDown: AsyncParsableCommand { stoppedAny = true print("Stopping container: \(name)") do { - try await client.stop(id: container.id) + try await client.stop( + id: container.id, + opts: ComposeStopOptions.resolve(for: target.service) + ) print("Successfully stopped container: \(name)") } catch { print("Error Stopping Container: \(error)") diff --git a/Sources/Container-Compose/Commands/ComposeUp.swift b/Sources/Container-Compose/Commands/ComposeUp.swift index d42efd7..32c4cab 100644 --- a/Sources/Container-Compose/Commands/ComposeUp.swift +++ b/Sources/Container-Compose/Commands/ComposeUp.swift @@ -72,6 +72,11 @@ private actor ForegroundRunHandle { func exitCodeIfCompleted() -> Int32? { exitCode } } +struct ComposeContainerStopTarget: Sendable { + let containerName: String + let timeoutInSeconds: Int32 +} + public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { public init() {} @@ -189,7 +194,7 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { // Stop Services. Pass every name a previous run might have used (legacy // dashed, dotted DNS-mode, and explicit container_name) so the cleanup // catches whichever shape exists on disk. - try await stopExistingContainers(project.services.flatMap(\.candidateContainerNames), remove: true) + try await stopExistingContainers(project.services, remove: true) // Process top-level networks // This creates named networks defined in the docker-compose.yml @@ -221,7 +226,13 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { } if !detach { - await runForegroundUntilStopped(containerNames: project.services.map({ containerName(for: $0.serviceName) })) + let stopTargets = project.services.map { + ComposeContainerStopTarget( + containerName: containerName(for: $0.serviceName), + timeoutInSeconds: $0.service.stopTimeoutInSeconds + ) + } + await runForegroundUntilStopped(containerTargets: stopTargets) } } @@ -231,10 +242,12 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { /// - 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 { + /// Takes resolved container names paired with their Compose stop timeouts: + /// a service's container may be named via explicit `container_name` or the + /// dotted `.` DNS convention, not just `-`. + func runForegroundUntilStopped(containerTargets: [ComposeContainerStopTarget]) async -> Never { + let containerNames = containerTargets.map(\.containerName) + // Exit once the containers stop by themselves or are stopped externally. if !containerNames.isEmpty { Task { @@ -256,7 +269,7 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { stopping = true print("\nGracefully stopping... (press Ctrl+C again to force)") Task { - await Self.stopContainers(containerNames) + await Self.stopContainers(containerTargets) Foundation.exit(0) } } else { @@ -289,15 +302,18 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { /// 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 { + private static func stopContainers(_ containerTargets: [ComposeContainerStopTarget]) async { let client = ContainerClient() - for name in containerNames { - guard let container = try? await client.get(id: name) else { continue } - print("Stopping container: \(name)") + for target in containerTargets { + guard let container = try? await client.get(id: target.containerName) else { continue } + print("Stopping container: \(target.containerName)") do { - try await client.stop(id: container.id) + try await client.stop( + id: container.id, + opts: ComposeStopOptions.resolve(timeoutInSeconds: target.timeoutInSeconds) + ) } catch { - print("Error stopping container \(name): \(error)") + print("Error stopping container \(target.containerName): \(error)") } } } @@ -651,26 +667,30 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { return Int32(response.int64(key: .exitCode)) } - /// Stops (and optionally removes) containers matching the given names. - /// Accepts pre-computed name strings so callers can pass all candidate - /// shapes (legacy dashed, dotted DNS, explicit `container_name`) and - /// teardown works regardless of which mode created them. - private func stopExistingContainers(_ names: [String], remove: Bool) async throws { - for container in names { - print("Stopping container: \(container)") - let client = ContainerClient() - guard let container = try? await client.get(id: container) else { continue } + /// Stops (and optionally removes) containers matching each service's + /// candidate names. This covers legacy dashed, dotted DNS, and explicit + /// `container_name` shapes from previous runs. + private func stopExistingContainers(_ targets: [ComposeProject.ServiceTarget], remove: Bool) async throws { + for target in targets { + for name in target.candidateContainerNames { + print("Stopping container: \(name)") + let client = ContainerClient() + guard let container = try? await client.get(id: name) else { continue } - do { - try await client.stop(id: container.id) - } catch { - print("Error Stopping Container: \(error)") - } - if remove { do { - try await client.delete(id: container.id) + try await client.stop( + id: container.id, + opts: ComposeStopOptions.resolve(for: target.service) + ) } catch { - print("Error Removing Container: \(error)") + print("Error Stopping Container: \(error)") + } + if remove { + do { + try await client.delete(id: container.id) + } catch { + print("Error Removing Container: \(error)") + } } } } diff --git a/Sources/Container-Compose/ComposeStopOptions.swift b/Sources/Container-Compose/ComposeStopOptions.swift new file mode 100644 index 0000000..47458c2 --- /dev/null +++ b/Sources/Container-Compose/ComposeStopOptions.swift @@ -0,0 +1,34 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerResource + +/// Converts Compose service shutdown configuration into Apple Container options. +/// +/// `signal` remains nil so the container runtime uses the image's configured +/// stop signal, or SIGTERM when the image does not specify one. +enum ComposeStopOptions { + static func resolve(for service: Service) -> ContainerStopOptions { + resolve(timeoutInSeconds: service.stopTimeoutInSeconds) + } + + static func resolve(timeoutInSeconds: Int32) -> ContainerStopOptions { + ContainerStopOptions( + timeoutInSeconds: timeoutInSeconds, + signal: nil + ) + } +} diff --git a/Tests/Container-Compose-DynamicTests/ComposeDownTests.swift b/Tests/Container-Compose-DynamicTests/ComposeDownTests.swift index 3e95d57..82915cd 100644 --- a/Tests/Container-Compose-DynamicTests/ComposeDownTests.swift +++ b/Tests/Container-Compose-DynamicTests/ComposeDownTests.swift @@ -61,6 +61,52 @@ struct ComposeDownTests { #expect(containers.filter({ $0.status == .stopped}).count == 2, "Expected 2 stopped containers for \(project.name), found \(containers.filter({ $0.status == .stopped }).count)") } + @Test("stop_grace_period controls the graceful stop deadline") + func testStopGracePeriod() async throws { + let markerDirectory = FileManager.default.temporaryDirectory + .appending(path: makeContainerName()) + try FileManager.default.createDirectory(at: markerDirectory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: markerDirectory) } + + let yaml = """ + services: + app: + image: alpine:latest + stop_grace_period: 1s + command: + - sh + - -c + - | + trap 'touch /marker/term; sleep 10; touch /marker/graceful' TERM + touch /marker/ready + while true; do sleep 1; done + volumes: + - \(markerDirectory.path):/marker + """ + let project = try DockerComposeYamlFiles.copyYamlToTemporaryLocation(yaml: yaml) + + var composeUp = try ComposeUp.parse([ + "-d", "--cwd", project.base.path(percentEncoded: false), + ]) + try await composeUp.run() + + let readyMarker = markerDirectory.appending(path: "ready") + let deadline = Date().addingTimeInterval(30) + while !FileManager.default.fileExists(atPath: readyMarker.path), Date() < deadline { + try await Task.sleep(nanoseconds: 100_000_000) + } + #expect(FileManager.default.fileExists(atPath: readyMarker.path)) + + let stopStarted = Date() + let composeDown = try ComposeDown.parse(["--cwd", project.base.path(percentEncoded: false)]) + try await composeDown.run() + let stopElapsed = Date().timeIntervalSince(stopStarted) + + #expect(stopElapsed < 4, "stop_grace_period was not applied; stop took \(stopElapsed)s") + #expect(FileManager.default.fileExists(atPath: markerDirectory.appending(path: "term").path)) + #expect(!FileManager.default.fileExists(atPath: markerDirectory.appending(path: "graceful").path)) + } + @Test("What goes up must come down - container_name") func testUpAndDownContainerName() async throws { // Create a new temporary UUID to use as a container name, otherwise we might conflict with diff --git a/Tests/Container-Compose-StaticTests/ForegroundWaitCpuTests.swift b/Tests/Container-Compose-StaticTests/ForegroundWaitCpuTests.swift index d2e863a..fe85041 100644 --- a/Tests/Container-Compose-StaticTests/ForegroundWaitCpuTests.swift +++ b/Tests/Container-Compose-StaticTests/ForegroundWaitCpuTests.swift @@ -72,7 +72,7 @@ struct ForegroundWaitCpuTests { // (it wouldn't matter — the function ignores cancellation by contract — // but this makes the leak explicit rather than incidental). Task.detached { - await composeUp.runForegroundUntilStopped(containerNames: []) + await composeUp.runForegroundUntilStopped(containerTargets: []) } try await Task.sleep(nanoseconds: 200_000_000) // 200ms diff --git a/Tests/Container-Compose-StaticTests/StopGracePeriodTests.swift b/Tests/Container-Compose-StaticTests/StopGracePeriodTests.swift new file mode 100644 index 0000000..03c0fc2 --- /dev/null +++ b/Tests/Container-Compose-StaticTests/StopGracePeriodTests.swift @@ -0,0 +1,87 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerResource +import Testing +import Yams + +@testable import ContainerComposeCore + +@Suite("Stop Grace Period Tests") +struct StopGracePeriodTests { + @Test("Parse a combined stop grace period") + func parseCombinedStopGracePeriod() throws { + let service = try decodeService(""" + image: alpine:latest + stop_grace_period: 1m30s + """) + + #expect(service.stop_grace_period?.timeoutInSeconds == 90) + #expect(service.stopTimeoutInSeconds == 90) + } + + @Test("Use Compose's ten-second default when omitted") + func defaultStopGracePeriod() throws { + let service = try decodeService("image: alpine:latest") + + #expect(service.stop_grace_period == nil) + #expect(service.stopTimeoutInSeconds == 10) + } + + @Test("Truncate subsecond stop grace periods for the runtime") + func truncateSubsecondStopGracePeriod() throws { + let service = try decodeService(""" + image: alpine:latest + stop_grace_period: 1.999s + """) + + #expect(service.stopTimeoutInSeconds == 1) + } + + @Test("Reject invalid stop grace periods") + func rejectInvalidStopGracePeriods() { + for value in ["1", "-1s", "1d", "1s-invalid", "2147483648s"] { + #expect(throws: Error.self) { + try decodeService(""" + image: alpine:latest + stop_grace_period: \(value) + """) + } + } + } + + @Test("Build explicit stop options") + func buildStopOptions() throws { + let service = try decodeService(""" + image: alpine:latest + stop_grace_period: 12s + """) + let options = ComposeStopOptions.resolve(for: service) + + #expect(options.timeoutInSeconds == 12) + #expect(options.signal == nil) + } + + private func decodeService(_ serviceYaml: String) throws -> Service { + let yaml = """ + services: + app: + \(serviceYaml.split(separator: "\n", omittingEmptySubsequences: false).map { " \($0)" }.joined(separator: "\n")) + """ + let compose = try YAMLDecoder().decode(DockerCompose.self, from: yaml) + return try #require(compose.services["app"] ?? nil) + } +}