Skip to content

Fix RecursiveDir metadata loss in -mt builds (TaskHost boundary only)#13318

Open
JanProvaznik wants to merge 5 commits intodotnet:mainfrom
JanProvaznik:fix-recursivedir-mt-only
Open

Fix RecursiveDir metadata loss in -mt builds (TaskHost boundary only)#13318
JanProvaznik wants to merge 5 commits intodotnet:mainfrom
JanProvaznik:fix-recursivedir-mt-only

Conversation

@JanProvaznik
Copy link
Member

Summary

When using MSBuild's -mt mode (/maxcpucount), %(RecursiveDir) metadata is lost when items cross the TaskHost process boundary. This causes dotnet pack /mt to flatten directory structures in NuGet packages.

Example: build/wix/bundle/bundle.wxs becomes build/bundle.wxs, causing NU5118 duplicate file errors.

Fixes #13140
Related: #3121

Root Cause

In -mt mode, tasks without [MSBuildMultiThreadableTask] (like NuGet's PackTask) are routed to out-of-process TaskHost sidecars via TaskRouter.NeedsTaskHostInMultiThreadedMode(). Items passed to these tasks are serialized through TaskParameterTaskItem, which calls CloneCustomMetadataEscaped() — copying only custom metadata.

RecursiveDir is a built-in metadata that is explicitly non-derivable (IsDerivableItemSpecModifier("RecursiveDir") returns false). Unlike other built-in metadata (FullPath, FileName, etc.), it cannot be recomputed from the item spec alone — it requires the original wildcard pattern (_includeBeforeWildcardExpansionEscaped). On the TaskHost side, GetMetadata("RecursiveDir") falls through to the custom metadata dictionary lookup, finds nothing, and returns empty string.

Without -mt, items are passed as raw ProjectItemInstance.TaskItem objects (no serialization), so GetMetadata("RecursiveDir") computes the value on-demand from _includeBeforeWildcardExpansionEscaped via BuiltInMetadata.GetRecursiveDirValue().

Fix

Explicitly copy RecursiveDir to the custom metadata dictionary in TaskParameterTaskItem's constructor (after CloneCustomMetadataEscaped), so it survives cross-process serialization. This aligns out-of-proc (-mt) behavior with in-proc behavior.

Why this is narrower than the reverted PR #13142

PR #13142 was reverted (#13245) because it fixed two separate serialization boundaries:

Boundary Where What PR #13142 changed Effect
1. TaskHost serialization (TaskParameterTaskItem) Items passed directly to tasks in -mt mode Added RecursiveDir to custom metadata ✅ Fixed -mt pack
2. MSBuild task output collection (GatherTaskItemOutputs) Items returned from <MSBuild> task TargetOutputs Preserved includeBeforeWildcardExpansionEscaped in new ProjectItemInstance ❌ Caused sfxproj/NuGet doubling

The Boundary 2 change caused NuGet pack to double-append RecursiveDir to PackagePath for items like sfxproj analyzers where RecursiveDir was already baked into TargetPath during evaluation (e.g., TargetPath="analyzers/%(RecursiveDir)").

This PR only fixes Boundary 1 (TaskParameter.cs). Boundary 2 is untouched, so sfxproj items that lose RecursiveDir at the MSBuild callback boundary still have RecursiveDir="" by the time they reach TaskParameterTaskItem — no doubling occurs.

Safety matrix

Scenario RecursiveDir at PackTask PackagePath Result
Arcade pack, no -mt computed on-demand build build/wix/bundle/
Arcade pack, -mt (before) "" (lost in TaskHost) build build/ ❌ flattened
Arcade pack, -mt (after) wix/bundle/ (in custom metadata) build build/wix/bundle/
sfxproj, no -mt "" (lost at callback) analyzers/dotnet/cs/ analyzers/dotnet/cs/
sfxproj, -mt (before) "" (lost at callback then TaskHost) analyzers/dotnet/cs/ analyzers/dotnet/cs/
sfxproj, -mt (after) "" (lost at callback before TaskHost) analyzers/dotnet/cs/ analyzers/dotnet/cs/

Testing

  • Added RecursiveDirSurvivesTaskParameterSerialization unit test verifying round-trip serialization preserves RecursiveDir
  • All 44 existing TaskParameter_Tests pass
  • Verified dotnet pack /mt on arcade's Microsoft.DotNet.Build.Tasks.Installers succeeds with correct directory structure using bootstrapped msbuild
  • Verified non-mt pack still works (regression check)

Future work

The underlying bug #3121 (RecursiveDir lost through MSBuild task callback boundary / GatherTaskItemOutputs) remains unfixed. Fixing that requires a coordinated change with NuGet to add an idempotency check in PackTaskLogic.cs (don't append RecursiveDir if PackagePath already ends with it), since consumers like sfxproj bake RecursiveDir into TargetPath as a workaround.

JanProvaznik and others added 2 commits March 3, 2026 12:04
When using MSBuild's -mt mode (/maxcpucount), tasks without
[MSBuildMultiThreadableTask] are routed to out-of-process TaskHost
sidecars. Items passed to these tasks are serialized via
TaskParameterTaskItem, which calls CloneCustomMetadataEscaped() —
copying only custom metadata. RecursiveDir is a built-in metadata
that is non-derivable (requires _includeBeforeWildcardExpansionEscaped),
so it's lost during this serialization, returning empty string on
the TaskHost side.

This causes dotnet pack /mt to flatten directory structures in NuGet
packages (e.g., build/wix/bundle/bundle.wxs becomes build/bundle.wxs).

Fix: Explicitly copy RecursiveDir to custom metadata in the
TaskParameterTaskItem constructor before serialization, so it
survives the cross-process boundary.

Unlike the previous attempt (PR dotnet#13142, reverted in PR dotnet#13245),
this fix ONLY touches the TaskHost serialization boundary
(TaskParameterTaskItem). It does NOT change GatherTaskItemOutputs
or ProjectItemInstance, which was what caused the NuGet/sfxproj
double-append regression. Items that lose RecursiveDir at the
MSBuild task callback boundary (e.g., sfxproj targets returning
items via TargetOutputs) are unaffected — they already have
RecursiveDir=empty before reaching TaskParameterTaskItem.

Fixes dotnet#13140
Related: dotnet#3121

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@JanProvaznik
Copy link
Member Author

@JanProvaznik
Copy link
Member Author

the pipeline succeeded packing the arcade project but then failed on #13188

@JanProvaznik
Copy link
Member Author

I'd say ready to review, but rerunning pipeline to verify that something does not break down the line.

@JanProvaznik JanProvaznik marked this pull request as ready for review March 3, 2026 13:42
Copilot AI review requested due to automatic review settings March 3, 2026 13:42
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes loss of %(RecursiveDir) when items are serialized across the TaskHost process boundary in multithreaded (-mt//maxcpucount) builds, preventing directory flattening issues (e.g., dotnet pack /mt producing NU5118 duplicates).

Changes:

  • Preserve RecursiveDir by explicitly copying it into TaskParameterTaskItem’s custom escaped metadata during TaskHost-bound serialization.
  • Add a unit test intended to validate RecursiveDir round-trip behavior through TaskParameter serialization.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

File Description
src/Shared/TaskParameter.cs Copies built-in RecursiveDir into the serialized custom metadata bag so it survives TaskHost cross-process translation in -mt builds.
src/Shared/UnitTests/TaskParameter_Tests.cs Adds a test for RecursiveDir surviving TaskParameter serialization/deserialization.

The previous test used Microsoft.Build.Utilities.TaskItem.SetMetadata which
stores RecursiveDir as custom metadata (in the _metadata dictionary). Since
CloneCustomMetadataEscaped() already copies all custom metadata, the test
passed trivially without exercising the actual fix.

The new test constructs a ProjectItemInstance.TaskItem with a wildcard pattern
(includeBeforeWildcardExpansionEscaped != includeEscaped) so RecursiveDir is
computed as built-in metadata via BuiltInMetadata.GetRecursiveDirValue().
This correctly exercises the bug: CloneCustomMetadataEscaped() does NOT
include built-in metadata, so without the fix RecursiveDir is lost during
TaskHost serialization.

Verified: test FAILs when the fix is commented out, PASSes with the fix.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@JanProvaznik JanProvaznik force-pushed the fix-recursivedir-mt-only branch from 963d813 to e79960b Compare March 3, 2026 16:08
Remove arcade, runtime, aspnetcore, sdk, efcore, and razor from the
MSBuildMTExcludedReposOverride list. These repos were excluded because
NuGet PackTask loses RecursiveDir metadata when routed through TaskHost
in -mt mode. The TaskParameterTaskItem fix preserves RecursiveDir,
unblocking these repos.

Repos without known RecursiveDir pack issues (roslyn, winforms, wpf,
source-build-reference-packages) remain excluded until individually
validated.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@JanProvaznik JanProvaznik force-pushed the fix-recursivedir-mt-only branch from 0d83c27 to 4651dad Compare March 4, 2026 12:42
The previous commit removed too many repos from the -mt exclusion list.
Only arcade has been validated to work with -mt in the RecursiveDir fix.
Other repos remain excluded until validated in CI source-build Stage 2.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

-mt pack does not work on dotnet/arcade repository

2 participants