Skip to content

Commit d9d3f4b

Browse files
committed
Introduce PTY support
This patch introduces two run overloads for spawning processes in PTY mode on Darwin and Linux
1 parent 7928f39 commit d9d3f4b

8 files changed

Lines changed: 750 additions & 252 deletions

File tree

Sources/Subprocess/API.swift

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -382,6 +382,52 @@ public func run<Result>(
382382
)
383383
}
384384

385+
#if !os(Windows)
386+
/// Run an executable with given parameters specified by a `Configuration`
387+
/// in PTY mode and a custom closure to manage the running subprocess' lifetime, write to its
388+
/// standard input, and stream its combined output.
389+
/// - Parameters:
390+
/// - executable: The executable to run
391+
/// - arguments: The arguments to pass to the executable
392+
/// - environment: The environment in which to run the executable
393+
/// - workingDirectory: The working directory in which to run the executable.
394+
/// - platformOptions: The platform-specific options to use when running the executable.
395+
/// - pseudoterminalOptions: The configuration options for the pseudoterminal.
396+
/// - preferredBufferSize: The preferred size in bytes for the buffer used when reading
397+
/// from the subprocess's standard output and error stream. If `nil`, uses the system page size
398+
/// as the default buffer size. Larger buffer sizes may improve performance for
399+
/// subprocesses that produce large amounts of output, while smaller buffer sizes
400+
/// may reduce memory usage and improve responsiveness for interactive applications.
401+
/// - isolation: the isolation context to run the body closure.
402+
/// - body: The custom configuration body to manually control the running process,
403+
/// its pseudoterminal, write to its standard input, and stream its combined output and error.
404+
/// - Returns: an `ExecutionOutcome` type containing the return value of the closure.
405+
public func run<Result>(
406+
_ executable: Executable,
407+
arguments: Arguments = [],
408+
environment: Environment = .inherit,
409+
workingDirectory: FilePath? = nil,
410+
platformOptions: PlatformOptions = PlatformOptions(),
411+
pseudoterminalOptions: PseudoterminalOptions,
412+
preferredBufferSize: Int? = nil,
413+
isolation: isolated (any Actor)? = #isolation,
414+
body: (Execution, Pseudoterminal, StandardInputWriter, AsyncBufferSequence) async throws -> Result
415+
) async throws -> ExecutionOutcome<Result> {
416+
var configuration = Configuration(
417+
executable: executable,
418+
arguments: arguments,
419+
environment: environment,
420+
workingDirectory: workingDirectory,
421+
platformOptions: platformOptions
422+
)
423+
return try await configuration.runPTY(
424+
pseudoterminalOptions: pseudoterminalOptions,
425+
preferredBufferSize: preferredBufferSize,
426+
body: body
427+
)
428+
}
429+
#endif
430+
385431
// MARK: - Configuration Based
386432

387433
#if SubprocessSpan
@@ -860,3 +906,36 @@ public func run<Result>(
860906
return try await body(execution, writer, outputSequence, errorSequence)
861907
}
862908
}
909+
910+
#if !os(Windows)
911+
/// Run an executable with given parameters specified by a `Configuration`
912+
/// in PTY mode and a custom closure to manage the running subprocess' lifetime, write to its
913+
/// standard input, and stream its combined output.
914+
/// - Parameters:
915+
/// - configuration: The `Subprocess` configuration to run.
916+
/// - pseudoterminalOptions: The configuration options for the pseudoterminal.
917+
/// - preferredBufferSize: The preferred size in bytes for the buffer used when reading
918+
/// from the subprocess's standard output and error stream. If `nil`, uses the system page size
919+
/// as the default buffer size. Larger buffer sizes may improve performance for
920+
/// subprocesses that produce large amounts of output, while smaller buffer sizes
921+
/// may reduce memory usage and improve responsiveness for interactive applications.
922+
/// - isolation: the isolation context to run the body closure.
923+
/// - body: The custom configuration body to manually control the running process,
924+
/// its pseudoterminal, write to its standard input, and stream its combined output and error.
925+
/// - Returns: an `ExecutionOutcome` type containing the return value of the closure.
926+
public func run<Result>(
927+
_ configuration: Configuration,
928+
pseudoterminalOptions: PseudoterminalOptions,
929+
preferredBufferSize: Int? = nil,
930+
isolation: isolated (any Actor)? = #isolation,
931+
body: (Execution, Pseudoterminal, StandardInputWriter, AsyncBufferSequence) async throws -> Result
932+
) async throws -> ExecutionOutcome<Result> {
933+
// Update environment and insert TERM
934+
var updatedConfiguration = configuration
935+
return try await updatedConfiguration.runPTY(
936+
pseudoterminalOptions: pseudoterminalOptions,
937+
preferredBufferSize: preferredBufferSize,
938+
body: body
939+
)
940+
}
941+
#endif

Sources/Subprocess/Configuration.swift

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,70 @@ public struct Configuration: Sendable {
149149
)
150150
}
151151
}
152+
153+
#if !os(Windows)
154+
internal mutating func runPTY<Result>(
155+
pseudoterminalOptions: PseudoterminalOptions,
156+
preferredBufferSize: Int?,
157+
isolation: isolated (any Actor)? = #isolation,
158+
body: (Execution, Pseudoterminal, StandardInputWriter, AsyncBufferSequence) async throws -> Result
159+
) async throws -> ExecutionOutcome<Result> {
160+
// PTY requires a new session
161+
self.platformOptions.createSession = true
162+
// Update environment and insert TERM
163+
self.environment = self.environment.updating(
164+
["TERM": pseudoterminalOptions.terminalType]
165+
)
166+
// Spawn!
167+
var spawnResults = try await self.spawnPTY(
168+
withOptions: pseudoterminalOptions,
169+
preferredBufferSize: preferredBufferSize
170+
)
171+
172+
let execution = spawnResults.execution
173+
defer {
174+
execution.processIdentifier.close()
175+
}
176+
177+
let teardownSequence = self.platformOptions.teardownSequence
178+
return try await withAsyncTaskCleanupHandler {
179+
let result: Swift.Result<Result, any Error>
180+
do {
181+
let bodyResult = try await body(
182+
execution,
183+
spawnResults.pseudoterminal,
184+
spawnResults.inputWriter,
185+
spawnResults.combinedOutputStream
186+
)
187+
result = .success(bodyResult)
188+
} catch {
189+
result = .failure(error)
190+
}
191+
192+
// Ensure that we begin monitoring process termination after `body` runs
193+
// and regardless of whether `body` throws, so that the pid gets reaped
194+
// even if `body` throws, and we are not leaving zombie processes in the
195+
// process table which will cause the process termination monitoring thread
196+
// to effectively hang due to the pid never being awaited
197+
let terminationStatus = try await monitorProcessTermination(
198+
for: execution.processIdentifier
199+
)
200+
201+
// Process has exited. We can/must close parentDescriptor now
202+
try spawnResults.parentDescriptor.safelyClose()
203+
204+
return ExecutionOutcome(
205+
terminationStatus: terminationStatus,
206+
value: try result.get()
207+
)
208+
} onCleanup: {
209+
// Attempt to terminate the child process
210+
await execution.runTeardownSequence(
211+
teardownSequence
212+
)
213+
}
214+
}
215+
#endif
152216
}
153217

154218
extension Configuration: CustomStringConvertible, CustomDebugStringConvertible {
@@ -648,6 +712,140 @@ extension TerminationStatus: CustomStringConvertible, CustomDebugStringConvertib
648712
}
649713
}
650714

