From b9d50330a9c9d44f4bc5afb1bbada0649ae30451 Mon Sep 17 00:00:00 2001 From: Ocean Bennett <204957658+undergroundrap@users.noreply.github.com> Date: Wed, 23 Sep 2026 06:06:50 +0000 Subject: [PATCH 1/2] fix: prevent PID-reuse flake in capture survival checks --- tools/test_fast_evidence_capture.ps1 | 76 ++++++++++++++++++++++++++-- 1 file changed, 72 insertions(+), 4 deletions(-) diff --git a/tools/test_fast_evidence_capture.ps1 b/tools/test_fast_evidence_capture.ps1 index ddf6f16..5ca84d0 100644 --- a/tools/test_fast_evidence_capture.ps1 +++ b/tools/test_fast_evidence_capture.ps1 @@ -149,7 +149,9 @@ if ($SyntheticChild -ne '') { $Process.StartInfo.UseShellExecute = $false $Process.StartInfo.CreateNoWindow = $true if (-not $Process.Start()) { throw 'descendant did not launch' } - Write-ExactAscii $Stdout "parent_alive=$PID`ndescendant_pid=$($Process.Id)`nparent_partial_stdout`n" + $ParentStartTime = (Get-Process -Id $PID).StartTime.ToString('o') + $DescendantStartTime = $Process.StartTime.ToString('o') + Write-ExactAscii $Stdout "parent_alive=$PID`nparent_start=$ParentStartTime`ndescendant_pid=$($Process.Id)`ndescendant_start=$DescendantStartTime`nparent_partial_stdout`n" Write-ExactAscii $Stderr "parent_partial_stderr`n" $Process.WaitForExit() exit $Process.ExitCode @@ -179,6 +181,50 @@ function Assert-Bytes { } } +# PID-reuse flake fix: A process "survives" only if a process with the given +# PID exists AND its start time matches the expected start time. If the PID +# exists but the start time differs, the PID was reused by a different process +# after the original exited — the original did NOT survive. +function Test-ProcessSurvived { + param( + [int] $ProcessId, + [Nullable[DateTime]] $ExpectedStartTime = $null + ) + $Proc = Get-Process -Id $ProcessId -ErrorAction SilentlyContinue + if ($null -eq $Proc) { return $false } + if ($null -eq $ExpectedStartTime) { + # No start time recorded; fall back to PID-only check (legacy behavior). + # Callers should record start times to avoid PID-reuse flakes. + return $true + } + # Compare start times with tick precision. A reused PID will have a + # different (newer) start time. + return $Proc.StartTime -eq $ExpectedStartTime +} + +# Regression test for PID-reuse flake: same PID with different start time +# must NOT be reported as survived. +function Test-ProcessSurvivedPidReuseRegression { + # Non-existent PID never survives. + $FakePid = 999999 + Assert-True (-not (Test-ProcessSurvived -ProcessId $FakePid)) 'non-existent PID reported as survived' + Assert-True (-not (Test-ProcessSurvived -ProcessId $FakePid -ExpectedStartTime ([DateTime]::UtcNow))) 'non-existent PID with start time reported as survived' + + # Current process with correct start time survives. + $Self = Get-Process -Id $PID + Assert-True (Test-ProcessSurvived -ProcessId $PID -ExpectedStartTime $Self.StartTime) 'current process with matching start time not reported as survived' + + # Current process with WRONG start time must NOT survive (simulates PID reuse: + # the original exited, a new process got the same PID with a different start). + $WrongStart = $Self.StartTime.AddHours(1) + Assert-True (-not (Test-ProcessSurvived -ProcessId $PID -ExpectedStartTime $WrongStart)) 'PID reuse (different start time) incorrectly reported as survived' + + # Legacy PID-only check (no start time) still reports existing PID as survived. + Assert-True (Test-ProcessSurvived -ProcessId $PID) 'legacy PID-only check failed for existing process' + + Write-Output "ok - Test-ProcessSurvived PID-reuse regression" +} + function Assert-Throws { param([scriptblock] $Action, [string] $Message) $Rejected = $false @@ -1425,7 +1471,17 @@ function Assert-PreflightDiagnosticEvidence { foreach ($Witness in @('parent_alive', 'descendant_pid')) { $Match = [regex]::Match($Text, "$Witness=([0-9]+)") Assert-True $Match.Success "timeout $Witness witness missing" - Assert-True ($null -eq (Get-Process -Id ([int]$Match.Groups[1].Value) -ErrorAction SilentlyContinue)) "timeout $Witness survived" + $WitnessPid = [int]$Match.Groups[1].Value + # PID-reuse flake fix: also parse the recorded start time. A process + # survives only if PID AND start time both match; a reused PID with + # a different start time means the original exited. + $StartField = if ($Witness -eq 'parent_alive') { 'parent_start' } else { 'descendant_start' } + $StartMatch = [regex]::Match($Text, "$StartField=(.+)") + $ExpectedStart = $null + if ($StartMatch.Success) { + $ExpectedStart = [DateTime]::Parse($StartMatch.Groups[1].Value.Trim()) + } + Assert-True (-not (Test-ProcessSurvived -ProcessId $WitnessPid -ExpectedStartTime $ExpectedStart)) "timeout $Witness survived" } $Wrong = $Result.PSObject.Copy(); $Wrong.ContainmentKind = 'other' Assert-Rejected { Assert-TimeoutBackendRecord $Wrong } 'backend substitution accepted' @@ -1442,7 +1498,12 @@ function Assert-PreflightDiagnosticEvidence { } if ($null -ne $Failure) { Assert-True ([IO.File]::ReadAllText((Join-Path $Diagnostics 'failure.txt')).Contains($Failure.Exception.Message)) 'original failure lost during retention' } } - if ($null -ne $Result -and $null -ne $Result.Pid) { Assert-True ($null -eq (Get-Process -Id $Result.Pid -ErrorAction SilentlyContinue)) 'diagnostic child survived' } + if ($null -ne $Result -and $null -ne $Result.Pid) { + # Note: No start time recorded for this PID, so this is a PID-only check. + # The timeout-witness check above uses start-time verification to avoid + # PID-reuse flakes; this path retains legacy behavior. + Assert-True (-not (Test-ProcessSurvived -ProcessId $Result.Pid)) 'diagnostic child survived' + } Write-Output "ok - preflight diagnostic $Mode" } $Missing = Join-Path $Root 'absent-capture' @@ -1818,6 +1879,10 @@ Assert-ContainmentWeakeningRejected 'SingleAbsoluteDeadline' $false Assert-ContainmentWeakeningRejected 'TerminationCount' 2 Assert-ContainmentWeakeningRejected 'DescendantAbsence' $false Assert-ContainmentWeakeningRejected 'PersistedFacts' $false +# PID-reuse flake regression: verify the start-time-aware survival check +# before running the main test suite. +Test-ProcessSurvivedPidReuseRegression + $OriginalHostIsWindows = $script:HumHostIsWindows try { Assert-True ($OriginalHostIsWindows -eq ([Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT)) 'descendant validator platform differs from the native host' @@ -2168,7 +2233,10 @@ try { if ($null -ne $Capture.Pid) { $CreatedPids.Add([int] $Capture.Pid) } } foreach ($CreatedPid in @($CreatedPids | Sort-Object -Unique)) { - Assert-True ($null -eq (Get-Process -Id $CreatedPid -ErrorAction SilentlyContinue)) "test-created process survived: $CreatedPid" + # Note: No start times recorded for these PIDs (they come from capture + # records), so this retains PID-only checking. The timeout-witness path + # above uses start-time verification where the flake was observed. + Assert-True (-not (Test-ProcessSurvived -ProcessId $CreatedPid)) "test-created process survived: $CreatedPid" } } finally { if (Test-Path -LiteralPath $ScratchRoot) { Remove-Item -LiteralPath $ScratchRoot -Recurse -Force } From d8daf65f54bd00dce26afb3e0ad53ba79900053a Mon Sep 17 00:00:00 2001 From: Ocean Bennett <204957658+undergroundrap@users.noreply.github.com> Date: Wed, 23 Sep 2026 06:14:04 +0000 Subject: [PATCH 2/2] fix: use capture completion time for PID-reuse detection The observed failure was 'test-created process survived: 1364' at the $CreatedPids loop, not the timeout-witness path. Each capture already records completed_utc.txt; a process with a recorded PID whose StartTime is later than the capture's completion can't be the original child. - Test-ProcessSurvivedCaptureChild: survives only if PID exists AND StartTime <= capture completed_utc - $CreatedPids loop now tracks PID -> CaptureDirectory and uses the completion-aware check - Diagnostic-child check uses the same completion-aware logic - Add Test-ProcessSurvivedCaptureChildRegression covering: start before completion (survives), start after completion (PID reuse, not survived), non-existent PID, missing completion record --- tools/test_fast_evidence_capture.ps1 | 88 +++++++++++++++++++++++++--- 1 file changed, 79 insertions(+), 9 deletions(-) diff --git a/tools/test_fast_evidence_capture.ps1 b/tools/test_fast_evidence_capture.ps1 index 5ca84d0..f69666f 100644 --- a/tools/test_fast_evidence_capture.ps1 +++ b/tools/test_fast_evidence_capture.ps1 @@ -202,6 +202,33 @@ function Test-ProcessSurvived { return $Proc.StartTime -eq $ExpectedStartTime } +# PID-reuse flake fix for capture children: a process "survives" only if a +# process with the given PID exists AND its StartTime is not later than the +# capture's recorded completion time. The original child must have started +# before its capture completed; a process with StartTime later than the +# completion is a PID reuse, not the original. +function Test-ProcessSurvivedCaptureChild { + param( + [int] $ProcessId, + [string] $CaptureDirectory + ) + $Proc = Get-Process -Id $ProcessId -ErrorAction SilentlyContinue + if ($null -eq $Proc) { return $false } + $CompletedPath = Join-Path $CaptureDirectory 'completed_utc.txt' + if (-not [IO.File]::Exists($CompletedPath)) { + # No completion record; fall back to PID-only check. + return $true + } + $Completed = [DateTime]::ParseExact( + [IO.File]::ReadAllText($CompletedPath).Trim(), + 'o', + [Globalization.CultureInfo]::InvariantCulture, + [Globalization.DateTimeStyles]::RoundtripKind) + # The original child started before the capture completed. If this process + # started after the completion, it's a different process that reused the PID. + return $Proc.StartTime.ToUniversalTime() -le $Completed.ToUniversalTime() +} + # Regression test for PID-reuse flake: same PID with different start time # must NOT be reported as survived. function Test-ProcessSurvivedPidReuseRegression { @@ -225,6 +252,38 @@ function Test-ProcessSurvivedPidReuseRegression { Write-Output "ok - Test-ProcessSurvived PID-reuse regression" } +# Regression test for the capture-child PID-reuse path: a process with a +# recorded PID whose StartTime is later than the capture's completed_utc +# must NOT be reported as survived. +function Test-ProcessSurvivedCaptureChildRegression { + $TempDir = Join-Path ([IO.Path]::GetTempPath()) ("pidreusetest_" + [Guid]::NewGuid().ToString("N")) + $null = [IO.Directory]::CreateDirectory($TempDir) + try { + $Self = Get-Process -Id $PID + $SelfStartUtc = $Self.StartTime.ToUniversalTime() + + # Case 1: completed_utc AFTER the process start -> could be the original. + $AfterPath = Join-Path $TempDir 'completed_utc.txt' + [IO.File]::WriteAllText($AfterPath, $SelfStartUtc.AddMinutes(5).ToString('o', [Globalization.CultureInfo]::InvariantCulture)) + Assert-True (Test-ProcessSurvivedCaptureChild -ProcessId $PID -CaptureDirectory $TempDir) 'process started before completion not reported as survived' + + # Case 2: completed_utc BEFORE the process start -> PID reuse, must NOT survive. + [IO.File]::WriteAllText($AfterPath, $SelfStartUtc.AddMinutes(-5).ToString('o', [Globalization.CultureInfo]::InvariantCulture)) + Assert-True (-not (Test-ProcessSurvivedCaptureChild -ProcessId $PID -CaptureDirectory $TempDir)) 'PID reuse (start after completion) incorrectly reported as survived' + + # Case 3: non-existent PID never survives. + Assert-True (-not (Test-ProcessSurvivedCaptureChild -ProcessId 999999 -CaptureDirectory $TempDir)) 'non-existent PID reported as survived' + + # Case 4: missing completed_utc.txt falls back to PID-only check. + Remove-Item -LiteralPath $AfterPath -Force + Assert-True (Test-ProcessSurvivedCaptureChild -ProcessId $PID -CaptureDirectory $TempDir) 'missing completion record failed for existing process' + + Write-Output "ok - Test-ProcessSurvivedCaptureChild PID-reuse regression" + } finally { + if (Test-Path -LiteralPath $TempDir) { Remove-Item -LiteralPath $TempDir -Recurse -Force } + } +} + function Assert-Throws { param([scriptblock] $Action, [string] $Message) $Rejected = $false @@ -1499,10 +1558,10 @@ function Assert-PreflightDiagnosticEvidence { if ($null -ne $Failure) { Assert-True ([IO.File]::ReadAllText((Join-Path $Diagnostics 'failure.txt')).Contains($Failure.Exception.Message)) 'original failure lost during retention' } } if ($null -ne $Result -and $null -ne $Result.Pid) { - # Note: No start time recorded for this PID, so this is a PID-only check. - # The timeout-witness check above uses start-time verification to avoid - # PID-reuse flakes; this path retains legacy behavior. - Assert-True (-not (Test-ProcessSurvived -ProcessId $Result.Pid)) 'diagnostic child survived' + # PID-reuse flake fix: a process with this PID whose StartTime is later + # than the capture's recorded completion can't be the original child. + $Survived = Test-ProcessSurvivedCaptureChild -ProcessId $Result.Pid -CaptureDirectory $Result.CaptureDirectory + Assert-True (-not $Survived) 'diagnostic child survived' } Write-Output "ok - preflight diagnostic $Mode" } @@ -1882,6 +1941,7 @@ Assert-ContainmentWeakeningRejected 'PersistedFacts' $false # PID-reuse flake regression: verify the start-time-aware survival check # before running the main test suite. Test-ProcessSurvivedPidReuseRegression +Test-ProcessSurvivedCaptureChildRegression $OriginalHostIsWindows = $script:HumHostIsWindows try { @@ -1911,6 +1971,10 @@ try { $script:HumHostIsWindows = $OriginalHostIsWindows } $CreatedPids = New-Object System.Collections.Generic.List[int] +# PID -> CaptureDirectory mapping for PID-reuse flake fix. A process with a +# recorded PID whose StartTime is later than the capture's completed_utc +# can't be the original child. +$CreatedPidCaptures = @{} $ValidCaptures = New-Object System.Collections.Generic.List[object] try { @@ -2230,13 +2294,19 @@ try { foreach ($Capture in @($Preflight, $Success, $Exit23, $Empty, $Missing, $Interleaved, $Unicode, $Early, $Duplicate, $Nonzero, $Timeout) + $WindowsCaptures + $SetupCaptures) { $null = Read-HumCaptureRecord $Capture.CaptureDirectory $ValidCaptures.Add($Capture) - if ($null -ne $Capture.Pid) { $CreatedPids.Add([int] $Capture.Pid) } + if ($null -ne $Capture.Pid) { + $PidInt = [int]$Capture.Pid + $CreatedPids.Add($PidInt) + # Track the capture directory for PID-reuse detection. If multiple + # captures share a PID (unlikely), keep the latest completion. + $CreatedPidCaptures[$PidInt] = $Capture.CaptureDirectory + } } foreach ($CreatedPid in @($CreatedPids | Sort-Object -Unique)) { - # Note: No start times recorded for these PIDs (they come from capture - # records), so this retains PID-only checking. The timeout-witness path - # above uses start-time verification where the flake was observed. - Assert-True (-not (Test-ProcessSurvived -ProcessId $CreatedPid)) "test-created process survived: $CreatedPid" + # PID-reuse flake fix: use the capture's completed_utc to distinguish + # the original child from a PID reuse. + $CaptureDir = $CreatedPidCaptures[$CreatedPid] + Assert-True (-not (Test-ProcessSurvivedCaptureChild -ProcessId $CreatedPid -CaptureDirectory $CaptureDir)) "test-created process survived: $CreatedPid" } } finally { if (Test-Path -LiteralPath $ScratchRoot) { Remove-Item -LiteralPath $ScratchRoot -Recurse -Force }