Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions packages/inquirerer/__tests__/no-eager-stdio.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
14 changes: 12 additions & 2 deletions packages/inquirerer/src/commander.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
Loading