acdream/tests/AcDream.App.Tests/Rendering/GlTextureOwnershipTests.cs
Erik c87b15303d 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>
2026-07-22 14:42:14 +02:00

396 lines
14 KiB
C#

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