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

@ -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;

View file

@ -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<int> 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;
}

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;
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<ResourceShutdownCleanupFailure> _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<ResourceShutdownCleanupFailure> 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);
}
}
}

View file

@ -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<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>
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);