feat(diagnostics): retain bounded local crash reports (#477)
Project the original Run failure with loaded build, cached GPU and cell context before unwind. Preserve crash status and rethrow; contain report failures and omit arbitrary messages, source paths and session data. Focused 19/19, narrow production/privacy review and default Release 17044/0/0 pass. Preserve the wrong-package smoke failure and real emitted report; corrected recipe10 smoke exits gracefully with two provisional PNG passes. AMD initiating cause and G4 remain open; FPS deferred. No new retail behavior deviation.
This commit is contained in:
parent
9baef7c507
commit
f9bb47ec63
10 changed files with 927 additions and 2 deletions
|
|
@ -0,0 +1,278 @@
|
|||
using System.Collections;
|
||||
using System.Reflection;
|
||||
using System.Runtime.ExceptionServices;
|
||||
using System.Text.Json;
|
||||
using AcDream.App.Diagnostics;
|
||||
using AcDream.App.Rendering.Gpu.Vk;
|
||||
using Silk.NET.Vulkan;
|
||||
|
||||
namespace AcDream.App.Tests.Diagnostics;
|
||||
|
||||
public sealed class LocalCrashReportWriterTests : IDisposable
|
||||
{
|
||||
private readonly string _directory = Directory.CreateTempSubdirectory("acdream-crash-report-tests-").FullName;
|
||||
|
||||
[Fact]
|
||||
public void WrittenReport_PreservesExactVulkanFieldsAndLoadedAssemblyIdentity()
|
||||
{
|
||||
VulkanCallException failure = ThrownVulkan();
|
||||
var notices = new List<string>();
|
||||
string path = Assert.IsType<string>(LocalCrashReportWriter.TryWrite(failure, _directory, notify: notices.Add));
|
||||
using JsonDocument document = JsonDocument.Parse(File.ReadAllBytes(path));
|
||||
JsonElement root = document.RootElement;
|
||||
JsonElement entry = Assert.Single(root.GetProperty("exceptions").EnumerateArray());
|
||||
Assert.Equal(1, root.GetProperty("schemaVersion").GetInt32());
|
||||
Assert.Equal(Environment.ProcessId, root.GetProperty("processId").GetInt32());
|
||||
Assert.InRange(DateTimeOffset.UtcNow - root.GetProperty("utc").GetDateTimeOffset(), TimeSpan.Zero, TimeSpan.FromMinutes(1));
|
||||
Assembly assembly = typeof(LocalCrashReportWriter).Assembly;
|
||||
Assert.Equal(assembly.GetName().Version!.ToString(), root.GetProperty("assemblyVersion").GetString());
|
||||
Assert.Equal(assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()!.InformationalVersion,
|
||||
root.GetProperty("informationalVersion").GetString());
|
||||
Assert.Equal(assembly.ManifestModule.ModuleVersionId, root.GetProperty("moduleMvid").GetGuid());
|
||||
Assert.Equal(typeof(VulkanCallException).FullName, entry.GetProperty("type").GetString());
|
||||
Assert.Equal(failure.HResult, entry.GetProperty("hResult").GetInt32());
|
||||
Assert.Contains(nameof(ThrownVulkan), entry.GetProperty("stack").GetString());
|
||||
JsonElement vk = entry.GetProperty("vulkan");
|
||||
Assert.Equal("vkQueueSubmit2 (abandoned frame timeline signal)", vk.GetProperty("operation").GetString());
|
||||
Assert.Equal(-4, vk.GetProperty("result").GetInt32());
|
||||
Assert.Equal(nameof(Result.ErrorDeviceLost), vk.GetProperty("resultName").GetString());
|
||||
Assert.Equal($"Local crash report saved: {path}", Assert.Single(notices));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NestedAggregate_PreservesParentAndChildOrderWithoutDuplicateFirstChild()
|
||||
{
|
||||
var failure = new AggregateException("omitted",
|
||||
new InvalidOperationException("omitted", new ArgumentException("omitted")),
|
||||
new AggregateException("omitted", new VulkanCallException("vkAcquireNextImageKHR", Result.ErrorDeviceLost)));
|
||||
using JsonDocument document = Write(failure);
|
||||
JsonElement[] nodes = document.RootElement.GetProperty("exceptions").EnumerateArray().ToArray();
|
||||
Assert.Equal(5, nodes.Length);
|
||||
Assert.Equal(new[] { "root", "aggregate", "inner", "aggregate", "aggregate" },
|
||||
nodes.Select(node => node.GetProperty("relation").GetString()));
|
||||
Assert.Equal(new int?[] { null, 0, 1, 0, 3 },
|
||||
nodes.Select(node => NullableInt(node.GetProperty("parentIndex"))));
|
||||
Assert.Equal(new int?[] { null, 0, null, 1, 0 },
|
||||
nodes.Select(node => NullableInt(node.GetProperty("aggregateIndex"))));
|
||||
Assert.False(document.RootElement.GetProperty("exceptionTreeTruncated").GetBoolean());
|
||||
Assert.Equal("vkAcquireNextImageKHR", nodes[4].GetProperty("vulkan").GetProperty("operation").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MissingContext_LeavesGpuAndWorldNull()
|
||||
{
|
||||
using JsonDocument document = Write(new Exception("private"));
|
||||
Assert.Equal(JsonValueKind.Null, document.RootElement.GetProperty("gpu").ValueKind);
|
||||
Assert.Equal(JsonValueKind.Null, document.RootElement.GetProperty("world").ValueKind);
|
||||
Assert.False(document.RootElement.GetProperty("contextCaptureFailed").GetBoolean());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Context_PreservesOnlyProjectedGpuAndCellLocalPosition()
|
||||
{
|
||||
var gpu = new LocalCrashGpu("AMD device", "cached driver", "1.3", "1.4", 123, 1920, 1080, 4);
|
||||
var world = new LocalCrashWorld(0xF4180111, 12.25f, -3.5f, 160, "Falling");
|
||||
using JsonDocument document = Write(new Exception(), () => new(gpu, world));
|
||||
JsonElement actualGpu = document.RootElement.GetProperty("gpu");
|
||||
Assert.Equal("AMD device", actualGpu.GetProperty("deviceName").GetString());
|
||||
Assert.Equal("cached driver", actualGpu.GetProperty("driverInfo").GetString());
|
||||
Assert.Equal("1.3", actualGpu.GetProperty("instanceApiVersion").GetString());
|
||||
Assert.Equal("1.4", actualGpu.GetProperty("deviceApiVersion").GetString());
|
||||
Assert.Equal(123u, actualGpu.GetProperty("deviceApiVersionPacked").GetUInt32());
|
||||
Assert.Equal(1920, actualGpu.GetProperty("width").GetInt32());
|
||||
Assert.Equal(1080, actualGpu.GetProperty("height").GetInt32());
|
||||
Assert.Equal(4, actualGpu.GetProperty("sampleCount").GetInt32());
|
||||
JsonElement actualWorld = document.RootElement.GetProperty("world");
|
||||
Assert.Equal(0xF4180111u, actualWorld.GetProperty("cellId").GetUInt32());
|
||||
Assert.Equal(12.25f, actualWorld.GetProperty("x").GetSingle());
|
||||
Assert.Equal(-3.5f, actualWorld.GetProperty("y").GetSingle());
|
||||
Assert.Equal(160f, actualWorld.GetProperty("z").GetSingle());
|
||||
Assert.Equal("Falling", actualWorld.GetProperty("state").GetString());
|
||||
Assert.False(actualWorld.GetProperty("nonFiniteCoordinates").GetBoolean());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NonFiniteCoordinates_AreExplicitlyNullAndDoNotPreventAReport()
|
||||
{
|
||||
using JsonDocument document = Write(new Exception(), () => new(null,
|
||||
new LocalCrashWorld(null, float.NaN, float.PositiveInfinity, float.NegativeInfinity, null)));
|
||||
JsonElement world = document.RootElement.GetProperty("world");
|
||||
foreach (string name in new[] { "cellId", "x", "y", "z", "state" })
|
||||
Assert.Equal(JsonValueKind.Null, world.GetProperty(name).ValueKind);
|
||||
Assert.True(world.GetProperty("nonFiniteCoordinates").GetBoolean());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LargeUnicodeAndWideExceptionTree_AreBoundedBeforeValidJsonIsWritten()
|
||||
{
|
||||
string huge = string.Concat(Enumerable.Repeat("秘密🌋", 20_000));
|
||||
var failure = new AggregateException(Enumerable.Range(0, 1_000)
|
||||
.Select(_ => new VulkanCallException(huge, Result.ErrorDeviceLost)));
|
||||
var gpu = new LocalCrashGpu(huge, huge, huge, huge, null, null, null, null);
|
||||
string path = Assert.IsType<string>(LocalCrashReportWriter.TryWrite(failure, _directory, () => new(gpu, new(null, null, null, null, huge))));
|
||||
byte[] bytes = File.ReadAllBytes(path);
|
||||
Assert.InRange(bytes.Length, 1, LocalCrashReportWriter.MaxReportBytes);
|
||||
using JsonDocument document = JsonDocument.Parse(bytes);
|
||||
JsonElement root = document.RootElement;
|
||||
Assert.Equal(LocalCrashReportWriter.MaxExceptionNodes, root.GetProperty("exceptions").GetArrayLength());
|
||||
Assert.True(root.GetProperty("exceptionTreeTruncated").GetBoolean());
|
||||
Assert.True(root.GetProperty("exceptions")[0].GetProperty("childrenTruncated").GetBoolean());
|
||||
Assert.EndsWith("[truncated]", root.GetProperty("gpu").GetProperty("deviceName").GetString());
|
||||
Assert.InRange(root.GetProperty("gpu").GetProperty("deviceName").GetString()!.Length, 1, 512);
|
||||
Assert.EndsWith("[truncated]", root.GetProperty("exceptions")[1].GetProperty("vulkan").GetProperty("operation").GetString());
|
||||
Assert.DoesNotContain("\uFFFD", document.RootElement.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeepInnerTree_StopsAtEightAndMarksOmittedChildren()
|
||||
{
|
||||
Exception failure = new Exception();
|
||||
for (int i = 0; i < 100; i++)
|
||||
failure = new Exception("not serialized", failure);
|
||||
using JsonDocument document = Write(failure);
|
||||
JsonElement nodes = document.RootElement.GetProperty("exceptions");
|
||||
Assert.Equal(LocalCrashReportWriter.MaxExceptionNodes, nodes.GetArrayLength());
|
||||
Assert.True(nodes[7].GetProperty("childrenTruncated").GetBoolean());
|
||||
Assert.True(document.RootElement.GetProperty("exceptionTreeTruncated").GetBoolean());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MessagesDataSourcePathsAndInjectedRemoteStack_AreNeverSerialized()
|
||||
{
|
||||
var failure = new Exception("PRIVATE_MESSAGE C:\\secret\\source.cs");
|
||||
failure.Data["PRIVATE_DATA"] = "secret session";
|
||||
ExceptionDispatchInfo.SetRemoteStackTrace(failure, "PRIVATE_REMOTE_STACK at Fake in C:\\secret\\injected.cs:line 42");
|
||||
try { throw failure; }
|
||||
catch (Exception caught)
|
||||
{
|
||||
string path = Assert.IsType<string>(LocalCrashReportWriter.TryWrite(caught, _directory));
|
||||
string text = File.ReadAllText(path);
|
||||
Assert.DoesNotContain("PRIVATE_", text);
|
||||
Assert.DoesNotContain("secret", text);
|
||||
Assert.DoesNotContain(".cs", text);
|
||||
using JsonDocument document = JsonDocument.Parse(text);
|
||||
Assert.Contains(nameof(MessagesDataSourcePathsAndInjectedRemoteStack_AreNeverSerialized),
|
||||
document.RootElement.GetProperty("exceptions")[0].GetProperty("stack").GetString());
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VirtualExceptionTextAccessors_AreNotRead()
|
||||
{
|
||||
using JsonDocument document = Write(new PoisonTextException());
|
||||
Assert.Equal(typeof(PoisonTextException).FullName,
|
||||
document.RootElement.GetProperty("exceptions")[0].GetProperty("type").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RepeatedWrites_AreUniqueNonOverwritingAndLeaveOnlyCompleteJson()
|
||||
{
|
||||
string sentinel = Path.Combine(_directory, "existing.json");
|
||||
File.WriteAllText(sentinel, "existing report must survive");
|
||||
string first = Assert.IsType<string>(LocalCrashReportWriter.TryWrite(new Exception(), _directory));
|
||||
byte[] firstBytes = File.ReadAllBytes(first);
|
||||
string second = Assert.IsType<string>(LocalCrashReportWriter.TryWrite(new Exception(), _directory));
|
||||
Assert.NotEqual(first, second);
|
||||
Assert.Equal(firstBytes, File.ReadAllBytes(first));
|
||||
Assert.Equal("existing report must survive", File.ReadAllText(sentinel));
|
||||
Assert.Equal(3, Directory.GetFiles(_directory).Length);
|
||||
Assert.Empty(Directory.GetFiles(_directory, "*.tmp"));
|
||||
foreach (string path in new[] { first, second })
|
||||
{
|
||||
Assert.StartsWith("crash-", Path.GetFileName(path));
|
||||
Assert.Contains($"-{Environment.ProcessId}-", Path.GetFileName(path));
|
||||
using JsonDocument document = JsonDocument.Parse(File.ReadAllBytes(path));
|
||||
Assert.Equal(1, document.RootElement.GetProperty("schemaVersion").GetInt32());
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FailedContextCapture_StillWritesMinimalReportWithoutSecondaryText()
|
||||
{
|
||||
using JsonDocument document = Write(new Exception(), () => throw new Exception("PRIVATE_CAPTURE"));
|
||||
Assert.True(document.RootElement.GetProperty("contextCaptureFailed").GetBoolean());
|
||||
Assert.Equal(JsonValueKind.Null, document.RootElement.GetProperty("gpu").ValueKind);
|
||||
Assert.Equal(JsonValueKind.Null, document.RootElement.GetProperty("world").ValueKind);
|
||||
Assert.DoesNotContain("PRIVATE_CAPTURE", document.RootElement.ToString());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public void InvalidOrBlockedDestination_DoesNotThrowOrChangeOriginalFailure(bool blockedByFile)
|
||||
{
|
||||
string destination = "\0";
|
||||
if (blockedByFile)
|
||||
{
|
||||
destination = Path.Combine(_directory, "not-a-directory");
|
||||
File.WriteAllText(destination, "preserve");
|
||||
}
|
||||
var notices = new List<string>();
|
||||
var original = new InvalidOperationException("original");
|
||||
Exception? propagated = Record.Exception((Action)(() =>
|
||||
{
|
||||
try { throw original; }
|
||||
catch (Exception failure)
|
||||
{
|
||||
Assert.Null(LocalCrashReportWriter.TryWrite(failure, destination, notify: notices.Add));
|
||||
throw;
|
||||
}
|
||||
}));
|
||||
Assert.Same(original, propagated);
|
||||
Assert.Equal("Local crash report unavailable.", Assert.Single(notices));
|
||||
Assert.Empty(Directory.GetFiles(_directory, "*.tmp"));
|
||||
Assert.Empty(Directory.GetFiles(_directory, "*.json"));
|
||||
if (blockedByFile)
|
||||
Assert.Equal("preserve", File.ReadAllText(destination));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public void FailedNotification_DoesNotThrowOrChangeOriginalFailure(bool invalidDestination)
|
||||
{
|
||||
var original = new Exception("original");
|
||||
string? path = null;
|
||||
int notices = 0;
|
||||
Exception? propagated = Record.Exception((Action)(() =>
|
||||
{
|
||||
try { throw original; }
|
||||
catch (Exception failure)
|
||||
{
|
||||
path = LocalCrashReportWriter.TryWrite(failure, invalidDestination ? "\0" : _directory,
|
||||
notify: _ => { notices++; throw new Exception("PRIVATE_NOTIFICATION"); });
|
||||
throw;
|
||||
}
|
||||
}));
|
||||
Assert.Same(original, propagated);
|
||||
Assert.Equal(1, notices);
|
||||
if (invalidDestination)
|
||||
Assert.Null(path);
|
||||
else
|
||||
{
|
||||
using JsonDocument document = JsonDocument.Parse(File.ReadAllBytes(Assert.IsType<string>(path)));
|
||||
Assert.DoesNotContain("PRIVATE_NOTIFICATION", document.RootElement.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
private JsonDocument Write(Exception failure, Func<LocalCrashReportContext?>? capture = null) =>
|
||||
JsonDocument.Parse(File.ReadAllBytes(Assert.IsType<string>(LocalCrashReportWriter.TryWrite(failure, _directory, capture))));
|
||||
|
||||
private static int? NullableInt(JsonElement value) => value.ValueKind == JsonValueKind.Null ? null : value.GetInt32();
|
||||
|
||||
private static VulkanCallException ThrownVulkan()
|
||||
{
|
||||
try { throw new VulkanCallException("vkQueueSubmit2 (abandoned frame timeline signal)", Result.ErrorDeviceLost); }
|
||||
catch (VulkanCallException failure) { return failure; }
|
||||
}
|
||||
|
||||
private sealed class PoisonTextException : Exception
|
||||
{
|
||||
public override string Message => throw new InvalidOperationException("PRIVATE_MESSAGE");
|
||||
public override string StackTrace => throw new InvalidOperationException("PRIVATE_STACK");
|
||||
public override IDictionary Data => throw new InvalidOperationException("PRIVATE_DATA");
|
||||
public override string ToString() => throw new InvalidOperationException("PRIVATE_TOSTRING");
|
||||
}
|
||||
|
||||
public void Dispose() => Directory.Delete(_directory, recursive: true);
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
using System.Reflection;
|
||||
using System.Reflection.Emit;
|
||||
using AcDream.App.Diagnostics;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.Tests.Architecture;
|
||||
using AcDream.Runtime.Session;
|
||||
|
|
@ -16,7 +17,7 @@ namespace AcDream.App.Tests.Rendering;
|
|||
public sealed class GameWindowCrashStatusTests
|
||||
{
|
||||
[Fact]
|
||||
public void Run_LatchesRunFailureBeforeRethrowingFromTheFrameLoopCatch()
|
||||
public void Run_LatchesThenReportsBeforeRethrowingFromTheFrameLoopCatch()
|
||||
{
|
||||
MethodInfo run = RequiredMethod(nameof(GameWindow.Run));
|
||||
IReadOnlyList<CompiledCall> calls = CompiledCallGraph.Read(run);
|
||||
|
|
@ -37,10 +38,41 @@ public sealed class GameWindowCrashStatusTests
|
|||
CompiledInstruction rethrow = Assert.Single(
|
||||
CompiledCallGraph.ReadInstructions(run),
|
||||
instruction => instruction.OpCode == OpCodes.Rethrow);
|
||||
CompiledCall report = Assert.Single(calls, call =>
|
||||
call.Target.DeclaringType == typeof(LocalCrashReportWriter)
|
||||
&& call.Target.Name == nameof(LocalCrashReportWriter.TryWrite));
|
||||
|
||||
Assert.True(frameLoop.Offset < retain.Offset);
|
||||
Assert.True(retain.Offset < latch.Offset);
|
||||
Assert.True(latch.Offset < rethrow.Offset);
|
||||
Assert.True(latch.Offset < report.Offset);
|
||||
Assert.True(report.Offset < rethrow.Offset);
|
||||
|
||||
ExceptionHandlingClause outerCatch = Assert.Single(run.GetMethodBody()!.ExceptionHandlingClauses,
|
||||
clause => clause.Flags == ExceptionHandlingClauseOptions.Clause
|
||||
&& clause.CatchType == typeof(Exception)
|
||||
&& clause.HandlerOffset <= retain.Offset
|
||||
&& retain.Offset < clause.HandlerOffset + clause.HandlerLength);
|
||||
Assert.InRange(rethrow.Offset, outerCatch.HandlerOffset, outerCatch.HandlerOffset + outerCatch.HandlerLength - 1);
|
||||
ExceptionHandlingClause reportGuard = Assert.Single(run.GetMethodBody()!.ExceptionHandlingClauses,
|
||||
clause => clause.Flags == ExceptionHandlingClauseOptions.Clause
|
||||
&& clause.TryOffset <= report.Offset
|
||||
&& report.Offset < clause.TryOffset + clause.TryLength);
|
||||
Assert.True(latch.Offset < reportGuard.TryOffset);
|
||||
foreach (CompiledCall call in calls.Where(call => call.Offset > latch.Offset && call.Offset <= report.Offset))
|
||||
Assert.InRange(call.Offset, reportGuard.TryOffset, reportGuard.TryOffset + reportGuard.TryLength - 1);
|
||||
Assert.Single(CompiledCallGraph.ReadDeclared(typeof(GameWindow)), call =>
|
||||
call.Target.DeclaringType == typeof(LocalCrashReportWriter));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CrashContext_ReadsControllerOnceAndUsesExistingCellPositionAndCachedGpuMetadata()
|
||||
{
|
||||
IReadOnlyList<CompiledCall> calls = CompiledCallGraph.Read(RequiredMethod("CaptureLocalCrashReportContext"));
|
||||
foreach (string getter in new[] { "get__playerController", "get_CellPosition", "get_State", "get_Capabilities", "get_Width", "get_Height", "get_SampleCount" })
|
||||
Assert.Single(calls, call => call.Target.Name == getter);
|
||||
Assert.DoesNotContain(calls, call => call.Target.Name == "get_Position" || call.Target.Name == "get_Device");
|
||||
Assert.DoesNotContain(CompiledCallGraph.Read(RequiredMethod("ReportExited")), call =>
|
||||
call.Target.DeclaringType == typeof(LocalCrashReportWriter));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue