Skip to content

Commit 4a22da9

Browse files
thymikeeclaude
andauthored
fix: propagate AbortSignal on cancellation to stop iOS runner promptly (#194)
* fix: propagate AbortSignal on request cancellation to stop iOS runner promptly When a client disconnects (socket close or HTTP connection drop), in-flight iOS runner operations now receive an AbortSignal that immediately interrupts pending HTTP fetches, retry delays, and retry loops—ensuring detached xcodebuild/runner processes are not driven after the parent workflow is canceled. Closes #193 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: ensure HTTP disconnect cancellation always propagates * fix: rethrow canceled runner waits before fallback * fix: harden cancellation tracking and propagation --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 780ecbc commit 4a22da9

11 files changed

Lines changed: 212 additions & 16 deletions

File tree

src/daemon.ts

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,14 @@ import { handleLeaseCommands } from './daemon/handlers/lease.ts';
2020
import { cleanupStaleAppLogProcesses } from './daemon/app-log.ts';
2121
import { assertSessionSelectorMatches } from './daemon/session-selector.ts';
2222
import { resolveEffectiveSessionName } from './daemon/session-routing.ts';
23-
import { clearRequestCanceled, isRequestCanceled, markRequestCanceled } from './daemon/request-cancel.ts';
23+
import {
24+
clearRequestCanceled,
25+
createRequestCanceledError,
26+
isRequestCanceled,
27+
markRequestCanceled,
28+
registerRequestAbort,
29+
resolveRequestTrackingId,
30+
} from './daemon/request-cancel.ts';
2431
import {
2532
isAgentDeviceDaemonProcess,
2633
readProcessStartTime,
@@ -437,12 +444,15 @@ function createSocketServer(): net.Server {
437444
let requestIdForCleanup: string | undefined;
438445
try {
439446
const req = JSON.parse(line) as DaemonRequest;
440-
requestIdForCleanup = req.meta?.requestId;
441-
if (requestIdForCleanup) {
442-
activeRequestIds.add(requestIdForCleanup);
443-
if (isRequestCanceled(requestIdForCleanup)) {
444-
throw new AppError('COMMAND_FAILED', 'request canceled');
445-
}
447+
requestIdForCleanup = resolveRequestTrackingId(req.meta?.requestId, 'socket');
448+
req.meta = {
449+
...req.meta,
450+
requestId: requestIdForCleanup,
451+
};
452+
activeRequestIds.add(requestIdForCleanup);
453+
registerRequestAbort(requestIdForCleanup);
454+
if (isRequestCanceled(requestIdForCleanup)) {
455+
throw createRequestCanceledError();
446456
}
447457
response = await handleRequest(req);
448458
} catch (err) {
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import test from 'node:test';
2+
import assert from 'node:assert/strict';
3+
import { AppError } from '../../utils/errors.ts';
4+
import {
5+
createRequestCanceledError,
6+
isRequestCanceledError,
7+
resolveRequestTrackingId,
8+
} from '../request-cancel.ts';
9+
10+
test('resolveRequestTrackingId keeps explicit request id', () => {
11+
assert.equal(resolveRequestTrackingId('req-123'), 'req-123');
12+
});
13+
14+
test('resolveRequestTrackingId generates unique ids for fallback seeds', () => {
15+
const first = resolveRequestTrackingId(undefined, 42);
16+
const second = resolveRequestTrackingId(undefined, 42);
17+
assert.match(first, /^req:42:/);
18+
assert.match(second, /^req:42:/);
19+
assert.notEqual(first, second);
20+
});
21+
22+
test('createRequestCanceledError includes stable cancellation reason marker', () => {
23+
const err = createRequestCanceledError();
24+
assert.equal(err.code, 'COMMAND_FAILED');
25+
assert.equal(err.message, 'request canceled');
26+
assert.equal(err.details?.reason, 'request_canceled');
27+
});
28+
29+
test('isRequestCanceledError accepts structured and legacy cancellation errors', () => {
30+
assert.equal(isRequestCanceledError(createRequestCanceledError()), true);
31+
assert.equal(isRequestCanceledError(new AppError('COMMAND_FAILED', 'request canceled')), true);
32+
assert.equal(isRequestCanceledError(new AppError('COMMAND_FAILED', 'different message')), false);
33+
});

src/daemon/http-server.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@ import http, { type IncomingHttpHeaders } from 'node:http';
22
import { AppError, normalizeError } from '../utils/errors.ts';
33
import type { DaemonRequest, DaemonResponse } from './types.ts';
44
import { normalizeTenantId } from './config.ts';
5+
import {
6+
clearRequestCanceled,
7+
markRequestCanceled,
8+
registerRequestAbort,
9+
resolveRequestTrackingId,
10+
} from './request-cancel.ts';
511
import path from 'node:path';
612
import { pathToFileURL } from 'node:url';
713
import { trackUploadedArtifact } from './upload-registry.ts';
@@ -294,6 +300,7 @@ export async function createDaemonHttpServer(options: {
294300
return;
295301
}
296302

303+
let requestIdForCleanup: string | undefined;
297304
try {
298305
const params = rpcRequest.params as Record<string, unknown>;
299306
const daemonRequest = methodToDaemonRequest(rpcRequest.method, params, req.headers);
@@ -305,6 +312,18 @@ export async function createDaemonHttpServer(options: {
305312
return;
306313
}
307314

315+
requestIdForCleanup = resolveRequestTrackingId(daemonRequest.meta?.requestId, rpcRequest.id);
316+
daemonRequest.meta = {
317+
...daemonRequest.meta,
318+
requestId: requestIdForCleanup,
319+
};
320+
registerRequestAbort(requestIdForCleanup);
321+
req.on('close', () => {
322+
if (!res.writableFinished) {
323+
markRequestCanceled(requestIdForCleanup);
324+
}
325+
});
326+
308327
const authResult = await runHttpAuthHook(authHook, {
309328
headers: req.headers,
310329
rpcRequest,
@@ -344,6 +363,8 @@ export async function createDaemonHttpServer(options: {
344363
createRpcError(rpcRequest.id ?? null, -32000, normalized.message, normalized),
345364
statusCodeForNormalizedError(normalized.code),
346365
);
366+
} finally {
367+
clearRequestCanceled(requestIdForCleanup);
347368
}
348369
});
349370
});

src/daemon/request-cancel.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,60 @@
1+
import { AppError } from '../utils/errors.ts';
2+
13
const canceledRequestIds = new Set<string>();
4+
const requestAbortControllers = new Map<string, AbortController>();
5+
const REQUEST_CANCELED_REASON = 'request_canceled';
6+
const REQUEST_CANCELED_MESSAGE = 'request canceled';
7+
8+
export function resolveRequestTrackingId(requestId: string | undefined, fallbackSeed?: unknown): string {
9+
if (typeof requestId === 'string' && requestId.length > 0) return requestId;
10+
const rawSeed = typeof fallbackSeed === 'string'
11+
? fallbackSeed
12+
: (typeof fallbackSeed === 'number' && Number.isFinite(fallbackSeed) ? String(fallbackSeed) : 'generated');
13+
const normalizedSeed = rawSeed.trim().replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 32) || 'generated';
14+
const nonce = Math.random().toString(36).slice(2, 10);
15+
return `req:${normalizedSeed}:${process.pid}:${Date.now()}:${nonce}`;
16+
}
17+
18+
export function registerRequestAbort(requestId: string | undefined): void {
19+
if (!requestId) return;
20+
const controller = new AbortController();
21+
requestAbortControllers.set(requestId, controller);
22+
if (canceledRequestIds.has(requestId)) {
23+
controller.abort();
24+
}
25+
}
226

327
export function markRequestCanceled(requestId: string | undefined): void {
428
if (!requestId) return;
529
canceledRequestIds.add(requestId);
30+
requestAbortControllers.get(requestId)?.abort();
631
}
732

833
export function clearRequestCanceled(requestId: string | undefined): void {
934
if (!requestId) return;
1035
canceledRequestIds.delete(requestId);
36+
requestAbortControllers.delete(requestId);
1137
}
1238

1339
export function isRequestCanceled(requestId: string | undefined): boolean {
1440
if (!requestId) return false;
1541
return canceledRequestIds.has(requestId);
1642
}
43+
44+
export function getRequestSignal(requestId: string | undefined): AbortSignal | undefined {
45+
if (!requestId) return undefined;
46+
return requestAbortControllers.get(requestId)?.signal;
47+
}
48+
49+
export function createRequestCanceledError(): AppError {
50+
return new AppError('COMMAND_FAILED', REQUEST_CANCELED_MESSAGE, {
51+
reason: REQUEST_CANCELED_REASON,
52+
});
53+
}
54+
55+
export function isRequestCanceledError(error: unknown): boolean {
56+
if (!(error instanceof AppError)) return false;
57+
if (error.code !== 'COMMAND_FAILED') return false;
58+
if (error.details?.reason === REQUEST_CANCELED_REASON) return true;
59+
return error.message === REQUEST_CANCELED_MESSAGE;
60+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import test from 'node:test';
2+
import assert from 'node:assert/strict';
3+
import type { DeviceInfo } from '../../../utils/device.ts';
4+
import { AppError } from '../../../utils/errors.ts';
5+
import { waitForRunner } from '../runner-transport.ts';
6+
7+
const iosSimulator: DeviceInfo = {
8+
platform: 'ios',
9+
id: 'sim-1',
10+
name: 'iPhone Simulator',
11+
kind: 'simulator',
12+
booted: true,
13+
};
14+
15+
test('waitForRunner propagates request cancellation without fallback', async () => {
16+
const signal = AbortSignal.abort();
17+
await assert.rejects(
18+
() => waitForRunner(iosSimulator, 8100, { command: 'snapshot' }, undefined, 5_000, undefined, signal),
19+
(error: unknown) => {
20+
assert.equal(error instanceof AppError, true);
21+
const appError = error as AppError;
22+
assert.equal(appError.code, 'COMMAND_FAILED');
23+
assert.equal(appError.message, 'request canceled');
24+
assert.equal(appError.message.includes('Runner did not accept connection'), false);
25+
return true;
26+
},
27+
);
28+
});

src/platforms/ios/runner-client.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { AppError } from '../../utils/errors.ts';
22
import { withRetry } from '../../utils/retry.ts';
33
import type { DeviceInfo } from '../../utils/device.ts';
4+
import { getRequestSignal } from '../../daemon/request-cancel.ts';
45
import {
56
isRetryableRunnerError,
67
shouldRetryRunnerConnectError,
@@ -93,6 +94,7 @@ async function executeRunnerCommand(
9394
options: { verbose?: boolean; logPath?: string; traceLogPath?: string; requestId?: string } = {},
9495
): Promise<Record<string, unknown>> {
9596
assertRunnerRequestActive(options.requestId);
97+
const signal = getRequestSignal(options.requestId);
9698
let session: RunnerSession | undefined;
9799
try {
98100
session = await ensureRunnerSession(device, options);
@@ -103,6 +105,7 @@ async function executeRunnerCommand(
103105
command,
104106
options.logPath,
105107
timeoutMs,
108+
signal,
106109
);
107110
} catch (err) {
108111
const appErr = err instanceof AppError ? err : new AppError('COMMAND_FAILED', String(err));
@@ -126,6 +129,8 @@ async function executeRunnerCommand(
126129
command,
127130
options.logPath,
128131
RUNNER_STARTUP_TIMEOUT_MS,
132+
undefined,
133+
signal,
129134
);
130135
return await parseRunnerResponse(response, session, options.logPath);
131136
}

src/platforms/ios/runner-errors.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { AppError } from '../../utils/errors.ts';
22
import { bootFailureHint, classifyBootFailure } from '../boot-diagnostics.ts';
3-
import { isRequestCanceled } from '../../daemon/request-cancel.ts';
3+
import { createRequestCanceledError, isRequestCanceled } from '../../daemon/request-cancel.ts';
44
import type { RunnerCommand } from './runner-client.ts';
55
import type { RunnerSession } from './runner-session.ts';
66

@@ -109,5 +109,5 @@ export function isReadOnlyRunnerCommand(command: RunnerCommand['command']): bool
109109

110110
export function assertRunnerRequestActive(requestId: string | undefined): void {
111111
if (!isRequestCanceled(requestId)) return;
112-
throw new AppError('COMMAND_FAILED', 'request canceled');
112+
throw createRequestCanceledError();
113113
}

src/platforms/ios/runner-session.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -247,8 +247,9 @@ export async function executeRunnerCommandWithSession(
247247
command: RunnerCommand,
248248
logPath: string | undefined,
249249
timeoutMs: number,
250+
signal?: AbortSignal,
250251
): Promise<Record<string, unknown>> {
251-
const response = await waitForRunner(device, session.port, command, logPath, timeoutMs, session);
252+
const response = await waitForRunner(device, session.port, command, logPath, timeoutMs, session, signal);
252253
return await parseRunnerResponse(response, session, logPath);
253254
}
254255

src/platforms/ios/runner-transport.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import fs from 'node:fs';
22
import os from 'node:os';
33
import path from 'node:path';
44
import net from 'node:net';
5+
import { createRequestCanceledError, isRequestCanceledError } from '../../daemon/request-cancel.ts';
56
import { AppError } from '../../utils/errors.ts';
67
import { runCmd } from '../../utils/exec.ts';
78
import { Deadline, retryWithPolicy } from '../../utils/retry.ts';
@@ -65,6 +66,7 @@ export async function waitForRunner(
6566
logPath?: string,
6667
timeoutMs: number = RUNNER_STARTUP_TIMEOUT_MS,
6768
session?: RunnerSession,
69+
signal?: AbortSignal,
6870
): Promise<Response> {
6971
const deadline = Deadline.fromTimeoutMs(timeoutMs);
7072
let endpoints = await resolveRunnerCommandEndpoints(device, port, deadline.remainingMs());
@@ -102,9 +104,13 @@ export async function waitForRunner(
102104
body: JSON.stringify(command),
103105
},
104106
Math.min(RUNNER_CONNECT_REQUEST_TIMEOUT_MS, remainingMs),
107+
signal,
105108
);
106109
return response;
107110
} catch (err) {
111+
if (signal?.aborted || isRequestCanceledError(err)) {
112+
throw createRequestCanceledError();
113+
}
108114
lastError = err;
109115
}
110116
}
@@ -121,14 +127,21 @@ export async function waitForRunner(
121127
jitter: 0.2,
122128
shouldRetry: shouldRetryRunnerConnectError,
123129
},
124-
{ deadline, phase: 'ios_runner_connect' },
130+
{ deadline, phase: 'ios_runner_connect', signal },
125131
);
126132
} catch (error) {
133+
if (signal?.aborted || isRequestCanceledError(error)) {
134+
throw createRequestCanceledError();
135+
}
127136
if (!lastError) {
128137
lastError = error;
129138
}
130139
}
131140

141+
if (signal?.aborted) {
142+
throw createRequestCanceledError();
143+
}
144+
132145
if (device.kind === 'simulator') {
133146
const remainingMs = deadline.remainingMs();
134147
if (remainingMs <= 0) {
@@ -161,13 +174,27 @@ async function fetchWithTimeout(
161174
url: string,
162175
init: RequestInit,
163176
timeoutMs: number,
177+
requestSignal?: AbortSignal,
164178
): Promise<Response> {
165179
const controller = new AbortController();
166180
const timeout = setTimeout(() => controller.abort(), timeoutMs);
181+
let onRequestAbort: (() => void) | undefined;
182+
if (requestSignal) {
183+
if (requestSignal.aborted) {
184+
clearTimeout(timeout);
185+
controller.abort();
186+
} else {
187+
onRequestAbort = () => controller.abort();
188+
requestSignal.addEventListener('abort', onRequestAbort, { once: true });
189+
}
190+
}
167191
try {
168192
return await fetch(url, { ...init, signal: controller.signal });
169193
} finally {
170194
clearTimeout(timeout);
195+
if (onRequestAbort && requestSignal) {
196+
requestSignal.removeEventListener('abort', onRequestAbort);
197+
}
171198
}
172199
}
173200

src/utils/interactors.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import {
2121
screenshotIos,
2222
} from '../platforms/ios/index.ts';
2323
import { runIosRunnerCommand } from '../platforms/ios/runner-client.ts';
24-
import { isRequestCanceled } from '../daemon/request-cancel.ts';
24+
import { createRequestCanceledError, isRequestCanceled } from '../daemon/request-cancel.ts';
2525

2626
export type RunnerContext = {
2727
requestId?: string;
@@ -95,7 +95,7 @@ function iosRunnerOverrides(device: DeviceInfo, ctx: RunnerContext): IoRunnerOve
9595
};
9696
const throwIfCanceled = () => {
9797
if (!isRequestCanceled(ctx.requestId)) return;
98-
throw new AppError('COMMAND_FAILED', 'request canceled');
98+
throw createRequestCanceledError();
9999
};
100100

101101
return {

0 commit comments

Comments
 (0)