Compare commits

..
26 changed files with 1055 additions and 1242 deletions

No files matched your search

+1 -1
View File
@@ -5,4 +5,4 @@
Record and upload Android emulator process output as a simulator session artifact by default. Add explicit source selection and a simulator CLI flag to opt in to recording and uploading device-wide logcat. Unselected sources are not recorded by simulator sessions.
Simulator sessions retain the last 20 MiB of each selected Android log source across launches and retries. Each log file is a fixed-size ring on disk, so the end of the final attempt is always kept; older output is dropped first, and artifacts remain uncompressed.
Simulator sessions retain the first 20 MiB of each selected Android log source across launches and retries. The limit is enforced while recording; excess output is drained without saving, and artifacts remain uncompressed.
+26 -195
View File
@@ -1,208 +1,39 @@
name: build-and-deploy-worker
# Assumes main branch always specifies an environment (staging/production).
# If environment is empty: PR testing build (no upload, no deploy)
# If environment is set: Build (unless cached on main), upload, and deploy
name: Build Android worker for manual testing
on:
workflow_dispatch:
inputs:
environment:
description: '"staging", "production" or empty'
type: string
description: Build a Linux artifact for manual staging testing; does not deploy
type: choice
options:
- staging
default: staging
required: true
workflow_call:
inputs:
environment:
description: '"staging", "production" or empty'
type: string
required: true
permissions:
contents: read
jobs:
build-and-deploy:
build-android-staging-test:
if: inputs.environment == 'staging'
runs-on: ubuntu-latest
permissions:
id-token: write
timeout-minutes: 25
steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
- uses: ./.github/actions/setup-mise
- name: Install eas-cli
run: npm install --global eas-cli@latest
- uses: ./.github/internal-actions/setup-gcloud
- name: Check if worker already exists
id: check_worker_exists
# Check if tarballs for this SHA already exist
# If they do, we can skip building regardless of branch
if: inputs.environment != ''
run: |
for platform in android ios; do
GCP_TARBALL="worker-$platform-sha1-$GITHUB_SHA.tar.gz"
if ! gsutil stat "gs://eas-build-worker-tarballs/$GCP_TARBALL"; then
echo "worker_exists=false" >> "$GITHUB_OUTPUT"
exit 0
fi
done
echo "worker_exists=true" >> "$GITHUB_OUTPUT"
- name: Start EAS Workflow to build worker tarballs
id: eas_workflow
# Build if: PR testing (no env), or deploying but tarballs don't exist
if: inputs.environment == '' || steps.check_worker_exists.outputs.worker_exists == 'false'
working-directory: packages/worker
- run: yarn install --immutable
- name: Package Linux worker from this checkout
env:
EXPO_TOKEN: ${{ secrets.EXPO_DEV_EXPO_GITHUB_ROBOT_ACCESS_TOKEN }}
OUTPUT_DIR: ${{ runner.temp }}/android-worker-staging-test
run: |
set -euo pipefail
# Only upload artifacts from EAS if GHA will upload them to GCS
# This avoids unnecessary artifact uploads for PR builds
SHOULD_UPLOAD_ARTIFACT="${{ inputs.environment != '' && steps.check_worker_exists.outputs.worker_exists == 'false' }}"
# Run workflow and capture output (includes run ID)
WORKFLOW_OUTPUT=$(eas workflow:run build-worker.yml \
--ref ${{ github.sha }} \
--input upload_artifact=$SHOULD_UPLOAD_ARTIFACT \
--non-interactive \
--no-wait \
--json)
WORKFLOW_RUN_ID=$(echo "$WORKFLOW_OUTPUT" | jq -r '.id')
if [ -z "$WORKFLOW_RUN_ID" ] || [ "$WORKFLOW_RUN_ID" = "null" ]; then
echo "Error: Could not determine workflow run ID"
echo "Workflow output:"
echo "$WORKFLOW_OUTPUT" | jq .
exit 1
fi
echo "workflow_run_id=$WORKFLOW_RUN_ID" >> "$GITHUB_OUTPUT"
- name: Wait for EAS workflow and capture artifacts
id: eas_artifacts
if: inputs.environment == '' || steps.check_worker_exists.outputs.worker_exists == 'false'
working-directory: packages/worker
env:
EXPO_TOKEN: ${{ secrets.EXPO_DEV_EXPO_GITHUB_ROBOT_ACCESS_TOKEN }}
run: |
set -euo pipefail
WORKFLOW_RUN_ID="${{ steps.eas_workflow.outputs.workflow_run_id }}"
if [ -z "$WORKFLOW_RUN_ID" ]; then
echo "Error: Missing workflow run ID"
exit 1
fi
SHOULD_UPLOAD_ARTIFACT="${{ inputs.environment != '' && steps.check_worker_exists.outputs.worker_exists == 'false' }}"
while true; do
VIEW_OUTPUT=$(eas workflow:view "$WORKFLOW_RUN_ID" \
--non-interactive \
--json)
STATUS=$(echo "$VIEW_OUTPUT" | jq -r '.status')
case "$STATUS" in
SUCCESS)
break
;;
FAILURE)
echo "EAS workflow failed."
echo "$VIEW_OUTPUT" | jq .
exit 1
;;
CANCELED)
echo "EAS workflow canceled."
echo "$VIEW_OUTPUT" | jq .
exit 1
;;
ACTION_REQUIRED)
echo "EAS workflow requires action."
echo "$VIEW_OUTPUT" | jq .
exit 1
;;
NEW|IN_PROGRESS)
sleep 7
;;
*)
echo "Unexpected workflow status: $STATUS"
echo "$VIEW_OUTPUT" | jq .
exit 1
;;
esac
done
# Skip artifact extraction if we didn't request uploads
if [ "$SHOULD_UPLOAD_ARTIFACT" == "false" ]; then
echo "Skipping artifact download - upload was disabled for this run"
exit 0
fi
# Extract artifact download URLs from workflow output
# Artifacts are in jobs[].artifacts[] for custom jobs
DOWNLOAD_IOS_URL=$(echo "$VIEW_OUTPUT" | jq -r '
.jobs[]
| .artifacts[]?
| select(.name == "worker-ios-tarball")
| .downloadUrl
' | head -1)
DOWNLOAD_ANDROID_URL=$(echo "$VIEW_OUTPUT" | jq -r '
.jobs[]
| .artifacts[]?
| select(.name == "worker-android-tarball")
| .downloadUrl
' | head -1)
if [ -z "$DOWNLOAD_IOS_URL" ] || [ "$DOWNLOAD_IOS_URL" = "null" ]; then
echo "Error: Could not find iOS artifact download URL"
echo "$VIEW_OUTPUT" | jq .
exit 1
fi
if [ -z "$DOWNLOAD_ANDROID_URL" ] || [ "$DOWNLOAD_ANDROID_URL" = "null" ]; then
echo "Error: Could not find Android artifact download URL"
echo "$VIEW_OUTPUT" | jq .
exit 1
fi
# Mask the download URLs
echo "::add-mask::$DOWNLOAD_IOS_URL"
echo "::add-mask::$DOWNLOAD_ANDROID_URL"
echo "download_ios_url=$DOWNLOAD_IOS_URL" >> "$GITHUB_OUTPUT"
echo "download_android_url=$DOWNLOAD_ANDROID_URL" >> "$GITHUB_OUTPUT"
- name: Cancel EAS workflow on GitHub cancellation
if: cancelled()
working-directory: packages/worker
env:
EXPO_TOKEN: ${{ secrets.EXPO_DEV_EXPO_GITHUB_ROBOT_ACCESS_TOKEN }}
run: |
set -euo pipefail
WORKFLOW_RUN_ID="${{ steps.eas_workflow.outputs.workflow_run_id }}"
if [ -n "$WORKFLOW_RUN_ID" ]; then
eas workflow:cancel "$WORKFLOW_RUN_ID" --non-interactive
fi
- name: Upload worker tarballs to GCP
# Upload when deploying if we built something new
# Don't upload PR builds (no environment) or when using cached tarballs
if: inputs.environment != '' && steps.check_worker_exists.outputs.worker_exists == 'false'
run: |
IOS_GCP_TARBALL="worker-ios-sha1-$GITHUB_SHA.tar.gz"
ANDROID_GCP_TARBALL="worker-android-sha1-$GITHUB_SHA.tar.gz"
curl -L "${{ steps.eas_artifacts.outputs.download_ios_url }}" | gsutil cp - "gs://eas-build-worker-tarballs/$IOS_GCP_TARBALL"
curl -L "${{ steps.eas_artifacts.outputs.download_android_url }}" | gsutil cp - "gs://eas-build-worker-tarballs/$ANDROID_GCP_TARBALL"
- name: Promote worker to ${{ inputs.environment }}
if: inputs.environment != ''
run: |
for platform in android ios; do
GCP_TARBALL="worker-$platform-sha1-$GITHUB_SHA.tar.gz"
WORKER_STAGING_TARBALL="worker-$platform-${{ inputs.environment }}.tar.gz"
gsutil cp "gs://eas-build-worker-tarballs/$GCP_TARBALL" "gs://eas-build-worker-tarballs/$WORKER_STAGING_TARBALL"
done
mkdir -p "$OUTPUT_DIR"
bash packages/worker/package.sh "$OUTPUT_DIR/worker-android.tar.gz" android
tar -xOf "$OUTPUT_DIR/worker-android.tar.gz" ./packages/build-tools/dist/steps/utils/androidEmulatorLogs.js > "$OUTPUT_DIR/androidEmulatorLogs.js"
cd "$OUTPUT_DIR"
sha256sum worker-android.tar.gz androidEmulatorLogs.js > SHA256SUMS
printf '%s\n' "$GITHUB_SHA" > build-revision.txt
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: android-worker-staging-test
path: ${{ runner.temp }}/android-worker-staging-test/
retention-days: 7
if-no-files-found: error
@@ -24,7 +24,7 @@ function createStep(callInputs: Record<string, unknown> = {}) {
return { ctx, global, logger, fn, step };
}
it('collects captured logs without a second source selection', async () => {
it('defaults an existing workflow without inputs to emulator output only', async () => {
const { ctx, global, fn, step } = createStep();
expect(fn.supportedRuntimePlatforms).toEqual([BuildRuntimePlatform.LINUX]);
await step.executeAsync();
@@ -32,9 +32,37 @@ it('collects captured logs without a second source selection', async () => {
global,
deviceRunSessionId: 'session',
logger: expect.anything(),
sources: ['emulator-output'],
});
});
it.each([
{ sources: ['emulator-output', 'device-logcat'] },
{ sources: ['device-logcat'] },
{ sources: [] },
])('passes an explicit selection of $sources through the step input', async ({ sources }) => {
const { step } = createStep({ sources });
await step.executeAsync();
expect(collectAndroidEmulatorLogsAsync).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ sources })
);
});
it.each([
{ sources: ['unknown'] },
{ sources: ['emulator-output', 'unknown'] },
{ sources: [1] },
{ sources: { logcat: true } },
])('does not collect any logs for invalid sources %j', async ({ sources }) => {
const { step } = createStep({ sources });
const collect = jest.mocked(collectAndroidEmulatorLogsAsync);
collect.mockClear();
await step.executeAsync();
expect(collect).not.toHaveBeenCalled();
expect(Sentry.capture).toHaveBeenCalled();
});
it('reports collection failure without changing the session result', async () => {
jest
.mocked(collectAndroidEmulatorLogsAsync)
@@ -5,7 +5,7 @@ import spawn from '@expo/turtle-spawn';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { type CustomBuildContext } from '../../../customBuildContext';
import { AndroidEmulatorLogCapture } from '../../../utils/androidEmulatorLogCapture';
import { getAndroidEmulatorLogCapture } from '../../utils/androidEmulatorLogs';
import { registerAndroidEmulatorLogDirectory } from '../../utils/androidEmulatorLogs';
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
import { stopLocalEgressResourcesAsync } from '../../utils/localEgress';
import {
@@ -116,21 +116,46 @@ function runStep(
): Promise<void> {
const buildFunction = createStartAgentDeviceSessionBuildFunction(stepCtx, { warm });
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
const global = createGlobalContextMock({
runtimePlatform,
staticContextContent: { job: { secrets: { robotAccessToken: 'robot-token' } } },
});
const inputs = Object.fromEntries(
(buildFunction.inputProviders ?? []).map(provider => {
const input = provider(global, 'test');
input.set(inputValues[input.id]);
return [input.id, { value: input.rawValue }];
})
[
'android_log_sources',
'device_identifier',
'system_image_package',
'lcd_width',
'lcd_height',
'lcd_density',
'install_app_path',
'build_id',
'application_archive_url',
'launch_app_identifier',
'launch_args',
'open_url',
'network_capture',
'network_capture_fields',
'public_preview',
'private_preview_url',
'frame_ancestors',
'cors_origins',
'package_version',
'max_idle_time_minutes',
'max_duration_seconds',
].map(id => [
id,
{
value:
id === 'android_log_sources' && !(id in inputValues)
? ['emulator-output', 'device-logcat']
: inputValues[id],
},
])
);
return buildFunction.fn!(
{
logger,
global,
global: {
runtimePlatform,
staticContext: { job: { secrets: { robotAccessToken: 'robot-token' } } },
},
} as unknown as BuildStepContext,
{ inputs, outputs: {}, env, signal } as never
) as Promise<void>;
@@ -161,11 +186,6 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
}
beforeEach(() => {
jest
.mocked(getAndroidEmulatorLogCapture)
.mockImplementation(
jest.requireActual('../../utils/androidEmulatorLogs').getAndroidEmulatorLogCapture
);
deviceLaunch = undefined;
jest.clearAllMocks();
jest.mocked(resolveLocalEgressServeSimBootEnvironmentAsync).mockResolvedValue(null);
@@ -430,7 +450,7 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
expect(installBuildAsync).not.toHaveBeenCalled();
expect(launchApplicationAsync).not.toHaveBeenCalled();
expect(getAndroidEmulatorLogCapture).not.toHaveBeenCalled();
expect(registerAndroidEmulatorLogDirectory).not.toHaveBeenCalled();
expect(sessionDevice()).toEqual({
iosSimulatorUdid: 'SELECTED-UDID',
bootEnv: undefined,
@@ -438,46 +458,36 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
});
});
it('defaults an omitted session log input to emulator output through the step boundary', async () => {
const buildFunction = createStartAgentDeviceSessionBuildFunction(ctx);
const global = createGlobalContextMock({ runtimePlatform: BuildRuntimePlatform.LINUX });
const step = buildFunction.createBuildStepFromFunctionCall(global, {
callInputs: {},
env: { DEVICE_RUN_SESSION_ID: 'session' },
});
await step.executeAsync();
expect(startAndroidEmulatorAsync).toHaveBeenCalledWith(
expect.objectContaining({ logSources: ['emulator-output'] })
);
});
it('passes an explicit empty source list and the workflow capture to Android boot', async () => {
it.each([
{ sources: [] },
{ sources: ['emulator-output'] },
{ sources: ['device-logcat'] },
{ sources: ['emulator-output', 'device-logcat'] },
])('selects $sources before Android boot', async ({ sources }) => {
const capture = new AndroidEmulatorLogCapture();
jest.mocked(getAndroidEmulatorLogCapture).mockReturnValue(capture);
const env = { DEVICE_RUN_SESSION_ID: 'session' };
jest.mocked(registerAndroidEmulatorLogDirectory).mockReturnValue(capture);
await runStep(
BuildRuntimePlatform.LINUX,
{ android_log_sources: [] },
{ android_log_sources: sources },
undefined,
ctx,
false,
env
{
DEVICE_RUN_SESSION_ID: 'session',
}
);
expect(getAndroidEmulatorLogCapture).toHaveBeenCalledWith(expect.anything(), env);
expect(startAndroidEmulatorAsync).toHaveBeenCalledWith(
expect.objectContaining({ logSources: [], logCapture: capture })
expect.objectContaining({ logSources: sources, logCapture: capture })
);
});
it('rejects invalid Android log sources before boot or downloading', async () => {
it('rejects invalid Android log sources before boot', async () => {
await expect(
runStep(BuildRuntimePlatform.LINUX, {
android_log_sources: ['unknown'],
build_id: 'build-id',
})
).rejects.toThrow();
expect(startAndroidEmulatorAsync).not.toHaveBeenCalled();
expect(downloadBuildAsync).not.toHaveBeenCalled();
});
it('boots the Android Emulator with the device inputs', async () => {
@@ -491,6 +501,10 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
});
expect(selectXcodeDeveloperDirectoryAsync).not.toHaveBeenCalled();
expect(registerAndroidEmulatorLogDirectory).toHaveBeenCalledWith(
expect.objectContaining({ runtimePlatform: BuildRuntimePlatform.LINUX }),
jest.mocked(startAndroidEmulatorAsync).mock.calls[0][0].logcatDirectory
);
expect(startAndroidEmulatorAsync).toHaveBeenCalledWith(
expect.objectContaining({
deviceIdentifier: 'pixel_7',
@@ -510,16 +524,18 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
const download = mockStalledDownload();
jest.mocked(startAndroidEmulatorAsync).mockRejectedValue(new Error('boot failed'));
await expect(
runStep(BuildRuntimePlatform.LINUX, { build_id: 'build-id' }, undefined, ctx, false, {
DEVICE_RUN_SESSION_ID: 'session',
})
).rejects.toThrow('boot failed');
await expect(runStep(BuildRuntimePlatform.LINUX, { build_id: 'build-id' })).rejects.toThrow(
'boot failed'
);
expect(download.aborted()).toBe(true);
// The step returned only after the download stopped.
expect(download.settled()).toBe(true);
expect(installBuildAsync).not.toHaveBeenCalled();
expect(registerAndroidEmulatorLogDirectory).toHaveBeenCalledTimes(1);
expect(
jest.mocked(registerAndroidEmulatorLogDirectory).mock.invocationCallOrder[0]
).toBeLessThan(jest.mocked(startAndroidEmulatorAsync).mock.invocationCallOrder[0]);
});
it('does not start work for an already cancelled step', async () => {
@@ -10,7 +10,7 @@ import {
import { retryAsync } from '../../../utils/retry';
import { resolveAndroidEmulatorLocalEgressAsync } from '../../utils/androidLocalEgress';
import { AndroidEmulatorLogCapture } from '../../../utils/androidEmulatorLogCapture';
import { getAndroidEmulatorLogCapture } from '../../utils/androidEmulatorLogs';
import { registerAndroidEmulatorLogDirectory } from '../../utils/androidEmulatorLogs';
import { createStartAndroidEmulatorBuildFunction } from '../startAndroidEmulator';
jest.mock('@expo/turtle-spawn', () => ({
@@ -66,11 +66,6 @@ function createStartResult(serialId: string) {
describe(createStartAndroidEmulatorBuildFunction, () => {
beforeEach(() => {
jest
.mocked(getAndroidEmulatorLogCapture)
.mockImplementation(
jest.requireActual('../../utils/androidEmulatorLogs').getAndroidEmulatorLogCapture
);
mockedMkdtemp.mockResolvedValue('/tmp/logcat-directory');
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
mockedAndroidUtils.getAvailableDevicesAsync.mockResolvedValue([]);
@@ -99,21 +94,22 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
mockedMkdtemp.mockRestore();
});
it('preserves an explicit empty source list and one capture through boot retries and clones', async () => {
it.each([
{ sources: [] },
{ sources: ['emulator-output'] },
{ sources: ['device-logcat'] },
{ sources: ['emulator-output', 'device-logcat'] },
])('preserves $sources through boot retries and clones', async ({ sources }) => {
mockedAndroidUtils.waitForReadyAsync.mockRejectedValueOnce(new Error('retry boot'));
const capture = new AndroidEmulatorLogCapture();
jest.mocked(getAndroidEmulatorLogCapture).mockReturnValue(capture);
jest.mocked(registerAndroidEmulatorLogDirectory).mockReturnValue(capture);
await createStep(
{ android_log_sources: [], count: 2 },
{ android_log_sources: sources, count: 2 },
{ DEVICE_RUN_SESSION_ID: 'session' }
).executeAsync();
expect(getAndroidEmulatorLogCapture).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ DEVICE_RUN_SESSION_ID: 'session' })
);
expect(mockedAndroidUtils.startAsync).toHaveBeenCalledTimes(4);
for (const [options] of mockedAndroidUtils.startAsync.mock.calls) {
expect(options.logSources).toEqual([]);
expect(options.logSources).toEqual(sources);
expect(options.logCapture).toBe(capture);
}
});
@@ -145,7 +141,6 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
env: expect.any(Object),
logcatDirectory: '/tmp/logcat-directory',
logSources: ['emulator-output', 'device-logcat'],
logCapture: expect.any(AndroidEmulatorLogCapture),
});
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenCalledWith({
env: expect.any(Object),
@@ -261,6 +256,7 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
expect.objectContaining({
deviceName: 'EasAndroidDevice01',
logcatDirectory: '/tmp/logcat-directory',
logSources: ['emulator-output', 'device-logcat'],
})
);
expect(mockedAndroidUtils.startAsync).toHaveBeenNthCalledWith(
@@ -268,6 +264,7 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
expect.objectContaining({
deviceName: 'EasAndroidDevice01',
logcatDirectory: '/tmp/logcat-directory',
logSources: ['emulator-output', 'device-logcat'],
})
);
expect(mockedAndroidUtils.deleteAsync).toHaveBeenCalledWith(
@@ -342,18 +339,21 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
expect.objectContaining({
deviceName: 'EasAndroidDevice01',
logcatDirectory: '/tmp/logcat-directory',
logSources: ['emulator-output', 'device-logcat'],
})
);
expect(mockedAndroidUtils.startAsync).toHaveBeenCalledWith(
expect.objectContaining({
deviceName: 'eas-simulator-1',
logcatDirectory: '/tmp/logcat-directory',
logSources: ['emulator-output', 'device-logcat'],
})
);
expect(mockedAndroidUtils.startAsync).toHaveBeenCalledWith(
expect.objectContaining({
deviceName: 'eas-simulator-2',
logcatDirectory: '/tmp/logcat-directory',
logSources: ['emulator-output', 'device-logcat'],
})
);
@@ -383,6 +383,13 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
);
expect(mockedAndroidUtils.deleteAsync).toHaveBeenCalledTimes(3);
expect(mockedAndroidUtils.startAsync).toHaveBeenCalledTimes(3);
expect(registerAndroidEmulatorLogDirectory).toHaveBeenCalledWith(
expect.anything(),
'/tmp/logcat-directory'
);
expect(
jest.mocked(registerAndroidEmulatorLogDirectory).mock.invocationCallOrder[0]
).toBeLessThan(mockedAndroidUtils.startAsync.mock.invocationCallOrder[0]);
});
it('outputs and uses a temporary logcat directory', async () => {
@@ -392,9 +399,14 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
expect(mockedAndroidUtils.startAsync).toHaveBeenCalledWith(
expect.objectContaining({
logcatDirectory: '/tmp/logcat-directory',
logSources: ['emulator-output', 'device-logcat'],
})
);
expect(step.getOutputValueByName('logcat_directory')).toBe('/tmp/logcat-directory');
expect(registerAndroidEmulatorLogDirectory).toHaveBeenCalledWith(
step.ctx.global,
'/tmp/logcat-directory'
);
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenCalledWith(
expect.objectContaining({
serialId: 'emulator-default',
@@ -1,7 +1,13 @@
import { BuildFunction, BuildRuntimePlatform } from '@expo/steps';
import {
BuildFunction,
BuildRuntimePlatform,
BuildStepInput,
BuildStepInputValueTypeName,
} from '@expo/steps';
import { type CustomBuildContext } from '../../customBuildContext';
import { Sentry } from '../../sentry';
import { AndroidEmulatorLogSourcesSchema } from '../../utils/androidEmulatorLogSources';
import { collectAndroidEmulatorLogsAsync } from '../utils/androidEmulatorLogs';
import { getDeviceRunSessionIdOrThrow } from '../utils/remoteDeviceRunSession';
@@ -14,12 +20,21 @@ export function createCollectAndroidEmulatorLogsBuildFunction(
name: 'Collect Android emulator logs',
__metricsId: 'eas/collect_android_emulator_logs',
supportedRuntimePlatforms: [BuildRuntimePlatform.LINUX],
fn: async ({ logger, global }, { env }) => {
inputProviders: [
BuildStepInput.createProvider({
id: 'sources',
required: false,
defaultValue: ['emulator-output'],
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
}),
],
fn: async ({ logger, global }, { inputs, env }) => {
try {
await collectAndroidEmulatorLogsAsync(ctx, {
global,
deviceRunSessionId: getDeviceRunSessionIdOrThrow(env),
logger,
sources: AndroidEmulatorLogSourcesSchema.parse(inputs.sources.value),
});
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
@@ -16,8 +16,11 @@ import {
AndroidVirtualDeviceName,
} from '../../utils/AndroidEmulatorUtils';
import { watchDeviceRunSessionStartup } from '../utils/deviceRunSessionStartup';
import { AndroidEmulatorLogSourcesSchema } from '../../utils/androidEmulatorLogSources';
import { getAndroidEmulatorLogCapture } from '../utils/androidEmulatorLogs';
import {
AndroidEmulatorLogSourcesSchema,
LEGACY_ANDROID_EMULATOR_LOG_SOURCES,
} from '../../utils/androidEmulatorLogSources';
import { registerAndroidEmulatorLogDirectory } from '../utils/androidEmulatorLogs';
import { getDeviceRunSessionWorkerApi } from '../utils/deviceRunSessionWorkerApi';
import { readIosApplicationIdentifierAsync } from '../utils/iosAppArtifact';
import { resolveIosSessionStartupAsync } from '../utils/iosSimulatorSession';
@@ -76,7 +79,7 @@ export function createStartAgentDeviceSessionBuildFunction(
BuildStepInput.createProvider({
id: 'android_log_sources',
required: false,
defaultValue: ['emulator-output'],
defaultValue: [...LEGACY_ANDROID_EMULATOR_LOG_SOURCES],
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
}),
// Device. On iOS a Simulator name or UDID, on Android an AVD hardware profile.
@@ -196,9 +199,6 @@ export function createStartAgentDeviceSessionBuildFunction(
} else {
validateAndroidLaunchOptions({ launchArgs, openUrl });
}
const logSources = isIos
? []
: AndroidEmulatorLogSourcesSchema.parse(inputs.android_log_sources.value);
const deviceIdentifier = inputs.device_identifier.value as string | undefined;
const capture = parseNetworkCaptureInputs(
{
@@ -252,9 +252,13 @@ export function createStartAgentDeviceSessionBuildFunction(
booted = iosSimulatorUdid
? Promise.resolve()
: tasks.run('Android Emulator boot', async taskLogger => {
const logSources = AndroidEmulatorLogSourcesSchema.parse(
inputs.android_log_sources.value
);
const logcatDirectory = await fs.promises.mkdtemp(
path.join(os.tmpdir(), 'eas-android-emulator-logcat-')
);
const capture = registerAndroidEmulatorLogDirectory(global, logcatDirectory);
tasks.signal.throwIfAborted();
await startAndroidEmulatorAsync({
deviceName: ANDROID_DEVICE_NAME,
@@ -265,7 +269,7 @@ export function createStartAgentDeviceSessionBuildFunction(
lcdDensity: inputs.lcd_density.value as number | undefined,
logcatDirectory,
logSources,
logCapture: getAndroidEmulatorLogCapture(global, env),
logCapture: env.DEVICE_RUN_SESSION_ID ? capture : undefined,
env,
logger: taskLogger,
});
@@ -24,11 +24,11 @@ import { type AndroidEmulatorLogCapture } from '../../utils/androidEmulatorLogCa
import { retryAsync } from '../../utils/retry';
import { resolveAndroidEmulatorLocalEgressAsync } from '../utils/androidLocalEgress';
import {
ALL_ANDROID_EMULATOR_LOG_SOURCES,
type AndroidEmulatorLogSource,
AndroidEmulatorLogSourcesSchema,
LEGACY_ANDROID_EMULATOR_LOG_SOURCES,
} from '../../utils/androidEmulatorLogSources';
import { getAndroidEmulatorLogCapture } from '../utils/androidEmulatorLogs';
import { registerAndroidEmulatorLogDirectory } from '../utils/androidEmulatorLogs';
const ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS = [60_000, 120_000, 180_000];
const ANDROID_STARTUP_RETRIES_COUNT = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS.length - 1;
@@ -43,7 +43,7 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
BuildStepInput.createProvider({
id: 'android_log_sources',
required: false,
defaultValue: [...ALL_ANDROID_EMULATOR_LOG_SOURCES],
defaultValue: [...LEGACY_ANDROID_EMULATOR_LOG_SOURCES],
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
}),
BuildStepInput.createProvider({
@@ -96,7 +96,8 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
const logcatDirectory = await fs.promises.mkdtemp(
path.join(os.tmpdir(), 'eas-android-emulator-logcat-')
);
const logCapture = getAndroidEmulatorLogCapture(global, env);
const capture = registerAndroidEmulatorLogDirectory(global, logcatDirectory);
const logCapture = env.DEVICE_RUN_SESSION_ID ? capture : undefined;
outputs.logcat_directory.set(logcatDirectory);
const deviceName = `${inputs.device_name.value}` as AndroidVirtualDeviceName;
@@ -233,8 +234,8 @@ export async function startAndroidEmulatorAsync({
lcdHeight?: number;
lcdDensity?: number;
logcatDirectory: string;
logSources: readonly AndroidEmulatorLogSource[];
logCapture: AndroidEmulatorLogCapture;
logSources?: readonly AndroidEmulatorLogSource[];
logCapture?: AndroidEmulatorLogCapture;
env: BuildStepEnv;
logger: bunyan;
}): Promise<{
@@ -1,39 +1,25 @@
import { type AndroidEmulatorLogSource } from '../../../utils/androidEmulatorLogSources';
import { type BuildStepGlobalContext } from '@expo/steps';
import { once } from 'node:events';
import fsPromises, {
mkdir,
mkdtemp,
readFile,
readdir,
rm,
symlink,
writeFile,
} from 'node:fs/promises';
import fs from 'node:fs';
import fsPromises, { mkdir, readFile, readdir, symlink, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { PassThrough, Readable } from 'node:stream';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { type CustomBuildContext } from '../../../customBuildContext';
import { MAX_ANDROID_LOG_BYTES_PER_SOURCE } from '../../../utils/androidEmulatorLogCapture';
import { type AndroidEmulatorLogSource } from '../../../utils/androidEmulatorLogSources';
import {
collectAndroidEmulatorLogsAsync,
getAndroidEmulatorLogCapture,
registerAndroidEmulatorLogDirectory,
} from '../androidEmulatorLogs';
import { uploadDeviceRunSessionArtifactAsync } from '../deviceRunSessionArtifacts';
jest.mock('../deviceRunSessionArtifacts');
jest.unmock('fs');
jest.unmock('node:fs');
jest.unmock('fs/promises');
jest.unmock('node:fs/promises');
const ctx = {} as CustomBuildContext;
const directory = path.join(os.tmpdir(), 'android-logs');
const emulatorPath = path.join(directory, 'device-attempt-1-emulator.log');
const logcatPath = path.join(directory, 'device-attempt-1-logcat.log');
const upload = jest.mocked(uploadDeviceRunSessionArtifactAsync);
let directory: string;
let emulatorPath: string;
let logcatPath: string;
let global: BuildStepGlobalContext;
let logger: ReturnType<typeof createMockLogger>;
let uploadedContents: Map<string, string>;
@@ -42,9 +28,7 @@ beforeEach(async () => {
global = {} as BuildStepGlobalContext;
logger = createMockLogger();
uploadedContents = new Map();
directory = await mkdtemp(path.join(os.tmpdir(), 'android-log-collection-'));
emulatorPath = path.join(directory, 'device-attempt-1-emulator.log');
logcatPath = path.join(directory, 'device-attempt-1-logcat.log');
await mkdir(directory, { recursive: true });
upload.mockReset().mockImplementation(async (_ctx, { filename, openStream }) => {
const chunks: Buffer[] = [];
for await (const chunk of openStream()) {
@@ -54,69 +38,83 @@ beforeEach(async () => {
});
});
afterEach(async () => {
jest.restoreAllMocks();
await rm(directory, { recursive: true, force: true });
});
async function captureLog(
filePath: string,
source: AndroidEmulatorLogSource,
text: string,
workflow = global
function collect(
maxBytes?: number,
sources: readonly AndroidEmulatorLogSource[] = ['emulator-output', 'device-logcat']
): Promise<void> {
const capture = getAndroidEmulatorLogCapture(workflow, { DEVICE_RUN_SESSION_ID: 'session' });
const input = Readable.from([Buffer.from(text)]);
capture.attach(input, filePath, source);
await once(input, 'end');
await capture.finishFileAsync(filePath);
}
function collect(): Promise<void> {
return collectAndroidEmulatorLogsAsync(ctx, {
global,
deviceRunSessionId: 'session',
logger,
maxBytes,
sources,
});
}
it.each([
{ env: {}, expectedBytes: 20 * 1024 * 1024 + 1 },
{ env: { DEVICE_RUN_SESSION_ID: 'session' }, expectedBytes: 20 * 1024 * 1024 },
])('retains $expectedBytes with workflow environment $env', async ({ env, expectedBytes }) => {
const capture = getAndroidEmulatorLogCapture(global, env);
const input = Readable.from([Buffer.alloc(20 * 1024 * 1024 + 1, 'x')]);
capture.attach(input, emulatorPath, 'emulator-output');
await once(input, 'end');
await capture.finishAsync();
expect((await fsPromises.stat(emulatorPath)).size).toBe(expectedBytes);
it('uploads only emulator output when sources are omitted', async () => {
await writeFile(emulatorPath, 'host output');
await writeFile(logcatPath, 'sensitive device output');
registerAndroidEmulatorLogDirectory(global, directory);
const open = jest.spyOn(fsPromises, 'open');
try {
await collectAndroidEmulatorLogsAsync(ctx, { global, deviceRunSessionId: 'session', logger });
expect([...uploadedContents.keys()]).toEqual([path.basename(emulatorPath)]);
expect(open).not.toHaveBeenCalledWith(logcatPath, expect.anything());
} finally {
open.mockRestore();
}
});
it('uses captured source records and ignores files outside the capture inventory', async () => {
const first = path.join(directory, 'first.txt');
const second = path.join(directory, 'second.txt');
await captureLog(first, 'emulator-output', 'host output');
await captureLog(second, 'device-logcat', 'Android output');
await captureLog(path.join(directory, 'retry.txt'), 'emulator-output', 'retry output');
await writeFile(emulatorPath, 'not captured');
const elsewhere = path.join(directory, 'elsewhere');
it('uploads only explicitly selected logcat and uploads nothing for an empty selection', async () => {
await writeFile(emulatorPath, 'host output');
await writeFile(logcatPath, 'device output');
registerAndroidEmulatorLogDirectory(global, directory);
await collect(undefined, []);
expect(upload).not.toHaveBeenCalled();
await collect(undefined, ['device-logcat']);
expect([...uploadedContents.keys()]).toEqual([path.basename(logcatPath)]);
});
it('honors different source selections during concurrent cleanup without duplicate uploads', async () => {
await writeFile(emulatorPath, 'host output');
await writeFile(logcatPath, 'device output');
registerAndroidEmulatorLogDirectory(global, directory);
await Promise.all([
collect(undefined, ['emulator-output']),
collect(undefined, ['emulator-output', 'device-logcat']),
]);
expect([...uploadedContents.keys()].sort()).toEqual([
path.basename(emulatorPath),
path.basename(logcatPath),
]);
expect(upload).toHaveBeenCalledTimes(2);
});
it('uploads both logs for every attempt from registered directories only', async () => {
await writeFile(emulatorPath, 'host output');
await writeFile(logcatPath, 'Android output');
await writeFile(path.join(directory, 'device-attempt-2-emulator.log'), 'retry output');
await writeFile(path.join(directory, 'unrelated.txt'), 'not a log');
const elsewhere = path.join(os.tmpdir(), 'unregistered');
await mkdir(elsewhere);
await writeFile(path.join(elsewhere, 'other-emulator.log'), 'another job');
await symlink(path.join(elsewhere, 'other-emulator.log'), logcatPath);
await symlink(
path.join(elsewhere, 'other-emulator.log'),
path.join(directory, 'link-logcat.log')
);
registerAndroidEmulatorLogDirectory(global, directory);
await collect();
expect([...uploadedContents.values()]).toEqual(['host output', 'Android output', 'retry output']);
expect(upload).toHaveBeenCalledWith(
ctx,
expect.objectContaining({
filename: 'first.txt',
filename: 'device-attempt-1-emulator.log',
kind: 'simulator-log',
metadata: expect.objectContaining({
platform: 'android',
source: 'emulator-output',
scope: 'emulator-process',
originalSizeBytes: 11,
omittedBytes: 0,
truncated: false,
}),
signal: expect.any(AbortSignal),
@@ -126,23 +124,48 @@ it('uses captured source records and ignores files outside the capture inventory
expect(upload).toHaveBeenCalledWith(
ctx,
expect.objectContaining({
filename: 'second.txt',
filename: 'device-attempt-1-logcat.log',
metadata: expect.objectContaining({ source: 'device-logcat', scope: 'device' }),
})
);
expect(await readdir(os.tmpdir())).not.toEqual(
expect.arrayContaining([expect.stringMatching(/^eas-android-log-snapshot-/)])
);
});
it('seals a live producer before upload and keeps retry bodies identical without copying files', async () => {
const input = new PassThrough();
const capture = getAndroidEmulatorLogCapture(global, { DEVICE_RUN_SESSION_ID: 'session' });
capture.attach(input, emulatorPath, 'emulator-output');
input.write('before');
it('keeps the earliest bytes and records truncation', async () => {
await writeFile(emulatorPath, 'old output\ncrash\n');
registerAndroidEmulatorLogDirectory(global, directory);
await collect(6);
expect(uploadedContents.get(path.basename(emulatorPath))).toBe('old ou');
expect(upload).toHaveBeenCalledWith(
ctx,
expect.objectContaining({
size: 6,
name: expect.stringContaining('truncated, 6 bytes retained'),
metadata: expect.objectContaining({
originalSizeBytes: 17,
omittedBytes: 11,
truncated: true,
}),
})
);
expect(await readFile(emulatorPath, 'utf8')).toBe('old output\ncrash\n');
});
it('fixes the snapshot range before append and keeps upload retries identical', async () => {
await writeFile(emulatorPath, 'before');
registerAndroidEmulatorLogDirectory(global, directory);
const createReadStream = fs.createReadStream;
const read = jest.spyOn(fs, 'createReadStream').mockImplementationOnce((filePath, options) => {
fs.appendFileSync(emulatorPath, ' during snapshot');
return createReadStream(filePath, options);
});
const contents: string[] = [];
upload.mockImplementationOnce(async (_ctx, { size, openStream, metadata }) => {
upload.mockImplementationOnce(async (_ctx, { size, openStream }) => {
expect(size).toBe(6);
expect(metadata).toEqual(expect.objectContaining({ originalSizeBytes: 6, truncated: false }));
for (let attempt = 0; attempt < 2; attempt++) {
input.write(' ignored after finalization');
await writeFile(emulatorPath, 'rewritten after snapshot');
const chunks: Buffer[] = [];
for await (const chunk of openStream()) {
chunks.push(Buffer.from(chunk));
@@ -153,37 +176,74 @@ it('seals a live producer before upload and keeps retry bodies identical without
try {
await collect();
expect(contents).toEqual(['before', 'before']);
expect(await readFile(emulatorPath, 'utf8')).toBe('before');
expect(await readdir(directory)).toEqual([path.basename(emulatorPath)]);
expect(input.readableFlowing).toBe(true);
const late = new PassThrough();
capture.attach(late, logcatPath, 'device-logcat');
late.end('not captured');
expect(late.readableFlowing).toBe(true);
await expect(fsPromises.stat(logcatPath)).rejects.toMatchObject({ code: 'ENOENT' });
} finally {
input.end();
read.mockRestore();
}
});
it('continues with the other log after a failed upload and never repeats the collection', async () => {
await captureLog(emulatorPath, 'emulator-output', 'host output');
await captureLog(logcatPath, 'device-logcat', 'Android output');
it('continues after a failed upload and retries only unsuccessful files on another collection', async () => {
await writeFile(emulatorPath, 'host output');
await writeFile(logcatPath, 'Android output');
registerAndroidEmulatorLogDirectory(global, directory);
upload.mockRejectedValueOnce(new Error('storage unavailable'));
await Promise.all([collect(), collect()]);
await collect();
await expect(collect()).resolves.toBeUndefined();
expect(upload).toHaveBeenCalledTimes(2);
expect(uploadedContents.get(path.basename(logcatPath))).toBe('Android output');
expect(logger.warn).toHaveBeenCalledTimes(1);
expect(logger.warn).toHaveBeenCalledWith(
expect.anything(),
expect.stringContaining('other logs will continue')
);
await collect();
expect(upload).toHaveBeenCalledTimes(3);
expect(upload.mock.calls[2][1].filename).toBe(path.basename(emulatorPath));
expect(upload.mock.calls[0][1].signal).not.toBe(upload.mock.calls[1][1].signal);
});
it('shares concurrent collection and skips successful uploads on repeated cleanup', async () => {
await writeFile(emulatorPath, 'host output');
registerAndroidEmulatorLogDirectory(global, directory);
registerAndroidEmulatorLogDirectory(global, directory);
await Promise.all([collect(), collect()]);
await collect();
expect(upload).toHaveBeenCalledTimes(1);
});
it('keeps successful uploads deduplicated when snapshot cleanup fails', async () => {
await writeFile(emulatorPath, 'host output');
registerAndroidEmulatorLogDirectory(global, directory);
const open = fsPromises.open;
const openFile = jest.spyOn(fsPromises, 'open').mockImplementationOnce(async (...args) => {
const file = await open(...args);
const close = file.close.bind(file);
file.close = async () => {
await close();
throw new Error('close failed');
};
return file;
});
const remove = jest.spyOn(fsPromises, 'rm').mockRejectedValueOnce(new Error('remove failed'));
try {
await expect(collect()).resolves.toBeUndefined();
await collect();
expect(upload).toHaveBeenCalledTimes(1);
expect(remove).toHaveBeenCalledTimes(1);
expect(logger.warn).toHaveBeenCalledWith(
expect.anything(),
expect.stringContaining('Could not close')
);
expect(logger.warn).toHaveBeenCalledWith(
expect.anything(),
expect.stringContaining('Could not remove')
);
} finally {
openFile.mockRestore();
remove.mockRestore();
}
});
it('uses a fresh 30 second budget for the next file after an upload times out', async () => {
await captureLog(emulatorPath, 'emulator-output', 'host output');
await captureLog(logcatPath, 'device-logcat', 'Android output');
await writeFile(emulatorPath, 'host output');
await writeFile(logcatPath, 'Android output');
registerAndroidEmulatorLogDirectory(global, directory);
const controllers: AbortController[] = [];
const timeout = jest.spyOn(AbortSignal, 'timeout').mockImplementation(() => {
const controller = new AbortController();
@@ -194,47 +254,69 @@ it('uses a fresh 30 second budget for the next file after an upload times out',
controllers[0].abort(new Error('upload timed out'));
signal?.throwIfAborted();
});
await expect(collect()).resolves.toBeUndefined();
expect(uploadedContents.get(path.basename(logcatPath))).toBe('Android output');
expect(timeout.mock.calls).toEqual([[30_000], [30_000]]);
expect(upload.mock.calls[1][1].signal?.aborted).toBe(false);
try {
await expect(collect()).resolves.toBeUndefined();
expect(uploadedContents.get(path.basename(logcatPath))).toBe('Android output');
expect(timeout.mock.calls).toEqual([[30_000], [30_000]]);
expect(upload.mock.calls[1][1].signal?.aborted).toBe(false);
} finally {
timeout.mockRestore();
}
});
it('does not collect another workflow and warns when a captured file disappears', async () => {
await captureLog(emulatorPath, 'emulator-output', 'other workflow', {} as BuildStepGlobalContext);
it('does not collect another workflow or empty files and tolerates missing directories', async () => {
registerAndroidEmulatorLogDirectory({} as BuildStepGlobalContext, directory);
await writeFile(emulatorPath, 'other workflow');
await collect();
expect(upload).not.toHaveBeenCalled();
await captureLog(path.join(directory, 'missing.log'), 'emulator-output', 'removed');
await rm(path.join(directory, 'missing.log'));
await writeFile(emulatorPath, '');
registerAndroidEmulatorLogDirectory(global, path.join(os.tmpdir(), 'missing'));
registerAndroidEmulatorLogDirectory(global, directory);
await expect(collect()).resolves.toBeUndefined();
expect(upload).not.toHaveBeenCalled();
expect(logger.warn).toHaveBeenCalledWith(
expect.anything(),
expect.stringContaining('missing.log')
expect.stringContaining('Could not read')
);
});
it('uploads the tail of a truncated log with the retained size and omission metadata', async () => {
const capture = getAndroidEmulatorLogCapture(global, { DEVICE_RUN_SESSION_ID: 'session' });
const input = Readable.from([
Buffer.concat([Buffer.from('head: '), Buffer.alloc(MAX_ANDROID_LOG_BYTES_PER_SOURCE, 'b')]),
]);
capture.attach(input, emulatorPath, 'emulator-output');
await once(input, 'end');
await collect();
expect(upload).toHaveBeenCalledWith(
ctx,
expect.objectContaining({
size: MAX_ANDROID_LOG_BYTES_PER_SOURCE,
name: expect.stringContaining('truncated, last 20 MiB retained'),
metadata: expect.objectContaining({
originalSizeBytes: MAX_ANDROID_LOG_BYTES_PER_SOURCE + 6,
omittedBytes: 6,
truncated: true,
}),
})
it('handles a snapshot read failure and still uploads the other log', async () => {
await writeFile(emulatorPath, 'host output');
await writeFile(logcatPath, 'Android output');
registerAndroidEmulatorLogDirectory(global, directory);
const createReadStream = fs.createReadStream;
const read = jest.spyOn(fs, 'createReadStream').mockImplementationOnce((filePath, options) => {
const stream = createReadStream(filePath, options);
stream.destroy(new Error('source read failed'));
return stream;
});
try {
await expect(collect()).resolves.toBeUndefined();
expect(uploadedContents.get(path.basename(logcatPath))).toBe('Android output');
expect(upload).toHaveBeenCalledTimes(1);
} finally {
read.mockRestore();
}
});
it('aborts an upload on a genuine snapshot file read error and continues with the other log', async () => {
await writeFile(emulatorPath, 'host output');
await writeFile(logcatPath, 'Android output');
registerAndroidEmulatorLogDirectory(global, directory);
const readError = Object.assign(new Error('snapshot read failed'), { code: 'EIO' });
upload.mockImplementationOnce(async (_ctx, { openStream, signal }) => {
const stream = openStream();
await new Promise<void>(resolve => {
stream.once('error', () => resolve());
stream.destroy(readError);
});
expect(signal?.reason).toBe(readError);
signal?.throwIfAborted();
});
await expect(collect()).resolves.toBeUndefined();
expect(uploadedContents.get(path.basename(logcatPath))).toBe('Android output');
expect(logger.warn).toHaveBeenCalledWith(
{ err: readError },
expect.stringContaining('other logs will continue')
);
const uploaded = uploadedContents.get(path.basename(emulatorPath));
expect(uploaded).toHaveLength(MAX_ANDROID_LOG_BYTES_PER_SOURCE);
expect(uploaded).not.toContain('head');
});
@@ -0,0 +1,155 @@
import { type BuildStepGlobalContext } from '@expo/steps';
import fs from 'node:fs';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { createServer } from 'node:http';
import { once } from 'node:events';
import { Readable } from 'node:stream';
import os from 'node:os';
import path from 'node:path';
import {
createCustomBuildContextMock,
createGlobalContextMock,
} from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { type CustomBuildContext } from '../../../customBuildContext';
import {
collectAndroidEmulatorLogsAsync,
registerAndroidEmulatorLogDirectory,
} from '../androidEmulatorLogs';
import { requireDeviceRunSessionWorkerApi } from '../deviceRunSessionWorkerApi';
jest.mock('../deviceRunSessionWorkerApi');
jest.unmock('fs');
jest.unmock('node:fs');
jest.unmock('fs/promises');
jest.unmock('node:fs/promises');
jest.unmock('node-fetch');
it('does not close a reused descriptor after a snapshot read failure', async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), 'android-log-descriptor-test-'));
const unrelatedPath = path.join(directory, 'unrelated.txt');
const readError = Object.assign(new Error('source read failed'), { code: 'EIO' });
const createReadStream = fs.createReadStream;
let sourceFd: number | undefined;
let reusedFd: number | undefined;
const read = jest.spyOn(fs, 'createReadStream').mockImplementationOnce((filePath, options) => {
if (!options || typeof options === 'string' || options.fd == null) {
throw new Error('Expected an open source file');
}
sourceFd = typeof options.fd === 'number' ? options.fd : options.fd.fd;
const stream = createReadStream(filePath, options);
stream.once('close', () => {
// Reuse the descriptor before the collector's final FileHandle.close().
reusedFd = fs.openSync(unrelatedPath, 'w');
});
stream.destroy(readError);
return stream;
});
try {
await writeFile(path.join(directory, 'device-emulator.log'), 'host output');
const global = createGlobalContextMock();
const logger = createMockLogger();
registerAndroidEmulatorLogDirectory(global, directory);
await collectAndroidEmulatorLogsAsync(createCustomBuildContextMock(), {
global,
deviceRunSessionId: 'session',
logger,
});
expect(reusedFd).toBe(sourceFd);
if (reusedFd === undefined) {
throw new Error('Expected the source descriptor to be reused');
}
expect(fs.writeSync(reusedFd, 'still open')).toBe(10);
expect(logger.warn).toHaveBeenCalledTimes(1);
expect(logger.warn).toHaveBeenCalledWith(
{ err: readError },
expect.stringContaining('other logs will continue')
);
expect(requireDeviceRunSessionWorkerApi).not.toHaveBeenCalled();
} finally {
read.mockRestore();
if (reusedFd !== undefined) {
try {
fs.closeSync(reusedFd);
} catch {
// The regression closes this descriptor before the test can clean it up.
}
}
await rm(directory, { recursive: true, force: true });
}
});
it('retries a capped log after an early storage error with real file streams and one artifact', async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), 'android-log-upload-test-'));
const maximumBytes = 20 * 1024 * 1024;
const payload = Buffer.alloc(maximumBytes + 6, 'a');
const filePath = path.join(directory, 'device-emulator.log');
const global = {} as BuildStepGlobalContext;
const logger = createMockLogger();
const capture = registerAndroidEmulatorLogDirectory(global, directory);
const input = Readable.from([payload]);
capture.attach(input, filePath, 'emulator-output');
await once(input, 'end');
let requests = 0;
let receivedBytes = 0;
const server = createServer((request, response) => {
requests++;
if (requests === 1) {
response.statusCode = 503;
response.flushHeaders();
return;
}
request.on('data', chunk => {
receivedBytes += chunk.length;
});
request.on('end', () => response.end());
});
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
try {
const address = server.address();
if (!address || typeof address === 'string') {
throw new Error('Missing test server address');
}
const createArtifactUploadSessionAsync = jest.fn().mockResolvedValue({
url: `http://127.0.0.1:${address.port}/log`,
headers: { 'Content-Length': String(maximumBytes) },
});
jest.mocked(requireDeviceRunSessionWorkerApi).mockReturnValue({
createArtifactUploadSessionAsync,
} as unknown as ReturnType<typeof requireDeviceRunSessionWorkerApi>);
const collect = async (): Promise<void> =>
await collectAndroidEmulatorLogsAsync({} as CustomBuildContext, {
global,
deviceRunSessionId: 'session',
logger,
});
await collect();
await collect();
expect(logger.warn).not.toHaveBeenCalled();
expect(requests).toBe(2);
expect(receivedBytes).toBe(maximumBytes);
expect((await fs.promises.stat(filePath)).size).toBe(maximumBytes);
expect(createArtifactUploadSessionAsync).toHaveBeenCalledTimes(1);
expect(createArtifactUploadSessionAsync).toHaveBeenCalledWith(
expect.objectContaining({
name: expect.stringContaining('truncated, 20 MiB retained'),
size: maximumBytes,
metadata: expect.objectContaining({
originalSizeBytes: payload.length,
omittedBytes: 6,
truncated: true,
}),
}),
expect.objectContaining({ signal: expect.any(AbortSignal) })
);
} finally {
server.closeAllConnections();
await new Promise<void>(resolve => server.close(() => resolve()));
await rm(directory, { recursive: true, force: true });
}
});
@@ -13,7 +13,6 @@ import type { CustomBuildContext } from '../../../customBuildContext';
import { uploadDeviceRunSessionArtifactAsync } from '../deviceRunSessionArtifacts';
import { findUnlistedDeviceScreenRecordingsAsync } from '../deviceRunSessionScreenRecordings';
import { DEVICE_RUN_SESSION_TOKEN_HEADER } from '../deviceRunSessionWorkerApi';
import { uploadDeviceRunSessionFileAsync } from '../uploadDeviceRunSessionFile';
jest.unmock('node-fetch');
jest.unmock('node:fs');
@@ -124,41 +123,6 @@ it('replays the full file after a transient PUT failure without recreating the u
expect(streams.every(stream => stream.destroyed)).toBe(true);
});
it('retries a PUT that fails before reading the body without aborting the file stream', async () => {
let puts = 0;
let replayed = 0;
handle = (request, response) => {
puts++;
if (puts === 1) {
response.statusCode = 503;
response.flushHeaders();
return;
}
request.on('data', chunk => {
replayed += chunk.length;
});
request.on('end', () => response.end());
};
const file = path.join(directory, 'emulator.log');
const payload = Buffer.alloc(8 * 1024 * 1024, '*');
await writeFile(file, payload);
await expect(
uploadDeviceRunSessionFileAsync(ctx, {
filePath: file,
describe: () => ({
deviceRunSessionId: 'drs-id',
artifactId: 'log-id',
name: 'Emulator log',
filename: 'emulator.log',
kind: 'simulator-log',
}),
attempts: 2,
})
).resolves.toBe(true);
expect(puts).toBe(2);
expect(replayed).toBe(payload.length);
});
it('keeps a slow PUT alive while its body is still flowing', async () => {
jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate', 'queueMicrotask'] });
let received = 0;
@@ -22,17 +22,29 @@ it('uploads NDJSON with explicit user-app scope', async () => {
kind: 'simulator-log',
filename: 'app-logs.ndjson',
size: 11,
attempts: 1,
metadata: expect.objectContaining({ scope: 'user-apps', udid: 'A' }),
})
);
});
it('warns and resolves when the upload fails', async () => {
it('skips empty and missing files', async () => {
await uploadServeSimLogsFileAsync(ctx, args);
await writeFile(filePath, '');
await uploadServeSimLogsFileAsync(ctx, args);
expect(uploadDeviceRunSessionArtifactAsync).not.toHaveBeenCalled();
});
it('aborts the upload on a file read error without an unhandled error', async () => {
await writeFile(filePath, '{}\n');
jest
.mocked(uploadDeviceRunSessionArtifactAsync)
.mockRejectedValueOnce(new Error('storage unavailable'));
.mockImplementationOnce(async (_ctx, { openStream, signal }) => {
const aborted = new Promise<void>((_resolve, reject) => {
signal!.addEventListener('abort', () => reject(signal!.reason), { once: true });
});
openStream().destroy(new Error('read failure'));
await aborted;
});
await expect(uploadServeSimLogsFileAsync(ctx, args)).resolves.toBeUndefined();
expect(logger.warn).toHaveBeenCalledWith(
expect.objectContaining({ err: expect.any(Error) }),
@@ -1,79 +0,0 @@
import { mkdtemp, rm, 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 { uploadDeviceRunSessionFileAsync } from '../uploadDeviceRunSessionFile';
jest.mock('../deviceRunSessionArtifacts');
jest.unmock('fs');
jest.unmock('node:fs');
jest.unmock('fs/promises');
jest.unmock('node:fs/promises');
const ctx = {} as CustomBuildContext;
const upload = jest.mocked(uploadDeviceRunSessionArtifactAsync);
const describeFile = (size: number) => ({
deviceRunSessionId: 'session',
artifactId: 'log',
name: `log (${size} bytes)`,
filename: 'log.txt',
kind: 'simulator-log',
});
let directory: string;
let filePath: string;
beforeEach(async () => {
directory = await mkdtemp(path.join(os.tmpdir(), 'device-run-session-file-'));
filePath = path.join(directory, 'log.txt');
});
afterEach(async () => {
await rm(directory, { recursive: true, force: true });
});
function uploadFile(options: { signal?: AbortSignal; attempts?: number } = {}): Promise<boolean> {
return uploadDeviceRunSessionFileAsync(ctx, { filePath, describe: describeFile, ...options });
}
it('skips an empty file without uploading', async () => {
await writeFile(filePath, '');
expect(await uploadFile()).toBe(false);
expect(upload).not.toHaveBeenCalled();
});
it('rejects a missing file before uploading', async () => {
await expect(uploadFile()).rejects.toMatchObject({ code: 'ENOENT' });
expect(upload).not.toHaveBeenCalled();
});
it('describes the file by its size and forwards attempts and the caller signal', async () => {
await writeFile(filePath, 'abc');
const controller = new AbortController();
expect(await uploadFile({ signal: controller.signal, attempts: 2 })).toBe(true);
expect(upload).toHaveBeenCalledWith(
ctx,
expect.objectContaining({ name: 'log (3 bytes)', size: 3, attempts: 2, filename: 'log.txt' })
);
const { signal } = upload.mock.calls[0][1];
const reason = new Error('stop');
controller.abort(reason);
expect(signal?.reason).toBe(reason);
});
it('aborts the upload on a file read error and ignores an aborted request body', async () => {
await writeFile(filePath, '{}\n');
const readError = Object.assign(new Error('read failed'), { code: 'EIO' });
upload.mockImplementationOnce(async (_ctx, { openStream, signal }) => {
const aborted = openStream();
aborted.destroy(Object.assign(new Error('request body aborted'), { name: 'AbortError' }));
await new Promise<void>(resolve => aborted.once('close', resolve));
expect(signal?.aborted).toBe(false);
const failed = openStream();
failed.destroy(readError);
await new Promise<void>(resolve => failed.once('close', resolve));
expect(signal?.reason).toBe(readError);
});
expect(await uploadFile()).toBe(true);
});
@@ -1,43 +1,47 @@
import { type bunyan } from '@expo/logger';
import { type BuildStepGlobalContext } from '@expo/steps';
import { createReadStream, createWriteStream } from 'node:fs';
import { mkdtemp, open, readdir, rm, stat } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { pipeline } from 'node:stream/promises';
import { type CustomBuildContext } from '../../customBuildContext';
import {
AndroidEmulatorLogCapture,
type FinalizedAndroidEmulatorLog,
MAX_ANDROID_LOG_BYTES_PER_SOURCE,
} from '../../utils/androidEmulatorLogCapture';
import { type AndroidEmulatorLogSource } from '../../utils/androidEmulatorLogSources';
import { uploadDeviceRunSessionFileAsync } from './uploadDeviceRunSessionFile';
import { uploadDeviceRunSessionArtifactAsync } from './deviceRunSessionArtifacts';
const MAX_BYTES_PER_FILE = MAX_ANDROID_LOG_BYTES_PER_SOURCE;
const UPLOAD_TIMEOUT_MS = 30_000;
const LOG_SOURCE_DETAILS = {
'emulator-output': { name: 'Emulator output', scope: 'emulator-process' },
'device-logcat': { name: 'Android device logcat', scope: 'device' },
} satisfies Record<AndroidEmulatorLogSource, { name: string; scope: string }>;
type Collection = {
capture: AndroidEmulatorLogCapture;
upload?: Promise<void>;
directories: Set<string>;
uploaded: Set<string>;
collecting?: Promise<void>;
};
// A worker can execute more than one job; each workflow owns its capture and uploads.
// A worker can execute more than one job in its lifetime. Only the workflow that
// created a directory may collect it; never discover logs by scanning /tmp.
const collections = new WeakMap<BuildStepGlobalContext, Collection>();
export function getAndroidEmulatorLogCapture(
export function registerAndroidEmulatorLogDirectory(
global: BuildStepGlobalContext,
env: NodeJS.ProcessEnv
directory: string
): AndroidEmulatorLogCapture {
let collection = collections.get(global);
if (!collection) {
collection = {
capture: new AndroidEmulatorLogCapture(
env.DEVICE_RUN_SESSION_ID ? MAX_ANDROID_LOG_BYTES_PER_SOURCE : Infinity
),
directories: new Set(),
uploaded: new Set(),
capture: new AndroidEmulatorLogCapture(),
};
collections.set(global, collection);
}
collection.directories.add(directory);
return collection.capture;
}
@@ -47,94 +51,186 @@ export async function collectAndroidEmulatorLogsAsync(
global,
deviceRunSessionId,
logger,
maxBytes = MAX_BYTES_PER_FILE,
sources = ['emulator-output'],
}: {
global: BuildStepGlobalContext;
deviceRunSessionId: string;
logger: bunyan;
maxBytes?: number;
sources?: readonly AndroidEmulatorLogSource[];
}
): Promise<void> {
const collection = collections.get(global);
if (!collection) {
logger.info('No Android emulator logs captured; skipping upload.');
logger.info('No Android emulator log directories registered; skipping upload.');
return;
}
// Generated session workflows collect once. Repeated calls share that finalization/upload.
collection.upload ??= uploadCapturedLogsAsync(ctx, {
while (collection.collecting) {
await collection.collecting;
}
collection.collecting = uploadRegisteredLogsAsync(ctx, {
collection,
deviceRunSessionId,
logger,
maxBytes,
sources,
});
await collection.upload;
try {
await collection.collecting;
} finally {
collection.collecting = undefined;
}
}
async function uploadCapturedLogsAsync(
async function uploadRegisteredLogsAsync(
ctx: CustomBuildContext,
{
collection,
deviceRunSessionId,
logger,
maxBytes,
sources,
}: {
collection: Collection;
deviceRunSessionId: string;
logger: bunyan;
maxBytes: number;
sources: readonly AndroidEmulatorLogSource[];
}
): Promise<void> {
const logs = await collection.capture.finishAsync();
for (const log of logs) {
const filename = path.basename(log.filePath);
await collection.capture.finishAsync();
for (const directory of collection.directories) {
try {
const uploaded = await uploadLogAsync(ctx, {
deviceRunSessionId,
log,
signal: AbortSignal.timeout(UPLOAD_TIMEOUT_MS),
});
if (uploaded) {
logger.info(`Uploaded Android diagnostic log ${filename}.`);
const entries = await readdir(directory, { withFileTypes: true });
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
const match = /-(emulator|logcat)\.log$/.exec(entry.name);
if (!entry.isFile() || !match) {
continue;
}
const source = match[1] === 'emulator' ? 'emulator-output' : 'device-logcat';
if (!sources.includes(source)) {
continue;
}
const filePath = path.join(directory, entry.name);
if (collection.uploaded.has(filePath)) {
continue;
}
try {
const uploaded = await uploadLogSnapshotAsync(ctx, {
deviceRunSessionId,
filePath,
bytesSeen: collection.capture.getBytesSeen(filePath),
source,
maxBytes,
signal: AbortSignal.timeout(UPLOAD_TIMEOUT_MS),
logger,
});
if (uploaded) {
collection.uploaded.add(filePath);
logger.info(`Uploaded Android diagnostic log ${entry.name}.`);
}
} catch (err) {
logger.warn(
{ err },
`Could not upload Android log ${entry.name}; other logs will continue.`
);
}
}
} catch (err) {
logger.warn({ err }, `Could not upload Android log ${filename}; other logs will continue.`);
logger.warn({ err }, `Could not read Android emulator log directory ${directory}.`);
}
}
}
async function uploadLogAsync(
async function uploadLogSnapshotAsync(
ctx: CustomBuildContext,
{
deviceRunSessionId,
log: { filePath, source, bytesSeen },
filePath,
bytesSeen,
source,
maxBytes,
signal,
logger,
}: {
deviceRunSessionId: string;
log: FinalizedAndroidEmulatorLog;
filePath: string;
bytesSeen?: number;
source: AndroidEmulatorLogSource;
maxBytes: number;
signal: AbortSignal;
logger: bunyan;
}
): Promise<boolean> {
const filename = path.basename(filePath);
return await uploadDeviceRunSessionFileAsync(ctx, {
filePath,
describe: size => {
const retainedSize =
size >= 1024 * 1024 ? `${Number((size / (1024 * 1024)).toFixed(2))} MiB` : `${size} bytes`;
const truncationNote = size < bytesSeen ? `; truncated, last ${retainedSize} retained` : '';
return {
deviceRunSessionId,
artifactId: filename,
name: `${LOG_SOURCE_DETAILS[source].name} (${filename}${truncationNote})`,
filename,
kind: 'simulator-log',
metadata: {
__eas_type: 'simulator-log',
platform: 'android',
source,
scope: LOG_SOURCE_DETAILS[source].scope,
originalSizeBytes: bytesSeen,
// The capture keeps the newest bytes, so the omitted bytes are the head.
omittedBytes: bytesSeen - size,
truncated: size < bytesSeen,
},
};
},
signal,
attempts: 2,
});
const file = await open(filePath, 'r');
let snapshotDirectory: string | undefined;
try {
const { size: fileSizeBytes } = await file.stat();
const originalSizeBytes = bytesSeen ?? fileSizeBytes;
if (fileSizeBytes === 0) {
return false;
}
snapshotDirectory = await mkdtemp(path.join(os.tmpdir(), 'eas-android-log-snapshot-'));
const filename = path.basename(filePath);
const snapshotPath = path.join(snapshotDirectory, filename);
const end = Math.min(fileSizeBytes, maxBytes) - 1;
// The emulator may still append. Fix both the range and upload contents now,
// so Content-Length and every retry describe the same snapshot.
await pipeline(
createReadStream(filePath, {
fd: file,
autoClose: false,
start: 0,
end,
}),
createWriteStream(snapshotPath),
{ signal }
);
const { size } = await stat(snapshotPath);
if (size === 0) {
return false;
}
const readController = new AbortController();
const retainedSize =
size >= 1024 * 1024 ? `${Number((size / (1024 * 1024)).toFixed(2))} MiB` : `${size} bytes`;
const truncationNote = size < originalSizeBytes ? `; truncated, ${retainedSize} retained` : '';
await uploadDeviceRunSessionArtifactAsync(ctx, {
deviceRunSessionId,
artifactId: filename,
name: `${source === 'emulator-output' ? 'Emulator output' : 'Android device logcat'} (${filename.replace(/-(emulator|logcat)\.log$/, '')}${truncationNote})`,
filename,
kind: 'simulator-log',
metadata: {
__eas_type: 'simulator-log',
platform: 'android',
source,
scope: source === 'emulator-output' ? 'emulator-process' : 'device',
originalSizeBytes,
omittedBytes: originalSizeBytes - size,
truncated: size < originalSizeBytes,
},
size,
openStream: () =>
createReadStream(snapshotPath).on('error', err => {
// The uploader aborts an unfinished body after an HTTP error. That
// cancels one attempt, not the file's remaining upload attempts.
if (err.name !== 'AbortError') {
readController.abort(err);
}
}),
signal: AbortSignal.any([signal, readController.signal]),
attempts: 2,
});
return true;
} finally {
await file.close().catch(err => {
logger.warn({ err }, `Could not close Android log ${filePath}.`);
});
if (snapshotDirectory) {
await rm(snapshotDirectory, { recursive: true, force: true }).catch(err => {
logger.warn({ err }, 'Could not remove the Android log upload snapshot.');
});
}
}
}
@@ -11,7 +11,7 @@ import {
import { withDeviceRunSessionTimeoutAsync } from './deviceRunSessionTimeout';
import { requireDeviceRunSessionWorkerApi } from './deviceRunSessionWorkerApi';
export type ArtifactMetadata = {
type ArtifactMetadata = {
deviceRunSessionId: string;
artifactId: string;
name: string;
@@ -1,7 +1,9 @@
import { type bunyan } from '@expo/logger';
import { createReadStream } from 'node:fs';
import { stat } from 'node:fs/promises';
import { type CustomBuildContext } from '../../customBuildContext';
import { uploadDeviceRunSessionFileAsync } from './uploadDeviceRunSessionFile';
import { uploadDeviceRunSessionArtifactAsync } from './deviceRunSessionArtifacts';
export async function uploadServeSimLogsFileAsync(
ctx: CustomBuildContext,
@@ -20,22 +22,21 @@ export async function uploadServeSimLogsFileAsync(
}
): Promise<void> {
try {
await uploadDeviceRunSessionFileAsync(ctx, {
filePath,
describe: () => ({
deviceRunSessionId,
artifactId: `simulator-log-${udid}`,
name: `App logs (${udid.slice(0, 8)})`,
filename: 'app-logs.ndjson',
kind: 'simulator-log',
metadata: {
__eas_type: 'simulator-log',
udid,
scope: 'user-apps',
source: 'serve-sim/logs',
},
}),
signal,
const { size } = await stat(filePath);
if (size === 0) {
return;
}
const fileController = new AbortController();
await uploadDeviceRunSessionArtifactAsync(ctx, {
deviceRunSessionId,
artifactId: `simulator-log-${udid}`,
name: `App logs (${udid.slice(0, 8)})`,
filename: 'app-logs.ndjson',
kind: 'simulator-log',
metadata: { __eas_type: 'simulator-log', udid, scope: 'user-apps', source: 'serve-sim/logs' },
size,
openStream: () => createReadStream(filePath).on('error', err => fileController.abort(err)),
signal: AbortSignal.any([fileController.signal, ...(signal ? [signal] : [])]),
});
} catch (err) {
logger.warn(
@@ -1,44 +0,0 @@
import { createReadStream } from 'node:fs';
import { stat } from 'node:fs/promises';
import { type CustomBuildContext } from '../../customBuildContext';
import {
type ArtifactMetadata,
uploadDeviceRunSessionArtifactAsync,
} from './deviceRunSessionArtifacts';
/** Upload a finalized file, reopening it for each attempt. */
export async function uploadDeviceRunSessionFileAsync(
ctx: CustomBuildContext,
{
filePath,
describe,
signal,
attempts = 1,
}: {
filePath: string;
describe: (size: number) => Omit<ArtifactMetadata, 'size'>;
signal?: AbortSignal;
attempts?: number;
}
): Promise<boolean> {
const { size } = await stat(filePath);
if (size === 0) {
return false;
}
const readController = new AbortController();
await uploadDeviceRunSessionArtifactAsync(ctx, {
...describe(size),
size,
openStream: () =>
createReadStream(filePath).on('error', err => {
// An HTTP error aborts one request body, not the remaining upload attempts.
if (err.name !== 'AbortError') {
readController.abort(err);
}
}),
signal: AbortSignal.any([readController.signal, ...(signal ? [signal] : [])]),
attempts,
});
return true;
}
@@ -13,7 +13,10 @@ import { setTimeout } from 'node:timers/promises';
import { z } from 'zod';
import { Sentry } from '../sentry';
import { type AndroidEmulatorLogSource } from './androidEmulatorLogSources';
import {
type AndroidEmulatorLogSource,
LEGACY_ANDROID_EMULATOR_LOG_SOURCES,
} from './androidEmulatorLogSources';
import { type AndroidEmulatorLogCapture } from './androidEmulatorLogCapture';
import { retryAsync } from './retry';
@@ -328,19 +331,21 @@ export namespace AndroidEmulatorUtils {
deviceName,
env,
logcatDirectory,
logSources,
logSources = LEGACY_ANDROID_EMULATOR_LOG_SOURCES,
launchGate,
logCapture,
}: {
deviceName: AndroidVirtualDeviceName;
env: NodeJS.ProcessEnv;
logcatDirectory: string;
logSources: readonly AndroidEmulatorLogSource[];
logSources?: readonly AndroidEmulatorLogSource[];
launchGate?: AndroidEmulatorLaunchGate;
logCapture: AndroidEmulatorLogCapture;
logCapture?: AndroidEmulatorLogCapture;
}): Promise<{
emulatorPromise: SpawnPromise<SpawnResult>;
serialId: AndroidDeviceSerialId;
logcatOutputPath: string | undefined;
emulatorOutputPath: string | undefined;
}> {
let logcatOutputPath: string | undefined;
let emulatorOutputPath: string | undefined;
@@ -365,9 +370,10 @@ export namespace AndroidEmulatorUtils {
});
}
const logcatOutput = logcatOutputPath
? await logCapture.prepareLogcatOutputAsync(logcatOutputPath)
: undefined;
const logcatPipe =
logCapture && logcatOutputPath
? await logCapture.createLogcatPipeAsync(logcatOutputPath)
: undefined;
const emulatorCommand = `${process.env.ANDROID_HOME}/emulator/emulator`;
const emulatorArgs = [
'-no-window',
@@ -375,7 +381,9 @@ export namespace AndroidEmulatorUtils {
'-writable-system',
'-noaudio',
'-no-snapshot-save',
...(logcatOutput ? ['-logcat', '*:v', '-logcat-output', logcatOutput.path] : []),
...(logcatOutputPath
? ['-logcat', '*:v', '-logcat-output', logcatPipe?.path ?? logcatOutputPath]
: []),
'-avd',
deviceName,
'-accel',
@@ -407,10 +415,10 @@ export namespace AndroidEmulatorUtils {
ignoreStdio: true,
env: emulatorEnv,
});
if (logcatOutput) {
emulatorPromise.child.once('close', logcatOutput.close);
if (logcatPipe) {
emulatorPromise.child.once('close', logcatPipe.close);
}
if (emulatorOutputPath) {
if (emulatorOutputPath && logCapture) {
for (const input of [emulatorPromise.child.stdout, emulatorPromise.child.stderr]) {
if (input) {
logCapture.attach(input, emulatorOutputPath, 'emulator-output');
@@ -419,6 +427,33 @@ export namespace AndroidEmulatorUtils {
emulatorPromise.child.once('close', () => {
void logCapture.finishFileAsync(emulatorOutputPath);
});
} else if (emulatorOutputPath) {
const emulatorOutputStream = fs.createWriteStream(emulatorOutputPath, { flags: 'a' });
let reportedEmulatorOutputError = false;
emulatorOutputStream.on('error', err => {
process.stderr.write(
`Failed to write Android emulator output to ${emulatorOutputPath}: ${err}\n`
);
if (!reportedEmulatorOutputError) {
reportedEmulatorOutputError = true;
Sentry.capture('Failed to write Android emulator process output', err, {
level: 'warning',
tags: {
errorCode: (err as NodeJS.ErrnoException).code ?? 'unknown',
},
extras: {
deviceName,
emulatorOutputPath,
},
});
}
});
// Keep process output out of the build log. Device sessions upload this file during cleanup.
emulatorPromise.child.stdout?.pipe(emulatorOutputStream, { end: false });
emulatorPromise.child.stderr?.pipe(emulatorOutputStream, { end: false });
emulatorPromise.child.once('close', () => {
emulatorOutputStream.end();
});
}
// Piped stdio creates socket handles in this process which, unlike inherited
// file descriptors, keep the event loop alive for as long as the emulator runs.
@@ -469,7 +504,7 @@ export namespace AndroidEmulatorUtils {
// We don't want to await the SpawnPromise here.
// eslint-disable-next-line @typescript-eslint/return-await
return { emulatorPromise, serialId };
return { emulatorPromise, serialId, logcatOutputPath, emulatorOutputPath };
}
export async function waitForReadyAsync({
@@ -13,8 +13,6 @@ import {
AndroidVirtualDeviceName,
} from '../AndroidEmulatorUtils';
import { retryAsync } from '../retry';
import { AndroidEmulatorLogCapture } from '../androidEmulatorLogCapture';
import { ALL_ANDROID_EMULATOR_LOG_SOURCES } from '../androidEmulatorLogSources';
// We need to use real fs for cloning devices to work.
jest.unmock('fs');
@@ -80,8 +78,6 @@ describe('AndroidEmulatorUtils', () => {
logger: createMockLogger({ logToConsole: true }),
});
({ serialId } = await AndroidEmulatorUtils.startAsync({
logCapture: new AndroidEmulatorLogCapture(Infinity),
logSources: ALL_ANDROID_EMULATOR_LOG_SOURCES,
deviceName,
env: { ...process.env, ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL: '1' },
logcatDirectory,
@@ -109,8 +105,6 @@ describe('AndroidEmulatorUtils', () => {
logger: createMockLogger({ logToConsole: true }),
});
const { serialId, emulatorPromise } = await AndroidEmulatorUtils.startAsync({
logCapture: new AndroidEmulatorLogCapture(Infinity),
logSources: ALL_ANDROID_EMULATOR_LOG_SOURCES,
deviceName,
env: { ...process.env, ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL: '1' },
logcatDirectory,
@@ -141,8 +135,6 @@ describe('AndroidEmulatorUtils', () => {
const { serialId: serialIdClone, emulatorPromise: emulatorPromiseClone } =
await AndroidEmulatorUtils.startAsync({
logCapture: new AndroidEmulatorLogCapture(Infinity),
logSources: ALL_ANDROID_EMULATOR_LOG_SOURCES,
deviceName: cloneDeviceName,
env: { ...process.env, ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL: '1' },
logcatDirectory,
@@ -180,8 +172,6 @@ describe('AndroidEmulatorUtils', () => {
});
const { serialId, emulatorPromise } = await AndroidEmulatorUtils.startAsync({
logCapture: new AndroidEmulatorLogCapture(Infinity),
logSources: ALL_ANDROID_EMULATOR_LOG_SOURCES,
deviceName,
env: { ...process.env, ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL: '1' },
logcatDirectory,
@@ -236,8 +226,6 @@ describe('AndroidEmulatorUtils', () => {
});
const startResult = await AndroidEmulatorUtils.startAsync({
logCapture: new AndroidEmulatorLogCapture(Infinity),
logSources: ALL_ANDROID_EMULATOR_LOG_SOURCES,
deviceName,
env: envForAttempt,
logcatDirectory,
@@ -321,8 +309,6 @@ describe('AndroidEmulatorUtils', () => {
logger,
});
const startResult = await AndroidEmulatorUtils.startAsync({
logCapture: new AndroidEmulatorLogCapture(Infinity),
logSources: ALL_ANDROID_EMULATOR_LOG_SOURCES,
deviceName,
env: { ...process.env, ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL: '1' },
logcatDirectory: testLogcatDirectory,
@@ -333,16 +319,7 @@ describe('AndroidEmulatorUtils', () => {
await AndroidEmulatorUtils.waitForReadyAsync({ serialId, env: process.env });
await retryAsync(
async () => {
const filename = (await fs.promises.readdir(testLogcatDirectory)).find(file =>
file.endsWith('-emulator.log')
);
if (!filename) {
throw new Error('Missing emulator output');
}
const contents = await fs.promises.readFile(
path.join(testLogcatDirectory, filename),
'utf-8'
);
const contents = await fs.promises.readFile(startResult.emulatorOutputPath!, 'utf-8');
if (!contents.includes('Android emulator version')) {
throw new Error('Did not find the emulator version in emulator process output yet.');
}
@@ -363,16 +340,7 @@ describe('AndroidEmulatorUtils', () => {
await retryAsync(
async () => {
const filename = (await fs.promises.readdir(testLogcatDirectory)).find(file =>
file.endsWith('-logcat.log')
);
if (!filename) {
throw new Error('Missing logcat output');
}
const contents = await fs.promises.readFile(
path.join(testLogcatDirectory, filename),
'utf-8'
);
const contents = await fs.promises.readFile(startResult.logcatOutputPath!, 'utf-8');
if (!contents.includes(marker)) {
throw new Error(`Did not find marker ${marker} in logcat output yet.`);
}
@@ -416,8 +384,6 @@ describe('AndroidEmulatorUtils', () => {
});
const { serialId, emulatorPromise } = await AndroidEmulatorUtils.startAsync({
logCapture: new AndroidEmulatorLogCapture(Infinity),
logSources: ALL_ANDROID_EMULATOR_LOG_SOURCES,
deviceName,
env: { ...process.env, ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL: '1' },
logcatDirectory,
@@ -1,6 +1,6 @@
import { SystemError } from '@expo/eas-build-job';
import spawn from '@expo/turtle-spawn';
import { EventEmitter } from 'node:events';
import { EventEmitter, once } from 'node:events';
import fs from 'node:fs';
import { Socket } from 'node:net';
import os from 'node:os';
@@ -15,15 +15,7 @@ import {
} from '../AndroidEmulatorUtils';
import { retryAsync } from '../retry';
import { AndroidEmulatorLogCapture } from '../androidEmulatorLogCapture';
import {
ALL_ANDROID_EMULATOR_LOG_SOURCES,
AndroidEmulatorLogSourcesSchema,
} from '../androidEmulatorLogSources';
jest.unmock('fs');
jest.unmock('node:fs');
jest.unmock('fs/promises');
jest.unmock('node:fs/promises');
import { AndroidEmulatorLogSourcesSchema } from '../androidEmulatorLogSources';
jest.mock('@expo/turtle-spawn', () => ({
__esModule: true,
@@ -45,11 +37,9 @@ const mockedRetryAsync = jest.mocked(retryAsync);
describe('AndroidEmulatorUtils', () => {
let temporaryDirectories: string[] = [];
let homeDirectory: string;
beforeEach(async () => {
homeDirectory = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'android-utils-home-'));
temporaryDirectories = [homeDirectory];
beforeEach(() => {
temporaryDirectories = [];
jest.mocked(Sentry.capture).mockReset();
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
mockedRetryAsync.mockImplementation(async fn => await fn(0));
@@ -67,7 +57,7 @@ describe('AndroidEmulatorUtils', () => {
describe(AndroidEmulatorUtils.createAsync, () => {
it('applies an explicit LCD configuration after screen scaling', async () => {
const deviceName = 'eas-simulator' as AndroidVirtualDeviceName;
const avdDirectory = `${homeDirectory}/.android/avd/${deviceName}.avd`;
const avdDirectory = `/home/expo/.android/avd/${deviceName}.avd`;
await fs.promises.mkdir(avdDirectory, { recursive: true });
await fs.promises.writeFile(
`${avdDirectory}/config.ini`,
@@ -95,7 +85,7 @@ describe('AndroidEmulatorUtils', () => {
lcdHeight: 1600,
lcdDensity: 262,
env: {
HOME: homeDirectory,
HOME: '/home/expo',
ANDROID_EMULATOR_ADJUST_SCREEN: '1',
ANDROID_EMULATOR_ADJUST_HEAP_SIZE: '0',
},
@@ -153,7 +143,7 @@ describe('AndroidEmulatorUtils', () => {
['device-run-session-id', 4096],
])('configures memory for session %s without an LCD override', async (sessionId, ramSize) => {
const deviceName = 'generic-emulator' as AndroidVirtualDeviceName;
const avdDirectory = `${homeDirectory}/.android/avd/${deviceName}.avd`;
const avdDirectory = `/home/expo/.android/avd/${deviceName}.avd`;
const initialConfig = ['skin.path=pixel_9', 'showDeviceFrame=yes'].join('\n');
await fs.promises.mkdir(avdDirectory, { recursive: true });
await fs.promises.writeFile(`${avdDirectory}/config.ini`, initialConfig);
@@ -169,7 +159,7 @@ describe('AndroidEmulatorUtils', () => {
lcdHeight: null,
lcdDensity: null,
env: {
HOME: homeDirectory,
HOME: '/home/expo',
ANDROID_EMULATOR_ADJUST_HEAP_SIZE: '0',
DEVICE_RUN_SESSION_ID: sessionId,
},
@@ -184,24 +174,6 @@ describe('AndroidEmulatorUtils', () => {
});
describe(AndroidEmulatorUtils.startAsync, () => {
let buildCapture: AndroidEmulatorLogCapture;
beforeEach(() => {
buildCapture = new AndroidEmulatorLogCapture(Infinity);
});
afterEach(async () => {
await buildCapture.finishAsync();
});
async function logFile(directory: string, source: 'logcat' | 'emulator'): Promise<string> {
const filename = (await fs.promises.readdir(directory)).find(file =>
file.endsWith(`-${source}.log`)
);
if (!filename) {
throw new Error(`Missing ${source} log in ${directory}`);
}
return path.join(directory, filename);
}
function mockSuccessfulStart(deviceName: AndroidVirtualDeviceName) {
// Under `stdio: 'pipe'` these are net.Sockets, which is what startAsync
// narrows on before unref-ing them.
@@ -232,30 +204,35 @@ describe('AndroidEmulatorUtils', () => {
return { child };
}
it('names log files after the device and detaches the emulator', async () => {
it('captures logcat and emulator process output', async () => {
const outputDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'logcat-staging-'));
temporaryDirectories.push(outputDir);
const deviceName = 'eas-simulator' as AndroidVirtualDeviceName;
const { child } = mockSuccessfulStart(deviceName);
const createWriteStreamSpy = jest.spyOn(fs, 'createWriteStream');
await AndroidEmulatorUtils.startAsync({
logSources: ALL_ANDROID_EMULATOR_LOG_SOURCES,
logCapture: buildCapture,
const result = await AndroidEmulatorUtils.startAsync({
deviceName,
env: process.env,
logcatDirectory: outputDir,
});
expect(await logFile(outputDir, 'logcat')).toMatch(
expect(result.logcatOutputPath).toMatch(
new RegExp(`^${outputDir}/eas-simulator-[a-f0-9]{8}-[a-f0-9]{4}-logcat\\.log$`)
);
expect(await logFile(outputDir, 'emulator')).toMatch(
expect(result.emulatorOutputPath).toMatch(
new RegExp(`^${outputDir}/eas-simulator-[a-f0-9]{8}-[a-f0-9]{4}-emulator\\.log$`)
);
await expect(fs.promises.access(result.logcatOutputPath!)).resolves.toBeUndefined();
await expect(fs.promises.access(result.emulatorOutputPath!)).resolves.toBeUndefined();
expect(mockedSpawn).toHaveBeenCalledWith(
expect.stringMatching(/\/emulator\/emulator$/),
expect.any(Array),
expect.objectContaining({ detached: true, ignoreStdio: true })
expect.arrayContaining(['-logcat', '*:v', '-logcat-output', result.logcatOutputPath]),
expect.objectContaining({
detached: true,
stdio: ['ignore', 'pipe', 'pipe'],
ignoreStdio: true,
})
);
expect(mockedSpawn).not.toHaveBeenCalledWith(
'adb',
@@ -267,7 +244,103 @@ describe('AndroidEmulatorUtils', () => {
// for as long as the emulator runs.
expect(child.stdout.unref).toHaveBeenCalled();
expect(child.stderr.unref).toHaveBeenCalled();
const emulatorOutputStream = createWriteStreamSpy.mock.results[0].value;
const stderrWriteSpy = jest.spyOn(process.stderr, 'write').mockImplementation(() => true);
const writeError = Object.assign(new Error('disk full'), { code: 'ENOSPC' });
emulatorOutputStream.emit('error', writeError);
emulatorOutputStream.emit('error', writeError);
expect(stderrWriteSpy).toHaveBeenCalledTimes(2);
expect(Sentry.capture).toHaveBeenCalledTimes(1);
expect(Sentry.capture).toHaveBeenCalledWith(
'Failed to write Android emulator process output',
writeError,
{
level: 'warning',
tags: { errorCode: 'ENOSPC' },
extras: {
deviceName,
emulatorOutputPath: result.emulatorOutputPath,
},
}
);
const emulatorOutputStreamClosed = once(emulatorOutputStream, 'close');
child.emit('close');
await emulatorOutputStreamClosed;
});
describe.each([false, true])('source selection with launch gate %s', gated => {
it.each([
{ sources: [] },
{ sources: ['emulator-output'] },
{ sources: ['device-logcat'] },
{ sources: ['emulator-output', 'device-logcat'] },
])('records only $sources', async ({ sources }) => {
const outputDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'selected-logs-'));
temporaryDirectories.push(outputDir);
const deviceName = 'selected-emulator' as AndroidVirtualDeviceName;
const { child } = mockSuccessfulStart(deviceName);
const stdin = { end: jest.fn() };
Object.assign(child, { stdin });
const directSpawn = mockedSpawn.getMockImplementation()!;
mockedSpawn.mockImplementation(((command: string, args: string[], options: object) =>
directSpawn(
command === 'bash' ? '/android/emulator/emulator' : command,
args,
options as any
)) as any);
const createWriteStreamSpy = jest.spyOn(fs, 'createWriteStream');
const stdoutPipe = jest.spyOn(child.stdout, 'pipe');
const stderrPipe = jest.spyOn(child.stderr, 'pipe');
const result = await AndroidEmulatorUtils.startAsync({
deviceName,
env: {},
logcatDirectory: outputDir,
logSources: AndroidEmulatorLogSourcesSchema.parse(sources),
...(gated
? {
launchGate: {
emulatorArgs: [],
wrapperCommand: 'bash',
wrapperArgs: [],
admitAsync: async () => {},
},
}
: {}),
});
const capturesOutput = sources.includes('emulator-output');
const capturesLogcat = sources.includes('device-logcat');
expect(result.logcatOutputPath !== undefined).toBe(capturesLogcat);
expect(result.emulatorOutputPath !== undefined).toBe(capturesOutput);
const [, args, options] = mockedSpawn.mock.calls[0];
expect(args.includes('-logcat')).toBe(capturesLogcat);
expect(args.includes('-logcat-output')).toBe(capturesLogcat);
expect(options?.stdio).toEqual([
gated ? 'pipe' : 'ignore',
capturesOutput ? 'pipe' : 'ignore',
capturesOutput ? 'pipe' : 'ignore',
]);
expect(stdoutPipe).toHaveBeenCalledTimes(capturesOutput ? 1 : 0);
expect(stderrPipe).toHaveBeenCalledTimes(capturesOutput ? 1 : 0);
expect(createWriteStreamSpy).toHaveBeenCalledTimes(capturesOutput ? 1 : 0);
if (capturesOutput) {
const stream = createWriteStreamSpy.mock.results[0].value;
child.stdout.emit('data', Buffer.from('stdout marker\n'));
child.stderr.emit('data', Buffer.from('stderr marker\n'));
const closed = once(stream, 'close');
child.emit('close');
await closed;
await expect(fs.promises.readFile(result.emulatorOutputPath!, 'utf8')).resolves.toBe(
'stdout marker\nstderr marker\n'
);
}
const files = await fs.promises.readdir(outputDir);
expect(files).toHaveLength(sources.length);
expect(files.some(file => file.endsWith('-logcat.log'))).toBe(capturesLogcat);
expect(files.some(file => file.endsWith('-emulator.log'))).toBe(capturesOutput);
if (gated) {
expect(stdin.end).toHaveBeenCalledWith('go\n');
}
});
});
it.each([
@@ -275,77 +348,37 @@ describe('AndroidEmulatorUtils', () => {
{ sources: ['emulator-output'] },
{ sources: ['device-logcat'] },
{ sources: ['emulator-output', 'device-logcat'] },
])('records only $sources', async ({ sources }) => {
const outputDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'selected-logs-'));
temporaryDirectories.push(outputDir);
const deviceName = 'selected-emulator' as AndroidVirtualDeviceName;
const { child } = mockSuccessfulStart(deviceName);
const stdoutPipe = jest.spyOn(child.stdout, 'pipe');
const stderrPipe = jest.spyOn(child.stderr, 'pipe');
const prepare = jest.spyOn(buildCapture, 'prepareLogcatOutputAsync');
await AndroidEmulatorUtils.startAsync({
logCapture: buildCapture,
deviceName,
env: {},
logcatDirectory: outputDir,
logSources: AndroidEmulatorLogSourcesSchema.parse(sources),
});
const capturesOutput = sources.includes('emulator-output');
const capturesLogcat = sources.includes('device-logcat');
const [, args, options] = mockedSpawn.mock.calls[0];
expect(prepare).toHaveBeenCalledTimes(capturesLogcat ? 1 : 0);
expect(args.includes('-logcat')).toBe(capturesLogcat);
expect(args.includes('-logcat-output')).toBe(capturesLogcat);
expect(options?.stdio).toEqual([
'ignore',
capturesOutput ? 'pipe' : 'ignore',
capturesOutput ? 'pipe' : 'ignore',
]);
expect(stdoutPipe).toHaveBeenCalledTimes(capturesOutput ? 1 : 0);
expect(stderrPipe).toHaveBeenCalledTimes(capturesOutput ? 1 : 0);
if (capturesOutput) {
child.stdout.emit('data', Buffer.from('stdout marker\n'));
child.stderr.emit('data', Buffer.from('stderr marker\n'));
child.emit('close');
await buildCapture.finishAsync();
await expect(
fs.promises.readFile(await logFile(outputDir, 'emulator'), 'utf8')
).resolves.toBe('stdout marker\nstderr marker\n');
}
const files = await fs.promises.readdir(outputDir);
expect(files).toHaveLength(sources.length);
expect(files.some(file => file.endsWith('-logcat.log'))).toBe(capturesLogcat);
expect(files.some(file => file.endsWith('-emulator.log'))).toBe(capturesOutput);
});
it('routes a session logcat through the capture pipe and closes it with the emulator', async () => {
])('bounds only selected session sources: $sources', async ({ sources }) => {
const outputDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'bounded-logs-'));
temporaryDirectories.push(outputDir);
const deviceName = 'bounded-emulator' as AndroidVirtualDeviceName;
const { child } = mockSuccessfulStart(deviceName);
const capture = new AndroidEmulatorLogCapture();
const capture = new AndroidEmulatorLogCapture(10);
const close = jest.fn();
const createPipe = jest.spyOn(capture, 'prepareLogcatOutputAsync').mockResolvedValue({
const createPipe = jest.spyOn(capture, 'createLogcatPipeAsync').mockResolvedValue({
path: '/logcat.pipe',
close,
closed: Promise.resolve(),
});
await AndroidEmulatorUtils.startAsync({
const result = await AndroidEmulatorUtils.startAsync({
deviceName,
env: {},
logcatDirectory: outputDir,
logSources: ['device-logcat'],
logSources: AndroidEmulatorLogSourcesSchema.parse(sources),
logCapture: capture,
});
expect(createPipe).toHaveBeenCalledWith(await logFile(outputDir, 'logcat'));
expect(mockedSpawn.mock.calls[0][1]).toEqual(
expect.arrayContaining(['-logcat-output', '/logcat.pipe'])
);
expect(close).not.toHaveBeenCalled();
const hasLogcat = sources.includes('device-logcat');
expect(createPipe).toHaveBeenCalledTimes(hasLogcat ? 1 : 0);
expect(mockedSpawn.mock.calls[0][1].includes('/logcat.pipe')).toBe(hasLogcat);
child.stdout.emit('data', Buffer.from('first stdout'));
child.stderr.emit('data', Buffer.from('later stderr'));
child.emit('close');
await capture.finishAsync();
expect(close).toHaveBeenCalledTimes(1);
expect(await fs.promises.readdir(outputDir)).toHaveLength(1);
expect(close).toHaveBeenCalledTimes(hasLogcat ? 1 : 0);
if (sources.includes('emulator-output')) {
expect(await fs.promises.readFile(result.emulatorOutputPath!, 'utf8')).toBe('first stdo');
}
expect(await fs.promises.readdir(outputDir)).toHaveLength(sources.length);
});
it('spawns the emulator directly with the same arguments when there is no launch gate', async () => {
@@ -355,9 +388,7 @@ describe('AndroidEmulatorUtils', () => {
mockSuccessfulStart(deviceName);
const env = { ANDROID_EMULATOR_EXTRA_ARGS: '-gpu swiftshader_indirect' };
await AndroidEmulatorUtils.startAsync({
logSources: ALL_ANDROID_EMULATOR_LOG_SOURCES,
logCapture: buildCapture,
const result = await AndroidEmulatorUtils.startAsync({
deviceName,
env,
logcatDirectory: outputDir,
@@ -378,7 +409,7 @@ describe('AndroidEmulatorUtils', () => {
'-logcat',
'*:v',
'-logcat-output',
await logFile(outputDir, 'logcat'),
result.logcatOutputPath,
'-avd',
deviceName,
'-accel',
@@ -415,9 +446,7 @@ describe('AndroidEmulatorUtils', () => {
: gatedSpawn(command, args, options as any)) as any);
const admitAsync = jest.fn().mockResolvedValue(undefined);
await AndroidEmulatorUtils.startAsync({
logSources: ALL_ANDROID_EMULATOR_LOG_SOURCES,
logCapture: buildCapture,
const result = await AndroidEmulatorUtils.startAsync({
deviceName,
env: {},
logcatDirectory: outputDir,
@@ -444,7 +473,7 @@ describe('AndroidEmulatorUtils', () => {
'-logcat',
'*:v',
'-logcat-output',
await logFile(outputDir, 'logcat'),
result.logcatOutputPath,
'-avd',
deviceName,
'-accel',
@@ -481,8 +510,6 @@ describe('AndroidEmulatorUtils', () => {
await expect(
AndroidEmulatorUtils.startAsync({
logSources: ALL_ANDROID_EMULATOR_LOG_SOURCES,
logCapture: buildCapture,
deviceName,
env: {},
logcatDirectory: outputDir,
@@ -507,8 +534,6 @@ describe('AndroidEmulatorUtils', () => {
try {
await expect(
AndroidEmulatorUtils.startAsync({
logSources: ALL_ANDROID_EMULATOR_LOG_SOURCES,
logCapture: buildCapture,
deviceName,
env: process.env,
logcatDirectory: outputDir,
@@ -1,6 +1,6 @@
import { spawn } from 'node:child_process';
import { once } from 'node:events';
import fs, { type FileHandle } from 'node:fs/promises';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { PassThrough, Readable } from 'node:stream';
@@ -37,123 +37,21 @@ async function record(
await capture.finishFileAsync(file);
}
async function waitForSize(file: string, size: number): Promise<void> {
const deadline = Date.now() + 2_000;
while ((await fs.stat(file)).size !== size && Date.now() < deadline) {
await new Promise(resolve => setTimeout(resolve, 5));
}
expect((await fs.stat(file)).size).toBe(size);
}
function trackOpenedFiles(): FileHandle[] {
const opened: FileHandle[] = [];
const open = fs.open;
jest.spyOn(fs, 'open').mockImplementation(async (file, flags, mode) => {
const handle = await open(file, flags, mode);
opened.push(handle);
return handle;
});
return opened;
}
it('keeps the newest bytes of a source across launches, with independent budgets for the two sources', async () => {
it('shares a source budget across launches, with independent budgets for the two sources', async () => {
const capture = new AndroidEmulatorLogCapture(10);
const first = path.join(directory, 'first.log');
const retry = path.join(directory, 'retry.log');
const device = path.join(directory, 'logcat.log');
await record(capture, first, 'first!');
await record(capture, retry, 'second!!');
await record(capture, retry, 'second log is too long');
const logcat = Readable.from([Buffer.from('device logs are separate')]);
capture.attach(logcat, device, 'device-logcat');
await once(logcat, 'end');
// A partially evicted file keeps its size on disk until it is sealed.
expect((await fs.stat(first)).size).toBe(6);
const logs = await capture.finishAsync();
expect(logs).toEqual([
{ filePath: first, source: 'emulator-output', bytesSeen: 6 },
{ filePath: retry, source: 'emulator-output', bytesSeen: 8 },
{ filePath: device, source: 'device-logcat', bytesSeen: 24 },
]);
// The retry evicts the head of the earlier launch; its own tail stays whole.
expect(await fs.readFile(first, 'utf8')).toBe('t!');
expect(await fs.readFile(retry, 'utf8')).toBe('second!!');
expect(await fs.readFile(device, 'utf8')).toBe('e separate');
});
it('frees a finished file on disk once a later launch evicts all of it', async () => {
const capture = new AndroidEmulatorLogCapture(10);
const first = path.join(directory, 'first.log');
const retry = path.join(directory, 'retry.log');
await record(capture, first, 'first!');
const input = new PassThrough();
capture.attach(input, retry, 'emulator-output');
input.write('second log is too long');
await waitForSize(first, 0);
await capture.finishAsync();
expect(await fs.readFile(first, 'utf8')).toBe('');
expect(await fs.readFile(retry, 'utf8')).toBe('s too long');
});
it('frees a fully evicted live capture on disk and records its later output', async () => {
const capture = new AndroidEmulatorLogCapture(10);
const first = path.join(directory, 'first.log');
const second = path.join(directory, 'second.log');
const a = new PassThrough();
const b = new PassThrough();
capture.attach(a, first, 'emulator-output');
capture.attach(b, second, 'emulator-output');
// Wait between writes: two streams deliver their first chunks in either order.
a.write('0123456789');
await waitForSize(first, 10);
b.write('abcdefghij');
await waitForSize(first, 0);
await waitForSize(second, 10);
a.write('xy');
await capture.finishAsync();
expect(await fs.readFile(first, 'utf8')).toBe('xy');
expect(await fs.readFile(second, 'utf8')).toBe('cdefghij');
});
it('closes an uncapped file when its stream finishes', async () => {
const opened = trackOpenedFiles();
const capture = new AndroidEmulatorLogCapture(Infinity);
const file = path.join(directory, 'build.log');
await record(capture, file, 'ordinary build output');
expect(opened).toHaveLength(1);
expect(opened[0].fd).toBe(-1);
expect(await fs.readFile(file, 'utf8')).toBe('ordinary build output');
expect(await capture.finishAsync()).toEqual([
{ filePath: file, source: 'emulator-output', bytesSeen: 21 },
]);
});
it('closes the file and reports once when sealing fails', async () => {
const opened = trackOpenedFiles();
const capture = new AndroidEmulatorLogCapture(10);
const file = path.join(directory, 'wrapped.log');
await record(capture, file, '0123456789abcdef');
const diskError = Object.assign(new Error('full'), { code: 'ENOSPC' });
jest.spyOn(opened[0], 'truncate').mockRejectedValueOnce(diskError);
await capture.finishAsync();
expect(opened[0].fd).toBe(-1);
expect(Sentry.capture).toHaveBeenCalledTimes(1);
expect(Sentry.capture).toHaveBeenCalledWith(
'Failed to write Android diagnostic log',
diskError,
expect.anything()
);
});
it('keeps the tail of a wrapped ring in order', async () => {
const capture = new AndroidEmulatorLogCapture(10);
const file = path.join(directory, 'wrapped.log');
const input = new PassThrough();
capture.attach(input, file, 'emulator-output');
for (const piece of ['0123', '4567', '89ab', 'cdef']) {
input.write(piece);
}
await capture.finishAsync();
expect(await fs.readFile(file, 'utf8')).toBe('6789abcdef');
expect(await fs.readFile(first, 'utf8')).toBe('first!');
expect(await fs.readFile(retry, 'utf8')).toBe('seco');
expect(await fs.readFile(device, 'utf8')).toBe('device log');
expect(capture.getBytesSeen(retry)).toBe(22);
});
it('reserves bytes across simultaneous captures and drains after finalization', async () => {
@@ -165,22 +63,20 @@ it('reserves bytes across simultaneous captures and drains after finalization',
capture.attach(a, first, 'emulator-output');
capture.attach(b, second, 'emulator-output');
a.write('12345678');
await waitForSize(first, 8);
b.write('abcdefgh');
await capture.finishAsync();
// The least recently written capture gives up its bytes first.
expect(await fs.readFile(first, 'utf8')).toBe('78');
expect(await fs.readFile(second, 'utf8')).toBe('abcdefgh');
expect(await fs.readFile(first, 'utf8')).toBe('12345678');
expect(await fs.readFile(second, 'utf8')).toBe('ab');
a.end('ignored after cleanup');
b.end('also ignored');
expect(a.readableFlowing).toBe(true);
expect(b.readableFlowing).toBe(true);
});
it('drains a real FIFO beyond 20 MiB, retains the tail, and closes after the writer exits', async () => {
it('drains a real FIFO beyond 20 MiB, retains the prefix, and closes after the writer exits', async () => {
const file = path.join(directory, 'logcat.log');
const capture = new AndroidEmulatorLogCapture();
const pipe = await capture.prepareLogcatOutputAsync(file);
const pipe = await capture.createLogcatPipeAsync(file);
expect((await fs.stat(pipe.path)).isFIFO()).toBe(true);
const writer = spawn(
process.execPath,
@@ -207,14 +103,12 @@ it('drains a real FIFO beyond 20 MiB, retains the tail, and closes after the wri
pipe.close();
pipe.close();
await pipe.closed;
const logs = await capture.finishAsync();
await capture.finishAsync();
const result = await fs.readFile(file);
expect(result.length).toBe(MAX_ANDROID_LOG_BYTES_PER_SOURCE);
expect(result.subarray(0, 4).toString()).toBe('xxxx');
expect(result.subarray(-4).toString()).toBe('\nEND');
expect(logs).toEqual([
{ filePath: file, source: 'device-logcat', bytesSeen: 64 * 1024 * 1024 + 10 },
]);
expect(result.subarray(0, 6).toString()).toBe('BEGIN\n');
expect(result.subarray(-4).toString()).toBe('xxxx');
expect(capture.getBytesSeen(file)).toBe(64 * 1024 * 1024 + 10);
await expect(fs.stat(pipe.path)).rejects.toMatchObject({ code: 'ENOENT' });
} finally {
writer.kill();
@@ -222,71 +116,13 @@ it('drains a real FIFO beyond 20 MiB, retains the tail, and closes after the wri
}
}, 30_000);
it('uses the original logcat path for ordinary builds without creating a FIFO', async () => {
const file = path.join(directory, 'build-logcat.log');
const capture = new AndroidEmulatorLogCapture(Infinity);
const output = await capture.prepareLogcatOutputAsync(file);
expect(output.path).toBe(file);
expect(await fs.readdir(directory)).toEqual([]);
output.close();
await output.closed;
expect(await capture.finishAsync()).toEqual([]);
});
it('closes an unused logcat pipe when the emulator fails to launch', async () => {
const file = path.join(directory, 'empty.log');
const capture = new AndroidEmulatorLogCapture();
const pipe = await capture.prepareLogcatOutputAsync(file);
const pipe = await capture.createLogcatPipeAsync(path.join(directory, 'empty.log'));
pipe.close();
await pipe.closed;
await expect(fs.stat(pipe.path)).rejects.toMatchObject({ code: 'ENOENT' });
expect(await capture.finishAsync()).toEqual([
{ filePath: file, source: 'device-logcat', bytesSeen: 0 },
]);
expect((await fs.stat(file)).size).toBe(0);
}, 5_000);
it('keeps draining the FIFO after capture finishes while the emulator is alive', async () => {
const file = path.join(directory, 'live-logcat.log');
const capture = new AndroidEmulatorLogCapture(10);
const output = await capture.prepareLogcatOutputAsync(file);
const writer = spawn(
process.execPath,
[
'-e',
`
const fs = require('node:fs');
const fd = fs.openSync(process.argv[1], 'w');
process.stdout.write('ready');
process.stdin.once('data', () => {
const chunk = Buffer.alloc(64 * 1024, 'x');
for (let i = 0; i < 128; i++) {
let offset = 0;
while (offset < chunk.length) offset += fs.writeSync(fd, chunk, offset);
}
fs.closeSync(fd);
process.stdin.destroy();
});
`,
output.path,
],
{ stdio: ['pipe', 'pipe', 'pipe'] }
);
try {
await once(writer.stdout, 'data');
await capture.finishAsync();
writer.stdin.end('go');
expect(await once(writer, 'close')).toEqual([0, null]);
output.close();
await output.closed;
expect((await fs.stat(file)).size).toBe(0);
await expect(fs.stat(output.path)).rejects.toMatchObject({ code: 'ENOENT' });
} finally {
writer.kill();
output.close();
await output.closed;
}
}, 10_000);
await capture.finishAsync();
});
it('reports a disk write failure once and continues draining output', async () => {
const capture = new AndroidEmulatorLogCapture(10);
@@ -1,56 +1,27 @@
import { execFile, spawn } from 'node:child_process';
import fs from 'node:fs';
import { type FileHandle } from 'node:fs/promises';
import { Socket } from 'node:net';
import { type Readable, Writable } from 'node:stream';
import { finished } from 'node:stream/promises';
import { promisify } from 'node:util';
import { Sentry } from '../sentry';
import { type AndroidEmulatorLogSource } from './androidEmulatorLogSources';
import { createAndroidLogcatPipeAsync } from './androidLogcatPipe';
export const MAX_ANDROID_LOG_BYTES_PER_SOURCE = 20 * 1024 * 1024;
export type FinalizedAndroidEmulatorLog = {
readonly filePath: string;
readonly source: AndroidEmulatorLogSource;
readonly bytesSeen: number;
};
/**
* One file is a ring of `maxBytes` bytes: `writePos` wraps and the newest
* `retained` bytes end at it. Sealing rewrites the file in log order.
*/
type Capture = {
source: AndroidEmulatorLogSource;
stream: Writable;
inputs: Set<Readable>;
done: Promise<void>;
/** Runs one file operation after every earlier one on the same handle. */
run: (operation: (handle: FileHandle) => Promise<void>) => Promise<void>;
sealed?: Promise<void>;
bytesSeen: number;
writePos: number;
retained: number;
lastWrite: number;
};
/**
* One budget per workflow, shared by all emulator launches and retries.
*
* A source keeps the last `maxBytes` bytes it produced, across files. The
* oldest bytes go first, so an earlier launch gives up its head to a later
* one and the final attempt always keeps its tail, where failures explain
* themselves. The least recently written file gives up bytes first, which
* approximates byte age without tracking every chunk.
*
* Eviction is bookkeeping until a file loses all its bytes, which frees it
* on disk at once. A partially evicted file keeps its size until it is
* sealed, so one source occupies at most twice its budget while recording.
*/
/** One budget per workflow, shared by all emulator launches and retries. */
export class AndroidEmulatorLogCapture {
private readonly sources = new Map<AndroidEmulatorLogSource, Capture[]>();
private readonly bytes = new Map<AndroidEmulatorLogSource, number>();
private readonly captures = new Map<string, Capture>();
private finishing = false;
private writes = 0;
constructor(private readonly maxBytes = MAX_ANDROID_LOG_BYTES_PER_SOURCE) {}
@@ -61,11 +32,48 @@ export class AndroidEmulatorLogCapture {
}
let capture = this.captures.get(filePath);
if (!capture) {
capture = this.open(filePath, source);
const file = fs.promises.open(filePath, 'a');
// Handle open errors even if the process never produces output.
void file.catch(() => {});
let failed = false;
const report = (err: unknown): void => {
if (!failed) {
failed = true;
Sentry.capture(
'Failed to write Android diagnostic log',
err instanceof Error ? err : new Error(String(err)),
{
level: 'warning',
extras: { filePath, source },
}
);
}
};
const stream = new Writable({
write: (chunk: Buffer, _encoding, callback) => {
capture!.bytesSeen += chunk.length;
const used = this.bytes.get(source) ?? 0;
const length = Math.min(chunk.length, this.maxBytes - used);
if (failed || length <= 0) {
callback();
return;
}
// Reserve before awaiting I/O so concurrent launches share the same cap.
this.bytes.set(source, used + length);
void file
.then(handle => handle.writeFile(chunk.subarray(0, length)))
.catch(report)
.then(() => callback());
},
final: callback => {
void file
.then(handle => handle.close())
.catch(report)
.then(() => callback());
},
});
capture = { stream, inputs: new Set(), done: finished(stream), bytesSeen: 0 };
this.captures.set(filePath, capture);
const list = this.sources.get(source) ?? [];
list.push(capture);
this.sources.set(source, list);
}
capture.inputs.add(input);
input.pipe(capture.stream, { end: false });
@@ -84,160 +92,75 @@ export class AndroidEmulatorLogCapture {
capture.inputs.clear();
capture.stream.end();
await capture.done;
// Nothing can evict an uncapped file later, so release its handle now.
if (!Number.isFinite(this.maxBytes)) {
await this.sealAsync(capture);
}
}
async finishAsync(): Promise<readonly FinalizedAndroidEmulatorLog[]> {
async finishAsync(): Promise<void> {
this.finishing = true;
await Promise.all([...this.captures.keys()].map(file => this.finishFileAsync(file)));
await Promise.all([...this.captures.values()].map(capture => this.sealAsync(capture)));
// Every writer is closed, so uploads can reopen these files on each retry.
return [...this.captures].map(([filePath, { source, bytesSeen }]) => ({
filePath,
source,
bytesSeen,
}));
}
/** Select the output transport without exposing the capture budget to emulator launch. */
async prepareLogcatOutputAsync(
getBytesSeen(filePath: string): number | undefined {
return this.captures.get(filePath)?.bytesSeen;
}
/** Keep logcat separate from emulator stderr without an unbounded intermediate file. */
async createLogcatPipeAsync(
filePath: string
): Promise<{ path: string; close: () => void; closed: Promise<void> }> {
// Ordinary builds retain the emulator's direct, uncapped logcat file writer.
if (!Number.isFinite(this.maxBytes)) {
return { path: filePath, close: () => {}, closed: Promise.resolve() };
}
const pipe = await createAndroidLogcatPipeAsync(`${filePath}.pipe`);
this.attach(pipe.stream, filePath, 'device-logcat');
const closed = pipe.closed.then(() => this.finishFileAsync(filePath)).catch(() => {});
return { path: pipe.path, close: pipe.close, closed };
}
private open(filePath: string, source: AndroidEmulatorLogSource): Capture {
const file = fs.promises.open(filePath, 'w+');
// Handle open errors even if the process never produces output.
void file.catch(() => {});
let failed = false;
const report = (err: unknown): void => {
if (!failed) {
failed = true;
Sentry.capture(
'Failed to write Android diagnostic log',
err instanceof Error ? err : new Error(String(err)),
{
level: 'warning',
extras: { filePath, source },
}
);
const pipePath = `${filePath}.pipe`;
await promisify(execFile)('mkfifo', ['-m', '600', pipePath]);
// The sentinel keeps the FIFO open before emulator launch. The reader signals
// after opening it, so even a failed launch can close the pipe without a race.
const sentinel = fs.openSync(pipePath, fs.constants.O_RDWR | fs.constants.O_NONBLOCK);
let sentinelOpen = true;
const close = (): void => {
if (sentinelOpen) {
sentinelOpen = false;
fs.closeSync(sentinel);
}
};
let queue: Promise<void> = Promise.resolve();
const run = (operation: (handle: FileHandle) => Promise<void>): Promise<void> => {
queue = queue
.then(() => file)
.then(operation)
.catch(report);
return queue;
};
const record = {
source,
inputs: new Set<Readable>(),
run,
bytesSeen: 0,
writePos: 0,
retained: 0,
lastWrite: 0,
};
const stream = new Writable({
write: (chunk: Buffer, _encoding, callback) => {
record.bytesSeen += chunk.length;
if (failed) {
callback();
return;
}
const max = this.maxBytes;
const data = chunk.length > max ? chunk.subarray(chunk.length - max) : chunk;
const position = record.writePos;
const head = Math.min(data.length, max - position);
// Account before awaiting I/O so concurrent launches share the same cap.
record.writePos = (position + data.length) % max;
record.retained = Math.min(record.retained + data.length, max);
record.lastWrite = ++this.writes;
this.evict(source);
void run(async handle => {
await writeAtAsync(handle, data.subarray(0, head), position);
await writeAtAsync(handle, data.subarray(head), 0);
}).then(() => callback());
},
});
return Object.assign(record, { stream, done: finished(stream) });
}
/** Drop the oldest bytes of a source until its files fit the budget together. */
private evict(source: AndroidEmulatorLogSource): void {
const list = [...(this.sources.get(source) ?? [])].sort((a, b) => a.lastWrite - b.lastWrite);
let excess = list.reduce((sum, capture) => sum + capture.retained, 0) - this.maxBytes;
for (const capture of list) {
if (excess <= 0) {
return;
const reader = spawn(
'sh',
['-c', 'exec 3<"$1" || exit 1; printf ready >&2; exec cat <&3', 'android-logcat', pipePath],
{
stdio: ['ignore', 'pipe', 'pipe'],
}
const dropped = Math.min(capture.retained, excess);
capture.retained -= dropped;
excess -= dropped;
if (dropped > 0 && capture.retained === 0) {
// Nothing of this file survives: free the disk after its queued writes land.
capture.writePos = 0;
void capture.run(handle => handle.truncate(0));
}
}
}
/** Rewrite the ring in log order, drop evicted bytes, and close the file. */
private sealAsync(capture: Capture): Promise<void> {
capture.sealed ??= capture.run(async handle => {
try {
const { retained } = capture;
let start = capture.writePos - retained;
if (start < 0) {
start += this.maxBytes;
}
if (start > 0) {
const tail = Buffer.alloc(retained);
const head = Math.min(retained, this.maxBytes - start);
await readAtAsync(handle, tail.subarray(0, head), start);
await readAtAsync(handle, tail.subarray(head), 0);
await writeAtAsync(handle, tail, 0);
}
await handle.truncate(retained);
} finally {
await handle.close();
}
});
return capture.sealed;
}
}
async function writeAtAsync(handle: FileHandle, data: Buffer, position: number): Promise<void> {
for (let offset = 0; offset < data.length; ) {
const { bytesWritten } = await handle.write(
data,
offset,
data.length - offset,
position + offset
);
offset += bytesWritten;
}
}
async function readAtAsync(handle: FileHandle, into: Buffer, position: number): Promise<void> {
for (let offset = 0; offset < into.length; ) {
const { bytesRead } = await handle.read(into, offset, into.length - offset, position + offset);
if (bytesRead === 0) {
throw new Error(`Android diagnostic log ended before offset ${position + into.length}`);
const ready = new Promise<void>((resolve, reject) => {
reader.once('error', reject);
reader.once('exit', () =>
reject(new Error('Android logcat reader exited before opening its pipe.'))
);
reader.stderr.once('data', () => resolve());
});
this.attach(reader.stdout, filePath, 'device-logcat');
reader.on('error', err => {
close();
Sentry.capture('Failed to read Android logcat pipe', err, { level: 'warning' });
});
const closed = new Promise<void>(resolve => {
reader.once('close', () => {
close();
void this.finishFileAsync(filePath)
.then(() => fs.promises.rm(pipePath, { force: true }))
.catch(() => {})
.then(resolve);
});
});
try {
await ready;
} catch (err) {
close();
reader.kill();
await closed;
throw err;
}
offset += bytesRead;
reader.unref();
for (const stream of [reader.stdout, reader.stderr]) {
if (stream instanceof Socket) {
stream.unref();
}
}
return { path: pipePath, close, closed };
}
}
@@ -6,7 +6,7 @@ export const AndroidEmulatorLogSourcesSchema = z.array(
export type AndroidEmulatorLogSource = z.infer<typeof AndroidEmulatorLogSourcesSchema>[number];
// Existing build workflows capture both sources. Device sessions pass their selection explicitly.
export const ALL_ANDROID_EMULATOR_LOG_SOURCES: readonly AndroidEmulatorLogSource[] = [
export const LEGACY_ANDROID_EMULATOR_LOG_SOURCES: readonly AndroidEmulatorLogSource[] = [
'emulator-output',
'device-logcat',
];
@@ -1,66 +0,0 @@
import { execFile, spawn } from 'node:child_process';
import fs from 'node:fs';
import { Socket } from 'node:net';
import { type Readable } from 'node:stream';
import { promisify } from 'node:util';
import { Sentry } from '../sentry';
/** Bridge the emulator's path-based logcat output to a readable stream. */
export async function createAndroidLogcatPipeAsync(
pipePath: string
): Promise<{ path: string; stream: Readable; close: () => void; closed: Promise<void> }> {
await promisify(execFile)('mkfifo', ['-m', '600', pipePath]);
// The sentinel keeps the FIFO open before emulator launch. The reader signals
// after opening it, so even a failed launch can close the pipe without a race.
const sentinel = fs.openSync(pipePath, fs.constants.O_RDWR | fs.constants.O_NONBLOCK);
let sentinelOpen = true;
const close = (): void => {
if (sentinelOpen) {
sentinelOpen = false;
fs.closeSync(sentinel);
}
};
const reader = spawn(
'sh',
['-c', 'exec 3<"$1" || exit 1; printf ready >&2; exec cat <&3', 'android-logcat', pipePath],
{
stdio: ['ignore', 'pipe', 'pipe'],
}
);
const ready = new Promise<void>((resolve, reject) => {
reader.once('error', reject);
reader.once('exit', () =>
reject(new Error('Android logcat reader exited before opening its pipe.'))
);
reader.stderr.once('data', () => resolve());
});
reader.on('error', err => {
close();
Sentry.capture('Failed to read Android logcat pipe', err, { level: 'warning' });
});
const closed = new Promise<void>(resolve => {
reader.once('close', () => {
close();
void fs.promises
.rm(pipePath, { force: true })
.catch(() => {})
.then(resolve);
});
});
try {
await ready;
} catch (err) {
close();
reader.kill();
await closed;
throw err;
}
reader.unref();
for (const stream of [reader.stdout, reader.stderr]) {
if (stream instanceof Socket) {
stream.unref();
}
}
return { path: pipePath, stream: reader.stdout, close, closed };
}
@@ -883,20 +883,21 @@ describe(Simulator, () => {
);
});
it('accumulates repeated --android-log-source flags in order', async () => {
it.each([
{ sources: ['emulator-output'] },
{ sources: ['device-logcat'] },
{ sources: ['emulator-output', 'device-logcat'] },
])('passes explicitly selected Android log sources $sources', async ({ sources }) => {
const { command } = createCommand([
'--platform',
'android',
'--non-interactive',
'--android-log-source',
'emulator-output',
'--android-log-source',
'device-logcat',
...sources.flatMap(source => ['--android-log-source', source]),
]);
await command.runAsync();
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(
graphqlClient,
expect.objectContaining({ android: { logSources: ['emulator-output', 'device-logcat'] } })
expect.objectContaining({ android: { logSources: sources } })
);
});
@@ -373,19 +373,6 @@ export default class Simulator extends EasCommand {
packageVersion: flags['package-version'],
nonInteractive,
});
const android = {
...(deviceIdentifier ? { deviceIdentifier } : {}),
...(systemImagePackage ? { systemImagePackage } : {}),
...(androidLogSources ? { logSources: androidLogSources } : {}),
};
const platformInput =
platform === AppPlatform.Ios
? deviceIdentifier
? { ios: { deviceIdentifier } }
: {}
: Object.keys(android).length
? { android }
: {};
const session = await withSimulatorRequestAnalyticsAsync(
analytics,
requestProperties,
@@ -401,7 +388,19 @@ export default class Simulator extends EasCommand {
packageVersion: flags['package-version'],
networkCapture: flags['network-capture'],
...(networkCaptureFields.length ? { networkCaptureFields } : {}),
...platformInput,
...(platform === AppPlatform.Ios
? deviceIdentifier
? { ios: { deviceIdentifier } }
: {}
: deviceIdentifier || systemImagePackage || androidLogSources
? {
android: {
...(deviceIdentifier ? { deviceIdentifier } : {}),
...(systemImagePackage ? { systemImagePackage } : {}),
...(androidLogSources ? { logSources: androidLogSources } : {}),
},
}
: {}),
...(buildId ? { buildId } : {}),
...(buildFingerprint ? { buildFingerprint } : {}),
...(applicationArchiveUrlFromFlag