diff --git a/packages/contracts/src/application-lifecycle-runtime.ts b/packages/contracts/src/application-lifecycle-runtime.ts index dcfe7c509..d4790a84e 100644 --- a/packages/contracts/src/application-lifecycle-runtime.ts +++ b/packages/contracts/src/application-lifecycle-runtime.ts @@ -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; @@ -260,6 +265,7 @@ export type AppleApplicationTools = Readonly<{ execution: ApplicationLifecycleExecution, signal: AbortSignal, propagateError: boolean, + options?: AppleRunnerSessionPrewarmOptions, ): Promise; notifyRunnerAppRelaunched( device: DeviceInfo, diff --git a/packages/platform-apple/src/lifecycle.test.ts b/packages/platform-apple/src/lifecycle.test.ts index 850e01fba..af0ec3c4c 100644 --- a/packages/platform-apple/src/lifecycle.test.ts +++ b/packages/platform-apple/src/lifecycle.test.ts @@ -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) => { @@ -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(); }, ); diff --git a/packages/platform-apple/src/lifecycle.ts b/packages/platform-apple/src/lifecycle.ts index ce4823114..ad6719afd 100644 --- a/packages/platform-apple/src/lifecycle.ts +++ b/packages/platform-apple/src/lifecycle.ts @@ -1,5 +1,6 @@ import { type ApplicationLifecycleRuntimeOperations, + type AppleRunnerSessionPrewarmOptions, type CloseApplicationFinalizationInput, type CloseApplicationInput, type OpenApplicationInput, @@ -362,17 +363,31 @@ function createRunnerPrewarm( ): RunnerPrewarm { let pending: Promise | 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; @@ -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, diff --git a/packages/platform-apple/src/runner/__tests__/runner-client-prewarm.test.ts b/packages/platform-apple/src/runner/__tests__/runner-client-prewarm.test.ts new file mode 100644 index 000000000..4ef9adaa3 --- /dev/null +++ b/packages/platform-apple/src/runner/__tests__/runner-client-prewarm.test.ts @@ -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('../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); +}); diff --git a/packages/platform-apple/src/runner/__tests__/runner-command-retry.test.ts b/packages/platform-apple/src/runner/__tests__/runner-command-retry.test.ts index 19af77832..0164168ea 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-command-retry.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-command-retry.test.ts @@ -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'; @@ -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', diff --git a/packages/platform-apple/src/runner/runner-client.ts b/packages/platform-apple/src/runner/runner-client.ts index 08ba4b39e..aa15e8cca 100644 --- a/packages/platform-apple/src/runner/runner-client.ts +++ b/packages/platform-apple/src/runner/runner-client.ts @@ -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 { @@ -16,6 +16,7 @@ import { createLocalAppleRunnerProvider, resolveAppleRunnerProvider, type AppleRunnerCommandOptions, + type AppleRunnerPrewarmOptions, type AppleRunnerProvider, } from './runner-provider.ts'; import { ensureXctestrunArtifact } from './runner-xctestrun.ts'; @@ -72,7 +73,7 @@ export async function notifyIosRunnerAppRelaunched( } } -type PrewarmIosRunnerOptions = RunnerSessionOptions & { +type PrewarmIosRunnerOptions = AppleRunnerPrewarmOptions & { propagateError?: boolean; }; @@ -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; + task: (options: AppleRunnerPrewarmOptions) => Promise; }): Promise { const { device, options, failurePhase, task } = params; const { propagateError = false, ...runnerOptions } = options; @@ -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, }); }, diff --git a/packages/platform-apple/src/runner/runner-provider.ts b/packages/platform-apple/src/runner/runner-provider.ts index 09ce57879..e0cb556ac 100644 --- a/packages/platform-apple/src/runner/runner-provider.ts +++ b/packages/platform-apple/src/runner/runner-provider.ts @@ -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; diff --git a/scripts/__tests__/test-file-size-ratchet.test.ts b/scripts/__tests__/test-file-size-ratchet.test.ts index 12372cf15..8b3eed0ea 100644 --- a/scripts/__tests__/test-file-size-ratchet.test.ts +++ b/scripts/__tests__/test-file-size-ratchet.test.ts @@ -44,7 +44,7 @@ const PINNED_TEST_FILE_LINES: Readonly> = 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, diff --git a/src/platform-runtime-apple-application-tools.ts b/src/platform-runtime-apple-application-tools.ts index ee189dd4b..82ef6be64 100644 --- a/src/platform-runtime-apple-application-tools.ts +++ b/src/platform-runtime-apple-application-tools.ts @@ -1,5 +1,6 @@ import type { AppleApplicationTools, + AppleRunnerSessionPrewarmOptions, CloseApplicationFinalizationInput, OpenTargetResolution, OpenTargetResolutionInput, @@ -17,12 +18,19 @@ export function createAppleApplicationTools(): AppleApplicationTools { await import('@agent-device/platform-apple/runner/operations'); await prewarmAppleRunnerCache(device, appleRunnerOptions(execution, signal)); }, - prewarmRunnerSession: async (device, execution, signal, propagateError) => { + prewarmRunnerSession: async ( + device, + execution, + signal, + propagateError, + options?: AppleRunnerSessionPrewarmOptions, + ) => { const { prewarmIosRunnerSession } = await import('@agent-device/platform-apple/runner/operations'); await prewarmIosRunnerSession(device, { ...appleRunnerOptions(execution, signal), propagateError, + ...options, }); }, notifyRunnerAppRelaunched: async (device, execution, signal) => {