That's a great question and the proposed API shape here is influenced by the learnings in other libraries such as swift-nio or grpc-swift when we adopted Swift Concurrency. To understand the underlying problem let's look at a fully async bidirectional streaming example:
try await Subprocess.run(executable: "foo") { standardInputWriter, standardOutput, standardError in
// Push data into the process
try await standardInputWriter.write("Hello".utf8)
// Read data out
for try await in standardOutput {
]
}
The reason why the standard input, output and error are only available inside this scope is due to the underlying resource management of the file descriptors and the process. We need to make sure that at the end of the scope of the run methods body closure everything is cleaned up. That means both making sure the file descriptors are properly closed and the process has terminated. At the moment this is only possible to spell through with methods that provide scoped access to a resource and make sure the resource is properly torn down at the end of the scope. Having the guarantee that a resource is torn down at a certain point of a program is really important otherwise it becomes impossible to reason about the current state of your application. Furthermore, worse than not being able to reason you might see sporadic failures like running out of file descriptors.
Let's take your proposed API and see where this becomes problematic:
let subtool = try Subprocess("sometool", …)
for path in inputs {
try subtool.stdin.write(path, terminator: "\n")
let (isError, response) = try await subtool.readLine()
…
}
A few things that stand out:
- When is the process being spawned here? Is it being spawned when I create a
Subprocess?
- What happens if I just drop the
Subprocess on the floor? Will the process be signaled and do we wait for termination?
- What are the file descriptors closed for stdin and stdout?
I have also tried to explore using ~Copyable + ~Escapable to model this so we don't require the scoping and that worked mostly until the resource teardown becomes async. With ~Copyable types it is possible to do it correctly for resources that require a sync cleanup but it is not yet possible to do it for async cleanups. In our case here, we need async cleans ups for some of the underlying resources and that can only be spelled using the with style scoped approach. Additionally, AsyncSequences can't be ~Copyable right now neither can they carry ~Copyable elements.
Now the proposal already does provide some convenience methods for patterns where we can make it easier for the user without requiring them to do the full blown scoped approach:
public static func run<S: AsyncSequence>(
executing executable: Executable,
arguments: Arguments = [],
environment: Environment = .inherit,
workingDirectory: FilePath? = nil,
platformOptions: PlatformOptions = .default,
input: S,
output: CollectedOutputMethod = .collect,
error: CollectedOutputMethod = .collect
) async throws -> CollectedResult where S.Element == UInt8