Skip to content

Commit e626b7e

Browse files
authored
improve process state management (#203)
1 parent ab6339b commit e626b7e

File tree

2 files changed

+141
-82
lines changed

2 files changed

+141
-82
lines changed

Sources/TSCBasic/Process.swift

Lines changed: 140 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -178,12 +178,20 @@ public final class Process: ObjectIdentifierProtocol {
178178
}
179179
}
180180

181+
// process execution mutable state
182+
private enum State {
183+
case idle
184+
case readingOutput(stdout: Thread, stderr: Thread?)
185+
case outputReady(stdout: Result<[UInt8], Swift.Error>, stderr: Result<[UInt8], Swift.Error>)
186+
case complete(ProcessResult)
187+
}
188+
181189
/// Typealias for process id type.
182-
#if !os(Windows)
190+
#if !os(Windows)
183191
public typealias ProcessID = pid_t
184-
#else
192+
#else
185193
public typealias ProcessID = DWORD
186-
#endif
194+
#endif
187195

188196
/// Typealias for stdout/stderr output closure.
189197
public typealias OutputClosure = ([UInt8]) -> Void
@@ -210,45 +218,47 @@ public final class Process: ObjectIdentifierProtocol {
210218
public let workingDirectory: AbsolutePath?
211219

212220
/// The process id of the spawned process, available after the process is launched.
213-
#if os(Windows)
221+
#if os(Windows)
214222
private var _process: Foundation.Process?
215223
public var processID: ProcessID {
216224
return DWORD(_process?.processIdentifier ?? 0)
217225
}
218-
#else
226+
#else
219227
public private(set) var processID = ProcessID()
220-
#endif
228+
#endif
229+
230+
// process execution mutable state
231+
private var state: State = .idle
221232

222-
/// If the subprocess has launched.
223-
/// Note: This property is not protected by the serial queue because it is only mutated in `launch()`, which will be
224-
/// called only once.
225-
public private(set) var launched = false
233+
/// Lock to protect execution state
234+
private let stateLock = Lock()
226235

227236
/// The result of the process execution. Available after process is terminated.
237+
/// This will block while the process is running, as such equivalent to `waitUntilExit`
238+
@available(*, deprecated, message: "use waitUntilExit instead")
228239
public var result: ProcessResult? {
229-
return self.serialQueue.sync {
230-
self._result
240+
return self.stateLock.withLock {
241+
switch self.state {
242+
case .complete(let result):
243+
return result
244+
default:
245+
return nil
246+
}
231247
}
232248
}
233249

234-
/// How process redirects its output.
235-
public let outputRedirection: OutputRedirection
250+
// ideally we would use the state for this, but we need to access it while the waitForExit is locking state
251+
private var _launched = false
252+
private let launchedLock = Lock()
236253

237-
/// The result of the process execution. Available after process is terminated.
238-
private var _result: ProcessResult?
239-
240-
/// If redirected, stdout result and reference to the thread reading the output.
241-
private var stdout: (result: Result<[UInt8], Swift.Error>, thread: Thread?) = (.success([]), nil)
242-
243-
/// If redirected, stderr result and reference to the thread reading the output.
244-
private var stderr: (result: Result<[UInt8], Swift.Error>, thread: Thread?) = (.success([]), nil)
245-
246-
/// Queue to protect concurrent reads.
247-
private let serialQueue = DispatchQueue(label: "org.swift.swiftpm.process")
254+
public var launched: Bool {
255+
return self.launchedLock.withLock {
256+
return self._launched
257+
}
258+
}
248259

249-
/// Queue to protect reading/writing on map of validated executables.
250-
private static let executablesQueue = DispatchQueue(
251-
label: "org.swift.swiftpm.process.findExecutable")
260+
/// How process redirects its output.
261+
public let outputRedirection: OutputRedirection
252262

253263
/// Indicates if a new progress group is created for the child process.
254264
private let startNewProcessGroup: Bool
@@ -257,7 +267,10 @@ public final class Process: ObjectIdentifierProtocol {
257267
///
258268
/// Key: Executable name or path.
259269
/// Value: Path to the executable, if found.
260-
static private var validatedExecutablesMap = [String: AbsolutePath?]()
270+
private static var validatedExecutablesMap = [String: AbsolutePath?]()
271+
272+
// Lock to protect reading/writing on validatedExecutablesMap.
273+
private static let validatedExecutablesMapLock = Lock()
261274

262275
/// Create a new process instance.
263276
///
@@ -348,7 +361,7 @@ public final class Process: ObjectIdentifierProtocol {
348361
}
349362
// This should cover the most common cases, i.e. when the cache is most helpful.
350363
if workingDirectory == localFileSystem.currentWorkingDirectory {
351-
return Process.executablesQueue.sync {
364+
return Process.validatedExecutablesMapLock.withLock {
352365
if let value = Process.validatedExecutablesMap[program] {
353366
return value
354367
}
@@ -364,10 +377,11 @@ public final class Process: ObjectIdentifierProtocol {
364377
/// Launch the subprocess.
365378
public func launch() throws {
366379
precondition(arguments.count > 0 && !arguments[0].isEmpty, "Need at least one argument to launch the process.")
367-
precondition(!launched, "It is not allowed to launch the same process object again.")
368380

369-
// Set the launch bool to true.
370-
launched = true
381+
self.launchedLock.withLock {
382+
precondition(!self._launched, "It is not allowed to launch the same process object again.")
383+
self._launched = true
384+
}
371385

372386
// Print the arguments if we are verbose.
373387
if self.verbose {
@@ -381,7 +395,7 @@ public final class Process: ObjectIdentifierProtocol {
381395
throw Process.Error.missingExecutableProgram(program: executable)
382396
}
383397

384-
#if os(Windows)
398+
#if os(Windows)
385399
_process = Foundation.Process()
386400
_process?.arguments = Array(arguments.dropFirst()) // Avoid including the executable URL twice.
387401
_process?.executableURL = executablePath.asURL
@@ -409,13 +423,13 @@ public final class Process: ObjectIdentifierProtocol {
409423
}
410424

411425
try _process?.run()
412-
#else
426+
#else
413427
// Initialize the spawn attributes.
414-
#if canImport(Darwin) || os(Android)
428+
#if canImport(Darwin) || os(Android)
415429
var attributes: posix_spawnattr_t? = nil
416-
#else
430+
#else
417431
var attributes = posix_spawnattr_t()
418-
#endif
432+
#endif
419433
posix_spawnattr_init(&attributes)
420434
defer { posix_spawnattr_destroy(&attributes) }
421435

@@ -425,13 +439,13 @@ public final class Process: ObjectIdentifierProtocol {
425439
posix_spawnattr_setsigmask(&attributes, &noSignals)
426440

427441
// Reset all signals to default behavior.
428-
#if os(macOS)
442+
#if os(macOS)
429443
var mostSignals = sigset_t()
430444
sigfillset(&mostSignals)
431445
sigdelset(&mostSignals, SIGKILL)
432446
sigdelset(&mostSignals, SIGSTOP)
433447
posix_spawnattr_setsigdefault(&attributes, &mostSignals)
434-
#else
448+
#else
435449
// On Linux, this can only be used to reset signals that are legal to
436450
// modify, so we have to take care about the set we use.
437451
var mostSignals = sigset_t()
@@ -443,7 +457,7 @@ public final class Process: ObjectIdentifierProtocol {
443457
sigaddset(&mostSignals, i)
444458
}
445459
posix_spawnattr_setsigdefault(&attributes, &mostSignals)
446-
#endif
460+
#endif
447461

448462
// Set the attribute flags.
449463
var flags = POSIX_SPAWN_SETSIGMASK | POSIX_SPAWN_SETSIGDEF
@@ -456,31 +470,31 @@ public final class Process: ObjectIdentifierProtocol {
456470
posix_spawnattr_setflags(&attributes, Int16(flags))
457471

458472
// Setup the file actions.
459-
#if canImport(Darwin) || os(Android)
473+
#if canImport(Darwin) || os(Android)
460474
var fileActions: posix_spawn_file_actions_t? = nil
461-
#else
475+
#else
462476
var fileActions = posix_spawn_file_actions_t()
463-
#endif
477+
#endif
464478
posix_spawn_file_actions_init(&fileActions)
465479
defer { posix_spawn_file_actions_destroy(&fileActions) }
466480

467481
if let workingDirectory = workingDirectory?.pathString {
468-
#if os(macOS)
482+
#if os(macOS)
469483
// The only way to set a workingDirectory is using an availability-gated initializer, so we don't need
470484
// to handle the case where the posix_spawn_file_actions_addchdir_np method is unavailable. This check only
471485
// exists here to make the compiler happy.
472486
if #available(macOS 10.15, *) {
473487
posix_spawn_file_actions_addchdir_np(&fileActions, workingDirectory)
474488
}
475-
#elseif os(Linux)
489+
#elseif os(Linux)
476490
guard SPM_posix_spawn_file_actions_addchdir_np_supported() else {
477491
throw Process.Error.workingDirectoryNotSupported
478492
}
479493

480494
SPM_posix_spawn_file_actions_addchdir_np(&fileActions, workingDirectory)
481-
#else
495+
#else
482496
throw Process.Error.workingDirectoryNotSupported
483-
#endif
497+
#endif
484498
}
485499

486500
// Workaround for https://sourceware.org/git/gitweb.cgi?p=glibc.git;h=89e435f3559c53084498e9baad22172b64429362
@@ -534,43 +548,84 @@ public final class Process: ObjectIdentifierProtocol {
534548
throw SystemError.posix_spawn(rv, arguments)
535549
}
536550

537-
if outputRedirection.redirectsOutput {
551+
if !outputRedirection.redirectsOutput {
552+
// no stdout or stderr in this case
553+
self.stateLock.withLock {
554+
self.state = .outputReady(stdout: .success([]), stderr: .success([]))
555+
}
556+
} else {
557+
var outputResult: (stdout: Result<[UInt8], Swift.Error>?, stderr: Result<[UInt8], Swift.Error>?)
558+
let outputResultLock = Lock()
559+
538560
let outputClosures = outputRedirection.outputClosures
539561

540562
// Close the write end of the output pipe.
541563
try close(fd: &outputPipe[1])
542564

543565
// Create a thread and start reading the output on it.
544-
var thread = Thread { [weak self] in
566+
let stdoutThread = Thread { [weak self] in
545567
if let readResult = self?.readOutput(onFD: outputPipe[0], outputClosure: outputClosures?.stdoutClosure) {
546-
self?.stdout.result = readResult
568+
outputResultLock.withLock {
569+
if let stderrResult = outputResult.stderr {
570+
self?.stateLock.withLock {
571+
self?.state = .outputReady(stdout: readResult, stderr: stderrResult)
572+
}
573+
} else {
574+
outputResult.stdout = readResult
575+
}
576+
}
577+
} else if let stderrResult = (outputResultLock.withLock { outputResult.stderr }) {
578+
// TODO: this is more of an error
579+
self?.stateLock.withLock {
580+
self?.state = .outputReady(stdout: .success([]), stderr: stderrResult)
581+
}
547582
}
548583
}
549-
thread.start()
550-
self.stdout.thread = thread
551584

552585
// Only schedule a thread for stderr if no redirect was requested.
586+
var stderrThread: Thread? = nil
553587
if !outputRedirection.redirectStderr {
554588
// Close the write end of the stderr pipe.
555589
try close(fd: &stderrPipe[1])
556590

557591
// Create a thread and start reading the stderr output on it.
558-
thread = Thread { [weak self] in
592+
stderrThread = Thread { [weak self] in
559593
if let readResult = self?.readOutput(onFD: stderrPipe[0], outputClosure: outputClosures?.stderrClosure) {
560-
self?.stderr.result = readResult
594+
outputResultLock.withLock {
595+
if let stdoutResult = outputResult.stdout {
596+
self?.stateLock.withLock {
597+
self?.state = .outputReady(stdout: stdoutResult, stderr: readResult)
598+
}
599+
} else {
600+
outputResult.stderr = readResult
601+
}
602+
}
603+
} else if let stdoutResult = (outputResultLock.withLock { outputResult.stdout }) {
604+
// TODO: this is more of an error
605+
self?.stateLock.withLock {
606+
self?.state = .outputReady(stdout: stdoutResult, stderr: .success([]))
607+
}
561608
}
562609
}
563-
thread.start()
564-
self.stderr.thread = thread
610+
} else {
611+
outputResultLock.withLock {
612+
outputResult.stderr = .success([]) // no stderr in this case
613+
}
614+
}
615+
// first set state then start reading threads
616+
self.stateLock.withLock {
617+
self.state = .readingOutput(stdout: stdoutThread, stderr: stderrThread)
565618
}
619+
stdoutThread.start()
620+
stderrThread?.start()
566621
}
567-
#endif // POSIX implementation
622+
#endif // POSIX implementation
568623
}
569624

570625
/// Blocks the calling process until the subprocess finishes execution.
571626
@discardableResult
572627
public func waitUntilExit() throws -> ProcessResult {
573-
#if os(Windows)
628+
#if os(Windows)
574629
precondition(_process != nil, "The process is not yet launched.")
575630
let p = _process!
576631
p.waitUntilExit()
@@ -585,19 +640,23 @@ public final class Process: ObjectIdentifierProtocol {
585640
stderrOutput: stderr.result
586641
)
587642
return executionResult
588-
#else
589-
return try serialQueue.sync {
590-
precondition(launched, "The process is not yet launched.")
591-
592-
// If the process has already finsihed, return it.
593-
if let existingResult = _result {
594-
return existingResult
595-
}
596-
643+
#else
644+
self.stateLock.lock()
645+
switch self.state {
646+
case .idle:
647+
defer { self.stateLock.unlock() }
648+
preconditionFailure("The process is not yet launched.")
649+
case .complete(let result):
650+
defer { self.stateLock.unlock() }
651+
return result
652+
case .readingOutput(let stdoutThread, let stderrThread):
653+
self.stateLock.unlock() // unlock early since output read thread need to change state
597654
// If we're reading output, make sure that is finished.
598-
stdout.thread?.join()
599-
stderr.thread?.join()
600-
655+
stdoutThread.join()
656+
stderrThread?.join()
657+
return try self.waitUntilExit()
658+
case .outputReady(let stdoutResult, let stderrResult):
659+
defer { self.stateLock.unlock() }
601660
// Wait until process finishes execution.
602661
var exitStatusCode: Int32 = 0
603662
var result = waitpid(processID, &exitStatusCode, 0)
@@ -613,13 +672,13 @@ public final class Process: ObjectIdentifierProtocol {
613672
arguments: arguments,
614673
environment: environment,
615674
exitStatusCode: exitStatusCode,
616-
output: stdout.result,
617-
stderrOutput: stderr.result
675+
output: stdoutResult,
676+
stderrOutput: stderrResult
618677
)
619-
self._result = executionResult
678+
self.state = .complete(executionResult)
620679
return executionResult
621680
}
622-
#endif
681+
#endif
623682
}
624683

625684
#if !os(Windows)
@@ -671,16 +730,16 @@ public final class Process: ObjectIdentifierProtocol {
671730
///
672731
/// Note: This will signal all processes in the process group.
673732
public func signal(_ signal: Int32) {
674-
#if os(Windows)
733+
#if os(Windows)
675734
if signal == SIGINT {
676-
_process?.interrupt()
735+
_process?.interrupt()
677736
} else {
678-
_process?.terminate()
737+
_process?.terminate()
679738
}
680-
#else
681-
assert(launched, "The process is not yet launched.")
739+
#else
740+
assert(self.launched, "The process is not yet launched.")
682741
_ = TSCLibc.kill(startNewProcessGroup ? -processID : processID, signal)
683-
#endif
742+
#endif
684743
}
685744
}
686745

0 commit comments

Comments
 (0)