From 6e81569340d8e73396576c6648498a723f88ea93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 3 Apr 2026 16:22:54 +0200 Subject: [PATCH 1/4] fix: respect scoped simulator sets in ios runner --- .../ios/__tests__/runner-xctestrun.test.ts | 131 ++++++++- src/platforms/ios/runner-session.ts | 70 +++-- src/platforms/ios/runner-xctestrun.ts | 269 ++++++++++++++++++ 3 files changed, 442 insertions(+), 28 deletions(-) diff --git a/src/platforms/ios/__tests__/runner-xctestrun.test.ts b/src/platforms/ios/__tests__/runner-xctestrun.test.ts index 3cbeb125..9185d1a6 100644 --- a/src/platforms/ios/__tests__/runner-xctestrun.test.ts +++ b/src/platforms/ios/__tests__/runner-xctestrun.test.ts @@ -4,7 +4,12 @@ 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,127 @@ 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-')); + 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'); + + const 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(); + + assert.equal(fs.lstatSync(xctestDeviceSetPath).isDirectory(), true); + assert.equal( + fs.readFileSync(path.join(xctestDeviceSetPath, 'original.txt'), 'utf8'), + originalMarkerPath, + ); + } finally { + 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-')); + 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'); + + const 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(); + + assert.equal(fs.existsSync(backupPath), false); + assert.equal( + fs.readFileSync(path.join(xctestDeviceSetPath, 'original.txt'), 'utf8'), + 'restored', + ); + } finally { + 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-')); + 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', + ); + + const handle = await acquireXcodebuildSimulatorSetRedirect( + { + ...iosSimulator, + simulatorSetPath: requestedSetPath, + }, + { lockDirPath, xctestDeviceSetPath }, + ); + + assert.notEqual(handle, null); + assert.equal(fs.lstatSync(xctestDeviceSetPath).isSymbolicLink(), true); + + await handle?.release(); + + assert.equal(fs.existsSync(lockDirPath), false); + } finally { + 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..236d0d81 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; + releaseXcodebuildSimulatorSetRedirect?: () => 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,11 @@ export async function ensureRunnerSession( testPromise, child, ready: false, + releaseXcodebuildSimulatorSetRedirect: simulatorSetRedirect + ? async () => { + await simulatorSetRedirect.release(); + } + : undefined, }; runnerSessions.set(device.id, session); return session; @@ -196,6 +211,7 @@ async function stopRunnerSessionInternal( await killRunnerProcessTree(session.child.pid, 'SIGKILL'); cleanupTempFile(session.xctestrunPath); cleanupTempFile(session.jsonPath); + await session.releaseXcodebuildSimulatorSetRedirect?.(); if (runnerSessions.get(deviceId) === session) { runnerSessions.delete(deviceId); } diff --git a/src/platforms/ios/runner-xctestrun.ts b/src/platforms/ios/runner-xctestrun.ts index 8a2f7d13..a5df0244 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'; @@ -23,10 +25,32 @@ import { const DEFAULT_IOS_RUNNER_APP_BUNDLE_ID = 'com.callstack.agentdevice.runner'; 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 +88,248 @@ 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}.agent-device-backup`; +} + +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 }); + fs.mkdirSync(path.dirname(xctestDeviceSetPath), { recursive: true }); + if (fs.existsSync(xctestDeviceSetPath)) { + fs.renameSync(xctestDeviceSetPath, backupPath); + } + fs.symlinkSync(requestedSetPath, xctestDeviceSetPath, 'dir'); + } 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) { + fs.rmSync(xctestDeviceSetPath, { recursive: true, force: true }); + } + if (!fs.existsSync(xctestDeviceSetPath)) { + fs.mkdirSync(path.dirname(xctestDeviceSetPath), { recursive: true }); + fs.renameSync(activeBackupPath, xctestDeviceSetPath); + } 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) { + fs.rmSync(xctestDeviceSetPath, { recursive: true, force: true }); + } +} + +function findLegacyXcodebuildSimulatorSetBackups(backupPath: string): string[] { + const parentDir = path.dirname(backupPath); + const backupBaseName = path.basename(backupPath).replace(/\.agent-device-backup$/, ''); + const legacyPrefix = `${backupBaseName.replace(/XCTestDevices$/, '.agent-device-xctestdevices-backup-')}`; + try { + return fs + .readdirSync(parentDir) + .filter((entry) => entry.startsWith(legacyPrefix)) + .sort() + .map((entry) => path.join(parentDir, entry)); + } catch { + return []; + } +} + +function sameResolvedPath(left: string, right: string): boolean { + const candidates = (value: string): string[] => { + const resolved = path.resolve(value); + try { + return [resolved, fs.realpathSync.native(resolved)]; + } catch { + return [resolved]; + } + }; + const rightCandidates = new Set(candidates(right)); + return candidates(left).some((candidate) => rightCandidates.has(candidate)); +} + +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); + fs.writeFileSync(ownerFilePath, JSON.stringify(owner), 'utf8'); + 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 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 && isLiveXcodebuildSimulatorSetLockOwner(owner)) { + return false; + } + if (owner && !isLiveXcodebuildSimulatorSetLockOwner(owner)) { + 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 +686,7 @@ async function buildRunnerXctestrun( ); const provisioningArgs = device.kind === 'device' ? ['-allowProvisioningUpdates'] : []; const performanceBuildSettings = resolveRunnerPerformanceBuildSettings(); + const simulatorSetRedirect = await acquireXcodebuildSimulatorSetRedirect(device); try { await runCmdStreaming( 'xcodebuild', @@ -467,6 +734,8 @@ async function buildRunnerXctestrun( logPath: options.logPath, hint, }); + } finally { + await simulatorSetRedirect?.release(); } } From b4d16529bf8ad9087d2c21e00b778a394cde94ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 3 Apr 2026 16:28:23 +0200 Subject: [PATCH 2/4] fix: harden xctest device set redirect --- .../ios/__tests__/runner-xctestrun.test.ts | 15 +++- src/platforms/ios/runner-xctestrun.ts | 72 ++++++++++++++++--- 2 files changed, 76 insertions(+), 11 deletions(-) diff --git a/src/platforms/ios/__tests__/runner-xctestrun.test.ts b/src/platforms/ios/__tests__/runner-xctestrun.test.ts index 9185d1a6..1fd83c72 100644 --- a/src/platforms/ios/__tests__/runner-xctestrun.test.ts +++ b/src/platforms/ios/__tests__/runner-xctestrun.test.ts @@ -107,6 +107,7 @@ test('resolveXcodebuildSimulatorDeviceSetPath uses XCTestDevices under the user 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'); @@ -116,7 +117,7 @@ test('acquireXcodebuildSimulatorSetRedirect swaps XCTestDevices to the requested fs.mkdirSync(xctestDeviceSetPath, { recursive: true }); fs.writeFileSync(path.join(xctestDeviceSetPath, 'original.txt'), originalMarkerPath, 'utf8'); - const handle = await acquireXcodebuildSimulatorSetRedirect( + handle = await acquireXcodebuildSimulatorSetRedirect( { ...iosSimulator, simulatorSetPath: requestedSetPath, @@ -132,6 +133,7 @@ test('acquireXcodebuildSimulatorSetRedirect swaps XCTestDevices to the requested ); await handle?.release(); + handle = null; assert.equal(fs.lstatSync(xctestDeviceSetPath).isDirectory(), true); assert.equal( @@ -139,6 +141,7 @@ test('acquireXcodebuildSimulatorSetRedirect swaps XCTestDevices to the requested originalMarkerPath, ); } finally { + await handle?.release(); fs.rmSync(root, { recursive: true, force: true }); } }); @@ -150,6 +153,7 @@ test('acquireXcodebuildSimulatorSetRedirect is a no-op for simulators without a 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'); @@ -163,7 +167,7 @@ test('acquireXcodebuildSimulatorSetRedirect restores stale redirected XCTestDevi fs.writeFileSync(path.join(backupPath, 'original.txt'), 'restored', 'utf8'); fs.symlinkSync(staleRequestedSetPath, xctestDeviceSetPath, 'dir'); - const handle = await acquireXcodebuildSimulatorSetRedirect( + handle = await acquireXcodebuildSimulatorSetRedirect( { ...iosSimulator, simulatorSetPath: requestedSetPath, @@ -178,6 +182,7 @@ test('acquireXcodebuildSimulatorSetRedirect restores stale redirected XCTestDevi ); await handle?.release(); + handle = null; assert.equal(fs.existsSync(backupPath), false); assert.equal( @@ -185,12 +190,14 @@ test('acquireXcodebuildSimulatorSetRedirect restores stale redirected XCTestDevi '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'); @@ -203,7 +210,7 @@ test('acquireXcodebuildSimulatorSetRedirect clears stale lock directories from d 'utf8', ); - const handle = await acquireXcodebuildSimulatorSetRedirect( + handle = await acquireXcodebuildSimulatorSetRedirect( { ...iosSimulator, simulatorSetPath: requestedSetPath, @@ -215,9 +222,11 @@ test('acquireXcodebuildSimulatorSetRedirect clears stale lock directories from d 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 }); } }); diff --git a/src/platforms/ios/runner-xctestrun.ts b/src/platforms/ios/runner-xctestrun.ts index a5df0244..2012be12 100644 --- a/src/platforms/ios/runner-xctestrun.ts +++ b/src/platforms/ios/runner-xctestrun.ts @@ -23,6 +23,9 @@ 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; @@ -99,7 +102,7 @@ function resolveXcodebuildSimulatorDeviceSetLockPath(homeDir: string = os.homedi function resolveXcodebuildSimulatorDeviceSetBackupPath( xctestDeviceSetPath: string = resolveXcodebuildSimulatorDeviceSetPath(), ): string { - return `${xctestDeviceSetPath}.agent-device-backup`; + return `${xctestDeviceSetPath}${XCTEST_DEVICE_SET_BACKUP_SUFFIX}`; } export async function acquireXcodebuildSimulatorSetRedirect( @@ -144,11 +147,13 @@ export async function acquireXcodebuildSimulatorSetRedirect( } fs.mkdirSync(requestedSetPath, { recursive: true }); - fs.mkdirSync(path.dirname(xctestDeviceSetPath), { recursive: true }); if (fs.existsSync(xctestDeviceSetPath)) { fs.renameSync(xctestDeviceSetPath, backupPath); } - fs.symlinkSync(requestedSetPath, xctestDeviceSetPath, 'dir'); + installXcodebuildSimulatorSetSymlink({ + requestedSetPath, + xctestDeviceSetPath, + }); } catch (error) { reconcileXcodebuildSimulatorSetRedirect({ xctestDeviceSetPath, @@ -194,7 +199,7 @@ function reconcileXcodebuildSimulatorSetRedirect(paths: { if (activeBackupPath) { if (xctestIsSymlink) { - fs.rmSync(xctestDeviceSetPath, { recursive: true, force: true }); + unlinkIfSymlink(xctestDeviceSetPath); } if (!fs.existsSync(xctestDeviceSetPath)) { fs.mkdirSync(path.dirname(xctestDeviceSetPath), { recursive: true }); @@ -213,14 +218,24 @@ function reconcileXcodebuildSimulatorSetRedirect(paths: { } if (xctestIsSymlink) { - fs.rmSync(xctestDeviceSetPath, { recursive: true, force: true }); + 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(/\.agent-device-backup$/, ''); - const legacyPrefix = `${backupBaseName.replace(/XCTestDevices$/, '.agent-device-xctestdevices-backup-')}`; + 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) @@ -232,6 +247,38 @@ function findLegacyXcodebuildSimulatorSetBackups(backupPath: string): string[] { } } +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 { const candidates = (value: string): string[] => { const resolved = path.resolve(value); @@ -258,7 +305,7 @@ async function acquireXcodebuildSimulatorSetLock(params: { while (Date.now() < deadline) { try { fs.mkdirSync(lockDirPath); - fs.writeFileSync(ownerFilePath, JSON.stringify(owner), 'utf8'); + writeXcodebuildSimulatorSetLockOwner(ownerFilePath, owner); let released = false; return async () => { if (released) { @@ -284,6 +331,15 @@ async function acquireXcodebuildSimulatorSetLock(params: { }); } +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 { From 8bf3a07cdfb209f79feca5fa52709e7c3db4be24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 3 Apr 2026 16:31:53 +0200 Subject: [PATCH 3/4] fix: preserve xctest backup on redirect collisions --- .../ios/__tests__/runner-xctestrun.test.ts | 48 ++++++++++++++++++- src/platforms/ios/runner-xctestrun.ts | 10 ++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/src/platforms/ios/__tests__/runner-xctestrun.test.ts b/src/platforms/ios/__tests__/runner-xctestrun.test.ts index 1fd83c72..99764ba0 100644 --- a/src/platforms/ios/__tests__/runner-xctestrun.test.ts +++ b/src/platforms/ios/__tests__/runner-xctestrun.test.ts @@ -1,4 +1,4 @@ -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'; @@ -230,3 +230,49 @@ test('acquireXcodebuildSimulatorSetRedirect clears stale lock directories from d 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-xctestrun.ts b/src/platforms/ios/runner-xctestrun.ts index 2012be12..2d7d4ebd 100644 --- a/src/platforms/ios/runner-xctestrun.ts +++ b/src/platforms/ios/runner-xctestrun.ts @@ -204,6 +204,16 @@ function reconcileXcodebuildSimulatorSetRedirect(paths: { 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 { From cf2fbd89b41b0fbd396571f356d8dacc4cf66a13 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Apr 2026 12:09:55 +0000 Subject: [PATCH 4/4] fix: release simulator-set redirect on abort path and simplify redirect internals Address P1 review feedback: abortAllIosRunnerSessions now releases simulator-set redirect handles after killing processes, preventing stale locks from blocking subsequent scoped simulator commands. Simplify redirect internals: - Store redirect handle directly on session instead of wrapping in closure - Simplify sameResolvedPath to direct comparison instead of set intersection - Collapse double liveness check in clearStaleXcodebuildSimulatorSetLock https://claude.ai/code/session_01VsAUdPKbQoGbwvftBZtXfB --- src/platforms/ios/runner-session.ts | 15 ++++++++------- src/platforms/ios/runner-xctestrun.ts | 26 ++++++++++++-------------- 2 files changed, 20 insertions(+), 21 deletions(-) diff --git a/src/platforms/ios/runner-session.ts b/src/platforms/ios/runner-session.ts index 236d0d81..50dcd953 100644 --- a/src/platforms/ios/runner-session.ts +++ b/src/platforms/ios/runner-session.ts @@ -38,7 +38,7 @@ export type RunnerSession = { testPromise: Promise; child: ExecBackgroundResult['child']; ready: boolean; - releaseXcodebuildSimulatorSetRedirect?: () => Promise; + simulatorSetRedirect?: { release: () => Promise }; }; const runnerSessions = new Map(); @@ -124,11 +124,7 @@ export async function ensureRunnerSession( testPromise, child, ready: false, - releaseXcodebuildSimulatorSetRedirect: simulatorSetRedirect - ? async () => { - await simulatorSetRedirect.release(); - } - : undefined, + simulatorSetRedirect: simulatorSetRedirect ?? undefined, }; runnerSessions.set(device.id, session); return session; @@ -211,7 +207,7 @@ async function stopRunnerSessionInternal( await killRunnerProcessTree(session.child.pid, 'SIGKILL'); cleanupTempFile(session.xctestrunPath); cleanupTempFile(session.jsonPath); - await session.releaseXcodebuildSimulatorSetRedirect?.(); + await session.simulatorSetRedirect?.release(); if (runnerSessions.get(deviceId) === session) { runnerSessions.delete(deviceId); } @@ -257,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 2d7d4ebd..fae16458 100644 --- a/src/platforms/ios/runner-xctestrun.ts +++ b/src/platforms/ios/runner-xctestrun.ts @@ -290,16 +290,14 @@ function unlinkIfSymlink(targetPath: string): void { } function sameResolvedPath(left: string, right: string): boolean { - const candidates = (value: string): string[] => { - const resolved = path.resolve(value); - try { - return [resolved, fs.realpathSync.native(resolved)]; - } catch { - return [resolved]; - } - }; - const rightCandidates = new Set(candidates(right)); - return candidates(left).some((candidate) => rightCandidates.has(candidate)); + 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: { @@ -359,10 +357,10 @@ function clearStaleXcodebuildSimulatorSetLock(lockDirPath: string, ownerFilePath } const owner = readXcodebuildSimulatorSetLockOwner(ownerFilePath); - if (owner && isLiveXcodebuildSimulatorSetLockOwner(owner)) { - return false; - } - if (owner && !isLiveXcodebuildSimulatorSetLockOwner(owner)) { + if (owner) { + if (isLiveXcodebuildSimulatorSetLockOwner(owner)) { + return false; + } fs.rmSync(lockDirPath, { recursive: true, force: true }); return true; }