From 78d5478227d6a23e7d8d9bb39e347b6e5840daf5 Mon Sep 17 00:00:00 2001 From: Jan Jones Date: Fri, 10 Apr 2026 11:58:55 +0200 Subject: [PATCH 1/2] Keep strong reference to virtual project's ProjectRootElement to prevent GC When dotnet run --file builds a virtual .csproj in memory, the ProjectRootElement is only held alive by MSBuild's ProjectRootElementCache which may demote it to a weak reference when many SDK import files fill the cache (e.g., in large repos with Directory.Packages.props). If GC collects the weakly referenced element, nested tasks during NuGet restore that re-evaluate the project with different properties fail to find it in the cache, try to load from disk, and throw MSB4025 because the virtual file does not exist. Fix: Store the ProjectRootElement in a field on VirtualProjectBuilder so it stays alive for the duration of the build. Fixes #52714 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../VirtualProjectBuilder.cs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/Microsoft.DotNet.ProjectTools/VirtualProjectBuilder.cs b/src/Microsoft.DotNet.ProjectTools/VirtualProjectBuilder.cs index b4adf03ea70c..11a88db5fe9b 100644 --- a/src/Microsoft.DotNet.ProjectTools/VirtualProjectBuilder.cs +++ b/src/Microsoft.DotNet.ProjectTools/VirtualProjectBuilder.cs @@ -22,6 +22,16 @@ public sealed class VirtualProjectBuilder private (ImmutableArray Original, ImmutableArray Evaluated)? _evaluatedDirectives; + /// + /// Prevents the virtual project's from being garbage collected + /// when MSBuild's demotes it to a weak reference + /// (which can happen when many SDK import files fill the cache during NuGet restore). + /// Without this, nested <MSBuild> tasks that re-evaluate the project with different properties + /// would fail to find the in the cache and try to load it from disk, + /// resulting in MSB4025 because the virtual project file does not exist on disk. + /// + private ProjectRootElement? _projectRootElement; + internal string EntryPointFileFullPath { get; } internal SourceFile EntryPointSourceFile @@ -394,6 +404,7 @@ ProjectRootElement CreateProjectRootElement(string projectFileText, ProjectColle using var xmlReader = XmlReader.Create(reader); var projectRoot = ProjectRootElement.Create(xmlReader, projectCollection); projectRoot.FullPath = GetVirtualProjectPath(EntryPointFileFullPath); + _projectRootElement = projectRoot; return projectRoot; } } From 7c4f8f4cad50231e314faa085620c009417e35f0 Mon Sep 17 00:00:00 2001 From: Jan Jones Date: Fri, 10 Apr 2026 15:48:56 +0200 Subject: [PATCH 2/2] Add regression test for virtual project GC eviction (dotnet/sdk#52714) Add VirtualProject_SurvivesGCDuringRestore test that forces GC of the virtual project's ProjectRootElement during NuGet restore by: - Setting MSBUILDPROJECTROOTELEMENTCACHESIZE=1 to ensure immediate eviction - Using an inline task in Directory.Build.targets to call GC.Collect() before the nested task in _FilterRestoreGraphProjectInputItems Without the strong reference fix, this test reliably reproduces MSB4025. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../CommandTests/Run/RunFileTests.cs | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/test/dotnet.Tests/CommandTests/Run/RunFileTests.cs b/test/dotnet.Tests/CommandTests/Run/RunFileTests.cs index cab95ebf77b3..9810feeb8d6e 100644 --- a/test/dotnet.Tests/CommandTests/Run/RunFileTests.cs +++ b/test/dotnet.Tests/CommandTests/Run/RunFileTests.cs @@ -6432,4 +6432,52 @@ Dictionary ReadFiles() return result; } } + + /// + /// Regression test for https://github.com/dotnet/sdk/issues/52714. + /// The virtual project's must survive GC + /// even after being evicted from MSBuild's strong cache (LRU of size N). + /// We force eviction via MSBUILDPROJECTROOTELEMENTCACHESIZE=1 + /// and trigger GC via an inline task during NuGet restore. + /// Without the fix (strong reference in VirtualProjectBuilder._projectRootElement), + /// this fails with MSB4025 "The project file could not be loaded". + /// + [Fact] + public void VirtualProject_SurvivesGCDuringRestore() + { + var testInstance = _testAssetsManager.CreateTestDirectory(); + File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), """ + Console.WriteLine("Hello from virtual project"); + """); + + // Directory.Build.targets that forces GC during restore, + // after SDK imports have already evicted the virtual PRE from the strong cache. + File.WriteAllText(Path.Join(testInstance.Path, "Directory.Build.targets"), """ + + + + + + + + <_ForceGCTask /> + + + """); + + new DotnetCommand(Log, "run", "--no-cache", "Program.cs") + // A cache size of 1 ensures the virtual PRE is evicted from the strong cache + // as soon as any SDK .targets/.props file is loaded during evaluation. + .WithEnvironmentVariable("MSBUILDPROJECTROOTELEMENTCACHESIZE", "1") + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Pass() + .And.HaveStdOut("Hello from virtual project"); + } }