Enlighten GetAssemblyIdentity task for multithreaded mode
Parent: #11834
Context
GetAssemblyIdentity is one of the remaining tasks listed in the migration epic (#11834) under "Other (either simple or with unknown issues)". The task is short — one Execute() method that loops over AssemblyFiles and emits one [Output] item per input — but it has exactly one cwd-dependent line:
GetAssemblyIdentity.Execute
→ foreach (ITaskItem item in AssemblyFiles)
→ AssemblyName.GetAssemblyName(item.ItemSpec)
→ File.OpenRead(path) // consumes process cwd when ItemSpec is relative
AssemblyName.GetAssemblyName opens the file by path on both .NET Framework and .NET (Core+) to read the manifest — a relative ItemSpec is resolved against Environment.CurrentDirectory. Under multithreaded execution that's wrong: concurrent GetAssemblyIdentity instances may belong to different projects, and there is only one cwd per process.
AssemblyName.GetAssemblyName is a BCL API with no baseDirectory overload, so the only correct fix is to absolutize item.ItemSpec at the task boundary and pass the resulting absolute string to the BCL.
The [Output] Assemblies items carry no path leak risk: each is constructed with ItemSpec = an.FullName (an assembly identity such as "Foo, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"), and item.CopyMetadataTo(newItem) copies the input item's metadata — including its original (unmodified) ItemSpec-derived metadata such as OriginalItemSpec, FullPath, etc. As long as we never assign an AbsolutePath (or its implicit string conversion) into the new item's metadata, Sin 1 is structurally avoided.
Approach
Apply the PR #13267 (MSBuild/CallTarget) pattern: absolutize at the task boundary, hand a pure string to the helper. Pattern #1 from the SKILL — smallest blast radius.
- Mark
GetAssemblyIdentity with [MSBuildMultiThreadableTask] and implement IMultiThreadableTask with:
public TaskEnvironment TaskEnvironment { get; set; } = TaskEnvironment.Fallback;
- Inside the loop, hoist the absolutization above the
try:
AbsolutePath assemblyPath;
try
{
assemblyPath = TaskEnvironment.GetAbsolutePath(item.ItemSpec);
}
catch (ArgumentException e)
{
Log.LogErrorWithCodeFromResources("GetAssemblyIdentity.CouldNotGetAssemblyName", item.ItemSpec, e.Message);
continue;
}
(See Sin 6 below — GetAbsolutePath throws ArgumentException for null/empty/invalid characters that the old code would have surfaced as an ArgumentException from AssemblyName.GetAssemblyName and caught via ExceptionHandling.IsIoRelatedException. Catching ArgumentException explicitly preserves the same per-item error semantics: log, continue to the next item.)
- Replace
AssemblyName.GetAssemblyName(item.ItemSpec) with AssemblyName.GetAssemblyName(assemblyPath) (implicit AbsolutePath → string conversion).
- Leave the existing
BadImageFormatException and ExceptionHandling.IsIoRelatedException catches unchanged — both still fire from AssemblyName.GetAssemblyName. The error message argument stays item.ItemSpec (Sin 2 prevention — user sees the path they wrote, not the absolutized form).
- Do not touch the output construction or
CopyMetadataTo — an.FullName is an assembly identity, not a path, and the input item's metadata is preserved as-is.
No canonicalization is needed (Sin 5 N/A): the absolutized path is consumed by File.OpenRead deep in the BCL, not used as a dictionary key, displayed to the user, or written to an output property.
Why no ChangeWave
For absolute AssemblyFiles inputs, behavior is byte-identical. For relative inputs the base directory changes from Environment.CurrentDirectory to TaskEnvironment.ProjectDirectory — but in legacy (non-MT) execution the two are equal, so observable behavior is unchanged. The semantic change only manifests under MT, which is the entire point of the migration.
The one edge case is null/empty/invalid-character ItemSpec: old code threw an ArgumentException from AssemblyName.GetAssemblyName, which IsIoRelatedException catches → log error and continue. New code throws ArgumentException from GetAbsolutePath first, which the new explicit catch (ArgumentException) handles identically (log error and continue). The MSBxxxx code, message format, and per-item continuation behavior are identical, so no ChangeWave is required.
If unforeseen behavioral diffs are found during review, gate the diff behind a new ChangeWave following the precedent set in PR #13069.
Sin 6 considerations
AssemblyName.GetAssemblyName already throws ArgumentException for null/empty paths and for paths containing invalid characters; that exception is caught today by ExceptionHandling.IsIoRelatedException (which includes ArgumentException). Adding an explicit catch (ArgumentException) around GetAbsolutePath preserves the exact same per-item outcome (error logged with the same MSBuild code, loop continues). No exception type observed by the user changes; Execute() still returns false if any item failed.
Test coverage assessment
Existing direct unit tests
None. A repo-wide search for GetAssemblyIdentity in src/Tasks.UnitTests/ finds no test file — the task ships with zero direct unit-test coverage today.
Integration tests in this repo
GetAssemblyIdentity is referenced from Microsoft.Common.CurrentVersion.targets (ClickOnce / publish flow, references resolution) and Microsoft.Common.tasks. End-to-end coverage of those flows lives in dotnet/sdk and the VS repo, not here. We will not add E2E publish/reference tests in this repo as part of this issue.
Gaps to fill in this PR
Because there is no existing test file, this PR creates src/Tasks.UnitTests/GetAssemblyIdentity_Tests.cs from scratch. Required tests:
- G0 — Baseline coverage (new file). At minimum:
- Absolute path to an existing assembly →
Assemblies[0].ItemSpec == an.FullName, Name/Version/PublicKeyToken/Culture metadata populated correctly, input metadata copied via CopyMetadataTo.
- Non-existent file →
Execute() returns false, error logged with MSBuild code from GetAssemblyIdentity.CouldNotGetAssemblyName, item is skipped, loop continues to subsequent items.
- Non-assembly file (e.g., a text file) →
BadImageFormatException path, error logged, loop continues.
- Mixed batch (one good, one bad) → good item appears in output, bad item's error is logged,
Execute() returns false.
- Null/empty
ItemSpec → error logged with the MSBuild code, loop continues, Execute() returns false (covers both old IsIoRelatedException path and new ArgumentException-from-GetAbsolutePath path; assertions identical).
- G1 — Concurrency test. Two
GetAssemblyIdentity instances with different ProjectDirectory values, each with the same relative AssemblyFiles ItemSpec pointing at a file present in the respective project directory only, run concurrently. Assert each instance resolves to its own project's file and produces the correct identity. (SKILL red-team Phase 4.)
- G2 — Project-relative test.
ProjectDirectory != Environment.CurrentDirectory, relative ItemSpec resolves against ProjectDirectory. Documents the intentional semantic change. Place a test assembly under ProjectDirectory, set process cwd elsewhere, assert success.
- G3 — Output integrity test (Sin 1 guard). Assert that for an input item with metadata
OriginalItemSpec=foo.dll;Foo=Bar, the output item's ItemSpec is the assembly identity string (not a path), and OriginalItemSpec/Foo metadata are copied verbatim from the input — i.e., no absolutized path leaks into output metadata.
- G4 — Error message integrity test (Sin 2 guard). Pass a relative non-existent
ItemSpec ("does-not-exist.dll") with ProjectDirectory set to a tmp dir. Assert the logged error message contains "does-not-exist.dll" (the original) and does not contain the absolutized ProjectDirectory + "/does-not-exist.dll" form.
Tests must inject t.TaskEnvironment = TaskEnvironmentHelper.CreateForTest() (G0, default) or TaskEnvironment.CreateWithProjectDirectoryAndEnvironment(...) (G1, G2, G4) per the SKILL "Updating Unit Tests" guidance. Run on both net472 and net10.0 — AssemblyName.GetAssemblyName exists on both but the underlying loader differs.
Acceptance criteria
Implementation order
- Create
src/Tasks.UnitTests/GetAssemblyIdentity_Tests.cs with G0 (baseline coverage) against the current unmodified task. Capture exact MSBuild error code, error-message format, and output item shape from a green main-branch run — these are the compatibility baseline.
- Apply
[MSBuildMultiThreadableTask] + IMultiThreadableTask to GetAssemblyIdentity; hoist GetAbsolutePath above the existing try with its own catch (ArgumentException); pass the absolute string to AssemblyName.GetAssemblyName.
- Run G0 — must still pass with no edits (proves no Sin 1/Sin 2 regression).
- Add G1 (concurrency), G2 (project-relative), G3 (output integrity), G4 (error message integrity).
- Run full
Tasks.UnitTests and Build.UnitTests on both net472 and net10.0; verify clean.
- Run repo build with
-v quiet to ensure no new warnings.
Risks / open questions
- Cross-framework loader differences. On .NET Framework,
AssemblyName.GetAssemblyName opens the file via the Fusion loader; on .NET (Core+) it uses the managed MetadataReader. Both consume cwd for relative paths, but error messages and the precise BadImageFormatException/FileLoadException discrimination differ. Existing catch arms already cover both shapes via IsIoRelatedException; no change expected, but G0 must run on both TFMs.
- ClickOnce / reference-resolution flow under MT is not exercised by this repo's tests. Coordination with
dotnet/sdk recommended before MT mode ships generally, but not blocking for this PR — the task itself is correct in isolation once migrated.
- No existing direct unit tests means this PR effectively bootstraps the test surface for
GetAssemblyIdentity. The G0 baseline tests should be reviewed independently of the migration changes — they document long-standing behavior for the first time.
References
Enlighten GetAssemblyIdentity task for multithreaded mode
Parent: #11834
Context
GetAssemblyIdentityis one of the remaining tasks listed in the migration epic (#11834) under "Other (either simple or with unknown issues)". The task is short — oneExecute()method that loops overAssemblyFilesand emits one[Output]item per input — but it has exactly one cwd-dependent line:AssemblyName.GetAssemblyNameopens the file by path on both .NET Framework and .NET (Core+) to read the manifest — a relativeItemSpecis resolved againstEnvironment.CurrentDirectory. Under multithreaded execution that's wrong: concurrentGetAssemblyIdentityinstances may belong to different projects, and there is only one cwd per process.AssemblyName.GetAssemblyNameis a BCL API with nobaseDirectoryoverload, so the only correct fix is to absolutizeitem.ItemSpecat the task boundary and pass the resulting absolute string to the BCL.The
[Output]Assembliesitems carry no path leak risk: each is constructed withItemSpec = an.FullName(an assembly identity such as"Foo, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"), anditem.CopyMetadataTo(newItem)copies the input item's metadata — including its original (unmodified)ItemSpec-derived metadata such asOriginalItemSpec,FullPath, etc. As long as we never assign anAbsolutePath(or its implicit string conversion) into the new item's metadata, Sin 1 is structurally avoided.Approach
Apply the PR #13267 (MSBuild/CallTarget) pattern: absolutize at the task boundary, hand a pure string to the helper. Pattern #1 from the SKILL — smallest blast radius.
GetAssemblyIdentitywith[MSBuildMultiThreadableTask]and implementIMultiThreadableTaskwith:try:GetAbsolutePaththrowsArgumentExceptionfor null/empty/invalid characters that the old code would have surfaced as anArgumentExceptionfromAssemblyName.GetAssemblyNameand caught viaExceptionHandling.IsIoRelatedException. CatchingArgumentExceptionexplicitly preserves the same per-item error semantics: log, continue to the next item.)AssemblyName.GetAssemblyName(item.ItemSpec)withAssemblyName.GetAssemblyName(assemblyPath)(implicitAbsolutePath → stringconversion).BadImageFormatExceptionandExceptionHandling.IsIoRelatedExceptioncatches unchanged — both still fire fromAssemblyName.GetAssemblyName. The error message argument staysitem.ItemSpec(Sin 2 prevention — user sees the path they wrote, not the absolutized form).CopyMetadataTo—an.FullNameis an assembly identity, not a path, and the input item's metadata is preserved as-is.No canonicalization is needed (Sin 5 N/A): the absolutized path is consumed by
File.OpenReaddeep in the BCL, not used as a dictionary key, displayed to the user, or written to an output property.Why no ChangeWave
For absolute
AssemblyFilesinputs, behavior is byte-identical. For relative inputs the base directory changes fromEnvironment.CurrentDirectorytoTaskEnvironment.ProjectDirectory— but in legacy (non-MT) execution the two are equal, so observable behavior is unchanged. The semantic change only manifests under MT, which is the entire point of the migration.The one edge case is null/empty/invalid-character
ItemSpec: old code threw anArgumentExceptionfromAssemblyName.GetAssemblyName, whichIsIoRelatedExceptioncatches → log error and continue. New code throwsArgumentExceptionfromGetAbsolutePathfirst, which the new explicitcatch (ArgumentException)handles identically (log error and continue). The MSBxxxx code, message format, and per-item continuation behavior are identical, so no ChangeWave is required.If unforeseen behavioral diffs are found during review, gate the diff behind a new ChangeWave following the precedent set in PR #13069.
Sin 6 considerations
AssemblyName.GetAssemblyNamealready throwsArgumentExceptionfor null/empty paths and for paths containing invalid characters; that exception is caught today byExceptionHandling.IsIoRelatedException(which includesArgumentException). Adding an explicitcatch (ArgumentException)aroundGetAbsolutePathpreserves the exact same per-item outcome (error logged with the same MSBuild code, loop continues). No exception type observed by the user changes;Execute()still returnsfalseif any item failed.Test coverage assessment
Existing direct unit tests
None. A repo-wide search for
GetAssemblyIdentityinsrc/Tasks.UnitTests/finds no test file — the task ships with zero direct unit-test coverage today.Integration tests in this repo
GetAssemblyIdentityis referenced fromMicrosoft.Common.CurrentVersion.targets(ClickOnce / publish flow, references resolution) andMicrosoft.Common.tasks. End-to-end coverage of those flows lives indotnet/sdkand the VS repo, not here. We will not add E2E publish/reference tests in this repo as part of this issue.Gaps to fill in this PR
Because there is no existing test file, this PR creates
src/Tasks.UnitTests/GetAssemblyIdentity_Tests.csfrom scratch. Required tests:Assemblies[0].ItemSpec == an.FullName,Name/Version/PublicKeyToken/Culturemetadata populated correctly, input metadata copied viaCopyMetadataTo.Execute()returnsfalse, error logged with MSBuild code fromGetAssemblyIdentity.CouldNotGetAssemblyName, item is skipped, loop continues to subsequent items.BadImageFormatExceptionpath, error logged, loop continues.Execute()returnsfalse.ItemSpec→ error logged with the MSBuild code, loop continues,Execute()returnsfalse(covers both oldIsIoRelatedExceptionpath and newArgumentException-from-GetAbsolutePathpath; assertions identical).GetAssemblyIdentityinstances with differentProjectDirectoryvalues, each with the same relativeAssemblyFilesItemSpecpointing at a file present in the respective project directory only, run concurrently. Assert each instance resolves to its own project's file and produces the correct identity. (SKILL red-team Phase 4.)ProjectDirectory != Environment.CurrentDirectory, relativeItemSpecresolves againstProjectDirectory. Documents the intentional semantic change. Place a test assembly underProjectDirectory, set process cwd elsewhere, assert success.OriginalItemSpec=foo.dll;Foo=Bar, the output item'sItemSpecis the assembly identity string (not a path), andOriginalItemSpec/Foometadata are copied verbatim from the input — i.e., no absolutized path leaks into output metadata.ItemSpec("does-not-exist.dll") withProjectDirectoryset to a tmp dir. Assert the logged error message contains"does-not-exist.dll"(the original) and does not contain the absolutizedProjectDirectory + "/does-not-exist.dll"form.Tests must inject
t.TaskEnvironment = TaskEnvironmentHelper.CreateForTest()(G0, default) orTaskEnvironment.CreateWithProjectDirectoryAndEnvironment(...)(G1, G2, G4) per the SKILL "Updating Unit Tests" guidance. Run on bothnet472andnet10.0—AssemblyName.GetAssemblyNameexists on both but the underlying loader differs.Acceptance criteria
GetAssemblyIdentitydecorated[MSBuildMultiThreadableTask]and implementsIMultiThreadableTaskwithpublic TaskEnvironment TaskEnvironment { get; set; } = TaskEnvironment.Fallback;.item.ItemSpecis absolutized viaTaskEnvironment.GetAbsolutePath(item.ItemSpec)before being passed toAssemblyName.GetAssemblyName.ArgumentExceptionand logsGetAssemblyIdentity.CouldNotGetAssemblyNamewith the originalitem.ItemSpec(Sin 2), thencontinues to the next item — preserving the legacy per-item error/continue semantics.BadImageFormatExceptionandExceptionHandling.IsIoRelatedExceptioncatches are preserved unchanged; both still log the originalitem.ItemSpec.Assemblies[i].ItemSpecremainsan.FullName(assembly identity); noAbsolutePathvalue is assigned to any output item'sItemSpecor metadata (Sin 1).item.CopyMetadataTo(newItem)continues to copy the input item's metadata as-is —OriginalItemSpecand other input metadata are not absolutized.src/Tasks.UnitTests/GetAssemblyIdentity_Tests.cs(G0, G1, G2, G3, G4) pass onnet472andnet10.0.AssemblyFilesinputs is byte-identical to today (outputItemSpec, metadata, error messages, exit code).ChangeWaverequired; if any new behavioral diff is discovered, document it in the PR and gate behind the next wave..\build.cmd -v quietsucceeds;Tasks.UnitTestsandBuild.UnitTestspass.multithreaded-task-migrationSKILL sign-off checklist walked and passes.Implementation order
src/Tasks.UnitTests/GetAssemblyIdentity_Tests.cswith G0 (baseline coverage) against the current unmodified task. Capture exact MSBuild error code, error-message format, and output item shape from a green main-branch run — these are the compatibility baseline.[MSBuildMultiThreadableTask]+IMultiThreadableTasktoGetAssemblyIdentity; hoistGetAbsolutePathabove the existingtrywith its owncatch (ArgumentException); pass the absolute string toAssemblyName.GetAssemblyName.Tasks.UnitTestsandBuild.UnitTestson bothnet472andnet10.0; verify clean.-v quietto ensure no new warnings.Risks / open questions
AssemblyName.GetAssemblyNameopens the file via the Fusion loader; on .NET (Core+) it uses the managedMetadataReader. Both consume cwd for relative paths, but error messages and the preciseBadImageFormatException/FileLoadExceptiondiscrimination differ. Existing catch arms already cover both shapes viaIsIoRelatedException; no change expected, but G0 must run on both TFMs.dotnet/sdkrecommended before MT mode ships generally, but not blocking for this PR — the task itself is correct in isolation once migrated.GetAssemblyIdentity. The G0 baseline tests should be reviewed independently of the migration changes — they document long-standing behavior for the first time.References
.github/skills/multithreaded-task-migration/SKILL.md