diff --git a/README.md b/README.md index 26eebee..41f02bf 100644 --- a/README.md +++ b/README.md @@ -143,7 +143,7 @@ let result = try await run(.path("/bin/exe"), platformOptions: platformOptions) By default, `Subprocess`: - Doesn’t send any input to the child process’s standard input -- Captures the child process’s standard output as a `String`, up to 128kB +- Asks the user how to capture the output - Ignores the child process’s standard error You can tailor how `Subprocess` handles the standard input, standard output, and standard error by setting the `input`, `output`, and `error` parameters: @@ -222,13 +222,13 @@ Use it by setting `.fileDescriptor(closeAfterSpawningProcess:)` for `output` or This option collects output as a `String` with the given encoding. -Use it by setting `.string` or `.string(limit:encoding:)` for `output` or `error`. +Use it by setting `.string(limit:encoding:)` for `output` or `error`. #### `BytesOutput` This option collects output as `[UInt8]`. -Use it by setting `.bytes` or `.bytes(limit:)` for `output` or `error`. +Use it by setting`.bytes(limit:)` for `output` or `error`. ### Cross-platform support diff --git a/Sources/Subprocess/API.swift b/Sources/Subprocess/API.swift index 6710d0a..c2f2f9f 100644 --- a/Sources/Subprocess/API.swift +++ b/Sources/Subprocess/API.swift @@ -41,7 +41,7 @@ public func run< workingDirectory: FilePath? = nil, platformOptions: PlatformOptions = PlatformOptions(), input: Input = .none, - output: Output = .string, + output: Output, error: Error = .discarded ) async throws -> CollectedResult { let configuration = Configuration( @@ -84,7 +84,7 @@ public func run< workingDirectory: FilePath? = nil, platformOptions: PlatformOptions = PlatformOptions(), input: borrowing Span, - output: Output = .string, + output: Output, error: Error = .discarded ) async throws -> CollectedResult { typealias RunResult = ( @@ -483,7 +483,7 @@ public func run< >( _ configuration: Configuration, input: Input = .none, - output: Output = .string, + output: Output, error: Error = .discarded ) async throws -> CollectedResult { typealias RunResult = ( diff --git a/Sources/Subprocess/Error.swift b/Sources/Subprocess/Error.swift index 5e4bd80..c62e24b 100644 --- a/Sources/Subprocess/Error.swift +++ b/Sources/Subprocess/Error.swift @@ -42,6 +42,7 @@ extension SubprocessError { case failedToMonitorProcess case streamOutputExceedsLimit(Int) case asyncIOFailed(String) + case outputBufferLimitExceeded(Int) // Signal case failedToSendSignal(Int32) // Windows Only @@ -70,18 +71,20 @@ extension SubprocessError { return 6 case .asyncIOFailed(_): return 7 - case .failedToSendSignal(_): + case .outputBufferLimitExceeded(_): return 8 - case .failedToTerminate: + case .failedToSendSignal(_): return 9 - case .failedToSuspend: + case .failedToTerminate: return 10 - case .failedToResume: + case .failedToSuspend: return 11 - case .failedToCreatePipe: + case .failedToResume: return 12 - case .invalidWindowsPath(_): + case .failedToCreatePipe: return 13 + case .invalidWindowsPath(_): + return 14 } } @@ -113,6 +116,8 @@ extension SubprocessError: CustomStringConvertible, CustomDebugStringConvertible return "Failed to create output from current buffer because the output limit (\(limit)) was reached." case .asyncIOFailed(let reason): return "An error occurred within the AsyncIO subsystem: \(reason). Underlying error: \(self.underlyingError!)" + case .outputBufferLimitExceeded(let limit): + return "Output exceeds the limit of \(limit) bytes." case .failedToSendSignal(let signal): return "Failed to send signal \(signal) to the child process." case .failedToTerminate: diff --git a/Sources/Subprocess/IO/Output.swift b/Sources/Subprocess/IO/Output.swift index c2744b9..1759091 100644 --- a/Sources/Subprocess/IO/Output.swift +++ b/Sources/Subprocess/IO/Output.swift @@ -158,13 +158,25 @@ public struct BytesOutput: OutputProtocol { var result: [UInt8]? = nil #endif do { - result = try await AsyncIO.shared.read(from: diskIO, upTo: self.maxSize) + var maxLength = self.maxSize + if maxLength != .max { + // If we actually have a max length, attempt to read one + // more byte to determine whether output exceeds the limit + maxLength += 1 + } + result = try await AsyncIO.shared.read(from: diskIO, upTo: maxLength) } catch { try diskIO.safelyClose() throw error } - try diskIO.safelyClose() + + if let result, result.count > self.maxSize { + throw SubprocessError( + code: .init(.outputBufferLimitExceeded(self.maxSize)), + underlyingError: nil + ) + } #if canImport(Darwin) return result?.array() ?? [] #else @@ -213,16 +225,19 @@ extension OutputProtocol where Self == FileDescriptorOutput { } extension OutputProtocol where Self == StringOutput { - /// Create a `Subprocess` output that collects output as - /// UTF8 String with 128kb limit. - public static var string: Self { - .init(limit: 128 * 1024, encoding: UTF8.self) + /// Create a `Subprocess` output that collects output as UTF8 String + /// with a buffer limit in bytes. Subprocess throws an error if the + /// child process emits more bytes than the limit. + public static func string(limit: Int) -> Self { + return .init(limit: limit, encoding: UTF8.self) } } extension OutputProtocol { /// Create a `Subprocess` output that collects output as - /// `String` using the given encoding up to limit it bytes. + /// `String` using the given encoding up to limit in bytes. + /// Subprocess throws an error if the child process emits + /// more bytes than the limit. public static func string( limit: Int, encoding: Encoding.Type @@ -234,11 +249,8 @@ extension OutputProtocol { extension OutputProtocol where Self == BytesOutput { /// Create a `Subprocess` output that collects output as - /// `Buffer` with 128kb limit. - public static var bytes: Self { .init(limit: 128 * 1024) } - - /// Create a `Subprocess` output that collects output as - /// `Buffer` up to limit it bytes. + /// `Buffer` with a buffer limit in bytes. Subprocess throws + /// an error if the child process emits more bytes than the limit. public static func bytes(limit: Int) -> Self { return .init(limit: limit) } @@ -299,13 +311,25 @@ extension OutputProtocol { var result: [UInt8]? = nil #endif do { - result = try await AsyncIO.shared.read(from: diskIO, upTo: self.maxSize) + var maxLength = self.maxSize + if maxLength != .max { + // If we actually have a max length, attempt to read one + // more byte to determine whether output exceeds the limit + maxLength += 1 + } + result = try await AsyncIO.shared.read(from: diskIO, upTo: maxLength) } catch { try diskIO.safelyClose() throw error } try diskIO.safelyClose() + if let result, result.count > self.maxSize { + throw SubprocessError( + code: .init(.outputBufferLimitExceeded(self.maxSize)), + underlyingError: nil + ) + } #if canImport(Darwin) return try self.output(from: result ?? .empty) #else diff --git a/Sources/Subprocess/SubprocessFoundation/Output+Foundation.swift b/Sources/Subprocess/SubprocessFoundation/Output+Foundation.swift index de965c0..0b2db7d 100644 --- a/Sources/Subprocess/SubprocessFoundation/Output+Foundation.swift +++ b/Sources/Subprocess/SubprocessFoundation/Output+Foundation.swift @@ -43,13 +43,8 @@ public struct DataOutput: OutputProtocol { extension OutputProtocol where Self == DataOutput { /// Create a `Subprocess` output that collects output as `Data` - /// up to 128kb. - public static var data: Self { - return .data(limit: 128 * 1024) - } - - /// Create a `Subprocess` output that collects output as `Data` - /// with given max number of bytes to collect. + /// with given buffer limit in bytes. Subprocess throws an error + /// if the child process emits more bytes than the limit. public static func data(limit: Int) -> Self { return .init(limit: limit) } diff --git a/Tests/SubprocessTests/SubprocessTests+Darwin.swift b/Tests/SubprocessTests/SubprocessTests+Darwin.swift index bd2a570..2c4acba 100644 --- a/Tests/SubprocessTests/SubprocessTests+Darwin.swift +++ b/Tests/SubprocessTests/SubprocessTests+Darwin.swift @@ -41,7 +41,7 @@ struct SubprocessDarwinTests { .path("/bin/bash"), arguments: ["-c", "ps -o pid,pgid,tpgid -p $$"], platformOptions: platformOptions, - output: .string + output: .string(limit: .max) ) try assertNewSessionCreated(with: psResult) } @@ -58,7 +58,7 @@ struct SubprocessDarwinTests { let pwdResult = try await Subprocess.run( .path("/bin/pwd"), platformOptions: platformOptions, - output: .string + output: .string(limit: .max) ) #expect(pwdResult.terminationStatus.isSuccess) let currentDir = try #require( diff --git a/Tests/SubprocessTests/SubprocessTests+Linting.swift b/Tests/SubprocessTests/SubprocessTests+Linting.swift index ce45119..d41be90 100644 --- a/Tests/SubprocessTests/SubprocessTests+Linting.swift +++ b/Tests/SubprocessTests/SubprocessTests+Linting.swift @@ -72,7 +72,7 @@ struct SubprocessLintingTest { let lintResult = try await Subprocess.run( configuration, output: .discarded, - error: .string + error: .string(limit: .max) ) #expect( lintResult.terminationStatus.isSuccess, diff --git a/Tests/SubprocessTests/SubprocessTests+Linux.swift b/Tests/SubprocessTests/SubprocessTests+Linux.swift index ca535e6..2e2e7d2 100644 --- a/Tests/SubprocessTests/SubprocessTests+Linux.swift +++ b/Tests/SubprocessTests/SubprocessTests+Linux.swift @@ -50,8 +50,8 @@ struct SubprocessLinuxTests { .path("/usr/bin/id"), arguments: ["-g"], platformOptions: platformOptions, - output: .string, - error: .string + output: .string(limit: 32), + error: .string(limit: 32) ) let error = try #require(idResult.standardError) try #require(error == "") diff --git a/Tests/SubprocessTests/SubprocessTests+Unix.swift b/Tests/SubprocessTests/SubprocessTests+Unix.swift index 4dc1a14..b9f78cd 100644 --- a/Tests/SubprocessTests/SubprocessTests+Unix.swift +++ b/Tests/SubprocessTests/SubprocessTests+Unix.swift @@ -51,7 +51,8 @@ extension SubprocessUnixTests { let message = "Hello, world!" let result = try await Subprocess.run( .name("echo"), - arguments: [message] + arguments: [message], + output: .string(limit: 32) ) #expect(result.terminationStatus.isSuccess) // rdar://138670128 @@ -62,7 +63,7 @@ extension SubprocessUnixTests { @Test func testExecutableNamedCannotResolve() async { do { - _ = try await Subprocess.run(.name("do-not-exist")) + _ = try await Subprocess.run(.name("do-not-exist"), output: .discarded) Issue.record("Expected to throw") } catch { guard let subprocessError: SubprocessError = error as? SubprocessError else { @@ -75,7 +76,7 @@ extension SubprocessUnixTests { @Test func testExecutableAtPath() async throws { let expected = FileManager.default.currentDirectoryPath - let result = try await Subprocess.run(.path("/bin/pwd"), output: .string) + let result = try await Subprocess.run(.path("/bin/pwd"), output: .string(limit: .max)) #expect(result.terminationStatus.isSuccess) // rdar://138670128 let maybePath = result.standardOutput? @@ -86,7 +87,7 @@ extension SubprocessUnixTests { @Test func testExecutableAtPathCannotResolve() async { do { - _ = try await Subprocess.run(.path("/usr/bin/do-not-exist")) + _ = try await Subprocess.run(.path("/usr/bin/do-not-exist"), output: .discarded) Issue.record("Expected to throw SubprocessError") } catch { guard let subprocessError: SubprocessError = error as? SubprocessError else { @@ -104,7 +105,7 @@ extension SubprocessUnixTests { let result = try await Subprocess.run( .path("/bin/sh"), arguments: ["-c", "echo Hello World!"], - output: .string + output: .string(limit: 32) ) #expect(result.terminationStatus.isSuccess) // rdar://138670128 @@ -122,7 +123,7 @@ extension SubprocessUnixTests { executablePathOverride: "apple", remainingValues: ["-c", "echo $0"] ), - output: .string + output: .string(limit: 32) ) #expect(result.terminationStatus.isSuccess) // rdar://138670128 @@ -141,7 +142,7 @@ extension SubprocessUnixTests { executablePathOverride: nil, remainingValues: [arguments] ), - output: .string + output: .string(limit: 32) ) #expect(result.terminationStatus.isSuccess) // rdar://138670128 @@ -160,7 +161,7 @@ extension SubprocessUnixTests { .path("/bin/sh"), arguments: ["-c", "printenv PATH"], environment: .inherit, - output: .string + output: .string(limit: .max) ) #expect(result.terminationStatus.isSuccess) // As a sanity check, make sure there's `/bin` in PATH @@ -178,7 +179,7 @@ extension SubprocessUnixTests { environment: .inherit.updating([ "HOME": "/my/new/home" ]), - output: .string + output: .string(limit: 32) ) #expect(result.terminationStatus.isSuccess) // rdar://138670128 @@ -195,7 +196,7 @@ extension SubprocessUnixTests { environment: .custom([ "PATH": "/bin:/usr/bin" ]), - output: .string + output: .string(limit: 32) ) #expect(result.terminationStatus.isSuccess) // There shouldn't be any other environment variables besides @@ -217,7 +218,7 @@ extension SubprocessUnixTests { let result = try await Subprocess.run( .path("/bin/pwd"), workingDirectory: nil, - output: .string + output: .string(limit: .max) ) #expect(result.terminationStatus.isSuccess) // There shouldn't be any other environment variables besides @@ -236,7 +237,7 @@ extension SubprocessUnixTests { let result = try await Subprocess.run( .path("/bin/pwd"), workingDirectory: workingDirectory, - output: .string + output: .string(limit: .max) ) #expect(result.terminationStatus.isSuccess) // There shouldn't be any other environment variables besides @@ -266,7 +267,7 @@ extension SubprocessUnixTests { let catResult = try await Subprocess.run( .path("/bin/cat"), input: .none, - output: .string + output: .string(limit: 16) ) #expect(catResult.terminationStatus.isSuccess) // We should have read exactly 0 bytes @@ -277,7 +278,8 @@ extension SubprocessUnixTests { let content = randomString(length: 64) let catResult = try await Subprocess.run( .path("/bin/cat"), - input: .string(content, using: UTF8.self) + input: .string(content, using: UTF8.self), + output: .string(limit: 64) ) #expect(catResult.terminationStatus.isSuccess) // Output should match the input content @@ -437,33 +439,31 @@ extension SubprocessUnixTests { #endif @Test func testCollectedOutput() async throws { - let expected = randomString(length: 32) + let expected = try Data(contentsOf: URL(filePath: theMysteriousIsland.string)) let echoResult = try await Subprocess.run( - .path("/bin/echo"), - arguments: [expected], - output: .string + .path("/bin/cat"), + arguments: [theMysteriousIsland.string], + output: .data(limit: .max) ) #expect(echoResult.terminationStatus.isSuccess) - let output = try #require( - echoResult.standardOutput - ).trimmingCharacters(in: .whitespacesAndNewlines) - #expect(output == expected) + #expect(echoResult.standardOutput == expected) } - @Test func testCollectedOutputWithLimit() async throws { - let limit = 4 - let expected = randomString(length: 32) - let echoResult = try await Subprocess.run( - .path("/bin/echo"), - arguments: [expected], - output: .string(limit: limit, encoding: UTF8.self) - ) - #expect(echoResult.terminationStatus.isSuccess) - let output = try #require( - echoResult.standardOutput - ).trimmingCharacters(in: .whitespacesAndNewlines) - let targetRange = expected.startIndex..&2"], + output: .discarded, error: .data(limit: 2048 * 1024) ) #expect(catResult.terminationStatus.isSuccess) @@ -671,7 +672,8 @@ extension SubprocessUnixTests { .name("swift"), arguments: [getgroupsSwift.string], platformOptions: platformOptions, - output: .string + output: .string(limit: .max), + error: .string(limit: .max), ) #expect(idResult.terminationStatus.isSuccess) let ids = try #require( @@ -699,7 +701,7 @@ extension SubprocessUnixTests { .path("/bin/sh"), arguments: ["-c", "ps -o pid,pgid -p $$"], platformOptions: platformOptions, - output: .string + output: .string(limit: .max) ) #expect(psResult.terminationStatus.isSuccess) let resultValue = try #require( @@ -726,7 +728,7 @@ extension SubprocessUnixTests { .path("/bin/sh"), arguments: ["-c", "ps -o pid,pgid,tpgid -p $$"], platformOptions: platformOptions, - output: .string + output: .string(limit: .max) ) try assertNewSessionCreated(with: psResult) } @@ -815,7 +817,8 @@ extension SubprocessUnixTests { for signal in signalsToTest { let result = try await Subprocess.run( .path("/bin/sh"), - arguments: ["-c", "kill -\(signal) $$"] + arguments: ["-c", "kill -\(signal) $$"], + output: .discarded ) #expect(result.terminationStatus == .unhandledException(signal)) } @@ -829,7 +832,8 @@ extension SubprocessUnixTests { group.addTask { return try await Subprocess.run( .path("/bin/sh"), - arguments: ["-c", "trap 'echo no' TERM; while true; do sleep 1; done"] + arguments: ["-c", "trap 'echo no' TERM; while true; do sleep 1; done"], + output: .string(limit: .max) ).terminationStatus } group.addTask { @@ -979,7 +983,7 @@ extension SubprocessUnixTests { .path("/usr/bin/id"), arguments: [argument], platformOptions: platformOptions, - output: .string + output: .string(limit: 32) ) #expect(idResult.terminationStatus.isSuccess) let id = try #require(idResult.standardOutput) @@ -1052,7 +1056,7 @@ extension SubprocessUnixTests { let limitResult = try await Subprocess.run( .path("/bin/sh"), arguments: ["-c", "ulimit -n"], - output: .string + output: .string(limit: 32) ) guard let limitString = limitResult @@ -1081,8 +1085,8 @@ extension SubprocessUnixTests { arguments: [ "-sc", #"echo "$1" && echo "$1" >&2"#, "--", String(repeating: "X", count: byteCount), ], - output: .data, - error: .data + output: .data(limit: .max), + error: .data(limit: .max) ) guard r.terminationStatus.isSuccess else { Issue.record("Unexpected exit \(r.terminationStatus) from \(r.processIdentifier)") @@ -1111,8 +1115,8 @@ extension SubprocessUnixTests { arguments: [ "-sc", #"echo "$1" && echo "$1" >&2"#, "--", String(repeating: "X", count: 100_000), ], - output: .data, - error: .data + output: .data(limit: .max), + error: .data(limit: .max) ) #expect(r.terminationStatus == .exited(0)) #expect(r.standardOutput.count == 100_001, "Standard output actual \(r.standardOutput)") @@ -1136,7 +1140,8 @@ extension SubprocessUnixTests { group.addTask { return try await Subprocess.run( .path("/bin/sleep"), - arguments: ["100000"] + arguments: ["100000"], + output: .string(limit: .max) ).terminationStatus } group.addTask { diff --git a/Tests/SubprocessTests/SubprocessTests+Windows.swift b/Tests/SubprocessTests/SubprocessTests+Windows.swift index 3b9bed7..683dcc3 100644 --- a/Tests/SubprocessTests/SubprocessTests+Windows.swift +++ b/Tests/SubprocessTests/SubprocessTests+Windows.swift @@ -39,7 +39,7 @@ extension SubprocessWindowsTests { let result = try await Subprocess.run( .name("cmd.exe"), arguments: ["/c", "echo", message], - output: .string, + output: .string(limit: 64), error: .discarded ) @@ -52,7 +52,7 @@ extension SubprocessWindowsTests { @Test func testExecutableNamedCannotResolve() async throws { do { - _ = try await Subprocess.run(.name("do-not-exist")) + _ = try await Subprocess.run(.name("do-not-exist"), output: .discarded) Issue.record("Expected to throw") } catch { guard let subprocessError = error as? SubprocessError else { @@ -69,7 +69,7 @@ extension SubprocessWindowsTests { let result = try await Subprocess.run( self.cmdExe, arguments: ["/c", "cd"], - output: .string + output: .string(limit: .max) ) #expect(result.terminationStatus.isSuccess) #expect( @@ -83,7 +83,7 @@ extension SubprocessWindowsTests { // Since we are using the path directly, // we expect the error to be thrown by the underlying // CreateProcessW - _ = try await Subprocess.run(.path("X:\\do-not-exist")) + _ = try await Subprocess.run(.path("X:\\do-not-exist"), output: .discarded) Issue.record("Expected to throw POSIXError") } catch { guard let subprocessError = error as? SubprocessError, @@ -109,7 +109,7 @@ extension SubprocessWindowsTests { let result = try await Subprocess.run( self.cmdExe, arguments: .init(args), - output: .string + output: .string(limit: 32) ) #expect(result.terminationStatus.isSuccess) #expect( @@ -126,7 +126,7 @@ extension SubprocessWindowsTests { self.cmdExe, arguments: ["/c", "echo %Path%"], environment: .inherit, - output: .string + output: .string(limit: .max) ) // As a sanity check, make sure there's // `C:\Windows\system32` in PATH @@ -142,7 +142,7 @@ extension SubprocessWindowsTests { environment: .inherit.updating([ "HOMEPATH": "/my/new/home" ]), - output: .string + output: .string(limit: 32) ) #expect(result.terminationStatus.isSuccess) #expect( @@ -162,7 +162,7 @@ extension SubprocessWindowsTests { "Path": "C:\\Windows\\system32;C:\\Windows", "ComSpec": "C:\\Windows\\System32\\cmd.exe", ]), - output: .string + output: .string(limit: .max) ) #expect(result.terminationStatus.isSuccess) // Make sure the newly launched process does @@ -182,7 +182,7 @@ extension SubprocessWindowsTests { self.cmdExe, arguments: ["/c", "cd"], workingDirectory: nil, - output: .string + output: .string(limit: .max) ) #expect(result.terminationStatus.isSuccess) // There shouldn't be any other environment variables besides @@ -201,7 +201,7 @@ extension SubprocessWindowsTests { self.cmdExe, arguments: ["/c", "cd"], workingDirectory: workingDirectory, - output: .string + output: .string(limit: .max) ) #expect(result.terminationStatus.isSuccess) // There shouldn't be any other environment variables besides @@ -221,7 +221,7 @@ extension SubprocessWindowsTests { self.cmdExe, arguments: ["/c", "more"], input: .none, - output: .data + output: .data(limit: 16) ) #expect(catResult.terminationStatus.isSuccess) // We should have read exactly 0 bytes @@ -370,7 +370,7 @@ extension SubprocessWindowsTests { let echoResult = try await Subprocess.run( self.cmdExe, arguments: ["/c", "echo \(expected)"], - output: .string + output: .string(limit: 35) ) #expect(echoResult.terminationStatus.isSuccess) let output = try #require( @@ -379,20 +379,23 @@ extension SubprocessWindowsTests { #expect(output == expected) } - @Test func testCollectedOutputWithLimit() async throws { - let limit = 2 - let expected = randomString(length: 32) - let echoResult = try await Subprocess.run( - self.cmdExe, - arguments: ["/c", "echo \(expected)"], - output: .string(limit: limit, encoding: UTF8.self) - ) - #expect(echoResult.terminationStatus.isSuccess) - let output = try #require( - echoResult.standardOutput - ).trimmingCharacters(in: .whitespacesAndNewlines) - let targetRange = expected.startIndex..