@@ -149,6 +149,68 @@ public struct Configuration: Sendable {
149149 )
150150 }
151151 }
152+
153+ internal mutating func runPTY< Result> (
154+ pseudoterminalOptions: PseudoterminalOptions ,
155+ preferredBufferSize: Int ? ,
156+ isolation: isolated ( any Actor ) ? = #isolation,
157+ body: ( Execution , Pseudoterminal , StandardInputWriter , AsyncBufferSequence ) async throws -> Result
158+ ) async throws -> ExecutionOutcome < Result > {
159+ // PTY requires a new session
160+ self . platformOptions. createSession = true
161+ // Update environment and insert TERM
162+ self . environment = self . environment. updating (
163+ [ " TERM " : pseudoterminalOptions. terminalType]
164+ )
165+ // Spawn!
166+ var spawnResults = try await self . spawnPTY (
167+ withOptions: pseudoterminalOptions,
168+ preferredBufferSize: preferredBufferSize
169+ )
170+
171+ let execution = spawnResults. execution
172+ defer {
173+ execution. processIdentifier. close ( )
174+ }
175+
176+ let teardownSequence = self . platformOptions. teardownSequence
177+ return try await withAsyncTaskCleanupHandler {
178+ let result : Swift . Result < Result , any Error >
179+ do {
180+ let bodyResult = try await body (
181+ execution,
182+ spawnResults. pseudoterminal,
183+ spawnResults. inputWriter,
184+ spawnResults. combinedOutputStream
185+ )
186+ result = . success( bodyResult)
187+ } catch {
188+ result = . failure( error)
189+ }
190+
191+ // Ensure that we begin monitoring process termination after `body` runs
192+ // and regardless of whether `body` throws, so that the pid gets reaped
193+ // even if `body` throws, and we are not leaving zombie processes in the
194+ // process table which will cause the process termination monitoring thread
195+ // to effectively hang due to the pid never being awaited
196+ let terminationStatus = try await monitorProcessTermination (
197+ for: execution. processIdentifier
198+ )
199+
200+ // Process has exited. We can/must close parentDescriptor now
201+ try spawnResults. parentDescriptor. safelyClose ( )
202+
203+ return ExecutionOutcome (
204+ terminationStatus: terminationStatus,
205+ value: try result. get ( )
206+ )
207+ } onCleanup: {
208+ // Attempt to terminate the child process
209+ await execution. runTeardownSequence (
210+ teardownSequence
211+ )
212+ }
213+ }
152214}
153215
154216extension Configuration : CustomStringConvertible , CustomDebugStringConvertible {
@@ -648,6 +710,124 @@ extension TerminationStatus: CustomStringConvertible, CustomDebugStringConvertib
648710 }
649711}
650712
713+ // MARK: - PTY
714+
715+ /// Settings to configure the pseudoterminal (PTY) when
716+ /// spawning in PTY mode.
717+ public struct PseudoterminalOptions : Sendable {
718+ /// Terminal mode configuration.
719+ ///
720+ /// On Darwin/Linux, this controls the initial `termios` settings applied to the
721+ /// PTY replica fd at spawn time via `openpty()`.
722+ ///
723+ /// On Windows (ConPTY), terminal mode is managed internally by the pseudo console.
724+ /// The child process controls its own mode via `SetConsoleMode()`. Therefore only
725+ /// `.cooked` mode is available on Windows
726+ public struct TerminalMode : Sendable {
727+ internal enum Storage : Sendable {
728+ case cooked
729+ case raw
730+ #if !os(Windows)
731+ case custom( termios )
732+ #endif
733+ }
734+
735+ internal let storage : Storage
736+
737+ private init ( _ storage: Storage ) {
738+ self . storage = storage
739+ }
740+
741+ /// Default cooked mode with kernel line editing, echo, and signal processing.
742+ /// - Darwin/Linux: `TTYDEF_IFLAG`, `TTYDEF_OFLAG`, `TTYDEF_LFLAG`, `TTYDEF_CFLAG`
743+ /// - Windows: ConPTY default (equivalent to `ENABLE_LINE_INPUT | ENABLE_ECHO_INPUT |
744+ /// ENABLE_PROCESSED_INPUT`)
745+ public static var cooked : Self { . init( . cooked) }
746+
747+ #if !os(Windows)
748+ /// Raw mode — all bytes passed through unmodified. Applies `cfmakeraw()` to the PTY.
749+ public static var raw : Self { . init( . raw) }
750+
751+ /// Custom termios configuration
752+ public static func custom( _ info: termios ) -> Self {
753+ return . init( . custom( info) )
754+ }
755+ #endif
756+ }
757+
758+ /// The initial termianl window size to set to.
759+ public let initialWindowSize : Pseudoterminal . WindowSize
760+ /// The type of this terminal as defined in `terminfo`, used to
761+ /// update TERM environment variable. Terminal type communicates
762+ /// the capabilities, instruction set, and control sequences of a terminal.
763+ public let terminalType : String
764+ /// The initial terminal line discipline mode.
765+ ///
766+ /// - `.cooked` (default): Standard terminal behavior with kernel
767+ /// line editing, echo, and signal generation. Use when spawning
768+ /// shells or general-purpose command-line tools.
769+ /// - `.raw`: Passes all bytes through unmodified. Use when the
770+ /// child process manages its own input handling, or when the
771+ /// parent process implements line editing.
772+ ///
773+ /// The child process may change this at any time via `tcsetattr`.
774+ /// This setting only controls the initial state at spawn time.
775+ ///
776+ /// For more informationm see `cfmakeraw(3)` and `termios(4)`.
777+ public let terminalMode : TerminalMode
778+
779+ public init (
780+ initialWindowSize: Pseudoterminal . WindowSize ,
781+ terminalType: String ,
782+ terminalMode: TerminalMode = . cooked
783+ ) {
784+ self . initialWindowSize = initialWindowSize
785+ self . terminalType = terminalType
786+ self . terminalMode = terminalMode
787+ }
788+ }
789+
790+ /// `Pseudoterminal` is used to get and update terminal information
791+ /// such as window size and terminal type while the child process
792+ /// is running.
793+ public struct Pseudoterminal : Sendable {
794+ /// `WindowSize` defines the dimensions of a terminal window
795+ public struct WindowSize : Sendable {
796+ public let rows : UInt16
797+ public let columns : UInt16
798+
799+ public init ( rows: UInt16 , columns: UInt16 ) {
800+ self . rows = rows
801+ self . columns = columns
802+ }
803+ }
804+ /// The dimension of this terminal window
805+ public var windowSize : WindowSize {
806+ get throws {
807+ var result = winsize ( )
808+ guard ioctl ( self . parentDescriptor, UInt ( TIOCGWINSZ) , & result) == 0 else {
809+ throw SubprocessError . spawnFailed (
810+ withUnderlyingError: . init( rawValue: errno) ,
811+ reason: " Failed to get window size "
812+ )
813+ }
814+ return WindowSize ( rows: result. ws_row, columns: result. ws_col)
815+ }
816+ }
817+ /// The type of this terminal as defined in `terminfo`
818+ public let terminalType : String
819+
820+ private let parentDescriptor : CInt
821+
822+ /// Update the dimension of this terminal window
823+ public func update( windowSize: WindowSize ) throws ( SubprocessError) { }
824+
825+ internal init ( parentDescriptor: CInt , terminalType: String ) {
826+ self . parentDescriptor = parentDescriptor
827+ self . terminalType = terminalType
828+ }
829+ }
830+
651831// MARK: - Internal
652832
653833extension Configuration {
@@ -685,6 +865,14 @@ extension Configuration {
685865 return self . _errorReadEnd. take ( )
686866 }
687867 }
868+
869+ internal struct SpawnPTYResult : ~ Copyable {
870+ let execution : Execution
871+ let pseudoterminal : Pseudoterminal
872+ let inputWriter : StandardInputWriter
873+ let combinedOutputStream : AsyncBufferSequence
874+ var parentDescriptor : IODescriptor
875+ }
688876}
689877
690878internal enum StringOrRawBytes : Sendable , Hashable {
0 commit comments