715+
// MARK: - PTY
716+
717+
#if !os(Windows)
718+
/// Settings to configure the pseudoterminal (PTY) when
719+
/// spawning in PTY mode.
720+
public struct PseudoterminalOptions: Sendable {
721+
/// Terminal mode configuration.
722+
///
723+
/// On Darwin/Linux, this controls the initial `termios` settings applied to the
724+
/// PTY replica fd at spawn time via `openpty()`.
725+
///
726+
/// On Windows (ConPTY), terminal mode is managed internally by the pseudo console.
727+
/// The child process controls its own mode via `SetConsoleMode()`. Therefore only
728+
/// `.cooked` mode is available on Windows
729+
public struct TerminalMode: Sendable {
730+
internal enum Storage: Sendable {
731+
case cooked
732+
case raw
733+
#if !os(Windows)
734+
case custom(termios)
735+
#endif
736+
}
737+
738+
internal let storage: Storage
739+
740+
private init(_ storage: Storage) {
741+
self.storage = storage
742+
}
743+
744+
/// Default cooked mode with kernel line editing, echo, and signal processing.
745+
/// - Darwin/Linux: `TTYDEF_IFLAG`, `TTYDEF_OFLAG`, `TTYDEF_LFLAG`, `TTYDEF_CFLAG`
746+
/// - Windows: ConPTY default (equivalent to `ENABLE_LINE_INPUT | ENABLE_ECHO_INPUT |
747+
/// ENABLE_PROCESSED_INPUT`)
748+
public static var cooked: Self { .init(.cooked) }
749+
750+
#if !os(Windows)
751+
/// Raw mode — all bytes passed through unmodified. Applies `cfmakeraw()` to the PTY.
752+
public static var raw: Self { .init(.raw) }
753+
754+
/// Custom termios configuration
755+
public static func custom(_ info: termios) -> Self {
756+
return .init(.custom(info))
757+
}
758+
#endif
759+
}
760+
761+
/// The initial termianl window size to set to.
762+
public let initialWindowSize: Pseudoterminal.WindowSize
763+
/// The type of this terminal as defined in `terminfo`, used to
764+
/// update TERM environment variable. Terminal type communicates
765+
/// the capabilities, instruction set, and control sequences of a terminal.
766+
public let terminalType: String
767+
/// The initial terminal line discipline mode.
768+
///
769+
/// - `.cooked` (default): Standard terminal behavior with kernel
770+
/// line editing, echo, and signal generation. Use when spawning
771+
/// shells or general-purpose command-line tools.
772+
/// - `.raw`: Passes all bytes through unmodified. Use when the
773+
/// child process manages its own input handling, or when the
774+
/// parent process implements line editing.
775+
///
776+
/// The child process may change this at any time via `tcsetattr`.
777+
/// This setting only controls the initial state at spawn time.
778+
///
779+
/// For more informationm see `cfmakeraw(3)` and `termios(4)`.
780+
public let terminalMode: TerminalMode
781+
782+
public init(
783+
initialWindowSize: Pseudoterminal.WindowSize,
784+
terminalType: String,
785+
terminalMode: TerminalMode = .cooked
786+
) {
787+
self.initialWindowSize = initialWindowSize
788+
self.terminalType = terminalType
789+
self.terminalMode = terminalMode
790+
}
791+
}
792+
793+
/// `Pseudoterminal` is used to get and update terminal information
794+
/// such as window size and terminal type while the child process
795+
/// is running.
796+
public struct Pseudoterminal: Sendable {
797+
/// `WindowSize` defines the dimensions of a terminal window
798+
public struct WindowSize: Sendable {
799+
public let rows: UInt16
800+
public let columns: UInt16
801+
802+
public init(rows: UInt16, columns: UInt16) {
803+
self.rows = rows
804+
self.columns = columns
805+
}
806+
}
807+
/// The dimension of this terminal window
808+
public var windowSize: WindowSize {
809+
get throws {
810+
var result = winsize()
811+
guard ioctl(self.parentDescriptor, UInt(TIOCGWINSZ), &result) == 0 else {
812+
throw SubprocessError.spawnFailed(
813+
withUnderlyingError: .init(rawValue: errno),
814+
reason: "Failed to get window size"
815+
)
816+
}
817+
return WindowSize(rows: result.ws_row, columns: result.ws_col)
818+
}
819+
}
820+
/// The type of this terminal as defined in `terminfo`
821+
public let terminalType: String
822+
823+
private let parentDescriptor: CInt
824+
825+
/// Update the dimension of this terminal window
826+
public func update(windowSize: WindowSize) throws(SubprocessError) {
827+
var winsize = winsize(
828+
ws_row: windowSize.rows,
829+
ws_col: windowSize.columns,
830+
ws_xpixel: 0,
831+
ws_ypixel: 0
832+
)
833+
guard ioctl(self.parentDescriptor, UInt(TIOCSWINSZ), &winsize) == 0 else {
834+
throw SubprocessError.spawnFailed(
835+
withUnderlyingError: .init(rawValue: errno),
836+
reason: "Failed to set window size"
837+
)
838+
}
839+
}
840+
841+
internal init(parentDescriptor: CInt, terminalType: String) {
842+
self.parentDescriptor = parentDescriptor
843+
self.terminalType = terminalType
844+
}
845+
}
846+
847+
#endif
848+
651849
// MARK: - Internal
652850

653851
extension Configuration {
@@ -685,6 +883,16 @@ extension Configuration {
685883
return self._errorReadEnd.take()
686884
}
687885
}
886+
887+
#if !os(Windows)
888+
internal struct SpawnPTYResult: ~Copyable {
889+
let execution: Execution
890+
let pseudoterminal: Pseudoterminal
891+
let inputWriter: StandardInputWriter
892+
let combinedOutputStream: AsyncBufferSequence
893+
var parentDescriptor: IODescriptor
894+
}
895+
#endif
688896
}
689897

690898
internal enum StringOrRawBytes: Sendable, Hashable {

Sources/Subprocess/IO/AsyncIO+Linux.swift

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -388,8 +388,12 @@ extension AsyncIO {
388388
try self.removeRegistration(for: fileDescriptor)
389389
return resultBuffer
390390
}
391-
} else if bytesRead == 0 {
391+
} else if bytesRead == 0 || capturedErrno == EIO {
392392
// We reached EOF. Return whatever's left
393+
394+
// On Linux, reading from a PTY parent returns EIO
395+
// when the child side is closed (i.e., child exited).
396+
// Treat this as EOF as well
393397
try self.removeRegistration(for: fileDescriptor)
394398
guard readLength > 0 else {
395399
return nil

0 commit comments

Comments
 (0)