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
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue