Skip to content

Commit b9d3b79

Browse files
committed
refactor: enhance CommandRunner to improve process output handling and add cancellation support
1 parent cfd8078 commit b9d3b79

1 file changed

Lines changed: 85 additions & 85 deletions

File tree

‎MacOSCleaner/Infrastructure/CommandRunner.swift‎

Lines changed: 85 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -30,44 +30,81 @@ public actor CommandRunner {
3030
let process = Process()
3131
process.executableURL = URL(fileURLWithPath: command)
3232
process.arguments = arguments
33+
process.standardInput = FileHandle.nullDevice
3334

3435
let stdoutPipe = Pipe()
3536
let stderrPipe = Pipe()
36-
3737
process.standardOutput = stdoutPipe
3838
process.standardError = stderrPipe
3939

40-
final class ProcessState: @unchecked Sendable {
41-
private let lock = NSLock()
42-
private var isResumed = false
43-
private var stdoutData = Data()
44-
private var stderrData = Data()
40+
let stdoutHandle = stdoutPipe.fileHandleForReading
41+
let stderrHandle = stderrPipe.fileHandleForReading
4542

46-
func appendStdout(_ data: Data) {
47-
lock.lock()
48-
stdoutData.append(data)
49-
lock.unlock()
50-
}
43+
// Parent still holds pipe write ends until we close them. Drain on
44+
// dedicated threads so a fast-exiting child cannot drop stderr (CI flake:
45+
// terminationHandler nilling readabilityHandler races availableData).
46+
let stdoutTask = Task.detached(priority: .utility) {
47+
CommandRunner.readPipeToEnd(stdoutHandle)
48+
}
49+
let stderrTask = Task.detached(priority: .utility) {
50+
CommandRunner.readPipeToEnd(stderrHandle)
51+
}
5152

52-
func appendStderr(_ data: Data) {
53-
lock.lock()
54-
stderrData.append(data)
55-
lock.unlock()
56-
}
53+
let stdoutWrite = stdoutPipe.fileHandleForWriting
54+
let stderrWrite = stderrPipe.fileHandleForWriting
5755

58-
func finish(process: Process) -> CommandResult {
59-
lock.lock()
60-
defer { lock.unlock() }
61-
return CommandResult(
62-
stdout: String(decoding: stdoutData, as: UTF8.self),
63-
stderr: String(decoding: stderrData, as: UTF8.self),
64-
exitCode: process.terminationStatus
65-
)
56+
let exitCode: Int32
57+
do {
58+
exitCode = try await withTaskCancellationHandler {
59+
try await CommandRunner.waitForExit(process, timeout: timeout)
60+
} onCancel: {
61+
if process.isRunning {
62+
process.terminate()
63+
}
64+
try? stdoutWrite.close()
65+
try? stderrWrite.close()
66+
}
67+
try? stdoutWrite.close()
68+
try? stderrWrite.close()
69+
} catch {
70+
if process.isRunning {
71+
process.terminate()
6672
}
73+
try? stdoutWrite.close()
74+
try? stderrWrite.close()
75+
_ = await stdoutTask.value
76+
_ = await stderrTask.value
77+
throw error
78+
}
79+
80+
let stdoutData = await stdoutTask.value
81+
let stderrData = await stderrTask.value
82+
return CommandResult(
83+
stdout: String(decoding: stdoutData, as: UTF8.self),
84+
stderr: String(decoding: stderrData, as: UTF8.self),
85+
exitCode: exitCode
86+
)
87+
}
88+
89+
/// Blocks until EOF. Empty `availableData` is EOF on a pipe.
90+
private nonisolated static func readPipeToEnd(_ handle: FileHandle) -> Data {
91+
var data = Data()
92+
while true {
93+
let chunk = handle.availableData
94+
if chunk.isEmpty { break }
95+
data.append(chunk)
96+
}
97+
return data
98+
}
99+
100+
private nonisolated static func waitForExit(_ process: Process, timeout: Duration) async throws -> Int32 {
101+
final class ResumeOnce: @unchecked Sendable {
102+
private let lock = NSLock()
103+
private var isResumed = false
67104

68-
func resumeOnce(
69-
continuation: CheckedContinuation<CommandResult, Error>,
70-
result: Result<CommandResult, Error>
105+
func resume(
106+
_ continuation: CheckedContinuation<Int32, Error>,
107+
_ result: Result<Int32, Error>
71108
) {
72109
lock.lock()
73110
defer { lock.unlock() }
@@ -77,71 +114,34 @@ public actor CommandRunner {
77114
}
78115
}
79116

80-
let state = ProcessState()
81-
82-
stdoutPipe.fileHandleForReading.readabilityHandler = { handle in
83-
let data = handle.availableData
84-
if !data.isEmpty {
85-
state.appendStdout(data)
86-
}
87-
}
88-
89-
stderrPipe.fileHandleForReading.readabilityHandler = { handle in
90-
let data = handle.availableData
91-
if !data.isEmpty {
92-
state.appendStderr(data)
93-
}
94-
}
95-
96-
return try await withTaskCancellationHandler {
97-
try await withThrowingTaskGroup(of: CommandResult.self) { group in
98-
group.addTask {
99-
try await withCheckedThrowingContinuation { continuation in
100-
process.terminationHandler = { proc in
101-
stdoutPipe.fileHandleForReading.readabilityHandler = nil
102-
stderrPipe.fileHandleForReading.readabilityHandler = nil
103-
104-
let remOut = stdoutPipe.fileHandleForReading.readDataToEndOfFile()
105-
let remErr = stderrPipe.fileHandleForReading.readDataToEndOfFile()
106-
if !remOut.isEmpty { state.appendStdout(remOut) }
107-
if !remErr.isEmpty { state.appendStderr(remErr) }
108-
109-
let result = state.finish(process: proc)
110-
state.resumeOnce(continuation: continuation, result: .success(result))
111-
}
112-
113-
do {
114-
try process.run()
115-
} catch {
116-
stdoutPipe.fileHandleForReading.readabilityHandler = nil
117-
stderrPipe.fileHandleForReading.readabilityHandler = nil
118-
state.resumeOnce(
119-
continuation: continuation,
120-
result: .failure(CommandRunnerError.invalidExecutable)
121-
)
122-
}
117+
let resume = ResumeOnce()
118+
return try await withThrowingTaskGroup(of: Int32.self) { group in
119+
group.addTask {
120+
try await withCheckedThrowingContinuation { continuation in
121+
process.terminationHandler = { proc in
122+
resume.resume(continuation, .success(proc.terminationStatus))
123123
}
124-
}
125-
126-
group.addTask {
127-
try await Task.sleep(for: timeout)
128-
if process.isRunning {
129-
process.terminate()
124+
do {
125+
try process.run()
126+
} catch {
127+
resume.resume(continuation, .failure(CommandRunnerError.invalidExecutable))
130128
}
131-
throw CommandRunnerError.timeout
132129
}
130+
}
133131

134-
guard let result = try await group.next() else {
135-
throw CommandRunnerError.invalidExecutable
132+
group.addTask {
133+
try await Task.sleep(for: timeout)
134+
if process.isRunning {
135+
process.terminate()
136136
}
137-
138-
group.cancelAll()
139-
return result
137+
throw CommandRunnerError.timeout
140138
}
141-
} onCancel: {
142-
if process.isRunning {
143-
process.terminate()
139+
140+
guard let code = try await group.next() else {
141+
throw CommandRunnerError.invalidExecutable
144142
}
143+
group.cancelAll()
144+
return code
145145
}
146146
}
147147

0 commit comments

Comments
 (0)