Skip to content
Open
Show file tree
Hide file tree
Changes from 10 commits
Commits
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
272 changes: 272 additions & 0 deletions src/Build/Logging/BinaryLogger/BinaryLogger.cs
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 System;
using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
using Microsoft.Build.Experimental.BuildCheck.Infrastructure.EditorConfig;
Expand Down Expand Up @@ -296,6 +297,17 @@ private static bool TryParsePathParameter(string parameter, out string filePath)

internal string FilePath { get; private set; }

/// <summary>
/// Gets or sets additional output file paths. When set, the binlog will be copied to all these paths
/// after the build completes. The primary FilePath will be used as the temporary write location.
/// </summary>
/// <remarks>
/// This property is intended for internal use by MSBuild command-line processing.
/// It should not be set by external code or logger implementations.
/// Use multiple logger instances with different Parameters instead.
/// </remarks>
public IReadOnlyList<string> AdditionalFilePaths { get; set; }

/// <summary> Gets or sets the verbosity level.</summary>
/// <remarks>
/// The binary logger Verbosity is always maximum (Diagnostic). It tries to capture as much
Expand Down Expand Up @@ -505,6 +517,15 @@ public void Shutdown()
}


// Log additional file paths before closing stream (so they're recorded in the binlog)
if (AdditionalFilePaths != null && AdditionalFilePaths.Count > 0 && stream != null)
{
foreach (var additionalPath in AdditionalFilePaths)
{
LogMessage("BinLogCopyDestination=" + additionalPath);
}
}

if (stream != null)
{
// It's hard to determine whether we're at the end of decoding GZipStream
Expand All @@ -514,6 +535,37 @@ public void Shutdown()
stream.Dispose();
stream = null;
}

// Copy the binlog file to additional destinations if specified
if (AdditionalFilePaths != null && AdditionalFilePaths.Count > 0)
{
foreach (var additionalPath in AdditionalFilePaths)
{
try
{
string directory = Path.GetDirectoryName(additionalPath);
if (!string.IsNullOrEmpty(directory))
{
Directory.CreateDirectory(directory);
}
File.Copy(FilePath, additionalPath, overwrite: true);
}
catch (Exception ex)
{
// Log the error but don't fail the build
// Note: We can't use LogMessage here since the stream is already closed
string message = ResourceUtilities.FormatResourceStringStripCodeAndKeyword(
out string errorCode,
out string helpKeyword,
"ErrorCopyingBinaryLog",
FilePath,
additionalPath,
ex.Message);

throw new LoggerException(message, ex, errorCode, helpKeyword);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@rainersigwald I am not sure how to handle it better - I some places I see Console.WriteLine usage, but it's not clear when we can use it?

Copy link

Copilot AI Dec 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment states "Log the error but don't fail the build" but the code immediately throws a LoggerException. This contradicts the comment and will actually fail the build.

If the intention is to fail the build on copy errors (which seems appropriate), update the comment to reflect this. If copy failures should not fail the build, remove the throw statement and consider logging the error in a different way (e.g., writing to stderr or a separate error collection).

Suggested change
throw new LoggerException(message, ex, errorCode, helpKeyword);
Console.Error.WriteLine(message);

Copilot uses AI. Check for mistakes.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does a single logger failing break the build? How does the loggingservice handle this?

}
}
}
}

private void RawEvents_LogDataSliceReceived(BinaryLogRecordKind recordKind, Stream stream)
Expand Down Expand Up @@ -668,5 +720,225 @@ private string GetUniqueStamp()

private static string ExpandPathParameter(string parameters)
=> $"{DateTime.UtcNow.ToString("yyyyMMdd-HHmmss")}--{EnvironmentUtilities.CurrentProcessId}--{StringUtils.GenerateRandomString(6)}";

/// <summary>
/// Extracts the file path from binary logger parameters string.
/// This is a helper method for processing multiple binlog parameters.
/// </summary>
/// <param name="parameters">The parameters string (e.g., "output.binlog" or "output.binlog;ProjectImports=None")</param>
/// <returns>The resolved file path, or "msbuild.binlog" if no path is specified</returns>
public static string ExtractFilePathFromParameters(string parameters)
{
const string DefaultBinlogFileName = "msbuild" + BinlogFileExtension;

if (string.IsNullOrEmpty(parameters))
{
return Path.GetFullPath(DefaultBinlogFileName);
}

var paramParts = parameters.Split(MSBuildConstants.SemicolonChar, StringSplitOptions.RemoveEmptyEntries);
string filePath = null;

foreach (var parameter in paramParts)
{
if (TryInterpretPathParameterStatic(parameter, out string extractedPath))
{
filePath = extractedPath;
break;
}
}
Comment on lines +718 to +725
Copy link

Copilot AI Dec 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This foreach loop implicitly filters its target sequence - consider filtering the sequence explicitly using '.Where(...)'.

Copilot uses AI. Check for mistakes.

if (filePath == null)
{
filePath = DefaultBinlogFileName;
}

try
{
return Path.GetFullPath(filePath);
}
catch
{
// If path resolution fails, return the original path
return filePath;
}
}

