diff --git a/packages/inquirerer/__tests__/no-eager-stdio.test.ts b/packages/inquirerer/__tests__/no-eager-stdio.test.ts new file mode 100644 index 0000000..49e441b --- /dev/null +++ b/packages/inquirerer/__tests__/no-eager-stdio.test.ts @@ -0,0 +1,62 @@ +import { execFileSync } from 'child_process'; +import { mkdtempSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +import { defaultCLIOptions } from '../src/commander'; + +describe('inquirerer does not eagerly open stdio on require', () => { + it('exposes defaultCLIOptions.input/output as lazy getters', () => { + const inputDesc = Object.getOwnPropertyDescriptor(defaultCLIOptions, 'input'); + const outputDesc = Object.getOwnPropertyDescriptor(defaultCLIOptions, 'output'); + + expect(inputDesc).toBeDefined(); + expect(outputDesc).toBeDefined(); + // Must be accessor properties, not data properties — otherwise + // `process.stdin` / `process.stdout` would be evaluated at + // module-load time and register libuv handles even for consumers + // that never construct a CLI. + expect(typeof inputDesc!.get).toBe('function'); + expect(typeof outputDesc!.get).toBe('function'); + expect(inputDesc!.value).toBeUndefined(); + expect(outputDesc!.value).toBeUndefined(); + }); + + it('reading the getters returns process.stdin / process.stdout', () => { + expect(defaultCLIOptions.input).toBe(process.stdin); + expect(defaultCLIOptions.output).toBe(process.stdout); + }); + + // End-to-end guard: spawn a fresh Node process with stdin piped (so fd 0 + // is a PIPEWRAP, which is exactly the Jest-worker / spawned-child case + // that surfaced the original bug in pgsql-test → @pgpmjs/core → genomic + // → inquirerer). Require inquirerer, then assert the active handle set + // is empty — i.e. no stdio was lazily materialised just by importing. + it('require() alone does not materialise stdio handles', () => { + const dir = mkdtempSync(join(tmpdir(), 'inquirerer-stdio-')); + const probe = join(dir, 'probe.js'); + writeFileSync( + probe, + ` + require(${JSON.stringify(require.resolve('../dist/index.js'))}); + setImmediate(() => { + const byType = {}; + for (const h of process._getActiveHandles()) { + const t = (h && h.constructor && h.constructor.name) || typeof h; + byType[t] = (byType[t] || 0) + 1; + } + process.stdout.write(JSON.stringify(byType)); + }); + ` + ); + + const out = execFileSync(process.execPath, [probe], { + input: '', + encoding: 'utf8', + }); + const byType = JSON.parse(out); + // No Socket (stdin) or WriteStream (stdout) should be kept alive. + expect(byType.Socket).toBeUndefined(); + expect(byType.WriteStream).toBeUndefined(); + }); +}); diff --git a/packages/inquirerer/src/commander.ts b/packages/inquirerer/src/commander.ts index bd1296c..9a4e49f 100644 --- a/packages/inquirerer/src/commander.ts +++ b/packages/inquirerer/src/commander.ts @@ -16,11 +16,21 @@ export interface CLIOptions { version: string; } +// NOTE: `input` and `output` are lazy getters rather than plain properties. +// `process.stdin` / `process.stdout` are Node getters that lazy-construct +// streams on first access. When fd 0 is a pipe (e.g. a Jest worker or any +// spawned child), constructing stdin registers a libuv PIPEWRAP handle +// that the runtime must close before the process can exit. Evaluating +// these defaults eagerly as object properties meant that merely +// `require('inquirerer')` — even via a transitive dep that never +// constructs `CLI` — would open stdin and produce a spurious open-handle +// warning under `jest --detectOpenHandles`. Deferring the access to +// read-time keeps the module side-effect free. export const defaultCLIOptions: CLIOptions = { version: `inquirerer@${getVersion()}`, noTty: false, - input: process.stdin, - output: process.stdout, + get input() { return process.stdin; }, + get output() { return process.stdout; }, minimistOpts: { alias: { v: 'version'