Skip to content

Commit 7ab1fec

Browse files
committed
improve process state management
motivation: reports on hang process (mostly tests) changes: * refactor Process to use a state machine to track the process execution state * replace use of DispatchQueue with Locks to protect state rdar://76087764
1 parent 4dcf37d commit 7ab1fec

File tree

2 files changed

+139
-82
lines changed

2 files changed

+139
-82
lines changed

Sources/TSCBasic/Process.swift

Lines changed: 138 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(Thread, Thread?)
185+
case outputReady(Result<[UInt8], Swift.Error>, 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,46 @@ 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
228238
public var result: ProcessResult? {
229-
return self.serialQueue.sync {
230-
self._result
239+
return self.stateLock.withLock {
240+
switch self.state {
241+
case .complete(let result):
242+
return result
243+
default:
244+
return nil
245+
}
231246
}
232247
}
233248

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

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")
253+
public var launched: Bool {
254+
return self.launchedLock.withLock {
255+
return self._launched
256+
}
257+
}
248258

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

253262
/// Indicates if a new progress group is created for the child process.
254263
private let startNewProcessGroup: Bool
@@ -257,7 +266,10 @@ public final class Process: ObjectIdentifierProtocol {
257266
///
258267
/// Key: Executable name or path.
259268
/// Value: Path to the executable, if found.
260-
static private var validatedExecutablesMap = [String: AbsolutePath?]()
269+
private static var validatedExecutablesMap = [String: AbsolutePath?]()
270+
271+
// Lock to protect reading/writing on validatedExecutablesMap.
272+
private static let validatedExecutablesMapLock = Lock()
261273

262274
/// Create a new process instance.
263275
///
@@ -316,7 +328,7 @@ public final class Process: ObjectIdentifierProtocol {
316328
///
317329
/// The program can be executable name, relative path or absolute path.
318330
public static func findExecutable(_ program: String) -> AbsolutePath? {
319-
return Process.executablesQueue.sync {
331+
return Process.validatedExecutablesMapLock.withLock {
320332
// Check if we already have a value for the program.
321333
if let value = Process.validatedExecutablesMap[program] {
322334
return value
@@ -337,10 +349,11 @@ public final class Process: ObjectIdentifierProtocol {
337349
/// Launch the subprocess.
338350
public func launch() throws {
339351
precondition(arguments.count > 0 && !arguments[0].isEmpty, "Need at least one argument to launch the process.")
340-
precondition(!launched, "It is not allowed to launch the same process object again.")
341352

342-
// Set the launch bool to true.
343-
launched = true
353+
self.launchedLock.withLock {
354+
precondition(!self._launched, "It is not allowed to launch the same process object again.")
355+
self._launched = true
356+
}
344357

345358
// Print the arguments if we are verbose.
346359
if self.verbose {
@@ -354,7 +367,7 @@ public final class Process: ObjectIdentifierProtocol {
354367
throw Process.Error.missingExecutableProgram(program: executable)
355368
}
356369

357-
#if os(Windows)
370+
#if os(Windows)
358371
_process = Foundation.Process()
359372
_process?.arguments = Array(arguments.dropFirst()) // Avoid including the executable URL twice.
360373
_process?.executableURL = executablePath.asURL
@@ -382,13 +395,13 @@ public final class Process: ObjectIdentifierProtocol {
382395
}
383396

384397
try _process?.run()
385-
#else
398+
#else
386399
// Initialize the spawn attributes.
387-
#if canImport(Darwin) || os(Android)
400+
#if canImport(Darwin) || os(Android)
388401
var attributes: posix_spawnattr_t? = nil
389-
#else
402+
#else
390403
var attributes = posix_spawnattr_t()
391-
#endif
404+
#endif
392405
posix_spawnattr_init(&attributes)
393406
defer { posix_spawnattr_destroy(&attributes) }
394407

@@ -398,13 +411,13 @@ public final class Process: ObjectIdentifierProtocol {
398411
posix_spawnattr_setsigmask(&attributes, &noSignals)
399412

400413
// Reset all signals to default behavior.
401-
#if os(macOS)
414+
#if os(macOS)
402415
var mostSignals = sigset_t()
403416
sigfillset(&mostSignals)
404417
sigdelset(&mostSignals, SIGKILL)
405418
sigdelset(&mostSignals, SIGSTOP)
406419
posix_spawnattr_setsigdefault(&attributes, &mostSignals)
407-
#else
420+
#else
408421
// On Linux, this can only be used to reset signals that are legal to
409422
// modify, so we have to take care about the set we use.
410423
var mostSignals = sigset_t()
@@ -416,7 +429,7 @@ public final class Process: ObjectIdentifierProtocol {
416429
sigaddset(&mostSignals, i)
417430
}
418431
posix_spawnattr_setsigdefault(&attributes, &mostSignals)
419-
#endif
432+
#endif
420433

421434
// Set the attribute flags.
422435
var flags = POSIX_SPAWN_SETSIGMASK | POSIX_SPAWN_SETSIGDEF
@@ -429,31 +442,31 @@ public final class Process: ObjectIdentifierProtocol {
429442
posix_spawnattr_setflags(&attributes, Int16(flags))
430443

431444
// Setup the file actions.
432-
#if canImport(Darwin) || os(Android)
445+
#if canImport(Darwin) || os(Android)
433446
var fileActions: posix_spawn_file_actions_t? = nil
434-
#else
447+
#else
435448
var fileActions = posix_spawn_file_actions_t()
436-
#endif
449+
#endif
437450
posix_spawn_file_actions_init(&fileActions)
438451
defer { posix_spawn_file_actions_destroy(&fileActions) }
439452

440453
if let workingDirectory = workingDirectory?.pathString {
441-
#if os(macOS)
454+
#if os(macOS)
442455
// The only way to set a workingDirectory is using an availability-gated initializer, so we don't need
443456
// to handle the case where the posix_spawn_file_actions_addchdir_np method is unavailable. This check only
444457
// exists here to make the compiler happy.
445458
if #available(macOS 10.15, *) {
446459
posix_spawn_file_actions_addchdir_np(&fileActions, workingDirectory)
447460
}
448-
#elseif os(Linux)
461+
#elseif os(Linux)
449462
guard SPM_posix_spawn_file_actions_addchdir_np_supported() else {
450463
throw Process.Error.workingDirectoryNotSupported
451464
}
452465

453466
SPM_posix_spawn_file_actions_addchdir_np(&fileActions, workingDirectory)
454-
#else
467+
#else
455468
throw Process.Error.workingDirectoryNotSupported
456-
#endif
469+
#endif
457470
}
458471

459472
// Workaround for https://sourceware.org/git/gitweb.cgi?p=glibc.git;h=89e435f3559c53084498e9baad22172b64429362
@@ -503,43 +516,84 @@ public final class Process: ObjectIdentifierProtocol {
503516
throw SystemError.posix_spawn(rv, arguments)
504517
}
505518

506-
if outputRedirection.redirectsOutput {
519+
if !outputRedirection.redirectsOutput {
520+
// no stdout or stderr in this case
521+
self.stateLock.withLock {
522+
self.state = .outputReady(.success([]), .success([]))
523+
}
524+
} else {
525+
var outputResult: (stdout: Result<[UInt8], Swift.Error>?, stderr: Result<[UInt8], Swift.Error>?)
526+
let outputResultLock = Lock()
527+
507528
let outputClosures = outputRedirection.outputClosures
508529

509530
// Close the write end of the output pipe.
510531
try close(fd: &outputPipe[1])
511532

512533
// Create a thread and start reading the output on it.
513-
var thread = Thread { [weak self] in
534+
let stdoutThread = Thread { [weak self] in
514535
if let readResult = self?.readOutput(onFD: outputPipe[0], outputClosure: outputClosures?.stdoutClosure) {
515-
self?.stdout.result = readResult
536+
outputResultLock.withLock {
537+
if let stderrResult = outputResult.stderr {
538+
self?.stateLock.withLock {
539+
self?.state = .outputReady(readResult, stderrResult)
540+
}
541+
} else {
542+
outputResult.stdout = readResult
543+
}
544+
}
545+
} else if let stderrResult = (outputResultLock.withLock { outputResult.stderr }) {
546+
// TODO: this is more of an error
547+
self?.stateLock.withLock {
548+
self?.state = .outputReady(.success([]), stderrResult)
549+
}
516550
}
517551
}
518-
thread.start()
519-
self.stdout.thread = thread
520552

521553
// Only schedule a thread for stderr if no redirect was requested.
554+
var stderrThread: Thread? = nil
522555
if !outputRedirection.redirectStderr {
523556
// Close the write end of the stderr pipe.
524557
try close(fd: &stderrPipe[1])
525558

526559
// Create a thread and start reading the stderr output on it.
527-
thread = Thread { [weak self] in
560+
stderrThread = Thread { [weak self] in
528561
if let readResult = self?.readOutput(onFD: stderrPipe[0], outputClosure: outputClosures?.stderrClosure) {
529-
self?.stderr.result = readResult
562+
outputResultLock.withLock {
563+
if let stdoutResult = outputResult.stdout {
564+
self?.stateLock.withLock {
565+
self?.state = .outputReady(stdoutResult, readResult)
566+
}
567+
} else {
568+
outputResult.stderr = readResult
569+
}
570+
}
571+
} else if let stdoutResult = (outputResultLock.withLock { outputResult.stdout }) {
572+
// TODO: this is more of an error
573+
self?.stateLock.withLock {
574+
self?.state = .outputReady(stdoutResult, .success([]))
575+
}
530576
}
531577
}
532-
thread.start()
533-
self.stderr.thread = thread
578+
} else {
579+
outputResultLock.withLock {
580+
outputResult.stderr = .success([]) // no stderr in this case
581+
}
582+
}
583+
// first set state then start reading threads
584+
self.stateLock.withLock {
585+
self.state = .readingOutput(stdoutThread, stderrThread)
534586
}
587+
stdoutThread.start()
588+
stderrThread?.start()
535589
}
536-
#endif // POSIX implementation
590+
#endif // POSIX implementation
537591
}
538592

539593
/// Blocks the calling process until the subprocess finishes execution.
540594
@discardableResult
541595
public func waitUntilExit() throws -> ProcessResult {
542-
#if os(Windows)
596+
#if os(Windows)
543597
precondition(_process != nil, "The process is not yet launched.")
544598
let p = _process!
545599
p.waitUntilExit()
@@ -554,19 +608,22 @@ public final class Process: ObjectIdentifierProtocol {
554608
stderrOutput: stderr.result
555609
)
556610
return executionResult
557-
#else
558-
return try serialQueue.sync {
559-
precondition(launched, "The process is not yet launched.")
560-
561-
// If the process has already finsihed, return it.
562-
if let existingResult = _result {
563-
return existingResult
564-
}
565-
611+
#else
612+
self.stateLock.lock()
613+
switch self.state {
614+
case .idle:
615+
defer { self.stateLock.unlock() }
616+
preconditionFailure("The process is not yet launched.")
617+
case .complete(let result):
618+
return result
619+
case .readingOutput(let stdoutThread, let stderrThread):
620+
self.stateLock.unlock() // unlock early since output read thread need to change state
566621
// If we're reading output, make sure that is finished.
567-
stdout.thread?.join()
568-
stderr.thread?.join()
569-
622+
stdoutThread.join()
623+
stderrThread?.join()
624+
return try self.waitUntilExit()
625+
case .outputReady(let stdoutResult, let stderrResult):
626+
defer { self.stateLock.unlock() }
570627
// Wait until process finishes execution.
571628
var exitStatusCode: Int32 = 0
572629
var result = waitpid(processID, &exitStatusCode, 0)
@@ -582,13 +639,13 @@ public final class Process: ObjectIdentifierProtocol {
582639
arguments: arguments,
583640
environment: environment,
584641
exitStatusCode: exitStatusCode,
585-
output: stdout.result,
586-
stderrOutput: stderr.result
642+
output: stdoutResult,
643+
stderrOutput: stderrResult
587644
)
588-
self._result = executionResult
645+
self.state = .complete(executionResult)
589646
return executionResult
590647
}
591-
#endif
648+
#endif
592649
}
593650

594651
#if !os(Windows)
@@ -640,16 +697,16 @@ public final class Process: ObjectIdentifierProtocol {
640697
///
641698
/// Note: This will signal all processes in the process group.
642699
public func signal(_ signal: Int32) {
643-
#if os(Windows)
700+
#if os(Windows)
644701
if signal == SIGINT {
645-
_process?.interrupt()
702+
_process?.interrupt()
646703
} else {
647-
_process?.terminate()
704+
_process?.terminate()
648705
}
649-
#else
650-
assert(launched, "The process is not yet launched.")
706+
#else
707+
assert(self.launched, "The process is not yet launched.")
651708
_ = TSCLibc.kill(startNewProcessGroup ? -processID : processID, signal)
652-
#endif
709+
#endif
653710
}
654711
}
655712

0 commit comments

Comments
 (0)