From bf59661f666606d931e6fb56073ee0d03b85e106 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 26 May 2026 19:39:14 +0100 Subject: [PATCH 1/8] test: verify exec completes when grandchild holds piped stdout When a child process spawns a grandchild that inherits the piped stdout fd (fd 1), the grandchild keeps the pipe open after the child exits. Without the fix, both `await exec()` and the async iterator hang indefinitely because the piped streams never end. Each test runs in a subprocess (spawnSync with a 10s timeout) so the orphaned grandchild doesn't block vitest. A 5s inner race detects whether exec completes or hangs. --- src/test/grandchild_test.ts | 130 ++++++++++++++++++++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 src/test/grandchild_test.ts diff --git a/src/test/grandchild_test.ts b/src/test/grandchild_test.ts new file mode 100644 index 0000000..06d23ab --- /dev/null +++ b/src/test/grandchild_test.ts @@ -0,0 +1,130 @@ +import {x} from '../main.js'; +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'; + +describe.skipIf(isWindows)('exec (grandchild pipe inheritance)', () => { + test('await completes when grandchild holds piped stdout open', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tinyexec-grandchild-')); + const childScript = path.join(dir, 'child.mjs'); + + fs.writeFileSync( + childScript, + `import { spawn } from 'node:child_process' +spawn('node', ['-e', 'setTimeout(() => void 0, 30000)'], { + stdio: ['ignore', 1, 'ignore'], +}) +process.stdout.write('output\\n') +process.exit(0) +` + ); + + // Run in a subprocess so the orphaned grandchild doesn't block vitest. + // The runner uses the built dist/main.mjs directly. + const runnerScript = path.join(dir, 'runner.mjs'); + const distPath = path.join(process.cwd(), 'dist', 'main.mjs'); + fs.writeFileSync( + runnerScript, + `import { x } from '${distPath}' + +const result = await Promise.race([ + x('node', ['${childScript}']).then(() => 'completed'), + new Promise((resolve) => setTimeout(() => resolve('hung'), 5000)), +]) + +try { process.kill(-process.pid) } catch {} +process.stdout.write(result) +process.exit(result === 'completed' ? 0 : 1) +` + ); + + try { + const proc = spawnSync('node', [runnerScript], { + timeout: 10000, + encoding: 'utf8', + killSignal: 'SIGKILL', + }); + + if (proc.signal === 'SIGKILL') { + expect.unreachable( + 'exec hung for 10s (grandchild held pipe open)' + ); + } + + expect(proc.status).toBe(0); + expect(proc.stdout.trim()).toBe('completed'); + } finally { + try { + spawnSync('pkill', ['-f', dir]); + } catch {} + fs.rmSync(dir, {recursive: true, force: true}); + } + }); + + test('async iterator completes when grandchild holds piped stdout open', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tinyexec-grandchild-')); + const childScript = path.join(dir, 'child.mjs'); + + fs.writeFileSync( + childScript, + `import { spawn } from 'node:child_process' +spawn('node', ['-e', 'setTimeout(() => void 0, 30000)'], { + stdio: ['ignore', 1, 'ignore'], +}) +process.stdout.write('line1\\nline2\\n') +process.exit(0) +` + ); + + const runnerScript = path.join(dir, 'runner.mjs'); + const distPath = path.join(process.cwd(), 'dist', 'main.mjs'); + fs.writeFileSync( + runnerScript, + `import { x } from '${distPath}' + +const lines = [] +const result = await Promise.race([ + (async () => { + for await (const line of x('node', ['${childScript}'])) { + lines.push(line) + } + return 'completed' + })(), + new Promise((resolve) => setTimeout(() => resolve('hung'), 5000)), +]) + +try { process.kill(-process.pid) } catch {} +process.stdout.write(JSON.stringify({ result, lines })) +process.exit(result === 'completed' ? 0 : 1) +` + ); + + try { + const proc = spawnSync('node', [runnerScript], { + timeout: 10000, + encoding: 'utf8', + killSignal: 'SIGKILL', + }); + + if (proc.signal === 'SIGKILL') { + expect.unreachable( + 'async iterator hung for 10s (grandchild held pipe open)' + ); + } + + expect(proc.status).toBe(0); + const parsed = JSON.parse(proc.stdout.trim()); + expect(parsed.result).toBe('completed'); + expect(parsed.lines).toEqual(['line1', 'line2']); + } finally { + try { + spawnSync('pkill', ['-f', dir]); + } catch {} + fs.rmSync(dir, {recursive: true, force: true}); + } + }); +}); From 9657bb51b5e793dcf27f3b1471ed2cf37835b2a8 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 26 May 2026 19:39:40 +0100 Subject: [PATCH 2/8] fix: destroy piped streams on child exit to prevent grandchild deadlock When a child process spawns a grandchild that inherits the piped stdout/stderr file descriptors, the grandchild holds them open after the child exits. Node's close event waits for all fds to be released, so it never fires. readStream and combineStreams hang indefinitely because the streams never end. Listen for the exit event (fires when the process exits, regardless of pipe state) and destroy the piped streams. This forces the PassThrough and readline consumers to complete. Refs: lint-staged/lint-staged#1800 --- src/main.ts | 10 +++ src/test/grandchild_test.ts | 136 ++++++++++++++++++------------------ 2 files changed, 79 insertions(+), 67 deletions(-) diff --git a/src/main.ts b/src/main.ts index cffa0a7..73c1f30 100644 --- a/src/main.ts +++ b/src/main.ts @@ -334,6 +334,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) { @@ -366,6 +367,15 @@ export class ExecProcess implements Result { this._thrownError = err; }; + protected _onExit = (): void => { + // Destroy piped streams when the child process exits, even if grandchild + // processes still hold the underlying file descriptors open. Without this, + // the 'close' event never fires (it waits for all fds to be released), + // causing readStream and combineStreams to hang indefinitely. + this._streamOut?.destroy(); + this._streamErr?.destroy(); + }; + protected _onClose = (): void => { if (this._resolveClose) { this._resolveClose(); diff --git a/src/test/grandchild_test.ts b/src/test/grandchild_test.ts index 06d23ab..643dd67 100644 --- a/src/test/grandchild_test.ts +++ b/src/test/grandchild_test.ts @@ -1,4 +1,3 @@ -import {x} from '../main.js'; import {describe, test, expect} from 'vitest'; import os from 'node:os'; import fs from 'node:fs'; @@ -7,124 +6,127 @@ import {spawnSync} from 'node:child_process'; const isWindows = os.platform() === 'win32'; +function runInSubprocess( + childScript: string, + runnerBody: string +): {status: number | null; signal: string | null; stdout: string} { + const dir = path.dirname(childScript); + const runnerScript = path.join(dir, 'runner.mjs'); + const distPath = JSON.stringify( + path.join(process.cwd(), 'dist', 'main.mjs') + ); + const childPath = JSON.stringify(childScript); + + fs.writeFileSync( + runnerScript, + `import { x } from ${distPath}\n${runnerBody.replace(/CHILD_SCRIPT/g, childPath)}` + ); + + try { + const proc = spawnSync('node', [runnerScript], { + timeout: 10000, + encoding: 'utf8', + killSignal: 'SIGKILL' + }); + + return { + status: proc.status, + signal: proc.signal, + stdout: proc.stdout ?? '' + }; + } finally { + try { + spawnSync('pkill', ['-f', dir]); + } catch {} + fs.rmSync(dir, {recursive: true, force: true}); + } +} + describe.skipIf(isWindows)('exec (grandchild pipe inheritance)', () => { test('await completes when grandchild holds piped stdout open', async () => { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tinyexec-grandchild-')); + const dir = fs.mkdtempSync( + path.join(os.tmpdir(), 'tinyexec-grandchild-') + ); const childScript = path.join(dir, 'child.mjs'); fs.writeFileSync( childScript, `import { spawn } from 'node:child_process' +console.log('output') spawn('node', ['-e', 'setTimeout(() => void 0, 30000)'], { stdio: ['ignore', 1, 'ignore'], }) -process.stdout.write('output\\n') process.exit(0) ` ); - // Run in a subprocess so the orphaned grandchild doesn't block vitest. - // The runner uses the built dist/main.mjs directly. - const runnerScript = path.join(dir, 'runner.mjs'); - const distPath = path.join(process.cwd(), 'dist', 'main.mjs'); - fs.writeFileSync( - runnerScript, - `import { x } from '${distPath}' - + const result = runInSubprocess( + childScript, + ` const result = await Promise.race([ - x('node', ['${childScript}']).then(() => 'completed'), + x('node', [CHILD_SCRIPT]).then(() => 'completed'), new Promise((resolve) => setTimeout(() => resolve('hung'), 5000)), ]) - -try { process.kill(-process.pid) } catch {} process.stdout.write(result) process.exit(result === 'completed' ? 0 : 1) ` ); - try { - const proc = spawnSync('node', [runnerScript], { - timeout: 10000, - encoding: 'utf8', - killSignal: 'SIGKILL', - }); - - if (proc.signal === 'SIGKILL') { - expect.unreachable( - 'exec hung for 10s (grandchild held pipe open)' - ); - } - - expect(proc.status).toBe(0); - expect(proc.stdout.trim()).toBe('completed'); - } finally { - try { - spawnSync('pkill', ['-f', dir]); - } catch {} - fs.rmSync(dir, {recursive: true, force: true}); + if (result.signal === 'SIGKILL') { + expect.unreachable( + 'exec hung for 10s (grandchild held pipe open)' + ); } + + expect(result.status).toBe(0); + expect(result.stdout.trim()).toBe('completed'); }); test('async iterator completes when grandchild holds piped stdout open', async () => { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tinyexec-grandchild-')); + const dir = fs.mkdtempSync( + path.join(os.tmpdir(), 'tinyexec-grandchild-') + ); const childScript = path.join(dir, 'child.mjs'); fs.writeFileSync( childScript, `import { spawn } from 'node:child_process' +console.log('line1') +console.log('line2') spawn('node', ['-e', 'setTimeout(() => void 0, 30000)'], { stdio: ['ignore', 1, 'ignore'], }) -process.stdout.write('line1\\nline2\\n') process.exit(0) ` ); - const runnerScript = path.join(dir, 'runner.mjs'); - const distPath = path.join(process.cwd(), 'dist', 'main.mjs'); - fs.writeFileSync( - runnerScript, - `import { x } from '${distPath}' - + const result = runInSubprocess( + childScript, + ` const lines = [] const result = await Promise.race([ (async () => { - for await (const line of x('node', ['${childScript}'])) { + for await (const line of x('node', [CHILD_SCRIPT])) { lines.push(line) } return 'completed' })(), new Promise((resolve) => setTimeout(() => resolve('hung'), 5000)), ]) - -try { process.kill(-process.pid) } catch {} process.stdout.write(JSON.stringify({ result, lines })) process.exit(result === 'completed' ? 0 : 1) ` ); - try { - const proc = spawnSync('node', [runnerScript], { - timeout: 10000, - encoding: 'utf8', - killSignal: 'SIGKILL', - }); - - if (proc.signal === 'SIGKILL') { - expect.unreachable( - 'async iterator hung for 10s (grandchild held pipe open)' - ); - } - - expect(proc.status).toBe(0); - const parsed = JSON.parse(proc.stdout.trim()); - expect(parsed.result).toBe('completed'); - expect(parsed.lines).toEqual(['line1', 'line2']); - } finally { - try { - spawnSync('pkill', ['-f', dir]); - } catch {} - fs.rmSync(dir, {recursive: true, force: true}); + if (result.signal === 'SIGKILL') { + expect.unreachable( + 'async iterator hung for 10s (grandchild held pipe open)' + ); } + + expect(result.status).toBe(0); + const parsed = JSON.parse(result.stdout.trim()); + expect(parsed.result).toBe('completed'); + expect(parsed.lines).toEqual(['line1', 'line2']); }); }); From 07f9240436b1363e3750be73941b7cf78818c774 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 27 May 2026 19:14:28 +0100 Subject: [PATCH 3/8] test: add grandchild fixture scripts for pipe inheritance tests Static test fixtures that spawn a long-lived grandchild process inheriting the piped stdout fd, simulating tsserver inheriting eslint's piped streams through lint-staged/tinyexec. --- test/fixtures/grandchild.mjs | 13 +++++++++++++ test/fixtures/grandchild_multiline.mjs | 11 +++++++++++ 2 files changed, 24 insertions(+) create mode 100644 test/fixtures/grandchild.mjs create mode 100644 test/fixtures/grandchild_multiline.mjs 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); From a253d7f787b48e89875e59becc62e67aaf0562a1 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 27 May 2026 19:14:47 +0100 Subject: [PATCH 4/8] test: restructure grandchild tests to use static fixtures Use committed fixture scripts instead of generated inline scripts. Run tests in a subprocess with isolated stdio so orphaned grandchildren don't block vitest's teardown. Clean up grandchildren via pkill with a marker comment in the fixture scripts. --- src/test/grandchild_test.ts | 155 ++++++++++++++---------------------- 1 file changed, 61 insertions(+), 94 deletions(-) diff --git a/src/test/grandchild_test.ts b/src/test/grandchild_test.ts index 643dd67..8129ed9 100644 --- a/src/test/grandchild_test.ts +++ b/src/test/grandchild_test.ts @@ -5,41 +5,15 @@ 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'); -function runInSubprocess( - childScript: string, - runnerBody: string -): {status: number | null; signal: string | null; stdout: string} { - const dir = path.dirname(childScript); - const runnerScript = path.join(dir, 'runner.mjs'); - const distPath = JSON.stringify( - path.join(process.cwd(), 'dist', 'main.mjs') - ); - const childPath = JSON.stringify(childScript); - - fs.writeFileSync( - runnerScript, - `import { x } from ${distPath}\n${runnerBody.replace(/CHILD_SCRIPT/g, childPath)}` - ); - +// Tests run in a subprocess because the grandchild process spawned by the +// fixture script stays alive for 30s and would block vitest's teardown. +// Grandchildren are tagged with TINYEXEC_TEST_GRANDCHILD=1 for cleanup. +function killTestGrandchildren(): void { try { - const proc = spawnSync('node', [runnerScript], { - timeout: 10000, - encoding: 'utf8', - killSignal: 'SIGKILL' - }); - - return { - status: proc.status, - signal: proc.signal, - stdout: proc.stdout ?? '' - }; - } finally { - try { - spawnSync('pkill', ['-f', dir]); - } catch {} - fs.rmSync(dir, {recursive: true, force: true}); - } + spawnSync('pkill', ['-f', 'tinyexec-test-grandchild']); + } catch {} } describe.skipIf(isWindows)('exec (grandchild pipe inheritance)', () => { @@ -47,86 +21,79 @@ describe.skipIf(isWindows)('exec (grandchild pipe inheritance)', () => { const dir = fs.mkdtempSync( path.join(os.tmpdir(), 'tinyexec-grandchild-') ); - const childScript = path.join(dir, 'child.mjs'); + const runnerScript = path.join(dir, 'runner.mjs'); + const distPath = JSON.stringify( + path.join(process.cwd(), 'dist', 'main.mjs') + ); + const fixturePath = JSON.stringify( + path.join(fixturesDir, 'grandchild.mjs') + ); fs.writeFileSync( - childScript, - `import { spawn } from 'node:child_process' -console.log('output') -spawn('node', ['-e', 'setTimeout(() => void 0, 30000)'], { - stdio: ['ignore', 1, 'ignore'], -}) -process.exit(0) + runnerScript, + `import { x } from ${distPath} +const result = await x('node', [${fixturePath}]) +process.stdout.write(JSON.stringify({ stdout: result.stdout, exitCode: result.exitCode })) ` ); - const result = runInSubprocess( - childScript, - ` -const result = await Promise.race([ - x('node', [CHILD_SCRIPT]).then(() => 'completed'), - new Promise((resolve) => setTimeout(() => resolve('hung'), 5000)), -]) -process.stdout.write(result) -process.exit(result === 'completed' ? 0 : 1) -` - ); + try { + const proc = spawnSync('node', [runnerScript], { + timeout: 10000, + encoding: 'utf8', + killSignal: 'SIGKILL', + stdio: ['pipe', 'pipe', 'pipe'] + }); - if (result.signal === 'SIGKILL') { - expect.unreachable( - 'exec hung for 10s (grandchild held pipe open)' - ); + 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 { + killTestGrandchildren(); + fs.rmSync(dir, {recursive: true, force: true}); } - - expect(result.status).toBe(0); - expect(result.stdout.trim()).toBe('completed'); }); test('async iterator completes when grandchild holds piped stdout open', async () => { const dir = fs.mkdtempSync( path.join(os.tmpdir(), 'tinyexec-grandchild-') ); - const childScript = path.join(dir, 'child.mjs'); - - fs.writeFileSync( - childScript, - `import { spawn } from 'node:child_process' -console.log('line1') -console.log('line2') -spawn('node', ['-e', 'setTimeout(() => void 0, 30000)'], { - stdio: ['ignore', 1, 'ignore'], -}) -process.exit(0) -` + const runnerScript = path.join(dir, 'runner.mjs'); + const distPath = JSON.stringify( + path.join(process.cwd(), 'dist', 'main.mjs') + ); + const fixturePath = JSON.stringify( + path.join(fixturesDir, 'grandchild_multiline.mjs') ); - const result = runInSubprocess( - childScript, - ` + fs.writeFileSync( + runnerScript, + `import { x } from ${distPath} const lines = [] -const result = await Promise.race([ - (async () => { - for await (const line of x('node', [CHILD_SCRIPT])) { - lines.push(line) - } - return 'completed' - })(), - new Promise((resolve) => setTimeout(() => resolve('hung'), 5000)), -]) -process.stdout.write(JSON.stringify({ result, lines })) -process.exit(result === 'completed' ? 0 : 1) +for await (const line of x('node', [${fixturePath}])) { + lines.push(line) +} +process.stdout.write(JSON.stringify(lines)) ` ); - if (result.signal === 'SIGKILL') { - expect.unreachable( - 'async iterator hung for 10s (grandchild held pipe open)' - ); - } + try { + const proc = spawnSync('node', [runnerScript], { + timeout: 10000, + encoding: 'utf8', + killSignal: 'SIGKILL', + stdio: ['pipe', 'pipe', 'pipe'] + }); - expect(result.status).toBe(0); - const parsed = JSON.parse(result.stdout.trim()); - expect(parsed.result).toBe('completed'); - expect(parsed.lines).toEqual(['line1', 'line2']); + expect(proc.signal).not.toBe('SIGKILL'); + expect(proc.status).toBe(0); + const parsed = JSON.parse(proc.stdout.trim()); + expect(parsed).toEqual(['line1', 'line2']); + } finally { + killTestGrandchildren(); + fs.rmSync(dir, {recursive: true, force: true}); + } }); }); From 5537d3bdb07b841e90fb88cac496af3e10f09b86 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 27 May 2026 19:15:11 +0100 Subject: [PATCH 5/8] fix: use setImmediate instead of setTimeout for stream cleanup setImmediate is sufficient to let buffered data drain before destroying the streams. Removes the timer tracking in _resetState and _onClose that was needed for the setTimeout approach. --- src/main.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/main.ts b/src/main.ts index 73c1f30..cc9efe4 100644 --- a/src/main.ts +++ b/src/main.ts @@ -368,12 +368,16 @@ export class ExecProcess implements Result { }; protected _onExit = (): void => { - // Destroy piped streams when the child process exits, even if grandchild - // processes still hold the underlying file descriptors open. Without this, - // the 'close' event never fires (it waits for all fds to be released), - // causing readStream and combineStreams to hang indefinitely. - this._streamOut?.destroy(); - this._streamErr?.destroy(); + // 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, 'close' never fires, + // so we destroy here to unblock readStream and combineStreams. + const out = this._streamOut; + const err = this._streamErr; + setImmediate(() => { + out?.destroy(); + err?.destroy(); + }); }; protected _onClose = (): void => { From 56af2d93b13b4552eef1e1eae13fc6d95c7f9d64 Mon Sep 17 00:00:00 2001 From: James Garbutt <43081j@users.noreply.github.com> Date: Wed, 27 May 2026 20:17:55 +0100 Subject: [PATCH 6/8] chore: clarify comment --- src/main.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main.ts b/src/main.ts index cc9efe4..4776bc2 100644 --- a/src/main.ts +++ b/src/main.ts @@ -370,8 +370,8 @@ export class ExecProcess implements Result { 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, 'close' never fires, - // so we destroy here to unblock readStream and combineStreams. + // 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; const err = this._streamErr; setImmediate(() => { From 1ee0b9fab8e38a3d3516f092b6a8199662db8e10 Mon Sep 17 00:00:00 2001 From: James Garbutt <43081j@users.noreply.github.com> Date: Wed, 27 May 2026 20:46:27 +0100 Subject: [PATCH 7/8] test: move tests into main --- src/test/grandchild_test.ts | 99 ------------------------------------- src/test/main_test.ts | 79 +++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 99 deletions(-) delete mode 100644 src/test/grandchild_test.ts diff --git a/src/test/grandchild_test.ts b/src/test/grandchild_test.ts deleted file mode 100644 index 8129ed9..0000000 --- a/src/test/grandchild_test.ts +++ /dev/null @@ -1,99 +0,0 @@ -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'); - -// Tests run in a subprocess because the grandchild process spawned by the -// fixture script stays alive for 30s and would block vitest's teardown. -// Grandchildren are tagged with TINYEXEC_TEST_GRANDCHILD=1 for cleanup. -function killTestGrandchildren(): void { - try { - spawnSync('pkill', ['-f', 'tinyexec-test-grandchild']); - } catch {} -} - -describe.skipIf(isWindows)('exec (grandchild pipe inheritance)', () => { - test('await 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(process.cwd(), 'dist', '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 { - killTestGrandchildren(); - fs.rmSync(dir, {recursive: true, force: true}); - } - }); - - test('async 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(process.cwd(), 'dist', '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 { - killTestGrandchildren(); - fs.rmSync(dir, {recursive: true, force: true}); - } - }); -}); 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)', () => { From 99da9355c04815aae74374d73a746c83e2a34120 Mon Sep 17 00:00:00 2001 From: James Garbutt <43081j@users.noreply.github.com> Date: Wed, 27 May 2026 21:10:25 +0100 Subject: [PATCH 8/8] fix: account for piped streams --- src/main.ts | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/src/main.ts b/src/main.ts index 4776bc2..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; @@ -343,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); + } } } } @@ -372,8 +378,17 @@ export class ExecProcess implements Result { // 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; - const err = this._streamErr; + 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();