Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
39 changes: 35 additions & 4 deletions install.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,37 @@ function Invoke-WithRetry {
}
}

function Invoke-WithFilesystemRetry {
param(
[Parameter(Mandatory = $true)][scriptblock]$Action,
[int]$Attempts = 5,
[int]$DelayMilliseconds = 250
)
Comment thread
BartoszBlizniak marked this conversation as resolved.

# 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
}
Comment thread
Copilot marked this conversation as resolved.
Outdated
}
}

function Test-DownloadUriAllowed {
param([Parameter(Mandatory = $true)][string]$Uri)

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