Graceful renames, better errors in updater
This commit is contained in:
parent
a0ae7c1aa2
commit
acda5b2cb3
4 changed files with 137 additions and 42 deletions
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue