From 255be36a75a7532932ac5bd2149cd18540e3fa64 Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Fri, 9 Oct 2026 01:27:50 -0700 Subject: [PATCH] [build-tools] Separate Argent and preview logs Co-Authored-By: GPT-6.1 Sol --- .changeset/serve-sim-pr-4587.md | 5 + ...tArgentRemoteSession-orchestration.test.ts | 165 +++++++++++++++++- .../functions/startArgentRemoteSession.ts | 99 ++++++----- 3 files changed, 222 insertions(+), 47 deletions(-) create mode 100644 .changeset/serve-sim-pr-4587.md diff --git a/.changeset/serve-sim-pr-4587.md b/.changeset/serve-sim-pr-4587.md new file mode 100644 index 0000000000..de648215a0 --- /dev/null +++ b/.changeset/serve-sim-pr-4587.md @@ -0,0 +1,5 @@ +--- +'@expo/build-tools': minor +--- + +Give Argent and its simulator preview separate session logs. diff --git a/packages/build-tools/src/steps/functions/__tests__/startArgentRemoteSession-orchestration.test.ts b/packages/build-tools/src/steps/functions/__tests__/startArgentRemoteSession-orchestration.test.ts index 08b23cfb32..25e0532708 100644 --- a/packages/build-tools/src/steps/functions/__tests__/startArgentRemoteSession-orchestration.test.ts +++ b/packages/build-tools/src/steps/functions/__tests__/startArgentRemoteSession-orchestration.test.ts @@ -6,10 +6,12 @@ import path from 'node:path'; import { createGlobalContextMock } from '../../../__tests__/utils/context'; import { type CustomBuildContext } from '../../../customBuildContext'; +import { Sentry } from '../../../sentry'; import { startDeviceSessionHostAsync } from '../../utils/deviceSessionHost'; import { isProcessDescendantOfAsync } from '../../../utils/processes'; import { pollArgentArtifactsForUploadAsync } from '../../utils/argentArtifacts'; import { startArgentEventCollectionAsync } from '../../utils/argentEvents'; +import { createProcessOutput } from '../../utils/processOutput'; import { ensureFfmpegInstalledOnceAsync, getDeviceRunSessionIdOrThrow, @@ -119,18 +121,21 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => { await fs.promises.rm(TEST_HOME, { recursive: true, force: true }); }); - it('reports an early exit with output before opening tunnels or publishing readiness', async () => { + it('reports an early exit with output and stops Argent before opening tunnels', async () => { + const stopError = new Error('drain timed out'); + const stopServer = jest.fn().mockRejectedValue(stopError); jest.mocked(spawnDetached).mockReturnValue({ pid: 4242, getOutput: () => 'could not bind server port', getExitError: () => new Error('process exited with code 1'), - stopAsync: jest.fn(), + stopAsync: stopServer, }); + const logger = { info: jest.fn(), warn: jest.fn() }; const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext); await expect( buildFunction.fn!( { - logger: { info: jest.fn(), warn: jest.fn() }, + logger, global: { runtimePlatform: BuildRuntimePlatform.LINUX }, } as unknown as BuildStepContext, { @@ -145,11 +150,66 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => { ).rejects.toThrow( 'Argent exited before becoming ready: process exited with code 1\nArgent tool-server output:\ncould not bind server port' ); + expect(stopServer).toHaveBeenCalledTimes(1); + expect(logger.warn).toHaveBeenCalledWith( + { err: stopError }, + 'Could not stop the Argent tool-server during remote session teardown.' + ); + expect(pollArgentArtifactsForUploadAsync).not.toHaveBeenCalled(); expect(startNgrokTunnelAsync).not.toHaveBeenCalled(); expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled(); expect(startArgentEventCollectionAsync).not.toHaveBeenCalled(); }); + it('drains artifact polling and stops the tool-server when event collection cannot start', async () => { + const collectionError = new Error('event collection failed'); + const stopError = new Error('drain timed out'); + let pollingFinished = false; + jest + .mocked(pollArgentArtifactsForUploadAsync) + .mockImplementationOnce(async (_ctx, { signal }) => { + await new Promise(resolve => signal.addEventListener('abort', resolve, { once: true })); + pollingFinished = true; + }); + jest.mocked(startArgentEventCollectionAsync).mockRejectedValueOnce(collectionError); + const stopServer = jest.fn(async () => { + expect(pollingFinished).toBe(true); + throw stopError; + }); + jest.mocked(spawnDetached).mockReturnValueOnce({ + pid: 4242, + getOutput: () => '', + getExitError: () => undefined, + stopAsync: stopServer, + }); + const logger = { info: jest.fn(), warn: jest.fn() }; + const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext); + await expect( + buildFunction.fn!( + { + logger, + global: { runtimePlatform: BuildRuntimePlatform.LINUX }, + } as unknown as BuildStepContext, + { + inputs: { + package_version: { value: undefined }, + max_idle_time_minutes: { value: undefined }, + }, + outputs: {}, + env: {}, + } as never + ) + ).rejects.toBe(collectionError); + expect(pollingFinished).toBe(true); + expect(stopServer).toHaveBeenCalledTimes(1); + expect(logger.warn).toHaveBeenCalledWith( + { err: stopError }, + 'Could not stop the Argent tool-server during remote session teardown.' + ); + expect(startNgrokTunnelAsync).not.toHaveBeenCalled(); + expect(startDeviceSessionHostAsync).not.toHaveBeenCalled(); + }); + it.each(['preview', 'config', 'wait'])( 'finishes the host and tools after %s fails', async phase => { @@ -188,6 +248,59 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => { } ); + it.each([ + ['succeeded', false], + ['failed', true], + ])( + 'fails after stopping the host and tools when the tools tunnel cannot close and the session %s', + async (_description, sessionFails) => { + const closeError = new Error('ngrok close failed'); + const sessionError = new Error('session failed'); + mockTunnelStopAsync.mockRejectedValueOnce(closeError); + if (sessionFails) { + jest.mocked(waitForDeviceRunSessionStoppedAsync).mockRejectedValueOnce(sessionError); + } + const logger = { info: jest.fn(), warn: jest.fn() }; + const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext); + // The tunnel may still be serving, so its failure fails a session that otherwise succeeded. + await expect( + buildFunction.fn!( + { + logger, + global: { runtimePlatform: BuildRuntimePlatform.LINUX }, + } as unknown as BuildStepContext, + { + inputs: { + package_version: { value: undefined }, + max_idle_time_minutes: { value: undefined }, + }, + outputs: {}, + env: {}, + } as never + ) + ).rejects.toBe(sessionFails ? sessionError : closeError); + expect(mockTunnelStopAsync).toHaveBeenCalledTimes(1); + expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1); + expect(mockStopAsync).toHaveBeenCalledTimes(1); + expect(jest.mocked(spawnDetached).mock.results[0].value.stopAsync).toHaveBeenCalledTimes(1); + expect(logger.warn).toHaveBeenCalledWith( + { err: closeError }, + 'Could not stop the Argent tunnel during remote session teardown.' + ); + expect(jest.mocked(Sentry.capture).mock.calls).toEqual( + sessionFails + ? [ + [ + 'Could not stop the Argent tunnel after the remote session failed', + closeError, + { level: 'warning' }, + ], + ] + : [] + ); + } + ); + it('enables the event log flag, disables the video watermark, shares one path, and starts/stops the collector', async () => { const ctx = {} as unknown as CustomBuildContext; const buildFunction = createStartArgentRemoteSessionBuildFunction(ctx); @@ -318,6 +431,52 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => { expect(waitForDeviceRunSessionStoppedAsync).toHaveBeenCalledTimes(1); }); + it('redacts startup credentials and registers the state token for later output', async () => { + const token = 'tool/server "secret"'; + await fs.promises.writeFile( + path.join(ARGENT_STATE_DIR, 'tool-server-orchestration.json'), + JSON.stringify({ port: 5678, pid: 9999, token }) + ); + const logger = { info: jest.fn(), warn: jest.fn() }; + jest.mocked(spawnDetached).mockImplementationOnce(options => { + const output = createProcessOutput(options.logger, options.secrets); + output.stdout.append(`argent link argent://${encodeURIComponent(token)}@127.0.0.1:5678\n`); + return { + pid: 4242, + getOutput: output.getOutput, + getExitError: () => undefined, + stopAsync: async () => { + output.stderr.append(`opaque ${token}\nencoded ${encodeURIComponent(token)}\n`); + output.stderr.append(`escaped ${JSON.stringify(token).slice(1, -1)}\n`); + output.finish(); + }, + }; + }); + const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext); + await buildFunction.fn!( + { + logger, + global: { runtimePlatform: BuildRuntimePlatform.LINUX }, + } as unknown as BuildStepContext, + { + inputs: { + package_version: { value: undefined }, + max_idle_time_minutes: { value: undefined }, + }, + outputs: {}, + env: {}, + } as never + ); + expect(logger.info).toHaveBeenCalledWith( + { source: 'stdout' }, + 'argent link argent://[REDACTED]@127.0.0.1:5678' + ); + for (const label of ['opaque', 'encoded', 'escaped']) { + expect(logger.info).toHaveBeenCalledWith({ source: 'stderr' }, `${label} [REDACTED]`); + } + expect(JSON.stringify(logger.info.mock.calls)).not.toContain('secret'); + }); + it('hands the launch inputs to serve-sim and announces them on an iOS session', async () => { const ctx = {} as unknown as CustomBuildContext; const logger = { info: jest.fn(), warn: jest.fn() }; diff --git a/packages/build-tools/src/steps/functions/startArgentRemoteSession.ts b/packages/build-tools/src/steps/functions/startArgentRemoteSession.ts index bd5f786652..1331ee5585 100644 --- a/packages/build-tools/src/steps/functions/startArgentRemoteSession.ts +++ b/packages/build-tools/src/steps/functions/startArgentRemoteSession.ts @@ -197,10 +197,13 @@ export function createStartArgentRemoteSessionBuildFunction( ); // Keep Argent itself in foreground mode under the detached process. This preserves // the npx/bun -> Argent CLI -> tool-server ancestry used to identify the matching state file. + const secrets: string[] = []; const argentServer = spawnDetached({ command: startServer.command, args: startServer.args, env: { ...env, ARGENT_EVENT_LOG: ARGENT_EVENT_LOG_PATH, ARGENT_EMULATOR_NO_WINDOW: '1' }, + logger, + secrets, }); if (argentServer.pid === undefined) { throw new SystemError( @@ -208,50 +211,55 @@ export function createStartArgentRemoteSessionBuildFunction( ); } - logger.info(`Waiting for argent tool-server state in ${ARGENT_STATE_DIR}.`); - let toolServerPort: number; - let toolServerToken: string | undefined; - try { - const toolServerState = await waitForArgentToolServerStateAsync({ - stateDir: ARGENT_STATE_DIR, - ancestorPid: argentServer.pid, - timeoutMs: STARTUP_TIMEOUT_MS, - getExitError: argentServer.getExitError, - }); - toolServerPort = toolServerState.port; - toolServerToken = toolServerState.token; - } catch (err) { - const output = argentServer.getOutput(); - throw new SystemError( - `${ - err instanceof Error ? err.message : `Timed out waiting for argent tool-server state.` - }${output ? `\nArgent tool-server output:\n${output}` : ''}` - ); - } - logger.info(`Argent tool-server is listening on port ${toolServerPort}.`); const artifactPollAbortController = new AbortController(); - const artifactPollSignal = signal - ? AbortSignal.any([signal, artifactPollAbortController.signal]) - : artifactPollAbortController.signal; - const artifactPollingPromise = pollArgentArtifactsForUploadAsync(ctx, { - deviceRunSessionId, - toolsUrl: `http://127.0.0.1:${toolServerPort}`, - toolsAuthToken: toolServerToken, - logger, - signal: artifactPollSignal, - }); - - const eventCollection = await startArgentEventCollectionAsync({ - ctx, - deviceRunSessionId, - eventLogPath: ARGENT_EVENT_LOG_PATH, - logger, - }); - + let artifactPollingPromise: Promise | undefined; + let eventCollection: Awaited> | undefined; let toolsTunnel: Awaited> | undefined; let sessionHost: DeviceSessionHost | undefined; let sessionFailed = false; try { + logger.info(`Waiting for argent tool-server state in ${ARGENT_STATE_DIR}.`); + let toolServerPort: number; + let toolServerToken: string | undefined; + try { + const toolServerState = await waitForArgentToolServerStateAsync({ + stateDir: ARGENT_STATE_DIR, + ancestorPid: argentServer.pid, + timeoutMs: STARTUP_TIMEOUT_MS, + getExitError: argentServer.getExitError, + }); + toolServerPort = toolServerState.port; + toolServerToken = toolServerState.token; + if (toolServerToken) { + secrets.push(toolServerToken); + } + } catch (err) { + const output = argentServer.getOutput(); + throw new SystemError( + `${ + err instanceof Error ? err.message : `Timed out waiting for argent tool-server state.` + }${output ? `\nArgent tool-server output:\n${output}` : ''}` + ); + } + logger.info(`Argent tool-server is listening on port ${toolServerPort}.`); + const artifactPollSignal = signal + ? AbortSignal.any([signal, artifactPollAbortController.signal]) + : artifactPollAbortController.signal; + artifactPollingPromise = pollArgentArtifactsForUploadAsync(ctx, { + deviceRunSessionId, + toolsUrl: `http://127.0.0.1:${toolServerPort}`, + toolsAuthToken: toolServerToken, + logger, + signal: artifactPollSignal, + }); + + eventCollection = await startArgentEventCollectionAsync({ + ctx, + deviceRunSessionId, + eventLogPath: ARGENT_EVENT_LOG_PATH, + logger, + }); + toolsTunnel = await startNgrokTunnelAsync({ port: toolServerPort, subdomainPrefix: 'argent', @@ -268,6 +276,7 @@ export function createStartArgentRemoteSessionBuildFunction( logger.info(launchDescription); } sessionHost = await startDeviceSessionHostAsync(ctx, { + separateLogPhase: true, runtimePlatform, env, logger, @@ -325,11 +334,13 @@ export function createStartArgentRemoteSessionBuildFunction( 'Argent tool-server', (async () => { try { - await stopArgentEventCollectionSafelyAsync({ - eventCollection, - deviceRunSessionId, - logger, - }); + if (eventCollection) { + await stopArgentEventCollectionSafelyAsync({ + eventCollection, + deviceRunSessionId, + logger, + }); + } artifactPollAbortController.abort(); try { await artifactPollingPromise;