Expand built-in metadata references in the task host - #14770
Expand built-in metadata references in the task host#14770JanProvaznik wants to merge 5 commits into
Conversation
Item definition metadata may reference built-in metadata, as in <OutputName>%(Filename)</OutputName>. Such a value is stored unexpanded and substituted when the metadata is read, so that it tracks the item it is read from. The marshalled item used to carry items across a process boundary holds a single flat dictionary and returned the stored text verbatim, so a task running in a task host saw the literal "%(Filename)" where the same task run in-proc saw "hello". Under -mt nearly every task runs in a task host, which is how the VS repository build ended up emitting paths containing "%(Filename)". Substitute the references on read instead, which keeps the value tracking the item spec even if the task reassigns it. Values a task writes on the item are literal and are excluded, matching what the task would read back in-proc. RecursiveDir is resolved from the item's own metadata because it derives from the wildcard the item was expanded from rather than from the item spec. No serialized state changes, so a task host built from different sources behaves exactly as it does today. Fixes dotnet#14763 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3c4a0419-0feb-42ee-9963-d1da3b10d3d6
A task that clones its input, through CopyMetadataTo or the TaskItem copy constructor that calls it, was handed the stored text rather than the finished value, so the inconsistency remained for that very common shape. An engine item substitutes when copying onto an item a task can reach; do the same here. Values the task wrote stay literal, as they do in-proc. Reassigning ItemSpec now clears the derived-metadata cache, as it does on an engine item. Without this a FullPath, RootDir or Directory read before the move was returned again after it. That was already the case for direct reads of those modifiers, and substitution on read would otherwise have extended it to references embedded in item definition metadata. Only remember a written value as literal when it could otherwise be substituted, so ordinary metadata writes no longer allocate. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3c4a0419-0feb-42ee-9963-d1da3b10d3d6
The marshalled item recovers something the boundary erased, but nothing said so.
Name it: a value has an origin, either set directly on the item or inherited
from an item definition, and only the latter is expanded on read. Say at the
type level that this is a flattened view of an engine item, how origin is
recovered, and which accessors expand.
Rename to match, and use one word for one idea. Expansion is what MSBuild calls
this, so drop "substitute" as a synonym: _locallySetMetadata becomes
_writtenByTask, Substitute becomes ExpandIfFromItemDefinition, and the repeated
inline checks for a remaining "%(" become IsUnexpanded, which is what actually
distinguishes the two origins.
Add tests that hold the concepts still: every name in ItemSpecModifiers.All
reads the same on both sides, so a modifier added later is covered without
anyone remembering to; qualified references stay a decision rather than an
accident; and receiving an item does not mark its metadata as written by the
task.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 3c4a0419-0feb-42ee-9963-d1da3b10d3d6
There was a problem hiding this comment.
Pull request overview
Fixes a long-standing mismatch between in-proc and out-of-proc (TaskHost) task execution: item-definition metadata containing built-in metadata references (for example %(Filename)) is now expanded for the task when read, aligning TaskHost behavior with in-proc ProjectItemInstance.TaskItem behavior. This prevents tasks from receiving inert literal strings like %(Filename) (and producing incorrect paths such as ...\%(Filename).cs) when running under -mt/TaskHost.
Changes:
- Teach
TaskParameterTaskItemto expand built-in metadata references on “task-facing” reads when the stored metadata appears to originate from an item definition (while preserving raw/bulk-read semantics and values written by the task). - Add a minimal built-in metadata expander in
Microsoft.Build.Frameworkto support expansion in TaskHost scenarios without pulling in the full evaluation expander. - Add targeted unit tests and end-to-end TaskHost tests to validate parity with in-proc behavior (including
RecursiveDirand cache invalidation onItemSpecreassignment).
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| src/Shared/TaskParameter.cs | Updates TaskHost-marshalled ITaskItem behavior to expand item-definition built-in metadata references on read; tracks task-written metadata and clears cached modifiers on ItemSpec changes. |
| src/Framework/BuiltInMetadataExpander.cs | Adds an internal helper to expand built-in metadata references (%(Filename), etc.) against an item spec (including RecursiveDir support). |
| src/Shared/UnitTests/TaskParameter_Tests.cs | Adds regression/unit coverage for TaskHost boundary behavior, cloning/copy semantics, RecursiveDir, and cache invalidation. |
| src/Build.UnitTests/BackEnd/MetadataObservationTask.cs | Adds a simple test task that reports observed metadata and process id to distinguish in-proc vs TaskHost execution. |
| src/Build.UnitTests/BackEnd/ItemDefinitionMetadataInTaskHost_Tests.cs | Adds end-to-end tests that run the task both in-proc and via TaskHostFactory and assert identical observed results. |
💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.
The expander had no tests of its own. It was covered only where TaskParameter
happened to exercise it, which left its own edge cases unchecked.
Add direct tests: whitespace and casing, text that is not a well formed
reference, several references in one value, a supplied RecursiveDir, derivation
from the given item spec, and no allocation when there is nothing to expand.
One test failed. After a "%(" that does not start a reference, the expander
resumed after the closing parenthesis, so it did not see a well formed
reference that began inside the text it had spanned. "%(foo%(Filename)" stayed
as it was, where evaluation gives "%(foohello". Resume just after the "%(",
which is what the evaluation expander does.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 3c4a0419-0feb-42ee-9963-d1da3b10d3d6
|
I decided against having the protocol change due to concerns about backportability and risk to introduce this so late to NET 11 and cross-version taskhost compat. The tradeoff is expander duplication here. This should probably be refactored so the needed unified expander part goes to Framework and can be used in both Shared and Build. But that would also be massive and hard to justify backporting. |
|
waiting for permissions to see the actual offending task, but I am pretty confident in the parity being acheived. |
| private ItemSpecModifiers.Cache _cachedModifiers; | ||
|
|
||
| /// <summary> | ||
| /// Names of metadata the task wrote on this item. Their values are read as stored. |
There was a problem hiding this comment.
| /// Names of metadata the task wrote on this item. Their values are read as stored. | |
| /// Names of metadata the task wrote on this item. The values of these metadata are returned without expansion. |
|
|
||
| // The destination has no notion of an unexpanded value, so hand it expanded ones, as an engine | ||
| // item does when copying onto an item a task can reach. | ||
| if (HasUnexpandedMetadata()) |
There was a problem hiding this comment.
I don't love doing a linear scan over the metadata to determine if we need to do a linear transform over the data. Could we collapse that?
| /// an item definition from one set directly on the item. | ||
| /// </summary> | ||
| private static bool IsUnexpanded(string escapedValue) | ||
| => escapedValue?.IndexOf("%(", StringComparison.Ordinal) >= 0; |
There was a problem hiding this comment.
In other contexts @DustinCampbell has found that searching for % by itself followed by an i+1 check for ( is faster (because the single-character search can vectorize better). Unfortunately the optimized helper method ExpressionShredder.ContainsMetadataMarker doesn't exist in all of our target branches (it's in #14697). Let's do this and file a followup to adopt that in the main branch.
There was a problem hiding this comment.
I hope it'll be there soon. I'm working on addressing CR feedback now.
There was a problem hiding this comment.
Yeah, the gotcha here is that the bug addressed by this PR is affecting internal CloudBuild users now, so we'd like to patch it, and I don't want to have to backport your change too.
| /// Keep in step with <c>Expander.ExpandIntoStringLeaveEscaped</c> under <c>ExpanderOptions.ExpandBuiltInMetadata</c>, | ||
| /// which is what <c>ProjectItemInstance.TaskItem.GetMetadataEscaped</c> uses for the same job in-proc. |
There was a problem hiding this comment.
I am on board with this for the purpose of getting a fix out, but please file a follow-up to unify these.
Collapse the scan that guarded the copy. HasUnexpandedMetadata walked every value to decide whether to run a transform whose first act was the same check on the same string, so the work was done twice whenever anything needed expanding. The engine item can afford that guard because it bails out at once when the item has no item definitions, and it scans only the small shared definition metadata. The marshalled item has no such field, since that is the distinction the boundary erased, so the guard could only ever be a full scan. Chain the transform unconditionally instead: ExpandIfFromItemDefinition returns the value it was given when there is nothing to expand. Look for the metadata marker by searching for a single character. IndexOf(char) vectorizes and beats an ordinal two-character search, above all when the marker is absent, which is the usual case for a metadata value. This mirrors ExpressionShredder.IndexOfMarker, which is not available in every branch this has to reach. Also correct a test comment that described what an engine item does rather than what the test asserts, and a repeated word in another. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3c4a0419-0feb-42ee-9963-d1da3b10d3d6
Fixes #14763.
The problem
A task that runs in a task host reads item metadata differently than the same task in the MSBuild process.
A project defines metadata on an item definition:
A task in the MSBuild process reads
OutputNameashello. The same task in a task host reads%(Filename). One build wrote a file path that ended with\%(Filename).cs.The defect is old. It is easy to find now, because
-mtsends almost all tasks to a task host.Terms
This description uses these terms. Each term has one meaning.
ProjectItemInstance.TaskItem. The item type that the engine uses.TaskParameterTaskItem. The item type that crosses a process boundary.ItemDefinitionGroup.%(Filename)with its value.%(...).Three concepts
Read these first. The rest of this description follows from them.
Concept 1: origin
An engine item keeps two groups of metadata. The origin of a value controls how the item reads that value.
OutputFileExtension=.csItemDefinitionGroupOutputName=%(Filename), stored as text, read ashelloThe item expands definition metadata at each read. The item does not expand it one time and keep the result. This is necessary. A task can change
ItemSpec, and then%(Filename)must give a new value.Concept 2: the boundary discards the origin
flowchart LR subgraph BEFORE["Engine item: two groups"] direction TB D1["Direct<br/>OutputFileExtension = .cs"] I1["Definition<br/>OutputName = %(Filename)"] end subgraph AFTER["Marshalled item: one dictionary"] direction TB M1["OutputFileExtension = .cs"] M2["OutputName = %(Filename)"] end BEFORE -->|"CloneCustomMetadataEscaped()"| AFTERCloneCustomMetadataEscaped()copies both groups into one dictionary. The marshalled item receives that dictionary. Thus the marshalled item does not know the origin of a value.But the value still shows its origin. Evaluation expands all other expression types. Only a built-in metadata reference stays unexpanded. Thus a value that still contains
%(...)is definition metadata. One exception exists. See limit 1 below.The marshalled item uses this fact to find the origin again. This is the key point of the change. The alternative is to send the origin across the boundary. That alternative changes the serialized data, and a serialization change cannot go into a servicing branch.
Concept 3: how the task host finds the origin
flowchart TB V["a stored value"] --> Q1{"did the task write it,<br/>after the task host<br/>received the item?"} Q1 -->|yes| P1["<b>Direct</b> — known<br/>recorded at the write"] Q1 -->|no| Q2{"is the value unexpanded?<br/>does it contain %(...)?"} Q2 -->|no| P2["<b>Direct</b> — known<br/>evaluation expanded it already"] Q2 -->|yes| P3["<b>Definition</b> — assumed"] P1 --> R1(["read as stored"]) P2 --> R2(["read as stored"]) P3 --> R3(["read expanded"]) P3 -.->|"the one wrong result"| W["direct metadata that contains %(...)<br/>as stored text. It arrives here only<br/>after a task output made the item flat."]Two questions give the origin. The first question has a known answer. The second question has an assumed answer.
The code uses these names:
_writtenByTaskIsUnexpandedExpandIfFromItemDefinitionNot all reads use this decision. The next table gives the rule.
GetMetadata,GetMetadataValueEscaped,CopyMetadataTo, and theTaskItemcopy constructor that calls itCloneCustomMetadata,CloneCustomMetadataEscaped,EnumerateMetadataIf you add an accessor, put it on the side of the accessor that it resembles. The type-level comment in the code gives this rule.
The changes
Each row is a way for a task to detect that it runs in a task host. All rows were measured. The test ran the same task two times: in the MSBuild process, and with
TaskHostFactory. The item isfolder\hello.txt. The definition setsNameMetato%(Filename).hello%(Filename)GetMetadataValueEscapedcallsExpandIfFromItemDefinition. This is concept 3.ItemSpec, then reads again.renamed%(Filename)ItemSpec.%(RecursiveDir), for exampleout\%(RecursiveDir)%(Filename)%(Extension).out\sub1\sub2\hello.txtout\%(RecursiveDir)hello.txtRecursiveDirfrom its own metadata.RecursiveDircomes from the wildcard, not fromItemSpec.new TaskItem(input)orCopyMetadataTo.hello%(Filename)CopyMetadataToexpands. This is whatBulkImportMetadatadoes on the engine item.%(FullPath), changesItemSpec, then reads again.ItemSpecsetters call_cachedModifiers.Clear(). The engine item does this already.Two notes on that table.
Row 4 is important. Many tasks create
new TaskItem(input). Without this change, the defect stays for those tasks.Row 5 is an old defect, not a new one. The released MSBuild gives the same old path for a direct
%(FullPath)read after a change toItemSpec. This change corrects it, because concept 3 would otherwise put the same defect into definition metadata.These conditions do not change:
explicitexplicit%25(Filename).%(Filename)%(Filename)%(Filename), then reads it.%(Filename)%(Filename)_writtenByTask. This is question 1 of concept 3.%(Filename), then copies the item.%(Filename)%(Filename)%(Filename)%(Filename)Two known limits
Limit 1. Direct metadata that contains
%(...)as text, after a task output.GatherTaskItemOutputsmakes task outputs flat in the MSBuild process. After that, such a value looks the same as definition metadata. The task host then expands it.To find this limit, a build must do all of these steps:
%(Filename)as text.%(.Steps 1 to 3 keep the correct value inside one task. Only step 4 can show the difference. No MSBuild code expands
%(...)in metadata after evaluation. Thus the value is text that has no function. To remove this limit, the change must add data to the serialized item.Limit 2. A reference with an item type, for example
%(Thing.Filename).The engine uses a metadata table that has no item type. Thus such a reference gives an empty value in the MSBuild process. The task host keeps the text.
An empty value here is not better. It would remove all text of that shape. This condition is the same as before the change. A test holds this decision.
Compatibility
The change does not modify the serialized data. Thus there is no version negotiation and no packet version increase. A task host from older sources runs older code and does not change its behavior.
TaskParameteris used only when items cross a process boundary. The four users areTaskHostConfiguration,TaskHostTaskComplete,TaskHostBuildResponse, and the RAR node. Thus the change does not affect a task in the MSBuild process.The CLR2 task host has its own copy of
TaskParameter. The change does not affect it.No change wave. The change replaces two kinds of value: text that no build can use, and an old path. A project cannot select the task host under
-mt. Thus no build selected the current behavior. A change wave also stops a clean backport, because the current wave does not exist in the servicing branches.The backport was tested. Both commits go into
vs18.9andvs18.10with no conflict. Both branches build and pass the tests. On avs18.9build, the example giveshello. The released 18.9.1 gives%(Filename).Tests
TaskParameter_Testscovers the five differences above. It also covers escaped text, spaces in a reference, an incomplete reference, collection reads, and a second boundary crossing. Most tests compare the marshalled item against the engine item. They do not use a fixed value. Thus the two item types cannot become different without a test failure.One test is a theory over
ItemSpecModifiers.All. A new modifier gets a test automatically.ItemDefinitionMetadataInTaskHost_Testsruns a task in a real task host. Each test asserts the process of the task, then compares the value against the value from the MSBuild process.Each change was removed one time to confirm that its own test fails. The
RecursiveDircode was also tested with a mutation.