Graceful renames, better errors in updater
This commit is contained in:
parent
a0ae7c1aa2
commit
acda5b2cb3
|
@ -4,7 +4,7 @@
|
||||||
/* eslint-disable no-console */
|
/* eslint-disable no-console */
|
||||||
import { createWriteStream } from 'fs';
|
import { createWriteStream } from 'fs';
|
||||||
import { pathExists } from 'fs-extra';
|
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 { promisify } from 'util';
|
||||||
import { execFile } from 'child_process';
|
import { execFile } from 'child_process';
|
||||||
import { join, normalize, extname } from 'path';
|
import { join, normalize, extname } from 'path';
|
||||||
|
@ -42,7 +42,7 @@ import type { SettingsChannel } from '../main/settingsChannel';
|
||||||
|
|
||||||
import type { LoggerType } from '../types/Logging';
|
import type { LoggerType } from '../types/Logging';
|
||||||
import { getGotOptions } from './got';
|
import { getGotOptions } from './got';
|
||||||
import { checkIntegrity } from './util';
|
import { checkIntegrity, gracefulRename } from './util';
|
||||||
import type { PrepareDownloadResultType as DifferentialDownloadDataType } from './differential';
|
import type { PrepareDownloadResultType as DifferentialDownloadDataType } from './differential';
|
||||||
import {
|
import {
|
||||||
prepareDownload as prepareDifferentialDownload,
|
prepareDownload as prepareDifferentialDownload,
|
||||||
|
@ -99,6 +99,8 @@ export abstract class Updater {
|
||||||
|
|
||||||
private activeDownload: Promise<boolean> | undefined;
|
private activeDownload: Promise<boolean> | undefined;
|
||||||
|
|
||||||
|
private markedCannotUpdate = false;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
protected readonly logger: LoggerType,
|
protected readonly logger: LoggerType,
|
||||||
private readonly settingsChannel: SettingsChannel,
|
private readonly settingsChannel: SettingsChannel,
|
||||||
|
@ -156,6 +158,29 @@ export abstract class Updater {
|
||||||
ipcMain.handleOnce('start-update', performUpdateCallback);
|
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
|
// Private methods
|
||||||
//
|
//
|
||||||
|
@ -243,6 +268,7 @@ export abstract class Updater {
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`downloadAndInstall: ${Errors.toLogFormat(error)}`);
|
logger.error(`downloadAndInstall: ${Errors.toLogFormat(error)}`);
|
||||||
|
this.markCannotUpdate(error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -450,6 +476,9 @@ export abstract class Updater {
|
||||||
const tempUpdatePath = join(tempDir, fileName);
|
const tempUpdatePath = join(tempDir, fileName);
|
||||||
const tempBlockMapPath = join(tempDir, blockMapFileName);
|
const tempBlockMapPath = join(tempDir, blockMapFileName);
|
||||||
|
|
||||||
|
// If true - we will attempt to install from a temporary directory.
|
||||||
|
let tempPathFailover = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
validatePath(cacheDir, targetUpdatePath);
|
validatePath(cacheDir, targetUpdatePath);
|
||||||
|
|
||||||
|
@ -491,7 +520,7 @@ export abstract class Updater {
|
||||||
|
|
||||||
// Move file into downloads directory
|
// Move file into downloads directory
|
||||||
try {
|
try {
|
||||||
await rename(targetUpdatePath, tempUpdatePath);
|
await gracefulRename(this.logger, targetUpdatePath, tempUpdatePath);
|
||||||
gotUpdate = true;
|
gotUpdate = true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
|
@ -561,16 +590,28 @@ export abstract class Updater {
|
||||||
|
|
||||||
// Backup old files
|
// Backup old files
|
||||||
const restoreDir = await getTempDir();
|
const restoreDir = await getTempDir();
|
||||||
await rename(cacheDir, restoreDir);
|
await gracefulRename(this.logger, cacheDir, restoreDir);
|
||||||
|
|
||||||
// Move the files into the final position
|
// Move the files into the final position
|
||||||
try {
|
try {
|
||||||
await rename(tempDir, cacheDir);
|
await gracefulRename(this.logger, tempDir, cacheDir);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Attempt to restore old files
|
try {
|
||||||
await rename(restoreDir, cacheDir);
|
// 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 {
|
try {
|
||||||
|
@ -584,7 +625,9 @@ export abstract class Updater {
|
||||||
|
|
||||||
return { updateFilePath: targetUpdatePath, signature };
|
return { updateFilePath: targetUpdatePath, signature };
|
||||||
} finally {
|
} finally {
|
||||||
await deleteTempDir(tempDir);
|
if (!tempPathFailover) {
|
||||||
|
await deleteTempDir(tempDir);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -29,26 +29,12 @@ export class MacOSUpdater extends Updater {
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const readOnly = 'Cannot update while running on a read-only volume';
|
const readOnly = 'Cannot update while running on a read-only volume';
|
||||||
const message: string = error.message || '';
|
const message: string = error.message || '';
|
||||||
const mainWindow = this.getMainWindow();
|
this.markCannotUpdate(
|
||||||
if (mainWindow && message.includes(readOnly)) {
|
error,
|
||||||
logger.info('downloadAndInstall: showing read-only dialog...');
|
message.includes(readOnly)
|
||||||
mainWindow.webContents.send(
|
? DialogType.MacOS_Read_Only
|
||||||
'show-update-dialog',
|
: DialogType.Cannot_Update
|
||||||
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'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,10 +2,15 @@
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import { createReadStream } from 'fs';
|
import { createReadStream } from 'fs';
|
||||||
|
import { rename } from 'fs/promises';
|
||||||
import { pipeline } from 'stream/promises';
|
import { pipeline } from 'stream/promises';
|
||||||
import { createHash } from 'crypto';
|
import { createHash } from 'crypto';
|
||||||
|
|
||||||
import * as Errors from '../types/errors';
|
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<
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
@ -11,7 +11,6 @@ import pify from 'pify';
|
||||||
|
|
||||||
import { Updater } from './common';
|
import { Updater } from './common';
|
||||||
import { markShouldQuit } from '../../app/window_state';
|
import { markShouldQuit } from '../../app/window_state';
|
||||||
import { DialogType } from '../types/Dialogs';
|
|
||||||
|
|
||||||
const readdir = pify(readdirCallback);
|
const readdir = pify(readdirCallback);
|
||||||
const unlink = pify(unlinkCallback);
|
const unlink = pify(unlinkCallback);
|
||||||
|
@ -54,18 +53,7 @@ export class WindowsUpdater extends Updater {
|
||||||
await this.install(updateFilePath);
|
await this.install(updateFilePath);
|
||||||
this.installing = true;
|
this.installing = true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const mainWindow = this.getMainWindow();
|
this.markCannotUpdate(error);
|
||||||
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...');
|
|
||||||
}
|
|
||||||
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue