refactor(app): extract focused window lifetime

Move the exact retryable shutdown manifest, typed root snapshot, terminal reporting, and native-window-last release out of GameWindow. Keep session and GPU convergence as hard barriers while reporting persistent physical callback cleanup without stranding dependent owners.

Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-22 19:43:52 +02:00
parent 5a55d08106
commit 31e6e192b3
21 changed files with 1297 additions and 572 deletions

View file

@ -0,0 +1,212 @@
using AcDream.App.Rendering;
namespace AcDream.App.Tests.Rendering;
public sealed class GameWindowLifetimeTests
{
[Fact]
public void CleanCompletionReleasesNativeWindowLastAndTerminalCallsAreInert()
{
var calls = new List<string>();
var transaction = new ResourceShutdownTransaction(
new ResourceShutdownStage("owners",
[
new("owner", () => calls.Add("owner")),
]));
var lifetime = new GameWindowLifetime(() => transaction);
var native = new RecordingDisposable("native", calls);
lifetime.PublishNativeWindow(native);
GameWindowLifetimeReport closing = lifetime.TryComplete();
Assert.Equal(GameWindowLifetimeStatus.Complete, closing.Status);
Assert.Equal(["owner"], calls);
GameWindowLifetimeReport disposed = lifetime.CompleteAndReleaseNativeWindow();
GameWindowLifetimeReport repeated = lifetime.CompleteAndReleaseNativeWindow();
Assert.Same(closing, disposed);
Assert.Same(disposed, repeated);
Assert.Equal(["owner", "native"], calls);
Assert.Equal(1, native.DisposeCalls);
Assert.True(lifetime.HasShutdownRoots);
Assert.False(lifetime.RetainsShutdownGraph);
}
[Fact]
public void HardBarrierFailureRetriesWithoutReplayThenCompletes()
{
int stableCalls = 0;
int pendingCalls = 0;
bool allowPending = false;
var transaction = new ResourceShutdownTransaction(
new ResourceShutdownStage("session barrier",
[
new("stable", () => stableCalls++),
new("pending", () =>
{
pendingCalls++;
if (!allowPending)
throw new InvalidOperationException("pending");
}),
]));
var lifetime = new GameWindowLifetime(() => transaction);
GameWindowLifetimeReport first = lifetime.TryComplete();
Assert.Equal(GameWindowLifetimeStatus.RetryableIncomplete, first.Status);
Assert.Equal("session barrier", first.BlockedStage);
int failedAttempts = pendingCalls;
allowPending = true;
GameWindowLifetimeReport second = lifetime.CompleteAndReleaseNativeWindow();
Assert.Equal(GameWindowLifetimeStatus.Complete, second.Status);
Assert.Equal(1, stableCalls);
Assert.Equal(failedAttempts + 1, pendingCalls);
}
[Fact]
public void PersistentSoftFailureCompletesAndRetainsStructuredReport()
{
int dependentCalls = 0;
var physicalFailure = new InvalidOperationException("remove failed");
var transaction = new ResourceShutdownTransaction(
new ResourceShutdownStage("physical ingress cleanup",
[
new(
"mouse",
() => throw physicalFailure,
ResourceShutdownOperationPolicy.ReportAndContinue),
]),
new ResourceShutdownStage("dependents",
[
new("dependent", () => dependentCalls++),
]));
var lifetime = new GameWindowLifetime(() => transaction);
GameWindowLifetimeReport report = lifetime.CompleteAndReleaseNativeWindow();
Assert.Equal(GameWindowLifetimeStatus.CompleteWithCleanupFailures, report.Status);
Assert.Equal(1, dependentCalls);
ResourceShutdownCleanupFailure cleanup = Assert.Single(report.CleanupFailures);
Assert.Equal("physical ingress cleanup", cleanup.Stage);
Assert.Equal("mouse", cleanup.Operation);
Assert.Same(physicalFailure, cleanup.Error);
Assert.Same(report, lifetime.CompleteAndReleaseNativeWindow());
Assert.False(lifetime.RetainsShutdownGraph);
}
[Fact]
public void PersistentHardFailureUsesNativeFallbackAndBecomesTerminal()
{
var calls = new List<string>();
int attempts = 0;
var transaction = new ResourceShutdownTransaction(
new ResourceShutdownStage("GPU barrier",
[
new("drain", () =>
{
attempts++;
throw new InvalidOperationException("driver wedged");
}),
]),
new ResourceShutdownStage("must stay protected",
[
new("dependent", () => calls.Add("dependent")),
]));
var lifetime = new GameWindowLifetime(() => transaction);
var native = new RecordingDisposable("native", calls);
lifetime.PublishNativeWindow(native);
GameWindowLifetimeReport report = lifetime.CompleteAndReleaseNativeWindow();
int terminalAttempts = attempts;
Assert.Equal(GameWindowLifetimeStatus.AbandonedIncomplete, report.Status);
Assert.Equal("GPU barrier", report.BlockedStage);
Assert.NotNull(report.Error);
Assert.Equal(["native"], calls);
Assert.Same(report, lifetime.TryComplete());
Assert.Same(report, lifetime.CompleteAndReleaseNativeWindow());
Assert.Equal(terminalAttempts, attempts);
Assert.Equal(1, native.DisposeCalls);
Assert.True(lifetime.RetainsShutdownGraph);
}
[Fact]
public void NativeReleaseFailureIsTerminalAndNeverRetried()
{
var transaction = new ResourceShutdownTransaction();
var lifetime = new GameWindowLifetime(() => transaction);
var native = new ThrowingDisposable();
lifetime.PublishNativeWindow(native);
GameWindowLifetimeReport report = lifetime.CompleteAndReleaseNativeWindow();
Assert.Equal(GameWindowLifetimeStatus.AbandonedIncomplete, report.Status);
Assert.Equal("native window", report.BlockedStage);
Assert.Equal(1, native.DisposeCalls);
Assert.Same(report, lifetime.CompleteAndReleaseNativeWindow());
Assert.Equal(1, native.DisposeCalls);
Assert.True(lifetime.RetainsShutdownGraph);
}
[Fact]
public void ReentrantCompletionDoesNotAbandonOrReplayOuterProgress()
{
GameWindowLifetime? lifetime = null;
GameWindowLifetimeReport? nested = null;
int calls = 0;
var transaction = new ResourceShutdownTransaction(
new ResourceShutdownStage("owner",
[
new("reentrant", () =>
{
calls++;
nested = lifetime!.CompleteAndReleaseNativeWindow();
}),
]));
lifetime = new GameWindowLifetime(() => transaction);
GameWindowLifetimeReport outer = lifetime.TryComplete();
Assert.NotNull(nested);
Assert.Equal(GameWindowLifetimeStatus.Active, nested.Status);
Assert.Equal(GameWindowLifetimeStatus.Complete, outer.Status);
Assert.Equal(1, calls);
}
[Fact]
public void ConstructedNeverRunCompletesWithoutNativeWindow()
{
var lifetime = new GameWindowLifetime(
static () => new ResourceShutdownTransaction());
GameWindowLifetimeReport report = lifetime.CompleteAndReleaseNativeWindow();
Assert.Equal(GameWindowLifetimeStatus.Complete, report.Status);
Assert.Same(report, lifetime.CompleteAndReleaseNativeWindow());
Assert.False(lifetime.RetainsShutdownGraph);
}
private sealed class RecordingDisposable(string name, List<string> calls)
: IDisposable
{
public int DisposeCalls { get; private set; }
public void Dispose()
{
DisposeCalls++;
calls.Add(name);
}
}
private sealed class ThrowingDisposable : IDisposable
{
public int DisposeCalls { get; private set; }
public void Dispose()
{
DisposeCalls++;
throw new InvalidOperationException("native failed");
}
}
}

View file

@ -133,33 +133,37 @@ public sealed class GameWindowRenderLeafCompositionTests
[Fact]
public void Shutdown_PreservesBorrowedDevtoolsLifetimeAndDrainsGpuBeforeFrontends()
{
string source = GameWindowSource();
string source = GameWindowLifetimeSource();
AssertAppearsInOrder(
source,
"new ResourceShutdownStage(\"submitted GPU work\"",
"new ResourceShutdownStage(\"render frontends\"",
"new(\"developer tools\"",
"owner.DisposeFrontend()",
"new(\"portal tunnel\"",
"new(\"paperdoll viewport\"",
"Hard(\"developer tools\", () => DisposeDevToolsFrontend(render.DevTools))",
"Hard(\"portal tunnel\"",
"Hard(\"paperdoll viewport\"",
"new ResourceShutdownStage(\"OpenGL context\"");
AssertAppearsInOrder(
source,
"private static void DisposeDevToolsFrontend(DevToolsCompositionOwner? owner)",
"owner.DisposeFrontend()",
"if (!owner.IsFrontendDisposalComplete)");
AssertAppearsInOrder(
source,
"new ResourceShutdownStage(\"frame borrowers\"",
"publication.Dispose();",
"bindings.Dispose();",
"frame.FrameGraphPublication?.Dispose()",
"frame.FrameBindings?.Dispose()",
"new ResourceShutdownStage(\"session dependents\"");
AssertAppearsInOrder(
source,
"new(\"frame pacing\", _displayFramePacing.Dispose)",
"new(\"frame profiler\", _frameProfiler.Dispose)");
"Hard(\"frame pacing\", render.FramePacing.Dispose)",
"Hard(\"frame profiler\", render.FrameProfiler.Dispose)");
AssertAppearsInOrder(
source,
"publication.Dispose();",
"owner.DisposeFrontend()",
"new ResourceShutdownStage(\"input\"",
"_input?.Dispose();",
"frame.FrameGraphPublication?.Dispose()",
"Hard(\"developer tools\", () => DisposeDevToolsFrontend(render.DevTools))",
"new ResourceShutdownStage(\"input context\"",
"platform.Input?.Dispose()",
"new ResourceShutdownStage(\"OpenGL context\"");
}
@ -239,6 +243,13 @@ public sealed class GameWindowRenderLeafCompositionTests
"Rendering",
"GameWindow.cs"));
private static string GameWindowLifetimeSource() => File.ReadAllText(Path.Combine(
FindRepoRoot(),
"src",
"AcDream.App",
"Rendering",
"GameWindowLifetime.cs"));
private static string LivePresentationSource() => File.ReadAllText(Path.Combine(
FindRepoRoot(),
"src",

View file

@ -233,9 +233,7 @@ public sealed class GameWindowSlice8BoundaryTests
"AcDream.App",
"Composition",
"SessionPlayerComposition.cs"));
string shutdown = MethodBody(
"private ResourceShutdownTransaction CreateShutdownTransaction()",
"private void OnFocusChanged(bool focused)");
string shutdown = GameWindowLifetimeSource();
Assert.DoesNotContain("private void OnInputAction(", source, StringComparison.Ordinal);
Assert.DoesNotContain("private void SetInputCombatScope(", source, StringComparison.Ordinal);
@ -256,15 +254,17 @@ public sealed class GameWindowSlice8BoundaryTests
"retainedGameplayLease.Resource.Attach();"));
AssertAppearsInOrder(
shutdown,
"_liveCombatModeCommands.Deactivate",
"_runtimeDiagnosticCommands.Deactivate",
"_retainedUiGameplayBinding?.Deactivate()",
"_gameplayInputActions?.Deactivate()",
"new ResourceShutdownStage(\"session lifetime\"",
"binding.Dispose();",
"_retainedUiGameplayBinding = null;",
"actions.Dispose();",
"_gameplayInputActions = null;");
"Hard(\"combat command slot\", ingress.CombatCommands.Deactivate)",
"Hard(\"diagnostic command slot\", ingress.DiagnosticCommands.Deactivate)",
"Hard(\"retained gameplay\", () => ingress.RetainedGameplay?.Deactivate())",
"Hard(\"gameplay actions\", () => ingress.GameplayActions?.Deactivate())",
"Hard(\"live session\", () => DisposeLiveSession(ingress.LiveSession))",
"new ResourceShutdownStage(\"physical ingress cleanup\"",
"Soft(\"retained gameplay\", () => DisposeRetainedGameplay(ingress.RetainedGameplay))",
"Soft(\"gameplay actions\", () => DisposeGameplayActions(ingress.GameplayActions))",
"Soft(\"camera pointer\", () => DisposeCameraPointer(ingress.CameraPointer))",
"new ResourceShutdownStage(\"session dependents\"",
"Hard(\"mouse capture\", () => live.CameraPointer?.ReleaseMouseLookAfterSessionRetirement())");
}
[Fact]
@ -310,9 +310,7 @@ public sealed class GameWindowSlice8BoundaryTests
string load = MethodBody(
"private void OnLoad()",
"private void OnUpdate(double dt)");
string shutdown = MethodBody(
"private ResourceShutdownTransaction CreateShutdownTransaction()",
"private void OnFocusChanged(bool focused)");
string shutdown = GameWindowLifetimeSource();
Assert.Contains(
"private readonly RuntimeSettingsController _runtimeSettings;",
@ -390,13 +388,13 @@ public sealed class GameWindowSlice8BoundaryTests
"_factory.CreateTerrain(");
AssertAppearsInOrder(
shutdown,
"_runtimeSettings.UnbindViewModel()",
"Soft(\"settings view model\", () => ingress.Settings.UnbindViewModel())",
"new ResourceShutdownStage(\"frame borrowers\"",
"bindings.Dispose();",
"_retailUiLease.Dispose()",
"_streamer?.Dispose()",
"_wbDrawDispatcher?.Dispose()",
"_terrain?.Dispose()");
"Hard(\"frame-root bindings\", () => frame.FrameBindings?.Dispose())",
"Hard(\"retail UI\", () => DisposeRetailUi(live.RetailUi))",
"Hard(\"streamer\", () => live.Streamer?.Dispose())",
"Hard(\"mesh draw dispatcher\", () => render.DrawDispatcher?.Dispose())",
"Hard(\"terrain\", () => render.Terrain?.Dispose())");
}
[Fact]
@ -418,7 +416,7 @@ public sealed class GameWindowSlice8BoundaryTests
string close = Slice(
source,
"private void OnClosing()",
"private ResourceShutdownTransaction CreateShutdownTransaction()");
"private void OnFocusChanged(bool focused)");
Assert.Equal(1, CountOccurrences(update, "_frameGraphs.Tick("));
Assert.Equal(1, CountOccurrences(render, "_frameGraphs.Render("));
@ -432,30 +430,42 @@ public sealed class GameWindowSlice8BoundaryTests
"=> _cameraPointerInput?.HandleFocusChanged(focused);",
focus,
StringComparison.Ordinal);
Assert.Contains(
"private void OnClosing() => CompleteShutdown(releaseNativeWindow: false);",
close,
StringComparison.Ordinal);
AssertAppearsInOrder(
close,
"_hostQuiescence.StopAccepting();",
"CompleteShutdown();",
"_shutdown.CompleteOrThrow();");
"private void CompleteShutdown(bool releaseNativeWindow)",
"if (!_lifetime.HasShutdownRoots)",
"_lifetime.PublishShutdownRoots(CaptureShutdownRoots());",
"? _lifetime.CompleteAndReleaseNativeWindow()",
": _lifetime.TryComplete();");
Assert.DoesNotContain("new ResourceShutdownStage(", close, StringComparison.Ordinal);
Assert.DoesNotContain("CompleteOrThrow", close, StringComparison.Ordinal);
}
[Fact]
public void Shutdown_PreservesDependencyStagesAndNativeWindowLast()
{
string source = GameWindowSource();
string lifetime = GameWindowLifetimeSource();
string manifest = Slice(
source,
"private ResourceShutdownTransaction CreateShutdownTransaction()",
"private void OnFocusChanged(bool focused)");
lifetime,
"internal static class GameWindowShutdownManifest",
"private static ResourceShutdownOperation Hard");
string nativeRelease = Slice(
lifetime,
"public GameWindowLifetimeReport CompleteAndReleaseNativeWindow()",
"private void EnsureTransaction()");
int disposeStart = source.IndexOf("public void Dispose()", StringComparison.Ordinal);
Assert.True(disposeStart >= 0);
string dispose = source[disposeStart..];
string[] stages =
[
"new ResourceShutdownStage(\"input callback deactivation\"",
"new ResourceShutdownStage(\"session lifetime\"",
"new ResourceShutdownStage(\"input callback detach\"",
"new ResourceShutdownStage(\"host and session barriers\"",
"new ResourceShutdownStage(\"physical ingress cleanup\"",
"new ResourceShutdownStage(\"frame borrowers\"",
"new ResourceShutdownStage(\"session dependents\"",
"new ResourceShutdownStage(\"live entities\"",
@ -470,7 +480,7 @@ public sealed class GameWindowSlice8BoundaryTests
"new ResourceShutdownStage(\"failed render construction cleanup\"",
"new ResourceShutdownStage(\"frame flight owner\"",
"new ResourceShutdownStage(\"content mappings\"",
"new ResourceShutdownStage(\"input\"",
"new ResourceShutdownStage(\"input context\"",
"new ResourceShutdownStage(\"OpenGL context\"",
];
AssertAppearsInOrder(manifest, stages);
@ -479,32 +489,38 @@ public sealed class GameWindowSlice8BoundaryTests
Assert.Equal(1, CountOccurrences(manifest, stage));
AssertAppearsInOrder(
manifest,
"binding.Dispose();",
"if (!binding.IsDisposalComplete)",
"_windowCallbacks = null;");
AssertAppearsInOrder(
manifest,
"new(\"combat command slot\", _liveCombatModeCommands.Deactivate)",
"new(\"diagnostic command slot\", _runtimeDiagnosticCommands.Deactivate)",
"new(\"retained gameplay\", () => _retainedUiGameplayBinding?.Deactivate())",
"new(\"gameplay actions\", () => _gameplayInputActions?.Deactivate())",
"new(\"camera pointer\", () => _cameraPointerInput?.Deactivate())",
"new ResourceShutdownStage(\"session lifetime\"",
"controller.Dispose();",
"new ResourceShutdownStage(\"input callback detach\"",
"binding.Dispose();",
"actions.Dispose();",
"pointer.Dispose();",
"Hard(\"combat command slot\", ingress.CombatCommands.Deactivate)",
"Hard(\"diagnostic command slot\", ingress.DiagnosticCommands.Deactivate)",
"Hard(\"retained gameplay\", () => ingress.RetainedGameplay?.Deactivate())",
"Hard(\"gameplay actions\", () => ingress.GameplayActions?.Deactivate())",
"Hard(\"camera pointer\", () => ingress.CameraPointer?.Deactivate())",
"Hard(\"live session\", () => DisposeLiveSession(ingress.LiveSession))",
"new ResourceShutdownStage(\"physical ingress cleanup\"",
"Soft(\"retained gameplay\", () => DisposeRetainedGameplay(ingress.RetainedGameplay))",
"Soft(\"gameplay actions\", () => DisposeGameplayActions(ingress.GameplayActions))",
"Soft(\"camera pointer\", () => DisposeCameraPointer(ingress.CameraPointer))",
"Soft(\"native window callbacks\", () => DisposeWindowCallbacks(ingress.WindowCallbacks))",
"new ResourceShutdownStage(\"session dependents\"",
"pointer.ReleaseMouseLookAfterSessionRetirement();",
"_cameraPointerInput = null;");
"Hard(\"mouse capture\", () => live.CameraPointer?.ReleaseMouseLookAfterSessionRetirement())");
Assert.Contains(
"new(name, action, ResourceShutdownOperationPolicy.ReportAndContinue)",
lifetime,
StringComparison.Ordinal);
Assert.Contains(
"UiHost? RetainedUiHost,",
lifetime,
StringComparison.Ordinal);
Assert.Contains("_uiHost,", source, StringComparison.Ordinal);
AssertAppearsInOrder(
nativeRelease,
"TryComplete();",
"ReleaseNativeWindow();");
AssertAppearsInOrder(
dispose,
"CompleteShutdown();",
"_window?.Dispose();",
"CompleteShutdown(releaseNativeWindow: true);",
"_window = null;");
Assert.Equal(1, CountOccurrences(dispose, "CompleteShutdown();"));
Assert.Equal(1, CountOccurrences(dispose, "_window?.Dispose();"));
Assert.Equal(1, CountOccurrences(dispose, "CompleteShutdown(releaseNativeWindow: true);"));
Assert.DoesNotContain("_window?.Dispose();", dispose, StringComparison.Ordinal);
Assert.Equal(1, CountOccurrences(dispose, "_window = null;"));
int nativeReleased = dispose.IndexOf("_window = null;", StringComparison.Ordinal)
+ "_window = null;".Length;
@ -526,10 +542,7 @@ public sealed class GameWindowSlice8BoundaryTests
string load = MethodBody(
"private void OnLoad()",
"private void OnUpdate(double dt)");
string shutdown = Slice(
source,
"private ResourceShutdownTransaction CreateShutdownTransaction()",
"private void OnFocusChanged(bool focused)");
string shutdown = GameWindowLifetimeSource();
string terrainAtlas = File.ReadAllText(Path.Combine(
FindRepoRoot(),
"src",
@ -586,17 +599,17 @@ public sealed class GameWindowSlice8BoundaryTests
"TerrainModernRenderer terrain = AcquireAndPublish(");
AssertAppearsInOrder(
shutdown,
"publication.Dispose();",
"bindings.Dispose();",
"_retailUiLease.Dispose();",
"_localPlayerTeleport?.Dispose();",
"_portalTunnelFallback.ReleaseFallback();",
"_skyRenderer?.Dispose()",
"_terrain?.Dispose();",
"_renderResourceLifetime.ReleaseSkyShader",
"_renderResourceLifetime.ReleaseTerrainAtlas",
"_glConstructionCleanup.Dispose",
"_gl?.Dispose();");
"frame.FrameGraphPublication?.Dispose()",
"frame.FrameBindings?.Dispose()",
"DisposeRetailUi(live.RetailUi)",
"render.LocalTeleport?.Dispose();",
"render.PortalTunnelFallback.ReleaseFallback();",
"render.Sky?.Dispose()",
"render.Terrain?.Dispose()",
"render.DedicatedResources.ReleaseSkyShader",
"render.DedicatedResources.ReleaseTerrainAtlas",
"render.ConstructionCleanup.Dispose",
"platform.Gl?.Dispose()");
Assert.DoesNotContain(
"RetailUiRuntime.Mount(",
@ -614,7 +627,12 @@ public sealed class GameWindowSlice8BoundaryTests
source,
"_window.Run();",
"_glConstructionCleanup.RetainFrom(failure);",
"_glConstructionCleanup.Dispose");
"private GameWindowShutdownRoots CaptureShutdownRoots()",
"_glConstructionCleanup)");
Assert.Contains(
"Hard(\"GL construction ledger\", render.ConstructionCleanup.Dispose)",
shutdown,
StringComparison.Ordinal);
}
private static string MethodBody(string start, string end) =>
@ -661,6 +679,13 @@ public sealed class GameWindowSlice8BoundaryTests
"Rendering",
"GameWindow.cs"));
private static string GameWindowLifetimeSource() => File.ReadAllText(Path.Combine(
FindRepoRoot(),
"src",
"AcDream.App",
"Rendering",
"GameWindowLifetime.cs"));
private static string LivePresentationSource() => File.ReadAllText(Path.Combine(
FindRepoRoot(),
"src",

View file

@ -159,4 +159,97 @@ public sealed class ResourceShutdownTransactionTests
Assert.Equal(1, calls);
Assert.True(transaction.IsComplete);
}
[Fact]
public void ReportableFailureRetriesThenAllowsDependentStagesAndIsStructured()
{
var calls = new List<string>();
var failure = new InvalidOperationException("physical detach failed");
var transaction = new ResourceShutdownTransaction(
new ResourceShutdownStage("physical ingress",
[
new(
"mouse callback",
() =>
{
calls.Add("detach");
throw failure;
},
ResourceShutdownOperationPolicy.ReportAndContinue),
]),
new ResourceShutdownStage("dependent",
[
new("dependent", () => calls.Add("dependent")),
]));
transaction.CompleteOrThrow();
Assert.Equal(["detach", "detach", "dependent"], calls);
Assert.True(transaction.IsComplete);
ResourceShutdownCleanupFailure cleanup = Assert.Single(
transaction.CleanupFailures);
Assert.Equal("physical ingress", cleanup.Stage);
Assert.Equal("mouse callback", cleanup.Operation);
Assert.Same(failure, cleanup.Error);
}
[Fact]
public void TransientReportableFailureConvergesWithoutCleanupFailure()
{
int calls = 0;
var transaction = new ResourceShutdownTransaction(
new ResourceShutdownStage("physical ingress",
[
new(
"keyboard callback",
() =>
{
if (calls++ == 0)
throw new InvalidOperationException("transient");
},
ResourceShutdownOperationPolicy.ReportAndContinue),
]));
transaction.CompleteOrThrow();
Assert.Equal(2, calls);
Assert.Empty(transaction.CleanupFailures);
}
[Fact]
public void SettledReportableFailureNeverReplaysWhileHardBarrierRetries()
{
int softCalls = 0;
int hardCalls = 0;
bool allowHard = false;
var transaction = new ResourceShutdownTransaction(
new ResourceShutdownStage("mixed",
[
new(
"soft",
() =>
{
softCalls++;
throw new InvalidOperationException("soft");
},
ResourceShutdownOperationPolicy.ReportAndContinue),
new("hard", () =>
{
hardCalls++;
if (!allowHard)
throw new InvalidOperationException("hard");
}),
]));
Assert.Throws<AggregateException>(transaction.CompleteOrThrow);
int settledSoftCalls = softCalls;
allowHard = true;
transaction.CompleteOrThrow();
Assert.Equal(settledSoftCalls, softCalls);
Assert.True(hardCalls > 1);
Assert.True(transaction.IsComplete);
Assert.Single(transaction.CleanupFailures);
}
}

View file

@ -328,6 +328,12 @@ public sealed class WorldRenderFrameBuilderTests
public void World_scene_uses_the_typed_builder_and_orchestrator_owns_its_local_composition()
{
string gameWindow = GameWindowSource();
string lifetime = File.ReadAllText(Path.Combine(
FindRepoRoot(),
"src",
"AcDream.App",
"Rendering",
"GameWindowLifetime.cs"));
string frameRoot = File.ReadAllText(Path.Combine(
FindRepoRoot(),
"src",
@ -369,14 +375,14 @@ public sealed class WorldRenderFrameBuilderTests
"new RenderFrameOrchestrator(",
"d.FrameGraphs.PublishOwned(");
AssertAppearsInOrder(
gameWindow,
"publication.Dispose();",
"new(\"equipped children\"",
"new(\"effect network state\"",
"new(\"audio\"",
"new(\"mesh draw dispatcher\"",
"new(\"environment cells\"",
"new(\"scene lighting\"");
lifetime,
"frame.FrameGraphPublication?.Dispose()",
"Hard(\"equipped children\"",
"Hard(\"effect network state\"",
"Hard(\"audio\"",
"Hard(\"mesh draw dispatcher\"",
"Hard(\"environment cells\"",
"Hard(\"scene lighting\"");
}
private static string BuilderSource() => File.ReadAllText(Path.Combine(

View file

@ -356,23 +356,39 @@ public sealed class LandblockBuildOriginTests
"src",
"AcDream.App",
"Rendering",
"GameWindow.cs"));
"GameWindowLifetime.cs"));
int sessionStage = source.IndexOf(
"new ResourceShutdownStage(\"session lifetime\"",
"new ResourceShutdownStage(\"host and session barriers\"",
StringComparison.Ordinal);
Assert.True(sessionStage >= 0);
int sessionOperation = source.IndexOf(
"new(\"live session\", () =>",
"Hard(\"live session\", () => DisposeLiveSession(ingress.LiveSession))",
sessionStage,
StringComparison.Ordinal);
Assert.True(sessionOperation > sessionStage);
int sessionDispose = source.IndexOf(
"controller.Dispose()",
int dependentStage = source.IndexOf(
"new ResourceShutdownStage(\"session dependents\"",
sessionOperation,
StringComparison.Ordinal);
Assert.True(sessionDispose > sessionOperation);
Assert.True(dependentStage > sessionOperation);
int streamerDispose = source.IndexOf(
"Hard(\"streamer\", () => live.Streamer?.Dispose())",
dependentStage,
StringComparison.Ordinal);
Assert.True(streamerDispose > dependentStage);
int helper = source.IndexOf(
"private static void DisposeLiveSession(LiveSessionController? controller)",
StringComparison.Ordinal);
Assert.True(helper > streamerDispose);
int sessionDispose = source.IndexOf(
"controller.Dispose()",
helper,
StringComparison.Ordinal);
Assert.True(sessionDispose > helper);
int disposalCompletionBarrier = source.IndexOf(
"if (!controller.IsDisposalComplete)",
@ -380,24 +396,10 @@ public sealed class LandblockBuildOriginTests
StringComparison.Ordinal);
Assert.True(disposalCompletionBarrier > sessionDispose);
int sessionReferenceRelease = source.IndexOf(
"_liveSessionController = null;",
disposalCompletionBarrier,
Assert.Contains(
"Live-session disposal was deferred by a reentrant lifecycle callback.",
source[disposalCompletionBarrier..],
StringComparison.Ordinal);
Assert.True(sessionReferenceRelease > disposalCompletionBarrier);
int dependentStage = source.IndexOf(
"new ResourceShutdownStage(\"session dependents\"",
sessionReferenceRelease,
StringComparison.Ordinal);
Assert.True(dependentStage > sessionReferenceRelease);
int streamerDispose = source.IndexOf(
"new(\"streamer\", () => _streamer?.Dispose())",
dependentStage,
StringComparison.Ordinal);
Assert.True(streamerDispose > dependentStage);
}
private static LandblockBuild EmptyBuild(uint landblockId, LandblockBuildOrigin origin) =>