From d27b9617e4a631eb54ba515571bf35d6076ef0e5 Mon Sep 17 00:00:00 2001 From: SJ Kim Date: Wed, 23 Sep 2026 16:33:36 -0400 Subject: [PATCH 1/8] [build-tools] Collect shared simulator logs (ENG-26642) --- CHANGELOG.md | 1 + .../build-tools/src/steps/easFunctions.ts | 4 + .../functions/__tests__/serveSimLogs.test.ts | 60 ++++ .../steps/functions/collectServeSimLogs.ts | 38 +++ .../src/steps/functions/startServeSimLogs.ts | 23 ++ .../deviceRunSessionArtifacts.test.ts | 51 ++- .../__tests__/serveSimLogsArtifacts.test.ts | 57 ++++ .../utils/__tests__/serveSimLogsHttp.test.ts | 189 +++++++++++ .../__tests__/serveSimLogsRecorder.test.ts | 254 +++++++++++++++ .../steps/utils/deviceRunSessionArtifacts.ts | 38 ++- .../src/steps/utils/serveSimLogsArtifacts.ts | 53 ++++ .../src/steps/utils/serveSimLogsRecorder.ts | 299 ++++++++++++++++++ 12 files changed, 1057 insertions(+), 10 deletions(-) create mode 100644 packages/build-tools/src/steps/functions/__tests__/serveSimLogs.test.ts create mode 100644 packages/build-tools/src/steps/functions/collectServeSimLogs.ts create mode 100644 packages/build-tools/src/steps/functions/startServeSimLogs.ts create mode 100644 packages/build-tools/src/steps/utils/__tests__/serveSimLogsArtifacts.test.ts create mode 100644 packages/build-tools/src/steps/utils/__tests__/serveSimLogsHttp.test.ts create mode 100644 packages/build-tools/src/steps/utils/__tests__/serveSimLogsRecorder.test.ts create mode 100644 packages/build-tools/src/steps/utils/serveSimLogsArtifacts.ts create mode 100644 packages/build-tools/src/steps/utils/serveSimLogsRecorder.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f5d8193da9..b68f1edc28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ This is the log of notable changes to EAS CLI and related packages. ### 🎉 New features +- [build-tools] Add start and collect steps that attach bounded simulator logs from serve-sim to device run sessions. - [eas-cli] Accept build IDs in `workflow:ssh `. ([#4452](https://github.com/expo/eas-cli/pull/4452) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR)) - [eas-cli] Make `eas simulator:*` commands visible in help output. ([#4456](https://github.com/expo/eas-cli/pull/4456) by [@szdziedzic](https://github.com/szdziedzic)) - [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)) diff --git a/packages/build-tools/src/steps/easFunctions.ts b/packages/build-tools/src/steps/easFunctions.ts index 68969b99bf..6349402caf 100644 --- a/packages/build-tools/src/steps/easFunctions.ts +++ b/packages/build-tools/src/steps/easFunctions.ts @@ -54,6 +54,8 @@ import { createStartIosSimulatorRecordingsBuildFunction } from './functions/star import { createStartLocalEgressBuildFunction } from './functions/startLocalEgress'; import { createStartWebPreviewRemoteSessionBuildFunction } from './functions/startWebPreviewRemoteSession'; import { createStartServeSimMetricsBuildFunction } from './functions/startServeSimMetrics'; +import { createStartServeSimLogsBuildFunction } from './functions/startServeSimLogs'; +import { createCollectServeSimLogsBuildFunction } from './functions/collectServeSimLogs'; import { createStartSandboxBuildFunction } from './functions/startSandbox'; import { createCollectServeSimMetricsBuildFunction } from './functions/collectServeSimMetrics'; import { createUploadArtifactBuildFunction } from './functions/uploadArtifact'; @@ -111,6 +113,8 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] { createUploadDeviceRunSessionScreenRecordingsBuildFunction(ctx), createStartWebPreviewRemoteSessionBuildFunction(ctx), createStartServeSimMetricsBuildFunction(), + createStartServeSimLogsBuildFunction(), + createCollectServeSimLogsBuildFunction(ctx), createStartSandboxBuildFunction(ctx), createCollectServeSimMetricsBuildFunction(ctx), createInstallMaestroBuildFunction(), diff --git a/packages/build-tools/src/steps/functions/__tests__/serveSimLogs.test.ts b/packages/build-tools/src/steps/functions/__tests__/serveSimLogs.test.ts new file mode 100644 index 0000000000..ce11913c69 --- /dev/null +++ b/packages/build-tools/src/steps/functions/__tests__/serveSimLogs.test.ts @@ -0,0 +1,60 @@ +import { type bunyan } from '@expo/logger'; +import { type BuildStepContext, type BuildStepEnv } from '@expo/steps'; + +import { type CustomBuildContext } from '../../../customBuildContext'; +import { getDeviceRunSessionIdOrThrow } from '../../utils/remoteDeviceRunSession'; +import { uploadServeSimLogsFileAsync } from '../../utils/serveSimLogsArtifacts'; +import { ServeSimLogsRecorder } from '../../utils/serveSimLogsRecorder'; +import { createCollectServeSimLogsBuildFunction } from '../collectServeSimLogs'; +import { createStartServeSimLogsBuildFunction } from '../startServeSimLogs'; + +jest.mock('../../utils/serveSimLogsRecorder'); +jest.mock('../../utils/serveSimLogsArtifacts'); +jest.mock('../../utils/remoteDeviceRunSession'); + +const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan; +const ctx = {} as CustomBuildContext; +const step = { logger } as BuildStepContext; +const args = { inputs: {}, outputs: {}, env: {} as BuildStepEnv }; + +beforeEach(() => { + jest.mocked(getDeviceRunSessionIdOrThrow).mockReturnValue('session-id'); + jest.mocked(ServeSimLogsRecorder.finishAsync).mockResolvedValue([]); +}); + +it('starts collection and tolerates startup failures', async () => { + jest.mocked(ServeSimLogsRecorder.startAsync).mockRejectedValueOnce(new Error('disk failure')); + await expect(createStartServeSimLogsBuildFunction().fn?.(step, args)).resolves.toBeUndefined(); + expect(logger.warn).toHaveBeenCalled(); +}); + +it('uploads each device using one bounded finalization signal', async () => { + jest.mocked(ServeSimLogsRecorder.finishAsync).mockResolvedValue([ + { udid: 'A', filePath: '/tmp/A.ndjson' }, + { udid: 'B', filePath: '/tmp/B.ndjson' }, + ]); + await createCollectServeSimLogsBuildFunction(ctx).fn?.(step, args); + expect(uploadServeSimLogsFileAsync).toHaveBeenCalledTimes(2); + expect(uploadServeSimLogsFileAsync).toHaveBeenCalledWith( + ctx, + expect.objectContaining({ + deviceRunSessionId: 'session-id', + udid: 'A', + filePath: '/tmp/A.ndjson', + signal: expect.any(AbortSignal), + }) + ); + expect(jest.mocked(uploadServeSimLogsFileAsync).mock.calls[0][1].signal).toBe( + jest.mocked(uploadServeSimLogsFileAsync).mock.calls[1][1].signal + ); +}); + +it('skips empty collection and warns on finalization failure', async () => { + await createCollectServeSimLogsBuildFunction(ctx).fn?.(step, args); + expect(uploadServeSimLogsFileAsync).not.toHaveBeenCalled(); + jest.mocked(ServeSimLogsRecorder.finishAsync).mockRejectedValueOnce(new Error('failure')); + await expect( + createCollectServeSimLogsBuildFunction(ctx).fn?.(step, args) + ).resolves.toBeUndefined(); + expect(logger.warn).toHaveBeenCalled(); +}); diff --git a/packages/build-tools/src/steps/functions/collectServeSimLogs.ts b/packages/build-tools/src/steps/functions/collectServeSimLogs.ts new file mode 100644 index 0000000000..cb081e4614 --- /dev/null +++ b/packages/build-tools/src/steps/functions/collectServeSimLogs.ts @@ -0,0 +1,38 @@ +import { BuildFunction, BuildRuntimePlatform } from '@expo/steps'; + +import { type CustomBuildContext } from '../../customBuildContext'; +import { getDeviceRunSessionIdOrThrow } from '../utils/remoteDeviceRunSession'; +import { uploadServeSimLogsFileAsync } from '../utils/serveSimLogsArtifacts'; +import { ServeSimLogsRecorder } from '../utils/serveSimLogsRecorder'; + +export function createCollectServeSimLogsBuildFunction(ctx: CustomBuildContext): BuildFunction { + return new BuildFunction({ + namespace: 'eas', + id: 'collect_serve_sim_logs', + name: 'Collect serve-sim simulator logs', + __metricsId: 'eas/collect_serve_sim_logs', + supportedRuntimePlatforms: [BuildRuntimePlatform.DARWIN], + fn: async ({ logger }, { env }) => { + try { + const collected = await ServeSimLogsRecorder.finishAsync({ logger }); + if (collected.length === 0) { + logger.info('No simulator logs collected; skipping upload.'); + return; + } + const deviceRunSessionId = getDeviceRunSessionIdOrThrow(env); + const signal = AbortSignal.timeout(30_000); + for (const { udid, filePath } of collected) { + await uploadServeSimLogsFileAsync(ctx, { + deviceRunSessionId, + udid, + filePath, + logger, + signal, + }); + } + } catch (err) { + logger.warn({ err }, 'Could not finalize simulator logs; the session result is unchanged.'); + } + }, + }); +} diff --git a/packages/build-tools/src/steps/functions/startServeSimLogs.ts b/packages/build-tools/src/steps/functions/startServeSimLogs.ts new file mode 100644 index 0000000000..64b19d281f --- /dev/null +++ b/packages/build-tools/src/steps/functions/startServeSimLogs.ts @@ -0,0 +1,23 @@ +import { BuildFunction, BuildRuntimePlatform } from '@expo/steps'; + +import { ServeSimLogsRecorder } from '../utils/serveSimLogsRecorder'; + +export function createStartServeSimLogsBuildFunction(): BuildFunction { + return new BuildFunction({ + namespace: 'eas', + id: 'start_serve_sim_logs', + name: 'Start serve-sim simulator logs', + __metricsId: 'eas/start_serve_sim_logs', + supportedRuntimePlatforms: [BuildRuntimePlatform.DARWIN], + fn: async ({ logger }) => { + try { + await ServeSimLogsRecorder.startAsync({ logger }); + } catch (err) { + logger.warn( + { err }, + 'Could not start simulator log collection; the session will continue.' + ); + } + }, + }); +} 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..b30fb2f3f5 100644 --- a/packages/build-tools/src/steps/utils/__tests__/deviceRunSessionArtifacts.test.ts +++ b/packages/build-tools/src/steps/utils/__tests__/deviceRunSessionArtifacts.test.ts @@ -63,7 +63,8 @@ describe(uploadDeviceRunSessionArtifactAsync, () => { metadata: { firstFrameRecordAt: 'test-time' }, size: reportedSize, }, - }) + }), + undefined ); expect(jest.mocked(fetch)).toHaveBeenCalledWith( 'https://uploads.expo.test/artifact', @@ -73,4 +74,52 @@ describe(uploadDeviceRunSessionArtifactAsync, () => { }) ); }); + + it('forwards cancellation through upload allocation and skips PUT when allocation aborts', async () => { + const controller = new AbortController(); + const mutation = jest.fn().mockReturnValue({ + toPromise: async () => { + controller.abort(); + return { + data: { + deviceRunSession: { + createArtifactUploadSession: { + uploadSession: { + url: 'https://uploads.expo.test/artifact', + headers: {}, + }, + }, + }, + }, + }; + }, + }); + const ctx = { graphqlClient: { mutation } } as unknown as CustomBuildContext; + await expect( + uploadDeviceRunSessionArtifactAsync(ctx, { + deviceRunSessionId: 'session', + artifactId: 'id', + name: 'Logs', + filename: 'logs.ndjson', + kind: 'simulator-log', + size: 0, + stream: Readable.from([]), + signal: controller.signal, + }) + ).rejects.toThrow(); + expect(fetch).not.toHaveBeenCalled(); + const allocationFetch = mutation.mock.calls[0][2].fetch; + const mockFetch = jest.spyOn(globalThis, 'fetch').mockRejectedValueOnce(new Error('aborted')); + try { + await expect(allocationFetch('https://api.expo.test/graphql', {})).rejects.toThrow('aborted'); + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.expo.test/graphql', + expect.objectContaining({ + signal: expect.objectContaining({ aborted: true }), + }) + ); + } finally { + mockFetch.mockRestore(); + } + }); }); diff --git a/packages/build-tools/src/steps/utils/__tests__/serveSimLogsArtifacts.test.ts b/packages/build-tools/src/steps/utils/__tests__/serveSimLogsArtifacts.test.ts new file mode 100644 index 0000000000..dbf56f5e4c --- /dev/null +++ b/packages/build-tools/src/steps/utils/__tests__/serveSimLogsArtifacts.test.ts @@ -0,0 +1,57 @@ +import { type bunyan } from '@expo/logger'; +import { Readable } from 'node:stream'; +import { writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { type CustomBuildContext } from '../../../customBuildContext'; +import { uploadDeviceRunSessionArtifactAsync } from '../deviceRunSessionArtifacts'; +import { uploadServeSimLogsFileAsync } from '../serveSimLogsArtifacts'; + +jest.mock('../deviceRunSessionArtifacts'); +const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan; +const ctx = {} as CustomBuildContext; +const filePath = path.join(os.tmpdir(), 'logs.ndjson'); +const args = { deviceRunSessionId: 'session', udid: 'A', filePath, logger }; + +it('uploads NDJSON with explicit simulator scope and destroys the stream afterward', async () => { + await writeFile(filePath, '{"pid":42}\n'); + await uploadServeSimLogsFileAsync(ctx, args); + expect(uploadDeviceRunSessionArtifactAsync).toHaveBeenCalledWith( + ctx, + expect.objectContaining({ + kind: 'simulator-log', + filename: 'simulator.ndjson', + size: 11, + metadata: expect.objectContaining({ scope: 'simulator', udid: 'A' }), + }) + ); + expect( + (jest.mocked(uploadDeviceRunSessionArtifactAsync).mock.calls[0][1].stream as Readable).destroyed + ).toBe(true); +}); + +it('skips empty and missing files', async () => { + await uploadServeSimLogsFileAsync(ctx, args); + await writeFile(filePath, ''); + await uploadServeSimLogsFileAsync(ctx, args); + expect(uploadDeviceRunSessionArtifactAsync).not.toHaveBeenCalled(); +}); + +it('handles a file read error while upload allocation is pending without an unhandled error', async () => { + await writeFile(filePath, '{}\n'); + jest + .mocked(uploadDeviceRunSessionArtifactAsync) + .mockImplementationOnce(async (_ctx, { stream, signal }) => { + const aborted = new Promise((_resolve, reject) => { + signal!.addEventListener('abort', () => reject(signal!.reason), { once: true }); + }); + (stream as Readable).destroy(new Error('read failure')); + await aborted; + }); + await expect(uploadServeSimLogsFileAsync(ctx, args)).resolves.toBeUndefined(); + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ err: expect.any(Error) }), + expect.stringContaining('Could not upload') + ); +}); diff --git a/packages/build-tools/src/steps/utils/__tests__/serveSimLogsHttp.test.ts b/packages/build-tools/src/steps/utils/__tests__/serveSimLogsHttp.test.ts new file mode 100644 index 0000000000..806cf3fbd1 --- /dev/null +++ b/packages/build-tools/src/steps/utils/__tests__/serveSimLogsHttp.test.ts @@ -0,0 +1,189 @@ +import { type bunyan } from '@expo/logger'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { type Server, createServer } from 'node:http'; +import { type AddressInfo } from 'node:net'; +import os from 'node:os'; +import path from 'node:path'; + +import { type CustomBuildContext } from '../../../customBuildContext'; +import { uploadServeSimLogsFileAsync } from '../serveSimLogsArtifacts'; +import { streamServeSimLogsToFileAsync } from '../serveSimLogsRecorder'; + +// This test exercises actual sockets, UTF-8 decoding, files, and PUT uploads. +// Only WWW's allocation response is substituted; it does not verify cloud storage. +jest.unmock('fs'); +jest.unmock('node:fs'); +jest.unmock('fs/promises'); +jest.unmock('node:fs/promises'); +jest.unmock('node-fetch'); + +const logger = { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), +} as unknown as bunyan; + +let directory: string; +let server: Server; +let baseUrl: string; + +beforeEach(async () => { + directory = await mkdtemp(path.join(os.tmpdir(), 'simulator-logs-http-test-')); + server = createServer(); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; +}); + +afterEach(async () => { + server.closeAllConnections(); + await new Promise((resolve, reject) => + server.close(error => (error ? reject(error) : resolve())) + ); + await rm(directory, { recursive: true, force: true }); +}); + +it('streams selected-device logs into NDJSON and uploads the exact bytes as a session artifact', async () => { + const record = JSON.stringify({ eventMessage: 'Hello 서울 🌲', process: 'MyApp' }); + const expected = Buffer.from(record + '\n'); + const envelope = JSON.stringify({ seq: 7, at: 1000, raw: record }); + const wire = Buffer.from( + `: heartbeat\n\ndata: ${envelope}\n\ndata: invalid json\n\ndata: {"partial":` + ); + const splitAt = wire.indexOf(Buffer.from('서울')) + 1; + let streamRequest: { url?: string; authorization?: string } | undefined; + let uploaded: Buffer | undefined; + let uploadedLength: string | undefined; + server.on('request', (request, response) => { + if (request.method === 'GET') { + streamRequest = { url: request.url, authorization: request.headers.authorization }; + response.writeHead(200, { 'Content-Type': 'text/event-stream' }); + response.write(wire.subarray(0, splitAt)); + setImmediate(() => response.end(wire.subarray(splitAt))); + return; + } + const chunks: Buffer[] = []; + request.on('data', chunk => chunks.push(Buffer.from(chunk))); + request.on('end', () => { + uploaded = Buffer.concat(chunks); + uploadedLength = request.headers['content-length']; + response.writeHead(200).end(); + }); + }); + const filePath = path.join(directory, 'simulator.ndjson'); + const result = await streamServeSimLogsToFileAsync({ + serveSimUrl: baseUrl, + serveSimToken: 'local-test-token', + serveSimDevice: 'device-B', + filePath, + signal: new AbortController().signal, + logger, + }); + expect(streamRequest).toEqual({ + url: '/logs?envelope=true&device=device-B', + authorization: 'Bearer local-test-token', + }); + expect(result).toEqual({ + receivedData: true, + limitReached: false, + bytesWritten: expected.length, + lastSequence: 7, + }); + expect(await readFile(filePath)).toEqual(expected); + + const mutation = jest.fn().mockReturnValue({ + toPromise: async () => ({ + data: { + deviceRunSession: { + createArtifactUploadSession: { + uploadSession: { + url: `${baseUrl}/upload`, + headers: { 'Content-Length': String(expected.length) }, + }, + }, + }, + }, + }), + }); + await uploadServeSimLogsFileAsync( + { graphqlClient: { mutation } } as unknown as CustomBuildContext, + { deviceRunSessionId: 'session-test', udid: 'device-B', filePath, logger } + ); + expect(mutation.mock.calls[0][1]).toEqual({ + deviceRunSessionId: 'session-test', + input: { + name: 'Simulator logs (device-B)', + filename: 'simulator.ndjson', + kind: 'simulator-log', + metadata: { + __eas_type: 'simulator-log', + udid: 'device-B', + scope: 'simulator', + source: 'serve-sim/logs', + }, + size: expected.length, + }, + }); + expect(uploaded).toEqual(expected); + expect(uploadedLength).toBe(String(expected.length)); + expect(logger.warn).not.toHaveBeenCalled(); +}); + +it('resumes a buffered stream without appending already persisted sequence numbers', async () => { + const urls: string[] = []; + const first = JSON.stringify({ eventMessage: 'first' }); + const second = JSON.stringify({ eventMessage: 'second' }); + const frame = (seq: number, raw: string): string => + `data: ${JSON.stringify({ seq, at: seq * 1000, raw })}\n\n`; + server.on('request', (request, response) => { + urls.push(request.url!); + response.writeHead(200, { 'Content-Type': 'text/event-stream' }); + // Deliberately resend seq=1 on reconnect to exercise the client's guard too. + response.end(frame(1, first) + (urls.length > 1 ? frame(2, second) : '')); + }); + const filePath = path.join(directory, 'resumed.ndjson'); + const options = { + serveSimUrl: baseUrl, + serveSimDevice: 'device-A', + filePath, + signal: new AbortController().signal, + logger, + }; + const initial = await streamServeSimLogsToFileAsync(options); + const resumed = await streamServeSimLogsToFileAsync({ + ...options, + since: initial.lastSequence, + }); + expect(initial.lastSequence).toBe(1); + expect(resumed.lastSequence).toBe(2); + expect(resumed.bytesWritten).toBe(Buffer.byteLength(second + '\n')); + expect(urls).toEqual([ + '/logs?envelope=true&device=device-A', + '/logs?envelope=true&since=1&device=device-A', + ]); + expect(await readFile(filePath, 'utf8')).toBe(first + '\n' + second + '\n'); +}); + +it('aborts a real open HTTP stream without waiting for the server to finish', async () => { + const controller = new AbortController(); + let sawRequest: () => void = () => {}; + const requestStarted = new Promise(resolve => (sawRequest = resolve)); + server.on('request', (_request, response) => { + response.writeHead(200, { 'Content-Type': 'text/event-stream' }); + response.write(': heartbeat\n\n'); + sawRequest(); + }); + const streaming = streamServeSimLogsToFileAsync({ + serveSimUrl: baseUrl, + filePath: path.join(directory, 'aborted.ndjson'), + signal: controller.signal, + logger, + }); + await requestStarted; + controller.abort(); + await expect(streaming).resolves.toEqual({ + receivedData: false, + limitReached: false, + bytesWritten: 0, + }); +}); diff --git a/packages/build-tools/src/steps/utils/__tests__/serveSimLogsRecorder.test.ts b/packages/build-tools/src/steps/utils/__tests__/serveSimLogsRecorder.test.ts new file mode 100644 index 0000000000..80ecc88709 --- /dev/null +++ b/packages/build-tools/src/steps/utils/__tests__/serveSimLogsRecorder.test.ts @@ -0,0 +1,254 @@ +import { type bunyan } from '@expo/logger'; +import fetch from 'node-fetch'; +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { Readable } from 'node:stream'; +import { setTimeout as delay } from 'node:timers/promises'; + +import { ServeSimLogsRecorder, streamServeSimLogsToFileAsync } from '../serveSimLogsRecorder'; + +jest.mock('node-fetch'); +jest.mock('../../../sentry'); +jest.unmock('node:fs'); +jest.unmock('node:fs/promises'); +const { Response } = jest.requireActual('node-fetch') as typeof import('node-fetch'); +const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan; +let directory: string; + +beforeEach(async () => { + jest.mocked(fetch).mockReset(); + directory = await mkdtemp(path.join(os.tmpdir(), 'logs-test-')); +}); + +afterEach(async () => { + const files = await ServeSimLogsRecorder.finishAsync({ logger }); + for (const file of files) { + await rm(path.dirname(file.filePath), { recursive: true, force: true }); + } + await rm(directory, { recursive: true, force: true }); +}); + +function record(options: Partial[0]> = {}) { + return streamServeSimLogsToFileAsync({ + serveSimUrl: 'http://localhost:1234', + filePath: path.join(directory, 'logs.ndjson'), + signal: new AbortController().signal, + logger, + ...options, + }); +} + +it('preserves split UTF-8 and CRLF records, skips heartbeat and invalid JSON, and selects a device', async () => { + const input = Buffer.from( + ':\r\n\r\ndata: {"message":"안녕"}\r\n\r\ndata: bad\n\ndata: {"pid":42}\n\n' + ); + jest + .mocked(fetch) + .mockResolvedValue(new Response(Readable.from([...input].map(byte => Buffer.from([byte]))))); + const result = await record({ serveSimToken: 'secret', serveSimDevice: 'device two' }); + expect(result.receivedData).toBe(true); + expect(await readFile(path.join(directory, 'logs.ndjson'), 'utf8')).toBe( + '{"message":"안녕"}\n{"pid":42}\n' + ); + expect(fetch).toHaveBeenCalledWith( + 'http://localhost:1234/logs?envelope=true&device=device+two', + expect.objectContaining({ headers: { Authorization: 'Bearer secret' } }) + ); +}); + +it('retains completed records when a connection errors', async () => { + const stream = Readable.from( + (async function* () { + yield Buffer.from('data: {"pid":42}\n\n'); + await delay(10); + throw new Error('disconnect'); + })() + ); + jest.mocked(fetch).mockResolvedValue(new Response(stream)); + expect(await record()).toEqual({ receivedData: true, bytesWritten: 11, limitReached: false }); + expect(await readFile(path.join(directory, 'logs.ndjson'), 'utf8')).toBe('{"pid":42}\n'); +}); + +it('aborts a quiet live stream and drains preceding writes before returning', async () => { + const controller = new AbortController(); + const stream = new Readable({ read() {} }); + stream.push('data: {"pid":42}\n\n'); + jest.mocked(fetch).mockResolvedValue(new Response(stream)); + const done = record({ signal: controller.signal }); + await delay(20); + controller.abort(); + expect((await done).receivedData).toBe(true); + expect(stream.destroyed).toBe(true); + expect(await readFile(path.join(directory, 'logs.ndjson'), 'utf8')).toBe('{"pid":42}\n'); +}); + +it('enforces the byte limit without writing a truncated record', async () => { + jest + .mocked(fetch) + .mockResolvedValue(new Response(Readable.from(['data: {"pid":42}\n\ndata: {"pid":43}\n\n']))); + expect(await record({ maxBytes: 15 })).toEqual({ + receivedData: true, + bytesWritten: 11, + limitReached: true, + }); + expect(await readFile(path.join(directory, 'logs.ndjson'), 'utf8')).toBe('{"pid":42}\n'); +}); + +it('drops an oversized partial SSE line and resumes at the next complete record', async () => { + jest + .mocked(fetch) + .mockResolvedValue( + new Response( + Readable.from(['data: ' + 'x'.repeat(1024 * 1024 + 1), 'tail\n\ndata: {"pid":42}\n\n']) + ) + ); + expect((await record()).bytesWritten).toBe(11); +}); + +it('warns and resolves on HTTP and file failures', async () => { + jest.mocked(fetch).mockResolvedValueOnce(new Response('missing', { status: 404 })); + expect((await record()).receivedData).toBe(false); + jest.mocked(fetch).mockResolvedValueOnce(new Response(Readable.from(['data: {}\n\n']))); + expect((await record({ filePath: '/missing-dir/log.ndjson' })).receivedData).toBe(false); + expect(logger.warn).toHaveBeenCalledTimes(2); +}); + +async function register(): Promise { + const stateDir = path.join(directory, 'state'); + await mkdir(stateDir); + await writeFile( + path.join(stateDir, 'server-A.json'), + JSON.stringify({ device: 'A', url: 'http://localhost:1234' }) + ); + return stateDir; +} + +it('caps bytes across reconnects and finalizes only once even concurrently', async () => { + const stateDir = await register(); + let seq = 0; + jest + .mocked(fetch) + .mockImplementation( + async () => + new Response( + Readable.from([`data: ${JSON.stringify({ seq: ++seq, raw: '{"pid":42}' })}\n\n`]) + ) + ); + await ServeSimLogsRecorder.startAsync({ logger, stateDir, pollIntervalMs: 5, maxBytes: 22 }); + await delay(100); + expect(fetch).toHaveBeenCalledTimes(2); + const [first, second] = await Promise.all([ + ServeSimLogsRecorder.finishAsync({ logger }), + ServeSimLogsRecorder.finishAsync({ logger }), + ]); + expect(first).toHaveLength(1); + expect(second).toEqual([]); + expect(await readFile(first[0].filePath, 'utf8')).toBe('{"pid":42}\n{"pid":42}\n'); + expect(await ServeSimLogsRecorder.finishAsync({ logger })).toEqual([]); +}); + +it('returns immediately before a server exists and discovers a later registration', async () => { + const stateDir = path.join(directory, 'state'); + jest.mocked(fetch).mockImplementation(async () => new Response(Readable.from(['data: {}\n\n']))); + await ServeSimLogsRecorder.startAsync({ logger, stateDir, pollIntervalMs: 5 }); + expect(fetch).not.toHaveBeenCalled(); + await register(); + await delay(30); + expect(await ServeSimLogsRecorder.finishAsync({ logger })).toHaveLength(1); +}); + +it('stops discovery and active streams at the session time limit', async () => { + const stateDir = await register(); + const stream = new Readable({ read() {} }); + stream.push('data: {}\n\n'); + jest.mocked(fetch).mockResolvedValue(new Response(stream)); + await ServeSimLogsRecorder.startAsync({ logger, stateDir, pollIntervalMs: 5, maxDurationMs: 30 }); + await delay(80); + expect(stream.destroyed).toBe(true); + expect(fetch).toHaveBeenCalledTimes(1); + expect(await ServeSimLogsRecorder.finishAsync({ logger })).toHaveLength(1); +}); + +it('stops retrying after ten consecutive failures', async () => { + const stateDir = await register(); + jest.mocked(fetch).mockImplementation(async () => new Response('bad', { status: 503 })); + await ServeSimLogsRecorder.startAsync({ logger, stateDir, pollIntervalMs: 5 }); + await delay(150); + expect(fetch).toHaveBeenCalledTimes(10); + expect(await ServeSimLogsRecorder.finishAsync({ logger })).toEqual([]); +}); + +it('selects each registered device when two simulators share one server URL', async () => { + const stateDir = await register(); + await writeFile( + path.join(stateDir, 'server-B.json'), + JSON.stringify({ device: 'B', url: 'http://localhost:1234' }) + ); + jest.mocked(fetch).mockImplementation(async () => new Response(Readable.from(['data: {}\n\n']))); + await ServeSimLogsRecorder.startAsync({ logger, stateDir, pollIntervalMs: 5, maxBytes: 3 }); + await delay(50); + expect(fetch).toHaveBeenCalledWith( + 'http://localhost:1234/logs?envelope=true&device=A', + expect.anything() + ); + expect(fetch).toHaveBeenCalledWith( + 'http://localhost:1234/logs?envelope=true&device=B', + expect.anything() + ); + expect(fetch).toHaveBeenCalledTimes(2); +}); + +it('persists the envelope cursor only for written records and drops replayed sequences', async () => { + const frames = [1, 2, 3].map( + seq => `data: ${JSON.stringify({ seq, raw: JSON.stringify({ pid: seq }) })}\n\n` + ); + jest.mocked(fetch).mockResolvedValue(new Response(Readable.from(frames))); + expect(await record({ since: 1, maxBytes: 10 })).toEqual({ + receivedData: true, + bytesWritten: 10, + limitReached: true, + lastSequence: 2, + }); + expect(await readFile(path.join(directory, 'logs.ndjson'), 'utf8')).toBe('{"pid":2}\n'); + expect(fetch).toHaveBeenCalledWith( + 'http://localhost:1234/logs?envelope=true&since=1', + expect.anything() + ); +}); + +it('does not reconnect a legacy data stream without a replay cursor', async () => { + const stateDir = await register(); + jest.mocked(fetch).mockImplementation(async () => new Response(Readable.from(['data: {}\n\n']))); + await ServeSimLogsRecorder.startAsync({ logger, stateDir, pollIntervalMs: 5 }); + await delay(50); + expect(fetch).toHaveBeenCalledTimes(1); + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('without a replay cursor')); +}); + +it('resets the replay cursor when a replacement server changes its token', async () => { + const stateDir = await register(); + let calls = 0; + jest.mocked(fetch).mockImplementation(async (_url, options) => { + calls++; + if (calls === 1) { + await writeFile( + path.join(stateDir, 'server-A.json'), + JSON.stringify({ + device: 'A', + url: 'http://localhost:1234', + token: 'new-token', + }) + ); + } + const seq = options?.headers ? 1 : 50; + return new Response(Readable.from([`data: ${JSON.stringify({ seq, raw: '{}' })}\n\n`])); + }); + await ServeSimLogsRecorder.startAsync({ logger, stateDir, pollIntervalMs: 5, maxBytes: 6 }); + await delay(50); + expect(fetch).toHaveBeenCalledTimes(2); + expect(String(jest.mocked(fetch).mock.calls[1][0])).not.toContain('since='); + expect(jest.mocked(fetch).mock.calls[1][1]?.headers).toEqual({ + Authorization: 'Bearer new-token', + }); +}); 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( diff --git a/packages/build-tools/src/steps/utils/serveSimLogsArtifacts.ts b/packages/build-tools/src/steps/utils/serveSimLogsArtifacts.ts new file mode 100644 index 0000000000..9209d4adf6 --- /dev/null +++ b/packages/build-tools/src/steps/utils/serveSimLogsArtifacts.ts @@ -0,0 +1,53 @@ +import { type bunyan } from '@expo/logger'; +import { createReadStream } from 'node:fs'; +import { stat } from 'node:fs/promises'; + +import { type CustomBuildContext } from '../../customBuildContext'; +import { uploadDeviceRunSessionArtifactAsync } from './deviceRunSessionArtifacts'; + +export async function uploadServeSimLogsFileAsync( + ctx: CustomBuildContext, + { + deviceRunSessionId, + udid, + filePath, + logger, + signal, + }: { + deviceRunSessionId: string; + udid: string; + filePath: string; + logger: bunyan; + signal?: AbortSignal; + } +): Promise { + let stream: ReturnType | undefined; + try { + const { size } = await stat(filePath); + if (size === 0) { + return; + } + const fileController = new AbortController(); + stream = createReadStream(filePath); + // A read error can occur while the upload session is still being allocated. + stream.on('error', err => fileController.abort(err)); + await uploadDeviceRunSessionArtifactAsync(ctx, { + deviceRunSessionId, + artifactId: `simulator-log-${udid}`, + name: `Simulator logs (${udid.slice(0, 8)})`, + filename: 'simulator.ndjson', + kind: 'simulator-log', + metadata: { __eas_type: 'simulator-log', udid, scope: 'simulator', source: 'serve-sim/logs' }, + size, + stream, + signal: AbortSignal.any([fileController.signal, ...(signal ? [signal] : [])]), + }); + } catch (err) { + logger.warn( + { err }, + `Could not upload simulator logs for ${udid}; other artifacts will continue.` + ); + } finally { + stream?.destroy(); + } +} diff --git a/packages/build-tools/src/steps/utils/serveSimLogsRecorder.ts b/packages/build-tools/src/steps/utils/serveSimLogsRecorder.ts new file mode 100644 index 0000000000..5f55e79e14 --- /dev/null +++ b/packages/build-tools/src/steps/utils/serveSimLogsRecorder.ts @@ -0,0 +1,299 @@ +import { type bunyan } from '@expo/logger'; +import fetch from 'node-fetch'; +import { mkdtemp, open } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { setTimeout as delay } from 'node:timers/promises'; + +import { SERVE_SIM_STATE_DIR, readServeSimServersAsync } from './serveSimMetricsRecorder'; + +// The endpoint includes all simulator processes. Newer serve-sim versions replay +// buffered records and support a sequence cursor; older versions stream raw NDJSON. +const MAX_BYTES_PER_DEVICE = 20 * 1024 * 1024; +const MAX_DURATION_MS = 30 * 60 * 1000; +const MAX_LINE_LENGTH = 1024 * 1024; +const MAX_CONSECUTIVE_FAILURES = 10; + +type CollectedLog = { udid: string; filePath: string }; +type Session = { + controller: AbortController; + timer: NodeJS.Timeout; + polling: Promise; + streams: Map>; + files: Map; + finishing?: Promise; +}; +let activeSession: Session | undefined; + +export namespace ServeSimLogsRecorder { + export async function startAsync({ + logger, + stateDir = SERVE_SIM_STATE_DIR, + pollIntervalMs = 2_000, + maxBytes = MAX_BYTES_PER_DEVICE, + maxDurationMs = MAX_DURATION_MS, + }: { + logger: bunyan; + stateDir?: string; + pollIntervalMs?: number; + maxBytes?: number; + maxDurationMs?: number; + }): Promise { + if (activeSession) { + return; + } + const controller = new AbortController(); + const session: Session = { + controller, + timer: setTimeout(() => controller.abort(), maxDurationMs), + polling: Promise.resolve(), + streams: new Map(), + files: new Map(), + }; + session.timer.unref(); + activeSession = session; + // Reserve the session before asynchronous setup so concurrent starts are harmless. + session.polling = (async () => { + const outputDirectory = await mkdtemp(path.join(os.tmpdir(), 'serve-sim-logs-')); + const failures = new Map(); + const bytes = new Map(); + const capped = new Set(); + const cursors = new Map(); + while (!controller.signal.aborted) { + const servers = await readServeSimServersAsync(stateDir); + const present = new Set(servers.map(server => server.udid)); + for (const udid of failures.keys()) { + if (!present.has(udid)) { + failures.delete(udid); + cursors.delete(udid); + } + } + for (const server of servers) { + if (controller.signal.aborted) { + break; + } + if (session.streams.has(server.udid) || capped.has(server.udid)) { + continue; + } + const filePath = path.join(outputDirectory, `${encodeURIComponent(server.udid)}.ndjson`); + const identity = JSON.stringify([server.url, server.token]); + let cursor = cursors.get(server.udid); + if (cursor?.identity !== identity) { + cursor = { identity }; + cursors.set(server.udid, cursor); + failures.delete(server.udid); + } + if ((failures.get(server.udid) ?? 0) >= MAX_CONSECUTIVE_FAILURES) { + continue; + } + failures.set(server.udid, (failures.get(server.udid) ?? 0) + 1); + const done = streamServeSimLogsToFileAsync({ + serveSimUrl: server.url, + serveSimToken: server.token, + serveSimDevice: server.udid, + since: cursor?.sequence, + filePath, + signal: controller.signal, + logger, + maxBytes: maxBytes - (bytes.get(server.udid) ?? 0), + }) + .then(result => { + bytes.set(server.udid, (bytes.get(server.udid) ?? 0) + result.bytesWritten); + if (result.lastSequence !== undefined && cursors.get(server.udid) === cursor) { + cursors.set(server.udid, { identity, sequence: result.lastSequence }); + } + if (result.receivedData) { + session.files.set(server.udid, { udid: server.udid, filePath }); + failures.set(server.udid, 0); + if (result.lastSequence === undefined) { + capped.add(server.udid); + if (!controller.signal.aborted && !result.limitReached) { + logger.warn( + `Simulator log stream for ${server.udid} ended without a replay cursor; retaining collected logs without reconnecting.` + ); + } + } + } + if (result.limitReached) { + capped.add(server.udid); + logger.warn(`serve-sim simulator logs reached the size limit for ${server.udid}.`); + } + }) + .finally(() => session.streams.delete(server.udid)); + session.streams.set(server.udid, done); + } + await delay(pollIntervalMs, undefined, { signal: controller.signal }).catch(() => {}); + } + })().catch(err => { + logger.warn({ err }, 'Could not collect serve-sim simulator logs.'); + controller.abort(); + }); + logger.info( + 'Started collecting simulator logs (all processes, up to 20 MiB per device and 30 minutes).' + ); + } + + export async function finishAsync({ logger }: { logger: bunyan }): Promise { + const session = activeSession; + if (!session || session.finishing) { + // Only the first finalizer receives files, preventing duplicate uploads. + await session?.finishing; + return []; + } + session.finishing = (async () => { + clearTimeout(session.timer); + session.controller.abort(); + await session.polling; + await Promise.all(session.streams.values()); + logger.info('Finished collecting serve-sim simulator logs.'); + return [...session.files.values()]; + })(); + try { + return await session.finishing; + } finally { + activeSession = undefined; + } + } +} + +export async function streamServeSimLogsToFileAsync({ + serveSimUrl, + serveSimToken, + serveSimDevice, + since, + filePath, + signal, + logger, + maxBytes = MAX_BYTES_PER_DEVICE, +}: { + serveSimUrl: string; + serveSimToken?: string; + serveSimDevice?: string; + since?: number; + filePath: string; + signal: AbortSignal; + logger: bunyan; + maxBytes?: number; +}): Promise<{ + receivedData: boolean; + limitReached: boolean; + bytesWritten: number; + lastSequence?: number; +}> { + let bytesWritten = 0; + let limitReached = maxBytes <= 0; + let lastSequence = since; + let file: Awaited> | undefined; + let body: NodeJS.ReadableStream | undefined; + const closeBody = (): void => { + (body as import('node:stream').Readable | undefined)?.destroy?.(); + }; + try { + if (signal.aborted || limitReached) { + return { receivedData: false, limitReached, bytesWritten }; + } + const url = new URL('/logs', serveSimUrl); + url.searchParams.set('envelope', 'true'); + if (since !== undefined) { + url.searchParams.set('since', String(since)); + } + if (serveSimDevice) { + url.searchParams.set('device', serveSimDevice); + } + const response = await fetch(url.toString(), { + signal, + ...(serveSimToken ? { headers: { Authorization: `Bearer ${serveSimToken}` } } : {}), + }); + body = response.body; + signal.addEventListener('abort', closeBody, { once: true }); + if (signal.aborted) { + closeBody(); + return { receivedData: false, limitReached, bytesWritten }; + } + if (!response.ok || !response.body) { + logger.warn(`serve-sim /logs responded ${response.status}; simulator logs will be retried.`); + return { receivedData: false, limitReached, bytesWritten }; + } + file = await open(filePath, 'a'); + const decoder = new TextDecoder(); + let buffer = ''; + let droppingLine = false; + stream: for await (const chunk of response.body) { + buffer += + typeof chunk === 'string' ? chunk : decoder.decode(chunk as Buffer, { stream: true }); + let newline: number; + while ((newline = buffer.indexOf('\n')) !== -1) { + const line = buffer.slice(0, newline).replace(/\r$/, ''); + buffer = buffer.slice(newline + 1); + const skip = droppingLine || line.length > MAX_LINE_LENGTH; + droppingLine = false; + if (skip || !line.startsWith('data:')) { + continue; + } + let payload = line.slice(5).trim(); + let sequence: number | undefined; + try { + const parsed: unknown = JSON.parse(payload); + if ( + parsed && + typeof parsed === 'object' && + 'seq' in parsed && + 'raw' in parsed && + typeof parsed.seq === 'number' && + Number.isSafeInteger(parsed.seq) && + parsed.seq >= 0 && + typeof parsed.raw === 'string' + ) { + sequence = parsed.seq; + if (lastSequence !== undefined && sequence <= lastSequence) { + continue; + } + // Keep the downloaded artifact homogeneous across old and new servers. + payload = JSON.stringify(JSON.parse(parsed.raw)); + } + } catch { + continue; + } + const record = payload + '\n'; + const size = Buffer.byteLength(record); + if (bytesWritten + size > maxBytes) { + limitReached = true; + break stream; + } + // Await writes to bound buffering, including during teardown; close waits for these writes. + await file.writeFile(record); + bytesWritten += size; + if (sequence !== undefined) { + lastSequence = sequence; + } + if (bytesWritten >= maxBytes) { + limitReached = true; + break stream; + } + } + if (buffer.length > MAX_LINE_LENGTH) { + buffer = ''; + droppingLine = true; + } + } + } catch (err) { + if (!signal.aborted) { + logger.warn( + { err }, + 'serve-sim simulator log stream ended; collected logs will be retained.' + ); + } + } finally { + signal.removeEventListener('abort', closeBody); + closeBody(); + await file?.close().catch(err => { + logger.warn({ err }, 'Could not close the simulator log file.'); + }); + } + return { + receivedData: bytesWritten > 0, + limitReached, + bytesWritten, + ...(lastSequence !== undefined ? { lastSequence } : {}), + }; +} From dd3521352456224a83d4df97ba37b68aee8868a4 Mon Sep 17 00:00:00 2001 From: SJ Kim Date: Wed, 23 Sep 2026 16:42:02 -0400 Subject: [PATCH 2/8] [build-tools] Link simulator log changelog entry (ENG-26642) --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b68f1edc28..d4acb8c42d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ This is the log of notable changes to EAS CLI and related packages. ### 🎉 New features -- [build-tools] Add start and collect steps that attach bounded simulator logs from serve-sim to device run sessions. +- [build-tools] Add start and collect steps that attach bounded simulator logs from serve-sim to device run sessions. ([#4463](https://github.com/expo/eas-cli/pull/4463) by [@sjkim-expo](https://github.com/sjkim-expo)) - [eas-cli] Accept build IDs in `workflow:ssh `. ([#4452](https://github.com/expo/eas-cli/pull/4452) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR)) - [eas-cli] Make `eas simulator:*` commands visible in help output. ([#4456](https://github.com/expo/eas-cli/pull/4456) by [@szdziedzic](https://github.com/szdziedzic)) - [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)) From cabeea976404a7769854729a9ccaa2e17ddc8b23 Mon Sep 17 00:00:00 2001 From: SJ Kim Date: Wed, 23 Sep 2026 18:00:33 -0400 Subject: [PATCH 3/8] [build-tools] Require user-app scope for session logs (ENG-26642) --- .../__tests__/serveSimLogsArtifacts.test.ts | 4 +-- .../utils/__tests__/serveSimLogsHttp.test.ts | 25 ++++++++++----- .../__tests__/serveSimLogsRecorder.test.ts | 32 ++++++++++++++++--- .../src/steps/utils/serveSimLogsArtifacts.ts | 4 +-- .../src/steps/utils/serveSimLogsRecorder.ts | 16 +++++++--- 5 files changed, 59 insertions(+), 22 deletions(-) diff --git a/packages/build-tools/src/steps/utils/__tests__/serveSimLogsArtifacts.test.ts b/packages/build-tools/src/steps/utils/__tests__/serveSimLogsArtifacts.test.ts index dbf56f5e4c..a3d2a1c549 100644 --- a/packages/build-tools/src/steps/utils/__tests__/serveSimLogsArtifacts.test.ts +++ b/packages/build-tools/src/steps/utils/__tests__/serveSimLogsArtifacts.test.ts @@ -14,7 +14,7 @@ const ctx = {} as CustomBuildContext; const filePath = path.join(os.tmpdir(), 'logs.ndjson'); const args = { deviceRunSessionId: 'session', udid: 'A', filePath, logger }; -it('uploads NDJSON with explicit simulator scope and destroys the stream afterward', async () => { +it('uploads NDJSON with explicit user-app scope and destroys the stream afterward', async () => { await writeFile(filePath, '{"pid":42}\n'); await uploadServeSimLogsFileAsync(ctx, args); expect(uploadDeviceRunSessionArtifactAsync).toHaveBeenCalledWith( @@ -23,7 +23,7 @@ it('uploads NDJSON with explicit simulator scope and destroys the stream afterwa kind: 'simulator-log', filename: 'simulator.ndjson', size: 11, - metadata: expect.objectContaining({ scope: 'simulator', udid: 'A' }), + metadata: expect.objectContaining({ scope: 'user-apps', udid: 'A' }), }) ); expect( diff --git a/packages/build-tools/src/steps/utils/__tests__/serveSimLogsHttp.test.ts b/packages/build-tools/src/steps/utils/__tests__/serveSimLogsHttp.test.ts index 806cf3fbd1..cf6a328106 100644 --- a/packages/build-tools/src/steps/utils/__tests__/serveSimLogsHttp.test.ts +++ b/packages/build-tools/src/steps/utils/__tests__/serveSimLogsHttp.test.ts @@ -57,7 +57,10 @@ it('streams selected-device logs into NDJSON and uploads the exact bytes as a se server.on('request', (request, response) => { if (request.method === 'GET') { streamRequest = { url: request.url, authorization: request.headers.authorization }; - response.writeHead(200, { 'Content-Type': 'text/event-stream' }); + response.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'X-Serve-Sim-Log-Scope': 'user-apps', + }); response.write(wire.subarray(0, splitAt)); setImmediate(() => response.end(wire.subarray(splitAt))); return; @@ -80,7 +83,7 @@ it('streams selected-device logs into NDJSON and uploads the exact bytes as a se logger, }); expect(streamRequest).toEqual({ - url: '/logs?envelope=true&device=device-B', + url: '/logs?envelope=true&scope=user-apps&device=device-B', authorization: 'Bearer local-test-token', }); expect(result).toEqual({ @@ -112,13 +115,13 @@ it('streams selected-device logs into NDJSON and uploads the exact bytes as a se expect(mutation.mock.calls[0][1]).toEqual({ deviceRunSessionId: 'session-test', input: { - name: 'Simulator logs (device-B)', + name: 'App logs (device-B)', filename: 'simulator.ndjson', kind: 'simulator-log', metadata: { __eas_type: 'simulator-log', udid: 'device-B', - scope: 'simulator', + scope: 'user-apps', source: 'serve-sim/logs', }, size: expected.length, @@ -137,7 +140,10 @@ it('resumes a buffered stream without appending already persisted sequence numbe `data: ${JSON.stringify({ seq, at: seq * 1000, raw })}\n\n`; server.on('request', (request, response) => { urls.push(request.url!); - response.writeHead(200, { 'Content-Type': 'text/event-stream' }); + response.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'X-Serve-Sim-Log-Scope': 'user-apps', + }); // Deliberately resend seq=1 on reconnect to exercise the client's guard too. response.end(frame(1, first) + (urls.length > 1 ? frame(2, second) : '')); }); @@ -158,8 +164,8 @@ it('resumes a buffered stream without appending already persisted sequence numbe expect(resumed.lastSequence).toBe(2); expect(resumed.bytesWritten).toBe(Buffer.byteLength(second + '\n')); expect(urls).toEqual([ - '/logs?envelope=true&device=device-A', - '/logs?envelope=true&since=1&device=device-A', + '/logs?envelope=true&scope=user-apps&device=device-A', + '/logs?envelope=true&scope=user-apps&since=1&device=device-A', ]); expect(await readFile(filePath, 'utf8')).toBe(first + '\n' + second + '\n'); }); @@ -169,7 +175,10 @@ it('aborts a real open HTTP stream without waiting for the server to finish', as let sawRequest: () => void = () => {}; const requestStarted = new Promise(resolve => (sawRequest = resolve)); server.on('request', (_request, response) => { - response.writeHead(200, { 'Content-Type': 'text/event-stream' }); + response.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'X-Serve-Sim-Log-Scope': 'user-apps', + }); response.write(': heartbeat\n\n'); sawRequest(); }); diff --git a/packages/build-tools/src/steps/utils/__tests__/serveSimLogsRecorder.test.ts b/packages/build-tools/src/steps/utils/__tests__/serveSimLogsRecorder.test.ts index 80ecc88709..1b958825e5 100644 --- a/packages/build-tools/src/steps/utils/__tests__/serveSimLogsRecorder.test.ts +++ b/packages/build-tools/src/steps/utils/__tests__/serveSimLogsRecorder.test.ts @@ -12,7 +12,14 @@ jest.mock('node-fetch'); jest.mock('../../../sentry'); jest.unmock('node:fs'); jest.unmock('node:fs/promises'); -const { Response } = jest.requireActual('node-fetch') as typeof import('node-fetch'); +const { Response: FetchResponse } = jest.requireActual('node-fetch') as typeof import('node-fetch'); +// A compatible serve-sim explicitly acknowledges the requested log scope. +class Response extends FetchResponse { + constructor(...args: ConstructorParameters) { + super(...args); + this.headers.set('x-serve-sim-log-scope', 'user-apps'); + } +} const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan; let directory: string; @@ -52,7 +59,7 @@ it('preserves split UTF-8 and CRLF records, skips heartbeat and invalid JSON, an '{"message":"안녕"}\n{"pid":42}\n' ); expect(fetch).toHaveBeenCalledWith( - 'http://localhost:1234/logs?envelope=true&device=device+two', + 'http://localhost:1234/logs?envelope=true&scope=user-apps&device=device+two', expect.objectContaining({ headers: { Authorization: 'Bearer secret' } }) ); }); @@ -70,6 +77,21 @@ it('retains completed records when a connection errors', async () => { expect(await readFile(path.join(directory, 'logs.ndjson'), 'utf8')).toBe('{"pid":42}\n'); }); +it.each([undefined, 'all'])('refuses an unconfirmed user-app scope (%s)', async scope => { + const body = Readable.from(['data: {"eventMessage":"system noise"}\n\n']); + const response = new FetchResponse(body); + if (scope) { + response.headers.set('x-serve-sim-log-scope', scope); + } + jest.mocked(fetch).mockResolvedValue(response); + expect(await record()).toEqual({ receivedData: false, bytesWritten: 0, limitReached: false }); + expect(body.destroyed).toBe(true); + await expect(readFile(path.join(directory, 'logs.ndjson'))).rejects.toMatchObject({ + code: 'ENOENT', + }); + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('did not confirm user-app')); +}); + it('aborts a quiet live stream and drains preceding writes before returning', async () => { const controller = new AbortController(); const stream = new Readable({ read() {} }); @@ -189,11 +211,11 @@ it('selects each registered device when two simulators share one server URL', as await ServeSimLogsRecorder.startAsync({ logger, stateDir, pollIntervalMs: 5, maxBytes: 3 }); await delay(50); expect(fetch).toHaveBeenCalledWith( - 'http://localhost:1234/logs?envelope=true&device=A', + 'http://localhost:1234/logs?envelope=true&scope=user-apps&device=A', expect.anything() ); expect(fetch).toHaveBeenCalledWith( - 'http://localhost:1234/logs?envelope=true&device=B', + 'http://localhost:1234/logs?envelope=true&scope=user-apps&device=B', expect.anything() ); expect(fetch).toHaveBeenCalledTimes(2); @@ -212,7 +234,7 @@ it('persists the envelope cursor only for written records and drops replayed seq }); expect(await readFile(path.join(directory, 'logs.ndjson'), 'utf8')).toBe('{"pid":2}\n'); expect(fetch).toHaveBeenCalledWith( - 'http://localhost:1234/logs?envelope=true&since=1', + 'http://localhost:1234/logs?envelope=true&scope=user-apps&since=1', expect.anything() ); }); diff --git a/packages/build-tools/src/steps/utils/serveSimLogsArtifacts.ts b/packages/build-tools/src/steps/utils/serveSimLogsArtifacts.ts index 9209d4adf6..f2bc830ee1 100644 --- a/packages/build-tools/src/steps/utils/serveSimLogsArtifacts.ts +++ b/packages/build-tools/src/steps/utils/serveSimLogsArtifacts.ts @@ -34,10 +34,10 @@ export async function uploadServeSimLogsFileAsync( await uploadDeviceRunSessionArtifactAsync(ctx, { deviceRunSessionId, artifactId: `simulator-log-${udid}`, - name: `Simulator logs (${udid.slice(0, 8)})`, + name: `App logs (${udid.slice(0, 8)})`, filename: 'simulator.ndjson', kind: 'simulator-log', - metadata: { __eas_type: 'simulator-log', udid, scope: 'simulator', source: 'serve-sim/logs' }, + metadata: { __eas_type: 'simulator-log', udid, scope: 'user-apps', source: 'serve-sim/logs' }, size, stream, signal: AbortSignal.any([fileController.signal, ...(signal ? [signal] : [])]), diff --git a/packages/build-tools/src/steps/utils/serveSimLogsRecorder.ts b/packages/build-tools/src/steps/utils/serveSimLogsRecorder.ts index 5f55e79e14..19ecd20779 100644 --- a/packages/build-tools/src/steps/utils/serveSimLogsRecorder.ts +++ b/packages/build-tools/src/steps/utils/serveSimLogsRecorder.ts @@ -7,8 +7,8 @@ import { setTimeout as delay } from 'node:timers/promises'; import { SERVE_SIM_STATE_DIR, readServeSimServersAsync } from './serveSimMetricsRecorder'; -// The endpoint includes all simulator processes. Newer serve-sim versions replay -// buffered records and support a sequence cursor; older versions stream raw NDJSON. +// Require user-app scope acknowledgement; replay cursors avoid duplicate records +// on reconnect. Older servers that ignore the filter are not safe to collect from. const MAX_BYTES_PER_DEVICE = 20 * 1024 * 1024; const MAX_DURATION_MS = 30 * 60 * 1000; const MAX_LINE_LENGTH = 1024 * 1024; @@ -128,9 +128,7 @@ export namespace ServeSimLogsRecorder { logger.warn({ err }, 'Could not collect serve-sim simulator logs.'); controller.abort(); }); - logger.info( - 'Started collecting simulator logs (all processes, up to 20 MiB per device and 30 minutes).' - ); + logger.info('Started collecting user-app logs (up to 20 MiB per device and 30 minutes).'); } export async function finishAsync({ logger }: { logger: bunyan }): Promise { @@ -194,6 +192,7 @@ export async function streamServeSimLogsToFileAsync({ } const url = new URL('/logs', serveSimUrl); url.searchParams.set('envelope', 'true'); + url.searchParams.set('scope', 'user-apps'); if (since !== undefined) { url.searchParams.set('since', String(since)); } @@ -214,6 +213,13 @@ export async function streamServeSimLogsToFileAsync({ logger.warn(`serve-sim /logs responded ${response.status}; simulator logs will be retried.`); return { receivedData: false, limitReached, bytesWritten }; } + // Older servers ignore unknown query parameters and would return system logs. + if (response.headers.get('x-serve-sim-log-scope') !== 'user-apps') { + logger.warn( + 'serve-sim did not confirm user-app log filtering. Skipping this stream; use a serve-sim version that supports /logs?scope=user-apps.' + ); + return { receivedData: false, limitReached, bytesWritten }; + } file = await open(filePath, 'a'); const decoder = new TextDecoder(); let buffer = ''; From 714dbc7f346d581dbbb2516df5a58e746cada60a Mon Sep 17 00:00:00 2001 From: SJ Kim Date: Fri, 25 Sep 2026 15:18:07 -0400 Subject: [PATCH 4/8] [build-tools] Name simulator log artifacts app-logs.ndjson (ENG-26642) --- .../src/steps/utils/__tests__/serveSimLogsArtifacts.test.ts | 2 +- .../src/steps/utils/__tests__/serveSimLogsHttp.test.ts | 2 +- packages/build-tools/src/steps/utils/serveSimLogsArtifacts.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/build-tools/src/steps/utils/__tests__/serveSimLogsArtifacts.test.ts b/packages/build-tools/src/steps/utils/__tests__/serveSimLogsArtifacts.test.ts index a3d2a1c549..ab8d905938 100644 --- a/packages/build-tools/src/steps/utils/__tests__/serveSimLogsArtifacts.test.ts +++ b/packages/build-tools/src/steps/utils/__tests__/serveSimLogsArtifacts.test.ts @@ -21,7 +21,7 @@ it('uploads NDJSON with explicit user-app scope and destroys the stream afterwar ctx, expect.objectContaining({ kind: 'simulator-log', - filename: 'simulator.ndjson', + filename: 'app-logs.ndjson', size: 11, metadata: expect.objectContaining({ scope: 'user-apps', udid: 'A' }), }) diff --git a/packages/build-tools/src/steps/utils/__tests__/serveSimLogsHttp.test.ts b/packages/build-tools/src/steps/utils/__tests__/serveSimLogsHttp.test.ts index cf6a328106..3d692ea64a 100644 --- a/packages/build-tools/src/steps/utils/__tests__/serveSimLogsHttp.test.ts +++ b/packages/build-tools/src/steps/utils/__tests__/serveSimLogsHttp.test.ts @@ -116,7 +116,7 @@ it('streams selected-device logs into NDJSON and uploads the exact bytes as a se deviceRunSessionId: 'session-test', input: { name: 'App logs (device-B)', - filename: 'simulator.ndjson', + filename: 'app-logs.ndjson', kind: 'simulator-log', metadata: { __eas_type: 'simulator-log', diff --git a/packages/build-tools/src/steps/utils/serveSimLogsArtifacts.ts b/packages/build-tools/src/steps/utils/serveSimLogsArtifacts.ts index f2bc830ee1..44a074d94a 100644 --- a/packages/build-tools/src/steps/utils/serveSimLogsArtifacts.ts +++ b/packages/build-tools/src/steps/utils/serveSimLogsArtifacts.ts @@ -35,7 +35,7 @@ export async function uploadServeSimLogsFileAsync( deviceRunSessionId, artifactId: `simulator-log-${udid}`, name: `App logs (${udid.slice(0, 8)})`, - filename: 'simulator.ndjson', + filename: 'app-logs.ndjson', kind: 'simulator-log', metadata: { __eas_type: 'simulator-log', udid, scope: 'user-apps', source: 'serve-sim/logs' }, size, From 9aaa48128512223a3e17600deefd8ac7aff7454d Mon Sep 17 00:00:00 2001 From: SJ Kim Date: Fri, 25 Sep 2026 15:18:25 -0400 Subject: [PATCH 5/8] [build-tools] Time out simulator log uploads per device (ENG-26642) --- .../src/steps/functions/__tests__/serveSimLogs.test.ts | 4 ++-- .../build-tools/src/steps/functions/collectServeSimLogs.ts | 6 ++++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/build-tools/src/steps/functions/__tests__/serveSimLogs.test.ts b/packages/build-tools/src/steps/functions/__tests__/serveSimLogs.test.ts index ce11913c69..578987ab3e 100644 --- a/packages/build-tools/src/steps/functions/__tests__/serveSimLogs.test.ts +++ b/packages/build-tools/src/steps/functions/__tests__/serveSimLogs.test.ts @@ -28,7 +28,7 @@ it('starts collection and tolerates startup failures', async () => { expect(logger.warn).toHaveBeenCalled(); }); -it('uploads each device using one bounded finalization signal', async () => { +it('uploads each device with its own bounded signal', async () => { jest.mocked(ServeSimLogsRecorder.finishAsync).mockResolvedValue([ { udid: 'A', filePath: '/tmp/A.ndjson' }, { udid: 'B', filePath: '/tmp/B.ndjson' }, @@ -44,7 +44,7 @@ it('uploads each device using one bounded finalization signal', async () => { signal: expect.any(AbortSignal), }) ); - expect(jest.mocked(uploadServeSimLogsFileAsync).mock.calls[0][1].signal).toBe( + expect(jest.mocked(uploadServeSimLogsFileAsync).mock.calls[0][1].signal).not.toBe( jest.mocked(uploadServeSimLogsFileAsync).mock.calls[1][1].signal ); }); diff --git a/packages/build-tools/src/steps/functions/collectServeSimLogs.ts b/packages/build-tools/src/steps/functions/collectServeSimLogs.ts index cb081e4614..afb0b9027f 100644 --- a/packages/build-tools/src/steps/functions/collectServeSimLogs.ts +++ b/packages/build-tools/src/steps/functions/collectServeSimLogs.ts @@ -5,6 +5,8 @@ import { getDeviceRunSessionIdOrThrow } from '../utils/remoteDeviceRunSession'; import { uploadServeSimLogsFileAsync } from '../utils/serveSimLogsArtifacts'; import { ServeSimLogsRecorder } from '../utils/serveSimLogsRecorder'; +const UPLOAD_TIMEOUT_MS = 30_000; + export function createCollectServeSimLogsBuildFunction(ctx: CustomBuildContext): BuildFunction { return new BuildFunction({ namespace: 'eas', @@ -20,14 +22,14 @@ export function createCollectServeSimLogsBuildFunction(ctx: CustomBuildContext): return; } const deviceRunSessionId = getDeviceRunSessionIdOrThrow(env); - const signal = AbortSignal.timeout(30_000); for (const { udid, filePath } of collected) { + // Each device gets its own budget so one stuck upload does not cancel the rest. await uploadServeSimLogsFileAsync(ctx, { deviceRunSessionId, udid, filePath, logger, - signal, + signal: AbortSignal.timeout(UPLOAD_TIMEOUT_MS), }); } } catch (err) { From 65092806c2f29b486a2cabc1956e843a58543c11 Mon Sep 17 00:00:00 2001 From: SJ Kim Date: Fri, 25 Sep 2026 15:19:33 -0400 Subject: [PATCH 6/8] [build-tools] Report simulator log failures to Sentry (ENG-26642) --- .../src/steps/functions/__tests__/serveSimLogs.test.ts | 10 ++++++++++ .../src/steps/functions/collectServeSimLogs.ts | 8 +++++++- .../src/steps/functions/startServeSimLogs.ts | 5 ++++- .../src/steps/utils/serveSimLogsRecorder.ts | 1 + 4 files changed, 22 insertions(+), 2 deletions(-) diff --git a/packages/build-tools/src/steps/functions/__tests__/serveSimLogs.test.ts b/packages/build-tools/src/steps/functions/__tests__/serveSimLogs.test.ts index 578987ab3e..8eaaedcae9 100644 --- a/packages/build-tools/src/steps/functions/__tests__/serveSimLogs.test.ts +++ b/packages/build-tools/src/steps/functions/__tests__/serveSimLogs.test.ts @@ -2,12 +2,14 @@ import { type bunyan } from '@expo/logger'; import { type BuildStepContext, type BuildStepEnv } from '@expo/steps'; import { type CustomBuildContext } from '../../../customBuildContext'; +import { Sentry } from '../../../sentry'; import { getDeviceRunSessionIdOrThrow } from '../../utils/remoteDeviceRunSession'; import { uploadServeSimLogsFileAsync } from '../../utils/serveSimLogsArtifacts'; import { ServeSimLogsRecorder } from '../../utils/serveSimLogsRecorder'; import { createCollectServeSimLogsBuildFunction } from '../collectServeSimLogs'; import { createStartServeSimLogsBuildFunction } from '../startServeSimLogs'; +jest.mock('../../../sentry'); jest.mock('../../utils/serveSimLogsRecorder'); jest.mock('../../utils/serveSimLogsArtifacts'); jest.mock('../../utils/remoteDeviceRunSession'); @@ -26,6 +28,10 @@ it('starts collection and tolerates startup failures', async () => { jest.mocked(ServeSimLogsRecorder.startAsync).mockRejectedValueOnce(new Error('disk failure')); await expect(createStartServeSimLogsBuildFunction().fn?.(step, args)).resolves.toBeUndefined(); expect(logger.warn).toHaveBeenCalled(); + expect(Sentry.capture).toHaveBeenCalledWith( + 'Could not start serve-sim simulator logs', + expect.any(Error) + ); }); it('uploads each device with its own bounded signal', async () => { @@ -57,4 +63,8 @@ it('skips empty collection and warns on finalization failure', async () => { createCollectServeSimLogsBuildFunction(ctx).fn?.(step, args) ).resolves.toBeUndefined(); expect(logger.warn).toHaveBeenCalled(); + expect(Sentry.capture).toHaveBeenCalledWith( + 'Could not finalize serve-sim simulator logs', + expect.any(Error) + ); }); diff --git a/packages/build-tools/src/steps/functions/collectServeSimLogs.ts b/packages/build-tools/src/steps/functions/collectServeSimLogs.ts index afb0b9027f..a2c3b79221 100644 --- a/packages/build-tools/src/steps/functions/collectServeSimLogs.ts +++ b/packages/build-tools/src/steps/functions/collectServeSimLogs.ts @@ -1,6 +1,7 @@ import { BuildFunction, BuildRuntimePlatform } from '@expo/steps'; import { type CustomBuildContext } from '../../customBuildContext'; +import { Sentry } from '../../sentry'; import { getDeviceRunSessionIdOrThrow } from '../utils/remoteDeviceRunSession'; import { uploadServeSimLogsFileAsync } from '../utils/serveSimLogsArtifacts'; import { ServeSimLogsRecorder } from '../utils/serveSimLogsRecorder'; @@ -33,7 +34,12 @@ export function createCollectServeSimLogsBuildFunction(ctx: CustomBuildContext): }); } } catch (err) { - logger.warn({ err }, 'Could not finalize simulator logs; the session result is unchanged.'); + const error = err instanceof Error ? err : new Error(String(err)); + Sentry.capture('Could not finalize serve-sim simulator logs', error); + logger.warn( + { err: error }, + 'Could not finalize simulator logs; the session result is unchanged.' + ); } }, }); diff --git a/packages/build-tools/src/steps/functions/startServeSimLogs.ts b/packages/build-tools/src/steps/functions/startServeSimLogs.ts index 64b19d281f..b229d3fda6 100644 --- a/packages/build-tools/src/steps/functions/startServeSimLogs.ts +++ b/packages/build-tools/src/steps/functions/startServeSimLogs.ts @@ -1,5 +1,6 @@ import { BuildFunction, BuildRuntimePlatform } from '@expo/steps'; +import { Sentry } from '../../sentry'; import { ServeSimLogsRecorder } from '../utils/serveSimLogsRecorder'; export function createStartServeSimLogsBuildFunction(): BuildFunction { @@ -13,8 +14,10 @@ export function createStartServeSimLogsBuildFunction(): BuildFunction { try { await ServeSimLogsRecorder.startAsync({ logger }); } catch (err) { + const error = err instanceof Error ? err : new Error(String(err)); + Sentry.capture('Could not start serve-sim simulator logs', error); logger.warn( - { err }, + { err: error }, 'Could not start simulator log collection; the session will continue.' ); } diff --git a/packages/build-tools/src/steps/utils/serveSimLogsRecorder.ts b/packages/build-tools/src/steps/utils/serveSimLogsRecorder.ts index 19ecd20779..6bb06026ef 100644 --- a/packages/build-tools/src/steps/utils/serveSimLogsRecorder.ts +++ b/packages/build-tools/src/steps/utils/serveSimLogsRecorder.ts @@ -5,6 +5,7 @@ import os from 'node:os'; import path from 'node:path'; import { setTimeout as delay } from 'node:timers/promises'; +import { Sentry } from '../../sentry'; import { SERVE_SIM_STATE_DIR, readServeSimServersAsync } from './serveSimMetricsRecorder'; // Require user-app scope acknowledgement; replay cursors avoid duplicate records From 9ec6362793d02d0de4179db86f13df8666c622c5 Mon Sep 17 00:00:00 2001 From: SJ Kim Date: Fri, 25 Sep 2026 15:19:43 -0400 Subject: [PATCH 7/8] [build-tools] Use a type import for the log stream body (ENG-26642) --- packages/build-tools/src/steps/utils/serveSimLogsRecorder.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/build-tools/src/steps/utils/serveSimLogsRecorder.ts b/packages/build-tools/src/steps/utils/serveSimLogsRecorder.ts index 6bb06026ef..dc50b5a2f1 100644 --- a/packages/build-tools/src/steps/utils/serveSimLogsRecorder.ts +++ b/packages/build-tools/src/steps/utils/serveSimLogsRecorder.ts @@ -3,6 +3,7 @@ import fetch from 'node-fetch'; import { mkdtemp, open } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; +import { type Readable } from 'node:stream'; import { setTimeout as delay } from 'node:timers/promises'; import { Sentry } from '../../sentry'; @@ -185,7 +186,7 @@ export async function streamServeSimLogsToFileAsync({ let file: Awaited> | undefined; let body: NodeJS.ReadableStream | undefined; const closeBody = (): void => { - (body as import('node:stream').Readable | undefined)?.destroy?.(); + (body as Readable | undefined)?.destroy?.(); }; try { if (signal.aborted || limitReached) { From fdfb50bd063280f71f46663015ab077dd2ac6385 Mon Sep 17 00:00:00 2001 From: SJ Kim Date: Fri, 25 Sep 2026 15:21:38 -0400 Subject: [PATCH 8/8] [build-tools] Validate serve-sim log envelopes and report malformed records (ENG-26642) --- .../utils/__tests__/serveSimLogsHttp.test.ts | 8 ++++- .../__tests__/serveSimLogsRecorder.test.ts | 6 +++- .../src/steps/utils/serveSimLogsRecorder.ts | 35 ++++++++++++------- 3 files changed, 34 insertions(+), 15 deletions(-) diff --git a/packages/build-tools/src/steps/utils/__tests__/serveSimLogsHttp.test.ts b/packages/build-tools/src/steps/utils/__tests__/serveSimLogsHttp.test.ts index 3d692ea64a..bc6d91f487 100644 --- a/packages/build-tools/src/steps/utils/__tests__/serveSimLogsHttp.test.ts +++ b/packages/build-tools/src/steps/utils/__tests__/serveSimLogsHttp.test.ts @@ -11,6 +11,7 @@ import { streamServeSimLogsToFileAsync } from '../serveSimLogsRecorder'; // This test exercises actual sockets, UTF-8 decoding, files, and PUT uploads. // Only WWW's allocation response is substituted; it does not verify cloud storage. +jest.mock('../../../sentry'); jest.unmock('fs'); jest.unmock('node:fs'); jest.unmock('fs/promises'); @@ -129,7 +130,12 @@ it('streams selected-device logs into NDJSON and uploads the exact bytes as a se }); expect(uploaded).toEqual(expected); expect(uploadedLength).toBe(String(expected.length)); - expect(logger.warn).not.toHaveBeenCalled(); + // Only the invalid JSON record is reported; the partial trailing record is not a record yet. + expect(logger.warn).toHaveBeenCalledTimes(1); + expect(logger.warn).toHaveBeenCalledWith( + { malformedRecords: 1 }, + expect.stringContaining('not valid JSON') + ); }); it('resumes a buffered stream without appending already persisted sequence numbers', async () => { diff --git a/packages/build-tools/src/steps/utils/__tests__/serveSimLogsRecorder.test.ts b/packages/build-tools/src/steps/utils/__tests__/serveSimLogsRecorder.test.ts index 1b958825e5..ad2a54e6db 100644 --- a/packages/build-tools/src/steps/utils/__tests__/serveSimLogsRecorder.test.ts +++ b/packages/build-tools/src/steps/utils/__tests__/serveSimLogsRecorder.test.ts @@ -6,6 +6,7 @@ import path from 'node:path'; import { Readable } from 'node:stream'; import { setTimeout as delay } from 'node:timers/promises'; +import { Sentry } from '../../../sentry'; import { ServeSimLogsRecorder, streamServeSimLogsToFileAsync } from '../serveSimLogsRecorder'; jest.mock('node-fetch'); @@ -46,7 +47,7 @@ function record(options: Partial { +it('preserves split UTF-8 and CRLF records, skips heartbeat and reports invalid JSON, and selects a device', async () => { const input = Buffer.from( ':\r\n\r\ndata: {"message":"안녕"}\r\n\r\ndata: bad\n\ndata: {"pid":42}\n\n' ); @@ -58,6 +59,9 @@ it('preserves split UTF-8 and CRLF records, skips heartbeat and invalid JSON, an expect(await readFile(path.join(directory, 'logs.ndjson'), 'utf8')).toBe( '{"message":"안녕"}\n{"pid":42}\n' ); + expect(Sentry.capture).toHaveBeenCalledWith('serve-sim sent malformed simulator log records', { + extras: { malformedRecords: 1, serveSimDevice: 'device two' }, + }); expect(fetch).toHaveBeenCalledWith( 'http://localhost:1234/logs?envelope=true&scope=user-apps&device=device+two', expect.objectContaining({ headers: { Authorization: 'Bearer secret' } }) diff --git a/packages/build-tools/src/steps/utils/serveSimLogsRecorder.ts b/packages/build-tools/src/steps/utils/serveSimLogsRecorder.ts index dc50b5a2f1..d0c26ca2ca 100644 --- a/packages/build-tools/src/steps/utils/serveSimLogsRecorder.ts +++ b/packages/build-tools/src/steps/utils/serveSimLogsRecorder.ts @@ -5,6 +5,7 @@ import os from 'node:os'; import path from 'node:path'; import { type Readable } from 'node:stream'; import { setTimeout as delay } from 'node:timers/promises'; +import { z } from 'zod'; import { Sentry } from '../../sentry'; import { SERVE_SIM_STATE_DIR, readServeSimServersAsync } from './serveSimMetricsRecorder'; @@ -16,6 +17,11 @@ const MAX_DURATION_MS = 30 * 60 * 1000; const MAX_LINE_LENGTH = 1024 * 1024; const MAX_CONSECUTIVE_FAILURES = 10; +const ServeSimLogEnvelopeSchema = z.object({ + seq: z.number().int().nonnegative(), + raw: z.string(), +}); + type CollectedLog = { udid: string; filePath: string }; type Session = { controller: AbortController; @@ -183,6 +189,7 @@ export async function streamServeSimLogsToFileAsync({ let bytesWritten = 0; let limitReached = maxBytes <= 0; let lastSequence = since; + let malformedRecords = 0; let file: Awaited> | undefined; let body: NodeJS.ReadableStream | undefined; const closeBody = (): void => { @@ -241,25 +248,18 @@ export async function streamServeSimLogsToFileAsync({ let payload = line.slice(5).trim(); let sequence: number | undefined; try { - const parsed: unknown = JSON.parse(payload); - if ( - parsed && - typeof parsed === 'object' && - 'seq' in parsed && - 'raw' in parsed && - typeof parsed.seq === 'number' && - Number.isSafeInteger(parsed.seq) && - parsed.seq >= 0 && - typeof parsed.raw === 'string' - ) { - sequence = parsed.seq; + const envelope = ServeSimLogEnvelopeSchema.safeParse(JSON.parse(payload)); + if (envelope.success) { + sequence = envelope.data.seq; if (lastSequence !== undefined && sequence <= lastSequence) { continue; } // Keep the downloaded artifact homogeneous across old and new servers. - payload = JSON.stringify(JSON.parse(parsed.raw)); + payload = JSON.stringify(JSON.parse(envelope.data.raw)); } } catch { + // Skip the record so the rest of the stream is kept; the count is reported below. + malformedRecords += 1; continue; } const record = payload + '\n'; @@ -292,6 +292,15 @@ export async function streamServeSimLogsToFileAsync({ ); } } finally { + if (malformedRecords > 0) { + Sentry.capture('serve-sim sent malformed simulator log records', { + extras: { malformedRecords, serveSimDevice }, + }); + logger.warn( + { malformedRecords }, + 'serve-sim sent simulator log records that are not valid JSON; they were skipped.' + ); + } signal.removeEventListener('abort', closeBody); closeBody(); await file?.close().catch(err => {