Skip to content
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

readline: add questionCancel() method to cancel ongoing question #33676

Closed
Closed
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
53 changes: 48 additions & 5 deletions doc/api/readline.md
Original file line number Diff line number Diff line change
Expand Up @@ -234,13 +234,16 @@ paused.
If the `readline.Interface` was created with `output` set to `null` or
`undefined` the prompt is not written.

### `rl.question(query, callback)`
### `rl.question(query[, options], callback)`
<!-- YAML
added: v0.3.3
-->

* `query` {string} A statement or query to write to `output`, prepended to the
prompt.
* `options` {Object}
* `signal` {AbortSignal} Optionally allows the `question()` to be canceled
using an `AbortController`.
* `callback` {Function} A callback function that is invoked with the user's
input in response to the `query`.

Expand All @@ -254,6 +257,10 @@ paused.
If the `readline.Interface` was created with `output` set to `null` or
`undefined` the `query` is not written.

The `callback` function passed to `rl.question()` does not follow the typical
pattern of accepting an `Error` object or `null` as the first argument.
The `callback` is called with the provided answer as the only argument.

Example usage:

```js
Expand All @@ -262,9 +269,41 @@ rl.question('What is your favorite food? ', (answer) => {
});
```

The `callback` function passed to `rl.question()` does not follow the typical
pattern of accepting an `Error` object or `null` as the first argument.
The `callback` is called with the provided answer as the only argument.
Using an `AbortController` to cancel a question.

```js
const ac = new AbortController();
const signal = ac.signal;

rl.question('What is your favorite food? ', { signal }, (answer) => {
console.log(`Oh, so your favorite food is ${answer}`);
});

signal.addEventListener('abort', () => {
console.log('The food question timed out');
}, { once: true });

setTimeout(() => ac.abort(), 10000);
```

If this method is invoked as it's util.promisify()ed version, it returns a
Promise that fulfills with the answer. If the question is canceled using
an `AbortController` it will reject with an `AbortError`.

```js
const util = require('util');
const question = util.promisify(rl.question).bind(rl);

async function questionExample() {
try {
const answer = await question('What is you favorite food? ');
console.log(`Oh, so your favorite food is ${answer}`);
} catch (err) {
console.error('Question rejected', err);
}
}
questionExample();
```

### `rl.resume()`
<!-- YAML
Expand Down Expand Up @@ -374,9 +413,13 @@ asynchronous iteration may result in missed lines.
### `rl.line`
<!-- YAML
added: v0.1.98
changes:
- version: REPLACEME
pr-url: https://github.com/nodejs/node/pull/33676
description: Value will always be a string, never undefined.
-->

* {string|undefined}
* {string}
benjamingr marked this conversation as resolved.
Show resolved Hide resolved

The current input data being processed by node.

Expand Down
47 changes: 45 additions & 2 deletions lib/readline.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,15 +57,21 @@ const {
StringPrototypeSplit,
StringPrototypeStartsWith,
StringPrototypeTrim,
Promise,
Symbol,
SymbolAsyncIterator,
SafeStringIterator,
} = primordials;

const {
AbortError,
codes
} = require('internal/errors');

const {
ERR_INVALID_ARG_VALUE,
ERR_INVALID_CURSOR_POS,
} = require('internal/errors').codes;
} = codes;
const {
validateCallback,
validateString,
Expand All @@ -86,6 +92,8 @@ const {
kSubstringSearch,
} = require('internal/readline/utils');

const { promisify } = require('internal/util');

const { clearTimeout, setTimeout } = require('timers');
const {
kEscape,
Expand All @@ -95,6 +103,7 @@ const {
kClearScreenDown
} = CSI;


const { StringDecoder } = require('string_decoder');

// Lazy load Readable for startup performance.
Expand Down Expand Up @@ -188,6 +197,7 @@ function Interface(input, output, completer, terminal) {

const self = this;

this.line = '';
this[kSubstringSearch] = null;
this.output = output;
this.input = input;
Expand All @@ -204,6 +214,8 @@ function Interface(input, output, completer, terminal) {
};
}

this._questionCancel = FunctionPrototypeBind(_questionCancel, this);

this.setPrompt(prompt);

this.terminal = !!terminal;
Expand Down Expand Up @@ -348,7 +360,16 @@ Interface.prototype.prompt = function(preserveCursor) {
};


Interface.prototype.question = function(query, cb) {
Interface.prototype.question = function(query, options, cb) {
cb = typeof options === 'function' ? options : cb;
options = typeof options === 'object' ? options : {};

if (options.signal) {
options.signal.addEventListener('abort', () => {
this._questionCancel();
}, { once: true });
}

if (typeof cb === 'function') {
if (this._questionCallback) {
this.prompt();
Expand All @@ -361,6 +382,28 @@ Interface.prototype.question = function(query, cb) {
}
};

Interface.prototype.question[promisify.custom] = function(query, options) {
options = typeof options === 'object' ? options : {};

return new Promise((resolve, reject) => {
this.question(query, options, resolve);

if (options.signal) {
options.signal.addEventListener('abort', () => {
reject(new AbortError());
}, { once: true });
}
});
};

function _questionCancel() {
if (this._questionCallback) {
this._questionCallback = null;
this.setPrompt(this._oldPrompt);
this.clearLine();
}
}


Interface.prototype._onLine = function(line) {
if (this._questionCallback) {
Expand Down
46 changes: 46 additions & 0 deletions test/parallel/test-readline-interface.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ common.skipIfDumbTerminal();

const assert = require('assert');
const readline = require('readline');
const util = require('util');
const {
getStringWidth,
stripVTControlCharacters
Expand Down Expand Up @@ -894,6 +895,51 @@ for (let i = 0; i < 12; i++) {
rli.close();
}

// Calling the promisified question
{
const [rli] = getInterface({ terminal });
const question = util.promisify(rli.question).bind(rli);
question('foo?')
.then(common.mustCall((answer) => {
assert.strictEqual(answer, 'bar');
}));
rli.write('bar\n');
rli.close();
}

// Aborting a question
{
const ac = new AbortController();
const signal = ac.signal;
const [rli] = getInterface({ terminal });
rli.on('line', common.mustCall((line) => {
assert.strictEqual(line, 'bar');
}));
rli.question('hello?', { signal }, common.mustNotCall());
ac.abort();
rli.write('bar\n');
rli.close();
}

// Aborting a promisified question
{
const ac = new AbortController();
const signal = ac.signal;
const [rli] = getInterface({ terminal });
const question = util.promisify(rli.question).bind(rli);
rli.on('line', common.mustCall((line) => {
assert.strictEqual(line, 'bar');
}));
question('hello?', { signal })
.then(common.mustNotCall())
.catch(common.mustCall((error) => {
assert.strictEqual(error.name, 'AbortError');
}));
ac.abort();
rli.write('bar\n');
rli.close();
}

// Can create a new readline Interface with a null output argument
{
const [rli, fi] = getInterface({ output: null, terminal });
Expand Down