fix(install): cut the swap's stopped window to the swapped-in folder (#905)
v2-ci / unit-tests (push) Has been cancelled

Root cause: the protected-DACL coverage scan, not the ACL writes. Both -Reapply and
-Verify walked every denied folder (all of E:\ApiInstall on vd03, nested roots again)
with Get-ChildItem -Recurse + Get-Acl per item + a pipeline per item for -Skip, about
3.4 ms an item on PS 5.1, all while the service was stopped. An unchanged deny is read,
not rewritten; the re-applied install-folder deny propagates over ~1,400 files in 0.5 s.

- Find-OtOpcUaDenyCoverageGap: .NET enumeration, -Skip folders pruned, DACL-only
  GetAccessControl(Access) (about 0.15 ms an item); reparse points not followed.
- Get-OtOpcUaCoverageWalkRoots: each denied folder walked once (outermost only).
- Install-ScriptWorkerIsolation.ps1 -Scope All|InstallRoot|Outside (with -Reapply,
  -Verify, -PlanOnly). InstallRoot = the folder ACEs a swap drops; Outside = the rest,
  including the firewall rule (its query costs 3-5 s; its exe path does not change).
  The two partition the plan. Prints the scan time.
- Invoke-OtOpcUaInstallSwap.ps1: -Reapply/-Verify -Scope Outside before the stop (a
  failure stops before anything moves), -Scope InstallRoot inside the window; preflight
  checks the candidate carries the worker exe; prints the stopped window and its parts.
  -NoStart, -NoCarry and the pre-#901 refusal unchanged.

