Crash report: '[App] Kind (AssetType)' issue titles, native location as the subtitle

This commit is contained in:
Virgile Bello
2026-09-07 16:00:58 +09:00
parent ceeb3f7135
commit d5efd6ba71
10 changed files with 130 additions and 27 deletions
@@ -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:
/// "&lt;asset label&gt;\t&lt;definition path&gt;" (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.
/// "&lt;asset label&gt;\t&lt;asset type&gt;\t&lt;definition path&gt;" (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(),