Skip to content
Open
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
79 changes: 79 additions & 0 deletions Sources/Subprocess/API.swift
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,52 @@ public func run<Result>(
)
}

#if !os(Windows)
/// Run an executable with given parameters specified by a `Configuration`
/// in PTY mode and a custom closure to manage the running subprocess' lifetime, write to its
/// standard input, and stream its combined output.
/// - Parameters:
/// - executable: The executable to run
/// - arguments: The arguments to pass to the executable
/// - environment: The environment in which to run the executable
/// - workingDirectory: The working directory in which to run the executable.
/// - platformOptions: The platform-specific options to use when running the executable.
/// - pseudoterminalOptions: The configuration options for the pseudoterminal.
/// - preferredBufferSize: The preferred size in bytes for the buffer used when reading
/// from the subprocess's standard output and error stream. If `nil`, uses the system page size
/// as the default buffer size. Larger buffer sizes may improve performance for
/// subprocesses that produce large amounts of output, while smaller buffer sizes
/// may reduce memory usage and improve responsiveness for interactive applications.
/// - isolation: the isolation context to run the body closure.
/// - body: The custom configuration body to manually control the running process,
/// its pseudoterminal, write to its standard input, and stream its combined output and error.
/// - Returns: an `ExecutionOutcome` type containing the return value of the closure.
public func run<Result>(
_ executable: Executable,
arguments: Arguments = [],
environment: Environment = .inherit,
workingDirectory: FilePath? = nil,
platformOptions: PlatformOptions = PlatformOptions(),
pseudoterminalOptions: PseudoterminalOptions,
preferredBufferSize: Int? = nil,
isolation: isolated (any Actor)? = #isolation,
body: (Execution, Pseudoterminal, StandardInputWriter, AsyncBufferSequence) async throws -> Result
) async throws -> ExecutionOutcome<Result> {
var configuration = Configuration(
executable: executable,
arguments: arguments,
environment: environment,
workingDirectory: workingDirectory,
platformOptions: platformOptions
)
return try await configuration.runPTY(
pseudoterminalOptions: pseudoterminalOptions,
preferredBufferSize: preferredBufferSize,
body: body
)
}
#endif

// MARK: - Configuration Based

#if SubprocessSpan
Expand Down Expand Up @@ -860,3 +906,36 @@ public func run<Result>(
return try await body(execution, writer, outputSequence, errorSequence)
}
}

#if !os(Windows)
/// Run an executable with given parameters specified by a `Configuration`
/// in PTY mode and a custom closure to manage the running subprocess' lifetime, write to its
/// standard input, and stream its combined output.
/// - Parameters:
/// - configuration: The `Subprocess` configuration to run.
/// - pseudoterminalOptions: The configuration options for the pseudoterminal.
/// - preferredBufferSize: The preferred size in bytes for the buffer used when reading
/// from the subprocess's standard output and error stream. If `nil`, uses the system page size
/// as the default buffer size. Larger buffer sizes may improve performance for
/// subprocesses that produce large amounts of output, while smaller buffer sizes
/// may reduce memory usage and improve responsiveness for interactive applications.
/// - isolation: the isolation context to run the body closure.
/// - body: The custom configuration body to manually control the running process,
/// its pseudoterminal, write to its standard input, and stream its combined output and error.
/// - Returns: an `ExecutionOutcome` type containing the return value of the closure.
public func run<Result>(
_ configuration: Configuration,
pseudoterminalOptions: PseudoterminalOptions,
preferredBufferSize: Int? = nil,
isolation: isolated (any Actor)? = #isolation,
body: (Execution, Pseudoterminal, StandardInputWriter, AsyncBufferSequence) async throws -> Result
) async throws -> ExecutionOutcome<Result> {
// Update environment and insert TERM
var updatedConfiguration = configuration
return try await updatedConfiguration.runPTY(
pseudoterminalOptions: pseudoterminalOptions,
preferredBufferSize: preferredBufferSize,
body: body
)
}
#endif
208 changes: 208 additions & 0 deletions Sources/Subprocess/Configuration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,70 @@ public struct Configuration: Sendable {
)
}
}

#if !os(Windows)
internal mutating func runPTY<Result>(
pseudoterminalOptions: PseudoterminalOptions,
preferredBufferSize: Int?,
isolation: isolated (any Actor)? = #isolation,
body: (Execution, Pseudoterminal, StandardInputWriter, AsyncBufferSequence) async throws -> Result
) async throws -> ExecutionOutcome<Result> {
// PTY requires a new session
self.platformOptions.createSession = true
// Update environment and insert TERM
self.environment = self.environment.updating(
["TERM": pseudoterminalOptions.terminalType]
)
// Spawn!
var spawnResults = try await self.spawnPTY(
withOptions: pseudoterminalOptions,
preferredBufferSize: preferredBufferSize
)

let execution = spawnResults.execution
defer {
execution.processIdentifier.close()
}

let teardownSequence = self.platformOptions.teardownSequence
return try await withAsyncTaskCleanupHandler {
let result: Swift.Result<Result, any Error>
do {
let bodyResult = try await body(
execution,
spawnResults.pseudoterminal,
spawnResults.inputWriter,
spawnResults.combinedOutputStream
)
result = .success(bodyResult)
} catch {
result = .failure(error)
}

// Ensure that we begin monitoring process termination after `body` runs
// and regardless of whether `body` throws, so that the pid gets reaped
// even if `body` throws, and we are not leaving zombie processes in the
// process table which will cause the process termination monitoring thread
// to effectively hang due to the pid never being awaited
let terminationStatus = try await monitorProcessTermination(
for: execution.processIdentifier
)

// Process has exited. We can/must close parentDescriptor now
try spawnResults.parentDescriptor.safelyClose()

return ExecutionOutcome(
terminationStatus: terminationStatus,
value: try result.get()
)
} onCleanup: {
// Attempt to terminate the child process
await execution.runTeardownSequence(
teardownSequence
)
}
}
#endif
}

extension Configuration: CustomStringConvertible, CustomDebugStringConvertible {
Expand Down Expand Up @@ -648,6 +712,140 @@ extension TerminationStatus: CustomStringConvertible, CustomDebugStringConvertib
}
}

// MARK: - PTY

#if !os(Windows)
/// Settings to configure the pseudoterminal (PTY) when
/// spawning in PTY mode.
public struct PseudoterminalOptions: Sendable {
/// Terminal mode configuration.
///
/// On Darwin/Linux, this controls the initial `termios` settings applied to the
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Docs should say POSIX to be inclusive of BSDs

/// PTY replica fd at spawn time via `openpty()`.
///
/// On Windows (ConPTY), terminal mode is managed internally by the pseudo console.
/// The child process controls its own mode via `SetConsoleMode()`. Therefore only
/// `.cooked` mode is available on Windows
public struct TerminalMode: Sendable {
internal enum Storage: Sendable {
case cooked
case raw
#if !os(Windows)
case custom(termios)
#endif
}

internal let storage: Storage

private init(_ storage: Storage) {
self.storage = storage
}

/// Default cooked mode with kernel line editing, echo, and signal processing.
/// - Darwin/Linux: `TTYDEF_IFLAG`, `TTYDEF_OFLAG`, `TTYDEF_LFLAG`, `TTYDEF_CFLAG`
/// - Windows: ConPTY default (equivalent to `ENABLE_LINE_INPUT | ENABLE_ECHO_INPUT |
/// ENABLE_PROCESSED_INPUT`)
public static var cooked: Self { .init(.cooked) }

#if !os(Windows)
/// Raw mode — all bytes passed through unmodified. Applies `cfmakeraw()` to the PTY.
public static var raw: Self { .init(.raw) }

/// Custom termios configuration
public static func custom(_ info: termios) -> Self {
return .init(.custom(info))
}
#endif
}

/// The initial termianl window size to set to.
public let initialWindowSize: Pseudoterminal.WindowSize
/// The type of this terminal as defined in `terminfo`, used to
/// update TERM environment variable. Terminal type communicates
/// the capabilities, instruction set, and control sequences of a terminal.
public let terminalType: String
/// The initial terminal line discipline mode.
///
/// - `.cooked` (default): Standard terminal behavior with kernel
/// line editing, echo, and signal generation. Use when spawning
/// shells or general-purpose command-line tools.
/// - `.raw`: Passes all bytes through unmodified. Use when the
/// child process manages its own input handling, or when the
/// parent process implements line editing.
///
/// The child process may change this at any time via `tcsetattr`.
/// This setting only controls the initial state at spawn time.
///
/// For more informationm see `cfmakeraw(3)` and `termios(4)`.
public let terminalMode: TerminalMode

public init(
initialWindowSize: Pseudoterminal.WindowSize,
terminalType: String,
terminalMode: TerminalMode = .cooked
) {
self.initialWindowSize = initialWindowSize
self.terminalType = terminalType
self.terminalMode = terminalMode
}
}

