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

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