From 23984040bedd7d487166914c287e27309dcd0725 Mon Sep 17 00:00:00 2001 From: Daniel Covington Date: Mon, 24 Aug 2026 12:40:57 -0400 Subject: [PATCH] Fix deploy: wait for app pools to actually stop before wiping RemoteDir Stop-WebAppPool only requests a stop - the w3wp.exe worker process can keep running for a few seconds afterwards, still holding the site's files open, so the previous fix (stopping both sites before the wipe) wasn't sufficient on its own. - Wait-AppPoolFullyStopped: polls app pool state until Stopped (30s timeout), then force-kills any lingering w3wp.exe for that pool - Remove-DirectoryContentsWithRetry: retries the wipe a few times with a short delay, since file handles can take a moment to release even after the worker process is confirmed gone --- scripts/deploy-iis-remote-apply.ps1 | 51 ++++++++++++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/scripts/deploy-iis-remote-apply.ps1 b/scripts/deploy-iis-remote-apply.ps1 index 3a512dc..28eef32 100644 --- a/scripts/deploy-iis-remote-apply.ps1 +++ b/scripts/deploy-iis-remote-apply.ps1 @@ -25,6 +25,53 @@ param( $ErrorActionPreference = 'Stop' Import-Module WebAdministration +# Stop-WebAppPool only requests a stop - the w3wp.exe worker process can keep running for a +# few seconds afterwards (finishing in-flight requests), still holding the site's files open. +# Wait for the pool to actually report Stopped, then force-kill any worker process that's +# still lingering past the timeout so the wipe below doesn't hit "Access is denied". +function Wait-AppPoolFullyStopped { + param([string]$PoolName, [int]$TimeoutSec = 30) + + if([string]::IsNullOrWhiteSpace($PoolName)){ return } + + $deadline = (Get-Date).AddSeconds($TimeoutSec) + while((Get-Date) -lt $deadline){ + if((Get-WebAppPoolState -Name $PoolName -ErrorAction SilentlyContinue).Value -eq 'Stopped'){ + return + } + Start-Sleep -Milliseconds 500 + } + + Write-Host "App pool $PoolName did not report Stopped within ${TimeoutSec}s - killing any lingering worker process" + Get-CimInstance Win32_Process -Filter "Name = 'w3wp.exe'" -ErrorAction SilentlyContinue | + Where-Object { $_.CommandLine -like ('*' + $PoolName + '*') } | + ForEach-Object { + try { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue } catch { } + } +} + +# Wipes are attempted a few times with a short pause - file handles (IIS or AV scanning +# the just-stopped worker process's files) can take a moment to release even after the +# worker process itself is confirmed gone. +function Remove-DirectoryContentsWithRetry { + param([string]$Path, [int]$MaxAttempts = 5, [int]$DelayMs = 2000) + + for($attempt = 1; $attempt -le $MaxAttempts; $attempt++){ + $failure = $null + try { + Remove-Item -Recurse -Force $Path -ErrorAction Stop + return + } catch { + $failure = $_ + if($attempt -lt $MaxAttempts){ + Write-Host "Wipe attempt $attempt failed (files still locked?) - retrying in $($DelayMs)ms" + Start-Sleep -Milliseconds $DelayMs + } + } + } + throw $failure +} + if(!(Get-Website -Name $SiteName -ErrorAction SilentlyContinue)){ throw "IIS site '$SiteName' does not exist yet. Create the site and app pool once manually (or via a one-time setup script) before running this deploy." } @@ -35,6 +82,7 @@ if(!(Test-Path ('IIS:\AppPools\' + $AppPool))){ Write-Host "Stopping IIS site $SiteName and app pool $AppPool" try { Stop-Website -Name $SiteName } catch { } try { Stop-WebAppPool -Name $AppPool } catch { } +Wait-AppPoolFullyStopped -PoolName $AppPool # The admin site's physical path may already point inside RemoteDir (e.g. RemoteDir\public-admin) # from a previous deploy - if it's left running, IIS holds those files open and the wipe below @@ -44,12 +92,13 @@ if(![string]::IsNullOrWhiteSpace($AdminSiteName) -and (Get-Website -Name $AdminS try { Stop-Website -Name $AdminSiteName } catch { } if(![string]::IsNullOrWhiteSpace($AdminAppPool)){ try { Stop-WebAppPool -Name $AdminAppPool } catch { } + Wait-AppPoolFullyStopped -PoolName $AdminAppPool } } if(Test-Path $RemoteDir){ Write-Host "Wiping $RemoteDir" - Remove-Item -Recurse -Force (Join-Path $RemoteDir '*') -ErrorAction SilentlyContinue + Remove-DirectoryContentsWithRetry -Path (Join-Path $RemoteDir '*') } else { New-Item -ItemType Directory -Force -Path $RemoteDir | Out-Null }