From 754b8eca04ef8a8302bf6cc6e7c656be958b9548 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Jare=C5=A1?= Date: Sat, 5 Sep 2026 14:00:02 +0200 Subject: [PATCH 1/2] Stop a child process's exit code leaking into the test harness Invoke-InNewProcess calls the child as a native command, which overwrites $LASTEXITCODE in the parent, and nothing put it back. Several tests here run a child that exits non-zero on purpose, and they read that code from the child's own output, not from ours. So test.ps1 could end with a non-zero $LASTEXITCODE while every test passed. CI ends the step with `exit $LASTEXITCODE`, which turns a fully green run red with no failing test in it. Which test ran last decided whether it happened, which is what would make it intermittent. Measured against main: a child that exits 3 left the caller on 3, now it stays 0. The helper restores the caller's value in a finally, and test.ps1 sets the exit code to 0 once it has decided the run passed. That second one is the safety net for any native command added to the suite later. Both sit after the existing `exit 1` and `throw "Run failed!"` paths, so neither can hide a real failure. The bug was one-directional: green runs going red, never red runs going green. Added a test that fails against main. --- test.ps1 | 6 ++++++ tst/PTestHelpers.psm1 | 14 +++++++++++++- tst/Pester.RSpec.InNewProcess.ts.ps1 | 10 ++++++++++ 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/test.ps1 b/test.ps1 index 6b110b383..9502ac664 100644 --- a/test.ps1 +++ b/test.ps1 @@ -285,3 +285,9 @@ if ($CC) { if ("Failed" -eq $r.Result) { throw "Run failed!" } + +# Every test passed, so say so with the exit code as well. A native command run anywhere +# in the suite leaves its exit code in $LASTEXITCODE, and CI ends the step with +# `exit $LASTEXITCODE`. Without this a child process that exited non-zero on purpose, +# which several tests do, turns a fully green run red with no failing test to look at. +$global:LASTEXITCODE = 0 diff --git a/tst/PTestHelpers.psm1 b/tst/PTestHelpers.psm1 index 405db6d5f..897b3ec2e 100644 --- a/tst/PTestHelpers.psm1 +++ b/tst/PTestHelpers.psm1 @@ -77,7 +77,19 @@ $cmd = "& { $command } -PesterPath ""$PesterPath"" -ScriptBlock { $($ScriptBlock -replace '"','\"') }" } - & $powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command $cmd + # Keep the caller's $LASTEXITCODE. Calling a native command overwrites it, and several + # tests here run a child that exits non-zero on purpose. They read that code from the + # child's own output, nobody reads it from ours. Leaving it set means test.ps1 can end + # with a non-zero $LASTEXITCODE while every test passed, and the CI step does + # `exit $LASTEXITCODE`, so the run goes red with nothing to look at. Which test runs + # last decides whether it happens, which is what made it intermittent. + $lastExitCodeBeforeChild = $global:LASTEXITCODE + try { + & $powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command $cmd + } + finally { + $global:LASTEXITCODE = $lastExitCodeBeforeChild + } } function Verify-PathEqual { diff --git a/tst/Pester.RSpec.InNewProcess.ts.ps1 b/tst/Pester.RSpec.InNewProcess.ts.ps1 index 364b82ec9..5b422da3b 100644 --- a/tst/Pester.RSpec.InNewProcess.ts.ps1 +++ b/tst/Pester.RSpec.InNewProcess.ts.ps1 @@ -273,6 +273,16 @@ i -PassThru:$PassThru { b "Exit codes" { + t "Invoke-InNewProcess does not leak the child's exit code to the caller" { + # A child that exits non-zero used to overwrite $LASTEXITCODE in this process and + # nothing put it back. When such a test happened to run last, test.ps1 ended with a + # non-zero $LASTEXITCODE while every test passed, and CI's `exit $LASTEXITCODE` + # turned a green run red with no failing test in it. + $global:LASTEXITCODE = 0 + $null = Invoke-InNewProcess -ScriptBlock { exit 3 } + $LASTEXITCODE | Verify-Equal 0 + } + t "Exitcode is -1 when the test path is invalid" { $temp = [IO.Path]::GetTempPath() $testpath = Join-Path $temp "$([Guid]::NewGuid().Guid).txt" From 9f6559449aa424330105d5912b63d56ef4e0754b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Jare=C5=A1?= Date: Sat, 5 Sep 2026 16:53:29 +0200 Subject: [PATCH 2/2] Let the child's exit code through again, only test.ps1 resets it Invoke-InNewProcess propagating the child's exit code is intended and tested: "Exitcode is set to the number of failed tests and the process exits when tests fail" asserts $LASTEXITCODE is 1 in the caller after the child ran, and two more tests assert it is 0. Restoring it in the helper broke that contract, and CI caught it on every leg. So the helper goes back to what it was, and the fix is only the reset at the end of test.ps1, after both failure paths. Whichever test ran last still decides what $LASTEXITCODE holds when the harness finishes, and this stops that leaking into CI's `exit $LASTEXITCODE`. Also drops the test I added, it asserted the opposite of the contract above. --- test.ps1 | 14 ++++++++++---- tst/PTestHelpers.psm1 | 14 +------------- tst/Pester.RSpec.InNewProcess.ts.ps1 | 10 ---------- 3 files changed, 11 insertions(+), 27 deletions(-) diff --git a/test.ps1 b/test.ps1 index 9502ac664..3a0b2c809 100644 --- a/test.ps1 +++ b/test.ps1 @@ -286,8 +286,14 @@ if ("Failed" -eq $r.Result) { throw "Run failed!" } -# Every test passed, so say so with the exit code as well. A native command run anywhere -# in the suite leaves its exit code in $LASTEXITCODE, and CI ends the step with -# `exit $LASTEXITCODE`. Without this a child process that exited non-zero on purpose, -# which several tests do, turns a fully green run red with no failing test to look at. +# Every test passed, so say so with the exit code as well. +# +# Invoke-InNewProcess deliberately lets the child's exit code through, and tests assert on +# it (see "Exitcode is set to the number of failed tests..."), so the helper cannot reset it. +# That means whichever test ran last decides what $LASTEXITCODE holds when we get here, and +# CI ends the step with `exit $LASTEXITCODE`. Without this line a child that exited non-zero +# on purpose turns a fully green run red with no failing test to look at. +# +# This sits after both failure paths above, `exit 1` for P tests and the throw for a failed +# run, so it cannot hide a real failure. $global:LASTEXITCODE = 0 diff --git a/tst/PTestHelpers.psm1 b/tst/PTestHelpers.psm1 index 897b3ec2e..405db6d5f 100644 --- a/tst/PTestHelpers.psm1 +++ b/tst/PTestHelpers.psm1 @@ -77,19 +77,7 @@ $cmd = "& { $command } -PesterPath ""$PesterPath"" -ScriptBlock { $($ScriptBlock -replace '"','\"') }" } - # Keep the caller's $LASTEXITCODE. Calling a native command overwrites it, and several - # tests here run a child that exits non-zero on purpose. They read that code from the - # child's own output, nobody reads it from ours. Leaving it set means test.ps1 can end - # with a non-zero $LASTEXITCODE while every test passed, and the CI step does - # `exit $LASTEXITCODE`, so the run goes red with nothing to look at. Which test runs - # last decides whether it happens, which is what made it intermittent. - $lastExitCodeBeforeChild = $global:LASTEXITCODE - try { - & $powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command $cmd - } - finally { - $global:LASTEXITCODE = $lastExitCodeBeforeChild - } + & $powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command $cmd } function Verify-PathEqual { diff --git a/tst/Pester.RSpec.InNewProcess.ts.ps1 b/tst/Pester.RSpec.InNewProcess.ts.ps1 index 5b422da3b..364b82ec9 100644 --- a/tst/Pester.RSpec.InNewProcess.ts.ps1 +++ b/tst/Pester.RSpec.InNewProcess.ts.ps1 @@ -273,16 +273,6 @@ i -PassThru:$PassThru { b "Exit codes" { - t "Invoke-InNewProcess does not leak the child's exit code to the caller" { - # A child that exits non-zero used to overwrite $LASTEXITCODE in this process and - # nothing put it back. When such a test happened to run last, test.ps1 ended with a - # non-zero $LASTEXITCODE while every test passed, and CI's `exit $LASTEXITCODE` - # turned a green run red with no failing test in it. - $global:LASTEXITCODE = 0 - $null = Invoke-InNewProcess -ScriptBlock { exit 3 } - $LASTEXITCODE | Verify-Equal 0 - } - t "Exitcode is -1 when the test path is invalid" { $temp = [IO.Path]::GetTempPath() $testpath = Join-Path $temp "$([Guid]::NewGuid().Guid).txt"