From 4c7b2e6e8dbdc2a35eeb6a76594b5d8379e5603e Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 22 Aug 2026 13:27:21 -0500 Subject: [PATCH 1/9] Docs(feat[contributing]): Say how the psmux runner is provisioned why: The gates section named the runner and its labels but not how to get one, so the only path to a working release machine was reading release.yml and inferring it. The psmux job has no hosted fallback, so a missing or offline runner queues the job rather than failing it, which reads as a hung release. what: - Give the config.cmd invocation, including the label that makes runs-on match and the registration token's lifetime - State the machine's prerequisites, including the WSL side the smoke drives - Show the command that reports what GitHub actually sees --- .github/CONTRIBUTING.md | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index a30c211..8dc549c 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -393,6 +393,41 @@ $ mise exec -- which dotnet Those inputs make the gates runnable; they do not by themselves complete the runtime evidence. +#### Provisioning the runner + +The machine needs Windows x64, a Windows `dotnet` and `git`, and a WSL +distribution holding a checkout with its own Linux `dotnet`. The psmux job +drives both sides from one PowerShell process, so a runner without WSL fails +the gate rather than skipping it. + +Register it with the `psmux` label; `self-hosted`, `Windows` and `X64` are +added for you, and `runs-on` matches on all four: + +```console +$ ./config.cmd \ + --unattended \ + --replace \ + --url https://github.com/libtmux/libtmux-dotnet \ + --token "$(gh api -X POST \ + repos/libtmux/libtmux-dotnet/actions/runners/registration-token \ + --jq .token)" \ + --name psmux-wsl-win \ + --labels psmux +``` + +A registration token expires in an hour, so generate it when you use it. Run +the listener with `./run.cmd`, or install it as a service with `./svc.cmd +install` if it should survive a reboot. The runner must be online when the tag +is pushed: `psmux` has no `ubuntu-latest` fallback, so a queued job waits +rather than failing fast. + +Confirm what GitHub sees before relying on it: + +```console +$ gh api repos/libtmux/libtmux-dotnet/actions/runners \ + --jq '.runners[] | "\(.name) \(.status) [\([.labels[].name] | join(","))]"' +``` + ### Recorded evidence is a release artifact A capability row is `pending` until a matrix run records evidence for it, and From 730063ad49b0a3e163d25f2f5e7172a748d1e381 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 22 Aug 2026 13:37:27 -0500 Subject: [PATCH 2/9] Ci(fix[psmux]): Install the pinned SDK where the runner can write why: The psmux job never ran before, and its first run failed in setup-dotnet: global.json pins 10.0.302 with rollForward disabled, so the action installs that exact SDK rather than using what Windows has, and it defaults to C:\Program Files\dotnet. A self-hosted runner is an ordinary user account, so the install was denied and the gate failed before reaching any psmux code. what: - Point DOTNET_INSTALL_DIR at the runner tool cache for that step, which the runner account owns and which persists between runs --- .github/workflows/release.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fa9714e..77f1fe4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -107,7 +107,13 @@ jobs: with: fetch-depth: 0 + # global.json pins an exact SDK, so this runner installs it rather than + # using whatever Windows already has. A self-hosted runner is an ordinary + # user account that cannot write to C:\Program Files\dotnet, so the + # install goes to the runner's own tool cache, which also survives runs. - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + env: + DOTNET_INSTALL_DIR: ${{ runner.tool_cache }}\dotnet with: global-json-file: global.json From 78e9a1cef95b2a019fe154e0e58ae9dc04ee1d57 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 22 Aug 2026 13:58:45 -0500 Subject: [PATCH 3/9] Psmux(fix[pin]): Move the build markers to the v3.3.8 commit why: The re-pin changed the version, full commit, banner and hash but left the short-hash and date markers on aa26cd3 and 2026-08-17. Those markers are scanned in the binary itself, so LibTmux and the smoke harness would reject the very client the pin now names. Nothing local caught it: the marker scan only runs against a real psmux executable, which is Windows-only, so the first psmux gate run was the first time it executed. what: - Point the markers in PsmuxBinaryTrust and Invoke-PsmuxSmoke.ps1 at 66cf613 and 2026-08-18, which the published v3.3.8 client contains and the old one does not - Move the unit-test fixtures onto the same markers --- eng/psmux/Invoke-PsmuxSmoke.ps1 | 4 ++-- eng/psmux/tests/test_smoke_harness.py | 4 ++-- src/LibTmux/Internal/PsmuxBinaryTrust.cs | 4 ++-- tests/LibTmux.UnitTests/Connection/PsmuxConnectionTests.cs | 6 +++--- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/eng/psmux/Invoke-PsmuxSmoke.ps1 b/eng/psmux/Invoke-PsmuxSmoke.ps1 index e276f94..aa6e22e 100644 --- a/eng/psmux/Invoke-PsmuxSmoke.ps1 +++ b/eng/psmux/Invoke-PsmuxSmoke.ps1 @@ -595,8 +595,8 @@ try { $binaryText = [Text.Encoding]::ASCII.GetString( [IO.File]::ReadAllBytes($psmuxFile.FullName)) - if (-not $binaryText.Contains('aa26cd3') -or - -not $binaryText.Contains('2026-08-17')) { + if (-not $binaryText.Contains('66cf613') -or + -not $binaryText.Contains('2026-08-18')) { throw 'psmux does not contain the audited build markers.' } diff --git a/eng/psmux/tests/test_smoke_harness.py b/eng/psmux/tests/test_smoke_harness.py index 045426a..538986c 100644 --- a/eng/psmux/tests/test_smoke_harness.py +++ b/eng/psmux/tests/test_smoke_harness.py @@ -25,8 +25,8 @@ def test_first_psmux_launch_uses_verified_binary_and_isolated_data() -> None: assert script.index("Get-FileHash", 0, first_launch) >= 0 assert script.index("$ExpectedSha256 -ine $supportedSha256", 0, first_launch) >= 0 assert "54e5c54db259218348f966b5d0d0b5153fdef6350074855ea9ce627d20537b0d" in script - assert script.index("$binaryText.Contains('aa26cd3')", 0, first_launch) >= 0 - assert script.index("$binaryText.Contains('2026-08-17')", 0, first_launch) >= 0 + assert script.index("$binaryText.Contains('66cf613')", 0, first_launch) >= 0 + assert script.index("$binaryText.Contains('2026-08-18')", 0, first_launch) >= 0 assert script.index("$env:PSMUX_DATA_DIR = $DataDirectory", 0, first_launch) >= 0 assert script.index("$env:PSMUX_NO_WARM = '1'", 0, first_launch) >= 0 assert '"set -g warm off`n"' in script diff --git a/src/LibTmux/Internal/PsmuxBinaryTrust.cs b/src/LibTmux/Internal/PsmuxBinaryTrust.cs index bda8242..c3d57ef 100644 --- a/src/LibTmux/Internal/PsmuxBinaryTrust.cs +++ b/src/LibTmux/Internal/PsmuxBinaryTrust.cs @@ -45,8 +45,8 @@ private static async Task VerifyCoreAsync( BufferSize, FileOptions.Asynchronous | FileOptions.SequentialScan); using IncrementalHash hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); - var commit = new MarkerMatcher("aa26cd3"u8); - var date = new MarkerMatcher("2026-08-17"u8); + var commit = new MarkerMatcher("66cf613"u8); + var date = new MarkerMatcher("2026-08-18"u8); long total = 0; while (true) { diff --git a/tests/LibTmux.UnitTests/Connection/PsmuxConnectionTests.cs b/tests/LibTmux.UnitTests/Connection/PsmuxConnectionTests.cs index 2e48f6c..7bc6ca5 100644 --- a/tests/LibTmux.UnitTests/Connection/PsmuxConnectionTests.cs +++ b/tests/LibTmux.UnitTests/Connection/PsmuxConnectionTests.cs @@ -153,8 +153,8 @@ public async Task Binary_trust_streams_hash_and_markers_across_buffer_boundaries $"libtmux-streamed-psmux-{Guid.NewGuid():N}.exe"); byte[] contents = new byte[82032]; Array.Fill(contents, (byte)'x'); - "aa26cd3"u8.CopyTo(contents.AsSpan(81917)); - "2026-08-17"u8.CopyTo(contents.AsSpan(82000)); + "66cf613"u8.CopyTo(contents.AsSpan(81917)); + "2026-08-18"u8.CopyTo(contents.AsSpan(82000)); await File.WriteAllBytesAsync(binary, contents, TestContext.Current.CancellationToken); string hash = Convert.ToHexString( System.Security.Cryptography.SHA256.HashData(contents)); @@ -177,7 +177,7 @@ public async Task Binary_trust_does_not_capture_the_callers_synchronization_cont string binary = Path.Combine( Path.GetTempPath(), $"libtmux-context-psmux-{Guid.NewGuid():N}.exe"); - byte[] contents = "aa26cd3 2026-08-17"u8.ToArray(); + byte[] contents = "66cf613 2026-08-18"u8.ToArray(); await File.WriteAllBytesAsync(binary, contents, TestContext.Current.CancellationToken); string hash = Convert.ToHexString( System.Security.Cryptography.SHA256.HashData(contents)); From d968ac6da7ebf999f6064d6fb412c5711c9e51b1 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 22 Aug 2026 14:02:44 -0500 Subject: [PATCH 4/9] Ci(fix[psmux]): Give the cold NuGet restore room to finish why: The gate restores into a throwaway NuGet cache so the build proves it needs nothing already on the machine, which verify_workflows enforces by requiring the isolation before the solution restore. On a self-hosted Windows runner that cold restore is far slower than the hosted equivalent and ran past the 45 minute limit while still making progress. what: - Raise the psmux job timeout to 90 minutes and say why the restore is cold --- .github/workflows/release.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 77f1fe4..37e7a43 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -101,7 +101,10 @@ jobs: name: psmux native Windows and WSL needs: [validate, psmux-metadata] runs-on: [self-hosted, Windows, X64, psmux] - timeout-minutes: 45 + # The gate restores into a throwaway NuGet cache on purpose, so every run + # pays a full cold restore. That is the point of the isolation, and on a + # self-hosted Windows machine it is slower than the hosted equivalent. + timeout-minutes: 90 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: From 21e2b446979a74c9cc8aef2faa9408f5fef496bf Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 22 Aug 2026 14:11:48 -0500 Subject: [PATCH 5/9] Psmux(fix[smoke]): Hand WSL its scripts with Unix line endings why: The harness passes three shell scripts to /bin/sh inside WSL. git checks this file out with CRLF wherever core.autocrlf is on, which is the default on Windows, so the first line arrived as 'case ... in' followed by a carriage return and dash rejected it with 'word unexpected (expecting in)'. The gate failed on the checkout's line endings rather than on anything the harness or psmux did. what: - Normalise the three WSL scripts to LF where they are defined, so they are correct by construction rather than by how the checkout landed --- eng/psmux/Invoke-PsmuxSmoke.ps1 | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/eng/psmux/Invoke-PsmuxSmoke.ps1 b/eng/psmux/Invoke-PsmuxSmoke.ps1 index aa6e22e..19f594f 100644 --- a/eng/psmux/Invoke-PsmuxSmoke.ps1 +++ b/eng/psmux/Invoke-PsmuxSmoke.ps1 @@ -113,6 +113,14 @@ elif [ "$requirement" != path ]; then fi printf '%s\n' "$candidate" '@ + +# git checks this file out with CRLF wherever core.autocrlf is on, and the +# scripts above are handed to /bin/sh inside WSL. A trailing carriage return +# turns the first line into "expecting in", so these are LF by construction +# rather than by however the checkout happened to land. +$wslTimeoutScript = $wslTimeoutScript -replace "`r`n", "`n" +$wslDotnetValidationScript = $wslDotnetValidationScript -replace "`r`n", "`n" +$wslPathResolutionScript = $wslPathResolutionScript -replace "`r`n", "`n" if ($ExpectedSha256 -ine $supportedSha256) { throw 'ExpectedSha256 must match the exact audited psmux client build.' } From 1b6ecba72c2962027fc7158405087f89516f5e1a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 22 Aug 2026 14:20:37 -0500 Subject: [PATCH 6/9] Ci(fix[psmux]): Install the net8.0 runtime the smoke runs on why: The smoke exercises both target frameworks, but global.json pins only the 10.x SDK, so an isolated install directory holds no .NET 8 runtime and the native Windows lane died with framework 'Microsoft.NETCore.App' version '8.0.0' not found. A hosted image hides this by shipping .NET 8 already. what: - Install the 8.0 runtime into the same directory, so the job carries both frameworks it runs rather than inheriting one from the machine --- .github/workflows/release.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 37e7a43..1088202 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -120,6 +120,16 @@ jobs: with: global-json-file: global.json + # The smoke runs the net8.0 assemblies as well, and global.json only + # brings the pinned 10.x SDK and its runtime. A hosted image happens to + # carry .NET 8 already; an isolated install directory does not, so the + # job installs it rather than depending on what the machine has. + - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + env: + DOTNET_INSTALL_DIR: ${{ runner.tool_cache }}\dotnet + with: + dotnet-version: '8.0' + - name: Download the audited psmux release shell: pwsh env: From ea7981c5654cb09bed5319f3fd4135b1a086e366 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 22 Aug 2026 14:34:38 -0500 Subject: [PATCH 7/9] Psmux(fix[smoke]): Assert the pinned version rather than a copy of it why: The smoke asserted the literal 3.3.7 while the pin had moved to 3.3.8, so the gate failed against the client it is supposed to accept. This is the third copy of the pin the re-pin missed, and each one was invisible until a real psmux ran: this class only executes against a live client, so no Linux lane ever reached the assertion. what: - Assert PsmuxCompatibility.SupportedVersion, so the check moves with the pin instead of being another value to remember --- tests/LibTmux.UnitTests/Connection/PsmuxProcessSmokeTests.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/LibTmux.UnitTests/Connection/PsmuxProcessSmokeTests.cs b/tests/LibTmux.UnitTests/Connection/PsmuxProcessSmokeTests.cs index 4931595..8d248c0 100644 --- a/tests/LibTmux.UnitTests/Connection/PsmuxProcessSmokeTests.cs +++ b/tests/LibTmux.UnitTests/Connection/PsmuxProcessSmokeTests.cs @@ -36,7 +36,9 @@ public async Task Connect_and_typed_queries_use_audited_psmux() socketName), cancellationToken); - Assert.Equal(TmuxVersion.Parse("3.3.7"), server.Version); + Assert.Equal( + TmuxVersion.Parse(LibTmux.Internal.PsmuxCompatibility.SupportedVersion), + server.Version); PsmuxServer refreshed = await server.RefreshAsync(cancellationToken); Assert.Equal(server.Version, refreshed.Version); From 358182d4b59972655198cc4c33863af22de8dd51 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 22 Aug 2026 14:34:39 -0500 Subject: [PATCH 8/9] Psmux(feat[smoke]): Report the assertion a failing leg printed why: A failed leg said only 'Native Windows .NET smoke exited 1.' A test runner writes the failing assertion to stdout and leaves stderr empty, and the harness kept stdout in a buffer it never showed, so the one place this gate runs is the one place its output was discarded. Diagnosing it needed a patched copy of the harness. what: - Fall back to a bounded tail of stdout when stderr carries no detail - Pass the captured output at every leg that already passed its error --- eng/psmux/Invoke-PsmuxSmoke.ps1 | 52 +++++++++++++++++++++++---------- 1 file changed, 36 insertions(+), 16 deletions(-) diff --git a/eng/psmux/Invoke-PsmuxSmoke.ps1 b/eng/psmux/Invoke-PsmuxSmoke.ps1 index 19f594f..62ed2c2 100644 --- a/eng/psmux/Invoke-PsmuxSmoke.ps1 +++ b/eng/psmux/Invoke-PsmuxSmoke.ps1 @@ -206,13 +206,21 @@ function Get-BoundedNativeErrorDetail([string] $ErrorText) { function Get-NativeExitMessage( [string] $Leg, [int] $ExitCode, - [string] $ErrorText) { + [string] $ErrorText, + [string] $OutputText = '') { $message = "$Leg exited $ExitCode." $detail = Get-BoundedNativeErrorDetail $ErrorText - if (-not $detail) { - return $message + if ($detail) { + return "$message stderr: $detail" + } + # A test runner reports the assertion that failed on stdout and leaves + # stderr empty, so an exit code on its own tells whoever reads the gate + # nothing about which check broke. + $detail = Get-BoundedNativeErrorDetail $OutputText + if ($detail) { + return "$message stdout: $detail" } - return "$message stderr: $detail" + return $message } function Assert-QueryProgram( @@ -344,7 +352,8 @@ function Invoke-CapturedNative( throw (Get-NativeExitMessage ` "$Leg process-tree termination" ` $treeKill.ExitCode ` - $treeKill.Error) + $treeKill.Error ` + $treeKill.Output) } } else { @@ -444,7 +453,8 @@ function Convert-ToWslPath( throw (Get-NativeExitMessage ` "WSL $Kind path $operation" ` $translation.ExitCode ` - $translation.Error) + $translation.Error ` + $translation.Output) } if ($translation.Output.Count -ne 1 -or [string]::IsNullOrWhiteSpace($translation.Output[0])) { @@ -644,7 +654,8 @@ try { throw (Get-NativeExitMessage ` 'WSL .NET executable validation' ` $wslDotnetResult.ExitCode ` - $wslDotnetResult.Error) + $wslDotnetResult.Error ` + $wslDotnetResult.Output) } if ($wslDotnetResult.Output.Count -ne 1 -or [string]::IsNullOrWhiteSpace($wslDotnetResult.Output[0])) { @@ -670,7 +681,8 @@ try { throw (Get-NativeExitMessage ` 'psmux version query' ` $bannerResult.ExitCode ` - $bannerResult.Error) + $bannerResult.Error ` + $bannerResult.Output) } if ([string]::Join("`n", $banner) -cne [string]::Join("`n", $expectedBanner)) { @@ -695,7 +707,8 @@ try { throw (Get-NativeExitMessage ` 'psmux isolated namespace inspection' ` $existingResult.ExitCode ` - $existingResult.Error) + $existingResult.Error ` + $existingResult.Output) } if ($existing.Count -ne 0) { throw "The isolated namespace is not empty: $([string]::Join(', ', $existing))" @@ -751,7 +764,8 @@ try { throw (Get-NativeExitMessage ` 'psmux new-session after the creation attempt' ` $creationExitCode ` - $creationResult.Error) + $creationResult.Error ` + $creationResult.Output) } if (-not $createdSessionId) { throw 'psmux created no session with an exact verifiable identity.' @@ -772,7 +786,8 @@ try { throw (Get-NativeExitMessage ` 'psmux fixture input' ` $sendResult.ExitCode ` - $sendResult.Error) + $sendResult.Error ` + $sendResult.Output) } $ready = $false @@ -792,7 +807,8 @@ try { throw (Get-NativeExitMessage ` 'psmux pane capture' ` $captureResult.ExitCode ` - $captureResult.Error) + $captureResult.Error ` + $captureResult.Output) } $ready = $capture.Where({ $_ -clike "*$expectedText*" }).Count -gt 0 if (-not $ready) { @@ -828,7 +844,8 @@ try { throw (Get-NativeExitMessage ` 'Native Windows .NET smoke' ` $exitCode ` - $nativeTestResult.Error) + $nativeTestResult.Error ` + $nativeTestResult.Output) } Assert-OnePassingTest $nativeResultPath 'Native Windows .NET' $nativeExampleResult = Invoke-CapturedNative ` @@ -903,7 +920,8 @@ try { throw (Get-NativeExitMessage ` 'WSL .NET smoke' ` $exitCode ` - $wslTestResult.Error) + $wslTestResult.Error ` + $wslTestResult.Output) } Assert-OnePassingTest $wslResultPath 'WSL .NET' $wslExampleResult = Invoke-CapturedNative ` @@ -992,7 +1010,8 @@ finally { throw (Get-NativeExitMessage ` 'psmux cleanup identity query' ` $currentIdentityResult.ExitCode ` - $currentIdentityResult.Error) + $currentIdentityResult.Error ` + $currentIdentityResult.Output) } } elseif ($currentIdentity.Count -ne 1) { @@ -1014,7 +1033,8 @@ finally { throw (Get-NativeExitMessage ` 'psmux exact session cleanup' ` $killResult.ExitCode ` - $killResult.Error) + $killResult.Error ` + $killResult.Output) } for ($attempt = 0; $attempt -lt 50 -and From 13bd82c4b57ca344a3e83a4b627e63c40e5f0629 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 22 Aug 2026 14:43:39 -0500 Subject: [PATCH 9/9] Docs(fix[contributing]): Name the runner's real prerequisites why: Standing the runner up failed three times on machine setup rather than on psmux, and each was something a hosted runner supplies: PowerShell 7, a writable place for the pinned SDK, and the .NET 8 runtime the smoke also runs on. A reader provisioning a machine should not have to rediscover any of them from a red gate. what: - Require pwsh explicitly and give the install command - Say a Windows dotnet is not needed, because the job installs the pinned SDK into its own tool cache - Say the listener reads its environment once, so it needs a restart after a PATH change --- .github/CONTRIBUTING.md | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 8dc549c..8643668 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -395,11 +395,23 @@ runtime evidence. #### Provisioning the runner -The machine needs Windows x64, a Windows `dotnet` and `git`, and a WSL +The machine needs Windows x64, `git`, PowerShell 7 (`pwsh`), and a WSL distribution holding a checkout with its own Linux `dotnet`. The psmux job drives both sides from one PowerShell process, so a runner without WSL fails the gate rather than skipping it. +`pwsh` is the one a hosted runner would have supplied. Windows PowerShell 5.1 +is not it, and the job's steps ask for `pwsh` by name: + +```console +$ winget install --id Microsoft.PowerShell --silent +``` + +A Windows `dotnet` is not a prerequisite. `global.json` pins an exact SDK, so +the job installs that version into the runner's tool cache regardless of what +the machine already has. Restart the listener after installing anything it +needs to find on `PATH`; it reads the environment once, at start. + Register it with the `psmux` label; `self-hosted`, `Windows` and `X64` are added for you, and `runs-on` matches on all four: