@@ -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
154218extension 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
653851extension 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
690898internal enum StringOrRawBytes : Sendable , Hashable {
0 commit comments