From 2fefc29ea7b133fa1323aa56705c1a5636fb7a7b Mon Sep 17 00:00:00 2001 From: David Federman Date: Fri, 14 Aug 2026 08:40:32 -0700 Subject: [PATCH] Process MSVC logger events on the primary node The distributed forwarding logger is not created for single-node MSBuild 18 builds. Run the same event parser in the central logger so unused library diagnostics still work with /m:1. Correlate parser state by node and project context so concurrent builds of the same project remain isolated even when task IDs differ between events. Copilot-Session: 1a0d74b8-3e5c-4b54-ae88-080aa7d2a62e --- src/Loggers/MSVC/CentralLogger.cs | 31 +++++ src/Loggers/MSVC/ForwardingLogger.cs | 85 ++++++++++-- src/Tests/MsvcLoggerTests.cs | 195 +++++++++++++++++++++++++++ 3 files changed, 300 insertions(+), 11 deletions(-) diff --git a/src/Loggers/MSVC/CentralLogger.cs b/src/Loggers/MSVC/CentralLogger.cs index 0e2d2dc..10bb99d 100644 --- a/src/Loggers/MSVC/CentralLogger.cs +++ b/src/Loggers/MSVC/CentralLogger.cs @@ -8,8 +8,29 @@ namespace ReferenceTrimmer.Loggers.MSVC; /// public sealed class CentralLogger : Logger { + private sealed class LocalEventRedirector : IEventRedirector + { + private readonly Action _forwardEvent; + + public LocalEventRedirector(Action forwardEvent) + { + _forwardEvent = forwardEvent; + } + + public void ForwardEvent(BuildEventArgs buildEvent) + { + if (buildEvent is not CustomBuildEventArgs customBuildEvent) + { + throw new LoggerException($"Unexpected local forwarding event type: {buildEvent.GetType().FullName}"); + } + + _forwardEvent(customBuildEvent); + } + } + private readonly object _jsonLogWriteLock = new(); private Lazy? _lazyJsonLogFileStreamWriter; + private ForwardingLogger? _localForwardingLogger; private bool _firstEvent = true; private string? _jsonLogFilePath; @@ -42,11 +63,21 @@ public override void Initialize(IEventSource eventSource) }); eventSource.CustomEventRaised += CustomEventHandler; + + _localForwardingLogger = new ForwardingLogger + { + BuildEventRedirector = new LocalEventRedirector(e => CustomEventHandler(this, e)), + Parameters = Parameters, + Verbosity = Verbosity, + }; + _localForwardingLogger.Initialize(eventSource); } /// public override void Shutdown() { + _localForwardingLogger?.Shutdown(); + lock (_jsonLogWriteLock) { if (_lazyJsonLogFileStreamWriter is not null && _lazyJsonLogFileStreamWriter.IsValueCreated) diff --git a/src/Loggers/MSVC/ForwardingLogger.cs b/src/Loggers/MSVC/ForwardingLogger.cs index 718edad..f9c91b8 100644 --- a/src/Loggers/MSVC/ForwardingLogger.cs +++ b/src/Loggers/MSVC/ForwardingLogger.cs @@ -46,11 +46,18 @@ private enum LibType private sealed class ProjectStateLibs { + public ProjectStateLibs(string projectFilePath) + { + ProjectFilePath = projectFilePath; + } + + public string ProjectFilePath { get; } public State ProjectState { get; set; } public SortedSet UnusedProjectLibPaths { get; } = new(StringComparer.OrdinalIgnoreCase); } private IEventSource? _eventSource; + private readonly ConcurrentDictionary<(int NodeId, int ProjectContextId), ProjectStateLibs> _buildContexts = new(); private readonly ConcurrentDictionary _projects = new(StringComparer.OrdinalIgnoreCase); public const string HelpKeyword = "ReferenceTrimmerUnusedMSVCLibraries"; @@ -114,27 +121,39 @@ public void Shutdown() private void OnTaskStarted(object sender, TaskStartedEventArgs e) { - if (!string.IsNullOrEmpty(e.ProjectFile) && e.TaskName.Equals(LinkTaskName, StringComparison.OrdinalIgnoreCase)) + string? projectFilePath = e.ProjectFile; + if (!string.IsNullOrEmpty(projectFilePath) && e.TaskName.Equals(LinkTaskName, StringComparison.OrdinalIgnoreCase)) { - _projects[e.ProjectFile] = new ProjectStateLibs { ProjectState = State.LinkStarted }; + var projectState = new ProjectStateLibs(projectFilePath) + { + ProjectState = State.LinkStarted, + }; + + if (TryGetProjectContextKey(e, out (int NodeId, int ProjectContextId) projectContextKey)) + { + _buildContexts[projectContextKey] = projectState; + } + else + { + _projects[projectFilePath] = projectState; + } } } private void OnTaskFinished(object sender, TaskFinishedEventArgs e) { - if (string.IsNullOrEmpty(e.ProjectFile) || e.TaskName != LinkTaskName || !e.Succeeded) + if (!e.TaskName.Equals(LinkTaskName, StringComparison.OrdinalIgnoreCase) || !e.Succeeded) { return; } - string projectFilePath = e.ProjectFile; - - // Project state present in map if the Link task was detected running in OnTaskStarted. - if (!_projects.TryGetValue(projectFilePath, out ProjectStateLibs projState)) + if (!TryGetProjectState(e, e.ProjectFile, out ProjectStateLibs projState)) { return; } + string projectFilePath = projState.ProjectFilePath; + if (projState.ProjectState is State.UnusedLibsStarted or State.UnusedLibsEnded && projState.UnusedProjectLibPaths.Count > 0) { @@ -219,7 +238,7 @@ private void OnTaskFinished(object sender, TaskFinishedEventArgs e) jsonSb.ToString())); } - _projects.TryRemove(projectFilePath, out _); + RemoveProjectState(e, projectFilePath); } private static string EscapeJsonChars(string str) @@ -229,15 +248,14 @@ private static string EscapeJsonChars(string str) private void OnMessageRaised(object sender, BuildMessageEventArgs e) { - string? projectFilePath = e.ProjectFile; string? message = e.Message; - if (string.IsNullOrEmpty(projectFilePath) || message is null) + if (message is null) { return; } - if (!_projects.TryGetValue(projectFilePath, out ProjectStateLibs? projState)) + if (!TryGetProjectState(e, e.ProjectFile, out ProjectStateLibs projState)) { return; } @@ -280,4 +298,49 @@ private void OnMessageRaised(object sender, BuildMessageEventArgs e) break; } } + + private bool TryGetProjectState(BuildEventArgs e, string? projectFilePath, out ProjectStateLibs projectState) + { + if (TryGetProjectContextKey(e, out (int NodeId, int ProjectContextId) projectContextKey)) + { + return _buildContexts.TryGetValue(projectContextKey, out projectState!); + } + + if (projectFilePath is { Length: > 0 }) + { + return _projects.TryGetValue(projectFilePath, out projectState!); + } + + projectState = null!; + return false; + } + + private void RemoveProjectState(BuildEventArgs e, string projectFilePath) + { + if (TryGetProjectContextKey(e, out (int NodeId, int ProjectContextId) projectContextKey)) + { + _buildContexts.TryRemove(projectContextKey, out _); + } + else + { + _projects.TryRemove(projectFilePath, out _); + } + } + + private static bool TryGetProjectContextKey( + BuildEventArgs e, + out (int NodeId, int ProjectContextId) projectContextKey) + { + BuildEventContext? context = e.BuildEventContext; + if (context is not null && + context.NodeId != BuildEventContext.InvalidNodeId && + context.ProjectContextId != BuildEventContext.InvalidProjectContextId) + { + projectContextKey = (context.NodeId, context.ProjectContextId); + return true; + } + + projectContextKey = default; + return false; + } } diff --git a/src/Tests/MsvcLoggerTests.cs b/src/Tests/MsvcLoggerTests.cs index 4577fc1..dd053fe 100644 --- a/src/Tests/MsvcLoggerTests.cs +++ b/src/Tests/MsvcLoggerTests.cs @@ -193,6 +193,84 @@ public void ForwardingLogger_ForwardsUnusedLibs() } } + [TestMethod] + public void ForwardingLogger_IsolatesConcurrentBuildContextsForSameProject() + { + var eventSource = new MockEventSource(); + var eventRedirector = new MockEventRedirector(); + var logger = new ForwardingLogger { BuildEventRedirector = eventRedirector }; + logger.Initialize(eventSource); + var firstTaskContext = new BuildEventContext(nodeId: 1, targetId: 2, projectContextId: 3, taskId: 4); + var firstMessageContext = new BuildEventContext(nodeId: 1, targetId: 2, projectContextId: 3, taskId: 5); + var secondTaskContext = new BuildEventContext(nodeId: 1, targetId: 6, projectContextId: 7, taskId: 8); + var secondMessageContext = new BuildEventContext(nodeId: 1, targetId: 6, projectContextId: 7, taskId: 9); + try + { + SendLinkTaskStarted(eventSource, "same.proj", firstTaskContext); + SendLinkTaskStarted(eventSource, "same.proj", secondTaskContext); + SendLinkMessage(eventSource, "same.proj", "Unused libraries:", firstMessageContext); + SendLinkMessage(eventSource, "same.proj", "Unused libraries:", secondMessageContext); + SendLinkMessage(eventSource, "same.proj", " first.lib", firstMessageContext); + SendLinkMessage(eventSource, "same.proj", " second.lib", secondMessageContext); + SendLinkTaskFinished(eventSource, string.Empty, firstTaskContext); + SendLinkTaskFinished(eventSource, string.Empty, secondTaskContext); + + Assert.HasCount(2, eventRedirector.Events); + UnusedLibsCustomBuildEventArgs firstEvent = GetUnusedLibEvent(eventRedirector.Events[0]); + UnusedLibsCustomBuildEventArgs secondEvent = GetUnusedLibEvent(eventRedirector.Events[1]); + Assert.Contains("first.lib", firstEvent.UnusedLibraryPathsJson); + Assert.IsFalse(firstEvent.UnusedLibraryPathsJson.Contains("second.lib", StringComparison.Ordinal)); + Assert.Contains("second.lib", secondEvent.UnusedLibraryPathsJson); + Assert.IsFalse(secondEvent.UnusedLibraryPathsJson.Contains("first.lib", StringComparison.Ordinal)); + } + finally + { + logger.Shutdown(); + } + } + + [TestMethod] + public void ForwardingLogger_FallsBackToProjectPathForPartialBuildContexts() + { + var eventSource = new MockEventSource(); + var eventRedirector = new MockEventRedirector(); + var logger = new ForwardingLogger { BuildEventRedirector = eventRedirector }; + logger.Initialize(eventSource); + var firstContext = new BuildEventContext( + nodeId: 1, + targetId: 2, + projectContextId: BuildEventContext.InvalidProjectContextId, + taskId: 3); + var secondContext = new BuildEventContext( + nodeId: 1, + targetId: 4, + projectContextId: BuildEventContext.InvalidProjectContextId, + taskId: 5); + try + { + SendLinkTaskStarted(eventSource, "first.proj", firstContext); + SendLinkTaskStarted(eventSource, "second.proj", secondContext); + SendLinkMessage(eventSource, "first.proj", "Unused libraries:", firstContext); + SendLinkMessage(eventSource, "second.proj", "Unused libraries:", secondContext); + SendLinkMessage(eventSource, "first.proj", " first.lib", firstContext); + SendLinkMessage(eventSource, "second.proj", " second.lib", secondContext); + SendLinkTaskFinished(eventSource, "first.proj", firstContext); + SendLinkTaskFinished(eventSource, "second.proj", secondContext); + + Assert.HasCount(2, eventRedirector.Events); + UnusedLibsCustomBuildEventArgs firstEvent = GetUnusedLibEvent(eventRedirector.Events[0]); + UnusedLibsCustomBuildEventArgs secondEvent = GetUnusedLibEvent(eventRedirector.Events[1]); + Assert.Contains("first.lib", firstEvent.UnusedLibraryPathsJson); + Assert.IsFalse(firstEvent.UnusedLibraryPathsJson.Contains("second.lib", StringComparison.Ordinal)); + Assert.Contains("second.lib", secondEvent.UnusedLibraryPathsJson); + Assert.IsFalse(secondEvent.UnusedLibraryPathsJson.Contains("first.lib", StringComparison.Ordinal)); + } + finally + { + logger.Shutdown(); + } + } + [TestMethod] public void ForwardingLogger_ForwardsNothingIfLinkTaskFails() { @@ -239,6 +317,65 @@ public void CentralLogger_WritesNoJsonIfNoUnusedLibEvents() Assert.IsFalse(File.Exists(jsonPath)); } + [TestMethod] + [DoNotParallelize] + public async Task CentralLogger_ProcessesUnusedLibEventsFromPrimaryNode() + { + string jsonPath = Path.Combine(Environment.CurrentDirectory, CentralLogger.JsonLogFileName); + DeleteIfExists(jsonPath); + + var eventSource = new MockEventSource(); + var centralLogger = new CentralLogger(); + centralLogger.Initialize(eventSource); + eventSource.AssertExpectedCentralLoggerEventSubscriptions(); + eventSource.AssertExpectedForwardingEventSubscriptions(); + eventSource.SendTaskStarted(new TaskStartedEventArgs( + message: "Link starting", + helpKeyword: "Link", + projectFile: "a.proj", + taskFile: "a.proj", + taskName: "Link")); + eventSource.SendMessageRaised(new BuildMessageEventArgs( + message: "Unused libraries:", + helpKeyword: "Link", + senderName: "Link", + MessageImportance.High, + DateTime.Now) + { + ProjectFile = "a.proj", + }); + eventSource.SendMessageRaised(new BuildMessageEventArgs( + message: " user32.lib", + helpKeyword: "Link", + senderName: "Link", + MessageImportance.High, + DateTime.Now) + { + ProjectFile = "a.proj", + }); + eventSource.SendMessageRaised(new BuildMessageEventArgs( + message: string.Empty, + helpKeyword: "Link", + senderName: "Link", + MessageImportance.High, + DateTime.Now) + { + ProjectFile = "a.proj", + }); + eventSource.SendTaskFinished(new TaskFinishedEventArgs( + message: "Link finished", + helpKeyword: "Link", + projectFile: "a.proj", + taskFile: "a.proj", + taskName: "Link", + succeeded: true)); + centralLogger.Shutdown(); + + Assert.IsTrue(File.Exists(jsonPath)); + string json = await File.ReadAllTextAsync(jsonPath); + Assert.Contains("user32.lib", json); + } + [TestMethod] [DoNotParallelize] public async Task CentralLogger_JsonOnUnusedLibEvents() @@ -260,6 +397,64 @@ public async Task CentralLogger_JsonOnUnusedLibEvents() await File.ReadAllTextAsync(jsonPath)); } + private static void SendLinkTaskStarted( + MockEventSource eventSource, + string projectFile, + BuildEventContext context) + { + eventSource.SendTaskStarted(new TaskStartedEventArgs( + message: "Link starting", + helpKeyword: "Link", + projectFile: projectFile, + taskFile: projectFile, + taskName: "Link") + { + BuildEventContext = context, + }); + } + + private static void SendLinkMessage( + MockEventSource eventSource, + string projectFile, + string message, + BuildEventContext context) + { + eventSource.SendMessageRaised(new BuildMessageEventArgs( + message: message, + helpKeyword: "Link", + senderName: "Link", + MessageImportance.High, + DateTime.Now) + { + BuildEventContext = context, + ProjectFile = projectFile, + }); + } + + private static void SendLinkTaskFinished( + MockEventSource eventSource, + string projectFile, + BuildEventContext context) + { + eventSource.SendTaskFinished(new TaskFinishedEventArgs( + message: "Link finished", + helpKeyword: "Link", + projectFile: projectFile, + taskFile: projectFile, + taskName: "Link", + succeeded: true) + { + BuildEventContext = context, + }); + } + + private static UnusedLibsCustomBuildEventArgs GetUnusedLibEvent(BuildEventArgs buildEvent) + { + var unusedLibEvent = buildEvent as UnusedLibsCustomBuildEventArgs; + Assert.IsNotNull(unusedLibEvent); + return unusedLibEvent; + } + private static void DeleteIfExists(string path) { if (File.Exists(path))