diff --git a/src/daemon/server/http-server.ts b/src/daemon/server/http-server.ts index fef15b4c6..44ec83d6c 100644 --- a/src/daemon/server/http-server.ts +++ b/src/daemon/server/http-server.ts @@ -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); } diff --git a/src/platforms/apple/core/__tests__/runner-disposal.test.ts b/src/platforms/apple/core/__tests__/runner-disposal.test.ts new file mode 100644 index 000000000..2ad1ebaa5 --- /dev/null +++ b/src/platforms/apple/core/__tests__/runner-disposal.test.ts @@ -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(); + return { + ...actual, + isProcessAlive: mockIsProcessAlive, + isProcessGroupAlive: mockIsProcessGroupAlive, + signalPidsBestEffort: mockSignalPidsBestEffort, + }; +}); + +vi.mock('../runner/runner-transport.ts', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, cleanupTempFile: mockCleanupTempFile }; +}); + +vi.mock('../tool-provider.ts', async (importOriginal) => { + const actual = await importOriginal(); + 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(); + 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(); + 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(() => {})); + + 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(() => {})); + + await abortRunnerSessionsAndPrepProcesses([session]); + + expect(runnerSignals(session)).toEqual(['SIGINT', 'SIGTERM', 'SIGKILL']); + }, +); + +function makeRunnerSession( + device: RunnerSession['device'], + testPromise: Promise, +): 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(): { + promise: Promise; + resolve: (value: T) => void; +} { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +function execResult(): ExecResult { + return { exitCode: 0, stdout: '', stderr: '' }; +} diff --git a/src/platforms/apple/core/runner/runner-disposal.ts b/src/platforms/apple/core/runner/runner-disposal.ts index 8637c080e..f6d95cf9d 100644 --- a/src/platforms/apple/core/runner/runner-disposal.ts +++ b/src/platforms/apple/core/runner/runner-disposal.ts @@ -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, @@ -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; @@ -35,15 +37,22 @@ export async function disposeRunnerSession( session: RunnerSession, options: { graceful?: boolean; waitTimeoutMs?: number } = {}, ): Promise { + 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); } @@ -56,12 +65,15 @@ export async function abortRunnerSessionsAndPrepProcesses( activeSessions: readonly RunnerSession[], ): Promise { 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); @@ -83,7 +95,7 @@ export async function stopRunnerPrepProcesses(): Promise { ); } -async function shutdownRunnerSessionGracefully(session: RunnerSession): Promise { +async function shutdownRunnerSessionGracefully(session: RunnerSession): Promise { try { await waitForRunner( session.device, @@ -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 { +): Promise { + let timeout: NodeJS.Timeout | undefined; try { - await Promise.race([ - session.testPromise, - new Promise((resolve) => setTimeout(resolve, waitTimeoutMs)), + const exited = await Promise.race([ + session.testPromise.then( + () => true, + () => true, + ), + new Promise((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 { + 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 { + 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 { diff --git a/test/integration/provider-scenarios/daemon-http-disconnect.test.ts b/test/integration/provider-scenarios/daemon-http-disconnect.test.ts new file mode 100644 index 000000000..d58562fd4 --- /dev/null +++ b/test/integration/provider-scenarios/daemon-http-disconnect.test.ts @@ -0,0 +1,116 @@ +import assert from 'node:assert/strict'; +import http from 'node:http'; +import { afterEach, expect, test, vi } from 'vitest'; +import { emitRequestProgress } from '../../../src/daemon/request-progress.ts'; +import { getRequestSignal } from '../../../src/daemon/request-cancel.ts'; +import { createDaemonHttpServer } from '../../../src/daemon/server/http-server.ts'; +import type { DaemonResponse } from '../../../src/daemon/types.ts'; +import { + closeLoopbackServer, + listenOnLoopback, + skipWhenLoopbackUnavailable, +} from '../../../src/__tests__/test-utils/loopback.ts'; + +const { abortAllIosRunnerSessions } = vi.hoisted(() => ({ + abortAllIosRunnerSessions: vi.fn(async () => {}), +})); + +vi.mock('../../../src/platforms/apple/core/runner/runner-client.ts', async (importOriginal) => { + const actual = + await importOriginal< + typeof import('../../../src/platforms/apple/core/runner/runner-client.ts') + >(); + return { ...actual, abortAllIosRunnerSessions }; +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +test('macOS HTTP snapshot disconnect aborts in-flight Apple runner sessions', async (t) => { + if (await skipWhenLoopbackUnavailable(t, 'daemon HTTP disconnect coverage')) return; + + const requestId = 'req-http-macos-snapshot-disconnect'; + let resolveHandlerDone: () => void = () => {}; + const handlerDone = new Promise((resolve) => { + resolveHandlerDone = resolve; + }); + const server = await createDaemonHttpServer({ + token: 'provider-scenario-token', + handleRequest: async (): Promise => { + emitRequestProgress({ + type: 'command', + status: 'progress', + message: 'Starting macOS XCTest runner', + }); + const signal = getRequestSignal(requestId); + assert.ok(signal, 'request abort signal should be registered during snapshot'); + await waitForAbort(signal); + resolveHandlerDone(); + return { ok: true, data: { canceled: true } }; + }, + }); + + try { + const port = await listenOnLoopback(server); + await Promise.all([ + abortStreamingRpcAfterFirstChunk(port, { + jsonrpc: '2.0', + id: 'rpc-macos-snapshot-disconnect', + method: 'agent_device.command', + params: { + command: 'snapshot', + flags: { platform: 'macos' }, + meta: { requestId, requestProgress: 'command' }, + }, + }), + handlerDone, + ]); + await vi.waitFor(() => { + expect(abortAllIosRunnerSessions).toHaveBeenCalled(); + }); + } finally { + await closeLoopbackServer(server); + } +}); + +function abortStreamingRpcAfterFirstChunk( + port: number, + payload: Record, +): Promise { + return new Promise((resolve, reject) => { + let sawChunk = false; + const body = JSON.stringify(payload); + const req = http.request( + { + host: '127.0.0.1', + port, + path: '/rpc', + method: 'POST', + headers: { + authorization: 'Bearer provider-scenario-token', + 'content-type': 'application/json', + 'content-length': Buffer.byteLength(body), + }, + }, + (res) => { + res.once('data', () => { + sawChunk = true; + res.destroy(); + req.destroy(); + }); + res.on('close', () => (sawChunk ? resolve() : reject(new Error('response closed early')))); + res.on('error', (error) => (sawChunk ? resolve() : reject(error))); + }, + ); + req.on('error', (error) => (sawChunk ? resolve() : reject(error))); + req.end(body); + }); +} + +async function waitForAbort(signal: AbortSignal): Promise { + if (signal.aborted) return; + await new Promise((resolve) => { + signal.addEventListener('abort', () => resolve(), { once: true }); + }); +}