Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

"Fix" repl race condition #1677

Closed
wants to merge 9 commits into from
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -387,7 +387,7 @@ bench-idle:
./$(NODE_EXE) benchmark/idle_clients.js &

jslint:
./$(NODE_EXE) tools/eslint/bin/eslint.js src/*.js lib/*.js --reset --quiet
./$(NODE_EXE) tools/eslint/bin/eslint.js src/*.js lib/*.js lib/internal/*.js --reset --quiet

CPPLINT_EXCLUDE ?=
CPPLINT_EXCLUDE += src/node_lttng.cc
Expand Down
123 changes: 95 additions & 28 deletions lib/internal/repl.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
const Interface = require('readline').Interface;
const REPL = require('repl');
const path = require('path');
const util = require('util');

const debug = util.debuglog('repl');

module.exports = Object.create(REPL);
module.exports.createInternalRepl = createRepl;
Expand All @@ -17,16 +20,18 @@ function replStart() {
return REPL.start.apply(REPL, arguments);
}

function createRepl(env, cb) {
function createRepl(env, attemptPersistentHistory, cb) {
const opts = {
ignoreUndefined: false,
terminal: process.stdout.isTTY,
useGlobal: true
};

if (parseInt(env.NODE_NO_READLINE)) {
opts.terminal = false;
}
if (parseInt(env.NODE_FORCE_READLINE)) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't we use the radix here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It shouldn't be a problem in this case – we're just detecting whether the number is "0" or not.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Then we can explicitly do that right? As it is, the intention of this code is not clear, IMO.

opts.terminal = true;
}
if (parseInt(env.NODE_DISABLE_COLORS)) {
opts.useColors = false;
}
Expand All @@ -51,8 +56,13 @@ function createRepl(env, cb) {
}

const repl = REPL.start(opts);
if (opts.terminal && env.NODE_REPL_HISTORY_FILE) {
return setupHistory(repl, env.NODE_REPL_HISTORY_FILE, cb);
if (env.NODE_REPL_HISTORY_FILE && attemptPersistentHistory) {
return setupHistory(repl, env.NODE_REPL_HISTORY_FILE, function(err) {
if (err) {
repl.close();
}
cb(err, repl);
});
}
repl._historyPrev = _replHistoryMessage;
cb(null, repl);
Expand All @@ -64,27 +74,14 @@ function setupHistory(repl, historyPath, ready) {
var writing = false;
var pending = false;
repl.pause();
fs.open(historyPath, 'a+', oninit);

function oninit(err, hnd) {
if (err) {
return ready(err);
}
fs.close(hnd, onclose);
}

function onclose(err) {
if (err) {
return ready(err);
}
fs.readFile(historyPath, 'utf8', onread);
}
fs.readFile(historyPath, {encoding: 'utf8', flag: 'a+'}, ondata);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does 'a+' make sense when reading from a file?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Per the docs, I'm using "a+" for "create file if it doesn't exist, retain contents for reading if it does."


function onread(err, data) {
function ondata(err, data) {
if (err) {
return ready(err);
}

debug('loaded %s', historyPath);
if (data) {
try {
repl.history = JSON.parse(data);
Expand All @@ -98,17 +95,16 @@ function setupHistory(repl, historyPath, ready) {
}
}

fs.open(historyPath, 'w', onhandle);
getTempFile(ontmpfile);
}

function onhandle(err, hnd) {
function ontmpfile(err, tmpinfo) {
if (err) {
return ready(err);
}
repl._historyHandle = hnd;
repl._historyTempInfo = tmpinfo;
repl.on('line', online);

// reading the file data out erases it
repl.on('exit', removeTempFile);
repl.once('flushHistory', function() {
repl.resume();
ready(null, repl);
Expand All @@ -134,11 +130,11 @@ function setupHistory(repl, historyPath, ready) {
return;
}
writing = true;
const historyData = JSON.stringify(repl.history, null, 2);
fs.write(repl._historyHandle, historyData, 0, 'utf8', onwritten);
const historyData = JSON.stringify(repl.history || [], null, 2);
writeAndSwapTemp(historyData, repl._historyTempInfo, historyPath, onflushed);
}

function onwritten(err, data) {
function onflushed(err, data) {
writing = false;
if (pending) {
pending = false;
Expand All @@ -150,6 +146,17 @@ function setupHistory(repl, historyPath, ready) {
}
}
}

function removeTempFile() {
if (repl._flushing)
return repl.once('flushHistory', function() {
removeTempFile();
});
repl._flushing = true;
fs.unlink(repl._historyTempInfo.path, function() {
onflushed();
});
}
}


Expand All @@ -165,3 +172,63 @@ function _replHistoryMessage() {
this._historyPrev = Interface.prototype._historyPrev;
return this._historyPrev();
}


function getTempFile(ready) {
var tmpPath = os.tmpdir();
if (!tmpPath) {
return ready(new Error('no tmpdir available'));
}
tmpPath = path.join(tmpPath, `${process.pid}-node-repl.tmp`);
fs.open(tmpPath, 'wx', 0o600, function(err, fd) {
if (err) {
return ready(err);
}
return ready(null, {
fd: fd,
path: tmpPath
});
});
}

// Write data, sync the fd, close it, overwrite the target
// with a rename, and open a new tmpfile.
//
// We use fs.write instead of writeFile because the latter
// does not accept an fd at the time of writing.
function writeAndSwapTemp(data, tmpInfo, target, ready) {
debug('writing tmp file... %s', tmpInfo.path, data);
return fs.write(tmpInfo.fd, data, 0, 'utf8', onwritten);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seeing that you're using fs.readFile() above, maybe use fs.writeFile() here?

EDIT: Never mind, I thought that fs.writeFile() accepted a file descriptor these days but it doesn't.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a note about this so future humans know to change this if writeFile gains fd support.


function onwritten(err) {
if (err) return ready(err);
debug('syncing... %s', tmpInfo.path);
fs.fsync(tmpInfo.fd, onsync);
}

function onsync(err) {
if (err) return ready(err);
debug('closing... %s', tmpInfo.path);
fs.close(tmpInfo.fd, onclose);
}

function onclose(err) {
if (err) return ready(err);
debug('rename... %s -> %s', tmpInfo.path, target);
fs.rename(tmpInfo.path, target, onrename);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is a regrettable race condition here... maybe add a comment so people don't think it's an oversight.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A race condition between processes renaming their tmpfiles, or a race condition within the process itself (that I missed)?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I could swear I'd replied... a race window between creating and renaming the temporary file. In theory, something like a cron job can delete the file in that time window.

There is also possibly an issue with multiple iojs processes terminating at the same time, e.g. because the login session is terminated.

}

function onrename(err) {
if (err) return ready(err);
debug('re-loading...');
getTempFile(ontmpfile);
}

function ontmpfile(err, info) {
if (err) return ready(err);
tmpInfo.fd = info.fd;
tmpInfo.path = info.path;
debug('done!');
return ready(null);
}
}
15 changes: 13 additions & 2 deletions src/node.js
Original file line number Diff line number Diff line change
Expand Up @@ -131,9 +131,20 @@
if (process._forceRepl || NativeModule.require('tty').isatty(0)) {
// REPL
var cliRepl = Module.requireRepl();
cliRepl.createInternalRepl(process.env, function(err, repl) {
cliRepl.createInternalRepl(process.env, true, function(err, repl) {
if (err) {
throw err;
console.error('Encountered error with persistent history support.')
console.error('Run with NODE_DEBUG=repl for more information.');
if (/repl/.test(process.env.NODE_DEBUG || '')) {
console.error(err.stack);
}
return cliRepl.createInternalRepl(
process.env, false, function(err, repl) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems like an odd style choice, but make jslint didn't complain!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The linter is set up pretty conservative right now. Not sure it has a rule for odd linebreaks, but it seems fine to me there.

if (err) {
throw err;
}
repl.on('exit', process.exit);
});
}
repl.on('exit', function() {
if (repl._flushing) {
Expand Down
60 changes: 60 additions & 0 deletions test/parallel/test-internal-repl-race.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
var spawn = require('child_process').spawn;
var common = require('../common');
var assert = require('assert');
var path = require('path');
var os = require('os');
var fs = require('fs');

var filename = path.join(common.tmpDir, 'node-history.json');

var config = {
env: {
NODE_REPL_HISTORY_FILE: filename,
NODE_FORCE_READLINE: 1
}
}

var lhs = spawn(process.execPath, ['-i'], config);
var rhs = spawn(process.execPath, ['-i'], config);

lhs.stdout.pipe(process.stdout);
rhs.stdout.pipe(process.stdout);
lhs.stderr.pipe(process.stderr);
rhs.stderr.pipe(process.stderr);


var i = 0;
var tried = 0;
var exitcount = 0;
while (i++ < 100) {
lhs.stdin.write('hello = 0' + os.EOL);
rhs.stdin.write('OK = 0' + os.EOL);
}
lhs.stdin.write('\u0004')
rhs.stdin.write('\u0004')

lhs.once('exit', onexit)
rhs.once('exit', onexit)

function onexit() {
if (++exitcount !== 2) {
return;
}
check();
}

function check() {
fs.readFile(filename, 'utf8', function(err, data) {
if (err) {
console.log(err)
if (++tried > 100) {
throw err;
}
return setTimeout(check, 15);
}
assert.doesNotThrow(function() {
console.log(data)
JSON.parse(data);
})
})
}