Browse Source

Added GLib event loop support to wayland backend (#21669)

pull/21688/head
Nikita Tsukanov 3 months ago
committed by GitHub
parent
commit
59b473f31e
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 2
      src/Avalonia.Wayland/Avalonia.Wayland.csproj
  2. 19
      src/Avalonia.Wayland/WaylandGlibDispatcher.cs
  3. 5
      src/Avalonia.Wayland/WaylandPlatform.cs
  4. 18
      src/Avalonia.Wayland/WaylandPlatformOptions.cs
  5. 264
      src/Avalonia.X11/Dispatching/GLibDispatcherImpl.cs
  6. 289
      src/Avalonia.X11/Dispatching/GlibDispatcherImplBase.cs

2
src/Avalonia.Wayland/Avalonia.Wayland.csproj

@ -17,6 +17,8 @@
<Compile Include="..\Shared\RawEventGrouping.cs" />
<Compile Include="..\Avalonia.X11\X11IconLoader.cs" />
<Compile Include="..\Avalonia.X11\Clipboard\ClipboardUriListHelper.cs" />
<Compile Include="..\Avalonia.X11\Interop\Glib.cs" Link="Dispatching\Glib.cs" />
<Compile Include="..\Avalonia.X11\Dispatching\GlibDispatcherImplBase.cs" Link="Dispatching\GlibDispatcherImplBase.cs" />
</ItemGroup>
<Import Project="..\..\build\SourceGenerators.props" />
<Import Project="..\..\build\TrimmingEnable.props" />

19
src/Avalonia.Wayland/WaylandGlibDispatcher.cs

@ -0,0 +1,19 @@
using System;
using Avalonia.X11.Dispatching;
namespace Avalonia.Wayland;
/// <summary>
/// GLib (GMainLoop) based UI-thread dispatcher for the Wayland backend, enabled via
/// <see cref="WaylandPlatformOptions.UseGLibMainLoop"/>. It lets Avalonia share a GLib main loop with GLib/GTK
/// based libraries on the UI thread. Unlike X11 it attaches no platform event source of its own: the Wayland
/// connection is owned and pumped by the worker thread, which posts input/events back via the dispatcher, so the
/// base class' signaling/timer/background machinery is all the UI thread needs.
/// </summary>
internal sealed class WaylandGlibDispatcher : GlibDispatcherImplBase
{
public WaylandGlibDispatcher(Action<Exception>? externalExceptionLogger)
: base(externalExceptionLogger)
{
}
}

5
src/Avalonia.Wayland/WaylandPlatform.cs

@ -28,7 +28,10 @@ class WaylandPlatform
// that force an X11 fallback when the compositor lacks them.
var inputDispatchQueue = new AutomaticRawEventGrouperDispatchQueue();
Dispatcher.InitializeUIThreadDispatcher(new ManagedDispatcherImpl(null));
IDispatcherImpl dispatcherImpl = options.UseGLibMainLoop
? new WaylandGlibDispatcher(options.ExternalGLibMainLoopExceptionLogger)
: new ManagedDispatcherImpl(null);
Dispatcher.InitializeUIThreadDispatcher(dispatcherImpl);
var worker = new WaylandWorker(inputDispatchQueue);

18
src/Avalonia.Wayland/WaylandPlatformOptions.cs

@ -1,3 +1,4 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using Avalonia.OpenGL;
@ -72,4 +73,21 @@ public class WaylandPlatformOptions
/// decides based on compositor and driver capabilities.
/// </summary>
public bool? UseDmabufSwapchain { get; set; }
/// <summary>
/// If this option is set to true, a GMainLoop and GSource based dispatcher implementation will be used for the
/// UI thread instead of the default managed one.
/// Use this if you need to use GLib-based libraries on the main thread.
/// </summary>
public bool UseGLibMainLoop { get; set; }
/// <summary>
/// If Avalonia is in control of a run loop, we propagate exceptions by stopping the run loop frame
/// and rethrowing an exception. However, if there is no Avalonia-controlled run loop frame,
/// there is no way to report such exceptions, since allowing those to escape native->managed call boundary
/// will likely brick GLib machinery since it's not aware of managed Exceptions.
/// This property allows to inspect such exceptions before they will be ignored.
/// Only used when <see cref="UseGLibMainLoop"/> is enabled.
/// </summary>
public Action<Exception>? ExternalGLibMainLoopExceptionLogger { get; set; }
}

264
src/Avalonia.X11/Dispatching/GLibDispatcherImpl.cs

