Skip to content

Commit 79eedf7

Browse files
committed
fix(inquirerer): defer process.stdin/stdout access in defaultCLIOptions
Evaluating `input: process.stdin` / `output: process.stdout` as plain properties on the `defaultCLIOptions` object literal meant those streams were constructed at module-load time, not when a `CLI` was actually instantiated. `process.stdin` and `process.stdout` are lazy getters on the process object that materialise Readable/Writable streams on first access. When fd 0 is a pipe (Jest workers, spawned children, backgrounded processes), constructing stdin registers a libuv PIPEWRAP handle that the runtime must close before exit. As a result, any library that transitively required `inquirerer` (e.g. pgsql-test -> pgsql-client -> @pgpmjs/core -> genomic -> inquirerer) would open stdin as a pure import side-effect and trigger false positives under `jest --detectOpenHandles`. Convert the two fields to accessor properties so the stream references are only evaluated when someone reads them (i.e. when `new CLI(...)` actually runs). No behavioural change for direct CLI consumers; the fields still return `process.stdin` / `process.stdout` on read. Add a regression test that (1) asserts the descriptors are getters and (2) spawns a fresh Node process with piped stdin, requires the built package, and verifies no Socket/WriteStream handles remain active.
1 parent b1d40ce commit 79eedf7

2 files changed

Lines changed: 74 additions & 2 deletions

File tree

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { execFileSync } from 'child_process';
2+
import { mkdtempSync, writeFileSync } from 'fs';
3+
import { tmpdir } from 'os';
4+
import { join } from 'path';
5+
6+
import { defaultCLIOptions } from '../src/commander';
7+
8+
describe('inquirerer does not eagerly open stdio on require', () => {
9+
it('exposes defaultCLIOptions.input/output as lazy getters', () => {
10+
const inputDesc = Object.getOwnPropertyDescriptor(defaultCLIOptions, 'input');
11+
const outputDesc = Object.getOwnPropertyDescriptor(defaultCLIOptions, 'output');
12+
13+
expect(inputDesc).toBeDefined();
14+
expect(outputDesc).toBeDefined();
15+
// Must be accessor properties, not data properties — otherwise
16+
// `process.stdin` / `process.stdout` would be evaluated at
17+
// module-load time and register libuv handles even for consumers
18+
// that never construct a CLI.
19+
expect(typeof inputDesc!.get).toBe('function');
20+
expect(typeof outputDesc!.get).toBe('function');
21+
expect(inputDesc!.value).toBeUndefined();
22+
expect(outputDesc!.value).toBeUndefined();
23+
});
24+
25+
it('reading the getters returns process.stdin / process.stdout', () => {
26+
expect(defaultCLIOptions.input).toBe(process.stdin);
27+
expect(defaultCLIOptions.output).toBe(process.stdout);
28+
});
29+
30+
// End-to-end guard: spawn a fresh Node process with stdin piped (so fd 0
31+
// is a PIPEWRAP, which is exactly the Jest-worker / spawned-child case
32+
// that surfaced the original bug in pgsql-test → @pgpmjs/core → genomic
33+
// → inquirerer). Require inquirerer, then assert the active handle set
34+
// is empty — i.e. no stdio was lazily materialised just by importing.
35+
it('require() alone does not materialise stdio handles', () => {
36+
const dir = mkdtempSync(join(tmpdir(), 'inquirerer-stdio-'));
37+
const probe = join(dir, 'probe.js');
38+
writeFileSync(
39+
probe,
40+
`
41+
require(${JSON.stringify(require.resolve('../dist/index.js'))});
42+
setImmediate(() => {
43+
const byType = {};
44+
for (const h of process._getActiveHandles()) {
45+
const t = (h && h.constructor && h.constructor.name) || typeof h;
46+
byType[t] = (byType[t] || 0) + 1;
47+
}
48+
process.stdout.write(JSON.stringify(byType));
49+
});
50+
`
51+
);
52+
53+
const out = execFileSync(process.execPath, [probe], {
54+
input: '',
55+
encoding: 'utf8',
56+
});
57+
const byType = JSON.parse(out);
58+
// No Socket (stdin) or WriteStream (stdout) should be kept alive.
59+
expect(byType.Socket).toBeUndefined();
60+
expect(byType.WriteStream).toBeUndefined();
61+
});
62+
});

packages/inquirerer/src/commander.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,21 @@ export interface CLIOptions {
1616
version: string;
1717
}
1818

19+
// NOTE: `input` and `output` are lazy getters rather than plain properties.
20+
// `process.stdin` / `process.stdout` are Node getters that lazy-construct
21+
// streams on first access. When fd 0 is a pipe (e.g. a Jest worker or any
22+
// spawned child), constructing stdin registers a libuv PIPEWRAP handle
23+
// that the runtime must close before the process can exit. Evaluating
24+
// these defaults eagerly as object properties meant that merely
25+
// `require('inquirerer')` — even via a transitive dep that never
26+
// constructs `CLI` — would open stdin and produce a spurious open-handle
27+
// warning under `jest --detectOpenHandles`. Deferring the access to
28+
// read-time keeps the module side-effect free.
1929
export const defaultCLIOptions: CLIOptions = {
2030
version: `inquirerer@${getVersion()}`,
2131
noTty: false,
22-
input: process.stdin,
23-
output: process.stdout,
32+
get input() { return process.stdin; },
33+
get output() { return process.stdout; },
2434
minimistOpts: {
2535
alias: {
2636
v: 'version'

0 commit comments

Comments
 (0)