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.
src/Xamarin.Android.Tools.AndroidSdk/Models/EmulatorBootOptions.cs
Outdated
Show resolved
Hide resolved
src/Xamarin.Android.Tools.AndroidSdk/Runners/AvdManagerRunner.cs
Outdated
Show resolved
Hide resolved
src/Xamarin.Android.Tools.AndroidSdk/Runners/AvdManagerRunner.cs
Outdated
Show resolved
Hide resolved
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`.
…structured RunShellCommandAsync overload - 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>
Replace the 454-line BootAndroidEmulator implementation with a thin ~180-line wrapper that delegates to EmulatorRunner.BootEmulatorAsync() from Xamarin.Android.Tools.AndroidSdk. Key changes: - Remove all process management, polling, and boot detection logic - Delegate to EmulatorRunner.BootEmulatorAsync() for the full 3-phase boot: check online → check AVD running → launch + poll + wait - Map EmulatorBootResult errors to existing XA0143/XA0145 error codes - Virtual ExecuteBoot() method for clean test mocking - Update submodule to feature/emulator-runner (d8ee2d5) Tests updated from 9 to 10 (added ExtraArguments and UnknownError tests) using simplified mock pattern — MockBootAndroidEmulator overrides ExecuteBoot() to return canned EmulatorBootResult values. Depends on: dotnet/android-tools#284 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
@jonathanpeppers Here's the dotnet/android consumer PR you requested: dotnet/android#10948 It replaces the 454-line The PR is in draft since it depends on this PR (#284) merging first — the submodule currently points to |
|
Note: the consumer PR was recreated after a branch rename — the correct link is now dotnet/android#10949 (the previous #10948 was auto-closed). |
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>
src/Xamarin.Android.Tools.AndroidSdk/Models/EmulatorBootOptions.cs
Outdated
Show resolved
Hide resolved
src/Xamarin.Android.Tools.AndroidSdk/Models/EmulatorBootOptions.cs
Outdated
Show resolved
Hide resolved
src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/net10.0/PublicAPI.Unshipped.txt
Outdated
Show resolved
Hide resolved
src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt
Outdated
Show resolved
Hide resolved
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>
🔬 Definitive Proof:
|
| Method | Result |
|---|---|
adb -s emulator-5554 emu avd name |
EMPTY (exit code 0, no output) |
adb shell getprop ro.boot.qemu.avd_name |
MAUI_Emulator_API_36 ✅ |
echo "avd name" | nc localhost 5554 (raw telnet) |
MAUI_Emulator_API_36 ✅ |
| Console port 5554 | OPEN (nc -z succeeds) |
Analysis
- The console port IS accessible — raw telnet to 5554 returns the AVD name correctly
adb emureturns empty —adbuses a different protocol path than raw telnet, and something changed in emulator v36 that breaks it- The emulator warns:
The emulator now requires a signed jwt token for gRPC access!— while gRPC (port 8554) differs from telnet console (port 5554), this may affect howadbauthenticates to the console
Impact on dotnet/android
The original BootAndroidEmulator.GetRunningAvdName() on main uses the exact same command:
MonoAndroidHelper.RunProcess(adbPath, $"-s {serial} emu avd name", ...);This means FindRunningEmulatorForAvd would fail to match the AVD → WaitForEmulatorOnline would poll indefinitely → timeout after 120s. This is exactly the bug @jonathanpeppers reported.
Fix Validation
Our getprop ro.boot.qemu.avd_name fallback in AdbRunner.GetEmulatorAvdNameAsync:
- Completes in 13ms (vs infinite timeout)
BootEmulatorAsyncend-to-end: 2.8 seconds (vs 120s timeout)- All 259 existing tests pass
🔄 Correction: ADB v37 Regression (not emulator v36 issue)After deeper investigation, the root cause is more specific: The Real Issue: ADB v37.0.0 broke
|
📋 Research: ADB v37.0.0
|
| Evidence | Finding |
|---|---|
| Platform-tools 37.0.0 | ✅ Stable, public release on dl.google.com |
| GitHub Actions macOS 15 | ✅ Ships with pt 37.0.0 + emulator 36.4.9 |
| Google Issue Tracker | ✅ #251776353 — known open bug |
| dotnet/android CI | Uses pt 36.0.0 (pinned) — not yet affected |
getprop fallback |
Works on ALL ADB versions — forward-compatible fix |
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>
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>
src/Xamarin.Android.Tools.AndroidSdk/Runners/AvdManagerRunner.cs
Outdated
Show resolved
Hide resolved
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>
- 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>
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>
Add early process exit detection in BootEmulatorAsync boot polling loop. Previously, if the emulator failed immediately (e.g., insufficient disk space, missing AVD), the full 300s timeout was wasted before reporting. On macOS, the emulator binary forks the real QEMU process and the parent exits with code 0 immediately. Only non-zero exit codes are treated as immediate failures; exit code 0 continues polling since the real emulator runs as a separate process. Context: dotnet/android#10965 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
jonathanpeppers
left a comment
There was a problem hiding this comment.
The build failures is an issue rerunning -- attempt number need to be on artifacts (I'm fixing separately).
Merging shortly.
Replace the 454-line BootAndroidEmulator implementation with a thin ~180-line wrapper that delegates to EmulatorRunner.BootEmulatorAsync() from Xamarin.Android.Tools.AndroidSdk. Key changes: - Remove all process management, polling, and boot detection logic - Delegate to EmulatorRunner.BootEmulatorAsync() for the full 3-phase boot: check online → check AVD running → launch + poll + wait - Map EmulatorBootResult errors to existing XA0143/XA0145 error codes - Virtual ExecuteBoot() method for clean test mocking - Update submodule to feature/emulator-runner (d8ee2d5) Tests updated from 9 to 10 (added ExtraArguments and UnknownError tests) using simplified mock pattern — MockBootAndroidEmulator overrides ExecuteBoot() to return canned EmulatorBootResult values. Depends on: dotnet/android-tools#284 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Replace the 454-line BootAndroidEmulator implementation with a thin ~180-line wrapper that delegates to EmulatorRunner.BootEmulatorAsync() from Xamarin.Android.Tools.AndroidSdk. Key changes: - Remove all process management, polling, and boot detection logic - Delegate to EmulatorRunner.BootEmulatorAsync() for the full 3-phase boot: check online → check AVD running → launch + poll + wait - Map EmulatorBootResult errors to existing XA0143/XA0145 error codes - Virtual ExecuteBoot() method for clean test mocking - Update submodule to feature/emulator-runner (d8ee2d5) Tests updated from 9 to 10 (added ExtraArguments and UnknownError tests) using simplified mock pattern — MockBootAndroidEmulator overrides ExecuteBoot() to return canned EmulatorBootResult values. Depends on: dotnet/android-tools#284 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Replace the 454-line BootAndroidEmulator implementation with a thin ~180-line wrapper that delegates to EmulatorRunner.BootEmulatorAsync() from Xamarin.Android.Tools.AndroidSdk. Key changes: - Remove all process management, polling, and boot detection logic - Delegate to EmulatorRunner.BootEmulatorAsync() for the full 3-phase boot: check online → check AVD running → launch + poll + wait - Map EmulatorBootResult errors to existing XA0143/XA0145 error codes - Virtual ExecuteBoot() method for clean test mocking - Update submodule to feature/emulator-runner (d8ee2d5) Tests updated from 9 to 10 (added ExtraArguments and UnknownError tests) using simplified mock pattern — MockBootAndroidEmulator overrides ExecuteBoot() to return canned EmulatorBootResult values. Depends on: dotnet/android-tools#284 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Replace the 454-line BootAndroidEmulator implementation with a thin ~180-line wrapper that delegates to EmulatorRunner.BootEmulatorAsync() from Xamarin.Android.Tools.AndroidSdk. Key changes: - Remove all process management, polling, and boot detection logic - Delegate to EmulatorRunner.BootEmulatorAsync() for the full 3-phase boot: check online → check AVD running → launch + poll + wait - Map EmulatorBootResult errors to existing XA0143/XA0145 error codes - Virtual ExecuteBoot() method for clean test mocking - Update submodule to feature/emulator-runner (d8ee2d5) Tests updated from 9 to 10 (added ExtraArguments and UnknownError tests) using simplified mock pattern — MockBootAndroidEmulator overrides ExecuteBoot() to return canned EmulatorBootResult values. Depends on: dotnet/android-tools#284 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

EmulatorRunner: High-level emulator lifecycle management
Adds
EmulatorRunner— a managed wrapper over the Android SDKemulatorCLI binary, following the same pattern asAdbRunnerandAvdManagerRunner.API Surface
LaunchEmulator(avdName, options?)Processhandle. Caller owns the process lifetime. ValidatesavdNameis non-empty.BootEmulatorAsync(avdName, adb, options?, token?)adb devicesuntil boot completes or timeout. ReturnsEmulatorBootResultwith status and serial. Disposes Process handle on success (emulator keeps running).ListAvdNamesAsync(token?)emulator -list-avds. Checks exit code for failures.Key Design Decisions
LaunchEmulator(fire-and-forget) vsBootEmulatorAsync(full lifecycle) — clear verb distinction matching the emulator domainEmulatorRunnername (notAvdRunner) — follows convention of naming runners after their CLI binary (emulator→EmulatorRunner,adb→AdbRunner)LaunchEmulatorreturnsProcess(caller-owned);BootEmulatorAsyncdisposes handle on success (emulator keeps running as detached process), kills+disposes on failure/timeoutLaunchEmulatorcallsBeginOutputReadLine()/BeginErrorReadLine()afterStart()to prevent OS pipe buffer deadlockTryKillProcess: Instance method, uses typedcatch (Exception ex)with logger for diagnostics, usesKill(entireProcessTree: true)on .NET 5+AdbRunner Enhancements (in this PR)
Action? loggerparameter to constructorRunShellCommandAsync(serial, command, ct)— single-string shell command (RunShellCommandAsync(serial, command, args, ct)— NEW: structured overload that passes args as separate tokens, bypassing device shell interpretation viaexec(). Safer for dynamic input.GetShellPropertyAsyncreturns first non-empty line (forgetpropqueries)CancellationTokenGetEmulatorAvdNameAsyncnow falls back toadb shell getprop ro.boot.qemu.avd_namewhenadb emu avd namereturns empty (observed returning empty on some adb/emulator v36 combinations)Models
EmulatorBootOptions— configurable timeout (default 120s), poll interval (default 2s), cold boot, extra args (IEnumerable?)EmulatorBootResult— immutablerecordwithinit-only properties:Status(enum),Serial,Message. Statuses:Success,AlreadyRunning,Timeout,ErrorBug Fix: AVD Name Detection on Emulator v36+
The
adb emu avd nameconsole command can return empty output on some adb/emulator version combinations (observed with adb v36). This causedBootEmulatorAsyncto never match the running emulator by AVD name, resulting in a perpetual polling loop and eventual timeout.Root cause:
GetEmulatorAvdNameAsyncrelied solely onadb -s <serial> emu avd name. On some adb/emulator version combinations this command silently returns empty output (exit code 0, no content). The exact cause is unclear but thegetpropfallback provides reliable AVD name resolution regardless.Fix: Added 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 via the standard adb shell interface and does not depend on the emulator console protocol.Verified:
BootEmulatorAsyncnow completes in ~3s (was timing out at 120s) on emulator v36.4.9 with API 36 image.Consumer PR
BootAndroidEmulatorMSBuild task (~454 lines) with a ~180-line wrapper delegating toEmulatorRunner.BootEmulatorAsync()Tests (24 EmulatorRunner + 9 AdbRunner = 33 total)
EmulatorRunner (24):
emulator -list-avdsoutput (empty, single, multiple, blank lines, Windows newlines) — 4 testsLaunchEmulatorargument validation (null, empty, whitespace AVD name) — 3 testsBootEmulatorAsynclifecycle: already online device, already running AVD, successful boot after polling, timeout, launch failure, cancellation token — 6 testsBootEmulatorAsyncvalidation: invalid timeout, invalid poll interval, null AdbRunner, empty device name — 4 testsBootAndroidEmulatorTests: physical device passthrough, AdditionalArgs forwarding, ColdBoot flag, cancellation abort — 4 testsAdbRunner (9):
FirstNonEmptyLineparsing (null, empty, whitespace, single value, multiline, mixed) — 9 testsReview Feedback Addressed
LaunchEmulatorvalidatesavdNameparameter (throwsArgumentException)LaunchEmulatordrains stdout/stderr pipes viaBeginOutputReadLine()/BeginErrorReadLine()RunShellCommandAsyncreturns full stdout (not just first line)RunShellCommandAsyncoverload (no shell interpretation)HasExitedguard fromTryKillProcessListAvdNamesAsyncchecks exit codeTryKillProcessuses typedcatch (Exception ex)with loggingRunShellCommandAsyncXML doc warns about shell interpretationEmulatorBootResultusesinit-only properties (immutable record)BootAndroidEmulatorTests