Search code examples
javascriptpromisees6-promisereadline

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


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).


Solution

  • 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;
    }