Skip to content

Commit 5b31383

Browse files
committed
fix(host-kit): a killed command settles on exit; background exec cannot take a timeout
A command killed by its own deadline or by request cancellation settled on 'close', which waits for the stdio pipes to drain. A descendant that inherited those pipes keeps them open after the direct child is gone, wedging the request and the device lock it owns. Both the foreground promise and the background wait now settle on 'exit' once this module asked for the kill, and still wait for a full drain on 'close' when it did not. One finish() owns the timer clear, the abort-listener release, and the trace emit, so the stdin-failure rejection stops bypassing them. killProcessTree no longer signals a child Node already reaped: its pid, and with it the process-group id a detached spawn handed out, is reusable by then. execHostAdb spawns detached like execSerialAdb already does, so a deadline can signal the group instead of only the client. Background runs lose the timeoutMs field they never armed: ExecBackgroundOptions and AndroidAdbSpawnOptions omit it, the two app-log call sites that forwarded it are dropped, and the app-log adb command contract no longer offers it.
1 parent 730b201 commit 5b31383

11 files changed

Lines changed: 524 additions & 168 deletions

File tree

‎packages/contracts/src/app-log-runtime.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ export type AppLogProcessCommand =
116116
kind: 'android-adb';
117117
serial: string;
118118
args: readonly string[];
119-
options?: Pick<HostCommandRequest, 'allowFailure' | 'cwd' | 'env' | 'timeoutMs'>;
119+
options?: Pick<HostCommandRequest, 'allowFailure' | 'cwd' | 'env'>;
120120
}>;
121121

122122
export type AppLogBackgroundProcessRequest = Readonly<{

‎packages/host-kit/src/internal/exec-boundary-faults.test.ts‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@ import { test } from 'vitest';
33
import { AppError } from '@agent-device/kernel/errors';
44
import {
55
runCmd,
6+
runCmdBackground,
7+
runCmdDetached,
8+
runCmdStreaming,
9+
runCmdSync,
610
withCommandExecutorOverride,
711
type CommandExecutorOverride,
812
} from '@agent-device/host-kit/command';
@@ -28,3 +32,27 @@ test('fail-Nth executor drives one deterministic command failure without hiding
2832

2933
assert.deepEqual(calls, [['first'], ['second'], ['third']]);
3034
});
35+
36+
test('the override seam covers the foreground commands and no other spawn path', async () => {
37+
const consulted: string[] = [];
38+
39+
await withCommandExecutorOverride(
40+
(command) => {
41+
consulted.push(command);
42+
return undefined;
43+
},
44+
async () => {
45+
runCmdSync(process.execPath, ['-e', 'process.stdout.write("sync")']);
46+
const background = runCmdBackground(process.execPath, [
47+
'-e',
48+
'process.stdout.write("background")',
49+
]);
50+
await background.wait;
51+
runCmdDetached(process.execPath, ['-e', 'process.exit(0)']);
52+
await runCmdStreaming(process.execPath, ['-e', 'process.stdout.write("streaming")']);
53+
await runCmd(process.execPath, ['-e', 'process.stdout.write("foreground")']);
54+
},
55+
);
56+
57+
assert.deepEqual(consulted, [process.execPath, process.execPath]);
58+
});
Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
import assert from 'node:assert/strict';
2+
import fs from 'node:fs';
3+
import path from 'node:path';
4+
import { test } from 'vitest';
5+
import {
6+
isCommandTimeoutError,
7+
runCmd,
8+
runCmdBackground,
9+
runCmdStreaming,
10+
type ExecBackgroundOptions,
11+
} from './exec.ts';
12+
import { shellQuote } from './shell-quote.ts';
13+
import { sleep } from './timeouts.ts';
14+
import { mkdtempForTestSync } from './tmp-dir.fixtures.ts';
15+
16+
// A direct child can hand our stdout/stderr pipes to a descendant, and `close` waits
17+
// for those pipes to drain: a command this module killed stayed unsettled — holding
18+
// its request and the device lock it owns — until the descendant died by itself.
19+
// `sh` and `sleep` start in milliseconds, so the deadline never races a runtime
20+
// booting, and the leaked holder is a timer, not a runtime.
21+
22+
const HOLDER_LIFETIME_SECONDS = 3;
23+
const DEADLINE_MS = 400;
24+
25+
function pipeHolderShellScript(pidFilePath: string): string {
26+
return `sleep ${HOLDER_LIFETIME_SECONDS} & printf %s $! > ${shellQuote(pidFilePath)}; wait`;
27+
}
28+
29+
function holderPidFilePath(label: string): string {
30+
return path.join(mkdtempForTestSync(`agent-device-exec-${label}-`), 'holder.pid');
31+
}
32+
33+
function isProcessRunning(pid: number): boolean {
34+
try {
35+
process.kill(pid, 0);
36+
return true;
37+
} catch (error) {
38+
// EPERM is a live process owned by someone else; only ESRCH is absence.
39+
return (error as NodeJS.ErrnoException).code === 'EPERM';
40+
}
41+
}
42+
43+
async function readRecordedHolderPid(pidFilePath: string): Promise<number> {
44+
const deadline = Date.now() + 2_000;
45+
for (;;) {
46+
try {
47+
const recorded = fs.readFileSync(pidFilePath, 'utf8').trim();
48+
if (recorded) return Number(recorded);
49+
} catch {}
50+
if (Date.now() > deadline) throw new Error('the pipe holder never recorded its pid');
51+
await sleep(10);
52+
}
53+
}
54+
55+
async function waitForProcessToExit(pid: number): Promise<boolean> {
56+
const deadline = Date.now() + 2_000;
57+
while (isProcessRunning(pid)) {
58+
if (Date.now() > deadline) return false;
59+
await sleep(20);
60+
}
61+
return true;
62+
}
63+
64+
function settledRejection<T>(promise: Promise<T>): Promise<{ error: unknown } | null> {
65+
return promise.then(
66+
() => null,
67+
(error: unknown) => ({ error }),
68+
);
69+
}
70+
71+
test.runIf(process.platform !== 'win32')(
72+
'runCmd killed at its deadline settles on the child exit, not on an inherited pipe',
73+
async () => {
74+
const pidFilePath = holderPidFilePath('timeout-holder');
75+
const killed = runCmd('/bin/sh', ['-c', pipeHolderShellScript(pidFilePath)], {
76+
timeoutMs: DEADLINE_MS,
77+
});
78+
const rejection = settledRejection(killed);
79+
80+
const holderPid = await readRecordedHolderPid(pidFilePath);
81+
const outcome = await rejection;
82+
83+
assert.ok(outcome, 'a command killed at its deadline must not resolve');
84+
assert.ok(isCommandTimeoutError(outcome.error));
85+
assert.equal(
86+
isProcessRunning(holderPid),
87+
true,
88+
'settled only once the pipe holder died, which is the wedge this settles before',
89+
);
90+
},
91+
);
92+
93+
test.runIf(process.platform !== 'win32')(
94+
'a deadline on a detached command kills the descendant that inherited the pipes',
95+
async () => {
96+
const pidFilePath = holderPidFilePath('detached-holder');
97+
const killed = runCmd('/bin/sh', ['-c', pipeHolderShellScript(pidFilePath)], {
98+
timeoutMs: DEADLINE_MS,
99+
detached: true,
100+
});
101+
const rejection = settledRejection(killed);
102+
103+
const holderPid = await readRecordedHolderPid(pidFilePath);
104+
const outcome = await rejection;
105+
106+
assert.ok(outcome);
107+
assert.ok(isCommandTimeoutError(outcome.error));
108+
assert.equal(
109+
await waitForProcessToExit(holderPid),
110+
true,
111+
'the process-group kill left the inherited-pipe holder running',
112+
);
113+
},
114+
);
115+
116+
test.runIf(process.platform !== 'win32')(
117+
'runCmdBackground killed by request cancellation settles on the child exit',
118+
async () => {
119+
const pidFilePath = holderPidFilePath('abort-holder');
120+
const controller = new AbortController();
121+
const { wait } = runCmdBackground('/bin/sh', ['-c', pipeHolderShellScript(pidFilePath)], {
122+
signal: controller.signal,
123+
captureOutput: false,
124+
});
125+
const rejection = settledRejection(wait);
126+
127+
const holderPid = await readRecordedHolderPid(pidFilePath);
128+
controller.abort();
129+
const outcome = await rejection;
130+
131+
assert.ok(outcome, 'a canceled background command must not resolve');
132+
assert.equal(
133+
isProcessRunning(holderPid),
134+
true,
135+
'settled only once the pipe holder died, which is the wedge this settles before',
136+
);
137+
const details = (outcome.error as { details?: Record<string, unknown> }).details;
138+
assert.equal(details?.reason, 'request_canceled');
139+
},
140+
);
141+
142+
test.runIf(process.platform !== 'win32')(
143+
'a cancellation arriving after the child was reaped signals no process group',
144+
async () => {
145+
// The direct child exits at once and leaves `sleep 0.4` draining the streams.
146+
// Its pid — and therefore its process-group id — is reusable from the moment it
147+
// is reaped, so the cancellation must not address that group any more.
148+
let childReaped: () => void = () => {};
149+
const reaped = new Promise<void>((resolve) => {
150+
childReaped = resolve;
151+
});
152+
const controller = new AbortController();
153+
const running = runCmdStreaming('/bin/sh', ['-c', 'sleep 0.4 &'], {
154+
signal: controller.signal,
155+
detached: true,
156+
onSpawn: (child) => {
157+
child.once('exit', childReaped);
158+
},
159+
});
160+
const rejection = settledRejection(running);
161+
162+
await reaped;
163+
controller.abort();
164+
const outcome = await rejection;
165+
166+
assert.ok(outcome, 'a canceled command must not resolve');
167+
const details = (outcome.error as { details?: Record<string, unknown> }).details;
168+
assert.equal(details?.reason, 'request_canceled');
169+
},
170+
);
171+
172+
test.runIf(process.platform !== 'win32')(
173+
'runCmd that was never killed still drains output a descendant writes after its parent exited',
174+
async () => {
175+
const result = await runCmd('/bin/sh', ['-c', 'printf head; { sleep 0.2; printf tail; } &']);
176+
177+
assert.equal(result.stdout, 'headtail');
178+
},
179+
);
180+
181+
test('a killed command still fails with its deadline even when it allowed failure', async () => {
182+
const outcome = await runCmd(process.execPath, ['-e', 'setTimeout(() => {}, 10_000)'], {
183+
timeoutMs: 60,
184+
allowFailure: true,
185+
}).then(
186+
() => null,
187+
(error: unknown) => error,
188+
);
189+
190+
assert.ok(isCommandTimeoutError(outcome));
191+
});
192+
193+
test('binaryStdout returns every byte the command wrote', async () => {
194+
const bytes = 4096;
195+
const result = await runCmd(
196+
process.execPath,
197+
['-e', 'process.stdout.write(Buffer.alloc(4096, 7))'],
198+
{ binaryStdout: true },
199+
);
200+
201+
assert.equal(result.stdout, '');
202+
assert.equal(result.stdoutBuffer?.length, bytes);
203+
});
204+
205+
test('runCmdBackground captures the full stdout of a child that writes over a megabyte', async () => {
206+
const bytes = 1_500_000;
207+
const { wait } = runCmdBackground(process.execPath, [
208+
'-e',
209+
`process.stdout.write("a".repeat(${bytes}))`,
210+
]);
211+
212+
const result = await wait;
213+
214+
assert.equal(result.stdout.length, bytes);
215+
});
216+
217+
test('background exec arms no deadline when timeoutMs crosses an unchecked options spread', async () => {
218+
const leakedOptions = { timeoutMs: 20 } as unknown as ExecBackgroundOptions;
219+
220+
const { wait } = runCmdBackground(
221+
process.execPath,
222+
['-e', 'setTimeout(() => process.exit(0), 150)'],
223+
leakedOptions,
224+
);
225+
226+
const result = await wait;
227+
228+
assert.equal(result.exitCode, 0);
229+
});

0 commit comments

Comments
 (0)