Out-of-band ConPTY, CsWin32 interop, and the concurrency harness that found it all - #10
Conversation
tomlm
left a comment
There was a problem hiding this comment.
Hey, this looks good. Can you comment on the extra metadata that it looks like a consumer needs to use?
| <GeneratePackageOnBuild>true</GeneratePackageOnBuild> | ||
| <RepositoryUrl>https://github.com/tomlm/Porta.Pty</RepositoryUrl> | ||
| <Copyright>Tom Laird-McConnell All Rights Reserved</Copyright> | ||
| <Version>1.0.7</Version> |
There was a problem hiding this comment.
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> |
There was a problem hiding this comment.
why were keywords and symbols removed?
There was a problem hiding this comment.
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> |
| 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> |
There was a problem hiding this comment.
do people who use Porta.Pty need to do this?
There was a problem hiding this comment.
🟡 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" /> |
| return NativeLibrary.TryLoad( | ||
| Path.Combine(AppContext.BaseDirectory, "conpty.dll"), out _); |
| /// 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 |
on it :) |
|
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. |
… 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>
bf10558 to
a384469
Compare
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>
|
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.
Porta.Pty references ConPTY directly, so our build staged the host and our tests passed. A consumer of Porta.Pty got I found it by packing the library and building a
Setting The only knob is an opt-out: Why the tests couldn't have caught thatThey reference the library by project, which bypasses the So there's now All three run in CI. The Windows leg does Verified on a physical Windows ARM64 machine: both The Linux/macOS CI failuresMine, 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 Rather than patch the path, the library now stages its own shim into a project-reference consumer's output via 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 Does this fix #6?Testing it rather than guessing — The reason to expect it does: |
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>
|
@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. |
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>
|
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: Correcting my earlier answer on consumer metadataI 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 The gap was real. Native assets are only flattened into the app root for a RID-specific build; a portable build keeps the
Verified on Windows ARM64 by process census: portable build, This rests on "do people who use Porta.Pty need to do this?"No. That was on
That second point is also why the packaging bugs hid for so long: a The Copilot reviewAll six addressed:
The third and fourth were the good ones. On the fourth in particular — The CI failures were mineThe test project is RID-specific now, so the ConPTY natives land beside the test assembly on Windows. That moved its output from 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 Does it fix #6?Yes — measured, not assumed. Native AOT publishes clean and spawns correctly on
The reason it works: Where it standsCI green on all three platforms. Beyond the existing suite, each push now also:
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. |
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
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.ConPTY—conpty.dll+OpenConsole.exe, the same implementation Windows Terminal carries. Both paths are wired up behindPORTAPTY_CONPTYso the choice could be measured rather than argued. Out-of-band is the default, with an automatic fallback to in-box whenconpty.dllisn'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:
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:System32\conpty.dll, so an unqualifiedDllImportresolves 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.conhost.exeper pseudoconsole, out-of-band anOpenConsole.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
CreateProcessWwasn't givenCREATE_SUSPENDED, so a child could run and exit beforeAssignProcessToJobObjectreached 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.
ConcurrentSpawnTestsis 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 surfacedforkpty'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.jsongivesdotnet testthe MTP runner. I deliberately left yourbuild-*.ymlandPublishNuget.ymlalone.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
forkptyand 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
.nupkgentirely — noruntimes/resolution, nobuildTransitive/, no native asset staging — so no packaging defect is visible from them. Two were sitting there, both silent:conpty.dllwith noOpenConsole.exe. ConPTY ships its host-staging logic underbuild/, which imports for a direct reference only.conpty.dllwith no host doesn't error; it falls back to in-box conhost.buildTransitive/Porta.Pty.targetsforwards it, so a consumer needs no metadata.Content-flowed from the library, so the copy steps are gone and a guard asserts it landed.samples/Porta.Pty.Demois 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:All three run in CI. The Windows leg covers
win-x64as 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.