refactor(lifetime): own render resource prefixes
Give terrain, sky, retained UI, portal preparation, and the update/render frame pair explicit single owners. Make shader, texture, text, bindless, and GL construction prefixes checked and retryable so partial failure cannot lose or replay resource ownership. Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
parent
fec0d94148
commit
c87b15303d
41 changed files with 3768 additions and 355 deletions
|
|
@ -43,8 +43,9 @@ public sealed class GameWindowRenderLeafCompositionTests
|
|||
|
||||
AssertAppearsInOrder(
|
||||
source,
|
||||
"_portalTunnelFallback.Transfer(",
|
||||
"new AcDream.App.Streaming.LocalPlayerTeleportController(",
|
||||
"_portalTunnel = null;",
|
||||
"new AcDream.App.Streaming.LocalPlayerTeleportPresentation(",
|
||||
"_localPlayerTeleportSink.Bind(_localPlayerTeleport);",
|
||||
"new AcDream.App.Rendering.LocalPlayerTeleportRenderStateSource(",
|
||||
"new AcDream.App.Rendering.RenderFrameResourceController(",
|
||||
|
|
@ -125,7 +126,7 @@ public sealed class GameWindowRenderLeafCompositionTests
|
|||
AssertAppearsInOrder(
|
||||
source,
|
||||
"new ResourceShutdownStage(\"frame borrowers\"",
|
||||
"_renderFrameOrchestrator = null;",
|
||||
"_frameGraphs.Withdraw();",
|
||||
"new ResourceShutdownStage(\"session dependents\"");
|
||||
AssertAppearsInOrder(
|
||||
source,
|
||||
|
|
@ -133,7 +134,7 @@ public sealed class GameWindowRenderLeafCompositionTests
|
|||
"new(\"frame profiler\", _frameProfiler.Dispose)");
|
||||
AssertAppearsInOrder(
|
||||
source,
|
||||
"_renderFrameOrchestrator = null;",
|
||||
"_frameGraphs.Withdraw();",
|
||||
"_devToolsBackend?.Dispose()",
|
||||
"new ResourceShutdownStage(\"input\"",
|
||||
"_input?.Dispose();",
|
||||
|
|
@ -175,7 +176,7 @@ public sealed class GameWindowRenderLeafCompositionTests
|
|||
Assert.True(start >= 0 && end > start);
|
||||
string body = source[start..end];
|
||||
|
||||
Assert.Equal(1, CountOccurrences(body, "_renderFrameOrchestrator!.Render("));
|
||||
Assert.Equal(1, CountOccurrences(body, "_frameGraphs.Render("));
|
||||
Assert.DoesNotContain("_gpuFrameFlights", body);
|
||||
Assert.DoesNotContain("_worldScene", body);
|
||||
Assert.DoesNotContain("_devToolsFramePresenter", body);
|
||||
|
|
|
|||
|
|
@ -67,17 +67,18 @@ public sealed class GameWindowSlice8BoundaryTests
|
|||
"_dats = RuntimeDatCollectionFactory.OpenReadOnly(_datDir);",
|
||||
"_runtimeSettings.ApplyStartup(",
|
||||
"new RuntimeSettingsStartupTargets(",
|
||||
"_uiHost = new AcDream.App.UI.UiHost(",
|
||||
"_uiHost = _retailUiLease.AcquireHost(",
|
||||
"_uiHost.WireMouse(m)",
|
||||
"_uiHost.WireKeyboard(kb)",
|
||||
"_retailUiRuntime = AcDream.App.UI.RetailUiRuntime.Mount(",
|
||||
"_retailUiRuntime = _retailUiLease.Mount(",
|
||||
"_liveEntities = new AcDream.App.World.LiveEntityRuntime(",
|
||||
"_selectionInteractions ??= new AcDream.App.Interaction.SelectionInteractionController(",
|
||||
"_retainedUiGameplayBinding =",
|
||||
"AcDream.App.Input.RetainedUiGameplayBinding.Create(",
|
||||
"_retainedUiGameplayBinding.Attach();",
|
||||
"_renderFrameOrchestrator =",
|
||||
"_updateFrameOrchestrator = new AcDream.App.Update.UpdateFrameOrchestrator(",
|
||||
"var renderFrameOrchestrator =",
|
||||
"var updateFrameOrchestrator = new AcDream.App.Update.UpdateFrameOrchestrator(",
|
||||
"_frameGraphs.Publish(updateFrameOrchestrator, renderFrameOrchestrator);",
|
||||
"_liveSessionHost = CreateLiveSessionHost();",
|
||||
"_liveCombatModeCommands.Bind(combatCommand);",
|
||||
"_runtimeDiagnosticCommands.Bind(runtimeDiagnostics);",
|
||||
|
|
@ -265,7 +266,7 @@ public sealed class GameWindowSlice8BoundaryTests
|
|||
shutdown,
|
||||
"_runtimeSettings.UnbindViewModel()",
|
||||
"_runtimeSettings.UnbindRuntimeTargets",
|
||||
"_retailUiRuntime?.Dispose()",
|
||||
"_retailUiLease.Dispose()",
|
||||
"_streamer?.Dispose()",
|
||||
"_wbDrawDispatcher?.Dispose()",
|
||||
"_terrain?.Dispose()");
|
||||
|
|
@ -292,12 +293,12 @@ public sealed class GameWindowSlice8BoundaryTests
|
|||
"private void OnClosing()",
|
||||
"private ResourceShutdownTransaction CreateShutdownTransaction()");
|
||||
|
||||
Assert.Equal(1, CountOccurrences(update, "_updateFrameOrchestrator.Tick("));
|
||||
Assert.Equal(1, CountOccurrences(render, "_renderFrameOrchestrator!.Render("));
|
||||
Assert.Equal(1, CountOccurrences(update, "_frameGraphs.Tick("));
|
||||
Assert.Equal(1, CountOccurrences(render, "_frameGraphs.Render("));
|
||||
AssertAppearsInOrder(
|
||||
render,
|
||||
"Vector2D<int> size = _window!.Size;",
|
||||
"_renderFrameOrchestrator!.Render(",
|
||||
"_frameGraphs.Render(",
|
||||
"new AcDream.App.Rendering.RenderFrameInput(");
|
||||
Assert.DoesNotContain("FramebufferSize", render, StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
|
|
@ -338,6 +339,8 @@ public sealed class GameWindowSlice8BoundaryTests
|
|||
"new ResourceShutdownStage(\"shared texture owners\"",
|
||||
"new ResourceShutdownStage(\"mesh adapter\"",
|
||||
"new ResourceShutdownStage(\"remaining render owners\"",
|
||||
"new ResourceShutdownStage(\"dedicated render resources\"",
|
||||
"new ResourceShutdownStage(\"failed render construction cleanup\"",
|
||||
"new ResourceShutdownStage(\"frame flight owner\"",
|
||||
"new ResourceShutdownStage(\"content mappings\"",
|
||||
"new ResourceShutdownStage(\"input\"",
|
||||
|
|
@ -390,6 +393,66 @@ public sealed class GameWindowSlice8BoundaryTests
|
|||
character => Assert.True(char.IsWhiteSpace(character) || character == '}'));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResourceRootsAndFramePairHaveExplicitAcquireTransferAndReleaseBoundaries()
|
||||
{
|
||||
string source = GameWindowSource();
|
||||
string load = MethodBody(
|
||||
"private void OnLoad()",
|
||||
"private AcDream.App.Net.LiveSessionHost");
|
||||
string shutdown = Slice(
|
||||
source,
|
||||
"private ResourceShutdownTransaction CreateShutdownTransaction()",
|
||||
"private void OnFocusChanged(bool focused)");
|
||||
string terrainAtlas = File.ReadAllText(Path.Combine(
|
||||
FindRepoRoot(),
|
||||
"src",
|
||||
"AcDream.App",
|
||||
"Rendering",
|
||||
"TerrainAtlas.cs"));
|
||||
|
||||
AssertAppearsInOrder(
|
||||
load,
|
||||
"_renderResourceLifetime.AcquireTerrainAtlas(",
|
||||
"_terrain = new TerrainModernRenderer(",
|
||||
"_portalTunnelFallback.AcquirePrepared(",
|
||||
"static portalTunnel => portalTunnel.PrepareResources());",
|
||||
"_renderResourceLifetime.AcquireSkyShader(",
|
||||
"_skyRenderer = new AcDream.App.Rendering.Sky.SkyRenderer(",
|
||||
"_portalTunnelFallback.Transfer(",
|
||||
"_frameGraphs.Publish(updateFrameOrchestrator, renderFrameOrchestrator);");
|
||||
AssertAppearsInOrder(
|
||||
shutdown,
|
||||
"_frameGraphs.Withdraw();",
|
||||
"_retailUiLease.Dispose();",
|
||||
"_localPlayerTeleport?.Dispose();",
|
||||
"_portalTunnelFallback.ReleaseFallback();",
|
||||
"_skyRenderer?.Dispose()",
|
||||
"_terrain?.Dispose();",
|
||||
"_renderResourceLifetime.ReleaseSkyShader",
|
||||
"_renderResourceLifetime.ReleaseTerrainAtlas",
|
||||
"_glConstructionCleanup.Dispose",
|
||||
"_gl?.Dispose();");
|
||||
|
||||
Assert.DoesNotContain(
|
||||
"RetailUiRuntime.Mount(",
|
||||
load,
|
||||
StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("gl.GenTexture()", terrainAtlas, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain(
|
||||
"portalTunnel.PrepareResources();",
|
||||
load,
|
||||
StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("_portalTunnel =", source, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("_renderFrameOrchestrator", source, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("_updateFrameOrchestrator", source, StringComparison.Ordinal);
|
||||
AssertAppearsInOrder(
|
||||
source,
|
||||
"_window.Run();",
|
||||
"_glConstructionCleanup.RetainFrom(failure);",
|
||||
"_glConstructionCleanup.Dispose");
|
||||
}
|
||||
|
||||
private static string MethodBody(string start, string end) =>
|
||||
Slice(GameWindowSource(), start, end);
|
||||
|
||||
|
|
|
|||
396
tests/AcDream.App.Tests/Rendering/GlTextureOwnershipTests.cs
Normal file
396
tests/AcDream.App.Tests/Rendering/GlTextureOwnershipTests.cs
Normal file
|
|
@ -0,0 +1,396 @@
|
|||
using AcDream.App.Rendering;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering;
|
||||
|
||||
public sealed class GlTextureOwnershipTests
|
||||
{
|
||||
[Fact]
|
||||
public void ConstructionRollbackDeletesAllNamesInReverseOrder()
|
||||
{
|
||||
var api = new FakeTextureNameApi();
|
||||
var transaction = new GlTextureConstructionTransaction(api);
|
||||
|
||||
Assert.Equal(1u, transaction.Allocate());
|
||||
Assert.Equal(2u, transaction.Allocate());
|
||||
Assert.Equal(3u, transaction.Allocate());
|
||||
|
||||
transaction.Rollback();
|
||||
transaction.Rollback();
|
||||
|
||||
Assert.Equal([3u, 2u, 1u], api.DeleteAttempts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConstructionRollbackAttemptsEveryNameAndReportsFailures()
|
||||
{
|
||||
var api = new FakeTextureNameApi { FailingDelete = 2 };
|
||||
var transaction = new GlTextureConstructionTransaction(api);
|
||||
_ = transaction.Allocate();
|
||||
_ = transaction.Allocate();
|
||||
_ = transaction.Allocate();
|
||||
|
||||
AggregateException failure = Assert.Throws<AggregateException>(transaction.Rollback);
|
||||
|
||||
Assert.Single(failure.InnerExceptions);
|
||||
Assert.Equal([3u, 2u, 1u], api.DeleteAttempts);
|
||||
Assert.Equal([3u, 1u], api.Deleted);
|
||||
|
||||
api.FailingDelete = null;
|
||||
transaction.RetryCleanup();
|
||||
|
||||
Assert.True(transaction.IsCleanupComplete);
|
||||
Assert.Equal([3u, 2u, 1u, 2u], api.DeleteAttempts);
|
||||
Assert.Equal([3u, 1u, 2u], api.Deleted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CommittedNamesAreNeverDeletedByConstructionTransaction()
|
||||
{
|
||||
var api = new FakeTextureNameApi();
|
||||
var transaction = new GlTextureConstructionTransaction(api);
|
||||
_ = transaction.Allocate();
|
||||
_ = transaction.Allocate();
|
||||
|
||||
transaction.Commit();
|
||||
transaction.Rollback();
|
||||
|
||||
Assert.Empty(api.DeleteAttempts);
|
||||
Assert.Throws<InvalidOperationException>(() => transaction.Allocate());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TrackedTextureUploadFailureLeavesEveryActualBranchNameInTransaction()
|
||||
{
|
||||
var api = new FakeTextureNameApi();
|
||||
var transaction = new GlTextureConstructionTransaction(api);
|
||||
uint first = TrackedTextureConstruction.Create(transaction, _ => { });
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
TrackedTextureConstruction.Create(
|
||||
transaction,
|
||||
_ => throw new InvalidOperationException("upload failed")));
|
||||
transaction.Rollback();
|
||||
|
||||
Assert.Equal(1u, first);
|
||||
Assert.Equal([2u, 1u], api.DeleteAttempts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TerrainAtlasRoutesEveryTerrainAlphaAndFallbackUploadThroughTracker()
|
||||
{
|
||||
string source = File.ReadAllText(Path.Combine(
|
||||
FindRepoRoot(),
|
||||
"src",
|
||||
"AcDream.App",
|
||||
"Rendering",
|
||||
"TerrainAtlas.cs"));
|
||||
|
||||
Assert.Equal(5, CountOccurrences(source, "TrackedTextureConstruction.Create("));
|
||||
Assert.Equal(
|
||||
5,
|
||||
System.Text.RegularExpressions.Regex.Matches(
|
||||
source,
|
||||
"TrackedTextureConstruction\\.Create\\(\\s*textures,\\s*gl,\\s*\\\"").Count);
|
||||
Assert.DoesNotContain("textures.Allocate()", source, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProductionGlResourcePathsUseAlwaysOnCheckedCommitBoundaries()
|
||||
{
|
||||
string root = FindRepoRoot();
|
||||
string textureNames = File.ReadAllText(Path.Combine(
|
||||
root, "src", "AcDream.App", "Rendering", "GlTextureConstructionTransaction.cs"));
|
||||
string shaderPrograms = File.ReadAllText(Path.Combine(
|
||||
root, "src", "AcDream.App", "Rendering", "ShaderProgramConstruction.cs"));
|
||||
string terrain = File.ReadAllText(Path.Combine(
|
||||
root, "src", "AcDream.App", "Rendering", "TerrainAtlas.cs"));
|
||||
string text = File.ReadAllText(Path.Combine(
|
||||
root, "src", "AcDream.App", "Rendering", "TextRenderer.cs"));
|
||||
string bindless = File.ReadAllText(Path.Combine(
|
||||
root, "src", "AcDream.App", "Rendering", "Wb", "BindlessSupport.cs"));
|
||||
|
||||
Assert.Contains("GlResourceCommand.CreateTexture", textureNames, StringComparison.Ordinal);
|
||||
Assert.Contains("GlResourceCommand.DeleteTexture", textureNames, StringComparison.Ordinal);
|
||||
Assert.Contains("GlResourceCommand.CreateName", shaderPrograms, StringComparison.Ordinal);
|
||||
Assert.Contains("GlResourceCommand.DeleteShader", shaderPrograms, StringComparison.Ordinal);
|
||||
Assert.Contains("GlResourceCommand.DeleteProgram", shaderPrograms, StringComparison.Ordinal);
|
||||
Assert.Contains("_anisotropyBindingMutation.Execute", terrain, StringComparison.Ordinal);
|
||||
Assert.Contains("GlResourceCommand.DeleteTexture", terrain, StringComparison.Ordinal);
|
||||
Assert.Contains("GlResourceCommand.CreateTexture", text, StringComparison.Ordinal);
|
||||
Assert.Contains("GlResourceCommand.Execute", text, StringComparison.Ordinal);
|
||||
Assert.Contains("make bindless handle", bindless, StringComparison.Ordinal);
|
||||
Assert.Contains("GlResourceCommand.Execute", bindless, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SecondBindlessAcquireFailureRollsBackFirstHandle()
|
||||
{
|
||||
var residency = new FakeResidency { FailingAcquireTexture = 20 };
|
||||
var pair = new BindlessTexturePair(10, 20, residency.Acquire, residency.Release);
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => pair.Acquire());
|
||||
|
||||
Assert.False(pair.IsFullyResident);
|
||||
Assert.Equal([10u, 20u], residency.AcquireAttempts);
|
||||
Assert.Equal([1010ul], residency.ReleaseAttempts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FailedPrefixRollbackRemainsOwnedAndRetryDoesNotReacquireIt()
|
||||
{
|
||||
var residency = new FakeResidency
|
||||
{
|
||||
FailingAcquireTexture = 20,
|
||||
FailingReleaseHandle = 1010,
|
||||
};
|
||||
var pair = new BindlessTexturePair(10, 20, residency.Acquire, residency.Release);
|
||||
|
||||
Assert.Throws<AggregateException>(() => pair.Acquire());
|
||||
residency.FailingAcquireTexture = null;
|
||||
residency.FailingReleaseHandle = null;
|
||||
|
||||
Assert.Equal((1010ul, 1020ul), pair.Acquire());
|
||||
|
||||
Assert.True(pair.IsFullyResident);
|
||||
Assert.Equal([10u, 20u, 20u], residency.AcquireAttempts);
|
||||
Assert.Equal([1010ul], residency.ReleaseAttempts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BindlessReleaseAttemptsBothAndRetriesOnlyPendingHandle()
|
||||
{
|
||||
var residency = new FakeResidency();
|
||||
var pair = new BindlessTexturePair(10, 20, residency.Acquire, residency.Release);
|
||||
_ = pair.Acquire();
|
||||
residency.FailingReleaseHandle = 1010;
|
||||
|
||||
Assert.Throws<AggregateException>(pair.Release);
|
||||
Assert.False(pair.IsFullyResident);
|
||||
Assert.True(pair.HasAnyResident);
|
||||
Assert.Equal([1010ul, 1020ul], residency.ReleaseAttempts);
|
||||
|
||||
residency.FailingReleaseHandle = null;
|
||||
pair.Release();
|
||||
pair.Release();
|
||||
|
||||
Assert.False(pair.HasAnyResident);
|
||||
Assert.Equal([1010ul, 1020ul, 1010ul], residency.ReleaseAttempts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MutationFailureStillRestoresThePreviouslyResidentPair()
|
||||
{
|
||||
var residency = new FakeResidency();
|
||||
var pair = new BindlessTexturePair(10, 20, residency.Acquire, residency.Release);
|
||||
var guard = new BindlessTextureMutationGuard(pair);
|
||||
_ = pair.Acquire();
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
guard.Execute(() => throw new InvalidOperationException("mutation failed")));
|
||||
|
||||
Assert.True(pair.IsFullyResident);
|
||||
Assert.False(guard.RestoreRequired);
|
||||
Assert.Equal([10u, 20u, 10u, 20u], residency.AcquireAttempts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FailedReacquireKeepsRestoreIntentUntilALaterMutationRetry()
|
||||
{
|
||||
var residency = new FakeResidency();
|
||||
var pair = new BindlessTexturePair(10, 20, residency.Acquire, residency.Release);
|
||||
var guard = new BindlessTextureMutationGuard(pair);
|
||||
_ = pair.Acquire();
|
||||
residency.FailingAcquireTexture = 20;
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => guard.Execute(() => { }));
|
||||
Assert.True(guard.RestoreRequired);
|
||||
Assert.False(pair.HasAnyResident);
|
||||
|
||||
residency.FailingAcquireTexture = null;
|
||||
guard.Execute(() => { });
|
||||
|
||||
Assert.False(guard.RestoreRequired);
|
||||
Assert.True(pair.IsFullyResident);
|
||||
Assert.Equal([10u, 20u, 10u, 20u, 10u, 20u], residency.AcquireAttempts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PartialReleaseFailurePreventsMutationAndRestoresPairBeforeThrowing()
|
||||
{
|
||||
var residency = new FakeResidency();
|
||||
var pair = new BindlessTexturePair(10, 20, residency.Acquire, residency.Release);
|
||||
var guard = new BindlessTextureMutationGuard(pair);
|
||||
_ = pair.Acquire();
|
||||
residency.FailingReleaseHandle = 1010;
|
||||
int mutations = 0;
|
||||
|
||||
Assert.Throws<AggregateException>(() => guard.Execute(() => mutations++));
|
||||
|
||||
Assert.Equal(0, mutations);
|
||||
Assert.True(pair.IsFullyResident);
|
||||
Assert.False(guard.RestoreRequired);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FailedTextureMutationRestoresTheExactPriorBinding()
|
||||
{
|
||||
var bindings = new List<uint>();
|
||||
var owner = new RestoredTextureBindingMutation();
|
||||
|
||||
InvalidOperationException failure = Assert.Throws<InvalidOperationException>(() =>
|
||||
owner.Execute(
|
||||
() => 77,
|
||||
bindings.Add,
|
||||
42,
|
||||
() => throw new InvalidOperationException("mutation failed")));
|
||||
|
||||
Assert.Equal("mutation failed", failure.Message);
|
||||
Assert.Equal([42u, 77u], bindings);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TextureMutationReportsBothMutationAndBindingRestoreFailure()
|
||||
{
|
||||
int bindCalls = 0;
|
||||
var owner = new RestoredTextureBindingMutation();
|
||||
|
||||
AggregateException failure = Assert.Throws<AggregateException>(() =>
|
||||
owner.Execute(
|
||||
() => 77,
|
||||
_ =>
|
||||
{
|
||||
bindCalls++;
|
||||
if (bindCalls == 2)
|
||||
throw new InvalidOperationException("restore failed");
|
||||
},
|
||||
42,
|
||||
() => throw new InvalidOperationException("mutation failed")));
|
||||
|
||||
Assert.Equal(2, failure.InnerExceptions.Count);
|
||||
Assert.Equal(2, bindCalls);
|
||||
Assert.True(owner.HasPendingRestore);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FailedBindingRestoreRetriesTheOriginalBindingBeforeAnotherMutation()
|
||||
{
|
||||
var owner = new RestoredTextureBindingMutation();
|
||||
var bindings = new List<uint>();
|
||||
int restoreFailures = 1;
|
||||
int mutations = 0;
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
owner.Execute(
|
||||
() => 77,
|
||||
binding =>
|
||||
{
|
||||
bindings.Add(binding);
|
||||
if (binding == 77 && restoreFailures-- > 0)
|
||||
throw new InvalidOperationException("restore failed");
|
||||
},
|
||||
42,
|
||||
() => mutations++));
|
||||
|
||||
Assert.True(owner.HasPendingRestore);
|
||||
owner.Execute(
|
||||
() => 77,
|
||||
bindings.Add,
|
||||
42,
|
||||
() => mutations++);
|
||||
|
||||
Assert.False(owner.HasPendingRestore);
|
||||
Assert.Equal([42u, 77u, 77u, 42u, 77u], bindings);
|
||||
Assert.Equal(2, mutations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConstructionCleanupLedgerRetainsNestedFailureUntilRetryCompletes()
|
||||
{
|
||||
var api = new FakeTextureNameApi { FailingDelete = 1 };
|
||||
var transaction = new GlTextureConstructionTransaction(api);
|
||||
_ = transaction.Allocate();
|
||||
AggregateException cleanupFailure = Assert.Throws<AggregateException>(
|
||||
transaction.Rollback);
|
||||
var constructionFailure = new GlResourceConstructionException(
|
||||
"synthetic construction failure",
|
||||
transaction,
|
||||
[new InvalidOperationException("build failed"), cleanupFailure]);
|
||||
var ledger = new GlConstructionCleanupLedger();
|
||||
|
||||
Assert.True(ledger.RetainFrom(constructionFailure));
|
||||
Assert.Throws<AggregateException>(ledger.Dispose);
|
||||
Assert.False(ledger.IsComplete);
|
||||
|
||||
api.FailingDelete = null;
|
||||
ledger.Dispose();
|
||||
|
||||
Assert.True(ledger.IsComplete);
|
||||
Assert.True(constructionFailure.IsCleanupComplete);
|
||||
}
|
||||
|
||||
private sealed class FakeTextureNameApi : IGlTextureNameApi
|
||||
{
|
||||
private uint _nextName = 1;
|
||||
|
||||
public uint? FailingDelete { get; set; }
|
||||
public List<uint> DeleteAttempts { get; } = [];
|
||||
public List<uint> Deleted { get; } = [];
|
||||
|
||||
public uint GenTexture() => _nextName++;
|
||||
|
||||
public void DeleteTexture(uint texture)
|
||||
{
|
||||
DeleteAttempts.Add(texture);
|
||||
if (FailingDelete == texture)
|
||||
throw new InvalidOperationException("delete failed");
|
||||
Deleted.Add(texture);
|
||||
}
|
||||
}
|
||||
|
||||
private static int CountOccurrences(string source, string value)
|
||||
{
|
||||
int count = 0;
|
||||
int cursor = 0;
|
||||
while ((cursor = source.IndexOf(value, cursor, StringComparison.Ordinal)) >= 0)
|
||||
{
|
||||
count++;
|
||||
cursor += value.Length;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private static string FindRepoRoot()
|
||||
{
|
||||
DirectoryInfo? directory = new(AppContext.BaseDirectory);
|
||||
while (directory is not null)
|
||||
{
|
||||
if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx")))
|
||||
return directory.FullName;
|
||||
directory = directory.Parent;
|
||||
}
|
||||
throw new DirectoryNotFoundException("Could not find AcDream.slnx.");
|
||||
}
|
||||
|
||||
private sealed class FakeResidency
|
||||
{
|
||||
public uint? FailingAcquireTexture { get; set; }
|
||||
public ulong? FailingReleaseHandle { get; set; }
|
||||
public List<uint> AcquireAttempts { get; } = [];
|
||||
public List<ulong> ReleaseAttempts { get; } = [];
|
||||
|
||||
public ulong Acquire(uint texture)
|
||||
{
|
||||
AcquireAttempts.Add(texture);
|
||||
if (FailingAcquireTexture == texture)
|
||||
throw new InvalidOperationException("acquire failed");
|
||||
return 1000ul + texture;
|
||||
}
|
||||
|
||||
public void Release(ulong handle)
|
||||
{
|
||||
ReleaseAttempts.Add(handle);
|
||||
if (FailingReleaseHandle == handle)
|
||||
throw new InvalidOperationException("release failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
122
tests/AcDream.App.Tests/Rendering/ResourceCleanupGroupTests.cs
Normal file
122
tests/AcDream.App.Tests/Rendering/ResourceCleanupGroupTests.cs
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
using AcDream.App.Rendering;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering;
|
||||
|
||||
public sealed class ResourceCleanupGroupTests
|
||||
{
|
||||
[Fact]
|
||||
public void CleanupRunsInReverseOrderAndNeverReplaysSuccess()
|
||||
{
|
||||
var calls = new List<string>();
|
||||
var resources = new ResourceCleanupGroup();
|
||||
int middleFailures = 1;
|
||||
resources.Add("first", () => calls.Add("first"));
|
||||
resources.Add("middle", () =>
|
||||
{
|
||||
calls.Add("middle");
|
||||
if (middleFailures-- > 0)
|
||||
throw new InvalidOperationException("middle failed");
|
||||
});
|
||||
resources.Add("last", () => calls.Add("last"));
|
||||
|
||||
Assert.Throws<AggregateException>(resources.RetryCleanup);
|
||||
Assert.Equal(["last", "middle", "first"], calls);
|
||||
|
||||
resources.RetryCleanup();
|
||||
resources.RetryCleanup();
|
||||
|
||||
Assert.True(resources.IsCleanupComplete);
|
||||
Assert.Equal(["last", "middle", "first", "middle"], calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TextRendererPublishesEveryConstructorResourceBeforeLaterGlWork()
|
||||
{
|
||||
string source = File.ReadAllText(Path.Combine(
|
||||
FindRepoRoot(),
|
||||
"src",
|
||||
"AcDream.App",
|
||||
"Rendering",
|
||||
"TextRenderer.cs"));
|
||||
|
||||
AssertAppearsInOrder(
|
||||
source,
|
||||
"shader = new Shader(gl,",
|
||||
"resources.Add(\"text shader\", shader.Dispose);",
|
||||
"frameBuffers[i] = CreateFrameBufferSet(resources);",
|
||||
"whiteTexture = GlResourceCommand.CreateTexture(",
|
||||
"resources.Add(",
|
||||
"\"white texture\"",
|
||||
"GlResourceCommand.Execute(",
|
||||
"initialize TextRenderer white texture",
|
||||
"resources.RetryCleanup();",
|
||||
"_resources = resources;");
|
||||
AssertAppearsInOrder(
|
||||
source,
|
||||
"private FrameBufferSet CreateFrameBufferSet(ResourceCleanupGroup resources)",
|
||||
"TrackedGlResource.CreateVertexArray(",
|
||||
"resources.Add(\"frame VAO\", vaoRelease.Run);",
|
||||
"TrackedGlResource.CreateBuffer(",
|
||||
"resources.Add(",
|
||||
"\"frame VBO\"");
|
||||
Assert.Contains("_resources.RetryCleanup();", source, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("private FrameBufferSet CreateFrameBufferSet()", source,
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReturnedNameIsRetainedWhenPostconditionAndFirstRollbackFail()
|
||||
{
|
||||
bool deleteFails = true;
|
||||
int deleteCalls = 0;
|
||||
|
||||
GlResourceConstructionException failure =
|
||||
Assert.Throws<GlResourceConstructionException>(() =>
|
||||
GlResourceCommand.CreateNameCore(
|
||||
"synthetic buffer",
|
||||
static () => { },
|
||||
() => 42,
|
||||
() => throw new InvalidOperationException("postcondition failed"),
|
||||
name =>
|
||||
{
|
||||
Assert.Equal(42u, name);
|
||||
deleteCalls++;
|
||||
if (deleteFails)
|
||||
throw new InvalidOperationException("delete failed");
|
||||
}));
|
||||
|
||||
Assert.False(failure.IsCleanupComplete);
|
||||
Assert.Equal(1, deleteCalls);
|
||||
|
||||
deleteFails = false;
|
||||
failure.RetryCleanup();
|
||||
failure.RetryCleanup();
|
||||
|
||||
Assert.True(failure.IsCleanupComplete);
|
||||
Assert.Equal(2, deleteCalls);
|
||||
}
|
||||
|
||||
private static void AssertAppearsInOrder(string source, params string[] values)
|
||||
{
|
||||
int cursor = -1;
|
||||
foreach (string value in values)
|
||||
{
|
||||
int next = source.IndexOf(value, cursor + 1, StringComparison.Ordinal);
|
||||
Assert.True(next > cursor, $"Missing or out-of-order source fragment: {value}");
|
||||
cursor = next;
|
||||
}
|
||||
}
|
||||
|
||||
private static string FindRepoRoot()
|
||||
{
|
||||
DirectoryInfo? directory = new(AppContext.BaseDirectory);
|
||||
while (directory is not null)
|
||||
{
|
||||
if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx")))
|
||||
return directory.FullName;
|
||||
directory = directory.Parent;
|
||||
}
|
||||
|
||||
throw new DirectoryNotFoundException("Could not find AcDream.slnx.");
|
||||
}
|
||||
}
|
||||
271
tests/AcDream.App.Tests/Rendering/RuntimeResourceSlotTests.cs
Normal file
271
tests/AcDream.App.Tests/Rendering/RuntimeResourceSlotTests.cs
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
using AcDream.App.Rendering;
|
||||
using AcDream.App.Update;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering;
|
||||
|
||||
public sealed class RuntimeResourceSlotTests
|
||||
{
|
||||
[Fact]
|
||||
public void OwnedSlotPublishesOnceAndRetriesOnlyFailedRelease()
|
||||
{
|
||||
var slot = new OwnedResourceSlot<RetryableResource>();
|
||||
var resource = new RetryableResource(remainingFailures: 1);
|
||||
|
||||
Assert.Same(resource, slot.Acquire(() => resource));
|
||||
Assert.Same(resource, slot.Borrow());
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
slot.Acquire(() => new RetryableResource()));
|
||||
|
||||
Assert.Throws<InvalidOperationException>(slot.Release);
|
||||
Assert.True(slot.HasResource);
|
||||
|
||||
slot.Release();
|
||||
slot.Release();
|
||||
|
||||
Assert.False(slot.HasResource);
|
||||
Assert.Equal(2, resource.DisposeCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TransferSlotKeepsFallbackUntilCompleteDestinationExists()
|
||||
{
|
||||
var slot = new TransferableResourceSlot<RetryableResource>();
|
||||
var resource = slot.Acquire(() => new RetryableResource());
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
slot.Transfer<object>(_ => throw new InvalidOperationException("destination failed")));
|
||||
Assert.True(slot.HasFallback);
|
||||
Assert.Same(resource, slot.Borrow());
|
||||
|
||||
var owner = slot.Transfer(value => new ResourceOwner(value));
|
||||
|
||||
Assert.False(slot.HasFallback);
|
||||
slot.ReleaseFallback();
|
||||
Assert.Equal(0, resource.DisposeCalls);
|
||||
|
||||
owner.Dispose();
|
||||
Assert.Equal(1, resource.DisposeCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TransferSlotReleasesOnlyFallbackOnPartialShutdown()
|
||||
{
|
||||
var fallback = new TransferableResourceSlot<RetryableResource>();
|
||||
RetryableResource fallbackResource = fallback.Acquire(
|
||||
() => new RetryableResource());
|
||||
|
||||
fallback.ReleaseFallback();
|
||||
fallback.ReleaseFallback();
|
||||
|
||||
Assert.Equal(1, fallbackResource.DisposeCalls);
|
||||
|
||||
var transferred = new TransferableResourceSlot<RetryableResource>();
|
||||
RetryableResource transferredResource = transferred.Acquire(
|
||||
() => new RetryableResource());
|
||||
ResourceOwner owner = transferred.Transfer(value => new ResourceOwner(value));
|
||||
|
||||
owner.Dispose();
|
||||
transferred.ReleaseFallback();
|
||||
|
||||
Assert.Equal(1, transferredResource.DisposeCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TransferSlotCoversAcquirePrepareAndPostTransferBindingPrefixes()
|
||||
{
|
||||
var acquisition = new TransferableResourceSlot<RetryableResource>();
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
acquisition.Acquire(() => throw new InvalidOperationException("create failed")));
|
||||
Assert.False(acquisition.HasFallback);
|
||||
|
||||
var preparation = new TransferableResourceSlot<RetryableResource>();
|
||||
var prepared = new RetryableResource(remainingPrepareFailures: 1);
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
preparation.AcquirePrepared(() => prepared, static value => value.Prepare()));
|
||||
Assert.True(preparation.HasFallback);
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
preparation.Transfer(value => new ResourceOwner(value)));
|
||||
Assert.Same(
|
||||
prepared,
|
||||
preparation.AcquirePrepared(
|
||||
() => throw new InvalidOperationException("factory replayed"),
|
||||
static value => value.Prepare()));
|
||||
Assert.Equal(2, prepared.PrepareCalls);
|
||||
preparation.ReleaseFallback();
|
||||
Assert.Equal(1, prepared.DisposeCalls);
|
||||
|
||||
var binding = new TransferableResourceSlot<RetryableResource>();
|
||||
RetryableResource transferred = binding.Acquire(() => new RetryableResource());
|
||||
ResourceOwner? owner = null;
|
||||
Assert.Throws<InvalidOperationException>((Action)(() =>
|
||||
{
|
||||
owner = binding.Transfer(value => new ResourceOwner(value));
|
||||
throw new InvalidOperationException("binding failed");
|
||||
}));
|
||||
Assert.False(binding.HasFallback);
|
||||
Assert.NotNull(owner);
|
||||
owner.Dispose();
|
||||
binding.ReleaseFallback();
|
||||
Assert.Equal(1, transferred.DisposeCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FrameGraphsPublishAtomicallyRouteAndBecomeSilentAfterWithdrawal()
|
||||
{
|
||||
var calls = new List<string>();
|
||||
var slot = new GameFrameGraphSlot();
|
||||
|
||||
Assert.False(slot.Tick(new UpdateFrameInput(0.25)));
|
||||
Assert.False(slot.Render(new RenderFrameInput(0.5, 800, 600), out _));
|
||||
|
||||
var update = new RecordingUpdateRoot(calls);
|
||||
var render = new RecordingRenderRoot(calls);
|
||||
slot.Publish(update, render);
|
||||
|
||||
Assert.True(slot.IsPublished);
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
slot.Publish(new RecordingUpdateRoot(calls), new RecordingRenderRoot(calls)));
|
||||
Assert.True(slot.Tick(new UpdateFrameInput(0.25)));
|
||||
Assert.True(slot.Render(new RenderFrameInput(0.5, 800, 600), out _));
|
||||
Assert.Equal(["update:0.25", "render:0.5:800:600"], calls);
|
||||
|
||||
slot.Withdraw();
|
||||
slot.Withdraw();
|
||||
|
||||
Assert.False(slot.IsPublished);
|
||||
Assert.False(slot.Tick(new UpdateFrameInput(1)));
|
||||
Assert.False(slot.Render(new RenderFrameInput(1, 1, 1), out _));
|
||||
Assert.Equal(2, calls.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithdrawnFrameSlotAcceptsACompleteReplacementPair()
|
||||
{
|
||||
var calls = new List<string>();
|
||||
var slot = new GameFrameGraphSlot();
|
||||
slot.Publish(new RecordingUpdateRoot(calls), new RecordingRenderRoot(calls));
|
||||
slot.Withdraw();
|
||||
|
||||
slot.Publish(new RecordingUpdateRoot(calls), new RecordingRenderRoot(calls));
|
||||
|
||||
Assert.True(slot.Tick(new UpdateFrameInput(2)));
|
||||
Assert.Equal(["update:2"], calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OwnedSlotRejectsReentrantAcquireWithoutLosingOuterResource()
|
||||
{
|
||||
var slot = new OwnedResourceSlot<RetryableResource>();
|
||||
var outer = new RetryableResource();
|
||||
int innerFactories = 0;
|
||||
|
||||
Assert.Same(outer, slot.Acquire(() =>
|
||||
{
|
||||
Assert.Throws<InvalidOperationException>(() => slot.Acquire(() =>
|
||||
{
|
||||
innerFactories++;
|
||||
return new RetryableResource();
|
||||
}));
|
||||
return outer;
|
||||
}));
|
||||
|
||||
Assert.Equal(0, innerFactories);
|
||||
Assert.Same(outer, slot.Borrow());
|
||||
slot.Release();
|
||||
Assert.Equal(1, outer.DisposeCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OwnedSlotRejectsReentrantReleaseWithoutDoubleDisposal()
|
||||
{
|
||||
var slot = new OwnedResourceSlot<CallbackResource>();
|
||||
var resource = new CallbackResource(() =>
|
||||
Assert.Throws<InvalidOperationException>(slot.Release));
|
||||
slot.Acquire(() => resource);
|
||||
|
||||
slot.Release();
|
||||
|
||||
Assert.Equal(1, resource.DisposeCalls);
|
||||
Assert.False(slot.HasResource);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TransferSlotRejectsNestedTransferAndPreservesOuterTransfer()
|
||||
{
|
||||
var slot = new TransferableResourceSlot<RetryableResource>();
|
||||
var resource = slot.Acquire(() => new RetryableResource());
|
||||
int nestedOwners = 0;
|
||||
|
||||
ResourceOwner owner = slot.Transfer(value =>
|
||||
{
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
slot.Transfer(nested =>
|
||||
{
|
||||
nestedOwners++;
|
||||
return new ResourceOwner(nested);
|
||||
}));
|
||||
return new ResourceOwner(value);
|
||||
});
|
||||
|
||||
Assert.Equal(0, nestedOwners);
|
||||
Assert.False(slot.HasFallback);
|
||||
owner.Dispose();
|
||||
Assert.Equal(1, resource.DisposeCalls);
|
||||
}
|
||||
|
||||
private sealed class RetryableResource(
|
||||
int remainingFailures = 0,
|
||||
int remainingPrepareFailures = 0) : IDisposable
|
||||
{
|
||||
private int _remainingFailures = remainingFailures;
|
||||
private int _remainingPrepareFailures = remainingPrepareFailures;
|
||||
|
||||
public int DisposeCalls { get; private set; }
|
||||
public int PrepareCalls { get; private set; }
|
||||
|
||||
public void Prepare()
|
||||
{
|
||||
PrepareCalls++;
|
||||
if (_remainingPrepareFailures-- > 0)
|
||||
throw new InvalidOperationException("prepare failed");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
DisposeCalls++;
|
||||
if (_remainingFailures-- > 0)
|
||||
throw new InvalidOperationException("synthetic release failure");
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ResourceOwner(RetryableResource resource) : IDisposable
|
||||
{
|
||||
public void Dispose() => resource.Dispose();
|
||||
}
|
||||
|
||||
private sealed class CallbackResource(Action onDispose) : IDisposable
|
||||
{
|
||||
public int DisposeCalls { get; private set; }
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
DisposeCalls++;
|
||||
onDispose();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RecordingUpdateRoot(List<string> calls) : IGameUpdateFrameRoot
|
||||
{
|
||||
public void Tick(UpdateFrameInput input) => calls.Add($"update:{input.HostDeltaSeconds}");
|
||||
}
|
||||
|
||||
private sealed class RecordingRenderRoot(List<string> calls) : IGameRenderFrameRoot
|
||||
{
|
||||
public RenderFrameOutcome Render(RenderFrameInput input)
|
||||
{
|
||||
calls.Add(
|
||||
$"render:{input.DeltaSeconds}:{input.ViewportWidth}:{input.ViewportHeight}");
|
||||
return default;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,197 @@
|
|||
using AcDream.App.Rendering;
|
||||
using Silk.NET.OpenGL;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering;
|
||||
|
||||
public sealed class ShaderProgramConstructionTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("ShaderSource:VertexShader", 1, 0)]
|
||||
[InlineData("CompileShader:VertexShader", 1, 0)]
|
||||
[InlineData("CreateShader:FragmentShader", 1, 0)]
|
||||
[InlineData("ShaderSource:FragmentShader", 2, 0)]
|
||||
[InlineData("CompileShader:FragmentShader", 2, 0)]
|
||||
[InlineData("CreateProgram", 2, 0)]
|
||||
[InlineData("AttachShader:VertexShader", 2, 1)]
|
||||
[InlineData("AttachShader:FragmentShader", 2, 1)]
|
||||
[InlineData("LinkProgram", 2, 1)]
|
||||
public void ThrowAfterEachAllocatedNameRollsBackEveryPublishedName(
|
||||
string failingOperation,
|
||||
int expectedShaderDeletes,
|
||||
int expectedProgramDeletes)
|
||||
{
|
||||
var api = new FakeShaderApi { ThrowOn = failingOperation };
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
ShaderProgramConstruction.Build(api, "vertex", "fragment"));
|
||||
|
||||
Assert.Equal(expectedShaderDeletes, api.DeletedShaders.Count);
|
||||
Assert.Equal(expectedProgramDeletes, api.DeletedPrograms.Count);
|
||||
Assert.Equal(api.CreatedShaders, api.DeletedShaders.Order().ToArray());
|
||||
Assert.Equal(api.CreatedPrograms, api.DeletedPrograms.Order().ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompileFailurePreservesOriginalFailureWhenRollbackIsClean()
|
||||
{
|
||||
var api = new FakeShaderApi { VertexCompiles = false };
|
||||
|
||||
InvalidOperationException failure = Assert.Throws<InvalidOperationException>(() =>
|
||||
ShaderProgramConstruction.Build(api, "vertex", "fragment"));
|
||||
|
||||
Assert.Contains("VertexShader compile failed", failure.Message);
|
||||
Assert.Equal([1u], api.DeletedShaders);
|
||||
Assert.Empty(api.CreatedPrograms);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LinkFailureDetachesAndDeletesBothShadersAndProgram()
|
||||
{
|
||||
var api = new FakeShaderApi { ProgramLinks = false };
|
||||
|
||||
InvalidOperationException failure = Assert.Throws<InvalidOperationException>(() =>
|
||||
ShaderProgramConstruction.Build(api, "vertex", "fragment"));
|
||||
|
||||
Assert.Contains("program link failed", failure.Message);
|
||||
Assert.Equal(["DetachShader:VertexShader", "DetachShader:FragmentShader"], api.DetachCalls);
|
||||
Assert.Equal([1u, 2u], api.DeletedShaders);
|
||||
Assert.Equal([3u], api.DeletedPrograms);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SuccessfulBuildCommitsOnlyProgramAndReleasesTemporaryShaders()
|
||||
{
|
||||
var api = new FakeShaderApi();
|
||||
|
||||
uint program = ShaderProgramConstruction.Build(api, "vertex", "fragment");
|
||||
|
||||
Assert.Equal(3u, program);
|
||||
Assert.Equal([1u, 2u], api.DeletedShaders);
|
||||
Assert.Empty(api.DeletedPrograms);
|
||||
Assert.Equal(["DetachShader:VertexShader", "DetachShader:FragmentShader"], api.DetachCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CleanupFailureStillAttemptsEveryOtherReleaseAndProgramRollback()
|
||||
{
|
||||
var api = new FakeShaderApi { ThrowOn = "DetachShader:VertexShader" };
|
||||
|
||||
AggregateException failure = Assert.Throws<AggregateException>(() =>
|
||||
ShaderProgramConstruction.Build(api, "vertex", "fragment"));
|
||||
|
||||
Assert.Single(failure.InnerExceptions);
|
||||
Assert.Equal(["DetachShader:VertexShader", "DetachShader:FragmentShader"], api.DetachCalls);
|
||||
Assert.Equal([1u, 2u], api.DeletedShaders);
|
||||
Assert.Equal([3u], api.DeletedPrograms);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConstructionAndRollbackFailuresAreReportedTogether()
|
||||
{
|
||||
var api = new FakeShaderApi
|
||||
{
|
||||
ProgramLinks = false,
|
||||
ThrowOn = "DeleteShader:VertexShader",
|
||||
};
|
||||
|
||||
GlResourceConstructionException failure = Assert.Throws<GlResourceConstructionException>(() =>
|
||||
ShaderProgramConstruction.Build(api, "vertex", "fragment"));
|
||||
|
||||
Assert.Equal(2, failure.InnerExceptions.Count);
|
||||
Assert.Contains("program link failed", failure.InnerExceptions[0].Message);
|
||||
Assert.Equal([1u, 2u], api.DeleteShaderAttempts);
|
||||
Assert.Equal([2u], api.DeletedShaders);
|
||||
Assert.Equal([3u], api.DeletedPrograms);
|
||||
|
||||
api.ThrowOn = null;
|
||||
failure.RetryCleanup();
|
||||
|
||||
Assert.True(failure.IsCleanupComplete);
|
||||
Assert.Equal([2u, 1u], api.DeletedShaders);
|
||||
Assert.Equal([1u, 2u, 1u], api.DeleteShaderAttempts);
|
||||
}
|
||||
|
||||
private sealed class FakeShaderApi : IShaderProgramBuildApi
|
||||
{
|
||||
private readonly Dictionary<uint, ShaderType> _shaderTypes = [];
|
||||
private uint _nextName = 1;
|
||||
|
||||
public string? ThrowOn { get; set; }
|
||||
public bool VertexCompiles { get; init; } = true;
|
||||
public bool FragmentCompiles { get; init; } = true;
|
||||
public bool ProgramLinks { get; init; } = true;
|
||||
public List<uint> CreatedShaders { get; } = [];
|
||||
public List<uint> CreatedPrograms { get; } = [];
|
||||
public List<uint> DeleteShaderAttempts { get; } = [];
|
||||
public List<uint> DeletedShaders { get; } = [];
|
||||
public List<uint> DeletedPrograms { get; } = [];
|
||||
public List<string> DetachCalls { get; } = [];
|
||||
|
||||
public uint CreateShader(ShaderType type)
|
||||
{
|
||||
Fail($"CreateShader:{type}");
|
||||
uint name = _nextName++;
|
||||
CreatedShaders.Add(name);
|
||||
_shaderTypes.Add(name, type);
|
||||
return name;
|
||||
}
|
||||
|
||||
public void ShaderSource(uint shader, string source) =>
|
||||
Fail($"ShaderSource:{TypeOf(shader)}");
|
||||
|
||||
public void CompileShader(uint shader) =>
|
||||
Fail($"CompileShader:{TypeOf(shader)}");
|
||||
|
||||
public int GetShaderCompileStatus(uint shader) => TypeOf(shader) switch
|
||||
{
|
||||
ShaderType.VertexShader => VertexCompiles ? 1 : 0,
|
||||
ShaderType.FragmentShader => FragmentCompiles ? 1 : 0,
|
||||
_ => 0,
|
||||
};
|
||||
|
||||
public string GetShaderInfoLog(uint shader) => $"bad {TypeOf(shader)}";
|
||||
|
||||
public uint CreateProgram()
|
||||
{
|
||||
Fail("CreateProgram");
|
||||
uint name = _nextName++;
|
||||
CreatedPrograms.Add(name);
|
||||
return name;
|
||||
}
|
||||
|
||||
public void AttachShader(uint program, uint shader) =>
|
||||
Fail($"AttachShader:{TypeOf(shader)}");
|
||||
|
||||
public void LinkProgram(uint program) => Fail("LinkProgram");
|
||||
public int GetProgramLinkStatus(uint program) => ProgramLinks ? 1 : 0;
|
||||
public string GetProgramInfoLog(uint program) => "bad link";
|
||||
|
||||
public void DetachShader(uint program, uint shader)
|
||||
{
|
||||
string operation = $"DetachShader:{TypeOf(shader)}";
|
||||
DetachCalls.Add(operation);
|
||||
Fail(operation);
|
||||
}
|
||||
|
||||
public void DeleteShader(uint shader)
|
||||
{
|
||||
DeleteShaderAttempts.Add(shader);
|
||||
Fail($"DeleteShader:{TypeOf(shader)}");
|
||||
DeletedShaders.Add(shader);
|
||||
}
|
||||
|
||||
public void DeleteProgram(uint program)
|
||||
{
|
||||
Fail("DeleteProgram");
|
||||
DeletedPrograms.Add(program);
|
||||
}
|
||||
|
||||
private ShaderType TypeOf(uint shader) => _shaderTypes[shader];
|
||||
|
||||
private void Fail(string operation)
|
||||
{
|
||||
if (ThrowOn == operation)
|
||||
throw new InvalidOperationException(operation + " failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -361,7 +361,7 @@ public sealed class WorldRenderFrameBuilderTests
|
|||
"var skyPesFrame = new AcDream.App.Rendering.SkyPesFrameController(",
|
||||
"var worldRenderFrameBuilder =",
|
||||
"new AcDream.App.Rendering.RenderFrameOrchestrator(",
|
||||
"_renderFrameOrchestrator = null;",
|
||||
"_frameGraphs.Withdraw();",
|
||||
"new(\"equipped children\"",
|
||||
"new(\"effect network state\"",
|
||||
"new(\"audio\"",
|
||||
|
|
|
|||
352
tests/AcDream.App.Tests/UI/RetailUiRuntimeLeaseTests.cs
Normal file
352
tests/AcDream.App.Tests/UI/RetailUiRuntimeLeaseTests.cs
Normal file
|
|
@ -0,0 +1,352 @@
|
|||
using AcDream.App.UI;
|
||||
|
||||
namespace AcDream.App.Tests.UI;
|
||||
|
||||
public sealed class RetailUiRuntimeLeaseTests
|
||||
{
|
||||
[Fact]
|
||||
public void ConstructorFailureLeavesHostOwnedUntilLifetimeDisposal()
|
||||
{
|
||||
var lease = new RetailUiRuntimeLease();
|
||||
var host = AcquireHost(lease);
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
lease.MountCore<FakeRuntime>(
|
||||
() => throw new InvalidOperationException("constructor failed"),
|
||||
static runtime => runtime.Initialize(),
|
||||
static runtime => runtime.Dispose(),
|
||||
static runtime => runtime.IsDisposalComplete));
|
||||
|
||||
Assert.True(lease.RetainsResources);
|
||||
Assert.Equal(0, host.DisposeCalls);
|
||||
|
||||
lease.Dispose();
|
||||
lease.Dispose();
|
||||
|
||||
Assert.True(lease.IsDisposalComplete);
|
||||
Assert.False(lease.RetainsResources);
|
||||
Assert.Equal(1, host.DisposeCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InitializeFailureCleansRuntimeAndHostExactlyOnce()
|
||||
{
|
||||
var lease = new RetailUiRuntimeLease();
|
||||
var host = AcquireHost(lease);
|
||||
var runtime = new FakeRuntime(host) { InitializeFailure = true };
|
||||
|
||||
InvalidOperationException failure = Assert.Throws<InvalidOperationException>(() =>
|
||||
Mount(lease, runtime));
|
||||
|
||||
Assert.Equal("initialize failed", failure.Message);
|
||||
Assert.True(lease.IsDisposalComplete);
|
||||
Assert.False(lease.RetainsResources);
|
||||
Assert.Equal(1, runtime.DisposeCalls);
|
||||
Assert.Equal(1, host.DisposeCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TransientInitializeCleanupFailureRetainsOwnershipForLifetimeRetry()
|
||||
{
|
||||
var lease = new RetailUiRuntimeLease();
|
||||
var host = AcquireHost(lease);
|
||||
var runtime = new FakeRuntime(host)
|
||||
{
|
||||
InitializeFailure = true,
|
||||
RemainingDisposeFailures = 1,
|
||||
};
|
||||
|
||||
AggregateException failure = Assert.Throws<AggregateException>(() =>
|
||||
Mount(lease, runtime));
|
||||
|
||||
Assert.Equal(2, failure.InnerExceptions.Count);
|
||||
Assert.True(lease.RetainsResources);
|
||||
Assert.False(lease.IsDisposalComplete);
|
||||
Assert.Equal(1, runtime.DisposeCalls);
|
||||
Assert.Equal(0, host.DisposeCalls);
|
||||
|
||||
lease.Dispose();
|
||||
|
||||
Assert.True(lease.IsDisposalComplete);
|
||||
Assert.False(lease.RetainsResources);
|
||||
Assert.Equal(2, runtime.DisposeCalls);
|
||||
Assert.Equal(1, host.DisposeCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PersistentCleanupFailureCanBeExplicitlyAbandonedAndBecomesInert()
|
||||
{
|
||||
var lease = new RetailUiRuntimeLease();
|
||||
var host = AcquireHost(lease);
|
||||
var runtime = new FakeRuntime(host)
|
||||
{
|
||||
InitializeFailure = true,
|
||||
RemainingDisposeFailures = int.MaxValue,
|
||||
};
|
||||
|
||||
Assert.Throws<AggregateException>(() => Mount(lease, runtime));
|
||||
Assert.Throws<InvalidOperationException>(lease.Dispose);
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
new RetailUiRuntimeLease().AbandonAfterTerminalFailure());
|
||||
|
||||
lease.AbandonAfterTerminalFailure();
|
||||
lease.Dispose();
|
||||
lease.QuiesceInput();
|
||||
lease.DeactivateInput();
|
||||
|
||||
Assert.True(lease.IsAbandoned);
|
||||
Assert.False(lease.IsDisposalComplete);
|
||||
Assert.True(lease.RetainsResources);
|
||||
Assert.Equal(2, runtime.DisposeCalls);
|
||||
Assert.Equal(0, host.DisposeCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QuiesceAndDeactivateAreSeparateIdempotentOperations()
|
||||
{
|
||||
var lease = new RetailUiRuntimeLease();
|
||||
var host = AcquireHost(lease);
|
||||
|
||||
lease.QuiesceInput();
|
||||
lease.QuiesceInput();
|
||||
lease.DeactivateInput();
|
||||
lease.DeactivateInput();
|
||||
|
||||
Assert.Equal(1, host.QuiesceCalls);
|
||||
Assert.Equal(1, host.DeactivateCalls);
|
||||
Assert.Equal(0, host.DisposeCalls);
|
||||
|
||||
lease.Dispose();
|
||||
|
||||
Assert.Equal(1, host.DisposeCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HostAndRuntimeMayOnlyBePublishedOnce()
|
||||
{
|
||||
var lease = new RetailUiRuntimeLease();
|
||||
var host = AcquireHost(lease);
|
||||
var runtime = new FakeRuntime(host);
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => AcquireHost(lease));
|
||||
Assert.Same(runtime, Mount(lease, runtime));
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
Mount(lease, new FakeRuntime(host)));
|
||||
|
||||
lease.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReentrantHostAndRuntimeFactoriesCannotOverwriteOuterOwnership()
|
||||
{
|
||||
var lease = new RetailUiRuntimeLease();
|
||||
var host = new FakeHost();
|
||||
int nestedHosts = 0;
|
||||
FakeHost acquired = lease.AcquireHostCore(
|
||||
() =>
|
||||
{
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
lease.AcquireHostCore(
|
||||
() =>
|
||||
{
|
||||
nestedHosts++;
|
||||
return new FakeHost();
|
||||
},
|
||||
static value => value.QuiesceInput(),
|
||||
static value => value.DeactivateInput(),
|
||||
static value => value.Dispose(),
|
||||
static value => value.IsDisposalComplete));
|
||||
return host;
|
||||
},
|
||||
static value => value.QuiesceInput(),
|
||||
static value => value.DeactivateInput(),
|
||||
static value => value.Dispose(),
|
||||
static value => value.IsDisposalComplete);
|
||||
var runtime = new FakeRuntime(host);
|
||||
int nestedRuntimes = 0;
|
||||
|
||||
FakeRuntime mounted = lease.MountCore(
|
||||
() =>
|
||||
{
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
lease.MountCore(
|
||||
() =>
|
||||
{
|
||||
nestedRuntimes++;
|
||||
return new FakeRuntime(host);
|
||||
},
|
||||
static value => value.Initialize(),
|
||||
static value => value.Dispose(),
|
||||
static value => value.IsDisposalComplete));
|
||||
return runtime;
|
||||
},
|
||||
static value => value.Initialize(),
|
||||
static value => value.Dispose(),
|
||||
static value => value.IsDisposalComplete);
|
||||
|
||||
Assert.Same(host, acquired);
|
||||
Assert.Same(runtime, mounted);
|
||||
Assert.Equal(0, nestedHosts);
|
||||
Assert.Equal(0, nestedRuntimes);
|
||||
lease.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReentrantDisposeIsRejectedWithoutDoubleDisposingRuntimeOrHost()
|
||||
{
|
||||
var lease = new RetailUiRuntimeLease();
|
||||
var host = AcquireHost(lease);
|
||||
var runtime = new FakeRuntime(host)
|
||||
{
|
||||
OnDispose = () =>
|
||||
Assert.Throws<InvalidOperationException>(lease.Dispose),
|
||||
};
|
||||
Mount(lease, runtime);
|
||||
|
||||
lease.Dispose();
|
||||
|
||||
Assert.Equal(1, runtime.DisposeCalls);
|
||||
Assert.Equal(1, host.DisposeCalls);
|
||||
Assert.True(lease.IsDisposalComplete);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HostFactoryCannotPrematurelyCompleteInputCutoffOperations()
|
||||
{
|
||||
var lease = new RetailUiRuntimeLease();
|
||||
var host = new FakeHost();
|
||||
|
||||
lease.AcquireHostCore(
|
||||
() =>
|
||||
{
|
||||
Assert.Throws<InvalidOperationException>(lease.QuiesceInput);
|
||||
Assert.Throws<InvalidOperationException>(lease.DeactivateInput);
|
||||
return host;
|
||||
},
|
||||
static value => value.QuiesceInput(),
|
||||
static value => value.DeactivateInput(),
|
||||
static value => value.Dispose(),
|
||||
static value => value.IsDisposalComplete);
|
||||
|
||||
lease.QuiesceInput();
|
||||
lease.DeactivateInput();
|
||||
|
||||
Assert.Equal(1, host.QuiesceCalls);
|
||||
Assert.Equal(1, host.DeactivateCalls);
|
||||
lease.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InputCutoffRejectsReentrantMutationAndRetriesFailedCallback()
|
||||
{
|
||||
var lease = new RetailUiRuntimeLease();
|
||||
var host = new FakeHost();
|
||||
int quiesceAttempts = 0;
|
||||
int deactivateAttempts = 0;
|
||||
lease.AcquireHostCore(
|
||||
() => host,
|
||||
_ =>
|
||||
{
|
||||
quiesceAttempts++;
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
lease.MountCore(
|
||||
() => new FakeRuntime(host),
|
||||
static value => value.Initialize(),
|
||||
static value => value.Dispose(),
|
||||
static value => value.IsDisposalComplete));
|
||||
if (quiesceAttempts == 1)
|
||||
throw new InvalidOperationException("quiesce failed");
|
||||
host.QuiesceInput();
|
||||
},
|
||||
_ =>
|
||||
{
|
||||
deactivateAttempts++;
|
||||
Assert.Throws<InvalidOperationException>(lease.Dispose);
|
||||
if (deactivateAttempts == 1)
|
||||
throw new InvalidOperationException("deactivate failed");
|
||||
host.DeactivateInput();
|
||||
},
|
||||
static value => value.Dispose(),
|
||||
static value => value.IsDisposalComplete);
|
||||
|
||||
Assert.Throws<InvalidOperationException>(lease.QuiesceInput);
|
||||
lease.QuiesceInput();
|
||||
Assert.Throws<InvalidOperationException>(lease.DeactivateInput);
|
||||
lease.DeactivateInput();
|
||||
|
||||
Assert.Equal(2, quiesceAttempts);
|
||||
Assert.Equal(2, deactivateAttempts);
|
||||
Assert.Equal(1, host.QuiesceCalls);
|
||||
Assert.Equal(1, host.DeactivateCalls);
|
||||
lease.Dispose();
|
||||
}
|
||||
|
||||
private static FakeHost AcquireHost(RetailUiRuntimeLease lease)
|
||||
{
|
||||
var host = new FakeHost();
|
||||
return lease.AcquireHostCore(
|
||||
() => host,
|
||||
static value => value.QuiesceInput(),
|
||||
static value => value.DeactivateInput(),
|
||||
static value => value.Dispose(),
|
||||
static value => value.IsDisposalComplete);
|
||||
}
|
||||
|
||||
private static FakeRuntime Mount(
|
||||
RetailUiRuntimeLease lease,
|
||||
FakeRuntime runtime) => lease.MountCore(
|
||||
() => runtime,
|
||||
static value => value.Initialize(),
|
||||
static value => value.Dispose(),
|
||||
static value => value.IsDisposalComplete);
|
||||
|
||||
private sealed class FakeHost : IDisposable
|
||||
{
|
||||
public int QuiesceCalls { get; private set; }
|
||||
public int DeactivateCalls { get; private set; }
|
||||
public int DisposeCalls { get; private set; }
|
||||
public bool IsDisposalComplete { get; private set; }
|
||||
|
||||
public void QuiesceInput() => QuiesceCalls++;
|
||||
|
||||
public void DeactivateInput() => DeactivateCalls++;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (IsDisposalComplete)
|
||||
return;
|
||||
|
||||
DisposeCalls++;
|
||||
IsDisposalComplete = true;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeRuntime(FakeHost host) : IDisposable
|
||||
{
|
||||
public bool InitializeFailure { get; init; }
|
||||
public int RemainingDisposeFailures { get; set; }
|
||||
public int DisposeCalls { get; private set; }
|
||||
public bool IsDisposalComplete { get; private set; }
|
||||
public Action? OnDispose { get; init; }
|
||||
|
||||
public void Initialize()
|
||||
{
|
||||
if (InitializeFailure)
|
||||
throw new InvalidOperationException("initialize failed");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (IsDisposalComplete)
|
||||
return;
|
||||
|
||||
DisposeCalls++;
|
||||
OnDispose?.Invoke();
|
||||
if (RemainingDisposeFailures-- > 0)
|
||||
throw new InvalidOperationException("dispose failed");
|
||||
|
||||
host.Dispose();
|
||||
IsDisposalComplete = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -362,7 +362,7 @@ public sealed class UpdateFrameOrchestratorTests
|
|||
Assert.DoesNotContain("_streamingFrame", source, StringComparison.Ordinal);
|
||||
Assert.Equal(1, CountOccurrences(
|
||||
source,
|
||||
"_updateFrameOrchestrator.Tick("));
|
||||
"_frameGraphs.Tick("));
|
||||
Assert.DoesNotContain("_streamingController.Tick(observerCx", source,
|
||||
StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("DungeonStreamingGate.Compute", source,
|
||||
|
|
@ -650,7 +650,7 @@ public sealed class UpdateFrameOrchestratorTests
|
|||
"GameWindow.cs"));
|
||||
Assert.Equal(1, CountOccurrences(
|
||||
source,
|
||||
"_updateFrameOrchestrator.Tick("));
|
||||
"_frameGraphs.Tick("));
|
||||
Assert.DoesNotContain("CanAdvanceLocalPlayer", source, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("GetCombatCameraTargetPoint()", source,
|
||||
StringComparison.Ordinal);
|
||||
|
|
@ -686,7 +686,7 @@ public sealed class UpdateFrameOrchestratorTests
|
|||
|
||||
Assert.Equal(1, CountOccurrences(
|
||||
source,
|
||||
"_updateFrameOrchestrator.Tick("));
|
||||
"_frameGraphs.Tick("));
|
||||
Assert.DoesNotContain("_updateFrameClock.Advance(", source,
|
||||
StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("_liveFrameCoordinator", source,
|
||||
|
|
@ -699,7 +699,7 @@ public sealed class UpdateFrameOrchestratorTests
|
|||
source,
|
||||
"private void OnUpdate(double dt)",
|
||||
"_frameProfiler.BeginStage(",
|
||||
"_updateFrameOrchestrator.Tick(",
|
||||
"_frameGraphs.Tick(",
|
||||
"private void OnRender(double deltaSeconds)");
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue