From 5bceb959496c149dedf08474ef7942766e254921 Mon Sep 17 00:00:00 2001 From: Rui Marinho Date: Mon, 16 Mar 2026 12:05:54 +0000 Subject: [PATCH 1/2] Use shared EmulatorRunner from android-tools for BootAndroidEmulator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the 454-line BootAndroidEmulator implementation with a thin ~180-line wrapper that delegates to EmulatorRunner.BootEmulatorAsync() from Xamarin.Android.Tools.AndroidSdk. Key changes: - Remove all process management, polling, and boot detection logic - Delegate to EmulatorRunner.BootEmulatorAsync() for the full 3-phase boot: check online → check AVD running → launch + poll + wait - Map EmulatorBootResult errors to existing XA0143/XA0145 error codes - Virtual ExecuteBoot() method for clean test mocking - Update submodule to feature/emulator-runner (d8ee2d5) Tests updated from 9 to 10 (added ExtraArguments and UnknownError tests) using simplified mock pattern — MockBootAndroidEmulator overrides ExecuteBoot() to return canned EmulatorBootResult values. Depends on: dotnet/android-tools#284 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- external/xamarin-android-tools | 2 +- .../Tasks/BootAndroidEmulator.cs | 366 +++--------------- .../Tasks/BootAndroidEmulatorTests.cs | 193 ++++----- 3 files changed, 134 insertions(+), 427 deletions(-) diff --git a/external/xamarin-android-tools b/external/xamarin-android-tools index 40b30131791..d8ee2d591af 160000 --- a/external/xamarin-android-tools +++ b/external/xamarin-android-tools @@ -1 +1 @@ -Subproject commit 40b30131791e7e996e20d461f8d3694b273f6985 +Subproject commit d8ee2d591afb4175c5ebf75e8ca261ed9c6685ff diff --git a/src/Xamarin.Android.Build.Tasks/Tasks/BootAndroidEmulator.cs b/src/Xamarin.Android.Build.Tasks/Tasks/BootAndroidEmulator.cs index bcfa0d656de..62ca7fc79a1 100644 --- a/src/Xamarin.Android.Build.Tasks/Tasks/BootAndroidEmulator.cs +++ b/src/Xamarin.Android.Build.Tasks/Tasks/BootAndroidEmulator.cs @@ -1,10 +1,8 @@ #nullable enable using System; -using System.Collections.Generic; using System.Diagnostics; using System.IO; -using System.Threading; using Microsoft.Android.Build.Tasks; using Microsoft.Build.Framework; using Xamarin.Android.Tools; @@ -24,11 +22,13 @@ namespace Xamarin.Android.Tasks; /// the emulator and waits for it to become fully ready. /// /// On success, outputs the resolved ADB serial and AdbTarget for use by subsequent tasks. +/// +/// Boot logic is delegated to and +/// in Xamarin.Android.Tools.AndroidSdk. /// public class BootAndroidEmulator : AndroidTask { const int DefaultBootTimeoutSeconds = 300; - const int PollIntervalMilliseconds = 500; public override string TaskPrefix => "BAE"; @@ -95,71 +95,63 @@ public class BootAndroidEmulator : AndroidTask public override bool RunTask () { var adbPath = ResolveAdbPath (); + var emulatorPath = ResolveEmulatorPath (); + var logger = this.CreateTaskLogger (); - // Check if DeviceId is already a known online ADB serial - if (IsOnlineAdbDevice (adbPath, Device)) { - Log.LogMessage (MessageImportance.Normal, $"Device '{Device}' is already online."); - ResolvedDevice = Device; - AdbTarget = $"-s {Device}"; - return true; - } - - // DeviceId is not an online serial — treat it as an AVD name and boot it - Log.LogMessage (MessageImportance.Normal, $"Device '{Device}' is not an online ADB device. Treating as AVD name."); + var options = new EmulatorBootOptions { + BootTimeout = TimeSpan.FromSeconds (BootTimeoutSeconds), + AdditionalArgs = ParseExtraArguments (EmulatorExtraArguments), + }; - var emulatorPath = ResolveEmulatorPath (); - var avdName = Device; + var result = ExecuteBoot (adbPath, emulatorPath, logger, Device, options); - // Check if this AVD is already running (but perhaps still booting) - var existingSerial = FindRunningEmulatorForAvd (adbPath, avdName); - if (existingSerial != null) { - Log.LogMessage (MessageImportance.High, $"Emulator '{avdName}' is already running as '{existingSerial}'"); - ResolvedDevice = existingSerial; - AdbTarget = $"-s {existingSerial}"; - return WaitForFullBoot (adbPath, avdName, existingSerial); + if (result.Success) { + ResolvedDevice = result.Serial; + AdbTarget = $"-s {result.Serial}"; + Log.LogMessage (MessageImportance.High, $"Emulator '{Device}' ({result.Serial}) is fully booted and ready."); + return true; } - // Launch the emulator process in the background - Log.LogMessage (MessageImportance.High, $"Booting emulator '{avdName}'..."); - using var emulatorProcess = LaunchEmulatorProcess (emulatorPath, avdName); - if (emulatorProcess == null) { - return false; + // Map the error message to the appropriate MSBuild error code + var errorMessage = result.ErrorMessage ?? "Unknown error"; + + if (errorMessage.Contains ("Failed to launch")) { + Log.LogCodedError ("XA0143", Properties.Resources.XA0143, Device, errorMessage); + } else if (errorMessage.Contains ("Timed out")) { + Log.LogCodedError ("XA0145", Properties.Resources.XA0145, Device, BootTimeoutSeconds); + } else { + Log.LogCodedError ("XA0145", Properties.Resources.XA0145, Device, BootTimeoutSeconds); } - try { - var timeout = TimeSpan.FromSeconds (BootTimeoutSeconds); - var stopwatch = Stopwatch.StartNew (); + return false; + } - // Phase 1: Wait for the emulator to appear in 'adb devices' as online - Log.LogMessage (MessageImportance.Normal, "Waiting for emulator to appear in adb devices..."); - var serial = WaitForEmulatorOnline (adbPath, avdName, emulatorProcess, stopwatch, timeout); - if (serial == null) { - if (emulatorProcess.HasExited) { - Log.LogCodedError ("XA0144", Properties.Resources.XA0144, avdName, emulatorProcess.ExitCode); - } else { - Log.LogCodedError ("XA0145", Properties.Resources.XA0145, avdName, BootTimeoutSeconds); - } - return false; - } + /// + /// Executes the full boot flow via . + /// Virtual so tests can return canned results without launching real processes. + /// + protected virtual EmulatorBootResult ExecuteBoot ( + string adbPath, + string emulatorPath, + Action logger, + string device, + EmulatorBootOptions options) + { + var adbRunner = new AdbRunner (adbPath, logger: logger); + var emulatorRunner = new EmulatorRunner (emulatorPath, logger: logger); + return emulatorRunner.BootEmulatorAsync (device, adbRunner, options) + .GetAwaiter ().GetResult (); + } - ResolvedDevice = serial; - AdbTarget = $"-s {serial}"; - Log.LogMessage (MessageImportance.Normal, $"Emulator appeared as '{serial}'"); + /// + /// Parses space-separated extra arguments into an array suitable for . + /// + static string[]? ParseExtraArguments (string? extraArgs) + { + if (extraArgs.IsNullOrEmpty ()) + return null; - // Phase 2: Wait for the device to fully boot - return WaitForFullBoot (adbPath, avdName, serial); - } finally { - // Stop async reads and unsubscribe events; using var handles Dispose - try { - emulatorProcess.CancelOutputRead (); - emulatorProcess.CancelErrorRead (); - } catch (InvalidOperationException e) { - // Async reads may not have been started or process already exited - Log.LogDebugMessage ($"Failed to cancel async reads: {e}"); - } - emulatorProcess.OutputDataReceived -= EmulatorOutputDataReceived; - emulatorProcess.ErrorDataReceived -= EmulatorErrorDataReceived; - } + return extraArgs.Split ([' '], StringSplitOptions.RemoveEmptyEntries); } /// @@ -193,262 +185,4 @@ string ResolveEmulatorPath () return dir.IsNullOrEmpty () ? exe : Path.Combine (dir, exe); } - - /// - /// Checks whether the given deviceId is currently listed as an online device in 'adb devices'. - /// - protected virtual bool IsOnlineAdbDevice (string adbPath, string deviceId) - { - bool found = false; - - MonoAndroidHelper.RunProcess ( - adbPath, "devices", - Log, - onOutput: (sender, e) => { - if (e.Data != null && e.Data.Contains ("device") && !e.Data.Contains ("List of devices")) { - var parts = e.Data.Split (['\t', ' '], StringSplitOptions.RemoveEmptyEntries); - if (parts.Length >= 2 && parts [1] == "device" && - string.Equals (parts [0], deviceId, StringComparison.OrdinalIgnoreCase)) { - found = true; - } - } - }, - logWarningOnFailure: false - ); - - return found; - } - - /// - /// Checks if an emulator with the specified AVD name is already running by querying - /// 'adb devices' and then 'adb -s serial emu avd name' for each running emulator. - /// - protected virtual string? FindRunningEmulatorForAvd (string adbPath, string avdName) - { - var emulatorSerials = new List (); - - MonoAndroidHelper.RunProcess ( - adbPath, "devices", - Log, - onOutput: (sender, e) => { - if (e.Data != null && e.Data.StartsWith ("emulator-", StringComparison.OrdinalIgnoreCase) && e.Data.Contains ("device")) { - var parts = e.Data.Split (['\t', ' '], StringSplitOptions.RemoveEmptyEntries); - if (parts.Length >= 2 && parts [1] == "device") { - emulatorSerials.Add (parts [0]); - } - } - }, - logWarningOnFailure: false - ); - - foreach (var serial in emulatorSerials) { - var name = GetRunningAvdName (adbPath, serial); - if (string.Equals (name, avdName, StringComparison.OrdinalIgnoreCase)) { - return serial; - } - } - - return null; - } - - /// - /// Gets the AVD name from a running emulator via 'adb -s serial emu avd name'. - /// - protected virtual string? GetRunningAvdName (string adbPath, string serial) - { - string? avdName = null; - try { - var outputLines = new List (); - MonoAndroidHelper.RunProcess ( - adbPath, $"-s {serial} emu avd name", - Log, - onOutput: (sender, e) => { - if (!e.Data.IsNullOrEmpty ()) { - outputLines.Add (e.Data); - } - }, - logWarningOnFailure: false - ); - - if (outputLines.Count > 0) { - var name = outputLines [0].Trim (); - if (!name.IsNullOrEmpty () && !name.Equals ("OK", StringComparison.OrdinalIgnoreCase)) { - avdName = name; - } - } - } catch (Exception ex) { - Log.LogDebugMessage ($"Failed to get AVD name for {serial}: {ex.Message}"); - } - - return avdName; - } - - /// - /// Launches the emulator process in the background. The emulator window is shown by default, - /// but this can be customized (for example, by passing -no-window) via EmulatorExtraArguments. - /// - protected virtual Process? LaunchEmulatorProcess (string emulatorPath, string avdName) - { - var arguments = $"-avd \"{avdName}\""; - if (!EmulatorExtraArguments.IsNullOrEmpty ()) { - arguments += $" {EmulatorExtraArguments}"; - } - - Log.LogMessage (MessageImportance.Normal, $"Starting: {emulatorPath} {arguments}"); - - try { - var psi = new ProcessStartInfo { - FileName = emulatorPath, - Arguments = arguments, - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true, - }; - - var process = new Process { StartInfo = psi }; - - // Capture output for diagnostics but don't block on it - process.OutputDataReceived += EmulatorOutputDataReceived; - process.ErrorDataReceived += EmulatorErrorDataReceived; - - process.Start (); - process.BeginOutputReadLine (); - process.BeginErrorReadLine (); - - return process; - } catch (Exception ex) { - Log.LogCodedError ("XA0143", Properties.Resources.XA0143, avdName, ex.Message); - return null; - } - } - - void EmulatorOutputDataReceived (object sender, DataReceivedEventArgs e) - { - if (e.Data != null) { - Log.LogDebugMessage ($"emulator stdout: {e.Data}"); - } - } - - void EmulatorErrorDataReceived (object sender, DataReceivedEventArgs e) - { - if (e.Data != null) { - Log.LogDebugMessage ($"emulator stderr: {e.Data}"); - } - } - - /// - /// Polls 'adb devices' until a new emulator serial appears with state "device" (online). - /// Returns the serial or null on timeout / emulator process exit. - /// - string? WaitForEmulatorOnline (string adbPath, string avdName, Process emulatorProcess, Stopwatch stopwatch, TimeSpan timeout) - { - while (stopwatch.Elapsed < timeout) { - if (emulatorProcess.HasExited) { - return null; - } - - var serial = FindRunningEmulatorForAvd (adbPath, avdName); - if (serial != null) { - return serial; - } - - Thread.Sleep (PollIntervalMilliseconds); - } - - return null; - } - - /// - /// Waits for the emulator to fully boot by checking: - /// 1. sys.boot_completed property equals "1" - /// 2. Package manager is responsive (pm path android returns "package:") - /// - bool WaitForFullBoot (string adbPath, string avdName, string serial) - { - Log.LogMessage (MessageImportance.Normal, "Waiting for emulator to fully boot..."); - var stopwatch = Stopwatch.StartNew (); - var timeout = TimeSpan.FromSeconds (BootTimeoutSeconds); - - // Phase 1: Wait for sys.boot_completed == 1 - while (stopwatch.Elapsed < timeout) { - var bootCompleted = GetShellProperty (adbPath, serial, "sys.boot_completed"); - if (bootCompleted == "1") { - Log.LogMessage (MessageImportance.Normal, "sys.boot_completed = 1"); - break; - } - - Thread.Sleep (PollIntervalMilliseconds); - } - - if (stopwatch.Elapsed >= timeout) { - Log.LogCodedError ("XA0145", Properties.Resources.XA0145, avdName, BootTimeoutSeconds); - return false; - } - - var remaining = timeout - stopwatch.Elapsed; - Log.LogMessage (MessageImportance.Normal, $"Phase 1 complete. {remaining.TotalSeconds:F0}s remaining for package manager."); - - // Phase 2: Wait for package manager to be responsive - while (stopwatch.Elapsed < timeout) { - var pmResult = RunShellCommand (adbPath, serial, "pm path android"); - if (pmResult != null && pmResult.StartsWith ("package:", StringComparison.OrdinalIgnoreCase)) { - Log.LogMessage (MessageImportance.High, $"Emulator '{avdName}' ({serial}) is fully booted and ready."); - return true; - } - - Thread.Sleep (PollIntervalMilliseconds); - } - - Log.LogCodedError ("XA0145", Properties.Resources.XA0145, avdName, BootTimeoutSeconds); - return false; - } - - /// - /// Gets a system property from the device via 'adb -s serial shell getprop property'. - /// - protected virtual string? GetShellProperty (string adbPath, string serial, string propertyName) - { - string? value = null; - try { - MonoAndroidHelper.RunProcess ( - adbPath, $"-s {serial} shell getprop {propertyName}", - Log, - onOutput: (sender, e) => { - if (!e.Data.IsNullOrEmpty ()) { - value = e.Data.Trim (); - } - }, - logWarningOnFailure: false - ); - } catch (Exception ex) { - Log.LogDebugMessage ($"Failed to get property '{propertyName}' from {serial}: {ex.Message}"); - } - - return value; - } - - /// - /// Runs a shell command on the device and returns the first line of output. - /// - protected virtual string? RunShellCommand (string adbPath, string serial, string command) - { - string? result = null; - try { - MonoAndroidHelper.RunProcess ( - adbPath, $"-s {serial} shell {command}", - Log, - onOutput: (sender, e) => { - if (result == null && !e.Data.IsNullOrEmpty ()) { - result = e.Data.Trim (); - } - }, - logWarningOnFailure: false - ); - } catch (Exception ex) { - Log.LogDebugMessage ($"Failed to run shell command '{command}' on {serial}: {ex.Message}"); - } - - return result; - } } diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/BootAndroidEmulatorTests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/BootAndroidEmulatorTests.cs index 58ab1ae095e..9eb4b15c819 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/BootAndroidEmulatorTests.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/BootAndroidEmulatorTests.cs @@ -8,6 +8,7 @@ using Microsoft.Build.Utilities; using NUnit.Framework; using Xamarin.Android.Tasks; +using Xamarin.Android.Tools; namespace Xamarin.Android.Build.Tests; @@ -26,73 +27,23 @@ public void Setup () } /// - /// Mock version of BootAndroidEmulator that overrides all process-dependent methods - /// so we can test the task logic without launching real emulators or adb. + /// Mock version of BootAndroidEmulator that overrides + /// to return a configurable without launching real processes. /// class MockBootAndroidEmulator : BootAndroidEmulator { - public HashSet OnlineDevices { get; set; } = []; - public Dictionary RunningEmulatorAvdNames { get; set; } = new (); - public Dictionary EmulatorBootBehavior { get; set; } = new (); - public Dictionary BootCompletedValues { get; set; } = new (); - public Dictionary PmPathResults { get; set; } = new (); - public bool SimulateLaunchFailure { get; set; } - public string? LastLaunchAvdName { get; private set; } - - readonly Dictionary findCallCounts = new (); - - protected override bool IsOnlineAdbDevice (string adbPath, string deviceId) - => OnlineDevices.Contains (deviceId); - - protected override string? FindRunningEmulatorForAvd (string adbPath, string avdName) + public EmulatorBootResult BootResult { get; set; } = new () { Success = true, Serial = "emulator-5554" }; + public string? LastBootedDevice { get; private set; } + + protected override EmulatorBootResult ExecuteBoot ( + string adbPath, + string emulatorPath, + Action logger, + string device, + EmulatorBootOptions options) { - foreach (var kvp in RunningEmulatorAvdNames) { - if (string.Equals (kvp.Value, avdName, StringComparison.OrdinalIgnoreCase) && - OnlineDevices.Contains (kvp.Key)) { - return kvp.Key; - } - } - - if (EmulatorBootBehavior.TryGetValue (avdName, out var behavior)) { - findCallCounts.TryAdd (avdName, 0); - findCallCounts [avdName]++; - if (findCallCounts [avdName] >= behavior.PollsUntilOnline) { - OnlineDevices.Add (behavior.Serial); - RunningEmulatorAvdNames [behavior.Serial] = avdName; - return behavior.Serial; - } - } - - return null; - } - - protected override string? GetRunningAvdName (string adbPath, string serial) - => RunningEmulatorAvdNames.TryGetValue (serial, out var name) ? name : null; - - protected override Process? LaunchEmulatorProcess (string emulatorPath, string avdName) - { - LastLaunchAvdName = avdName; - - if (SimulateLaunchFailure) { - Log.LogError ("XA0143: Failed to launch emulator for AVD '{0}': {1}", avdName, "Simulated launch failure"); - return null; - } - - return Process.GetCurrentProcess (); - } - - protected override string? GetShellProperty (string adbPath, string serial, string propertyName) - { - if (propertyName == "sys.boot_completed" && BootCompletedValues.TryGetValue (serial, out var value)) - return value; - return null; - } - - protected override string? RunShellCommand (string adbPath, string serial, string command) - { - if (command == "pm path android" && PmPathResults.TryGetValue (serial, out var result)) - return result; - return null; + LastBootedDevice = device; + return BootResult; } } @@ -112,8 +63,12 @@ MockBootAndroidEmulator CreateTask (string device = "Pixel_6_API_33") [Test] public void AlreadyOnlineDevice_PassesThrough () { + // BootEmulatorAsync returns success immediately (device is already online) var task = CreateTask ("emulator-5554"); - task.OnlineDevices = ["emulator-5554"]; + task.BootResult = new EmulatorBootResult { + Success = true, + Serial = "emulator-5554", + }; Assert.IsTrue (task.RunTask (), "RunTask should succeed"); Assert.AreEqual ("emulator-5554", task.ResolvedDevice); @@ -125,7 +80,10 @@ public void AlreadyOnlineDevice_PassesThrough () public void AlreadyOnlinePhysicalDevice_PassesThrough () { var task = CreateTask ("0A041FDD400327"); - task.OnlineDevices = ["0A041FDD400327"]; + task.BootResult = new EmulatorBootResult { + Success = true, + Serial = "0A041FDD400327", + }; Assert.IsTrue (task.RunTask (), "RunTask should succeed"); Assert.AreEqual ("0A041FDD400327", task.ResolvedDevice); @@ -136,73 +94,54 @@ public void AlreadyOnlinePhysicalDevice_PassesThrough () public void AvdAlreadyRunning_WaitsForFullBoot () { var task = CreateTask ("Pixel_6_API_33"); - task.OnlineDevices = ["emulator-5554"]; - task.RunningEmulatorAvdNames = new () { - { "emulator-5554", "Pixel_6_API_33" } + task.BootResult = new EmulatorBootResult { + Success = true, + Serial = "emulator-5554", }; - task.BootCompletedValues = new () { { "emulator-5554", "1" } }; - task.PmPathResults = new () { { "emulator-5554", "package:/system/framework/framework-res.apk" } }; Assert.IsTrue (task.RunTask (), "RunTask should succeed"); Assert.AreEqual ("emulator-5554", task.ResolvedDevice); Assert.AreEqual ("-s emulator-5554", task.AdbTarget); + Assert.AreEqual ("Pixel_6_API_33", task.LastBootedDevice); } [Test] public void BootEmulator_AppearsAfterPolling () { var task = CreateTask ("Pixel_6_API_33"); - // Not online initially, will appear after 2 polls - task.EmulatorBootBehavior = new () { - { "Pixel_6_API_33", ("emulator-5556", 2) } + task.BootResult = new EmulatorBootResult { + Success = true, + Serial = "emulator-5556", }; - task.BootCompletedValues = new () { { "emulator-5556", "1" } }; - task.PmPathResults = new () { { "emulator-5556", "package:/system/framework/framework-res.apk" } }; Assert.IsTrue (task.RunTask (), "RunTask should succeed"); Assert.AreEqual ("emulator-5556", task.ResolvedDevice); Assert.AreEqual ("-s emulator-5556", task.AdbTarget); - Assert.AreEqual ("Pixel_6_API_33", task.LastLaunchAvdName); + Assert.AreEqual ("Pixel_6_API_33", task.LastBootedDevice); } [Test] public void LaunchFailure_ReturnsError () { var task = CreateTask ("Pixel_6_API_33"); - task.SimulateLaunchFailure = true; - - Assert.IsFalse (task.RunTask (), "RunTask should fail"); - Assert.IsTrue (errors.Any (e => e.Message != null && e.Message.Contains ("XA0143")), "Should have XA0143 error"); - Assert.IsNull (task.ResolvedDevice, "ResolvedDevice should be null"); - } - - [Test] - public void BootTimeout_BootCompletedNeverReaches1 () - { - var task = CreateTask ("Pixel_6_API_33"); - task.BootTimeoutSeconds = 0; // Immediate timeout - // Emulator appears immediately but never finishes booting - task.OnlineDevices = ["emulator-5554"]; - task.RunningEmulatorAvdNames = new () { - { "emulator-5554", "Pixel_6_API_33" } + task.BootResult = new EmulatorBootResult { + Success = false, + ErrorMessage = "Failed to launch emulator: Simulated launch failure", }; - task.BootCompletedValues = new () { { "emulator-5554", "0" } }; Assert.IsFalse (task.RunTask (), "RunTask should fail"); - Assert.IsTrue (errors.Any (e => e.Code == "XA0145"), "Should have XA0145 timeout error"); + Assert.IsTrue (errors.Any (e => e.Code == "XA0143"), "Should have XA0143 error"); + Assert.IsNull (task.ResolvedDevice, "ResolvedDevice should be null"); } [Test] - public void BootTimeout_PmNeverResponds () + public void BootTimeout_ReturnsError () { var task = CreateTask ("Pixel_6_API_33"); - task.BootTimeoutSeconds = 0; // Immediate timeout - task.OnlineDevices = ["emulator-5554"]; - task.RunningEmulatorAvdNames = new () { - { "emulator-5554", "Pixel_6_API_33" } + task.BootResult = new EmulatorBootResult { + Success = false, + ErrorMessage = "Timed out waiting for emulator 'Pixel_6_API_33' to boot within 10s.", }; - task.BootCompletedValues = new () { { "emulator-5554", "1" } }; - // PmPathResults not set — pm never responds Assert.IsFalse (task.RunTask (), "RunTask should fail"); Assert.IsTrue (errors.Any (e => e.Code == "XA0145"), "Should have XA0145 timeout error"); @@ -212,13 +151,10 @@ public void BootTimeout_PmNeverResponds () public void MultipleEmulators_FindsCorrectAvd () { var task = CreateTask ("Pixel_9_Pro_XL"); - task.OnlineDevices = ["emulator-5554", "emulator-5556"]; - task.RunningEmulatorAvdNames = new () { - { "emulator-5554", "pixel_7_-_api_35" }, - { "emulator-5556", "Pixel_9_Pro_XL" } + task.BootResult = new EmulatorBootResult { + Success = true, + Serial = "emulator-5556", }; - task.BootCompletedValues = new () { { "emulator-5556", "1" } }; - task.PmPathResults = new () { { "emulator-5556", "package:/system/framework/framework-res.apk" } }; Assert.IsTrue (task.RunTask (), "RunTask should succeed"); Assert.AreEqual ("emulator-5556", task.ResolvedDevice); @@ -233,12 +169,49 @@ public void ToolPaths_ResolvedFromAndroidSdkDirectory () Device = "emulator-5554", AndroidSdkDirectory = "/android/sdk", BootTimeoutSeconds = 10, + BootResult = new EmulatorBootResult { + Success = true, + Serial = "emulator-5554", + }, }; - task.OnlineDevices = ["emulator-5554"]; - // Tool paths are not set explicitly — ResolveAdbPath/ResolveEmulatorPath - // should compute them from AndroidSdkDirectory Assert.IsTrue (task.RunTask (), "RunTask should succeed"); Assert.AreEqual ("emulator-5554", task.ResolvedDevice); } + + [Test] + public void ExtraArguments_PassedToOptions () + { + string[]? capturedArgs = null; + var task = new MockBootAndroidEmulator { + BuildEngine = engine, + Device = "Pixel_6_API_33", + EmulatorToolPath = "/sdk/emulator/", + EmulatorToolExe = "emulator", + AdbToolPath = "/sdk/platform-tools/", + AdbToolExe = "adb", + BootTimeoutSeconds = 10, + EmulatorExtraArguments = "-no-snapshot-load -gpu auto", + BootResult = new EmulatorBootResult { + Success = true, + Serial = "emulator-5554", + }, + }; + + Assert.IsTrue (task.RunTask (), "RunTask should succeed"); + Assert.AreEqual ("emulator-5554", task.ResolvedDevice); + } + + [Test] + public void UnknownError_MapsToXA0145 () + { + var task = CreateTask ("Pixel_6_API_33"); + task.BootResult = new EmulatorBootResult { + Success = false, + ErrorMessage = "Some unexpected error occurred", + }; + + Assert.IsFalse (task.RunTask (), "RunTask should fail"); + Assert.IsTrue (errors.Any (e => e.Code == "XA0145"), "Unknown errors should map to XA0145"); + } } From 1060e4655442dddeb7326386923b082a18a7f43f Mon Sep 17 00:00:00 2001 From: Rui Marinho Date: Wed, 18 Mar 2026 09:15:20 +0000 Subject: [PATCH 2/2] Convert BootAndroidEmulator to AsyncTask, use EmulatorBootErrorKind enum MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Changed base class from AndroidTask to AsyncTask - Override RunTaskAsync() instead of RunTask() - ExecuteBoot → ExecuteBootAsync with CancellationToken parameter - Replace string-matching error classification with switch on ErrorKind enum - Update tests for async pattern (Execute() instead of RunTask()) - Add LastBootOptions capture + assertion for ExtraArguments test - Set ErrorKind on test BootResult data (LaunchFailed, Timeout, Unknown) - Update submodule to feature/emulator-runner with EmulatorBootErrorKind Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- external/xamarin-android-tools | 2 +- .../Tasks/BootAndroidEmulator.cs | 39 +++++++-------- .../Tasks/BootAndroidEmulatorTests.cs | 49 +++++++++++++------ 3 files changed, 52 insertions(+), 38 deletions(-) diff --git a/external/xamarin-android-tools b/external/xamarin-android-tools index d8ee2d591af..2bf8d679e95 160000 --- a/external/xamarin-android-tools +++ b/external/xamarin-android-tools @@ -1 +1 @@ -Subproject commit d8ee2d591afb4175c5ebf75e8ca261ed9c6685ff +Subproject commit 2bf8d679e9570989841a8b24e1b0291445053b79 diff --git a/src/Xamarin.Android.Build.Tasks/Tasks/BootAndroidEmulator.cs b/src/Xamarin.Android.Build.Tasks/Tasks/BootAndroidEmulator.cs index 62ca7fc79a1..cf340e28510 100644 --- a/src/Xamarin.Android.Build.Tasks/Tasks/BootAndroidEmulator.cs +++ b/src/Xamarin.Android.Build.Tasks/Tasks/BootAndroidEmulator.cs @@ -3,6 +3,7 @@ using System; using System.Diagnostics; using System.IO; +using System.Threading.Tasks; using Microsoft.Android.Build.Tasks; using Microsoft.Build.Framework; using Xamarin.Android.Tools; @@ -26,7 +27,7 @@ namespace Xamarin.Android.Tasks; /// Boot logic is delegated to and /// in Xamarin.Android.Tools.AndroidSdk. /// -public class BootAndroidEmulator : AndroidTask +public class BootAndroidEmulator : AsyncTask { const int DefaultBootTimeoutSeconds = 300; @@ -92,7 +93,7 @@ public class BootAndroidEmulator : AndroidTask [Output] public string? AdbTarget { get; set; } - public override bool RunTask () + public override async Task RunTaskAsync () { var adbPath = ResolveAdbPath (); var emulatorPath = ResolveEmulatorPath (); @@ -103,55 +104,51 @@ public override bool RunTask () AdditionalArgs = ParseExtraArguments (EmulatorExtraArguments), }; - var result = ExecuteBoot (adbPath, emulatorPath, logger, Device, options); + var result = await ExecuteBootAsync (adbPath, emulatorPath, logger, Device, options, CancellationToken).ConfigureAwait (false); if (result.Success) { ResolvedDevice = result.Serial; AdbTarget = $"-s {result.Serial}"; Log.LogMessage (MessageImportance.High, $"Emulator '{Device}' ({result.Serial}) is fully booted and ready."); - return true; + return; } - // Map the error message to the appropriate MSBuild error code - var errorMessage = result.ErrorMessage ?? "Unknown error"; - - if (errorMessage.Contains ("Failed to launch")) { - Log.LogCodedError ("XA0143", Properties.Resources.XA0143, Device, errorMessage); - } else if (errorMessage.Contains ("Timed out")) { - Log.LogCodedError ("XA0145", Properties.Resources.XA0145, Device, BootTimeoutSeconds); - } else { + switch (result.ErrorKind) { + case EmulatorBootErrorKind.LaunchFailed: + Log.LogCodedError ("XA0143", Properties.Resources.XA0143, Device, result.ErrorMessage ?? "Unknown launch error"); + break; + default: Log.LogCodedError ("XA0145", Properties.Resources.XA0145, Device, BootTimeoutSeconds); + break; } - - return false; } /// /// Executes the full boot flow via . /// Virtual so tests can return canned results without launching real processes. /// - protected virtual EmulatorBootResult ExecuteBoot ( + protected virtual async Task ExecuteBootAsync ( string adbPath, string emulatorPath, Action logger, string device, - EmulatorBootOptions options) + EmulatorBootOptions options, + System.Threading.CancellationToken cancellationToken) { var adbRunner = new AdbRunner (adbPath, logger: logger); var emulatorRunner = new EmulatorRunner (emulatorPath, logger: logger); - return emulatorRunner.BootEmulatorAsync (device, adbRunner, options) - .GetAwaiter ().GetResult (); + return await emulatorRunner.BootEmulatorAsync (device, adbRunner, options, cancellationToken).ConfigureAwait (false); } /// - /// Parses space-separated extra arguments into an array suitable for . + /// Parses space-separated extra arguments into a list suitable for . /// - static string[]? ParseExtraArguments (string? extraArgs) + static List? ParseExtraArguments (string? extraArgs) { if (extraArgs.IsNullOrEmpty ()) return null; - return extraArgs.Split ([' '], StringSplitOptions.RemoveEmptyEntries); + return new List (extraArgs.Split ([' '], StringSplitOptions.RemoveEmptyEntries)); } /// diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/BootAndroidEmulatorTests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/BootAndroidEmulatorTests.cs index 9eb4b15c819..a0300d28614 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/BootAndroidEmulatorTests.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/BootAndroidEmulatorTests.cs @@ -4,6 +4,8 @@ using System.Collections.Generic; using System.Diagnostics; using System.Linq; +using System.Threading; +using System.Threading.Tasks; using Microsoft.Build.Framework; using Microsoft.Build.Utilities; using NUnit.Framework; @@ -27,23 +29,26 @@ public void Setup () } /// - /// Mock version of BootAndroidEmulator that overrides + /// Mock version of BootAndroidEmulator that overrides /// to return a configurable without launching real processes. /// class MockBootAndroidEmulator : BootAndroidEmulator { public EmulatorBootResult BootResult { get; set; } = new () { Success = true, Serial = "emulator-5554" }; public string? LastBootedDevice { get; private set; } + public EmulatorBootOptions? LastBootOptions { get; private set; } - protected override EmulatorBootResult ExecuteBoot ( + protected override Task ExecuteBootAsync ( string adbPath, string emulatorPath, Action logger, string device, - EmulatorBootOptions options) + EmulatorBootOptions options, + CancellationToken cancellationToken) { LastBootedDevice = device; - return BootResult; + LastBootOptions = options; + return Task.FromResult (BootResult); } } @@ -60,17 +65,21 @@ MockBootAndroidEmulator CreateTask (string device = "Pixel_6_API_33") }; } + bool RunTaskSynchronously (MockBootAndroidEmulator task) + { + return task.Execute (); + } + [Test] public void AlreadyOnlineDevice_PassesThrough () { - // BootEmulatorAsync returns success immediately (device is already online) var task = CreateTask ("emulator-5554"); task.BootResult = new EmulatorBootResult { Success = true, Serial = "emulator-5554", }; - Assert.IsTrue (task.RunTask (), "RunTask should succeed"); + Assert.IsTrue (RunTaskSynchronously (task), "Task should succeed"); Assert.AreEqual ("emulator-5554", task.ResolvedDevice); Assert.AreEqual ("-s emulator-5554", task.AdbTarget); Assert.AreEqual (0, errors.Count, "Should have no errors"); @@ -85,7 +94,7 @@ public void AlreadyOnlinePhysicalDevice_PassesThrough () Serial = "0A041FDD400327", }; - Assert.IsTrue (task.RunTask (), "RunTask should succeed"); + Assert.IsTrue (RunTaskSynchronously (task), "Task should succeed"); Assert.AreEqual ("0A041FDD400327", task.ResolvedDevice); Assert.AreEqual ("-s 0A041FDD400327", task.AdbTarget); } @@ -99,7 +108,7 @@ public void AvdAlreadyRunning_WaitsForFullBoot () Serial = "emulator-5554", }; - Assert.IsTrue (task.RunTask (), "RunTask should succeed"); + Assert.IsTrue (RunTaskSynchronously (task), "Task should succeed"); Assert.AreEqual ("emulator-5554", task.ResolvedDevice); Assert.AreEqual ("-s emulator-5554", task.AdbTarget); Assert.AreEqual ("Pixel_6_API_33", task.LastBootedDevice); @@ -114,7 +123,7 @@ public void BootEmulator_AppearsAfterPolling () Serial = "emulator-5556", }; - Assert.IsTrue (task.RunTask (), "RunTask should succeed"); + Assert.IsTrue (RunTaskSynchronously (task), "Task should succeed"); Assert.AreEqual ("emulator-5556", task.ResolvedDevice); Assert.AreEqual ("-s emulator-5556", task.AdbTarget); Assert.AreEqual ("Pixel_6_API_33", task.LastBootedDevice); @@ -126,10 +135,11 @@ public void LaunchFailure_ReturnsError () var task = CreateTask ("Pixel_6_API_33"); task.BootResult = new EmulatorBootResult { Success = false, + ErrorKind = EmulatorBootErrorKind.LaunchFailed, ErrorMessage = "Failed to launch emulator: Simulated launch failure", }; - Assert.IsFalse (task.RunTask (), "RunTask should fail"); + Assert.IsFalse (RunTaskSynchronously (task), "Task should fail"); Assert.IsTrue (errors.Any (e => e.Code == "XA0143"), "Should have XA0143 error"); Assert.IsNull (task.ResolvedDevice, "ResolvedDevice should be null"); } @@ -140,10 +150,11 @@ public void BootTimeout_ReturnsError () var task = CreateTask ("Pixel_6_API_33"); task.BootResult = new EmulatorBootResult { Success = false, + ErrorKind = EmulatorBootErrorKind.Timeout, ErrorMessage = "Timed out waiting for emulator 'Pixel_6_API_33' to boot within 10s.", }; - Assert.IsFalse (task.RunTask (), "RunTask should fail"); + Assert.IsFalse (RunTaskSynchronously (task), "Task should fail"); Assert.IsTrue (errors.Any (e => e.Code == "XA0145"), "Should have XA0145 timeout error"); } @@ -156,7 +167,7 @@ public void MultipleEmulators_FindsCorrectAvd () Serial = "emulator-5556", }; - Assert.IsTrue (task.RunTask (), "RunTask should succeed"); + Assert.IsTrue (RunTaskSynchronously (task), "Task should succeed"); Assert.AreEqual ("emulator-5556", task.ResolvedDevice); Assert.AreEqual ("-s emulator-5556", task.AdbTarget); } @@ -175,14 +186,13 @@ public void ToolPaths_ResolvedFromAndroidSdkDirectory () }, }; - Assert.IsTrue (task.RunTask (), "RunTask should succeed"); + Assert.IsTrue (RunTaskSynchronously (task), "Task should succeed"); Assert.AreEqual ("emulator-5554", task.ResolvedDevice); } [Test] public void ExtraArguments_PassedToOptions () { - string[]? capturedArgs = null; var task = new MockBootAndroidEmulator { BuildEngine = engine, Device = "Pixel_6_API_33", @@ -198,8 +208,14 @@ public void ExtraArguments_PassedToOptions () }, }; - Assert.IsTrue (task.RunTask (), "RunTask should succeed"); + Assert.IsTrue (RunTaskSynchronously (task), "Task should succeed"); Assert.AreEqual ("emulator-5554", task.ResolvedDevice); + Assert.IsNotNull (task.LastBootOptions, "Boot options should be captured"); + Assert.IsNotNull (task.LastBootOptions!.AdditionalArgs, "AdditionalArgs should not be null"); + CollectionAssert.AreEqual ( + new[] { "-no-snapshot-load", "-gpu", "auto" }, + task.LastBootOptions.AdditionalArgs, + "Extra arguments should be parsed and passed to options"); } [Test] @@ -208,10 +224,11 @@ public void UnknownError_MapsToXA0145 () var task = CreateTask ("Pixel_6_API_33"); task.BootResult = new EmulatorBootResult { Success = false, + ErrorKind = EmulatorBootErrorKind.Unknown, ErrorMessage = "Some unexpected error occurred", }; - Assert.IsFalse (task.RunTask (), "RunTask should fail"); + Assert.IsFalse (RunTaskSynchronously (task), "Task should fail"); Assert.IsTrue (errors.Any (e => e.Code == "XA0145"), "Unknown errors should map to XA0145"); } }