@@ -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