Upgrade improvements and refactor shutdown process #20

Closed
alexliebowitz wants to merge 7 commits from upgrades-2 into master
2 changed files with 191 additions and 108 deletions

View file

@ -1,20 +1,64 @@
const {app, BrowserWindow, ipcMain, shell} = require('electron');
const {app, BrowserWindow, ipcMain} = require('electron');
const path = require('path');
const jayson = require('jayson');
// tree-kill has better cross-platform handling of
// killing a process. child-process.kill was unreliable
const kill = require('tree-kill');
const child_process = require('child_process');
const assert = require('assert');
let client = jayson.client.http('http://localhost:5279/lbryapi');
// Keep a global reference of the window object, if you don't, the window will
// be closed automatically when the JavaScript object is garbage collected.
let win
let win;
// Also keep the daemon subprocess alive
let subpy
// set to true when the quitting sequence has started
let quitting = false;
let daemonSubprocess;
// This is set to true right before we try to shut the daemon subprocess --
// if it dies when we didn't ask it to shut down, we want to alert the user.
let daemonSubprocessKillRequested = false;
// When a quit is attempted, we cancel the quit, do some preparations, then
// this is set to true and app.quit() is called again to quit for real.
let readyToQuit = false;
/*
* Replacement for Electron's shell.openItem. The Electron version doesn't
* reliably work from the main process, and we need to be able to run it
* when no windows are open.
*/
function openItem(fullPath) {
const subprocOptions = {
detached: true,
stdio: 'ignore',
};
let child;
if (process.platform == 'darwin') {
child = child_process.spawn('open', [fullPath], subprocOptions);
} else if (process.platform == 'linux') {
child = child_process.spawn('xdg-open', [fullPath], subprocOptions);
} else if (process.platform == 'win32') {
child = child_process.execSync('start', [fullPath], subprocOptions);
}
// Causes child process reference to be garbage collected, allowing main process to exit
child.unref();
}
function getPidsForProcessName(name) {
if (process.platform == 'windows') {
const tasklistOut = child_process.execSync('tasklist',
['/fi', `Imagename eq ${name}.exe`, '/nh'],
{encoding: 'utf8'}
).stdout;
return tasklistOut.match(/[^\n]+/g).map((line) => line.split(/\s+/)[1]); // Second column of every non-empty line
lyoshenka commented 2017-03-24 02:42:37 +01:00 (Migrated from github.com)
Review

What does this mean? Did not display?

What does this mean? `Did not display`?
alexliebowitz commented 2017-03-24 03:01:18 +01:00 (Migrated from github.com)
Review

No idea where that came from. Maybe just a copy-and-paste error. I meant for it to say something like "did not intentionally quit daemon, so scheduling quit."

No idea where that came from. Maybe just a copy-and-paste error. I meant for it to say something like "did not intentionally quit daemon, so scheduling quit."
} else {
const pgrepOut = child_process.spawnSync('pgrep', ['-x', name], {encoding: 'utf8'}).stdout;
return pgrepOut.match(/\d+/g);
}
}
function createWindow () {
win = new BrowserWindow({backgroundColor: '#155b4a'})
@ -26,49 +70,61 @@ function createWindow () {
})
};
function handleDaemonSubprocessExited() {
console.log('The daemon has exited.');
daemonSubprocess = null;
if (!daemonSubprocessKillRequested) {
// We didn't stop the daemon subprocess on purpose, so display a
// warning and schedule a quit.
//
// TODO: maybe it would be better to restart the daemon?
if (win) {
console.log('Did not request daemon stop, so quitting in 5 seconds.');
win.loadURL(`file://${__dirname}/dist/warning.html`);
setTimeout(quitNow, 5000);
} else {
console.log('Did not request daemon stop, so quitting.');
quitNow();
}
}
}
function launchDaemon() {
if (subpy) {
return;
}
assert(!daemonSubprocess, 'Tried to launch daemon twice');
if (process.env.LBRY_DAEMON) {
executable = process.env.LBRY_DAEMON;
} else {
executable = path.join(__dirname, 'dist', 'lbrynet-daemon');
}
console.log('Launching daemon: ' + executable)
subpy = require('child_process').spawn(executable)
console.log('Launching daemon:', executable)
daemonSubprocess = child_process.spawn(executable)
// Need to handle the data event instead of attaching to
// process.stdout because the latter doesn't work. I believe on
// windows it buffers stdout and we don't get any meaningful output
subpy.stdout.on('data', (buf) => {console.log(String(buf).trim());});
subpy.stderr.on('data', (buf) => {console.log(String(buf).trim());});
subpy.on('exit', () => {
console.log('The daemon has exited. Quitting the app');
subpy = null;
if (quitting) {
// If quitting is True it means that we were expecting the daemon
// to be shutdown so we can quit right away
app.quit();
} else {
// Otherwise, this shutdown was a surprise so display a warning
// and schedule a quit
//
// TODO: maybe it would be better to restart the daemon?
win.loadURL(`file://${__dirname}/dist/warning.html`);
setTimeout(app.quit, 5000)
}
});
daemonSubprocess.stdout.on('data', (buf) => {console.log(String(buf).trim());});
daemonSubprocess.stderr.on('data', (buf) => {console.log(String(buf).trim());});
daemonSubprocess.on('exit', handleDaemonSubprocessExited);
console.log('lbrynet daemon has launched')
}
/*
* Quits without any preparation. When a quit is requested (either through the
* interface or through app.quit()), we abort the quit, try to shut down the daemon,
* and then call this to quit for real.
*/
function quitNow() {
readyToQuit = true;
app.quit();
}
app.on('ready', function(){
launchDaemonIfNotRunning();
alexliebowitz commented 2017-03-24 00:42:29 +01:00 (Migrated from github.com)
Review

We could probably use Promises to simplify the shutdown process so we're not calling quitNow in so many places, but for now my goal is to make the logic extremely explicit and easy to reason about.

We could probably use Promises to simplify the shutdown process so we're not calling quitNow in so many places, but for now my goal is to make the logic extremely explicit and easy to reason about.
lyoshenka commented 2017-03-24 02:43:32 +01:00 (Migrated from github.com)
Review

is this cross-platform?

is this cross-platform?
alexliebowitz commented 2017-03-24 03:09:07 +01:00 (Migrated from github.com)
Review

It'll work on Mac and Linux. Still thinking about Windows.

It'll work on Mac and Linux. Still thinking about Windows.
createWindow();
});
function launchDaemonIfNotRunning() {
// Check if the daemon is already running. If we get
// an error its because its not running
@ -86,6 +142,36 @@ function launchDaemonIfNotRunning() {
);
}
/*
* Last resort for killing unresponsive daemon instances.
* Looks for any processes called "lbrynet-daemon" and
* tries to force kill them.
*/
function forceKillAllDaemonsAndQuit() {
console.log('Attempting to force kill any running lbrynet-daemon instances...');
const daemonPids = getPidsForProcessName('lbrynet-daemon');
if (!daemonPids) {
console.log('No lbrynet-daemon found running.');
quitNow();
} else {
console.log(`Found ${daemonPids.length} running daemon instances. Attempting to force kill...`);
for (const pid of daemonPids) {
const daemonKillAttemptsComplete = 0;
kill(pid, 'SIGKILL', (err) => {
daemonKillAttemptsComplete++;
if (err) {
console.log(`Failed to force kill running daemon with pid ${pid}. Error message: ${err.message}`);
}
if (daemonKillAttemptsComplete >= daemonPids.length) {
quitNow();
}
});
}
}
}
// Quit when all windows are closed.
lyoshenka commented 2017-03-23 16:30:02 +01:00 (Migrated from github.com)
Review

