Skip to content

Commit e92722f

Browse files
authored
Merge pull request #91 from zero-ide/feature/commandrunner-stderr-structured
refactor: split command runner stderr and stdout in failure model
2 parents 188f951 + 4058f8a commit e92722f

File tree

5 files changed

+88
-30
lines changed

5 files changed

+88
-30
lines changed

Sources/Zero/Core/CommandRunner.swift

Lines changed: 56 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,21 @@
11
import Foundation
22

33
enum CommandRunnerError: LocalizedError {
4-
case commandFailed(command: String, arguments: [String], exitCode: Int, output: String)
4+
case commandFailed(command: String, arguments: [String], exitCode: Int, stdout: String, stderr: String)
55

66
var errorDescription: String? {
77
switch self {
8-
case .commandFailed(_, _, let exitCode, let output):
9-
let trimmedOutput = output.trimmingCharacters(in: .whitespacesAndNewlines)
10-
if trimmedOutput.isEmpty {
8+
case .commandFailed(_, _, let exitCode, let stdout, let stderr):
9+
let trimmedStderr = stderr.trimmingCharacters(in: .whitespacesAndNewlines)
10+
if !trimmedStderr.isEmpty {
11+
return trimmedStderr
12+
}
13+
14+
let trimmedStdout = stdout.trimmingCharacters(in: .whitespacesAndNewlines)
15+
if trimmedStdout.isEmpty {
1116
return "Command failed with exit code \(exitCode)."
1217
}
13-
return trimmedOutput
18+
return trimmedStdout
1419
}
1520
}
1621
}
@@ -31,14 +36,16 @@ final class CommandRunner: CommandRunning {
3136

3237
func executeStreaming(command: String, arguments: [String] = [], onOutput: @escaping (String) -> Void) throws -> String {
3338
let process = Process()
34-
let pipe = Pipe()
39+
let stdoutPipe = Pipe()
40+
let stderrPipe = Pipe()
3541
let dataLock = NSLock()
36-
var outputData = Data()
42+
var stdoutData = Data()
43+
var stderrData = Data()
3744

3845
process.executableURL = URL(fileURLWithPath: command)
3946
process.arguments = arguments
40-
process.standardOutput = pipe
41-
process.standardError = pipe
47+
process.standardOutput = stdoutPipe
48+
process.standardError = stderrPipe
4249

4350
processLock.lock()
4451
currentProcess = process
@@ -51,13 +58,28 @@ final class CommandRunner: CommandRunning {
5158
processLock.unlock()
5259
}
5360

54-
let fileHandle = pipe.fileHandleForReading
55-
fileHandle.readabilityHandler = { handle in
61+
let stdoutHandle = stdoutPipe.fileHandleForReading
62+
let stderrHandle = stderrPipe.fileHandleForReading
63+
64+
stdoutHandle.readabilityHandler = { handle in
5665
let chunkData = handle.availableData
5766
guard !chunkData.isEmpty else { return }
5867

5968
dataLock.lock()
60-
outputData.append(chunkData)
69+
stdoutData.append(chunkData)
70+
dataLock.unlock()
71+
72+
if let chunk = String(data: chunkData, encoding: .utf8), !chunk.isEmpty {
73+
onOutput(chunk)
74+
}
75+
}
76+
77+
stderrHandle.readabilityHandler = { handle in
78+
let chunkData = handle.availableData
79+
guard !chunkData.isEmpty else { return }
80+
81+
dataLock.lock()
82+
stderrData.append(chunkData)
6183
dataLock.unlock()
6284

6385
if let chunk = String(data: chunkData, encoding: .utf8), !chunk.isEmpty {
@@ -68,31 +90,45 @@ final class CommandRunner: CommandRunning {
6890
try process.run()
6991
process.waitUntilExit()
7092

71-
fileHandle.readabilityHandler = nil
93+
stdoutHandle.readabilityHandler = nil
94+
stderrHandle.readabilityHandler = nil
95+
96+
let trailingStdout = stdoutHandle.readDataToEndOfFile()
97+
if !trailingStdout.isEmpty {
98+
dataLock.lock()
99+
stdoutData.append(trailingStdout)
100+
dataLock.unlock()
101+
102+
if let trailingChunk = String(data: trailingStdout, encoding: .utf8), !trailingChunk.isEmpty {
103+
onOutput(trailingChunk)
104+
}
105+
}
72106

73-
let trailingData = fileHandle.readDataToEndOfFile()
74-
if !trailingData.isEmpty {
107+
let trailingStderr = stderrHandle.readDataToEndOfFile()
108+
if !trailingStderr.isEmpty {
75109
dataLock.lock()
76-
outputData.append(trailingData)
110+
stderrData.append(trailingStderr)
77111
dataLock.unlock()
78112

79-
if let trailingChunk = String(data: trailingData, encoding: .utf8), !trailingChunk.isEmpty {
113+
if let trailingChunk = String(data: trailingStderr, encoding: .utf8), !trailingChunk.isEmpty {
80114
onOutput(trailingChunk)
81115
}
82116
}
83117

84-
let output = String(data: outputData, encoding: .utf8) ?? ""
118+
let stdout = String(data: stdoutData, encoding: .utf8) ?? ""
119+
let stderr = String(data: stderrData, encoding: .utf8) ?? ""
85120

86121
if process.terminationStatus != 0 {
87122
throw CommandRunnerError.commandFailed(
88123
command: command,
89124
arguments: arguments,
90125
exitCode: Int(process.terminationStatus),
91-
output: output
126+
stdout: stdout,
127+
stderr: stderr
92128
)
93129
}
94130

95-
return output
131+
return stdout + stderr
96132
}
97133

98134
func cancelCurrentCommand() {

Sources/Zero/Services/DockerService.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -156,8 +156,8 @@ struct DockerService: DockerServiceProtocol {
156156
return zeroError
157157
}
158158

159-
if case let CommandRunnerError.commandFailed(binary, arguments, exitCode, output) = error {
160-
let debugDetails = "\(context) [binary=\(binary)] [args=\(arguments.joined(separator: " "))] [script=\(command)] [exit=\(exitCode)] [output=\(output)]"
159+
if case let CommandRunnerError.commandFailed(binary, arguments, exitCode, stdout, stderr) = error {
160+
let debugDetails = "\(context) [binary=\(binary)] [args=\(arguments.joined(separator: " "))] [script=\(command)] [exit=\(exitCode)] [stdout=\(stdout)] [stderr=\(stderr)]"
161161
return .runtimeCommandFailed(userMessage: context, debugDetails: debugDetails)
162162
}
163163

Sources/Zero/Services/ExecutionService.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,7 @@ class ExecutionService: ObservableObject {
217217
return "url_error_\(urlError.errorCode)"
218218
}
219219

220-
if case let CommandRunnerError.commandFailed(_, _, exitCode, _) = error {
220+
if case let CommandRunnerError.commandFailed(_, _, exitCode, _, _) = error {
221221
return "command_failed_\(exitCode)"
222222
}
223223

Tests/ZeroTests/CommandRunnerTests.swift

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,15 +21,35 @@ final class CommandRunnerTests: XCTestCase {
2121
XCTAssertThrowsError(
2222
try runner.execute(command: "/bin/sh", arguments: ["-c", "echo boom >&2; exit 7"])
2323
) { error in
24-
guard case let CommandRunnerError.commandFailed(command, arguments, exitCode, output) = error else {
24+
guard case let CommandRunnerError.commandFailed(command, arguments, exitCode, stdout, stderr) = error else {
2525
XCTFail("Expected CommandRunnerError.commandFailed")
2626
return
2727
}
2828

2929
XCTAssertEqual(command, "/bin/sh")
3030
XCTAssertEqual(arguments, ["-c", "echo boom >&2; exit 7"])
3131
XCTAssertEqual(exitCode, 7)
32-
XCTAssertTrue(output.contains("boom"))
32+
XCTAssertTrue(stdout.isEmpty)
33+
XCTAssertTrue(stderr.contains("boom"))
34+
}
35+
}
36+
37+
func testExecuteThrowsErrorWithSeparatedStdoutAndStderr() {
38+
// Given
39+
let runner = CommandRunner()
40+
41+
// When & Then
42+
XCTAssertThrowsError(
43+
try runner.execute(command: "/bin/sh", arguments: ["-c", "echo out; echo err >&2; exit 3"])
44+
) { error in
45+
guard case let CommandRunnerError.commandFailed(_, _, exitCode, stdout, stderr) = error else {
46+
XCTFail("Expected CommandRunnerError.commandFailed")
47+
return
48+
}
49+
50+
XCTAssertEqual(exitCode, 3)
51+
XCTAssertTrue(stdout.contains("out"))
52+
XCTAssertTrue(stderr.contains("err"))
3353
}
3454
}
3555
}

Tests/ZeroTests/DockerServiceTests.swift

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -187,10 +187,12 @@ final class DockerServiceTests: XCTestCase {
187187
func testExecuteShellMapsRunnerFailureToStructuredZeroError() {
188188
// Given
189189
let mockRunner = MockCommandRunner()
190-
mockRunner.mockError = NSError(
191-
domain: "CommandRunner",
192-
code: 127,
193-
userInfo: [NSLocalizedDescriptionKey: "sh: npm: not found"]
190+
mockRunner.mockError = CommandRunnerError.commandFailed(
191+
command: "/usr/local/bin/docker",
192+
arguments: ["exec", "zero-dev", "sh", "-c", "npm start"],
193+
exitCode: 127,
194+
stdout: "",
195+
stderr: "sh: npm: not found"
194196
)
195197
let service = DockerService(runner: mockRunner)
196198

@@ -204,7 +206,7 @@ final class DockerServiceTests: XCTestCase {
204206

205207
XCTAssertEqual(userMessage, "Docker shell command failed.")
206208
XCTAssertTrue(debugDetails.contains("npm start"))
207-
XCTAssertTrue(debugDetails.contains("not found"))
209+
XCTAssertTrue(debugDetails.contains("stderr=sh: npm: not found"))
208210
}
209211
}
210212
}

0 commit comments

Comments
 (0)