diff --git a/.changeset/serve-sim-pr-4602.md b/.changeset/serve-sim-pr-4602.md new file mode 100644 index 0000000000..6959988246 --- /dev/null +++ b/.changeset/serve-sim-pr-4602.md @@ -0,0 +1,5 @@ +--- +'@expo/build-tools': minor +--- + +Let serve-sim boot, install and launch Argent iOS sessions. diff --git a/packages/build-tools/src/steps/functions/__tests__/startArgentRemoteSession-orchestration.test.ts b/packages/build-tools/src/steps/functions/__tests__/startArgentRemoteSession-orchestration.test.ts index 358929be3f..d85cae8091 100644 --- a/packages/build-tools/src/steps/functions/__tests__/startArgentRemoteSession-orchestration.test.ts +++ b/packages/build-tools/src/steps/functions/__tests__/startArgentRemoteSession-orchestration.test.ts @@ -7,8 +7,9 @@ import path from 'node:path'; import { createGlobalContextMock } from '../../../__tests__/utils/context'; import { type CustomBuildContext } from '../../../customBuildContext'; import { Sentry } from '../../../sentry'; +import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils'; import { startDeviceSessionHostAsync } from '../../utils/deviceSessionHost'; -import { isProcessDescendantOfAsync } from '../../../utils/processes'; +import { isProcessDescendantOfAsync, killProcessGroup } from '../../../utils/processes'; import { pollArgentArtifactsForUploadAsync } from '../../utils/argentArtifacts'; import { startArgentEventCollectionAsync } from '../../utils/argentEvents'; import { createProcessOutput } from '../../utils/processOutput'; @@ -24,6 +25,7 @@ import { waitForDeviceRunSessionStoppedAsync, } from '../../utils/remoteDeviceRunSession'; import { createStartArgentRemoteSessionBuildFunction } from '../startArgentRemoteSession'; +import { readIosApplicationIdentifierAsync } from '../../utils/iosAppArtifact'; // Redirect ~/.argent (where the tool-server writes its state file and event log) to a temp // home so waitForArgentToolServerStateAsync — which lives in the module under test and cannot @@ -38,13 +40,32 @@ jest.mock('node:os', () => { }); jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() })); jest.mock('../../../sentry'); -jest.mock('../../../utils/processes', () => ({ isProcessDescendantOfAsync: jest.fn() })); +jest.mock('../../../utils/processes', () => ({ + isProcessDescendantOfAsync: jest.fn(), + killProcessGroup: jest.fn(), +})); jest.mock('../../utils/argentArtifacts', () => ({ pollArgentArtifactsForUploadAsync: jest.fn() })); jest.mock('../../utils/argentEvents', () => ({ ...jest.requireActual('../../utils/argentEvents'), startArgentEventCollectionAsync: jest.fn(), })); jest.mock('../../utils/deviceSessionHost'); +jest.mock('../../utils/iosAppArtifact', () => ({ + ...jest.requireActual('../../utils/iosAppArtifact'), + readIosApplicationIdentifierAsync: jest.fn().mockResolvedValue('dev.example.app'), +})); +jest.mock('../../../utils/IosSimulatorUtils', () => ({ + IosSimulatorUtils: { + resolveUdidAsync: jest.fn().mockResolvedValue('selected-ios-udid'), + getAvailableDevicesAsync: jest + .fn() + .mockResolvedValue([{ name: 'iPhone 17', udid: 'selected-ios-udid' }]), + }, +})); +jest.mock('../../utils/localEgressGuard', () => ({ + resolveLocalEgressServeSimBootEnvironmentAsync: jest.fn().mockResolvedValue(null), +})); + jest.mock('../../utils/remoteDeviceRunSession', () => ({ ...jest.requireActual('../../utils/remoteDeviceRunSession'), ensureFfmpegInstalledOnceAsync: jest.fn(), @@ -623,6 +644,280 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => { ); }); + it('boots and prepares the downloaded app while Argent installation is pending, and closes the host if installation fails', async () => { + let rejectInstall!: (error: Error) => void; + let enteredInstall!: () => void; + const installEntered = new Promise(resolve => { + enteredInstall = resolve; + }); + jest.mocked(spawn).mockImplementationOnce(() => { + enteredInstall(); + return new Promise((_resolve, reject) => { + rejectInstall = reject; + }) as never; + }); + const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext); + const running = buildFunction.fn!( + { + logger: { info: jest.fn(), warn: jest.fn() }, + global: { runtimePlatform: BuildRuntimePlatform.DARWIN }, + } as unknown as BuildStepContext, + { + inputs: { + package_version: { value: undefined }, + max_idle_time_minutes: { value: undefined }, + device_identifier: { value: 'chosen-device' }, + install_app_path: { value: '/tmp/App.app' }, + launch_args: { value: ['--literal'] }, + }, + outputs: {}, + env: {}, + } as never + ); + await installEntered; + expect( + jest.mocked(selectXcodeDeveloperDirectoryAsync).mock.invocationCallOrder[0] + ).toBeLessThan(jest.mocked(IosSimulatorUtils.resolveUdidAsync).mock.invocationCallOrder[0]); + expect(readIosApplicationIdentifierAsync).toHaveBeenCalledWith({ + artifactPath: '/tmp/App.app', + env: {}, + }); + expect(startDeviceSessionHostAsync).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + iosSimulatorUdid: 'SELECTED-IOS-UDID', + installAppPath: '/tmp/App.app', + launchAppIdentifier: 'dev.example.app', + launchArgs: ['--literal'], + }) + ); + const error = new Error('Argent install failed'); + rejectInstall(error); + await expect(running).rejects.toBe(error); + expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1); + expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled(); + }); + + it.each(['artifacts-list-endpoint', 'tool-server-event-log', 'video-watermark'])( + 'stops and drains the pending %s command when the concurrent host fails', + async flag => { + const hostError = new Error('serve-sim startup failed'); + const commandError = new Error('Argent command exited after cancellation'); + let failHost!: (error: Error) => void; + let finishHostCleanup!: () => void; + jest.mocked(startDeviceSessionHostAsync).mockImplementationOnce( + async (_ctx, options) => + await new Promise((_resolve, reject) => { + failHost = error => { + options.onStartupError?.(error); + finishHostCleanup = () => reject(error); + }; + }) + ); + let failCommand!: (error: Error) => void; + let commandStarted!: () => void; + const commandEntered = new Promise(resolve => { + commandStarted = resolve; + }); + const child = { pid: 4242, kill: jest.fn() }; + jest.mocked(spawn).mockImplementation((_command, args) => { + if (!args.includes(flag)) { + return Promise.resolve(undefined) as never; + } + const pending = new Promise((_resolve, reject) => { + failCommand = reject; + }); + commandStarted(); + return Object.assign(pending, { child }) as never; + }); + const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext); + const running = Promise.resolve( + buildFunction.fn!( + { + logger: { info: jest.fn(), warn: jest.fn() }, + global: { runtimePlatform: BuildRuntimePlatform.DARWIN }, + } as unknown as BuildStepContext, + { + inputs: { + package_version: { value: undefined }, + max_idle_time_minutes: { value: undefined }, + boot_simulator: { value: true }, + }, + outputs: {}, + env: {}, + } as never + ) + ); + let settled = false; + void running + .finally(() => { + settled = true; + }) + .catch(() => {}); + try { + await commandEntered; + failHost(hostError); + await new Promise(resolve => setImmediate(resolve)); + expect(killProcessGroup).toHaveBeenCalledWith(child, 'SIGKILL'); + expect(settled).toBe(false); + expect(spawnDetached).not.toHaveBeenCalled(); + failCommand(commandError); + await new Promise(resolve => setImmediate(resolve)); + expect(settled).toBe(false); + finishHostCleanup(); + await expect(running).rejects.toBe(hostError); + expect(spawnDetached).not.toHaveBeenCalled(); + expect(startNgrokTunnelAsync).not.toHaveBeenCalled(); + expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled(); + } finally { + failCommand?.(commandError); + finishHostCleanup?.(); + await running.catch(() => {}); + } + } + ); + + it('interrupts tool-server state polling and retains the original host error', async () => { + await fs.promises.rm(path.join(ARGENT_STATE_DIR, 'tool-server-orchestration.json')); + const hostError = new Error('serve-sim startup failed'); + let failHost!: (error: Error) => void; + jest.mocked(startDeviceSessionHostAsync).mockImplementationOnce( + async () => + await new Promise((_resolve, reject) => { + failHost = reject; + }) + ); + let pollingStarted!: () => void; + const pollingEntered = new Promise(resolve => { + pollingStarted = resolve; + }); + const getExitError = jest.fn(() => { + pollingStarted(); + return undefined; + }); + const stopServer = jest.fn().mockResolvedValue(undefined); + jest.mocked(spawnDetached).mockReturnValueOnce({ + pid: 4242, + getOutput: () => 'later controller diagnostic', + getExitError, + stopAsync: stopServer, + }); + const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext); + const running = Promise.resolve( + buildFunction.fn!( + { + logger: { info: jest.fn(), warn: jest.fn() }, + global: { runtimePlatform: BuildRuntimePlatform.DARWIN }, + } as unknown as BuildStepContext, + { + inputs: { + package_version: { value: undefined }, + max_idle_time_minutes: { value: undefined }, + boot_simulator: { value: true }, + }, + outputs: {}, + env: {}, + } as never + ) + ); + void running.catch(() => {}); + try { + await pollingEntered; + failHost(hostError); + await new Promise(resolve => setImmediate(resolve)); + const options = jest.mocked(startDeviceSessionHostAsync).mock.calls[0][1]; + expect(options.signal?.aborted).toBe(true); + getExitError.mockReturnValue(new Error('later Argent exit')); + await expect(running).rejects.toBe(hostError); + expect(stopServer).toHaveBeenCalledTimes(1); + expect(startArgentEventCollectionAsync).not.toHaveBeenCalled(); + expect(startNgrokTunnelAsync).not.toHaveBeenCalled(); + expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled(); + } finally { + getExitError.mockReturnValue(new Error('stop pending state poll')); + await running.catch(() => {}); + } + }); + + it('keeps Argent setup concurrent but waits for the iOS host before opening tools', async () => { + const host = await jest.mocked(startDeviceSessionHostAsync).getMockImplementation()!( + {} as CustomBuildContext, + {} as never + ); + let releaseHost!: () => void; + jest.mocked(startDeviceSessionHostAsync).mockReturnValueOnce( + new Promise(resolve => { + releaseHost = () => resolve(host); + }) + ); + let stateReady!: () => void; + const stateRead = new Promise(resolve => { + stateReady = resolve; + }); + jest.mocked(isProcessDescendantOfAsync).mockImplementationOnce(async () => { + stateReady(); + return true; + }); + const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext); + const running = Promise.resolve( + buildFunction.fn!( + { + logger: { info: jest.fn(), warn: jest.fn() }, + global: { runtimePlatform: BuildRuntimePlatform.DARWIN }, + } as unknown as BuildStepContext, + { + inputs: { + package_version: { value: undefined }, + max_idle_time_minutes: { value: undefined }, + boot_simulator: { value: true }, + }, + outputs: {}, + env: {}, + } as never + ) + ); + try { + await stateRead; + await new Promise(resolve => setImmediate(resolve)); + expect(spawnDetached).toHaveBeenCalledTimes(1); + expect(startArgentEventCollectionAsync).not.toHaveBeenCalled(); + expect(startNgrokTunnelAsync).not.toHaveBeenCalled(); + expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled(); + } finally { + releaseHost(); + await running; + } + expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledTimes(1); + expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1); + }); + + it.each([BuildRuntimePlatform.DARWIN, BuildRuntimePlatform.LINUX])( + 'keeps %s prepared setup commands outside the new cancellation path', + async runtimePlatform => { + const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext); + await buildFunction.fn!( + { + logger: { info: jest.fn(), warn: jest.fn() }, + global: { runtimePlatform }, + } as unknown as BuildStepContext, + { + inputs: { + package_version: { value: undefined }, + max_idle_time_minutes: { value: undefined }, + }, + outputs: {}, + env: {}, + } as never + ); + expect(spawn).toHaveBeenCalledTimes(3); + for (const [, , options] of jest.mocked(spawn).mock.calls) { + expect(options).not.toHaveProperty('detached'); + expect(options).not.toHaveProperty('signal'); + } + expect(killProcessGroup).not.toHaveBeenCalled(); + } + ); + it('fails before starting anything when a launch is asked for on Android', async () => { const ctx = {} as unknown as CustomBuildContext; const logger = { info: jest.fn(), warn: jest.fn() }; diff --git a/packages/build-tools/src/steps/functions/startArgentRemoteSession.ts b/packages/build-tools/src/steps/functions/startArgentRemoteSession.ts index aa462b673c..2e7be1cc7b 100644 --- a/packages/build-tools/src/steps/functions/startArgentRemoteSession.ts +++ b/packages/build-tools/src/steps/functions/startArgentRemoteSession.ts @@ -1,15 +1,17 @@ import { SystemError } from '@expo/eas-build-job'; import { type bunyan } from '@expo/logger'; +import { type Result, asyncResult } from '@expo/results'; import { BuildFunction, BuildRuntimePlatform, BuildStepInput, BuildStepInputValueTypeName, } from '@expo/steps'; -import spawn from '@expo/turtle-spawn'; +import spawn, { type SpawnOptions } from '@expo/turtle-spawn'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +import { setTimeout as setTimeoutAsync } from 'node:timers/promises'; import semver from 'semver'; import { z } from 'zod'; @@ -19,6 +21,7 @@ import { withLocalEgressSession, } from '../utils/localEgressSession'; import { type DeviceSessionHost, startDeviceSessionHostAsync } from '../utils/deviceSessionHost'; +import { resolveIosSessionStartupAsync } from '../utils/iosSimulatorSession'; import { createNetworkCaptureInputProviders, parseNetworkCaptureInputs, @@ -29,13 +32,14 @@ import { resolveConfiguredPackageManager, resolvePackageExec, } from '../../utils/packageManager'; -import { isProcessDescendantOfAsync } from '../../utils/processes'; +import { isProcessDescendantOfAsync, killProcessGroup } from '../../utils/processes'; import { sleepAsync } from '../../utils/retry'; import { pollArgentArtifactsForUploadAsync } from '../utils/argentArtifacts'; import { ARGENT_EVENT_LOG_FILENAME, startArgentEventCollectionAsync } from '../utils/argentEvents'; import { createCorsOriginsInputProvider, createFrameAncestorsInputProvider, + createIosSessionStartupInputProviders, createPrivatePreviewUrlInputProvider, createPublicPreviewInputProvider, createServeSimLaunchInputProviders, @@ -48,7 +52,6 @@ import { omitNgrokAuthtoken, parseCorsOriginsInput, parseFrameAncestorsInput, - parseServeSimLaunchInputs, selectXcodeDeveloperDirectoryAsync, spawnDetached, startNgrokTunnelAsync, @@ -90,6 +93,7 @@ export function createStartArgentRemoteSessionBuildFunction( __metricsId: 'eas/start_argent_remote_session', inputProviders: [ ...createServeSimLaunchInputProviders(), + ...createIosSessionStartupInputProviders(), ...createNetworkCaptureInputProviders(), createPublicPreviewInputProvider(), createPrivatePreviewUrlInputProvider(), @@ -128,14 +132,21 @@ export function createStartArgentRemoteSessionBuildFunction( warnIfArgentPackageVersionCannotBeVerified({ packageVersion, logger }); const versionSpec = packageVersion ?? 'latest'; const { runtimePlatform } = global; - const launch = parseServeSimLaunchInputs( - { - launchAppIdentifier: inputs.launch_app_identifier?.value as string | undefined, - launchArgs: inputs.launch_args?.value, - openUrl: inputs.open_url?.value as string | undefined, - }, - { runtimePlatform } - ); + if (runtimePlatform === BuildRuntimePlatform.DARWIN) { + await selectXcodeDeveloperDirectoryAsync({ env, logger }); + } + const { launch, iosStartup } = await resolveIosSessionStartupAsync({ + runtimePlatform, + bootSimulator: inputs.boot_simulator?.value as boolean | undefined, + deviceIdentifier: inputs.device_identifier?.value as string | undefined, + installAppPath: inputs.install_app_path?.value as string | undefined, + launchAppIdentifier: inputs.launch_app_identifier?.value, + launchArgs: inputs.launch_args?.value, + openUrl: inputs.open_url?.value, + env, + logger, + signal, + }); const { networkCapture, networkCaptureFields } = parseNetworkCaptureInputs( { networkCapture: inputs.network_capture?.value, @@ -147,10 +158,6 @@ export function createStartArgentRemoteSessionBuildFunction( `Starting argent remote session (version: ${versionSpec}, runtime: ${runtimePlatform}).` ); - if (runtimePlatform === BuildRuntimePlatform.DARWIN) { - await selectXcodeDeveloperDirectoryAsync({ env, logger }); - } - // Start the potentially slow installation while Argent is being prepared. // On Linux expo-device-hub calls this again and awaits the same in-flight // setup before launching. On macOS this remains non-blocking, so only a @@ -162,74 +169,124 @@ export function createStartArgentRemoteSessionBuildFunction( const argentExec = (args: string[]): { command: string; args: string[] } => resolvePackageExec(packageManager, args); - logger.info('Enabling the Argent artifacts list endpoint flag.'); - const enableArtifacts = argentExec([ - `${ARGENT_PACKAGE_NAME}@${versionSpec}`, - 'enable', - ARGENT_ARTIFACTS_LIST_ENDPOINT_FLAG, - ]); - await spawn(enableArtifacts.command, enableArtifacts.args, { env, logger }); - - logger.info('Enabling the Argent tool-server event log flag.'); - const enableEventLog = argentExec([ - `${ARGENT_PACKAGE_NAME}@${versionSpec}`, - 'enable', - ARGENT_EVENT_LOG_FLAG, - ]); - await spawn(enableEventLog.command, enableEventLog.args, { env, logger }); - - logger.info('Disabling the Argent video watermark flag.'); - const disableVideoWatermark = argentExec([ - `${ARGENT_PACKAGE_NAME}@${versionSpec}`, - 'disable', - ARGENT_VIDEO_WATERMARK_FLAG, - ]); - // The watermark is cosmetic, so a failure here must not fail the session. - try { - await spawn(disableVideoWatermark.command, disableVideoWatermark.args, { env, logger }); - } catch (err) { - logger.warn( - { err }, - 'Could not disable the Argent video watermark, so recorded videos may include it. Continuing the session.' - ); - } - - const startServer = argentExec([ - `${ARGENT_PACKAGE_NAME}@${versionSpec}`, - 'server', - 'start', - '--port', - '0', - '--idle-timeout', - '0', - '--force', - ]); - logger.info( - `Launching ${ARGENT_PACKAGE_NAME}@${versionSpec} tool-server via ${startServer.command}.` - ); - // Keep Argent itself in foreground mode under the detached process. This preserves - // the npx/bun -> Argent CLI -> tool-server ancestry used to identify the matching state file. - const secrets: string[] = []; - const argentServer = spawnDetached({ - command: startServer.command, - args: startServer.args, - env: { ...env, ARGENT_EVENT_LOG: ARGENT_EVENT_LOG_PATH, ARGENT_EMULATOR_NO_WINDOW: '1' }, - logger, - secrets, - }); - if (argentServer.pid === undefined) { - throw new SystemError( - 'Failed to start Argent: could not determine the PID of the launched process.' - ); - } - + const startupAbortController = new AbortController(); + const startupSignal = signal + ? AbortSignal.any([signal, startupAbortController.signal]) + : startupAbortController.signal; + const setupSignal = iosStartup ? startupSignal : undefined; const artifactPollAbortController = new AbortController(); let artifactPollingPromise: Promise | undefined; let eventCollection: Awaited> | undefined; let toolsTunnel: Awaited> | undefined; + let argentServer: ReturnType | undefined; + let hostStartup: Promise> | undefined; let sessionHost: DeviceSessionHost | undefined; let sessionFailed = false; + const startHost = () => + startDeviceSessionHostAsync(ctx, { + ...iosStartup, + separateLogPhase: true, + runtimePlatform, + env, + logger, + disableShare: inputs.public_preview?.value === false, + privatePreviewUrl: inputs.private_preview_url?.value as string | undefined, + frameAncestors: parseFrameAncestorsInput(inputs.frame_ancestors?.value), + corsOrigins: parseCorsOriginsInput(inputs.cors_origins?.value), + timeoutMs: STARTUP_TIMEOUT_MS, + signal: startupSignal, + onStartupError: iosStartup ? error => startupAbortController.abort(error) : undefined, + ...launch, + networkCapture, + networkCaptureFields, + }).catch(error => { + if (iosStartup) { + startupAbortController.abort(error); + } + throw error; + }); try { + if (iosStartup) { + hostStartup = asyncResult(startHost()); + } + logger.info('Enabling the Argent artifacts list endpoint flag.'); + const enableArtifacts = argentExec([ + `${ARGENT_PACKAGE_NAME}@${versionSpec}`, + 'enable', + ARGENT_ARTIFACTS_LIST_ENDPOINT_FLAG, + ]); + await runArgentSetupCommandAsync( + enableArtifacts.command, + enableArtifacts.args, + { env, logger }, + setupSignal + ); + + logger.info('Enabling the Argent tool-server event log flag.'); + const enableEventLog = argentExec([ + `${ARGENT_PACKAGE_NAME}@${versionSpec}`, + 'enable', + ARGENT_EVENT_LOG_FLAG, + ]); + await runArgentSetupCommandAsync( + enableEventLog.command, + enableEventLog.args, + { env, logger }, + setupSignal + ); + + logger.info('Disabling the Argent video watermark flag.'); + const disableVideoWatermark = argentExec([ + `${ARGENT_PACKAGE_NAME}@${versionSpec}`, + 'disable', + ARGENT_VIDEO_WATERMARK_FLAG, + ]); + // The watermark is cosmetic, so a failure here must not fail the session. + try { + await runArgentSetupCommandAsync( + disableVideoWatermark.command, + disableVideoWatermark.args, + { env, logger }, + setupSignal + ); + } catch (err) { + setupSignal?.throwIfAborted(); + logger.warn( + { err }, + 'Could not disable the Argent video watermark, so recorded videos may include it. Continuing the session.' + ); + } + + setupSignal?.throwIfAborted(); + const startServer = argentExec([ + `${ARGENT_PACKAGE_NAME}@${versionSpec}`, + 'server', + 'start', + '--port', + '0', + '--idle-timeout', + '0', + '--force', + ]); + logger.info( + `Launching ${ARGENT_PACKAGE_NAME}@${versionSpec} tool-server via ${startServer.command}.` + ); + // Keep Argent itself in foreground mode under the detached process. This preserves + // the npx/bun -> Argent CLI -> tool-server ancestry used to identify the matching state file. + const secrets: string[] = []; + argentServer = spawnDetached({ + command: startServer.command, + args: startServer.args, + env: { ...env, ARGENT_EVENT_LOG: ARGENT_EVENT_LOG_PATH, ARGENT_EMULATOR_NO_WINDOW: '1' }, + logger, + secrets, + }); + if (argentServer.pid === undefined) { + throw new SystemError( + 'Failed to start Argent: could not determine the PID of the launched process.' + ); + } + logger.info(`Waiting for argent tool-server state in ${ARGENT_STATE_DIR}.`); let toolServerPort: number; let toolServerToken: string | undefined; @@ -239,6 +296,7 @@ export function createStartArgentRemoteSessionBuildFunction( ancestorPid: argentServer.pid, timeoutMs: STARTUP_TIMEOUT_MS, getExitError: argentServer.getExitError, + signal: setupSignal, }); toolServerPort = toolServerState.port; toolServerToken = toolServerState.token; @@ -253,6 +311,10 @@ export function createStartArgentRemoteSessionBuildFunction( }${output ? `\nArgent tool-server output:\n${output}` : ''}` ); } + if (hostStartup) { + sessionHost = (await hostStartup).enforceValue(); + } + setupSignal?.throwIfAborted(); logger.info(`Argent tool-server is listening on port ${toolServerPort}.`); const artifactPollSignal = signal ? AbortSignal.any([signal, artifactPollAbortController.signal]) @@ -287,22 +349,7 @@ export function createStartArgentRemoteSessionBuildFunction( if (launchDescription) { logger.info(launchDescription); } - sessionHost = await startDeviceSessionHostAsync(ctx, { - separateLogPhase: true, - runtimePlatform, - env, - logger, - timeoutMs: STARTUP_TIMEOUT_MS, - disableShare: inputs.public_preview?.value === false, - privatePreviewUrl: inputs.private_preview_url?.value as string | undefined, - frameAncestors: parseFrameAncestorsInput(inputs.frame_ancestors?.value), - corsOrigins: parseCorsOriginsInput(inputs.cors_origins?.value), - launchAppIdentifier: launch.launchAppIdentifier, - launchArgs: launch.launchArgs, - openUrl: launch.openUrl, - networkCapture, - networkCaptureFields, - }); + sessionHost ??= (await (hostStartup ??= asyncResult(startHost()))).enforceValue(); const webPreview = await sessionHost.openPreviewAsync({ baseDomain: ngrokTunnelDomain, authtoken: ngrokAuthtoken, @@ -345,8 +392,9 @@ export function createStartArgentRemoteSessionBuildFunction( }); } catch (error) { sessionFailed = true; - throw error; + throw setupSignal?.aborted ? setupSignal.reason : error; } finally { + startupAbortController.abort(new Error('Argent session ended.')); await finishRemoteSessionAsync({ logger, sessionFailed, @@ -378,11 +426,11 @@ export function createStartArgentRemoteSessionBuildFunction( ); } } finally { - await argentServer.stopAsync(); + await argentServer?.stopAsync(); } })(), ], - ['session host', sessionHost?.finishAsync()], + ['session host', hostStartup?.then(result => result.value?.finishAsync())], ], }); } @@ -442,6 +490,31 @@ export function warnIfArgentPackageVersionCannotBeVerified({ } } +async function runArgentSetupCommandAsync( + command: string, + args: string[], + options: SpawnOptions, + signal?: AbortSignal +): Promise { + if (!signal) { + await spawn(command, args, options); + return; + } + signal.throwIfAborted(); + const task = spawn(command, args, { ...options, detached: true }); + const abort = (): void => killProcessGroup(task.child, 'SIGKILL'); + signal.addEventListener('abort', abort, { once: true }); + if (signal.aborted) { + abort(); + } + try { + await task; + signal.throwIfAborted(); + } finally { + signal.removeEventListener('abort', abort); + } +} + function parseArgentToolServerState(raw: string): ArgentToolServerState { let json: unknown; try { @@ -464,13 +537,16 @@ export async function waitForArgentToolServerStateAsync({ timeoutMs, pollIntervalMs = 1_000, getExitError, + signal, }: { stateDir: string; ancestorPid: number; timeoutMs: number; pollIntervalMs?: number; getExitError?: () => Error | undefined; + signal?: AbortSignal; }): Promise { + signal?.throwIfAborted(); const deadline = Date.now() + timeoutMs; let lastError: unknown; const throwIfExited = (): void => { @@ -481,6 +557,7 @@ export async function waitForArgentToolServerStateAsync({ }; while (Date.now() < deadline) { + signal?.throwIfAborted(); throwIfExited(); let matchingState: ArgentToolServerState | undefined; try { @@ -506,13 +583,19 @@ export async function waitForArgentToolServerStateAsync({ lastError = err; } // Keep process failures outside the retryable state-file read errors. + signal?.throwIfAborted(); throwIfExited(); if (matchingState) { return matchingState; } - await sleepAsync(pollIntervalMs); + if (signal) { + await setTimeoutAsync(pollIntervalMs, undefined, { signal }); + } else { + await sleepAsync(pollIntervalMs); + } } + signal?.throwIfAborted(); throw new SystemError( `Timed out waiting for an argent tool-server state file belonging to process ${ancestorPid} in ${stateDir}${ lastError instanceof Error ? `: ${lastError.message}` : ''