refactor(app): extract focused window lifetime

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

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

View file

@ -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 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 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 baseline. App Release passes 3,431 tests / 3 skips and the complete Release
suite passes 7,803 / 5 skips. Checkpoint I is complete; checkpoints JL—focused suite passes 7,803 / 5 skips. Checkpoint J moves the exact shutdown dependency
retryable shutdown, canonical soak snapshots, and final connected gates—remain graph into `GameWindowLifetime`: immutable typed roots, retry/no-replay state,
active. 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 KL—canonical soak snapshots and final connected
closeout—remain active.
See `docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md` and See `docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md` and
`docs/architecture/code-structure.md`. **Carried:** #232, #153, `docs/architecture/code-structure.md`. **Carried:** #232, #153,
#116, remaining #116, remaining

View file

@ -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 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 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 baseline. App Release passes 3,431 tests / 3 skips and the complete Release
suite passes 7,803 / 5 skips. Checkpoint I is complete; checkpoints JL—focused suite passes 7,803 / 5 skips. Checkpoint J moves the exact shutdown dependency
retryable shutdown, canonical soak snapshots, and final connected gates—remain graph into `GameWindowLifetime`: immutable typed roots, retry/no-replay state,
active. 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 KL—canonical soak snapshots and final connected
closeout—remain active.
See `docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md` and See `docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md` and
`docs/architecture/code-structure.md`. **Carried:** #232, #153, `docs/architecture/code-structure.md`. **Carried:** #232, #153,
#116, remaining #116, remaining

View file

@ -38,9 +38,12 @@ What does NOT go here:
publication, and lifecycle resource snapshot source. I.8c makes session publication, and lifecycle resource snapshot source. I.8c makes session
start the terminal `OnLoad` operation. I.9 corrected the production root to start the terminal `OnLoad` operation. I.9 corrected the production root to
invoke the exact tested pipeline and made every consumed platform/settings invoke the exact tested pipeline and made every consumed platform/settings
result explicit. `GameWindow.OnUpdate` and `GameWindow.OnRender` are typed result explicit. Checkpoint J moves the exact retryable shutdown graph into
orchestration handoffs and the class is 1,945 raw lines. Checkpoint I is `GameWindowLifetime`, including typed roots, hard session/GPU barriers,
complete; the shutdown/lifetime cutover and closeout remain active in 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 KL—the
canonical soak snapshots and final closeout—remain active in
[`docs/architecture/code-structure.md`](architecture/code-structure.md). [`docs/architecture/code-structure.md`](architecture/code-structure.md).
This is the behavior-preserving prerequisite before new M4 feature bodies. This is the behavior-preserving prerequisite before new M4 feature bodies.
- **Connected-gate fidelity:** `#232` adds canonical managed/GPU/cache-owner - **Connected-gate fidelity:** `#232` adds canonical managed/GPU/cache-owner

View file

@ -839,8 +839,13 @@ Current movement/collision ownership:
persistence/current-state owner, supplies the immutable pre-window snapshot, persistence/current-state owner, supplies the immutable pre-window snapshot,
applies startup pacing/display/FOV/audio once, and later borrows complete applies startup pacing/display/FOV/audio once, and later borrows complete
runtime targets without replay. Checked GL construction/state boundaries runtime targets without replay. Checked GL construction/state boundaries
retain exact cleanup obligations across failure. Ordered production retain exact cleanup obligations across failure. The executable nine-phase
composition is the next checkpoint. The reconciled ledger and sequence live in 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 `docs/architecture/code-structure.md`. This decision does not reclassify the
remaining L.2 conformance work or Track LH. remaining L.2 conformance work or Track LH.

View file

@ -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 E 4,266 lines / 194 fields / 65 methods
after Slice 8 checkpoint F 4,057 lines / 198 fields / 54 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 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: `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 future diagnostics can distinguish canonical owner growth from OS/driver/GC
residency without weakening leak detection. residency without weakening leak detection.
#### Slice 8 — composition and shutdown cleanup — ACTIVE #### Slice 8 — composition and shutdown cleanup — ACTIVE (KL remain)
Detailed execution ledger: Detailed execution ledger:
[`docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md`](../plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md). [`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 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 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 pipeline as the failure oracle, carries exact platform/settings results through
the typed contract, and keeps live-session start terminal. Checkpoint I is the typed contract, and keeps live-session start terminal. Checkpoint J then
complete; JL remain active with lifetime/shutdown extraction next. The App gate moves the complete shutdown manifest into `GameWindowLifetime`: typed root
passes 3,431 tests / 3 intentional skips and the complete Release suite passes groups, retry/no-replay progress, structured reportable physical-detach
7,803 tests / 5 intentional skips. The clean solution build retains only the 17 failures, hard session/GPU barriers, and native-window-last release now live
test-project warnings tracked by #228; the complete Checkpoint-I behavior, outside the host. The shell captures roots once and makes one lifetime call
architecture, and adversarial corrected-diff passes are clean. 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 KL remain active.
### 4.4 Exit criteria ### 4.4 Exit criteria

View file

@ -1,6 +1,6 @@
# acdream — strategic roadmap # 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 17 and Slice 8 checkpoints AH 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 17 and Slice 8 checkpoints AJ are complete, with only canonical soak instrumentation and final closeout checkpoints KL 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. **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, I.7 adds the complete streaming/session/hydration/local-player/teleport phase,
including explicit reversible ownership for every late edge. `GameWindow` is 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 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 as the failure oracle and keeps session start terminal. Checkpoint J extracts
complete; shutdown, canonical soak snapshots, and closeout checkpoints JL the exact shutdown dependency graph into `GameWindowLifetime`, with typed
remain. The App gate passes 3,431 tests / 3 intentional skips, the complete immutable roots, hard session/GPU barriers, reportable physical cleanup,
Release suite passes 7,803 tests / 5 skips, and the complete Checkpoint-I 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
KL 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. corrected-diff review is clean.
This is a behavior-preserving structural program. Severe regressions still get This is a behavior-preserving structural program. Severe regressions still get

View file

@ -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 atomically publishes the complete frame pair and extracts lifecycle resource
sampling. I.8c makes session start the terminal `OnLoad` operation. sampling. I.8c makes session start the terminal `OnLoad` operation.
`GameWindow` is 1,945 raw lines after I.9 routes production through the exact `GameWindow` is 1,945 raw lines after I.9 routes production through the exact
tested pipeline with explicit prior-result dependencies. Checkpoint I is tested pipeline with explicit prior-result dependencies. Checkpoint J adds the
complete and checkpoints JL remain active. Issue #232 tracks focused `GameWindowLifetime`, immutable typed shutdown roots, hard dependency
process-residency variance in the soak without loosening its leak threshold. barriers, structured report-and-continue physical cleanup, retry/no-replay
The App gate passes 3,431 tests / 3 skips, the complete Release suite passes state, and native-window-last terminal reporting. The host is now 1,625 raw
7,803 tests / 5 skips, and the complete Checkpoint-I corrected-diff review is lines. Checkpoints KL remain active; Issue #232 tracks process-residency
clean. 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: Carried:
#153 far-teleport residual, #116 slide-response, the remaining R6 ownership cleanup plus #153 far-teleport residual, #116 slide-response, the remaining R6 ownership cleanup plus

View file

@ -1,6 +1,6 @@
# GameWindow Slice 8 Checkpoint J — lifetime and shutdown # GameWindow Slice 8 Checkpoint J — lifetime and shutdown
**Status:** Active 2026-07-22. **Status:** Complete 2026-07-22.
**Parent:** **Parent:**
[`2026-07-22-gamewindow-slice-8-composition-lifecycle.md`](2026-07-22-gamewindow-slice-8-composition-lifecycle.md), [`2026-07-22-gamewindow-slice-8-composition-lifecycle.md`](2026-07-22-gamewindow-slice-8-composition-lifecycle.md),
Checkpoint J. Checkpoint J.
@ -12,6 +12,17 @@ passes 7,803 tests / 5 skips.
frame withdrawal, GL dependency, and native-window-last order. This checkpoint frame withdrawal, GL dependency, and native-window-last order. This checkpoint
changes lifetime ownership and failure reporting, not gameplay behavior. 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 ## 1. Outcome
Replace `GameWindow.CreateShutdownTransaction` and the window-owned transaction Replace `GameWindow.CreateShutdownTransaction` and the window-owned transaction

View file

@ -32,7 +32,7 @@ audit.
transactional partial-acquisition rollback. Production and failure tests now transactional partial-acquisition rollback. Production and failure tests now
invoke the same pipeline and carry exact typed prior results. Detailed plan: 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). [`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: 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). [`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 - [ ] 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 lifecycle/soak, framebuffer comparison, documentation, memory, and final
visual handoff. 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 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 while a new class delegates a substantial body back into `GameWindow`, stores
`GameWindow`, or replaces 196 fields with one runtime/service-locator bag. `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 after native fallback the terminal abandoned state makes repeated Dispose
inert. 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) ### K — canonical soak checkpoints (#232, separate commit)
- Extract `WorldLifecycleResourceSnapshotSource` from the window and reuse the - Extract `WorldLifecycleResourceSnapshotSource` from the window and reuse the

View file

@ -194,6 +194,8 @@ internal sealed class DevToolsCompositionOwner : IDisposable
public bool IsDisposalComplete => public bool IsDisposalComplete =>
_frontendShutdown.IsComplete && !_ownsInput; _frontendShutdown.IsComplete && !_ownsInput;
internal bool IsFrontendDisposalComplete => _frontendShutdown.IsComplete;
public void AdoptInput() => _ownsInput = true; public void AdoptInput() => _ownsInput = true;
public void AdoptBackend() => _ownsBackend = true; public void AdoptBackend() => _ownsBackend = true;
public void AdoptChat() => _ownsChat = true; public void AdoptChat() => _ownsChat = true;

View file

@ -114,7 +114,7 @@ public sealed class GameWindow :
_glConstructionCleanup = new(); _glConstructionCleanup = new();
private readonly AcDream.App.World.WorldEnvironmentController _worldEnvironment = private readonly AcDream.App.World.WorldEnvironmentController _worldEnvironment =
new(Console.WriteLine); new(Console.WriteLine);
private ResourceShutdownTransaction? _shutdown; private readonly GameWindowLifetime _lifetime = new();
private readonly DisplayFramePacingController _displayFramePacing; private readonly DisplayFramePacingController _displayFramePacing;
private readonly RuntimeSettingsController _runtimeSettings; private readonly RuntimeSettingsController _runtimeSettings;
@ -640,6 +640,7 @@ public sealed class GameWindow :
}; };
_window = Window.Create(options); _window = Window.Create(options);
_lifetime.PublishNativeWindow(_window);
_displayFramePacing.BindSurface( _displayFramePacing.BindSurface(
new SilkDisplayFramePacingSurface(_window)); new SilkDisplayFramePacingSurface(_window));
// The fixed binding preserves main Render before post-render pacing, // 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<int> newSize) private void OnFramebufferResize(Silk.NET.Maths.Vector2D<int> newSize)
=> _framebufferResize.Resize(newSize); => _framebufferResize.Resize(newSize);
private void OnClosing() private void OnClosing() => CompleteShutdown(releaseNativeWindow: false);
private void CompleteShutdown(bool releaseNativeWindow)
{ {
_hostQuiescence.StopAccepting(); if (!_lifetime.HasShutdownRoots)
CompleteShutdown(); _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() private GameWindowShutdownRoots CaptureShutdownRoots() => new(
{ new IngressShutdownRoots(
_hostQuiescence.StopAccepting(); _hostQuiescence,
_shutdown ??= CreateShutdownTransaction(); _liveCombatModeCommands,
try _runtimeDiagnosticCommands,
{ _retainedUiGameplayBinding,
_shutdown.CompleteOrThrow(); _gameplayInputActions,
} _cameraPointerInput,
catch (Exception error) _inputDispatcher,
{ _mouseSource,
// The session/network stage runs first, so ACE is disconnected _kbSource,
// gracefully even if a persistent driver failure prevents full GL _retailUiLease,
// convergence. Later dependencies remain intact and the native _uiHost,
// context/process teardown is the final safety net. _devToolsComposition,
Console.Error.WriteLine($"[shutdown] {error}"); _liveSessionController,
} _runtimeSettings,
} _movementInput,
_cameraInput,
private ResourceShutdownTransaction CreateShutdownTransaction() => new( _windowCallbacks),
// Logical cutoff precedes the live session's potentially long graceful new FrameShutdownRoots(
// close. Physical event removal follows session retirement, so a bad _frameGraphPublication,
// Silk accessor cannot prevent F653/transport teardown. The input _frameRootBindings,
// context and UI owners remain alive for reset, while copied callbacks _sessionPlayerBindings,
// are already inert. _interactionUiLateBindings),
new ResourceShutdownStage("input callback deactivation", new LiveShutdownRoots(
[ _cameraPointerInput,
new("combat command slot", _liveCombatModeCommands.Deactivate), _retailUiLease,
new("diagnostic command slot", _runtimeDiagnosticCommands.Deactivate), _combatTargetController,
new("retained gameplay", () => _retainedUiGameplayBinding?.Deactivate()), _combatAttackController,
new("gameplay actions", () => _gameplayInputActions?.Deactivate()), _itemInteractionController,
new("camera pointer", () => _cameraPointerInput?.Deactivate()), _externalContainerLifecycle,
new("dispatcher", () => _inputDispatcher?.Deactivate()), _streamer,
new("mouse source", () => _mouseSource?.Deactivate()), _equippedChildRenderer,
new("keyboard source", () => _kbSource?.Deactivate()), _liveEntities,
new("retained UI input", _retailUiLease.QuiesceInput), _livePresentationBindings,
new("developer tools input", () => _devToolsComposition?.DeactivateInput()), _entityEffectAdvance,
]), _entityEffects,
// Live-session reset retires the complete streaming window. Every _hookRegistrations,
// reset callback owner, especially LandblockStreamer, must remain live _liveEntityLights,
// until that transaction converges. _liveEntityPresentation,
new ResourceShutdownStage("session lifetime", _remoteTeleportController,
[ _animationHookFrames,
new("live session", () => _effectPoses,
{ _audioEngine),
AcDream.App.Net.LiveSessionController? controller = new RenderShutdownRoots(
_liveSessionController; _gpuFrameFlights,
if (controller is null) _devToolsComposition,
return; _localPlayerTeleport,
_portalTunnelFallback,
controller.Dispose(); _paperdollViewportRenderer,
if (!controller.IsDisposalComplete) _wbDrawDispatcher,
{ _envCellRenderer,
throw new InvalidOperationException( _portalDepthMask,
"Live-session disposal was deferred by a reentrant lifecycle callback."); _clipFrame,
} _skyRenderer,
_particleRenderer,
if (ReferenceEquals(_liveSessionController, controller)) _samplerCache,
{ _textureCache,
_liveSessionHost = null; _wbMeshAdapter,
_liveSessionController = null; _meshShader,
} _terrain,
}), _terrainModernShader,
]), _sceneLightingUbo,
new ResourceShutdownStage("input callback detach", _debugLines,
[ _textRenderer,
new("settings view model", () => _runtimeSettings.UnbindViewModel()), _debugFont,
new("retained gameplay", () => _displayFramePacing,
{ _frameProfiler,
AcDream.App.Input.RetainedUiGameplayBinding? binding = _renderResourceLifetime,
_retainedUiGameplayBinding; _glConstructionCleanup),
if (binding is null) return; new PlatformShutdownRoots(
binding.Dispose(); _dats,
if (!binding.IsDisposalComplete) _input,
throw new InvalidOperationException( _gl));
"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 void OnFocusChanged(bool focused) private void OnFocusChanged(bool focused)
=> _cameraPointerInput?.HandleFocusChanged(focused); => _cameraPointerInput?.HandleFocusChanged(focused);
@ -1937,8 +1618,7 @@ public sealed class GameWindow :
// window and exceptions during Window.Create/Run, so owned kernel // window and exceptions during Window.Create/Run, so owned kernel
// handles (including the frame timer) never depend on a native window // handles (including the frame timer) never depend on a native window
// event for disposal. // event for disposal.
CompleteShutdown(); CompleteShutdown(releaseNativeWindow: true);
_window?.Dispose();
_window = null; _window = null;
} }

View file

@ -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<ResourceShutdownCleanupFailure> 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<PortalTunnelPresentation> 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);
/// <summary>
/// Sole process-window lifetime owner. Runtime code receives no service lookup
/// through this type; it retains only teardown roots and the immutable terminal
/// report.
/// </summary>
internal sealed class GameWindowLifetime
{
private readonly Func<ResourceShutdownTransaction>? _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<ResourceShutdownTransaction> 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.");
}
}

View file

@ -1,6 +1,21 @@
namespace AcDream.App.Rendering; 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( internal sealed record ResourceShutdownStage(
string Name, string Name,
@ -16,6 +31,7 @@ internal sealed class ResourceShutdownTransaction(params ResourceShutdownStage[]
private sealed class StageState(int operationCount) private sealed class StageState(int operationCount)
{ {
public bool[] Complete { get; } = new bool[operationCount]; public bool[] Complete { get; } = new bool[operationCount];
public int[] Attempts { get; } = new int[operationCount];
} }
private const int MaximumConsecutiveStalledPasses = 2; private const int MaximumConsecutiveStalledPasses = 2;
@ -23,11 +39,15 @@ internal sealed class ResourceShutdownTransaction(params ResourceShutdownStage[]
stages ?? throw new ArgumentNullException(nameof(stages)); stages ?? throw new ArgumentNullException(nameof(stages));
private readonly StageState[] _states = private readonly StageState[] _states =
stages.Select(stage => new StageState(stage.Operations.Length)).ToArray(); stages.Select(stage => new StageState(stage.Operations.Length)).ToArray();
private readonly List<ResourceShutdownCleanupFailure> _cleanupFailures = [];
private int _currentStage; private int _currentStage;
private bool _completing; private bool _completing;
public bool IsComplete => _currentStage == _stages.Length; public bool IsComplete => _currentStage == _stages.Length;
internal int CurrentStage => _currentStage; internal int CurrentStage => _currentStage;
internal string? CurrentStageName => IsComplete ? null : _stages[_currentStage].Name;
internal IReadOnlyList<ResourceShutdownCleanupFailure> CleanupFailures =>
_cleanupFailures.ToArray();
public void CompleteOrThrow() public void CompleteOrThrow()
{ {
@ -87,9 +107,25 @@ internal sealed class ResourceShutdownTransaction(params ResourceShutdownStage[]
} }
catch (Exception error) catch (Exception error)
{ {
(failures ??= []).Add(new InvalidOperationException( var wrapped = new InvalidOperationException(
$"Shutdown operation '{operation.Name}' failed in stage '{stage.Name}'.", $"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);
}
} }
} }

View file

@ -27,6 +27,8 @@ internal sealed class RetailUiRuntimeLease : IDisposable
public bool IsAbandoned => _abandoned; public bool IsAbandoned => _abandoned;
internal bool HasDisposalFailure => _disposalFailed;
internal bool RetainsResources => _host is not null || _runtime is not null; internal bool RetainsResources => _host is not null || _runtime is not null;
public UiHost AcquireHost(Func<UiHost> factory) => AcquireHostCore( public UiHost AcquireHost(Func<UiHost> factory) => AcquireHostCore(

View file

@ -152,7 +152,9 @@ public sealed class UiHost : System.IDisposable
} }
/// <summary>Physically removes every retained input edge after quiescence.</summary> /// <summary>Physically removes every retained input edge after quiescence.</summary>
public void DeactivateInput() public void DeactivateInput() => CompleteInputShutdown(reportFailures: true);
private void CompleteInputShutdown(bool reportFailures)
{ {
QuiesceInput(); QuiesceInput();
@ -164,11 +166,21 @@ public sealed class UiHost : System.IDisposable
.Reverse() .Reverse()
.Select((binding, index) => new ResourceShutdownOperation( .Select((binding, index) => new ResourceShutdownOperation(
$"input binding {index}", $"input binding {index}",
binding.Dispose)) binding.Dispose,
ResourceShutdownOperationPolicy.ReportAndContinue))
.ToArray())); .ToArray()));
_inputShutdown.CompleteOrThrow(); _inputShutdown.CompleteOrThrow();
if (_inputShutdown.IsComplete) if (_inputShutdown.IsComplete && _inputShutdown.CleanupFailures.Count == 0)
_inputBindings.Clear(); _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) ───────────────── // ── Window manager forwarders (delegate to UiRoot) ─────────────────
@ -205,7 +217,7 @@ public sealed class UiHost : System.IDisposable
_disposeRequested = true; _disposeRequested = true;
_shutdown ??= CreateShutdownTransaction( _shutdown ??= CreateShutdownTransaction(
[DeactivateInput], [() => CompleteInputShutdown(reportFailures: false)],
() => { }, () => { },
WindowManager.Dispose, WindowManager.Dispose,
TextRenderer.Dispose); TextRenderer.Dispose);

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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