Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 57 additions & 39 deletions src/daemon/handlers/session-open.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
createAppleRunnerCacheColdBootPrewarmForOpen,
} from '../apple-runner-options.ts';
import { applyRuntimeHintsToApp } from '../runtime-hints.ts';
import { isIosFamily, type DeviceInfo } from '../../kernel/device.ts';
import { isApplePlatform, isIosFamily, type DeviceInfo } from '../../kernel/device.ts';
import type { DaemonRequest, DaemonResponse, SessionRuntimeHints, SessionState } from '../types.ts';
import {
resolveSessionRequestLogPath,
Expand Down Expand Up @@ -72,13 +72,13 @@ async function relaunchCloseApp(params: {
context: Parameters<typeof dispatchCommand>[4];
}): Promise<void> {
const { device, closeTarget, outFlag, context } = params;
// Simulator close/open go through simctl and never touch the XCUITest
// runner, so a healthy runner session survives the relaunch (~6s saved per
// open --relaunch). A runner that does go stale is caught by the readiness
// preflight and restarted via invalidateRunnerSession/restart-and-replay.
// Real devices keep the conservative teardown: their runner transport rides
// the device tunnel, which app relaunches can disturb.
if (device.platform !== 'android' && !isIosSimulator(device)) {
// Only Apple targets have an XCUITest runner to tear down, and simulators
// keep theirs hot: their close/open go through simctl and never touch the
// runner (~6s saved per open --relaunch); one that goes stale is caught by
// the readiness preflight and restarted via invalidateRunnerSession.
// macOS and real iOS devices keep the conservative teardown — the device
// transport rides the tunnel, which app relaunches can disturb.
if (isApplePlatform(device.platform) && !isIosSimulator(device)) {
await stopIosRunnerSession(device.id);
}
await dispatchCommand(device, 'close', [closeTarget], outFlag, context);
Expand Down Expand Up @@ -167,6 +167,47 @@ async function completeOpenCommand(params: {
const openCommandStartedAtMs = Date.now();
const timing: OpenTiming = {};

const shouldPrewarmIosRunner =
isIosFamily(device) &&
surface === 'app' &&
openPositionals.length > 0 &&
Boolean(sessionAppBundleId);
const runnerPrewarmOptions = buildAppleRunnerSessionOptions({
req,
logPath,
appBundleId: sessionAppBundleId,
traceLogPath,
});
const shouldPrewarmRunnerBeforeOpen = req.flags?.maestro?.prewarmRunnerBeforeOpen === true;
let runnerPrewarm: Promise<void> | undefined;
// Tracked separately from `runnerPrewarm`: prewarmIosRunnerSession may
// return undefined (prewarm unavailable), and one attempt is one attempt.
let runnerPrewarmScheduled = false;
let runnerPrewarmAwaited = false;
const schedulePrewarm = (
options: Parameters<typeof prewarmIosRunnerSession>[1] = runnerPrewarmOptions,
): void => {
runnerPrewarmScheduled = true;
timing.runnerPrewarmKind = 'session';
timing.runnerPrewarmScheduled = true;
runnerPrewarm = prewarmIosRunnerSession(device, options);
};
const awaitPrewarm = async (): Promise<void> => {
if (!runnerPrewarm || runnerPrewarmAwaited) return;
runnerPrewarmAwaited = true;
const startedAtMs = Date.now();
await runnerPrewarm;
timing.runnerPrewarmWaited = true;
timing.runnerPrewarmDurationMs = Math.max(0, Date.now() - startedAtMs);
};
// Start the runner spin-up before close/open dispatch on simulators: neither
// touches the runner there (both ride simctl), so the xcodebuild ramp
// overlaps the app relaunch instead of following it. Real devices tear the
// runner down in relaunchCloseApp, so their prewarm stays post-open.
if (shouldPrewarmIosRunner && isIosSimulator(device) && !shouldPrewarmRunnerBeforeOpen) {
schedulePrewarm();
}

if (shouldRelaunch && openTarget) {
const closeTarget = sessionAppBundleId ?? openTarget;
const closeStartedAtMs = Date.now();
Expand All @@ -193,29 +234,9 @@ async function completeOpenCommand(params: {
runtime,
});
timing.runtimeHintsDurationMs = Math.max(0, Date.now() - runtimeHintsStartedAtMs);
const shouldPrewarmIosRunner =
isIosFamily(device) && surface === 'app' && openPositionals.length > 0;
const runnerPrewarmOptions = buildAppleRunnerSessionOptions({
req,
logPath,
appBundleId: sessionAppBundleId,
traceLogPath,
});
const shouldPrewarmRunnerBeforeOpen = req.flags?.maestro?.prewarmRunnerBeforeOpen === true;
let runnerPrewarm: Promise<void> | undefined;
if (shouldPrewarmIosRunner && sessionAppBundleId) {
timing.runnerPrewarmKind = 'session';
timing.runnerPrewarmScheduled = true;
if (shouldPrewarmRunnerBeforeOpen) {
runnerPrewarm = prewarmIosRunnerSession(device, {
...runnerPrewarmOptions,
propagateError: true,
});
const runnerPrewarmStartedAtMs = Date.now();
await runnerPrewarm;
timing.runnerPrewarmWaited = true;
timing.runnerPrewarmDurationMs = Math.max(0, Date.now() - runnerPrewarmStartedAtMs);
}
if (shouldPrewarmIosRunner && shouldPrewarmRunnerBeforeOpen) {
schedulePrewarm({ ...runnerPrewarmOptions, propagateError: true });
await awaitPrewarm();
}
const openStartedAtMs = Date.now();
const provisionalSession = await prepareOpenDispatchSession({
Expand Down Expand Up @@ -247,15 +268,12 @@ async function completeOpenCommand(params: {
openPositionals,
});
timing.launchUrlDurationMs = Math.max(0, Date.now() - launchUrlStartedAtMs);
if (shouldPrewarmIosRunner && sessionAppBundleId && !runnerPrewarm) {
runnerPrewarm = prewarmIosRunnerSession(device, runnerPrewarmOptions);
if (shouldPrewarmIosRunner && !runnerPrewarmScheduled) {
schedulePrewarm();
}
if (shouldRelaunch && runnerPrewarm && timing.runnerPrewarmWaited !== true) {
const runnerPrewarmStartedAtMs = Date.now();
await runnerPrewarm;
timing.runnerPrewarmWaited = true;
timing.runnerPrewarmDurationMs = Math.max(0, Date.now() - runnerPrewarmStartedAtMs);
} else if (runnerPrewarm && timing.runnerPrewarmWaited !== true) {
if (shouldRelaunch) {
await awaitPrewarm();
} else if (runnerPrewarm && !runnerPrewarmAwaited) {
timing.runnerPrewarmWaited = false;
}
sessionAppBundleId = await inferAndroidPackageAfterOpen(device, openTarget, sessionAppBundleId);
Expand Down
10 changes: 8 additions & 2 deletions src/daemon/server/daemon-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,12 +239,18 @@ export async function startDaemonRuntime(
await emitFatalDiagnostic(shutdownOptions.cause);
}
await closeDaemonServers(servers);
// Hand healthy simulator runners off to the next daemon before session
// teardown gets a chance to kill them; everything left after this
// (real devices, unhealthy runners) goes through the normal stop path.
const { detachIosSimulatorRunnerSessionsForShutdown, stopAllIosRunnerSessions } =
await import('../../platforms/apple/core/runner/runner-client.ts');
try {
await detachIosSimulatorRunnerSessionsForShutdown();
} catch {}
await teardownDaemonSessions();
await Promise.allSettled(
providerDeviceRuntimes.map(async (runtime) => await runtime.shutdown()),
);
const { stopAllIosRunnerSessions } =
await import('../../platforms/apple/core/runner/runner-client.ts');
await stopAllIosRunnerSessions();
// Best effort: stop the PNG worker so an in-flight job cannot delay exit.
await Promise.race([
Expand Down
173 changes: 173 additions & 0 deletions src/platforms/apple/core/__tests__/runner-adoption.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
import type { DeviceInfo } from '../../../../kernel/device.ts';
import {
buildDetachedRunnerLease,
buildRunnerLease,
readStaleRunnerLease,
writeRunnerLease,
type RunnerLease,
} from '../runner/runner-lease.ts';
import {
isIosRunnerDetachEnabled,
tryAdoptRunnerSessionFromLease,
} from '../runner/runner-adoption.ts';
import { sendRunnerCommandOnce } from '../runner/runner-transport.ts';
import { isProcessAlive } from '../../../../utils/process-identity.ts';
import {
resolveExpectedRunnerCacheMetadata,
resolveRunnerDerivedPath,
} from '../runner/runner-xctestrun.ts';

vi.mock('../runner/runner-transport.ts', async (importOriginal) => {
const actual = await importOriginal<typeof import('../runner/runner-transport.ts')>();
return { ...actual, sendRunnerCommandOnce: vi.fn() };
});
vi.mock('../../../../utils/process-identity.ts', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../../utils/process-identity.ts')>();
return { ...actual, isProcessAlive: vi.fn(() => false) };
});

const mockSendRunnerCommandOnce = vi.mocked(sendRunnerCommandOnce);
const mockIsProcessAlive = vi.mocked(isProcessAlive);

const simulator: DeviceInfo = {
platform: 'apple',
id: 'adopt-sim-1',
name: 'iPhone 17 Pro',
kind: 'simulator',
target: 'mobile',
booted: true,
};

let leaseDir: string;
let expectedDerived: string;

function writeStaleLease(overrides: Partial<RunnerLease> = {}): RunnerLease {
const lease: RunnerLease = {
...buildRunnerLease({
deviceId: simulator.id,
sessionId: `${simulator.id}:50700:1`,
runnerPid: 424242,
port: 50700,
xctestrunPath: path.join(expectedDerived, 'Build', 'Products', 'env.session.xctestrun'),
jsonPath: path.join(expectedDerived, 'Build', 'Products', 'env.session.json'),
}),
// A pid+start-time that cannot belong to a live process makes the lease
// owner dead, i.e. the lease classifies as stale.
ownerToken: 'owner-99999-deadbeef',
ownerPid: 99999,
ownerStartTime: 'not-a-real-start-time',
...overrides,
};
writeRunnerLease(lease);
return lease;
}

beforeEach(() => {
leaseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-lease-test-'));
process.env.AGENT_DEVICE_IOS_RUNNER_LEASE_DIR = leaseDir;
expectedDerived = resolveRunnerDerivedPath(
simulator,
resolveExpectedRunnerCacheMetadata(simulator),
);
mockSendRunnerCommandOnce.mockReset();
mockIsProcessAlive.mockReset();
mockIsProcessAlive.mockReturnValue(false);
delete process.env.AGENT_DEVICE_IOS_RUNNER_DETACH;
});

afterEach(() => {
delete process.env.AGENT_DEVICE_IOS_RUNNER_LEASE_DIR;
delete process.env.AGENT_DEVICE_IOS_RUNNER_DETACH;
fs.rmSync(leaseDir, { recursive: true, force: true });
});

test('isIosRunnerDetachEnabled honors the kill switch', () => {
expect(isIosRunnerDetachEnabled({})).toBe(true);
expect(isIosRunnerDetachEnabled({ AGENT_DEVICE_IOS_RUNNER_DETACH: '0' })).toBe(false);
expect(isIosRunnerDetachEnabled({ AGENT_DEVICE_IOS_RUNNER_DETACH: 'false' })).toBe(false);
expect(isIosRunnerDetachEnabled({ AGENT_DEVICE_IOS_RUNNER_DETACH: '1' })).toBe(true);
});

test('readStaleRunnerLease returns dead-owner leases and skips owned ones', () => {
writeStaleLease();
expect(readStaleRunnerLease(simulator.id)?.port).toBe(50700);

// A lease written by this process is owned, not stale.
writeRunnerLease(
buildRunnerLease({
deviceId: simulator.id,
sessionId: `${simulator.id}:50700:2`,
runnerPid: 424242,
port: 50700,
xctestrunPath: '/tmp/x.xctestrun',
jsonPath: '/tmp/x.json',
}),
);
expect(readStaleRunnerLease(simulator.id)).toBeNull();
});

test('buildDetachedRunnerLease rewrites the token', () => {
const lease = writeStaleLease();
expect(buildDetachedRunnerLease(lease).ownerToken).toBe(`detached-${lease.ownerToken}`);
});

test('adoption succeeds for a live, matching, probe-healthy runner', async () => {
const lease = writeStaleLease();
mockIsProcessAlive.mockReturnValue(true);
mockSendRunnerCommandOnce.mockResolvedValue(new Response(JSON.stringify({ ok: true })));

const session = await tryAdoptRunnerSessionFromLease(simulator, {});

expect(session).not.toBeNull();
expect(session?.port).toBe(lease.port);
expect(session?.ready).toBe(true);
expect(session?.child.pid).toBe(424242);
expect(session?.xctestrunArtifact?.reason).toBe('adopted_from_lease');
// Adoption transfers ownership: the lease on disk now belongs to us.
expect(readStaleRunnerLease(simulator.id)).toBeNull();
});

test('adoption is skipped for devices in a custom simulator set', async () => {
writeStaleLease();
mockIsProcessAlive.mockReturnValue(true);

const scopedDevice = { ...simulator, simulatorSetPath: '/custom/device-set' };
expect(await tryAdoptRunnerSessionFromLease(scopedDevice, {})).toBeNull();
expect(mockSendRunnerCommandOnce).not.toHaveBeenCalled();
});

test('adoption is skipped when the runner process is dead', async () => {
writeStaleLease();
mockIsProcessAlive.mockReturnValue(false);

expect(await tryAdoptRunnerSessionFromLease(simulator, {})).toBeNull();
expect(mockSendRunnerCommandOnce).not.toHaveBeenCalled();
});

test('adoption is skipped on artifact fingerprint mismatch', async () => {
writeStaleLease({ xctestrunPath: '/somewhere/else/Build/Products/env.xctestrun' });
mockIsProcessAlive.mockReturnValue(true);

expect(await tryAdoptRunnerSessionFromLease(simulator, {})).toBeNull();
expect(mockSendRunnerCommandOnce).not.toHaveBeenCalled();
});

test('adoption is skipped when the probe fails', async () => {
writeStaleLease();
mockIsProcessAlive.mockReturnValue(true);
mockSendRunnerCommandOnce.mockRejectedValue(new Error('connection refused'));

expect(await tryAdoptRunnerSessionFromLease(simulator, {})).toBeNull();
});

test('adoption is disabled by the kill switch', async () => {
writeStaleLease();
mockIsProcessAlive.mockReturnValue(true);
process.env.AGENT_DEVICE_IOS_RUNNER_DETACH = '0';

expect(await tryAdoptRunnerSessionFromLease(simulator, {})).toBeNull();
});
37 changes: 37 additions & 0 deletions src/platforms/apple/core/__tests__/runner-session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ vi.mock('../runner/runner-xctestrun.ts', async () => {

import {
abortAllIosRunnerSessions,
detachIosSimulatorRunnerSessionsForShutdown,
ensureRunnerSession,
executeRunnerCommandWithSession,
getRunnerSessionSnapshot,
Expand Down Expand Up @@ -725,6 +726,42 @@ test('runner session does not require Apple developer mode for iOS simulators',
assert.equal(mockRunAppleToolCommand.mock.calls.some(isDevToolsSecurityStatusCall), false);
});

test('shutdown detach hands off default-set simulator runner sessions', async () => {
const device = { ...IOS_SIMULATOR, id: 'runner-session-detach-default-sim' };
// Default simulator set: no XCTestDevices redirect is held.
mockAcquireXcodebuildSimulatorSetRedirect.mockResolvedValue(null);
await ensureRunnerSession(device, {});

const detached = await detachIosSimulatorRunnerSessionsForShutdown();

assert.equal(detached, 1);
assert.equal(getRunnerSessionSnapshot(device.id), null);
const leaseRaw = fs.readFileSync(
path.join(process.env.AGENT_DEVICE_IOS_RUNNER_LEASE_DIR ?? '', `${device.id}.json`),
'utf8',
);
const lease = JSON.parse(leaseRaw) as { ownerToken: string };
assert.match(lease.ownerToken, /^detached-owner-/);
});

test('shutdown detach keeps scoped simulator-set runner sessions for the kill path', async () => {
const device = {
...IOS_SIMULATOR,
id: 'runner-session-detach-scoped-sim',
simulatorSetPath: '/tmp/custom-device-set',
};
await ensureRunnerSession(device, {});
assert.equal(mockAcquireXcodebuildSimulatorSetRedirect.mock.calls.length, 1);

const detached = await detachIosSimulatorRunnerSessionsForShutdown();

// The redirect-holding session must stay for disposal, which restores the
// XCTestDevices symlink; detach never releases the redirect itself.
assert.equal(detached, 0);
assert.ok(getRunnerSessionSnapshot(device.id));
assert.equal(mockRedirectRelease.mock.calls.length, 0);
});

test('runner session startup kills legacy ownerless xcodebuild before launching a new runner', async () => {
const device = { ...IOS_SIMULATOR, id: 'runner-session-startup-stale-sim' };

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { DeviceInfo } from '../../../../kernel/device.ts';
import {
__resetSimulatorBootedMemoForTests,
ensureBootedSimulator,
markSimulatorBooted,
shutdownSimulator,
SIMULATOR_BOOTED_MEMO_TTL_MS,
} from '../simulator.ts';
Expand Down Expand Up @@ -71,6 +72,13 @@ test('shutdownSimulator invalidates the booted memo', async () => {
expect(countSimctlListCalls()).toBe(2);
});

test('markSimulatorBooted seeds the memo so the first boot check skips the listing', async () => {
markSimulatorBooted(simulator);

await ensureBootedSimulator(simulator);
expect(countSimctlListCalls()).toBe(0);
});

test('booted memo is scoped per simulator set path', async () => {
await ensureBootedSimulator(simulator);
expect(countSimctlListCalls()).toBe(1);
Expand Down
Loading
Loading