From e6eee086927f7f7f75ad485337b093cd8159c3b5 Mon Sep 17 00:00:00 2001 From: Mattias Runge-Broberg Date: Sun, 31 May 2020 20:54:41 +0200 Subject: [PATCH] readline: add support for the AbortController to the question method In some cases a question asked needs to be canceled. For instance it might be desirable to cancel a question when a user presses ctrl+c and triggers the SIGINT event. Also an initial empty string was set for this.line since the cursor methods fail if line is not initialized. Added custom promisify support to the question method. PR-URL: https://github.com/nodejs/node/pull/33676 Reviewed-By: Benjamin Gruenbaum --- doc/api/readline.md | 53 +++++++++++++++++++++--- lib/readline.js | 49 ++++++++++++++++++++-- test/parallel/test-readline-interface.js | 48 ++++++++++++++++++++- 3 files changed, 141 insertions(+), 9 deletions(-) diff --git a/doc/api/readline.md b/doc/api/readline.md index cc3460cc02d29a..4708f8de35e2b5 100644 --- a/doc/api/readline.md +++ b/doc/api/readline.md @@ -256,13 +256,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)` * `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`. @@ -276,6 +279,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 @@ -284,9 +291,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()` -* {string|undefined} +* {string} The current input data being processed by node. diff --git a/lib/readline.js b/lib/readline.js index 55862069300789..ba131aa53bd30d 100644 --- a/lib/readline.js +++ b/lib/readline.js @@ -57,16 +57,22 @@ const { StringPrototypeSplit, StringPrototypeStartsWith, StringPrototypeTrim, + Promise, Symbol, SymbolAsyncIterator, SafeStringIterator, } = primordials; +const { + AbortError, + codes +} = require('internal/errors'); + const { ERR_INVALID_CALLBACK, ERR_INVALID_CURSOR_POS, - ERR_INVALID_OPT_VALUE -} = require('internal/errors').codes; + ERR_INVALID_OPT_VALUE, +} = codes; const { validateArray, validateString, @@ -87,6 +93,8 @@ const { kSubstringSearch, } = require('internal/readline/utils'); +const { promisify } = require('internal/util'); + const { clearTimeout, setTimeout } = require('timers'); const { kEscape, @@ -96,6 +104,7 @@ const { kClearScreenDown } = CSI; + const { StringDecoder } = require('string_decoder'); // Lazy load Readable for startup performance. @@ -197,6 +206,7 @@ function Interface(input, output, completer, terminal) { const self = this; + this.line = ''; this[kSubstringSearch] = null; this.output = output; this.input = input; @@ -214,6 +224,8 @@ function Interface(input, output, completer, terminal) { }; } + this._questionCancel = FunctionPrototypeBind(_questionCancel, this); + this.setPrompt(prompt); this.terminal = !!terminal; @@ -349,7 +361,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(); @@ -362,6 +383,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) { diff --git a/test/parallel/test-readline-interface.js b/test/parallel/test-readline-interface.js index ea10361fbba8f3..f8acc338adc981 100644 --- a/test/parallel/test-readline-interface.js +++ b/test/parallel/test-readline-interface.js @@ -19,13 +19,14 @@ // OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE // USE OR OTHER DEALINGS IN THE SOFTWARE. -// Flags: --expose-internals +// Flags: --expose-internals --experimental-abortcontroller 'use strict'; const common = require('../common'); common.skipIfDumbTerminal(); const assert = require('assert'); const readline = require('readline'); +const util = require('util'); const { getStringWidth, stripVTControlCharacters @@ -934,6 +935,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 });