diff --git a/Sources/TSCBasic/Await.swift b/Sources/TSCBasic/Await.swift index 04c69578..1ee921e4 100644 --- a/Sources/TSCBasic/Await.swift +++ b/Sources/TSCBasic/Await.swift @@ -14,10 +14,16 @@ /// should be passed to the async method's completion handler. /// - Returns: The value wrapped by the async method's result. /// - Throws: The error wrapped by the async method's result +#if compiler(>=5.8) +@available(*, noasync) +#endif public func tsc_await(_ body: (@escaping (Result) -> Void) -> Void) throws -> T { return try tsc_await(body).get() } +#if compiler(>=5.8) +@available(*, noasync) +#endif public func tsc_await(_ body: (@escaping (T) -> Void) -> Void) -> T { let condition = Condition() var result: T? = nil diff --git a/Sources/TSCBasic/Process.swift b/Sources/TSCBasic/Process.swift index 57b00be0..6242106f 100644 --- a/Sources/TSCBasic/Process.swift +++ b/Sources/TSCBasic/Process.swift @@ -21,10 +21,12 @@ import Foundation import TSCLibc import Dispatch +import _Concurrency + /// Process result data which is available after process termination. -public struct ProcessResult: CustomStringConvertible { +public struct ProcessResult: CustomStringConvertible, Sendable { - public enum Error: Swift.Error { + public enum Error: Swift.Error, Sendable { /// The output is not a valid UTF8 sequence. case illegalUTF8Sequence @@ -32,7 +34,7 @@ public struct ProcessResult: CustomStringConvertible { case nonZeroExit(ProcessResult) } - public enum ExitStatus: Equatable { + public enum ExitStatus: Equatable, Sendable { /// The process was terminated normally with a exit code. case terminated(code: Int32) #if os(Windows) @@ -125,12 +127,18 @@ public struct ProcessResult: CustomStringConvertible { } } +#if swift(<5.6) +extension Process: UnsafeSendable {} +#else +extension Process: @unchecked Sendable {} +#endif + /// Process allows spawning new subprocesses and working with them. /// /// Note: This class is thread safe. public final class Process { /// Errors when attempting to invoke a process - public enum Error: Swift.Error { + public enum Error: Swift.Error, Sendable { /// The program requested to be executed cannot be found on the existing search paths, or is not executable. case missingExecutableProgram(program: String) @@ -807,7 +815,29 @@ public final class Process { #endif // POSIX implementation } + /// Executes the process I/O state machine, returning the result when finished. + @available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) + @discardableResult + public func waitUntilExit() async throws -> ProcessResult { + #if compiler(>=5.6) + return try await withCheckedThrowingContinuation { continuation in + waitUntilExit(continuation.resume(with:)) + } + #else + if #available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) { + return try await withCheckedThrowingContinuation { continuation in + waitUntilExit(continuation.resume(with:)) + } + } else { + preconditionFailure("Unsupported with Swift 5.5 on this OS version") + } + #endif + } + /// Blocks the calling process until the subprocess finishes execution. + #if compiler(>=5.8) + @available(*, noasync) + #endif @discardableResult public func waitUntilExit() throws -> ProcessResult { let group = DispatchGroup() @@ -938,6 +968,88 @@ public final class Process { } } +extension Process { + /// Execute a subprocess and returns the result when it finishes execution + /// + /// - Parameters: + /// - arguments: The arguments for the subprocess. + /// - environment: The environment to pass to subprocess. By default the current process environment + /// will be inherited. + /// - loggingHandler: Handler for logging messages + @available(macOS 10.15, *) + static public func popen( + arguments: [String], + environment: [String: String] = ProcessEnv.vars, + loggingHandler: LoggingHandler? = .none + ) async throws -> ProcessResult { + let process = Process( + arguments: arguments, + environment: environment, + outputRedirection: .collect, + loggingHandler: loggingHandler + ) + try process.launch() + return try await process.waitUntilExit() + } + + /// Execute a subprocess and returns the result when it finishes execution + /// + /// - Parameters: + /// - args: The arguments for the subprocess. + /// - environment: The environment to pass to subprocess. By default the current process environment + /// will be inherited. + /// - loggingHandler: Handler for logging messages + @available(macOS 10.15, *) + static public func popen( + args: String..., + environment: [String: String] = ProcessEnv.vars, + loggingHandler: LoggingHandler? = .none + ) async throws -> ProcessResult { + try await popen(arguments: args, environment: environment, loggingHandler: loggingHandler) + } + + /// Execute a subprocess and get its (UTF-8) output if it has a non zero exit. + /// + /// - Parameters: + /// - arguments: The arguments for the subprocess. + /// - environment: The environment to pass to subprocess. By default the current process environment + /// will be inherited. + /// - loggingHandler: Handler for logging messages + /// - Returns: The process output (stdout + stderr). + @available(macOS 10.15, *) + @discardableResult + static public func checkNonZeroExit( + arguments: [String], + environment: [String: String] = ProcessEnv.vars, + loggingHandler: LoggingHandler? = .none + ) async throws -> String { + let result = try await popen(arguments: arguments, environment: environment, loggingHandler: loggingHandler) + // Throw if there was a non zero termination. + guard result.exitStatus == .terminated(code: 0) else { + throw ProcessResult.Error.nonZeroExit(result) + } + return try result.utf8Output() + } + + /// Execute a subprocess and get its (UTF-8) output if it has a non zero exit. + /// + /// - Parameters: + /// - args: The arguments for the subprocess. + /// - environment: The environment to pass to subprocess. By default the current process environment + /// will be inherited. + /// - loggingHandler: Handler for logging messages + /// - Returns: The process output (stdout + stderr). + @available(macOS 10.15, *) + @discardableResult + static public func checkNonZeroExit( + args: String..., + environment: [String: String] = ProcessEnv.vars, + loggingHandler: LoggingHandler? = .none + ) async throws -> String { + try await checkNonZeroExit(arguments: args, environment: environment, loggingHandler: loggingHandler) + } +} + extension Process { /// Execute a subprocess and calls completion block when it finishes execution /// @@ -948,6 +1060,9 @@ extension Process { /// - loggingHandler: Handler for logging messages /// - queue: Queue to use for callbacks /// - completion: A completion handler to return the process result + #if compiler(>=5.8) + @available(*, noasync) + #endif static public func popen( arguments: [String], environment: [String: String] = ProcessEnv.vars, @@ -982,6 +1097,9 @@ extension Process { /// will be inherited. /// - loggingHandler: Handler for logging messages /// - Returns: The process result. + #if compiler(>=5.8) + @available(*, noasync) + #endif @discardableResult static public func popen( arguments: [String], @@ -1006,6 +1124,9 @@ extension Process { /// will be inherited. /// - loggingHandler: Handler for logging messages /// - Returns: The process result. + #if compiler(>=5.8) + @available(*, noasync) + #endif @discardableResult static public func popen( args: String..., @@ -1023,6 +1144,9 @@ extension Process { /// will be inherited. /// - loggingHandler: Handler for logging messages /// - Returns: The process output (stdout + stderr). + #if compiler(>=5.8) + @available(*, noasync) + #endif @discardableResult static public func checkNonZeroExit( arguments: [String], @@ -1052,6 +1176,9 @@ extension Process { /// will be inherited. /// - loggingHandler: Handler for logging messages /// - Returns: The process output (stdout + stderr). + #if compiler(>=5.8) + @available(*, noasync) + #endif @discardableResult static public func checkNonZeroExit( args: String..., diff --git a/Sources/TSCBasic/ProcessSet.swift b/Sources/TSCBasic/ProcessSet.swift index cbb6f2b9..2b519144 100644 --- a/Sources/TSCBasic/ProcessSet.swift +++ b/Sources/TSCBasic/ProcessSet.swift @@ -64,6 +64,9 @@ public final class ProcessSet { /// Terminate all the processes. This method blocks until all processes in the set are terminated. /// /// A process set cannot be used once it has been asked to terminate. + #if compiler(>=5.8) + @available(*, noasync) + #endif public func terminate() { // Mark a process set as cancelled. serialQueue.sync { diff --git a/Sources/TSCBasic/WritableByteStream.swift b/Sources/TSCBasic/WritableByteStream.swift index 1de33d31..0a5d63c2 100644 --- a/Sources/TSCBasic/WritableByteStream.swift +++ b/Sources/TSCBasic/WritableByteStream.swift @@ -349,7 +349,7 @@ public final class ThreadSafeOutputByteStream: WritableByteStream { } -#if swift(<5.7) +#if swift(<5.6) extension ThreadSafeOutputByteStream: UnsafeSendable {} #else extension ThreadSafeOutputByteStream: @unchecked Sendable {} diff --git a/Tests/TSCBasicTests/ProcessTests.swift b/Tests/TSCBasicTests/ProcessTests.swift index 8f96ea17..7ffc680d 100644 --- a/Tests/TSCBasicTests/ProcessTests.swift +++ b/Tests/TSCBasicTests/ProcessTests.swift @@ -62,7 +62,7 @@ class ProcessTests: XCTestCase { } } - func testPopenAsync() throws { + func testPopenLegacyAsync() throws { #if os(Windows) let args = ["where.exe", "where"] let answer = "C:\\Windows\\System32\\where.exe" @@ -89,6 +89,25 @@ class ProcessTests: XCTestCase { } } + func testPopenAsync() async throws { + #if os(Windows) + let args = ["where.exe", "where"] + let answer = "C:\\Windows\\System32\\where.exe" + #else + let args = ["whoami"] + let answer = NSUserName() + #endif + let processResult: ProcessResult + do { + processResult = try await Process.popen(arguments: args) + } catch let error { + XCTFail("error = \(error)") + return + } + let output = try processResult.utf8Output() + XCTAssertTrue(output.hasPrefix(answer)) + } + func testCheckNonZeroExit() throws { do { let output = try Process.checkNonZeroExit(args: "echo", "hello") @@ -103,6 +122,20 @@ class ProcessTests: XCTestCase { } } + func testCheckNonZeroExitAsync() async throws { + do { + let output = try await Process.checkNonZeroExit(args: "echo", "hello") + XCTAssertEqual(output, "hello\n") + } + + do { + let output = try await Process.checkNonZeroExit(scriptName: "exit4") + XCTFail("Unexpected success \(output)") + } catch ProcessResult.Error.nonZeroExit(let result) { + XCTAssertEqual(result.exitStatus, .terminated(code: 4)) + } + } + func testFindExecutable() throws { try testWithTemporaryDirectory { tmpdir in // This process should always work. @@ -249,6 +282,26 @@ class ProcessTests: XCTestCase { XCTAssertEqual(result2, "hello\n") } + @available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) + func testThreadSafetyOnWaitUntilExitAsync() async throws { + let process = Process(args: "echo", "hello") + try process.launch() + + let t1 = Task { + try await process.waitUntilExit().utf8Output() + } + + let t2 = Task { + try await process.waitUntilExit().utf8Output() + } + + let result1 = try await t1.value + let result2 = try await t2.value + + XCTAssertEqual(result1, "hello\n") + XCTAssertEqual(result2, "hello\n") + } + func testStdin() throws { var stdout = [UInt8]() let process = Process(scriptName: "in-to-out", outputRedirection: .stream(stdout: { stdoutBytes in @@ -291,6 +344,31 @@ class ProcessTests: XCTestCase { } } + func testStdoutStdErrAsync() async throws { + // A simple script to check that stdout and stderr are captured separatly. + do { + let result = try await Process.popen(scriptName: "simple-stdout-stderr") + XCTAssertEqual(try result.utf8Output(), "simple output\n") + XCTAssertEqual(try result.utf8stderrOutput(), "simple error\n") + } + + // A long stdout and stderr output. + do { + let result = try await Process.popen(scriptName: "long-stdout-stderr") + let count = 16 * 1024 + XCTAssertEqual(try result.utf8Output(), String(repeating: "1", count: count)) + XCTAssertEqual(try result.utf8stderrOutput(), String(repeating: "2", count: count)) + } + + // This script will block if the streams are not read. + do { + let result = try await Process.popen(scriptName: "deadlock-if-blocking-io") + let count = 16 * 1024 + XCTAssertEqual(try result.utf8Output(), String(repeating: "1", count: count)) + XCTAssertEqual(try result.utf8stderrOutput(), String(repeating: "2", count: count)) + } + } + func testStdoutStdErrRedirected() throws { // A simple script to check that stdout and stderr are captured in the same location. do { @@ -401,6 +479,9 @@ fileprivate extension Process { self.init(arguments: [Self.script(scriptName)] + arguments, environment: Self.env(), outputRedirection: outputRedirection) } + #if compiler(>=5.8) + @available(*, noasync) + #endif static func checkNonZeroExit( scriptName: String, environment: [String: String] = ProcessEnv.vars, @@ -409,6 +490,17 @@ fileprivate extension Process { return try checkNonZeroExit(args: script(scriptName), environment: environment, loggingHandler: loggingHandler) } + static func checkNonZeroExit( + scriptName: String, + environment: [String: String] = ProcessEnv.vars, + loggingHandler: LoggingHandler? = .none + ) async throws -> String { + return try await checkNonZeroExit(args: script(scriptName), environment: environment, loggingHandler: loggingHandler) + } + + #if compiler(>=5.8) + @available(*, noasync) + #endif @discardableResult static func popen( scriptName: String, @@ -417,4 +509,13 @@ fileprivate extension Process { ) throws -> ProcessResult { return try popen(arguments: [script(scriptName)], environment: Self.env(), loggingHandler: loggingHandler) } + + @discardableResult + static func popen( + scriptName: String, + environment: [String: String] = ProcessEnv.vars, + loggingHandler: LoggingHandler? = .none + ) async throws -> ProcessResult { + return try await popen(arguments: [script(scriptName)], environment: Self.env(), loggingHandler: loggingHandler) + } }