@ -1,87 +1,15 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Runtime.ExceptionServices;
using System.Threading;
using Avalonia.Logging;
using Avalonia.Threading;
using static Avalonia.X11.Interop.Glib;
namespace Avalonia.X11.Dispatching;
internal class GlibDispatcherImpl :
IDispatcherImplWithExplicitBackgroundProcessing,
IControlledDispatcherImpl,
IX11PlatformDispatcher
internal class GlibDispatcherImpl : GlibDispatcherImplBase, IX11PlatformDispatcher
{
/*
GLib priorities and Avalonia priorities are a bit different. Avalonia follows the WPF model when there
are "background" and "foreground" priority groups. Foreground jobs are executed before any user input processing,
background jobs are executed strictly after user input processing.
GLib has numeric priorities that are used in the following way:
-100 G_PRIORITY_HIGH - "high" priority sources, not really used by GLib/GTK
0 G_PRIORITY_DEFAULT - polling X11 events (GTK) and default value for g_timeout_add
100 G_PRIORITY_HIGH_IDLE without a clear definition, used as an anchor value of sorts
110 Resize/layout operations (GTK)
120 Render operations (GTK)
200 G_PRIORITY_DEFAULT_IDLE - "idle" priority sources
So, unlike Avalonia, GTK puts way higher priority on input processing, then does resize/layout/render
So, to map our model to GLib we do the following:
- foreground jobs (including grouped user events) are executed with (-1) priority (_before_ any normal GLib jobs)
- X11 socket is polled with G_PRIORITY_DEFAULT, all X11 events are read until socket is empty,
we also group input events at that stage (this matches our epoll-based dispatcher)
- background jobs are executed with G_PRIORITY_DEFAULT_IDLE, so they would have lower priority than GTK
foreground jobs
Unfortunately we can't detect if there are pending _non-idle_ GLib jobs using g_main_context_pending, since
- g_main_context_pending doesn't accept max_priority argument
- even if it did, that would still involve a syscall to the kernel to poll for fds anyway
So we just report that we don't support pending input query and let the dispatcher to
call RequestBackgroundProcessing every time, which results in g_idle_add call for every background job.
Background jobs are expected to be relatively expensive to execute since on Windows
MsgWaitForMultipleObjectsEx results isn't really free too.
For signaling (aka waking up dispatcher for processing _high_ priority jobs we are using
g_idle_add_full with (-1) priority. While the naming suggests that it would enqueue an idle job,
it actually adds an always-triggered source that would be called before other sources with lower priority.
For timers we are using a simple g_timeout_add_full and discard the previous one when dispatcher requests
an update
Since GLib dispatches event sources in batches, we force-check for "signaled" flag to run high-prio jobs
whenever we get control back from GLib. We can still occasionally get GTK code to run before high-prio
Avalonia-jobs, but that should be fine since the point is to keep Avalonia-based jobs ordered properly
and to not have our low-priority jobs to prevent GLib-based code from running its own "foreground" jobs
Another implementation note here is that GLib (just as any other C library) is NOT aware of C# exceptions,
so we are NOT allowed to have exceptions to escape native->managed call boundary. So we have exception handlers
that try to propagate those to the nearest run loop frame that was initiated by Avalonia.
If there is no such frame, we have no choice but to log/swallow those
*/
private readonly AvaloniaX11Platform _platform;
// Note that we can't use g_main_context_is_owner outside a run loop, since context doesn't really have an
// inherent owner when run loop is not running and the context isn't explicitly "locked", so we just assume that
// the app author is initializing Avalonia on the intended UI thread and won't migrate the default run loop
// to a different thread
private readonly Thread _mainThread = Thread.CurrentThread;
private readonly X11EventDispatcher _x11Events;
private bool _signaled;
private bool _signaledSourceAdded;
private readonly object _signalLock = new();
private readonly Stack<ManagedLoopFrame> _runLoopStack = new();
private readonly Stopwatch _stopwatch = Stopwatch.StartNew();
private uint? _glibTimerSourceTag;
public GlibDispatcherImpl(AvaloniaX11Platform platform)
: base(platform.Options.ExternalGLibMainLoopExceptionLogger)
{
_platform = platform;
_x11Events = new X11EventDispatcher(platform);
@ -92,96 +20,14 @@ internal class GlibDispatcherImpl :
g_source_set_can_recurse(unixFdSource, 1);
}
public bool CurrentThreadIsLoopThread => _mainThread == Thread.CurrentThread;
public event Action? Signaled;
public void Signal()
{
lock (_signalLock)
{
if(_signaled)
return;
_signaled = true;
if(_signaledSourceAdded)
return;
_signaledSourceAdded = true;
}
g_idle_add_full(G_PRIORITY_DEFAULT - 1, SignalSourceCallback);
}
protected override void Flush() => _x11Events.Flush();
private void CheckSignaled()
{
lock (_signalLock)
{
if (!_signaled)
return;
_signaled = false;
}
public override bool HasPendingInput => _platform.EventGrouperDispatchQueue.HasJobs || _x11Events.IsPending;
try
{
Signaled?.Invoke();
}
catch (Exception e)
{
HandleException(e);
}
_x11Events.Flush();
}
private bool SignalSourceCallback()
{
lock (_signalLock)
{
_signaledSourceAdded = false;
}
CheckSignaled();
return false;
}
public event Action? Timer;
public long Now => _stopwatch.ElapsedMilliseconds;
public void UpdateTimer(long? dueTimeInMs)
{
if (_glibTimerSourceTag.HasValue)
{
g_source_remove(_glibTimerSourceTag.Value);
_glibTimerSourceTag = null;
}
if (dueTimeInMs == null)
return;
var interval = (uint)Math.Max(0, (int)Math.Min(int.MaxValue, dueTimeInMs.Value - Now));
_glibTimerSourceTag = g_timeout_add_once(interval, TimerCallback);
}
private void TimerCallback()
{
try
{
Timer?.Invoke();
}
catch (Exception e)
{
HandleException(e);
}
_x11Events.Flush();
}
public event Action? ReadyForBackgroundProcessing;
public void RequestBackgroundProcessing() =>
g_idle_add_once(() => ReadyForBackgroundProcessing?.Invoke());
public bool CanQueryPendingInput => false;
public bool HasPendingInput => _platform.EventGrouperDispatchQueue.HasJobs || _x11Events.IsPending;
private bool X11SourceCallback(int i, GIOCondition gioCondition)
{
CheckSignaled();
var token = _runLoopStack.Count > 0 ? _runLoopStack.Peek().Cancelled : CancellationToken.None;
var token = CurrentLoopCancellation;
try
{
// Completely drain X11 socket while we are at it
@ -209,106 +55,6 @@ internal class GlibDispatcherImpl :
return true;
}
public void RunLoop(CancellationToken token)
{
if(token.IsCancellationRequested)
return;
using var loop = new ManagedLoopFrame(token);
_runLoopStack.Push(loop);
loop.Run();
_runLoopStack.Pop();
// Propagate any managed exceptions that we've captured from this frame
if(loop.Exceptions.Count == 1)
loop.Exceptions[0].Throw();
else if (loop.Exceptions.Count > 1)
throw new AggregateException(loop.Exceptions.Select(x => x.SourceException));
}
void HandleException(Exception e)
{
if (_runLoopStack.Count > 0)
{
var frame = _runLoopStack.Peek();
frame.Exceptions.Add(ExceptionDispatchInfo.Capture(e));
frame.Stop();
}
else
{
var externalLogger = _platform.Options.ExternalGLibMainLoopExceptionLogger;
if (externalLogger != null)
externalLogger.Invoke(e);
else
Logger.TryGet(LogEventLevel.Error, LogArea.Control)
?.Log("Dispatcher", "Unhandled exception: {exception}", e);
}
}
private class ManagedLoopFrame : IDisposable
{
private readonly CancellationToken _externalToken;
private CancellationTokenSource? _internalTokenSource;
public CancellationToken Cancelled { get; private set; }
private readonly IntPtr _loop = g_main_loop_new(IntPtr.Zero, 1);
public List<ExceptionDispatchInfo> Exceptions { get; } = new();
private readonly object _destroyLock = new();
private bool _disposed;
public ManagedLoopFrame(CancellationToken token)
{
_externalToken = token;
}
public void Stop()
{
try
{
_internalTokenSource?.Cancel();
}
catch
{
// Ignore
}
}
public void Run()
{
if (_externalToken.IsCancellationRequested)
return;
using (_internalTokenSource = new())
using (var composite =
CancellationTokenSource.CreateLinkedTokenSource(_externalToken, _internalTokenSource.Token))
{
Cancelled = composite.Token;
using (Cancelled.Register(() =>
{
lock (_destroyLock)
{
if (_disposed)
return;
g_main_loop_quit(_loop);
}
}))
{
g_main_loop_run(_loop);
}
}
}
public void Dispose()
{
lock (_destroyLock)
{
if(_disposed)
return;
_disposed = true;
g_main_loop_unref(_loop);
}
}
}
public X11EventDispatcher EventDispatcher => _x11Events;
}

289
src/Avalonia.X11/Dispatching/GlibDispatcherImplBase.cs

@ -0,0 +1,289 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Runtime.ExceptionServices;
using System.Threading;
using Avalonia.Logging;
using Avalonia.Threading;
using static Avalonia.X11.Interop.Glib;
namespace Avalonia.X11.Dispatching;
/// <summary>
/// Platform-agnostic GLib (GMainLoop/GSource) based dispatcher implementation. It maps Avalonia's dispatcher model
/// onto GLib so Avalonia code can share a GLib main loop with GLib/GTK based libraries on the UI thread.
/// It owns no platform event source by itself; backends derive from it to attach their own (e.g. the X11 socket).
/// </summary>
internal abstract class GlibDispatcherImplBase :
IDispatcherImplWithExplicitBackgroundProcessing,
IControlledDispatcherImpl
{
/*
GLib priorities and Avalonia priorities are a bit different. Avalonia follows the WPF model when there
are "background" and "foreground" priority groups. Foreground jobs are executed before any user input processing,
background jobs are executed strictly after user input processing.
GLib has numeric priorities that are used in the following way:
-100 G_PRIORITY_HIGH - "high" priority sources, not really used by GLib/GTK
0 G_PRIORITY_DEFAULT - polling X11 events (GTK) and default value for g_timeout_add
100 G_PRIORITY_HIGH_IDLE without a clear definition, used as an anchor value of sorts
110 Resize/layout operations (GTK)
120 Render operations (GTK)
200 G_PRIORITY_DEFAULT_IDLE - "idle" priority sources
So, unlike Avalonia, GTK puts way higher priority on input processing, then does resize/layout/render
So, to map our model to GLib we do the following:
- foreground jobs (including grouped user events) are executed with (-1) priority (_before_ any normal GLib jobs)
- the platform event source is polled with G_PRIORITY_DEFAULT, all events are read until the socket is empty,
we also group input events at that stage (this matches our epoll-based dispatcher)
- background jobs are executed with G_PRIORITY_DEFAULT_IDLE, so they would have lower priority than GTK
foreground jobs
Unfortunately we can't detect if there are pending _non-idle_ GLib jobs using g_main_context_pending, since
- g_main_context_pending doesn't accept max_priority argument
- even if it did, that would still involve a syscall to the kernel to poll for fds anyway
So we just report that we don't support pending input query and let the dispatcher to
call RequestBackgroundProcessing every time, which results in g_idle_add call for every background job.
Background jobs are expected to be relatively expensive to execute since on Windows
MsgWaitForMultipleObjectsEx results isn't really free too.
For signaling (aka waking up dispatcher for processing _high_ priority jobs we are using
g_idle_add_full with (-1) priority. While the naming suggests that it would enqueue an idle job,
it actually adds an always-triggered source that would be called before other sources with lower priority.
For timers we are using a simple g_timeout_add_full and discard the previous one when dispatcher requests
an update
Since GLib dispatches event sources in batches, we force-check for "signaled" flag to run high-prio jobs
whenever we get control back from GLib. We can still occasionally get GTK code to run before high-prio
Avalonia-jobs, but that should be fine since the point is to keep Avalonia-based jobs ordered properly
and to not have our low-priority jobs to prevent GLib-based code from running its own "foreground" jobs
Another implementation note here is that GLib (just as any other C library) is NOT aware of C# exceptions,
so we are NOT allowed to have exceptions to escape native->managed call boundary. So we have exception handlers
that try to propagate those to the nearest run loop frame that was initiated by Avalonia.
If there is no such frame, we have no choice but to log/swallow those
*/
// Note that we can't use g_main_context_is_owner outside a run loop, since context doesn't really have an
// inherent owner when run loop is not running and the context isn't explicitly "locked", so we just assume that
// the app author is initializing Avalonia on the intended UI thread and won't migrate the default run loop
// to a different thread
private readonly Thread _mainThread = Thread.CurrentThread;
private readonly Action<Exception>? _externalExceptionLogger;
private bool _signaled;
private bool _signaledSourceAdded;
private readonly object _signalLock = new();
private readonly Stack<ManagedLoopFrame> _runLoopStack = new();
private readonly Stopwatch _stopwatch = Stopwatch.StartNew();
private uint? _glibTimerSourceTag;
protected GlibDispatcherImplBase(Action<Exception>? externalExceptionLogger)
{
_externalExceptionLogger = externalExceptionLogger;
}
public bool CurrentThreadIsLoopThread => _mainThread == Thread.CurrentThread;
public event Action? Signaled;
public void Signal()
{
lock (_signalLock)
{
if(_signaled)
return;
_signaled = true;
if(_signaledSourceAdded)
return;
_signaledSourceAdded = true;
}
g_idle_add_full(G_PRIORITY_DEFAULT - 1, SignalSourceCallback);
}
protected void CheckSignaled()
{
lock (_signalLock)
{
if (!_signaled)
return;
_signaled = false;
}
try
{
Signaled?.Invoke();
}
catch (Exception e)
{
HandleException(e);
}
Flush();
}
private bool SignalSourceCallback()
{
lock (_signalLock)
{
_signaledSourceAdded = false;
}
CheckSignaled();
return false;
}
public event Action? Timer;
public long Now => _stopwatch.ElapsedMilliseconds;
public void UpdateTimer(long? dueTimeInMs)
{
if (_glibTimerSourceTag.HasValue)
{
g_source_remove(_glibTimerSourceTag.Value);
_glibTimerSourceTag = null;
}
if (dueTimeInMs == null)
return;
var interval = (uint)Math.Max(0, (int)Math.Min(int.MaxValue, dueTimeInMs.Value - Now));
_glibTimerSourceTag = g_timeout_add_once(interval, TimerCallback);
}
private void TimerCallback()
{
try
{
Timer?.Invoke();
}
catch (Exception e)
{
HandleException(e);
}
Flush();
}
public event Action? ReadyForBackgroundProcessing;
public void RequestBackgroundProcessing() =>
g_idle_add_once(() => ReadyForBackgroundProcessing?.Invoke());
public virtual bool CanQueryPendingInput => false;
public virtual bool HasPendingInput => false;
/// <summary>
/// Flush any pending output to the platform event source after Avalonia jobs ran. Called whenever control
/// returns to GLib from a signaled/timer callback. The base implementation does nothing; backends that own a
/// socket (e.g. X11) override it to flush their connection.
/// </summary>
protected virtual void Flush()
{
}
/// <summary>The cancellation token of the innermost Avalonia-controlled run loop frame, or None when no frame
/// is running. Backends use it to stop draining their event source once a loop frame asked to quit.</summary>
protected CancellationToken CurrentLoopCancellation =>
_runLoopStack.Count > 0 ? _runLoopStack.Peek().Cancelled : CancellationToken.None;
public void RunLoop(CancellationToken token)
{
if(token.IsCancellationRequested)
return;
using var loop = new ManagedLoopFrame(token);
_runLoopStack.Push(loop);
loop.Run();
_runLoopStack.Pop();
// Propagate any managed exceptions that we've captured from this frame
if(loop.Exceptions.Count == 1)
loop.Exceptions[0].Throw();
else if (loop.Exceptions.Count > 1)
throw new AggregateException(loop.Exceptions.Select(x => x.SourceException));
}
protected void HandleException(Exception e)
{
if (_runLoopStack.Count > 0)
{
var frame = _runLoopStack.Peek();
frame.Exceptions.Add(ExceptionDispatchInfo.Capture(e));
frame.Stop();
}
else
{
if (_externalExceptionLogger != null)
_externalExceptionLogger.Invoke(e);
else
Logger.TryGet(LogEventLevel.Error, LogArea.Control)
?.Log("Dispatcher", "Unhandled exception: {exception}", e);
}
}
private class ManagedLoopFrame : IDisposable
{
private readonly CancellationToken _externalToken;
private CancellationTokenSource? _internalTokenSource;
public CancellationToken Cancelled { get; private set; }
private readonly IntPtr _loop = g_main_loop_new(IntPtr.Zero, 1);
public List<ExceptionDispatchInfo> Exceptions { get; } = new();
private readonly object _destroyLock = new();
private bool _disposed;
public ManagedLoopFrame(CancellationToken token)
{
_externalToken = token;
}
public void Stop()
{
try
{
_internalTokenSource?.Cancel();
}
catch
{
// Ignore
}
}
public void Run()
{
if (_externalToken.IsCancellationRequested)
return;
using (_internalTokenSource = new())
using (var composite =
CancellationTokenSource.CreateLinkedTokenSource(_externalToken, _internalTokenSource.Token))
{
Cancelled = composite.Token;
using (Cancelled.Register(() =>
{
lock (_destroyLock)
{
if (_disposed)
return;
g_main_loop_quit(_loop);
}
}))
{
g_main_loop_run(_loop);
}
}
}
public void Dispose()
{
lock (_destroyLock)
{
if(_disposed)
return;
_disposed = true;
g_main_loop_unref(_loop);
}
}
}
}
Loading…
Cancel
Save