Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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
51 changes: 45 additions & 6 deletions src/Build.UnitTests/BinaryLogger_Tests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -74,10 +74,12 @@ public class BinaryLoggerTests : IDisposable
</Project>";

private readonly TestEnvironment _env;
private readonly ITestOutputHelper _output;
private string _logFile;

public BinaryLoggerTests(ITestOutputHelper output)
{
_output = output;
_env = TestEnvironment.Create(output);

// this is needed to ensure the binary logger does not pollute the environment
Expand Down Expand Up @@ -437,30 +439,67 @@ private void AssemblyLoadsDuringTaskRun(string additionalEventText)
[Fact]
public void BinaryLoggerShouldEmbedFilesViaTaskOutput()
{
// Pin the process current directory to a stable, isolated transient folder so
// both the WriteLinesToFile task (which resolves the relative file path against
// the process CWD) and the binary logger's file-existence/embed code observe
// the same directory. Historically this test has been flaky (#9412, #13762)
// because relative ItemSpecs on the EmbedInBinlog item were resolved against
// a different CWD on the binary logger's background embedding thread.
TransientTestFolder workingFolder = _env.CreateFolder(createFolder: true);
_env.SetCurrentDirectory(workingFolder.Path);

const string OutputFileName = "testtaskoutputfile.txt";
string expectedOutputFilePath = Path.Combine(workingFolder.Path, OutputFileName);

using var buildManager = new BuildManager();
var binaryLogger = new BinaryLogger()
{
Parameters = $"LogFile={_logFile}",
CollectProjectImports = BinaryLogger.ProjectImportsCollectionMode.ZipFile,
};
var testProject = @"

// Capture every event the binary logger sees so we can include it in the
// failure message if the assertion below fires intermittently.
var mockLogger = new MockLogger(_output);

var testProject = $@"
<Project>
<Target Name=""Build"">
<WriteLinesToFile File=""testtaskoutputfile.txt"" Lines=""abc;def;ghi""/>
<CreateItem Include=""testtaskoutputfile.txt"">
<WriteLinesToFile File=""{OutputFileName}"" Lines=""abc;def;ghi""/>
<CreateItem Include=""{OutputFileName}"">
<Output TaskParameter=""Include"" ItemName=""EmbedInBinlog"" />
</CreateItem>
</Target>
</Project>";
ObjectModelHelpers.BuildProjectExpectSuccess(testProject, binaryLogger);
ObjectModelHelpers.BuildProjectExpectSuccess(testProject, binaryLogger, mockLogger);

// Sanity-check the output file produced by WriteLinesToFile actually exists
// on disk after the build. If it does not, the diagnostic information will
// make the failure mode obvious instead of just reporting an empty zip.
bool outputFileExists = File.Exists(expectedOutputFilePath);

var projectImportsZipPath = Path.ChangeExtension(_logFile, ".ProjectImports.zip");
using var fileStream = new FileStream(projectImportsZipPath, FileMode.Open);
using var zipArchive = new ZipArchive(fileStream, ZipArchiveMode.Read);

// Snapshot the zip entries up-front so the assertion message can describe
// every entry (FullName + Length), not just whichever one tripped Shouldly.
var entries = zipArchive.Entries
.Select(e => $" Name='{e.Name}' FullName='{e.FullName}' Length={e.Length}")
.ToList();

string diagnostics =
$"Working directory: {workingFolder.Path}{Environment.NewLine}" +
$"Process CWD at assert time: {Directory.GetCurrentDirectory()}{Environment.NewLine}" +
$"Expected output file: {expectedOutputFilePath} (Exists={outputFileExists}){Environment.NewLine}" +
$"Binlog path: {_logFile}{Environment.NewLine}" +
$"ProjectImports.zip path: {projectImportsZipPath}{Environment.NewLine}" +
$"Embedded files ({entries.Count}):{Environment.NewLine}{string.Join(Environment.NewLine, entries)}{Environment.NewLine}" +
$"=== Build log ==={Environment.NewLine}{mockLogger.FullLog}";

// Can't just compare `Name` because `ZipArchive` does not handle unix directory separators well
// thus producing garbled fully qualified paths in the actual .ProjectImports.zip entries
zipArchive.Entries.ShouldContain(zE => zE.Name.EndsWith("testtaskoutputfile.txt"),
$"Embedded files: {string.Join(",", zipArchive.Entries)}");
zipArchive.Entries.ShouldContain(zE => zE.Name.EndsWith(OutputFileName), diagnostics);
}

[RequiresSymbolicLinksFact]
Expand Down
14 changes: 14 additions & 0 deletions src/Build/BackEnd/Components/Logging/LoggingContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,20 @@ public LoggingContext(LoggingContext baseContext, BuildEventContext newEventCont
/// </summary>
internal IBuildEngineDataConsumer BuildEngineDataConsumer => this;

/// <summary>
/// The full path of the project this context is associated with, if any. Returns
/// <see langword="null"/> for contexts that are not project-scoped (e.g.
/// <see cref="NodeLoggingContext"/>).
/// </summary>
/// <remarks>
/// Used for resolving relative paths to absolute on the engine side before they
/// flow out to loggers, where neither the process current directory nor the
/// background thread CWD is a reliable base directory (especially in
/// multithreaded MSBuild execution; see
/// <c>documentation/specs/multithreading/multithreaded-msbuild.md</c>).
/// </remarks>
internal virtual string? ProjectFullPath => null;

/// <summary>
/// Retrieves the logging service
/// </summary>
Expand Down
5 changes: 5 additions & 0 deletions src/Build/BackEnd/Components/Logging/ProjectLoggingContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,11 @@ private ProjectLoggingContext(
this.IsValid = true;
}

/// <summary>
/// The full path of the project this context was created for.
/// </summary>
internal override string ProjectFullPath => _projectFullPath;

private static BuildEventContext CreateInitialContext(
NodeLoggingContext nodeLoggingContext,
int submissionId,
Expand Down
3 changes: 3 additions & 0 deletions src/Build/BackEnd/Components/Logging/TargetLoggingContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,9 @@ internal ProjectLoggingContext ProjectLoggingContext
}
}

/// <inheritdoc />
internal override string ProjectFullPath => _projectLoggingContext?.ProjectFullPath;

/// <summary>
/// Retrieves the target.
/// </summary>
Expand Down
3 changes: 3 additions & 0 deletions src/Build/BackEnd/Components/Logging/TaskLoggingContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,9 @@ internal TargetLoggingContext TargetLoggingContext
}
}

/// <inheritdoc />
internal override string ProjectFullPath => _targetLoggingContext?.ProjectFullPath;

/// <summary>
/// Retrieves the task node.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using System.Collections;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
#if FEATURE_APPDOMAIN
using System.Runtime.Remoting;
#endif
Expand Down Expand Up @@ -263,6 +264,20 @@ internal static void LogTaskParameter(
bool logItemMetadata,
IElementLocation location = null)
{
// For items destined for the binary logger via the EmbedInBinlog hint, resolve
// any relative item specs against the project's directory before the event
// leaves the engine. The logger consumes events on background threads where
// the process current directory is not a reliable base directory; this is
// especially true under multithreaded MSBuild execution where multiple
// projects may be running concurrently in the same process and the
// process-level CWD is not a per-project concept at all (see
// documentation/specs/multithreading/multithreaded-msbuild.md).
if (items != null
&& string.Equals(itemType, ItemTypeNames.EmbedInBinlog, StringComparison.OrdinalIgnoreCase))
{
items = MakeEmbedInBinlogItemSpecsAbsolute(items, loggingContext.ProjectFullPath);
}

var args = CreateTaskParameterEventArgs(
loggingContext.BuildEventContext,
messageKind,
Expand All @@ -278,6 +293,81 @@ internal static void LogTaskParameter(
loggingContext.LogBuildEvent(args);
}

/// <summary>
/// Returns an item list equivalent to <paramref name="items"/> in which every relative
/// <see cref="ITaskItem.ItemSpec"/> (or relative bare-string spec) has been resolved to
/// an absolute path against <paramref name="projectFullPath"/>'s directory. The original
/// items are not mutated; a snapshot is allocated only when at least one rewrite is
/// required.
/// </summary>
private static IList MakeEmbedInBinlogItemSpecsAbsolute(IList items, string projectFullPath)
{
if (string.IsNullOrEmpty(projectFullPath))
{
return items;
}

string projectDirectory = Path.GetDirectoryName(projectFullPath);
if (string.IsNullOrEmpty(projectDirectory))
{
return items;
}

int count = items.Count;
object[] cloned = null;

for (int i = 0; i < count; i++)
{
object item = items[i];
string originalSpec = item switch
{
ITaskItem taskItem => taskItem.ItemSpec,
string s => s,
_ => null,
};

if (string.IsNullOrEmpty(originalSpec) || Path.IsPathRooted(originalSpec))
{
if (cloned != null)
{
cloned[i] = item;
}

continue;
}

// Path.GetFullPath of an already-rooted path does not consult the process
// current directory, so this is safe in any execution mode.
string absoluteSpec = Path.GetFullPath(Path.Combine(projectDirectory, originalSpec));

if (cloned == null)
{
cloned = new object[count];
for (int j = 0; j < i; j++)
{
cloned[j] = items[j];
}
}

cloned[i] = item is ITaskItem origTaskItem
? new TaskItemData(absoluteSpec, CopyMetadata(origTaskItem))
: (object)absoluteSpec;
}

return cloned ?? items;
}

private static IDictionary<string, string> CopyMetadata(ITaskItem item)
{
var dictionary = new Dictionary<string, string>();
foreach (KeyValuePair<string, string> kvp in item.EnumerateMetadata())
{
dictionary[kvp.Key] = kvp.Value;
}

return dictionary;
}

internal static TaskParameterEventArgs CreateTaskParameterEventArgs(
BuildEventContext buildEventContext,
TaskParameterMessageKind messageKind,
Expand Down
Loading