fix(updater): preserve Linux package recovery after checks

This commit is contained in:
Neil
2026-09-01 22:40:15 -07:00
parent 755681ebc5
commit c930c5b289
2 changed files with 92 additions and 12 deletions
@@ -99,6 +99,16 @@ const ARTIFACT = {
path: '/home/tester/.cache/orca-updater/pending/orca-ide_1.0.61_amd64.deb',
sha512: 'LHlL7dKoqg98gS2nfQv878dK+UoktbAkm4M20/hoJ2Qr0Kqsa3MSL4VmWy/Lll/MYjQFkpvOxduQ/vswentozA=='
}
const MANUAL_INSTALL_STATUS = {
state: 'error',
message: 'Quit Orca before running the system package install command.',
recovery: {
kind: 'linux-package-install',
packageType: 'deb',
reason: 'manual-install-required',
version: '1.0.61'
}
} as const satisfies UpdateStatus
describe('linux package recovery actions', () => {
afterEach(() => {
@@ -199,6 +209,63 @@ describe('linux package recovery actions', () => {
expect(showItemInFolderMock).toHaveBeenCalledTimes(2)
})
it('restores recovery after a recheck resolves without a terminal event', async () => {
const { send, updater } = await startUpdater()
await activateRecovery(updater)
send.mockClear()
updater.checkForUpdatesFromMenu()
await vi.advanceTimersByTimeAsync(1_000)
expect(send).toHaveBeenLastCalledWith('updater:status', MANUAL_INSTALL_STATUS)
await expect(updater.showLinuxPackage()).resolves.toBeUndefined()
})
it('restores recovery after a recheck fails', async () => {
const { send, updater } = await startUpdater()
await activateRecovery(updater)
autoUpdaterMock.checkForUpdates.mockRejectedValueOnce(new Error('offline'))
send.mockClear()
updater.checkForUpdatesFromMenu()
await vi.advanceTimersByTimeAsync(0)
expect(send).toHaveBeenLastCalledWith('updater:status', MANUAL_INSTALL_STATUS)
await expect(updater.getLinuxPackageInstallInstructions()).resolves.toEqual({
ok: true,
command: "sudo apt install -- '<pkg>'",
packageFileName: 'p'
})
})
it('restores recovery when a pinned check resolves to the current version', async () => {
const { send, updater } = await startUpdater()
await activateRecovery(updater)
send.mockClear()
updater.checkForUpdatesFromMenu({ channel: 'stable', targetTag: 'v1.0.51' })
await vi.advanceTimersByTimeAsync(0)
expect(send).toHaveBeenLastCalledWith('updater:status', MANUAL_INSTALL_STATUS)
await expect(updater.showLinuxPackage()).resolves.toBeUndefined()
})
it('restores recovery when resolving a pinned check fails', async () => {
const { send, updater } = await startUpdater()
await activateRecovery(updater)
send.mockClear()
updater.checkForUpdatesFromMenu({ channel: 'stable', targetTag: 'not-a-release-tag' })
await vi.advanceTimersByTimeAsync(0)
expect(send).toHaveBeenLastCalledWith('updater:status', MANUAL_INSTALL_STATUS)
await expect(updater.getLinuxPackageInstallInstructions()).resolves.toEqual({
ok: true,
command: "sudo apt install -- '<pkg>'",
packageFileName: 'p'
})
})
it('replaces the structured status when revalidation fails so stale actions die', async () => {
const { send, updater } = await startUpdater()
await activateRecovery(updater)
+25 -12
View File
@@ -39,6 +39,7 @@ import {
getLinuxPackageType,
LINUX_PACKAGE_MARKER_UNUSABLE_MESSAGE
} from './linux-update-package-type'
import { getRetainedLinuxPackageManualInstallStatus } from './linux-package-downloaded-status'
import {
createUpdaterDiagnosticLogger,
redactLinuxPackageInstallText
@@ -354,6 +355,17 @@ function sendStatus(status: UpdateStatus, options?: { force?: boolean }): void {
mainWindowRef?.webContents.send('updater:status', decoratedStatus)
}
function sendSettledCheckStatus(status: UpdateStatus): void {
const retainedStatus = getRetainedLinuxPackageManualInstallStatus()
if (retainedStatus) {
sendStatus(retainedStatus)
} else if (status.state === 'error') {
sendErrorStatus(status.message, status.userInitiated)
} else {
sendStatus(status)
}
}
function getOptionsForUpdateCheckVariant(variant: UpdateCheckVariant): UpdateCheckOptions {
switch (variant) {
case 'perf':
@@ -571,7 +583,7 @@ function settleSilentUpdateCheck(attemptId: number, userInitiated: boolean | und
deferPendingUpdateNudgeUntilRetry()
return
}
sendStatus({ state: 'not-available', userInitiated })
sendSettledCheckStatus({ state: 'not-available', userInitiated })
}
}
return
@@ -581,7 +593,7 @@ function settleSilentUpdateCheck(attemptId: number, userInitiated: boolean | und
backgroundCheckPromotedToUserInitiated = false
userInitiatedCheck = false
completeSilentUpdateCheck(userInitiated)
sendStatus({ state: 'not-available', userInitiated })
sendSettledCheckStatus({ state: 'not-available', userInitiated })
}
function handleSettledUpdateCheckPromise(attemptId: number): void {
@@ -994,7 +1006,7 @@ async function sendCheckFailureStatus(
// error, or the pin blocks background checks for the process lifetime.
clearAvailableUpdateContext()
restoreReleaseUpdateSource()
sendStatus({ state: 'error', message, userInitiated })
sendSettledCheckStatus({ state: 'error', message, userInitiated })
return
}
const failureKey = getCheckFailureKey(message, userInitiated)
@@ -1039,18 +1051,19 @@ async function sendCheckFailureStatus(
scheduleAutomaticUpdateCheck(AUTO_UPDATE_RETRY_INTERVAL_MS)
if (userInitiated) {
// Why: a user click needs visible feedback (idle looks broken); distinguish incomplete releases from transport failures.
sendErrorStatus(
isStableReleaseNotReadyFailure(sourceError)
sendSettledCheckStatus({
state: 'error',
message: isStableReleaseNotReadyFailure(sourceError)
? "A newer release isn't available for this device yet. Check again later."
: "Couldn't reach the update server. Try again in a few minutes.",
true
)
userInitiated: true
})
} else {
if (isRetryableReleaseFeedPreflightFailure(sourceError)) {
// Why: release probes can fail transiently; keep the campaign pending so the short retry can still show it.
deferPendingUpdateNudgeUntilRetry()
}
sendStatus({ state: 'idle' })
sendSettledCheckStatus({ state: 'idle' })
}
return
}
@@ -1060,7 +1073,7 @@ async function sendCheckFailureStatus(
if (!userInitiated) {
scheduleAutomaticUpdateCheck(AUTO_UPDATE_RETRY_INTERVAL_MS)
}
sendErrorStatus(message, userInitiated)
sendSettledCheckStatus({ state: 'error', message, userInitiated })
}
pendingCheckFailureKey = failureKey
@@ -1585,7 +1598,7 @@ export function checkForUpdatesFromMenu(options?: UpdateCheckOptions): void {
userInitiatedCheck = false
finishActiveUpdateCheckAttempt()
recordCompletedUpdateCheck()
sendStatus({ state: 'not-available', userInitiated: true })
sendSettledCheckStatus({ state: 'not-available', userInitiated: true })
return false
}
return launch()
@@ -1712,7 +1725,7 @@ async function checkForPinnedBuild(channel: ReleaseChannel, tag: string): Promis
try {
const target = resolveTargetBuild(channel, tag)
if (compareVersions(target.version, app.getVersion()) === 0) {
sendStatus({ state: 'not-available', userInitiated: true })
sendSettledCheckStatus({ state: 'not-available', userInitiated: true })
return
}
closeLocalBuildFeed()
@@ -1741,7 +1754,7 @@ async function checkForPinnedBuild(channel: ReleaseChannel, tag: string): Promis
userInitiatedCheck = false
clearAvailableUpdateContext()
restoreReleaseUpdateSource()
sendStatus({
sendSettledCheckStatus({
state: 'error',
message: String((error as Error)?.message ?? error),
userInitiated: true