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:
Erik 2026-07-22 09:59:10 +02:00
parent 476c2e6de1
commit 18d4b999de
7 changed files with 1416 additions and 50 deletions

View file

@ -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();",

View 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);
}
}

View file

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