From 6c0f79740bdb177739d6c48901c50e109ea23b29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanis=C5=82aw=20Chmiela?= Date: Thu, 23 Apr 2026 17:32:37 +0200 Subject: [PATCH 1/3] Stage Android emulator logcat output --- .../__tests__/startAndroidEmulator.test.ts | 63 +++++++++++++++ .../steps/functions/startAndroidEmulator.ts | 17 +++- .../src/utils/AndroidEmulatorUtils.ts | 60 ++++++++++++++ .../__tests__/AndroidEmulatorUtils.test.ts | 78 +++++++++++++++++++ 4 files changed, 217 insertions(+), 1 deletion(-) diff --git a/packages/build-tools/src/steps/functions/__tests__/startAndroidEmulator.test.ts b/packages/build-tools/src/steps/functions/__tests__/startAndroidEmulator.test.ts index 0b877e509f..b62396ae7b 100644 --- a/packages/build-tools/src/steps/functions/__tests__/startAndroidEmulator.test.ts +++ b/packages/build-tools/src/steps/functions/__tests__/startAndroidEmulator.test.ts @@ -25,6 +25,8 @@ jest.mock('../../../utils/AndroidEmulatorUtils', () => ({ createAsync: jest.fn(), cloneAsync: jest.fn(), startAsync: jest.fn(), + startLogcatStreamingAsync: jest.fn(), + getLogcatStagingDirectoryPath: jest.fn(), waitForReadyAsync: jest.fn(), disableWindowAndTransitionAnimationsAsync: jest.fn(), deleteAsync: jest.fn(), @@ -59,6 +61,12 @@ describe(createStartAndroidEmulatorBuildFunction, () => { mockedAndroidUtils.createAsync.mockResolvedValue(undefined); mockedAndroidUtils.cloneAsync.mockResolvedValue(undefined); mockedAndroidUtils.startAsync.mockResolvedValue(createStartResult('emulator-default')); + mockedAndroidUtils.startLogcatStreamingAsync.mockResolvedValue({ + outputPath: '/non/existent/dir/android-emulator-logcat/emulator-default.log', + }); + mockedAndroidUtils.getLogcatStagingDirectoryPath.mockReturnValue( + '/non/existent/dir/android-emulator-logcat' + ); mockedAndroidUtils.waitForReadyAsync.mockResolvedValue(undefined); mockedAndroidUtils.disableWindowAndTransitionAnimationsAsync.mockResolvedValue(undefined); mockedAndroidUtils.deleteAsync.mockResolvedValue(undefined); @@ -106,6 +114,23 @@ describe(createStartAndroidEmulatorBuildFunction, () => { serialId: 'emulator-2222', }) ); + expect(mockedAndroidUtils.startLogcatStreamingAsync).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + serialId: 'emulator-1111', + outputDir: '/non/existent/dir/android-emulator-logcat', + }) + ); + expect(mockedAndroidUtils.startLogcatStreamingAsync).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + serialId: 'emulator-2222', + outputDir: '/non/existent/dir/android-emulator-logcat', + }) + ); + expect(mockedAndroidUtils.startLogcatStreamingAsync.mock.invocationCallOrder[0]).toBeLessThan( + mockedAndroidUtils.waitForReadyAsync.mock.invocationCallOrder[0] + ); expect(mockedAndroidUtils.deleteAsync).toHaveBeenCalledWith( expect.objectContaining({ serialId: 'emulator-1111', @@ -174,6 +199,26 @@ describe(createStartAndroidEmulatorBuildFunction, () => { serialId: 'emulator-clone-2-attempt-1', }) ); + expect(mockedAndroidUtils.startLogcatStreamingAsync).toHaveBeenCalledWith( + expect.objectContaining({ + serialId: 'emulator-base', + }) + ); + expect(mockedAndroidUtils.startLogcatStreamingAsync).toHaveBeenCalledWith( + expect.objectContaining({ + serialId: 'emulator-clone-1-attempt-1', + }) + ); + expect(mockedAndroidUtils.startLogcatStreamingAsync).toHaveBeenCalledWith( + expect.objectContaining({ + serialId: 'emulator-clone-1-attempt-2', + }) + ); + expect(mockedAndroidUtils.startLogcatStreamingAsync).toHaveBeenCalledWith( + expect.objectContaining({ + serialId: 'emulator-clone-2-attempt-1', + }) + ); expect(mockedAndroidUtils.deleteAsync).toHaveBeenCalledWith( expect.objectContaining({ @@ -200,6 +245,24 @@ describe(createStartAndroidEmulatorBuildFunction, () => { }) ); expect(mockedAndroidUtils.deleteAsync).toHaveBeenCalledTimes(3); + expect(mockedAndroidUtils.startLogcatStreamingAsync).toHaveBeenCalledTimes(3); + }); + + it('continues emulator startup when logcat streaming fails', async () => { + mockedAndroidUtils.startLogcatStreamingAsync.mockResolvedValue(null); + + await createStep().executeAsync(); + + expect(mockedAndroidUtils.startLogcatStreamingAsync).toHaveBeenCalledWith( + expect.objectContaining({ + serialId: 'emulator-default', + }) + ); + expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenCalledWith( + expect.objectContaining({ + serialId: 'emulator-default', + }) + ); }); it('skips animation scale adjustments when opt out env var is disabled', async () => { diff --git a/packages/build-tools/src/steps/functions/startAndroidEmulator.ts b/packages/build-tools/src/steps/functions/startAndroidEmulator.ts index b12101fada..50f897dc7d 100644 --- a/packages/build-tools/src/steps/functions/startAndroidEmulator.ts +++ b/packages/build-tools/src/steps/functions/startAndroidEmulator.ts @@ -49,7 +49,7 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction { allowedValueTypeName: BuildStepInputValueTypeName.NUMBER, }), ], - fn: async ({ logger }, { inputs, env }) => { + fn: async ({ logger, global }, { inputs, env }) => { if (env.EAS_NO_EMULATOR_HOST_SUPPORT_CHECK !== '1') { await assertAndroidEmulatorHostSupportAsync({ env }); } else { @@ -100,6 +100,9 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction { let emulatorPromise = null; let serialId = null; + const logcatOutputDir = AndroidEmulatorUtils.getLogcatStagingDirectoryPath({ + buildLogsDirectory: global.buildLogsDirectory, + }); await retryAsync( async attemptCount => { const timeoutMs = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS[attemptCount]; @@ -124,6 +127,12 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction { env, }); attemptSerialId = startResult.serialId; + await AndroidEmulatorUtils.startLogcatStreamingAsync({ + serialId: attemptSerialId, + outputDir: logcatOutputDir, + env, + logger, + }); await AndroidEmulatorUtils.waitForReadyAsync({ env, serialId: attemptSerialId, @@ -213,6 +222,12 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction { env, }); cloneSerialId = startResult.serialId; + await AndroidEmulatorUtils.startLogcatStreamingAsync({ + serialId: cloneSerialId, + outputDir: logcatOutputDir, + env, + logger, + }); logger.info('Waiting for emulator to become ready'); await AndroidEmulatorUtils.waitForReadyAsync({ diff --git a/packages/build-tools/src/utils/AndroidEmulatorUtils.ts b/packages/build-tools/src/utils/AndroidEmulatorUtils.ts index a2e4171c5d..50a0be6b99 100644 --- a/packages/build-tools/src/utils/AndroidEmulatorUtils.ts +++ b/packages/build-tools/src/utils/AndroidEmulatorUtils.ts @@ -24,6 +24,14 @@ export namespace AndroidEmulatorUtils { process.arch === 'arm64' ? 'arm64-v8a' : 'x86_64' }`; + export function getLogcatStagingDirectoryPath({ + buildLogsDirectory, + }: { + buildLogsDirectory: string; + }): string { + return path.join(buildLogsDirectory, 'android-emulator-logcat'); + } + export async function getAvailableDevicesAsync({ env, }: { @@ -490,6 +498,58 @@ export namespace AndroidEmulatorUtils { return { outputPath }; } + export async function startLogcatStreamingAsync({ + serialId, + outputDir, + env, + logger, + }: { + serialId: AndroidDeviceSerialId; + outputDir: string; + env: NodeJS.ProcessEnv; + logger: bunyan; + }): Promise<{ outputPath: string } | null> { + try { + await fs.promises.mkdir(outputDir, { recursive: true }); + + const logcatPromise = spawn('adb', ['-s', serialId, 'logcat', '-v', 'threadtime'], { + env, + stdio: ['ignore', 'pipe', 'pipe'], + }); + const { child } = logcatPromise; + + if (!child.stdout) { + throw new Error('"adb logcat" did not start correctly.'); + } + + const safeSerialId = serialId.replace(/[^a-zA-Z0-9_.-]/g, '_'); + const outputPath = path.join(outputDir, `${child.pid}-${safeSerialId}.log`); + const writeStream = fs.createWriteStream(outputPath); + child.stdout.pipe(writeStream); + child.stdout.on('error', err => { + logger.warn({ err }, `Failed to read Android emulator logcat for ${serialId}.`); + }); + writeStream.on('error', err => { + logger.warn({ err }, `Failed to write Android emulator logcat for ${serialId}.`); + }); + child.on('close', () => { + writeStream.end(); + }); + child.unref(); + + void logcatPromise.catch(err => { + logger.warn({ err }, `Android emulator logcat stream for ${serialId} stopped.`); + }); + + logger.info(`Streaming Android emulator logcat for ${serialId} to ${outputPath}.`); + + return { outputPath }; + } catch (err) { + logger.warn({ err }, `Failed to start Android emulator logcat stream for ${serialId}.`); + return null; + } + } + export async function stopAsync({ serialId, env, diff --git a/packages/build-tools/src/utils/__tests__/AndroidEmulatorUtils.test.ts b/packages/build-tools/src/utils/__tests__/AndroidEmulatorUtils.test.ts index d9695ab9f1..de22401db8 100644 --- a/packages/build-tools/src/utils/__tests__/AndroidEmulatorUtils.test.ts +++ b/packages/build-tools/src/utils/__tests__/AndroidEmulatorUtils.test.ts @@ -1,4 +1,9 @@ import spawn from '@expo/turtle-spawn'; +import { EventEmitter } from 'node:events'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { PassThrough } from 'node:stream'; import { createMockLogger } from '../../__tests__/utils/logger'; import { AndroidEmulatorUtils } from '../AndroidEmulatorUtils'; @@ -22,6 +27,79 @@ describe('AndroidEmulatorUtils', () => { mockedRetryAsync.mockImplementation(async fn => await fn(0)); }); + describe(AndroidEmulatorUtils.startLogcatStreamingAsync, () => { + function createSpawnPromise() { + const child = Object.assign(new EventEmitter(), { + pid: 1234, + stdout: new PassThrough(), + stderr: new PassThrough(), + unref: jest.fn(), + }); + const spawnPromise = Promise.resolve({ stdout: '', stderr: '' }) as any; + spawnPromise.child = child; + return { child, spawnPromise }; + } + + it('streams logcat to a staged file', async () => { + const outputDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'logcat-staging-')); + const logger = createMockLogger(); + const { child, spawnPromise } = createSpawnPromise(); + mockedSpawn.mockReturnValueOnce(spawnPromise); + + const result = await AndroidEmulatorUtils.startLogcatStreamingAsync({ + serialId: 'emulator-5554' as any, + outputDir, + env: process.env, + logger, + }); + + expect(result).not.toBeNull(); + expect(mockedSpawn).toHaveBeenCalledWith( + 'adb', + ['-s', 'emulator-5554', 'logcat', '-v', 'threadtime'], + { + env: process.env, + stdio: ['ignore', 'pipe', 'pipe'], + } + ); + expect(child.unref).toHaveBeenCalled(); + + child.stdout.write('log line\n'); + child.emit('close', 0); + await new Promise(resolve => setTimeout(resolve, 10)); + + expect(result!.outputPath.startsWith(outputDir)).toBe(true); + await expect(fs.promises.readFile(result!.outputPath, 'utf-8')).resolves.toContain( + 'log line' + ); + expect(path.basename(result!.outputPath)).toBe('1234-emulator-5554.log'); + }); + + it('returns null and warns when logcat cannot start', async () => { + const outputDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'logcat-staging-')); + const logger = createMockLogger(); + mockedSpawn.mockImplementationOnce(() => { + throw new Error('spawn failed'); + }); + + await expect( + AndroidEmulatorUtils.startLogcatStreamingAsync({ + serialId: 'emulator-5554' as any, + outputDir, + env: process.env, + logger, + }) + ).resolves.toBeNull(); + + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + err: expect.objectContaining({ message: 'spawn failed' }), + }), + 'Failed to start Android emulator logcat stream for emulator-5554.' + ); + }); + }); + describe(AndroidEmulatorUtils.waitForReadyAsync, () => { it('checks boot completion and verifies network with netcat to 1.1.1.1:443', async () => { mockedSpawn.mockImplementation((async (_command: string, args: string[]) => { From 2313ad9704dce4d0b697433c4ed1ef0f5e9ebed6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanis=C5=82aw=20Chmiela?= Date: Fri, 24 Apr 2026 13:48:18 +0200 Subject: [PATCH 2/3] Address logcat review feedback --- .../src/utils/AndroidEmulatorUtils.ts | 5 +- .../__tests__/AndroidEmulatorUtils.test.ts | 85 ++++++++++++------- 2 files changed, 59 insertions(+), 31 deletions(-) diff --git a/packages/build-tools/src/utils/AndroidEmulatorUtils.ts b/packages/build-tools/src/utils/AndroidEmulatorUtils.ts index 50a0be6b99..6c4b66e0bc 100644 --- a/packages/build-tools/src/utils/AndroidEmulatorUtils.ts +++ b/packages/build-tools/src/utils/AndroidEmulatorUtils.ts @@ -514,7 +514,7 @@ export namespace AndroidEmulatorUtils { const logcatPromise = spawn('adb', ['-s', serialId, 'logcat', '-v', 'threadtime'], { env, - stdio: ['ignore', 'pipe', 'pipe'], + stdio: ['ignore', 'pipe', 'ignore'], }); const { child } = logcatPromise; @@ -532,6 +532,9 @@ export namespace AndroidEmulatorUtils { writeStream.on('error', err => { logger.warn({ err }, `Failed to write Android emulator logcat for ${serialId}.`); }); + child.on('error', err => { + logger.warn({ err }, `Android emulator logcat process for ${serialId} failed.`); + }); child.on('close', () => { writeStream.end(); }); diff --git a/packages/build-tools/src/utils/__tests__/AndroidEmulatorUtils.test.ts b/packages/build-tools/src/utils/__tests__/AndroidEmulatorUtils.test.ts index de22401db8..96817430cc 100644 --- a/packages/build-tools/src/utils/__tests__/AndroidEmulatorUtils.test.ts +++ b/packages/build-tools/src/utils/__tests__/AndroidEmulatorUtils.test.ts @@ -1,5 +1,5 @@ import spawn from '@expo/turtle-spawn'; -import { EventEmitter } from 'node:events'; +import { EventEmitter, once } from 'node:events'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -22,17 +22,27 @@ const mockedSpawn = jest.mocked(spawn); const mockedRetryAsync = jest.mocked(retryAsync); describe('AndroidEmulatorUtils', () => { + let temporaryDirectories: string[] = []; + beforeEach(() => { + temporaryDirectories = []; mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any); mockedRetryAsync.mockImplementation(async fn => await fn(0)); }); - describe(AndroidEmulatorUtils.startLogcatStreamingAsync, () => { + afterEach(async () => { + await Promise.all( + temporaryDirectories.map(async temporaryDirectory => { + await fs.promises.rm(temporaryDirectory, { force: true, recursive: true }); + }) + ); + }); + + describe('AndroidEmulatorUtils.startLogcatStreamingAsync', () => { function createSpawnPromise() { const child = Object.assign(new EventEmitter(), { pid: 1234, stdout: new PassThrough(), - stderr: new PassThrough(), unref: jest.fn(), }); const spawnPromise = Promise.resolve({ stdout: '', stderr: '' }) as any; @@ -42,41 +52,56 @@ describe('AndroidEmulatorUtils', () => { it('streams logcat to a staged file', async () => { const outputDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'logcat-staging-')); + temporaryDirectories.push(outputDir); const logger = createMockLogger(); const { child, spawnPromise } = createSpawnPromise(); mockedSpawn.mockReturnValueOnce(spawnPromise); - - const result = await AndroidEmulatorUtils.startLogcatStreamingAsync({ - serialId: 'emulator-5554' as any, - outputDir, - env: process.env, - logger, - }); - - expect(result).not.toBeNull(); - expect(mockedSpawn).toHaveBeenCalledWith( - 'adb', - ['-s', 'emulator-5554', 'logcat', '-v', 'threadtime'], - { + const originalCreateWriteStream = fs.createWriteStream.bind(fs); + let writeStream: fs.WriteStream | undefined; + const createWriteStreamSpy = jest.spyOn(fs, 'createWriteStream').mockImplementation((( + ...args + ) => { + writeStream = originalCreateWriteStream(...args); + return writeStream; + }) as typeof fs.createWriteStream); + try { + const result = await AndroidEmulatorUtils.startLogcatStreamingAsync({ + serialId: 'emulator-5554' as any, + outputDir, env: process.env, - stdio: ['ignore', 'pipe', 'pipe'], - } - ); - expect(child.unref).toHaveBeenCalled(); - - child.stdout.write('log line\n'); - child.emit('close', 0); - await new Promise(resolve => setTimeout(resolve, 10)); - - expect(result!.outputPath.startsWith(outputDir)).toBe(true); - await expect(fs.promises.readFile(result!.outputPath, 'utf-8')).resolves.toContain( - 'log line' - ); - expect(path.basename(result!.outputPath)).toBe('1234-emulator-5554.log'); + logger, + }); + + expect(result).not.toBeNull(); + expect(mockedSpawn).toHaveBeenCalledWith( + 'adb', + ['-s', 'emulator-5554', 'logcat', '-v', 'threadtime'], + { + env: process.env, + stdio: ['ignore', 'pipe', 'ignore'], + } + ); + expect(child.unref).toHaveBeenCalled(); + + child.stdout.write('log line\n'); + child.stdout.end(); + child.emit('close', 0); + expect(writeStream).toBeDefined(); + await once(writeStream!, 'finish'); + + expect(result!.outputPath.startsWith(outputDir)).toBe(true); + await expect(fs.promises.readFile(result!.outputPath, 'utf-8')).resolves.toContain( + 'log line' + ); + expect(path.basename(result!.outputPath)).toBe('1234-emulator-5554.log'); + } finally { + createWriteStreamSpy.mockRestore(); + } }); it('returns null and warns when logcat cannot start', async () => { const outputDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'logcat-staging-')); + temporaryDirectories.push(outputDir); const logger = createMockLogger(); mockedSpawn.mockImplementationOnce(() => { throw new Error('spawn failed'); From 40c554e3523dcc02f272d90b7e67da76ac15c712 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanis=C5=82aw=20Chmiela?= Date: Fri, 24 Apr 2026 14:00:18 +0200 Subject: [PATCH 3/3] Guard missing logcat child pid --- .../src/utils/AndroidEmulatorUtils.ts | 4 +++ .../__tests__/AndroidEmulatorUtils.test.ts | 31 +++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/packages/build-tools/src/utils/AndroidEmulatorUtils.ts b/packages/build-tools/src/utils/AndroidEmulatorUtils.ts index 6c4b66e0bc..473458a3c0 100644 --- a/packages/build-tools/src/utils/AndroidEmulatorUtils.ts +++ b/packages/build-tools/src/utils/AndroidEmulatorUtils.ts @@ -521,6 +521,10 @@ export namespace AndroidEmulatorUtils { if (!child.stdout) { throw new Error('"adb logcat" did not start correctly.'); } + if (!child.pid) { + await logcatPromise; + throw new Error('"adb logcat" did not start correctly.'); + } const safeSerialId = serialId.replace(/[^a-zA-Z0-9_.-]/g, '_'); const outputPath = path.join(outputDir, `${child.pid}-${safeSerialId}.log`); diff --git a/packages/build-tools/src/utils/__tests__/AndroidEmulatorUtils.test.ts b/packages/build-tools/src/utils/__tests__/AndroidEmulatorUtils.test.ts index 96817430cc..7b72659f54 100644 --- a/packages/build-tools/src/utils/__tests__/AndroidEmulatorUtils.test.ts +++ b/packages/build-tools/src/utils/__tests__/AndroidEmulatorUtils.test.ts @@ -123,6 +123,37 @@ describe('AndroidEmulatorUtils', () => { 'Failed to start Android emulator logcat stream for emulator-5554.' ); }); + + it('returns null when logcat child pid is missing', async () => { + const outputDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'logcat-staging-')); + temporaryDirectories.push(outputDir); + const logger = createMockLogger(); + const child = Object.assign(new EventEmitter(), { + pid: undefined, + stdout: new PassThrough(), + unref: jest.fn(), + }); + const spawnError = new Error('spawn failed'); + const spawnPromise = Promise.reject(spawnError) as any; + spawnPromise.child = child; + mockedSpawn.mockReturnValueOnce(spawnPromise); + + await expect( + AndroidEmulatorUtils.startLogcatStreamingAsync({ + serialId: 'emulator-5554' as any, + outputDir, + env: process.env, + logger, + }) + ).resolves.toBeNull(); + + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + err: expect.objectContaining({ message: 'spawn failed' }), + }), + 'Failed to start Android emulator logcat stream for emulator-5554.' + ); + }); }); describe(AndroidEmulatorUtils.waitForReadyAsync, () => {