/// <summary>
/// Attempts to interpret a parameter string as a file path.
/// </summary>
/// <param name="parameter">The parameter to interpret (e.g., "LogFile=output.binlog" or "output.binlog")</param>
/// <param name="filePath">The extracted file path if the parameter is a path, otherwise the original parameter</param>
/// <returns>True if the parameter is a valid file path (ends with .binlog or contains wildcards), false otherwise</returns>
private static bool TryInterpretPathParameterStatic(string parameter, out string filePath)
{
bool hasPathPrefix = parameter.StartsWith(LogFileParameterPrefix, StringComparison.OrdinalIgnoreCase);

if (hasPathPrefix)
{
parameter = parameter.Substring(LogFileParameterPrefix.Length);
}

parameter = parameter.Trim('"');

bool isWildcard = ChangeWaves.AreFeaturesEnabled(ChangeWaves.Wave17_12) && parameter.Contains("{}");
bool hasProperExtension = parameter.EndsWith(BinlogFileExtension, StringComparison.OrdinalIgnoreCase);
filePath = parameter;

if (!isWildcard)
{
return hasProperExtension;
}

filePath = parameter.Replace("{}", ExpandPathParameter(string.Empty), StringComparison.Ordinal);

if (!hasProperExtension)
{
filePath += BinlogFileExtension;
}
return true;
}

/// <summary>
/// Extracts the non-file-path parameters from binary logger parameters string.
/// This is used to compare configurations between multiple binlog parameters.
/// </summary>
/// <param name="parameters">The parameters string (e.g., "output.binlog;ProjectImports=None")</param>
/// <returns>A normalized string of non-path parameters, or empty string if only path parameters</returns>
public static string ExtractNonPathParameters(string parameters)
{
if (string.IsNullOrEmpty(parameters))
{
return string.Empty;
}

var paramParts = parameters.Split(MSBuildConstants.SemicolonChar, StringSplitOptions.RemoveEmptyEntries);
var nonPathParams = new List<string>();

foreach (var parameter in paramParts)
{
// Skip file path parameters
if (TryInterpretPathParameterStatic(parameter, out _))
{
continue;
}

// This is a configuration parameter (like ProjectImports=None, OmitInitialInfo, etc.)
nonPathParams.Add(parameter);
}

// Sort for consistent comparison
nonPathParams.Sort(StringComparer.OrdinalIgnoreCase);
return string.Join(";", nonPathParams);
}

/// <summary>
/// Result of processing multiple binary logger parameter sets.
/// </summary>
public readonly struct ProcessedBinaryLoggerParameters
{
/// <summary>
/// List of distinct parameter sets that need separate logger instances.
/// </summary>
public IReadOnlyList<string> DistinctParameterSets { get; }

/// <summary>
/// If true, all parameter sets have identical configurations (only file paths differ),
/// so a single logger can be used with file copying for additional paths.
/// </summary>
public bool AllConfigurationsIdentical { get; }

/// <summary>
/// Additional file paths to copy the binlog to (only valid when AllConfigurationsIdentical is true).
/// </summary>
public IReadOnlyList<string> AdditionalFilePaths { get; }

/// <summary>
/// List of duplicate file paths that were filtered out.
/// </summary>
public IReadOnlyList<string> DuplicateFilePaths { get; }

/// <summary>
/// Initializes a new instance of the <see cref="ProcessedBinaryLoggerParameters"/> struct.
/// </summary>
/// <param name="distinctParameterSets">List of distinct parameter sets that need separate logger instances.</param>
/// <param name="allConfigurationsIdentical">Whether all parameter sets have identical configurations.</param>
/// <param name="additionalFilePaths">Additional file paths to copy the binlog to.</param>
/// <param name="duplicateFilePaths">List of duplicate file paths that were filtered out.</param>
public ProcessedBinaryLoggerParameters(
IReadOnlyList<string> distinctParameterSets,
bool allConfigurationsIdentical,
IReadOnlyList<string> additionalFilePaths,
IReadOnlyList<string> duplicateFilePaths)
{
DistinctParameterSets = distinctParameterSets;
AllConfigurationsIdentical = allConfigurationsIdentical;
AdditionalFilePaths = additionalFilePaths;
DuplicateFilePaths = duplicateFilePaths;
}
}

