Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
148 changes: 143 additions & 5 deletions tools/test_fast_evidence_capture.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -179,6 +181,109 @@ 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
}

# 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 {
# 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"
}

# 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
Expand Down Expand Up @@ -1425,7 +1530,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'
Expand All @@ -1442,7 +1557,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) {
# 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"
}
$Missing = Join-Path $Root 'absent-capture'
Expand Down Expand Up @@ -1818,6 +1938,11 @@ 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
Test-ProcessSurvivedCaptureChildRegression

$OriginalHostIsWindows = $script:HumHostIsWindows
try {
Assert-True ($OriginalHostIsWindows -eq ([Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT)) 'descendant validator platform differs from the native host'
Expand Down Expand Up @@ -1846,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 {
Expand Down Expand Up @@ -2165,10 +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)) {
Assert-True ($null -eq (Get-Process -Id $CreatedPid -ErrorAction SilentlyContinue)) "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 }
Expand Down
Loading