refactor(app): extract focused window lifetime
Move the exact retryable shutdown manifest, typed root snapshot, terminal reporting, and native-window-last release out of GameWindow. Keep session and GPU convergence as hard barriers while reporting persistent physical callback cleanup without stranding dependent owners. Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
parent
5a55d08106
commit
31e6e192b3
21 changed files with 1297 additions and 572 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
588
src/AcDream.App/Rendering/GameWindowLifetime.cs
Normal file
588
src/AcDream.App/Rendering/GameWindowLifetime.cs
Normal 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.");
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue