Skip to content

Out-of-band ConPTY, CsWin32 interop, and the concurrency harness that found it all - #10

Merged
tomlm merged 18 commits into
tomlm:mainfrom
JohnCampionJr:conpty-cswin32
Aug 22, 2026
Merged

Out-of-band ConPTY, CsWin32 interop, and the concurrency harness that found it all#10
tomlm merged 18 commits into
tomlm:mainfrom
JohnCampionJr:conpty-cswin32

Conversation

@JohnCampionJr

@JohnCampionJr JohnCampionJr commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

Answers #33 — thanks for the go-ahead. Everything here came out of driving this library hard from a desktop app (terminal panes and long-running children, many at once), so each piece is a measurement rather than a preference.

Rebased onto main now that #7, #8 and #9 are merged, so those three fixes are no longer in this diff. CI is green on all three platforms.


Out-of-band ConPTY

Microsoft ships ConPTY out of band as Microsoft.Windows.Console.ConPTYconpty.dll + OpenConsole.exe, the same implementation Windows Terminal carries. Both paths are wired up behind PORTAPTY_CONPTY so the choice could be measured rather than argued. Out-of-band is the default, with an automatic fallback to in-box when conpty.dll isn't beside the assembly — so a consumer who hasn't referenced the package can't break.

It appeared to cost ~3.0 seconds per pseudoconsole. It doesn't. ConPTY asks the terminal what it is (Primary Device Attributes) and blocks three seconds waiting for a reply a read-only consumer never sends. Answering it up front:

first output (ms)
before 3016, 3012, 3011, 3013, 3019
after 15, 9, 9, 8, 8

Out-of-band then measures 9ms per pseudoconsole against in-box's 13ms — and pins behaviour to one implementation rather than to whatever Windows build the user happens to run.

Two things made that A/B lie before it told the truth, both written up in docs/conpty-out-of-band.md:

  • Recent Windows 11 ships its own System32\conpty.dll, so an unqualified DllImport resolves the OS copy and the "out-of-band" arm is quietly in-box. DefaultDllImportSearchPaths(AssemblyDirectory) is load-bearing, and leaving it off is silent rather than fatal.
  • The only direct evidence of which implementation is live is a process census — in-box spawns a conhost.exe per pseudoconsole, out-of-band an OpenConsole.exe. Three earlier A/B runs produced plausible, near-identical tables because both arms were conhost.

CsWin32 replaces Vanara

The point is the runtime dependency: Vanara ships an assembly every consumer then carries, for about twenty entry points. CsWin32 is a build-time generator with PrivateAssets=all — nothing at runtime, nothing in a consumer's graph — and the generated interop is trimming/AOT friendly. The idea is Sylinko's, from their fork; this is an independent implementation of the same move.

Windows job-object race

CreateProcessW wasn't given CREATE_SUSPENDED, so a child could run and exit before AssignProcessToJobObject reached it — surfacing as "Failed to assign process to job object". Now created suspended, assigned, then resumed.

net10.0, MSTest on MTP, and the harness

MTP reports crashes and hangs instead of absorbing them, which matters here because almost everything interesting is threads and process lifetime — a swallowed hang looks exactly like a pass.

ConcurrentSpawnTests is where the rest of this came from: 24 concurrent spawns, 20 samples per cell, minimal and realistic shells. It's what showed the reader strategy dominating everything else — 137ms to first output on a dedicated thread against 7546ms pooled — and what surfaced forkpty's thread-unsafety on macOS as a reproducible 5/24 rather than an occasional mystery.


Verified: 23 tests, 0 failed on macOS (3 Windows-only skipped). Your existing CI should stay green — it already builds and stages the native shim, and global.json gives dotnet test the MTP runner. I deliberately left your build-*.yml and PublishNuget.yml alone.

Verified on Windows, which is where most of this had to be: the ConPTY latency numbers above, the in-box vs out-of-band process census, and the job-object race were all found and measured on real Windows machines — the race in particular only exists there. macOS covers the forkpty and controller-fd work. Linux is the one platform I have exercised least, and your matrix covers it.

Packaging, and a consumer that can see it

The tests reference the library by project, which bypasses the .nupkg entirely — no runtimes/ resolution, no buildTransitive/, no native asset staging — so no packaging defect is visible from them. Two were sitting there, both silent:

  • A package consumer got conpty.dll with no OpenConsole.exe. ConPTY ships its host-staging logic under build/, which imports for a direct reference only. conpty.dll with no host doesn't error; it falls back to in-box conhost. buildTransitive/Porta.Pty.targets forwards it, so a consumer needs no metadata.
  • The POSIX shim wasn't staged for project-reference consumers, which is why the workflows hand-copied it — to a path that stopped existing once the test output went RID-specific. Now Content-flowed from the library, so the copy steps are gone and a guard asserts it landed.

samples/Porta.Pty.Demo is a real consumer — spawns a pty, echoes a random token, asserts it comes back — and the verify scripts pack the library and build it against a local feed, so what runs is what a consumer gets:

scripts/verify-consumer.sh                  Linux and macOS
scripts/Verify-ConPtyConsumerStaging.ps1    Windows: conpty.dll + both hosts, then the round trip (-Aot for native AOT)
scripts/Verify-ConPtyHost.ps1               Windows: which host actually launched, from the process tree

All three run in CI. The Windows leg covers win-x64 as well as the runner's own architecture, since an x64 process runs on ARM64 Windows under emulation. Also verified on a physical Windows ARM64 machine: both RIDs build clean and pass the round trip with both hosts staged.

@tomlm tomlm left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Hey, this looks good. Can you comment on the extra metadata that it looks like a consumer needs to use?

Comment thread src/Porta.Pty/Porta.Pty.csproj Outdated
<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
<RepositoryUrl>https://github.com/tomlm/Porta.Pty</RepositoryUrl>
<Copyright>Tom Laird-McConnell All Rights Reserved</Copyright>
<Version>1.0.7</Version>

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

I think this change is significant enough to warrant V2.0.0

<AssemblyVersion>1.0.7.0</AssemblyVersion>
<RootNamespace>Porta.Pty</RootNamespace>
<Keyword>pty;conpty;ptty</Keyword>
<ContentTargetFolders>content</ContentTargetFolders>

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

why were keywords and symbols removed?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

glitch? this came from a previously private fork. will fix

what produced the SupportedOSPlatform annotations on Windows-only P/Invoke that had been
compiled into a cross-platform assembly with nothing marking it.
If a .NET Framework consumer ever appears, multi-target then. Do not pre-pay for it. -->
<TargetFramework>net10.0</TargetFramework>

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

same, this warrents v2.0

Comment thread src/Porta.Pty.Tests/PtyTests.cs
silently runs on conhost instead. The package derives ConptyNativePlatform from PlatformTarget,
which for a managed project is AnyCPU and matches none of its branches; setting the two flags
directly works because they are read by its .targets, which imports after this file. -->
<ConptyRequiresx64Host>true</ConptyRequiresx64Host>

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

do people who use Porta.Pty need to do this?

@tomlm
tomlm requested a balanced review from Copilot August 22, 2026 15:06

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Changes recommended

Linux/macOS CI currently fails, and packaged consumers do not reliably receive the out-of-band ConPTY host.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Adds out-of-band ConPTY support, replaces Vanara with CsWin32, fixes PTY lifecycle races, and introduces concurrency-focused testing.

Changes:

  • Adds selectable ConPTY implementations and safer Windows process startup.
  • Fixes Unix PTY allocation and descriptor cleanup.
  • Migrates to .NET 10, MSTest/MTP, and expanded concurrency tests.
File summaries
File Description
.gitignore Ignores native build outputs.
docs/conpty-out-of-band.md Documents ConPTY behavior and measurements.
global.json Selects Microsoft Testing Platform.
src/Porta.Pty.Native/build.sh Builds native binaries by platform and architecture.
src/Porta.Pty.Native/porta_pty.c Serializes PTY allocation.
src/Porta.Pty.Tests/ConcurrentSpawnTests.cs Adds concurrency and latency harnesses.
src/Porta.Pty.Tests/Porta.Pty.Tests.csproj Migrates tests to .NET 10 and MSTest.
src/Porta.Pty.Tests/PtyTests.cs Converts existing tests to MSTest assertions.
src/Porta.Pty/Linux/PtyConnection.cs Closes Linux controller descriptors.
src/Porta.Pty/Linux/PtyProvider.cs Improves spawn diagnostics.
src/Porta.Pty/Mac/PtyConnection.cs Closes macOS controller descriptors.
src/Porta.Pty/Mac/PtyProvider.cs Improves spawn diagnostics.
src/Porta.Pty/NativeMethods.txt Defines CsWin32 generation inputs.
src/Porta.Pty/PlatformServices.cs Simplifies platform provider selection.
src/Porta.Pty/Porta.Pty.csproj Targets .NET 10 and replaces Vanara.
src/Porta.Pty/PtyProvider.cs Exposes ConPTY selection diagnostics.
src/Porta.Pty/Unix/PtyConnection.cs Adds controller cleanup.
src/Porta.Pty/Windows/JobObject.cs Ports job-object interop to CsWin32.
src/Porta.Pty/Windows/NativeMethods.cs Ports attribute-list interop.
src/Porta.Pty/Windows/PseudoConsole.cs Implements dual ConPTY selection.
src/Porta.Pty/Windows/PseudoConsoleConnection.cs Uses new handle abstractions.
src/Porta.Pty/Windows/PtyProvider.cs Fixes process assignment race and handshake.
Review details
  • Files reviewed: 20/22 changed files
  • Comments generated: 6
  • Review effort level: Balanced

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

exercised the runtime its consumers run on. That matters here more than usual: the POSIX
shim exists BECAUSE .NET 7 turned on W^X by default, so runtime version is a known-live
variable in this codebase. -->
<TargetFramework>net10.0</TargetFramework>
<!-- conpty.dll + OpenConsole.exe, the implementation Windows Terminal ships. Back in the library
because the default path uses it; PseudoConsole falls back to in-box when it is absent, so a
consumer that somehow does not get the natives degrades rather than breaks. -->
<PackageReference Include="Microsoft.Windows.Console.ConPTY" Version="1.24.260710001" />
Comment thread src/Porta.Pty/Windows/PseudoConsole.cs Outdated
Comment on lines +112 to +113
return NativeLibrary.TryLoad(
Path.Combine(AppContext.BaseDirectory, "conpty.dll"), out _);
Comment thread src/Porta.Pty/PtyProvider.cs Outdated
Comment thread docs/conpty-out-of-band.md Outdated
/// mechanistic and single-process. It asks whether ConPTY holds a dead child's output for
/// a reader that shows up late. If this one fails, we have the mechanism outright and no
/// statistics are needed.
/// * <see cref="ShortLivedProcesses_SpawnedConcurrently_AllDeliverTheirOutput"/> is the
@JohnCampionJr

Copy link
Copy Markdown
Contributor Author

Hey, this looks good. Can you comment on the extra metadata that it looks like a consumer needs to use?

on it :)

@tomlm

tomlm commented Aug 22, 2026

Copy link
Copy Markdown
Owner

Hey do you think this fixes issue #6 ?

@JohnCampionJr

Copy link
Copy Markdown
Contributor Author

Hey do you think this fixes issue #6 ?

I bet it does, but I haven't personally tested it with AOT. Let me see what I can do.

JohnCampionJr and others added 2 commits August 22, 2026 11:49
… found it all

Answers #33. Everything here came out of driving this library hard from a
desktop app — terminal panes and long-running children, many at once — so each
piece is a measurement rather than a preference.

OUT-OF-BAND ConPTY. Microsoft ships ConPTY out of band as
Microsoft.Windows.Console.ConPTY (conpty.dll + OpenConsole.exe), the same
implementation Windows Terminal carries. Both are wired up behind
PORTAPTY_CONPTY so the choice can be measured rather than argued; out-of-band is
the default with an automatic fallback to in-box when conpty.dll is not beside
the assembly, so a consumer who has not referenced the package cannot break.

It appeared for a long time to cost ~3.0 SECONDS per pseudoconsole. It does not,
and the reason is worth the docs page: ConPTY asks the terminal what it is
(Primary Device Attributes) and blocks three seconds waiting for a reply that a
read-only consumer never sends. Answering it up front took first-output latency
from [3016,3012,3011,3013,3019]ms to [15,9,9,8,8]ms. Out-of-band then measures
9ms per pseudoconsole against in-box's 13ms.

Two things that made the A/B lie before it told the truth, both in docs: recent
Windows 11 ships its own System32\conpty.dll, so an unqualified DllImport
resolves the OS copy and the 'out-of-band' arm is quietly in-box; and the only
direct evidence of which implementation is live is a process census, because
in-box spawns conhost.exe per pseudoconsole and out-of-band spawns
OpenConsole.exe.

CsWin32 REPLACES Vanara. The point is the runtime dependency: Vanara ships an
assembly every consumer then carries, for about twenty entry points. CsWin32 is
a build-time generator with PrivateAssets=all, so it contributes nothing at
runtime and nothing to a consumer's graph, and the generated interop is trimming
and AOT friendly. The idea is Sylinko's, from their fork; this is an independent
implementation of it.

WINDOWS JOB-OBJECT RACE. CreateProcessW was not given CREATE_SUSPENDED, so a
child could run — and exit — before AssignProcessToJobObject reached it, failing
with 'Failed to assign process to job object'. Created suspended, assigned, then
resumed.

net10.0 and MSTest on the Microsoft Testing Platform. MTP reports crashes and
hangs instead of absorbing them, which matters here because almost everything
interesting is threads and process lifetime — a swallowed hang looks exactly
like a pass.

ConcurrentSpawnTests is the harness the rest came from: 24 concurrent spawns,
20 samples per cell, minimal and realistic shells. It is what showed the reader
strategy dominating everything else (137ms to first output on a dedicated thread
against 7546ms pooled) and what surfaced forkpty's thread-unsafety on macOS as
5/24 failures rather than as an occasional mystery.

Overlaps tomlm#7, tomlm#8 and tomlm#9 — those are the same fixes against the pre-CsWin32 tree.
Merge them first and I will rebase, or take this and close them, whichever you
prefer.

23 tests, 0 failed on macOS (3 Windows-only skipped).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The test project is RID-specific now, so the ConPTY natives land beside the test
assembly on Windows. That moved its output from bin/Debug/<tfm>/ to
bin/Debug/<tfm>/<rid>/, and the Linux and macOS staging steps copy the shim to a
fixed bin/Debug/net6.0/ — a path that does not exist, under a `|| true` that hides
the failure. The run then dies 20 lines later as

    DllNotFoundException: Unable to load shared library 'libporta_pty'

with nothing pointing back at the copy that did nothing. Locate the directory from
the built assembly and fail loudly if it is not there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
JohnCampionJr and others added 5 commits August 22, 2026 12:03
Microsoft.Windows.Console.ConPTY splits its payload across two locations and only
one half travels transitively:

  runtimes/win-<arch>/native/conpty.dll          ordinary native asset — flows
  build/native/runtimes/<arch>/OpenConsole.exe   staged by build/ targets

NuGet imports a package's build/ folder for a DIRECT PackageReference only, and
ConPTY ships no buildTransitive/. Porta.Pty references it directly, so our build
stages the host and our tests pass; a consumer of Porta.Pty got conpty.dll and
nothing else. conpty.dll with no host to launch does not fail — it falls back to
in-box conhost SILENTLY, which is the behaviour the out-of-band package was taken
to avoid. Measured by packing the library and building a win-x64 consumer: the
output held conpty.dll alone.

Setting ConptyRequires*Host from a shipped .props cannot fix it — the targets that
read those flags are the ones that never import. So buildTransitive/Porta.Pty.targets
adds the items itself, and a consumer needs no metadata at all.

Two details are load-bearing, both found by testing:

* The ConPTY package directory is DERIVED from the resolved conpty.dll, not composed
  from NuGetPackageRoot and a version literal. The consumer's graph decides which
  ConPTY version wins, and a hard-coded version resolves to a path that quietly does
  not exist.
* DestinationSubDirectory, NOT TargetPath. _CopyFilesMarkedCopyLocal composes
  $(OutDir)%(DestinationSubDirectory)%(Filename)%(Extension) and never reads
  TargetPath — with TargetPath both hosts copy flat and the second overwrites the
  first, leaving a win-x64 consumer holding the ARM64 host.

Verified on build and publish: conpty.dll, x64/OpenConsole.exe, arm64/OpenConsole.exe.
Opt out with PortaPtyStageConPtyHost=false.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The test suite references the library by PROJECT, which bypasses the .nupkg
entirely: no runtimes/ resolution, no buildTransitive/, no native asset staging.
Every packaging defect this repo has is therefore invisible from it, and two were
sitting there.

samples/Porta.Pty.Demo is a real consumer — spawns a pty, echoes a random token,
asserts it comes back. The verify scripts pack the library and build the sample
against a local feed, so what runs is what a consumer gets:

  scripts/verify-consumer.sh                  Linux and macOS
  scripts/Verify-ConPtyConsumerStaging.ps1    Windows: conpty.dll + both hosts, then the round trip
  scripts/Verify-ConPtyHost.ps1               Windows: WHICH host actually launched, from the process tree

All three are wired into CI. The Windows leg runs win-x64 as well as the runner's
own architecture, because an x64 process runs on ARM64 Windows under emulation;
the round trip is skipped, loudly, when the host cannot execute that RID.

Two defects this found, both silent:

* The library did not stage its POSIX shim for project-reference consumers, which
  is why the workflows hand-copied it. Now Content-flowed from Porta.Pty.csproj, so
  the copy steps are gone and a guard asserts the shim landed instead.
* Doing that naively BREAKS THE PACKAGE. A second item pointing at the same file
  with Pack="false" is reconciled by NuGet, Pack="false" wins, and the package
  silently loses that RID — and since the RID is the build host's, the package is
  always missing exactly the platform it was built on while every other platform is
  present. Measured: packing on osx-arm64 produced a package containing osx-x64
  alone. Each RID is now declared exactly once, the host's as Content.

Also: `unzip -l | grep -q` under `set -o pipefail` reports failure on a MATCH —
grep -q exits early, unzip takes SIGPIPE, pipefail propagates it. The check failed
on a perfectly good package until it listed into a variable first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ot a continuation

Two CI-only failures from the previous commit.

GeneratePackageOnBuild is true on the library, and with it set the Pack target does
NOT depend on Build — otherwise packing would recurse. `dotnet pack -c Release`
therefore packs whatever is already in bin/Release, and CI builds Debug, so there is
nothing there:

  NU5026: The file '.../bin/Release/net10.0/Porta.Pty.dll' to be packed was not found

It passes locally after any Release build, which is exactly what made it invisible
until it ran on a clean runner. All three scripts now pass -p:GeneratePackageOnBuild=false.

The PowerShell scripts had `` where they meant ` — two backticks are an escaped
backtick, not a line continuation, so the next line parsed as its own statement:
"Missing expression after unary operator '--'".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
On Windows the second --source argument reached NuGet as a relative path rather than
a URI, and NuGet resolved it against the project directory:

  NU1301: The local source '...\samples\Porta.Pty.Demo\https:\api.nuget.org\v3\index.json'
          doesn't exist.

A config file has no such ambiguity. --configfile also stops any NuGet.config in the
tree from contributing sources, so the check restores from exactly the local feed and
nuget.org. Applied to the shell script too, so both platforms restore identically
rather than one of them keeping a shape that only happens to work.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Native AOT is a separate question from packaging: whether the interop survives having
no JIT and no reflection fallback. It is worth its own switch because the failure is
not a build error — it publishes cleanly and then throws at the spawn, which is the
shape reported in tomlm/Iciclecreek.Avalonia.Terminal#6 ("Unable to convert object to
its binary format").

CsWin32 source-generates its P/Invoke, so there is nothing for the trimmer to fail to
see; the Vanara wrappers it replaced did not have that property uniformly. This turns
that reasoning into something measurable rather than an expectation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@JohnCampionJr

Copy link
Copy Markdown
Contributor Author

Both of your questions turned out to have the same answer underneath, and chasing the first one found a real defect. Rebased onto main (so #7, #8 and #9 are out of the diff) and CI is green on all three platforms now.

"the extra metadata that it looks like a consumer needs to use"

None — but you were right to ask, because until a few commits ago there was some, and it was undocumented and silent.

Microsoft.Windows.Console.ConPTY splits its payload across two locations, and only one half travels transitively:

path in the ConPTY package reaches a transitive consumer?
runtimes/win-<arch>/native/conpty.dll yes — ordinary native asset resolution
build/native/runtimes/<arch>/OpenConsole.exe nobuild/ imports for a direct PackageReference only, and ConPTY ships no buildTransitive/

Porta.Pty references ConPTY directly, so our build staged the host and our tests passed. A consumer of Porta.Pty got conpty.dll and nothing else — and conpty.dll with no host to launch doesn't fail. It falls back to in-box conhost silently: no error, no warning, exactly the behaviour the out-of-band package was taken to avoid.

I found it by packing the library and building a win-x64 consumer against it: the output directory held conpty.dll alone.

buildTransitive/Porta.Pty.targets now forwards the host, so a consumer needs no metadata at all. Two details in there are load-bearing:

  • The ConPTY package directory is derived from the resolved conpty.dll, not composed from NuGetPackageRoot plus a version literal. The consumer's graph decides which ConPTY version wins, and a hard-coded version resolves to a path that quietly doesn't exist.
  • DestinationSubDirectory, not TargetPath. _CopyFilesMarkedCopyLocal builds $(OutDir)%(DestinationSubDirectory)%(Filename)%(Extension) and never reads TargetPath — with TargetPath both hosts copy flat and the second overwrites the first, leaving a win-x64 consumer holding the ARM64 host.

Setting ConptyRequiresx64Host from a shipped .props can't fix this, incidentally — the targets that read those flags are precisely the ones that never import.

The only knob is an opt-out: PortaPtyStageConPtyHost=false, for a consumer staging the host itself.

Why the tests couldn't have caught that

They reference the library by project, which bypasses the .nupkg entirely — no runtimes/ resolution, no buildTransitive/, no native asset staging. Every packaging defect is invisible from there.

So there's now samples/Porta.Pty.Demo, a real consumer that spawns a pty, echoes a random token and asserts it comes back. The verify scripts pack the library and build the sample against a local feed, so what runs is what a consumer gets:

scripts/verify-consumer.sh                  Linux and macOS
scripts/Verify-ConPtyConsumerStaging.ps1    Windows: conpty.dll + both hosts, then the round trip
scripts/Verify-ConPtyHost.ps1               Windows: which host actually launched, from the process tree

All three run in CI. The Windows leg does win-x64 as well as the runner's own architecture, since an x64 process runs on ARM64 Windows under emulation.

Verified on a physical Windows ARM64 machine: both win-arm64 and win-x64 build clean and pass the round trip, with conpty.dll and both OpenConsole.exe hosts staged.

The Linux/macOS CI failures

Mine, and worth stating plainly: the test project is RID-specific now (so the ConPTY natives land beside the test assembly on Windows), which moved its output from bin/Debug/<tfm>/ to bin/Debug/<tfm>/<rid>/. Your staging step copied the shim to a fixed bin/Debug/net6.0/ under a || true, so it silently did nothing and the run died 20 lines later as DllNotFoundException naming nothing.

Rather than patch the path, the library now stages its own shim into a project-reference consumer's output via Content, so the copy steps are gone entirely and a guard asserts the shim landed.

That fix had a trap in it worth flagging, because it packs cleanly while being badly wrong: a second item pointing at the same file with Pack="false" is reconciled by NuGet, Pack="false" wins, and the package silently loses that RID. Since the RID is the build host's, the package ends up always missing exactly the platform it was built on, with every other platform present — so the listing looks plausible. Measured: packing on osx-arm64 produced a package containing osx-x64 alone. Each RID is now declared exactly once, the host's as Content.

Does this fix #6?

Testing it rather than guessing — -Aot on the consumer script publishes the demo with PublishAot=true and runs it, and I'll report back with the result.

The reason to expect it does: "Unable to convert object to its binary format" is a serialization path failing with no JIT behind it. CsWin32 source-generates its P/Invoke, so there's nothing for the trimmer to fail to see; the Vanara wrappers it replaces are mostly reflection-free but not uniformly, and Vanara also shipped a runtime assembly every consumer carried for roughly twenty entry points. That's the main reason for the swap — AOT was the bonus, and it's the part I want measured before claiming it.

JohnCampionJr and others added 4 commits August 22, 2026 12:38
Version 2.0.0 as tomlm asked: the target framework moves netstandard2.0 -> net10.0,
which breaks any .NET Framework or netstandard consumer, and Vanara leaves the
dependency graph.

Restores four properties this branch had dropped — Keyword, ContentTargetFolders,
IncludeSymbols and SymbolPackageFormat. They came off in the private-fork history,
not deliberately; losing IncludeSymbols means shipping a release with no snupkg.

The Copilot findings, minus the two already fixed (the transitive ConPTY host and
the POSIX CI staging):

* The conpty.dll availability probe looked in AppContext.BaseDirectory while the
  imports resolve against DllImportSearchPath.AssemblyDirectory. Identical for an
  ordinary app, different for a plugin or custom load context — and wrong in both
  directions there: it can report in-box with conpty.dll sitting beside the assembly,
  or report out-of-band and then fail the import. Probes the assembly directory
  first, falling back to the base directory since Assembly.Location is empty under
  single-file and native AOT.

* PseudoConsoleImplementation returned "oob" for "conpty.dll was selected", which is
  not the same claim. conpty.dll with no OpenConsole.exe beside it falls back to
  conhost silently, so that reading produces the exact false A/B the docs warn about:
  out-of-band against in-box with both arms conhost. It now reports "oob-no-host"
  for that case, backed by PseudoConsole.OutOfBandHostPresent.

* A <see cref> named a test method that does not exist, and the doc pointed at a
  .github/workflows/ci.yml this repo does not have.

One more thing measured while checking Copilot's transitive-host claim: host staging
is now gated on the build having a RuntimeIdentifier. Native assets are flattened
into the app root only for a RID-specific build; without one conpty.dll stays under
runtimes/win-x64/native/ where the imports cannot see it, and the library correctly
uses in-box. Staging the hosts there put 2.2MB of OpenConsole.exe into an output that
could never use them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
tomlm asked on the PR whether people using Porta.Pty need what the test project
does here. They do not, and the comment claiming otherwise was written before the
library carried the ConPTY reference itself.

A package consumer needs no Conpty* property and no ConPTY PackageReference:
buildTransitive/Porta.Pty.targets stages the hosts on their side.
samples/Porta.Pty.Demo sets neither and gets conpty.dll plus both hosts. The one
requirement is a RID-specific build, which is what flattens native assets into the
app root where DllImportSearchPath.AssemblyDirectory looks.

The test project needs it because it references the library by PROJECT. Package
build assets are a NuGet mechanism and do not apply across a ProjectReference, and
properties do not flow across one either — the same gap that let the consumer bug
hide in the first place.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Publishing a consumer with PublishAot warned:

  IL3000: 'Assembly.Location.get' always returns an empty string for assemblies
          embedded in a single-file app

An empty Location IS the single-file and AOT case, and it is already handled — the
caller falls back to AppContext.BaseDirectory. The warning's advice, use
BaseDirectory, cannot be the whole answer here: the imports resolve against the
ASSEMBLY directory, and the two differ for a plugin or a custom load context, which
is the bug this probe was fixed for in the first place.

Split into its own method so the suppression covers only the Location access and is
not sitting on an iterator, where it would attach to the method rather than the
generated state machine.

Verified on Windows ARM64: PublishAot on win-arm64 and win-x64 both build clean and
pass the pty round trip — which also answers whether this fixes the AOT spawn failure
reported in tomlm/Iciclecreek.Avalonia.Terminal#6.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three related changes to the "out-of-band silently became in-box" problem.

**One resolution point.** ConPtyPath resolves conpty.dll once and both the
availability answer and the actual load go through it, via a DllImportResolver
installed from PseudoConsole's static constructor. Those used to be separate lookups
that could disagree — the probe read AppContext.BaseDirectory, the imports resolved
against the assembly directory — and a probe that can disagree with the load is worse
than none, because it is confidently wrong in both directions. Copilot's suggestion
was to align the two; making them the same code removes the category.

The resolver loads by ABSOLUTE PATH, so it keeps the reason the imports are pinned in
the first place: Windows 11 ships a conhost-backed System32\conpty.dll that an
unpinned DllImport binds happily, giving a working pty on exactly the implementation
out-of-band exists to replace. Declining falls back to the pinned behaviour, so that
copy stays unreachable either way. It also covers the plugin / custom-load-context
case, where the assembly directory and the app base genuinely differ.

**Out-of-band now requires BOTH halves.** conpty.dll without its OpenConsole.exe does
not fail and does not warn — it falls back to conhost internally — so selecting it on
the strength of the DLL alone buys nothing over the in-box path and costs the ability
to say which one ran. Taking in-box deliberately keeps the two arms distinct, which is
what makes measuring them mean anything.

**PORTAPTY001.** A consumer with no RuntimeIdentifier cannot get out-of-band at all:
the SDK only flattens native assets into the app root for a RID-specific build, and a
portable build keeps the runtimes/ tree where the pinned imports cannot look. The
library then falls back correctly and nothing says so. That is worth a build warning
rather than a doc paragraph, since the consumer who needs to know is the one who did
not read the doc. Windows-only, and suppressible by NoWarn or by
PortaPtyNoRuntimeIdentifierWarning=false — all four cases verified.

One bug caught while writing this, worth recording because it would not have looked
like anything: static initializers run in DECLARATION order, so UseOutOfBand —
declared first, being the one callers care about — read a still-null ConPtyPath and
would have reported in-box unconditionally. In-box is a legitimate answer and the
tests force the mode explicitly, so nothing would have failed. The static constructor
now assigns all three in dependency order.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@JohnCampionJr

Copy link
Copy Markdown
Contributor Author

@tomlm it does indeed fix AOT. But the larger issue this all revealed is that this also breaks the portability of the earlier version. currently trying to find a way to have it all.

JohnCampionJr and others added 5 commits August 22, 2026 12:58
A library that forces every consumer to pin a RuntimeIdentifier is not a portable
library, and the previous commit was heading that way: it gated host staging on a RID
and added a build warning telling portable consumers they were holding it wrong.

They are not. The reason out-of-band ConPTY needed a RID was that the host was staged
only into the flattened layout, so in a portable build conpty.dll sat under
runtimes/win-<arch>/native/ with no OpenConsole.exe anywhere near it.

Each host is now staged NEXT TO ITS OWN conpty.dll, with the destination derived from
the DLL's DestinationSubDirectory rather than assumed:

  RID build       dsd = ''                          -> x64\ and arm64\ at the output root
  portable build  dsd = 'runtimes/win-x64/native/'  -> runtimes/win-x64/native/x64\

A RID build still gets both hosts, since an x64 process runs on ARM64 Windows under
emulation; a portable build already has one conpty.dll per architecture, so each gets
only its own. Both layouts verified.

The PORTAPTY001 warning is gone with the requirement that motivated it.

verify-consumer.sh now builds and runs the sample a second time with no RID, so the
portable claim is proved on Linux and macOS in CI rather than asserted — the POSIX
shim resolves through deps.json with no flattening involved. Verified locally: pty
round trip passes with no RuntimeIdentifier.

Windows has one question left that only a process census can answer — whether
conpty.dll looks beside ITSELF for its host in the portable layout. Both verify
scripts take -NoRid for exactly that.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…off Windows

`$x = if (...) { @('one') }` UNWRAPS a single-element array to a bare string, and
splatting a string with @x enumerates its CHARACTERS. MSBuild was handed '-', 'p',
':', 'U', ... as separate arguments and both -NoRid checks died before doing anything.
Reproduced exactly:

  OLD (uncast):     - p : U s e C u r r e n t R u n t i m e I d e n t i f i e r = f a l s e
  NEW ([string[]]): -p:UseCurrentRuntimeIdentifier=false

The [string[]] cast keeps it an array in both branches.

This is the third PowerShell defect in this branch to be caught by running it on a
Windows box rather than here — after a doubled backtick that is an escape, not a line
continuation, and --source reaching NuGet as a relative path. So the staging script's
run-gate is now OS-aware as well as architecture-aware: it checked $ridArch against
$hostArch and would happily try to execute a .exe on macOS. With that, everything
except the round trip runs anywhere, since a win-x64 build stages the Windows natives
like any other file.

Both paths now verified from macOS before pushing:

  -Rid win-x64   conpty.dll + x64\ + arm64\ hosts at the output root
  -NoRid         runtimes/win-{x64,arm64}/native/{conpty.dll, <arch>/OpenConsole.exe}

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The RID requirement is gone, so the places that stated it are wrong. docs said a
consumer "needs a RID-specific output" and listed it as one of three things required
for out-of-band; it was true only because nothing staged the host into the portable
layout — the DLL sat under runtimes/win-<rid>/native with no <arch>/ subdirectory
beside it. Requiring a RID of every consumer was the wrong fix, and the doc now says
what a consumer actually needs, which is nothing.

Worth recording: the host-lookup rule the doc already stated — conpty.dll launches
OpenConsole.exe from an <arch>/ subdirectory of ITS OWN directory — is confirmed by
census rather than by documentation, because the ConPTY package ships none. On
Windows ARM64, portable layout: OpenConsole.exe in the process tree by default,
conhost.exe with PORTAPTY_CONPTY=inbox as the control.

The AOT sample is now built and run on all three platforms. Its
RuntimeFeature.IsDynamicCodeSupported check earns its place: a misconfigured publish
that quietly produced an ordinary binary would otherwise pass and prove nothing.
Verified on macOS here (clean, passes) as well as on Windows ARM64 and x64.

It complements rather than duplicates the -Aot switch on the consumer script: this one
is a ProjectReference and always AOT, that one is a real package consumer published
with PublishAot.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The README was the worst of it, and the first thing a consumer reads:

* It advertised **.NET Standard 2.0** — the headline breaking change in 2.0.0, stated
  backwards. Now says net10.0, with an "Upgrading to 2.0" note giving the trade, since
  the API itself is unchanged and the major bump needs explaining.
* It listed **Vanara.PInvoke.Kernel32** and **Mono.Posix.NETStandard** as dependencies.
  Neither is one any more: CsWin32 replaced Vanara and is build-only, and Unix needs no
  managed interop package at all.
* Its Windows section did not mention out-of-band ConPTY, which is the main behavioural
  change in this branch.

Everything asserting a consumer needs a RuntimeIdentifier is corrected, now that the
portable layout is verified: docs/conpty-out-of-band.md listed it as one of three
requirements, the test csproj said "any consumer that wants the out-of-band path needs
the same RID-specific output", and the sample's comment called a RID required when it
is only a default.

Both verify scripts had comments posing the portable question as open. It is answered,
and they say so — while noting they are kept as regression guards, because the rule
they depend on (conpty.dll launches OpenConsole.exe from an <arch>/ subdirectory of its
own directory) is documented in no Microsoft package and could change under us.

Historical references to Vanara in the interop comments are deliberate: they explain
why the CsWin32 shape differs from what was there before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@JohnCampionJr

JohnCampionJr commented Aug 22, 2026

Copy link
Copy Markdown
Contributor Author

Whew, at long last. It all works perfectly now. It is fully portable, and AOT is confirmed, closes #6

Claude Below:

Bumped to 2.0.0 as you asked — the netstandard2.0 → net10.0 move is a breaking change for any .NET Framework or netstandard consumer, and Vanara leaving the dependency graph is a second one. The API itself is unchanged, so there's now an "Upgrading to 2.0" note in the README saying exactly that.

While in there I also restored four properties this branch had dropped: Keyword, ContentTargetFolders, IncludeSymbols, SymbolPackageFormat. That was the private-fork glitch, not a decision — and losing IncludeSymbols would have meant shipping a release with no .snupkg.

Correcting my earlier answer on consumer metadata

I said "none" a few comments up. That was right about properties and wrong by omission, and rather than caveat it I'd rather fix it: a consumer now needs nothing at all, including no RuntimeIdentifier.

The gap was real. Native assets are only flattened into the app root for a RID-specific build; a portable build keeps the runtimes/ tree, so conpty.dll sat under runtimes/win-x64/native/ with no OpenConsole.exe anywhere near it, and the out-of-band path silently became conhost. My first instinct was to document the requirement and warn about it at build time. That's the wrong shape for a library — forcing every consumer to pin a RID isn't a reasonable thing to ask — so instead:

  • Each host is staged next to its own conpty.dll, with the destination derived from the DLL's DestinationSubDirectory rather than assuming the flattened case:

    build conpty.dll host
    RID-specific app root x64/, arm64/
    portable runtimes/win-<arch>/native/ runtimes/win-<arch>/native/<arch>/
  • An import resolver finds it in either layout. The imports are pinned to DllImportSearchPath.AssemblyDirectory deliberately — Windows 11 ships a conhost-backed System32\conpty.dll that an unpinned DllImport binds happily — and that pin can't see runtimes/. Ordinary P/Invoke would resolve it through deps.json, but that's the resolution we're refusing. The resolver loads by absolute path, so it keeps the protection and extends the reach.

Verified on Windows ARM64 by process census: portable build, OpenConsole.exe in the process tree by default, conhost.exe with PORTAPTY_CONPTY=inbox as the control. The Linux/macOS equivalent runs in CI on every push.

This rests on conpty.dll launching OpenConsole.exe from an <arch>/ subdirectory of its own directory. Nothing in the ConPTY package documents that — no readme, and conpty.h says nothing — so it's confirmed by census rather than by contract, and scripts/Verify-ConPtyHost.ps1 -NoRid stays as a regression guard in case it changes.

"do people who use Porta.Pty need to do this?"

No. That was on ConptyRequiresx64Host in the test project, and the answer splits in two:

  • A package consumer needs none of it. The library carries the ConPTY reference and ships buildTransitive/Porta.Pty.targets. samples/Porta.Pty.Demo sets no Conpty* property and no ConPTY PackageReference, and gets conpty.dll plus its host.
  • The test project needs it because it references the library by project. Package build assets — build/ and buildTransitive/ alike — are a NuGet mechanism that doesn't apply across a ProjectReference, and MSBuild properties don't flow across one either.

That second point is also why the packaging bugs hid for so long: a ProjectReference can't exercise any of it, so the test suite was structurally incapable of noticing. Hence samples/Porta.Pty.Demo, which the verify scripts build from a real .nupkg — it spawns a pty, echoes a random token and asserts it comes back. That's what found the ConPTY host gap Copilot flagged, and two more besides.

The Copilot review

All six addressed:

finding
Transitive consumers don't get the ConPTY host Fixed — buildTransitive/Porta.Pty.targets
Linux/macOS CI failing Fixed — see below
Probe used AppContext.BaseDirectory, imports use AssemblyDirectory Fixed, and made structural: one resolved path now serves both, so they can't disagree
PseudoConsoleImplementation reported the selected branch, not what runs Fixed — reports oob-no-host separately
<see cref> to a method that doesn't exist Fixed
Doc pointed at a ci.yml that doesn't exist Fixed

The third and fourth were the good ones. On the fourth in particular — conpty.dll with no host falls back silently, so reporting oob on the strength of having selected it produces exactly the false A/B the docs warn about, both arms conhost and the numbers agreeing beautifully. Out-of-band is now only claimed when both halves are present; otherwise the in-box path is taken deliberately, so the two arms stay genuinely distinct.

The CI failures were mine

The test project is RID-specific now, so the ConPTY natives land beside the test assembly on Windows. That moved its output from bin/Debug/<tfm>/ to bin/Debug/<tfm>/<rid>/, and the POSIX staging steps copied the shim to a fixed bin/Debug/net6.0/ under a || true — so they silently did nothing and the run died 20 lines later as DllNotFoundException, naming nothing.

Rather than patch the path, the library now stages its own shim into a project-reference consumer's output, so those copy steps are gone and a guard asserts it landed instead.

Worth flagging one trap from that fix, since it packs cleanly while being badly wrong: a second item pointing at the same file with Pack="false" gets reconciled by NuGet, Pack="false" wins, and the package silently loses that RID — and since it's the build host's RID, the package ends up always missing exactly the platform it was built on while every other platform is present. Packing on osx-arm64 produced a package containing osx-x64 alone. Each RID is declared exactly once now.

Does it fix #6?

Yes — measured, not assumed. Native AOT publishes clean and spawns correctly on win-arm64, win-x64 under emulation, and macOS.

samples/Porta.Pty.AotDemo asserts RuntimeFeature.IsDynamicCodeSupported is false before it does anything, so a misconfigured publish that quietly produced an ordinary binary fails rather than passing. It runs on all three platforms in CI.

The reason it works: "Unable to convert object to its binary format" is a serialization path failing with no JIT behind it. CsWin32 source-generates its P/Invoke, so there's nothing for the trimmer to fail to see — the Vanara wrappers were mostly reflection-free but not uniformly. AOT was a bonus of the swap rather than its motivation; the motivation was that Vanara shipped a runtime assembly every consumer carried for about twenty entry points.

Where it stands

CI green on all three platforms. Beyond the existing suite, each push now also:

  • builds a real package consumer and runs a pty round trip through it — RID-specific and portable
  • asserts conpty.dll and both hosts are staged, on win-x64 as well as the runner's own architecture
  • publishes and runs the Native AOT sample

Verified on physical Windows ARM64 as well: the suite, both RIDs, portable, and AOT.

Happy to split any of this out if the PR is too much in one piece — the packaging work and the ConPTY/CsWin32 work are separable, though the packaging fixes are only visible because the ConPTY work made a consumer care.

@JohnCampionJr
JohnCampionJr requested a review from tomlm August 22, 2026 17:20
NuGet auto-imports buildTransitive/{PackageId}.targets and ignores every other
name. PackagePath="buildTransitive/" packs this file under its own name, which
happens to equal $(PackageId) here — so the Porta.Pty package is byte-identical
either way and nothing in this repo changes.

It only diverges when the project is packed under a different id, which is how I
hit it: a fork repackaging with -p:PackageId shipped the targets inert. Nothing
fails — the package restores and builds, ConPTY is never staged, and the library
quietly falls back to conhost. NU5131 is the warning, and it is easy to miss
because the file is demonstrably present in the nupkg.

Packing $(PackageId) is correct in both cases. Verified both ids produce the
right name with no NU5131.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ilently inert

The portable (no RuntimeIdentifier) layout is what ConPtyImportResolver and the
runtimes/win-<arch>/native probe exist for, and nothing in CI touched it.
verify-consumer.sh covers the portable case on Linux and macOS, where none of
that code exists; the Windows leg ran only -Rid win-x64 and -Rid win-arm64. So
the newest Windows behaviour shipped with no regression guard -- and it rests on
conpty.dll launching its host from an <arch>/ subdirectory of its OWN directory,
which is confirmed by process census rather than documented anywhere.

Adds both halves on Windows: -NoRid staging, then Verify-ConPtyHost.ps1 -NoRid
for the census, which is the only direct evidence of which implementation is
live.

Keyword -> PackageTags. Keyword is a Visual C++ project property that NuGet
never reads, so the tags packed as nothing: the generated nuspec carried no
<tags> element at all. Verified by packing -- <tags>pty conpty ptty</tags> is
there now.

UseOutOfBand's documentation had come to sit above the static constructor's own
<summary>, leaving the constructor with two summaries and the property with
none. Moved to the property, and dropped the "or whose output is not
RID-specific" clause from it, which the import resolver made untrue.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QnyxFCdinVii4qQVE8AiYH
@tomlm
tomlm merged commit 67062e4 into tomlm:main Aug 22, 2026
3 checks passed
@JohnCampionJr
JohnCampionJr deleted the conpty-cswin32 branch August 23, 2026 00:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants