diff --git a/.github/workflows/desktop-windows-release-check.yaml b/.github/workflows/desktop-windows-release-check.yaml index 2b81d2be4..6d8e933b9 100644 --- a/.github/workflows/desktop-windows-release-check.yaml +++ b/.github/workflows/desktop-windows-release-check.yaml @@ -23,6 +23,10 @@ on: required: false type: string default: "" + stale_apply_record: + description: Before each update, leave behind an hour-old record of an update that never installed (passes only when the installed versions retry it) + type: boolean + default: false pull_request: paths: - .github/workflows/desktop-windows-release-check.yaml @@ -53,6 +57,7 @@ jobs: VERSION: ${{ inputs.version || '2026.1009.20411' }} EXPECT_FAILURE: ${{ github.event_name == 'pull_request' && 'true' || inputs.expect_failure }} UPDATE_FROM: ${{ inputs.update_from || '' }} + STALE_APPLY_RECORD: ${{ inputs.stale_apply_record && 'true' || 'false' }} ARCH: ${{ matrix.arch }} steps: - name: Checkout source @@ -108,6 +113,13 @@ jobs: Get-Process | Where-Object { $_.Path -like "$installRoot*" } | Stop-Process -Force -ErrorAction SilentlyContinue $exe = Get-ChildItem -Path (Join-Path $installRoot 'current') -Filter 'Fluxer*.exe' | Where-Object { $_.Name -notlike '*_ExecutionStub.exe' } | Select-Object -First 1 if ($null -eq $exe) { $failures += "$from did not install"; continue } + if ($env:STALE_APPLY_RECORD -eq 'true') { + $dataRoot = Split-Path (Split-Path $logPath -Parent) -Parent + New-Item -ItemType Directory -Force -Path $dataRoot | Out-Null + $attemptedAt = [DateTimeOffset]::UtcNow.AddHours(-1).ToUnixTimeMilliseconds() + Set-Content -Path (Join-Path $dataRoot 'update-apply-state.json') -Value "{`"version`":`"$env:VERSION`",`"attemptedAt`":$attemptedAt}" -NoNewline + Write-Host "left a stale apply record for $env:VERSION" + } Start-Process -FilePath $exe.FullName -ArgumentList '--disable-gpu' $deadline = (Get-Date).AddMinutes(8) $reported = $null diff --git a/fluxer_desktop/src/main/ShellSelfUpdate.test.mjs b/fluxer_desktop/src/main/ShellSelfUpdate.test.mjs index cf6260e85..8818fdbd5 100644 --- a/fluxer_desktop/src/main/ShellSelfUpdate.test.mjs +++ b/fluxer_desktop/src/main/ShellSelfUpdate.test.mjs @@ -342,7 +342,7 @@ describe('velopack self update', () => { }); selfUpdateTest('an apply that never landed falls back instead of applying it again', async ({start}) => { - writeFileSync(applyStatePath, JSON.stringify({version: '2026.900.0', attemptedAt: 1})); + writeFileSync(applyStatePath, JSON.stringify({version: '2026.900.0', attemptedAt: Date.now() - 60_000})); velopack.pendingRestart = {id: 'staged', Version: '2026.900.0'}; const {calls, hooks} = createHooks(); @@ -355,6 +355,24 @@ describe('velopack self update', () => { assert.deepEqual(quits, []); }); + selfUpdateTest( + 'an old apply that never landed retries from the feed instead of blocking every update', + async ({start, stillPending}) => { + writeFileSync(applyStatePath, JSON.stringify({version: '2026.900.0', attemptedAt: Date.now() - 3_600_000})); + velopack.pendingRestart = {id: 'staged', Version: '2026.900.0'}; + velopack.checkResult = {id: 'v3', TargetFullRelease: {Version: '2026.950.0'}}; + const {hooks} = createHooks(); + + assert.equal(await stillPending(start('velopack', hooks)), true); + assert.deepEqual(velopack.calls, [ + 'checkForUpdatesAsync', + 'downloadUpdateAsync', + 'waitExitThenApplyUpdate:v3:true:true', + ]); + assert.equal(JSON.parse(readFileSync(applyStatePath, 'utf8')).version, '2026.950.0'); + }, + ); + selfUpdateTest('an apply that landed is forgotten and the next one is recorded', async ({start, stillPending}) => { writeFileSync(applyStatePath, JSON.stringify({version: '2026.800.0', attemptedAt: 1})); velopack.pendingRestart = {id: 'staged', Version: '2026.900.0'}; diff --git a/fluxer_desktop/src/main/ShellSelfUpdate.ts b/fluxer_desktop/src/main/ShellSelfUpdate.ts index 19679b13b..b4822fdae 100644 --- a/fluxer_desktop/src/main/ShellSelfUpdate.ts +++ b/fluxer_desktop/src/main/ShellSelfUpdate.ts @@ -73,14 +73,23 @@ function readInstalledVelopackVersion(manager: VelopackUpdateManager): string { } } -function hasUnlandedVelopackApply(manager: VelopackUpdateManager): boolean { +const UNLANDED_APPLY_RETRY_AFTER_MS = 30 * 60_000; + +type UnlandedApply = 'none' | 'recent' | 'stale'; + +function classifyUnlandedVelopackApply(manager: VelopackUpdateManager): UnlandedApply { const attempt = readVelopackApplyAttempt(); - if (attempt == null) return false; + if (attempt == null) return 'none'; if (compareShellVersions(readInstalledVelopackVersion(manager), attempt.version) >= 0) { clearVelopackApplyAttempt(); - return false; + return 'none'; } - return true; + if (Date.now() - attempt.attemptedAt < UNLANDED_APPLY_RETRY_AFTER_MS) { + return 'recent'; + } + logger.warn('An earlier update never installed, retrying from the feed', {version: attempt.version}); + clearVelopackApplyAttempt(); + return 'stale'; } function getStagedVelopackVersion(staged: UpdateInfo | VelopackAsset): string | null { @@ -122,16 +131,19 @@ function downloadVelopackUpdateOnce( async function runVelopackSelfUpdate(control: SelfUpdateControl, hooks: ShellSelfUpdateHooks): Promise { const {UpdateManager} = requireModule('velopack') as typeof import('velopack'); const manager = new UpdateManager(getUpdateBaseUrl()); - if (hasUnlandedVelopackApply(manager)) { + const unlanded = classifyUnlandedVelopackApply(manager); + if (unlanded === 'recent') { control.settle({reason: 'install-failed', detail: 'the last downloaded update was never installed'}); return; } - let staged: UpdateInfo | VelopackAsset | null; - try { - staged = manager.getUpdatePendingRestart(); - } catch (error) { - control.settle({reason: 'check-failed', detail: errorDetail(error)}); - return; + let staged: UpdateInfo | VelopackAsset | null = null; + if (unlanded === 'none') { + try { + staged = manager.getUpdatePendingRestart(); + } catch (error) { + control.settle({reason: 'check-failed', detail: errorDetail(error)}); + return; + } } if (control.isSettled()) return; if (staged == null) {