From 3f25816a310078f665ca0726dd51cb75e3b7e141 Mon Sep 17 00:00:00 2001 From: Bartosz Blizniak Date: Thu, 30 Jul 2026 13:29:20 +0100 Subject: [PATCH 1/2] fix: retry the Windows activation move on transient access denials On GitHub-hosted windows-latest runners, Defender's real-time scan (or Search indexing) can briefly hold a handle on the freshly extracted directory, causing Directory.Move to throw UnauthorizedAccessException or IOException during activation. Wrap the activation moves (including the rollback/restore path) in a bounded retry (5 attempts, ~250ms apart) that only catches those two exception types and rethrows after the final attempt. --- CHANGELOG.md | 2 + install.ps1 | 39 +++++++++++++++++-- tests/Install.Tests.ps1 | 84 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 121 insertions(+), 4 deletions(-) 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..274135c 100644 --- a/install.ps1 +++ b/install.ps1 @@ -66,6 +66,37 @@ function Invoke-WithRetry { } } +function Invoke-WithFilesystemRetry { + param( + [Parameter(Mandatory = $true)][scriptblock]$Action, + [int]$Attempts = 5, + [int]$DelayMilliseconds = 250 + ) + + # 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. + for ($attempt = 1; $attempt -le $Attempts; $attempt++) { + try { + return & $Action + } + catch [System.UnauthorizedAccessException] { + if ($attempt -eq $Attempts) { + throw + } + Start-Sleep -Milliseconds $DelayMilliseconds + } + catch [System.IO.IOException] { + if ($attempt -eq $Attempts) { + throw + } + Start-Sleep -Milliseconds $DelayMilliseconds + } + } +} + function Test-DownloadUriAllowed { param([Parameter(Mandatory = $true)][string]$Uri) @@ -482,17 +513,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..9bce8c2 100644 --- a/tests/Install.Tests.ps1 +++ b/tests/Install.Tests.ps1 @@ -46,6 +46,90 @@ 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)) + } + + 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 + } +} + Describe 'Cloudsmith CLI installer (install.ps1)' -Tag 'Windows' -Skip:(-not $script:RunningOnWindows) { BeforeAll { From ad586cfbc1efcc7f8823290fe4ad20bb5ab9a919 Mon Sep 17 00:00:00 2001 From: Bartosz Blizniak Date: Thu, 30 Jul 2026 13:40:48 +0100 Subject: [PATCH 2/2] fix: widen the activation retry window and validate its inputs Directory.Move races with antivirus and indexer handles, and the previous 1s window (5 attempts, 250ms apart) is well short of what the established implementations allow: MSBuild's Copy task retries the same two exception types 10 times 1s apart, and rustup retries renames for tens of seconds after hitting exactly this failure mode. Match MSBuild's defaults, report the attempt count when the window is exhausted, and reject attempt and delay values that would skip the action or sleep backwards. Co-Authored-By: Claude Fable 5 --- install.ps1 | 16 ++++++---------- tests/Install.Tests.ps1 | 21 +++++++++++++++++++++ 2 files changed, 27 insertions(+), 10 deletions(-) diff --git a/install.ps1 b/install.ps1 index 274135c..f0f2c9d 100644 --- a/install.ps1 +++ b/install.ps1 @@ -69,27 +69,23 @@ function Invoke-WithRetry { function Invoke-WithFilesystemRetry { param( [Parameter(Mandatory = $true)][scriptblock]$Action, - [int]$Attempts = 5, - [int]$DelayMilliseconds = 250 + [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. + # 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] { - if ($attempt -eq $Attempts) { - throw - } - Start-Sleep -Milliseconds $DelayMilliseconds - } - catch [System.IO.IOException] { + 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 diff --git a/tests/Install.Tests.ps1 b/tests/Install.Tests.ps1 index 9bce8c2..2826b49 100644 --- a/tests/Install.Tests.ps1 +++ b/tests/Install.Tests.ps1 @@ -71,6 +71,13 @@ Describe 'Cloudsmith CLI installer activation retry' { } . ([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 { @@ -128,6 +135,20 @@ Describe 'Cloudsmith CLI installer activation retry' { 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) {