diff --git a/packages/platform-apple/src/core/__tests__/simctl.test.ts b/packages/platform-apple/src/core/__tests__/simctl.test.ts index 294e1120f4..a7dc9d0643 100644 --- a/packages/platform-apple/src/core/__tests__/simctl.test.ts +++ b/packages/platform-apple/src/core/__tests__/simctl.test.ts @@ -106,6 +106,13 @@ test('simulatorAddressFor carries the set of iOS-family simulators only', () => }); }); +test('simulatorAddressFor names a blank set path as the default set', () => { + assert.deepEqual(simulatorAddressFor({ ...IOS_SIMULATOR, simulatorSetPath: ' ' }), { + udid: 'sim-1', + simulatorSetPath: undefined, + }); +}); + function compileTimeSimulatorScopeProof(): void { // @ts-expect-error A simulator address is minted from its DeviceInfo, never written by hand. const forged: SimulatorAddress = { udid: 'sim-1', simulatorSetPath: undefined }; diff --git a/packages/platform-apple/src/core/runner-host.ts b/packages/platform-apple/src/core/runner-host.ts index c6bdb0828e..cfbaffb42e 100644 --- a/packages/platform-apple/src/core/runner-host.ts +++ b/packages/platform-apple/src/core/runner-host.ts @@ -1,6 +1,5 @@ import type { AppleRunnerHost } from '../runner/index.ts'; import { publishFileSync, acquireProcessLock, withProcessLock } from '@agent-device/host-kit/file'; -import { resolveIosSimulatorDeviceSetPath } from '@agent-device/kernel/device-isolation'; import { isCommandTimeoutError, @@ -42,7 +41,7 @@ import { getRunnerDeviceClaimAuthorityProbe, getRunnerLeaseOwnerStateDir, } from './runner-owner-state.ts'; -import { buildSimctlArgsForDevice } from './simctl.ts'; +import { buildSimctlArgsForDevice, simulatorAddressFor } from './simctl.ts'; import { readApplePlistJson, runAppleToolCommand, runXcrun } from './tool-provider.ts'; /** @@ -86,7 +85,6 @@ export const appleRunnerHost: AppleRunnerHost = { createTtlMemo, parseBooleanLiteral, isRecord, - resolveIosSimulatorDeviceSetPath, emitRequestProgress, getRequestSignal, isRequestCanceled, @@ -96,6 +94,7 @@ export const appleRunnerHost: AppleRunnerHost = { runXcrun, readApplePlistJson, buildSimctlArgsForDevice, + simulatorAddressFor, resolveIosPhysicalDeviceControl, visitXmlPlistEntries, leaseOwnerStateDir: getRunnerLeaseOwnerStateDir, diff --git a/packages/platform-apple/src/core/simctl.ts b/packages/platform-apple/src/core/simctl.ts index 6c156bdd60..96f6df722a 100644 --- a/packages/platform-apple/src/core/simctl.ts +++ b/packages/platform-apple/src/core/simctl.ts @@ -5,14 +5,19 @@ import type { ScopedSimctlArgs } from '@agent-device/contracts/platform-runtime- import { runXcrun, simctlCommand, type ScopedSimctlCommand } from './tool-provider.ts'; declare const simulatorAddress: unique symbol; -/** A simulator udid with the set that holds it; minted only from a DeviceInfo. */ +/** + * A simulator udid with the resolved set that holds it (undefined for the default set); minted only + * from a DeviceInfo. Two devices name the same simulator only when both fields match. + */ export type SimulatorAddress = Readonly<{ udid: string; simulatorSetPath: string | undefined }> & { readonly [simulatorAddress]: true; }; export function simulatorAddressFor(device: DeviceInfo): SimulatorAddress { const simulatorSetPath = - isIosFamily(device) && device.kind === 'simulator' ? device.simulatorSetPath : undefined; + isIosFamily(device) && device.kind === 'simulator' + ? resolveIosSimulatorDeviceSetPath(device.simulatorSetPath) + : undefined; return Object.freeze({ udid: device.id, simulatorSetPath }) as SimulatorAddress; } diff --git a/packages/platform-apple/src/runner-operations-facade.ts b/packages/platform-apple/src/runner-operations-facade.ts index 2b51ab4b59..3cfb64fc2a 100644 --- a/packages/platform-apple/src/runner-operations-facade.ts +++ b/packages/platform-apple/src/runner-operations-facade.ts @@ -17,6 +17,7 @@ export { verifyLeaseRunnerPidIdentity, } from './core/runner-client.ts'; export { queryAppleRunnerSelector } from './core/runner-selector-query.ts'; +export { restoreLegacyXctestDeviceSetRedirect } from './runner/runner-device-set.ts'; export async function cleanupRunnerLeasesForOwner( owner: Parameters<(typeof import('./core/runner-client.ts'))['cleanupRunnerLeasesForOwner']>[0], diff --git a/packages/platform-apple/src/runner/__tests__/apple-toolchain-fixtures.ts b/packages/platform-apple/src/runner/__tests__/apple-toolchain-fixtures.ts index 9c896dcd4d..08ba597f34 100644 --- a/packages/platform-apple/src/runner/__tests__/apple-toolchain-fixtures.ts +++ b/packages/platform-apple/src/runner/__tests__/apple-toolchain-fixtures.ts @@ -2,7 +2,7 @@ import { beforeEach, vi } from 'vitest'; import { appleRunnerTestHost } from '../test-host.ts'; import type { ExecResult } from '@agent-device/host-kit/command'; -const STUBBED_APPLE_TOOLCHAIN = { +export const STUBBED_APPLE_TOOLCHAIN = { xcodeVersion: '26.2', xcodeBuildVersion: '17C52', sdkVersion: '26.2', diff --git a/packages/platform-apple/src/runner/__tests__/runner-adoption-route.test.ts b/packages/platform-apple/src/runner/__tests__/runner-adoption-route.test.ts index 68ccc6b29e..9fc9048709 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-adoption-route.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-adoption-route.test.ts @@ -126,7 +126,7 @@ afterEach(() => { function writeDetachedLease(device: DeviceInfo): void { const lease: RunnerLease = { ...buildRunnerLease({ - deviceId: device.id, + device, sessionId: `${device.id}:${RUNNER_PORT}:1`, runnerPid: RUNNER_PID, port: RUNNER_PORT, diff --git a/packages/platform-apple/src/runner/__tests__/runner-adoption.test.ts b/packages/platform-apple/src/runner/__tests__/runner-adoption.test.ts index a06aedd0c4..8ec0bebf1c 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-adoption.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-adoption.test.ts @@ -109,7 +109,7 @@ let expectedDerived: string; function writeStaleLeaseFor(device: DeviceInfo, overrides: Partial = {}): RunnerLease { const lease: RunnerLease = { ...buildRunnerLease({ - deviceId: device.id, + device, sessionId: `${device.id}:50700:1`, runnerPid: 424242, port: 50700, @@ -165,7 +165,7 @@ test('readStaleRunnerLease returns dead-owner leases and skips owned ones', () = // A lease written by this process is owned, not stale. writeRunnerLease( buildRunnerLease({ - deviceId: simulator.id, + device: simulator, sessionId: `${simulator.id}:50700:2`, runnerPid: 424242, port: 50700, @@ -323,12 +323,38 @@ test('adoption accepts a legacy lease whose live pid is runner-shaped', async () expect(session?.child.pid).toBe(424242); }); -test('adoption is skipped for devices in a custom simulator set', async () => { - writeStaleLease(); +test('a runner for a simulator in a custom simulator set is adopted', async () => { + const scopedDevice = { ...simulator, simulatorSetPath: '/custom/device-set' }; + writeStaleLeaseFor(scopedDevice); mockIsProcessAlive.mockReturnValue(true); + mockSendRunnerCommandOnce.mockResolvedValue(new Response(JSON.stringify({ ok: true }))); - const scopedDevice = { ...simulator, simulatorSetPath: '/custom/device-set' }; - expect(await tryAdoptRunnerSessionFromLease(scopedDevice, {})).toBeNull(); + const session = await tryAdoptRunnerSessionFromLease(scopedDevice, {}); + + expect(session?.state).toBe('ready'); + expect(session?.device.simulatorSetPath).toBe('/custom/device-set'); + const restamped = JSON.parse( + fs.readFileSync(path.join(leaseDir, `${scopedDevice.id}.json`), 'utf8'), + ) as RunnerLease; + expect(restamped.simulatorSetPath).toBe('/custom/device-set'); +}); + +test('a runner leased for the same udid in another simulator set is never adopted', async () => { + mockIsProcessAlive.mockReturnValue(true); + mockSendRunnerCommandOnce.mockResolvedValue(new Response(JSON.stringify({ ok: true }))); + const tenantA = { ...simulator, simulatorSetPath: '/custom/tenant-a' }; + const tenantB = { ...simulator, simulatorSetPath: '/custom/tenant-b' }; + + for (const [leasedFor, requested] of [ + [tenantA, tenantB], + [tenantA, simulator], + [simulator, tenantA], + ] as const) { + writeStaleLeaseFor(leasedFor); + + expect(await tryAdoptRunnerSessionFromLease(requested, {})).toBeNull(); + expect(adoptionRefusalReason()).toBe('simulator_set_mismatch'); + } expect(mockSendRunnerCommandOnce).not.toHaveBeenCalled(); }); diff --git a/packages/platform-apple/src/runner/__tests__/runner-close-finalization.test.ts b/packages/platform-apple/src/runner/__tests__/runner-close-finalization.test.ts index e4a9f6d2f4..545a8f78f4 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-close-finalization.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-close-finalization.test.ts @@ -14,7 +14,6 @@ import { makeRunnerLease, runnerError, runnerResponse, - redirectHandle, } from './runner-session-fixtures.ts'; import { mkdtempForTestSync } from './tmp-dir.ts'; import { bindAppleApplicationLifecycle } from '../../lifecycle.ts'; @@ -39,7 +38,6 @@ import { // capabilities behind the package's runner host port (`scripts/vitest-apple-runner-host-setup.ts`). const { - mockAcquireXcodebuildSimulatorSetRedirect, mockCleanupTempFile, mockEnsureXctestrunArtifact, mockGetFreePort, @@ -58,7 +56,6 @@ const { mockSignalProcessGroupBestEffort, mockWaitForRunner, } = vi.hoisted(() => ({ - mockAcquireXcodebuildSimulatorSetRedirect: vi.fn(), mockCleanupTempFile: vi.fn(), mockEnsureXctestrunArtifact: vi.fn(), mockGetFreePort: vi.fn(), @@ -113,7 +110,6 @@ vi.mock('../runner-xctestrun.ts', async () => { await vi.importActual('../runner-xctestrun.ts'); return { ...actual, - acquireXcodebuildSimulatorSetRedirect: mockAcquireXcodebuildSimulatorSetRedirect, ensureXctestrunArtifact: mockEnsureXctestrunArtifact, prepareXctestrunWithEnv: mockPrepareXctestrunWithEnv, resolveExpectedRunnerCacheMetadata: mockResolveExpectedRunnerCacheMetadata, @@ -168,7 +164,6 @@ beforeEach(async () => { }); mockResolveExpectedRunnerCacheMetadata.mockReturnValue({ schemaVersion: 1 }); mockResolveRunnerDerivedPath.mockReturnValue('/tmp/derived'); - mockAcquireXcodebuildSimulatorSetRedirect.mockResolvedValue(redirectHandle); mockRunCmdBackground.mockReturnValue(makeBackgroundRunner(4242)); mockRunAppleToolCommand.mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' }); mockIsProcessAlive.mockReturnValue(true); diff --git a/packages/platform-apple/src/runner/__tests__/runner-device-set-give-back.test.ts b/packages/platform-apple/src/runner/__tests__/runner-device-set-give-back.test.ts deleted file mode 100644 index c403e80055..0000000000 --- a/packages/platform-apple/src/runner/__tests__/runner-device-set-give-back.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import path from 'node:path'; -import { afterEach, test, vi } from 'vitest'; -import { AppError } from '@agent-device/kernel/errors'; -import type { DeviceInfo } from '@agent-device/kernel/device'; -import { mkdtempForTestSync } from './tmp-dir.ts'; - -// Which failure a redirect hands the caller is decided once, in the give-back. These tests reach the -// paths that have no handle to give back — the simulator that needs no redirect, and the redirect -// that never got installed — by answering the lock at the seam the module already acquires it -// through, which is the only window where a release can be made to fail from inside an acquire. -const { lockSeam } = vi.hoisted(() => ({ - lockSeam: { - override: null as null | (() => Promise<() => Promise>), - }, -})); - -vi.mock('../host.ts', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - acquireProcessLock: async (params: Parameters[0]) => - lockSeam.override ? await lockSeam.override() : await actual.acquireProcessLock(params), - }; -}); - -import { acquireXcodebuildSimulatorSetRedirect } from '../runner-device-set.ts'; - -function unverifiedRelease(): () => Promise { - return async () => { - throw new AppError('COMMAND_FAILED', 'Could not verify ownership of XCTest device set lock', { - ownerReleaseUnverified: true, - }); - }; -} - -afterEach(() => { - lockSeam.override = null; -}); - -test('a simulator already on the host device set is not failed by a lock it could not verify', async () => { - const root = mkdtempForTestSync('device-set-no-redirect-'); - try { - const xctestDeviceSetPath = path.join(root, 'Library', 'Developer', 'XCTestDevices'); - fs.mkdirSync(xctestDeviceSetPath, { recursive: true }); - // This simulator's set and the host's `XCTestDevices` are the same directory, so nothing is - // redirected and the caller is told so with a null handle. Before the give-back rule, the - // release inside the try fell into the catch, which reconciled, released again, and raised - // "Failed to redirect XCTest device set path" for a redirect that was never needed. - lockSeam.override = async () => unverifiedRelease(); - const device: DeviceInfo = { - platform: 'apple', - id: 'sim-host-set', - name: 'iPhone Simulator', - kind: 'simulator', - appleOs: 'ios', - booted: true, - simulatorSetPath: xctestDeviceSetPath, - }; - - const redirect = await acquireXcodebuildSimulatorSetRedirect(device, { - lockDirPath: path.join(root, '.agent-device', 'xctest-device-set.lock'), - xctestDeviceSetPath, - }); - - assert.equal(redirect, null); - } finally { - fs.rmSync(root, { recursive: true, force: true }); - } -}); diff --git a/packages/platform-apple/src/runner/__tests__/runner-device-set.test.ts b/packages/platform-apple/src/runner/__tests__/runner-device-set.test.ts index f2fcba7f86..8c8e8efabe 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-device-set.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-device-set.test.ts @@ -1,765 +1,210 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; -import { test, vi } from 'vitest'; -import { AppError } from '@agent-device/kernel/errors'; -import type { DeviceInfo } from '@agent-device/kernel/device'; +import { afterEach, beforeEach, test, vi } from 'vitest'; +import { resetAllProcessMemosForTests } from '@agent-device/kernel/ttl-memo'; +import { IOS_DEVICE, IOS_SIMULATOR, TVOS_SIMULATOR } from './device-fixtures.ts'; import { mkdtempForTestSync } from './tmp-dir.ts'; +import { appleRunnerTestHost } from '../test-host.ts'; +import { STUBBED_APPLE_TOOLCHAIN, stubAppleToolchainProbes } from './apple-toolchain-fixtures.ts'; import { - acquireXcodebuildSimulatorSetRedirect, - resolveXcodebuildSimulatorDeviceSetPath, - withXcodebuildSimulatorSetRedirect, + isSameRunnerSimulator, + restoreLegacyXctestDeviceSetRedirect, + runnerSimulatorSetFailureDetails, + xcodebuildDestinationArgs, } from '../runner-device-set.ts'; -// A runner build runs under the XCTest device-set redirect, which is a lock like any other, so -// the two failures it can report have an order: the build that failed outranks a redirect it could -// not hand back, and a build that succeeded does not get to hide one. - -const iosSimulator: DeviceInfo = { - platform: 'apple', - id: 'sim-1', - name: 'iPhone Simulator', - kind: 'simulator', - appleOs: 'ios', - booted: true, -}; - -type RedirectPaths = { - requestedSetPath: string; - xctestDeviceSetPath: string; - backupPath: string; - lockDirPath: string; -}; +const toolchainProbe = stubAppleToolchainProbes(); +beforeEach(resetAllProcessMemosForTests); +afterEach(() => { + vi.restoreAllMocks(); +}); -function makeRedirectPaths(root: string): RedirectPaths { - return { - requestedSetPath: path.join(root, 'requested'), - xctestDeviceSetPath: path.join(root, 'Library', 'Developer', 'XCTestDevices'), - backupPath: path.join(root, 'Library', 'Developer', 'XCTestDevices.set-aside'), - lockDirPath: path.join(root, '.agent-device', 'xctest-device-set.lock'), - }; -} +const DESTINATION = 'platform=iOS Simulator,id=sim-1'; -function redirectOptions(paths: RedirectPaths) { - return { - lockDirPath: paths.lockDirPath, - xctestDeviceSetPath: paths.xctestDeviceSetPath, - }; -} +test('a scoped-set simulator names its set to xcodebuild beside the destination', () => { + const device = { ...IOS_SIMULATOR, simulatorSetPath: '/tmp/tenant-a/simulators' }; -function makeScopedSimulator(paths: RedirectPaths): DeviceInfo { - return { ...iosSimulator, simulatorSetPath: paths.requestedSetPath }; -} + assert.deepEqual(xcodebuildDestinationArgs(device, DESTINATION), [ + '-destination', + DESTINATION, + '-DVTSimulatorSetLocation=/tmp/tenant-a/simulators', + ]); +}); -async function acquireRedirect( - paths: RedirectPaths, - options: Partial[1]> = {}, -): ReturnType { - return await acquireXcodebuildSimulatorSetRedirect(makeScopedSimulator(paths), { - ...redirectOptions(paths), - ...options, - }); -} +test('every Apple simulator family in a scoped set names its set', () => { + const device = { ...TVOS_SIMULATOR, simulatorSetPath: '/tmp/tenant-a/simulators' }; -function assertRedirectTargetsRequestedSet(paths: RedirectPaths): void { - assert.equal( - fs.realpathSync.native(paths.xctestDeviceSetPath), - fs.realpathSync.native(paths.requestedSetPath), + assert.ok( + xcodebuildDestinationArgs(device, DESTINATION).includes( + '-DVTSimulatorSetLocation=/tmp/tenant-a/simulators', + ), ); -} - -/** The lock is standing and its record cannot be read, which is what no release can forgive. */ -function makeReleaseUnverifiable(paths: RedirectPaths): void { - const ownerFilePath = path.join(paths.lockDirPath, 'owner.json'); - fs.rmSync(ownerFilePath); - fs.mkdirSync(ownerFilePath); -} +}); -async function withTempDir(prefix: string, task: (root: string) => Promise): Promise { - const root = mkdtempForTestSync(prefix); - try { - return await task(root); - } finally { - fs.rmSync(root, { recursive: true, force: true }); +test('the default set, a blank set path and a physical device leave the destination alone', () => { + for (const device of [ + IOS_SIMULATOR, + { ...IOS_SIMULATOR, simulatorSetPath: ' ' }, + { ...IOS_DEVICE, simulatorSetPath: '/tmp/tenant-a/simulators' }, + ]) { + assert.deepEqual(xcodebuildDestinationArgs(device, DESTINATION), ['-destination', DESTINATION]); } -} - -test('a build that failed outranks the redirect it could not give back', async () => { - await withTempDir('device-set-build-error-', async (root) => { - const paths = makeRedirectPaths(root); - fs.mkdirSync(paths.requestedSetPath, { recursive: true }); - const buildFailure = new AppError('COMMAND_FAILED', 'xcodebuild build-for-testing failed', { - hint: 'See the runner log.', - }); - - await assert.rejects( - () => - withXcodebuildSimulatorSetRedirect( - makeScopedSimulator(paths), - async () => { - makeReleaseUnverifiable(paths); - throw buildFailure; - }, - redirectOptions(paths), - ), - (error: unknown) => { - assert.equal(error, buildFailure); - return true; - }, - ); - assert.equal(fs.existsSync(paths.lockDirPath), true); - }); }); -test('a build that succeeded still reports the redirect it could not give back', async () => { - await withTempDir('device-set-release-error-', async (root) => { - const paths = makeRedirectPaths(root); - fs.mkdirSync(paths.requestedSetPath, { recursive: true }); - - await assert.rejects( - () => - withXcodebuildSimulatorSetRedirect( - makeScopedSimulator(paths), - async () => { - makeReleaseUnverifiable(paths); - return 'built'; - }, - redirectOptions(paths), - ), - (error: unknown) => { - assert.ok(error instanceof AppError); - assert.equal(error.details?.ownerReleaseUnverified, true); - return true; - }, - ); - }); -}); +test('a failure reports the scoped set and the selected Xcode only for a scoped-set simulator', () => { + const scoped = { ...IOS_SIMULATOR, simulatorSetPath: '/tmp/tenant-a/simulators' }; -test('a redirect handed back after its task keeps quiet about a release it cannot verify', async () => { - await withTempDir('device-set-teardown-', async (root) => { - const paths = makeRedirectPaths(root); - fs.mkdirSync(paths.requestedSetPath, { recursive: true }); - const redirect = await acquireXcodebuildSimulatorSetRedirect(makeScopedSimulator(paths), { - lockDirPath: paths.lockDirPath, - xctestDeviceSetPath: paths.xctestDeviceSetPath, - }); - assert.notEqual(redirect, null); - makeReleaseUnverifiable(paths); - - await redirect?.releaseBestEffort(); - assert.equal(fs.existsSync(paths.lockDirPath), true); + assert.deepEqual(runnerSimulatorSetFailureDetails(scoped), { + simulatorSetPath: '/tmp/tenant-a/simulators', + xcodeVersion: STUBBED_APPLE_TOOLCHAIN.xcodeVersion, }); + assert.deepEqual(runnerSimulatorSetFailureDetails(IOS_SIMULATOR), {}); }); -test('a redirect that could not restore the host device set reports it instead of swallowing it', async () => { - await withTempDir('device-set-restore-failure-', async (root) => { - const paths = makeRedirectPaths(root); - fs.mkdirSync(paths.requestedSetPath, { recursive: true }); - // The host has a device set of its own, so giving the redirect back renames it out of the - // backup. Without this the release has nothing to restore and no rename to attempt. - fs.mkdirSync(paths.xctestDeviceSetPath, { recursive: true }); - fs.writeFileSync(path.join(paths.xctestDeviceSetPath, 'host-device.txt'), 'the host owns this'); - const redirect = await acquireXcodebuildSimulatorSetRedirect(makeScopedSimulator(paths), { - lockDirPath: paths.lockDirPath, - xctestDeviceSetPath: paths.xctestDeviceSetPath, - }); - assert.notEqual(redirect, null); - - // The restore of the host's own `XCTestDevices` is a rename back from the backup, and a - // refusal there is a fact about this machine that no caller may lose. - let attempted = false; - const realRename = fs.renameSync; - const renameSpy = vi.spyOn(fs, 'renameSync').mockImplementation((( - from: fs.PathLike, - to: fs.PathLike, - ) => { - if (String(to) === paths.xctestDeviceSetPath && !attempted) { - attempted = true; - throw Object.assign(new Error('EACCES: permission denied'), { code: 'EACCES' }); - } - return realRename(from as string, to as string); - }) as typeof fs.renameSync); - - try { - await assert.rejects( - () => redirect!.releaseBestEffort(), - (error: unknown) => (error as NodeJS.ErrnoException).code === 'EACCES', - ); - assert.equal(attempted, true); - } finally { - renameSpy.mockRestore(); - } - }); -}); +test('a failure whose Xcode cannot be read still names the scoped set, and no Xcode key', () => { + toolchainProbe.mockReturnValue({ exitCode: 1, stdout: '', stderr: 'xcode-select: error' }); -test('a restore that was refused outranks the lock that could not be verified', async () => { - // Both steps lean on the same filesystem, so they fail together. The `finally` that handed the lock - // back used to replace the restore's EACCES with `ownerReleaseUnverified`, which the best-effort - // door then dropped and the strict door then reported in its place: the host kept an - // `XCTestDevices` pointing at this simulator's set, and the only report named the lock. - for (const door of ['release', 'releaseBestEffort'] as const) { - await withTempDir(`device-set-both-fail-${door}-`, async (root) => { - const paths = makeRedirectPaths(root); - fs.mkdirSync(paths.requestedSetPath, { recursive: true }); - fs.mkdirSync(paths.xctestDeviceSetPath, { recursive: true }); - fs.writeFileSync( - path.join(paths.xctestDeviceSetPath, 'host-device.txt'), - 'the host owns this', - ); - const redirect = await acquireXcodebuildSimulatorSetRedirect(makeScopedSimulator(paths), { - lockDirPath: paths.lockDirPath, - xctestDeviceSetPath: paths.xctestDeviceSetPath, - }); - assert.notEqual(redirect, null); - makeReleaseUnverifiable(paths); - - let attempted = false; - const realRename = fs.renameSync; - const renameSpy = vi.spyOn(fs, 'renameSync').mockImplementation((( - from: fs.PathLike, - to: fs.PathLike, - ) => { - if (String(to) === paths.xctestDeviceSetPath && !attempted) { - attempted = true; - throw Object.assign(new Error('EACCES: permission denied'), { code: 'EACCES' }); - } - return realRename(from, to); - }) as typeof fs.renameSync); - - try { - await assert.rejects( - () => redirect![door](), - (error: unknown) => (error as NodeJS.ErrnoException).code === 'EACCES', - door, - ); - assert.equal(attempted, true, door); - // The lock went back into the same refusing filesystem, which is what makes this the pair. - assert.equal(fs.existsSync(paths.lockDirPath), true, `${door}: the release failed too`); - } finally { - renameSpy.mockRestore(); - } - }); - } + assert.deepEqual( + runnerSimulatorSetFailureDetails({ ...IOS_SIMULATOR, simulatorSetPath: '/tmp/tenant-a/sims' }), + { simulatorSetPath: '/tmp/tenant-a/sims' }, + ); }); -test('an interrupted build that left the symlink is redirected again, not read as already done', async () => { - await withTempDir('device-set-leftover-', async (root) => { - const paths = makeRedirectPaths(root); - fs.mkdirSync(paths.requestedSetPath, { recursive: true }); - // What an interrupted build leaves: the host's set renamed aside, and `XCTestDevices` a symlink - // pointing into this simulator's requested set. Following that symlink makes the two paths look - // identical, and deciding from that would hand the symlink back and let the next `xcodebuild` run - // against the host's own devices. - fs.mkdirSync(paths.backupPath, { recursive: true }); - fs.writeFileSync(path.join(paths.backupPath, 'host-device.txt'), 'the host owns this'); - fs.symlinkSync(paths.requestedSetPath, paths.xctestDeviceSetPath, 'dir'); - - const handle = await acquireXcodebuildSimulatorSetRedirect(makeScopedSimulator(paths), { - ...redirectOptions(paths), - backupPath: paths.backupPath, - }); - - try { - assert.ok(handle, 'this simulator needs its own redirect, leftovers and all'); - assert.equal( - fs.realpathSync.native(paths.xctestDeviceSetPath), - fs.realpathSync.native(paths.requestedSetPath), - ); - // The leftover backup was put back where it belongs and then renamed aside by this run, so the - // host's device set is whole exactly once. - assert.equal( - fs.readFileSync(path.join(paths.backupPath, 'host-device.txt'), 'utf8'), - 'the host owns this', - ); - - await handle.release(); - assert.equal( - fs.readFileSync(path.join(paths.xctestDeviceSetPath, 'host-device.txt'), 'utf8'), - 'the host owns this', - ); - } finally { - await handle?.releaseBestEffort(); - } - }); -}); +test('one udid in two simulator sets names two simulators', () => { + const tenantA = { ...IOS_SIMULATOR, simulatorSetPath: '/tmp/tenant-a/simulators' }; -test('a restore that only works on the second look still ends the acquire', async () => { - await withTempDir('device-set-restore-retried-', async (root) => { - const paths = makeRedirectPaths(root); - fs.mkdirSync(paths.requestedSetPath, { recursive: true }); - fs.mkdirSync(paths.backupPath, { recursive: true }); - fs.writeFileSync(path.join(paths.backupPath, 'host-device.txt'), 'the host owns this'); - fs.symlinkSync(paths.requestedSetPath, paths.xctestDeviceSetPath, 'dir'); - - // A first rename that fails and a retry that would succeed. What the acquire must not do is decide - // from the state the failed restore left and then move the device set around without the lock. - let restoreAttempts = 0; - const realRename = fs.renameSync; - const renameSpy = vi.spyOn(fs, 'renameSync').mockImplementation(((from, to) => { - if (String(to) === paths.xctestDeviceSetPath) { - restoreAttempts += 1; - if (restoreAttempts === 1) { - throw Object.assign(new Error('EACCES: permission denied'), { code: 'EACCES' }); - } - } - return realRename(from, to); - }) as typeof fs.renameSync); - - try { - await assert.rejects( - () => - acquireXcodebuildSimulatorSetRedirect(makeScopedSimulator(paths), { - ...redirectOptions(paths), - backupPath: paths.backupPath, - }), - (error: unknown) => { - assert.ok(error instanceof AppError); - assert.equal(error.message, 'Failed to redirect XCTest device set path'); - assert.match(String(error.details?.error), /EACCES/); - return true; - }, - ); - assert.equal( - fs.existsSync(paths.lockDirPath), - false, - 'the lock must not outlive the failure', - ); - // The retry put the host's set back, and nothing renamed it aside again on the way out. - assert.equal( - fs.readFileSync(path.join(paths.xctestDeviceSetPath, 'host-device.txt'), 'utf8'), - 'the host owns this', - ); - assert.equal(fs.existsSync(paths.backupPath), false); - } finally { - renameSpy.mockRestore(); - } - }); + assert.equal(isSameRunnerSimulator(tenantA, { ...tenantA }), true); + assert.equal( + isSameRunnerSimulator(IOS_SIMULATOR, { ...IOS_SIMULATOR, simulatorSetPath: ' ' }), + true, + ); + assert.equal(isSameRunnerSimulator(tenantA, IOS_SIMULATOR), false); + assert.equal( + isSameRunnerSimulator(tenantA, { ...tenantA, simulatorSetPath: '/tmp/tenant-b/simulators' }), + false, + ); }); -test('a restore that was refused on the way in is not reported as a simulator that needs no redirect', async () => { - await withTempDir('device-set-leftover-restore-failed-', async (root) => { - const paths = makeRedirectPaths(root); - fs.mkdirSync(paths.requestedSetPath, { recursive: true }); - fs.mkdirSync(paths.backupPath, { recursive: true }); - fs.writeFileSync(path.join(paths.backupPath, 'host-device.txt'), 'the host owns this'); - fs.symlinkSync(paths.requestedSetPath, paths.xctestDeviceSetPath, 'dir'); - - // The same leftovers, with the rename that would put the host's set back refusing. Both paths still - // resolve to the same directory, so this is the moment where "nothing to do" and "the host has no - // device set" look identical from here. - const realRename = fs.renameSync; - const renameSpy = vi.spyOn(fs, 'renameSync').mockImplementation(((from, to) => { - if (String(to) === paths.xctestDeviceSetPath) { - throw Object.assign(new Error('EACCES: permission denied'), { code: 'EACCES' }); - } - return realRename(from, to); - }) as typeof fs.renameSync); - - try { - await assert.rejects( - () => - acquireXcodebuildSimulatorSetRedirect(makeScopedSimulator(paths), { - ...redirectOptions(paths), - backupPath: paths.backupPath, - }), - (error: unknown) => { - assert.ok(error instanceof AppError); - assert.equal(error.message, 'Failed to redirect XCTest device set path'); - assert.match(String(error.details?.error), /EACCES/); - assert.match(String(error.details?.restoreError), /EACCES/); - assert.ok(String(error.details?.hint).includes(paths.backupPath)); - return true; - }, - ); - // The host's set is still renamed aside, which is the fact the caller needs, and the lock went - // back anyway so the next acquire does not wait on this one. - assert.equal(fs.existsSync(paths.backupPath), true); - assert.equal(fs.existsSync(paths.lockDirPath), false); - } finally { - renameSpy.mockRestore(); - } - }); -}); +type LegacyRedirect = { + xctestDeviceSetPath: string; + backupPath: string; + scopedSetPath: string; +}; -test('the host’s device set is back before the lock is', async () => { - await withTempDir('device-set-hand-back-order-', async (root) => { - const paths = makeRedirectPaths(root); - fs.mkdirSync(paths.requestedSetPath, { recursive: true }); - fs.mkdirSync(paths.xctestDeviceSetPath, { recursive: true }); - - // The lock is what lets another runner build read the host's `XCTestDevices`, so the order the - // give-back works in is the contract: releasing first would hand out a directory that is still a - // symlink into this simulator's set. The events record when each step actually happened. - const events: string[] = []; - const realRename = fs.renameSync; - const renameSpy = vi.spyOn(fs, 'renameSync').mockImplementation(((from, to) => { - const source = String(from); - const target = String(to); - if (target === paths.backupPath) events.push('renamed-aside'); - else if (source === paths.backupPath) events.push('restored'); - else if (target === paths.xctestDeviceSetPath) events.push('symlink-installed'); - return realRename(from, to); - }) as typeof fs.renameSync); - const realRemoveDir = fs.rmdirSync; - const removeDirSpy = vi.spyOn(fs, 'rmdirSync').mockImplementation(((target, ...rest) => { - if (String(target) === paths.lockDirPath) events.push('lock-released'); - return realRemoveDir(target, ...rest); - }) as typeof fs.rmdirSync); - - try { - const handle = await acquireXcodebuildSimulatorSetRedirect(makeScopedSimulator(paths), { - ...redirectOptions(paths), - backupPath: paths.backupPath, - }); - assert.ok(handle); - await handle.release(); - - assert.deepEqual(events, ['renamed-aside', 'symlink-installed', 'restored', 'lock-released']); - } finally { - renameSpy.mockRestore(); - removeDirSpy.mockRestore(); - } - }); -}); +function makeLegacyPaths(): LegacyRedirect { + const root = mkdtempForTestSync('agent-device-legacy-xctest-set-'); + const developer = path.join(root, 'Library', 'Developer'); + fs.mkdirSync(developer, { recursive: true }); + const scopedSetPath = path.join(root, 'tenant-set'); + fs.mkdirSync(path.join(scopedSetPath, 'SCOPED-UDID'), { recursive: true }); + return { + xctestDeviceSetPath: path.join(developer, 'XCTestDevices'), + backupPath: path.join(developer, 'XCTestDevices.agent-device-backup'), + scopedSetPath, + }; +} -test('a restore that half-finished names the backup it was refused, not an older leftover', async () => { - await withTempDir('device-set-half-restored-legacy-', async (root) => { - const paths = makeRedirectPaths(root); - // The default backup path, so the older version's leftover prefix is the one a real host sees. - const backupPath = `${paths.xctestDeviceSetPath}.agent-device-backup`; - const legacyBackupPath = path.join( - path.dirname(backupPath), - '.agent-device-xctestdevices-backup-1600000000000', - ); - fs.mkdirSync(paths.requestedSetPath, { recursive: true }); - fs.mkdirSync(backupPath, { recursive: true }); - fs.writeFileSync(path.join(backupPath, 'host-device.txt'), 'the host owns this'); - fs.mkdirSync(legacyBackupPath, { recursive: true }); - fs.writeFileSync(path.join(legacyBackupPath, 'stale-device.txt'), 'an older interruption'); - fs.symlinkSync(paths.requestedSetPath, paths.xctestDeviceSetPath, 'dir'); - - // An interrupted build left the symlink, this run's backup holds the host's set, and an older - // version's leftover sits beside it. The first restore is refused after it took the symlink down, so - // the hand-back is the one that puts the host's set back — and it is refused while deleting the - // leftover. The report must name what is still renamed aside, which by then is nothing: sending the - // reader to the older leftover would have them copy a stale set over the one now in place. - const realRename = fs.renameSync; - let restoreAttempts = 0; - const renameSpy = vi.spyOn(fs, 'renameSync').mockImplementation(((from, to) => { - if (String(to) === paths.xctestDeviceSetPath) { - restoreAttempts += 1; - if (restoreAttempts === 1) { - throw Object.assign(new Error('EACCES: permission denied'), { code: 'EACCES' }); - } - } - return realRename(from, to); - }) as typeof fs.renameSync); - const realRm = fs.rmSync; - const rmSpy = vi.spyOn(fs, 'rmSync').mockImplementation(((target, options) => { - if (String(target) === legacyBackupPath) { - throw Object.assign(new Error('EPERM: operation not permitted'), { code: 'EPERM' }); - } - return realRm(target as Parameters[0], options); - }) as typeof fs.rmSync); - - try { - await assert.rejects( - () => - acquireXcodebuildSimulatorSetRedirect(makeScopedSimulator(paths), redirectOptions(paths)), - (error: unknown) => { - assert.ok(error instanceof AppError); - assert.equal(error.message, 'Failed to redirect XCTest device set path'); - assert.match(String(error.details?.error), /EACCES/); - assert.match(String(error.details?.restoreError), /EPERM/); - assert.equal(error.details?.hint, undefined); - return true; - }, - ); - assert.equal(fs.existsSync(legacyBackupPath), true, 'the leftover stays where it is'); - assert.equal( - fs.readFileSync(path.join(paths.xctestDeviceSetPath, 'host-device.txt'), 'utf8'), - 'the host owns this', - ); - assert.equal( - fs.existsSync(paths.lockDirPath), - false, - 'the lock must not outlive the failure', - ); - } finally { - rmSpy.mockRestore(); - renameSpy.mockRestore(); - } - }); +test('an older redirect is undone: the symlink goes and the host set comes back', () => { + const paths = makeLegacyPaths(); + fs.mkdirSync(path.join(paths.backupPath, 'HOST-UDID'), { recursive: true }); + fs.symlinkSync(paths.scopedSetPath, paths.xctestDeviceSetPath, 'dir'); + const phases = recordDiagnosticPhases(); + + restoreLegacyXctestDeviceSetRedirect(paths.xctestDeviceSetPath); + + assert.equal(fs.lstatSync(paths.xctestDeviceSetPath).isSymbolicLink(), false); + assert.ok(fs.existsSync(path.join(paths.xctestDeviceSetPath, 'HOST-UDID'))); + assert.equal(fs.existsSync(paths.backupPath), false); + // Unlinking never follows the link: the scoped set it pointed at keeps its devices. + assert.ok(fs.existsSync(path.join(paths.scopedSetPath, 'SCOPED-UDID'))); + assert.deepEqual(phases, [ + 'ios_runner_legacy_xctest_device_set_link_removed', + 'ios_runner_legacy_xctest_device_set_backup_restored', + ]); +}); + +test('a symlink an older redirect left without a backup is removed, whatever it points at', () => { + for (const target of ['scoped', 'external'] as const) { + const paths = makeLegacyPaths(); + const linkTarget = + target === 'scoped' + ? paths.scopedSetPath + : path.join(path.dirname(paths.scopedSetPath), 'external-volume'); + fs.mkdirSync(linkTarget, { recursive: true }); + fs.symlinkSync(linkTarget, paths.xctestDeviceSetPath, 'dir'); + + restoreLegacyXctestDeviceSetRedirect(paths.xctestDeviceSetPath); + + assert.equal(fs.lstatSync(paths.xctestDeviceSetPath, { throwIfNoEntry: false }), undefined); + assert.ok(fs.existsSync(linkTarget), target); + } }); -test('a redirect that failed before it moved anything names no backup that is not there', async () => { - await withTempDir('device-set-failed-before-rename-', async (root) => { - const paths = makeRedirectPaths(root); - fs.mkdirSync(paths.requestedSetPath, { recursive: true }); - fs.mkdirSync(paths.xctestDeviceSetPath, { recursive: true }); - fs.writeFileSync(path.join(paths.xctestDeviceSetPath, 'host-device.txt'), 'the host owns this'); - - // The install dies on the rename that was meant to move the host's set aside, so nothing ever left - // its place. A report that still said "still renamed aside at " would send the reader to a - // path that does not exist. - const realRename = fs.renameSync; - const renameSpy = vi.spyOn(fs, 'renameSync').mockImplementation(((from, to) => { - if (String(to) === paths.backupPath) { - throw Object.assign(new Error('EACCES: permission denied'), { code: 'EACCES' }); - } - return realRename(from, to); - }) as typeof fs.renameSync); - - try { - await assert.rejects( - () => - acquireXcodebuildSimulatorSetRedirect(makeScopedSimulator(paths), { - ...redirectOptions(paths), - backupPath: paths.backupPath, - }), - (error: unknown) => { - assert.ok(error instanceof AppError); - assert.equal(error.message, 'Failed to redirect XCTest device set path'); - assert.match(String(error.details?.error), /EACCES/); - assert.equal(error.details?.restoreError, undefined); - assert.equal(error.details?.hint, undefined); - return true; - }, - ); - assert.equal( - fs.readFileSync(path.join(paths.xctestDeviceSetPath, 'host-device.txt'), 'utf8'), - 'the host owns this', - ); - } finally { - renameSpy.mockRestore(); - } - }); -}); +test('a backup never replaces a host set that is already in place', () => { + const paths = makeLegacyPaths(); + fs.mkdirSync(path.join(paths.xctestDeviceSetPath, 'CURRENT-UDID'), { recursive: true }); + fs.mkdirSync(path.join(paths.backupPath, 'OLD-UDID'), { recursive: true }); -test('a redirect that could not be installed reports the redirect, not the failed clean-up', async () => { - await withTempDir('device-set-install-failure-', async (root) => { - const paths = makeRedirectPaths(root); - fs.mkdirSync(paths.requestedSetPath, { recursive: true }); - fs.mkdirSync(paths.xctestDeviceSetPath, { recursive: true }); - fs.writeFileSync(path.join(paths.xctestDeviceSetPath, 'host-device.txt'), 'the host owns this'); - - // The install breaks after the host's set is renamed into the backup, and the restore that the - // catch runs to undo it breaks too. Two failures, one report: the redirect that did not happen, - // carrying the restore that could not run and naming where the host's device set is waiting. - let restoreAttempted = false; - const realRename = fs.renameSync; - const renameSpy = vi.spyOn(fs, 'renameSync').mockImplementation((( - from: fs.PathLike, - to: fs.PathLike, - ) => { - if (String(to) === paths.xctestDeviceSetPath) { - restoreAttempted = true; - throw Object.assign(new Error('EACCES: permission denied'), { code: 'EACCES' }); - } - return realRename(from, to); - }) as typeof fs.renameSync); - const symlinkSpy = vi.spyOn(fs, 'symlinkSync').mockImplementation((() => { - throw Object.assign(new Error('EPERM: operation not permitted'), { code: 'EPERM' }); - }) as typeof fs.symlinkSync); - - try { - await assert.rejects( - () => - acquireXcodebuildSimulatorSetRedirect(makeScopedSimulator(paths), { - lockDirPath: paths.lockDirPath, - xctestDeviceSetPath: paths.xctestDeviceSetPath, - backupPath: paths.backupPath, - }), - (error: unknown) => { - assert.ok(error instanceof AppError); - assert.equal(error.message, 'Failed to redirect XCTest device set path'); - assert.match(String(error.details?.error), /EPERM/); - assert.match(String(error.details?.restoreError), /EACCES/); - assert.ok( - String(error.details?.hint).includes(paths.backupPath), - `the hint must name where the host's device set is: ${String(error.details?.hint)}`, - ); - return true; - }, - ); - assert.equal(restoreAttempted, true); - // The clean-up did not run to completion, and the lock went back regardless: the caller is - // free to try again rather than wait 30 s on a claim nobody holds. - assert.equal(fs.existsSync(paths.lockDirPath), false); - } finally { - renameSpy.mockRestore(); - symlinkSpy.mockRestore(); - } - }); -}); + restoreLegacyXctestDeviceSetRedirect(paths.xctestDeviceSetPath); -test('resolveXcodebuildSimulatorDeviceSetPath uses XCTestDevices under the user home', () => { - assert.equal( - resolveXcodebuildSimulatorDeviceSetPath('/tmp/agent-device-home'), - '/tmp/agent-device-home/Library/Developer/XCTestDevices', - ); + assert.ok(fs.existsSync(path.join(paths.xctestDeviceSetPath, 'CURRENT-UDID'))); + assert.ok(fs.existsSync(path.join(paths.backupPath, 'OLD-UDID'))); }); -test('acquireXcodebuildSimulatorSetRedirect swaps XCTestDevices to the requested simulator set', async () => { - let handle: Awaited> | null = null; - await withTempDir('device-set-redirect-', async (root) => { - const paths = makeRedirectPaths(root); - const originalMarkerPath = path.join(root, 'original-marker.txt'); - fs.mkdirSync(paths.requestedSetPath, { recursive: true }); - fs.mkdirSync(paths.xctestDeviceSetPath, { recursive: true }); - fs.writeFileSync( - path.join(paths.xctestDeviceSetPath, 'original.txt'), - originalMarkerPath, - 'utf8', - ); - - handle = await acquireRedirect(paths); - - assert.notEqual(handle, null); - assert.equal(fs.lstatSync(paths.xctestDeviceSetPath).isSymbolicLink(), true); - assertRedirectTargetsRequestedSet(paths); - - await handle?.release(); - handle = null; - - assert.equal(fs.lstatSync(paths.xctestDeviceSetPath).isDirectory(), true); - assert.equal( - fs.readFileSync(path.join(paths.xctestDeviceSetPath, 'original.txt'), 'utf8'), - originalMarkerPath, - ); - }).finally(async () => { - await handle?.release(); - }); -}); +test('a host with no leftovers is left untouched and reports nothing', () => { + const paths = makeLegacyPaths(); + const phases = recordDiagnosticPhases(); -test('acquireXcodebuildSimulatorSetRedirect is a no-op for simulators without a scoped device set', async () => { - const handle = await acquireXcodebuildSimulatorSetRedirect(iosSimulator); - assert.equal(handle, null); -}); + restoreLegacyXctestDeviceSetRedirect(paths.xctestDeviceSetPath); -test('acquireXcodebuildSimulatorSetRedirect restores stale redirected XCTestDevices before applying a new one', async () => { - let handle: Awaited> | null = null; - await withTempDir('device-set-redirect-', async (root) => { - const paths = makeRedirectPaths(root); - const staleRequestedSetPath = path.join(root, 'stale-requested'); - fs.mkdirSync(paths.requestedSetPath, { recursive: true }); - fs.mkdirSync(staleRequestedSetPath, { recursive: true }); - fs.mkdirSync(path.dirname(paths.xctestDeviceSetPath), { recursive: true }); - fs.mkdirSync(paths.backupPath, { recursive: true }); - fs.writeFileSync(path.join(paths.backupPath, 'original.txt'), 'restored', 'utf8'); - fs.symlinkSync(staleRequestedSetPath, paths.xctestDeviceSetPath, 'dir'); - - handle = await acquireRedirect(paths, { backupPath: paths.backupPath }); - - assert.notEqual(handle, null); - assertRedirectTargetsRequestedSet(paths); - - await handle?.release(); - handle = null; - - assert.equal(fs.existsSync(paths.backupPath), false); - assert.equal( - fs.readFileSync(path.join(paths.xctestDeviceSetPath, 'original.txt'), 'utf8'), - 'restored', - ); - }).finally(async () => { - await handle?.release(); - }); + assert.equal(fs.existsSync(paths.xctestDeviceSetPath), false); + assert.equal(fs.existsSync(paths.backupPath), false); + assert.deepEqual(phases, []); }); -test('acquireXcodebuildSimulatorSetRedirect restores the backup when XCTestDevices is a dangling symlink', async () => { - let handle: Awaited> | null = null; - await withTempDir('device-set-redirect-', async (root) => { - const paths = makeRedirectPaths(root); - fs.mkdirSync(paths.requestedSetPath, { recursive: true }); - fs.mkdirSync(path.dirname(paths.xctestDeviceSetPath), { recursive: true }); - fs.mkdirSync(paths.backupPath, { recursive: true }); - fs.writeFileSync(path.join(paths.backupPath, 'original.txt'), 'restored', 'utf8'); - // Stale redirect whose target set was deleted by its caller. - fs.symlinkSync(path.join(root, 'deleted-requested'), paths.xctestDeviceSetPath, 'dir'); - - handle = await acquireRedirect(paths, { backupPath: paths.backupPath }); - - assert.notEqual(handle, null); - assertRedirectTargetsRequestedSet(paths); - - await handle?.release(); - handle = null; - - assert.equal(fs.existsSync(paths.backupPath), false); - assert.equal( - fs.readFileSync(path.join(paths.xctestDeviceSetPath, 'original.txt'), 'utf8'), - 'restored', - ); - }).finally(async () => { - await handle?.release(); - }); -}); +/** Runs the restore with the other daemon's whole restore landing just before this one's `call`. */ +function withOtherDaemonFinishingBefore( + call: 'unlinkSync' | 'renameSync', + paths: LegacyRedirect, +): void { + const original = fs[call] as (...args: unknown[]) => unknown; + vi.spyOn(fs, call).mockImplementationOnce(((...args: unknown[]) => { + restoreLegacyXctestDeviceSetRedirect(paths.xctestDeviceSetPath); + return original.apply(fs, args); + }) as never); + restoreLegacyXctestDeviceSetRedirect(paths.xctestDeviceSetPath); +} -test('acquireXcodebuildSimulatorSetRedirect clears stale lock directories from dead owners', async () => { - let handle: Awaited> | null = null; - await withTempDir('device-set-redirect-', async (root) => { - const paths = makeRedirectPaths(root); - fs.mkdirSync(paths.requestedSetPath, { recursive: true }); - fs.mkdirSync(paths.lockDirPath, { recursive: true }); - fs.writeFileSync( - path.join(paths.lockDirPath, 'owner.json'), - JSON.stringify({ pid: 999_999, startTime: null, acquiredAtMs: Date.now() - 60_000 }), - 'utf8', - ); - - handle = await acquireRedirect(paths); - - assert.notEqual(handle, null); - assert.equal(fs.lstatSync(paths.xctestDeviceSetPath).isSymbolicLink(), true); - - await handle?.release(); - handle = null; - - assert.equal(fs.existsSync(paths.lockDirPath), false); - }).finally(async () => { - await handle?.release(); - }); +test('a daemon whose unlink or rename the other daemon already made finishes the restore', () => { + for (const call of ['unlinkSync', 'renameSync'] as const) { + const paths = makeLegacyPaths(); + fs.mkdirSync(path.join(paths.backupPath, 'HOST-UDID'), { recursive: true }); + fs.symlinkSync(paths.scopedSetPath, paths.xctestDeviceSetPath, 'dir'); + + withOtherDaemonFinishingBefore(call, paths); + + assert.equal(fs.lstatSync(paths.xctestDeviceSetPath).isSymbolicLink(), false, call); + assert.ok(fs.existsSync(path.join(paths.xctestDeviceSetPath, 'HOST-UDID')), call); + assert.equal(fs.existsSync(paths.backupPath), false, call); + assert.ok(fs.existsSync(path.join(paths.scopedSetPath, 'SCOPED-UDID')), call); + vi.restoreAllMocks(); + } }); -test('acquireXcodebuildSimulatorSetRedirect preserves the backup when XCTestDevices is recreated mid-swap', async () => { - const renameSync = fs.renameSync.bind(fs); - let xctestDeviceSetPath = ''; - 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); +test('an unlink that fails while the link is still there is reported', () => { + const paths = makeLegacyPaths(); + fs.mkdirSync(paths.backupPath); + fs.symlinkSync(paths.scopedSetPath, paths.xctestDeviceSetPath, 'dir'); + vi.spyOn(fs, 'unlinkSync').mockImplementationOnce(() => { + throw Object.assign(new Error('EACCES: permission denied'), { code: 'EACCES' }); }); - try { - await withTempDir('device-set-redirect-', async (root) => { - const paths = makeRedirectPaths(root); - xctestDeviceSetPath = paths.xctestDeviceSetPath; - fs.mkdirSync(paths.requestedSetPath, { recursive: true }); - fs.mkdirSync(paths.xctestDeviceSetPath, { recursive: true }); - fs.writeFileSync(path.join(paths.xctestDeviceSetPath, 'original.txt'), 'original', 'utf8'); - - await assert.rejects( - acquireRedirect(paths, { backupPath: paths.backupPath }), - /Failed to redirect XCTest device set path/, - ); - - assert.equal( - fs.readFileSync(path.join(paths.backupPath, 'original.txt'), 'utf8'), - 'original', - ); - assert.equal( - fs.readFileSync(path.join(paths.xctestDeviceSetPath, 'collision.txt'), 'utf8'), - 'collision', - ); - }); - } finally { - renameSpy.mockRestore(); - } + + assert.throws(() => restoreLegacyXctestDeviceSetRedirect(paths.xctestDeviceSetPath), /EACCES/); }); + +function recordDiagnosticPhases(): string[] { + const phases: string[] = []; + appleRunnerTestHost.update({ emitDiagnostic: (event) => phases.push(event.phase) }); + return phases; +} diff --git a/packages/platform-apple/src/runner/__tests__/runner-early-exit-diagnosis.test.ts b/packages/platform-apple/src/runner/__tests__/runner-early-exit-diagnosis.test.ts index 54bb0da57c..298473855b 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-early-exit-diagnosis.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-early-exit-diagnosis.test.ts @@ -1,13 +1,22 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; -import { test } from 'vitest'; +import { beforeEach, test } from 'vitest'; import type { AppError } from '@agent-device/kernel/errors'; +import { resetAllProcessMemosForTests } from '@agent-device/kernel/ttl-memo'; import type { ExecBackgroundResult } from '@agent-device/host-kit/command'; import { buildRunnerEarlyExitError } from '../runner-startup-transport.ts'; import { readRunnerLogTail } from '../runner-io.ts'; import type { RunnerSession } from '../runner-session-types.ts'; import { mkdtempForTestSync } from './tmp-dir.ts'; +import { STUBBED_APPLE_TOOLCHAIN, stubAppleToolchainProbes } from './apple-toolchain-fixtures.ts'; +import { + CAPTURED_LAUNCH_DESTINATION_NOT_FOUND_OUTPUT, + CAPTURED_SCOPED_SIMULATOR, +} from './runner-startup-failure-fixtures.ts'; + +const toolchainProbe = stubAppleToolchainProbes(); +beforeEach(resetAllProcessMemosForTests); // Verbatim xcodebuild output from an iPhone that was not in the signing account. // macOS localizes the installer prose, so the machine-readable anchors are the @@ -165,3 +174,80 @@ test('a session that never probed the device publishes no disk-image claim (#268 assert.equal('developerDiskImage' in (error.details ?? {}), false); }); + +const SET_WITHOUT_UDID = CAPTURED_SCOPED_SIMULATOR.setWithoutUdid; + +// An external xctestrun: the session carries no build of its own, so the Xcode it names comes from +// the toolchain the host selects. +function simulatorSessionFailingWith(log: string, simulatorSetPath?: string): RunnerSession { + return { + ...sessionFailingWith(log), + device: { + platform: 'apple', + id: CAPTURED_SCOPED_SIMULATOR.udid, + name: 'iPhone 17 Pro', + kind: 'simulator', + appleOs: 'ios', + booted: true, + simulatorSetPath, + }, + deviceId: CAPTURED_SCOPED_SIMULATOR.udid, + xctestrunArtifact: { + xctestrunPath: '/tmp/runner.xctestrun', + derived: '/tmp/derived', + cache: 'external', + artifact: 'valid', + buildMs: 0, + xctestrunPathSource: 'external', + }, + }; +} + +test('a scoped-set simulator whose destination is missing names its set and the Xcode', async () => { + const error = (await buildRunnerEarlyExitError({ + session: simulatorSessionFailingWith( + CAPTURED_LAUNCH_DESTINATION_NOT_FOUND_OUTPUT, + SET_WITHOUT_UDID, + ), + port: 8100, + })) as AppError; + + assert.equal(error.details?.reason, 'simulator_set_destination_not_found'); + assert.match(String(error.details?.hint), /-DVTSimulatorSetLocation/); + assert.equal(error.details?.simulatorSetPath, SET_WITHOUT_UDID); + assert.equal(error.details?.xcodeVersion, STUBBED_APPLE_TOOLCHAIN.xcodeVersion); + assert.ok( + error.message.endsWith( + `simulator set ${SET_WITHOUT_UDID} with Xcode ${STUBBED_APPLE_TOOLCHAIN.xcodeVersion}`, + ), + ); +}); + +test('a scoped-set destination error whose Xcode cannot be read still names the set', async () => { + toolchainProbe.mockReturnValue({ exitCode: 1, stdout: '', stderr: 'xcode-select: error' }); + + const error = (await buildRunnerEarlyExitError({ + session: simulatorSessionFailingWith( + CAPTURED_LAUNCH_DESTINATION_NOT_FOUND_OUTPUT, + SET_WITHOUT_UDID, + ), + port: 8100, + })) as AppError; + + assert.equal(error.details?.reason, 'simulator_set_destination_not_found'); + assert.equal(error.details?.simulatorSetPath, SET_WITHOUT_UDID); + assert.equal('xcodeVersion' in (error.details ?? {}), false); + assert.ok( + error.message.endsWith(`simulator set ${SET_WITHOUT_UDID} with Xcode (version unreadable)`), + ); +}); + +test('a default-set simulator early exit keeps its boot-failure reason', async () => { + const error = (await buildRunnerEarlyExitError({ + session: simulatorSessionFailingWith(CAPTURED_LAUNCH_DESTINATION_NOT_FOUND_OUTPUT), + port: 8100, + })) as AppError; + + assert.equal(error.details?.reason, 'IOS_RUNNER_CONNECT_TIMEOUT'); + assert.equal(error.details?.simulatorSetPath, undefined); +}); diff --git a/packages/platform-apple/src/runner/__tests__/runner-process-launch.test.ts b/packages/platform-apple/src/runner/__tests__/runner-process-launch.test.ts index 240fce06e9..99c7c6ddab 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-process-launch.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-process-launch.test.ts @@ -52,6 +52,33 @@ test("the runner is spawned detached onto its own log file, not onto this proces assert.equal(args[args.indexOf('-derivedDataPath') + 1], '/tmp/runner-derived'); }); +test('test-without-building resolves a scoped-set simulator in its own set', () => { + mockRunCmdBackground.mockReturnValue(makeBackgroundRunner(4242)); + + for (const simulatorSetPath of ['/tmp/tenant-a/simulators', undefined]) { + launchRunnerProcess({ + device: { ...IOS_SIMULATOR, simulatorSetPath }, + port: 8123, + xctestrunPath: '/tmp/runner.xctestrun', + derivedPath: '/tmp/runner-derived', + logPath: runnerLogPath(), + }); + } + + const [scopedArgs, defaultArgs] = mockRunCmdBackground.mock.calls.map( + (call) => call[1] as string[], + ); + assert.ok(scopedArgs?.includes('-DVTSimulatorSetLocation=/tmp/tenant-a/simulators')); + assert.equal( + scopedArgs?.[scopedArgs.indexOf('-destination') + 1], + 'platform=iOS Simulator,id=sim-1', + ); + assert.equal( + defaultArgs?.some((arg) => arg.startsWith('-DVTSimulatorSetLocation')), + false, + ); +}); + test('the listener-ready marker is read back from the runner log file', async () => { const logPath = runnerLogPath(); mockRunCmdBackground.mockReturnValue({ diff --git a/packages/platform-apple/src/runner/__tests__/runner-request-cancellation.test.ts b/packages/platform-apple/src/runner/__tests__/runner-request-cancellation.test.ts index 20f0a770cd..f6fbcc19d7 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-request-cancellation.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-request-cancellation.test.ts @@ -5,11 +5,9 @@ import path from 'node:path'; import { beforeEach, test, vi } from 'vitest'; import { IOS_DEVICE, IOS_SIMULATOR } from './device-fixtures.ts'; import { mkdtempForTestSync } from './tmp-dir.ts'; -import { redirectHandle } from './runner-session-fixtures.ts'; import { appleRunnerTestHost } from '../test-host.ts'; const { - mockAcquireXcodebuildSimulatorSetRedirect, mockEnsureXctestrunArtifact, mockGetFreePort, mockIsProcessAlive, @@ -24,7 +22,6 @@ const { mockSignalProcessGroupBestEffort, mockWaitForRunner, } = vi.hoisted(() => ({ - mockAcquireXcodebuildSimulatorSetRedirect: vi.fn(), mockEnsureXctestrunArtifact: vi.fn(), mockGetFreePort: vi.fn(), mockIsProcessAlive: vi.fn(), @@ -64,7 +61,6 @@ vi.mock('../runner-xctestrun.ts', async () => { await vi.importActual('../runner-xctestrun.ts'); return { ...actual, - acquireXcodebuildSimulatorSetRedirect: mockAcquireXcodebuildSimulatorSetRedirect, ensureXctestrunArtifact: mockEnsureXctestrunArtifact, prepareXctestrunWithEnv: mockPrepareXctestrunWithEnv, resolveExpectedRunnerCacheMetadata: mockResolveExpectedRunnerCacheMetadata, @@ -154,7 +150,6 @@ beforeEach(async () => { }); mockResolveExpectedRunnerCacheMetadata.mockReturnValue({ schemaVersion: 1 }); mockResolveRunnerDerivedPath.mockReturnValue('/tmp/derived'); - mockAcquireXcodebuildSimulatorSetRedirect.mockResolvedValue(redirectHandle); mockRunCmdBackground.mockReturnValue(makeBackgroundRunner(4242)); mockRunAppleToolCommand.mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' }); mockIsProcessAlive.mockReturnValue(true); diff --git a/packages/platform-apple/src/runner/__tests__/runner-requests.test.ts b/packages/platform-apple/src/runner/__tests__/runner-requests.test.ts index bb6a13fda8..5fd3915f23 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-requests.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-requests.test.ts @@ -101,7 +101,7 @@ test('runner-internal request sites build exactly their runner-requests.json ent }); writeRunnerLease({ ...buildRunnerLease({ - deviceId: IOS_SIMULATOR.id, + device: IOS_SIMULATOR, sessionId: `${IOS_SIMULATOR.id}:${server.port}:1`, runnerPid: 424242, port: server.port, diff --git a/packages/platform-apple/src/runner/__tests__/runner-session-close.test.ts b/packages/platform-apple/src/runner/__tests__/runner-session-close.test.ts index 40f86597bb..7e1e27c6eb 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-session-close.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-session-close.test.ts @@ -8,7 +8,6 @@ import { makeRunnerSession, runnerError, runnerResponse, - redirectHandle, } from './runner-session-fixtures.ts'; import { mkdtempForTestSync } from './tmp-dir.ts'; @@ -18,7 +17,6 @@ import { mkdtempForTestSync } from './tmp-dir.ts'; // rest of `runner-session.ts` and is held at the test-file size tripwire. const { - mockAcquireXcodebuildSimulatorSetRedirect, mockCleanupTempFile, mockEnsureXctestrunArtifact, mockGetFreePort, @@ -37,7 +35,6 @@ const { mockSignalProcessGroupBestEffort, mockWaitForRunner, } = vi.hoisted(() => ({ - mockAcquireXcodebuildSimulatorSetRedirect: vi.fn(), mockCleanupTempFile: vi.fn(), mockEnsureXctestrunArtifact: vi.fn(), mockGetFreePort: vi.fn(), @@ -92,7 +89,6 @@ vi.mock('../runner-xctestrun.ts', async () => { await vi.importActual('../runner-xctestrun.ts'); return { ...actual, - acquireXcodebuildSimulatorSetRedirect: mockAcquireXcodebuildSimulatorSetRedirect, ensureXctestrunArtifact: mockEnsureXctestrunArtifact, prepareXctestrunWithEnv: mockPrepareXctestrunWithEnv, resolveExpectedRunnerCacheMetadata: mockResolveExpectedRunnerCacheMetadata, @@ -151,7 +147,6 @@ beforeEach(async () => { }); mockResolveExpectedRunnerCacheMetadata.mockReturnValue({ schemaVersion: 1 }); mockResolveRunnerDerivedPath.mockReturnValue('/tmp/derived'); - mockAcquireXcodebuildSimulatorSetRedirect.mockResolvedValue(redirectHandle); mockRunCmdBackground.mockReturnValue(makeBackgroundRunner(4242)); mockRunAppleToolCommand.mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' }); mockIsProcessAlive.mockReturnValue(true); diff --git a/packages/platform-apple/src/runner/__tests__/runner-session-fixtures.ts b/packages/platform-apple/src/runner/__tests__/runner-session-fixtures.ts index 0aa6a8f605..bbe2a4c1ab 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-session-fixtures.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-session-fixtures.ts @@ -1,13 +1,11 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import { EventEmitter } from 'node:events'; -import { vi } from 'vitest'; import { AppError } from '@agent-device/kernel/errors'; import { IOS_SIMULATOR } from './device-fixtures.ts'; import { appleRunnerTestHost } from '../test-host.ts'; import { runnerOwnerStartTime, type RunnerLease } from '../runner-lease.ts'; import type { RunnerSession } from '../runner-session-types.ts'; -import type { XcodebuildSimulatorSetRedirectHandle } from '../runner-device-set.ts'; import { runnerConnectFailureDetails, type RunnerConnectFailureReason, @@ -210,15 +208,3 @@ export function makeClassifyOwnerLivenessViaMocks(deps: { return stateDir ? classifyStateDir(stateDir) : 'live'; }; } - -/** - * The give-back a launched session holds. One spy answers both doors, because these tests ask whether - * the host's device set came back; which door it came back through is pinned where the handle is - * made, in `runner-device-set.test.ts`. - */ -export const redirectRelease = vi.fn(async () => {}); - -export const redirectHandle: XcodebuildSimulatorSetRedirectHandle = { - release: redirectRelease, - releaseBestEffort: redirectRelease, -}; diff --git a/packages/platform-apple/src/runner/__tests__/runner-session-lifecycle.test.ts b/packages/platform-apple/src/runner/__tests__/runner-session-lifecycle.test.ts index cbb75e7ccc..2bba59df4b 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-session-lifecycle.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-session-lifecycle.test.ts @@ -13,13 +13,10 @@ import { assertRunnerCommand, makeBackgroundRunner, runnerResponse, - redirectHandle, - redirectRelease, } from './runner-session-fixtures.ts'; import { mkdtempForTestSync } from './tmp-dir.ts'; const { - mockAcquireXcodebuildSimulatorSetRedirect, mockCleanupTempFile, mockEnsureXctestrunArtifact, mockGetFreePort, @@ -39,7 +36,6 @@ const { mockWaitForRunner, runnerStateTransitions, } = vi.hoisted(() => ({ - mockAcquireXcodebuildSimulatorSetRedirect: vi.fn(), mockCleanupTempFile: vi.fn(), mockEnsureXctestrunArtifact: vi.fn(), mockGetFreePort: vi.fn(), @@ -104,7 +100,6 @@ vi.mock('../runner-xctestrun.ts', async () => { await vi.importActual('../runner-xctestrun.ts'); return { ...actual, - acquireXcodebuildSimulatorSetRedirect: mockAcquireXcodebuildSimulatorSetRedirect, ensureXctestrunArtifact: mockEnsureXctestrunArtifact, prepareXctestrunWithEnv: mockPrepareXctestrunWithEnv, resolveExpectedRunnerCacheMetadata: mockResolveExpectedRunnerCacheMetadata, @@ -192,12 +187,6 @@ beforeEach(async () => { }); mockResolveExpectedRunnerCacheMetadata.mockReturnValue({ schemaVersion: 1 }); mockResolveRunnerDerivedPath.mockReturnValue('/tmp/derived'); - // Faithful to `acquireXcodebuildSimulatorSetRedirect`, which never holds a redirect for a - // non-simulator. Tests covering the default simulator set, where the real helper also returns - // no handle, override with null (#2681). - mockAcquireXcodebuildSimulatorSetRedirect.mockImplementation(async (device: DeviceInfo) => - device.kind === 'simulator' ? redirectHandle : null, - ); mockRunCmdBackground.mockReturnValue(makeBackgroundRunner(4242)); mockRunAppleToolCommand.mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' }); mockIsProcessAlive.mockReturnValue(true); @@ -350,7 +339,6 @@ test('an abort drains a registered runner before reporting it stopped', async () test('shutdown detach moves a handed-off session to stopped without killing its runner', async () => { const device = { ...IOS_SIMULATOR, id: 'runner-lifecycle-detach' }; - mockAcquireXcodebuildSimulatorSetRedirect.mockResolvedValue(null); const session = await ensureRunnerSession(device, {}); await serveOneCommand(device, session); const runnerPid = session.child.pid; @@ -366,7 +354,7 @@ test('shutdown detach moves a handed-off session to stopped without killing its assert.match(leaseRaw(device.id), /"ownerToken": "detached-owner-/); }); -test('a scoped simulator-set session stays on the kill path that restores the redirect', async () => { +test('a scoped simulator-set session hands off like one in the default set', async () => { const device = { ...IOS_SIMULATOR, id: 'runner-lifecycle-detach-scoped-sim', @@ -374,17 +362,11 @@ test('a scoped simulator-set session stays on the kill path that restores the re }; const session = await ensureRunnerSession(device, {}); await serveOneCommand(device, session); - assert.equal(mockAcquireXcodebuildSimulatorSetRedirect.mock.calls.length, 1); - const diagnostics = await captureDiagnostics(async () => { - assert.equal(await detachIosRunnerSessionsForShutdown(), 0); - }); + assert.equal(await detachIosRunnerSessionsForShutdown(), 1); - // The redirect-holding session must stay for disposal, which restores the - // XCTestDevices symlink; detach never releases the redirect itself. - assert.match(diagnostics, /"reason":"simulator_set_redirect"/); - assert.ok(readRunnerSessionLiveness(device.id)); - assert.equal(redirectRelease.mock.calls.length, 0); + assert.equal(session.state, 'stopped'); + assert.match(leaseRaw(device.id), /"ownerToken": "detached-owner-/); }); // #2681: the handoff lanes and every gate that keeps a runner on the kill path. @@ -641,6 +623,26 @@ test('a registered runner whose process died is recycled instead of reused', asy }); }); +test('the same udid in another simulator set starts its own runner instead of reusing this one', async () => { + const tenantA = { + ...IOS_SIMULATOR, + id: 'runner-lifecycle-two-sets', + simulatorSetPath: '/tmp/tenant-a/simulators', + }; + const tenantB = { ...tenantA, simulatorSetPath: '/tmp/tenant-b/simulators' }; + const first = await ensureRunnerSession(tenantA, {}); + assert.equal(await ensureRunnerSession({ ...tenantA }, {}), first); + + mockGetFreePort.mockResolvedValueOnce(8124); + mockRunCmdBackground.mockReturnValueOnce(makeBackgroundRunner(4243)); + const second = await ensureRunnerSession(tenantB, {}); + + assert.notEqual(second.sessionId, first.sessionId); + assert.equal(first.state, 'stopped'); + assert.equal(second.device.simulatorSetPath, '/tmp/tenant-b/simulators'); + assert.match(leaseRaw(tenantB.id), /"simulatorSetPath": "\/tmp\/tenant-b\/simulators"/); +}); + test('a draining session is never reused while its next command starts a fresh runner', async () => { const device = { ...IOS_SIMULATOR, id: 'runner-lifecycle-draining-reuse' }; const first = await ensureRunnerSession(device, {}); diff --git a/packages/platform-apple/src/runner/__tests__/runner-session-readiness.test.ts b/packages/platform-apple/src/runner/__tests__/runner-session-readiness.test.ts index 96ee609ecb..90ebf2a359 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-session-readiness.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-session-readiness.test.ts @@ -12,14 +12,12 @@ import { makeRunnerSession, runnerError, runnerResponse, - redirectHandle, } from './runner-session-fixtures.ts'; import { mkdtempForTestSync } from './tmp-dir.ts'; import { createLocalAppleToolProvider, withAppleToolProvider } from '../../core/tool-provider.ts'; import { IOS_DEVICE_DEVELOPER_DISK_IMAGE_HINT } from '../../core/devicectl.ts'; const { - mockAcquireXcodebuildSimulatorSetRedirect, mockCleanupTempFile, mockEnsureXctestrunArtifact, mockGetFreePort, @@ -38,7 +36,6 @@ const { mockSignalProcessGroupBestEffort, mockWaitForRunner, } = vi.hoisted(() => ({ - mockAcquireXcodebuildSimulatorSetRedirect: vi.fn(), mockCleanupTempFile: vi.fn(), mockEnsureXctestrunArtifact: vi.fn(), mockGetFreePort: vi.fn(), @@ -108,7 +105,6 @@ vi.mock('../runner-xctestrun.ts', async () => { await vi.importActual('../runner-xctestrun.ts'); return { ...actual, - acquireXcodebuildSimulatorSetRedirect: mockAcquireXcodebuildSimulatorSetRedirect, ensureXctestrunArtifact: mockEnsureXctestrunArtifact, prepareXctestrunWithEnv: mockPrepareXctestrunWithEnv, resolveExpectedRunnerCacheMetadata: mockResolveExpectedRunnerCacheMetadata, @@ -174,7 +170,6 @@ beforeEach(async () => { }); mockResolveExpectedRunnerCacheMetadata.mockReturnValue({ schemaVersion: 1 }); mockResolveRunnerDerivedPath.mockReturnValue('/tmp/derived'); - mockAcquireXcodebuildSimulatorSetRedirect.mockResolvedValue(redirectHandle); mockRunCmdBackground.mockReturnValue(makeBackgroundRunner(4242)); mockRunAppleToolCommand.mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' }); mockIsProcessAlive.mockReturnValue(true); diff --git a/packages/platform-apple/src/runner/__tests__/runner-session-speculative.test.ts b/packages/platform-apple/src/runner/__tests__/runner-session-speculative.test.ts index 85e48971b5..0e814ba88c 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-session-speculative.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-session-speculative.test.ts @@ -6,12 +6,10 @@ import { makeBackgroundRunner, makeClassifyOwnerLivenessViaMocks, runnerResponse, - redirectHandle, } from './runner-session-fixtures.ts'; import { mkdtempForTestSync } from './tmp-dir.ts'; const { - mockAcquireXcodebuildSimulatorSetRedirect, mockCleanupTempFile, mockEnsureXctestrunArtifact, mockGetFreePort, @@ -30,7 +28,6 @@ const { mockSignalProcessGroupBestEffort, mockWaitForRunner, } = vi.hoisted(() => ({ - mockAcquireXcodebuildSimulatorSetRedirect: vi.fn(), mockCleanupTempFile: vi.fn(), mockEnsureXctestrunArtifact: vi.fn(), mockGetFreePort: vi.fn(), @@ -70,7 +67,6 @@ vi.mock('../runner-xctestrun.ts', async () => { await vi.importActual('../runner-xctestrun.ts'); return { ...actual, - acquireXcodebuildSimulatorSetRedirect: mockAcquireXcodebuildSimulatorSetRedirect, ensureXctestrunArtifact: mockEnsureXctestrunArtifact, prepareXctestrunWithEnv: mockPrepareXctestrunWithEnv, resolveExpectedRunnerCacheMetadata: mockResolveExpectedRunnerCacheMetadata, @@ -137,7 +133,6 @@ beforeEach(async () => { }); mockResolveExpectedRunnerCacheMetadata.mockReturnValue({ schemaVersion: 1 }); mockResolveRunnerDerivedPath.mockReturnValue('/tmp/derived'); - mockAcquireXcodebuildSimulatorSetRedirect.mockResolvedValue(redirectHandle); mockRunCmdBackground.mockReturnValue(makeBackgroundRunner(4242)); mockRunAppleToolCommand.mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' }); mockIsProcessAlive.mockReturnValue(true); @@ -186,9 +181,9 @@ test('a release that arrives while the speculative start is still in flight stop const gate = new Promise((resolve) => { openGate = resolve; }); - mockAcquireXcodebuildSimulatorSetRedirect.mockImplementation(async () => { + mockGetFreePort.mockImplementation(async () => { await gate; - return redirectHandle; + return 8123; }); const starting = ensureRunnerSession(device, { speculative: true }); @@ -212,9 +207,9 @@ test('a release that waits out a demanded start leaves that runner alone', async const gate = new Promise((resolve) => { openGate = resolve; }); - mockAcquireXcodebuildSimulatorSetRedirect.mockImplementation(async () => { + mockGetFreePort.mockImplementation(async () => { await gate; - return redirectHandle; + return 8123; }); const starting = ensureRunnerSession(device, {}); diff --git a/packages/platform-apple/src/runner/__tests__/runner-session-stale-bundles.test.ts b/packages/platform-apple/src/runner/__tests__/runner-session-stale-bundles.test.ts index 693cfa442e..24c4626247 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-session-stale-bundles.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-session-stale-bundles.test.ts @@ -6,12 +6,10 @@ import { makeBackgroundRunner, makeClassifyOwnerLivenessViaMocks, runnerResponse, - redirectHandle, } from './runner-session-fixtures.ts'; import { mkdtempForTestSync } from './tmp-dir.ts'; const { - mockAcquireXcodebuildSimulatorSetRedirect, mockCleanupTempFile, mockEnsureXctestrunArtifact, mockGetFreePort, @@ -30,7 +28,6 @@ const { mockSignalProcessGroupBestEffort, mockWaitForRunner, } = vi.hoisted(() => ({ - mockAcquireXcodebuildSimulatorSetRedirect: vi.fn(), mockCleanupTempFile: vi.fn(), mockEnsureXctestrunArtifact: vi.fn(), mockGetFreePort: vi.fn(), @@ -81,7 +78,6 @@ vi.mock('../runner-xctestrun.ts', async () => { await vi.importActual('../runner-xctestrun.ts'); return { ...actual, - acquireXcodebuildSimulatorSetRedirect: mockAcquireXcodebuildSimulatorSetRedirect, ensureXctestrunArtifact: mockEnsureXctestrunArtifact, prepareXctestrunWithEnv: mockPrepareXctestrunWithEnv, resolveExpectedRunnerCacheMetadata: mockResolveExpectedRunnerCacheMetadata, @@ -145,7 +141,6 @@ beforeEach(async () => { }); mockResolveExpectedRunnerCacheMetadata.mockReturnValue({ schemaVersion: 1 }); mockResolveRunnerDerivedPath.mockReturnValue('/tmp/derived'); - mockAcquireXcodebuildSimulatorSetRedirect.mockResolvedValue(redirectHandle); mockRunCmdBackground.mockReturnValue(makeBackgroundRunner(4242)); mockRunAppleToolCommand.mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' }); mockIsProcessAlive.mockReturnValue(true); diff --git a/packages/platform-apple/src/runner/__tests__/runner-session.test.ts b/packages/platform-apple/src/runner/__tests__/runner-session.test.ts index 4419e5ed3b..aa96594fec 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-session.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-session.test.ts @@ -15,13 +15,10 @@ import { makeRunnerSession, runnerError, runnerResponse, - redirectHandle, - redirectRelease, } from './runner-session-fixtures.ts'; import { mkdtempForTestSync } from './tmp-dir.ts'; const { - mockAcquireXcodebuildSimulatorSetRedirect, mockCleanupTempFile, mockEnsureXctestrunArtifact, mockGetFreePort, @@ -40,7 +37,6 @@ const { mockSignalProcessGroupBestEffort, mockWaitForRunner, } = vi.hoisted(() => ({ - mockAcquireXcodebuildSimulatorSetRedirect: vi.fn(), mockCleanupTempFile: vi.fn(), mockEnsureXctestrunArtifact: vi.fn(), mockGetFreePort: vi.fn(), @@ -110,7 +106,6 @@ vi.mock('../runner-xctestrun.ts', async () => { await vi.importActual('../runner-xctestrun.ts'); return { ...actual, - acquireXcodebuildSimulatorSetRedirect: mockAcquireXcodebuildSimulatorSetRedirect, ensureXctestrunArtifact: mockEnsureXctestrunArtifact, prepareXctestrunWithEnv: mockPrepareXctestrunWithEnv, resolveExpectedRunnerCacheMetadata: mockResolveExpectedRunnerCacheMetadata, @@ -191,7 +186,6 @@ beforeEach(async () => { }); mockResolveExpectedRunnerCacheMetadata.mockReturnValue({ schemaVersion: 1 }); mockResolveRunnerDerivedPath.mockReturnValue('/tmp/derived'); - mockAcquireXcodebuildSimulatorSetRedirect.mockResolvedValue(redirectHandle); mockRunCmdBackground.mockReturnValue(makeBackgroundRunner(4242)); mockRunAppleToolCommand.mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' }); mockIsProcessAlive.mockReturnValue(true); @@ -340,7 +334,6 @@ test('runner session emits XCTest startup progress only after a runner rebuild', xctestrunPath: '/tmp/session-runner.xctestrun', jsonPath: '/tmp/session-runner.json', }); - mockAcquireXcodebuildSimulatorSetRedirect.mockResolvedValue(redirectHandle); mockRunCmdBackground.mockReturnValue(makeBackgroundRunner(4242)); mockWaitForRunner.mockResolvedValue(runnerResponse({ uptimeMs: 1 })); @@ -1069,7 +1062,6 @@ test('runner session restarts dead runner without graceful shutdown', async () = ['/tmp/session-runner.xctestrun'], ['/tmp/session-runner.json'], ]); - assert.equal(redirectRelease.mock.calls.length, 1); }); test('runner session stop kills only owned stale xcodebuild runner processes without in-memory session', async () => { @@ -1108,7 +1100,6 @@ test('runner session abort removes owned lease for in-memory sessions', async () ['/tmp/session-runner.xctestrun'], ['/tmp/session-runner.json'], ]); - assert.equal(redirectRelease.mock.calls.length, 1); }); function isXcodebuildPkillCall(call: unknown[]): boolean { @@ -1150,7 +1141,6 @@ test('runner session invalidation skips graceful shutdown and removes stale sess ['/tmp/session-runner.xctestrun'], ['/tmp/session-runner.json'], ]); - assert.equal(redirectRelease.mock.calls.length, 1); assert.equal(readRunnerSessionLiveness(device.id), null); }); diff --git a/packages/platform-apple/src/runner/__tests__/runner-startup-failure-fixtures.ts b/packages/platform-apple/src/runner/__tests__/runner-startup-failure-fixtures.ts index c38b2b1dd7..484648a495 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-startup-failure-fixtures.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-startup-failure-fixtures.ts @@ -1,10 +1,12 @@ import { AppError } from '@agent-device/kernel/errors'; +import type { DeviceInfo } from '@agent-device/kernel/device'; import type { RunnerDeviceReadinessFailureReason, RunnerStartupFailureReason, } from '../runner-error-classification.ts'; import { RUNNER_DEVICE_READINESS_FAILURE_REASONS } from '../runner-error-classification.ts'; import type { IosPhysicalDeviceRunnerControl } from '../../core/physical-device-routing.ts'; +import { IOS_SIMULATOR } from './device-fixtures.ts'; /** * Recorded startup failures for {@link classifyRunnerStartupFailure} (#2680). @@ -75,6 +77,24 @@ export type RunnerStartupFailureCarrier = 'exec-details' | 'message-only' | 'hos const UNOBSERVED = 'unobserved'; +/** `xcodebuild -version` for the destination captures below (#2963 review). */ +const CAPTURED_DESTINATION_XCODE_VERSION = 'Xcode 26.2 / Build version 17C52'; + +/** The scoped-set simulator the destination captures targeted, and a set that does not hold it. */ +export const CAPTURED_SCOPED_SIMULATOR = { + udid: '9DD70D58-C485-4CE5-8743-5FED48468446', + setWithoutUdid: + '/Users/thymikee/Developer/agent-device/.claude/worktrees/_scratch/2963-simset-empty', +} as const; + +/** + * The runner launch (`test-without-building`) output for {@link CAPTURED_SCOPED_SIMULATOR} with + * `-DVTSimulatorSetLocation` naming the set that does not hold it, captured with the launch argv from + * `runner-process-launch.ts` against a cached xctestrun. Host destination lines are left out. + */ +export const CAPTURED_LAUNCH_DESTINATION_NOT_FOUND_OUTPUT = + 'Command line invocation:\n /Applications/Xcode-26.2.0.app/Contents/Developer/usr/bin/xcodebuild test-without-building -only-testing AgentDeviceRunnerUITests/RunnerTests/testCommand -parallel-testing-enabled NO -test-timeouts-enabled NO -collect-test-diagnostics never -maximum-concurrent-test-simulator-destinations 1 -destination-timeout 20 -xctestrun /Users/thymikee/.agent-device/apple-runner/derived/ios-simulator/cache-327953f5ae7efca8/Build/Products/AgentDeviceRunner_AgentDeviceRunnerUITests_iphonesimulator26.2-arm64.xctestrun -derivedDataPath /Users/thymikee/Developer/agent-device/.claude/worktrees/_scratch/2963-capture-derived -destination "platform=iOS Simulator,id=9DD70D58-C485-4CE5-8743-5FED48468446" -DVTSimulatorSetLocation=/Users/thymikee/Developer/agent-device/.claude/worktrees/_scratch/2963-simset-empty\n\n2026-09-25 14:06:21.656 xcodebuild[56376:166317816] [MT] IDERunDestination: Supported platforms for the buildables in the current scheme is empty.\n2026-09-25 14:06:21.753 xcodebuild[56376:166317816] [MT] IDERunDestination: Supported platforms for the buildables in the current scheme is empty.\n2026-09-25 14:06:41.674 xcodebuild[56376:166317816] [MT] IDERunDestination: Supported platforms for the buildables in the current scheme is empty.\n2026-09-25 14:06:41.685 xcodebuild[56376:166317816] Writing error result bundle to /var/folders/65/fz9_2bsj6fzgct46vx226s8c0000gn/T/ResultBundle_2026-25-09_14-06-0041.xcresult\nxcodebuild: error: Unable to find a device matching the provided destination specifier:\n\t\t{ platform:iOS Simulator, id:9DD70D58-C485-4CE5-8743-5FED48468446 }\n\n\tThe requested device could not be found because no available devices matched the request.\n\n\tAvailable destinations for the "AgentDeviceRunner" scheme:\n\t\t{ platform:iOS, id:dvtdevice-DVTiPhonePlaceholder-iphoneos:placeholder, name:Any iOS Device }\n\t\t{ platform:iOS Simulator, id:dvtdevice-DVTiOSDeviceSimulatorPlaceholder-iphonesimulator:placeholder, name:Any iOS Simulator Device }\n\t\t{ platform:macOS, name:Any Mac }\n\t\t{ platform:macOS, variant:Mac Catalyst, name:Any Mac }\n\t\t{ platform:tvOS, id:dvtdevice-DVTiOSDevicePlaceholder-appletvos:placeholder, name:Any tvOS Device }\n\t\t{ platform:tvOS Simulator, id:dvtdevice-DVTiOSDeviceSimulatorPlaceholder-appletvsimulator:placeholder, name:Any tvOS Simulator Device }\n\t\t{ platform:visionOS, id:dvtdevice-DVTiOSDevicePlaceholder-xros:placeholder, name:Any visionOS Device }\n\t\t{ platform:visionOS Simulator, id:dvtdevice-DVTiOSDeviceSimulatorPlaceholder-xrsimulator:placeholder, name:Any visionOS Simulator Device }\n\t\t{ platform:watchOS, id:dvtdevice-DVTiOSDevicePlaceholder-watchos:placeholder, name:Any watchOS Device }\n\t\t{ platform:watchOS Simulator, id:dvtdevice-DVTiOSDeviceSimulatorPlaceholder-watchsimulator:placeholder, name:Any watchOS Simulator Device }\n'; + export type RunnerStartupFailureFixture = Readonly<{ /** Stable name for a focused test or a review comment. */ id: string; @@ -94,6 +114,8 @@ export type RunnerStartupFailureFixture = Readonly<{ output: string; /** The argv the exec reported, which is never evidence of a cause (#2680). */ args?: readonly string[]; + /** The device the build targets; a physical iPhone unless the reason is about a simulator. */ + device?: DeviceInfo; /** * The device's own states. On the `device-readiness` site this is the evidence the preflight reads; * on a `build-for-testing` entry it is what the startup carried onto that build, which is the pairing @@ -217,6 +239,36 @@ export const RUNNER_STARTUP_FAILURE_FIXTURES: readonly RunnerStartupFailureFixtu ], note: 'The argv we were asked to run is not xcodebuild evidence: a caller who pinned a profile still gets cache-recovery advice for a compile error (#2680).', }, + { + id: 'scoped-set-destination-not-found', + reason: 'simulator_set_destination_not_found', + site: 'build-for-testing', + command: + '/Applications/Xcode-26.2.0.app/Contents/Developer/usr/bin/xcodebuild build-for-testing -project /Users/thymikee/Developer/agent-device/.claude/worktrees/impl-2935b/apple/runner/AgentDeviceRunner/AgentDeviceRunner.xcodeproj -scheme AgentDeviceRunner -parallel-testing-enabled NO -maximum-concurrent-test-simulator-destinations 1 -destination "platform=iOS Simulator,id=9DD70D58-C485-4CE5-8743-5FED48468446" -DVTSimulatorSetLocation=/Users/thymikee/Developer/agent-device/.claude/worktrees/_scratch/2963-simset-empty -derivedDataPath /Users/thymikee/Developer/agent-device/.claude/worktrees/_scratch/2963-capture-derived COMPILER_INDEX_STORE_ENABLE=NO ENABLE_CODE_COVERAGE=NO ONLY_ACTIVE_ARCH=YES ENABLE_PREVIEWS=NO ENABLE_DEBUG_DYLIB=NO -IDEPackageSupportDisableManifestSandbox=1 -IDEPackageSupportDisablePluginExecutionSandbox=1 ENABLE_USER_SCRIPT_SANDBOXING=NO "OTHER_SWIFT_FLAGS=$(inherited) -disable-sandbox" AGENT_DEVICE_IOS_RUNNER_APP_BUNDLE_ID=com.callstack.agentdevice.runner AGENT_DEVICE_IOS_RUNNER_TEST_BUNDLE_ID=com.callstack.agentdevice.runner.uitests', + xcodeVersion: CAPTURED_DESTINATION_XCODE_VERSION, + provenance: 'captured', + output: + 'Command line invocation:\n /Applications/Xcode-26.2.0.app/Contents/Developer/usr/bin/xcodebuild build-for-testing -project /Users/thymikee/Developer/agent-device/.claude/worktrees/impl-2935b/apple/runner/AgentDeviceRunner/AgentDeviceRunner.xcodeproj -scheme AgentDeviceRunner -parallel-testing-enabled NO -maximum-concurrent-test-simulator-destinations 1 -destination "platform=iOS Simulator,id=9DD70D58-C485-4CE5-8743-5FED48468446" -DVTSimulatorSetLocation=/Users/thymikee/Developer/agent-device/.claude/worktrees/_scratch/2963-simset-empty -derivedDataPath /Users/thymikee/Developer/agent-device/.claude/worktrees/_scratch/2963-capture-derived COMPILER_INDEX_STORE_ENABLE=NO ENABLE_CODE_COVERAGE=NO ONLY_ACTIVE_ARCH=YES ENABLE_PREVIEWS=NO ENABLE_DEBUG_DYLIB=NO -IDEPackageSupportDisableManifestSandbox=1 -IDEPackageSupportDisablePluginExecutionSandbox=1 ENABLE_USER_SCRIPT_SANDBOXING=NO "OTHER_SWIFT_FLAGS=$(inherited) -disable-sandbox" AGENT_DEVICE_IOS_RUNNER_APP_BUNDLE_ID=com.callstack.agentdevice.runner AGENT_DEVICE_IOS_RUNNER_TEST_BUNDLE_ID=com.callstack.agentdevice.runner.uitests\n\nBuild settings from command line:\n AGENT_DEVICE_IOS_RUNNER_APP_BUNDLE_ID = com.callstack.agentdevice.runner\n AGENT_DEVICE_IOS_RUNNER_TEST_BUNDLE_ID = com.callstack.agentdevice.runner.uitests\n COMPILER_INDEX_STORE_ENABLE = NO\n ENABLE_CODE_COVERAGE = NO\n ENABLE_DEBUG_DYLIB = NO\n ENABLE_PREVIEWS = NO\n ENABLE_USER_SCRIPT_SANDBOXING = NO\n ONLY_ACTIVE_ARCH = YES\n OTHER_SWIFT_FLAGS = $(inherited) -disable-sandbox\n\nResolve Package Graph\n\n\nResolved source packages:\n agent-device-snapshot-presentation: /Users/thymikee/Developer/agent-device/.claude/worktrees/impl-2935b/apple/snapshot-presentation @ local\n\n2026-09-25 14:05:36.652 xcodebuild[34716:166287301] Writing error result bundle to /var/folders/65/fz9_2bsj6fzgct46vx226s8c0000gn/T/ResultBundle_2026-25-09_14-05-0036.xcresult\nxcodebuild: error: Unable to find a device matching the provided destination specifier:\n\t\t{ platform:iOS Simulator, id:9DD70D58-C485-4CE5-8743-5FED48468446 }\n\n\tThe requested device could not be found because no available devices matched the request.\n\n\tAvailable destinations for the "AgentDeviceRunner" scheme:\n\t\t{ platform:iOS, id:dvtdevice-DVTiPhonePlaceholder-iphoneos:placeholder, name:Any iOS Device }\n\t\t{ platform:iOS Simulator, id:dvtdevice-DVTiOSDeviceSimulatorPlaceholder-iphonesimulator:placeholder, name:Any iOS Simulator Device }\n\t\t{ platform:macOS, name:Any Mac }\n\t\t{ platform:tvOS, id:dvtdevice-DVTiOSDevicePlaceholder-appletvos:placeholder, name:Any tvOS Device }\n\t\t{ platform:tvOS Simulator, id:dvtdevice-DVTiOSDeviceSimulatorPlaceholder-appletvsimulator:placeholder, name:Any tvOS Simulator Device }\n\t\t{ platform:visionOS, id:dvtdevice-DVTiOSDevicePlaceholder-xros:placeholder, name:Any visionOS Device }\n\t\t{ platform:visionOS Simulator, id:dvtdevice-DVTiOSDeviceSimulatorPlaceholder-xrsimulator:placeholder, name:Any visionOS Simulator Device }\n', + device: { + ...IOS_SIMULATOR, + id: CAPTURED_SCOPED_SIMULATOR.udid, + simulatorSetPath: CAPTURED_SCOPED_SIMULATOR.setWithoutUdid, + }, + note: "Captured by running the runner build argv agent-device records in runner.log, with `-DVTSimulatorSetLocation` naming a set that does not hold the udid: what a simulator deleted from its scoped set, or an Xcode that stops honoring the key, leaves xcodebuild to report. Destination lines naming this host's Mac, paired iPhone and default-set simulators are left out; the rest is verbatim.", + }, + { + id: 'default-set-destination-not-found', + reason: 'build_failed_unclassified', + site: 'build-for-testing', + command: + '/Applications/Xcode-26.2.0.app/Contents/Developer/usr/bin/xcodebuild build-for-testing -project /Users/thymikee/Developer/agent-device/.claude/worktrees/impl-2935b/apple/runner/AgentDeviceRunner/AgentDeviceRunner.xcodeproj -scheme AgentDeviceRunner -parallel-testing-enabled NO -maximum-concurrent-test-simulator-destinations 1 -destination "platform=iOS Simulator,id=9DD70D58-C485-4CE5-8743-5FED48468446" -derivedDataPath /Users/thymikee/Developer/agent-device/.claude/worktrees/_scratch/2963-capture-derived COMPILER_INDEX_STORE_ENABLE=NO ENABLE_CODE_COVERAGE=NO ONLY_ACTIVE_ARCH=YES ENABLE_PREVIEWS=NO ENABLE_DEBUG_DYLIB=NO -IDEPackageSupportDisableManifestSandbox=1 -IDEPackageSupportDisablePluginExecutionSandbox=1 ENABLE_USER_SCRIPT_SANDBOXING=NO "OTHER_SWIFT_FLAGS=$(inherited) -disable-sandbox" AGENT_DEVICE_IOS_RUNNER_APP_BUNDLE_ID=com.callstack.agentdevice.runner AGENT_DEVICE_IOS_RUNNER_TEST_BUNDLE_ID=com.callstack.agentdevice.runner.uitests', + xcodeVersion: CAPTURED_DESTINATION_XCODE_VERSION, + provenance: 'captured', + output: + 'Command line invocation:\n /Applications/Xcode-26.2.0.app/Contents/Developer/usr/bin/xcodebuild build-for-testing -project /Users/thymikee/Developer/agent-device/.claude/worktrees/impl-2935b/apple/runner/AgentDeviceRunner/AgentDeviceRunner.xcodeproj -scheme AgentDeviceRunner -parallel-testing-enabled NO -maximum-concurrent-test-simulator-destinations 1 -destination "platform=iOS Simulator,id=9DD70D58-C485-4CE5-8743-5FED48468446" -derivedDataPath /Users/thymikee/Developer/agent-device/.claude/worktrees/_scratch/2963-capture-derived COMPILER_INDEX_STORE_ENABLE=NO ENABLE_CODE_COVERAGE=NO ONLY_ACTIVE_ARCH=YES ENABLE_PREVIEWS=NO ENABLE_DEBUG_DYLIB=NO -IDEPackageSupportDisableManifestSandbox=1 -IDEPackageSupportDisablePluginExecutionSandbox=1 ENABLE_USER_SCRIPT_SANDBOXING=NO "OTHER_SWIFT_FLAGS=$(inherited) -disable-sandbox" AGENT_DEVICE_IOS_RUNNER_APP_BUNDLE_ID=com.callstack.agentdevice.runner AGENT_DEVICE_IOS_RUNNER_TEST_BUNDLE_ID=com.callstack.agentdevice.runner.uitests\n\nBuild settings from command line:\n AGENT_DEVICE_IOS_RUNNER_APP_BUNDLE_ID = com.callstack.agentdevice.runner\n AGENT_DEVICE_IOS_RUNNER_TEST_BUNDLE_ID = com.callstack.agentdevice.runner.uitests\n COMPILER_INDEX_STORE_ENABLE = NO\n ENABLE_CODE_COVERAGE = NO\n ENABLE_DEBUG_DYLIB = NO\n ENABLE_PREVIEWS = NO\n ENABLE_USER_SCRIPT_SANDBOXING = NO\n ONLY_ACTIVE_ARCH = YES\n OTHER_SWIFT_FLAGS = $(inherited) -disable-sandbox\n\nResolve Package Graph\n\n\nResolved source packages:\n agent-device-snapshot-presentation: /Users/thymikee/Developer/agent-device/.claude/worktrees/impl-2935b/apple/snapshot-presentation @ local\n\n2026-09-25 14:04:35.750 xcodebuild[28777:166276731] Writing error result bundle to /var/folders/65/fz9_2bsj6fzgct46vx226s8c0000gn/T/ResultBundle_2026-25-09_14-04-0035.xcresult\nxcodebuild: error: Unable to find a device matching the provided destination specifier:\n\t\t{ platform:iOS Simulator, id:9DD70D58-C485-4CE5-8743-5FED48468446 }\n\n\tThe requested device could not be found because no available devices matched the request.\n\n\tAvailable destinations for the "AgentDeviceRunner" scheme:\n\t\t{ platform:iOS, id:dvtdevice-DVTiPhonePlaceholder-iphoneos:placeholder, name:Any iOS Device }\n\t\t{ platform:iOS Simulator, id:dvtdevice-DVTiOSDeviceSimulatorPlaceholder-iphonesimulator:placeholder, name:Any iOS Simulator Device }\n\t\t{ platform:macOS, name:Any Mac }\n\t\t{ platform:tvOS, id:dvtdevice-DVTiOSDevicePlaceholder-appletvos:placeholder, name:Any tvOS Device }\n\t\t{ platform:tvOS Simulator, id:dvtdevice-DVTiOSDeviceSimulatorPlaceholder-appletvsimulator:placeholder, name:Any tvOS Simulator Device }\n\t\t{ platform:visionOS, id:dvtdevice-DVTiOSDevicePlaceholder-xros:placeholder, name:Any visionOS Device }\n\t\t{ platform:visionOS Simulator, id:dvtdevice-DVTiOSDeviceSimulatorPlaceholder-xrsimulator:placeholder, name:Any visionOS Simulator Device }\n', + device: { ...IOS_SIMULATOR, id: CAPTURED_SCOPED_SIMULATOR.udid }, + note: "Captured with the same argv and no `-DVTSimulatorSetLocation`, so xcodebuild looked in the default set, which does not hold the udid. The text is the scoped row's, word for word: only the scoped set path on the device tells the two apart. Host destination lines are left out as above.", + }, // Narrowed profile rows (#2688 review): each of these requires the profile AND the complaint Xcode // attaches to it. The bare phrase alone was the shipped sniffer's trigger and is not evidence, so the // negative entry below is what keeps those rows honest. diff --git a/packages/platform-apple/src/runner/__tests__/runner-startup-failure-reasons.test.ts b/packages/platform-apple/src/runner/__tests__/runner-startup-failure-reasons.test.ts index 94a26223e6..10c8b29c6c 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-startup-failure-reasons.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-startup-failure-reasons.test.ts @@ -9,6 +9,7 @@ import { IOS_DEVICE_DEVELOPER_MODE_OFF_HINT, } from '../../core/devicectl.ts'; import { appleRunnerTestHost } from '../test-host.ts'; +import type { DeviceInfo } from '@agent-device/kernel/device'; import type { ExecResult } from '@agent-device/host-kit/command'; import { createRunnerPhaseBudget, ensureXctestrunArtifact } from '../runner-xctestrun.ts'; import { @@ -21,9 +22,10 @@ import { type RunnerStartupFailureReason, } from '../runner-error-classification.ts'; import { assertDevToolsSecurityForIosRunner } from '../runner-dev-tools-security.ts'; -import { appleToolchainProbeResult } from './apple-toolchain-fixtures.ts'; +import { appleToolchainProbeResult, STUBBED_APPLE_TOOLCHAIN } from './apple-toolchain-fixtures.ts'; import { IOS_DEVICE } from './device-fixtures.ts'; import { + CAPTURED_SCOPED_SIMULATOR, RUNNER_STARTUP_FAILURE_FIXTURES, buildFixtureById, buildForTestingExecFailure, @@ -59,6 +61,7 @@ const HINT_FOR_REASON: Record = { signing_provisioning_profile_missing: 'AGENT_DEVICE_IOS_PROVISIONING_PROFILE', signing_unspecified: 'Automatic Signing', devtools_security_developer_mode_disabled: 'DevToolsSecurity -enable', + simulator_set_destination_not_found: '-DVTSimulatorSetLocation', // Both device remedies are owned by `core/devicectl.ts` and travel on the device report, so this // table quotes them instead of restating them; `runner-device-readiness.test.ts` is where the // preflight publishing them is asserted. @@ -171,7 +174,7 @@ function assertFailureEnvelope( fixture: RunnerStartupFailureFixture, ): void { assert.equal(envelope.code, 'COMMAND_FAILED'); - assert.equal(envelope.message, 'xcodebuild build-for-testing failed'); + assert.ok(envelope.message.startsWith('xcodebuild build-for-testing failed')); assert.equal(envelope.details?.reason, fixture.reason); assert.ok( String(envelope.hint).includes(HINT_FOR_REASON[fixture.reason]), @@ -235,6 +238,35 @@ test('every reason the classifier can name is produced by a rule row', () => { } }); +test('a scoped-set simulator xcodebuild cannot find names its set and the Xcode', async () => { + const { udid, setWithoutUdid } = CAPTURED_SCOPED_SIMULATOR; + const envelope = await driveBuildFailure(buildFixtureById('scoped-set-destination-not-found')); + + assert.equal(envelope.details?.reason, 'simulator_set_destination_not_found'); + assert.equal(envelope.details?.simulatorSetPath, setWithoutUdid); + assert.equal(envelope.details?.xcodeVersion, STUBBED_APPLE_TOOLCHAIN.xcodeVersion); + assert.equal( + envelope.message, + `xcodebuild build-for-testing failed: xcodebuild found no simulator ${udid} in simulator set ${setWithoutUdid} with Xcode ${STUBBED_APPLE_TOOLCHAIN.xcodeVersion}`, + ); + const buildArgs = runCmdStreaming.mock.calls[0]?.[1] as string[]; + assert.ok(buildArgs.includes(`-DVTSimulatorSetLocation=${setWithoutUdid}`)); +}); + +test('a default-set simulator xcodebuild cannot find names no simulator set', async () => { + const envelope = await driveBuildFailure(buildFixtureById('default-set-destination-not-found')); + + assert.equal(envelope.details?.reason, RUNNER_STARTUP_FAILURE_UNCLASSIFIED_REASON); + assert.equal(envelope.details?.simulatorSetPath, undefined); + assert.equal(envelope.message, 'xcodebuild build-for-testing failed'); + assert.doesNotMatch(String(envelope.hint), /DVTSimulatorSetLocation/); + const buildArgs = runCmdStreaming.mock.calls[0]?.[1] as string[]; + assert.equal( + buildArgs.some((arg) => arg.startsWith('-DVTSimulatorSetLocation')), + false, + ); +}); + test('an argv that names a provisioning profile is not evidence of a signing failure', async () => { // The exec reports the invocation we asked for in `details.args`. Reading the whole details bag // would let a caller's own pinned profile name the cause of an unrelated compile error and take @@ -322,7 +354,7 @@ test('a conflicting-settings failure is not answered with missing-profile advice * either. */ async function driveBuildFailure(fixture: RunnerStartupFailureFixture): Promise { - const thrown = await runBuildCatch(() => buildForTestingExecFailure(fixture)); + const thrown = await runBuildCatch(() => buildForTestingExecFailure(fixture), fixture.device); return normalizeThrown( enrichRunnerStartupFailureWithDeviceStates(thrown, deviceStatesOf(fixture)), ); @@ -343,7 +375,10 @@ async function driveBuildRejection(rejection: unknown): Promise return normalizeThrown(await runBuildCatch(() => rejection)); } -async function runBuildCatch(buildRejection: () => unknown): Promise { +async function runBuildCatch( + buildRejection: () => unknown, + device: DeviceInfo = IOS_DEVICE, +): Promise { runCmdStreaming.mockReset().mockImplementation(async () => { throw buildRejection(); }); @@ -351,7 +386,7 @@ async function runBuildCatch(buildRejection: () => unknown): Promise { let caught: unknown; await assert.rejects( () => - ensureXctestrunArtifact(IOS_DEVICE, { + ensureXctestrunArtifact(device, { logPath, budget: createRunnerPhaseBudget(120_000, undefined), }), diff --git a/packages/platform-apple/src/runner/host.ts b/packages/platform-apple/src/runner/host.ts index 1c5c9bbb60..0c93a9acc4 100644 --- a/packages/platform-apple/src/runner/host.ts +++ b/packages/platform-apple/src/runner/host.ts @@ -5,7 +5,6 @@ import type * as HostProcess from '@agent-device/host-kit/process'; import type * as HostRequest from '@agent-device/host-kit/request'; import type * as HostRetry from '@agent-device/host-kit/retry'; import type * as HostVersion from '@agent-device/host-kit/version'; -import type * as KernelDeviceIsolation from '@agent-device/kernel/device-isolation'; import type * as KernelDeviceShell from '@agent-device/kernel/device-shell'; import type * as KernelKeyedLock from '@agent-device/kernel/keyed-lock'; import type * as KernelRecord from '@agent-device/kernel/record'; @@ -78,11 +77,10 @@ export type AppleRunnerHost = Pick< Pick & Pick & Pick & - Pick & Pick & Pick & Pick & - Pick & + Pick & Pick & { /** * The `Deadline` constructor is a class static, so the port carries the factory alone, typed @@ -178,13 +176,13 @@ export const withKeyedLock = delegate('withKeyedLock'); export const createTtlMemo = delegate('createTtlMemo'); export const isRecord = delegate('isRecord'); export const parseBooleanLiteral = delegate('parseBooleanLiteral'); -export const resolveIosSimulatorDeviceSetPath = delegate('resolveIosSimulatorDeviceSetPath'); export const classifyBootFailure = delegate('classifyBootFailure'); export const bootFailureHint = delegate('bootFailureHint'); export const runAppleToolCommand = delegate('runAppleToolCommand'); export const runXcrun = delegate('runXcrun'); export const readApplePlistJson = delegate('readApplePlistJson'); export const buildSimctlArgsForDevice = delegate('buildSimctlArgsForDevice'); +export const simulatorAddressFor = delegate('simulatorAddressFor'); export const visitXmlPlistEntries = delegate('visitXmlPlistEntries'); export const resolveIosPhysicalDeviceControl = delegate('resolveIosPhysicalDeviceControl'); export const leaseOwnerStateDir = delegate('leaseOwnerStateDir'); diff --git a/packages/platform-apple/src/runner/runner-adoption.ts b/packages/platform-apple/src/runner/runner-adoption.ts index e76df1c702..763549dabd 100644 --- a/packages/platform-apple/src/runner/runner-adoption.ts +++ b/packages/platform-apple/src/runner/runner-adoption.ts @@ -1,10 +1,5 @@ import path from 'node:path'; -import { - resolveIosSimulatorDeviceSetPath, - emitDiagnostic, - isProcessAlive, - parseBooleanLiteral, -} from './host.ts'; +import { emitDiagnostic, isProcessAlive, parseBooleanLiteral } from './host.ts'; import type { ExecResult } from '@agent-device/host-kit/command'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { isRequestCanceledError } from '@agent-device/kernel/errors'; @@ -14,6 +9,7 @@ import { type RunnerHandoffRefusal, } from './apple-runner-platform.ts'; import { sendRunnerCommandOnce } from './runner-transport.ts'; +import { runnerSimulatorSetPath } from './runner-device-set.ts'; import { decodeRunnerResponseBody, isRunnerResponseOk, @@ -64,9 +60,10 @@ export function isIosRunnerDetachEnabled(env: NodeJS.ProcessEnv = process.env): type RunnerAdoptionRefusal = | RunnerHandoffRefusal - | 'simulator_set_redirect' | 'lease_absent' | RunnerLeaseAdoptionRefusal + /** The leased runner serves a simulator with this udid in another simulator set. */ + | 'simulator_set_mismatch' | 'session_identity_mismatch' | 'runner_pid_missing' | 'runner_process_dead' @@ -112,19 +109,21 @@ export async function tryAdoptRunnerSessionFromLease( }); return null; }; - const eligible = resolveHandoffLane(device, target); - if ('refusal' in eligible) return skip(eligible.refusal); + if (!target.handoff) return skip(target.reason); const leaseVerdict = readRunnerLeaseForAdoption(device.id); if (leaseVerdict.type === 'absent') return skip('lease_absent'); if (leaseVerdict.type === 'refused') return skip(leaseVerdict.reason, leaseVerdict.lease); const lease = leaseVerdict.lease; + if (lease.simulatorSetPath !== runnerSimulatorSetPath(device)) { + return skip('simulator_set_mismatch', lease); + } const leased = verifyLeasedRunnerProcess(lease, options.expectedRunnerSessionId); if ('refusal' in leased) return skip(leased.refusal, lease); const fingerprint = verifyLeaseArtifactFingerprint(device, lease, options.budget); if ('refusal' in fingerprint) return skip(fingerprint.refusal, lease); const runnerPid = leased.value; const expectedDerived = fingerprint.value; - const probe = await probeRunnerAnswersUptime(device, lease.port, eligible.value, options.budget); + const probe = await probeRunnerAnswersUptime(device, lease.port, target.lane, options.budget); if (probe !== 'answered') return skip(probe, lease); // The probe awaited network I/O — the xcodebuild can have exited and its pid // been recycled while the old port still answers. Re-verify before the @@ -144,7 +143,7 @@ export async function tryAdoptRunnerSessionFromLease( phase: 'ios_runner_lease_adopted', data: { deviceId: device.id, - lane: eligible.value, + lane: target.lane, sessionId: session.sessionId, runnerPid, port: lease.port, @@ -157,24 +156,6 @@ export async function tryAdoptRunnerSessionFromLease( /** A guard group's verdict: the value adoption needs next, or the typed reason it stopped. */ type RunnerAdoptionCheck = { value: Value } | { refusal: RunnerAdoptionRefusal }; -/** - * Which lane this device hands a runner through, if it may hand one across daemons at all. Answers it - * before any lease is read, so an ineligible device never touches lease state. - */ -function resolveHandoffLane( - device: DeviceInfo, - target: ReturnType, -): RunnerAdoptionCheck { - if (!target.handoff) return { refusal: target.reason }; - // Custom simulator sets run behind the XCTestDevices redirect, whose - // symlink+lock lifetime is bound to the owning session and cannot be - // carried across daemons; scoped-set runners always restart fresh. - if (target.lane === 'simulator' && resolveIosSimulatorDeviceSetPath(device.simulatorSetPath)) { - return { refusal: 'simulator_set_redirect' }; - } - return { value: target.lane }; -} - /** * Whether the leased pid is a runner this daemon may take over, and the one adoption will adopt. * The adopted session later signals this pid on disposal — and adoption re-stamps the lease with the @@ -329,7 +310,7 @@ function buildAdoptedRunnerSession( requireRunnerPhaseRemainingMs(options.budget, 'runner_session_adoption'), ), lease: buildRunnerLease({ - deviceId: device.id, + device, sessionId, runnerPid, port: lease.port, diff --git a/packages/platform-apple/src/runner/runner-artifact.ts b/packages/platform-apple/src/runner/runner-artifact.ts index ba7cff5734..9b724b57c8 100644 --- a/packages/platform-apple/src/runner/runner-artifact.ts +++ b/packages/platform-apple/src/runner/runner-artifact.ts @@ -15,7 +15,11 @@ import type { ExecBackgroundResult } from '@agent-device/host-kit/command'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { classifyRunnerStartupFailure } from './runner-error-classification.ts'; import { logChunk } from './runner-io.ts'; -import { withXcodebuildSimulatorSetRedirect } from './runner-device-set.ts'; +import { + runnerSimulatorSetFailureDetails, + simulatorSetDestinationNotFoundMessage, + xcodebuildDestinationArgs, +} from './runner-device-set.ts'; import { acquireRunnerXctestrunCacheLock, assertSafeDerivedCleanup, @@ -472,61 +476,68 @@ async function buildRunnerXctestrun( const provisioningArgs = device.kind === 'device' ? ['-allowProvisioningUpdates'] : []; const performanceBuildSettings = resolveRunnerPerformanceBuildSettings(); const sandboxBuildArgs = resolveRunnerSandboxBuildArgs(); - await withXcodebuildSimulatorSetRedirect(device, async () => { - try { - await runCmdStreaming( - 'xcodebuild', - [ - 'build-for-testing', - '-project', - projectPath, - '-scheme', - 'AgentDeviceRunner', - '-parallel-testing-enabled', - 'NO', - resolveRunnerMaxConcurrentDestinationsFlag(device), - '1', - '-destination', - resolveRunnerBuildDestination(device), - '-derivedDataPath', - derived, - ...performanceBuildSettings, - ...sandboxBuildArgs, - ...runnerBundleBuildSettings, - ...provisioningArgs, - ...signingBuildSettings, - ], - { - detached: true, - timeoutMs: buildTimeoutMs, - signal: options.budget?.signal, - onSpawn: (child) => { - runnerPrepProcesses.add(child); - child.on('close', () => { - runnerPrepProcesses.delete(child); - }); - }, - onStdoutChunk: (chunk) => { - logChunk(chunk, options.logPath, options.traceLogPath, options.verbose); - }, - onStderrChunk: (chunk) => { - logChunk(chunk, options.logPath, options.traceLogPath, options.verbose); - }, + try { + await runCmdStreaming( + 'xcodebuild', + [ + 'build-for-testing', + '-project', + projectPath, + '-scheme', + 'AgentDeviceRunner', + '-parallel-testing-enabled', + 'NO', + resolveRunnerMaxConcurrentDestinationsFlag(device), + '1', + ...xcodebuildDestinationArgs(device, resolveRunnerBuildDestination(device)), + '-derivedDataPath', + derived, + ...performanceBuildSettings, + ...sandboxBuildArgs, + ...runnerBundleBuildSettings, + ...provisioningArgs, + ...signingBuildSettings, + ], + { + detached: true, + timeoutMs: buildTimeoutMs, + signal: options.budget?.signal, + onSpawn: (child) => { + runnerPrepProcesses.add(child); + child.on('close', () => { + runnerPrepProcesses.delete(child); + }); }, - ); - } catch (error) { - if (isRequestCanceledError(error)) throw error; - const appErr = - error instanceof AppError ? error : new AppError('COMMAND_FAILED', String(error)); - // The reason and the hint beside it come from one classifier (#2680), so the reason a caller - // switches on can never disagree with the advice it is handed. - const { reason, hint, matched } = classifyRunnerStartupFailure(appErr); - const hostDeadlineHit = isCommandTimeoutError(appErr); - // `startupRuleMatched` travels with the verdict: this wrapper buries the tool's text a level too - // deep for the rows to read again, and whether a row spoke is not recoverable from the reason - // alone (#2690 review). The device's own state is attached further out, by the startup catch that - // can see this build and the launch after it. - throw new AppError('COMMAND_FAILED', 'xcodebuild build-for-testing failed', { + onStdoutChunk: (chunk) => { + logChunk(chunk, options.logPath, options.traceLogPath, options.verbose); + }, + onStderrChunk: (chunk) => { + logChunk(chunk, options.logPath, options.traceLogPath, options.verbose); + }, + }, + ); + } catch (error) { + if (isRequestCanceledError(error)) throw error; + const appErr = + error instanceof AppError ? error : new AppError('COMMAND_FAILED', String(error)); + const simulatorSet = runnerSimulatorSetFailureDetails(device); + // The reason and the hint beside it come from one classifier (#2680), so the reason a caller + // switches on can never disagree with the advice it is handed. + const { reason, hint, matched } = classifyRunnerStartupFailure( + new AppError(appErr.code, appErr.message, { ...appErr.details, ...simulatorSet }), + ); + const hostDeadlineHit = isCommandTimeoutError(appErr); + // `startupRuleMatched` travels with the verdict: this wrapper buries the tool's text a level too + // deep for the rows to read again, and whether a row spoke is not recoverable from the reason + // alone (#2690 review). The device's own state is attached further out, by the startup catch that + // can see this build and the launch after it. + const message = 'xcodebuild build-for-testing failed'; + throw new AppError( + 'COMMAND_FAILED', + reason === 'simulator_set_destination_not_found' + ? simulatorSetDestinationNotFoundMessage(message, device, simulatorSet) + : message, + { reason, error: appErr.message, details: appErr.details, @@ -534,7 +545,8 @@ async function buildRunnerXctestrun( hint, startupRuleMatched: matched, startupHostDeadlineHit: hostDeadlineHit, - }); - } - }); + ...simulatorSet, + }, + ); + } } diff --git a/packages/platform-apple/src/runner/runner-cache-metadata.ts b/packages/platform-apple/src/runner/runner-cache-metadata.ts index cdf874e44a..af48309f62 100644 --- a/packages/platform-apple/src/runner/runner-cache-metadata.ts +++ b/packages/platform-apple/src/runner/runner-cache-metadata.ts @@ -38,6 +38,8 @@ const RUNNER_CACHE_METADATA_VALUE_MAX_LENGTH = 300; * probe, its warm retry, and the two probes still to run (#2422). */ const TOOLCHAIN_FINGERPRINT_BUDGET_MS = 45_000; +/** What naming the Xcode in a failure report may spend when the fingerprint is not memoized yet. */ +const XCODE_VERSION_REPORT_BUDGET_MS = 5_000; const TOOLCHAIN_PROBE_MAX_BUFFER = 128 * 1024; const TOOLCHAIN_PROBE_DETAIL_MAX_LENGTH = 200; const TOOLCHAIN_PROBE_HINT = @@ -276,6 +278,22 @@ function requireRunnerToolchainFingerprint( return fingerprint.value; } +/** + * The selected Xcode's version, for a failure report that names it. Reads the fingerprint the cache + * decision already memoized; a cold or unreadable toolchain answers undefined within + * {@link XCODE_VERSION_REPORT_BUDGET_MS} instead of delaying the failure it describes. + */ +export function readRunnerXcodeVersion(device: DeviceInfo): string | undefined { + try { + return requireRunnerToolchainFingerprint( + resolveRunnerSdkName(resolveRunnerPlatformName(device), device.kind), + createRunnerPhaseBudget(XCODE_VERSION_REPORT_BUDGET_MS, undefined), + ).xcodeVersion; + } catch { + return undefined; + } +} + function readRunnerToolchainFingerprint( sdkName: string, clock: ToolchainProbeClock, diff --git a/packages/platform-apple/src/runner/runner-device-set.ts b/packages/platform-apple/src/runner/runner-device-set.ts index bde300e25c..48c30b6e17 100644 --- a/packages/platform-apple/src/runner/runner-device-set.ts +++ b/packages/platform-apple/src/runner/runner-device-set.ts @@ -1,392 +1,115 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { AppError } from '@agent-device/kernel/errors'; -import { isIosFamily, type DeviceInfo } from '@agent-device/kernel/device'; -import { - resolveIosSimulatorDeviceSetPath, - emitDiagnostic, - readProcessStartTime, - acquireProcessLock, - withProcessLock, -} from './host.ts'; -import type { ProcessLockRelease } from '@agent-device/host-kit/file'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { emitDiagnostic, simulatorAddressFor } from './host.ts'; +import { readRunnerXcodeVersion } from './runner-cache-metadata.ts'; -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 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; - -export type XcodebuildSimulatorSetRedirectHandle = { - /** - * Runs the ordered give-back — restore the host's device set, then release the lock — and reports - * both failures, throwing the restore failure when there is one and the release failure otherwise. - */ - release: () => Promise; - /** - * The same ordered give-back for a caller whose own outcome is already decided — a launch that - * failed, a teardown that ran. A release that cannot verify ownership is dropped rather than - * thrown: that claim is spent, and the next reclaim from this process reads it as dead. A failure - * to restore the host's own `XCTestDevices` outranks it and is thrown all the same. - */ - releaseBestEffort: () => Promise; -}; - -type XcodebuildSimulatorSetRedirectOptions = { - xctestDeviceSetPath?: string; - backupPath?: string; - lockDirPath?: string; -}; - -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}`; -} - -/** - * Runs `task` with the XCTest device set redirected at this simulator's device set, and gives the - * redirect back on every path out. The task is what owns the redirect's lifetime here, so a build - * that failed keeps its own error and the lock it could not hand back goes to the stale-clear path - * instead of becoming the reportable failure. - */ -export async function withXcodebuildSimulatorSetRedirect( - device: DeviceInfo, - task: () => Promise, - options: XcodebuildSimulatorSetRedirectOptions = {}, -): Promise { - const redirect = await acquireXcodebuildSimulatorSetRedirect(device, options); - if (!redirect) return await task(); - return await withProcessLock({ acquire: async () => redirect.release, task }); +/** The scoped simulator set that holds this runner's simulator, or undefined for the default set. */ +export function runnerSimulatorSetPath(device: DeviceInfo): string | undefined { + return simulatorAddressFor(device).simulatorSetPath; } -export async function acquireXcodebuildSimulatorSetRedirect( - device: DeviceInfo, - options: XcodebuildSimulatorSetRedirectOptions = {}, -): Promise { - if (!isIosFamily(device) || 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 releaseLock = await acquireProcessLock({ - lockDirPath, - owner: { - pid: process.pid, - startTime: readProcessStartTime(process.pid), - acquiredAtMs: Date.now(), - }, - timeoutMs: XCTEST_DEVICE_SET_LOCK_TIMEOUT_MS, - pollMs: XCTEST_DEVICE_SET_LOCK_POLL_MS, - ownerGraceMs: XCTEST_DEVICE_SET_LOCK_OWNER_GRACE_MS, - description: 'XCTest device set lock', - }); - - const paths = { xctestDeviceSetPath, backupPath }; - let needsRedirect = false; - - // One try, so the lock cannot be given back and then worked under: the restore of an interrupted - // build's leftovers runs first because the same-set check follows symlinks, and `XCTestDevices` left - // pointing into this simulator's requested set would otherwise read as "already redirected" and hand - // the next build the host's own devices. - try { - reconcileXcodebuildSimulatorSetRedirect(paths); - needsRedirect = !sameResolvedPath(requestedSetPath, xctestDeviceSetPath); - if (needsRedirect) { - installDeviceSetRedirect(paths, requestedSetPath); - } - } catch (error) { - // Anything the hand-back could not undo travels with this report; the lock never outlives the - // failure that ends the acquire. - const handBack = await handBackDeviceSet(paths, lockDirPath, releaseLock); - throw redirectFailure(error, handBack, { requestedSetPath, ...paths }); - } - - if (!needsRedirect) { - // Nothing is displaced and the caller gets no handle: a lock this simulator never needed must not - // arrive as a redirect problem, and a host device set that could not be put back still must. - const handBack = await handBackDeviceSet(paths, lockDirPath, releaseLock); - if (handBack.restoreFailure !== null) { - throw handBack.restoreFailure; - } - return null; - } - - let givenBack = false; - const giveBack = async (reportUnverifiedRelease: boolean): Promise => { - if (givenBack) { - return; - } - givenBack = true; - const handBack = await handBackDeviceSet(paths, lockDirPath, releaseLock); - if (handBack.restoreFailure !== null) { - throw handBack.restoreFailure; - } - if (handBack.releaseFailure !== null && reportUnverifiedRelease) { - throw handBack.releaseFailure; - } - }; - return { - release: () => giveBack(true), - releaseBestEffort: () => giveBack(false), - }; -} - -/** The two paths a redirect moves around: where the host keeps its set, and where this run put it. */ -type DeviceSetPaths = { - xctestDeviceSetPath: string; - backupPath: string; -}; - -/** The rename that gives this simulator the host's slot, and the symlink that occupies it. */ -function installDeviceSetRedirect(paths: DeviceSetPaths, requestedSetPath: string): void { - fs.mkdirSync(requestedSetPath, { recursive: true }); - if (fs.existsSync(paths.xctestDeviceSetPath)) { - fs.renameSync(paths.xctestDeviceSetPath, paths.backupPath); - } - installXcodebuildSimulatorSetSymlink({ - requestedSetPath, - xctestDeviceSetPath: paths.xctestDeviceSetPath, - }); -} - -/** - * Why this redirect did not happen, plus whatever the hand-back could not put right on the way out. A - * backup path is named only when that backup is really on disk: a reader sent to restore a path that - * does not exist learns the wrong lesson from this error. - */ -function redirectFailure( - cause: unknown, - handBack: DeviceSetHandBack, - paths: DeviceSetPaths & { requestedSetPath: string }, -): AppError { - return new AppError('COMMAND_FAILED', 'Failed to redirect XCTest device set path', { - ...paths, - error: String(cause), - ...(handBack.restoreFailure === null ? {} : { restoreError: String(handBack.restoreFailure) }), - ...(handBack.renamedAsidePath === null - ? {} - : { - hint: - `The host's own device set is still renamed aside at ${handBack.renamedAsidePath}: ` + - 'restore it, or remove that path, before another runner build redirects it.', - }), - }); -} - -/** What one ordered hand-back found, with neither failure able to hide the other. */ -type DeviceSetHandBack = { - /** The host's own `XCTestDevices` could not be put back, so a symlink or nothing is in its place. */ - restoreFailure: unknown; - /** The path the host's own set is waiting at when a restore left it renamed aside, else null. */ - renamedAsidePath: string | null; - /** The lock could not be given back, or could not be verified as ours when it was. */ - releaseFailure: unknown; -}; - -/** - * The one ordered hand-back, used by every path that leaves this redirect behind: restore the host's - * own device set, then release the lock, and record whatever the release could not do. Neither step can - * hide the other, because a restore that could not run is a fact about this machine that outlives the - * request — every later `simctl` run sees the wrong devices — and the lock going back is what keeps the - * next acquire from waiting on a claim nobody is acting on. Which of the two a caller *hears* stays with - * the caller: a redirect that failed reports both, and one that merely ended reports the restore. - */ -async function handBackDeviceSet( - paths: DeviceSetPaths, - lockDirPath: string, - releaseLock: ProcessLockRelease, -): Promise { - let restoreFailure: unknown = null; - let renamedAsidePath: string | null = null; - let releaseFailure: unknown = null; - try { - // Idempotent, so an exit that already reconciled on its way to this decision pays only a look. - reconcileXcodebuildSimulatorSetRedirect(paths); - } catch (error) { - restoreFailure = error; - // Observed here, while the lock is still held and the release has not moved anything. - renamedAsidePath = findDeviceSetBackup(paths); - } - try { - await releaseLock(); - } catch (error) { - releaseFailure = error; - } - recordReleaseFailure(releaseFailure, lockDirPath); - return { restoreFailure, renamedAsidePath, releaseFailure }; +/** Whether a runner started for one device serves the other: one udid in one simulator set. */ +export function isSameRunnerSimulator(runnerDevice: DeviceInfo, device: DeviceInfo): boolean { + const runner = simulatorAddressFor(runnerDevice); + const requested = simulatorAddressFor(device); + return runner.udid === requested.udid && runner.simulatorSetPath === requested.simulatorSetPath; } /** - * Where the host's own device set sits when it is not in place: the backup this run would have written, - * or the older name an earlier version used, but only while it is really on disk. Once the host's set is - * back at its own path nothing is renamed aside, and naming a leftover backup would point a reader at a - * copy they could restore over the set in use. + * `-destination` for a runner xcodebuild phase. xcodebuild has no `--set` option: it resolves a + * simulator in a scoped set only through the `DVTSimulatorSetLocation` Xcode user default, which it + * accepts as an argument in the `-Key=value` form alone. */ -function findDeviceSetBackup(paths: DeviceSetPaths): string | null { - const { xctestDeviceSetPath, backupPath } = paths; - if (!isSymlink(xctestDeviceSetPath) && fs.existsSync(xctestDeviceSetPath)) { - // The host's own set is back where it belongs. Any backup still on disk is a leftover of an older - // interruption, and sending a reader to copy it back would overwrite the set that is in place. - return null; - } - return ( - [backupPath, ...findLegacyXcodebuildSimulatorSetBackups(backupPath)].find((candidate) => - fs.existsSync(candidate), - ) ?? null - ); +export function xcodebuildDestinationArgs(device: DeviceInfo, destination: string): string[] { + const simulatorSetPath = runnerSimulatorSetPath(device); + return simulatorSetPath === undefined + ? ['-destination', destination] + : ['-destination', destination, `-DVTSimulatorSetLocation=${simulatorSetPath}`]; } /** - * Records a lock release that no caller is being made to throw. A release whose ownership could not be - * verified is already recorded where the claim lives, so only the rest reaches the log. + * Puts the host's own `~/Library/Developer/XCTestDevices` back where an older agent-device left it + * redirected into a scoped simulator set: a symlink in its place, with the real directory renamed to + * `XCTestDevices.agent-device-backup` when one existed. Xcode's first-launch cleanup deletes every + * device in `XCTestDevices`, so a symlink left there deletes the set it points at. Any symlink there + * is removed, as released versions did on every scoped runner start; unlinking deletes no data. + * Daemons starting together may both undo it: a step the other daemon already took is done, not a + * failure. */ -function recordReleaseFailure(error: unknown, lockDirPath: string): void { - if (error === null || isOwnerReleaseUnverified(error)) { - return; - } - emitDiagnostic({ - level: 'warn', - phase: 'ios_runner_xctest_device_set_hand_back_failed', - data: { lockDirPath, error: String(error) }, - }); -} - -function isOwnerReleaseUnverified(error: unknown): boolean { - return error instanceof AppError && error.details?.ownerReleaseUnverified === true; -} - -// fallow-ignore-next-line complexity -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 xctestIsSymlink = isSymlink(xctestDeviceSetPath); - - 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; +export function restoreLegacyXctestDeviceSetRedirect( + xctestDeviceSetPath: string = path.join(os.homedir(), 'Library', 'Developer', 'XCTestDevices'), +): void { + const backupPath = `${xctestDeviceSetPath}.agent-device-backup`; + if (isSymlinkAt(xctestDeviceSetPath)) { + const linkTarget = readLinkTarget(xctestDeviceSetPath); + removeSymlinkUnlessGone(xctestDeviceSetPath); + emitDiagnostic({ + level: 'warn', + phase: 'ios_runner_legacy_xctest_device_set_link_removed', + data: { xctestDeviceSetPath, linkTarget }, + }); } - - if (xctestIsSymlink) { + if (fs.existsSync(backupPath) && !fs.existsSync(xctestDeviceSetPath)) { + restoreBackupUnlessRestored(backupPath, xctestDeviceSetPath); emitDiagnostic({ level: 'warn', - phase: 'ios_runner_xctest_device_set_orphaned_symlink', - data: { - xctestDeviceSetPath, - }, + phase: 'ios_runner_legacy_xctest_device_set_backup_restored', + data: { xctestDeviceSetPath, backupPath }, }); - 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}`; +function removeSymlinkUnlessGone(linkPath: string): void { try { - return fs - .readdirSync(parentDir) - .filter((entry) => entry.startsWith(legacyPrefix)) - .sort() - .map((entry) => path.join(parentDir, entry)); - } catch { - return []; + fs.unlinkSync(linkPath); + } catch (error) { + if (isSymlinkAt(linkPath)) throw error; } } -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 }); +function restoreBackupUnlessRestored(backupPath: string, restoredPath: string): void { try { - fs.symlinkSync(requestedSetPath, tmpSymlinkPath, 'dir'); - fs.renameSync(tmpSymlinkPath, xctestDeviceSetPath); + fs.renameSync(backupPath, restoredPath); } catch (error) { - unlinkIfSymlink(tmpSymlinkPath); - throw error; + if (fs.existsSync(backupPath) && !fs.existsSync(restoredPath)) throw error; } } -// lstat instead of existsSync: existsSync follows symlinks, so a dangling -// symlink (target deleted) would read as absent and never get cleaned up. -function isSymlink(targetPath: string): boolean { - return fs.lstatSync(targetPath, { throwIfNoEntry: false })?.isSymbolicLink() ?? false; +function isSymlinkAt(filePath: string): boolean { + return fs.lstatSync(filePath, { throwIfNoEntry: false })?.isSymbolicLink() ?? false; } -function unlinkIfSymlink(targetPath: string): void { - if (isSymlink(targetPath)) { - fs.unlinkSync(targetPath); - } -} - -function sameResolvedPath(left: string, right: string): boolean { - if (path.resolve(left) === path.resolve(right)) { - return true; - } +function readLinkTarget(linkPath: string): string | undefined { try { - return fs.realpathSync.native(left) === fs.realpathSync.native(right); + return fs.readlinkSync(linkPath); } catch { - return false; + return undefined; } } + +/** What a runner xcodebuild failure reports about the scoped set it resolved its destination in. */ +type RunnerSimulatorSetFailureDetails = { simulatorSetPath?: string; xcodeVersion?: string }; + +/** + * The scoped set a runner xcodebuild phase resolved its destination in, with the selected Xcode when + * the toolchain answers; empty for the default set. + */ +export function runnerSimulatorSetFailureDetails( + device: DeviceInfo, +): RunnerSimulatorSetFailureDetails { + const simulatorSetPath = runnerSimulatorSetPath(device); + if (simulatorSetPath === undefined) return {}; + const xcodeVersion = readRunnerXcodeVersion(device); + return xcodeVersion === undefined ? { simulatorSetPath } : { simulatorSetPath, xcodeVersion }; +} + +/** Names the simulator, the scoped set and the Xcode behind a `simulator_set_destination_not_found`. */ +export function simulatorSetDestinationNotFoundMessage( + message: string, + device: DeviceInfo, + details: RunnerSimulatorSetFailureDetails, +): string { + return `${message}: xcodebuild found no simulator ${device.id} in simulator set ${details.simulatorSetPath} with Xcode ${details.xcodeVersion ?? '(version unreadable)'}`; +} diff --git a/packages/platform-apple/src/runner/runner-disposal.ts b/packages/platform-apple/src/runner/runner-disposal.ts index 9702320626..fdfc5c08b2 100644 --- a/packages/platform-apple/src/runner/runner-disposal.ts +++ b/packages/platform-apple/src/runner/runner-disposal.ts @@ -201,8 +201,7 @@ async function cleanupRunnerSessionResources( await settleOwnedRunnerDeviceState(session, options); cleanupTempFile(session.xctestrunPath); cleanupTempFile(session.jsonPath); - await session.simulatorSetRedirect?.releaseBestEffort(); - // The session's own resources are gone: no lease, no temp files, no redirect to settle. + // The session's own resources are gone: no lease, no temp files. advanceRunnerSessionState(session, 'stopped'); } diff --git a/packages/platform-apple/src/runner/runner-error-classification.ts b/packages/platform-apple/src/runner/runner-error-classification.ts index 97dd1b393f..34ff472b59 100644 --- a/packages/platform-apple/src/runner/runner-error-classification.ts +++ b/packages/platform-apple/src/runner/runner-error-classification.ts @@ -80,6 +80,9 @@ const hasRunnerBusyCode: RunnerErrorDetailsMatch = (details) => */ const hasDevToolsSecurityStatus: RunnerErrorDetailsMatch = (details) => typeof details.devToolsSecurityStatus === 'string'; +/** The failed xcodebuild phase resolved its destination in a scoped simulator set. */ +const hasSimulatorSetPath: RunnerErrorDetailsMatch = (details) => + typeof details.simulatorSetPath === 'string'; const hasUsbmuxDeviceUnattached: RunnerErrorDetailsMatch = (details) => details.usbmuxDeviceAttached === false; const hasRunnerConnectFailureReason = @@ -157,6 +160,7 @@ export const RUNNER_STARTUP_FAILURE_REASONS = [ 'signing_provisioning_profile_missing', 'signing_unspecified', 'devtools_security_developer_mode_disabled', + 'simulator_set_destination_not_found', ...RUNNER_DEVICE_READINESS_FAILURE_REASONS, 'build_failed_unclassified', ] as const; @@ -329,6 +333,20 @@ export const RUNNER_ERROR_RULES: readonly RunnerErrorRule[] = [ // builder — it classifies a runner that DID build and then exited early, whose reason axis is the // `BootFailureReason` `classifyBootFailure` already returns, and a build that never produced a // binary has no boot to classify. + { + // A scoped set's simulator is reachable to xcodebuild only through `-DVTSimulatorSetLocation`, a + // private Xcode user default; an Xcode that stops reading it finds no simulator with that id. + reason: 'simulator_set_destination_not_found', + match: { + toolTextIncludesAll: ['matching the provided destination specifier'], + details: hasSimulatorSetPath, + }, + verdicts: {}, + buildFailure: { + reason: 'simulator_set_destination_not_found', + hint: 'Check that the simulator still exists in the --ios-simulator-device-set this error names (`xcrun simctl --set list devices`). If it does, the Xcode this error names no longer honors -DVTSimulatorSetLocation, so use a simulator in the default set.', + }, + }, { reason: 'bundle_identifier_registration_failed', match: { toolTextIncludesAll: ['failed registering bundle identifier'] }, diff --git a/packages/platform-apple/src/runner/runner-lease.ts b/packages/platform-apple/src/runner/runner-lease.ts index 3f3f683bd2..4110631f68 100644 --- a/packages/platform-apple/src/runner/runner-lease.ts +++ b/packages/platform-apple/src/runner/runner-lease.ts @@ -18,6 +18,7 @@ import { import { AppError } from '@agent-device/kernel/errors'; import type { DeviceInfo } from '@agent-device/kernel/device'; import type { RunnerLogicalLeaseContext } from '@agent-device/contracts/runner-lease-context'; +import { runnerSimulatorSetPath } from './runner-device-set.ts'; const RUNNER_LEASE_SCHEMA_VERSION = 1; const RUNNER_LEASE_LOCK_TIMEOUT_MS = 30_000; @@ -57,6 +58,11 @@ export type RunnerLease = { * Absent on leases written before the runner's stdio moved onto a file. */ runnerLogPath?: string; + /** + * The scoped simulator set that holds the leased runner's simulator; absent for the default set, + * and on leases written before a scoped-set runner could be handed off. + */ + simulatorSetPath?: string; createdAtMs: number; /** * The owner arbitrates device ownership through host-global device claims @@ -96,7 +102,7 @@ export type RunnerLeaseCleanupAdapter = { }; export function buildRunnerLease(params: { - deviceId: string; + device: DeviceInfo; sessionId: string; runnerPid: number | undefined; port: number; @@ -107,7 +113,7 @@ export function buildRunnerLease(params: { const runnerLogPath = readOptionalNonEmptyString(params.runnerLogPath); return { schemaVersion: RUNNER_LEASE_SCHEMA_VERSION, - deviceId: params.deviceId, + deviceId: params.device.id, ownerToken: runnerOwnerToken(), ownerPid: RUNNER_OWNER_PID, ownerStartTime: runnerOwnerStartTime(), @@ -119,6 +125,7 @@ export function buildRunnerLease(params: { xctestrunPath: params.xctestrunPath, jsonPath: params.jsonPath, ...(runnerLogPath ? { runnerLogPath } : {}), + ...optionalSimulatorSetPath(runnerSimulatorSetPath(params.device)), createdAtMs: Date.now(), deviceClaimProtocol: 1, }; @@ -470,6 +477,7 @@ function normalizeRunnerLease(value: unknown, deviceId: string): RunnerLease | n runnerPid: readPositiveInteger(raw.runnerPid), runnerStartTime: readOptionalString(raw.runnerStartTime), runnerLogPath: readOptionalNonEmptyString(raw.runnerLogPath), + ...optionalSimulatorSetPath(raw.simulatorSetPath), ...(raw.deviceClaimProtocol === 1 ? { deviceClaimProtocol: 1 as const } : {}), }; } @@ -498,6 +506,11 @@ function readOptionalNonEmptyString(value: unknown): string | undefined { return readNonEmptyString(value) ?? undefined; } +function optionalSimulatorSetPath(value: unknown): Pick { + const simulatorSetPath = readNonEmptyString(value); + return simulatorSetPath ? { simulatorSetPath } : {}; +} + function readOptionalString(value: unknown): string | null { return typeof value === 'string' ? value : null; } diff --git a/packages/platform-apple/src/runner/runner-process-launch.ts b/packages/platform-apple/src/runner/runner-process-launch.ts index cf95c9f966..8450942fd4 100644 --- a/packages/platform-apple/src/runner/runner-process-launch.ts +++ b/packages/platform-apple/src/runner/runner-process-launch.ts @@ -5,6 +5,7 @@ import { runCmdBackground } from './host.ts'; import type { ExecBackgroundResult } from '@agent-device/host-kit/command'; import { resolveRunnerDestination } from './apple-runner-platform.ts'; import { resolveRunnerMaxConcurrentDestinationsFlag } from './runner-cache-metadata.ts'; +import { xcodebuildDestinationArgs } from './runner-device-set.ts'; import { createRunnerLogFile, logChunk, @@ -80,8 +81,7 @@ export function launchRunnerProcess(input: LaunchRunnerProcessInput): LaunchedRu input.xctestrunPath, '-derivedDataPath', input.derivedPath, - '-destination', - resolveRunnerDestination(input.device), + ...xcodebuildDestinationArgs(input.device, resolveRunnerDestination(input.device)), ], { allowFailure: true, diff --git a/packages/platform-apple/src/runner/runner-session-types.ts b/packages/platform-apple/src/runner/runner-session-types.ts index 71c8c4c73b..793a2f2656 100644 --- a/packages/platform-apple/src/runner/runner-session-types.ts +++ b/packages/platform-apple/src/runner/runner-session-types.ts @@ -3,7 +3,6 @@ import type { ExecResult } from '@agent-device/host-kit/command'; import type { DeviceInfo } from '@agent-device/kernel/device'; import type { RunnerXctestrunArtifact } from './runner-xctestrun.ts'; import type { RunnerLease } from './runner-lease.ts'; -import type { XcodebuildSimulatorSetRedirectHandle } from './runner-device-set.ts'; import type { IosRunnerDeviceStates } from './runner-error-classification.ts'; /** @@ -125,7 +124,6 @@ export type RunnerSession = { */ startupDeviceStates?: IosRunnerDeviceStates; logicalLeaseContext?: RunnerLogicalLeaseContext; - simulatorSetRedirect?: XcodebuildSimulatorSetRedirectHandle; lease?: RunnerLease; }; diff --git a/packages/platform-apple/src/runner/runner-session.ts b/packages/platform-apple/src/runner/runner-session.ts index c8f655733b..5319e7f0e3 100644 --- a/packages/platform-apple/src/runner/runner-session.ts +++ b/packages/platform-apple/src/runner/runner-session.ts @@ -21,7 +21,6 @@ import { flushRunnerLogAppends, getFreePort, resolveRunnerLaunchLogPath } from ' import { waitForRunner, RUNNER_STARTUP_TIMEOUT_MS } from './runner-startup-transport.ts'; import { sendRunnerCommandOnce } from './runner-transport.ts'; import { - acquireXcodebuildSimulatorSetRedirect, createRunnerPhaseBudget, ensureXctestrunArtifact, IOS_RUNNER_CONTAINER_BUNDLE_IDS, @@ -90,6 +89,7 @@ import { type RunnerSessionRegistration, } from './runner-session-types.ts'; import { launchRunnerProcess, type LaunchedRunnerProcess } from './runner-process-launch.ts'; +import { isSameRunnerSimulator } from './runner-device-set.ts'; export type { RunnerSession } from './runner-session-types.ts'; @@ -247,9 +247,6 @@ async function startRunnerSessionWithLease( let port: number; let xctestrunPath: string; let jsonPath: string; - let simulatorSetRedirect: - | Awaited> - | undefined; const runnerLogPath = resolveRunnerLaunchLogPath(options.logPath, device.id); let runnerProcess: LaunchedRunnerProcess; // One catch for everything between here and a runner that answers, because the device's own answer @@ -283,11 +280,6 @@ async function startRunnerSessionWithLease( { iosXctestEnvDir: options.iosXctestEnvDir }, ), )); - simulatorSetRedirect = await measureRunnerStartupStep( - startupTimings, - 'simulator_set_redirect', - async () => await acquireXcodebuildSimulatorSetRedirect(device), - ); if (xctestrunArtifact.buildMs > 0) { emitRequestProgress({ type: 'command', @@ -316,12 +308,11 @@ async function startRunnerSessionWithLease( }, ); } catch (error) { - await simulatorSetRedirect?.releaseBestEffort(); throw enrichRunnerStartupFailureWithDeviceStates(error, deviceStates); } const sessionId = buildRunnerSessionId(device.id, port); const lease = buildRunnerLease({ - deviceId: device.id, + device, sessionId, runnerPid: runnerProcess.child.pid, port, @@ -350,7 +341,6 @@ async function startRunnerSessionWithLease( startupTimings, startupDeviceStates: deviceStates, logicalLeaseContext, - simulatorSetRedirect: simulatorSetRedirect ?? undefined, lease, speculative: options.speculative === true, }; @@ -397,11 +387,11 @@ function runnerSessionOwnershipChanged(): AppError { ); } -async function resolveReusableRunnerSession( +/** Whether a registered session can serve this device; one that cannot is stopped when it must be. */ +async function isRunnerSessionServing( device: DeviceInfo, existing: RunnerSession, - startupBudget: RunnerPhaseBudget, -): Promise { +): Promise { const liveness = readRunnerSessionLivenessFor(existing); if (liveness === 'gone') { await measureRunnerStartupStep({}, 'stop_stale_session', async () => { @@ -410,11 +400,24 @@ async function resolveReusableRunnerSession( waitTimeoutMs: RUNNER_INVALIDATE_WAIT_TIMEOUT_MS, }); }); - return null; + return false; } // A registered session already being taken down or already handed off is not usable, even when // its runner process is still there for a moment while disposal works. - if (liveness !== 'starting' && liveness !== 'ready') return null; + if (liveness !== 'starting' && liveness !== 'ready') return false; + if (isSameRunnerSimulator(existing.device, device)) return true; + await measureRunnerStartupStep({}, 'stop_other_simulator_set_session', async () => { + await stopRunnerSessionInternal(device.id, existing); + }); + return false; +} + +async function resolveReusableRunnerSession( + device: DeviceInfo, + existing: RunnerSession, + startupBudget: RunnerPhaseBudget, +): Promise { + if (!(await isRunnerSessionServing(device, existing))) return null; const existingArtifact = existing.xctestrunArtifact; if (existingArtifact?.cache === 'external') { @@ -718,7 +721,6 @@ export async function abortAllIosRunnerSessions(): Promise { type RunnerDetachSkippedReason = | RunnerHandoffRefusal | RunnerDetachRefusal - | 'simulator_set_redirect' | 'lease_absent' | 'runner_process_dead' | 'lease_write_failed'; @@ -733,9 +735,8 @@ type RunnerDetachSkippedReason = // Every gate that keeps a session on the kill path is named and reported, because a handoff that // silently declines is indistinguishable from a rebuild: the handoff lanes // (`resolveRunnerHandoffTarget`), a session that never served a command, still owes a response, or -// last reported main-thread work still draining (`resolveRunnerDetachDecision`), a scoped -// simulator-set redirect, a missing or unwritable lease, and a runner this process cannot prove -// alive. What stays in the map is torn down by `stopAllIosRunnerSessions`, which the daemon's +// last reported main-thread work still draining (`resolveRunnerDetachDecision`), a missing or +// unwritable lease, and a runner this process cannot prove alive. What stays in the map is torn down by `stopAllIosRunnerSessions`, which the daemon's // shutdown runs right after this — so a shutdown during a startup tears that runner down rather than // handing off one that never reached its listener (#2681). export async function detachIosRunnerSessionsForShutdown(): Promise { @@ -786,13 +787,6 @@ function detachRunnerSessionForShutdown( return { detached: false, lane: undefined, reason: target.reason }; } const lane = target.lane; - // CONSERVATIVE: Scoped simulator sets depend on the global XCTestDevices symlink for their - // whole runner lifetime; handoff could restore the symlink under a live runner or leak the - // redirect lock. Reachable only in the simulator lane — `acquireXcodebuildSimulatorSetRedirect` - // returns no handle for any non-simulator — so a physical handoff never waits on it. - if (session.simulatorSetRedirect) { - return { detached: false, lane, reason: 'simulator_set_redirect' }; - } const decision = resolveRunnerDetachDecision(session); if (!decision.detach) { return { detached: false, lane, reason: decision.reason }; diff --git a/packages/platform-apple/src/runner/runner-startup-transport.ts b/packages/platform-apple/src/runner/runner-startup-transport.ts index 4eb7ce8488..d816c1b3c7 100644 --- a/packages/platform-apple/src/runner/runner-startup-transport.ts +++ b/packages/platform-apple/src/runner/runner-startup-transport.ts @@ -20,6 +20,7 @@ import { type RunnerCommandRoute, } from './runner-command-route.ts'; import { + classifyRunnerStartupFailure, enrichRunnerStartupFailureWithDeviceStates, isUsbmuxDeviceUnattachedError, RUNNER_CACHE_RECOVERY_HINT, @@ -29,6 +30,10 @@ import { } from './runner-error-classification.ts'; import type { RunnerCommand } from './runner-contract.ts'; import type { RunnerSession } from './runner-session-types.ts'; +import { + runnerSimulatorSetFailureDetails, + simulatorSetDestinationNotFoundMessage, +} from './runner-device-set.ts'; import { canFallBackFromUsbmux, fetchWithTimeout, @@ -527,24 +532,38 @@ export async function buildRunnerEarlyExitError(params: { stderr: output, context: { platform: 'ios', phase: 'connect' }, }); + const simulatorSet = runnerSimulatorSetFailureDetails(session.device); + const setDestination = classifyRunnerStartupFailure( + new AppError('COMMAND_FAILED', message, { stderr: output, ...simulatorSet }), + ); + const setDestinationMissing = setDestination.reason === 'simulator_set_destination_not_found'; // exec-guard-allow: xcodebuild can exit 0 and still count as an early exit; // the trio is nested tool context under `xcodebuild`, classified into // `reason`/`hint` above — not a process-exit wrap. - const error = new AppError('COMMAND_FAILED', message, { - port, - // The quote always comes from the runner's own file, so that is the file the error has to name; - // pointing at the request's log would advertise a file that does not contain what is quoted (#2681). - logPath: session.runnerLogPath ?? logPath, - xcodebuild: { - exitCode: result.exitCode, - // One merged file since #2681: the tail is reported under `stderr`, which is where readers - // already look, next to the file it came from. - stderr: output, + const error = new AppError( + 'COMMAND_FAILED', + setDestinationMissing + ? simulatorSetDestinationNotFoundMessage(message, session.device, simulatorSet) + : message, + { + port, + // The quote always comes from the runner's own file, so that is the file the error has to name; + // pointing at the request's log would advertise a file that does not contain what is quoted (#2681). + logPath: session.runnerLogPath ?? logPath, + xcodebuild: { + exitCode: result.exitCode, + // One merged file since #2681: the tail is reported under `stderr`, which is where readers + // already look, next to the file it came from. + stderr: output, + }, + reason: setDestinationMissing ? setDestination.reason : reason, + hint: setDestinationMissing + ? setDestination.hint + : resolveRunnerEarlyExitHint(message, output, output, reason), + ...simulatorSet, + ...runnerConnectFailureDetails('xcodebuild_exited_early'), }, - reason, - hint: resolveRunnerEarlyExitHint(message, output, output, reason), - ...runnerConnectFailureDetails('xcodebuild_exited_early'), - }); + ); // The build catch is not the only way a runner stops before serving a command. A locked phone lets // the build finish and kills `xcodebuild test-without-building` instead, so nothing reaches that // catch and the disk-image state read before the build would be dropped. Same enrichment, applied diff --git a/packages/platform-apple/src/runner/runner-xctestrun.ts b/packages/platform-apple/src/runner/runner-xctestrun.ts index 41e4694db4..944fb4652d 100644 --- a/packages/platform-apple/src/runner/runner-xctestrun.ts +++ b/packages/platform-apple/src/runner/runner-xctestrun.ts @@ -20,4 +20,3 @@ export { resolveRunnerDerivedPath, type RunnerPhaseBudget, } from './runner-cache-metadata.ts'; -export { acquireXcodebuildSimulatorSetRedirect } from './runner-device-set.ts'; diff --git a/src/__tests__/cli-help.test.ts b/src/__tests__/cli-help.test.ts index 26e60605f3..289b1539f9 100644 --- a/src/__tests__/cli-help.test.ts +++ b/src/__tests__/cli-help.test.ts @@ -162,7 +162,7 @@ test('help physical-device documents the runner/daemon lifecycle detail moved ou ); assert.match( result.stdout, - /keeps a healthy iOS simulator XCTest runner warm by default so the next open on that device skips the runner build/, + /keeps a healthy iOS simulator XCTest runner warm by default so the next open on that simulator \(same udid in the same simulator set\) skips the runner build/, ); assert.match(result.stdout, /the session held a device lease/); assert.match(result.stdout, /AGENT_DEVICE_IOS_RUNNER_IDLE_STOP_MS/); diff --git a/src/commands/schema/cli-help.ts b/src/commands/schema/cli-help.ts index 444a845d1d..c3fd791ac7 100644 --- a/src/commands/schema/cli-help.ts +++ b/src/commands/schema/cli-help.ts @@ -700,7 +700,7 @@ Android physical-device prerequisites: Runner and daemon lifecycle (applies to simulators too): open without --relaunch is idempotent-foreground for an already-running app (it brings the process forward; it does not restart it). open --relaunch restarts the app; on iOS simulators this collapses to one simctl launch --terminate-running-process call instead of a separate terminate-then-launch. No runner read launches a session app that is not running: snapshot, wait, is, get, a reading find, and an interaction's leading reads (a gesture's viewport read, the capture that resolves a selector click/fill) answer the retriable APP_NOT_RUNNING instead of bare-launching over a launch SpringBoard still holds behind its deep-link confirmation. Only open, activate, and a command that mutates without a leading read bring a stopped app up. - close keeps a healthy iOS simulator XCTest runner warm by default so the next open on that device skips the runner build, unless --shutdown was requested, the session was recording, the session held a device lease, or the device used a scoped (non-default) simulator set. A retained runner auto-stops after an idle window (default 5 minutes); set AGENT_DEVICE_IOS_RUNNER_IDLE_STOP_MS to override, or 0 to disable idle stop and retain until daemon exit. + close keeps a healthy iOS simulator XCTest runner warm by default so the next open on that simulator (same udid in the same simulator set) skips the runner build, unless --shutdown was requested, the session was recording, or the session held a device lease. A retained runner auto-stops after an idle window (default 5 minutes); set AGENT_DEVICE_IOS_RUNNER_IDLE_STOP_MS to override, or 0 to disable idle stop and retain until daemon exit. Each AGENT_DEVICE_STATE_DIR runs its own daemon. It self-exits after an idle window (default 5 minutes, matching the runner idle-stop default) once it has no open sessions, no in-flight requests, and no active recording; set AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS to override, or 0 to disable idle reap. A stale iOS runner lease — its owner process dead, or its AGENT_DEVICE_STATE_DIR deleted — is reclaimed automatically instead of failing with "is already owned by another agent-device daemon". A live owner's runner is also reclaimed when the requesting daemon holds the host-global device claim for that device: claims are exclusive, so holding one proves the runner's owner released the device and merely kept the runner warm. The error remains only for owners outside claim arbitration (a pre-claims build, or daemons pointed at different claim stores). diff --git a/src/daemon/handlers/__tests__/session-relaunch-close.test.ts b/src/daemon/handlers/__tests__/session-relaunch-close.test.ts index 2b71e6c3f9..0ee75ae19a 100644 --- a/src/daemon/handlers/__tests__/session-relaunch-close.test.ts +++ b/src/daemon/handlers/__tests__/session-relaunch-close.test.ts @@ -564,7 +564,7 @@ test('close on iOS simulator session retains runner and deletes the session', as expect(sessionStore.get(sessionName)).toBeUndefined(); }); -test('close on iOS simulator with scoped simulator set stops runner before deleting session', async () => { +test('close on iOS simulator with scoped simulator set retains runner and deletes the session', async () => { const sessionStore = makeSessionStore('agent-device-relaunch-close-'); const sessionName = 'ios-scoped-simulator-session'; sessionStore.set( @@ -585,7 +585,7 @@ test('close on iOS simulator with scoped simulator set stops runner before delet const response = await createHandler(sessionStore)(sessionRequest(sessionName, 'close')); expect(response.ok).toBe(true); - expect(mockReleaseRunnerOnClose).toHaveBeenCalledWith('sim-1', { retain: false }); + expect(mockReleaseRunnerOnClose).toHaveBeenCalledWith('sim-1', { retain: true }); expect(sessionStore.get(sessionName)).toBeUndefined(); }); diff --git a/src/daemon/platform-owner-lifecycle.ts b/src/daemon/platform-owner-lifecycle.ts index cee91f3330..faf4356d9c 100644 --- a/src/daemon/platform-owner-lifecycle.ts +++ b/src/daemon/platform-owner-lifecycle.ts @@ -40,6 +40,11 @@ export type PlatformOwnerLifecycle = Readonly<{ ownedProcessRecords?: OwnedProcessRecordStore; }>, ): Promise; + /** + * Startup, after daemon.log publication: puts back the host's own `XCTestDevices` directory where + * an older agent-device left it redirected into a scoped simulator set. + */ + restoreLegacyXctestDeviceSetRedirect(): Promise; /** Shutdown: resets Android snapshot-helper runtime sessions. */ resetAndroidSnapshotHelper(): Promise; }>; diff --git a/src/daemon/server/daemon-runtime-xctest-device-set.test.ts b/src/daemon/server/daemon-runtime-xctest-device-set.test.ts new file mode 100644 index 0000000000..f3f99dc146 --- /dev/null +++ b/src/daemon/server/daemon-runtime-xctest-device-set.test.ts @@ -0,0 +1,75 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { afterEach, expect, test, vi } from 'vitest'; +import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; +import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts'; + +const { mockRestoreLegacyXctestDeviceSetRedirect } = vi.hoisted(() => ({ + mockRestoreLegacyXctestDeviceSetRedirect: vi.fn(), +})); + +vi.mock('../../platform-runtime-daemon-lifecycle.ts', async (importOriginal) => { + const actual = + await importOriginal(); + return { + platformDaemonLifecycleOwners: { + ...actual.platformDaemonLifecycleOwners, + restoreLegacyXctestDeviceSetRedirect: mockRestoreLegacyXctestDeviceSetRedirect, + }, + }; +}); + +import { restoreLegacyXctestDeviceSetForDaemonStartup } from './daemon-runtime.ts'; + +const roots: string[] = []; + +afterEach(() => { + mockRestoreLegacyXctestDeviceSetRedirect.mockReset(); + for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); +}); + +function daemonLogPath(): string { + const root = mkdtempForTestSync('agent-device-daemon-xctest-device-set-'); + roots.push(root); + return path.join(root, 'daemon.log'); +} + +function loggedPhases(logPath: string): string[] { + if (!fs.existsSync(logPath)) return []; + return fs + .readFileSync(logPath, 'utf8') + .trim() + .split('\n') + .map((line) => (JSON.parse(line) as { phase: string }).phase); +} + +test('what the restore puts back is recorded in daemon.log', async () => { + const logPath = daemonLogPath(); + mockRestoreLegacyXctestDeviceSetRedirect.mockImplementationOnce(async () => { + emitDiagnostic({ level: 'warn', phase: 'ios_runner_legacy_xctest_device_set_link_removed' }); + }); + + await restoreLegacyXctestDeviceSetForDaemonStartup(logPath); + + expect(loggedPhases(logPath)).toEqual(['ios_runner_legacy_xctest_device_set_link_removed']); +}); + +test('a restore that fails is recorded in daemon.log and does not fail the daemon startup', async () => { + const logPath = daemonLogPath(); + mockRestoreLegacyXctestDeviceSetRedirect.mockRejectedValueOnce( + Object.assign(new Error('EACCES: permission denied'), { code: 'EACCES' }), + ); + + await expect(restoreLegacyXctestDeviceSetForDaemonStartup(logPath)).resolves.toBeUndefined(); + + expect(loggedPhases(logPath)).toEqual(['ios_runner_legacy_xctest_device_set_restore_failed']); +}); + +test('a host with nothing to put back leaves daemon.log untouched', async () => { + const logPath = daemonLogPath(); + mockRestoreLegacyXctestDeviceSetRedirect.mockResolvedValueOnce(undefined); + + await restoreLegacyXctestDeviceSetForDaemonStartup(logPath); + + expect(fs.existsSync(logPath)).toBe(false); +}); diff --git a/src/daemon/server/daemon-runtime.ts b/src/daemon/server/daemon-runtime.ts index c5edcb3e32..aca7766de1 100644 --- a/src/daemon/server/daemon-runtime.ts +++ b/src/daemon/server/daemon-runtime.ts @@ -559,6 +559,7 @@ export async function startDaemonRuntime( createOwnerScopedDeviceClaimReconciler(createDaemonRecoveryPlatformScope()), baseDir, ); + await restoreLegacyXctestDeviceSetForDaemonStartup(logPath); // Arms the initial idle-reap timer: a daemon that starts and never // receives a request must still be able to reap itself. idleReap.noteActivity(); @@ -707,6 +708,28 @@ async function reconcileDeviceClaimsForDaemonStartup( ); } +/** + * Best effort: the runner never reads `XCTestDevices`, so a restore that fails here is recorded in + * daemon.log and fails neither this daemon nor a runner start. + */ +export async function restoreLegacyXctestDeviceSetForDaemonStartup(logPath: string): Promise { + await withDiagnosticsScope( + { command: 'daemon', session: 'daemon', logPath, debug: false }, + async () => { + try { + await platformDaemonLifecycleOwners.restoreLegacyXctestDeviceSetRedirect(); + } catch (error) { + emitDiagnostic({ + level: 'warn', + phase: 'ios_runner_legacy_xctest_device_set_restore_failed', + data: { error: error instanceof Error ? error.message : String(error) }, + }); + } + flushDiagnosticsToSessionFile({ force: true }); + }, + ); +} + export async function cleanupWebBrowserOrphansForDaemonStartup(params: { stateDir: string; sessionStore: SessionStore; diff --git a/src/platform-runtime-daemon-lifecycle.ts b/src/platform-runtime-daemon-lifecycle.ts index 73bfab7911..be1b22bec0 100644 --- a/src/platform-runtime-daemon-lifecycle.ts +++ b/src/platform-runtime-daemon-lifecycle.ts @@ -6,13 +6,14 @@ import { import { cleanupManagedWebRuntimeOrphans, resetAndroidSnapshotHelperRuntime, + restoreLegacyXctestDeviceSetRedirectRuntime, } from './platform-runtime-resource-cleanup.ts'; /** * Root composition of the daemon's typed lifecycle-participation surface (#2333). This is the * one place that names the platform resource owners (the Apple runner owner, the Android - * snapshot-helper and Web orphan cleanups, and legacy app-log marker recovery); the daemon holds - * only the typed `PlatformOwnerLifecycle` contract. + * snapshot-helper and Web orphan cleanups, legacy app-log marker recovery, and the legacy + * `XCTestDevices` restore); the daemon holds only the typed `PlatformOwnerLifecycle` contract. */ export const platformDaemonLifecycleOwners: PlatformOwnerLifecycle = Object.freeze({ configureForDaemonLock: async (input) => { @@ -31,6 +32,9 @@ export const platformDaemonLifecycleOwners: PlatformOwnerLifecycle = Object.free cleanupManagedWebOrphans: async (params) => { await cleanupManagedWebRuntimeOrphans(params); }, + restoreLegacyXctestDeviceSetRedirect: async () => { + await restoreLegacyXctestDeviceSetRedirectRuntime(); + }, resetAndroidSnapshotHelper: async () => { await resetAndroidSnapshotHelperRuntime(); }, diff --git a/src/platform-runtime-resource-cleanup.ts b/src/platform-runtime-resource-cleanup.ts index 104a503b6a..e39c21b20d 100644 --- a/src/platform-runtime-resource-cleanup.ts +++ b/src/platform-runtime-resource-cleanup.ts @@ -16,6 +16,14 @@ async function stopAndroidSnapshotHelperRuntimeForDevice(device: DeviceInfo): Pr await stopAndroidSnapshotHelperSessionForDevice(device); } +/** Only a macOS host has an `XCTestDevices` an older agent-device could have redirected. */ +export async function restoreLegacyXctestDeviceSetRedirectRuntime(): Promise { + if (process.platform !== 'darwin') return; + const { restoreLegacyXctestDeviceSetRedirect } = + await import('@agent-device/platform-apple/runner/operations'); + restoreLegacyXctestDeviceSetRedirect(); +} + export async function cleanupManagedWebRuntimeOrphans(params: { stateDir: string; openWebSessionNames: readonly string[]; @@ -85,8 +93,7 @@ export const platformResourceCleanup: PlatformResourceCleanup = Object.freeze({ params.device.kind === 'simulator' && !params.shutdownRequested && !params.hasScreenRecording && - !params.hasLease && - !params.device.simulatorSetPath + !params.hasLease ); }, }); diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index 9216a0b935..f17c2983e6 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -204,6 +204,8 @@ agent-device devices --platform android --android-device-allowlist emulator-5554 ``` - `--ios-simulator-device-set ` constrains simulator discovery and simulator command execution via `xcrun simctl --set ...`. +- The XCTest runner's `xcodebuild` phases resolve a scoped simulator in the same set through `-DVTSimulatorSetLocation=`; `~/Library/Developer/XCTestDevices` is never redirected. If the selected Xcode no longer resolves the simulator that way, the runner start fails with `details.reason: "simulator_set_destination_not_found"`, and the error names the set and the selected Xcode version. +- On macOS, daemon startup puts back a `~/Library/Developer/XCTestDevices` that an older agent-device left redirected into a scoped set: it removes any symlink at that path, restores `XCTestDevices.agent-device-backup` when it exists, and records each step in `daemon.log`. A failed restore is recorded there too and does not stop the daemon or a runner start. - `--android-device-allowlist ` constrains Android discovery/selection to comma or space separated serials. - Scope is applied before selectors (`--device`, `--udid`, `--serial`), so out-of-scope selectors fail with `DEVICE_NOT_FOUND`. - With iOS simulator-set scope enabled, iOS physical devices are not enumerated.