Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions packages/platform-ios/src/__tests__/xctest-agent-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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',
Expand All @@ -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);
});
Expand Down
55 changes: 53 additions & 2 deletions packages/platform-ios/src/__tests__/xctest-agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const mocks = vi.hoisted(() => ({
status: 'ok',
})),
kill: vi.fn(),
shutdown: vi.fn(async () => undefined),
spawn: vi.fn(),
}));

Expand All @@ -38,6 +39,7 @@ vi.mock('../xctest-agent-client.js', () => ({
dispose: mocks.disposeClient,
getPermissionsConfig: vi.fn(),
health: mocks.health,
shutdown: mocks.shutdown,
})),
}));

Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -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);
});

Expand All @@ -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>(() => undefined)
);

const controller = createXCTestAgentController({
port: 49154,
shutdownTimeoutMs: 1,
Expand All @@ -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 });
Expand Down
9 changes: 8 additions & 1 deletion packages/platform-ios/src/xctest-agent-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export type XCTestAgentPermissionsConfiguration = {

type XCTestAgentHealthResponse = {
permissions: XCTestAgentPermissionsConfiguration;
status: 'ok';
status: 'ok' | 'shutting-down';
};

type XCTestAgentPermissionsResponse = {
Expand All @@ -23,6 +23,7 @@ export type XCTestAgentClient = {
dispose: () => Promise<void>;
getPermissionsConfig: () => Promise<XCTestAgentPermissionsConfiguration>;
health: () => Promise<XCTestAgentHealthResponse>;
shutdown: () => Promise<void>;
};

export const createXCTestAgentClient = (
Expand Down Expand Up @@ -67,6 +68,12 @@ export const createXCTestAgentClient = (

return response.permissions;
},
shutdown: async () => {
await requestJson<XCTestAgentHealthResponse>({
method: 'POST',
path: '/shutdown',
});
},
dispose: async () => {
await transport.dispose();
},
Expand Down
76 changes: 75 additions & 1 deletion packages/platform-ios/src/xctest-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -776,6 +776,36 @@ const waitForShutdown = async (options: {
return result !== timedOut;
};

const waitForGracefulShutdown = async (options: {
client: ReturnType<typeof createXCTestAgentClient>;
processTask: Promise<void> | 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;

Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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 {
Expand All @@ -313,6 +335,6 @@ final class HarnessXCTestAgentUITests: XCTestCase {
)
}

log("testAgentSession completed")
log("testAgentSession completed (shutdownRequested=\(state.shouldShutdown))")
}
}