Skip to main content
Glama
emulator.ts25.7 kB
import { ChildProcess, exec, spawn } from "child_process"; import { promisify } from "util"; import { logger } from "../logger"; import { BootedDevice, DeviceInfo, ExecResult, ActionableError } from "../../models"; import { AdbUtils } from "./adb"; import { arch } from "os"; import { detectAndroidCommandLineTools, getBestAndroidToolsLocation } from "./detection"; const execAsync = async (command: string): Promise<ExecResult> => { const result = await promisify(exec)(command); // Add the required string methods // noinspection UnnecessaryLocalVariableJS const enhancedResult: ExecResult = { stdout: result.stdout, stderr: result.stderr, toString() { return this.stdout; }, trim() { return this.stdout.trim(); }, includes(searchString: string) { return this.stdout.includes(searchString); } }; return enhancedResult; }; export class AndroidEmulator { private execAsync: (command: string) => Promise<ExecResult>; private spawnFn: typeof spawn; private emulatorPath: string; /** * Create an DeviceUtils instance * @param execAsyncFn - promisified exec function (for testing) * @param spawnFn - spawn function (for testing) */ constructor( execAsyncFn: ((command: string) => Promise<ExecResult>) | null = null, spawnFn: typeof spawn | null = null ) { this.execAsync = execAsyncFn || execAsync; this.spawnFn = spawnFn || spawn; // Only set a fallback emulator path here; proper detection happens lazily this.emulatorPath = this.getFallbackEmulatorPath(); } /** * Get the path to the emulator executable. * This function tries the best available path synchronously, falling back to env/PATH. * Actual async detection is performed when needed by ensureEmulatorPath(). * @returns The path to the emulator */ private getFallbackEmulatorPath(): string { const androidHome = process.env.ANDROID_HOME || process.env.ANDROID_SDK_ROOT || process.env.ANDROID_SDK_HOME; if (androidHome) { return `${androidHome}/emulator/emulator`; } return "emulator"; } /** * Gets the emulator path asynchronously via detection. * @returns Promise<string> */ private async getEmulatorPath(): Promise<string> { // Try to find via Android command line tools detection try { const locations = await detectAndroidCommandLineTools(); const bestLocation = getBestAndroidToolsLocation(locations); if (bestLocation) { // For Homebrew installations, the emulator is in the SDK root directory if (bestLocation.source === "homebrew") { // /opt/homebrew/share/android-commandlinetools/cmdline-tools/latest -> /opt/homebrew/share/android-commandlinetools const sdkRoot = bestLocation.path.replace("/cmdline-tools/latest", ""); return `${sdkRoot}/emulator/emulator`; } // For standard installations, look in the parent SDK directory const sdkRoot = bestLocation.path.replace("/cmdline-tools/latest", ""); return `${sdkRoot}/emulator/emulator`; } } catch (error) { logger.debug(`Failed to detect emulator path via Android tools detection: ${error}`); } // Fall back to default return this.getFallbackEmulatorPath(); } /** * Ensure emulator path is properly detected and cached */ private async ensureEmulatorPath(): Promise<string> { // Update cached path if needed const detectedPath = await this.getEmulatorPath(); this.emulatorPath = detectedPath; return this.emulatorPath; } /** * Get the host architecture * @returns The host architecture string */ private getHostArchitecture(): string { return arch(); } /** * Check if an AVD architecture is compatible with the host * @param avdName - The AVD name to check * @returns Promise with compatibility result */ private async checkArchitectureCompatibility(avdName: string): Promise<{ compatible: boolean; hostArch: string; avdArch?: string; reason?: string }> { const hostArch = this.getHostArchitecture(); try { // Get AVD config to determine its architecture const result = await this.executeCommand(`-avd ${avdName} -verbose`, 3000); const output = result.stdout + result.stderr; // Look for architecture information in the output let avdArch: string | undefined; // Check for target architecture in verbose output const archMatch = output.match(/Found AVD target architecture: (\w+)/); if (archMatch) { avdArch = archMatch[1]; } // If we couldn't determine from verbose output, try to infer from common patterns if (!avdArch) { // This is a fallback - we'll let the actual emulator start attempt reveal the issue return { compatible: true, hostArch, reason: "Could not determine AVD architecture, allowing attempt" }; } // Check compatibility const compatible = this.isArchitectureCompatible(hostArch, avdArch); const reason = compatible ? undefined : `Host architecture '${hostArch}' cannot run AVD with architecture '${avdArch}'`; return { compatible, hostArch, avdArch, reason }; } catch (error) { // If we can't check, we'll let the emulator start attempt proceed and catch errors there logger.debug(`Could not check architecture compatibility for ${avdName}: ${error}`); return { compatible: true, hostArch, reason: "Could not verify compatibility, allowing attempt" }; } } /** * Check if host architecture can run AVD architecture * @param hostArch - Host architecture * @param avdArch - AVD architecture * @returns Boolean indicating compatibility */ private isArchitectureCompatible(hostArch: string, avdArch: string): boolean { // ARM64 hosts (Apple Silicon) cannot run x86/x86_64 AVDs if ((hostArch === "arm64" || hostArch === "aarch64") && (avdArch === "x86" || avdArch === "x86_64")) { return false; } // x86_64 hosts can generally run both x86 and ARM (with performance impact) // ARM hosts can run ARM AVDs return true; } /** * Detect if emulator output contains architecture-related PANIC errors * @param output - Emulator output to check * @returns Error details if PANIC detected, null otherwise */ private detectArchitecturePanic(output: string): { isPanic: boolean; message?: string; hostArch?: string; avdArch?: string } { // Look for the specific PANIC message about architecture compatibility const panicMatch = output.match(/PANIC: Avd's CPU Architecture '(\w+)' is not supported by the QEMU2 emulator on (\w+) host/); if (panicMatch) { const avdArch = panicMatch[1]; const hostArch = panicMatch[2]; return { isPanic: true, message: `AVD architecture '${avdArch}' is not supported on ${hostArch} host`, hostArch, avdArch }; } // Check for other PANIC messages that might be architecture-related if (output.includes("PANIC:") && (output.includes("architecture") || output.includes("CPU") || output.includes("QEMU"))) { return { isPanic: true, message: "Emulator PANIC detected (possibly architecture-related)", }; } return { isPanic: false }; } /** * Execute an emulator command * @param command - The command to execute * @param timeoutMs - Optional timeout in milliseconds * @returns Promise with stdout and stderr */ async executeCommand(command: string, timeoutMs?: number): Promise<ExecResult> { await this.ensureEmulatorPath(); const fullCommand = `${this.emulatorPath} ${command}`; logger.debug(`Executing emulator command: ${fullCommand}`); // Use Promise.race to implement timeout if specified if (timeoutMs) { let timeoutId: NodeJS.Timeout; const timeoutPromise = new Promise<ExecResult>((_, reject) => { timeoutId = setTimeout(() => reject(new ActionableError(`Command timed out after ${timeoutMs}ms: ${fullCommand}`)), timeoutMs ); }); try { return await Promise.race([this.execAsync(fullCommand), timeoutPromise]); } finally { clearTimeout(timeoutId!); } } return await this.execAsync(fullCommand); } /** * List all available AVDs * @returns Promise with array of AVD names */ async listAvds(): Promise<DeviceInfo[]> { try { const result = await this.executeCommand("-list-avds"); return result.stdout .split("\n") .map(line => line.trim()) .filter(line => line.length > 0) .map(name => ({ name, platform: "android", isRunning: false, source: "local" } as DeviceInfo)); } catch (error) { logger.error("Failed to list AVDs:", error); throw new ActionableError(`Failed to list AVDs: ${error}`); } } /** * Check if a specific AVD is running * @param avdName - The AVD name to check * @returns Promise with boolean indicating if the AVD is running */ async isAvdRunning(avdName: string): Promise<boolean> { const runningEmulators = await this.getBootedDevices(); return runningEmulators.some(emulator => emulator.name === avdName); } /** * Check if any emulator is currently running * @returns Promise with array of running emulator info */ async getBootedDevices(onlyEmulators: boolean = false): Promise<BootedDevice[]> { try { const adb = new AdbUtils(); const devices = await adb.getBootedAndroidDevices(); const runningDevices: BootedDevice[] = []; // Add local emulator devices const emulatorDevices = devices.filter(device => device.deviceId.startsWith("emulator-")); const physicalDevices = devices.filter(device => !device.deviceId.startsWith("emulator-")); for (const device of emulatorDevices) { const deviceId = device.deviceId; try { // Try to get the AVD name from the running emulator const adbWithDevice = new AdbUtils(device); const result = await adbWithDevice.executeCommand("emu avd name"); const avdName = result.stdout.trim().replace(/\r?\n.*$/, ""); // Remove any trailing newlines and additional text logger.info(`AVD name detection for ${deviceId}: raw="${result.stdout}" (${result.stdout.length} chars), cleaned="${avdName}"`); runningDevices.push({ name: avdName || `Unknown (${deviceId})`, platform: "android", deviceId: deviceId, source: "local" }); } catch (error) { // If we can't get the AVD name, just use the device ID logger.info(`Failed to get AVD name for ${deviceId}: ${error}`); runningDevices.push({ name: `Unknown (${deviceId})`, platform: "android", deviceId: deviceId, source: "local" }); } } for (const device of physicalDevices) { let deviceName = device.deviceId; // Default fallback try { // Try to get the actual device model name const adbWithDevice = new AdbUtils(device); const result = await adbWithDevice.executeCommand("shell getprop ro.product.model"); const modelName = result.stdout.trim(); if (modelName && modelName !== "unknown" && modelName.length > 0) { deviceName = modelName; logger.info(`Got model name for ${device.deviceId}: "${modelName}"`); } else { logger.info(`No model name found for ${device.deviceId}, using device ID`); } } catch (error) { logger.info(`Failed to get model name for ${device.deviceId}: ${error}`); } runningDevices.push({ name: deviceName, platform: "android", deviceId: device.deviceId, source: "local" }); } return runningDevices; } catch (error) { logger.error("Failed to get running emulators:", error); return []; } } /** * Start an emulator with the specified AVD * @param avdName - The AVD name to start * @returns Promise with the spawned child process */ async startEmulator( avdName: string, ): Promise<ChildProcess> { logger.info(`Using local emulator for AVD: ${avdName}`); // Check if the AVD exists const availableAvds = await this.listAvds(); if (!availableAvds.find(emu => emu.name === avdName)) { throw new ActionableError(`AVD '${avdName}' not found. Available AVDs: ${availableAvds.join(", ")}`); } // Check if already running if (await this.isAvdRunning(avdName)) { throw new ActionableError(`AVD '${avdName}' is already running`); } // Check architecture compatibility before attempting to start const compatibility = await this.checkArchitectureCompatibility(avdName); if (!compatibility.compatible && compatibility.reason) { logger.error(`Architecture compatibility check failed: ${compatibility.reason}`); throw new ActionableError(`Cannot start AVD '${avdName}': ${compatibility.reason}. On ${compatibility.hostArch} hosts, use AVDs with compatible architectures (e.g., arm64-v8a for Apple Silicon Macs).`); } const args = ["-avd", avdName]; logger.info(`Starting emulator with AVD: ${avdName}`); logger.debug(`Emulator command: ${this.emulatorPath} ${args.join(" ")}`); return new Promise((resolve, reject) => { const child = this.spawnFn(this.emulatorPath, args); // Buffer to collect initial output for PANIC detection let initialOutput = ""; const outputBuffer: string[] = []; const maxBufferLines = 50; // Keep last 50 lines for error analysis let startupValidationComplete = false; // Monitor emulator output for PANIC errors const monitorOutput = (data: any) => { const output = data.toString(); initialOutput += output; // Keep a rolling buffer of recent output const lines = output.split("\n"); outputBuffer.push(...lines); if (outputBuffer.length > maxBufferLines) { outputBuffer.splice(0, outputBuffer.length - maxBufferLines); } // Check for PANIC in the output const panicResult = this.detectArchitecturePanic(initialOutput); if (panicResult.isPanic) { logger.error(`Emulator PANIC detected: ${panicResult.message}`); // Create a more helpful error message let errorMessage = `Emulator failed to start: ${panicResult.message}`; if (panicResult.hostArch && panicResult.avdArch) { errorMessage += `\n\nSuggestion: On ${panicResult.hostArch} hosts, create AVDs with compatible architectures:`; if (panicResult.hostArch === "aarch64" || panicResult.hostArch === "arm64") { errorMessage += `\n- Use ARM64 system images (arm64-v8a) instead of x86/x86_64`; errorMessage += `\n- Example: avdmanager create avd -n MyAVD -k "system-images;android-35;google_apis;arm64-v8a"`; } else if (panicResult.hostArch === "x86" || panicResult.hostArch === "x86_64") { errorMessage += `\n- Use x86/x86_64 system images instead of ARM64`; errorMessage += `\n- Example: avdmanager create avd -n MyAVD -k "system-images;android-35;google_apis;x86_64"`; } } // Kill the process if it's still running if (!child.killed) { child.kill(); } // Reject the promise instead of just emitting error if (!startupValidationComplete) { startupValidationComplete = true; reject(new ActionableError(errorMessage)); } return; } // Check for successful startup indicators if (output.includes("INFO | emuDirName:") || output.includes("Hax is enabled") || output.includes("Detected GPU type")) { // Emulator has started successfully, resolve with the child process if (!startupValidationComplete) { startupValidationComplete = true; resolve(child); } } }; // Set a timeout for startup validation (5 seconds should be enough to detect PANIC) const startupTimeout = setTimeout(() => { if (!startupValidationComplete) { startupValidationComplete = true; // If no PANIC detected and no clear success indicators, assume success resolve(child); } }, 5000); // Log emulator output for debugging and monitor for PANIC child.stdout?.on("data", data => { logger.debug(`Emulator stdout: ${data}`); monitorOutput(data); }); child.stderr?.on("data", data => { logger.debug(`Emulator stderr: ${data}`); monitorOutput(data); }); child.on("exit", code => { clearTimeout(startupTimeout); if (code !== 0) { logger.error(`Emulator process exited with code: ${code}`); // Check if exit was due to PANIC const panicResult = this.detectArchitecturePanic(initialOutput); if (panicResult.isPanic) { logger.error(`Exit was due to PANIC: ${panicResult.message}`); if (!startupValidationComplete) { startupValidationComplete = true; reject(new ActionableError(`Emulator failed to start: ${panicResult.message}`)); } } else if (!startupValidationComplete) { startupValidationComplete = true; reject(new ActionableError(`Emulator process exited with code: ${code}`)); } } else { logger.info(`Emulator process exited with code: ${code}`); } }); child.on("error", error => { clearTimeout(startupTimeout); if (!startupValidationComplete) { startupValidationComplete = true; reject(new ActionableError(`Emulator failed to start: ${error.message}`)); } }); }); } /** * Kill a running emulator * @param device - The device to kill * @returns Promise that resolves when emulator is stopped */ async killDevice(device: BootedDevice): Promise<void> { const runningEmulators = await this.getBootedDevices(); const emulator = runningEmulators.find(emu => emu.deviceId === device.deviceId); if (!emulator || !emulator.deviceId) { throw new ActionableError(`Emulator '${device.name}' is not running`); } // Use ADB to stop the emulator const adb = new AdbUtils(emulator); await adb.executeCommand("emu kill"); logger.info(`Killed emulator '${device.name}'`); } /** * Wait for the emulator to be ready for use * @param avdName - The AVD name to wait for * @param timeoutMs - Maximum time to wait in milliseconds (default: 120000 = 2 minutes) * @returns Promise that resolves with device ID when emulator is ready */ async waitForEmulatorReady(avdName: string, timeoutMs: number = 120000): Promise<BootedDevice> { const startTime = Date.now(); // Read polling interval from environment variable (default: 500ms, minimum: 100ms) const pollingIntervalMs = Math.max(parseInt(process.env.EMULATOR_POLLING_INTERVAL_MS || "500", 10), 100); logger.info(`Waiting for emulator '${avdName}' to be ready... (polling interval: ${pollingIntervalMs}ms)`); // Start background polling immediately with configurable intervals let pollingActive = true; let foundDeviceId: string | null = null; const backgroundPoller = async () => { while (pollingActive && !foundDeviceId) { try { logger.info(`Background polling iteration - checking for emulator '${avdName}'...`); // For local emulators, check for running devices logger.info(`Checking for running local emulators...`); const runningEmulators = await this.getBootedDevices(); logger.info(`Device scan complete - found ${runningEmulators.length} running emulators`); if (runningEmulators.length > 0) { logger.info(`Found ${runningEmulators.length} running emulators: ${runningEmulators.map(e => `${e.name}(${e.deviceId})`).join(", ")}`); // Look for emulator by name first let emulator = runningEmulators.find(emu => emu.name === avdName); logger.info(`Exact name match for '${avdName}': ${emulator ? `Found ${emulator.deviceId}` : "Not found"}`); // If not found by exact name, try to find any local emulator if (!emulator) { emulator = runningEmulators.find(emu => emu.source === "local"); if (emulator) { logger.info(`Found local emulator with deviceId ${emulator.deviceId}, but name mismatch. Expected: ${avdName}, Found: ${emulator.name}`); } else { logger.info(`No local emulators found to use as fallback`); } } if (emulator && emulator.deviceId) { logger.info(`Target emulator found: ${emulator.name} (${emulator.deviceId}) - starting readiness checks`); // Check if the device is online and ready // Run device state and package manager checks in parallel for faster detection logger.info(`[PARALLEL] Running device state and package manager checks for ${emulator.deviceId}...`); const adb = new AdbUtils(emulator); try { const [deviceStateResult, packageManagerResult] = await Promise.allSettled([ adb.executeCommand("get-state"), adb.executeCommand("shell pm list packages") ]); // Check device state result if (deviceStateResult.status !== "fulfilled" || packageManagerResult.status !== "fulfilled") { logger.info(`[PARALLEL] Checks not yet complete: deviceStatus: ${deviceStateResult.status}, packageManager: ${packageManagerResult.status}`); } else { const stateOutput = deviceStateResult.value.stdout.trim(); logger.info(`[PARALLEL] Package manager command completed for ${emulator.deviceId} - output: ${packageManagerResult.value.stdout.length} bytes`); if (!stateOutput.includes("device")) { logger.info(`[PARALLEL] ❌ Device state check failed for ${emulator.deviceId}: state="${stateOutput}"`); } else if (!packageManagerResult.value.stdout || !packageManagerResult.value.stdout.includes("package:")) { logger.info(`[PARALLEL] ❌ Package manager returned no packages for ${emulator.deviceId} (${packageManagerResult.value.stdout.length} bytes output)`); } else if (packageManagerResult.value.stderr || packageManagerResult.value.stderr.includes("Failure")) { logger.info(`[PARALLEL] ❌ Package manager returned failure for ${emulator.deviceId}: ${packageManagerResult.value.stderr}`); } else { logger.info(`[PARALLEL] ✅ Device state check passed for ${emulator.deviceId}`); logger.info(`[PARALLEL] ✅ Package manager is responsive for ${emulator.deviceId} - emulator is ready!`); logger.info(`[PARALLEL] ✅ No package manager errors detected - marking emulator as ready`); foundDeviceId = emulator.deviceId; return; } } } catch (parallelError) { logger.info(`[PARALLEL] ❌ Parallel checks failed for ${emulator.deviceId}: ${parallelError}`); } } else { logger.info(`No suitable emulator found for '${avdName}' - will continue polling`); } } else { logger.info(`No running emulators detected - will continue polling`); } } catch (error) { logger.info(`Background polling error (will continue): ${error}`); } // Always wait at least the minimum polling interval logger.info(`Background polling cycle complete - sleeping ${pollingIntervalMs}ms before next check`); await this.sleep(pollingIntervalMs); } logger.info(`Background polling stopped - pollingActive: ${pollingActive}, foundDeviceId: ${foundDeviceId}`); }; // Start background polling immediately const pollingPromise = backgroundPoller(); // Main timeout loop while (Date.now() - startTime < timeoutMs && pollingActive) { if (foundDeviceId) { pollingActive = false; logger.info(`Emulator '${avdName}' is ready! Device ID: ${foundDeviceId}`); return { name: avdName, platform: "android", deviceId: foundDeviceId } as BootedDevice; } // Check less frequently in main loop since background polling is doing the work await this.sleep(500); } // Stop background polling pollingActive = false; await pollingPromise; if (foundDeviceId) { logger.info(`Emulator '${avdName}' is ready! Device ID: ${foundDeviceId}`); return { name: avdName, platform: "android", deviceId: foundDeviceId } as BootedDevice; } throw new ActionableError(`Emulator '${avdName}' failed to become ready within ${timeoutMs}ms`); } /** * Utility method to sleep for a specified duration * @param ms - Milliseconds to sleep */ private sleep(ms: number): Promise<void> { return new Promise(resolve => setTimeout(resolve, ms)); } }

Implementation Reference

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/zillow/auto-mobile'

If you have feedback or need assistance with the MCP directory API, please join our Discord server