acdream/tests/AcDream.App.Tests/Rendering/Packs/RenderPackRuntimeFailureRecoveryTests.cs

341 lines
12 KiB
C#

using AcDream.App.Plugins;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Gpu;
using AcDream.App.Rendering.Gpu.Vk;
using AcDream.App.Rendering.Packs;
using AcDream.App.Rendering.Wb;
using AcDream.App.Tests.Architecture;
using AcDream.App.Tests.Rendering.Gpu;
using AcDream.Plugin.Abstractions.Rendering;
using AcDream.UI.Abstractions.Panels.Settings;
using Silk.NET.Vulkan;
namespace AcDream.App.Tests.Rendering.Packs;
public sealed class RenderPackRuntimeFailureRecoveryTests
{
[Fact]
public void DirectionalShadowFailureCancelsBorrowedTransformsBeforePackRetirementAndRetailReplay()
{
var render = typeof(VulkanWorldScenePhase).GetMethod(
nameof(VulkanWorldScenePhase.Render))!;
string[] lifetimeCalls = CompiledCallGraph.Read(render)
.Where(call =>
(call.Target.DeclaringType == typeof(WbDrawDispatcher)
&& call.Target.Name == nameof(
WbDrawDispatcher.CancelDirectionalShadowTransformFrame))
|| (call.Target.DeclaringType == typeof(RenderPackController)
&& call.Target.Name == nameof(RenderPackController.OnRuntimeFailure))
|| (call.Target.DeclaringType == typeof(VulkanWorldScenePhase)
&& call.Target.Name == "RenderRetail"))
.Select(call => call.Target.Name)
.ToArray();
bool hasSafeLateFailureHandoff = Enumerable.Range(
0,
Math.Max(0, lifetimeCalls.Length - 2))
.Any(index =>
lifetimeCalls[index]
== nameof(WbDrawDispatcher.CancelDirectionalShadowTransformFrame)
&& lifetimeCalls[index + 1]
== nameof(RenderPackController.OnRuntimeFailure)
&& lifetimeCalls[index + 2] == "RenderRetail");
Assert.True(
hasSafeLateFailureHandoff,
"A post-publication directional-shadow failure must cancel the "
+ "borrowed transform frame before retiring the pack-owned buffer "
+ "and replaying the frame through retail rendering.");
}
[Fact]
public void EnhancedWorldFailureQuarantinesPackAndNextFrameUsesDefaultRenderer()
{
using var rig = new FailureRig(failEnhancedWorld: true, failPostProcess: false);
WorldRenderFrameOutcome failedFrame = rig.RenderFrame();
Assert.Equal(default, failedFrame);
Assert.Equal(RenderPackActivationState.FailedToRetail, rig.Controller.Snapshot.State);
Assert.Contains(
"Atmospheric world rendering failed: enhanced world failed",
rig.Controller.Snapshot.Reason,
StringComparison.Ordinal);
Assert.Null(rig.Controller.ActiveRuntime);
Assert.True(rig.Factory.Runtime.Disposed);
WorldRenderFrameOutcome recovered = rig.RenderFrame();
Assert.Equal(FailingWorldPhase.Success, recovered);
Assert.Equal(2, rig.World.RenderCount);
Assert.Contains(
rig.Device.Calls.OfType<GpuRecordedPassBegin>(),
call => call.Name == "vk-world");
Assert.Equal(RenderPackActivationState.FailedToRetail, rig.Controller.Snapshot.State);
Assert.Contains("enhanced world failed", rig.Controller.Snapshot.Reason);
rig.Controller.Request(rig.Selection);
Assert.Equal(FailingWorldPhase.Success, rig.RenderFrame());
Assert.Equal(1, rig.Factory.BuildCount);
Assert.Contains("will not be retried", rig.Controller.Snapshot.Reason);
}
[Fact]
public void PostProcessFailureKeepsWorldOutcomeAndNextFrameUsesDefaultRenderer()
{
using var rig = new FailureRig(failEnhancedWorld: false, failPostProcess: true);
WorldRenderFrameOutcome failedFrame = rig.RenderFrame();
Assert.Equal(FailingWorldPhase.Success, failedFrame);
Assert.Equal(RenderPackActivationState.FailedToRetail, rig.Controller.Snapshot.State);
Assert.Contains(
"Atmospheric post-processing failed: post process failed",
rig.Controller.Snapshot.Reason,
StringComparison.Ordinal);
Assert.Null(rig.Controller.ActiveRuntime);
Assert.True(rig.Factory.Runtime.Disposed);
WorldRenderFrameOutcome recovered = rig.RenderFrame();
Assert.Equal(FailingWorldPhase.Success, recovered);
Assert.Equal(2, rig.World.RenderCount);
Assert.Contains(
rig.Device.Calls.OfType<GpuRecordedPassBegin>(),
call => call.Name == "vk-world");
Assert.Equal(RenderPackActivationState.FailedToRetail, rig.Controller.Snapshot.State);
Assert.Contains("post process failed", rig.Controller.Snapshot.Reason);
}
[Theory]
[InlineData(Result.ErrorDeviceLost)]
[InlineData(Result.ErrorOutOfHostMemory)]
[InlineData(Result.ErrorOutOfDeviceMemory)]
public void FatalVulkanPostProcessFailureIsRethrownWithoutPretendingRetailCanRecover(
Result result)
{
var failure = new VulkanCallException("pack post process", result);
using var rig = new FailureRig(
enhancedWorldFailure: null,
postProcessFailure: failure);
VulkanCallException thrown = Assert.Throws<VulkanCallException>(
() => rig.RenderFrame());
Assert.Same(failure, thrown);
Assert.Equal(RenderPackActivationState.Active, rig.Controller.Snapshot.State);
Assert.False(rig.Factory.Runtime.Disposed);
}
[Fact]
public void NonTerminalVulkanPostProcessFailureQuarantinesPackAndFallsBack()
{
using var rig = new FailureRig(
enhancedWorldFailure: null,
postProcessFailure: new VulkanCallException(
"pack post process",
Result.ErrorFormatNotSupported));
WorldRenderFrameOutcome failedFrame = rig.RenderFrame();
Assert.Equal(FailingWorldPhase.Success, failedFrame);
Assert.Equal(RenderPackActivationState.FailedToRetail, rig.Controller.Snapshot.State);
Assert.Null(rig.Controller.ActiveRuntime);
Assert.True(rig.Factory.Runtime.Disposed);
Assert.Contains("ErrorFormatNotSupported", rig.Controller.Snapshot.Reason,
StringComparison.Ordinal);
}
private sealed class FailureRig : IDisposable
{
private readonly BufferedRenderPackRegistry _registry = new();
private readonly IDisposable _registration;
private readonly GpuDeviceFrameLifetime _lifetime;
private readonly VulkanWorldScenePhase _phase;
internal FailureRig(bool failEnhancedWorld, bool failPostProcess)
: this(
failEnhancedWorld
? new InvalidOperationException("enhanced world failed")
: null,
failPostProcess
? new InvalidOperationException("post process failed")
: null)
{
}
internal FailureRig(
Exception? enhancedWorldFailure,
Exception? postProcessFailure)
{
RenderPackDescriptor descriptor = Descriptor();
_registration = _registry.Register(descriptor, EmptyAssets.Instance);
Device = new RecordingGpuDevice();
Factory = new FailingGraphFactory(Device, postProcessFailure);
Controller = new RenderPackController(
() => RenderPackCatalog.Build(
_registry.Snapshot(),
RenderPackHostCapabilities.Conformance),
Factory,
preparationScheduler: InlineRenderPackPreparationScheduler.Instance);
Selection = new RenderPackSelectionSettings(
descriptor.Id,
descriptor.PackVersion.ToString(),
"default");
Controller.Request(Selection);
_lifetime = new GpuDeviceFrameLifetime(Device);
var scope = new VulkanWorldPassScope(sampleCount: 1);
World = new FailingWorldPhase(enhancedWorldFailure);
_phase = new VulkanWorldScenePhase(
_lifetime,
new VulkanBackbufferClearState(),
sampleCount: static () => 1,
scope,
World,
Controller,
new AtmosphericFrameInputState());
}
internal RecordingGpuDevice Device { get; }
internal FailingGraphFactory Factory { get; }
internal RenderPackController Controller { get; }
internal RenderPackSelectionSettings Selection { get; }
internal FailingWorldPhase World { get; }
internal WorldRenderFrameOutcome RenderFrame()
{
_lifetime.BeginFrame();
try
{
return _phase.Render(new RenderFrameInput(1.0 / 60.0, 1280, 720));
}
finally
{
_lifetime.EndFrame();
}
}
public void Dispose()
{
Controller.Dispose();
_registration.Dispose();
_registry.Dispose();
Device.Dispose();
}
}
private sealed class FailingGraphFactory(
RecordingGpuDevice device,
Exception? postProcessFailure) : IRenderPackRuntimeFactory
{
internal FailingGraphRuntime Runtime { get; private set; } = null!;
internal int BuildCount { get; private set; }
public IRenderPackRuntime Build(
RenderPackDescriptor descriptor,
ValidatedRenderPackShaderAssets assets,
RenderQualityPreset preset,
IReadOnlyDictionary<string, string> userSettingOverrides)
{
BuildCount++;
Runtime = new FailingGraphRuntime(device, descriptor, preset, postProcessFailure);
return Runtime;
}
}
private sealed class FailingGraphRuntime : IAtmosphericWorldGraphRuntime
{
private readonly RecordingGpuDevice _device;
private readonly Exception? _postProcessFailure;
private IGpuRenderTarget? _target;
internal FailingGraphRuntime(
RecordingGpuDevice device,
RenderPackDescriptor descriptor,
RenderQualityPreset preset,
Exception? postProcessFailure)
{
_device = device;
Descriptor = descriptor;
Preset = preset;
_postProcessFailure = postProcessFailure;
}
public RenderPackDescriptor Descriptor { get; }
public RenderQualityPreset Preset { get; }
internal bool Disposed { get; private set; }
public IGpuRenderTarget PrepareWorldTarget(int width, int height, int sampleCount) =>
_target ??= _device.CreateRenderTarget(new GpuRenderTargetDescription(
"failing-pack-target",
width,
height,
GpuTextureFormat.Rgba16FloatRenderTarget,
GpuTextureFormat.Depth24Stencil8,
sampleCount));
public void RenderPostProcess(IGpuFrame frame, in AtmosphericFrameInputs inputs)
{
if (_postProcessFailure is not null)
throw _postProcessFailure;
}
public void Dispose()
{
if (Disposed)
return;
Disposed = true;
_target?.Dispose();
_target = null;
}
}
private sealed class FailingWorldPhase(Exception? firstFailure) : IWorldSceneFramePhase
{
internal static WorldRenderFrameOutcome Success { get; } = new(5, 9, true);
internal int RenderCount { get; private set; }
public WorldRenderFrameOutcome Render(RenderFrameInput input)
{
RenderCount++;
if (firstFailure is not null && RenderCount == 1)
throw firstFailure;
return Success;
}
}
private static RenderPackDescriptor Descriptor() => new(
"failure.pack",
"Failure pack",
new Version(1, 0, 0),
RenderPackApi.Current,
RenderPackTier.Tier1,
[],
[],
[],
[],
[],
[],
[new RenderQualityPreset("default", "Default", [], [], [], 0, 0, 0, 0, 0)],
[],
null)
{
FeatureSummary = "Runtime failure test pack.",
};
private sealed class EmptyAssets : IRenderPackAssets
{
internal static EmptyAssets Instance { get; } = new();
public Stream OpenRead(string assetKey) =>
throw new InvalidOperationException("The failure test pack declares no assets.");
}
}