From 5b996a7676471af603418ddb29efc2b2f55410e9 Mon Sep 17 00:00:00 2001 From: Jeremy Kuhne Date: Sun, 28 Jun 2026 12:25:14 -0700 Subject: [PATCH 1/4] Enable trim/AOT analyzers for Microsoft.Build and clean up annotations Makes the MSBuild evaluation object model trim- and Native-AOT-capable so an AOT-compiled host (the dotnet CLI) can evaluate and build in-process, and fail observably where reflective loading is required so the host can fall back to a JIT MSBuild. Code: trim/AOT annotations, feature switches, and closed-world host-registration APIs (SdkResolver.Register, Task.RegisterTask, TaskItem task-parameter-type registration); new diagnostics MSB4282/MSB4283 for unsupported reflective paths. Validation: a Native AOT harness that publishes and runs an AOT image, evaluating and building the classlib (library) and console (executable) templates. Documentation: design/strategy notes, living suppression and annotation trackers, per-area deep dives, and the host-registration API specs. --- .gitignore | 3 + documentation/aot/README.md | 109 +++ documentation/aot/aot-annotation-map.md | 189 ++++ documentation/aot/aot-trim-suppressions.md | 243 +++++ documentation/aot/aot-trimming-strategy.md | 450 +++++++++ .../aot/buildcheck-reflection-removal.md | 302 ++++++ documentation/aot/follow-up-work.md | 63 ++ .../aot/managing-trimming-and-aot.md | 871 ++++++++++++++++++ .../aot/property-functions-reachability.md | 380 ++++++++ .../aot/sdk-msbuild-object-model-audit.md | 312 +++++++ documentation/aot/sdk-resolution.md | 367 ++++++++ documentation/aot/task-factory-aot.md | 389 ++++++++ documentation/aot/task-parameter-types.md | 262 ++++++ .../sdk-resolver-host-registration-api.md | 235 +++++ .../specs/task-class-registration-api.md | 219 +++++ .../task-parameter-type-registration-api.md | 265 ++++++ .../BackEnd/RegisteredTaskExecution_Tests.cs | 171 ++++ .../BackEnd/SdkResolverService_Tests.cs | 174 ++++ .../BackEnd/TaskExecutionHost_Tests.cs | 59 +- .../Evaluation/Expander_Tests.cs | 6 +- .../ToolsetConfigurationNet5_Tests.cs | 23 + .../BackEnd/BuildManager/BuildManager.cs | 22 + .../BackEnd/BuildManager/BuildSubmission.cs | 5 + .../Components/Logging/ILoggingService.cs | 3 + .../Components/Logging/LoggingService.cs | 28 + .../ProjectCache/ProjectCacheService.cs | 11 +- .../RequestBuilder/AssemblyLoadsTracker.cs | 14 + .../IntrinsicTasks/IntrinsicTaskFactory.cs | 16 +- .../Components/RequestBuilder/TaskBuilder.cs | 6 +- .../SdkResolution/CachingSdkResolverLoader.cs | 3 + .../SdkResolution/SdkResolverLoader.cs | 18 + .../SdkResolution/SdkResolverService.cs | 28 +- src/Build/BackEnd/Node/OutOfProcNode.cs | 4 + .../TaskExecutionHost/TaskExecutionHost.cs | 271 +++++- .../BuildCheckAcquisitionModule.cs | 2 + .../IBuildCheckAcquisitionModule.cs | 2 + .../BuildCheckBuildEventHandler.cs | 35 +- .../BuildCheckManagerProvider.cs | 2 + .../Infrastructure/IBuildCheckManager.cs | 2 + .../Infrastructure/NullBuildCheckManager.cs | 2 + .../Solution/SolutionProjectGenerator.cs | 6 + src/Build/Definition/Project.cs | 12 + src/Build/Definition/ProjectCollection.cs | 46 +- src/Build/Definition/ToolsetReader.cs | 47 +- src/Build/Evaluation/Expander.Function.cs | 74 +- .../Evaluation/Expander.FunctionBuilder.cs | 40 +- .../Evaluation/PropertyFunctionReceiver.cs | 6 +- src/Build/FeatureSwitches.cs | 58 -- src/Build/Graph/GraphBuildSubmission.cs | 3 + src/Build/Instance/ProjectInstance.cs | 15 + .../TaskFactories/AssemblyTaskFactory.cs | 12 +- .../TaskFactories/RegisteredTaskFactory.cs | 72 ++ src/Build/Instance/TaskRegistry.cs | 90 +- src/Build/Logging/LoggerDescription.cs | 42 +- src/Build/Microsoft.Build.csproj | 25 +- .../DefinitionObjectsLinks/ProjectLink.cs | 2 + src/Build/Resources/Constants.cs | 22 + src/Build/Resources/Strings.resx | 19 + src/Build/Resources/xlf/Strings.cs.xlf | 25 + src/Build/Resources/xlf/Strings.de.xlf | 25 + src/Build/Resources/xlf/Strings.es.xlf | 25 + src/Build/Resources/xlf/Strings.fr.xlf | 25 + src/Build/Resources/xlf/Strings.it.xlf | 25 + src/Build/Resources/xlf/Strings.ja.xlf | 25 + src/Build/Resources/xlf/Strings.ko.xlf | 25 + src/Build/Resources/xlf/Strings.pl.xlf | 25 + src/Build/Resources/xlf/Strings.pt-BR.xlf | 25 + src/Build/Resources/xlf/Strings.ru.xlf | 25 + src/Build/Resources/xlf/Strings.tr.xlf | 25 + src/Build/Resources/xlf/Strings.zh-Hans.xlf | 25 + src/Build/Resources/xlf/Strings.zh-Hant.xlf | 25 + src/Framework/BackEnd/Handshake.cs | 6 +- src/Framework/BuildEnvironmentHelper.cs | 64 +- src/Framework/FeatureSwitches.cs | 248 +++++ src/Framework/ITaskFactory.cs | 4 + src/Framework/ITaskFactory2.cs | 3 + src/Framework/ITaskFactory3.cs | 3 + src/Framework/Loader/CoreCLRAssemblyLoader.cs | 9 + src/Framework/Loader/LoadedType.cs | 20 +- src/Framework/Loader/MSBuildLoadContext.cs | 10 +- .../Microsoft.Build.Framework.csproj | 75 ++ src/Framework/NativeMethods.cs | 25 +- .../Polyfills/AotTrimmingPolyfills.cs | 14 + src/Framework/ReflectableTaskPropertyInfo.cs | 4 +- src/Framework/Sdk/SdkResolver.cs | 90 ++ src/Framework/TaskClassRegistration.cs | 74 ++ src/Framework/TaskClassRegistry.cs | 124 +++ src/Framework/TaskParameterTypeRegistry.cs | 128 +++ src/Framework/TestInfo.cs | 13 +- src/Framework/Utilities/TypeExtensions.cs | 10 +- .../Microsoft.Build.Framework.targets | 32 + .../OutOfProcTaskAppDomainWrapperBase.cs | 2 +- src/Shared/TaskEngineAssemblyResolver.cs | 14 +- src/Shared/TaskLoader.cs | 16 +- src/Shared/TypeLoader.cs | 92 +- src/Shared/UnitTests/TypeLoader_Tests.cs | 14 +- src/Tasks/BuiltInTasks.cs | 37 + src/Tasks/CodeTaskFactory.cs | 8 + src/Tasks/GenerateManifestBase.cs | 13 +- src/Tasks/Microsoft.Build.Tasks.csproj | 1 + .../RoslynCodeTaskFactory.cs | 33 +- src/Tasks/XamlTaskFactory/XamlTaskFactory.cs | 7 + src/Tasks/XslTransformation.cs | 13 +- src/Utilities/Task.cs | 61 ++ src/Utilities/TaskItem.cs | 54 ++ src/aot-validation/AssemblyInfo.cs | 11 + src/aot-validation/Directory.Build.props | 21 + src/aot-validation/Directory.Build.targets | 6 + src/aot-validation/Directory.Packages.props | 11 + src/aot-validation/DotnetTemplateAotTests.cs | 252 +++++ src/aot-validation/HarnessEnvironment.cs | 61 ++ src/aot-validation/InProcBuild.cs | 48 + .../Microsoft.Build.AotValidation.csproj | 137 +++ src/aot-validation/ObjectModelAotTests.cs | 261 ++++++ .../PropertyFunctionAotTests.cs | 106 +++ src/aot-validation/README.md | 270 ++++++ .../RegisteredSdkResolverAotTests.cs | 119 +++ src/aot-validation/RegisteredTaskAotTests.cs | 224 +++++ .../TaskParameterTypeRegistryAotTests.cs | 134 +++ src/aot-validation/TempDirectory.cs | 50 + src/aot-validation/ToolchainSmokeTests.cs | 21 + 121 files changed, 9159 insertions(+), 376 deletions(-) create mode 100644 documentation/aot/README.md create mode 100644 documentation/aot/aot-annotation-map.md create mode 100644 documentation/aot/aot-trim-suppressions.md create mode 100644 documentation/aot/aot-trimming-strategy.md create mode 100644 documentation/aot/buildcheck-reflection-removal.md create mode 100644 documentation/aot/follow-up-work.md create mode 100644 documentation/aot/managing-trimming-and-aot.md create mode 100644 documentation/aot/property-functions-reachability.md create mode 100644 documentation/aot/sdk-msbuild-object-model-audit.md create mode 100644 documentation/aot/sdk-resolution.md create mode 100644 documentation/aot/task-factory-aot.md create mode 100644 documentation/aot/task-parameter-types.md create mode 100644 documentation/specs/sdk-resolver-host-registration-api.md create mode 100644 documentation/specs/task-class-registration-api.md create mode 100644 documentation/specs/task-parameter-type-registration-api.md create mode 100644 src/Build.UnitTests/BackEnd/RegisteredTaskExecution_Tests.cs delete mode 100644 src/Build/FeatureSwitches.cs create mode 100644 src/Build/Instance/TaskFactories/RegisteredTaskFactory.cs create mode 100644 src/Framework/FeatureSwitches.cs create mode 100644 src/Framework/TaskClassRegistration.cs create mode 100644 src/Framework/TaskClassRegistry.cs create mode 100644 src/Framework/TaskParameterTypeRegistry.cs create mode 100644 src/Framework/buildTransitive/Microsoft.Build.Framework.targets create mode 100644 src/Tasks/BuiltInTasks.cs create mode 100644 src/aot-validation/AssemblyInfo.cs create mode 100644 src/aot-validation/Directory.Build.props create mode 100644 src/aot-validation/Directory.Build.targets create mode 100644 src/aot-validation/Directory.Packages.props create mode 100644 src/aot-validation/DotnetTemplateAotTests.cs create mode 100644 src/aot-validation/HarnessEnvironment.cs create mode 100644 src/aot-validation/InProcBuild.cs create mode 100644 src/aot-validation/Microsoft.Build.AotValidation.csproj create mode 100644 src/aot-validation/ObjectModelAotTests.cs create mode 100644 src/aot-validation/PropertyFunctionAotTests.cs create mode 100644 src/aot-validation/README.md create mode 100644 src/aot-validation/RegisteredSdkResolverAotTests.cs create mode 100644 src/aot-validation/RegisteredTaskAotTests.cs create mode 100644 src/aot-validation/TaskParameterTypeRegistryAotTests.cs create mode 100644 src/aot-validation/TempDirectory.cs create mode 100644 src/aot-validation/ToolchainSmokeTests.cs diff --git a/.gitignore b/.gitignore index 43f47c9e72a..aa8fae5a3e6 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,9 @@ artifacts/ .packages/ BenchmarkDotNet.Artifacts/ +# Local scratch / experimentation output +scratch/ + # Visual Studio 2015 cache/options directory .vs/ diff --git a/documentation/aot/README.md b/documentation/aot/README.md new file mode 100644 index 00000000000..8af1e9d18a7 --- /dev/null +++ b/documentation/aot/README.md @@ -0,0 +1,109 @@ +# MSBuild trimming & Native AOT documentation + +This folder collects the design notes, strategy, living trackers, and per-area analyses behind making +**MSBuild trim- and Native-AOT-capable**. The goal is to let an **AOT-compiled host - the +`dotnet` CLI -** run the MSBuild object model **in-process**: evaluate and inspect projects directly, and, +where an AOT image cannot do something (load a task, SDK resolver, logger, or build check by reflection, or +emit code at run time), **detect that and fall back** to a JIT MSBuild rather than crash. + +> **The one rule that governs everything here: fail observably, never silently** +> ([definition](managing-trimming-and-aot.md#msbuilds-overriding-design-criterion-fail-observably-never-silently)). +> A trimmed/AOT path that cannot run must surface a reported build **error** (e.g. **MSB4282**, **MSB4283**) +> or a branchable property - never a silent no-op and never an uncaught crash. + +The companion **host-registration API proposals** live one folder up in [`../specs/`](../specs/) (per the +repo convention that API proposals belong under `specs/`); they are linked from the index below. + +## Evaluation and execution + +The trim/AOT effort is evaluation-first. Evaluation - reading a project's properties, items, imports, and +conditions - is mostly MSBuild's own managed code and is the primary in-process object-model surface the +SDK CLI needs. Execution - running targets and tasks - is the harder tier because arbitrary tasks, loggers, +SDK resolvers, project-cache plugins, and build checks are discovered at run time. This change makes more +execution possible through closed-world host registration (for example registered SDK resolvers and task +classes), while unsupported open-world execution paths must fail observably so the host can fall back. + +## Start here + +Pick the entry point that matches what you are doing: + +| If you want to… | Read, in order | +| --- | --- | +| **Understand the annotation model** (you are new to trim/AOT) | [managing-trimming-and-aot.md](managing-trimming-and-aot.md) → [aot-trimming-strategy.md](aot-trimming-strategy.md) | +| **Decide what to do with a flagged code path** | [aot-trimming-strategy.md](aot-trimming-strategy.md) (the S1-S8 catalog) → [aot-trim-suppressions.md](aot-trim-suppressions.md) (is it already tracked?) → [aot-annotation-map.md](aot-annotation-map.md) (where annotations live + next steps) | +| **See the current suppression / annotation state** | [aot-trim-suppressions.md](aot-trim-suppressions.md) and [aot-annotation-map.md](aot-annotation-map.md) | +| **See current Backlog work** | [follow-up-work.md](follow-up-work.md) | +| **Build an AOT host** (e.g. an AOT `dotnet` CLI) | [sdk-msbuild-object-model-audit.md](sdk-msbuild-object-model-audit.md) (the surface you depend on) → the [host-registration APIs](#host-registration-api-proposals-in-specs) → [follow-up-work.md](follow-up-work.md) (what remains) | +| **Work on a specific subsystem** | the matching [per-area deep dive](#per-area-deep-dives) | + +## The documents + +### Design & strategy + +- [aot-trimming-strategy.md](aot-trimming-strategy.md) - **the decision framework.** When to *remove, gate, + register, annotate, or suppress* an AOT-unfriendly path; the **S1-S8** strategy catalog; and the + operating rules for keeping the warning gate and live trackers honest. Start here for "what do I do with + this warning?" +- [follow-up-work.md](follow-up-work.md) - **the remaining work.** A single, consolidated list of follow-up + items after the current annotation and host-registration pass. +- [managing-trimming-and-aot.md](managing-trimming-and-aot.md) - **the mechanics how-to**, for someone new to + the space: what each attribute means (`[RequiresUnreferencedCode]`, `[DynamicallyAccessedMembers]`, + `[FeatureSwitchDefinition]`, `[FeatureGuard]`), the **analyzer-vs-trimmer** split, the `IL4000` gotcha, and + feature-switch plumbing. Defines the **fail-observably** criterion the rest of the work obeys. + +### Living trackers (kept in sync with the code) + +- [aot-trim-suppressions.md](aot-trim-suppressions.md) - every `[UnconditionalSuppressMessage]` for a trim/AOT + rule, each with a status (**Vetted** / **Investigate** / **Backlog**), plus the merged Backlog deep + analysis. +- [aot-annotation-map.md](aot-annotation-map.md) - where the `[RequiresUnreferencedCode]`, + `[DynamicallyAccessedMembers]`, and feature-guard annotations live (counts by subsystem), a correctness + review, and prioritized suggestions to drive the remaining suppressions down. + +### Per-area deep dives + +- [sdk-resolution.md](sdk-resolution.md) - how SDK resolution works and the plan to make it trim/AOT-safe + (in-box resolution stays reflection-free; a dynamically-loaded resolver fails observably with **MSB4282**). +- [sdk-msbuild-object-model-audit.md](sdk-msbuild-object-model-audit.md) - audit of how the .NET SDK CLI + consumes the MSBuild object model, mapped to the CLI command that exposes each usage - the surface a + trimmed/AOT host must keep working, and where evaluation versus execution boundaries fall. +- [task-factory-aot.md](task-factory-aot.md) - how MSBuild creates and executes tasks through the + `ITaskFactory` family, why that is fundamentally reflection-bound, and the design for an AOT-safe task + mechanism. +- [task-parameter-types.md](task-parameter-types.md) - precisely which .NET types are legal as a task + parameter, how `` resolves a `ParameterType` string to a `Type`, the `ITaskItem` + implementations, and whether a trim-safe type registry is feasible. +- [property-functions-reachability.md](property-functions-reachability.md) - which types and members a + property-function expression can reach by "dotting in," and the receiver-restriction design (§10) that + bounds that surface for trimming. +- [buildcheck-reflection-removal.md](buildcheck-reflection-removal.md) - how the BuildCheck system discovers + and runs checks, and a proposal to remove reflection from it (permanently, or only under trim/AOT). + +### Host-registration API proposals (in `../specs/`) + +These propose the public, reflection-free APIs a host uses so the engine can run without reflecting at run +time. All three are **implemented**. + +- [../specs/task-class-registration-api.md](../specs/task-class-registration-api.md) - + `Microsoft.Build.Utilities.Task.RegisterTask`: register task classes so the engine constructs, binds, + and runs them with reflective task execution disabled. +- [../specs/task-parameter-type-registration-api.md](../specs/task-parameter-type-registration-api.md) - + `Microsoft.Build.Utilities.TaskItem.RegisterTaskParameterValueType`/`RegisterTaskParameterItemType`: resolve + task parameter types without a by-name `Type.GetType`. +- [../specs/sdk-resolver-host-registration-api.md](../specs/sdk-resolver-host-registration-api.md) - + `SdkResolver.Register`: contribute a reflection-free SDK resolver instead of MSBuild loading one by + reflection. + +## How these relate + + +```mermaid +flowchart TD + M[managing-trimming-and-aot.md
mechanics] --> S[aot-trimming-strategy.md
strategy + S1-S8] + S --> SUP[aot-trim-suppressions.md
suppression tracker] + S --> MAP[aot-annotation-map.md
annotation map] + AUDIT[sdk-msbuild-object-model-audit.md
surface to preserve] --> S + S -. references .-> D[per-area deep dives] + AUDIT -. enabled by .-> API[host-registration APIs
../specs/] + S --> TODO[follow-up-work.md
remaining work] +``` diff --git a/documentation/aot/aot-annotation-map.md b/documentation/aot/aot-annotation-map.md new file mode 100644 index 00000000000..f3e6a1f5a77 --- /dev/null +++ b/documentation/aot/aot-annotation-map.md @@ -0,0 +1,189 @@ +# Trim / AOT annotation map and improvement strategies + +**Status:** Living (annotation inventory). + +A map of **where** the MSBuild engine carries trim/AOT annotations - `[RequiresUnreferencedCode]` (RUC), +`[RequiresDynamicCode]` (RDC), `[DynamicallyAccessedMembers]` (DAM), and +`[FeatureSwitchDefinition]`/`[FeatureGuard]` switches - followed by a prioritized set of **suggested +improvements** that drive the remaining `[UnconditionalSuppressMessage]` count down via feature guards and +the other strategies in the catalog. + +This is the annotation-landscape companion to two documents (see the [folder README](README.md) for the full map): + +- [aot-trim-suppressions.md](aot-trim-suppressions.md) - the live **suppression** tracker and the merged + Backlog deep analysis. +- [aot-trimming-strategy.md](aot-trimming-strategy.md) - the **strategy catalog** (S1-S8) and decision + framework these improvements draw on. + +Scope: the three trim-enabled assemblies - **Microsoft.Build**, **Microsoft.Build.Framework**, and +**Microsoft.Build.Utilities**. `Microsoft.Build.Tasks` is not trim-enabled; its suppressions are tracked +as Backlog in the suppression tracker. Line numbers drift - search by member +name if a reference is stale. + +## How the annotation kinds relate + +| Attribute | What it says | How it is "discharged" | +| --- | --- | --- | +| `[RequiresUnreferencedCode]` (RUC) | "This member reflects over types/assemblies discovered at run time; trimming may remove what it needs." | Propagate up to a boundary, **or** put the reflective call behind a `[FeatureGuard]` switch, **or** restructure so there is no reflection (registration / direct construction). | +| `[RequiresDynamicCode]` (RDC) | "This member needs runtime code generation (not available under Native AOT)." | Same options; in MSBuild the only RDC member rooted is `Enum.GetValues(Type)`, proven unreachable via property functions. | +| `[DynamicallyAccessedMembers]` (DAM) | "Preserve *these* members of the `Type` that flows here." | The annotation **is** the fix - it makes a reflective member access trim-safe. The member types requested must match how the code actually reflects. | +| `[FeatureSwitchDefinition]` / `[FeatureGuard]` | "This AppContext switch is substituted to a constant under trim; treat the guarded branch as removed (and, for `[FeatureGuard]`, as discharging the RUC inside it)." | The trimmer folds the constant and machine-checks the guarded reflective branch is dropped, so no suppression is needed. | + +The MSBuild design rule throughout: **fail observably, never silently** (see +[managing-trimming-and-aot.md](managing-trimming-and-aot.md)). Every gated-off reflective leaf raises a +reported build error (e.g. **MSB4283**, **MSB4282**) so an AOT host can detect the unsupported path and fall +back to a JIT MSBuild. + +## Annotation inventory (counts) + +Counts as of 2026-06-28. There are **no** `[RequiresDynamicCode]` +annotations; the two IL3050s are *suppressions* (rooted-but-unreachable `Enum.GetValues(Type)`), counted in +the suppression row. + +| Category | Microsoft.Build | Microsoft.Build.Framework | Microsoft.Build.Utilities | Total | +| --- | --- | --- | --- | --- | +| `[RequiresUnreferencedCode]` | 116 | 17 | 0 | **133** | +| `[DynamicallyAccessedMembers]` | 11 | 10 | 2 | **23** | +| `[RequiresDynamicCode]` | 0 | 0 | 0 | **0** | +| `[FeatureSwitchDefinition]` | 0 | 8 | 0 | **8** | +| `[FeatureGuard]` (subset of the above) | 0 | 6 | 0 | **6** | +| Active `[UnconditionalSuppressMessage]` (in-scope) | 8 | 4 | 0 | **12** (+1 in the AOT harness) | + +The high `[RequiresUnreferencedCode]` count in **Microsoft.Build** (116) is concentrated in the +logger / project-cache-plugin / solution-metaproject / SDK-resolution reflective subsystems and the public +entry points that reach them (`BuildManager`, `Project`, `ProjectInstance`, `BuildSubmission`, +`SolutionProjectGenerator`). Gating those subsystems behind feature switches - as +`EnableReflectiveTaskExecution` already did for the task chain - is what would let large stretches of that +RUC drop (see [Remaining follow-up work](#remaining-follow-up-work)). + +## Where the `[RequiresUnreferencedCode]` annotations live + +The 133 RUC annotations cluster into a handful of reflective subsystems. The leaf is reflective; the RUC +either propagates to an honest boundary or is held behind a feature guard. + +| Subsystem | Representative members | Gated by a `[FeatureGuard]` today? | +| --- | --- | --- | +| **Reflective task execution** | [`TaskExecutionHost.FindTaskInRegistry` / `InstantiateTask`](../../src/Build/BackEnd/TaskExecutionHost/TaskExecutionHost.cs); [`TaskRegistry.GetRegisteredTask` / `LoadTaskFactory` / `CreateTaskFactory`](../../src/Build/Instance/TaskRegistry.cs) | **Yes** - `EnableReflectiveTaskExecution` gates the three `TaskExecutionHost` leaves; the chain above them is now non-RUC. | +| **Task parameter type by-name** | [`TaskRegistry.ResolveParameterTypeByName`](../../src/Build/Instance/TaskRegistry.cs) | **Yes** - `EnableReflectiveTaskParameterTypes` (registry-first, by-name fallback gated). | +| **Task assembly loaders** | [`TaskFactoryUtilities`](../../src/Framework/Utilities/TaskFactoryUtilities.cs) resolve/handler methods; [`CoreCLRAssemblyLoader`](../../src/Framework/Loader/CoreCLRAssemblyLoader.cs) (7 methods) | Partially - `EnableCustomPluginProbing` gates `MSBuildLoadContext`/`TaskEngineAssemblyResolver`. | +| **Public task-factory interfaces** | [`ITaskFactory` / `ITaskFactory2` / `ITaskFactory3`](../../src/Framework/ITaskFactory.cs) `Initialize` / `CreateTask` | No - preview cleanup candidate. The RUC annotations should be removed before ship if the remaining interface path can be made analyzer-clean; registered/intrinsic tasks already bypass it via non-interface methods. | +| **Reflective logger loading** | [`LoggerDescription.CreateForwardingLogger` / `CreateLogger`](../../src/Build/Logging/LoggerDescription.cs); [`OutOfProcNode.HandleNodeConfiguration`](../../src/Build/BackEnd/Node/OutOfProcNode.cs) | Partially - `EnableReflectiveLoggerLoading` gates the `LoggingService` forwarding-logger calls; the node-configuration leaf is **not yet** gated (the surviving `OutOfProcNode` IL2026). | +| **Project-cache plugins** | [`ProjectCacheService`](../../src/Build/BackEnd/Components/ProjectCache/ProjectCacheService.cs) (8 methods) | **No feature gate** - candidate (see [Remaining follow-up work](#remaining-follow-up-work)). | +| **Solution metaproject generation** | [`SolutionProjectGenerator`](../../src/Build/Construction/Solution/SolutionProjectGenerator.cs); [`ProjectInstance`](../../src/Build/Instance/ProjectInstance.cs) solution methods | No - reaches evaluation + SDK resolution. | +| **Build orchestration boundary** | [`BuildManager`](../../src/Build/BackEnd/BuildManager/BuildManager.cs) (logger/plugin/solution init); message pumps | Boundary; cannot itself carry the gate. | + +## Where the `[DynamicallyAccessedMembers]` annotations live + +The 23 DAM annotations are the *positive* trim fixes - they make a reflective access safe by preserving the +exact members the code touches. + +| Family | Members | Requested member types | Correct? | +| --- | --- | --- | --- | +| **Property-function receivers** | [`Function._receiverType` + ctor param](../../src/Build/Evaluation/Expander.Function.cs); [`FunctionBuilder.SetReceiverType`](../../src/Build/Evaluation/Expander.FunctionBuilder.cs) | `PublicConstructors \| PublicMethods \| PublicProperties \| PublicFields` | **Yes** - property functions invoke constructors (`new`), call methods, read properties **and** fields (`MaxValue`); kept in sync with `Constants.PropertyFunctionMembers`. Narrowing would break field/constructor access. | +| **Loaded task types** | [`LoadedType` ctor + `TaskType`](../../src/Framework/Loader/LoadedType.cs); [`IntrinsicTaskFactory` ctor](../../src/Build/BackEnd/Components/RequestBuilder/IntrinsicTasks/IntrinsicTaskFactory.cs); [`TaskExecutionHost.CreateIntrinsicTaskFactoryWrapper`](../../src/Build/BackEnd/TaskExecutionHost/TaskExecutionHost.cs) | `PublicParameterlessConstructor \| PublicProperties` | **Yes** - construct (`new`) + bind public properties; no fields/methods reflected. | +| **Registration generics** | [`TaskClassRegistry.Register` / `CreateLoadedType`](../../src/Framework/TaskClassRegistry.cs); [`Utilities.Task.RegisterTask`](../../src/Utilities/Task.cs); [`TaskParameterTypeRegistry.RegisterValueType`](../../src/Framework/TaskParameterTypeRegistry.cs); [`TaskItem.RegisterTaskParameterValueType`](../../src/Utilities/TaskItem.cs) | `PublicParameterlessConstructor \| PublicProperties` (task types) / `All` (parameter value types) | **Yes** - the generic type parameter carries the DAM so `typeof(T)` flows it to the `LoadedType` build; `All` for value-type parameters is deliberately conservative (any member can be marshalled). | +| **Plugin types** | [`ProjectCacheService.CreatePluginInstanceFromType` / `GetTypeFromAssemblyPath`](../../src/Build/BackEnd/Components/ProjectCache/ProjectCacheService.cs) | `PublicParameterlessConstructor` | **Yes** - plugin is only `Activator.CreateInstance`d. | +| **Reflection helpers** | [`TypeExtensions.InvokePublicMember` / `InvokeMemberPublicOnly`](../../src/Framework/Utilities/TypeExtensions.cs) | public member surface constant | **Yes** - paired with the IL2070 suppression; `BindingFlags.NonPublic` is rejected by the caller. | +| **Public contract** | [`ITaskFactory.TaskType`](../../src/Framework/ITaskFactory.cs) | `PublicProperties` | **Yes** - factories expose the task's bindable properties. | + +## Feature switches + +All eight live in [`FeatureSwitches.cs`](../../src/Framework/FeatureSwitches.cs) in Framework (the lowest +assembly), each mapped to an AppContext switch and a `RuntimeHostConfigurationOption` that supplies the +trimmed constant. Package consumers get the trimmed defaults through the Framework package's +`buildTransitive` targets; non-package consumers such as the in-repo AOT harness still re-declare them +locally because project-level `RuntimeHostConfigurationOption` items do not flow across project references. + +| Switch | Default (JIT) | Trimmed | `[FeatureGuard]` | Gates | +| --- | --- | --- | --- | --- | +| `EnableCustomPluginProbing` | true | false | RUC | Plugin/task assembly probing (`MSBuildLoadContext`, `TaskEngineAssemblyResolver`). | +| `EnableAllPropertyFunctions` | false | false | RUC | Run-time property-function receiver type probing. | +| `RestrictPropertyFunctionReceivers` | false | **true** | - | Restricts instance receivers to the curated set; no RUC to guard. | +| `EnableSdkResolverDynamicLoading` | true | false | RUC | Dynamically-loaded SDK resolver plugins (else **MSB4282**). | +| `EnableConfigurationFileToolsets` | true | false | - | `.exe.config` toolset reader; drops `System.Configuration`. | +| `EnableReflectiveTaskExecution` | true | false | RUC | Reflective task load/instantiate/bind (else **MSB4283**). | +| `EnableReflectiveTaskParameterTypes` | true | false | RUC | By-name task parameter `Type.GetType` fallback. | +| `EnableReflectiveLoggerLoading` | true | false | RUC | `LoggerDescription.CreateForwardingLogger` reflective load (else **MSB4285**). | + +## RUC / DAM correctness verification + +The annotations were inventoried across the three assemblies, and the representative families plus every +site an automated pass flagged were re-read against their member bodies. **No incorrect annotation was +found.** Three candidate "narrowings" were considered and **rejected** because they would break working +behavior or contradict the proven analysis: + +- **Do not narrow the property-function receiver DAM** to `PublicMethods | PublicProperties`. Property + functions invoke constructors and read fields (e.g. `$([System.Int32]::MaxValue)`), and the allowed + binding flags include `GetField`; the set must stay + `PublicConstructors | PublicMethods | PublicProperties | PublicFields`, matching + `Constants.PropertyFunctionMembers`. +- **Do not remove RUC from `LoggerDescription.CreateLogger()` / `CreateForwardingLogger()`.** Both delegate + to the private reflective `CreateLogger(bool)`, so calling either reaches reflection - the RUC is honest; + removing it would resurface IL2026 at the call. +- **Do not add `[RequiresDynamicCode]` to `Constants.InitializeAvailableMethods`.** Its IL3050 is a + *rooted-but-unreachable* `Enum.GetValues(Type)` (kept only because `typeof(Enum)` roots the allowlist), + not a live dynamic-code call; an author cannot supply a `Type` argument (MSB4185/MSB4186), proven under + Native AOT by `PropertyFunctionAotTests`. The suppression with that justification is the correct marker. + +## Remaining follow-up work + +The canonical list of remaining work is [follow-up-work.md](follow-up-work.md). The items below are a +short summary of the annotation/suppression cleanup opportunities from that list. Each removes or shrinks an +*accurate* warning by **restructuring or gating**, not by silencing, and preserves observable failure. None is +required for the engine to be trim-correct today; they shrink the residual `Backlog` set. + +### 1. Gate project-cache plugin loading behind a feature switch (S3) + +[`ProjectCacheService`](../../src/Build/BackEnd/Components/ProjectCache/ProjectCacheService.cs)'s eight RUC +methods load plugin assemblies from disk and reflect over their types, but - unlike task and logger loading - +they sit behind **no** feature switch. Add an `EnableReflectiveProjectCachePlugins` (or generalize the +existing `EnableCustomPluginProbing`) `[FeatureSwitchDefinition]` + `[FeatureGuard]`, default on under the +JIT, whose disabled branch reports an observable error (mirroring the BuildCheck acquisition guard, which is +the reference shape). Effect: the trimmer drops the plugin path from a trimmed image and an AOT host that +requests a project-cache plugin fails observably instead of crashing in reflection. + +### 2. Gate the node forwarding-logger leaf (removes the `OutOfProcNode` IL2026) + +The surviving [`OutOfProcNode.HandlePacket`](../../src/Build/BackEnd/Node/OutOfProcNode.cs) IL2026 exists +only because `HandleNodeConfiguration` initializes node forwarding loggers by reflection. The +`EnableReflectiveLoggerLoading` switch already exists and already gates the equivalent +`LoggerDescription.CreateForwardingLogger` calls in `LoggingService`. Extend that guard to the +node-configuration leaf so `HandleNodeConfiguration` can drop its RUC; the message-pump suppression on +`HandlePacket` then has nothing left to silence and can be removed. + +### 3. Gate solution-metaproject generation (chips at the `BuildManager` IL2026) + +The [`BuildManager.PacketReceived`](../../src/Build/BackEnd/BuildManager/BuildManager.cs) IL2026 reaches +evaluation, SDK resolution (already behind `EnableSdkResolverDynamicLoading`), logger loading (behind +`EnableReflectiveLoggerLoading`), **and** solution-metaproject generation +([`SolutionProjectGenerator`](../../src/Build/Construction/Solution/SolutionProjectGenerator.cs)), which is +still ungated. A `EnableSolutionMetaprojectGeneration`-style guard (observable failure when off) would +remove the last ungated reflective subsystem the work-queue boundary reaches. The boundary itself cannot +carry the gate; this is the leaf-gate pattern (S3) applied one subsystem at a time, exactly as +`EnableReflectiveTaskExecution` cleared the task chain. + +### 4. Reuse the parameter-type registry for the serialization path (S5) + +The one remaining Group B row - +[`TaskRegistry.TranslatorForTaskParameterValue`](../../src/Build/Instance/TaskRegistry.cs) (IL2057) - +reconstructs a task parameter type from a serialized assembly-qualified name. Its sibling `` +site was already retired by [`TaskParameterTypeRegistry`](../../src/Framework/TaskParameterTypeRegistry.cs). +The serialization path is a candidate to consult the same registry first (reflection-free for known types) +and gate the by-name `Type.GetType` fallback behind `EnableReflectiveTaskParameterTypes`. The open-world +value-type half (`IsValueType` admits any `struct`) bounds how far this can go, so it shrinks rather than +fully removes the row; see [task-parameter-types.md](task-parameter-types.md). + +### 5. Remove task-factory interface RUC before ship + +The RUC annotations on the public [`ITaskFactory` / `ITaskFactory2` / `ITaskFactory3`](../../src/Framework/ITaskFactory.cs) +interface members are part of the preview AOT annotation work, not a permanent shipped contract. Before GA, +try to remove those attributes rather than treating them as non-actionable. Registered and intrinsic tasks +already avoid the RUC interface methods by constructing through non-interface methods +(`RegisteredTaskFactory.CreateRegisteredTask`, `IntrinsicTaskFactory.CreateIntrinsicTask`), so the cleanup is +to prove any remaining interface call path is analyzer-clean, feature-gated, or fails observably when the +reflective path is disabled. + +### Not actionable + +- The **`TypeExtensions` reflection helpers** and the **two `Enum.GetValues(Type)` AOT rows** are provable + false positives (Vetted) and stay suppressed. diff --git a/documentation/aot/aot-trim-suppressions.md b/documentation/aot/aot-trim-suppressions.md new file mode 100644 index 00000000000..a0c28f61123 --- /dev/null +++ b/documentation/aot/aot-trim-suppressions.md @@ -0,0 +1,243 @@ +# Trim / AOT suppression tracker + +**Status:** Living (suppression inventory). + +Tracks every `[UnconditionalSuppressMessage(...)]` for a trim/AOT analyzer rule +(`IL2xxx`, `IL3xxx`) in `src/`, so we can drive the count down - ideally until only +provable false positives remain. + +> The **strategy** for *how* to drive the count down — remove / gate / register / annotate, with the +> full decision framework and audit plan — is [aot-trimming-strategy.md](aot-trimming-strategy.md). +> This file is the live inventory that plan operates on. +> +> A line-by-line audit of every `Backlog` row - the exact call graph, what fix was tried, and where +> the annotation/feature-guard flow breaks — is the +> [Backlog deep analysis](#backlog-deep-analysis) appendix below, so this is the single combined audit. +> +> A companion map of *every* trim/AOT annotation in the engine (`[RequiresUnreferencedCode]`, +> `[DynamicallyAccessedMembers]`, `[FeatureGuard]`/`[FeatureSwitchDefinition]`) and the strategies to drive +> the remaining suppressions down is [aot-annotation-map.md](aot-annotation-map.md). + +The `Triggering call` and `Justification` columns are condensed; the attribute in +source is authoritative. Line numbers drift as code changes — search for the +member name if a link is stale. + +## Suppression validity (design criterion) + +MSBuild's trim/AOT work follows one overriding rule - **fail observably, never silently** +(see [managing-trimming-and-aot.md](managing-trimming-and-aot.md#msbuilds-overriding-design-criterion-fail-observably-never-silently)). +An AOT host (the dotnet CLI) runs MSBuild in-process and must be able to *detect* a path it +cannot execute and fall back to a JIT MSBuild. That gives a strict test for every row here: + +* A `[UnconditionalSuppressMessage]` is **valid only when the warning is inaccurate** - the + code is provably trim/AOT-safe and the analyzer simply cannot see it (a *false positive*). + These are the `Vetted` rows. +* Suppressing an **accurate** warning is not a clean resolution - the warning is the very + signal the host relies on. Such a path must instead **propagate `[RequiresUnreferencedCode]`** + to a boundary, be **gated behind a `[FeatureGuard]` whose disabled branch raises a reported + error** (observable failure - see the BuildCheck acquisition guard), or be replaced with a + closed-world registration/direct path. Until that feature work exists, the row is `Backlog`. +* A suppression may **never** mask a path that would, under trim/AOT, **silently misbehave or + crash**. That is forbidden outright. + +Consequently an accurate-warning suppression is never an accepted final state. It may remain only +as `Backlog`: explicit pending work that requires additional feature work. Observable failure is +the minimum safety bar while the work is pending, not a reason to declare the suppression done. + +## Status legend + +| Status | Meaning | +| --- | --- | +| **Vetted** | Reviewed; the warning is a **false positive** - the code is provably trim/AOT-safe and the analyzer cannot see it. Per the design criterion (above) this is the only strictly-valid suppression. | +| **Investigate** | Not yet classified. Audit the call graph and either remove/restructure the warning, prove it `Vetted`, or move it to `Backlog` with the required feature work named. | +| **Backlog** | Requires additional feature work. The warning is accurate, or the assembly/subsystem is not yet trim/AOT-ready; the row must name the missing work or backlog bucket. This is not a permanent accepted state. | + +## Current state + +**As of 2026-06-28.** In-scope product suppressions: **12** +(Microsoft.Build 8 + Microsoft.Build.Framework 4) = **8 Vetted + 4 Backlog**; plus **9** +`Microsoft.Build.Tasks` Backlog rows. There are no `Investigate` rows. The Backlog rows need additional +feature work; the current observable-failure behavior only prevents silent failure or crashes while that +work is pending. The AOT validation harness is not product code and is not counted. The `[RequiresUnreferencedCode]` and `[DynamicallyAccessedMembers]` attributes are verified +correct against current source - see the companion [aot-annotation-map.md](aot-annotation-map.md). The +per-row inventory and the [Backlog deep analysis](#backlog-deep-analysis) below reflect this state. The +step-by-step removal history is in the git log. + +## Microsoft.Build.Framework (`src/Framework/`) + +| File:Line | IL rule | Member | Triggering call | Justification (short) | Status | +| --- | --- | --- | --- | --- | --- | +| [TypeExtensions.cs:30](../../src/Framework/Utilities/TypeExtensions.cs#L30) | IL3000 | `Type.GetAssemblyPath()` | `type.Assembly.Location` | `Location` is empty under single-file/AOT; the empty result is handled (AOT hosts supply the path via `MSBUILD_EXE_PATH`) and `Path.GetFullPath` is skipped for it. | Vetted | +| [TypeExtensions.cs:47](../../src/Framework/Utilities/TypeExtensions.cs#L47) | IL2067 | `Type.CreateDefault()` | `Activator.CreateInstance(type)` | Only invoked for value types (guarded by `IsValueType`), which always have a public parameterless ctor. | Vetted | +| [TypeExtensions.cs:83](../../src/Framework/Utilities/TypeExtensions.cs#L83) | IL2070 | `InvokeMemberPublicOnly(...)` | `type.InvokeMember(...)` | Sole caller rejects `BindingFlags.NonPublic`; receiver public surface preserved via `[DynamicallyAccessedMembers]`. | Vetted | +| [TaskClassRegistration.cs:65](../../src/Framework/TaskClassRegistration.cs#L65) | IL2072 | `CreateLoadedTypeFromFactory()` | `_createInstance().GetType()` | Only on the `RegisterTask(string, Func)` overload, whose task type is host-supplied and not statically known, so the `LoadedType` is built lazily from the first instance's type. The generic overload - which the built-in tasks and most hosts use - supplies the `LoadedType` eagerly and is fully trim-safe. Backlog work is to give the non-generic registration path an explicit trim-safe type/metadata contract instead of relying on host rooting (`RegisterTask` or `TrimmerRootAssembly`). | Backlog | + +## Microsoft.Build (`src/Build/`) + +| File:Line | IL rule | Member | Triggering call | Justification (short) | Status | +| --- | --- | --- | --- | --- | --- | +| [Expander.FunctionBuilder.cs:55](../../src/Build/Evaluation/Expander.FunctionBuilder.cs#L55) | IL2069 | `FunctionBuilder.SetReceiverType(Type)` | store into DAM-annotated `ReceiverType` | Receiver bounded to preserved-member allowlists: static types to `AvailableStaticMethods` (members preserved by `Constants.PropertyFunctionMembers` `[DynamicDependency]`); instance receivers to `PropertyFunctionReceiver` (§10 `RestrictPropertyFunctionReceivers` substituted `true` under trim). The reflected members are preserved, so the dataflow warning is a false positive. | Vetted | +| [Expander.Function.cs:363](../../src/Build/Evaluation/Expander.Function.cs#L363) | IL2074 | `Function.Execute(...)` | `_receiverType` from runtime value | Same bounded-allowlist receiver as above; `Constants.PropertyFunctionMembers` preserves the reflected members under trim (the code comment there calls these suppressions "honest under trimming"). | Vetted | +| [Expander.Function.cs:365](../../src/Build/Evaluation/Expander.Function.cs#L365) | IL2080 | `Function.Execute(...)` | `_receiverType.GetMethods(_bindingFlags)` (out-param path) | `_bindingFlags` is masked to `AllowedBindingFlags` at construction so it never carries `BindingFlags.NonPublic`; the call binds only public methods of the bounded allowlist receiver, whose public members are preserved for trimming. | Vetted | +| [Expander.Function.cs:669](../../src/Build/Evaluation/Expander.Function.cs#L669) | IL2096 | `Function.GetTypeForStaticMethod(...)` | case-insensitive lookup vs `AvailableStaticMethods` | Only resolves to curated `AvailableStaticMethods` allowlist types, whose members are preserved for trimming. | Vetted | +| [Expander.Function.cs:1189](../../src/Build/Evaluation/Expander.Function.cs#L1189) | IL2080 | `Function.FindPublicMethodBySignature(...)` | `_receiverType.GetMethods(_bindingFlags)` | Same `_bindingFlags` no-`NonPublic` invariant; public-only bind over the bounded allowlist receiver (the GetMethods signature match added with #14191). | Vetted | +| [Expander.Function.cs:1240](../../src/Build/Evaluation/Expander.Function.cs#L1240) | IL3050 | `Function.LateBindExecute(...)` | `Enum.GetValues(Type)` (rooted) | `Enum.GetValues(Type)` is unreachable via property functions - no way to supply a `Type` arg (MSB4185/MSB4186) - and would fail observably if reached; proven under Native AOT by `PropertyFunctionAotTests`. | Vetted | +| [Expander.Function.cs:1242](../../src/Build/Evaluation/Expander.Function.cs#L1242) | IL2080 | `Function.LateBindExecute(...)` | `_receiverType.GetMethods(_bindingFlags)` | Same `_bindingFlags` no-`NonPublic` invariant; public-only bind over the bounded allowlist receiver. | Vetted | +| [Constants.cs:303](../../src/Build/Resources/Constants.cs#L303) | IL3050 | `Constants.InitializeAvailableMethods()` | `Enum.GetValues(Type)` (rooted for allowlist) | Same `Enum.GetValues(Type)` false positive: rooted by `typeof(Enum)` for the allowlist but unreachable via property functions; verified by `PropertyFunctionAotTests`. | Vetted | +| [OutOfProcNode.cs:631](../../src/Build/BackEnd/Node/OutOfProcNode.cs#L631) | IL2026 | `HandlePacket(INodePacket)` | `NodeConfiguration` arm -> `HandleNodeConfiguration` | The build-request arms now go through the **`EnableReflectiveTaskExecution` leaf gate** (fail observably with MSB4283). The residual RUC is `HandleNodeConfiguration`, which loads node **forwarding loggers** by reflection - a separate subsystem this task gate does not cover. Backlog work is to gate the node forwarding-logger leaf. | Backlog | +| [BuildManager.cs:1480](../../src/Build/BackEnd/BuildManager/BuildManager.cs#L1480) | IL2026 | `INodePacketHandler.PacketReceived(...)` | work-queue -> `IssueBuildRequestForBuildSubmission` | Reaches **solution-configuration evaluation / SDK resolution and logger/plugin** init - not task execution (now gated). Backlog work is to gate the remaining solution, logger, and project-cache plugin paths that this boundary can reach. | Backlog | +| [TaskRegistry.cs:1870](../../src/Build/Instance/TaskRegistry.cs#L1870) | IL2057 | `TranslatorForTaskParameterValue(...)` | `Type.GetType(propertyTypeName)` | Task parameter type reconstructed from a **serialized** assembly-qualified name during task-host marshalling. `IL2057` is dataflow-class (no `[FeatureGuard]` silences it), the input is a `string` (no DAM target), and the name is serialization-supplied. Backlog work is to reuse `TaskParameterTypeRegistry` for this serialization path where possible. See [Backlog deep analysis - Group B](#group-b--inline-task-by-name-type-resolution). | Backlog | + +> **Status of the audited Backlog rows (covered in the +> [Backlog deep analysis](#backlog-deep-analysis) appendix below).** None masks a silent +> failure or a crash: every terminal reflective operation reports an observable build error +> (`TaskInstantiationFailureError` / `TaskLoadFailure` / `InvalidProjectFileException` / **MSB4283**). +> - **The task-execution boundary rows are gone.** The audit's Group A leaf gate is **implemented**: +> `EnableReflectiveTaskExecution` (`[FeatureSwitchDefinition]`+`[FeatureGuard]`, substituted `false` +> under trim) gates the three reflective task leaves (`FindTask`, `InitializeForBatch`, +> `SetTaskParameters` in `TaskExecutionHost`), which fail observably with **MSB4283**. That freed the +> whole build-execution chain (the nodes, `BuildRequestEngine`, `RequestBuilder`/`TargetBuilder`/ +> `TaskBuilder`, `TaskHost`, and the `IBuildEngine3`/`INodePacketHandler`/`IRequestBuilder`/ +> `ITaskBuilder` interfaces) of `[RequiresUnreferencedCode]`, so **5 of the 7** former boundary +> suppressions were removed (`InProcNode`, `TaskHost.BuildProjectFilesInParallel`, +> `MSBuild.ExecuteTargets`, the two `BuildRequestEngine` event handlers). See +> [Backlog deep analysis - Group A](#group-a--the-il2026-build-execution-boundary). +> - **The 2 remaining `IL2026` rows reach a *different* subsystem** - node forwarding-logger loading +> (`OutOfProcNode.HandleNodeConfiguration`) and solution-configuration/SDK/logger init +> (`BuildManager` work queue) - not task execution. They are `Backlog` until those subsystems are +> gated too. +> - **The remaining `TaskRegistry.TranslatorForTaskParameterValue` `IL2057` row** is `Backlog`: it +> reconstructs a task parameter type from a **serialized** assembly-qualified name; `IL2057` is +> dataflow-class (no `[FeatureGuard]` can silence it), the input is a `string` (no DAM target), and the +> name is serialization-supplied (not removable). The other two `TaskRegistry` rows (the +> `` `ParameterType` resolution) are handled by the task parameter type registry, +> not a suppression (see +> [task-parameter-type-registration-api.md](../specs/task-parameter-type-registration-api.md)). The +> analysis is in +> [Backlog deep analysis - Group B](#group-b--inline-task-by-name-type-resolution). +> +> The former `Expander` property-function rows are now **Vetted**, not `Backlog`: §10's +> `RestrictPropertyFunctionReceivers` switch (substituted `true` under trim, paired with +> `EnableAllPropertyFunctions` substituted `false`) bounds every property-function receiver to a +> preserved-member allowlist, and `Constants.PropertyFunctionMembers` `[DynamicDependency]` keeps +> that surface, so the dataflow the analyzer cannot follow is provably trim-safe. + + +## Microsoft.Build.Tasks (`src/Tasks/`) - Backlog + +`Microsoft.Build.Tasks` is not trim/AOT-ready. These rows are `Backlog` because they require +additional feature work before this assembly can be treated as trim/AOT-enabled. One bucket is XML +handling (`XmlSerializer`, `XslCompiledTransform`, `SignedXml`); the others are attribute reflection +and assembly metadata handling. + +| File:Line | IL rule | Member | Triggering call | Status | +| --- | --- | --- | --- | --- | +| [BootstrapperBuilder.cs:133](../../src/Tasks/BootstrapperUtil/BootstrapperBuilder.cs#L133) | IL3050 | `Build(BuildSettings)` | `XslCompiledTransform` | Backlog | +| [WriteCodeFragment.cs:82](../../src/Tasks/WriteCodeFragment.cs#L82) | IL2026 | `Execute()` | attribute-type reflection | Backlog | +| [GenerateManifestBase.cs:280](../../src/Tasks/GenerateManifestBase.cs#L280) | IL2026 | `Execute()` | `XmlSerializer` | Backlog | +| [SignFile.cs:46](../../src/Tasks/SignFile.cs#L46) | IL2026 | `Execute()` | `XmlSerializer` | Backlog | +| [SignFile.cs:48](../../src/Tasks/SignFile.cs#L48) | IL3050 | `Execute()` | `XmlSerializer` / `XslCompiledTransform` / `SignedXml` | Backlog | +| [RoslynCodeTaskFactory.cs:95](../../src/Tasks/RoslynCodeTaskFactory/RoslynCodeTaskFactory.cs#L95) | IL3002 | `GetThisAssemblyDirectory()` | `Assembly.ManifestModule.FullyQualifiedName` | Backlog | +| [TrustInfo.cs:533](../../src/Tasks/ManifestUtil/TrustInfo.cs#L533) | IL3050 | `ToString()` | `XslCompiledTransform` | Backlog | +| [DeployManifest.cs:551](../../src/Tasks/ManifestUtil/DeployManifest.cs#L551) | IL2026 | `Validate()` | `XmlSerializer` | Backlog | +| [DeployManifest.cs:553](../../src/Tasks/ManifestUtil/DeployManifest.cs#L553) | IL3050 | `Validate()` | `XmlSerializer` / `XslCompiledTransform` | Backlog | + +## AOT validation harness (`src/aot-validation/`) + +Not product code — the Native AOT validation harness — ignore. + +## Remaining follow-up work + +There is no **Investigate** group: the current rows are either `Vetted` or `Backlog`. The remaining +work is tracked in one place: [follow-up-work.md](follow-up-work.md). The product Backlog buckets most +directly related to this tracker are host-supplied task type metadata, project-cache plugin loading, +the node forwarding-logger leaf, solution-metaproject generation, and the serialized task-parameter +type path. The `Microsoft.Build.Tasks` Backlog includes an XML handling bucket +(`XmlSerializer`, `XslCompiledTransform`, `SignedXml`) plus smaller attribute-reflection and assembly +metadata rows. + +Every remaining in-scope suppression is therefore one of: **Vetted** (a provable false positive: the +`TypeExtensions` reflection helpers, the property-function receiver dataflow now bounded by §10, and +the two `Enum.GetValues(Type)` AOT rows proven unreachable via property functions); or **Backlog** +(feature work still required). None masks a silent failure or a crash while the Backlog work is pending. + +## Backlog deep analysis + +This appendix is the line-by-line audit of the `Backlog` rows - the exact call graph each reaches, +what fix was tried, and precisely what additional feature work is required. The strategy catalog +it references (S1-S8) is [aot-trimming-strategy.md](aot-trimming-strategy.md). + +In summary: + +- The `Backlog` rows are **accurate** warnings, not false positives - each path genuinely reflects or + reaches a subsystem that is not trim/AOT-ready. +- The terminal reflective operation **already fails observably** - a reported build error (**MSB4283**, + `TaskLoadFailure`, `TaskInstantiationFailureError`, or `InvalidProjectFileException`) - never a silent + no-op and never an uncaught crash. That is a safety property while the row is pending, not a final + resolution. +- The clean end-state is to **remove** the warning (gate or restructure), not silence it. Group A's leaf + gate does this for most of its rows; two Group A rows, one Group B row, and the Group C task-metadata + row remain. + +### Group A — the IL2026 build-execution boundary + +**Implemented (leaf gate).** [`FeatureSwitches.EnableReflectiveTaskExecution`](../../src/Framework/FeatureSwitches.cs) +(`[FeatureSwitchDefinition]` + `[FeatureGuard(typeof(RequiresUnreferencedCodeAttribute))]`, substituted +`false` under trim) gates the three reflective task leaves in +[`TaskExecutionHost`](../../src/Build/BackEnd/TaskExecutionHost/TaskExecutionHost.cs) - `FindTask`, +`InitializeForBatch`, `SetTaskParameters` - whose disabled branch raises an observable **MSB4283** +(`ReflectiveTaskExecutionNotSupported`) `InvalidProjectFileException`. Because the switch is `true` in +every JIT build, the existing reflective path is unchanged for real builds; the gated branch only runs +under a trimmed/AOT MSBuild, which cannot run reflective tasks anyway. + +With the leaves guarded, the whole build-execution chain dropped its `[RequiresUnreferencedCode]`, +removing **5 of the 7** original boundary suppressions: `InProcNode.HandlePacket`, +`TaskHost.BuildProjectFilesInParallel`, `MSBuild.ExecuteTargets`, and the two `BuildRequestEngine` event +handlers. + +The two rows that **survive** - `OutOfProcNode.HandlePacket` and `BuildManager.PacketReceived` - do so +because they *also* reach a **different** reflective subsystem (node forwarding-logger loading; solution- +configuration / SDK / project-cache-plugin init) that the task gate does not cover. Gating that subsystem +is tracked in [follow-up-work.md](follow-up-work.md). + +Why forwarding the RUC out of these two boundaries (instead of gating) does not work: + +| Boundary | Why RUC cannot be forwarded past it | +| --- | --- | +| `OutOfProcNode.HandlePacket` (private message pump) | A **dispatch root** driven by the node packet loop, not a synchronous caller. Its only upward boundary is the internal `INodePacketHandler.PacketReceived` contract, implemented by many handlers that process **non-reflective** packets and invoked generically by the packet router - marking it RUC is over-broad and amplifies rather than removes. | +| `BuildManager.PacketReceived` (`INodePacketHandler` impl) | The same internal interface, over-broad for the same reason; it fronts the work-queue that reaches evaluation / SDK resolution / logger init. | + +The terminal already fails observably: task **instantiation** is wrapped in +`catch (...) → LogError("TaskInstantiationFailureError")` returning `null`; task **load** reports +`"TaskLoadFailure"` via `ProjectErrorUtilities.ThrowInvalidProject`. So under AOT an unloadable task is a +reported MSB error, never a silent skip or a crash. + +### Group B — inline-task by-name type resolution + +This is where the annotation / feature-guard flow is **genuinely unsolvable**, provably. The one surviving +row - [`TaskRegistry.TranslatorForTaskParameterValue`](../../src/Build/Instance/TaskRegistry.cs#L1870) +(`IL2057`) - reconstructs a task parameter type from a **serialized** assembly-qualified name +(`Type.GetType(propertyTypeName)`) during task-host marshalling. + +| Strategy | Why it cannot apply | +| --- | --- | +| `[FeatureGuard]` (S3) | A feature guard silences only the `Requires*` trio (`IL2026`/`IL3050`/`IL30xx`). `IL2057` is a **dataflow** warning about an unanalyzable `Type.GetType(string)`; **no** guard or switch can suppress it. | +| `[DynamicallyAccessedMembers]` (S6) | DAM annotates a **`Type`**-typed target. Here the input is a **`string`** (a serialized assembly-qualified name) - there is no `Type` to annotate. | +| Remove the reflection (S1/S2) | The name is serialization-supplied; there is no compile-time-known type to substitute without deleting the feature. | +| Registration (S5) | The `` sibling site **was** retired this way - [`TaskParameterTypeRegistry`](../../src/Framework/TaskParameterTypeRegistry.cs) resolves known types reflection-free, with the by-name fallback gated by `EnableReflectiveTaskParameterTypes`. The serialization path is a **candidate** to reuse the registry, but the value-type half is open-world (`IsValueType` admits any `struct`). | + +If the name does not resolve, the path reports `InvalidProjectFileException` - observable. The +`[UnconditionalSuppressMessage]` is therefore the only mechanism for an accurate `IL2057` on an +unavoidable, serialization-supplied `Type.GetType(string)`. The allowed-type set and registry-feasibility +analysis are in [task-parameter-types.md](task-parameter-types.md). + +### Group C - host-supplied task type metadata + +[`TaskClassRegistration.CreateLoadedTypeFromFactory`](../../src/Framework/TaskClassRegistration.cs#L65) +(`IL2072`) exists only on the `RegisterTask(string, Func)` overload. The generic registration +path roots the task type and builds `LoadedType` eagerly; the factory-only overload supplies only a +delegate, so MSBuild learns the concrete task type by calling the factory and reading `GetType()`. + +Backlog work: give the factory registration path an explicit trim-safe type/metadata contract, or route +hosts to an API shape that carries the task type with DAM at registration time. Until then, hosts that use +the factory overload own rooting the task type's bindable public properties. diff --git a/documentation/aot/aot-trimming-strategy.md b/documentation/aot/aot-trimming-strategy.md new file mode 100644 index 00000000000..700fa2f931d --- /dev/null +++ b/documentation/aot/aot-trimming-strategy.md @@ -0,0 +1,450 @@ +# MSBuild trimming & Native AOT — design and strategy + +**Status:** Living. This is the **single source of truth** for *how MSBuild +decides what to do* with a trim/AOT-unfriendly code path. It collapses the design rationale and +the per-area tactics that were previously scattered across the AOT specs into one decision +framework, a catalog of concrete strategies, and the operating rules for driving suppressions and +annotations down. + +It is deliberately *strategy*, not *mechanics*. For the annotation model itself — what each +attribute does, the analyzer-vs-trimmer split, the IL4000 gotcha, feature-switch internals — +read the companion how-to and keep this document as the layer above it. + +See the [folder README](README.md) for the full document map. The two most relevant companions: +the **mechanics how-to** ([managing-trimming-and-aot.md](managing-trimming-and-aot.md)) for the +annotation model, and the **live tracker** ([aot-trim-suppressions.md](aot-trim-suppressions.md)) for +every active suppression. Known Backlog work is tracked in [follow-up-work.md](follow-up-work.md). + +--- + +## 1. Why we are doing this + +MSBuild is being made trim/AOT-capable so an **AOT-compiled host - the `dotnet` CLI -** can run +the MSBuild object model **in-process** (MSBuild compiled into the host). The host evaluates and +inspects projects directly; when it needs something an AOT image cannot do (load a task, SDK +resolver, logger, or build check by reflection; emit code at runtime), it must **detect that and +fall back** to a JIT MSBuild (the managed CLI, or `MSBuild.exe` out of process). + +Two facts follow and shape everything below: + +1. **Evaluation is the high-value, achievable target; execution is the hard tier.** Evaluating a + project - reading its properties, items, imports, conditions - is almost entirely MSBuild's own + managed code and is reachable under trimming. Executing targets pulls in arbitrary third-party + task assemblies discovered at runtime and is structurally reflective. The plan is to keep + evaluation trim-clean now and make execution **opt-in / closed-world** later. The SDK-facing + evaluation and execution surfaces are mapped in + [sdk-msbuild-object-model-audit.md](sdk-msbuild-object-model-audit.md). +2. **The fallback only works if MSBuild fails *observably*.** That is the overriding design + criterion (next section). A silent no-op or a crash deep in the engine gives the host nothing + to branch on. + +--- + +## 2. The overriding design criterion: fail observably, never silently + +Canonical statement: +[managing-trimming-and-aot.md §"MSBuild's overriding design criterion"](managing-trimming-and-aot.md#msbuilds-overriding-design-criterion-fail-observably-never-silently). +In one line: **express intent, or fail loudly — never suppress a real problem, never go silent, +never crash.** + +- **No silent failures.** A path that cannot work under trim/AOT must surface a build **error** (or + set a host-readable property) — never quietly do nothing or return a wrong result. Dropping a + project's expressed intent (a custom task, resolver, check) without a word is a silent failure. +- **No crashes.** An unhandled exception or `PlatformNotSupportedException` deep in the engine is + *worse* than an error — a host cannot cleanly fall back from a crash. Convert incompatible paths + into clean, reported errors. +- **A suppression may never hide a real problem.** `[UnconditionalSuppressMessage]` is legitimate + **only** for a provable false positive. Suppressing an *accurate* warning hides exactly the + signal the host relies on to fall back. + +--- + +## 3. Principles (the rules this document enforces) + +These encode the explicit direction for this effort: + +- **P-A — No unjustified suppressions.** Every `[UnconditionalSuppressMessage]` for an IL rule must + be tracked as `Vetted`, `Investigate`, or `Backlog`. `Vetted` means the warning is a false positive: + code provably trim/AOT-safe that the analyzer simply cannot see, with a `Justification` that states + the invariant. `Backlog` means the warning is accurate and requires additional feature work; it is + not an accepted end-state. (This is also the official .NET guidance: a suppression is valid only + when the warning "doesn't represent a real issue at runtime."[^suppress]) +- **P-B — Public API may be annotated, but not casually.** We **can** put + `[RequiresUnreferencedCode]` / `[RequiresDynamicCode]` / `[DynamicallyAccessedMembers]` on + **public** surface when the API is structurally reflective and no trim-safe contract exists. It is + a public-surface decision, not a convenient way to quiet a warning. Preview annotations remain + candidates for removal before the surface becomes stable; the public task-factory interface RUC is + in that bucket. +- **P-C — Prefer feature checks to isolate AOT-unfriendly code.** When a path is incompatible under + AOT but fine under the JIT, gate it behind a **feature check** (`[FeatureSwitchDefinition]`, + optionally `[FeatureGuard]`, or `RuntimeFeature.IsDynamicCodeSupported`). The trimmer folds the + switch to a constant and **physically removes** the unsafe branch from the AOT image, so the + warning is gone with no suppression *and* the dangerous code does not ship. +- **P-D — A gated-off path must fail observably or degrade to an expected value.** The disabled + branch of a feature check must do exactly one of two things (§5): **(a)** raise a useful, + reported error so the host can fall back, or **(b)** return a value that is *correct and + actionable* in an AOT host (e.g. "no custom resolver available; use the in-box one"). Never an + unreported skip. +- **P-E — Keep the warning gate and inventories honest.** `Microsoft.Build` must stay clean on the + analyzed TFM, the AOT harness must publish and run, and the live trackers + ([aot-trim-suppressions.md](aot-trim-suppressions.md) and + [aot-annotation-map.md](aot-annotation-map.md)) must be updated in the same change that moves a + warning, suppression, or annotation. The tracker is not archival decoration; it is the reviewable + contract for every remaining exception. +- **P-F — Treat `Backlog` as pending feature work, not a waiver.** A `Backlog` suppression requires + additional feature work. Observable failure is the minimum safety bar while that work is pending; + it is not a reason to call an accurate warning resolved. + +Decision order when you hit (or are about to write) an IL warning: + +```mermaid +flowchart TD + W["IL trim/AOT warning on a path"] --> Q0{Is the reflection
actually needed?} + Q0 -->|not needed| ELIM["S1 Remove the reflection
no annotation, no suppression"] + Q0 -->|needed| Q1{Members statically
known?} + Q1 -->|yes| DAM["S6 DynamicallyAccessedMembers
machine-checked"] + Q1 -->|no| Q2{Incompatible only
under AOT, fine under JIT?} + Q2 -->|yes| FEAT{Disabled branch
behavior?} + FEAT -->|return an actionable value| S3["S3 Feature check + expected default"] + FEAT -->|can only fail| S2["S2 Feature check + rich reported error"] + Q2 -->|inherently reflective| Q3{Can a closed-world
registration replace probing?} + Q3 -->|yes| REG["S5 Type-injection / registration API"] + Q3 -->|no| Q4{Is the warning a
provable false positive?} + Q4 -->|yes| SUP["S8 UnconditionalSuppressMessage + vetted Justification"] + Q4 -->|no| Q5{Stable structurally
reflective public boundary?} + Q5 -->|yes| RUC["S7 honest RequiresUnreferencedCode to the public boundary"] + Q5 -->|no| BACKLOG["Backlog: feature work required"] +``` + +`[UnconditionalSuppressMessage]` is the **bottom** of the order as a final state, reachable only when +the warning is a false positive. An *accurate* warning can remain only as `Backlog` while feature work + is pending; the final state is S2/S3 (gated + observable), S5 (registered), or S7 (honest RUC on a + stable public boundary that is truly structurally reflective). + +Apply the catalog with this loop: + +1. **Start from the warning or code path, not a subsystem tour.** Identify the reflective leaf and + the first boundary that makes the warning user-visible. +2. **Classify the row in the live tracker.** Use the same labels everywhere: `Vetted`, `Investigate`, + or `Backlog`. +3. **Apply the first matching strategy.** Remove vestigial reflection (S1), register a closed-world + type (S5), gate the JIT-only feature with an observable branch or expected default (S2/S3), add + honest RUC to a real boundary (S7), or add DAM when the member set is statically knowable (S6). +4. **Tighten while you are there.** Remove over-broad RUC on methods that only invoke delegates, + localize unavoidable DAM suppressions to the smallest member, and update the tracker when a row + moves or disappears. +5. **Verify the touched slice.** A change that affects the analyzed engine must keep + `Microsoft.Build` at 0 warnings / 0 errors on both `net10.0` and `net472`, publish and run the + AOT harness when the closure changes, and run the targeted tests for the switch/path being moved. + +--- + +## 4. Strategy catalog + +Each strategy is a concrete way to make a path trim/AOT-correct **without** an unjustified +suppression. Ordered roughly best-to-last-resort. The examples are real changes in the codebase. + +### S1 — Remove the reflection entirely + +The best fix: the reflection was vestigial, so delete it and read the value directly. Clears the +warning (including **dataflow** warnings, which *no* guard or switch can silence) with nothing left +behind. + +| Where | Was | Now | +| --- | --- | --- | +| `BuildEnvironmentHelper.CheckIfRunningTests` (`src/Framework/`) | reflected over `TestInfo.s_runningTests` — IL2026 **+ IL2075 dataflow** | direct `_runningTests ?? TestInfo.s_runningTests` read | +| `ProjectCollection.Version` (`src/Build/Definition/`) | `FileVersionInfo.GetVersionInfo(Assembly.Location)` — empty under single-file → throw | read `AssemblyFileVersionAttribute` directly (same value, no file path) | +| `NuGetFrameworkWrapper` (`src/Build/Utilities/`) | reflective wrapper | net-core partner `.Direct.cs` references `NuGet.Frameworks` directly (`new()`, no reflection); the reflective `.Reflection.cs` compiles only on net472 where the analyzer never runs | + +**When:** the reflected member is in the same closure, or a direct API exists. Always check this +first — it is the only fix that also clears IL2067–IL2095 dataflow warnings. + +### S2 — Feature check that **fails observably** with a rich error + +For a path that is meaningless or unsupported under AOT and that a project can *reach*: gate it +behind a feature switch whose **disabled** branch raises a reported, actionable error. Under the +JIT the switch is on and behavior is unchanged; under trimming the switch folds to a constant, the +reflective branch is **removed**, and the only thing left is the throw. This is P-C + P-D(a) and is +the reference shape for "reachable incompatible path." + +| Switch (in [`src/Framework/FeatureSwitches.cs`](../../src/Framework/FeatureSwitches.cs)) | Gated path | Disabled-branch failure | +| --- | --- | --- | +| `EnableSdkResolverDynamicLoading` (`[FeatureSwitchDefinition]`+`[FeatureGuard(RUC)]`) | `SdkResolverService.GetResolvers` — plugin SDK-resolver assembly load | `ProjectFileErrorUtilities.ThrowInvalidProjectFile("SdkResolverDynamicLoadingNotSupported")` → **MSB4282** | +| `EnableConfigurationFileToolsets` (`[FeatureSwitchDefinition]`) | `ToolsetReader.ReadAllToolsets` — `ToolsetConfigurationReader` / `System.Configuration` | `ErrorUtilities.ThrowArgument("OM_ConfigurationFileToolsetsNotSupported")` (and the whole `System.Configuration` dependency drops out of the trimmed closure) | +| `EnableCustomPluginProbing` (BuildCheck path) | `BuildCheckBuildEventHandler.HandleBuildCheckAcquisitionEvent` — custom-check load | `checkContext.DispatchAsErrorFromText(...)` — a reported build error instead of a silent skip | +| `EnableReflectiveTaskExecution` (`[FeatureSwitchDefinition]`+`[FeatureGuard(RUC)]`) | `TaskExecutionHost.FindTask` / `InitializeForBatch` / `SetTaskParameters` — the reflective task load/instantiate/bind leaves | `ProjectErrorUtilities.ThrowInvalidProject("ReflectiveTaskExecutionNotSupported")` → **MSB4283**. Gating the leaf freed the whole build-execution chain (nodes, `BuildRequestEngine`, `RequestBuilder`/`TargetBuilder`/`TaskBuilder`, `TaskHost`, their interfaces) of `[RequiresUnreferencedCode]`, removing 5 boundary suppressions — see [aot-trim-suppressions.md - Group A](aot-trim-suppressions.md#group-a--the-il2026-build-execution-boundary) | +| `RuntimeFeature.IsDynamicCodeSupported` (recognized IL3050 guard) | Task entry points that use runtime code generation, such as `XslTransformation.Execute()` and `GenerateManifestBase.Execute()` (`XslCompiledTransform`, `XmlSerializer`) | Reuse an existing task error resource with a clear detail, return `false`, and avoid crashing deep in the BCL under Native AOT. This guards **IL3050 only**; trim warnings (`IL2026`, `IL2070`, `IL2057`) still need RUC guards, DAM, registration, or Backlog feature work. | + +**Pattern:** `if (FeatureSwitches.X) { } else { }`. The +switch-guarded `then` branch is what the trimmer dead-strips; the `else` keeps the analyzer happy +(the RUC call stays in the `then`) and gives the host a clean failure. Authoring a new reported +error: see [authoring-errors-and-warnings skill](../../.github/skills/authoring-errors-and-warnings/SKILL.md). + +### S3 — Feature check with an **expected, actionable default** return + +Same mechanism, but the disabled branch returns a value that is *correct* for an AOT host rather +than an error — because falling back is the right behavior, not a failure (P-D(b)). The trimmer +removes the unsafe branch; the default is what AOT ships. + +| Check | Gated path | AOT default (why it is correct) | +| --- | --- | --- | +| `EnableCustomPluginProbing` | `MSBuildLoadContext.Load`, `TaskEngineAssemblyResolver.ResolveAssembly` | `return null` — defers to the default `AssemblyLoadContext`, which still throws `FileNotFoundException` if the assembly is genuinely needed (so it is *not* silent; it removes only MSBuild's *extra* reflective search) | +| `RuntimeFeature.IsDynamicCodeSupported` | `AssemblyLoadsTracker` (`AppDomain.AssemblyLoad` never fires under AOT) | early-return `EmptyDisposable.Instance` → ILC proves the tracker is never instantiated and strips its `Assembly.Location` read (clears **IL3000** with no suppression) | +| `RuntimeFeature.IsDynamicCodeSupported` | `BuildEnvironmentHelper.Initialize` / `GetProcessFromRunningProcess` | fall straight to the running process path (an empty `Assembly.Location` is meaningless under AOT anyway) | +| `RuntimeFeature.IsDynamicCodeSupported` | `NativeMethods.FrameworkCurrentPath` | empty string — every consumer already treats empty as ".NET Framework not found", which is correct (an AOT process has no .NET Framework) | +| `EnableAllPropertyFunctions` (default **false**) | property-function *type probing* | the curated allowlist is the only path; the wide "probe any assembly" branch is removed | +| `RestrictPropertyFunctionReceivers` (trimmed default **true**) | instance "dotting-in" receiver set | bounded, side-effect-free receiver allowlist (`PropertyFunctionReceiver`) — see [property-functions-reachability.md §10](property-functions-reachability.md) | + +**When:** the gated feature has a sensible "not available here" behavior the rest of the engine +already copes with. The default must be **observable-compatible** — i.e. it must not mask a needed +operation (the `Load → null → default loader → FileNotFoundException` chain is observable; a bare +swallow would not be). + +### S4 — Overloads / fast-path splits that **avoid** the reflective code path + +Restructure so the common case never reaches the reflective member, and the reflective member is +either isolated behind honest RUC or only used by an opt-in overload. + +| Where | Split | +| --- | --- | +| `ProjectCollection` ctors (`src/Build/Definition/`) | master ctor split into a **private trim-safe core** (builds the collection, registers ordinary loggers) + a thin **`[RequiresUnreferencedCode]` public wrapper** that registers forwarding loggers. Overloads that pass no forwarding loggers (and `GlobalProjectCollection`) chain to the core and are honestly non-RUC. Cleared **5× IL2026** with no public-API change. | +| `TypeLoader.Create()` (`src/Shared/`) | factory stores the interface *as data* and does the `GetInterface` match inside the already-`[RequiresUnreferencedCode]` load path → retired an **IL2070** (and the per-filter `Func` delegate) | +| SDK resolution | the in-box `DefaultSdkResolver` is a **reflection-free** directory probe tried **first**; only plugin resolvers reflect, and they are gated (S2). The reflection-free path is the default. | + +**When:** a method mixes a trim-safe majority with a reflective minority. Separate them so only the +minority carries the cost. + +### S5 — Type-injection / registration APIs for lookup tables (closed-world) + +Replace *probing for types by name at runtime* with *the host handing us the type up front*. The +registered type is referenced from the host's compile graph, so the trimmer can see and preserve +it. This is the **only** way to make an otherwise open-world plugin lookup trim-safe, and it is how +the runtime itself solves the same problem (§6). + +| API | Replaces | +| --- | --- | +| `SdkResolver.Register(SdkResolver)` (`src/Framework/Sdk/SdkResolver.cs`) — host pushes a pre-constructed resolver, folded into `SdkResolverLoader.GetDefaultResolvers()` on the reflection-free pass ([sdk-resolver-host-registration-api.md](../specs/sdk-resolver-host-registration-api.md)) | discovering & `Assembly.LoadFrom`-ing SDK-resolver plugins | +| `Task.RegisterTask(...)` / `TaskClassRegistry.Register(...)` — host supplies the concrete task type at registration, with DAM rooting the public parameterless constructor and public properties ([task-class-registration-api.md](../specs/task-class-registration-api.md)) | discovering a task type by name and constructing/binding it through the public task-factory interface path | +| `TaskParameterTypeRegistry.RegisterValueType(...)` — host supplies known task parameter value types so `` parsing resolves known names before the by-name fallback ([task-parameter-type-registration-api.md](../specs/task-parameter-type-registration-api.md)) | `Type.GetType(string)` for known task parameter value types | +| `PropertyFunctionReceiver` allowlist — a closed `FrozenSet` of side-effect-free receiver types ([property-functions-reachability.md §10](property-functions-reachability.md)) | dotting into the open-ended BCL type graph | +| **Backlog:** explicit metadata for `RegisterTask(string, Func)` and generated task parameter binders ([task-factory-aot.md §7](task-factory-aot.md)) | lazy `_createInstance().GetType()` metadata discovery and reflective property get/set over registered task types | + +The annotation recipe for "we still reflect, but only over *registered* types" is in §6 — it is the +part the user asked us to get right, and it has a direct runtime precedent in +`TypeDescriptor.RegisterType`. + +### S6 — `[DynamicallyAccessedMembers]` (preserve members; localize to the smallest member) + +When the reflection *is* needed and the member kinds *are* statically known, annotate the flow. +This is machine-checked end-to-end and is the **preferred** annotation. Localize an unavoidable +residual to the smallest possible member so the surrounding code stays clean. + +| Where | Annotation | +| --- | --- | +| `TypeExtensions.InvokeMemberPublicOnly` (`src/Framework/Utilities/`) | receiver annotated with the exact public-member surface it binds | +| `Expander.FunctionBuilder.SetReceiverType` (`src/Build/Evaluation/`) | DAM on the one-line backing-field setter; the single residual IL2069 lives here so `Function.ExtractPropertyFunction` is suppression-free | +| `ITaskFactory.TaskType` (`src/Framework/`) | `[DynamicallyAccessedMembers(PublicProperties)]` on the public property | + +### S7 — Honest `[RequiresUnreferencedCode]` to a stable public boundary (P-B) + +When the path is **structurally** reflective (runtime-named types that don't exist in the trimmed +world), the API is a stable public contract, and no closed-world registration or feature gate can express +the behavior, mark the public contract honestly and propagate the requirement up the real call chain. +This is *not* a defeat - it tells the caller the truth and lets a host fall back. + +Use S7 only after asking whether the public surface should instead be changed before it stabilizes. +Preview-era annotations are Backlog when feature work can remove them. The current +`ITaskFactory` / `ITaskFactory2` / `ITaskFactory3` `Initialize` / `CreateTask` RUC is in that category: +registered and intrinsic tasks already bypass those public interface methods through non-interface +construction paths, so the strategy is to prove any remaining interface path analyzer-clean, +feature-gated, or observably unsupported and then remove those RUC annotations before the surface is +treated as stable. + +### S8 — Vetted false-positive suppression (the only final suppression) + +`[UnconditionalSuppressMessage]` is allowed **only** when the analyzer is wrong: the code is +provably safe by an invariant it cannot see, and the `Justification` states that invariant. These +are the `Vetted` rows in the tracker. Accurate warnings that still have suppressions are `Backlog`, +which means they require additional feature work. + +| Where | Why it is a false positive | +| --- | --- | +| `TypeExtensions.CreateDefault` (IL2067) | only invoked for value types (guarded by `IsValueType`), which always have a public parameterless ctor | +| `TypeExtensions.InvokeMemberPublicOnly` (IL2070) | sole caller rejects `BindingFlags.NonPublic`; receiver's public surface preserved via DAM | +| `TypeExtensions.GetAssemblyPath` (IL3000) | the generic `Assembly.Location` self-discovery primitive, correct in a hosted/JIT layout, hardened to return the empty path rather than throw | +| Property-function receiver dataflow (`FunctionBuilder.SetReceiverType`, `Function.Execute`, `Function.GetTypeForStaticMethod`) | receiver sets are bounded to preserved-member allowlists (`AvailableStaticMethods` and `PropertyFunctionReceiver`), with `RestrictPropertyFunctionReceivers` substituted `true` under trim and `Constants.PropertyFunctionMembers` preserving the reflected surface | +| `Enum.GetValues(Type)` rooted through property-function allowlists | rooted by `typeof(Enum)` but unreachable via property functions because authors cannot supply a `Type` argument (MSB4185/MSB4186), proven by AOT property-function tests | + +> The companion IL4000 `#pragma warning disable` on every `[FeatureGuard]` switch is **not** in this +> category — it is the BCL-sanctioned acknowledgement that the analyzer cannot model an +> `AppContext.TryGetSwitch` body, signed against the contract that the trimmer substitutes the whole +> getter. See [managing-trimming-and-aot.md §6.4](managing-trimming-and-aot.md#64-the-il4000-gotcha-the-definitive-explanation). + +### Current Backlog buckets + +Backlog means the warning is accurate or the subsystem is not trim/AOT-ready; it requires feature work. +The current buckets are: + +| Bucket | Strategy direction | +| --- | --- | +| Host-supplied task type metadata (`RegisterTask(string, Func)`) | S5/S6: add an explicit trim-safe type or metadata contract so MSBuild does not infer metadata by calling the factory and reading `GetType()` | +| Node forwarding-logger initialization | S2: reuse `EnableReflectiveLoggerLoading` at the out-of-proc node configuration leaf | +| Build-manager solution/logger/plugin boundary | S2/S5: gate solution-metaproject generation and project-cache plugin loading; keep logger/plugin initialization behind feature switches or registration paths | +| Serialized task parameter value types | S5: reuse `TaskParameterTypeRegistry` for serialized names where possible, keeping the open-world fallback isolated | +| Public task-factory interface RUC | S4/S7 review: prove remaining `ITaskFactory` interface call paths analyzer-clean, feature-gated, or observably unsupported, then remove preview RUC before the surface is treated as stable | +| `Microsoft.Build.Tasks` XML handling | Feature work for `XmlSerializer`, `XslCompiledTransform`, and `SignedXml`: use task-entry `RuntimeFeature.IsDynamicCodeSupported` guards for graceful IL3050 failure, evaluate source-generated or pre-generated XML serializers for genuinely AOT-capable paths, and keep IL2xxx trimming work separate. Smaller Backlog rows cover attribute reflection and assembly metadata. | + +--- + +## 5. Two flavors of a gated-off branch — choose deliberately + +Every feature-checked path (S2/S3) must pick one, and the choice is a design decision, not an +accident: + +| | **Fail observably (S2)** | **Expected default (S3)** | +| --- | --- | --- | +| Use when | the project *expressed intent* the AOT host cannot honor (a custom resolver, a config-file toolset, a custom check) | "not available here" is a *correct, normal* outcome the engine already handles | +| Disabled branch | raises a reported error (`MSBxxxx`, `ArgumentException`, dispatched build error) | returns a value (`null`, empty path, the in-box default) the caller copes with | +| Host sees | a clean error → fall back to JIT MSBuild | nothing to fall back from — it just works with the reduced surface | +| Trap to avoid | — | the default must still be **observable-compatible**: it may not swallow a genuinely-needed operation (verify the *downstream* still throws if the thing is truly required) | + +If you cannot honestly put a path in either column — i.e. the disabled branch would have to silently +do nothing useful — then a feature check is the **wrong** tool and you owe either S5 (register it) or +S7 (honest RUC). + +--- + +## 6. AOT-safe reflection over *registered* types — the annotation recipe + +This is the recipe for S5 ("we still reflect, but only over types the host registered") and the +research the effort specifically asked for. The principle: a generic registration entry point +carries the reflection requirement on its **type parameter**, so the trimmer preserves exactly the +needed members of every concrete type that is ever registered, and the call site does no +unannotated reflection. + +**The pattern.** + +```csharp +// Registration entry point: the DAM on T preserves the ctor of every registered type. +public static void Register<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] T>() + where T : ITask, new() +{ + s_factories[typeof(T).Name] = static () => new T(); // typed delegate — no Activator.CreateInstance(Type) +} + +// Lookup + create: reflection-free; the delegate closes over `new T()`. +ITask Create(string name) => s_factories[name](); +``` + +Why it is trim-safe: the `[DynamicallyAccessedMembers]` on `T` flows to every `Register()` +call; the trimmer roots `Concrete`'s parameterless ctor; `new T()` (and the captured delegate) are +ordinary statically-analyzable code. No `Activator.CreateInstance(Type)`, no `Type.GetType(string)`. + +**Choosing the member kinds** (full list: +[DynamicallyAccessedMemberTypes](https://learn.microsoft.com/dotnet/api/system.diagnostics.codeanalysis.dynamicallyaccessedmembertypes)): + +| Reflection you do on the registered type | DAM kind to require on the registration param | +| --- | --- | +| `new T()` / `Activator.CreateInstance` | `PublicParameterlessConstructor` | +| construct with args | `PublicConstructors` | +| read/write public properties (e.g. task parameter binding) | `PublicProperties` | +| invoke public methods | `PublicMethods` | +| combination | bit-or them (`PublicParameterlessConstructor | PublicProperties`) | + +**Runtime precedent — `System.ComponentModel`.** The runtime added exactly this shape for +TypeDescriptor/TypeConverter under trimming: +[`TypeDescriptor.RegisterType()`](https://learn.microsoft.com/dotnet/api/system.componentmodel.typedescriptor.registertype) +(net9+) — *"Registers the type so it can be used by reflection-based providers in trimmed +applications."* Its `T` carries a DAM annotation; calling it roots the members the +reflection-based provider will touch, and the provider then works without a trim warning. When an +MSBuild lookup table needs reflection over host-supplied types, this is the model to copy. + +**Adjacent AOT-friendly primitives worth preferring:** + +- **`Activator.CreateInstance()`** (the generic form) is statically analyzable and AOT-friendly; + **`Activator.CreateInstance(Type)`** is not (it needs the `Type` to carry + `PublicParameterlessConstructor` and warns otherwise). +- **`[DynamicDependency]`** can *root* members the trimmer would drop, but it does **not** silence a + warning by itself and is a last resort — prefer the DAM-on-generic-parameter flow above.[^suppress] +- For generated metadata specifically, the same "generator emits a static registry" idea is the + Backlog direction for registered task parameter binders ([task-factory-aot.md §7](task-factory-aot.md)). + +**When registration is *not* enough.** If the type name is genuinely open-world — a +`` against an assembly the host never references, or a +user-/serialization-supplied `Type.GetType(name)` — no annotation can preserve a type the trimmer +cannot see. Stable public open-world APIs may need S7; implementation paths stay Backlog until they are +gated, registered, or narrowed. Registration covers the *closed-world subset* (e.g. the SDK's own tasks +compiled into a future AOT `dotnet build`); AOT support is necessarily a subset. + +--- + +## 7. Vetting against the `dotnet-aot-compat` skill + +The repo's [`dotnet-aot-compat`](../../.github/skills/dotnet-aot-compat/SKILL.md) skill is a solid +*mechanical warning-cleanup* recipe and is correct on the fundamentals MSBuild also follows: + +- **Keep:** prefer `[DynamicallyAccessedMembers]`; fix innermost-first and let cascades guide you; + propagate RUC to a public boundary; preserve annotation flow (don't box `Type` through `object[]`); + the trimmed **test-app + `TrimmerRootAssembly`** validation pattern; multi-TFM gotchas and polyfills. + +But it is a *generic app-cleanup* skill, and for MSBuild's **engine/host** problem it **falls short** +in specific, important ways. Where this document and the skill disagree, **this document governs for +MSBuild**. + +1. **"NEVER use `[UnconditionalSuppressMessage]`" and "NEVER use `#pragma warning disable`" are too + absolute.** The skill bans both outright. That contradicts the **official** .NET guidance, which + sanctions `[UnconditionalSuppressMessage]` for a warning that "doesn't represent a real issue at + runtime" with a stated `Justification`,[^suppress] and it contradicts MSBuild's reality: we keep + **vetted false-positive** suppressions (S8) and we use the **BCL-sanctioned `#pragma warning + disable IL4000`** on every feature-guard property (the analyzer provably cannot model an + `AppContext.TryGetSwitch` body). The correct rule is **P-A** ("no *unjustified* suppression"), + not "none." +2. **It omits feature switches entirely.** `[FeatureSwitchDefinition]` / `[FeatureGuard]` / + `RuntimeFeature.IsDynamicCodeSupported` — MSBuild's **primary** tool for isolating AOT-unfriendly + code so the trimmer *removes* it (S2/S3) — does not appear in the skill at all. For an engine that + must keep JIT behavior while shedding code under AOT, this is the single biggest gap. +3. **It omits the "fail observably" design criterion.** The skill's world is "make the warning go + away." MSBuild's is "the AOT host must be able to *detect* an unsupported path and fall back," so a + gated-off branch must raise a reported error or return an actionable default (§2, §5). The skill + has no concept of the disabled-branch contract. +4. **It is `JsonSerializer`-centric.** Its headline "Strategy C" (source-generated JSON) is the + recommended first move and assumes IL2026/IL3050 are dominated by `JsonSerializer`. MSBuild has no + `System.Text.Json` on the evaluation/execution hot path; that strategy is inapplicable here. +5. **It omits type-injection / registration APIs.** The closed-world registration pattern (S5) and + its annotation recipe (`Register<[DynamicallyAccessedMembers(...)] T>()`, the + `TypeDescriptor.RegisterType` precedent, `Activator.CreateInstance()`) — the way MSBuild + turns an open-world plugin lookup trim-safe — is absent. The skill treats reflection only as + something to *annotate*, never as something to *restructure into a closed world*. +6. **It omits single-file `IL3000` and the dead-strip exclusion pattern.** `Assembly.Location` + returning empty under single-file/AOT, and the `RuntimeFeature.IsDynamicCodeSupported` + early-return that lets ILC dead-strip a whole feature (S3 — `AssemblyLoadsTracker`, + `FrameworkCurrentPath`), are core MSBuild techniques the skill does not cover. +7. **It frames `[RequiresUnreferencedCode]` as a "last resort."** For a stable, structurally + reflective **public contract**, honest RUC on the boundary can be the correct outcome (P-B / S7), + not merely a last resort. But preview public annotations should still be challenged: if feature work + can make the path analyzer-clean, feature-gated, or observably unsupported, track it as Backlog and + remove the public RUC before the surface is treated as stable. +8. **Its "don't explore the codebase / stay warning-driven / cap at 3 iterations then escalate to + RUC" loop is the wrong altitude for the hard cases.** That tight loop is fine for annotating a + `Type` parameter, but MSBuild's hard paths (task loading, SDK resolution, property functions, + toolset config) need a **design decision** — remove vs register vs gate vs honest-RUC — that the + warning text alone cannot make. This document exists precisely for those decisions; use the skill + for the mechanical cascade *within* a chosen strategy. + +Net: treat the skill as the **how-to for executing S6/S7 mechanically once a strategy is chosen**, +and treat this document as the **strategy chooser** (S1–S8) and the keeper of the fail-observably +contract. + +> The other AOT-adjacent skills — [`cswin32-interop`](../../.github/skills/cswin32-interop/SKILL.md) +> and [`cswin32-com`](../../.github/skills/cswin32-com/SKILL.md) — are **interop mechanics** +> (CsWin32-generated P/Invoke and struct-based COM bindings), not strategy. They are complementary: +> reach for them when a specific Win32/COM call needs an AOT-friendly binding. They do not speak to +> the remove-vs-gate-vs-register-vs-annotate decision this document owns, and they have no gap to +> flag for that purpose. + +[^suppress]: [Prepare .NET libraries for trimming — "UnconditionalSuppressMessage"](https://learn.microsoft.com/dotnet/core/deploying/trimming/prepare-libraries-for-trimming#unconditionalsuppressmessage): +a suppression is valid for code whose intent "can't be expressed with the annotations" and that +"generates a warning but doesn't represent a real issue at runtime," and you are "responsible for +guaranteeing the trim compatibility … based on invariants you know to be true by inspection and +testing." `[DynamicDependency]` is called out as a **last resort** that keeps members but does not +silence warnings on its own. diff --git a/documentation/aot/buildcheck-reflection-removal.md b/documentation/aot/buildcheck-reflection-removal.md new file mode 100644 index 00000000000..c73dc0af533 --- /dev/null +++ b/documentation/aot/buildcheck-reflection-removal.md @@ -0,0 +1,302 @@ +# BuildCheck: execution model, discovery, and a proposal to remove reflection + +**Status:** Design proposal. Option 1 (fail custom checks observably under trim/AOT) is implemented; Options 2-3 are not. + +This document explains how the BuildCheck (MSBuild analyzer) system works: when checks +run relative to evaluation and execution, how they are discovered and registered, how +they are invoked, and which parts of the system are public. It ends with a concrete +proposal to remove reflection from the model, either permanently or only in +trimmed/AOT scenarios. + +It complements the existing specs: + +* [BuildCheck - Design Spec](../specs/BuildCheck/BuildCheck.md) (user point of view) +* [BuildCheck - Architecture and Implementation Spec](../specs/BuildCheck/BuildCheck-Architecture.md) (internal) +* [Custom BuildCheck Analyzers](../specs/BuildCheck/CustomBuildCheck.md) + +All file references point at the implementation under +[`src/Build/BuildCheck`](../../src/Build/BuildCheck) and +[`src/Framework/BuildCheck`](../../src/Framework/BuildCheck). Line numbers drift; +search by member name if a link looks stale. + +--- + +## TL;DR (direct answers) + +* **When do checks run? Are they part of evaluation, or only execution?** + Neither, exclusively. BuildCheck is a **cross-cutting observer of the entire build**. + It consumes build data as that data is produced and fires each check's registered + callbacks **synchronously as the relevant data arrives**. That data spans *both* + phases: evaluation-derived data (evaluated properties/items, property reads/writes, + environment-variable reads, imports) and execution data (task invocations, + project start/finish). So evaluation-oriented checks effectively run during/just + after evaluation, and execution-oriented checks run during execution - but the check + code itself lives in the BuildCheck infrastructure (a logger plus an engine + component), not inside the evaluator or the task host. + +* **How are checks discovered?** + Two kinds. **Built-in ("inbox") checks** are a compile-time list instantiated with + `new()` (no reflection). **Custom checks** ship as NuGet packages, are announced by a + `$([MSBuild]::RegisterBuildCheck())` property-function call during evaluation, + and are loaded by reflection from the assembly path. + +* **How are they run?** + Each check's `Initialize` method registers callbacks for the data categories it cares + about. The infrastructure translates incoming build data into a small typed object + model and invokes those callbacks. A check reports findings via `ReportResult`; the + infrastructure filters/severity-maps them and emits them through the normal MSBuild + logging pipeline as warnings/errors/messages. + +* **Is any of it public?** + Yes. The authoring surface is **public but `[Experimental]`**, in namespace + `Microsoft.Build.Experimental.BuildCheck` (`Check`, `CheckRule`, `CheckConfiguration`, + `BuildCheckResult`, the `CheckData` object model, etc.). The infrastructure + (manager, acquisition module, event handler, event args) is internal. + +* **Where is the reflection?** + In exactly one place: custom-check loading in + [`BuildCheckAcquisitionModule.CreateCheckFactories`](../../src/Build/BuildCheck/Acquisition/BuildCheckAcquisitionModule.cs) + (`Assembly.LoadFrom` -> `GetExportedTypes` -> `IsAssignableFrom` -> + `Activator.CreateInstance`). Built-in checks use none. + +--- + +## How BuildCheck works (in brief) + +The TL;DR above is all the orientation the proposal below needs; the full execution model +(logger-plus-engine-component hosting, live/replay modes, discovery and registration, the check +lifecycle, the event-to-callback pipeline, and the public `[Experimental]` authoring surface) lives +in the BuildCheck specs linked at the top of this document and is not repeated here. + +The one fact the rest of this document turns on: **built-in checks are a compile-time list +instantiated with `new()` (no reflection); custom checks are loaded by reflection from a NuGet +assembly path.** Everything below follows from that split. + +--- + +## Where reflection lives + +The entire reflective surface of BuildCheck is the custom-check loader, +[`BuildCheckAcquisitionModule.CreateCheckFactories`](../../src/Build/BuildCheck/Acquisition/BuildCheckAcquisitionModule.cs): + +```csharp +// 1. Load a third-party assembly from a path +assembly = s_coreClrAssemblyLoader.LoadFromPath(path); // net core +// assembly = Assembly.LoadFrom(path); // net472 + +// 2. Enumerate its public types +Type[] availableTypes = assembly.GetExportedTypes(); + +// 3. Find the ones that are checks +Type[] checkTypes = availableTypes.Where(t => typeof(Check).IsAssignableFrom(t)).ToArray(); + +// 4. Build a factory per check that instantiates it +checksFactories.Add(() => (Check)Activator.CreateInstance(checkCandidate)!); +``` + +That is the only place. The trim/AOT analyzer therefore flags a chain of +`[RequiresUnreferencedCode]` members rooted here: + +| Member | File | Why | +| --- | --- | --- | +| `IBuildCheckAcquisitionModule.CreateCheckFactories` | `Acquisition/IBuildCheckAcquisitionModule.cs` | contract for the loader | +| `BuildCheckAcquisitionModule.CreateCheckFactories` | `Acquisition/BuildCheckAcquisitionModule.cs` | the reflection above | +| `IBuildCheckManager.ProcessCheckAcquisition` / impl | `Infrastructure/IBuildCheckManager.cs`, `BuildCheckManagerProvider.cs` | calls the loader | +| `RegisterCustomCheck`, `SetupSingleCheck` | `Infrastructure/BuildCheckManagerProvider.cs` | invoke the reflectively-built factories | + +Two call sites had to either propagate `[RequiresUnreferencedCode]` or suppress it, +because they sit on boundaries that cannot carry the attribute (an event handler and a +mixed built-in/custom setup loop): + +* `BuildCheckBuildEventHandler.HandleBuildCheckAcquisitionEvent` (the event-dispatch + boundary). +* `BuildCheckManagerProvider.SetupChecksForNewProject` (materializes *all* checks for a + project - built-in and custom - so it cannot simply be marked RUC without implying + built-in checks are unsafe). + +Built-in checks contribute **zero** reflection: their factories are `Construct()` +(`new()`), and the type list is referenced at compile time. + +--- + +## Proposal: removing reflection + +The original brief was to remove reflection "permanently, or in AOT scenarios only." +Answering honestly means separating two publish modes that are usually lumped together as +"trim/AOT" but that have **opposite** capabilities here. + +### Trimming is not AOT, and that decides what is possible + +* **Trimming (`PublishTrimmed`)** still uses the JIT (or ReadyToRun with a JIT fallback). + **Runtime assembly loading works**: `Assembly.LoadFrom` of a custom-check package + succeeds and the loaded code runs. The trimmer's only concern is that it could not see, + and may have removed, types that the loaded assembly - or MSBuild's reflection over it - + depends on. So the BuildCheck reflection (`GetExportedTypes` / `IsAssignableFrom` / + `Activator`) is a trim-*correctness* warning (IL2026 / IL2070), not a hard block. + **Custom checks can work under trimming.** +* **Native AOT (`PublishAot`)** has no JIT. **An external managed assembly cannot be + loaded and executed at run time at all** - there is no compiled code for its types. A + custom check ships as a NuGet assembly that was never part of the AOT image, so it can + never load or run under AOT. This is a hard platform limitation, not an annotation + problem: **no design that loads a third-party check by path can support Native AOT.** + +So: + +* **Built-in checks** (compiled into `Microsoft.Build`, instantiated with `new()`) work + everywhere, including AOT. +* **Custom checks** are categorically **impossible under Native AOT** and must be disabled + there. Under trimming they remain possible; their only problem is the type-reflection + warnings. + +### What "remove reflection" can and cannot mean + +* You **cannot** remove *all* reflection while still supporting load-by-path third-party + checks: loading the assembly (`Assembly.LoadFrom`) is irreducible and is itself + `[RequiresUnreferencedCode]`. +* You **can** remove the *type-discovery + `Activator`* reflection MSBuild performs over + the loaded assembly - but that only helps **trimming**, because under AOT the assembly + cannot load in the first place. +* The only way to make BuildCheck *fully* reflection-free permanently is to **stop + supporting load-by-path third-party checks** (ship only built-in or compile-time- + referenced checks). + +### Option 1 - Fail custom checks observably under trim/AOT (the only AOT story) + +Gate the acquisition path behind the existing +[`FeatureSwitches.EnableCustomPluginProbing`](../../src/Framework/FeatureSwitches.cs) +`[FeatureGuard(typeof(RequiresUnreferencedCodeAttribute))]` switch - the same mechanism +already used for plugin-dependency probing (`MSBuildLoadContext`) and task-assembly +resolution (`TaskEngineAssemblyResolver`). The switch is `true` under the JIT and +substituted `false` when trimmed (via a `RuntimeHostConfigurationOption`). + +Because a project *explicitly* requests a custom check, the disabled branch must obey +MSBuild's [fail-observably design criterion](managing-trimming-and-aot.md#msbuilds-overriding-design-criterion-fail-observably-never-silently): +it does **not** silently drop the request, it raises a build **error** naming the check it +could not load, so an AOT host can detect the failure and fall back to a JIT MSBuild. +(Unlike `MSBuildLoadContext`/`TaskEngineAssemblyResolver`, which return `null` and defer to +the default resolver - which itself fails observably when an assembly is genuinely missing - +acquisition has no such downstream failure, so it must raise the error itself.) + +```csharp +if (FeatureSwitches.EnableCustomPluginProbing) +{ + // existing reflective acquisition +} +else +{ + // The project asked for a custom check this host cannot load by reflection. Fail + // observably instead of returning silently, so the host can detect it and fall back. + string message = ResourceUtilities.FormatResourceStringStripCodeAndKeyword( + out string? errorCode, + out string? helpKeyword, + "BuildCheckCustomCheckNotSupportedInTrimmedHost", + acquisitionData.AssemblyPath); + + checkContext.DispatchAsErrorFromText( + null, + errorCode, + helpKeyword, + string.IsNullOrEmpty(acquisitionData.ProjectPath) + ? BuildEventFileInfo.Empty + : new BuildEventFileInfo(acquisitionData.ProjectPath), + message); +} +``` + +* **Under the JIT:** unchanged - custom checks load and run exactly as today. +* **Under trim/AOT:** custom-check acquisition cannot run, so the guard's disabled branch + **emits a build error** naming the check it could not load (it never silently drops the + project's request); built-in checks run normally; the trimmer dead-strips the reflective + branch and the IL2026 warnings disappear with no suppression. +* **This is the only correct behavior under Native AOT** - you cannot load the check + assembly at all, so failing observably (rather than attempting and crashing, or skipping + in silence) is the only option. It is also a reasonable, detectable failure under + trimming. +* **Status: implemented.** The event-handler entry point + (`HandleBuildCheckAcquisitionEvent`) is guarded by `EnableCustomPluginProbing`, and its + disabled branch **dispatches a localized MSB4284 build error** instead of silently returning (the + observable-failure contract). `ProcessCheckAcquisition` and `CreateCheckFactories` keep + `[RequiresUnreferencedCode]` and are reachable only through that guarded entry, so the + analyzer treats the reflective acquisition as removed under trim. The materialization loop + turned out to need **no** annotation: `SetupChecksForNewProject` -> `SetupSingleCheck` and + `RegisterCustomCheck` only invoke already-built `CheckFactory` delegates (built-in = + `Construct()`; custom factories are built, and stay RUC, in `CreateCheckFactories`), so + their former `[RequiresUnreferencedCode]` and the `SetupChecksForNewProject` + `[UnconditionalSuppressMessage]` were over-broad and have been removed. No suppression + remains on the acquisition path. +* **Pros:** tiny, low-risk, preserves full JIT behavior, removes the suppressions, and + fails observably (a build error a host can detect and fall back from) rather than + silently dropping the check. +* **Cons:** a trimmed/AOT MSBuild **cannot** run a project's custom checks - it reports a + build error for each requested check (an intentional, detectable behavior change for + those configurations), and the reflection code still exists in the JIT build. + +### Option 2 - Reduce reflection under *trimming* via self-registration (does NOT enable AOT) + +This option is **only about the trimmed-but-jitted case.** It removes the type-discovery +and `Activator` reflection so custom checks keep working under trimming with the IL2026 / +IL2070 warnings gone. It does **not** make custom checks work under Native AOT: the loaded +assembly still has to execute, and AOT cannot execute a separately loaded assembly. Under +AOT this path is still skipped by Option 1. + +Replace MSBuild's type discovery with an explicit registration contract emitted by the +custom-check template's source generator - for example a **module initializer** that +self-registers when the assembly is loaded: + +```csharp +[ModuleInitializer] +internal static void Register() + => BuildCheckRegistry.Register(MyCheck.Rules, static () => new MyCheck()); +``` + +MSBuild's loader becomes: load the assembly (which runs the module initializer); the +assembly hands back typed `() => new MyCheck()` factories. No `GetExportedTypes`, no +`IsAssignableFrom`, no `Activator.CreateInstance` on the MSBuild side. + +```mermaid +flowchart LR + subgraph before[Before: type reflection in MSBuild] + b1[Assembly.LoadFrom] --> b2[GetExportedTypes] + b2 --> b3["IsAssignableFrom(Check)"] + b3 --> b4[Activator.CreateInstance] + end + subgraph after[After, trimming only: self-registration] + a1[Assembly.LoadFrom] --> a2[Module initializer runs] + a2 --> a3["BuildCheckRegistry.Register(ids, () => new MyCheck())"] + end +``` + +* **Pros (trimming only):** removes MSBuild-side **type** reflection (the IL2026 / IL2070 + on `GetExportedTypes` / `IsAssignableFrom` / `Activator`); the author's `new MyCheck()` + factories are trim-safe inside their own assembly; aligns with how source generators + self-register. +* **Cons:** does **not** help Native AOT at all - the `Assembly.LoadFrom` still cannot run + there, and it remains `[RequiresUnreferencedCode]`. It also changes the custom-check + **authoring contract** (the template must emit the registrar; existing checks recompile), + which is acceptable only because the API is `[Experimental]`, and needs a new public + `BuildCheckRegistry.Register(...)` API. + +### Option 3 - Permanent, total removal: drop load-by-path checks + +The only way to remove **all** reflection (including `Assembly.LoadFrom`) permanently and +in every mode is to stop supporting third-party checks loaded by path - ship only built-in +checks, or checks referenced at compile time. That deletes the custom-check plugin model +entirely. It is the only thing that makes the *whole* BuildCheck system AOT-capable with no +reflection, but it is a large product decision and is not recommended unless the plugin +model is judged not worth its cost. + +### Recommendation + +* **For AOT there is exactly one option: Option 1.** You cannot load a custom check under + Native AOT, so the correct behavior is to stop attempting acquisition and instead **fail + observably** (a build error per requested check) while built-in checks keep working - + letting an AOT host detect the failure and fall back to a JIT MSBuild. Completing the + `EnableCustomPluginProbing` guard across all four acquisition entry points *is* what + "remove reflection in AOT scenarios only" means in practice, and it is already started. +* **For trimming**, Option 1 (disable) is the simplest. If custom checks should keep + *working* under trimming, layer Option 2 (self-registration) on top to drop the + type-reflection warnings - but treat it as a trimming refinement, not an AOT enabler; it + still guards off under AOT. +* **Option 3** (dropping load-by-path checks) is the only route to a fully reflection-free + model and should be considered only if first-class AOT support for *all* checks ever + becomes a hard requirement. diff --git a/documentation/aot/follow-up-work.md b/documentation/aot/follow-up-work.md new file mode 100644 index 00000000000..66bd7ba4bf9 --- /dev/null +++ b/documentation/aot/follow-up-work.md @@ -0,0 +1,63 @@ +# MSBuild trim / Native AOT follow-up work + +**Status:** Living follow-up list. + +This is the canonical list of known work that remains after the initial trim/AOT annotation and host-registration work. It intentionally does not repeat the full analysis from the strategy, suppression, or per-area documents; each item links to the owning implementation surface and the deeper note that explains the design. + +All work here still follows the strategy guide's rule: **fail observably, never silently**. If a path cannot run under trimming or Native AOT, it must either be removed from the trimmed closure, replaced with a closed-world registration path, or fail with a reported error that a host can use to fall back to a JIT MSBuild. + +## Product follow-ups + +### 1. Gate project-cache plugin loading + +- **Strategy:** S2/S3 feature gate, likely a new `EnableReflectiveProjectCachePlugins` switch or a carefully-scoped reuse of `EnableCustomPluginProbing`. +- **Implementation surface:** [`ProjectCacheService`](../../src/Build/BackEnd/Components/ProjectCache/ProjectCacheService.cs) and the `BuildManager` / `Project` / `ProjectInstance` build entry points that reach it. +- **Why it remains:** project-cache plugins load assemblies from disk and reflect over their types, but unlike task execution, SDK resolver loading, BuildCheck acquisition, and logger loading, this subsystem is not yet behind a trim-time feature switch. +- **Expected shape:** keep JIT behavior unchanged; when disabled in a trimmed/AOT host and a cache plugin is configured, fail observably with a reported build error rather than attempting plugin reflection. +- **Deeper context:** [aot-annotation-map.md](aot-annotation-map.md), remaining follow-up work. + +### 2. Finish the node forwarding-logger leaf + +- **Strategy:** S2 feature gate using the existing `EnableReflectiveLoggerLoading` switch. +- **Implementation surface:** [`OutOfProcNode.HandleNodeConfiguration`](../../src/Build/BackEnd/Node/OutOfProcNode.cs) and its call from `HandlePacket`. +- **Why it remains:** `LoggingService` already gates equivalent forwarding-logger creation, but the out-of-proc node configuration path still carries the surviving `OutOfProcNode.HandlePacket` IL2026 boundary suppression. +- **Expected shape:** gate the node forwarding-logger initialization leaf, then remove the message-pump suppression if no other RUC path remains through that packet arm. +- **Deeper context:** [aot-trim-suppressions.md](aot-trim-suppressions.md), `OutOfProcNode.HandlePacket` row. + +### 3. Gate solution-metaproject generation + +- **Strategy:** S2 feature gate with observable failure when a trimmed/AOT host is asked to build a solution path that needs generated metaprojects. +- **Implementation surface:** [`SolutionProjectGenerator`](../../src/Build/Construction/Solution/SolutionProjectGenerator.cs), solution-loading helpers in [`ProjectInstance`](../../src/Build/Instance/ProjectInstance.cs), and the `BuildManager.PacketReceived` boundary that can reach them. +- **Why it remains:** solution metaproject generation still pulls evaluation / SDK / logger surfaces through a message-pump boundary. Some of those leaves are now gated, but the solution generation subsystem itself is not. +- **Expected shape:** keep ordinary JIT solution builds unchanged; in a trimmed/AOT host, fail observably for solution-build execution paths the host cannot support, allowing fallback. +- **Deeper context:** [aot-annotation-map.md](aot-annotation-map.md), remaining follow-up work. + +### 4. Reuse the task parameter type registry for serialized task parameter types + +- **Strategy:** S5 registry-first lookup, with a gated by-name fallback for anything still open-world. +- **Implementation surface:** `TaskRegistry.TranslatorForTaskParameterValue` in [`TaskRegistry.cs`](../../src/Build/Instance/TaskRegistry.cs). +- **Why it remains:** the `` parser now uses `TaskParameterTypeRegistry`, but task-host serialization still reconstructs a parameter type from a serialized assembly-qualified name with `Type.GetType(string)`, producing the remaining IL2057 row. +- **Expected shape:** consult `TaskParameterTypeRegistry` first for known serialized names; keep or further isolate the by-name fallback for genuinely open-world cases. This may shrink rather than fully remove the row because arbitrary value types remain legal. +- **Deeper context:** [task-parameter-types.md](task-parameter-types.md) and [task-parameter-type-registration-api.md](../specs/task-parameter-type-registration-api.md). + +### 5. Source-generated task parameter binder + +- **Strategy:** S5 closed-world registration, extending task class registration from construction into parameter binding. +- **Implementation surface:** the host task registry (`TaskClassRegistry` / `Task.RegisterTask`) and [`TaskExecutionHost`](../../src/Build/BackEnd/TaskExecutionHost/TaskExecutionHost.cs) parameter get/set paths. +- **Why it remains:** registered task classes run under AOT today by rooting public constructors and properties, but parameter binding still uses reflection over rooted properties. That is trim-safe for registered types, but a generated binder would remove this reflection and reduce rooting pressure. +- **Expected shape:** a generator or explicit registration surface emits task metadata plus strongly typed setters/getters for registered tasks. +- **Deeper context:** [task-class-registration-api.md](../specs/task-class-registration-api.md) and [task-factory-aot.md](task-factory-aot.md). + +### 6. Verify package feature-switch defaults in the SDK AOT publish path + +- **Strategy:** P-E validation / packaging follow-up. +- **Implementation surface:** [`Microsoft.Build.Framework.targets`](../../src/Framework/buildTransitive/Microsoft.Build.Framework.targets) in the Framework package and the consuming .NET SDK AOT publish. +- **Why it remains:** the Framework package now carries buildTransitive `RuntimeHostConfigurationOption` defaults for package consumers, and the in-repo AOT harness re-declares them for project-reference validation. The remaining work is to verify the SDK's real AOT publish consumes the package defaults as intended. +- **Expected shape:** inspect the SDK publish response file or equivalent output and confirm the `Microsoft.Build.*` feature settings are supplied without manual duplication. +- **Deeper context:** [managing-trimming-and-aot.md §6.5](managing-trimming-and-aot.md#65-how-a-librarys-switch-reaches-a-consumer-transitivity-defaulting-override) and [sdk-msbuild-object-model-audit.md](sdk-msbuild-object-model-audit.md). + +## Backlog and non-goals + +- **`Microsoft.Build.Tasks` as a fully trim/AOT-enabled assembly.** The Tasks assembly still has Backlog suppressions. One pending bucket is XML handling (`XmlSerializer`, `XslCompiledTransform`, `SignedXml`); other rows cover attribute reflection and assembly metadata. Some task entry points now fail gracefully under Native AOT with `RuntimeFeature.IsDynamicCodeSupported` guards, but the assembly still needs feature work before it can be treated as trimmable. The durable strategy is in [aot-trimming-strategy.md](aot-trimming-strategy.md), and the guard mechanics are in [managing-trimming-and-aot.md](managing-trimming-and-aot.md#53-dynamic-code-runtime-code-generation). +- **Removing public `ITaskFactory` RUC.** The public task-factory interface annotations are preview-era AOT annotations, not a permanent end-state. Registered and intrinsic tasks bypass the public interface members on the AOT-safe path; the remaining work is to prove the public interface call paths analyzer-clean, feature-gated, or observably unsupported, then remove the RUC before shipping the surface as stable. See [task-factory-aot.md](task-factory-aot.md). +- **Vetted false-positive suppressions.** The `TypeExtensions` helper suppressions and rooted-but-unreachable `Enum.GetValues(Type)` AOT suppressions remain valid false positives. They are tracked in [aot-trim-suppressions.md](aot-trim-suppressions.md). diff --git a/documentation/aot/managing-trimming-and-aot.md b/documentation/aot/managing-trimming-and-aot.md new file mode 100644 index 00000000000..bd9b3b86cd2 --- /dev/null +++ b/documentation/aot/managing-trimming-and-aot.md @@ -0,0 +1,871 @@ +# Managing Trimming and Native AOT Annotations + +**Status:** Living reference. + +A practical, source-grounded guide to making code trim- and AOT-safe, written for an +expert developer who is **completely new to this space**. It explains the annotation +model (`[RequiresUnreferencedCode]`, `[DynamicallyAccessedMembers]`, +`[FeatureSwitchDefinition]`, `[FeatureGuard]`, …), exactly what the **analyzer** does at +build time versus what the **trimmer / AOT compiler** does at publish time, and the +**precise** runtime limitations of trimming, single-file, and Native AOT — including +which limitations are configurable. + +**How to read this:** §1-4 are the on-ramp (the mental model and the four annotations) - read these +first. §5-7 are **deep reference** (exact per-mode limitations and feature-switch internals) to return +to when you need specifics; §8-9 are day-to-day conventions and a cheat sheet. + +> Companion documents (see the [folder README](README.md) for the full map): +> [aot-trimming-strategy.md](aot-trimming-strategy.md) is the **strategy layer** (decide what to do with +> an AOT-unfriendly path); [aot-trim-suppressions.md](aot-trim-suppressions.md) is the **live tracker** of +> every active suppression. This guide explains the *why* (the mechanics) behind both. + +--- + +## 1. TL;DR / mental model + +There are **two independent machines** that read the same annotations: + +| | Build / edit time | Publish time | +| --- | --- | --- | +| **Tool** | ILLink **Roslyn analyzer** (in-process with the C# compiler) | **ILLink** trimmer (`PublishTrimmed`) / **ILC** AOT compiler (`PublishAot`) | +| **Sees** | One method/assembly at a time, a *limited* subset of patterns | The whole-program closure of everything reachable | +| **Produces** | Squiggles / `ILxxxx` warnings | The trimmed or native output, plus the full set of `ILxxxx` warnings | +| **Acts on switches** | Treats `[FeatureGuard]` as a guard; does **not** constant-fold feature switches | **Substitutes** `[FeatureSwitchDefinition]` properties to a constant and removes dead branches | + +The annotations are a **contract** you write so that *both* machines agree on what is +safe. The analyzer gives you fast, local feedback; the trimmer/ILC make the actual +decisions about what code survives. + +```mermaid +flowchart LR + subgraph Edit["Build / edit time (per-compilation)"] + SRC["Source + annotations"] --> AN["ILLink Roslyn analyzer"] + AN --> W1["ILxxxx warnings in IDE/build"] + end + subgraph Publish["Publish time (whole-program)"] + SRC --> ILL["ILLink trimmer"] + ILL --> TRIM["Trimmed IL"] + TRIM --> ILC["ILC native compiler"] + ILC --> NAT["Native AOT image"] + ILL --> W2["Full ILxxxx warnings"] + end + style Edit fill:#1f3b57,color:#fff + style Publish fill:#3b1f57,color:#fff +``` + +**Golden rules** + +1. Prefer *expressing intent* (`[DynamicallyAccessedMembers]`, feature switches) over + *silencing* (`[UnconditionalSuppressMessage]`). +2. When code is genuinely incompatible, mark it `[RequiresUnreferencedCode]` / + `[RequiresDynamicCode]` and **propagate** the attribute up to a public boundary. +3. **Suppress only a false positive.** `[UnconditionalSuppressMessage]` is permitted + **only when the warning is inaccurate** - the code is provably safe and the analyzer + simply cannot see it. Never suppress an *accurate* warning to make a build quiet. + +### MSBuild's overriding design criterion: fail observably, never silently + +MSBuild is being made trim/AOT-capable so that an **AOT-compiled host - the dotnet CLI -** +can run the MSBuild object model in-process (MSBuild is compiled into the host). When a +project needs something the AOT host cannot do (load a task, SDK resolver, logger, or +build check by reflection; emit dynamic code; ...), the host must **detect the failure and +fall back** to a JIT-based MSBuild (the managed CLI, or `MSBuild.exe` out of process). +That fallback only works if MSBuild fails *observably*. Therefore, for any code the +trim/AOT analyzers flag: + +* **No silent failures.** A path that cannot work under trim/AOT must surface a build + **error** (or otherwise fail in a host-detectable way) - never quietly do nothing or + return a wrong result. A project that registers a custom check, a custom task, etc. is + expressing intent; dropping that intent without a word is a silent failure. +* **No crashes.** An unhandled exception or `PlatformNotSupportedException` deep in the + engine is *worse* than an error - a host cannot cleanly fall back from a crash. Convert + incompatible paths into clean, reported errors. +* **`[UnconditionalSuppressMessage]` may NEVER be used if it causes a silent failure or a + crash.** The only valid suppression is a known-inaccurate warning (rule 3 above). If the + warning is accurate, suppressing it hides exactly the signal the host relies on to fall + back. +* **Reachable incompatible paths fail observably via a guard.** When a path is genuinely + incompatible and a project can reach it, gate it behind a feature guard + (`[FeatureGuard]`) whose *disabled* branch raises a reported error. That keeps full + behavior under the JIT and turns the unsupported case into a clean, detectable failure + under trim/AOT - not a silent skip, and not a suppression. + +In one line: **express intent, or fail loudly - never suppress a real problem, never go +silent, never crash.** + +--- + +## 2. The three deployment modes + + +```mermaid +flowchart TD + A["Default (framework-dependent or self-contained)"] --> B["PublishTrimmed=true"] + B -->|adds| C["PublishSingleFile=true"] + D["PublishAot=true"] + D -->|implies| B + D -->|implies| C + classDef m fill:#223344,color:#fff; + class A,B,C,D m; +``` + +`PublishAot` is the strict superset: it **implies trimming and single-file**, and adds +its own native-code constraints. So an AOT-*compatible* library is automatically trim- and +single-file-compatible, but not vice-versa. + +> **Trimming and AOT happen only at the *final* compilation.** They are whole-program steps +> that run **once**, at the leaf application's `dotnet publish` — there is no library-to-library +> or intermediate AOT, and no "AOT-to-AOT" build. A library (even one marked +> `IsAotCompatible`/`IsTrimmable`) always ships as **IL**; `IsAotCompatible` is a static +> *annotation/promise*, whereas AOT *compilation* (ILC) and trimming (ILLink) run only in the +> app that publishes. So where this guide says a library "trims/AOTs," it is shorthand for +> *a consuming application trims or AOT-publishes a closure that includes that library.* + +| Mode | MSBuild switch | What the toolchain does | Analyzer enabled by | +| --- | --- | --- | --- | +| **Trimming** | `PublishTrimmed=true` | ILLink removes unreferenced types/members; substitutes feature switches; disables trim-incompatible framework features. | `EnableTrimAnalyzer` | +| **Single-file** | `PublishSingleFile=true` | Bundles assemblies into one host executable; files are no longer on disk. | `EnableSingleFileAnalyzer` | +| **Native AOT** | `PublishAot=true` | Runs the trimmer, then ILC compiles IL → native code; **no JIT** ships. | `EnableAotAnalyzer` | + +A library opts into **all** of the relevant analyzers at once with: + +```xml +true + +``` + +`true` alone marks the assembly trimmable and enables only the +trim analyzer. Use `true` to get warnings without +marking the assembly trimmable. + +> **TFM note:** the analyzers run only on .NET 8+ (`net8.0` for AOT/single-file analyzers, +> `net6.0`+ for trim). They do **not** run on `net472`/`netstandard2.0`. This matters for +> multi-targeted projects like MSBuild — see [§8](#8-msbuild-specific-conventions). + +--- + +## 3. The warning families + +Authoritative index: [dotnet/runtime `docs/tools/illink/error-codes.md`](https://github.com/dotnet/runtime/blob/main/docs/tools/illink/error-codes.md). + +| Range | Family | Emitted for | Typical cause | +| --- | --- | --- | --- | +| **IL2xxx** | Trim analysis | `PublishTrimmed` / `IsTrimmable` | Reflection the trimmer can't follow | +| **IL3000–IL3002** | Single-file | `PublishSingleFile` | `Assembly.Location`, `Assembly.GetFiles`, `[RequiresAssemblyFiles]` | +| **IL3050–IL30xx** | AOT (dynamic code) | `PublishAot` | `Reflection.Emit`, `MakeGenericType`, runtime codegen | +| **IL4000–IL4001** | Feature checks | trim analysis | A `[FeatureGuard]` whose body the analyzer can't validate | + +The most important individual codes: + +| Code | Meaning | +| --- | --- | +| **IL2026** | Calling a method annotated `[RequiresUnreferencedCode]`. | +| **IL2067–IL2095** | Dataflow: a `Type`/member value doesn't satisfy a `[DynamicallyAccessedMembers]` requirement (parameter `…67`, return `…73/74`, field `…74/77`, `this` `…70/75`, generic `…91/96`, etc.). | +| **IL2057** | `Type.GetType(string)` with a name not statically known. | +| **IL3000** | `Assembly.Location` is empty in a single-file app. | +| **IL3001** | `Assembly.GetFiles` / satellite probing not available single-file. | +| **IL3002** | Calling a method annotated `[RequiresAssemblyFiles]`. | +| **IL3050** | Calling a method annotated `[RequiresDynamicCode]`. | +| **IL4000** | A `[FeatureGuard]` property's body doesn't *provably* return `false` when the guarded feature is disabled (see [§6.4](#64-the-il4000-gotcha-the-definitive-explanation)). | + +--- + +## 4. The core annotations + +All live in `System.Diagnostics.CodeAnalysis`. On older TFMs they are polyfilled (MSBuild +keeps copies in [AotTrimmingPolyfills.cs](../../src/Framework/Polyfills/AotTrimmingPolyfills.cs)). + +### 4.1 The "Requires" family — *"this code is fundamentally incompatible"* + +`[RequiresUnreferencedCode]` (RUC, → IL2026), `[RequiresDynamicCode]` (RDC, → IL3050), +`[RequiresAssemblyFiles]` (→ IL3002). + +Putting one of these on a method does two things: + +1. **Silences all in-body trim/AOT warnings** for that method (the method is declared + incompatible, so the analyzer stops checking *inside* it). +2. **Warns at every caller** — which you fix by propagating the same attribute up, until + you reach a public API boundary or a guarded call site. + +```mermaid +flowchart TD + Pub["public API (annotated RUC) — callers warned"] --> Mid["internal helper (RUC)"] + Mid --> Leaf["the actual reflection (RUC) — body NOT analyzed"] + Guard["if (FeatureGuard) { … }"] -. suppresses .-> Mid +``` + +> **Key subtlety used throughout MSBuild:** RUC on a method also covers **lambdas and +> local functions** declared inside it. That is why +> `TypeLoader.AssemblyInfoToLoadedTypes.IsDesiredType` can call `Type.GetInterface` +> with no per-call suppression — it is only reached from `[RequiresUnreferencedCode]` +> load methods whose RUC covers the nested closures. + +### 4.2 `[DynamicallyAccessedMembers]` (DAM) — *"preserve these members"* + +When you *can* describe statically which members reflection will touch, annotate the +`Type`/parameter/field/return with the member kinds to keep. The trimmer preserves them; +the analyzer flows the requirement to every assignment and call (IL2067-family) until it +reaches a concrete `typeof(...)` or another annotation. + +```csharp +static void Use([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods)] Type t) + => t.GetMethods(); // no warning: the requirement is satisfied by the annotation +``` + +This is the **preferred** fix: it is machine-checked end-to-end, unlike a suppression. +MSBuild example: [TypeExtensions.InvokeMemberPublicOnly](../../src/Framework/Utilities/TypeExtensions.cs) +annotates its receiver with the exact public-member surface it binds. + +### 4.3 Escape hatches — `[UnconditionalSuppressMessage]` and `[DynamicDependency]` + +- `[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "…")]` — persisted + in IL and honored by the trimmer. Use **only** when you can guarantee safety by + inspection. The `Justification` must explain the invariant. +- `[DynamicDependency("Member", typeof(T))]` — *keeps* a member the trimmer would + otherwise remove. It does **not** silence warnings on its own; pair with a suppression. + +### 4.4 Choosing an annotation + +```mermaid +flowchart TD + Q1{Can you statically name the members reflected on?} -->|Yes| DAM["Use [DynamicallyAccessedMembers]"] + Q1 -->|No| Q2{Is it an optional feature with a fallback?} + Q2 -->|Yes| FS["Gate behind a feature switch + [FeatureGuard]"] + Q2 -->|No| Q3{Provably safe by inspection?} + Q3 -->|Yes| SUP["[UnconditionalSuppressMessage] + Justification"] + Q3 -->|No| RUC["Mark [RequiresUnreferencedCode] / [RequiresDynamicCode] and propagate"] +``` + +### 4.5 What silences which warning, and what cannot + +Each mechanism covers a specific slice of the warning space. The single most important fact, +and the one most often gotten wrong: **feature switches and `[FeatureGuard]` only ever silence +the three "Requires\*" call-site warnings (IL2026 / IL3050 / IL3002). They never silence a +dataflow (DAM) warning** (the IL2067–IL2095 band). + +| To clear this warning | Use | +| --- | --- | +| **IL2026** `RequiresUnreferencedCode` | `[FeatureGuard(typeof(RequiresUnreferencedCodeAttribute))]`, or propagate `[RequiresUnreferencedCode]`, or `[UnconditionalSuppressMessage]` | +| **IL3050** `RequiresDynamicCode` | `[FeatureGuard(typeof(RequiresDynamicCodeAttribute))]` (`RuntimeFeature.IsDynamicCodeCompiled` / `IsDynamicCodeSupported`), or propagate `[RequiresDynamicCode]`, or suppress | +| **IL3002** `RequiresAssemblyFiles` | `[FeatureGuard(typeof(RequiresAssemblyFilesAttribute))]`, or propagate, or suppress | +| **IL2067–IL2095** dataflow (DAM on param/return/field/`this`/generic) | `[DynamicallyAccessedMembers]` on the value's declaration, or `[UnconditionalSuppressMessage]` — **never a switch or guard** ([§4.6](#46-why-a-feature-switch-cannot-silence-a-dataflow-warning)) | +| **IL2057** `Type.GetType(string)` | a statically-known type name + `[DynamicallyAccessedMembers]`, or suppress | +| **IL3000 / IL3001** single-file file APIs | `AppContext.BaseDirectory`, `[RequiresAssemblyFiles]`, or suppress | + +Read the other way — exactly what each guard covers: + +| `[FeatureGuard(typeof(...))]` | Silences | Does **not** silence | +| --- | --- | --- | +| `RequiresUnreferencedCodeAttribute` | IL2026 | IL3050, IL3002, **all dataflow** | +| `RequiresDynamicCodeAttribute` | IL3050 | IL2026, IL3002, **all dataflow** | +| `RequiresAssemblyFilesAttribute` | IL3002 (+ IL3000/IL3001 patterns) | IL2026, IL3050, **all dataflow** | + +`FeatureGuardAttribute` is `AllowMultiple = true`, so one property can stack guards and silence +several "Requires" families at once — MAUI's `IsHybridWebViewSupported` carries both +`RequiresUnreferencedCode` and `RequiresDynamicCode` ([§6.6](#66-external-example-a-product-switch-registry-maui)). +There is still **no** stack that reaches dataflow. + +### 4.6 Why a feature switch cannot silence a dataflow warning + +This is the question everyone asks: *"I guard the reflection with `if (!Switch) return;` and I +know the trimmer deletes that branch — so why does IL2075 still fire?"* Three facts explain it: + +1. **You are looking at the analyzer, not the trimmer.** The warning comes from the Roslyn + ILLink **analyzer**, which runs per-method at compile time — before any publish-time elision. + When it runs, nothing has been trimmed. +2. **The analyzer never constant-folds a feature switch.** Per [§6.2](#62-the-three-pieces-and-exactly-what-each-does), + `[FeatureSwitchDefinition]` is *metadata* to the analyzer; it does not evaluate the switch or + treat either branch as dead. Both branches are "live," so every warning in them is reported. + The dead-branch removal is the **trimmer's** job at publish ([§7](#7-end-to-end-how-a-feature-switch-is-removed)). +3. **`[FeatureGuard]` is a narrow exception that, by construction, only covers "Requires."** A + guard lets the analyzer accept one specific claim inside `if (guard)`: *"a method that requires + capability X was called."* A dataflow warning is a different kind of claim — *"this `Type` + value must carry [these members], but the value flowing in doesn't promise them."* No boolean + can express which members a `Type` carries; that fact can only travel **with the value**, as a + `[DynamicallyAccessedMembers]` annotation. So there is nothing for a guard to certify, and no + guard-shaped form of it exists. + +**What elision actually buys you.** If you *do* supply the switch value (`Trim="true"`), the +trimmer folds the getter to a constant, removes the dead branch, and emits **no publish-time** +warning for the removed code — the elision is real. But it happens at publish, in the trimmer; +it does not retroactively quiet the build-time analyzer, and official builds fail on the analyzer +warning. So a dataflow warning has exactly two cures, both of which satisfy the analyzer itself: +annotate the flow with `[DynamicallyAccessedMembers]` (preferred — machine-checked end to end), +or `[UnconditionalSuppressMessage]` it where you can prove safety by inspection. + +> **Rule of thumb:** if the code is in the **IL2067–IL2095** dataflow band, stop looking for a +> switch — reach for a DAM annotation or a localized suppression. Switches and guards are for the +> **IL2026 / IL3050 / IL3002** "Requires" family only. + +--- + +> **Deep reference begins here.** §1-4 above are the on-ramp; the sections below (exact per-mode +> limitations, the feature-switch deep dive, and the end-to-end removal walkthrough) are reference +> material - consult them as needed rather than reading front to back. + +## 5. Single-file, trimming, and AOT: exact limitations + +### 5.1 Trimming (`PublishTrimmed`) + +**What it does:** whole-program reachability analysis; unreferenced types/members are +removed; feature switches are substituted; trim-incompatible framework features are +disabled by default. + +**Runtime reality:** the JIT is still present — you *can* still `Assembly.LoadFrom`, emit +IL, etc. The hazard is purely that **members the trimmer couldn't see may be gone**, so +reflection over them throws `MissingMethod`/`MissingMember` or returns `null`. + +**Framework features _disabled by default_ when trimming** (re-enable "at your own risk"): +`BuiltInComInteropSupport`, `CustomResourceTypesSupport`, `EnableCppCLIHostActivation`, +`EnableUnsafeBinaryFormatterInDesigntimeLicenseContextSerialization`, `StartupHookSupport`. + +### 5.2 Single-file (`PublishSingleFile`) + +**What it does:** bundles managed assemblies into the host executable; at runtime they are +loaded from the bundle, not from files on disk. + +**Exact limitations** (the IL300x analyzer covers these): + +| API | Behavior single-file | +| --- | --- | +| `Assembly.Location` | Returns an **empty string** (IL3000). | +| `Assembly.GetFiles`, `Assembly.CodeBase`, `Module.FullyQualifiedName` | Throw / unavailable (IL3001). | +| `AppContext.BaseDirectory` | Works (points at the extraction/app dir). | +| Native libraries | Extracted or loaded from bundle depending on settings. | + +**Configurable:** `IncludeNativeLibrariesForSelfExtract`, +`IncludeAllContentForSelfExtract`, `EnableCompressionInSingleFile`, `SelfContained`. + +### 5.3 Native AOT (`PublishAot`) + +**What it does:** ILC compiles IL to native code ahead of time. **No JIT ships.** Implies +trimming + single-file. + +**Exact, non-negotiable limitations** (from the +[official limitations list](https://learn.microsoft.com/dotnet/core/deploying/native-aot/#limitations-of-native-aot-deployment)): + +- **No runtime code generation** — `System.Reflection.Emit`, `DynamicMethod`. +- **No dynamic assembly loading** — `Assembly.LoadFile`/`LoadFrom` of managed IL. +- **`System.Linq.Expressions` always interpreted** (never `Compile()`d) → slower. +- **No C++/CLI**, **no built-in COM** (Windows). +- **All generic instantiations are pre-generated** — struct type arguments and generic + virtual methods expand at compile time, affecting binary size; instantiations that can't + be discovered statically fail. +- Requires trimming (inherits all trim limitations) and single-file (all those too). +- Some runtime libraries aren't fully annotated → a few unactionable warnings. + +**The runtime gate:** [`RuntimeFeature.IsDynamicCodeSupported`](https://learn.microsoft.com/dotnet/api/system.runtime.compilerservices.runtimefeature.isdynamiccodesupported) +is `false` under Native AOT and `true` under the JIT — it is the property you check to fall back +at run time: + +```csharp +if (RuntimeFeature.IsDynamicCodeSupported) + UseEmit(); // IL3050 suppressed here; ILC removes this branch +else + UseFallback(); +``` + +There are in fact **two** related properties, and it is worth knowing which is which. Under +Native AOT the runtime hard-codes both to `false` +([`RuntimeFeature.NativeAot.cs`](https://github.com/dotnet/runtime/blob/main/src/coreclr/nativeaot/System.Private.CoreLib/src/System/Runtime/CompilerServices/RuntimeFeature.NativeAot.cs)): + +```csharp +[FeatureSwitchDefinition("System.Runtime.CompilerServices.RuntimeFeature.IsDynamicCodeSupported")] +public static bool IsDynamicCodeSupported => false; + +[FeatureGuard(typeof(RequiresDynamicCodeAttribute))] +public static bool IsDynamicCodeCompiled => false; +``` + +| Property | Attribute | Role | +| --- | --- | --- | +| `IsDynamicCodeSupported` | `[FeatureSwitchDefinition]` | The **switch** the trimmer substitutes; the analyzer also recognizes a check of it as an IL3050 guard. | +| `IsDynamicCodeCompiled` | `[FeatureGuard(typeof(RequiresDynamicCodeAttribute))]` | The **explicit guard** (it just returns the switch). | + +They diverge only on the Mono interpreter, where code is *supported* but never JIT-*compiled* +(`IsDynamicCodeSupported == true`, `IsDynamicCodeCompiled == false`). For an AOT-vs-JIT fork +either reads correctly; prefer `IsDynamicCodeCompiled` when the fallback exists specifically +because there is no compiled codegen (for example `Expression.Compile`). **Both gate IL3050 +only** — neither silences IL2026 or any dataflow warning ([§4.5](#45-what-silences-which-warning-and-what-cannot)). + +For MSBuild tasks that call runtime-code-generation APIs (`XslCompiledTransform`, `XmlSerializer`, +`Reflection.Emit`, `Expression.Compile`, and similar APIs), put the guard at the task entry point, +before any dynamic-code work runs: + +```csharp +#if NET +if (!RuntimeFeature.IsDynamicCodeSupported) +{ + Log.LogErrorWithCodeFromResources( + "", + "Dynamic code generation is not supported in this runtime environment."); + return false; +} +#endif +``` + +Use an existing task-specific error resource with a detail slot when one exists. The guard is `#if NET` +because .NET Framework always supports runtime code generation; add `using System.Runtime.CompilerServices;` +when needed. The analyzer recognizes the early return as an IL3050 guard, so entry-point IL3050 suppressions +can be removed while leaf helpers that actually require dynamic code keep `[RequiresDynamicCode]`. + +Do not use this guard for trimming warnings. `RuntimeFeature.IsDynamicCodeSupported` does **not** silence +IL2026, IL2070/IL2075, IL2057, or any other dataflow warning. Those require the normal trim strategies: +remove the reflection, add DAM, use a `[FeatureGuard(typeof(RequiresUnreferencedCodeAttribute))]` switch, +register the type up front, or track the row as Backlog. + +### 5.4 Configurable framework feature switches (trimming **and** AOT) + +These MSBuild properties trim the corresponding code *and* set the matching +`runtimeconfig` switch. Full list + runtimeconfig names: +[feature-switches.md](https://github.com/dotnet/runtime/blob/main/docs/workflow/trimming/feature-switches.md). + +| Property | Effect when set | +| --- | --- | +| `InvariantGlobalization=true` | Removes globalization data/code. | +| `UseSystemResourceKeys=true` | Strips `System.*` exception message text. | +| `EventSourceSupport=false` | Removes EventSource logic. | +| `MetadataUpdaterSupport=false` | Removes hot-reload support. | +| `StackTraceSupport=false` | Removes runtime stack-trace generation. | +| `DebuggerSupport=false` | Removes debugger aids (also strips symbols). | +| `HttpActivityPropagationSupport=false` | Removes `System.Net.Http` diagnostics. | +| `MetricsSupport=false` | Removes `System.Diagnostics.Metrics` instrumentation. | +| `AutoreleasePoolSupport=true` | Adds autorelease pools (Apple platforms). | +| `EnableUnsafeBinaryFormatterSerialization=false` | Removes `BinaryFormatter`. | +| `EnableUnsafeUTF7Encoding=false` | Removes UTF-7. | +| `XmlResolverIsNetworkingEnabledByDefault=false` | File-only XML resolving. | +| `UseSizeOptimizedLinq=true` (.NET 10+) | Smaller, less throughput-optimized LINQ (default under AOT). | + +You can author **your own** switches with the same mechanism — that is [§6](#6-feature-switches-deep-dive). + +--- + +## 6. Feature switches deep dive + +This is the part most people get wrong, so it is the most detailed. + +### 6.1 Design and history + +- Original design: [dotnet/designs — feature-switch.md (2020)](https://github.com/dotnet/designs/blob/main/accepted/2020/feature-switch.md). +- Attribute model (the `[FeatureSwitchDefinition]`/`[FeatureGuard]` you use today): + API proposal [dotnet/runtime#96859](https://github.com/dotnet/runtime/issues/96859), + design discussion [dotnet/designs#305](https://github.com/dotnet/designs/pull/305), + analyzer implementation [dotnet/runtime#94944](https://github.com/dotnet/runtime/pull/94944). +- API docs: [FeatureSwitchDefinitionAttribute](https://learn.microsoft.com/dotnet/api/system.diagnostics.codeanalysis.featureswitchdefinitionattribute), + [FeatureGuardAttribute](https://learn.microsoft.com/dotnet/api/system.diagnostics.codeanalysis.featureguardattribute). + +A **feature switch** is a named boolean (an `AppContext` switch) that the trimmer can fold +to a constant, letting it delete the disabled feature's code. + +### 6.2 The three pieces and exactly what each does + +| Piece | Where | Build/analysis time | Publish/trim time | +| --- | --- | --- | --- | +| `[FeatureSwitchDefinition("Name")]` | on a `static bool` property | Metadata only — the analyzer does **not** constant-fold it. | ILLink **substitutes the property body** with the configured constant, enabling dead-branch removal. | +| `[FeatureGuard(typeof(RequiresX))]` | on a `static bool` property | Analyzer treats `if (Prop)` as guarding `RequiresX` code → no IL2026/IL3050 inside; **validates** the guard body (→ IL4000). | Not used directly; the trimmer relies on substitution. | +| `RuntimeHostConfigurationOption Include="Name" Value="v" Trim="true"` | csproj | — | Writes the `runtimeconfig` default **and** (because `Trim="true"`) supplies the switch value `v` to ILLink, which drives the substitution above. **Build-local** — it is *not* embedded in the assembly and does **not** transit to consumers; see [§6.5](#65-how-a-librarys-switch-reaches-a-consumer-transitivity-defaulting-override). | + + +```mermaid +sequenceDiagram + participant Dev as Your code + participant An as Roslyn analyzer + participant ILL as ILLink at publish + Dev->>An: guarded branch - if FeatureSwitches.X then the RUC call + Note over An: FeatureGuard suppresses IL2026 inside the branch, then validates the guard body and may emit IL4000 + Dev->>ILL: same code, plus FeatureSwitchDefinition and a RuntimeHostConfigurationOption of Value false and Trim true + Note over ILL: substitutes X to false, so the dead branch and the unreferenced RUC call are removed +``` + +So: the **analyzer** uses `[FeatureGuard]` to stay quiet at call sites; the **trimmer** +uses `[FeatureSwitchDefinition]` + the host-config value to actually delete the code. +They are complementary and you normally need **all three** pieces. + +**Where the SDK and runtime implement this.** `RuntimeHostConfigurationOption` is an +SDK/runtime item (not an MSBuild-engine one); three targets across two repos consume it: + +| Consumer | Repo · target file | Target / how | +| --- | --- | --- | +| `runtimeconfig.json` `configProperties` | dotnet/sdk · [Microsoft.NET.Sdk.targets](https://github.com/dotnet/sdk/blob/main/src/Tasks/Microsoft.NET.Build.Tasks/targets/Microsoft.NET.Sdk.targets) | `GenerateBuildRuntimeConfigurationFiles` calls the `GenerateRuntimeConfigurationFiles` task with `HostConfigurationOptions="@(RuntimeHostConfigurationOption)"`. | +| Trim substitution (the `Trim="true"` half) | dotnet/runtime · [Microsoft.NET.ILLink.targets](https://github.com/dotnet/runtime/blob/main/src/tools/illink/src/ILLink.Tasks/build/Microsoft.NET.ILLink.targets) | `_PrepareTrimConfiguration` builds `@(_TrimmerFeatureSettings)` from options where `%(Trim)=='true'`; `_RunILLink` passes them as `FeatureSettings` to the `ILLink` task. | +| Native AOT | dotnet/runtime · [Microsoft.NETCore.Native.targets](https://github.com/dotnet/runtime/blob/main/src/coreclr/nativeaot/BuildIntegration/Microsoft.NETCore.Native.targets) | Emits `--feature:Name=Value` (from `_TrimmerFeatureSettings`) **and** `--runtimeknob:Name=Value` (from every option) to ILC. | + +**Trimmer eligibility — the exact shape a feature-switch property must have.** ILLink only +substitutes a getter that passes +[`MemberActionStore.TryGetFeatureCheckValue`](https://github.com/dotnet/runtime/blob/main/src/tools/illink/src/linker/Linker/MemberActionStore.cs): +it must be **static**, **return `bool`**, be a **property getter**, and the property must +have **no setter**. There is **no accessibility requirement** — `public`, `internal`, and +`private` all work (the API docs' "public … property" wording is descriptive, not +enforced; the BCL and MSBuild both use `internal`). The switch value must also be *supplied* +(via `--feature`, i.e. the `Trim="true"` `RuntimeHostConfigurationOption`); a +`[FeatureSwitchDefinition]` with no supplied value is left untouched. When eligible, +[`CodeRewriterStep`](https://github.com/dotnet/runtime/blob/main/src/tools/illink/src/linker/Linker.Steps/CodeRewriterStep.cs) +(`RewriteBodyToStub` → `CreateStubBody`) **discards the entire body** and replaces it with +`return ` (`ldc.i4.0` / `ldc.i4.1`). Because the body is thrown away wholesale, its +logic is never evaluated at trim time — which is exactly why a body like +`AppContext.TryGetSwitch(...)` (or even one reading an environment variable) substitutes +correctly even though the analyzer cannot model it ([§6.4](#64-the-il4000-gotcha-the-definitive-explanation)). +Hard limits of the stub mechanism: the method must be IL (not an intrinsic/native method) +and must have no `out` parameters. + +### 6.3 Worked example (MSBuild) + +[FeatureSwitches.cs](../../src/Framework/FeatureSwitches.cs) + +[Microsoft.Build.Framework.csproj](../../src/Framework/Microsoft.Build.Framework.csproj): + +```csharp +[FeatureSwitchDefinition("Microsoft.Build.EnableAllPropertyFunctions")] +[FeatureGuard(typeof(RequiresUnreferencedCodeAttribute))] +#pragma warning disable IL4000 // see §6.4 +internal static bool EnableAllPropertyFunctions => + AppContext.TryGetSwitch("Microsoft.Build.EnableAllPropertyFunctions", out bool isEnabled) + ? isEnabled + : Environment.GetEnvironmentVariable("MSBUILDENABLEALLPROPERTYFUNCTIONS") == "1"; +#pragma warning restore IL4000 +``` +```xml + +``` + +At the probing call site in +[`GetTypeForStaticMethod`](../../src/Build/Evaluation/Expander.Function.cs) (in the +de-genericized `Expander.Function` partial), the `[FeatureGuard]` lets the trim-unsafe +assembly-probing run with **no per-call `[UnconditionalSuppressMessage]`** — it replaced a +standing IL2026 suppression; the trimmer removes the whole branch from a trimmed/AOT build +because the switch folds to `false`. In untrimmed builds the getter preserves the legacy +`MSBUILDENABLEALLPROPERTYFUNCTIONS` behavior when the AppContext switch is unset; in trimmed/AOT +builds the getter body is replaced with `false`, so the environment variable cannot re-enable the +removed probing path. + +### 6.4 The IL4000 gotcha (the definitive explanation) + +You will hit `IL4000: "Return value does not match FeatureGuard annotations of the +property. The check should return false whenever any of the features referenced in the +FeatureGuard annotations is disabled."` — even for the textbook +`AppContext.TryGetSwitch(...) && isEnabled` body. Here is precisely why, from the analyzer +source: + +The analyzer computes which features a guard body checks in +[`FeatureChecksVisitor`](https://github.com/dotnet/runtime/blob/main/src/tools/illink/src/ILLink.RoslynAnalyzer/DataFlow/FeatureChecksVisitor.cs): + +```csharp +public override FeatureChecksValue DefaultVisit(IOperation operation, StateValue state) +{ + // Visiting a non-understood pattern should return the empty set of features, which will + // prevent this check from acting as a guard for any feature. + return FeatureChecksValue.None; +} +``` + +The **only** body shapes it understands as guarding a feature are: + +- a reference to **another property that is itself a recognized `[FeatureGuard]`/Requires + check** (e.g. `RuntimeFeature.IsDynamicCodeSupported`) — `VisitPropertyReference`; +- the **literal `false`** (guards everything) — `VisitLiteral`; +- boolean **combinations** of those (`!`, `==`, `!=`, `is` patterns). + +`AppContext.TryGetSwitch(...)` is a **method invocation** — there is no `VisitInvocation`, +so it falls through to `DefaultVisit` → `FeatureChecksValue.None`. Then +[`FeatureCheckReturnValuePattern`](https://github.com/dotnet/runtime/blob/main/src/tools/illink/src/ILLink.RoslynAnalyzer/TrimAnalysis/FeatureCheckReturnValuePattern.cs) +reports IL4000 because the computed set doesn't contain the guarded feature: + +```csharp +foreach (string feature in FeatureCheckAnnotations.GetKnownValues()) + if (!returnValueFeatures.Contains(feature)) + diagnosticContext.AddDiagnostic(DiagnosticId.ReturnValueDoesNotMatchFeatureGuards, …); +``` + +**Consequence:** a feature guard implemented with `AppContext.TryGetSwitch` **always** +trips IL4000 because the analyzer only models a small, "obvious" set of +patterns; introduced in [#94944](https://github.com/dotnet/runtime/pull/94944)). The real +ILLink trimmer doesn't evaluate the body at all — it *substitutes* the property — so +trimming is still correct. + +**The sanctioned fix is a one-line `#pragma warning disable IL4000`**, exactly as the BCL +does for its own guards: `System.Data.DataSet.XmlSerializationIsSupported`, +`System.ComponentModel.TypeDescriptor.IsComObjectDescriptorSupported`, +`System.ComponentModel.DefaultValueAttribute.IsSupported`, +`System.ComponentModel.Design.IDesignerHost.IsSupported`. (There is **no** open bug to +"fix" this; it is intended conservative analyzer behavior.) + +**Why the analyzer is *designed* to fire here.** Two levels are worth separating, and both +matter. *Mechanically*, the analyzer warns because it can't model the body: +`AppContext.TryGetSwitch` is a method invocation, so `FeatureChecksVisitor` yields the empty +feature set and the guard can't be certified (above). That conservatism exists **because** a +feature-switch body is destined for wholesale substitution and the analyzer +cannot trace the ramifications of that replacement. Its job is to certify that a guard +property faithfully tracks a feature — so it can both suppress the `Requires` warning inside +`if (guard)` *and* trust that the trimmer will fold that exact branch away when it substitutes +the switch. It can make that promise only for bodies it can model as feature checks; for +anything else it refuses to certify the guard, because an un-verifiable body plus substitution +could silently desynchronize the suppressed warning from the code that actually survives. The +analyzer is being conservative *about the side effects of a replacement it knows is coming but +cannot reason through* — which is exactly the design point. + +This is why a body the analyzer *can* model is treated differently even though it is +substituted just the same: + +```csharp +[FeatureGuard(typeof(RequiresDynamicCodeAttribute))] +static bool IsDynamicCodeSupported => RuntimeFeature.IsDynamicCodeSupported; // no IL4000 +``` + +Here the analyzer can verify the body *is* the feature switch, so it certifies the guard and +stays quiet. The dividing line is therefore not substitution itself (which happens in both +cases) but whether the analyzer can prove the body tracks the switch it is being substituted +from — a proof it demands precisely because the replacement is coming. + +So `#pragma warning disable IL4000` is not "silence a false positive" — it is a deliberate +contract you sign: *the trimmer will discard this entire getter and replace it with the +constant supplied by `RuntimeHostConfigurationOption … Value="v" Trim="true"`*, so whatever +logic the body contains is irrelevant to the trimmed/AOT result. You take responsibility for +the two facts that make that true: + +1. the property really is a `[FeatureSwitchDefinition]` switch (so it is *eligible* for + substitution — see the trimmer-eligibility rules in [§6.2](#62-the-three-pieces-and-exactly-what-each-does)), and +2. a value for it is supplied at trim time (the `Trim="true"` option). + +If either is missing the body is **not** substituted: the getter keeps running its real logic +(returning `false` for an unset switch), the guard becomes a no-op, and the branch is *kept*. +That is still correct at run time — it just means nothing was trimmed. + +> Corollary: do **not** read an environment variable inside a guard body. Any non-switch +> expression also yields the empty feature set. If you need env-var behavior, keep the +> guard a pure `AppContext.TryGetSwitch` and consult the env var at a separate, +> non-guarding call site (MSBuild does this at the property-function *gates*). + +### 6.5 How a library's switch reaches a consumer (transitivity, defaulting, override) + +`RuntimeHostConfigurationOption` is a **build-local MSBuild item** — evaluated only in the +project that declares it, and it does **not** transit across `ProjectReference` / +`PackageReference`. What actually travels inside the built assembly is only the +`[FeatureSwitchDefinition("X")]` attribute — the trimmer reads it +([`MemberActionStore.TryGetFeatureCheckValue`](https://github.com/dotnet/runtime/blob/main/src/tools/illink/src/linker/Linker/MemberActionStore.cs)) +to learn *which* member switch `X` controls, but **the attribute carries no value**. So at a +consumer's trim/AOT publish ILC substitutes the getter **only if the value of `X` is supplied +in that consumer's own build**. + +```mermaid +flowchart LR + subgraph DLL["Library.dll (travels to the consumer)"] + ATTR["[FeatureSwitchDefinition('X')] on the getter - name only, NO value"] + end + subgraph Consumer["Consumer's AOT publish (must supply the value)"] + VAL["--feature X=false (from a RuntimeHostConfigurationOption Trim=true in THIS build)"] + end + ATTR --> SUB{"ILC: substitute X?"} + VAL --> SUB + SUB -->|"value present"| YES["stub getter to false then cull the branch"] + SUB -->|"no value"| NO["not substituted - code kept"] +``` + +Two consequences for a library (like `Microsoft.Build`) consumed programmatically — e.g. by +the dotnet SDK: + +- **Runtime default is automatic.** With a body of `AppContext.TryGetSwitch(name, out v) && v`, + an unset switch makes the getter return `false`, so the feature is off at run time even with + nothing configured. Only the **code removal** needs the value to flow. +- **Trim/AOT code removal is *not* automatic** unless the value reaches the consumer's build. + +**How the runtime's own framework switches get auto-culled for AOT.** The SDK does it, not the +runtime DLLs: a `` in +[Microsoft.NET.ILLink.targets](https://github.com/dotnet/runtime/blob/main/src/tools/illink/src/ILLink.Tasks/build/Microsoft.NET.ILLink.targets) +hardcodes the trim-safe default of every framework switch (`StartupHookSupport=false`, +`EventSourceSupport`, `UseSystemResourceKeys`, `DebuggerSupport`, …), and +[Microsoft.NET.Sdk.targets](https://github.com/dotnet/sdk/blob/main/src/Tasks/Microsoft.NET.Build.Tasks/targets/Microsoft.NET.Sdk.targets) +maps those MSBuild properties to `RuntimeHostConfigurationOption … Trim="true"`. At publish +those become `--feature Name=Value`, the `[FeatureSwitchDefinition]` getters are substituted, +and the dead branches are culled. **The defaults live in the SDK, gated on +`PublishTrimmed`/`PublishAot`** — `PublishAot` implies `PublishTrimmed`, so AOT inherits them. + +**To make your *own* switch default to elided in AOT**, pick one (there is no in-DLL attribute +that carries a default — the value must come from the build): + +| Option | Mechanism | Auto-flows to consumers? | Overridable? | +| --- | --- | --- | --- | +| **buildTransitive props** *(library-owned, recommended)* | ship `buildTransitive/.props` adding `RuntimeHostConfigurationOption Include="X" Value="false" Trim="true"` (optionally `Condition="'$(PublishTrimmed)'=='true'"`) | Yes — via the NuGet package, transitively | Yes (consumer sets the property/option to `true`) | +| **SDK injection** *(consumer-owned)* | the consuming SDK adds the default like a framework switch (the runtime pattern above) | Only within that SDK | Yes | +| **Unconditional embedded `ILLink.Substitutions.xml`** | embed `` (no `feature=` condition) | Yes — the trimmer reads it from the DLL | **No** — forced off in *any* trimmed/AOT build | + +For your SDK scenario the clean, self-contained answer is **buildTransitive props in the +`Microsoft.Build` package** (see +[NuGet: MSBuild props/targets in a package](https://learn.microsoft.com/nuget/concepts/msbuild-props-and-targets)): +the SDK then gets the trim-safe default for free, without dotnet/sdk needing to know each +switch. Use the **unconditional embedded substitution** only when the AOT-unsafe path must +*never* be resurrected in a trimmed build (it removes the escape hatch). A bare +`RuntimeHostConfigurationOption` in the *library's* csproj does none of these. + +**Override + the `Trim="true"` baking caveat.** A consumer overrides by setting the value in +their build (`RuntimeHostConfigurationOption … Value="true" Trim="true"`). But because +`Trim="true"` makes ILC bake the constant at publish, a **runtime** `AppContext.SetSwitch` or +environment variable has **no effect** on the substituted getter in a trimmed/AOT app (the +body is gone). In an *untrimmed* app the getter runs normally, so the switch / env var work +at run time. + +### 6.6 External example: a product switch registry (MAUI) + +MSBuild keeps its switches in one `internal static` registry ([FeatureSwitches.cs](../../src/Framework/FeatureSwitches.cs)); +.NET MAUI does the same in [`RuntimeFeature`](https://github.com/dotnet/maui/blob/main/src/Core/src/RuntimeFeature.cs), +and it is a useful second reference because it shows the multi-target and stacked-guard cases: + +```csharp +// dotnet/maui · src/Core/src/RuntimeFeature.cs (abridged) +internal static class RuntimeFeature +{ + private const bool IsHybridWebViewSupportedByDefault = true; + +#pragma warning disable IL4000 // AppContext.TryGetSwitch is not a body the analyzer can model — §6.4 +#if NET9_0_OR_GREATER + [FeatureSwitchDefinition("Microsoft.Maui.RuntimeFeature.IsHybridWebViewSupported")] + [FeatureGuard(typeof(RequiresUnreferencedCodeAttribute))] + [FeatureGuard(typeof(RequiresDynamicCodeAttribute))] // one property may stack guards +#endif + internal static bool IsHybridWebViewSupported => + AppContext.TryGetSwitch("Microsoft.Maui.RuntimeFeature.IsHybridWebViewSupported", out bool isSupported) + ? isSupported : IsHybridWebViewSupportedByDefault; +#pragma warning restore IL4000 +} +``` + +What it adds to the MSBuild example: + +- **Stacked guards.** `[FeatureGuard]` is `AllowMultiple` — `IsHybridWebViewSupported` guards + both `RequiresUnreferencedCode` *and* `RequiresDynamicCode`, for a fallback that covers + reflection and runtime codegen at once. +- **Default-valued bodies.** `AppContext.TryGetSwitch(name, out v) ? v : DefaultConst` lets a + switch default **true** (the const), where MSBuild's `… && isEnabled` form defaults **false**. + Pick per feature; both still trip IL4000, hence the class-wide pragma. +- **TFM fencing vs polyfills.** MAUI fences the attributes with `#if NET9_0_OR_GREATER`; MSBuild + instead polyfills them ([AotTrimmingPolyfills.cs](../../src/Framework/Polyfills/AotTrimmingPolyfills.cs)) + so the source stays unfenced ([§8](#8-msbuild-specific-conventions)). +- **Defaults live in SDK targets.** As in MSBuild, the MSBuild-property→switch mapping and the + trimmed defaults are declared in the product's SDK (`Microsoft.Maui.Sdk.Before.targets`), not + the library. + +--- + +## 7. End-to-end: how a feature switch is removed + + + +```mermaid +flowchart TD + subgraph Author + P["static bool Prop => AppContext.TryGetSwitch('X', out var v) && v"] + P --> FSD["[FeatureSwitchDefinition('X')]"] + P --> FG["[FeatureGuard(typeof(RequiresUnreferencedCode))]"] + CS["if (Prop) { RucProbe(); }"] + end + subgraph Project + RHCO["RuntimeHostConfigurationOption X = false, Trim=true"] + end + subgraph Trim["ILLink at publish"] + SUB["Prop body ⇒ return false"] + DBE["if (false) { … } ⇒ removed"] + RUCGONE["RucProbe() unreferenced ⇒ removed"] + end + FSD --> SUB + RHCO --> SUB + SUB --> DBE --> RUCGONE + FG -. analyzer only .-> CS +``` + +--- + +## 8. MSBuild-specific conventions + +- **Central registry:** declare new switches in + [FeatureSwitches.cs](../../src/Framework/FeatureSwitches.cs) and add the matching + `RuntimeHostConfigurationOption` in + [Microsoft.Build.Framework.csproj](../../src/Framework/Microsoft.Build.Framework.csproj). + Package consumers also need those switch values at their own publish, so the Framework package carries + matching `buildTransitive` targets. ProjectReference-only consumers, including the in-repo AOT harness, + still re-declare the options locally because `RuntimeHostConfigurationOption` items do not flow across + project references. +- **Plugin probing uses expected-default guards.** `EnableCustomPluginProbing` gates + `MSBuildLoadContext.Load` and `TaskEngineAssemblyResolver.ResolveAssembly`. When disabled, those methods + return `null` and defer to the default loader, which still fails if the dependency is truly required. That + makes the disabled branch observable-compatible without logging an error there. Paths with no downstream + failure, such as custom BuildCheck acquisition, must instead emit their own reported error (currently + `MSB4284`). +- **Polyfills:** the attributes don't exist on `net472`/`netstandard2.0`, so MSBuild + defines internal copies in + [AotTrimmingPolyfills.cs](../../src/Framework/Polyfills/AotTrimmingPolyfills.cs) under + `#if !NET`. The analyzer never runs on those TFMs, so the polyfills only need to compile. + - Gotcha: a `` doc-comment is **ambiguous** + on `net472` (the polyfill plus embedded copies in dependencies) → `CS0419`. Use + `RequiresUnreferencedCode` in XML docs instead. +- **Suppression budget:** every `[UnconditionalSuppressMessage]` is tracked in + [aot-trim-suppressions.md](aot-trim-suppressions.md) with a status + (`Vetted` / `Investigate` / `Backlog`). Add a row when you add a suppression. +- **Warnings are errors:** official builds treat new `ILxxxx` warnings as build breaks, so + a new reflection pattern is a hard failure, not a squiggle. +- **Prefer refactors that move reflection into an already-`[RequiresUnreferencedCode]` + context** over adding a new suppression (e.g. the `TypeLoader.Create()` + refactor that avoids an IL2070 suppression — see the suppression tracker). +- **Localize an unavoidable suppression to the smallest member.** When a + `[DynamicallyAccessedMembers]` store genuinely can't be proven, push it into a one-line + setter instead of annotating a whole method — e.g. `FunctionBuilder.SetReceiverType` in + [Expander.FunctionBuilder.cs](../../src/Build/Evaluation/Expander.FunctionBuilder.cs) owns the + single IL2067 suppression for the property-function receiver type, keeping + `Function.ExtractPropertyFunction` suppression-free. + +--- + +## 9. Cheat sheet + + +```mermaid +flowchart TD + S{"Trim or AOT problem?"} --> R["Reflection over members"] + S --> G["Runtime codegen: Emit / Expression.Compile"] + S --> F["On-disk file assumptions"] + R --> R1{"Members statically known?"} + R1 -->|Yes| RDAM["[DynamicallyAccessedMembers]"] + R1 -->|No, but optional| RFS["feature switch + [FeatureGuard] (pragma IL4000)"] + R1 -->|No, core path| RRUC["[RequiresUnreferencedCode] then propagate"] + G --> G1{"Optional with fallback?"} + G1 -->|Yes| GG["guard with RuntimeFeature.IsDynamicCodeSupported"] + G1 -->|No| GRDC["[RequiresDynamicCode] then propagate"] + F --> FF["[RequiresAssemblyFiles] or AppContext.BaseDirectory"] +``` + +--- + +## 10. References + +**Official docs** +- Trimming options & feature properties: +- Prepare libraries for trimming: +- Trim warnings index: +- Native AOT overview & limitations: +- Single-file overview & incompatibilities: +- `RuntimeFeature.IsDynamicCodeSupported`: +- `FeatureGuardAttribute`: + +**Designs / proposals / PRs** +- Feature-switch design (2020): +- Attribute-model API proposal: +- Attribute-model design discussion: +- Analyzer support for feature checks (introduces IL4000): + +**Sources** +- Feature-check body modeling: [FeatureChecksVisitor.cs](https://github.com/dotnet/runtime/blob/main/src/tools/illink/src/ILLink.RoslynAnalyzer/DataFlow/FeatureChecksVisitor.cs) +- IL4000 decision: [FeatureCheckReturnValuePattern.cs](https://github.com/dotnet/runtime/blob/main/src/tools/illink/src/ILLink.RoslynAnalyzer/TrimAnalysis/FeatureCheckReturnValuePattern.cs) +- IL4000 warning text: [SharedStrings.resx](https://github.com/dotnet/runtime/blob/main/src/tools/illink/src/ILLink.Shared/SharedStrings.resx) +- `RuntimeHostConfigurationOption` → trimmer feature switches: [Microsoft.NET.ILLink.targets](https://github.com/dotnet/runtime/blob/main/src/tools/illink/src/ILLink.Tasks/build/Microsoft.NET.ILLink.targets) (targets `_PrepareTrimConfiguration`, `_RunILLink`) +- `RuntimeHostConfigurationOption` → ILC args: [Microsoft.NETCore.Native.targets](https://github.com/dotnet/runtime/blob/main/src/coreclr/nativeaot/BuildIntegration/Microsoft.NETCore.Native.targets) +- `RuntimeHostConfigurationOption` → runtimeconfig.json: [Microsoft.NET.Sdk.targets](https://github.com/dotnet/sdk/blob/main/src/Tasks/Microsoft.NET.Build.Tasks/targets/Microsoft.NET.Sdk.targets) (target `GenerateBuildRuntimeConfigurationFiles`) +- Error-code list: +- Feature-switch list: + +**Real-world feature-switch registries** +- BCL `RuntimeFeature` under Native AOT (both properties hard-coded `false`): [RuntimeFeature.NativeAot.cs](https://github.com/dotnet/runtime/blob/main/src/coreclr/nativeaot/System.Private.CoreLib/src/System/Runtime/CompilerServices/RuntimeFeature.NativeAot.cs) +- .NET MAUI's product switch registry (stacked guards, const defaults, `#if NET9_0_OR_GREATER`): [RuntimeFeature.cs](https://github.com/dotnet/maui/blob/main/src/Core/src/RuntimeFeature.cs) + +**This repo** — see the [folder README](README.md) for the full document map. Key source: +- Central feature switches: [FeatureSwitches.cs](../../src/Framework/FeatureSwitches.cs) +- Attribute polyfills: [AotTrimmingPolyfills.cs](../../src/Framework/Polyfills/AotTrimmingPolyfills.cs) +- Property-function reflection, `[FeatureGuard]` probing, and the env-var gates: [Expander.Function.cs](../../src/Build/Evaluation/Expander.Function.cs) +- Localized IL2067 suppression (`SetReceiverType`): [Expander.FunctionBuilder.cs](../../src/Build/Evaluation/Expander.FunctionBuilder.cs) +- Curated property-function receiver allowlist: [PropertyFunctionReceiver.cs](../../src/Build/Evaluation/PropertyFunctionReceiver.cs) diff --git a/documentation/aot/property-functions-reachability.md b/documentation/aot/property-functions-reachability.md new file mode 100644 index 00000000000..4704b230087 --- /dev/null +++ b/documentation/aot/property-functions-reachability.md @@ -0,0 +1,380 @@ +# Property functions: execution model, reachability, and constraints + +**Status:** Background analysis. The constraining mode it motivates shipped in PR #14079 (see §10). + +**Bottom line:** instance property-function "dotting-in" could reach an open-ended, partly +state-mutating BCL type graph; the receiver surface is now **bounded to a small, rooted allowlist** and +the wide path is gated off (and trim-removed) behind feature switches. The analysis below is why. + +> Scope note: this document analyzes which types and members a property-function +> expression can reach by "dotting in" through chained calls. The reachable set +> matters for two engine concerns: **trimming/AOT** - an unbounded receiver surface +> forces the trimmer to root the members of an open-ended BCL type graph for +> reflection, which cannot be made trim-correct - and **the read-only expectation of +> property evaluation** - populating a property is expected to compute a value, so +> members that mutate external state fall outside that contract. The constraining +> design in §10 addresses both by bounding the receiver types to a small, statically +> rooted, side-effect-free set. + +## 1. Summary + +A *property function* is a call embedded in a `$(...)` property expression, e.g. +`$([System.Math]::Max(1, 2))` (static) or `$(SomeProp.Substring(0, 3))` +(instance). The engine parses the expression, resolves a receiver `Type`, binds a +public member by reflection, invokes it, and feeds the result back into the +remainder of the expression so calls can be **chained** (`$(P.A().B())`). + +Two gates are intended to constrain what can be called: + +1. **Static calls** are limited to a curated allowlist of types/members + (plus the MSBuild intrinsics). +2. **Instance calls** are allowed on *any* public member except `GetType`. + +The second gate is effectively unbounded. Because each chained call rebinds against +the **runtime type of the previous return value**, any allowlisted static that +returns a rich object (most importantly `System.IO.Directory.GetParent` → +`DirectoryInfo`) exposes that object's entire public surface, and transitively the +connected BCL object graph. This open-ended "dotting-in" reach is the core problem +both for trimming (the reflected member surface that must be rooted is unbounded) +and for the read-only expectation of property evaluation (the reachable surface +includes state-mutating members). + +## 2. Where the code lives + +The property-function code lives in the `Expander.*.cs` partial classes - primarily +[Expander.Function.cs](../../src/Build/Evaluation/Expander.Function.cs) (the nested type +`PropertyExpander.Function`), with the outer property expansion in +[Expander.PropertyExpander.cs](../../src/Build/Evaluation/Expander.PropertyExpander.cs) and the +argument splitter in [Expander.cs](../../src/Build/Evaluation/Expander.cs). The monolithic +`Expander.cs` was later split into these partials, so the inline `Expander.cs#L...` line anchors further +down predate the split and are approximate; the table here is current. + +| Concern | Member | Location | +| --- | --- | --- | +| Parse a `$(...)` body into a function and recurse the chain | `PropertyExpander.ExpandPropertyBody` | [Expander.PropertyExpander.cs#L255](../../src/Build/Evaluation/Expander.PropertyExpander.cs#L255) | +| Split a comma-separated argument list (atomic `$()` / quotes) | `ExtractFunctionArguments` | [Expander.cs#L606](../../src/Build/Evaluation/Expander.cs#L606) | +| Extract receiver/method/args/remainder; **derive receiver type** | `Function.ExtractPropertyFunction` | [Expander.Function.cs#L206](../../src/Build/Evaluation/Expander.Function.cs#L206) | +| Split method name / arguments / remainder | `Function.ConstructFunction` | [Expander.Function.cs#L888](../../src/Build/Evaluation/Expander.Function.cs#L888) | +| Execute the call, escape the result, recurse the remainder | `Function.Execute` | [Expander.Function.cs#L367](../../src/Build/Evaluation/Expander.Function.cs#L367) | +| Resolve a static receiver `Type` | `GetTypeForStaticMethod` | [Expander.Function.cs#L671](../../src/Build/Evaluation/Expander.Function.cs#L671) | +| **Static** allow gate | `IsStaticMethodAvailable` | [Expander.Function.cs#L1134](../../src/Build/Evaluation/Expander.Function.cs#L1134) | +| **Instance** allow gate (only blocks `GetType`) | `IsInstanceMethodAvailable` | [Expander.Function.cs#L1154](../../src/Build/Evaluation/Expander.Function.cs#L1154) | +| Argument coercion fallback | `CoerceArguments` | [Expander.Function.cs#L999](../../src/Build/Evaluation/Expander.Function.cs#L999) | +| Late-bound overload resolution | `LateBindExecute` | [Expander.Function.cs#L1244](../../src/Build/Evaluation/Expander.Function.cs#L1244) | +| Public-only binding invariant | `AllowedBindingFlags` + ctor assert | [Expander.Function.cs#L87](../../src/Build/Evaluation/Expander.Function.cs#L87) | +| The static allowlist data | `AvailableStaticMethods.InitializeAvailableMethods` | [Constants.cs#L305](../../src/Build/Resources/Constants.cs#L305) | +| Well-known function fast paths (no reflection) | `WellKnownFunctions.TryExecuteWellKnownFunction` | [WellKnownFunctions.cs](../../src/Build/Evaluation/Expander/WellKnownFunctions.cs) | +| Feature switch / legacy env-var escape hatch (read by **type resolution** and the **gates**) | `FeatureSwitches.EnableAllPropertyFunctions` | [FeatureSwitches.cs](../../src/Framework/FeatureSwitches.cs) | + +## 3. Execution model + +```mermaid +flowchart TD + A["$(body)"] --> B{IsValidPropertyName?} + B -->|yes| P[plain property lookup -> string] + B -->|"no, contains '.' or '['"| C[ExtractPropertyFunction] + C --> D{receiver} + D -->|"[Type]::M (propertyValue == null)"| E[GetTypeForStaticMethod] + D -->|"prop.M / chained"| F["receiverType = propertyValue?.GetType() ?? string"] + E --> G[Execute] + F --> G[Execute] + G --> H{objectInstance == null?} + H -->|yes static| I[IsStaticMethodAvailable] + H -->|no instance| J["IsInstanceMethodAvailable (only blocks GetType)"] + I --> K[bind public member, invoke] + J --> K + K --> L{remainder empty?} + L -->|yes| M[return result -> stringified into the property] + L -->|"no (.X / [i])"| C2[ExpandPropertyBody on remainder with result as new receiver] + C2 --> C +``` + +The chaining recursion is the crux. `Execute` finishes by calling +`ExpandPropertyBody(_remainder, functionResult, ...)` +([Expander.cs#L4277](../../src/Build/Evaluation/Expander.cs#L4277)). The +`functionResult` is carried as a **live object** - it is only converted to a +string if it already *is* a string (the escaping branch at +[Expander.cs#L4267](../../src/Build/Evaluation/Expander.cs#L4267)). When the +remainder is parsed, the next receiver type is +`propertyValue?.GetType() ?? typeof(string)` +([Expander.cs#L4019](../../src/Build/Evaluation/Expander.cs#L4019)) - i.e. the +**runtime type** of the previous result, with its full public surface. + +### 3.1 Static call gating is two-stage + +A static call `[Type]::Method(...)` is checked twice: + +1. **Type resolution** in `GetTypeForStaticMethod` + ([Expander.cs#L4373](../../src/Build/Evaluation/Expander.cs#L4373)): + - the allowlist cache (`AvailableStaticMethods.GetTypeInformationFromTypeCache`), then + - `Type.GetType(typeName)` against **corelib / the calling assembly only** (no + assembly-qualified probing), then + - assembly probing **only** when the `EnableAllPropertyFunctions` feature + switch is on. + A type that is neither allowlisted nor in corelib (e.g. + `System.Diagnostics.Process`) fails here → `InvalidFunctionTypeUnavailable` + (MSB4212). +2. **Execution gate** in `IsStaticMethodAvailable` + ([Expander.cs#L4835](../../src/Build/Evaluation/Expander.cs#L4835)): the + resolved type + method must be in the allowlist (or be `IntrinsicFunctions`, + or `EnableAllPropertyFunctions` must be set). A corelib type that resolved in + stage 1 but is not allowlisted (e.g. `System.GC`) fails here → + `InvalidFunctionMethodUnavailable` (MSB4185). + +### 3.2 Instance call gating is effectively unbounded by default + +In the **unrestricted** configuration (the untrimmed default), `IsInstanceMethodAvailable` +([Expander.cs#L4853](../../src/Build/Evaluation/Expander.cs#L4853)) reduces to a single denial: + +```csharp +return !string.Equals("GetType", methodName, StringComparison.OrdinalIgnoreCase); +``` + +so every other public instance method/property/field on the runtime receiver type is callable. +That unbounded surface is exactly what the **now-implemented** receiver restriction bounds: when +`RestrictPropertyFunctionReceivers` is on - the default under trimming, opt-in otherwise - this gate +instead consults the `PropertyFunctionReceiver` allowlist (§10). The reachability analysis in §4-§8 +describes the unrestricted surface; §10 is what closes it. + +## 4. The static allowlist + +Defined in +[`AvailableStaticMethods.InitializeAvailableMethods`](../../src/Build/Resources/Constants.cs#L305). +Two shapes of entry: + +- **Whole-type** (every public static is callable): numeric primitives, `Convert`, + `DateTime`, `DateTimeOffset`, `Enum`, `Guid`, `Math`, `String`, `StringComparer`, + `TimeSpan`, `Regex`, `Uri`, `UriBuilder`, `Version`, `IO.Path`, + `RuntimeInformation`, `OSPlatform`, `OperatingSystem`, plus the MSBuild + intrinsics `IntrinsicFunctions` (`[MSBuild]::`) and `ToolLocationHelper`. +- **Specific-member only** (only the named members are callable, but the receiver + *type* is still bound, so its full surface is reflected over once you have an + instance of it): `Environment`, `IO.Directory`, `IO.File`, + `Globalization.CultureInfo`. + +Every allowlisted `File`/`Directory`/`Environment` member is **read-only**. There +is no write/delete/move member anywhere in the allowlist, and `IntrinsicFunctions` +contains no filesystem/registry/process mutator. + +## 5. Parameters and conversions + +### 5.1 What an argument can be + +Arguments are parsed by `ExtractFunctionArguments` +([Expander.cs#L848](../../src/Build/Evaluation/Expander.cs#L848)): the content +between the call parentheses is split on `,`, with two kinds of span treated +atomically (commas inside them do not split): + +- a nested property expression `$(...)` (scanned by `ScanForClosingParenthesis`), and +- a quoted span using `` ` ``, `"`, or `'` (scanned by `ScanForClosingQuote`). + +Each raw argument is therefore a **string** at parse time. Empty entries are kept +and later treated as `null` ("We will keep empty entries so that we can treat them +as null"). At execution, each argument is expanded by +`ExpandPropertiesLeaveTypedAndEscaped` +([Expander.cs#L4110](../../src/Build/Evaluation/Expander.cs#L4110)), so a +nested `$()` *can* yield a typed object, but a bare literal stays a string +(unescaped before being passed out). + +Consequence: **you cannot express an arbitrary object argument.** There is no +syntax to construct a `System.Type`, a `byte[]`, a delegate, a `Stream`, a +`FileMode` other than via enum-from-string coercion, etc. This single fact +excludes large swaths of the BCL from being *callable* even though the types are +*reachable* as receivers (see §6 "inadvertent constraints"). + +### 5.2 How a string becomes a typed parameter + +Binding happens in `Execute` in three tiers: + +1. **Well-known fast path** - `WellKnownFunctions.TryExecuteWellKnownFunction` + handles common functions without reflection + ([Expander.cs#L4209](../../src/Build/Evaluation/Expander.cs#L4209)). +2. **Standard binder** - `_receiverType.InvokePublicMember(name, flags, instance, args)` + ([Expander.cs#L4245](../../src/Build/Evaluation/Expander.cs#L4245)) lets the + default reflection binder match and coerce. +3. **Late bind** - on `MissingMethodException`, `LateBindExecute` + ([Expander.cs#L4907](../../src/Build/Evaluation/Expander.cs#L4907)) tries an + all-`string` signature, then matches by name + argument count and runs + `CoerceArguments`. + +`CoerceArguments` +([Expander.cs#L4700](../../src/Build/Evaluation/Expander.cs#L4700)) is the +explicit conversion table: + +| Parameter type | Conversion | +| --- | --- | +| `char[]` | `arg.ToString().ToCharArray()` | +| an `enum` and the string contains `.` | strip leaf/full type name, `|`→`,`, `Enum.Parse` | +| anything else | `Convert.ChangeType(arg, paramType, InvariantCulture)` | + +Failures are swallowed and turned into "no match": `InvalidCastException`, +`FormatException`, and `OverflowException` all return `null` +([Expander.cs#L4743](../../src/Build/Evaluation/Expander.cs#L4743)). A +parameter type that is not `IConvertible`-coercible from a string therefore makes +the overload silently fail to bind. + +### 5.3 Special-case argument handling (in `Execute`) + +- **`Equals` / `CompareTo`**: the single argument is `Convert.ChangeType`-d to the + receiver's runtime type so comparisons line up + ([Expander.cs#L4162](../../src/Build/Evaluation/Expander.cs#L4162)). +- **`File` / `Directory` / `Path` receivers**: string args run through + `FileUtilities.FixFilePath`, and `File`/`Directory` path args are made absolute + against the thread working directory in `-mt` mode + ([Expander.cs#L4128](../../src/Build/Evaluation/Expander.cs#L4128)). +- **`new`**: routed to a constructor (`TryExecuteWellKnownConstructorNoThrow` or + `LateBindExecute` as a constructor). Only public constructors on the resolved + receiver type are eligible, so object construction is limited to allowlisted + types (e.g. `[System.Globalization.CultureInfo]::new('en-US')`). +- **`out _`**: out-parameters are defaulted and passed through `GetMethodResult`. + +### 5.4 Binding is public-only + +`AllowedBindingFlags` +([Expander.cs#L3789](../../src/Build/Evaluation/Expander.cs#L3789)) is +`IgnoreCase | Public | Static | Instance | InvokeMethod | GetProperty | GetField`. +`BindingFlags.NonPublic` is never set; the `Function` constructor masks the +incoming flags and asserts the invariant +([Expander.cs#L3853](../../src/Build/Evaluation/Expander.cs#L3853)). Private and +internal members are unreachable. + +## 6. Constraints, explicit and inadvertent + +### 6.1 Explicit constraints (designed gates) + +| Constraint | Code | Effect | +| --- | --- | --- | +| Static type must resolve from allowlist/corelib/(probe) | `GetTypeForStaticMethod` [L4373](../../src/Build/Evaluation/Expander.cs#L4373) | non-corelib, non-allowlisted type → "type unavailable" | +| Static method must be allowlisted | `IsStaticMethodAvailable` [L4835](../../src/Build/Evaluation/Expander.cs#L4835) | corelib-but-not-allowlisted method → "not available" | +| Instance method must not be `GetType` | `IsInstanceMethodAvailable` [L4853](../../src/Build/Evaluation/Expander.cs#L4853) | blocks reflection bootstrap via `obj.GetType()` | +| Public-only binding | `AllowedBindingFlags` [L3789](../../src/Build/Evaluation/Expander.cs#L3789) | private/internal members unreachable | + +### 6.2 Inadvertent constraints (things that fail to bind by accident) + +These are not designed constraints; they are limits of the syntax and the binder that +happen to make many otherwise-reachable members uncallable. They are the reason +the *practical* reachable set is far smaller than a naive type-graph closure. + +| Apparent capability | Why it actually fails | Code | +| --- | --- | --- | +| Reflection (`Type`, `Assembly`, `MethodInfo`, ...) | No argument can be a `System.Type`, so `Enum.GetUnderlyingType(Type)` (the only allowlisted member returning `Type`) can't be called; and `obj.GetType()` is blocked. The reflection graph is unreachable despite being in the type closure. | `ExtractFunctionArguments` [L848](../../src/Build/Evaluation/Expander.cs#L848); `IsInstanceMethodAvailable` [L4853](../../src/Build/Evaluation/Expander.cs#L4853) | +| `async` overloads returning `Task` | The allowlisted entry points (`File`/`Directory`) don't expose async statics, and reaching async I/O instance methods needs non-string args (`byte[]` buffers) that can't be expressed. | allowlist [Constants.cs#L305](../../src/Build/Resources/Constants.cs#L305); `CoerceArguments` [L4700](../../src/Build/Evaluation/Expander.cs#L4700) | +| Methods needing a non-coercible parameter (`Stream`, delegate, complex object) | `Convert.ChangeType` throws → caught → overload returns `null` → `MissingMethodException` → error. | `CoerceArguments` [L4743](../../src/Build/Evaluation/Expander.cs#L4743) | +| Array element access `arr[i]` | There is no indexer syntax. (Workaround: `arr.GetValue(0)` is a normal public method and *does* work - see §7.) | `ConstructFunction` [L4589](../../src/Build/Evaluation/Expander.cs#L4589) | +| Ending a chain on a non-string object | Not an error: the object is `ToString()`-ed into the property, often producing a useless value like `System.Threading.Tasks.Task\`1[...]`. "Works" only if the final value stringifies usefully. | result handling [L4267](../../src/Build/Evaluation/Expander.cs#L4267) | + +## 7. Vetted reachability examples + +All confirmed against a locally-built bootstrap MSBuild. Representative results: + +### 7.1 Reachable via dotting (observed result) + +| Expression | Result | Note | +| --- | --- | --- | +| `$([System.IO.Path]::GetFileName('x/HelloWorld').Substring(0,5))` | `Hello` | static → instance chain (normal) | +| `$([System.IO.Directory]::GetParent('F').Parent.FullName)` | parent dir | read-only directory navigation | +| `$(...GetFiles().GetValue(0).OpenRead().Length)` | `15` | array index reaches the open-ended `FileInfo`/`FileStream` surface | +| `$(...GetFiles('n').GetValue(0).OpenWrite().CanWrite)` | `True` | reaches a **state-mutating** member | +| `$(...GetFiles('n').GetValue(0).Delete())` | (empty) | reaches a **state-mutating** member | + +From `GetValue(0)` onward the chain flows through `FileInfo`/`FileStream`/`DirectoryInfo` - open-ended +types whose entire public surface would have to be rooted for trimming, several of whose members mutate +state (outside the read-only expectation of property evaluation). §10 bounds the receiver set so these +types are unreachable under the restriction. + +### 7.2 Blocked (observed error) + +| Expression | Error | Reason | +| --- | --- | --- | +| `$([System.IO.File]::ReadAllTextAsync('F'))` | MSB4185 | async static not allowlisted | +| `$([System.Diagnostics.Process]::GetCurrentProcess().Id)` | MSB4212 | type not allowlisted and not in corelib | +| `$([System.Enum]::GetUnderlyingType('System.DayOfWeek'))` | MSB4186 | `string`→`Type` can't coerce; no way to obtain a `Type` | +| `$(P.GetType().FullName)` | MSB4184 | `GetType` is the one denied instance method | + +## 8. Implications and the constraining hook + +- **Reads are already a supported capability** (`File::ReadAllText`, + `Directory::GetFiles`, `[MSBuild]::GetRegistryValue`, `Environment` reads), so + the read-via-`OpenRead` path adds no new capability - but it still reaches the + open-ended `Stream` surface, which bounded type rooting must exclude. +- **The members outside the read-only expectation are the state-mutating ones** - + `OpenWrite`/`Create`/`CopyTo`/`Delete`/`MoveTo`/`CreateSubdirectory` reached + through `DirectoryInfo`/`FileInfo`. Nothing in the read-only allowlist grants + these. They run at **evaluation time** (IDE folder open, `restore`, design-time + builds, `-getProperty`, auto-imported `Directory.Build.props`, NuGet-injected + `.props`/`.targets`), where populating a property is expected to be a read-only + computation and no target or task runs. +- The reachable I/O is **local filesystem only** (`HttpClient`/`WebClient` can't be + constructed; `Uri` does no I/O); there is no reachable network primitive. + +The **now-implemented** constraining mode (§10) does exactly this: it hooks `IsInstanceMethodAvailable` +([Expander.cs#L4853](../../src/Build/Evaluation/Expander.cs#L4853)), giving it the receiver runtime +`Type`, and enforces an **allowlist of side-effect-free receiver types** (string, primitives, `DateTime`/`TimeSpan`/ +`Version`/`Guid`/`decimal`, `CultureInfo`, `Uri`, `Regex`/`Match`, enums) over a +member deny-list - a small, statically known set the trimmer can root, rather than the +open BCL closure. Because dir-walk idioms such as +`$([System.IO.Directory]::GetParent($(X)).Parent.FullName)` are common in real +builds, the restriction is **opt-in under the JIT** (the `RestrictPropertyFunctionReceivers` +feature switch, default off) and **forced on under trimming** - a `[FeatureSwitchDefinition]` rather +than a `Trait` (which the trimmer keeps) or a `ChangeWave` (time-boxed opt-*outs*), so it folds to a +constant and the unbounded branch is removed. The full trim-safe design is in §10. + +## 9. The existing escape hatch and trim-time substitution + +`MSBUILDENABLEALLPROPERTYFUNCTIONS=1` widens reachability in untrimmed builds. The important trim +detail is that the environment variable is read through the +`FeatureSwitches.EnableAllPropertyFunctions` property, not directly at the call sites: + +- **The gates** `IsStaticMethodAvailable` + ([Expander.cs#L4843](../../src/Build/Evaluation/Expander.cs#L4843)) and + `IsInstanceMethodAvailable` + ([Expander.cs#L4855](../../src/Build/Evaluation/Expander.cs#L4855)) read + `FeatureSwitches.EnableAllPropertyFunctions`, so the "anything goes" branch is guarded by a + trimmer-substitutable property. +- **Type resolution** `GetTypeForStaticMethod` + ([Expander.cs#L4435](../../src/Build/Evaluation/Expander.cs#L4435)) reads + `FeatureSwitches.EnableAllPropertyFunctions` + ([FeatureSwitches.cs](../../src/Framework/FeatureSwitches.cs)), a + `[FeatureSwitchDefinition]` for `Microsoft.Build.EnableAllPropertyFunctions`. In untrimmed builds, + when the AppContext switch is unset, that property honors the legacy environment variable. Under + trimming the property is substituted with the constant `false`, so the assembly-probing branch and + its `[RequiresUnreferencedCode]` helpers (`GetTypeFromAssembly` + [L4509](../../src/Build/Evaluation/Expander.cs#L4509), + `GetTypeFromAssemblyUsingNamespace` + [L4467](../../src/Build/Evaluation/Expander.cs#L4467)) are removed. + +**Consequence.** In a *trimmed* application both the assembly-probing path and the wide +property-function gates are removed, and no runtime setting can re-enable them. In an untrimmed +application, the legacy environment variable still behaves as before when no AppContext switch was set. + +## 10. Constraining mode (implemented in #14079) + +**Status: implemented (PR #14079).** The unbounded instance "dotting-in" surface analyzed above is now +bounded by an opt-in / trim-forced receiver-type restriction. + +What shipped: + +- A receiver allowlist, [`PropertyFunctionReceiver`](../../src/Build/Evaluation/PropertyFunctionReceiver.cs), + gated by the `RestrictPropertyFunctionReceivers` feature switch in + [`FeatureSwitches`](../../src/Framework/FeatureSwitches.cs). The wide property-function gates now read + `FeatureSwitches.EnableAllPropertyFunctions` (a `[FeatureGuard]` switch) instead of the old `Traits` + environment read, so the trimmer can substitute a constant and remove the unbounded branch. +- The allowlist is a small, static `typeof(...)` set of side-effect-free receivers (`string`, the numeric + primitives, `bool`/`char`, `DateTime`/`DateTimeOffset`/`TimeSpan`, `Guid`, `Version`, `CultureInfo`, + `Uri`, `Regex`/`Match`/`Group`/`Capture`, plus any enum). Directory/file navigation is allowed via a + **member** allowlist (`FullName`, `Name`, `Exists`, `Parent`, `Root`, `Extension`, `Length`, `Directory`, + `DirectoryName`) that excludes every state-mutating member (`Open*`, `Create*`, `CopyTo`, `MoveTo`, + `Delete`, ...), so the common `$([System.IO.Directory]::GetParent($(X)).Parent.FullName)` idiom keeps + working while `FileInfo.OpenWrite`/`Delete` become unreachable. + +**Why a feature switch (not a `Trait` or a `ChangeWave`):** a `Trait` is a runtime environment read the +trimmer keeps, so it could not satisfy "no runtime re-enablement under trimming"; a +`[FeatureSwitchDefinition]` is substituted to a constant and its dead branch removed. ChangeWaves are +time-boxed opt-*outs*; this is a permanent trim boundary that must be non-disableable under trimming. + +**Behavior:** the untrimmed default is **wide** (unchanged - opt in via the +`Microsoft.Build.RestrictPropertyFunctionReceivers` AppContext switch, which has no environment variable); +a trimmed/AOT MSBuild **enforces the restriction by default**, and no runtime setting (including +`MSBUILDENABLEALLPROPERTYFUNCTIONS=1`) can re-open the removed branches. The one remaining suppression is +the `InvokePublicMember` call in [Expander.cs](../../src/Build/Evaluation/Expander.cs), whose +`DynamicallyAccessedMembers` justification is now honest because only allowlisted receiver types flow to it. diff --git a/documentation/aot/sdk-msbuild-object-model-audit.md b/documentation/aot/sdk-msbuild-object-model-audit.md new file mode 100644 index 00000000000..05694ee1c2e --- /dev/null +++ b/documentation/aot/sdk-msbuild-object-model-audit.md @@ -0,0 +1,312 @@ +# Audit: MSBuild object model usage in the .NET SDK CLI + +**Status:** Background analysis. + +This document audits how the .NET SDK (`dotnet` CLI) consumes the **MSBuild object +model** (the `Microsoft.Build.*` evaluation/execution/construction/graph APIs), and maps +each usage to the **CLI command** that exposes it. It is a companion to the MSBuild +trim/AOT specs - it identifies the precise OM surface a trimmed/AOT MSBuild host (the +`dotnet` CLI) must keep working, and which CLI commands drive the reflective execution +engine that cannot run under AOT (see the [folder README](README.md) for the full map): + +* [managing-trimming-and-aot.md](managing-trimming-and-aot.md) (the trim/AOT how-to and the + fail-observably design criterion) +* [aot-trimming-strategy.md](aot-trimming-strategy.md) (the strategy for removing, gating, + registering, annotating, or honestly marking trim/AOT-unfriendly paths) + +> **Method.** Audited a local checkout of `dotnet/sdk` at `n:\repos\sdk` +> (`main`, commit `7b0f367f33`) by searching for imports and call sites of +> `Microsoft.Build.Evaluation` / `Execution` / `Construction` / `Graph` / `Definition` +> and the forwarding-app types. SDK file paths below are repo-relative to `n:\repos\sdk`; +> line numbers drift, so search by member name. Test projects and the MSBuild **tasks** +> the SDK *ships* (`src/Tasks`, `src/WebSdk`, `src/RazorSdk`, ...) are not covered here - this +> audit is about the CLI **consuming** the OM, not authoring tasks. + +--- + +## 1. Two consumption models + +The SDK reaches MSBuild in two fundamentally different ways. + +```mermaid +flowchart TD + cli["dotnet CLI command"] --> decide{Needs the build engine,
or just project data?} + decide -- "build / restore / publish / pack / clean" --> fwd["Model A: command-line forwarding
MSBuildForwardingApp"] + decide -- "read or edit a project in-proc" --> om["Model B: in-process object model"] + + fwd --> inproc["ExecuteInProc:
MSBuild command-line entry point
inside the dotnet process (default)"] + fwd --> outproc["Out-of-proc:
spawn MSBuild.dll
(DOTNET_CLI_RUN_MSBUILD_OUTOFPROC,
custom MSBuild path)"] + + om --> evalm["Evaluation
Project / ProjectInstance / ProjectCollection"] + om --> constr["Construction
ProjectRootElement / SolutionFile"] + om --> graphtier["Graph
ProjectGraph"] + om --> exec["Execution engine
BuildManager"] + + style fwd fill:#1f3b57,color:#fff + style om fill:#3b1f57,color:#fff +``` + +### Model A - command-line forwarding (the build path) + +The dominant path. The CLI parses its own arguments, re-emits them as an MSBuild command +line, and runs MSBuild. It does **not** drive the object model - it hands a string[] to +MSBuild's entry point. + +* `MSBuildForwardingApp` (`src/Cli/dotnet/Commands/MSBuild/MSBuildForwardingApp.cs`) wraps + `MSBuildForwardingAppWithoutLogging` + (`src/Cli/Microsoft.DotNet.Cli.Utils/MSBuildForwardingAppWithoutLogging.cs`). +* `Execute()` runs MSBuild one of two ways: + * **In-process** (`_forwardingAppWithoutLogging.ExecuteInProc(arguments)`) - the default + when invoking the bundled `MSBuild.dll`. MSBuild's command-line front end runs *inside + the `dotnet` process*. This is the host that the trim/AOT effort ultimately targets. + * **Out-of-process** (`ProcessStartInfo.Execute()` via `ForwardingAppImplementation`) - + selected when `DOTNET_CLI_RUN_MSBUILD_OUTOFPROC=1`, a non-default `--msbuildPath` is + used, or an env-var edge case forces it. Spawns `MSBuild.dll` as a child process. + * Optionally via the **MSBuild server** (`DOTNET_CLI_USE_MSBUILD_SERVER` -> + `MSBUILDUSESERVER=1`). +* The **only** OM member this path touches is + `Microsoft.Build.Evaluation.ProjectCollection.DisplayVersion` (for `dotnet --version` / + `dotnet msbuild --version`). Everything else is argument, environment, and process + plumbing. +* It attaches the CLI's distributed **telemetry logger** pair (`MSBuildLogger` + + `MSBuildForwardingLogger`) via `-distributedlogger`. +* `RestoringCommand` (`src/Cli/dotnet/Commands/Restore/RestoringCommand.cs`) is the base + class for commands that implicitly restore and then build through this path. + +**Commands using Model A:** `build`, `clean`, `restore`, `publish`, `pack`, `msbuild`, +`store`, `package add`, `package list` (and the implicit restore baked into most of the +above). For these, MSBuild's *own* trim/AOT story governs - the SDK adds essentially no OM +surface. + +### Model B - in-process object model + +A sizable set of commands evaluate, inspect, edit, or build projects **in-process** using +the OM directly. These are the commands that pin a concrete OM surface, enumerated below. + +--- + +## 2. Object-model API surface the SDK depends on + +| Namespace | Types / members used | Where (representative) | +| --- | --- | --- | +| `Microsoft.Build.Evaluation` | `ProjectCollection` (many ctor overloads incl. `reuseProjectRootElementCache`, `GlobalProjectCollection`, `DisplayVersion`, `LoadProject`, `RegisterLogger`, `UnloadAllProjects`), `Project` (`CreateProjectInstance`, evaluated props/items via extensions), `ToolsetDefinitionLocations`, `Context.EvaluationContext` (`SharingPolicy.Shared`) | forwarding (`DisplayVersion`), `MsbuildProject`, `RunCommand`, `MSBuildEvaluator`, Test MTP, `dotnet watch`, completion | +| `Microsoft.Build.Execution` | `ProjectInstance` (`FromFile`, `new(ProjectRootElement)`, `GetPropertyValue`, `GetItems`, `CreateProjectInstance`), `BuildManager` (`DefaultBuildManager`, `BeginBuild`, `PendBuildRequest`/`ExecuteAsync`, `EndBuild`, `CancelAllSubmissions`, `ShutdownAllNodes`), `BuildParameters`, `BuildRequestData`, `BuildResult` / `BuildResultCode`, `TargetResult`, `ProjectOptions` | `ReleasePropertyProjectLocator`, `RunCommand`, `ProjectConvertCommand`, Test MTP, `dotnet watch` (`ProjectBuildManager`), `build-server`, file-based build | +| `Microsoft.Build.Construction` | `ProjectRootElement` (`Open`, `Create`, `CreateItemElement`, item/group elements), `ProjectItemElement`, `ProjectItemGroupElement`, `SolutionFile` (`Parse`, `ProjectsInOrder`, `SolutionConfigurations`, `GetDefaultConfigurationName`/`PlatformName`, **internal** `ProjectShouldBuild`) | `MsbuildProject`, `SolutionAddCommand`, `VirtualProjectBuilder`, `VirtualProjectPackageReflector`, Test MTP | +| `Microsoft.Build.Graph` | `ProjectGraph`, `ProjectGraphEntryPoint`, `ProjectCreationFailedException` | `dotnet watch` (`ProjectGraphFactory`, `LoadedProjectGraph`), `workload restore` | +| `Microsoft.Build.Definition` | `ProjectOptions` (passed to `ProjectInstance.FromFile` / `Project`) | `VirtualProjectBuilder`, `ProjectConvertCommand`, Test MTP | +| `Microsoft.Build.Logging` | `ConsoleLogger`, `BinaryLogger`, `SimpleErrorLogger`; plus the CLI's `MSBuildLogger` / `MSBuildForwardingLogger` | `MsbuildProject` (interactive auth), `VirtualProjectBuildingCommand`, `dotnet watch`, forwarding | +| `Microsoft.Build.Framework` | `ILogger`, `LoggerVerbosity`, `BuildEventArgs` (logging contracts consumed by the loggers above) | across the OM consumers | + +--- + +## 3. Command -> object model map + +Legend for **OM tier**: **Fwd** = forwarding only; **Eval** = evaluation + property/item +reads; **Constr** = XML construction; **Graph** = `ProjectGraph`; **Exec** = drives +`BuildManager` / the build engine in-proc. + +| CLI command | OM tier | What the OM is used for | Key SDK file(s) | +| --- | --- | --- | --- | +| `dotnet build` | Fwd | Emit MSBuild command line; run in-proc or out-of-proc | `Commands/Build/*`, `Commands/Restore/RestoringCommand.cs`, `Commands/MSBuild/MSBuildForwardingApp.cs` | +| `dotnet clean` | Fwd | same | `Commands/Clean/CleanCommand.cs` | +| `dotnet restore` | Fwd | same | `Commands/Restore/RestoreCommand.cs` | +| `dotnet msbuild` | Fwd | Pass-through to MSBuild | `Commands/MSBuild/MSBuildCommand.cs` | +| `dotnet store` | Fwd | Runtime store build | `Commands/Tool/Store/StoreCommand.cs` | +| `dotnet package add` / `list` | Fwd | Edit/list package refs via MSBuild targets | `Commands/Package/Add`, `Commands/Package/List` | +| `dotnet publish` | Fwd + **Eval** | Build via forwarding; **plus** evaluate the project/solution to read `PublishRelease` and inject `Configuration=Release` | `Commands/Publish/PublishCommand.cs`, `ReleasePropertyProjectLocator.cs` | +| `dotnet pack` | Fwd + **Eval** | same, for `PackRelease` | `Commands/Pack/*`, `ReleasePropertyProjectLocator.cs` | +| `dotnet reference add` / `remove` | **Constr** + **Eval** | Edit `ProjectReference` items in the project XML (`ProjectRootElement`); evaluate `Project` to check TFM / RID / Configuration compatibility | `MsbuildProject.cs`, `Commands/Reference/Add`, `Commands/Reference/Remove` | +| `dotnet reference list` | **Constr** + **Exec*** | `ProjectRootElement` + `new ProjectInstance(root)` then `GetItems("ProjectReference")` | `Commands/Reference/List/ReferenceListCommand.cs` | +| `dotnet solution add` | **Constr** + **Exec*** | `ProjectRootElement.Open` + `new ProjectInstance(root)`; `GetItems("ProjectReference")` to add referenced projects/solution folders | `Commands/Solution/Add/SolutionAddCommand.cs` | +| `dotnet run` (project) | **Eval** + **Exec*** | `ProjectCollection.LoadProject(...).CreateProjectInstance()`; read `RunCommand`/`RunArguments`/`RunWorkingDirectory`/`OutputType`/`TargetFramework(s)`; `GetItems(ProjectCapability)`; invoke the `ComputeRunArguments` target | `Commands/Run/RunCommand.cs` | +| `dotnet run file.cs` (file-based app) | **Constr** + **Eval** + **Exec** | Build an **in-memory** virtual project (`ProjectRootElement` -> `ProjectInstance`) and build it via `BuildManager` (or skip to a CSC fast path) | `Commands/Run/VirtualProjectBuildingCommand.cs`, `Microsoft.DotNet.ProjectTools/VirtualProjectBuilder.cs` | +| `dotnet project convert` | **Eval** + **Exec** | Materialize a real `.csproj` from a file-based app: `VirtualProjectBuilder.CreateProjectInstance`, `ProjectInstance.FromFile`, `GetItems`/`GetPropertyValue` | `Commands/Project/Convert/ProjectConvertCommand.cs` | +| `dotnet pack file.cs` / file-based package | **Constr** + **Exec** | Reflect NuGet's edits back to directives via `ProjectRootElement`; build the virtual project | `Commands/Package/VirtualProjectPackageReflector.cs`, `Commands/NuGet/NuGetVirtualProjectBuilder.cs` | +| `dotnet run-api` (IDE protocol) | **Eval** + **Exec** | `VirtualProjectBuilder.CreateProjectInstance` to answer IDE run queries | `Commands/Run/Api/RunApiCommand.cs` | +| `dotnet test` (Microsoft.Testing.Platform) | **Constr** + **Eval** + **Exec*** | `SolutionFile.Parse` to enumerate projects; per-project `ProjectInstance.FromFile` evaluation over a shared `EvaluationContext` to read `IsTestProject`, `IsTestingPlatformApplication`, `TargetFramework(s)`, `RunCommand`, `TargetPath`, ... | `Commands/Test/MTP/MSBuildUtility.cs`, `Commands/Test/MTP/SolutionAndProjectUtility.cs` | +| `dotnet new` (templates) | **Eval** | `ProjectCollection` + `Project` evaluation to read **project capabilities** / SDK-style / TFM for template constraint matching | `Commands/New/MSBuildEvaluation/MSBuildEvaluator.cs`, `.../ProjectCapabilityConstraint.cs` | +| `dotnet workload restore` | **Eval** + **Graph** | Evaluate the project/graph to discover workload references to restore | `Commands/Workload/Restore/WorkloadRestoreCommand.cs` | +| `dotnet build-server shutdown` | **Exec** | `BuildManager.DefaultBuildManager.ShutdownAllNodes()` | `BuildServer/MSBuildServer.cs` | +| shell tab completion | **Eval** | `new ProjectCollection()` to evaluate for completion candidates | `CliCompletion.cs` | +| `dotnet watch` | **Eval** + **Graph** + **Exec** | Build a `ProjectGraph` (watched-file set + dependency order) over a cached `ProjectCollection`; run **incremental in-proc builds** via `BuildManager` for Hot Reload | `src/Dotnet.Watch/Watch/Build/*` (`ProjectGraphFactory`, `LoadedProjectGraph`, `ProjectBuildManager`, `EvaluationResult`, `ProjectGraphUtilities`), `.../HotReload/CompilationHandler.cs`, `.../Build/MsBuildFileSetFactory.cs` | + +\* **Exec\*** here means a `ProjectInstance` is constructed (which runs *evaluation*), but +no `BuildManager` build is driven - only evaluated data is read. True build-engine drivers +(`BuildManager`) are `dotnet watch`, the file-based `run`/`pack`/`convert`/`run-api` +virtual builds, and `build-server`. + +--- + +## 4. Deep dives on the in-process consumers + +### 4.1 Release-configuration detection (`publish`, `pack`) + +`ReleasePropertyProjectLocator` exists because `Configuration` cannot be set *inside* a +project file but must be known *before* evaluation. The CLI evaluates the targeted project +(or an arbitrary project from a solution) as a `ProjectInstance` and reads `PublishRelease` +/ `PackRelease` (`ProjectInstance.GetPropertyValue`) so it can inject +`-property:Configuration=Release` into the subsequent forwarding build. For solutions it +parses with the SDK's `SlnFileFactory` and evaluates projects in parallel, throwing +`GracefulException` if projects disagree. **Pure evaluation + property reads.** + +### 4.2 Project / solution editing (`reference`, `solution add`) + +`MsbuildProject` is the shared helper. Reference add/remove operate on the **construction** +model (`ProjectRootElement`, `ProjectItemElement`, `ProjectItemGroupElement`, +`CreateItemElement`) - editing `ProjectReference` items in the XML and saving. Listing and +`solution add` additionally construct a `ProjectInstance` from the `ProjectRootElement` to +enumerate evaluated `ProjectReference` items. TFM/RID/Configuration compatibility checks go +through an evaluated `Project` (`GetTargetFrameworks`, `GetRuntimeIdentifiers`, +`GetConfigurations`). For interactive restore it registers a `ConsoleLogger`. **No build +engine.** + +### 4.3 `dotnet run` (project) + +`RunCommand` loads the project (`ProjectCollection.LoadProject(...).CreateProjectInstance()`), +validates it (`OutputType`, `TargetFramework(s)`), reads `RunCommand`/`RunArguments`/ +`RunWorkingDirectory`, checks `ProjectCapability` items, and can invoke the +`ComputeRunArguments`/run-arguments target to compute how to launch the app. The build +itself is delegated to the forwarding path unless `--no-build`. **Evaluation + targeted +property/item reads (+ a target invocation).** + +### 4.4 File-based apps (`run file.cs`, `project convert`, file-based `pack`, `run-api`) + +This is the heaviest OM dependency. `VirtualProjectBuilder` +(`src/Microsoft.DotNet.ProjectTools`) constructs an **in-memory** project from a `.cs` +file's `#:` directives: it builds a `ProjectRootElement` (Construction + Definition), +produces a `ProjectInstance`, and `VirtualProjectBuildingCommand` either runs a **CSC-only +fast path** (when the file needs no MSBuild props/targets/restore) or a full +`BuildManager` build. Notable engine couplings: + +* It **pins** the virtual `ProjectRootElement` to keep MSBuild's `ProjectRootElementCache` + from demoting it to a weak reference (otherwise nested `` re-evaluations fail + with `MSB4025`, because the project does not exist on disk). This is a deliberate + dependence on `ProjectRootElementCache` GC semantics. +* `ProjectConvertCommand` turns the virtual project into a real `.csproj`, reading items + and properties (`UserSecretsId`, default props) off the `ProjectInstance`. + +These paths drive the **full build engine in-process** and therefore exercise the +reflective task-loading subsystem. + +### 4.5 `dotnet test` (Microsoft.Testing.Platform mode) + +`MSBuildUtility` + `SolutionAndProjectUtility` parse a `SolutionFile` +(`SolutionFile.Parse`, `ProjectsInOrder`, `SolutionConfigurations`) and evaluate each +project (`ProjectInstance.FromFile` over a shared `EvaluationContext`) to discover test +modules and their properties (`IsTestProject`, `IsTestingPlatformApplication`, +`TargetFramework(s)`, `RunCommand`, `TargetPath`, `TestTfmsInParallel`). It reaches into +MSBuild's **internal** `SolutionFile.ProjectShouldBuild` via `[UnsafeAccessor]` - see the +caveat in section 6. **Solution parse + evaluation; the actual build is forwarded.** + +### 4.6 `dotnet new` (template engine) + +`MSBuildEvaluator` keeps a `ProjectCollection` and evaluates the project at the output +location to feed template **constraints** (project capabilities, SDK-style detection, +target frameworks). **Pure evaluation.** + +### 4.7 `dotnet watch` + +The richest consumer. `ProjectGraphFactory` builds a `ProjectGraph` over a long-lived +`ProjectCollection` constructed with `reuseProjectRootElementCache: true` and +`maxNodeCount: 1`, with a custom `ProjectInstance` factory. `LoadedProjectGraph` derives +the watched-file set and dependency order. `ProjectBuildManager` then drives **incremental +in-proc builds** with the full Execution API: `BuildManager.DefaultBuildManager`, +`BuildParameters(collection)`, `BuildRequestData(projectInstance, targets)`, +`BeginBuild` / `PendBuildRequest(...).ExecuteAsync(...)` / `EndBuild`, +`CancelAllSubmissions`, and reads `BuildResult` / `TargetResult` / `BuildResultCode`. Hot +Reload (`CompilationHandler`) reuses these evaluated instances. **Graph + evaluation + the +build engine.** + +--- + +## 5. The SDK already partitions the object model for AOT + +The SDK ships an **AOT build of the CLI** (`src/Cli/dotnet-aot/`) and guards OM-heavy code +behind the `CLI_AOT` compilation symbol. For example, `MsbuildProject.cs` wraps **all** of +its `Microsoft.Build.Construction` / `Evaluation` usage in `#if !CLI_AOT`, and +`MSBuildForwardingAppWithoutLogging` is `#if NET`. Files currently carrying the partition +include `MsbuildProject.cs`, `CommandBase.cs`, `Program.cs`, `Parser.cs`, +`CommandLineInfo.cs`, `Extensions/ParseResultExtensions.cs`, and +`Commands/Solution/SolutionCommandParser.cs`. + +The takeaway for MSBuild: the SDK already treats the in-proc object model as **not +AOT-ready** and routes its AOT CLI toward the forwarding/process model. A trim/AOT-capable +MSBuild evaluation OM is what would let those `#if !CLI_AOT` exclusions shrink. + +--- + +## 6. Implications for the MSBuild trim/AOT effort + +Mapping the surface above onto the [fail-observably design +criterion](managing-trimming-and-aot.md#msbuilds-overriding-design-criterion-fail-observably-never-silently) +and the strategy in [aot-trimming-strategy.md](aot-trimming-strategy.md): + +The practical target is **evaluation first, execution by closed-world opt-in**. Evaluation is the +highest-value surface for the SDK and can be kept trim/AOT-clean. Execution is still the hard tier, but it +is no longer an all-or-nothing wall: intrinsic tasks and host-registered task classes can run in-process under +AOT, while arbitrary runtime-discovered tasks and plugins must report an observable error so the CLI can fall +back to a JIT MSBuild. + +1. **The forwarding path is already trim-safe** (it only reads `ProjectCollection.DisplayVersion`). + It is also the dominant build path, so `build`/`restore`/`publish`/`pack`/`clean` ride + on MSBuild's *own* command-line front end - whatever AOT story MSBuild has for + `MSBuild.dll` covers them. +2. **Evaluation + property/item reads are the SDK's real OM dependency.** + `publish`/`pack` (release detection), `new`, `test` discovery, `run` (project), + `reference` compatibility, `workload restore`, completion, and the `watch` graph all + need `Project` / `ProjectInstance` / `ProjectCollection` evaluation plus + `GetPropertyValue` / `GetItems` / `ProjectGraph` to be trim-safe. This is the highest-value + surface to keep working under trim/AOT. +3. **Construction (XML) editing is inherently trim-safe.** `reference`/`solution` editing + uses `ProjectRootElement` / `SolutionFile` (XML and text parsing, no task loading) and + needs no special treatment beyond ordinary trim-correctness. +4. **The execution-engine drivers are the trim/AOT-hard paths.** `dotnet watch`'s + incremental `BuildManager` builds and the file-based `run`/`pack`/`convert`/`run-api` + virtual builds drive the full engine. A closed-world subset can run under AOT when the host + registers the tasks it needs, and intrinsic `MSBuild`/`CallTarget` tasks stay available; an + open-world task/plugin path that needs reflective loading must **fail observably** (or fall back to a + JIT/out-of-proc MSBuild). The forwarding path is the natural fallback the AOT CLI already has. +5. **`SolutionFile.ProjectShouldBuild` is consumed via `[UnsafeAccessor]`** (a private + member) in `dotnet test`. This is a fragile cross-repo coupling; MSBuild exposing a + public equivalent (tracked by dotnet/msbuild#12711) would remove a reflective dependency + from the SDK's test path. +6. **`ProjectRootElementCache` semantics are load-bearing.** The file-based-app builder + pins its in-memory `ProjectRootElement` to survive cache demotion (else `MSB4025`). + Changes to cache eviction or `reuseProjectRootElementCache` behavior can break + `dotnet run file.cs`. + +--- + +## 7. Appendix - production OM consumers (file inventory) + +`src/Cli/dotnet`: + +* `Commands/MSBuild/MSBuildForwardingApp.cs`, `Commands/MSBuild/MSBuildCommand.cs` +* `Commands/Restore/RestoringCommand.cs`, `Commands/Restore/RestoreCommand.cs` +* `ReleasePropertyProjectLocator.cs` +* `MsbuildProject.cs`, `Extensions/ProjectExtensions.cs`, + `Extensions/ProjectInstanceExtensions.cs`, `Extensions/ProjectRootElementExtensions.cs` +* `Commands/Reference/{Add,List,Remove}/*`, `Commands/Solution/Add/SolutionAddCommand.cs` +* `Commands/Run/{RunCommand,VirtualProjectBuildingCommand,RunCommandSelector,EnvironmentVariablesToMSBuild,RunProperties,RunTelemetry}.cs`, + `Commands/Run/Api/RunApiCommand.cs` +* `Commands/Project/Convert/ProjectConvertCommand.cs`, + `Commands/Package/VirtualProjectPackageReflector.cs`, + `Commands/NuGet/NuGetVirtualProjectBuilder.cs` +* `Commands/Test/MTP/{MSBuildUtility,SolutionAndProjectUtility,MicrosoftTestingPlatformTestCommand}.cs` +* `Commands/New/MSBuildEvaluation/{MSBuildEvaluator,ProjectCapabilityConstraint}.cs` +* `Commands/Workload/Restore/WorkloadRestoreCommand.cs` +* `BuildServer/MSBuildServer.cs`, `CliCompletion.cs`, + `CommandFactory/CommandResolution/MSBuildProject.cs` + +`src/Cli/Microsoft.DotNet.Cli.Utils`: + +* `MSBuildForwardingAppWithoutLogging.cs`, `Extensions/MSBuildProjectExtensions.cs` + +`src/Microsoft.DotNet.ProjectTools`: + +* `VirtualProjectBuilder.cs` + +`src/Dotnet.Watch/Watch`: + +* `Build/{ProjectGraphFactory,LoadedProjectGraph,ProjectBuildManager,EvaluationResult,ProjectGraphUtilities,FilePathExclusions,BuildResult,BuildRequest,MsBuildFileSetFactory}.cs` +* `HotReload/{CompilationHandler,HotReloadDotNetWatcher}.cs`, plus the `AppModels/*` set diff --git a/documentation/aot/sdk-resolution.md b/documentation/aot/sdk-resolution.md new file mode 100644 index 00000000000..a067bad512f --- /dev/null +++ b/documentation/aot/sdk-resolution.md @@ -0,0 +1,367 @@ +# SDK resolution in MSBuild: how it works, and a plan to make it trim/AOT-safe + +**Status:** Implemented (the fail-observably SDK-resolution path; see Part 2). + +This document has two parts: + +1. **How SDK resolution works** - where resolver manifests come from, what the built-in + fallback is, and how discovered resolvers are matched to a project's `Sdk` (including + `` and a project with no `Sdk` at all). +2. **A plan** to stop the SDK-resolution path from surfacing `[RequiresUnreferencedCode]` + all the way up to the `Project` constructors, and instead **fail observably** (via + `ProjectFileErrorUtilities.ThrowInvalidProjectFile`) when an SDK actually needs a + dynamically loaded resolver. + +See the [folder README](README.md) for the full map; this most directly complements the mechanics +how-to, [managing-trimming-and-aot.md](managing-trimming-and-aot.md). File and line +references drift - search by member name. + +--- + +## Part 1 - How SDK resolution works + +### 1.1 What triggers resolution + +An SDK reference enters evaluation in one of these forms, all of which become an +`SdkReference` on a (possibly implicit) `ProjectImportElement`: + +* `` or `Sdk="Name/Version"` - MSBuild synthesizes two + **implicit imports**: `Sdk.props` at the very top of the project and `Sdk.targets` at the + very bottom, each carrying the `SdkReference`. +* `` element - same implicit-import behavior. +* `` - an explicit SDK-style import. + +During evaluation, `Evaluator.ExpandAndLoadImportsFromUnescapedImportExpression` +([Evaluator.cs](../../src/Build/Evaluation/Evaluator.cs)) sees `importElement.SdkReference` +is non-null and calls `_sdkResolverService.ResolveSdk(...)` to turn the SDK *name* into a +*path*, then loads the imported file from `Path.Combine(sdkResult.Path, project)`. + +**A project with no `Sdk`** - no `Sdk` attribute, no `` element, and no `Sdk=` on any +`` - never produces an `SdkReference`, so `ResolveSdk` is **never called** and no +resolver (not even the default one) is touched. This is the trivial, fully trim-safe case. + +### 1.2 Where resolver manifests come from + +The central resolver is `SdkResolverService` +([SdkResolverService.cs](../../src/Build/BackEnd/Components/SdkResolution/SdkResolverService.cs)). +On first use it builds a manifest registry via `RegisterResolversManifests` -> +`SdkResolverLoader.GetResolversManifests` +([SdkResolverLoader.cs](../../src/Build/BackEnd/Components/SdkResolution/SdkResolverLoader.cs)) +-> `FindPotentialSdkResolversManifests`: + +* Root folder = `BuildEnvironmentHelper.Instance.MSBuildToolsDirectoryRoot` + `\SdkResolvers`. +* Each **immediate subfolder** is one resolver package. For a subfolder `Foo\`, the loader + looks for, in order: + * `Foo\Foo.xml` - a **manifest** (preferred): + ```xml + + relative-or-absolute\Resolver.dll + Optional regex of SDK names this resolver handles + + ``` + * `Foo\Foo.dll` - the resolver assembly directly (no pattern), if there is no manifest. + * If **neither** exists -> `ProjectFileErrorUtilities.ThrowInvalidProjectFile("SdkResolverNoDllOrManifest")`. +* Manifest parsing (`SdkResolverManifest.Load`) reads the XML and, if present, compiles + `ResolvableSdkPattern` into a `Regex` (with a 500 ms match timeout). **This step is + reflection-free** - it only reads files and builds a registry; no resolver assembly is + loaded yet. + +Knobs: + +* `MSBUILDADDITIONALSDKRESOLVERSFOLDER` (and `_NET` / `_NETFRAMEWORK` variants) - test hook + that adds an override resolver folder. +* `MSBUILDINCLUDEDEFAULTSDKRESOLVER=false` - drop the built-in default resolver. +* Legacy path: when ChangeWave 17.10 is **disabled**, `SdkResolverService` uses a + non-caching `SdkResolverLoader` and the eager `LoadAllResolvers`, which scans + `MSBuildToolsDirectory32\SdkResolvers` and loads **every** resolver up front. The default + (17.10 enabled) path is manifest-based and lazy via `CachingSdkResolverLoader`. + +### 1.3 The built-in fallback resolver + +`SdkResolverLoader.GetDefaultResolvers()` returns a single, in-process, **reflection-free** +`DefaultSdkResolver` +([DefaultSdkResolver.cs](../../src/Build/BackEnd/Components/SdkResolution/DefaultSdkResolver.cs)), +Priority `10000` (lowest). It resolves an SDK purely by probing the filesystem: + +``` +sdkPath = Path.Combine(BuildEnvironmentHelper.Instance.MSBuildSDKsPath, sdk.Name, "Sdk") +-> Directory.Exists(sdkPath) ? success(sdkPath) : failure +``` + +`MSBuildSDKsPath` is the SDK install's `Sdks` folder (`\sdk\\Sdks`), or VS's +`MSBuild\Sdks`, or the `MSBUILDSDKSPATH` override. **No assembly is loaded.** + +On **.NET** (`dotnet build`), `ResolveSdk` asks the `DefaultSdkResolver` **first** (the +`#if NET` + Wave17_10 block at the top of `SdkResolverService.ResolveSdk`), as a perf +optimization and for parity with the Framework `Microsoft.DotNet.MSBuildSdkResolver`. So +**in-box SDKs are resolved by a directory probe before any plugin assembly is loaded.** + +### 1.4 How resolvers are matched to a project's SDK + +If the default resolver does not resolve it, `ResolveSdkUsingResolversWithPatternsFirst` +runs a two-pass match over the manifest registry: + +1. **Specific resolvers** = manifests that have a `ResolvableSdkPattern`. The SDK name is + tested with `manifest.ResolvableSdkRegex.IsMatch(sdk.Name)`. Every matching manifest's + resolvers are **loaded** (this is the reflective step - see 1.6), sorted by `Priority`, + and tried in order until one returns success. +2. **General resolvers** = manifests with **no** pattern (they apply to any SDK name). If the + first pass did not succeed, these are loaded, sorted by `Priority`, and tried next. + +First success wins; the `SdkResult` carries the resolved `Path` (plus optional version, +properties, and items). So a resolver opts into a name family by declaring +``; with no pattern it is a general resolver tried for every SDK. + +```mermaid +flowchart TD + A["Sdk reference (e.g. Microsoft.NET.Sdk)"] --> B{".NET? default resolver first"} + B -- "in-box: MSBuildSDKsPath\\Name\\Sdk exists" --> OK["Resolved (no assembly loaded)"] + B -- "not in-box / Framework" --> C[Register manifests
read XML, build regex registry] + C --> D{"Specific manifests
ResolvableSdkRegex.IsMatch(name)?"} + D -- match --> E["LOAD those resolver DLLs
(reflection) -> try by priority"] + E -- success --> OK + D -- "no match / all failed" --> F["LOAD general resolver DLLs
(reflection) -> try by priority"] + F -- success --> OK + F -- "none resolve" --> G["failOnUnresolvedSdk?
ThrowInvalidProject CouldNotResolveSdk"] + style E fill:#3b1f57,color:#fff + style F fill:#3b1f57,color:#fff + style OK fill:#1f3b57,color:#fff +``` + +### 1.5 Worked examples + +* **`` on `dotnet build`:** the `DefaultSdkResolver` + probes `\sdk\\Sdks\Microsoft.NET.Sdk\Sdk`, which exists, so it resolves with + **no plugin assembly loaded**. (On `MSBuild.exe`/Framework, `Microsoft.DotNet.MSBuildSdkResolver` + - a plugin - does the equivalent in-box lookup plus global.json/workload logic.) +* **`` (a NuGet-delivered SDK):** the default resolver fails + (not under `MSBuildSDKsPath`), so the general `Microsoft.Build.NuGetSdkResolver` is + **loaded by reflection** and downloads/resolves the package. +* **A workload SDK:** the workload resolver (`Microsoft.NET.Sdk.WorkloadMSBuildSdkResolver`) + - a plugin - handles it. +* **No `Sdk` at all:** `ResolveSdk` is never invoked. + +### 1.6 Where the reflection (the RUC root) is + +The only reflective work is **loading a resolver assembly**, in +[SdkResolverLoader.cs](../../src/Build/BackEnd/Components/SdkResolution/SdkResolverLoader.cs): + +* `LoadResolverAssembly` -> `Assembly.LoadFrom` / `Assembly.Load` / `CoreClrAssemblyLoader.LoadFromPath`. +* `GetResolverTypes` -> `assembly.ExportedTypes` + `typeof(SdkResolver).IsAssignableFrom(t)`. +* `LoadResolvers` -> `Activator.CreateInstance` of each resolver type. + +These are `[RequiresUnreferencedCode]`, and the attribute propagates up the call graph: +`LoadResolversFromManifest` -> `SdkResolverService.GetResolvers` -> +`ResolveSdkUsingResolversWithPatternsFirst` -> `ResolveSdk` -> `ISdkResolverService.ResolveSdk` +(and its `Caching`/`MainNode`/`OutOfProc`/`Hosted` implementations) -> +`Evaluator.ExpandAndLoadImports*` / `Evaluator.Evaluate` -> **every public `Project` and +`ProjectInstance` constructor and factory** (see +[Project.cs](../../src/Build/Definition/Project.cs)). That propagation is *honest* today, but +it taints the entire evaluation entry surface that the .NET SDK depends on. + +### 1.7 How resolution fails today + +* No resolver resolves and `failOnUnresolvedSdk` is set (the default unless + `ProjectLoadSettings.IgnoreMissingImports`): the evaluator already **fails observably** via + `ProjectErrorUtilities.ThrowInvalidProject(importElement.SdkLocation, "CouldNotResolveSdk", ...)`. +* A resolver throws: it becomes `SDKResolverFailed` / `SDKResolverCriticalFailure` (also a + reported project-file error). +* A manifest folder has neither a `.dll` nor a `.xml`: `SdkResolverNoDllOrManifest`. + +So evaluation already has an observable-failure contract for *unresolvable* SDKs. The plan +below adds one more observable-failure case: an SDK that *could* be resolved, but only by a +resolver that would have to be **dynamically loaded** in a host that cannot do so. + +--- + +## Part 2 - Plan: stop surfacing RUC; fail observably when a resolver must be dynamically loaded + +> **Status: implemented.** All steps below are in the tree: the `EnableSdkResolverDynamicLoading` +> feature switch, the guarded `SdkResolverService.GetResolvers` funnel, the `MSB4282` +> (`SdkResolverDynamicLoadingNotSupported`) resource, the RUC removal up to the public +> `Project`/`ProjectInstance`/`ProjectGraph`/`ProjectCollection` surface, the +> `SdkResolverService_Tests` switch tests, and the `aot-validation` harness's +> `Evaluation_InBoxSdkResolvesReflectionFree` test (the harness's `#pragma warning disable IL2026` +> is gone). `Microsoft.Build` builds warning-free for `net10.0`; the harness is green under Native AOT. + +### 2.1 Goal and shape + +Per the +[fail-observably design criterion](managing-trimming-and-aot.md#msbuilds-overriding-design-criterion-fail-observably-never-silently), +the trim/AOT analyzers should **not** see a `[RequiresUnreferencedCode]` chain rooted in SDK +resolution running up into the `Project` constructors. Instead: + +* **In-box SDK resolution stays trim-safe.** The `DefaultSdkResolver` (a directory probe, no + reflection) keeps working, so `` and friends evaluate + under Native AOT. +* **Dynamically loaded resolvers fail observably.** When an SDK can only be resolved by a + plugin resolver that must be loaded by reflection (NuGet, workload, custom), the engine + raises a clean, reported evaluation error via `ProjectFileErrorUtilities.ThrowInvalidProjectFile` + tied to the `` location - instead of attempting `Assembly.LoadFrom` (which + cannot work under AOT) and instead of carrying RUC up to evaluation. + +This is tractable precisely because **manifest discovery and the default resolver are +reflection-free, and on .NET the default resolver runs first** (1.3) - so the common case +(in-box SDKs) never reaches plugin loading and is unaffected. + +### 2.2 Step 1 - add a feature switch + +Add to [src/Framework/FeatureSwitches.cs](../../src/Framework/FeatureSwitches.cs) (mirroring the +existing `EnableAllPropertyFunctions`): + +```csharp +private const bool EnableSdkResolverDynamicLoadingByDefault = true; + +/// +/// Controls whether MSBuild may load SDK resolver plugin assemblies from disk by reflection. +/// True under the JIT; substituted to false when trimmed so the trimmer dead-strips - and the +/// analyzer treats as unreachable - the reflective resolver-loading branch. When false, an SDK +/// that needs a dynamically loaded resolver fails observably instead. +/// +[FeatureSwitchDefinition("Microsoft.Build.EnableSdkResolverDynamicLoading")] +[FeatureGuard(typeof(RequiresUnreferencedCodeAttribute))] +internal static bool EnableSdkResolverDynamicLoading => + AppContext.TryGetSwitch("Microsoft.Build.EnableSdkResolverDynamicLoading", out bool enabled) + ? enabled + : EnableSdkResolverDynamicLoadingByDefault; +``` + +Add the trimmer substitution to [Microsoft.Build.Framework.csproj](../../src/Framework/Microsoft.Build.Framework.csproj): + +```xml + +``` + +`[FeatureGuard(typeof(RequiresUnreferencedCodeAttribute))]` makes the analyzer treat +`if (EnableSdkResolverDynamicLoading) { }` as safe; the +`RuntimeHostConfigurationOption` makes the trimmer fold the value to `false` and remove the +branch. (A one-line `#pragma warning disable IL4000` on the property is needed, the same way +`EnableAllPropertyFunctions` does it - the analyzer cannot see the trimmed default.) + +### 2.3 Step 2 - guard the single load funnel and fail observably + +`SdkResolverService.GetResolvers` is the one place that turns manifests into loaded +resolvers. Gate the load and throw in the disabled branch: + +```csharp +[RequiresUnreferencedCode(...)] // REMOVE in step 3 once the guard is in place +private List GetResolvers(IReadOnlyList resolversManifests, + LoggingContext loggingContext, ElementLocation sdkReferenceLocation, SdkReference sdk) // thread sdk for the message +{ + List resolvers = new(); + foreach (var resolverManifest in resolversManifests) + { + IReadOnlyList newResolvers; + lock (_lockObject) + { + if (!_manifestToResolvers.TryGetValue(resolverManifest, out newResolvers)) + { + if (FeatureSwitches.EnableSdkResolverDynamicLoading) + { + newResolvers = _sdkResolverLoader.LoadResolversFromManifest(resolverManifest, sdkReferenceLocation); + } + else + { + // Trimmed / Native AOT host: we cannot load a plugin SDK resolver by reflection. + // Fail observably so the caller (e.g. the AOT dotnet CLI) can fall back to a JIT MSBuild. + ProjectFileErrorUtilities.ThrowInvalidProjectFile( + new BuildEventFileInfo(sdkReferenceLocation), + "SdkResolverDynamicLoadingNotSupported", + sdk.Name, + resolverManifest.DisplayName); + } + + _manifestToResolvers[resolverManifest] = newResolvers; + } + } + resolvers.AddRange(newResolvers); + } + + resolvers.Sort((l, r) => l.Priority.CompareTo(r.Priority)); + return resolvers; +} +``` + +Why this placement is correct: + +* On .NET, in-box SDKs are resolved by the default resolver *before* `GetResolvers` is ever + called, so they are unaffected. +* If control reaches `GetResolvers`, the default resolver already failed, so the SDK + genuinely needs a plugin - and every manifest-based resolver requires loading its DLL. + Throwing is the right, detectable outcome. +* The throw uses `ProjectFileErrorUtilities.ThrowInvalidProjectFile` with the `` + location, so the developer gets a precise error and an AOT host can detect it. + +### 2.4 Step 3 - remove the now-unnecessary RUC up the chain + +With the guard in place, the analyzer no longer sees a reflective call escaping +`GetResolvers`, so its RUC, and the RUC of everything that only reached reflection *through* +it, can be removed. Drop `[RequiresUnreferencedCode]` from (build-verify after each layer): + +* `SdkResolverService`: `GetResolvers`, `ResolveSdkUsingResolversWithPatternsFirst`, `ResolveSdk`. +* `ISdkResolverService.ResolveSdk` and its implementers: `CachingSdkResolverService`, + `MainNodeSdkResolverService` (also delete its `[UnconditionalSuppressMessage("IL2026")]` on + `PacketReceived` - tracked in [aot-trim-suppressions.md](aot-trim-suppressions.md)), + `OutOfProcNodeSdkResolverService`, `HostedSdkResolverServiceBase`. +* `Evaluator`: `Evaluate` (static + instance), `EvaluateImportElement`, + `EvaluateImportGroupElement`, `ExpandAndLoadImports`, + `ExpandAndLoadImportsFromUnescapedImportExpression[Conditioned]`, and the design-time query + helpers that re-evaluate (`GetAllGlobs`, `GetItemProvenance`, `Reevaluate*`, `Initialize`, + `CreateProjectInstance`). +* [Project.cs](../../src/Build/Definition/Project.cs) and `ProjectInstance`: the public + constructors and factories (`FromFile`, `FromProjectRootElement`, `FromXmlReader`, + `CreateProjectInstance`, `ReevaluateIfNecessary`, `GetAllGlobs`, `GetItemProvenance`). + +**Keep** `[RequiresUnreferencedCode]` on the genuinely reflective leaves, now reachable only +through the guard: `SdkResolverLoader.LoadResolverAssembly`, `GetResolverTypes`, +`LoadResolvers`, `LoadResolversFromManifest`, `LoadAllResolvers`, and +`CachingSdkResolverLoader.LoadResolversFromManifest`. + +Legacy note: the `LoadAllResolvers` path (only used when ChangeWave 17.10 is disabled) is also +reflective. Either apply the same guard there, or document that disabling Wave17.10 is not part +of the trim-safe contract (the default, caching, manifest-based path is what trims). + +### 2.5 Step 4 - the error resource + +Add to [src/Build/Resources/Strings.resx](../../src/Build/Resources/Strings.resx) (assigned `MSB4282`; +see the +[authoring-errors-and-warnings skill](../../.github/skills/authoring-errors-and-warnings/SKILL.md)): + +```xml + + MSB4282: The SDK "{0}" could not be resolved because it requires an SDK resolver ("{1}") that must be loaded dynamically, which is not supported in this trimmed or Native AOT host. SDKs available in the SDK installation are supported; build with a JIT-based MSBuild to use NuGet, workload, or custom SDK resolvers. + {StrBegin="MSB4282: "} {0} is the SDK name; {1} is the resolver manifest display name. + +``` + +### 2.6 Step 5 - tests and validation + +* **Unit tests** (`Microsoft.Build.Engine.UnitTests`, SDK-resolution fixtures): with the + AppContext switch off, a project whose SDK only a manifest resolver can resolve throws + `InvalidProjectFileException` carrying the new code at the `Sdk` location; an in-box SDK + resolved by `DefaultSdkResolver` still succeeds with the switch off. +* **AOT harness** ([aot-validation/](../../src/aot-validation/)): add a test that evaluates a real + `` end to end under Native AOT (now reachable because the + RUC is gone and in-box resolution is reflection-free), and a test asserting that a + NuGet-SDK project throws the observable error under AOT. These replace the harness's current + `#pragma warning disable IL2026`. +* **Warning check**: rebuild `Microsoft.Build` for `net10.0` and confirm zero new IL warnings + - the `[FeatureGuard]` satisfies the analyzer and the leaves keep their RUC. + +### 2.7 Payoff and risk + +* **Payoff:** the `Project` / `ProjectInstance` constructors become non-RUC, so the SDK CLI's + in-process **evaluation** surface (the audit's "Eval" tier - `dotnet new`, `run`, `test` + discovery, `publish`/`pack` release detection, reference/workload) is honestly trim-safe, + and in-box-SDK projects evaluate under Native AOT. SDKs needing a plugin resolver fail + observably, letting an AOT host fall back to a JIT/out-of-proc MSBuild. +* **Risk:** the behavior change happens **only under trim/AOT** (the switch is substituted + `false`); under the JIT the default is `true` and behavior is identical to today. No + ChangeWave is needed because it is trim-only, opt-out via the AppContext switch. +* **Out-of-proc nodes:** a worker forwards SDK resolution to the main node + (`OutOfProcNodeSdkResolverService` -> `MainNodeSdkResolverService`); under trim the main + node hits the same guard, so the failure is consistent. +* **Environment dependency:** the in-box path uses `BuildEnvironmentHelper.MSBuildSDKsPath`, + which (like the rest of evaluation) needs the host to provide its toolset location under AOT + - see the `MSBUILD_EXE_PATH` finding in [aot-validation/README.md](../../src/aot-validation/README.md). diff --git a/documentation/aot/task-factory-aot.md b/documentation/aot/task-factory-aot.md new file mode 100644 index 00000000000..b07e0f3b094 --- /dev/null +++ b/documentation/aot/task-factory-aot.md @@ -0,0 +1,389 @@ +# ITaskFactory infrastructure and AOT/trimming safety + +**Status:** Implemented (honest RUC on the public task-factory contract); the AOT-safe replacement in §7 is a design proposal. + +**Scope:** `Microsoft.Build` task creation/execution. + +**Bottom line:** the `ITaskFactory` contract is inherently reflective (it loads task assemblies by name and +activates types at runtime), so it cannot be made trim/AOT-safe in place; it carries honest +`[RequiresUnreferencedCode]`, and an AOT host must fall back to JIT MSBuild for custom-task execution until a +closed-world task-registration mechanism (§7) exists. + +## 1. Purpose + +This document describes how MSBuild creates and executes tasks through the +`ITaskFactory` family, analyzes why that infrastructure is **fundamentally +incompatible with trimming and Native AOT** in its current form, and proposes +what an AOT-safe task mechanism would have to look like. + +The honesty position below is MSBuild's +[fail-observably-never-silently design criterion](managing-trimming-and-aot.md#msbuilds-overriding-design-criterion-fail-observably-never-silently) +applied to a public contract: rather than suppress the warning - which would let a trimmed/AOT +host reach task creation and then fail confusingly - the surface carries honest +`[RequiresUnreferencedCode]`, so the incompatibility is visible at the boundary and a host can +fall back to a JIT MSBuild. + +It also enforces a correctness principle: **the public `ITaskFactory` surface must not +be annotated in a way that tells consumers "this is trim-safe" when it is not.** +The public `ITaskFactory` / `ITaskFactory2` / `ITaskFactory3` +`Initialize` and `CreateTask` members carry `[RequiresUnreferencedCode]`, the +matching RUC is present on every implementer (so the build satisfies the IL2046 +symmetry rule), and no boundary `[UnconditionalSuppressMessage]` masks the trim +warning on `RoslynCodeTaskFactory`'s public methods. The public contract tells the +truth: a caller that reaches task creation through the interface gets an honest +IL2026. §6 describes the annotation and §8 records the suppression inventory. + +## 2. The ITaskFactory interface family + +All in `src/Framework/`. + +### `ITaskFactory` (public) + +```csharp +public interface ITaskFactory +{ + string FactoryName { get; } + + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] + Type TaskType { get; } + + bool Initialize(string taskName, IDictionary parameterGroup, + string taskBody, IBuildEngine taskFactoryLoggingHost); + + TaskPropertyInfo[] GetTaskParameters(); + + ITask CreateTask(IBuildEngine taskFactoryLoggingHost); + + void CleanupTask(ITask task); +} +``` + +### `ITaskFactory2 : ITaskFactory` (public) + +Adds Runtime/Architecture-aware overloads: + +```csharp +bool Initialize(string taskName, IDictionary factoryIdentityParameters, + IDictionary parameterGroup, string taskBody, + IBuildEngine taskFactoryLoggingHost); +ITask CreateTask(IBuildEngine taskFactoryLoggingHost, IDictionary taskIdentityParameters); +``` + +### `ITaskFactory3 : ITaskFactory2` (public) + +Same shape, but keyed off the struct `TaskHostParameters` instead of an +`IDictionary`: + +```csharp +bool Initialize(string taskName, TaskHostParameters factoryIdentityParameters, + IDictionary parameterGroup, string taskBody, + IBuildEngine taskFactoryLoggingHost); +ITask CreateTask(IBuildEngine taskFactoryLoggingHost, TaskHostParameters taskIdentityParameters); +``` + +`ITaskFactory3`'s XML doc already discourages custom implementations and steers +authors toward the built-in factories — relevant below. + +### Internal supporting interfaces + +- `IOutOfProcTaskFactory` — `string? GetAssemblyPath()`. A marker that a factory + can hand the engine an on-disk assembly path so the task can be run in a task + host (out-of-process). Only MSBuild-shipped factories implement it; **custom + factories cannot run out of process**. +- `ITaskFactoryBuildParameterProvider` — `IsMultiThreadedBuild`, + `ForceOutOfProcessExecution`. + +## 3. Built-in factories (what actually runs on .NET) + +| Factory | Implements | net10.0 behavior | Reflection used | +|---|---|---|---| +| `AssemblyTaskFactory` (`src/Build/Instance/TaskFactories/`) | `ITaskFactory3` | **Active — the main path** | `TypeLoader.Load` (assembly load + type resolution), then `Activator.CreateInstance` via `TaskLoader.CreateTask`, or a `TaskHostTask` for out-of-proc | +| `IntrinsicTaskFactory` (`.../IntrinsicTasks/`) | `ITaskFactory` | Active | None — `new MSBuild()` / `new CallTarget()` directly | +| `RoslynCodeTaskFactory` (`src/Tasks/RoslynCodeTaskFactory/`) | `ITaskFactory`, `IOutOfProcTaskFactory` | **Active** | Compiles source at runtime, `Assembly.GetExportedTypes`, `Activator.CreateInstance` | +| `CodeTaskFactory` (`src/Tasks/`) | `ITaskFactory`, `IOutOfProcTaskFactory` | **Stub that throws** (`FEATURE_CODETASKFACTORY` is net472-only) | n/a on net core | +| `XamlTaskFactory` (`src/Tasks/XamlTaskFactory/`) | `ITaskFactory`, `IOutOfProcTaskFactory` | **Stub that throws** | n/a on net core | + +So on .NET (where the analyzers run) the reflective task surface is really two +factories: **`AssemblyTaskFactory`** (every compiled task — i.e. essentially all +of them) and **`RoslynCodeTaskFactory`** (inline C#/VB). + +## 4. Creation / execution flow + +``` + (project XML, discovered at runtime) + │ +TaskRegistry.RegisterTasksFromUsingTaskElement evaluates UsingTask, caches RegisteredTaskRecord + │ GetRegisteredTask ──► creates the ITaskFactory, calls factory.Initialize(...) + ▼ +TaskFactoryWrapper (wraps ITaskFactory + LoadedType) + │ +TaskBuilder.ExecuteTask [RequiresUnreferencedCode] + ▼ +TaskExecutionHost.FindTask / InstantiateTask + ├─ AssemblyTaskFactory ─► CreateTaskInstance ─► TaskLoader.CreateTask ─► Activator.CreateInstance(loadedType.Type) + │ └► or TaskHostTask (out-of-proc) + ├─ IntrinsicTaskFactory ─► new MSBuild()/new CallTarget() + └─ custom / Roslyn ─► ITaskFactory[2|3].CreateTask(...) ─► Activator.CreateInstance(TaskType) + ▼ +TaskExecutionHost sets parameters via reflection (ReflectableTaskPropertyInfo / property get/set) + ▼ +ITask.Execute() +``` + +Two reflection mechanisms matter for trimming: + +1. **Type discovery + instantiation.** `TypeLoader` resolves a type by name from + an assembly that is named in the project file at runtime (`Assembly.Load`, + `Assembly.LoadFrom`, `AssemblyLoadContext.LoadFromAssemblyPath`, or a + `MetadataLoadContext` for cross-arch/out-of-proc). The instance is created + with `Activator.CreateInstance`. +2. **Parameter binding.** `TaskExecutionHost` reads/writes task parameters by + reflecting over the task type's public properties + (`ReflectableTaskPropertyInfo`, `Type.GetProperty`/`GetProperties`). + +## 5. Why this cannot be trim/AOT safe as-is + +The trimmer/AOT compiler operate on a **closed world**: every type that can be +instantiated or reflected over must be statically discoverable from the app's +reference graph. MSBuild's task model violates that at four distinct points: + +1. **Task assemblies are discovered at runtime.** `` + / `AssemblyName=...` names an assembly that is not part of the host's compile + graph. The trimmer cannot see it, so it cannot preserve it or anything in it. +2. **Tasks are instantiated reflectively.** `Activator.CreateInstance(taskType)` + needs the parameterless constructor preserved. For a runtime-discovered type + the trimmer has no way to know that. +3. **Parameters are bound reflectively.** Even if the task type survived, the + trimmer can freely remove unused public property setters/getters; MSBuild then + fails to set `[Required]`/`[Output]` parameters. This is the classic "trims to + a non-working state" failure — and it is **silent**. +4. **Two factories compile code at runtime.** `RoslynCodeTaskFactory` / + `CodeTaskFactory` invoke a compiler and load the result. Runtime code + generation is categorically incompatible with AOT (`RequiresDynamicCode`) and + undesirable under trimming. + +Points 1–3 apply to **every** task, including the `AssemblyTaskFactory` path that +runs essentially all real-world tasks. The conclusion: **`ITaskFactory` is a +runtime plugin-loading contract. There is no annotation that makes the existing +contract trim-safe**, because the types it operates on do not exist in the +trimmed world. + +### What `IsAotCompatible` actually promised + +`src/Build/Microsoft.Build.csproj` sets, for net8.0+: + +```xml +true +``` + +The in-repo comment frames this as "enable the trim/AOT **analyzers**." But in +the .NET SDK, `IsAotCompatible=true` also implies **`IsTrimmable=true`** (along +with `EnableTrimAnalyzer`, `EnableSingleFileAnalyzer`, `EnableAotAnalyzer`). +`IsTrimmable=true` is a **promise to the trimmer**: "this assembly opted in; trim +it, and assume the remaining warnings are handled." Every +`[UnconditionalSuppressMessage]` we add is us telling the trimmer that promise is +satisfied at that call site. Where it is not actually satisfied — the task-loading +paths — we have converted a loud build-time warning into a silent runtime break +for anyone who ever trims `Microsoft.Build`. + +That is the heart of the concern: **if we mark the assembly trimmable, it must not +trim to a non-working state.** It still would if a host actually trims it — the +task model is structurally reflective — but the annotations no longer *hide* that. +The reflective engine paths and the public `ITaskFactory` contract now carry +honest `[RequiresUnreferencedCode]` (§6), so the incompatibility surfaces as an +IL2026 the caller can see rather than a silent runtime break. Genuinely AOT-safe +task execution still requires the closed-world mechanism in §7. + +## 6. The honest annotations + +The design rule is: *propagate `[RequiresUnreferencedCode]` (RUC) up the real call +chain; suppress only at boundaries the analyzer genuinely cannot cross.* The internal +engine paths follow it directly: `TaskBuilder.ExecuteTask`, `TaskExecutionHost.FindTask`, +`TaskRegistry.GetRegisteredTask`, `AssemblyTaskFactory.InitializeFactory`, +`TypeLoader.Load`, etc. are all RUC, so a caller that reaches them through the +internal API gets an honest IL2026. + +The **public `ITaskFactory` contract** carries the requirement too. A public +interface implementation cannot carry RUC unless the **interface member** also +carries it (the IL2046 symmetry rule — the attribute must be present on both sides); +suppressing on the implementation instead (`RoslynCodeTaskFactory.CreateTask` / +`Initialize`) would leave the public method presenting to every caller and to the +trimmer as trim-safe, which is the trim-analysis equivalent of lying to the caller. +The contract is annotated honestly on both sides: + +1. **`[RequiresUnreferencedCode]` on the public contract.** `ITaskFactory.Initialize` + and `ITaskFactory.CreateTask`, plus the `ITaskFactory2` and `ITaskFactory3` + `Initialize` / `CreateTask` overloads, now carry RUC + (`src/Framework/ITaskFactory.cs`, `ITaskFactory2.cs`, `ITaskFactory3.cs`). The + message states that task factories create tasks by reflecting over a task type + discovered or generated at runtime, which is incompatible with trimming. +2. **Matching RUC on every implementer** (the IL2046 symmetry requirement): + - `AssemblyTaskFactory` — all six `Initialize`/`CreateTask` members + (ITaskFactory/2/3). + - `IntrinsicTaskFactory` — `Initialize`/`CreateTask` (even though the bodies are + `new MSBuild()` / `new CallTarget()`; the attribute must be present to match + the interface). + - `RoslynCodeTaskFactory` — the two public `ITaskFactory` members carry RUC that + legally matches the interface; no suppression masks the warning on them. The + genuinely unsafe work stays isolated in the private RUC helpers + (`TryCompileAssembly`, `TryResolveCompiledTaskType`, `CreateTaskInstance`). + - `CodeTaskFactory` and `XamlTaskFactory` — both the real (`net472`, + `FEATURE_*`) implementations and the .NET-core throwing stubs. +3. **Internal dispatch matches.** `TaskExecutionHost.InitializeForBatch` + / `InstantiateTask` / `CreateTaskHostTaskForOutOfProcFactory` and + `TaskBuilder.InitializeAndExecuteTask` carry RUC so the internal chain that feeds + the factory contract stays honest end to end. + +With both sides annotated, a host that calls `ITaskFactory.CreateTask`/`Initialize` +through the interface gets an honest IL2026, and the build is clean on both `net10.0` +and `net472` under warnings-as-errors (0 IL warnings, 0 warnings, 0 errors). The +attributes compile on `net472`/`netstandard2.0` via the internal polyfill in +`src/Framework/Polyfills/AotTrimmingPolyfills.cs` (the trim analyzer only runs on +`net10.0`). + +### Consequences accepted + +- **Public-surface change.** Adding RUC to `ITaskFactory`/2/3 is a public-contract + change (trim metadata, no managed-signature change). It passes the in-repo + public-API baseline analyzers (RS0016 / ApiCompat = 0) but is the kind of change + that should be called out for API review. +- **Third-party factories/tasks.** A third-party `ITaskFactory` implementation that + enables trim analysis will now get IL2046 until it adds the matching RUC. That is + the **correct** signal — their factory is not trim-safe either. +- **`RequiresDynamicCode`** still belongs on the two compiling factories + (`RoslynCodeTaskFactory` / `CodeTaskFactory`) for the AOT (IL3050) story, tracked + separately from the RUC annotations. + +## 7. What an actually AOT-safe task mechanism would require + +Because the incompatibility is structural, "fixing" `ITaskFactory` is not an +annotation exercise — it needs a **second, opt-in, closed-world mechanism**. The +shape: + +1. **Static task registration via a source generator.** A task author (or the SDK) + marks task types — e.g. `[MSBuildTask]` — in an assembly that is *referenced at + compile time* by the host. A generator emits a static registry: + - `taskName → Func` (a typed `new MyTask()` delegate) so creation never + calls `Activator.CreateInstance`. + - Per-parameter typed accessors (`Action` setters, + `Func` getters) so `TaskExecutionHost` binds parameters + without `Type.GetProperty`/`GetProperties`. This replaces the reflective + `ReflectableTaskPropertyInfo` on the AOT path. + - The `TaskPropertyInfo[]` metadata (`[Required]`/`[Output]`, types) computed at + compile time. +2. **Engine consults the static registry first.** `TaskRegistry` / + `TaskExecutionHost` look up a statically-registered factory before falling back + to `AssemblyTaskFactory`'s reflection. Under AOT, a task that is *not* statically + registered produces a clean, deterministic error ("task X is not available in a + trimmed/AOT host") instead of a silent failure. +3. **Closed-world constraint is explicit.** Statically-registered tasks only work + when the task assembly is referenced by the host being trimmed — e.g. the SDK's + own tasks compiled into a future AOT `dotnet build`. Arbitrary + `` against an unreferenced DLL remains reflection-only + and remains marked RUC. This is acceptable: AOT support is necessarily a subset. +4. **No runtime compilation on the AOT path.** `RoslynCodeTaskFactory` / + `CodeTaskFactory` have no AOT story by construction; they stay RUC + + `RequiresDynamicCode` and are simply unavailable in an AOT host. + +This is a substantial feature (new public attribute/contract, a generator, engine +plumbing, and parameter-binding changes), not part of the current annotation pass. +It belongs in its own proposal; this document is the rationale for why it is the +only real path to AOT-safe task execution. + +## 8. Inventory: the remaining suppressions, grouped by why each exists + +Line numbers are approximate. The public-contract case §6 covers is **not** an +inventory row: those members carry honest `[RequiresUnreferencedCode]`, not a +suppression. + +### A. Public-contract suppressions — must not exist (see §6) + +A suppression on a public `ITaskFactory`/`ITask` member would tell consumers the path +is trim-safe when it is not. None exist: the `ITaskFactory`/2/3 `Initialize`/`CreateTask` +members and every implementer (`AssemblyTaskFactory`, `IntrinsicTaskFactory`, +`CodeTaskFactory`, `XamlTaskFactory`) carry matching RUC instead (§6). The analogous +`ITask.Execute` / XmlSerializer-based public tasks in **other assemblies** are tracked as Backlog - +see Category D - and can be revisited the same way. + +### B. Boundary suppressions — RUC genuinely cannot flow further (message pumps, delegates, contracts) + +These terminate an otherwise-honest RUC chain at a point the analyzer can't cross +(an `Action`/event delegate, an `INodePacket` switch, or a public contract that +can't carry RUC). They are defensible, but every one of them is a place a caller +loses the trim signal, so they belong in the same audit. + +| Location | Code | Boundary | +|---|---|---| +| `BackEnd/Components/RequestBuilder/TaskHost.cs` ~336 | IL2026 | `IBuildEngine3.BuildProjectFilesInParallel` (public contract) | +| `BackEnd/Components/RequestBuilder/IntrinsicTasks/MSBuild.cs` ~514 | IL2026 | shared impl of intrinsic `MSBuild`/`CallTarget` `ITask.Execute` | +| `BackEnd/Node/InProcNode.cs` ~379 | IL2026 | node packet-pump `HandlePacket` | +| `BackEnd/Node/OutOfProcNode.cs` ~631 | IL2026 | node packet-pump `HandlePacket` | +| `BackEnd/Components/BuildRequestEngine/BuildRequestEngine.cs` ~1049 | IL2026 | `ActivateBuildRequest` driven from the `QueueAction` pump | +| `BackEnd/Components/BuildRequestEngine/BuildRequestEngine.cs` ~1068 | IL2026 | `Builder_On*Request` event handlers | +| `BackEnd/BuildManager/BuildManager.cs` ~1456 | IL2026 | `INodePacketHandler.PacketReceived` | +| `BackEnd/Components/SdkResolution/MainNodeSdkResolverService.cs` ~60 | IL2026 | `INodePacketHandler.PacketReceived` | +| `BuildCheck/Infrastructure/BuildCheckBuildEventHandler.cs` ~134 | IL2026 | build-event handler dispatch | +| `BuildCheck/Infrastructure/BuildCheckManagerProvider.cs` ~331 | IL2026 | `SetupChecksForNewProject` (custom-check materialization) | +| `Definition/ProjectCollection.cs` ~212/225/238/253 | IL2026 | ctors that pass no loggers (reflective logger path not exercised) | +| `Definition/ProjectCollection.cs` ~466 | IL2026 | `GlobalProjectCollection` singleton accessor | + +### C. `UnrecognizedReflectionPattern` family — by-name resolution / filter delegates + +These sit inside methods that are already RUC, or on `Func` +filter delegates that cannot carry `[DynamicallyAccessedMembers]`. Not "lies" so +much as the residue of by-name type handling; listed for completeness. + +| Location | Code(s) | +|---|---| +| `BackEnd/TaskExecutionHost/TaskExecutionHost.cs` ~1127, ~1809 | IL2057, IL2072 | +| `Instance/TaskRegistry.cs` ~1741, ~1743, ~1851 | IL2057, IL2096 | +| `Evaluation/Expander.cs` ~3828, 3987, 4250, 4300, 4433, 4435 | IL2072/2074/2096/2026 (property-function dispatch) | +| `Logging/LoggerDescription.cs` ~261, ~275 | IL2070 (`IsLoggerClass`/`IsForwardingLoggerClass` filters) | +| `src/Shared/TaskLoader.cs` ~37 | IL2070 (`IsTaskClass` filter) | + +### D. Pre-existing suppressions in other assemblies - Backlog + +Recorded so the contract decision in §6 and the XML-handling backlog can be applied consistently later. + +| Location | Code(s) | Origin | +|---|---|---| +| `src/Tasks/SignFile.cs` ~46/48 | IL2026/IL3050 | XML handling backlog (`XmlSerializer`/crypto) | +| `src/Tasks/ManifestUtil/DeployManifest.cs` ~551/553 | IL2026/IL3050 | XML handling backlog (`XmlSerializer`) | +| `src/Tasks/GenerateManifestBase.cs` ~277/279 | IL2026/IL3050 | XML handling backlog (`XmlSerializer`) | +| `src/Tasks/ManifestUtil/TrustInfo.cs` ~533 | IL3050 | XML handling backlog (`XslCompiledTransform`) | +| `src/Tasks/WriteCodeFragment.cs` ~82 | IL2026 | attribute/code generation backlog (`CodeDom`) | +| `src/Tasks/XslTransformation.cs` ~104 | IL3050 | XML handling backlog (`XslCompiledTransform`) | +| `src/Tasks/BootstrapperUtil/BootstrapperBuilder.cs` ~133 | IL3050 | XML handling backlog (`XslCompiledTransform`) | +| `src/Tasks/RoslynCodeTaskFactory/RoslynCodeTaskFactory.cs` ~95 | IL3002 | single-file (not trimming); on-disk ref-assembly lookup | + +The `RoslynCodeTaskFactory` public `ITask`-creating XmlSerializer-style tasks in +Category D are the same kind of public-contract suppression as Category A; if the +project adopts "RUC on the contract," `ITask.Execute` overrides like `SignFile`, +`GenerateManifestBase`, `DeployManifest` should be revisited the same way. + +## 9. Recommendations + +1. **Surface the public-contract change for API review.** + `ITaskFactory`/`ITaskFactory2`/`ITaskFactory3` `Initialize`/`CreateTask` members + carry `[RequiresUnreferencedCode]` and every implementer matches (§6), making the + incompatibility visible instead of a "trimmable assembly + suppressed public task + contract." Because this is a public-surface change (trim metadata), it should be + called out for formal API review. +2. **Don't re-introduce Category A.** Don't add new + `[UnconditionalSuppressMessage]` to public `ITaskFactory`/`ITask` members; if a + warning appears there, route the reflection into a private RUC helper and/or add + matching RUC on the contract rather than suppressing on the public method. +3. **Keep Category B/C honest internally.** Those are fine as long as the internal + chain that feeds them is RUC (it is). Re-audit if any of them stop being fed by + an RUC caller. +4. **Track the AOT-safe mechanism (§7) as its own proposal.** It is the only path + to running tasks in a trimmed/AOT host, and it is additive (closed-world, + opt-in), so it does not disturb the existing reflection-based model. + +## 10. Related + +- AOT follow-up items (the `IL2046` symmetry rule and the `ITaskFactory.TaskType` + `[DynamicallyAccessedMembers]` API-review item) are covered by §7 above and the + [strategy doc](aot-trimming-strategy.md). +- `documentation/wiki/Contributing-Tasks.md`, `documentation/wiki/Tasks.md` — task + authoring model. diff --git a/documentation/aot/task-parameter-types.md b/documentation/aot/task-parameter-types.md new file mode 100644 index 00000000000..cd0451e047c --- /dev/null +++ b/documentation/aot/task-parameter-types.md @@ -0,0 +1,262 @@ +# Task parameter types: the allowed set, resolution, and an `ITaskItem` registry + +**Status:** Background analysis. + +**Bottom line:** the legal task-parameter type set is small and fixed, but inline-task +`` resolves arbitrary type names through `Type.GetType`, so a closed-world `ITaskItem` +registry can cover the common path yet cannot fully replace by-name resolution without a breaking change. + +This documents **precisely** which .NET types are legal for an MSBuild task parameter, how the +inline-task `` resolves a declared `ParameterType` string to a `System.Type`, what +`ITaskItem` implementations exist in the product, and whether a public, trim-safe `ITaskItem` type +registry is feasible. + +It exists because the parameter-type resolution in +[`TaskRegistry.ParseUsingTaskParameterGroupElement`](../../src/Build/Instance/TaskRegistry.cs#L1764) carries +two trim/AOT suppressions (`IL2057`/`IL2096`) - the "Group B" rows in +[aot-trim-suppressions.md](aot-trim-suppressions.md#group-b--inline-task-by-name-type-resolution) - +and understanding the *exact* constraint set is a prerequisite for deciding whether a closed-world +registry (strategy **S5** in [aot-trimming-strategy.md](aot-trimming-strategy.md)) could ever replace +the by-name `Type.GetType` there or in the broader task parameter-binding path. + +--- + +## 1. The allowed parameter type set (precise) + +The single source of truth is [`TaskParameterTypeVerifier`](../../src/Shared/TaskParameterTypeVerifier.cs). +A declared parameter type is validated by `IsValidInputParameter` or `IsValidOutputParameter` +depending on whether the parameter is `Output="true"`. The predicates are: + +```csharp +// INPUT = scalar OR vector +IsValidScalarInputParameter(t) = t.IsValueType || t == typeof(string) || t == typeof(ITaskItem); +IsValidVectorInputParameter(t) = (t.IsArray && t.GetElementType().IsValueType) + || t == typeof(string[]) + || t == typeof(ITaskItem[]); + +// OUTPUT = value-type-output OR assignable-to-ITaskItem +IsValueTypeOutputParameter(t) = (t.IsArray && t.GetElementType().IsValueType) + || t == typeof(string[]) + || t.IsValueType + || t == typeof(string); +IsAssignableToITask(t) = typeof(ITaskItem[]).IsAssignableFrom(t) + || typeof(ITaskItem).IsAssignableFrom(t); +``` + +Effective table (✔ = allowed, ✘ = rejected): + +| Declared parameter type | As **input** | As **output** | +| --- | :---: | :---: | +| `System.String` | ✔ | ✔ | +| `System.String[]` | ✔ | ✔ | +| any value type (`bool`, `int`, `DateTime`, **a user `struct`**, …) | ✔ | ✔ | +| array of a value type (`int[]`, `MyStruct[]`, …) | ✔ | ✔ | +| `Microsoft.Build.Framework.ITaskItem` (the interface itself) | ✔ | ✔ | +| `Microsoft.Build.Framework.ITaskItem[]` (the interface array) | ✔ | ✔ | +| a **custom** `T : ITaskItem` (scalar), e.g. `ITaskItem2` or a user class | ✘ | ✔ | +| a **custom** `T[]` where `T : ITaskItem` (array) | ✘ | ✔ | +| any other reference type (`object`, `System.IO.FileInfo`, a non-item class) | ✘ | ✘ | + +### The three things to notice + +1. **Input vs output is asymmetric for items.** Inputs are checked with **reference equality** + (`t == typeof(ITaskItem)` / `t == typeof(ITaskItem[])`), so **only the exact `ITaskItem` / + `ITaskItem[]` interface types are legal inputs** - `ITaskItem2`, `Microsoft.Build.Utilities.TaskItem`, + or any user item class are **rejected as inputs**. Outputs are checked with + `IsAssignableFrom`, so **any** `ITaskItem` implementer (and, via array covariance, any array of one) + is a legal output. +2. **Any value type is legal in both directions** - not just BCL primitives. `t.IsValueType` admits an + arbitrary user `struct`. This is the part of the set that is *not* a finite, statically-known list. +3. **Reference types that are neither `string` nor an `ITaskItem` are never legal** (neither input nor + output). You cannot declare a parameter of type `object`, `FileInfo`, `Stream`, etc. + +> The same predicates gate parameters of **compiled** tasks too: when a task assembly is loaded, +> [`LoadedType`](../../src/Framework/Loader/LoadedType.cs#L152) records `isAssignableToITask` per +> property (`iTaskItemType.IsAssignableFrom(pt)` on the element type), which +> [`TaskExecutionHost.SetTaskItemParameter`](../../src/Build/BackEnd/TaskExecutionHost/TaskExecutionHost.cs#L916) +> uses to bind item-typed inputs/outputs. `TaskParameterTypeVerifier` is the **declaration-time** +> gate; `LoadedType` is the **load-time** classification. + +--- + +## 2. Where the constraint is enforced, and the suppressed resolution + +`TaskParameterTypeVerifier` is consulted in exactly one declaration path: +[`ParseUsingTaskParameterGroupElement`](../../src/Build/Instance/TaskRegistry.cs#L1764), which parses the +`` of a ``. The parse - including the type resolution below - runs at +**evaluation/registration time for *any* `` that carries a ``** +([call site](../../src/Build/Instance/TaskRegistry.cs#L416): `if (projectUsingTaskXml.Count > 0)`), +**before and independent of any task factory**. A `` is *consumed* only by the inline +factories (`CodeTaskFactory`, `RoslynCodeTaskFactory`, `XamlTaskFactory`) - the +`AssemblyTaskFactory`/`TaskHostFactory` path reflects parameters off the compiled task type instead - but +that consumption happens later, at execution; the name resolution here is factory-agnostic. + +As of [task-parameter-type-registration-api.md](../specs/task-parameter-type-registration-api.md) this +resolution is **registry-first**: the reflection-free +[`TaskParameterTypeRegistry`](../../src/Framework/TaskParameterTypeRegistry.cs) (the intrinsic value +types, `string`, and the MSBuild `ITaskItem` types, plus any a host registers) is consulted first, and +only a name it does not know falls back to `Type.GetType` - and only when the +`EnableReflectiveTaskParameterTypes` switch is on. That gate (a `[FeatureGuard]` +`[RequiresUnreferencedCode]` helper, +[`ResolveParameterTypeByName`](../../src/Build/Instance/TaskRegistry.cs#L1743)) **retired the +`IL2057`/`IL2096` suppressions** this path used to carry. + +One `Type.GetType(string)` site here still carries a trim suppression: + +* [`TranslatorForTaskParameterValue`](../../src/Build/Instance/TaskRegistry.cs#L1872) (`IL2057`) - reconstructs + the (already-validated) type from its serialized `AssemblyQualifiedName` when a `TaskPropertyInfo` + crosses the task-host boundary. (This serialization path is a candidate to reuse the same registry; it + is separate from the parameter-type registry change.) + +--- + +## 3. How a `ParameterType` string is resolved to a `Type` + +The author writes a .NET type name (the C# keyword forms like `bool` do **not** resolve; +`Type.GetType` needs `System.Boolean`, `System.String`, `Microsoft.Build.Framework.ITaskItem`, …). +The reflection-free registry is consulted first; the **by-name fallback** below (now the gated +[`ResolveParameterTypeByName`](../../src/Build/Instance/TaskRegistry.cs#L1743) helper) runs only for a +name the registry does not know, and only when `EnableReflectiveTaskParameterTypes` is on. After +property/item expansion the string is `expandedType`, and the fallback resolution is: + +```csharp +if (expandedType.StartsWith("Microsoft.Build.Framework.", OrdinalIgnoreCase) && !expandedType.Contains(",")) +{ + // (A) Framework-prefixed, unqualified name: try the *loaded* Framework assembly FIRST. + paramType = Type.GetType(expandedType + "," + typeof(ITaskItem).Assembly.FullName, throwOnError: false, ignoreCase: true) + ?? Type.GetType(expandedType); +} +else +{ + // (B) everything else: try the bare name first, then fall back to the Framework assembly. + paramType = Type.GetType(expandedType) + ?? Type.GetType(expandedType + "," + typeof(ITaskItem).Assembly.FullName, throwOnError: false, ignoreCase: true); +} +``` + +Precisely what this does (correcting the "Framework-only branch" intuition - it is **not** a compile +-time `#if`, it is a *runtime name-prefix* special-case plus a *universal fallback*): + +* **The assembly-name fallback is universal.** `!expandedType.Contains(",")` means "the name is **not** + assembly-qualified." For any unqualified name, MSBuild will, as a fallback, append the **currently + loaded** `Microsoft.Build.Framework` identity (`typeof(ITaskItem).Assembly.FullName` - + `typeof(ITaskItem).Assembly` *is* `Microsoft.Build.Framework`) and retry. This is why + `Microsoft.Build.Framework.ITaskItem` resolves even though it is written without an assembly + qualifier, and why authors can write `ITaskItem` parameter types without spelling out the assembly. +* **Branch (A) only reorders the attempts** for names that start with `Microsoft.Build.Framework.`: it + resolves against the loaded Framework assembly **first**, then falls back to a bare `Type.GetType`. + This is a deliberate workaround (internal bug 1448821): Visual Studio can have **more than one + version** of `Microsoft.Build.Framework.dll` loaded, and a bare `Type.GetType("Microsoft.Build.Framework.ITaskItem")` + could otherwise bind to the *wrong* version, yielding a `Type` that is reference-unequal to the + engine's `typeof(ITaskItem)` and then failing the `== typeof(ITaskItem)` input check above with a + spurious `UnsupportedTaskParameterTypeError`. Forcing the loaded-Framework identity first pins the + resolution to the engine's own `ITaskItem`. +* **Assembly-qualified names** (containing a comma) take branch (B) and resolve through the bare + `Type.GetType(expandedType)` using the supplied assembly identity. + +If resolution yields `null`, the author gets a reported `InvalidProjectFileException` +(`InvalidEvaluatedAttributeValue`); if it resolves but fails `TaskParameterTypeVerifier`, they get +`UnsupportedTaskParameterTypeError`. Both are observable build errors. + +--- + +## 4. The `ITaskItem` implementation landscape + +`ITaskItem` and `ITaskItem2` are **public** interfaces in `Microsoft.Build.Framework` +([ITaskItem2.cs](../../src/Framework/ITaskItem2.cs)). The concrete implementations the product ships: + +| Type | Assembly | Visibility | Role | +| --- | --- | --- | --- | +| [`Microsoft.Build.Utilities.TaskItem`](../../src/Utilities/TaskItem.cs#L37) | Microsoft.Build.Utilities.Core | **public**, `sealed` | The canonical item a task author constructs (`new TaskItem(spec)`). Implements `ITaskItem2, IMetadataContainer`. | +| [`Microsoft.Build.Execution.ProjectItemInstance.TaskItem`](../../src/Build/Instance/ProjectItemInstance.cs#L781) | Microsoft.Build | internal | The **engine's** runtime item - what the engine actually hands to and reads back from tasks. Implements `ITaskItem2` (+ engine interfaces). | +| [`Microsoft.Build.Framework.TaskItemData`](../../src/Framework/TaskItemData.cs#L17) | Microsoft.Build.Framework | internal | Lightweight immutable item (e.g. binary-log replay). Implements `ITaskItem, IMetadataContainer`. | +| `Microsoft.Build.BackEnd.TaskParameter.TaskParameterTaskItem` | MSBuildTaskHost | internal (nested) | Item marshalled across the out-of-proc task-host boundary. | + +As of [task-parameter-type-registration-api.md](../specs/task-parameter-type-registration-api.md), the +registry pre-registers the Framework-visible item types (`ITaskItem`, `ITaskItem2`, `TaskItemData`) and +the engine's `ProjectItemInstance.TaskItem` (from `Microsoft.Build`); the public +`Microsoft.Build.Utilities.TaskItem` is registered by a host through the public API (it is above +Framework). The private `TaskParameterTaskItem` is never declared as a parameter type and is not +registered. Item types are registered **without** member rooting (they are validated by assignability, +never member-reflected), so this is free under trimming. + +### Can users create any implementation? + +**Yes - the interfaces are public and the engine consumes items purely through them.** A task can +expose an **output** property typed as a custom `ITaskItem`/`ITaskItem2` and the engine will read it +through the interface. Two caveats that follow directly from §1 and from the type design: + +* For an **inline-task `` declaration**, a custom item type is legal **only as an + output** (`IsAssignableFrom`); as an **input** only the exact `ITaskItem`/`ITaskItem[]` are legal. +* `Microsoft.Build.Utilities.TaskItem` is `sealed` **on purpose** - its class comment notes the engine + "instantiates its own copy of this type," so subclassing it would not get engine behavior. Authors + who want a custom item implement the interface directly rather than deriving from the public class. + +In practice the overwhelming majority of real task parameters are `string`, a BCL value type, or +`ITaskItem`/`ITaskItem[]`; custom `ITaskItem` *parameter types* are rare, and custom value-type +parameters rarer still. + +--- + +## 5. A public, trim-safe task parameter type registry - implemented + +This is **implemented** in [task-parameter-type-registration-api.md](../specs/task-parameter-type-registration-api.md): +a reflection-free [`TaskParameterTypeRegistry`](../../src/Framework/TaskParameterTypeRegistry.cs) keyed by +type name, pre-registered with the intrinsics, `string`, and the `ITaskItem` types and rooted for +trimming, with two public registration methods on `Microsoft.Build.Utilities.TaskItem` +(`RegisterTaskParameterValueType` for value types, `RegisterTaskParameterItemType` for `ITaskItem` +types). It is the closed-world registration recipe (strategy **S5** / +[aot-trimming-strategy.md §6](aot-trimming-strategy.md#6-aot-safe-reflection-over-registered-types--the-annotation-recipe)): + +```csharp +// The DAM on T roots T's entire member surface, so the trimmer preserves a registered item type +// in full and any later reflection over it (construct, set metadata, copy) stays trim-safe. +public static void RegisterTaskItemType<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] T>() + where T : ITaskItem +{ + s_itemTypesByName[typeof(T).FullName] = typeof(T); // name -> Type, no Type.GetType at use time +} +``` + +A host (the SDK, a custom app) would register its item types at startup; the engine would resolve a +declared/serialized item parameter type via the dictionary instead of `Type.GetType`, and the +trimmer would preserve those types because each `Register()` call site roots `Concrete`. +This is the same shape as the shipped `SdkResolver.Register` +([sdk-resolver-host-registration-api.md](../specs/sdk-resolver-host-registration-api.md)) and the +proposed static task generator ([task-factory-aot.md §7](task-factory-aot.md)). + +**But weigh what it actually buys, against the two sites in §2:** + +* It **cannot fully close the set.** §1 shows the legal set includes *any* value type (`IsValueType`) + and *any* `ITaskItem` implementer. A registry bounds the item-type half (a host can enumerate its + item classes) but not arbitrary user `struct`s. So a registry resolves the **registered + well-known** + subset without reflection and leaves a remainder that must still fall back to `Type.GetType` + (narrowed honest RUC) or be declared unsupported under AOT (fail observably). +* For [`ParseUsingTaskParameterGroupElement`](../../src/Build/Instance/TaskRegistry.cs#L1764) this is now + **done**: the registry resolves the known types reflection-free and the by-name fallback is gated, which + **retired the `IL2057`/`IL2096` suppressions** there. The surrounding inline-task *execution* stays + AOT-incompatible (it compiles source at run time) and gated by `EnableReflectiveTaskExecution`, so the + win is a trim-clean *parse*, not a runnable inline task under AOT. +* The registry's **real value is the compiled-task parameter-binding path** - the `LoadedType` / + `TaskExecutionHost.SetTaskItemParameter` reflection over item-typed properties, which is the genuine + AOT-hard tier (now behind `EnableReflectiveTaskExecution`). A member-rooted, closed `ITaskItem` type + set is what would eventually let that path reflect trim-safely over a known world, complementing the + task source-generator. If an `ITaskItem` registry is built, build it for **that**, and let the + inline-task declaration site reuse it opportunistically. + +**Bottom line.** The registry is implemented and wired into the `` resolution, retiring +its two trim suppressions while keeping observable failure for unknown types under AOT. It does not fully +close the set (arbitrary value types remain open-ended), and the higher-value target remains the +**compiled-task** parameter-binding path (`LoadedType` / `TaskExecutionHost.SetTaskItemParameter`), for +which the same registry is the reusable primitive - see +[task-class-registration-api.md](../specs/task-class-registration-api.md). + +--- + +## Related + +* [task-parameter-type-registration-api.md](../specs/task-parameter-type-registration-api.md) - the implemented registry this section describes. +* [task-class-registration-api.md](../specs/task-class-registration-api.md) - the companion proposal for registering task *classes*. +* [aot-trim-suppressions.md - Backlog deep analysis (Group B)](aot-trim-suppressions.md#group-b--inline-task-by-name-type-resolution) - why the serialized `Type.GetType` row remains Backlog and why the `` site was retired. +* [aot-trimming-strategy.md](aot-trimming-strategy.md) - S5 (registration) and the `Register<[DAM] T>()` recipe (§6). +* [task-factory-aot.md](task-factory-aot.md) - the proposed static task registration / source generator this registry would complement. diff --git a/documentation/specs/sdk-resolver-host-registration-api.md b/documentation/specs/sdk-resolver-host-registration-api.md new file mode 100644 index 00000000000..26916261174 --- /dev/null +++ b/documentation/specs/sdk-resolver-host-registration-api.md @@ -0,0 +1,235 @@ +# API Proposal: Host registration of reflection-free SDK resolvers + +**Status:** Implemented as the static `SdkResolver.Register(SdkResolver)` member +([SdkResolver.cs](../../src/Framework/Sdk/SdkResolver.cs)). The proposal below is the original design +rationale. + +## Background and motivation + +MSBuild resolves `` references through a chain of `SdkResolver`s. On .NET, that chain is: + +1. The built-in `DefaultSdkResolver` - a reflection-free directory probe of `MSBuildSDKsPath\\Sdk`, + constructed with `new` and tried first + ([`SdkResolverLoader.GetDefaultResolvers()`](../../src/Build/BackEnd/Components/SdkResolution/SdkResolverLoader.cs)). +2. Plugin resolvers discovered on disk under the `SdkResolvers` directory and **loaded by reflection** + (`Assembly.LoadFrom` + `Activator.CreateInstance`) - notably + `Microsoft.NET.Sdk.WorkloadMSBuildSdkResolver` and `Microsoft.Build.NuGetSdkResolver`. + +The plugin step is incompatible with a trimmed / Native AOT host. MSBuild already gates it behind the +`Microsoft.Build.EnableSdkResolverDynamicLoading` feature switch; when that switch is off (as it is for an +AOT host), [`SdkResolverService.GetResolvers`](../../src/Build/BackEnd/Components/SdkResolution/SdkResolverService.cs) +fails observably with **MSB4282** instead of attempting an unsupported assembly load. + +This is a real wall for evaluating stock SDK projects under AOT. Every `Microsoft.NET.Sdk` project +unconditionally imports two **workload-locator SDKs** - +`Microsoft.NET.SDK.WorkloadAutoImportPropsLocator` and `Microsoft.NET.SDK.WorkloadManifestTargetsLocator` - +which only the (reflection-loaded) workload resolver knows how to resolve. With dynamic loading off, a +plain `dotnet new console` project cannot be evaluated; the only workaround today is to set +`MSBuildEnableWorkloadResolver=false`, which *disables* workload resolution rather than supporting it. + +A host that runs the MSBuild engine in-process and is itself trimmed/AOT-compiled (the .NET SDK CLI is the +motivating case) already references its resolver assemblies statically. It needs a way to hand MSBuild a +pre-constructed resolver instance and have it participate in resolution **without any assembly loading or +reflection**. No such API exists today: resolvers can only be contributed by being discovered on disk. + +This proposal adds a minimal host-registration surface so an in-process host can "bake in" resolvers - for +example a Native-AOT-compatible workload resolver - that run on the existing reflection-free code path. + +## API Proposal + +```diff + namespace Microsoft.Build.Framework; + + // Existing public contract a resolver implements; this proposal adds a single static member to it. + public abstract class SdkResolver + { + public abstract string Name { get; } + public abstract int Priority { get; } + public abstract SdkResult Resolve(SdkReference sdkReference, SdkResolverContext resolverContext, SdkResultFactory factory); + ++ /// ++ /// Registers an to be consulted during SDK resolution by a host that runs ++ /// the MSBuild engine in-process (for example the .NET SDK CLI), without MSBuild discovering and ++ /// loading it from disk by reflection. ++ /// ++ /// The resolver instance to register. ++ /// ++ /// ++ /// This is the supported way to provide SDK resolvers in a trimmed or Native AOT host, where the ++ /// on-disk SdkResolvers probing and reflection-based loading used for plugin resolvers are ++ /// unavailable. The registered resolver is consulted on the same reflection-free code path as ++ /// MSBuild's built-in resolver, so it never triggers the dynamic-loading failure (MSB4282). ++ /// ++ /// ++ /// The registered resolver participates in resolution in order alongside ++ /// MSBuild's built-in resolver, with no assembly loading or reflection. It is consulted for every SDK ++ /// reference in the process. ++ /// ++ /// ++ /// Intended to be called once per resolver during host initialization, before the first project is ++ /// evaluated. The set of registered resolvers is captured the first time an SDK is resolved in the ++ /// process; registrations performed after that point are not guaranteed to take effect. ++ /// ++ /// ++ /// This method is thread-safe. Registering the same instance more than once has no additional effect. ++ /// ++ /// ++ /// is . ++ public static void Register(SdkResolver resolver); + } +``` + +### Semantics + +- **Reflection-free.** Registered resolvers are appended to the list returned by the engine's existing + reflection-free default-resolver step. On .NET that list is tried first, before any manifest is read, so + registered resolvers bypass `EnableSdkResolverDynamicLoading` / MSB4282 entirely. +- **Ordering.** The built-in `DefaultSdkResolver` (`Priority` 10000) and all registered resolvers are + consulted in ascending `Priority` order, matching how disk-discovered resolvers are ordered today. A + workload resolver with `Priority` 4000 is therefore tried before the in-box probe; it returns `null` for + SDKs it does not own, so the in-box probe still resolves `Microsoft.NET.Sdk`. +- **Process scope and lifetime.** Resolution is served by a process-wide singleton + (`SdkResolverService.Instance`) that all `Project`/`ProjectInstance` evaluations share. Registration is + correspondingly process-wide and is meant to be performed once at host startup. See **Risks** for the + caching/timing nuance. +- **No removal.** There is intentionally no `Unregister`/`Clear` on the public surface (host startup is a + one-time operation). A test-only reset is discussed under **Alternative Designs**. + +## API Usage + +### Host (the in-process, AOT SDK CLI) registers its resolver at startup + +```csharp +using Microsoft.Build.Framework; + +// During SDK CLI initialization, before the first evaluation. The resolver type is referenced +// statically and constructed with `new` - no Assembly.LoadFrom, so it works under Native AOT. +SdkResolver.Register(new Microsoft.NET.Sdk.WorkloadMSBuildSdkResolver.WorkloadSdkResolver()); + +// Optionally also the NuGet-based SDK resolver, for projects that reference ``. +// SdkResolver.Register(new Microsoft.Build.NuGetSdkResolver.NuGetSdkResolver()); +``` + +### A minimal in-box-style resolver (the existing shape registered resolvers implement) + +```csharp +internal sealed class WorkloadAutoImportLocatorResolver : SdkResolver +{ + public override string Name => "InProcWorkloadLocatorResolver"; + + public override int Priority => 4000; + + public override SdkResult Resolve(SdkReference sdk, SdkResolverContext context, SdkResultFactory factory) + { + // Returns the /Sdk directories (or an empty set when no workloads are installed) for the + // Microsoft.NET.SDK.Workload*Locator SDKs; null for everything else. No reflection, no plugin load. + if (TryResolveWorkloadLocator(sdk.Name, out IEnumerable paths)) + { + return factory.IndicateSuccess(paths, sdk.Version); + } + + return null; // defer to the next resolver + } +} +``` + +### Effect on the validation harness + +The AOT validation harness today must disable workloads to evaluate a stock template +([`DotnetTemplateAotTests.cs`](../../src/aot-validation/DotnetTemplateAotTests.cs)): + +```csharp +Dictionary globalProperties = new() { ["MSBuildEnableWorkloadResolver"] = "false" }; +return new Project(projectPath, globalProperties, toolsVersion: null, collection); +``` + +With this API, the harness can instead register a baked-in locator resolver once and evaluate the project +with no special global property, proving the end-to-end path the SDK CLI would use. + +## Alternative Designs + +1. **Internal API + `InternalsVisibleTo` for the SDK.** Keep the registration `internal` and grant the SDK + assembly visibility. Smaller public surface, but couples the SDK to an unversioned internal contract and + does not help any other in-process host (custom build servers, test platforms). A public, documented + surface is preferred for a cross-repo boundary. + +2. **Per-`ProjectCollection` / `EvaluationContext` registration.** Attach resolvers to the evaluation scope + instead of the process. More precise lifetime, but SDK resolution is served by a process singleton today; + threading a per-collection resolver set through evaluation is a substantially larger change and is not + required by the motivating scenario (the host's resolver set is process-global and fixed). + +3. **A provider abstraction instead of imperative registration.** + `public static void RegisterProvider(Func> provider);` or an + `ISdkResolverProvider` interface. More flexible (lazy, recomputable) but heavier than the scenario needs; + the host knows its fixed resolver set at startup. + +4. **`params SdkResolver[]` / `IEnumerable` overload.** Convenience for registering several at + once. Compatible with this proposal and can be added later; the single-instance method is the primitive. + +5. **A dedicated `SdkResolverRegistry` static class** instead of a static method on `SdkResolver`. This keeps + the registration mutator off the abstract contract that resolver authors implement, which is arguably a + cleaner separation of concerns, and is the natural fallback if API review prefers not to add a static, + factory-style member to the abstract base. The trade-off is a second new public type for a one-method + feature; the proposed `SdkResolver.Register` adds a single member to an existing type, which is why it is + the primary design. + +6. **Reuse `MSBUILDADDITIONALSDKRESOLVERSFOLDER`.** Rejected: that hook still discovers and loads resolvers + from disk by reflection, which is exactly what is unavailable under AOT. + +## Risks + +- **Process-global mutable static.** The registration state is process-wide and mutable, which is generally + discouraged. It is justified here because SDK resolution is already a process singleton and the resolver + set is established once per host process. The surface is deliberately minimal (a single `Register`). + +- **Registration timing vs. caching.** The engine caches the default-resolver list on first use + (`CachingSdkResolverLoader`). Resolvers registered after the first SDK resolution in the process may be + ignored. Mitigations to decide during review: (a) document "register during host startup" (proposed); + (b) throw `InvalidOperationException` from `Register` once the set has been captured, making the misuse + loud; or (c) re-read the registry on each evaluation (gives up the cache). + +- **Ordering / precedence.** Folding registered resolvers into the reflection-free first pass changes the + order in which resolvers run relative to the in-box probe (now `Priority`-ordered within that pass). + Behavior is unchanged for in-box SDKs (a workload resolver returns `null` for them), but the contract + ("consulted in `Priority` order alongside the built-in resolver") must be explicit. + +- **Layering.** The API lives on `Microsoft.Build.Framework.SdkResolver` and accepts only that public type; + the engine (`Microsoft.Build`) reads the registration state. MSBuild gains no dependency on any SDK type, + so the dependency direction stays SDK -> MSBuild. + +- **Thread-safety.** `Register` must be safe to call concurrently and concurrently with the first + resolution; the implementation uses a thread-safe collection and an immutable snapshot at capture time. + +- **Test isolation.** Because the registration state is process-global, tests that register resolvers need a + reset hook. Proposed as an `internal` test-only reset (exposed via `InternalsVisibleTo`), not part of the + public surface. + +- **Resolver AOT-readiness is out of scope.** This API only provides the injection seam. The registered + resolver and its dependencies must themselves be trim/AOT-safe (for the workload resolver this means + removing `Assembly.Location` use and making the workload-manifest reader trim-safe). That work lives in + the resolver's owning repo (dotnet/sdk). + +## Implementation status + +The primary design has been implemented: + +- **API.** `public static void Register(SdkResolver resolver)` on + [`Microsoft.Build.Framework.SdkResolver`](../../src/Framework/Sdk/SdkResolver.cs). Null-checks its + argument, is thread-safe (`lock`), and de-duplicates the same instance. Registration state is held in a + process-global `private static List`. +- **Engine wiring.** + [`SdkResolverLoader.GetDefaultResolvers()`](../../src/Build/BackEnd/Components/SdkResolution/SdkResolverLoader.cs) + folds the registered resolvers into the reflection-free default-resolver pass and sorts the combined set + by `Priority`. This is the path tried first on .NET (before any manifest is read) and the one pre-populated + as a general manifest on .NET Framework, so registered resolvers never reach the dynamic-loading failure + (MSB4282). The engine reads the registration through an `internal` snapshot property + (`SdkResolver.RegisteredResolvers`) exposed via the existing Framework -> Microsoft.Build `InternalsVisibleTo`. +- **Caching/timing nuance.** Resolved as option (a) from **Risks**: documented "register during host startup, + before the first evaluation." No `InvalidOperationException`-on-late-registration guard was added. +- **Test isolation.** An `internal static void ClearRegisteredResolversForTests()` reset hook (test-only, via + `InternalsVisibleTo`) was added; no removal API is on the public surface. +- **Tests.** Four tests in + [`SdkResolverService_Tests.cs`](../../src/Build.UnitTests/BackEnd/SdkResolverService_Tests.cs) cover the + null-argument throw, idempotent re-registration, `Priority`-ordered inclusion in + `GetDefaultResolvers()`, and an end-to-end resolve through `SdkResolverService`. They run on both + `net10.0` and `net472`. diff --git a/documentation/specs/task-class-registration-api.md b/documentation/specs/task-class-registration-api.md new file mode 100644 index 00000000000..b160a22d52e --- /dev/null +++ b/documentation/specs/task-class-registration-api.md @@ -0,0 +1,219 @@ +# API Proposal: Host registration of task classes + +**Status:** Implemented as the static `Microsoft.Build.Utilities.Task.RegisterTask` overloads +([Task.cs](../../src/Utilities/Task.cs)). The API was placed on `Task` rather than a new `TaskClassRegistry` +type, and the engine now constructs, binds, and executes a registered task with the reflective +task-execution path disabled - so a trimmed/Native AOT host can build a project whose tasks are registered. +Companion to [task-parameter-type-registration-api.md](task-parameter-type-registration-api.md). + +## Background and motivation + +To execute a task, MSBuild reflects over the task type end to end: it loads the task assembly, resolves +the task `Type`, constructs an instance (via the task factory / `Activator.CreateInstance`), binds the +declared parameters onto its properties by reflection, calls `Execute()`, and reads `[Output]` properties +back - all in +[`TaskExecutionHost`](../../src/Build/BackEnd/TaskExecutionHost/TaskExecutionHost.cs). This entire +path is reflective and is gated by the +[`EnableReflectiveTaskExecution`](../../src/Framework/FeatureSwitches.cs) feature switch. Under a +trimmed / Native AOT host the switch is substituted `false`: the reflective instantiation path is +removed and reaching task execution fails observably (the engine reports an error rather than crashing in +reflection). + +The consequence is that a trimmed/AOT host can **evaluate** projects but cannot **run tasks**. Closing +that gap needs a reflection-free way to (a) instantiate a task by its registered name and (b) bind its +parameters. This proposal covers (a) and the registration seam; (b) builds directly on the parameter +**type** registry already implemented in +[task-parameter-type-registration-api.md](task-parameter-type-registration-api.md). + +A host that runs the engine in-process and is itself trimmed/AOT-compiled (the .NET SDK CLI) already +references the task assemblies it cares about statically. It needs to hand MSBuild a pre-constructed way +to make those tasks, the same shape as [`SdkResolver.Register`](sdk-resolver-host-registration-api.md) +and the task **parameter type** registry. + +## API Proposal + +Two static methods on the public `Microsoft.Build.Utilities.Task` (the base class task authors derive from), +mirroring the host-registration shape of [`SdkResolver.Register`](sdk-resolver-host-registration-api.md) and +the task **parameter type** registry on `TaskItem`: + +```diff + namespace Microsoft.Build.Utilities; + + public abstract class Task : ITask + { + // ... existing members ... + ++ /// ++ /// Registers a task type under the name a target uses to invoke it (the TaskName of a ), so ++ /// MSBuild can instantiate and run it without loading its assembly or resolving its type by reflection. ++ /// The [DynamicallyAccessedMembers] roots the type's public constructor and properties so construction ++ /// and parameter binding stay trim-safe. ++ /// ++ public static void RegisterTask< ++ [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor | DynamicallyAccessedMemberTypes.PublicProperties)] T>( ++ string taskName) where T : ITask, new(); ++ ++ /// ++ /// Registers a task under the given name with an explicit factory, so construction is fully ++ /// reflection-free (the host supplies the constructor). The host is responsible for preserving the ++ /// task type's public properties under trimming (parameter binding still reflects over them). ++ /// ++ public static void RegisterTask(string taskName, Func factory); + } +``` + +The generic overload is the trim-safe primitive (its `[DynamicallyAccessedMembers]` roots the type, and +construction is `new T()`); the `Func` overload is the convenience for tasks without a public +parameterless constructor or that need custom construction. Both forward to an internal +[`TaskClassRegistry`](../../src/Framework/TaskClassRegistry.cs) in Microsoft.Build.Framework - the lowest +assembly - so the engine, the task library, and the public surface all reach it. Placing the methods on the +existing public `Task` keeps the surface minimal, the same trade-off the parameter-type registry made by +hanging its methods off `TaskItem`. + +### Pre-registering the common MSBuild tasks + +The proposal includes pre-registering the frequently used built-in tasks (for example `Message`, `Copy`, +`MakeDir`, `RemoveDir`, `WriteLinesToFile`, `ReadLinesFromFile`, `Delete`, `Touch`, `Error`, `Warning`), +so a stock build runs the common tasks under AOT with no host action. These tasks live in +**Microsoft.Build.Tasks.Core**, which the engine (`Microsoft.Build`) must not reference (layering). So +pre-registration is published *from the Tasks assembly*, by one of: + +- a `[ModuleInitializer]` in Microsoft.Build.Tasks.Core that registers its common tasks when the assembly + is loaded, or +- an explicit `public static void Microsoft.Build.Tasks.BuiltInTasks.RegisterAll()` a host calls during + startup (more explicit, no load-order surprises). + +The curated set, and whether `ToolTask`-based tasks (Csc/Vbc, which shell out) belong in it, are open +questions for review. + +## Semantics + +- **Registry first, reflection-free.** When a task name is registered, the engine constructs it from the + registration (factory or rooted type) and binds parameters without loading the task assembly by path or + reflecting to discover the type. This is the path that works with `EnableReflectiveTaskExecution` + effectively satisfied for that task. +- **Precedence and task identity.** The registry is consulted before the project's `` table, so + a registered name takes precedence over a same-named ``. Registration is by name only - it does + not participate in `Runtime`/`Architecture` task-identity selection (the registered task is always the one + constructed), so registering a name collapses any task-identity variants of it. For stock builds nothing is + registered, so existing `` resolution and task-identity selection are unchanged. +- **Fallback / observable failure.** An unregistered task name falls back to the existing reflective load + under the JIT, and fails observably under AOT (the current `EnableReflectiveTaskExecution`-off + behavior), unchanged. +- **Intrinsic tasks stay available.** The engine-internal `MSBuild` and `CallTarget` tasks are resolved + from known engine types (via `IntrinsicTaskFactory`) with no assembly probing or by-name type + resolution, so they run with `EnableReflectiveTaskExecution` off and need no registration. Because + virtually every real build dispatches through `` and ``, treating them as + always-available (rather than requiring the host to register them) keeps the registered/AOT path usable + for real project graphs. +- **Parameter binding depends on the type registry.** Binding a ``-typed or + reflected-property parameter still needs the parameter's *type* to be resolvable; that is exactly what + [task-parameter-type-registration-api.md](task-parameter-type-registration-api.md) provides. The two + registries are designed to be used together. +- **Process scope and timing.** Process-global, established once at host startup, mirroring the SDK + resolver and parameter-type registries. + +## API Usage + +```csharp +using Microsoft.Build.Utilities; + +// A host bakes in the tasks it supports under AOT, before the first build. +Task.RegisterTask("Message"); +Task.RegisterTask("Copy"); + +// Or with an explicit factory (construction is reflection-free; the host roots the type for binding): +Task.RegisterTask("MyTask", () => new MyNamespace.MyTask()); + +// The common built-ins in one call (published from the Tasks assembly): +Microsoft.Build.Tasks.BuiltInTasks.RegisterAll(); +``` + +## Alternative designs + +1. **Static methods on `Microsoft.Build.Utilities.Task`** (the base class most task authors derive from) + instead of a new `TaskClassRegistry` type - discoverable from the type authors already use, mirroring + how the parameter-type registry hangs off `TaskItem`. Trade-off: `Utilities` is above the engine, so + the backing store would still live in Framework with `Utilities` forwarding (as the parameter-type + registry does). +2. **A source generator** ([task-factory-aot.md](../aot/task-factory-aot.md)) that emits the registrations and + strongly-typed parameter setters from the task classes a host references. The generator is the + declarative counterpart; this imperative `RegisterTask` is the primitive it would target, and the + manual API is what hosts use until/unless the generator ships. +3. **Factory-only (no generic overload).** Smallest, most explicit, fully reflection-free surface; the + generic overload is sugar that relies on rooting. Could ship factory-only first. +4. **Per-`ProjectCollection` registration** instead of process-global - more precise lifetime, larger + change; not required by the motivating in-process-host scenario. + +## Risks + +- **Parameter binding, not construction, is the hard part under AOT.** Constructing a registered task is + easy; setting its parameters today goes through reflection over the task's properties. Rooting the + properties (the `[DynamicallyAccessedMembers]` above) makes reflective set work but at a size cost; a + generated binder (alternative 2) avoids reflection entirely and is the longer-term answer. This is the + main reason the proposal is staged after the parameter-**type** registry. +- **Layering for the built-in pre-registration.** The engine cannot reference Microsoft.Build.Tasks.Core, + so the built-in set must be registered from the Tasks assembly (module initializer or explicit call), + which introduces a startup-ordering contract. +- **Output parameters and item conversion.** Reading `[Output]` properties back and converting to + `ITaskItem`/value types reuses the same reflection-sensitive machinery; it must be made trim-safe + alongside, again leaning on the parameter-type registry. +- **Process-global mutable static / test isolation.** As with the other registries; a test-only reset and + "register at startup" guidance apply. + +## Related + +- [task-parameter-type-registration-api.md](task-parameter-type-registration-api.md) - the implemented companion (registers parameter **types**); required for binding. +- [sdk-resolver-host-registration-api.md](sdk-resolver-host-registration-api.md) - the host-registration shape this mirrors. +- [task-factory-aot.md](../aot/task-factory-aot.md) - the source-generator direction this would complement. +- [EnableReflectiveTaskExecution](../../src/Framework/FeatureSwitches.cs) - the switch that gates today's reflective task execution and fails observably under AOT. + +## Implementation status + +Implemented in this change: + +- **API.** `RegisterTask(string)` and `RegisterTask(string, Func)` on + [`Microsoft.Build.Utilities.Task`](../../src/Utilities/Task.cs), forwarding to the internal + [`TaskClassRegistry`](../../src/Framework/TaskClassRegistry.cs) (a name -> registration map; each + [`TaskClassRegistration`](../../src/Framework/TaskClassRegistration.cs) holds a `Func` and a + `LoadedType` built once, eagerly for the generic overload where the type's `[DynamicallyAccessedMembers]` + is in scope). +- **Engine wiring.** [`TaskExecutionHost`](../../src/Build/BackEnd/TaskExecutionHost/TaskExecutionHost.cs) + consults the registry first in `FindTask` (building a `TaskFactoryWrapper` from a new + [`RegisteredTaskFactory`](../../src/Build/Instance/TaskFactories/RegisteredTaskFactory.cs) and the + registered `LoadedType`), constructs a registered task with a non-interface, reflection-free method + (avoiding the `[RequiresUnreferencedCode]` `ITaskFactory.CreateTask`), and binds its parameters with the + `Type.GetType`-free `ResolveTaskParameterType` (the live property type in-proc; the assembly-qualified + fallback is gated behind `EnableReflectiveTaskExecution`). A registered task runs with that switch off. + The intrinsic `MSBuild` and `CallTarget` tasks are resolved on the same switch-off path + (`TryCreateIntrinsicTaskFactory` building an `IntrinsicTaskFactory`, instantiated by + `IntrinsicTaskFactory.CreateIntrinsicTask` - a direct `new` with no reflection), so real builds that + dispatch through ``/`` run with the switch off too. +- **Built-ins.** [`Microsoft.Build.Tasks.BuiltInTasks.RegisterAll`](../../src/Tasks/BuiltInTasks.cs) + registers the common tasks (`Message`, `Warning`, `Error`, `MakeDir`, `RemoveDir`, `Copy`, `Delete`, + `Touch`, `WriteLinesToFile`, `ReadLinesFromFile`) from the Tasks assembly (the engine cannot reference it). +- **Build-path AOT enablement.** Running a build in-process pulls in the build-execution path the prior AOT + work scoped out. Making it trim-clean for registered-task builds added a feature switch + [`EnableReflectiveLoggerLoading`](../../src/Framework/FeatureSwitches.cs) that gates the reflective + `LoggerDescription.CreateForwardingLogger` calls in + [`LoggingService`](../../src/Build/BackEnd/Components/Logging/LoggingService.cs) (so the trimmer drops + `TypeLoader`/`MetadataLoadContext`), read the handshake file version from `AssemblyFileVersionAttribute` + instead of `Assembly.Location` ([`Handshake`](../../src/Framework/BackEnd/Handshake.cs)), and guarded + the remaining single-file `Assembly.Location` reads on `RuntimeFeature.IsDynamicCodeSupported` + ([`LoadedType`](../../src/Framework/Loader/LoadedType.cs), + [`CoreClrAssemblyLoader`](../../src/Framework/Loader/CoreCLRAssemblyLoader.cs)). Loggers supplied as + `ILogger` instances are the supported way to log under AOT. +- **Tests.** [`RegisteredTaskAotTests`](../../src/aot-validation/RegisteredTaskAotTests.cs) builds a + hand-authored project end to end under Native AOT - the built-in tasks and a host-registered custom task + (with an `[Output]` bound back) run, with real file side effects - and an unregistered task fails + observably. [`DotnetTemplateAotTests`](../../src/aot-validation/DotnetTemplateAotTests.cs) builds the + real `dotnet new` `console`/`classlib` templates under AOT: evaluation and the registered built-in tasks + run, then the build fails observably at the first task from the SDK's own task assembly + (`Microsoft.NET.Build.Tasks`, which is not part of `Microsoft.Build.Tasks.Core` and cannot be registered) + - degrading to a reported error rather than a reflection crash. The harness AOT-publishes warning-clean + and passes under Native AOT. + +Not done (future work): a source-generated parameter binder (alternative 2) to remove the rooted-reflection +parameter binding; fully AOT-cleaning the rest of the `BuildManager` surface (the build entry points remain +`[RequiresUnreferencedCode]` for reflective logger/plugin loading - an in-process host that passes +`ILogger` instances and no project-cache plugins, like the harness, does not hit that path). diff --git a/documentation/specs/task-parameter-type-registration-api.md b/documentation/specs/task-parameter-type-registration-api.md new file mode 100644 index 00000000000..a31663b3d5d --- /dev/null +++ b/documentation/specs/task-parameter-type-registration-api.md @@ -0,0 +1,265 @@ +# API Proposal: Host registration of task parameter types + +**Status:** Implemented as `Microsoft.Build.Utilities.TaskItem.RegisterTaskParameterValueType` / +`RegisterTaskParameterItemType` ([TaskItem.cs](../../src/Utilities/TaskItem.cs)), backed by the +reflection-free [`TaskParameterTypeRegistry`](../../src/Framework/TaskParameterTypeRegistry.cs). The +proposal below is the original design rationale. + +## Background and motivation + +A `` may declare its parameters in a ``, each with a `ParameterType` naming a +.NET type: + +```xml + + + + + + + ... + +``` + +MSBuild restricts these types to a small, well-defined set (see +[task-parameter-types.md](../aot/task-parameter-types.md)): any value type, `string`, and the +`Microsoft.Build.Framework.ITaskItem` family, each also allowed as an array. To turn the declared +*name* into a `System.Type`, +[`TaskRegistry.ParseUsingTaskParameterGroupElement`](../../src/Build/Instance/TaskRegistry.cs#L1764) +historically called `System.Type.GetType(string)`. + +`Type.GetType(string)` is incompatible with trimming and Native AOT: the type is named at run time by +the project author, so the trimmer cannot know which type to preserve. The call carries `IL2057` / +`IL2096` trim warnings, and in a trimmed image the named type's metadata may have been removed, so +resolution fails. This is the only thing standing between a stock `` and a clean AOT +evaluation - the *set* of legal types is almost entirely statically known (the intrinsics and the +`ITaskItem` types), even though the *resolution* was reflective. + +This proposal adds a small registry of task parameter types, pre-populated with the product-known set +and rooted for trimming, plus a host-registration surface for any additional types a host uses. The +by-name `Type.GetType` becomes a gated fallback that a trimmed/AOT image removes. + +## API Proposal + +Two static methods on the public `Microsoft.Build.Utilities.TaskItem`, mirroring the host-registration +shape of [`SdkResolver.Register`](sdk-resolver-host-registration-api.md): + +```diff + namespace Microsoft.Build.Utilities; + + public sealed class TaskItem : ITaskItem2, IMetadataContainer + { + // ... existing members ... + ++ /// ++ /// Registers a value type so it can be used as a task parameter type (the ParameterType of a ++ /// parameter) in a trimmed or Native AOT host. ++ /// ++ public static void RegisterTaskParameterValueType< ++ [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] T>() where T : struct; ++ ++ /// ++ /// Registers an ITaskItem type so it can be used as a task parameter type (the ParameterType of a ++ /// parameter) in a trimmed or Native AOT host. ++ /// ++ public static void RegisterTaskParameterItemType() where T : ITaskItem; + } +``` + +The two constraints (`struct` and `ITaskItem`) correspond exactly to the two open-ended families of the +allowed set; `string` and its array are intrinsic and never need registration. `[DynamicallyAccessedMembers(All)]` +on the **value-type** method roots the registered struct/enum, so a trimmed image can still convert a +parameter from its string form (which may use a `TypeConverter`/`Enum.Parse`). The **item-type** method +carries no DAM: an item-typed parameter is validated by assignability and is never member-reflected through +the registry, so only the type reference is needed - which keeps registering even large concrete item +classes essentially free under trimming. + +### Supporting internals + +- A new internal store, + [`Microsoft.Build.Framework.TaskParameterTypeRegistry`](../../src/Framework/TaskParameterTypeRegistry.cs), + maps a type's `FullName` (for example `System.Int32`, `System.Int32[]`, + `Microsoft.Build.Framework.ITaskItem`) to the `Type`. It lives in Framework - the lowest assembly - + so both the engine (`Microsoft.Build`) and the public surface (`Microsoft.Build.Utilities`) reach it. + The public `TaskItem` methods forward to it. +- It is **pre-registered** in its static constructor with the product-known set: + - `string` and `string[]`. + - The intrinsic value types and their arrays (`bool`, `byte`, `sbyte`, `char`, `short`, `ushort`, + `int`, `uint`, `long`, `ulong`, `float`, `double`, `decimal`, `DateTime`), each member-rooted via the + `[DynamicallyAccessedMembers(All)]` on `RegisterValueType`. + - The Framework `ITaskItem` types and their arrays (`ITaskItem`, `ITaskItem2`, `TaskItemData`), with no + member rooting (item types are validated by assignability only). + The **concrete** item types in higher assemblies are registered by their owning assembly, since Framework + cannot reference them: the engine's internal `ProjectItemInstance.TaskItem` from a static constructor in + `Microsoft.Build` (next to the `` parser), and the public + `Microsoft.Build.Utilities.TaskItem` by a host through the public API (a higher-layer type the engine + does not reference, and one a multi-targeted library cannot cleanly module-initialize - Utilities targets + `netstandard2.0`, where `[ModuleInitializer]` does not exist and would also trip `CA2255`). The + out-of-proc task host's private `TaskParameterTaskItem` is never declared as a parameter type and is not + registered. +- A new feature switch + [`FeatureSwitches.EnableReflectiveTaskParameterTypes`](../../src/Framework/FeatureSwitches.cs) + (default `true` under the JIT, substituted `false` when trimming) gates the by-name fallback. + +### Resolution logic + +[`ParseUsingTaskParameterGroupElement`](../../src/Build/Instance/TaskRegistry.cs#L1764) becomes: + +```csharp +// Always consult the reflection-free registry first. +Type paramType = TaskParameterTypeRegistry.TryGetType(expandedType); +if (paramType == null) +{ + if (FeatureSwitches.EnableReflectiveTaskParameterTypes) + { + paramType = ResolveParameterTypeByName(expandedType); // the old Type.GetType path, now a RUC helper + } +} + +ProjectErrorUtilities.VerifyThrowInvalidProject(paramType != null, /* InvalidEvaluatedAttributeValue */ ...); +``` + +The reflective fallback is isolated in a `[RequiresUnreferencedCode]` helper +([`ResolveParameterTypeByName`](../../src/Build/Instance/TaskRegistry.cs#L1743)). Because the switch +is a `[FeatureGuard]`, calling that helper inside `if (EnableReflectiveTaskParameterTypes)` needs no +suppression; because the switch is a `[FeatureSwitchDefinition]`, the trimmer substitutes it `false` and +removes the helper call entirely. The two `[UnconditionalSuppressMessage]` rows on +`ParseUsingTaskParameterGroupElement` (`IL2057`, `IL2096`) are deleted - the warnings are gone, not +suppressed. + +## Semantics + +- **Registry first, always.** Every resolution consults the registry first, on both the JIT and trimmed + paths, with no reflection. The switch only gates the fallback for names the registry does not know. +- **Observable failure.** With the switch off, an unregistered name leaves `paramType` null and the + existing `VerifyThrowInvalidProject(... "InvalidEvaluatedAttributeValue" ...)` reports an + `InvalidProjectFileException` - the same error an unresolvable type already produced, now reached + without reflection. +- **Process scope and timing.** The registry is process-global and consulted fresh on every lookup, so + (unlike SDK resolver registration) there is no first-use snapshot; a registration takes effect for any + later evaluation. Intended to be called once per type at host startup. +- **Thread-safety / idempotence.** Backed by a `ConcurrentDictionary`; registering the same type twice + is harmless. +- **Validation is unchanged.** The registry only replaces name -> `Type`. `TaskParameterTypeVerifier` + still enforces the input/output rules afterward, so registering a type does not widen what is legal. + +## API Usage + +```csharp +using Microsoft.Build.Utilities; + +// During host startup, before the first evaluation. The type is referenced statically (no reflection). +// The value-type method roots the struct/enum for the trimmer (string-to-value conversion may reflect); +// the item-type method does not need to (item types are validated by assignability). +TaskItem.RegisterTaskParameterValueType(); +TaskItem.RegisterTaskParameterValueType(); +TaskItem.RegisterTaskParameterItemType(); +``` + +After this, a `` may declare `ParameterType="MyNamespace.MyEnum"` (etc.) and it resolves +from the registry under Native AOT with no `Type.GetType`. + +### Effect on the validation harness + +[`TaskParameterTypeRegistryAotTests`](../../src/aot-validation/TaskParameterTypeRegistryAotTests.cs) +bakes `EnableReflectiveTaskParameterTypes=false` and proves, under Native AOT: pre-registered types +(`string`, the intrinsics, `ITaskItem`) resolve and a project evaluates; a host-registered custom +`struct` and the public concrete `Microsoft.Build.Utilities.TaskItem` both resolve through the public +seam; and an unregistered type (`System.Guid`) fails observably with `InvalidProjectFileException` rather +than crashing in reflection. + +## What this does and does not buy + +- It resolves the **common, product-known** parameter types under AOT with no reflection, and gives a + host a supported way to add its own. That covers the overwhelming majority of real `` + declarations (`string`, an intrinsic, `ITaskItem`/`ITaskItem[]`). +- It **cannot fully close the set.** The legal set includes *any* value type (`IsValueType`), which is + not enumerable, so a name the host did not register still falls back to `Type.GetType` (under the JIT) + or fails observably (under AOT). This is inherent and is documented in + [task-parameter-types.md](../aot/task-parameter-types.md). +- **The .NET SDK itself declares no typed parameter groups.** A scan of every build file in a `10.0.300` + SDK (`*.targets`/`*.props`/`*.tasks`) found **zero** `ParameterType="..."` declarations - the SDK uses + compiled tasks, not typed inline tasks. So this primarily serves non-SDK / host scenarios and is + infrastructure for the compiled-task path; the SDK's own evaluation needs none of it. +- The `` path it most directly affects is **inline-task only and evaluation-time**, and + inline tasks are already AOT-incompatible at execution (they compile source at run time). So the + immediate payoff is removing two trim suppressions and making the parse trim-clean; the same registry + is the reusable primitive for the higher-value **compiled-task** parameter-binding path + (`LoadedType` / `TaskExecutionHost.SetTaskItemParameter`). + +## Alternative designs + +1. **A dedicated public `TaskParameterTypeRegistry` type** instead of methods on `TaskItem`. Cleaner + separation, but a second new public type for a two-method feature; hanging the methods off the + existing public `TaskItem` (the type a parameter author already knows) keeps the surface minimal, the + same trade-off `SdkResolver.Register` made. +2. **A single `Register()`** without the `struct` / `ITaskItem` split. Rejected: the two constraints + map exactly to the two legal families and stop a caller from registering a reference type that the + verifier would reject anyway. +3. **Tunable value-type rooting (`DynamicallyAccessedMemberTypes`).** Value types use `All` (conservative, + to keep string-to-value conversion working); item types use none. A narrower value-type set (for example + `PublicParameterlessConstructor | PublicProperties`) would shrink the image further if a scenario needs + it; the level is an implementation detail behind the same API. +4. **Where the concrete `ITaskItem` implementations are registered.** Framework cannot reference them, so + each is registered by its owning assembly: the engine's `ProjectItemInstance.TaskItem` from a static + constructor in `Microsoft.Build` (free, since item types are not member-rooted), and the public + `Microsoft.Build.Utilities.TaskItem` through the public API by a host that declares it. A library module + initializer was rejected for the latter (`netstandard2.0` has no `[ModuleInitializer]`, and it trips + `CA2255`). + +## Risks + +- **Process-global mutable static.** Justified as for `SdkResolver.Register`: a host's parameter-type set + is fixed and established once at startup. The surface is two `Register` methods with no removal. +- **Rooting size cost.** Only the **value** types carry `[DynamicallyAccessedMembers(All)]` (item types + are validated by assignability, so they are registered without member rooting). Measured on the AOT + validation harness (`win-x64`, Release): + + | Native image | Bytes | Delta vs control | + | --- | --- | --- | + | No member rooting (control) | 19,319,296 | - | + | As implemented (value types member-rooted; item + concrete item types registered without member rooting) | 19,969,536 | **+650,240 (~635 KB, +3.4%)** | + | For reference: also member-rooting every item/concrete type with `All` | 20,478,464 | +1,159,168 (~1.1 MB) | + + The cost is entirely value-type rooting and is a one-time, fixed cost (it does not grow with project + count). Registering the concrete item types (including the engine's `ProjectItemInstance.TaskItem`) is + **free** because item types are never member-reflected - the third row shows what member-rooting them + would have cost, which is why the implementation does not. +- **Evaluation-time failure shift.** Turning the unknown-type case into an evaluation error (under AOT) + is reached when a project is *evaluated*, which is broader than task *execution*. This is acceptable + because an inline task that cannot resolve its parameter types cannot run under AOT regardless, and the + failure is observable rather than a crash. +- **Layering.** The store lives in `Microsoft.Build.Framework`; the engine and Utilities reach it through + the existing `InternalsVisibleTo`. MSBuild gains no new dependency. + +## Implementation status + +Implemented in this change: + +- **API.** `RegisterTaskParameterValueType()` and `RegisterTaskParameterItemType()` on + [`Microsoft.Build.Utilities.TaskItem`](../../src/Utilities/TaskItem.cs), forwarding to the internal + [`TaskParameterTypeRegistry`](../../src/Framework/TaskParameterTypeRegistry.cs). +- **Engine wiring.** [`ParseUsingTaskParameterGroupElement`](../../src/Build/Instance/TaskRegistry.cs#L1764) + consults the registry first and gates the by-name fallback + ([`ResolveParameterTypeByName`](../../src/Build/Instance/TaskRegistry.cs#L1743)) behind + [`FeatureSwitches.EnableReflectiveTaskParameterTypes`](../../src/Framework/FeatureSwitches.cs); the + `IL2057`/`IL2096` suppressions are removed. +- **Trimmed default.** A matching `RuntimeHostConfigurationOption` in Microsoft.Build.Framework.csproj, + plus the Framework package's buildTransitive targets for package consumers. The AOT harness re-declares + the switch because project-level host-configuration options do not flow across project references. +- **Concrete item types.** The engine's `ProjectItemInstance.TaskItem` is registered from a static + constructor on the `` parser; the public `Microsoft.Build.Utilities.TaskItem` is + registered by a host through the public API (it is above Framework, and a multi-targeted library cannot + cleanly module-initialize it). Item types carry no member rooting, so registering them is free. +- **Tests.** Four Native-AOT tests in + [`TaskParameterTypeRegistryAotTests.cs`](../../src/aot-validation/TaskParameterTypeRegistryAotTests.cs); + the harness publishes warning-clean and passes. Both TFMs of `Microsoft.Build` and all + three TFMs of `Microsoft.Build.Utilities` (including `netstandard2.0`) build warning-clean, with 102 + `TaskRegistry_Tests` as a JIT regression guard. + +## Related + +- [task-parameter-types.md](../aot/task-parameter-types.md) - the precise allowed-type set and the `ITaskItem` landscape. +- [sdk-resolver-host-registration-api.md](sdk-resolver-host-registration-api.md) - the host-registration shape this mirrors. +- [task-class-registration-api.md](task-class-registration-api.md) - the companion proposal for registering task *classes*. +- [task-factory-aot.md](../aot/task-factory-aot.md) - the static task-registration / source-generator direction this complements. diff --git a/src/Build.UnitTests/BackEnd/RegisteredTaskExecution_Tests.cs b/src/Build.UnitTests/BackEnd/RegisteredTaskExecution_Tests.cs new file mode 100644 index 00000000000..dc82bcb6c63 --- /dev/null +++ b/src/Build.UnitTests/BackEnd/RegisteredTaskExecution_Tests.cs @@ -0,0 +1,171 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Build.Framework; +using Microsoft.Build.UnitTests; +using Microsoft.Build.Utilities; +using Xunit; + +#nullable enable + +namespace Microsoft.Build.Engine.UnitTests.BackEnd +{ + /// + /// Integration coverage for the host task-class registration API + /// ( and the + /// overload) exercised through a real in-process build on the default + /// (JIT) engine, where reflective task execution is enabled. The Native AOT (reflective-off) counterparts + /// live in the aot-validation harness, which is not part of the CI test run; these run in CI. + /// + /// + /// The task registry is process-global and has no unregister API, so each test uses a unique, distinctive + /// task name that no real task or other test uses; the leftover registrations are harmless (a registered + /// name only affects a build that invokes a task of exactly that name). + /// + public sealed class RegisteredTaskExecution_Tests + { + private readonly ITestOutputHelper _output; + + public RegisteredTaskExecution_Tests(ITestOutputHelper output) => _output = output; + + /// + /// A task registered through the generic overload resolves from the registry (consulted before the + /// project's UsingTask table) and executes under the default JIT engine, and its [Output] binds + /// back to a property - exercising reflective parameter binding over the registered, trim-rooted type. + /// + [Fact] + public void RegisterTaskGeneric_ResolvesAndExecutesAndBindsOutput() + { + Task.RegisterTask("RegTaskTest_GenericEcho"); + + string project = """ + + + + + + + + + """; + + MockLogger logger = ObjectModelHelpers.BuildProjectExpectSuccess(project, _output); + + logger.AssertLogContains("RegisteredEchoTestTask ran: hello!"); + logger.AssertLogContains("Bound: hello!"); + } + + /// + /// A task registered through the overload (whose task type is not + /// statically known at registration) resolves and executes, exercising the lazily-built + /// LoadedType and the parameter binding that depends on it. + /// + [Fact] + public void RegisterTaskFactory_ResolvesAndExecutesAndBindsOutput() + { + Task.RegisterTask("RegTaskTest_FactoryEcho", static () => new RegisteredEchoTestTask()); + + string project = """ + + + + + + + + + """; + + MockLogger logger = ObjectModelHelpers.BuildProjectExpectSuccess(project, _output); + + logger.AssertLogContains("RegisteredEchoTestTask ran: world!"); + logger.AssertLogContains("Bound: world!"); + } + + /// + /// Registering a name a second time replaces the previous registration; the most recently registered + /// task is the one the engine constructs. + /// + [Fact] + public void RegisterTask_RegisteringSameNameAgain_ReplacesPreviousRegistration() + { + Task.RegisterTask("RegTaskTest_Replace"); + + // Re-register the same name with a different task type; the latest registration must win. + Task.RegisterTask("RegTaskTest_Replace"); + + string project = """ + + + + + + """; + + MockLogger logger = ObjectModelHelpers.BuildProjectExpectSuccess(project, _output); + + logger.AssertLogContains("RegisteredMarkerTestTask ran"); + logger.AssertLogDoesntContain("RegisteredEchoTestTask ran"); + } + + /// + /// A registered task followed by an intrinsic task on the same (reused) TaskExecutionHost both + /// execute correctly: the registered-task factory does not leak across tasks. If it did, the intrinsic + /// CallTarget would be mis-constructed as the registered task and its target would not run. + /// + [Fact] + public void RegisteredTaskFollowedByIntrinsicTask_BothExecute_NoStateLeak() + { + Task.RegisterTask("RegTaskTest_ResetEcho"); + + string project = """ + + + + + + + + + + """; + + MockLogger logger = ObjectModelHelpers.BuildProjectExpectSuccess(project, _output); + + logger.AssertLogContains("RegisteredEchoTestTask ran: x!"); + logger.AssertLogContains("SideTargetRan"); + } + } + + /// + /// A simple host-registered task: echoes its with a "!" suffix into an + /// [Output] property and logs a marker so a build can assert it executed and that its output bound. + /// + public sealed class RegisteredEchoTestTask : Task + { + public string? Input { get; set; } + + [Output] + public string? Result { get; set; } + + public override bool Execute() + { + Result = Input + "!"; + Log.LogMessage(MessageImportance.High, "RegisteredEchoTestTask ran: " + Result); + return true; + } + } + + /// + /// A second host-registered task that logs a distinct marker, used to prove that re-registering a name + /// replaces the previous registration. + /// + public sealed class RegisteredMarkerTestTask : Task + { + public override bool Execute() + { + Log.LogMessage(MessageImportance.High, "RegisteredMarkerTestTask ran"); + return true; + } + } +} diff --git a/src/Build.UnitTests/BackEnd/SdkResolverService_Tests.cs b/src/Build.UnitTests/BackEnd/SdkResolverService_Tests.cs index 474c9b5f77b..08d542177c9 100644 --- a/src/Build.UnitTests/BackEnd/SdkResolverService_Tests.cs +++ b/src/Build.UnitTests/BackEnd/SdkResolverService_Tests.cs @@ -75,6 +75,156 @@ public void AssertAllResolverErrorsLoggedWhenSdkNotResolved() _logger.Warnings.Select(i => i.Message).ShouldBe(new[] { "WARNING4", "WARNING2" }); } + [Fact] + // Scenario: in a trimmed / Native AOT host the dynamic loading of plugin SDK resolvers is disabled. + // An SDK that can only be resolved by such a resolver must fail observably with a reported + // project-file error (MSB4282) rather than silently or by attempting an unsupported Assembly.LoadFrom. + public void AssertDynamicResolverLoadingDisabledFailsObservably() + { + bool switchWasSet = AppContext.TryGetSwitch("Microsoft.Build.EnableSdkResolverDynamicLoading", out bool originalValue); + try + { + AppContext.SetSwitch("Microsoft.Build.EnableSdkResolverDynamicLoading", false); + + var service = new SdkResolverService(); + service.InitializeForTests(new MockLoaderStrategy(includeResolversWithPatterns: true)); + + SdkReference sdk = new SdkReference("1sdkName", "referencedVersion", "minimumVersion"); + + Microsoft.Build.Exceptions.InvalidProjectFileException exception = Should.Throw(() => service.ResolveSdk( + BuildEventContext.InvalidSubmissionId, + sdk, + _loggingContext, + new MockElementLocation("file"), + "sln", + "projectPath", + interactive: false, + isRunningInVisualStudio: false, + failOnUnresolvedSdk: true)); + + exception.ErrorCode.ShouldBe("MSB4282"); + } + finally + { + // AppContext switches cannot be returned to "unset"; restore the prior value (the default is + // enabled when unset), so other tests keep dynamic resolver loading on. + AppContext.SetSwitch("Microsoft.Build.EnableSdkResolverDynamicLoading", !switchWasSet || originalValue); + } + } + +#if NET + [Fact] + // Scenario: even with dynamic resolver loading disabled (trimmed / Native AOT host), an in-box SDK + // resolved by the reflection-free default resolver still succeeds, and the reflective manifest-load + // funnel is never reached. + public void AssertDefaultResolverSucceedsWhenDynamicLoadingDisabled() + { + bool switchWasSet = AppContext.TryGetSwitch("Microsoft.Build.EnableSdkResolverDynamicLoading", out bool originalValue); + try + { + AppContext.SetSwitch("Microsoft.Build.EnableSdkResolverDynamicLoading", false); + + var service = new SdkResolverService(); + var strategy = new MockLoaderStrategy(includeDefaultResolver: true); + service.InitializeForTests(strategy); + + SdkReference sdk = new SdkReference("1sdkName", "referencedVersion", "minimumVersion"); + + var result = service.ResolveSdk(BuildEventContext.InvalidSubmissionId, sdk, _loggingContext, new MockElementLocation("file"), "sln", "projectPath", interactive: false, isRunningInVisualStudio: false, failOnUnresolvedSdk: true); + + result.Success.ShouldBeTrue(); + result.Path.ShouldBe("defaultpath"); + + // The reflection-based plugin-resolver load funnel must never have been reached. + strategy.ResolversHaveBeenLoaded.ShouldBeFalse(); + } + finally + { + AppContext.SetSwitch("Microsoft.Build.EnableSdkResolverDynamicLoading", !switchWasSet || originalValue); + } + } +#endif + + [Fact] + // SdkResolver.Register validates its argument. + public void RegisterSdkResolver_NullResolver_Throws() + { + Should.Throw(() => SdkResolver.Register(null)); + } + + [Fact] + // Registering the same instance more than once has no additional effect. + public void RegisterSdkResolver_SameInstanceTwice_RegistersOnce() + { + var resolver = new MockRegisteredSdkResolver(); + try + { + SdkResolver.Register(resolver); + SdkResolver.Register(resolver); + + SdkResolver.RegisteredResolvers.Count(r => ReferenceEquals(r, resolver)).ShouldBe(1); + } + finally + { + SdkResolver.ClearRegisteredResolversForTests(); + } + } + + [Fact] + // A host-registered resolver is folded into the reflection-free default-resolver pass, ordered by Priority. + public void RegisteredResolverIsIncludedInDefaultResolversByPriority() + { + var resolver = new MockRegisteredSdkResolver(); // Priority 1 + try + { + SdkResolver.Register(resolver); + + IReadOnlyList defaultResolvers = new SdkResolverLoader().GetDefaultResolvers(); + + defaultResolvers.ShouldContain(resolver); + + // Priority 1 sorts ahead of the built-in DefaultSdkResolver (Priority 10000). + defaultResolvers[0].ShouldBeSameAs(resolver); + } + finally + { + SdkResolver.ClearRegisteredResolversForTests(); + } + } + + [Fact] + // End to end: a resolver registered via SdkResolver.Register resolves an SDK through the service on + // the reflection-free path, with no on-disk resolver manifests involved. + public void RegisteredResolverResolvesSdkThroughService() + { + var resolver = new MockRegisteredSdkResolver(); + try + { + SdkResolver.Register(resolver); + + var service = new SdkResolverService(); + service.InitializeForTests(new ManifestlessResolverLoader()); + + var result = service.ResolveSdk( + BuildEventContext.InvalidSubmissionId, + new SdkReference("RegisteredSdk", "1.0", minimumVersion: null), + _loggingContext, + new MockElementLocation("file"), + solutionPath: null, + projectPath: "projectPath", + interactive: false, + isRunningInVisualStudio: false, + failOnUnresolvedSdk: false); + + result.Success.ShouldBeTrue(); + result.Path.ShouldBe("registeredPath"); + } + finally + { + SdkResolver.ClearRegisteredResolversForTests(); + } + } + [Fact] public void AssertSingleResolverErrorLoggedWhenSdkNotResolved() { @@ -897,6 +1047,30 @@ internal override IReadOnlyList GetDefaultResolvers() } } + private sealed class MockRegisteredSdkResolver : SdkResolver + { + public override string Name => nameof(MockRegisteredSdkResolver); + + public override int Priority => 1; + + public override SdkResultBase Resolve(SdkReference sdk, SdkResolverContextBase resolverContext, SdkResultFactoryBase factory) + { + return sdk.Name.Equals("RegisteredSdk", StringComparison.Ordinal) + ? factory.IndicateSuccess("registeredPath", sdk.Version) + : null; + } + } + + // A loader that exercises the real default-resolver pipeline (so resolvers registered via + // SdkResolver.Register flow through the base GetDefaultResolvers) but reports no on-disk resolver + // manifests, keeping the test independent of the machine's SdkResolvers folder. This validates + // end-to-end resolution on both .NET (first pass) and .NET Framework (default-resolver manifest pass). + private sealed class ManifestlessResolverLoader : SdkResolverLoader + { + internal override IReadOnlyList GetResolversManifests(ElementLocation location) + => new List(); + } + private sealed class MockResolverReturnsNull : SdkResolver { public override string Name => nameof(MockResolverReturnsNull); diff --git a/src/Build.UnitTests/BackEnd/TaskExecutionHost_Tests.cs b/src/Build.UnitTests/BackEnd/TaskExecutionHost_Tests.cs index b32bb7f5f73..ad64e95964a 100644 --- a/src/Build.UnitTests/BackEnd/TaskExecutionHost_Tests.cs +++ b/src/Build.UnitTests/BackEnd/TaskExecutionHost_Tests.cs @@ -1506,6 +1506,53 @@ public void TestTaskResolutionFailureWithNoUsingTask() _logger.AssertLogContains("MSB4036"); } + /// + /// In a trimmed / Native AOT host, reflective task loading/execution is disabled + /// (EnableReflectiveTaskExecution is substituted false). FindTask - the leaf that loads a task + /// factory/type by reflection - must fail observably with a reported project error (MSB4283) + /// rather than crashing in reflection, so the host can detect the unsupported path and fall back + /// to a JIT MSBuild. + /// + [Fact] + public void ReflectiveTaskExecutionDisabledFailsObservably() + { + bool switchWasSet = AppContext.TryGetSwitch("Microsoft.Build.EnableReflectiveTaskExecution", out bool originalValue); + try + { + AppContext.SetSwitch("Microsoft.Build.EnableReflectiveTaskExecution", false); + + using var host = new TaskExecutionHost(); + TargetLoggingContext tlc = new TargetLoggingContext(_loggingService, new BuildEventContext(1, 1, BuildEventContext.InvalidProjectContextId, 1)); + + ProjectInstance project = CreateTestProject(); + host.InitializeForTask( + this, + tlc, + project, + "TaskWithNoUsingTask", + ElementLocation.Create("none", 1, 1), + this, + false, + projectFile: "proj.proj", +#if FEATURE_APPDOMAIN + null, +#endif + null, + false, + CancellationToken.None, + TaskEnvironmentHelper.CreateForTest()); + + InvalidProjectFileException exception = Assert.Throws(() => host.FindTask(TaskHostParameters.Empty)); + exception.ErrorCode.ShouldBe("MSB4283"); + } + finally + { + // AppContext switches cannot be returned to "unset"; restore the prior value (the default + // is enabled when unset) so other tests keep reflective task execution on. + AppContext.SetSwitch("Microsoft.Build.EnableReflectiveTaskExecution", !switchWasSet || originalValue); + } + } + /// /// https://github.com/dotnet/msbuild/issues/8864 /// @@ -1693,16 +1740,6 @@ public bool BuildProjectFile(string projectFileName, string[] targetNames, IDict #region Validation Routines - /// - /// Is the class a task factory - /// - private static bool IsTaskFactoryClass(Type type, object unused) - { - return type.IsClass && - !type.IsAbstract && - (type.GetInterface("Microsoft.Build.Framework.ITaskFactory") != null); - } - /// /// Initialize the host object /// @@ -1717,7 +1754,7 @@ private void InitializeHost() // Set up a temporary project and add some items to it. ProjectInstance project = CreateTestProject(); - TypeLoader typeLoader = new TypeLoader(IsTaskFactoryClass); + TypeLoader typeLoader = TypeLoader.Create(); #if !FEATURE_ASSEMBLYLOADCONTEXT AssemblyLoadInfo loadInfo = AssemblyLoadInfo.Create(Assembly.GetAssembly(typeof(TaskBuilderTestTask.TaskBuilderTestTaskFactory)).FullName, null); #else diff --git a/src/Build.UnitTests/Evaluation/Expander_Tests.cs b/src/Build.UnitTests/Evaluation/Expander_Tests.cs index bbbe514e40c..194019c6eac 100644 --- a/src/Build.UnitTests/Evaluation/Expander_Tests.cs +++ b/src/Build.UnitTests/Evaluation/Expander_Tests.cs @@ -2788,11 +2788,11 @@ public void PropertyStaticFunctionLocatedFromAssemblyWithNamespaceName() Expander expander = new Expander(pg, FileSystems.Default); - string env = Environment.GetEnvironmentVariable("MSBUILDENABLEALLPROPERTYFUNCTIONS"); + AppContext.TryGetSwitch("Microsoft.Build.EnableAllPropertyFunctions", out bool originalSwitch); try { - Environment.SetEnvironmentVariable("MSBUILDENABLEALLPROPERTYFUNCTIONS", "1"); + AppContext.SetSwitch("Microsoft.Build.EnableAllPropertyFunctions", true); string result = expander.ExpandIntoStringLeaveEscaped("$([System.Diagnostics.Process]::GetCurrentProcess().Id)", ExpanderOptions.ExpandProperties, MockElementLocation.Instance); @@ -2802,7 +2802,7 @@ public void PropertyStaticFunctionLocatedFromAssemblyWithNamespaceName() } finally { - Environment.SetEnvironmentVariable("MSBUILDENABLEALLPROPERTYFUNCTIONS", env); + AppContext.SetSwitch("Microsoft.Build.EnableAllPropertyFunctions", originalSwitch); AvailableStaticMethods.Reset_ForUnitTestsOnly(); } } diff --git a/src/Build.UnitTests/Evaluation/ToolsetConfigurationNet5_Tests.cs b/src/Build.UnitTests/Evaluation/ToolsetConfigurationNet5_Tests.cs index 6a3ad2f17bd..683384d6f41 100644 --- a/src/Build.UnitTests/Evaluation/ToolsetConfigurationNet5_Tests.cs +++ b/src/Build.UnitTests/Evaluation/ToolsetConfigurationNet5_Tests.cs @@ -5,6 +5,7 @@ /* This test is designed especially to test Configuration parsing in net5.0 * which means it WON'T work in net472 and thus we don't run it in net472 */ +using System; using System.Collections.Generic; using Microsoft.Build.Evaluation; using Microsoft.Build.Execution; @@ -76,6 +77,28 @@ IDictionary toolsetProperties toolsetProperties["MSBuildToolsRoot"].ShouldNotBeNullOrEmpty(); toolsetProperties["MSBuildExtensionsPath"].ShouldNotBeNullOrEmpty(); } + + [Fact] + // When the EnableConfigurationFileToolsets feature switch is disabled (as a trimmed or Native AOT host bakes + // it off so System.Configuration.ConfigurationManager can be trimmed), explicitly requesting + // ToolsetDefinitionLocations.ConfigurationFile must fail observably with an ArgumentException rather than + // silently returning no configuration-file toolsets. + public void ToolsetDefinitionLocationsIsConfigurationThrowsWhenFeatureDisabled() + { + const string SwitchName = "Microsoft.Build.EnableConfigurationFileToolsets"; + bool switchWasSet = AppContext.TryGetSwitch(SwitchName, out bool originalValue); + try + { + AppContext.SetSwitch(SwitchName, false); + + Should.Throw(() => new ProjectCollection(ToolsetDefinitionLocations.ConfigurationFile)); + } + finally + { + // The switch defaults to true when unset, so restore that effective default if it was not set before. + AppContext.SetSwitch(SwitchName, !switchWasSet || originalValue); + } + } } } #endif diff --git a/src/Build/BackEnd/BuildManager/BuildManager.cs b/src/Build/BackEnd/BuildManager/BuildManager.cs index b46cccfe0bc..4c682ca4de9 100644 --- a/src/Build/BackEnd/BuildManager/BuildManager.cs +++ b/src/Build/BackEnd/BuildManager/BuildManager.cs @@ -464,6 +464,7 @@ public DeferredBuildMessage(string text, string code, DeferredBuildMessageSeveri /// The build parameters. May be null. /// Build messages to be logged before the build begins. /// Thrown if a build is already in progress. + [RequiresUnreferencedCode("Initializes loggers and project cache plugins by reflecting over assemblies discovered at runtime, which is incompatible with trimming.")] public void BeginBuild(BuildParameters parameters, IEnumerable deferredBuildMessages) { // TEMP can be modified from the environment. Most of Traits is lasts for the duration of the process (with a manual reset for tests) @@ -494,6 +495,7 @@ private void UpdatePriority(Process p, ProcessPriorityClass priority) /// /// The build parameters. May be null. /// Thrown if a build is already in progress. + [RequiresUnreferencedCode("Initializes loggers and project cache plugins by reflecting over assemblies discovered at runtime, which is incompatible with trimming.")] public void BeginBuild(BuildParameters parameters) { #if NETFRAMEWORK @@ -1010,6 +1012,7 @@ private BuildSubmissionBase PendBuildRequest(TRequestData requestData) where TRequestData : BuildRequestData where TResultData : BuildResultBase @@ -1019,6 +1022,7 @@ private TResultData BuildRequest(TRequestData request /// Convenience method. Submits a build request and blocks until the results are available. /// /// Thrown if StartBuild has not been called or if EndBuild has been called. + [RequiresUnreferencedCode("Initializes loggers and project cache plugins by reflecting over assemblies discovered at runtime, which is incompatible with trimming.")] public BuildResult BuildRequest(BuildRequestData requestData) => BuildRequest(requestData); @@ -1026,6 +1030,7 @@ public BuildResult BuildRequest(BuildRequestData requestData) /// Convenience method. Submits a graph build request and blocks until the results are available. /// /// Thrown if StartBuild has not been called or if EndBuild has been called. + [RequiresUnreferencedCode("Initializes loggers and project cache plugins by reflecting over assemblies discovered at runtime, which is incompatible with trimming.")] public GraphBuildResult BuildRequest(GraphBuildRequestData requestData) => BuildRequest(requestData); @@ -1404,6 +1409,7 @@ private void EmitEndBuildHangDiagnostics(string waitPhase, Stopwatch hangWatch) /// Convenience method. Submits a lone build request and blocks until results are available. /// /// Thrown if a build is already in progress. + [RequiresUnreferencedCode("Initializes loggers and project cache plugins by reflecting over assemblies discovered at runtime, which is incompatible with trimming.")] private TResultData Build(BuildParameters parameters, TRequestData requestData) where TRequestData : BuildRequestData where TResultData : BuildResultBase @@ -1431,6 +1437,7 @@ private TResultData Build(BuildParameters parameters, /// Convenience method. Submits a lone build request and blocks until results are available. /// /// Thrown if a build is already in progress. + [RequiresUnreferencedCode("Initializes loggers and project cache plugins by reflecting over assemblies discovered at runtime, which is incompatible with trimming.")] public BuildResult Build(BuildParameters parameters, BuildRequestData requestData) => Build(parameters, requestData); @@ -1438,6 +1445,7 @@ public BuildResult Build(BuildParameters parameters, BuildRequestData requestDat /// Convenience method. Submits a lone graph build request and blocks until results are available. /// /// Thrown if a build is already in progress. + [RequiresUnreferencedCode("Initializes loggers and project cache plugins by reflecting over assemblies discovered at runtime, which is incompatible with trimming.")] public GraphBuildResult Build(BuildParameters parameters, GraphBuildRequestData requestData) => Build(parameters, requestData); @@ -1469,6 +1477,8 @@ public void Dispose() /// /// The node from which the packet was received. /// The packet. + [UnconditionalSuppressMessage("Trimming", "IL2026:RequiresUnreferencedCode", + Justification = "ProcessPacket is dispatched from the work-queue message pump; the evaluation path it reaches is reflective and unsupported under trimming.")] void INodePacketHandler.PacketReceived(int node, INodePacket packet) { _workQueue!.Post(() => ProcessPacket(node, packet)); @@ -1515,6 +1525,7 @@ TComponent IBuildComponentHost.GetComponent(BuildComponentType type) /// [SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Justification = "Standard ExpectedException pattern used")] [SuppressMessage("Microsoft.Maintainability", "CA1506:AvoidExcessiveClassCoupling", Justification = "Complex class might need refactoring to separate scheduling elements from submission elements.")] + [RequiresUnreferencedCode("Initializes project cache plugins, which load plugin assemblies from disk and reflect over their types; incompatible with trimming.")] private void ExecuteSubmission(BuildSubmission submission, bool allowMainThreadBuild) { ArgumentNullException.ThrowIfNull(submission); @@ -1638,6 +1649,7 @@ private void ExecuteSubmission(BuildSubmission submission, bool allowMainThreadB // Cache requests on configuration N do not block future build submissions depending on configuration N. // It is assumed that the higher level build orchestrator (static graph scheduler, VS, quickbuild) submits a // project build request only when its references have finished building. + [RequiresUnreferencedCode("Loads project cache plugin assemblies from disk and reflects over their types, which is incompatible with trimming.")] private void IssueCacheRequestForBuildSubmission(CacheRequest cacheRequest) { Debug.Assert(Monitor.IsEntered(_syncLock)); @@ -1655,6 +1667,7 @@ private void IssueCacheRequestForBuildSubmission(CacheRequest cacheRequest) }); } + [RequiresUnreferencedCode("Initializes project cache plugins, which load plugin assemblies from disk and reflect over their types; incompatible with trimming.")] internal void ExecuteSubmission( BuildSubmissionBase submission, bool allowMainThreadBuild) where TRequestData : BuildRequestDataBase @@ -1692,6 +1705,7 @@ internal void ExecuteSubmission( /// /// This method adds the graph build request in the specified submission to the set of requests being handled by the scheduler. /// + [RequiresUnreferencedCode("Initializes project cache plugins, which load plugin assemblies from disk and reflect over their types; incompatible with trimming.")] private void ExecuteSubmission(GraphBuildSubmission submission) { VerifyStateInternal(BuildManagerState.Building); @@ -1740,6 +1754,7 @@ private void ExecuteSubmission(GraphBuildSubmission submission) /// /// Creates the traversal and metaproject instances necessary to represent the solution and populates new configurations with them. /// + [RequiresUnreferencedCode("Evaluates a solution's projects, which resolves SDKs and reflects over their types; incompatible with trimming.")] private void LoadSolutionIntoConfiguration(BuildRequestConfiguration config, BuildRequest request) { Debug.Assert(Monitor.IsEntered(_syncLock)); @@ -1873,6 +1888,7 @@ private void ProcessWorkQueue(Action action) /// /// Processes a packet /// + [RequiresUnreferencedCode("Evaluates solution configurations, which resolves SDKs and reflects over their types; incompatible with trimming.")] private void ProcessPacket(int node, INodePacket packet) { lock (_syncLock) @@ -2075,6 +2091,7 @@ private static void AddProxyBuildRequestToSubmission( /// The submission is a top level build request entering the BuildManager. /// Sends the request to the scheduler with optional legacy threading semantics behavior. /// + [RequiresUnreferencedCode("Evaluates solution configurations, which resolves SDKs and reflects over their types; incompatible with trimming.")] private void IssueBuildRequestForBuildSubmission(BuildSubmission submission, BuildRequestConfiguration configuration, bool allowMainThreadBuild = false) { _workQueue!.Post( @@ -2164,6 +2181,7 @@ private bool IsInvalidProjectOrIORelatedException(Exception e) return !ExceptionHandling.IsCriticalException(e) && !ExceptionHandling.NotExpectedException(e) && e is not BuildAbortedException; } + [RequiresUnreferencedCode("Initializes project cache plugins, which load plugin assemblies from disk and reflect over their types; incompatible with trimming.")] private void ExecuteGraphBuildScheduler(GraphBuildSubmission submission) { if (_shuttingDown) @@ -2267,6 +2285,7 @@ static void DumpGraph(ProjectGraph graph, IReadOnlyDictionary BuildGraph( ProjectGraph projectGraph, IReadOnlyDictionary> targetsPerNode, @@ -2546,6 +2565,7 @@ private BuildRequestConfiguration AddNewConfiguration(BuildRequestConfiguration return newConfiguration; } + [RequiresUnreferencedCode("Evaluates solution configurations, which resolves SDKs and reflects over their types; incompatible with trimming.")] internal void PostCacheResult(CacheRequest cacheRequest, CacheResult cacheResult, int projectContextId) { _workQueue!.Post(() => @@ -2610,6 +2630,7 @@ void HandleCacheResult() /// /// Handles a new request coming from a node. /// + [RequiresUnreferencedCode("Evaluates solution configurations, which resolves SDKs and reflects over their types; incompatible with trimming.")] private void HandleNewRequest(int node, BuildRequestBlocker blocker) { // If we received any solution files, populate their configurations now. @@ -3184,6 +3205,7 @@ internal void EnableBuildCheck() /// /// Creates a logging service around the specified set of loggers. /// + [RequiresUnreferencedCode("Creates forwarding loggers by reflecting over logger assemblies discovered at runtime, which is incompatible with trimming.")] private ILoggingService CreateLoggingService( IEnumerable? loggers, IEnumerable? forwardingLoggers, diff --git a/src/Build/BackEnd/BuildManager/BuildSubmission.cs b/src/Build/BackEnd/BuildManager/BuildSubmission.cs index 1ae5d02e03e..6f68be03df9 100644 --- a/src/Build/BackEnd/BuildManager/BuildSubmission.cs +++ b/src/Build/BackEnd/BuildManager/BuildSubmission.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Threading; using Microsoft.Build.BackEnd; @@ -59,8 +60,10 @@ protected internal BuildSubmissionBase(BuildManager buildManager, int submission /// Starts the request and blocks until results are available. /// /// The request has already been started or is already complete. + [RequiresUnreferencedCode("Initializes project cache plugins, which load plugin assemblies from disk and reflect over their types; incompatible with trimming.")] public abstract TResultData Execute(); + [RequiresUnreferencedCode("Initializes project cache plugins, which load plugin assemblies from disk and reflect over their types; incompatible with trimming.")] private protected void ExecuteAsync( BuildSubmissionCompleteCallbackInternal? callback, object? context, @@ -165,6 +168,7 @@ internal BuildSubmission(BuildManager buildManager, int submissionId, BuildReque /// Starts the request asynchronously and immediately returns control to the caller. /// /// The request has already been started or is already complete. + [RequiresUnreferencedCode("Initializes project cache plugins, which load plugin assemblies from disk and reflect over their types; incompatible with trimming.")] public void ExecuteAsync(BuildSubmissionCompleteCallback? callback, object? context) { void Clb(BuildSubmissionBase submission) @@ -179,6 +183,7 @@ void Clb(BuildSubmissionBase submission) /// Starts the request and blocks until results are available. /// /// The request has already been started or is already complete. + [RequiresUnreferencedCode("Initializes project cache plugins, which load plugin assemblies from disk and reflect over their types; incompatible with trimming.")] public override BuildResult Execute() { LegacyThreadingData legacyThreadingData = ((IBuildComponentHost)BuildManager).LegacyThreadingData; diff --git a/src/Build/BackEnd/Components/Logging/ILoggingService.cs b/src/Build/BackEnd/Components/Logging/ILoggingService.cs index 822b29f9a1b..f1c5c747401 100644 --- a/src/Build/BackEnd/Components/Logging/ILoggingService.cs +++ b/src/Build/BackEnd/Components/Logging/ILoggingService.cs @@ -4,6 +4,7 @@ using System; using System.Collections; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using Microsoft.Build.Experimental.BuildCheck.Infrastructure; using Microsoft.Build.Framework; using Microsoft.Build.Framework.Profiler; @@ -328,6 +329,7 @@ MessageImportance MinimumRequiredMessageImportance /// Central logger which is to receive the events created by the forwarding logger /// A description of the forwarding logger /// True if the central and forwarding loggers were registered. False if the central logger or the forwarding logger were already registered + [RequiresUnreferencedCode("Creates forwarding loggers by reflecting over logger assemblies discovered at runtime, which is incompatible with trimming.")] bool RegisterDistributedLogger(ILogger centralLogger, LoggerDescription forwardingLogger); /// @@ -352,6 +354,7 @@ MessageImportance MinimumRequiredMessageImportance /// The id of the node the logging services is on /// When forwardingLoggerSink is null /// When loggerDescriptions is null + [RequiresUnreferencedCode("Creates forwarding loggers by reflecting over logger assemblies discovered at runtime, which is incompatible with trimming.")] void InitializeNodeLoggers(ICollection loggerDescriptions, IBuildEventSink forwardingLoggerSink, int nodeId); #endregion diff --git a/src/Build/BackEnd/Components/Logging/LoggingService.cs b/src/Build/BackEnd/Components/Logging/LoggingService.cs index 3dc970072b9..b81f8ceecfe 100644 --- a/src/Build/BackEnd/Components/Logging/LoggingService.cs +++ b/src/Build/BackEnd/Components/Logging/LoggingService.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Linq; using System.Reflection; @@ -1110,6 +1111,7 @@ public void UnregisterAllLoggers() /// If forwardingLogger is null /// If a logger exception is thrown while creating or initializing the distributed or central logger /// If any exception (other than a loggerException)is thrown while creating or initializing the distributed or central logger, we will wrap these exceptions in an InternalLoggerException + [RequiresUnreferencedCode("Creates forwarding loggers by reflecting over logger assemblies discovered at runtime, which is incompatible with trimming.")] public bool RegisterDistributedLogger(ILogger centralLogger, LoggerDescription forwardingLogger) { lock (_lockObject) @@ -1121,10 +1123,30 @@ public bool RegisterDistributedLogger(ILogger centralLogger, LoggerDescription f centralLogger = new NullCentralLogger(); } + if (!FeatureSwitches.EnableReflectiveLoggerLoading) + { + throw CreateReflectiveLoggerLoadingDisabledException(); + } + return RegisterDistributedLoggerCore(centralLogger, forwardingLogger, forwardingLogger.CreateForwardingLogger()); } } + /// + /// Creates the exception thrown when a logger described by its assembly and class name cannot be + /// created because reflective logger loading is disabled (a trimmed or Native AOT host). Loggers + /// supplied to the engine as already-constructed instances are unaffected and + /// are the supported way to log under trimming/AOT. + /// + private static InternalLoggerException CreateReflectiveLoggerLoadingDisabledException() + { + string message = ResourceUtilities.FormatResourceStringStripCodeAndKeyword( + out string errorCode, + out string helpKeyword, + "ReflectiveLoggerLoadingNotSupported"); + return new InternalLoggerException(message, innerException: null, e: null, errorCode, helpKeyword, initializationException: true); + } + private bool RegisterDistributedLoggerCore(ILogger centralLogger, LoggerDescription forwardingLogger, IForwardingLogger localForwardingLogger) { Assumed.NotEqual(_serviceState, LoggingServiceState.Shutdown, " The object is shutdown, should not do any operations on a shutdown component"); @@ -1187,6 +1209,7 @@ private bool RegisterDistributedLoggerCore(ILogger centralLogger, LoggerDescript /// The id of the node the logging services is on /// When forwardingLoggerSink is null /// When loggerDescriptions is null + [RequiresUnreferencedCode("Creates forwarding loggers by reflecting over logger assemblies discovered at runtime, which is incompatible with trimming.")] public void InitializeNodeLoggers(ICollection descriptions, IBuildEventSink forwardingLoggerSink, int nodeId) { lock (_lockObject) @@ -1217,6 +1240,11 @@ public void InitializeNodeLoggers(ICollection descriptions, I CreateFilterEventSource(); + if (!FeatureSwitches.EnableReflectiveLoggerLoading) + { + throw CreateReflectiveLoggerLoadingDisabledException(); + } + foreach (LoggerDescription description in descriptions) { IForwardingLogger forwardingLogger = description.CreateForwardingLogger(); diff --git a/src/Build/BackEnd/Components/ProjectCache/ProjectCacheService.cs b/src/Build/BackEnd/Components/ProjectCache/ProjectCacheService.cs index f3d820befd2..8e09004f299 100644 --- a/src/Build/BackEnd/Components/ProjectCache/ProjectCacheService.cs +++ b/src/Build/BackEnd/Components/ProjectCache/ProjectCacheService.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; using System.Reflection; @@ -99,6 +100,7 @@ public ProjectCacheService( /// /// Optimization which frontloads plugin initialization since we have an entire graph. /// + [RequiresUnreferencedCode("Loads project cache plugin assemblies from disk and reflects over their types, which is incompatible with trimming.")] public void InitializePluginsForGraph( ProjectGraph projectGraph, ICollection requestedTargets, @@ -125,6 +127,7 @@ public void InitializePluginsForGraph( cancellationToken); } + [RequiresUnreferencedCode("Loads project cache plugin assemblies from disk and reflects over their types, which is incompatible with trimming.")] public void InitializePluginsForVsScenario( IEnumerable projectCacheDescriptors, BuildRequestConfiguration buildRequestConfiguration, @@ -157,6 +160,7 @@ public void InitializePluginsForVsScenario( cancellationToken); } + [RequiresUnreferencedCode("Loads a project cache plugin assembly from disk and reflects over its types, which is incompatible with trimming.")] private Task GetProjectCachePluginAsync( ProjectCacheDescriptor projectCacheDescriptor, ProjectGraph? projectGraph, @@ -192,6 +196,7 @@ private IEnumerable GetProjectCacheDescriptors(ProjectIn } } + [RequiresUnreferencedCode("Loads a project cache plugin assembly from disk and reflects over its types, which is incompatible with trimming.")] private async Task CreateAndInitializePluginAsync( ProjectCacheDescriptor projectCacheDescriptor, ProjectGraph? projectGraph, @@ -397,7 +402,7 @@ private IReadOnlyDictionary GetGlobalProperties(BuildRequestConf return globalProperties; }); - private static IProjectCachePluginBase CreatePluginInstanceFromType(Type pluginType) + private static IProjectCachePluginBase CreatePluginInstanceFromType([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] Type pluginType) { try { @@ -424,6 +429,8 @@ private static IProjectCachePluginBase CreatePluginInstanceFromType(Type pluginT } } + [RequiresUnreferencedCode("Loads a project cache plugin assembly from disk and reflects over its exported types, which is incompatible with trimming.")] + [return: DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] private static Type GetTypeFromAssemblyPath(string pluginAssemblyPath) { Assembly assembly = LoadAssembly(pluginAssemblyPath); @@ -501,6 +508,7 @@ private bool IsDesignTimeBuild(BuildRequestConfiguration buildRequestConfigurati || (buildingProject != null && !ConversionUtilities.ConvertStringToBool(buildingProject, nullOrWhitespaceIsFalse: true)); } + [RequiresUnreferencedCode("Loads project cache plugin assemblies from disk and reflects over their types, which is incompatible with trimming.")] public void PostCacheRequest(CacheRequest cacheRequest, CancellationToken cancellationToken) { EnsureNotDisposed(); @@ -569,6 +577,7 @@ void EvaluateProjectIfNecessary(BuildSubmission submission, BuildRequestConfigur } } + [RequiresUnreferencedCode("Loads project cache plugin assemblies from disk and reflects over their types, which is incompatible with trimming.")] private async ValueTask GetCacheResultAsync(BuildRequestData buildRequest, BuildRequestConfiguration buildRequestConfiguration, BuildEventContext buildEventContext, CancellationToken cancellationToken) { Assumed.NotNull(buildRequest.ProjectInstance); diff --git a/src/Build/BackEnd/Components/RequestBuilder/AssemblyLoadsTracker.cs b/src/Build/BackEnd/Components/RequestBuilder/AssemblyLoadsTracker.cs index bcbce22fcd1..d76fced37ed 100644 --- a/src/Build/BackEnd/Components/RequestBuilder/AssemblyLoadsTracker.cs +++ b/src/Build/BackEnd/Components/RequestBuilder/AssemblyLoadsTracker.cs @@ -5,6 +5,9 @@ #if FEATURE_APPDOMAIN #endif using System.Reflection; +#if NET +using System.Runtime.CompilerServices; +#endif #if FEATURE_ASSEMBLYLOADCONTEXT using System.Runtime.Loader; #endif @@ -81,6 +84,17 @@ private static IDisposable StartTracking( string? initiatorName, AppDomain? appDomain) { +#if NET + // Native AOT has no runtime assembly loading, so AppDomain.AssemblyLoad never fires and there + // is nothing to track. Returning early also lets the trimmer prove AssemblyLoadsTracker is never + // instantiated under Native AOT and remove CurrentDomainOnAssemblyLoad along with its + // Assembly.Location read, so the single-file/AOT build produces no IL3000 for it. + if (!RuntimeFeature.IsDynamicCodeSupported) + { + return EmptyDisposable.Instance; + } +#endif + if (// We do not want to load all assembly loads (including those triggered by builtin types) !Traits.Instance.LogAllAssemblyLoads && ( diff --git a/src/Build/BackEnd/Components/RequestBuilder/IntrinsicTasks/IntrinsicTaskFactory.cs b/src/Build/BackEnd/Components/RequestBuilder/IntrinsicTasks/IntrinsicTaskFactory.cs index b98fb11ff83..bb64cdb9331 100644 --- a/src/Build/BackEnd/Components/RequestBuilder/IntrinsicTasks/IntrinsicTaskFactory.cs +++ b/src/Build/BackEnd/Components/RequestBuilder/IntrinsicTasks/IntrinsicTaskFactory.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Reflection; using Microsoft.Build.Execution; using Microsoft.Build.Framework; @@ -19,7 +20,7 @@ internal class IntrinsicTaskFactory : ITaskFactory /// /// Constructor /// - public IntrinsicTaskFactory(Type intrinsicType) + public IntrinsicTaskFactory([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] Type intrinsicType) { this.TaskType = intrinsicType; } @@ -35,6 +36,7 @@ public string FactoryName /// /// Returns the task type. /// + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] public Type TaskType { get; @@ -44,6 +46,7 @@ public Type TaskType /// /// Initialize the factory. /// + [RequiresUnreferencedCode("Task factories create tasks by reflecting over a task type discovered or generated at runtime, which is incompatible with trimming.")] public bool Initialize(string taskName, IDictionary parameterGroup, string taskBody, IBuildEngine taskFactoryLoggingHost) { Assumed.Equal(taskName, TaskType.Name, StringComparison.OrdinalIgnoreCase, $"Unexpected task name {taskName}. Expected {TaskType.Name}"); @@ -69,7 +72,16 @@ public TaskPropertyInfo[] GetTaskParameters() /// /// Creates an instance of the task. /// - public ITask CreateTask(IBuildEngine taskFactoryLoggingHost) + [RequiresUnreferencedCode("Task factories create tasks by reflecting over a task type discovered or generated at runtime, which is incompatible with trimming.")] + public ITask CreateTask(IBuildEngine taskFactoryLoggingHost) => CreateIntrinsicTask(); + + /// + /// Creates the intrinsic task instance by direct construction (no reflection), so it is safe under + /// trimming/Native AOT. The interface member carries + /// [RequiresUnreferencedCode] for general task factories; this engine-internal + /// entry point does not, letting the registered/AOT task path construct intrinsic tasks directly. + /// + internal ITask CreateIntrinsicTask() { if (TaskType == typeof(MSBuild)) { diff --git a/src/Build/BackEnd/Components/RequestBuilder/TaskBuilder.cs b/src/Build/BackEnd/Components/RequestBuilder/TaskBuilder.cs index 8a6977affe2..5222a68a822 100644 --- a/src/Build/BackEnd/Components/RequestBuilder/TaskBuilder.cs +++ b/src/Build/BackEnd/Components/RequestBuilder/TaskBuilder.cs @@ -3,10 +3,6 @@ using System; using System.Collections.Generic; - -#if FEATURE_APARTMENT_STATE -using System.Diagnostics.CodeAnalysis; -#endif using System.Linq; using System.Reflection; #if FEATURE_APARTMENT_STATE @@ -578,7 +574,7 @@ private TaskHostParameters GatherTaskIdentityParameters(Expander - [SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Justification = "Exception is caught and rethrown in the correct thread.")] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Justification = "Exception is caught and rethrown in the correct thread.")] private WorkUnitResult ExecuteTaskInSTAThread(ItemBucket bucket, TaskLoggingContext taskLoggingContext, TaskHostParameters taskIdentityParameters, TaskHost taskHost, TaskExecutionMode howToExecuteTask) { WorkUnitResult taskResult = new WorkUnitResult(WorkUnitResultCode.Failed, WorkUnitActionCode.Stop, null); diff --git a/src/Build/BackEnd/Components/SdkResolution/CachingSdkResolverLoader.cs b/src/Build/BackEnd/Components/SdkResolution/CachingSdkResolverLoader.cs index 0600e1ac769..4b0c0fd41ad 100644 --- a/src/Build/BackEnd/Components/SdkResolution/CachingSdkResolverLoader.cs +++ b/src/Build/BackEnd/Components/SdkResolution/CachingSdkResolverLoader.cs @@ -3,6 +3,7 @@ using System.Collections.Concurrent; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using Microsoft.Build.Construction; using Microsoft.Build.Framework; @@ -72,6 +73,7 @@ internal static void ResetStateForTests() internal override IReadOnlyList GetDefaultResolvers() => _defaultResolvers; /// + [RequiresUnreferencedCode("Loads SDK resolver assemblies from disk and reflects over their types, which is incompatible with trimming.")] internal override IReadOnlyList LoadAllResolvers(ElementLocation location) { lock (_lock) @@ -90,6 +92,7 @@ internal override IReadOnlyList GetResolversManifests(Eleme } /// + [RequiresUnreferencedCode("Loads SDK resolver assemblies from disk and reflects over their types, which is incompatible with trimming.")] protected internal override IReadOnlyList LoadResolversFromManifest(SdkResolverManifest manifest, ElementLocation location) { return _resolversByManifest.GetOrAdd(manifest, (manifest) => base.LoadResolversFromManifest(manifest, location)); diff --git a/src/Build/BackEnd/Components/SdkResolution/SdkResolverLoader.cs b/src/Build/BackEnd/Components/SdkResolution/SdkResolverLoader.cs index 8cb891c88aa..d552ada6c49 100644 --- a/src/Build/BackEnd/Components/SdkResolution/SdkResolverLoader.cs +++ b/src/Build/BackEnd/Components/SdkResolution/SdkResolverLoader.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; using System.Reflection; @@ -39,9 +40,22 @@ internal virtual IReadOnlyList GetDefaultResolvers() var resolvers = !string.Equals(IncludeDefaultResolver, "false", StringComparison.OrdinalIgnoreCase) ? new List { new DefaultSdkResolver() } : new List(); + + // Fold in any resolvers the host registered in-process via SdkResolver.Register. These run on + // this reflection-free path (no Assembly.LoadFrom), so they work in a trimmed / Native AOT host + // and never reach the dynamic-loading failure (MSB4282). They are ordered with the built-in + // resolver by Priority, matching how disk-discovered resolvers are ordered. + IReadOnlyList registeredResolvers = SdkResolver.RegisteredResolvers; + if (registeredResolvers.Count > 0) + { + resolvers.AddRange(registeredResolvers); + resolvers.Sort((left, right) => left.Priority.CompareTo(right.Priority)); + } + return resolvers; } + [RequiresUnreferencedCode("Loads SDK resolver assemblies from disk and reflects over their types, which is incompatible with trimming.")] internal virtual IReadOnlyList LoadAllResolvers(ElementLocation location) { MSBuildEventSource.Log.SdkResolverLoadAllResolversStart(); @@ -220,12 +234,14 @@ private bool TryAddAssemblyManifestFromDll(string assemblyPath, List GetResolverTypes(Assembly assembly) { return assembly.ExportedTypes .Where(t => t.IsClass && t.IsPublic && !t.IsAbstract && typeof(SdkResolver).IsAssignableFrom(t)); } + [RequiresUnreferencedCode("Loads an SDK resolver assembly from disk, which is incompatible with trimming.")] protected virtual Assembly LoadResolverAssembly(string resolverPath) { #if !FEATURE_ASSEMBLYLOADCONTEXT @@ -276,6 +292,7 @@ private AssemblyName CreateAssemblyNameWithCodeBase(string assemblyName, string } #endif + [RequiresUnreferencedCode("Loads SDK resolver assemblies from disk and reflects over their types, which is incompatible with trimming.")] protected internal virtual IReadOnlyList LoadResolversFromManifest(SdkResolverManifest manifest, ElementLocation location) { MSBuildEventSource.Log.SdkResolverLoadResolversStart(); @@ -291,6 +308,7 @@ protected internal virtual IReadOnlyList LoadResolversFromManifest( return resolvers; } + [RequiresUnreferencedCode("Loads an SDK resolver assembly from disk and instantiates its resolver types, which is incompatible with trimming.")] protected virtual void LoadResolvers(string resolverPath, ElementLocation location, List resolvers) { Assembly assembly; diff --git a/src/Build/BackEnd/Components/SdkResolution/SdkResolverService.cs b/src/Build/BackEnd/Components/SdkResolution/SdkResolverService.cs index 0f488567f65..0a1a535954a 100644 --- a/src/Build/BackEnd/Components/SdkResolution/SdkResolverService.cs +++ b/src/Build/BackEnd/Components/SdkResolution/SdkResolverService.cs @@ -201,7 +201,7 @@ private SdkResult ResolveSdkUsingResolversWithPatternsFirst(int submissionId, Sd if (matchingResolversManifests.Count != 0) { // First pass. - resolvers = GetResolvers(matchingResolversManifests, loggingContext, sdkReferenceLocation); + resolvers = GetResolvers(matchingResolversManifests, loggingContext, sdkReferenceLocation, sdk); if (TryResolveSdkUsingSpecifiedResolvers( resolvers, @@ -228,7 +228,8 @@ private SdkResult ResolveSdkUsingResolversWithPatternsFirst(int submissionId, Sd resolvers = GetResolvers( _generalResolversManifestsRegistry, loggingContext, - sdkReferenceLocation); + sdkReferenceLocation, + sdk); if (TryResolveSdkUsingSpecifiedResolvers( resolvers, @@ -270,7 +271,7 @@ private SdkResult ResolveSdkUsingResolversWithPatternsFirst(int submissionId, Sd return new SdkResult(sdk, null, null); } - private List GetResolvers(IReadOnlyList resolversManifests, LoggingContext loggingContext, ElementLocation sdkReferenceLocation) + private List GetResolvers(IReadOnlyList resolversManifests, LoggingContext loggingContext, ElementLocation sdkReferenceLocation, SdkReference sdk) { // Create a sorted by priority list of resolvers. Load them if needed. List resolvers = new List(); @@ -281,8 +282,25 @@ private List GetResolvers(IReadOnlyList resolv { if (!_manifestToResolvers.TryGetValue(resolverManifest, out newResolvers)) { - // Loading of the needed resolvers. - newResolvers = _sdkResolverLoader.LoadResolversFromManifest(resolverManifest, sdkReferenceLocation); + if (Framework.FeatureSwitches.EnableSdkResolverDynamicLoading) + { + // Loading of the needed resolvers. + newResolvers = _sdkResolverLoader.LoadResolversFromManifest(resolverManifest, sdkReferenceLocation); + } + else + { + // Trimmed / Native AOT host: we cannot load a plugin SDK resolver by reflection. + // The reflection-free DefaultSdkResolver has already been tried, so an SDK that + // reaches here genuinely needs a dynamically loaded resolver. Fail observably with a + // reported project error (so a host such as the AOT dotnet CLI can detect it and fall + // back to a JIT MSBuild) rather than attempting an Assembly.LoadFrom that cannot work here. + ProjectFileErrorUtilities.ThrowInvalidProjectFile( + new BuildEventFileInfo(sdkReferenceLocation), + "SdkResolverDynamicLoadingNotSupported", + sdk.Name, + resolverManifest.DisplayName); + } + _manifestToResolvers[resolverManifest] = newResolvers; } } diff --git a/src/Build/BackEnd/Node/OutOfProcNode.cs b/src/Build/BackEnd/Node/OutOfProcNode.cs index c2c7e036869..30bd35c548f 100644 --- a/src/Build/BackEnd/Node/OutOfProcNode.cs +++ b/src/Build/BackEnd/Node/OutOfProcNode.cs @@ -6,6 +6,7 @@ using System.Collections.Frozen; using System.Collections.Generic; using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.IO; using System.Threading; @@ -627,6 +628,8 @@ private void SendPacket(INodePacket packet) /// /// Dispatches the packet to the correct handler. /// + [UnconditionalSuppressMessage("Trimming", "IL2026:RequiresUnreferencedCode", + Justification = "The build-request arms now reach task execution through the EnableReflectiveTaskExecution leaf gate, which fails observably under trimming. The remaining RequiresUnreferencedCode reached here is HandleNodeConfiguration, which initializes node forwarding loggers by reflection - a separate subsystem this task-execution gate does not cover. This message-pump switch cannot carry RequiresUnreferencedCode.")] private void HandlePacket(INodePacket packet) { // Console.WriteLine("Handling packet {0} at {1}", packet.Type, DateTime.Now); @@ -706,6 +709,7 @@ private void HandleResourceResponse(ResourceResponse response) /// /// Handles the NodeConfiguration packet. /// + [RequiresUnreferencedCode("Initializes node loggers by reflecting over logger assemblies discovered at runtime, which is incompatible with trimming.")] private void HandleNodeConfiguration(NodeConfiguration configuration) { // Grab the system parameters. diff --git a/src/Build/BackEnd/TaskExecutionHost/TaskExecutionHost.cs b/src/Build/BackEnd/TaskExecutionHost/TaskExecutionHost.cs index 351541ea865..6945869a895 100644 --- a/src/Build/BackEnd/TaskExecutionHost/TaskExecutionHost.cs +++ b/src/Build/BackEnd/TaskExecutionHost/TaskExecutionHost.cs @@ -6,10 +6,16 @@ using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; using System.IO; using System.Linq; using System.Linq.Expressions; using System.Reflection; +#if NET +using System.Runtime.CompilerServices; +#endif #if FEATURE_APPDOMAIN using System.Runtime.Remoting; #endif @@ -137,6 +143,13 @@ internal class TaskExecutionHost : IDisposable /// private TaskFactoryWrapper _taskFactoryWrapper; + /// + /// When the task was resolved from (a host-registered task), the + /// factory that constructs it with no assembly loading or reflection. Non-null only for registered + /// tasks, which run even when reflective task execution is disabled (trimmed/AOT host). + /// + private RegisteredTaskFactory _registeredTaskFactory; + /// /// Set to true if the execution has been cancelled. /// @@ -301,7 +314,45 @@ public void InitializeForTask( /// The task requirements and task factory wrapper if the task is found, (null, null) otherwise. public (TaskRequirements? requirements, TaskFactoryWrapper taskFactoryWrapper) FindTask(in TaskHostParameters taskIdentityParameters) { - _taskFactoryWrapper ??= FindTaskInRegistry(taskIdentityParameters); + if (_taskFactoryWrapper is null) + { + // A fresh task resolution: clear any registered-task factory left over from a previous task on + // this reused host, so an unregistered (e.g. intrinsic) task is not mistaken for the prior + // registered one when constructing its instance. + _registeredTaskFactory = null; + + // A host-registered task (TaskClassRegistry) resolves with no assembly loading or by-name + // type resolution, so it runs even when reflective task execution is disabled - the path a + // trimmed/AOT host takes. Consult the registry first. + if (TryCreateRegisteredTaskFactory(out TaskFactoryWrapper registeredTaskFactoryWrapper)) + { + _taskFactoryWrapper = registeredTaskFactoryWrapper; + } + else if (!FeatureSwitches.EnableReflectiveTaskExecution) + { + // The intrinsic MSBuild and CallTarget tasks are engine-internal types resolved without + // reflecting over a runtime-discovered assembly, so they stay available when reflective task + // execution is disabled (the trimmed/AOT path) - virtually every real build uses them. + if (TryCreateIntrinsicTaskFactory(out TaskFactoryWrapper intrinsicTaskFactoryWrapper)) + { + _taskFactoryWrapper = intrinsicTaskFactoryWrapper; + } + else + { + // Loading a task factory/type reflects over an assembly discovered at run time, which a + // trimmed/AOT host cannot do. Fail observably with a reported build error (rather than + // crashing in reflection) so the host can fall back to a JIT MSBuild. This is the leaf gate + // that frees the whole build-execution chain above from carrying [RequiresUnreferencedCode], + // and it lets the trimmer remove the reflective task-loading path from the image. + ProjectErrorUtilities.ThrowInvalidProject(_taskLocation, "ReflectiveTaskExecutionNotSupported", _taskName); + return (null, null); + } + } + else + { + _taskFactoryWrapper = FindTaskInRegistry(taskIdentityParameters); + } + } if (_taskFactoryWrapper is null) { @@ -310,6 +361,13 @@ public void InitializeForTask( TaskRequirements requirements = TaskRequirements.None; + // HasSTAThreadAttribute / HasLoadInSeparateAppDomainAttribute come from custom attributes + // ([RunInSTA] / [LoadInSeparateAppDomain]) on the task type. A registered task's LoadedType is + // rooted for trimming with PublicParameterlessConstructor | PublicProperties only, so under Native + // AOT those attributes are not preserved and read as false - a registered task declaring them would + // not get STA / separate-AppDomain treatment. That is acceptable for the in-process registered-task + // path (separate AppDomains do not exist on .NET Core regardless); the reflective JIT path, which + // loads the full type, observes the attributes exactly as before. if (_taskFactoryWrapper.TaskFactoryLoadedType.HasSTAThreadAttribute) { requirements |= TaskRequirements.RequireSTAThread; @@ -327,6 +385,70 @@ public void InitializeForTask( return (requirements, _taskFactoryWrapper); } + /// + /// Attempts to resolve the current task from the host task registry (). + /// A registered task is constructed with no assembly loading or by-name type resolution, so it can run + /// even in a trimmed/AOT host where reflective task execution is disabled. + /// + /// The wrapper for the registered task, or if the task is not registered. + /// if the task was found in the registry. + private bool TryCreateRegisteredTaskFactory(out TaskFactoryWrapper taskFactoryWrapper) + { + if (TaskClassRegistry.TryGetRegistration(_taskName, out TaskClassRegistration registration)) + { + LoadedType loadedType = registration.GetLoadedType(); + _registeredTaskFactory = new RegisteredTaskFactory(registration, loadedType); + taskFactoryWrapper = new TaskFactoryWrapper(_registeredTaskFactory, loadedType, _taskName, TaskHostParameters.Empty); + return true; + } + + taskFactoryWrapper = null; + return false; + } + + /// + /// Attempts to resolve the current task as an intrinsic engine task (MSBuild or CallTarget). + /// These map to engine-internal types via with no reflection over a + /// runtime-discovered assembly, so they remain usable when reflective task execution is disabled (the + /// trimmed/Native AOT path). The reflective path resolves them by type in . + /// + /// The wrapper for the intrinsic task, or if the task is not intrinsic. + /// if the task is an intrinsic engine task. + private bool TryCreateIntrinsicTaskFactory(out TaskFactoryWrapper taskFactoryWrapper) + { + if (string.Equals(_taskName, "MSBuild", StringComparison.OrdinalIgnoreCase)) + { + taskFactoryWrapper = CreateIntrinsicTaskFactoryWrapper(typeof(MSBuild)); + return true; + } + + if (string.Equals(_taskName, "CallTarget", StringComparison.OrdinalIgnoreCase)) + { + taskFactoryWrapper = CreateIntrinsicTaskFactoryWrapper(typeof(CallTarget)); + return true; + } + + taskFactoryWrapper = null; + return false; + } + + /// + /// Builds a for an intrinsic engine task (MSBuild or + /// CallTarget) by direct type reference - no assembly probing or by-name resolution. Shared by + /// the reflective resolution path () and the reflection-free path + /// () so the two constructions cannot drift. + /// + private TaskFactoryWrapper CreateIntrinsicTaskFactoryWrapper( + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor | DynamicallyAccessedMemberTypes.PublicProperties)] Type intrinsicTaskType) + { + Assembly taskExecutionHostAssembly = typeof(TaskExecutionHost).Assembly; + return new TaskFactoryWrapper( + new IntrinsicTaskFactory(intrinsicTaskType), + new LoadedType(intrinsicTaskType, AssemblyLoadInfo.Create(taskExecutionHostAssembly.FullName, null), taskExecutionHostAssembly, typeof(ITaskItem)), + _taskName, + TaskHostParameters.Empty); + } + /// /// Initialize to run a specific batch of the current task. /// @@ -348,7 +470,9 @@ public bool InitializeForBatch(TaskLoggingContext loggingContext, ItemBucket bat // here. Instead, NDP will try to Load (not LoadFrom!) the task assembly into our AppDomain, and since // we originally used LoadFrom, it will fail miserably not knowing where to find it. // We need to temporarily subscribe to the AppDomain.AssemblyResolve event to fix it. - if (_resolver == null) + // A registered task's assembly is already loaded (the host referenced it statically), so it + // needs no assembly-resolve handler. + if (_registeredTaskFactory is null && _resolver == null) { _resolver = new TaskEngineAssemblyResolver(); _resolver.Initialize(_taskFactoryWrapper.TaskFactoryLoadedType.Assembly.AssemblyFile); @@ -356,40 +480,62 @@ public bool InitializeForBatch(TaskLoggingContext loggingContext, ItemBucket bat } #endif - // We instantiate a new task object for each batch - TaskInstance = InstantiateTask(scheduledNodeId, taskIdentityParameters); + // We instantiate a new task object for each batch. + if (_registeredTaskFactory is not null) + { + // Reflection-free construction via the host-supplied factory. This deliberately avoids the + // [RequiresUnreferencedCode] ITaskFactory.CreateTask interface member, so the registered-task + // path carries no trim warning and runs under Native AOT. + TaskInstance = _registeredTaskFactory.CreateRegisteredTask(); + } + else if (!FeatureSwitches.EnableReflectiveTaskExecution && _taskFactoryWrapper.TaskFactory is IntrinsicTaskFactory intrinsicTaskFactory) + { + // Reflective-OFF (trimmed/AOT) path only: construct the intrinsic MSBuild/CallTarget task by + // direct `new` (no reflection), because the reflective InstantiateTask below is gated off and + // dead-stripped under trimming. Under the JIT default (switch on) intrinsic tasks deliberately + // fall through to InstantiateTask exactly as before, so they keep their TaskFactoryEngineContext + // lifecycle and ProjectTelemetry accounting - this branch must not alter the JIT path. + TaskInstance = intrinsicTaskFactory.CreateIntrinsicTask(); + } + else if (!FeatureSwitches.EnableReflectiveTaskExecution) + { + // See FindTask: instantiating an unregistered task reflects over a runtime-discovered type, so + // a trimmed/AOT host fails observably here. Normally unreachable - FindTask already failed - + // but it keeps the reflective InstantiateTask below behind the feature guard. + ProjectErrorUtilities.ThrowInvalidProject(_taskLocation, "ReflectiveTaskExecutionNotSupported", _taskName); + return false; + } + else + { + TaskInstance = InstantiateTask(scheduledNodeId, taskIdentityParameters); + } if (TaskInstance == null) { return false; } - string realTaskAssemblyLocation = TaskInstance.GetType().Assembly.Location; - - // When MSBuild loads a task assembly, it uses Assembly.LoadFrom() with a specific path, - // but .NET then loads based on the assembly identity with that path only as a hint. - // This can result in the assembly being loaded from a different location than expected: - // - // 1. Assembly loading from the Global Assembly Cache (GAC) if that assembly version is GACed - // 2. Assembly loading from elsewhere if someone already loaded an assembly with the same - // identity from a different path - // - // Both scenarios can result in confusing task behavior because you're not loading the - // assembly you intended. MSBuild tells .NET Framework to load a specific assembly, - // but .NET Framework may load something else entirely. - // - // Common example: A NuGet package task that doesn't change its assembly version between - // package versions. When you update the package and build while worker nodes are still - // alive, the task stays loaded from the old version instead of the new one. - // - // This validation helps identify these scenarios by checking if the loaded assembly - // location matches what we expected, and logging a message when there's a mismatch - // to help diagnose confusing task behavior issues. - if (!string.IsNullOrWhiteSpace(realTaskAssemblyLocation) && realTaskAssemblyLocation != _taskFactoryWrapper.TaskFactoryLoadedType.Path) - { - if (!IsTaskAssemblyMatchFactoryType()) + // The task-assembly location-mismatch diagnostic reads Assembly.Location, which is empty (and + // meaningless) in a single-file/Native AOT host - and a registered task is already the loaded + // type. On .NET, guard the read on dynamic-code support so ILC dead-strips it (and its IL3000) + // under Native AOT while the JIT keeps the diagnostic; .NET Framework (no AOT) always runs it. +#if NET + if (RuntimeFeature.IsDynamicCodeSupported) +#endif + { + // When MSBuild loads a task assembly, it uses Assembly.LoadFrom() with a specific path, but + // .NET then loads based on the assembly identity with that path only as a hint. This can + // result in the assembly being loaded from a different location than expected (for example + // from the GAC, or because something already loaded the same identity from another path), + // which can cause confusing task behavior. This validation logs a message when the loaded + // assembly location does not match the path we resolved the task from. + string realTaskAssemblyLocation = TaskInstance.GetType().Assembly.Location; + if (!string.IsNullOrWhiteSpace(realTaskAssemblyLocation) && realTaskAssemblyLocation != _taskFactoryWrapper.TaskFactoryLoadedType.Path) { - _taskLoggingContext.LogComment(MessageImportance.Normal, "TaskAssemblyLocationMismatch", realTaskAssemblyLocation, _taskFactoryWrapper.TaskFactoryLoadedType.Path); + if (!IsTaskAssemblyMatchFactoryType()) + { + _taskLoggingContext.LogComment(MessageImportance.Normal, "TaskAssemblyLocationMismatch", realTaskAssemblyLocation, _taskFactoryWrapper.TaskFactoryLoadedType.Path); + } } } @@ -415,6 +561,16 @@ bool IsTaskAssemblyMatchFactoryType() => TaskInstance is not TaskHostTask tht /// True if the parameters were set correctly, false otherwise. public bool SetTaskParameters(IDictionary parameters) { + if (_registeredTaskFactory is null && _taskFactoryWrapper.TaskFactory is not IntrinsicTaskFactory && !FeatureSwitches.EnableReflectiveTaskExecution) + { + // Binding task parameters reflects over the task type. A registered task's type is trim-rooted + // (so binding stays trim-safe) and is exempt, as is an intrinsic MSBuild/CallTarget task (an + // engine-internal type); for any other task in a trimmed/AOT host this fails observably. See + // FindTask: normally unreachable (FindTask fails first). + ProjectErrorUtilities.ThrowInvalidProject(_taskLocation, "ReflectiveTaskExecutionNotSupported", _taskName); + return false; + } + ArgumentNullException.ThrowIfNull(parameters); bool taskInitialized = true; @@ -619,6 +775,9 @@ public void CleanupForTask() _taskFactoryWrapper = null; + // Clear the registered-task factory too, so it cannot leak into the next task that reuses this host. + _registeredTaskFactory = null; + // We must null this out because it could be a COM object (or any other ref-counted object) which needs to // be released. _taskHost = null; @@ -1030,6 +1189,7 @@ private string[] GetValueOutputs(TaskPropertyInfo parameter) /// If the set of task identity parameters are defined, only tasks that match that identity are chosen. /// /// The Type of the task, or null if it was not found. + [RequiresUnreferencedCode("Creates and loads a task factory by reflecting over an assembly discovered at runtime, which is incompatible with trimming.")] private TaskFactoryWrapper FindTaskInRegistry(in TaskHostParameters taskIdentityParameters) { if (!_intrinsicTasks.TryGetValue(_taskName, out TaskFactoryWrapper returnClass)) @@ -1076,14 +1236,12 @@ private TaskFactoryWrapper FindTaskInRegistry(in TaskHostParameters taskIdentity // Map to an intrinsic task, if necessary. if (String.Equals(returnClass.TaskFactory.TaskType.FullName, "Microsoft.Build.Tasks.MSBuild", StringComparison.OrdinalIgnoreCase)) { - Assembly taskExecutionHostAssembly = typeof(TaskExecutionHost).Assembly; - returnClass = new TaskFactoryWrapper(new IntrinsicTaskFactory(typeof(MSBuild)), new LoadedType(typeof(MSBuild), AssemblyLoadInfo.Create(taskExecutionHostAssembly.FullName, null), taskExecutionHostAssembly, typeof(ITaskItem)), _taskName, TaskHostParameters.Empty); + returnClass = CreateIntrinsicTaskFactoryWrapper(typeof(MSBuild)); _intrinsicTasks[_taskName] = returnClass; } else if (String.Equals(returnClass.TaskFactory.TaskType.FullName, "Microsoft.Build.Tasks.CallTarget", StringComparison.OrdinalIgnoreCase)) { - Assembly taskExecutionHostAssembly = typeof(TaskExecutionHost).Assembly; - returnClass = new TaskFactoryWrapper(new IntrinsicTaskFactory(typeof(CallTarget)), new LoadedType(typeof(CallTarget), AssemblyLoadInfo.Create(taskExecutionHostAssembly.FullName, null), taskExecutionHostAssembly, typeof(ITaskItem)), _taskName, TaskHostParameters.Empty); + returnClass = CreateIntrinsicTaskFactoryWrapper(typeof(CallTarget)); _intrinsicTasks[_taskName] = returnClass; } } @@ -1094,6 +1252,7 @@ private TaskFactoryWrapper FindTaskInRegistry(in TaskHostParameters taskIdentity /// /// Instantiates the task. /// + [RequiresUnreferencedCode("Instantiates a task by reflecting over a task type discovered at runtime, which is incompatible with trimming.")] private ITask InstantiateTask(int scheduledNodeId, in TaskHostParameters taskIdentityParameters) { ITask task = null; @@ -1245,16 +1404,14 @@ private bool SetTaskParameter( if (indexOfParameter != -1) { parameter = loadedType.Properties[indexOfParameter]; - parameterType = Type.GetType( - loadedType.PropertyAssemblyQualifiedNames?[indexOfParameter] ?? - parameter.PropertyType.AssemblyQualifiedName); + parameterType = ResolveTaskParameterType(loadedType, parameter, indexOfParameter); } else { parameter = _taskFactoryWrapper.GetProperty(parameterName); if (parameter != null) { - parameterType = Type.GetType(parameter.PropertyType.AssemblyQualifiedName); + parameterType = ResolveTaskParameterType(loadedType, parameter, indexOfParameter: -1); } } @@ -1335,6 +1492,45 @@ private bool SetTaskParameter( return success; } + /// + /// Resolves the .NET of a task parameter for binding. + /// + /// + /// For an in-proc task the property's is already the live, + /// usable type, so no reflection is required - this is the path a registered task and every in-proc + /// task take. Only a type loaded via MetadataLoadContext (the out-of-proc task host) is + /// reflection-only and must be re-resolved by assembly-qualified name; that path is reflective and is + /// gated behind , so a trimmed/AOT image + /// (which never loads task types via MetadataLoadContext) drops it. + /// + private static Type ResolveTaskParameterType(LoadedType loadedType, TaskPropertyInfo parameter, int indexOfParameter) + { + if (!loadedType.LoadedViaMetadataLoadContext) + { + return parameter.PropertyType; + } + + if (FeatureSwitches.EnableReflectiveTaskExecution) + { + return ResolveTaskParameterTypeByName(loadedType, parameter, indexOfParameter); + } + + return null; + } + + /// + /// Re-resolves a MetadataLoadContext-loaded parameter type into the live runtime by its + /// assembly-qualified name. + /// + [RequiresUnreferencedCode("Resolves the task parameter type from its assembly-qualified name by reflection, which is incompatible with trimming.")] + private static Type ResolveTaskParameterTypeByName(LoadedType loadedType, TaskPropertyInfo parameter, int indexOfParameter) + { + string assemblyQualifiedName = + (indexOfParameter != -1 ? loadedType.PropertyAssemblyQualifiedNames?[indexOfParameter] : null) + ?? parameter.PropertyType.AssemblyQualifiedName; + return Type.GetType(assemblyQualifiedName); + } + /// /// Given an instantiated task, this helper method sets the specified scalar parameter based on its type. /// @@ -1898,6 +2094,7 @@ private void DisplayCancelWaitMessage() /// The out-of-process task factory instance. /// Node for which the task host should be called /// A TaskHostTask that will execute the inner task out of process, or null if task creation fails. + [RequiresUnreferencedCode("Instantiates a task by reflecting over a task type discovered at runtime, which is incompatible with trimming.")] private ITask CreateTaskHostTaskForOutOfProcFactory( in TaskHostParameters taskIdentityParameters, TaskFactoryEngineContext taskFactoryEngineContext, diff --git a/src/Build/BuildCheck/Acquisition/BuildCheckAcquisitionModule.cs b/src/Build/BuildCheck/Acquisition/BuildCheckAcquisitionModule.cs index 286e1bf9073..9fa3ac22a35 100644 --- a/src/Build/BuildCheck/Acquisition/BuildCheckAcquisitionModule.cs +++ b/src/Build/BuildCheck/Acquisition/BuildCheckAcquisitionModule.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Reflection; using Microsoft.Build.Experimental.BuildCheck.Infrastructure; @@ -25,6 +26,7 @@ internal class BuildCheckAcquisitionModule : IBuildCheckAcquisitionModule /// /// Creates a list of factory delegates for building check rules instances from a given assembly path. /// + [RequiresUnreferencedCode("Loads custom build check assemblies from disk and reflects over their types, which is incompatible with trimming.")] public List CreateCheckFactories( CheckAcquisitionData checkAcquisitionData, ICheckContext checkContext) diff --git a/src/Build/BuildCheck/Acquisition/IBuildCheckAcquisitionModule.cs b/src/Build/BuildCheck/Acquisition/IBuildCheckAcquisitionModule.cs index 4715209c972..59f7b96d7d3 100644 --- a/src/Build/BuildCheck/Acquisition/IBuildCheckAcquisitionModule.cs +++ b/src/Build/BuildCheck/Acquisition/IBuildCheckAcquisitionModule.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using Microsoft.Build.Experimental.BuildCheck.Infrastructure; namespace Microsoft.Build.Experimental.BuildCheck.Acquisition; @@ -11,5 +12,6 @@ internal interface IBuildCheckAcquisitionModule /// /// Creates a list of factory delegates for building check rules instances from a given assembly path. /// + [RequiresUnreferencedCode("Loads custom build check assemblies from disk and reflects over their types, which is incompatible with trimming.")] List CreateCheckFactories(CheckAcquisitionData checkAcquisitionData, ICheckContext checkContext); } diff --git a/src/Build/BuildCheck/Infrastructure/BuildCheckBuildEventHandler.cs b/src/Build/BuildCheck/Infrastructure/BuildCheckBuildEventHandler.cs index bd1d71a89f5..ebd621a0948 100644 --- a/src/Build/BuildCheck/Infrastructure/BuildCheckBuildEventHandler.cs +++ b/src/Build/BuildCheck/Infrastructure/BuildCheckBuildEventHandler.cs @@ -131,9 +131,38 @@ private void HandleTaskParameterEvent(TaskParameterEventArgs eventArgs) eventArgs); private void HandleBuildCheckAcquisitionEvent(BuildCheckAcquisitionEventArgs eventArgs) - => _buildCheckManager.ProcessCheckAcquisition( - eventArgs.ToCheckAcquisitionData(), - _checkContextFactory.CreateCheckContext(GetBuildEventContext(eventArgs))); + { + CheckAcquisitionData acquisitionData = eventArgs.ToCheckAcquisitionData(); + ICheckContext checkContext = _checkContextFactory.CreateCheckContext(GetBuildEventContext(eventArgs)); + + // Acquiring a custom check loads its assembly from disk and reflects over its types, which a + // trimmed or Native AOT host cannot do. EnableCustomPluginProbing is a [FeatureGuard], so the + // analyzer treats the reflective ProcessCheckAcquisition call below as unreachable when the + // switch is off. Built-in checks are unaffected - they need no reflection. + if (FeatureSwitches.EnableCustomPluginProbing) + { + _buildCheckManager.ProcessCheckAcquisition(acquisitionData, checkContext); + } + else + { + // The project explicitly requested this custom check. Per the trim/AOT design criteria + // (documentation/aot/managing-trimming-and-aot.md), do NOT silently drop it: fail the + // build with an error so a host such as the AOT dotnet CLI can detect the failure and fall + // back to a JIT-based MSBuild that can load the check. This branch performs no reflection. + string message = ResourceUtilities.FormatResourceStringStripCodeAndKeyword( + out string? errorCode, + out string? helpKeyword, + "BuildCheckCustomCheckNotSupportedInTrimmedHost", + acquisitionData.AssemblyPath); + + checkContext.DispatchAsErrorFromText( + null, + errorCode, + helpKeyword, + string.IsNullOrEmpty(acquisitionData.ProjectPath) ? BuildEventFileInfo.Empty : new BuildEventFileInfo(acquisitionData.ProjectPath), + message); + } + } private void HandleEnvironmentVariableReadEvent(EnvironmentVariableReadEventArgs eventArgs) => _buildCheckManager.ProcessEnvironmentVariableReadEventArgs( diff --git a/src/Build/BuildCheck/Infrastructure/BuildCheckManagerProvider.cs b/src/Build/BuildCheck/Infrastructure/BuildCheckManagerProvider.cs index d49a734b0cd..94b1dcb5e70 100644 --- a/src/Build/BuildCheck/Infrastructure/BuildCheckManagerProvider.cs +++ b/src/Build/BuildCheck/Infrastructure/BuildCheckManagerProvider.cs @@ -5,6 +5,7 @@ using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.Linq; using Microsoft.Build.BackEnd; using Microsoft.Build.BackEnd.Logging; @@ -98,6 +99,7 @@ public void SetDataSource(BuildCheckDataSource buildCheckDataSource) _tracingReporter.AddSetDataSourceStats(stopwatch.Elapsed); } + [RequiresUnreferencedCode("Loads custom build check assemblies from disk and reflects over their types, which is incompatible with trimming.")] public void ProcessCheckAcquisition( CheckAcquisitionData acquisitionData, ICheckContext checkContext) diff --git a/src/Build/BuildCheck/Infrastructure/IBuildCheckManager.cs b/src/Build/BuildCheck/Infrastructure/IBuildCheckManager.cs index 82ddf65489e..53fbcb43ebf 100644 --- a/src/Build/BuildCheck/Infrastructure/IBuildCheckManager.cs +++ b/src/Build/BuildCheck/Infrastructure/IBuildCheckManager.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Diagnostics.CodeAnalysis; using Microsoft.Build.BackEnd.Logging; using Microsoft.Build.Experimental.BuildCheck.Acquisition; using Microsoft.Build.Framework; @@ -57,6 +58,7 @@ void ProcessTaskParameterEventArgs( void SetDataSource(BuildCheckDataSource buildCheckDataSource); + [RequiresUnreferencedCode("Loads custom build check assemblies from disk and reflects over their types, which is incompatible with trimming.")] void ProcessCheckAcquisition(CheckAcquisitionData acquisitionData, ICheckContext checksContext); void ProcessProjectImportedEventArgs(ICheckContext checkContext, ProjectImportedEventArgs projectImportedEventArgs); diff --git a/src/Build/BuildCheck/Infrastructure/NullBuildCheckManager.cs b/src/Build/BuildCheck/Infrastructure/NullBuildCheckManager.cs index f262903c187..e337bf89e52 100644 --- a/src/Build/BuildCheck/Infrastructure/NullBuildCheckManager.cs +++ b/src/Build/BuildCheck/Infrastructure/NullBuildCheckManager.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Diagnostics.CodeAnalysis; using Microsoft.Build.BackEnd.Logging; using Microsoft.Build.Experimental.BuildCheck.Acquisition; using Microsoft.Build.Framework; @@ -45,6 +46,7 @@ public void ProcessTaskParameterEventArgs( { } + [RequiresUnreferencedCode("Loads custom build check assemblies from disk and reflects over their types, which is incompatible with trimming.")] public void ProcessCheckAcquisition( CheckAcquisitionData acquisitionData, ICheckContext checkContext) diff --git a/src/Build/Construction/Solution/SolutionProjectGenerator.cs b/src/Build/Construction/Solution/SolutionProjectGenerator.cs index eddf89d641c..bbe7094c95f 100644 --- a/src/Build/Construction/Solution/SolutionProjectGenerator.cs +++ b/src/Build/Construction/Solution/SolutionProjectGenerator.cs @@ -6,6 +6,7 @@ using System.Collections; #endif using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Collections.Immutable; using System.Collections.ObjectModel; using System.Globalization; @@ -206,6 +207,7 @@ private SolutionProjectGenerator( /// An to use. /// The current build submission ID. /// An array of ProjectInstances. The first instance is the traversal project, the remaining are the metaprojects for each project referenced in the solution. + [RequiresUnreferencedCode("Evaluates a generated solution metaproject, which resolves SDKs and loads loggers by reflection at runtime; incompatible with trimming.")] internal static ProjectInstance[] Generate( SolutionFile solution, IDictionary globalProperties, @@ -736,6 +738,7 @@ internal static bool WouldProjectBuild(SolutionFile solutionFile, string selecte /// project to be generated is the private variable "msbuildProject" and the SolutionFile containing information /// about the solution is the private variable "solutionFile" /// + [RequiresUnreferencedCode("Evaluates a generated solution metaproject, which resolves SDKs and loads loggers by reflection at runtime; incompatible with trimming.")] private ProjectInstance[] Generate() { // The Version is not available in the new parser. @@ -763,6 +766,7 @@ private ProjectInstance[] Generate() /// Given a parsed solution, generate a top level traversal project and the metaprojects representing the dependencies for each real project /// referenced in the solution. /// + [RequiresUnreferencedCode("Evaluates a generated solution metaproject, which resolves SDKs and loads loggers by reflection at runtime; incompatible with trimming.")] private ProjectInstance[] CreateSolutionProject(string wrapperProjectToolsVersion, bool explicitToolsVersionSpecified) { AddFakeReleaseSolutionConfigurationIfNecessary(); @@ -899,6 +903,7 @@ private void AddStandardTraversalTargets(ProjectInstance traversalInstance, List /// /// Creates the traversal project instance. This has all of the properties against which we can perform evaluations for the remainder of the process. /// + [RequiresUnreferencedCode("Evaluates a generated solution metaproject, which resolves SDKs and loads loggers by reflection at runtime; incompatible with trimming.")] private ProjectInstance CreateTraversalInstance(string wrapperProjectToolsVersion, bool explicitToolsVersionSpecified, List projectsInOrder) { // Create the traversal project's root element. We will later instantiate this, and use it for evaluation of conditions on @@ -2188,6 +2193,7 @@ private string PredictActiveSolutionConfigurationName() /// Loads each MSBuild project in this solution and looks for its project-to-project references so that /// we know what build order we should use when building the solution. /// + [RequiresUnreferencedCode("Constructs and evaluates projects, which resolves SDKs and reflects over their types; incompatible with trimming.")] private void ScanProjectDependencies(string childProjectToolsVersion, string fullSolutionConfigurationName) { // Don't bother with all this if the solution configuration doesn't even exist. diff --git a/src/Build/Definition/Project.cs b/src/Build/Definition/Project.cs index 1715795c361..951f3377354 100644 --- a/src/Build/Definition/Project.cs +++ b/src/Build/Definition/Project.cs @@ -1488,6 +1488,7 @@ public void SaveLogicalProject(TextWriter writer) /// Does not modify the Project object. /// /// Returns true on success and false on failure or disabled build. + [RequiresUnreferencedCode("Initializes loggers and project cache plugins by reflecting over assemblies discovered at runtime, which is incompatible with trimming.")] public bool Build() { return Build((string[])null); @@ -1502,6 +1503,7 @@ public bool Build() /// /// Logger to use. /// Returns true on success and false on failure or disabled build. + [RequiresUnreferencedCode("Initializes loggers and project cache plugins by reflecting over assemblies discovered at runtime, which is incompatible with trimming.")] public bool Build(ILogger logger) { var loggers = new List(1) { logger }; @@ -1517,6 +1519,7 @@ public bool Build(ILogger logger) /// /// List of loggers. /// Returns true on success and false on failure or disabled build. + [RequiresUnreferencedCode("Initializes loggers and project cache plugins by reflecting over assemblies discovered at runtime, which is incompatible with trimming.")] public bool Build(IEnumerable loggers) { return Build((string[])null, loggers, null); @@ -1532,6 +1535,7 @@ public bool Build(IEnumerable loggers) /// List of loggers. /// Remote loggers for multi proc logging. /// Returns true on success and false on failure or disabled build. + [RequiresUnreferencedCode("Initializes loggers and project cache plugins by reflecting over assemblies discovered at runtime, which is incompatible with trimming.")] public bool Build(IEnumerable loggers, IEnumerable remoteLoggers) { return Build((string[])null, loggers, remoteLoggers); @@ -1546,6 +1550,7 @@ public bool Build(IEnumerable loggers, IEnumerable /// Target to build. /// Returns true on success and false on failure or disabled build. + [RequiresUnreferencedCode("Initializes loggers and project cache plugins by reflecting over assemblies discovered at runtime, which is incompatible with trimming.")] public bool Build(string target) { return Build(target, null, null); @@ -1561,6 +1566,7 @@ public bool Build(string target) /// Target to build. /// List of loggers. /// Returns true on success and false on failure or disabled build. + [RequiresUnreferencedCode("Initializes loggers and project cache plugins by reflecting over assemblies discovered at runtime, which is incompatible with trimming.")] public bool Build(string target, IEnumerable loggers) { return Build(target, loggers, null); @@ -1577,6 +1583,7 @@ public bool Build(string target, IEnumerable loggers) /// List of loggers. /// Remote loggers for multi proc logging. /// Returns true on success and false on failure or disabled build. + [RequiresUnreferencedCode("Initializes loggers and project cache plugins by reflecting over assemblies discovered at runtime, which is incompatible with trimming.")] public bool Build(string target, IEnumerable loggers, IEnumerable remoteLoggers) { // targets may be null, but not an entry within it @@ -1594,6 +1601,7 @@ public bool Build(string target, IEnumerable loggers, IEnumerable /// Targets to build. /// Returns true on success and false on failure or disabled build. + [RequiresUnreferencedCode("Initializes loggers and project cache plugins by reflecting over assemblies discovered at runtime, which is incompatible with trimming.")] public bool Build(string[] targets) { return Build(targets, null, null); @@ -1610,6 +1618,7 @@ public bool Build(string[] targets) /// Targets to build. /// List of loggers. /// Returns true on success and false on failure or disabled build. + [RequiresUnreferencedCode("Initializes loggers and project cache plugins by reflecting over assemblies discovered at runtime, which is incompatible with trimming.")] public bool Build(string[] targets, IEnumerable loggers) { return Build(targets, loggers, null); @@ -1627,6 +1636,7 @@ public bool Build(string[] targets, IEnumerable loggers) /// List of loggers. /// Remote loggers for multi proc logging. /// Returns true on success and false on failure or disabled build. + [RequiresUnreferencedCode("Initializes loggers and project cache plugins by reflecting over assemblies discovered at runtime, which is incompatible with trimming.")] public bool Build(string[] targets, IEnumerable loggers, IEnumerable remoteLoggers) { return Build(targets, loggers, remoteLoggers, null); @@ -1640,6 +1650,7 @@ public bool Build(string[] targets, IEnumerable loggers, IEnumerableRemote loggers for multi proc logging. /// The evaluation context to use in case reevaluation is required. /// Returns true on success and false on failure or disabled build. + [RequiresUnreferencedCode("Initializes loggers and project cache plugins by reflecting over assemblies discovered at runtime, which is incompatible with trimming.")] public bool Build(string[] targets, IEnumerable loggers, IEnumerable remoteLoggers, EvaluationContext evaluationContext) { return implementation.Build(targets, loggers, remoteLoggers, evaluationContext); @@ -3335,6 +3346,7 @@ public override void SaveLogicalProject(TextWriter writer) /// List of loggers. /// Remote loggers for multi proc logging. /// The evaluation context to use in case reevaluation is required. + [RequiresUnreferencedCode("Initializes loggers and project cache plugins by reflecting over assemblies discovered at runtime, which is incompatible with trimming.")] public override bool Build(string[] targets, IEnumerable loggers, IEnumerable remoteLoggers, EvaluationContext evaluationContext) { if (!IsBuildEnabled) diff --git a/src/Build/Definition/ProjectCollection.cs b/src/Build/Definition/ProjectCollection.cs index 9e5a89a5e53..4668591ab06 100644 --- a/src/Build/Definition/ProjectCollection.cs +++ b/src/Build/Definition/ProjectCollection.cs @@ -251,7 +251,7 @@ public ProjectCollection(IDictionary globalProperties) /// The loggers to register. May be null. /// The locations from which to load toolsets. public ProjectCollection(IDictionary globalProperties, IEnumerable loggers, ToolsetDefinitionLocations toolsetDefinitionLocations) - : this(globalProperties, loggers, null, toolsetDefinitionLocations, 1 /* node count */, false /* do not only log critical events */) + : this(globalProperties, loggers, toolsetDefinitionLocations, 1 /* node count */, false /* do not only log critical events */, loadProjectsReadOnly: false, useAsynchronousLogging: false, reuseProjectRootElementCache: false, enableTargetOutputLogging: false) { } @@ -268,6 +268,7 @@ public ProjectCollection(IDictionary globalProperties, IEnumerab /// The locations from which to load toolsets. /// The maximum number of nodes to use for building. /// If set to true, only critical events will be logged. + [RequiresUnreferencedCode("Registers loggers, which can load forwarding logger assemblies by reflection at runtime; incompatible with trimming.")] public ProjectCollection(IDictionary globalProperties, IEnumerable loggers, IEnumerable remoteLoggers, ToolsetDefinitionLocations toolsetDefinitionLocations, int maxNodeCount, bool onlyLogCriticalEvents) : this(globalProperties, loggers, null, toolsetDefinitionLocations, maxNodeCount, onlyLogCriticalEvents, loadProjectsReadOnly: false) { @@ -287,6 +288,7 @@ public ProjectCollection(IDictionary globalProperties, IEnumerab /// The maximum number of nodes to use for building. /// If set to true, only critical events will be logged. /// If set to true, load all projects as read-only. + [RequiresUnreferencedCode("Registers loggers, which can load forwarding logger assemblies by reflection at runtime; incompatible with trimming.")] public ProjectCollection(IDictionary globalProperties, IEnumerable loggers, IEnumerable remoteLoggers, ToolsetDefinitionLocations toolsetDefinitionLocations, int maxNodeCount, bool onlyLogCriticalEvents, bool loadProjectsReadOnly) : this(globalProperties, loggers, remoteLoggers, toolsetDefinitionLocations, maxNodeCount, onlyLogCriticalEvents, loadProjectsReadOnly, useAsynchronousLogging: false, reuseProjectRootElementCache: false, enableTargetOutputLogging: false) { @@ -311,6 +313,7 @@ public ProjectCollection(IDictionary globalProperties, IEnumerab /// /// This constructor disables target output logging, so TerminalLogger and other loggers may not work well. Prefer instead to control this behavior. /// + [RequiresUnreferencedCode("Registers loggers, which can load forwarding logger assemblies by reflection at runtime; incompatible with trimming.")] public ProjectCollection(IDictionary globalProperties, IEnumerable loggers, IEnumerable remoteLoggers, ToolsetDefinitionLocations toolsetDefinitionLocations, int maxNodeCount, bool onlyLogCriticalEvents, bool loadProjectsReadOnly, bool useAsynchronousLogging, bool reuseProjectRootElementCache) : this(globalProperties: globalProperties, loggers: loggers, remoteLoggers: remoteLoggers, toolsetDefinitionLocations: toolsetDefinitionLocations, maxNodeCount: maxNodeCount, onlyLogCriticalEvents: onlyLogCriticalEvents, loadProjectsReadOnly: loadProjectsReadOnly, useAsynchronousLogging: useAsynchronousLogging, reuseProjectRootElementCache: reuseProjectRootElementCache, enableTargetOutputLogging: false) { @@ -333,7 +336,31 @@ public ProjectCollection(IDictionary globalProperties, IEnumerab /// If set to true, asynchronous logging will be used. has to called to clear resources used by async logging. /// If set to true, it will try to reuse singleton. /// If set to true, loggers will collect and send Target outputs when targets are finished executing. + [RequiresUnreferencedCode("Registers loggers, which can load forwarding logger assemblies by reflection at runtime; incompatible with trimming.")] public ProjectCollection(IDictionary globalProperties, IEnumerable loggers, IEnumerable remoteLoggers, ToolsetDefinitionLocations toolsetDefinitionLocations, int maxNodeCount, bool onlyLogCriticalEvents, bool loadProjectsReadOnly, bool useAsynchronousLogging, bool reuseProjectRootElementCache, bool enableTargetOutputLogging) + : this(globalProperties, loggers, toolsetDefinitionLocations, maxNodeCount, onlyLogCriticalEvents, loadProjectsReadOnly, useAsynchronousLogging, reuseProjectRootElementCache, enableTargetOutputLogging) + { + // Forwarding loggers load logger assemblies by reflection, which is incompatible with + // trimming - that is why every overload taking forwarding loggers is RequiresUnreferencedCode + // while the overloads taking none are not. The trim-safe construction happens in the chained + // private constructor above; only this reflective registration is gated behind the attribute. + try + { + RegisterForwardingLoggers(remoteLoggers); + } + catch (Exception) + { + ShutDownLoggingService(); + throw; + } + } + + /// + /// Trim-safe construction shared by every constructor overload. Builds the collection and + /// registers ordinary (non-forwarding) loggers, but does not load forwarding loggers by + /// reflection, so the overloads that take none can be called without RequiresUnreferencedCode. + /// + private ProjectCollection(IDictionary globalProperties, IEnumerable loggers, ToolsetDefinitionLocations toolsetDefinitionLocations, int maxNodeCount, bool onlyLogCriticalEvents, bool loadProjectsReadOnly, bool useAsynchronousLogging, bool reuseProjectRootElementCache, bool enableTargetOutputLogging) { _loadedProjects = new LoadedProjectCollection(); ToolsetLocations = toolsetDefinitionLocations; @@ -369,7 +396,6 @@ public ProjectCollection(IDictionary globalProperties, IEnumerab CreateLoggingService(maxNodeCount, onlyLogCriticalEvents, enableTargetOutputLogging); RegisterLoggers(loggers); - RegisterForwardingLoggers(remoteLoggers); if (globalProperties != null) { @@ -465,7 +491,7 @@ public static ProjectCollection GlobalProjectCollection { // Take care to ensure that there is never more than one value observed // from this property even in the case of race conditions while lazily initializing. - var local = new ProjectCollection(null, null, null, ToolsetDefinitionLocations.Default, + var local = new ProjectCollection(null, null, ToolsetDefinitionLocations.Default, maxNodeCount: 1, onlyLogCriticalEvents: false, loadProjectsReadOnly: false, useAsynchronousLogging: true, reuseProjectRootElementCache: false, enableTargetOutputLogging: false); if (Interlocked.CompareExchange(ref s_globalProjectCollection, local, null) != null) @@ -494,12 +520,13 @@ public static Version Version { if (s_engineVersion == null) { - // Get the file version from the currently executing assembly. - // Use .CodeBase instead of .Location, because .Location doesn't - // work when Microsoft.Build.dll has been shadow-copied, for example - // in scenarios where NUnit is loading Microsoft.Build. - var versionInfo = FileVersionInfo.GetVersionInfo(typeof(ProjectCollection).GetAssemblyPath()); - s_engineVersion = new Version(versionInfo.FileMajorPart, versionInfo.FileMinorPart, versionInfo.FileBuildPart, versionInfo.FilePrivatePart); + // Read the file version from the assembly's AssemblyFileVersionAttribute rather than + // from the file on disk. FileVersionInfo.GetVersionInfo requires an assembly path, but + // Assembly.Location is empty in single-file and Native AOT apps (and unreliable under + // shadow-copy), which would throw. The attribute carries the same file version. + string fileVersion = typeof(ProjectCollection).Assembly + .GetCustomAttribute()?.Version; + s_engineVersion = fileVersion != null ? Version.Parse(fileVersion) : new Version(0, 0, 0, 0); } return s_engineVersion; @@ -1383,6 +1410,7 @@ public void RegisterLoggers(IEnumerable loggers) /// Adds some remote loggers to the collection of remote loggers used for builds of projects in this collection. /// May be null. /// + [RequiresUnreferencedCode("Creates forwarding loggers by reflecting over logger assemblies discovered at runtime, which is incompatible with trimming.")] public void RegisterForwardingLoggers(IEnumerable remoteLoggers) { using (_locker.EnterDisposableWriteLock()) diff --git a/src/Build/Definition/ToolsetReader.cs b/src/Build/Definition/ToolsetReader.cs index 9e1fda0d5e0..536ca33028e 100644 --- a/src/Build/Definition/ToolsetReader.cs +++ b/src/Build/Definition/ToolsetReader.cs @@ -115,26 +115,41 @@ internal static string ReadAllToolsets( if ((locations & ToolsetDefinitionLocations.ConfigurationFile) == ToolsetDefinitionLocations.ConfigurationFile) { - if (configurationReader == null) + // The configuration-file reader is gated by the EnableConfigurationFileToolsets feature switch so + // the trimmer can fold this check to its substituted constant. When the switch is disabled (the + // trimmer substitutes false) the reader subtree - and with it the System.Configuration.ConfigurationManager + // dependency - is dead-stripped from a trimmed/AOT application, leaving only the observable throw below. + if (Framework.FeatureSwitches.EnableConfigurationFileToolsets) { - configurationReader = new ToolsetConfigurationReader(environmentProperties, globalProperties); - } + if (configurationReader == null) + { + configurationReader = new ToolsetConfigurationReader(environmentProperties, globalProperties); + } - ReadConfigToolset(); + ReadConfigToolset(); - // This is isolated into its own function in order to isolate loading of - // System.Configuration.ConfigurationManager.dll to codepaths that really - // need it as a way of mitigating the need to update references to that - // assembly in API consumers. - // - // https://github.com/microsoft/MSBuildLocator/issues/159 - [MethodImplAttribute(MethodImplOptions.NoInlining)] - void ReadConfigToolset() + // This is isolated into its own function in order to isolate loading of + // System.Configuration.ConfigurationManager.dll to codepaths that really + // need it as a way of mitigating the need to update references to that + // assembly in API consumers. + // + // https://github.com/microsoft/MSBuildLocator/issues/159 + [MethodImplAttribute(MethodImplOptions.NoInlining)] + void ReadConfigToolset() + { + // Accumulation of properties is okay in the config file because it's deterministically ordered + defaultToolsVersionFromConfiguration = configurationReader.ReadToolsets(toolsets, globalProperties, + initialProperties, true /* accumulate properties */, out overrideTasksPathFromConfiguration, + out defaultOverrideToolsVersionFromConfiguration); + } + } + else { - // Accumulation of properties is okay in the config file because it's deterministically ordered - defaultToolsVersionFromConfiguration = configurationReader.ReadToolsets(toolsets, globalProperties, - initialProperties, true /* accumulate properties */, out overrideTasksPathFromConfiguration, - out defaultOverrideToolsVersionFromConfiguration); + // The caller explicitly requested configuration-file toolsets, but the feature was compiled out + // of this trimmed/Native AOT host. Fail observably rather than silently returning no toolsets. + // The configuration file is not a default toolset location on .NET, so hosts that do not opt in + // to ToolsetDefinitionLocations.ConfigurationFile never reach this path. + ErrorUtilities.ThrowArgument("OM_ConfigurationFileToolsetsNotSupported"); } } diff --git a/src/Build/Evaluation/Expander.Function.cs b/src/Build/Evaluation/Expander.Function.cs index 240f24deb0a..de8f2aa230b 100644 --- a/src/Build/Evaluation/Expander.Function.cs +++ b/src/Build/Evaluation/Expander.Function.cs @@ -17,7 +17,7 @@ using Microsoft.Build.Shared.FileSystem; using Microsoft.NET.StringTools; using AvailableStaticMethods = Microsoft.Build.Internal.AvailableStaticMethods; -using FeatureSwitches = Microsoft.Build.Internal.FeatureSwitches; +using FeatureSwitches = Microsoft.Build.Framework.FeatureSwitches; using ParseArgs = Microsoft.Build.Evaluation.Expander.ArgumentParser; #if FEATURE_MSIOREDIST @@ -181,6 +181,28 @@ internal string Receiver /// /// Extract the function details from the given property function expression. /// + /// The property-function body, e.g. SomeProp.ToLower() or [System.Math]::Max(1, 2). + /// Location used for error reporting. + /// + /// The receiver instance the function binds against. It is used here only to derive the receiver + /// (via GetType()); the instance itself is passed to Execute later. + /// Legitimate values are: + /// + /// for a static call ([Type]::Method()) or the first + /// instance call in a chain, where the receiver type defaults to . + /// A , the evaluated value of an MSBuild property (the common case; + /// property values are always strings). + /// The return value of a preceding function in a chain such as $(Prop.A().B()), + /// which can be any type that function produced. + /// + /// Only the receiver type's public member surface (constructors, methods, properties, fields) is reflected + /// over. Because that runtime type is open-ended it cannot be statically preserved nor expressed as a + /// DynamicallyAccessedMembers constraint on an parameter, so the unavoidable + /// trim suppression lives, minimized, in FunctionBuilder.SetReceiverType. + /// + /// Tracks property reads performed while evaluating the function. + /// File system abstraction used by file and directory property functions. + /// Logging context for the operation; may be . internal static Function ExtractPropertyFunction( string expressionFunction, IElementLocation elementLocation, @@ -248,7 +270,7 @@ internal static Function ExtractPropertyFunction( ProjectErrorUtilities.ThrowInvalidProject(elementLocation, "InvalidFunctionTypeUnavailable", expressionFunction, typeName); } - functionBuilder.ReceiverType = receiverType; + functionBuilder.SetReceiverType(receiverType); } else if (expressionFunction[0] == '[') // We have an indexer { @@ -261,7 +283,7 @@ internal static Function ExtractPropertyFunction( var methodStartIndex = indexerEndIndex + 1; - functionBuilder.ReceiverType = propertyValue.GetType(); + functionBuilder.SetReceiverType(propertyValue.GetType()); ConstructIndexerFunction(expressionFunction, elementLocation, propertyValue, methodStartIndex, indexerEndIndex, ref functionBuilder); } @@ -295,7 +317,7 @@ internal static Function ExtractPropertyFunction( var receiverType = propertyValue?.GetType() ?? typeof(string); functionBuilder.Receiver = functionReceiver; - functionBuilder.ReceiverType = receiverType; + functionBuilder.SetReceiverType(receiverType); ConstructFunction(elementLocation, expressionFunction, argumentStartIndex, methodStartIndex, ref functionBuilder); } @@ -340,6 +362,8 @@ private static bool IsFileOrDirectoryPathArgument(string methodName, int argInde /// [UnconditionalSuppressMessage("Trimming", "IL2074:UnrecognizedReflectionPattern", Justification = "_receiverType is reassigned from a runtime property value whose type is restricted to the property-function allowlist, whose members are preserved for trimming.")] + [UnconditionalSuppressMessage("Trimming", "IL2080:UnrecognizedReflectionPattern", + Justification = "_bindingFlags is masked to AllowedBindingFlags at construction, so it never carries BindingFlags.NonPublic; GetMethods(_bindingFlags) therefore binds only public methods of the property-function allowlist receiver, whose public members are preserved for trimming.")] internal object Execute(object objectInstance, IPropertyProvider

properties, ExpanderOptions options, IElementLocation elementLocation) { object functionResult = String.Empty; @@ -644,8 +668,6 @@ private object GetMethodResult(object objectInstance, IEnumerable me /// [UnconditionalSuppressMessage("Trimming", "IL2096:UnrecognizedReflectionPattern", Justification = "The type name is resolved against the curated AvailableStaticMethods allowlist; the case-insensitive lookup only resolves to allowlist types, whose members are preserved for trimming.")] - [UnconditionalSuppressMessage("Trimming", "IL2026:RequiresUnreferencedCode", - Justification = "The GetTypeFromAssembly / GetTypeFromAssemblyUsingNamespace calls below are reached only when the EnableAllPropertyFunctions feature switch is enabled (MSBUILDENABLEALLPROPERTYFUNCTIONS=1). That switch is a FeatureSwitchDefinition defaulting to false under trimming (see the RuntimeHostConfigurationOption in Microsoft.Build.csproj), so the trimmer removes this branch from a trimmed application; the trim analyzer does not evaluate the feature-switch default and therefore still reports the call as reachable.")] private static Type GetTypeForStaticMethod(string typeName, string simpleMethodName) { Type receiverType; @@ -677,7 +699,7 @@ private static Type GetTypeForStaticMethod(string typeName, string simpleMethodN var assemblyQualifiedTypeName = cachedTypeInformation.Item1; // Get the type from the assembly qualified type name from AvailableStaticMethods - receiverType = Type.GetType(assemblyQualifiedTypeName, false /* do not throw TypeLoadException if not found */, true /* ignore case */); + receiverType = Type.GetType(assemblyQualifiedTypeName, throwOnError: false, ignoreCase: true); // If the type information from the cache is not loadable, it means the cache information got corrupted somehow // Throw here to prevent adding null types in the cache @@ -692,7 +714,7 @@ private static Type GetTypeForStaticMethod(string typeName, string simpleMethodN } // Get the type from mscorlib (or the currently running assembly) - receiverType = Type.GetType(typeName, false /* do not throw TypeLoadException if not found */, true /* ignore case */); + receiverType = Type.GetType(typeName, throwOnError: false, ignoreCase: true); if (receiverType != null) { @@ -703,11 +725,12 @@ private static Type GetTypeForStaticMethod(string typeName, string simpleMethodN return receiverType; } - // Note the following code path is only entered when MSBUILDENABLEALLPROPERTYFUNCTIONS == 1. - // It is modeled as a trimmer feature switch (see FeatureSwitches) so this reflective - // probing is removed from trimmed applications, where only the curated allowlist of - // receiver types is supported. The environment variable is still honored at runtime when - // not trimmed, and must not be cached so it stays dynamically settable. + // The following reflective probing runs only when the EnableAllPropertyFunctions feature + // switch is enabled (or, in untrimmed builds, the legacy MSBUILDENABLEALLPROPERTYFUNCTIONS + // environment variable is set). That switch is a [FeatureGuard] for RequiresUnreferencedCode, + // so the analyzer treats this branch as the trim-unsafe region (no suppression needed). In + // trimmed / AOT applications the trimmer substitutes the switch false and removes this branch, + // so only the curated allowlist of receiver types is supported. if (FeatureSwitches.EnableAllPropertyFunctions) { // We didn't find the type, so go probing. First in System @@ -1116,6 +1139,9 @@ private static bool IsStaticMethodAvailable(Type receiverType, string methodName return true; } + // The escape hatch opens everything. The feature switch also preserves the legacy + // MSBUILDENABLEALLPROPERTYFUNCTIONS environment-variable behavior in untrimmed builds; under + // trimming it is substituted false, so this wide gate is removed. if (FeatureSwitches.EnableAllPropertyFunctions) { // anything goes @@ -1128,8 +1154,9 @@ private static bool IsStaticMethodAvailable(Type receiverType, string methodName private static bool IsInstanceMethodAvailable(Type receiverType, string methodName) { // The escape hatch opens everything (this preserves the historical behavior, including - // allowing GetType). Under trimming EnableAllPropertyFunctions is substituted false and - // this branch is removed. + // allowing GetType). The feature switch also preserves the legacy + // MSBUILDENABLEALLPROPERTYFUNCTIONS environment-variable behavior in untrimmed builds; under + // trimming it is substituted false, so this wide gate is removed. if (FeatureSwitches.EnableAllPropertyFunctions) { return true; @@ -1159,6 +1186,8 @@ private static bool IsInstanceMethodAvailable(Type receiverType, string methodNa /// Finds a public method on the receiver type by name (case-insensitive) and exact /// parameter-type signature, filtering by the current binding flags (instance/static). /// + [UnconditionalSuppressMessage("Trimming", "IL2080:UnrecognizedReflectionPattern", + Justification = "_bindingFlags is masked to AllowedBindingFlags at construction, so it never carries BindingFlags.NonPublic; GetMethods(_bindingFlags) therefore binds only public methods of the property-function allowlist receiver, whose public members are preserved for trimming.")] private MethodInfo FindPublicMethodBySignature(string methodName, Type[] parameterTypes) { foreach (MethodInfo method in _receiverType.GetMethods(_bindingFlags)) @@ -1197,6 +1226,21 @@ private MethodInfo FindPublicMethodBySignature(string methodName, Type[] paramet /// Construct and instance of objectType based on the constructor or method arguments provided. /// Arguments must never be null. /// + // This reflective invoke can in principle reach any public method of an allowlisted receiver type. + // The only such method carrying [RequiresDynamicCode] is Enum.GetValues(Type) (on System.Enum) - + // this is the IL3050 suppressed below. + // + // Reaching it would require an author to pass a System.Type argument, and a property function has no + // way to produce one: string does not coerce to Type (evaluation reports MSB4186, "method not + // found"), and [System.Type]::GetType(...) is not an available property function (MSB4185, even with + // MSBUILDENABLEALLPROPERTYFUNCTIONS=1). The receiver is a runtime Type, so the static + // Enum.GetValues() overload cannot be substituted either. The case is therefore blocked before + // this invoke (identically on JIT and AOT) and would still fail observably (InvalidProjectFileException) + // if reached - never silently. Verified under Native AOT by src/aot-validation/PropertyFunctionAotTests.cs. + [UnconditionalSuppressMessage("AOT", "IL3050:RequiresDynamicCode", + Justification = "The only RDC method reachable here is Enum.GetValues(Type), which is unreachable via property functions; see comment above.")] + [UnconditionalSuppressMessage("Trimming", "IL2080:UnrecognizedReflectionPattern", + Justification = "_bindingFlags is masked to AllowedBindingFlags at construction, so it never carries BindingFlags.NonPublic; GetMethods(_bindingFlags) therefore binds only public methods of the property-function allowlist receiver, whose public members are preserved for trimming.")] private object LateBindExecute(Exception ex, BindingFlags bindingFlags, object objectInstance /* null unless instance method */, object[] args, bool isConstructor) { // First let's try for a method where all arguments are strings.. diff --git a/src/Build/Evaluation/Expander.FunctionBuilder.cs b/src/Build/Evaluation/Expander.FunctionBuilder.cs index 88e7e91e858..30c6855083c 100644 --- a/src/Build/Evaluation/Expander.FunctionBuilder.cs +++ b/src/Build/Evaluation/Expander.FunctionBuilder.cs @@ -18,9 +18,43 @@ internal partial class Expander private struct FunctionBuilder { ///

- /// The type of this function's receiver. + /// Backing field for . Carries the same annotation as the property so the + /// getter's return value is satisfied by a field with a matching requirement: a compiler-generated + /// auto-property backing field does not inherit the property's annotation, which is what produces IL2078. /// - public Type ReceiverType { get; set; } + [DynamicallyAccessedMembers( + DynamicallyAccessedMemberTypes.PublicConstructors | + DynamicallyAccessedMemberTypes.PublicMethods | + DynamicallyAccessedMemberTypes.PublicProperties | + DynamicallyAccessedMemberTypes.PublicFields)] + private Type _receiverType; + + /// + /// The type of this function's receiver. Only the public member surface is preserved for trimming: + /// property functions never bind non-public members ( is rejected + /// by TypeExtensions.InvokePublicMember). Keep in sync with Function._receiverType and + /// Constants.PropertyFunctionMembers. + /// + [DynamicallyAccessedMembers( + DynamicallyAccessedMemberTypes.PublicConstructors | + DynamicallyAccessedMemberTypes.PublicMethods | + DynamicallyAccessedMemberTypes.PublicProperties | + DynamicallyAccessedMemberTypes.PublicFields)] + public readonly Type ReceiverType => _receiverType; + + /// + /// Sets from a property-function receiver type. That type is always either + /// a type from MSBuild's curated static-method allowlist (resolved by name, its public members + /// preserved for trimming by Constants.PropertyFunctionMembers) or a runtime value's + /// GetType(); property functions bind only the public surface. This one-line setter writes the + /// annotated field directly, so it is the single place an un-annotated + /// enters the DynamicallyAccessedMembers-tracked flow and the localized, + /// minimized home of the IL2069 suppression - every downstream hop (Build -> Function + /// -> InvokePublicMember) is then machine-checked. + /// + [UnconditionalSuppressMessage("Trimming", "IL2069", + Justification = "Receiver type comes from the static-method allowlist (public members preserved by Constants.PropertyFunctionMembers) or a runtime GetType(); only public members are bound. See the summary for the DAM-flow rationale.")] + internal void SetReceiverType(Type receiverType) => _receiverType = receiverType; /// /// The name of the function. @@ -61,8 +95,6 @@ private struct FunctionBuilder /// public PropertiesUseTracker PropertiesUseTracker { get; set; } - [UnconditionalSuppressMessage("Trimming", "IL2072:UnrecognizedReflectionPattern", - Justification = "The receiver type stored in ReceiverType is a property-function receiver, restricted to the curated AvailableStaticMethods allowlist (whose members are preserved for trimming) or to a property value of an allowlist type; the DynamicallyAccessedMembers requirement of the Function constructor is satisfied for those preserved types.")] internal readonly Function Build() { return new Function( diff --git a/src/Build/Evaluation/PropertyFunctionReceiver.cs b/src/Build/Evaluation/PropertyFunctionReceiver.cs index 05501b2b7d7..714202546e7 100644 --- a/src/Build/Evaluation/PropertyFunctionReceiver.cs +++ b/src/Build/Evaluation/PropertyFunctionReceiver.cs @@ -13,9 +13,9 @@ namespace Microsoft.Build.Evaluation; /// /// /// -/// Used when is enabled. It -/// limits dotting to a curated, bounded set of receiver types so the members reachable by reflection are -/// predictable and statically known, which keeps the property-function path trim compatible. +/// Used when the RestrictPropertyFunctionReceivers feature switch (in Microsoft.Build.Framework) is +/// enabled. It limits dotting to a curated, bounded set of receiver types so the members reachable by +/// reflection are predictable and statically known, which keeps the property-function path trim compatible. /// /// /// Common read-only navigation such as $([System.IO.Directory]::GetParent(x).Parent.FullName) diff --git a/src/Build/FeatureSwitches.cs b/src/Build/FeatureSwitches.cs deleted file mode 100644 index b97af5c3098..00000000000 --- a/src/Build/FeatureSwitches.cs +++ /dev/null @@ -1,58 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System; -using System.Diagnostics.CodeAnalysis; - -namespace Microsoft.Build.Internal; - -/// -/// Aggregates MSBuild trimmer feature switches. -/// -/// -/// Each property is annotated with and mapped to an -/// AppContext switch. When Microsoft.Build is trimmed (for example when publishing a trimmed -/// application that embeds it), the trimmer can substitute a constant value for the property and -/// remove the statically unreachable code it guards. The default value used during trimming is -/// declared with a matching RuntimeHostConfigurationOption item in Microsoft.Build.csproj. -/// New feature switches should be added here so they can be discovered and configured in one place. -/// -internal static class FeatureSwitches -{ - /// - /// When (the default in a trimmed application), property-function - /// receiver types are restricted to the curated allowlist in AvailableStaticMethods, - /// all of which are statically known and preserved. When - /// (MSBUILDENABLEALLPROPERTYFUNCTIONS=1 or the matching AppContext switch), MSBuild - /// additionally probes assemblies at runtime to resolve arbitrary receiver types - reflection - /// that is incompatible with trimming. This is modeled as a trimmer feature switch: under - /// trimming the property is substituted with a constant , removing the - /// probing path; at run time (untrimmed) the environment variable and AppContext switch are - /// read fresh so the setting stays dynamically settable. - /// - [FeatureSwitchDefinition("Microsoft.Build.EnableAllPropertyFunctions")] - internal static bool EnableAllPropertyFunctions => - (AppContext.TryGetSwitch("Microsoft.Build.EnableAllPropertyFunctions", out bool isEnabled) && isEnabled) - || Environment.GetEnvironmentVariable("MSBUILDENABLEALLPROPERTYFUNCTIONS") == "1"; - - /// - /// Whether instance property-function calls are limited to a curated set of receiver types. - /// - /// - /// - /// When enabled, instance "dotting in" is restricted to a curated set of receiver types (see - /// ), so the members reachable by reflection are - /// predictable and statically known. When disabled, any public instance member except GetType - /// is callable, preserving the historical behavior. - /// - /// - /// The untrimmed default is ; under trimming the constant is substituted - /// so the unrestricted branch is removed, keeping the property-function path - /// trim compatible. This switch is set only through its AppContext switch; it has no environment - /// variable. - /// - /// - [FeatureSwitchDefinition("Microsoft.Build.RestrictPropertyFunctionReceivers")] - internal static bool RestrictPropertyFunctionReceivers => - AppContext.TryGetSwitch("Microsoft.Build.RestrictPropertyFunctionReceivers", out bool isEnabled) && isEnabled; -} diff --git a/src/Build/Graph/GraphBuildSubmission.cs b/src/Build/Graph/GraphBuildSubmission.cs index b5d21efa092..5a23cd89944 100644 --- a/src/Build/Graph/GraphBuildSubmission.cs +++ b/src/Build/Graph/GraphBuildSubmission.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using System.Diagnostics.CodeAnalysis; using System.Threading; using Microsoft.Build.Execution; @@ -34,6 +35,7 @@ internal GraphBuildSubmission(BuildManager buildManager, int submissionId, Graph /// Starts the request asynchronously and immediately returns control to the caller. /// /// The request has already been started or is already complete. + [RequiresUnreferencedCode("Initializes project cache plugins, which load plugin assemblies from disk and reflect over their types; incompatible with trimming.")] public void ExecuteAsync(GraphBuildSubmissionCompleteCallback? callback, object? context) { void Clb(BuildSubmissionBase submission) @@ -48,6 +50,7 @@ void Clb(BuildSubmissionBase submission /// Starts the request and blocks until results are available. /// /// The request has already been started or is already complete. + [RequiresUnreferencedCode("Initializes project cache plugins, which load plugin assemblies from disk and reflect over their types; incompatible with trimming.")] public override GraphBuildResult Execute() { ExecuteAsync(null, null); diff --git a/src/Build/Instance/ProjectInstance.cs b/src/Build/Instance/ProjectInstance.cs index 559b2bf7563..23385240c11 100644 --- a/src/Build/Instance/ProjectInstance.cs +++ b/src/Build/Instance/ProjectInstance.cs @@ -2179,6 +2179,7 @@ public ProjectInstance DeepCopy(bool isImmutable) /// Returns true on success, false on failure. /// Only valid if mutable. /// + [RequiresUnreferencedCode("Initializes loggers and project cache plugins by reflecting over assemblies discovered at runtime, which is incompatible with trimming.")] public bool Build() { return Build(null); @@ -2194,6 +2195,7 @@ public bool Build() /// If any of the loggers supplied are already attached to the logging service we /// were passed, throws InvalidOperationException. /// + [RequiresUnreferencedCode("Initializes loggers and project cache plugins by reflecting over assemblies discovered at runtime, which is incompatible with trimming.")] public bool Build(IEnumerable loggers) { return Build((string[])null, loggers, null); @@ -2209,6 +2211,7 @@ public bool Build(IEnumerable loggers) /// If any of the loggers supplied are already attached to the logging service we /// were passed, throws InvalidOperationException. /// + [RequiresUnreferencedCode("Initializes loggers and project cache plugins by reflecting over assemblies discovered at runtime, which is incompatible with trimming.")] public bool Build(IEnumerable loggers, IEnumerable remoteLoggers) { return Build((string[])null, loggers, remoteLoggers); @@ -2225,6 +2228,7 @@ public bool Build(IEnumerable loggers, IEnumerable + [RequiresUnreferencedCode("Initializes loggers and project cache plugins by reflecting over assemblies discovered at runtime, which is incompatible with trimming.")] public bool Build(string target, IEnumerable loggers) { return Build(target, loggers, null); @@ -2242,6 +2246,7 @@ public bool Build(string target, IEnumerable loggers) /// If any of the loggers supplied are already attached to the logging service we /// were passed, throws InvalidOperationException. /// + [RequiresUnreferencedCode("Initializes loggers and project cache plugins by reflecting over assemblies discovered at runtime, which is incompatible with trimming.")] public bool Build(string target, IEnumerable loggers, IEnumerable remoteLoggers) { string[] targets = (target == null) ? [] : [target]; @@ -2260,6 +2265,7 @@ public bool Build(string target, IEnumerable loggers, IEnumerable + [RequiresUnreferencedCode("Initializes loggers and project cache plugins by reflecting over assemblies discovered at runtime, which is incompatible with trimming.")] public bool Build(string[] targets, IEnumerable loggers) { return Build(targets, loggers, null); @@ -2277,6 +2283,7 @@ public bool Build(string[] targets, IEnumerable loggers) /// If any of the loggers supplied are already attached to the logging service we /// were passed, throws InvalidOperationException. /// + [RequiresUnreferencedCode("Initializes loggers and project cache plugins by reflecting over assemblies discovered at runtime, which is incompatible with trimming.")] public bool Build(string[] targets, IEnumerable loggers, IEnumerable remoteLoggers) { IDictionary targetOutputs; @@ -2295,6 +2302,7 @@ public bool Build(string[] targets, IEnumerable loggers, IEnumerable + [RequiresUnreferencedCode("Initializes loggers and project cache plugins by reflecting over assemblies discovered at runtime, which is incompatible with trimming.")] public bool Build(string[] targets, IEnumerable loggers, out IDictionary targetOutputs) { return Build(targets, loggers, null, null, out targetOutputs); @@ -2312,6 +2320,7 @@ public bool Build(string[] targets, IEnumerable loggers, out IDictionar /// If any of the loggers supplied are already attached to the logging service we /// were passed, throws InvalidOperationException. /// + [RequiresUnreferencedCode("Initializes loggers and project cache plugins by reflecting over assemblies discovered at runtime, which is incompatible with trimming.")] public bool Build(string[] targets, IEnumerable loggers, IEnumerable remoteLoggers, out IDictionary targetOutputs) { return Build(targets, loggers, remoteLoggers, null, out targetOutputs); @@ -2620,6 +2629,7 @@ private void TranslateItems(ITranslator translator) /// /// Creates a set of project instances which represent the project dependency graph for a solution build. /// + [RequiresUnreferencedCode("Evaluates a solution's projects, which resolves SDKs and reflects over their types; incompatible with trimming.")] internal static ProjectInstance[] LoadSolutionForBuild( string projectFile, PropertyDictionary globalPropertiesInstances, @@ -2684,6 +2694,7 @@ internal static ProjectInstance[] LoadSolutionForBuild( return projectInstances; } + [RequiresUnreferencedCode("Evaluates a solution's projects, which resolves SDKs and reflects over their types; incompatible with trimming.")] private static ProjectInstance[] CalculateToolsVersionAndGenerateSolutionWrapper( string projectFile, BuildParameters buildParameters, @@ -2778,6 +2789,7 @@ internal static void VerifyThrowNotImmutable(bool isImmutable) /// /// Builds a list of targets with the specified loggers. /// + [RequiresUnreferencedCode("Initializes loggers and project cache plugins by reflecting over assemblies discovered at runtime, which is incompatible with trimming.")] internal bool Build(string[] targets, IEnumerable loggers, IEnumerable remoteLoggers, ILoggingService loggingService, int maxNodeCount, out IDictionary targetOutputs) { VerifyThrowNotImmutable(); @@ -2833,6 +2845,7 @@ internal bool Build(string[] targets, IEnumerable loggers, IEnumerable< /// /// Builds a list of targets with the specified loggers. /// + [RequiresUnreferencedCode("Initializes loggers and project cache plugins by reflecting over assemblies discovered at runtime, which is incompatible with trimming.")] internal bool Build(string[] targets, IEnumerable loggers, IEnumerable remoteLoggers, ILoggingService loggingService, out IDictionary targetOutputs) { return Build(targets, loggers, remoteLoggers, loggingService, 1, out targetOutputs); @@ -2975,6 +2988,7 @@ internal void VerifyThrowNotImmutable() /// /// /// The ProjectRootElement for the root traversal and each of the metaprojects. + [RequiresUnreferencedCode("Evaluates a generated solution metaproject, which resolves SDKs and loads loggers by reflection at runtime; incompatible with trimming.")] private static ProjectInstance[] GenerateSolutionWrapper( string projectFile, @@ -3028,6 +3042,7 @@ private static ProjectInstance[] GenerateSolutionWrapper( /// /// An appropriate ProjectRootElement [MethodImpl(MethodImplOptions.NoInlining)] + [RequiresUnreferencedCode("Evaluates a generated solution metaproject, which resolves SDKs and reflects over their types; incompatible with trimming.")] private static ProjectInstance[] GenerateSolutionWrapperUsingOldOM( string projectFile, IDictionary globalProperties, diff --git a/src/Build/Instance/TaskFactories/AssemblyTaskFactory.cs b/src/Build/Instance/TaskFactories/AssemblyTaskFactory.cs index 7fbb669b8c3..9ad3591fbd0 100644 --- a/src/Build/Instance/TaskFactories/AssemblyTaskFactory.cs +++ b/src/Build/Instance/TaskFactories/AssemblyTaskFactory.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Reflection; #if FEATURE_APPDOMAIN using System.Collections.Concurrent; @@ -39,7 +40,7 @@ internal class AssemblyTaskFactory : ITaskFactory3 /// /// The type loader to load types which derrive from ITask or ITask2 /// - private readonly TypeLoader _typeLoader = new TypeLoader(TaskLoader.IsTaskClass); + private readonly TypeLoader _typeLoader = TypeLoader.Create(); /// /// Name of the task wrapped by the task factory @@ -89,6 +90,7 @@ public string FactoryName /// /// Gets the type of task this factory creates. /// + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] public Type TaskType { get { return _loadedType.Type; } @@ -109,6 +111,7 @@ public Type TaskType /// The taskFactoryLoggingHost will log messages in the context of the target where the task is first used. /// /// + [RequiresUnreferencedCode("Loads the task type by reflecting over an assembly discovered at runtime, which is incompatible with trimming.")] public bool Initialize(string taskName, IDictionary parameterGroup, string taskBody, IBuildEngine taskFactoryLoggingHost) => InternalError.Throw("Use internal call to properly initialize the assembly task factory"); @@ -131,6 +134,7 @@ public bool Initialize(string taskName, IDictionary pa /// The taskFactoryLoggingHost will log messages in the context of the target where the task is first used. /// /// + [RequiresUnreferencedCode("Loads the task type by reflecting over an assembly discovered at runtime, which is incompatible with trimming.")] public bool Initialize(string taskName, IDictionary factoryIdentityParameters, IDictionary parameterGroup, string taskBody, IBuildEngine taskFactoryLoggingHost) => InternalError.Throw("Use internal call to properly initialize the assembly task factory"); @@ -152,6 +156,7 @@ public TaskPropertyInfo[] GetTaskParameters() /// /// The generated task, or null if the task failed to be created. /// + [RequiresUnreferencedCode("Loads the task type by reflecting over an assembly discovered at runtime, which is incompatible with trimming.")] public ITask CreateTask(IBuildEngine taskFactoryLoggingHost) => InternalError.Throw("Use internal call to properly create a task instance from the assembly task factory"); @@ -172,6 +177,7 @@ public ITask CreateTask(IBuildEngine taskFactoryLoggingHost) /// /// The generated task, or null if the task failed to be created. /// + [RequiresUnreferencedCode("Loads the task type by reflecting over an assembly discovered at runtime, which is incompatible with trimming.")] public ITask CreateTask(IBuildEngine taskFactoryLoggingHost, IDictionary taskIdentityParameters) => InternalError.Throw("Use internal call to properly create a task instance from the assembly task factory"); @@ -227,6 +233,7 @@ public void CleanupTask(ITask task) /// /// Initialize the factory from the task registry. /// + [RequiresUnreferencedCode("Loads the task type by reflecting over an assembly discovered at runtime, which is incompatible with trimming.")] internal LoadedType InitializeFactory( AssemblyLoadInfo loadInfo, string taskName, @@ -459,6 +466,7 @@ private TaskHostParameters UpdateTaskHostParameters(TaskHostParameters taskHostP /// Is the given task name able to to be created by the task factory. In the case of an assembly task factory /// this question is answered by checking the assembly wrapped by the task factory to see if it exists. /// + [RequiresUnreferencedCode("Loads the task type by reflecting over an assembly discovered at runtime, which is incompatible with trimming.")] internal bool TaskNameCreatableByFactory(string taskName, in TaskHostParameters taskIdentityParameters, string taskProjectFile, TargetLoggingContext targetLoggingContext, ElementLocation elementLocation) { if (!TaskIdentityParametersMatchFactory(_factoryIdentityParameters, taskIdentityParameters)) @@ -765,6 +773,7 @@ private bool IsMicrosoftAuthoredTask() /// The taskFactoryLoggingHost will log messages in the context of the target where the task is first used. /// /// + [RequiresUnreferencedCode("Loads the task type by reflecting over an assembly discovered at runtime, which is incompatible with trimming.")] public bool Initialize(string taskName, TaskHostParameters factoryIdentityParameters, IDictionary parameterGroup, string taskBody, IBuildEngine taskFactoryLoggingHost) => InternalError.Throw("Use internal call to properly initialize the assembly task factory"); @@ -785,6 +794,7 @@ public bool Initialize(string taskName, TaskHostParameters factoryIdentityParame /// /// The generated task, or null if the task failed to be created. /// + [RequiresUnreferencedCode("Loads the task type by reflecting over an assembly discovered at runtime, which is incompatible with trimming.")] public ITask CreateTask(IBuildEngine taskFactoryLoggingHost, TaskHostParameters taskIdentityParameters) => InternalError.Throw("Use internal call to properly create a task instance from the assembly task factory"); diff --git a/src/Build/Instance/TaskFactories/RegisteredTaskFactory.cs b/src/Build/Instance/TaskFactories/RegisteredTaskFactory.cs new file mode 100644 index 00000000000..ff550af556c --- /dev/null +++ b/src/Build/Instance/TaskFactories/RegisteredTaskFactory.cs @@ -0,0 +1,72 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Build.Framework; +using Microsoft.Build.Shared; + +namespace Microsoft.Build.BackEnd +{ + /// + /// An that constructs a task that a host registered through + /// (via Microsoft.Build.Utilities.Task.RegisterTask), with no + /// assembly loading or by-name type resolution. + /// + /// + /// The engine instantiates a registered task by calling directly, + /// which is reflection-free and avoids the interface member (that + /// member is [RequiresUnreferencedCode], so calling it would reintroduce a trim warning). The + /// this factory exposes was built at registration from the registered, trim-rooted + /// task type, so parameter discovery and binding stay trim-safe. + /// + internal sealed class RegisteredTaskFactory : ITaskFactory + { + /// + /// The registration that supplies the reflection-free constructor. + /// + private readonly TaskClassRegistration _registration; + + /// + /// The reflected type metadata, built from the registered task type at registration time. + /// + private readonly LoadedType _loadedType; + + internal RegisteredTaskFactory(TaskClassRegistration registration, LoadedType loadedType) + { + _registration = registration; + _loadedType = loadedType; + } + + /// + public string FactoryName => "Registered Task Factory"; + + /// + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] + public Type TaskType => _loadedType.Type; + + /// + /// Constructs a new instance of the registered task. Reflection-free: it invokes the registered + /// factory. The engine calls this instead of so no trim-unsafe interface + /// member is reached on the registered-task path. + /// + internal ITask CreateRegisteredTask() => _registration.CreateInstance(); + + /// + public TaskPropertyInfo[] GetTaskParameters() => _loadedType.Properties; + + /// + [RequiresUnreferencedCode("Task factories create tasks by reflecting over a task type discovered or generated at runtime, which is incompatible with trimming.")] + public bool Initialize(string taskName, IDictionary parameterGroup, string taskBody, IBuildEngine taskFactoryLoggingHost) => true; + + /// + [RequiresUnreferencedCode("Task factories create tasks by reflecting over a task type discovered or generated at runtime, which is incompatible with trimming.")] + public ITask CreateTask(IBuildEngine taskFactoryLoggingHost) => _registration.CreateInstance(); + + /// + public void CleanupTask(ITask task) + { + } + } +} diff --git a/src/Build/Instance/TaskRegistry.cs b/src/Build/Instance/TaskRegistry.cs index 70a6fb706c1..d3114ec80bf 100644 --- a/src/Build/Instance/TaskRegistry.cs +++ b/src/Build/Instance/TaskRegistry.cs @@ -5,6 +5,7 @@ using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; using System.Reflection; @@ -445,6 +446,7 @@ private static void RegisterTasksFromUsingTaskElement /// Given a task name, this method retrieves the task class. If the task has been requested before, it will be found in /// the class cache; otherwise, <UsingTask> declarations will be used to search the appropriate assemblies. /// + [RequiresUnreferencedCode("Creates and loads a task factory by reflecting over an assembly discovered at runtime, which is incompatible with trimming.")] internal TaskFactoryWrapper GetRegisteredTask( string taskName, string taskProjectFile, @@ -503,6 +505,7 @@ internal TaskFactoryWrapper GetRegisteredTask( /// True if the record was retrieved from the cache. /// Whether the build is running in multi-threaded mode. /// The task registration record, or null if none was found. + [RequiresUnreferencedCode("Creates and loads a task factory by reflecting over an assembly discovered at runtime, which is incompatible with trimming.")] internal RegisteredTaskRecord GetTaskRegistrationRecord( string taskName, string taskProjectFile, @@ -632,16 +635,6 @@ ConcurrentDictionary taskRecords return taskRecord; } - /// - /// Is the class being loaded a task factory class - /// - private static bool IsTaskFactoryClass(Type type, object unused) - { - return type.IsClass && - !type.IsAbstract && - typeof(Microsoft.Build.Framework.ITaskFactory).IsAssignableFrom(type); - } - /// /// Searches all task declarations for the given task name. /// If no exact match is found, looks for partial matches. @@ -753,6 +746,7 @@ private static Dictionary> Cr /// Given a task name and a list of records which may contain the task, this helper method will ask the records to see if the task name /// can be created by the factories which are wrapped by the records. (this is done by instantiating the task factory and asking it). /// + [RequiresUnreferencedCode("Creates and loads a task factory by reflecting over an assembly discovered at runtime, which is incompatible with trimming.")] private RegisteredTaskRecord GetMatchingRegistration( string taskName, IEnumerable taskRecords, @@ -1028,11 +1022,6 @@ internal class RegisteredTaskRecord : ITranslatable private const string UnhandledFactoryError = "\nThis is an unhandled exception from a task factory-- PLEASE OPEN A BUG AGAINST THE TASK FACTORY OWNER. "; #endif - /// - /// Type filter to make sure we only look for taskFactoryClasses - /// - private static readonly Func s_taskFactoryTypeFilter = IsTaskFactoryClass; - /// /// Lock object to ensure that only one thread can access the task factory type loader at a time. /// @@ -1281,6 +1270,7 @@ internal ParameterGroupAndTaskElementRecord ParameterGroupAndTaskBody /// loads an external file and uses that to generate the tasks. /// /// true if the task can be created by the factory, false if it cannot be created + [RequiresUnreferencedCode("Creates and loads a task factory by reflecting over an assembly discovered at runtime, which is incompatible with trimming.")] internal bool CanTaskBeCreatedByFactory(string taskName, string taskProjectFile, TaskHostParameters taskIdentityParameters, TargetLoggingContext targetLoggingContext, ElementLocation elementLocation, bool isMultiThreadedBuild) { // First check (fast path - no locking) @@ -1389,6 +1379,7 @@ internal bool CanTaskBeCreatedByFactory(string taskName, string taskProjectFile, /// Given a Registered task record and a task name. Check create an instance of the task factory using the record. /// If the factory is a assembly task factory see if the assemblyFile has the correct task inside of it. /// + [RequiresUnreferencedCode("Creates and loads a task factory by reflecting over an assembly discovered at runtime, which is incompatible with trimming.")] internal TaskFactoryWrapper GetTaskFactoryFromRegistrationRecord(string taskName, string taskProjectFile, in TaskHostParameters taskIdentityParameters, TargetLoggingContext targetLoggingContext, ElementLocation elementLocation, bool isMultiThreadedBuild) { if (CanTaskBeCreatedByFactory(taskName, taskProjectFile, taskIdentityParameters, targetLoggingContext, elementLocation, isMultiThreadedBuild)) @@ -1403,6 +1394,7 @@ internal TaskFactoryWrapper GetTaskFactoryFromRegistrationRecord(string taskName /// Create an instance of the task factory and load it from the assembly. /// /// If the task factory could not be properly created an InvalidProjectFileException will be thrown + [RequiresUnreferencedCode("Creates and loads a task factory by reflecting over an assembly discovered at runtime, which is incompatible with trimming.")] private bool GetTaskFactory(TargetLoggingContext targetLoggingContext, ElementLocation elementLocation, string taskProjectFile, bool isMultiThreadedBuild) { // see if we have already created the factory before, only create it once @@ -1456,7 +1448,7 @@ private bool GetTaskFactory(TargetLoggingContext targetLoggingContext, ElementLo { if (s_taskFactoryTypeLoader == null) { - s_taskFactoryTypeLoader = new TypeLoader(s_taskFactoryTypeFilter); + s_taskFactoryTypeLoader = TypeLoader.Create(); } } @@ -1633,6 +1625,20 @@ internal class ParameterGroupAndTaskElementRecord : ITranslatable /// private bool _taskBodyEvaluated; + /// + /// Registers the engine's concrete runtime type + /// () with the reflection-free + /// before the first <ParameterGroup> is + /// parsed, so a parameter declared as that type resolves without by-name reflection. Framework's + /// TaskItemData is pre-registered in the registry itself; the public + /// Microsoft.Build.Utilities.TaskItem is a higher-layer type a host registers through the + /// public API if it declares it (the engine does not reference Microsoft.Build.Utilities). + /// + static ParameterGroupAndTaskElementRecord() + { + TaskParameterTypeRegistry.RegisterTaskItemType(); + } + /// /// Create an empty ParameterGroupAndTaskElementRecord /// @@ -1726,6 +1732,30 @@ private void EvaluateTaskBody(Expander expander, ProjectUsingTaskBod } } + /// + /// Resolves a task parameter type from its declared name by reflecting over the loaded + /// assemblies. This is the fallback for names the reflection-free + /// does not know; it is reached only when the + /// switch is enabled, so a + /// trimmer that substitutes that switch to false removes this path from the image entirely. + /// + [RequiresUnreferencedCode("Resolves a task parameter type by name with Type.GetType; the type cannot be determined statically, so this path is unsupported under trimming.")] + private static Type ResolveParameterTypeByName(string expandedType) + { + if (expandedType.StartsWith("Microsoft.Build.Framework.", StringComparison.OrdinalIgnoreCase) && !expandedType.Contains(",")) + { + // This is workaround for internal bug https://devdiv.visualstudio.com/DevDiv/_workitems/edit/1448821 + // Visual Studio can load different version of Microsoft.Build.Framework.dll and non fully classified type could be resolved from it + // which cause InvalidProjectFileException with "UnsupportedTaskParameterTypeError" message. + // Another way to address this is to load types from compiled assembly - that would be more robust solution but also much more complex and risky code changes. + return Type.GetType($"{expandedType}," + typeof(ITaskItem).Assembly.FullName, false /* don't throw on error */, true /* case-insensitive */) ?? + Type.GetType(expandedType); + } + + return Type.GetType(expandedType) ?? + Type.GetType(expandedType + "," + typeof(ITaskItem).Assembly.FullName, false /* don't throw on error */, true /* case-insensitive */); + } + /// /// Convert the UsingTaskParameterGroupElement into a list of parameter names and UsingTaskParameters /// @@ -1753,20 +1783,20 @@ private void ParseUsingTaskParameterGroupElement(UsingTaskParameterGroupEl XMakeAttributes.parameterType, XMakeElements.usingTaskParameter); - Type paramType; - if (expandedType.StartsWith("Microsoft.Build.Framework.", StringComparison.OrdinalIgnoreCase) && !expandedType.Contains(",")) - { - // This is workaround for internal bug https://devdiv.visualstudio.com/DevDiv/_workitems/edit/1448821 - // Visual Studio can load different version of Microsoft.Build.Framework.dll and non fully classified type could be resolved from it - // which cause InvalidProjectFileException with "UnsupportedTaskParameterTypeError" message. - // Another way to address this is to load types from compiled assembly - that would be more robust solution but also much more complex and risky code changes. - paramType = Type.GetType(expandedType + "," + typeof(ITaskItem).Assembly.FullName, false /* don't throw on error */, true /* case-insensitive */) ?? - Type.GetType(expandedType); - } - else + // Always consult the reflection-free registry first: it knows the intrinsic value + // types, string, and the MSBuild ITaskItem types, plus any a host has registered, and + // resolves them with no reflection on both the JIT and trimmed paths. Only a name the + // registry does not know falls back to reflecting the type from its name, and only when + // EnableReflectiveTaskParameterTypes is on. In a trimmed/AOT image the switch is + // substituted false, the trimmer removes the fallback, and an unknown name fails + // observably at the VerifyThrowInvalidProject below. + Type paramType = TaskParameterTypeRegistry.TryGetType(expandedType); + if (paramType == null) { - paramType = Type.GetType(expandedType) ?? - Type.GetType(expandedType + "," + typeof(ITaskItem).Assembly.FullName, false /* don't throw on error */, true /* case-insensitive */); + if (FeatureSwitches.EnableReflectiveTaskParameterTypes) + { + paramType = ResolveParameterTypeByName(expandedType); + } } ProjectErrorUtilities.VerifyThrowInvalidProject( @@ -1837,6 +1867,8 @@ private static void TranslatorForTaskParametersKey(ITranslator translator, ref s } // todo move to nested function after C# 7 + [UnconditionalSuppressMessage("Trimming", "IL2057:UnrecognizedReflectionPattern", + Justification = "Resolves a task parameter type from its serialized assembly-qualified name; the type cannot be statically determined and this path is unsupported under trimming.")] private static void TranslatorForTaskParameterValue(ITranslator translator, ref TaskPropertyInfo taskPropertyInfo) { string name = null; diff --git a/src/Build/Logging/LoggerDescription.cs b/src/Build/Logging/LoggerDescription.cs index 64a3f51c784..9611de4f098 100644 --- a/src/Build/Logging/LoggerDescription.cs +++ b/src/Build/Logging/LoggerDescription.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using System.Diagnostics.CodeAnalysis; using System.IO; using System.Reflection; using Microsoft.Build.BackEnd; @@ -153,6 +154,7 @@ public LoggerVerbosity Verbosity /// exceptions if desired. /// /// + [RequiresUnreferencedCode("Loads and instantiates a forwarding logger type by reflecting over an assembly discovered at runtime, which is incompatible with trimming.")] internal IForwardingLogger CreateForwardingLogger() { IForwardingLogger forwardingLogger = null; @@ -181,6 +183,7 @@ internal IForwardingLogger CreateForwardingLogger() /// exceptions if desired. /// /// + [RequiresUnreferencedCode("Loads and instantiates a logger type by reflecting over an assembly discovered at runtime, which is incompatible with trimming.")] public ILogger CreateLogger() { return CreateLogger(false); @@ -190,6 +193,7 @@ public ILogger CreateLogger() /// Loads a logger from its assembly, instantiates it, and handles errors. /// /// Instantiated logger. + [RequiresUnreferencedCode("Loads and instantiates a logger type by reflecting over an assembly discovered at runtime, which is incompatible with trimming.")] private ILogger CreateLogger(bool forwardingLogger) { ILogger logger = null; @@ -199,7 +203,7 @@ private ILogger CreateLogger(bool forwardingLogger) if (forwardingLogger) { // load the logger from its assembly - LoadedType loggerClass = (new TypeLoader(s_forwardingLoggerClassFilter)).Load(_loggerClassName, _loggerAssembly, logWarning: (format, args) => { }); + LoadedType loggerClass = TypeLoader.Create().Load(_loggerClassName, _loggerAssembly, logWarning: (format, args) => { }); if (loggerClass != null) { @@ -210,7 +214,7 @@ private ILogger CreateLogger(bool forwardingLogger) else { // load the logger from its assembly - LoadedType loggerClass = (new TypeLoader(s_loggerClassFilter)).Load(_loggerClassName, _loggerAssembly, logWarning: (format, args) => { }); + LoadedType loggerClass = TypeLoader.Create().Load(_loggerClassName, _loggerAssembly, logWarning: (format, args) => { }); if (loggerClass != null) { @@ -239,40 +243,6 @@ private ILogger CreateLogger(bool forwardingLogger) return logger; } - /// - /// Used for finding loggers when reflecting through assemblies. - /// - private static readonly Func s_forwardingLoggerClassFilter = IsForwardingLoggerClass; - - /// - /// Used for finding loggers when reflecting through assemblies. - /// - private static readonly Func s_loggerClassFilter = IsLoggerClass; - - /// - /// Checks if the given type is a logger class. - /// - /// This method is used as a Type Filter delegate. - /// true, if specified type is a logger - private static bool IsForwardingLoggerClass(Type type, object unused) - { - return type.IsClass && - !type.IsAbstract && - (type.GetInterface("IForwardingLogger") != null); - } - - /// - /// Checks if the given type is a logger class. - /// - /// This method is used as a TypeFilter delegate. - /// true, if specified type is a logger - private static bool IsLoggerClass(Type type, object unused) - { - return type.IsClass && - !type.IsAbstract && - (type.GetInterface("ILogger") != null); - } - /// /// Converts the path to the logger assembly to a full path /// diff --git a/src/Build/Microsoft.Build.csproj b/src/Build/Microsoft.Build.csproj index 83a3d450ece..059b491fb24 100644 --- a/src/Build/Microsoft.Build.csproj +++ b/src/Build/Microsoft.Build.csproj @@ -12,6 +12,10 @@ false + + true + true @@ -40,25 +44,6 @@ - - - - - - - @@ -219,7 +204,6 @@ - @@ -597,6 +581,7 @@ + diff --git a/src/Build/ObjectModelRemoting/DefinitionObjectsLinks/ProjectLink.cs b/src/Build/ObjectModelRemoting/DefinitionObjectsLinks/ProjectLink.cs index d13f83f817e..c0dbcc30542 100644 --- a/src/Build/ObjectModelRemoting/DefinitionObjectsLinks/ProjectLink.cs +++ b/src/Build/ObjectModelRemoting/DefinitionObjectsLinks/ProjectLink.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.IO; using Microsoft.Build.Construction; using Microsoft.Build.Evaluation; @@ -251,6 +252,7 @@ public abstract class ProjectLink /// /// Facilitate support for remote build. /// + [RequiresUnreferencedCode("Initializes loggers and project cache plugins by reflecting over assemblies discovered at runtime, which is incompatible with trimming.")] public abstract bool Build(string[] targets, IEnumerable loggers, IEnumerable remoteLoggers, EvaluationContext evaluationContext); /// diff --git a/src/Build/Resources/Constants.cs b/src/Build/Resources/Constants.cs index fd7ffbd2b10..4df9171444e 100644 --- a/src/Build/Resources/Constants.cs +++ b/src/Build/Resources/Constants.cs @@ -278,8 +278,30 @@ internal static void Reset_ForUnitTestsOnly() [DynamicDependency(PropertyFunctionMembers, typeof(UriBuilder))] [DynamicDependency(PropertyFunctionMembers, typeof(Version))] #if NET + // ToolLocationHelper lives in Microsoft.Build.Utilities.Core in the SDK, which Microsoft.Build does not + // directly reference, so it cannot be named with typeof here like the entries above. Root it with the + // (memberTypes, typeName, assemblyName) string overload instead - it is otherwise an ordinary allowlist + // entry (see the TryAdd for it below). That overload (and trimming itself) exists only on .NET, so this + // entry is guarded for the .NET build; the allowlist still includes the type at run time on .NET Framework. + [DynamicDependency(PropertyFunctionMembers, "Microsoft.Build.Utilities.ToolLocationHelper", "Microsoft.Build.Utilities.Core")] [DynamicDependency(PropertyFunctionMembers, typeof(OperatingSystem))] #endif + // The DynamicDependency allowlist above preserves each property-function receiver type's public + // surface so trimming keeps it. Across all of those types the only member carrying + // [RequiresDynamicCode] is Enum.GetValues(Type) (rooted by typeof(Enum)) - this is the IL3050 + // suppressed below. + // + // It is rooted but unreachable from a property function: an author cannot invoke Enum.GetValues(Type) + // (or any reflective Type-taking method) because there is no way to supply a System.Type argument. + // - string does not coerce to Type, so the overload never binds and evaluation reports MSB4186 + // ("method not found ... parameters of the correct type"). + // - [System.Type]::GetType(...) is not an available property function (MSB4185), and stays + // unavailable even with MSBUILDENABLEALLPROPERTYFUNCTIONS=1. + // So the case is blocked before any reflective invoke, identically on JIT and AOT, and would still + // fail observably (InvalidProjectFileException) if it were ever reached - never silently. This is + // verified end to end under Native AOT by src/aot-validation/PropertyFunctionAotTests.cs. + [UnconditionalSuppressMessage("AOT", "IL3050:RequiresDynamicCode", + Justification = "Enum.GetValues(Type) is rooted for the allowlist but is unreachable via property functions; see comment above.")] private static void InitializeAvailableMethods() { if (s_availableStaticMethods == null) diff --git a/src/Build/Resources/Strings.resx b/src/Build/Resources/Strings.resx index 8b5451576fe..2a542f59adc 100644 --- a/src/Build/Resources/Strings.resx +++ b/src/Build/Resources/Strings.resx @@ -1337,6 +1337,22 @@ MSB4236: The SDK '{0}' specified could not be found. {StrBegin="MSB4236: "} + + MSB4282: The SDK "{0}" could not be resolved because it requires an SDK resolver ("{1}") that must be loaded dynamically, which is not supported in this trimmed or Native AOT host. SDKs available in the SDK installation are supported; build with a JIT-based MSBuild to use NuGet, workload, or custom SDK resolvers. + {StrBegin="MSB4282: "}{0} is the SDK name; {1} is the SDK resolver manifest display name. + + + MSB4283: The "{0}" task could not be run because executing tasks requires loading task assemblies and types by reflection, which is not supported in this trimmed or Native AOT host. Build with a JIT-based MSBuild to run tasks. + {StrBegin="MSB4283: "}{0} is the task name. + + + MSB4284: The custom build check "{0}" could not be loaded because this MSBuild host has custom plugin probing disabled, for example in a trimmed or Native AOT build, and cannot load check assemblies by reflection. Build with a JIT-based MSBuild to run custom checks. + {StrBegin="MSB4284: "}{0} is the path to the custom build check assembly. + + + MSB4285: A logger could not be created from its assembly and class name because loading loggers by reflection is not supported in this trimmed or Native AOT host. Provide the logger to the engine as a constructed ILogger instance, or build with a JIT-based MSBuild. + {StrBegin="MSB4285: "} + MSB4237: The SDK resolver type "{0}" failed to load. {1} {StrBegin="MSB4237: "} @@ -1628,6 +1644,9 @@ Utilization: {0} Average Utilization: {1:###.0} Method {0} cannot be called with a collection containing null or empty target names. + + Reading toolset definitions from the application configuration file (ToolsetDefinitionLocations.ConfigurationFile) is not supported in this trimmed or Native AOT host. Remove ConfigurationFile from the requested toolset locations, or run a JIT-based MSBuild. + An <Otherwise> element cannot be located before a <When> or <Otherwise> element. diff --git a/src/Build/Resources/xlf/Strings.cs.xlf b/src/Build/Resources/xlf/Strings.cs.xlf index 8f3b478d011..729a2cbe004 100644 --- a/src/Build/Resources/xlf/Strings.cs.xlf +++ b/src/Build/Resources/xlf/Strings.cs.xlf @@ -141,6 +141,11 @@ Zápis není podporován. + + MSB4284: The custom build check "{0}" could not be loaded because this MSBuild host has custom plugin probing disabled, for example in a trimmed or Native AOT build, and cannot load check assemblies by reflection. Build with a JIT-based MSBuild to run custom checks. + MSB4284: The custom build check "{0}" could not be loaded because this MSBuild host has custom plugin probing disabled, for example in a trimmed or Native AOT build, and cannot load check assemblies by reflection. Build with a JIT-based MSBuild to run custom checks. + {StrBegin="MSB4284: "}{0} is the path to the custom build check assembly. + The BuildCheck is enabled for this build. Pro tento build je povolena funkce BuildCheck. @@ -689,6 +694,11 @@ LOCALIZATION: Do not localize the following words: ProjectInstanceFactoryFunc. + + Reading toolset definitions from the application configuration file (ToolsetDefinitionLocations.ConfigurationFile) is not supported in this trimmed or Native AOT host. Remove ConfigurationFile from the requested toolset locations, or run a JIT-based MSBuild. + Reading toolset definitions from the application configuration file (ToolsetDefinitionLocations.ConfigurationFile) is not supported in this trimmed or Native AOT host. Remove ConfigurationFile from the requested toolset locations, or run a JIT-based MSBuild. + + Only item types may be referenced when removing with MatchOnMetadata. Při odebírání pomocí MatchOnMetadata je možné odkazovat jen na typy položek. @@ -908,6 +918,16 @@ MSB4274: Zakázání uzlu inproc způsobí snížení výkonu při používání modulů plug-in mezipaměti projektu, které vysílají žádosti o sestavení proxy serveru. + + MSB4285: A logger could not be created from its assembly and class name because loading loggers by reflection is not supported in this trimmed or Native AOT host. Provide the logger to the engine as a constructed ILogger instance, or build with a JIT-based MSBuild. + MSB4285: A logger could not be created from its assembly and class name because loading loggers by reflection is not supported in this trimmed or Native AOT host. Provide the logger to the engine as a constructed ILogger instance, or build with a JIT-based MSBuild. + {StrBegin="MSB4285: "} + + + MSB4283: The "{0}" task could not be run because executing tasks requires loading task assemblies and types by reflection, which is not supported in this trimmed or Native AOT host. Build with a JIT-based MSBuild to run tasks. + MSB4283: The "{0}" task could not be run because executing tasks requires loading task assemblies and types by reflection, which is not supported in this trimmed or Native AOT host. Build with a JIT-based MSBuild to run tasks. + {StrBegin="MSB4283: "}{0} is the task name. + Reporting file accesses is only currently supported using the x64 flavor of MSBuild. Přístupy k souborům sestav se v současné době podporují jenom pomocí varianty x64 nástroje MSBuild. @@ -989,6 +1009,11 @@ Chyby: {3} Sada SDK nastavila proměnnou prostředí {0} na hodnotu {1}, která přepíše hodnotu {2} zděděnou z procesu. + + MSB4282: The SDK "{0}" could not be resolved because it requires an SDK resolver ("{1}") that must be loaded dynamically, which is not supported in this trimmed or Native AOT host. SDKs available in the SDK installation are supported; build with a JIT-based MSBuild to use NuGet, workload, or custom SDK resolvers. + MSB4282: The SDK "{0}" could not be resolved because it requires an SDK resolver ("{1}") that must be loaded dynamically, which is not supported in this trimmed or Native AOT host. SDKs available in the SDK installation are supported; build with a JIT-based MSBuild to use NuGet, workload, or custom SDK resolvers. + {StrBegin="MSB4282: "}{0} is the SDK name; {1} is the SDK resolver manifest display name. + SDK '{0}' could not be resolved by the SDK resolver '{1}'. {2} Sadu SDK {0} se nepodařilo vyřešit pomocí překladače sady SDK {1}. {2} diff --git a/src/Build/Resources/xlf/Strings.de.xlf b/src/Build/Resources/xlf/Strings.de.xlf index 958033d7c13..434f9c4bd37 100644 --- a/src/Build/Resources/xlf/Strings.de.xlf +++ b/src/Build/Resources/xlf/Strings.de.xlf @@ -141,6 +141,11 @@ Schreibvorgänge werden nicht unterstützt. + + MSB4284: The custom build check "{0}" could not be loaded because this MSBuild host has custom plugin probing disabled, for example in a trimmed or Native AOT build, and cannot load check assemblies by reflection. Build with a JIT-based MSBuild to run custom checks. + MSB4284: The custom build check "{0}" could not be loaded because this MSBuild host has custom plugin probing disabled, for example in a trimmed or Native AOT build, and cannot load check assemblies by reflection. Build with a JIT-based MSBuild to run custom checks. + {StrBegin="MSB4284: "}{0} is the path to the custom build check assembly. + The BuildCheck is enabled for this build. BuildCheck ist für diesen Build aktiviert. @@ -689,6 +694,11 @@ LOCALIZATION: Do not localize the following words: ProjectInstanceFactoryFunc. + + Reading toolset definitions from the application configuration file (ToolsetDefinitionLocations.ConfigurationFile) is not supported in this trimmed or Native AOT host. Remove ConfigurationFile from the requested toolset locations, or run a JIT-based MSBuild. + Reading toolset definitions from the application configuration file (ToolsetDefinitionLocations.ConfigurationFile) is not supported in this trimmed or Native AOT host. Remove ConfigurationFile from the requested toolset locations, or run a JIT-based MSBuild. + + Only item types may be referenced when removing with MatchOnMetadata. Beim Entfernen mit "MatchOnMetadata" können nur Elementtypen referenziert werden. @@ -908,6 +918,16 @@ MSB4274: Das Deaktivieren des In-Process-Knotens führt zu Leistungseinbußen bei der Verwendung von Projektcache-Plug-Ins, die Proxybuildanforderungen ausgeben. + + MSB4285: A logger could not be created from its assembly and class name because loading loggers by reflection is not supported in this trimmed or Native AOT host. Provide the logger to the engine as a constructed ILogger instance, or build with a JIT-based MSBuild. + MSB4285: A logger could not be created from its assembly and class name because loading loggers by reflection is not supported in this trimmed or Native AOT host. Provide the logger to the engine as a constructed ILogger instance, or build with a JIT-based MSBuild. + {StrBegin="MSB4285: "} + + + MSB4283: The "{0}" task could not be run because executing tasks requires loading task assemblies and types by reflection, which is not supported in this trimmed or Native AOT host. Build with a JIT-based MSBuild to run tasks. + MSB4283: The "{0}" task could not be run because executing tasks requires loading task assemblies and types by reflection, which is not supported in this trimmed or Native AOT host. Build with a JIT-based MSBuild to run tasks. + {StrBegin="MSB4283: "}{0} is the task name. + Reporting file accesses is only currently supported using the x64 flavor of MSBuild. Das Melden von Dateizugriffen wird derzeit nur mit der x64-Variante von MSBuild unterstützt. @@ -989,6 +1009,11 @@ Fehler: {3} Ein SDK legte die Umgebungsvariable „{0}“ auf „{1}“ fest und überschrieb den Wert „{2}“, der vom Prozess geerbt wurde. + + MSB4282: The SDK "{0}" could not be resolved because it requires an SDK resolver ("{1}") that must be loaded dynamically, which is not supported in this trimmed or Native AOT host. SDKs available in the SDK installation are supported; build with a JIT-based MSBuild to use NuGet, workload, or custom SDK resolvers. + MSB4282: The SDK "{0}" could not be resolved because it requires an SDK resolver ("{1}") that must be loaded dynamically, which is not supported in this trimmed or Native AOT host. SDKs available in the SDK installation are supported; build with a JIT-based MSBuild to use NuGet, workload, or custom SDK resolvers. + {StrBegin="MSB4282: "}{0} is the SDK name; {1} is the SDK resolver manifest display name. + SDK '{0}' could not be resolved by the SDK resolver '{1}'. {2} SDK „{0}“ konnte vom SDK-Resolver „{1}“ nicht aufgelöst werden. {2} diff --git a/src/Build/Resources/xlf/Strings.es.xlf b/src/Build/Resources/xlf/Strings.es.xlf index de663988746..795e7844be0 100644 --- a/src/Build/Resources/xlf/Strings.es.xlf +++ b/src/Build/Resources/xlf/Strings.es.xlf @@ -141,6 +141,11 @@ No se admite la escritura. + + MSB4284: The custom build check "{0}" could not be loaded because this MSBuild host has custom plugin probing disabled, for example in a trimmed or Native AOT build, and cannot load check assemblies by reflection. Build with a JIT-based MSBuild to run custom checks. + MSB4284: The custom build check "{0}" could not be loaded because this MSBuild host has custom plugin probing disabled, for example in a trimmed or Native AOT build, and cannot load check assemblies by reflection. Build with a JIT-based MSBuild to run custom checks. + {StrBegin="MSB4284: "}{0} is the path to the custom build check assembly. + The BuildCheck is enabled for this build. BuildCheck está habilitado para esta compilación. @@ -689,6 +694,11 @@ LOCALIZATION: Do not localize the following words: ProjectInstanceFactoryFunc. + + Reading toolset definitions from the application configuration file (ToolsetDefinitionLocations.ConfigurationFile) is not supported in this trimmed or Native AOT host. Remove ConfigurationFile from the requested toolset locations, or run a JIT-based MSBuild. + Reading toolset definitions from the application configuration file (ToolsetDefinitionLocations.ConfigurationFile) is not supported in this trimmed or Native AOT host. Remove ConfigurationFile from the requested toolset locations, or run a JIT-based MSBuild. + + Only item types may be referenced when removing with MatchOnMetadata. Solo se puede hacer referencia a los tipos de elemento al quitar con MatchOnMetadata. @@ -908,6 +918,16 @@ MSB4274: Al deshabilitar el nodo InProc, se degrada el rendimiento cuando use los complementos de caché de proyectos que emiten solicitudes de compilación de proxy. + + MSB4285: A logger could not be created from its assembly and class name because loading loggers by reflection is not supported in this trimmed or Native AOT host. Provide the logger to the engine as a constructed ILogger instance, or build with a JIT-based MSBuild. + MSB4285: A logger could not be created from its assembly and class name because loading loggers by reflection is not supported in this trimmed or Native AOT host. Provide the logger to the engine as a constructed ILogger instance, or build with a JIT-based MSBuild. + {StrBegin="MSB4285: "} + + + MSB4283: The "{0}" task could not be run because executing tasks requires loading task assemblies and types by reflection, which is not supported in this trimmed or Native AOT host. Build with a JIT-based MSBuild to run tasks. + MSB4283: The "{0}" task could not be run because executing tasks requires loading task assemblies and types by reflection, which is not supported in this trimmed or Native AOT host. Build with a JIT-based MSBuild to run tasks. + {StrBegin="MSB4283: "}{0} is the task name. + Reporting file accesses is only currently supported using the x64 flavor of MSBuild. Los accesos a archivos de informes solo se admiten actualmente con el tipo x64 de MSBuild. @@ -989,6 +1009,11 @@ Errores: {3} Un SDK establece la variable de entorno "{0}" en "{1}", reemplazando el valor "{2}" heredado del proceso. + + MSB4282: The SDK "{0}" could not be resolved because it requires an SDK resolver ("{1}") that must be loaded dynamically, which is not supported in this trimmed or Native AOT host. SDKs available in the SDK installation are supported; build with a JIT-based MSBuild to use NuGet, workload, or custom SDK resolvers. + MSB4282: The SDK "{0}" could not be resolved because it requires an SDK resolver ("{1}") that must be loaded dynamically, which is not supported in this trimmed or Native AOT host. SDKs available in the SDK installation are supported; build with a JIT-based MSBuild to use NuGet, workload, or custom SDK resolvers. + {StrBegin="MSB4282: "}{0} is the SDK name; {1} is the SDK resolver manifest display name. + SDK '{0}' could not be resolved by the SDK resolver '{1}'. {2} El SDK '{0}' no se pudo resolver mediante la resolución de SDK '{1}'. {2} diff --git a/src/Build/Resources/xlf/Strings.fr.xlf b/src/Build/Resources/xlf/Strings.fr.xlf index 73d32983168..7da50ed1690 100644 --- a/src/Build/Resources/xlf/Strings.fr.xlf +++ b/src/Build/Resources/xlf/Strings.fr.xlf @@ -141,6 +141,11 @@ L’écriture n’est pas recommandée. + + MSB4284: The custom build check "{0}" could not be loaded because this MSBuild host has custom plugin probing disabled, for example in a trimmed or Native AOT build, and cannot load check assemblies by reflection. Build with a JIT-based MSBuild to run custom checks. + MSB4284: The custom build check "{0}" could not be loaded because this MSBuild host has custom plugin probing disabled, for example in a trimmed or Native AOT build, and cannot load check assemblies by reflection. Build with a JIT-based MSBuild to run custom checks. + {StrBegin="MSB4284: "}{0} is the path to the custom build check assembly. + The BuildCheck is enabled for this build. BuildCheck est activé pour cette build. @@ -689,6 +694,11 @@ LOCALIZATION: Do not localize the following words: ProjectInstanceFactoryFunc. + + Reading toolset definitions from the application configuration file (ToolsetDefinitionLocations.ConfigurationFile) is not supported in this trimmed or Native AOT host. Remove ConfigurationFile from the requested toolset locations, or run a JIT-based MSBuild. + Reading toolset definitions from the application configuration file (ToolsetDefinitionLocations.ConfigurationFile) is not supported in this trimmed or Native AOT host. Remove ConfigurationFile from the requested toolset locations, or run a JIT-based MSBuild. + + Only item types may be referenced when removing with MatchOnMetadata. Seuls les types d'élément peuvent être référencés pour la suppression à l'aide de MatchOnMetadata. @@ -908,6 +918,16 @@ MSB4274: la désactivation du nœud inproc entraîne une détérioration des performances lors de l’utilisation de plug-ins de cache de projet qui émettent des requêtes de build proxy. + + MSB4285: A logger could not be created from its assembly and class name because loading loggers by reflection is not supported in this trimmed or Native AOT host. Provide the logger to the engine as a constructed ILogger instance, or build with a JIT-based MSBuild. + MSB4285: A logger could not be created from its assembly and class name because loading loggers by reflection is not supported in this trimmed or Native AOT host. Provide the logger to the engine as a constructed ILogger instance, or build with a JIT-based MSBuild. + {StrBegin="MSB4285: "} + + + MSB4283: The "{0}" task could not be run because executing tasks requires loading task assemblies and types by reflection, which is not supported in this trimmed or Native AOT host. Build with a JIT-based MSBuild to run tasks. + MSB4283: The "{0}" task could not be run because executing tasks requires loading task assemblies and types by reflection, which is not supported in this trimmed or Native AOT host. Build with a JIT-based MSBuild to run tasks. + {StrBegin="MSB4283: "}{0} is the task name. + Reporting file accesses is only currently supported using the x64 flavor of MSBuild. Les accès aux fichiers de création de rapports sont uniquement pris en charge à l’aide de la saveur x64 de MSBuild. @@ -989,6 +1009,11 @@ Erreurs : {3} Un kit de développement logiciel (SDK) a défini la variable d’environnement « {0} » sur « {1} », remplaçant la valeur « {2} » héritée du processus. + + MSB4282: The SDK "{0}" could not be resolved because it requires an SDK resolver ("{1}") that must be loaded dynamically, which is not supported in this trimmed or Native AOT host. SDKs available in the SDK installation are supported; build with a JIT-based MSBuild to use NuGet, workload, or custom SDK resolvers. + MSB4282: The SDK "{0}" could not be resolved because it requires an SDK resolver ("{1}") that must be loaded dynamically, which is not supported in this trimmed or Native AOT host. SDKs available in the SDK installation are supported; build with a JIT-based MSBuild to use NuGet, workload, or custom SDK resolvers. + {StrBegin="MSB4282: "}{0} is the SDK name; {1} is the SDK resolver manifest display name. + SDK '{0}' could not be resolved by the SDK resolver '{1}'. {2} Le Kit de développement logiciel (SDK) « {0} » n’a pas pu être résolu par le résolveur de SDK « {1} ». {2} diff --git a/src/Build/Resources/xlf/Strings.it.xlf b/src/Build/Resources/xlf/Strings.it.xlf index 1ac9bb8b66c..ee9b16b8cf6 100644 --- a/src/Build/Resources/xlf/Strings.it.xlf +++ b/src/Build/Resources/xlf/Strings.it.xlf @@ -141,6 +141,11 @@ La scrittura non è supportata. + + MSB4284: The custom build check "{0}" could not be loaded because this MSBuild host has custom plugin probing disabled, for example in a trimmed or Native AOT build, and cannot load check assemblies by reflection. Build with a JIT-based MSBuild to run custom checks. + MSB4284: The custom build check "{0}" could not be loaded because this MSBuild host has custom plugin probing disabled, for example in a trimmed or Native AOT build, and cannot load check assemblies by reflection. Build with a JIT-based MSBuild to run custom checks. + {StrBegin="MSB4284: "}{0} is the path to the custom build check assembly. + The BuildCheck is enabled for this build. BuildCheck è abilitato per questa compilazione. @@ -689,6 +694,11 @@ LOCALIZATION: Do not localize the following words: ProjectInstanceFactoryFunc. + + Reading toolset definitions from the application configuration file (ToolsetDefinitionLocations.ConfigurationFile) is not supported in this trimmed or Native AOT host. Remove ConfigurationFile from the requested toolset locations, or run a JIT-based MSBuild. + Reading toolset definitions from the application configuration file (ToolsetDefinitionLocations.ConfigurationFile) is not supported in this trimmed or Native AOT host. Remove ConfigurationFile from the requested toolset locations, or run a JIT-based MSBuild. + + Only item types may be referenced when removing with MatchOnMetadata. Durante la rimozione con MatchOnMetadata è possibile fare riferimento a tipi di elemento. @@ -908,6 +918,16 @@ MSB4274: la disabilitazione del nodo InProc porta a una riduzione del livello delle prestazioni quando si usano plug-in della cache del progetto che emettono richieste di compilazione proxy. + + MSB4285: A logger could not be created from its assembly and class name because loading loggers by reflection is not supported in this trimmed or Native AOT host. Provide the logger to the engine as a constructed ILogger instance, or build with a JIT-based MSBuild. + MSB4285: A logger could not be created from its assembly and class name because loading loggers by reflection is not supported in this trimmed or Native AOT host. Provide the logger to the engine as a constructed ILogger instance, or build with a JIT-based MSBuild. + {StrBegin="MSB4285: "} + + + MSB4283: The "{0}" task could not be run because executing tasks requires loading task assemblies and types by reflection, which is not supported in this trimmed or Native AOT host. Build with a JIT-based MSBuild to run tasks. + MSB4283: The "{0}" task could not be run because executing tasks requires loading task assemblies and types by reflection, which is not supported in this trimmed or Native AOT host. Build with a JIT-based MSBuild to run tasks. + {StrBegin="MSB4283: "}{0} is the task name. + Reporting file accesses is only currently supported using the x64 flavor of MSBuild. Gli accessi ai file di report sono attualmente supportati solo con la versione x64 di MSBuild. @@ -989,6 +1009,11 @@ Errori: {3} Un SDK ha impostato la variabile d'ambiente "{0}" su "{1}", eseguendo l'override del valore "{2}" ereditato dal processo. + + MSB4282: The SDK "{0}" could not be resolved because it requires an SDK resolver ("{1}") that must be loaded dynamically, which is not supported in this trimmed or Native AOT host. SDKs available in the SDK installation are supported; build with a JIT-based MSBuild to use NuGet, workload, or custom SDK resolvers. + MSB4282: The SDK "{0}" could not be resolved because it requires an SDK resolver ("{1}") that must be loaded dynamically, which is not supported in this trimmed or Native AOT host. SDKs available in the SDK installation are supported; build with a JIT-based MSBuild to use NuGet, workload, or custom SDK resolvers. + {StrBegin="MSB4282: "}{0} is the SDK name; {1} is the SDK resolver manifest display name. + SDK '{0}' could not be resolved by the SDK resolver '{1}'. {2} Il resolver SDK '{0}' non è riuscito a risolvere l'SDK '{1}'. {2} diff --git a/src/Build/Resources/xlf/Strings.ja.xlf b/src/Build/Resources/xlf/Strings.ja.xlf index 732e6c75e39..8dfb4ea14e2 100644 --- a/src/Build/Resources/xlf/Strings.ja.xlf +++ b/src/Build/Resources/xlf/Strings.ja.xlf @@ -141,6 +141,11 @@ 書き込みはサポートされていません。 + + MSB4284: The custom build check "{0}" could not be loaded because this MSBuild host has custom plugin probing disabled, for example in a trimmed or Native AOT build, and cannot load check assemblies by reflection. Build with a JIT-based MSBuild to run custom checks. + MSB4284: The custom build check "{0}" could not be loaded because this MSBuild host has custom plugin probing disabled, for example in a trimmed or Native AOT build, and cannot load check assemblies by reflection. Build with a JIT-based MSBuild to run custom checks. + {StrBegin="MSB4284: "}{0} is the path to the custom build check assembly. + The BuildCheck is enabled for this build. BuildCheck は、このビルドに対して有効になっています。 @@ -689,6 +694,11 @@ LOCALIZATION: Do not localize the following words: ProjectInstanceFactoryFunc. + + Reading toolset definitions from the application configuration file (ToolsetDefinitionLocations.ConfigurationFile) is not supported in this trimmed or Native AOT host. Remove ConfigurationFile from the requested toolset locations, or run a JIT-based MSBuild. + Reading toolset definitions from the application configuration file (ToolsetDefinitionLocations.ConfigurationFile) is not supported in this trimmed or Native AOT host. Remove ConfigurationFile from the requested toolset locations, or run a JIT-based MSBuild. + + Only item types may be referenced when removing with MatchOnMetadata. MatchOnMetadata で削除する場合、参照できるのは項目の種類のみです。 @@ -908,6 +918,16 @@ MSB4274: プロキシ・ビルド要求を出すプロジェクト キャッシュ プラグインを使用する場合、InProc ノードを無効にするとパフォーマンスが低下します。 + + MSB4285: A logger could not be created from its assembly and class name because loading loggers by reflection is not supported in this trimmed or Native AOT host. Provide the logger to the engine as a constructed ILogger instance, or build with a JIT-based MSBuild. + MSB4285: A logger could not be created from its assembly and class name because loading loggers by reflection is not supported in this trimmed or Native AOT host. Provide the logger to the engine as a constructed ILogger instance, or build with a JIT-based MSBuild. + {StrBegin="MSB4285: "} + + + MSB4283: The "{0}" task could not be run because executing tasks requires loading task assemblies and types by reflection, which is not supported in this trimmed or Native AOT host. Build with a JIT-based MSBuild to run tasks. + MSB4283: The "{0}" task could not be run because executing tasks requires loading task assemblies and types by reflection, which is not supported in this trimmed or Native AOT host. Build with a JIT-based MSBuild to run tasks. + {StrBegin="MSB4283: "}{0} is the task name. + Reporting file accesses is only currently supported using the x64 flavor of MSBuild. ファイル アクセスのレポートは、現在、MSBuild の x64 フレーバーを使用してのみサポートされています。 @@ -989,6 +1009,11 @@ Errors: {3} SDK によって環境変数 "{0}" が "{1}" に設定され、プロセスから継承された値 "{2}" が上書きされました。 + + MSB4282: The SDK "{0}" could not be resolved because it requires an SDK resolver ("{1}") that must be loaded dynamically, which is not supported in this trimmed or Native AOT host. SDKs available in the SDK installation are supported; build with a JIT-based MSBuild to use NuGet, workload, or custom SDK resolvers. + MSB4282: The SDK "{0}" could not be resolved because it requires an SDK resolver ("{1}") that must be loaded dynamically, which is not supported in this trimmed or Native AOT host. SDKs available in the SDK installation are supported; build with a JIT-based MSBuild to use NuGet, workload, or custom SDK resolvers. + {StrBegin="MSB4282: "}{0} is the SDK name; {1} is the SDK resolver manifest display name. + SDK '{0}' could not be resolved by the SDK resolver '{1}'. {2} SDK '{0}' を SDK リゾルバー '{1}' で解決できませんでした。{2} diff --git a/src/Build/Resources/xlf/Strings.ko.xlf b/src/Build/Resources/xlf/Strings.ko.xlf index a7157350dd0..ed4c528d256 100644 --- a/src/Build/Resources/xlf/Strings.ko.xlf +++ b/src/Build/Resources/xlf/Strings.ko.xlf @@ -141,6 +141,11 @@ 쓰기는 지원되지 않습니다. + + MSB4284: The custom build check "{0}" could not be loaded because this MSBuild host has custom plugin probing disabled, for example in a trimmed or Native AOT build, and cannot load check assemblies by reflection. Build with a JIT-based MSBuild to run custom checks. + MSB4284: The custom build check "{0}" could not be loaded because this MSBuild host has custom plugin probing disabled, for example in a trimmed or Native AOT build, and cannot load check assemblies by reflection. Build with a JIT-based MSBuild to run custom checks. + {StrBegin="MSB4284: "}{0} is the path to the custom build check assembly. + The BuildCheck is enabled for this build. 이 빌드에 대해 BuildCheck를 사용할 수 있습니다. @@ -689,6 +694,11 @@ LOCALIZATION: Do not localize the following words: ProjectInstanceFactoryFunc. + + Reading toolset definitions from the application configuration file (ToolsetDefinitionLocations.ConfigurationFile) is not supported in this trimmed or Native AOT host. Remove ConfigurationFile from the requested toolset locations, or run a JIT-based MSBuild. + Reading toolset definitions from the application configuration file (ToolsetDefinitionLocations.ConfigurationFile) is not supported in this trimmed or Native AOT host. Remove ConfigurationFile from the requested toolset locations, or run a JIT-based MSBuild. + + Only item types may be referenced when removing with MatchOnMetadata. MatchOnMetadata를 사용하여 제거하는 경우 항목 종류를 하나만 참조할 수 있습니다. @@ -908,6 +918,16 @@ MSB4274: 프록시 빌드 요청을 내보내는 프로젝트 캐시 플러그 인을 사용할 때 inproc 노드를 사용하지 않도록 설정하면 성능이 저하됩니다. + + MSB4285: A logger could not be created from its assembly and class name because loading loggers by reflection is not supported in this trimmed or Native AOT host. Provide the logger to the engine as a constructed ILogger instance, or build with a JIT-based MSBuild. + MSB4285: A logger could not be created from its assembly and class name because loading loggers by reflection is not supported in this trimmed or Native AOT host. Provide the logger to the engine as a constructed ILogger instance, or build with a JIT-based MSBuild. + {StrBegin="MSB4285: "} + + + MSB4283: The "{0}" task could not be run because executing tasks requires loading task assemblies and types by reflection, which is not supported in this trimmed or Native AOT host. Build with a JIT-based MSBuild to run tasks. + MSB4283: The "{0}" task could not be run because executing tasks requires loading task assemblies and types by reflection, which is not supported in this trimmed or Native AOT host. Build with a JIT-based MSBuild to run tasks. + {StrBegin="MSB4283: "}{0} is the task name. + Reporting file accesses is only currently supported using the x64 flavor of MSBuild. 파일 액세스 보고는 현재 x64 버전의 MSBuild를 사용하는 경우에만 지원됩니다. @@ -989,6 +1009,11 @@ Errors: {3} SDK가 환경 변수 "{0}"을(를) "{1}"(으)로 설정하여 프로세스에서 상속된 값 "{2}"을(를) 재정의했습니다. + + MSB4282: The SDK "{0}" could not be resolved because it requires an SDK resolver ("{1}") that must be loaded dynamically, which is not supported in this trimmed or Native AOT host. SDKs available in the SDK installation are supported; build with a JIT-based MSBuild to use NuGet, workload, or custom SDK resolvers. + MSB4282: The SDK "{0}" could not be resolved because it requires an SDK resolver ("{1}") that must be loaded dynamically, which is not supported in this trimmed or Native AOT host. SDKs available in the SDK installation are supported; build with a JIT-based MSBuild to use NuGet, workload, or custom SDK resolvers. + {StrBegin="MSB4282: "}{0} is the SDK name; {1} is the SDK resolver manifest display name. + SDK '{0}' could not be resolved by the SDK resolver '{1}'. {2} SDK 확인자 '{1}'에서 SDK '{0}'을(를) 확인할 수 없습니다. {2} diff --git a/src/Build/Resources/xlf/Strings.pl.xlf b/src/Build/Resources/xlf/Strings.pl.xlf index a2257207af0..f06a86308dd 100644 --- a/src/Build/Resources/xlf/Strings.pl.xlf +++ b/src/Build/Resources/xlf/Strings.pl.xlf @@ -141,6 +141,11 @@ Zapisywanie nie jest obsługiwane. + + MSB4284: The custom build check "{0}" could not be loaded because this MSBuild host has custom plugin probing disabled, for example in a trimmed or Native AOT build, and cannot load check assemblies by reflection. Build with a JIT-based MSBuild to run custom checks. + MSB4284: The custom build check "{0}" could not be loaded because this MSBuild host has custom plugin probing disabled, for example in a trimmed or Native AOT build, and cannot load check assemblies by reflection. Build with a JIT-based MSBuild to run custom checks. + {StrBegin="MSB4284: "}{0} is the path to the custom build check assembly. + The BuildCheck is enabled for this build. Dla tej kompilacji włączono funkcję BuildCheck. @@ -689,6 +694,11 @@ LOCALIZATION: Do not localize the following words: ProjectInstanceFactoryFunc. + + Reading toolset definitions from the application configuration file (ToolsetDefinitionLocations.ConfigurationFile) is not supported in this trimmed or Native AOT host. Remove ConfigurationFile from the requested toolset locations, or run a JIT-based MSBuild. + Reading toolset definitions from the application configuration file (ToolsetDefinitionLocations.ConfigurationFile) is not supported in this trimmed or Native AOT host. Remove ConfigurationFile from the requested toolset locations, or run a JIT-based MSBuild. + + Only item types may be referenced when removing with MatchOnMetadata. Tylko typy elementów mogą być przywoływane podczas usuwania przy użyciu elementu MatchOnMetadata. @@ -908,6 +918,16 @@ MSB4274: wyłączenie węzła InProc prowadzi do obniżenia wydajności, gdy używane są wtyczki pamięci podręcznej projektu, które emitują żądania kompilowania serwera proxy. + + MSB4285: A logger could not be created from its assembly and class name because loading loggers by reflection is not supported in this trimmed or Native AOT host. Provide the logger to the engine as a constructed ILogger instance, or build with a JIT-based MSBuild. + MSB4285: A logger could not be created from its assembly and class name because loading loggers by reflection is not supported in this trimmed or Native AOT host. Provide the logger to the engine as a constructed ILogger instance, or build with a JIT-based MSBuild. + {StrBegin="MSB4285: "} + + + MSB4283: The "{0}" task could not be run because executing tasks requires loading task assemblies and types by reflection, which is not supported in this trimmed or Native AOT host. Build with a JIT-based MSBuild to run tasks. + MSB4283: The "{0}" task could not be run because executing tasks requires loading task assemblies and types by reflection, which is not supported in this trimmed or Native AOT host. Build with a JIT-based MSBuild to run tasks. + {StrBegin="MSB4283: "}{0} is the task name. + Reporting file accesses is only currently supported using the x64 flavor of MSBuild. Raportowanie dostępu do plików jest obecnie obsługiwane tylko przy użyciu wersji x64 programu MSBuild. @@ -989,6 +1009,11 @@ Błędy: {3} Zestaw SDK ustawił zmienną środowiskową „{0}” na „{1}”, zastępując wartość „{2}” odziedziczoną z procesu. + + MSB4282: The SDK "{0}" could not be resolved because it requires an SDK resolver ("{1}") that must be loaded dynamically, which is not supported in this trimmed or Native AOT host. SDKs available in the SDK installation are supported; build with a JIT-based MSBuild to use NuGet, workload, or custom SDK resolvers. + MSB4282: The SDK "{0}" could not be resolved because it requires an SDK resolver ("{1}") that must be loaded dynamically, which is not supported in this trimmed or Native AOT host. SDKs available in the SDK installation are supported; build with a JIT-based MSBuild to use NuGet, workload, or custom SDK resolvers. + {StrBegin="MSB4282: "}{0} is the SDK name; {1} is the SDK resolver manifest display name. + SDK '{0}' could not be resolved by the SDK resolver '{1}'. {2} Nie można rozpoznać zestawu SDK „{0}” przez program rozpoznawania nazw zestawu SDK „{1}”. {2} diff --git a/src/Build/Resources/xlf/Strings.pt-BR.xlf b/src/Build/Resources/xlf/Strings.pt-BR.xlf index 98bb0f725a9..e7dd76b89a9 100644 --- a/src/Build/Resources/xlf/Strings.pt-BR.xlf +++ b/src/Build/Resources/xlf/Strings.pt-BR.xlf @@ -141,6 +141,11 @@ Não há suporte para gravação. + + MSB4284: The custom build check "{0}" could not be loaded because this MSBuild host has custom plugin probing disabled, for example in a trimmed or Native AOT build, and cannot load check assemblies by reflection. Build with a JIT-based MSBuild to run custom checks. + MSB4284: The custom build check "{0}" could not be loaded because this MSBuild host has custom plugin probing disabled, for example in a trimmed or Native AOT build, and cannot load check assemblies by reflection. Build with a JIT-based MSBuild to run custom checks. + {StrBegin="MSB4284: "}{0} is the path to the custom build check assembly. + The BuildCheck is enabled for this build. O BuildCheck está habilitado para esse build. @@ -689,6 +694,11 @@ LOCALIZATION: Do not localize the following words: ProjectInstanceFactoryFunc. + + Reading toolset definitions from the application configuration file (ToolsetDefinitionLocations.ConfigurationFile) is not supported in this trimmed or Native AOT host. Remove ConfigurationFile from the requested toolset locations, or run a JIT-based MSBuild. + Reading toolset definitions from the application configuration file (ToolsetDefinitionLocations.ConfigurationFile) is not supported in this trimmed or Native AOT host. Remove ConfigurationFile from the requested toolset locations, or run a JIT-based MSBuild. + + Only item types may be referenced when removing with MatchOnMetadata. Somente tipos de item podem ser referenciados durante a remoção com MatchOnMetadata. @@ -908,6 +918,16 @@ MSB4274: desativar o nó inproc leva à degradação do desempenho ao usar plug-ins de cache de projeto que emitem solicitações de construção de proxy. + + MSB4285: A logger could not be created from its assembly and class name because loading loggers by reflection is not supported in this trimmed or Native AOT host. Provide the logger to the engine as a constructed ILogger instance, or build with a JIT-based MSBuild. + MSB4285: A logger could not be created from its assembly and class name because loading loggers by reflection is not supported in this trimmed or Native AOT host. Provide the logger to the engine as a constructed ILogger instance, or build with a JIT-based MSBuild. + {StrBegin="MSB4285: "} + + + MSB4283: The "{0}" task could not be run because executing tasks requires loading task assemblies and types by reflection, which is not supported in this trimmed or Native AOT host. Build with a JIT-based MSBuild to run tasks. + MSB4283: The "{0}" task could not be run because executing tasks requires loading task assemblies and types by reflection, which is not supported in this trimmed or Native AOT host. Build with a JIT-based MSBuild to run tasks. + {StrBegin="MSB4283: "}{0} is the task name. + Reporting file accesses is only currently supported using the x64 flavor of MSBuild. Atualmente, o relatório de acessos a arquivos só tem suporte usando o tipo x64 do MSBuild. @@ -989,6 +1009,11 @@ Erros: {3} Um SDK definiu a variável de ambiente "{0}" como "{1}", substituindo o valor "{2}" herdado do processo. + + MSB4282: The SDK "{0}" could not be resolved because it requires an SDK resolver ("{1}") that must be loaded dynamically, which is not supported in this trimmed or Native AOT host. SDKs available in the SDK installation are supported; build with a JIT-based MSBuild to use NuGet, workload, or custom SDK resolvers. + MSB4282: The SDK "{0}" could not be resolved because it requires an SDK resolver ("{1}") that must be loaded dynamically, which is not supported in this trimmed or Native AOT host. SDKs available in the SDK installation are supported; build with a JIT-based MSBuild to use NuGet, workload, or custom SDK resolvers. + {StrBegin="MSB4282: "}{0} is the SDK name; {1} is the SDK resolver manifest display name. + SDK '{0}' could not be resolved by the SDK resolver '{1}'. {2} O SDK '{0}' não pôde ser resolvido pelo resolvedor de SDK '{1}'. {2} diff --git a/src/Build/Resources/xlf/Strings.ru.xlf b/src/Build/Resources/xlf/Strings.ru.xlf index e1284dcac5e..ec0af4b82f8 100644 --- a/src/Build/Resources/xlf/Strings.ru.xlf +++ b/src/Build/Resources/xlf/Strings.ru.xlf @@ -141,6 +141,11 @@ Запись не поддерживается. + + MSB4284: The custom build check "{0}" could not be loaded because this MSBuild host has custom plugin probing disabled, for example in a trimmed or Native AOT build, and cannot load check assemblies by reflection. Build with a JIT-based MSBuild to run custom checks. + MSB4284: The custom build check "{0}" could not be loaded because this MSBuild host has custom plugin probing disabled, for example in a trimmed or Native AOT build, and cannot load check assemblies by reflection. Build with a JIT-based MSBuild to run custom checks. + {StrBegin="MSB4284: "}{0} is the path to the custom build check assembly. + The BuildCheck is enabled for this build. Для этой сборки включен параметр BuildCheck. @@ -689,6 +694,11 @@ LOCALIZATION: Do not localize the following words: ProjectInstanceFactoryFunc. + + Reading toolset definitions from the application configuration file (ToolsetDefinitionLocations.ConfigurationFile) is not supported in this trimmed or Native AOT host. Remove ConfigurationFile from the requested toolset locations, or run a JIT-based MSBuild. + Reading toolset definitions from the application configuration file (ToolsetDefinitionLocations.ConfigurationFile) is not supported in this trimmed or Native AOT host. Remove ConfigurationFile from the requested toolset locations, or run a JIT-based MSBuild. + + Only item types may be referenced when removing with MatchOnMetadata. При удалении с помощью MatchOnMetadata можно ссылаться только на типы элементов. @@ -908,6 +918,16 @@ MSB4274: Отключение внутрипроцессного узла приводит к замедлению при использовании плагинов кэша проекта, которые создают запросы на сборку прокси-сервера. + + MSB4285: A logger could not be created from its assembly and class name because loading loggers by reflection is not supported in this trimmed or Native AOT host. Provide the logger to the engine as a constructed ILogger instance, or build with a JIT-based MSBuild. + MSB4285: A logger could not be created from its assembly and class name because loading loggers by reflection is not supported in this trimmed or Native AOT host. Provide the logger to the engine as a constructed ILogger instance, or build with a JIT-based MSBuild. + {StrBegin="MSB4285: "} + + + MSB4283: The "{0}" task could not be run because executing tasks requires loading task assemblies and types by reflection, which is not supported in this trimmed or Native AOT host. Build with a JIT-based MSBuild to run tasks. + MSB4283: The "{0}" task could not be run because executing tasks requires loading task assemblies and types by reflection, which is not supported in this trimmed or Native AOT host. Build with a JIT-based MSBuild to run tasks. + {StrBegin="MSB4283: "}{0} is the task name. + Reporting file accesses is only currently supported using the x64 flavor of MSBuild. Доступ к файлам отчетов сейчас поддерживается только при использовании 64-разрядного варианта приложения MSBuild. @@ -989,6 +1009,11 @@ Errors: {3} Набор SDK установил для переменной среды "{0}" значение "{1}", переопределив значение "{2}", унаследованное от процесса. + + MSB4282: The SDK "{0}" could not be resolved because it requires an SDK resolver ("{1}") that must be loaded dynamically, which is not supported in this trimmed or Native AOT host. SDKs available in the SDK installation are supported; build with a JIT-based MSBuild to use NuGet, workload, or custom SDK resolvers. + MSB4282: The SDK "{0}" could not be resolved because it requires an SDK resolver ("{1}") that must be loaded dynamically, which is not supported in this trimmed or Native AOT host. SDKs available in the SDK installation are supported; build with a JIT-based MSBuild to use NuGet, workload, or custom SDK resolvers. + {StrBegin="MSB4282: "}{0} is the SDK name; {1} is the SDK resolver manifest display name. + SDK '{0}' could not be resolved by the SDK resolver '{1}'. {2} Не удалось разрешить SDK "{0}" с помощью сопоставителя SDK "{1}". {2} diff --git a/src/Build/Resources/xlf/Strings.tr.xlf b/src/Build/Resources/xlf/Strings.tr.xlf index 73bea2686de..eda7c668120 100644 --- a/src/Build/Resources/xlf/Strings.tr.xlf +++ b/src/Build/Resources/xlf/Strings.tr.xlf @@ -141,6 +141,11 @@ Yazma işlemi desteklenmiyor. + + MSB4284: The custom build check "{0}" could not be loaded because this MSBuild host has custom plugin probing disabled, for example in a trimmed or Native AOT build, and cannot load check assemblies by reflection. Build with a JIT-based MSBuild to run custom checks. + MSB4284: The custom build check "{0}" could not be loaded because this MSBuild host has custom plugin probing disabled, for example in a trimmed or Native AOT build, and cannot load check assemblies by reflection. Build with a JIT-based MSBuild to run custom checks. + {StrBegin="MSB4284: "}{0} is the path to the custom build check assembly. + The BuildCheck is enabled for this build. BuildCheck bu derleme için etkinleştirildi. @@ -689,6 +694,11 @@ LOCALIZATION: Do not localize the following words: ProjectInstanceFactoryFunc. + + Reading toolset definitions from the application configuration file (ToolsetDefinitionLocations.ConfigurationFile) is not supported in this trimmed or Native AOT host. Remove ConfigurationFile from the requested toolset locations, or run a JIT-based MSBuild. + Reading toolset definitions from the application configuration file (ToolsetDefinitionLocations.ConfigurationFile) is not supported in this trimmed or Native AOT host. Remove ConfigurationFile from the requested toolset locations, or run a JIT-based MSBuild. + + Only item types may be referenced when removing with MatchOnMetadata. MatchOnMetadata ile kaldırırken yalnızca öğe türlerine başvurulabilir. @@ -908,6 +918,16 @@ MSB4274: InProc düğümünün devre dışı bırakılması, ara sunucu oluşturma istekleri gönderen proje önbelleği eklentileri kullanılırken performans düşüşüne yol açar. + + MSB4285: A logger could not be created from its assembly and class name because loading loggers by reflection is not supported in this trimmed or Native AOT host. Provide the logger to the engine as a constructed ILogger instance, or build with a JIT-based MSBuild. + MSB4285: A logger could not be created from its assembly and class name because loading loggers by reflection is not supported in this trimmed or Native AOT host. Provide the logger to the engine as a constructed ILogger instance, or build with a JIT-based MSBuild. + {StrBegin="MSB4285: "} + + + MSB4283: The "{0}" task could not be run because executing tasks requires loading task assemblies and types by reflection, which is not supported in this trimmed or Native AOT host. Build with a JIT-based MSBuild to run tasks. + MSB4283: The "{0}" task could not be run because executing tasks requires loading task assemblies and types by reflection, which is not supported in this trimmed or Native AOT host. Build with a JIT-based MSBuild to run tasks. + {StrBegin="MSB4283: "}{0} is the task name. + Reporting file accesses is only currently supported using the x64 flavor of MSBuild. Raporlama dosyası erişimleri şu anda yalnızca MSBuild x64 varyantı kullanıldığında destekleniyor. @@ -989,6 +1009,11 @@ Hatalar: {3} Bir SDK, "{0}" ortam değişkenini "{1}" olarak ayarlayarak, işlemden devralınan "{2}" değerini geçersiz kıldı. + + MSB4282: The SDK "{0}" could not be resolved because it requires an SDK resolver ("{1}") that must be loaded dynamically, which is not supported in this trimmed or Native AOT host. SDKs available in the SDK installation are supported; build with a JIT-based MSBuild to use NuGet, workload, or custom SDK resolvers. + MSB4282: The SDK "{0}" could not be resolved because it requires an SDK resolver ("{1}") that must be loaded dynamically, which is not supported in this trimmed or Native AOT host. SDKs available in the SDK installation are supported; build with a JIT-based MSBuild to use NuGet, workload, or custom SDK resolvers. + {StrBegin="MSB4282: "}{0} is the SDK name; {1} is the SDK resolver manifest display name. + SDK '{0}' could not be resolved by the SDK resolver '{1}'. {2} SDK '{0}', SDK çözümleyici '{1}' tarafından çözümlenemedi. {2} diff --git a/src/Build/Resources/xlf/Strings.zh-Hans.xlf b/src/Build/Resources/xlf/Strings.zh-Hans.xlf index c890e02e230..9ff8a7e8f54 100644 --- a/src/Build/Resources/xlf/Strings.zh-Hans.xlf +++ b/src/Build/Resources/xlf/Strings.zh-Hans.xlf @@ -141,6 +141,11 @@ 不支持写入。 + + MSB4284: The custom build check "{0}" could not be loaded because this MSBuild host has custom plugin probing disabled, for example in a trimmed or Native AOT build, and cannot load check assemblies by reflection. Build with a JIT-based MSBuild to run custom checks. + MSB4284: The custom build check "{0}" could not be loaded because this MSBuild host has custom plugin probing disabled, for example in a trimmed or Native AOT build, and cannot load check assemblies by reflection. Build with a JIT-based MSBuild to run custom checks. + {StrBegin="MSB4284: "}{0} is the path to the custom build check assembly. + The BuildCheck is enabled for this build. 已为此内部版本启用 BuildCheck。 @@ -689,6 +694,11 @@ LOCALIZATION: Do not localize the following words: ProjectInstanceFactoryFunc. + + Reading toolset definitions from the application configuration file (ToolsetDefinitionLocations.ConfigurationFile) is not supported in this trimmed or Native AOT host. Remove ConfigurationFile from the requested toolset locations, or run a JIT-based MSBuild. + Reading toolset definitions from the application configuration file (ToolsetDefinitionLocations.ConfigurationFile) is not supported in this trimmed or Native AOT host. Remove ConfigurationFile from the requested toolset locations, or run a JIT-based MSBuild. + + Only item types may be referenced when removing with MatchOnMetadata. 使用 MatchOnMetadata 删除时,只能引用项类型。 @@ -908,6 +918,16 @@ MSB4274: 使用发出代理构建请求的项目缓存插件时,禁用 inproc 节点会导致性能下降。 + + MSB4285: A logger could not be created from its assembly and class name because loading loggers by reflection is not supported in this trimmed or Native AOT host. Provide the logger to the engine as a constructed ILogger instance, or build with a JIT-based MSBuild. + MSB4285: A logger could not be created from its assembly and class name because loading loggers by reflection is not supported in this trimmed or Native AOT host. Provide the logger to the engine as a constructed ILogger instance, or build with a JIT-based MSBuild. + {StrBegin="MSB4285: "} + + + MSB4283: The "{0}" task could not be run because executing tasks requires loading task assemblies and types by reflection, which is not supported in this trimmed or Native AOT host. Build with a JIT-based MSBuild to run tasks. + MSB4283: The "{0}" task could not be run because executing tasks requires loading task assemblies and types by reflection, which is not supported in this trimmed or Native AOT host. Build with a JIT-based MSBuild to run tasks. + {StrBegin="MSB4283: "}{0} is the task name. + Reporting file accesses is only currently supported using the x64 flavor of MSBuild. 当前仅支持使用 x64 风格的 MSBuild 来报告文件访问情况。 @@ -989,6 +1009,11 @@ Errors: {3} 某个 SDK 将环境变量“{0}”设置为“{1}”,覆盖了从进程继承的值“{2}”。 + + MSB4282: The SDK "{0}" could not be resolved because it requires an SDK resolver ("{1}") that must be loaded dynamically, which is not supported in this trimmed or Native AOT host. SDKs available in the SDK installation are supported; build with a JIT-based MSBuild to use NuGet, workload, or custom SDK resolvers. + MSB4282: The SDK "{0}" could not be resolved because it requires an SDK resolver ("{1}") that must be loaded dynamically, which is not supported in this trimmed or Native AOT host. SDKs available in the SDK installation are supported; build with a JIT-based MSBuild to use NuGet, workload, or custom SDK resolvers. + {StrBegin="MSB4282: "}{0} is the SDK name; {1} is the SDK resolver manifest display name. + SDK '{0}' could not be resolved by the SDK resolver '{1}'. {2} SDK 解析程序“{1}”无法解析 SDK“{0}”。{2} diff --git a/src/Build/Resources/xlf/Strings.zh-Hant.xlf b/src/Build/Resources/xlf/Strings.zh-Hant.xlf index 619e48da0ba..3d72c08dace 100644 --- a/src/Build/Resources/xlf/Strings.zh-Hant.xlf +++ b/src/Build/Resources/xlf/Strings.zh-Hant.xlf @@ -141,6 +141,11 @@ 不支援寫入。 + + MSB4284: The custom build check "{0}" could not be loaded because this MSBuild host has custom plugin probing disabled, for example in a trimmed or Native AOT build, and cannot load check assemblies by reflection. Build with a JIT-based MSBuild to run custom checks. + MSB4284: The custom build check "{0}" could not be loaded because this MSBuild host has custom plugin probing disabled, for example in a trimmed or Native AOT build, and cannot load check assemblies by reflection. Build with a JIT-based MSBuild to run custom checks. + {StrBegin="MSB4284: "}{0} is the path to the custom build check assembly. + The BuildCheck is enabled for this build. 已為此組建啟用 BuildCheck。 @@ -689,6 +694,11 @@ LOCALIZATION: Do not localize the following words: ProjectInstanceFactoryFunc. + + Reading toolset definitions from the application configuration file (ToolsetDefinitionLocations.ConfigurationFile) is not supported in this trimmed or Native AOT host. Remove ConfigurationFile from the requested toolset locations, or run a JIT-based MSBuild. + Reading toolset definitions from the application configuration file (ToolsetDefinitionLocations.ConfigurationFile) is not supported in this trimmed or Native AOT host. Remove ConfigurationFile from the requested toolset locations, or run a JIT-based MSBuild. + + Only item types may be referenced when removing with MatchOnMetadata. 使用 MatchOnMetadata 移除時,只能參考項目類型。 @@ -908,6 +918,16 @@ MSB4274: 停用 inproc 節點會在使用可發出 proxy 組建要求的專案快取外掛程式時,導致效能降低。 + + MSB4285: A logger could not be created from its assembly and class name because loading loggers by reflection is not supported in this trimmed or Native AOT host. Provide the logger to the engine as a constructed ILogger instance, or build with a JIT-based MSBuild. + MSB4285: A logger could not be created from its assembly and class name because loading loggers by reflection is not supported in this trimmed or Native AOT host. Provide the logger to the engine as a constructed ILogger instance, or build with a JIT-based MSBuild. + {StrBegin="MSB4285: "} + + + MSB4283: The "{0}" task could not be run because executing tasks requires loading task assemblies and types by reflection, which is not supported in this trimmed or Native AOT host. Build with a JIT-based MSBuild to run tasks. + MSB4283: The "{0}" task could not be run because executing tasks requires loading task assemblies and types by reflection, which is not supported in this trimmed or Native AOT host. Build with a JIT-based MSBuild to run tasks. + {StrBegin="MSB4283: "}{0} is the task name. + Reporting file accesses is only currently supported using the x64 flavor of MSBuild. 目前只支援使用 MSBuild 的 x64 變體來報告檔案存取。 @@ -989,6 +1009,11 @@ Errors: {3} SDK 將環境變數 "{0}" 設定為 "{1}",並覆寫從程序繼承的值 "{2}"。 + + MSB4282: The SDK "{0}" could not be resolved because it requires an SDK resolver ("{1}") that must be loaded dynamically, which is not supported in this trimmed or Native AOT host. SDKs available in the SDK installation are supported; build with a JIT-based MSBuild to use NuGet, workload, or custom SDK resolvers. + MSB4282: The SDK "{0}" could not be resolved because it requires an SDK resolver ("{1}") that must be loaded dynamically, which is not supported in this trimmed or Native AOT host. SDKs available in the SDK installation are supported; build with a JIT-based MSBuild to use NuGet, workload, or custom SDK resolvers. + {StrBegin="MSB4282: "}{0} is the SDK name; {1} is the SDK resolver manifest display name. + SDK '{0}' could not be resolved by the SDK resolver '{1}'. {2} SDK 解析程式 '{1}' 無法解析 SDK '{0}'。{2} diff --git a/src/Framework/BackEnd/Handshake.cs b/src/Framework/BackEnd/Handshake.cs index fffaa253288..a8dbf87984f 100644 --- a/src/Framework/BackEnd/Handshake.cs +++ b/src/Framework/BackEnd/Handshake.cs @@ -185,7 +185,11 @@ private bool IsClr2TaskHost private static HandshakeComponents CreateStandardComponents(int options, int salt, int sessionId) { - var fileVersion = new Version(FileVersionInfo.GetVersionInfo(Assembly.GetExecutingAssembly().Location).FileVersion!); + // Read the file version from the assembly's AssemblyFileVersionAttribute rather than from the file on + // disk: Assembly.Location is empty in a single-file/Native AOT host (and the on-disk read carries an + // IL3000), while the attribute carries the same value and is preserved under trimming. + var fileVersion = new Version( + Assembly.GetExecutingAssembly().GetCustomAttribute()!.Version); return new( options, diff --git a/src/Framework/BuildEnvironmentHelper.cs b/src/Framework/BuildEnvironmentHelper.cs index 56cd648f6cd..4d756f3eb90 100644 --- a/src/Framework/BuildEnvironmentHelper.cs +++ b/src/Framework/BuildEnvironmentHelper.cs @@ -3,9 +3,11 @@ using System; using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; +#if NET +using System.Runtime.CompilerServices; +#endif using System.Text.RegularExpressions; using Microsoft.Build.Framework; using Microsoft.Build.Shared.FileSystem; @@ -102,9 +104,24 @@ private static BuildEnvironment Initialize() // will be in the output path of the test project, which is what we want. string msbuildExePath; +#if NET + if (!RuntimeFeature.IsDynamicCodeSupported) + { + // Native AOT has no managed assembly file on disk, so typeof(...).Assembly.Location would + // be empty. An empty path is meaningless here, so use the running process path instead. + msbuildExePath = s_getProcessFromRunningProcess(); + } + else +#endif if (s_runningTests()) { msbuildExePath = typeof(BuildEnvironmentHelper).Assembly.Location; + + // In a single-file app Assembly.Location is also empty; fall back to the process path there. + if (string.IsNullOrEmpty(msbuildExePath)) + { + msbuildExePath = s_getProcessFromRunningProcess(); + } } else { @@ -373,37 +390,13 @@ private static string GetMSBuildExeFromVsRoot(string visualStudioRoot) } private static bool? _runningTests; - private static readonly LockType _runningTestsLock = new LockType(); - [UnconditionalSuppressMessage("Trimming", "IL2026:RequiresUnreferencedCode", - Justification = "Deliberately reads the internal Microsoft.Build.Framework.TestInfo.s_runningTests field by reflection so this type can be shared across assemblies without a hard reference. The DynamicDependency below keeps the field under trimming.")] - [UnconditionalSuppressMessage("Trimming", "IL2075:UnrecognizedReflectionPattern", - Justification = "TestInfo.s_runningTests is preserved by the DynamicDependency below, so the GetField lookup remains valid under trimming.")] - [DynamicDependency("s_runningTests", "Microsoft.Build.Framework.TestInfo", "Microsoft.Build.Framework")] private static bool CheckIfRunningTests() { - if (_runningTests != null) - { - return _runningTests.Value; - } - - lock (_runningTestsLock) - { - if (_runningTests != null) - { - return _runningTests.Value; - } - - // Check if running tests via the TestInfo class in Microsoft.Build.Framework. - // See the comments on the TestInfo class for an explanation of why it works this way. - var frameworkAssembly = typeof(Framework.ITask).Assembly; - var testInfoType = frameworkAssembly.GetType("Microsoft.Build.Framework.TestInfo"); - var runningTestsField = testInfoType.GetField("s_runningTests", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static); - - _runningTests = (bool)runningTestsField.GetValue(null); - - return _runningTests.Value; - } + // BuildEnvironmentHelper and TestInfo are both compiled into Microsoft.Build.Framework, + // so the flag the test host sets (see TestInfo) can be read directly. _runningTests is a + // test-only override applied through ResetInstance_ForUnitTestsOnly. + return _runningTests ?? Framework.TestInfo.s_runningTests; } /// @@ -436,8 +429,17 @@ private static string GetProcessFromRunningProcess() return processName; } - // EntryAssembly can be null in some hosting scenarios (e.g., when loaded as a library) - return System.Reflection.Assembly.GetEntryAssembly()?.Location ?? processName; + // Under Native AOT there is no separate managed entry assembly on disk (Assembly.Location is + // empty), so the process path is the only meaningful value. + if (!RuntimeFeature.IsDynamicCodeSupported) + { + return processName; + } + + // EntryAssembly can be null in some hosting scenarios (e.g., when loaded as a library), and + // Assembly.Location is empty in a single-file app; fall back to the process path in both cases. + string entryAssemblyPath = System.Reflection.Assembly.GetEntryAssembly()?.Location; + return string.IsNullOrEmpty(entryAssemblyPath) ? processName : entryAssemblyPath; #else return EnvironmentUtilities.ProcessPath; diff --git a/src/Framework/FeatureSwitches.cs b/src/Framework/FeatureSwitches.cs new file mode 100644 index 00000000000..2d42781813b --- /dev/null +++ b/src/Framework/FeatureSwitches.cs @@ -0,0 +1,248 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Diagnostics.CodeAnalysis; + +namespace Microsoft.Build.Framework; + +/// +/// Aggregates MSBuild's trimmer feature switches. +/// +/// +/// This is the single registry for MSBuild feature switches across the product. It lives in +/// Microsoft.Build.Framework because Framework is the lowest assembly in the stack - the engine and +/// tasks reference it but it references neither - so every assembly can read these switches. Each +/// property is a [FeatureSwitchDefinition] mapped to an AppContext switch (so the trimmer can +/// substitute a constant and remove the guarded branch); where it gates trim-unsafe reflection it is +/// also a [FeatureGuard] (so the analyzer treats the guarded branch as safe). Trimmed defaults +/// are declared by matching RuntimeHostConfigurationOption items in Microsoft.Build.Framework.csproj. +/// New feature switches should be added here so they can be discovered and configured in one place. +/// +internal static class FeatureSwitches +{ + private const bool EnableCustomPluginProbingByDefault = true; + + /// + /// Whether MSBuild may probe for and load plugin and task assemblies by path at run time. When + /// (the default under the JIT) the custom assembly resolvers + /// (MSBuildLoadContext for plugin dependencies and TaskEngineAssemblyResolver for task + /// assemblies) resolve assemblies themselves, which is reflection incompatible with trimming. When + /// (the substituted default in a trimmed or AOT application) custom probing + /// is skipped and resolution falls back to the default load behavior. + /// + /// + /// This is both a [FeatureSwitchDefinition] - so the trimmer substitutes a constant + /// and removes the probing branch from a trimmed application - and a + /// [FeatureGuard] for RequiresUnreferencedCode, so the analyzer treats + /// if (EnableCustomPluginProbing) as guarding the trim-unsafe LoadFromAssemblyPath + /// calls and no per-call suppression is required. The trimmed default is declared by a matching + /// RuntimeHostConfigurationOption in Microsoft.Build.Framework.csproj. + /// + [FeatureSwitchDefinition("Microsoft.Build.EnableCustomPluginProbing")] + [FeatureGuard(typeof(RequiresUnreferencedCodeAttribute))] +#pragma warning disable IL4000 // The analyzer can't model the AppContext-switch body (it can't see the trimmed default in the csproj), so it can't prove the guard is false when trimming; ILLink applies that substitution and removes the guarded probing branch. Same pattern as the BCL feature guards (e.g. DataSet.XmlSerializationIsSupported). + internal static bool EnableCustomPluginProbing => + AppContext.TryGetSwitch("Microsoft.Build.EnableCustomPluginProbing", out bool isEnabled) + ? isEnabled + : EnableCustomPluginProbingByDefault; +#pragma warning restore IL4000 + + /// + /// Whether MSBuild may probe and load assemblies at run time to resolve arbitrary property-function + /// receiver types. When (the default in a trimmed or AOT application), + /// receiver types are restricted to the curated allowlist in AvailableStaticMethods, all of + /// which are statically known and preserved. When , MSBuild additionally probes + /// assemblies at run time - reflection that is incompatible with trimming. + /// + /// + /// This is both a [FeatureSwitchDefinition] - so the trimmer substitutes a constant + /// and removes the probing path from a trimmed application - and a + /// [FeatureGuard] for RequiresUnreferencedCode, so the analyzer treats + /// if (EnableAllPropertyFunctions) as guarding the trim-unsafe probing and no per-call + /// suppression is required. In untrimmed builds, the legacy MSBUILDENABLEALLPROPERTYFUNCTIONS + /// environment variable still enables run-time type probing when the AppContext switch is unset. In + /// trimmed/AOT builds, the trimmer substitutes this property to before the body + /// runs, so that environment variable cannot re-open the removed probing path. The trimmed default is + /// declared by a matching RuntimeHostConfigurationOption in Microsoft.Build.Framework.csproj and + /// flows to package consumers through the package's buildTransitive targets. + /// + [FeatureSwitchDefinition("Microsoft.Build.EnableAllPropertyFunctions")] + [FeatureGuard(typeof(RequiresUnreferencedCodeAttribute))] +#pragma warning disable IL4000 // The Roslyn analyzer can't see the trimmed default (the RuntimeHostConfigurationOption in Microsoft.Build.Framework.csproj), so it can't prove this guard is false when trimming; the ILLink trimmer applies that substitution and removes the guarded probing branch. Same pattern as BCL feature guards (e.g. DataSet.XmlSerializationIsSupported, TypeDescriptor.IsComObjectDescriptorSupported). + internal static bool EnableAllPropertyFunctions => + AppContext.TryGetSwitch("Microsoft.Build.EnableAllPropertyFunctions", out bool isEnabled) + ? isEnabled + : Environment.GetEnvironmentVariable("MSBUILDENABLEALLPROPERTYFUNCTIONS") == "1"; +#pragma warning restore IL4000 + + /// + /// Whether instance property-function calls are limited to a curated set of receiver types. + /// + /// + /// + /// When enabled, instance "dotting in" is restricted to a curated set of receiver types (the engine's + /// PropertyFunctionReceiver), so the members reachable by reflection are predictable and + /// statically known. When disabled, any public instance member except GetType is callable, + /// preserving the historical behavior. + /// + /// + /// The untrimmed default is ; under trimming the constant is substituted + /// so the unrestricted branch is removed, keeping the property-function path + /// trim compatible. This switch is set only through its AppContext switch; it has no environment + /// variable. + /// + /// + [FeatureSwitchDefinition("Microsoft.Build.RestrictPropertyFunctionReceivers")] + internal static bool RestrictPropertyFunctionReceivers => + AppContext.TryGetSwitch("Microsoft.Build.RestrictPropertyFunctionReceivers", out bool isEnabled) && isEnabled; + + private const bool EnableSdkResolverDynamicLoadingByDefault = true; + + /// + /// Whether MSBuild may load SDK resolver plugin assemblies from disk by reflection. When + /// (the default under the JIT) MSBuild discovers and loads SDK resolver + /// assemblies to resolve SDKs that the built-in, reflection-free DefaultSdkResolver cannot. + /// When (substituted by the trimmer) an SDK that can only be resolved by a + /// dynamically loaded resolver fails observably with a reported project error instead, and the + /// reflective resolver-loading path is removed from a trimmed application. + /// + /// + /// Both a [FeatureSwitchDefinition] (so the trimmer substitutes the constant and removes the + /// guarded loading branch) and a [FeatureGuard] for RequiresUnreferencedCode (so the + /// analyzer treats if (EnableSdkResolverDynamicLoading) as guarding the trim-unsafe load and + /// no per-call suppression is required). A pure AppContext switch; the trimmed default is declared by + /// a matching RuntimeHostConfigurationOption in Microsoft.Build.Framework.csproj. + /// + [FeatureSwitchDefinition("Microsoft.Build.EnableSdkResolverDynamicLoading")] + [FeatureGuard(typeof(RequiresUnreferencedCodeAttribute))] +#pragma warning disable IL4000 // The Roslyn analyzer can't see the trimmed default (the RuntimeHostConfigurationOption in Microsoft.Build.Framework.csproj), so it can't prove this guard is false when trimming; the ILLink trimmer applies that substitution and removes the guarded loading branch. Same pattern as BCL feature guards. + internal static bool EnableSdkResolverDynamicLoading => + AppContext.TryGetSwitch("Microsoft.Build.EnableSdkResolverDynamicLoading", out bool isEnabled) + ? isEnabled + : EnableSdkResolverDynamicLoadingByDefault; +#pragma warning restore IL4000 + + private const bool EnableConfigurationFileToolsetsByDefault = true; + + /// + /// Whether MSBuild reads toolset definitions from the application configuration file (the + /// <msbuildToolsets> section of an .exe.config/app.config) when a caller requests + /// ToolsetDefinitionLocations.ConfigurationFile. When (the default under + /// the JIT) the configuration reader runs. When (substituted by the trimmer) + /// the configuration-reading branch is removed, which lets the trimmer drop the entire + /// ToolsetConfigurationReader subtree and the System.Configuration.ConfigurationManager + /// dependency from a trimmed/AOT application. The configuration file is not one of the default toolset + /// locations on .NET, so hosts that do not opt in are unaffected; a host that disables the switch and + /// still requests ToolsetDefinitionLocations.ConfigurationFile gets an + /// (failing observably) rather than silently missing those toolsets. + /// + /// + /// A pure AppContext switch with a [FeatureSwitchDefinition] so the trimmer folds it to the + /// constant declared by the matching RuntimeHostConfigurationOption in Microsoft.Build.Framework.csproj. + /// No [FeatureGuard] is needed: the guarded code is not [RequiresUnreferencedCode]; the + /// switch exists purely to let the trimmer remove an otherwise-reachable assembly reference. + /// + [FeatureSwitchDefinition("Microsoft.Build.EnableConfigurationFileToolsets")] + internal static bool EnableConfigurationFileToolsets => + AppContext.TryGetSwitch("Microsoft.Build.EnableConfigurationFileToolsets", out bool isEnabled) + ? isEnabled + : EnableConfigurationFileToolsetsByDefault; + + private const bool EnableReflectiveTaskExecutionByDefault = true; + + /// + /// Whether MSBuild may load and execute tasks by reflecting over task assemblies and task types + /// discovered at run time. When (the default under the JIT) the engine + /// instantiates tasks by reflection (loading the task assembly, resolving the task type, calling + /// the task factory, and binding parameters) - the reflective leaf the whole build-execution path + /// reaches. When (the substituted default in a trimmed or AOT application) + /// the engine does not attempt reflective task execution: the gated leaves report an observable + /// build error (ReflectiveTaskExecutionNotSupported, MSB4283) and the trimmer removes the + /// reflective instantiation path from the image, so a trimmed/AOT host fails observably and can fall + /// back to a JIT MSBuild instead of crashing in reflection. + /// + /// + /// Both a [FeatureSwitchDefinition] (so the trimmer substitutes the constant and removes the + /// reflective instantiation branch) and a [FeatureGuard] for RequiresUnreferencedCode + /// (so the analyzer treats if (EnableReflectiveTaskExecution) as guarding the trim-unsafe + /// reflection and no per-call suppression is required up the build-execution chain). This is the + /// leaf gate that lets the engine-internal build-execution methods drop their + /// [RequiresUnreferencedCode]; the public ITaskFactory contract keeps its honest RUC + /// for callers that reach it directly. The task-registration API + /// (Microsoft.Build.Utilities.Task.RegisterTask backed by TaskClassRegistry, see + /// task-class-registration-api.md) lets host-registered tasks run with this switch off - the + /// engine constructs them reflection-free - and the intrinsic MSBuild/CallTarget tasks + /// resolve the same way. A pure AppContext switch; the trimmed default is declared by a matching + /// RuntimeHostConfigurationOption in Microsoft.Build.Framework.csproj. + /// + [FeatureSwitchDefinition("Microsoft.Build.EnableReflectiveTaskExecution")] + [FeatureGuard(typeof(RequiresUnreferencedCodeAttribute))] +#pragma warning disable IL4000 // The analyzer can't model the AppContext-switch body (it can't see the trimmed default in the csproj), so it can't prove the guard is false when trimming; ILLink applies that substitution and removes the guarded reflective-execution branch. Same pattern as the other feature guards (e.g. EnableCustomPluginProbing). + internal static bool EnableReflectiveTaskExecution => + AppContext.TryGetSwitch("Microsoft.Build.EnableReflectiveTaskExecution", out bool isEnabled) + ? isEnabled + : EnableReflectiveTaskExecutionByDefault; +#pragma warning restore IL4000 + + private const bool EnableReflectiveTaskParameterTypesByDefault = true; + + /// + /// Whether MSBuild may resolve a task parameter type declared by name (the ParameterType of a + /// <UsingTask> <ParameterGroup> parameter) by reflecting over the loaded + /// assemblies with . When (the default + /// under the JIT) an unregistered type name falls back to Type.GetType. When + /// (the substituted default in a trimmed or AOT application) only types in the statically-known + /// TaskParameterTypeRegistry (the intrinsic value types, string, and the MSBuild + /// types, plus any a host has registered) resolve; an unregistered name fails + /// observably with a reported project error instead, and the trimmer removes the reflective + /// name-resolution branch from the image. + /// + /// + /// Both a [FeatureSwitchDefinition] (so the trimmer substitutes the constant and removes the + /// reflective Type.GetType branch) and a [FeatureGuard] for RequiresUnreferencedCode + /// (so the analyzer treats if (EnableReflectiveTaskParameterTypes) as guarding the trim-unsafe + /// by-name resolution and no per-call suppression is required). The registry is always consulted first, + /// reflection-free, on both the JIT and trimmed paths; this switch only gates the fallback for names the + /// registry does not know. A pure AppContext switch; the trimmed default is declared by a matching + /// RuntimeHostConfigurationOption in Microsoft.Build.Framework.csproj. + /// + [FeatureSwitchDefinition("Microsoft.Build.EnableReflectiveTaskParameterTypes")] + [FeatureGuard(typeof(RequiresUnreferencedCodeAttribute))] +#pragma warning disable IL4000 // The analyzer can't model the AppContext-switch body (it can't see the trimmed default in the csproj), so it can't prove the guard is false when trimming; ILLink applies that substitution and removes the guarded by-name resolution branch. Same pattern as the other feature guards (e.g. EnableReflectiveTaskExecution). + internal static bool EnableReflectiveTaskParameterTypes => + AppContext.TryGetSwitch("Microsoft.Build.EnableReflectiveTaskParameterTypes", out bool isEnabled) + ? isEnabled + : EnableReflectiveTaskParameterTypesByDefault; +#pragma warning restore IL4000 + + private const bool EnableReflectiveLoggerLoadingByDefault = true; + + /// + /// Whether MSBuild may create a logger from a LoggerDescription (a logger named by its assembly + /// and class) by reflecting over the logger assembly at run time. When (the + /// default under the JIT) a distributed/forwarding logger described by name is loaded and instantiated by + /// reflection. When (the substituted default in a trimmed or AOT application) + /// that reflective load is not attempted: a logger described by name fails observably with a reported + /// error instead, and the trimmer removes the reflective logger-loading path (and its + /// MetadataLoadContext/TypeLoader dependency) from the image. Loggers supplied to the + /// engine as already-constructed instances are unaffected and remain the supported + /// way to log under trimming/AOT. + /// + /// + /// Both a [FeatureSwitchDefinition] (so the trimmer substitutes the constant and removes the + /// reflective forwarding-logger creation branch) and a [FeatureGuard] for + /// RequiresUnreferencedCode (so the analyzer treats if (EnableReflectiveLoggerLoading) as + /// guarding the trim-unsafe LoggerDescription.CreateForwardingLogger call and no per-call + /// suppression is required). A pure AppContext switch; the trimmed default is declared by a matching + /// RuntimeHostConfigurationOption in Microsoft.Build.Framework.csproj. + /// + [FeatureSwitchDefinition("Microsoft.Build.EnableReflectiveLoggerLoading")] + [FeatureGuard(typeof(RequiresUnreferencedCodeAttribute))] +#pragma warning disable IL4000 // The analyzer can't model the AppContext-switch body (it can't see the trimmed default in the csproj), so it can't prove the guard is false when trimming; ILLink applies that substitution and removes the guarded reflective logger-loading branch. Same pattern as the other feature guards (e.g. EnableReflectiveTaskExecution). + internal static bool EnableReflectiveLoggerLoading => + AppContext.TryGetSwitch("Microsoft.Build.EnableReflectiveLoggerLoading", out bool isEnabled) + ? isEnabled + : EnableReflectiveLoggerLoadingByDefault; +#pragma warning restore IL4000 +} diff --git a/src/Framework/ITaskFactory.cs b/src/Framework/ITaskFactory.cs index 65e1dd7690d..f0fdfb0f6dc 100644 --- a/src/Framework/ITaskFactory.cs +++ b/src/Framework/ITaskFactory.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; #nullable disable @@ -22,6 +23,7 @@ public interface ITaskFactory /// /// Gets the type of the task this factory will instantiate. Implementations must return a value for this property. /// + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] Type TaskType { get; } /// @@ -39,6 +41,7 @@ public interface ITaskFactory /// The taskFactoryLoggingHost will log messages in the context of the target where the task is first used. /// /// + [RequiresUnreferencedCode("Task factories create tasks by reflecting over a task type discovered or generated at runtime, which is incompatible with trimming.")] bool Initialize(string taskName, IDictionary parameterGroup, string taskBody, IBuildEngine taskFactoryLoggingHost); /// @@ -56,6 +59,7 @@ public interface ITaskFactory /// /// The generated task, or null if the task failed to be created. /// + [RequiresUnreferencedCode("Task factories create tasks by reflecting over a task type discovered or generated at runtime, which is incompatible with trimming.")] ITask CreateTask(IBuildEngine taskFactoryLoggingHost); /// diff --git a/src/Framework/ITaskFactory2.cs b/src/Framework/ITaskFactory2.cs index cd2de556f6a..c6625245a9e 100644 --- a/src/Framework/ITaskFactory2.cs +++ b/src/Framework/ITaskFactory2.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; #nullable disable @@ -35,6 +36,7 @@ public interface ITaskFactory2 : ITaskFactory /// The taskFactoryLoggingHost will log messages in the context of the target where the task is first used. /// /// + [RequiresUnreferencedCode("Task factories create tasks by reflecting over a task type discovered or generated at runtime, which is incompatible with trimming.")] bool Initialize(string taskName, IDictionary factoryIdentityParameters, IDictionary parameterGroup, string taskBody, IBuildEngine taskFactoryLoggingHost); /// @@ -58,6 +60,7 @@ public interface ITaskFactory2 : ITaskFactory /// /// The generated task, or null if the task failed to be created. /// + [RequiresUnreferencedCode("Task factories create tasks by reflecting over a task type discovered or generated at runtime, which is incompatible with trimming.")] ITask CreateTask(IBuildEngine taskFactoryLoggingHost, IDictionary taskIdentityParameters); } } diff --git a/src/Framework/ITaskFactory3.cs b/src/Framework/ITaskFactory3.cs index 591e739faf7..16d9472c889 100644 --- a/src/Framework/ITaskFactory3.cs +++ b/src/Framework/ITaskFactory3.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; #nullable disable @@ -47,6 +48,7 @@ public interface ITaskFactory3 : ITaskFactory2 /// The taskFactoryLoggingHost will log messages in the context of the target where the task is first used. /// /// + [RequiresUnreferencedCode("Task factories create tasks by reflecting over a task type discovered or generated at runtime, which is incompatible with trimming.")] bool Initialize(string taskName, TaskHostParameters factoryIdentityParameters, IDictionary parameterGroup, string taskBody, IBuildEngine taskFactoryLoggingHost); /// @@ -70,6 +72,7 @@ public interface ITaskFactory3 : ITaskFactory2 /// /// The generated task, or null if the task failed to be created. /// + [RequiresUnreferencedCode("Task factories create tasks by reflecting over a task type discovered or generated at runtime, which is incompatible with trimming.")] ITask CreateTask(IBuildEngine taskFactoryLoggingHost, TaskHostParameters taskIdentityParameters); } } diff --git a/src/Framework/Loader/CoreCLRAssemblyLoader.cs b/src/Framework/Loader/CoreCLRAssemblyLoader.cs index 81d054c6f7d..d8e6a60f4fd 100644 --- a/src/Framework/Loader/CoreCLRAssemblyLoader.cs +++ b/src/Framework/Loader/CoreCLRAssemblyLoader.cs @@ -9,6 +9,7 @@ using System.Diagnostics.CodeAnalysis; using System.IO; using System.Reflection; +using System.Runtime.CompilerServices; using System.Runtime.Loader; using Microsoft.Build.Framework; using Microsoft.Build.Shared.FileSystem; @@ -135,6 +136,14 @@ private Assembly TryGetWellKnownAssembly(AssemblyLoadContext context, AssemblyNa // of the Microsoft.Build.* assembly. assemblyName.Version = _currentAssemblyVersion; + // Assembly.Location is empty in a single-file/Native AOT host, where the well-known MSBuild + // assembly is already loaded into the image; skip the path-based search there (ILC dead-strips + // this read and its IL3000) and let the default resolution find the loaded assembly. + if (!RuntimeFeature.IsDynamicCodeSupported) + { + return null; + } + string[] searchPaths = [Assembly.GetExecutingAssembly().Location]; return TryResolveAssemblyFromPaths(context, assemblyName, searchPaths); } diff --git a/src/Framework/Loader/LoadedType.cs b/src/Framework/Loader/LoadedType.cs index f11707e1826..6467c20d99f 100644 --- a/src/Framework/Loader/LoadedType.cs +++ b/src/Framework/Loader/LoadedType.cs @@ -4,6 +4,9 @@ using System; using System.Reflection; using System.Diagnostics.CodeAnalysis; +#if NET +using System.Runtime.CompilerServices; +#endif using Microsoft.Build.Execution; using Microsoft.Build.Framework; @@ -28,7 +31,7 @@ internal sealed class LoadedType /// Assembly architecture extracted from PE flags /// Whether this type was loaded via MetadataLoadContext internal LoadedType( - [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor | DynamicallyAccessedMemberTypes.PublicProperties)] Type type, AssemblyLoadInfo assemblyLoadInfo, Assembly loadedAssembly, @@ -50,10 +53,18 @@ internal LoadedType( Architecture = architecture; Runtime = runtime; - // For inline tasks loaded from bytes, Assembly.Location is empty, so use the original path - Path = string.IsNullOrEmpty(loadedAssembly.Location) + // Assembly.Location is empty for inline tasks loaded from bytes, and for every assembly in a + // single-file/Native AOT host; in those cases fall back to the original load path. On .NET the + // read is guarded on dynamic-code support so ILC dead-strips it (and its IL3000) under AOT, while + // the JIT still prefers the real loaded location. +#if NET + string loadedAssemblyLocation = RuntimeFeature.IsDynamicCodeSupported ? loadedAssembly.Location : string.Empty; +#else + string loadedAssemblyLocation = loadedAssembly.Location; +#endif + Path = string.IsNullOrEmpty(loadedAssemblyLocation) ? assemblyLoadInfo.AssemblyLocation - : loadedAssembly.Location; + : loadedAssemblyLocation; LoadedAssembly = loadedAssembly; @@ -214,6 +225,7 @@ private bool CheckForHardcodedSTARequirement() /// Gets the type that was loaded from an assembly. /// /// The loaded type. + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor | DynamicallyAccessedMemberTypes.PublicProperties)] internal Type Type { get; private set; } internal AssemblyName LoadedAssemblyName { get; private set; } diff --git a/src/Framework/Loader/MSBuildLoadContext.cs b/src/Framework/Loader/MSBuildLoadContext.cs index 0e090fa8762..02db3b4aeac 100644 --- a/src/Framework/Loader/MSBuildLoadContext.cs +++ b/src/Framework/Loader/MSBuildLoadContext.cs @@ -5,7 +5,6 @@ using System; using System.Collections.Immutable; -using System.Diagnostics.CodeAnalysis; using System.IO; using System.Reflection; using System.Runtime.Loader; @@ -48,10 +47,15 @@ public MSBuildLoadContext(string assemblyPath) null; } - [UnconditionalSuppressMessage("Trimming", "IL2026:RequiresUnreferencedCode", - Justification = "This overrides AssemblyLoadContext.Load, which is not annotated with RequiresUnreferencedCode, so the requirement cannot be propagated to this method. Loading plugin assemblies by path is the intended purpose of this isolated load context.")] protected override Assembly? Load(AssemblyName assemblyName) { + if (!Framework.FeatureSwitches.EnableCustomPluginProbing) + { + // Custom plugin probing is disabled (for example when trimmed); fall back to the + // default AssemblyLoadContext rather than resolving dependencies by reflection. + return null; + } + if (WellKnownAssemblyNames.Contains(assemblyName.Name!)) { // Force MSBuild assemblies to be loaded in the default ALC diff --git a/src/Framework/Microsoft.Build.Framework.csproj b/src/Framework/Microsoft.Build.Framework.csproj index dcb2abebb5b..b77c0fb5acc 100644 --- a/src/Framework/Microsoft.Build.Framework.csproj +++ b/src/Framework/Microsoft.Build.Framework.csproj @@ -28,6 +28,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Framework/NativeMethods.cs b/src/Framework/NativeMethods.cs index de45ea0bc21..a62b415fd2b 100644 --- a/src/Framework/NativeMethods.cs +++ b/src/Framework/NativeMethods.cs @@ -3,6 +3,9 @@ using System; using System.IO; +#if NET +using System.Runtime.CompilerServices; +#endif using System.Runtime.InteropServices; using System.Runtime.Versioning; using Microsoft.Build.Framework.Logging; @@ -600,11 +603,25 @@ internal static string FrameworkCurrentPath { if (s_frameworkCurrentPath == null) { - var baseTypeLocation = typeof(string).Assembly.Location; +#if NET + // Under Native AOT there is no core library on disk (typeof(string).Assembly.Location is + // empty), so the running runtime's directory is unknown. Every consumer of this value is + // locating an installed .NET Framework (or Mono) - which a Native AOT process never has - + // and already treats an empty path as "not found", so report empty here instead of reading + // the (empty) assembly location. + if (!RuntimeFeature.IsDynamicCodeSupported) + { + s_frameworkCurrentPath = string.Empty; + } + else +#endif + { + var baseTypeLocation = typeof(string).Assembly.Location; - s_frameworkCurrentPath = - Path.GetDirectoryName(baseTypeLocation) - ?? string.Empty; + s_frameworkCurrentPath = + Path.GetDirectoryName(baseTypeLocation) + ?? string.Empty; + } } return s_frameworkCurrentPath; diff --git a/src/Framework/Polyfills/AotTrimmingPolyfills.cs b/src/Framework/Polyfills/AotTrimmingPolyfills.cs index 95c17e9b3f5..4a6fddf2e3a 100644 --- a/src/Framework/Polyfills/AotTrimmingPolyfills.cs +++ b/src/Framework/Polyfills/AotTrimmingPolyfills.cs @@ -161,6 +161,20 @@ internal sealed class FeatureSwitchDefinitionAttribute : Attribute public string SwitchName { get; } } + + /// + /// Indicates that the annotated static boolean property guards access to a feature that requires + /// the referenced capability (for example or + /// ). The trim/AOT analyzer treats a check of the property + /// as a guard, so calls to the referenced capability inside the guarded branch do not warn. + /// + [AttributeUsage(AttributeTargets.Property, Inherited = false, AllowMultiple = true)] + internal sealed class FeatureGuardAttribute : Attribute + { + public FeatureGuardAttribute(Type featureType) => FeatureType = featureType; + + public Type FeatureType { get; } + } } #endif diff --git a/src/Framework/ReflectableTaskPropertyInfo.cs b/src/Framework/ReflectableTaskPropertyInfo.cs index 5e80b046dae..92aecf27048 100644 --- a/src/Framework/ReflectableTaskPropertyInfo.cs +++ b/src/Framework/ReflectableTaskPropertyInfo.cs @@ -24,7 +24,7 @@ internal class ReflectableTaskPropertyInfo : TaskPropertyInfo /// /// The type of the generated tasks. /// - [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.NonPublicProperties)] + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] private Type _taskType; /// @@ -34,7 +34,7 @@ internal class ReflectableTaskPropertyInfo : TaskPropertyInfo /// The type to reflect over to get the reflection propertyinfo later. internal ReflectableTaskPropertyInfo( TaskPropertyInfo taskPropertyInfo, - [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.NonPublicProperties)] + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] Type taskType) : base(taskPropertyInfo.Name, taskPropertyInfo.PropertyType, taskPropertyInfo.Output, taskPropertyInfo.Required) { diff --git a/src/Framework/Sdk/SdkResolver.cs b/src/Framework/Sdk/SdkResolver.cs index fc9c7f3ed2f..6ba27eb63c5 100644 --- a/src/Framework/Sdk/SdkResolver.cs +++ b/src/Framework/Sdk/SdkResolver.cs @@ -1,6 +1,9 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System; +using System.Collections.Generic; + namespace Microsoft.Build.Framework { /// @@ -36,5 +39,92 @@ public abstract class SdkResolver public abstract SdkResult? Resolve(SdkReference sdkReference, SdkResolverContext resolverContext, SdkResultFactory factory); + + /// + /// The set of resolvers registered in-process via . + /// + private static readonly List s_registeredResolvers = new(); + + /// + /// Registers an to be consulted during SDK resolution by a host that runs + /// the MSBuild engine in-process (for example the .NET SDK CLI), without MSBuild discovering and + /// loading it from disk by reflection. + /// + /// The resolver instance to register. + /// + /// + /// This is the supported way to provide SDK resolvers in a trimmed or Native AOT host, where the + /// on-disk SdkResolvers probing and reflection-based loading used for plugin resolvers are + /// unavailable. The registered resolver is consulted on the same reflection-free code path as + /// MSBuild's built-in resolver, so it never triggers the dynamic-loading failure (MSB4282). + /// + /// + /// The registered resolver participates in resolution in order alongside + /// MSBuild's built-in resolver, with no assembly loading or reflection. It is consulted for every + /// SDK reference in the process. + /// + /// + /// Intended to be called once per resolver during host initialization, before the first project is + /// evaluated. The set of registered resolvers is captured the first time an SDK is resolved in the + /// process; registrations performed after that point are not guaranteed to take effect. + /// + /// + /// This method is thread-safe. Registering the same instance more than once has no additional effect. + /// + /// + /// is . + public static void Register(SdkResolver resolver) + { + if (resolver is null) + { + throw new ArgumentNullException(nameof(resolver)); + } + + lock (s_registeredResolvers) + { + bool alreadyRegistered = false; + foreach (SdkResolver registeredResolver in s_registeredResolvers) + { + if (ReferenceEquals(registeredResolver, resolver)) + { + alreadyRegistered = true; + break; + } + } + + if (!alreadyRegistered) + { + s_registeredResolvers.Add(resolver); + } + } + } + + /// + /// Gets a snapshot of the resolvers registered via , for the engine to fold + /// into its reflection-free default-resolver pass. + /// + internal static IReadOnlyList RegisteredResolvers + { + get + { + lock (s_registeredResolvers) + { + return s_registeredResolvers.Count == 0 + ? Array.Empty() + : s_registeredResolvers.ToArray(); + } + } + } + + /// + /// Clears all registered resolvers. For test use only, to reset the process-global registration state. + /// + internal static void ClearRegisteredResolversForTests() + { + lock (s_registeredResolvers) + { + s_registeredResolvers.Clear(); + } + } } } diff --git a/src/Framework/TaskClassRegistration.cs b/src/Framework/TaskClassRegistration.cs new file mode 100644 index 00000000000..3458be74e0b --- /dev/null +++ b/src/Framework/TaskClassRegistration.cs @@ -0,0 +1,74 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Build.Shared; + +namespace Microsoft.Build.Framework; + +/// +/// A single entry in the : how to construct a registered task and the +/// reflected type metadata the engine binds its parameters against. +/// +/// +/// The is built once. For the generic +/// overload it is supplied eagerly at registration (where the task type is trim-rooted). For the +/// overload - where only an untyped factory is +/// known - it is built lazily from the first constructed instance's type. +/// +internal sealed class TaskClassRegistration +{ + private readonly Func _createInstance; + private readonly object _loadedTypeLock = new(); + private volatile LoadedType? _loadedType; + + /// + /// Creates a registration whose is already known (the generic, trim-rooted path). + /// + internal TaskClassRegistration(Func createInstance, LoadedType loadedType) + { + _createInstance = createInstance; + _loadedType = loadedType; + } + + /// + /// Creates a registration backed only by a factory; the is computed lazily from + /// the first instance's type. + /// + internal TaskClassRegistration(Func createInstance) => _createInstance = createInstance; + + /// + /// Constructs a new instance of the registered task. Reflection-free: it invokes the registered factory. + /// + internal ITask CreateInstance() => _createInstance(); + + /// + /// Gets the reflected type metadata the engine uses to discover and bind the task's parameters. + /// + internal LoadedType GetLoadedType() + { + if (_loadedType is null) + { + lock (_loadedTypeLock) + { + _loadedType ??= CreateLoadedTypeFromFactory(); + } + } + + return _loadedType; + } + + /// + /// Builds the for the factory-only registration from a probe instance's type. + /// + [UnconditionalSuppressMessage( + "Trimming", + "IL2072:UnrecognizedReflectionPattern", + Justification = "The Func registration overload's task type is supplied by the host and is not statically known here. " + + "The host is responsible for preserving the task type's public properties under trimming (for example by also registering it " + + "through the generic RegisterTask overload, which roots them, or via a TrimmerRootAssembly entry). The generic overload, " + + "which the built-in tasks and most hosts use, supplies the LoadedType eagerly and is fully trim-safe.")] + private LoadedType CreateLoadedTypeFromFactory() + => TaskClassRegistry.CreateLoadedType(_createInstance().GetType()); +} diff --git a/src/Framework/TaskClassRegistry.cs b/src/Framework/TaskClassRegistry.cs new file mode 100644 index 00000000000..dace6bafc6f --- /dev/null +++ b/src/Framework/TaskClassRegistry.cs @@ -0,0 +1,124 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using Microsoft.Build.Shared; + +namespace Microsoft.Build.Framework; + +/// +/// A statically-known, reflection-free map from a task name (the element name a target invokes, i.e. the +/// TaskName of a <UsingTask>) to a way to construct that task and the reflected metadata +/// the engine needs to bind its parameters. +/// +/// +/// +/// To run a task MSBuild normally loads the task assembly by path, resolves the task , and +/// constructs an instance - all reflective, and incompatible with trimming and Native AOT. A host that +/// references its task assemblies statically (for example the .NET SDK CLI when AOT-compiled) can instead +/// register those tasks here at startup, so the engine instantiates them with no assembly probing or +/// by-name type resolution. +/// +/// +/// A registration captures everything reflection-sensitive at registration time, where the task type is +/// statically known. The generic overload roots the task type's public +/// constructor and properties for trimming via its [DynamicallyAccessedMembers], so the +/// it builds (a walk) stays trim-safe and the +/// engine never re-reflects the type by name. Hosts register through the public +/// Microsoft.Build.Utilities.Task.RegisterTask methods, which forward here. +/// +/// +/// This type lives in Microsoft.Build.Framework - the lowest assembly - so the engine +/// (Microsoft.Build), the task library (Microsoft.Build.Tasks.Core, which pre-registers the common +/// built-in tasks), and the public registration surface (Microsoft.Build.Utilities) can all reach it. +/// +/// +internal static class TaskClassRegistry +{ + /// + /// Maps a task name to its registration. Keyed case-insensitively to mirror the case-insensitive task + /// lookup the engine's TaskRegistry performs. + /// + private static readonly ConcurrentDictionary s_tasksByName = new(StringComparer.OrdinalIgnoreCase); + + /// + /// Set once any task has been registered. The overwhelmingly common case is a + /// host that never registers, so this lets skip hashing the task name + /// and probing the dictionary on every task invocation until a registration actually exists. Written + /// after the entry is published to the dictionary, so a reader observing sees it. + /// + private static volatile bool s_hasRegistrations; + + /// + /// Registers a task type under the given name so the engine can instantiate it without loading its + /// assembly or resolving its type by reflection. The [DynamicallyAccessedMembers] roots the + /// type's public constructor and properties so construction and reflective parameter binding stay + /// trim-safe in a trimmed/AOT image. + /// + /// The task type to register. + /// The name a target uses to invoke the task (the TaskName of a <UsingTask>). + internal static void Register<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor | DynamicallyAccessedMemberTypes.PublicProperties)] T>(string taskName) + where T : ITask, new() + { + ArgumentException.ThrowIfNullOrEmpty(taskName); + + // typeof(T) carries T's [DynamicallyAccessedMembers] here, so building the LoadedType (a + // GetProperties walk) is trim-safe and is done once, eagerly, at registration. + LoadedType loadedType = CreateLoadedType(typeof(T)); + s_tasksByName[taskName] = new TaskClassRegistration(static () => new T(), loadedType); + s_hasRegistrations = true; + } + + /// + /// Registers a task under the given name with an explicit factory, so construction is fully + /// reflection-free (the host supplies the constructor). + /// + /// The name a target uses to invoke the task (the TaskName of a <UsingTask>). + /// A delegate that creates a new instance of the task. + internal static void Register(string taskName, Func factory) + { + ArgumentException.ThrowIfNullOrEmpty(taskName); + ArgumentNullException.ThrowIfNull(factory); + + // The host-supplied factory's task type is not statically known here, so the LoadedType (needed to + // bind parameters) is built lazily from the first constructed instance's type. The host is + // responsible for preserving that type's public properties under trimming (for example via the + // generic Register overload or a TrimmerRootAssembly entry). + s_tasksByName[taskName] = new TaskClassRegistration(factory); + s_hasRegistrations = true; + } + + /// + /// Looks up a registered task by name, with no reflection. + /// + /// The task name to resolve. + /// The matching registration, or if the name is not registered. + /// if a registration was found. + internal static bool TryGetRegistration(string taskName, [NotNullWhen(true)] out TaskClassRegistration? registration) + { + // Hot path: the engine calls this for every task invocation. When nothing is registered (the common + // case) skip the dictionary probe entirely. + if (!s_hasRegistrations) + { + registration = null; + return false; + } + + return s_tasksByName.TryGetValue(taskName, out registration); + } + + /// + /// Builds a for an already-loaded, trim-rooted task type. Only reflects over the + /// type's properties (trim-safe given the [DynamicallyAccessedMembers] on ), + /// with synthetic assembly-name load info so no assembly is loaded by path. + /// + internal static LoadedType CreateLoadedType( + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor | DynamicallyAccessedMemberTypes.PublicProperties)] Type taskType) + { + Assembly assembly = taskType.Assembly; + return new LoadedType(taskType, AssemblyLoadInfo.Create(assembly.FullName, null), assembly, typeof(ITaskItem)); + } +} diff --git a/src/Framework/TaskParameterTypeRegistry.cs b/src/Framework/TaskParameterTypeRegistry.cs new file mode 100644 index 00000000000..ea1f8201044 --- /dev/null +++ b/src/Framework/TaskParameterTypeRegistry.cs @@ -0,0 +1,128 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; + +namespace Microsoft.Build.Framework; + +/// +/// A statically-known, reflection-free map from a task parameter type name (the ParameterType +/// string of a <UsingTask> <ParameterGroup> parameter) to its . +/// +/// +/// +/// MSBuild restricts task parameter types to a small, well-defined set (see +/// TaskParameterTypeVerifier): any value type, , and the +/// family - each also allowed as an array. Resolving such a type from its +/// declared name historically used , which is incompatible with +/// trimming and Native AOT (the type cannot be determined statically, so the trimmer cannot know to +/// preserve it). +/// +/// +/// This registry replaces that lookup for the common, product-known types: the intrinsic value types, +/// , and the MSBuild types are pre-registered here, so they +/// resolve with no reflection in a trimmed/AOT image. Registered value types are member-rooted +/// for trimming (a value parameter may be converted from its string form) via the +/// [DynamicallyAccessedMembers] on ; item types are +/// validated by assignability only and are never member-reflected, so they need no member rooting. A host +/// that uses additional task parameter types can register them at startup through +/// Microsoft.Build.Utilities.TaskItem.RegisterTaskParameterValueType and +/// RegisterTaskParameterItemType, which forward here. +/// +/// +/// Names that the registry does not know fall back to only when the +/// Microsoft.Build.EnableReflectiveTaskParameterTypes feature switch is enabled (the JIT default); +/// in a trimmed/AOT application that switch is substituted and an unknown name +/// fails observably instead. This type lives in Microsoft.Build.Framework so both the engine +/// (Microsoft.Build) and the public registration surface (Microsoft.Build.Utilities) can reach it. +/// +/// +internal static class TaskParameterTypeRegistry +{ + /// + /// Maps a type's (for example System.Int32, System.Int32[], + /// or Microsoft.Build.Framework.ITaskItem) to the type. Keyed case-insensitively to mirror the + /// case-insensitive fallback the by-name resolution used. + /// + private static readonly ConcurrentDictionary s_typesByName = new(StringComparer.OrdinalIgnoreCase); + + static TaskParameterTypeRegistry() + { + // string is the single most common task parameter type. It is neither a value type nor an + // ITaskItem, so it is registered directly; the BCL already preserves the members it needs. + Add(typeof(string)); + Add(typeof(string[])); + + // The intrinsic value types MSBuild accepts as task parameter types. Each call also roots the + // type (and its array form) for trimming via the [DynamicallyAccessedMembers] on RegisterValueType. + RegisterValueType(); + RegisterValueType(); + RegisterValueType(); + RegisterValueType(); + RegisterValueType(); + RegisterValueType(); + RegisterValueType(); + RegisterValueType(); + RegisterValueType(); + RegisterValueType(); + RegisterValueType(); + RegisterValueType(); + RegisterValueType(); + RegisterValueType(); + + // The MSBuild ITaskItem types visible from Framework. The concrete item types in higher assemblies + // cannot be referenced from here: the engine's internal ProjectItemInstance.TaskItem is registered + // from Microsoft.Build, and the public Microsoft.Build.Utilities.TaskItem - a higher-layer type the + // engine does not reference - is registered by a host through the public API if it is declared as a + // parameter type. (The out-of-proc task host's private TaskParameterTaskItem is never declared, so + // it is not registered.) + RegisterTaskItemType(); + RegisterTaskItemType(); + RegisterTaskItemType(); + } + + /// + /// Registers a value type (and its array form) as a resolvable task parameter type, rooting it for + /// trimming so it can be resolved with no reflection in a trimmed/AOT image. + /// + /// The value type to register. Enums and user-defined structs are permitted. + internal static void RegisterValueType<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] T>() + where T : struct + { + Add(typeof(T)); + Add(typeof(T[])); + } + + /// + /// Registers an type (and its array form) as a resolvable task parameter type. + /// Item-typed parameters are validated by assignability and are never member-reflected through the + /// registry, so only the type reference is needed (no [DynamicallyAccessedMembers] rooting) and + /// resolution stays reflection-free in a trimmed/AOT image. + /// + /// The type to register. + internal static void RegisterTaskItemType() + where T : ITaskItem + { + Add(typeof(T)); + Add(typeof(T[])); + } + + /// + /// Looks up a task parameter type by its declared name, with no reflection. + /// + /// The expanded ParameterType name, for example System.String. + /// The registered , or if no type is registered under that name. + internal static Type? TryGetType(string typeName) => + s_typesByName.TryGetValue(typeName, out Type? type) ? type : null; + + private static void Add(Type type) + { + string? fullName = type.FullName; + if (fullName is not null) + { + s_typesByName[fullName] = type; + } + } +} diff --git a/src/Framework/TestInfo.cs b/src/Framework/TestInfo.cs index 71540225ae4..77a54106e36 100644 --- a/src/Framework/TestInfo.cs +++ b/src/Framework/TestInfo.cs @@ -3,14 +3,13 @@ namespace Microsoft.Build.Framework { - // This is a central place to keep track of whether tests are running or not. Test startup code - // will set this to true. It is consumed in BuildEnvironmentHelper. However, since that class - // is compiled into each project separately, it's not possible for the test startup code to - // interact directly with the BuildEnvironmentHelper class - hence this central location. + // Central flag for whether tests are running. The test host (TestAssemblyInfo) sets this to + // true at startup; BuildEnvironmentHelper, which is compiled into this same assembly + // (Microsoft.Build.Framework), reads it directly. - // This class is accessed via reflection, because adding the InternalsVisibleTo attributes which - // would be required to access it statically causes errors due to other shared internal classes - // which are compiled into multiple projects. + // The test host sets the field by reflection: TestAssemblyInfo is compiled into every test + // assembly, and reflection lets that one shared file set the flag without requiring an + // InternalsVisibleTo entry from Microsoft.Build.Framework for each of those assemblies. internal static class TestInfo { public static bool s_runningTests = false; diff --git a/src/Framework/Utilities/TypeExtensions.cs b/src/Framework/Utilities/TypeExtensions.cs index 50dd1931cbb..1639845587a 100644 --- a/src/Framework/Utilities/TypeExtensions.cs +++ b/src/Framework/Utilities/TypeExtensions.cs @@ -27,8 +27,16 @@ internal static class TypeExtensions extension(Type type) { + [UnconditionalSuppressMessage("SingleFile", "IL3000", + Justification = "Assembly.Location is empty under single-file/Native AOT; the empty result is handled here (and AOT hosts supply the MSBuild path via MSBUILD_EXE_PATH rather than relying on it).")] public string GetAssemblyPath() - => Path.GetFullPath(type.Assembly.Location); + { + // Path.GetFullPath throws on an empty string, which is exactly what Assembly.Location returns + // for a single-file/Native AOT app, so return the empty path as-is in that case. In a hosted + // (non-single-file) host Location is populated and this behaves as before. + string location = type.Assembly.Location; + return location.Length == 0 ? location : Path.GetFullPath(location); + } /// /// Returns a boxed zero-initialized instance when the receiver is a value type, or diff --git a/src/Framework/buildTransitive/Microsoft.Build.Framework.targets b/src/Framework/buildTransitive/Microsoft.Build.Framework.targets new file mode 100644 index 00000000000..f6758f310bb --- /dev/null +++ b/src/Framework/buildTransitive/Microsoft.Build.Framework.targets @@ -0,0 +1,32 @@ + + + + + + false + false + true + false + false + false + false + false + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/MSBuild/OutOfProcTaskAppDomainWrapperBase.cs b/src/MSBuild/OutOfProcTaskAppDomainWrapperBase.cs index 023e4aca22c..5266a31c6e4 100644 --- a/src/MSBuild/OutOfProcTaskAppDomainWrapperBase.cs +++ b/src/MSBuild/OutOfProcTaskAppDomainWrapperBase.cs @@ -122,7 +122,7 @@ internal OutOfProcTaskHostTaskResult ExecuteTask( LoadedType taskType = null; try { - TypeLoader typeLoader = new(TaskLoader.IsTaskClass); + TypeLoader typeLoader = TypeLoader.Create(); taskType = typeLoader.Load( taskName, AssemblyLoadInfo.Create(null, taskLocation), diff --git a/src/Shared/TaskEngineAssemblyResolver.cs b/src/Shared/TaskEngineAssemblyResolver.cs index 153ce441e68..925e0abf6e8 100644 --- a/src/Shared/TaskEngineAssemblyResolver.cs +++ b/src/Shared/TaskEngineAssemblyResolver.cs @@ -85,21 +85,27 @@ internal void RemoveHandler() } } - #if FEATURE_APPDOMAIN /// /// This is an assembly resolution handler necessary for fixing up types instantiated in different /// AppDomains and loaded with a Assembly.LoadFrom equivalent call. See comments in TaskEngine.ExecuteTask /// for more details. /// - /// - /// - /// internal Assembly ResolveAssembly(object sender, ResolveEventArgs args) #else private Assembly ResolveAssembly(AssemblyLoadContext assemblyLoadContext, AssemblyName assemblyName) #endif { +#if NET + // When custom plugin/task assembly probing is disabled (for example when trimmed), don't + // resolve task assemblies by path; let the default resolution fail rather than reflect. This + // guard also lets the trim analyzer treat the reflection-based load below as unreachable. + if (!Framework.FeatureSwitches.EnableCustomPluginProbing) + { + return null; + } +#endif + // Is this our task assembly? if (_taskAssemblyFile != null) { diff --git a/src/Shared/TaskLoader.cs b/src/Shared/TaskLoader.cs index 5ceb24cc8ec..73bd09fad77 100644 --- a/src/Shared/TaskLoader.cs +++ b/src/Shared/TaskLoader.cs @@ -3,12 +3,9 @@ using System; using Microsoft.Build.Framework; -#if FEATURE_APPDOMAIN -using Microsoft.Build.Shared.Debugging; -#endif - #if FEATURE_APPDOMAIN using System.Reflection; +using Microsoft.Build.Shared.Debugging; #endif namespace Microsoft.Build.Shared @@ -31,17 +28,6 @@ internal static class TaskLoader /// internal delegate void LogError(string taskLocation, int taskLine, int taskColumn, string message, params object[] messageArgs); - /// - /// Checks if the given type is a task factory. - /// - /// This method is used as a type filter delegate. - /// true, if specified type is a task - internal static bool IsTaskClass(Type type, object unused) - { - return type.IsClass && !type.IsAbstract && - type.GetInterface("Microsoft.Build.Framework.ITask") != null; - } - /// /// Creates an ITask instance and returns it. /// diff --git a/src/Shared/TypeLoader.cs b/src/Shared/TypeLoader.cs index 365355cf620..e112b65fb4a 100644 --- a/src/Shared/TypeLoader.cs +++ b/src/Shared/TypeLoader.cs @@ -5,6 +5,7 @@ using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; using System.Reflection; @@ -51,19 +52,20 @@ internal class TypeLoader private const string VersioningNamespaceName = "System.Runtime.Versioning"; /// - /// Cache to keep track of the assemblyLoadInfos based on a given type filter. + /// Cache to keep track of the assemblyLoadInfos based on the desired interface. /// - private static readonly ConcurrentDictionary, ConcurrentDictionary> s_cacheOfLoadedTypesByFilter = new ConcurrentDictionary, ConcurrentDictionary>(); + private static readonly ConcurrentDictionary> s_cacheOfLoadedTypesByFilter = new ConcurrentDictionary>(); /// - /// Cache to keep track of the assemblyLoadInfos based on a given type filter for assemblies which are to be loaded for reflectionOnlyLoads. + /// Cache to keep track of the assemblyLoadInfos based on the desired interface for assemblies which are to be loaded for reflectionOnlyLoads. /// - private static readonly ConcurrentDictionary, ConcurrentDictionary> s_cacheOfReflectionOnlyLoadedTypesByFilter = new ConcurrentDictionary, ConcurrentDictionary>(); + private static readonly ConcurrentDictionary> s_cacheOfReflectionOnlyLoadedTypesByFilter = new ConcurrentDictionary>(); /// - /// Type filter for this typeloader. + /// The interface that loaded types must implement. Stored as a so that construction is + /// trim-safe; the actual interface lookup happens by name inside the [RequiresUnreferencedCode] load methods. /// - private Func _isDesiredType; + private readonly Type _desiredInterface; private static readonly string[] runtimeAssemblies = findRuntimeAssembliesWithMicrosoftBuildFramework(); @@ -114,13 +116,24 @@ private static string[] FindRuntimeAssembliesWithMicrosoftBuildFrameworkCLR2CLR3 /// /// Constructor. /// - internal TypeLoader(Func isDesiredType) + private TypeLoader(Type desiredInterface) { - Assumed.NotNull(isDesiredType, "need a type filter"); - - _isDesiredType = isDesiredType; + _desiredInterface = desiredInterface; } + /// + /// Creates a that selects concrete, public types implementing + /// (for example or ). + /// + /// + /// Capturing the interface as data (rather than a filter delegate) keeps construction trim-safe: no reflection + /// happens here, so callers in field initializers and other contexts that are not [RequiresUnreferencedCode] do + /// not produce trim warnings. The interface match runs later, by name, inside the load methods that are already + /// annotated [RequiresUnreferencedCode]; name matching is also the only test that works for types inspected + /// through a . + /// + internal static TypeLoader Create() where TInterface : class => new TypeLoader(typeof(TInterface)); + /// /// Delegate used to log warning messages with formatted string support. /// @@ -214,6 +227,7 @@ internal static bool IsPartialTypeNameMatch(string typeName1, string typeName2) /// /// /// + [RequiresUnreferencedCode("Loads task and factory assemblies discovered at runtime, which is incompatible with trimming.")] private static Assembly LoadAssembly(AssemblyLoadInfo assemblyLoadInfo) { try @@ -303,6 +317,7 @@ private static void AddAssembliesToDictionary(Dictionary assembl /// any) is unambiguous; otherwise, if there are multiple types with the same name in different namespaces, the first type /// found will be returned. /// + [RequiresUnreferencedCode("Loads types by reflecting over assemblies discovered at runtime, which is incompatible with trimming.")] internal LoadedType Load( string typeName, AssemblyLoadInfo assembly, @@ -319,6 +334,7 @@ internal LoadedType Load( /// found will be returned. /// /// The loaded type, or null if the type was not found. + [RequiresUnreferencedCode("Loads types by reflecting over assemblies discovered at runtime, which is incompatible with trimming.")] internal LoadedType ReflectionOnlyLoad( string typeName, AssemblyLoadInfo assembly) => GetLoadedType(s_cacheOfReflectionOnlyLoadedTypesByFilter, typeName, assembly, useTaskHost: false, taskHostParamsMatchCurrentProc: true, logWarning: (format, args) => { }); @@ -328,22 +344,23 @@ internal LoadedType ReflectionOnlyLoad( /// any) is unambiguous; otherwise, if there are multiple types with the same name in different namespaces, the first type /// found will be returned. /// + [RequiresUnreferencedCode("Loads types by reflecting over assemblies discovered at runtime, which is incompatible with trimming.")] private LoadedType GetLoadedType( - ConcurrentDictionary, ConcurrentDictionary> cache, + ConcurrentDictionary> cache, string typeName, AssemblyLoadInfo assembly, bool useTaskHost, bool taskHostParamsMatchCurrentProc, LogWarningDelegate logWarning) { - // A given type filter have been used on a number of assemblies, Based on the type filter we will get another dictionary which + // A given interface has been used on a number of assemblies. Based on the interface we will get another dictionary which // will map a specific AssemblyLoadInfo to a AssemblyInfoToLoadedTypes class which knows how to find a typeName in a given assembly. ConcurrentDictionary loadInfoToType = - cache.GetOrAdd(_isDesiredType, (_) => new ConcurrentDictionary()); + cache.GetOrAdd(_desiredInterface, (_) => new ConcurrentDictionary()); // Get an object which is able to take a typename and determine if it is in the assembly pointed to by the AssemblyInfo. AssemblyInfoToLoadedTypes typeNameToType = - loadInfoToType.GetOrAdd(assembly, (_) => new AssemblyInfoToLoadedTypes(_isDesiredType, _)); + loadInfoToType.GetOrAdd(assembly, (_) => new AssemblyInfoToLoadedTypes(_desiredInterface, _)); return typeNameToType.GetLoadedTypeByTypeName(typeName, useTaskHost, taskHostParamsMatchCurrentProc, logWarning); } @@ -354,7 +371,7 @@ private LoadedType GetLoadedType( /// /// This type represents a combination of a type filter and an assemblyInfo object. /// - [DebuggerDisplay("Types in {_assemblyLoadInfo} matching {_isDesiredType}")] + [DebuggerDisplay("Types in {_assemblyLoadInfo} matching {_desiredInterface}")] private class AssemblyInfoToLoadedTypes { /// @@ -364,9 +381,14 @@ private class AssemblyInfoToLoadedTypes private readonly LockType _lockObject = new(); /// - /// Type filter to pick the correct types out of an assembly + /// The interface that selected types must implement. /// - private Func _isDesiredType; + private readonly Type _desiredInterface; + + /// + /// The full name of , cached for the by-name interface lookup. + /// + private readonly string _desiredInterfaceName; /// /// Assembly load information so we can load an assembly @@ -415,23 +437,39 @@ private class AssemblyInfoToLoadedTypes private volatile bool _hasReadRuntimeAndArchitecture; /// - /// Given a type filter, and an assembly to load the type information from determine if a given type name is in the assembly or not. + /// Given a desired interface, and an assembly to load the type information from determine if a given type name is in the assembly or not. /// - internal AssemblyInfoToLoadedTypes(Func typeFilter, AssemblyLoadInfo loadInfo) + internal AssemblyInfoToLoadedTypes(Type desiredInterface, AssemblyLoadInfo loadInfo) { - ArgumentNullException.ThrowIfNull(typeFilter, "typefilter"); + ArgumentNullException.ThrowIfNull(desiredInterface); ArgumentNullException.ThrowIfNull(loadInfo); - _isDesiredType = typeFilter; + _desiredInterface = desiredInterface; + _desiredInterfaceName = desiredInterface.FullName; _assemblyLoadInfo = loadInfo; _typeNameToType = new(StringComparer.OrdinalIgnoreCase); _publicTypeNameToType = new Dictionary(StringComparer.OrdinalIgnoreCase); _publicTypeNameToLoadedType = new(StringComparer.OrdinalIgnoreCase); } + /// + /// Determines whether is a concrete, public class that implements the desired interface. + /// + /// + /// The interface is matched by name rather than with typeof(...).IsAssignableFrom because the candidate + /// type may have been inspected through a , whose reflection universe is + /// separate from the running one (so IsAssignableFrom would always be false). The + /// call is trim-unsafe, but this method is only reachable from the + /// [RequiresUnreferencedCode] load paths, which load task and logger assemblies discovered at runtime. + /// + [RequiresUnreferencedCode("Matches a runtime-discovered type against the desired interface by reflecting over its interface list, which is incompatible with trimming.")] + private bool IsDesiredType(Type type) => + type.IsClass && !type.IsAbstract && type.GetInterface(_desiredInterfaceName) is not null; + /// /// Determine if a given type name is in the assembly or not. Return null if the type is not in the assembly. /// + [RequiresUnreferencedCode("Loads types by reflecting over assemblies discovered at runtime, which is incompatible with trimming.")] internal LoadedType GetLoadedTypeByTypeName( string typeName, bool useTaskHost, @@ -470,6 +508,7 @@ internal LoadedType GetLoadedTypeByTypeName( /// This loads the assembly for actual execution (not metadata-only). /// /// The type to be loaded. + [RequiresUnreferencedCode("Loads types by reflecting over assemblies discovered at runtime, which is incompatible with trimming.")] private LoadedType LoadInProc(string typeName) { Type type = _typeNameToType.GetOrAdd(typeName, (key) => @@ -482,7 +521,7 @@ private LoadedType LoadInProc(string typeName) Type t2 = Type.GetType(typeName + "," + _assemblyLoadInfo.AssemblyName, false /* don't throw on error */, true /* case-insensitive */); if (t2 != null) { - return !_isDesiredType(t2, null) ? null : t2; + return !IsDesiredType(t2) ? null : t2; } } catch (ArgumentException) @@ -530,6 +569,7 @@ private LoadedType LoadInProc(string typeName) private bool ShouldUseMetadataLoadContext(bool useTaskHost, bool taskHostParamsMatchCurrentProc) => (useTaskHost || !taskHostParamsMatchCurrentProc) && _assemblyLoadInfo.AssemblyFile is not null; + [RequiresUnreferencedCode("Loads types by reflecting over assemblies discovered at runtime, which is incompatible with trimming.")] private LoadedType GetTypeForOutOfProcExecution(string typeName) => _publicTypeNameToLoadedType .GetOrAdd(typeName, typeName => { @@ -545,7 +585,7 @@ private LoadedType GetTypeForOutOfProcExecution(string typeName) => _publicTypeN if (!string.IsNullOrEmpty(typeName)) { foundType = loadedAssembly.GetType(typeName, throwOnError: false); - if (foundType != null && foundType.IsPublic && _isDesiredType(foundType, null)) + if (foundType != null && foundType.IsPublic && IsDesiredType(foundType)) { numberOfTypesSearched = 1; } @@ -559,7 +599,7 @@ private LoadedType GetTypeForOutOfProcExecution(string typeName) => _publicTypeN numberOfTypesSearched++; try { - if (_isDesiredType(publicType, null) && (typeName.Length == 0 || IsPartialTypeNameMatch(publicType.FullName, typeName))) + if (IsDesiredType(publicType) && (typeName.Length == 0 || IsPartialTypeNameMatch(publicType.FullName, typeName))) { foundType = publicType; break; @@ -591,6 +631,7 @@ private LoadedType GetTypeForOutOfProcExecution(string typeName) => _publicTypeN /// /// Gets architecture and runtime from the assembly using MetadataLoadContext. /// + [RequiresUnreferencedCode("Reflects over a runtime-loaded assembly to determine its target runtime and architecture, which is incompatible with trimming.")] private void SetArchitectureAndRuntime(Assembly assembly) { if (_hasReadRuntimeAndArchitecture) @@ -683,6 +724,7 @@ void SetArchitecture() /// Scan the assembly pointed to by the assemblyLoadInfo for public types. We will use these public types to do partial name matching on /// to find tasks, loggers, and task factories. /// + [RequiresUnreferencedCode("Loads and reflects over a runtime-discovered assembly's public types, which is incompatible with trimming.")] private void ScanAssemblyForPublicTypes() { // we need to search the assembly for the type... @@ -692,7 +734,7 @@ private void ScanAssemblyForPublicTypes() Type[] allPublicTypesInAssembly = _loadedAssembly.GetExportedTypes(); foreach (Type publicType in allPublicTypesInAssembly) { - if (_isDesiredType(publicType, null)) + if (IsDesiredType(publicType)) { _publicTypeNameToType.Add(publicType.FullName, publicType); } diff --git a/src/Shared/UnitTests/TypeLoader_Tests.cs b/src/Shared/UnitTests/TypeLoader_Tests.cs index 3494f3f3bd6..820d5c3595f 100644 --- a/src/Shared/UnitTests/TypeLoader_Tests.cs +++ b/src/Shared/UnitTests/TypeLoader_Tests.cs @@ -111,7 +111,7 @@ public void LoadTaskDependingOnMSBuild() string utilities = Path.Combine(portableTaskPath, utilitiesName); File.Copy(utilities, Path.Combine(folder.Path, utilitiesName)); File.Copy(currentAssembly, newAssemblyLocation); - TypeLoader typeLoader = new(TaskLoader.IsTaskClass); + TypeLoader typeLoader = TypeLoader.Create(); // If we cannot accept MSBuild next to the task assembly we're loading, this will throw. typeLoader.Load("TypeLoader_Tests", AssemblyLoadInfo.Create(null, newAssemblyLocation), logWarning: (format, args) => { }, useTaskHost: true); @@ -234,13 +234,13 @@ private void CheckIfCorrectAssemblyLoaded(string scriptOutput, string expectedAs public void Regress640476PartialName() { string forwardingLoggerLocation = typeof(Microsoft.Build.Logging.ConfigurableForwardingLogger).Assembly.Location; - TypeLoader loader = new TypeLoader(IsForwardingLoggerClass); + TypeLoader loader = TypeLoader.Create(); LoadedType loadedType = loader.Load("ConfigurableForwardingLogger", AssemblyLoadInfo.Create(null, forwardingLoggerLocation), logWarning: (format, args) => { }); Assert.NotNull(loadedType); Assert.Equal(forwardingLoggerLocation, loadedType.Assembly.AssemblyLocation); string fileLoggerLocation = typeof(Microsoft.Build.Logging.FileLogger).Assembly.Location; - loader = new TypeLoader(IsLoggerClass); + loader = TypeLoader.Create(); loadedType = loader.Load("FileLogger", AssemblyLoadInfo.Create(null, fileLoggerLocation), logWarning: (format, args) => { }); Assert.NotNull(loadedType); Assert.Equal(fileLoggerLocation, loadedType.Assembly.AssemblyLocation); @@ -255,14 +255,14 @@ public void Regress640476FullyQualifiedName() { Type forwardingLoggerType = typeof(Microsoft.Build.Logging.ConfigurableForwardingLogger); string forwardingLoggerLocation = forwardingLoggerType.Assembly.Location; - TypeLoader loader = new TypeLoader(IsForwardingLoggerClass); + TypeLoader loader = TypeLoader.Create(); LoadedType loadedType = loader.Load(forwardingLoggerType.FullName, AssemblyLoadInfo.Create(null, forwardingLoggerLocation), logWarning: (format, args) => { }); Assert.NotNull(loadedType); Assert.Equal(forwardingLoggerLocation, loadedType.Assembly.AssemblyLocation); Type fileLoggerType = typeof(Microsoft.Build.Logging.FileLogger); string fileLoggerLocation = fileLoggerType.Assembly.Location; - loader = new TypeLoader(IsLoggerClass); + loader = TypeLoader.Create(); loadedType = loader.Load(fileLoggerType.FullName, AssemblyLoadInfo.Create(null, fileLoggerLocation), logWarning: (format, args) => { }); Assert.NotNull(loadedType); Assert.Equal(fileLoggerLocation, loadedType.Assembly.AssemblyLocation); @@ -282,7 +282,7 @@ public void NoTypeNamePicksFirstType() Func forwardingLoggerfilter = IsForwardingLoggerClass; Type firstPublicType = FirstPublicDesiredType(forwardingLoggerfilter, forwardingLoggerAssemblyLocation); - TypeLoader loader = new TypeLoader(forwardingLoggerfilter); + TypeLoader loader = TypeLoader.Create(); LoadedType loadedType = loader.Load(String.Empty, AssemblyLoadInfo.Create(null, forwardingLoggerAssemblyLocation), logWarning: (format, args) => { }); Assert.NotNull(loadedType); Assert.Equal(forwardingLoggerAssemblyLocation, loadedType.Assembly.AssemblyLocation); @@ -297,7 +297,7 @@ public void NoTypeNamePicksFirstType() Func fileLoggerfilter = IsLoggerClass; firstPublicType = FirstPublicDesiredType(fileLoggerfilter, fileLoggerAssemblyLocation); - loader = new TypeLoader(fileLoggerfilter); + loader = TypeLoader.Create(); loadedType = loader.Load(String.Empty, AssemblyLoadInfo.Create(null, fileLoggerAssemblyLocation), logWarning: (format, args) => { }); Assert.NotNull(loadedType); Assert.Equal(fileLoggerAssemblyLocation, loadedType.Assembly.AssemblyLocation); diff --git a/src/Tasks/BuiltInTasks.cs b/src/Tasks/BuiltInTasks.cs new file mode 100644 index 00000000000..75ba0ede789 --- /dev/null +++ b/src/Tasks/BuiltInTasks.cs @@ -0,0 +1,37 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.Build.Tasks +{ + /// + /// Pre-registers the commonly used built-in MSBuild tasks with the host task registry so they run with + /// no assembly loading or reflection - the path required in a trimmed or Native AOT host. + /// + /// + /// The MSBuild engine (Microsoft.Build) cannot reference this task assembly + /// (Microsoft.Build.Tasks.Core), so these registrations are published from here. A host that runs the + /// engine in-process under trimming/AOT calls once at startup, before its + /// first build, after which a stock build can run these tasks with the reflective task-execution path + /// disabled. Each registration roots the task type's public constructor and properties for trimming, so + /// construction and parameter binding stay trim-safe. + /// + public static class BuiltInTasks + { + /// + /// Registers the commonly used built-in tasks with the host task registry. + /// + public static void RegisterAll() + { + Utilities.Task.RegisterTask(nameof(Message)); + Utilities.Task.RegisterTask(nameof(Warning)); + Utilities.Task.RegisterTask(nameof(Error)); + Utilities.Task.RegisterTask(nameof(MakeDir)); + Utilities.Task.RegisterTask(nameof(RemoveDir)); + Utilities.Task.RegisterTask(nameof(Copy)); + Utilities.Task.RegisterTask(nameof(Delete)); + Utilities.Task.RegisterTask(nameof(Touch)); + Utilities.Task.RegisterTask(nameof(WriteLinesToFile)); + Utilities.Task.RegisterTask(nameof(ReadLinesFromFile)); + } + } +} diff --git a/src/Tasks/CodeTaskFactory.cs b/src/Tasks/CodeTaskFactory.cs index 357e7f7361f..a7b0abc03e5 100644 --- a/src/Tasks/CodeTaskFactory.cs +++ b/src/Tasks/CodeTaskFactory.cs @@ -170,6 +170,7 @@ private static Assembly CurrentDomainOnAssemblyResolve(object sender, ResolveEve /// /// Gets the type of the generated task. /// + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] public Type TaskType { get; private set; } public string GetAssemblyPath() => _assemblyPath; @@ -187,6 +188,7 @@ public TaskPropertyInfo[] GetTaskParameters() /// /// Initializes the task factory. /// + [RequiresUnreferencedCode("Compiles and loads a task assembly at runtime and reflects over its types, which is incompatible with trimming.")] public bool Initialize(string taskName, IDictionary taskParameters, string taskElementContents, IBuildEngine taskFactoryLoggingHost) { _nameOfTask = taskName; @@ -326,6 +328,7 @@ public bool Initialize(string taskName, IDictionary ta /// /// Create a taskfactory instance which contains the data that needs to be refreshed between task invocations. /// + [RequiresUnreferencedCode("Instantiates a task type from an assembly compiled at runtime, which is incompatible with trimming.")] public ITask CreateTask(IBuildEngine loggingHost) { // The assembly will have been compiled during class factory initialization, create an instance of it @@ -1122,6 +1125,8 @@ int IComparable.CompareTo(FullTaskSpecification other) } } #else + using System.Diagnostics.CodeAnalysis; + /// /// A task factory which can take code dom supported languages and create a task out of it /// @@ -1132,8 +1137,10 @@ public sealed class CodeTaskFactory : ITaskFactory { public string FactoryName => "Code Task Factory"; + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] public Type TaskType { get; } = null; + [RequiresUnreferencedCode("The CodeTaskFactory is not supported on .NET Core.")] public bool Initialize(string taskName, IDictionary parameterGroup, string taskBody, IBuildEngine taskFactoryLoggingHost) { TaskLoggingHelper log = new TaskLoggingHelper(taskFactoryLoggingHost, taskName) @@ -1152,6 +1159,7 @@ public TaskPropertyInfo[] GetTaskParameters() throw new NotSupportedException(); } + [RequiresUnreferencedCode("The CodeTaskFactory is not supported on .NET Core.")] public ITask CreateTask(IBuildEngine taskFactoryLoggingHost) { throw new NotSupportedException(); diff --git a/src/Tasks/GenerateManifestBase.cs b/src/Tasks/GenerateManifestBase.cs index 05793c597b5..4faed71110c 100644 --- a/src/Tasks/GenerateManifestBase.cs +++ b/src/Tasks/GenerateManifestBase.cs @@ -4,6 +4,9 @@ using System; using System.Diagnostics.CodeAnalysis; using System.IO; +#if NET +using System.Runtime.CompilerServices; +#endif using Microsoft.Build.Framework; using Microsoft.Build.Shared; using Microsoft.Build.Tasks.Deployment.ManifestUtilities; @@ -276,8 +279,6 @@ private AssemblyIdentity CreateAssemblyIdentity(AssemblyIdentity baseIdentity, A [UnconditionalSuppressMessage("TrimAnalysis", "IL2026:RequiresUnreferencedCode", Justification = "ClickOnce manifest generation reads and writes manifests with XmlSerializer; this task is inherently incompatible with trimming.")] - [UnconditionalSuppressMessage("AotAnalysis", "IL3050:RequiresDynamicCode", - Justification = "ClickOnce manifest generation uses XmlSerializer and XslCompiledTransform; this task is inherently incompatible with Native AOT.")] public override bool Execute() { if (!NativeMethodsShared.IsWindows) @@ -286,6 +287,14 @@ public override bool Execute() return false; } +#if NET + if (!RuntimeFeature.IsDynamicCodeSupported) + { + Log.LogErrorWithCodeFromResources("GenerateManifest.General", "Dynamic code generation is not supported in this runtime environment."); + return false; + } +#endif + bool success = true; Type manifestType = GetObjectType(); diff --git a/src/Tasks/Microsoft.Build.Tasks.csproj b/src/Tasks/Microsoft.Build.Tasks.csproj index c766af3919b..7df26a14bb7 100644 --- a/src/Tasks/Microsoft.Build.Tasks.csproj +++ b/src/Tasks/Microsoft.Build.Tasks.csproj @@ -50,6 +50,7 @@ + AssemblyDependency\AssemblyFoldersEx.cs diff --git a/src/Tasks/RoslynCodeTaskFactory/RoslynCodeTaskFactory.cs b/src/Tasks/RoslynCodeTaskFactory/RoslynCodeTaskFactory.cs index 5bc4b0dcc60..c774f0ba586 100644 --- a/src/Tasks/RoslynCodeTaskFactory/RoslynCodeTaskFactory.cs +++ b/src/Tasks/RoslynCodeTaskFactory/RoslynCodeTaskFactory.cs @@ -130,6 +130,7 @@ public sealed class RoslynCodeTaskFactory : ITaskFactory, IOutOfProcTaskFactory /// /// Gets the of the compiled task. /// + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] public Type TaskType { get; private set; } /// @@ -142,13 +143,12 @@ public void CleanupTask(ITask task) } /// - [UnconditionalSuppressMessage("TrimAnalysis", "IL2072:UnrecognizedReflectionPattern", - Justification = "TaskType is a type from an assembly compiled at runtime from user-supplied source, so its constructor cannot be statically preserved; this factory is inherently incompatible with trimming.")] + [RequiresUnreferencedCode("Instantiates a task type from an assembly compiled at runtime from user-supplied source, which is incompatible with trimming.")] public ITask CreateTask(IBuildEngine taskFactoryLoggingHost) { // The type of the task has already been determined and the assembly is already loaded after compilation so // just create an instance of the type and return it. - ITask taskInstance = Activator.CreateInstance(TaskType) as ITask; + ITask taskInstance = CreateTaskInstance(TaskType); if (taskInstance is null) { TaskLoggingHelper taskInvocationLog = new TaskLoggingHelper(taskFactoryLoggingHost, _taskName) @@ -162,6 +162,9 @@ public ITask CreateTask(IBuildEngine taskFactoryLoggingHost) return taskInstance; } + [RequiresUnreferencedCode("Instantiates a task type from an assembly compiled at runtime from user-supplied source; its parameterless constructor cannot be statically preserved.")] + private static ITask CreateTaskInstance(Type taskType) => Activator.CreateInstance(taskType) as ITask; + /// public TaskPropertyInfo[] GetTaskParameters() { @@ -172,10 +175,7 @@ public TaskPropertyInfo[] GetTaskParameters() public string GetAssemblyPath() => _assemblyPath; /// - [UnconditionalSuppressMessage("TrimAnalysis", "IL2026:RequiresUnreferencedCode", - Justification = "RoslynCodeTaskFactory compiles and loads a task assembly at runtime and reflects over its exported types; this is inherently incompatible with trimming.")] - [UnconditionalSuppressMessage("TrimAnalysis", "IL2075:UnrecognizedReflectionPattern", - Justification = "The task type comes from an assembly compiled at runtime from user-supplied source, so its properties cannot be statically preserved.")] + [RequiresUnreferencedCode("Compiles and loads a task assembly at runtime and reflects over its exported types, which is incompatible with trimming.")] public bool Initialize(string taskName, IDictionary parameterGroup, string taskBody, IBuildEngine taskFactoryLoggingHost) { _log = new TaskLoggingHelper(taskFactoryLoggingHost, taskName) @@ -202,6 +202,22 @@ public bool Initialize(string taskName, IDictionary pa return false; } + if (!TryResolveCompiledTaskType(assembly, parameterGroup, taskInfo, taskName)) + { + return false; + } + + // Initialization succeeded if we found a type matching the task name from the compiled assembly + return TaskType != null; + } + + /// + /// Reflects over the exported types of the runtime-compiled to locate the task type + /// and, when the user supplied a whole class, derive its parameters from the type's properties. + /// + [RequiresUnreferencedCode("Reflects over the exported types of an assembly compiled at runtime from user-supplied source, which is incompatible with trimming.")] + private bool TryResolveCompiledTaskType(Assembly assembly, IDictionary parameterGroup, RoslynCodeTaskFactoryTaskInfo taskInfo, string taskName) + { if (assembly != null) { Type[] exportedTypes = assembly.GetExportedTypes(); @@ -230,8 +246,7 @@ public bool Initialize(string taskName, IDictionary pa } } - // Initialization succeeded if we found a type matching the task name from the compiled assembly - return TaskType != null; + return true; } /// diff --git a/src/Tasks/XamlTaskFactory/XamlTaskFactory.cs b/src/Tasks/XamlTaskFactory/XamlTaskFactory.cs index 510ee36218a..f2fd0ec191b 100644 --- a/src/Tasks/XamlTaskFactory/XamlTaskFactory.cs +++ b/src/Tasks/XamlTaskFactory/XamlTaskFactory.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using Microsoft.Build.Framework; using Microsoft.Build.Shared; using Microsoft.Build.Utilities; @@ -80,6 +81,7 @@ public class XamlTaskFactory : ITaskFactory, IOutOfProcTaskFactory /// /// The task type object. /// + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] public Type TaskType { get @@ -97,6 +99,7 @@ public Type TaskType /// MSBuild engine will call this to initialize the factory. This should initialize the factory enough so that the factory can be asked /// whether or not task names can be created by the factory. /// + [RequiresUnreferencedCode("Generates and loads a task assembly at runtime and reflects over its types, which is incompatible with trimming.")] public bool Initialize(string taskName, IDictionary taskParameters, string taskElementContents, IBuildEngine taskFactoryLoggingHost) { ArgumentNullException.ThrowIfNull(taskName); @@ -217,6 +220,7 @@ public bool Initialize(string taskName, IDictionary ta /// Create an instance of the task to be used. /// /// The task factory logging host will log messages in the context of the task. + [RequiresUnreferencedCode("Instantiates a task type from an assembly generated at runtime, which is incompatible with trimming.")] public ITask CreateTask(IBuildEngine taskFactoryLoggingHost) { string fullTaskName = $"{TaskNamespace}.{TaskName}"; @@ -267,8 +271,10 @@ public sealed class XamlTaskFactory : ITaskFactory { public string FactoryName => "XamlTaskFactory"; + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] public Type TaskType { get; } = null; + [RequiresUnreferencedCode("The XamlTaskFactory is not supported on .NET Core.")] public bool Initialize(string taskName, IDictionary parameterGroup, string taskBody, IBuildEngine taskFactoryLoggingHost) { TaskLoggingHelper log = new TaskLoggingHelper(taskFactoryLoggingHost, taskName) @@ -287,6 +293,7 @@ public TaskPropertyInfo[] GetTaskParameters() throw new NotSupportedException(); } + [RequiresUnreferencedCode("The XamlTaskFactory is not supported on .NET Core.")] public ITask CreateTask(IBuildEngine taskFactoryLoggingHost) { throw new NotSupportedException(); diff --git a/src/Tasks/XslTransformation.cs b/src/Tasks/XslTransformation.cs index c3a48263f29..5de3e96725f 100644 --- a/src/Tasks/XslTransformation.cs +++ b/src/Tasks/XslTransformation.cs @@ -4,6 +4,9 @@ using System; using System.Diagnostics.CodeAnalysis; using System.IO; +#if NET +using System.Runtime.CompilerServices; +#endif using System.Xml; using System.Xml.XPath; using System.Xml.Xsl; @@ -101,14 +104,20 @@ public ITaskItem[] OutputPaths /// Executes the XslTransform task. /// /// true if transformation succeeds. - [UnconditionalSuppressMessage("AotAnalysis", "IL3050:RequiresDynamicCode", - Justification = "The XslTransformation task compiles the user-supplied stylesheet with XslCompiledTransform, which generates IL at runtime and is inherently incompatible with Native AOT.")] public override bool Execute() { XmlInput xmlinput; XsltInput xsltinput; ArgumentNullException.ThrowIfNull(_outputPaths, "OutputPath"); +#if NET + if (!RuntimeFeature.IsDynamicCodeSupported) + { + Log.LogErrorWithCodeFromResources("XslTransform.XsltLoadError", "Dynamic code generation is not supported in this runtime environment."); + return false; + } +#endif + // Load XmlInput, XsltInput parameters try { diff --git a/src/Utilities/Task.cs b/src/Utilities/Task.cs index 2c16effe9f1..4814a1fae04 100644 --- a/src/Utilities/Task.cs +++ b/src/Utilities/Task.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System; +using System.Diagnostics.CodeAnalysis; using System.Resources; using Microsoft.Build.Framework; @@ -156,5 +158,64 @@ protected string HelpKeywordPrefix public abstract bool Execute(); #endregion + + #region Task class registration + + /// + /// Registers a task type under the name a target uses to invoke it (the TaskName of a + /// <UsingTask>), so MSBuild can instantiate and run it without loading its assembly or + /// resolving its type by reflection - the path required in a trimmed or Native AOT host. + /// + /// + /// The task type to register. It must have a public parameterless constructor. The + /// [DynamicallyAccessedMembers] roots the type's public constructor and properties so a + /// trimmer preserves them, keeping both construction and parameter binding working. + /// + /// + /// The name a target uses to invoke the task. This is the TaskName of the corresponding + /// <UsingTask> (typically the task's class name, optionally namespace-qualified). + /// + /// + /// Intended to be called once per task during host initialization, before the first build. This + /// method is thread-safe; registering the same name again replaces the previous registration. A + /// registered name takes precedence over a project-level <UsingTask> of the same name, + /// and registration does not participate in Runtime/Architecture task-identity + /// selection - the registered task is always the one the engine constructs. + /// + public static void RegisterTask< + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor | DynamicallyAccessedMemberTypes.PublicProperties)] T>( + string taskName) + where T : ITask, new() + => TaskClassRegistry.Register(taskName); + + /// + /// Registers a task under the name a target uses to invoke it (the TaskName of a + /// <UsingTask>) with an explicit factory, so construction is fully reflection-free (the + /// host supplies the constructor). Use this for tasks without a public parameterless constructor or + /// that need custom construction. + /// + /// + /// The name a target uses to invoke the task. This is the TaskName of the corresponding + /// <UsingTask> (typically the task's class name, optionally namespace-qualified). + /// + /// A delegate that creates a new instance of the task. + /// + /// + /// Construction is reflection-free, but binding the task's parameters still reflects over its + /// properties. Because the task type is not statically known through this overload, the host is + /// responsible for preserving that type's public properties under trimming (for example by also + /// registering it through , which roots them). The generic + /// overload is the fully trim-safe path. + /// + /// + /// Intended to be called once per task during host initialization, before the first build. This + /// method is thread-safe; registering the same name again replaces the previous registration. A + /// registered name takes precedence over a project-level <UsingTask> of the same name. + /// + /// + public static void RegisterTask(string taskName, Func factory) + => TaskClassRegistry.Register(taskName, factory); + + #endregion } } diff --git a/src/Utilities/TaskItem.cs b/src/Utilities/TaskItem.cs index c25f4cf6148..9e625259bc8 100644 --- a/src/Utilities/TaskItem.cs +++ b/src/Utilities/TaskItem.cs @@ -5,6 +5,7 @@ using System.Collections; using System.Collections.Generic; using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; using System.Linq; #if FEATURE_APPDOMAIN using System.Runtime.Remoting; @@ -166,6 +167,59 @@ public TaskItem( #endregion + #region Task parameter type registration + + /// + /// Registers a value type so it can be used as a task parameter type (the ParameterType of a + /// <UsingTask> <ParameterGroup> parameter) in a trimmed or Native AOT host, + /// where resolving the type from its declared name by reflection is unavailable. + /// + /// + /// The value type to register. Enums and user-defined structs are permitted. The type and its array + /// form (T[]) both become resolvable, and the type is rooted so a trimmer preserves it. + /// + /// + /// + /// The MSBuild intrinsic value types (, , , + /// and so on), , and the MSBuild types are already + /// registered; call this only for an additional value type a host uses as a task parameter type. + /// + /// + /// Intended to be called once per type during host initialization, before the first project is + /// evaluated. This method is thread-safe and idempotent. + /// + /// + public static void RegisterTaskParameterValueType<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] T>() + where T : struct + => TaskParameterTypeRegistry.RegisterValueType(); + + /// + /// Registers an type so it can be used as a task parameter type (the + /// ParameterType of a <UsingTask> <ParameterGroup> parameter) in a + /// trimmed or Native AOT host, where resolving the type from its declared name by reflection is + /// unavailable. + /// + /// + /// The type to register. The type and its array form (T[]) both become + /// resolvable. Item-typed parameters are validated by assignability, not member-reflected, so the + /// type reference alone is preserved (no member rooting is required). + /// + /// + /// + /// The MSBuild types are already registered; call this only for an additional + /// item type a host uses as a task parameter type. + /// + /// + /// Intended to be called once per type during host initialization, before the first project is + /// evaluated. This method is thread-safe and idempotent. + /// + /// + public static void RegisterTaskParameterItemType() + where T : ITaskItem + => TaskParameterTypeRegistry.RegisterTaskItemType(); + + #endregion + #region Properties /// diff --git a/src/aot-validation/AssemblyInfo.cs b/src/aot-validation/AssemblyInfo.cs new file mode 100644 index 00000000000..b6c7b53aa8a --- /dev/null +++ b/src/aot-validation/AssemblyInfo.cs @@ -0,0 +1,11 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +// The harness exercises process-global engine state - the SDK resolver and its MSBuildSDKsPath probe, the +// BuildManager (in-process builds), and the host registries. The MTP test host runs tests in parallel by +// default, which races on that global state (for example one test's temporary MSBuildSDKsPath override +// versus another test resolving an SDK concurrently). Run the harness serially so each test sees a clean, +// uncontended engine; the suite is small and fast, so this costs little. +[assembly: DoNotParallelize] diff --git a/src/aot-validation/Directory.Build.props b/src/aot-validation/Directory.Build.props new file mode 100644 index 00000000000..448e4158ce7 --- /dev/null +++ b/src/aot-validation/Directory.Build.props @@ -0,0 +1,21 @@ + + + + + + + false + + + diff --git a/src/aot-validation/Directory.Build.targets b/src/aot-validation/Directory.Build.targets new file mode 100644 index 00000000000..13c014df547 --- /dev/null +++ b/src/aot-validation/Directory.Build.targets @@ -0,0 +1,6 @@ + + + + + diff --git a/src/aot-validation/Directory.Packages.props b/src/aot-validation/Directory.Packages.props new file mode 100644 index 00000000000..688a069bac3 --- /dev/null +++ b/src/aot-validation/Directory.Packages.props @@ -0,0 +1,11 @@ + + + + + + false + + + diff --git a/src/aot-validation/DotnetTemplateAotTests.cs b/src/aot-validation/DotnetTemplateAotTests.cs new file mode 100644 index 00000000000..ff7fe0cee66 --- /dev/null +++ b/src/aot-validation/DotnetTemplateAotTests.cs @@ -0,0 +1,252 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using Microsoft.Build.Evaluation; +using Microsoft.Build.Tasks; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.Build.AotValidation; + +/// +/// End-to-end validation that the MSBuild object model can evaluate real SDK-style projects - +/// the ones the .NET SDK CLI produces - under Native AOT, not just synthetic in-memory projects. +/// +/// Each test shells out to the bootstrap dotnet new to lay down a stock template (a library and +/// an executable) into a temporary directory, then opens the generated .csproj with +/// . That exercises the full evaluation path a host like the SDK relies on: +/// resolving the Microsoft.NET.Sdk SDK (the reflection-free in-box resolver), importing its +/// implicit Sdk.props/Sdk.targets and the whole common-targets graph, and computing the +/// derived properties (output directory, target framework, output type) the SDK reads back. The +/// bootstrap toolset the harness points at via MSBUILD_EXE_PATH is the same SDK +/// dotnet new uses, so the SDK reference resolves to a real on-disk SDK. +/// +[TestClass] +public sealed class DotnetTemplateAotTests +{ + [TestMethod] + public void DotnetNew_Console_EvaluatesAsExecutableProject() + { + using var temp = new TempDirectory(); + CreateFromTemplate("console", "ConsoleApp", temp.Path); + + using var collection = new ProjectCollection(); + Project project = EvaluateSingleProject(temp.Path, collection); + + // The console template declares an executable. + Assert.AreEqual("Exe", project.GetPropertyValue("OutputType")); + AssertEvaluatedSdkProject(project, "ConsoleApp"); + } + + [TestMethod] + public void DotnetNew_Classlib_EvaluatesAsLibraryProject() + { + using var temp = new TempDirectory(); + CreateFromTemplate("classlib", "ClassLib", temp.Path); + + using var collection = new ProjectCollection(); + Project project = EvaluateSingleProject(temp.Path, collection); + + // The class-library template sets no OutputType, so it evaluates to the SDK default of "Library". + Assert.AreEqual("Library", project.GetPropertyValue("OutputType")); + AssertEvaluatedSdkProject(project, "ClassLib"); + } + + [TestMethod] + public void DotnetNew_Console_BuildUnderAot_RunsRegisteredTasksThenFailsObservably() + => AssertTemplateBuildEngagesTasksThenFailsObservably("console", "ConsoleApp"); + + [TestMethod] + public void DotnetNew_Classlib_BuildUnderAot_RunsRegisteredTasksThenFailsObservably() + => AssertTemplateBuildEngagesTasksThenFailsObservably("classlib", "ClassLib"); + + /// + /// Builds a real SDK template in-process under the AOT configuration and asserts the build engages task + /// execution and fails observably, rather than crashing in reflection. + /// + /// + /// A full SDK build also runs tasks from Microsoft.NET.Build.Tasks (the SDK's own task + /// assembly - ProcessFrameworkReferences, ResolvePackageAssets, and so on), which are not + /// part of Microsoft.Build.Tasks.Core and so cannot be registered from this harness. With the + /// reflective task-loading path disabled (the trimmed/AOT host), evaluation and the registered built-in + /// tasks still work, but reaching the first unregistered SDK task fails observably with a reported error. + /// This pins the exact AOT boundary for a real SDK build: it degrades to a reported error, never a crash. + /// + private static void AssertTemplateBuildEngagesTasksThenFailsObservably(string template, string name) + { + // Pre-register the common built-in tasks so the failure is isolated to the SDK's own task assembly, + // not the core tasks a build also uses. + BuiltInTasks.RegisterAll(); + + using var temp = new TempDirectory(); + CreateFromTemplate(template, name, temp.Path); + + string projectPath = Directory + .GetFiles(temp.Path, "*.csproj", SearchOption.AllDirectories) + .Single(); + + Dictionary globalProperties = new() + { + ["MSBuildEnableWorkloadResolver"] = "false", + }; + + var logger = new CapturingLogger(); + using var collection = new ProjectCollection(globalProperties); + Project project = new(projectPath, globalProperties, toolsVersion: null, collection); + + // The build must not throw (no AOT/reflection crash); it returns a result. + bool success = InProcBuild.Run(project, "Build", logger); + + Assert.IsFalse( + success, + "A full SDK build cannot complete under AOT without the SDK's own task assembly; it should fail observably."); + + // The build evaluated the real SDK project, then executed targets until it reached the first task + // from the SDK's own task assembly (for example AllowEmptyTelemetry, ProcessFrameworkReferences), + // which is not registered. With reflective task loading disabled, that fails with the observable + // "reflective task execution not supported" error - proving the build reached task execution and + // degraded to a reported error rather than crashing in reflection. + Assert.IsTrue( + logger.Errors.Exists(e => e.Contains("trimmed or Native AOT host", StringComparison.Ordinal)), + "Expected an observable reflective-task-execution-not-supported error. Errors:" + Environment.NewLine + + string.Join(Environment.NewLine, logger.Errors)); + } + + /// + /// Opens the single generated project under with the MSBuild object + /// model and returns the evaluated . A successful return is itself the core + /// assertion: a real SDK project resolved its SDK and imported its entire targets graph under AOT. + /// + private static Project EvaluateSingleProject(string directory, ProjectCollection collection) + { + string projectPath = Directory + .GetFiles(directory, "*.csproj", SearchOption.AllDirectories) + .Single(); + + // Disable workload resolution. Microsoft.NET.Sdk otherwise imports the workload-locator SDKs + // (Microsoft.NET.SDK.WorkloadAutoImportPropsLocator / ...WorkloadManifestTargetsLocator), which are + // resolved by the dynamically-loaded NuGet/workload plugin resolver - the AOT-hard path this harness + // bakes off, so reaching it fails observably with MSB4282. The SDK gates that whole import behind the + // MSBuildEnableWorkloadResolver flag (Microsoft.NET.Sdk.props), so an AOT host turns it off and the + // project then evaluates entirely through the reflection-free in-box SDK path. + Dictionary globalProperties = new() + { + ["MSBuildEnableWorkloadResolver"] = "false", + }; + + return new Project(projectPath, globalProperties, toolsVersion: null, collection); + } + + /// + /// Validates the properties a host commonly reads back off an evaluated SDK project, including the + /// "output directory" the SDK computes during evaluation. + /// + private static void AssertEvaluatedSdkProject(Project project, string expectedName) + { + // The project name is a reserved property derived from the file name. + Assert.AreEqual(expectedName, project.GetPropertyValue("MSBuildProjectName")); + + // By default the assembly name follows the project name. + Assert.AreEqual(expectedName, project.GetPropertyValue("AssemblyName")); + + // The SDK reference resolved and its whole import graph (Sdk.props/.targets plus the common + // targets) loaded - a synthetic project would have a couple of imports; a real SDK project has many. + Assert.IsTrue( + project.Imports.Count > 10, + $"Expected the full SDK import graph to load; only {project.Imports.Count} imports were evaluated."); + + // The template established a target framework. + string targetFramework = project.GetPropertyValue("TargetFramework"); + Assert.IsTrue( + targetFramework.StartsWith("net", StringComparison.Ordinal), + $"Unexpected TargetFramework '{targetFramework}'."); + + // The default build configuration. + Assert.AreEqual("Debug", project.GetPropertyValue("Configuration")); + + // The output directory: the SDK derives OutputPath (e.g. bin\Debug\\) during evaluation. + string outputPath = project.GetPropertyValue("OutputPath"); + Assert.IsFalse(string.IsNullOrEmpty(outputPath), "OutputPath should be set by the SDK after evaluation."); + StringAssert.Contains(outputPath, "bin", $"OutputPath '{outputPath}' should live under 'bin'."); + + // The intermediate (obj) directory is likewise derived during evaluation. + string intermediateOutputPath = project.GetPropertyValue("IntermediateOutputPath"); + Assert.IsFalse( + string.IsNullOrEmpty(intermediateOutputPath), + "IntermediateOutputPath should be set by the SDK after evaluation."); + StringAssert.Contains( + intermediateOutputPath, + "obj", + $"IntermediateOutputPath '{intermediateOutputPath}' should live under 'obj'."); + } + + /// + /// Runs dotnet new <template> --name <name> --output <dir> with the bootstrap + /// SDK and asserts it succeeded. + /// + private static void CreateFromTemplate(string template, string name, string outputDirectory) + { + var startInfo = new ProcessStartInfo(FindBootstrapDotnet()) + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + WorkingDirectory = outputDirectory, + }; + startInfo.ArgumentList.Add("new"); + startInfo.ArgumentList.Add(template); + startInfo.ArgumentList.Add("--name"); + startInfo.ArgumentList.Add(name); + startInfo.ArgumentList.Add("--output"); + startInfo.ArgumentList.Add(outputDirectory); + + // Keep the invocation quiet, offline-friendly, and deterministic. + startInfo.Environment["DOTNET_CLI_TELEMETRY_OPTOUT"] = "1"; + startInfo.Environment["DOTNET_NOLOGO"] = "1"; + startInfo.Environment["DOTNET_SKIP_FIRST_TIME_EXPERIENCE"] = "1"; + + using var process = new Process { StartInfo = startInfo }; + process.Start(); + + // Read both streams asynchronously to avoid a pipe-buffer deadlock. + Task standardOutput = process.StandardOutput.ReadToEndAsync(); + Task standardError = process.StandardError.ReadToEndAsync(); + + if (!process.WaitForExit(milliseconds: 120_000)) + { + process.Kill(entireProcessTree: true); + Assert.Fail($"`dotnet new {template}` did not complete within the timeout."); + } + + // Ensure the redirected streams are fully flushed before reading their results. + process.WaitForExit(); + + Assert.AreEqual( + 0, + process.ExitCode, + $"`dotnet new {template}` failed (exit {process.ExitCode}).{Environment.NewLine}" + + $"stdout:{Environment.NewLine}{standardOutput.GetAwaiter().GetResult()}{Environment.NewLine}" + + $"stderr:{Environment.NewLine}{standardError.GetAwaiter().GetResult()}"); + } + + /// + /// Locates the bootstrap dotnet host (the same complete SDK the harness evaluates against, + /// produced by build.cmd) by walking up from the executable directory to the repository root. + /// + private static string FindBootstrapDotnet() + { + string dotnetFileName = OperatingSystem.IsWindows() ? "dotnet.exe" : "dotnet"; + + for (DirectoryInfo? dir = new(AppContext.BaseDirectory); dir is not null; dir = dir.Parent) + { + string candidate = Path.Combine(dir.FullName, "artifacts", "bin", "bootstrap", "core", dotnetFileName); + if (File.Exists(candidate)) + { + return candidate; + } + } + + throw new InvalidOperationException( + "Could not locate the bootstrap dotnet host under artifacts/bin/bootstrap/core. Run build.cmd first."); + } +} diff --git a/src/aot-validation/HarnessEnvironment.cs b/src/aot-validation/HarnessEnvironment.cs new file mode 100644 index 00000000000..3a0bd8daa6b --- /dev/null +++ b/src/aot-validation/HarnessEnvironment.cs @@ -0,0 +1,61 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Runtime.CompilerServices; + +namespace Microsoft.Build.AotValidation; + +/// +/// Provides the MSBuild toolset location the object model needs before any of its types initialize. +/// +/// FINDING (the reason this exists): in a Native AOT / single-file executable there is no +/// MSBuild.dll on disk next to the app and +/// returns an empty string, so BuildEnvironmentHelper cannot discover a toolset on its own and +/// new ProjectCollection() throws ArgumentException: The path is empty. A real AOT host +/// (the dotnet CLI) already knows where its SDK/MSBuild lives and points the engine at it via the +/// MSBUILD_EXE_PATH environment variable (the SDK does exactly this today). This harness mirrors +/// that contract by pointing at the repository's bootstrap toolset before the first object-model access. +/// +internal static class HarnessEnvironment +{ + [ModuleInitializer] + internal static void EnsureMSBuildToolset() + { + // Respect an externally provided toolset (for example a real SDK layout in CI). + if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("MSBUILD_EXE_PATH"))) + { + return; + } + + string? toolset = FindBootstrapMSBuild(AppContext.BaseDirectory); + if (toolset is not null) + { + Environment.SetEnvironmentVariable("MSBUILD_EXE_PATH", toolset); + } + } + + /// + /// Walks up from the executable directory to the repository root and returns the bootstrap + /// MSBuild.dll (a complete toolset produced by build.cmd), or null if not found. + /// + private static string? FindBootstrapMSBuild(string startDirectory) + { + for (DirectoryInfo? dir = new(startDirectory); dir is not null; dir = dir.Parent) + { + string sdkRoot = Path.Combine(dir.FullName, "artifacts", "bin", "bootstrap", "core", "sdk"); + if (Directory.Exists(sdkRoot)) + { + foreach (string sdkVersionDir in Directory.EnumerateDirectories(sdkRoot)) + { + string candidate = Path.Combine(sdkVersionDir, "MSBuild.dll"); + if (File.Exists(candidate)) + { + return candidate; + } + } + } + } + + return null; + } +} diff --git a/src/aot-validation/InProcBuild.cs b/src/aot-validation/InProcBuild.cs new file mode 100644 index 00000000000..422f7bb2d8a --- /dev/null +++ b/src/aot-validation/InProcBuild.cs @@ -0,0 +1,48 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; +using Microsoft.Build.Evaluation; +using Microsoft.Build.Framework; + +namespace Microsoft.Build.AotValidation; + +/// +/// Runs an in-process MSBuild build for the harness's build tests. +/// +/// +/// In-process MSBuild builds go through the process-global BuildManager.DefaultBuildManager, which +/// allows only one build at a time; the MTP test host runs tests in parallel, so builds are serialized +/// here with a process-wide lock. +/// +internal static class InProcBuild +{ + private static readonly object s_buildLock = new(); + + /// + /// Builds of in-process with the given logger, + /// serialized against any other harness build. + /// + /// + /// is annotated + /// because the build entry points can load loggers and + /// project cache plugins by reflecting over assemblies named at run time. The harness passes a + /// pre-constructed and configures no project cache plugins, so that reflective + /// path is never taken; the registered-task execution path under test is itself reflection-free. Making + /// the build entry points fully trim-clean (gating the reflective logger/plugin loading behind a feature + /// switch) is separate, larger work outside this task-registration change. + /// + [UnconditionalSuppressMessage( + "Trimming", + "IL2026:RequiresUnreferencedCode", + Justification = "The build is invoked with a pre-constructed ILogger and no project cache plugins, so the " + + "reflective logger/plugin-loading path that annotates Project.Build is not exercised. The task-execution " + + "path under test is reflection-free (host-registered tasks).")] + public static bool Run(Project project, string target, ILogger logger) + { + lock (s_buildLock) + { + return project.Build(target, [logger]); + } + } +} diff --git a/src/aot-validation/Microsoft.Build.AotValidation.csproj b/src/aot-validation/Microsoft.Build.AotValidation.csproj new file mode 100644 index 00000000000..8efd462d251 --- /dev/null +++ b/src/aot-validation/Microsoft.Build.AotValidation.csproj @@ -0,0 +1,137 @@ + + + + + + Exe + net10.0 + enable + enable + latest + win-x64 + + + true + true + + + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/aot-validation/ObjectModelAotTests.cs b/src/aot-validation/ObjectModelAotTests.cs new file mode 100644 index 00000000000..722452acc0f --- /dev/null +++ b/src/aot-validation/ObjectModelAotTests.cs @@ -0,0 +1,261 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Build.Construction; +using Microsoft.Build.Definition; +using Microsoft.Build.Evaluation; +using Microsoft.Build.Execution; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.Build.AotValidation; + +// The MSBuild evaluation entry points (new Project(...), new ProjectInstance(...), +// ProjectCollection.LoadProject, Project.CreateProjectInstance) are now trim/AOT-safe: their +// SDK-resolution [RequiresUnreferencedCode] was removed once in-box SDK resolution was made +// reflection-free and plugin-resolver loading was gated behind a feature switch that fails +// observably under trimming (see documentation/aot/sdk-resolution.md). No IL2026 suppression +// is needed here anymore. + +/// +/// Vets the MSBuild object-model scenarios the .NET SDK CLI relies on in-process +/// (see documentation/aot/sdk-msbuild-object-model-audit.md) under Native AOT. +/// +/// Scope is the evaluation + construction tiers - the surface that should be trim/AOT-safe and +/// that the SDK uses for project/solution inspection (dotnet new/run/test discovery, publish/pack +/// release detection, reference and solution editing). The execution engine (BuildManager) is +/// intentionally out of scope: per the audit it is the AOT-hard path that a host falls back to a +/// forwarded/JIT MSBuild for. +/// +[TestClass] +public sealed class ObjectModelAotTests +{ + // ---- Construction tier: dotnet reference / dotnet solution add edit project XML ---- + + [TestMethod] + public void Construction_CreateAndReadProjectRootElement() + { + ProjectRootElement root = ProjectRootElement.Create(); + root.AddProperty("Configuration", "Debug"); + ProjectItemElement item = root.AddItem("ProjectReference", @"..\Lib\Lib.csproj"); + + Assert.AreEqual("ProjectReference", item.ItemType); + Assert.AreEqual(@"..\Lib\Lib.csproj", item.Include); + Assert.IsTrue(root.Properties.Any(p => p.Name == "Configuration" && p.Value == "Debug")); + } + + // ---- Evaluation tier: dotnet new (capabilities), dotnet run, reference TFM checks ---- + + [TestMethod] + public void Evaluation_InMemoryProject_PropertiesAndItems() + { + ProjectRootElement root = ProjectRootElement.Create(); + root.AddProperty("TargetFramework", "net10.0"); + root.AddProperty("OutputType", "Exe"); + root.AddItem("Compile", "A.cs"); + root.AddItem("Compile", "B.cs"); + + using var collection = new ProjectCollection(); + var project = new Project(root, globalProperties: null, toolsVersion: null, collection); + + Assert.AreEqual("net10.0", project.GetPropertyValue("TargetFramework")); + Assert.AreEqual("Exe", project.GetPropertyValue("OutputType")); + Assert.AreEqual(2, project.GetItems("Compile").Count); + } + + [TestMethod] + public void Evaluation_ConditionsEvaluate() + { + ProjectRootElement root = ProjectRootElement.Create(); + root.AddProperty("Configuration", "Release"); + ProjectPropertyGroupElement pg = root.AddPropertyGroup(); + pg.Condition = "'$(Configuration)' == 'Release'"; + pg.AddProperty("Optimize", "true"); + + using var collection = new ProjectCollection(); + var project = new Project(root, null, null, collection); + + Assert.AreEqual("true", project.GetPropertyValue("Optimize")); + } + + [TestMethod] + public void Evaluation_IntrinsicPropertyFunction() + { + // Intrinsic MSBuild property functions are not arbitrary-type reflection, so they are the + // AOT-friendly subset of the property-function surface (see property-functions-reachability.md). + ProjectRootElement root = ProjectRootElement.Create(); + root.AddProperty("Sum", "$([MSBuild]::Add(2, 3))"); + + using var collection = new ProjectCollection(); + var project = new Project(root, null, null, collection); + + Assert.AreEqual("5", project.GetPropertyValue("Sum")); + } + + // ---- ProjectInstance tier: release locator, dotnet run, test discovery, solution add ---- + + [TestMethod] + public void ProjectInstance_InMemory_PropertiesAndItems() + { + ProjectRootElement root = ProjectRootElement.Create(); + root.AddProperty("IsTestProject", "true"); + root.AddItem("ProjectReference", @"..\Lib\Lib.csproj"); + + var instance = new ProjectInstance(root); + + Assert.AreEqual("true", instance.GetPropertyValue("IsTestProject")); + Assert.AreEqual(1, instance.GetItems("ProjectReference").Count()); + } + + [TestMethod] + public void ProjectInstance_FromFile_MirrorsTestDiscoveryAndReleaseLocator() + { + RunInTempDir(dir => + { + string proj = Path.Combine(dir, "App.csproj"); + File.WriteAllText(proj, + """ + + + net10.0 + true + true + + + + + + """); + + using var collection = new ProjectCollection(); + var instance = ProjectInstance.FromFile(proj, new ProjectOptions { ProjectCollection = collection }); + + Assert.AreEqual("net10.0", instance.GetPropertyValue("TargetFramework")); + Assert.AreEqual("true", instance.GetPropertyValue("PublishRelease")); + Assert.AreEqual("true", instance.GetPropertyValue("IsTestProject")); + Assert.AreEqual(1, instance.GetItems("Compile").Count()); + }); + } + + [TestMethod] + public void Evaluation_LoadProjectFromDisk_MirrorsRunCommand() + { + RunInTempDir(dir => + { + string proj = Path.Combine(dir, "Lib.csproj"); + File.WriteAllText(proj, + """ + + + dotnet + Library + + + """); + + using var collection = new ProjectCollection(); + Project project = collection.LoadProject(proj); + ProjectInstance instance = project.CreateProjectInstance(); + + Assert.AreEqual("dotnet", instance.GetPropertyValue("RunCommand")); + Assert.AreEqual("Library", instance.GetPropertyValue("OutputType")); + }); + } + + // ---- SDK resolution tier: in-box (reflection-free) resolution ---- + + [TestMethod] + public void Evaluation_InBoxSdkResolvesReflectionFree() + { + // The in-box SDK resolver is a reflection-free directory probe + // (BuildEnvironmentHelper.MSBuildSDKsPath\\Sdk), so a whose SDK lives + // in the SDK folder resolves and its implicit Sdk.props/Sdk.targets imports load end to end under + // Native AOT - no resolver assembly is loaded. Plugin resolvers (NuGet/workload/custom) are the + // AOT-hard path and fail observably (MSB4282) instead; that branch is covered by the engine unit tests. + RunInTempDir(dir => + { + const string sdkName = "Harness.InBox.Sdk"; + string sdkRoot = Path.Combine(dir, "Sdks"); + string sdkDir = Path.Combine(sdkRoot, sdkName, "Sdk"); + Directory.CreateDirectory(sdkDir); + File.WriteAllText( + Path.Combine(sdkDir, "Sdk.props"), + "props-value"); + File.WriteAllText( + Path.Combine(sdkDir, "Sdk.targets"), + "targets-value"); + + string proj = Path.Combine(dir, "App.csproj"); + File.WriteAllText(proj, $"Debug"); + + // The in-box resolver probes MSBuildSDKsPath, which honors this environment override on every read. + string? previousSdksPath = Environment.GetEnvironmentVariable("MSBuildSDKsPath"); + Environment.SetEnvironmentVariable("MSBuildSDKsPath", sdkRoot); + try + { + using var collection = new ProjectCollection(); + var project = new Project(proj, globalProperties: null, toolsVersion: null, collection); + + // The implicit Sdk.props (top) and Sdk.targets (bottom) both resolved and were imported. + Assert.AreEqual("props-value", project.GetPropertyValue("FromSdkProps")); + Assert.AreEqual("targets-value", project.GetPropertyValue("FromSdkTargets")); + Assert.AreEqual(2, project.Imports.Count); + } + finally + { + Environment.SetEnvironmentVariable("MSBuildSDKsPath", previousSdksPath); + } + }); + } + + // ---- SolutionFile tier: dotnet test discovery / publish-pack release locator ---- + + [TestMethod] + public void Construction_ParseSolutionFile() + { + RunInTempDir(dir => + { + string sln = Path.Combine(dir, "App.sln"); + File.WriteAllText(sln, MinimalSolution); + + SolutionFile solution = SolutionFile.Parse(sln); + + Assert.AreEqual(1, solution.ProjectsInOrder.Count); + Assert.AreEqual("App", solution.ProjectsInOrder[0].ProjectName); + Assert.AreEqual("App.csproj", solution.ProjectsInOrder[0].RelativePath); + }); + } + + private static void RunInTempDir(Action body) + { + string dir = Path.Combine(Path.GetTempPath(), "msb-aot-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + try + { + body(dir); + } + finally + { + Directory.Delete(dir, recursive: true); + } + } + + private static readonly string MinimalSolution = string.Join("\r\n", + [ + "Microsoft Visual Studio Solution File, Format Version 12.00", + "# Visual Studio Version 17", + "Project(\"{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}\") = \"App\", \"App.csproj\", \"{11111111-1111-1111-1111-111111111111}\"", + "EndProject", + "Global", + "\tGlobalSection(SolutionConfigurationPlatforms) = preSolution", + "\t\tDebug|Any CPU = Debug|Any CPU", + "\t\tRelease|Any CPU = Release|Any CPU", + "\tEndGlobalSection", + "\tGlobalSection(ProjectConfigurationPlatforms) = postSolution", + "\t\t{11111111-1111-1111-1111-111111111111}.Debug|Any CPU.ActiveCfg = Debug|Any CPU", + "\t\t{11111111-1111-1111-1111-111111111111}.Debug|Any CPU.Build.0 = Debug|Any CPU", + "\t\t{11111111-1111-1111-1111-111111111111}.Release|Any CPU.ActiveCfg = Release|Any CPU", + "\t\t{11111111-1111-1111-1111-111111111111}.Release|Any CPU.Build.0 = Release|Any CPU", + "\tEndGlobalSection", + "EndGlobal", + ]); +} diff --git a/src/aot-validation/PropertyFunctionAotTests.cs b/src/aot-validation/PropertyFunctionAotTests.cs new file mode 100644 index 00000000000..1de139d33a7 --- /dev/null +++ b/src/aot-validation/PropertyFunctionAotTests.cs @@ -0,0 +1,106 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Build.Construction; +using Microsoft.Build.Evaluation; +using Microsoft.Build.Exceptions; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.Build.AotValidation; + +/// +/// Pins the consumer-facing error an MSBuild author sees when a property function tries to reach a +/// reflective, dynamic-code member - here System.Enum.GetValues(Type), which carries +/// [RequiresDynamicCode] and is the source of the IL3050 the engine suppresses - under Native AOT. +/// +/// Finding (captured under Native AOT, both with the default allowlist and with the +/// MSBUILDENABLEALLPROPERTYFUNCTIONS=1 escape hatch): a property function can never reach +/// Enum.GetValues(Type) (or any reflective method that takes a System.Type), because there is +/// no way to produce a Type argument: +/// +/// string never coerces to Type, so the overload does not bind - the author gets +/// MSB4186 ("Method '...' not found ... Check that all parameters are ... of the correct type"). +/// [System.Type]::GetType(...) is not an available property function - the author gets +/// MSB4185 ("The function 'GetType' ... is not available for execution as an MSBuild property +/// function"); with the escape hatch on it still fails to bind (MSB4186). +/// +/// So the failure is always a normal, AOT-independent property-function diagnostic that points at the +/// author's own expression - never an AOT crash, and never a NotSupportedException leaking out of the +/// trimmed dynamic-code path. There is therefore nothing to special-case for Enum.GetValues: the +/// existing MSB4185/MSB4186 errors already block it cleanly and identically on JIT and AOT, which is also why +/// the engine's IL3050 suppression is a static-reachability false positive rather than a hidden failure. +/// +[TestClass] +public sealed class PropertyFunctionAotTests +{ + [TestMethod] + public void EnumGetValues_WithStringArgument_FailsWithUnderstandableMethodNotFound() + { + // string -> Type does not coerce, so Enum.GetValues(Type) never binds and is never invoked. + InvalidProjectFileException ex = EvaluateExpectingFailure("$([System.Enum]::GetValues('System.DayOfWeek'))"); + + Assert.AreEqual("MSB4186", ex.ErrorCode); + StringAssert.Contains(ex.Message, "System.Enum.GetValues"); + // The author sees a property-function diagnostic, not a leaked AOT/dynamic-code exception. + Assert.IsNull(ex.InnerException); + } + + [TestMethod] + public void TypeGetType_IsNotAnAvailablePropertyFunction() + { + // There is no allowed property function that produces a System.Type, so a Type argument cannot be + // built to feed a reflective Type-taking method. + InvalidProjectFileException ex = EvaluateExpectingFailure("$([System.Type]::GetType('System.DayOfWeek'))"); + + Assert.AreEqual("MSB4185", ex.ErrorCode); + } + + [TestMethod] + public void EnumGetValues_WithNestedGetType_FailsBeforeReachingTheReflectiveInvoke() + { + // The inner [System.Type]::GetType is rejected first (MSB4185), so the outer + // Enum.GetValues(Type) - the [RequiresDynamicCode] member - is never reached under AOT. + InvalidProjectFileException ex = EvaluateExpectingFailure( + "$([System.Enum]::GetValues($([System.Type]::GetType('System.DayOfWeek'))))"); + + Assert.AreEqual("MSB4185", ex.ErrorCode); + } + + [TestMethod] + public void AllowlistedReceiverStaticFunctions_DispatchByReflectionUnderAot() + { + // The positive counterpart to the blocked cases above: a property function over a curated BCL + // receiver type (System.IO.Path, System.String) dispatches over that type's public members by + // reflection - the ReceiverType path whose [DynamicallyAccessedMembers] annotation keeps those + // members under trimming. This confirms reflective property-function evaluation still works under AOT. + Assert.AreEqual("HelloWorld", EvaluateToValue("$([System.IO.Path]::GetFileName('x/HelloWorld'))")); + Assert.AreEqual("ab", EvaluateToValue("$([System.String]::Concat('a', 'b'))")); + } + + /// + /// Evaluates a single property-function expression as a project property and returns the resolved value. + /// + private static string EvaluateToValue(string expression) + { + ProjectRootElement root = ProjectRootElement.Create(); + root.AddProperty("Result", expression); + using var collection = new ProjectCollection(); + + return new Project(root, globalProperties: null, toolsVersion: null, collection).GetPropertyValue("Result"); + } + + /// + /// Evaluates a single property-function expression as a project property (evaluation happens in the + /// constructor) and returns the the + /// author would see. Fails the test if evaluation unexpectedly succeeds or throws a different type. + /// + private static InvalidProjectFileException EvaluateExpectingFailure(string expression) + { + ProjectRootElement root = ProjectRootElement.Create(); + root.AddProperty("Result", expression); + using var collection = new ProjectCollection(); + + return Assert.ThrowsException( + () => new Project(root, globalProperties: null, toolsVersion: null, collection)); + } +} diff --git a/src/aot-validation/README.md b/src/aot-validation/README.md new file mode 100644 index 00000000000..d2f565ddab1 --- /dev/null +++ b/src/aot-validation/README.md @@ -0,0 +1,270 @@ +# MSBuild object model - Native AOT validation harness + +A standalone **MSTest + Microsoft.Testing.Platform (MTP)** test project that runs **Native +AOT-published** and validates that the MSBuild object-model scenarios the .NET SDK CLI relies on +in-process actually work under AOT. + +It is driven by [documentation/aot/sdk-msbuild-object-model-audit.md](../../documentation/aot/sdk-msbuild-object-model-audit.md), +which maps every in-process object-model usage in `dotnet/sdk` to the CLI command that exposes it. +This harness exercises the **evaluation** and **construction** tiers from that audit - the surface +that should be trim/AOT-safe (`dotnet new`/`run`/`test` discovery, `publish`/`pack` release +detection, `reference`/`solution` editing) - and now a **registered-task build** tier: with the host +task registry (`Microsoft.Build.Utilities.Task.RegisterTask`), a project whose tasks are registered +**builds** end to end under Native AOT (see [RegisteredTaskAotTests.cs](RegisteredTaskAotTests.cs)). The +broader **execution engine** (`BuildManager`) remains the AOT-hard path a host falls back to a +forwarded/JIT MSBuild for - its build entry points stay `[RequiresUnreferencedCode]` for reflective +logger/plugin loading; an in-process host that passes pre-constructed `ILogger` instances and no +project-cache plugins (as the harness does) does not hit that path. + +> This project is **intentionally not part of the repository's Arcade build** (it is not in +> `MSBuild.slnx`). The local empty `Directory.Build.props` / `Directory.Build.targets` / +> `Directory.Packages.props` isolate it from the repo's test machinery so it can use a Native-AOT, +> MSTest-on-MTP configuration. Build and run it explicitly with the commands below. + +## How to run + +From the repository root, using the repo's pinned SDK (`.dotnet\dotnet.exe`): + +```powershell +# Fast JIT pass (build + run the MTP test host) +.\.dotnet\dotnet.exe build src\aot-validation\Microsoft.Build.AotValidation.csproj +.\src\aot-validation\bin\Debug\net10.0\win-x64\Microsoft.Build.AotValidation.exe + +# Native AOT pass (the real validation): publish, then run the native exe +.\.dotnet\dotnet.exe publish src\aot-validation\Microsoft.Build.AotValidation.csproj -r win-x64 -c Release +.\src\aot-validation\bin\Release\net10.0\win-x64\publish\Microsoft.Build.AotValidation.exe +``` + +Native AOT publishing requires the Visual Studio C++ toolchain (the MSVC linker). A run prints the +standard MTP summary and exits `0` when all tests pass. + +## Why this MSTest/MTP configuration + +MSTest has two MTP integrations, and only one is AOT-compatible: + +- `MSTest.TestAdapter` routes discovery/execution through the reflection-based **VSTest bridge**, + which throws `NotSupportedException: Running tests ... is not supported for the selected platform` + under Native AOT. +- `MSTest.Engine` + `MSTest.SourceGeneration` is MSTest's **source-generated** runner. It registers + tests and the MTP entry point with no runtime reflection, so it works under AOT. This harness uses + it, with `Microsoft.Testing.Platform.MSBuild` generating the entry point. This is the same MTP + family `dotnet test` uses in MTP mode. + +## What it found + +Vetting the object model under AOT surfaced two concrete blockers. Both are the classic single-file +problem: `Assembly.Location` returns an empty string in a single-file / Native AOT executable. + +1. **Toolset discovery (`BuildEnvironmentHelper`).** With no `MSBuild.dll` on disk next to the app and + an empty `Assembly.Location`, `new ProjectCollection()` throws + `ArgumentException: The path is empty`. A real AOT host (the dotnet CLI) already knows where its + SDK/MSBuild lives and points the engine at it via the `MSBUILD_EXE_PATH` environment variable - the + SDK does exactly this today. The harness mirrors that contract: a `[ModuleInitializer]` + ([HarnessEnvironment.cs](HarnessEnvironment.cs)) sets `MSBUILD_EXE_PATH` to the repository's + bootstrap toolset before any object-model type initializes. This is a host responsibility, not an + engine bug. + +2. **`ProjectCollection.Version` (engine bug, fixed).** Evaluation reads `ProjectCollection.Version` + (to stamp the built-in `MSBuildVersion` property), which called + `FileVersionInfo.GetVersionInfo(Assembly.Location)` - empty under AOT, throwing + `The path is empty` from `Path.GetFullPath`. Fixed in + [src/Build/Definition/ProjectCollection.cs](../Build/Definition/ProjectCollection.cs) to read + the `AssemblyFileVersionAttribute` directly (same value, no file path needed - also more robust + under shadow-copy and single-file). + +With those two in place, **all evaluation/construction scenarios pass under Native AOT.** Remaining +`Assembly.Location` usages in the engine are on the task-execution path (task/SDK/logger loading), +which is the AOT-hard tier this harness deliberately does not cover. + +3. **SDK resolution (``).** The evaluation entry points used to carry an + SDK-resolution `[RequiresUnreferencedCode]` all the way up to the `Project`/`ProjectInstance` + constructors, forcing this harness to `#pragma warning disable IL2026`. That suppression is gone: + in-box SDK resolution is a reflection-free directory probe, so it stays trim-safe, and the + reflective plugin-resolver load is now gated behind the + `Microsoft.Build.EnableSdkResolverDynamicLoading` feature switch that fails observably (MSB4282) + when disabled (see [documentation/aot/sdk-resolution.md](../../documentation/aot/sdk-resolution.md)). + This harness bakes that switch **off** (a `RuntimeHostConfigurationOption` with `Trim="true"`, mirroring + an AOT dotnet CLI), so ILC dead-strips the reflective branch and `Evaluation_InBoxSdkResolvesReflectionFree` + proves a `` still resolves and imports its `Sdk.props`/`Sdk.targets` under Native AOT. + +4. **`System.Configuration.ConfigurationManager` (transitive dependency, now trimmed).** The config-file + toolset reader (`ToolsetConfigurationReader` -> `ToolsetElement` -> `System.Configuration`) is compiled + into the .NET build, and the `locations & ConfigurationFile` selector is a runtime flag ILC cannot prove + is never set, so it used to keep the whole subtree and surface an **IL2104** for the + `System.Configuration.ConfigurationManager` assembly. That block is now gated behind the + `Microsoft.Build.EnableConfigurationFileToolsets` feature switch (default **on**, so the JIT keeps reading + `.exe.config` toolsets exactly as before). This harness bakes the switch **off** (another `Trim="true"` + `RuntimeHostConfigurationOption`), so ILC folds the selector to `false`, dead-strips the + `ToolsetConfigurationReader` subtree, and `System.Configuration` drops out of the closure entirely - the + IL2104 is gone rather than suppressed. On .NET the config-file location is not in the default toolset set + anyway (the matching tests are .NET Framework-only), so nothing this harness exercises regresses. A host + that disables the switch and still asks for `ToolsetDefinitionLocations.ConfigurationFile` fails observably + with an `ArgumentException` rather than silently returning no toolsets. + +## Scenarios + +[ObjectModelAotTests.cs](ObjectModelAotTests.cs) mirrors the audit tiers: + +| Test | Audit scenario it represents | +| --- | --- | +| `Construction_CreateAndReadProjectRootElement` | `dotnet reference` / `solution add` editing project XML | +| `Construction_ParseSolutionFile` | `dotnet test` discovery / release locator (`SolutionFile.Parse`) | +| `Evaluation_InMemoryProject_PropertiesAndItems` | `dotnet new` capabilities, `run`, reference TFM checks | +| `Evaluation_ConditionsEvaluate` | core condition evaluation | +| `Evaluation_IntrinsicPropertyFunction` | AOT-friendly subset of property functions (`$([MSBuild]::...)`) | +| `Evaluation_LoadProjectFromDisk_MirrorsRunCommand` | `dotnet run` project property reads | +| `ProjectInstance_InMemory_PropertiesAndItems` | release locator / `run` / test discovery / `solution add` | +| `ProjectInstance_FromFile_MirrorsTestDiscoveryAndReleaseLocator` | `dotnet test` `ProjectInstance.FromFile`, `PublishRelease` detection | +| `Evaluation_InBoxSdkResolvesReflectionFree` | `` in-box SDK resolution - reflection-free directory probe, no resolver assembly loaded | +| `RegisteredResolver_ResolvesSdk_AndImportsItsPropsAndTargets` | `SdkResolver.Register` host registration - a reflection-free resolver baked in at startup resolves a `` the in-box probe can't, with no assembly loading (the workload-resolver injection seam) | +| `DotnetNew_Console_EvaluatesAsExecutableProject` | `dotnet new console` -> a real `Microsoft.NET.Sdk` project evaluates end to end (`OutputType=Exe`, output dir, TFM) | +| `DotnetNew_Classlib_EvaluatesAsLibraryProject` | `dotnet new classlib` -> a real `Microsoft.NET.Sdk` project evaluates end to end (`OutputType=Library`) | +| `PropertyFunctionAotTests.*` | a property function cannot reach a reflective `Type`-taking member (`Enum.GetValues(Type)`) - the author gets MSB4185/MSB4186, never an AOT crash | +| `RegisteredTaskAotTests.RegisteredBuiltInAndCustomTasks_Build_UnderAot` | a host registers built-in and custom task classes, then **builds** a project end to end - the tasks run under Native AOT with the reflective task-loading path off | +| `RegisteredTaskAotTests.IntrinsicCallTargetAndMSBuildTasks_Build_UnderAot` | the intrinsic `MSBuild` and `CallTarget` tasks (engine-internal, never host-registered) drive a child build and a target call under Native AOT with the reflective path off | +| `RegisteredTaskAotTests.UnregisteredTask_WithReflectionOff_FailsObservably` | an unregistered task fails the build with a reported error, never a reflection crash | +| `DotnetTemplateAotTests.DotnetNew_*_BuildUnderAot_RunsRegisteredTasksThenFailsObservably` | a real SDK template build evaluates and runs registered tasks, then fails observably at the first task from the SDK's own task assembly (`Microsoft.NET.Build.Tasks`, which is not part of `Microsoft.Build.Tasks.Core`) | +| `ToolchainSmokeTests.TestHostRunsUnderAot` | the MSTest+MTP+AOT host itself | + +### Property-function reflective members under AOT + +`Enum.GetValues(Type)` carries `[RequiresDynamicCode]` and is the source of an IL3050 the engine +suppresses. [PropertyFunctionAotTests.cs](PropertyFunctionAotTests.cs) confirms empirically that a +property function can **never** reach it (or any reflective method that takes a `System.Type`): there is +no way to produce a `Type` argument - `string` does not coerce to `Type` (**MSB4186** "method not found, +check that parameters are of the correct type"), and `[System.Type]::GetType(...)` is not an available +property function (**MSB4185**), even with `MSBUILDENABLEALLPROPERTYFUNCTIONS=1`. So an author always sees +a normal, AOT-independent property-function diagnostic pointing at their expression - never an AOT crash or +a `NotSupportedException` from the trimmed dynamic-code path - which is why the IL3050 suppression is a +static-reachability false positive and no special-casing is warranted. + +### Evaluating real `dotnet new` templates under AOT + +[DotnetTemplateAotTests.cs](DotnetTemplateAotTests.cs) goes past synthetic projects: it shells out to the +bootstrap `dotnet new` to create the stock `console` and `classlib` templates, then opens each real +`Microsoft.NET.Sdk` project with `new Project(...)`. Getting a full SDK project to evaluate under Native +AOT surfaced three host responsibilities a real AOT MSBuild host must take on - each mirrored in the `.csproj`: + +- **Disable workload resolution.** `Microsoft.NET.Sdk` unconditionally imports the workload-locator SDKs + (`Microsoft.NET.SDK.WorkloadAutoImportPropsLocator` / `...WorkloadManifestTargetsLocator`), resolved by the + dynamically-loaded NuGet/workload plugin resolver - the AOT-hard path baked off here, so reaching it fails + observably with **MSB4282**. The SDK gates that whole import behind `MSBuildEnableWorkloadResolver`, so the + test evaluates with that global property set to `false` (what an AOT host does); the project then resolves + only through the in-box, reflection-free `Microsoft.NET.Sdk`. +- **Bundle `NuGet.Frameworks`.** SDK evaluation calls NuGet-backed property functions + (`[MSBuild]::GetTargetFrameworkIdentifier` and friends). `Microsoft.Build` references `NuGet.Frameworks` + with `PrivateAssets="all"` (in a real SDK it loads the copy next to `MSBuild.dll`), so it does not flow to + the harness transitively; the harness adds its own reference to put it in the output and the AOT image. +- **Root `Microsoft.Build.Utilities.Core`.** The SDK invokes + `[Microsoft.Build.Utilities.ToolLocationHelper]::...` property functions, and the allowlist resolves such + cross-assembly receivers by assembly-qualified name via `Type.GetType` - which under AOT only succeeds if + the type's metadata is preserved. The harness references the (`IsAotCompatible`) assembly and roots it with + `TrimmerRootAssembly`. + +With those in place both templates evaluate end to end under Native AOT, and the tests read back the derived +properties a host cares about: `OutputType`, the `bin`/`obj` output directories, `TargetFramework`, and +`AssemblyName`. + +### Building a project under AOT with registered tasks + +[RegisteredTaskAotTests.cs](RegisteredTaskAotTests.cs) goes past evaluation and actually **builds** under +Native AOT. The harness bakes `EnableReflectiveTaskExecution=false`, so the reflective task-loading path +(assembly probing, by-name type resolution) is trimmed away and an *un*registered task fails observably. A +host instead pre-registers its tasks with the host task registry (see +[task-class-registration-api.md](../../documentation/specs/task-class-registration-api.md)): the +common built-in tasks through `Microsoft.Build.Tasks.BuiltInTasks.RegisterAll()`, and its own tasks through +`Microsoft.Build.Utilities.Task.RegisterTask(name)`. A registered task is constructed and bound with no +assembly loading or by-name type resolution, so `RegisteredBuiltInAndCustomTasks_Build_UnderAot` runs a real +in-process build of a hand-authored project - `MakeDir`/`WriteLinesToFile`/`Copy` produce files, and a +host-registered custom task's `[Output]` is bound back to a property - entirely under AOT. + +The engine-internal intrinsic tasks `MSBuild` and `CallTarget` - which virtually every real build dispatches +through but no host registers - resolve the same reflection-free way (a direct `new`, no assembly probing), so +they stay available with the switch off too: `IntrinsicCallTargetAndMSBuildTasks_Build_UnderAot` builds a child +project through `` and dispatches a target through `` under Native AOT. + +Making the in-process build trim-clean surfaced the build-execution path the rest of the harness deliberately +avoids. Three host responsibilities (all mirrored by the `.csproj` switches and engine guards): + +- **Pass loggers as instances, not descriptions.** Creating a logger from a `LoggerDescription` + (assembly/class name) reflects over the logger assembly. The new `EnableReflectiveLoggerLoading` switch + (baked off here) drops that path - and with it `TypeLoader` and `System.Reflection.MetadataLoadContext` - + so the harness logs through pre-constructed `ILogger` instances. +- **No custom plugins.** `EnableCustomPluginProbing` (baked off) drops the reflective build-check/plugin + acquisition the build wires up. +- **The build entry point stays `[RequiresUnreferencedCode]`.** `Project.Build` can still load loggers and + project-cache plugins by reflection in general, so the single call is isolated behind a documented + suppression in [InProcBuild.cs](InProcBuild.cs) (the harness passes neither). In-process MSBuild builds + use process-global state, so the harness runs serially (`[assembly: DoNotParallelize]`). + +The `DotnetTemplateAotTests.DotnetNew_*_BuildUnderAot_*` tests build the real `console`/`classlib` templates +the same way: evaluation and the registered built-in tasks run, then the build fails observably at the first +task from the SDK's own task assembly (`Microsoft.NET.Build.Tasks` - for example `AllowEmptyTelemetry` - which +is not part of `Microsoft.Build.Tasks.Core` and so cannot be registered from this harness). That pins the +exact AOT boundary for a real SDK build: it degrades to a reported error, never a reflection crash. + +### Host-registered SDK resolvers under AOT + +[RegisteredSdkResolverAotTests.cs](RegisteredSdkResolverAotTests.cs) covers the `SdkResolver.Register` +host-registration API (see +[documentation/specs/sdk-resolver-host-registration-api.md](../../documentation/specs/sdk-resolver-host-registration-api.md)) - +the seam an AOT host (the .NET SDK CLI) uses to contribute a reflection-free SDK resolver without MSBuild +discovering and loading it from disk. It is what makes an AOT-ready workload resolver possible now that the +plugin-resolver load is baked off. + +The test constructs a tiny resolver with `new` (no `Assembly.LoadFrom`, no reflection) and registers it from a +`[ModuleInitializer]` - mirroring how a host registers at startup, before the first evaluation, which is what +guarantees the resolver is present when the engine snapshots its default-resolver list on the first SDK +resolution. It then evaluates a `` whose SDK deliberately does **not** +live under `MSBuildSDKsPath`, so the in-box probe cannot find it; only the registered resolver can. Because +plugin loading is baked off, an unresolved SDK would fail observably with **MSB4282** instead - so the project +evaluating, with the SDK's `Sdk.props`/`Sdk.targets` imported, is end-to-end proof that a host-registered, +reflection-free resolver participates in resolution by `Priority` alongside the built-in resolver under Native +AOT. The resolver claims only its own SDK name and defers for every other SDK, so it leaves the rest of the +harness's resolution unchanged. + +## Warning gate + +The harness builds with `TreatWarningsAsErrors=true`, so **any new trim/AOT/single-file warning fails the +publish** - the engine's AOT surface stays explicitly triaged. The harness keeps **no `WarningsNotAsErrors` +exemptions**: every warning is driven to zero, so a regression cannot slip in as a "known" warning. + +- **IL3000** (`Assembly.Location` is empty in a single-file/AOT app) - the engine's on-disk self-discovery. + Most reachable sites are **excluded** rather than suppressed, by guarding the `Assembly.Location` read on + `RuntimeFeature.IsDynamicCodeSupported` (which ILC substitutes to `false` under Native AOT, then dead-strips + the guarded branch - so the warning is gone with no suppression at all): + - `AssemblyLoadsTracker` subscribes to `AppDomain.AssemblyLoad`, which never fires under Native AOT, so + its entry point returns early and ILC proves the tracker is never instantiated, dropping + `CurrentDomainOnAssemblyLoad` and its `Assembly.Location` read. + - `BuildEnvironmentHelper.Initialize` and `GetProcessFromRunningProcess` fall straight to the running + process path under AOT (an empty assembly location is meaningless there), so their `Assembly.Location` + reads are dead-stripped. + - `NativeMethods.FrameworkCurrentPath` reports an empty path under AOT. Its only consumers locate an + installed .NET Framework (or Mono) - which a Native AOT process never has - and already treat an empty + result as "framework not found", so the guard both keeps the behavior sensible and dead-strips the read. + + One site remains a documented `[UnconditionalSuppressMessage("SingleFile", "IL3000")]`: + `TypeExtensions.GetAssemblyPath`, the generic self-discovery primitive that is correct in a hosted/JIT + layout and cannot simply switch to the process path (it is also hardened to return the empty path rather + than throw from `Path.GetFullPath`). The supported AOT contract is that the host supplies the toolset via + `MSBUILD_EXE_PATH` (finding #1), so none of these paths are the source of truth here. + +Everything else is driven to zero too: the SDK-resolution and property-function reflective paths are gated +behind feature switches baked off here, the `Enum.GetValues(Type)` IL3050 is a vetted false positive (see +above), and the property-function receiver `IL2078` was fixed by annotating the `FunctionBuilder` backing +field. The `System.Configuration.ConfigurationManager` dependency (previously an exempted **IL2104**) is +now trimmed out entirely: the config-file toolset reader is gated behind the +`Microsoft.Build.EnableConfigurationFileToolsets` feature switch, baked off here so ILC dead-strips the +`ToolsetConfigurationReader` subtree and `System.Configuration` leaves the closure. + +## Files + +- `Microsoft.Build.AotValidation.csproj` - the AOT, MSTest-on-MTP test project (ProjectReferences `Microsoft.Build`). +- `HarnessEnvironment.cs` - `[ModuleInitializer]` that supplies `MSBUILD_EXE_PATH` (finding #1). +- `ObjectModelAotTests.cs` - the object-model scenarios. +- `PropertyFunctionAotTests.cs` - property-function behavior under AOT (reflective `Type`-taking members are unreachable; allowlisted receivers work). +- `DotnetTemplateAotTests.cs` - evaluates real `dotnet new` console/classlib projects under AOT (see "Evaluating real `dotnet new` templates"). +- `RegisteredSdkResolverAotTests.cs` - validates the `SdkResolver.Register` host-registration API under AOT (see "Host-registered SDK resolvers under AOT"). +- `TempDirectory.cs` - a disposable temp directory, mirroring the test infrastructure's `TransientTestFolder`. +- `ToolchainSmokeTests.cs` - validates the MSTest/MTP/AOT host independent of the object model. +- `Directory.Build.props` / `Directory.Build.targets` / `Directory.Packages.props` - isolate the harness from the Arcade build. diff --git a/src/aot-validation/RegisteredSdkResolverAotTests.cs b/src/aot-validation/RegisteredSdkResolverAotTests.cs new file mode 100644 index 00000000000..c6164fbcab1 --- /dev/null +++ b/src/aot-validation/RegisteredSdkResolverAotTests.cs @@ -0,0 +1,119 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Runtime.CompilerServices; +using Microsoft.Build.Evaluation; +using Microsoft.Build.Framework; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.Build.AotValidation; + +/// +/// Validates the host-registration API under Native AOT - the seam an +/// AOT host (the .NET SDK CLI) uses to contribute a reflection-free SDK resolver without MSBuild having to +/// discover and load it from disk by reflection. +/// +/// The target scenario: a host that runs the engine in-process and is itself trimmed/AOT-compiled hands +/// MSBuild a pre-constructed resolver instance (built with new, e.g. an AOT-ready workload-locator +/// resolver). MSBuild folds it into the same reflection-free pass as its built-in resolver, so it resolves +/// SDKs with no Assembly.LoadFrom and never reaches the dynamic-loading failure (MSB4282) this harness bakes +/// off via EnableSdkResolverDynamicLoading=false. +/// +/// Registration is process-global and the engine snapshots its default-resolver list on first use, so the +/// resolver is registered from a - before any test evaluates a +/// project - mirroring how a host registers during startup, ahead of the first evaluation. The resolver +/// claims only its own SDK name and defers (returns ) for every other SDK, so it +/// leaves all other harness resolution unchanged. +/// +[TestClass] +public sealed class RegisteredSdkResolverAotTests +{ + [TestMethod] + public void RegisteredResolver_ResolvesSdk_AndImportsItsPropsAndTargets() + { + using var projectDir = new TempDirectory(); + string projectPath = Path.Combine(projectDir.Path, "App.csproj"); + + // A for an SDK that does NOT live under MSBuildSDKsPath, so the in-box + // DefaultSdkResolver's directory probe cannot find it. Only the registered resolver can resolve it, + // which is precisely what makes this a test of the registration seam rather than the in-box path. + File.WriteAllText( + projectPath, + $"""Debug"""); + + using var collection = new ProjectCollection(); + var project = new Project(projectPath, globalProperties: null, toolsVersion: null, collection); + + // The registered resolver returned the fixture directory, so the SDK's implicit Sdk.props (imported + // at the top) and Sdk.targets (imported at the bottom) both loaded - end-to-end proof that a + // host-registered, reflection-free resolver participates in resolution under Native AOT. + Assert.AreEqual("props-value", project.GetPropertyValue("FromRegisteredSdkProps")); + Assert.AreEqual("targets-value", project.GetPropertyValue("FromRegisteredSdkTargets")); + Assert.AreEqual(2, project.Imports.Count); + } +} + +/// +/// Lays down a tiny on-disk SDK (just an Sdk.props and Sdk.targets) and registers a reflection-free resolver +/// for it at module load, mirroring an AOT host that calls during startup +/// before the first evaluation. Module-load registration is what guarantees the resolver is present when the +/// engine snapshots its default-resolver list on the first SDK resolution in the process. +/// +internal static class RegisteredSdkResolverFixture +{ + /// The SDK name the registered resolver owns. Distinct so nothing else in the harness resolves it. + internal const string SdkName = "Harness.Registered.Sdk"; + + // The on-disk SDK fixture is process-scoped: it must outlive every evaluation, so it is held in a static + // field (which also transfers disposal ownership off the module initializer) and disposed at process exit. + private static TempDirectory? s_sdkFixture; + + [ModuleInitializer] + internal static void Register() + { + TempDirectory sdkFixture = new(); + s_sdkFixture = sdkFixture; + File.WriteAllText( + Path.Combine(sdkFixture.Path, "Sdk.props"), + "props-value"); + File.WriteAllText( + Path.Combine(sdkFixture.Path, "Sdk.targets"), + "targets-value"); + + // Construct the resolver directly (no reflection, no Assembly.LoadFrom) and register it - the exact + // call an AOT host makes. It works under AOT because Register only stores the instance and the engine + // folds it into the reflection-free default-resolver pass, by Priority, alongside the built-in resolver. + SdkResolver.Register(new FixedPathSdkResolver(SdkName, sdkFixture.Path)); + + AppDomain.CurrentDomain.ProcessExit += (_, _) => sdkFixture.Dispose(); + } +} + +/// +/// A minimal reflection-free : it resolves one SDK name to a fixed directory and +/// defers (returns ) for everything else. This is the shape a host bakes in for AOT +/// (for example a workload-locator resolver) - constructed with new, with no assembly loading and no +/// reflection on the resolution path. +/// +internal sealed class FixedPathSdkResolver : SdkResolver +{ + private readonly string _sdkName; + private readonly string _sdkPath; + + internal FixedPathSdkResolver(string sdkName, string sdkPath) + { + _sdkName = sdkName; + _sdkPath = sdkPath; + } + + public override string Name => nameof(FixedPathSdkResolver); + + // Below the in-box DefaultSdkResolver's 10000 so this is consulted first, but it claims only its own SDK; + // every other SDK falls through to the in-box resolver exactly as before. + public override int Priority => 100; + + public override SdkResult? Resolve(SdkReference sdkReference, SdkResolverContext resolverContext, SdkResultFactory factory) + => string.Equals(sdkReference.Name, _sdkName, StringComparison.OrdinalIgnoreCase) + ? factory.IndicateSuccess(_sdkPath, sdkReference.Version ?? string.Empty) + : null; +} diff --git a/src/aot-validation/RegisteredTaskAotTests.cs b/src/aot-validation/RegisteredTaskAotTests.cs new file mode 100644 index 00000000000..a41346ede85 --- /dev/null +++ b/src/aot-validation/RegisteredTaskAotTests.cs @@ -0,0 +1,224 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Build.Evaluation; +using Microsoft.Build.Framework; +using Microsoft.Build.Tasks; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.Build.AotValidation; + +/// +/// Validates that MSBuild can actually build - run tasks, not just evaluate - under Native AOT, +/// using host-registered task classes. +/// +/// The harness bakes EnableReflectiveTaskExecution=false, so the reflective task-loading path +/// (assembly probing, by-name type resolution) is trimmed away and an unregistered task fails +/// observably. A host instead pre-registers its tasks with the host task registry: the common built-in +/// tasks through , and its own tasks through +/// . A registered task is constructed and bound with +/// no assembly loading or by-name type resolution, so it runs under AOT. These tests drive a real +/// in-process build () +/// of a hand-authored project and assert the tasks' real side effects. +/// +[TestClass] +public sealed class RegisteredTaskAotTests +{ + [TestMethod] + public void RegisteredBuiltInAndCustomTasks_Build_UnderAot() + { + // Pre-register the common built-in tasks and a host custom task before the build. (Idempotent, so + // calling RegisterAll across tests is safe.) + BuiltInTasks.RegisterAll(); + Utilities.Task.RegisterTask(nameof(HarnessEchoTask)); + + using TempDirectory dir = new(); + string outDir = Path.Combine(dir.Path, "out"); + string projectPath = Path.Combine(dir.Path, "Build.proj"); + + // A self-contained project (no SDK, no imports) whose single target runs a chain of registered + // tasks: a built-in directory/file/copy sequence plus a host-registered custom task whose [Output] + // is bound back to a property and echoed. Exercising the whole FindTask -> construct -> bind -> + // Execute path for registered tasks under AOT. + File.WriteAllText( + projectPath, + $""" + + + {outDir} + + + + + + + + + + + + + + + + """); + + CapturingLogger logger = new(); + using ProjectCollection collection = new(); + Project project = new(projectPath, globalProperties: null, toolsVersion: null, collection); + + bool success = InProcBuild.Run(project, "Build", logger); + + Assert.IsTrue( + success, + "Build failed. Errors:" + Environment.NewLine + string.Join(Environment.NewLine, logger.Errors)); + + // The built-in MakeDir/WriteLinesToFile/Copy tasks ran (real file side effects). + string copiedFile = Path.Combine(outDir, "copy.txt"); + Assert.IsTrue(File.Exists(Path.Combine(outDir, "lines.txt")), "WriteLinesToFile did not produce lines.txt."); + Assert.IsTrue(File.Exists(copiedFile), "Copy did not produce copy.txt."); + CollectionAssert.AreEqual(new[] { "alpha", "beta" }, File.ReadAllLines(copiedFile)); + + // The host-registered custom task ran, and its [Output] was bound back to a property (reflective + // parameter binding over the registered, trim-rooted task type). + Assert.IsTrue( + logger.Messages.Exists(m => m.Contains("Echo result: hello!", StringComparison.Ordinal)), + "The custom registered task's output was not bound. Messages:" + Environment.NewLine + + string.Join(Environment.NewLine, logger.Messages)); + } + + [TestMethod] + public void IntrinsicCallTargetAndMSBuildTasks_Build_UnderAot() + { + // The intrinsic MSBuild and CallTarget tasks are engine-internal types resolved without reflecting + // over a runtime-discovered assembly, so they must stay available with reflective task execution + // disabled (the AOT path) even though they are not host-registered - virtually every real build uses + // them. The inner leaf tasks (MakeDir/WriteLinesToFile) are host-registered as usual. + BuiltInTasks.RegisterAll(); + + using TempDirectory dir = new(); + string outDir = Path.Combine(dir.Path, "out"); + string childMarker = Path.Combine(outDir, "child.txt"); + string callTargetMarker = Path.Combine(outDir, "calltarget.txt"); + string childProjectPath = Path.Combine(dir.Path, "Child.proj"); + string projectPath = Path.Combine(dir.Path, "Build.proj"); + + File.WriteAllText( + childProjectPath, + $""" + + + + + + + """); + + File.WriteAllText( + projectPath, + $""" + + + {outDir} + + + + + + + + + + + + + """); + + CapturingLogger logger = new(); + using ProjectCollection collection = new(); + Project project = new(projectPath, globalProperties: null, toolsVersion: null, collection); + + bool success = InProcBuild.Run(project, "Build", logger); + + Assert.IsTrue( + success, + "Build failed. Errors:" + Environment.NewLine + string.Join(Environment.NewLine, logger.Errors)); + + // CallTarget (intrinsic, unregistered) dispatched to the ViaCallTarget target. + Assert.IsTrue(File.Exists(callTargetMarker), "CallTarget did not run the ViaCallTarget target under AOT."); + + // The MSBuild task (intrinsic, unregistered) built the child project in-process. + Assert.IsTrue(File.Exists(childMarker), "The MSBuild task did not build the child project under AOT."); + } + + [TestMethod] + public void UnregisteredTask_WithReflectionOff_FailsObservably() + { + // A task that is neither pre-registered nor host-registered. With the reflective task-loading path + // trimmed away, the build fails with a reported error rather than crashing in reflection. + using TempDirectory dir = new(); + string projectPath = Path.Combine(dir.Path, "Unregistered.proj"); + File.WriteAllText( + projectPath, + """ + + + + + + """); + + CapturingLogger logger = new(); + using ProjectCollection collection = new(); + Project project = new(projectPath, globalProperties: null, toolsVersion: null, collection); + + bool success = InProcBuild.Run(project, "Build", logger); + + Assert.IsFalse(success, "An unregistered task must not build under AOT."); + Assert.IsTrue(logger.Errors.Count > 0, "An unregistered task must fail observably with a reported error."); + } +} + +/// +/// A host task registered through the public registration API. Its is an +/// [Output] bound back to a property, exercising reflective parameter binding over a registered, +/// trim-rooted task type under AOT. +/// +public sealed class HarnessEchoTask : Utilities.Task +{ + public string? Input { get; set; } + + [Output] + public string? Result { get; set; } + + public override bool Execute() + { + Result = Input + "!"; + Log.LogMessage(MessageImportance.High, "Echo result: " + Result); + return true; + } +} + +/// +/// A minimal, reflection-free that captures messages and errors for assertions. +/// +internal sealed class CapturingLogger : ILogger +{ + public List Messages { get; } = []; + + public List Errors { get; } = []; + + public LoggerVerbosity Verbosity { get; set; } = LoggerVerbosity.Normal; + + public string? Parameters { get; set; } + + public void Initialize(IEventSource eventSource) + { + eventSource.MessageRaised += (_, e) => Messages.Add(e.Message ?? string.Empty); + eventSource.ErrorRaised += (_, e) => Errors.Add(e.Message ?? string.Empty); + } + + public void Shutdown() + { + } +} diff --git a/src/aot-validation/TaskParameterTypeRegistryAotTests.cs b/src/aot-validation/TaskParameterTypeRegistryAotTests.cs new file mode 100644 index 00000000000..660461b4b70 --- /dev/null +++ b/src/aot-validation/TaskParameterTypeRegistryAotTests.cs @@ -0,0 +1,134 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Build.Evaluation; +using Microsoft.Build.Exceptions; +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.Build.AotValidation; + +/// +/// Validates the reflection-free task-parameter-type resolution under Native AOT - the path a +/// <UsingTask> <ParameterGroup> takes when MSBuild turns a declared +/// ParameterType name into a . +/// +/// This harness bakes EnableReflectiveTaskParameterTypes=false, so the by-name +/// Type.GetType fallback is trimmed away. Only types in TaskParameterTypeRegistry (the +/// intrinsic value types, , and the MSBuild types, plus any +/// a host registers through / +/// ) resolve. Parsing the <ParameterGroup> +/// happens during evaluation (registration), independent of any task factory, so simply evaluating the +/// project exercises the registry. The inline task is never executed. +/// +[TestClass] +public sealed class TaskParameterTypeRegistryAotTests +{ + [TestMethod] + public void PreRegisteredParameterTypes_ResolveWithReflectionOff() + { + // Every one of these is pre-registered (string, the intrinsic value types and their arrays, and + // the ITaskItem family), so the parses with no Type.GetType even though the + // reflective fallback is trimmed away in this image. Evaluation completing is the proof. + Project project = Evaluate( + """ + + + + + + + + + """); + + Assert.AreEqual("ok", project.GetPropertyValue("Sentinel")); + } + + [TestMethod] + public void HostRegisteredValueType_ResolvesWithReflectionOff() + { + // A value type that is NOT pre-registered. The host registers it through the public seam; after + // that the name resolves from the registry with no reflection, which is the whole point of the + // registration API under AOT. The [DynamicallyAccessedMembers] on the register method roots the + // struct so the trimmer preserves it. + TaskItem.RegisterTaskParameterValueType(); + + Project project = Evaluate( + $""" + + """); + + Assert.AreEqual("ok", project.GetPropertyValue("Sentinel")); + } + + [TestMethod] + public void HostRegisteredConcreteItemType_ResolvesWithReflectionOff() + { + // Microsoft.Build.Utilities.TaskItem is the public concrete ITaskItem a task author constructs. It + // is a higher-layer type the engine does not pre-register (Microsoft.Build does not reference + // Microsoft.Build.Utilities); a host that declares it registers it through the public API, after + // which it resolves from the registry with no reflection. (Concrete item types are legal only as + // outputs, so this is declared Output.) + TaskItem.RegisterTaskParameterItemType(); + + Project project = Evaluate( + """ + + """); + + Assert.AreEqual("ok", project.GetPropertyValue("Sentinel")); + } + + [TestMethod] + public void UnregisteredType_WithReflectionOff_FailsObservably() + { + // System.Guid is a valid value-type parameter but is intentionally NOT pre-registered. With the + // reflective fallback trimmed away, the name does not resolve and evaluation fails with a reported + // project error (InvalidProjectFileException) - the observable-failure contract this switch + // promises, not a reflection crash. + Assert.ThrowsException( + () => Evaluate( + """ + + """)); + } + + /// + /// Writes a project whose single <UsingTask> declares the given parameters in a + /// <ParameterGroup> and evaluates it. Evaluation parses the parameter group (resolving + /// each declared type) but never executes the task, so an unresolvable assembly/factory is irrelevant. + /// + private static Project Evaluate(string parameterGroupInner) + { + string projectXml = + $$""" + + ok + + + {{parameterGroupInner}} + + // never executed + + + """; + + using TempDirectory dir = new(); + string projectPath = Path.Combine(dir.Path, "App.proj"); + File.WriteAllText(projectPath, projectXml); + + using ProjectCollection collection = new(); + return new Project(projectPath, globalProperties: null, toolsVersion: null, collection); + } +} + +/// +/// A value type the product does not pre-register, used to prove host registration of a custom task +/// parameter value type resolves under Native AOT. +/// +internal struct HarnessParameterStruct +{ + public int Value { get; set; } +} diff --git a/src/aot-validation/TempDirectory.cs b/src/aot-validation/TempDirectory.cs new file mode 100644 index 00000000000..712cab5b0b4 --- /dev/null +++ b/src/aot-validation/TempDirectory.cs @@ -0,0 +1,50 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.Build.AotValidation; + +/// +/// A disposable, uniquely-named temporary directory that is recursively deleted on . +/// +/// This mirrors the MSBuild test infrastructure's TransientTestFolder +/// (src/UnitTests.Shared/TestEnvironment.cs), including its guard against deleting an obviously-wrong +/// path. The harness is deliberately isolated from the test-infrastructure assemblies, so this is a +/// small self-contained equivalent rather than a reference to that type. +/// +internal sealed class TempDirectory : IDisposable +{ + public TempDirectory() + { + Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "msb-aot-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(Path); + } + + /// + /// The absolute path of the created directory. + /// + public string Path { get; } + + public void Dispose() + { + // Basic safety checks before a recursive delete (mirrors TransientTestFolder.Revert): never delete + // a non-rooted path or the temp root itself. + if (string.IsNullOrEmpty(Path) + || !System.IO.Path.IsPathRooted(Path) + || System.IO.Path.GetFullPath(Path) == System.IO.Path.GetFullPath(System.IO.Path.GetTempPath())) + { + return; + } + + try + { + Directory.Delete(Path, recursive: true); + } + catch (IOException) + { + // Best-effort cleanup: a transiently locked file under the temp directory must not fail the test. + } + catch (UnauthorizedAccessException) + { + } + } +} diff --git a/src/aot-validation/ToolchainSmokeTests.cs b/src/aot-validation/ToolchainSmokeTests.cs new file mode 100644 index 00000000000..d819d079504 --- /dev/null +++ b/src/aot-validation/ToolchainSmokeTests.cs @@ -0,0 +1,21 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.Build.AotValidation; + +/// +/// Smoke test that validates the MSTest + Microsoft.Testing.Platform + Native AOT toolchain +/// itself, independent of the MSBuild object model. If this passes in an AOT-published run, +/// the test host works under AOT and any failures in the object-model tests are real findings. +/// +[TestClass] +public sealed class ToolchainSmokeTests +{ + [TestMethod] + public void TestHostRunsUnderAot() + { + Assert.AreEqual(4, 2 + 2); + } +} From 68be57f89d55e47a5343877dea49d37a165079ea Mon Sep 17 00:00:00 2001 From: Jeremy Kuhne Date: Sun, 28 Jun 2026 18:50:02 -0700 Subject: [PATCH 2/4] Fix property-function allowlist + overlapping-build NRE test regressions Add ReadAllBytes/GetCreationTimeUtc/GetLastWriteTimeUtc to the read-only File property-function allowlist; the -mt tests for those methods now rely on the allowlist instead of the escape hatch. Route every EnableAllPropertyFunctions hatch test through the AppContext feature switch for deterministic, order-independent behavior, and keep a single env-var test that reflectively clears the switch (AppContext has no public unset state) to vet that MSBUILDENABLEALLPROPERTYFUNCTIONS still flows. Null-guard the BuildProject finally so a rejected overlapping build cannot NRE. --- .../Evaluation/Expander_Tests.cs | 78 +++++++++++++++---- ...opertyFunctionReceiverRestriction_Tests.cs | 12 ++- .../RequestBuilder/RequestBuilder.cs | 9 ++- src/Build/Resources/Constants.cs | 3 + 4 files changed, 78 insertions(+), 24 deletions(-) diff --git a/src/Build.UnitTests/Evaluation/Expander_Tests.cs b/src/Build.UnitTests/Evaluation/Expander_Tests.cs index 194019c6eac..a85d7a91b3b 100644 --- a/src/Build.UnitTests/Evaluation/Expander_Tests.cs +++ b/src/Build.UnitTests/Evaluation/Expander_Tests.cs @@ -2,10 +2,12 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using System.Collections; using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; +using System.Reflection; using System.Runtime.InteropServices; using System.Runtime.Versioning; using System.Text; @@ -2759,7 +2761,7 @@ public void PropertyStaticFunctionAllEnabled() { using (var env = TestEnvironment.Create()) { - env.SetEnvironmentVariable("MSBUILDENABLEALLPROPERTYFUNCTIONS", "1"); + env.WithTransientTestState(new TransientEnableAllPropertyFunctions()); PropertyDictionary pg = new PropertyDictionary(); @@ -5347,6 +5349,14 @@ public void GetTypeMethod_ShouldBeAllowed_EnabledByEnvVariable(string methodName { using (var env = TestEnvironment.Create()) { + // This is the one test that vets the environment-variable opt-in actually flows through + // to the feature check. Mimic a prior test having set the AppContext switch (which cannot + // be returned to "unset" via the public API), then clear it reflectively so FeatureSwitches + // falls back to the variable. Doing both makes the test deterministic regardless of test + // ordering and self-validates the reflective unset on every runtime (.NET Core and .NET + // Framework store the switch in different internal fields). + AppContext.SetSwitch("Microsoft.Build.EnableAllPropertyFunctions", false); + UnsetAppContextSwitch("Microsoft.Build.EnableAllPropertyFunctions"); env.SetEnvironmentVariable("MSBUILDENABLEALLPROPERTYFUNCTIONS", "1"); var root = env.CreateFolder(); @@ -5484,6 +5494,49 @@ public override void Revert() } } + /// + /// TransientTestState that flips the EnableAllPropertyFunctions AppContext switch on and restores + /// its original value on revert (deterministic; does not stick across tests). + /// + private sealed class TransientEnableAllPropertyFunctions : TransientTestState + { + private readonly bool _original; + + public TransientEnableAllPropertyFunctions() + { + AppContext.TryGetSwitch("Microsoft.Build.EnableAllPropertyFunctions", out _original); + AppContext.SetSwitch("Microsoft.Build.EnableAllPropertyFunctions", true); + } + + public override void Revert() => AppContext.SetSwitch("Microsoft.Build.EnableAllPropertyFunctions", _original); + } + + /// + /// Returns an AppContext switch to the "unset" state so that the FeatureSwitches check falls + /// back to the environment variable. AppContext can only set a switch true or false (never + /// unset), so the entry is removed reflectively from the runtime's private switch table. The + /// backing field differs by runtime (.NET Core uses `s_switches`, .NET Framework uses + /// `s_switchMap`), so this scans the non-public static dictionaries and clears the key from + /// whichever one holds it rather than hard-coding a field name. + /// + private static void UnsetAppContextSwitch(string switchName) + { + foreach (FieldInfo field in typeof(AppContext).GetFields(BindingFlags.NonPublic | BindingFlags.Static)) + { + if (field.GetValue(null) is IDictionary switches) + { + lock (switches) + { + if (switches.Contains(switchName)) + { + switches.Remove(switchName); + return; + } + } + } + } + } + /// /// Helper: expand a property function expression with CurrentThreadWorkingDirectory set, /// simulating -mt mode where Environment.CurrentDirectory may point elsewhere. @@ -5717,7 +5770,7 @@ public void DirectoryGetLastAccessTime_RelativePath_ResolvesFromThreadWorkingDir public void FileReadAllLines_RelativePath_ResolvesFromThreadWorkingDirectory() { using var env = TestEnvironment.Create(_output); - env.SetEnvironmentVariable("MSBUILDENABLEALLPROPERTYFUNCTIONS", "1"); + env.WithTransientTestState(new TransientEnableAllPropertyFunctions()); var correctDir = env.CreateFolder(createFolder: true); var wrongDir = env.CreateFolder(createFolder: true); @@ -5734,7 +5787,6 @@ public void FileReadAllLines_RelativePath_ResolvesFromThreadWorkingDirectory() public void FileReadAllBytes_RelativePath_ResolvesFromThreadWorkingDirectory() { using var env = TestEnvironment.Create(_output); - env.SetEnvironmentVariable("MSBUILDENABLEALLPROPERTYFUNCTIONS", "1"); var correctDir = env.CreateFolder(createFolder: true); var wrongDir = env.CreateFolder(createFolder: true); @@ -5750,7 +5802,7 @@ public void FileReadAllBytes_RelativePath_ResolvesFromThreadWorkingDirectory() public void FileWriteAllText_RelativePath_ResolvesFromThreadWorkingDirectory() { using var env = TestEnvironment.Create(_output); - env.SetEnvironmentVariable("MSBUILDENABLEALLPROPERTYFUNCTIONS", "1"); + env.WithTransientTestState(new TransientEnableAllPropertyFunctions()); var correctDir = env.CreateFolder(createFolder: true); var wrongDir = env.CreateFolder(createFolder: true); @@ -5766,7 +5818,7 @@ public void FileWriteAllText_RelativePath_ResolvesFromThreadWorkingDirectory() public void FileAppendAllText_RelativePath_ResolvesFromThreadWorkingDirectory() { using var env = TestEnvironment.Create(_output); - env.SetEnvironmentVariable("MSBUILDENABLEALLPROPERTYFUNCTIONS", "1"); + env.WithTransientTestState(new TransientEnableAllPropertyFunctions()); var correctDir = env.CreateFolder(createFolder: true); var wrongDir = env.CreateFolder(createFolder: true); @@ -5782,7 +5834,7 @@ public void FileAppendAllText_RelativePath_ResolvesFromThreadWorkingDirectory() public void FileDelete_RelativePath_ResolvesFromThreadWorkingDirectory() { using var env = TestEnvironment.Create(_output); - env.SetEnvironmentVariable("MSBUILDENABLEALLPROPERTYFUNCTIONS", "1"); + env.WithTransientTestState(new TransientEnableAllPropertyFunctions()); var correctDir = env.CreateFolder(createFolder: true); var wrongDir = env.CreateFolder(createFolder: true); @@ -5802,7 +5854,6 @@ public void FileDelete_RelativePath_ResolvesFromThreadWorkingDirectory() public void FileGetCreationTimeUtc_RelativePath_ResolvesFromThreadWorkingDirectory() { using var env = TestEnvironment.Create(_output); - env.SetEnvironmentVariable("MSBUILDENABLEALLPROPERTYFUNCTIONS", "1"); var correctDir = env.CreateFolder(createFolder: true); var wrongDir = env.CreateFolder(createFolder: true); @@ -5820,7 +5871,6 @@ public void FileGetCreationTimeUtc_RelativePath_ResolvesFromThreadWorkingDirecto public void FileGetLastWriteTimeUtc_RelativePath_ResolvesFromThreadWorkingDirectory() { using var env = TestEnvironment.Create(_output); - env.SetEnvironmentVariable("MSBUILDENABLEALLPROPERTYFUNCTIONS", "1"); var correctDir = env.CreateFolder(createFolder: true); var wrongDir = env.CreateFolder(createFolder: true); @@ -5838,7 +5888,7 @@ public void FileGetLastWriteTimeUtc_RelativePath_ResolvesFromThreadWorkingDirect public void FileGetLastAccessTimeUtc_RelativePath_ResolvesFromThreadWorkingDirectory() { using var env = TestEnvironment.Create(_output); - env.SetEnvironmentVariable("MSBUILDENABLEALLPROPERTYFUNCTIONS", "1"); + env.WithTransientTestState(new TransientEnableAllPropertyFunctions()); var correctDir = env.CreateFolder(createFolder: true); var wrongDir = env.CreateFolder(createFolder: true); @@ -5860,7 +5910,7 @@ public void FileGetLastAccessTimeUtc_RelativePath_ResolvesFromThreadWorkingDirec public void DirectoryCreateDirectory_RelativePath_ResolvesFromThreadWorkingDirectory() { using var env = TestEnvironment.Create(_output); - env.SetEnvironmentVariable("MSBUILDENABLEALLPROPERTYFUNCTIONS", "1"); + env.WithTransientTestState(new TransientEnableAllPropertyFunctions()); var correctDir = env.CreateFolder(createFolder: true); var wrongDir = env.CreateFolder(createFolder: true); @@ -5875,7 +5925,7 @@ public void DirectoryCreateDirectory_RelativePath_ResolvesFromThreadWorkingDirec public void DirectoryDelete_RelativePath_ResolvesFromThreadWorkingDirectory() { using var env = TestEnvironment.Create(_output); - env.SetEnvironmentVariable("MSBUILDENABLEALLPROPERTYFUNCTIONS", "1"); + env.WithTransientTestState(new TransientEnableAllPropertyFunctions()); var correctDir = env.CreateFolder(createFolder: true); var wrongDir = env.CreateFolder(createFolder: true); @@ -5984,7 +6034,7 @@ public void FileExists_AbsolutePath_NotMangledByThreadWorkingDirectory() public void FileCopy_TwoRelativePaths_BothResolveFromThreadWorkingDirectory() { using var env = TestEnvironment.Create(_output); - env.SetEnvironmentVariable("MSBUILDENABLEALLPROPERTYFUNCTIONS", "1"); + env.WithTransientTestState(new TransientEnableAllPropertyFunctions()); var correctDir = env.CreateFolder(createFolder: true); var wrongDir = env.CreateFolder(createFolder: true); @@ -6002,7 +6052,7 @@ public void FileCopy_TwoRelativePaths_BothResolveFromThreadWorkingDirectory() public void FileMove_TwoRelativePaths_BothResolveFromThreadWorkingDirectory() { using var env = TestEnvironment.Create(_output); - env.SetEnvironmentVariable("MSBUILDENABLEALLPROPERTYFUNCTIONS", "1"); + env.WithTransientTestState(new TransientEnableAllPropertyFunctions()); var correctDir = env.CreateFolder(createFolder: true); var wrongDir = env.CreateFolder(createFolder: true); @@ -6020,7 +6070,7 @@ public void FileMove_TwoRelativePaths_BothResolveFromThreadWorkingDirectory() public void DirectoryMove_TwoRelativePaths_BothResolveFromThreadWorkingDirectory() { using var env = TestEnvironment.Create(_output); - env.SetEnvironmentVariable("MSBUILDENABLEALLPROPERTYFUNCTIONS", "1"); + env.WithTransientTestState(new TransientEnableAllPropertyFunctions()); var correctDir = env.CreateFolder(createFolder: true); var wrongDir = env.CreateFolder(createFolder: true); diff --git a/src/Build.UnitTests/Evaluation/PropertyFunctionReceiverRestriction_Tests.cs b/src/Build.UnitTests/Evaluation/PropertyFunctionReceiverRestriction_Tests.cs index 2991811de86..e4760682be5 100644 --- a/src/Build.UnitTests/Evaluation/PropertyFunctionReceiverRestriction_Tests.cs +++ b/src/Build.UnitTests/Evaluation/PropertyFunctionReceiverRestriction_Tests.cs @@ -21,17 +21,15 @@ namespace Microsoft.Build.UnitTests.Evaluation; /// interaction with the Microsoft.Build.EnableAllPropertyFunctions escape hatch. /// /// -/// The restriction is driven through its AppContext switch (set explicitly per test and reset to false -/// afterwards) because an AppContext switch, once set, cannot be returned to the "unset" state in -/// process. The EnableAllPropertyFunctions escape hatch is exercised through its environment -/// variable so that its AppContext switch stays unset, preserving the behavior the existing -/// env-var-based tests rely on. The new restriction switch intentionally has no environment variable. +/// Both the restriction switch and the EnableAllPropertyFunctions escape hatch are driven +/// through their AppContext switches (set explicitly per test and reset to false afterwards) because +/// an AppContext switch, once set, cannot be returned to the "unset" state in process. The new +/// restriction switch intentionally has no environment variable. /// public class PropertyFunctionReceiverRestriction_Tests { private const string RestrictSwitch = "Microsoft.Build.RestrictPropertyFunctionReceivers"; private const string RestrictEnvVar = "MSBUILDRESTRICTPROPERTYFUNCTIONS"; - private const string EnableAllEnvVar = "MSBUILDENABLEALLPROPERTYFUNCTIONS"; private static string Evaluate(string expression, params (string name, string value)[] properties) { @@ -211,8 +209,8 @@ public void EnvironmentVariable_DoesNotEnableRestriction() public void EnableAllPropertyFunctions_BypassesRestriction() { using TestEnvironment env = TestEnvironment.Create(); - env.SetEnvironmentVariable(EnableAllEnvVar, "1"); + using (SetSwitch("Microsoft.Build.EnableAllPropertyFunctions", true)) using (SetSwitch(RestrictSwitch, true)) { // EnableAll takes precedence over the restriction (and over the GetType block), preserving diff --git a/src/Build/BackEnd/Components/RequestBuilder/RequestBuilder.cs b/src/Build/BackEnd/Components/RequestBuilder/RequestBuilder.cs index a1cb415786a..b8c8f7308da 100644 --- a/src/Build/BackEnd/Components/RequestBuilder/RequestBuilder.cs +++ b/src/Build/BackEnd/Components/RequestBuilder/RequestBuilder.cs @@ -1276,9 +1276,12 @@ private async Task BuildProject() } finally { - buildCheckManager?.EndProjectRequest( - new CheckLoggingContext(_nodeLoggingContext.LoggingService, _projectLoggingContext.BuildEventContext), - _requestEntry.RequestConfiguration.ProjectFullPath); + if (buildCheckManager is not null && _projectLoggingContext is not null) + { + buildCheckManager.EndProjectRequest( + new CheckLoggingContext(_nodeLoggingContext.LoggingService, _projectLoggingContext.BuildEventContext), + _requestEntry.RequestConfiguration.ProjectFullPath); + } } BuildResult CopyTargetResultsFromProxyTargetsToRealTargets(BuildResult resultFromTargetBuilder) diff --git a/src/Build/Resources/Constants.cs b/src/Build/Resources/Constants.cs index 4df9171444e..90f10b7a2a6 100644 --- a/src/Build/Resources/Constants.cs +++ b/src/Build/Resources/Constants.cs @@ -356,7 +356,10 @@ private static void InitializeAvailableMethods() availableStaticMethods.TryAdd("System.IO.File::GetAttributes", fileType); availableStaticMethods.TryAdd("System.IO.File::GetLastAccessTime", fileType); availableStaticMethods.TryAdd("System.IO.File::GetLastWriteTime", fileType); + availableStaticMethods.TryAdd("System.IO.File::GetCreationTimeUtc", fileType); + availableStaticMethods.TryAdd("System.IO.File::GetLastWriteTimeUtc", fileType); availableStaticMethods.TryAdd("System.IO.File::ReadAllText", fileType); + availableStaticMethods.TryAdd("System.IO.File::ReadAllBytes", fileType); availableStaticMethods.TryAdd("System.Globalization.CultureInfo::GetCultureInfo", new Tuple(null, typeof(CultureInfo))); // user request availableStaticMethods.TryAdd("System.Globalization.CultureInfo::new", new Tuple(null, typeof(CultureInfo))); // user request From 8d7acb9d8843245ff1b5b33793a40c7a85593a7f Mon Sep 17 00:00:00 2001 From: Jeremy Kuhne Date: Mon, 6 Jul 2026 12:03:18 -0700 Subject: [PATCH 3/4] Resolve MSBuild from the SDK-published Microsoft.DotNet.Sdk.Root AppContext value When the .NET SDK hosts MSBuild in a trimmed / Native AOT process the BCL 'where am I' APIs point at the muxer / install root, not the versioned SDK directory that contains MSBuild. Following dotnet/sdk#55110, read the SDK-published 'Microsoft.DotNet.Sdk.Root' AppContext value (new DotNetSdkPaths helper) as an early step in BuildEnvironmentHelper resolution, before falling back to process/assembly discovery. --- documentation/aot/aot-trimming-strategy.md | 2 +- .../BuildEnvironmentHelper_Tests.cs | 23 +++++++++++ src/Framework/BuildEnvironmentHelper.cs | 30 +++++++++++++- src/Framework/Utilities/DotNetSdkPaths.cs | 41 +++++++++++++++++++ 4 files changed, 94 insertions(+), 2 deletions(-) create mode 100644 src/Framework/Utilities/DotNetSdkPaths.cs diff --git a/documentation/aot/aot-trimming-strategy.md b/documentation/aot/aot-trimming-strategy.md index 700fa2f931d..3e5d26ea229 100644 --- a/documentation/aot/aot-trimming-strategy.md +++ b/documentation/aot/aot-trimming-strategy.md @@ -190,7 +190,7 @@ removes the unsafe branch; the default is what AOT ships. | --- | --- | --- | | `EnableCustomPluginProbing` | `MSBuildLoadContext.Load`, `TaskEngineAssemblyResolver.ResolveAssembly` | `return null` — defers to the default `AssemblyLoadContext`, which still throws `FileNotFoundException` if the assembly is genuinely needed (so it is *not* silent; it removes only MSBuild's *extra* reflective search) | | `RuntimeFeature.IsDynamicCodeSupported` | `AssemblyLoadsTracker` (`AppDomain.AssemblyLoad` never fires under AOT) | early-return `EmptyDisposable.Instance` → ILC proves the tracker is never instantiated and strips its `Assembly.Location` read (clears **IL3000** with no suppression) | -| `RuntimeFeature.IsDynamicCodeSupported` | `BuildEnvironmentHelper.Initialize` / `GetProcessFromRunningProcess` | fall straight to the running process path (an empty `Assembly.Location` is meaningless under AOT anyway) | +| `RuntimeFeature.IsDynamicCodeSupported` | `BuildEnvironmentHelper.Initialize` / `GetProcessFromRunningProcess` | prefer the versioned SDK directory the host publishes through the `Microsoft.DotNet.Sdk.Root` AppContext value (`DotNetSdkPaths`, mirroring SDK [PR #55110](https://github.com/dotnet/sdk/pull/55110)); only when it is unset fall straight to the running process path (an empty `Assembly.Location` is meaningless under AOT anyway) | | `RuntimeFeature.IsDynamicCodeSupported` | `NativeMethods.FrameworkCurrentPath` | empty string — every consumer already treats empty as ".NET Framework not found", which is correct (an AOT process has no .NET Framework) | | `EnableAllPropertyFunctions` (default **false**) | property-function *type probing* | the curated allowlist is the only path; the wide "probe any assembly" branch is removed | | `RestrictPropertyFunctionReceivers` (trimmed default **true**) | instance "dotting-in" receiver set | bounded, side-effect-free receiver allowlist (`PropertyFunctionReceiver`) — see [property-functions-reachability.md §10](property-functions-reachability.md) | diff --git a/src/Build.UnitTests/BuildEnvironmentHelper_Tests.cs b/src/Build.UnitTests/BuildEnvironmentHelper_Tests.cs index cc123f4ec56..bb50d10cea9 100644 --- a/src/Build.UnitTests/BuildEnvironmentHelper_Tests.cs +++ b/src/Build.UnitTests/BuildEnvironmentHelper_Tests.cs @@ -194,6 +194,29 @@ public void FindBuildEnvironmentFromAppContextDirectory() } } + [Fact] + public void FindBuildEnvironmentFromSdkRoot() + { + using (var env = new EmptyStandaloneEnviroment(Constants.MSBuildExecutableName)) + { + // Simulate the .NET SDK host publishing the versioned SDK directory through the + // "Microsoft.DotNet.Sdk.Root" AppContext value (as it does when hosting MSBuild in a + // trimmed / Native AOT process). Nothing else points at MSBuild - the process path, + // executing assembly, and AppContext.BaseDirectory all return null - so resolution must + // come from the published SDK root. + BuildEnvironmentHelper.ResetInstance_ForUnitTestsOnly(ReturnNull, ReturnNull, ReturnNull, env.VsInstanceMock, env.EnvironmentMock, () => false, () => env.BuildDirectory); + + // Make sure we get the right MSBuild entry point. + Path.GetFileName(BuildEnvironmentHelper.Instance.CurrentMSBuildExePath).ShouldBe(Constants.MSBuildExecutableName); + + BuildEnvironmentHelper.Instance.MSBuildToolsDirectory32.ShouldBe(env.BuildDirectory); + BuildEnvironmentHelper.Instance.MSBuildToolsDirectory64.ShouldBe(env.BuildDirectory); + BuildEnvironmentHelper.Instance.RunningInVisualStudio.ShouldBeFalse(); + BuildEnvironmentHelper.Instance.RunningTests.ShouldBeFalse(); + BuildEnvironmentHelper.Instance.Mode.ShouldBe(BuildEnvironmentMode.Standalone); + } + } + [WindowsFullFrameworkOnlyFact(additionalMessage: "No Visual Studio installed for .NET.")] public void FindBuildEnvironmentFromVisualStudioRoot() { diff --git a/src/Framework/BuildEnvironmentHelper.cs b/src/Framework/BuildEnvironmentHelper.cs index 4d756f3eb90..a69733f9567 100644 --- a/src/Framework/BuildEnvironmentHelper.cs +++ b/src/Framework/BuildEnvironmentHelper.cs @@ -77,6 +77,7 @@ private static BuildEnvironment Initialize() var possibleLocations = new Func[] { TryFromEnvironmentVariable, + TryFromSdkRoot, TryFromVisualStudioProcess, TryFromMSBuildProcess, TryFromMSBuildAppHost, @@ -146,6 +147,25 @@ private static BuildEnvironment TryFromEnvironmentVariable() : TryFromMSBuildExeUnderVisualStudio(msBuildExePath, allowLegacyToolsVersion: true) ?? TryFromStandaloneMSBuildExe(msBuildExePath); } + private static BuildEnvironment TryFromSdkRoot() + { + // When the .NET SDK hosts MSBuild in a trimmed or Native AOT process, the process path, + // AppContext.BaseDirectory, and Assembly.Location point at the muxer / install root rather + // than the versioned SDK directory that actually contains MSBuild. The SDK publishes that + // directory through the "Microsoft.DotNet.Sdk.Root" AppContext value (see DotNetSdkPaths); + // prefer it when present. A normal JIT MSBuild that discovers itself by path leaves the value + // unset, so this simply falls through to the process/assembly-based discovery below. + var sdkRoot = s_getSdkRootFromAppContext(); + if (string.IsNullOrEmpty(sdkRoot)) + { + return null; + } + + // Prioritize MSBuild[.exe] over MSBuild.dll, mirroring TryFromAppContextBaseDirectory. + return TryFromStandaloneMSBuildExe(Path.Combine(sdkRoot, Constants.MSBuildExecutableName)) + ?? TryFromStandaloneMSBuildExe(Path.Combine(sdkRoot, Constants.MSBuildAssemblyName)); + } + private static BuildEnvironment TryFromVisualStudioProcess() { if (!NativeMethods.IsWindows) @@ -456,6 +476,11 @@ private static string GetAppContextBaseDirectory() return AppContext.BaseDirectory; } + private static string GetSdkRootFromAppContext() + { + return DotNetSdkPaths.SdkRootFromAppContext; + } + private static string GetEnvironmentVariable(string variable) { return Environment.GetEnvironmentVariable(variable); @@ -468,13 +493,15 @@ internal static void ResetInstance_ForUnitTestsOnly(Func getProcessFromR Func getExecutingAssemblyPath = null, Func getAppContextBaseDirectory = null, Func> getVisualStudioInstances = null, Func getEnvironmentVariable = null, - Func runningTests = null) + Func runningTests = null, + Func getSdkRootFromAppContext = null) { s_getProcessFromRunningProcess = getProcessFromRunningProcess ?? GetProcessFromRunningProcess; s_getExecutingAssemblyPath = getExecutingAssemblyPath ?? GetExecutingAssemblyPath; s_getAppContextBaseDirectory = getAppContextBaseDirectory ?? GetAppContextBaseDirectory; s_getVisualStudioInstances = getVisualStudioInstances ?? VisualStudioLocationHelper.GetInstances; s_getEnvironmentVariable = getEnvironmentVariable ?? GetEnvironmentVariable; + s_getSdkRootFromAppContext = getSdkRootFromAppContext ?? GetSdkRootFromAppContext; // Tests which specifically test the BuildEnvironmentHelper need it to be able to act as if it is not running tests s_runningTests = runningTests ?? CheckIfRunningTests; @@ -495,6 +522,7 @@ internal static void ResetInstance_ForUnitTestsOnly(BuildEnvironment buildEnviro private static Func s_getProcessFromRunningProcess = GetProcessFromRunningProcess; private static Func s_getExecutingAssemblyPath = GetExecutingAssemblyPath; private static Func s_getAppContextBaseDirectory = GetAppContextBaseDirectory; + private static Func s_getSdkRootFromAppContext = GetSdkRootFromAppContext; private static Func> s_getVisualStudioInstances = VisualStudioLocationHelper.GetInstances; private static Func s_getEnvironmentVariable = GetEnvironmentVariable; private static Func s_runningTests = CheckIfRunningTests; diff --git a/src/Framework/Utilities/DotNetSdkPaths.cs b/src/Framework/Utilities/DotNetSdkPaths.cs new file mode 100644 index 00000000000..963cdf62859 --- /dev/null +++ b/src/Framework/Utilities/DotNetSdkPaths.cs @@ -0,0 +1,41 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; + +#nullable enable + +namespace Microsoft.Build.Shared +{ + /// + /// Helpers for locating the versioned .NET SDK directory (the sdk/<version>/ folder that + /// contains MSBuild) when MSBuild is hosted by the .NET SDK CLI. + /// + /// + /// When the SDK hosts MSBuild in a trimmed or Native AOT process the muxer loads the SDK entry point + /// directly, so the BCL "where am I" APIs do not point at the versioned SDK directory: + /// and Environment.ProcessPath resolve to the install + /// root (the muxer's own directory) and Assembly.Location is empty. The SDK therefore publishes + /// the resolved SDK directory as the AppContext value so hosted + /// components can resolve SDK-relative paths from it rather than probing a dll path. This mirrors + /// Microsoft.DotNet.Cli.Utils.SdkPaths on the SDK side; the data name must stay in sync with it. + /// + internal static class DotNetSdkPaths + { + /// + /// The data name the .NET SDK uses to publish the resolved versioned SDK + /// directory for the components it hosts. An AppContext value is process-local (unlike an + /// environment variable it is not inherited by child processes) and can also be supplied through a + /// runtimeconfig.json configProperties entry. Must match + /// Microsoft.DotNet.Cli.Utils.SdkPaths.DataName. + /// + internal const string SdkRootAppContextName = "Microsoft.DotNet.Sdk.Root"; + + /// + /// The versioned SDK directory the .NET SDK host published through the + /// AppContext value, or when no host + /// published one (for example a normal JIT MSBuild that discovers itself from its own path). + /// + internal static string? SdkRootFromAppContext => AppContext.GetData(SdkRootAppContextName) as string; + } +} From 89e023947c5557bf292ec3589f470898ad2a10d4 Mon Sep 17 00:00:00 2001 From: Jeremy Kuhne Date: Mon, 6 Jul 2026 12:17:46 -0700 Subject: [PATCH 4/4] Reconcile typed TaskItem parameters with AOT analyzers after rebase The rebase onto upstream/main integrated the typed TaskItem / ITaskItem task-parameter feature into Microsoft.Build, which this branch compiles with the trim/AOT analyzers enabled. Fix the merge-resolved using block (drop a duplicate System.Diagnostics and an unused System.Globalization) and guard CreateTaskItemOfT's MakeGenericType + expression Compile() behind RuntimeFeature.IsDynamicCodeSupported so it fails observably under trimming / Native AOT (clearing IL3050). Tracked as follow-up item 7. --- documentation/aot/follow-up-work.md | 8 ++++++++ .../TaskExecutionHost/TaskExecutionHost.cs | 16 ++++++++++++++-- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/documentation/aot/follow-up-work.md b/documentation/aot/follow-up-work.md index 66bd7ba4bf9..8945b6f3963 100644 --- a/documentation/aot/follow-up-work.md +++ b/documentation/aot/follow-up-work.md @@ -56,6 +56,14 @@ All work here still follows the strategy guide's rule: **fail observably, never - **Expected shape:** inspect the SDK publish response file or equivalent output and confirm the `Microsoft.Build.*` feature settings are supplied without manual duplication. - **Deeper context:** [managing-trimming-and-aot.md §6.5](managing-trimming-and-aot.md#65-how-a-librarys-switch-reaches-a-consumer-transitivity-defaulting-override) and [sdk-msbuild-object-model-audit.md](sdk-msbuild-object-model-audit.md). +### 7. Design an AOT-safe binding for typed `TaskItem` / `ITaskItem` parameters + +- **Strategy:** S3 feature check today (a `RuntimeFeature.IsDynamicCodeSupported` guard that fails observably); a durable strategy likely needs closed-world registration or a source-generated wrapper factory. +- **Implementation surface:** `CreateTaskItemOfT` in [`TaskExecutionHost`](../../src/Build/BackEnd/TaskExecutionHost/TaskExecutionHost.cs). +- **Why it remains:** the typed task-parameter feature (`TaskItem` / `ITaskItem`) was integrated from upstream after the initial AOT annotation work. Wrapping an `ITaskItem` into a closed-generic `TaskItem` uses `Type.MakeGenericType` plus an expression-tree `Compile()`, both of which require runtime code generation (IL3050). To keep the AOT analyzers clean after the rebase, the wrapper is guarded with `RuntimeFeature.IsDynamicCodeSupported` and throws `NotSupportedException` under trimming / Native AOT — which fails observably but disables the feature in an AOT host. +- **Expected shape:** keep JIT behavior unchanged; provide an AOT-safe path (for example a registered or source-generated `ITaskItem` → `TaskItem` factory for the closed generic arguments a task actually declares) so typed task-item parameters can bind without `MakeGenericType` / expression compilation, replacing the observable-failure stopgap. +- **Deeper context:** [managing-trimming-and-aot.md §5.3](managing-trimming-and-aot.md#53-dynamic-code-runtime-code-generation). + ## Backlog and non-goals - **`Microsoft.Build.Tasks` as a fully trim/AOT-enabled assembly.** The Tasks assembly still has Backlog suppressions. One pending bucket is XML handling (`XmlSerializer`, `XslCompiledTransform`, `SignedXml`); other rows cover attribute reflection and assembly metadata. Some task entry points now fail gracefully under Native AOT with `RuntimeFeature.IsDynamicCodeSupported` guards, but the assembly still needs feature work before it can be treated as trimmable. The durable strategy is in [aot-trimming-strategy.md](aot-trimming-strategy.md), and the guard mechanics are in [managing-trimming-and-aot.md](managing-trimming-and-aot.md#53-dynamic-code-runtime-code-generation). diff --git a/src/Build/BackEnd/TaskExecutionHost/TaskExecutionHost.cs b/src/Build/BackEnd/TaskExecutionHost/TaskExecutionHost.cs index 6945869a895..2a374c74df5 100644 --- a/src/Build/BackEnd/TaskExecutionHost/TaskExecutionHost.cs +++ b/src/Build/BackEnd/TaskExecutionHost/TaskExecutionHost.cs @@ -6,9 +6,7 @@ using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; -using System.Diagnostics; using System.Diagnostics.CodeAnalysis; -using System.Globalization; using System.IO; using System.Linq; using System.Linq.Expressions; @@ -938,6 +936,20 @@ private static ITaskItem CreateTaskItemOfT(Type genericArgument, ITaskItem item) { Func factory = s_taskItemOfTFactories.GetOrAdd(genericArgument, static t => { +#if NET + if (!RuntimeFeature.IsDynamicCodeSupported) + { + // Wrapping an ITaskItem into a closed-generic TaskItem requires Type.MakeGenericType + // plus an expression-tree Compile(), both of which need runtime code generation. Fail + // observably under trimming / Native AOT rather than silently mis-binding the typed task + // parameter. (See documentation/aot/follow-up-work.md - the typed TaskItem parameter + // feature still needs a proper AOT-safe binding strategy.) + throw new NotSupportedException( + "Task parameters typed as TaskItem or ITaskItem require runtime code generation " + + "(Type.MakeGenericType and expression compilation) and are not supported when MSBuild " + + "runs trimmed or with Native AOT."); + } +#endif ConstructorInfo constructor = typeof(TaskItem<>).MakeGenericType(t).GetConstructor([typeof(ITaskItem)]); ParameterExpression itemParameter = Expression.Parameter(typeof(ITaskItem), "item"); return Expression.Lambda>(