Skip to content

Commit 11c28b2

Browse files
CopilotmarcpopMSFTbaronfel
authored
Make GenerateDepsFile and GenerateRuntimeConfigurationFiles tasks internally-incremental (#49459)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: marcpopMSFT <12663534+marcpopMSFT@users.noreply.github.com> Co-authored-by: baronfel <573979+baronfel@users.noreply.github.com> Co-authored-by: Chet Husk <chusk3@gmail.com>
1 parent a622859 commit 11c28b2

File tree

5 files changed

+256
-5
lines changed

5 files changed

+256
-5
lines changed
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using FluentAssertions;
5+
using Microsoft.NET.TestFramework;
6+
using Xunit;
7+
8+
namespace Microsoft.NET.Build.Tasks.UnitTests
9+
{
10+
public class GivenAGenerateDepsFile
11+
{
12+
private readonly string _depsFilePath;
13+
14+
public GivenAGenerateDepsFile()
15+
{
16+
string testTempDir = Path.Combine(Path.GetTempPath(), "dotnetSdkTests");
17+
Directory.CreateDirectory(testTempDir);
18+
_depsFilePath = Path.Combine(testTempDir, nameof(GivenAGenerateDepsFile) + ".deps.json");
19+
if (File.Exists(_depsFilePath))
20+
{
21+
File.Delete(_depsFilePath);
22+
}
23+
}
24+
25+
[Fact]
26+
public void ItDoesNotOverwriteFileWithSameContent()
27+
{
28+
// Execute task first time
29+
var task = CreateTestTask();
30+
task.PublicExecuteCore();
31+
var firstWriteTime = File.GetLastWriteTimeUtc(_depsFilePath);
32+
33+
// Wait a bit to ensure timestamp would change if file is rewritten
34+
Thread.Sleep(100);
35+
36+
// Execute task again with same configuration
37+
var task2 = CreateTestTask();
38+
task2.PublicExecuteCore();
39+
var secondWriteTime = File.GetLastWriteTimeUtc(_depsFilePath);
40+
41+
// File should not have been rewritten when content is the same
42+
secondWriteTime.Should().Be(firstWriteTime, "file should not be rewritten when content is unchanged");
43+
}
44+
45+
private TestableGenerateDepsFile CreateTestTask()
46+
{
47+
return new TestableGenerateDepsFile
48+
{
49+
BuildEngine = new MockNeverCacheBuildEngine4(),
50+
ProjectPath = "TestProject.csproj",
51+
DepsFilePath = _depsFilePath,
52+
TargetFramework = "net8.0",
53+
AssemblyName = "TestProject",
54+
AssemblyExtension = ".dll",
55+
AssemblyVersion = "1.0.0.0",
56+
IncludeMainProject = true,
57+
CompileReferences = new MockTaskItem[0],
58+
ResolvedNuGetFiles = new MockTaskItem[0],
59+
ResolvedRuntimeTargetsFiles = new MockTaskItem[0],
60+
RuntimeGraphPath = ""
61+
};
62+
}
63+
64+
private class TestableGenerateDepsFile : GenerateDepsFile
65+
{
66+
public void PublicExecuteCore()
67+
{
68+
base.ExecuteCore();
69+
}
70+
}
71+
}
72+
}

src/Tasks/Microsoft.NET.Build.Tasks.UnitTests/GivenAGenerateRuntimeConfigurationFiles.cs

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// The .NET Foundation licenses this file to you under the MIT license.
33

44
using FluentAssertions;
5+
using Microsoft.Build.Utilities;
56
using Microsoft.NET.TestFramework;
67
using Xunit;
78

@@ -218,6 +219,70 @@ public void GivenTargetMonikerItGeneratesShortName()
218219
}}");
219220
}
220221

