Skip to content

Commit 6d2dc9f

Browse files
authored
Merge pull request #85 from zero-ide/feature/timeout-retry-policy
feat(execution): add timeout and retry policy for runtime installs
2 parents 652d118 + 2c4c9a0 commit 6d2dc9f

File tree

2 files changed

+122
-3
lines changed

2 files changed

+122
-3
lines changed

Sources/Zero/Services/ExecutionService.swift

Lines changed: 57 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ class ExecutionService: ObservableObject {
1515
@Published var status: ExecutionStatus = .idle
1616
@Published var output: String = ""
1717
private var cancellationRequested = false
18+
private let installTimeoutSeconds: TimeInterval = 20
19+
private let installMaxAttempts = 3
20+
private let installRetryDelayNanoseconds: UInt64 = 300_000_000
1821

1922
init(
2023
dockerService: DockerServiceProtocol,
@@ -85,19 +88,70 @@ class ExecutionService: ObservableObject {
8588
private func setupEnvironment(for command: String, container: String) async throws {
8689
if command.contains("npm") {
8790
await MainActor.run { self.output += "\n📦 Installing Node.js..." }
88-
_ = try dockerService.executeShell(container: container, script: "apk add --no-cache nodejs npm")
91+
try await installPackage(
92+
container: container,
93+
script: "apk add --no-cache nodejs npm",
94+
runtimeName: "Node.js"
95+
)
8996
} else if command.contains("python") {
9097
await MainActor.run { self.output += "\n📦 Installing Python..." }
91-
_ = try dockerService.executeShell(container: container, script: "apk add --no-cache python3")
98+
try await installPackage(
99+
container: container,
100+
script: "apk add --no-cache python3",
101+
runtimeName: "Python"
102+
)
92103
} else if command.contains("javac") || command.contains("mvn") || command.contains("gradle") {
93104
await MainActor.run { self.output += "\n📦 Setting up Java environment..." }
94105
// Note: JDK should be pre-installed in the container image
95106
// This is handled by using the configured JDK image
96107
} else if command.contains("go") {
97108
await MainActor.run { self.output += "\n📦 Installing Go..." }
98-
_ = try dockerService.executeShell(container: container, script: "apk add --no-cache go")
109+
try await installPackage(
110+
container: container,
111+
script: "apk add --no-cache go",
112+
runtimeName: "Go"
113+
)
99114
}
100115
}
116+
117+
private func installPackage(container: String, script: String, runtimeName: String) async throws {
118+
var lastError: Error?
119+
120+
for attempt in 1...installMaxAttempts {
121+
do {
122+
let timeoutScript = "timeout \(Int(installTimeoutSeconds)) sh -lc \"\(escapeForDoubleQuotedShell(script))\""
123+
_ = try dockerService.executeShell(container: container, script: timeoutScript)
124+
return
125+
} catch {
126+
lastError = error
127+
128+
if attempt == installMaxAttempts {
129+
let debugDetails = "runtime=\(runtimeName) attempts=\(installMaxAttempts) timeoutSeconds=\(installTimeoutSeconds) lastError=\(error.localizedDescription)"
130+
throw ZeroError.runtimeCommandFailed(
131+
userMessage: "Failed to install \(runtimeName) after \(installMaxAttempts) attempts.",
132+
debugDetails: debugDetails
133+
)
134+
}
135+
136+
await MainActor.run {
137+
self.output += "\n⚠️ Retrying \(runtimeName) installation (attempt \(attempt + 1)/\(installMaxAttempts))..."
138+
}
139+
try? await Task.sleep(nanoseconds: installRetryDelayNanoseconds)
140+
}
141+
}
142+
143+
let fallbackMessage = "Failed to install \(runtimeName) after \(installMaxAttempts) attempts."
144+
throw ZeroError.runtimeCommandFailed(
145+
userMessage: fallbackMessage,
146+
debugDetails: "runtime=\(runtimeName) lastError=\(lastError?.localizedDescription ?? "unknown")"
147+
)
148+
}
149+
150+
private func escapeForDoubleQuotedShell(_ script: String) -> String {
151+
script
152+
.replacingOccurrences(of: "\\", with: "\\\\")
153+
.replacingOccurrences(of: "\"", with: "\\\"")
154+
}
101155

102156
func createJavaContainer(name: String) async throws -> String {
103157
let config = try buildConfigService.load()

Tests/ZeroTests/ExecutionServiceTests.swift

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,57 @@ final class ExecutionServiceTests: XCTestCase {
231231
XCTAssertTrue(service.output.contains("❌ Error: Docker shell command failed."))
232232
}
233233

234+
func testRunRetriesPackageInstallAfterTransientFailure() async {
235+
// Given
236+
mockDocker.scriptedShellResults = [
237+
.failure(ZeroError.runtimeCommandFailed(userMessage: "Docker shell command timed out.", debugDetails: "attempt 1 timeout")),
238+
.success(""),
239+
.success("run ok")
240+
]
241+
242+
// When
243+
await service.run(container: "test-container", command: "npm start")
244+
245+
// Then
246+
XCTAssertEqual(service.status, .success)
247+
XCTAssertTrue(service.output.contains("Retrying Node.js installation (attempt 2/3)"))
248+
249+
let installCommands = mockDocker.executedShellScripts.filter { $0.contains("apk add --no-cache nodejs npm") }
250+
XCTAssertEqual(installCommands.count, 2)
251+
}
252+
253+
func testRunFailsWhenPackageInstallRetriesExhausted() async {
254+
// Given
255+
mockDocker.scriptedShellResults = [
256+
.failure(ZeroError.runtimeCommandFailed(userMessage: "Docker shell command timed out.", debugDetails: "attempt 1 timeout")),
257+
.failure(ZeroError.runtimeCommandFailed(userMessage: "Docker shell command timed out.", debugDetails: "attempt 2 timeout")),
258+
.failure(ZeroError.runtimeCommandFailed(userMessage: "Docker shell command timed out.", debugDetails: "attempt 3 timeout"))
259+
]
260+
261+
// When
262+
await service.run(container: "test-container", command: "npm start")
263+
264+
// Then
265+
XCTAssertEqual(service.status, .failed("Failed to install Node.js after 3 attempts."))
266+
XCTAssertTrue(service.output.contains("❌ Error: Failed to install Node.js after 3 attempts."))
267+
}
268+
269+
func testRunWrapsPackageInstallWithTimeoutPolicy() async {
270+
// Given
271+
mockDocker.scriptedShellResults = [.success(""), .success("run ok")]
272+
273+
// When
274+
await service.run(container: "test-container", command: "npm start")
275+
276+
// Then
277+
guard let installScript = mockDocker.executedShellScripts.first(where: { $0.contains("apk add --no-cache nodejs npm") }) else {
278+
XCTFail("Expected Node.js install command to run")
279+
return
280+
}
281+
282+
XCTAssertTrue(installScript.contains("timeout 20 sh -lc"))
283+
}
284+
234285
func testSaveAndLoadRunProfileCommand() throws {
235286
// Given
236287
let repositoryURL = URL(string: "https://github.com/zero-ide/Zero.git")!
@@ -319,6 +370,8 @@ class MockExecutionDockerService: DockerServiceProtocol {
319370
var interChunkDelayNanoseconds: UInt64 = 0
320371
var dockerCommandAvailable = true
321372
var executionError: Error?
373+
var scriptedShellResults: [Result<String, Error>] = []
374+
var executedShellScripts: [String] = []
322375

323376
func checkInstallation() throws -> Bool { return true }
324377

@@ -334,6 +387,18 @@ class MockExecutionDockerService: DockerServiceProtocol {
334387
func runContainer(image: String, name: String) throws -> String { return "" }
335388
func executeCommand(container: String, command: String) throws -> String { return "" }
336389
func executeShell(container: String, script: String) throws -> String {
390+
executedShellScripts.append(script)
391+
392+
if !scriptedShellResults.isEmpty {
393+
let nextResult = scriptedShellResults.removeFirst()
394+
switch nextResult {
395+
case .success(let output):
396+
return output
397+
case .failure(let error):
398+
throw error
399+
}
400+
}
401+
337402
if let executionError {
338403
throw executionError
339404
}

0 commit comments

Comments
 (0)