windev, vd03-like tree (17,851 items): stopped window 153.1 s -> 6.0 s (PS 5.1),
101.6 s -> 6.2 s (PS 7); the Outside part takes ~23 s with the old build serving.
Dry-run harness 101 -> 123 passed; Windows test 72 passed on PS 5.1 and PS 7.

Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
This commit is contained in:
Joseph Doherty
2026-11-13 03:51:41 -05:00
parent f817c77e02
commit 0c19f26c81
8 changed files with 475 additions and 58 deletions
@@ -2,7 +2,8 @@
.SYNOPSIS
Windows-executed test of Install-ScriptWorkerIsolation.ps1 (lmxopcua#883, #890, #901): apply -> re-apply -> verify -> shorter
-DenyPaths -> -Verify -> folder swap -> -Verify fails -> -Reapply -> -Verify passes -> -Reapply unchanged ->
Invoke-OtOpcUaInstallSwap.ps1 (re-apply + verify built in; refuses a pre-#901 record) -> uninstall (from the record,
Invoke-OtOpcUaInstallSwap.ps1 (re-apply + verify built in; refuses a pre-#901 record) -> -Scope InstallRoot/Outside
after a swap, the swap's pre-stop gate, -NoStart, a -NoCarry rollback (lmxopcua#905) -> uninstall (from the record,
without -DenyPaths) -> uninstall. ACL level only; the restricted-token proof that
the worker cannot read the denied folders is InstallerLayoutIsolationTests (ScriptWorker.Tests, OTOPCUA_ISOLATION_INSTALLER_E2E=1).
The dry-run harness (Test-ScriptWorkerIsolation.ps1) only asserts plan data; it cannot see host-specific failures such as
@@ -12,6 +13,13 @@
pwsh -NoProfile -File scripts\install\tests\Test-ScriptWorkerIsolation.Windows.ps1
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\install\tests\Test-ScriptWorkerIsolation.Windows.ps1
lmxopcua#905 timing: -TimingFiles N also builds a vd03-like tree (a denied ApiInstall folder holding the install, a
candidate and N more files in other app folders, 48 protected-DACL files), installs the isolation on it and times
Invoke-OtOpcUaInstallSwap.ps1. -TimingOnly skips the functional part. -ScriptsDir runs the timing against another
copy of scripts\install (e.g. the scripts before #905) for a before/after:
pwsh -NoProfile -File ...\Test-ScriptWorkerIsolation.Windows.ps1 -TimingOnly -TimingFiles 17000 -ScriptsDir C:\old\install
.DESCRIPTION
Needs an elevated Windows session. It works in a scratch folder under $env:TEMP with a scratch worker service name and a
scratch stand-in host service (a disabled service that is never started), and uses -HostServiceAccount 'NT AUTHORITY\SYSTEM',
@@ -22,7 +30,7 @@
#>
[CmdletBinding()]
param([string]$WorkerServiceName = 'OtOpcUaIsoTestWorker', [string]$HostServiceName = 'OtOpcUaIsoTestHost', [switch]$LocalHostAccount,
[string]$LocalHostAccountName = 'otopcuaIsoTestAcct')
[string]$LocalHostAccountName = 'otopcuaIsoTestAcct', [int]$TimingFiles = 0, [switch]$TimingOnly, [string]$ScriptsDir)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
@@ -32,6 +40,8 @@ $principal = [Security.Principal.WindowsPrincipal][Security.Principal.WindowsIde
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { Write-Host 'run elevated'; exit 2 }
$installer = Join-Path (Split-Path -Parent $PSScriptRoot) 'Install-ScriptWorkerIsolation.ps1'
if (-not $ScriptsDir) { $ScriptsDir = Split-Path -Parent $PSScriptRoot }
$exe = if ($PSVersionTable.PSEdition -eq 'Core') { (Get-Process -Id $PID).Path } else { 'powershell.exe' }
Import-Module (Join-Path (Split-Path -Parent $PSScriptRoot) 'ScriptWorkerIsolation.psm1') -Force
$ruleName = 'OtOpcUa-ScriptWorker-BlockOutbound'
if (Get-NetFirewallRule -Name $ruleName -ErrorAction SilentlyContinue) { Write-Host "refusing: firewall rule $ruleName already exists (real isolation installed on this node)"; exit 2 }
@@ -54,12 +64,12 @@ function Invoke-Installer([string[]]$Extra) {
$steps = @([regex]::Matches($out, '\[(?<o>[a-z]+)\s*\]\s+(?<id>[A-Za-z]+)\s') | ForEach-Object { [pscustomobject]@{ Outcome = $_.Groups['o'].Value; Id = $_.Groups['id'].Value } })
[pscustomobject]@{ Exit = $LASTEXITCODE; Output = $out; Steps = $steps }
}
function Invoke-Mode([string]$Mode) {
function Invoke-Mode([string]$Mode, [string[]]$Extra = @()) {
# lmxopcua#901: -Reapply / -Verify with NO settings argument, only the scratch worker name (the record selector).
$exe = if ($PSVersionTable.PSEdition -eq 'Core') { (Get-Process -Id $PID).Path } else { 'powershell.exe' }
# Continue: an expected failure writes to stderr, which Stop would turn into a terminating error here. Whitespace is
# collapsed (and pwsh's '|' margins dropped) because both hosts wrap the error text at the console width.
$out = & { $ErrorActionPreference = 'Continue'; & $exe -NoProfile -ExecutionPolicy Bypass -File $installer "-$Mode" -WorkerServiceName $WorkerServiceName 2>&1 | Out-String }
$out = & { $ErrorActionPreference = 'Continue'; & $exe -NoProfile -ExecutionPolicy Bypass -File $installer "-$Mode" -WorkerServiceName $WorkerServiceName @Extra 2>&1 | Out-String }
$steps = @([regex]::Matches($out, '\[(?<o>[a-z]+)\s*\]\s+(?<id>[A-Za-z]+)\s+(?<t>\S+)') | ForEach-Object { [pscustomobject]@{ Outcome = $_.Groups['o'].Value; Id = $_.Groups['id'].Value; Target = $_.Groups['t'].Value } })
$code = $LASTEXITCODE
$missing = @([regex]::Matches($out, '\[MISSING\]\s+(?<id>[A-Za-z]+)\s+(?<t>\S+)') | ForEach-Object { "$($_.Groups['id'].Value) $($_.Groups['t'].Value)" })
@@ -100,6 +110,7 @@ try {
Set-ItemProperty -Path $hostKey -Name Environment -Type MultiString -Value ([string[]]@('Keep__Me=1'))
Write-Host "Host: $($PSVersionTable.PSEdition) $($PSVersionTable.PSVersion); scratch $scratch; host account $script:hostAccount"
if (-not $TimingOnly) {
Write-Host 'Dry run'
$dry = Invoke-Installer @('-WhatIf', '-DenyPaths', ($deny -join ','))
Assert ($dry.Exit -eq 0) "-WhatIf exits 0 ($($dry.Output))"
@@ -223,6 +234,95 @@ try {
Assert ($r4.Exit -ne 0 -and $r4.Flat -match 'full arguments') "-Reapply without a full record refuses and says how to fix it ($($r4.Exit))"
Set-ItemProperty -Path $recordKey -Name InstallRoot -Value $saved
Write-Host 'Scoped -Reapply / -Verify after a raw swap (lmxopcua#905)'
$cand4 = Join-Path $scratch 'install.new4'
New-Item -ItemType Directory -Path (Join-Path $cand4 'script-worker') -Force | Out-Null
Set-Content -LiteralPath (Join-Path $cand4 'script-worker\ZB.MOM.WW.OtOpcUa.ScriptWorker.exe') -Value 'stub v4'
$prev4 = Join-Path $scratch 'install.bak.4'
Move-Item -LiteralPath $root -Destination $prev4
Move-Item -LiteralPath $cand4 -Destination $root
& robocopy (Join-Path $prev4 'pki') (Join-Path $root 'pki') /E /COPY:DAT /R:0 /W:0 /NFL /NDL /NJH /NJS /NP | Out-Null
$vo = Invoke-Mode 'Verify' @('-Scope', 'Outside'); Show 'Verify -Scope Outside (after swap)' $vo
Assert ($vo.Exit -eq 0 -and $vo.Output -match 'VERIFY OK \(scope Outside\): 9/9') "a swap leaves the 9 Outside steps intact ($($vo.Output))"
$vi = Invoke-Mode 'Verify' @('-Scope', 'InstallRoot'); Show 'Verify -Scope InstallRoot (after swap)' $vi
Assert ($vi.Exit -eq 1 -and (@($vi.Missing | Sort-Object) -join '|') -eq (@($expectMissing | Sort-Object) -join '|') -and ($vi.Steps.Count + $vi.Missing.Count) -eq 3) `
"the InstallRoot scope checks 3 steps and finds exactly the three dropped ACEs ($($vi.Missing -join ' | '))"
$ri = Invoke-Mode 'Reapply' @('-Scope', 'InstallRoot'); Show 'Reapply -Scope InstallRoot' $ri
$riChanged = @($ri.Steps | Where-Object Outcome -eq 'changed' | ForEach-Object { "$($_.Id) $($_.Target)" })
Assert ($ri.Exit -eq 0 -and $ri.Steps.Count -eq 3 -and (@($riChanged | Sort-Object) -join '|') -eq (@($expectMissing | Sort-Object) -join '|')) `
"scoped reapply runs 3 steps and changes exactly the three ($($riChanged -join ' | '))"
$covLine = [regex]::Match($ri.Flat, 'coverage: \d+ denied folder.s.; \d+ folder.s. walked').Value
Assert ($ri.Flat -match 'coverage: 4 denied folder\(s\); 1 folder\(s\) walked') "scoped reapply walks InstallRoot only ($covLine)"
$vi2 = Invoke-Mode 'Verify' @('-Scope', 'InstallRoot'); $vAll = Invoke-Mode 'Verify'; Show 'Verify (full, after scoped Reapply)' $vAll
Assert ($vi2.Exit -eq 0 -and $vAll.Exit -eq 0 -and $vAll.Output -match 'VERIFY OK: 12/12') 'after the scoped reapply both the scoped and the full verify pass'
$scanLine = [regex]::Match($vAll.Flat, 'coverage scan: \d+ folder.s. walked').Value
Assert ($vAll.Flat -match 'coverage scan: 1 folder\(s\) walked') "the full verify walks the outermost denied folder once ($scanLine)"
Write-Host 'Negative control: each scope sees only its own entries'
& icacls $cache /grant '*S-1-5-32-545:(OI)(CI)(RX)' /Q | Out-Null # an Outside entry broken
$vi3 = Invoke-Mode 'Verify' @('-Scope', 'InstallRoot'); $vo3 = Invoke-Mode 'Verify' @('-Scope', 'Outside'); Show 'Verify -Scope Outside (cache broken)' $vo3
Assert ($vi3.Exit -eq 0 -and $vo3.Exit -eq 1 -and ($vo3.Missing -join '|') -eq "PeCacheDir $cache") "the cache ACL is Outside's to catch, not InstallRoot's ($($vo3.Missing -join ' | '))"
$ro3 = Invoke-Mode 'Reapply' @('-Scope', 'Outside')
Assert ($ro3.Exit -eq 0 -and (@($ro3.Steps | Where-Object Outcome -eq 'changed' | ForEach-Object Id) -join ',') -eq 'PeCacheDir' -and $ro3.Steps.Count -eq 9) 'Reapply -Scope Outside repairs it (9 steps, 1 changed)'
Write-Host 'Coverage scan: protected items found once, script-worker\ skipped, scope-limited'
$prot = Join-Path $root 'pki\own.key'; $protSkip = Join-Path $root 'script-worker\private.key'
foreach ($f in $prot, $protSkip) { Set-Content -LiteralPath $f -Value 'k'; & icacls $f /inheritance:r /grant:r '*S-1-5-18:(F)' '*S-1-5-32-544:(F)' /Q | Out-Null }
$squash = { param($t) $t -replace '[\s|]+', '' }
$vc = Invoke-Mode 'Verify' @('-Scope', 'InstallRoot'); $vco = Invoke-Mode 'Verify' @('-Scope', 'Outside'); $vca = Invoke-Mode 'Verify'
$hit = (& $squash $prot) + 'doesnotinherit'
Assert ($vc.Exit -eq 0 -and (& $squash $vc.Output).Contains($hit)) 'InstallRoot scope warns the protected pki\ file (a warning, not a failure)'
Assert (-not (& $squash $vc.Output).Contains((& $squash $protSkip))) 'script-worker\ is not walked'
Assert (-not (& $squash $vco.Output).Contains($hit)) 'the Outside scope skips InstallRoot'
Assert (([regex]::Matches((& $squash $vca.Output), [regex]::Escape($hit))).Count -eq 1) 'the full verify reports it exactly once (pki\ is inside two denied folders)'
foreach ($f in $prot, $protSkip) { & icacls $f /reset /Q | Out-Null; Remove-Item -LiteralPath $f -Force }
Write-Host 'Invoke-OtOpcUaInstallSwap.ps1 refuses a candidate without the worker exe the firewall rule names (lmxopcua#905)'
$cand6 = Join-Path $scratch 'install.new6'
New-Item -ItemType Directory -Path (Join-Path $cand6 'script-worker') -Force | Out-Null
$sw6 = & { $ErrorActionPreference = 'Continue'; & $exe -NoProfile -ExecutionPolicy Bypass -File $swapScript -InstallRoot $root -Candidate $cand6 `
-PreviousInstall (Join-Path $scratch 'install.bak.6') -NoServiceControl -WorkerServiceName $WorkerServiceName 2>&1 | Out-String }
$sw6Exit = $LASTEXITCODE
Assert ($sw6Exit -ne 0 -and ($sw6 -replace '[\s|]+', '') -match 'candidatehasno' -and (Test-Path $cand6) -and -not (Test-Path (Join-Path $scratch 'install.bak.6')) -and
$sw6 -notmatch 'Scope Outside') "refused in preflight, before the pre-stop isolation; nothing moved ($sw6)"
Remove-Item -LiteralPath $cand6 -Recurse -Force
Write-Host 'Invoke-OtOpcUaInstallSwap.ps1: the pre-stop gate stops before anything moves'
$dataDir = Join-Path $scratch 'data'
& icacls $dataDir /reset /Q | Out-Null; Remove-Item -LiteralPath $dataDir -Force # an Outside deny folder gone: -Reapply -Scope Outside refuses
$cand5 = Join-Path $scratch 'install.new5'
New-Item -ItemType Directory -Path (Join-Path $cand5 'script-worker') -Force | Out-Null
Set-Content -LiteralPath (Join-Path $cand5 'script-worker\ZB.MOM.WW.OtOpcUa.ScriptWorker.exe') -Value 'stub v5'
$prev5 = Join-Path $scratch 'install.bak.5'
$sw3 = & { $ErrorActionPreference = 'Continue'; & $exe -NoProfile -ExecutionPolicy Bypass -File $swapScript -InstallRoot $root -Candidate $cand5 `
-PreviousInstall $prev5 -Carry 'appsettings*.json,pki' -ServiceName $HostServiceName -WorkerServiceName $WorkerServiceName 2>&1 | Out-String }
$sw3Exit = $LASTEXITCODE
Write-Host (" {0,-34} exit {1}; {2}" -f 'swap with an Outside gap', $sw3Exit, $(if ((& $squash $sw3) -match 'ScopeOutsidefailed') { 'refused before the stop' } else { 'NOT refused' }))
Assert ($sw3Exit -ne 0 -and (& $squash $sw3) -match 'ScopeOutsidefailed.*nothingstoppedorswapped' -and (Test-Path $cand5) -and -not (Test-Path $prev5) -and
(Get-Content -LiteralPath $workerExe) -eq 'stub v4' -and $sw3 -notmatch '==> stop') "refused before the stop, nothing moved ($sw3)"
New-Item -ItemType Directory -Path $dataDir -Force | Out-Null
Write-Host 'Invoke-OtOpcUaInstallSwap.ps1 -NoStart with service control (the stand-in host service is disabled: a start would fail)'
$sw4 = & { $ErrorActionPreference = 'Continue'; & $exe -NoProfile -ExecutionPolicy Bypass -File $swapScript -InstallRoot $root -Candidate $cand5 `
-PreviousInstall $prev5 -Carry 'appsettings*.json,pki' -ServiceName $HostServiceName -NoStart -WorkerServiceName $WorkerServiceName 2>&1 | Out-String }
$sw4Exit = $LASTEXITCODE
$w4 = [regex]::Match($sw4, 'stop -> swapped and verified \(left stopped\): [^\r\n]*').Value
Write-Host (" {0,-34} exit {1}; {2}" -f 'swap -NoStart', $sw4Exit, $w4)
Assert ($sw4Exit -eq 0 -and $sw4 -match 'Swap complete' -and $w4 -and $sw4 -notmatch '==> start' -and (Get-Service $HostServiceName).Status -eq 'Stopped') "-NoStart: swapped, verified, not started ($sw4)"
Assert ($sw4 -match 'pre-stop isolation' -and $sw4 -match 'VERIFY OK \(scope Outside\)' -and $sw4 -match 'VERIFY OK \(scope InstallRoot\)') 'both scoped gates ran (Outside before the stop, InstallRoot after the swap)'
Assert ($sw4.IndexOf('VERIFY OK (scope Outside)') -lt $sw4.IndexOf('==> stop') -and $sw4.IndexOf('==> stop') -lt $sw4.IndexOf('-Reapply -Scope InstallRoot')) 'Outside gate before the stop, InstallRoot after it'
Assert ((Get-Content -LiteralPath $workerExe) -eq 'stub v5' -and @(Get-Explicit $root $workerSid).Count -eq 1 -and @(Get-Explicit (Join-Path $root 'pki') $workerSid).Count -eq 1) 'v5 live, its ACEs in place'
Write-Host 'Invoke-OtOpcUaInstallSwap.ps1 -NoCarry: the rollback swaps the previous install back'
$failed5 = Join-Path $scratch 'install.failed.5'
$sw5 = & { $ErrorActionPreference = 'Continue'; & $exe -NoProfile -ExecutionPolicy Bypass -File $swapScript -InstallRoot $root -Candidate $prev5 `
-PreviousInstall $failed5 -NoCarry -NoServiceControl -WorkerServiceName $WorkerServiceName 2>&1 | Out-String }
$sw5Exit = $LASTEXITCODE
Write-Host (" {0,-34} exit {1}; {2}" -f 'swap -NoCarry (rollback)', $sw5Exit, [regex]::Match($sw5, 'swap window[^\r\n]*').Value)
Assert ($sw5Exit -eq 0 -and $sw5 -notmatch 'carried' -and (Get-Content -LiteralPath $workerExe) -eq 'stub v4' -and $sw5 -match 'VERIFY OK \(scope InstallRoot\)') "rollback: v4 back, nothing carried, gate passed ($sw5)"
$vEnd = Invoke-Mode 'Verify'
Assert ($vEnd.Exit -eq 0) 'full verify passes after the rollback'
Write-Host 'Uninstall WITHOUT -DenyPaths (the record drives it), twice'
$u1 = Invoke-Installer @('-Uninstall')
Assert ($u1.Exit -eq 0) "uninstall exits 0 ($($u1.Output))"
@@ -239,6 +339,53 @@ try {
$u2 = Invoke-Installer @('-Uninstall')
Assert ($u2.Exit -eq 0) "second uninstall exits 0 ($($u2.Output))"
Assert ($u2.Steps.Count -eq 7 -and @($u2.Steps | Where-Object Outcome -ne 'unchanged').Count -eq 0) "second uninstall: 7 steps, all unchanged ($(($u2.Steps | ForEach-Object { "$($_.Id)=$($_.Outcome)" }) -join ','))"
}
if ($TimingFiles -gt 0) {
# lmxopcua#905: a vd03-like layout. ApiInstall is denied as a whole and holds the install folder, the candidate and
# other app folders; pki\ is inside the install; 48 protected-DACL files (vd03 reported 48).
Write-Host "Swap timing (lmxopcua#905): scripts from $ScriptsDir"
$tInstaller = Join-Path $ScriptsDir 'Install-ScriptWorkerIsolation.ps1'
$tSwap = Join-Path $ScriptsDir 'Invoke-OtOpcUaInstallSwap.ps1'
$api = Join-Path $scratch 'ApiInstall'; $tRoot = Join-Path $api 'OtOpcUa'; $tCand = Join-Path $api 'OtOpcUa.new'
$tData = Join-Path $api 'OtOpcUa-data'; $tKek = Join-Path $scratch 'kek'; $tCache = Join-Path $scratch 'pe-cache-t'
function New-DummyApp([string]$Dir, [int]$N, [string]$Stub) {
for ($d = 0; $d -lt 60; $d++) { [IO.Directory]::CreateDirectory("$Dir\sub$d") | Out-Null }
for ($i = 0; $i -lt $N; $i++) { [IO.File]::WriteAllText("$Dir\sub$($i % 60)\f$i.dll", 'x') }
if ($Stub) { [IO.Directory]::CreateDirectory("$Dir\script-worker") | Out-Null; [IO.File]::WriteAllText("$Dir\script-worker\ZB.MOM.WW.OtOpcUa.ScriptWorker.exe", $Stub) }
}
New-DummyApp $tRoot 1400 'live'
foreach ($d in "$tRoot\pki\own\private", "$tRoot\logs", $tData, $tKek) { [IO.Directory]::CreateDirectory($d) | Out-Null }
[IO.File]::WriteAllText("$tRoot\appsettings.json", '{}')
New-DummyApp $tCand 1400 'candidate'
$left = [Math]::Max(0, $TimingFiles - 2800); $n = 0
while ($left -gt 0) { $n++; $take = [Math]::Min(1400, $left); New-DummyApp (Join-Path $api "app$n") $take ''; $left -= $take }
$protDir = if ($n) { Join-Path $api 'app1\sub0' } else { $tData }
for ($p = 0; $p -lt 48; $p++) { $f = "$protDir\prot$p.key"; [IO.File]::WriteAllText($f, 'k'); & icacls $f /inheritance:r /grant:r '*S-1-5-18:(F)' '*S-1-5-32-544:(F)' /Q | Out-Null }
$items = @([IO.DirectoryInfo]::new($api).EnumerateFileSystemInfos('*', [IO.SearchOption]::AllDirectories)).Count
$tDeny = "$api,$tRoot\pki,$tData,$tKek"
$ti = [Diagnostics.Stopwatch]::StartNew()
$tiOut = & { $ErrorActionPreference = 'Continue'; & $exe -NoProfile -ExecutionPolicy Bypass -File $tInstaller -InstallRoot $tRoot -HostServiceAccount $script:hostAccount `
-PeCacheDir $tCache -WorkerServiceName $WorkerServiceName -HostServiceName $HostServiceName -ConfigureHostService -DenyPaths $tDeny 2>&1 | Out-String }
$tiExit = $LASTEXITCODE; $ti.Stop()
Assert ($tiExit -eq 0) "timing: isolation install exits 0 ($tiOut)"
$ts = [Diagnostics.Stopwatch]::StartNew()
$tsOut = & { $ErrorActionPreference = 'Continue'; & $exe -NoProfile -ExecutionPolicy Bypass -File $tSwap -InstallRoot $tRoot -Candidate $tCand `
-PreviousInstall (Join-Path $api 'OtOpcUa.bak.t') -NoServiceControl -WorkerServiceName $WorkerServiceName 2>&1 | Out-String }
$tsExit = $LASTEXITCODE; $ts.Stop()
Assert ($tsExit -eq 0 -and $tsOut -match 'Swap complete' -and [IO.File]::ReadAllText("$tRoot\script-worker\ZB.MOM.WW.OtOpcUa.ScriptWorker.exe") -eq 'candidate') "timing: swap exits 0 ($tsOut)"
$wm = [regex]::Match($tsOut, 'swap window[^:]*: (?<s>[0-9.,]+) s(?<rest>[^\r\n]*)')
# Before #905 the whole swap script ran inside the stopped window (nothing ran before the stop).
$window = if ($wm.Success) { [double]::Parse($wm.Groups['s'].Value, [Globalization.CultureInfo]::CurrentCulture) } else { $ts.Elapsed.TotalSeconds }
$pre = [regex]::Match($tsOut, 'pre-stop isolation (?<s>[0-9.,]+) s').Groups['s'].Value
Write-Host (" TIMING {0} {1}: {2} items under the denied folder; install {3:N1} s; swap script {4:N1} s; pre-stop {5}; STOPPED WINDOW {6:N1} s {7}" -f `
$PSVersionTable.PSEdition, $PSVersionTable.PSVersion, $items, $ti.Elapsed.TotalSeconds, $ts.Elapsed.TotalSeconds,
$(if ($pre) { "$pre s" } else { 'none (all in the window)' }), $window, $wm.Groups['rest'].Value)
$tv = & { $ErrorActionPreference = 'Continue'; & $exe -NoProfile -ExecutionPolicy Bypass -File $tInstaller -Verify -WorkerServiceName $WorkerServiceName 2>&1 | Out-String }
Assert ($LASTEXITCODE -eq 0) 'timing: full verify passes after the swap'
$tu = & { $ErrorActionPreference = 'Continue'; & $exe -NoProfile -ExecutionPolicy Bypass -File $tInstaller -Uninstall -WorkerServiceName $WorkerServiceName 2>&1 | Out-String }
Assert ($LASTEXITCODE -eq 0 -and -not (Get-NetFirewallRule -Name $ruleName -ErrorAction SilentlyContinue)) "timing: uninstall from the record ($tu)"
}
}
catch { $script:failures++; Write-Host " FAIL: unexpected error: $($_ | Out-String)" -ForegroundColor Red }
finally {