using System.Collections.Concurrent; using System.Runtime.InteropServices; namespace CursorLang.Tests.Shared; /// /// A thread with a Win32 message loop on it — what the tests have instead of a /// dispatcher. /// /// /// Core's timers are SetTimer timers, and they only tick where messages are /// pumped. WPF used to provide that thread; Core must not depend on WPF, so the tests /// provide it themselves. The loop is a real one, so timers fire on their own and a /// test only has to await the consequences through . /// /// One thread for the whole run: starting and stopping message loops between tests /// costs more than it proves, and the hook, the timers and the windows under test are /// happy to share. /// /// Waiting is allowed from the pump thread itself, and that is the delicate part. A /// plain wait there would stop the queue and with it everything being waited for, so /// on that thread the waiting is done by a nested loop that keeps dispatching. /// public static class Pump { private static readonly Lock Gate = new(); private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(5); private static readonly ConcurrentQueue Posted = new(); private static uint _threadId; /// Runs an action on the pump thread and waits for it to finish. public static void Run(Action action) => Run(() => { action(); return null; }); /// The same for an action that returns a result. public static TResult Run(Func action) { EnsureStarted(); if (IsOnPumpThread) { return action(); } var done = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); Posted.Enqueue(() => { try { done.SetResult(action()); } catch (Exception e) { done.SetException(e); } }); Wake(); return done.Task.GetAwaiter().GetResult(); } /// /// Hands an action to the pump without waiting for it. What the agent gives the /// keyboard hook, so that a test sees the same deferral the application does. /// public static void Post(Action action) { EnsureStarted(); Posted.Enqueue(action); Wake(); } /// /// Runs an action on a separate STA thread and waits for it to finish. /// /// /// Needed where the foreignness of the thread is the point: kernel objects such as /// a mutex let their own owner in again, so a "second instance" of the application /// on the same thread does not count as second. /// public static void RunApart(Action action) { Exception? failure = null; var thread = new Thread(() => { try { action(); } catch (Exception e) { failure = e; } }) { IsBackground = true, Name = "CursorLang.Tests apart", }; thread.SetApartmentState(ApartmentState.STA); thread.Start(); thread.Join(); if (failure is not null) { throw new InvalidOperationException("The action on the separate thread failed", failure); } } /// /// Waits until everything posted to the pump has run. Work handed over with /// has happened by the time this returns. /// public static void Drain() { EnsureStarted(); if (IsOnPumpThread) { DispatchPending(); return; } Run(static () => { }); } /// /// Waits for a condition, letting the pump get on with its work. /// /// /// A timeout is reported as an exception rather than as a failed assertion so that /// this assembly needs no test framework. To the test the difference is the wording /// of the failure, and carries that either way. /// /// The condition never came true. public static void WaitFor(Func condition, string because, TimeSpan? timeout = null) { TimeSpan limit = timeout ?? DefaultTimeout; DateTime deadline = DateTime.UtcNow + limit; while (!condition()) { if (DateTime.UtcNow >= deadline) { throw new TimeoutException($"Waited {limit.TotalMilliseconds:N0} ms in vain: {because}"); } Idle(TimeSpan.FromMilliseconds(5)); } } /// /// Waits for the given time while the pump keeps running: that is how /// "nothing happened during this time" is verified. /// public static void Pause(TimeSpan duration) { Idle(duration); Drain(); } private static bool IsOnPumpThread => _threadId != 0 && GetCurrentThreadId() == _threadId; // A wait during which the queue still gets its chance to run. On any thread but the // pump's that is a plain sleep — the pump is elsewhere and busy on its own private static void Idle(TimeSpan duration) { EnsureStarted(); if (!IsOnPumpThread) { Thread.Sleep(duration); return; } DateTime deadline = DateTime.UtcNow + duration; while (DateTime.UtcNow < deadline) { if (!DispatchPending()) { // Nothing waiting: give the timers a moment to post something rather // than spinning through the deadline at full speed Thread.Sleep(1); } } DispatchPending(); } /// /// Runs everything already in the queue — messages and posted work alike — and /// says whether there was anything. Only ever called on the pump thread. /// private static bool DispatchPending() { var any = false; while (Posted.TryDequeue(out Action? action)) { action(); any = true; } while (PeekMessage(out Message message, IntPtr.Zero, 0, 0, PM_REMOVE)) { any = true; if (message.hwnd == IntPtr.Zero && message.message == WakeMessage) { while (Posted.TryDequeue(out Action? posted)) { posted(); } continue; } TranslateMessage(ref message); DispatchMessage(ref message); } return any; } private static void EnsureStarted() { lock (Gate) { if (_threadId != 0) { return; } var ready = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var thread = new Thread(() => { // The queue has to exist before anyone is told the thread is up: // PostThreadMessage to a thread without one is silently dropped PeekMessage(out Message _, IntPtr.Zero, WM_USER, WM_USER, PM_NOREMOVE); ready.SetResult(GetCurrentThreadId()); Loop(); }) { IsBackground = true, Name = "CursorLang.Tests pump", }; thread.SetApartmentState(ApartmentState.STA); thread.Start(); _threadId = ready.Task.GetAwaiter().GetResult(); } } private static void Loop() { while (GetMessage(out Message message, IntPtr.Zero, 0, 0) > 0) { if (message.hwnd == IntPtr.Zero && message.message == WakeMessage) { while (Posted.TryDequeue(out Action? action)) { action(); } continue; } TranslateMessage(ref message); DispatchMessage(ref message); } } private static void Wake() => PostThreadMessage(_threadId, WakeMessage, IntPtr.Zero, IntPtr.Zero); /// WM_APP and up belong to the application, and here that is the tests. private const uint WakeMessage = 0x8000 + 200; private const uint WM_USER = 0x0400; private const uint PM_REMOVE = 0x0001; private const uint PM_NOREMOVE = 0x0000; [DllImport("user32.dll", CharSet = CharSet.Unicode, EntryPoint = "GetMessageW")] private static extern int GetMessage(out Message lpMsg, IntPtr hWnd, uint filterMin, uint filterMax); [DllImport("user32.dll", CharSet = CharSet.Unicode, EntryPoint = "PeekMessageW")] private static extern bool PeekMessage( out Message lpMsg, IntPtr hWnd, uint filterMin, uint filterMax, uint removeMsg); [DllImport("user32.dll")] private static extern bool TranslateMessage(ref Message lpMsg); [DllImport("user32.dll", CharSet = CharSet.Unicode, EntryPoint = "DispatchMessageW")] private static extern IntPtr DispatchMessage(ref Message lpMsg); [DllImport("user32.dll", CharSet = CharSet.Unicode, EntryPoint = "PostThreadMessageW")] private static extern bool PostThreadMessage(uint threadId, uint message, IntPtr wParam, IntPtr lParam); [DllImport("kernel32.dll")] private static extern uint GetCurrentThreadId(); [StructLayout(LayoutKind.Sequential)] private struct Message { public IntPtr hwnd; public uint message; public IntPtr wParam; public IntPtr lParam; public uint time; public int x; public int y; public uint lPrivate; } }