lightweight variant (#1)
Reviewed-on: #1 Co-authored-by: Aleksandr Neychev <alexnejchev73@gmail.com>
This commit was merged in pull request #1.
This commit is contained in:
@@ -0,0 +1,324 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace CursorLang.Tests.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// A thread with a Win32 message loop on it — what the tests have instead of a
|
||||
/// dispatcher.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Core's timers are <c>SetTimer</c> 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 <see cref="WaitFor"/>.
|
||||
///
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
public static class Pump
|
||||
{
|
||||
private static readonly Lock Gate = new();
|
||||
private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(5);
|
||||
private static readonly ConcurrentQueue<Action> Posted = new();
|
||||
|
||||
private static uint _threadId;
|
||||
|
||||
/// <summary>Runs an action on the pump thread and waits for it to finish.</summary>
|
||||
public static void Run(Action action) => Run<object?>(() =>
|
||||
{
|
||||
action();
|
||||
return null;
|
||||
});
|
||||
|
||||
/// <summary>The same for an action that returns a result.</summary>
|
||||
public static TResult Run<TResult>(Func<TResult> action)
|
||||
{
|
||||
EnsureStarted();
|
||||
|
||||
if (IsOnPumpThread)
|
||||
{
|
||||
return action();
|
||||
}
|
||||
|
||||
var done = new TaskCompletionSource<TResult>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
Posted.Enqueue(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
done.SetResult(action());
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
done.SetException(e);
|
||||
}
|
||||
});
|
||||
|
||||
Wake();
|
||||
|
||||
return done.Task.GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static void Post(Action action)
|
||||
{
|
||||
EnsureStarted();
|
||||
Posted.Enqueue(action);
|
||||
Wake();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs an action on a separate STA thread and waits for it to finish.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Waits until everything posted to the pump has run. Work handed over with
|
||||
/// <see cref="Post"/> has happened by the time this returns.
|
||||
/// </summary>
|
||||
public static void Drain()
|
||||
{
|
||||
EnsureStarted();
|
||||
|
||||
if (IsOnPumpThread)
|
||||
{
|
||||
DispatchPending();
|
||||
return;
|
||||
}
|
||||
|
||||
Run(static () => { });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Waits for a condition, letting the pump get on with its work.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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 <paramref name="because"/> carries that either way.
|
||||
/// </remarks>
|
||||
/// <exception cref="TimeoutException">The condition never came true.</exception>
|
||||
public static void WaitFor(Func<bool> 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));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Waits for the given time while the pump keeps running: that is how
|
||||
/// "nothing happened during this time" is verified.
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs everything already in the queue — messages and posted work alike — and
|
||||
/// says whether there was anything. Only ever called on the pump thread.
|
||||
/// </summary>
|
||||
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<uint>(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);
|
||||
|
||||
/// <summary>WM_APP and up belong to the application, and here that is the tests.</summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user