From 31e6e192b38dad76a0c226e661b6166129c4971a Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 22 Jul 2026 19:43:52 +0200 Subject: [PATCH] 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 --- AGENTS.md | 11 +- CLAUDE.md | 11 +- docs/ISSUES.md | 9 +- docs/architecture/acdream-architecture.md | 9 +- docs/architecture/code-structure.md | 21 +- docs/plans/2026-04-11-roadmap.md | 13 +- docs/plans/2026-05-12-milestones.md | 14 +- ...-slice-8-checkpoint-j-lifetime-shutdown.md | 13 +- ...amewindow-slice-8-composition-lifecycle.md | 17 +- .../SettingsDevToolsComposition.cs | 2 + src/AcDream.App/Rendering/GameWindow.cs | 520 +++------------- .../Rendering/GameWindowLifetime.cs | 588 ++++++++++++++++++ .../Rendering/ResourceShutdownTransaction.cs | 42 +- src/AcDream.App/UI/RetailUiRuntimeLease.cs | 2 + src/AcDream.App/UI/UiHost.cs | 20 +- .../Rendering/GameWindowLifetimeTests.cs | 212 +++++++ .../GameWindowRenderLeafCompositionTests.cs | 37 +- .../GameWindowSlice8BoundaryTests.cs | 165 ++--- .../ResourceShutdownTransactionTests.cs | 93 +++ .../Rendering/WorldRenderFrameBuilderTests.cs | 22 +- .../Streaming/LandblockBuildOriginTests.cs | 48 +- 21 files changed, 1297 insertions(+), 572 deletions(-) create mode 100644 src/AcDream.App/Rendering/GameWindowLifetime.cs create mode 100644 tests/AcDream.App.Tests/Rendering/GameWindowLifetimeTests.cs diff --git a/AGENTS.md b/AGENTS.md index 4afdb56e..382b531a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -155,9 +155,14 @@ pipeline as its failure oracle, carries exact platform/settings results through typed phase inputs, atomically publishes frame roots, and makes session start terminal. `GameWindow` is 1,945 raw lines, down 13,778 lines (87.6%) from baseline. App Release passes 3,431 tests / 3 skips and the complete Release -suite passes 7,803 / 5 skips. Checkpoint I is complete; checkpoints J–L—focused -retryable shutdown, canonical soak snapshots, and final connected gates—remain -active. +suite passes 7,803 / 5 skips. Checkpoint J moves the exact shutdown dependency +graph into `GameWindowLifetime`: immutable typed roots, retry/no-replay state, +hard session/GPU barriers, reportable physical callback cleanup, structured +terminal reporting, and native-window-last release now live outside the shell. +`GameWindow` is 1,625 raw lines, down 14,098 lines (89.7%) from baseline. App +Release passes 3,441 tests / 3 skips and the complete Release suite passes +7,813 / 5 skips. Checkpoints K–L—canonical soak snapshots and final connected +closeout—remain active. See `docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md` and `docs/architecture/code-structure.md`. **Carried:** #232, #153, #116, remaining diff --git a/CLAUDE.md b/CLAUDE.md index 2aa07e45..4d7a99db 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -153,9 +153,14 @@ pipeline as its failure oracle, carries exact platform/settings results through typed phase inputs, atomically publishes frame roots, and makes session start terminal. `GameWindow` is 1,945 raw lines, down 13,778 lines (87.6%) from baseline. App Release passes 3,431 tests / 3 skips and the complete Release -suite passes 7,803 / 5 skips. Checkpoint I is complete; checkpoints J–L—focused -retryable shutdown, canonical soak snapshots, and final connected gates—remain -active. +suite passes 7,803 / 5 skips. Checkpoint J moves the exact shutdown dependency +graph into `GameWindowLifetime`: immutable typed roots, retry/no-replay state, +hard session/GPU barriers, reportable physical callback cleanup, structured +terminal reporting, and native-window-last release now live outside the shell. +`GameWindow` is 1,625 raw lines, down 14,098 lines (89.7%) from baseline. App +Release passes 3,441 tests / 3 skips and the complete Release suite passes +7,813 / 5 skips. Checkpoints K–L—canonical soak snapshots and final connected +closeout—remain active. See `docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md` and `docs/architecture/code-structure.md`. **Carried:** #232, #153, #116, remaining diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 1ea954d5..bb6de5ec 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -38,9 +38,12 @@ What does NOT go here: publication, and lifecycle resource snapshot source. I.8c makes session start the terminal `OnLoad` operation. I.9 corrected the production root to invoke the exact tested pipeline and made every consumed platform/settings - result explicit. `GameWindow.OnUpdate` and `GameWindow.OnRender` are typed - orchestration handoffs and the class is 1,945 raw lines. Checkpoint I is - complete; the shutdown/lifetime cutover and closeout remain active in + result explicit. Checkpoint J moves the exact retryable shutdown graph into + `GameWindowLifetime`, including typed roots, hard session/GPU barriers, + reportable physical detach, structured terminal state, and native-window-last + release. `GameWindow.OnUpdate` and `GameWindow.OnRender` are typed + orchestration handoffs and the class is 1,625 raw lines. Checkpoints K–L—the + canonical soak snapshots and final closeout—remain active in [`docs/architecture/code-structure.md`](architecture/code-structure.md). This is the behavior-preserving prerequisite before new M4 feature bodies. - **Connected-gate fidelity:** `#232` adds canonical managed/GPU/cache-owner diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md index 4179c50b..b4a3b69b 100644 --- a/docs/architecture/acdream-architecture.md +++ b/docs/architecture/acdream-architecture.md @@ -839,8 +839,13 @@ Current movement/collision ownership: persistence/current-state owner, supplies the immutable pre-window snapshot, applies startup pacing/display/FOV/audio once, and later borrows complete runtime targets without replay. Checked GL construction/state boundaries - retain exact cleanup obligations across failure. Ordered production - composition is the next checkpoint. The reconciled ledger and sequence live in + retain exact cleanup obligations across failure. The executable nine-phase + production pipeline and terminal session start are complete. Checkpoint J + moves the exact shutdown dependency graph into `GameWindowLifetime`, whose + typed roots preserve hard session/GPU barriers, reportable physical detach, + retry/no-replay progress, and native-window-last release. `GameWindow` is + 1,625 raw lines; only the canonical soak instrumentation and final connected + closeout checkpoints remain. The reconciled ledger and sequence live in `docs/architecture/code-structure.md`. This decision does not reclassify the remaining L.2 conformance work or Track LH. diff --git a/docs/architecture/code-structure.md b/docs/architecture/code-structure.md index 753bf098..9ef4f8a6 100644 --- a/docs/architecture/code-structure.md +++ b/docs/architecture/code-structure.md @@ -35,6 +35,8 @@ after Slice 8 checkpoint D 4,330 lines / 192 fields / 67 method after Slice 8 checkpoint E 4,266 lines / 194 fields / 65 methods after Slice 8 checkpoint F 4,057 lines / 198 fields / 54 methods after Slice 8 checkpoint G 3,663 lines / 162 fields / 37 methods +after Slice 8 checkpoint I 1,945 lines / startup composition shell +after Slice 8 checkpoint J 1,625 lines / focused lifetime shell ``` `GameWindow` is the single object that: @@ -683,7 +685,7 @@ Issue #232 records the coarse process-residency gate's run-to-run variance so future diagnostics can distinguish canonical owner growth from OS/driver/GC residency without weakening leak detection. -#### Slice 8 — composition and shutdown cleanup — ACTIVE +#### Slice 8 — composition and shutdown cleanup — ACTIVE (K–L remain) Detailed execution ledger: [`docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md`](../plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md). @@ -741,12 +743,17 @@ body into `FrameRootCompositionPhase`, publishes the pair through an exact lease, and gives lifecycle resource sampling a focused source. `GameWindow` is 1,945 raw lines after I.9 corrects production to invoke the same executable pipeline as the failure oracle, carries exact platform/settings results through -the typed contract, and keeps live-session start terminal. Checkpoint I is -complete; J–L remain active with lifetime/shutdown extraction next. The App gate -passes 3,431 tests / 3 intentional skips and the complete Release suite passes -7,803 tests / 5 intentional skips. The clean solution build retains only the 17 -test-project warnings tracked by #228; the complete Checkpoint-I behavior, -architecture, and adversarial corrected-diff passes are clean. +the typed contract, and keeps live-session start terminal. Checkpoint J then +moves the complete shutdown manifest into `GameWindowLifetime`: typed root +groups, retry/no-replay progress, structured reportable physical-detach +failures, hard session/GPU barriers, and native-window-last release now live +outside the host. The shell captures roots once and makes one lifetime call +from Closing/Dispose; no shutdown stage remains in it. `GameWindow` is 1,625 +raw lines after J. The App gate passes 3,441 tests / 3 intentional skips and +the complete Release suite passes 7,813 tests / 5 intentional skips. The clean +solution build retains only the 17 test-project warnings tracked by #228; the +complete Checkpoint-J behavior, architecture, and adversarial corrected-diff +passes are clean. Checkpoints K–L remain active. ### 4.4 Exit criteria diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index c83bc1d5..a52494b2 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -1,6 +1,6 @@ # acdream — strategic roadmap -**Status:** Living document. Updated 2026-07-22. **M3 landed; M4 is next after the active `GameWindow` structural prerequisite.** M3's retail casting/UI, R6 locomotion/collision/projectile/teleport/radar rebaseline, deterministic fresh-login/portal world lifecycle, and final two-client portal observer flow are user-gated. The current program is the behavior-preserving ownership campaign in [`docs/architecture/code-structure.md`](../architecture/code-structure.md); Slices 1–7 and Slice 8 checkpoints A–H are complete, with the remaining composition/shutdown checkpoints active. New M4 quest/emote/character-creation feature bodies wait until that campaign is complete. Issue #225's lifestone/particle alpha comparison remains a separate rendering visual gate. +**Status:** Living document. Updated 2026-07-22. **M3 landed; M4 is next after the active `GameWindow` structural prerequisite.** M3's retail casting/UI, R6 locomotion/collision/projectile/teleport/radar rebaseline, deterministic fresh-login/portal world lifecycle, and final two-client portal observer flow are user-gated. The current program is the behavior-preserving ownership campaign in [`docs/architecture/code-structure.md`](../architecture/code-structure.md); Slices 1–7 and Slice 8 checkpoints A–J are complete, with only canonical soak instrumentation and final closeout checkpoints K–L active. New M4 quest/emote/character-creation feature bodies wait until that campaign is complete. Issue #225's lifestone/particle alpha comparison remains a separate rendering visual gate. **Purpose:** One source of truth for where the project is and where it's going. Every observed defect or missing feature has a named phase that owns it; when something looks wrong in-game, look here to find the phase that'll address it. Implementation details live in per-phase specs under `docs/superpowers/specs/`, not in this file. --- @@ -105,10 +105,13 @@ resources have transactional phase ownership and exact-owner late bindings. I.7 adds the complete streaming/session/hydration/local-player/teleport phase, including explicit reversible ownership for every late edge. `GameWindow` is now 1,945 raw lines after I.9 makes production invoke the same typed pipeline -as the failure oracle and keeps session start terminal. Checkpoint I is -complete; shutdown, canonical soak snapshots, and closeout checkpoints J–L -remain. The App gate passes 3,431 tests / 3 intentional skips, the complete -Release suite passes 7,803 tests / 5 skips, and the complete Checkpoint-I +as the failure oracle and keeps session start terminal. Checkpoint J extracts +the exact shutdown dependency graph into `GameWindowLifetime`, with typed +immutable roots, hard session/GPU barriers, reportable physical cleanup, +retry/no-replay state, and native-window-last terminal reporting. The host is +now 1,625 raw lines. Only canonical soak snapshots and closeout checkpoints +K–L remain. The App gate passes 3,441 tests / 3 intentional skips, the complete +Release suite passes 7,813 tests / 5 skips, and the complete Checkpoint-J corrected-diff review is clean. This is a behavior-preserving structural program. Severe regressions still get diff --git a/docs/plans/2026-05-12-milestones.md b/docs/plans/2026-05-12-milestones.md index 70c0ccb4..98390267 100644 --- a/docs/plans/2026-05-12-milestones.md +++ b/docs/plans/2026-05-12-milestones.md @@ -64,12 +64,14 @@ and attaches command/input owners before frame publication. I.8b composes and atomically publishes the complete frame pair and extracts lifecycle resource sampling. I.8c makes session start the terminal `OnLoad` operation. `GameWindow` is 1,945 raw lines after I.9 routes production through the exact -tested pipeline with explicit prior-result dependencies. Checkpoint I is -complete and checkpoints J–L remain active. Issue #232 tracks -process-residency variance in the soak without loosening its leak threshold. -The App gate passes 3,431 tests / 3 skips, the complete Release suite passes -7,803 tests / 5 skips, and the complete Checkpoint-I corrected-diff review is -clean. +tested pipeline with explicit prior-result dependencies. Checkpoint J adds the +focused `GameWindowLifetime`, immutable typed shutdown roots, hard dependency +barriers, structured report-and-continue physical cleanup, retry/no-replay +state, and native-window-last terminal reporting. The host is now 1,625 raw +lines. Checkpoints K–L remain active; Issue #232 tracks process-residency +variance in the soak without loosening its leak threshold. The App gate passes +3,441 tests / 3 skips, the complete Release suite passes 7,813 tests / 5 skips, +and the complete Checkpoint-J corrected-diff review is clean. Carried: #153 far-teleport residual, #116 slide-response, the remaining R6 ownership cleanup plus diff --git a/docs/plans/2026-07-22-gamewindow-slice-8-checkpoint-j-lifetime-shutdown.md b/docs/plans/2026-07-22-gamewindow-slice-8-checkpoint-j-lifetime-shutdown.md index 0a79a8bd..c5d7b789 100644 --- a/docs/plans/2026-07-22-gamewindow-slice-8-checkpoint-j-lifetime-shutdown.md +++ b/docs/plans/2026-07-22-gamewindow-slice-8-checkpoint-j-lifetime-shutdown.md @@ -1,6 +1,6 @@ # GameWindow Slice 8 Checkpoint J — lifetime and shutdown -**Status:** Active 2026-07-22. +**Status:** Complete 2026-07-22. **Parent:** [`2026-07-22-gamewindow-slice-8-composition-lifecycle.md`](2026-07-22-gamewindow-slice-8-composition-lifecycle.md), Checkpoint J. @@ -12,6 +12,17 @@ passes 7,803 tests / 5 skips. frame withdrawal, GL dependency, and native-window-last order. This checkpoint changes lifetime ownership and failure reporting, not gameplay behavior. +**Result:** `GameWindowLifetime` now owns the immutable typed root snapshot, +retryable staged transaction, structured terminal report, and native window. +Persistent physical-detach failures are retried and reported without stranding +later owners; session convergence and GPU drain remain hard barriers. Clean +native release drops the completed transaction/root graph, while terminal +abandonment retains it. `GameWindow.cs` is 1,625 raw lines, down 14,098 lines +(89.7%) from the campaign baseline. The App Release gate passes 3,441 tests / 3 +skips; the complete Release gate passes 7,813 / 5 skips; the solution build has +only #228's 17 existing test warnings. Three corrected-diff review passes are +clean and no retail-divergence row changed. + ## 1. Outcome Replace `GameWindow.CreateShutdownTransaction` and the window-owned transaction diff --git a/docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md b/docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md index 0ba200db..1d390a12 100644 --- a/docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md +++ b/docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md @@ -32,7 +32,7 @@ audit. transactional partial-acquisition rollback. Production and failure tests now invoke the same pipeline and carry exact typed prior results. Detailed plan: [`2026-07-22-gamewindow-slice-8-checkpoint-i-ordered-composition.md`](2026-07-22-gamewindow-slice-8-checkpoint-i-ordered-composition.md). -- [ ] J — move the exact retryable shutdown manifest to a focused lifetime +- [x] J — move the exact retryable shutdown manifest to a focused lifetime owner and prove all partial-load/reentrant/retry paths. Detailed plan: [`2026-07-22-gamewindow-slice-8-checkpoint-j-lifetime-shutdown.md`](2026-07-22-gamewindow-slice-8-checkpoint-j-lifetime-shutdown.md). - [ ] K — in a separate #232 commit, add canonical owner snapshots to every soak @@ -41,6 +41,12 @@ audit. lifecycle/soak, framebuffer comparison, documentation, memory, and final visual handoff. +Checkpoint J leaves `GameWindow.cs` at 1,625 raw lines. The focused lifetime +owner now holds the typed shutdown roots, exact 18-stage dependency manifest, +retry/no-replay state, structured soft-cleanup failures, hard-barrier fallback, +and native-window-last release. App Release passes 3,441 tests / 3 skips and +the complete Release suite passes 7,813 / 5 skips. Checkpoint K is next. + Each checked checkpoint lands as a bisectable commit. A checkpoint is not done while a new class delegates a substantial body back into `GameWindow`, stores `GameWindow`, or replaces 196 fields with one runtime/service-locator bag. @@ -656,6 +662,15 @@ correction; Checkpoint L retains the connected lifecycle and soak gates. after native fallback the terminal abandoned state makes repeated Dispose inert. +Result: `GameWindowLifetime` owns the typed root snapshot, 18-stage manifest, +transaction progress, terminal report, and native window. Clean, soft-failure, +hard-failure, native-failure, retry, reentrant, and never-run paths are pinned. +The UI host remains explicitly rooted through failed physical detach until the +native edge is gone; successful finalization releases the completed lifetime +graph, while abandonment retains it. No shutdown stage remains in `GameWindow`, +which is now 1,625 raw lines. App Release passes 3,441 tests / 3 skips and the +complete Release suite passes 7,813 / 5 skips. + ### K — canonical soak checkpoints (#232, separate commit) - Extract `WorldLifecycleResourceSnapshotSource` from the window and reuse the diff --git a/src/AcDream.App/Composition/SettingsDevToolsComposition.cs b/src/AcDream.App/Composition/SettingsDevToolsComposition.cs index 878d4bae..7ac20626 100644 --- a/src/AcDream.App/Composition/SettingsDevToolsComposition.cs +++ b/src/AcDream.App/Composition/SettingsDevToolsComposition.cs @@ -194,6 +194,8 @@ internal sealed class DevToolsCompositionOwner : IDisposable public bool IsDisposalComplete => _frontendShutdown.IsComplete && !_ownsInput; + internal bool IsFrontendDisposalComplete => _frontendShutdown.IsComplete; + public void AdoptInput() => _ownsInput = true; public void AdoptBackend() => _ownsBackend = true; public void AdoptChat() => _ownsChat = true; diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 1c8083cc..fe6574b1 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -114,7 +114,7 @@ public sealed class GameWindow : _glConstructionCleanup = new(); private readonly AcDream.App.World.WorldEnvironmentController _worldEnvironment = new(Console.WriteLine); - private ResourceShutdownTransaction? _shutdown; + private readonly GameWindowLifetime _lifetime = new(); private readonly DisplayFramePacingController _displayFramePacing; private readonly RuntimeSettingsController _runtimeSettings; @@ -640,6 +640,7 @@ public sealed class GameWindow : }; _window = Window.Create(options); + _lifetime.PublishNativeWindow(_window); _displayFramePacing.BindSurface( new SilkDisplayFramePacingSurface(_window)); // The fixed binding preserves main Render before post-render pacing, @@ -1507,426 +1508,106 @@ public sealed class GameWindow : private void OnFramebufferResize(Silk.NET.Maths.Vector2D newSize) => _framebufferResize.Resize(newSize); - private void OnClosing() + private void OnClosing() => CompleteShutdown(releaseNativeWindow: false); + + private void CompleteShutdown(bool releaseNativeWindow) { - _hostQuiescence.StopAccepting(); - CompleteShutdown(); + if (!_lifetime.HasShutdownRoots) + _lifetime.PublishShutdownRoots(CaptureShutdownRoots()); + + GameWindowLifetimeReport report = releaseNativeWindow + ? _lifetime.CompleteAndReleaseNativeWindow() + : _lifetime.TryComplete(); + if (report.Status == GameWindowLifetimeStatus.Complete) + return; + + Console.Error.WriteLine( + $"[shutdown] status={report.Status}, blocked={report.BlockedStage ?? "none"}"); + foreach (ResourceShutdownCleanupFailure cleanup in report.CleanupFailures) + { + Console.Error.WriteLine( + $"[shutdown] cleanup '{cleanup.Operation}' in '{cleanup.Stage}': " + + cleanup.Error); + } + + if (report.Error is not null) + Console.Error.WriteLine($"[shutdown] {report.Error}"); } - private void CompleteShutdown() - { - _hostQuiescence.StopAccepting(); - _shutdown ??= CreateShutdownTransaction(); - try - { - _shutdown.CompleteOrThrow(); - } - catch (Exception error) - { - // The session/network stage runs first, so ACE is disconnected - // gracefully even if a persistent driver failure prevents full GL - // convergence. Later dependencies remain intact and the native - // context/process teardown is the final safety net. - Console.Error.WriteLine($"[shutdown] {error}"); - } - } - - private ResourceShutdownTransaction CreateShutdownTransaction() => new( - // Logical cutoff precedes the live session's potentially long graceful - // close. Physical event removal follows session retirement, so a bad - // Silk accessor cannot prevent F653/transport teardown. The input - // context and UI owners remain alive for reset, while copied callbacks - // are already inert. - new ResourceShutdownStage("input callback deactivation", - [ - 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("dispatcher", () => _inputDispatcher?.Deactivate()), - new("mouse source", () => _mouseSource?.Deactivate()), - new("keyboard source", () => _kbSource?.Deactivate()), - new("retained UI input", _retailUiLease.QuiesceInput), - new("developer tools input", () => _devToolsComposition?.DeactivateInput()), - ]), - // Live-session reset retires the complete streaming window. Every - // reset callback owner, especially LandblockStreamer, must remain live - // until that transaction converges. - new ResourceShutdownStage("session lifetime", - [ - new("live session", () => - { - AcDream.App.Net.LiveSessionController? controller = - _liveSessionController; - if (controller is null) - return; - - controller.Dispose(); - if (!controller.IsDisposalComplete) - { - throw new InvalidOperationException( - "Live-session disposal was deferred by a reentrant lifecycle callback."); - } - - if (ReferenceEquals(_liveSessionController, controller)) - { - _liveSessionHost = null; - _liveSessionController = null; - } - }), - ]), - new ResourceShutdownStage("input callback detach", - [ - new("settings view model", () => _runtimeSettings.UnbindViewModel()), - new("retained gameplay", () => - { - AcDream.App.Input.RetainedUiGameplayBinding? binding = - _retainedUiGameplayBinding; - if (binding is null) return; - binding.Dispose(); - if (!binding.IsDisposalComplete) - throw new InvalidOperationException( - "Retained gameplay callback removal remains pending."); - _retainedUiGameplayBinding = null; - }), - new("gameplay actions", () => - { - AcDream.App.Input.GameplayInputActionRouter? actions = - _gameplayInputActions; - if (actions is null) return; - actions.Dispose(); - if (!actions.IsDisposalComplete) - throw new InvalidOperationException( - "Gameplay action callback removal remains pending."); - _gameplayInputActions = null; - }), - new("retained UI input", _retailUiLease.DeactivateInput), - new("developer tools input", () => _devToolsComposition?.DetachInput()), - new("camera pointer", () => - { - AcDream.App.Input.CameraPointerInputController? pointer = - _cameraPointerInput; - if (pointer is null) return; - pointer.Dispose(); - if (!pointer.IsDisposalComplete) - throw new InvalidOperationException( - "Camera pointer callback removal remains pending."); - }), - new("dispatcher", () => - { - AcDream.UI.Abstractions.Input.InputDispatcher? dispatcher = - _inputDispatcher; - if (dispatcher is null) return; - _movementInput.Unbind(dispatcher); - _cameraInput.Unbind(dispatcher); - dispatcher.Dispose(); - if (!dispatcher.IsDisposalComplete) - throw new InvalidOperationException( - "Input dispatcher source removal remains pending."); - _inputDispatcher = null; - }), - new("mouse source", () => - { - AcDream.App.Input.SilkMouseSource? source = _mouseSource; - if (source is null) return; - source.Dispose(); - if (!source.IsDisposalComplete) - throw new InvalidOperationException( - "Mouse source callback removal remains pending."); - _mouseSource = null; - }), - new("keyboard source", () => - { - AcDream.App.Input.SilkKeyboardSource? source = _kbSource; - if (source is null) return; - source.Dispose(); - if (!source.IsDisposalComplete) - throw new InvalidOperationException( - "Keyboard source callback removal remains pending."); - _kbSource = null; - }), - ]), - // Frame composition borrows equipped, effect, audio, and render - // owners. Withdraw the graph before the first borrowed owner closes; - // the later render-frontend stage still disposes the GL owners. - new ResourceShutdownStage("frame borrowers", - [ - new("world frame composition", () => - { - IDisposable? publication = _frameGraphPublication; - if (publication is null) - return; - publication.Dispose(); - _frameGraphPublication = null; - }), - new("frame-root bindings", () => - { - FrameRootRuntimeBindings? bindings = _frameRootBindings; - if (bindings is null) - return; - bindings.Dispose(); - _frameRootBindings = null; - _worldLifecycleAutomation = null; - }), - new("session/player bindings", () => - { - SessionPlayerRuntimeBindings? bindings = _sessionPlayerBindings; - if (bindings is null) - return; - bindings.Dispose(); - _sessionPlayerBindings = null; - }), - ]), - new ResourceShutdownStage("session dependents", - [ - new("interaction/UI late bindings", () => - { - InteractionUiLateBindings? bindings = _interactionUiLateBindings; - if (bindings is null) - return; - bindings.Dispose(); - _interactionUiLateBindings = null; - }), - new("mouse capture", () => - { - AcDream.App.Input.CameraPointerInputController? pointer = - _cameraPointerInput; - if (pointer is null) return; - pointer.ReleaseMouseLookAfterSessionRetirement(); - _cameraPointerInput = null; - }), - new("retail UI", () => - { - _retailUiLease.Dispose(); - if (!_retailUiLease.IsDisposalComplete) - throw new InvalidOperationException( - "The retained UI ownership lease did not complete disposal."); - _retailUiRuntime = null; - _uiHost = null; - }), - new("combat target", () => - { - _combatTargetController?.Dispose(); - _combatTargetController = null; - }), - new("combat attack", () => - { - _combatAttackController?.Dispose(); - _combatAttackController = null; - }), - new("item interaction", () => - { - _itemInteractionController?.Dispose(); - _itemInteractionController = null; - }), - new("external containers", () => - { - _externalContainerLifecycle?.Dispose(); - _externalContainerLifecycle = null; - }), - new("magic runtime", () => - { - _magicRuntime = null; - _magicCatalog = null; - }), - new("streamer", () => _streamer?.Dispose()), - new("equipped children", () => _equippedChildRenderer?.Dispose()), - ]), - // Retained tombstones are retried while every callback owner is alive. - new ResourceShutdownStage("live entities", - [ - new("live entity runtime", () => _liveEntities?.Clear()), - ]), - new ResourceShutdownStage("effect dispatch edges", - [ - new("live-presentation bindings", () => - { - LivePresentationRuntimeBindings? bindings = - _livePresentationBindings; - if (bindings is null) - return; - bindings.Dispose(); - _livePresentationBindings = null; - }), - new("entity-effect advance source", () => - { - if (_entityEffects is { } effects) - _entityEffectAdvance.Unbind(effects); - _entityEffectAdvance.Deactivate(); - }), - new("animation-hook registrations", () => - { - AnimationHookRegistrationSet? registrations = _hookRegistrations; - if (registrations is null) - return; - registrations.Dispose(); - if (!registrations.IsCleanupComplete) - { - throw new InvalidOperationException( - "Animation-hook registration cleanup remains pending."); - } - _hookRegistrations = null; - }), - ]), - new ResourceShutdownStage("live entity dependents", - [ - new("live lights", () => - { - _liveEntityLights?.Dispose(); - _liveEntityLights = null; - }), - new("live presentation", () => _liveEntityPresentation?.Dispose()), - new("remote teleport", () => - { - _remoteTeleportController?.Dispose(); - _remoteTeleportController = null; - }), - new("effect network state", () => - { - _entityEffects?.ClearNetworkState(); - _animationHookFrames?.Clear(); - _effectPoses.Clear(); - }), - new("audio", () => - { - AcDream.App.Audio.OpenAlAudioEngine? engine = _audioEngine; - if (engine is null) - return; - engine.Dispose(); - if (!engine.IsDisposalComplete) - { - throw new InvalidOperationException( - "OpenAL native-resource cleanup remains pending."); - } - _audioEngine = null; - _audioSink = null; - _soundCache = null; - _entitySoundTables = null; - }), - ]), - new ResourceShutdownStage("submitted GPU work", - [ - new("frame flight drain", () => _gpuFrameFlights?.WaitForSubmittedWork()), - ]), - new ResourceShutdownStage("render frontends", - [ - new("developer tools", () => - { - DevToolsCompositionOwner? owner = _devToolsComposition; - if (owner is null) - return; - owner.DisposeFrontend(); - if (!owner.IsDisposalComplete) - throw new InvalidOperationException( - "Developer-tools cleanup remains incomplete."); - _devToolsComposition = null; - _devToolsFramePresenter = null; - _devToolsCommandBus = null; - _debugVm = null; - }), - new("portal tunnel", () => - { - _localPlayerTeleport?.Dispose(); - _localPlayerTeleport = null; - _portalTunnelFallback.ReleaseFallback(); - }), - new("paperdoll viewport", () => - { - _paperdollViewportRenderer?.Dispose(); - _paperdollViewportRenderer = null; - _paperdollFramePresenter = null; - }), - new("mesh draw dispatcher", () => _wbDrawDispatcher?.Dispose()), - new("environment cells", () => _envCellRenderer?.Dispose()), - new("portal depth mask", () => _portalDepthMask?.Dispose()), - new("clip frame", () => _clipFrame?.Dispose()), - new("sky", () => _skyRenderer?.Dispose()), - new("particles", () => _particleRenderer?.Dispose()), - ]), - new ResourceShutdownStage("shared texture owners", - [ - new("sampler cache", () => _samplerCache?.Dispose()), - new("texture cache", () => _textureCache?.Dispose()), - ]), - new ResourceShutdownStage("mesh adapter", - [ - new("WB mesh adapter", () => - { - _wbMeshAdapter?.Dispose(); - _wbMeshAdapter = null; - }), - ]), - new ResourceShutdownStage("remaining render owners", - [ - new("mesh shader", () => _meshShader?.Dispose()), - new("terrain", () => - { - _terrain?.Dispose(); - _terrain = null; - }), - new("terrain shader", () => _terrainModernShader?.Dispose()), - new("scene lighting", () => _sceneLightingUbo?.Dispose()), - new("debug lines", () => _debugLines?.Dispose()), - new("text renderer", () => _textRenderer?.Dispose()), - new("debug font", () => _debugFont?.Dispose()), - new("frame pacing", _displayFramePacing.Dispose), - new("frame profiler", _frameProfiler.Dispose), - ]), - new ResourceShutdownStage("dedicated render resources", - [ - new("sky shader", _renderResourceLifetime.ReleaseSkyShader), - new("terrain atlas", _renderResourceLifetime.ReleaseTerrainAtlas), - ]), - new ResourceShutdownStage("failed render construction cleanup", - [ - new("GL construction ledger", _glConstructionCleanup.Dispose), - ]), - new ResourceShutdownStage("frame flight owner", - [ - new("frame flights", () => - { - _gpuFrameFlights?.Dispose(); - _gpuFrameFlights = null; - }), - ]), - new ResourceShutdownStage("content mappings", - [ - new("DAT collection", () => - { - _dats?.Dispose(); - _dats = null; - }), - ]), - new ResourceShutdownStage("input", - [ - new("native window callbacks", () => - { - SilkWindowCallbackBinding? binding = _windowCallbacks; - if (binding is null) - return; - - binding.Dispose(); - if (!binding.IsDisposalComplete) - throw new NativeWindowCallbackCleanupDeferredException(); - if (ReferenceEquals(_windowCallbacks, binding)) - _windowCallbacks = null; - }), - new("input context", () => - { - _input?.Dispose(); - _input = null; - }), - ]), - new ResourceShutdownStage("OpenGL context", - [ - new("OpenGL", () => - { - _gl?.Dispose(); - _gl = null; - }), - ])); - + private GameWindowShutdownRoots CaptureShutdownRoots() => new( + new IngressShutdownRoots( + _hostQuiescence, + _liveCombatModeCommands, + _runtimeDiagnosticCommands, + _retainedUiGameplayBinding, + _gameplayInputActions, + _cameraPointerInput, + _inputDispatcher, + _mouseSource, + _kbSource, + _retailUiLease, + _uiHost, + _devToolsComposition, + _liveSessionController, + _runtimeSettings, + _movementInput, + _cameraInput, + _windowCallbacks), + new FrameShutdownRoots( + _frameGraphPublication, + _frameRootBindings, + _sessionPlayerBindings, + _interactionUiLateBindings), + new LiveShutdownRoots( + _cameraPointerInput, + _retailUiLease, + _combatTargetController, + _combatAttackController, + _itemInteractionController, + _externalContainerLifecycle, + _streamer, + _equippedChildRenderer, + _liveEntities, + _livePresentationBindings, + _entityEffectAdvance, + _entityEffects, + _hookRegistrations, + _liveEntityLights, + _liveEntityPresentation, + _remoteTeleportController, + _animationHookFrames, + _effectPoses, + _audioEngine), + new RenderShutdownRoots( + _gpuFrameFlights, + _devToolsComposition, + _localPlayerTeleport, + _portalTunnelFallback, + _paperdollViewportRenderer, + _wbDrawDispatcher, + _envCellRenderer, + _portalDepthMask, + _clipFrame, + _skyRenderer, + _particleRenderer, + _samplerCache, + _textureCache, + _wbMeshAdapter, + _meshShader, + _terrain, + _terrainModernShader, + _sceneLightingUbo, + _debugLines, + _textRenderer, + _debugFont, + _displayFramePacing, + _frameProfiler, + _renderResourceLifetime, + _glConstructionCleanup), + new PlatformShutdownRoots( + _dats, + _input, + _gl)); private void OnFocusChanged(bool focused) => _cameraPointerInput?.HandleFocusChanged(focused); @@ -1937,8 +1618,7 @@ public sealed class GameWindow : // window and exceptions during Window.Create/Run, so owned kernel // handles (including the frame timer) never depend on a native window // event for disposal. - CompleteShutdown(); - _window?.Dispose(); + CompleteShutdown(releaseNativeWindow: true); _window = null; } diff --git a/src/AcDream.App/Rendering/GameWindowLifetime.cs b/src/AcDream.App/Rendering/GameWindowLifetime.cs new file mode 100644 index 00000000..f32696a0 --- /dev/null +++ b/src/AcDream.App/Rendering/GameWindowLifetime.cs @@ -0,0 +1,588 @@ +using AcDream.App.Audio; +using AcDream.App.Combat; +using AcDream.App.Composition; +using AcDream.App.Diagnostics; +using AcDream.App.Input; +using AcDream.App.Net; +using AcDream.App.Physics; +using AcDream.App.Rendering.Sky; +using AcDream.App.Rendering.Vfx; +using AcDream.App.Rendering.Wb; +using AcDream.App.Settings; +using AcDream.App.Streaming; +using AcDream.App.UI; +using AcDream.App.World; +using AcDream.Content; +using AcDream.Core.Audio; +using AcDream.Core.Physics; +using AcDream.UI.Abstractions.Input; +using DatReaderWriter; +using Silk.NET.Input; +using Silk.NET.OpenGL; + +namespace AcDream.App.Rendering; + +internal enum GameWindowLifetimeStatus +{ + Active, + RetryableIncomplete, + Complete, + CompleteWithCleanupFailures, + AbandonedIncomplete, +} + +internal sealed record GameWindowLifetimeReport( + GameWindowLifetimeStatus Status, + string? BlockedStage, + IReadOnlyList CleanupFailures, + Exception? Error) +{ + public bool IsTerminal => Status is + GameWindowLifetimeStatus.Complete + or GameWindowLifetimeStatus.CompleteWithCleanupFailures + or GameWindowLifetimeStatus.AbandonedIncomplete; +} + +internal sealed record IngressShutdownRoots( + HostQuiescenceGate HostQuiescence, + LiveCombatModeCommandSlot CombatCommands, + RuntimeDiagnosticCommandSlot DiagnosticCommands, + RetainedUiGameplayBinding? RetainedGameplay, + GameplayInputActionRouter? GameplayActions, + CameraPointerInputController? CameraPointer, + InputDispatcher? Dispatcher, + SilkMouseSource? MouseSource, + SilkKeyboardSource? KeyboardSource, + RetailUiRuntimeLease RetailUi, + // Keeps failed physical UI bindings alive through native-window release. + UiHost? RetainedUiHost, + DevToolsCompositionOwner? DevTools, + LiveSessionController? LiveSession, + RuntimeSettingsController Settings, + DispatcherMovementInputSource MovementInput, + DispatcherCameraInputSource CameraInput, + SilkWindowCallbackBinding? WindowCallbacks); + +internal sealed record FrameShutdownRoots( + IDisposable? FrameGraphPublication, + FrameRootRuntimeBindings? FrameBindings, + SessionPlayerRuntimeBindings? SessionBindings, + InteractionUiLateBindings? InteractionBindings); + +internal sealed record LiveShutdownRoots( + CameraPointerInputController? CameraPointer, + RetailUiRuntimeLease RetailUi, + CombatTargetController? CombatTarget, + CombatAttackController? CombatAttack, + ItemInteractionController? ItemInteraction, + ExternalContainerLifecycleController? ExternalContainers, + LandblockStreamer? Streamer, + EquippedChildRenderController? EquippedChildren, + LiveEntityRuntime? LiveEntities, + LivePresentationRuntimeBindings? PresentationBindings, + DeferredEntityEffectAdvanceSource EffectAdvance, + EntityEffectController? EntityEffects, + AnimationHookRegistrationSet? HookRegistrations, + LiveEntityLightController? LiveLights, + LiveEntityPresentationController? LivePresentation, + RemoteTeleportController? RemoteTeleport, + AnimationHookFrameQueue? AnimationHookFrames, + EntityEffectPoseRegistry EffectPoses, + OpenAlAudioEngine? Audio); + +internal sealed record RenderShutdownRoots( + GpuFrameFlightController? FrameFlights, + DevToolsCompositionOwner? DevTools, + LocalPlayerTeleportController? LocalTeleport, + TransferableResourceSlot PortalTunnelFallback, + PaperdollViewportRenderer? Paperdoll, + WbDrawDispatcher? DrawDispatcher, + EnvCellRenderer? EnvironmentCells, + PortalDepthMaskRenderer? PortalDepthMask, + ClipFrame? ClipFrame, + SkyRenderer? Sky, + ParticleRenderer? Particles, + SamplerCache? Samplers, + TextureCache? Textures, + WbMeshAdapter? MeshAdapter, + Shader? MeshShader, + TerrainModernRenderer? Terrain, + Shader? TerrainShader, + SceneLightingUboBinding? SceneLighting, + DebugLineRenderer? DebugLines, + TextRenderer? TextRenderer, + BitmapFont? DebugFont, + DisplayFramePacingController FramePacing, + FrameProfiler FrameProfiler, + GameRenderResourceLifetime DedicatedResources, + GlConstructionCleanupLedger ConstructionCleanup); + +internal sealed record PlatformShutdownRoots( + IDatReaderWriter? Dats, + IInputContext? Input, + GL? Gl); + +internal sealed record GameWindowShutdownRoots( + IngressShutdownRoots Ingress, + FrameShutdownRoots Frame, + LiveShutdownRoots Live, + RenderShutdownRoots Render, + PlatformShutdownRoots Platform); + +/// +/// Sole process-window lifetime owner. Runtime code receives no service lookup +/// through this type; it retains only teardown roots and the immutable terminal +/// report. +/// +internal sealed class GameWindowLifetime +{ + private readonly Func? _injectedTransactionFactory; + private GameWindowShutdownRoots? _roots; + private ResourceShutdownTransaction? _transaction; + private IDisposable? _nativeWindow; + private bool _shutdownRootsPublished; + private bool _nativeReleaseAttempted; + private bool _completing; + private GameWindowLifetimeReport _report = new( + GameWindowLifetimeStatus.Active, + null, + [], + null); + + public GameWindowLifetime() + { + } + + internal GameWindowLifetime(Func transactionFactory) + { + _injectedTransactionFactory = transactionFactory + ?? throw new ArgumentNullException(nameof(transactionFactory)); + } + + public GameWindowLifetimeReport Report => _report; + public bool HasShutdownRoots => + _shutdownRootsPublished || _injectedTransactionFactory is not null; + internal bool RetainsShutdownGraph => _roots is not null || _transaction is not null; + + public void PublishNativeWindow(IDisposable nativeWindow) + { + ArgumentNullException.ThrowIfNull(nativeWindow); + if (_nativeReleaseAttempted || _nativeWindow is not null) + throw new InvalidOperationException("A native window is already lifetime-owned."); + _nativeWindow = nativeWindow; + } + + public void PublishShutdownRoots(GameWindowShutdownRoots roots) + { + ArgumentNullException.ThrowIfNull(roots); + if (_transaction is not null || _report.Status != GameWindowLifetimeStatus.Active) + throw new InvalidOperationException("Shutdown roots cannot change after completion starts."); + if (_roots is not null && !ReferenceEquals(_roots, roots)) + throw new InvalidOperationException("Shutdown roots are already published."); + _roots = roots; + _shutdownRootsPublished = true; + } + + public GameWindowLifetimeReport TryComplete() + { + if (_report.IsTerminal || _completing) + return _report; + EnsureTransaction(); + + _completing = true; + try + { + try + { + _transaction!.CompleteOrThrow(); + } + catch (Exception error) + { + _report = new GameWindowLifetimeReport( + GameWindowLifetimeStatus.RetryableIncomplete, + _transaction!.CurrentStageName, + _transaction.CleanupFailures, + error); + return _report; + } + + GameWindowLifetimeStatus status = _transaction!.CleanupFailures.Count == 0 + ? GameWindowLifetimeStatus.Complete + : GameWindowLifetimeStatus.CompleteWithCleanupFailures; + _report = new GameWindowLifetimeReport( + status, + null, + _transaction.CleanupFailures, + null); + return _report; + } + finally + { + _completing = false; + } + } + + public GameWindowLifetimeReport CompleteAndReleaseNativeWindow() + { + if (_completing) + return _report; + if (!_report.IsTerminal) + TryComplete(); + + if (_report.Status == GameWindowLifetimeStatus.RetryableIncomplete) + { + AbandonRetainedRootsAfterTerminalFailure(); + Exception? nativeFailure = ReleaseNativeWindow(); + Exception terminalError = nativeFailure is null + ? _report.Error ?? new InvalidOperationException( + "Shutdown did not converge before native fallback.") + : new AggregateException( + "Shutdown and native fallback both failed.", + _report.Error ?? new InvalidOperationException( + "Shutdown did not converge before native fallback."), + nativeFailure); + _report = new GameWindowLifetimeReport( + GameWindowLifetimeStatus.AbandonedIncomplete, + _report.BlockedStage, + _report.CleanupFailures, + terminalError); + return _report; + } + + if (_report.IsTerminal && !_nativeReleaseAttempted) + { + Exception? nativeFailure = ReleaseNativeWindow(); + if (nativeFailure is not null) + { + _report = new GameWindowLifetimeReport( + GameWindowLifetimeStatus.AbandonedIncomplete, + "native window", + _report.CleanupFailures, + nativeFailure); + } + else + { + ReleaseCompletedRoots(); + } + } + + return _report; + } + + private void EnsureTransaction() + { + if (_transaction is not null) + return; + _transaction = _injectedTransactionFactory?.Invoke() + ?? GameWindowShutdownManifest.Create( + _roots ?? throw new InvalidOperationException( + "Shutdown roots must publish before completion starts.")); + } + + private Exception? ReleaseNativeWindow() + { + if (_nativeReleaseAttempted) + return null; + _nativeReleaseAttempted = true; + try + { + _nativeWindow?.Dispose(); + _nativeWindow = null; + return null; + } + catch (Exception error) + { + return error; + } + } + + private void ReleaseCompletedRoots() + { + // The native event source is now gone, so even reportable physical-edge + // failures can no longer call back. Terminal failures deliberately do + // not take this path: their exact owner graph stays rooted for safety + // and diagnosis. + _transaction = null; + _roots = null; + } + + private void AbandonRetainedRootsAfterTerminalFailure() + { + RetailUiRuntimeLease? retainedUi = _roots?.Live.RetailUi; + if (retainedUi?.HasDisposalFailure == true) + retainedUi.AbandonAfterTerminalFailure(); + } +} + +internal static class GameWindowShutdownManifest +{ + public static ResourceShutdownTransaction Create(GameWindowShutdownRoots roots) + { + ArgumentNullException.ThrowIfNull(roots); + IngressShutdownRoots ingress = roots.Ingress; + FrameShutdownRoots frame = roots.Frame; + LiveShutdownRoots live = roots.Live; + RenderShutdownRoots render = roots.Render; + PlatformShutdownRoots platform = roots.Platform; + + return new ResourceShutdownTransaction( + new ResourceShutdownStage("host and session barriers", + [ + Hard("host quiescence", ingress.HostQuiescence.StopAccepting), + 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("dispatcher", () => ingress.Dispatcher?.Deactivate()), + Hard("mouse source", () => ingress.MouseSource?.Deactivate()), + Hard("keyboard source", () => ingress.KeyboardSource?.Deactivate()), + Hard("retained UI input", ingress.RetailUi.QuiesceInput), + Hard("developer tools input", () => ingress.DevTools?.DeactivateInput()), + Hard("live session", () => DisposeLiveSession(ingress.LiveSession)), + ]), + new ResourceShutdownStage("physical ingress cleanup", + [ + Soft("settings view model", () => ingress.Settings.UnbindViewModel()), + Soft("retained gameplay", () => DisposeRetainedGameplay(ingress.RetainedGameplay)), + Soft("gameplay actions", () => DisposeGameplayActions(ingress.GameplayActions)), + Soft("retained UI input", ingress.RetailUi.DeactivateInput), + Soft("developer tools input", () => ingress.DevTools?.DetachInput()), + Soft("camera pointer", () => DisposeCameraPointer(ingress.CameraPointer)), + Soft("dispatcher", () => DisposeDispatcher(ingress)), + Soft("mouse source", () => DisposeMouseSource(ingress.MouseSource)), + Soft("keyboard source", () => DisposeKeyboardSource(ingress.KeyboardSource)), + Soft("native window callbacks", () => DisposeWindowCallbacks(ingress.WindowCallbacks)), + ]), + new ResourceShutdownStage("frame borrowers", + [ + Hard("world frame composition", () => frame.FrameGraphPublication?.Dispose()), + Hard("frame-root bindings", () => frame.FrameBindings?.Dispose()), + Hard("session/player bindings", () => frame.SessionBindings?.Dispose()), + ]), + new ResourceShutdownStage("session dependents", + [ + Hard("interaction/UI late bindings", () => frame.InteractionBindings?.Dispose()), + Hard("mouse capture", () => live.CameraPointer?.ReleaseMouseLookAfterSessionRetirement()), + Hard("retail UI", () => DisposeRetailUi(live.RetailUi)), + Hard("combat target", () => live.CombatTarget?.Dispose()), + Hard("combat attack", () => live.CombatAttack?.Dispose()), + Hard("item interaction", () => live.ItemInteraction?.Dispose()), + Hard("external containers", () => live.ExternalContainers?.Dispose()), + Hard("streamer", () => live.Streamer?.Dispose()), + Hard("equipped children", () => live.EquippedChildren?.Dispose()), + ]), + new ResourceShutdownStage("live entities", + [ + Hard("live entity runtime", () => live.LiveEntities?.Clear()), + ]), + new ResourceShutdownStage("effect dispatch edges", + [ + Hard("live-presentation bindings", () => live.PresentationBindings?.Dispose()), + Hard("entity-effect advance source", () => + { + if (live.EntityEffects is { } effects) + live.EffectAdvance.Unbind(effects); + live.EffectAdvance.Deactivate(); + }), + Hard("animation-hook registrations", () => DisposeHookRegistrations(live.HookRegistrations)), + ]), + new ResourceShutdownStage("live entity dependents", + [ + Hard("live lights", () => live.LiveLights?.Dispose()), + Hard("live presentation", () => live.LivePresentation?.Dispose()), + Hard("remote teleport", () => live.RemoteTeleport?.Dispose()), + Hard("effect network state", () => + { + live.EntityEffects?.ClearNetworkState(); + live.AnimationHookFrames?.Clear(); + live.EffectPoses.Clear(); + }), + Hard("audio", () => DisposeAudio(live.Audio)), + ]), + new ResourceShutdownStage("submitted GPU work", + [ + Hard("frame flight drain", () => render.FrameFlights?.WaitForSubmittedWork()), + ]), + new ResourceShutdownStage("render frontends", + [ + Hard("developer tools", () => DisposeDevToolsFrontend(render.DevTools)), + Hard("portal tunnel", () => + { + render.LocalTeleport?.Dispose(); + render.PortalTunnelFallback.ReleaseFallback(); + }), + Hard("paperdoll viewport", () => render.Paperdoll?.Dispose()), + Hard("mesh draw dispatcher", () => render.DrawDispatcher?.Dispose()), + Hard("environment cells", () => render.EnvironmentCells?.Dispose()), + Hard("portal depth mask", () => render.PortalDepthMask?.Dispose()), + Hard("clip frame", () => render.ClipFrame?.Dispose()), + Hard("sky", () => render.Sky?.Dispose()), + Hard("particles", () => render.Particles?.Dispose()), + ]), + new ResourceShutdownStage("shared texture owners", + [ + Hard("sampler cache", () => render.Samplers?.Dispose()), + Hard("texture cache", () => render.Textures?.Dispose()), + ]), + new ResourceShutdownStage("mesh adapter", + [ + Hard("WB mesh adapter", () => render.MeshAdapter?.Dispose()), + ]), + new ResourceShutdownStage("remaining render owners", + [ + Hard("mesh shader", () => render.MeshShader?.Dispose()), + Hard("terrain", () => render.Terrain?.Dispose()), + Hard("terrain shader", () => render.TerrainShader?.Dispose()), + Hard("scene lighting", () => render.SceneLighting?.Dispose()), + Hard("debug lines", () => render.DebugLines?.Dispose()), + Hard("text renderer", () => render.TextRenderer?.Dispose()), + Hard("debug font", () => render.DebugFont?.Dispose()), + Hard("frame pacing", render.FramePacing.Dispose), + Hard("frame profiler", render.FrameProfiler.Dispose), + ]), + new ResourceShutdownStage("dedicated render resources", + [ + Hard("sky shader", render.DedicatedResources.ReleaseSkyShader), + Hard("terrain atlas", render.DedicatedResources.ReleaseTerrainAtlas), + ]), + new ResourceShutdownStage("failed render construction cleanup", + [ + Hard("GL construction ledger", render.ConstructionCleanup.Dispose), + ]), + new ResourceShutdownStage("frame flight owner", + [ + Hard("frame flights", () => render.FrameFlights?.Dispose()), + ]), + new ResourceShutdownStage("content mappings", + [ + Hard("DAT collection", () => platform.Dats?.Dispose()), + ]), + new ResourceShutdownStage("input context", + [ + Hard("input context", () => platform.Input?.Dispose()), + ]), + new ResourceShutdownStage("OpenGL context", + [ + Hard("OpenGL", () => platform.Gl?.Dispose()), + ])); + } + + private static ResourceShutdownOperation Hard(string name, Action action) => + new(name, action); + + private static ResourceShutdownOperation Soft(string name, Action action) => + new(name, action, ResourceShutdownOperationPolicy.ReportAndContinue); + + private static void DisposeLiveSession(LiveSessionController? controller) + { + if (controller is null) + return; + controller.Dispose(); + if (!controller.IsDisposalComplete) + { + throw new InvalidOperationException( + "Live-session disposal was deferred by a reentrant lifecycle callback."); + } + } + + private static void DisposeRetainedGameplay(RetainedUiGameplayBinding? binding) + { + if (binding is null) + return; + binding.Dispose(); + if (!binding.IsDisposalComplete) + throw new InvalidOperationException("Retained gameplay callback removal remains pending."); + } + + private static void DisposeGameplayActions(GameplayInputActionRouter? actions) + { + if (actions is null) + return; + actions.Dispose(); + if (!actions.IsDisposalComplete) + throw new InvalidOperationException("Gameplay action callback removal remains pending."); + } + + private static void DisposeCameraPointer(CameraPointerInputController? pointer) + { + if (pointer is null) + return; + pointer.Dispose(); + if (!pointer.IsDisposalComplete) + throw new InvalidOperationException("Camera pointer callback removal remains pending."); + } + + private static void DisposeDispatcher(IngressShutdownRoots roots) + { + InputDispatcher? dispatcher = roots.Dispatcher; + if (dispatcher is null) + return; + roots.MovementInput.Unbind(dispatcher); + roots.CameraInput.Unbind(dispatcher); + dispatcher.Dispose(); + if (!dispatcher.IsDisposalComplete) + throw new InvalidOperationException("Input dispatcher source removal remains pending."); + } + + private static void DisposeMouseSource(SilkMouseSource? source) + { + if (source is null) + return; + source.Dispose(); + if (!source.IsDisposalComplete) + throw new InvalidOperationException("Mouse source callback removal remains pending."); + } + + private static void DisposeKeyboardSource(SilkKeyboardSource? source) + { + if (source is null) + return; + source.Dispose(); + if (!source.IsDisposalComplete) + throw new InvalidOperationException("Keyboard source callback removal remains pending."); + } + + private static void DisposeWindowCallbacks(SilkWindowCallbackBinding? binding) + { + if (binding is null) + return; + binding.Dispose(); + if (!binding.IsDisposalComplete) + throw new NativeWindowCallbackCleanupDeferredException(); + } + + private static void DisposeRetailUi(RetailUiRuntimeLease lease) + { + lease.Dispose(); + if (!lease.IsDisposalComplete) + throw new InvalidOperationException("The retained UI ownership lease did not complete disposal."); + } + + private static void DisposeHookRegistrations(AnimationHookRegistrationSet? registrations) + { + if (registrations is null) + return; + registrations.Dispose(); + if (!registrations.IsCleanupComplete) + throw new InvalidOperationException("Animation-hook registration cleanup remains pending."); + } + + private static void DisposeAudio(OpenAlAudioEngine? engine) + { + if (engine is null) + return; + engine.Dispose(); + if (!engine.IsDisposalComplete) + throw new InvalidOperationException("OpenAL native-resource cleanup remains pending."); + } + + private static void DisposeDevToolsFrontend(DevToolsCompositionOwner? owner) + { + if (owner is null) + return; + owner.DisposeFrontend(); + if (!owner.IsFrontendDisposalComplete) + throw new InvalidOperationException("Developer-tools frontend cleanup remains incomplete."); + } +} diff --git a/src/AcDream.App/Rendering/ResourceShutdownTransaction.cs b/src/AcDream.App/Rendering/ResourceShutdownTransaction.cs index 5f4d7167..0a97fb53 100644 --- a/src/AcDream.App/Rendering/ResourceShutdownTransaction.cs +++ b/src/AcDream.App/Rendering/ResourceShutdownTransaction.cs @@ -1,6 +1,21 @@ namespace AcDream.App.Rendering; -internal readonly record struct ResourceShutdownOperation(string Name, Action Execute); +internal enum ResourceShutdownOperationPolicy +{ + HardBarrier, + ReportAndContinue, +} + +internal readonly record struct ResourceShutdownOperation( + string Name, + Action Execute, + ResourceShutdownOperationPolicy Policy = + ResourceShutdownOperationPolicy.HardBarrier); + +internal sealed record ResourceShutdownCleanupFailure( + string Stage, + string Operation, + Exception Error); internal sealed record ResourceShutdownStage( string Name, @@ -16,6 +31,7 @@ internal sealed class ResourceShutdownTransaction(params ResourceShutdownStage[] private sealed class StageState(int operationCount) { public bool[] Complete { get; } = new bool[operationCount]; + public int[] Attempts { get; } = new int[operationCount]; } private const int MaximumConsecutiveStalledPasses = 2; @@ -23,11 +39,15 @@ internal sealed class ResourceShutdownTransaction(params ResourceShutdownStage[] stages ?? throw new ArgumentNullException(nameof(stages)); private readonly StageState[] _states = stages.Select(stage => new StageState(stage.Operations.Length)).ToArray(); + private readonly List _cleanupFailures = []; private int _currentStage; private bool _completing; public bool IsComplete => _currentStage == _stages.Length; internal int CurrentStage => _currentStage; + internal string? CurrentStageName => IsComplete ? null : _stages[_currentStage].Name; + internal IReadOnlyList CleanupFailures => + _cleanupFailures.ToArray(); public void CompleteOrThrow() { @@ -87,9 +107,25 @@ internal sealed class ResourceShutdownTransaction(params ResourceShutdownStage[] } catch (Exception error) { - (failures ??= []).Add(new InvalidOperationException( + var wrapped = new InvalidOperationException( $"Shutdown operation '{operation.Name}' failed in stage '{stage.Name}'.", - error)); + error); + state.Attempts[i]++; + if (operation.Policy == + ResourceShutdownOperationPolicy.ReportAndContinue + && state.Attempts[i] >= MaximumConsecutiveStalledPasses) + { + state.Complete[i] = true; + progressed = true; + _cleanupFailures.Add(new ResourceShutdownCleanupFailure( + stage.Name, + operation.Name, + error)); + } + else + { + (failures ??= []).Add(wrapped); + } } } diff --git a/src/AcDream.App/UI/RetailUiRuntimeLease.cs b/src/AcDream.App/UI/RetailUiRuntimeLease.cs index b7f44bff..19e83dbc 100644 --- a/src/AcDream.App/UI/RetailUiRuntimeLease.cs +++ b/src/AcDream.App/UI/RetailUiRuntimeLease.cs @@ -27,6 +27,8 @@ internal sealed class RetailUiRuntimeLease : IDisposable public bool IsAbandoned => _abandoned; + internal bool HasDisposalFailure => _disposalFailed; + internal bool RetainsResources => _host is not null || _runtime is not null; public UiHost AcquireHost(Func factory) => AcquireHostCore( diff --git a/src/AcDream.App/UI/UiHost.cs b/src/AcDream.App/UI/UiHost.cs index 3aa7eb20..935e9c89 100644 --- a/src/AcDream.App/UI/UiHost.cs +++ b/src/AcDream.App/UI/UiHost.cs @@ -152,7 +152,9 @@ public sealed class UiHost : System.IDisposable } /// Physically removes every retained input edge after quiescence. - public void DeactivateInput() + public void DeactivateInput() => CompleteInputShutdown(reportFailures: true); + + private void CompleteInputShutdown(bool reportFailures) { QuiesceInput(); @@ -164,11 +166,21 @@ public sealed class UiHost : System.IDisposable .Reverse() .Select((binding, index) => new ResourceShutdownOperation( $"input binding {index}", - binding.Dispose)) + binding.Dispose, + ResourceShutdownOperationPolicy.ReportAndContinue)) .ToArray())); _inputShutdown.CompleteOrThrow(); - if (_inputShutdown.IsComplete) + if (_inputShutdown.IsComplete && _inputShutdown.CleanupFailures.Count == 0) _inputBindings.Clear(); + if (reportFailures && _inputShutdown.CleanupFailures.Count != 0) + { + throw new AggregateException( + "Retained UI input callback cleanup completed with failures.", + _inputShutdown.CleanupFailures.Select(static failure => + new InvalidOperationException( + $"Retained UI operation '{failure.Operation}' failed in stage '{failure.Stage}'.", + failure.Error))); + } } // ── Window manager forwarders (delegate to UiRoot) ───────────────── @@ -205,7 +217,7 @@ public sealed class UiHost : System.IDisposable _disposeRequested = true; _shutdown ??= CreateShutdownTransaction( - [DeactivateInput], + [() => CompleteInputShutdown(reportFailures: false)], () => { }, WindowManager.Dispose, TextRenderer.Dispose); diff --git a/tests/AcDream.App.Tests/Rendering/GameWindowLifetimeTests.cs b/tests/AcDream.App.Tests/Rendering/GameWindowLifetimeTests.cs new file mode 100644 index 00000000..a6e04231 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/GameWindowLifetimeTests.cs @@ -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(); + 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(); + 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 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"); + } + } +} diff --git a/tests/AcDream.App.Tests/Rendering/GameWindowRenderLeafCompositionTests.cs b/tests/AcDream.App.Tests/Rendering/GameWindowRenderLeafCompositionTests.cs index 5024d5ce..68e1ac81 100644 --- a/tests/AcDream.App.Tests/Rendering/GameWindowRenderLeafCompositionTests.cs +++ b/tests/AcDream.App.Tests/Rendering/GameWindowRenderLeafCompositionTests.cs @@ -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", diff --git a/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs b/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs index af545f9a..e90c7cb8 100644 --- a/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs +++ b/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs @@ -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", diff --git a/tests/AcDream.App.Tests/Rendering/ResourceShutdownTransactionTests.cs b/tests/AcDream.App.Tests/Rendering/ResourceShutdownTransactionTests.cs index 20e56a53..6a0fbe96 100644 --- a/tests/AcDream.App.Tests/Rendering/ResourceShutdownTransactionTests.cs +++ b/tests/AcDream.App.Tests/Rendering/ResourceShutdownTransactionTests.cs @@ -159,4 +159,97 @@ public sealed class ResourceShutdownTransactionTests Assert.Equal(1, calls); Assert.True(transaction.IsComplete); } + + [Fact] + public void ReportableFailureRetriesThenAllowsDependentStagesAndIsStructured() + { + var calls = new List(); + 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(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); + } } diff --git a/tests/AcDream.App.Tests/Rendering/WorldRenderFrameBuilderTests.cs b/tests/AcDream.App.Tests/Rendering/WorldRenderFrameBuilderTests.cs index e99adb3d..8c41edc0 100644 --- a/tests/AcDream.App.Tests/Rendering/WorldRenderFrameBuilderTests.cs +++ b/tests/AcDream.App.Tests/Rendering/WorldRenderFrameBuilderTests.cs @@ -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( diff --git a/tests/AcDream.App.Tests/Streaming/LandblockBuildOriginTests.cs b/tests/AcDream.App.Tests/Streaming/LandblockBuildOriginTests.cs index e5e49bec..064ffe12 100644 --- a/tests/AcDream.App.Tests/Streaming/LandblockBuildOriginTests.cs +++ b/tests/AcDream.App.Tests/Streaming/LandblockBuildOriginTests.cs @@ -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) =>