Skip to content

Commit 714177d

Browse files
committed
fix(command): Settle executor on exit+streams-drained instead of close alone
The close event can be delayed indefinitely when a detached grandchild inherits stdout/stderr file descriptors. Replace the single close listener with a state machine that tracks open streams, observes exit, and settles when both conditions are met. A 100ms safety timer after exit handles the case where streams never drain. The close event remains a final authority that forces immediate settlement.
1 parent 79cb6c0 commit 714177d

2 files changed

Lines changed: 183 additions & 33 deletions

File tree

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { __getRealCommandExecutor } from '../command.ts';
3+
4+
describe('defaultExecutor', () => {
5+
it('settles after exit even when child close is delayed', async () => {
6+
const executor = __getRealCommandExecutor();
7+
const startedAt = Date.now();
8+
9+
const result = await executor(
10+
['/bin/sh', '-lc', '(sleep 1) & echo launch failed 1>&2; exit 7'],
11+
'Test Run',
12+
);
13+
14+
const durationMs = Date.now() - startedAt;
15+
16+
expect(result).toMatchObject({
17+
success: false,
18+
exitCode: 7,
19+
error: 'launch failed\n',
20+
});
21+
expect(durationMs).toBeLessThan(900);
22+
});
23+
24+
it('does not attach stdout or stderr listeners for detached commands', async () => {
25+
const executor = __getRealCommandExecutor();
26+
const result = await executor(
27+
['/bin/sh', '-lc', 'sleep 1'],
28+
'Detached Test',
29+
false,
30+
undefined,
31+
true,
32+
);
33+
34+
try {
35+
expect(result.process.stdout?.listenerCount('data') ?? 0).toBe(0);
36+
expect(result.process.stderr?.listenerCount('data') ?? 0).toBe(0);
37+
} finally {
38+
result.process.kill('SIGKILL');
39+
}
40+
});
41+
});

src/utils/command.ts

Lines changed: 142 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -76,25 +76,118 @@ async function defaultExecutor(
7676

7777
const childProcess = spawn(executable, args, spawnOpts);
7878

79-
if (verbose) {
80-
childProcess.stdout?.pipe(process.stderr, { end: false });
81-
childProcess.stderr?.pipe(process.stderr, { end: false });
82-
}
83-
8479
let stdout = '';
8580
let stderr = '';
8681

87-
childProcess.stdout?.on('data', (data: Buffer) => {
88-
const chunk = data.toString();
89-
stdout += chunk;
90-
opts?.onStdout?.(chunk);
91-
});
82+
const streamClosers: Array<() => void> = [];
83+
const streamDetachers: Array<() => void> = [];
84+
let openStreamCount = 0;
85+
let settled = false;
86+
let exitObserved = false;
87+
let exitCode: number | null = null;
88+
let exitSettleTimer: NodeJS.Timeout | null = null;
89+
90+
const clearExitSettleTimer = (): void => {
91+
if (exitSettleTimer) {
92+
clearTimeout(exitSettleTimer);
93+
exitSettleTimer = null;
94+
}
95+
};
9296

93-
childProcess.stderr?.on('data', (data: Buffer) => {
94-
const chunk = data.toString();
95-
stderr += chunk;
96-
opts?.onStderr?.(chunk);
97-
});
97+
const detachStreamListeners = (): void => {
98+
for (const detachStream of streamDetachers) {
99+
detachStream();
100+
}
101+
streamDetachers.length = 0;
102+
};
103+
104+
const handleError = (err: Error): void => {
105+
if (settled) {
106+
return;
107+
}
108+
settled = true;
109+
clearExitSettleTimer();
110+
detachStreamListeners();
111+
logSpawnError(err);
112+
reject(err);
113+
};
114+
115+
const settle = (code: number | null): void => {
116+
if (settled) {
117+
return;
118+
}
119+
settled = true;
120+
clearExitSettleTimer();
121+
detachStreamListeners();
122+
123+
const success = code === 0;
124+
const response: CommandResponse = {
125+
success,
126+
output: stdout,
127+
error: success ? undefined : stderr,
128+
process: childProcess,
129+
exitCode: code ?? undefined,
130+
};
131+
132+
resolve(response);
133+
};
134+
135+
const maybeSettleAfterExit = (): void => {
136+
if (!exitObserved || settled || openStreamCount > 0) {
137+
return;
138+
}
139+
settle(exitCode);
140+
};
141+
142+
const scheduleExitSettle = (): void => {
143+
if (settled || exitSettleTimer) {
144+
return;
145+
}
146+
exitSettleTimer = setTimeout(() => {
147+
settle(exitCode);
148+
}, 100);
149+
};
150+
151+
const attachStream = (
152+
stream: NodeJS.ReadableStream | null | undefined,
153+
onChunk: (chunk: string) => void,
154+
mirrorToStderr: boolean,
155+
): void => {
156+
if (!stream) {
157+
return;
158+
}
159+
160+
openStreamCount += 1;
161+
let streamClosed = false;
162+
163+
const markClosed = (): void => {
164+
if (streamClosed) {
165+
return;
166+
}
167+
streamClosed = true;
168+
openStreamCount = Math.max(0, openStreamCount - 1);
169+
maybeSettleAfterExit();
170+
};
171+
172+
const handleData = (data: Buffer | string): void => {
173+
if (settled) {
174+
return;
175+
}
176+
const chunk = data.toString();
177+
onChunk(chunk);
178+
if (mirrorToStderr) {
179+
process.stderr.write(chunk);
180+
}
181+
};
182+
183+
stream.on('data', handleData);
184+
stream.once('end', markClosed);
185+
stream.once('close', markClosed);
186+
streamClosers.push(markClosed);
187+
streamDetachers.push(() => {
188+
stream.off('data', handleData);
189+
});
190+
};
98191

99192
if (detached) {
100193
let resolved = false;
@@ -126,25 +219,41 @@ async function defaultExecutor(
126219
}
127220
}
128221
}, 100);
129-
} else {
130-
childProcess.on('close', (code) => {
131-
const success = code === 0;
132-
const response: CommandResponse = {
133-
success,
134-
output: stdout,
135-
error: success ? undefined : stderr,
136-
process: childProcess,
137-
exitCode: code ?? undefined,
138-
};
139-
140-
resolve(response);
141-
});
142-
143-
childProcess.on('error', (err) => {
144-
logSpawnError(err);
145-
reject(err);
146-
});
222+
return;
147223
}
224+
225+
attachStream(
226+
childProcess.stdout,
227+
(chunk) => {
228+
stdout += chunk;
229+
opts?.onStdout?.(chunk);
230+
},
231+
verbose,
232+
);
233+
234+
attachStream(
235+
childProcess.stderr,
236+
(chunk) => {
237+
stderr += chunk;
238+
opts?.onStderr?.(chunk);
239+
},
240+
verbose,
241+
);
242+
243+
childProcess.once('error', handleError);
244+
childProcess.once('exit', (code) => {
245+
exitObserved = true;
246+
exitCode = code;
247+
maybeSettleAfterExit();
248+
scheduleExitSettle();
249+
});
250+
childProcess.once('close', (code) => {
251+
clearExitSettleTimer();
252+
for (const closeStream of streamClosers) {
253+
closeStream();
254+
}
255+
settle(code ?? exitCode);
256+
});
148257
});
149258
}
150259

0 commit comments

Comments
 (0)