Skip to content

Commit 446daa5

Browse files
committed
fix: propagate command cancellation
1 parent 5d65115 commit 446daa5

3 files changed

Lines changed: 107 additions & 12 deletions

File tree

src/platforms/ios/runner-transport.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,7 @@ export async function waitForRunner(
154154
if (remainingMs <= 0) {
155155
throw buildRunnerConnectError({ port, endpoints, logPath, lastError });
156156
}
157-
const simResponse = await postCommandViaSimulator(device, port, command, remainingMs);
157+
const simResponse = await postCommandViaSimulator(device, port, command, remainingMs, signal);
158158
return new Response(simResponse.body, { status: simResponse.status });
159159
}
160160

@@ -250,6 +250,7 @@ async function postCommandViaSimulator(
250250
port: number,
251251
command: RunnerCommand,
252252
timeoutMs: number,
253+
signal?: AbortSignal,
253254
): Promise<{ status: number; body: string }> {
254255
const payload = JSON.stringify(command);
255256
const args = buildSimctlArgsForDevice(device, [
@@ -265,7 +266,7 @@ async function postCommandViaSimulator(
265266
payload,
266267
`http://127.0.0.1:${port}/command`,
267268
]);
268-
const result = await runCmd('xcrun', args, { allowFailure: true, timeoutMs });
269+
const result = await runCmd('xcrun', args, { allowFailure: true, timeoutMs, signal });
269270
const body = result.stdout as string;
270271
if (result.exitCode !== 0) {
271272
const reason = classifyBootFailure({

src/utils/__tests__/exec.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,41 @@ test('runCmd enforces timeoutMs and rejects with COMMAND_FAILED', async () => {
2727
);
2828
});
2929

30+
test('runCmd aborts with request cancellation details', async () => {
31+
const controller = new AbortController();
32+
const promise = runCmd(process.execPath, ['-e', 'setTimeout(() => {}, 10_000)'], {
33+
signal: controller.signal,
34+
});
35+
controller.abort();
36+
37+
await assert.rejects(promise, (error: unknown) => {
38+
const err = error as { code?: string; message?: string; details?: Record<string, unknown> };
39+
return (
40+
err?.code === 'COMMAND_FAILED' &&
41+
err.message === 'request canceled' &&
42+
err.details?.reason === 'request_canceled'
43+
);
44+
});
45+
});
46+
47+
test('runCmd writes stdin through pipeline', async () => {
48+
const stdin = Buffer.alloc(256_000, 'a');
49+
const result = await runCmd(
50+
process.execPath,
51+
[
52+
'-e',
53+
[
54+
'let bytes = 0;',
55+
'process.stdin.on("data", chunk => { bytes += chunk.length; });',
56+
'process.stdin.on("end", () => process.stdout.write(String(bytes)));',
57+
].join(''),
58+
],
59+
{ stdin },
60+
);
61+
62+
assert.equal(result.stdout, String(stdin.length));
63+
});
64+
3065
test('whichCmd resolves absolute executable paths without invoking a shell', async () => {
3166
assert.equal(await whichCmd(process.execPath), true);
3267
});

src/utils/exec.ts

Lines changed: 69 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { constants } from 'node:fs';
22
import { access, stat } from 'node:fs/promises';
33
import path from 'node:path';
44
import { spawn, spawnSync, type ChildProcess, type StdioOptions } from 'node:child_process';
5+
import { Readable } from 'node:stream';
6+
import { pipeline } from 'node:stream/promises';
57
import { AppError } from './errors.ts';
68

79
export type ExecResult = {
@@ -19,6 +21,7 @@ type ExecOptions = {
1921
stdin?: string | Buffer;
2022
timeoutMs?: number;
2123
detached?: boolean;
24+
signal?: AbortSignal;
2225
};
2326

2427
type ExecStreamOptions = ExecOptions & {
@@ -75,27 +78,32 @@ function runSpawnedCommand(
7578
const stdoutChunks: Buffer[] | undefined = options.binaryStdout ? [] : undefined;
7679
let stderr = '';
7780
let didTimeout = false;
81+
let didAbort = false;
7882
const timeoutMs = normalizeTimeoutMs(options.timeoutMs);
7983
const timeoutHandle = timeoutMs
8084
? setTimeout(() => {
8185
didTimeout = true;
8286
killProcessTree(child, options.detached);
8387
}, timeoutMs)
8488
: null;
89+
const onAbort = () => {
90+
didAbort = true;
91+
killProcessTree(child, options.detached);
92+
};
93+
if (options.signal?.aborted) {
94+
onAbort();
95+
} else {
96+
options.signal?.addEventListener('abort', onAbort, { once: true });
97+
}
8598

8699
if (!options.binaryStdout) child.stdout.setEncoding('utf8');
87100
child.stderr.setEncoding('utf8');
88101

89-
child.stdin.on('error', (err: NodeJS.ErrnoException) => {
90-
if (err.code !== 'EPIPE') {
91-
child.emit('error', err);
92-
}
102+
void writeChildStdin(child, options.stdin).catch((err: unknown) => {
103+
if (isEpipeError(err)) return;
104+
reject(createStdinError(executable, cmd, args, err));
105+
killProcessTree(child, options.detached);
93106
});
94-
if (options.stdin !== undefined) {
95-
child.stdin.end(options.stdin);
96-
} else {
97-
child.stdin.end();
98-
}
99107

100108
child.stdout.on('data', (chunk) => {
101109
if (options.binaryStdout) {
@@ -115,12 +123,22 @@ function runSpawnedCommand(
115123

116124
child.on('error', (err) => {
117125
if (timeoutHandle) clearTimeout(timeoutHandle);
118-
reject(createSpawnError(executable, cmd, args, err));
126+
options.signal?.removeEventListener('abort', onAbort);
127+
reject(
128+
didAbort
129+
? createCommandCanceledError(executable, cmd, args)
130+
: createSpawnError(executable, cmd, args, err),
131+
);
119132
});
120133

121134
child.on('close', (code) => {
122135
if (timeoutHandle) clearTimeout(timeoutHandle);
136+
options.signal?.removeEventListener('abort', onAbort);
123137
const exitCode = code ?? 1;
138+
if (didAbort) {
139+
reject(createCommandCanceledError(executable, cmd, args));
140+
return;
141+
}
124142
if (didTimeout && timeoutMs) {
125143
reject(createTimeoutError(executable, cmd, args, timeoutMs, exitCode, stdout, stderr));
126144
return;
@@ -341,6 +359,29 @@ function createCommandFailedError(
341359
return new AppError('COMMAND_FAILED', `Failed to run ${executable}`, { cmd, args }, cause);
342360
}
343361

362+
function createStdinError(
363+
executable: string,
364+
cmd: string,
365+
args: string[],
366+
cause: unknown,
367+
): AppError {
368+
return new AppError(
369+
'COMMAND_FAILED',
370+
`Failed to write stdin for ${executable}`,
371+
{ cmd, args },
372+
cause instanceof Error ? cause : undefined,
373+
);
374+
}
375+
376+
function createCommandCanceledError(executable: string, cmd: string, args: string[]): AppError {
377+
return new AppError('COMMAND_FAILED', 'request canceled', {
378+
cmd,
379+
args,
380+
executable,
381+
reason: 'request_canceled',
382+
});
383+
}
384+
344385
function createTimeoutError(
345386
executable: string,
346387
cmd: string,
@@ -460,3 +501,21 @@ function killProcessTree(child: ChildProcess, detached: boolean | undefined): vo
460501
}
461502
child.kill('SIGKILL');
462503
}
504+
505+
async function writeChildStdin(
506+
child: ChildProcess,
507+
stdin: string | Buffer | undefined,
508+
): Promise<void> {
509+
if (!child.stdin) return;
510+
if (stdin === undefined) {
511+
child.stdin?.end();
512+
return;
513+
}
514+
await pipeline(Readable.from([stdin]), child.stdin);
515+
}
516+
517+
function isEpipeError(error: unknown): boolean {
518+
return (
519+
error instanceof Error && 'code' in error && (error as NodeJS.ErrnoException).code === 'EPIPE'
520+
);
521+
}

0 commit comments

Comments
 (0)