Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 40 additions & 39 deletions lib/internal/repl/history.js
Original file line number Diff line number Diff line change
Expand Up @@ -79,10 +79,14 @@ class ReplHistory {
initialize(onReadyCallback) {
// Empty string disables persistent history
if (this[kHistoryPath] === '') {
// Save a reference to the context's original _historyPrev
this.historyPrev = this[kContext]._historyPrev;
this[kContext]._historyPrev = this[kReplHistoryMessage].bind(this);
return onReadyCallback(null, this[kContext]);

try {
return onReadyCallback(null, this[kContext]);
} catch (err) {
process.nextTick(() => { throw err; });
}
}

const resolvedPath = this[kResolveHistoryPath]();
Expand All @@ -93,10 +97,14 @@ class ReplHistory {
'REPL session history will not be persisted.\n',
);

// Save a reference to the context's original _historyPrev
this.historyPrev = this[kContext]._historyPrev;
this[kContext]._historyPrev = this[kReplHistoryMessage].bind(this);
return onReadyCallback(null, this[kContext]);

try {
return onReadyCallback(null, this[kContext]);
} catch (err) {
process.nextTick(() => { throw err; });
}
}

if (!this[kHasWritePermission]()) {
Expand All @@ -105,7 +113,12 @@ class ReplHistory {
'\nAccess to FileSystemWrite is restricted.\n' +
'REPL session history will not be persisted.\n',
);
return onReadyCallback(null, this[kContext]);

try {
return onReadyCallback(null, this[kContext]);
} catch (err) {
process.nextTick(() => { throw err; });
}
}

this[kContext].pause();
Expand All @@ -120,35 +133,26 @@ class ReplHistory {

if (line.length === 0) return '';

// If the history is disabled then return the line
if (this[kSize] === 0) return line;

// If the trimmed line is empty then return the line
if (StringPrototypeTrim(line).length === 0) return line;

// This is necessary because each line would be saved in the history while creating
// a new multiline, and we don't want that.
if (isMultiline && this[kIndex] === -1) {
ArrayPrototypeShift(this[kHistory]);
} else if (lastCommandErrored) {
// If the last command errored and we are trying to edit the history to fix it
// remove the broken one from the history
ArrayPrototypeShift(this[kHistory]);
}

const normalizedLine = ReplHistory[kNormalizeLineEndings](line, '\n', '\r');

if (this[kHistory].length === 0 || this[kHistory][0] !== normalizedLine) {
if (this[kRemoveHistoryDuplicates]) {
// Remove older history line if identical to new one
const dupIndex = ArrayPrototypeIndexOf(this[kHistory], line);
if (dupIndex !== -1) ArrayPrototypeSplice(this[kHistory], dupIndex, 1);
}

// Add the new line to the history
ArrayPrototypeUnshift(this[kHistory], normalizedLine);

// Only store so many
if (this[kHistory].length > this[kSize])
ArrayPrototypePop(this[kHistory]);
}
Expand All @@ -157,10 +161,6 @@ class ReplHistory {

const finalLine = isMultiline ? reverseString(this[kHistory][0]) : this[kHistory][0];

// The listener could change the history object, possibly
// to remove the last added entry if it is sensitive and should
// not be persisted in the history, like a password
// Emit history event to notify listeners of update
this[kContext].emit('history', this[kHistory]);

return finalLine;
Expand Down Expand Up @@ -229,8 +229,6 @@ class ReplHistory {
get index() { return this[kIndex]; }
set index(value) { this[kIndex] = value; }

// Start private methods

static [kGetHistoryPath](options) {
let historyPath = options.filePath;
if (typeof historyPath === 'string') {
Expand All @@ -240,13 +238,6 @@ class ReplHistory {
}

static [kNormalizeLineEndings](line, from, to) {
// Multiline history entries are saved reversed
// History is structured with the newest entries at the top
// and the oldest at the bottom. Multiline histories, however, only occupy
// one line in the history file. When loading multiline history with
// an old node binary, the history will be saved in the old format.
// This is why we need to reverse the multilines.
// Reversing the multilines is necessary when adding / editing and displaying them
return reverseString(line, from, to);
}

Expand Down Expand Up @@ -288,9 +279,6 @@ class ReplHistory {

async [kInitializeHistory](onReadyCallback) {
try {
// Open and close file first to ensure it exists
// History files are conventionally not readable by others
// 0o0600 = read/write for owner only
const hnd = await fs.promises.open(this[kHistoryPath], 'a+', 0o0600);
await hnd.close();

Expand Down Expand Up @@ -320,7 +308,11 @@ class ReplHistory {
this[kContext].once('flushHistory', () => {
if (!this[kContext].closed) {
this[kContext].resume();
onReadyCallback(null, this[kContext]);
try {
onReadyCallback(null, this[kContext]);
} catch (err) {
process.nextTick(() => { throw err; });
}
}
});

Expand All @@ -331,20 +323,22 @@ class ReplHistory {
}

[kHandleHistoryInitError](err, onReadyCallback) {
// Cannot open history file.
// Don't crash, just don't persist history.
ReplHistory[kWriteToOutput](
this[kContext],
'\nError: Could not open history file.\n' +
'REPL session history will not be persisted.\n',
);
debug(err.stack);

// Save a reference to the context's original _historyPrev
this.historyPrev = this[kContext]._historyPrev;
this[kContext]._historyPrev = this[kReplHistoryMessage].bind(this);
this[kContext].resume();
return onReadyCallback(null, this[kContext]);

try {
return onReadyCallback(null, this[kContext]);
} catch (err2) {
process.nextTick(() => { throw err2; });
}
}

[kOnLine]() {
Expand Down Expand Up @@ -402,6 +396,7 @@ class ReplHistory {
}
}

// === FIXED VERSION FOR ISSUE #60837 ===
[kReplHistoryMessage]() {
if (this[kHistory].length === 0) {
ReplHistory[kWriteToOutput](
Expand All @@ -411,10 +406,16 @@ class ReplHistory {
'a valid, user-writable path to enable.\n',
);
}
// First restore the original method on the context
this[kContext]._historyPrev = this.historyPrev;
// Then call it with the correct context
return this[kContext]._historyPrev();

const prev = this.historyPrev;
this[kContext]._historyPrev = prev;

// Fix: Propagate callback errors
try {
return prev.call(this[kContext]);
} catch (err) {
process.nextTick(() => { throw err; });
}
}
}

Expand Down
26 changes: 26 additions & 0 deletions test/parallel/test-repl-setup-history-error.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
'use strict';
const common = require('../common');

Check failure on line 2 in test/parallel/test-repl-setup-history-error.js

View workflow job for this annotation

GitHub Actions / lint-js-and-md

'common' is assigned a value but never used
const assert = require('assert');
const { spawnSync } = require('child_process');

// This test verifies that setupHistory callback errors propagate.
// On main branch (without your fix), this test MUST FAIL.
// After your patch, it MUST PASS.

const child = spawnSync(process.execPath, ['-e', `
const repl = require('repl');
const server = repl.start({ prompt: "" });
server.setupHistory('/tmp/history-test', (err) => {
throw new Error("INTENTIONAL_CALLBACK_THROW");
});
`], {
encoding: 'utf8'
});

// The bug: before your fix, Node *does NOT* propagate the thrown error,
// so stderr does NOT contain the message.
// After your fix, stderr WILL contain INTENTIONAL_CALLBACK_THROW.

assert.match(child.stderr, /INTENTIONAL_CALLBACK_THROW/,

Check failure on line 24 in test/parallel/test-repl-setup-history-error.js

View workflow job for this annotation

GitHub Actions / lint-js-and-md

Trailing spaces not allowed
'setupHistory callback error did NOT propagate');

Check failure on line 25 in test/parallel/test-repl-setup-history-error.js

View workflow job for this annotation

GitHub Actions / lint-js-and-md

Expected indentation of 13 spaces but found 2

Check failure on line 26 in test/parallel/test-repl-setup-history-error.js

View workflow job for this annotation

GitHub Actions / lint-js-and-md

Too many blank lines at the end of file. Max of 0 allowed
Loading