diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index 9f46603..30b589c 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -9,10 +9,10 @@ jobs: name: Test uses: swiftlang/github-workflows/.github/workflows/swift_package_test.yml@main with: - linux_os_versions: '["amazonlinux2", "bookworm", "noble", "jammy", "focal", "rhel-ubi9"]' + linux_os_versions: '["amazonlinux2", "bookworm", "noble", "jammy", "rhel-ubi9"]' linux_swift_versions: '["6.1", "nightly-main"]' linux_pre_build_command: | - if command -v apt-get >/dev/null 2>&1 ; then # bookworm, noble, jammy, focal + if command -v apt-get >/dev/null 2>&1 ; then # bookworm, noble, jammy apt-get update -y # Test dependencies diff --git a/Sources/Subprocess/API.swift b/Sources/Subprocess/API.swift index f70c679..7c74a76 100644 --- a/Sources/Subprocess/API.swift +++ b/Sources/Subprocess/API.swift @@ -32,7 +32,7 @@ public func run< Input: InputProtocol, Output: OutputProtocol, - Error: OutputProtocol + Error: ErrorOutputProtocol >( _ executable: Executable, arguments: Arguments = [], @@ -74,7 +74,7 @@ public func run< public func run< InputElement: BitwiseCopyable, Output: OutputProtocol, - Error: OutputProtocol + Error: ErrorOutputProtocol >( _ executable: Executable, arguments: Arguments = [], @@ -117,7 +117,7 @@ public func run< /// - isolation: the isolation context to run the body closure. /// - body: The custom execution body to manually control the running process /// - Returns: an `ExecutableResult` type containing the return value of the closure. -public func run( +public func run( _ executable: Executable, arguments: Arguments = [], environment: Environment = .inherit, @@ -164,7 +164,7 @@ public func run( +public func run( _ executable: Executable, arguments: Arguments = [], environment: Environment = .inherit, @@ -257,7 +257,7 @@ public func run( /// - isolation: the isolation context to run the body closure. /// - body: The custom execution body to manually control the running process /// - Returns: An `ExecutableResult` type containing the return value of the closure. -public func run( +public func run( _ executable: Executable, arguments: Arguments = [], environment: Environment = .inherit, @@ -391,7 +391,7 @@ public func run( public func run< InputElement: BitwiseCopyable, Output: OutputProtocol, - Error: OutputProtocol + Error: ErrorOutputProtocol >( _ configuration: Configuration, input: borrowing Span, @@ -451,7 +451,7 @@ public func run< public func run< Input: InputProtocol, Output: OutputProtocol, - Error: OutputProtocol + Error: ErrorOutputProtocol >( _ configuration: Configuration, input: Input = .none, @@ -463,10 +463,13 @@ public func run< standardOutput: Output.OutputType, standardError: Error.OutputType ) + let inputPipe = try input.createPipe() + let outputPipe = try output.createPipe() + let errorPipe = try error.createPipe(from: outputPipe) let result = try await configuration.run( - input: try input.createPipe(), - output: try output.createPipe(), - error: try error.createPipe() + input: inputPipe, + output: outputPipe, + error: errorPipe ) { (execution, inputIO, outputIO, errorIO) -> RunResult in // Write input, capture output and error in parallel var inputIOBox: IOChannel? = consume inputIO @@ -540,7 +543,7 @@ public func run< /// - body: The custom execution body to manually control the running process /// - Returns an executableResult type containing the return value /// of the closure. -public func run( +public func run( _ configuration: Configuration, input: Input = .none, output: Output = .discarded, @@ -548,10 +551,13 @@ public func run Result) ) async throws -> ExecutionResult where Error.OutputType == Void { + let inputPipe = try input.createPipe() + let outputPipe = try output.createPipe() + let errorPipe = try error.createPipe(from: outputPipe) return try await configuration.run( - input: try input.createPipe(), - output: try output.createPipe(), - error: try error.createPipe() + input: inputPipe, + output: outputPipe, + error: errorPipe ) { execution, inputIO, outputIO, errorIO in var inputIOBox: IOChannel? = consume inputIO return try await withThrowingTaskGroup( @@ -590,7 +596,7 @@ public func run( +public func run( _ configuration: Configuration, input: Input = .none, error: Error = .discarded, @@ -599,10 +605,13 @@ public func run( body: ((Execution, AsyncBufferSequence) async throws -> Result) ) async throws -> ExecutionResult where Error.OutputType == Void { let output = SequenceOutput() + let inputPipe = try input.createPipe() + let outputPipe = try output.createPipe() + let errorPipe = try error.createPipe(from: outputPipe) return try await configuration.run( - input: try input.createPipe(), - output: try output.createPipe(), - error: try error.createPipe() + input: inputPipe, + output: outputPipe, + error: errorPipe ) { execution, inputIO, outputIO, errorIO in var inputIOBox: IOChannel? = consume inputIO var outputIOBox: IOChannel? = consume outputIO @@ -702,7 +711,7 @@ public func run( /// - body: The custom execution body to manually control the running process /// - Returns an executableResult type containing the return value /// of the closure. -public func run( +public func run( _ configuration: Configuration, error: Error = .discarded, preferredBufferSize: Int? = nil, @@ -711,10 +720,13 @@ public func run( ) async throws -> ExecutionResult where Error.OutputType == Void { let input = CustomWriteInput() let output = SequenceOutput() + let inputPipe = try input.createPipe() + let outputPipe = try output.createPipe() + let errorPipe = try error.createPipe(from: outputPipe) return try await configuration.run( - input: try input.createPipe(), - output: try output.createPipe(), - error: try error.createPipe() + input: inputPipe, + output: outputPipe, + error: errorPipe ) { execution, inputIO, outputIO, errorIO in let writer = StandardInputWriter(diskIO: inputIO!) let outputSequence = AsyncBufferSequence( diff --git a/Sources/Subprocess/Configuration.swift b/Sources/Subprocess/Configuration.swift index de019e4..fe3be73 100644 --- a/Sources/Subprocess/Configuration.swift +++ b/Sources/Subprocess/Configuration.swift @@ -811,6 +811,19 @@ internal struct IODescriptor: ~Copyable { self.closeWhenDone = closeWhenDone } + internal init?(duplicating ioDescriptor: borrowing IODescriptor?) throws { + let descriptor = try ioDescriptor?.duplicate() + if let descriptor { + self = descriptor + } else { + return nil + } + } + + func duplicate() throws -> IODescriptor { + return try IODescriptor(self.descriptor.duplicate(), closeWhenDone: self.closeWhenDone) + } + consuming func createIOChannel() -> IOChannel { let shouldClose = self.closeWhenDone self.closeWhenDone = false @@ -962,6 +975,13 @@ internal struct CreatedPipe: ~Copyable { return self._writeFileDescriptor.take() } + internal init(duplicating createdPipe: borrowing CreatedPipe) throws { + self.init( + readFileDescriptor: try IODescriptor(duplicating: createdPipe._readFileDescriptor), + writeFileDescriptor: try IODescriptor(duplicating: createdPipe._writeFileDescriptor) + ) + } + internal init(closeWhenDone: Bool, purpose: Purpose) throws { #if canImport(WinSDK) /// On Windows, we need to create a named pipe. @@ -1213,3 +1233,32 @@ extension Set { return self.remove(element) != nil } } + +#if canImport(WinSDK) +extension HANDLE { + func duplicate() throws -> HANDLE { + var handle: HANDLE? = nil + guard + DuplicateHandle( + GetCurrentProcess(), + self, + GetCurrentProcess(), + &handle, + 0, true, DWORD(DUPLICATE_SAME_ACCESS) + ) + else { + throw SubprocessError( + code: .init(.failedToCreatePipe), + underlyingError: .init(rawValue: GetLastError()) + ) + } + guard let handle else { + throw SubprocessError( + code: .init(.failedToCreatePipe), + underlyingError: .init(rawValue: GetLastError()) + ) + } + return handle + } +} +#endif diff --git a/Sources/Subprocess/IO/Output.swift b/Sources/Subprocess/IO/Output.swift index a51689a..4d2f37d 100644 --- a/Sources/Subprocess/IO/Output.swift +++ b/Sources/Subprocess/IO/Output.swift @@ -56,7 +56,7 @@ extension OutputProtocol { /// On Unix-like systems, `DiscardedOutput` redirects the /// standard output of the subprocess to `/dev/null`, while on Windows, /// redirects the output to `NUL`. -public struct DiscardedOutput: OutputProtocol { +public struct DiscardedOutput: OutputProtocol, ErrorOutputProtocol { /// The type for the output. public typealias OutputType = Void @@ -82,7 +82,7 @@ public struct DiscardedOutput: OutputProtocol { /// /// Developers have the option to instruct the `Subprocess` to automatically /// close the related `FileDescriptor` after the subprocess is spawned. -public struct FileDescriptorOutput: OutputProtocol { +public struct FileDescriptorOutput: OutputProtocol, ErrorOutputProtocol { /// The type for this output. public typealias OutputType = Void @@ -115,7 +115,7 @@ public struct FileDescriptorOutput: OutputProtocol { /// A concrete `Output` type for subprocesses that collects output /// from the subprocess as `String` with the given encoding. -public struct StringOutput: OutputProtocol { +public struct StringOutput: OutputProtocol, ErrorOutputProtocol { /// The type for this output. public typealias OutputType = String? /// The max number of bytes to collect. @@ -147,7 +147,7 @@ public struct StringOutput: OutputProtocol { /// A concrete `Output` type for subprocesses that collects output from /// the subprocess as `[UInt8]`. -public struct BytesOutput: OutputProtocol { +public struct BytesOutput: OutputProtocol, ErrorOutputProtocol { /// The output type for this output option public typealias OutputType = [UInt8] /// The max number of bytes to collect @@ -286,6 +286,57 @@ extension OutputProtocol where Self == BytesOutput { } } +// MARK: - ErrorOutputProtocol + +/// Error output protocol specifies the set of methods that a type must implement to +/// serve as the error output target for a subprocess. +/// +/// Instead of developing custom implementations of `ErrorOutputProtocol`, use the +/// default implementations provided by the `Subprocess` library to specify the +/// output handling requirements. +public protocol ErrorOutputProtocol: OutputProtocol {} + +/// A concrete error output type for subprocesses that combines the standard error +/// output with the standard output stream. +/// +/// When `CombinedErrorOutput` is used as the error output for a subprocess, both +/// standard output and standard error from the child process are merged into a +/// single output stream. This is equivalent to using shell redirection like `2>&1`. +/// +/// This output type is useful when you want to capture or redirect both output +/// streams together, making it possible to process all subprocess output as a unified +/// stream rather than handling standard output and standard error separately. +public struct CombinedErrorOutput: ErrorOutputProtocol { + public typealias OutputType = Void +} + +extension ErrorOutputProtocol { + internal func createPipe(from outputPipe: borrowing CreatedPipe) throws -> CreatedPipe { + if self is CombinedErrorOutput { + return try CreatedPipe(duplicating: outputPipe) + } + return try createPipe() + } +} + +extension ErrorOutputProtocol where Self == CombinedErrorOutput { + /// Creates an error output that combines standard error with standard output. + /// + /// When using `combineWithOutput`, both standard output and standard error from + /// the child process are merged into a single output stream. This is equivalent + /// to using shell redirection like `2>&1`. + /// + /// This is useful when you want to capture or redirect both output streams + /// together, making it possible to process all subprocess output as a unified + /// stream rather than handling standard output and standard error separately + /// + /// - Returns: A `CombinedErrorOutput` instance that merges standard error + /// with standard output. + public static var combineWithOutput: Self { + return CombinedErrorOutput() + } +} + // MARK: - Span Default Implementations #if SubprocessSpan extension OutputProtocol { diff --git a/Sources/Subprocess/SubprocessFoundation/Output+Foundation.swift b/Sources/Subprocess/SubprocessFoundation/Output+Foundation.swift index ed98938..bc32638 100644 --- a/Sources/Subprocess/SubprocessFoundation/Output+Foundation.swift +++ b/Sources/Subprocess/SubprocessFoundation/Output+Foundation.swift @@ -21,7 +21,7 @@ import FoundationEssentials /// A concrete `Output` type for subprocesses that collects output /// from the subprocess as data. -public struct DataOutput: OutputProtocol { +public struct DataOutput: OutputProtocol, ErrorOutputProtocol { /// The output type for this output option public typealias OutputType = Data /// The maximum number of bytes to collect. diff --git a/Tests/SubprocessTests/IntegrationTests.swift b/Tests/SubprocessTests/IntegrationTests.swift index aa2f3ac..a3da958 100644 --- a/Tests/SubprocessTests/IntegrationTests.swift +++ b/Tests/SubprocessTests/IntegrationTests.swift @@ -1634,6 +1634,168 @@ extension SubprocessIntegrationTests { } } } + + @Test func testCombinedStringOutput() async throws { + #if os(Windows) + let setup = TestSetup( + executable: .name("cmd.exe"), + arguments: ["/c", "echo Hello Stdout & echo Hello Stderr 1>&2"] + ) + #else + let setup = TestSetup( + executable: .path("/bin/sh"), + arguments: ["-c", "echo Hello Stdout; echo Hello Stderr 1>&2"] + ) + #endif + let result = try await _run( + setup, + input: .none, + output: .string(limit: 64), + error: .combineWithOutput + ) + #expect(result.terminationStatus.isSuccess) + let output = try #require(result.standardOutput) + #expect(output.contains("Hello Stdout")) + #expect(output.contains("Hello Stderr")) + } + + @Test func testCombinedBytesOutput() async throws { + #if os(Windows) + let setup = TestSetup( + executable: .name("cmd.exe"), + arguments: ["/c", "echo Hello Stdout & echo Hello Stderr 1>&2"] + ) + #else + let setup = TestSetup( + executable: .path("/bin/sh"), + arguments: ["-c", "echo Hello Stdout; echo Hello Stderr 1>&2"] + ) + #endif + let result = try await _run( + setup, + input: .none, + output: .bytes(limit: 64), + error: .combineWithOutput + ) + #expect(result.terminationStatus.isSuccess) + #expect( + result.standardOutput.contains( + "Hello Stdout".byteArray(using: UTF8.self).unsafelyUnwrapped + ) + ) + #expect( + result.standardOutput.contains( + "Hello Stderr".byteArray(using: UTF8.self).unsafelyUnwrapped + ) + ) + } + + @Test func testCombinedFileDescriptorOutput() async throws { + #if os(Windows) + let setup = TestSetup( + executable: .name("cmd.exe"), + arguments: ["/c", "echo Hello Stdout & echo Hello Stderr 1>&2"] + ) + #else + let setup = TestSetup( + executable: .path("/bin/sh"), + arguments: ["-c", "echo Hello Stdout; echo Hello Stderr 1>&2"] + ) + #endif + + let outputFilePath = FilePath(FileManager.default.temporaryDirectory._fileSystemPath) + .appending("CombinedTest.out") + defer { + try? FileManager.default.removeItem(atPath: outputFilePath.string) + } + if FileManager.default.fileExists(atPath: outputFilePath.string) { + try FileManager.default.removeItem(atPath: outputFilePath.string) + } + let outputFile: FileDescriptor = try .open( + outputFilePath, + .readWrite, + options: .create, + permissions: [.ownerReadWrite, .groupReadWrite] + ) + let echoResult = try await outputFile.closeAfter { + let echoResult = try await _run( + setup, + input: .none, + output: .fileDescriptor( + outputFile, + closeAfterSpawningProcess: false + ), + error: .combineWithOutput + ) + #expect(echoResult.terminationStatus.isSuccess) + return echoResult + } + let outputData: Data = try Data( + contentsOf: URL(filePath: outputFilePath.string) + ) + let output = try #require( + String(data: outputData, encoding: .utf8) + ).trimmingNewLineAndQuotes() + #expect(echoResult.terminationStatus.isSuccess) + #expect(output.contains("Hello Stdout")) + #expect(output.contains("Hello Stderr")) + } + + #if SubprocessFoundation + @Test func testCombinedDataOutput() async throws { + #if os(Windows) + let setup = TestSetup( + executable: .name("cmd.exe"), + arguments: ["/c", "echo Hello Stdout & echo Hello Stderr 1>&2"] + ) + #else + let setup = TestSetup( + executable: .path("/bin/sh"), + arguments: ["-c", "echo Hello Stdout; echo Hello Stderr 1>&2"] + ) + #endif + let catResult = try await _run( + setup, + input: .none, + output: .data(limit: 64), + error: .combineWithOutput + ) + #expect(catResult.terminationStatus.isSuccess) + #expect( + catResult.standardOutput.contains("Hello Stdout".data(using: .utf8).unsafelyUnwrapped) + ) + #expect( + catResult.standardOutput.contains("Hello Stderr".data(using: .utf8).unsafelyUnwrapped) + ) + } + #endif // SubprocessFoundation + + @Test func testCombinedStreamingOutput() async throws { + #if os(Windows) + let setup = TestSetup( + executable: .name("cmd.exe"), + arguments: ["/c", "echo Hello Stdout & echo Hello Stderr 1>&2"] + ) + #else + let setup = TestSetup( + executable: .path("/bin/sh"), + arguments: ["-c", "echo Hello Stdout; echo Hello Stderr 1>&2"] + ) + #endif + + _ = try await _run( + setup, + input: .none, + error: .combineWithOutput + ) { execution, standardOutput in + var output: String = "" + for try await line in standardOutput.lines() { + output += line + } + #expect(output.contains("Hello Stdout")) + #expect(output.contains("Hello Stderr")) + } + } } // MARK: - Other Tests @@ -2216,7 +2378,7 @@ struct TestSetup { func _run< Input: InputProtocol, Output: OutputProtocol, - Error: OutputProtocol + Error: ErrorOutputProtocol >( _ testSetup: TestSetup, platformOptions: PlatformOptions = PlatformOptions(), @@ -2240,7 +2402,7 @@ func _run< func _run< InputElement: BitwiseCopyable, Output: OutputProtocol, - Error: OutputProtocol + Error: ErrorOutputProtocol >( _ testSetup: TestSetup, input: borrowing Span, @@ -2262,7 +2424,7 @@ func _run< func _run< Result, Input: InputProtocol, - Error: OutputProtocol + Error: ErrorOutputProtocol >( _ setup: TestSetup, input: Input, @@ -2284,7 +2446,7 @@ func _run< func _run< Result, - Error: OutputProtocol + Error: ErrorOutputProtocol >( _ setup: TestSetup, error: Error,