/// `Pseudoterminal` is used to get and update terminal information
/// such as window size and terminal type while the child process
/// is running.
public struct Pseudoterminal: Sendable {
/// `WindowSize` defines the dimensions of a terminal window
public struct WindowSize: Sendable {
public let rows: UInt16
public let columns: UInt16

public init(rows: UInt16, columns: UInt16) {
self.rows = rows
self.columns = columns
}
}
/// The dimension of this terminal window
public var windowSize: WindowSize {
get throws {
var result = winsize()
guard ioctl(self.parentDescriptor, UInt(TIOCGWINSZ), &result) == 0 else {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Can ioctl block for an indefinite amount of time for TIOCGWINSZ/TIOCSWINSZ?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

throw SubprocessError.spawnFailed(
withUnderlyingError: .init(rawValue: errno),
reason: "Failed to get window size"
)
}
return WindowSize(rows: result.ws_row, columns: result.ws_col)
}
}
/// The type of this terminal as defined in `terminfo`
public let terminalType: String

private let parentDescriptor: CInt

/// Update the dimension of this terminal window
public func update(windowSize: WindowSize) throws(SubprocessError) {
var winsize = winsize(
ws_row: windowSize.rows,
ws_col: windowSize.columns,
ws_xpixel: 0,
ws_ypixel: 0
)
guard ioctl(self.parentDescriptor, UInt(TIOCSWINSZ), &winsize) == 0 else {
throw SubprocessError.spawnFailed(
withUnderlyingError: .init(rawValue: errno),
reason: "Failed to set window size"
)
}
}

internal init(parentDescriptor: CInt, terminalType: String) {
self.parentDescriptor = parentDescriptor
self.terminalType = terminalType
}
}

#endif

// MARK: - Internal

extension Configuration {
Expand Down Expand Up @@ -685,6 +883,16 @@ extension Configuration {
return self._errorReadEnd.take()
}
}

#if !os(Windows)
internal struct SpawnPTYResult: ~Copyable {
let execution: Execution
let pseudoterminal: Pseudoterminal
let inputWriter: StandardInputWriter
let combinedOutputStream: AsyncBufferSequence
var parentDescriptor: IODescriptor
}
#endif
}

internal enum StringOrRawBytes: Sendable, Hashable {
Expand Down
6 changes: 5 additions & 1 deletion Sources/Subprocess/IO/AsyncIO+Linux.swift
Original file line number Diff line number Diff line change
Expand Up @@ -388,8 +388,12 @@ extension AsyncIO {
try self.removeRegistration(for: fileDescriptor)
return resultBuffer
}
} else if bytesRead == 0 {
} else if bytesRead == 0 || capturedErrno == EIO {
// We reached EOF. Return whatever's left

// On Linux, reading from a PTY parent returns EIO
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

// when the child side is closed (i.e., child exited).
// Treat this as EOF as well
try self.removeRegistration(for: fileDescriptor)
guard readLength > 0 else {
return nil
Expand Down
Loading
Loading