@@ -46,6 +46,9 @@ public struct ComposeDown: AsyncParsableCommand {
4646 @Option ( name: [ . customShort( " f " ) , . customLong( " file " ) ] , help: " The path to your Docker Compose file " )
4747 var composeFile : String ? = nil
4848
49+ @Option ( name: . long, help: " Per-service stop timeout in seconds (default: 30) " )
50+ var timeoutSeconds : Int = 30
51+
4952 private var foundFilename : String ?
5053 private var composePath : String {
5154 if let file = composeFile {
@@ -57,6 +60,76 @@ public struct ComposeDown: AsyncParsableCommand {
5760 private var fileManager : FileManager { FileManager . default }
5861 private var projectName : String ?
5962
63+ // MARK: - Result Model
64+
65+ /// Tracks the outcome of stopping each service for exit code calculation.
66+ public struct DownResult : Sendable {
67+ public let stopped : [ String ]
68+ public let timeouts : [ String ]
69+ public let errors : [ String ]
70+
71+ public init ( stopped: [ String ] , timeouts: [ String ] , errors: [ String ] ) {
72+ self . stopped = stopped
73+ self . timeouts = timeouts
74+ self . errors = errors
75+ }
76+
77+ /// Worst-case exit code: 0=all clean, 1=some timeouts, 2=fatal errors.
78+ public var exitCode : Int32 {
79+ if !errors. isEmpty { return 2 }
80+ if !timeouts. isEmpty { return 1 }
81+ return 0
82+ }
83+
84+ public var isSuccess : Bool { exitCode == 0 }
85+
86+ public var summary : String {
87+ var parts : [ String ] = [ ]
88+ if !stopped. isEmpty {
89+ parts. append ( " \( stopped. count) stopped " )
90+ }
91+ if !timeouts. isEmpty {
92+ parts. append ( " \( timeouts. count) timeout " )
93+ }
94+ if !errors. isEmpty {
95+ parts. append ( " \( errors. count) error " )
96+ }
97+ if parts. isEmpty {
98+ return " 0 stopped "
99+ }
100+ return parts. joined ( separator: " , " )
101+ }
102+ }
103+
104+ // MARK: - State File (Idempotent Teardown)
105+
106+ /// Path to the state file for a given working directory.
107+ public static func stateFilePath( cwd: String ) -> URL {
108+ URL ( fileURLWithPath: cwd) . appendingPathComponent ( " .container-compose.state " )
109+ }
110+
111+ /// Read owned container names from the state file. Returns empty array if file doesn't exist.
112+ public static func readStateFile( _ url: URL ) -> [ String ] {
113+ guard let data = FileManager . default. contents ( atPath: url. path) ,
114+ let content = String ( data: data, encoding: . utf8) else {
115+ return [ ]
116+ }
117+ return content. split ( separator: " \n " ) . map ( String . init) . filter { !$0. isEmpty }
118+ }
119+
120+ /// Write owned container names to the state file.
121+ public static func writeStateFile( _ url: URL , containerNames: [ String ] ) {
122+ let content = containerNames. joined ( separator: " \n " )
123+ try ? content. write ( to: url, atomically: true , encoding: . utf8)
124+ }
125+
126+ /// Remove the state file. No-op if it doesn't exist.
127+ public static func removeStateFile( _ url: URL ) {
128+ try ? FileManager . default. removeItem ( at: url)
129+ }
130+
131+ // MARK: - Run
132+
60133 public mutating func run( ) async throws {
61134
62135 // Skip CWD scanning if -f was explicitly provided
@@ -115,11 +188,24 @@ public struct ComposeDown: AsyncParsableCommand {
115188 } )
116189 }
117190
118- try await stopOldStuff ( services, remove: false )
191+ let result = try await stopOldStuff ( services, remove: false )
192+
193+ // Report summary
194+ print ( " Summary: \( result. summary) " )
195+
196+ // Exit with appropriate code
197+ if !result. isSuccess {
198+ throw ComposeDownError . teardownIncomplete ( result)
199+ }
119200 }
120201
121- private func stopOldStuff( _ services: [ ( serviceName: String , service: Service ) ] , remove: Bool ) async throws {
122- guard let projectName else { return }
202+ private func stopOldStuff( _ services: [ ( serviceName: String , service: Service ) ] , remove: Bool ) async throws -> DownResult {
203+ guard let projectName else { return DownResult ( stopped: [ ] , timeouts: [ ] , errors: [ ] ) }
204+
205+ var stopped : [ String ] = [ ]
206+ var timeouts : [ String ] = [ ]
207+ var errors : [ String ] = [ ]
208+ var ownedContainerNames : [ String ] = [ ]
123209
124210 for (serviceName, service) in services {
125211 // Respect explicit container_name, otherwise use default pattern
@@ -130,26 +216,94 @@ public struct ComposeDown: AsyncParsableCommand {
130216 containerName = " \( projectName) - \( serviceName) "
131217 }
132218
219+ ownedContainerNames. append ( containerName)
220+
133221 print ( " Stopping container: \( containerName) " )
134222 guard let container = try ? await ClientContainer . get ( id: containerName) else {
135223 print ( " Warning: Container ' \( containerName) ' not found, skipping. " )
136224 continue
137225 }
138226
139- do {
140- try await container. stop ( )
227+ // Stop with per-service timeout
228+ let didStop = try await stopWithTimeout ( container: container, name: containerName, timeout: timeoutSeconds)
229+ if didStop {
141230 print ( " Successfully stopped container: \( containerName) " )
142- } catch {
143- print ( " Error Stopping Container: \( error) " )
231+ stopped. append ( containerName)
232+ } else {
233+ print ( " Warning: Timeout stopping container: \( containerName) (force-stopped) " )
234+ timeouts. append ( containerName)
144235 }
236+
145237 if remove {
146238 do {
147239 try await container. delete ( )
148240 print ( " Successfully removed container: \( containerName) " )
149241 } catch {
150242 print ( " Error Removing Container: \( error) " )
243+ errors. append ( containerName)
151244 }
152245 }
153246 }
247+
248+ // Write state file so a subsequent `down` can resume
249+ let statePath = ComposeDown . stateFilePath ( cwd: cwd)
250+ if ownedContainerNames. isEmpty {
251+ // No services to manage — remove state file if it exists (idempotent no-op)
252+ ComposeDown . removeStateFile ( statePath)
253+ } else {
254+ ComposeDown . writeStateFile ( statePath, containerNames: ownedContainerNames)
255+ }
256+
257+ return DownResult ( stopped: stopped, timeouts: timeouts, errors: errors)
258+ }
259+
260+ /// Stop a container with a timeout. Returns true if stopped gracefully, false if timed out.
261+ private func stopWithTimeout( container: ClientContainer , name: String , timeout: Int ) async throws -> Bool {
262+ let timeoutNs = UInt64 ( timeout) * 1_000_000_000
263+
264+ return try await withThrowingTaskGroup ( of: Bool . self) { group in
265+ // Primary: try graceful stop
266+ group. addTask {
267+ try await container. stop ( )
268+ return true
269+ }
270+
271+ // Timeout: cancel after N seconds
272+ group. addTask {
273+ try await Task . sleep ( nanoseconds: timeoutNs)
274+ return false
275+ }
276+
277+ // Wait for first result
278+ let graceful = try await group. next ( ) ?? false
279+ group. cancelAll ( )
280+
281+ if graceful {
282+ return true
283+ }
284+
285+ // Timed out — attempt force stop
286+ print ( " Graceful stop timed out for \( name) , attempting force stop... " )
287+ do {
288+ try await container. delete ( force: true )
289+ return false // stopped but not gracefully
290+ } catch {
291+ print ( " Force stop also failed for \( name) : \( error) " )
292+ return false
293+ }
294+ }
295+ }
296+ }
297+
298+ // MARK: - Error Types
299+
300+ public enum ComposeDownError : Error , CustomStringConvertible {
301+ case teardownIncomplete( ComposeDown . DownResult )
302+
303+ public var description : String {
304+ switch self {
305+ case . teardownIncomplete( let result) :
306+ return " Teardown incomplete: \( result. summary) "
307+ }
154308 }
155309}
0 commit comments