diff --git a/docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md b/docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md index 61eccef9..b60e4e37 100644 --- a/docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md +++ b/docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md @@ -16,7 +16,7 @@ audit. - [x] A — freeze construction/callback/shutdown order and delete proven dead or test-facade residue. -- [ ] B — make native-window callback intake an explicit reversible owner and +- [x] B — make native-window callback intake an explicit reversible owner and define host-quiescence failure semantics. - [ ] C — extract live-session host/reset/binding callbacks and verify the embedded skill formula against named retail. @@ -220,20 +220,31 @@ Add a focused reversible binding equivalent to: ```csharp internal sealed class SilkWindowCallbackBinding : IDisposable { - public static SilkWindowCallbackBinding Attach( + public static SilkWindowCallbackBinding Create( IWindow window, WindowCallbackTargets targets, DisplayFramePacingController pacing); + + public void Attach(); } ``` `WindowCallbackTargets` is a fixed typed set for Load, Update, Render, Closing, -FocusChanged, and FramebufferResize. It is not a general callback list. Attach -rolls back in reverse order if any registration fails; Dispose detaches in -reverse registration order, is idempotent/reentrant-safe, and makes later -window events inert. The separate pacing Render callback remains ordered after -main Render. Every callback first checks the shared no-throw host-quiescence -gate, so a failed physical unsubscribe cannot re-enter a retired owner. +FocusChanged, and FramebufferResize. It is not a general callback list. The +binding is published into its lifetime slot after `Create` and before `Attach` +begins, so even an attach failure plus rollback failure retains a cleanup owner. +Attach treats the current edge as possibly acquired before calling a custom +event add accessor and rolls back in reverse order if registration fails. +The explicit lifecycle state monitor is released around every external event +accessor; external cleanup waits for Attaching/Detaching to converge, while a +gate-owned reentrant cleanup reports typed deferred completion instead of +deadlocking or falsely succeeding. Dispose detaches in reverse registration +order, joins concurrent callers, is idempotent/reentrant-safe, and makes later +window events inert. `GameWindow` clears the retained slot only after terminal +disposal. The separate pacing Render callback remains ordered after main Render. +Every callback enters the shared no-throw host-quiescence monitor, so external +shutdown drains an admitted callback, Closing can stop reentrantly, and a failed +physical unsubscribe cannot re-enter a retired owner. ### 3.2 Input owners @@ -416,6 +427,17 @@ tests and the complete App suite (2,991 pass / 3 intentional skips) pass. - Add post-detach silence, failure-after-Nth-attach, repeated/reentrant Dispose, callback-during-detach, and persistent physical-detach tests. +Result: one fixed typed binding now owns the exact nine Silk edges, including +the two ordered Render callbacks. Create/publish/Attach and the explicit +lifecycle state preserve cleanup ownership across partial event-accessor +failure, rollback failure, concurrent or reentrant shutdown, and condition +wakeups. The shared host gate drains admitted callbacks and makes failed +physical detach logically inert. Three corrected-diff review loops are clean; +54 focused tests, the App suite (3,027 pass / 3 intentional skips), and the full +Release suite (7,386 pass / 5 intentional skips) pass. No connected gate was +required because this checkpoint changes ownership only and preserves the +frozen callback behavior. + ### C — live-session host and skill formula - Extract selection/entry/reset/binding factories around the existing canonical diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index e94cbb6d..b1cefe90 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -22,7 +22,9 @@ public sealed class GameWindow : IDisposable private readonly WorldGameState _worldGameState; private readonly WorldEvents _worldEvents; private readonly AcDream.Core.Selection.SelectionState _selection; + private readonly HostQuiescenceGate _hostQuiescence = new(); private IWindow? _window; + private SilkWindowCallbackBinding? _windowCallbacks; private GL? _gl; private IInputContext? _input; private TerrainModernRenderer? _terrain; @@ -667,23 +669,20 @@ public sealed class GameWindow : IDisposable _window = Window.Create(options); _displayFramePacing.BindSurface( new SilkDisplayFramePacingSurface(_window)); - _window.Load += OnLoad; - _window.Update += OnUpdate; - _window.Render += OnRender; - // Registered after OnRender so software pacing waits after all frame - // work and before Silk.NET performs its automatic buffer swap. - _window.Render += _displayFramePacing.OnFrameRendered; - _window.Closing += OnClosing; - _window.FocusChanged += OnFocusChanged; - _window.Move += _displayFramePacing.OnWindowMoved; - _window.StateChanged += _displayFramePacing.OnWindowStateChanged; - // L.0 Display tab: keep the GL viewport + camera aspect in sync - // with the window framebuffer. Without this handler, resizing - // the window (or applying a Display-tab Resolution change at - // startup) leaves the viewport pinned to the original size — - // user sees a small render in the corner of a big window. - _window.FramebufferResize += OnFramebufferResize; - + // The fixed binding preserves main Render before post-render pacing, + // owns rollback/reverse-detach, and gates every native entry point. + _windowCallbacks = SilkWindowCallbackBinding.Create( + _window, + new WindowCallbackTargets( + OnLoad, + OnUpdate, + OnRender, + OnClosing, + OnFocusChanged, + OnFramebufferResize), + _displayFramePacing, + _hostQuiescence); + _windowCallbacks.Attach(); _window.Run(); } @@ -4375,10 +4374,14 @@ public sealed class GameWindow : IDisposable } private void OnClosing() - => CompleteShutdown(); + { + _hostQuiescence.StopAccepting(); + CompleteShutdown(); + } private void CompleteShutdown() { + _hostQuiescence.StopAccepting(); _shutdown ??= CreateShutdownTransaction(); try { @@ -4574,6 +4577,18 @@ public sealed class GameWindow : IDisposable ]), 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("combat input subscription", () => Combat.CombatModeChanged -= SetInputCombatScope), new("input context", () => diff --git a/src/AcDream.App/Rendering/HostQuiescenceGate.cs b/src/AcDream.App/Rendering/HostQuiescenceGate.cs new file mode 100644 index 00000000..8685e173 --- /dev/null +++ b/src/AcDream.App/Rendering/HostQuiescenceGate.cs @@ -0,0 +1,55 @@ +namespace AcDream.App.Rendering; + +/// +/// Single logical admission gate for callbacks entering the application host. +/// Physical event removal remains important for ownership, but closing this +/// gate first makes a still-attached native callback inert while teardown +/// converges. +/// +internal sealed class HostQuiescenceGate +{ + private readonly object _sync = new(); + private bool _accepting = true; + + public bool IsAccepting + { + get + { + lock (_sync) + return _accepting; + } + } + + internal bool IsEnteredByCurrentThread => Monitor.IsEntered(_sync); + + /// + /// Closes admission after any callback already executing on another + /// thread has returned. Monitor reentrancy lets the native Closing + /// callback close its own gate without waiting on itself. + /// + public void StopAccepting() + { + lock (_sync) + _accepting = false; + } + + public void Invoke(Action callback) + { + ArgumentNullException.ThrowIfNull(callback); + lock (_sync) + { + if (_accepting) + callback(); + } + } + + public void Invoke(Action callback, T value) + { + ArgumentNullException.ThrowIfNull(callback); + lock (_sync) + { + if (_accepting) + callback(value); + } + } +} diff --git a/src/AcDream.App/Rendering/SilkWindowCallbackBinding.cs b/src/AcDream.App/Rendering/SilkWindowCallbackBinding.cs new file mode 100644 index 00000000..73081938 --- /dev/null +++ b/src/AcDream.App/Rendering/SilkWindowCallbackBinding.cs @@ -0,0 +1,413 @@ +using Silk.NET.Maths; +using Silk.NET.Windowing; + +namespace AcDream.App.Rendering; + +internal sealed class NativeWindowCallbackCleanupDeferredException() + : InvalidOperationException( + "Native window callback cleanup was requested reentrantly and remains pending."); + +/// Fixed application callbacks accepted from the native window. +internal sealed class WindowCallbackTargets +{ + public WindowCallbackTargets( + Action load, + Action update, + Action render, + Action closing, + Action focusChanged, + Action> framebufferResize) + { + Load = load ?? throw new ArgumentNullException(nameof(load)); + Update = update ?? throw new ArgumentNullException(nameof(update)); + Render = render ?? throw new ArgumentNullException(nameof(render)); + Closing = closing ?? throw new ArgumentNullException(nameof(closing)); + FocusChanged = focusChanged ?? throw new ArgumentNullException(nameof(focusChanged)); + FramebufferResize = framebufferResize + ?? throw new ArgumentNullException(nameof(framebufferResize)); + } + + public Action Load { get; } + public Action Update { get; } + public Action Render { get; } + public Action Closing { get; } + public Action FocusChanged { get; } + public Action> FramebufferResize { get; } +} + +/// +/// Narrow event surface used to test native callback acquisition without +/// implementing Silk's complete contract. +/// +internal interface IWindowCallbackSurface +{ + void AddLoad(Action callback); + void RemoveLoad(Action callback); + void AddUpdate(Action callback); + void RemoveUpdate(Action callback); + void AddRender(Action callback); + void RemoveRender(Action callback); + void AddClosing(Action callback); + void RemoveClosing(Action callback); + void AddFocusChanged(Action callback); + void RemoveFocusChanged(Action callback); + void AddMove(Action> callback); + void RemoveMove(Action> callback); + void AddStateChanged(Action callback); + void RemoveStateChanged(Action callback); + void AddFramebufferResize(Action> callback); + void RemoveFramebufferResize(Action> callback); +} + +/// Production adapter over Silk's native event surface. +internal sealed class SilkWindowCallbackSurface(IWindow window) : IWindowCallbackSurface +{ + private readonly IWindow _window = window ?? throw new ArgumentNullException(nameof(window)); + + public void AddLoad(Action callback) => _window.Load += callback; + public void RemoveLoad(Action callback) => _window.Load -= callback; + public void AddUpdate(Action callback) => _window.Update += callback; + public void RemoveUpdate(Action callback) => _window.Update -= callback; + public void AddRender(Action callback) => _window.Render += callback; + public void RemoveRender(Action callback) => _window.Render -= callback; + public void AddClosing(Action callback) => _window.Closing += callback; + public void RemoveClosing(Action callback) => _window.Closing -= callback; + public void AddFocusChanged(Action callback) => _window.FocusChanged += callback; + public void RemoveFocusChanged(Action callback) => _window.FocusChanged -= callback; + public void AddMove(Action> callback) => _window.Move += callback; + public void RemoveMove(Action> callback) => _window.Move -= callback; + public void AddStateChanged(Action callback) => _window.StateChanged += callback; + public void RemoveStateChanged(Action callback) => _window.StateChanged -= callback; + public void AddFramebufferResize(Action> callback) => + _window.FramebufferResize += callback; + public void RemoveFramebufferResize(Action> callback) => + _window.FramebufferResize -= callback; +} + +/// +/// Owns the exact GameWindow-to-Silk callback edge. Registration is +/// transactional, physical removal is retriable, and the shared logical gate +/// is closed before any detach can re-enter a retiring owner. +/// +internal sealed class SilkWindowCallbackBinding : IDisposable +{ + private enum LifecycleState + { + Created, + Attaching, + Attached, + AttachFailed, + Detaching, + DetachPending, + Detached, + } + + private const int LoadIndex = 0; + private const int UpdateIndex = 1; + private const int MainRenderIndex = 2; + private const int PacingRenderIndex = 3; + private const int ClosingIndex = 4; + private const int FocusChangedIndex = 5; + private const int MoveIndex = 6; + private const int StateChangedIndex = 7; + private const int FramebufferResizeIndex = 8; + private const int AttachmentCount = 9; + + private readonly IWindowCallbackSurface _surface; + private readonly HostQuiescenceGate _quiescence; + private readonly Action _load; + private readonly Action _update; + private readonly Action _mainRender; + private readonly Action _pacingRender; + private readonly Action _closing; + private readonly Action _focusChanged; + private readonly Action> _moved; + private readonly Action _stateChanged; + private readonly Action> _framebufferResize; + private readonly ResourceShutdownTransaction _detach; + private readonly bool[] _attached = new bool[AttachmentCount]; + private readonly object _lifecycleSync = new(); + private int _attachedCount; + private int _detachRequested; + private int _lifecycleOwnerThreadId; + private LifecycleState _lifecycleState = LifecycleState.Created; + + private SilkWindowCallbackBinding( + IWindowCallbackSurface surface, + WindowCallbackTargets targets, + DisplayFramePacingController pacing, + HostQuiescenceGate quiescence) + { + _surface = surface ?? throw new ArgumentNullException(nameof(surface)); + ArgumentNullException.ThrowIfNull(targets); + ArgumentNullException.ThrowIfNull(pacing); + _quiescence = quiescence ?? throw new ArgumentNullException(nameof(quiescence)); + + _load = () => _quiescence.Invoke(targets.Load); + _update = value => _quiescence.Invoke(targets.Update, value); + _mainRender = value => _quiescence.Invoke(targets.Render, value); + _pacingRender = value => _quiescence.Invoke(pacing.OnFrameRendered, value); + _closing = () => _quiescence.Invoke(targets.Closing); + _focusChanged = value => _quiescence.Invoke(targets.FocusChanged, value); + _moved = value => _quiescence.Invoke(pacing.OnWindowMoved, value); + _stateChanged = value => _quiescence.Invoke(pacing.OnWindowStateChanged, value); + _framebufferResize = value => _quiescence.Invoke(targets.FramebufferResize, value); + + _detach = new ResourceShutdownTransaction( + new ResourceShutdownStage("native window callbacks", + [ + new("framebuffer resize", () => Remove(FramebufferResizeIndex)), + new("window state", () => Remove(StateChangedIndex)), + new("window move", () => Remove(MoveIndex)), + new("focus changed", () => Remove(FocusChangedIndex)), + new("closing", () => Remove(ClosingIndex)), + new("pacing render", () => Remove(PacingRenderIndex)), + new("main render", () => Remove(MainRenderIndex)), + new("update", () => Remove(UpdateIndex)), + new("load", () => Remove(LoadIndex)), + ])); + } + + public bool IsDetached + { + get + { + lock (_lifecycleSync) + return _attached.All(static attached => !attached); + } + } + + public bool IsDisposalComplete + { + get + { + lock (_lifecycleSync) + return _lifecycleState == LifecycleState.Detached; + } + } + + public static SilkWindowCallbackBinding Create( + IWindow window, + WindowCallbackTargets targets, + DisplayFramePacingController pacing, + HostQuiescenceGate quiescence) => + Create(new SilkWindowCallbackSurface(window), targets, pacing, quiescence); + + internal static SilkWindowCallbackBinding Create( + IWindowCallbackSurface surface, + WindowCallbackTargets targets, + DisplayFramePacingController pacing, + HostQuiescenceGate quiescence) => + new(surface, targets, pacing, quiescence); + + public void Attach() + { + int threadId = Environment.CurrentManagedThreadId; + lock (_lifecycleSync) + { + if (_lifecycleState != LifecycleState.Created) + { + throw _lifecycleState == LifecycleState.Detached + ? new ObjectDisposedException(nameof(SilkWindowCallbackBinding)) + : new InvalidOperationException( + "Native window callback attachment has already started."); + } + if (Volatile.Read(ref _detachRequested) != 0 || _detach.IsComplete) + throw new ObjectDisposedException(nameof(SilkWindowCallbackBinding)); + + _lifecycleState = LifecycleState.Attaching; + _lifecycleOwnerThreadId = threadId; + } + + try + { + Attach(LoadIndex); + Attach(UpdateIndex); + Attach(MainRenderIndex); + Attach(PacingRenderIndex); + Attach(ClosingIndex); + Attach(FocusChangedIndex); + Attach(MoveIndex); + Attach(StateChangedIndex); + Attach(FramebufferResizeIndex); + + lock (_lifecycleSync) + { + if (Volatile.Read(ref _detachRequested) != 0) + { + throw new ObjectDisposedException( + nameof(SilkWindowCallbackBinding), + "Shutdown was requested while native callbacks were attaching."); + } + + _lifecycleState = LifecycleState.Attached; + _lifecycleOwnerThreadId = 0; + System.Threading.Monitor.PulseAll(_lifecycleSync); + } + } + catch (Exception attachError) + { + Interlocked.Exchange(ref _detachRequested, 1); + _quiescence.StopAccepting(); + List? rollbackErrors = null; + for (int index = _attachedCount - 1; index >= 0; index--) + { + try + { + Remove(index); + } + catch (Exception rollbackError) + { + (rollbackErrors ??= []).Add(new InvalidOperationException( + $"Failed to roll back native window callback index {index}.", + rollbackError)); + } + } + + lock (_lifecycleSync) + { + _lifecycleState = LifecycleState.AttachFailed; + _lifecycleOwnerThreadId = 0; + System.Threading.Monitor.PulseAll(_lifecycleSync); + } + + if (rollbackErrors is not null) + { + rollbackErrors.Insert(0, new InvalidOperationException( + "Native window callback registration failed.", attachError)); + throw new AggregateException( + "Native window callback registration and rollback both failed.", + rollbackErrors); + } + + throw new InvalidOperationException( + "Native window callback registration failed and was rolled back.", + attachError); + } + } + + private void Attach(int index) + { + // A custom event add accessor is allowed to perform its side effect + // before throwing. Treat the edge as possibly acquired until a + // matching remove succeeds; removing an unattached delegate is safe. + lock (_lifecycleSync) + { + _attached[index] = true; + _attachedCount = Math.Max(_attachedCount, index + 1); + } + switch (index) + { + case LoadIndex: _surface.AddLoad(_load); break; + case UpdateIndex: _surface.AddUpdate(_update); break; + case MainRenderIndex: _surface.AddRender(_mainRender); break; + case PacingRenderIndex: _surface.AddRender(_pacingRender); break; + case ClosingIndex: _surface.AddClosing(_closing); break; + case FocusChangedIndex: _surface.AddFocusChanged(_focusChanged); break; + case MoveIndex: _surface.AddMove(_moved); break; + case StateChangedIndex: _surface.AddStateChanged(_stateChanged); break; + case FramebufferResizeIndex: + _surface.AddFramebufferResize(_framebufferResize); + break; + default: throw new ArgumentOutOfRangeException(nameof(index)); + } + + if (Volatile.Read(ref _detachRequested) != 0) + { + throw new ObjectDisposedException( + nameof(SilkWindowCallbackBinding), + "Shutdown was requested while native callbacks were attaching."); + } + } + + private void Remove(int index) + { + lock (_lifecycleSync) + { + if (index >= _attachedCount || !_attached[index]) + return; + } + + switch (index) + { + case LoadIndex: _surface.RemoveLoad(_load); break; + case UpdateIndex: _surface.RemoveUpdate(_update); break; + case MainRenderIndex: _surface.RemoveRender(_mainRender); break; + case PacingRenderIndex: _surface.RemoveRender(_pacingRender); break; + case ClosingIndex: _surface.RemoveClosing(_closing); break; + case FocusChangedIndex: _surface.RemoveFocusChanged(_focusChanged); break; + case MoveIndex: _surface.RemoveMove(_moved); break; + case StateChangedIndex: _surface.RemoveStateChanged(_stateChanged); break; + case FramebufferResizeIndex: + _surface.RemoveFramebufferResize(_framebufferResize); + break; + default: throw new ArgumentOutOfRangeException(nameof(index)); + } + + lock (_lifecycleSync) + _attached[index] = false; + } + + public void Dispose() + { + Interlocked.Exchange(ref _detachRequested, 1); + // Gate order is deliberate: never hold the physical-detach monitor + // while waiting for an in-flight native callback to finish. Closing + // itself holds the gate reentrantly and may enter this method. + _quiescence.StopAccepting(); + int threadId = Environment.CurrentManagedThreadId; + bool ownsGate = _quiescence.IsEnteredByCurrentThread; + while (true) + { + lock (_lifecycleSync) + { + if (_lifecycleState == LifecycleState.Detached) + return; + if (_lifecycleState == LifecycleState.Attaching) + { + if (_lifecycleOwnerThreadId == threadId || ownsGate) + throw new NativeWindowCallbackCleanupDeferredException(); + + System.Threading.Monitor.Wait(_lifecycleSync); + continue; + } + if (_lifecycleState == LifecycleState.Detaching) + { + if (_lifecycleOwnerThreadId == threadId) + return; + if (ownsGate) + throw new NativeWindowCallbackCleanupDeferredException(); + + System.Threading.Monitor.Wait(_lifecycleSync); + continue; + } + + _lifecycleState = LifecycleState.Detaching; + _lifecycleOwnerThreadId = threadId; + break; + } + } + + try + { + _detach.CompleteOrThrow(); + lock (_lifecycleSync) + { + _lifecycleState = LifecycleState.Detached; + _lifecycleOwnerThreadId = 0; + System.Threading.Monitor.PulseAll(_lifecycleSync); + } + } + catch + { + lock (_lifecycleSync) + { + _lifecycleState = LifecycleState.DetachPending; + _lifecycleOwnerThreadId = 0; + System.Threading.Monitor.PulseAll(_lifecycleSync); + } + + throw; + } + } +} diff --git a/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs b/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs index f466e66b..e2f0da7d 100644 --- a/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs +++ b/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs @@ -22,31 +22,25 @@ public sealed class GameWindowSlice8BoundaryTests "Samples = startupQuality.MsaaSamples", "Window.Create(options)", "_displayFramePacing.BindSurface(", - "_window.Load += OnLoad;", - "_window.Update += OnUpdate;", - "_window.Render += OnRender;", - "_window.Render += _displayFramePacing.OnFrameRendered;", - "_window.Closing += OnClosing;", - "_window.FocusChanged += OnFocusChanged;", - "_window.Move += _displayFramePacing.OnWindowMoved;", - "_window.StateChanged += _displayFramePacing.OnWindowStateChanged;", - "_window.FramebufferResize += OnFramebufferResize;", + "_windowCallbacks = SilkWindowCallbackBinding.Create(", + "new WindowCallbackTargets(", + "OnLoad,", + "OnUpdate,", + "OnRender,", + "OnClosing,", + "OnFocusChanged,", + "OnFramebufferResize),", + "_displayFramePacing,", + "_hostQuiescence);", + "_windowCallbacks.Attach();", "_window.Run();"); - string[] registrations = - [ - "_window.Load += OnLoad;", - "_window.Update += OnUpdate;", - "_window.Render += OnRender;", - "_window.Render += _displayFramePacing.OnFrameRendered;", - "_window.Closing += OnClosing;", - "_window.FocusChanged += OnFocusChanged;", - "_window.Move += _displayFramePacing.OnWindowMoved;", - "_window.StateChanged += _displayFramePacing.OnWindowStateChanged;", - "_window.FramebufferResize += OnFramebufferResize;", - ]; - foreach (string registration in registrations) - Assert.Equal(1, CountOccurrences(body, registration)); + Assert.Equal(1, CountOccurrences(body, "SilkWindowCallbackBinding.Create(")); + Assert.Equal(1, CountOccurrences(body, "_windowCallbacks.Attach();")); + Assert.DoesNotContain("_window.Load +=", body, StringComparison.Ordinal); + Assert.DoesNotContain("_window.Update +=", body, StringComparison.Ordinal); + Assert.DoesNotContain("_window.Render +=", body, StringComparison.Ordinal); + Assert.DoesNotContain("_window.Closing +=", body, StringComparison.Ordinal); } [Fact] @@ -160,7 +154,11 @@ public sealed class GameWindowSlice8BoundaryTests "new AcDream.App.Rendering.RenderFrameInput("); Assert.DoesNotContain("FramebufferSize", render, StringComparison.Ordinal); AssertAppearsInOrder(focus, "if (!focused)", "_gameplayInputFrame?.EndMouseLook();"); - AssertAppearsInOrder(close, "=> CompleteShutdown();", "_shutdown.CompleteOrThrow();"); + AssertAppearsInOrder( + close, + "_hostQuiescence.StopAccepting();", + "CompleteShutdown();", + "_shutdown.CompleteOrThrow();"); } [Fact] @@ -197,6 +195,11 @@ public sealed class GameWindowSlice8BoundaryTests Assert.Equal(stages.Length, CountOccurrences(manifest, "new ResourceShutdownStage(")); foreach (string stage in stages) Assert.Equal(1, CountOccurrences(manifest, stage)); + AssertAppearsInOrder( + manifest, + "binding.Dispose();", + "if (!binding.IsDisposalComplete)", + "_windowCallbacks = null;"); AssertAppearsInOrder( dispose, "CompleteShutdown();", diff --git a/tests/AcDream.App.Tests/Rendering/HostQuiescenceGateTests.cs b/tests/AcDream.App.Tests/Rendering/HostQuiescenceGateTests.cs new file mode 100644 index 00000000..04602aa3 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/HostQuiescenceGateTests.cs @@ -0,0 +1,57 @@ +using AcDream.App.Rendering; + +namespace AcDream.App.Tests.Rendering; + +public sealed class HostQuiescenceGateTests +{ + [Fact] + public async Task ExternalStopWaitsForAdmittedCallbackToReturn() + { + var gate = new HostQuiescenceGate(); + using var callbackEntered = new ManualResetEventSlim(false); + using var releaseCallback = new ManualResetEventSlim(false); + int calls = 0; + Task callback = Task.Run(() => gate.Invoke(() => + { + callbackEntered.Set(); + Assert.True(releaseCallback.Wait(TimeSpan.FromSeconds(5))); + calls++; + })); + Assert.True(callbackEntered.Wait(TimeSpan.FromSeconds(5))); + + using var stopStarted = new ManualResetEventSlim(false); + Task stop = Task.Run(() => + { + stopStarted.Set(); + gate.StopAccepting(); + }); + Assert.True(stopStarted.Wait(TimeSpan.FromSeconds(5))); + await Task.Delay(50); + Assert.False(stop.IsCompleted); + + releaseCallback.Set(); + await callback; + await stop; + gate.Invoke(() => calls++); + + Assert.Equal(1, calls); + Assert.False(gate.IsAccepting); + } + + [Fact] + public void CallbackCanStopGateReentrantlyWithoutDeadlock() + { + var gate = new HostQuiescenceGate(); + int calls = 0; + + gate.Invoke(() => + { + calls++; + gate.StopAccepting(); + }); + gate.Invoke(() => calls++); + + Assert.Equal(1, calls); + Assert.False(gate.IsAccepting); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/SilkWindowCallbackBindingTests.cs b/tests/AcDream.App.Tests/Rendering/SilkWindowCallbackBindingTests.cs new file mode 100644 index 00000000..f7480830 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/SilkWindowCallbackBindingTests.cs @@ -0,0 +1,801 @@ +using AcDream.App.Diagnostics; +using AcDream.App.Rendering; +using Silk.NET.Maths; +using Silk.NET.Windowing; + +namespace AcDream.App.Tests.Rendering; + +public sealed class SilkWindowCallbackBindingTests +{ + [Fact] + public void AttachAndDisposeUseExactForwardAndReverseOrder() + { + using var profiler = new FrameProfiler(); + using var pacing = CreatePacing(profiler); + var surface = new FakeWindowCallbackSurface(); + var gate = new HostQuiescenceGate(); + + using SilkWindowCallbackBinding binding = CreateAttachedBinding( + surface, + Targets(gate), + pacing, + gate); + + Assert.Equal( + [ + "add load", + "add update", + "add render main", + "add render pacing", + "add closing", + "add focus", + "add move", + "add state", + "add framebuffer", + ], surface.Operations); + + surface.Operations.Clear(); + binding.Dispose(); + + Assert.Equal( + [ + "remove framebuffer", + "remove state", + "remove move", + "remove focus", + "remove closing", + "remove render pacing", + "remove render main", + "remove update", + "remove load", + ], surface.Operations); + Assert.True(binding.IsDetached); + Assert.False(gate.IsAccepting); + } + + [Fact] + public void MainRenderIsRegisteredBeforePacingRender() + { + var order = new List(); + var clock = new OrderingClock(); + using var profiler = new FrameProfiler(); + using var pacing = new DisplayFramePacingController( + uncappedRendering: false, + profiler, + new FramePacingController(clock, new OrderingWaiter(clock, order))); + pacing.InitializeStartup(requestedVSync: false); + var surface = new FakeWindowCallbackSurface(); + var gate = new HostQuiescenceGate(); + WindowCallbackTargets targets = Targets( + gate, + render: _ => order.Add("main render")); + using SilkWindowCallbackBinding binding = CreateAttachedBinding( + surface, + targets, + pacing, + gate); + + Assert.Equal(2, surface.RenderCallbacks.Count); + + surface.RaiseRender(1d / 60d); + + Assert.Equal(["main render", "pacing render"], order); + } + + public static IEnumerable AttachFailureCases => + Enumerable.Range(1, 9).SelectMany(attempt => new[] + { + new object[] { attempt, false }, + new object[] { attempt, true }, + }); + + [Theory] + [MemberData(nameof(AttachFailureCases))] + public void FailureAfterNthAttachRollsBackPossiblyAcquiredPrefix( + int failedAttempt, + bool failAfterSideEffect) + { + using var profiler = new FrameProfiler(); + using var pacing = CreatePacing(profiler); + var surface = new FakeWindowCallbackSurface + { + FailAddAttempt = failedAttempt, + FailAddAfterSideEffect = failAfterSideEffect, + }; + var gate = new HostQuiescenceGate(); + SilkWindowCallbackBinding binding = SilkWindowCallbackBinding.Create( + surface, + Targets(gate), + pacing, + gate); + + InvalidOperationException error = Assert.Throws(binding.Attach); + + Assert.Contains("rolled back", error.Message, StringComparison.OrdinalIgnoreCase); + Assert.Equal(0, surface.SubscriptionCount); + Assert.False(gate.IsAccepting); + + string[] added = surface.Operations + .Where(operation => operation.StartsWith("add ", StringComparison.Ordinal)) + .Take(failedAttempt) + .ToArray(); + string[] removed = surface.Operations + .Where(operation => operation.StartsWith("remove ", StringComparison.Ordinal)) + .ToArray(); + Assert.Equal( + added + .Reverse() + .Select(operation => operation.Replace("add ", "remove ", StringComparison.Ordinal)), + removed); + Assert.True(binding.IsDetached); + + int operationsAfterRollback = surface.Operations.Count; + binding.Dispose(); + Assert.Equal(operationsAfterRollback, surface.Operations.Count); + } + + [Fact] + public void RollbackFailureIsReportedAndRemainingCallbackIsLogicallySilent() + { + using var profiler = new FrameProfiler(); + using var pacing = CreatePacing(profiler); + var surface = new FakeWindowCallbackSurface + { + FailAddAttempt = 4, + }; + surface.PersistentRemoveFailures.Add("update"); + var gate = new HostQuiescenceGate(); + int updateCalls = 0; + SilkWindowCallbackBinding binding = SilkWindowCallbackBinding.Create( + surface, + Targets(gate, update: _ => updateCalls++), + pacing, + gate); + + AggregateException error = Assert.Throws(binding.Attach); + + Assert.Contains( + error.InnerExceptions, + exception => exception.Message.Contains("registration failed", StringComparison.Ordinal)); + Assert.Contains( + error.InnerExceptions, + exception => exception.Message.Contains("index 1", StringComparison.Ordinal)); + Assert.False(gate.IsAccepting); + Assert.Equal(1, surface.SubscriptionCount); + + surface.RaiseUpdate(0.1d); + + Assert.Equal(0, updateCalls); + + surface.PersistentRemoveFailures.Clear(); + binding.Dispose(); + + Assert.True(binding.IsDetached); + Assert.Equal(0, surface.SubscriptionCount); + } + + [Fact] + public void PersistentPhysicalDetachFailureIsRetriableWithoutReplayingSuccesses() + { + using var profiler = new FrameProfiler(); + using var pacing = CreatePacing(profiler); + var surface = new FakeWindowCallbackSurface(); + surface.PersistentRemoveFailures.Add("focus"); + var gate = new HostQuiescenceGate(); + int updateCalls = 0; + SilkWindowCallbackBinding binding = CreateAttachedBinding( + surface, + Targets(gate, update: _ => updateCalls++), + pacing, + gate); + + Assert.Throws(binding.Dispose); + Assert.False(binding.IsDetached); + Assert.False(gate.IsAccepting); + int successfulRemovalCount = surface.Operations.Count(operation => + operation.StartsWith("remove ", StringComparison.Ordinal) + && operation != "remove focus"); + Assert.Equal(8, successfulRemovalCount); + + surface.RaiseUpdate(0.1d); + Assert.Equal(0, updateCalls); + + surface.PersistentRemoveFailures.Clear(); + binding.Dispose(); + + Assert.True(binding.IsDetached); + Assert.Equal(0, surface.SubscriptionCount); + Assert.Equal(8, surface.Operations.Count(operation => + operation.StartsWith("remove ", StringComparison.Ordinal) + && operation != "remove focus")); + } + + [Fact] + public void RemoveThenTransientThrowIsRetriedToPhysicalConvergence() + { + using var profiler = new FrameProfiler(); + using var pacing = CreatePacing(profiler); + var surface = new FakeWindowCallbackSurface(); + surface.RemoveAfterSideEffectFailures.Add("focus"); + var gate = new HostQuiescenceGate(); + SilkWindowCallbackBinding binding = CreateAttachedBinding( + surface, + Targets(gate), + pacing, + gate); + + binding.Dispose(); + + Assert.True(binding.IsDetached); + Assert.Equal(0, surface.SubscriptionCount); + Assert.Equal(2, surface.Operations.Count(operation => operation == "remove focus")); + } + + [Fact] + public void DisposeIsRepeatedAndReentrantSafe() + { + using var profiler = new FrameProfiler(); + using var pacing = CreatePacing(profiler); + var surface = new FakeWindowCallbackSurface(); + var gate = new HostQuiescenceGate(); + SilkWindowCallbackBinding? binding = null; + surface.Removing = _ => binding!.Dispose(); + binding = CreateAttachedBinding(surface, Targets(gate), pacing, gate); + + binding.Dispose(); + int operationsAfterFirstDispose = surface.Operations.Count; + binding.Dispose(); + + Assert.True(binding.IsDetached); + Assert.Equal(operationsAfterFirstDispose, surface.Operations.Count); + Assert.Equal(0, surface.SubscriptionCount); + } + + [Fact] + public async Task ConcurrentDisposeWaitsForPhysicalDetachToComplete() + { + using var profiler = new FrameProfiler(); + using var pacing = CreatePacing(profiler); + var surface = new FakeWindowCallbackSurface + { + BlockRemoveName = "framebuffer", + }; + var gate = new HostQuiescenceGate(); + SilkWindowCallbackBinding binding = CreateAttachedBinding( + surface, + Targets(gate), + pacing, + gate); + + Task first = Task.Run(() => Record.Exception(binding.Dispose)); + Assert.True(surface.RemoveEntered.Wait(TimeSpan.FromSeconds(5))); + using var secondEntered = new ManualResetEventSlim(false); + Task second = Task.Run(() => + { + secondEntered.Set(); + return Record.Exception(binding.Dispose); + }); + Assert.True(secondEntered.Wait(TimeSpan.FromSeconds(5))); + await Task.Delay(50); + Assert.False(second.IsCompleted); + + surface.ContinueRemove.Set(); + + Assert.Null(await first); + Assert.Null(await second); + Assert.True(binding.IsDetached); + } + + [Fact] + public async Task ConcurrentDisposeCannotHidePhysicalDetachFailure() + { + using var profiler = new FrameProfiler(); + using var pacing = CreatePacing(profiler); + var surface = new FakeWindowCallbackSurface + { + BlockRemoveName = "framebuffer", + }; + surface.PersistentRemoveFailures.Add("framebuffer"); + var gate = new HostQuiescenceGate(); + SilkWindowCallbackBinding binding = CreateAttachedBinding( + surface, + Targets(gate), + pacing, + gate); + + Task first = Task.Run(() => Record.Exception(binding.Dispose)); + Assert.True(surface.RemoveEntered.Wait(TimeSpan.FromSeconds(5))); + using var secondEntered = new ManualResetEventSlim(false); + Task second = Task.Run(() => + { + secondEntered.Set(); + return Record.Exception(binding.Dispose); + }); + Assert.True(secondEntered.Wait(TimeSpan.FromSeconds(5))); + await Task.Delay(50); + Assert.False(second.IsCompleted); + + surface.ContinueRemove.Set(); + + Assert.IsType(await first); + Assert.IsType(await second); + Assert.False(binding.IsDetached); + + surface.PersistentRemoveFailures.Clear(); + binding.Dispose(); + Assert.True(binding.IsDetached); + } + + [Fact] + public async Task DisposeDuringBlockedAttachForcesRollbackBeforeDetachCompletes() + { + using var profiler = new FrameProfiler(); + using var pacing = CreatePacing(profiler); + var surface = new FakeWindowCallbackSurface + { + BlockAddName = "load", + }; + var gate = new HostQuiescenceGate(); + SilkWindowCallbackBinding binding = SilkWindowCallbackBinding.Create( + surface, + Targets(gate), + pacing, + gate); + + Task attach = Task.Run(() => Record.Exception(binding.Attach)); + Assert.True(surface.AddEntered.Wait(TimeSpan.FromSeconds(5))); + Task dispose = Task.Run(() => Record.Exception(binding.Dispose)); + Assert.True(SpinWait.SpinUntil( + () => !gate.IsAccepting, + TimeSpan.FromSeconds(5))); + Assert.False(dispose.IsCompleted); + + surface.ContinueAdd.Set(); + + Assert.IsType(await attach); + Assert.Null(await dispose); + Assert.True(binding.IsDetached); + Assert.Equal(0, surface.SubscriptionCount); + } + + [Fact] + public async Task ConcurrentDoubleAttachAllowsOnlyTheLifecycleOwner() + { + using var profiler = new FrameProfiler(); + using var pacing = CreatePacing(profiler); + var surface = new FakeWindowCallbackSurface + { + BlockAddName = "load", + }; + var gate = new HostQuiescenceGate(); + SilkWindowCallbackBinding binding = SilkWindowCallbackBinding.Create( + surface, + Targets(gate), + pacing, + gate); + + Task first = Task.Run(() => Record.Exception(binding.Attach)); + Assert.True(surface.AddEntered.Wait(TimeSpan.FromSeconds(5))); + using var secondStarted = new ManualResetEventSlim(false); + Task second = Task.Run(() => + { + secondStarted.Set(); + return Record.Exception(binding.Attach); + }); + Assert.True(secondStarted.Wait(TimeSpan.FromSeconds(5))); + Exception secondError = await second.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.IsType(secondError); + + surface.ContinueAdd.Set(); + + Assert.Null(await first); + binding.Dispose(); + Assert.True(binding.IsDetached); + } + + [Fact] + public void AddAccessorCanRequestDisposeReentrantlyWithoutDeadlock() + { + using var profiler = new FrameProfiler(); + using var pacing = CreatePacing(profiler); + var surface = new FakeWindowCallbackSurface(); + var gate = new HostQuiescenceGate(); + SilkWindowCallbackBinding? binding = null; + surface.Adding = _ => binding!.Dispose(); + binding = SilkWindowCallbackBinding.Create( + surface, + Targets(gate), + pacing, + gate); + + Assert.Throws(binding.Attach); + Assert.False(binding.IsDisposalComplete); + binding.Dispose(); + + Assert.True(binding.IsDetached); + Assert.True(binding.IsDisposalComplete); + Assert.Equal(0, surface.SubscriptionCount); + } + + [Fact] + public void ReentrantAttachCleanupRemainsPublishedUntilRetryCompletes() + { + using var profiler = new FrameProfiler(); + using var pacing = CreatePacing(profiler); + var surface = new FakeWindowCallbackSurface(); + surface.PersistentRemoveFailures.Add("load"); + var gate = new HostQuiescenceGate(); + SilkWindowCallbackBinding? slot = SilkWindowCallbackBinding.Create( + surface, + Targets(gate), + pacing, + gate); + ResourceShutdownTransaction? shutdown = null; + shutdown = new ResourceShutdownTransaction( + new ResourceShutdownStage("input", + [ + new("window callbacks", () => + { + SilkWindowCallbackBinding? current = slot; + if (current is null) + return; + + current.Dispose(); + if (!current.IsDisposalComplete) + throw new NativeWindowCallbackCleanupDeferredException(); + slot = null; + }), + ])); + surface.Adding = _ => shutdown.CompleteOrThrow(); + + Assert.Throws(slot.Attach); + Assert.NotNull(slot); + Assert.False(slot.IsDisposalComplete); + + surface.PersistentRemoveFailures.Clear(); + shutdown.CompleteOrThrow(); + + Assert.Null(slot); + Assert.True(shutdown.IsComplete); + Assert.Equal(0, surface.SubscriptionCount); + } + + [Fact] + public async Task ConcurrentCallbackDisposeAndLaterAddFailureCannotDeadlock() + { + using var profiler = new FrameProfiler(); + using var pacing = CreatePacing(profiler); + var surface = new FakeWindowCallbackSurface(); + var gate = new HostQuiescenceGate(); + using var loadEntered = new ManualResetEventSlim(false); + SilkWindowCallbackBinding? binding = null; + Task? callback = null; + var targets = new WindowCallbackTargets( + load: () => + { + loadEntered.Set(); + binding!.Dispose(); + }, + update: static _ => { }, + render: static _ => { }, + closing: gate.StopAccepting, + focusChanged: static _ => { }, + framebufferResize: static _ => { }); + binding = SilkWindowCallbackBinding.Create(surface, targets, pacing, gate); + surface.Adding = name => + { + if (name != "update") + return; + + callback = Task.Run(() => Record.Exception(surface.RaiseLoad)); + Assert.True(loadEntered.Wait(TimeSpan.FromSeconds(5))); + throw new InvalidOperationException("synthetic later add failure"); + }; + + Task attach = Task.Run(() => Record.Exception(binding.Attach)); + Exception attachError = await attach.WaitAsync(TimeSpan.FromSeconds(5)); + Exception callbackError = await callback!.WaitAsync(TimeSpan.FromSeconds(5)); + + Assert.IsType(attachError); + Assert.IsType(callbackError); + Assert.False(binding.IsDisposalComplete); + + binding.Dispose(); + Assert.True(binding.IsDisposalComplete); + Assert.Equal(0, surface.SubscriptionCount); + } + + [Fact] + public void CallbackRaisedDuringDetachAndAfterDetachIsSilent() + { + using var profiler = new FrameProfiler(); + using var pacing = CreatePacing(profiler); + var surface = new FakeWindowCallbackSurface(); + var gate = new HostQuiescenceGate(); + int updateCalls = 0; + SilkWindowCallbackBinding binding = CreateAttachedBinding( + surface, + Targets(gate, update: _ => updateCalls++), + pacing, + gate); + surface.Removing = _ => surface.RaiseUpdate(0.1d); + + binding.Dispose(); + surface.RaiseUpdate(0.1d); + + Assert.Equal(0, updateCalls); + } + + [Fact] + public void ClosingCanQuiesceEveryLaterNativeCallback() + { + using var profiler = new FrameProfiler(); + using var pacing = CreatePacing(profiler); + var surface = new FakeWindowCallbackSurface(); + var gate = new HostQuiescenceGate(); + int closingCalls = 0; + int updateCalls = 0; + WindowCallbackTargets targets = Targets( + gate, + update: _ => updateCalls++, + closing: () => + { + closingCalls++; + gate.StopAccepting(); + }); + using SilkWindowCallbackBinding binding = CreateAttachedBinding( + surface, + targets, + pacing, + gate); + + surface.RaiseClosing(); + surface.RaiseClosing(); + surface.RaiseUpdate(0.1d); + + Assert.Equal(1, closingCalls); + Assert.Equal(0, updateCalls); + Assert.False(gate.IsAccepting); + } + + [Fact] + public void ClosedGateSilencesEveryNativeWindowEdge() + { + var pacingCalls = new List(); + var clock = new OrderingClock(); + using var profiler = new FrameProfiler(); + using var pacing = new DisplayFramePacingController( + uncappedRendering: false, + profiler, + new FramePacingController(clock, new OrderingWaiter(clock, pacingCalls))); + pacing.InitializeStartup(requestedVSync: false); + var pacingSurface = new CountingPacingSurface(); + pacing.BindSurface(pacingSurface); + var surface = new FakeWindowCallbackSurface(); + var gate = new HostQuiescenceGate(); + int targetCalls = 0; + var targets = new WindowCallbackTargets( + () => targetCalls++, + _ => targetCalls++, + _ => targetCalls++, + () => targetCalls++, + _ => targetCalls++, + _ => targetCalls++); + using SilkWindowCallbackBinding binding = CreateAttachedBinding( + surface, + targets, + pacing, + gate); + gate.StopAccepting(); + + surface.RaiseLoad(); + surface.RaiseUpdate(0.1d); + surface.RaiseRender(0.1d); + surface.RaiseClosing(); + surface.RaiseFocusChanged(false); + surface.RaiseMove(default); + surface.RaiseStateChanged(default); + surface.RaiseFramebufferResize(new Vector2D(1280, 720)); + + Assert.Equal(0, targetCalls); + Assert.Empty(pacingCalls); + Assert.Equal(0, pacingSurface.RefreshReadCount); + } + + private static DisplayFramePacingController CreatePacing(FrameProfiler profiler) + { + var pacing = new DisplayFramePacingController( + uncappedRendering: false, + profiler); + pacing.InitializeStartup(requestedVSync: true); + return pacing; + } + + private static SilkWindowCallbackBinding CreateAttachedBinding( + IWindowCallbackSurface surface, + WindowCallbackTargets targets, + DisplayFramePacingController pacing, + HostQuiescenceGate gate) + { + SilkWindowCallbackBinding binding = SilkWindowCallbackBinding.Create( + surface, + targets, + pacing, + gate); + binding.Attach(); + return binding; + } + + private static WindowCallbackTargets Targets( + HostQuiescenceGate gate, + Action? update = null, + Action? render = null, + Action? closing = null) => new( + load: static () => { }, + update: update ?? (static _ => { }), + render: render ?? (static _ => { }), + closing: closing ?? gate.StopAccepting, + focusChanged: static _ => { }, + framebufferResize: static _ => { }); + + private sealed class FakeWindowCallbackSurface : IWindowCallbackSurface + { + private readonly List _load = []; + private readonly List> _update = []; + private readonly List> _render = []; + private readonly List _closing = []; + private readonly List> _focus = []; + private readonly List>> _move = []; + private readonly List> _state = []; + private readonly List>> _framebuffer = []; + private readonly Dictionary, string> _renderNames = + new(ReferenceEqualityComparer.Instance); + private int _addAttempts; + private int _addBlockUsed; + private int _removeBlockUsed; + + public List Operations { get; } = []; + public HashSet PersistentRemoveFailures { get; } = []; + public HashSet RemoveAfterSideEffectFailures { get; } = []; + public int? FailAddAttempt { get; init; } + public bool FailAddAfterSideEffect { get; init; } + public string? BlockAddName { get; init; } + public string? BlockRemoveName { get; init; } + public ManualResetEventSlim AddEntered { get; } = new(false); + public ManualResetEventSlim ContinueAdd { get; } = new(false); + public ManualResetEventSlim RemoveEntered { get; } = new(false); + public ManualResetEventSlim ContinueRemove { get; } = new(false); + public Action? Adding { get; set; } + public Action? Removing { get; set; } + public IReadOnlyList> RenderCallbacks => _render; + + public int SubscriptionCount => + _load.Count + _update.Count + _render.Count + _closing.Count + + _focus.Count + _move.Count + _state.Count + _framebuffer.Count; + + public void AddLoad(Action callback) => Add("load", _load, callback); + public void RemoveLoad(Action callback) => Remove("load", _load, callback); + public void AddUpdate(Action callback) => Add("update", _update, callback); + public void RemoveUpdate(Action callback) => Remove("update", _update, callback); + public void AddRender(Action callback) + { + string name = _render.Count == 0 ? "render main" : "render pacing"; + _renderNames.Add(callback, name); + Add(name, _render, callback); + } + public void RemoveRender(Action callback) + { + string name = _renderNames[callback]; + Remove(name, _render, callback); + _renderNames.Remove(callback); + } + public void AddClosing(Action callback) => Add("closing", _closing, callback); + public void RemoveClosing(Action callback) => Remove("closing", _closing, callback); + public void AddFocusChanged(Action callback) => Add("focus", _focus, callback); + public void RemoveFocusChanged(Action callback) => Remove("focus", _focus, callback); + public void AddMove(Action> callback) => Add("move", _move, callback); + public void RemoveMove(Action> callback) => Remove("move", _move, callback); + public void AddStateChanged(Action callback) => Add("state", _state, callback); + public void RemoveStateChanged(Action callback) => Remove("state", _state, callback); + public void AddFramebufferResize(Action> callback) => + Add("framebuffer", _framebuffer, callback); + public void RemoveFramebufferResize(Action> callback) => + Remove("framebuffer", _framebuffer, callback); + + public void RaiseLoad() => InvokeSnapshot(_load); + public void RaiseUpdate(double delta) => InvokeSnapshot(_update, delta); + public void RaiseRender(double delta) => InvokeSnapshot(_render, delta); + public void RaiseClosing() => InvokeSnapshot(_closing); + public void RaiseFocusChanged(bool focused) => InvokeSnapshot(_focus, focused); + public void RaiseMove(Vector2D position) => InvokeSnapshot(_move, position); + public void RaiseStateChanged(WindowState state) => InvokeSnapshot(_state, state); + public void RaiseFramebufferResize(Vector2D size) => + InvokeSnapshot(_framebuffer, size); + + private void Add(string name, List callbacks, T callback) + { + Operations.Add($"add {name}"); + _addAttempts++; + if (name == BlockAddName + && Interlocked.Exchange(ref _addBlockUsed, 1) == 0) + { + AddEntered.Set(); + if (!ContinueAdd.Wait(TimeSpan.FromSeconds(10))) + throw new TimeoutException("Synthetic add block timed out."); + } + bool fail = FailAddAttempt == _addAttempts; + if (fail && !FailAddAfterSideEffect) + throw new InvalidOperationException($"synthetic add failure: {name}"); + callbacks.Add(callback); + Adding?.Invoke(name); + if (fail) + throw new InvalidOperationException($"synthetic add-after-side-effect failure: {name}"); + } + + private void Remove(string name, List callbacks, T callback) + { + Operations.Add($"remove {name}"); + Removing?.Invoke(name); + if (name == BlockRemoveName + && Interlocked.Exchange(ref _removeBlockUsed, 1) == 0) + { + RemoveEntered.Set(); + if (!ContinueRemove.Wait(TimeSpan.FromSeconds(10))) + throw new TimeoutException("Synthetic remove block timed out."); + } + if (PersistentRemoveFailures.Contains(name)) + throw new InvalidOperationException($"synthetic remove failure: {name}"); + callbacks.Remove(callback); + if (RemoveAfterSideEffectFailures.Remove(name)) + { + throw new InvalidOperationException( + $"synthetic remove-after-side-effect failure: {name}"); + } + } + + private static void InvokeSnapshot(List callbacks) + { + foreach (Action callback in callbacks.ToArray()) + callback(); + } + + private static void InvokeSnapshot(List> callbacks, T value) + { + foreach (Action callback in callbacks.ToArray()) + callback(value); + } + } + + private sealed class OrderingClock : IFramePacingClock + { + public long Frequency => 1_000; + public long Timestamp { get; set; } + public long GetTimestamp() => Timestamp; + } + + private sealed class OrderingWaiter( + OrderingClock clock, + List order) : IFramePacingWaiter + { + public void Wait(long durationTicks, long clockFrequency) + { + order.Add("pacing render"); + clock.Timestamp += durationTicks; + } + } + + private sealed class CountingPacingSurface : IDisplayFramePacingSurface + { + public bool VSync { get; set; } + public int RefreshReadCount { get; private set; } + + public int? ActiveMonitorRefreshHz + { + get + { + RefreshReadCount++; + return 144; + } + } + } +}