Files
maui-linux/docs/THREADING.md

249 lines
8.6 KiB
Markdown
Raw Normal View History

# 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:
```csharp
// LinuxApplication.cs
private static int _gtkThreadId;
private static void StartHeartbeat()
{
_gtkThreadId = Environment.CurrentManagedThreadId;
// ...
}
```
```csharp
// 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.
```csharp
// 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.
```csharp
// 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:
```csharp
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.
```csharp
// 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.