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:
parent
5a55d08106
commit
31e6e192b3
21 changed files with 1297 additions and 572 deletions
212
tests/AcDream.App.Tests/Rendering/GameWindowLifetimeTests.cs
Normal file
212
tests/AcDream.App.Tests/Rendering/GameWindowLifetimeTests.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue