diff --git a/ts/updater/common.ts b/ts/updater/common.ts index 2f877499c..7a03dc80f 100644 --- a/ts/updater/common.ts +++ b/ts/updater/common.ts @@ -4,7 +4,7 @@ /* eslint-disable no-console */ import { createWriteStream } from 'fs'; import { pathExists } from 'fs-extra'; -import { readdir, rename, stat, writeFile } from 'fs/promises'; +import { readdir, stat, writeFile } from 'fs/promises'; import { promisify } from 'util'; import { execFile } from 'child_process'; import { join, normalize, extname } from 'path'; @@ -42,7 +42,7 @@ import type { SettingsChannel } from '../main/settingsChannel'; import type { LoggerType } from '../types/Logging'; import { getGotOptions } from './got'; -import { checkIntegrity } from './util'; +import { checkIntegrity, gracefulRename } from './util'; import type { PrepareDownloadResultType as DifferentialDownloadDataType } from './differential'; import { prepareDownload as prepareDifferentialDownload, @@ -99,6 +99,8 @@ export abstract class Updater { private activeDownload: Promise | undefined; + private markedCannotUpdate = false; + constructor( protected readonly logger: LoggerType, private readonly settingsChannel: SettingsChannel, @@ -156,6 +158,29 @@ export abstract class Updater { ipcMain.handleOnce('start-update', performUpdateCallback); } + protected markCannotUpdate( + error: Error, + dialogType = DialogType.Cannot_Update + ): void { + if (this.markedCannotUpdate) { + this.logger.warn( + 'updater/markCannotUpdate: already marked', + Errors.toLogFormat(error) + ); + return; + } + this.markedCannotUpdate = true; + + this.logger.error( + 'updater/markCannotUpdate: marking due to error: ' + + `${Errors.toLogFormat(error)}, ` + + `dialogType: ${dialogType}` + ); + + const mainWindow = this.getMainWindow(); + mainWindow?.webContents.send('show-update-dialog', dialogType); + } + // // Private methods // @@ -243,6 +268,7 @@ export abstract class Updater { return true; } catch (error) { logger.error(`downloadAndInstall: ${Errors.toLogFormat(error)}`); + this.markCannotUpdate(error); throw error; } } @@ -450,6 +476,9 @@ export abstract class Updater { const tempUpdatePath = join(tempDir, fileName); const tempBlockMapPath = join(tempDir, blockMapFileName); + // If true - we will attempt to install from a temporary directory. + let tempPathFailover = false; + try { validatePath(cacheDir, targetUpdatePath); @@ -491,7 +520,7 @@ export abstract class Updater { // Move file into downloads directory try { - await rename(targetUpdatePath, tempUpdatePath); + await gracefulRename(this.logger, targetUpdatePath, tempUpdatePath); gotUpdate = true; } catch (error) { this.logger.error( @@ -561,16 +590,28 @@ export abstract class Updater { // Backup old files const restoreDir = await getTempDir(); - await rename(cacheDir, restoreDir); + await gracefulRename(this.logger, cacheDir, restoreDir); // Move the files into the final position try { - await rename(tempDir, cacheDir); + await gracefulRename(this.logger, tempDir, cacheDir); } catch (error) { - // Attempt to restore old files - await rename(restoreDir, cacheDir); + try { + // Attempt to restore old files + await gracefulRename(this.logger, restoreDir, cacheDir); + } catch (restoreError) { + this.logger.warn( + 'downloadUpdate: Failed to restore from backup folder, ignoring', + Errors.toLogFormat(restoreError) + ); + } - throw error; + this.logger.warn( + 'downloadUpdate: running update from a temporary folder due to error', + Errors.toLogFormat(error) + ); + tempPathFailover = true; + return { updateFilePath: tempUpdatePath, signature }; } try { @@ -584,7 +625,9 @@ export abstract class Updater { return { updateFilePath: targetUpdatePath, signature }; } finally { - await deleteTempDir(tempDir); + if (!tempPathFailover) { + await deleteTempDir(tempDir); + } } } diff --git a/ts/updater/macos.ts b/ts/updater/macos.ts index 678ea2286..fefbfc9b7 100644 --- a/ts/updater/macos.ts +++ b/ts/updater/macos.ts @@ -29,26 +29,12 @@ export class MacOSUpdater extends Updater { } catch (error) { const readOnly = 'Cannot update while running on a read-only volume'; const message: string = error.message || ''; - const mainWindow = this.getMainWindow(); - if (mainWindow && message.includes(readOnly)) { - logger.info('downloadAndInstall: showing read-only dialog...'); - mainWindow.webContents.send( - 'show-update-dialog', - DialogType.MacOS_Read_Only - ); - } else if (mainWindow) { - logger.info( - 'downloadAndInstall: showing general update failure dialog...' - ); - mainWindow.webContents.send( - 'show-update-dialog', - DialogType.Cannot_Update - ); - } else { - logger.warn( - 'downloadAndInstall: no mainWindow, cannot show update dialog' - ); - } + this.markCannotUpdate( + error, + message.includes(readOnly) + ? DialogType.MacOS_Read_Only + : DialogType.Cannot_Update + ); throw error; } diff --git a/ts/updater/util.ts b/ts/updater/util.ts index b1b038e34..5bff132ea 100644 --- a/ts/updater/util.ts +++ b/ts/updater/util.ts @@ -2,10 +2,15 @@ // SPDX-License-Identifier: AGPL-3.0-only import { createReadStream } from 'fs'; +import { rename } from 'fs/promises'; import { pipeline } from 'stream/promises'; import { createHash } from 'crypto'; import * as Errors from '../types/errors'; +import type { LoggerType } from '../types/Logging'; +import * as durations from '../util/durations'; +import { isOlderThan } from '../util/timestamp'; +import { sleep } from '../util/sleep'; export type CheckIntegrityResultType = Readonly< | { @@ -42,3 +47,76 @@ export async function checkIntegrity( }; } } + +async function doGracefulRename({ + logger, + fromPath, + toPath, + startedAt, + retryCount, + retryAfter = 5 * durations.SECOND, + timeout = 5 * durations.MINUTE, +}: { + logger: LoggerType; + fromPath: string; + toPath: string; + startedAt: number; + retryCount: number; + retryAfter?: number; + timeout?: number; +}): Promise { + try { + await rename(fromPath, toPath); + + if (retryCount !== 0) { + logger.info( + `gracefulRename: succeeded after ${retryCount} retries, renamed ` + + `${fromPath} to ${toPath}` + ); + } + } catch (error) { + if (error.code !== 'EACCESS' && error.code !== 'EPERM') { + throw error; + } + + if (isOlderThan(startedAt, timeout)) { + logger.warn( + 'gracefulRename: timed out while retrying renaming ' + + `${fromPath} to ${toPath}` + ); + throw error; + } + + logger.warn( + `gracefulRename: got ${error.code} when renaming ` + + `${fromPath} to ${toPath}, retrying in one second. ` + + `(retryCount=${retryCount})` + ); + + await sleep(retryAfter); + + return doGracefulRename({ + logger, + fromPath, + toPath, + startedAt, + retryCount: retryCount + 1, + retryAfter, + timeout, + }); + } +} + +export async function gracefulRename( + logger: LoggerType, + fromPath: string, + toPath: string +): Promise { + return doGracefulRename({ + logger, + fromPath, + toPath, + startedAt: Date.now(), + retryCount: 0, + }); +} diff --git a/ts/updater/windows.ts b/ts/updater/windows.ts index 9b0aac477..bfdb2e52d 100644 --- a/ts/updater/windows.ts +++ b/ts/updater/windows.ts @@ -11,7 +11,6 @@ import pify from 'pify'; import { Updater } from './common'; import { markShouldQuit } from '../../app/window_state'; -import { DialogType } from '../types/Dialogs'; const readdir = pify(readdirCallback); const unlink = pify(unlinkCallback); @@ -54,18 +53,7 @@ export class WindowsUpdater extends Updater { await this.install(updateFilePath); this.installing = true; } catch (error) { - const mainWindow = this.getMainWindow(); - if (mainWindow) { - logger.info( - 'createUpdater: showing general update failure dialog...' - ); - mainWindow.webContents.send( - 'show-update-dialog', - DialogType.Cannot_Update - ); - } else { - logger.warn('createUpdater: no mainWindow, just failing over...'); - } + this.markCannotUpdate(error); throw error; }