does this work on osx and windows? or is this only called on linux?

does this work on osx and windows? or is this only called on linux?
lyoshenka commented 2017-03-23 16:31:16 +01:00 (Migrated from github.com)
Review

also, i find it hard to believe that we cant tell whether this worked or failed

also, i find it hard to believe that we cant tell whether this worked or failed
alexliebowitz commented 2017-03-23 17:06:17 +01:00 (Migrated from github.com)
Review

It's called everywhere, but yesterday I factored this out and made it choose the command based on OS. That'll go up with my next set of changes.

Re: checking if it worked or failed, we can detect some failure conditions (where the installer or package manager completely fails to launch, arguably the least likely scenario) by seeing if the process returns with an error status code. But we can't detect anything that happens after that, like getting that weird dialog box because of a user mismatch. But you're right, I'll add a check for an error code.

It's called everywhere, but yesterday I factored this out and made it choose the command based on OS. That'll go up with my next set of changes. Re: checking if it worked or failed, we can detect _some_ failure conditions (where the installer or package manager completely fails to launch, arguably the least likely scenario) by seeing if the process returns with an error status code. But we can't detect anything that happens after that, like getting that weird dialog box because of a user mismatch. But you're right, I'll add a check for an error code.
lyoshenka commented 2017-03-23 18:58:56 +01:00 (Migrated from github.com)
Review

gotcha

gotcha
app.on('window-all-closed', () => {
@ -98,12 +184,15 @@ app.on('window-all-closed', () => {
lyoshenka commented 2017-03-24 02:45:40 +01:00 (Migrated from github.com)
Review

reminder to remove this before merging

reminder to remove this before merging
app.on('before-quit', (event) => {
if (subpy == null) {
return
if (!readyToQuit) {
// We need to shutdown the daemon before we're ready to actually quit. This
// event will be triggered re-entrantly once preparation is done.
event.preventDefault();
shutdownDaemonAndQuit();
} else {
console.log('Quitting.')
}
event.preventDefault();
shutdownDaemon();
})
});
app.on('activate', () => {
@ -112,52 +201,60 @@ app.on('activate', () => {
if (win === null) {
createWindow()
}
})
});
function shutdownDaemon(evenIfNotStartedByApp = false) {
if (subpy) {
// When a quit is attempted, this is called. It attempts to shutdown the daemon,
// then calls quitNow() to quit for real.
function shutdownDaemonAndQuit(evenIfNotStartedByApp = false) {
if (daemonSubprocess) {
console.log('Killing lbrynet-daemon process');
kill(subpy.pid, undefined, (err) => {
kill(daemonSubprocess.pid, undefined, (err) => {
console.log('Killed lbrynet-daemon process');
requestedDaemonSubprocessKilled = true;
quitNow();
});
} else if (evenIfNotStartedByApp) {
console.log('Killing lbrynet-daemon, even though app did not start it');
client.request('daemon_stop', []);
// TODO: If the daemon errors or times out when we make this request, find
// the process and force quit it.
console.log('Stopping lbrynet-daemon, even though app did not start it');
client.request('daemon_stop', [], (err, res) => {
if (err) {
// We could get an error because the daemon is already stopped (good)
// or because it's running but not responding properly (bad).
// So try to force kill any daemons that are still running.
console.log(`received error when stopping lbrynet-daemon. Error message: ${err.message}`);
forceKillAllDaemonsAndQuit();
} else {
console.log('Successfully stopped daemon via RPC call.')
quitNow();
}
});
} else {
console.log('Not killing lbrynet-daemon because app did not start it')
console.log('Not killing lbrynet-daemon because app did not start it');
quitNow();
}
// Is it safe to start the installer before the daemon finishes running?
// If not, we should wait until the daemon is closed before we start the install.
}
function shutdown() {
if (win) {
win.loadURL(`file://${__dirname}/dist/quit.html`);
}
quitting = true;
shutdownDaemon();
}
function upgrade(event, installerPath) {
app.on('quit', () => {
shell.openItem(installerPath);
console.log('Launching upgrade installer at', installerPath);
// This gets triggered called after *all* other quit-related events, so
// we'll only get here if we're fully prepared and quitting for real.
openItem(installerPath);
});
if (win) {
win.loadURL(`file://${__dirname}/dist/upgrade.html`);
}
quitting = true;
shutdownDaemon(true);
app.quit();
// wait for daemon to shut down before upgrading
// what to do if no shutdown in a long time?
console.log('Update downloaded to ', installerPath);
console.log('Update downloaded to', installerPath);
console.log('The app will close, and you will be prompted to install the latest version of LBRY.');
console.log('After the install is complete, please reopen the app.');
}
ipcMain.on('upgrade', upgrade);
ipcMain.on('shutdown', shutdown);

View file

@ -24,9 +24,9 @@ import {Link} from './component/link.js';
const {remote, ipcRenderer, shell} = require('electron');
const {download} = remote.require('electron-dl');
const os = require('os');
const path = require('path');
const app = require('electron').remote.app;
const fs = remote.require('fs');
var App = React.createClass({
@ -42,14 +42,26 @@ var App = React.createClass({
_upgradeDownloadItem: null,
_version: null,
// Temporary workaround since electron-dl throws errors when you try to get the filename
getUpdateUrl: function() {
switch (process.platform) {
case 'darwin':
return 'https://lbry.io/get/lbry.dmg';
case 'linux':
return 'https://lbry.io/get/lbry.deb';
case 'win32':
return 'https://lbry.io/get/lbry.exe';
}
},
// Hard code the filenames as a temporary workaroound, because
// electron-dl throws errors when you try to get the filename
getUpgradeFilename: function() {
if (os.platform() == 'darwin') {
return `LBRY-${this._version}.dmg`;
} else if (os.platform() == 'linux') {
return `LBRY-${this._version}.deb`;
} else {
return `LBRY-${this._version}_amd64.deb`;
switch (process.platform) {
case 'darwin':
return `LBRY-${this._version}.dmg`;
case 'linux':
return `LBRY_${this._version}_amd64.deb`;
case 'windows':
return `LBRY.Setup.${this._version}.exe`;
}
},
getInitialState: function() {
@ -66,8 +78,6 @@ var App = React.createClass({
pageArgs: typeof val !== 'undefined' ? val : null,
errorInfo: null,
modal: null,
updateUrl: null,
isOldOSX: null,
downloadProgress: null,
downloadComplete: false,
};
@ -90,38 +100,20 @@ var App = React.createClass({
}
});
lbry.checkNewVersionAvailable((isAvailable) => {
if (!isAvailable || sessionStorage.getItem('upgradeSkipped')) {
return;
}
lbry.getVersionInfo((versionInfo) => {
this._version = versionInfo.lbrynet_version; // temp for building upgrade filename
var isOldOSX = false;
if (versionInfo.os_system == 'Darwin') {
var updateUrl = 'https://lbry.io/get/lbry.dmg';
var maj, min, patch;
[maj, min, patch] = versionInfo.lbrynet_version.split('.');
if (maj == 0 && min <= 2 && patch <= 2) {
isOldOSX = true;
}
} else if (versionInfo.os_system == 'Linux') {
var updateUrl = 'https://lbry.io/get/lbry.deb';
} else if (versionInfo.os_system == 'Windows') {
var updateUrl = 'https://lbry.io/get/lbry.exe';
} else {
var updateUrl = 'https://lbry.io/get';
if (!sessionStorage.getItem('upgradeSkipped')) {
lbry.checkNewVersionAvailable(({isAvailable}) => {
if (!isAvailable) {
return;
}
this.setState({
modal: 'upgrade',
isOldOSX: isOldOSX,
updateUrl: updateUrl,
})
lbry.getVersionInfo((versionInfo) => {
this._version = versionInfo.lbrynet_version;
this.setState({
modal: 'upgrade',
});
});
});
});
}
},
openDrawer: function() {
sessionStorage.setItem('drawerOpen', true);
@ -137,16 +129,14 @@ var App = React.createClass({
});
},
handleUpgradeClicked: function() {
// TODO: create a callback for onProgress and have the UI
// show download progress
// TODO: calling lbry.stop() ends up displaying the "daemon
// unexpectedly stopped" page. Have a better way of shutting down
let dir = app.getPath('temp');
// Make a new directory within temp directory so the filename is guaranteed to be available
const dir = fs.mkdtempSync(app.getPath('temp') + require('path').sep);
let options = {
onProgress: (p) => this.setState({downloadProgress: Math.round(p * 100)}),
directory: dir,
};
download(remote.getCurrentWindow(), this.state.updateUrl, options)
download(remote.getCurrentWindow(), this.getUpdateUrl(), options)
.then(downloadItem => {
/**
* TODO: get the download path directly from the download object. It should just be
@ -289,11 +279,7 @@ var App = React.createClass({
<Modal isOpen={this.state.modal == 'upgrade'} contentLabel="Update available"
type="confirm" confirmButtonLabel="Upgrade" abortButtonLabel="Skip"
onConfirmed={this.handleUpgradeClicked} onAborted={this.handleSkipClicked}>
<p>Your version of LBRY is out of date and may be unreliable or insecure.</p>
{this.state.isOldOSX
? <p>Before installing the new version, make sure to exit LBRY. If you started the app, click the LBRY icon in your status bar and choose "Quit."</p>
: null}
Your version of LBRY is out of date and may be unreliable or insecure.
</Modal>
<Modal isOpen={this.state.modal == 'downloading'} contentLabel="Downloading Update" type="custom">
Downloading Update{this.state.downloadProgress ? `: ${this.state.downloadProgress}%` : null}