Skip to content

repl: catch promise errors during eval in completion #58943

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

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
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
44 changes: 41 additions & 3 deletions lib/repl.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
'use strict';

const {
ArrayIsArray,
ArrayPrototypeAt,
ArrayPrototypeFilter,
ArrayPrototypeFindLastIndex,
Expand Down Expand Up @@ -98,6 +99,7 @@ const {

const {
isProxy,
isPromise,
} = require('internal/util/types');

const { BuiltinModule } = require('internal/bootstrap/realm');
Expand Down Expand Up @@ -351,6 +353,7 @@ function REPLServer(prompt,
this.allowBlockingCompletions = !!options.allowBlockingCompletions;
this.useColors = !!options.useColors;
this._domain = options.domain || domain.create();
this._completeDomain = domain.create();
Copy link
Member

Choose a reason for hiding this comment

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

why is this needed? the domain API is deprecated so I think that we should ideally use it as less as possible (although it is already quite pervasive in the repl logic... but I think we should not make the issue worse if we can help it)

Copy link
Member Author

Choose a reason for hiding this comment

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

Thank you for taking a look.
This is the reason for it:
#58943 (comment)

Copy link
Member

Choose a reason for hiding this comment

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

I've completely removed this _completeDomain from the source code (alongside completeEval) and also from the test files

Diff

For completeness this is my whole diff:

diff --git a/lib/repl.js b/lib/repl.js
index 49848069063..6e7643c18e7 100644
--- a/lib/repl.js
+++ b/lib/repl.js
@@ -353,7 +353,6 @@ function REPLServer(prompt,
   this.allowBlockingCompletions = !!options.allowBlockingCompletions;
   this.useColors = !!options.useColors;
   this._domain = options.domain || domain.create();
-  this._completeDomain = domain.create();
   this.useGlobal = !!useGlobal;
   this.ignoreUndefined = !!ignoreUndefined;
   this.replMode = replMode || module.exports.REPL_MODE_SLOPPY;
@@ -671,8 +670,6 @@ function REPLServer(prompt,
   }
 
   self.eval = self._domain.bind(eval_);
-  self.completeEval = self._completeDomain.bind(eval_);
-  self._completeDomain.on('error', (err) => { });
 
   self._domain.on('error', function debugDomainError(e) {
     debug('domain error');
@@ -1546,7 +1543,7 @@ function complete(line, callback) {
     return includesProxiesOrGetters(
       completeTargetAst.body[0].expression,
       parsableCompleteTarget,
-      this.completeEval,
+      this.eval,
       this.context,
       (includes) => {
         if (includes) {
@@ -1563,7 +1560,7 @@ function complete(line, callback) {
 
         const memberGroups = [];
         const evalExpr = `try { ${expr} } catch {}`;
-        this.completeEval(evalExpr, this.context, getREPLResourceName(), (e, obj) => {
+        this.eval(evalExpr, this.context, getREPLResourceName(), (e, obj) => {
           try {
             reclusiveCatchPromise(obj);
             let p;
diff --git a/test/fixtures/repl-tab-completion-nested-repls.js b/test/fixtures/repl-tab-completion-nested-repls.js
index 5a0c64d60a8..1d2b154f2b3 100644
--- a/test/fixtures/repl-tab-completion-nested-repls.js
+++ b/test/fixtures/repl-tab-completion-nested-repls.js
@@ -32,7 +32,7 @@ const putIn = new ArrayStream();
 const testMe = repl.start('', putIn);
 
 // Some errors are passed to the domain, but do not callback.
-testMe._completeDomain.on('error', function(err) {
+testMe._domain.on('error', function(err) {
   throw err;
 });
 
diff --git a/test/parallel/test-repl-tab-complete-getter-error.js b/test/parallel/test-repl-tab-complete-getter-error.js
index 5ea9e4266f1..e2e36b85c58 100644
--- a/test/parallel/test-repl-tab-complete-getter-error.js
+++ b/test/parallel/test-repl-tab-complete-getter-error.js
@@ -21,7 +21,7 @@ async function runTest() {
     terminal: true
   });
 
-  replServer._completeDomain.on('error', (e) => {
+  replServer._domain.on('error', (e) => {
     assert.fail(`Error in REPL domain: ${e}`);
   });
 
diff --git a/test/parallel/test-repl-tab-complete-promises.js b/test/parallel/test-repl-tab-complete-promises.js
index b2225f8b03c..86aac9b508b 100644
--- a/test/parallel/test-repl-tab-complete-promises.js
+++ b/test/parallel/test-repl-tab-complete-promises.js
@@ -64,13 +64,13 @@ async function runReplCompleteTests(tests) {
     if (completeError) {
       completeErrorPromise = new Promise((resolve) => {
         const handleError = () => {
-          replServer._completeDomain.removeListener('error', handleError);
+          replServer._domain.removeListener('error', handleError);
           resolve();
         };
-        replServer._completeDomain.on('error', handleError);
+        replServer._domain.on('error', handleError);
       });
     } else {
-      replServer._completeDomain.on('error', onError);
+      replServer._domain.on('error', onError);
     }
 
     await replServer.complete(
@@ -88,7 +88,7 @@ async function runReplCompleteTests(tests) {
     if (!completeError) {
       await new Promise((resolve) => {
         setImmediate(() => {
-          replServer._completeDomain.removeListener('error', onError);
+          replServer._domain.removeListener('error', onError);
           resolve();
         });
       });
diff --git a/test/parallel/test-repl-tab-complete.js b/test/parallel/test-repl-tab-complete.js
index d77067cb8f5..f37916e30d8 100644
--- a/test/parallel/test-repl-tab-complete.js
+++ b/test/parallel/test-repl-tab-complete.js
@@ -44,7 +44,7 @@ function prepareREPL() {
   });
 
   // Some errors are passed to the domain, but do not callback
-  replServer._completeDomain.on('error', assert.ifError);
+  replServer._domain.on('error', assert.ifError);
 
   return { replServer, input };
 }

After that (and rebuilding node of course) it looks like all the reply tests are still passing?
Screenshot at 2025-07-20 20-00-30

am I missing something?

Copy link
Member Author

Choose a reason for hiding this comment

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

I don't think the test will fail.

     if (completeError) {
       completeErrorPromise = new Promise((resolve) => {
         const handleError = () => {
-          replServer._completeDomain.removeListener('error', handleError);
+          replServer._domain.removeListener('error', handleError);
           resolve();
         };
-        replServer._completeDomain.on('error', handleError);
+        replServer._domain.on('error', handleError);
       });
     } else {
-      replServer._completeDomain.on('error', onError);
+      replServer._domain.on('error', onError);
     }

However, the problem is that this test throws an error in replServer._domain.
When completeError is true, an error is expected.
If the error occurs in replServer._domain, it can cause an infinite.

If _domain is not used for completion, errors won't affect the REPL even if they occur.

Copy link
Member Author

Choose a reason for hiding this comment

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

@dario-piotrowicz
Just a gentle ping, no hurry.

Copy link
Member Author

@islandryu islandryu Jul 29, 2025

Choose a reason for hiding this comment

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

That's exactly right, I'll give it a try.

However, I'm concerned that completely eliminating this issue might require extensive AST traversal, potentially excluding a large number of functions from execution.
For example, this issue can also occur in cases like the following:

async function foo() { fetch("") };
function bar() { foo() };
bar().

This means we would need to traverse all called functions — including transitively called functions — and if any part involves an async function, we would have to exclude it from execution.

Copy link
Member Author

Choose a reason for hiding this comment

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

Anyway, I'll see if there's an easy way to make this work.

Copy link
Member

Choose a reason for hiding this comment

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

That's exactly right, I'll give it a try.

However, I'm concerned that completely eliminating this issue might require extensive AST traversal, potentially excluding a large number of functions from execution. For example, this issue can also occur in cases like the following:

async function foo() { fetch("") };
function bar() { foo() };
bar().

This means we would need to traverse all called functions — including transitively called functions — and if any part involves an async function, we would have to exclude it from execution.

I am not sure I follow, why would traversal be needed?
If we encounter bar(). we see that it's a member accessor on a function call and stop there, I don't think there is any need to then traverse the "call stack"? 🤔

Regarding completely eliminating the issue I feel like there is a limit to what the REPL can do (and there are already a few things that it's not great at) and I think it's ok for it not to covert every possible small edge case. There are cases like the one you presented in your comment, that I struggle to imagine people actually unintentionally encountering, so at the end of the day my personal opinion is that if some solutions require a significant effort to maintain and implement and/or have significant drawbacks but almost no one will ever need them then there it's not really worth investing time and effort into them (or possibly trying to solve them only after people legitimately encounter them and open issues regarding them).

Copy link
Member Author

Choose a reason for hiding this comment

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

I had assumed that we would traverse the function to check whether it could throw an async error, and only in that case disable completion.
But are you saying that, regardless of the function’s contents, completion is simply disabled for a member accessor on a function call?

Copy link
Member

Choose a reason for hiding this comment

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

Yes, and we do exactly that for getter functions

for example, right now if you do something like:

({ get foo() { return 5; } }).

we don't complete that because foo is a getter and as such it can be side effectful so we don't want to run it at all (and we don't try to traverse it and understand what it might do etc...) (see: code)

And I think your example is exactly the same thing, the only difference being that in your example bar is not a getter.

Copy link
Member Author

@islandryu islandryu Jul 20, 2025

Choose a reason for hiding this comment

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

_completeDomain is for handling errors that cannot be caught.
https://github.com/nodejs/node/pull/58943/files#diff-24ded3e78d8bb56bd21deb058a4766995ee891242cf993a9a185c57213ce38c0R21-R23
For instance, in this test, baz[getPropText()] is evaluated as part of the completion process, but since it doesn't return a Promise object, the error cannot be caught.
Because the error is unavoidable in this situation, I've chosen to run it in a separate domain to prevent it from impacting the main domain.

However, I'm not entirely confident that this is the correct or most appropriate approach, so it might be better to leave this type of case as a TODO for now and hold off on implementing a fix.

this.useGlobal = !!useGlobal;
this.ignoreUndefined = !!ignoreUndefined;
this.replMode = replMode || module.exports.REPL_MODE_SLOPPY;
Expand Down Expand Up @@ -668,6 +671,8 @@ function REPLServer(prompt,
}

self.eval = self._domain.bind(eval_);
self.completeEval = self._completeDomain.bind(eval_);
self._completeDomain.on('error', (err) => { });

self._domain.on('error', function debugDomainError(e) {
debug('domain error');
Expand Down Expand Up @@ -1541,7 +1546,7 @@ function complete(line, callback) {
return includesProxiesOrGetters(
completeTargetAst.body[0].expression,
parsableCompleteTarget,
this.eval,
this.completeEval,
this.context,
(includes) => {
if (includes) {
Expand All @@ -1558,8 +1563,9 @@ function complete(line, callback) {

const memberGroups = [];
const evalExpr = `try { ${expr} } catch {}`;
this.eval(evalExpr, this.context, getREPLResourceName(), (e, obj) => {
this.completeEval(evalExpr, this.context, getREPLResourceName(), (e, obj) => {
try {
reclusiveCatchPromise(obj);
let p;
if ((typeof obj === 'object' && obj !== null) ||
typeof obj === 'function') {
Expand Down Expand Up @@ -1800,6 +1806,7 @@ function includesProxiesOrGetters(expr, exprStr, evalFn, ctx, callback) {
// is the property identifier/literal)
if (expr.object.type === 'Identifier') {
return evalFn(`try { ${expr.object.name} } catch {}`, ctx, getREPLResourceName(), (err, obj) => {
reclusiveCatchPromise(obj);
if (err) {
return callback(false);
}
Expand All @@ -1815,6 +1822,7 @@ function includesProxiesOrGetters(expr, exprStr, evalFn, ctx, callback) {

return evalFn(
`try { ${exprStr} } catch {} `, ctx, getREPLResourceName(), (err, obj) => {
reclusiveCatchPromise(obj);
if (err) {
return callback(false);
}
Expand Down Expand Up @@ -1877,6 +1885,7 @@ function includesProxiesOrGetters(expr, exprStr, evalFn, ctx, callback) {
ctx,
getREPLResourceName(),
(err, evaledProp) => {
reclusiveCatchPromise(evaledProp);
if (err) {
return callback(false);
}
Expand All @@ -1902,7 +1911,9 @@ function includesProxiesOrGetters(expr, exprStr, evalFn, ctx, callback) {
function safeIsProxyAccess(obj, prop) {
// Accessing `prop` may trigger a getter that throws, so we use try-catch to guard against it
try {
return isProxy(obj[prop]);
const value = obj[prop];
reclusiveCatchPromise(value);
return isProxy(value);
} catch {
return false;
}
Expand All @@ -1911,6 +1922,33 @@ function includesProxiesOrGetters(expr, exprStr, evalFn, ctx, callback) {
return callback(false);
}

function reclusiveCatchPromise(obj, seen = new SafeWeakSet()) {
Copy link
Member Author

Choose a reason for hiding this comment

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

This reclusive code is necessary to handle errors that might be thrown from within objects or similar structures.
https://github.com/nodejs/node/pull/58943/files#diff-24ded3e78d8bb56bd21deb058a4766995ee891242cf993a9a185c57213ce38c0R15

if (isPromise(obj)) {
return obj.catch(() => {});
} else if (ArrayIsArray(obj)) {
obj.forEach((item) => {
reclusiveCatchPromise(item, seen);
});
} else if (obj && typeof obj === 'object') {
if (seen.has(obj)) return;
seen.add(obj);

let props;
try {
props = ObjectGetOwnPropertyNames(obj);
} catch {
return;
}
for (const key of props) {
try {
reclusiveCatchPromise(obj[key], seen);
} catch {
continue;
}
}
}
}

REPLServer.prototype.completeOnEditorMode = (callback) => (err, results) => {
if (err) return callback(err);

Expand Down
2 changes: 1 addition & 1 deletion test/fixtures/repl-tab-completion-nested-repls.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ const putIn = new ArrayStream();
const testMe = repl.start('', putIn);

// Some errors are passed to the domain, but do not callback.
testMe._domain.on('error', function(err) {
testMe._completeDomain.on('error', function(err) {
throw err;
});

Expand Down
62 changes: 62 additions & 0 deletions test/parallel/test-repl-eval-promises.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
'use strict';

const common = require('../common');
const repl = require('repl');
const ArrayStream = require('../common/arraystream');
const assert = require('assert');

const tests = [
Copy link
Member

@dario-piotrowicz dario-piotrowicz Jul 5, 2025

Choose a reason for hiding this comment

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

Why are there non-completion tests here?

If these tests are necessary I would suggest to have them in their own separate test file, I don't think there's any benefit in running both set of tests here, is there?

Copy link
Member Author

Choose a reason for hiding this comment

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

I had originally added it just to be safe, even though I knew it wouldn't have any real effect.
But since it’s not strictly necessary, I’ve removed it.

Copy link
Member

Choose a reason for hiding this comment

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

I think we can add it as a separate test file, as I had a quick look and I feel like promise evaluation is not being tested? anyways that can be also done separately if we want I think 🙂

Copy link
Member Author

Choose a reason for hiding this comment

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

Oh I see, I'll keep it as well in a separate file.
Is it okay to include that in this PR?

Copy link
Member

Choose a reason for hiding this comment

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

Oh I see, I'll keep it as well in a separate file.

yeah sounds good to me 🙂

Is it okay to include that in this PR?

Of course 🙂

If you're up for it I think it would be great if you could rebase and have two commits here, one for the tab-complete and one for the new eval tests then we can commit-queue-rebase and have the two clean commits in, I think that that would be the cleanest way to land this 🙂 , but if you don't feel like it the current merge squash is completely fine too 👍

Copy link
Member Author

Choose a reason for hiding this comment

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

I’ve rebased the branch!

{
send: 'Promise.reject()',
expect: /Promise \{[\s\S]*?Uncaught undefined\n?$/
},
{
send: 'let p = Promise.reject()',
expect: /undefined\nUncaught undefined\n?$/
},
{
send: `Promise.resolve()`,
expect: /Promise \{[\s\S]*?}\n?$/
},
{
send: `Promise.resolve().then(() => {})`,
expect: /Promise \{[\s\S]*?}\n?$/
},
{
send: `async function f() { throw new Error('test'); };f();`,
expect: /Promise \{[\s\S]*?<rejected> Error: test[\s\S]*?Uncaught Error: test[\s\S]*?\n?$/
},
{
send: `async function f() {};f();`,
expect: /Promise \{[\s\S]*?}\n?$/
},
];

(async function() {
await runReplTests(tests);
})().then(common.mustCall());

async function runReplTests(tests) {
for (const { send, expect } of tests) {
const input = new ArrayStream();
const output = new ArrayStream();
let outputText = '';
function write(data) {
outputText += data;
}
output.write = write;
const replServer = repl.start({
prompt: '',
input,
output: output,
});
input.emit('data', `${send}\n`);
await new Promise((resolve) => {
setTimeout(() => {
assert.match(outputText, expect);
replServer.close();
resolve();
}, 10);
});
}
}
2 changes: 1 addition & 1 deletion test/parallel/test-repl-tab-complete-getter-error.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ async function runTest() {
terminal: true
});

replServer._domain.on('error', (e) => {
replServer._completeDomain.on('error', (e) => {
assert.fail(`Error in REPL domain: ${e}`);
});

Expand Down
101 changes: 101 additions & 0 deletions test/parallel/test-repl-tab-complete-promises.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
'use strict';

const common = require('../common');
const repl = require('repl');
const ArrayStream = require('../common/arraystream');
const assert = require('assert');

const completionTests = [
{ send: 'Promise.reject().' },
{ send: 'let p = Promise.reject().' },
{ send: 'Promise.resolve().' },
{ send: 'Promise.resolve().then(() => {}).' },
{ send: `async function f() {throw new Error('test');}; f().` },
{ send: `async function f() {}; f().` },
{ send: 'const foo = { bar: Promise.reject() }; foo.bar.' },
// Test for that reclusiveCatchPromise does not infinitely recurse
// see lib/repl.js:reclusiveCatchPromise
{ send: 'const a = {}; a.self = a; a.self.' },
{ run: `const foo = { get name() { return Promise.reject(); } };`,
send: `foo.name` },
{ run: 'const baz = { get bar() { return ""; } }; const getPropText = () => Promise.reject();',
send: 'baz[getPropText()].',
completeError: true },
{
send: 'const quux = { bar: { return Promise.reject(); } }; const getPropText = () => "bar"; quux[getPropText()].',
},
];

(async function() {
await runReplCompleteTests(completionTests);
})().then(common.mustCall());

async function runReplCompleteTests(tests) {
const input = new ArrayStream();
const output = new ArrayStream();

const replServer = repl.start({
prompt: '',
input,
output: output,
allowBlockingCompletions: true,
terminal: true
});

replServer._domain.on('error', (err) => {
assert.fail(`Unexpected domain error: ${err.message}`);
});

for (const { send, run, completeError = false } of tests) {
if (run) {
await new Promise((resolve, reject) => {
replServer.eval(run, replServer.context, '', (err) => {
if (err) {
reject(err);
} else {
resolve();
}
});
});
}

const onError = (e) => {
assert.fail(`Unexpected error: ${e.message}`);
};

let completeErrorPromise = Promise.resolve();

if (completeError) {
completeErrorPromise = new Promise((resolve) => {
const handleError = () => {
replServer._completeDomain.removeListener('error', handleError);
resolve();
};
replServer._completeDomain.on('error', common.mustCall(handleError));
});
} else {
replServer._completeDomain.on('error', onError);
}

await replServer.complete(
send,
common.mustCall((error, data) => {
assert.strictEqual(error, null);
assert.strictEqual(data.length, 2);
assert.strictEqual(typeof data[1], 'string');
assert.ok(send.includes(data[1]));
})
);

await completeErrorPromise;

if (!completeError) {
await new Promise((resolve) => {
setImmediate(() => {
replServer._completeDomain.removeListener('error', onError);
resolve();
});
});
}
}
}
2 changes: 1 addition & 1 deletion test/parallel/test-repl-tab-complete.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ function prepareREPL() {
});

// Some errors are passed to the domain, but do not callback
replServer._domain.on('error', assert.ifError);
replServer._completeDomain.on('error', assert.ifError);

return { replServer, input };
}
Expand Down
Loading