Skip to content

Commit b857f01

Browse files
feat(phase5a): add XPC timeout hardening and idempotent teardown
Add per-service --timeout-seconds flag (default 30s) with graceful→force escalation via TaskGroup. Track owned containers in state file for crash-resilient teardown. DownResult model provides structured exit codes (0=clean, 1=timeout, 2=error) and summary output. 18 static tests pass. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 8574f06 commit b857f01

2 files changed

Lines changed: 360 additions & 7 deletions

File tree

Sources/Container-Compose/Commands/ComposeDown.swift

Lines changed: 161 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)