refactor(app): own native window callback lifetime
Publish the fixed Silk callback binding before acquisition, quiesce admitted callbacks before teardown, and retain failed physical detach ownership for retry. Preserve the frozen callback order while covering partial event accessors, concurrency, reentrancy, and shutdown completion. Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
parent
476c2e6de1
commit
18d4b999de
7 changed files with 1416 additions and 50 deletions
|
|
@ -16,7 +16,7 @@ audit.
|
||||||
|
|
||||||
- [x] A — freeze construction/callback/shutdown order and delete proven dead or
|
- [x] A — freeze construction/callback/shutdown order and delete proven dead or
|
||||||
test-facade residue.
|
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.
|
define host-quiescence failure semantics.
|
||||||
- [ ] C — extract live-session host/reset/binding callbacks and verify the
|
- [ ] C — extract live-session host/reset/binding callbacks and verify the
|
||||||
embedded skill formula against named retail.
|
embedded skill formula against named retail.
|
||||||
|
|
@ -220,20 +220,31 @@ Add a focused reversible binding equivalent to:
|
||||||
```csharp
|
```csharp
|
||||||
internal sealed class SilkWindowCallbackBinding : IDisposable
|
internal sealed class SilkWindowCallbackBinding : IDisposable
|
||||||
{
|
{
|
||||||
public static SilkWindowCallbackBinding Attach(
|
public static SilkWindowCallbackBinding Create(
|
||||||
IWindow window,
|
IWindow window,
|
||||||
WindowCallbackTargets targets,
|
WindowCallbackTargets targets,
|
||||||
DisplayFramePacingController pacing);
|
DisplayFramePacingController pacing);
|
||||||
|
|
||||||
|
public void Attach();
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`WindowCallbackTargets` is a fixed typed set for Load, Update, Render, Closing,
|
`WindowCallbackTargets` is a fixed typed set for Load, Update, Render, Closing,
|
||||||
FocusChanged, and FramebufferResize. It is not a general callback list. Attach
|
FocusChanged, and FramebufferResize. It is not a general callback list. The
|
||||||
rolls back in reverse order if any registration fails; Dispose detaches in
|
binding is published into its lifetime slot after `Create` and before `Attach`
|
||||||
reverse registration order, is idempotent/reentrant-safe, and makes later
|
begins, so even an attach failure plus rollback failure retains a cleanup owner.
|
||||||
window events inert. The separate pacing Render callback remains ordered after
|
Attach treats the current edge as possibly acquired before calling a custom
|
||||||
main Render. Every callback first checks the shared no-throw host-quiescence
|
event add accessor and rolls back in reverse order if registration fails.
|
||||||
gate, so a failed physical unsubscribe cannot re-enter a retired owner.
|
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
|
### 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,
|
- Add post-detach silence, failure-after-Nth-attach, repeated/reentrant Dispose,
|
||||||
callback-during-detach, and persistent physical-detach tests.
|
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
|
### C — live-session host and skill formula
|
||||||
|
|
||||||
- Extract selection/entry/reset/binding factories around the existing canonical
|
- Extract selection/entry/reset/binding factories around the existing canonical
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,9 @@ public sealed class GameWindow : IDisposable
|
||||||
private readonly WorldGameState _worldGameState;
|
private readonly WorldGameState _worldGameState;
|
||||||
private readonly WorldEvents _worldEvents;
|
private readonly WorldEvents _worldEvents;
|
||||||
private readonly AcDream.Core.Selection.SelectionState _selection;
|
private readonly AcDream.Core.Selection.SelectionState _selection;
|
||||||
|
private readonly HostQuiescenceGate _hostQuiescence = new();
|
||||||
private IWindow? _window;
|
private IWindow? _window;
|
||||||
|
private SilkWindowCallbackBinding? _windowCallbacks;
|
||||||
private GL? _gl;
|
private GL? _gl;
|
||||||
private IInputContext? _input;
|
private IInputContext? _input;
|
||||||
private TerrainModernRenderer? _terrain;
|
private TerrainModernRenderer? _terrain;
|
||||||
|
|
@ -667,23 +669,20 @@ public sealed class GameWindow : IDisposable
|
||||||
_window = Window.Create(options);
|
_window = Window.Create(options);
|
||||||
_displayFramePacing.BindSurface(
|
_displayFramePacing.BindSurface(
|
||||||
new SilkDisplayFramePacingSurface(_window));
|
new SilkDisplayFramePacingSurface(_window));
|
||||||
_window.Load += OnLoad;
|
// The fixed binding preserves main Render before post-render pacing,
|
||||||
_window.Update += OnUpdate;
|
// owns rollback/reverse-detach, and gates every native entry point.
|
||||||
_window.Render += OnRender;
|
_windowCallbacks = SilkWindowCallbackBinding.Create(
|
||||||
// Registered after OnRender so software pacing waits after all frame
|
_window,
|
||||||
// work and before Silk.NET performs its automatic buffer swap.
|
new WindowCallbackTargets(
|
||||||
_window.Render += _displayFramePacing.OnFrameRendered;
|
OnLoad,
|
||||||
_window.Closing += OnClosing;
|
OnUpdate,
|
||||||
_window.FocusChanged += OnFocusChanged;
|
OnRender,
|
||||||
_window.Move += _displayFramePacing.OnWindowMoved;
|
OnClosing,
|
||||||
_window.StateChanged += _displayFramePacing.OnWindowStateChanged;
|
OnFocusChanged,
|
||||||
// L.0 Display tab: keep the GL viewport + camera aspect in sync
|
OnFramebufferResize),
|
||||||
// with the window framebuffer. Without this handler, resizing
|
_displayFramePacing,
|
||||||
// the window (or applying a Display-tab Resolution change at
|
_hostQuiescence);
|
||||||
// startup) leaves the viewport pinned to the original size —
|
_windowCallbacks.Attach();
|
||||||
// user sees a small render in the corner of a big window.
|
|
||||||
_window.FramebufferResize += OnFramebufferResize;
|
|
||||||
|
|
||||||
_window.Run();
|
_window.Run();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -4375,10 +4374,14 @@ public sealed class GameWindow : IDisposable
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnClosing()
|
private void OnClosing()
|
||||||
=> CompleteShutdown();
|
{
|
||||||
|
_hostQuiescence.StopAccepting();
|
||||||
|
CompleteShutdown();
|
||||||
|
}
|
||||||
|
|
||||||
private void CompleteShutdown()
|
private void CompleteShutdown()
|
||||||
{
|
{
|
||||||
|
_hostQuiescence.StopAccepting();
|
||||||
_shutdown ??= CreateShutdownTransaction();
|
_shutdown ??= CreateShutdownTransaction();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|
@ -4574,6 +4577,18 @@ public sealed class GameWindow : IDisposable
|
||||||
]),
|
]),
|
||||||
new ResourceShutdownStage("input",
|
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", ()
|
new("combat input subscription", ()
|
||||||
=> Combat.CombatModeChanged -= SetInputCombatScope),
|
=> Combat.CombatModeChanged -= SetInputCombatScope),
|
||||||
new("input context", () =>
|
new("input context", () =>
|
||||||
|
|
|
||||||
55
src/AcDream.App/Rendering/HostQuiescenceGate.cs
Normal file
55
src/AcDream.App/Rendering/HostQuiescenceGate.cs
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
namespace AcDream.App.Rendering;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public void StopAccepting()
|
||||||
|
{
|
||||||
|
lock (_sync)
|
||||||
|
_accepting = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Invoke(Action callback)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(callback);
|
||||||
|
lock (_sync)
|
||||||
|
{
|
||||||
|
if (_accepting)
|
||||||
|
callback();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Invoke<T>(Action<T> callback, T value)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(callback);
|
||||||
|
lock (_sync)
|
||||||
|
{
|
||||||
|
if (_accepting)
|
||||||
|
callback(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
413
src/AcDream.App/Rendering/SilkWindowCallbackBinding.cs
Normal file
413
src/AcDream.App/Rendering/SilkWindowCallbackBinding.cs
Normal file
|
|
@ -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.");
|
||||||
|
|
||||||
|
/// <summary>Fixed application callbacks accepted from the native window.</summary>
|
||||||
|
internal sealed class WindowCallbackTargets
|
||||||
|
{
|
||||||
|
public WindowCallbackTargets(
|
||||||
|
Action load,
|
||||||
|
Action<double> update,
|
||||||
|
Action<double> render,
|
||||||
|
Action closing,
|
||||||
|
Action<bool> focusChanged,
|
||||||
|
Action<Vector2D<int>> 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<double> Update { get; }
|
||||||
|
public Action<double> Render { get; }
|
||||||
|
public Action Closing { get; }
|
||||||
|
public Action<bool> FocusChanged { get; }
|
||||||
|
public Action<Vector2D<int>> FramebufferResize { get; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Narrow event surface used to test native callback acquisition without
|
||||||
|
/// implementing Silk's complete <see cref="IWindow"/> contract.
|
||||||
|
/// </summary>
|
||||||
|
internal interface IWindowCallbackSurface
|
||||||
|
{
|
||||||
|
void AddLoad(Action callback);
|
||||||
|
void RemoveLoad(Action callback);
|
||||||
|
void AddUpdate(Action<double> callback);
|
||||||
|
void RemoveUpdate(Action<double> callback);
|
||||||
|
void AddRender(Action<double> callback);
|
||||||
|
void RemoveRender(Action<double> callback);
|
||||||
|
void AddClosing(Action callback);
|
||||||
|
void RemoveClosing(Action callback);
|
||||||
|
void AddFocusChanged(Action<bool> callback);
|
||||||
|
void RemoveFocusChanged(Action<bool> callback);
|
||||||
|
void AddMove(Action<Vector2D<int>> callback);
|
||||||
|
void RemoveMove(Action<Vector2D<int>> callback);
|
||||||
|
void AddStateChanged(Action<WindowState> callback);
|
||||||
|
void RemoveStateChanged(Action<WindowState> callback);
|
||||||
|
void AddFramebufferResize(Action<Vector2D<int>> callback);
|
||||||
|
void RemoveFramebufferResize(Action<Vector2D<int>> callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Production adapter over Silk's native event surface.</summary>
|
||||||
|
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<double> callback) => _window.Update += callback;
|
||||||
|
public void RemoveUpdate(Action<double> callback) => _window.Update -= callback;
|
||||||
|
public void AddRender(Action<double> callback) => _window.Render += callback;
|
||||||
|
public void RemoveRender(Action<double> callback) => _window.Render -= callback;
|
||||||
|
public void AddClosing(Action callback) => _window.Closing += callback;
|
||||||
|
public void RemoveClosing(Action callback) => _window.Closing -= callback;
|
||||||
|
public void AddFocusChanged(Action<bool> callback) => _window.FocusChanged += callback;
|
||||||
|
public void RemoveFocusChanged(Action<bool> callback) => _window.FocusChanged -= callback;
|
||||||
|
public void AddMove(Action<Vector2D<int>> callback) => _window.Move += callback;
|
||||||
|
public void RemoveMove(Action<Vector2D<int>> callback) => _window.Move -= callback;
|
||||||
|
public void AddStateChanged(Action<WindowState> callback) => _window.StateChanged += callback;
|
||||||
|
public void RemoveStateChanged(Action<WindowState> callback) => _window.StateChanged -= callback;
|
||||||
|
public void AddFramebufferResize(Action<Vector2D<int>> callback) =>
|
||||||
|
_window.FramebufferResize += callback;
|
||||||
|
public void RemoveFramebufferResize(Action<Vector2D<int>> callback) =>
|
||||||
|
_window.FramebufferResize -= callback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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<double> _update;
|
||||||
|
private readonly Action<double> _mainRender;
|
||||||
|
private readonly Action<double> _pacingRender;
|
||||||
|
private readonly Action _closing;
|
||||||
|
private readonly Action<bool> _focusChanged;
|
||||||
|
private readonly Action<Vector2D<int>> _moved;
|
||||||
|
private readonly Action<WindowState> _stateChanged;
|
||||||
|
private readonly Action<Vector2D<int>> _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<Exception>? 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -22,31 +22,25 @@ public sealed class GameWindowSlice8BoundaryTests
|
||||||
"Samples = startupQuality.MsaaSamples",
|
"Samples = startupQuality.MsaaSamples",
|
||||||
"Window.Create(options)",
|
"Window.Create(options)",
|
||||||
"_displayFramePacing.BindSurface(",
|
"_displayFramePacing.BindSurface(",
|
||||||
"_window.Load += OnLoad;",
|
"_windowCallbacks = SilkWindowCallbackBinding.Create(",
|
||||||
"_window.Update += OnUpdate;",
|
"new WindowCallbackTargets(",
|
||||||
"_window.Render += OnRender;",
|
"OnLoad,",
|
||||||
"_window.Render += _displayFramePacing.OnFrameRendered;",
|
"OnUpdate,",
|
||||||
"_window.Closing += OnClosing;",
|
"OnRender,",
|
||||||
"_window.FocusChanged += OnFocusChanged;",
|
"OnClosing,",
|
||||||
"_window.Move += _displayFramePacing.OnWindowMoved;",
|
"OnFocusChanged,",
|
||||||
"_window.StateChanged += _displayFramePacing.OnWindowStateChanged;",
|
"OnFramebufferResize),",
|
||||||
"_window.FramebufferResize += OnFramebufferResize;",
|
"_displayFramePacing,",
|
||||||
|
"_hostQuiescence);",
|
||||||
|
"_windowCallbacks.Attach();",
|
||||||
"_window.Run();");
|
"_window.Run();");
|
||||||
|
|
||||||
string[] registrations =
|
Assert.Equal(1, CountOccurrences(body, "SilkWindowCallbackBinding.Create("));
|
||||||
[
|
Assert.Equal(1, CountOccurrences(body, "_windowCallbacks.Attach();"));
|
||||||
"_window.Load += OnLoad;",
|
Assert.DoesNotContain("_window.Load +=", body, StringComparison.Ordinal);
|
||||||
"_window.Update += OnUpdate;",
|
Assert.DoesNotContain("_window.Update +=", body, StringComparison.Ordinal);
|
||||||
"_window.Render += OnRender;",
|
Assert.DoesNotContain("_window.Render +=", body, StringComparison.Ordinal);
|
||||||
"_window.Render += _displayFramePacing.OnFrameRendered;",
|
Assert.DoesNotContain("_window.Closing +=", body, StringComparison.Ordinal);
|
||||||
"_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));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
@ -160,7 +154,11 @@ public sealed class GameWindowSlice8BoundaryTests
|
||||||
"new AcDream.App.Rendering.RenderFrameInput(");
|
"new AcDream.App.Rendering.RenderFrameInput(");
|
||||||
Assert.DoesNotContain("FramebufferSize", render, StringComparison.Ordinal);
|
Assert.DoesNotContain("FramebufferSize", render, StringComparison.Ordinal);
|
||||||
AssertAppearsInOrder(focus, "if (!focused)", "_gameplayInputFrame?.EndMouseLook();");
|
AssertAppearsInOrder(focus, "if (!focused)", "_gameplayInputFrame?.EndMouseLook();");
|
||||||
AssertAppearsInOrder(close, "=> CompleteShutdown();", "_shutdown.CompleteOrThrow();");
|
AssertAppearsInOrder(
|
||||||
|
close,
|
||||||
|
"_hostQuiescence.StopAccepting();",
|
||||||
|
"CompleteShutdown();",
|
||||||
|
"_shutdown.CompleteOrThrow();");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
@ -197,6 +195,11 @@ public sealed class GameWindowSlice8BoundaryTests
|
||||||
Assert.Equal(stages.Length, CountOccurrences(manifest, "new ResourceShutdownStage("));
|
Assert.Equal(stages.Length, CountOccurrences(manifest, "new ResourceShutdownStage("));
|
||||||
foreach (string stage in stages)
|
foreach (string stage in stages)
|
||||||
Assert.Equal(1, CountOccurrences(manifest, stage));
|
Assert.Equal(1, CountOccurrences(manifest, stage));
|
||||||
|
AssertAppearsInOrder(
|
||||||
|
manifest,
|
||||||
|
"binding.Dispose();",
|
||||||
|
"if (!binding.IsDisposalComplete)",
|
||||||
|
"_windowCallbacks = null;");
|
||||||
AssertAppearsInOrder(
|
AssertAppearsInOrder(
|
||||||
dispose,
|
dispose,
|
||||||
"CompleteShutdown();",
|
"CompleteShutdown();",
|
||||||
|
|
|
||||||
57
tests/AcDream.App.Tests/Rendering/HostQuiescenceGateTests.cs
Normal file
57
tests/AcDream.App.Tests/Rendering/HostQuiescenceGateTests.cs
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<string>();
|
||||||
|
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<object[]> 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<InvalidOperationException>(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<AggregateException>(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<AggregateException>(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<Exception> first = Task.Run(() => Record.Exception(binding.Dispose));
|
||||||
|
Assert.True(surface.RemoveEntered.Wait(TimeSpan.FromSeconds(5)));
|
||||||
|
using var secondEntered = new ManualResetEventSlim(false);
|
||||||
|
Task<Exception> 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<Exception> first = Task.Run(() => Record.Exception(binding.Dispose));
|
||||||
|
Assert.True(surface.RemoveEntered.Wait(TimeSpan.FromSeconds(5)));
|
||||||
|
using var secondEntered = new ManualResetEventSlim(false);
|
||||||
|
Task<Exception> 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<AggregateException>(await first);
|
||||||
|
Assert.IsType<AggregateException>(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<Exception> attach = Task.Run(() => Record.Exception(binding.Attach));
|
||||||
|
Assert.True(surface.AddEntered.Wait(TimeSpan.FromSeconds(5)));
|
||||||
|
Task<Exception> 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<InvalidOperationException>(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<Exception> first = Task.Run(() => Record.Exception(binding.Attach));
|
||||||
|
Assert.True(surface.AddEntered.Wait(TimeSpan.FromSeconds(5)));
|
||||||
|
using var secondStarted = new ManualResetEventSlim(false);
|
||||||
|
Task<Exception> 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<InvalidOperationException>(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<InvalidOperationException>(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<AggregateException>(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<Exception>? 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<Exception> 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<InvalidOperationException>(attachError);
|
||||||
|
Assert.IsType<NativeWindowCallbackCleanupDeferredException>(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<string>();
|
||||||
|
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<int>(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<double>? update = null,
|
||||||
|
Action<double>? 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<Action> _load = [];
|
||||||
|
private readonly List<Action<double>> _update = [];
|
||||||
|
private readonly List<Action<double>> _render = [];
|
||||||
|
private readonly List<Action> _closing = [];
|
||||||
|
private readonly List<Action<bool>> _focus = [];
|
||||||
|
private readonly List<Action<Vector2D<int>>> _move = [];
|
||||||
|
private readonly List<Action<WindowState>> _state = [];
|
||||||
|
private readonly List<Action<Vector2D<int>>> _framebuffer = [];
|
||||||
|
private readonly Dictionary<Action<double>, string> _renderNames =
|
||||||
|
new(ReferenceEqualityComparer.Instance);
|
||||||
|
private int _addAttempts;
|
||||||
|
private int _addBlockUsed;
|
||||||
|
private int _removeBlockUsed;
|
||||||
|
|
||||||
|
public List<string> Operations { get; } = [];
|
||||||
|
public HashSet<string> PersistentRemoveFailures { get; } = [];
|
||||||
|
public HashSet<string> 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<string>? Adding { get; set; }
|
||||||
|
public Action<string>? Removing { get; set; }
|
||||||
|
public IReadOnlyList<Action<double>> 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<double> callback) => Add("update", _update, callback);
|
||||||
|
public void RemoveUpdate(Action<double> callback) => Remove("update", _update, callback);
|
||||||
|
public void AddRender(Action<double> callback)
|
||||||
|
{
|
||||||
|
string name = _render.Count == 0 ? "render main" : "render pacing";
|
||||||
|
_renderNames.Add(callback, name);
|
||||||
|
Add(name, _render, callback);
|
||||||
|
}
|
||||||
|
public void RemoveRender(Action<double> 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<bool> callback) => Add("focus", _focus, callback);
|
||||||
|
public void RemoveFocusChanged(Action<bool> callback) => Remove("focus", _focus, callback);
|
||||||
|
public void AddMove(Action<Vector2D<int>> callback) => Add("move", _move, callback);
|
||||||
|
public void RemoveMove(Action<Vector2D<int>> callback) => Remove("move", _move, callback);
|
||||||
|
public void AddStateChanged(Action<WindowState> callback) => Add("state", _state, callback);
|
||||||
|
public void RemoveStateChanged(Action<WindowState> callback) => Remove("state", _state, callback);
|
||||||
|
public void AddFramebufferResize(Action<Vector2D<int>> callback) =>
|
||||||
|
Add("framebuffer", _framebuffer, callback);
|
||||||
|
public void RemoveFramebufferResize(Action<Vector2D<int>> 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<int> position) => InvokeSnapshot(_move, position);
|
||||||
|
public void RaiseStateChanged(WindowState state) => InvokeSnapshot(_state, state);
|
||||||
|
public void RaiseFramebufferResize(Vector2D<int> size) =>
|
||||||
|
InvokeSnapshot(_framebuffer, size);
|
||||||
|
|
||||||
|
private void Add<T>(string name, List<T> 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<T>(string name, List<T> 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<Action> callbacks)
|
||||||
|
{
|
||||||
|
foreach (Action callback in callbacks.ToArray())
|
||||||
|
callback();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void InvokeSnapshot<T>(List<Action<T>> callbacks, T value)
|
||||||
|
{
|
||||||
|
foreach (Action<T> 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<string> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue