From b40da04684c61463563e437268f60f803894c715 Mon Sep 17 00:00:00 2001 From: szdziedzic Date: Thu, 30 Jul 2026 13:05:56 +0200 Subject: [PATCH 1/4] Run Expo serve-sim under EAS supervision --- ...tArgentRemoteSession-orchestration.test.ts | 14 +- .../startAgentDeviceRemoteSession.ts | 92 ++++--- .../functions/startArgentRemoteSession.ts | 14 +- .../functions/startServeSimRemoteSession.ts | 36 +-- .../__tests__/remoteDeviceRunSession.test.ts | 102 +++++++ .../src/steps/utils/remoteDeviceRunSession.ts | 252 ++++++++++++++---- 6 files changed, 399 insertions(+), 111 deletions(-) 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 3fa8541e33..4b22fe3cb4 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 @@ -56,6 +56,7 @@ const ARGENT_STATE_DIR = path.join(TEST_HOME, '.argent'); const EXPECTED_EVENT_LOG_PATH = path.join(ARGENT_STATE_DIR, 'tool-server-events.jsonl'); const mockStopAsync = jest.fn(); +const mockTunnelStopAsync = jest.fn(); describe('createStartArgentRemoteSessionBuildFunction orchestration', () => { beforeEach(async () => { @@ -65,14 +66,22 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => { jest.mocked(isProcessDescendantOfAsync).mockResolvedValue(true); jest.mocked(pollArgentArtifactsForUploadAsync).mockResolvedValue(undefined); mockStopAsync.mockResolvedValue(undefined); + mockTunnelStopAsync.mockResolvedValue(undefined); jest.mocked(startArgentEventCollectionAsync).mockResolvedValue({ stopAsync: mockStopAsync }); jest.mocked(getDeviceRunSessionIdOrThrow).mockReturnValue('device-run-session-id'); jest.mocked(getNgrokTunnelDomainOrThrow).mockReturnValue('tunnel.example.com'); jest.mocked(getNgrokAuthtokenOrThrow).mockReturnValue('ngrok-token'); jest.mocked(selectXcodeDeveloperDirectoryAsync).mockResolvedValue(undefined); - jest.mocked(spawnDetached).mockReturnValue({ pid: 4242, getOutput: () => '' }); - jest.mocked(startNgrokTunnelAsync).mockResolvedValue('https://argent-abc.tunnel.example.com'); + jest.mocked(spawnDetached).mockReturnValue({ + pid: 4242, + getOutput: () => '', + stopAsync: jest.fn(), + }); + jest.mocked(startNgrokTunnelAsync).mockResolvedValue({ + url: 'https://argent-abc.tunnel.example.com', + stopAsync: mockTunnelStopAsync, + }); jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined); jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined); @@ -134,5 +143,6 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => { expect(mockStopAsync.mock.invocationCallOrder[0]).toBeGreaterThan( jest.mocked(waitForDeviceRunSessionStoppedAsync).mock.invocationCallOrder[0] ); + expect(mockTunnelStopAsync).toHaveBeenCalledTimes(1); }); }); diff --git a/packages/build-tools/src/steps/functions/startAgentDeviceRemoteSession.ts b/packages/build-tools/src/steps/functions/startAgentDeviceRemoteSession.ts index a8374b7e78..e73990886f 100644 --- a/packages/build-tools/src/steps/functions/startAgentDeviceRemoteSession.ts +++ b/packages/build-tools/src/steps/functions/startAgentDeviceRemoteSession.ts @@ -84,54 +84,57 @@ export function createStartAgentDeviceRemoteSessionBuildFunction( }); logger.info(`Daemon is listening on port ${daemonPort}; loaded auth token.`); - const agentDeviceRemoteSessionUrl = await startNgrokTunnelAsync({ + const agentDeviceTunnel = await startNgrokTunnelAsync({ port: daemonPort, subdomainPrefix: 'agent-device', baseDomain: ngrokTunnelDomain, authtoken: ngrokAuthtoken, logger, }); + const agentDeviceRemoteSessionUrl = agentDeviceTunnel.url; logger.info(`Tunnel is ready at ${agentDeviceRemoteSessionUrl}.`); - // serve-sim is iOS-only โ€” only launch it (and report a webPreviewUrl) - // on Darwin. Android sessions go without a preview URL. - let webPreviewUrl: string | undefined; - if (runtimePlatform === BuildRuntimePlatform.DARWIN) { - const { previewUrl } = await startServeSimWithTunnelAsync(ctx, { - baseDomain: ngrokTunnelDomain, - env, + let serveSim: Awaited> | undefined; + let eventCollection: + | Awaited> + | undefined; + try { + // serve-sim is iOS-only โ€” only launch it (and report a webPreviewUrl) + // on Darwin. Android sessions go without a preview URL. + if (runtimePlatform === BuildRuntimePlatform.DARWIN) { + serveSim = await startServeSimWithTunnelAsync(ctx, { + baseDomain: ngrokTunnelDomain, + env, + logger, + timeoutMs: STARTUP_TIMEOUT_MS, + }); + logger.info(`Web preview URL: ${serveSim.previewUrl}`); + } + + await uploadRemoteSessionConfigAsync({ + ctx, + deviceRunSessionId, + remoteConfig: { + agentDeviceRemoteSessionUrl, + agentDeviceRemoteSessionToken: daemonToken, + ...(serveSim ? { webPreviewUrl: serveSim.previewUrl } : {}), + }, + logger, + }); + void pollAgentDeviceArtifactsForUploadAsync(ctx, { + deviceRunSessionId, + daemonUrl: `http://127.0.0.1:${daemonPort}`, + daemonToken, logger, - timeoutMs: STARTUP_TIMEOUT_MS, }); - webPreviewUrl = previewUrl; - logger.info(`Web preview URL: ${webPreviewUrl}`); - } - - await uploadRemoteSessionConfigAsync({ - ctx, - deviceRunSessionId, - remoteConfig: { - agentDeviceRemoteSessionUrl, - agentDeviceRemoteSessionToken: daemonToken, - ...(webPreviewUrl ? { webPreviewUrl } : {}), - }, - logger, - }); - void pollAgentDeviceArtifactsForUploadAsync(ctx, { - deviceRunSessionId, - daemonUrl: `http://127.0.0.1:${daemonPort}`, - daemonToken, - logger, - }); - const eventCollection = await startAgentDeviceEventCollectionAsync({ - ctx, - deviceRunSessionId, - stateDir: AGENT_DEVICE_STATE_DIR, - logger, - }); + eventCollection = await startAgentDeviceEventCollectionAsync({ + ctx, + deviceRunSessionId, + stateDir: AGENT_DEVICE_STATE_DIR, + logger, + }); - try { await waitForDeviceRunSessionStoppedAsync({ ctx, deviceRunSessionId, @@ -139,11 +142,18 @@ export function createStartAgentDeviceRemoteSessionBuildFunction( signal, }); } finally { - await stopAgentDeviceEventCollectionSafelyAsync({ - eventCollection, - deviceRunSessionId, - logger, - }); + if (serveSim) { + await serveSim.stopAsync(); + } + await agentDeviceTunnel.stopAsync(); + if (eventCollection) { + await stopAgentDeviceEventCollectionSafelyAsync({ + eventCollection, + deviceRunSessionId, + logger, + }); + } + await daemonProcess.stopAsync(); } }, }); diff --git a/packages/build-tools/src/steps/functions/startArgentRemoteSession.ts b/packages/build-tools/src/steps/functions/startArgentRemoteSession.ts index f507a97e1f..b88fbd797f 100644 --- a/packages/build-tools/src/steps/functions/startArgentRemoteSession.ts +++ b/packages/build-tools/src/steps/functions/startArgentRemoteSession.ts @@ -165,8 +165,10 @@ export function createStartArgentRemoteSessionBuildFunction( logger, }); + let toolsTunnel: Awaited> | undefined; + let serveSim: Awaited> | undefined; try { - const publicToolsUrl = await startNgrokTunnelAsync({ + toolsTunnel = await startNgrokTunnelAsync({ port: toolServerPort, subdomainPrefix: 'argent', baseDomain: ngrokTunnelDomain, @@ -174,12 +176,13 @@ export function createStartArgentRemoteSessionBuildFunction( rewriteHostHeader: true, logger, }); + const publicToolsUrl = toolsTunnel.url; logger.info(`Tunnel is ready at ${publicToolsUrl}.`); // serve-sim is iOS-only โ€” Android sessions go without a preview URL. let webPreviewUrl: string | undefined; if (runtimePlatform === BuildRuntimePlatform.DARWIN) { - const serveSim = await startServeSimWithTunnelAsync(ctx, { + serveSim = await startServeSimWithTunnelAsync(ctx, { baseDomain: ngrokTunnelDomain, env, logger, @@ -207,6 +210,12 @@ export function createStartArgentRemoteSessionBuildFunction( signal, }); } finally { + if (serveSim) { + await serveSim.stopAsync(); + } + if (toolsTunnel) { + await toolsTunnel.stopAsync(); + } await stopArgentEventCollectionSafelyAsync({ eventCollection, deviceRunSessionId, logger }); artifactPollAbortController.abort(); try { @@ -216,6 +225,7 @@ export function createStartArgentRemoteSessionBuildFunction( Sentry.capture('Could not finish Argent remote session artifact polling', error); logger.warn({ err: error }, 'Could not finish Argent remote session artifact polling.'); } + await argentServer.stopAsync(); } }, }); diff --git a/packages/build-tools/src/steps/functions/startServeSimRemoteSession.ts b/packages/build-tools/src/steps/functions/startServeSimRemoteSession.ts index 69c24f21c6..eafe44cee4 100644 --- a/packages/build-tools/src/steps/functions/startServeSimRemoteSession.ts +++ b/packages/build-tools/src/steps/functions/startServeSimRemoteSession.ts @@ -29,27 +29,31 @@ export function createStartServeSimRemoteSessionBuildFunction( await selectXcodeDeveloperDirectoryAsync({ env, logger }); - const { previewUrl } = await startServeSimWithTunnelAsync(ctx, { + const serveSim = await startServeSimWithTunnelAsync(ctx, { baseDomain: ngrokTunnelDomain, env, logger, timeoutMs: STARTUP_TIMEOUT_MS, }); - logger.info(`Preview URL: ${previewUrl}`); - - await uploadRemoteSessionConfigAsync({ - ctx, - deviceRunSessionId, - remoteConfig: { previewUrl }, - logger, - }); - - await waitForDeviceRunSessionStoppedAsync({ - ctx, - deviceRunSessionId, - logger, - signal, - }); + logger.info(`Preview URL: ${serveSim.previewUrl}`); + + try { + await uploadRemoteSessionConfigAsync({ + ctx, + deviceRunSessionId, + remoteConfig: { previewUrl: serveSim.previewUrl }, + logger, + }); + + await waitForDeviceRunSessionStoppedAsync({ + ctx, + deviceRunSessionId, + logger, + signal, + }); + } finally { + await serveSim.stopAsync(); + } }, }); } diff --git a/packages/build-tools/src/steps/utils/__tests__/remoteDeviceRunSession.test.ts b/packages/build-tools/src/steps/utils/__tests__/remoteDeviceRunSession.test.ts index d1ef10adc7..2f3fca32ac 100644 --- a/packages/build-tools/src/steps/utils/__tests__/remoteDeviceRunSession.test.ts +++ b/packages/build-tools/src/steps/utils/__tests__/remoteDeviceRunSession.test.ts @@ -1,18 +1,25 @@ import { bunyan } from '@expo/logger'; import { BuildStepEnv } from '@expo/steps'; +import * as ngrok from '@ngrok/ngrok'; import { setTimeout as setTimeoutAsync } from 'node:timers/promises'; import { CustomBuildContext } from '../../../customBuildContext'; import { Sentry } from '../../../sentry'; import { turtleFetch } from '../../../utils/turtleFetch'; +import { sleepAsync } from '../../../utils/retry'; import { + createServeSimArgs, fetchServeSimTurnArgsAsync, + startNgrokTunnelAsync, turnIceServersToServeSimArgs, waitForDeviceRunSessionStoppedAsync, + waitForServeSimReadyAsync, } from '../remoteDeviceRunSession'; +jest.mock('@ngrok/ngrok'); jest.mock('node:timers/promises'); jest.mock('../../../utils/turtleFetch'); +jest.mock('../../../utils/retry', () => ({ sleepAsync: jest.fn() })); jest.mock('../../../sentry'); function createLoggerMock(): bunyan { @@ -80,6 +87,101 @@ function createEnvMock(): BuildStepEnv { return { DEVICE_RUN_SESSION_ID: 'drs-id' } as unknown as BuildStepEnv; } +describe(createServeSimArgs, () => { + it('pins the Expo package and applies the EAS streaming policy', () => { + expect( + createServeSimArgs({ + port: 4321, + turnArgs: ['--turn-url', 'turns:turn.example.test:443'], + }) + ).toEqual([ + '--yes', + '@expo/serve-sim@0.1.37', + '--port', + '4321', + '--host', + '127.0.0.1', + '--transport', + 'webrtc', + '--webrtc-codec', + 'h264', + '--max-dimension', + '1280', + '--mjpeg-fps', + '10', + '--mjpeg-quality', + '0.55', + '--h264-bitrate', + '3000000', + '--h264-fps', + '30', + '--turn-url', + 'turns:turn.example.test:443', + ]); + }); +}); + +describe(waitForServeSimReadyAsync, () => { + beforeEach(() => { + jest.mocked(turtleFetch).mockReset(); + jest.mocked(sleepAsync).mockReset(); + jest.mocked(sleepAsync).mockResolvedValue(undefined); + }); + + it('waits for the stable readiness endpoint', async () => { + jest + .mocked(turtleFetch) + .mockRejectedValueOnce(new Error('not ready')) + .mockResolvedValueOnce({ + json: async () => ({ status: 'ready', device: 'DEVICE-A' }), + } as unknown as Awaited>); + + await waitForServeSimReadyAsync({ + serveSim: { pid: undefined, getOutput: () => '' }, + port: 4321, + timeoutMs: 10_000, + }); + + expect(jest.mocked(turtleFetch)).toHaveBeenCalledTimes(2); + expect(jest.mocked(turtleFetch)).toHaveBeenLastCalledWith( + 'http://127.0.0.1:4321/readyz', + 'GET', + expect.objectContaining({ retries: 0 }) + ); + expect(sleepAsync).toHaveBeenCalledTimes(1); + }); +}); + +describe(startNgrokTunnelAsync, () => { + it('uses a 128-bit capability hostname and exposes explicit cleanup', async () => { + const close = jest.fn().mockResolvedValue(undefined); + jest.mocked(ngrok.forward).mockResolvedValue({ + url: () => 'https://serve-sim.example.test', + close, + } as never); + + const tunnel = await startNgrokTunnelAsync({ + port: 4321, + subdomainPrefix: 'serve-sim', + baseDomain: 'tunnel.example.test', + authtoken: 'token', + logger: createLoggerMock(), + }); + + expect(ngrok.forward).toHaveBeenCalledWith( + expect.objectContaining({ + addr: 4321, + authtoken: 'token', + domain: expect.stringMatching(/^serve-sim-[a-f0-9]{32}\.tunnel\.example\.test$/), + }) + ); + expect(tunnel.url).toBe('https://serve-sim.example.test'); + await tunnel.stopAsync(); + await tunnel.stopAsync(); + expect(close).toHaveBeenCalledTimes(1); + }); +}); + describe(turnIceServersToServeSimArgs, () => { it('returns no args for an empty ICE server list', () => { expect(turnIceServersToServeSimArgs([])).toEqual([]); diff --git a/packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts b/packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts index 4bb849bf49..a5e449a522 100644 --- a/packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts +++ b/packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts @@ -8,6 +8,7 @@ import nullthrows from 'nullthrows'; import { z } from 'zod'; import { randomBytes } from 'node:crypto'; import fs from 'node:fs'; +import { createServer } from 'node:net'; import { setTimeout as setTimeoutAsync } from 'node:timers/promises'; import { CustomBuildContext } from '../../customBuildContext'; @@ -16,6 +17,13 @@ import { sleepAsync } from '../../utils/retry'; import { turtleFetch } from '../../utils/turtleFetch'; const XCODE_DEVELOPER_DIR = '/Applications/Xcode.app/Contents/Developer'; +const SERVE_SIM_PACKAGE_SPEC = '@expo/serve-sim@0.1.37'; +const SERVE_SIM_HOST = '127.0.0.1'; +const SERVE_SIM_MAX_DIMENSION = '1280'; +const SERVE_SIM_MJPEG_FPS = '10'; +const SERVE_SIM_MJPEG_QUALITY = '0.55'; +const SERVE_SIM_H264_BITRATE = '3000000'; +const SERVE_SIM_H264_FPS = '30'; const START_DEVICE_RUN_SESSION_MUTATION = graphql(` mutation StartDeviceRunSession($deviceRunSessionId: ID!, $remoteConfig: JSONObject!) { @@ -296,8 +304,50 @@ export type DetachedProcessHandle = { /** PID of the directly spawned process, if the OS assigned one. */ pid: number | undefined; getOutput: () => string; + stopAsync: () => Promise; }; +function isProcessRunning(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +async function stopDetachedProcessAsync(pid: number | undefined): Promise { + if (pid === undefined || !isProcessRunning(pid)) { + return; + } + try { + // spawnDetached creates a dedicated process group. Signaling the group also + // terminates npx/bunx descendants instead of leaving the actual daemon alive. + process.kill(-pid, 'SIGTERM'); + } catch { + try { + process.kill(pid, 'SIGTERM'); + } catch { + return; + } + } + + const deadline = Date.now() + 5_000; + while (Date.now() < deadline && isProcessRunning(pid)) { + await sleepAsync(100); + } + if (!isProcessRunning(pid)) { + return; + } + try { + process.kill(-pid, 'SIGKILL'); + } catch { + try { + process.kill(pid, 'SIGKILL'); + } catch {} + } +} + export function spawnDetached({ command, args, @@ -327,9 +377,110 @@ export function spawnDetached({ promise.child.stdout?.on('data', appendChunk); promise.child.stderr?.on('data', appendChunk); - return { pid: promise.child.pid, getOutput: () => output }; + const pid = promise.child.pid; + return { + pid, + getOutput: () => output, + stopAsync: async () => await stopDetachedProcessAsync(pid), + }; +} + +export function createServeSimArgs({ + port, + turnArgs = [], +}: { + port: number; + turnArgs?: string[]; +}): string[] { + return [ + '--yes', + SERVE_SIM_PACKAGE_SPEC, + '--port', + String(port), + '--host', + SERVE_SIM_HOST, + '--transport', + 'webrtc', + '--webrtc-codec', + 'h264', + '--max-dimension', + SERVE_SIM_MAX_DIMENSION, + '--mjpeg-fps', + SERVE_SIM_MJPEG_FPS, + '--mjpeg-quality', + SERVE_SIM_MJPEG_QUALITY, + '--h264-bitrate', + SERVE_SIM_H264_BITRATE, + '--h264-fps', + SERVE_SIM_H264_FPS, + ...turnArgs, + ]; +} + +async function findAvailablePortAsync(): Promise { + const server = createServer(); + server.unref(); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, SERVE_SIM_HOST, () => resolve()); + }); + const address = server.address(); + await new Promise((resolve, reject) => { + server.close(err => (err ? reject(err) : resolve())); + }); + if (!address || typeof address === 'string') { + throw new SystemError('Could not allocate a local port for serve-sim.'); + } + return address.port; +} + +const ServeSimReadyResponseSchema = z.object({ + status: z.literal('ready'), + device: z.string(), +}); + +export async function waitForServeSimReadyAsync({ + serveSim, + port, + timeoutMs, +}: { + serveSim: Pick; + port: number; + timeoutMs: number; +}): Promise { + const readyUrl = `http://${SERVE_SIM_HOST}:${port}/readyz`; + const deadline = Date.now() + timeoutMs; + let lastError: unknown; + while (Date.now() < deadline) { + if (serveSim.pid !== undefined && !isProcessRunning(serveSim.pid)) { + throw new SystemError( + `serve-sim exited before becoming ready. Last output:\n${serveSim.getOutput() || ''}` + ); + } + try { + const response = await turtleFetch(readyUrl, 'GET', { + retries: 0, + timeout: 2_000, + }); + ServeSimReadyResponseSchema.parse(await response.json()); + return; + } catch (error) { + lastError = error; + } + await sleepAsync(1_000); + } + throw new SystemError( + `Timed out waiting for serve-sim readiness at ${readyUrl}${ + lastError instanceof Error ? `: ${lastError.message}` : '' + }. Last output:\n${serveSim.getOutput() || ''}` + ); } +export type ServeSimPreviewHandle = { + previewUrl: string; + stopAsync: () => Promise; +}; + export async function startServeSimWithTunnelAsync( ctx: CustomBuildContext, { @@ -343,57 +494,47 @@ export async function startServeSimWithTunnelAsync( logger: bunyan; timeoutMs: number; } -): Promise<{ previewUrl: string }> { - logger.info('Launching serve-sim with tunnel.'); +): Promise { + const port = await findAvailablePortAsync(); + logger.info(`Launching ${SERVE_SIM_PACKAGE_SPEC} on ${SERVE_SIM_HOST}:${port}.`); const turnArgs = await fetchServeSimTurnArgsAsync(ctx, { env, logger }); const serveSim = spawnDetached({ command: 'npx', - args: [ - 'serve-sim-szdziedzic@latest', - '--tunnel', - '--tunnel-provider', - 'ngrok', - '--tunnel-domain', - baseDomain, - '--stream-max-dimension', - '1280', - '--stream-quality', - '0.55', - '--codec', - 'webrtc', - ...turnArgs, - ], + args: createServeSimArgs({ port, turnArgs }), env, }); - logger.info('Waiting for serve-sim to report tunnel URL.'); - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - const output = serveSim.getOutput(); - const previewUrl = matchTunnelUrl({ output, baseDomain }); - if (previewUrl) { - return { previewUrl }; - } - await sleepAsync(1_000); + try { + logger.info('Waiting for serve-sim to become ready.'); + await waitForServeSimReadyAsync({ serveSim, port, timeoutMs }); + const tunnel = await startNgrokTunnelAsync({ + port, + subdomainPrefix: 'serve-sim', + baseDomain, + authtoken: getNgrokAuthtokenOrThrow(env), + logger, + }); + return { + previewUrl: tunnel.url, + stopAsync: async () => { + const results = await Promise.allSettled([tunnel.stopAsync(), serveSim.stopAsync()]); + for (const result of results) { + if (result.status === 'rejected') { + logger.warn({ err: result.reason }, 'Could not stop a serve-sim preview resource.'); + } + } + }, + }; + } catch (error) { + await serveSim.stopAsync(); + throw error; } - throw new SystemError( - `Timed out waiting for serve-sim to report Tunnel URL. Last output:\n${serveSim.getOutput() || ''}` - ); } -function matchTunnelUrl({ - output, - baseDomain, -}: { - output: string; - baseDomain: string; -}): string | null { - const labelPattern = new RegExp( - `Tunnel:\\s*(https:\\/\\/[a-z0-9-]+\\.${escapeRegExp(baseDomain)})` - ); - const match = labelPattern.exec(output); - return match ? match[1] : null; -} +export type NgrokTunnelHandle = { + url: string; + stopAsync: () => Promise; +}; export async function startNgrokTunnelAsync({ port, @@ -409,8 +550,8 @@ export async function startNgrokTunnelAsync({ authtoken: string; rewriteHostHeader?: boolean; logger: bunyan; -}): Promise { - const domain = `${subdomainPrefix}-${randomBytes(8).toString('hex')}.${baseDomain}`; +}): Promise { + const domain = `${subdomainPrefix}-${randomBytes(16).toString('hex')}.${baseDomain}`; logger.info(`Starting ngrok tunnel ${domain} -> http://localhost:${port}.`); // Run the ngrok agent in-process via the SDK; it keeps the session alive until // the process exits, and the step blocks forever to hold it open. @@ -422,13 +563,24 @@ export async function startNgrokTunnelAsync({ }); const url = listener.url(); if (!url) { + await listener.close(); throw new SystemError(`ngrok tunnel for ${domain} did not return a public URL.`); } - return url; -} - -function escapeRegExp(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + let stopped = false; + return { + url, + stopAsync: async () => { + if (stopped) { + return; + } + stopped = true; + try { + await listener.close(); + } catch (error) { + logger.warn({ err: error }, `Could not stop ngrok tunnel ${domain}.`); + } + }, + }; } export async function waitForFileAsync({ From 8cbc40601b9baaa63fe2ef762118358ea20d2e4a Mon Sep 17 00:00:00 2001 From: szdziedzic Date: Thu, 30 Jul 2026 13:08:32 +0200 Subject: [PATCH 2/4] Add build-tools changelog entry --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8820100bdc..b7fe16ff05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,8 @@ This is the log of notable changes to EAS CLI and related packages. ### ๐Ÿงน Chores +- [build-tools] Run EAS Simulator previews with the official `@expo/serve-sim` package under EAS-owned supervision. ([#4114](https://github.com/expo/eas-cli/pull/4114) by [@szdziedzic](https://github.com/szdziedzic)) + ## [21.4.0](https://github.com/expo/eas-cli/releases/tag/v21.4.0) - 2026-07-28 ### ๐ŸŽ‰ New features From 6689e383b687da2fbfb15b2078c9abbc92982c47 Mon Sep 17 00:00:00 2001 From: szdziedzic Date: Thu, 30 Jul 2026 13:30:39 +0200 Subject: [PATCH 3/4] Use VM-safe VP8 streaming policy --- .../__tests__/remoteDeviceRunSession.test.ts | 10 ++++------ .../src/steps/utils/remoteDeviceRunSession.ts | 17 +++++++---------- 2 files changed, 11 insertions(+), 16 deletions(-) diff --git a/packages/build-tools/src/steps/utils/__tests__/remoteDeviceRunSession.test.ts b/packages/build-tools/src/steps/utils/__tests__/remoteDeviceRunSession.test.ts index 2f3fca32ac..de88f84900 100644 --- a/packages/build-tools/src/steps/utils/__tests__/remoteDeviceRunSession.test.ts +++ b/packages/build-tools/src/steps/utils/__tests__/remoteDeviceRunSession.test.ts @@ -104,17 +104,15 @@ describe(createServeSimArgs, () => { '--transport', 'webrtc', '--webrtc-codec', - 'h264', + 'vp8', '--max-dimension', '1280', - '--mjpeg-fps', - '10', '--mjpeg-quality', '0.55', - '--h264-bitrate', + '--video-bitrate', '3000000', - '--h264-fps', - '30', + '--video-fps', + '60', '--turn-url', 'turns:turn.example.test:443', ]); diff --git a/packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts b/packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts index a5e449a522..7bd92745dd 100644 --- a/packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts +++ b/packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts @@ -20,10 +20,9 @@ const XCODE_DEVELOPER_DIR = '/Applications/Xcode.app/Contents/Developer'; const SERVE_SIM_PACKAGE_SPEC = '@expo/serve-sim@0.1.37'; const SERVE_SIM_HOST = '127.0.0.1'; const SERVE_SIM_MAX_DIMENSION = '1280'; -const SERVE_SIM_MJPEG_FPS = '10'; const SERVE_SIM_MJPEG_QUALITY = '0.55'; -const SERVE_SIM_H264_BITRATE = '3000000'; -const SERVE_SIM_H264_FPS = '30'; +const SERVE_SIM_VIDEO_BITRATE = '3000000'; +const SERVE_SIM_VIDEO_FPS = '60'; const START_DEVICE_RUN_SESSION_MUTATION = graphql(` mutation StartDeviceRunSession($deviceRunSessionId: ID!, $remoteConfig: JSONObject!) { @@ -402,17 +401,15 @@ export function createServeSimArgs({ '--transport', 'webrtc', '--webrtc-codec', - 'h264', + 'vp8', '--max-dimension', SERVE_SIM_MAX_DIMENSION, - '--mjpeg-fps', - SERVE_SIM_MJPEG_FPS, '--mjpeg-quality', SERVE_SIM_MJPEG_QUALITY, - '--h264-bitrate', - SERVE_SIM_H264_BITRATE, - '--h264-fps', - SERVE_SIM_H264_FPS, + '--video-bitrate', + SERVE_SIM_VIDEO_BITRATE, + '--video-fps', + SERVE_SIM_VIDEO_FPS, ...turnArgs, ]; } From cc0a823b7f3cde531da0e902dc5b13e14f1b0b6e Mon Sep 17 00:00:00 2001 From: szdziedzic Date: Thu, 30 Jul 2026 13:49:01 +0200 Subject: [PATCH 4/4] Always install latest serve-sim --- .../src/steps/utils/__tests__/remoteDeviceRunSession.test.ts | 4 ++-- .../build-tools/src/steps/utils/remoteDeviceRunSession.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/build-tools/src/steps/utils/__tests__/remoteDeviceRunSession.test.ts b/packages/build-tools/src/steps/utils/__tests__/remoteDeviceRunSession.test.ts index de88f84900..5b5ed21e2b 100644 --- a/packages/build-tools/src/steps/utils/__tests__/remoteDeviceRunSession.test.ts +++ b/packages/build-tools/src/steps/utils/__tests__/remoteDeviceRunSession.test.ts @@ -88,7 +88,7 @@ function createEnvMock(): BuildStepEnv { } describe(createServeSimArgs, () => { - it('pins the Expo package and applies the EAS streaming policy', () => { + it('uses the latest Expo package and applies the EAS streaming policy', () => { expect( createServeSimArgs({ port: 4321, @@ -96,7 +96,7 @@ describe(createServeSimArgs, () => { }) ).toEqual([ '--yes', - '@expo/serve-sim@0.1.37', + '@expo/serve-sim@latest', '--port', '4321', '--host', diff --git a/packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts b/packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts index 7bd92745dd..cf444a061c 100644 --- a/packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts +++ b/packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts @@ -17,7 +17,7 @@ import { sleepAsync } from '../../utils/retry'; import { turtleFetch } from '../../utils/turtleFetch'; const XCODE_DEVELOPER_DIR = '/Applications/Xcode.app/Contents/Developer'; -const SERVE_SIM_PACKAGE_SPEC = '@expo/serve-sim@0.1.37'; +const SERVE_SIM_PACKAGE_SPEC = '@expo/serve-sim@latest'; const SERVE_SIM_HOST = '127.0.0.1'; const SERVE_SIM_MAX_DIMENSION = '1280'; const SERVE_SIM_MJPEG_QUALITY = '0.55';