diff --git a/src/Build.UnitTests/TerminalLogger_Tests.cs b/src/Build.UnitTests/TerminalLogger_Tests.cs index 575b727ad46..9d781c082a9 100644 --- a/src/Build.UnitTests/TerminalLogger_Tests.cs +++ b/src/Build.UnitTests/TerminalLogger_Tests.cs @@ -1018,6 +1018,72 @@ public async Task ProjectFinishedReportsTargetFrameworkAndRuntimeIdentifier() await Verify(_outputWriter.ToString(), _settings).UniqueForOSPlatform(); } + [Fact] + public void ReplayBinaryLogWithFewerNodesThanOriginalBuild() + { + // This test validates that replaying a binary log with terminal logger + // using fewer nodes than the original build does not cause an IndexOutOfRangeException. + // See issue: https://github.com/dotnet/msbuild/issues/10596 + + using (TestEnvironment env = TestEnvironment.Create()) + { + // Create multiple projects that will build in parallel + TransientTestFolder logFolder = env.CreateFolder(createFolder: true); + + // Create three simple projects + TransientTestFile project1 = env.CreateFile(logFolder, "project1.proj", @" + + + + +"); + + TransientTestFile project2 = env.CreateFile(logFolder, "project2.proj", @" + + + + +"); + + TransientTestFile project3 = env.CreateFile(logFolder, "project3.proj", @" + + + + +"); + + // Create a solution file that builds all projects in parallel + string solutionContents = $@" + + + + +"; + TransientTestFile solutionFile = env.CreateFile(logFolder, "solution.proj", solutionContents); + + string binlogPath = env.ExpectFile(".binlog").Path; + + // Build with multiple nodes to create a binlog with higher node IDs + RunnerUtilities.ExecMSBuild($"{solutionFile.Path} /m:4 /bl:{binlogPath}", out bool success, outputHelper: _outputHelper); + success.ShouldBeTrue(); + + // Replay the binlog with TerminalLogger using only 1 node + // This should NOT throw an IndexOutOfRangeException + var replayEventSource = new BinaryLogReplayEventSource(); + using var outputWriter = new StringWriter(); + using var mockTerminal = new Terminal(outputWriter); + var terminalLogger = new TerminalLogger(mockTerminal); + + // Initialize with only 1 node (fewer than the original build) + terminalLogger.Initialize(replayEventSource, nodeCount: 1); + + // This should complete without throwing an exception + Should.NotThrow(() => replayEventSource.Replay(binlogPath)); + + terminalLogger.Shutdown(); + } + } + [Theory] [InlineData(true)] [InlineData(false)] diff --git a/src/Build/Logging/TerminalLogger/TerminalLogger.cs b/src/Build/Logging/TerminalLogger/TerminalLogger.cs index dffb6064a90..e89b370249f 100644 --- a/src/Build/Logging/TerminalLogger/TerminalLogger.cs +++ b/src/Build/Logging/TerminalLogger/TerminalLogger.cs @@ -151,6 +151,11 @@ public EvalContext(BuildEventContext context) /// private ProjectContext? _restoreContext; + /// + /// True if we're replaying a binary log. In this mode, we may encounter NodeIds higher than the initial node count. + /// + private bool _isReplayMode = false; + /// /// The thread that performs periodic refresh of the console output. /// @@ -432,6 +437,9 @@ public void Initialize(IEventSource eventSource) { ParseParameters(); + // Detect if we're in replay mode + _isReplayMode = eventSource is IBinaryLogReplaySource; + eventSource.BuildStarted += BuildStarted; eventSource.BuildFinished += BuildFinished; eventSource.ProjectStarted += ProjectStarted; @@ -732,6 +740,7 @@ private void ProjectStarted(object sender, ProjectStartedEventArgs e) { _restoreContext = c; int nodeIndex = NodeIndexForContext(e.BuildEventContext); + EnsureNodeCapacity(nodeIndex); _nodes[nodeIndex] = new TerminalNodeStatus(e.ProjectFile!, targetFramework, runtimeIdentifier, "Restore", _projects[c].Stopwatch); } } @@ -1047,9 +1056,31 @@ private void TargetStarted(object sender, TargetStartedEventArgs e) private void UpdateNodeStatus(BuildEventContext buildEventContext, TerminalNodeStatus? nodeStatus) { int nodeIndex = NodeIndexForContext(buildEventContext); + EnsureNodeCapacity(nodeIndex); _nodes[nodeIndex] = nodeStatus; } + /// + /// Ensures that the array has enough capacity to accommodate the given index. + /// This is necessary for binary log replay scenarios where the replay may use fewer nodes than the original build. + /// + private void EnsureNodeCapacity(int nodeIndex) + { + // Only resize in replay mode - during normal builds, the node count is fixed + if (_isReplayMode && nodeIndex >= _nodes.Length) + { + // Resize to accommodate the new index plus some extra capacity + lock (_lock) + { + if (nodeIndex >= _nodes.Length) + { + int newSize = Math.Max(nodeIndex + 1, _nodes.Length * 2); + Array.Resize(ref _nodes, newSize); + } + } + } + } + /// /// The callback. Unused. /// @@ -1185,7 +1216,9 @@ private void MessageRaised(object sender, BuildMessageEventArgs e) if (hasProject && project!.IsTestProject) { - TerminalNodeStatus? node = _nodes[NodeIndexForContext(buildEventContext)]; + int nodeIndex = NodeIndexForContext(buildEventContext); + EnsureNodeCapacity(nodeIndex); + TerminalNodeStatus? node = _nodes[nodeIndex]; // Consumes test update messages produced by VSTest and MSTest runner. if (e is IExtendedBuildEventArgs extendedMessage)