From 86f3b63a65f501ee0acd37cf2d1491f820f691dc Mon Sep 17 00:00:00 2001 From: Marc Rousavy Date: Wed, 3 Jun 2026 12:09:22 +0200 Subject: [PATCH] fix: gracefully stop iOS XCTest agent --- .../src/__tests__/xctest-agent-client.test.ts | 16 ++++ .../src/__tests__/xctest-agent.test.ts | 55 +++++++++++++- .../platform-ios/src/xctest-agent-client.ts | 9 ++- packages/platform-ios/src/xctest-agent.ts | 76 ++++++++++++++++++- .../HarnessXCTestAgentUITests.swift | 26 ++++++- 5 files changed, 176 insertions(+), 6 deletions(-) diff --git a/packages/platform-ios/src/__tests__/xctest-agent-client.test.ts b/packages/platform-ios/src/__tests__/xctest-agent-client.test.ts index 6defc1aa..b83af609 100644 --- a/packages/platform-ios/src/__tests__/xctest-agent-client.test.ts +++ b/packages/platform-ios/src/__tests__/xctest-agent-client.test.ts @@ -33,6 +33,16 @@ describe('xctest-agent client', () => { }), headers: {}, statusCode: 200, + }) + .mockResolvedValueOnce({ + body: JSON.stringify({ + permissions: { + autoAcceptPermissions: true, + }, + status: 'shutting-down', + }), + headers: {}, + statusCode: 200, }); const dispose = vi.fn(async () => undefined); const client = createXCTestAgentClient({ @@ -56,6 +66,7 @@ describe('xctest-agent client', () => { await expect(client.getPermissionsConfig()).resolves.toEqual({ autoAcceptPermissions: true, }); + await expect(client.shutdown()).resolves.toBeUndefined(); expect(request).toHaveBeenNthCalledWith(1, { method: 'GET', @@ -74,6 +85,11 @@ describe('xctest-agent client', () => { path: '/permissions', body: undefined, }); + expect(request).toHaveBeenNthCalledWith(4, { + method: 'POST', + path: '/shutdown', + body: undefined, + }); await client.dispose(); expect(dispose).toHaveBeenCalledTimes(1); }); diff --git a/packages/platform-ios/src/__tests__/xctest-agent.test.ts b/packages/platform-ios/src/__tests__/xctest-agent.test.ts index 81b8390a..f38057a1 100644 --- a/packages/platform-ios/src/__tests__/xctest-agent.test.ts +++ b/packages/platform-ios/src/__tests__/xctest-agent.test.ts @@ -18,6 +18,7 @@ const mocks = vi.hoisted(() => ({ status: 'ok', })), kill: vi.fn(), + shutdown: vi.fn(async () => undefined), spawn: vi.fn(), })); @@ -38,6 +39,7 @@ vi.mock('../xctest-agent-client.js', () => ({ dispose: mocks.disposeClient, getPermissionsConfig: vi.fn(), health: mocks.health, + shutdown: mocks.shutdown, })), })); @@ -148,6 +150,11 @@ describe('xctest-agent orchestration', () => { deviceBuildRoot = path.join(tempProjectRoot, '.harness', 'xctest-agent'); rmBuildRoot(); mocks.activeAgentStops.length = 0; + mocks.shutdown.mockImplementation(async () => { + for (const stop of mocks.activeAgentStops) { + stop(); + } + }); mocks.spawn.mockImplementation((file: string, args?: string[]) => { if (file === 'xcodebuild' && args?.join(' ') === '-version') { return Promise.resolve({ stdout: xcodeVersion }); @@ -466,7 +473,8 @@ describe('xctest-agent orchestration', () => { await controller.dispose(); - expect(mocks.kill).toHaveBeenCalledTimes(1); + expect(mocks.shutdown).toHaveBeenCalledTimes(1); + expect(mocks.kill).not.toHaveBeenCalled(); expect(mocks.disposeClient).toHaveBeenCalledTimes(1); }); @@ -488,7 +496,48 @@ describe('xctest-agent orchestration', () => { }); }); - it('kills the agent process during disposal', async () => { + it('requests graceful shutdown during disposal', async () => { + const controller = createXCTestAgentController({ + port: 49154, + shutdownTimeoutMs: 100, + target: { + kind: 'simulator', + id: 'sim-timeout', + }, + }); + + await controller.ensureStarted(); + await controller.dispose(); + + expect(mocks.shutdown).toHaveBeenCalledTimes(1); + expect(mocks.disposeClient).toHaveBeenCalledTimes(1); + expect(mocks.kill).not.toHaveBeenCalled(); + }); + + it('kills the agent process when graceful shutdown times out', async () => { + mocks.shutdown.mockResolvedValue(undefined); + + const controller = createXCTestAgentController({ + port: 49154, + shutdownTimeoutMs: 1, + target: { + kind: 'simulator', + id: 'sim-timeout', + }, + }); + + await controller.ensureStarted(); + await controller.dispose(); + + expect(mocks.kill).toHaveBeenCalledTimes(1); + expect(mocks.kill).toHaveBeenCalledWith('SIGTERM'); + }); + + it('kills the agent process when the graceful shutdown request hangs', async () => { + mocks.shutdown.mockImplementation( + () => new Promise(() => undefined) + ); + const controller = createXCTestAgentController({ port: 49154, shutdownTimeoutMs: 1, @@ -506,6 +555,8 @@ describe('xctest-agent orchestration', () => { }); it('force kills the agent process when graceful shutdown times out', async () => { + mocks.shutdown.mockResolvedValue(undefined); + mocks.spawn.mockImplementation((file: string, args?: string[]) => { if (file === 'xcodebuild' && args?.join(' ') === '-version') { return Promise.resolve({ stdout: xcodeVersion }); diff --git a/packages/platform-ios/src/xctest-agent-client.ts b/packages/platform-ios/src/xctest-agent-client.ts index 93f28b5c..ca53d4c3 100644 --- a/packages/platform-ios/src/xctest-agent-client.ts +++ b/packages/platform-ios/src/xctest-agent-client.ts @@ -9,7 +9,7 @@ export type XCTestAgentPermissionsConfiguration = { type XCTestAgentHealthResponse = { permissions: XCTestAgentPermissionsConfiguration; - status: 'ok'; + status: 'ok' | 'shutting-down'; }; type XCTestAgentPermissionsResponse = { @@ -23,6 +23,7 @@ export type XCTestAgentClient = { dispose: () => Promise; getPermissionsConfig: () => Promise; health: () => Promise; + shutdown: () => Promise; }; export const createXCTestAgentClient = ( @@ -67,6 +68,12 @@ export const createXCTestAgentClient = ( return response.permissions; }, + shutdown: async () => { + await requestJson({ + method: 'POST', + path: '/shutdown', + }); + }, dispose: async () => { await transport.dispose(); }, diff --git a/packages/platform-ios/src/xctest-agent.ts b/packages/platform-ios/src/xctest-agent.ts index 00e3034e..916780cf 100644 --- a/packages/platform-ios/src/xctest-agent.ts +++ b/packages/platform-ios/src/xctest-agent.ts @@ -31,7 +31,7 @@ const XCTEST_AGENT_XCTESTRUN_FILE_ENV = 'HARNESS_IOS_XCTESTRUN_FILE'; const XCTEST_AGENT_DERIVED_DATA_PATH_ENV = 'HARNESS_IOS_XCTEST_DERIVED_DATA_PATH'; const XCTEST_AGENT_STARTUP_TIMEOUT_MS = 120_000; -const XCTEST_AGENT_SHUTDOWN_TIMEOUT_MS = 5_000; +const XCTEST_AGENT_SHUTDOWN_TIMEOUT_MS = 30_000; const XCTEST_AGENT_STARTUP_POLL_INTERVAL_MS = 250; const HARNESS_DIRNAME = '.harness'; const XCTEST_AGENT_BUILD_DIRNAME = 'xctest-agent'; @@ -776,6 +776,36 @@ const waitForShutdown = async (options: { return result !== timedOut; }; +const waitForGracefulShutdown = async (options: { + client: ReturnType; + processTask: Promise | null; + shutdownTimeoutMs: number; +}): Promise<{ didStop: boolean; requestError: unknown | null }> => { + let requestError: unknown | null = null; + const timedOut = Symbol('timedOut'); + + const result = await Promise.race([ + (async () => { + try { + await options.client.shutdown(); + } catch (error) { + requestError = error; + } + + return await waitForShutdown({ + processTask: options.processTask, + shutdownTimeoutMs: options.shutdownTimeoutMs, + }); + })(), + delay(options.shutdownTimeoutMs).then(() => timedOut), + ]); + + return { + didStop: result !== timedOut && result === true, + requestError, + }; +}; + const waitForChildProcessExit = async (subprocess: Subprocess) => { const childProcess = await subprocess.nodeChildProcess; @@ -1113,6 +1143,50 @@ export const createXCTestAgentController = (options: { target.kind ); + if (currentClient) { + try { + xctestAgentLogger.info( + 'Requesting XCTest agent graceful shutdown for %s target', + target.kind, + ); + + const gracefulShutdown = await waitForGracefulShutdown({ + client: currentClient, + processTask: currentProcessTask, + shutdownTimeoutMs, + }); + + if (gracefulShutdown.didStop) { + xctestAgentLogger.info( + 'XCTest agent session for %s target stopped gracefully', + target.kind, + ); + await currentClient.dispose(); + return; + } + + if (gracefulShutdown.requestError) { + xctestAgentLogger.warn( + 'XCTest agent graceful shutdown request failed for %s: %s', + target.kind, + getErrorMessage(gracefulShutdown.requestError), + ); + } + + xctestAgentLogger.warn( + 'XCTest agent session for %s target did not stop gracefully after %dms; terminating xcodebuild', + target.kind, + shutdownTimeoutMs, + ); + } catch (error) { + xctestAgentLogger.warn( + 'XCTest agent graceful shutdown failed for %s: %s', + target.kind, + getErrorMessage(error), + ); + } + } + await currentClient?.dispose(); await stopProcess({ process: currentProcess, diff --git a/packages/platform-ios/xctest-agent/HarnessXCTestAgentUITests/HarnessXCTestAgentUITests.swift b/packages/platform-ios/xctest-agent/HarnessXCTestAgentUITests/HarnessXCTestAgentUITests.swift index 31abf4b7..a7ccf68a 100644 --- a/packages/platform-ios/xctest-agent/HarnessXCTestAgentUITests/HarnessXCTestAgentUITests.swift +++ b/packages/platform-ios/xctest-agent/HarnessXCTestAgentUITests/HarnessXCTestAgentUITests.swift @@ -4,6 +4,7 @@ import Network final class HarnessXCTestAgentState { private let lock = NSLock() private var _permissions: PermissionPromptConfiguration + private var _shouldShutdown = false init(permissions: PermissionPromptConfiguration) { _permissions = permissions @@ -20,6 +21,18 @@ final class HarnessXCTestAgentState { _permissions = permissions lock.unlock() } + + var shouldShutdown: Bool { + lock.lock() + defer { lock.unlock() } + return _shouldShutdown + } + + func requestShutdown() { + lock.lock() + _shouldShutdown = true + lock.unlock() + } } private struct XCTestAgentHealthResponse: Codable { @@ -250,6 +263,15 @@ final class HarnessXCTestAgentUITests: XCTestCase { return jsonResponse(XCTestAgentPermissionsResponse(permissions: state.permissions)) case ("GET", "/permissions"): return jsonResponse(XCTestAgentPermissionsResponse(permissions: state.permissions)) + case ("POST", "/shutdown"): + log("shutdown requested") + state.requestShutdown() + return jsonResponse( + XCTestAgentHealthResponse( + permissions: state.permissions, + status: "shutting-down" + ) + ) default: return XCTestAgentResponse(body: Data("{\"error\":\"not found\"}".utf8), statusCode: 404) } @@ -301,7 +323,7 @@ final class HarnessXCTestAgentUITests: XCTestCase { let sessionDeadline = Date().addingTimeInterval(Constants.defaultSessionDuration) - while Date() < sessionDeadline { + while Date() < sessionDeadline && !state.shouldShutdown { observeTargetApplication() for capability in capabilities { @@ -313,6 +335,6 @@ final class HarnessXCTestAgentUITests: XCTestCase { ) } - log("testAgentSession completed") + log("testAgentSession completed (shutdownRequested=\(state.shouldShutdown))") } }