mirror of
https://github.com/stride3d/stride.git
synced 2026-10-12 02:23:23 +00:00
Crash report: '[App] Kind (AssetType)' issue titles, native location as the subtitle
This commit is contained in:
@@ -99,14 +99,14 @@ namespace Stride.AssetCompiler
|
||||
|
||||
/// <summary>
|
||||
/// What the native-fault recorder notes beside the dump for the command a native crash happened in:
|
||||
/// "<asset label>\t<definition path>" (the path is empty when unknown, the label falls back to
|
||||
/// the command title). Null when nothing is running. The adopt step turns it back into the report's
|
||||
/// affected asset and attachable definition, the same fields a managed command crash carries.
|
||||
/// "<asset label>\t<asset type>\t<definition path>" (type and path empty when unknown,
|
||||
/// the label falls back to the command title). Null when nothing is running. The adopt step turns it back
|
||||
/// into the report's asset fields and attachable definition, the same a managed command crash carries.
|
||||
/// </summary>
|
||||
public static string Breadcrumb(Command command, AssetItem asset)
|
||||
{
|
||||
var label = AssetLabel(asset) ?? command?.Title;
|
||||
return label == null ? null : label + "\t" + (asset?.FullPath?.ToOSPath() ?? string.Empty);
|
||||
return label == null ? null : $"{label}\t{asset?.Asset?.GetType().Name}\t{asset?.FullPath?.ToOSPath()}";
|
||||
}
|
||||
|
||||
/// <summary>The breadcrumb for the step running on the current async flow (the master's case).</summary>
|
||||
@@ -168,13 +168,16 @@ namespace Stride.AssetCompiler
|
||||
// The recorder also notes what the faulting thread was building (Windows only, see Breadcrumb).
|
||||
var (recordedFrame, breadcrumb) = ReadRecordedFaultingFrame(dumpPath);
|
||||
var faultingFrame = NativeCrashReporting.FaultingFrameFromDump(dumpPath) ?? recordedFrame;
|
||||
var assetLabel = breadcrumb?.Split('\t', 2)[0];
|
||||
var assetDefinitionPath = breadcrumb?.Split('\t', 2).ElementAtOrDefault(1);
|
||||
var crumbs = breadcrumb?.Split('\t') ?? Array.Empty<string>();
|
||||
var assetLabel = crumbs.ElementAtOrDefault(0);
|
||||
var assetType = crumbs.ElementAtOrDefault(1);
|
||||
var assetDefinitionPath = crumbs.ElementAtOrDefault(2);
|
||||
|
||||
var data = new CrashReportData
|
||||
{
|
||||
["Application"] = ApplicationName,
|
||||
["Exception"] = NativeCrashReporting.NativeCrashMessage(faultingFrame),
|
||||
["Exception"] = NativeCrashReporting.NativeCrashMessage(faultingFrame, assetLabel),
|
||||
["AssetType"] = string.IsNullOrEmpty(assetType) ? null : assetType,
|
||||
["Asset"] = assetLabel,
|
||||
["Platform"] = platform,
|
||||
["GraphicsApi"] = graphicsApi,
|
||||
|
||||
@@ -27,7 +27,7 @@ if (mode == "record")
|
||||
// The dialog is suppressed here so a dev box or CI runner can't hang on it.
|
||||
Trigger.SuppressCrashDialog();
|
||||
Stride.NativeCrashHandler.InstallFaultingFrameRecorder(Path.Combine(dir, "probe.dmp.frame"),
|
||||
() => "probe.sdm3d (ModelAsset)\t" + Path.Combine(dir, "probe.sdm3d"));
|
||||
() => "probe.sdm3d (ModelAsset)\tModelAsset\t" + Path.Combine(dir, "probe.sdm3d"));
|
||||
Trigger.NativeAccessViolation();
|
||||
Console.Error.WriteLine("probe: survived the AV (unexpected)");
|
||||
return 3;
|
||||
|
||||
@@ -146,7 +146,7 @@ public class CrashCaptureTests
|
||||
var lines = File.ReadAllLines(Path.Combine(dir, "probe.dmp.frame"));
|
||||
Assert.Equal(2, lines.Length);
|
||||
Assert.Contains("+0x", lines[0]);
|
||||
Assert.Equal("probe.sdm3d (ModelAsset)\t" + Path.Combine(dir, "probe.sdm3d"), lines[1]);
|
||||
Assert.Equal("probe.sdm3d (ModelAsset)\tModelAsset\t" + Path.Combine(dir, "probe.sdm3d"), lines[1]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
@@ -8,6 +8,26 @@ namespace Stride.CrashReport.Tests;
|
||||
|
||||
public class SenderTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("GameStudio", null, new[] { "System.InvalidOperationException" }, "[GameStudio] InvalidOperationException")]
|
||||
[InlineData("AssetCompiler", "ModelAsset", new[] { "System.NotSupportedException" }, "[AssetCompiler] NotSupportedException (ModelAsset)")]
|
||||
[InlineData("AssetCompiler", "ModelAsset", new[] { "NativeCrash" }, "[AssetCompiler] NativeCrash (ModelAsset)")]
|
||||
// Wrappers are unwrapped for the title only; the chain is listed innermost-first.
|
||||
[InlineData("GameStudio", null, new[] { "System.NullReferenceException", "System.Reflection.TargetInvocationException" }, "[GameStudio] NullReferenceException")]
|
||||
[InlineData("GameStudio", null, new[] { "System.IO.IOException", "System.AggregateException" }, "[GameStudio] IOException")]
|
||||
[InlineData("GameStudio", null, new[] { "System.NullReferenceException", "Stride.Core.Assets.AssetException" }, "[GameStudio] AssetException")]
|
||||
[InlineData("Launcher", null, new[] { "System.Collections.Generic.KeyNotFoundException`1" }, "[Launcher] KeyNotFoundException")]
|
||||
public void TitleNamesAppKindAndAssetType(string application, string? assetType, string[] chainInnermostFirst, string expected)
|
||||
=> Assert.Equal(expected, CrashReportSender.Title(application, CrashReportSender.TitleKind(chainInnermostFirst), assetType));
|
||||
|
||||
[Theory]
|
||||
[InlineData("Stride.Core.BuildEngine.CommandBuildStep+<StartCommand>d__30", "MoveNext", "Stride.Core.BuildEngine.CommandBuildStep.StartCommand")]
|
||||
[InlineData("Stride.Core.BuildEngine.CommandBuildStep", "Execute", "Stride.Core.BuildEngine.CommandBuildStep.Execute")]
|
||||
[InlineData("Stride.Core.BuildEngine.Builder+<>c__DisplayClass81_2", "<ScheduleBuildStep>b__0", "Stride.Core.BuildEngine.Builder+<>c__DisplayClass81_2.<ScheduleBuildStep>b__0")]
|
||||
[InlineData(null, "Main", "Main")]
|
||||
public void FrameNamesFoldAsyncStateMachines(string? type, string method, string expected)
|
||||
=> Assert.Equal(expected, FrameNames.Qualified(type, method));
|
||||
|
||||
[Fact]
|
||||
public async Task SendToUnreachableDsnThrowsSoTheReportIsKept()
|
||||
{
|
||||
|
||||
@@ -115,7 +115,8 @@ public static class CrashReportSender
|
||||
// No user identity beyond the SDK's random installation id; a contact email only travels
|
||||
// through the feedback when the user typed one
|
||||
options.SendDefaultPii = false;
|
||||
options.SetBeforeSend((sentryEvent, _) => Anonymize(sentryEvent));
|
||||
// BeforeSend runs after the SDK's processors filled in the exception chain of a live exception.
|
||||
options.SetBeforeSend((sentryEvent, _) => Anonymize(Retitle(sentryEvent, applicationName, report["AssetType"])));
|
||||
});
|
||||
|
||||
SentrySdk.ConfigureScope(scope =>
|
||||
@@ -141,7 +142,9 @@ public static class CrashReportSender
|
||||
else if (structuredExceptions is { Count: > 0 })
|
||||
sentryEvent = BuildEventFromStored(structuredExceptions); // handoff managed crash: rebuild the frames
|
||||
else
|
||||
sentryEvent = new SentryEvent { Message = new SentryMessage { Formatted = report["Exception"] ?? "Unknown crash" } }; // native / no frames
|
||||
// A native crash whose dump could not be walked: no frames, but still an exception (not a bare message)
|
||||
// so its title has the same shape as every other crash.
|
||||
sentryEvent = new SentryEvent { SentryExceptions = [new Sentry.Protocol.SentryException { Type = "NativeCrash", Value = report["Exception"] ?? "Unknown crash" }] };
|
||||
sentryEvent.Level = SentryLevel.Fatal;
|
||||
// Group by the crash's signature, not the reporter's stack or message.
|
||||
if (!string.IsNullOrEmpty(fingerprint))
|
||||
@@ -364,6 +367,45 @@ public static class CrashReportSender
|
||||
/// The Sentry event carries its own copy of messages and stack frames, so it needs the same scrubbing
|
||||
/// as the report text.
|
||||
/// </summary>
|
||||
// Sentry titles an issue by its outermost exception's type. Ours reads "[App] Kind (AssetType)": the app, because
|
||||
// the list shows no tags and one bug seen from two apps is two issues anyway (the signature has the step kind);
|
||||
// the asset type for compiler crashes, because it is what the crash is about; never the version, which is a tag
|
||||
// and would split a title per release. Inner exceptions keep their real type, so the chain reads as usual.
|
||||
internal static SentryEvent Retitle(SentryEvent sentryEvent, string applicationName, string assetType)
|
||||
{
|
||||
var chain = sentryEvent.SentryExceptions?.ToList();
|
||||
if (chain is not { Count: > 0 })
|
||||
return sentryEvent;
|
||||
chain[^1].Type = Title(applicationName, TitleKind(chain.Select(e => e.Type).ToList()), assetType);
|
||||
sentryEvent.SentryExceptions = chain;
|
||||
return sentryEvent;
|
||||
}
|
||||
|
||||
/// <summary>The issue title for a crash: "[App] Kind (AssetType)", the asset type only when known.</summary>
|
||||
public static string Title(string applicationName, string kind, string assetType)
|
||||
=> $"[{applicationName}] {kind}" + (string.IsNullOrEmpty(assetType) ? "" : $" ({assetType})");
|
||||
|
||||
// The kind named in the title: the outermost type, short, unless it is a pure wrapper (reflection, a type
|
||||
// initializer, a task) around the inner one, which is then the crash worth naming.
|
||||
public static string TitleKind(IReadOnlyList<string> chainInnermostFirst)
|
||||
{
|
||||
var outer = ShortTypeName(chainInnermostFirst[^1]);
|
||||
if (chainInnermostFirst.Count > 1
|
||||
&& outer is "TargetInvocationException" or "TypeInitializationException" or "AggregateException")
|
||||
return ShortTypeName(chainInnermostFirst[^2]);
|
||||
return outer;
|
||||
}
|
||||
|
||||
private static string ShortTypeName(string fullName)
|
||||
{
|
||||
if (string.IsNullOrEmpty(fullName))
|
||||
return "Exception";
|
||||
var generic = fullName.IndexOf('`');
|
||||
if (generic >= 0)
|
||||
fullName = fullName[..generic];
|
||||
return fullName[(fullName.LastIndexOf('.') + 1)..];
|
||||
}
|
||||
|
||||
private static SentryEvent Anonymize(SentryEvent sentryEvent)
|
||||
{
|
||||
// Keep only the install id, no location. A concrete non-routable IP is used rather than null so
|
||||
|
||||
@@ -31,6 +31,6 @@ public static class CrashSignature
|
||||
var method = frames[0].GetMethod();
|
||||
if (method == null)
|
||||
return "?";
|
||||
return $"{method.DeclaringType?.FullName ?? "?"}.{method.Name}";
|
||||
return FrameNames.Qualified(method.DeclaringType?.FullName ?? "?", method.Name);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,15 +116,14 @@ public static class DumpStackWalk
|
||||
var faultSite = frames.Count > 0 ? frames[0].Function : null;
|
||||
if (!string.IsNullOrEmpty(faultingFrame))
|
||||
frames.Insert(0, new StoredFrame { Function = faultingFrame });
|
||||
var message = string.IsNullOrEmpty(faultSite)
|
||||
? NativeCrashReporting.NativeCrashMessage(faultingFrame)
|
||||
: $"Native access violation in {faultSite}.";
|
||||
// The exception's message is Sentry's subtitle: the native location, the one thing the managed
|
||||
// stack (whose top frame Sentry shows as the culprit) can't tell. The report line says it all.
|
||||
crash.Exceptions = new List<StoredException>
|
||||
{
|
||||
new() { Type = "NativeCrash", Message = message, Frames = frames },
|
||||
new() { Type = "NativeCrash", Message = NativeCrashReporting.NativeCrashValue(faultingFrame), Frames = frames },
|
||||
};
|
||||
crash.CrashedThreadId = (int)thread.OSThreadId;
|
||||
SetReportException(crash, message); // so the reporter window title and report.txt name the fault site too
|
||||
SetReportException(crash, NativeCrashReporting.NativeCrashMessage(faultingFrame, crash.AffectedAssets.FirstOrDefault(), faultSite));
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -183,7 +182,7 @@ public static class DumpStackWalk
|
||||
continue;
|
||||
frames.Add(new StoredFrame
|
||||
{
|
||||
Function = method.Type is not null ? method.Type.Name + "." + method.Name : method.Name,
|
||||
Function = FrameNames.Qualified(method.Type?.Name, method.Name),
|
||||
// ClrModule.Name is the module's full on-disk path; keep only the assembly name (its file name),
|
||||
// both because that is what Sentry expects and to avoid shipping a local, user-identifying path.
|
||||
Module = ModuleName(method.Type?.Module),
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net)
|
||||
// Distributed under the MIT license. See the LICENSE.md file in the project root for more information.
|
||||
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Stride.CrashReport;
|
||||
|
||||
/// <summary>
|
||||
/// One naming for managed frames across the live capture, the dump walk and the signature.
|
||||
/// </summary>
|
||||
public static class FrameNames
|
||||
{
|
||||
// The compiler's async state machine: "Ns.Type+<Method>d__30" running "MoveNext". The number is the compiler's
|
||||
// and moves between builds, so a title or a signature that kept it would split one bug per build.
|
||||
private static readonly Regex AsyncStateMachine = new(@"^(?<type>.+)\+<(?<method>[^>]+)>d__\d+$", RegexOptions.Compiled);
|
||||
|
||||
/// <summary>"Ns.Type.Method", with an async state machine folded back to the method it implements.</summary>
|
||||
public static string Qualified(string typeFullName, string methodName)
|
||||
{
|
||||
if (string.IsNullOrEmpty(typeFullName))
|
||||
return methodName;
|
||||
if (methodName == "MoveNext" && AsyncStateMachine.Match(typeFullName) is { Success: true } async)
|
||||
return async.Groups["type"].Value + "." + async.Groups["method"].Value;
|
||||
return typeFullName + "." + methodName;
|
||||
}
|
||||
}
|
||||
@@ -85,14 +85,28 @@ namespace Stride.CrashReport
|
||||
public static string FaultingFrameFromDump(string dumpPath) => MinidumpReader.FaultingFrame(dumpPath);
|
||||
|
||||
/// <summary>
|
||||
/// The crash-report exception line for a native access violation, naming the faulting frame when known. The
|
||||
/// frame travels in the message so Sentry — which has no stack trace for these — groups them by fault location
|
||||
/// instead of collapsing every native crash into one issue.
|
||||
/// The crash-report exception line for a native access violation: the faulting frame when known, the asset
|
||||
/// being compiled and the managed fault site when the dump walk found them. This is what the report text and
|
||||
/// the reporter window show; Sentry's title comes from <see cref="NativeCrashValue"/> instead.
|
||||
/// </summary>
|
||||
public static string NativeCrashMessage(string faultingFrame)
|
||||
=> string.IsNullOrEmpty(faultingFrame)
|
||||
? "Native crash (access violation). See the attached minidump for the faulting thread and loaded modules."
|
||||
: $"Native crash (access violation) in {faultingFrame}. See the attached minidump for the faulting thread and loaded modules.";
|
||||
public static string NativeCrashMessage(string faultingFrame, string assetLabel = null, string managedFaultSite = null)
|
||||
{
|
||||
var message = $"Native crash (access violation) in {NativeLocation(faultingFrame)}";
|
||||
if (!string.IsNullOrEmpty(assetLabel))
|
||||
message += $" while compiling {assetLabel}";
|
||||
if (!string.IsNullOrEmpty(managedFaultSite))
|
||||
message += $"; managed fault site {managedFaultSite}";
|
||||
return message + ". See the attached minidump for the faulting thread and loaded modules.";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The one-line value of a native crash's synthetic exception, Sentry's subtitle under the issue title: the
|
||||
/// native location only. The managed stack travels as frames, and its top frame is the culprit line Sentry
|
||||
/// shows already, so repeating it here would hide the one detail the stack can't give.
|
||||
/// </summary>
|
||||
public static string NativeCrashValue(string faultingFrame) => $"Access violation in {NativeLocation(faultingFrame)}";
|
||||
|
||||
private static string NativeLocation(string faultingFrame) => string.IsNullOrEmpty(faultingFrame) ? "native code" : faultingFrame;
|
||||
|
||||
/// <summary>
|
||||
/// Launches the out-of-process reporter for a crash run, returning false when the reporter can't be
|
||||
|
||||
@@ -130,8 +130,7 @@ public sealed class StoredException
|
||||
var method = frame.GetMethod();
|
||||
stored.Frames.Add(new StoredFrame
|
||||
{
|
||||
Function = method is null ? null
|
||||
: method.DeclaringType is null ? method.Name : method.DeclaringType.FullName + "." + method.Name,
|
||||
Function = method is null ? null : FrameNames.Qualified(method.DeclaringType?.FullName, method.Name),
|
||||
Module = method?.DeclaringType?.Assembly.GetName().Name,
|
||||
File = frame.GetFileName(),
|
||||
Line = frame.GetFileLineNumber(),
|
||||
|
||||
Reference in New Issue
Block a user