Skip to content

Commit 3656ded

Browse files
committed
Introduce PTY support
1 parent 7928f39 commit 3656ded

8 files changed

Lines changed: 732 additions & 251 deletions

File tree

Sources/Subprocess/API.swift

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

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

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

Sources/Subprocess/Configuration.swift

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

154216
extension 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

653833
extension 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

690878
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)