222+
[Fact]
223+
public void ItDoesNotOverwriteFileWithSameContent()
224+
{
225+
// Execute task first time
226+
var task = CreateBasicTestTask();
227+
task.PublicExecuteCore();
228+
var firstWriteTime = File.GetLastWriteTimeUtc(_runtimeConfigPath);
229+
230+
// Wait a bit to ensure timestamp would change if file is rewritten
231+
Thread.Sleep(100);
232+
233+
// Execute task again with same configuration
234+
var task2 = CreateBasicTestTask();
235+
task2.PublicExecuteCore();
236+
var secondWriteTime = File.GetLastWriteTimeUtc(_runtimeConfigPath);
237+
238+
// File should not have been rewritten when content is the same
239+
secondWriteTime.Should().Be(firstWriteTime, "file should not be rewritten when content is unchanged");
240+
}
241+
242+
[Fact]
243+
public void GivenDifferentRuntimeHostOptionsItWritesNewConfig()
244+
{
245+
// Execute task first time
246+
var task = CreateBasicTestTask();
247+
task.PublicExecuteCore();
248+
var firstWriteTime = File.GetLastWriteTimeUtc(_runtimeConfigPath);
249+
250+
// Wait a bit to ensure timestamp would change if file is rewritten
251+
Thread.Sleep(100);
252+
253+
// Execute task again with different host options
254+
var task2 = CreateBasicTestTask();
255+
task2.HostConfigurationOptions = [
256+
new TaskItem("System.Runtime.TieredCompilation", new Dictionary<string, string>{{"Value", "false"}}),
257+
new TaskItem("System.GC.Concurrent", new Dictionary<string, string>{{"Value", "false"}}),
258+
];
259+
task2.PublicExecuteCore();
260+
var secondWriteTime = File.GetLastWriteTimeUtc(_runtimeConfigPath);
261+
// File should have been rewritten when content is different
262+
secondWriteTime.Should().BeAfter(firstWriteTime, "file should be rewritten when content is different");
263+
}
264+
265+
private TestableGenerateRuntimeConfigurationFiles CreateBasicTestTask()
266+
{
267+
return new TestableGenerateRuntimeConfigurationFiles
268+
{
269+
BuildEngine = new MockNeverCacheBuildEngine4(),
270+
TargetFrameworkMoniker = $".NETCoreApp,Version=v{ToolsetInfo.CurrentTargetFrameworkVersion}",
271+
RuntimeConfigPath = _runtimeConfigPath,
272+
RuntimeFrameworks = new[]
273+
{
274+
new MockTaskItem(
275+
"Microsoft.NETCore.App",
276+
new Dictionary<string, string>
277+
{
278+
{"FrameworkName", "Microsoft.NETCore.App"}, {"Version", $"{ToolsetInfo.CurrentTargetFrameworkVersion}.0"}
279+
}
280+
)
281+
},
282+
RollForward = "LatestMinor"
283+
};
284+
}
285+
221286
private class TestableGenerateRuntimeConfigurationFiles : GenerateRuntimeConfigurationFiles
222287
{
223288
public void PublicExecuteCore()

src/Tasks/Microsoft.NET.Build.Tasks/GenerateDepsFile.cs

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -254,10 +254,54 @@ bool ShouldIncludeRuntimeAsset(ITaskItem item)
254254
DependencyContext dependencyContext = builder.Build(UserRuntimeAssemblies);
255255

256256
var writer = new DependencyContextWriter();
257-
using (var fileStream = File.Create(depsFilePath))
257+
258+
bool shouldWriteFile = true;
259+
var tempDepsFilePath = depsFilePath + ".tmp";
260+
261+
// Generate new content
262+
using (var fileStream = File.Create(tempDepsFilePath))
258263
{
259264
writer.Write(dependencyContext, fileStream);
260265
}
266+
267+
// If file exists, check if content is different using streaming hash comparison
268+
if (File.Exists(depsFilePath))
269+
{
270+
Log.LogMessage("File {0} already exists, checking hash.", depsFilePath);
271+
using var existingFileContentStream = File.OpenRead(depsFilePath);
272+
var existingContentHash = HashingUtils.ComputeXXHash64(existingFileContentStream);
273+
var existingContentHashRendered = BitConverter.ToString(existingContentHash).Replace("-", "");
274+
Log.LogMessage("Existing file hash: {0}", existingContentHashRendered);
275+
using var newContentStream = File.OpenRead(tempDepsFilePath);
276+
var newContentHash = HashingUtils.ComputeXXHash64(newContentStream);
277+
var newContentHashRendered = BitConverter.ToString(newContentHash).Replace("-", "");
278+
Log.LogMessage("New content hash: {0}", newContentHashRendered);
279+
// If hashes are equal, content is the same - don't write
280+
if (existingContentHash.SequenceEqual(newContentHash))
281+
{
282+
Log.LogMessage("File {0} is unchanged, skipping write.", depsFilePath);
283+
shouldWriteFile = false;
284+
}
285+
}
286+
287+
if (shouldWriteFile)
288+
{
289+
Log.LogMessage("Writing file {0}.", depsFilePath);
290+
#if NET
291+
File.Move(tempDepsFilePath, depsFilePath, overwrite: true);
292+
#else
293+
// For .NET Framework, we can't use File.Move because it doesn't overwrite the existing file
294+
// so we delete the existing file first.
295+
File.Delete(depsFilePath);
296+
File.Move(tempDepsFilePath, depsFilePath);
297+
#endif
298+
}
299+
else
300+
{
301+
// If we didn't write the file, delete the temporary file
302+
Log.LogMessage("Deleting temporary file {0}.", tempDepsFilePath);
303+
File.Delete(tempDepsFilePath);
304+
}
261305
_filesWritten.Add(new TaskItem(depsFilePath));
262306

263307
if (ValidRuntimeIdentifierPlatformsForAssets != null)

src/Tasks/Microsoft.NET.Build.Tasks/GenerateRuntimeConfigurationFiles.cs

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,7 @@ private void WriteRuntimeConfig(
176176
AddAdditionalProbingPaths(config.RuntimeOptions, packageFolders);
177177
}
178178

179-
WriteToJsonFile(RuntimeConfigPath, config);
179+
WriteToJsonFile(Log, RuntimeConfigPath, config);
180180
_filesWritten.Add(new TaskItem(RuntimeConfigPath));
181181
}
182182

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

341341
AddAdditionalProbingPaths(devConfig.RuntimeOptions, packageFolders);
342342

343-
WriteToJsonFile(RuntimeConfigDevPath, devConfig);
343+
WriteToJsonFile(Log, RuntimeConfigDevPath, devConfig);
344344
_filesWritten.Add(new TaskItem(RuntimeConfigDevPath));
345345
}
346346

@@ -381,7 +381,7 @@ private static string EnsureNoTrailingDirectorySeparator(string path)
381381
return path;
382382
}
383383

384-
private static void WriteToJsonFile(string fileName, object value)
384+
private static void WriteToJsonFile(Logger log, string filePath, object value)
385385
{
386386
JsonSerializer serializer = new()
387387
{
@@ -390,10 +390,55 @@ private static void WriteToJsonFile(string fileName, object value)
390390
DefaultValueHandling = DefaultValueHandling.Ignore
391391
};
392392

393-
using (JsonTextWriter writer = new(new StreamWriter(File.Create(fileName))))
393+
bool shouldWriteFile = true;
394+
var tempFilePath = filePath + ".tmp";
395+
396+
// Generate new content
397+
using (JsonTextWriter writer = new(new StreamWriter(File.Create(tempFilePath))))
394398
{
395399
serializer.Serialize(writer, value);
396400
}
401+
402+
// If file exists, check if content is different using streaming hash comparison
403+
if (File.Exists(filePath))
404+
{
405+
log.LogMessage("File {0} already exists, checking hash.", filePath);
406+
// stream positions are reset as part of these utility calls
407+
using var existingContentStream = File.OpenRead(filePath);
408+
var existingContentHash = HashingUtils.ComputeXXHash64(existingContentStream);
409+
var existingContentHashRendered = BitConverter.ToString(existingContentHash).Replace("-", "");
410+
log.LogMessage("Existing file hash: {0}", existingContentHashRendered);
411+
using var newContentStream = File.OpenRead(tempFilePath);
412+
var newContentHash = HashingUtils.ComputeXXHash64(newContentStream);
413+
var newContentHashRendered = BitConverter.ToString(newContentHash).Replace("-", "");
414+
log.LogMessage("New content hash: {0}", existingContentHashRendered);
415+
// If hashes are equal, content is the same - don't write
416+
if (existingContentHash.SequenceEqual(newContentHash))
417+
{
418+
log.LogMessage("File {0} is unchanged, skipping write.", filePath);
419+
shouldWriteFile = false;
420+
}
421+
422+
}
423+
424+
if (shouldWriteFile)
425+
{
426+
log.LogMessage("Writing file {0}.", filePath);
427+
#if NET
428+
File.Move(tempFilePath, filePath, overwrite: true);
429+
#else
430+
// For .NET Framework, we can't use File.Move because it doesn't overwrite the existing file
431+
// so we delete the existing file first.
432+
File.Delete(filePath);
433+
File.Move(tempFilePath, filePath);
434+
#endif
435+
}
436+
else
437+
{
438+
// If we didn't write the file, delete the temporary file
439+
log.LogMessage("Deleting temporary file {0}.", tempFilePath);
440+
File.Delete(tempFilePath);
441+
}
397442
}
398443
}
399444
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System.IO.Hashing;
5+
6+
namespace Microsoft.NET.Build.Tasks;
7+
8+
public static class HashingUtils
9+
{
10+
/// <summary>
11+
/// Computes the XxHash64 hash of a file.
12+
/// </summary>
13+
/// <param name="content">A stream to read for the hash. If the stream is seekable it will be reset to its incoming position.</param>
14+
public static byte[] ComputeXXHash64(Stream content)
15+
{
16+
var initialPosition = content.CanSeek ? content.Position : 0;
17+
var hasher = new XxHash64();
18+
hasher.Append(content);
19+
if (content.CanSeek)
20+
{
21+
content.Position = initialPosition;
22+
}
23+
return hasher.GetCurrentHash();
24+
}
25+
}

0 commit comments

Comments
 (0)