diff --git a/src/platforms/ios/__tests__/runner-xctestrun.test.ts b/src/platforms/ios/__tests__/runner-xctestrun.test.ts index 3cbeb125..99764ba0 100644 --- a/src/platforms/ios/__tests__/runner-xctestrun.test.ts +++ b/src/platforms/ios/__tests__/runner-xctestrun.test.ts @@ -1,10 +1,15 @@ -import { test } from 'vitest'; +import { test, vi } from 'vitest'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import type { DeviceInfo } from '../../../utils/device.ts'; -import { findXctestrun, scoreXctestrunCandidate } from '../runner-xctestrun.ts'; +import { + acquireXcodebuildSimulatorSetRedirect, + findXctestrun, + resolveXcodebuildSimulatorDeviceSetPath, + scoreXctestrunCandidate, +} from '../runner-xctestrun.ts'; const iosSimulator: DeviceInfo = { platform: 'ios', @@ -92,3 +97,182 @@ test('scoreXctestrunCandidate penalizes macos and env xctestrun files for simula assert.ok(simulatorScore > macosEnvScore); }); + +test('resolveXcodebuildSimulatorDeviceSetPath uses XCTestDevices under the user home', () => { + assert.equal( + resolveXcodebuildSimulatorDeviceSetPath('/tmp/agent-device-home'), + '/tmp/agent-device-home/Library/Developer/XCTestDevices', + ); +}); + +test('acquireXcodebuildSimulatorSetRedirect swaps XCTestDevices to the requested simulator set', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'runner-xctestrun-redirect-')); + let handle: Awaited> | null = null; + try { + const requestedSetPath = path.join(root, 'requested'); + const xctestDeviceSetPath = path.join(root, 'Library', 'Developer', 'XCTestDevices'); + const lockDirPath = path.join(root, '.agent-device', 'xctest-device-set.lock'); + const originalMarkerPath = path.join(root, 'original-marker.txt'); + fs.mkdirSync(requestedSetPath, { recursive: true }); + fs.mkdirSync(xctestDeviceSetPath, { recursive: true }); + fs.writeFileSync(path.join(xctestDeviceSetPath, 'original.txt'), originalMarkerPath, 'utf8'); + + handle = await acquireXcodebuildSimulatorSetRedirect( + { + ...iosSimulator, + simulatorSetPath: requestedSetPath, + }, + { lockDirPath, xctestDeviceSetPath }, + ); + + assert.notEqual(handle, null); + assert.equal(fs.lstatSync(xctestDeviceSetPath).isSymbolicLink(), true); + assert.equal( + fs.realpathSync.native(xctestDeviceSetPath), + fs.realpathSync.native(requestedSetPath), + ); + + await handle?.release(); + handle = null; + + assert.equal(fs.lstatSync(xctestDeviceSetPath).isDirectory(), true); + assert.equal( + fs.readFileSync(path.join(xctestDeviceSetPath, 'original.txt'), 'utf8'), + originalMarkerPath, + ); + } finally { + await handle?.release(); + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('acquireXcodebuildSimulatorSetRedirect is a no-op for simulators without a scoped device set', async () => { + const handle = await acquireXcodebuildSimulatorSetRedirect(iosSimulator); + assert.equal(handle, null); +}); + +test('acquireXcodebuildSimulatorSetRedirect restores stale redirected XCTestDevices before applying a new one', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'runner-xctestrun-redirect-')); + let handle: Awaited> | null = null; + try { + const requestedSetPath = path.join(root, 'requested'); + const staleRequestedSetPath = path.join(root, 'stale-requested'); + const xctestDeviceSetPath = path.join(root, 'Library', 'Developer', 'XCTestDevices'); + const backupPath = `${xctestDeviceSetPath}.agent-device-backup`; + const lockDirPath = path.join(root, '.agent-device', 'xctest-device-set.lock'); + fs.mkdirSync(requestedSetPath, { recursive: true }); + fs.mkdirSync(staleRequestedSetPath, { recursive: true }); + fs.mkdirSync(path.dirname(xctestDeviceSetPath), { recursive: true }); + fs.mkdirSync(backupPath, { recursive: true }); + fs.writeFileSync(path.join(backupPath, 'original.txt'), 'restored', 'utf8'); + fs.symlinkSync(staleRequestedSetPath, xctestDeviceSetPath, 'dir'); + + handle = await acquireXcodebuildSimulatorSetRedirect( + { + ...iosSimulator, + simulatorSetPath: requestedSetPath, + }, + { backupPath, lockDirPath, xctestDeviceSetPath }, + ); + + assert.notEqual(handle, null); + assert.equal( + fs.realpathSync.native(xctestDeviceSetPath), + fs.realpathSync.native(requestedSetPath), + ); + + await handle?.release(); + handle = null; + + assert.equal(fs.existsSync(backupPath), false); + assert.equal( + fs.readFileSync(path.join(xctestDeviceSetPath, 'original.txt'), 'utf8'), + 'restored', + ); + } finally { + await handle?.release(); + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('acquireXcodebuildSimulatorSetRedirect clears stale lock directories from dead owners', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'runner-xctestrun-redirect-')); + let handle: Awaited> | null = null; + try { + const requestedSetPath = path.join(root, 'requested'); + const xctestDeviceSetPath = path.join(root, 'Library', 'Developer', 'XCTestDevices'); + const lockDirPath = path.join(root, '.agent-device', 'xctest-device-set.lock'); + fs.mkdirSync(requestedSetPath, { recursive: true }); + fs.mkdirSync(lockDirPath, { recursive: true }); + fs.writeFileSync( + path.join(lockDirPath, 'owner.json'), + JSON.stringify({ pid: 999_999, startTime: null, acquiredAtMs: Date.now() - 60_000 }), + 'utf8', + ); + + handle = await acquireXcodebuildSimulatorSetRedirect( + { + ...iosSimulator, + simulatorSetPath: requestedSetPath, + }, + { lockDirPath, xctestDeviceSetPath }, + ); + + assert.notEqual(handle, null); + assert.equal(fs.lstatSync(xctestDeviceSetPath).isSymbolicLink(), true); + + await handle?.release(); + handle = null; + + assert.equal(fs.existsSync(lockDirPath), false); + } finally { + await handle?.release(); + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('acquireXcodebuildSimulatorSetRedirect preserves the backup when XCTestDevices is recreated mid-swap', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'runner-xctestrun-redirect-')); + const renameSync = fs.renameSync.bind(fs); + const xctestDeviceSetPath = path.join(root, 'Library', 'Developer', 'XCTestDevices'); + const backupPath = `${xctestDeviceSetPath}.agent-device-backup`; + const renameSpy = vi.spyOn(fs, 'renameSync').mockImplementation((oldPath, newPath) => { + if ( + typeof oldPath === 'string' && + typeof newPath === 'string' && + newPath === xctestDeviceSetPath && + oldPath.includes('.agent-device-link-') + ) { + fs.mkdirSync(xctestDeviceSetPath, { recursive: true }); + fs.writeFileSync(path.join(xctestDeviceSetPath, 'collision.txt'), 'collision', 'utf8'); + } + return renameSync(oldPath, newPath); + }); + try { + const requestedSetPath = path.join(root, 'requested'); + const lockDirPath = path.join(root, '.agent-device', 'xctest-device-set.lock'); + fs.mkdirSync(requestedSetPath, { recursive: true }); + fs.mkdirSync(xctestDeviceSetPath, { recursive: true }); + fs.writeFileSync(path.join(xctestDeviceSetPath, 'original.txt'), 'original', 'utf8'); + + await assert.rejects( + acquireXcodebuildSimulatorSetRedirect( + { + ...iosSimulator, + simulatorSetPath: requestedSetPath, + }, + { backupPath, lockDirPath, xctestDeviceSetPath }, + ), + /Failed to redirect XCTest device set path/, + ); + + assert.equal(fs.readFileSync(path.join(backupPath, 'original.txt'), 'utf8'), 'original'); + assert.equal( + fs.readFileSync(path.join(xctestDeviceSetPath, 'collision.txt'), 'utf8'), + 'collision', + ); + } finally { + renameSpy.mockRestore(); + fs.rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/src/platforms/ios/runner-session.ts b/src/platforms/ios/runner-session.ts index 3ac7c52c..50dcd953 100644 --- a/src/platforms/ios/runner-session.ts +++ b/src/platforms/ios/runner-session.ts @@ -18,6 +18,7 @@ import { RUNNER_DESTINATION_TIMEOUT_SECONDS, } from './runner-transport.ts'; import { + acquireXcodebuildSimulatorSetRedirect, ensureXctestrun, IOS_RUNNER_CONTAINER_BUNDLE_IDS, prepareXctestrunWithEnv, @@ -37,6 +38,7 @@ export type RunnerSession = { testPromise: Promise; child: ExecBackgroundResult['child']; ready: boolean; + simulatorSetRedirect?: { release: () => Promise }; }; const runnerSessions = new Map(); @@ -70,33 +72,41 @@ export async function ensureRunnerSession( { AGENT_DEVICE_RUNNER_PORT: String(port) }, `session-${device.id}-${port}`, ); - const { child, wait: testPromise } = runCmdBackground( - 'xcodebuild', - [ - 'test-without-building', - '-only-testing', - 'AgentDeviceRunnerUITests/RunnerTests/testCommand', - '-parallel-testing-enabled', - 'NO', - '-test-timeouts-enabled', - 'NO', - '-collect-test-diagnostics', - 'never', - resolveRunnerMaxConcurrentDestinationsFlag(device), - '1', - '-destination-timeout', - String(RUNNER_DESTINATION_TIMEOUT_SECONDS), - '-xctestrun', - xctestrunPath, - '-destination', - resolveRunnerDestination(device), - ], - { - allowFailure: true, - env: { ...process.env, AGENT_DEVICE_RUNNER_PORT: String(port) }, - detached: true, - }, - ); + const simulatorSetRedirect = await acquireXcodebuildSimulatorSetRedirect(device); + let child: ExecBackgroundResult['child']; + let testPromise: Promise; + try { + ({ child, wait: testPromise } = runCmdBackground( + 'xcodebuild', + [ + 'test-without-building', + '-only-testing', + 'AgentDeviceRunnerUITests/RunnerTests/testCommand', + '-parallel-testing-enabled', + 'NO', + '-test-timeouts-enabled', + 'NO', + '-collect-test-diagnostics', + 'never', + resolveRunnerMaxConcurrentDestinationsFlag(device), + '1', + '-destination-timeout', + String(RUNNER_DESTINATION_TIMEOUT_SECONDS), + '-xctestrun', + xctestrunPath, + '-destination', + resolveRunnerDestination(device), + ], + { + allowFailure: true, + env: { ...process.env, AGENT_DEVICE_RUNNER_PORT: String(port) }, + detached: true, + }, + )); + } catch (error) { + await simulatorSetRedirect?.release(); + throw error; + } child.stdout?.on('data', (chunk: string) => { logChunk(chunk, options.logPath, options.traceLogPath, options.verbose); }); @@ -114,6 +124,7 @@ export async function ensureRunnerSession( testPromise, child, ready: false, + simulatorSetRedirect: simulatorSetRedirect ?? undefined, }; runnerSessions.set(device.id, session); return session; @@ -196,6 +207,7 @@ async function stopRunnerSessionInternal( await killRunnerProcessTree(session.child.pid, 'SIGKILL'); cleanupTempFile(session.xctestrunPath); cleanupTempFile(session.jsonPath); + await session.simulatorSetRedirect?.release(); if (runnerSessions.get(deviceId) === session) { runnerSessions.delete(deviceId); } @@ -241,6 +253,11 @@ export async function abortAllIosRunnerSessions(): Promise { runnerPrepProcesses.delete(child); }), ); + await Promise.allSettled( + activeSessions.map(async (session) => { + await session.simulatorSetRedirect?.release(); + }), + ); } export async function stopAllIosRunnerSessions(): Promise { diff --git a/src/platforms/ios/runner-xctestrun.ts b/src/platforms/ios/runner-xctestrun.ts index 8a2f7d13..fae16458 100644 --- a/src/platforms/ios/runner-xctestrun.ts +++ b/src/platforms/ios/runner-xctestrun.ts @@ -9,6 +9,8 @@ import { runCmdStreaming, type ExecBackgroundResult, } from '../../utils/exec.ts'; +import { resolveIosSimulatorDeviceSetPath } from '../../utils/device-isolation.ts'; +import { isProcessAlive, readProcessStartTime } from '../../utils/process-identity.ts'; import { isEnvTruthy } from '../../utils/retry.ts'; import { resolveApplePlatformName, type DeviceInfo } from '../../utils/device.ts'; import { withKeyedLock } from '../../utils/keyed-lock.ts'; @@ -21,12 +23,37 @@ import { } from './runner-macos-products.ts'; const DEFAULT_IOS_RUNNER_APP_BUNDLE_ID = 'com.callstack.agentdevice.runner'; +const XCTEST_DEVICE_SET_BASE_NAME = 'XCTestDevices'; +const XCTEST_DEVICE_SET_BACKUP_SUFFIX = '.agent-device-backup'; +const XCTEST_DEVICE_SET_LEGACY_BACKUP_PREFIX = '.agent-device-xctestdevices-backup-'; const RUNNER_DERIVED_ROOT = path.join(os.homedir(), '.agent-device', 'ios-runner'); +const XCTEST_DEVICE_SET_LOCK_TIMEOUT_MS = 30_000; +const XCTEST_DEVICE_SET_LOCK_POLL_MS = 100; +const XCTEST_DEVICE_SET_LOCK_OWNER_GRACE_MS = 5_000; const runnerXctestrunBuildLocks = new Map>(); export const runnerPrepProcesses = new Set(); +type XcodebuildSimulatorSetRedirectHandle = { + release: () => Promise; +}; + +type XcodebuildSimulatorSetRedirectOptions = { + xctestDeviceSetPath?: string; + backupPath?: string; + lockDirPath?: string; + ownerPid?: number; + ownerStartTime?: string | null; + nowMs?: number; +}; + +type XcodebuildSimulatorSetLockOwner = { + pid: number; + startTime: string | null; + acquiredAtMs: number; +}; + function normalizeBundleId(value: string | undefined): string { return value?.trim() ?? ''; } @@ -64,6 +91,309 @@ export const IOS_RUNNER_CONTAINER_BUNDLE_IDS: string[] = resolveRunnerContainerB process.env, ); +export function resolveXcodebuildSimulatorDeviceSetPath(homeDir: string = os.homedir()): string { + return path.join(homeDir, 'Library', 'Developer', 'XCTestDevices'); +} + +function resolveXcodebuildSimulatorDeviceSetLockPath(homeDir: string = os.homedir()): string { + return path.join(homeDir, '.agent-device', 'xctest-device-set.lock'); +} + +function resolveXcodebuildSimulatorDeviceSetBackupPath( + xctestDeviceSetPath: string = resolveXcodebuildSimulatorDeviceSetPath(), +): string { + return `${xctestDeviceSetPath}${XCTEST_DEVICE_SET_BACKUP_SUFFIX}`; +} + +export async function acquireXcodebuildSimulatorSetRedirect( + device: DeviceInfo, + options: XcodebuildSimulatorSetRedirectOptions = {}, +): Promise { + if (device.platform !== 'ios' || device.kind !== 'simulator') { + return null; + } + const simulatorSetPath = resolveIosSimulatorDeviceSetPath(device.simulatorSetPath); + if (!simulatorSetPath) { + return null; + } + const requestedSetPath = path.resolve(simulatorSetPath); + const xctestDeviceSetPath = path.resolve( + options.xctestDeviceSetPath ?? resolveXcodebuildSimulatorDeviceSetPath(), + ); + const backupPath = path.resolve( + options.backupPath ?? resolveXcodebuildSimulatorDeviceSetBackupPath(xctestDeviceSetPath), + ); + const lockDirPath = path.resolve( + options.lockDirPath ?? resolveXcodebuildSimulatorDeviceSetLockPath(), + ); + const ownerStartTime = options.ownerStartTime ?? readProcessStartTime(process.pid); + const releaseLock = await acquireXcodebuildSimulatorSetLock({ + lockDirPath, + owner: { + pid: options.ownerPid ?? process.pid, + startTime: ownerStartTime, + acquiredAtMs: options.nowMs ?? Date.now(), + }, + }); + + try { + reconcileXcodebuildSimulatorSetRedirect({ + xctestDeviceSetPath, + backupPath, + }); + if (sameResolvedPath(requestedSetPath, xctestDeviceSetPath)) { + await releaseLock(); + return null; + } + + fs.mkdirSync(requestedSetPath, { recursive: true }); + if (fs.existsSync(xctestDeviceSetPath)) { + fs.renameSync(xctestDeviceSetPath, backupPath); + } + installXcodebuildSimulatorSetSymlink({ + requestedSetPath, + xctestDeviceSetPath, + }); + } catch (error) { + reconcileXcodebuildSimulatorSetRedirect({ + xctestDeviceSetPath, + backupPath, + }); + await releaseLock(); + throw new AppError('COMMAND_FAILED', 'Failed to redirect XCTest device set path', { + requestedSetPath, + xctestDeviceSetPath, + backupPath, + error: String(error), + }); + } + + let released = false; + return { + release: async () => { + if (released) { + return; + } + released = true; + try { + reconcileXcodebuildSimulatorSetRedirect({ + xctestDeviceSetPath, + backupPath, + }); + } finally { + await releaseLock(); + } + }, + }; +} + +function reconcileXcodebuildSimulatorSetRedirect(paths: { + xctestDeviceSetPath: string; + backupPath: string; +}): void { + const { xctestDeviceSetPath, backupPath } = paths; + const existingBackups = [backupPath, ...findLegacyXcodebuildSimulatorSetBackups(backupPath)]; + const activeBackupPath = existingBackups.find((candidate) => fs.existsSync(candidate)); + const xctestExists = fs.existsSync(xctestDeviceSetPath); + const xctestIsSymlink = xctestExists && fs.lstatSync(xctestDeviceSetPath).isSymbolicLink(); + + if (activeBackupPath) { + if (xctestIsSymlink) { + unlinkIfSymlink(xctestDeviceSetPath); + } + if (!fs.existsSync(xctestDeviceSetPath)) { + fs.mkdirSync(path.dirname(xctestDeviceSetPath), { recursive: true }); + fs.renameSync(activeBackupPath, xctestDeviceSetPath); + } else if (!xctestIsSymlink) { + emitDiagnostic({ + level: 'warn', + phase: 'ios_runner_xctest_device_set_restore_collision', + data: { + xctestDeviceSetPath, + activeBackupPath, + }, + }); + return; + } else if (activeBackupPath !== backupPath) { + fs.rmSync(activeBackupPath, { recursive: true, force: true }); + } else { + fs.rmSync(backupPath, { recursive: true, force: true }); + } + for (const candidate of existingBackups) { + if (candidate !== activeBackupPath && fs.existsSync(candidate)) { + fs.rmSync(candidate, { recursive: true, force: true }); + } + } + return; + } + + if (xctestIsSymlink) { + emitDiagnostic({ + level: 'warn', + phase: 'ios_runner_xctest_device_set_orphaned_symlink', + data: { + xctestDeviceSetPath, + }, + }); + unlinkIfSymlink(xctestDeviceSetPath); + } +} + +function findLegacyXcodebuildSimulatorSetBackups(backupPath: string): string[] { + const parentDir = path.dirname(backupPath); + const backupBaseName = path.basename(backupPath).replace(XCTEST_DEVICE_SET_BACKUP_SUFFIX, ''); + const legacyPrefix = + backupBaseName === XCTEST_DEVICE_SET_BASE_NAME + ? XCTEST_DEVICE_SET_LEGACY_BACKUP_PREFIX + : `${backupBaseName}${XCTEST_DEVICE_SET_LEGACY_BACKUP_PREFIX}`; + try { + return fs + .readdirSync(parentDir) + .filter((entry) => entry.startsWith(legacyPrefix)) + .sort() + .map((entry) => path.join(parentDir, entry)); + } catch { + return []; + } +} + +function installXcodebuildSimulatorSetSymlink(paths: { + requestedSetPath: string; + xctestDeviceSetPath: string; +}): void { + const { requestedSetPath, xctestDeviceSetPath } = paths; + const parentDir = path.dirname(xctestDeviceSetPath); + const tmpSymlinkPath = path.join( + parentDir, + `${XCTEST_DEVICE_SET_BASE_NAME}.agent-device-link-${process.pid}-${Date.now()}`, + ); + fs.mkdirSync(parentDir, { recursive: true }); + try { + fs.symlinkSync(requestedSetPath, tmpSymlinkPath, 'dir'); + fs.renameSync(tmpSymlinkPath, xctestDeviceSetPath); + } catch (error) { + if (fs.existsSync(tmpSymlinkPath)) { + unlinkIfSymlink(tmpSymlinkPath); + } + throw error; + } +} + +function unlinkIfSymlink(targetPath: string): void { + if (!fs.existsSync(targetPath)) { + return; + } + if (!fs.lstatSync(targetPath).isSymbolicLink()) { + return; + } + fs.unlinkSync(targetPath); +} + +function sameResolvedPath(left: string, right: string): boolean { + if (path.resolve(left) === path.resolve(right)) { + return true; + } + try { + return fs.realpathSync.native(left) === fs.realpathSync.native(right); + } catch { + return false; + } +} + +async function acquireXcodebuildSimulatorSetLock(params: { + lockDirPath: string; + owner: XcodebuildSimulatorSetLockOwner; +}): Promise<() => Promise> { + const { lockDirPath, owner } = params; + const ownerFilePath = path.join(lockDirPath, 'owner.json'); + const deadline = Date.now() + XCTEST_DEVICE_SET_LOCK_TIMEOUT_MS; + + fs.mkdirSync(path.dirname(lockDirPath), { recursive: true }); + + while (Date.now() < deadline) { + try { + fs.mkdirSync(lockDirPath); + writeXcodebuildSimulatorSetLockOwner(ownerFilePath, owner); + let released = false; + return async () => { + if (released) { + return; + } + released = true; + fs.rmSync(lockDirPath, { recursive: true, force: true }); + }; + } catch (error) { + const err = error as NodeJS.ErrnoException; + if (err.code !== 'EEXIST') { + throw err; + } + if (clearStaleXcodebuildSimulatorSetLock(lockDirPath, ownerFilePath)) { + continue; + } + await new Promise((resolve) => setTimeout(resolve, XCTEST_DEVICE_SET_LOCK_POLL_MS)); + } + } + + throw new AppError('COMMAND_FAILED', 'Timed out waiting for XCTest device set lock', { + lockDirPath, + }); +} + +function writeXcodebuildSimulatorSetLockOwner( + ownerFilePath: string, + owner: XcodebuildSimulatorSetLockOwner, +): void { + const tmpOwnerFilePath = `${ownerFilePath}.${process.pid}.${Date.now()}.tmp`; + fs.writeFileSync(tmpOwnerFilePath, JSON.stringify(owner), 'utf8'); + fs.renameSync(tmpOwnerFilePath, ownerFilePath); +} + +function clearStaleXcodebuildSimulatorSetLock(lockDirPath: string, ownerFilePath: string): boolean { + let ownerStats: fs.Stats | null = null; + try { + ownerStats = fs.statSync(lockDirPath); + } catch { + return true; + } + + const owner = readXcodebuildSimulatorSetLockOwner(ownerFilePath); + if (owner) { + if (isLiveXcodebuildSimulatorSetLockOwner(owner)) { + return false; + } + fs.rmSync(lockDirPath, { recursive: true, force: true }); + return true; + } + if (Date.now() - ownerStats.mtimeMs < XCTEST_DEVICE_SET_LOCK_OWNER_GRACE_MS) { + return false; + } + fs.rmSync(lockDirPath, { recursive: true, force: true }); + return true; +} + +function readXcodebuildSimulatorSetLockOwner( + ownerFilePath: string, +): XcodebuildSimulatorSetLockOwner | null { + try { + return JSON.parse(fs.readFileSync(ownerFilePath, 'utf8')) as XcodebuildSimulatorSetLockOwner; + } catch { + return null; + } +} + +function isLiveXcodebuildSimulatorSetLockOwner(owner: XcodebuildSimulatorSetLockOwner): boolean { + if (!Number.isInteger(owner.pid) || owner.pid <= 0) { + return false; + } + if (!isProcessAlive(owner.pid)) { + return false; + } + if (owner.startTime) { + return readProcessStartTime(owner.pid) === owner.startTime; + } + return true; +} + export async function ensureXctestrun( device: DeviceInfo, options: { verbose?: boolean; logPath?: string; traceLogPath?: string }, @@ -420,6 +750,7 @@ async function buildRunnerXctestrun( ); const provisioningArgs = device.kind === 'device' ? ['-allowProvisioningUpdates'] : []; const performanceBuildSettings = resolveRunnerPerformanceBuildSettings(); + const simulatorSetRedirect = await acquireXcodebuildSimulatorSetRedirect(device); try { await runCmdStreaming( 'xcodebuild', @@ -467,6 +798,8 @@ async function buildRunnerXctestrun( logPath: options.logPath, hint, }); + } finally { + await simulatorSetRedirect?.release(); } }