Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<version>/<file>`), 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

Expand Down
35 changes: 31 additions & 4 deletions install.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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
}
Expand Down
105 changes: 105 additions & 0 deletions tests/Install.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading