Skip to content

Commit c78de13

Browse files
BridgeARtargos
authored andcommitted
repl: handle uncaughtException properly
When running the REPL as standalone program it's now possible to use `process.on('uncaughtException', listener)`. It is going to use those listeners from now on and the regular error output is suppressed. It also fixes the issue that REPL instances started inside of an application would silence all application errors. It is now prohibited to add the exception listener in such REPL instances. Trying to add such listeners throws an `ERR_INVALID_REPL_INPUT` error. Fixes: #19998 PR-URL: #27151 Fixes: #19998 Reviewed-By: Lance Ball <[email protected]> Reviewed-By: Rich Trott <[email protected]>
1 parent 815a957 commit c78de13

8 files changed

+244
-17
lines changed

doc/api/errors.md

+9-2
Original file line numberDiff line numberDiff line change
@@ -1310,8 +1310,14 @@ An invalid `options.protocol` was passed to `http.request()`.
13101310
<a id="ERR_INVALID_REPL_EVAL_CONFIG"></a>
13111311
### ERR_INVALID_REPL_EVAL_CONFIG
13121312

1313-
Both `breakEvalOnSigint` and `eval` options were set in the REPL config, which
1314-
is not supported.
1313+
Both `breakEvalOnSigint` and `eval` options were set in the [`REPL`][] config,
1314+
which is not supported.
1315+
1316+
<a id="ERR_INVALID_REPL_INPUT"></a>
1317+
### ERR_INVALID_REPL_INPUT
1318+
1319+
The input may not be used in the [`REPL`][]. All prohibited inputs are
1320+
documented in the [`REPL`][]'s documentation.
13151321

13161322
<a id="ERR_INVALID_RETURN_PROPERTY"></a>
13171323
### ERR_INVALID_RETURN_PROPERTY
@@ -2307,6 +2313,7 @@ such as `process.stdout.on('data')`.
23072313
[`Class: assert.AssertionError`]: assert.html#assert_class_assert_assertionerror
23082314
[`ERR_INVALID_ARG_TYPE`]: #ERR_INVALID_ARG_TYPE
23092315
[`EventEmitter`]: events.html#events_class_eventemitter
2316+
[`REPL`]: repl.html
23102317
[`Writable`]: stream.html#stream_class_stream_writable
23112318
[`child_process`]: child_process.html
23122319
[`cipher.getAuthTag()`]: crypto.html#crypto_cipher_getauthtag

doc/api/repl.md

