-
-
Notifications
You must be signed in to change notification settings - Fork 32.3k
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
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -43,6 +43,7 @@ | |
'use strict'; | ||
|
||
const { | ||
ArrayIsArray, | ||
ArrayPrototypeAt, | ||
ArrayPrototypeFilter, | ||
ArrayPrototypeFindLastIndex, | ||
|
@@ -98,6 +99,7 @@ const { | |
|
||
const { | ||
isProxy, | ||
isPromise, | ||
} = require('internal/util/types'); | ||
|
||
const { BuiltinModule } = require('internal/bootstrap/realm'); | ||
|
@@ -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(); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
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; | ||
|
@@ -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'); | ||
|
@@ -1541,7 +1546,7 @@ function complete(line, callback) { | |
return includesProxiesOrGetters( | ||
completeTargetAst.body[0].expression, | ||
parsableCompleteTarget, | ||
this.eval, | ||
this.completeEval, | ||
this.context, | ||
(includes) => { | ||
if (includes) { | ||
|
@@ -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') { | ||
|
@@ -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); | ||
} | ||
|
@@ -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); | ||
} | ||
|
@@ -1877,6 +1885,7 @@ function includesProxiesOrGetters(expr, exprStr, evalFn, ctx, callback) { | |
ctx, | ||
getREPLResourceName(), | ||
(err, evaledProp) => { | ||
reclusiveCatchPromise(evaledProp); | ||
if (err) { | ||
return callback(false); | ||
} | ||
|
@@ -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; | ||
} | ||
|
@@ -1911,6 +1922,33 @@ function includesProxiesOrGetters(expr, exprStr, evalFn, ctx, callback) { | |
return callback(false); | ||
} | ||
|
||
function reclusiveCatchPromise(obj, seen = new SafeWeakSet()) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. |
||
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); | ||
|
||
|
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 = [ | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 🙂 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
yeah sounds good to me 🙂
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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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); | ||
}); | ||
} | ||
} |
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(); | ||
}); | ||
}); | ||
} | ||
} | ||
} |
There was a problem hiding this comment.
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)There was a problem hiding this comment.
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)
There was a problem hiding this comment.
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 (alongsidecompleteEval
) and also from the test filesDiff
For completeness this is my whole diff:
After that (and rebuilding node of course) it looks like all the reply tests are still passing?

am I missing something?
There was a problem hiding this comment.
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.
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.There was a problem hiding this comment.
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.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
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:
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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).
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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:
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.