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:
Erik 2026-07-22 14:42:14 +02:00
parent fec0d94148
commit c87b15303d
41 changed files with 3768 additions and 355 deletions

View file

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

View file

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

View 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");
}
}
}

View 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.");
}
}

View 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;
}
}
}

View file

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

View file

@ -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\"",