From 206ff8147b657b2a066167dccb997152a5b180fb Mon Sep 17 00:00:00 2001 From: szdziedzic Date: Wed, 29 Jul 2026 14:48:54 +0200 Subject: [PATCH 1/7] [build-tools] Install ffmpeg for argent screen recording when missing Argent encodes screen recordings by piping simulator frames into ffmpeg. The macOS worker image does not ship it, so `screen-recording-start` fails with "`ffmpeg` was not found on PATH" on every EAS Simulator argent session. Installing ffmpeg pulls a large Homebrew dependency tree, so this runs in the background rather than delaying session readiness. It is best-effort: screen recording is one optional argent tool, so a failure is logged and the session continues without it. Co-Authored-By: Claude Opus 5 --- .../functions/startArgentRemoteSession.ts | 7 ++ .../__tests__/remoteDeviceRunSession.test.ts | 103 +++++++++++++++++- .../src/steps/utils/remoteDeviceRunSession.ts | 71 +++++++++++- 3 files changed, 179 insertions(+), 2 deletions(-) diff --git a/packages/build-tools/src/steps/functions/startArgentRemoteSession.ts b/packages/build-tools/src/steps/functions/startArgentRemoteSession.ts index b88fbd797f..a62c2dfe91 100644 --- a/packages/build-tools/src/steps/functions/startArgentRemoteSession.ts +++ b/packages/build-tools/src/steps/functions/startArgentRemoteSession.ts @@ -20,6 +20,7 @@ import { sleepAsync } from '../../utils/retry'; import { pollArgentArtifactsForUploadAsync } from '../utils/argentArtifacts'; import { ARGENT_EVENT_LOG_FILENAME, startArgentEventCollectionAsync } from '../utils/argentEvents'; import { + ensureFfmpegInstalledAsync, getDeviceRunSessionIdOrThrow, getNgrokAuthtokenOrThrow, getNgrokTunnelDomainOrThrow, @@ -89,6 +90,12 @@ export function createStartArgentRemoteSessionBuildFunction( await selectXcodeDeveloperDirectoryAsync({ env, logger }); } + // Argent shells out to ffmpeg to record the screen. Installing it pulls a + // large Homebrew dependency tree, so do not block session readiness on it: + // the session comes up at its usual speed and only a recording started in + // the first moments misses ffmpeg. Never rejects, so `void` is safe. + void ensureFfmpegInstalledAsync({ runtimePlatform, env, logger }); + logger.info('Enabling the Argent artifacts list endpoint flag.'); await spawn( 'bunx', 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 5b5ed21e2b..d764bc5f4a 100644 --- a/packages/build-tools/src/steps/utils/__tests__/remoteDeviceRunSession.test.ts +++ b/packages/build-tools/src/steps/utils/__tests__/remoteDeviceRunSession.test.ts @@ -1,6 +1,8 @@ import { bunyan } from '@expo/logger'; -import { BuildStepEnv } from '@expo/steps'; +import { BuildRuntimePlatform, BuildStepEnv } from '@expo/steps'; +import spawn from '@expo/turtle-spawn'; import * as ngrok from '@ngrok/ngrok'; +import fs from 'node:fs'; import { setTimeout as setTimeoutAsync } from 'node:timers/promises'; import { CustomBuildContext } from '../../../customBuildContext'; @@ -9,6 +11,7 @@ import { turtleFetch } from '../../../utils/turtleFetch'; import { sleepAsync } from '../../../utils/retry'; import { createServeSimArgs, + ensureFfmpegInstalledAsync, fetchServeSimTurnArgsAsync, startNgrokTunnelAsync, turnIceServersToServeSimArgs, @@ -21,6 +24,7 @@ jest.mock('node:timers/promises'); jest.mock('../../../utils/turtleFetch'); jest.mock('../../../utils/retry', () => ({ sleepAsync: jest.fn() })); jest.mock('../../../sentry'); +jest.mock('@expo/turtle-spawn'); function createLoggerMock(): bunyan { return { @@ -379,3 +383,100 @@ describe(waitForDeviceRunSessionStoppedAsync, () => { ); }); }); + +describe(ensureFfmpegInstalledAsync, () => { + const spawnMock = jest.mocked(spawn); + let existsSyncSpy: jest.SpyInstance; + + function spawnResolved(): ReturnType { + return Promise.resolve({}) as unknown as ReturnType; + } + + function spawnRejected(): ReturnType { + return Promise.reject(new Error('boom')) as unknown as ReturnType; + } + + beforeEach(() => { + spawnMock.mockReset(); + jest.mocked(Sentry).capture.mockReset(); + // Default to "no ffmpeg on any fallback path" so each test opts in. + existsSyncSpy = jest.spyOn(fs, 'existsSync').mockReturnValue(false); + }); + + afterEach(() => { + existsSyncSpy.mockRestore(); + }); + + it('does not install when ffmpeg is already on a fallback path', async () => { + existsSyncSpy.mockImplementation(path => path === '/opt/homebrew/bin/ffmpeg'); + + await ensureFfmpegInstalledAsync({ + runtimePlatform: BuildRuntimePlatform.DARWIN, + env: createEnvMock(), + logger: createLoggerMock(), + }); + + expect(spawnMock).not.toHaveBeenCalled(); + }); + + it('does not install when ffmpeg is on PATH', async () => { + spawnMock.mockReturnValueOnce(spawnResolved()); + + await ensureFfmpegInstalledAsync({ + runtimePlatform: BuildRuntimePlatform.DARWIN, + env: createEnvMock(), + logger: createLoggerMock(), + }); + + expect(spawnMock).toHaveBeenCalledTimes(1); + expect(spawnMock).toHaveBeenCalledWith('sh', ['-c', 'command -v ffmpeg'], expect.anything()); + }); + + it('installs ffmpeg with Homebrew on darwin when it is missing', async () => { + spawnMock.mockReturnValueOnce(spawnRejected()).mockReturnValueOnce(spawnResolved()); + + await ensureFfmpegInstalledAsync({ + runtimePlatform: BuildRuntimePlatform.DARWIN, + env: createEnvMock(), + logger: createLoggerMock(), + }); + + expect(spawnMock).toHaveBeenLastCalledWith( + 'brew', + ['install', 'ffmpeg'], + expect.objectContaining({ + env: expect.objectContaining({ HOMEBREW_NO_AUTO_UPDATE: '1' }), + }) + ); + }); + + it('warns instead of installing on linux, where Homebrew is unavailable', async () => { + spawnMock.mockReturnValueOnce(spawnRejected()); + const logger = createLoggerMock(); + + await ensureFfmpegInstalledAsync({ + runtimePlatform: BuildRuntimePlatform.LINUX, + env: createEnvMock(), + logger, + }); + + expect(spawnMock).toHaveBeenCalledTimes(1); + expect(logger.warn).toHaveBeenCalled(); + }); + + it('warns and resolves when the install fails, so the session still starts', async () => { + spawnMock.mockReturnValueOnce(spawnRejected()).mockReturnValueOnce(spawnRejected()); + const logger = createLoggerMock(); + + await expect( + ensureFfmpegInstalledAsync({ + runtimePlatform: BuildRuntimePlatform.DARWIN, + env: createEnvMock(), + logger, + }) + ).resolves.toBeUndefined(); + + expect(logger.warn).toHaveBeenCalled(); + expect(jest.mocked(Sentry).capture).toHaveBeenCalled(); + }); +}); diff --git a/packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts b/packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts index cf444a061c..3cab4aded1 100644 --- a/packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts +++ b/packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts @@ -1,6 +1,7 @@ import { SystemError } from '@expo/eas-build-job'; import { bunyan } from '@expo/logger'; -import { BuildStepEnv, spawnAsync } from '@expo/steps'; +import { asyncResult } from '@expo/results'; +import { BuildRuntimePlatform, BuildStepEnv, spawnAsync } from '@expo/steps'; import spawn from '@expo/turtle-spawn'; import * as ngrok from '@ngrok/ngrok'; import { graphql } from 'gql.tada'; @@ -186,6 +187,74 @@ async function sleepUntilAbortedAsync( } } +// Argent encodes screen recordings by piping simulator frames into `ffmpeg`, +// which it resolves from PATH and then from these prefixes. Keep the list in +// sync with argent so we never reinstall a binary it can already find. +const FFMPEG_FALLBACK_PATHS = [ + '/opt/homebrew/bin/ffmpeg', + '/usr/local/bin/ffmpeg', + '/usr/bin/ffmpeg', +]; + +async function isFfmpegAvailableAsync(env: BuildStepEnv): Promise { + if (FFMPEG_FALLBACK_PATHS.some(ffmpegPath => fs.existsSync(ffmpegPath))) { + return true; + } + return (await asyncResult(spawn('sh', ['-c', 'command -v ffmpeg'], { env }))).ok; +} + +/** + * Install ffmpeg when the runtime does not already provide it, so argent's + * `screen-recording-start` tool can encode a video. The worker image does not + * ship ffmpeg yet, so without this the tool fails with "`ffmpeg` was not found + * on PATH". + * + * Best-effort by design: screen recording is one optional argent tool, so a + * failure here is logged and the session continues without it. Never rejects, + * which is what lets the caller run it in the background. + */ +export async function ensureFfmpegInstalledAsync({ + runtimePlatform, + env, + logger, +}: { + runtimePlatform: BuildRuntimePlatform; + env: BuildStepEnv; + logger: bunyan; +}): Promise { + if (await isFfmpegAvailableAsync(env)) { + logger.info('ffmpeg is already installed.'); + return; + } + + // Homebrew is only available on the macOS workers. Linux workers would need a + // package manager and sudo, so report the gap instead of guessing. + if (runtimePlatform !== BuildRuntimePlatform.DARWIN) { + logger.warn( + `ffmpeg is not installed and EAS only installs it on ${BuildRuntimePlatform.DARWIN} runtimes. ` + + 'Argent screen recording will not work in this session.' + ); + return; + } + + logger.info('ffmpeg is not installed, installing it with Homebrew for argent screen recording.'); + const installResult = await asyncResult( + spawn('brew', ['install', 'ffmpeg'], { + env: { ...env, HOMEBREW_NO_AUTO_UPDATE: '1' }, + logger, + }) + ); + if (!installResult.ok) { + Sentry.capture('Could not install ffmpeg for argent screen recording', installResult.reason); + logger.warn( + { err: installResult.reason }, + 'Could not install ffmpeg. Argent screen recording will not work in this session.' + ); + return; + } + logger.info('Installed ffmpeg.'); +} + const TurnIceServersResponseSchema = z.object({ data: z.object({ iceServers: TurnIceServersSchema, From 6be07dcbabb71eb768f429d68a75ca1520b7515d Mon Sep 17 00:00:00 2001 From: szdziedzic Date: Wed, 29 Jul 2026 14:50:43 +0200 Subject: [PATCH 2/7] update CHANGELOG.md --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b7fe16ff05..99f6111323 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ This is the log of notable changes to EAS CLI and related packages. ### ๐Ÿ› Bug fixes - [eas-cli] clean up error handling in local builds. ([#4105](https://github.com/expo/eas-cli/pull/4105) by [@douglowder](https://github.com/douglowder)) +- [build-tools] Install ffmpeg when it is missing so Argent screen recording works in EAS Simulator sessions. ([#4110](https://github.com/expo/eas-cli/pull/4110) by [@szdziedzic](https://github.com/szdziedzic)) ### ๐Ÿงน Chores From e571caf6fb802d31a58e27eec2d210645dd46d91 Mon Sep 17 00:00:00 2001 From: szdziedzic Date: Wed, 29 Jul 2026 16:40:11 +0200 Subject: [PATCH 3/7] [build-tools] Mock ensureFfmpegInstalledAsync in the orchestration test The orchestration test replaces the whole remoteDeviceRunSession module with an explicit factory, so a new export has to be listed there too. Without it the step under test threw "ensureFfmpegInstalledAsync is not a function". Co-Authored-By: Claude Opus 5 --- .../__tests__/startArgentRemoteSession-orchestration.test.ts | 1 + 1 file changed, 1 insertion(+) 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 4b22fe3cb4..94f9b4b5b8 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 @@ -40,6 +40,7 @@ jest.mock('../../utils/argentEvents', () => ({ startArgentEventCollectionAsync: jest.fn(), })); jest.mock('../../utils/remoteDeviceRunSession', () => ({ + ensureFfmpegInstalledAsync: jest.fn(), getDeviceRunSessionIdOrThrow: jest.fn(), getNgrokAuthtokenOrThrow: jest.fn(), getNgrokTunnelDomainOrThrow: jest.fn(), From 14f8f7cfa27595b53261c9e4cb6d76352bd6669d Mon Sep 17 00:00:00 2001 From: szdziedzic Date: Wed, 29 Jul 2026 16:57:47 +0200 Subject: [PATCH 4/7] [build-tools] Install ffmpeg with apt on Linux workers too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Argent screen recording supports Android emulators and encodes through the same ffmpeg path, so Android sessions hit the same failure as iOS. The Linux workers have passwordless sudo, so install with apt there instead of warning that the platform is unsupported. The package index can be older than the image the worker booted from, so refresh it first. A failed refresh is not fatal โ€” the existing index may still resolve the package. Co-Authored-By: Claude Opus 5 --- .../__tests__/remoteDeviceRunSession.test.ts | 43 ++++++++++++-- .../src/steps/utils/remoteDeviceRunSession.ts | 56 +++++++++++++------ 2 files changed, 78 insertions(+), 21 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 d764bc5f4a..c2ea69a7b8 100644 --- a/packages/build-tools/src/steps/utils/__tests__/remoteDeviceRunSession.test.ts +++ b/packages/build-tools/src/steps/utils/__tests__/remoteDeviceRunSession.test.ts @@ -450,8 +450,39 @@ describe(ensureFfmpegInstalledAsync, () => { ); }); - it('warns instead of installing on linux, where Homebrew is unavailable', async () => { - spawnMock.mockReturnValueOnce(spawnRejected()); + it('installs ffmpeg with apt on linux when it is missing', async () => { + spawnMock + .mockReturnValueOnce(spawnRejected()) // command -v ffmpeg + .mockReturnValueOnce(spawnResolved()) // apt-get update + .mockReturnValueOnce(spawnResolved()); // apt-get install + + await ensureFfmpegInstalledAsync({ + runtimePlatform: BuildRuntimePlatform.LINUX, + env: createEnvMock(), + logger: createLoggerMock(), + }); + + expect(spawnMock).toHaveBeenCalledWith( + 'sudo', + ['apt-get', 'update'], + expect.objectContaining({ + env: expect.objectContaining({ DEBIAN_FRONTEND: 'noninteractive' }), + }) + ); + expect(spawnMock).toHaveBeenLastCalledWith( + 'sudo', + ['apt-get', 'install', '-y', 'ffmpeg'], + expect.objectContaining({ + env: expect.objectContaining({ DEBIAN_FRONTEND: 'noninteractive' }), + }) + ); + }); + + it('still installs on linux when the apt index refresh fails', async () => { + spawnMock + .mockReturnValueOnce(spawnRejected()) // command -v ffmpeg + .mockReturnValueOnce(spawnRejected()) // apt-get update + .mockReturnValueOnce(spawnResolved()); // apt-get install const logger = createLoggerMock(); await ensureFfmpegInstalledAsync({ @@ -460,8 +491,12 @@ describe(ensureFfmpegInstalledAsync, () => { logger, }); - expect(spawnMock).toHaveBeenCalledTimes(1); - expect(logger.warn).toHaveBeenCalled(); + expect(spawnMock).toHaveBeenLastCalledWith( + 'sudo', + ['apt-get', 'install', '-y', 'ffmpeg'], + expect.anything() + ); + expect(logger.warn).not.toHaveBeenCalled(); }); it('warns and resolves when the install fails, so the session still starts', async () => { diff --git a/packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts b/packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts index 3cab4aded1..5caed1753a 100644 --- a/packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts +++ b/packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts @@ -203,11 +203,39 @@ async function isFfmpegAvailableAsync(env: BuildStepEnv): Promise { return (await asyncResult(spawn('sh', ['-c', 'command -v ffmpeg'], { env }))).ok; } +async function installFfmpegWithHomebrewAsync({ + env, + logger, +}: { + env: BuildStepEnv; + logger: bunyan; +}): Promise { + await spawn('brew', ['install', 'ffmpeg'], { + env: { ...env, HOMEBREW_NO_AUTO_UPDATE: '1' }, + logger, + }); +} + +async function installFfmpegWithAptAsync({ + env, + logger, +}: { + env: BuildStepEnv; + logger: bunyan; +}): Promise { + const aptEnv = { ...env, DEBIAN_FRONTEND: 'noninteractive' }; + // The worker's package index can be older than the image it booted from, which + // makes the install 404 on a moved package. Refreshing first avoids that; a + // failed refresh is not fatal because the existing index may still resolve. + await asyncResult(spawn('sudo', ['apt-get', 'update'], { env: aptEnv, logger })); + await spawn('sudo', ['apt-get', 'install', '-y', 'ffmpeg'], { env: aptEnv, logger }); +} + /** * Install ffmpeg when the runtime does not already provide it, so argent's - * `screen-recording-start` tool can encode a video. The worker image does not + * `screen-recording-start` tool can encode a video. The worker images do not * ship ffmpeg yet, so without this the tool fails with "`ffmpeg` was not found - * on PATH". + * on PATH" โ€” on macOS (iOS simulators) and Linux (Android emulators) alike. * * Best-effort by design: screen recording is one optional argent tool, so a * failure here is logged and the session continues without it. Never rejects, @@ -227,22 +255,16 @@ export async function ensureFfmpegInstalledAsync({ return; } - // Homebrew is only available on the macOS workers. Linux workers would need a - // package manager and sudo, so report the gap instead of guessing. - if (runtimePlatform !== BuildRuntimePlatform.DARWIN) { - logger.warn( - `ffmpeg is not installed and EAS only installs it on ${BuildRuntimePlatform.DARWIN} runtimes. ` + - 'Argent screen recording will not work in this session.' - ); - return; - } - - logger.info('ffmpeg is not installed, installing it with Homebrew for argent screen recording.'); + const isDarwin = runtimePlatform === BuildRuntimePlatform.DARWIN; + logger.info( + `ffmpeg is not installed, installing it with ${ + isDarwin ? 'Homebrew' : 'apt' + } for argent screen recording.` + ); const installResult = await asyncResult( - spawn('brew', ['install', 'ffmpeg'], { - env: { ...env, HOMEBREW_NO_AUTO_UPDATE: '1' }, - logger, - }) + isDarwin + ? installFfmpegWithHomebrewAsync({ env, logger }) + : installFfmpegWithAptAsync({ env, logger }) ); if (!installResult.ok) { Sentry.capture('Could not install ffmpeg for argent screen recording', installResult.reason); From 6c81c1030e5f19f9a875299c2d23be57679e6189 Mon Sep 17 00:00:00 2001 From: szdziedzic Date: Thu, 30 Jul 2026 16:42:33 +0200 Subject: [PATCH 5/7] [build-tools] Detect ffmpeg on PATH only Argent resolves ffmpeg from PATH first and only probes install prefixes when that lookup fails, which is a workaround for hosts with a sanitized PATH. The tool-server inherits this step's env, so PATH here is the PATH argent will search. Probing the prefixes ourselves added nothing and coupled us to argent's internal fallback list. If ffmpeg is ever installed off PATH, the package manager no-ops on the redundant install. Co-Authored-By: Claude Opus 5 --- .../__tests__/remoteDeviceRunSession.test.ts | 20 ------------------- .../src/steps/utils/remoteDeviceRunSession.ts | 13 ++---------- 2 files changed, 2 insertions(+), 31 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 c2ea69a7b8..f5c827e9d6 100644 --- a/packages/build-tools/src/steps/utils/__tests__/remoteDeviceRunSession.test.ts +++ b/packages/build-tools/src/steps/utils/__tests__/remoteDeviceRunSession.test.ts @@ -2,7 +2,6 @@ import { bunyan } from '@expo/logger'; import { BuildRuntimePlatform, BuildStepEnv } from '@expo/steps'; import spawn from '@expo/turtle-spawn'; import * as ngrok from '@ngrok/ngrok'; -import fs from 'node:fs'; import { setTimeout as setTimeoutAsync } from 'node:timers/promises'; import { CustomBuildContext } from '../../../customBuildContext'; @@ -386,7 +385,6 @@ describe(waitForDeviceRunSessionStoppedAsync, () => { describe(ensureFfmpegInstalledAsync, () => { const spawnMock = jest.mocked(spawn); - let existsSyncSpy: jest.SpyInstance; function spawnResolved(): ReturnType { return Promise.resolve({}) as unknown as ReturnType; @@ -399,24 +397,6 @@ describe(ensureFfmpegInstalledAsync, () => { beforeEach(() => { spawnMock.mockReset(); jest.mocked(Sentry).capture.mockReset(); - // Default to "no ffmpeg on any fallback path" so each test opts in. - existsSyncSpy = jest.spyOn(fs, 'existsSync').mockReturnValue(false); - }); - - afterEach(() => { - existsSyncSpy.mockRestore(); - }); - - it('does not install when ffmpeg is already on a fallback path', async () => { - existsSyncSpy.mockImplementation(path => path === '/opt/homebrew/bin/ffmpeg'); - - await ensureFfmpegInstalledAsync({ - runtimePlatform: BuildRuntimePlatform.DARWIN, - env: createEnvMock(), - logger: createLoggerMock(), - }); - - expect(spawnMock).not.toHaveBeenCalled(); }); it('does not install when ffmpeg is on PATH', async () => { diff --git a/packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts b/packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts index 5caed1753a..c575032614 100644 --- a/packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts +++ b/packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts @@ -188,18 +188,9 @@ async function sleepUntilAbortedAsync( } // Argent encodes screen recordings by piping simulator frames into `ffmpeg`, -// which it resolves from PATH and then from these prefixes. Keep the list in -// sync with argent so we never reinstall a binary it can already find. -const FFMPEG_FALLBACK_PATHS = [ - '/opt/homebrew/bin/ffmpeg', - '/usr/local/bin/ffmpeg', - '/usr/bin/ffmpeg', -]; - +// which it resolves from PATH. It inherits this step's env, so PATH here is the +// PATH argent will search โ€” no need to probe install prefixes ourselves. async function isFfmpegAvailableAsync(env: BuildStepEnv): Promise { - if (FFMPEG_FALLBACK_PATHS.some(ffmpegPath => fs.existsSync(ffmpegPath))) { - return true; - } return (await asyncResult(spawn('sh', ['-c', 'command -v ffmpeg'], { env }))).ok; } From a62a7a11ee318a87c341015fe613a3b85fbc05b6 Mon Sep 17 00:00:00 2001 From: szdziedzic Date: Thu, 30 Jul 2026 17:05:18 +0200 Subject: [PATCH 6/7] [build-tools] Drop the shell from the ffmpeg check `command -v` is a shell builtin, which is the only reason `sh -c` was there. Spawning ffmpeg directly does the PATH lookup in the spawn call itself: it rejects with ENOENT when the binary is absent. Running it also proves the binary actually works, which a path lookup does not. Co-Authored-By: Claude Opus 5 --- .../steps/utils/__tests__/remoteDeviceRunSession.test.ts | 6 +++--- .../build-tools/src/steps/utils/remoteDeviceRunSession.ts | 7 ++++--- 2 files changed, 7 insertions(+), 6 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 f5c827e9d6..d86681a097 100644 --- a/packages/build-tools/src/steps/utils/__tests__/remoteDeviceRunSession.test.ts +++ b/packages/build-tools/src/steps/utils/__tests__/remoteDeviceRunSession.test.ts @@ -409,7 +409,7 @@ describe(ensureFfmpegInstalledAsync, () => { }); expect(spawnMock).toHaveBeenCalledTimes(1); - expect(spawnMock).toHaveBeenCalledWith('sh', ['-c', 'command -v ffmpeg'], expect.anything()); + expect(spawnMock).toHaveBeenCalledWith('ffmpeg', ['-version'], expect.anything()); }); it('installs ffmpeg with Homebrew on darwin when it is missing', async () => { @@ -432,7 +432,7 @@ describe(ensureFfmpegInstalledAsync, () => { it('installs ffmpeg with apt on linux when it is missing', async () => { spawnMock - .mockReturnValueOnce(spawnRejected()) // command -v ffmpeg + .mockReturnValueOnce(spawnRejected()) // ffmpeg -version .mockReturnValueOnce(spawnResolved()) // apt-get update .mockReturnValueOnce(spawnResolved()); // apt-get install @@ -460,7 +460,7 @@ describe(ensureFfmpegInstalledAsync, () => { it('still installs on linux when the apt index refresh fails', async () => { spawnMock - .mockReturnValueOnce(spawnRejected()) // command -v ffmpeg + .mockReturnValueOnce(spawnRejected()) // ffmpeg -version .mockReturnValueOnce(spawnRejected()) // apt-get update .mockReturnValueOnce(spawnResolved()); // apt-get install const logger = createLoggerMock(); diff --git a/packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts b/packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts index c575032614..af0c42ad38 100644 --- a/packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts +++ b/packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts @@ -188,10 +188,11 @@ async function sleepUntilAbortedAsync( } // Argent encodes screen recordings by piping simulator frames into `ffmpeg`, -// which it resolves from PATH. It inherits this step's env, so PATH here is the -// PATH argent will search โ€” no need to probe install prefixes ourselves. +// which it resolves from PATH. The tool-server inherits this step's env, so +// spawning ffmpeg resolves against the same PATH argent will search: it rejects +// with ENOENT when the binary is absent, and running it also proves it works. async function isFfmpegAvailableAsync(env: BuildStepEnv): Promise { - return (await asyncResult(spawn('sh', ['-c', 'command -v ffmpeg'], { env }))).ok; + return (await asyncResult(spawn('ffmpeg', ['-version'], { env }))).ok; } async function installFfmpegWithHomebrewAsync({ From f0756ec452d070a93a9d7b30198c7a050bda01e9 Mon Sep 17 00:00:00 2001 From: szdziedzic Date: Thu, 30 Jul 2026 17:20:37 +0200 Subject: [PATCH 7/7] [build-tools] Stop a synchronous spawn failure from crashing the session The caller runs ensureFfmpegInstalledAsync in the background with `void`, and there is no unhandledRejection handler in the worker, so any rejection here would crash the process and take the live session down with it. `spawn` is not an async function and can throw synchronously. `asyncResult` only wraps an already-created promise, so it never saw that throw: it escaped through the unguarded availability check and out of the `void`. Wrap the whole body instead of trusting each callee, matching fetchServeSimTurnArgsAsync. Co-Authored-By: Claude Opus 5 --- .../__tests__/remoteDeviceRunSession.test.ts | 21 ++++++++ .../src/steps/utils/remoteDeviceRunSession.ts | 52 +++++++++++-------- 2 files changed, 51 insertions(+), 22 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 d86681a097..84908b4cec 100644 --- a/packages/build-tools/src/steps/utils/__tests__/remoteDeviceRunSession.test.ts +++ b/packages/build-tools/src/steps/utils/__tests__/remoteDeviceRunSession.test.ts @@ -494,4 +494,25 @@ describe(ensureFfmpegInstalledAsync, () => { expect(logger.warn).toHaveBeenCalled(); expect(jest.mocked(Sentry).capture).toHaveBeenCalled(); }); + + // The caller runs this with `void` and the worker installs no unhandledRejection + // handler, so a rejection here would crash the process. `spawn` is not async and + // can throw synchronously, which `asyncResult` cannot catch. + it('resolves when the availability check throws synchronously', async () => { + spawnMock.mockImplementationOnce(() => { + throw new Error('sync spawn failure'); + }); + const logger = createLoggerMock(); + + await expect( + ensureFfmpegInstalledAsync({ + runtimePlatform: BuildRuntimePlatform.DARWIN, + env: createEnvMock(), + logger, + }) + ).resolves.toBeUndefined(); + + expect(logger.warn).toHaveBeenCalled(); + expect(jest.mocked(Sentry).capture).toHaveBeenCalled(); + }); }); diff --git a/packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts b/packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts index af0c42ad38..25c10084b1 100644 --- a/packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts +++ b/packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts @@ -230,8 +230,13 @@ async function installFfmpegWithAptAsync({ * on PATH" โ€” on macOS (iOS simulators) and Linux (Android emulators) alike. * * Best-effort by design: screen recording is one optional argent tool, so a - * failure here is logged and the session continues without it. Never rejects, - * which is what lets the caller run it in the background. + * failure here is logged and the session continues without it. + * + * The whole body is wrapped because the caller runs this in the background with + * `void`. There is no unhandledRejection handler in the worker, so a rejection + * escaping here would crash the process and take the live session with it. + * `spawn` is not an async function and can throw synchronously, which + * `asyncResult` cannot catch โ€” it only wraps an already-created promise. */ export async function ensureFfmpegInstalledAsync({ runtimePlatform, @@ -242,31 +247,34 @@ export async function ensureFfmpegInstalledAsync({ env: BuildStepEnv; logger: bunyan; }): Promise { - if (await isFfmpegAvailableAsync(env)) { - logger.info('ffmpeg is already installed.'); - return; - } + try { + if (await isFfmpegAvailableAsync(env)) { + logger.info('ffmpeg is already installed.'); + return; + } - const isDarwin = runtimePlatform === BuildRuntimePlatform.DARWIN; - logger.info( - `ffmpeg is not installed, installing it with ${ - isDarwin ? 'Homebrew' : 'apt' - } for argent screen recording.` - ); - const installResult = await asyncResult( - isDarwin - ? installFfmpegWithHomebrewAsync({ env, logger }) - : installFfmpegWithAptAsync({ env, logger }) - ); - if (!installResult.ok) { - Sentry.capture('Could not install ffmpeg for argent screen recording', installResult.reason); + const isDarwin = runtimePlatform === BuildRuntimePlatform.DARWIN; + logger.info( + `ffmpeg is not installed, installing it with ${ + isDarwin ? 'Homebrew' : 'apt' + } for argent screen recording.` + ); + if (isDarwin) { + await installFfmpegWithHomebrewAsync({ env, logger }); + } else { + await installFfmpegWithAptAsync({ env, logger }); + } + logger.info('Installed ffmpeg.'); + } catch (err) { + const error = err instanceof Error ? err : new Error(String(err)); + Sentry.capture('Could not install ffmpeg for argent screen recording', error, { + level: 'warning', + }); logger.warn( - { err: installResult.reason }, + { err: error }, 'Could not install ffmpeg. Argent screen recording will not work in this session.' ); - return; } - logger.info('Installed ffmpeg.'); } const TurnIceServersResponseSchema = z.object({