-
Notifications
You must be signed in to change notification settings - Fork 1.4k
Description
Description
MSBuild property functions that invoke System.IO.File methods (e.g., ReadAllText, Exists, ReadAllLines) with relative paths produce incorrect results or fail in multi-threaded (-mt) mode. This is because these calls are dispatched via reflection and resolve relative paths using Environment.CurrentDirectory, which is a process-global shared state — unsafe when multiple worker nodes are evaluating projects concurrently.
Repro
Project file (e.g., src/Containers/packaging/package.csproj in dotnet/sdk):
xml <Target Name="PreparePackageReleaseNotesFromFile" BeforeTargets="GenerateNuspec"> <PropertyGroup> <PackageReleaseNotesFile>../docs/ReleaseNotes/v8.0.300.md</PackageReleaseNotesFile> <PackageReleaseNotes>$([System.IO.File]::ReadAllText($(PackageReleaseNotesFile)))</PackageReleaseNotes> </PropertyGroup> </Target>
Without -mt: Works correctly. ../docs/ReleaseNotes/v8.0.300.md resolves relative to the project directory.
With -mt: Fails with:
error MSB4184: The expression "[System.IO.File]::ReadAllText(../docs/ReleaseNotes/v8.0.300.md)" cannot be evaluated. Could not find a part of the path '/home/builder/dotnet/src/docs/ReleaseNotes/v8.0.300.md'.
The relative path resolved from the wrong directory — another project's directory that happened to be the current Environment.CurrentDirectory on a different worker node.
Root Cause
MSBuild's property function evaluation has two paths:
-
Well-known functions (WellKnownFunctions.cs) — intercepted and handled with proper working directory context. For example, Path.GetFullPath correctly uses FileUtilities.CurrentThreadWorkingDirectory (line 108):
csharp returnVal = !string.IsNullOrEmpty(FileUtilities.CurrentThreadWorkingDirectory) ? Path.GetFullPath(Path.Combine(FileUtilities.CurrentThreadWorkingDirectory, arg0)) : Path.GetFullPath(arg0); -
Everything else — falls through to reflection-based InvokeMember (Expander.cs line 4108):
csharp functionResult = _receiverType.InvokeMember(_methodMethodName, _bindingFlags, Type.DefaultBinder, objectInstance, args, CultureInfo.InvariantCulture);
System.IO.File.ReadAllText is not a well-known function, so it takes path (2). When called with a relative path, the CLR resolves it using Environment.CurrentDirectory — a process-global that is concurrently modified by other MSBuild worker nodes.
Impact
This is a compatibility bug — any project file that uses [System.IO.File]::ReadAllText(relative-path) (or similar System.IO.File methods with relative paths) works correctly in single-threaded mode but silently produces wrong results or fails in -mt mode.
Found in VMR source-build testing: dotnet/sdk's src/Containers/packaging/package.csproj fails with this pattern. A grep of the VMR shows 37 usages of [System.IO.File] in project files — any using relative paths are potentially affected.
Expected Behavior
Property functions that accept file paths should resolve relative paths consistently using the project directory (MSBuildProjectDirectory) regardless of -mt mode, the same way Path.GetFullPath does.
Suggested Fix
Either:
- Intercept System.IO.File methods in WellKnownFunctions.cs and resolve relative paths using FileUtilities.CurrentThreadWorkingDirectory before delegating, or
- Set Environment.CurrentDirectory per-thread before invoking any property function via reflection (not feasible — it's process-global), or
- Resolve relative path arguments in the generic InvokeMember fallback path — for any System.IO.File method argument that is a relative path, prepend FileUtilities.CurrentThreadWorkingDirectory before invoking.
Option (1) is the most reliable and consistent with the existing Path.GetFullPath interception pattern.
Environment
- MSBuild 18.5.0 (main branch, preview 2)
- Ubuntu 24.04, -mt mode
- Discovered during VMR 2-stage source-build with MSBuild -mt validation