Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
b586822
Out-of-band ConPTY, CsWin32 interop, and the concurrency harness that…
JohnCampionJr Aug 22, 2026
a384469
CI: locate the test output instead of hard-coding it
JohnCampionJr Aug 22, 2026
96553da
Forward the out-of-band ConPTY host to package consumers
JohnCampionJr Aug 22, 2026
14849e9
Add a package consumer as a test, on all three platforms
JohnCampionJr Aug 22, 2026
92369a4
Fix the consumer checks: pack must build, and a doubled backtick is n…
JohnCampionJr Aug 22, 2026
fc75c05
Restore the verify consumers from a generated nuget.config
JohnCampionJr Aug 22, 2026
15de81e
Add an -Aot mode to the consumer check
JohnCampionJr Aug 22, 2026
ce0d09e
v2.0.0, restore dropped package metadata, and address the Copilot review
JohnCampionJr Aug 22, 2026
1f3eef0
Answer, in the file, whether a consumer needs the Conpty* flags
JohnCampionJr Aug 22, 2026
acd2090
Silence IL3000 on the assembly-directory probe
JohnCampionJr Aug 22, 2026
6a41389
Make the RID requirement visible, and resolve conpty.dll once
JohnCampionJr Aug 22, 2026
d75acb3
Keep Porta.Pty consumable from a RID-independent project
JohnCampionJr Aug 22, 2026
803597b
Fix argument splatting in the verify scripts, and make them runnable …
JohnCampionJr Aug 22, 2026
2c500b9
Add Native AOT PTY demo
JohnCampionJr Aug 22, 2026
4827738
Run the AOT demo in CI, and correct the docs now that portable is proven
JohnCampionJr Aug 22, 2026
b6d1929
Sweep the docs, README and comments for what this branch made untrue
JohnCampionJr Aug 22, 2026
466e57a
Name the packaged targets after the package id
JohnCampionJr Aug 22, 2026
beb9e0f
Guard the portable Windows path in CI, and fix two things that were s…
tomlm Aug 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 25 additions & 4 deletions .github/workflows/build-linux.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,32 @@ jobs:
- name: Build
run: dotnet build --no-restore src/Porta.Pty.sln --property WarningLevel=0

- name: Copy native library for tests
# The library stages its own shim into a ProjectReference consumer's output now, so there is no
# copy step here any more. This asserts that it happened: a missing shim otherwise surfaces twenty
# lines later as DllNotFoundException, naming nothing that points back at the staging.
- name: Verify the native shim was staged for the tests
run: |
# Copy to a location the runtime can find for tests
cp src/Porta.Pty.Native/output/runtimes/linux-x64/native/libporta_pty.so \
src/Porta.Pty.Tests/bin/Debug/net6.0/ || true
out=$(dirname "$(find src/Porta.Pty.Tests/bin/Debug -name 'Porta.Pty.Tests.dll' | head -1)")
[ -n "$out" ] || { echo "::error::test output not found"; exit 1; }
if [ ! -f "$out/libporta_pty.so" ]; then
echo "::error::libporta_pty.so is not beside the test assembly in $out"
ls -la "$out"
exit 1
fi
echo "shim staged in $out"

- name: Test
run: dotnet test --no-build --verbosity normal src/Porta.Pty.sln

# The tests reference the library by PROJECT, so they cannot see a packaging defect at all: no
# .nupkg, no runtimes/ resolution, no buildTransitive/. This packs and consumes for real.
- name: Verify a package consumer works
run: ./scripts/verify-consumer.sh

# Native AOT is a different question from packaging: whether the interop survives with no JIT and no
# reflection fallback. The sample asserts RuntimeFeature.IsDynamicCodeSupported is FALSE first, so a
# misconfigured publish that quietly produced an ordinary binary fails rather than passing.
- name: Native AOT demo
run: |
dotnet publish samples/Porta.Pty.AotDemo/Porta.Pty.AotDemo.csproj -c Release -o aot --nologo
./aot/Porta.Pty.AotDemo
30 changes: 25 additions & 5 deletions .github/workflows/build-macos.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,12 +48,32 @@ jobs:
- name: Build
run: dotnet build --no-restore src/Porta.Pty.sln --property WarningLevel=0

- name: Copy native library for tests
# The library stages its own shim into a ProjectReference consumer's output now, so there is no
# copy step here any more. This asserts that it happened: a missing shim otherwise surfaces twenty
# lines later as DllNotFoundException, naming nothing that points back at the staging.
- name: Verify the native shim was staged for the tests
run: |
RID=${{ steps.rid.outputs.rid }}
# Copy to a location the runtime can find for tests
cp src/Porta.Pty.Native/output/runtimes/${RID}/native/libporta_pty.dylib \
src/Porta.Pty.Tests/bin/Debug/net6.0/ || true
out=$(dirname "$(find src/Porta.Pty.Tests/bin/Debug -name 'Porta.Pty.Tests.dll' | head -1)")
[ -n "$out" ] || { echo "::error::test output not found"; exit 1; }
if [ ! -f "$out/libporta_pty.dylib" ]; then
echo "::error::libporta_pty.dylib is not beside the test assembly in $out"
ls -la "$out"
exit 1
fi
echo "shim staged in $out"

- name: Test
run: dotnet test --no-build --verbosity normal src/Porta.Pty.sln

# The tests reference the library by PROJECT, so they cannot see a packaging defect at all: no
# .nupkg, no runtimes/ resolution, no buildTransitive/. This packs and consumes for real.
- name: Verify a package consumer works
run: ./scripts/verify-consumer.sh

# Native AOT is a different question from packaging: whether the interop survives with no JIT and no
# reflection fallback. The sample asserts RuntimeFeature.IsDynamicCodeSupported is FALSE first, so a
# misconfigured publish that quietly produced an ordinary binary fails rather than passing.
- name: Native AOT demo
run: |
dotnet publish samples/Porta.Pty.AotDemo/Porta.Pty.AotDemo.csproj -c Release -o aot --nologo
./aot/Porta.Pty.AotDemo
43 changes: 43 additions & 0 deletions .github/workflows/build-windows.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,46 @@ jobs:

- name: Test
run: dotnet test --no-build --verbosity normal src/Porta.Pty.sln

# The tests reference the library by PROJECT, which bypasses the .nupkg entirely — so they cannot
# observe that ConPTY ships its host-staging logic under build/ (direct references only) and that
# a package consumer therefore got conpty.dll with no OpenConsole.exe. That failure is silent:
# conpty.dll with no host falls back to in-box conhost without erroring.
#
# x64 as well as the runner's own architecture, because an x64 process runs on ARM64 Windows under
# emulation and needs the x64 host — the case a flat copy silently broke.
- name: Verify a package consumer works (win-x64)
run: ./scripts/Verify-ConPtyConsumerStaging.ps1 -Rid win-x64
shell: pwsh

- name: Verify a package consumer works (win-arm64)
run: ./scripts/Verify-ConPtyConsumerStaging.ps1 -Rid win-arm64
shell: pwsh

# And with NO RuntimeIdentifier, which is the layout the newest Windows code exists to handle and
# the only one nothing else here covers. verify-consumer.sh does the portable case on Linux and
# macOS, but ConPtyImportResolver and the runtimes/win-<arch>/native probe do not exist there — so
# without this step the portable path ships with no regression guard at all.
- name: Verify a package consumer works (portable, no RID)
run: ./scripts/Verify-ConPtyConsumerStaging.ps1 -NoRid
shell: pwsh

# Files on disk is the weaker claim. This is the process census, and it is the only DIRECT evidence
# of which implementation is live: in-box spawns a conhost.exe per pseudoconsole, out-of-band an
# OpenConsole.exe. It matters most in the portable layout, which rests on conpty.dll launching its
# host from an <arch>/ subdirectory of its OWN directory — behaviour confirmed by census rather than
# documented anywhere, so it is exactly the kind of thing that can change underneath us silently.
- name: Verify the out-of-band host actually launched (portable, no RID)
run: ./scripts/Verify-ConPtyHost.ps1 -NoRid
shell: pwsh

# Native AOT is a different question from packaging: whether the interop survives with no JIT and no
# reflection fallback. This is the configuration reported broken in
# tomlm/Iciclecreek.Avalonia.Terminal#6 ("Unable to convert object to its binary format"). The sample
# asserts RuntimeFeature.IsDynamicCodeSupported is FALSE first, so a misconfigured publish that
# quietly produced an ordinary binary fails rather than passing.
- name: Native AOT demo
run: |
dotnet publish samples/Porta.Pty.AotDemo/Porta.Pty.AotDemo.csproj -c Release -o aot --nologo
./aot/Porta.Pty.AotDemo.exe
shell: pwsh
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -416,3 +416,9 @@ FodyWeavers.xsd
*.msix
*.msm
*.msp

# Native shim build output. build.sh writes here and CI rebuilds it per platform; a checked-in
# slice is one architecture pretending to be the answer, and it went in by accident with the net10
# change.
src/Porta.Pty.Native/output/
src/Porta.Pty.Native/build/
55 changes: 52 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@ A cross-platform pseudoterminal (PTY) library for .NET that enables spawning and
- **Full PTY Control**: Read/write streams, resize terminal, handle process exit events
- **Unicode Support**: Full UTF-8 support including complex characters
- **Native PTY Shim**: Includes a native C library to avoid .NET runtime permissioning issues with `fork()` on Linux/macOS
- **.NET Standard 2.0**: Compatible with .NET Core 2.0+, .NET 5+, and .NET Framework 4.6.1+
- **Out-of-band ConPTY**: On Windows, uses the `conpty.dll` + `OpenConsole.exe` implementation Windows
Terminal ships, falling back to the in-box console host when it is unavailable
- **Native AOT**: Interop is source-generated, so the library works in a `PublishAot` application
- **.NET 10**: As of 2.0.0. Earlier versions targeted .NET Standard 2.0; see below

## Installation

Expand All @@ -31,6 +34,20 @@ Or via the Package Manager Console in Visual Studio:
Install-Package Porta.Pty
```

### Upgrading to 2.0

**2.0.0 targets `net10.0`.** Earlier versions targeted .NET Standard 2.0 so one package could also serve
.NET Framework; that reach is gone, and it is the reason for the major bump rather than anything in the
API, which is unchanged.

The trade: netstandard2.0 pins C# 8 and puts most modern interop behind a polyfill or out of reach, and —
the part that mattered in this codebase — it meant the library was never *compiled* against the runtime
its consumers run on, in a repo whose POSIX shim exists precisely because a runtime version changed
behaviour underneath it (.NET 7 enabling W^X by default).

Nothing else is required of a consumer: no properties, no extra package references, and no
`RuntimeIdentifier`.

## Usage

### Basic Example
Expand Down Expand Up @@ -144,6 +161,8 @@ flowchart TB
- Leverages `CreatePseudoConsole`, `ResizePseudoConsole`, and `ClosePseudoConsole` native functions
- Process isolation via Windows Job Objects for clean process termination
- Implements proper cleanup order per Microsoft documentation
- Prefers the **out-of-band** ConPTY (`conpty.dll` + `OpenConsole.exe`) over the in-box one, and falls
back rather than failing when it is absent — see [docs/conpty-out-of-band.md](docs/conpty-out-of-band.md)

#### Linux & macOS
- Uses **POSIX PTY** functions (`forkpty`, `openpty`) via a native C shim library
Expand All @@ -170,8 +189,14 @@ By delegating the fork+exec to native C code, Porta.Pty avoids running any manag

### Dependencies

- **Vanara.PInvoke.Kernel32**: Windows API P/Invoke bindings
- **Mono.Posix.NETStandard**: POSIX API bindings for Unix platforms
- **Microsoft.Windows.CsWin32**: source-generates the Win32 P/Invoke. A build-time analyzer with
`PrivateAssets="all"`, so it contributes nothing at run time and nothing to a consumer's graph — this
replaced **Vanara.PInvoke.Kernel32**, which shipped a runtime assembly every consumer carried for about
twenty entry points
- **Microsoft.Windows.Console.ConPTY**: `conpty.dll` and `OpenConsole.exe`, the out-of-band console host

Unix needs no managed interop package: the POSIX work happens in the native shim, so
**Mono.Posix.NETStandard** is gone too.

## License

Expand All @@ -180,3 +205,27 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file
## Contributing

Contributions are welcome! Please feel free to submit a Pull Request.

### Testing on Windows ARM64

Run the test project directly for each architecture:

```powershell
dotnet test .\src\Porta.Pty.Tests\Porta.Pty.Tests.csproj --arch arm64
dotnet test .\src\Porta.Pty.Tests\Porta.Pty.Tests.csproj --arch x64
```

Running the x64 suite under emulation requires the .NET 10 x64 runtime in
addition to the native ARM64 runtime.

### Native AOT smoke test

Publish and run the AOT demo for the target Windows architecture:

```powershell
dotnet publish .\samples\Porta.Pty.AotDemo\Porta.Pty.AotDemo.csproj -c Release -r win-arm64
.\samples\Porta.Pty.AotDemo\bin\Release\net10.0\win-arm64\publish\Porta.Pty.AotDemo.exe
```

Replace `win-arm64` with `win-x64` to test the x64 executable. The demo verifies
that dynamic code is unavailable and completes a real PTY round trip.
Loading
Loading