Implement SafeHandle wrappers for native resources (SafeGtkWidgetHandle, SafeGObjectHandle, SafeX11DisplayHandle, SafeX11CursorHandle, SafeDlopenHandle) to prevent memory leaks. Move gesture and rendering configuration from static properties to LinuxApplicationOptions for better testability and DI compatibility. Add THREADING.md and DI-MIGRATION.md documentation. Include LayoutIntegrationTests for Measure/Arrange pipeline and SkiaViewTheoryTests with parameterized test cases using [Theory] attributes.
8.6 KiB
Threading Model and Native Handle Ownership
This document describes the threading rules, event loop architecture, and native resource management patterns used in the maui-linux project.
Overview
maui-linux uses a single-threaded UI model inherited from GTK. All UI operations -- rendering, layout, input handling, and widget mutation -- must execute on a single designated thread called the GTK thread. Background work is permitted, but its results must be marshaled back to the GTK thread before touching any UI state.
The GTK Thread
At startup, LinuxApplication.Run initializes the dispatcher and records the
current managed thread ID:
// LinuxApplication.cs
private static int _gtkThreadId;
private static void StartHeartbeat()
{
_gtkThreadId = Environment.CurrentManagedThreadId;
// ...
}
// LinuxDispatcher.cs
public static void Initialize()
{
_mainThreadId = Environment.CurrentManagedThreadId;
_mainDispatcher = new LinuxDispatcher();
}
Both _gtkThreadId (in LinuxApplication) and _mainThreadId (in
LinuxDispatcher) capture the same thread -- the one that will run the event
loop. Diagnostic logging in LogInvalidate and LogRequestRedraw warns when
UI work is attempted from any other thread.
Thread Marshaling
There are two mechanisms to marshal work onto the GTK thread.
1. GLibNative.IdleAdd / GLibNative.TimeoutAdd (low-level)
These wrap g_idle_add and g_timeout_add from GLib. The callback runs on the
next iteration of the GTK main loop. Returning true reschedules the callback;
returning false removes it.
// Marshal a one-shot action to the GTK thread
GLibNative.IdleAdd(() =>
{
// Runs on the GTK thread
RequestRedrawInternal();
return false; // do not repeat
});
// Schedule a recurring action every 250 ms
GLibNative.TimeoutAdd(250, () =>
{
// heartbeat logic
return true; // keep repeating
});
GLibNative prevents the delegate from being garbage-collected by storing it
in a static _callbacks list (protected by _callbackLock). The wrapper is
removed from the list when the callback returns false.
GtkNative has its own IdleAdd overload with the same prevent-GC pattern
(via _idleCallbacks). Prefer the GLibNative versions for new code -- they
have proper locking and error logging.
2. LinuxDispatcher (high-level, MAUI-compatible)
LinuxDispatcher implements IDispatcher so that MAUI's Dispatcher.Dispatch
works correctly on Linux.
// From any thread:
dispatcher.Dispatch(() =>
{
// Runs on the GTK thread via GLibNative.IdleAdd
});
dispatcher.DispatchDelayed(TimeSpan.FromMilliseconds(500), () =>
{
// Runs on the GTK thread after a 500 ms delay via GLibNative.TimeoutAdd
});
If Dispatch is called from the GTK thread, the action runs synchronously
(no round-trip through the event loop). DispatchDelayed always goes through
TimeoutAdd, regardless of calling thread.
LinuxDispatcherTimer wraps GLibNative.TimeoutAdd to implement
IDispatcherTimer. Stopping a timer calls GLibNative.SourceRemove to cancel
the GLib source.
Event Loop
The application supports two event-loop modes, selected by
LinuxApplicationOptions.UseGtk.
X11 Mode (RunX11)
A manual polling loop on the main thread:
while (_mainWindow.IsRunning)
{
_mainWindow.ProcessEvents(); // drain X11 event queue
SkiaWebView.ProcessGtkEvents(); // pump GTK for WebView support
UpdateAnimations();
Render();
Thread.Sleep(1); // yield CPU
}
X11 events are read with XNextEvent / XPending. GTK is pre-initialized
(gtk_init_check) so that WebView (WebKitGTK) works, but GTK does not own the
main loop. GTK events are drained cooperatively via ProcessGtkEvents.
GTK Mode (RunGtk)
GTK owns the main loop:
StartHeartbeat(); // records _gtkThreadId, starts 250 ms heartbeat
PerformGtkLayout(w, h);
_gtkWindow.RequestRedraw();
_gtkWindow.Run(); // calls gtk_main() -- blocks until quit
GtkHostService.Instance.Shutdown();
All rendering is driven by GTK draw callbacks. GLibNative.IdleAdd and
TimeoutAdd integrate naturally because GTK's main loop processes GLib
sources.
Native Handle Ownership
Raw IntPtr Handles (current codebase)
Most native resources are currently held as raw IntPtr fields. The owning
class implements IDisposable and frees resources in Dispose(bool).
X11 resources (X11Window):
| Resource | Acquire | Release |
|---|---|---|
Display* |
XOpenDisplay |
XCloseDisplay |
Window |
XCreateSimpleWindow |
XDestroyWindow |
Cursor |
XCreateFontCursor |
XFreeCursor |
XImage* |
XCreateImage |
XDestroyImage (also frees pixel buffer) |
Release order matters: cursors must be freed before the display is closed, and the window must be destroyed before the display is closed.
GObject resources (GtkHostWindow and others):
| Resource | Acquire | Release |
|---|---|---|
| GTK widget | gtk_window_new, gtk_drawing_area_new, etc. |
gtk_widget_destroy |
| GdkPixbuf | gdk_pixbuf_new_from_file |
g_object_unref |
| GtkCssProvider | gtk_css_provider_new |
g_object_unref |
GObject uses reference counting. g_object_unref decrements the ref
count; when it reaches zero the object is freed. If you receive an object with
a "floating" reference (common for newly created GTK widgets), adding it to a
container sinks the reference -- the container then owns it. Only call
g_object_unref on objects you have explicitly ref'd or that you own.
Signal Connection / Disconnection
GTK signals are connected with g_signal_connect_data and return a ulong
handler ID. Signals must be disconnected before the widget is destroyed,
or the pointers backing the managed delegates become dangling.
// Connect (store the handler ID)
_deleteSignalId = GtkNative.g_signal_connect_data(
_window, "delete-event",
Marshal.GetFunctionPointerForDelegate(_deleteEventHandler),
IntPtr.Zero, IntPtr.Zero, 0);
// Disconnect (in Dispose, before gtk_widget_destroy)
if (_deleteSignalId != 0)
GtkNative.g_signal_handler_disconnect(_window, _deleteSignalId);
Keep the delegate instance alive for as long as the signal is connected (store it as a field). If the GC collects the delegate while the signal is still connected, GTK will call into freed memory.
SafeHandle Wrappers (for new code)
Native/SafeHandles.cs provides SafeHandle-derived wrappers that automate
release:
| Type | Releases via |
|---|---|
SafeGtkWidgetHandle |
gtk_widget_destroy |
SafeGObjectHandle |
g_object_unref |
SafeX11DisplayHandle |
XCloseDisplay |
SafeX11CursorHandle |
XFreeCursor (requires display ptr at construction) |
SafeCssProviderHandle |
g_object_unref |
New code should prefer these over raw IntPtr where practical. They guarantee
release even if Dispose is not called (via the CLR release mechanism),
and they prevent double-free by tracking validity.
Common Pitfalls
-
UI work off the GTK thread. Any call to GTK or X11 APIs, or mutation of
SkiaViewstate, from a background thread is undefined behavior. Always marshal withGLibNative.IdleAddorLinuxDispatcher.Dispatch. The diagnostic warnings fromLogInvalidate/LogRequestRedrawexist to catch this -- do not ignore them. -
Delegate collection during signal connection. A
GSourceFuncor signal callback delegate passed to native code must be stored in a field or a static list for its entire connected lifetime.GLibNative._callbacksandGtkNative._idleCallbacksexist for this purpose. -
Reentrancy in
RequestRedraw. The_isRedrawingflag guards against recursive invalidation. CallingRequestRedrawfrom within a render callback is safe (it will be a no-op), but scheduling further idle callbacks from within an idle callback can lead to unbounded queue growth if not gated. -
X11 resource release ordering. Cursors and windows depend on the display connection. Always free them before calling
XCloseDisplay. TheX11Window.Disposemethod shows the correct order: cursors first, then the window, then the display. -
XDestroyImagefrees the pixel buffer. When you callXCreateImagewith a pointer to pixel data,XDestroyImagewill free that memory. If you allocated the buffer withMarshal.AllocHGlobal, do not free it yourself afterXDestroyImage. IfXCreateImagefails, you must free it yourself. -
Thread.Sleep(1)in X11 mode. The X11 event loop uses a 1 ms sleep to yield CPU. This limits frame rate and increases input latency compared to GTK mode. Be aware of this when profiling rendering performance.