diff --git a/src/read.ts b/src/read.ts index e820854..c64495c 100644 --- a/src/read.ts +++ b/src/read.ts @@ -90,7 +90,10 @@ export async function read ({ } /* c8 ignore stop */ + let finished = false + const done = () => { + finished = true rl.close() clearTimeout(timer) m.mute() @@ -121,6 +124,19 @@ export async function read ({ /* c8 ignore stop */ }) + // readline emits 'close' without ever emitting 'line' when the input stream + // ends first, which is what happens any time stdin is not something a user + // can type into: /dev/null, an already closed pipe, a child process spawned + // without stdin. Settle the promise here or it never settles at all, and the + // caller waits on it until the event loop empties and the process exits with + // no error to report. Treated as a cancel, same as SIGINT, since both mean no + // answer is coming. + rl.on('close', () => { + if (!finished) { + onError(new Error('canceled')) + } + }) + // TODO: add tests for sigint /* c8 ignore start */ rl.on('SIGINT', () => { diff --git a/test/basic.ts b/test/basic.ts index 7997be4..fdb7e4b 100644 --- a/test/basic.ts +++ b/test/basic.ts @@ -1,5 +1,6 @@ import { test } from 'node:test' import { strict as assert } from 'node:assert' +import { PassThrough } from 'node:stream' import { read } from '../src/read.ts' import spawnRead from './fixtures/setup.ts' @@ -73,6 +74,25 @@ const main = () => { // @ts-expect-error await assert.rejects(() => read({ default: {} })) }) + + test('input stream ends without a line', async () => { + const input = new PassThrough() + const output = new PassThrough() + input.end() + + await assert.rejects( + () => read({ prompt: 'Username:', input, output }), + /canceled/ + ) + }) + + test('input stream ends after a line still resolves', async () => { + const input = new PassThrough() + const output = new PassThrough() + input.end('a user\n') + + assert.equal(await read({ prompt: 'Username:', input, output }), 'a user') + }) } if (process.argv[2] === 'child') {