Skip to content
Merged
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
2 changes: 1 addition & 1 deletion src/daemon/server/http-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -770,7 +770,7 @@ async function abortInFlightIosRunnerSessionsWhileDisconnected(

function shouldAbortIosRunnerSessionsOnDisconnect(req: DaemonRequest): boolean {
if (req.flags?.platform === 'android') return false;
if (req.flags?.platform === 'ios') return true;
if (req.flags?.platform === 'ios' || req.flags?.platform === 'macos') return true;
return IOS_RUNNER_ABORT_REPLAY_COMMANDS.has(req.command);
}

Expand Down
166 changes: 166 additions & 0 deletions src/platforms/apple/core/__tests__/runner-disposal.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
import {
IOS_SIMULATOR,
MACOS_DEVICE,
TVOS_SIMULATOR,
} from '../../../../__tests__/test-utils/index.ts';
import type { ExecResult } from '../../../../utils/exec.ts';
import type { RunnerSession } from '../runner/runner-session-types.ts';

const {
mockCleanupTempFile,
mockIsProcessAlive,
mockIsProcessGroupAlive,
mockRunAppleToolCommand,
mockRunXcrun,
mockSignalPidsBestEffort,
} = vi.hoisted(() => ({
mockCleanupTempFile: vi.fn(),
mockIsProcessAlive: vi.fn(),
mockIsProcessGroupAlive: vi.fn(),
mockRunAppleToolCommand: vi.fn(),
mockRunXcrun: vi.fn(),
mockSignalPidsBestEffort: vi.fn(),
}));

vi.mock('../../../../utils/host-process.ts', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../../utils/host-process.ts')>();
return {
...actual,
isProcessAlive: mockIsProcessAlive,
isProcessGroupAlive: mockIsProcessGroupAlive,
signalPidsBestEffort: mockSignalPidsBestEffort,
};
});

vi.mock('../runner/runner-transport.ts', async (importOriginal) => {
const actual = await importOriginal<typeof import('../runner/runner-transport.ts')>();
return { ...actual, cleanupTempFile: mockCleanupTempFile };
});

vi.mock('../tool-provider.ts', async (importOriginal) => {
const actual = await importOriginal<typeof import('../tool-provider.ts')>();
return {
...actual,
runAppleToolCommand: mockRunAppleToolCommand,
runXcrun: mockRunXcrun,
};
});

import { abortRunnerSessionsAndPrepProcesses } from '../runner/runner-disposal.ts';

beforeEach(() => {
vi.useFakeTimers();
vi.spyOn(process, 'kill').mockImplementation(() => true);
mockIsProcessAlive.mockReturnValue(true);
mockIsProcessGroupAlive.mockReturnValue(false);
mockRunAppleToolCommand.mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' });
mockRunXcrun.mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' });
});

afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
vi.resetAllMocks();
});

test('macOS runner abort waits for XCTest teardown after SIGINT', async () => {
const testRun = deferred<ExecResult>();
const session = makeRunnerSession(MACOS_DEVICE, testRun.promise);

const abort = abortRunnerSessionsAndPrepProcesses([session]);
await vi.advanceTimersByTimeAsync(0);

expect(runnerSignals(session)).toEqual(['SIGINT']);

mockIsProcessAlive.mockReturnValue(false);
testRun.resolve(execResult());
await abort;

expect(runnerSignals(session)).toEqual(['SIGINT']);
expect(mockCleanupTempFile).toHaveBeenCalledWith(session.xctestrunPath);
expect(mockCleanupTempFile).toHaveBeenCalledWith(session.jsonPath);
});

test('macOS runner abort stages TERM after the interrupt grace period', async () => {
const testRun = deferred<ExecResult>();
const session = makeRunnerSession(MACOS_DEVICE, testRun.promise);

const abort = abortRunnerSessionsAndPrepProcesses([session]);
await vi.advanceTimersByTimeAsync(4_999);
expect(runnerSignals(session)).toEqual(['SIGINT']);

await vi.advanceTimersByTimeAsync(1);
expect(runnerSignals(session)).toEqual(['SIGINT', 'SIGTERM']);

mockIsProcessAlive.mockReturnValue(false);
testRun.resolve(execResult());
await abort;

expect(runnerSignals(session)).toEqual(['SIGINT', 'SIGTERM']);
});

test('macOS runner abort force-kills only after both grace periods expire', async () => {
const session = makeRunnerSession(MACOS_DEVICE, new Promise<ExecResult>(() => {}));

const abort = abortRunnerSessionsAndPrepProcesses([session]);
await vi.advanceTimersByTimeAsync(5_000);
expect(runnerSignals(session)).toEqual(['SIGINT', 'SIGTERM']);

await vi.advanceTimersByTimeAsync(1_999);
expect(runnerSignals(session)).toEqual(['SIGINT', 'SIGTERM']);

await vi.advanceTimersByTimeAsync(1);
await abort;
expect(runnerSignals(session)).toEqual(['SIGINT', 'SIGTERM', 'SIGKILL']);
});

test.each([IOS_SIMULATOR, TVOS_SIMULATOR])(
'$appleOs runner abort preserves immediate cancellation',
async (device) => {
const session = makeRunnerSession(device, new Promise<ExecResult>(() => {}));

await abortRunnerSessionsAndPrepProcesses([session]);

expect(runnerSignals(session)).toEqual(['SIGINT', 'SIGTERM', 'SIGKILL']);
},
);

function makeRunnerSession(
device: RunnerSession['device'],
testPromise: Promise<ExecResult>,
): RunnerSession {
return {
sessionId: `${device.id}:8123:test`,
device,
deviceId: device.id,
port: 8123,
xctestrunPath: `/tmp/${device.id}.xctestrun`,
jsonPath: `/tmp/${device.id}.json`,
testPromise,
child: { pid: 42, exitCode: null },
ready: true,
};
}

function runnerSignals(session: RunnerSession): NodeJS.Signals[] {
return vi
.mocked(process.kill)
.mock.calls.filter(([pid]) => pid === -(session.child.pid ?? 0))
.map(([, signal]) => signal as NodeJS.Signals);
}

function deferred<T>(): {
promise: Promise<T>;
resolve: (value: T) => void;
} {
let resolve!: (value: T) => void;
const promise = new Promise<T>((done) => {
resolve = done;
});
return { promise, resolve };
}

function execResult(): ExecResult {
return { exitCode: 0, stdout: '', stderr: '' };
}
87 changes: 72 additions & 15 deletions src/platforms/apple/core/runner/runner-disposal.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { emitDiagnostic } from '../../../../utils/diagnostics.ts';
import type { DeviceInfo } from '../../../../kernel/device.ts';
import { isMacOs, type DeviceInfo } from '../../../../kernel/device.ts';
import {
isProcessAlive,
isProcessGroupAlive,
Expand All @@ -22,6 +22,8 @@ export const RUNNER_INVALIDATE_WAIT_TIMEOUT_MS = 1_000;

const RUNNER_STOP_WAIT_TIMEOUT_MS = 10_000;
const RUNNER_SHUTDOWN_TIMEOUT_MS = 15_000;
const MACOS_RUNNER_INTERRUPT_WAIT_TIMEOUT_MS = 5_000;
const MACOS_RUNNER_TERM_WAIT_TIMEOUT_MS = 2_000;
const RUNNER_STALE_XCODEBUILD_KILL_TIMEOUT_MS = 2_000;
const RUNNER_SIMULATOR_TERMINATE_TIMEOUT_MS = 2_000;

Expand All @@ -35,15 +37,22 @@ export async function disposeRunnerSession(
session: RunnerSession,
options: { graceful?: boolean; waitTimeoutMs?: number } = {},
): Promise<void> {
let processExitHandled = false;
if (options.graceful !== false) {
await shutdownRunnerSessionGracefully(session);
processExitHandled = await shutdownRunnerSessionGracefully(session);
} else if (isMacOs(session.device)) {
await interruptMacOsRunnerSessions([session]);
await cleanupRunnerSessionResources(session);
return;
} else {
await killRunnerProcessTree(session.child.pid, 'SIGTERM');
}

await waitForRunnerProcessExit(session, options.waitTimeoutMs ?? RUNNER_STOP_WAIT_TIMEOUT_MS);
if (isRunnerProcessTreeAlive(session.child.pid)) {
await killRunnerProcessTree(session.child.pid, 'SIGKILL');
if (!processExitHandled) {
await waitForRunnerProcessExit(session, options.waitTimeoutMs ?? RUNNER_STOP_WAIT_TIMEOUT_MS);
if (isRunnerProcessTreeAlive(session.child.pid)) {
await killRunnerProcessTree(session.child.pid, 'SIGKILL');
}
}
await cleanupRunnerSessionResources(session);
}
Expand All @@ -56,12 +65,15 @@ export async function abortRunnerSessionsAndPrepProcesses(
activeSessions: readonly RunnerSession[],
): Promise<void> {
const prepProcesses = Array.from(runnerPrepProcesses);
await signalRunnerSessions(activeSessions, 'SIGINT');
const macOsSessions = activeSessions.filter((session) => isMacOs(session.device));
const otherSessions = activeSessions.filter((session) => !isMacOs(session.device));
await signalRunnerSessions(otherSessions, 'SIGINT');
await signalRunnerPrepProcesses(prepProcesses, 'SIGINT');
await signalRunnerSessions(activeSessions, 'SIGTERM');
await signalRunnerSessions(otherSessions, 'SIGTERM');
await signalRunnerPrepProcesses(prepProcesses, 'SIGTERM');
await signalRunnerSessions(activeSessions, 'SIGKILL');
await signalRunnerSessions(otherSessions, 'SIGKILL');
await signalRunnerPrepProcesses(prepProcesses, 'SIGKILL');
await interruptMacOsRunnerSessions(macOsSessions);
await Promise.allSettled(
activeSessions.map(async (session) => {
await cleanupRunnerSessionResources(session);
Expand All @@ -83,7 +95,7 @@ export async function stopRunnerPrepProcesses(): Promise<void> {
);
}

async function shutdownRunnerSessionGracefully(session: RunnerSession): Promise<void> {
async function shutdownRunnerSessionGracefully(session: RunnerSession): Promise<boolean> {
try {
await waitForRunner(
session.device,
Expand All @@ -94,21 +106,66 @@ async function shutdownRunnerSessionGracefully(session: RunnerSession): Promise<
undefined,
RUNNER_SHUTDOWN_TIMEOUT_MS,
);
return false;
} catch {
await killRunnerProcessTree(session.child.pid, 'SIGTERM');
if (isMacOs(session.device)) {
await interruptMacOsRunnerSessions([session]);
return true;
} else {
await killRunnerProcessTree(session.child.pid, 'SIGTERM');
return false;
}
}
}

async function waitForRunnerProcessExit(
session: RunnerSession,
waitTimeoutMs: number,
): Promise<void> {
): Promise<boolean> {
let timeout: NodeJS.Timeout | undefined;
try {
await Promise.race([
session.testPromise,
new Promise<void>((resolve) => setTimeout(resolve, waitTimeoutMs)),
const exited = await Promise.race([
session.testPromise.then(
() => true,
() => true,
),
new Promise<boolean>((resolve) => {
timeout = setTimeout(() => resolve(false), waitTimeoutMs);
}),
]);
} catch {}
return exited || !isRunnerProcessTreeAlive(session.child.pid);
} finally {
if (timeout) clearTimeout(timeout);
}
}

async function interruptMacOsRunnerSessions(sessions: readonly RunnerSession[]): Promise<void> {
if (sessions.length === 0) return;

// CONSERVATIVE: XCTest disables the host screen saver while macOS UI automation runs and
// restores it during xcodebuild teardown. Give SIGINT time to complete that teardown before
// escalating; revisit only if XCTest exposes a separate public cleanup acknowledgement.
await signalRunnerSessions(sessions, 'SIGINT');
const afterInterrupt = await runnerSessionsStillAlive(
sessions,
MACOS_RUNNER_INTERRUPT_WAIT_TIMEOUT_MS,
);
await signalRunnerSessions(afterInterrupt, 'SIGTERM');
const afterTerm = await runnerSessionsStillAlive(
afterInterrupt,
MACOS_RUNNER_TERM_WAIT_TIMEOUT_MS,
);
await signalRunnerSessions(afterTerm, 'SIGKILL');
}

async function runnerSessionsStillAlive(
sessions: readonly RunnerSession[],
waitTimeoutMs: number,
): Promise<RunnerSession[]> {
const exited = await Promise.all(
sessions.map(async (session) => await waitForRunnerProcessExit(session, waitTimeoutMs)),
);
return sessions.filter((_, index) => !exited[index]);
}

async function cleanupRunnerSessionResources(session: RunnerSession): Promise<void> {
Expand Down
Loading
Loading