Fix duplicate runtime pack crash in dotnet test device deployment - #55502
Conversation
`dotnet test` against a device project built `DeployToDevice` and then
`ComputeRunArguments` against the same `ProjectInstance`. MSBuild item
outputs append and `ProjectInstance.Build()` does not reset instance state
between calls, so every target shared by both builds contributed its items
twice. On Android this made `ProcessFrameworkReferences` append a second set
of `@(TargetingPack)` items, and `ResolveFrameworkReferences` then failed:
error MSB4018: The "ResolveFrameworkReferences" task failed unexpectedly.
System.ArgumentException: An item with the same key has already been added.
Key: Microsoft.NETCore.App
Users only saw the generic "Running the ComputeRunArguments target to
discover run commands failed for this project."
Deploy against a fresh `ProjectInstance` so the item state it produces does
not leak into the `ComputeRunArguments` build. This mirrors the fix made for
`dotnet run` in #52046 (`RunCommandSelector.OpenProjectIfNeeded`).
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 5e0b26f1-213e-4340-929e-0dee032886bc
|
Azure Pipelines: Successfully started running 1 pipeline(s). 2 pipeline(s) were filtered out due to trigger conditions. There may be pipelines that require an authorized user to comment /azp run to run. |
There was a problem hiding this comment.
Pull request overview
Fixes a dotnet test crash during device deployment (e.g., Android) caused by reusing the same ProjectInstance for multiple MSBuild Build() calls, which accumulates item state and can introduce duplicate runtime pack entries.
Changes:
- Build
DeployToDeviceusing a freshProjectInstance(DeepCopy()) to prevent item accumulation betweenDeployToDeviceandComputeRunArguments. - Update the
DotnetTestDevicestest asset to executeResolveFrameworkReferencesduring both deploy and run-argument computation, so existing device tests regress this failure mode.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| test/TestAssets/TestProjects/DotnetTestDevices/DotnetTestDevices.csproj | Makes the test asset mimic Android by running ResolveFrameworkReferences during DeployToDevice and before ComputeRunArguments. |
| src/Cli/dotnet/Commands/Test/MTP/SolutionAndProjectUtility.cs | Avoids ProjectInstance state leakage by running the deploy build on project.DeepCopy() before building ComputeRunArguments. |
Only the DeployToDevice build runs on a copy; ComputeRunArguments has to build the original instance because RunProperties.FromProject and EnvironmentVariablesToMSBuild.ReadFromItems read their results from it. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5e0b26f1-213e-4340-929e-0dee032886bc
Evangelink
left a comment
There was a problem hiding this comment.
Reviewed this over three passes — fix correctness, test asset and coverage, and repo-wide consistency — and re-validated every finding against MSBuild's own source and the device tests before writing it up. The fix is correct. Approving. Everything below is non-blocking.
What I verified
DeepCopy() is the right primitive here. Checked against the MSBuild this repo actually consumes (18.10.0-1.26363.117, eng/Version.Details.xml): DeepCopy() calls DeepCopy(_isImmutable) and the clone keeps the mutability of the original. Every ProjectInstance that reaches DeployAndGetRunProperties comes from ProjectInstance.FromFile(...) with ProjectOptions that never set ProjectInstanceSettings, so it is always mutable — there is no path where DeepCopy() degenerates into returning this and Build() then trips VerifyThrowNotImmutable. The copy constructor deep-clones properties, items and global properties while sharing the immutable _targets, Toolset, TaskRegistry and ProjectRootElementCache, and _hostServices is preserved, so the deploy build itself behaves exactly as it did before. Neither DeepCopy overload carries [RequiresUnreferencedCode]/[RequiresDynamicCode], so no new trim/AOT surface, and this file is not in AotSourceFiles.props.
The ordering relative to AddAsItems matters, and it is right. EnvironmentVariablesToMSBuild.AddAsItems runs before the copy, so @(RuntimeEnvironmentVariable) is carried into the deploy build. That is pinned by ItPassesEnvironmentVariablesToBuildDeployAndRunArgumentsTargets, which asserts FOO=BAR appears in the DeployToDevice output — it would fail if the copy ever stopped carrying items. Worth noting because a re-evaluation (CreateProjectInstance/LoadProject) instead of DeepCopy() would have silently dropped them.
The state-flow concern doesn't hold up. The obvious risk is that something a real device SDK's DeployToDevice sets no longer reaches ComputeRunArguments. But dotnet run computes run arguments on an instance that never ran DeployToDevice (RunCommand.EvaluateProject loads its own instance; deploy happens on RunCommandSelector.OpenProjectIfNeeded's), and RunProperties.FromProject reads only RunCommand, RunArguments, RunWorkingDirectory, RuntimeIdentifier, DefaultAppHostRuntimeIdentifier and TargetFrameworkVersion. If any of those depended on a prior deploy, dotnet run would already be broken on Android/iOS. This moves dotnet test onto the same contract rather than off it. dotnet watch reaches for DeepCopy() for the same reason (EvaluationResult.cs, CompilationHandler.cs).
No double deploy, no residual accumulation. TrySelectDevice only builds ComputeAvailableDevices; TryDeployToDevice is reachable only from RunCommand. GetProjectProperties creates a fresh ProjectInstance per TFM, so ComputeRunArguments is built at most once per instance and the nine ContainSingle deploy assertions in the device tests still hold.
The asset change is genuinely required. ComputeRunArguments is <Target Name="ComputeRunArguments" /> — empty, no DependsOnTargets, and that is the only definition in the repo — so the BeforeTargets + DependsOnTargets hook really is the only way to pull ResolveFrameworkReferences into the second build. I also re-verified the BeforeTargets+DependsOnTargets ordering empirically on a scratch project rather than taking it on trust.
CI
Both red work items are unrelated to this change:
dotnet.Tests.dll.10(Windows FullFramework, exit 2) — 30 failures, all inRunFileTests_BuildOptions/RunFileTests_CscOnlyAndApi, i.e.dotnet run file.cs, on the "binary log option was specified but build will be skipped because output is up to date" mismatch. That console log has zero hits forDeployToDevice,ResolveFrameworkReferences,MSB4018orsame key has already been added, and every device test passed in the same build (ItDeploysBeforeComputingRunArguments,ItCallsDeployToDeviceTarget*,ItDeploysEveryTargetFramework,ItAutoSelectsSingleDevicePerTfm, ...). Same signature as #53869.dotnet-new.IntegrationTests.dll.1(Linux, exit 7) — hang-dump timeout withCommonTemplatesTests.FeaturesSupportstill running, SIGKILL (137), 224/224 recovered results passed. Same exit-code-7 hang class as #55494 / #55477 / #55430 / #55258, anddotnet-new.IntegrationTestsalso failed onmainin build 1529734 earlier the same day.
One trap for anyone else looking: the AzDO run-level failedTests: 0 on this pipeline is wrong. Run 42131574 reports 0 but actually has 26 distinct failures — the Helix exit codes and TRX files are the ground truth.
Nits
The title says "duplicate runtime pack", but the throw is on the first line of ResolveFrameworkReferences.ExecuteCore — ResolvedTargetingPacks.ToDictionary(tp => tp.ItemSpec, ...) — i.e. targeting packs, which the description itself gets right. Worth fixing since the title is the searchable part.
I did consider the alternative of collapsing this into one submission, project.Build([DeployToDevice, ComputeRunArguments], loggers). MSBuild would dedupe the shared targets so it would avoid the crash too, but it costs the distinction between RunCommandDeployFailed and RunCommandEvaluationExceptionBuildFailed (pinned by ItFailsWhenDeployToDeviceTargetFails) and still shares every other deploy-side mutation with the compute build. Agree with the shape you picked.
Optional and low priority: three commands now hand-roll "fresh instance for the next build" — dotnet watch via DeepCopy, dotnet run via CreateProjectInstance, and now this. A small ProjectInstanceExtensions.BuildFresh(...) with a doc comment describing the accumulation hazard would make it harder for the next person to reintroduce.
Also agree with leaving ResolveFrameworkReferences unhardened — de-duplicating there would hide doubled item state that every downstream @(ResolvedTargetingPack) consumer would still see.
| so ResolveFrameworkReferences has to run in the second build too to mimic Android. --> | ||
| <Target Name="_ComputeRunArgumentsDependsOnResolveFrameworkReferences" | ||
| BeforeTargets="ComputeRunArguments" | ||
| DependsOnTargets="ResolveFrameworkReferences" /> |
There was a problem hiding this comment.
This hook is what carries the whole regression guard, and nothing asserts that it exists. If someone later tidies up this empty target, ResolveFrameworkReferences stops running in the ComputeRunArguments build, every device test still passes, and the protection is gone with no signal. The comment helps, but a comment isn't a failing test.
ItDeploysBeforeComputingRunArguments already reads msbuild-dotnet-test.binlog through the AssertTargetInBinlog helper, so pinning it is cheap:
AssertTargetInBinlog(binlogPath, "ResolveFrameworkReferences", targets =>
targets.Should().HaveCount(2, "ResolveFrameworkReferences must run in both the DeployToDevice build and the ComputeRunArguments build"));To be precise about what that buys, since it's easy to overclaim: it guards the asset (deleting this hook takes the count 2 → 1). It does not discriminate fixed CLI from unfixed — before the fix the count is also 2, the second one just fails. Reverting DeepCopy() is already caught by these tests crashing outright. Worth confirming the exact count on a real run before committing to the number.
There was a problem hiding this comment.
If someone later tidies up this empty target ... every device test still passes
So, if someone deleted this target, tests would start failing. I think that is good, and the comment should help someone understand why it exists.
There was a problem hiding this comment.
I checked this and I think it's the other way around: deleting that target makes the tests pass, not fail.
The PR description already measured exactly that configuration:
| Test asset | CLI | Result |
|---|---|---|
DeployToDevice DependsOnTargets only, no ComputeRunArguments hook |
unfixed | 29 passed — bug not caught |
| Both asset changes | unfixed | 13 failed |
| Both asset changes | fixed | 29 passed |
Row 1 is the "someone deleted the target" state, and it's green even against the unfixed CLI. It's green against the fixed CLI too, so the deletion is invisible in both directions.
That follows from the mechanism described in the PR body: the SDK's ComputeRunArguments is empty with no DependsOnTargets. Remove the hook and the second build runs nothing, so nothing accumulates and the duplicate Microsoft.NETCore.App key never occurs. The hook is what creates the failure condition — it isn't something the failure condition depends on.
And nothing else pins it: GivenDotnetTestSelectsDevice.cs never mentions ResolveFrameworkReferences, the asset's ComputeRunArguments targets only touch @(RuntimeEnvironmentVariable), and ItDeploysBeforeComputingRunArguments only asserts DeployToDevice.EndTime <= ComputeRunArguments.StartTime.
So the comment does its job of explaining why the target is there, but there's no failing signal if it goes away — which is why I'd still like a cheap assertion on the target count.
Fixes the
dotnet testcrash reported in dotnet/android#12254.The bug
dotnet testagainst a device project (e.g. Android) failed with only a generic message:The real error was visible only in a binlog:
Root cause
SolutionAndProjectUtility.DeployAndGetRunPropertiesbuiltDeployToDeviceand thenComputeRunArgumentsagainst the sameProjectInstance.MSBuild
<Output ItemName="..."/>appends, andProjectInstance.Build()is a self-containedBuildManagersession that does not reset instance state between calls. So every target shared by both builds contributed its items a second time:ProcessFrameworkReferencesappended 4 more@(TargetingPack)items (4 → 8).ResolveFrameworkReferences'sGetPackageDirectory Items="@(TargetingPack)"appended its 8 outputs onto the 4@(ResolvedTargetingPack)items already present → 12, containingMicrosoft.NETCore.Appthree times.ResolveFrameworkReferences.ExecuteCore()'sResolvedTargetingPacks.ToDictionary(tp => tp.ItemSpec, ...)threw.Confirmed from the CI binlog — three MSBuild submissions on the same project, the third reusing the second's instance:
ResolvedTargetingPackseen / addedComputeAvailableDevices(own instance, viaRunCommandSelector)DeployToDeviceComputeRunArguments— same instance as #2dotnet rundoes not hit this because it never shares an instance across builds — #52046 fixed exactly this class of bug inRunCommandSelector.OpenProjectIfNeeded, andComputeRunArgumentsis built on a separately loaded instance inRunCommand.The fix
Deploy against a fresh
ProjectInstanceso its item state cannot leak into theComputeRunArgumentsbuild, matchingdotnet run:ComputeRunArgumentsstill builds the original instance, which is whatRunProperties.FromProjectandEnvironmentVariablesToMSBuild.ReadFromItemsread from afterwards.ProjectInstance.DeepCopy()is theProjectInstance-level analogue ofProject.CreateProjectInstance()used bydotnet run(both are snapshots of an already-evaluated project, not re-evaluations); it also preserves the@(RuntimeEnvironmentVariable)items thatEnvironmentVariablesToMSBuild.AddAsItemsadds before the deploy build.dotnet watchusesDeepCopy()for the same reason.Test coverage
Following the same approach as #52046, there is no bespoke regression test — instead
DotnetTestDevices.csprojnow runs the realResolveFrameworkReferencestask, which makes the existing device tests catch the bug.Two asset changes are needed. The first mirrors
DotnetRunDevices.csprojexactly:The second has no counterpart in #52046, and is required.
dotnet run's accumulating pair wasComputeAvailableDevices+DeployToDevice(both throughRunCommandSelector), whereasdotnet test's pair isDeployToDevice+ComputeRunArguments. The SDK'sComputeRunArgumentstarget is empty with noDependsOnTargets, so without a hook the second build runs nothing and nothing accumulates:This is faithful to Android: the CI binlog shows the real
ComputeRunArgumentssubmission running the full framework-reference chain.Verified red → green
All three combinations were measured against
GivenDotnetTestSelectsDevice:DeployToDevice DependsOnTargetsonly, noComputeRunArgumentshookThe 13 tests that the
DotnetTestDevices.csprojchange causes to fail without the CLI fix, and which theSolutionAndProjectUtility.cschange fixes:ItRunsTestsWithSpecifiedDevice("test-device-1")ItRunsTestsWithSpecifiedDevice("test-device-2")ItAutoSelectsSingleDeviceItAutoSelectsSingleDevicePerTfmItRunsWithDeviceAndFrameworkItRunsDeviceProjectsInSolutionItAcceptsDeviceViaMSBuildPropertyItPassesEnvironmentVariablesToBuildDeployAndRunArgumentsTargetsItCallsDeployToDeviceTargetWhenDeviceIsSpecifiedItCallsDeployToDeviceTargetWhenDeviceIsAutoSelectedItCallsDeployToDeviceTargetEvenWithNoBuildItDeploysBeforeComputingRunArgumentsItDeploysEveryTargetFrameworkGivenDotnetRunSelectsDevicealso passes 23/23.End-to-end validation
Validated on a real Android device (
dotnet new androidtestagainst SDK11.0.100-preview.7.26376.106with the releasedMicrosoft.Android.Sdk.Windows 37.0.0-preview.6.59, physical device attached):dotnet testComputeRunArgumentsmessageComputeRunArguments, deploys the APK, and runsam instrumenton devicelogcat confirms MSTest actually starts on device. Note there is a separate, unrelated downstream failure once the test host is running (
NotSupportedException: Running tests in any of the provided sources is not supported for the selected platform) which reproduces against the released Android workload and is being tracked on the dotnet/android side — this PR fixes the item-accumulation crash, it does not claimdotnet testfully succeeds end-to-end on Android yet.Notes
ResolveFrameworkReferencesis deliberately not hardened to tolerate duplicates. De-duplicating there would mask doubled item state that every downstream@(ResolvedRuntimePack)/@(ResolvedTargetingPack)consumer would still see, so a recurrence should keep failing loudly.