readline: reduce createInterface overhead

Speed up Interface construction:

- Hoist the history accessor property descriptors to module scope and
  define them with a single ObjectDefineProperties call, instead of
  allocating six closures and four descriptor objects per instance.
- Stop assigning the history options onto the input stream. This avoids
  hidden class transitions on the user provided stream and no longer
  mutates it observably.
- Only check process.env.TERM for a dumb terminal when the interface is
  in terminal mode. Reading process.env goes through the environment
  interceptor and is comparatively expensive, and _ttyWrite is never
  called when terminal is false.

Signed-off-by: Matteo Collina <hello@matteocollina.com>
PR-URL: https://github.com/nodejs/node/pull/64585
Reviewed-By: James M Snell <jasnell@gmail.com>
Reviewed-By: Rafael Gonzaga <rafael.nunu@hotmail.com>
Reviewed-By: Yagiz Nizipli <yagiz@nizipli.com>
Reviewed-By: Benjamin Gruenbaum <benjamingr@gmail.com>
Reviewed-By: Gürgün Dayıoğlu <hey@gurgun.day>
This commit is contained in:
Matteo Collina authored and avivkeller committed 2026-07-24 09:09:01 -07:00
1 parent 5841155d92
commit 9041ad047a
3 files changed
+96 -52

No files matched your search

@@ -0,0 +1,26 @@
'use strict';
const common = require('../common.js');
const readline = require('readline');
const { Readable, Writable } = require('stream');
const bench = common.createBenchmark(main, {
n: [1e5],
terminal: [0, 1],
});
function main({ n, terminal }) {
bench.start();
for (let i = 0; i < n; i++) {
const input = new Readable({ read() {} });
const output = new Writable({ write(chunk, encoding, callback) {
callback();
} });
const rl = readline.createInterface({
input,
output,
terminal: Boolean(terminal),
});
rl.close();
}
bench.end(n);
}
+67 -51
View File
@@ -17,9 +17,10 @@ const {
MathMax,
MathMaxApply,
NumberIsFinite,
ObjectDefineProperty,
ObjectDefineProperties,
ObjectSetPrototypeOf,
RegExpPrototypeExec,
RegExpPrototypeSymbolSplit,
SafeStringIterator,
StringPrototypeCodePointAt,
StringPrototypeEndsWith,
@@ -172,6 +173,7 @@ function InterfaceConstructor(input, output, completer, terminal) {
let crlfDelay;
let prompt = '> ';
let signal;
let historyOptions;
if (input?.input) {
// An options object was given
@@ -210,12 +212,15 @@ function InterfaceConstructor(input, output, completer, terminal) {
crlfDelay = input.crlfDelay;
input = input.input;
input.size = historySize;
input.history = history;
input.removeHistoryDuplicates = removeHistoryDuplicates;
historyOptions = {
__proto__: null,
size: historySize,
history,
removeHistoryDuplicates,
};
}
this.setupHistoryManager(input);
this.setupHistoryManager(historyOptions ?? input);
if (completer !== undefined && typeof completer !== 'function') {
throw new ERR_INVALID_ARG_VALUE('completer', completer);
@@ -358,6 +363,30 @@ function InterfaceConstructor(input, output, completer, terminal) {
ObjectSetPrototypeOf(InterfaceConstructor.prototype, EventEmitter.prototype);
ObjectSetPrototypeOf(InterfaceConstructor, EventEmitter);
// Shared descriptors for the history accessors defined on each instance.
// Hoisted to avoid allocating fresh closures on every construction.
const kHistoryAccessorDescriptors = {
__proto__: null,
history: {
__proto__: null, configurable: true, enumerable: true,
get() { return this.historyManager.history; },
set(newHistory) { return this.historyManager.history = newHistory; },
},
historyIndex: {
__proto__: null, configurable: true, enumerable: true,
get() { return this.historyManager.index; },
set(historyIndex) { return this.historyManager.index = historyIndex; },
},
historySize: {
__proto__: null, configurable: true, enumerable: true,
get() { return this.historyManager.size; },
},
isFlushing: {
__proto__: null, configurable: true, enumerable: true,
get() { return this.historyManager.isFlushing; },
},
};
class Interface extends InterfaceConstructor {
get columns() {
if (this.output?.columns) return this.output.columns;
@@ -388,27 +417,7 @@ class Interface extends InterfaceConstructor {
this.historyManager.initialize(options.onHistoryFileLoaded);
}
ObjectDefineProperty(this, 'history', {
__proto__: null, configurable: true, enumerable: true,
get() { return this.historyManager.history; },
set(newHistory) { return this.historyManager.history = newHistory; },
});
ObjectDefineProperty(this, 'historyIndex', {
__proto__: null, configurable: true, enumerable: true,
get() { return this.historyManager.index; },
set(historyIndex) { return this.historyManager.index = historyIndex; },
});
ObjectDefineProperty(this, 'historySize', {
__proto__: null, configurable: true, enumerable: true,
get() { return this.historyManager.size; },
});
ObjectDefineProperty(this, 'isFlushing', {
__proto__: null, configurable: true, enumerable: true,
get() { return this.historyManager.isFlushing; },
});
ObjectDefineProperties(this, kHistoryAccessorDescriptors);
}
[kSetRawMode](mode) {
@@ -622,37 +631,44 @@ class Interface extends InterfaceConstructor {
this[kSawReturnAt] = 0;
}
// Run test() on the new string chunk, not on the entire line buffer.
let newPartContainsEnding = RegExpPrototypeExec(lineEnding, string);
if (newPartContainsEnding !== null) {
if (this[kLine_buffer]) {
string = this[kLine_buffer] + string;
this[kLine_buffer] = null;
lineEnding.lastIndex = 0; // Start the search from the beginning of the string.
newPartContainsEnding = RegExpPrototypeExec(lineEnding, string);
}
this[kSawReturnAt] = StringPrototypeEndsWith(string, '\r') ?
DateNow() :
0;
if (!string) {
return;
}
const indexes = [0, newPartContainsEnding.index, lineEnding.lastIndex];
let nextMatch;
while ((nextMatch = RegExpPrototypeExec(lineEnding, string)) !== null) {
ArrayPrototypePush(indexes, nextMatch.index, lineEnding.lastIndex);
}
const lastIndex = indexes.length - 1;
// Either '' or (conceivably) the unfinished portion of the next line
this[kLine_buffer] = StringPrototypeSlice(string, indexes[lastIndex]);
for (let i = 1; i < lastIndex; i += 2) {
this[kOnLine](StringPrototypeSlice(string, indexes[i - 1], indexes[i]));
}
} else if (string) {
// No newlines this time, save what we have for next time
// Split the new string chunk, not the entire line buffer: a single
// split pass avoids allocating a match object per line ending.
// When the chunk contains none of the rare line endings, a plain
// string split is much cheaper than the regular expression.
const lines =
StringPrototypeIncludes(string, '\r') ||
StringPrototypeIncludes(string, '\u2028') ||
StringPrototypeIncludes(string, '\u2029') ?
RegExpPrototypeSymbolSplit(lineEnding, string) :
StringPrototypeSplit(string, '\n');
const lastIndex = lines.length - 1;
if (lastIndex === 0) {
// No line endings this time, save what we have for next time.
if (this[kLine_buffer]) {
this[kLine_buffer] += string;
} else {
this[kLine_buffer] = string;
}
return;
}
this[kSawReturnAt] = StringPrototypeEndsWith(string, '\r') ?
DateNow() :
0;
let first = lines[0];
if (this[kLine_buffer]) {
first = this[kLine_buffer] + first;
}
// Either '' or (conceivably) the unfinished portion of the next line
this[kLine_buffer] = lines[lastIndex];
this[kOnLine](first);
for (let i = 1; i < lastIndex; i++) {
this[kOnLine](lines[i]);
}
}
+3 -1
View File
@@ -115,7 +115,9 @@ function Interface(input, output, completer, terminal) {
FunctionPrototypeCall(InterfaceConstructor, this,
input, output, completer, terminal);
if (process.env.TERM === 'dumb') {
// Reading process.env is expensive and _ttyWrite is only used in
// terminal mode, so only check for a dumb terminal when relevant.
if (this.terminal && process.env.TERM === 'dumb') {
this._ttyWrite = FunctionPrototypeBind(_ttyWriteDumb, this);
}
}