From fb7d9366aa3b102925e41f1420f990d83035c976 Mon Sep 17 00:00:00 2001 From: YuliiaKovalova Date: Fri, 27 Feb 2026 15:17:04 +0100 Subject: [PATCH 1/3] Enhance crash telemetry with richer diagnostics and EndBuild hang detection - Add StackCaller: skips throw-helper frames to find actual crash site - Add FullStackTrace: multi-frame sanitized trace (4096 char cap) - Add ExceptionMessage: truncated + path-redacted to avoid PII - Add CrashThreadName: captures thread identity at crash time - Add EndBuild hang detection: replace infinite WaitOne() with timed 30s loops that emit periodic diagnostic telemetry - Add CrashExitType.EndBuildHang with 6 diagnostic properties: EndBuildWaitPhase, EndBuildWaitDurationMs, PendingSubmissionCount, SubmissionsWithResultNoLogging, ThreadExceptionRecorded, UnmatchedProjectStartedCount - Add DumpHangDiagnosticsToFile: persists hang state to disk - PII protection: regex path redaction in exception messages, SanitizeFilePathsInText for stack traces, SanitizeStackFrame for individual frames - 61 tests covering all new functionality --- .../BackEnd/BuildManager/BuildManager.cs | 64 ++- .../CrashTelemetry_Tests.cs | 412 ++++++++++++++++++ src/Framework/Telemetry/CrashTelemetry.cs | 259 +++++++++++ .../Telemetry/CrashTelemetryRecorder.cs | 52 +++ src/Shared/ExceptionHandling.cs | 27 ++ 5 files changed, 812 insertions(+), 2 deletions(-) diff --git a/src/Build/BackEnd/BuildManager/BuildManager.cs b/src/Build/BackEnd/BuildManager/BuildManager.cs index 97c7d449b1f..b977410211b 100644 --- a/src/Build/BackEnd/BuildManager/BuildManager.cs +++ b/src/Build/BackEnd/BuildManager/BuildManager.cs @@ -1038,9 +1038,22 @@ public void EndBuild() } } - _noActiveSubmissionsEvent!.WaitOne(); + { + Stopwatch hangWatch = Stopwatch.StartNew(); + while (!_noActiveSubmissionsEvent!.WaitOne(CrashTelemetryRecorder.EndBuildHangDiagnosticsIntervalMs)) + { + EmitEndBuildHangDiagnostics("WaitingForSubmissions", hangWatch); + } + } + ShutdownConnectedNodes(false /* normal termination */); - _noNodesActiveEvent!.WaitOne(); + { + Stopwatch hangWatch = Stopwatch.StartNew(); + while (!_noNodesActiveEvent!.WaitOne(CrashTelemetryRecorder.EndBuildHangDiagnosticsIntervalMs)) + { + EmitEndBuildHangDiagnostics("WaitingForNodes", hangWatch); + } + } // Wait for all of the actions in the work queue to drain. // _workQueue.Completion.Wait() could throw here if there was an unhandled exception in the work queue, @@ -1230,6 +1243,53 @@ private void RecordCrashTelemetry(Exception exception, bool isUnhandled) host); } + /// + /// Extracts build state under lock and delegates to + /// for EndBuild hang diagnostic telemetry emission. Also writes diagnostics to disk + /// via . + /// + private void EmitEndBuildHangDiagnostics(string waitPhase, Stopwatch hangWatch) + { + int pendingSubmissionCount; + int submissionsWithResultNoLogging = 0; + bool threadExceptionRecorded; + int unmatchedProjectStartedCount; + string? host; + + lock (_syncLock) + { + foreach (BuildSubmissionBase submission in _buildSubmissions.Values) + { + if (submission.BuildResultBase is not null && !submission.LoggingCompleted) + { + submissionsWithResultNoLogging++; + } + } + + pendingSubmissionCount = _buildSubmissions.Count; + threadExceptionRecorded = _threadException is not null; + unmatchedProjectStartedCount = _projectStartedEvents.Count; + host = _buildTelemetry?.BuildEngineHost ?? BuildEnvironmentState.GetHostName(); + } + + string diagnostics = $"Phase={waitPhase}, Duration={hangWatch.ElapsedMilliseconds}ms, " + + $"PendingSubmissions={pendingSubmissionCount}, WithResultNoLogging={submissionsWithResultNoLogging}, " + + $"ThreadException={threadExceptionRecorded}, UnmatchedProjectStarted={unmatchedProjectStartedCount}"; + + ExceptionHandling.DumpHangDiagnosticsToFile(diagnostics); + + CrashTelemetryRecorder.CollectAndEmitEndBuildHangDiagnostics( + waitPhase, + hangWatch.ElapsedMilliseconds, + pendingSubmissionCount, + submissionsWithResultNoLogging, + threadExceptionRecorded, + unmatchedProjectStartedCount, + ProjectCollection.Version?.ToString(), + NativeMethodsShared.FrameworkName, + host); + } + /// /// Convenience method. Submits a lone build request and blocks until results are available. diff --git a/src/Framework.UnitTests/CrashTelemetry_Tests.cs b/src/Framework.UnitTests/CrashTelemetry_Tests.cs index 0e4bd1b8c86..311c03c34e6 100644 --- a/src/Framework.UnitTests/CrashTelemetry_Tests.cs +++ b/src/Framework.UnitTests/CrashTelemetry_Tests.cs @@ -341,6 +341,418 @@ public void GetActivityProperties_IncludesNewFields() props[nameof(CrashTelemetry.InnermostExceptionType)].ShouldBe("System.OutOfMemoryException"); } + [Fact] + public void ExtractStackCaller_ReturnsCallerFrame_WhenTopIsThrowHelper() + { + string fakeStack = + " at Microsoft.Build.Shared.ErrorUtilities.ThrowInternalError(String message, Object[] args)\r\n" + + " at Microsoft.Build.BackEnd.RequestBuilder.BuildProject(String projectFile)"; + Exception ex = CreateExceptionWithStack(fakeStack); + + string? caller = CrashTelemetry.ExtractStackCaller(ex); + + caller.ShouldNotBeNull(); + caller.ShouldContain("Microsoft.Build.BackEnd.RequestBuilder.BuildProject"); + } + + [Fact] + public void ExtractStackCaller_ReturnsNull_WhenTopIsNotThrowHelper() + { + string fakeStack = + " at Microsoft.Build.BackEnd.RequestBuilder.BuildProject(String projectFile)\r\n" + + " at Microsoft.Build.BackEnd.BuildManager.Build()"; + Exception ex = CreateExceptionWithStack(fakeStack); + + string? caller = CrashTelemetry.ExtractStackCaller(ex); + + caller.ShouldBeNull(); + } + + [Fact] + public void ExtractStackCaller_ReturnsNull_WhenThrowHelperIsOnlyFrame() + { + string fakeStack = " at Microsoft.Build.Shared.ErrorUtilities.ThrowInternalError(String message, Object[] args)"; + Exception ex = CreateExceptionWithStack(fakeStack); + + string? caller = CrashTelemetry.ExtractStackCaller(ex); + + caller.ShouldBeNull(); + } + + [Fact] + public void ExtractStackCaller_ReturnsNull_WhenNoStackTrace() + { + Exception ex = new Exception("no stack"); + + string? caller = CrashTelemetry.ExtractStackCaller(ex); + + caller.ShouldBeNull(); + } + + [Theory] + [InlineData("ErrorUtilities.VerifyThrowInternalError(")] + [InlineData("ErrorUtilities.ThrowInternalErrorUnreachable(")] + [InlineData("ErrorUtilities.VerifyThrowInternalNull(")] + [InlineData("ErrorUtilities.ThrowInvalidOperation(")] + [InlineData("ErrorUtilities.VerifyThrow(")] + public void ExtractStackCaller_RecognizesAllThrowHelpers(string helperMethod) + { + string fakeStack = + $" at Microsoft.Build.Shared.{helperMethod}String message)\r\n" + + " at Microsoft.Build.Evaluation.Evaluator.Evaluate()"; + Exception ex = CreateExceptionWithStack(fakeStack); + + string? caller = CrashTelemetry.ExtractStackCaller(ex); + + caller.ShouldNotBeNull(); + caller.ShouldContain("Microsoft.Build.Evaluation.Evaluator.Evaluate"); + } + + [Fact] + public void ExtractStackCaller_RedactsFilePaths_InCallerFrame() + { + string fakeStack = + " at Microsoft.Build.Shared.ErrorUtilities.ThrowInternalError(String message, Object[] args)\r\n" + + " at Microsoft.Build.BackEnd.RequestBuilder.BuildProject(String projectFile) in C:\\Users\\username\\src\\file.cs:line 42"; + Exception ex = CreateExceptionWithStack(fakeStack); + + string? caller = CrashTelemetry.ExtractStackCaller(ex); + + caller.ShouldNotBeNull(); + caller.ShouldNotContain("username"); + caller.ShouldContain(""); + caller.ShouldContain(":line 42"); + } + + [Fact] + public void PopulateFromException_SetsStackCaller_WhenThrowHelperIsOnTop() + { + CrashTelemetry telemetry = new(); + + // Simulate a throw-helper scenario using a fake stack trace. + string fakeStack = + " at Microsoft.Build.Shared.ErrorUtilities.ThrowInternalError(String message, Object[] args)\r\n" + + " at Microsoft.Build.Scheduler.ScheduleRequest(BuildRequest request)"; + Exception ex = CreateExceptionWithStack(fakeStack); + + telemetry.PopulateFromException(ex); + + telemetry.StackTop.ShouldContain("ErrorUtilities.ThrowInternalError"); + telemetry.StackCaller.ShouldNotBeNull(); + telemetry.StackCaller!.ShouldContain("Microsoft.Build.Scheduler.ScheduleRequest"); + } + + [Fact] + public void PopulateFromException_SetsExceptionMessage() + { + CrashTelemetry telemetry = new(); + + try + { + throw new InvalidOperationException("something went wrong"); + } + catch (Exception ex) + { + telemetry.PopulateFromException(ex); + } + + telemetry.ExceptionMessage.ShouldBe("something went wrong"); + } + + [Fact] + public void PopulateFromException_StripsInternalErrorPrefix() + { + CrashTelemetry telemetry = new(); + + try + { + throw new Exception("MSB0001: Internal MSBuild Error: All submissions not yet complete."); + } + catch (Exception ex) + { + telemetry.PopulateFromException(ex); + } + + telemetry.ExceptionMessage.ShouldBe("All submissions not yet complete."); + } + + [Fact] + public void TruncateMessage_ReturnsNull_WhenEmpty() + { + CrashTelemetry.TruncateMessage(null).ShouldBeNull(); + CrashTelemetry.TruncateMessage("").ShouldBeNull(); + } + + [Fact] + public void TruncateMessage_TruncatesLongMessages() + { + string longMessage = new string('x', 500); + string? result = CrashTelemetry.TruncateMessage(longMessage); + + result.ShouldNotBeNull(); + result.Length.ShouldBe(256); + } + + [Fact] + public void TruncateMessage_RedactsWindowsPaths() + { + string message = @"C:\Users\johndoe\src\project.csproj unexpectedly not a rooted path"; + string? result = CrashTelemetry.TruncateMessage(message); + + result.ShouldNotBeNull(); + result.ShouldNotContain("johndoe"); + result.ShouldNotContain(@"C:\Users"); + result.ShouldContain(""); + result.ShouldContain("unexpectedly not a rooted path"); + } + + [Fact] + public void TruncateMessage_RedactsUnixPaths() + { + string message = @"/home/johndoe/src/project.csproj unexpectedly not a rooted path"; + string? result = CrashTelemetry.TruncateMessage(message); + + result.ShouldNotBeNull(); + result.ShouldNotContain("johndoe"); + result.ShouldContain(""); + } + + [Fact] + public void TruncateMessage_PreservesNonPathMessages() + { + string message = "All submissions not yet complete."; + string? result = CrashTelemetry.TruncateMessage(message); + result.ShouldBe("All submissions not yet complete."); + } + + [Fact] + public void PopulateFromException_SetsCrashThreadName() + { + CrashTelemetry telemetry = new(); + + try + { + throw new Exception("test"); + } + catch (Exception ex) + { + telemetry.PopulateFromException(ex); + } + + // The thread name may be null in test harness but the property should be set (even if null). + // Just verify no exception was thrown during population. + // In a named-thread scenario, it would capture the name. + } + + [Fact] + public void GetProperties_IncludesStackCaller_WhenSet() + { + CrashTelemetry telemetry = new() + { + ExceptionType = "Microsoft.Build.Framework.InternalErrorException", + IsUnhandled = true, + StackTop = "at Microsoft.Build.Shared.ErrorUtilities.ThrowInternalError(String message, Object[] args)", + StackCaller = "at Microsoft.Build.BackEnd.RequestBuilder.BuildProject(String projectFile)", + ExceptionMessage = "All submissions not yet complete.", + }; + + IDictionary props = telemetry.GetProperties(); + props[nameof(CrashTelemetry.StackCaller)].ShouldBe("at Microsoft.Build.BackEnd.RequestBuilder.BuildProject(String projectFile)"); + props[nameof(CrashTelemetry.ExceptionMessage)].ShouldBe("All submissions not yet complete."); + } + + [Fact] + public void GetProperties_OmitsStackCaller_WhenNull() + { + CrashTelemetry telemetry = new() + { + ExceptionType = "System.NullReferenceException", + IsUnhandled = true, + StackTop = "at Microsoft.Build.BackEnd.RequestBuilder.BuildProject(String projectFile)", + StackCaller = null, + }; + + IDictionary props = telemetry.GetProperties(); + props.ShouldNotContainKey(nameof(CrashTelemetry.StackCaller)); + } + + [Fact] + public void GetActivityProperties_IncludesStackCaller_WhenSet() + { + CrashTelemetry telemetry = new() + { + ExceptionType = "Microsoft.Build.Framework.InternalErrorException", + IsUnhandled = true, + StackTop = "at Microsoft.Build.Shared.ErrorUtilities.ThrowInternalError(String message, Object[] args)", + StackCaller = "at Microsoft.Build.BackEnd.RequestBuilder.BuildProject(String projectFile)", + }; + + Dictionary props = telemetry.GetActivityProperties(); + props[nameof(CrashTelemetry.StackCaller)].ShouldBe("at Microsoft.Build.BackEnd.RequestBuilder.BuildProject(String projectFile)"); + } + + [Fact] + public void PopulateFromException_SetsFullStackTrace() + { + string fakeStack = + " at Microsoft.Build.Shared.ErrorUtilities.ThrowInternalError(String message, Object[] args)\n" + + " at Microsoft.Build.BackEnd.RequestBuilder.BuildProject(String projectFile) in C:\\Users\\user\\src\\file.cs:line 42\n" + + " at Microsoft.Build.BackEnd.BuildManager.Build() in C:\\Users\\user\\src\\mgr.cs:line 100"; + Exception ex = CreateExceptionWithStack(fakeStack); + + CrashTelemetry telemetry = new(); + telemetry.PopulateFromException(ex); + + telemetry.FullStackTrace.ShouldNotBeNull(); + // Should contain all frames + telemetry.FullStackTrace.ShouldContain("ErrorUtilities.ThrowInternalError"); + telemetry.FullStackTrace.ShouldContain("RequestBuilder.BuildProject"); + telemetry.FullStackTrace.ShouldContain("BuildManager.Build"); + // File paths should be redacted + telemetry.FullStackTrace.ShouldNotContain("C:\\Users\\user"); + telemetry.FullStackTrace.ShouldContain("in :line 42"); + } + + [Fact] + public void ExtractFullStackTrace_ReturnsNull_WhenNoStackTrace() + { + Exception ex = CreateExceptionWithStack(null!); + CrashTelemetry.ExtractFullStackTrace(ex).ShouldBeNull(); + } + + [Fact] + public void ExtractFullStackTrace_TruncatesLongStackTraces() + { + // Build a stack trace longer than MaxStackTraceLength + var sb = new System.Text.StringBuilder(); + for (int i = 0; i < 200; i++) + { + sb.AppendLine($" at Namespace.Type.Method{i}()"); + } + Exception ex = CreateExceptionWithStack(sb.ToString()); + + string? result = CrashTelemetry.ExtractFullStackTrace(ex); + result.ShouldNotBeNull(); + result!.Length.ShouldBeLessThanOrEqualTo(CrashTelemetry.MaxStackTraceLength + "... [truncated]".Length); + result.ShouldEndWith("... [truncated]"); + } + + [Fact] + public void GetProperties_IncludesFullStackTrace_WhenSet() + { + CrashTelemetry telemetry = new() + { + ExceptionType = "System.Exception", + FullStackTrace = " at Foo.Bar()\n at Baz.Qux()", + }; + + IDictionary props = telemetry.GetProperties(); + props[nameof(CrashTelemetry.FullStackTrace)].ShouldBe(" at Foo.Bar()\n at Baz.Qux()"); + } + + [Fact] + public void SanitizeFilePathsInText_RedactsPathsInStackFrames() + { + string input = " at Foo.Bar() in C:\\Users\\secret\\src\\file.cs:line 99"; + string result = CrashTelemetry.SanitizeFilePathsInText(input); + result.ShouldNotContain("secret"); + result.ShouldContain("in :line 99"); + } + + [Fact] + public void SanitizeFilePathsInText_LeavesNonPathLinesUnchanged() + { + string input = "System.Exception: something broke\n at Foo.Bar()"; + string result = CrashTelemetry.SanitizeFilePathsInText(input); + result.ShouldBe(input); + } + + [Fact] + public void EndBuildHang_GetProperties_IncludesHangDiagnostics() + { + CrashTelemetry telemetry = new() + { + ExitType = CrashExitType.EndBuildHang, + EndBuildWaitPhase = "WaitingForSubmissions", + EndBuildWaitDurationMs = 60000, + PendingSubmissionCount = 3, + SubmissionsWithResultNoLogging = 1, + ThreadExceptionRecorded = false, + UnmatchedProjectStartedCount = 2, + }; + + IDictionary props = telemetry.GetProperties(); + props[nameof(CrashTelemetry.ExitType)].ShouldBe("EndBuildHang"); + props[nameof(CrashTelemetry.EndBuildWaitPhase)].ShouldBe("WaitingForSubmissions"); + props[nameof(CrashTelemetry.EndBuildWaitDurationMs)].ShouldBe("60000"); + props[nameof(CrashTelemetry.PendingSubmissionCount)].ShouldBe("3"); + props[nameof(CrashTelemetry.SubmissionsWithResultNoLogging)].ShouldBe("1"); + props[nameof(CrashTelemetry.ThreadExceptionRecorded)].ShouldBe("False"); + props[nameof(CrashTelemetry.UnmatchedProjectStartedCount)].ShouldBe("2"); + } + + [Fact] + public void EndBuildHang_GetActivityProperties_IncludesHangDiagnostics() + { + CrashTelemetry telemetry = new() + { + ExitType = CrashExitType.EndBuildHang, + EndBuildWaitPhase = "WaitingForNodes", + EndBuildWaitDurationMs = 30000, + PendingSubmissionCount = 0, + SubmissionsWithResultNoLogging = 0, + ThreadExceptionRecorded = true, + UnmatchedProjectStartedCount = 0, + }; + + Dictionary props = telemetry.GetActivityProperties(); + props[nameof(CrashTelemetry.EndBuildWaitPhase)].ShouldBe("WaitingForNodes"); + props[nameof(CrashTelemetry.EndBuildWaitDurationMs)].ShouldBe(30000L); + props[nameof(CrashTelemetry.PendingSubmissionCount)].ShouldBe(0); + props[nameof(CrashTelemetry.SubmissionsWithResultNoLogging)].ShouldBe(0); + props[nameof(CrashTelemetry.ThreadExceptionRecorded)].ShouldBe(true); + props[nameof(CrashTelemetry.UnmatchedProjectStartedCount)].ShouldBe(0); + } + + [Fact] + public void EndBuildHang_GetProperties_OmitsNullHangProperties() + { + CrashTelemetry telemetry = new() + { + ExitType = CrashExitType.EndBuildHang, + EndBuildWaitPhase = "WaitingForSubmissions", + }; + + IDictionary props = telemetry.GetProperties(); + props.ShouldContainKey(nameof(CrashTelemetry.EndBuildWaitPhase)); + props.ShouldNotContainKey(nameof(CrashTelemetry.PendingSubmissionCount)); + props.ShouldNotContainKey(nameof(CrashTelemetry.ThreadExceptionRecorded)); + } + + [Fact] + public void EndBuildHang_DroppedProperties_NotPresent() + { + // Verify that the dropped properties from the critical evaluation + // (ActiveNodeCount, SubmissionsWithNoResult, CancellationRequested, + // ShuttingDown, SchedulerHitNoLoggingCompleted, SchedulerNoLoggingDetails) + // do not appear in the telemetry output. + CrashTelemetry telemetry = new() + { + ExitType = CrashExitType.EndBuildHang, + EndBuildWaitPhase = "WaitingForSubmissions", + EndBuildWaitDurationMs = 30000, + PendingSubmissionCount = 1, + }; + + IDictionary props = telemetry.GetProperties(); + props.ShouldNotContainKey("ActiveNodeCount"); + props.ShouldNotContainKey("SubmissionsWithNoResult"); + props.ShouldNotContainKey("CancellationRequested"); + props.ShouldNotContainKey("ShuttingDown"); + props.ShouldNotContainKey("SchedulerHitNoLoggingCompleted"); + props.ShouldNotContainKey("SchedulerNoLoggingDetails"); + } + /// /// Creates an exception whose StackTrace property returns the given fake stack string. /// diff --git a/src/Framework/Telemetry/CrashTelemetry.cs b/src/Framework/Telemetry/CrashTelemetry.cs index db241340f00..fe27d8e563e 100644 --- a/src/Framework/Telemetry/CrashTelemetry.cs +++ b/src/Framework/Telemetry/CrashTelemetry.cs @@ -84,6 +84,13 @@ internal enum CrashExitType /// An OutOfMemoryException occurred. /// OutOfMemory, + + /// + /// EndBuild is stuck waiting for submissions or nodes to complete. + /// Emitted periodically during the hang so diagnostics are available + /// even if the hang never resolves. + /// + EndBuildHang, } /// @@ -125,9 +132,40 @@ internal class CrashTelemetry : TelemetryBase, IActivityTelemetryDataHolder /// /// The method at the top of the call stack where the exception originated. + /// When the top frame is a known throw-helper (e.g., ErrorUtilities.ThrowInternalError), + /// this still contains that frame for backward compatibility. /// public string? StackTop { get; set; } + /// + /// The first meaningful caller frame, skipping known throw-helper methods. + /// For example, if the top frame is ErrorUtilities.ThrowInternalError, + /// this will contain the frame that called it — which is what you actually need for triage. + /// Null if the stack trace has no frame beyond the throw-helper, or if the top frame + /// is not a throw-helper (in which case already has the meaningful frame). + /// + public string? StackCaller { get; set; } + + /// + /// The full exception stack trace with file paths sanitized to remove PII. + /// Each frame is preserved so that the complete call chain is visible in telemetry, + /// unlike which only captures one frame. + /// Truncated to characters. + /// + public string? FullStackTrace { get; set; } + + /// + /// Maximum number of characters to include from the sanitized stack trace. + /// + internal const int MaxStackTraceLength = 4096; + + /// + /// A prefix of the exception message, truncated and sanitized to avoid PII. + /// Particularly useful for InternalErrorException where the message text + /// identifies the specific assertion that failed. + /// + public string? ExceptionMessage { get; set; } + /// /// The HResult from the exception, if available. /// @@ -181,6 +219,46 @@ internal class CrashTelemetry : TelemetryBase, IActivityTelemetryDataHolder /// public int? MemoryLoadPercent { get; set; } + /// + /// The name of the thread on which the crash occurred. + /// Helps identify whether the crash was on the main thread, a worker thread, + /// a node communication thread, etc. + /// + public string? CrashThreadName { get; set; } + + // --- EndBuild hang diagnostic properties (populated only for ExitType == EndBuildHang) --- + + /// + /// Which wait point EndBuild is stuck at (e.g. "WaitingForSubmissions", "WaitingForNodes"). + /// + public string? EndBuildWaitPhase { get; set; } + + /// + /// How long EndBuild has been waiting, in milliseconds. + /// + public long? EndBuildWaitDurationMs { get; set; } + + /// + /// Number of submissions still in the pending dictionary. + /// + public int? PendingSubmissionCount { get; set; } + + /// + /// Number of submissions that have a BuildResult but LoggingCompleted is false. + /// These submissions are the ones blocking EndBuild. + /// + public int? SubmissionsWithResultNoLogging { get; set; } + + /// + /// Whether a thread exception has been recorded on the BuildManager. + /// + public bool? ThreadExceptionRecorded { get; set; } + + /// + /// Number of unmatched ProjectStarted events (no corresponding ProjectFinished). + /// + public int? UnmatchedProjectStartedCount { get; set; } + /// /// The original exception, kept for passing to FaultEvent. /// Not serialized to telemetry properties. @@ -197,10 +275,14 @@ public void PopulateFromException(Exception exception) InnerExceptionType = exception.InnerException?.GetType().FullName; InnermostExceptionType = GetInnermostException(exception)?.GetType().FullName; HResult = exception.HResult; + ExceptionMessage = TruncateMessage(exception.Message); StackHash = ComputeStackHash(exception); StackTop = ExtractStackTop(exception); + StackCaller = ExtractStackCaller(exception); + FullStackTrace = ExtractFullStackTrace(exception); CrashOriginNamespace = ExtractOriginNamespace(exception); CrashOrigin = ClassifyOrigin(CrashOriginNamespace); + CrashThreadName = System.Threading.Thread.CurrentThread.Name; PopulateMemoryStats(); } @@ -265,6 +347,9 @@ public Dictionary GetActivityProperties() AddIfNotNull(IsUnhandled); AddIfNotNull(StackHash); AddIfNotNull(StackTop); + AddIfNotNull(StackCaller); + AddIfNotNull(FullStackTrace); + AddIfNotNull(ExceptionMessage); AddIfNotNull(HResult); AddIfNotNull(BuildEngineVersion); AddIfNotNull(BuildEngineFrameworkName); @@ -274,10 +359,19 @@ public Dictionary GetActivityProperties() telemetryItems.Add(nameof(CrashOrigin), CrashOrigin.ToString()); } AddIfNotNull(CrashOriginNamespace); + AddIfNotNull(CrashThreadName); AddIfNotNull(InnermostExceptionType); AddIfNotNull(ProcessWorkingSetMB); AddIfNotNull(MemoryLoadPercent); + // EndBuild hang diagnostic properties + AddIfNotNull(EndBuildWaitPhase); + AddIfNotNull(EndBuildWaitDurationMs); + AddIfNotNull(PendingSubmissionCount); + AddIfNotNull(SubmissionsWithResultNoLogging); + AddIfNotNull(ThreadExceptionRecorded); + AddIfNotNull(UnmatchedProjectStartedCount); + return telemetryItems; void AddIfNotNull(object? value, [CallerArgumentExpression(nameof(value))] string key = "") @@ -303,6 +397,9 @@ public override IDictionary GetProperties() AddIfNotNull(IsUnhandled.ToString(), nameof(IsUnhandled)); AddIfNotNull(StackHash); AddIfNotNull(StackTop); + AddIfNotNull(StackCaller); + AddIfNotNull(FullStackTrace); + AddIfNotNull(ExceptionMessage); AddIfNotNull(HResult?.ToString(), nameof(HResult)); AddIfNotNull(BuildEngineVersion); AddIfNotNull(BuildEngineFrameworkName); @@ -312,10 +409,19 @@ public override IDictionary GetProperties() AddIfNotNull(CrashOrigin.ToString(), nameof(CrashOrigin)); } AddIfNotNull(CrashOriginNamespace); + AddIfNotNull(CrashThreadName); AddIfNotNull(InnermostExceptionType); AddIfNotNull(ProcessWorkingSetMB?.ToString(), nameof(ProcessWorkingSetMB)); AddIfNotNull(MemoryLoadPercent?.ToString(), nameof(MemoryLoadPercent)); + // EndBuild hang diagnostic properties + AddIfNotNull(EndBuildWaitPhase); + AddIfNotNull(PendingSubmissionCount?.ToString(), nameof(PendingSubmissionCount)); + AddIfNotNull(SubmissionsWithResultNoLogging?.ToString(), nameof(SubmissionsWithResultNoLogging)); + AddIfNotNull(EndBuildWaitDurationMs?.ToString(), nameof(EndBuildWaitDurationMs)); + AddIfNotNull(ThreadExceptionRecorded?.ToString(), nameof(ThreadExceptionRecorded)); + AddIfNotNull(UnmatchedProjectStartedCount?.ToString(), nameof(UnmatchedProjectStartedCount)); + return properties; void AddIfNotNull(string? value, [CallerArgumentExpression(nameof(value))] string key = "") @@ -514,6 +620,53 @@ internal static CrashOriginKind ClassifyOrigin(string? originNamespace) #endif } + /// + /// Truncates the exception message and sanitizes file paths to avoid sending PII. + /// Some ThrowInternalError call sites embed file paths (e.g., project paths, SDK paths) + /// in the message, which may contain usernames or other PII. + /// + internal static string? TruncateMessage(string? message) + { + if (string.IsNullOrEmpty(message)) + { + return null; + } + + // Strip the "MSB0001: Internal MSBuild Error: " prefix that InternalErrorException prepends. + const string internalErrorPrefix = "MSB0001: Internal MSBuild Error: "; + if (message!.StartsWith(internalErrorPrefix, StringComparison.Ordinal)) + { + message = message.Substring(internalErrorPrefix.Length); + } + + // Redact file/directory paths that may contain PII (e.g., C:\Users\johndoe\...). + // Matches Windows paths (X:\...) and Unix paths (/home/...). + message = System.Text.RegularExpressions.Regex.Replace( + message, + @"(?:[A-Za-z]:\\|/)(?:[^\s""'<>|*?]+)", + ""); + + const int maxLength = 256; + return message.Length <= maxLength ? message : message.Substring(0, maxLength); + } + + /// + /// Known throw-helper method suffixes. When the top stack frame ends with one of + /// these, will skip it and return the next frame. + /// These are methods that only exist to format and throw an exception — the real + /// bug is always in their caller. + /// + private static readonly string[] s_throwHelperSuffixes = + [ + "ErrorUtilities.ThrowInternalError(", + "ErrorUtilities.VerifyThrowInternalError(", + "ErrorUtilities.ThrowInternalErrorUnreachable(", + "ErrorUtilities.VerifyThrowInternalErrorUnreachable(", + "ErrorUtilities.VerifyThrowInternalNull(", + "ErrorUtilities.ThrowInvalidOperation(", + "ErrorUtilities.VerifyThrow(", + ]; + /// /// Extracts the top frame of the stack trace to identify the crash location. /// @@ -531,6 +684,76 @@ internal static CrashOriginKind ClassifyOrigin(string? originNamespace) return SanitizeStackFrame(topFrame.Trim()); } + /// + /// Extracts and sanitizes the full stack trace from the exception. + /// Each frame has file paths redacted. Truncated to . + /// + internal static string? ExtractFullStackTrace(Exception exception) + { + string? stackTrace = exception.StackTrace; + if (string.IsNullOrEmpty(stackTrace)) + { + return null; + } + + string sanitized = SanitizeFilePathsInText(stackTrace!); + + if (sanitized.Length > MaxStackTraceLength) + { + sanitized = sanitized.Substring(0, MaxStackTraceLength) + "... [truncated]"; + } + + return sanitized; + } + + /// + /// If the top stack frame is a known throw-helper (e.g., ErrorUtilities.ThrowInternalError), + /// extracts the next frame — the actual caller where the bug lives. + /// Returns null if the top frame is not a throw-helper or no further frames exist. + /// + internal static string? ExtractStackCaller(Exception exception) + { + string? stackTrace = exception.StackTrace; + if (stackTrace is null) + { + return null; + } + + // Check if the first frame is a known throw-helper. + int firstNewLine = stackTrace.IndexOf('\n'); + string firstFrame = (firstNewLine >= 0 ? stackTrace.Substring(0, firstNewLine) : stackTrace).Trim(); + + bool isThrowHelper = false; + foreach (string suffix in s_throwHelperSuffixes) + { + if (firstFrame.IndexOf(suffix, StringComparison.Ordinal) >= 0) + { + isThrowHelper = true; + break; + } + } + + if (!isThrowHelper || firstNewLine < 0) + { + return null; + } + + // Extract the second frame (the caller of the throw-helper). + int secondStart = firstNewLine + 1; + if (secondStart >= stackTrace.Length) + { + return null; + } + + int secondNewLine = stackTrace.IndexOf('\n', secondStart); + string secondFrame = secondNewLine >= 0 + ? stackTrace.Substring(secondStart, secondNewLine - secondStart) + : stackTrace.Substring(secondStart); + + string trimmed = secondFrame.Trim(); + return trimmed.Length > 0 ? SanitizeStackFrame(trimmed) : null; + } + /// /// Redacts file paths from a stack frame to avoid leaking PII (e.g. usernames in paths). /// Preserves the method signature and line number. @@ -563,4 +786,40 @@ private static string SanitizeStackFrame(string frame) string lineSuffix = frame.Substring(lineIndex); return prefix + "" + lineSuffix; } + + /// + /// Sanitizes file paths embedded in multi-line text (e.g., exception dumps) to remove PII. + /// Each line that looks like a stack frame gets its file path redacted. + /// + internal static string SanitizeFilePathsInText(string text) + { + string[] lines = text.Split('\n'); + for (int i = 0; i < lines.Length; i++) + { + string line = lines[i]; + + // Sanitize " in :line N" patterns (stack frames) + const string inToken = " in "; + const string lineToken = ":line "; + + int inIndex = line.IndexOf(inToken, StringComparison.Ordinal); + if (inIndex >= 0) + { + int lineIndex = line.IndexOf(lineToken, inIndex, StringComparison.Ordinal); + if (lineIndex >= 0) + { + string prefix = line.Substring(0, inIndex + inToken.Length); + string lineSuffix = line.Substring(lineIndex); + lines[i] = prefix + "" + lineSuffix; + } + else + { + // " in " without ":line N" + lines[i] = line.Substring(0, inIndex + inToken.Length) + ""; + } + } + } + + return string.Join("\n", lines); + } } diff --git a/src/Framework/Telemetry/CrashTelemetryRecorder.cs b/src/Framework/Telemetry/CrashTelemetryRecorder.cs index 16e89033f28..dc1a16ba9f5 100644 --- a/src/Framework/Telemetry/CrashTelemetryRecorder.cs +++ b/src/Framework/Telemetry/CrashTelemetryRecorder.cs @@ -15,6 +15,12 @@ namespace Microsoft.Build.Framework.Telemetry; /// internal static class CrashTelemetryRecorder { + /// + /// Interval in milliseconds between EndBuild hang diagnostic emissions. + /// When EndBuild is stuck waiting for submissions or nodes, diagnostics are emitted at this interval. + /// + public const int EndBuildHangDiagnosticsIntervalMs = 30_000; + /// /// Records crash telemetry data for later emission via . /// @@ -173,4 +179,50 @@ private static CrashTelemetry CreateCrashTelemetry( crashTelemetry.IsUnhandled = isUnhandled; return crashTelemetry; } + + /// + /// Collects and emits diagnostic telemetry when EndBuild is stuck waiting. + /// Called periodically from timed wait loops so that diagnostics are available + /// even if the hang never resolves (crash telemetry in the finally block would be unreachable). + /// + [MethodImpl(MethodImplOptions.NoInlining)] + public static void CollectAndEmitEndBuildHangDiagnostics( + string waitPhase, + long waitDurationMs, + int pendingSubmissionCount, + int submissionsWithResultNoLogging, + bool threadExceptionRecorded, + int unmatchedProjectStartedCount, + string? buildEngineVersion, + string? buildEngineFrameworkName, + string? buildEngineHost) + { + try + { + var crashTelemetry = new CrashTelemetry + { + ExitType = CrashExitType.EndBuildHang, + BuildEngineVersion = buildEngineVersion, + BuildEngineFrameworkName = buildEngineFrameworkName, + BuildEngineHost = buildEngineHost, + EndBuildWaitPhase = waitPhase, + EndBuildWaitDurationMs = waitDurationMs, + PendingSubmissionCount = pendingSubmissionCount, + SubmissionsWithResultNoLogging = submissionsWithResultNoLogging, + ThreadExceptionRecorded = threadExceptionRecorded, + UnmatchedProjectStartedCount = unmatchedProjectStartedCount, + }; + + TelemetryManager.Instance?.Initialize(isStandalone: false); + + using IActivity? activity = TelemetryManager.Instance + ?.DefaultActivitySource + ?.StartActivity(TelemetryConstants.Crash); + activity?.SetTags(crashTelemetry); + } + catch + { + // Best effort: diagnostic telemetry must never cause a secondary failure. + } + } } diff --git a/src/Shared/ExceptionHandling.cs b/src/Shared/ExceptionHandling.cs index 77383b611cd..66bbb1460b8 100644 --- a/src/Shared/ExceptionHandling.cs +++ b/src/Shared/ExceptionHandling.cs @@ -422,6 +422,33 @@ internal static void DumpExceptionToFile(Exception ex) } } + /// + /// Writes hang diagnostic information to a file so it persists on disk + /// for later retrieval from customer machines. + /// File is written to the same directory as crash dump files (). + /// + internal static void DumpHangDiagnosticsToFile(string diagnostics) + { + try + { + Directory.CreateDirectory(DebugDumpPath); + + var pid = EnvironmentUtilities.CurrentProcessId; + string fileName = Path.Combine(DebugDumpPath, $"MSBuild_pid-{pid}.hang.txt"); + + using (StreamWriter writer = FileUtilities.OpenWrite(fileName, append: true)) + { + writer.WriteLine(DateTime.Now.ToString("G", CultureInfo.CurrentCulture)); + writer.WriteLine(diagnostics); + writer.WriteLine("==================="); + } + } + catch + { + // Best-effort: diagnostic file writing must never make things worse. + } + } + /// /// Returns the content of any exception dump files modified /// since the provided time, otherwise returns an empty string. From 8d7cc771646541e74cb8880e8877ce5f9de9e810 Mon Sep 17 00:00:00 2001 From: YuliiaKovalova Date: Fri, 27 Feb 2026 15:31:54 +0100 Subject: [PATCH 2/3] Fix FullStackTrace truncation to stay within MaxStackTraceLength Account for the suffix length when truncating, so total output (content + '... [truncated]') never exceeds MaxStackTraceLength. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Framework.UnitTests/CrashTelemetry_Tests.cs | 2 +- src/Framework/Telemetry/CrashTelemetry.cs | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Framework.UnitTests/CrashTelemetry_Tests.cs b/src/Framework.UnitTests/CrashTelemetry_Tests.cs index 311c03c34e6..d2779042d52 100644 --- a/src/Framework.UnitTests/CrashTelemetry_Tests.cs +++ b/src/Framework.UnitTests/CrashTelemetry_Tests.cs @@ -633,7 +633,7 @@ public void ExtractFullStackTrace_TruncatesLongStackTraces() string? result = CrashTelemetry.ExtractFullStackTrace(ex); result.ShouldNotBeNull(); - result!.Length.ShouldBeLessThanOrEqualTo(CrashTelemetry.MaxStackTraceLength + "... [truncated]".Length); + result!.Length.ShouldBeLessThanOrEqualTo(CrashTelemetry.MaxStackTraceLength); result.ShouldEndWith("... [truncated]"); } diff --git a/src/Framework/Telemetry/CrashTelemetry.cs b/src/Framework/Telemetry/CrashTelemetry.cs index fe27d8e563e..32b8998ed7a 100644 --- a/src/Framework/Telemetry/CrashTelemetry.cs +++ b/src/Framework/Telemetry/CrashTelemetry.cs @@ -700,7 +700,8 @@ internal static CrashOriginKind ClassifyOrigin(string? originNamespace) if (sanitized.Length > MaxStackTraceLength) { - sanitized = sanitized.Substring(0, MaxStackTraceLength) + "... [truncated]"; + const string truncationSuffix = "... [truncated]"; + sanitized = sanitized.Substring(0, MaxStackTraceLength - truncationSuffix.Length) + truncationSuffix; } return sanitized; From 5db49bbd3d54be17b3d26931e6f5594cd6b3f6d2 Mon Sep 17 00:00:00 2001 From: YuliiaKovalova Date: Fri, 27 Feb 2026 15:49:47 +0100 Subject: [PATCH 3/3] Fix CS8604 nullable warning in tests (CI treats as error) Add null-forgiving operator after ShouldNotBeNull() assertions for StackTop and FullStackTrace properties. --- src/Framework.UnitTests/CrashTelemetry_Tests.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Framework.UnitTests/CrashTelemetry_Tests.cs b/src/Framework.UnitTests/CrashTelemetry_Tests.cs index d2779042d52..3f003db1625 100644 --- a/src/Framework.UnitTests/CrashTelemetry_Tests.cs +++ b/src/Framework.UnitTests/CrashTelemetry_Tests.cs @@ -437,7 +437,8 @@ public void PopulateFromException_SetsStackCaller_WhenThrowHelperIsOnTop() telemetry.PopulateFromException(ex); - telemetry.StackTop.ShouldContain("ErrorUtilities.ThrowInternalError"); + telemetry.StackTop.ShouldNotBeNull(); + telemetry.StackTop!.ShouldContain("ErrorUtilities.ThrowInternalError"); telemetry.StackCaller.ShouldNotBeNull(); telemetry.StackCaller!.ShouldContain("Microsoft.Build.Scheduler.ScheduleRequest"); } @@ -605,7 +606,7 @@ public void PopulateFromException_SetsFullStackTrace() telemetry.FullStackTrace.ShouldNotBeNull(); // Should contain all frames - telemetry.FullStackTrace.ShouldContain("ErrorUtilities.ThrowInternalError"); + telemetry.FullStackTrace!.ShouldContain("ErrorUtilities.ThrowInternalError"); telemetry.FullStackTrace.ShouldContain("RequestBuilder.BuildProject"); telemetry.FullStackTrace.ShouldContain("BuildManager.Build"); // File paths should be redacted