Skip to content

Commit 14bc398

Browse files
authored
fix(platform-ios): gracefully stop XCTest agent (#139)
## What is this? Harness now ends the iOS XCTest permission agent gracefully when a test run finishes, instead of immediately signalling `xcodebuild` to stop. Previously, normal teardown sent `SIGTERM` to the long-running `xcodebuild test-without-building` session while its UI test was still active, interrupting an otherwise-passing agent session and leaving stray simulator and `xcodebuild` processes behind. ## How does it work? The on-device XCTest agent gains a `/shutdown` endpoint. When it receives the request it flips an internal flag, its session loop exits on the next tick, and the UI test completes on its own so `xcodebuild` finishes and tears down its own child processes. During teardown the platform controller asks the agent to shut down first and waits, bounded by a timeout, for the process to exit. If the request fails or the agent does not stop in time, Harness falls back to the existing `SIGTERM`/`SIGKILL` termination. The shutdown timeout is raised to accommodate the slower-but-clean exit. ## Why is this useful? Test runs finish without cutting off a healthy agent session, and fewer orphaned simulator and `xcodebuild` processes survive teardown — reducing flakiness and resource leaks across consecutive iOS runs.
1 parent 50808c7 commit 14bc398

6 files changed

Lines changed: 181 additions & 6 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
__default__: patch
3+
---
4+
5+
Harness now asks the iOS XCTest permission agent to stop gracefully when a test run ends, letting its session finish on its own before Harness falls back to terminating xcodebuild. This avoids cutting off an otherwise-passing agent session during teardown and leaves fewer stray simulator and xcodebuild processes behind after tests complete.

packages/platform-ios/src/__tests__/xctest-agent-client.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,16 @@ describe('xctest-agent client', () => {
3333
}),
3434
headers: {},
3535
statusCode: 200,
36+
})
37+
.mockResolvedValueOnce({
38+
body: JSON.stringify({
39+
permissions: {
40+
autoAcceptPermissions: true,
41+
},
42+
status: 'shutting-down',
43+
}),
44+
headers: {},
45+
statusCode: 200,
3646
});
3747
const dispose = vi.fn(async () => undefined);
3848
const client = createXCTestAgentClient({
@@ -56,6 +66,7 @@ describe('xctest-agent client', () => {
5666
await expect(client.getPermissionsConfig()).resolves.toEqual({
5767
autoAcceptPermissions: true,
5868
});
69+
await expect(client.shutdown()).resolves.toBeUndefined();
5970

6071
expect(request).toHaveBeenNthCalledWith(1, {
6172
method: 'GET',
@@ -74,6 +85,11 @@ describe('xctest-agent client', () => {
7485
path: '/permissions',
7586
body: undefined,
7687
});
88+
expect(request).toHaveBeenNthCalledWith(4, {
89+
method: 'POST',
90+
path: '/shutdown',
91+
body: undefined,
92+
});
7793
await client.dispose();
7894
expect(dispose).toHaveBeenCalledTimes(1);
7995
});

packages/platform-ios/src/__tests__/xctest-agent.test.ts

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ const mocks = vi.hoisted(() => ({
1818
status: 'ok',
1919
})),
2020
kill: vi.fn(),
21+
shutdown: vi.fn(async () => undefined),
2122
spawn: vi.fn(),
2223
}));
2324

@@ -38,6 +39,7 @@ vi.mock('../xctest-agent-client.js', () => ({
3839
dispose: mocks.disposeClient,
3940
getPermissionsConfig: vi.fn(),
4041
health: mocks.health,
42+
shutdown: mocks.shutdown,
4143
})),
4244
}));
4345

@@ -148,6 +150,11 @@ describe('xctest-agent orchestration', () => {
148150
deviceBuildRoot = path.join(tempProjectRoot, '.harness', 'xctest-agent');
149151
rmBuildRoot();
150152
mocks.activeAgentStops.length = 0;
153+
mocks.shutdown.mockImplementation(async () => {
154+
for (const stop of mocks.activeAgentStops) {
155+
stop();
156+
}
157+
});
151158
mocks.spawn.mockImplementation((file: string, args?: string[]) => {
152159
if (file === 'xcodebuild' && args?.join(' ') === '-version') {
153160
return Promise.resolve({ stdout: xcodeVersion });
@@ -466,7 +473,8 @@ describe('xctest-agent orchestration', () => {
466473

467474
await controller.dispose();
468475

469-
expect(mocks.kill).toHaveBeenCalledTimes(1);
476+
expect(mocks.shutdown).toHaveBeenCalledTimes(1);
477+
expect(mocks.kill).not.toHaveBeenCalled();
470478
expect(mocks.disposeClient).toHaveBeenCalledTimes(1);
471479
});
472480

@@ -488,7 +496,48 @@ describe('xctest-agent orchestration', () => {
488496
});
489497
});
490498

491-
it('kills the agent process during disposal', async () => {
499+
it('requests graceful shutdown during disposal', async () => {
500+
const controller = createXCTestAgentController({
501+
port: 49154,
502+
shutdownTimeoutMs: 100,
503+
target: {
504+
kind: 'simulator',
505+
id: 'sim-timeout',
506+
},
507+
});
508+
509+
await controller.ensureStarted();
510+
await controller.dispose();
511+
512+
expect(mocks.shutdown).toHaveBeenCalledTimes(1);
513+
expect(mocks.disposeClient).toHaveBeenCalledTimes(1);
514+
expect(mocks.kill).not.toHaveBeenCalled();
515+
});
516+
517+
it('kills the agent process when graceful shutdown times out', async () => {
518+
mocks.shutdown.mockResolvedValue(undefined);
519+
520+
const controller = createXCTestAgentController({
521+
port: 49154,
522+
shutdownTimeoutMs: 1,
523+
target: {
524+
kind: 'simulator',
525+
id: 'sim-timeout',
526+
},
527+
});
528+
529+
await controller.ensureStarted();
530+
await controller.dispose();
531+
532+
expect(mocks.kill).toHaveBeenCalledTimes(1);
533+
expect(mocks.kill).toHaveBeenCalledWith('SIGTERM');
534+
});
535+
536+
it('kills the agent process when the graceful shutdown request hangs', async () => {
537+
mocks.shutdown.mockImplementation(
538+
() => new Promise<undefined>(() => undefined)
539+
);
540+
492541
const controller = createXCTestAgentController({
493542
port: 49154,
494543
shutdownTimeoutMs: 1,
@@ -506,6 +555,8 @@ describe('xctest-agent orchestration', () => {
506555
});
507556

508557
it('force kills the agent process when graceful shutdown times out', async () => {
558+
mocks.shutdown.mockResolvedValue(undefined);
559+
509560
mocks.spawn.mockImplementation((file: string, args?: string[]) => {
510561
if (file === 'xcodebuild' && args?.join(' ') === '-version') {
511562
return Promise.resolve({ stdout: xcodeVersion });

packages/platform-ios/src/xctest-agent-client.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ export type XCTestAgentPermissionsConfiguration = {
99

1010
type XCTestAgentHealthResponse = {
1111
permissions: XCTestAgentPermissionsConfiguration;
12-
status: 'ok';
12+
status: 'ok' | 'shutting-down';
1313
};
1414

1515
type XCTestAgentPermissionsResponse = {
@@ -23,6 +23,7 @@ export type XCTestAgentClient = {
2323
dispose: () => Promise<void>;
2424
getPermissionsConfig: () => Promise<XCTestAgentPermissionsConfiguration>;
2525
health: () => Promise<XCTestAgentHealthResponse>;
26+
shutdown: () => Promise<void>;
2627
};
2728

2829
export const createXCTestAgentClient = (
@@ -67,6 +68,12 @@ export const createXCTestAgentClient = (
6768

6869
return response.permissions;
6970
},
71+
shutdown: async () => {
72+
await requestJson<XCTestAgentHealthResponse>({
73+
method: 'POST',
74+
path: '/shutdown',
75+
});
76+
},
7077
dispose: async () => {
7178
await transport.dispose();
7279
},

packages/platform-ios/src/xctest-agent.ts

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ const XCTEST_AGENT_XCTESTRUN_FILE_ENV = 'HARNESS_IOS_XCTESTRUN_FILE';
3333
const XCTEST_AGENT_DERIVED_DATA_PATH_ENV =
3434
'HARNESS_IOS_XCTEST_DERIVED_DATA_PATH';
3535
const XCTEST_AGENT_STARTUP_TIMEOUT_MS = 120_000;
36-
const XCTEST_AGENT_SHUTDOWN_TIMEOUT_MS = 5_000;
36+
const XCTEST_AGENT_SHUTDOWN_TIMEOUT_MS = 30_000;
3737
const XCTEST_AGENT_STARTUP_POLL_INTERVAL_MS = 250;
3838
const HARNESS_DIRNAME = '.harness';
3939
const XCTEST_AGENT_BUILD_DIRNAME = 'xctest-agent';
@@ -778,6 +778,36 @@ const waitForShutdown = async (options: {
778778
return result !== timedOut;
779779
};
780780

781+
const waitForGracefulShutdown = async (options: {
782+
client: ReturnType<typeof createXCTestAgentClient>;
783+
processTask: Promise<void> | null;
784+
shutdownTimeoutMs: number;
785+
}): Promise<{ didStop: boolean; requestError: unknown | null }> => {
786+
let requestError: unknown | null = null;
787+
const timedOut = Symbol('timedOut');
788+
789+
const result = await Promise.race([
790+
(async () => {
791+
try {
792+
await options.client.shutdown();
793+
} catch (error) {
794+
requestError = error;
795+
}
796+
797+
return await waitForShutdown({
798+
processTask: options.processTask,
799+
shutdownTimeoutMs: options.shutdownTimeoutMs,
800+
});
801+
})(),
802+
delay(options.shutdownTimeoutMs).then(() => timedOut),
803+
]);
804+
805+
return {
806+
didStop: result !== timedOut && result === true,
807+
requestError,
808+
};
809+
};
810+
781811
const waitForChildProcessExit = async (subprocess: Subprocess) => {
782812
const childProcess = await subprocess.nodeChildProcess;
783813

@@ -1122,6 +1152,50 @@ export const createXCTestAgentController = (options: {
11221152
target.kind
11231153
);
11241154

1155+
if (currentClient) {
1156+
try {
1157+
xctestAgentLogger.info(
1158+
'Requesting XCTest agent graceful shutdown for %s target',
1159+
target.kind,
1160+
);
1161+
1162+
const gracefulShutdown = await waitForGracefulShutdown({
1163+
client: currentClient,
1164+
processTask: currentProcessTask,
1165+
shutdownTimeoutMs,
1166+
});
1167+
1168+
if (gracefulShutdown.didStop) {
1169+
xctestAgentLogger.info(
1170+
'XCTest agent session for %s target stopped gracefully',
1171+
target.kind,
1172+
);
1173+
await currentClient.dispose();
1174+
return;
1175+
}
1176+
1177+
if (gracefulShutdown.requestError) {
1178+
xctestAgentLogger.warn(
1179+
'XCTest agent graceful shutdown request failed for %s: %s',
1180+
target.kind,
1181+
getErrorMessage(gracefulShutdown.requestError),
1182+
);
1183+
}
1184+
1185+
xctestAgentLogger.warn(
1186+
'XCTest agent session for %s target did not stop gracefully after %dms; terminating xcodebuild',
1187+
target.kind,
1188+
shutdownTimeoutMs,
1189+
);
1190+
} catch (error) {
1191+
xctestAgentLogger.warn(
1192+
'XCTest agent graceful shutdown failed for %s: %s',
1193+
target.kind,
1194+
getErrorMessage(error),
1195+
);
1196+
}
1197+
}
1198+
11251199
await currentClient?.dispose();
11261200
await stopProcess({
11271201
process: currentProcess,

packages/platform-ios/xctest-agent/HarnessXCTestAgentUITests/HarnessXCTestAgentUITests.swift

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import Network
44
final class HarnessXCTestAgentState {
55
private let lock = NSLock()
66
private var _permissions: PermissionPromptConfiguration
7+
private var _shouldShutdown = false
78

89
init(permissions: PermissionPromptConfiguration) {
910
_permissions = permissions
@@ -20,6 +21,18 @@ final class HarnessXCTestAgentState {
2021
_permissions = permissions
2122
lock.unlock()
2223
}
24+
25+
var shouldShutdown: Bool {
26+
lock.lock()
27+
defer { lock.unlock() }
28+
return _shouldShutdown
29+
}
30+
31+
func requestShutdown() {
32+
lock.lock()
33+
_shouldShutdown = true
34+
lock.unlock()
35+
}
2336
}
2437

2538
private struct XCTestAgentHealthResponse: Codable {
@@ -264,6 +277,15 @@ final class HarnessXCTestAgentUITests: XCTestCase {
264277
return jsonResponse(XCTestAgentPermissionsResponse(permissions: state.permissions))
265278
case ("GET", "/permissions"):
266279
return jsonResponse(XCTestAgentPermissionsResponse(permissions: state.permissions))
280+
case ("POST", "/shutdown"):
281+
log("shutdown requested")
282+
state.requestShutdown()
283+
return jsonResponse(
284+
XCTestAgentHealthResponse(
285+
permissions: state.permissions,
286+
status: "shutting-down"
287+
)
288+
)
267289
default:
268290
return XCTestAgentResponse(body: Data("{\"error\":\"not found\"}".utf8), statusCode: 404)
269291
}
@@ -315,7 +337,7 @@ final class HarnessXCTestAgentUITests: XCTestCase {
315337

316338
let sessionDeadline = Date().addingTimeInterval(Constants.defaultSessionDuration)
317339

318-
while Date() < sessionDeadline {
340+
while Date() < sessionDeadline && !state.shouldShutdown {
319341
observeTargetApplication()
320342

321343
for capability in capabilities {
@@ -327,6 +349,6 @@ final class HarnessXCTestAgentUITests: XCTestCase {
327349
)
328350
}
329351

330-
log("testAgentSession completed")
352+
log("testAgentSession completed (shutdownRequested=\(state.shouldShutdown))")
331353
}
332354
}

0 commit comments

Comments
 (0)