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:
Erik 2026-09-05 19:49:22 +02:00
parent 9baef7c507
commit f9bb47ec63
10 changed files with 927 additions and 2 deletions

View file

@ -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);
}

View file

@ -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]