From 7bd509d94716215cc1d8cadc73d9576eea079c0d Mon Sep 17 00:00:00 2001 From: Daijiro Wachi Date: Wed, 13 May 2026 19:07:13 +0900 Subject: [PATCH] readline: reject pending question promise on close --- lib/internal/readline/interface.js | 11 +++- .../test-readline-promises-interface.js | 6 +- .../test-readline-promises-question-close.js | 64 +++++++++++++++++++ 3 files changed, 77 insertions(+), 4 deletions(-) create mode 100644 test/parallel/test-readline-promises-question-close.js diff --git a/lib/internal/readline/interface.js b/lib/internal/readline/interface.js index 08f7aaa9e3e7e8..e8df7e23073a50 100644 --- a/lib/internal/readline/interface.js +++ b/lib/internal/readline/interface.js @@ -553,6 +553,9 @@ class Interface extends InterfaceConstructor { } this.closed = true; this.emit('close'); + const reject = this[kQuestionReject]; + this[kQuestionReject] = null; + reject?.(new AbortError()); } /** @@ -1327,8 +1330,10 @@ class Interface extends InterfaceConstructor { this.emit('SIGINT'); } else { // This readline instance is finished + const rejectC = this[kQuestionReject]; + this[kQuestionReject] = null; this.close(); - this[kQuestionReject]?.(new AbortError('Aborted with Ctrl+C')); + rejectC?.(new AbortError('Aborted with Ctrl+C')); } break; @@ -1339,8 +1344,10 @@ class Interface extends InterfaceConstructor { case 'd': // delete right or EOF if (this.cursor === 0 && this.line.length === 0) { // This readline instance is finished + const rejectD = this[kQuestionReject]; + this[kQuestionReject] = null; this.close(); - this[kQuestionReject]?.(new AbortError('Aborted with Ctrl+D')); + rejectD?.(new AbortError('Aborted with Ctrl+D')); } else if (this.cursor < this.line.length) { this[kDeleteRight](); } diff --git a/test/parallel/test-readline-promises-interface.js b/test/parallel/test-readline-promises-interface.js index 260431dd69cbc9..e981e80d9f9cb1 100644 --- a/test/parallel/test-readline-promises-interface.js +++ b/test/parallel/test-readline-promises-interface.js @@ -396,7 +396,8 @@ function assertCursorRowsAndCols(rli, rows, cols) { { const [rli] = getInterface({ terminal: true }); const expectedLines = ['foo']; - rli.question(expectedLines[0]).then(() => rli.close()).then(common.mustNotCall('never settling promise')); + // close() should reject a pending question with AbortError + assert.rejects(rli.question(expectedLines[0]), { name: 'AbortError' }).then(common.mustCall()); assertCursorRowsAndCols(rli, 0, expectedLines[0].length); rli.close(); } @@ -405,7 +406,8 @@ function assertCursorRowsAndCols(rli, rows, cols) { { const [rli] = getInterface({ terminal: true }); const expectedLines = ['foo', 'bar']; - rli.question(expectedLines.join('\n')).then(() => rli.close()).then(common.mustNotCall('never settling promise')); + // close() should reject a pending question with AbortError + assert.rejects(rli.question(expectedLines.join('\n')), { name: 'AbortError' }).then(common.mustCall()); assertCursorRowsAndCols( rli, expectedLines.length - 1, expectedLines.slice(-1)[0].length); rli.close(); diff --git a/test/parallel/test-readline-promises-question-close.js b/test/parallel/test-readline-promises-question-close.js new file mode 100644 index 00000000000000..c6c31cdac9345e --- /dev/null +++ b/test/parallel/test-readline-promises-question-close.js @@ -0,0 +1,64 @@ +'use strict'; + +// Tests that calling rl.close() while a question() promise is pending rejects +// the promise with AbortError instead of leaving it permanently unresolved. + +const common = require('../common'); +const assert = require('node:assert'); +const readline = require('node:readline/promises'); +const { Readable } = require('node:stream'); +const { EventEmitter } = require('node:events'); +const { Writable } = require('node:stream'); + +class FakeInput extends EventEmitter { + resume() {} + pause() {} + write() {} + end() {} +} + +// Programmatic close() rejects the pending question +{ + const input = new Readable({ read() {} }); + const rl = readline.createInterface({ input, terminal: false }); + assert.rejects(rl.question('prompt: '), { name: 'AbortError' }).then(common.mustCall()); + rl.close(); +} + +// close() rejects only once when called multiple times +{ + const input = new Readable({ read() {} }); + const rl = readline.createInterface({ input, terminal: false }); + assert.rejects(rl.question('prompt: '), { name: 'AbortError' }).then(common.mustCall()); + rl.close(); + rl.close(); // Second close() should be a no-op +} + +// close() does not throw if there is no pending question +{ + const input = new Readable({ read() {} }); + const rl = readline.createInterface({ input, terminal: false }); + rl.close(); // Should not throw +} + +// Terminal interface: close() rejects a pending question +{ + const fi = new FakeInput(); + const output = new Writable({ write(chunk, enc, cb) { cb(); } }); + const rl = readline.createInterface({ terminal: true, input: fi, output }); + assert.rejects(rl.question('prompt: '), { name: 'AbortError' }).then(common.mustCall()); + rl.close(); +} + +// Answering before close() resolves normally; subsequent close() is no-op +{ + const fi = new FakeInput(); + const output = new Writable({ write(chunk, enc, cb) { cb(); } }); + const rl = readline.createInterface({ terminal: true, input: fi, output }); + rl.question('prompt: ').then(common.mustCall((answer) => { + assert.strictEqual(answer, 'hello'); + })); + fi.emit('data', 'hello\n'); + // close() with no pending question should not throw + setImmediate(common.mustCall(() => rl.close())); +}