Skip to content

Commit 4542c46

Browse files
authored
Add async version of Process.popen() (#261)
* Add async version of popen() * Add withTemporary{File,Directory} with cleanup block
1 parent 1907218 commit 4542c46

File tree

4 files changed

+179
-52
lines changed

4 files changed

+179
-52
lines changed

Sources/TSCBasic/Process.swift

Lines changed: 71 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -180,10 +180,10 @@ public final class Process {
180180
// process execution mutable state
181181
private enum State {
182182
case idle
183-
case readingOutputThread(stdout: Thread, stderr: Thread?)
184-
case readingOutputPipe(sync: DispatchGroup)
183+
case readingOutput(sync: DispatchGroup)
185184
case outputReady(stdout: Result<[UInt8], Swift.Error>, stderr: Result<[UInt8], Swift.Error>)
186185
case complete(ProcessResult)
186+
case failed(Swift.Error)
187187
}
188188

189189
/// Typealias for process id type.
@@ -230,6 +230,8 @@ public final class Process {
230230
// process execution mutable state
231231
private var state: State = .idle
232232
private let stateLock = Lock()
233+
private static let sharedCompletionQueue = DispatchQueue(label: "org.swift.tools-support-core.process-completion")
234+
private var completionQueue = Process.sharedCompletionQueue
233235

234236
/// The result of the process execution. Available after process is terminated.
235237
/// This will block while the process is awaiting result
@@ -395,13 +397,14 @@ public final class Process {
395397
}
396398

397399
#if os(Windows)
398-
_process = Foundation.Process()
399-
_process?.arguments = Array(arguments.dropFirst()) // Avoid including the executable URL twice.
400-
_process?.executableURL = executablePath.asURL
401-
_process?.environment = environment
400+
let process = Foundation.Process()
401+
_process = process
402+
process.arguments = Array(arguments.dropFirst()) // Avoid including the executable URL twice.
403+
process.executableURL = executablePath.asURL
404+
process.environment = environment
402405

403406
let stdinPipe = Pipe()
404-
_process?.standardInput = stdinPipe
407+
process.standardInput = stdinPipe
405408

406409
let group = DispatchGroup()
407410

@@ -445,25 +448,25 @@ public final class Process {
445448
}
446449
}
447450

448-
_process?.standardOutput = stdoutPipe
449-
_process?.standardError = stderrPipe
451+
process.standardOutput = stdoutPipe
452+
process.standardError = stderrPipe
450453
}
451454

452455
// first set state then start reading threads
453456
let sync = DispatchGroup()
454457
sync.enter()
455458
self.stateLock.withLock {
456-
self.state = .readingOutputPipe(sync: sync)
459+
self.state = .readingOutput(sync: sync)
457460
}
458461

459-
group.notify(queue: .global()) {
462+
group.notify(queue: self.completionQueue) {
460463
self.stateLock.withLock {
461464
self.state = .outputReady(stdout: .success(stdout), stderr: .success(stderr))
462465
}
463466
sync.leave()
464467
}
465468

466-
try _process?.run()
469+
try process.run()
467470
return stdinPipe.fileHandleForWriting
468471
#elseif (!canImport(Darwin) || os(macOS))
469472
// Initialize the spawn attributes.
@@ -596,6 +599,7 @@ public final class Process {
596599
// Close the local read end of the input pipe.
597600
try close(fd: stdinPipe[0])
598601

602+
let group = DispatchGroup()
599603
if !outputRedirection.redirectsOutput {
600604
// no stdout or stderr in this case
601605
self.stateLock.withLock {
@@ -611,6 +615,7 @@ public final class Process {
611615
try close(fd: outputPipe[1])
612616

613617
// Create a thread and start reading the output on it.
618+
group.enter()
614619
let stdoutThread = Thread { [weak self] in
615620
if let readResult = self?.readOutput(onFD: outputPipe[0], outputClosure: outputClosures?.stdoutClosure) {
616621
pendingLock.withLock {
@@ -622,11 +627,13 @@ public final class Process {
622627
pending = readResult
623628
}
624629
}
630+
group.leave()
625631
} else if let stderrResult = (pendingLock.withLock { pending }) {
626632
// TODO: this is more of an error
627633
self?.stateLock.withLock {
628634
self?.state = .outputReady(stdout: .success([]), stderr: stderrResult)
629635
}
636+
group.leave()
630637
}
631638
}
632639

@@ -637,6 +644,7 @@ public final class Process {
637644
try close(fd: stderrPipe[1])
638645

639646
// Create a thread and start reading the stderr output on it.
647+
group.enter()
640648
stderrThread = Thread { [weak self] in
641649
if let readResult = self?.readOutput(onFD: stderrPipe[0], outputClosure: outputClosures?.stderrClosure) {
642650
pendingLock.withLock {
@@ -648,22 +656,26 @@ public final class Process {
648656
pending = readResult
649657
}
650658
}
659+
group.leave()
651660
} else if let stdoutResult = (pendingLock.withLock { pending }) {
652661
// TODO: this is more of an error
653662
self?.stateLock.withLock {
654663
self?.state = .outputReady(stdout: stdoutResult, stderr: .success([]))
655664
}
665+
group.leave()
656666
}
657667
}
658668
} else {
659669
pendingLock.withLock {
660670
pending = .success([]) // no stderr in this case
661671
}
662672
}
673+
663674
// first set state then start reading threads
664675
self.stateLock.withLock {
665-
self.state = .readingOutputThread(stdout: stdoutThread, stderr: stderrThread)
676+
self.state = .readingOutput(sync: group)
666677
}
678+
667679
stdoutThread.start()
668680
stderrThread?.start()
669681
}
@@ -677,24 +689,35 @@ public final class Process {
677689
/// Blocks the calling process until the subprocess finishes execution.
678690
@discardableResult
679691
public func waitUntilExit() throws -> ProcessResult {
692+
let group = DispatchGroup()
693+
group.enter()
694+
var processResult : Result<ProcessResult, Swift.Error>?
695+
self.waitUntilExit() { result in
696+
processResult = result
697+
group.leave()
698+
}
699+
group.wait()
700+
return try processResult.unsafelyUnwrapped.get()
701+
}
702+
703+
/// Executes the process I/O state machine, calling completion block when finished.
704+
private func waitUntilExit(_ completion: @escaping (Result<ProcessResult, Swift.Error>) -> Void) {
680705
self.stateLock.lock()
681706
switch self.state {
682707
case .idle:
683708
defer { self.stateLock.unlock() }
684709
preconditionFailure("The process is not yet launched.")
685710
case .complete(let result):
686-
defer { self.stateLock.unlock() }
687-
return result
688-
case .readingOutputThread(let stdoutThread, let stderrThread):
689-
self.stateLock.unlock() // unlock early since output read thread need to change state
690-
// If we're reading output, make sure that is finished.
691-
stdoutThread.join()
692-
stderrThread?.join()
693-
return try self.waitUntilExit()
694-
case .readingOutputPipe(let sync):
695-
self.stateLock.unlock() // unlock early since output read thread need to change state
696-
sync.wait()
697-
return try self.waitUntilExit()
711+
self.stateLock.unlock()
712+
completion(.success(result))
713+
case .failed(let error):
714+
self.stateLock.unlock()
715+
completion(.failure(error))
716+
case .readingOutput(let sync):
717+
self.stateLock.unlock()
718+
sync.notify(queue: self.completionQueue) {
719+
self.waitUntilExit(completion)
720+
}
698721
case .outputReady(let stdoutResult, let stderrResult):
699722
defer { self.stateLock.unlock() }
700723
// Wait until process finishes execution.
@@ -710,7 +733,7 @@ public final class Process {
710733
result = waitpid(processID, &exitStatusCode, 0)
711734
}
712735
if result == -1 {
713-
throw SystemError.waitpid(errno)
736+
self.state = .failed(SystemError.waitpid(errno))
714737
}
715738
#endif
716739

@@ -723,7 +746,9 @@ public final class Process {
723746
stderrOutput: stderrResult
724747
)
725748
self.state = .complete(executionResult)
726-
return executionResult
749+
self.completionQueue.async {
750+
self.waitUntilExit(completion)
751+
}
727752
}
728753
}
729754

@@ -790,6 +815,25 @@ public final class Process {
790815
}
791816

792817
extension Process {
818+
/// Execute a subprocess and calls completion block when it finishes execution
819+
///
820+
/// - Parameters:
821+
/// - arguments: The arguments for the subprocess.
822+
/// - environment: The environment to pass to subprocess. By default the current process environment
823+
/// will be inherited.
824+
/// - Returns: The process result.
825+
static public func popen(arguments: [String], environment: [String: String] = ProcessEnv.vars,
826+
queue: DispatchQueue? = nil, completion: @escaping (Result<ProcessResult, Swift.Error>) -> Void) {
827+
do {
828+
let process = Process(arguments: arguments, environment: environment, outputRedirection: .collect)
829+
process.completionQueue = queue ?? Self.sharedCompletionQueue
830+
try process.launch()
831+
process.waitUntilExit(completion)
832+
} catch {
833+
completion(.failure(error))
834+
}
835+
}
836+
793837
/// Execute a subprocess and block until it finishes execution
794838
///
795839
/// - Parameters:

Sources/TSCBasic/TemporaryFile.swift

Lines changed: 67 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,8 @@ extension TemporaryFile: CustomStringConvertible {
121121
}
122122

123123
/// Creates a temporary file and evaluates a closure with the temporary file as an argument.
124-
/// The temporary file will live on disk while the closure is evaluated and will be deleted afterwards.
124+
/// The temporary file will live on disk while the closure is evaluated and will be deleted when
125+
/// the cleanup block is called.
125126
///
126127
/// This function is basically a wrapper over posix's mkstemps() function to create disposable files.
127128
///
@@ -131,28 +132,50 @@ extension TemporaryFile: CustomStringConvertible {
131132
/// set, dir will be set to `/tmp/`.
132133
/// - prefix: The prefix to the temporary file name.
133134
/// - suffix: The suffix to the temporary file name.
134-
/// - deleteOnClose: Whether the file should get deleted after the call of `body`
135135
/// - body: A closure to execute that receives the TemporaryFile as an argument.
136136
/// If `body` has a return value, that value is also used as the
137137
/// return value for the `withTemporaryFile` function.
138+
/// The cleanup block should be called when the temporary file is no longer needed.
138139
///
139140
/// - Throws: TempFileError and rethrows all errors from `body`.
140141
public func withTemporaryFile<Result>(
141-
dir: AbsolutePath? = nil, prefix: String = "TemporaryFile", suffix: String = "", deleteOnClose: Bool = true, _ body: (TemporaryFile) throws -> Result
142+
dir: AbsolutePath? = nil, prefix: String = "TemporaryFile", suffix: String = "", _ body: (TemporaryFile, @escaping (TemporaryFile) -> Void) throws -> Result
142143
) throws -> Result {
143-
let tempFile = try TemporaryFile(dir: dir, prefix: prefix, suffix: suffix)
144-
defer {
145-
if deleteOnClose {
144+
return try body(TemporaryFile(dir: dir, prefix: prefix, suffix: suffix)) { tempFile in
146145
#if os(Windows)
147-
_ = tempFile.path.pathString.withCString(encodedAs: UTF16.self) {
148-
_wunlink($0)
149-
}
146+
_ = tempFile.path.pathString.withCString(encodedAs: UTF16.self) {
147+
_wunlink($0)
148+
}
150149
#else
151-
unlink(tempFile.path.pathString)
150+
unlink(tempFile.path.pathString)
152151
#endif
153-
}
154152
}
155-
return try body(tempFile)
153+
}
154+
155+
/// Creates a temporary file and evaluates a closure with the temporary file as an argument.
156+
/// The temporary file will live on disk while the closure is evaluated and will be deleted afterwards.
157+
///
158+
/// This function is basically a wrapper over posix's mkstemps() function to create disposable files.
159+
///
160+
/// - Parameters:
161+
/// - dir: If specified the temporary file will be created in this directory otherwise environment variables
162+
/// TMPDIR, TEMP and TMP will be checked for a value (in that order). If none of the env variables are
163+
/// set, dir will be set to `/tmp/`.
164+
/// - prefix: The prefix to the temporary file name.
165+
/// - suffix: The suffix to the temporary file name.
166+
/// - deleteOnClose: Whether the file should get deleted after the call of `body`
167+
/// - body: A closure to execute that receives the TemporaryFile as an argument.
168+
/// If `body` has a return value, that value is also used as the
169+
/// return value for the `withTemporaryFile` function.
170+
///
171+
/// - Throws: TempFileError and rethrows all errors from `body`.
172+
public func withTemporaryFile<Result>(
173+
dir: AbsolutePath? = nil, prefix: String = "TemporaryFile", suffix: String = "", deleteOnClose: Bool = true, _ body: (TemporaryFile) throws -> Result
174+
) throws -> Result {
175+
try withTemporaryFile(dir: dir, prefix: prefix, suffix: suffix) { tempFile, cleanup in
176+
defer { if (deleteOnClose) { cleanup(tempFile) } }
177+
return try body(tempFile)
178+
}
156179
}
157180

158181
// FIXME: This isn't right place to declare this, probably POSIX or merge with FileSystemError?
@@ -204,7 +227,8 @@ extension MakeDirectoryError: CustomNSError {
204227
}
205228

206229
/// Creates a temporary directory and evaluates a closure with the directory path as an argument.
207-
/// The temporary directory will live on disk while the closure is evaluated and will be deleted afterwards.
230+
/// The temporary directory will live on disk while the closure is evaluated and will be deleted when
231+
/// the cleanup closure is called. This allows the temporary directory to have an arbitrary lifetime.
208232
///
209233
/// This function is basically a wrapper over posix's mkdtemp() function.
210234
///
@@ -213,14 +237,14 @@ extension MakeDirectoryError: CustomNSError {
213237
/// variables TMPDIR, TEMP and TMP will be checked for a value (in that order). If none of the env
214238
/// variables are set, dir will be set to `/tmp/`.
215239
/// - prefix: The prefix to the temporary file name.
216-
/// - removeTreeOnDeinit: If enabled try to delete the whole directory tree otherwise remove only if its empty.
217240
/// - body: A closure to execute that receives the absolute path of the directory as an argument.
218-
/// If `body` has a return value, that value is also used as the
219-
/// return value for the `withTemporaryDirectory` function.
241+
/// If `body` has a return value, that value is also used as the
242+
/// return value for the `withTemporaryDirectory` function.
243+
/// The cleanup block should be called when the temporary directory is no longer needed.
220244
///
221245
/// - Throws: MakeDirectoryError and rethrows all errors from `body`.
222246
public func withTemporaryDirectory<Result>(
223-
dir: AbsolutePath? = nil, prefix: String = "TemporaryDirectory", removeTreeOnDeinit: Bool = false , _ body: (AbsolutePath) throws -> Result
247+
dir: AbsolutePath? = nil, prefix: String = "TemporaryDirectory" , _ body: (AbsolutePath, @escaping (AbsolutePath) -> Void) throws -> Result
224248
) throws -> Result {
225249
// Construct path to the temporary directory.
226250
let templatePath = try determineTempDirectory(dir).appending(RelativePath(prefix + ".XXXXXX"))
@@ -234,13 +258,33 @@ public func withTemporaryDirectory<Result>(
234258
throw MakeDirectoryError(errno: errno)
235259
}
236260

237-
let path = AbsolutePath(String(cString: template))
261+
return try body(AbsolutePath(String(cString: template))) { path in
262+
_ = try? FileManager.default.removeItem(atPath: path.pathString)
263+
}
264+
}
238265

239-
defer {
240-
if removeTreeOnDeinit {
241-
_ = try? FileManager.default.removeItem(atPath: path.pathString)
242-
}
266+
/// Creates a temporary directory and evaluates a closure with the directory path as an argument.
267+
/// The temporary directory will live on disk while the closure is evaluated and will be deleted afterwards.
268+
///
269+
/// This function is basically a wrapper over posix's mkdtemp() function.
270+
///
271+
/// - Parameters:
272+
/// - dir: If specified the temporary directory will be created in this directory otherwise environment
273+
/// variables TMPDIR, TEMP and TMP will be checked for a value (in that order). If none of the env
274+
/// variables are set, dir will be set to `/tmp/`.
275+
/// - prefix: The prefix to the temporary file name.
276+
/// - removeTreeOnDeinit: If enabled try to delete the whole directory tree otherwise remove only if its empty.
277+
/// - body: A closure to execute that receives the absolute path of the directory as an argument.
278+
/// If `body` has a return value, that value is also used as the
279+
/// return value for the `withTemporaryDirectory` function.
280+
///
281+
/// - Throws: MakeDirectoryError and rethrows all errors from `body`.
282+
public func withTemporaryDirectory<Result>(
283+
dir: AbsolutePath? = nil, prefix: String = "TemporaryDirectory", removeTreeOnDeinit: Bool = false , _ body: (AbsolutePath) throws -> Result
284+
) throws -> Result {
285+
try withTemporaryDirectory(dir: dir, prefix: prefix) { path, cleanup in
286+
defer { if removeTreeOnDeinit { cleanup(path) } }
287+
return try body(path)
243288
}
244-
return try body(path)
245289
}
246290

0 commit comments

Comments
 (0)