/// <summary>
/// Processes multiple binary logger parameter sets and returns distinct paths and configuration info.
/// </summary>
/// <param name="binaryLoggerParameters">Array of parameter strings from command line</param>
/// <returns>Processed result with distinct parameter sets and configuration info</returns>
public static ProcessedBinaryLoggerParameters ProcessParameters(string[] binaryLoggerParameters)
{
var distinctParameterSets = new List<string>();
var additionalFilePaths = new List<string>();
var duplicateFilePaths = new List<string>();
bool allConfigurationsIdentical = true;

if (binaryLoggerParameters == null || binaryLoggerParameters.Length == 0)
{
return new ProcessedBinaryLoggerParameters(distinctParameterSets, allConfigurationsIdentical, additionalFilePaths, duplicateFilePaths);
}

if (binaryLoggerParameters.Length == 1)
{
distinctParameterSets.Add(binaryLoggerParameters[0]);
return new ProcessedBinaryLoggerParameters(distinctParameterSets, allConfigurationsIdentical, additionalFilePaths, duplicateFilePaths);
}

string primaryArguments = binaryLoggerParameters[0];
string primaryNonPathParams = ExtractNonPathParameters(primaryArguments);

var distinctFilePaths = new HashSet<string>(StringComparer.OrdinalIgnoreCase);

// Check if all parameter sets have the same non-path configuration
for (int i = 0; i < binaryLoggerParameters.Length; i++)
{
string currentParams = binaryLoggerParameters[i];
string currentNonPathParams = ExtractNonPathParameters(currentParams);
string currentFilePath = ExtractFilePathFromParameters(currentParams);

// Check if this is a duplicate file path
if (distinctFilePaths.Add(currentFilePath))
{
if (!string.Equals(primaryNonPathParams, currentNonPathParams, StringComparison.OrdinalIgnoreCase))
{
allConfigurationsIdentical = false;
}
distinctParameterSets.Add(currentParams);
}
else
{
// Track duplicate paths for logging
duplicateFilePaths.Add(currentFilePath);
}
}

// If all configurations are identical, compute additional file paths for copying
if (allConfigurationsIdentical && distinctParameterSets.Count > 1)
{
for (int i = 1; i < distinctParameterSets.Count; i++)
{
additionalFilePaths.Add(ExtractFilePathFromParameters(distinctParameterSets[i]));
}
}

return new ProcessedBinaryLoggerParameters(distinctParameterSets, allConfigurationsIdentical, additionalFilePaths, duplicateFilePaths);
}
Comment on lines 700 to 933
Copy link

Copilot AI Dec 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing test coverage for the new public static methods ProcessParameters, ExtractFilePathFromParameters, and ExtractNonPathParameters in BinaryLogger. These are now part of the public API but have no direct unit tests in the BinaryLogger test suite.

While these methods are tested indirectly through the XMake integration tests, they should have dedicated unit tests to verify edge cases such as:

  • Empty or null parameter arrays
  • Parameters with wildcards
  • Parameters with various configurations (ProjectImports, OmitInitialInfo, etc.)
  • Invalid path formats
  • Mixed valid and invalid parameters

This would provide better isolation and faster feedback when these methods have bugs.

Copilot uses AI. Check for mistakes.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot add the tests suggested by your colleague

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added dedicated unit tests for all three public static methods: ExtractFilePathFromParameters, ExtractNonPathParameters, and ProcessParameters. Tests cover edge cases including null/empty inputs, various parameter formats, duplicate path handling (case-insensitive), and different configuration scenarios. (3f5e369)

}
}
6 changes: 5 additions & 1 deletion src/Build/Resources/Strings.resx
Original file line number Diff line number Diff line change
Expand Up @@ -2449,6 +2449,10 @@ Utilization: {0} Average Utilization: {1:###.0}</value>
<data name="SDKPathCheck_Failed" xml:space="preserve">
<value>The directory does not exist: {0}. .NET Runtime Task Host could not be instantiated. See https://aka.ms/nettaskhost for details on how to resolve this error.</value>
</data>
<data name="ErrorCopyingBinaryLog" xml:space="preserve">
<value>MSB4279: Failed to copy binary log from "{0}" to "{1}". {2}</value>
<comment>{StrBegin="MSB4279: "}UE: This is shown when the Binary Logger fails to copy the log file to one of the specified output locations.</comment>
</data>
<data name="AssemblyLoad_Warning" xml:space="preserve">
<value>The custom task '{0}' required a fallback to out-of-process execution because the UsingTask definition does not specify the correct Runtime and Architecture. This reduces build performance. Update the UsingTask element to explicitly specify Runtime and Architecture attributes (e.g., Runtime="CLR4" Architecture="x64") or use TaskFactory="TaskHostFactory".</value>
</data>
Expand All @@ -2459,7 +2463,7 @@ Utilization: {0} Average Utilization: {1:###.0}</value>
<!--
The Build message bucket is: MSB4000 - MSB4999

Next message code should be MSB4279
Next message code should be MSB4280

Don't forget to update this comment after using a new code.
-->
Expand Down
5 changes: 5 additions & 0 deletions src/Build/Resources/xlf/Strings.cs.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions src/Build/Resources/xlf/Strings.de.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions src/Build/Resources/xlf/Strings.es.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions src/Build/Resources/xlf/Strings.fr.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions src/Build/Resources/xlf/Strings.it.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading