diff --git a/CHANGELOG.md b/CHANGELOG.md index 38b9020..2ea5fcb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The release workflow verifies the published Cloudsmith files at their actual download path (`raw/versions//`), and a failed download is reported as a notice instead of a spurious checksum mismatch. +- Windows activation retries transient access-denied errors from antivirus/ + indexer scans instead of failing the install. ## [0.1.0] - 2026-07-30 diff --git a/install.ps1 b/install.ps1 index 3014d99..f0f2c9d 100644 --- a/install.ps1 +++ b/install.ps1 @@ -66,6 +66,33 @@ function Invoke-WithRetry { } } +function Invoke-WithFilesystemRetry { + param( + [Parameter(Mandatory = $true)][scriptblock]$Action, + [ValidateRange(1, [int]::MaxValue)][int]$Attempts = 10, + [ValidateRange(0, [int]::MaxValue)][int]$DelayMilliseconds = 1000 + ) + + # Windows Defender's real-time scan (or Search indexing) can briefly hold + # a handle on a just-extracted or just-touched directory, and + # Directory.Move fails outright if any handle is open. Retry only the + # transient access errors that pattern produces; anything else propagates + # immediately. The defaults match MSBuild's Copy task, which retries the + # same two exception types for the same reason. + for ($attempt = 1; $attempt -le $Attempts; $attempt++) { + try { + return & $Action + } + catch [System.UnauthorizedAccessException], [System.IO.IOException] { + if ($attempt -eq $Attempts) { + Write-Warning "Filesystem operation still blocked after $Attempts attempts $DelayMilliseconds ms apart; giving up" + throw + } + Start-Sleep -Milliseconds $DelayMilliseconds + } + } +} + function Test-DownloadUriAllowed { param([Parameter(Mandatory = $true)][string]$Uri) @@ -482,17 +509,17 @@ try { # on wildcard characters like [ ] in the install root. $temporaryFinal = Join-Path $finalParent (".cloudsmith.new." + [Guid]::NewGuid().ToString("N")) $oldFinal = Join-Path $finalParent (".cloudsmith.old." + [Guid]::NewGuid().ToString("N")) - [System.IO.Directory]::Move($stagedDirectory, $temporaryFinal) + Invoke-WithFilesystemRetry { [System.IO.Directory]::Move($stagedDirectory, $temporaryFinal) } if (Test-Path -LiteralPath $binDirectory) { - [System.IO.Directory]::Move($binDirectory, $oldFinal) + Invoke-WithFilesystemRetry { [System.IO.Directory]::Move($binDirectory, $oldFinal) } } try { - [System.IO.Directory]::Move($temporaryFinal, $binDirectory) + Invoke-WithFilesystemRetry { [System.IO.Directory]::Move($temporaryFinal, $binDirectory) } } catch { if (Test-Path -LiteralPath $oldFinal) { - try { [System.IO.Directory]::Move($oldFinal, $binDirectory) } catch { Write-Warning "Failed to restore previous installation directory during rollback: $_" } + try { Invoke-WithFilesystemRetry { [System.IO.Directory]::Move($oldFinal, $binDirectory) } } catch { Write-Warning "Failed to restore previous installation directory during rollback: $_" } } throw } diff --git a/tests/Install.Tests.ps1 b/tests/Install.Tests.ps1 index e268bbe..2826b49 100644 --- a/tests/Install.Tests.ps1 +++ b/tests/Install.Tests.ps1 @@ -46,6 +46,111 @@ Describe 'Cloudsmith CLI installer request metadata' { } } +Describe 'Cloudsmith CLI installer activation retry' { + + BeforeAll { + $installScriptPath = (Resolve-Path (Join-Path (Join-Path $PSScriptRoot '..') 'install.ps1')).ProviderPath + $tokens = $null + $errors = $null + $ast = [System.Management.Automation.Language.Parser]::ParseFile( + $installScriptPath, + [ref]$tokens, + [ref]$errors + ) + if ($errors.Count -gt 0) { + throw 'install.ps1 has parse errors' + } + + $retryFunction = $ast.Find({ + param($node) + $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -eq 'Invoke-WithFilesystemRetry' + }, $true) + if (-not $retryFunction) { + throw 'unable to find Invoke-WithFilesystemRetry' + } + + . ([scriptblock]::Create($retryFunction.Extent.Text)) + + $script:RetryDefaults = @{} + foreach ($parameter in $retryFunction.Body.ParamBlock.Parameters) { + if ($parameter.DefaultValue) { + $script:RetryDefaults[$parameter.Name.VariablePath.UserPath] = $parameter.DefaultValue.Extent.Text + } + } + } + + BeforeEach { + $script:AttemptCount = 0 + } + + It 'succeeds after transient UnauthorizedAccessException errors, retrying only as needed' { + $action = { + $script:AttemptCount++ + if ($script:AttemptCount -lt 3) { + throw [System.UnauthorizedAccessException]::new("Access to the path 'C:\fake' is denied.") + } + return 'moved' + } + + $result = Invoke-WithFilesystemRetry -Action $action -Attempts 5 -DelayMilliseconds 1 + + $result | Should -Be 'moved' + $script:AttemptCount | Should -Be 3 + } + + It 'succeeds after transient IOException errors' { + $action = { + $script:AttemptCount++ + if ($script:AttemptCount -lt 2) { + throw [System.IO.IOException]::new('The process cannot access the file because it is being used by another process.') + } + return 'moved' + } + + $result = Invoke-WithFilesystemRetry -Action $action -Attempts 5 -DelayMilliseconds 1 + + $result | Should -Be 'moved' + $script:AttemptCount | Should -Be 2 + } + + It 'rethrows once retries are exhausted' { + $action = { + $script:AttemptCount++ + throw [System.UnauthorizedAccessException]::new("Access to the path 'C:\fake' is denied.") + } + + { Invoke-WithFilesystemRetry -Action $action -Attempts 3 -DelayMilliseconds 1 } | + Should -Throw -ExceptionType ([System.UnauthorizedAccessException]) + $script:AttemptCount | Should -Be 3 + } + + It 'does not retry unrelated exception types' { + $action = { + $script:AttemptCount++ + throw [System.InvalidOperationException]::new('not a filesystem access error') + } + + { Invoke-WithFilesystemRetry -Action $action -Attempts 5 -DelayMilliseconds 1 } | + Should -Throw -ExceptionType ([System.InvalidOperationException]) + $script:AttemptCount | Should -Be 1 + } + + It 'keeps retrying by default for long enough to outlast an antivirus scan' { + $attempts = [int]$script:RetryDefaults['Attempts'] + $delay = [int]$script:RetryDefaults['DelayMilliseconds'] + + (($attempts - 1) * $delay) | Should -BeGreaterOrEqual 9000 + } + + It 'rejects an attempt count that would skip the action' { + { Invoke-WithFilesystemRetry -Action { 'never runs' } -Attempts 0 -DelayMilliseconds 1 } | + Should -Throw + { Invoke-WithFilesystemRetry -Action { 'never runs' } -DelayMilliseconds -1 } | + Should -Throw + } +} + Describe 'Cloudsmith CLI installer (install.ps1)' -Tag 'Windows' -Skip:(-not $script:RunningOnWindows) { BeforeAll {