aa60b3e34e
v2-ci / unit-tests (push) Has been cancelled
Acceptance runs for #799 (owner scope 2026-11-12: Windows x64 + Linux) on current master. Fixture: since #808 an UNS folder the authority snapshot does not place is denied under enforcement (fail closed). The canonical soak creates its own "SoakEquipment" folder but never placed it, so on master the very first cycle's UNS monitored item came back bad (retained failed Linux launch onb61f3e05, cycle 1). The rig gains AdditionalFolderScopes (merged into FolderScopes by Enforce) and the soak places its folder there, as a deployed generation does - the same fixture gap #888 fixed for the SDK matrix. No server authorization code, cap, baseline or canonical operation changed; the soak test bodies are otherwise byte-identical tob18f9723. Windows port (test/harness only, opt-in): - DescriptorCountsAsync gains a Windows branch: total = every handle in the process handle table, PKI = disk file/directory handles whose GetFinalPathNameByHandle path lies under a canonicalised PKI root; dedicated thread under the same 5 s bound as the macOS lsof probe. - Descriptor controls enabled on Windows (symlink alias; timeout control via cmd.exe + ping). - LateSlopeRun.ValidateRequest accepts Linux ARM64 or Windows x64; duration/runtime/leak tracking unchanged. - late-slope/analyze.py: expected architecture is a module constant (Linux default Arm64 unchanged). - windows/: prepare.py (win-x64 self-contained 10.0.14 bundle + manifest), launch.ps1 (container.sh equivalent: bundle re-verification, 2/2 controls first, PID/apphost/coreclr verification, external sampler, 7440 s outer deadline), analyze.py (Linux row/window logic, X64), README with every adaptation. Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
158 lines
9.5 KiB
PowerShell
158 lines
9.5 KiB
PowerShell
<#
|
|
.SYNOPSIS
|
|
Windows counterpart of ../late-slope/container.sh + launch.py for the lmxopcua#799 acceptance soak.
|
|
|
|
.DESCRIPTION
|
|
Runs the immutable self-contained win-x64 bundle through the installed `dotnet vstest` controller, exactly as the
|
|
Linux container runs it: the two descriptor controls must pass first, then the single canonical test
|
|
Repeated_lifecycles_return_resources_to_a_steady_baseline in late-slope mode (7,200 s, unchanged cycle-3 caps,
|
|
first-rejection latch, all canonical operations, independent shutdown checks).
|
|
|
|
Windows adaptations (documented in README.md):
|
|
- No container. Isolation is a fresh private task directory (inheritance removed, current user + SYSTEM only)
|
|
that holds TEMP/TMP, the CLI home, results and fixture PKI. The machine is otherwise idle.
|
|
- The target process is verified by PID: its image must be the bundle apphost and coreclr.dll must be mapped from
|
|
the bundle with the receipt's SHA-256 (the /proc/<pid>/exe + maps check on Linux).
|
|
- An external read-only sampler records the target's WorkingSet64 / PrivateMemorySize64 / HandleCount every 30 s
|
|
(descriptive only; the in-process quiescent samples remain the gated values), plus MxAccessGw readiness.
|
|
- The 7,440 s outer deadline kills only the task-owned process tree.
|
|
#>
|
|
param(
|
|
[Parameter(Mandatory)] [string] $Bundle,
|
|
[Parameter(Mandatory)] [string] $Output,
|
|
[Parameter(Mandatory)] [string] $SourceRevision,
|
|
[ValidateSet('late-slope', 'diagnostic')] [string] $Mode = 'late-slope',
|
|
[int] $Seconds = 7200
|
|
)
|
|
$ErrorActionPreference = 'Stop'
|
|
Set-StrictMode -Version 3
|
|
$app = 'ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests'
|
|
$testClass = "$app.Sdk2LifecycleSoakTests"
|
|
$outerDeadline = 7440
|
|
if ($SourceRevision -notmatch '^[0-9a-f]{40}$') { throw 'Invalid source revision' }
|
|
if ($Mode -eq 'late-slope' -and $Seconds -ne 7200) { throw 'late-slope is fixed at 7200 s' }
|
|
$Bundle = (Resolve-Path $Bundle).Path
|
|
if (Test-Path $Output) { throw 'Use a fresh output directory' }
|
|
New-Item -ItemType Directory -Path $Output | Out-Null
|
|
$Output = (Resolve-Path $Output).Path
|
|
foreach ($d in 'tmp', 'cli', 'data', 'results') { New-Item -ItemType Directory -Path (Join-Path $Output $d) | Out-Null }
|
|
# Private task directory: no inherited ACEs, only the running user and SYSTEM.
|
|
& icacls $Output /inheritance:r /grant:r "$($env:USERNAME):(OI)(CI)F" '*S-1-5-18:(OI)(CI)F' | Out-Null
|
|
$started = Get-Date
|
|
|
|
function Write-Json($path, $value) { $value | ConvertTo-Json -Depth 6 | Set-Content -Encoding UTF8 -Path $path }
|
|
|
|
# 1. Re-verify every bundle file against the prepared manifest before anything runs.
|
|
$bad = 0; $count = 0
|
|
foreach ($line in Get-Content (Join-Path $Bundle 'manifest.sha256')) {
|
|
$sha, $name = $line -split ' ', 2
|
|
$file = Join-Path $Bundle ($name -replace '/', '\')
|
|
$count++
|
|
if ((Get-FileHash -Algorithm SHA256 -LiteralPath $file).Hash.ToLowerInvariant() -ne $sha) { $bad++ }
|
|
}
|
|
if ($bad -ne 0 -or $count -lt 900) { throw "Bundle verification failed: $bad mismatches of $count" }
|
|
$appDir = Join-Path $Bundle 'app'
|
|
$coreClr = Join-Path $appDir 'coreclr.dll'
|
|
$coreClrSha = (Get-FileHash -Algorithm SHA256 $coreClr).Hash.ToLowerInvariant()
|
|
Write-Json (Join-Path $Output 'launch.json') ([ordered]@{
|
|
kind = 'launch'; protocol = 'sdk2-late-slope-v1'; platform = 'windows'; mode = $Mode; requestedSeconds = $Seconds
|
|
sourceRevision = $SourceRevision; bundlePath = $Bundle; outputPath = $Output; verifiedBundleFiles = $count
|
|
coreClrSha256 = $coreClrSha; outerDeadlineSeconds = $outerDeadline; startedUtc = $started.ToUniversalTime().ToString('o')
|
|
host = $env:COMPUTERNAME; os = [Environment]::OSVersion.VersionString
|
|
logicalProcessors = [Environment]::ProcessorCount
|
|
physicalMemoryBytes = (Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory })
|
|
|
|
# 2. Same environment as container.sh.
|
|
$env:TEMP = Join-Path $Output 'tmp'; $env:TMP = $env:TEMP
|
|
$env:DOTNET_CLI_HOME = Join-Path $Output 'cli'
|
|
$env:OTOPCUA_SDK2_SOAK = '1'; $env:OTOPCUA_SDK2_SOAK_MODE = $Mode; $env:OTOPCUA_SDK2_SOAK_SECONDS = "$Seconds"
|
|
$env:OTOPCUA_SDK2_SOAK_OUTPUT = Join-Path $Output 'resources.jsonl'; $env:OPCUA_CERTIFICATE_LEAK_TRACKING = '1'
|
|
$env:DOTNET_CLI_TELEMETRY_OPTOUT = '1'; $env:DOTNET_NOLOGO = '1'; $env:OTOPCUA_SOAK_BUILD_SHA = $SourceRevision
|
|
& dotnet --info > (Join-Path $Output 'controller-runtime.txt')
|
|
$dll = Join-Path $appDir "$app.dll"
|
|
$results = Join-Path $Output 'results'
|
|
|
|
# 3. Descriptor controls must actually execute and pass before the long test.
|
|
& dotnet vstest $dll "--TestCaseFilter:FullyQualifiedName~$testClass.Descriptor_probe_" "--ResultsDirectory:$results" `
|
|
'--Logger:trx;LogFileName=descriptor-controls.trx' > (Join-Path $Output 'descriptor-controls.log') 2>&1
|
|
[xml] $trx = Get-Content (Join-Path $results 'descriptor-controls.trx')
|
|
$c = $trx.TestRun.ResultSummary.Counters
|
|
if ($c.total -ne '2' -or $c.executed -ne '2' -or $c.passed -ne '2' -or $c.failed -ne '0') {
|
|
'controls-failed' | Set-Content (Join-Path $Output 'launcher-outcome.txt'); exit 2
|
|
}
|
|
|
|
# 4. The canonical test, one process, output captured.
|
|
$psi = New-Object System.Diagnostics.ProcessStartInfo 'dotnet'
|
|
$psi.Arguments = "vstest `"$dll`" `"--TestCaseFilter:FullyQualifiedName=$testClass.Repeated_lifecycles_return_resources_to_a_steady_baseline`" `"--ResultsDirectory:$results`" `"--Logger:trx;LogFileName=late-slope.trx`""
|
|
$psi.UseShellExecute = $false; $psi.RedirectStandardOutput = $true; $psi.RedirectStandardError = $true
|
|
$vstest = [System.Diagnostics.Process]::Start($psi)
|
|
$log = [System.IO.StreamWriter]::new((Join-Path $Output 'test.log'))
|
|
$outTask = $vstest.StandardOutput.ReadToEndAsync(); $errTask = $vstest.StandardError.ReadToEndAsync()
|
|
|
|
# 5. Verify the actual test process within 150 s: bundle apphost image + coreclr mapped from the bundle.
|
|
$resources = Join-Path $Output 'resources.jsonl'
|
|
$targetPid = $null; $deadline = (Get-Date).AddSeconds(150)
|
|
while ((Get-Date) -lt $deadline -and -not $vstest.HasExited) {
|
|
if ((Test-Path $resources) -and (Get-Item $resources).Length -gt 0) {
|
|
$fs = [System.IO.File]::Open($resources, 'Open', 'Read', 'ReadWrite')
|
|
try { $header = ([System.IO.StreamReader]::new($fs)).ReadLine() } finally { $fs.Dispose() }
|
|
if ($header -and $header.Contains('"kind":"start"')) {
|
|
$targetPid = [int](($header | ConvertFrom-Json).processId); break
|
|
}
|
|
}
|
|
Start-Sleep -Milliseconds 200
|
|
}
|
|
$verified = $false
|
|
if ($targetPid) {
|
|
$target = Get-Process -Id $targetPid
|
|
$image = $target.Path
|
|
$mapped = @($target.Modules | Where-Object { $_.ModuleName -ieq 'coreclr.dll' } | ForEach-Object FileName)
|
|
$mappedSha = if ($mapped.Count -eq 1) { (Get-FileHash -Algorithm SHA256 $mapped[0]).Hash.ToLowerInvariant() } else { '' }
|
|
$verified = ($image -ieq (Join-Path $appDir "$app.exe")) -and $mapped.Count -eq 1 -and ($mapped[0] -ieq $coreClr) -and ($mappedSha -eq $coreClrSha)
|
|
"targetPid=$targetPid`r`nactualExecutable=$image`r`nmappedCoreClr=$($mapped -join ';')`r`ncoreClrSha256=$mappedSha" |
|
|
Set-Content -Encoding ASCII (Join-Path $Output 'target-runtime.txt')
|
|
}
|
|
if (-not $verified) {
|
|
try { & taskkill /PID $vstest.Id /T /F | Out-Null } catch { }
|
|
}
|
|
|
|
# 6. External read-only sampler + outer deadline.
|
|
$sampler = Join-Path $Output 'external-samples.jsonl'
|
|
$nextHealth = Get-Date
|
|
while (-not $vstest.WaitForExit(30000)) {
|
|
$elapsed = ((Get-Date) - $started).TotalSeconds
|
|
$row = [ordered]@{ utc = (Get-Date).ToUniversalTime().ToString('o'); launcherElapsedSeconds = [math]::Round($elapsed, 1) }
|
|
try {
|
|
$p = Get-Process -Id $targetPid -ErrorAction Stop
|
|
$row.workingSet64 = $p.WorkingSet64; $row.privateMemorySize64 = $p.PrivateMemorySize64
|
|
$row.pagedMemorySize64 = $p.PagedMemorySize64; $row.peakWorkingSet64 = $p.PeakWorkingSet64
|
|
$row.handleCount = $p.HandleCount; $row.threads = $p.Threads.Count
|
|
} catch { $row.targetGone = $true }
|
|
if ((Get-Date) -ge $nextHealth) {
|
|
try { $row.mxAccessGwReady = (Invoke-WebRequest -UseBasicParsing -TimeoutSec 5 'http://localhost:5130/health/ready').StatusCode }
|
|
catch { $row.mxAccessGwReady = "error: $($_.Exception.Message)" }
|
|
$row.mxAccessGwService = (Get-Service MxAccessGw).Status.ToString()
|
|
$nextHealth = (Get-Date).AddMinutes(5)
|
|
}
|
|
($row | ConvertTo-Json -Compress) | Add-Content -Encoding UTF8 $sampler
|
|
if ($elapsed -gt $outerDeadline) {
|
|
& taskkill /PID $vstest.Id /T /F | Out-Null
|
|
'outer-deadline' | Set-Content (Join-Path $Output 'launcher-outcome.txt')
|
|
}
|
|
}
|
|
$log.Write($outTask.Result); $log.Write($errTask.Result); $log.Dispose()
|
|
$status = $vstest.ExitCode
|
|
"$status" | Set-Content -Encoding ASCII (Join-Path $Output 'test-exit-status.txt')
|
|
$wall = ((Get-Date) - $started).TotalSeconds
|
|
$evidence = [ordered]@{}
|
|
foreach ($f in Get-ChildItem -LiteralPath $Output -File -Recurse | Where-Object { $_.FullName -notlike "$Output\tmp\*" -and $_.FullName -notlike "$Output\cli\*" -and $_.FullName -notlike "$Output\data\*" }) {
|
|
$evidence[$f.FullName.Substring($Output.Length + 1).Replace('\', '/')] = (Get-FileHash -Algorithm SHA256 $f.FullName).Hash.ToLowerInvariant()
|
|
}
|
|
Write-Json (Join-Path $Output 'run-receipt.json') ([ordered]@{
|
|
kind = 'run'; platform = 'windows'; sourceRevision = $SourceRevision; mode = $Mode; testExitStatus = $status
|
|
targetVerified = $verified; wallSeconds = $wall; finishedUtc = (Get-Date).ToUniversalTime().ToString('o')
|
|
leftoverTaskProcesses = @(Get-CimInstance Win32_Process | Where-Object { $_.ExecutablePath -and $_.ExecutablePath.StartsWith($Bundle, 'OrdinalIgnoreCase') }).Count
|
|
evidenceSha256 = $evidence })
|
|
'done' | Add-Content (Join-Path $Output 'launcher-outcome.txt')
|
|
exit $status
|