Files
maui-linux/docs/THREADING.md
logikonline 6b45f28b4e feat(interop): add safe handle wrappers and configuration options
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.
2026-03-06 23:40:51 -05:00

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

  1. UI work off the GTK thread. Any call to GTK or X11 APIs, or mutation of SkiaView state, from a background thread is undefined behavior. Always marshal with GLibNative.IdleAdd or LinuxDispatcher.Dispatch. The diagnostic warnings from LogInvalidate / LogRequestRedraw exist to catch this -- do not ignore them.

  2. Delegate collection during signal connection. A GSourceFunc or signal callback delegate passed to native code must be stored in a field or a static list for its entire connected lifetime. GLibNative._callbacks and GtkNative._idleCallbacks exist for this purpose.

  3. Reentrancy in RequestRedraw. The _isRedrawing flag guards against recursive invalidation. Calling RequestRedraw from 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.

  4. X11 resource release ordering. Cursors and windows depend on the display connection. Always free them before calling XCloseDisplay. The X11Window.Dispose method shows the correct order: cursors first, then the window, then the display.

  5. XDestroyImage frees the pixel buffer. When you call XCreateImage with a pointer to pixel data, XDestroyImage will free that memory. If you allocated the buffer with Marshal.AllocHGlobal, do not free it yourself after XDestroyImage. If XCreateImage fails, you must free it yourself.

  6. 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.