Subprocess

repository·main·Indexed 20 days ago

https://github.com/swiftlang/swift-subprocess

A cross-platform Swift package for spawning child processes, built natively with Swift concurrency. It provides tools for executing commands via name or path, managing standard input/output/error streams, and configuring platform-specific options for Unix, macOS, and Windows. Features include asynchronous streaming of output via SubprocessOutputSequence, configurable teardown sequences for graceful process termination, and support for custom environment variables and working directories.

Tokens
13.1K
Snippets
40
Records
85
Agent score
71%

What's inside swift-subprocess

  1. Avoid deadlocks by draining all output streams concurrently

    main

    A subprocess can hang if its output or error pipes become full. On many systems, if you only read standardOutput and ignore standardError, the process will block once the error buffer (e.g., ~64 KB on Linux) is full, preventing the process from ever exiting.

    Rule: Always drain every output stream you open.

    To prevent deadlocks, use a TaskGroup to read both standardOutput and standardError concurrently so that neither pipe blocks the other.

    _ = try await run(
        .name("swift"),
        arguments: ["build"],
        input: .none,
        output: .sequence,
        error: .sequence
    ) { execution in
        try await withThrowingTaskGroup(of: Void.self) { group in
            group.addTask {
                for try await line in execution.standardOutput.strings() {
                    print("out:", line)
                }
            }
            group.addTask {
                for try await line in execution.standardError.strings() {
                    print("err:", line)
                }
            }
            try await group.waitForAll()
        }
    }
  2. Run a process with a custom closure for interactive control

    main

    For fine-grained control, pass a closure to run. The closure receives an Execution object used to send signals, write to standard input, and stream standard output/error.

    Important: The Execution, SubprocessOutputSequence, and StandardInputWriter values are only valid for the duration of the closure. Do not let them escape the closure.

    To enable interactive streams, use these parameters:

    • Write to standard input: Pass input: .inputWriter and use execution.standardInputWriter.
    • Stream standard output: Pass output: .sequence and use execution.standardOutput.
    • Stream standard error: Pass error: .sequence and use execution.standardError.

    Output streams are delivered as a SubprocessOutputSequence (an asynchronous sequence of Buffer values). Use .strings() to read output line by line.

    import Subprocess
    
    let result = try await run(
        .path("/usr/bin/tail"),
        arguments: ["-f", "/path/to/nginx.log"],
        input: .none,
        output: .sequence,
        error: .discarded
    ) { execution in
        for try await line in execution.standardOutput.strings() {
            if line.contains("500") {
                // Oh no, 500 error
            }
        }
    }
  3. Handle Command Failures vs Swift Errors

    main

    It is critical to distinguish between three types of errors:

    1. Swift Error: Thrown by run via do/catch. This indicates a setup/environment failure (e.g., command not found in PATH) or that the output exceeded the specified limit.
    2. Command Failure: The process ran but exited with a non-zero code. This is not a Swift error. You must check result.terminationStatus.isSuccess or inspect the terminationStatus enum.
    3. Standard Error Output: Data written to the error stream. This is often used for diagnostics and does not necessarily mean the command failed.

    To properly handle a command that might fail, use a do/catch block for the environment and an if check for the exit status.

    do {
        let result = try await run(
            .name("git"),
            arguments: ["status"],
            output: .string(limit: 64 * 1024)
        )
        
        if result.terminationStatus.isSuccess {
            print(result.standardOutput)
        } else {
            print("git reported failure")
        }
    } catch {
        // Handles: git not found, or output exceeded limit
        print("couldn't run git: \(error)")
    }
  4. Understand Foundation integration and dependencies

    main

    By default, Subprocess includes the SubprocessFoundation trait. This enables Data-based input and output by importing Foundation (the system Foundation on Darwin, or swift-foundation's FoundationEssentials on other platforms).

    If you need to build a lightweight version of the package without a dependency on Foundation, you can disable this trait.

  5. How Subprocess works: The core execution model

    main

    Subprocess is built around the run function pattern. The lifecycle of a subprocess execution follows these steps:

    1. Launch: An Executable is invoked with specific Arguments, Environment, and a workingDirectory.
    2. Execution: The process runs. You can provide input via InputProtocol types or configure platform-specific behaviors via PlatformOptions.
    3. Termination: The process finishes or is terminated. If the Swift Task running the subprocess is canceled, Subprocess can execute a sequence of TeardownStep values (e.g., a graceful shutdown followed by a forced kill).
    4. Result: An ExecutionResult is returned, containing the ProcessIdentifier, TerminationStatus, and the requested output (defined by OutputProtocol).
  6. Specify an Executable and its Arguments

    main

    The first parameter to run is an Executable, which can be defined in two ways:

    • Executable.name(_:): Looks up the command using the PATH environment variable (shell-like behavior).
    • Executable.path(_:): Runs the command at an exact file system location, skipping the PATH search.

    Arguments are provided as an Arguments value (typically an array literal). Because Subprocess does not run a shell, there is no shell expansion, quoting, or injection risk; each element in the array is passed to the command exactly as written.

    // Using name (PATH lookup)
    let result = try await run(.name("git"), arguments: ["commit", "-m", "a message with spaces"], output: .string(limit: 16 * 1024))
    
    // Using exact path
    let result = try await run(.path("/bin/ls"), arguments: ["-la"], output: .string(limit: 4096))
  7. Configure platform-specific execution with PlatformOptions

    main

    Use PlatformOptions to configure low-level execution details that are specific to the underlying operating system. This includes setting user/group identities, process groups, quality of service, and platform-specific behaviors like Windows console management or teardown sequences. You can initialize an empty configuration using init() and then modify the desired properties.

    let options = PlatformOptions()
    // Configure properties like userID, groupID, or qualityOfService
  8. Configure Input, Output, and Error streams

    main

    The input, output, and error parameters are independent.

    • Input: Conforms to InputProtocol.
    • Output/Error: Conform to OutputProtocol.
    • Combining Streams: To merge standard error into standard output (equivalent to 2>&1 in a shell), pass .combinedWithOutput to the error parameter.

    Use the collecting form of run for short-lived processes where output fits in memory. For long-running processes or large outputs, use the streaming form (see StreamingAndInput).

    // Separate output and error
    let result = try await run(
        .name("swift"),
        arguments: ["build"],
        output: .string(limit: 2 * 1024 * 1024),
        error: .string(limit: 512 * 1024)
    )
    
    // Combined output and error (2>&1)
    let result = try await run(
        .name("swift"),
        arguments: ["build"],
        output: .string(limit: 2 * 1024 * 1024),
        error: .combinedWithOutput
    )
  9. Run a subprocess and collect output

    main

    The simplest way to use Subprocess is to call a run function. This launches an executable, waits for it to terminate, and returns an ExecutionResult. The result contains the processIdentifier, the terminationStatus, and the collected output.

    To run a command and collect its standard output as a string, use the .name() executable and specify the output parameter with .string(limit:).

    import Subprocess
    
    let result = try await run(.name("ls"), output: .string(limit: 4096))
    
    print(result.processIdentifier) // e.g. 1234
    print(result.terminationStatus) // e.g. exited(0)
    print(result.standardOutput)    // e.g. "LICENSE\nPackage.swift\n..."
  10. Use file descriptors or inherited I/O

    main

    For high efficiency or terminal interaction, you can bypass the Swift process for data transfer:

    • File Descriptors: Use FileDescriptorInput.fileDescriptor(_:closeAfterSpawningProcess:) or FileDescriptorOutput.fileDescriptor(_:closeAfterSpawningProcess:) to point input/output directly to a file descriptor. This avoids copying bytes through your Swift code.
    • Inherited I/O: To let a subprocess use your current process's terminal (e.g., for interactive commands like less), use .currentStandardInput, .currentStandardOutput, or .currentStandardError.
    // Use inherited standard I/O for interactive tools
    _ = try await run(
        .name("less"),
        arguments: ["Package.swift"],
        input: .currentStandardInput,
        output: .currentStandardOutput,
        error: .currentStandardError
    )
  11. Run a command and collect its output

    main

    To execute a command and wait for its completion, use the collecting form of the run function. You must specify how to handle standard output using the output parameter.

    Key behaviors:

    • The output parameter is required.
    • Use .string(limit:) to collect output as a String or .bytes(limit:) for raw data like images.
    • The limit is a byte count, not a character count, and is applied before decoding. If the output exceeds this limit, run throws a SubprocessError rather than truncating the result.
    • The input parameter defaults to .none and the error parameter defaults to .discarded.
    import Subprocess
    
    let result = try await run(
        .name("ls"),
        arguments: ["-la"],
        output: .string(limit: 4096)
    )
    print(result.standardOutput)