From d9d3f4b8d8ca4df3f0f96f4ba93c6372dc08c706 Mon Sep 17 00:00:00 2001 From: Charles Hu Date: Mon, 16 Mar 2026 15:36:51 -0700 Subject: [PATCH] Introduce PTY support This patch introduces two run overloads for spawning processes in PTY mode on Darwin and Linux --- Sources/Subprocess/API.swift | 79 +++++ Sources/Subprocess/Configuration.swift | 208 ++++++++++++ Sources/Subprocess/IO/AsyncIO+Linux.swift | 6 +- .../Platforms/Subprocess+Darwin.swift | 303 +++++++----------- .../Platforms/Subprocess+Unix.swift | 266 +++++++++++---- .../_SubprocessCShims/include/process_shims.h | 3 +- Sources/_SubprocessCShims/process_shims.c | 15 +- Tests/SubprocessTests/IntegrationTests.swift | 122 +++++++ 8 files changed, 750 insertions(+), 252 deletions(-) diff --git a/Sources/Subprocess/API.swift b/Sources/Subprocess/API.swift index df52ef93..324fe029 100644 --- a/Sources/Subprocess/API.swift +++ b/Sources/Subprocess/API.swift @@ -382,6 +382,52 @@ public func run( ) } +#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( + _ 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 { + 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 @@ -860,3 +906,36 @@ public func run( 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( + _ configuration: Configuration, + pseudoterminalOptions: PseudoterminalOptions, + preferredBufferSize: Int? = nil, + isolation: isolated (any Actor)? = #isolation, + body: (Execution, Pseudoterminal, StandardInputWriter, AsyncBufferSequence) async throws -> Result +) async throws -> ExecutionOutcome { + // Update environment and insert TERM + var updatedConfiguration = configuration + return try await updatedConfiguration.runPTY( + pseudoterminalOptions: pseudoterminalOptions, + preferredBufferSize: preferredBufferSize, + body: body + ) +} +#endif diff --git a/Sources/Subprocess/Configuration.swift b/Sources/Subprocess/Configuration.swift index 0425893d..a109dc93 100644 --- a/Sources/Subprocess/Configuration.swift +++ b/Sources/Subprocess/Configuration.swift @@ -149,6 +149,70 @@ public struct Configuration: Sendable { ) } } + + #if !os(Windows) + internal mutating func runPTY( + pseudoterminalOptions: PseudoterminalOptions, + preferredBufferSize: Int?, + isolation: isolated (any Actor)? = #isolation, + body: (Execution, Pseudoterminal, StandardInputWriter, AsyncBufferSequence) async throws -> Result + ) async throws -> ExecutionOutcome { + // 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 + 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 { @@ -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 + /// 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 { + 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 { @@ -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 { diff --git a/Sources/Subprocess/IO/AsyncIO+Linux.swift b/Sources/Subprocess/IO/AsyncIO+Linux.swift index 53eeb9ee..54b5dc2d 100644 --- a/Sources/Subprocess/IO/AsyncIO+Linux.swift +++ b/Sources/Subprocess/IO/AsyncIO+Linux.swift @@ -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 + // 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 diff --git a/Sources/Subprocess/Platforms/Subprocess+Darwin.swift b/Sources/Subprocess/Platforms/Subprocess+Darwin.swift index 45f03943..4baef20c 100644 --- a/Sources/Subprocess/Platforms/Subprocess+Darwin.swift +++ b/Sources/Subprocess/Platforms/Subprocess+Darwin.swift @@ -185,32 +185,17 @@ extension Configuration { let gidPtr: UnsafeMutablePointer? } - internal func spawn( - withInput inputPipe: consuming CreatedPipe, - outputPipe: consuming CreatedPipe, - errorPipe: consuming CreatedPipe - ) async throws -> SpawnResult { + internal func spawnCore( + fileActionsConfig: (inout posix_spawn_file_actions_t?) throws -> Void, + ) async throws -> Execution { // Instead of checking if every possible executable path // is valid, spawn each directly and catch ENOENT let possiblePaths = self.executable.possibleExecutablePaths( withPathValue: self.environment.pathValue() ) - var inputPipeBox: CreatedPipe? = consume inputPipe - var outputPipeBox: CreatedPipe? = consume outputPipe - var errorPipeBox: CreatedPipe? = consume errorPipe - return try await self.preSpawn { args throws -> SpawnResult in + return try await self.preSpawn { args throws -> Execution in let (env, uidPtr, gidPtr, supplementaryGroups) = args - var _inputPipe = inputPipeBox.take()! - var _outputPipe = outputPipeBox.take()! - var _errorPipe = errorPipeBox.take()! - - let inputReadFileDescriptor: IODescriptor? = _inputPipe.readFileDescriptor() - let inputWriteFileDescriptor: IODescriptor? = _inputPipe.writeFileDescriptor() - let outputReadFileDescriptor: IODescriptor? = _outputPipe.readFileDescriptor() - let outputWriteFileDescriptor: IODescriptor? = _outputPipe.writeFileDescriptor() - let errorReadFileDescriptor: IODescriptor? = _errorPipe.readFileDescriptor() - let errorWriteFileDescriptor: IODescriptor? = _errorPipe.writeFileDescriptor() for possibleExecutablePath in possiblePaths { // Setup Arguments @@ -221,7 +206,7 @@ extension Configuration { for ptr in argv { ptr?.deallocate() } } - // Setup file actions and spawn attributes + // Setup file actions var fileActions: posix_spawn_file_actions_t? = nil var spawnAttributes: posix_spawnattr_t? = nil // Setup stdin, stdout, and stderr @@ -229,121 +214,8 @@ extension Configuration { defer { posix_spawn_file_actions_destroy(&fileActions) } + try fileActionsConfig(&fileActions) - // Input - var result: Int32 = -1 - if inputReadFileDescriptor != nil { - result = posix_spawn_file_actions_adddup2( - &fileActions, inputReadFileDescriptor!.platformDescriptor(), 0) - guard result == 0 else { - try self.safelyCloseMultiple( - inputRead: inputReadFileDescriptor, - inputWrite: inputWriteFileDescriptor, - outputRead: outputReadFileDescriptor, - outputWrite: outputWriteFileDescriptor, - errorRead: errorReadFileDescriptor, - errorWrite: errorWriteFileDescriptor - ) - throw SubprocessError.spawnFailed( - withUnderlyingError: Errno(rawValue: result) - ) - } - } - if inputWriteFileDescriptor != nil { - // Close parent side - result = posix_spawn_file_actions_addclose( - &fileActions, inputWriteFileDescriptor!.platformDescriptor() - ) - guard result == 0 else { - try self.safelyCloseMultiple( - inputRead: inputReadFileDescriptor, - inputWrite: inputWriteFileDescriptor, - outputRead: outputReadFileDescriptor, - outputWrite: outputWriteFileDescriptor, - errorRead: errorReadFileDescriptor, - errorWrite: errorWriteFileDescriptor - ) - throw SubprocessError.spawnFailed( - withUnderlyingError: Errno(rawValue: result) - ) - } - } - // Output - if outputWriteFileDescriptor != nil { - result = posix_spawn_file_actions_adddup2( - &fileActions, outputWriteFileDescriptor!.platformDescriptor(), 1 - ) - guard result == 0 else { - try self.safelyCloseMultiple( - inputRead: inputReadFileDescriptor, - inputWrite: inputWriteFileDescriptor, - outputRead: outputReadFileDescriptor, - outputWrite: outputWriteFileDescriptor, - errorRead: errorReadFileDescriptor, - errorWrite: errorWriteFileDescriptor - ) - throw SubprocessError.spawnFailed( - withUnderlyingError: Errno(rawValue: result) - ) - } - } - if outputReadFileDescriptor != nil { - // Close parent side - result = posix_spawn_file_actions_addclose( - &fileActions, outputReadFileDescriptor!.platformDescriptor() - ) - guard result == 0 else { - try self.safelyCloseMultiple( - inputRead: inputReadFileDescriptor, - inputWrite: inputWriteFileDescriptor, - outputRead: outputReadFileDescriptor, - outputWrite: outputWriteFileDescriptor, - errorRead: errorReadFileDescriptor, - errorWrite: errorWriteFileDescriptor - ) - throw SubprocessError.spawnFailed( - withUnderlyingError: Errno(rawValue: result) - ) - } - } - // Error - if errorWriteFileDescriptor != nil { - result = posix_spawn_file_actions_adddup2( - &fileActions, errorWriteFileDescriptor!.platformDescriptor(), 2 - ) - guard result == 0 else { - try self.safelyCloseMultiple( - inputRead: inputReadFileDescriptor, - inputWrite: inputWriteFileDescriptor, - outputRead: outputReadFileDescriptor, - outputWrite: outputWriteFileDescriptor, - errorRead: errorReadFileDescriptor, - errorWrite: errorWriteFileDescriptor - ) - throw SubprocessError.spawnFailed( - withUnderlyingError: Errno(rawValue: result) - ) - } - } - if errorReadFileDescriptor != nil { - // Close parent side - result = posix_spawn_file_actions_addclose( - &fileActions, errorReadFileDescriptor!.platformDescriptor() - ) - guard result == 0 else { - try self.safelyCloseMultiple( - inputRead: inputReadFileDescriptor, - inputWrite: inputWriteFileDescriptor, - outputRead: outputReadFileDescriptor, - outputWrite: outputWriteFileDescriptor, - errorRead: errorReadFileDescriptor, - errorWrite: errorWriteFileDescriptor - ) - throw SubprocessError.spawnFailed( - withUnderlyingError: Errno(rawValue: result) - ) - } - } // Setup spawnAttributes posix_spawnattr_init(&spawnAttributes) defer { @@ -362,6 +234,9 @@ extension Configuration { flags |= POSIX_SPAWN_SETPGROUP spawnAttributeError = posix_spawnattr_setpgroup(&spawnAttributes, pid_t(pgid)) } + if self.platformOptions.createSession { + flags |= POSIX_SPAWN_SETSID + } spawnAttributeError = posix_spawnattr_setflags(&spawnAttributes, Int16(flags)) // Set QualityOfService // spanattr_qos seems to only accept `QOS_CLASS_UTILITY` or `QOS_CLASS_BACKGROUND` @@ -384,18 +259,9 @@ extension Configuration { // Error handling if chdirError != 0 || spawnAttributeError != 0 { - try self.safelyCloseMultiple( - inputRead: inputReadFileDescriptor, - inputWrite: inputWriteFileDescriptor, - outputRead: outputReadFileDescriptor, - outputWrite: outputWriteFileDescriptor, - errorRead: errorReadFileDescriptor, - errorWrite: errorWriteFileDescriptor - ) - let error: SubprocessError if spawnAttributeError != 0 { - error = SubprocessError.spawnFailed(withUnderlyingError: Errno(rawValue: result)) + error = SubprocessError.spawnFailed(withUnderlyingError: Errno(rawValue: spawnAttributeError)) } else { error = SubprocessError.failedToChangeWorkingDirectory( self.workingDirectory?.string, @@ -434,8 +300,7 @@ extension Configuration { spawnContext.uidPtr, spawnContext.gidPtr, Int32(supplementaryGroups?.count ?? 0), - sgroups?.baseAddress, - self.platformOptions.createSession ? 1 : 0 + sgroups?.baseAddress ) return (rc, pid) } @@ -448,36 +313,12 @@ extension Configuration { continue } // Throw all other errors - try self.safelyCloseMultiple( - inputRead: inputReadFileDescriptor, - inputWrite: inputWriteFileDescriptor, - outputRead: outputReadFileDescriptor, - outputWrite: outputWriteFileDescriptor, - errorRead: errorReadFileDescriptor, - errorWrite: errorWriteFileDescriptor - ) throw SubprocessError.spawnFailed(withUnderlyingError: Errno(rawValue: spawnError)) } - - // After spawn finishes, close all child side fds - try self.safelyCloseMultiple( - inputRead: inputReadFileDescriptor, - inputWrite: nil, - outputRead: nil, - outputWrite: outputWriteFileDescriptor, - errorRead: nil, - errorWrite: errorWriteFileDescriptor - ) - let execution = Execution( processIdentifier: .init(value: pid) ) - return SpawnResult( - execution: execution, - inputWriteEnd: inputWriteFileDescriptor?.createIOChannel(), - outputReadEnd: outputReadFileDescriptor?.createIOChannel(), - errorReadEnd: errorReadFileDescriptor?.createIOChannel() - ) + return execution } // If we reach this point, it means either the executable path @@ -485,14 +326,6 @@ extension Configuration { // provide which one is not valid, here we make a best effort guess // by checking whether the working directory is valid. This technically // still causes TOUTOC issue, but it's the best we can do for error recovery. - try self.safelyCloseMultiple( - inputRead: inputReadFileDescriptor, - inputWrite: inputWriteFileDescriptor, - outputRead: outputReadFileDescriptor, - outputWrite: outputWriteFileDescriptor, - errorRead: errorReadFileDescriptor, - errorWrite: errorWriteFileDescriptor - ) if let workingDirectory = self.workingDirectory?.string { guard Configuration.pathAccessible(workingDirectory, mode: F_OK) else { throw SubprocessError.failedToChangeWorkingDirectory( @@ -507,6 +340,118 @@ extension Configuration { ) } } + + internal func spawn( + withInput inputPipe: consuming CreatedPipe, + outputPipe: consuming CreatedPipe, + errorPipe: consuming CreatedPipe + ) async throws -> SpawnResult { + let inputReadFileDescriptor: IODescriptor? = inputPipe.readFileDescriptor() + let inputWriteFileDescriptor: IODescriptor? = inputPipe.writeFileDescriptor() + let outputReadFileDescriptor: IODescriptor? = outputPipe.readFileDescriptor() + let outputWriteFileDescriptor: IODescriptor? = outputPipe.writeFileDescriptor() + let errorReadFileDescriptor: IODescriptor? = errorPipe.readFileDescriptor() + let errorWriteFileDescriptor: IODescriptor? = errorPipe.writeFileDescriptor() + + let execution: Execution + do { + execution = try await self.spawnCore { fileActions in + // Input + var result: Int32 = -1 + if inputReadFileDescriptor != nil { + result = posix_spawn_file_actions_adddup2( + &fileActions, inputReadFileDescriptor!.platformDescriptor(), 0) + guard result == 0 else { + throw SubprocessError.spawnFailed( + withUnderlyingError: Errno(rawValue: result) + ) + } + } + if inputWriteFileDescriptor != nil { + // Close parent side + result = posix_spawn_file_actions_addclose( + &fileActions, inputWriteFileDescriptor!.platformDescriptor() + ) + guard result == 0 else { + throw SubprocessError.spawnFailed( + withUnderlyingError: Errno(rawValue: result) + ) + } + } + // Output + if outputWriteFileDescriptor != nil { + result = posix_spawn_file_actions_adddup2( + &fileActions, outputWriteFileDescriptor!.platformDescriptor(), 1 + ) + guard result == 0 else { + throw SubprocessError.spawnFailed( + withUnderlyingError: Errno(rawValue: result) + ) + } + } + if outputReadFileDescriptor != nil { + // Close parent side + result = posix_spawn_file_actions_addclose( + &fileActions, outputReadFileDescriptor!.platformDescriptor() + ) + guard result == 0 else { + throw SubprocessError.spawnFailed( + withUnderlyingError: Errno(rawValue: result) + ) + } + } + // Error + if errorWriteFileDescriptor != nil { + result = posix_spawn_file_actions_adddup2( + &fileActions, errorWriteFileDescriptor!.platformDescriptor(), 2 + ) + guard result == 0 else { + throw SubprocessError.spawnFailed( + withUnderlyingError: Errno(rawValue: result) + ) + } + } + if errorReadFileDescriptor != nil { + // Close parent side + result = posix_spawn_file_actions_addclose( + &fileActions, errorReadFileDescriptor!.platformDescriptor() + ) + guard result == 0 else { + throw SubprocessError.spawnFailed( + withUnderlyingError: Errno(rawValue: result) + ) + } + } + } + } catch { + try? self.safelyCloseMultiple( + inputRead: inputReadFileDescriptor, + inputWrite: inputWriteFileDescriptor, + outputRead: outputReadFileDescriptor, + outputWrite: outputWriteFileDescriptor, + errorRead: errorReadFileDescriptor, + errorWrite: errorWriteFileDescriptor + ) + throw error + } + + // After spawn finishes, close all child side fds + try self.safelyCloseMultiple( + inputRead: inputReadFileDescriptor, + inputWrite: nil, + outputRead: nil, + outputWrite: outputWriteFileDescriptor, + errorRead: nil, + errorWrite: errorWriteFileDescriptor + ) + + return SpawnResult( + execution: execution, + inputWriteEnd: inputWriteFileDescriptor?.createIOChannel(), + outputReadEnd: outputReadFileDescriptor?.createIOChannel(), + errorReadEnd: errorReadFileDescriptor?.createIOChannel() + ) + } } // MARK: - ProcessIdentifier diff --git a/Sources/Subprocess/Platforms/Subprocess+Unix.swift b/Sources/Subprocess/Platforms/Subprocess+Unix.swift index 24cea157..8b85f3e7 100644 --- a/Sources/Subprocess/Platforms/Subprocess+Unix.swift +++ b/Sources/Subprocess/Platforms/Subprocess+Unix.swift @@ -406,6 +406,142 @@ internal typealias PlatformFileDescriptor = CInt // MARK: - Spawning +extension Configuration { + internal func spawnPTY( + withOptions ptyOptions: PseudoterminalOptions, + preferredBufferSize: Int? + ) async throws -> SpawnPTYResult { + var termiosConfig = termios() + switch ptyOptions.terminalMode.storage { + case .cooked: + // Cooked mode: standard terminal behavior + // From ttydefaults.h + let _TTYDEF_CFLAG_VALUE = tcflag_t(CREAD | CS8 | HUPCL) + let _TTYDEF_LFLAG_VALUE = tcflag_t(ECHO | ICANON | ISIG | IEXTEN | ECHOE | ECHOKE | ECHOCTL) + let _TTYDEF_IFLAG_VALUE = tcflag_t(BRKINT | ICRNL | IMAXBEL | IXON | IXANY) + let _TTYDEF_OFLAG_VALUE = tcflag_t(OPOST | ONLCR) + termiosConfig.c_cflag = _TTYDEF_CFLAG_VALUE + termiosConfig.c_lflag = _TTYDEF_LFLAG_VALUE + termiosConfig.c_iflag = _TTYDEF_IFLAG_VALUE + termiosConfig.c_oflag = _TTYDEF_OFLAG_VALUE + case .raw: + cfmakeraw(&termiosConfig) + case .custom(let config): + termiosConfig = config + } + var windowSize = winsize( + ws_row: ptyOptions.initialWindowSize.rows, + ws_col: ptyOptions.initialWindowSize.columns, + ws_xpixel: 0, + ws_ypixel: 0 + ) + + var parentRawFD: CInt = -1 + var childRawFD: CInt = -1 + guard openpty(&parentRawFD, &childRawFD, nil, &termiosConfig, &windowSize) == 0 else { + throw SubprocessError.spawnFailed( + withUnderlyingError: .init(rawValue: errno), + reason: "openpty failed" + ) + } + var parentDescriptor = IODescriptor(FileDescriptor(rawValue: parentRawFD), closeWhenDone: true) + var childDescriptor = IODescriptor(FileDescriptor(rawValue: childRawFD), closeWhenDone: true) + + let execution: Execution + do { + execution = try await spawnCore { fileAttr in + #if canImport(Darwin) + // Darwin: use posix_spawn + // Input + var result: Int32 = posix_spawn_file_actions_adddup2( + &fileAttr, childDescriptor.platformDescriptor(), 0 + ) + guard result == 0 else { + throw SubprocessError.spawnFailed( + withUnderlyingError: Errno(rawValue: result) + ) + } + // Output + result = posix_spawn_file_actions_adddup2( + &fileAttr, childDescriptor.platformDescriptor(), 1 + ) + guard result == 0 else { + throw SubprocessError.spawnFailed( + withUnderlyingError: Errno(rawValue: result) + ) + } + // Error + result = posix_spawn_file_actions_adddup2( + &fileAttr, childDescriptor.platformDescriptor(), 2 + ) + guard result == 0 else { + throw SubprocessError.spawnFailed( + withUnderlyingError: Errno(rawValue: result) + ) + } + // Close original parent and childID + result = posix_spawn_file_actions_addclose( + &fileAttr, parentDescriptor.platformDescriptor() + ) + guard result == 0 else { + throw SubprocessError.spawnFailed( + withUnderlyingError: Errno(rawValue: result) + ) + } + result = posix_spawn_file_actions_addclose( + &fileAttr, childDescriptor.platformDescriptor() + ) + guard result == 0 else { + throw SubprocessError.spawnFailed( + withUnderlyingError: Errno(rawValue: result) + ) + } + #else + // Other Unix-like system: use fork/exec + // Bind child descriptor to standard input, output, and error + fileAttr = [ + childDescriptor.platformDescriptor(), // Input Read + -1, // Input Write (unused) + childDescriptor.platformDescriptor(), // Output Write + -1, // Output Read (unused) + childDescriptor.platformDescriptor(), // Error Write + -1, // Error Read (unused) + ] + #endif + } + } catch { + // Close parentFD and childFD + try? parentDescriptor.safelyClose() + try? childDescriptor.safelyClose() + + throw error + } + // After spawn finishes, close childFD + try childDescriptor.safelyClose() + // Duplicate parent descriptor for read/write + let readDescriptor = try parentDescriptor.duplicate() + let writeDescriptor = try parentDescriptor.duplicate() + + let inputWriter = StandardInputWriter( + diskIO: writeDescriptor.createIOChannel() + ) + let outputSequence = AsyncBufferSequence( + diskIO: readDescriptor.createIOChannel().consumeIOChannel(), + preferredBufferSize: preferredBufferSize + ) + return SpawnPTYResult( + execution: execution, + pseudoterminal: Pseudoterminal( + parentDescriptor: parentRawFD, + terminalType: ptyOptions.terminalType + ), + inputWriter: inputWriter, + combinedOutputStream: outputSequence, + parentDescriptor: parentDescriptor + ) + } +} + #if !canImport(Darwin) extension Configuration { @@ -421,11 +557,9 @@ extension Configuration { let processGroupIDPtr: UnsafeMutablePointer? } - internal func spawn( - withInput inputPipe: consuming CreatedPipe, - outputPipe: consuming CreatedPipe, - errorPipe: consuming CreatedPipe - ) async throws -> SpawnResult { + internal func spawnCore( + fileActionConfig: (inout [CInt]) throws -> Void + ) async throws -> Execution { // Ensure the waiter thread is running. #if os(Linux) || os(Android) _setupMonitorSignalHandler() @@ -436,24 +570,10 @@ extension Configuration { let possiblePaths = self.executable.possibleExecutablePaths( withPathValue: self.environment.pathValue() ) - var inputPipeBox: CreatedPipe? = consume inputPipe - var outputPipeBox: CreatedPipe? = consume outputPipe - var errorPipeBox: CreatedPipe? = consume errorPipe - return try await self.preSpawn { args throws -> SpawnResult in + return try await self.preSpawn { args throws -> Execution in let (env, uidPtr, gidPtr, supplementaryGroups) = args - var _inputPipe = inputPipeBox.take()! - var _outputPipe = outputPipeBox.take()! - var _errorPipe = errorPipeBox.take()! - - let inputReadFileDescriptor: IODescriptor? = _inputPipe.readFileDescriptor() - let inputWriteFileDescriptor: IODescriptor? = _inputPipe.writeFileDescriptor() - let outputReadFileDescriptor: IODescriptor? = _outputPipe.readFileDescriptor() - let outputWriteFileDescriptor: IODescriptor? = _outputPipe.writeFileDescriptor() - let errorReadFileDescriptor: IODescriptor? = _errorPipe.readFileDescriptor() - let errorWriteFileDescriptor: IODescriptor? = _errorPipe.writeFileDescriptor() - for possibleExecutablePath in possiblePaths { var processGroupIDPtr: UnsafeMutablePointer? = nil if let processGroupID = self.platformOptions.processGroupID { @@ -468,14 +588,9 @@ extension Configuration { for ptr in argv { ptr?.deallocate() } } // Setup input - let fileDescriptors: [CInt] = [ - inputReadFileDescriptor?.platformDescriptor() ?? -1, - inputWriteFileDescriptor?.platformDescriptor() ?? -1, - outputWriteFileDescriptor?.platformDescriptor() ?? -1, - outputReadFileDescriptor?.platformDescriptor() ?? -1, - errorWriteFileDescriptor?.platformDescriptor() ?? -1, - errorReadFileDescriptor?.platformDescriptor() ?? -1, - ] + var _fileDescriptors: [CInt] = Array(repeating: -1, count: 6) + try fileActionConfig(&_fileDescriptors) + let fileDescriptors = _fileDescriptors // Spawn let spawnContext = SpawnContext( @@ -517,37 +632,15 @@ extension Configuration { continue } // Throw all other errors - try self.safelyCloseMultiple( - inputRead: inputReadFileDescriptor, - inputWrite: inputWriteFileDescriptor, - outputRead: outputReadFileDescriptor, - outputWrite: outputWriteFileDescriptor, - errorRead: errorReadFileDescriptor, - errorWrite: errorWriteFileDescriptor - ) throw SubprocessError.spawnFailed(withUnderlyingError: Errno(rawValue: spawnError)) } - // After spawn finishes, close all child side fds - try self.safelyCloseMultiple( - inputRead: inputReadFileDescriptor, - inputWrite: nil, - outputRead: nil, - outputWrite: outputWriteFileDescriptor, - errorRead: nil, - errorWrite: errorWriteFileDescriptor - ) let execution = Execution( processIdentifier: .init( value: pid, processDescriptor: processDescriptor ) ) - return SpawnResult( - execution: execution, - inputWriteEnd: inputWriteFileDescriptor?.createIOChannel(), - outputReadEnd: outputReadFileDescriptor?.createIOChannel(), - errorReadEnd: errorReadFileDescriptor?.createIOChannel() - ) + return execution } // If we reach this point, it means either the executable path @@ -555,14 +648,6 @@ extension Configuration { // provide which one is not valid, here we make a best effort guess // by checking whether the working directory is valid. This technically // still causes TOUTOC issue, but it's the best we can do for error recovery. - try self.safelyCloseMultiple( - inputRead: inputReadFileDescriptor, - inputWrite: inputWriteFileDescriptor, - outputRead: outputReadFileDescriptor, - outputWrite: outputWriteFileDescriptor, - errorRead: errorReadFileDescriptor, - errorWrite: errorWriteFileDescriptor - ) if let workingDirectory = self.workingDirectory?.string { guard Configuration.pathAccessible(workingDirectory, mode: F_OK) else { throw SubprocessError.failedToChangeWorkingDirectory( @@ -577,6 +662,69 @@ extension Configuration { ) } } + + internal func spawn( + withInput inputPipe: consuming CreatedPipe, + outputPipe: consuming CreatedPipe, + errorPipe: consuming CreatedPipe + ) async throws -> SpawnResult { + + var inputPipeBox: CreatedPipe? = consume inputPipe + var outputPipeBox: CreatedPipe? = consume outputPipe + var errorPipeBox: CreatedPipe? = consume errorPipe + + var _inputPipe = inputPipeBox.take()! + var _outputPipe = outputPipeBox.take()! + var _errorPipe = errorPipeBox.take()! + + let inputReadFileDescriptor: IODescriptor? = _inputPipe.readFileDescriptor() + let inputWriteFileDescriptor: IODescriptor? = _inputPipe.writeFileDescriptor() + let outputReadFileDescriptor: IODescriptor? = _outputPipe.readFileDescriptor() + let outputWriteFileDescriptor: IODescriptor? = _outputPipe.writeFileDescriptor() + let errorReadFileDescriptor: IODescriptor? = _errorPipe.readFileDescriptor() + let errorWriteFileDescriptor: IODescriptor? = _errorPipe.writeFileDescriptor() + + let execution: Execution + do { + execution = try await self.spawnCore { fds in + // Setup input + fds = [ + inputReadFileDescriptor?.platformDescriptor() ?? -1, + inputWriteFileDescriptor?.platformDescriptor() ?? -1, + outputWriteFileDescriptor?.platformDescriptor() ?? -1, + outputReadFileDescriptor?.platformDescriptor() ?? -1, + errorWriteFileDescriptor?.platformDescriptor() ?? -1, + errorReadFileDescriptor?.platformDescriptor() ?? -1, + ] + } + } catch { + try? self.safelyCloseMultiple( + inputRead: inputReadFileDescriptor, + inputWrite: inputWriteFileDescriptor, + outputRead: outputReadFileDescriptor, + outputWrite: outputWriteFileDescriptor, + errorRead: errorReadFileDescriptor, + errorWrite: errorWriteFileDescriptor + ) + throw error + } + + // After spawn finishes, close all child side fds + try self.safelyCloseMultiple( + inputRead: inputReadFileDescriptor, + inputWrite: nil, + outputRead: nil, + outputWrite: outputWriteFileDescriptor, + errorRead: nil, + errorWrite: errorWriteFileDescriptor + ) + return SpawnResult( + execution: execution, + inputWriteEnd: inputWriteFileDescriptor?.createIOChannel(), + outputReadEnd: outputReadFileDescriptor?.createIOChannel(), + errorReadEnd: errorReadFileDescriptor?.createIOChannel() + ) + } } // MARK: - ProcessIdentifier diff --git a/Sources/_SubprocessCShims/include/process_shims.h b/Sources/_SubprocessCShims/include/process_shims.h index 0eef4141..c4adb44b 100644 --- a/Sources/_SubprocessCShims/include/process_shims.h +++ b/Sources/_SubprocessCShims/include/process_shims.h @@ -65,8 +65,7 @@ int _subprocess_spawn( char * _Nullable const env[_Nullable], uid_t * _Nullable uid, gid_t * _Nullable gid, - int number_of_sgroups, const gid_t * _Nullable sgroups, - int create_session + int number_of_sgroups, const gid_t * _Nullable sgroups ); #endif // TARGET_OS_MAC diff --git a/Sources/_SubprocessCShims/process_shims.c b/Sources/_SubprocessCShims/process_shims.c index 8fd234bb..dd6977c9 100644 --- a/Sources/_SubprocessCShims/process_shims.c +++ b/Sources/_SubprocessCShims/process_shims.c @@ -108,8 +108,7 @@ static int _subprocess_spawn_prefork( char * _Nullable const env[_Nullable], uid_t * _Nullable uid, gid_t * _Nullable gid, - int number_of_sgroups, const gid_t * _Nullable sgroups, - int create_session + int number_of_sgroups, const gid_t * _Nullable sgroups ) { #define write_error_and_exit int error = errno; \ write(pipefd[1], &error, sizeof(error));\ @@ -198,10 +197,6 @@ static int _subprocess_spawn_prefork( } } - if (create_session != 0) { - (void)setsid(); - } - // Use posix_spawnas exec int error = posix_spawn(pid, exec_path, file_actions, spawn_attrs, args, env); // If we reached this point, something went wrong @@ -249,13 +244,11 @@ int _subprocess_spawn( char * _Nullable const env[_Nullable], uid_t * _Nullable uid, gid_t * _Nullable gid, - int number_of_sgroups, const gid_t * _Nullable sgroups, - int create_session + int number_of_sgroups, const gid_t * _Nullable sgroups ) { int require_pre_fork = uid != NULL || gid != NULL || - number_of_sgroups > 0 || - create_session > 0; + number_of_sgroups > 0; if (require_pre_fork != 0) { int rc = _subprocess_spawn_prefork( @@ -263,7 +256,7 @@ int _subprocess_spawn( exec_path, file_actions, spawn_attrs, args, env, - uid, gid, number_of_sgroups, sgroups, create_session + uid, gid, number_of_sgroups, sgroups ); return rc; } diff --git a/Tests/SubprocessTests/IntegrationTests.swift b/Tests/SubprocessTests/IntegrationTests.swift index 6ae02cc4..54fea6e8 100644 --- a/Tests/SubprocessTests/IntegrationTests.swift +++ b/Tests/SubprocessTests/IntegrationTests.swift @@ -2042,6 +2042,109 @@ extension SubprocessIntegrationTests { } } +// MARK: - Pseudoterminal Tests +#if !os(Windows) +extension SubprocessIntegrationTests { + @Test func testPTYChildProcessSeesTerminal() async throws { + let setup = TestSetup( + executable: .path("/usr/bin/tty"), + arguments: [] + ) + + _ = try await _run( + setup, + pseudoterminalOptions: PseudoterminalOptions( + initialWindowSize: .init(rows: 40, columns: 120), + terminalType: "xterm-256color" + ), + preferredBufferSize: 1 + ) { execution, terminal, standardInput, outputStream in + for try await line in outputStream.lines() { + // Normal spawn causes tty to return `not a tty` + #if canImport(Darwin) + #expect(line.contains("/dev/ttys")) + #else + #expect(line.contains("/dev/pts")) + #endif + } + } + } + + @Test func testPTYLsPrintsColor() async throws { + // Make sure ls -G now can print color + #if canImport(Darwin) + let setup = TestSetup( + executable: .path("/bin/ls"), + arguments: ["-G"] + ) + #else + let setup = TestSetup( + executable: .path("/bin/ls"), + arguments: ["--color=always"] + ) + #endif + + _ = try await _run( + setup, + pseudoterminalOptions: PseudoterminalOptions( + initialWindowSize: .init(rows: 128, columns: 128), + terminalType: "xterm-256color" + ), + preferredBufferSize: 1 + ) { execution, terminal, standardInput, outputStream in + var combinedOutput = "" + for try await line in outputStream.lines() { + combinedOutput += line + } + // ANSI escape sequences start with ESC[ + #expect(combinedOutput.contains("\u{1B}[")) + } + } + + @Test func testPTYWindowSize() async throws { + // Make sure initialWindowSize is correctly set + let setup = TestSetup( + executable: .path("/bin/stty"), + arguments: ["size"] + ) + + _ = try await _run( + setup, + pseudoterminalOptions: PseudoterminalOptions( + initialWindowSize: .init(rows: 64, columns: 128), + terminalType: "xterm-256color" + ), + preferredBufferSize: 1 + ) { execution, terminal, standardInput, outputStream in + for try await line in outputStream.lines() { + #expect(line.trimmingNewLineAndQuotes() == "64 128") + } + } + } + + @Test func testPTYTerminalType() async throws { + // Make sure terminalType is correct set + let setup = TestSetup( + executable: .path("/usr/bin/printenv"), + arguments: ["TERM"] + ) + + _ = try await _run( + setup, + pseudoterminalOptions: PseudoterminalOptions( + initialWindowSize: .init(rows: 64, columns: 128), + terminalType: "xterm-256color" + ), + preferredBufferSize: 1 + ) { execution, terminal, standardInput, outputStream in + for try await line in outputStream.lines() { + #expect(line.trimmingNewLineAndQuotes() == "xterm-256color") + } + } + } +} +#endif + // MARK: - Other Tests extension SubprocessIntegrationTests { @Test func testTerminateProcess() async throws { @@ -2740,6 +2843,25 @@ func _run( ) } +#if !os(Windows) +func _run( + _ setup: TestSetup, + pseudoterminalOptions: PseudoterminalOptions, + preferredBufferSize: Int? = nil, + body: (Execution, Pseudoterminal, StandardInputWriter, AsyncBufferSequence) async throws -> Result +) async throws -> ExecutionOutcome { + return try await Subprocess.run( + setup.executable, + arguments: setup.arguments, + environment: setup.environment, + workingDirectory: setup.workingDirectory, + pseudoterminalOptions: pseudoterminalOptions, + preferredBufferSize: preferredBufferSize, + body: body + ) +} +#endif + extension FileDescriptor { /// Runs a closure and then closes the FileDescriptor, even if an error occurs. ///