Skip to content

Add support for pseudo-terminal (PTY) mode in CLI commands#309

Open
thomhurst wants to merge 17 commits intoTyrrrz:primefrom
thomhurst:feature/225
Open

Add support for pseudo-terminal (PTY) mode in CLI commands#309
thomhurst wants to merge 17 commits intoTyrrrz:primefrom
thomhurst:feature/225

Conversation

@thomhurst
Copy link

Closes #225

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

Copilot AI review requested due to automatic review settings December 9, 2025 09:21
Copy link
Contributor

Copilot AI left a 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.

Comment on lines +119 to +126
public void CloseSlave()
{
if (!_slaveFdClosed)
{
NativeMethods.Unix.Close(_slaveFd);
_slaveFdClosed = true;
}
}
Copy link

Copilot AI Dec 9, 2025

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.

Copilot uses AI. Check for mistakes.
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved -- CloseSlave() now uses a Lock to prevent concurrent double-close.

Comment on lines +52 to +59
_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;
Copy link

Copilot AI Dec 9, 2025

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.

Copilot uses AI. Check for mistakes.
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved -- the catch block now tracks masterStreamCreated and only closes _masterFd if the stream hasn't taken ownership of it.

Comment on lines +78 to +92
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;
Copy link

Copilot AI Dec 9, 2025

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;
}
Suggested change
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;
}

Copilot uses AI. Check for mistakes.
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved -- replaced recursion with a while loop that uses continue on EINTR.


for (var i = 0; i < taskCount; i++)
{
outputs[i] = new StringBuilder();
Copy link

Copilot AI Dec 9, 2025

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.

Copilot uses AI. Check for mistakes.
@Tyrrrz Tyrrrz changed the title feat: Add support for pseudo-terminal (PTY) mode in CLI commands Add support for pseudo-terminal (PTY) mode in CLI commands Dec 9, 2025
Copy link
Owner

@Tyrrrz Tyrrrz left a 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.

// 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)) { }
Copy link
Owner

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.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

thomhurst and others added 4 commits December 11, 2025 12:29
…inalSpecs

feat: Implement asynchronous ReadAsync and WriteAsync methods in UnixFdStream
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
@Tyrrrz
Copy link
Owner

Tyrrrz commented Dec 19, 2025

Currently failiing on these two tests: https://github.com/Tyrrrz/CliWrap/actions/runs/20142129317?pr=309

image

- 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
@thomhurst
Copy link
Author

Sorry lost track of this one! Just pushed some updates

@JasonLandbridge
Copy link

Awesome work! Just happen to run into needing this

@Tyrrrz Could you please take a look again?

@Tyrrrz
Copy link
Owner

Tyrrrz commented Feb 10, 2026

Hi there. I will take a look as soon as I can. Currently really busy with IRL stuff.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +94 to +100
catch
{
_inputWriteHandle.Dispose();
_outputReadHandle.Dispose();
NativeMethods.Windows.ClosePseudoConsole(_pseudoConsoleHandle);
throw;
}
Copy link

Copilot AI Feb 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +579 to +603
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
);
}
Copy link

Copilot AI Feb 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +354 to +359
// 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)
);
Copy link

Copilot AI Feb 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Copy link
Owner

@Tyrrrz Tyrrrz left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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,
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
/// 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) =>
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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,
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
/// 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,

Comment on lines +164 to +167
// 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.
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Restore the original comment since it was rewritten

Suggested change
// 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];
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be able to use MemoryPool here and pass Memory to ReadAsync

/// <summary>
/// Default PTY options (disabled).
/// </summary>
public static PseudoTerminalOptions Default { get; } = new();
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should probably rename to:

Suggested change
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)
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
public PseudoTerminalOptionsBuilder SetEnabled(bool enabled = true)
public PseudoTerminalOptionsBuilder SetEnabled(bool isEnabled = true)

|| OperatingSystem.IsLinux()
|| OperatingSystem.IsMacOS();

#region Configuration Tests - Default Values
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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()
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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()
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Definitely drop tests like these

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Option to allocate a pseudo-terminal for running command-line applications

4 participants