[Xamarin.Android.Build.Tasks] instrumentation-only apps + dotnet run - #12261
Conversation
An app whose only entry point is an `Android.App.Instrumentation` subclass, such as a BenchmarkDotNet host, could not be launched with `dotnet run`. Instrumentation auto-detection was gated behind `$(EnableMSTestRunner)`, and several code paths in `Microsoft.Android.Run` assumed an `<activity/>`. * New `$(AndroidUseInstrumentation)` property, defaulting to true when `$(EnableMSTestRunner)` is true. When set, the app launches through its `<instrumentation/>` even if it also declares a launchable `<activity/>`. * `$(AndroidInstrumentation)` is now also resolved as a fallback when the app has no launchable `<activity/>`, so no opt-in is required. * `GetAndroidInstrumentationName` logs XA1042/XA1043 instead of throwing. These codes existed in `Resources.Designer.cs` but not in `Resources.resx`. * `$(WaitForExit)` == false now uses `am instrument` when instrumentation is set, instead of always using `am start`. * `Microsoft.Android.Run` passes `-r` to `am instrument -w`, polls for the app process instead of a single `pidof` (which raced and dropped all `Console.WriteLine` output), returns a non-zero exit code when the instrumentation crashes or reports `INSTRUMENTATION_CODE: 0`, and forwards trailing `dotnet run -- <args>` as `-e` extras. * New `DotNetRunBenchmarkDotNet` device test. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 46043fa1-90cb-4cde-8bda-288924fe57f7
Document that `$(WaitForExit)` controls whether `-w` is passed to `adb shell am instrument`, and that setting it to `false` means no output and no meaningful exit code from an instrumentation-only app. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 46043fa1-90cb-4cde-8bda-288924fe57f7
There was a problem hiding this comment.
Pull request overview
This PR improves dotnet run support for Android apps that launch via an Android.App.Instrumentation entry point (including instrumentation-only apps), aligning behavior with existing dotnet test support and making headless scenarios like on-device BenchmarkDotNet hosts workable.
Changes:
- Added
$(AndroidUseInstrumentation)and updated launch resolution so instrumentation can be preferred (or used as a fallback when no launchable activity exists), with coded build errors instead of exceptions. - Fixed
Microsoft.Android.Runinstrumentation execution: raw status streaming (-r), logcat streaming reliability (PID polling), failure detection via output parsing, and forwarding trailing args asam instrumentextras. - Added XA1042/XA1043 resource entries + documentation and updated build property docs accordingly, plus a new device integration test covering the instrumentation-only
dotnet runflow.
Reviewed changes
Copilot reviewed 11 out of 12 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs | Adds a device test that builds and runs an instrumentation-only BenchmarkDotNet host and validates arg forwarding + exit semantics. |
| src/Xamarin.Android.Build.Tasks/Tasks/GetAndroidInstrumentationName.cs | Converts missing instrumentation/name cases into coded XA1042/XA1043 errors instead of exceptions. |
| src/Xamarin.Android.Build.Tasks/Properties/Resources.resx | Adds XA1042/XA1043 localized message entries. |
| src/Xamarin.Android.Build.Tasks/Properties/Resources.Designer.cs | Updates generated resource bindings/comments for XA1042/XA1043. |
| src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.DefaultProperties.targets | Introduces $(AndroidUseInstrumentation) defaulting behavior (auto-true for MSTest runner). |
| src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.Application.targets | Updates run-argument computation to resolve/choose instrumentation appropriately and uses am instrument when $(WaitForExit)=false and instrumentation is selected. |
| src/Microsoft.Android.Run/Program.cs | Implements instrumentation robustness fixes: -r, logcat PID polling, output parsing for failures, and arg forwarding to extras. |
| Documentation/docs-mobile/TOC.yml | Adds XA1042/XA1043 docs to the documentation TOC. |
| Documentation/docs-mobile/messages/xa1043.md | New documentation page for XA1043. |
| Documentation/docs-mobile/messages/xa1042.md | New documentation page for XA1042. |
| Documentation/docs-mobile/messages/index.md | Adds XA1042/XA1043 entries to the message index. |
| Documentation/docs-mobile/building-apps/build-properties.md | Updates $(AndroidInstrumentation) docs, adds $(AndroidUseInstrumentation), and clarifies $(WaitForExit) behavior for instrumentation runs. |
Files not reviewed (1)
- src/Xamarin.Android.Build.Tasks/Properties/Resources.Designer.cs: Generated file
…ion/>` XA1043 reads "does not contain a launchable `<activity>` or an `<instrumentation>` element", but `GetAndroidInstrumentationName` reported it whenever `<instrumentation>` was missing. Since the task also runs when `$(AndroidUseInstrumentation)` is true, an app that has an `<activity>` but no `<instrumentation>` got an error claiming it had no activity either. Split the two conditions: * XA1048 is new, and means "the app opted into launching through an instrumentation, but declares no `<instrumentation/>`". * XA1043 keeps its existing meaning and is now only reported from the fallback call site, where the app was already found to have no launchable activity. The new `NoLaunchableActivity` task property selects between the two. Also read the captured `am instrument` output under the same lock the `OutputDataReceived`/`ErrorDataReceived` callbacks use. `WaitForExitAsync` already awaits `AsyncStreamReader.EOF` for both readers, so the readers have drained by then, but taking the lock makes that explicit. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 46043fa1-90cb-4cde-8bda-288924fe57f7
|
/review |
|
✅ Android PR Reviewer completed successfully! |
There was a problem hiding this comment.
🤖 Code Review — ⚠️ Minor suggestions (no blockers)
Reviewed the diff independently before reading the PR description, then reconciled. This is a well-scoped, unusually well-documented change: the dotnet run instrumentation path fixes (streaming -r, polling for the app PID instead of a single racy pidof, non-zero exit on am instrument failure, and arg forwarding) all address real root causes, and the MSBuild resolution order ($(AndroidUseInstrumentation) → activity → instrumentation fallback → coded error) is sound and preserves dotnet test behavior byte-for-byte.
Verified & liked
GetAndroidInstrumentationNamecorrectly converts thrownInvalidOperationExceptions into coded errors (XA1042/XA1043/XA1048) and returns!Log.HasLoggedErrors— the right pattern for an MSBuild task.XA1042/XA1043were dangling inResources.Designer.cswithout.resxentries; now backfilled with proper{0}manifest-path substitution and translator comments. NewXA1048is fully wired intoResources.resx,index.md,TOC.yml, and a dedicated doc.- Nullable handling is clean — no
!operator, usesIsNullOrEmpty ()extension,NoLaunchableActivitydefaults correctly. - The
-w/-rdistinction and the$(WaitForExit)==falseinstrumentation branch (am instrument -r, no-w) match the documented intent.
Suggestions (inline, non-blocking)
- 💡 Add fast task-level unit tests for the three new coded-error branches — currently only a device-only test exercises them.
- 💡 Note the embedded-double-quote limitation in
QuoteForDeviceShell, or useArgumentListto avoidProcessStartInfo.Argumentsre-parsing.
Notes (no action required)
- The
INSTRUMENTATION_CODE: 0→ failure heuristic is a reasonable default and is already called out as a known trade-off in the PR description for third-party runners that use0for success. - CI shows
mergeable_state: blocked(pending review approval / required checks) rather than a code failure; nothing in the diff looks like it would break the build.
Nice work — the parsing helpers and target logic are easy to follow thanks to the inline rationale comments.
Generated by Android PR Reviewer for #12261 · 127.2 AIC · ⌖ 19 AIC · ⊞ 6.9K
Comment /review to run again
…nstrument`
`RunInstrumentationAsync()` built a single `adb` command line string and
handed it to `ProcessStartInfo.Arguments`, which .NET re-parses into argv
before launching `adb`. That parser only treats `"` as a grouping
character, so the double quotes in a value such as:
dotnet run -- 'greeting=say "hi" there'
were consumed locally and never reached the device:
stdout: greeting say hi there
`adb shell` deliberately does not escape the arguments it forwards, it
just joins them with spaces (like `ssh`), so quoting for the *device*
shell still has to be done by hand. But the *local* round of parsing can
be avoided entirely by using `ProcessStartInfo.ArgumentList`, which
quotes each element for us:
stdout: greeting say "hi" there
Add an `AdbHelper.CreateStartInfo()` overload that takes an argument
list, and have `BuildInstrumentationExtras()` return `List<string>`
instead of a pre-joined string.
Also add `GetAndroidInstrumentationNameTests`, so the `XA1042` /
`XA1043` / `XA1048` branch selection in `GetAndroidInstrumentationName`
is covered by fast task-level tests instead of only being exercised
indirectly by the device-only `DotNetRunBenchmarkDotNet` test.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 46043fa1-90cb-4cde-8bda-288924fe57f7
Why
You can already write an Android "app" whose only entry point is an
Android.App.Instrumentationsubclass, with noActivityat all. This is exactly the shape you want for a BenchmarkDotNet host, or any headless on-device harness.dotnet testhandles this today, butdotnet rundid not. Instrumentation auto-detection was gated behind$(EnableMSTestRunner), and several code paths inMicrosoft.Android.Runassumed a launchable<activity/>existed.What changed
Resolving what to launch. New
$(AndroidUseInstrumentation)property, defaulting totruewhen$(EnableMSTestRunner)istrue. When set, the app launches through its<instrumentation/>even if it also declares a launchable activity. Separately,$(AndroidInstrumentation)is now resolved as a fallback when the app has no launchable<activity/>, so an instrumentation-only app needs no opt-in at all. If the app has neither, the build fails with a coded error instead of an unhandled exception.Microsoft.Android.Runfixes. Four bugs made the instrumentation path unusable for anything but MSTest:-ronam instrument -w, so status blocks were buffered instead of streamed.pidofcall raced the app process start and usually lost, which silently dropped allConsole.WriteLineoutput. Now polls until the process appears.am instrumentexits 0 even when the instrumentation crashes. Output is now parsed forINSTRUMENTATION_FAILED,shortMsg/longMsg, andINSTRUMENTATION_CODEto produce a real exit code.dotnet run -- <args>are now forwarded asam instrumentextras:KEY=VALUEbecomes-e KEY VALUE, and anything else is collected into a single-e args "...".$(WaitForExit). Thefalsepath hard-codedam start; it now usesam instrument(without-w) when instrumentation is set.Error codes.
XA1042andXA1043existed inResources.Designer.csbut were missing fromResources.resx. Added, along withxa1042.md/xa1043.mddocs.Notes for reviewers
Does this break
dotnet test? No.$(EnableMSTestRunner)opts into$(AndroidUseInstrumentation), so instrumentation resolution is byte-for-byte identical to before.dotnet testroutes throughRunDotnetTestAsync, which is untouched; all the new behavior is confined toRunInstrumentationAsync.One deliberate design constraint worth flagging: I did not add a
--end-of-options separator toRunArguments. Mono.Options treats--as a terminator, which would push--server dotnettestcliinto the unparsed list and silently break MTP. Trailing user args are picked up from theremaininglist instead. The known trade-off is that user args colliding withMicrosoft.Android.Run's own option names (--verbose,--package,--adb) get consumed by the tool.The failure heuristic treats
INSTRUMENTATION_CODE: 0(Activity.RESULT_CANCELED) as a failure and-1(RESULT_OK) as success, matching what theandroidtesttemplate does. A third-party runner that uses0for success would be misreported.Still not covered: pulling
BenchmarkDotNet.Artifacts/back off the device.Testing
New
DotNetRunBenchmarkDotNetdevice test inMSBuildDeviceIntegration. It builds an app whoseMainActivity.csis replaced with a BenchmarkDotNet host and anInstrumentationsubclass, runsdotnet run -- greeting=hello --custom-flag, and asserts on arg forwarding, benchmark results, and the instrumentation exit code.Verified on a physical Pixel 5:
So BenchmarkDotNet does genuinely run in-process on Android via
InProcessNoEmitToolchain.DotNetRunWaitForExitalso passes. ThreeDotNetNewAndroidTestcases fail locally, but I confirmed they are pre-existing by revertingsrc/andtests/to the parent commit and reproducing the identical failures. They areXA0101 @(Content) build action is not supportedwarnings coming frommicrosoft.testplatform.testhost18.4.0, unrelated to this change.Documentation
Documentation/docs-mobile/building-apps/build-properties.mdupdates:$(AndroidInstrumentation)rewritten to describe the resolution order, exit code semantics, and argument forwarding.$(AndroidUseInstrumentation)section.$(WaitForExit)now documents that it controls whether-wis passed toam instrument, with a table of the four activity/instrumentation combinations, and a note thatfalsemeans no output and no meaningful exit code.