From 9e408d86f09ea59dd6f2d5c740d181d11156ed82 Mon Sep 17 00:00:00 2001 From: Rui Marinho Date: Mon, 9 Mar 2026 12:06:20 +0000 Subject: [PATCH 01/16] Add EmulatorRunner for emulator CLI operations Adds EmulatorRunner with StartAvd, ListAvdNamesAsync, and BootAndWaitAsync. Adds virtual shell methods to AdbRunner for testability. Adds ConfigureEnvironment to AndroidEnvironmentHelper. 212/213 tests pass (1 pre-existing JDK failure). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Models/EmulatorBootOptions.cs | 18 + .../Models/EmulatorBootResult.cs | 15 + .../Runners/AdbRunner.cs | 36 +- .../Runners/AndroidEnvironmentHelper.cs | 11 + .../Runners/EmulatorRunner.cs | 237 +++++++++++++ .../EmulatorRunnerTests.cs | 326 ++++++++++++++++++ 6 files changed, 642 insertions(+), 1 deletion(-) create mode 100644 src/Xamarin.Android.Tools.AndroidSdk/Models/EmulatorBootOptions.cs create mode 100644 src/Xamarin.Android.Tools.AndroidSdk/Models/EmulatorBootResult.cs create mode 100644 src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs create mode 100644 tests/Xamarin.Android.Tools.AndroidSdk-Tests/EmulatorRunnerTests.cs diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Models/EmulatorBootOptions.cs b/src/Xamarin.Android.Tools.AndroidSdk/Models/EmulatorBootOptions.cs new file mode 100644 index 00000000..8765df77 --- /dev/null +++ b/src/Xamarin.Android.Tools.AndroidSdk/Models/EmulatorBootOptions.cs @@ -0,0 +1,18 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; + +namespace Xamarin.Android.Tools +{ + /// + /// Options for booting an Android emulator. + /// + public class EmulatorBootOptions + { + public TimeSpan BootTimeout { get; set; } = TimeSpan.FromSeconds (300); + public string? AdditionalArgs { get; set; } + public bool ColdBoot { get; set; } + public TimeSpan PollInterval { get; set; } = TimeSpan.FromMilliseconds (500); + } +} diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Models/EmulatorBootResult.cs b/src/Xamarin.Android.Tools.AndroidSdk/Models/EmulatorBootResult.cs new file mode 100644 index 00000000..59281792 --- /dev/null +++ b/src/Xamarin.Android.Tools.AndroidSdk/Models/EmulatorBootResult.cs @@ -0,0 +1,15 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Xamarin.Android.Tools +{ + /// + /// Result of an emulator boot operation. + /// + public class EmulatorBootResult + { + public bool Success { get; set; } + public string? Serial { get; set; } + public string? ErrorMessage { get; set; } + } +} diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs index 24a62cec..da2e9799 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs +++ b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs @@ -47,7 +47,7 @@ public AdbRunner (string adbPath, IDictionary? environmentVariab /// Lists connected devices using 'adb devices -l'. /// For emulators, queries the AVD name using 'adb -s <serial> emu avd name'. /// - public async Task> ListDevicesAsync (CancellationToken cancellationToken = default) + public virtual async Task> ListDevicesAsync (CancellationToken cancellationToken = default) { using var stdout = new StringWriter (); using var stderr = new StringWriter (); @@ -135,6 +135,40 @@ public async Task StopEmulatorAsync (string serial, CancellationToken cancellati ProcessUtils.ThrowIfFailed (exitCode, $"adb -s {serial} emu kill", stderr); } + /// + /// Gets a system property from a device via 'adb -s <serial> shell getprop <property>'. + /// + public virtual async Task GetShellPropertyAsync (string serial, string propertyName, CancellationToken cancellationToken = default) + { + using var stdout = new StringWriter (); + using var stderr = new StringWriter (); + var psi = ProcessUtils.CreateProcessStartInfo (adbPath, "-s", serial, "shell", "getprop", propertyName); + var exitCode = await ProcessUtils.StartProcess (psi, stdout, stderr, cancellationToken, environmentVariables).ConfigureAwait (false); + return exitCode == 0 ? FirstNonEmptyLine (stdout.ToString ()) : null; + } + + /// + /// Runs a shell command on a device via 'adb -s <serial> shell <command>'. + /// + public virtual async Task RunShellCommandAsync (string serial, string command, CancellationToken cancellationToken = default) + { + using var stdout = new StringWriter (); + using var stderr = new StringWriter (); + var psi = ProcessUtils.CreateProcessStartInfo (adbPath, "-s", serial, "shell", command); + var exitCode = await ProcessUtils.StartProcess (psi, stdout, stderr, cancellationToken, environmentVariables).ConfigureAwait (false); + return exitCode == 0 ? FirstNonEmptyLine (stdout.ToString ()) : null; + } + + internal static string? FirstNonEmptyLine (string output) + { + foreach (var line in output.Split ('\n')) { + var trimmed = line.Trim (); + if (trimmed.Length > 0) + return trimmed; + } + return null; + } + /// /// Parses the output lines from 'adb devices -l'. /// Accepts an to avoid allocating a joined string. diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Runners/AndroidEnvironmentHelper.cs b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AndroidEnvironmentHelper.cs index 51f6309d..3cf67207 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/Runners/AndroidEnvironmentHelper.cs +++ b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AndroidEnvironmentHelper.cs @@ -37,4 +37,15 @@ internal static Dictionary GetEnvironmentVariables (string? sdkP return env; } + + /// + /// Applies Android SDK environment variables directly to a . + /// Used by runners that manage their own process lifecycle (e.g., EmulatorRunner). + /// + internal static void ConfigureEnvironment (System.Diagnostics.ProcessStartInfo psi, string? sdkPath, string? jdkPath) + { + var env = GetEnvironmentVariables (sdkPath, jdkPath); + foreach (var kvp in env) + psi.Environment [kvp.Key] = kvp.Value; + } } diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs b/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs new file mode 100644 index 00000000..837108f5 --- /dev/null +++ b/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs @@ -0,0 +1,237 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace Xamarin.Android.Tools; + +/// +/// Runs Android Emulator commands. +/// +public class EmulatorRunner +{ + readonly Func getSdkPath; + readonly Func? getJdkPath; + + public EmulatorRunner (Func getSdkPath) + : this (getSdkPath, null) + { + } + + public EmulatorRunner (Func getSdkPath, Func? getJdkPath) + { + this.getSdkPath = getSdkPath ?? throw new ArgumentNullException (nameof (getSdkPath)); + this.getJdkPath = getJdkPath; + } + + public string? EmulatorPath { + get { + var sdkPath = getSdkPath (); + if (string.IsNullOrEmpty (sdkPath)) + return null; + + var ext = OS.IsWindows ? ".exe" : ""; + var path = Path.Combine (sdkPath, "emulator", "emulator" + ext); + + return File.Exists (path) ? path : null; + } + } + + public bool IsAvailable => EmulatorPath is not null; + + string RequireEmulatorPath () + { + return EmulatorPath ?? throw new InvalidOperationException ("Android Emulator not found."); + } + + void ConfigureEnvironment (ProcessStartInfo psi) + { + AndroidEnvironmentHelper.ConfigureEnvironment (psi, getSdkPath (), getJdkPath?.Invoke ()); + } + + public Process StartAvd (string avdName, bool coldBoot = false, string? additionalArgs = null) + { + var emulatorPath = RequireEmulatorPath (); + + var args = new List { "-avd", avdName }; + if (coldBoot) + args.Add ("-no-snapshot-load"); + if (!string.IsNullOrEmpty (additionalArgs)) + args.Add (additionalArgs); + + var psi = ProcessUtils.CreateProcessStartInfo (emulatorPath, args.ToArray ()); + ConfigureEnvironment (psi); + + // Redirect stdout/stderr so the emulator process doesn't inherit the + // caller's pipes. Without this, parent processes (e.g. VS Code spawn) + // never see the 'close' event because the emulator holds the pipes open. + psi.RedirectStandardOutput = true; + psi.RedirectStandardError = true; + + var process = new Process { StartInfo = psi }; + process.Start (); + + return process; + } + + public async Task> ListAvdNamesAsync (CancellationToken cancellationToken = default) + { + var emulatorPath = RequireEmulatorPath (); + + using var stdout = new StringWriter (); + var psi = ProcessUtils.CreateProcessStartInfo (emulatorPath, "-list-avds"); + ConfigureEnvironment (psi); + + await ProcessUtils.StartProcess (psi, stdout, null, cancellationToken).ConfigureAwait (false); + + return ParseListAvdsOutput (stdout.ToString ()); + } + + internal static List ParseListAvdsOutput (string output) + { + var avds = new List (); + foreach (var line in output.Split ('\n')) { + var trimmed = line.Trim (); + if (!string.IsNullOrEmpty (trimmed)) + avds.Add (trimmed); + } + return avds; + } + + /// + /// Boots an emulator and waits for it to be fully booted. + /// Ported from dotnet/android BootAndroidEmulator MSBuild task. + /// + public async Task BootAndWaitAsync ( + string deviceOrAvdName, + AdbRunner adbRunner, + EmulatorBootOptions? options = null, + Action? logger = null, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace (deviceOrAvdName)) + throw new ArgumentException ("Device or AVD name must not be empty.", nameof (deviceOrAvdName)); + if (adbRunner == null) + throw new ArgumentNullException (nameof (adbRunner)); + + options = options ?? new EmulatorBootOptions (); + void Log (TraceLevel level, string message) => logger?.Invoke (level, message); + + Log (TraceLevel.Info, $"Booting emulator for '{deviceOrAvdName}'..."); + + // Phase 1: Check if deviceOrAvdName is already an online ADB device by serial + var devices = await adbRunner.ListDevicesAsync (cancellationToken).ConfigureAwait (false); + var onlineDevice = devices.FirstOrDefault (d => + d.Status == AdbDeviceStatus.Online && + string.Equals (d.Serial, deviceOrAvdName, StringComparison.OrdinalIgnoreCase)); + + if (onlineDevice != null) { + Log (TraceLevel.Info, $"Device '{deviceOrAvdName}' is already online."); + return new EmulatorBootResult { Success = true, Serial = onlineDevice.Serial }; + } + + // Phase 2: Check if AVD is already running (possibly still booting) + var runningSerial = FindRunningAvdSerial (devices, deviceOrAvdName); + if (runningSerial != null) { + Log (TraceLevel.Info, $"AVD '{deviceOrAvdName}' is already running as '{runningSerial}', waiting for full boot..."); + return await WaitForFullBootAsync (adbRunner, runningSerial, options, logger, cancellationToken).ConfigureAwait (false); + } + + // Phase 3: Launch the emulator + if (EmulatorPath == null) { + return new EmulatorBootResult { + Success = false, + ErrorMessage = "Android Emulator not found. Ensure the Android SDK is installed and the emulator is available.", + }; + } + + Log (TraceLevel.Info, $"Launching AVD '{deviceOrAvdName}'..."); + Process emulatorProcess; + try { + emulatorProcess = StartAvd (deviceOrAvdName, options.ColdBoot, options.AdditionalArgs); + } catch (Exception ex) { + return new EmulatorBootResult { + Success = false, + ErrorMessage = $"Failed to launch emulator: {ex.Message}", + }; + } + + // Poll for the new emulator serial to appear + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource (cancellationToken); + timeoutCts.CancelAfter (options.BootTimeout); + + try { + string? newSerial = null; + while (newSerial == null) { + timeoutCts.Token.ThrowIfCancellationRequested (); + await Task.Delay (options.PollInterval, timeoutCts.Token).ConfigureAwait (false); + + devices = await adbRunner.ListDevicesAsync (timeoutCts.Token).ConfigureAwait (false); + newSerial = FindRunningAvdSerial (devices, deviceOrAvdName); + } + + Log (TraceLevel.Info, $"Emulator appeared as '{newSerial}', waiting for full boot..."); + return await WaitForFullBootAsync (adbRunner, newSerial, options, logger, timeoutCts.Token).ConfigureAwait (false); + } catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { + return new EmulatorBootResult { + Success = false, + ErrorMessage = $"Timed out waiting for emulator '{deviceOrAvdName}' to boot within {options.BootTimeout.TotalSeconds}s.", + }; + } + } + + static string? FindRunningAvdSerial (IReadOnlyList devices, string avdName) + { + foreach (var d in devices) { + if (d.Type == AdbDeviceType.Emulator && + !string.IsNullOrEmpty (d.AvdName) && + string.Equals (d.AvdName, avdName, StringComparison.OrdinalIgnoreCase)) { + return d.Serial; + } + } + return null; + } + + async Task WaitForFullBootAsync ( + AdbRunner adbRunner, + string serial, + EmulatorBootOptions options, + Action? logger, + CancellationToken cancellationToken) + { + void Log (TraceLevel level, string message) => logger?.Invoke (level, message); + + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource (cancellationToken); + timeoutCts.CancelAfter (options.BootTimeout); + + try { + while (true) { + timeoutCts.Token.ThrowIfCancellationRequested (); + + var bootCompleted = await adbRunner.GetShellPropertyAsync (serial, "sys.boot_completed", timeoutCts.Token).ConfigureAwait (false); + if (string.Equals (bootCompleted, "1", StringComparison.Ordinal)) { + var pmResult = await adbRunner.RunShellCommandAsync (serial, "pm path android", timeoutCts.Token).ConfigureAwait (false); + if (pmResult != null && pmResult.StartsWith ("package:", StringComparison.Ordinal)) { + Log (TraceLevel.Info, $"Emulator '{serial}' is fully booted."); + return new EmulatorBootResult { Success = true, Serial = serial }; + } + } + + await Task.Delay (options.PollInterval, timeoutCts.Token).ConfigureAwait (false); + } + } catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { + return new EmulatorBootResult { + Success = false, + Serial = serial, + ErrorMessage = $"Timed out waiting for emulator '{serial}' to fully boot within {options.BootTimeout.TotalSeconds}s.", + }; + } + } +} + diff --git a/tests/Xamarin.Android.Tools.AndroidSdk-Tests/EmulatorRunnerTests.cs b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/EmulatorRunnerTests.cs new file mode 100644 index 00000000..490b18e7 --- /dev/null +++ b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/EmulatorRunnerTests.cs @@ -0,0 +1,326 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; + +namespace Xamarin.Android.Tools.Tests; + +[TestFixture] +public class EmulatorRunnerTests +{ + [Test] + public void ParseListAvdsOutput_MultipleAvds () + { + var output = "Pixel_7_API_35\nMAUI_Emulator\nNexus_5X\n"; + + var avds = EmulatorRunner.ParseListAvdsOutput (output); + + Assert.AreEqual (3, avds.Count); + Assert.AreEqual ("Pixel_7_API_35", avds [0]); + Assert.AreEqual ("MAUI_Emulator", avds [1]); + Assert.AreEqual ("Nexus_5X", avds [2]); + } + + [Test] + public void ParseListAvdsOutput_EmptyOutput () + { + var avds = EmulatorRunner.ParseListAvdsOutput (""); + Assert.AreEqual (0, avds.Count); + } + + [Test] + public void ParseListAvdsOutput_WindowsNewlines () + { + var output = "Pixel_7_API_35\r\nMAUI_Emulator\r\n"; + + var avds = EmulatorRunner.ParseListAvdsOutput (output); + + Assert.AreEqual (2, avds.Count); + Assert.AreEqual ("Pixel_7_API_35", avds [0]); + Assert.AreEqual ("MAUI_Emulator", avds [1]); + } + + [Test] + public void ParseListAvdsOutput_BlankLines () + { + var output = "\nPixel_7_API_35\n\n\nMAUI_Emulator\n\n"; + + var avds = EmulatorRunner.ParseListAvdsOutput (output); + + Assert.AreEqual (2, avds.Count); + } + + [Test] + public void EmulatorPath_FindsInSdk () + { + var tempDir = Path.Combine (Path.GetTempPath (), $"emu-test-{Path.GetRandomFileName ()}"); + var emulatorDir = Path.Combine (tempDir, "emulator"); + Directory.CreateDirectory (emulatorDir); + + try { + var emuName = OS.IsWindows ? "emulator.exe" : "emulator"; + File.WriteAllText (Path.Combine (emulatorDir, emuName), ""); + + var runner = new EmulatorRunner (() => tempDir); + + Assert.IsNotNull (runner.EmulatorPath); + Assert.IsTrue (runner.IsAvailable); + } finally { + Directory.Delete (tempDir, true); + } + } + + [Test] + public void EmulatorPath_MissingSdk_ReturnsNull () + { + var runner = new EmulatorRunner (() => "/nonexistent/path"); + Assert.IsNull (runner.EmulatorPath); + Assert.IsFalse (runner.IsAvailable); + } + + [Test] + public void EmulatorPath_NullSdk_ReturnsNull () + { + var runner = new EmulatorRunner (() => null); + Assert.IsNull (runner.EmulatorPath); + Assert.IsFalse (runner.IsAvailable); + } + + // --- BootAndWaitAsync tests (ported from dotnet/android BootAndroidEmulatorTests) --- + + [Test] + public async Task AlreadyOnlineDevice_PassesThrough () + { + var devices = new List { + new AdbDeviceInfo { + Serial = "emulator-5554", + Type = AdbDeviceType.Emulator, + Status = AdbDeviceStatus.Online, + AvdName = "Pixel_7_API_35", + }, + }; + + var mockAdb = new MockAdbRunner (devices); + var runner = new EmulatorRunner (() => null); + + var result = await runner.BootAndWaitAsync ("emulator-5554", mockAdb); + + Assert.IsTrue (result.Success); + Assert.AreEqual ("emulator-5554", result.Serial); + Assert.IsNull (result.ErrorMessage); + } + + [Test] + public async Task AvdAlreadyRunning_WaitsForFullBoot () + { + var devices = new List { + new AdbDeviceInfo { + Serial = "emulator-5554", + Type = AdbDeviceType.Emulator, + Status = AdbDeviceStatus.Online, + AvdName = "Pixel_7_API_35", + }, + }; + + var mockAdb = new MockAdbRunner (devices); + mockAdb.ShellProperties ["sys.boot_completed"] = "1"; + mockAdb.ShellCommands ["pm path android"] = "package:/system/framework/framework-res.apk"; + + var runner = new EmulatorRunner (() => null); + var options = new EmulatorBootOptions { BootTimeout = TimeSpan.FromSeconds (5), PollInterval = TimeSpan.FromMilliseconds (50) }; + + var result = await runner.BootAndWaitAsync ("Pixel_7_API_35", mockAdb, options); + + Assert.IsTrue (result.Success); + Assert.AreEqual ("emulator-5554", result.Serial); + } + + [Test] + public async Task BootEmulator_AppearsAfterPolling () + { + var devices = new List (); + var mockAdb = new MockAdbRunner (devices); + mockAdb.ShellProperties ["sys.boot_completed"] = "1"; + mockAdb.ShellCommands ["pm path android"] = "package:/system/framework/framework-res.apk"; + + int pollCount = 0; + mockAdb.OnListDevices = () => { + pollCount++; + if (pollCount >= 2) { + devices.Add (new AdbDeviceInfo { + Serial = "emulator-5554", + Type = AdbDeviceType.Emulator, + Status = AdbDeviceStatus.Online, + AvdName = "Pixel_7_API_35", + }); + } + }; + + var tempDir = CreateFakeEmulatorSdk (); + try { + var runner = new EmulatorRunner (() => tempDir); + var options = new EmulatorBootOptions { + BootTimeout = TimeSpan.FromSeconds (10), + PollInterval = TimeSpan.FromMilliseconds (50), + }; + + var result = await runner.BootAndWaitAsync ("Pixel_7_API_35", mockAdb, options); + + Assert.IsTrue (result.Success); + Assert.AreEqual ("emulator-5554", result.Serial); + Assert.IsTrue (pollCount >= 2); + } finally { + Directory.Delete (tempDir, true); + } + } + + [Test] + public async Task LaunchFailure_ReturnsError () + { + var devices = new List (); + var mockAdb = new MockAdbRunner (devices); + + // No emulator path → EmulatorPath returns null → error + var runner = new EmulatorRunner (() => null); + var options = new EmulatorBootOptions { BootTimeout = TimeSpan.FromSeconds (2) }; + + var result = await runner.BootAndWaitAsync ("Pixel_7_API_35", mockAdb, options); + + Assert.IsFalse (result.Success); + Assert.IsNotNull (result.ErrorMessage); + Assert.IsTrue (result.ErrorMessage!.Contains ("not found"), $"Unexpected error: {result.ErrorMessage}"); + } + + [Test] + public async Task BootTimeout_BootCompletedNeverReaches1 () + { + var devices = new List { + new AdbDeviceInfo { + Serial = "emulator-5554", + Type = AdbDeviceType.Emulator, + Status = AdbDeviceStatus.Online, + AvdName = "Pixel_7_API_35", + }, + }; + + var mockAdb = new MockAdbRunner (devices); + // boot_completed never returns "1" + mockAdb.ShellProperties ["sys.boot_completed"] = "0"; + + var runner = new EmulatorRunner (() => null); + var options = new EmulatorBootOptions { + BootTimeout = TimeSpan.FromMilliseconds (200), + PollInterval = TimeSpan.FromMilliseconds (50), + }; + + var result = await runner.BootAndWaitAsync ("Pixel_7_API_35", mockAdb, options); + + Assert.IsFalse (result.Success); + Assert.IsNotNull (result.ErrorMessage); + Assert.IsTrue (result.ErrorMessage!.Contains ("Timed out"), $"Unexpected error: {result.ErrorMessage}"); + } + + [Test] + public async Task MultipleEmulators_FindsCorrectAvd () + { + var devices = new List { + new AdbDeviceInfo { + Serial = "emulator-5554", + Type = AdbDeviceType.Emulator, + Status = AdbDeviceStatus.Online, + AvdName = "Pixel_5_API_30", + }, + new AdbDeviceInfo { + Serial = "emulator-5556", + Type = AdbDeviceType.Emulator, + Status = AdbDeviceStatus.Online, + AvdName = "Pixel_7_API_35", + }, + new AdbDeviceInfo { + Serial = "emulator-5558", + Type = AdbDeviceType.Emulator, + Status = AdbDeviceStatus.Online, + AvdName = "Nexus_5X_API_28", + }, + }; + + var mockAdb = new MockAdbRunner (devices); + mockAdb.ShellProperties ["sys.boot_completed"] = "1"; + mockAdb.ShellCommands ["pm path android"] = "package:/system/framework/framework-res.apk"; + + var runner = new EmulatorRunner (() => null); + var options = new EmulatorBootOptions { BootTimeout = TimeSpan.FromSeconds (5), PollInterval = TimeSpan.FromMilliseconds (50) }; + + var result = await runner.BootAndWaitAsync ("Pixel_7_API_35", mockAdb, options); + + Assert.IsTrue (result.Success); + Assert.AreEqual ("emulator-5556", result.Serial, "Should find the correct AVD among multiple emulators"); + } + + // --- Helpers --- + + static string CreateFakeEmulatorSdk () + { + var tempDir = Path.Combine (Path.GetTempPath (), $"emu-boot-test-{Path.GetRandomFileName ()}"); + var emulatorDir = Path.Combine (tempDir, "emulator"); + Directory.CreateDirectory (emulatorDir); + + var emuName = OS.IsWindows ? "emulator.exe" : "emulator"; + var emuPath = Path.Combine (emulatorDir, emuName); + // Create a fake emulator script that just exits + if (OS.IsWindows) { + File.WriteAllText (emuPath, "@echo off"); + } else { + File.WriteAllText (emuPath, "#!/bin/sh\nsleep 60\n"); + // Make executable + var psi = new ProcessStartInfo ("chmod", $"+x \"{emuPath}\"") { UseShellExecute = false }; + Process.Start (psi)?.WaitForExit (); + } + + return tempDir; + } + + /// + /// Mock AdbRunner for testing BootAndWaitAsync without real adb commands. + /// + class MockAdbRunner : AdbRunner + { + readonly List devices; + + public Dictionary ShellProperties { get; } = new Dictionary (StringComparer.OrdinalIgnoreCase); + public Dictionary ShellCommands { get; } = new Dictionary (StringComparer.OrdinalIgnoreCase); + public Action? OnListDevices { get; set; } + + public MockAdbRunner (List devices) + : base ("/fake/adb") + { + this.devices = devices; + } + + public override Task> ListDevicesAsync (CancellationToken cancellationToken = default) + { + OnListDevices?.Invoke (); + return Task.FromResult> (devices); + } + + public override Task GetShellPropertyAsync (string serial, string propertyName, CancellationToken cancellationToken = default) + { + ShellProperties.TryGetValue (propertyName, out var value); + return Task.FromResult (value); + } + + public override Task RunShellCommandAsync (string serial, string command, CancellationToken cancellationToken = default) + { + ShellCommands.TryGetValue (command, out var value); + return Task.FromResult (value); + } + } +} From d0d188ab4802a88987a9351fc8197f254ff92984 Mon Sep 17 00:00:00 2001 From: Rui Marinho Date: Tue, 10 Mar 2026 12:50:57 +0000 Subject: [PATCH 02/16] Fix EmulatorPath Windows lookup and test fake to use .bat On Windows, the fake emulator was created as emulator.exe with batch script content (@echo off), which is not a valid PE binary. Process.Start() throws Win32Exception when trying to execute it, causing BootEmulator_AppearsAfterPolling to fail. Fix: - EmulatorPath now prefers .exe, falls back to .bat/.cmd on Windows (matching how older Android SDK tools ship batch wrappers) - Test fake creates emulator.bat instead of emulator.exe on Windows, with a proper idle command (ping -n 60) so the process stays alive during the polling test Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Runners/EmulatorRunner.cs | 14 ++++++++++++-- .../EmulatorRunnerTests.cs | 6 +++--- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs b/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs index 837108f5..eb5fe5e0 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs +++ b/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs @@ -36,9 +36,19 @@ public string? EmulatorPath { if (string.IsNullOrEmpty (sdkPath)) return null; - var ext = OS.IsWindows ? ".exe" : ""; - var path = Path.Combine (sdkPath, "emulator", "emulator" + ext); + var emulatorDir = Path.Combine (sdkPath, "emulator"); + + if (OS.IsWindows) { + // Prefer .exe, fall back to .bat/.cmd (older SDK versions) + foreach (var ext in new [] { ".exe", ".bat", ".cmd" }) { + var candidate = Path.Combine (emulatorDir, "emulator" + ext); + if (File.Exists (candidate)) + return candidate; + } + return null; + } + var path = Path.Combine (emulatorDir, "emulator"); return File.Exists (path) ? path : null; } } diff --git a/tests/Xamarin.Android.Tools.AndroidSdk-Tests/EmulatorRunnerTests.cs b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/EmulatorRunnerTests.cs index 490b18e7..dd12c6bf 100644 --- a/tests/Xamarin.Android.Tools.AndroidSdk-Tests/EmulatorRunnerTests.cs +++ b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/EmulatorRunnerTests.cs @@ -273,11 +273,11 @@ static string CreateFakeEmulatorSdk () var emulatorDir = Path.Combine (tempDir, "emulator"); Directory.CreateDirectory (emulatorDir); - var emuName = OS.IsWindows ? "emulator.exe" : "emulator"; + var emuName = OS.IsWindows ? "emulator.bat" : "emulator"; var emuPath = Path.Combine (emulatorDir, emuName); - // Create a fake emulator script that just exits + // Create a fake emulator script that just idles if (OS.IsWindows) { - File.WriteAllText (emuPath, "@echo off"); + File.WriteAllText (emuPath, "@echo off\r\nping -n 60 127.0.0.1 >nul\r\n"); } else { File.WriteAllText (emuPath, "#!/bin/sh\nsleep 60\n"); // Make executable From 5fb7e6e61209cec4ff480d7152908e8d0306b719 Mon Sep 17 00:00:00 2001 From: Rui Marinho Date: Wed, 11 Mar 2026 20:01:25 +0000 Subject: [PATCH 03/16] Address review findings: structured args, record types, pipe draining, NUnit constraints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - EmulatorBootOptions.AdditionalArgs: string? → IEnumerable? (prevents single-arg bug) - EmulatorBootResult: class → record with init properties, file-scoped namespace - EmulatorBootOptions: file-scoped namespace - StartAvd: drain redirected stdout/stderr with BeginOutputReadLine/BeginErrorReadLine - Cache Windows emulator extensions as static readonly array - Tests: replace null-forgiving '!' with Assert.That + Does.Contain - Tests: use ProcessUtils.CreateProcessStartInfo for chmod instead of raw ProcessStartInfo - Add PublicAPI.Unshipped.txt entries for all new public types Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Models/EmulatorBootOptions.cs | 22 +-- .../Models/EmulatorBootResult.cs | 19 ++- .../PublicAPI/net10.0/PublicAPI.Unshipped.txt | 27 +++- .../netstandard2.0/PublicAPI.Unshipped.txt | 27 +++- .../Runners/AndroidEnvironmentHelper.cs | 11 -- .../Runners/EmulatorRunner.cs | 133 +++++++++--------- .../EmulatorRunnerTests.cs | 107 ++++++++------ 7 files changed, 202 insertions(+), 144 deletions(-) diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Models/EmulatorBootOptions.cs b/src/Xamarin.Android.Tools.AndroidSdk/Models/EmulatorBootOptions.cs index 8765df77..7c011fde 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/Models/EmulatorBootOptions.cs +++ b/src/Xamarin.Android.Tools.AndroidSdk/Models/EmulatorBootOptions.cs @@ -2,17 +2,17 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using System.Collections.Generic; -namespace Xamarin.Android.Tools +namespace Xamarin.Android.Tools; + +/// +/// Options for booting an Android emulator. +/// +public class EmulatorBootOptions { - /// - /// Options for booting an Android emulator. - /// - public class EmulatorBootOptions - { - public TimeSpan BootTimeout { get; set; } = TimeSpan.FromSeconds (300); - public string? AdditionalArgs { get; set; } - public bool ColdBoot { get; set; } - public TimeSpan PollInterval { get; set; } = TimeSpan.FromMilliseconds (500); - } + public TimeSpan BootTimeout { get; set; } = TimeSpan.FromSeconds (300); + public IEnumerable? AdditionalArgs { get; set; } + public bool ColdBoot { get; set; } + public TimeSpan PollInterval { get; set; } = TimeSpan.FromMilliseconds (500); } diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Models/EmulatorBootResult.cs b/src/Xamarin.Android.Tools.AndroidSdk/Models/EmulatorBootResult.cs index 59281792..4316f8b6 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/Models/EmulatorBootResult.cs +++ b/src/Xamarin.Android.Tools.AndroidSdk/Models/EmulatorBootResult.cs @@ -1,15 +1,14 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -namespace Xamarin.Android.Tools +namespace Xamarin.Android.Tools; + +/// +/// Result of an emulator boot operation. +/// +public record EmulatorBootResult { - /// - /// Result of an emulator boot operation. - /// - public class EmulatorBootResult - { - public bool Success { get; set; } - public string? Serial { get; set; } - public string? ErrorMessage { get; set; } - } + public bool Success { get; init; } + public string? Serial { get; init; } + public string? ErrorMessage { get; init; } } diff --git a/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/net10.0/PublicAPI.Unshipped.txt b/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/net10.0/PublicAPI.Unshipped.txt index 03a3352e..3cd52f61 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/net10.0/PublicAPI.Unshipped.txt +++ b/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/net10.0/PublicAPI.Unshipped.txt @@ -33,7 +33,8 @@ Xamarin.Android.Tools.AdbDeviceType.Device = 0 -> Xamarin.Android.Tools.AdbDevic Xamarin.Android.Tools.AdbDeviceType.Emulator = 1 -> Xamarin.Android.Tools.AdbDeviceType Xamarin.Android.Tools.AdbRunner Xamarin.Android.Tools.AdbRunner.AdbRunner(string! adbPath, System.Collections.Generic.IDictionary? environmentVariables = null) -> void -Xamarin.Android.Tools.AdbRunner.ListDevicesAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! +*REMOVED*Xamarin.Android.Tools.AdbRunner.ListDevicesAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! +virtual Xamarin.Android.Tools.AdbRunner.ListDevicesAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! Xamarin.Android.Tools.AdbRunner.StopEmulatorAsync(string! serial, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! Xamarin.Android.Tools.AdbRunner.WaitForDeviceAsync(string? serial = null, System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! Xamarin.Android.Tools.ChecksumType @@ -145,3 +146,27 @@ Xamarin.Android.Tools.AvdManagerRunner.AvdManagerRunner(string! avdManagerPath, Xamarin.Android.Tools.AvdManagerRunner.GetOrCreateAvdAsync(string! name, string! systemImage, string? deviceProfile = null, bool force = false, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! Xamarin.Android.Tools.AvdManagerRunner.DeleteAvdAsync(string! name, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! Xamarin.Android.Tools.AvdManagerRunner.ListAvdsAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! +virtual Xamarin.Android.Tools.AdbRunner.GetShellPropertyAsync(string! serial, string! propertyName, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +virtual Xamarin.Android.Tools.AdbRunner.RunShellCommandAsync(string! serial, string! command, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +Xamarin.Android.Tools.EmulatorBootOptions +Xamarin.Android.Tools.EmulatorBootOptions.AdditionalArgs.get -> System.Collections.Generic.IEnumerable? +Xamarin.Android.Tools.EmulatorBootOptions.AdditionalArgs.set -> void +Xamarin.Android.Tools.EmulatorBootOptions.BootTimeout.get -> System.TimeSpan +Xamarin.Android.Tools.EmulatorBootOptions.BootTimeout.set -> void +Xamarin.Android.Tools.EmulatorBootOptions.ColdBoot.get -> bool +Xamarin.Android.Tools.EmulatorBootOptions.ColdBoot.set -> void +Xamarin.Android.Tools.EmulatorBootOptions.EmulatorBootOptions() -> void +Xamarin.Android.Tools.EmulatorBootOptions.PollInterval.get -> System.TimeSpan +Xamarin.Android.Tools.EmulatorBootOptions.PollInterval.set -> void +Xamarin.Android.Tools.EmulatorBootResult +Xamarin.Android.Tools.EmulatorBootResult.ErrorMessage.get -> string? +Xamarin.Android.Tools.EmulatorBootResult.ErrorMessage.init -> void +Xamarin.Android.Tools.EmulatorBootResult.Serial.get -> string? +Xamarin.Android.Tools.EmulatorBootResult.Serial.init -> void +Xamarin.Android.Tools.EmulatorBootResult.Success.get -> bool +Xamarin.Android.Tools.EmulatorBootResult.Success.init -> void +Xamarin.Android.Tools.EmulatorRunner +Xamarin.Android.Tools.EmulatorRunner.BootAndWaitAsync(string! deviceOrAvdName, Xamarin.Android.Tools.AdbRunner! adbRunner, Xamarin.Android.Tools.EmulatorBootOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +Xamarin.Android.Tools.EmulatorRunner.EmulatorRunner(string! emulatorPath, System.Collections.Generic.IDictionary? environmentVariables = null, System.Action? logger = null) -> void +Xamarin.Android.Tools.EmulatorRunner.ListAvdNamesAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! +Xamarin.Android.Tools.EmulatorRunner.StartAvd(string! avdName, bool coldBoot = false, System.Collections.Generic.IEnumerable? additionalArgs = null) -> System.Diagnostics.Process! diff --git a/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt b/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt index 03a3352e..3cd52f61 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt +++ b/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt @@ -33,7 +33,8 @@ Xamarin.Android.Tools.AdbDeviceType.Device = 0 -> Xamarin.Android.Tools.AdbDevic Xamarin.Android.Tools.AdbDeviceType.Emulator = 1 -> Xamarin.Android.Tools.AdbDeviceType Xamarin.Android.Tools.AdbRunner Xamarin.Android.Tools.AdbRunner.AdbRunner(string! adbPath, System.Collections.Generic.IDictionary? environmentVariables = null) -> void -Xamarin.Android.Tools.AdbRunner.ListDevicesAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! +*REMOVED*Xamarin.Android.Tools.AdbRunner.ListDevicesAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! +virtual Xamarin.Android.Tools.AdbRunner.ListDevicesAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! Xamarin.Android.Tools.AdbRunner.StopEmulatorAsync(string! serial, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! Xamarin.Android.Tools.AdbRunner.WaitForDeviceAsync(string? serial = null, System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! Xamarin.Android.Tools.ChecksumType @@ -145,3 +146,27 @@ Xamarin.Android.Tools.AvdManagerRunner.AvdManagerRunner(string! avdManagerPath, Xamarin.Android.Tools.AvdManagerRunner.GetOrCreateAvdAsync(string! name, string! systemImage, string? deviceProfile = null, bool force = false, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! Xamarin.Android.Tools.AvdManagerRunner.DeleteAvdAsync(string! name, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! Xamarin.Android.Tools.AvdManagerRunner.ListAvdsAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! +virtual Xamarin.Android.Tools.AdbRunner.GetShellPropertyAsync(string! serial, string! propertyName, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +virtual Xamarin.Android.Tools.AdbRunner.RunShellCommandAsync(string! serial, string! command, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +Xamarin.Android.Tools.EmulatorBootOptions +Xamarin.Android.Tools.EmulatorBootOptions.AdditionalArgs.get -> System.Collections.Generic.IEnumerable? +Xamarin.Android.Tools.EmulatorBootOptions.AdditionalArgs.set -> void +Xamarin.Android.Tools.EmulatorBootOptions.BootTimeout.get -> System.TimeSpan +Xamarin.Android.Tools.EmulatorBootOptions.BootTimeout.set -> void +Xamarin.Android.Tools.EmulatorBootOptions.ColdBoot.get -> bool +Xamarin.Android.Tools.EmulatorBootOptions.ColdBoot.set -> void +Xamarin.Android.Tools.EmulatorBootOptions.EmulatorBootOptions() -> void +Xamarin.Android.Tools.EmulatorBootOptions.PollInterval.get -> System.TimeSpan +Xamarin.Android.Tools.EmulatorBootOptions.PollInterval.set -> void +Xamarin.Android.Tools.EmulatorBootResult +Xamarin.Android.Tools.EmulatorBootResult.ErrorMessage.get -> string? +Xamarin.Android.Tools.EmulatorBootResult.ErrorMessage.init -> void +Xamarin.Android.Tools.EmulatorBootResult.Serial.get -> string? +Xamarin.Android.Tools.EmulatorBootResult.Serial.init -> void +Xamarin.Android.Tools.EmulatorBootResult.Success.get -> bool +Xamarin.Android.Tools.EmulatorBootResult.Success.init -> void +Xamarin.Android.Tools.EmulatorRunner +Xamarin.Android.Tools.EmulatorRunner.BootAndWaitAsync(string! deviceOrAvdName, Xamarin.Android.Tools.AdbRunner! adbRunner, Xamarin.Android.Tools.EmulatorBootOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +Xamarin.Android.Tools.EmulatorRunner.EmulatorRunner(string! emulatorPath, System.Collections.Generic.IDictionary? environmentVariables = null, System.Action? logger = null) -> void +Xamarin.Android.Tools.EmulatorRunner.ListAvdNamesAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! +Xamarin.Android.Tools.EmulatorRunner.StartAvd(string! avdName, bool coldBoot = false, System.Collections.Generic.IEnumerable? additionalArgs = null) -> System.Diagnostics.Process! diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Runners/AndroidEnvironmentHelper.cs b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AndroidEnvironmentHelper.cs index 3cf67207..51f6309d 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/Runners/AndroidEnvironmentHelper.cs +++ b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AndroidEnvironmentHelper.cs @@ -37,15 +37,4 @@ internal static Dictionary GetEnvironmentVariables (string? sdkP return env; } - - /// - /// Applies Android SDK environment variables directly to a . - /// Used by runners that manage their own process lifecycle (e.g., EmulatorRunner). - /// - internal static void ConfigureEnvironment (System.Diagnostics.ProcessStartInfo psi, string? sdkPath, string? jdkPath) - { - var env = GetEnvironmentVariables (sdkPath, jdkPath); - foreach (var kvp in env) - psi.Environment [kvp.Key] = kvp.Value; - } } diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs b/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs index eb5fe5e0..615d19e7 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs +++ b/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs @@ -16,67 +16,34 @@ namespace Xamarin.Android.Tools; /// public class EmulatorRunner { - readonly Func getSdkPath; - readonly Func? getJdkPath; + readonly string emulatorPath; + readonly IDictionary? environmentVariables; + readonly Action? logger; - public EmulatorRunner (Func getSdkPath) - : this (getSdkPath, null) - { - } - - public EmulatorRunner (Func getSdkPath, Func? getJdkPath) - { - this.getSdkPath = getSdkPath ?? throw new ArgumentNullException (nameof (getSdkPath)); - this.getJdkPath = getJdkPath; - } - - public string? EmulatorPath { - get { - var sdkPath = getSdkPath (); - if (string.IsNullOrEmpty (sdkPath)) - return null; - - var emulatorDir = Path.Combine (sdkPath, "emulator"); - - if (OS.IsWindows) { - // Prefer .exe, fall back to .bat/.cmd (older SDK versions) - foreach (var ext in new [] { ".exe", ".bat", ".cmd" }) { - var candidate = Path.Combine (emulatorDir, "emulator" + ext); - if (File.Exists (candidate)) - return candidate; - } - return null; - } - - var path = Path.Combine (emulatorDir, "emulator"); - return File.Exists (path) ? path : null; - } - } - - public bool IsAvailable => EmulatorPath is not null; - - string RequireEmulatorPath () - { - return EmulatorPath ?? throw new InvalidOperationException ("Android Emulator not found."); - } - - void ConfigureEnvironment (ProcessStartInfo psi) + /// + /// Creates a new EmulatorRunner with the full path to the emulator executable. + /// + /// Full path to the emulator executable (e.g., "/path/to/sdk/emulator/emulator"). + /// Optional environment variables to pass to emulator processes. + /// Optional logger callback for diagnostic messages. + public EmulatorRunner (string emulatorPath, IDictionary? environmentVariables = null, Action? logger = null) { - AndroidEnvironmentHelper.ConfigureEnvironment (psi, getSdkPath (), getJdkPath?.Invoke ()); + if (string.IsNullOrWhiteSpace (emulatorPath)) + throw new ArgumentException ("Path to emulator must not be empty.", nameof (emulatorPath)); + this.emulatorPath = emulatorPath; + this.environmentVariables = environmentVariables; + this.logger = logger; } - public Process StartAvd (string avdName, bool coldBoot = false, string? additionalArgs = null) + public Process StartAvd (string avdName, bool coldBoot = false, IEnumerable? additionalArgs = null) { - var emulatorPath = RequireEmulatorPath (); - var args = new List { "-avd", avdName }; if (coldBoot) args.Add ("-no-snapshot-load"); - if (!string.IsNullOrEmpty (additionalArgs)) - args.Add (additionalArgs); + if (additionalArgs != null) + args.AddRange (additionalArgs); var psi = ProcessUtils.CreateProcessStartInfo (emulatorPath, args.ToArray ()); - ConfigureEnvironment (psi); // Redirect stdout/stderr so the emulator process doesn't inherit the // caller's pipes. Without this, parent processes (e.g. VS Code spawn) @@ -84,21 +51,37 @@ public Process StartAvd (string avdName, bool coldBoot = false, string? addition psi.RedirectStandardOutput = true; psi.RedirectStandardError = true; + logger?.Invoke (TraceLevel.Verbose, $"Starting emulator AVD '{avdName}'"); + var process = new Process { StartInfo = psi }; + + // Forward emulator output to the logger so crash messages (e.g. "HAX is + // not working", "image not found") are captured instead of silently lost. + process.OutputDataReceived += (_, e) => { + if (e.Data != null) + logger?.Invoke (TraceLevel.Verbose, $"[emulator] {e.Data}"); + }; + process.ErrorDataReceived += (_, e) => { + if (e.Data != null) + logger?.Invoke (TraceLevel.Warning, $"[emulator] {e.Data}"); + }; + process.Start (); + // Drain redirected streams asynchronously to prevent pipe buffer deadlocks + process.BeginOutputReadLine (); + process.BeginErrorReadLine (); + return process; } public async Task> ListAvdNamesAsync (CancellationToken cancellationToken = default) { - var emulatorPath = RequireEmulatorPath (); - using var stdout = new StringWriter (); var psi = ProcessUtils.CreateProcessStartInfo (emulatorPath, "-list-avds"); - ConfigureEnvironment (psi); - await ProcessUtils.StartProcess (psi, stdout, null, cancellationToken).ConfigureAwait (false); + logger?.Invoke (TraceLevel.Verbose, "Running: emulator -list-avds"); + await ProcessUtils.StartProcess (psi, stdout, null, cancellationToken, environmentVariables).ConfigureAwait (false); return ParseListAvdsOutput (stdout.ToString ()); } @@ -122,7 +105,6 @@ public async Task BootAndWaitAsync ( string deviceOrAvdName, AdbRunner adbRunner, EmulatorBootOptions? options = null, - Action? logger = null, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace (deviceOrAvdName)) @@ -130,7 +112,12 @@ public async Task BootAndWaitAsync ( if (adbRunner == null) throw new ArgumentNullException (nameof (adbRunner)); - options = options ?? new EmulatorBootOptions (); + options ??= new EmulatorBootOptions (); + if (options.BootTimeout <= TimeSpan.Zero) + throw new ArgumentOutOfRangeException (nameof (options), "BootTimeout must be positive."); + if (options.PollInterval <= TimeSpan.Zero) + throw new ArgumentOutOfRangeException (nameof (options), "PollInterval must be positive."); + void Log (TraceLevel level, string message) => logger?.Invoke (level, message); Log (TraceLevel.Info, $"Booting emulator for '{deviceOrAvdName}'..."); @@ -150,17 +137,10 @@ public async Task BootAndWaitAsync ( var runningSerial = FindRunningAvdSerial (devices, deviceOrAvdName); if (runningSerial != null) { Log (TraceLevel.Info, $"AVD '{deviceOrAvdName}' is already running as '{runningSerial}', waiting for full boot..."); - return await WaitForFullBootAsync (adbRunner, runningSerial, options, logger, cancellationToken).ConfigureAwait (false); + return await WaitForFullBootAsync (adbRunner, runningSerial, options, cancellationToken).ConfigureAwait (false); } // Phase 3: Launch the emulator - if (EmulatorPath == null) { - return new EmulatorBootResult { - Success = false, - ErrorMessage = "Android Emulator not found. Ensure the Android SDK is installed and the emulator is available.", - }; - } - Log (TraceLevel.Info, $"Launching AVD '{deviceOrAvdName}'..."); Process emulatorProcess; try { @@ -172,7 +152,9 @@ public async Task BootAndWaitAsync ( }; } - // Poll for the new emulator serial to appear + // Poll for the new emulator serial to appear. + // If the boot times out or is cancelled, terminate the process we spawned + // to avoid leaving orphan emulator processes. using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource (cancellationToken); timeoutCts.CancelAfter (options.BootTimeout); @@ -187,12 +169,16 @@ public async Task BootAndWaitAsync ( } Log (TraceLevel.Info, $"Emulator appeared as '{newSerial}', waiting for full boot..."); - return await WaitForFullBootAsync (adbRunner, newSerial, options, logger, timeoutCts.Token).ConfigureAwait (false); + return await WaitForFullBootAsync (adbRunner, newSerial, options, timeoutCts.Token).ConfigureAwait (false); } catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { + TryKillProcess (emulatorProcess); return new EmulatorBootResult { Success = false, ErrorMessage = $"Timed out waiting for emulator '{deviceOrAvdName}' to boot within {options.BootTimeout.TotalSeconds}s.", }; + } catch { + TryKillProcess (emulatorProcess); + throw; } } @@ -208,11 +194,22 @@ public async Task BootAndWaitAsync ( return null; } + static void TryKillProcess (Process process) + { + try { + if (!process.HasExited) + process.Kill (); + } catch { + // Best-effort: process may have already exited between check and kill + } finally { + process.Dispose (); + } + } + async Task WaitForFullBootAsync ( AdbRunner adbRunner, string serial, EmulatorBootOptions options, - Action? logger, CancellationToken cancellationToken) { void Log (TraceLevel level, string message) => logger?.Invoke (level, message); diff --git a/tests/Xamarin.Android.Tools.AndroidSdk-Tests/EmulatorRunnerTests.cs b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/EmulatorRunnerTests.cs index dd12c6bf..3d672132 100644 --- a/tests/Xamarin.Android.Tools.AndroidSdk-Tests/EmulatorRunnerTests.cs +++ b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/EmulatorRunnerTests.cs @@ -59,39 +59,21 @@ public void ParseListAvdsOutput_BlankLines () } [Test] - public void EmulatorPath_FindsInSdk () + public void Constructor_ThrowsOnNullPath () { - var tempDir = Path.Combine (Path.GetTempPath (), $"emu-test-{Path.GetRandomFileName ()}"); - var emulatorDir = Path.Combine (tempDir, "emulator"); - Directory.CreateDirectory (emulatorDir); - - try { - var emuName = OS.IsWindows ? "emulator.exe" : "emulator"; - File.WriteAllText (Path.Combine (emulatorDir, emuName), ""); - - var runner = new EmulatorRunner (() => tempDir); - - Assert.IsNotNull (runner.EmulatorPath); - Assert.IsTrue (runner.IsAvailable); - } finally { - Directory.Delete (tempDir, true); - } + Assert.Throws (() => new EmulatorRunner (null!)); } [Test] - public void EmulatorPath_MissingSdk_ReturnsNull () + public void Constructor_ThrowsOnEmptyPath () { - var runner = new EmulatorRunner (() => "/nonexistent/path"); - Assert.IsNull (runner.EmulatorPath); - Assert.IsFalse (runner.IsAvailable); + Assert.Throws (() => new EmulatorRunner ("")); } [Test] - public void EmulatorPath_NullSdk_ReturnsNull () + public void Constructor_ThrowsOnWhitespacePath () { - var runner = new EmulatorRunner (() => null); - Assert.IsNull (runner.EmulatorPath); - Assert.IsFalse (runner.IsAvailable); + Assert.Throws (() => new EmulatorRunner (" ")); } // --- BootAndWaitAsync tests (ported from dotnet/android BootAndroidEmulatorTests) --- @@ -109,7 +91,7 @@ public async Task AlreadyOnlineDevice_PassesThrough () }; var mockAdb = new MockAdbRunner (devices); - var runner = new EmulatorRunner (() => null); + var runner = new EmulatorRunner ("/fake/emulator"); var result = await runner.BootAndWaitAsync ("emulator-5554", mockAdb); @@ -134,7 +116,7 @@ public async Task AvdAlreadyRunning_WaitsForFullBoot () mockAdb.ShellProperties ["sys.boot_completed"] = "1"; mockAdb.ShellCommands ["pm path android"] = "package:/system/framework/framework-res.apk"; - var runner = new EmulatorRunner (() => null); + var runner = new EmulatorRunner ("/fake/emulator"); var options = new EmulatorBootOptions { BootTimeout = TimeSpan.FromSeconds (5), PollInterval = TimeSpan.FromMilliseconds (50) }; var result = await runner.BootAndWaitAsync ("Pixel_7_API_35", mockAdb, options); @@ -164,9 +146,10 @@ public async Task BootEmulator_AppearsAfterPolling () } }; - var tempDir = CreateFakeEmulatorSdk (); + var (tempDir, emuPath) = CreateFakeEmulatorSdk (); + Process? emulatorProcess = null; try { - var runner = new EmulatorRunner (() => tempDir); + var runner = new EmulatorRunner (emuPath); var options = new EmulatorBootOptions { BootTimeout = TimeSpan.FromSeconds (10), PollInterval = TimeSpan.FromMilliseconds (50), @@ -178,6 +161,12 @@ public async Task BootEmulator_AppearsAfterPolling () Assert.AreEqual ("emulator-5554", result.Serial); Assert.IsTrue (pollCount >= 2); } finally { + // Kill any emulator process spawned by the test + try { + emulatorProcess = FindEmulatorProcess (emuPath); + emulatorProcess?.Kill (); + emulatorProcess?.WaitForExit (1000); + } catch { } Directory.Delete (tempDir, true); } } @@ -188,15 +177,14 @@ public async Task LaunchFailure_ReturnsError () var devices = new List (); var mockAdb = new MockAdbRunner (devices); - // No emulator path → EmulatorPath returns null → error - var runner = new EmulatorRunner (() => null); + // Nonexistent path → StartAvd throws → error result + var runner = new EmulatorRunner ("/nonexistent/emulator"); var options = new EmulatorBootOptions { BootTimeout = TimeSpan.FromSeconds (2) }; var result = await runner.BootAndWaitAsync ("Pixel_7_API_35", mockAdb, options); Assert.IsFalse (result.Success); - Assert.IsNotNull (result.ErrorMessage); - Assert.IsTrue (result.ErrorMessage!.Contains ("not found"), $"Unexpected error: {result.ErrorMessage}"); + Assert.That (result.ErrorMessage, Does.Contain ("Failed to launch")); } [Test] @@ -215,7 +203,7 @@ public async Task BootTimeout_BootCompletedNeverReaches1 () // boot_completed never returns "1" mockAdb.ShellProperties ["sys.boot_completed"] = "0"; - var runner = new EmulatorRunner (() => null); + var runner = new EmulatorRunner ("/fake/emulator"); var options = new EmulatorBootOptions { BootTimeout = TimeSpan.FromMilliseconds (200), PollInterval = TimeSpan.FromMilliseconds (50), @@ -224,8 +212,29 @@ public async Task BootTimeout_BootCompletedNeverReaches1 () var result = await runner.BootAndWaitAsync ("Pixel_7_API_35", mockAdb, options); Assert.IsFalse (result.Success); - Assert.IsNotNull (result.ErrorMessage); - Assert.IsTrue (result.ErrorMessage!.Contains ("Timed out"), $"Unexpected error: {result.ErrorMessage}"); + Assert.That (result.ErrorMessage, Does.Contain ("Timed out")); + } + + [Test] + public void BootAndWaitAsync_InvalidBootTimeout_Throws () + { + var runner = new EmulatorRunner ("/fake/emulator"); + var mockAdb = new MockAdbRunner (new List ()); + var options = new EmulatorBootOptions { BootTimeout = TimeSpan.Zero }; + + Assert.ThrowsAsync (() => + runner.BootAndWaitAsync ("test", mockAdb, options)); + } + + [Test] + public void BootAndWaitAsync_InvalidPollInterval_Throws () + { + var runner = new EmulatorRunner ("/fake/emulator"); + var mockAdb = new MockAdbRunner (new List ()); + var options = new EmulatorBootOptions { PollInterval = TimeSpan.FromMilliseconds (-1) }; + + Assert.ThrowsAsync (() => + runner.BootAndWaitAsync ("test", mockAdb, options)); } [Test] @@ -256,7 +265,7 @@ public async Task MultipleEmulators_FindsCorrectAvd () mockAdb.ShellProperties ["sys.boot_completed"] = "1"; mockAdb.ShellCommands ["pm path android"] = "package:/system/framework/framework-res.apk"; - var runner = new EmulatorRunner (() => null); + var runner = new EmulatorRunner ("/fake/emulator"); var options = new EmulatorBootOptions { BootTimeout = TimeSpan.FromSeconds (5), PollInterval = TimeSpan.FromMilliseconds (50) }; var result = await runner.BootAndWaitAsync ("Pixel_7_API_35", mockAdb, options); @@ -267,7 +276,7 @@ public async Task MultipleEmulators_FindsCorrectAvd () // --- Helpers --- - static string CreateFakeEmulatorSdk () + static (string tempDir, string emulatorPath) CreateFakeEmulatorSdk () { var tempDir = Path.Combine (Path.GetTempPath (), $"emu-boot-test-{Path.GetRandomFileName ()}"); var emulatorDir = Path.Combine (tempDir, "emulator"); @@ -275,17 +284,31 @@ static string CreateFakeEmulatorSdk () var emuName = OS.IsWindows ? "emulator.bat" : "emulator"; var emuPath = Path.Combine (emulatorDir, emuName); - // Create a fake emulator script that just idles if (OS.IsWindows) { File.WriteAllText (emuPath, "@echo off\r\nping -n 60 127.0.0.1 >nul\r\n"); } else { File.WriteAllText (emuPath, "#!/bin/sh\nsleep 60\n"); - // Make executable - var psi = new ProcessStartInfo ("chmod", $"+x \"{emuPath}\"") { UseShellExecute = false }; - Process.Start (psi)?.WaitForExit (); + var psi = ProcessUtils.CreateProcessStartInfo ("chmod", "+x", emuPath); + using var chmod = new Process { StartInfo = psi }; + chmod.Start (); + chmod.WaitForExit (); } - return tempDir; + return (tempDir, emuPath); + } + + static Process? FindEmulatorProcess (string emuPath) + { + // Best-effort: find the process by matching the command line + try { + foreach (var p in Process.GetProcessesByName ("emulator")) { + return p; + } + foreach (var p in Process.GetProcessesByName ("sleep")) { + return p; + } + } catch { } + return null; } /// From 7e42effe23b034b62c6fdf001f266b51a20b48d3 Mon Sep 17 00:00:00 2001 From: Rui Marinho Date: Thu, 12 Mar 2026 18:17:18 +0000 Subject: [PATCH 04/16] Fix env vars, timeout duplication, and process tree cleanup - Apply constructor environmentVariables to ProcessStartInfo in StartAvd - Move timeoutCts before Phase 2 so both Phase 2 (AVD already running) and Phase 3 (launch emulator) share a single boot timeout - Remove dead try-catch from WaitForFullBootAsync (callers handle timeout) - Use Process.Kill(entireProcessTree: true) on .NET 5+ to clean up child processes on Linux/macOS Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Runners/EmulatorRunner.cs | 64 +++++++++++-------- 1 file changed, 37 insertions(+), 27 deletions(-) diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs b/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs index 615d19e7..223939bb 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs +++ b/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs @@ -45,6 +45,11 @@ public Process StartAvd (string avdName, bool coldBoot = false, IEnumerable BootAndWaitAsync ( return new EmulatorBootResult { Success = true, Serial = onlineDevice.Serial }; } + // Single timeout CTS for the entire boot operation (covers Phase 2 and Phase 3). + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource (cancellationToken); + timeoutCts.CancelAfter (options.BootTimeout); + // Phase 2: Check if AVD is already running (possibly still booting) var runningSerial = FindRunningAvdSerial (devices, deviceOrAvdName); if (runningSerial != null) { Log (TraceLevel.Info, $"AVD '{deviceOrAvdName}' is already running as '{runningSerial}', waiting for full boot..."); - return await WaitForFullBootAsync (adbRunner, runningSerial, options, cancellationToken).ConfigureAwait (false); + try { + return await WaitForFullBootAsync (adbRunner, runningSerial, options, timeoutCts.Token).ConfigureAwait (false); + } catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { + return new EmulatorBootResult { + Success = false, + ErrorMessage = $"Timed out waiting for emulator '{deviceOrAvdName}' to boot within {options.BootTimeout.TotalSeconds}s.", + }; + } } // Phase 3: Launch the emulator @@ -155,9 +171,6 @@ public async Task BootAndWaitAsync ( // Poll for the new emulator serial to appear. // If the boot times out or is cancelled, terminate the process we spawned // to avoid leaving orphan emulator processes. - using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource (cancellationToken); - timeoutCts.CancelAfter (options.BootTimeout); - try { string? newSerial = null; while (newSerial == null) { @@ -197,8 +210,13 @@ public async Task BootAndWaitAsync ( static void TryKillProcess (Process process) { try { - if (!process.HasExited) + if (!process.HasExited) { +#if NET5_0_OR_GREATER + process.Kill (entireProcessTree: true); +#else process.Kill (); +#endif + } } catch { // Best-effort: process may have already exited between check and kill } finally { @@ -214,30 +232,22 @@ async Task WaitForFullBootAsync ( { void Log (TraceLevel level, string message) => logger?.Invoke (level, message); - using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource (cancellationToken); - timeoutCts.CancelAfter (options.BootTimeout); - - try { - while (true) { - timeoutCts.Token.ThrowIfCancellationRequested (); - - var bootCompleted = await adbRunner.GetShellPropertyAsync (serial, "sys.boot_completed", timeoutCts.Token).ConfigureAwait (false); - if (string.Equals (bootCompleted, "1", StringComparison.Ordinal)) { - var pmResult = await adbRunner.RunShellCommandAsync (serial, "pm path android", timeoutCts.Token).ConfigureAwait (false); - if (pmResult != null && pmResult.StartsWith ("package:", StringComparison.Ordinal)) { - Log (TraceLevel.Info, $"Emulator '{serial}' is fully booted."); - return new EmulatorBootResult { Success = true, Serial = serial }; - } + // The caller is responsible for enforcing the overall boot timeout via + // cancellationToken (a linked CTS with CancelAfter). This method simply + // polls until boot completes or the token is cancelled. + while (true) { + cancellationToken.ThrowIfCancellationRequested (); + + var bootCompleted = await adbRunner.GetShellPropertyAsync (serial, "sys.boot_completed", cancellationToken).ConfigureAwait (false); + if (string.Equals (bootCompleted, "1", StringComparison.Ordinal)) { + var pmResult = await adbRunner.RunShellCommandAsync (serial, "pm path android", cancellationToken).ConfigureAwait (false); + if (pmResult != null && pmResult.StartsWith ("package:", StringComparison.Ordinal)) { + Log (TraceLevel.Info, $"Emulator '{serial}' is fully booted."); + return new EmulatorBootResult { Success = true, Serial = serial }; } - - await Task.Delay (options.PollInterval, timeoutCts.Token).ConfigureAwait (false); } - } catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { - return new EmulatorBootResult { - Success = false, - Serial = serial, - ErrorMessage = $"Timed out waiting for emulator '{serial}' to fully boot within {options.BootTimeout.TotalSeconds}s.", - }; + + await Task.Delay (options.PollInterval, cancellationToken).ConfigureAwait (false); } } } From 97c05e0498097c467c22a284e9e60109fe4e9b11 Mon Sep 17 00:00:00 2001 From: Rui Marinho Date: Fri, 13 Mar 2026 09:49:01 +0000 Subject: [PATCH 05/16] =?UTF-8?q?Rename=20StartAvd=E2=86=92LaunchAvd=20and?= =?UTF-8?q?=20BootAndWaitAsync=E2=86=92BootAvdAsync?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Improve public API naming for EmulatorRunner: - LaunchAvd: fire-and-forget process spawn, returns Process immediately - BootAvdAsync: full lifecycle — launch + poll until fully booted - Add comprehensive XML documentation explaining the behavioral difference between the two methods - Update PublicAPI surface files and all test references Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../PublicAPI/net10.0/PublicAPI.Unshipped.txt | 4 +- .../netstandard2.0/PublicAPI.Unshipped.txt | 4 +- .../Runners/EmulatorRunner.cs | 40 ++++++++++++++++--- .../EmulatorRunnerTests.cs | 26 ++++++------ 4 files changed, 51 insertions(+), 23 deletions(-) diff --git a/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/net10.0/PublicAPI.Unshipped.txt b/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/net10.0/PublicAPI.Unshipped.txt index 3cd52f61..37d3586c 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/net10.0/PublicAPI.Unshipped.txt +++ b/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/net10.0/PublicAPI.Unshipped.txt @@ -166,7 +166,7 @@ Xamarin.Android.Tools.EmulatorBootResult.Serial.init -> void Xamarin.Android.Tools.EmulatorBootResult.Success.get -> bool Xamarin.Android.Tools.EmulatorBootResult.Success.init -> void Xamarin.Android.Tools.EmulatorRunner -Xamarin.Android.Tools.EmulatorRunner.BootAndWaitAsync(string! deviceOrAvdName, Xamarin.Android.Tools.AdbRunner! adbRunner, Xamarin.Android.Tools.EmulatorBootOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +Xamarin.Android.Tools.EmulatorRunner.BootAvdAsync(string! deviceOrAvdName, Xamarin.Android.Tools.AdbRunner! adbRunner, Xamarin.Android.Tools.EmulatorBootOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! Xamarin.Android.Tools.EmulatorRunner.EmulatorRunner(string! emulatorPath, System.Collections.Generic.IDictionary? environmentVariables = null, System.Action? logger = null) -> void Xamarin.Android.Tools.EmulatorRunner.ListAvdNamesAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! -Xamarin.Android.Tools.EmulatorRunner.StartAvd(string! avdName, bool coldBoot = false, System.Collections.Generic.IEnumerable? additionalArgs = null) -> System.Diagnostics.Process! +Xamarin.Android.Tools.EmulatorRunner.LaunchAvd(string! avdName, bool coldBoot = false, System.Collections.Generic.IEnumerable? additionalArgs = null) -> System.Diagnostics.Process! diff --git a/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt b/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt index 3cd52f61..37d3586c 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt +++ b/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt @@ -166,7 +166,7 @@ Xamarin.Android.Tools.EmulatorBootResult.Serial.init -> void Xamarin.Android.Tools.EmulatorBootResult.Success.get -> bool Xamarin.Android.Tools.EmulatorBootResult.Success.init -> void Xamarin.Android.Tools.EmulatorRunner -Xamarin.Android.Tools.EmulatorRunner.BootAndWaitAsync(string! deviceOrAvdName, Xamarin.Android.Tools.AdbRunner! adbRunner, Xamarin.Android.Tools.EmulatorBootOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +Xamarin.Android.Tools.EmulatorRunner.BootAvdAsync(string! deviceOrAvdName, Xamarin.Android.Tools.AdbRunner! adbRunner, Xamarin.Android.Tools.EmulatorBootOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! Xamarin.Android.Tools.EmulatorRunner.EmulatorRunner(string! emulatorPath, System.Collections.Generic.IDictionary? environmentVariables = null, System.Action? logger = null) -> void Xamarin.Android.Tools.EmulatorRunner.ListAvdNamesAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! -Xamarin.Android.Tools.EmulatorRunner.StartAvd(string! avdName, bool coldBoot = false, System.Collections.Generic.IEnumerable? additionalArgs = null) -> System.Diagnostics.Process! +Xamarin.Android.Tools.EmulatorRunner.LaunchAvd(string! avdName, bool coldBoot = false, System.Collections.Generic.IEnumerable? additionalArgs = null) -> System.Diagnostics.Process! diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs b/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs index 223939bb..d2856fe9 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs +++ b/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs @@ -35,7 +35,18 @@ public EmulatorRunner (string emulatorPath, IDictionary? environ this.logger = logger; } - public Process StartAvd (string avdName, bool coldBoot = false, IEnumerable? additionalArgs = null) + /// + /// Launches an emulator process for the specified AVD and returns immediately. + /// The returned represents the running emulator — the caller + /// is responsible for managing its lifetime (e.g., killing it on shutdown). + /// This method does not wait for the emulator to finish booting. + /// To launch and wait until the device is fully booted, use instead. + /// + /// Name of the AVD to launch (as shown by emulator -list-avds). + /// When true, forces a cold boot by passing -no-snapshot-load. + /// Optional extra arguments to pass to the emulator command line. + /// The running the emulator. Stdout/stderr are redirected and forwarded to the logger. + public Process LaunchAvd (string avdName, bool coldBoot = false, IEnumerable? additionalArgs = null) { var args = new List { "-avd", avdName }; if (coldBoot) @@ -56,7 +67,7 @@ public Process StartAvd (string avdName, bool coldBoot = false, IEnumerable ParseListAvdsOutput (string output) } /// - /// Boots an emulator and waits for it to be fully booted. - /// Ported from dotnet/android BootAndroidEmulator MSBuild task. + /// Boots an emulator for the specified AVD and waits until it is fully ready to accept commands. + /// + /// Unlike , which only spawns the emulator process, this method + /// handles the full lifecycle: it checks whether the device is already online, launches + /// the emulator if needed, then polls sys.boot_completed and pm path android + /// until the Android OS is fully booted and the package manager is responsive. + /// + /// Ported from the dotnet/android BootAndroidEmulator MSBuild task. /// - public async Task BootAndWaitAsync ( + /// + /// Either an ADB device serial (e.g., emulator-5554) to wait for, + /// or an AVD name (e.g., Pixel_7_API_35) to launch and boot. + /// + /// An used to query device status and boot properties. + /// Optional boot configuration (timeout, poll interval, cold boot, extra args). + /// Cancellation token to abort the operation. + /// + /// An indicating success or failure, including the device serial on success + /// or an error message on timeout/failure. + /// + public async Task BootAvdAsync ( string deviceOrAvdName, AdbRunner adbRunner, EmulatorBootOptions? options = null, @@ -160,7 +188,7 @@ public async Task BootAndWaitAsync ( Log (TraceLevel.Info, $"Launching AVD '{deviceOrAvdName}'..."); Process emulatorProcess; try { - emulatorProcess = StartAvd (deviceOrAvdName, options.ColdBoot, options.AdditionalArgs); + emulatorProcess = LaunchAvd (deviceOrAvdName, options.ColdBoot, options.AdditionalArgs); } catch (Exception ex) { return new EmulatorBootResult { Success = false, diff --git a/tests/Xamarin.Android.Tools.AndroidSdk-Tests/EmulatorRunnerTests.cs b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/EmulatorRunnerTests.cs index 3d672132..b260de2a 100644 --- a/tests/Xamarin.Android.Tools.AndroidSdk-Tests/EmulatorRunnerTests.cs +++ b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/EmulatorRunnerTests.cs @@ -76,7 +76,7 @@ public void Constructor_ThrowsOnWhitespacePath () Assert.Throws (() => new EmulatorRunner (" ")); } - // --- BootAndWaitAsync tests (ported from dotnet/android BootAndroidEmulatorTests) --- + // --- BootAvdAsync tests (ported from dotnet/android BootAndroidEmulatorTests) --- [Test] public async Task AlreadyOnlineDevice_PassesThrough () @@ -93,7 +93,7 @@ public async Task AlreadyOnlineDevice_PassesThrough () var mockAdb = new MockAdbRunner (devices); var runner = new EmulatorRunner ("/fake/emulator"); - var result = await runner.BootAndWaitAsync ("emulator-5554", mockAdb); + var result = await runner.BootAvdAsync ("emulator-5554", mockAdb); Assert.IsTrue (result.Success); Assert.AreEqual ("emulator-5554", result.Serial); @@ -119,7 +119,7 @@ public async Task AvdAlreadyRunning_WaitsForFullBoot () var runner = new EmulatorRunner ("/fake/emulator"); var options = new EmulatorBootOptions { BootTimeout = TimeSpan.FromSeconds (5), PollInterval = TimeSpan.FromMilliseconds (50) }; - var result = await runner.BootAndWaitAsync ("Pixel_7_API_35", mockAdb, options); + var result = await runner.BootAvdAsync ("Pixel_7_API_35", mockAdb, options); Assert.IsTrue (result.Success); Assert.AreEqual ("emulator-5554", result.Serial); @@ -155,7 +155,7 @@ public async Task BootEmulator_AppearsAfterPolling () PollInterval = TimeSpan.FromMilliseconds (50), }; - var result = await runner.BootAndWaitAsync ("Pixel_7_API_35", mockAdb, options); + var result = await runner.BootAvdAsync ("Pixel_7_API_35", mockAdb, options); Assert.IsTrue (result.Success); Assert.AreEqual ("emulator-5554", result.Serial); @@ -177,11 +177,11 @@ public async Task LaunchFailure_ReturnsError () var devices = new List (); var mockAdb = new MockAdbRunner (devices); - // Nonexistent path → StartAvd throws → error result + // Nonexistent path → LaunchAvd throws → error result var runner = new EmulatorRunner ("/nonexistent/emulator"); var options = new EmulatorBootOptions { BootTimeout = TimeSpan.FromSeconds (2) }; - var result = await runner.BootAndWaitAsync ("Pixel_7_API_35", mockAdb, options); + var result = await runner.BootAvdAsync ("Pixel_7_API_35", mockAdb, options); Assert.IsFalse (result.Success); Assert.That (result.ErrorMessage, Does.Contain ("Failed to launch")); @@ -209,32 +209,32 @@ public async Task BootTimeout_BootCompletedNeverReaches1 () PollInterval = TimeSpan.FromMilliseconds (50), }; - var result = await runner.BootAndWaitAsync ("Pixel_7_API_35", mockAdb, options); + var result = await runner.BootAvdAsync ("Pixel_7_API_35", mockAdb, options); Assert.IsFalse (result.Success); Assert.That (result.ErrorMessage, Does.Contain ("Timed out")); } [Test] - public void BootAndWaitAsync_InvalidBootTimeout_Throws () + public void BootAvdAsync_InvalidBootTimeout_Throws () { var runner = new EmulatorRunner ("/fake/emulator"); var mockAdb = new MockAdbRunner (new List ()); var options = new EmulatorBootOptions { BootTimeout = TimeSpan.Zero }; Assert.ThrowsAsync (() => - runner.BootAndWaitAsync ("test", mockAdb, options)); + runner.BootAvdAsync ("test", mockAdb, options)); } [Test] - public void BootAndWaitAsync_InvalidPollInterval_Throws () + public void BootAvdAsync_InvalidPollInterval_Throws () { var runner = new EmulatorRunner ("/fake/emulator"); var mockAdb = new MockAdbRunner (new List ()); var options = new EmulatorBootOptions { PollInterval = TimeSpan.FromMilliseconds (-1) }; Assert.ThrowsAsync (() => - runner.BootAndWaitAsync ("test", mockAdb, options)); + runner.BootAvdAsync ("test", mockAdb, options)); } [Test] @@ -268,7 +268,7 @@ public async Task MultipleEmulators_FindsCorrectAvd () var runner = new EmulatorRunner ("/fake/emulator"); var options = new EmulatorBootOptions { BootTimeout = TimeSpan.FromSeconds (5), PollInterval = TimeSpan.FromMilliseconds (50) }; - var result = await runner.BootAndWaitAsync ("Pixel_7_API_35", mockAdb, options); + var result = await runner.BootAvdAsync ("Pixel_7_API_35", mockAdb, options); Assert.IsTrue (result.Success); Assert.AreEqual ("emulator-5556", result.Serial, "Should find the correct AVD among multiple emulators"); @@ -312,7 +312,7 @@ public async Task MultipleEmulators_FindsCorrectAvd () } /// - /// Mock AdbRunner for testing BootAndWaitAsync without real adb commands. + /// Mock AdbRunner for testing BootAvdAsync without real adb commands. /// class MockAdbRunner : AdbRunner { From b4739177335cfbb3ed10fe546437aa8ac9be5d44 Mon Sep 17 00:00:00 2001 From: Rui Marinho Date: Fri, 13 Mar 2026 16:10:03 +0000 Subject: [PATCH 06/16] Address review findings: validation, logging, tests Fix #1: Validate avdName in LaunchAvd (ArgumentException on null/empty) Fix #2: RunShellCommandAsync now returns full trimmed stdout (not just first line) Fix #3: Add 9 FirstNonEmptyLine parsing tests + 3 LaunchAvd validation tests Fix #5: Log stderr via logger on shell command failures (AdbRunner gets logger param) Fix #6: Remove TOCTOU HasExited check in TryKillProcess (rely on catch block) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../PublicAPI/net10.0/PublicAPI.Unshipped.txt | 2 +- .../netstandard2.0/PublicAPI.Unshipped.txt | 2 +- .../Runners/AdbRunner.cs | 24 +++++++- .../Runners/EmulatorRunner.cs | 11 ++-- .../AdbRunnerTests.cs | 59 +++++++++++++++++++ .../EmulatorRunnerTests.cs | 21 +++++++ 6 files changed, 109 insertions(+), 10 deletions(-) diff --git a/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/net10.0/PublicAPI.Unshipped.txt b/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/net10.0/PublicAPI.Unshipped.txt index 37d3586c..10b52aa6 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/net10.0/PublicAPI.Unshipped.txt +++ b/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/net10.0/PublicAPI.Unshipped.txt @@ -32,7 +32,7 @@ Xamarin.Android.Tools.AdbDeviceType Xamarin.Android.Tools.AdbDeviceType.Device = 0 -> Xamarin.Android.Tools.AdbDeviceType Xamarin.Android.Tools.AdbDeviceType.Emulator = 1 -> Xamarin.Android.Tools.AdbDeviceType Xamarin.Android.Tools.AdbRunner -Xamarin.Android.Tools.AdbRunner.AdbRunner(string! adbPath, System.Collections.Generic.IDictionary? environmentVariables = null) -> void +Xamarin.Android.Tools.AdbRunner.AdbRunner(string! adbPath, System.Collections.Generic.IDictionary? environmentVariables = null, System.Action? logger = null) -> void *REMOVED*Xamarin.Android.Tools.AdbRunner.ListDevicesAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! virtual Xamarin.Android.Tools.AdbRunner.ListDevicesAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! Xamarin.Android.Tools.AdbRunner.StopEmulatorAsync(string! serial, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! diff --git a/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt b/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt index 37d3586c..10b52aa6 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt +++ b/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt @@ -32,7 +32,7 @@ Xamarin.Android.Tools.AdbDeviceType Xamarin.Android.Tools.AdbDeviceType.Device = 0 -> Xamarin.Android.Tools.AdbDeviceType Xamarin.Android.Tools.AdbDeviceType.Emulator = 1 -> Xamarin.Android.Tools.AdbDeviceType Xamarin.Android.Tools.AdbRunner -Xamarin.Android.Tools.AdbRunner.AdbRunner(string! adbPath, System.Collections.Generic.IDictionary? environmentVariables = null) -> void +Xamarin.Android.Tools.AdbRunner.AdbRunner(string! adbPath, System.Collections.Generic.IDictionary? environmentVariables = null, System.Action? logger = null) -> void *REMOVED*Xamarin.Android.Tools.AdbRunner.ListDevicesAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! virtual Xamarin.Android.Tools.AdbRunner.ListDevicesAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! Xamarin.Android.Tools.AdbRunner.StopEmulatorAsync(string! serial, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs index da2e9799..f4d1844b 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs +++ b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs @@ -21,6 +21,7 @@ public class AdbRunner { readonly string adbPath; readonly IDictionary? environmentVariables; + readonly Action? logger; // Pattern to match device lines: [key:value ...] // Uses \s+ to match one or more whitespace characters (spaces or tabs) between fields. @@ -35,12 +36,14 @@ public class AdbRunner /// /// Full path to the adb executable (e.g., "/path/to/sdk/platform-tools/adb"). /// Optional environment variables to pass to adb processes. - public AdbRunner (string adbPath, IDictionary? environmentVariables = null) + /// Optional logger callback for diagnostic messages. + public AdbRunner (string adbPath, IDictionary? environmentVariables = null, Action? logger = null) { if (string.IsNullOrWhiteSpace (adbPath)) throw new ArgumentException ("Path to adb must not be empty.", nameof (adbPath)); this.adbPath = adbPath; this.environmentVariables = environmentVariables; + this.logger = logger; } /// @@ -137,6 +140,7 @@ public async Task StopEmulatorAsync (string serial, CancellationToken cancellati /// /// Gets a system property from a device via 'adb -s <serial> shell getprop <property>'. + /// Returns the property value (first non-empty line of stdout), or null on failure. /// public virtual async Task GetShellPropertyAsync (string serial, string propertyName, CancellationToken cancellationToken = default) { @@ -144,11 +148,18 @@ public async Task StopEmulatorAsync (string serial, CancellationToken cancellati using var stderr = new StringWriter (); var psi = ProcessUtils.CreateProcessStartInfo (adbPath, "-s", serial, "shell", "getprop", propertyName); var exitCode = await ProcessUtils.StartProcess (psi, stdout, stderr, cancellationToken, environmentVariables).ConfigureAwait (false); - return exitCode == 0 ? FirstNonEmptyLine (stdout.ToString ()) : null; + if (exitCode != 0) { + var stderrText = stderr.ToString ().Trim (); + if (stderrText.Length > 0) + logger?.Invoke (TraceLevel.Warning, $"adb shell getprop {propertyName} failed (exit {exitCode}): {stderrText}"); + return null; + } + return FirstNonEmptyLine (stdout.ToString ()); } /// /// Runs a shell command on a device via 'adb -s <serial> shell <command>'. + /// Returns the full stdout output trimmed, or null on failure. /// public virtual async Task RunShellCommandAsync (string serial, string command, CancellationToken cancellationToken = default) { @@ -156,7 +167,14 @@ public async Task StopEmulatorAsync (string serial, CancellationToken cancellati using var stderr = new StringWriter (); var psi = ProcessUtils.CreateProcessStartInfo (adbPath, "-s", serial, "shell", command); var exitCode = await ProcessUtils.StartProcess (psi, stdout, stderr, cancellationToken, environmentVariables).ConfigureAwait (false); - return exitCode == 0 ? FirstNonEmptyLine (stdout.ToString ()) : null; + if (exitCode != 0) { + var stderrText = stderr.ToString ().Trim (); + if (stderrText.Length > 0) + logger?.Invoke (TraceLevel.Warning, $"adb shell {command} failed (exit {exitCode}): {stderrText}"); + return null; + } + var output = stdout.ToString ().Trim (); + return output.Length > 0 ? output : null; } internal static string? FirstNonEmptyLine (string output) diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs b/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs index d2856fe9..6e7233b5 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs +++ b/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs @@ -48,6 +48,9 @@ public EmulatorRunner (string emulatorPath, IDictionary? environ /// The running the emulator. Stdout/stderr are redirected and forwarded to the logger. public Process LaunchAvd (string avdName, bool coldBoot = false, IEnumerable? additionalArgs = null) { + if (string.IsNullOrWhiteSpace (avdName)) + throw new ArgumentException ("AVD name must not be empty.", nameof (avdName)); + var args = new List { "-avd", avdName }; if (coldBoot) args.Add ("-no-snapshot-load"); @@ -238,15 +241,13 @@ public async Task BootAvdAsync ( static void TryKillProcess (Process process) { try { - if (!process.HasExited) { #if NET5_0_OR_GREATER - process.Kill (entireProcessTree: true); + process.Kill (entireProcessTree: true); #else - process.Kill (); + process.Kill (); #endif - } } catch { - // Best-effort: process may have already exited between check and kill + // Best-effort: process may have already exited } finally { process.Dispose (); } diff --git a/tests/Xamarin.Android.Tools.AndroidSdk-Tests/AdbRunnerTests.cs b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/AdbRunnerTests.cs index 1e9a232a..c9536cf9 100644 --- a/tests/Xamarin.Android.Tools.AndroidSdk-Tests/AdbRunnerTests.cs +++ b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/AdbRunnerTests.cs @@ -655,4 +655,63 @@ public void WaitForDeviceAsync_ZeroTimeout_ThrowsArgumentOutOfRange () Assert.ThrowsAsync ( async () => await runner.WaitForDeviceAsync (timeout: System.TimeSpan.Zero)); } + + // --- FirstNonEmptyLine tests --- + + [Test] + public void FirstNonEmptyLine_ReturnsFirstLine () + { + Assert.AreEqual ("hello", AdbRunner.FirstNonEmptyLine ("hello\nworld\n")); + } + + [Test] + public void FirstNonEmptyLine_SkipsBlankLines () + { + Assert.AreEqual ("hello", AdbRunner.FirstNonEmptyLine ("\n\nhello\nworld\n")); + } + + [Test] + public void FirstNonEmptyLine_TrimsWhitespace () + { + Assert.AreEqual ("hello", AdbRunner.FirstNonEmptyLine (" hello \n")); + } + + [Test] + public void FirstNonEmptyLine_HandlesWindowsNewlines () + { + Assert.AreEqual ("hello", AdbRunner.FirstNonEmptyLine ("\r\nhello\r\nworld\r\n")); + } + + [Test] + public void FirstNonEmptyLine_EmptyString_ReturnsNull () + { + Assert.IsNull (AdbRunner.FirstNonEmptyLine ("")); + } + + [Test] + public void FirstNonEmptyLine_OnlyNewlines_ReturnsNull () + { + Assert.IsNull (AdbRunner.FirstNonEmptyLine ("\n\n\n")); + } + + [Test] + public void FirstNonEmptyLine_SingleValue_ReturnsIt () + { + Assert.AreEqual ("1", AdbRunner.FirstNonEmptyLine ("1\n")); + } + + [Test] + public void FirstNonEmptyLine_TypicalGetpropOutput () + { + // adb shell getprop sys.boot_completed returns "1\n" or "1\r\n" + Assert.AreEqual ("1", AdbRunner.FirstNonEmptyLine ("1\r\n")); + } + + [Test] + public void FirstNonEmptyLine_PmPathOutput () + { + // adb shell pm path android returns "package:/system/framework/framework-res.apk\n" + var output = "package:/system/framework/framework-res.apk\n"; + Assert.AreEqual ("package:/system/framework/framework-res.apk", AdbRunner.FirstNonEmptyLine (output)); + } } diff --git a/tests/Xamarin.Android.Tools.AndroidSdk-Tests/EmulatorRunnerTests.cs b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/EmulatorRunnerTests.cs index b260de2a..ba8ed996 100644 --- a/tests/Xamarin.Android.Tools.AndroidSdk-Tests/EmulatorRunnerTests.cs +++ b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/EmulatorRunnerTests.cs @@ -76,6 +76,27 @@ public void Constructor_ThrowsOnWhitespacePath () Assert.Throws (() => new EmulatorRunner (" ")); } + [Test] + public void LaunchAvd_ThrowsOnNullAvdName () + { + var runner = new EmulatorRunner ("/fake/emulator"); + Assert.Throws (() => runner.LaunchAvd (null!)); + } + + [Test] + public void LaunchAvd_ThrowsOnEmptyAvdName () + { + var runner = new EmulatorRunner ("/fake/emulator"); + Assert.Throws (() => runner.LaunchAvd ("")); + } + + [Test] + public void LaunchAvd_ThrowsOnWhitespaceAvdName () + { + var runner = new EmulatorRunner ("/fake/emulator"); + Assert.Throws (() => runner.LaunchAvd (" ")); + } + // --- BootAvdAsync tests (ported from dotnet/android BootAndroidEmulatorTests) --- [Test] From 21f8b94f4aec78de3b9964b58c76bb4c5fc29f6e Mon Sep 17 00:00:00 2001 From: Rui Marinho Date: Fri, 13 Mar 2026 16:44:28 +0000 Subject: [PATCH 07/16] Fix Process handle leak on successful BootAvdAsync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dispose the Process wrapper on the success path so file handles and native resources are released. The emulator OS process keeps running — only the .NET Process handle is freed. Failure/timeout paths already dispose via TryKillProcess. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Runners/EmulatorRunner.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs b/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs index 6e7233b5..c6a9db11 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs +++ b/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs @@ -213,7 +213,12 @@ public async Task BootAvdAsync ( } Log (TraceLevel.Info, $"Emulator appeared as '{newSerial}', waiting for full boot..."); - return await WaitForFullBootAsync (adbRunner, newSerial, options, timeoutCts.Token).ConfigureAwait (false); + var result = await WaitForFullBootAsync (adbRunner, newSerial, options, timeoutCts.Token).ConfigureAwait (false); + + // Release the Process handle — the emulator process itself keeps running. + // We no longer need stdout/stderr forwarding since boot is complete. + emulatorProcess.Dispose (); + return result; } catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { TryKillProcess (emulatorProcess); return new EmulatorBootResult { From f19b01426c248d2ab3a7471392246ad34dc97501 Mon Sep 17 00:00:00 2001 From: Rui Marinho Date: Fri, 13 Mar 2026 17:14:08 +0000 Subject: [PATCH 08/16] Address bot review: exit code check, typed catch, shell doc - ListAvdNamesAsync: check exit code from emulator -list-avds - TryKillProcess: change bare catch to catch(Exception ex) with logger - TryKillProcess: make instance method to access logger field - RunShellCommandAsync: add XML doc warning about shell interpretation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Runners/AdbRunner.cs | 5 +++++ .../Runners/EmulatorRunner.cs | 9 ++++++--- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs index f4d1844b..741c3951 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs +++ b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs @@ -161,6 +161,11 @@ public async Task StopEmulatorAsync (string serial, CancellationToken cancellati /// Runs a shell command on a device via 'adb -s <serial> shell <command>'. /// Returns the full stdout output trimmed, or null on failure. /// + /// + /// The is passed as a single argument to adb shell, + /// which means the device's shell interprets it (shell expansion, pipes, semicolons are active). + /// Do not pass untrusted or user-supplied input without proper validation. + /// public virtual async Task RunShellCommandAsync (string serial, string command, CancellationToken cancellationToken = default) { using var stdout = new StringWriter (); diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs b/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs index c6a9db11..557ab823 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs +++ b/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs @@ -97,10 +97,12 @@ public Process LaunchAvd (string avdName, bool coldBoot = false, IEnumerable> ListAvdNamesAsync (CancellationToken cancellationToken = default) { using var stdout = new StringWriter (); + using var stderr = new StringWriter (); var psi = ProcessUtils.CreateProcessStartInfo (emulatorPath, "-list-avds"); logger?.Invoke (TraceLevel.Verbose, "Running: emulator -list-avds"); - await ProcessUtils.StartProcess (psi, stdout, null, cancellationToken, environmentVariables).ConfigureAwait (false); + var exitCode = await ProcessUtils.StartProcess (psi, stdout, stderr, cancellationToken, environmentVariables).ConfigureAwait (false); + ProcessUtils.ThrowIfFailed (exitCode, "emulator -list-avds", stderr); return ParseListAvdsOutput (stdout.ToString ()); } @@ -243,7 +245,7 @@ public async Task BootAvdAsync ( return null; } - static void TryKillProcess (Process process) + void TryKillProcess (Process process) { try { #if NET5_0_OR_GREATER @@ -251,8 +253,9 @@ static void TryKillProcess (Process process) #else process.Kill (); #endif - } catch { + } catch (Exception ex) { // Best-effort: process may have already exited + logger?.Invoke (TraceLevel.Verbose, $"Failed to stop emulator process: {ex.Message}"); } finally { process.Dispose (); } From d8ee2d591afb4175c5ebf75e8ca261ed9c6685ff Mon Sep 17 00:00:00 2001 From: Rui Marinho Date: Fri, 13 Mar 2026 18:05:43 +0000 Subject: [PATCH 09/16] =?UTF-8?q?Rename=20LaunchAvd=E2=86=92LaunchEmulator?= =?UTF-8?q?,=20BootAvdAsync=E2=86=92BootEmulatorAsync,=20add=20structured?= =?UTF-8?q?=20RunShellCommandAsync=20overload?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename LaunchAvd to LaunchEmulator (fire-and-forget) - Rename BootAvdAsync to BootEmulatorAsync (full lifecycle) - Add RunShellCommandAsync(serial, command, args, ct) overload that passes args as separate tokens (exec, no shell interpretation) - Fix RS0026/RS0027: only the most-params overload has optional ct - Update all tests and PublicAPI files Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../PublicAPI/net10.0/PublicAPI.Unshipped.txt | 7 ++-- .../netstandard2.0/PublicAPI.Unshipped.txt | 7 ++-- .../Runners/AdbRunner.cs | 34 ++++++++++++++++- .../Runners/EmulatorRunner.cs | 10 ++--- .../EmulatorRunnerTests.cs | 38 +++++++++---------- 5 files changed, 65 insertions(+), 31 deletions(-) diff --git a/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/net10.0/PublicAPI.Unshipped.txt b/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/net10.0/PublicAPI.Unshipped.txt index 10b52aa6..484b7243 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/net10.0/PublicAPI.Unshipped.txt +++ b/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/net10.0/PublicAPI.Unshipped.txt @@ -147,7 +147,8 @@ Xamarin.Android.Tools.AvdManagerRunner.GetOrCreateAvdAsync(string! name, string! Xamarin.Android.Tools.AvdManagerRunner.DeleteAvdAsync(string! name, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! Xamarin.Android.Tools.AvdManagerRunner.ListAvdsAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! virtual Xamarin.Android.Tools.AdbRunner.GetShellPropertyAsync(string! serial, string! propertyName, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! -virtual Xamarin.Android.Tools.AdbRunner.RunShellCommandAsync(string! serial, string! command, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +virtual Xamarin.Android.Tools.AdbRunner.RunShellCommandAsync(string! serial, string! command, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +virtual Xamarin.Android.Tools.AdbRunner.RunShellCommandAsync(string! serial, string! command, string![]! args, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! Xamarin.Android.Tools.EmulatorBootOptions Xamarin.Android.Tools.EmulatorBootOptions.AdditionalArgs.get -> System.Collections.Generic.IEnumerable? Xamarin.Android.Tools.EmulatorBootOptions.AdditionalArgs.set -> void @@ -166,7 +167,7 @@ Xamarin.Android.Tools.EmulatorBootResult.Serial.init -> void Xamarin.Android.Tools.EmulatorBootResult.Success.get -> bool Xamarin.Android.Tools.EmulatorBootResult.Success.init -> void Xamarin.Android.Tools.EmulatorRunner -Xamarin.Android.Tools.EmulatorRunner.BootAvdAsync(string! deviceOrAvdName, Xamarin.Android.Tools.AdbRunner! adbRunner, Xamarin.Android.Tools.EmulatorBootOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +Xamarin.Android.Tools.EmulatorRunner.BootEmulatorAsync(string! deviceOrAvdName, Xamarin.Android.Tools.AdbRunner! adbRunner, Xamarin.Android.Tools.EmulatorBootOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! Xamarin.Android.Tools.EmulatorRunner.EmulatorRunner(string! emulatorPath, System.Collections.Generic.IDictionary? environmentVariables = null, System.Action? logger = null) -> void Xamarin.Android.Tools.EmulatorRunner.ListAvdNamesAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! -Xamarin.Android.Tools.EmulatorRunner.LaunchAvd(string! avdName, bool coldBoot = false, System.Collections.Generic.IEnumerable? additionalArgs = null) -> System.Diagnostics.Process! +Xamarin.Android.Tools.EmulatorRunner.LaunchEmulator(string! avdName, bool coldBoot = false, System.Collections.Generic.IEnumerable? additionalArgs = null) -> System.Diagnostics.Process! diff --git a/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt b/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt index 10b52aa6..484b7243 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt +++ b/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt @@ -147,7 +147,8 @@ Xamarin.Android.Tools.AvdManagerRunner.GetOrCreateAvdAsync(string! name, string! Xamarin.Android.Tools.AvdManagerRunner.DeleteAvdAsync(string! name, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! Xamarin.Android.Tools.AvdManagerRunner.ListAvdsAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! virtual Xamarin.Android.Tools.AdbRunner.GetShellPropertyAsync(string! serial, string! propertyName, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! -virtual Xamarin.Android.Tools.AdbRunner.RunShellCommandAsync(string! serial, string! command, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +virtual Xamarin.Android.Tools.AdbRunner.RunShellCommandAsync(string! serial, string! command, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +virtual Xamarin.Android.Tools.AdbRunner.RunShellCommandAsync(string! serial, string! command, string![]! args, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! Xamarin.Android.Tools.EmulatorBootOptions Xamarin.Android.Tools.EmulatorBootOptions.AdditionalArgs.get -> System.Collections.Generic.IEnumerable? Xamarin.Android.Tools.EmulatorBootOptions.AdditionalArgs.set -> void @@ -166,7 +167,7 @@ Xamarin.Android.Tools.EmulatorBootResult.Serial.init -> void Xamarin.Android.Tools.EmulatorBootResult.Success.get -> bool Xamarin.Android.Tools.EmulatorBootResult.Success.init -> void Xamarin.Android.Tools.EmulatorRunner -Xamarin.Android.Tools.EmulatorRunner.BootAvdAsync(string! deviceOrAvdName, Xamarin.Android.Tools.AdbRunner! adbRunner, Xamarin.Android.Tools.EmulatorBootOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +Xamarin.Android.Tools.EmulatorRunner.BootEmulatorAsync(string! deviceOrAvdName, Xamarin.Android.Tools.AdbRunner! adbRunner, Xamarin.Android.Tools.EmulatorBootOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! Xamarin.Android.Tools.EmulatorRunner.EmulatorRunner(string! emulatorPath, System.Collections.Generic.IDictionary? environmentVariables = null, System.Action? logger = null) -> void Xamarin.Android.Tools.EmulatorRunner.ListAvdNamesAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! -Xamarin.Android.Tools.EmulatorRunner.LaunchAvd(string! avdName, bool coldBoot = false, System.Collections.Generic.IEnumerable? additionalArgs = null) -> System.Diagnostics.Process! +Xamarin.Android.Tools.EmulatorRunner.LaunchEmulator(string! avdName, bool coldBoot = false, System.Collections.Generic.IEnumerable? additionalArgs = null) -> System.Diagnostics.Process! diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs index 741c3951..5437ce8f 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs +++ b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs @@ -166,7 +166,7 @@ public async Task StopEmulatorAsync (string serial, CancellationToken cancellati /// which means the device's shell interprets it (shell expansion, pipes, semicolons are active). /// Do not pass untrusted or user-supplied input without proper validation. /// - public virtual async Task RunShellCommandAsync (string serial, string command, CancellationToken cancellationToken = default) + public virtual async Task RunShellCommandAsync (string serial, string command, CancellationToken cancellationToken) { using var stdout = new StringWriter (); using var stderr = new StringWriter (); @@ -182,6 +182,38 @@ public async Task StopEmulatorAsync (string serial, CancellationToken cancellati return output.Length > 0 ? output : null; } + /// + /// Runs a shell command on a device via adb -s <serial> shell <command> <args>. + /// Returns the full stdout output trimmed, or null on failure. + /// + /// + /// When adb shell receives the command and arguments as separate tokens, it uses + /// exec() directly on the device — bypassing the device's shell interpreter. + /// This avoids shell expansion, pipes, and injection risks, making it safer for dynamic input. + /// + public virtual async Task RunShellCommandAsync (string serial, string command, string[] args, CancellationToken cancellationToken = default) + { + using var stdout = new StringWriter (); + using var stderr = new StringWriter (); + // Build: adb -s shell ... + var allArgs = new string [3 + 1 + args.Length]; + allArgs [0] = "-s"; + allArgs [1] = serial; + allArgs [2] = "shell"; + allArgs [3] = command; + Array.Copy (args, 0, allArgs, 4, args.Length); + var psi = ProcessUtils.CreateProcessStartInfo (adbPath, allArgs); + var exitCode = await ProcessUtils.StartProcess (psi, stdout, stderr, cancellationToken, environmentVariables).ConfigureAwait (false); + if (exitCode != 0) { + var stderrText = stderr.ToString ().Trim (); + if (stderrText.Length > 0) + logger?.Invoke (TraceLevel.Warning, $"adb shell {command} failed (exit {exitCode}): {stderrText}"); + return null; + } + var output = stdout.ToString ().Trim (); + return output.Length > 0 ? output : null; + } + internal static string? FirstNonEmptyLine (string output) { foreach (var line in output.Split ('\n')) { diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs b/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs index 557ab823..83f67ea5 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs +++ b/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs @@ -40,13 +40,13 @@ public EmulatorRunner (string emulatorPath, IDictionary? environ /// The returned represents the running emulator — the caller /// is responsible for managing its lifetime (e.g., killing it on shutdown). /// This method does not wait for the emulator to finish booting. - /// To launch and wait until the device is fully booted, use instead. + /// To launch and wait until the device is fully booted, use instead. /// /// Name of the AVD to launch (as shown by emulator -list-avds). /// When true, forces a cold boot by passing -no-snapshot-load. /// Optional extra arguments to pass to the emulator command line. /// The running the emulator. Stdout/stderr are redirected and forwarded to the logger. - public Process LaunchAvd (string avdName, bool coldBoot = false, IEnumerable? additionalArgs = null) + public Process LaunchEmulator (string avdName, bool coldBoot = false, IEnumerable? additionalArgs = null) { if (string.IsNullOrWhiteSpace (avdName)) throw new ArgumentException ("AVD name must not be empty.", nameof (avdName)); @@ -121,7 +121,7 @@ internal static List ParseListAvdsOutput (string output) /// /// Boots an emulator for the specified AVD and waits until it is fully ready to accept commands. /// - /// Unlike , which only spawns the emulator process, this method + /// Unlike , which only spawns the emulator process, this method /// handles the full lifecycle: it checks whether the device is already online, launches /// the emulator if needed, then polls sys.boot_completed and pm path android /// until the Android OS is fully booted and the package manager is responsive. @@ -139,7 +139,7 @@ internal static List ParseListAvdsOutput (string output) /// An indicating success or failure, including the device serial on success /// or an error message on timeout/failure. /// - public async Task BootAvdAsync ( + public async Task BootEmulatorAsync ( string deviceOrAvdName, AdbRunner adbRunner, EmulatorBootOptions? options = null, @@ -193,7 +193,7 @@ public async Task BootAvdAsync ( Log (TraceLevel.Info, $"Launching AVD '{deviceOrAvdName}'..."); Process emulatorProcess; try { - emulatorProcess = LaunchAvd (deviceOrAvdName, options.ColdBoot, options.AdditionalArgs); + emulatorProcess = LaunchEmulator (deviceOrAvdName, options.ColdBoot, options.AdditionalArgs); } catch (Exception ex) { return new EmulatorBootResult { Success = false, diff --git a/tests/Xamarin.Android.Tools.AndroidSdk-Tests/EmulatorRunnerTests.cs b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/EmulatorRunnerTests.cs index ba8ed996..f095a872 100644 --- a/tests/Xamarin.Android.Tools.AndroidSdk-Tests/EmulatorRunnerTests.cs +++ b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/EmulatorRunnerTests.cs @@ -77,27 +77,27 @@ public void Constructor_ThrowsOnWhitespacePath () } [Test] - public void LaunchAvd_ThrowsOnNullAvdName () + public void LaunchEmulator_ThrowsOnNullAvdName () { var runner = new EmulatorRunner ("/fake/emulator"); - Assert.Throws (() => runner.LaunchAvd (null!)); + Assert.Throws (() => runner.LaunchEmulator (null!)); } [Test] - public void LaunchAvd_ThrowsOnEmptyAvdName () + public void LaunchEmulator_ThrowsOnEmptyAvdName () { var runner = new EmulatorRunner ("/fake/emulator"); - Assert.Throws (() => runner.LaunchAvd ("")); + Assert.Throws (() => runner.LaunchEmulator ("")); } [Test] - public void LaunchAvd_ThrowsOnWhitespaceAvdName () + public void LaunchEmulator_ThrowsOnWhitespaceAvdName () { var runner = new EmulatorRunner ("/fake/emulator"); - Assert.Throws (() => runner.LaunchAvd (" ")); + Assert.Throws (() => runner.LaunchEmulator (" ")); } - // --- BootAvdAsync tests (ported from dotnet/android BootAndroidEmulatorTests) --- + // --- BootEmulatorAsync tests (ported from dotnet/android BootAndroidEmulatorTests) --- [Test] public async Task AlreadyOnlineDevice_PassesThrough () @@ -114,7 +114,7 @@ public async Task AlreadyOnlineDevice_PassesThrough () var mockAdb = new MockAdbRunner (devices); var runner = new EmulatorRunner ("/fake/emulator"); - var result = await runner.BootAvdAsync ("emulator-5554", mockAdb); + var result = await runner.BootEmulatorAsync ("emulator-5554", mockAdb); Assert.IsTrue (result.Success); Assert.AreEqual ("emulator-5554", result.Serial); @@ -140,7 +140,7 @@ public async Task AvdAlreadyRunning_WaitsForFullBoot () var runner = new EmulatorRunner ("/fake/emulator"); var options = new EmulatorBootOptions { BootTimeout = TimeSpan.FromSeconds (5), PollInterval = TimeSpan.FromMilliseconds (50) }; - var result = await runner.BootAvdAsync ("Pixel_7_API_35", mockAdb, options); + var result = await runner.BootEmulatorAsync ("Pixel_7_API_35", mockAdb, options); Assert.IsTrue (result.Success); Assert.AreEqual ("emulator-5554", result.Serial); @@ -176,7 +176,7 @@ public async Task BootEmulator_AppearsAfterPolling () PollInterval = TimeSpan.FromMilliseconds (50), }; - var result = await runner.BootAvdAsync ("Pixel_7_API_35", mockAdb, options); + var result = await runner.BootEmulatorAsync ("Pixel_7_API_35", mockAdb, options); Assert.IsTrue (result.Success); Assert.AreEqual ("emulator-5554", result.Serial); @@ -202,7 +202,7 @@ public async Task LaunchFailure_ReturnsError () var runner = new EmulatorRunner ("/nonexistent/emulator"); var options = new EmulatorBootOptions { BootTimeout = TimeSpan.FromSeconds (2) }; - var result = await runner.BootAvdAsync ("Pixel_7_API_35", mockAdb, options); + var result = await runner.BootEmulatorAsync ("Pixel_7_API_35", mockAdb, options); Assert.IsFalse (result.Success); Assert.That (result.ErrorMessage, Does.Contain ("Failed to launch")); @@ -230,32 +230,32 @@ public async Task BootTimeout_BootCompletedNeverReaches1 () PollInterval = TimeSpan.FromMilliseconds (50), }; - var result = await runner.BootAvdAsync ("Pixel_7_API_35", mockAdb, options); + var result = await runner.BootEmulatorAsync ("Pixel_7_API_35", mockAdb, options); Assert.IsFalse (result.Success); Assert.That (result.ErrorMessage, Does.Contain ("Timed out")); } [Test] - public void BootAvdAsync_InvalidBootTimeout_Throws () + public void BootEmulatorAsync_InvalidBootTimeout_Throws () { var runner = new EmulatorRunner ("/fake/emulator"); var mockAdb = new MockAdbRunner (new List ()); var options = new EmulatorBootOptions { BootTimeout = TimeSpan.Zero }; Assert.ThrowsAsync (() => - runner.BootAvdAsync ("test", mockAdb, options)); + runner.BootEmulatorAsync ("test", mockAdb, options)); } [Test] - public void BootAvdAsync_InvalidPollInterval_Throws () + public void BootEmulatorAsync_InvalidPollInterval_Throws () { var runner = new EmulatorRunner ("/fake/emulator"); var mockAdb = new MockAdbRunner (new List ()); var options = new EmulatorBootOptions { PollInterval = TimeSpan.FromMilliseconds (-1) }; Assert.ThrowsAsync (() => - runner.BootAvdAsync ("test", mockAdb, options)); + runner.BootEmulatorAsync ("test", mockAdb, options)); } [Test] @@ -289,7 +289,7 @@ public async Task MultipleEmulators_FindsCorrectAvd () var runner = new EmulatorRunner ("/fake/emulator"); var options = new EmulatorBootOptions { BootTimeout = TimeSpan.FromSeconds (5), PollInterval = TimeSpan.FromMilliseconds (50) }; - var result = await runner.BootAvdAsync ("Pixel_7_API_35", mockAdb, options); + var result = await runner.BootEmulatorAsync ("Pixel_7_API_35", mockAdb, options); Assert.IsTrue (result.Success); Assert.AreEqual ("emulator-5556", result.Serial, "Should find the correct AVD among multiple emulators"); @@ -333,7 +333,7 @@ public async Task MultipleEmulators_FindsCorrectAvd () } /// - /// Mock AdbRunner for testing BootAvdAsync without real adb commands. + /// Mock AdbRunner for testing BootEmulatorAsync without real adb commands. /// class MockAdbRunner : AdbRunner { @@ -361,7 +361,7 @@ public override Task> ListDevicesAsync (Cancellatio return Task.FromResult (value); } - public override Task RunShellCommandAsync (string serial, string command, CancellationToken cancellationToken = default) + public override Task RunShellCommandAsync (string serial, string command, CancellationToken cancellationToken) { ShellCommands.TryGetValue (command, out var value); return Task.FromResult (value); From b462f243198cf92485c912515ec5b5efc59f423d Mon Sep 17 00:00:00 2001 From: Rui Marinho Date: Mon, 16 Mar 2026 18:56:38 +0000 Subject: [PATCH 10/16] Add tests ported from dotnet/android BootAndroidEmulator Port additional test coverage from dotnet/android PR #10949: - AlreadyOnlinePhysicalDevice: physical device serial passthrough - AdditionalArgs_PassedToLaunchEmulator: verify extra args reach process - CancellationToken_AbortsBoot: cancellation during polling phase - ColdBoot_PassesNoSnapshotLoad: verify -no-snapshot-load flag - BootEmulatorAsync_NullAdbRunner_Throws: null guard validation - BootEmulatorAsync_EmptyDeviceName_Throws: empty string guard Total EmulatorRunner test count: 24 (18 existing + 6 new) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../EmulatorRunnerTests.cs | 185 ++++++++++++++++++ 1 file changed, 185 insertions(+) diff --git a/tests/Xamarin.Android.Tools.AndroidSdk-Tests/EmulatorRunnerTests.cs b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/EmulatorRunnerTests.cs index f095a872..37874127 100644 --- a/tests/Xamarin.Android.Tools.AndroidSdk-Tests/EmulatorRunnerTests.cs +++ b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/EmulatorRunnerTests.cs @@ -295,6 +295,191 @@ public async Task MultipleEmulators_FindsCorrectAvd () Assert.AreEqual ("emulator-5556", result.Serial, "Should find the correct AVD among multiple emulators"); } + // --- Tests ported from dotnet/android BootAndroidEmulatorTests --- + + [Test] + public async Task AlreadyOnlinePhysicalDevice_PassesThrough () + { + // Physical devices have non-emulator serials (e.g., USB serial numbers). + // BootEmulatorAsync should recognise them as already-online devices and + // return immediately without attempting to launch an emulator. + var devices = new List { + new AdbDeviceInfo { + Serial = "0A041FDD400327", + Type = AdbDeviceType.Device, + Status = AdbDeviceStatus.Online, + }, + }; + + var mockAdb = new MockAdbRunner (devices); + var runner = new EmulatorRunner ("/fake/emulator"); + + var result = await runner.BootEmulatorAsync ("0A041FDD400327", mockAdb); + + Assert.IsTrue (result.Success); + Assert.AreEqual ("0A041FDD400327", result.Serial); + Assert.IsNull (result.ErrorMessage); + } + + [Test] + public async Task AdditionalArgs_PassedToLaunchEmulator () + { + // Verify that AdditionalArgs from EmulatorBootOptions are forwarded + // to the emulator process. We use a fake emulator script that logs + // its arguments so we can inspect them after the boot times out. + var (tempDir, emuPath) = CreateFakeEmulatorSdk (); + var argsLogPath = Path.Combine (tempDir, "args.log"); + + // Rewrite the fake emulator to log its arguments + if (OS.IsWindows) { + File.WriteAllText (emuPath, $"@echo off\r\necho %* > \"{argsLogPath}\"\r\nping -n 60 127.0.0.1 >nul\r\n"); + } else { + File.WriteAllText (emuPath, $"#!/bin/sh\necho \"$@\" > \"{argsLogPath}\"\nsleep 60\n"); + } + + try { + var devices = new List (); + var mockAdb = new MockAdbRunner (devices); + + var runner = new EmulatorRunner (emuPath); + var options = new EmulatorBootOptions { + BootTimeout = TimeSpan.FromMilliseconds (500), + PollInterval = TimeSpan.FromMilliseconds (50), + AdditionalArgs = new [] { "-gpu", "auto", "-no-audio" }, + }; + + // Boot will time out (no device appears), but the emulator process + // should have been launched with the additional args. + var result = await runner.BootEmulatorAsync ("Test_AVD", mockAdb, options); + + Assert.IsFalse (result.Success, "Boot should time out"); + + // Give the script a moment to flush args.log + await Task.Delay (200); + + if (File.Exists (argsLogPath)) { + var logged = File.ReadAllText (argsLogPath); + Assert.That (logged, Does.Contain ("-gpu"), "Should contain -gpu arg"); + Assert.That (logged, Does.Contain ("auto"), "Should contain auto value"); + Assert.That (logged, Does.Contain ("-no-audio"), "Should contain -no-audio arg"); + Assert.That (logged, Does.Contain ("-avd"), "Should contain -avd flag"); + Assert.That (logged, Does.Contain ("Test_AVD"), "Should contain AVD name"); + } + } finally { + // Clean up any spawned processes + try { + foreach (var p in Process.GetProcessesByName ("sleep")) { + try { p.Kill (); p.WaitForExit (1000); } catch { } + } + } catch { } + Directory.Delete (tempDir, true); + } + } + + [Test] + public async Task CancellationToken_AbortsBoot () + { + // Verify that cancelling the token during the polling phase causes + // BootEmulatorAsync to return promptly rather than blocking for the + // full BootTimeout duration. We need a real fake emulator script so + // LaunchEmulator succeeds (starts the process), then cancel while polling. + var (tempDir, emuPath) = CreateFakeEmulatorSdk (); + + try { + var devices = new List (); + var mockAdb = new MockAdbRunner (devices); + + var runner = new EmulatorRunner (emuPath); + var options = new EmulatorBootOptions { + BootTimeout = TimeSpan.FromSeconds (30), + PollInterval = TimeSpan.FromMilliseconds (50), + }; + + using var cts = new CancellationTokenSource (); + cts.CancelAfter (TimeSpan.FromMilliseconds (300)); + + var sw = Stopwatch.StartNew (); + try { + await runner.BootEmulatorAsync ("Nonexistent_AVD", mockAdb, options, cts.Token); + Assert.Fail ("Should have thrown OperationCanceledException"); + } catch (OperationCanceledException) { + sw.Stop (); + // Should abort well before the 30s BootTimeout + Assert.That (sw.Elapsed.TotalSeconds, Is.LessThan (5), + "Cancellation should abort within a few seconds, not wait for full timeout"); + } + } finally { + try { + foreach (var p in Process.GetProcessesByName ("sleep")) { + try { p.Kill (); p.WaitForExit (1000); } catch { } + } + } catch { } + Directory.Delete (tempDir, true); + } + } + + [Test] + public async Task ColdBoot_PassesNoSnapshotLoad () + { + // Verify that ColdBoot = true causes -no-snapshot-load to be passed. + var (tempDir, emuPath) = CreateFakeEmulatorSdk (); + var argsLogPath = Path.Combine (tempDir, "args.log"); + + if (OS.IsWindows) { + File.WriteAllText (emuPath, $"@echo off\r\necho %* > \"{argsLogPath}\"\r\nping -n 60 127.0.0.1 >nul\r\n"); + } else { + File.WriteAllText (emuPath, $"#!/bin/sh\necho \"$@\" > \"{argsLogPath}\"\nsleep 60\n"); + } + + try { + var devices = new List (); + var mockAdb = new MockAdbRunner (devices); + + var runner = new EmulatorRunner (emuPath); + var options = new EmulatorBootOptions { + BootTimeout = TimeSpan.FromMilliseconds (500), + PollInterval = TimeSpan.FromMilliseconds (50), + ColdBoot = true, + }; + + var result = await runner.BootEmulatorAsync ("Test_AVD", mockAdb, options); + + Assert.IsFalse (result.Success, "Boot should time out"); + await Task.Delay (200); + + if (File.Exists (argsLogPath)) { + var logged = File.ReadAllText (argsLogPath); + Assert.That (logged, Does.Contain ("-no-snapshot-load"), "ColdBoot should pass -no-snapshot-load"); + } + } finally { + try { + foreach (var p in Process.GetProcessesByName ("sleep")) { + try { p.Kill (); p.WaitForExit (1000); } catch { } + } + } catch { } + Directory.Delete (tempDir, true); + } + } + + [Test] + public void BootEmulatorAsync_NullAdbRunner_Throws () + { + var runner = new EmulatorRunner ("/fake/emulator"); + + Assert.ThrowsAsync (() => + runner.BootEmulatorAsync ("test", null!)); + } + + [Test] + public void BootEmulatorAsync_EmptyDeviceName_Throws () + { + var runner = new EmulatorRunner ("/fake/emulator"); + var mockAdb = new MockAdbRunner (new List ()); + + Assert.ThrowsAsync (() => + runner.BootEmulatorAsync ("", mockAdb)); + } + // --- Helpers --- static (string tempDir, string emulatorPath) CreateFakeEmulatorSdk () From 8cbb0aea8108f0a8b5b8b8800f59ebb767bee4d5 Mon Sep 17 00:00:00 2001 From: Rui Marinho Date: Tue, 17 Mar 2026 11:27:25 +0000 Subject: [PATCH 11/16] Fix AVD name detection for emulator 36+ with getprop fallback The 'adb emu avd name' console command returns empty output on emulator v36+ due to gRPC authentication requirements. This causes BootEmulatorAsync to never match the running emulator by AVD name, resulting in a perpetual polling loop and eventual timeout. Add a fallback to 'adb shell getprop ro.boot.qemu.avd_name' which reads the boot property set by the emulator kernel. This property is always available and doesn't require console authentication. The fix benefits all consumers of ListDevicesAsync/GetEmulatorAvdNameAsync, not just BootEmulatorAsync. Verified locally: BootEmulatorAsync now completes in ~3s (was timing out at 120s) on emulator v36.4.9 with API 36 image. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Runners/AdbRunner.cs | 25 +++++++++++++++---- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs index 5437ce8f..e82b4024 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs +++ b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs @@ -73,12 +73,16 @@ public virtual async Task> ListDevicesAsync (Cancel } /// - /// Queries the emulator for its AVD name using 'adb -s <serial> emu avd name'. - /// Returns null if the query fails or produces no output. - /// Ported from dotnet/android GetAvailableAndroidDevices.GetEmulatorAvdName. + /// Queries the emulator for its AVD name. + /// Tries adb -s <serial> emu avd name first (emulator console protocol), + /// then falls back to adb shell getprop ro.boot.qemu.avd_name which reads the + /// boot property set by the emulator kernel. The fallback is needed because newer + /// emulator versions (36+) may require authentication for console commands, causing + /// emu avd name to return empty output. /// internal async Task GetEmulatorAvdNameAsync (string serial, CancellationToken cancellationToken = default) { + // Try 1: Console command (fast, works on most emulator versions) try { using var stdout = new StringWriter (); var psi = ProcessUtils.CreateProcessStartInfo (adbPath, "-s", serial, "emu", "avd", "name"); @@ -93,8 +97,19 @@ public virtual async Task> ListDevicesAsync (Cancel } } catch (OperationCanceledException) { throw; - } catch (Exception ex) { - Trace.WriteLine ($"GetEmulatorAvdNameAsync adb query failed for '{serial}': {ex.Message}"); + } catch { + // Fall through to getprop fallback + } + + // Try 2: Shell property (works when emu console requires auth, e.g. emulator 36+) + try { + var avdName = await GetShellPropertyAsync (serial, "ro.boot.qemu.avd_name", cancellationToken).ConfigureAwait (false); + if (!string.IsNullOrWhiteSpace (avdName)) + return avdName!.Trim (); + } catch (OperationCanceledException) { + throw; + } catch { + // Both methods failed } return null; From 63c0fcf67ef4d35025e8f4caed4668ab42e2b616 Mon Sep 17 00:00:00 2001 From: Rui Marinho Date: Tue, 17 Mar 2026 12:22:42 +0000 Subject: [PATCH 12/16] Address PR review: record type, List, simplify code Changes: - Convert EmulatorBootOptions from class to record with init properties - Change AdditionalArgs from IEnumerable to List for collection initializers - Remove REMOVED lines from PublicAPI.Unshipped.txt files - Remove local Log function, inline logger calls - Simplify while loop condition in WaitForFullBootAsync - Remove entireProcessTree from process termination Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Models/EmulatorBootOptions.cs | 10 +++---- .../PublicAPI/net10.0/PublicAPI.Unshipped.txt | 14 +++++----- .../netstandard2.0/PublicAPI.Unshipped.txt | 14 +++++----- .../Runners/EmulatorRunner.cs | 27 ++++++++----------- .../EmulatorRunnerTests.cs | 2 +- 5 files changed, 29 insertions(+), 38 deletions(-) diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Models/EmulatorBootOptions.cs b/src/Xamarin.Android.Tools.AndroidSdk/Models/EmulatorBootOptions.cs index 7c011fde..698e1a0e 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/Models/EmulatorBootOptions.cs +++ b/src/Xamarin.Android.Tools.AndroidSdk/Models/EmulatorBootOptions.cs @@ -9,10 +9,10 @@ namespace Xamarin.Android.Tools; /// /// Options for booting an Android emulator. /// -public class EmulatorBootOptions +public record EmulatorBootOptions { - public TimeSpan BootTimeout { get; set; } = TimeSpan.FromSeconds (300); - public IEnumerable? AdditionalArgs { get; set; } - public bool ColdBoot { get; set; } - public TimeSpan PollInterval { get; set; } = TimeSpan.FromMilliseconds (500); + public TimeSpan BootTimeout { get; init; } = TimeSpan.FromSeconds (300); + public List? AdditionalArgs { get; init; } + public bool ColdBoot { get; init; } + public TimeSpan PollInterval { get; init; } = TimeSpan.FromMilliseconds (500); } diff --git a/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/net10.0/PublicAPI.Unshipped.txt b/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/net10.0/PublicAPI.Unshipped.txt index 484b7243..c6deb256 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/net10.0/PublicAPI.Unshipped.txt +++ b/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/net10.0/PublicAPI.Unshipped.txt @@ -1,5 +1,4 @@ #nullable enable -*REMOVED*static Xamarin.Android.Tools.ProcessUtils.StartProcess(System.Diagnostics.ProcessStartInfo! psi, System.IO.TextWriter? stdout, System.IO.TextWriter? stderr, System.Threading.CancellationToken cancellationToken, System.Action? onStarted = null) -> System.Threading.Tasks.Task! Xamarin.Android.Tools.AdbDeviceInfo Xamarin.Android.Tools.AdbDeviceInfo.AdbDeviceInfo() -> void Xamarin.Android.Tools.AdbDeviceInfo.AvdName.get -> string? @@ -33,7 +32,6 @@ Xamarin.Android.Tools.AdbDeviceType.Device = 0 -> Xamarin.Android.Tools.AdbDevic Xamarin.Android.Tools.AdbDeviceType.Emulator = 1 -> Xamarin.Android.Tools.AdbDeviceType Xamarin.Android.Tools.AdbRunner Xamarin.Android.Tools.AdbRunner.AdbRunner(string! adbPath, System.Collections.Generic.IDictionary? environmentVariables = null, System.Action? logger = null) -> void -*REMOVED*Xamarin.Android.Tools.AdbRunner.ListDevicesAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! virtual Xamarin.Android.Tools.AdbRunner.ListDevicesAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! Xamarin.Android.Tools.AdbRunner.StopEmulatorAsync(string! serial, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! Xamarin.Android.Tools.AdbRunner.WaitForDeviceAsync(string? serial = null, System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! @@ -150,15 +148,15 @@ virtual Xamarin.Android.Tools.AdbRunner.GetShellPropertyAsync(string! serial, st virtual Xamarin.Android.Tools.AdbRunner.RunShellCommandAsync(string! serial, string! command, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! virtual Xamarin.Android.Tools.AdbRunner.RunShellCommandAsync(string! serial, string! command, string![]! args, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! Xamarin.Android.Tools.EmulatorBootOptions -Xamarin.Android.Tools.EmulatorBootOptions.AdditionalArgs.get -> System.Collections.Generic.IEnumerable? -Xamarin.Android.Tools.EmulatorBootOptions.AdditionalArgs.set -> void +Xamarin.Android.Tools.EmulatorBootOptions.AdditionalArgs.get -> System.Collections.Generic.List? +Xamarin.Android.Tools.EmulatorBootOptions.AdditionalArgs.init -> void Xamarin.Android.Tools.EmulatorBootOptions.BootTimeout.get -> System.TimeSpan -Xamarin.Android.Tools.EmulatorBootOptions.BootTimeout.set -> void +Xamarin.Android.Tools.EmulatorBootOptions.BootTimeout.init -> void Xamarin.Android.Tools.EmulatorBootOptions.ColdBoot.get -> bool -Xamarin.Android.Tools.EmulatorBootOptions.ColdBoot.set -> void +Xamarin.Android.Tools.EmulatorBootOptions.ColdBoot.init -> void Xamarin.Android.Tools.EmulatorBootOptions.EmulatorBootOptions() -> void Xamarin.Android.Tools.EmulatorBootOptions.PollInterval.get -> System.TimeSpan -Xamarin.Android.Tools.EmulatorBootOptions.PollInterval.set -> void +Xamarin.Android.Tools.EmulatorBootOptions.PollInterval.init -> void Xamarin.Android.Tools.EmulatorBootResult Xamarin.Android.Tools.EmulatorBootResult.ErrorMessage.get -> string? Xamarin.Android.Tools.EmulatorBootResult.ErrorMessage.init -> void @@ -170,4 +168,4 @@ Xamarin.Android.Tools.EmulatorRunner Xamarin.Android.Tools.EmulatorRunner.BootEmulatorAsync(string! deviceOrAvdName, Xamarin.Android.Tools.AdbRunner! adbRunner, Xamarin.Android.Tools.EmulatorBootOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! Xamarin.Android.Tools.EmulatorRunner.EmulatorRunner(string! emulatorPath, System.Collections.Generic.IDictionary? environmentVariables = null, System.Action? logger = null) -> void Xamarin.Android.Tools.EmulatorRunner.ListAvdNamesAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! -Xamarin.Android.Tools.EmulatorRunner.LaunchEmulator(string! avdName, bool coldBoot = false, System.Collections.Generic.IEnumerable? additionalArgs = null) -> System.Diagnostics.Process! +Xamarin.Android.Tools.EmulatorRunner.LaunchEmulator(string! avdName, bool coldBoot = false, System.Collections.Generic.List? additionalArgs = null) -> System.Diagnostics.Process! diff --git a/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt b/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt index 484b7243..c6deb256 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt +++ b/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt @@ -1,5 +1,4 @@ #nullable enable -*REMOVED*static Xamarin.Android.Tools.ProcessUtils.StartProcess(System.Diagnostics.ProcessStartInfo! psi, System.IO.TextWriter? stdout, System.IO.TextWriter? stderr, System.Threading.CancellationToken cancellationToken, System.Action? onStarted = null) -> System.Threading.Tasks.Task! Xamarin.Android.Tools.AdbDeviceInfo Xamarin.Android.Tools.AdbDeviceInfo.AdbDeviceInfo() -> void Xamarin.Android.Tools.AdbDeviceInfo.AvdName.get -> string? @@ -33,7 +32,6 @@ Xamarin.Android.Tools.AdbDeviceType.Device = 0 -> Xamarin.Android.Tools.AdbDevic Xamarin.Android.Tools.AdbDeviceType.Emulator = 1 -> Xamarin.Android.Tools.AdbDeviceType Xamarin.Android.Tools.AdbRunner Xamarin.Android.Tools.AdbRunner.AdbRunner(string! adbPath, System.Collections.Generic.IDictionary? environmentVariables = null, System.Action? logger = null) -> void -*REMOVED*Xamarin.Android.Tools.AdbRunner.ListDevicesAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! virtual Xamarin.Android.Tools.AdbRunner.ListDevicesAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! Xamarin.Android.Tools.AdbRunner.StopEmulatorAsync(string! serial, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! Xamarin.Android.Tools.AdbRunner.WaitForDeviceAsync(string? serial = null, System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! @@ -150,15 +148,15 @@ virtual Xamarin.Android.Tools.AdbRunner.GetShellPropertyAsync(string! serial, st virtual Xamarin.Android.Tools.AdbRunner.RunShellCommandAsync(string! serial, string! command, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! virtual Xamarin.Android.Tools.AdbRunner.RunShellCommandAsync(string! serial, string! command, string![]! args, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! Xamarin.Android.Tools.EmulatorBootOptions -Xamarin.Android.Tools.EmulatorBootOptions.AdditionalArgs.get -> System.Collections.Generic.IEnumerable? -Xamarin.Android.Tools.EmulatorBootOptions.AdditionalArgs.set -> void +Xamarin.Android.Tools.EmulatorBootOptions.AdditionalArgs.get -> System.Collections.Generic.List? +Xamarin.Android.Tools.EmulatorBootOptions.AdditionalArgs.init -> void Xamarin.Android.Tools.EmulatorBootOptions.BootTimeout.get -> System.TimeSpan -Xamarin.Android.Tools.EmulatorBootOptions.BootTimeout.set -> void +Xamarin.Android.Tools.EmulatorBootOptions.BootTimeout.init -> void Xamarin.Android.Tools.EmulatorBootOptions.ColdBoot.get -> bool -Xamarin.Android.Tools.EmulatorBootOptions.ColdBoot.set -> void +Xamarin.Android.Tools.EmulatorBootOptions.ColdBoot.init -> void Xamarin.Android.Tools.EmulatorBootOptions.EmulatorBootOptions() -> void Xamarin.Android.Tools.EmulatorBootOptions.PollInterval.get -> System.TimeSpan -Xamarin.Android.Tools.EmulatorBootOptions.PollInterval.set -> void +Xamarin.Android.Tools.EmulatorBootOptions.PollInterval.init -> void Xamarin.Android.Tools.EmulatorBootResult Xamarin.Android.Tools.EmulatorBootResult.ErrorMessage.get -> string? Xamarin.Android.Tools.EmulatorBootResult.ErrorMessage.init -> void @@ -170,4 +168,4 @@ Xamarin.Android.Tools.EmulatorRunner Xamarin.Android.Tools.EmulatorRunner.BootEmulatorAsync(string! deviceOrAvdName, Xamarin.Android.Tools.AdbRunner! adbRunner, Xamarin.Android.Tools.EmulatorBootOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! Xamarin.Android.Tools.EmulatorRunner.EmulatorRunner(string! emulatorPath, System.Collections.Generic.IDictionary? environmentVariables = null, System.Action? logger = null) -> void Xamarin.Android.Tools.EmulatorRunner.ListAvdNamesAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! -Xamarin.Android.Tools.EmulatorRunner.LaunchEmulator(string! avdName, bool coldBoot = false, System.Collections.Generic.IEnumerable? additionalArgs = null) -> System.Diagnostics.Process! +Xamarin.Android.Tools.EmulatorRunner.LaunchEmulator(string! avdName, bool coldBoot = false, System.Collections.Generic.List? additionalArgs = null) -> System.Diagnostics.Process! diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs b/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs index 83f67ea5..d818f3d2 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs +++ b/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs @@ -46,7 +46,7 @@ public EmulatorRunner (string emulatorPath, IDictionary? environ /// When true, forces a cold boot by passing -no-snapshot-load. /// Optional extra arguments to pass to the emulator command line. /// The running the emulator. Stdout/stderr are redirected and forwarded to the logger. - public Process LaunchEmulator (string avdName, bool coldBoot = false, IEnumerable? additionalArgs = null) + public Process LaunchEmulator (string avdName, bool coldBoot = false, List? additionalArgs = null) { if (string.IsNullOrWhiteSpace (avdName)) throw new ArgumentException ("AVD name must not be empty.", nameof (avdName)); @@ -156,9 +156,7 @@ public async Task BootEmulatorAsync ( if (options.PollInterval <= TimeSpan.Zero) throw new ArgumentOutOfRangeException (nameof (options), "PollInterval must be positive."); - void Log (TraceLevel level, string message) => logger?.Invoke (level, message); - - Log (TraceLevel.Info, $"Booting emulator for '{deviceOrAvdName}'..."); + logger?.Invoke (TraceLevel.Info, $"Booting emulator for '{deviceOrAvdName}'..."); // Phase 1: Check if deviceOrAvdName is already an online ADB device by serial var devices = await adbRunner.ListDevicesAsync (cancellationToken).ConfigureAwait (false); @@ -167,7 +165,7 @@ public async Task BootEmulatorAsync ( string.Equals (d.Serial, deviceOrAvdName, StringComparison.OrdinalIgnoreCase)); if (onlineDevice != null) { - Log (TraceLevel.Info, $"Device '{deviceOrAvdName}' is already online."); + logger?.Invoke (TraceLevel.Info, $"Device '{deviceOrAvdName}' is already online."); return new EmulatorBootResult { Success = true, Serial = onlineDevice.Serial }; } @@ -178,7 +176,7 @@ public async Task BootEmulatorAsync ( // Phase 2: Check if AVD is already running (possibly still booting) var runningSerial = FindRunningAvdSerial (devices, deviceOrAvdName); if (runningSerial != null) { - Log (TraceLevel.Info, $"AVD '{deviceOrAvdName}' is already running as '{runningSerial}', waiting for full boot..."); + logger?.Invoke (TraceLevel.Info, $"AVD '{deviceOrAvdName}' is already running as '{runningSerial}', waiting for full boot..."); try { return await WaitForFullBootAsync (adbRunner, runningSerial, options, timeoutCts.Token).ConfigureAwait (false); } catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { @@ -190,7 +188,7 @@ public async Task BootEmulatorAsync ( } // Phase 3: Launch the emulator - Log (TraceLevel.Info, $"Launching AVD '{deviceOrAvdName}'..."); + logger?.Invoke (TraceLevel.Info, $"Launching AVD '{deviceOrAvdName}'..."); Process emulatorProcess; try { emulatorProcess = LaunchEmulator (deviceOrAvdName, options.ColdBoot, options.AdditionalArgs); @@ -214,7 +212,7 @@ public async Task BootEmulatorAsync ( newSerial = FindRunningAvdSerial (devices, deviceOrAvdName); } - Log (TraceLevel.Info, $"Emulator appeared as '{newSerial}', waiting for full boot..."); + logger?.Invoke (TraceLevel.Info, $"Emulator appeared as '{newSerial}', waiting for full boot..."); var result = await WaitForFullBootAsync (adbRunner, newSerial, options, timeoutCts.Token).ConfigureAwait (false); // Release the Process handle — the emulator process itself keeps running. @@ -248,11 +246,7 @@ public async Task BootEmulatorAsync ( void TryKillProcess (Process process) { try { -#if NET5_0_OR_GREATER - process.Kill (entireProcessTree: true); -#else process.Kill (); -#endif } catch (Exception ex) { // Best-effort: process may have already exited logger?.Invoke (TraceLevel.Verbose, $"Failed to stop emulator process: {ex.Message}"); @@ -267,25 +261,26 @@ async Task WaitForFullBootAsync ( EmulatorBootOptions options, CancellationToken cancellationToken) { - void Log (TraceLevel level, string message) => logger?.Invoke (level, message); - // The caller is responsible for enforcing the overall boot timeout via // cancellationToken (a linked CTS with CancelAfter). This method simply // polls until boot completes or the token is cancelled. - while (true) { + while (!cancellationToken.IsCancellationRequested) { cancellationToken.ThrowIfCancellationRequested (); var bootCompleted = await adbRunner.GetShellPropertyAsync (serial, "sys.boot_completed", cancellationToken).ConfigureAwait (false); if (string.Equals (bootCompleted, "1", StringComparison.Ordinal)) { var pmResult = await adbRunner.RunShellCommandAsync (serial, "pm path android", cancellationToken).ConfigureAwait (false); if (pmResult != null && pmResult.StartsWith ("package:", StringComparison.Ordinal)) { - Log (TraceLevel.Info, $"Emulator '{serial}' is fully booted."); + logger?.Invoke (TraceLevel.Info, $"Emulator '{serial}' is fully booted."); return new EmulatorBootResult { Success = true, Serial = serial }; } } await Task.Delay (options.PollInterval, cancellationToken).ConfigureAwait (false); } + + cancellationToken.ThrowIfCancellationRequested (); + return new EmulatorBootResult { Success = false, ErrorMessage = "Boot cancelled." }; } } diff --git a/tests/Xamarin.Android.Tools.AndroidSdk-Tests/EmulatorRunnerTests.cs b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/EmulatorRunnerTests.cs index 37874127..39fdca5b 100644 --- a/tests/Xamarin.Android.Tools.AndroidSdk-Tests/EmulatorRunnerTests.cs +++ b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/EmulatorRunnerTests.cs @@ -345,7 +345,7 @@ public async Task AdditionalArgs_PassedToLaunchEmulator () var options = new EmulatorBootOptions { BootTimeout = TimeSpan.FromMilliseconds (500), PollInterval = TimeSpan.FromMilliseconds (50), - AdditionalArgs = new [] { "-gpu", "auto", "-no-audio" }, + AdditionalArgs = new List { "-gpu", "auto", "-no-audio" }, }; // Boot will time out (no device appears), but the emulator process From d12dde70222b2ecf4219a4b9abf1e9ed6af41510 Mon Sep 17 00:00:00 2001 From: Rui Marinho Date: Tue, 17 Mar 2026 12:27:06 +0000 Subject: [PATCH 13/16] Apply NullLogger pattern across all runners Replace logger?.Invoke with logger.Invoke using a static NullLogger no-op delegate in EmulatorRunner, AdbRunner, and AvdManagerRunner. The constructor assigns logger ?? NullLogger so the field is never null. Static methods use logger ??= NullLogger at entry. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../PublicAPI/net10.0/PublicAPI.Unshipped.txt | 1 - .../netstandard2.0/PublicAPI.Unshipped.txt | 1 - .../Runners/AdbRunner.cs | 24 +++++++++------- .../Runners/AvdManagerRunner.cs | 14 ++++++---- .../Runners/EmulatorRunner.cs | 28 ++++++++++--------- 5 files changed, 37 insertions(+), 31 deletions(-) diff --git a/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/net10.0/PublicAPI.Unshipped.txt b/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/net10.0/PublicAPI.Unshipped.txt index c6deb256..ed4d92d0 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/net10.0/PublicAPI.Unshipped.txt +++ b/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/net10.0/PublicAPI.Unshipped.txt @@ -154,7 +154,6 @@ Xamarin.Android.Tools.EmulatorBootOptions.BootTimeout.get -> System.TimeSpan Xamarin.Android.Tools.EmulatorBootOptions.BootTimeout.init -> void Xamarin.Android.Tools.EmulatorBootOptions.ColdBoot.get -> bool Xamarin.Android.Tools.EmulatorBootOptions.ColdBoot.init -> void -Xamarin.Android.Tools.EmulatorBootOptions.EmulatorBootOptions() -> void Xamarin.Android.Tools.EmulatorBootOptions.PollInterval.get -> System.TimeSpan Xamarin.Android.Tools.EmulatorBootOptions.PollInterval.init -> void Xamarin.Android.Tools.EmulatorBootResult diff --git a/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt b/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt index c6deb256..ed4d92d0 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt +++ b/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt @@ -154,7 +154,6 @@ Xamarin.Android.Tools.EmulatorBootOptions.BootTimeout.get -> System.TimeSpan Xamarin.Android.Tools.EmulatorBootOptions.BootTimeout.init -> void Xamarin.Android.Tools.EmulatorBootOptions.ColdBoot.get -> bool Xamarin.Android.Tools.EmulatorBootOptions.ColdBoot.init -> void -Xamarin.Android.Tools.EmulatorBootOptions.EmulatorBootOptions() -> void Xamarin.Android.Tools.EmulatorBootOptions.PollInterval.get -> System.TimeSpan Xamarin.Android.Tools.EmulatorBootOptions.PollInterval.init -> void Xamarin.Android.Tools.EmulatorBootResult diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs index e82b4024..21a320c9 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs +++ b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs @@ -21,7 +21,9 @@ public class AdbRunner { readonly string adbPath; readonly IDictionary? environmentVariables; - readonly Action? logger; + readonly Action logger; + + static readonly Action NullLogger = static (_, _) => { }; // Pattern to match device lines: [key:value ...] // Uses \s+ to match one or more whitespace characters (spaces or tabs) between fields. @@ -43,7 +45,7 @@ public AdbRunner (string adbPath, IDictionary? environmentVariab throw new ArgumentException ("Path to adb must not be empty.", nameof (adbPath)); this.adbPath = adbPath; this.environmentVariables = environmentVariables; - this.logger = logger; + this.logger = logger ?? NullLogger; } /// @@ -166,7 +168,7 @@ public async Task StopEmulatorAsync (string serial, CancellationToken cancellati if (exitCode != 0) { var stderrText = stderr.ToString ().Trim (); if (stderrText.Length > 0) - logger?.Invoke (TraceLevel.Warning, $"adb shell getprop {propertyName} failed (exit {exitCode}): {stderrText}"); + logger.Invoke (TraceLevel.Warning, $"adb shell getprop {propertyName} failed (exit {exitCode}): {stderrText}"); return null; } return FirstNonEmptyLine (stdout.ToString ()); @@ -190,7 +192,7 @@ public async Task StopEmulatorAsync (string serial, CancellationToken cancellati if (exitCode != 0) { var stderrText = stderr.ToString ().Trim (); if (stderrText.Length > 0) - logger?.Invoke (TraceLevel.Warning, $"adb shell {command} failed (exit {exitCode}): {stderrText}"); + logger.Invoke (TraceLevel.Warning, $"adb shell {command} failed (exit {exitCode}): {stderrText}"); return null; } var output = stdout.ToString ().Trim (); @@ -222,7 +224,7 @@ public async Task StopEmulatorAsync (string serial, CancellationToken cancellati if (exitCode != 0) { var stderrText = stderr.ToString ().Trim (); if (stderrText.Length > 0) - logger?.Invoke (TraceLevel.Warning, $"adb shell {command} failed (exit {exitCode}): {stderrText}"); + logger.Invoke (TraceLevel.Warning, $"adb shell {command} failed (exit {exitCode}): {stderrText}"); return null; } var output = stdout.ToString ().Trim (); @@ -323,10 +325,11 @@ public static IReadOnlyList ParseAdbDevicesOutput (IEnumerable public static string BuildDeviceDescription (AdbDeviceInfo device, Action? logger = null) { + logger ??= NullLogger; if (device.Type == AdbDeviceType.Emulator && device.AvdName is { Length: > 0 } avdName) { - logger?.Invoke (TraceLevel.Verbose, $"Emulator {device.Serial}, original AVD name: {avdName}"); + logger.Invoke (TraceLevel.Verbose, $"Emulator {device.Serial}, original AVD name: {avdName}"); var formatted = FormatDisplayName (avdName); - logger?.Invoke (TraceLevel.Verbose, $"Emulator {device.Serial}, formatted AVD display name: {formatted}"); + logger.Invoke (TraceLevel.Verbose, $"Emulator {device.Serial}, formatted AVD display name: {formatted}"); return formatted; } @@ -368,6 +371,7 @@ public static string FormatDisplayName (string avdName) /// public static IReadOnlyList MergeDevicesAndEmulators (IReadOnlyList adbDevices, IReadOnlyList availableEmulators, Action? logger = null) { + logger ??= NullLogger; var result = new List (adbDevices); // Build a set of AVD names that are already running @@ -377,12 +381,12 @@ public static IReadOnlyList MergeDevicesAndEmulators (IReadOnlyLi runningAvdNames.Add (avdName); } - logger?.Invoke (TraceLevel.Verbose, $"Running emulators AVD names: {string.Join (", ", runningAvdNames)}"); + logger.Invoke (TraceLevel.Verbose, $"Running emulators AVD names: {string.Join (", ", runningAvdNames)}"); // Add non-running emulators foreach (var avdName in availableEmulators) { if (runningAvdNames.Contains (avdName)) { - logger?.Invoke (TraceLevel.Verbose, $"Emulator '{avdName}' is already running, skipping"); + logger.Invoke (TraceLevel.Verbose, $"Emulator '{avdName}' is already running, skipping"); continue; } @@ -394,7 +398,7 @@ public static IReadOnlyList MergeDevicesAndEmulators (IReadOnlyLi Status = AdbDeviceStatus.NotRunning, AvdName = avdName, }); - logger?.Invoke (TraceLevel.Verbose, $"Added non-running emulator: {avdName}"); + logger.Invoke (TraceLevel.Verbose, $"Added non-running emulator: {avdName}"); } // Sort: online devices first, then not-running emulators, alphabetically by description diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Runners/AvdManagerRunner.cs b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AvdManagerRunner.cs index d678991b..ede9f60e 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/Runners/AvdManagerRunner.cs +++ b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AvdManagerRunner.cs @@ -18,7 +18,9 @@ public class AvdManagerRunner { readonly string avdManagerPath; readonly IDictionary? environmentVariables; - readonly Action? logger; + readonly Action logger; + + static readonly Action NullLogger = static (_, _) => { }; /// /// Creates a new AvdManagerRunner with the full path to the avdmanager executable. @@ -32,7 +34,7 @@ public AvdManagerRunner (string avdManagerPath, IDictionary? env throw new ArgumentException ("Path to avdmanager must not be empty.", nameof (avdManagerPath)); this.avdManagerPath = avdManagerPath; this.environmentVariables = environmentVariables; - this.logger = logger; + this.logger = logger ?? NullLogger; } public async Task> ListAvdsAsync (CancellationToken cancellationToken = default) @@ -40,7 +42,7 @@ public async Task> ListAvdsAsync (CancellationToken cance using var stdout = new StringWriter (); using var stderr = new StringWriter (); var psi = ProcessUtils.CreateProcessStartInfo (avdManagerPath, "list", "avd"); - logger?.Invoke (TraceLevel.Verbose, "Running: avdmanager list avd"); + logger.Invoke (TraceLevel.Verbose, "Running: avdmanager list avd"); var exitCode = await ProcessUtils.StartProcess (psi, stdout, stderr, cancellationToken, environmentVariables).ConfigureAwait (false); ProcessUtils.ThrowIfFailed (exitCode, "avdmanager list avd", stderr); @@ -65,7 +67,7 @@ public async Task GetOrCreateAvdAsync (string name, string systemImage, var existing = (await ListAvdsAsync (cancellationToken).ConfigureAwait (false)) .FirstOrDefault (a => string.Equals (a.Name, name, StringComparison.OrdinalIgnoreCase)); if (existing is not null) { - logger?.Invoke (TraceLevel.Verbose, $"AVD '{name}' already exists, returning existing"); + logger.Invoke (TraceLevel.Verbose, $"AVD '{name}' already exists, returning existing"); return existing; } } @@ -88,7 +90,7 @@ public async Task GetOrCreateAvdAsync (string name, string systemImage, p.StandardInput.WriteLine ("no"); p.StandardInput.Close (); } catch (IOException ex) { - logger?.Invoke (TraceLevel.Warning, $"Failed to write to avdmanager stdin: {ex.Message}"); + logger.Invoke (TraceLevel.Warning, $"Failed to write to avdmanager stdin: {ex.Message}"); } }).ConfigureAwait (false); @@ -111,7 +113,7 @@ public async Task DeleteAvdAsync (string name, CancellationToken cancellationTok // Idempotent: if the AVD doesn't exist, treat as success var avds = await ListAvdsAsync (cancellationToken).ConfigureAwait (false); if (!avds.Any (a => string.Equals (a.Name, name, StringComparison.OrdinalIgnoreCase))) { - logger?.Invoke (TraceLevel.Verbose, $"AVD '{name}' does not exist, nothing to delete"); + logger.Invoke (TraceLevel.Verbose, $"AVD '{name}' does not exist, nothing to delete"); return; } diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs b/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs index d818f3d2..976dac2f 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs +++ b/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs @@ -16,9 +16,11 @@ namespace Xamarin.Android.Tools; /// public class EmulatorRunner { + static readonly Action NullLogger = static (_, _) => { }; + readonly string emulatorPath; readonly IDictionary? environmentVariables; - readonly Action? logger; + readonly Action logger; /// /// Creates a new EmulatorRunner with the full path to the emulator executable. @@ -32,7 +34,7 @@ public EmulatorRunner (string emulatorPath, IDictionary? environ throw new ArgumentException ("Path to emulator must not be empty.", nameof (emulatorPath)); this.emulatorPath = emulatorPath; this.environmentVariables = environmentVariables; - this.logger = logger; + this.logger = logger ?? NullLogger; } /// @@ -70,7 +72,7 @@ public Process LaunchEmulator (string avdName, bool coldBoot = false, List { if (e.Data != null) - logger?.Invoke (TraceLevel.Verbose, $"[emulator] {e.Data}"); + logger.Invoke (TraceLevel.Verbose, $"[emulator] {e.Data}"); }; process.ErrorDataReceived += (_, e) => { if (e.Data != null) - logger?.Invoke (TraceLevel.Warning, $"[emulator] {e.Data}"); + logger.Invoke (TraceLevel.Warning, $"[emulator] {e.Data}"); }; process.Start (); @@ -100,7 +102,7 @@ public async Task> ListAvdNamesAsync (CancellationToken ca using var stderr = new StringWriter (); var psi = ProcessUtils.CreateProcessStartInfo (emulatorPath, "-list-avds"); - logger?.Invoke (TraceLevel.Verbose, "Running: emulator -list-avds"); + logger.Invoke (TraceLevel.Verbose, "Running: emulator -list-avds"); var exitCode = await ProcessUtils.StartProcess (psi, stdout, stderr, cancellationToken, environmentVariables).ConfigureAwait (false); ProcessUtils.ThrowIfFailed (exitCode, "emulator -list-avds", stderr); @@ -156,7 +158,7 @@ public async Task BootEmulatorAsync ( if (options.PollInterval <= TimeSpan.Zero) throw new ArgumentOutOfRangeException (nameof (options), "PollInterval must be positive."); - logger?.Invoke (TraceLevel.Info, $"Booting emulator for '{deviceOrAvdName}'..."); + logger.Invoke (TraceLevel.Info, $"Booting emulator for '{deviceOrAvdName}'..."); // Phase 1: Check if deviceOrAvdName is already an online ADB device by serial var devices = await adbRunner.ListDevicesAsync (cancellationToken).ConfigureAwait (false); @@ -165,7 +167,7 @@ public async Task BootEmulatorAsync ( string.Equals (d.Serial, deviceOrAvdName, StringComparison.OrdinalIgnoreCase)); if (onlineDevice != null) { - logger?.Invoke (TraceLevel.Info, $"Device '{deviceOrAvdName}' is already online."); + logger.Invoke (TraceLevel.Info, $"Device '{deviceOrAvdName}' is already online."); return new EmulatorBootResult { Success = true, Serial = onlineDevice.Serial }; } @@ -176,7 +178,7 @@ public async Task BootEmulatorAsync ( // Phase 2: Check if AVD is already running (possibly still booting) var runningSerial = FindRunningAvdSerial (devices, deviceOrAvdName); if (runningSerial != null) { - logger?.Invoke (TraceLevel.Info, $"AVD '{deviceOrAvdName}' is already running as '{runningSerial}', waiting for full boot..."); + logger.Invoke (TraceLevel.Info, $"AVD '{deviceOrAvdName}' is already running as '{runningSerial}', waiting for full boot..."); try { return await WaitForFullBootAsync (adbRunner, runningSerial, options, timeoutCts.Token).ConfigureAwait (false); } catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { @@ -188,7 +190,7 @@ public async Task BootEmulatorAsync ( } // Phase 3: Launch the emulator - logger?.Invoke (TraceLevel.Info, $"Launching AVD '{deviceOrAvdName}'..."); + logger.Invoke (TraceLevel.Info, $"Launching AVD '{deviceOrAvdName}'..."); Process emulatorProcess; try { emulatorProcess = LaunchEmulator (deviceOrAvdName, options.ColdBoot, options.AdditionalArgs); @@ -212,7 +214,7 @@ public async Task BootEmulatorAsync ( newSerial = FindRunningAvdSerial (devices, deviceOrAvdName); } - logger?.Invoke (TraceLevel.Info, $"Emulator appeared as '{newSerial}', waiting for full boot..."); + logger.Invoke (TraceLevel.Info, $"Emulator appeared as '{newSerial}', waiting for full boot..."); var result = await WaitForFullBootAsync (adbRunner, newSerial, options, timeoutCts.Token).ConfigureAwait (false); // Release the Process handle — the emulator process itself keeps running. @@ -249,7 +251,7 @@ void TryKillProcess (Process process) process.Kill (); } catch (Exception ex) { // Best-effort: process may have already exited - logger?.Invoke (TraceLevel.Verbose, $"Failed to stop emulator process: {ex.Message}"); + logger.Invoke (TraceLevel.Verbose, $"Failed to stop emulator process: {ex.Message}"); } finally { process.Dispose (); } @@ -271,7 +273,7 @@ async Task WaitForFullBootAsync ( if (string.Equals (bootCompleted, "1", StringComparison.Ordinal)) { var pmResult = await adbRunner.RunShellCommandAsync (serial, "pm path android", cancellationToken).ConfigureAwait (false); if (pmResult != null && pmResult.StartsWith ("package:", StringComparison.Ordinal)) { - logger?.Invoke (TraceLevel.Info, $"Emulator '{serial}' is fully booted."); + logger.Invoke (TraceLevel.Info, $"Emulator '{serial}' is fully booted."); return new EmulatorBootResult { Success = true, Serial = serial }; } } From 2bf8d679e9570989841a8b24e1b0291445053b79 Mon Sep 17 00:00:00 2001 From: Rui Marinho Date: Wed, 18 Mar 2026 09:09:38 +0000 Subject: [PATCH 14/16] Add EmulatorBootErrorKind enum to EmulatorBootResult Add structured error classification enum (None, LaunchFailed, Timeout, Cancelled, Unknown) so consumers can switch on ErrorKind instead of parsing ErrorMessage strings. Set ErrorKind on all BootEmulatorAsync return paths. Addresses review feedback from dotnet/android#10949. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Models/EmulatorBootResult.cs | 27 +++++++++++++++++++ .../PublicAPI/net10.0/PublicAPI.Unshipped.txt | 8 ++++++ .../netstandard2.0/PublicAPI.Unshipped.txt | 8 ++++++ .../Runners/EmulatorRunner.cs | 9 ++++--- 4 files changed, 49 insertions(+), 3 deletions(-) diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Models/EmulatorBootResult.cs b/src/Xamarin.Android.Tools.AndroidSdk/Models/EmulatorBootResult.cs index 4316f8b6..e73cb939 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/Models/EmulatorBootResult.cs +++ b/src/Xamarin.Android.Tools.AndroidSdk/Models/EmulatorBootResult.cs @@ -3,6 +3,27 @@ namespace Xamarin.Android.Tools; +/// +/// Classifies the reason an emulator boot operation failed. +/// +public enum EmulatorBootErrorKind +{ + /// No error — the boot succeeded. + None, + + /// The emulator process could not be launched (e.g., binary not found, AVD missing). + LaunchFailed, + + /// The emulator launched but did not finish booting within the allowed timeout. + Timeout, + + /// The boot was cancelled via . + Cancelled, + + /// An unexpected error occurred. + Unknown, +} + /// /// Result of an emulator boot operation. /// @@ -11,4 +32,10 @@ public record EmulatorBootResult public bool Success { get; init; } public string? Serial { get; init; } public string? ErrorMessage { get; init; } + + /// + /// Structured error classification. Consumers should switch on this value + /// instead of parsing strings. + /// + public EmulatorBootErrorKind ErrorKind { get; init; } } diff --git a/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/net10.0/PublicAPI.Unshipped.txt b/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/net10.0/PublicAPI.Unshipped.txt index ed4d92d0..9d51f08c 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/net10.0/PublicAPI.Unshipped.txt +++ b/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/net10.0/PublicAPI.Unshipped.txt @@ -156,7 +156,15 @@ Xamarin.Android.Tools.EmulatorBootOptions.ColdBoot.get -> bool Xamarin.Android.Tools.EmulatorBootOptions.ColdBoot.init -> void Xamarin.Android.Tools.EmulatorBootOptions.PollInterval.get -> System.TimeSpan Xamarin.Android.Tools.EmulatorBootOptions.PollInterval.init -> void +Xamarin.Android.Tools.EmulatorBootErrorKind +Xamarin.Android.Tools.EmulatorBootErrorKind.None = 0 -> Xamarin.Android.Tools.EmulatorBootErrorKind +Xamarin.Android.Tools.EmulatorBootErrorKind.LaunchFailed = 1 -> Xamarin.Android.Tools.EmulatorBootErrorKind +Xamarin.Android.Tools.EmulatorBootErrorKind.Timeout = 2 -> Xamarin.Android.Tools.EmulatorBootErrorKind +Xamarin.Android.Tools.EmulatorBootErrorKind.Cancelled = 3 -> Xamarin.Android.Tools.EmulatorBootErrorKind +Xamarin.Android.Tools.EmulatorBootErrorKind.Unknown = 4 -> Xamarin.Android.Tools.EmulatorBootErrorKind Xamarin.Android.Tools.EmulatorBootResult +Xamarin.Android.Tools.EmulatorBootResult.ErrorKind.get -> Xamarin.Android.Tools.EmulatorBootErrorKind +Xamarin.Android.Tools.EmulatorBootResult.ErrorKind.init -> void Xamarin.Android.Tools.EmulatorBootResult.ErrorMessage.get -> string? Xamarin.Android.Tools.EmulatorBootResult.ErrorMessage.init -> void Xamarin.Android.Tools.EmulatorBootResult.Serial.get -> string? diff --git a/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt b/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt index ed4d92d0..9d51f08c 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt +++ b/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt @@ -156,7 +156,15 @@ Xamarin.Android.Tools.EmulatorBootOptions.ColdBoot.get -> bool Xamarin.Android.Tools.EmulatorBootOptions.ColdBoot.init -> void Xamarin.Android.Tools.EmulatorBootOptions.PollInterval.get -> System.TimeSpan Xamarin.Android.Tools.EmulatorBootOptions.PollInterval.init -> void +Xamarin.Android.Tools.EmulatorBootErrorKind +Xamarin.Android.Tools.EmulatorBootErrorKind.None = 0 -> Xamarin.Android.Tools.EmulatorBootErrorKind +Xamarin.Android.Tools.EmulatorBootErrorKind.LaunchFailed = 1 -> Xamarin.Android.Tools.EmulatorBootErrorKind +Xamarin.Android.Tools.EmulatorBootErrorKind.Timeout = 2 -> Xamarin.Android.Tools.EmulatorBootErrorKind +Xamarin.Android.Tools.EmulatorBootErrorKind.Cancelled = 3 -> Xamarin.Android.Tools.EmulatorBootErrorKind +Xamarin.Android.Tools.EmulatorBootErrorKind.Unknown = 4 -> Xamarin.Android.Tools.EmulatorBootErrorKind Xamarin.Android.Tools.EmulatorBootResult +Xamarin.Android.Tools.EmulatorBootResult.ErrorKind.get -> Xamarin.Android.Tools.EmulatorBootErrorKind +Xamarin.Android.Tools.EmulatorBootResult.ErrorKind.init -> void Xamarin.Android.Tools.EmulatorBootResult.ErrorMessage.get -> string? Xamarin.Android.Tools.EmulatorBootResult.ErrorMessage.init -> void Xamarin.Android.Tools.EmulatorBootResult.Serial.get -> string? diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs b/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs index 976dac2f..3e0a63e8 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs +++ b/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs @@ -168,7 +168,7 @@ public async Task BootEmulatorAsync ( if (onlineDevice != null) { logger.Invoke (TraceLevel.Info, $"Device '{deviceOrAvdName}' is already online."); - return new EmulatorBootResult { Success = true, Serial = onlineDevice.Serial }; + return new EmulatorBootResult { Success = true, Serial = onlineDevice.Serial, ErrorKind = EmulatorBootErrorKind.None }; } // Single timeout CTS for the entire boot operation (covers Phase 2 and Phase 3). @@ -184,6 +184,7 @@ public async Task BootEmulatorAsync ( } catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { return new EmulatorBootResult { Success = false, + ErrorKind = EmulatorBootErrorKind.Timeout, ErrorMessage = $"Timed out waiting for emulator '{deviceOrAvdName}' to boot within {options.BootTimeout.TotalSeconds}s.", }; } @@ -197,6 +198,7 @@ public async Task BootEmulatorAsync ( } catch (Exception ex) { return new EmulatorBootResult { Success = false, + ErrorKind = EmulatorBootErrorKind.LaunchFailed, ErrorMessage = $"Failed to launch emulator: {ex.Message}", }; } @@ -225,6 +227,7 @@ public async Task BootEmulatorAsync ( TryKillProcess (emulatorProcess); return new EmulatorBootResult { Success = false, + ErrorKind = EmulatorBootErrorKind.Timeout, ErrorMessage = $"Timed out waiting for emulator '{deviceOrAvdName}' to boot within {options.BootTimeout.TotalSeconds}s.", }; } catch { @@ -274,7 +277,7 @@ async Task WaitForFullBootAsync ( var pmResult = await adbRunner.RunShellCommandAsync (serial, "pm path android", cancellationToken).ConfigureAwait (false); if (pmResult != null && pmResult.StartsWith ("package:", StringComparison.Ordinal)) { logger.Invoke (TraceLevel.Info, $"Emulator '{serial}' is fully booted."); - return new EmulatorBootResult { Success = true, Serial = serial }; + return new EmulatorBootResult { Success = true, Serial = serial, ErrorKind = EmulatorBootErrorKind.None }; } } @@ -282,7 +285,7 @@ async Task WaitForFullBootAsync ( } cancellationToken.ThrowIfCancellationRequested (); - return new EmulatorBootResult { Success = false, ErrorMessage = "Boot cancelled." }; + return new EmulatorBootResult { Success = false, ErrorKind = EmulatorBootErrorKind.Cancelled, ErrorMessage = "Boot cancelled." }; } } From e46f7de224a490aab09a6c870769dc70e38e13c8 Mon Sep 17 00:00:00 2001 From: Rui Marinho Date: Wed, 18 Mar 2026 10:32:02 +0000 Subject: [PATCH 15/16] Consolidate NullLogger and remove null-forgiving operator - Extract shared NullLogger to RunnerDefaults utility class - Remove duplicate NullLogger from AdbRunner, EmulatorRunner, AvdManagerRunner Addresses review feedback from @jonathanpeppers on PR #284. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Runners/AdbRunner.cs | 12 +++++------- .../Runners/AvdManagerRunner.cs | 4 +--- .../Runners/EmulatorRunner.cs | 4 +--- .../Runners/RunnerDefaults.cs | 19 +++++++++++++++++++ 4 files changed, 26 insertions(+), 13 deletions(-) create mode 100644 src/Xamarin.Android.Tools.AndroidSdk/Runners/RunnerDefaults.cs diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs index 21a320c9..8d998e50 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs +++ b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs @@ -23,8 +23,6 @@ public class AdbRunner readonly IDictionary? environmentVariables; readonly Action logger; - static readonly Action NullLogger = static (_, _) => { }; - // Pattern to match device lines: [key:value ...] // Uses \s+ to match one or more whitespace characters (spaces or tabs) between fields. // Explicit state list prevents false positives from non-device lines. @@ -45,7 +43,7 @@ public AdbRunner (string adbPath, IDictionary? environmentVariab throw new ArgumentException ("Path to adb must not be empty.", nameof (adbPath)); this.adbPath = adbPath; this.environmentVariables = environmentVariables; - this.logger = logger ?? NullLogger; + this.logger = logger ?? RunnerDefaults.NullLogger; } /// @@ -106,8 +104,8 @@ public virtual async Task> ListDevicesAsync (Cancel // Try 2: Shell property (works when emu console requires auth, e.g. emulator 36+) try { var avdName = await GetShellPropertyAsync (serial, "ro.boot.qemu.avd_name", cancellationToken).ConfigureAwait (false); - if (!string.IsNullOrWhiteSpace (avdName)) - return avdName!.Trim (); + if (avdName is { Length: > 0 } name && !string.IsNullOrWhiteSpace (name)) + return name.Trim (); } catch (OperationCanceledException) { throw; } catch { @@ -325,7 +323,7 @@ public static IReadOnlyList ParseAdbDevicesOutput (IEnumerable public static string BuildDeviceDescription (AdbDeviceInfo device, Action? logger = null) { - logger ??= NullLogger; + logger ??= RunnerDefaults.NullLogger; if (device.Type == AdbDeviceType.Emulator && device.AvdName is { Length: > 0 } avdName) { logger.Invoke (TraceLevel.Verbose, $"Emulator {device.Serial}, original AVD name: {avdName}"); var formatted = FormatDisplayName (avdName); @@ -371,7 +369,7 @@ public static string FormatDisplayName (string avdName) /// public static IReadOnlyList MergeDevicesAndEmulators (IReadOnlyList adbDevices, IReadOnlyList availableEmulators, Action? logger = null) { - logger ??= NullLogger; + logger ??= RunnerDefaults.NullLogger; var result = new List (adbDevices); // Build a set of AVD names that are already running diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Runners/AvdManagerRunner.cs b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AvdManagerRunner.cs index ede9f60e..b233ae38 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/Runners/AvdManagerRunner.cs +++ b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AvdManagerRunner.cs @@ -20,8 +20,6 @@ public class AvdManagerRunner readonly IDictionary? environmentVariables; readonly Action logger; - static readonly Action NullLogger = static (_, _) => { }; - /// /// Creates a new AvdManagerRunner with the full path to the avdmanager executable. /// @@ -34,7 +32,7 @@ public AvdManagerRunner (string avdManagerPath, IDictionary? env throw new ArgumentException ("Path to avdmanager must not be empty.", nameof (avdManagerPath)); this.avdManagerPath = avdManagerPath; this.environmentVariables = environmentVariables; - this.logger = logger ?? NullLogger; + this.logger = logger ?? RunnerDefaults.NullLogger; } public async Task> ListAvdsAsync (CancellationToken cancellationToken = default) diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs b/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs index 3e0a63e8..d2ac7f58 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs +++ b/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs @@ -16,8 +16,6 @@ namespace Xamarin.Android.Tools; /// public class EmulatorRunner { - static readonly Action NullLogger = static (_, _) => { }; - readonly string emulatorPath; readonly IDictionary? environmentVariables; readonly Action logger; @@ -34,7 +32,7 @@ public EmulatorRunner (string emulatorPath, IDictionary? environ throw new ArgumentException ("Path to emulator must not be empty.", nameof (emulatorPath)); this.emulatorPath = emulatorPath; this.environmentVariables = environmentVariables; - this.logger = logger ?? NullLogger; + this.logger = logger ?? RunnerDefaults.NullLogger; } /// diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Runners/RunnerDefaults.cs b/src/Xamarin.Android.Tools.AndroidSdk/Runners/RunnerDefaults.cs new file mode 100644 index 00000000..e71b2df1 --- /dev/null +++ b/src/Xamarin.Android.Tools.AndroidSdk/Runners/RunnerDefaults.cs @@ -0,0 +1,19 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Diagnostics; + +namespace Xamarin.Android.Tools; + +/// +/// Shared defaults for Android SDK tool runners. +/// +static class RunnerDefaults +{ + /// + /// A no-op logger that discards all messages. Used as the default + /// when callers don't provide a logger callback. + /// + internal static readonly Action NullLogger = static (_, _) => { }; +} From 92d5e25f3e6826658c255e222d8ff83c0f34373a Mon Sep 17 00:00:00 2001 From: Rui Marinho Date: Wed, 18 Mar 2026 11:09:54 +0000 Subject: [PATCH 16/16] Fix inaccurate comment about emulator console auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The comment incorrectly claimed the getprop fallback was needed because 'emulator 36+ requires auth for console commands'. After reviewing the actual adb source code (console.cpp), adb emu handles console auth automatically — it reads ~/.emulator_console_auth_token and sends it before any command. This has been the case since ~2016. The real reason for the fallback is that 'adb emu avd name' can return empty output on some adb/emulator version combinations (observed with adb v36). Updated both the XML doc and inline comment to accurately describe the issue. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs index 8d998e50..0dd60476 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs +++ b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs @@ -76,9 +76,9 @@ public virtual async Task> ListDevicesAsync (Cancel /// Queries the emulator for its AVD name. /// Tries adb -s <serial> emu avd name first (emulator console protocol), /// then falls back to adb shell getprop ro.boot.qemu.avd_name which reads the - /// boot property set by the emulator kernel. The fallback is needed because newer - /// emulator versions (36+) may require authentication for console commands, causing - /// emu avd name to return empty output. + /// boot property set by the emulator kernel. The fallback is needed because + /// emu avd name can return empty output on some adb/emulator version + /// combinations (observed with adb v36). /// internal async Task GetEmulatorAvdNameAsync (string serial, CancellationToken cancellationToken = default) { @@ -101,7 +101,7 @@ public virtual async Task> ListDevicesAsync (Cancel // Fall through to getprop fallback } - // Try 2: Shell property (works when emu console requires auth, e.g. emulator 36+) + // Try 2: Shell property (fallback when 'adb emu avd name' returns empty on some adb/emulator versions) try { var avdName = await GetShellPropertyAsync (serial, "ro.boot.qemu.avd_name", cancellationToken).ConfigureAwait (false); if (avdName is { Length: > 0 } name && !string.IsNullOrWhiteSpace (name))