Add EmulatorRunner for emulator CLI operations#284
Conversation
There was a problem hiding this comment.
Pull request overview
This PR adds a new EmulatorRunner to Xamarin.Android.Tools.AndroidSdk intended to wrap Android emulator CLI operations, alongside new shared infrastructure for running Android SDK command-line tools with environment setup and result modeling.
Changes:
- Added
EmulatorRunnerto start an AVD, stop an emulator, and list available AVD names. - Added
AndroidToolRunnerutility to run SDK tools sync/async (with timeouts) and to start long-running background processes. - Added
AndroidEnvironmentHelperandToolRunnerResult/ToolRunnerResult<T>to standardize tool environment and execution results.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 8 comments.
| File | Description |
|---|---|
| src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs | Introduces emulator wrapper methods (start/stop/list AVDs) built on the tool runner infrastructure. |
| src/Xamarin.Android.Tools.AndroidSdk/Runners/AndroidToolRunner.cs | Adds process execution helpers (sync/async + background) with timeout/output capture. |
| src/Xamarin.Android.Tools.AndroidSdk/Runners/AndroidEnvironmentHelper.cs | Adds env var setup and mapping helpers (ABI/API/tag display names). |
| src/Xamarin.Android.Tools.AndroidSdk/Models/ToolRunnerResult.cs | Adds a shared result model for tool execution. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
src/Xamarin.Android.Tools.AndroidSdk/Runners/AndroidEnvironmentHelper.cs
Outdated
Show resolved
Hide resolved
src/Xamarin.Android.Tools.AndroidSdk/Runners/AndroidEnvironmentHelper.cs
Outdated
Show resolved
Hide resolved
src/Xamarin.Android.Tools.AndroidSdk/Runners/AndroidToolRunner.cs
Outdated
Show resolved
Hide resolved
src/Xamarin.Android.Tools.AndroidSdk/Runners/AndroidToolRunner.cs
Outdated
Show resolved
Hide resolved
src/Xamarin.Android.Tools.AndroidSdk/Runners/AndroidToolRunner.cs
Outdated
Show resolved
Hide resolved
|
I'd like to get the System.Diagnostics.Process code unified like mentioned here: |
Addresses PR #284 feedback to use existing ProcessUtils instead of the removed AndroidToolRunner. Simplifies API: - Methods now throw InvalidOperationException on failure - Uses ProcessUtils.RunToolAsync() and StartToolBackground() - Removed complex ToolRunnerResult wrapper types Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
f1aa44f to
826d4aa
Compare
Addresses PR #283/#284 feedback to use existing ProcessUtils. Simplifies API by throwing exceptions on failure instead of returning result types with error states. Changes: - AdbRunner: Simplified using ProcessUtils.RunToolAsync() - EmulatorRunner: Uses ProcessUtils.StartToolBackground() - Removed duplicate AndroidDeviceInfo from Models directory Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
39617c8 to
5268300
Compare
1b10889 to
ee31e4b
Compare
ee31e4b to
3a788bb
Compare
Review feedback addressed — commit references
New files:
Modified:
Draft dotnet/android consumer PR to follow. |
jonathanpeppers
left a comment
There was a problem hiding this comment.
🤖 AI Review Summary
Found 7 issues: 1 correctness, 2 error handling, 1 API design, 1 code duplication, 1 code organization, 1 naming.
- Correctness:
StartAvdredirects stdout/stderr but never drains the pipes — OS buffer fill will deadlock the emulator process (EmulatorRunner.cs:74) - API design:
AdditionalArgsis a singlestring— will be treated as one argument byProcessUtils.ArgumentList, breaking multi-token args like-gpu swiftshader_indirect(EmulatorBootOptions.cs:14) - Error handling:
ListDevicesAsyncignores the exit code fromProcessUtils.StartProcesswhile sibling methods inAvdManagerRunnercheck it consistently (AdbRunner.cs:72) - Code duplication:
AvdManagerRunner.AvdManagerPathreimplements the cmdline-tools version scanning thatProcessUtils.FindCmdlineTool(added in this same PR) already provides (AvdManagerRunner.cs:33) - Error handling: Bare
catch { }swallows all exceptions without capturing them (AdbRunner.cs:107)
👍 Solid three-phase boot logic ported faithfully from dotnet/android. Good use of virtual on AdbRunner methods to enable clean test mocking. Thorough test coverage with 13+ unit tests covering parsing, edge cases, and the full boot flow. Nice extraction of AndroidEnvironmentHelper for shared env var setup.
This review was generated by the android-tools-reviewer skill based on review guidelines established by @jonathanpeppers.
| // 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; |
There was a problem hiding this comment.
🤖 Pattern / Correctness — RedirectStandardOutput and RedirectStandardError are set to true but the output is never drained. When the emulator writes enough output to fill the OS pipe buffer (~4–64 KB depending on platform), the write will block and the emulator process will deadlock.
Since StartAvd is a fire-and-forget launch that returns the Process to the caller, the simplest fix is to call BeginOutputReadLine() and BeginErrorReadLine() immediately after Start() to asynchronously discard the output:
process.Start ();
process.BeginOutputReadLine ();
process.BeginErrorReadLine ();Alternatively, if you don't need to redirect at all (just prevent pipe inheritance), you could set Redirect* = false and rely on UseShellExecute = false (already set by ProcessUtils.CreateProcessStartInfo) which doesn't inherit the caller's console.
Also: using new Process { StartInfo = psi } + process.Start() directly bypasses ProcessUtils. This is an intentional pattern for long-running background processes, but worth a brief comment explaining why ProcessUtils.StartProcess isn't used here (it awaits completion).
Rule: Don't redirect stdout/stderr without draining (Postmortem #48); Use ProcessUtils (Postmortem #15)
| public class EmulatorBootOptions | ||
| { | ||
| public TimeSpan BootTimeout { get; set; } = TimeSpan.FromSeconds (300); | ||
| public string? AdditionalArgs { get; set; } |
There was a problem hiding this comment.
🤖 API design — AdditionalArgs is a single string? that gets passed directly to args.Add(additionalArgs) in StartAvd. Since ProcessUtils.CreateProcessStartInfo uses ArgumentList on net5+ (no shell parsing), a string like ""-gpu swiftshader_indirect"" becomes one argument instead of two separate ones.
Consider changing this to IEnumerable<string>? so each argument is passed separately:
public IEnumerable<string>? AdditionalArgs { get; set; }And in StartAvd:
if (options.AdditionalArgs != null)
args.AddRange (options.AdditionalArgs);This is safer and consistent with how ProcessUtils is designed to work.
Rule: Structured args, not string interpolation (Postmortem #50)
| var adb = RequireAdb (); | ||
| using var stdout = new StringWriter (); | ||
| var psi = CreateAdbProcess (adb, "devices", "-l"); | ||
| await ProcessUtils.StartProcess (psi, stdout, null, cancellationToken).ConfigureAwait (false); |
There was a problem hiding this comment.
🤖 Error handling — ListDevicesAsync ignores the exit code returned by ProcessUtils.StartProcess. Compare with AvdManagerRunner.ListAvdsAsync which captures and checks it:
var exitCode = await ProcessUtils.StartProcess (psi, stdout, stderr, cancellationToken)...;
if (exitCode != 0)
throw new InvalidOperationException (...);The same pattern should be applied here (and in GetShellPropertyAsync, RunShellCommandAsync, WaitForDeviceAsync, StopEmulatorAsync). Inconsistent exit-code checking creates a false sense of safety — if ListAvdsAsync checks, callers will assume ListDevicesAsync does too.
Rule: Check exit codes consistently (Postmortem #48)
| this.getJdkPath = getJdkPath; | ||
| } | ||
|
|
||
| public string? AvdManagerPath { |
There was a problem hiding this comment.
🤖 Code duplication — This property reimplements the versioned cmdline-tools scanning (list dirs → parse versions → sort descending → try each → fallback to ""latest"" → fallback to legacy tools/bin) that ProcessUtils.FindCmdlineTool already provides. Both were added in this PR. Consider delegating:
public string? AvdManagerPath {
get {
var sdkPath = getSdkPath ();
if (string.IsNullOrEmpty (sdkPath))
return null;
var ext = OS.IsWindows ? "".bat"" : """";
return ProcessUtils.FindCmdlineTool (sdkPath, ""avdmanager"", ext);
}
}This removes ~20 lines and ensures both paths stay consistent if the search logic changes.
Rule: Reinventing the wheel / code duplication (Postmortem #40)
| /// <summary> | ||
| /// Represents the type of an Android device. | ||
| /// </summary> | ||
| public enum AdbDeviceType |
There was a problem hiding this comment.
🤖 Code organization — This file contains three public types: AdbDeviceType (enum), AdbDeviceStatus (enum), and AdbDeviceInfo (class). Per the one-type-per-file convention, each should be in its own file: AdbDeviceType.cs, AdbDeviceStatus.cs, and AdbDeviceInfo.cs.
Rule: One type per file (Postmortem #9)
| static string GetAvdRootDirectory () | ||
| { | ||
| // ANDROID_AVD_HOME takes highest priority | ||
| var avdHome = Environment.GetEnvironmentVariable ("ANDROID_AVD_HOME"); |
There was a problem hiding this comment.
🤖 Naming — Raw string ""ANDROID_AVD_HOME"" should use a constant from EnvironmentVariableNames (same as AndroidUserHome added in this PR). This prevents silent failures from typos and keeps all environment variable names centralized.
Consider adding public const string AndroidAvdHome = ""ANDROID_AVD_HOME""; to EnvironmentVariableNames and using it here.
Rule: Environment variable constants (Postmortem #19)
| } | ||
| } catch (OperationCanceledException) { | ||
| throw; | ||
| } catch { |
There was a problem hiding this comment.
🤖 Error handling — Bare catch { } swallows all exceptions without capturing the Exception. Even though this failure is intentional (emulator may not support the command), capturing the exception enables diagnostic logging when issues surface:
} catch (Exception) {
// Expected: emulator may not support 'emu avd name' command
}At minimum, use catch (Exception) instead of bare catch so it's clear the intent is to catch CLR exceptions (not SEH exceptions on older runtimes).
Rule: No empty/bare catch blocks (Postmortem #11)
This skill let's you say:
review this PR: #284
Some example code reviews:
* #283 (review)
* #284 (review)
This is built off a combination of previous code reviews, saved in
`docs/CODE_REVIEW_POSTMORTEM.md`, and the review rules in
`references/review-rules.md`.
Port the adb device parsing, display name formatting, status mapping, and emulator merging logic from dotnet/android's GetAvailableAndroidDevices MSBuild task into the shared android-tools library. Changes: - AdbDeviceInfo: Add Type, Status, Description, AvdName, Product, TransportId fields using AdbDeviceType/AdbDeviceStatus enums - AdbRunner.ParseAdbDevicesOutput: Regex-based parser matching dotnet/android (handles device/offline/unauthorized/no permissions states) - AdbRunner.BuildDeviceDescription: Priority order AVD name > model > product > device > serial, with underscore-to-space cleanup - AdbRunner.FormatDisplayName: Title case + API capitalization for AVD names - AdbRunner.MapAdbStateToStatus: Maps adb states to AdbDeviceStatus enum - AdbRunner.MergeDevicesAndEmulators: Merges running + non-running emulators, deduplicates by AVD name (case-insensitive), sorts online first - AdbRunner.GetEmulatorAvdNameAsync: Queries AVD name via adb emu avd name - AdbRunnerTests: 33 tests ported from dotnet/android test cases Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The bare catch block was swallowing cancellation tokens, causing cancelled operations to silently continue spawning adb processes instead of propagating the cancellation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- StartAvd(): Start an emulator for an AVD - ListAvdNamesAsync(): List installed AVD names Minimal implementation without external dependencies. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Add XML documentation to all public members - Use 'is not null' instead of '!= null' - Improve code formatting - Remove unused variable assignment Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Add optional Func<string?> getJdkPath constructor parameter - ConfigureEnvironment() sets JAVA_HOME and ANDROID_HOME on all ProcessStartInfo - Applied to StartAvd and ListAvdNamesAsync - Backward compatible: existing 1-arg constructor still works Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…riter Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… add tests - Replace manual ProcessStartInfo with ProcessUtils.CreateProcessStartInfo - Use AndroidEnvironmentHelper.ConfigureEnvironment instead of duplicated method - Extract ParseListAvdsOutput as internal static for testability - Return IReadOnlyList<string> from ListAvdNamesAsync - Add RequireEmulatorPath helper - Add EmulatorRunnerTests (7 tests): parsing, path discovery, null/missing sdk Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- ListAvdsAsync(): List configured AVDs - DeleteAvdAsync(): Delete an AVD Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Add XML documentation to all public members - Split AvdInfo into its own file (one type per file) - Use 'is not null' instead of '!= null' - Improve code formatting Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Add optional Func<string?> getJdkPath constructor parameter - ConfigureEnvironment() sets JAVA_HOME and ANDROID_HOME on all ProcessStartInfo - Add CreateAvdAsync with duplicate detection, orphaned AVD directory handling, stdin 'no' for hardware profile prompt - Extract ParseAvdListOutput as internal static for testability - Backward compatible: existing 1-arg constructor still works Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Add 'using' to StringWriter instances in ListAvdsAsync and CreateAvdAsync to ensure proper disposal - Add AvdManagerRunnerTests with 5 focused tests for ParseAvdListOutput: multiple AVDs, Windows newlines, empty output, no AVDs, missing device Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Convert AvdManagerRunner.cs and AvdInfo.cs to file-scoped namespaces per repo conventions for new files - Remove accidentally tracked nupkg (already in .gitignore) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ironmentVariableNames, exit code checks - AvdManagerPath: enumerate versioned cmdline-tools dirs descending (mirrors SdkManager.FindSdkManagerPath pattern), then latest, then legacy tools/bin - Use ProcessUtils.CreateProcessStartInfo with separate args instead of building Arguments string manually (all 3 methods) - Use EnvironmentVariableNames.AndroidHome/.JavaHome constants instead of hard-coded strings - Check exit codes in ListAvdsAsync and DeleteAvdAsync, throw on failure - Return IReadOnlyList<AvdInfo> from ListAvdsAsync - Set Path on AvdInfo returned from CreateAvdAsync for consistency - Extract RequireAvdManagerPath helper Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Delegate to AndroidEnvironmentHelper.ConfigureEnvironment instead of inline setup - Add 5 path discovery tests: versioned dir, higher version priority, latest fallback, null/missing sdk Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Fix argument validation: CreateAvdAsync/DeleteAvdAsync now throw ArgumentException for empty strings, ArgumentNullException for null, and validate before RequireAvdManagerPath() - Make AndroidEnvironmentHelper internal, remove unused GetToolEnvironment - Add ANDROID_USER_HOME to ConfigureEnvironment for consistent AVD location across tools (matches SdkManager behavior) - Add AndroidUserHome constant to EnvironmentVariableNames - Fix hard-coded AVD directory: use GetAvdRootDirectory() that respects ANDROID_AVD_HOME and ANDROID_USER_HOME env vars - Re-list after CreateAvdAsync to get actual path from avdmanager - Add 5 new validation tests for Create/DeleteAvdAsync Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…eTool - ProcessUtils.ThrowIfFailed: consistent exit code checking with stderr/stdout context - ProcessUtils.ValidateNotNullOrEmpty: reusable null/empty argument validation - ProcessUtils.FindCmdlineTool: version-aware cmdline-tools directory search with legacy fallback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Add BootAndWaitAsync to EmulatorRunner with multi-phase boot logic - Add GetShellPropertyAsync and RunShellCommandAsync to AdbRunner - Add EmulatorBootResult and EmulatorBootOptions model types - Port unit tests from dotnet/android BootAndroidEmulatorTests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
In netstandard2.0, string.IsNullOrEmpty lacks [NotNullWhen(false)], so the compiler doesn't narrow string? after null checks. Add null- forgiving operators where the preceding guard guarantees non-null. Fixes: CS8601 in AndroidEnvironmentHelper.cs (jdkPath in Path.Combine) Fixes: CS8620 in AdbRunner.cs (serial in string[] array literal) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Adds authorizing, recovery, sideload, bootloader, connecting, host states and IgnoreCase flag to match the adb-runner branch regex. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Redirect stdout/stderr on the emulator process so it doesn't inherit the caller's pipes. Without this, parent processes (e.g. VS Code spawn with stdio:'pipe') never see the 'close' event because the emulator holds the pipes open indefinitely. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
ddfc763 to
5cbcc13
Compare
…litting
- AdbRunner: check exit codes consistently in ListDevicesAsync, WaitForDeviceAsync,
StopEmulatorAsync, GetShellPropertyAsync, RunShellCommandAsync
- AdbRunner: bare catch {} → catch (Exception) in GetEmulatorAvdNameAsync
- AdbDeviceInfo.cs: split 3 public types into separate files (AdbDeviceType.cs, AdbDeviceStatus.cs)
- EnvironmentVariableNames: add AndroidAvdHome constant, use in AvdManagerRunner
- AvdManagerRunner.AvdManagerPath: delegate to ProcessUtils.FindCmdlineTool (remove duplicate logic)
- EmulatorRunner.StartAvd: additionalArgs string? → IEnumerable<string>? for proper arg separation
- EmulatorRunner.StartAvd: set RedirectStandardOutput/Error=false to prevent pipe deadlock
- EmulatorBootOptions.AdditionalArgs: string? → IEnumerable<string>?
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Summary
Wraps
emulatorCLI operations for starting and managing Android emulators. Addresses #278.Changes
EmulatorRunner — Emulator boot and management
New:
BootAndWaitAsync— Ports the boot emulator logic fromdotnet/android'sBootAndroidEmulatorMSBuild task into the shared library.Three-phase boot:
adb devices→ pass throughFull boot =
sys.boot_completed == "1"ANDpm path androidreturnspackage:prefix.New:
EmulatorBootResult— Result type withSuccess,Serial,ErrorMessageNew:
EmulatorBootOptions— Options type withBootTimeout,AdditionalArgs,ColdBoot,PollIntervalAdbRunner — Shell command helpers
New:
GetShellPropertyAsync— Runsadb -s serial shell getprop propertyNew:
RunShellCommandAsync— Runsadb -s serial shell commandThese support the boot wait logic (checking
sys.boot_completedandpm path android).API
Downstream Consumer
dotnet/androidBootAndroidEmulator.cswill delegate toEmulatorRunner.BootAndWaitAsync()via the submodule — similar to howGetAvailableAndroidDevicesdelegates toAdbRunner.ParseAdbDevicesOutput().Draft dotnet/android PR to follow.
Tests
13 unit tests (
EmulatorRunnerTests.cs):ParseListAvdsOutput: multiple AVDs, empty, Windows newlines, blank linesEmulatorPath: SDK discovery, missing SDK, null SDKAlreadyOnlineDevice_PassesThrough: device already in adb devicesAvdAlreadyRunning_WaitsForFullBoot: AVD running, wait for boot completionBootEmulator_AppearsAfterPolling: AVD not running, appears after pollingLaunchFailure_ReturnsError: emulator path missingBootTimeout_BootCompletedNeverReaches1: boot timeoutMultipleEmulators_FindsCorrectAvd: finds correct AVD among multipleTest scenarios ported from
dotnet/androidBootAndroidEmulatorTests.cs.Dependencies
Requires #283 (AdbRunner) — uses
ListDevicesAsync,GetEmulatorAvdNameAsync,GetShellPropertyAsync,RunShellCommandAsync.Co-authored-by: Copilot 223556219+Copilot@users.noreply.github.com