From 6b5a26ae61ba835dc402a499cfe5dabc00ae28ab Mon Sep 17 00:00:00 2001 From: Yakub Hossain <28190921+1yakub@users.noreply.github.com> Date: Sun, 9 Aug 2026 03:09:55 +0600 Subject: [PATCH 1/2] fix: settle the promise when the input stream ends --- src/read.ts | 16 ++++++++++++++++ test/basic.ts | 20 ++++++++++++++++++++ 2 files changed, 36 insertions(+) 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') { From 631f4bfc7f633b595c20acfaa9188c8a400c2da0 Mon Sep 17 00:00:00 2001 From: Yakub Hossain Date: Fri, 4 Sep 2026 21:42:03 +0600 Subject: [PATCH 2/2] fix: let onError own the close on SIGINT, add a SIGINT test --- src/read.ts | 7 +++---- test/basic.ts | 17 +++++++++++++++++ 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/src/read.ts b/src/read.ts index c64495c..74d207f 100644 --- a/src/read.ts +++ b/src/read.ts @@ -137,12 +137,11 @@ export async function read ({ } }) - // TODO: add tests for sigint - /* c8 ignore start */ + // onError() calls done(), which closes the interface. An explicit close + // here would re enter through the 'close' listener above and run the + // cleanup twice. rl.on('SIGINT', () => { - rl.close() onError(new Error('canceled')) }) - /* c8 ignore stop */ }) } diff --git a/test/basic.ts b/test/basic.ts index fdb7e4b..f1a10a6 100644 --- a/test/basic.ts +++ b/test/basic.ts @@ -93,6 +93,23 @@ const main = () => { assert.equal(await read({ prompt: 'Username:', input, output }), 'a user') }) + + test('SIGINT rejects with canceled and leaves read usable', async () => { + const input = new PassThrough() + const output = new PassThrough() + + const p = read({ prompt: 'Username:', input, output, terminal: true }) + // ctrl+c keypress; readline turns it into a 'SIGINT' event in terminal mode + input.write('\x03') + await assert.rejects(() => p, /canceled/) + input.end() + + // a fresh read after the cancel still works + const input2 = new PassThrough() + const output2 = new PassThrough() + input2.end('after\n') + assert.equal(await read({ prompt: 'Username:', input: input2, output: output2 }), 'after') + }) } if (process.argv[2] === 'child') {