Compare commits

...
Author SHA1 Message Date
Vojtech NovakandClaude Fable 5.1 23952dfb64 [build-tools] Free evicted Android log files early and close uncapped files
Serialize each file's I/O through one queue so a fully evicted file is
truncated as soon as its queued writes land, live or finished. Evict the
least recently written file first. Seal uncapped files when their stream
finishes and close the handle even when sealing fails.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-11-05 22:58:13 +01:00
Vojtech NovakandClaude Fable 5.1 6ab793bbc9 [build-tools] Describe tail retention in the Android log changeset
Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-11-05 21:52:57 +01:00
Vojtech NovakandClaude Fable 5.1 96b3e76c63 [build-tools] Name truncated Android logs as tail retention
Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-11-05 21:52:44 +01:00
Vojtech NovakandClaude Fable 5.1 9f087b164e [build-tools] Retain the last 20 MiB of each Android log source
Each capture file is a fixed-size ring on disk. A source keeps its newest
bytes across launches: a later launch evicts the head of an earlier one, so
the final attempt always keeps its tail. Finalization rewrites each ring in
log order and truncates evicted bytes.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-11-05 21:52:44 +01:00
Vojtech NovakandClaude Fable 5.1 2d8ad58854 [build-tools] Test the shared upload helper once and drop duplicate log cases
Give uploadDeviceRunSessionFileAsync its own suite for empty, missing,
read-error and signal forwarding, and keep one failure case per caller.
Move the early-503 replay to the HTTP suite with a small file. Fold the
memoization and failure-isolation cases into one, test the truncation
metadata through the mocked upload, and give the unused-pipe case
assertions. Drop the capture-side Infinity case that the factory suite
already covers.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-11-05 21:25:03 +01:00
Vojtech NovakandClaude Fable 5.1 95bcffb731 [build-tools] Collapse log-source pass-through grids to one case each
The step and CLI layers hand the parsed source list on unchanged, so
the array value never changes the path. Keep one explicit empty-list
case per step, which also guards the disable-logging contract, and one
repeated-flag case for the CLI. Drop capture-factory ordering checks
that cannot fail, since the capture is an argument of the boot call.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-11-05 21:22:45 +01:00
Vojtech NovakandClaude Fable 5.1 1099ac72ac [build-tools] Trim overlapping startAsync log tests
Drop the launch-gate dimension from the source-selection grid; the gate
and the log sources never meet in one expression. Keep one pipe-routing
case and reduce the output test to filename and detach assertions that
no other case covers. Assert prepareLogcatOutputAsync directly instead
of through the -logcat argument.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-11-05 21:19:37 +01:00
Vojtech Novak 4dbdaed113 [build-tools] Default session logs safely and isolate FIFO transport 2026-11-05 15:50:50 +01:00
Vojtech Novak 85ea9a2a0b [build-tools] Simplify Android diagnostic collection 2026-11-05 15:18:05 +01:00
25 changed files with 929 additions and 730 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 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.
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.
@@ -24,7 +24,7 @@ function createStep(callInputs: Record<string, unknown> = {}) {
return { ctx, global, logger, fn, step };
}
it('defaults an existing workflow without inputs to emulator output only', async () => {
it('collects captured logs without a second source selection', async () => {
const { ctx, global, fn, step } = createStep();
expect(fn.supportedRuntimePlatforms).toEqual([BuildRuntimePlatform.LINUX]);
await step.executeAsync();
@@ -32,37 +32,9 @@ it('defaults an existing workflow without inputs to emulator output only', 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)
@@ -116,46 +116,21 @@ 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(
[
'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],
},
])
(buildFunction.inputProviders ?? []).map(provider => {
const input = provider(global, 'test');
input.set(inputValues[input.id]);
return [input.id, { value: input.rawValue }];
})
);
return buildFunction.fn!(
{
logger,
global: {
runtimePlatform,
staticContext: { job: { secrets: { robotAccessToken: 'robot-token' } } },
},
global,
} as unknown as BuildStepContext,
{ inputs, outputs: {}, env, signal } as never
) as Promise<void>;
@@ -186,6 +161,11 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
}
beforeEach(() => {
jest
.mocked(getAndroidEmulatorLogCapture)
.mockImplementation(
jest.requireActual('../../utils/androidEmulatorLogs').getAndroidEmulatorLogCapture
);
deviceLaunch = undefined;
jest.clearAllMocks();
jest.mocked(resolveLocalEgressServeSimBootEnvironmentAsync).mockResolvedValue(null);
@@ -458,36 +438,46 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
});
});
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);
await runStep(
BuildRuntimePlatform.LINUX,
{ android_log_sources: sources },
undefined,
ctx,
false,
{
DEVICE_RUN_SESSION_ID: 'session',
}
);
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: sources, logCapture: capture })
expect.objectContaining({ logSources: ['emulator-output'] })
);
});
it('rejects invalid Android log sources before boot', async () => {
it('passes an explicit empty source list and the workflow capture to Android boot', async () => {
const capture = new AndroidEmulatorLogCapture();
jest.mocked(getAndroidEmulatorLogCapture).mockReturnValue(capture);
const env = { DEVICE_RUN_SESSION_ID: 'session' };
await runStep(
BuildRuntimePlatform.LINUX,
{ android_log_sources: [] },
undefined,
ctx,
false,
env
);
expect(getAndroidEmulatorLogCapture).toHaveBeenCalledWith(expect.anything(), env);
expect(startAndroidEmulatorAsync).toHaveBeenCalledWith(
expect.objectContaining({ logSources: [], logCapture: capture })
);
});
it('rejects invalid Android log sources before boot or downloading', 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 () => {
@@ -501,8 +491,6 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
});
expect(selectXcodeDeveloperDirectoryAsync).not.toHaveBeenCalled();
expect(getAndroidEmulatorLogCapture).not.toHaveBeenCalled();
expect(jest.mocked(startAndroidEmulatorAsync).mock.calls[0][0].logCapture).toBeUndefined();
expect(startAndroidEmulatorAsync).toHaveBeenCalledWith(
expect.objectContaining({
deviceIdentifier: 'pixel_7',
@@ -532,10 +520,6 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
// The step returned only after the download stopped.
expect(download.settled()).toBe(true);
expect(installBuildAsync).not.toHaveBeenCalled();
expect(getAndroidEmulatorLogCapture).toHaveBeenCalledTimes(1);
expect(jest.mocked(getAndroidEmulatorLogCapture).mock.invocationCallOrder[0]).toBeLessThan(
jest.mocked(startAndroidEmulatorAsync).mock.invocationCallOrder[0]
);
});
it('does not start work for an already cancelled step', async () => {
@@ -66,6 +66,11 @@ 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([]);
@@ -94,22 +99,21 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
mockedMkdtemp.mockRestore();
});
it.each([
{ sources: [] },
{ sources: ['emulator-output'] },
{ sources: ['device-logcat'] },
{ sources: ['emulator-output', 'device-logcat'] },
])('preserves $sources through boot retries and clones', async ({ sources }) => {
it('preserves an explicit empty source list and one capture through boot retries and clones', async () => {
mockedAndroidUtils.waitForReadyAsync.mockRejectedValueOnce(new Error('retry boot'));
const capture = new AndroidEmulatorLogCapture();
jest.mocked(getAndroidEmulatorLogCapture).mockReturnValue(capture);
await createStep(
{ android_log_sources: sources, count: 2 },
{ android_log_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(sources);
expect(options.logSources).toEqual([]);
expect(options.logCapture).toBe(capture);
}
});
@@ -141,6 +145,7 @@ 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),
@@ -256,7 +261,6 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
expect.objectContaining({
deviceName: 'EasAndroidDevice01',
logcatDirectory: '/tmp/logcat-directory',
logSources: ['emulator-output', 'device-logcat'],
})
);
expect(mockedAndroidUtils.startAsync).toHaveBeenNthCalledWith(
@@ -264,7 +268,6 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
expect.objectContaining({
deviceName: 'EasAndroidDevice01',
logcatDirectory: '/tmp/logcat-directory',
logSources: ['emulator-output', 'device-logcat'],
})
);
expect(mockedAndroidUtils.deleteAsync).toHaveBeenCalledWith(
@@ -339,21 +342,18 @@ 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'],
})
);
@@ -372,9 +372,7 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
.mockResolvedValueOnce(createStartResult('emulator-attempt-3'));
mockedAndroidUtils.waitForReadyAsync.mockRejectedValue(new Error('network unavailable'));
await expect(
createStep(undefined, { DEVICE_RUN_SESSION_ID: 'session' }).executeAsync()
).rejects.toThrow('network unavailable');
await expect(createStep().executeAsync()).rejects.toThrow('network unavailable');
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenNthCalledWith(
3,
@@ -385,10 +383,6 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
);
expect(mockedAndroidUtils.deleteAsync).toHaveBeenCalledTimes(3);
expect(mockedAndroidUtils.startAsync).toHaveBeenCalledTimes(3);
expect(getAndroidEmulatorLogCapture).toHaveBeenCalledWith(expect.anything());
expect(jest.mocked(getAndroidEmulatorLogCapture).mock.invocationCallOrder[0]).toBeLessThan(
mockedAndroidUtils.startAsync.mock.invocationCallOrder[0]
);
});
it('outputs and uses a temporary logcat directory', async () => {
@@ -398,12 +392,9 @@ 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(getAndroidEmulatorLogCapture).not.toHaveBeenCalled();
expect(mockedAndroidUtils.startAsync.mock.calls[0][0].logCapture).toBeUndefined();
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenCalledWith(
expect.objectContaining({
serialId: 'emulator-default',
@@ -1,13 +1,7 @@
import {
BuildFunction,
BuildRuntimePlatform,
BuildStepInput,
BuildStepInputValueTypeName,
} from '@expo/steps';
import { BuildFunction, BuildRuntimePlatform } 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';
@@ -20,21 +14,12 @@ export function createCollectAndroidEmulatorLogsBuildFunction(
name: 'Collect Android emulator logs',
__metricsId: 'eas/collect_android_emulator_logs',
supportedRuntimePlatforms: [BuildRuntimePlatform.LINUX],
inputProviders: [
BuildStepInput.createProvider({
id: 'sources',
required: false,
defaultValue: ['emulator-output'],
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
}),
],
fn: async ({ logger, global }, { inputs, env }) => {
fn: async ({ logger, global }, { 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,10 +16,7 @@ import {
AndroidVirtualDeviceName,
} from '../../utils/AndroidEmulatorUtils';
import { watchDeviceRunSessionStartup } from '../utils/deviceRunSessionStartup';
import {
AndroidEmulatorLogSourcesSchema,
LEGACY_ANDROID_EMULATOR_LOG_SOURCES,
} from '../../utils/androidEmulatorLogSources';
import { AndroidEmulatorLogSourcesSchema } from '../../utils/androidEmulatorLogSources';
import { getAndroidEmulatorLogCapture } from '../utils/androidEmulatorLogs';
import { getDeviceRunSessionWorkerApi } from '../utils/deviceRunSessionWorkerApi';
import { readIosApplicationIdentifierAsync } from '../utils/iosAppArtifact';
@@ -79,7 +76,7 @@ export function createStartAgentDeviceSessionBuildFunction(
BuildStepInput.createProvider({
id: 'android_log_sources',
required: false,
defaultValue: [...LEGACY_ANDROID_EMULATOR_LOG_SOURCES],
defaultValue: ['emulator-output'],
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
}),
// Device. On iOS a Simulator name or UDID, on Android an AVD hardware profile.
@@ -199,6 +196,9 @@ 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,6 @@ 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-')
);
@@ -268,9 +265,7 @@ export function createStartAgentDeviceSessionBuildFunction(
lcdDensity: inputs.lcd_density.value as number | undefined,
logcatDirectory,
logSources,
logCapture: env.DEVICE_RUN_SESSION_ID
? getAndroidEmulatorLogCapture(global)
: undefined,
logCapture: getAndroidEmulatorLogCapture(global, env),
env,
logger: taskLogger,
});
@@ -24,9 +24,9 @@ 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';
@@ -43,7 +43,7 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
BuildStepInput.createProvider({
id: 'android_log_sources',
required: false,
defaultValue: [...LEGACY_ANDROID_EMULATOR_LOG_SOURCES],
defaultValue: [...ALL_ANDROID_EMULATOR_LOG_SOURCES],
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
}),
BuildStepInput.createProvider({
@@ -96,9 +96,7 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
const logcatDirectory = await fs.promises.mkdtemp(
path.join(os.tmpdir(), 'eas-android-emulator-logcat-')
);
const logCapture = env.DEVICE_RUN_SESSION_ID
? getAndroidEmulatorLogCapture(global)
: undefined;
const logCapture = getAndroidEmulatorLogCapture(global, env);
outputs.logcat_directory.set(logcatDirectory);
const deviceName = `${inputs.device_name.value}` as AndroidVirtualDeviceName;
@@ -235,8 +233,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<{
@@ -15,6 +15,7 @@ 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,
@@ -64,51 +65,31 @@ async function captureLog(
text: string,
workflow = global
): Promise<void> {
const capture = getAndroidEmulatorLogCapture(workflow);
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(
sources: readonly AndroidEmulatorLogSource[] = ['emulator-output', 'device-logcat']
): Promise<void> {
function collect(): Promise<void> {
return collectAndroidEmulatorLogsAsync(ctx, {
global,
deviceRunSessionId: 'session',
logger,
sources,
});
}
it('uploads only emulator output when sources are omitted', async () => {
await captureLog(emulatorPath, 'emulator-output', 'host output');
await captureLog(logcatPath, 'device-logcat', 'sensitive device output');
const stat = jest.spyOn(fsPromises, 'stat');
await collectAndroidEmulatorLogsAsync(ctx, { global, deviceRunSessionId: 'session', logger });
expect([...uploadedContents.keys()]).toEqual([path.basename(emulatorPath)]);
expect(stat).not.toHaveBeenCalledWith(logcatPath);
});
it('uploads only explicitly selected logcat and uploads nothing for an empty selection', async () => {
await captureLog(emulatorPath, 'emulator-output', 'host output');
await captureLog(logcatPath, 'device-logcat', 'device output');
await collect([]);
expect(upload).not.toHaveBeenCalled();
await collect(['device-logcat']);
expect([...uploadedContents.keys()]).toEqual([path.basename(logcatPath)]);
});
it('honors different source selections during concurrent cleanup without duplicate uploads', async () => {
await captureLog(emulatorPath, 'emulator-output', 'host output');
await captureLog(logcatPath, 'device-logcat', 'device output');
await Promise.all([collect(['emulator-output']), collect(['emulator-output', 'device-logcat'])]);
expect([...uploadedContents.keys()].sort()).toEqual([
path.basename(emulatorPath),
path.basename(logcatPath),
]);
expect(upload).toHaveBeenCalledTimes(2);
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('uses captured source records and ignores files outside the capture inventory', async () => {
@@ -153,7 +134,7 @@ it('uses captured source records and ignores files outside the capture inventory
it('seals a live producer before upload and keeps retry bodies identical without copying files', async () => {
const input = new PassThrough();
const capture = getAndroidEmulatorLogCapture(global);
const capture = getAndroidEmulatorLogCapture(global, { DEVICE_RUN_SESSION_ID: 'session' });
capture.attach(input, emulatorPath, 'emulator-output');
input.write('before');
const contents: string[] = [];
@@ -185,28 +166,19 @@ it('seals a live producer before upload and keeps retry bodies identical without
}
});
it('continues after a failed upload and retries only unsuccessful files on another collection', async () => {
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');
upload.mockRejectedValueOnce(new Error('storage unavailable'));
await expect(collect()).resolves.toBeUndefined();
await Promise.all([collect(), collect()]);
await collect();
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 workflow capture and skips successful uploads on repeated or concurrent cleanup', async () => {
expect(getAndroidEmulatorLogCapture(global)).toBe(getAndroidEmulatorLogCapture(global));
await captureLog(emulatorPath, 'emulator-output', 'host output');
await Promise.all([collect(), collect()]);
await collect();
expect(upload).toHaveBeenCalledTimes(1);
});
it('uses a fresh 30 second budget for the next file after an upload times out', async () => {
@@ -228,11 +200,10 @@ it('uses a fresh 30 second budget for the next file after an upload times out',
expect(upload.mock.calls[1][1].signal?.aborted).toBe(false);
});
it('does not collect another workflow or empty files and continues after a captured file disappears', async () => {
it('does not collect another workflow and warns when a captured file disappears', async () => {
await captureLog(emulatorPath, 'emulator-output', 'other workflow', {} as BuildStepGlobalContext);
await collect();
expect(upload).not.toHaveBeenCalled();
await captureLog(logcatPath, 'device-logcat', '');
await captureLog(path.join(directory, 'missing.log'), 'emulator-output', 'removed');
await rm(path.join(directory, 'missing.log'));
await expect(collect()).resolves.toBeUndefined();
@@ -243,23 +214,27 @@ it('does not collect another workflow or empty files and continues after a captu
);
});
it('aborts on a genuine log file read error and continues with the other log', async () => {
await captureLog(emulatorPath, 'emulator-output', 'host output');
await captureLog(logcatPath, 'device-logcat', 'Android output');
const readError = Object.assign(new Error('log 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')
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,
}),
})
);
const uploaded = uploadedContents.get(path.basename(emulatorPath));
expect(uploaded).toHaveLength(MAX_ANDROID_LOG_BYTES_PER_SOURCE);
expect(uploaded).not.toContain('head');
});
@@ -1,97 +0,0 @@
import { type BuildStepGlobalContext } from '@expo/steps';
import fs from 'node:fs';
import { mkdtemp, rm } from 'node:fs/promises';
import { createServer } from 'node:http';
import { PassThrough } from 'node:stream';
import os from 'node:os';
import path from 'node:path';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { type CustomBuildContext } from '../../../customBuildContext';
import {
collectAndroidEmulatorLogsAsync,
getAndroidEmulatorLogCapture,
} 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('retries a finalized capped log while its producer keeps writing, with 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 = getAndroidEmulatorLogCapture(global);
const input = new PassThrough();
capture.attach(input, filePath, 'emulator-output');
input.write(payload);
let requests = 0;
let receivedBytes = 0;
const server = createServer((request, response) => {
requests++;
if (requests === 1) {
input.write('ignored after finalization');
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(input.readableFlowing).toBe(true);
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 {
input.end();
server.closeAllConnections();
await new Promise<void>(resolve => server.close(() => resolve()));
await rm(directory, { recursive: true, force: true });
}
});
@@ -13,6 +13,7 @@ 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');
@@ -123,6 +124,41 @@ 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,29 +22,17 @@ 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('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 () => {
it('warns and resolves when the upload fails', async () => {
await writeFile(filePath, '{}\n');
jest
.mocked(uploadDeviceRunSessionArtifactAsync)
.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;
});
.mockRejectedValueOnce(new Error('storage unavailable'));
await expect(uploadServeSimLogsFileAsync(ctx, args)).resolves.toBeUndefined();
expect(logger.warn).toHaveBeenCalledWith(
expect.objectContaining({ err: expect.any(Error) }),
@@ -0,0 +1,79 @@
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,34 +1,41 @@
import { type bunyan } from '@expo/logger';
import { type BuildStepGlobalContext } from '@expo/steps';
import { createReadStream } from 'node:fs';
import { stat } from 'node:fs/promises';
import path from 'node:path';
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 { uploadDeviceRunSessionArtifactAsync } from './deviceRunSessionArtifacts';
import { uploadDeviceRunSessionFileAsync } from './uploadDeviceRunSessionFile';
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;
uploaded: Set<string>;
collecting?: Promise<void>;
upload?: Promise<void>;
};
// A worker can execute more than one job; each workflow owns its capture and uploads.
const collections = new WeakMap<BuildStepGlobalContext, Collection>();
export function getAndroidEmulatorLogCapture(
global: BuildStepGlobalContext
global: BuildStepGlobalContext,
env: NodeJS.ProcessEnv
): AndroidEmulatorLogCapture {
let collection = collections.get(global);
if (!collection) {
collection = { capture: new AndroidEmulatorLogCapture(), uploaded: new Set() };
collection = {
capture: new AndroidEmulatorLogCapture(
env.DEVICE_RUN_SESSION_ID ? MAX_ANDROID_LOG_BYTES_PER_SOURCE : Infinity
),
};
collections.set(global, collection);
}
return collection.capture;
@@ -40,12 +47,10 @@ export async function collectAndroidEmulatorLogsAsync(
global,
deviceRunSessionId,
logger,
sources = ['emulator-output'],
}: {
global: BuildStepGlobalContext;
deviceRunSessionId: string;
logger: bunyan;
sources?: readonly AndroidEmulatorLogSource[];
}
): Promise<void> {
const collection = collections.get(global);
@@ -53,20 +58,13 @@ export async function collectAndroidEmulatorLogsAsync(
logger.info('No Android emulator logs captured; skipping upload.');
return;
}
while (collection.collecting) {
await collection.collecting;
}
collection.collecting = uploadCapturedLogsAsync(ctx, {
// Generated session workflows collect once. Repeated calls share that finalization/upload.
collection.upload ??= uploadCapturedLogsAsync(ctx, {
collection,
deviceRunSessionId,
logger,
sources,
});
try {
await collection.collecting;
} finally {
collection.collecting = undefined;
}
await collection.upload;
}
async function uploadCapturedLogsAsync(
@@ -75,19 +73,14 @@ async function uploadCapturedLogsAsync(
collection,
deviceRunSessionId,
logger,
sources,
}: {
collection: Collection;
deviceRunSessionId: string;
logger: bunyan;
sources: readonly AndroidEmulatorLogSource[];
}
): Promise<void> {
const logs = await collection.capture.finishAsync();
for (const log of logs) {
if (!sources.includes(log.source) || collection.uploaded.has(log.filePath)) {
continue;
}
const filename = path.basename(log.filePath);
try {
const uploaded = await uploadLogAsync(ctx, {
@@ -96,7 +89,6 @@ async function uploadCapturedLogsAsync(
signal: AbortSignal.timeout(UPLOAD_TIMEOUT_MS),
});
if (uploaded) {
collection.uploaded.add(log.filePath);
logger.info(`Uploaded Android diagnostic log ${filename}.`);
}
} catch (err) {
@@ -117,40 +109,32 @@ async function uploadLogAsync(
signal: AbortSignal;
}
): Promise<boolean> {
const { size } = await stat(filePath);
if (size === 0) {
return false;
}
const filename = path.basename(filePath);
const readController = new AbortController();
const retainedSize =
size >= 1024 * 1024 ? `${Number((size / (1024 * 1024)).toFixed(2))} MiB` : `${size} bytes`;
const truncationNote = size < bytesSeen ? `; 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: bytesSeen,
omittedBytes: bytesSeen - size,
truncated: size < bytesSeen,
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,
},
};
},
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([signal, readController.signal]),
signal,
attempts: 2,
});
return true;
}
@@ -11,7 +11,7 @@ import {
import { withDeviceRunSessionTimeoutAsync } from './deviceRunSessionTimeout';
import { requireDeviceRunSessionWorkerApi } from './deviceRunSessionWorkerApi';
type ArtifactMetadata = {
export type ArtifactMetadata = {
deviceRunSessionId: string;
artifactId: string;
name: string;
@@ -1,9 +1,7 @@
import { type bunyan } from '@expo/logger';
import { createReadStream } from 'node:fs';
import { stat } from 'node:fs/promises';
import { type CustomBuildContext } from '../../customBuildContext';
import { uploadDeviceRunSessionArtifactAsync } from './deviceRunSessionArtifacts';
import { uploadDeviceRunSessionFileAsync } from './uploadDeviceRunSessionFile';
export async function uploadServeSimLogsFileAsync(
ctx: CustomBuildContext,
@@ -22,21 +20,22 @@ export async function uploadServeSimLogsFileAsync(
}
): Promise<void> {
try {
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] : [])]),
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,
});
} catch (err) {
logger.warn(
@@ -0,0 +1,44 @@
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,10 +13,7 @@ import { setTimeout } from 'node:timers/promises';
import { z } from 'zod';
import { Sentry } from '../sentry';
import {
type AndroidEmulatorLogSource,
LEGACY_ANDROID_EMULATOR_LOG_SOURCES,
} from './androidEmulatorLogSources';
import { type AndroidEmulatorLogSource } from './androidEmulatorLogSources';
import { type AndroidEmulatorLogCapture } from './androidEmulatorLogCapture';
import { retryAsync } from './retry';
@@ -331,21 +328,19 @@ export namespace AndroidEmulatorUtils {
deviceName,
env,
logcatDirectory,
logSources = LEGACY_ANDROID_EMULATOR_LOG_SOURCES,
logSources,
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;
@@ -370,10 +365,9 @@ export namespace AndroidEmulatorUtils {
});
}
const logcatPipe =
logCapture && logcatOutputPath
? await logCapture.createLogcatPipeAsync(logcatOutputPath)
: undefined;
const logcatOutput = logcatOutputPath
? await logCapture.prepareLogcatOutputAsync(logcatOutputPath)
: undefined;
const emulatorCommand = `${process.env.ANDROID_HOME}/emulator/emulator`;
const emulatorArgs = [
'-no-window',
@@ -381,9 +375,7 @@ export namespace AndroidEmulatorUtils {
'-writable-system',
'-noaudio',
'-no-snapshot-save',
...(logcatOutputPath
? ['-logcat', '*:v', '-logcat-output', logcatPipe?.path ?? logcatOutputPath]
: []),
...(logcatOutput ? ['-logcat', '*:v', '-logcat-output', logcatOutput.path] : []),
'-avd',
deviceName,
'-accel',
@@ -415,10 +407,10 @@ export namespace AndroidEmulatorUtils {
ignoreStdio: true,
env: emulatorEnv,
});
if (logcatPipe) {
emulatorPromise.child.once('close', logcatPipe.close);
if (logcatOutput) {
emulatorPromise.child.once('close', logcatOutput.close);
}
if (emulatorOutputPath && logCapture) {
if (emulatorOutputPath) {
for (const input of [emulatorPromise.child.stdout, emulatorPromise.child.stderr]) {
if (input) {
logCapture.attach(input, emulatorOutputPath, 'emulator-output');
@@ -427,33 +419,6 @@ 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.
@@ -504,7 +469,7 @@ export namespace AndroidEmulatorUtils {
// We don't want to await the SpawnPromise here.
// eslint-disable-next-line @typescript-eslint/return-await
return { emulatorPromise, serialId, logcatOutputPath, emulatorOutputPath };
return { emulatorPromise, serialId };
}
export async function waitForReadyAsync({
@@ -13,6 +13,8 @@ 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');
@@ -78,6 +80,8 @@ 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,
@@ -105,6 +109,8 @@ 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,
@@ -135,6 +141,8 @@ 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,
@@ -172,6 +180,8 @@ 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,
@@ -226,6 +236,8 @@ describe('AndroidEmulatorUtils', () => {
});
const startResult = await AndroidEmulatorUtils.startAsync({
logCapture: new AndroidEmulatorLogCapture(Infinity),
logSources: ALL_ANDROID_EMULATOR_LOG_SOURCES,
deviceName,
env: envForAttempt,
logcatDirectory,
@@ -309,6 +321,8 @@ 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,
@@ -319,7 +333,16 @@ describe('AndroidEmulatorUtils', () => {
await AndroidEmulatorUtils.waitForReadyAsync({ serialId, env: process.env });
await retryAsync(
async () => {
const contents = await fs.promises.readFile(startResult.emulatorOutputPath!, 'utf-8');
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'
);
if (!contents.includes('Android emulator version')) {
throw new Error('Did not find the emulator version in emulator process output yet.');
}
@@ -340,7 +363,16 @@ describe('AndroidEmulatorUtils', () => {
await retryAsync(
async () => {
const contents = await fs.promises.readFile(startResult.logcatOutputPath!, 'utf-8');
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'
);
if (!contents.includes(marker)) {
throw new Error(`Did not find marker ${marker} in logcat output yet.`);
}
@@ -384,6 +416,8 @@ 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, once } from 'node:events';
import { EventEmitter } from 'node:events';
import fs from 'node:fs';
import { Socket } from 'node:net';
import os from 'node:os';
@@ -15,7 +15,15 @@ import {
} from '../AndroidEmulatorUtils';
import { retryAsync } from '../retry';
import { AndroidEmulatorLogCapture } from '../androidEmulatorLogCapture';
import { AndroidEmulatorLogSourcesSchema } from '../androidEmulatorLogSources';
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');
jest.mock('@expo/turtle-spawn', () => ({
__esModule: true,
@@ -37,9 +45,11 @@ const mockedRetryAsync = jest.mocked(retryAsync);
describe('AndroidEmulatorUtils', () => {
let temporaryDirectories: string[] = [];
let homeDirectory: string;
beforeEach(() => {
temporaryDirectories = [];
beforeEach(async () => {
homeDirectory = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'android-utils-home-'));
temporaryDirectories = [homeDirectory];
jest.mocked(Sentry.capture).mockReset();
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
mockedRetryAsync.mockImplementation(async fn => await fn(0));
@@ -57,7 +67,7 @@ describe('AndroidEmulatorUtils', () => {
describe(AndroidEmulatorUtils.createAsync, () => {
it('applies an explicit LCD configuration after screen scaling', async () => {
const deviceName = 'eas-simulator' as AndroidVirtualDeviceName;
const avdDirectory = `/home/expo/.android/avd/${deviceName}.avd`;
const avdDirectory = `${homeDirectory}/.android/avd/${deviceName}.avd`;
await fs.promises.mkdir(avdDirectory, { recursive: true });
await fs.promises.writeFile(
`${avdDirectory}/config.ini`,
@@ -85,7 +95,7 @@ describe('AndroidEmulatorUtils', () => {
lcdHeight: 1600,
lcdDensity: 262,
env: {
HOME: '/home/expo',
HOME: homeDirectory,
ANDROID_EMULATOR_ADJUST_SCREEN: '1',
ANDROID_EMULATOR_ADJUST_HEAP_SIZE: '0',
},
@@ -143,7 +153,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 = `/home/expo/.android/avd/${deviceName}.avd`;
const avdDirectory = `${homeDirectory}/.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);
@@ -159,7 +169,7 @@ describe('AndroidEmulatorUtils', () => {
lcdHeight: null,
lcdDensity: null,
env: {
HOME: '/home/expo',
HOME: homeDirectory,
ANDROID_EMULATOR_ADJUST_HEAP_SIZE: '0',
DEVICE_RUN_SESSION_ID: sessionId,
},
@@ -174,6 +184,24 @@ 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.
@@ -204,35 +232,30 @@ describe('AndroidEmulatorUtils', () => {
return { child };
}
it('captures logcat and emulator process output', async () => {
it('names log files after the device and detaches the emulator', 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');
const result = await AndroidEmulatorUtils.startAsync({
await AndroidEmulatorUtils.startAsync({
logSources: ALL_ANDROID_EMULATOR_LOG_SOURCES,
logCapture: buildCapture,
deviceName,
env: process.env,
logcatDirectory: outputDir,
});
expect(result.logcatOutputPath).toMatch(
expect(await logFile(outputDir, 'logcat')).toMatch(
new RegExp(`^${outputDir}/eas-simulator-[a-f0-9]{8}-[a-f0-9]{4}-logcat\\.log$`)
);
expect(result.emulatorOutputPath).toMatch(
expect(await logFile(outputDir, 'emulator')).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.arrayContaining(['-logcat', '*:v', '-logcat-output', result.logcatOutputPath]),
expect.objectContaining({
detached: true,
stdio: ['ignore', 'pipe', 'pipe'],
ignoreStdio: true,
})
expect.any(Array),
expect.objectContaining({ detached: true, ignoreStdio: true })
);
expect(mockedSpawn).not.toHaveBeenCalledWith(
'adb',
@@ -244,103 +267,7 @@ 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([
@@ -348,37 +275,77 @@ describe('AndroidEmulatorUtils', () => {
{ sources: ['emulator-output'] },
{ sources: ['device-logcat'] },
{ sources: ['emulator-output', 'device-logcat'] },
])('bounds only selected session sources: $sources', async ({ sources }) => {
const outputDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'bounded-logs-'));
])('records only $sources', async ({ sources }) => {
const outputDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'selected-logs-'));
temporaryDirectories.push(outputDir);
const deviceName = 'bounded-emulator' as AndroidVirtualDeviceName;
const deviceName = 'selected-emulator' as AndroidVirtualDeviceName;
const { child } = mockSuccessfulStart(deviceName);
const capture = new AndroidEmulatorLogCapture(10);
const close = jest.fn();
const createPipe = jest.spyOn(capture, 'createLogcatPipeAsync').mockResolvedValue({
path: '/logcat.pipe',
close,
closed: Promise.resolve(),
});
const result = await AndroidEmulatorUtils.startAsync({
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 () => {
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 close = jest.fn();
const createPipe = jest.spyOn(capture, 'prepareLogcatOutputAsync').mockResolvedValue({
path: '/logcat.pipe',
close,
closed: Promise.resolve(),
});
await AndroidEmulatorUtils.startAsync({
deviceName,
env: {},
logcatDirectory: outputDir,
logSources: ['device-logcat'],
logCapture: capture,
});
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'));
expect(createPipe).toHaveBeenCalledWith(await logFile(outputDir, 'logcat'));
expect(mockedSpawn.mock.calls[0][1]).toEqual(
expect.arrayContaining(['-logcat-output', '/logcat.pipe'])
);
expect(close).not.toHaveBeenCalled();
child.emit('close');
await capture.finishAsync();
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);
expect(close).toHaveBeenCalledTimes(1);
expect(await fs.promises.readdir(outputDir)).toHaveLength(1);
});
it('spawns the emulator directly with the same arguments when there is no launch gate', async () => {
@@ -388,7 +355,9 @@ describe('AndroidEmulatorUtils', () => {
mockSuccessfulStart(deviceName);
const env = { ANDROID_EMULATOR_EXTRA_ARGS: '-gpu swiftshader_indirect' };
const result = await AndroidEmulatorUtils.startAsync({
await AndroidEmulatorUtils.startAsync({
logSources: ALL_ANDROID_EMULATOR_LOG_SOURCES,
logCapture: buildCapture,
deviceName,
env,
logcatDirectory: outputDir,
@@ -409,7 +378,7 @@ describe('AndroidEmulatorUtils', () => {
'-logcat',
'*:v',
'-logcat-output',
result.logcatOutputPath,
await logFile(outputDir, 'logcat'),
'-avd',
deviceName,
'-accel',
@@ -446,7 +415,9 @@ describe('AndroidEmulatorUtils', () => {
: gatedSpawn(command, args, options as any)) as any);
const admitAsync = jest.fn().mockResolvedValue(undefined);
const result = await AndroidEmulatorUtils.startAsync({
await AndroidEmulatorUtils.startAsync({
logSources: ALL_ANDROID_EMULATOR_LOG_SOURCES,
logCapture: buildCapture,
deviceName,
env: {},
logcatDirectory: outputDir,
@@ -473,7 +444,7 @@ describe('AndroidEmulatorUtils', () => {
'-logcat',
'*:v',
'-logcat-output',
result.logcatOutputPath,
await logFile(outputDir, 'logcat'),
'-avd',
deviceName,
'-accel',
@@ -510,6 +481,8 @@ describe('AndroidEmulatorUtils', () => {
await expect(
AndroidEmulatorUtils.startAsync({
logSources: ALL_ANDROID_EMULATOR_LOG_SOURCES,
logCapture: buildCapture,
deviceName,
env: {},
logcatDirectory: outputDir,
@@ -534,6 +507,8 @@ 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 from 'node:fs/promises';
import fs, { type FileHandle } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { PassThrough, Readable } from 'node:stream';
@@ -37,25 +37,123 @@ async function record(
await capture.finishFileAsync(file);
}
it('shares a source budget across launches, with independent budgets for the two sources', async () => {
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 () => {
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 log is too long');
await record(capture, retry, 'second!!');
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: 22 },
{ filePath: retry, source: 'emulator-output', bytesSeen: 8 },
{ filePath: device, source: 'device-logcat', bytesSeen: 24 },
]);
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');
// 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');
});
it('reserves bytes across simultaneous captures and drains after finalization', async () => {
@@ -67,20 +165,22 @@ 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();
expect(await fs.readFile(first, 'utf8')).toBe('12345678');
expect(await fs.readFile(second, 'utf8')).toBe('ab');
// 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');
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 prefix, and closes after the writer exits', async () => {
it('drains a real FIFO beyond 20 MiB, retains the tail, and closes after the writer exits', async () => {
const file = path.join(directory, 'logcat.log');
const capture = new AndroidEmulatorLogCapture();
const pipe = await capture.createLogcatPipeAsync(file);
const pipe = await capture.prepareLogcatOutputAsync(file);
expect((await fs.stat(pipe.path)).isFIFO()).toBe(true);
const writer = spawn(
process.execPath,
@@ -110,8 +210,8 @@ it('drains a real FIFO beyond 20 MiB, retains the prefix, and closes after the w
const logs = await capture.finishAsync();
const result = await fs.readFile(file);
expect(result.length).toBe(MAX_ANDROID_LOG_BYTES_PER_SOURCE);
expect(result.subarray(0, 6).toString()).toBe('BEGIN\n');
expect(result.subarray(-4).toString()).toBe('xxxx');
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 },
]);
@@ -122,13 +222,71 @@ it('drains a real FIFO beyond 20 MiB, retains the prefix, and closes after the w
}
}, 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.createLogcatPipeAsync(path.join(directory, 'empty.log'));
const pipe = await capture.prepareLogcatOutputAsync(file);
pipe.close();
await pipe.closed;
await capture.finishAsync();
});
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);
it('reports a disk write failure once and continues draining output', async () => {
const capture = new AndroidEmulatorLogCapture(10);
@@ -1,12 +1,11 @@
import { execFile, spawn } from 'node:child_process';
import fs from 'node:fs';
import { Socket } from 'node:net';
import { type FileHandle } from 'node:fs/promises';
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;
@@ -16,19 +15,42 @@ export type FinalizedAndroidEmulatorLog = {
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. */
/**
* 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.
*/
export class AndroidEmulatorLogCapture {
private readonly bytes = new Map<AndroidEmulatorLogSource, number>();
private readonly sources = new Map<AndroidEmulatorLogSource, Capture[]>();
private readonly captures = new Map<string, Capture>();
private finishing = false;
private writes = 0;
constructor(private readonly maxBytes = MAX_ANDROID_LOG_BYTES_PER_SOURCE) {}
@@ -39,48 +61,11 @@ export class AndroidEmulatorLogCapture {
}
let capture = this.captures.get(filePath);
if (!capture) {
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 = { source, stream, inputs: new Set(), done: finished(stream), bytesSeen: 0 };
capture = this.open(filePath, source);
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 });
@@ -99,11 +84,16 @@ 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[]> {
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,
@@ -112,64 +102,142 @@ export class AndroidEmulatorLogCapture {
}));
}
/** Keep logcat separate from emulator stderr without an unbounded intermediate file. */
async createLogcatPipeAsync(
/** Select the output transport without exposing the capture budget to emulator launch. */
async prepareLogcatOutputAsync(
filePath: string
): Promise<{ path: string; close: () => void; closed: Promise<void> }> {
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);
// 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 reader = spawn(
'sh',
['-c', 'exec 3<"$1" || exit 1; printf ready >&2; exec cat <&3', 'android-logcat', pipePath],
{
stdio: ['ignore', 'pipe', 'pipe'],
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 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;
}
reader.unref();
for (const stream of [reader.stdout, reader.stderr]) {
if (stream instanceof Socket) {
stream.unref();
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));
}
}
return { path: pipePath, close, closed };
}
/** 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}`);
}
offset += bytesRead;
}
}
@@ -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 LEGACY_ANDROID_EMULATOR_LOG_SOURCES: readonly AndroidEmulatorLogSource[] = [
export const ALL_ANDROID_EMULATOR_LOG_SOURCES: readonly AndroidEmulatorLogSource[] = [
'emulator-output',
'device-logcat',
];
@@ -0,0 +1,66 @@
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,21 +883,20 @@ describe(Simulator, () => {
);
});
it.each([
{ sources: ['emulator-output'] },
{ sources: ['device-logcat'] },
{ sources: ['emulator-output', 'device-logcat'] },
])('passes explicitly selected Android log sources $sources', async ({ sources }) => {
it('accumulates repeated --android-log-source flags in order', async () => {
const { command } = createCommand([
'--platform',
'android',
'--non-interactive',
...sources.flatMap(source => ['--android-log-source', source]),
'--android-log-source',
'emulator-output',
'--android-log-source',
'device-logcat',
]);
await command.runAsync();
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(
graphqlClient,
expect.objectContaining({ android: { logSources: sources } })
expect.objectContaining({ android: { logSources: ['emulator-output', 'device-logcat'] } })
);
});
@@ -373,6 +373,19 @@ 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,
@@ -388,19 +401,7 @@ export default class Simulator extends EasCommand {
packageVersion: flags['package-version'],
networkCapture: flags['network-capture'],
...(networkCaptureFields.length ? { networkCaptureFields } : {}),
...(platform === AppPlatform.Ios
? deviceIdentifier
? { ios: { deviceIdentifier } }
: {}
: deviceIdentifier || systemImagePackage || androidLogSources
? {
android: {
...(deviceIdentifier ? { deviceIdentifier } : {}),
...(systemImagePackage ? { systemImagePackage } : {}),
...(androidLogSources ? { logSources: androidLogSources } : {}),
},
}
: {}),
...platformInput,
...(buildId ? { buildId } : {}),
...(buildFingerprint ? { buildFingerprint } : {}),
...(applicationArchiveUrlFromFlag