Skip to content

Commit bf59661

Browse files
committed
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.
1 parent 51e4f27 commit bf59661

1 file changed

Lines changed: 130 additions & 0 deletions

File tree

src/test/grandchild_test.ts

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import {x} from '../main.js';
2+
import {describe, test, expect} from 'vitest';
3+
import os from 'node:os';
4+
import fs from 'node:fs';
5+
import path from 'node:path';
6+
import {spawnSync} from 'node:child_process';
7+
8+
const isWindows = os.platform() === 'win32';
9+
10+
describe.skipIf(isWindows)('exec (grandchild pipe inheritance)', () => {
11+
test('await completes when grandchild holds piped stdout open', async () => {
12+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tinyexec-grandchild-'));
13+
const childScript = path.join(dir, 'child.mjs');
14+
15+
fs.writeFileSync(
16+
childScript,
17+
`import { spawn } from 'node:child_process'
18+
spawn('node', ['-e', 'setTimeout(() => void 0, 30000)'], {
19+
stdio: ['ignore', 1, 'ignore'],
20+
})
21+
process.stdout.write('output\\n')
22+
process.exit(0)
23+
`
24+
);
25+
26+
// Run in a subprocess so the orphaned grandchild doesn't block vitest.
27+
// The runner uses the built dist/main.mjs directly.
28+
const runnerScript = path.join(dir, 'runner.mjs');
29+
const distPath = path.join(process.cwd(), 'dist', 'main.mjs');
30+
fs.writeFileSync(
31+
runnerScript,
32+
`import { x } from '${distPath}'
33+
34+
const result = await Promise.race([
35+
x('node', ['${childScript}']).then(() => 'completed'),
36+
new Promise((resolve) => setTimeout(() => resolve('hung'), 5000)),
37+
])
38+
39+
try { process.kill(-process.pid) } catch {}
40+
process.stdout.write(result)
41+
process.exit(result === 'completed' ? 0 : 1)
42+
`
43+
);
44+
45+
try {
46+
const proc = spawnSync('node', [runnerScript], {
47+
timeout: 10000,
48+
encoding: 'utf8',
49+
killSignal: 'SIGKILL',
50+
});
51+
52+
if (proc.signal === 'SIGKILL') {
53+
expect.unreachable(
54+
'exec hung for 10s (grandchild held pipe open)'
55+
);
56+
}
57+
58+
expect(proc.status).toBe(0);
59+
expect(proc.stdout.trim()).toBe('completed');
60+
} finally {
61+
try {
62+
spawnSync('pkill', ['-f', dir]);
63+
} catch {}
64+
fs.rmSync(dir, {recursive: true, force: true});
65+
}
66+
});
67+
68+
test('async iterator completes when grandchild holds piped stdout open', async () => {
69+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tinyexec-grandchild-'));
70+
const childScript = path.join(dir, 'child.mjs');
71+
72+
fs.writeFileSync(
73+
childScript,
74+
`import { spawn } from 'node:child_process'
75+
spawn('node', ['-e', 'setTimeout(() => void 0, 30000)'], {
76+
stdio: ['ignore', 1, 'ignore'],
77+
})
78+
process.stdout.write('line1\\nline2\\n')
79+
process.exit(0)
80+
`
81+
);
82+
83+
const runnerScript = path.join(dir, 'runner.mjs');
84+
const distPath = path.join(process.cwd(), 'dist', 'main.mjs');
85+
fs.writeFileSync(
86+
runnerScript,
87+
`import { x } from '${distPath}'
88+
89+
const lines = []
90+
const result = await Promise.race([
91+
(async () => {
92+
for await (const line of x('node', ['${childScript}'])) {
93+
lines.push(line)
94+
}
95+
return 'completed'
96+
})(),
97+
new Promise((resolve) => setTimeout(() => resolve('hung'), 5000)),
98+
])
99+
100+
try { process.kill(-process.pid) } catch {}
101+
process.stdout.write(JSON.stringify({ result, lines }))
102+
process.exit(result === 'completed' ? 0 : 1)
103+
`
104+
);
105+
106+
try {
107+
const proc = spawnSync('node', [runnerScript], {
108+
timeout: 10000,
109+
encoding: 'utf8',
110+
killSignal: 'SIGKILL',
111+
});
112+
113+
if (proc.signal === 'SIGKILL') {
114+
expect.unreachable(
115+
'async iterator hung for 10s (grandchild held pipe open)'
116+
);
117+
}
118+
119+
expect(proc.status).toBe(0);
120+
const parsed = JSON.parse(proc.stdout.trim());
121+
expect(parsed.result).toBe('completed');
122+
expect(parsed.lines).toEqual(['line1', 'line2']);
123+
} finally {
124+
try {
125+
spawnSync('pkill', ['-f', dir]);
126+
} catch {}
127+
fs.rmSync(dir, {recursive: true, force: true});
128+
}
129+
});
130+
});

0 commit comments

Comments
 (0)