Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ This is the log of notable changes to EAS CLI and related packages.

- [build-tools][eas-cli] Detect iOS Development provisioning profiles and set correct code signing identity instead of treating them as Ad Hoc. ([#3496](https://github.com/expo/eas-cli/pull/3496) by [@qwertey6](https://github.com/qwertey6))
- [build-tools] Prevent detecting Yarn Modern as Classic based on lockfile ([#3572](https://github.com/expo/eas-cli/pull/3572) by [@kitten](https://github.com/kitten))
- [build-tools] Stop `eas/start_android_emulator` early on Linux when CPU virtualization flags (`vmx`/`svm`) are not available, with an error that points at nested-virtualization-capable build images. ([233500d2](https://github.com/expo/eas-cli/commit/233500d2cfe9f96c4b56ed64a78e199702d37f31) by [@gwdp](https://github.com/gwdp))

### 🧹 Chores

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import spawn from '@expo/turtle-spawn';
import { BuildRuntimePlatform } from '@expo/steps';

import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
Expand Down Expand Up @@ -34,11 +35,14 @@ jest.mock('../../../utils/AndroidEmulatorUtils', () => ({
const mockedSpawn = jest.mocked(spawn);
const mockedRetryAsync = jest.mocked(retryAsync);
const mockedAndroidUtils = jest.mocked(AndroidEmulatorUtils);

function createStep(callInputs?: Record<string, unknown>, envOverrides?: NodeJS.ProcessEnv) {
function createStep(
callInputs?: Record<string, unknown>,
envOverrides?: NodeJS.ProcessEnv,
runtimePlatform: BuildRuntimePlatform = BuildRuntimePlatform.LINUX
) {
const logger = createMockLogger();
const fn = createStartAndroidEmulatorBuildFunction();
const globalCtx = createGlobalContextMock({ logger });
const globalCtx = createGlobalContextMock({ logger, runtimePlatform });
globalCtx.updateEnv({ HOME: '/home/expo', ANDROID_HOME: '/android/home', ...envOverrides });
const step = fn.createBuildStepFromFunctionCall(globalCtx, {
callInputs,
Expand Down Expand Up @@ -212,4 +216,43 @@ describe(createStartAndroidEmulatorBuildFunction, () => {

expect(mockedAndroidUtils.disableWindowAndTransitionAnimationsAsync).not.toHaveBeenCalled();
});

it('fails early on Linux when cpu virtualization flags are not available', async () => {
mockedSpawn.mockRejectedValueOnce(new Error('grep did not match') as any);

const step = createStep();
await expect(step.executeAsync()).rejects.toThrow(/nested virtualization/i);

expect(mockedSpawn).toHaveBeenCalledWith('grep', ['-Eq', '(vmx|svm)', '/proc/cpuinfo'], {
env: expect.any(Object),
});
expect(mockedSpawn.mock.calls.some(([command]) => command === 'sdkmanager')).toBe(false);
});

it('continues startup on Linux when cpu virtualization flags are available', async () => {
await createStep().executeAsync();

expect(mockedSpawn).toHaveBeenCalledWith('grep', ['-Eq', '(vmx|svm)', '/proc/cpuinfo'], {
env: expect.any(Object),
});
expect(mockedSpawn.mock.calls.some(([command]) => command === 'sdkmanager')).toBe(true);
});

it('fails early on non-Linux hosts and does not probe /proc/cpuinfo', async () => {
await expect(
createStep(undefined, undefined, BuildRuntimePlatform.DARWIN).executeAsync()
).rejects.toThrow(/nested virtualization/i);

expect(mockedSpawn.mock.calls.some(([command]) => command === 'grep')).toBe(false);
expect(mockedSpawn.mock.calls.some(([command]) => command === 'sdkmanager')).toBe(false);
});

it('fails early on any non-Linux runtimePlatform value and does not probe /proc/cpuinfo', async () => {
await expect(
createStep(undefined, undefined, 'UNKNOWN' as BuildRuntimePlatform).executeAsync()
).rejects.toThrow(/nested virtualization/i);

expect(mockedSpawn.mock.calls.some(([command]) => command === 'grep')).toBe(false);
expect(mockedSpawn.mock.calls.some(([command]) => command === 'sdkmanager')).toBe(false);
});
});
48 changes: 46 additions & 2 deletions packages/build-tools/src/steps/functions/startAndroidEmulator.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { asyncResult } from '@expo/results';
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
import {
BuildFunction,
BuildRuntimePlatform,
BuildStepInput,
BuildStepInputValueTypeName,
} from '@expo/steps';
import spawn from '@expo/turtle-spawn';

import {
Expand All @@ -12,6 +17,36 @@ import { retryAsync } from '../../utils/retry';
const ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS = [60_000, 120_000, 180_000];
const ANDROID_STARTUP_RETRIES_COUNT = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS.length - 1;

const ANDROID_EMULATOR_LINUX_HARDWARE_VIRT_ERROR = [
'The Android emulator needs a Linux runner with nested virtualization. This job is not on that kind of runner, so the emulator cannot start here.',
'',
'In your workflow YAML, set runs_on to a nested-virtualization Linux image, for example:',
' runs_on: linux-medium-nested-virtualization',
' runs_on: linux-large-nested-virtualization',
'',
'If you use your own build hardware, use a Linux host with nested virtualization enabled for Android emulators.',
].join('\n');

/**
* On Linux, Android emulator hardware acceleration requires CPU virtualization flags.
* We detect support by checking `/proc/cpuinfo` for `vmx` (Intel) or `svm` (AMD).
* Non-Linux hosts are treated as unsupported for this step.
*/
async function getIsNestedVirtualizationEnabledAsync(
env: NodeJS.ProcessEnv,
runtimePlatform: BuildRuntimePlatform
): Promise<boolean> {
if (runtimePlatform !== BuildRuntimePlatform.LINUX) {
return false;
}
try {
await spawn('grep', ['-Eq', '(vmx|svm)', '/proc/cpuinfo'], { env });
return true;
} catch {
return false;
}
}

export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
return new BuildFunction({
namespace: 'eas',
Expand Down Expand Up @@ -43,7 +78,7 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
}),
],
fn: async ({ logger }, { inputs, env }) => {
fn: async ({ logger, global }, { inputs, env }) => {
try {
const availableDevices = await AndroidEmulatorUtils.getAvailableDevicesAsync({ env });
logger.info(`Available Android devices:\n- ${availableDevices.join(`\n- `)}`);
Expand All @@ -53,6 +88,15 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
logger.info('');
}

const isNestedVirtualizationEnabled = await getIsNestedVirtualizationEnabledAsync(
env,
global.runtimePlatform
);
if (!isNestedVirtualizationEnabled) {
logger.error(ANDROID_EMULATOR_LINUX_HARDWARE_VIRT_ERROR);
throw new Error(ANDROID_EMULATOR_LINUX_HARDWARE_VIRT_ERROR);
}

const deviceName = `${inputs.device_name.value}` as AndroidVirtualDeviceName;
const systemImagePackage = `${inputs.system_image_package.value}`;
// We can cast because allowedValueTypeName validated this is a string.
Expand Down
Loading