Ghostflyby.Pty 1.0.0

dotnet add package Ghostflyby.Pty --version 1.0.0
                    
NuGet\Install-Package Ghostflyby.Pty -Version 1.0.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Ghostflyby.Pty" Version="1.0.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Ghostflyby.Pty" Version="1.0.0" />
                    
Directory.Packages.props
<PackageReference Include="Ghostflyby.Pty" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Ghostflyby.Pty --version 1.0.0
                    
#r "nuget: Ghostflyby.Pty, 1.0.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Ghostflyby.Pty@1.0.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Ghostflyby.Pty&version=1.0.0
                    
Install as a Cake Addin
#tool nuget:?package=Ghostflyby.Pty&version=1.0.0
                    
Install as a Cake Tool

Pty.Net

The multi-platform pty wrapper in pure C# with P/Invoke: drive interactive shells with a real terminal on Windows, macOS, and Linux.

CI · NuGet Version · License: Apache-2.0

Features

  • Real pseudo-terminal sessions — full-screen programs, job control, and terminal escape sequences behave as they do in a real terminal.
  • Cross-platform — ConPTY on Windows; posix_openpt + fork/exec on macOS and Linux. Architecture-neutral managed IL (one package covers x64 and arm64).
  • Text and raw I/OInput/Output text facades over the raw BaseStream.
  • Deterministic termination — a configurable graceful-close window, then a force kill; Dispose blocks until the cleanup has actually completed.
  • Exit notification with the terminal resultExited supplies the process after its normal exit code or Unix termination signal has been published.
  • AOT compatible — no reflection, no dynamic loading; the pty stays out of the way of trimmed/published apps.

Install

dotnet add package Ghostflyby.Pty

Quick start

Launch an interactive shell and drive it like a user at a terminal:

using Ghostflyby.Pty;

// An interactive bash session in a real pty. The bare name resolves through
// PATH on every platform, like Process.Start — pass bash.exe (Git for Windows)
// or powershell.exe on Windows.
using var bash = PtyProcess.Start("bash", ["--noprofile", "--norc", "-i"]);

// Write a command and read until a marker proves the output landed.
bash.Input.WriteLine("echo hello-from-pty; echo __DONE__");
var output = ReadUntil(bash.Output, "__DONE__", TimeSpan.FromSeconds(10));
                                                   // contains "hello-from-pty"

ReadUntil is a small helper — the pty emits a prompt and terminal control sequences alongside the payload, so the reliable pattern is "read until a marker":

static string ReadUntil(StreamReader reader, string marker, TimeSpan timeout)
{
    var deadline = DateTime.UtcNow + timeout;
    var text = "";
    while (!text.Contains(marker, StringComparison.Ordinal))
    {
        if (DateTime.UtcNow >= deadline)
            throw new TimeoutException($"timed out waiting for '{marker}'");
        var buf = new char[4096];
        var n = reader.ReadAsync(buf, 0, buf.Length).AsTask().WaitAsync(timeout).GetAwaiter().GetResult();
        if (n == 0) break;                 // child exited
        text += new string(buf, 0, n);
    }
    return text;
}

Launch configuration

PtyStartInfo mirrors the commonly used subset of ProcessStartInfo and accepts one directly via its ProcessStartInfo constructor, so existing launch code ports by changing only the factory call:

using System.Collections.Immutable;
using Ghostflyby.Pty;

var info = new PtyStartInfo("/bin/sh")
{
    Arguments   = ["-c", "echo $GREETING"],
    Column      = 100,            // initial terminal width in characters
    Row         = 40,             // initial terminal height in characters
    Environment = ImmutableDictionary<string, string?>.Empty
                      .Add("GREETING", "hello"),   // overrides, merged into the parent env at launch
};

using var p = PtyProcess.Start(info);

// Resize the live terminal; the child re-lays out immediately (SIGWINCH / ConPTY).
p.Resize(120, 50);

Environment semantics: the child inherits the parent's environment by default; entries in Environment override inherited variables, and a null value removes one.

Termination

Method Unix Windows
RequestClose() SIGHUP CTRL_CLOSE_EVENT (async)
Kill() SIGKILL TerminateProcess
Dispose() / DisposeAsync() SIGHUP → wait → SIGKILL CTRL_CLOSE_EVENT → wait → TerminateProcess
  • RequestClose asks the terminal session to close; the child decides how to handle it. Fire-and-forget, like Kill.
  • Kill force-terminates without cleanup.
  • Dispose blocks until the cleanup is actually done: it sends the graceful signal, waits GracefulExitTimeout (default 30 s, configurable) for the child to exit on its own, force-kills if it does not, then blocks until the reaper has collected the child. A child that ignores the graceful signal is terminated, never left running in the background.
  • DisposeAsync has the same semantics without blocking a thread. Discard the returned task for fire-and-forget; the internal grace window and force-kill still complete the cleanup. To impose an outer deadline: await p.DisposeAsync().AsTask().WaitAsync(5s).

A manual graceful-termination pattern works on both platforms:

p.RequestClose();
if (!p.WaitForExit(TimeSpan.FromSeconds(5)))
    p.Kill();

Waiting and exit notification

p.Exited += process =>
{
    if (process.ExitCode is int code)
        Console.WriteLine($"child exited with {code}");
    else
        Console.WriteLine($"child terminated by signal {process.TerminationSignal}");
};

p.WaitForExit();                        // blocks until reaped
bool ok = p.WaitForExit(TimeSpan.FromSeconds(5));
bool ok2 = await p.WaitForExitAsync(TimeSpan.FromSeconds(5));   // thread-pool-free
await p.WaitForExitAsync();             // null timeout = wait indefinitely

While the child is running, ExitCode and TerminationSignal are both null. After it is reaped, HasExited is true and exactly one is non-null: normal exits populate ExitCode; Unix signal termination populates TerminationSignal with the platform's native positive signal number (SIGKILL is 9 on the supported platforms). Windows always reports an exit code. A shell can itself exit normally with 128 + signal; that remains an exit code and is distinct from this library observing its direct child die from a signal.

Exited fires on the shared reaper thread after the terminal result is published; the handler must not block, and exceptions it throws are swallowed. The event is not replayed to late subscribers: await a wait method and inspect the properties when the child may already have exited. Exit waits, including disposal, are released before handlers run, so disposal does not wait for a handler to finish.

Platform notes

  • Windows needs Windows 10 1809 (build 17763) or later (ConPTY).
  • Architectures — the package ships runtimes for win-x64, win-arm64, linux-x64, linux-arm64, osx-x64, and osx-arm64; the assemblies are architecture-neutral managed IL. CI runs the full suite natively on one runner per RID — Windows x64 and arm64, Linux x64 and arm64 (plus glibc and musl containers), and macOS arm64 and Intel — so every shipped RID is exercised. (The Windows arm64 runner is a GitHub public preview; macos-15-intel is GitHub's final Intel image, retiring August 2027.)
  • The pty merges the child's stdout and stderr into the single Output stream — there is no separate stderr, as in a real terminal.

Caveats

  • Input/Output (text) and BaseStream (raw bytes) read the same channel; never mix both on the same direction.
  • While WaitForExit/WaitForExitAsync run, output is drained so the child never blocks on a full pty buffer. The drained output is preserved and remains readable after the wait, on every platform — the trade-off is memory: a child producing pathological output volume while being waited on grows the buffer with its output. For such children, consume the output concurrently instead of waiting.
  • The process owns the underlying stream: disposing Input or Output alone never breaks the other facade or the process; Dispose/DisposeAsync closes everything.
  • InheritParentEnvironment = false (allowlist) is strict: the child receives exactly the listed variables, nothing is injected implicitly. Terminal-aware programs need TERM — add it explicitly, e.g. Environment = new Dictionary<string, string?> { ["TERM"] = "xterm-256color" }.

License

Apache License 2.0

Product Compatible and additional computed target framework versions.
.NET net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.
  • net10.0

    • No dependencies.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
1.0.0 365 9/5/2026
0.5.1 115 9/5/2026
0.5.0 724 9/3/2026
0.4.0 117 9/2/2026
0.3.1 111 9/1/2026
0.3.0 111 9/1/2026
0.2.2 1,072 8/18/2026
0.2.1 99 8/18/2026
0.2.0 105 8/15/2026
0.1.0 123 8/13/2026