Skip to content

Remove the default collected output buffer limit and throw an error when the limit is reached #130

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jul 30, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions Sources/Subprocess/API.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Output, Error> {
let configuration = Configuration(
Expand Down Expand Up @@ -84,7 +84,7 @@ public func run<
workingDirectory: FilePath? = nil,
platformOptions: PlatformOptions = PlatformOptions(),
input: borrowing Span<InputElement>,
output: Output = .string,
output: Output,
error: Error = .discarded
) async throws -> CollectedResult<Output, Error> {
typealias RunResult = (
Expand Down Expand Up @@ -483,7 +483,7 @@ public func run<
>(
_ configuration: Configuration,
input: Input = .none,
output: Output = .string,
output: Output,
error: Error = .discarded
) async throws -> CollectedResult<Output, Error> {
typealias RunResult = (
Expand Down
17 changes: 11 additions & 6 deletions Sources/Subprocess/Error.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ extension SubprocessError {
case failedToMonitorProcess
case streamOutputExceedsLimit(Int)
case asyncIOFailed(String)
case outputBufferLimitExceeded(Int)
// Signal
case failedToSendSignal(Int32)
// Windows Only
Expand Down Expand Up @@ -70,18 +71,20 @@ extension SubprocessError {
return 6
case .asyncIOFailed(_):
return 7
case .failedToSendSignal(_):
case .outputBufferLimitExceeded(_):
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After we set the API we should probably consider the error numbers to be frozen, in case people have written documentation about them or compare them to raw values.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree. I'm only doing this because we haven't tagged 0.0.1 yet.

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
}
}

Expand Down Expand Up @@ -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:
Expand Down
50 changes: 37 additions & 13 deletions Sources/Subprocess/IO/Output.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this mean that we've drained 1 more byte from the I/O than the caller expected?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. Unfortunately this is the only way to detect whether there's more data coming. If the caller was only expected N bytes, then the child process should only process N bytes with EOF at the end. If not then we considered this an error.

}
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
Expand Down Expand Up @@ -213,16 +225,19 @@ extension OutputProtocol where Self == FileDescriptorOutput {
}

extension OutputProtocol where Self == StringOutput<UTF8> {
/// 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<Encoding: Unicode.Encoding>(
limit: Int,
encoding: Encoding.Type
Expand All @@ -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)
}
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
4 changes: 2 additions & 2 deletions Tests/SubprocessTests/SubprocessTests+Darwin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion Tests/SubprocessTests/SubprocessTests+Linting.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions Tests/SubprocessTests/SubprocessTests+Linux.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 == "")
Expand Down
Loading