Add support for pseudo-terminal (PTY) mode in CLI commands#309
Add support for pseudo-terminal (PTY) mode in CLI commands#309thomhurst wants to merge 17 commits intoTyrrrz:primefrom
Conversation
…ing and validation
There was a problem hiding this comment.
Pull request overview
This PR adds support for pseudo-terminal (PTY) mode in CLI command execution, enabling applications to behave as if running in an interactive terminal with features like colored output and progress indicators. The implementation is cross-platform, supporting Windows ConPTY (Windows 10 1809+), Linux, and macOS through native PTY APIs.
Key changes:
- Platform-specific PTY implementations using Windows ConPTY and Unix openpty
- New public API for configuring PTY options (
PseudoTerminalOptions,PseudoTerminalOptionsBuilder) - Custom process wrapper (
PtyProcessEx) for PTY-based execution with stdin/stdout/stderr redirection - Comprehensive test suite covering configuration, execution, and edge cases
Reviewed changes
Copilot reviewed 12 out of 12 changed files in this pull request and generated 95 comments.
Show a summary per file
| File | Description |
|---|---|
CliWrap/PseudoTerminalOptions.cs |
Public API for PTY configuration with dimension validation |
CliWrap/Builders/PseudoTerminalOptionsBuilder.cs |
Fluent builder for configuring PTY options |
CliWrap/ICommandConfiguration.cs |
Extends interface to include PTY configuration |
CliWrap/Command.cs |
Adds WithPseudoTerminal() methods for enabling PTY mode |
CliWrap/Command.Execution.cs |
Implements PTY-specific execution pipeline with stream piping |
CliWrap/Utils/PseudoTerminal.cs |
Abstract base class for platform-specific PTY implementations |
CliWrap/Utils/WindowsPseudoTerminal.cs |
Windows ConPTY implementation with pipe-based I/O |
CliWrap/Utils/UnixPseudoTerminal.cs |
Unix PTY implementation using openpty() |
CliWrap/Utils/UnixFdStream.cs |
Stream wrapper for Unix file descriptors with read/write operations |
CliWrap/Utils/PtyProcessEx.cs |
PTY process wrapper handling process creation and lifecycle |
CliWrap/Utils/NativeMethods.cs |
P/Invoke declarations for Windows and Unix PTY APIs |
CliWrap.Tests/PseudoTerminalSpecs.cs |
Comprehensive test suite with 1099 lines covering configuration, execution, and edge cases |
Comments suppressed due to low confidence (2)
CliWrap/Utils/NativeMethods.cs:106
- Minimise the use of unmanaged code.
public static extern int WaitPid(int pid, out int status, int options);
CliWrap/Utils/NativeMethods.cs:124
- Minimise the use of unmanaged code.
public static extern int CreatePseudoConsole(
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| public void CloseSlave() | ||
| { | ||
| if (!_slaveFdClosed) | ||
| { | ||
| NativeMethods.Unix.Close(_slaveFd); | ||
| _slaveFdClosed = true; | ||
| } | ||
| } |
There was a problem hiding this comment.
The CloseSlave() method has a check-then-act race condition. If called concurrently from multiple threads, Close() could be called multiple times on the same file descriptor, which could close a different fd if it was reused by the OS. Consider using a lock to ensure thread-safety:
private readonly object _slaveLock = new();
public void CloseSlave()
{
lock (_slaveLock)
{
if (!_slaveFdClosed)
{
NativeMethods.Unix.Close(_slaveFd);
_slaveFdClosed = true;
}
}
}The same protection should be applied in the Dispose() method when closing the slave fd.
There was a problem hiding this comment.
Resolved -- CloseSlave() now uses a Lock to prevent concurrent double-close.
| _masterStream = new UnixFdStream(_masterFd, canRead: true, canWrite: true); | ||
| } | ||
| catch | ||
| { | ||
| // Clean up fds if initialization fails after openpty | ||
| NativeMethods.Unix.Close(_masterFd); | ||
| NativeMethods.Unix.Close(_slaveFd); | ||
| throw; |
There was a problem hiding this comment.
If the UnixFdStream constructor (line 52) fails, the exception handler will call Close() on _masterFd. However, since UnixFdStream was already created with _masterFd, its Dispose() method will also try to close the same fd, leading to a double-close. The UnixFdStream.Dispose() takes ownership of the fd, so you should not close it again in the exception handler. Remove the Close(_masterFd) call from the catch block:
catch
{
// Only close slave - UnixFdStream hasn't taken ownership if it failed to construct
NativeMethods.Unix.Close(_slaveFd);
throw;
}Actually, review this more carefully - if UnixFdStream constructor throws before completion, it won't dispose the fd, so you may need to track whether the stream was successfully created.
There was a problem hiding this comment.
Resolved -- the catch block now tracks masterStreamCreated and only closes _masterFd if the stream hasn't taken ownership of it.
CliWrap/Utils/UnixFdStream.cs
Outdated
| var bytesRead = NativeMethods.Unix.Read(_fd, ref buffer[offset], (nuint)count); | ||
|
|
||
| if (bytesRead < 0) | ||
| { | ||
| var error = Marshal.GetLastWin32Error(); | ||
| // Retry if interrupted by signal | ||
| if (error == NativeMethods.Unix.EINTR) | ||
| return Read(buffer, offset, count); | ||
|
|
||
| throw new IOException( | ||
| $"Failed to read from file descriptor {_fd}. Error code: {error}" | ||
| ); | ||
| } | ||
|
|
||
| return (int)bytesRead; |
There was a problem hiding this comment.
The recursive call to Read() when EINTR error occurs can potentially cause a stack overflow if signals are repeatedly interrupting the read operation. Consider using a loop instead of recursion to handle EINTR retries.
Example fix:
while (true)
{
var bytesRead = NativeMethods.Unix.Read(_fd, ref buffer[offset], (nuint)count);
if (bytesRead < 0)
{
var error = Marshal.GetLastWin32Error();
if (error == NativeMethods.Unix.EINTR)
continue; // Retry
throw new IOException($"Failed to read from file descriptor {_fd}. Error code: {error}");
}
return (int)bytesRead;
}| var bytesRead = NativeMethods.Unix.Read(_fd, ref buffer[offset], (nuint)count); | |
| if (bytesRead < 0) | |
| { | |
| var error = Marshal.GetLastWin32Error(); | |
| // Retry if interrupted by signal | |
| if (error == NativeMethods.Unix.EINTR) | |
| return Read(buffer, offset, count); | |
| throw new IOException( | |
| $"Failed to read from file descriptor {_fd}. Error code: {error}" | |
| ); | |
| } | |
| return (int)bytesRead; | |
| while (true) | |
| { | |
| var bytesRead = NativeMethods.Unix.Read(_fd, ref buffer[offset], (nuint)count); | |
| if (bytesRead < 0) | |
| { | |
| var error = Marshal.GetLastWin32Error(); | |
| // Retry if interrupted by signal | |
| if (error == NativeMethods.Unix.EINTR) | |
| continue; | |
| throw new IOException( | |
| $"Failed to read from file descriptor {_fd}. Error code: {error}" | |
| ); | |
| } | |
| return (int)bytesRead; | |
| } |
There was a problem hiding this comment.
Resolved -- replaced recursion with a while loop that uses continue on EINTR.
|
|
||
| for (var i = 0; i < taskCount; i++) | ||
| { | ||
| outputs[i] = new StringBuilder(); |
There was a problem hiding this comment.
Creating a 'StringBuilder' in a loop.
…rminal implementations
Tyrrrz
left a comment
There was a problem hiding this comment.
Thank you for the PR! Left some comments. Copilot also left some good ones too.
…tionality with platform-specific methods
…rocessEx and UnixPseudoTerminal
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
CliWrap/Command.Execution.cs
Outdated
| // Flush to ensure data is sent to the PTY immediately | ||
| await process.StandardInput.FlushAsync(cancellationToken).ConfigureAwait(false); | ||
| } | ||
| catch (IOException ex) when (ex.GetType() == typeof(IOException)) { } |
There was a problem hiding this comment.
Can you please add comments describing when each ignored exception type can happen and why we're ignoring them. Same for the PipePtyOutputAsync method.
There was a problem hiding this comment.
Done -- both PipePtyInputAsync and PipePtyOutputAsync now have comments above the catch clause explaining each exception type: IOException (PTY closed/pipe broken during process termination), ObjectDisposedException (stream disposed during cleanup), and OperationCanceledException (cancellation requested).
…inalSpecs feat: Implement asynchronous ReadAsync and WriteAsync methods in UnixFdStream
…to feature/225
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
|
Currently failiing on these two tests: https://github.com/Tyrrrz/CliWrap/actions/runs/20142129317?pr=309
|
- Fix ParseArguments to handle escaped quotes (fixes special characters test) - Use SIGINT directly on Unix for reliable interrupt signal delivery (fixes Ctrl+C test) - Close master fd in child process to prevent fd leaks (fixes concurrent execution issues) - Implement CloseConsole on Unix to properly signal EOF and unblock reads - Fix potential double-dispose of master stream with proper state tracking
- Add SIGINT and SIGKILL constants to NativeMethods.Unix, replacing magic numbers - Fix WindowsPseudoTerminal dimension validation to check >= 1 instead of >= short.MinValue - Add error checking for posix_spawnattr_setflags return value - Document shared stream behavior on Unix (InputStream/OutputStream are same fd) - Document working directory race condition limitation with posix_spawn
|
Sorry lost track of this one! Just pushed some updates |
|
Awesome work! Just happen to run into needing this @Tyrrrz Could you please take a look again? |
|
Hi there. I will take a look as soon as I can. Currently really busy with IRL stuff. |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 16 out of 16 changed files in this pull request and generated 3 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| catch | ||
| { | ||
| _inputWriteHandle.Dispose(); | ||
| _outputReadHandle.Dispose(); | ||
| NativeMethods.Windows.ClosePseudoConsole(_pseudoConsoleHandle); | ||
| throw; | ||
| } |
There was a problem hiding this comment.
In the exception handler at lines 94-100, handles may be disposed twice if one FileStream construction succeeds but the other fails. When FileStream is constructed with a SafeFileHandle, it takes ownership by default. If _inputStream is successfully created (line 81-86) but _outputStream creation throws (line 87-92), then _inputWriteHandle is already owned by _inputStream. The subsequent disposal at line 96 attempts to dispose a handle that's already owned by the FileStream, which could cause issues.
Consider tracking which streams were successfully created and only disposing handles that weren't transferred to a FileStream, or wrap each FileStream construction in a separate try-catch block.
There was a problem hiding this comment.
Resolved -- the catch block now checks whether _inputStream was successfully created. If so, it disposes the stream (which owns the handle) instead of disposing the handle directly.
| private CommandTask<CommandResult> ExecuteWithPtyAsync( | ||
| CancellationToken forcefulCancellationToken, | ||
| CancellationToken gracefulCancellationToken | ||
| ) | ||
| { | ||
| // Create pseudo-terminal | ||
| var pty = PseudoTerminal.Create(PseudoTerminalOptions.Columns, PseudoTerminalOptions.Rows); | ||
|
|
||
| var process = new PtyProcessEx( | ||
| pty, | ||
| GetOptimallyQualifiedTargetFilePath(), | ||
| Arguments, | ||
| WorkingDirPath, | ||
| CreateEnvironmentBlock() | ||
| ); | ||
|
|
||
| process.Start(); | ||
|
|
||
| var processId = process.Id; | ||
|
|
||
| return new CommandTask<CommandResult>( | ||
| ExecuteAsync(process, forcefulCancellationToken, gracefulCancellationToken), | ||
| processId | ||
| ); | ||
| } |
There was a problem hiding this comment.
The PTY execution path does not support Credentials or ResourcePolicy that are configured on the Command. When PTY mode is enabled, these settings are silently ignored. This should be documented in the WithPseudoTerminal methods' remarks section, or the code should validate and throw an exception if these are set when PTY is enabled. Users might expect these features to work with PTY and could be surprised when their credentials or resource policies are not applied.
There was a problem hiding this comment.
Acknowledged -- this is a known limitation of the PTY path. Credentials and ResourcePolicy require System.Diagnostics.Process APIs that are not available when using native process creation (CreateProcessW / posix_spawn). The PseudoTerminalOptions docs note that PTY uses a different execution path. Could add explicit validation to throw if these are set with PTY enabled, if preferred.
There was a problem hiding this comment.
Hmm, I wonder if it would be better that, instead of the IProcessEx abstraction (or maybe in addition to), we make ICommand with Command and PtyCommand.
PtyCommand would not have the configuration methods that it doesn't support. It would also not have any configuration for StandardError pipes (for the interface, it could still explicitly implement it, but hide it in the actual class).
The execution models such as ExecuteBufferedAsync() would probably work just fine off the ICommand interface.
| // Start piping streams in the background | ||
| // With PTY, stderr is merged into stdout, so we only pipe stdin and stdout | ||
| var pipingTask = Task.WhenAll( | ||
| PipePtyInputAsync(process, stdInCts.Token), | ||
| PipePtyOutputAsync(process, stdOutCts.Token) | ||
| ); |
There was a problem hiding this comment.
When PTY mode is enabled, StandardErrorPipe is completely ignored and never receives any data or EOF signal. This could cause issues if the pipe target expects to be notified of stream completion. Consider piping an empty stream to StandardErrorPipe (e.g., Stream.Null) to ensure the target completes properly, or document this behavior more explicitly. The test at PseudoTerminalSpecs.cs:381 verifies stderr buffer is empty, but this only works because StringBuilder doesn't block - other pipe targets might behave differently.
There was a problem hiding this comment.
Acknowledged -- this is by design since PTY merges stderr into stdout at the terminal level. The PseudoTerminalOptions docs state this: "When PTY is enabled, stderr is merged into stdout on all platforms. The StandardErrorPipe will receive an empty stream." The existing test verifies this behavior. Piping Stream.Null to StandardErrorPipe could be added for completeness if desired.
There was a problem hiding this comment.
To that end, I think we should indeed pipe an empty (Stream.Null) stream to stderr to explify emptiness, rather than just leave it idle
… overflow The Unix PTY had no validation before casting int to ushort in SetSize() and the constructor, so values > 65535 would silently overflow. This adds ValidateDimensions matching the Windows side's pattern. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…y fails If _inputStream was successfully created (taking ownership of _inputWriteHandle) but _outputStream creation throws, dispose the stream instead of the handle directly to avoid double-dispose. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Tyrrrz
left a comment
There was a problem hiding this comment.
Sorry for a long delay, got a lot of work outside of OSS and I keep putting off these 3000+ line changes 🥲
Didn't get to read through everything, but added some comments to unblock the PR
| /// </summary> | ||
| /// <remarks> | ||
| /// <para> | ||
| /// PTY mode makes CLI applications behave as if running in an interactive terminal, |
There was a problem hiding this comment.
| /// PTY mode makes CLI applications behave as if running in an interactive terminal, | |
| /// Pseudo-terminal (PTY) mode makes CLI applications behave as if running in an interactive terminal, |
| /// </para> | ||
| /// </remarks> | ||
| [Pure] | ||
| public Command WithPseudoTerminal(bool enabled = true) => |
There was a problem hiding this comment.
| public Command WithPseudoTerminal(bool enabled = true) => | |
| public Command WithPseudoTerminal(bool isEnabled = true) => |
| /// </summary> | ||
| /// <remarks> | ||
| /// <para> | ||
| /// PTY mode makes CLI applications behave as if running in an interactive terminal, |
There was a problem hiding this comment.
| /// PTY mode makes CLI applications behave as if running in an interactive terminal, | |
| /// Pseudo-terminal (PTY) mode makes CLI applications behave as if running in an interactive terminal, |
| // IOException (exact type only): "The pipe has been ended" (Windows) or "Broken pipe" (Unix). | ||
| // This occurs when the process terminates before consuming all stdin data - not an error | ||
| // since the process may not need all input to complete successfully. | ||
| // Don't catch derived exceptions (e.g., FileNotFoundException) to avoid masking real errors. |
There was a problem hiding this comment.
Restore the original comment since it was rewritten
| // IOException (exact type only): "The pipe has been ended" (Windows) or "Broken pipe" (Unix). | |
| // This occurs when the process terminates before consuming all stdin data - not an error | |
| // since the process may not need all input to complete successfully. | |
| // Don't catch derived exceptions (e.g., FileNotFoundException) to avoid masking real errors. | |
| // Expect IOException: "The pipe has been ended" (Windows) or "Broken pipe" (Unix). | |
| // This may happen if the process terminated before the pipe has been exhausted. | |
| // It's not an exceptional situation because the process may not need the entire | |
| // stdin to complete successfully. | |
| // Don't catch derived exceptions, such as FileNotFoundException, to avoid false positives. | |
| // We also can't rely on process.HasExited here because of potential race conditions. |
| try | ||
| { | ||
| // Read in a loop and pipe to output target | ||
| var buffer = new byte[4096]; |
There was a problem hiding this comment.
Should be able to use MemoryPool here and pass Memory to ReadAsync
| /// <summary> | ||
| /// Default PTY options (disabled). | ||
| /// </summary> | ||
| public static PseudoTerminalOptions Default { get; } = new(); |
There was a problem hiding this comment.
Should probably rename to:
| public static PseudoTerminalOptions Default { get; } = new(); | |
| public static PseudoTerminalOptions Disabled { get; } = new(); |
| /// <remarks> | ||
| /// Default is <c>true</c> when using the builder. | ||
| /// </remarks> | ||
| public PseudoTerminalOptionsBuilder SetEnabled(bool enabled = true) |
There was a problem hiding this comment.
| public PseudoTerminalOptionsBuilder SetEnabled(bool enabled = true) | |
| public PseudoTerminalOptionsBuilder SetEnabled(bool isEnabled = true) |
| || OperatingSystem.IsLinux() | ||
| || OperatingSystem.IsMacOS(); | ||
|
|
||
| #region Configuration Tests - Default Values |
There was a problem hiding this comment.
Let's drop regions. If it feels like we have so many tests that we need regions, we probably have many redundat tests.
| #region Configuration Tests - Default Values | ||
|
|
||
| [Fact] | ||
| public void PseudoTerminalOptions_has_correct_default_values() |
There was a problem hiding this comment.
Generally, we can move all configuration-related tests to ConfigurationSpecs. See the existing tests there for inspiration. There shouldn't be too many tests.
| } | ||
|
|
||
| [Fact] | ||
| public void Builder_SetEnabled_defaults_to_true() |

Closes #225
I got AI to help me as I'm not the most familiar with native code etc 😄