diff --git a/src/main.ts b/src/main.ts index cffa0a7..5ac033f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -17,6 +17,8 @@ export {NonZeroExitError, normalizeSpawnCommand}; const LINE_SEPARATOR_REGEX = /\r?\n/; +const pipedStreams = new WeakSet(); + export interface Output { stderr: string; stdout: string; @@ -334,6 +336,7 @@ export class ExecProcess implements Result { this._process = handle; handle.once('error', this._onError); + handle.once('exit', this._onExit); handle.once('close', this._onClose); if (handle.stdin) { @@ -342,7 +345,11 @@ export class ExecProcess implements Result { if (typeof stdin === 'string') { handle.stdin.end(stdin); } else { - stdin?.process?.stdout?.pipe(handle.stdin); + const src = stdin?.process?.stdout; + if (src) { + src.pipe(handle.stdin); + pipedStreams.add(src); + } } } } @@ -366,6 +373,28 @@ export class ExecProcess implements Result { this._thrownError = err; }; + protected _onExit = (): void => { + // Node emits 'exit' before stdio streams have drained. Use setImmediate + // to let buffered data flow through before destroying the streams. + // If grandchild processes hold the pipe fds open, they would never fire + // 'close', so we destroy here to unblock readStream and combineStreams. + const out = + this._streamOut && !pipedStreams.has(this._streamOut) + ? this._streamOut + : undefined; + const err = + this._streamErr && !pipedStreams.has(this._streamErr) + ? this._streamErr + : undefined; + if (!out && !err) { + return; + } + setImmediate(() => { + out?.destroy(); + err?.destroy(); + }); + }; + protected _onClose = (): void => { if (this._resolveClose) { this._resolveClose(); diff --git a/src/test/main_test.ts b/src/test/main_test.ts index d499f97..f2c70c6 100644 --- a/src/test/main_test.ts +++ b/src/test/main_test.ts @@ -3,8 +3,11 @@ import {describe, test, expect} from 'vitest'; import os from 'node:os'; import fs from 'node:fs'; import path from 'node:path'; +import {spawnSync} from 'node:child_process'; const isWindows = os.platform() === 'win32'; +const fixturesDir = path.join(import.meta.dirname, '../../test/fixtures'); +const distDir = path.join(import.meta.dirname, '../../dist'); const variants = [ {name: 'async', x, isAsync: true}, @@ -363,6 +366,82 @@ if (!isWindows) { fs.rmSync(dir, {recursive: true, force: true}); } }); + + test('resolves when grandchild holds piped stdout open', async () => { + const dir = fs.mkdtempSync( + path.join(os.tmpdir(), 'tinyexec-grandchild-') + ); + const runnerScript = path.join(dir, 'runner.mjs'); + const distPath = JSON.stringify(path.join(distDir, 'main.mjs')); + const fixturePath = JSON.stringify( + path.join(fixturesDir, 'grandchild.mjs') + ); + + fs.writeFileSync( + runnerScript, + `import { x } from ${distPath} + const result = await x('node', [${fixturePath}]) + process.stdout.write(JSON.stringify({ stdout: result.stdout, exitCode: result.exitCode })) + ` + ); + + try { + const proc = spawnSync('node', [runnerScript], { + timeout: 10000, + encoding: 'utf8', + killSignal: 'SIGKILL', + stdio: ['pipe', 'pipe', 'pipe'] + }); + + expect(proc.signal).not.toBe('SIGKILL'); + expect(proc.status).toBe(0); + const parsed = JSON.parse(proc.stdout.trim()); + expect(parsed.exitCode).toBe(0); + expect(parsed.stdout).toBe('output\n'); + } finally { + spawnSync('pkill', ['-f', 'tinyexec-test-grandchild']); + fs.rmSync(dir, {recursive: true, force: true}); + } + }); + + test('iterator completes when grandchild holds piped stdout open', async () => { + const dir = fs.mkdtempSync( + path.join(os.tmpdir(), 'tinyexec-grandchild-') + ); + const runnerScript = path.join(dir, 'runner.mjs'); + const distPath = JSON.stringify(path.join(distDir, 'main.mjs')); + const fixturePath = JSON.stringify( + path.join(fixturesDir, 'grandchild_multiline.mjs') + ); + + fs.writeFileSync( + runnerScript, + `import { x } from ${distPath} + const lines = [] + for await (const line of x('node', [${fixturePath}])) { + lines.push(line) + } + process.stdout.write(JSON.stringify(lines)) + ` + ); + + try { + const proc = spawnSync('node', [runnerScript], { + timeout: 10000, + encoding: 'utf8', + killSignal: 'SIGKILL', + stdio: ['pipe', 'pipe', 'pipe'] + }); + + expect(proc.signal).not.toBe('SIGKILL'); + expect(proc.status).toBe(0); + const parsed = JSON.parse(proc.stdout.trim()); + expect(parsed).toEqual(['line1', 'line2']); + } finally { + spawnSync('pkill', ['-f', 'tinyexec-test-grandchild']); + fs.rmSync(dir, {recursive: true, force: true}); + } + }); }); describe('exec (unix-like) (sync)', () => { diff --git a/test/fixtures/grandchild.mjs b/test/fixtures/grandchild.mjs new file mode 100644 index 0000000..52ef8ab --- /dev/null +++ b/test/fixtures/grandchild.mjs @@ -0,0 +1,13 @@ +import {spawn} from 'node:child_process'; + +// Spawn a grandchild that inherits the piped stdout fd, simulating +// tsserver inheriting eslint's piped streams. Short timeout to avoid +// blocking test teardown. +spawn( + process.argv[0], + ['-e', 'setTimeout(() => void 0, 3000)'], + {stdio: ['ignore', 1, 'ignore']} +); + +console.log('output'); +process.exit(0); diff --git a/test/fixtures/grandchild_multiline.mjs b/test/fixtures/grandchild_multiline.mjs new file mode 100644 index 0000000..253e1e2 --- /dev/null +++ b/test/fixtures/grandchild_multiline.mjs @@ -0,0 +1,11 @@ +import {spawn} from 'node:child_process'; + +spawn( + process.argv[0], + ['-e', 'setTimeout(() => void 0, 3000)'], + {stdio: ['ignore', 1, 'ignore']} +); + +console.log('line1'); +console.log('line2'); +process.exit(0);