From 8d08fd2f634df640db83d28f69f9581b687350e8 Mon Sep 17 00:00:00 2001 From: inoway46 Date: Tue, 11 Aug 2026 20:22:43 +0900 Subject: [PATCH] repl: redraw prompt after asynchronous console output Signed-off-by: inoway46 --- lib/internal/console/constructor.js | 20 ++++- lib/repl.js | 25 ++++++ .../test-repl-async-console-output.js | 85 +++++++++++++++++++ 3 files changed, 129 insertions(+), 1 deletion(-) create mode 100644 test/parallel/test-repl-async-console-output.js diff --git a/lib/internal/console/constructor.js b/lib/internal/console/constructor.js index 9d653793f133..77ae178dfb11 100644 --- a/lib/internal/console/constructor.js +++ b/lib/internal/console/constructor.js @@ -97,6 +97,12 @@ const kUseStdout = Symbol('kUseStdout'); const kUseStderr = Symbol('kUseStderr'); const optionsMap = new SafeWeakMap(); +const afterConsoleWriteCallbacks = new SafeWeakMap(); + +function setAfterConsoleWrite(console, callback) { + afterConsoleWriteCallbacks.set(console, callback); +} + function Console(options /* or: stdout, stderr, ignoreErrors = true */) { // We have to test new.target here to see if this function is called // with new, because we need to define a custom instanceof to accommodate @@ -297,7 +303,15 @@ ObjectDefineProperties(Console.prototype, { } string += '\n'; - if (ignoreErrors === false) return stream.write(string); + const afterWrite = afterConsoleWriteCallbacks.get(this); + + if (ignoreErrors === false) { + const result = stream.write(string); + if (afterWrite !== undefined) { + afterWrite(stream); + } + return result; + } // There may be an error occurring synchronously (e.g. for files or TTYs // on POSIX systems) or asynchronously (e.g. pipes on POSIX systems), so @@ -309,6 +323,9 @@ ObjectDefineProperties(Console.prototype, { stream.once('error', noop); stream.write(string, errorHandler); + if (afterWrite !== undefined) { + afterWrite(stream); + } } catch (e) { // Console is a debugging utility, so it swallowing errors is not // desirable even in edge cases such as low stack space. @@ -713,5 +730,6 @@ module.exports = { Console, kBindStreamsLazy, kBindProperties, + setAfterConsoleWrite, initializeGlobalConsole, }; diff --git a/lib/repl.js b/lib/repl.js index 4c227a0c17da..06c4e483bc66 100644 --- a/lib/repl.js +++ b/lib/repl.js @@ -97,6 +97,10 @@ const { commonPrefix, } = require('internal/readline/utils'); const { Console } = require('console'); +const globalConsole = require('internal/console/global'); +const { + setAfterConsoleWrite, +} = require('internal/console/constructor'); const { shouldColorize } = require('internal/util/colors'); const { highlight } = require('internal/repl/highlight'); const CJSModule = require('internal/modules/cjs/loader').Module; @@ -187,6 +191,23 @@ const kPendingClose = Symbol('pendingClose'); const kHasPendingWork = Symbol('hasPendingWork'); const kDrainOnClose = Symbol('drainOnClose'); +function redrawPromptAfterConsoleWrite(stream) { + const repl = replContext.getStore()?.replServer; + if (repl === undefined || + repl.closed || + !repl.terminal || + repl[kHasPendingWork]() || + stream !== repl.output) { + return; + } + + repl.displayPrompt(true); +} + +function setupConsolePromptRedraw(console) { + setAfterConsoleWrite(console, redrawPromptAfterConsoleWrite); +} + fixReplRequire(module); // This is the default "writer" value, if none is passed in the REPL options, @@ -309,6 +330,9 @@ class REPLServer extends Interface { this[kLastCommandErrored] = false; this[kPendingClose] = false; this[kDrainOnClose] = false; + if (this.useGlobal) { + setupConsolePromptRedraw(globalConsole); + } // Readline calls close() for both explicit programmatic closure and input // EOF. Only EOF should wait for queued evaluations to drain. this.input.prependListener('end', () => { @@ -922,6 +946,7 @@ class REPLServer extends Interface { }); context.global = context; const _console = new Console(this.output); + setupConsolePromptRedraw(_console); ObjectDefineProperty(context, 'console', { __proto__: null, configurable: true, diff --git a/test/parallel/test-repl-async-console-output.js b/test/parallel/test-repl-async-console-output.js new file mode 100644 index 000000000000..944c5c01198f --- /dev/null +++ b/test/parallel/test-repl-async-console-output.js @@ -0,0 +1,85 @@ +'use strict'; + +// Flags: --expose-internals + +// Regression test for https://github.com/nodejs/node/issues/43074. + +const common = require('../common'); +const assert = require('assert'); +const globalConsole = require('internal/console/global'); +const { startNewREPLServer } = require('../common/repl'); + +// Ignore terminal settings so readline uses its terminal redraw path. +process.env.TERM = ''; + +const prompt = '> '; +const refresh = (line, cursor) => + `\x1b[1G\x1b[0J${prompt}${line}\x1b[${prompt.length + cursor + 1}G`; + +async function test(useGlobal) { + const originalStdout = globalConsole._stdout; + const { + replServer, + output, + waitForIdle, + } = startNewREPLServer({ + ignoreUndefined: true, + preview: false, + prompt, + terminal: true, + useGlobal, + }); + + if (useGlobal) { + globalConsole._stdout = output; + } + + try { + output.accumulator = ''; + replServer.emit('line', "console.log('sync')"); + await waitForIdle(); + assert.strictEqual(output.accumulator, `sync\n${refresh('', 0)}`); + + const logged = Promise.withResolvers(); + replServer.context.__resetAsyncConsoleOutput = () => { + output.accumulator = ''; + }; + replServer.context.__asyncConsoleLogged = logged.resolve; + + output.accumulator = ''; + replServer.emit( + 'line', + 'void setTimeout(() => { __resetAsyncConsoleOutput(); ' + + "console.log('async'); __asyncConsoleLogged(); }, 0)", + ); + + // These edits are buffered while the timer is being evaluated, then + // replayed before the event loop can invoke its callback. + replServer.write('good'); + replServer.write('', { name: 'left' }); + replServer.write('', { name: 'left' }); + + await logged.promise; + + // Match REPL-managed asynchronous errors: write the output first, then + // redraw the editable line while preserving its contents and cursor. + assert.strictEqual( + output.accumulator, + `async\n${refresh('good', 2)}`, + ); + assert.strictEqual(replServer.line, 'good'); + assert.strictEqual(replServer.cursor, 2); + } finally { + delete replServer.context.__resetAsyncConsoleOutput; + delete replServer.context.__asyncConsoleLogged; + replServer.close(); + if (useGlobal) { + globalConsole._stdout = originalStdout; + } + } +} + +(async () => { + await test(false); + await test(true); +})().then(common.mustCall());