Graceful renames, better errors in updater

This commit is contained in:
Fedor Indutny 2022-03-28 12:05:44 -07:00 committed by GitHub
parent a0ae7c1aa2
commit acda5b2cb3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 137 additions and 42 deletions

View File

@ -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<boolean> | 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);
}
}
}

View File

@ -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;
}

View File

@ -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<void> {
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<void> {
return doGracefulRename({
logger,
fromPath,
toPath,
startedAt: Date.now(),
retryCount: 0,
});
}

View File

@ -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;
}