Skip to content

Commit 5f62ef4

Browse files
authored
fix: tolerate android emulator avd probe timeouts (#243)
* fix: tolerate android emulator avd probe timeouts * fix: only ignore timed out android avd probes
1 parent 6669eee commit 5f62ef4

2 files changed

Lines changed: 121 additions & 8 deletions

File tree

src/platforms/android/__tests__/devices.test.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,16 @@ import assert from 'node:assert/strict';
33
import { promises as fs } from 'node:fs';
44
import os from 'node:os';
55
import path from 'node:path';
6+
import { AppError } from '../../../utils/errors.ts';
67
import {
78
ensureAndroidEmulatorBooted,
9+
listAndroidDevices,
810
parseAndroidAvdList,
911
parseAndroidEmulatorAvdNameOutput,
1012
parseAndroidFeatureListForTv,
1113
parseAndroidTargetFromCharacteristics,
1214
resolveAndroidAvdName,
15+
resolveAndroidEmulatorAvdName,
1316
} from '../devices.ts';
1417

1518
const MOCK_ANDROID_ADB_SCRIPT = [
@@ -22,14 +25,23 @@ const MOCK_ANDROID_ADB_SCRIPT = [
2225
' exit 0',
2326
'fi',
2427
'if [ "$1" = "-s" ] && [ "$2" = "emulator-5554" ] && [ "$3" = "emu" ] && [ "$4" = "avd" ] && [ "$5" = "name" ]; then',
28+
' if [ "$AGENT_DEVICE_TEST_AVD_NAME_MODE" = "missing" ]; then',
29+
' exit 0',
30+
' fi',
2531
' echo "Pixel_9_Pro_XL"',
2632
' exit 0',
2733
'fi',
2834
'if [ "$1" = "-s" ] && [ "$2" = "emulator-5554" ] && [ "$3" = "shell" ] && [ "$4" = "getprop" ] && [ "$5" = "ro.boot.qemu.avd_name" ]; then',
35+
' if [ "$AGENT_DEVICE_TEST_AVD_NAME_MODE" = "missing" ]; then',
36+
' exit 0',
37+
' fi',
2938
' echo "Pixel_9_Pro_XL"',
3039
' exit 0',
3140
'fi',
3241
'if [ "$1" = "-s" ] && [ "$2" = "emulator-5554" ] && [ "$3" = "shell" ] && [ "$4" = "getprop" ] && [ "$5" = "persist.sys.avd_name" ]; then',
42+
' if [ "$AGENT_DEVICE_TEST_AVD_NAME_MODE" = "missing" ]; then',
43+
' exit 0',
44+
' fi',
3345
' echo "Pixel_9_Pro_XL"',
3446
' exit 0',
3547
'fi',
@@ -137,6 +149,7 @@ test('resolveAndroidAvdName supports space vs underscore matching', () => {
137149

138150
async function withMockedAndroidTools(
139151
run: (ctx: { emulatorLogPath: string; emulatorBootedPath: string }) => Promise<void>,
152+
options: { avdNameMode?: 'success' | 'missing' } = {},
140153
): Promise<void> {
141154
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-android-headless-'));
142155
const emulatorLogPath = path.join(tmpDir, 'emulator.log');
@@ -153,6 +166,7 @@ async function withMockedAndroidTools(
153166
PATH: `${tmpDir}${path.delimiter}${process.env.PATH ?? ''}`,
154167
AGENT_DEVICE_TEST_EMU_BOOTED_FILE: emulatorBootedPath,
155168
AGENT_DEVICE_TEST_EMU_LOG_FILE: emulatorLogPath,
169+
AGENT_DEVICE_TEST_AVD_NAME_MODE: options.avdNameMode ?? 'success',
156170
HOME: tmpDir,
157171
ANDROID_SDK_ROOT: undefined,
158172
ANDROID_HOME: undefined,
@@ -198,6 +212,72 @@ async function withMockedAndroidSdkRoot(
198212
}
199213
}
200214

215+
test('resolveAndroidEmulatorAvdName ignores probe timeouts and keeps probing', async () => {
216+
const calls: string[][] = [];
217+
const results = [
218+
new AppError('COMMAND_FAILED', 'adb timed out after 1500ms', { timeoutMs: 1500 }),
219+
{ stdout: '', stderr: '', exitCode: 0 },
220+
{ stdout: 'Pixel_9_Pro_XL\n', stderr: '', exitCode: 0 },
221+
];
222+
const runAdb = async (
223+
_cmd: string,
224+
args: string[],
225+
): Promise<{ stdout: string; stderr: string; exitCode: number }> => {
226+
calls.push(args);
227+
const next = results.shift();
228+
if (next instanceof AppError) throw next;
229+
assert.ok(next);
230+
return next;
231+
};
232+
233+
const avdName = await resolveAndroidEmulatorAvdName('emulator-5554', runAdb);
234+
235+
assert.equal(avdName, 'Pixel_9_Pro_XL');
236+
assert.deepEqual(
237+
calls.map((args) => args.slice(2)),
238+
[
239+
['shell', 'getprop', 'ro.boot.qemu.avd_name'],
240+
['shell', 'getprop', 'persist.sys.avd_name'],
241+
['emu', 'avd', 'name'],
242+
],
243+
);
244+
});
245+
246+
test('resolveAndroidEmulatorAvdName rethrows non-timeout probe failures', async () => {
247+
const failure = new AppError('COMMAND_FAILED', 'adb exited with code 1', {
248+
stderr: 'device offline',
249+
exitCode: 1,
250+
processExitError: true,
251+
});
252+
let callCount = 0;
253+
const runAdb = async (): Promise<{ stdout: string; stderr: string; exitCode: number }> => {
254+
callCount += 1;
255+
throw failure;
256+
};
257+
258+
await assert.rejects(
259+
async () => await resolveAndroidEmulatorAvdName('emulator-5554', runAdb),
260+
(error) => error === failure,
261+
);
262+
assert.equal(callCount, 1);
263+
});
264+
265+
test('listAndroidDevices falls back to model when emulator avd name is unavailable', async () => {
266+
await withMockedAndroidTools(
267+
async ({ emulatorBootedPath }) => {
268+
await fs.writeFile(emulatorBootedPath, 'ready', 'utf8');
269+
270+
const devices = await listAndroidDevices();
271+
272+
assert.equal(devices.length, 1);
273+
assert.equal(devices[0]?.id, 'emulator-5554');
274+
assert.equal(devices[0]?.name, 'Pixel 9 Pro XL');
275+
assert.equal(devices[0]?.kind, 'emulator');
276+
},
277+
{ avdNameMode: 'missing' },
278+
);
279+
});
280+
201281
test('ensureAndroidEmulatorBooted launches emulator in headless mode when requested', async () => {
202282
await withMockedAndroidTools(async ({ emulatorLogPath, emulatorBootedPath }) => {
203283
const device = await ensureAndroidEmulatorBooted({

src/platforms/android/devices.ts

Lines changed: 41 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ type AndroidDeviceDiscoveryOptions = {
2222
serialAllowlist?: ReadonlySet<string>;
2323
};
2424

25+
type AndroidAdbRunner = typeof runCmd;
26+
2527
function commandOutput(result: ExecResult): string {
2628
return `${result.stdout}\n${result.stderr}`;
2729
}
@@ -68,22 +70,53 @@ async function resolveAndroidDeviceName(serial: string, rawModel: string): Promi
6870
return modelName || serial;
6971
}
7072

71-
async function resolveAndroidEmulatorAvdName(serial: string): Promise<string | undefined> {
72-
const avdPropKeys = ['ro.boot.qemu.avd_name', 'persist.sys.avd_name'];
73-
for (const prop of avdPropKeys) {
74-
const result = await runCmd('adb', adbArgs(serial, ['shell', 'getprop', prop]), {
73+
async function runBestEffortAndroidEmulatorNameProbe(
74+
serial: string,
75+
args: string[],
76+
runAdb: AndroidAdbRunner,
77+
): Promise<ExecResult | undefined> {
78+
try {
79+
return await runAdb('adb', adbArgs(serial, args), {
7580
allowFailure: true,
7681
timeoutMs: ANDROID_EMULATOR_AVD_NAME_TIMEOUT_MS,
7782
});
83+
} catch (error) {
84+
const appError = asAppError(error);
85+
// Friendly-name lookup is optional during discovery, but only probe timeouts should fall back.
86+
if (isAndroidEmulatorNameProbeTimeout(appError)) {
87+
return undefined;
88+
}
89+
throw error;
90+
}
91+
}
92+
93+
function isAndroidEmulatorNameProbeTimeout(error: AppError): boolean {
94+
return error.code === 'COMMAND_FAILED' && typeof error.details?.timeoutMs === 'number';
95+
}
96+
97+
export async function resolveAndroidEmulatorAvdName(
98+
serial: string,
99+
runAdb: AndroidAdbRunner = runCmd,
100+
): Promise<string | undefined> {
101+
const avdPropKeys = ['ro.boot.qemu.avd_name', 'persist.sys.avd_name'];
102+
for (const prop of avdPropKeys) {
103+
const result = await runBestEffortAndroidEmulatorNameProbe(
104+
serial,
105+
['shell', 'getprop', prop],
106+
runAdb,
107+
);
108+
if (!result) continue;
78109
const value = result.stdout.trim();
79110
if (result.exitCode === 0 && value.length > 0) {
80111
return value;
81112
}
82113
}
83-
const emuResult = await runCmd('adb', adbArgs(serial, ['emu', 'avd', 'name']), {
84-
allowFailure: true,
85-
timeoutMs: ANDROID_EMULATOR_AVD_NAME_TIMEOUT_MS,
86-
});
114+
const emuResult = await runBestEffortAndroidEmulatorNameProbe(
115+
serial,
116+
['emu', 'avd', 'name'],
117+
runAdb,
118+
);
119+
if (!emuResult) return undefined;
87120
const emuValue = parseAndroidEmulatorAvdNameOutput(emuResult.stdout);
88121
if (emuResult.exitCode === 0 && emuValue) {
89122
return emuValue;

0 commit comments

Comments
 (0)