-
-
Notifications
You must be signed in to change notification settings - Fork 280
Add support for pseudo-terminal (PTY) mode in CLI commands #309
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Conversation
…ing and validation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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; | ||
| } | ||
| } |
Copilot
AI
Dec 9, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
| _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; |
Copilot
AI
Dec 9, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
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; |
Copilot
AI
Dec 9, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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; | |
| } |
|
|
||
| for (var i = 0; i < taskCount; i++) | ||
| { | ||
| outputs[i] = new StringBuilder(); |
Copilot
AI
Dec 9, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Creating a 'StringBuilder' in a loop.
…rminal implementations
Tyrrrz
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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 <[email protected]>
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can you please add comments describing when each ignored exception type can happen and why we're ignoring them. Same for the PipePtyOutputAsync method.
…inalSpecs feat: Implement asynchronous ReadAsync and WriteAsync methods in UnixFdStream
…to feature/225
Co-authored-by: Copilot <[email protected]>
Co-authored-by: Copilot <[email protected]>
|
Currently failiing on these two tests: https://github.com/Tyrrrz/CliWrap/actions/runs/20142129317?pr=309
|

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