6

Why does Javascript readline question method read more than one line when wrapped in a Promise?

The code below is supposed to simply add line numbers to the input. It works as expected if I run it and type the input at the command line. However, if I redirect a file into the process, then it consumes the entire file at once. Why is that?

Expected output:

Next line, please: File line 1
1       File line 1
Next line, please: File line 2
2       File line 2
Next line, please: File line 3
3       File line 3
Next line, please: File line 4
4       File line 4
Next line, please: Input stream closed.

Observed output (when running node testReadline.mjs < the_file.txt)

Next line, please: File line 1
File line 2
File line 3
File line 4
1       File line 1
Next line, please: Input stream closed.

It appears to be consuming the entire file after the first call to question, rather than consuming only one line at a time.

(I know there is the readline/promises package. I'm curious why the code below doesn't behave as expected.)

import * as readline from 'node:readline';

const io = readline.createInterface({
    input: process.stdin,
    output: process.stdout,
  });

io.on('close', () => {console.log("Input stream closed.")});

let questionWrapper = (prompt) => {
    return new Promise((resolve, reject) => {
        io.question(prompt, (line) => {
            resolve(line)
        });
    });
}

let printLine_await = async () => {
    let line_num = 1;
    while(true) {
        let line = await questionWrapper('Next line, please: ');
        console.log(`${line_num}\t${line}`)
        line_num++;
    }
}

printLine_await(1)

For what it's worth, I get the expected result when using callbacks.

This code

let printLine_callback = (line_num) => {
    io.question('Next line, please: ', (line) => {
        console.log(`${line_num}\t${line}`)
        printLine_callback(line_num + 1)
    })
}

Produces this result:

Next line, please: File line 1
1       File line 1
Next line, please: File line 2
2       File line 2
Next line, please: File line 3
3       File line 3
Next line, please: File line 4
4       File line 4
Next line, please: Input stream closed.

It's not clear from the documentation what is supposed to happen if the input ends while question is waiting; but, the behavior I see makes sense in this case (where the file ends with a newline).

Zack
  • 6,232
  • 8
  • 38
  • 68
  • 1
    @Zack "*when wrapped in a Promise*" feels like a red herring. Or do you have evidence that it works as expected when not using promises but callback style? – Bergi Oct 12 '22 at 02:37
  • 2
    Only the first line callback of each input's `data` event is actually called asynchronously, all others are called sync. https://github.com/nodejs/node/blob/v18.10.0/lib/internal/readline/interface.js#L614 – Kaiido Oct 12 '22 at 05:49
  • I first was wondering why I couldn't see where *promises.js* did the magic, but after testing, I get the same result with */promises* than with your wrapper... – Kaiido Oct 12 '22 at 11:34
  • @Bergi It does work as expected when using callbacks only. (See edit above.) – Zack Oct 12 '22 at 13:22
  • @Kaiido What is the significance of only the first line being called asynchronously. Does that suggest a bug / limitation of the library, or is there something I can do to get the expected behavior? – Zack Oct 13 '22 at 11:44
  • 1
    I must admit I'm not 100% familiar with this lib, and from what I read here and there it seems that `question()` may not be the best way to do what you're doing... However I didn't find what would be that "best way", hence no answer from me, yet. And regarding the significance of having all other callbacks fired sync this means that when your (and `readline/promises`'s) code return Promises from the callbacks, these Promises's `.then()` will all get executed after all the initial callbacks fired, i.e after the stream has ended being read entirely. – Kaiido Oct 14 '22 at 01:02
  • "*I know there is the readline/promises package*" - from studying its code, I would think its `.question()` method has exactly the same problem. The only advantage is that it's cancellable. – Bergi Oct 15 '22 at 23:03

1 Answers1

2

As @Kaiido noticed, if the input stream data event contains multiple lines together (as will be the case when piping a file into the process, with a stream chunk size much larger then the average line length), the line events fire all at once. It's just a synchronous loop.

The question method more or less attaches the callback as a once-only line event handler. (It will be called instead of firing a line event, but that's just a detail). The problem with using a promise to wait for the question's answer is that when the promise is fulfilled, it takes an extra tick of the event loop until the code after the await gets to run, which would attach the next line handler. But with multiple lines entered at once, those line events will have fired before the question() could be called again. You can verify this by adding

io.on('line', line => { console.log("Ignore\t"+line); });

Is there something I can do to get the expected behavior?

When using the question() method, not really. However, what it does is rather trivial, so you can replace it by your own working version. I would recommend to use the async iterator of readline which does the necessary buffering and can be consumed using async/await syntax. For your demo, the equivalent code would be

import * as readline from 'node:readline';

const io = readline.createInterface({
  input: process.stdin,
  output: process.stdout,
});
let line_num = 1;
io.setPrompt('Next line, please: ');
io.prompt()
for await (const line of io) {
    console.log(`${line_num}\t${line}`)
    line_num++;
    io.prompt()
}
console.log("Input stream closed.")

More generally, for scripts that are not just a single loop, I'd do

const iterator = io[Symbol.asyncIterator]();
function question(query) {
    const old = io.getPrompt();
    io.setPrompt(query);
    io.prompt();
    const {done, value} = await iterator.next();
    io.setPrompt(old);
    if (done) throw new Error('EOF'); // or something?
    return value;
}
Bergi
  • 630,263
  • 148
  • 957
  • 1,375