Skip to content

Make GenerateDepsFile and GenerateRuntimeConfigurationFiles tasks internally-incremental #49459

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 18 commits into from
Jun 27, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
776ec14
Implement internally-incremental GenerateDepsFile and GenerateRuntime…
Copilot Jun 17, 2025
8c75475
Optimize hash computation to use streaming approach instead of loadin…
Copilot Jun 18, 2025
a2b5900
Replace hardcoded hash buffer size with XxHash64.HashSizeInBytes prop…
Copilot Jun 18, 2025
ba29265
Use MemoryStream.CopyTo instead of double serialization for increment…
Copilot Jun 18, 2025
40ca057
Fix property name: use HashLengthInBytes instead of HashSizeInBytes
Copilot Jun 18, 2025
b27ba05
Fix HashLengthInBytes to use instance property instead of static access
Copilot Jun 18, 2025
1ebc5a5
Fix build errors: update StreamWriter constructor and fix shouldWrite…
Copilot Jun 18, 2025
4d37482
Improve resource management and optimize hash length access in increm…
Copilot Jun 19, 2025
18f7a20
fix compilation
baronfel Jun 20, 2025
d248e2e
deduplication
baronfel Jun 20, 2025
9e4e826
Refactor tests to reduce duplication by extracting helper methods
Copilot Jun 20, 2025
6e72e0e
Reset MemoryStream position before copying content to destination files
Copilot Jun 24, 2025
fa763b9
ensure that we close the read streams
baronfel Jun 25, 2025
9bad4af
remove code that obscures what's going on
baronfel Jun 25, 2025
88dc75c
runtimeconfig.json encoding needs to be UTF8 without BOM
baronfel Jun 26, 2025
3038ed7
Wrestle with encodings to generate accurate hashes for comparison
baronfel Jun 26, 2025
5b5c971
Safely move the generated file
baronfel Jun 26, 2025
251e953
Skip trying to do in-memory things and just compare temp files
baronfel Jun 26, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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 FluentAssertions;
using Microsoft.NET.TestFramework;
using Xunit;

namespace Microsoft.NET.Build.Tasks.UnitTests
{
public class GivenAGenerateDepsFile
{
private readonly string _depsFilePath;

public GivenAGenerateDepsFile()
{
string testTempDir = Path.Combine(Path.GetTempPath(), "dotnetSdkTests");
Directory.CreateDirectory(testTempDir);
_depsFilePath = Path.Combine(testTempDir, nameof(GivenAGenerateDepsFile) + ".deps.json");
if (File.Exists(_depsFilePath))
{
File.Delete(_depsFilePath);
}
}

[Fact]
public void ItDoesNotOverwriteFileWithSameContent()
{
// Execute task first time
var task = CreateTestTask();
task.PublicExecuteCore();
var firstWriteTime = File.GetLastWriteTimeUtc(_depsFilePath);

// Wait a bit to ensure timestamp would change if file is rewritten
Thread.Sleep(100);

// Execute task again with same configuration
var task2 = CreateTestTask();
task2.PublicExecuteCore();
var secondWriteTime = File.GetLastWriteTimeUtc(_depsFilePath);

// File should not have been rewritten when content is the same
secondWriteTime.Should().Be(firstWriteTime, "file should not be rewritten when content is unchanged");
}

private TestableGenerateDepsFile CreateTestTask()
{
return new TestableGenerateDepsFile
{
BuildEngine = new MockNeverCacheBuildEngine4(),
ProjectPath = "TestProject.csproj",
DepsFilePath = _depsFilePath,
TargetFramework = "net8.0",
AssemblyName = "TestProject",
AssemblyExtension = ".dll",
AssemblyVersion = "1.0.0.0",
IncludeMainProject = true,
CompileReferences = new MockTaskItem[0],
ResolvedNuGetFiles = new MockTaskItem[0],
ResolvedRuntimeTargetsFiles = new MockTaskItem[0],
RuntimeGraphPath = ""
};
}

private class TestableGenerateDepsFile : GenerateDepsFile
{
public void PublicExecuteCore()
{
base.ExecuteCore();
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// The .NET Foundation licenses this file to you under the MIT license.

using FluentAssertions;
using Microsoft.Build.Utilities;
using Microsoft.NET.TestFramework;
using Xunit;

Expand Down Expand Up @@ -218,6 +219,70 @@ public void GivenTargetMonikerItGeneratesShortName()
}}");
}

[Fact]
public void ItDoesNotOverwriteFileWithSameContent()
{
// Execute task first time
var task = CreateBasicTestTask();
task.PublicExecuteCore();
var firstWriteTime = File.GetLastWriteTimeUtc(_runtimeConfigPath);

// Wait a bit to ensure timestamp would change if file is rewritten
Thread.Sleep(100);

// Execute task again with same configuration
var task2 = CreateBasicTestTask();
task2.PublicExecuteCore();
var secondWriteTime = File.GetLastWriteTimeUtc(_runtimeConfigPath);

// File should not have been rewritten when content is the same
secondWriteTime.Should().Be(firstWriteTime, "file should not be rewritten when content is unchanged");
}

[Fact]
public void GivenDifferentRuntimeHostOptionsItWritesNewConfig()
{
// Execute task first time
var task = CreateBasicTestTask();
task.PublicExecuteCore();
var firstWriteTime = File.GetLastWriteTimeUtc(_runtimeConfigPath);

// Wait a bit to ensure timestamp would change if file is rewritten
Thread.Sleep(100);

// Execute task again with different host options
var task2 = CreateBasicTestTask();
task2.HostConfigurationOptions = [
new TaskItem("System.Runtime.TieredCompilation", new Dictionary<string, string>{{"Value", "false"}}),
new TaskItem("System.GC.Concurrent", new Dictionary<string, string>{{"Value", "false"}}),
];
task2.PublicExecuteCore();
var secondWriteTime = File.GetLastWriteTimeUtc(_runtimeConfigPath);
// File should have been rewritten when content is different
secondWriteTime.Should().BeAfter(firstWriteTime, "file should be rewritten when content is different");
}

private TestableGenerateRuntimeConfigurationFiles CreateBasicTestTask()
{
return new TestableGenerateRuntimeConfigurationFiles
{
BuildEngine = new MockNeverCacheBuildEngine4(),
TargetFrameworkMoniker = $".NETCoreApp,Version=v{ToolsetInfo.CurrentTargetFrameworkVersion}",
RuntimeConfigPath = _runtimeConfigPath,
RuntimeFrameworks = new[]
{
new MockTaskItem(
"Microsoft.NETCore.App",
new Dictionary<string, string>
{
{"FrameworkName", "Microsoft.NETCore.App"}, {"Version", $"{ToolsetInfo.CurrentTargetFrameworkVersion}.0"}
}
)
},
RollForward = "LatestMinor"
};
}

private class TestableGenerateRuntimeConfigurationFiles : GenerateRuntimeConfigurationFiles
{
public void PublicExecuteCore()
Expand Down
46 changes: 45 additions & 1 deletion src/Tasks/Microsoft.NET.Build.Tasks/GenerateDepsFile.cs
Original file line number Diff line number Diff line change
Expand Up @@ -254,10 +254,54 @@ bool ShouldIncludeRuntimeAsset(ITaskItem item)
DependencyContext dependencyContext = builder.Build(UserRuntimeAssemblies);

var writer = new DependencyContextWriter();
using (var fileStream = File.Create(depsFilePath))

bool shouldWriteFile = true;
var tempDepsFilePath = depsFilePath + ".tmp";

// Generate new content
using (var fileStream = File.Create(tempDepsFilePath))
{
writer.Write(dependencyContext, fileStream);
}

// If file exists, check if content is different using streaming hash comparison
if (File.Exists(depsFilePath))
{
Log.LogMessage("File {0} already exists, checking hash.", depsFilePath);
using var existingFileContentStream = File.OpenRead(depsFilePath);
var existingContentHash = HashingUtils.ComputeXXHash64(existingFileContentStream);
var existingContentHashRendered = BitConverter.ToString(existingContentHash).Replace("-", "");
Log.LogMessage("Existing file hash: {0}", existingContentHashRendered);
using var newContentStream = File.OpenRead(tempDepsFilePath);
var newContentHash = HashingUtils.ComputeXXHash64(newContentStream);
var newContentHashRendered = BitConverter.ToString(newContentHash).Replace("-", "");
Log.LogMessage("New content hash: {0}", newContentHashRendered);
// If hashes are equal, content is the same - don't write
if (existingContentHash.SequenceEqual(newContentHash))
{
Log.LogMessage("File {0} is unchanged, skipping write.", depsFilePath);
shouldWriteFile = false;
}
}

if (shouldWriteFile)
{
Log.LogMessage("Writing file {0}.", depsFilePath);
#if NET
File.Move(tempDepsFilePath, depsFilePath, overwrite: true);
#else
// For .NET Framework, we can't use File.Move because it doesn't overwrite the existing file
// so we delete the existing file first.
File.Delete(depsFilePath);
File.Move(tempDepsFilePath, depsFilePath);
#endif
}
else
{
// If we didn't write the file, delete the temporary file
Log.LogMessage("Deleting temporary file {0}.", tempDepsFilePath);
File.Delete(tempDepsFilePath);
}
_filesWritten.Add(new TaskItem(depsFilePath));

if (ValidRuntimeIdentifierPlatformsForAssets != null)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ private void WriteRuntimeConfig(
AddAdditionalProbingPaths(config.RuntimeOptions, packageFolders);
}

WriteToJsonFile(RuntimeConfigPath, config);
WriteToJsonFile(Log, RuntimeConfigPath, config);
_filesWritten.Add(new TaskItem(RuntimeConfigPath));
}

Expand Down Expand Up @@ -340,7 +340,7 @@ private void WriteDevRuntimeConfig(IList<LockFileItem> packageFolders)

AddAdditionalProbingPaths(devConfig.RuntimeOptions, packageFolders);

WriteToJsonFile(RuntimeConfigDevPath, devConfig);
WriteToJsonFile(Log, RuntimeConfigDevPath, devConfig);
_filesWritten.Add(new TaskItem(RuntimeConfigDevPath));
}