+33-1
Original file line numberDiff line numberDiff line change
@@ -138,16 +138,47 @@ global or scoped variable, the input `fs` will be evaluated on-demand as
138138
```
139139

140140
#### Global Uncaught Exceptions
141+
<!-- YAML
142+
changes:
143+
- version: REPLACEME
144+
pr-url: https://github.com/nodejs/node/pull/27151
145+
description: The `'uncaughtException'` event is from now on triggered if the
146+
repl is used as standalone program.
147+
-->
141148

142149
The REPL uses the [`domain`][] module to catch all uncaught exceptions for that
143150
REPL session.
144151

145152
This use of the [`domain`][] module in the REPL has these side effects:
146153

147-
* Uncaught exceptions do not emit the [`'uncaughtException'`][] event.
154+
* Uncaught exceptions only emit the [`'uncaughtException'`][] event if the
155+
`repl` is used as standalone program. If the `repl` is included anywhere in
156+
another application, adding a listener for this event will throw an
157+
[`ERR_INVALID_REPL_INPUT`][] exception.
148158
* Trying to use [`process.setUncaughtExceptionCaptureCallback()`][] throws
149159
an [`ERR_DOMAIN_CANNOT_SET_UNCAUGHT_EXCEPTION_CAPTURE`][] error.
150160

161+
As standalone program:
162+
163+
```js
164+
process.on('uncaughtException', () => console.log('Uncaught'));
165+
166+
throw new Error('foobar');
167+
// Uncaught
168+
```
169+
170+
When used in another application:
171+
172+
```js
173+
process.on('uncaughtException', () => console.log('Uncaught'));
174+
// TypeError [ERR_INVALID_REPL_INPUT]: Listeners for `uncaughtException`
175+
// cannot be used in the REPL
176+
177+
throw new Error('foobar');
178+
// Thrown:
179+
// Error: foobar
180+
```
181+
151182
#### Assignment of the `_` (underscore) variable
152183
<!-- YAML
153184
changes:
@@ -661,6 +692,7 @@ For an example of running a REPL instance over [curl(1)][], see:
661692
[`'uncaughtException'`]: process.html#process_event_uncaughtexception
662693
[`--experimental-repl-await`]: cli.html#cli_experimental_repl_await
663694
[`ERR_DOMAIN_CANNOT_SET_UNCAUGHT_EXCEPTION_CAPTURE`]: errors.html#errors_err_domain_cannot_set_uncaught_exception_capture
695+
[`ERR_INVALID_REPL_INPUT`]: errors.html#errors_err_invalid_repl_input
664696
[`domain`]: domain.html
665697
[`process.setUncaughtExceptionCaptureCallback()`]: process.html#process_process_setuncaughtexceptioncapturecallback_fn
666698
[`readline.InterfaceCompleter`]: readline.html#readline_use_of_the_completer_function

lib/internal/errors.js

+1
Original file line numberDiff line numberDiff line change
@@ -895,6 +895,7 @@ E('ERR_INVALID_PROTOCOL',
895895
TypeError);
896896
E('ERR_INVALID_REPL_EVAL_CONFIG',
897897
'Cannot specify both "breakEvalOnSigint" and "eval" for REPL', TypeError);
898+
E('ERR_INVALID_REPL_INPUT', '%s', TypeError);
898899
E('ERR_INVALID_RETURN_PROPERTY', (input, name, prop, value) => {
899900
return `Expected a valid ${input} to be returned for the "${prop}" from the` +
900901
` "${name}" function but got ${value}.`;

lib/repl.js

+47-12
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ const {
7272
ERR_CANNOT_WATCH_SIGINT,
7373
ERR_INVALID_ARG_TYPE,
7474
ERR_INVALID_REPL_EVAL_CONFIG,
75+
ERR_INVALID_REPL_INPUT,
7576
ERR_SCRIPT_EXECUTION_INTERRUPTED
7677
} = require('internal/errors').codes;
7778
const { sendInspectorCommand } = require('internal/util/inspector');
@@ -101,10 +102,13 @@ let processTopLevelAwait;
101102

102103
const parentModule = module;
103104
const replMap = new WeakMap();
105+
const domainSet = new WeakSet();
104106

105107
const kBufferedCommandSymbol = Symbol('bufferedCommand');
106108
const kContextId = Symbol('contextId');
107109

110+
let addedNewListener = false;
111+
108112
try {
109113
// Hack for require.resolve("./relative") to work properly.
110114
module.filename = path.resolve('repl');
@@ -204,6 +208,28 @@ function REPLServer(prompt,
204208
throw new ERR_INVALID_REPL_EVAL_CONFIG();
205209
}
206210

211+
// Add this listener only once and use a WeakSet that contains the REPLs
212+
// domains. Otherwise we'd have to add a single listener to each REPL instance
213+
// and that could trigger the `MaxListenersExceededWarning`.
214+
if (!options[kStandaloneREPL] && !addedNewListener) {
215+
process.prependListener('newListener', (event, listener) => {
216+
if (event === 'uncaughtException' &&
217+
process.domain &&
218+
listener.name !== 'domainUncaughtExceptionClear' &&
219+
domainSet.has(process.domain)) {
220+
// Throw an error so that the event will not be added and the current
221+
// domain takes over. That way the user is notified about the error
222+
// and the current code evaluation is stopped, just as any other code
223+
// that contains an error.
224+
throw new ERR_INVALID_REPL_INPUT(
225+
'Listeners for `uncaughtException` cannot be used in the REPL');
226+
}
227+
});
228+
addedNewListener = true;
229+
}
230+
231+
domainSet.add(this._domain);
232+
207233
let rli = this;
208234
Object.defineProperty(this, 'rli', {
209235
get: deprecate(() => rli,
@@ -264,7 +290,7 @@ function REPLServer(prompt,
264290
// statement rather than an object literal. So, we first try
265291
// to wrap it in parentheses, so that it will be interpreted as
266292
// an expression. Note that if the above condition changes,
267-
// lib/internal/repl/recoverable.js needs to be changed to match.
293+
// lib/internal/repl/utils.js needs to be changed to match.
268294
code = `(${code.trim()})\n`;
269295
wrappedCmd = true;
270296
}
@@ -461,22 +487,31 @@ function REPLServer(prompt,
461487
}
462488
}
463489

464-
if (errStack === '') {
465-
errStack = `Thrown: ${self.writer(e)}\n`;
466-
} else {
467-
const ln = errStack.endsWith('\n') ? '' : '\n';
468-
errStack = `Thrown:\n${errStack}${ln}`;
469-
}
470-
471490
if (!self.underscoreErrAssigned) {
472491
self.lastError = e;
473492
}
474493

475494
const top = replMap.get(self);
476-
top.outputStream.write(errStack);
477-
top.clearBufferedCommand();
478-
top.lines.level = [];
479-
top.displayPrompt();
495+
if (options[kStandaloneREPL] &&
496+
process.listenerCount('uncaughtException') !== 0) {
497+
process.nextTick(() => {
498+
process.emit('uncaughtException', e);
499+
top.clearBufferedCommand();
500+
top.lines.level = [];
501+
top.displayPrompt();
502+
});
503+
} else {
504+
if (errStack === '') {
505+
errStack = `Thrown: ${self.writer(e)}\n`;
506+
} else {
507+
const ln = errStack.endsWith('\n') ? '' : '\n';
508+
errStack = `Thrown:\n${errStack}${ln}`;
509+
}
510+
top.outputStream.write(errStack);
511+
top.clearBufferedCommand();
512+
top.lines.level = [];
513+
top.displayPrompt();
514+
}
480515
});
481516

482517
self.resetContext();

test/parallel/test-repl-pretty-custom-stack.js

-2
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,6 @@ process.on('uncaughtException', (e) => {
4040
throw e;
4141
});
4242

43-
process.on('exit', () => (Error.prepareStackTrace = origPrepareStackTrace));
44-
4543
const tests = [
4644
{
4745
// test .load for a file that throws
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
'use strict';
2+
3+
// This verifies that adding an `uncaughtException` listener in an REPL instance
4+
// does not suppress errors in the whole application. Adding such listener
5+
// should throw.
6+
7+
require('../common');
8+
const ArrayStream = require('../common/arraystream');
9+
const repl = require('repl');
10+
const assert = require('assert');
11+
12+
let accum = '';
13+
14+
const output = new ArrayStream();
15+
output.write = (data) => accum += data.replace('\r', '');
16+
17+
const r = repl.start({
18+
prompt: '',
19+
input: new ArrayStream(),
20+
output,
21+
terminal: false,
22+
useColors: false,
23+
global: false
24+
});
25+
26+
r.write(
27+
'process.nextTick(() => {\n' +
28+
' process.on("uncaughtException", () => console.log("Foo"));\n' +
29+
' throw new TypeError("foobar");\n' +
30+
'});\n'
31+
);
32+
r.write(
33+
'setTimeout(() => {\n' +
34+
' throw new RangeError("abc");\n' +
35+
'}, 1);console.log()\n'
36+
);
37+
r.close();
38+
39+
setTimeout(() => {
40+
const len = process.listenerCount('uncaughtException');
41+
process.removeAllListeners('uncaughtException');
42+
assert.strictEqual(len, 0);
43+
assert(/ERR_INVALID_REPL_INPUT.*(?!Type)RangeError: abc/s.test(accum));
44+
}, 2);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
'use strict';
2+
const common = require('../common');
3+
const assert = require('assert');
4+
const cp = require('child_process');
5+
const child = cp.spawn(process.execPath, ['-i']);
6+
let output = '';
7+
8+
child.stdout.setEncoding('utf8');
9+
child.stdout.on('data', (data) => {
10+
output += data;
11+
});
12+
13+
child.on('exit', common.mustCall(() => {
14+
const results = output.split('\n');
15+
results.shift();
16+
assert.deepStrictEqual(
17+
results,
18+
[
19+
'Type ".help" for more information.',
20+
// x\n
21+
'> Thrown:',
22+
'ReferenceError: x is not defined',
23+
// Added `uncaughtException` listener.
24+
'> short',
25+
'undefined',
26+
// x\n
27+
'> Foobar',
28+
'> '
29+
]
30+
);
31+
}));
32+
33+
child.stdin.write('x\n');
34+
child.stdin.write(
35+
'process.on("uncaughtException", () => console.log("Foobar"));' +
36+
'console.log("short")\n');
37+
child.stdin.write('x\n');
38+
child.stdin.end();
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
'use strict';
2+
require('../common');
3+
const ArrayStream = require('../common/arraystream');
4+
const assert = require('assert');
5+
const repl = require('repl');
6+
7+
let count = 0;
8+
9+
function run({ command, expected }) {
10+
let accum = '';
11+
12+
const output = new ArrayStream();
13+
output.write = (data) => accum += data.replace('\r', '');
14+
15+
const r = repl.start({
16+
prompt: '',
17+
input: new ArrayStream(),
18+
output,
19+
terminal: false,
20+
useColors: false
21+
});
22+
23+
r.write(`${command}\n`);
24+
if (typeof expected === 'string') {
25+
assert.strictEqual(accum, expected);
26+
} else {
27+
assert(expected.test(accum), accum);
28+
}
29+
30+
// Verify that the repl is still working as expected.
31+
accum = '';
32+
r.write('1 + 1\n');
33+
assert.strictEqual(accum, '2\n');
34+
r.close();
35+
count++;
36+
}
37+
38+
const tests = [
39+
{
40+
command: 'x',
41+
expected: 'Thrown:\n' +
42+
'ReferenceError: x is not defined\n'
43+
},
44+
{
45+
command: 'process.on("uncaughtException", () => console.log("Foobar"));\n',
46+
expected: /^Thrown:\nTypeError \[ERR_INVALID_REPL_INPUT]: Listeners for `/
47+
},
48+
{
49+
command: 'x;\n',
50+
expected: 'Thrown:\n' +
51+
'ReferenceError: x is not defined\n'
52+
},
53+
{
54+
command: 'process.on("uncaughtException", () => console.log("Foobar"));' +
55+
'console.log("Baz");\n',
56+
expected: /^Thrown:\nTypeError \[ERR_INVALID_REPL_INPUT]: Listeners for `/
57+
},
58+
{
59+
command: 'console.log("Baz");' +
60+
'process.on("uncaughtException", () => console.log("Foobar"));\n',
61+
expected: /^Baz\nThrown:\nTypeError \[ERR_INVALID_REPL_INPUT]:.*uncaughtException/
62+
}
63+
];
64+
65+
process.on('exit', () => {
66+
// To actually verify that the test passed we have to make sure no
67+
// `uncaughtException` listeners exist anymore.
68+
process.removeAllListeners('uncaughtException');
69+
assert.strictEqual(count, tests.length);
70+
});
71+
72+
tests.forEach(run);

0 commit comments

Comments
 (0)