denoland / deno

A modern runtime for JavaScript and TypeScript.
https://deno.com
MIT License
93.88k stars 5.22k forks source link

Abortable single-character reader for stdin #21930

Open m93a opened 7 months ago

m93a commented 7 months ago

The Problem

In my CLI application, I need to read single characters from stdin until an event happens (eg. a timer runs out), and then I need to cancel all the pending reads and change back to normal stdin mode, so that things like alert work.

This is the code I've used:

async function* readKeys(signal: AbortSignal) {
  const decoder = new TextDecoder();
  const key = new Uint8Array(1);

  Deno.stdin.setRaw(true, { cbreak: true });
  while (!signal.aborted) {
    await Deno.stdin.read(key);
    if (signal.aborted) break;
    yield decoder.decode(key);
  }
  Deno.stdin.setRaw(false);
}

alert("When you're ready, press Enter, then you have 5s to press various keys.");

const ac = new AbortController();
setTimeout(() => {
  console.log("Time's out!");
  ac.abort();
}, 5_000);

for await (const key of readKeys(ac.signal)) {
  console.log("Key pressed: ", key);
}
alert("Good job, you pressed keys.");

If you try to run this, you'll notice that after the “Time's out!” message, the application hangs until you press another button, and only then it will print out “Good job, you pressed keys.” That is because the function Deno.stdin.read(key) is still locked and waiting for another key. Since Deno.stdin.read doesn't accept an AbortSignal as an argument, there is really no way to cancel the read.

One might naïvely think (as I did) that not awaiting the call would fix the problem. It doesn't: the read function is still blocking the stdin stream, and resetting the mode to non-raw before the function finishes makes the problem even worse, as it will only release the stream once the user presses Enter.

A Possible Solution

Adding a second optional parameter of type AbortSignal to Deno.stdin.read fix my issue. However, considering that Deno.Reader is deprecated, a solution using Deno.stdin.readable would be preferable.

m93a commented 7 months ago

Reproduction With ReadableStream

Rewriting the code to use Deno.stdin.readable results in the same behavior.

async function* readKeys(signal: AbortSignal) {
  Deno.stdin.setRaw(true, { cbreak: true });
  const decoder = new TextDecoder();
  const reader = Deno.stdin.readable.getReader();

  while (!signal.aborted) {
    const arr = await reader.read();
    if (signal.aborted) break;
    yield decoder.decode(arr.value);
  }
  Deno.stdin.setRaw(false);
}

Furthermore, trying to close the reader on abort by adding the following lines...

  signal.addEventListener('abort', () => {
    reader.cancel();
  });

... results in an internal error:

error: Uncaught BadResource: Bad resource ID
    Deno.stdin.setRaw(false);
               ^
    at Stdin.setRaw (ext:deno_io/12_io.js:198:5)
m93a commented 7 months ago

Reproduction With Released ReadableStream

Regarding the releaseLock() method on ReadableStreamDefaultReader, MDN has the following to say (emphasis mine):

If the reader's lock is released while it still has pending read requests then the promises returned by the reader's ReadableStreamDefaultReader.read() method are immediately rejected with a TypeError. Unread chunks remain in the stream's internal queue and can be read later by acquiring a new reader.

Therefore, my code should do exactly what I wanted if I replace the readKeys function with this code:

async function* readKeys(signal: AbortSignal) {
  const decoder = new TextDecoder();
  const reader = Deno.stdin.readable.getReader();
  signal.addEventListener('abort', () => reader.releaseLock());

  try {
    Deno.stdin.setRaw(true, { cbreak: true });
    while (!signal.aborted) {
      const arr = await reader.read();
      if (signal.aborted) break;
      yield decoder.decode(arr.value);
    }
  } catch (e) {
    if (!(e instanceof TypeError)) throw e;
  } finally {
    Deno.stdin.setRaw(false);
  }
}

However, Deno does not follow the MDN description of the specs, as the entire next line of stdin is consumed and not given to the next reader. In my example this manifests by the fact that after the message “Good job, you pressed keys.” appears, the user has to press Enter two times (the first one is ignored).