Expand Down Expand Up @@ -381,7 +381,7 @@ private static string EnsureNoTrailingDirectorySeparator(string path)
return path;
}

private static void WriteToJsonFile(string fileName, object value)
private static void WriteToJsonFile(Logger log, string filePath, object value)
{
JsonSerializer serializer = new()
{
Expand All @@ -390,10 +390,55 @@ private static void WriteToJsonFile(string fileName, object value)
DefaultValueHandling = DefaultValueHandling.Ignore
};

using (JsonTextWriter writer = new(new StreamWriter(File.Create(fileName))))
bool shouldWriteFile = true;
var tempFilePath = filePath + ".tmp";

// Generate new content
using (JsonTextWriter writer = new(new StreamWriter(File.Create(tempFilePath))))
{
serializer.Serialize(writer, value);
}

// If file exists, check if content is different using streaming hash comparison
if (File.Exists(filePath))
{
log.LogMessage("File {0} already exists, checking hash.", filePath);
// stream positions are reset as part of these utility calls
using var existingContentStream = File.OpenRead(filePath);
var existingContentHash = HashingUtils.ComputeXXHash64(existingContentStream);
var existingContentHashRendered = BitConverter.ToString(existingContentHash).Replace("-", "");
log.LogMessage("Existing file hash: {0}", existingContentHashRendered);
using var newContentStream = File.OpenRead(tempFilePath);
var newContentHash = HashingUtils.ComputeXXHash64(newContentStream);
var newContentHashRendered = BitConverter.ToString(newContentHash).Replace("-", "");
log.LogMessage("New content hash: {0}", existingContentHashRendered);
// If hashes are equal, content is the same - don't write
if (existingContentHash.SequenceEqual(newContentHash))
{
log.LogMessage("File {0} is unchanged, skipping write.", filePath);
shouldWriteFile = false;
}

}

if (shouldWriteFile)
{
log.LogMessage("Writing file {0}.", filePath);
#if NET
File.Move(tempFilePath, filePath, overwrite: true);
#else
// For .NET Framework, we can't use File.Move because it doesn't overwrite the existing file
// so we delete the existing file first.
File.Delete(filePath);
File.Move(tempFilePath, filePath);
#endif
}
else
{
// If we didn't write the file, delete the temporary file
log.LogMessage("Deleting temporary file {0}.", tempFilePath);
File.Delete(tempFilePath);
}
}
}
}
25 changes: 25 additions & 0 deletions src/Tasks/Microsoft.NET.Build.Tasks/HashingUtils.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.IO.Hashing;

namespace Microsoft.NET.Build.Tasks;

public static class HashingUtils
{
/// <summary>
/// Computes the XxHash64 hash of a file.
/// </summary>
/// <param name="content">A stream to read for the hash. If the stream is seekable it will be reset to its incoming position.</param>
public static byte[] ComputeXXHash64(Stream content)
{
var initialPosition = content.CanSeek ? content.Position : 0;
var hasher = new XxHash64();
hasher.Append(content);
if (content.CanSeek)
{
content.Position = initialPosition;
}
return hasher.GetCurrentHash();
}
}
Loading