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
6 changes: 6 additions & 0 deletions packages/contracts/src/application-lifecycle-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,11 @@ export type PrepareAppleRunnerResult = Readonly<{
failureReason?: string;
}>;

/** Controls whether an opportunistic runner session prewarm also proves readiness. */
export type AppleRunnerSessionPrewarmOptions = Readonly<{
healthCheck?: boolean;
}>;

/** Individual semantic operations exposed by the application lifecycle runtime facet. */
export type ApplicationLifecycleRuntimeOperations = Readonly<{
resolveOpenTarget(input: OpenTargetResolutionInput): Promise<OpenTargetResolution>;
Expand Down Expand Up @@ -260,6 +265,7 @@ export type AppleApplicationTools = Readonly<{
execution: ApplicationLifecycleExecution,
signal: AbortSignal,
propagateError: boolean,
options?: AppleRunnerSessionPrewarmOptions,
): Promise<void>;
notifyRunnerAppRelaunched(
device: DeviceInfo,
Expand Down
69 changes: 69 additions & 0 deletions packages/platform-apple/src/lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,10 +57,78 @@ test.each(['coredevice', 'xctest'] as const)(

expect(events).toEqual(['close', 'open', 'prewarm', 'reset']);
expect(stopRunnerSession).not.toHaveBeenCalled();
expect(prewarmRunnerSession).toHaveBeenCalledWith(selectedDevice, {}, signal, false);
expect(notifyRunnerAppRelaunched).toHaveBeenCalledWith(selectedDevice, {}, signal);
},
);

test('starts an unawaited physical iOS first-open runner without a redundant health check', async () => {
const signal = new AbortController().signal;
const events: string[] = [];
const interactor = {
open: vi.fn(async () => {
events.push('open');
}),
} as unknown as Interactor;
const baseHost = platformRuntimeHostFixture();
const prewarmRunnerSession = vi.fn(async () => {
events.push('prewarm');
});
const host = {
...baseHost,
localInteractors: { resolve: async () => interactor },
appleApplications: {
...baseHost.appleApplications,
prewarmRunnerSession,
},
} as unknown as PlatformRuntimeHost;
const lifecycle = bindAppleApplicationLifecycle({ host, device, signal });

await lifecycle.openApplication({
...openInput(),
hasExistingSession: false,
relaunch: false,
});

expect(events).toEqual(['open', 'prewarm']);
expect(prewarmRunnerSession).toHaveBeenCalledWith(device, {}, signal, false, {
healthCheck: false,
});
});

test('preserves the health check when physical iOS runner prewarm is awaited', async () => {
const signal = new AbortController().signal;
const events: string[] = [];
const interactor = {
open: vi.fn(async () => {
events.push('open');
}),
} as unknown as Interactor;
const baseHost = platformRuntimeHostFixture();
const prewarmRunnerSession = vi.fn(async () => {
events.push('prewarm');
});
const host = {
...baseHost,
localInteractors: { resolve: async () => interactor },
appleApplications: {
...baseHost.appleApplications,
prewarmRunnerSession,
},
} as unknown as PlatformRuntimeHost;
const lifecycle = bindAppleApplicationLifecycle({ host, device, signal });

await lifecycle.openApplication({
...openInput(),
hasExistingSession: false,
relaunch: false,
prewarmRunnerBeforeOpen: true,
});

expect(events).toEqual(['prewarm', 'open']);
expect(prewarmRunnerSession).toHaveBeenCalledWith(device, {}, signal, true);
});

test.each(['ipados', 'tvos', 'visionos'] as const)(
'preserves runner restart semantics for a physical %s target',
async (appleOs) => {
Expand Down Expand Up @@ -100,6 +168,7 @@ test.each(['ipados', 'tvos', 'visionos'] as const)(
await lifecycle.openApplication(openInput());

expect(events).toEqual(['stop', 'close', 'open', 'prewarm']);
expect(prewarmRunnerSession).toHaveBeenCalledWith(selectedDevice, {}, signal, false);
expect(notifyRunnerAppRelaunched).not.toHaveBeenCalled();
},
);
Expand Down
36 changes: 30 additions & 6 deletions packages/platform-apple/src/lifecycle.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
type ApplicationLifecycleRuntimeOperations,
type AppleRunnerSessionPrewarmOptions,
type CloseApplicationFinalizationInput,
type CloseApplicationInput,
type OpenApplicationInput,
Expand Down Expand Up @@ -362,17 +363,31 @@ function createRunnerPrewarm(
): RunnerPrewarm {
let pending: Promise<void> | undefined;
let awaited = false;
const options: AppleRunnerSessionPrewarmOptions | undefined = isUnawaitedPhysicalIosOpen(
binding.device,
input,
)
? { healthCheck: false }
: undefined;
return {
schedule: (propagateError = false) => {
if (pending) return;
timing.runnerPrewarmKind = 'session';
timing.runnerPrewarmScheduled = true;
pending = host.appleApplications.prewarmRunnerSession(
binding.device,
input.execution,
binding.signal,
propagateError,
);
pending = options
? host.appleApplications.prewarmRunnerSession(
binding.device,
input.execution,
binding.signal,
propagateError,
options,
)
: host.appleApplications.prewarmRunnerSession(
binding.device,
input.execution,
binding.signal,
propagateError,
);
},
wait: async () => {
if (!pending || awaited) return;
Expand All @@ -390,6 +405,15 @@ function createRunnerPrewarm(
};
}

function isUnawaitedPhysicalIosOpen(device: DeviceInfo, input: OpenApplicationInput): boolean {
return (
device.kind === 'device' &&
device.appleOs === 'ios' &&
!input.relaunch &&
!input.prewarmRunnerBeforeOpen
);
}

function openLaunchPlan(
input: OpenApplicationInput,
foldLaunchUrl: boolean,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { beforeEach, test, vi } from 'vitest';
import assert from 'node:assert/strict';
import { AppError } from '@agent-device/kernel/errors';
import { IOS_SIMULATOR } from './device-fixtures.ts';
import { makeRunnerSession } from './runner-session-fixtures.ts';
import { appleRunnerTestHost } from '../test-host.ts';

const { mockEnsureRunnerSession, mockExecuteRunnerCommandWithSession, mockEmitDiagnostic } =
vi.hoisted(() => ({
mockEnsureRunnerSession: vi.fn(),
mockExecuteRunnerCommandWithSession: vi.fn(),
mockEmitDiagnostic: vi.fn(),
}));

vi.mock('../runner-session.ts', async () => {
const actual =
await vi.importActual<typeof import('../runner-session.ts')>('../runner-session.ts');
return {
...actual,
ensureRunnerSession: mockEnsureRunnerSession,
executeRunnerCommandWithSession: mockExecuteRunnerCommandWithSession,
};
});

import { prewarmIosRunnerSession } from '../runner-client.ts';

beforeEach(() => {
vi.resetAllMocks();
appleRunnerTestHost.update({ emitDiagnostic: mockEmitDiagnostic });
});

test('prewarmIosRunnerSession proves cached runner health with uptime', async () => {
const session = makeRunnerSession({ port: 8100 });
mockEnsureRunnerSession.mockResolvedValueOnce(session);
mockExecuteRunnerCommandWithSession.mockResolvedValueOnce({ uptimeMs: 42 });

const prewarm = prewarmIosRunnerSession(IOS_SIMULATOR, {
buildTimeoutMs: 300_000,
requestId: 'prewarm-request',
});

await prewarm;

assert.equal(mockEnsureRunnerSession.mock.calls.length, 1);
assert.equal(mockEnsureRunnerSession.mock.calls[0]?.[1]?.buildTimeoutMs, 300_000);
assert.equal(mockEnsureRunnerSession.mock.calls[0]?.[1]?.requestId, 'prewarm-request');
assert.equal(mockEnsureRunnerSession.mock.calls[0]?.[1]?.healthTimeoutMs, 45_000);
assert.equal(mockExecuteRunnerCommandWithSession.mock.calls.length, 1);
assert.equal(mockExecuteRunnerCommandWithSession.mock.calls[0]?.[1], session);
assert.equal(mockExecuteRunnerCommandWithSession.mock.calls[0]?.[2].command, 'uptime');
assert.equal(mockExecuteRunnerCommandWithSession.mock.calls[0]?.[4], 45_000);
});

test('prewarmIosRunnerSession can start a session without a redundant health command', async () => {
const session = makeRunnerSession({ port: 8100 });
mockEnsureRunnerSession.mockResolvedValueOnce(session);

const prewarm = prewarmIosRunnerSession(IOS_SIMULATOR, { healthCheck: false });

await prewarm;

assert.equal(mockEnsureRunnerSession.mock.calls.length, 1);
assert.equal(mockEnsureRunnerSession.mock.calls[0]?.[1]?.healthCheck, undefined);
assert.equal(mockExecuteRunnerCommandWithSession.mock.calls.length, 0);
});

test('prewarmIosRunnerSession can propagate setup failures for blocking callers', async () => {
const failure = new AppError('COMMAND_FAILED', 'Developer mode is disabled');
mockEnsureRunnerSession.mockRejectedValueOnce(failure);
const prewarm = prewarmIosRunnerSession(IOS_SIMULATOR, { propagateError: true });

assert.ok(prewarm);
await assert.rejects(prewarm, (error: unknown) => error === failure);

assert.deepEqual(mockEmitDiagnostic.mock.calls[0]?.[0], {
level: 'warn',
phase: 'ios_runner_session_prewarm_failed',
data: {
deviceId: IOS_SIMULATOR.id,
error: 'Developer mode is disabled',
},
});
assert.equal(mockEnsureRunnerSession.mock.calls[0]?.[1]?.propagateError, undefined);
});
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,7 @@ vi.mock('../runner-xctestrun.ts', async () => {
};
});

import {
prepareIosRunner,
prewarmIosRunnerSession,
runAppleRunnerCommand,
} from '../runner-client.ts';
import { prepareIosRunner, runAppleRunnerCommand } from '../runner-client.ts';
import { resetRunnerRecycleLedgerForTests } from '../runner-recycle-ledger.ts';
import type { RunnerXctestrunArtifact } from '../runner-xctestrun.ts';

Expand Down Expand Up @@ -210,47 +206,6 @@ test('prepareIosRunner spends one shared deadline across setup and health check'
}
});

test('prewarmIosRunnerSession proves cached runner health with uptime', async () => {
const session = makeRunnerSession({ port: 8100 });
mockEnsureRunnerSession.mockResolvedValueOnce(session);
mockExecuteRunnerCommandWithSession.mockResolvedValueOnce({ uptimeMs: 42 });

const prewarm = prewarmIosRunnerSession(IOS_SIMULATOR, {
buildTimeoutMs: 300_000,
requestId: 'prewarm-request',
});

await prewarm;

assert.equal(mockEnsureRunnerSession.mock.calls.length, 1);
assert.equal(mockEnsureRunnerSession.mock.calls[0]?.[1]?.buildTimeoutMs, 300_000);
assert.equal(mockEnsureRunnerSession.mock.calls[0]?.[1]?.requestId, 'prewarm-request');
assert.equal(mockEnsureRunnerSession.mock.calls[0]?.[1]?.healthTimeoutMs, 45_000);
assert.equal(mockExecuteRunnerCommandWithSession.mock.calls.length, 1);
assert.equal(mockExecuteRunnerCommandWithSession.mock.calls[0]?.[1], session);
assert.equal(mockExecuteRunnerCommandWithSession.mock.calls[0]?.[2].command, 'uptime');
assert.equal(mockExecuteRunnerCommandWithSession.mock.calls[0]?.[4], 45_000);
});

test('prewarmIosRunnerSession can propagate setup failures for blocking callers', async () => {
const failure = new AppError('COMMAND_FAILED', 'Developer mode is disabled');
mockEnsureRunnerSession.mockRejectedValueOnce(failure);
const prewarm = prewarmIosRunnerSession(IOS_SIMULATOR, { propagateError: true });

assert.ok(prewarm);
await assert.rejects(prewarm, (error: unknown) => error === failure);

assert.deepEqual(mockEmitDiagnostic.mock.calls[0]?.[0], {
level: 'warn',
phase: 'ios_runner_session_prewarm_failed',
data: {
deviceId: IOS_SIMULATOR.id,
error: 'Developer mode is disabled',
},
});
assert.equal(mockEnsureRunnerSession.mock.calls[0]?.[1]?.propagateError, undefined);
});

test('prepareIosRunner does not force a rebuild when the relaunched fresh session still cannot connect', async () => {
const missArtifact = makeRunnerArtifact({
xctestrunPath: '/tmp/miss.xctestrun',
Expand Down
14 changes: 10 additions & 4 deletions packages/platform-apple/src/runner/runner-client.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { retryWithPolicy, emitDiagnostic } from './host.ts';
import { isIosFamily, type DeviceInfo } from '@agent-device/kernel/device';
import {
ensureRunnerSession,
stopIosRunnerSession,
type RunnerSessionOptions,
validateRunnerDevice,
} from './runner-session.ts';
import {
Expand All @@ -16,6 +16,7 @@ import {
createLocalAppleRunnerProvider,
resolveAppleRunnerProvider,
type AppleRunnerCommandOptions,
type AppleRunnerPrewarmOptions,
type AppleRunnerProvider,
} from './runner-provider.ts';
import { ensureXctestrunArtifact } from './runner-xctestrun.ts';
Expand Down Expand Up @@ -72,7 +73,7 @@ export async function notifyIosRunnerAppRelaunched(
}
}

type PrewarmIosRunnerOptions = RunnerSessionOptions & {
type PrewarmIosRunnerOptions = AppleRunnerPrewarmOptions & {
propagateError?: boolean;
};

Expand Down Expand Up @@ -124,7 +125,7 @@ function runBestEffortIosRunnerPrewarm(params: {
device: DeviceInfo;
options: PrewarmIosRunnerOptions;
failurePhase: 'ios_runner_cache_prewarm_failed' | 'ios_runner_session_prewarm_failed';
task: (options: RunnerSessionOptions) => Promise<void>;
task: (options: AppleRunnerPrewarmOptions) => Promise<void>;
}): Promise<void> {
const { device, options, failurePhase, task } = params;
const { propagateError = false, ...runnerOptions } = options;
Expand Down Expand Up @@ -178,8 +179,13 @@ function resolveAppleRunnerRuntime(
const LOCAL_APPLE_RUNNER_RUNTIME = createLocalAppleRunnerProvider(executeRunnerCommand, {
prepare: prepareLocalIosRunner,
prewarm: async (device, options) => {
const { healthCheck, ...runnerOptions } = options;
if (healthCheck === false) {
await ensureRunnerSession(device, runnerOptions);
return;
}
await prepareLocalIosRunner(device, {
...options,
...runnerOptions,
healthTimeoutMs: RUNNER_COMMAND_TIMEOUT_MS,
});
},
Expand Down
5 changes: 4 additions & 1 deletion packages/platform-apple/src/runner/runner-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,10 @@ export type AppleRunnerLifecycleOptions = AppleRunnerCommandOptions & {
forceRunnerXctestrunRebuild?: boolean;
};

export type AppleRunnerPrewarmOptions = AppleRunnerLifecycleOptions;
export type AppleRunnerPrewarmOptions = AppleRunnerLifecycleOptions & {
/** A false value starts the session and lets its first consumer prove readiness. */
healthCheck?: boolean;
};

export type AppleRunnerPrepareOptions = AppleRunnerLifecycleOptions & {
healthTimeoutMs: number;
Expand Down
2 changes: 1 addition & 1 deletion scripts/__tests__/test-file-size-ratchet.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ const PINNED_TEST_FILE_LINES: Readonly<Record<string, number>> = Object.freeze({
'src/__tests__/client.test.ts': 1592,
'test/integration/provider-scenarios/android-lifecycle.test.ts': 1556,
'src/utils/__tests__/daemon-client-lifecycle.test.ts': 1413,
'packages/platform-apple/src/runner/__tests__/runner-command-retry.test.ts': 1325,
'packages/platform-apple/src/runner/__tests__/runner-command-retry.test.ts': 1280,
'src/__tests__/cli-client-commands.test.ts': 1304,
'src/__tests__/cli-config.test.ts': 1282,
'src/daemon/handlers/__tests__/find.test.ts': 1199,
Expand Down
Loading
Loading