From e3c4d422e04b4673fcf72a0959869c632b962963 Mon Sep 17 00:00:00 2001 From: SJ Kim Date: Thu, 17 Sep 2026 11:05:28 -0400 Subject: [PATCH 1/2] [build-tools] Preserve captured simulator app logs (ENG-26642) --- CHANGELOG.md | 1 + .../startAgentDeviceRemoteSession.test.ts | 34 ++++ .../startAgentDeviceRemoteSession.ts | 43 +++-- .../__tests__/agentDeviceAppLogs.test.ts | 150 +++++++++++++++++ .../deviceRunSessionArtifacts.test.ts | 57 ++++++- .../src/steps/utils/agentDeviceAppLogs.ts | 153 ++++++++++++++++++ .../steps/utils/deviceRunSessionArtifacts.ts | 38 +++-- 7 files changed, 454 insertions(+), 22 deletions(-) create mode 100644 packages/build-tools/src/steps/utils/__tests__/agentDeviceAppLogs.test.ts create mode 100644 packages/build-tools/src/steps/utils/agentDeviceAppLogs.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c7038790a5..54a0173a0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ This is the log of notable changes to EAS CLI and related packages. - [build-tools] Pass `--share-url` when launching serve-sim so Share copies the expo.dev preview page instead of the tunnel URL. ([#4434](https://github.com/expo/eas-cli/pull/4434) by [@gwdp](https://github.com/gwdp)) - [eas-cli] Add `eas integrations:supabase:advisors` to list unresolved Supabase Security and Performance Advisor findings for the linked Supabase project. ([#4342](https://github.com/expo/eas-cli/pull/4342) by [@fiberjw](https://github.com/fiberjw)) +- [build-tools] Preserve already-captured Agent Device app logs as simulator session artifacts after shutdown. ### 🐛 Bug fixes diff --git a/packages/build-tools/src/steps/functions/__tests__/startAgentDeviceRemoteSession.test.ts b/packages/build-tools/src/steps/functions/__tests__/startAgentDeviceRemoteSession.test.ts index 4e25b2cf98..1ec1955bc4 100644 --- a/packages/build-tools/src/steps/functions/__tests__/startAgentDeviceRemoteSession.test.ts +++ b/packages/build-tools/src/steps/functions/__tests__/startAgentDeviceRemoteSession.test.ts @@ -7,6 +7,7 @@ import { Sentry } from '../../../sentry'; import { spawnDetached } from '../../utils/remoteDeviceRunSession'; import { startAgentDeviceDaemonAsync, + stopAgentDeviceAndUploadAppLogsAsync, stopAgentDeviceEventCollectionSafelyAsync, } from '../startAgentDeviceRemoteSession'; @@ -34,6 +35,39 @@ async function writeDaemonEntry(cwd: string): Promise { return daemonPath; } +describe(stopAgentDeviceAndUploadAppLogsAsync, () => { + it('waits for daemon shutdown before collecting final log bytes', async () => { + let finish!: () => void; + const daemonStopped = new Promise(resolve => { + finish = resolve; + }); + const upload = jest.fn(async () => {}); + const stopped = stopAgentDeviceAndUploadAppLogsAsync({ + daemonProcess: { stopAsync: () => daemonStopped }, + appLogs: { stopAsync: upload }, + }); + expect(upload).not.toHaveBeenCalled(); + finish(); + await stopped; + expect(upload).toHaveBeenCalledTimes(1); + }); + + it('does not snapshot a daemon that failed to stop', async () => { + const upload = jest.fn(); + await expect( + stopAgentDeviceAndUploadAppLogsAsync({ + daemonProcess: { + stopAsync: async () => { + throw new Error('stop failed'); + }, + }, + appLogs: { stopAsync: upload }, + }) + ).rejects.toThrow('stop failed'); + expect(upload).not.toHaveBeenCalled(); + }); +}); + describe(stopAgentDeviceEventCollectionSafelyAsync, () => { beforeEach(() => { jest.clearAllMocks(); diff --git a/packages/build-tools/src/steps/functions/startAgentDeviceRemoteSession.ts b/packages/build-tools/src/steps/functions/startAgentDeviceRemoteSession.ts index a9ad89aa02..b684abdff0 100644 --- a/packages/build-tools/src/steps/functions/startAgentDeviceRemoteSession.ts +++ b/packages/build-tools/src/steps/functions/startAgentDeviceRemoteSession.ts @@ -24,6 +24,7 @@ import { resolvePackageAdd, resolvePackageInstall, } from '../../utils/packageManager'; +import { startAgentDeviceAppLogCollectionAsync } from '../utils/agentDeviceAppLogs'; import { pollAgentDeviceArtifactsForUploadAsync } from '../utils/agentDeviceArtifacts'; import { startAgentDeviceEventCollectionAsync } from '../utils/agentDeviceEvents'; import { @@ -98,6 +99,12 @@ export function createStartAgentDeviceRemoteSessionBuildFunction( } logger.info('Launching agent-device daemon.'); + const appLogs = await startAgentDeviceAppLogCollectionAsync({ + ctx, + deviceRunSessionId, + stateDir: AGENT_DEVICE_STATE_DIR, + logger, + }); const daemonProcess = await startAgentDeviceDaemonAsync({ packageVersion, env, logger }); logger.info(`Waiting for daemon credentials at ${DAEMON_JSON_PATH}.`); @@ -175,18 +182,21 @@ export function createStartAgentDeviceRemoteSessionBuildFunction( : undefined, }); } finally { - if (webPreview) { - await webPreview.stopAsync(); - } - await agentDeviceTunnel.stopAsync(); - if (eventCollection) { - await stopAgentDeviceEventCollectionSafelyAsync({ - eventCollection, - deviceRunSessionId, - logger, - }); + try { + if (webPreview) { + await webPreview.stopAsync(); + } + await agentDeviceTunnel.stopAsync(); + if (eventCollection) { + await stopAgentDeviceEventCollectionSafelyAsync({ + eventCollection, + deviceRunSessionId, + logger, + }); + } + } finally { + await stopAgentDeviceAndUploadAppLogsAsync({ daemonProcess, appLogs }); } - await daemonProcess.stopAsync(); } }), }); @@ -260,6 +270,17 @@ export async function startAgentDeviceDaemonAsync({ } } +export async function stopAgentDeviceAndUploadAppLogsAsync({ + daemonProcess, + appLogs, +}: { + daemonProcess: { stopAsync: () => Promise }; + appLogs: { stopAsync: () => Promise }; +}): Promise { + await daemonProcess.stopAsync(); + await appLogs.stopAsync(); +} + export async function stopAgentDeviceEventCollectionSafelyAsync({ eventCollection, deviceRunSessionId, diff --git a/packages/build-tools/src/steps/utils/__tests__/agentDeviceAppLogs.test.ts b/packages/build-tools/src/steps/utils/__tests__/agentDeviceAppLogs.test.ts new file mode 100644 index 0000000000..b998636c7d --- /dev/null +++ b/packages/build-tools/src/steps/utils/__tests__/agentDeviceAppLogs.test.ts @@ -0,0 +1,150 @@ +import { type bunyan } from '@expo/logger'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { type Readable } from 'node:stream'; + +import { type CustomBuildContext } from '../../../customBuildContext'; +import { startAgentDeviceAppLogCollectionAsync } from '../agentDeviceAppLogs'; +import { uploadDeviceRunSessionArtifactAsync } from '../deviceRunSessionArtifacts'; + +jest.mock('../deviceRunSessionArtifacts'); +jest.mock('../../../sentry'); +jest.unmock('node:fs'); +jest.unmock('node:fs/promises'); + +describe(startAgentDeviceAppLogCollectionAsync, () => { + let stateDir: string; + const logger = { warn: jest.fn() } as unknown as bunyan; + const uploads: { name: string; contents: string }[] = []; + + beforeEach(async () => { + jest.clearAllMocks(); + uploads.length = 0; + stateDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-app-logs-test-')); + jest.mocked(uploadDeviceRunSessionArtifactAsync).mockImplementation(async (_ctx, args) => { + const chunks: string[] = []; + for await (const chunk of args.stream as Readable) { + chunks.push(Buffer.from(chunk).toString()); + } + uploads.push({ name: args.name, contents: chunks.join('') }); + }); + }); + + afterEach(async () => { + jest.useRealTimers(); + await fs.rm(stateDir, { recursive: true, force: true }); + }); + + const start = () => + startAgentDeviceAppLogCollectionAsync({ + ctx: {} as CustomBuildContext, + deviceRunSessionId: 'run-id', + stateDir, + logger, + }); + + async function writeLog(session: string, contents: string): Promise { + const directory = path.join(stateDir, 'sessions', session); + await fs.mkdir(directory, { recursive: true }); + const filename = path.join(directory, 'app.log'); + await fs.writeFile(filename, contents); + return filename; + } + + it('does not upload when no logs were captured', async () => { + const collection = await start(); + await collection.stopAsync(); + expect(uploadDeviceRunSessionArtifactAsync).not.toHaveBeenCalled(); + expect(logger.warn).not.toHaveBeenCalled(); + }); + + it('reads final contents once and attributes multiple logs to the run', async () => { + const collection = await start(); + const filename = await writeLog('first', 'initial\n'); + await writeLog('second', 'other app'); + await fs.appendFile(filename, 'final\n'); + await Promise.all([collection.stopAsync(), collection.stopAsync()]); + expect(uploads).toEqual([ + { name: 'App log (first)', contents: 'initial\nfinal\n' }, + { name: 'App log (second)', contents: 'other app' }, + ]); + expect(uploadDeviceRunSessionArtifactAsync).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + deviceRunSessionId: 'run-id', + kind: 'native-app-log', + size: 14, + }) + ); + }); + + it('excludes preexisting session directories, empty logs and missing logs', async () => { + await writeLog('old', 'previous run'); + const collection = await start(); + await writeLog('empty', ''); + await fs.mkdir(path.join(stateDir, 'sessions', 'missing')); + await collection.stopAsync(); + expect(uploads).toEqual([]); + }); + + it('does not follow session or file symlinks', async () => { + const collection = await start(); + const outside = path.join(stateDir, 'outside'); + await fs.mkdir(outside); + await fs.writeFile(path.join(outside, 'app.log'), 'private'); + await fs.mkdir(path.join(stateDir, 'sessions', 'file-link'), { recursive: true }); + await fs.symlink(outside, path.join(stateDir, 'sessions', 'directory-link')); + await fs.symlink( + path.join(outside, 'app.log'), + path.join(stateDir, 'sessions', 'file-link', 'app.log') + ); + await collection.stopAsync(); + expect(uploads).toEqual([]); + expect(logger.warn).toHaveBeenCalled(); + }); + + it('skips oversized logs without failing shutdown', async () => { + const collection = await start(); + const file = await writeLog('large', ''); + await fs.truncate(file, 10 * 1024 * 1024 + 1); + await expect(collection.stopAsync()).resolves.toBeUndefined(); + expect(uploads).toEqual([]); + expect(logger.warn).toHaveBeenCalled(); + }); + + it('continues after one upload fails without allocating a retry', async () => { + const collection = await start(); + await writeLog('first', 'first'); + await writeLog('second', 'second'); + jest.mocked(uploadDeviceRunSessionArtifactAsync).mockRejectedValueOnce(new Error('offline')); + await expect(collection.stopAsync()).resolves.toBeUndefined(); + expect(uploadDeviceRunSessionArtifactAsync).toHaveBeenCalledTimes(2); + expect(uploads).toEqual([{ name: 'App log (second)', contents: 'second' }]); + expect(logger.warn).toHaveBeenCalled(); + }); + + it('aborts network work and returns when the shutdown budget expires', async () => { + const collection = await start(); + await writeLog('first', 'first'); + let markStarted!: () => void; + const started = new Promise(resolve => { + markStarted = resolve; + }); + let signal: AbortSignal | undefined; + jest.mocked(uploadDeviceRunSessionArtifactAsync).mockImplementationOnce(async (_ctx, args) => { + signal = args.signal; + markStarted(); + await new Promise((_resolve, reject) => { + args.signal!.addEventListener('abort', () => reject(new Error('aborted')), { once: true }); + }); + }); + jest.useFakeTimers(); + const stopped = collection.stopAsync(); + await started; + await jest.advanceTimersByTimeAsync(30_000); + await stopped; + expect(signal?.aborted).toBe(true); + expect(logger.warn).toHaveBeenCalled(); + }); +}); diff --git a/packages/build-tools/src/steps/utils/__tests__/deviceRunSessionArtifacts.test.ts b/packages/build-tools/src/steps/utils/__tests__/deviceRunSessionArtifacts.test.ts index 040dc16cb1..84b033db3e 100644 --- a/packages/build-tools/src/steps/utils/__tests__/deviceRunSessionArtifacts.test.ts +++ b/packages/build-tools/src/steps/utils/__tests__/deviceRunSessionArtifacts.test.ts @@ -13,7 +13,9 @@ describe(uploadDeviceRunSessionArtifactAsync, () => { jest.mocked(fetch).mockReset(); }); - it('streams an artifact through a signed upload URL', async () => { + it.each([false, true])('streams an artifact with cancellation enabled: %s', async cancelable => { + const controller = new AbortController(); + const signal = cancelable ? controller.signal : undefined; const stream = Readable.from(Buffer.from('artifact-data')); const reportedSize = 1024; const mutation = jest.fn().mockReturnValue({ @@ -50,6 +52,7 @@ describe(uploadDeviceRunSessionArtifactAsync, () => { metadata: { firstFrameRecordAt: 'test-time' }, size: reportedSize, stream, + signal, }); expect(mutation).toHaveBeenCalledWith( @@ -63,14 +66,64 @@ describe(uploadDeviceRunSessionArtifactAsync, () => { metadata: { firstFrameRecordAt: 'test-time' }, size: reportedSize, }, - }) + }), + cancelable ? { fetch: expect.any(Function) } : undefined ); expect(jest.mocked(fetch)).toHaveBeenCalledWith( 'https://uploads.expo.test/artifact', expect.objectContaining({ method: 'PUT', body: stream, + ...(signal ? { signal } : {}), }) ); + if (cancelable) { + const requestFetch = mutation.mock.calls[0][2].fetch as typeof globalThis.fetch; + const nativeFetch = jest + .spyOn(globalThis, 'fetch') + .mockResolvedValue(new globalThis.Response()); + try { + const requestController = new AbortController(); + await requestFetch('https://api.expo.test/graphql', { + headers: { Authorization: 'Bearer test-token' }, + signal: requestController.signal, + }); + expect(nativeFetch).toHaveBeenCalledWith( + 'https://api.expo.test/graphql', + expect.objectContaining({ + headers: { Authorization: 'Bearer test-token' }, + }) + ); + const forwardedSignal = nativeFetch.mock.calls[0][1]!.signal!; + expect(forwardedSignal.aborted).toBe(false); + controller.abort(); + expect(forwardedSignal.aborted).toBe(true); + } finally { + nativeFetch.mockRestore(); + } + } + }); + + it('does not allocate an upload after cancellation', async () => { + const controller = new AbortController(); + controller.abort(); + const mutation = jest.fn(); + await expect( + uploadDeviceRunSessionArtifactAsync( + { graphqlClient: { mutation } } as unknown as CustomBuildContext, + { + deviceRunSessionId: 'run', + artifactId: 'log', + name: 'log', + filename: 'app.log', + kind: 'native-app-log', + size: 1, + stream: Readable.from(['x']), + signal: controller.signal, + } + ) + ).rejects.toThrow(); + expect(mutation).not.toHaveBeenCalled(); + expect(fetch).not.toHaveBeenCalled(); }); }); diff --git a/packages/build-tools/src/steps/utils/agentDeviceAppLogs.ts b/packages/build-tools/src/steps/utils/agentDeviceAppLogs.ts new file mode 100644 index 0000000000..62c6e7f650 --- /dev/null +++ b/packages/build-tools/src/steps/utils/agentDeviceAppLogs.ts @@ -0,0 +1,153 @@ +import { type bunyan } from '@expo/logger'; +import { constants } from 'node:fs'; +import { lstat, open, readdir, realpath } from 'node:fs/promises'; +import path from 'node:path'; +import { Readable } from 'node:stream'; + +import { type CustomBuildContext } from '../../customBuildContext'; +import { Sentry } from '../../sentry'; +import { uploadDeviceRunSessionArtifactAsync } from './deviceRunSessionArtifacts'; + +const MAX_LOG_BYTES = 10 * 1024 * 1024; +const FINAL_UPLOAD_TIMEOUT_MS = 30_000; + +export async function startAgentDeviceAppLogCollectionAsync({ + ctx, + deviceRunSessionId, + stateDir, + logger, +}: { + ctx: CustomBuildContext; + deviceRunSessionId: string; + stateDir: string; + logger: bunyan; +}): Promise<{ stopAsync: () => Promise }> { + const sessionsDir = path.join(stateDir, 'sessions'); + let existingSessions: Set; + const report = (err: unknown): void => { + const error = err instanceof Error ? err : new Error(String(err)); + logger.warn({ err: error }, 'Could not preserve agent-device app logs.'); + Sentry.capture('Could not preserve agent-device app logs', error, { + level: 'warning', + extras: { deviceRunSessionId }, + }); + }; + try { + existingSessions = new Set(await listSessionDirectoriesAsync(sessionsDir)); + } catch (err) { + report(err); + return { stopAsync: async () => {} }; + } + + let completion: Promise | undefined; + return { + stopAsync: () => (completion ??= finishAsync().catch(report)), + }; + + async function finishAsync(): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), FINAL_UPLOAD_TIMEOUT_MS); + const aborted = new Promise((_resolve, reject) => { + controller.signal.addEventListener( + 'abort', + () => { + reject(new Error('App log upload exceeded the 30-second shutdown budget.')); + }, + { once: true } + ); + }); + try { + await Promise.race([uploadLogsAsync(), aborted]); + } finally { + clearTimeout(timer); + } + + async function uploadLogsAsync(): Promise { + let remainingBytes = MAX_LOG_BYTES; + const sessions = await listSessionDirectoriesAsync(sessionsDir); + if (!sessions.length) { + return; + } + const canonicalSessionsDir = await realpath(sessionsDir); + for (const session of sessions) { + controller.signal.throwIfAborted(); + if (existingSessions.has(session)) { + continue; + } + try { + const sessionDir = path.join(canonicalSessionsDir, session); + if ((await realpath(sessionDir)) !== sessionDir) { + throw new Error('Skipping app log outside the expected session directory.'); + } + const file = await open( + path.join(sessionDir, 'app.log'), + constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK + ); + let contents: Uint8Array; + try { + const stat = await file.stat(); + if (!stat.isFile() || stat.size === 0) { + continue; + } + if (stat.size > remainingBytes) { + throw new Error('Skipping app log that exceeds the 10 MiB per-run upload budget.'); + } + const buffer = new Uint8Array(stat.size); + let offset = 0; + while (offset < buffer.length) { + const { bytesRead } = await file.read(buffer, offset, buffer.length - offset, offset); + if (bytesRead === 0) { + break; + } + offset += bytesRead; + } + contents = buffer.subarray(0, offset); + } finally { + await file.close(); + } + controller.signal.throwIfAborted(); + if (!contents.length) { + continue; + } + remainingBytes -= contents.length; + const stream = Readable.from([contents]); + try { + await uploadDeviceRunSessionArtifactAsync(ctx, { + deviceRunSessionId, + artifactId: `app-log:${session}`, + name: `App log (${session})`, + filename: `${session}-app.log`, + kind: 'native-app-log', + size: contents.length, + stream, + signal: controller.signal, + }); + } finally { + stream.destroy(); + } + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { + report(err); + } + } + } + } + } +} + +async function listSessionDirectoriesAsync(sessionsDir: string): Promise { + try { + if (!(await lstat(sessionsDir)).isDirectory()) { + throw new Error('Expected a real agent-device sessions directory.'); + } + return (await readdir(sessionsDir, { withFileTypes: true })) + .filter(entry => entry.isDirectory()) + .map(entry => entry.name) + .sort(); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + return []; + } + throw err; + } +} diff --git a/packages/build-tools/src/steps/utils/deviceRunSessionArtifacts.ts b/packages/build-tools/src/steps/utils/deviceRunSessionArtifacts.ts index 9f718ac7ae..6ea402dec7 100644 --- a/packages/build-tools/src/steps/utils/deviceRunSessionArtifacts.ts +++ b/packages/build-tools/src/steps/utils/deviceRunSessionArtifacts.ts @@ -31,6 +31,7 @@ export async function uploadDeviceRunSessionArtifactAsync( metadata, size, stream, + signal, }: { deviceRunSessionId: string; artifactId: string; @@ -40,8 +41,10 @@ export async function uploadDeviceRunSessionArtifactAsync( metadata?: Record; size: number; stream: NodeJS.ReadableStream; + signal?: AbortSignal; } ): Promise { + signal?.throwIfAborted(); const uploadSession = await createDeviceRunSessionArtifactUploadSessionAsync(ctx, { deviceRunSessionId, artifactId, @@ -50,11 +53,14 @@ export async function uploadDeviceRunSessionArtifactAsync( kind, metadata, size, + signal, }); + signal?.throwIfAborted(); const response = await fetch(uploadSession.url, { method: 'PUT', headers: new Headers(uploadSession.headers as Record), body: stream, + ...(signal ? { signal } : {}), }); if (!response.ok) { throw new SystemError( @@ -74,6 +80,7 @@ async function createDeviceRunSessionArtifactUploadSessionAsync( kind, metadata, size, + signal, }: { deviceRunSessionId: string; artifactId: string; @@ -82,19 +89,32 @@ async function createDeviceRunSessionArtifactUploadSessionAsync( kind: string | undefined; metadata?: Record; size: number; + signal?: AbortSignal; } ) { const result = await ctx.graphqlClient - .mutation(CREATE_DEVICE_RUN_SESSION_ARTIFACT_UPLOAD_SESSION_MUTATION, { - deviceRunSessionId, - input: { - name, - filename, - ...(kind !== undefined ? { kind } : {}), - ...(metadata !== undefined ? { metadata } : {}), - size, + .mutation( + CREATE_DEVICE_RUN_SESSION_ARTIFACT_UPLOAD_SESSION_MUTATION, + { + deviceRunSessionId, + input: { + name, + filename, + ...(kind !== undefined ? { kind } : {}), + ...(metadata !== undefined ? { metadata } : {}), + size, + }, }, - }) + signal + ? { + fetch: (input, options) => + globalThis.fetch(input, { + ...options, + signal: AbortSignal.any([signal, ...(options?.signal ? [options.signal] : [])]), + }), + } + : undefined + ) .toPromise(); if (result.error) { throw new SystemError( From 3ad807c438bd24a0fbae84b69e39b2ad24c936dc Mon Sep 17 00:00:00 2001 From: SJ Kim Date: Thu, 17 Sep 2026 11:06:40 -0400 Subject: [PATCH 2/2] [build-tools] Link app log changelog entry (ENG-26642) --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 54a0173a0a..92e5bfbc5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ This is the log of notable changes to EAS CLI and related packages. - [build-tools] Pass `--share-url` when launching serve-sim so Share copies the expo.dev preview page instead of the tunnel URL. ([#4434](https://github.com/expo/eas-cli/pull/4434) by [@gwdp](https://github.com/gwdp)) - [eas-cli] Add `eas integrations:supabase:advisors` to list unresolved Supabase Security and Performance Advisor findings for the linked Supabase project. ([#4342](https://github.com/expo/eas-cli/pull/4342) by [@fiberjw](https://github.com/fiberjw)) -- [build-tools] Preserve already-captured Agent Device app logs as simulator session artifacts after shutdown. +- [build-tools] Preserve already-captured Agent Device app logs as simulator session artifacts after shutdown. ([#4429](https://github.com/expo/eas-cli/pull/4429) by [@sjkim-expo](https://github.com/sjkim-expo)) ### 🐛 Bug fixes