Skip to content

Fix Linux WebView first-paint and Wayland-session GTK init (#5)#38

Open
christopherthompson81 wants to merge 2 commits into
AvaloniaUI:mainfrom
christopherthompson81:fix/issue-5-linux-x11-initial-layout
Open

Fix Linux WebView first-paint and Wayland-session GTK init (#5)#38
christopherthompson81 wants to merge 2 commits into
AvaloniaUI:mainfrom
christopherthompson81:fix/issue-5-linux-x11-initial-layout

Conversation

@christopherthompson81
Copy link
Copy Markdown

@christopherthompson81 christopherthompson81 commented May 2, 2026

Contributes to #5.

Two related fixes for the same broken-on-Linux symptom in the GTK adapters. Each can be discussed independently if you'd prefer them split — happy to break this into two PRs.


1. First-paint blank pane on Linux

Symptom

NativeWebView loads adapters cleanly on Linux — installed=True, AdapterCreated handle=XID, NavigationCompleted ok=True — but paints zero pixels until the user resizes the window. Setting a coloured Background doesn't tint either.

Root cause

NativeWebView constructs an internal NativeWebViewControlHost (an Avalonia NativeControlHost) and adds it via VisualChildren.Add. Adding to VisualChildren makes a control a visual descendant but not a layout participant — Avalonia only measures and arranges children the parent explicitly drives. The base MeasureOverride returned size based on the requested control size but didn't measure its visual children, and there was no ArrangeOverride at all.

Cascading consequence:

  • controlHostImpl.Bounds stays default(Rect) (its Measure/Arrange are never called).
  • The bounds-zero guard inside NativeControlHost.TryUpdateNativeControlPosition bails (a zero-size slot isn't worth committing to the platform).
  • INativeControlHostImpl.ShowInBounds is never called.
  • On X11 specifically, the slot is created sized 1×1 and IsUnMapped. After GtkX11WebViewAdapter.SetParent reparents the GTK toplevel under that slot, xwininfo shows:
layer size map state
Avalonia toplevel (window size) IsViewable
Native-host slot 1×1 IsUnMapped
Reparented GTK toplevel 200×200 (GTK default) IsUnviewable

The slot's descendants are IsUnviewable per X11 semantics — an ancestor isn't mapped, so even a correctly-rendering WebKit surface inside it has nowhere to reach the screen.

A user-driven resize happens to trigger a full layout pass that does propagate bounds into the slot, maps it, and the size-sync wiring takes over for the lifetime of the control — hence "blank until I resize the window". The size-sync wiring isn't broken; the host's children were never driven through layout in the first place. Avalonia's X11 INativeControlHostImpl is fine.

Fix

Drive Measure and Arrange over VisualChildren explicitly inside NativeWebView.MeasureOverride / ArrangeOverride. With this wiring, controlHostImpl.Bounds is populated on first layout, the bounds-zero guard passes, and ShowInBounds maps the slot at the correct size on first show.

protected override ControlSize MeasureOverride(ControlSize availableSize)
{
    var measured = base.MeasureOverride(availableSize);
    var resolved = /* existing sizing logic */;

    foreach (var child in VisualChildren)
    {
        if (child is Layoutable layoutable)
            layoutable.Measure(resolved);
    }

    return resolved;
}

protected override ControlSize ArrangeOverride(ControlSize finalSize)
{
    var arranged = base.ArrangeOverride(finalSize);
    foreach (var child in VisualChildren)
    {
        if (child is Layoutable layoutable)
            layoutable.Arrange(new Rect(arranged));
    }
    return arranged;
}

Gated to the Avalonia code path via #if AVALONIA so WPF / XPF stay untouched. The wiring runs on all Avalonia platforms but only changes observable behavior on Linux — Windows / macOS / Android were driving layout correctly through other paths and continue to. No Avalonia core changes needed.


2. Force GDK_BACKEND=x11 around GTK init

Symptom

Under a Wayland session (e.g. KDE Plasma 6 Wayland, default GNOME on most distros) GDK_BACKEND is pre-set to wayland by the desktop. With the package as-is, Avalonia's GTK bridge (Avalonia.X11.NativeDialogs.Gtk.StartGtk) consequently picks the Wayland backend and gtk_init fails:

System.InvalidOperationException: Unable to initialize GTK
   at Avalonia.Controls.Gtk.AvaloniaGtk.<RunTask>g__PrivateApi|12_0[T](...)
   at Avalonia.Controls.Gtk.GtkX11WebViewAdapter.CreateBuilder(...)

Both GtkX11WebViewAdapter and GtkOffscreenAvaloniaWebViewAdapter go through the same bridge and both rely on X11/GDK-X11-only entry points, so the failure mode is shared. Consumers had to set GDK_BACKEND=x11 in the shell before launching, which is awkward to document for an end-user app.

Fix

Add a shared helper AvaloniaGtk.EnsureX11GdkBackendForGtkInit() and use it via using var _ = ... at the top of each adapter's CreateBuilder:

public static async Task<...> CreateBuilder(...)
{
    using var _ = EnsureX11GdkBackendForGtkInit();
    var adapter = await RunOnGlibThreadAsync(() => new ...);
    // ...
}

Properties of the helper:

  • Scoped via IDisposable. The override only persists for the duration of the bridge call. By the time CreateBuilder returns, GDK_BACKEND is back to its original value. GTK has already locked in the X11 backend for the process by then, so restoring doesn't switch backends — it just keeps the process env clean for any unrelated code that reads GDK_BACKEND.

  • Conservative about user intent. Only overrides the unset case and the implicit "wayland" case (the default under a Wayland session). If the user has explicitly set some other backend like "broadway", that's a deliberate choice in conflict with loading an X11-only adapter, and we let gtk_init fail with its own error rather than silently override. The unset / implicit-wayland path is the no-decision case where the override is unambiguously what the consumer wants.

  • Uses libc setenv directly via P/Invoke. Environment.SetEnvironmentVariable is not sufficient on Linux: it updates .NET's managed env cache but does not propagate to libc's environ, which is what gtk_init reads via getenv. Verified empirically — setting it via Environment.SetEnvironmentVariable from Program.Main left gtk_init still failing despite the managed cache reading x11. A direct libc setenv call (routed through the existing Avalonia.Controls.Linux.Interop.LibC) is what actually takes effect.

gdk_set_allowed_backends was attempted first as the GTK-blessed alternative but did not constrain Avalonia's GTK init helper in practice — the call returned cleanly and gtk_init continued to pick Wayland. The setenv route was the only one that worked end-to-end. (Happy to switch to the GTK API if a reviewer can identify why it didn't take effect.)

DllNotFoundException / EntryPointNotFoundException are swallowed defensively in case a future Linux runtime ships without a libc binding under that exact name. A non-zero setenv return is also treated as a bail-out, logged via Avalonia.Logging, so managed and native env state can't diverge.


Testing

Verified on two mainstream Linux configurations using a minimal Avalonia 12 standalone repro app (single NativeWebView, hard-coded red <body> test page) with no consumer-side workarounds in the picture:

dev box cross-check
OS KDE Neon 24.04 Arch Linux
compositor KWin / X11 KWin / Wayland session, XWayland for X11
KDE Plasma 5 6
webkit2gtk-4.1 2.50.1 2.52.3
.NET 10.0.103 10.0.104
stock 12.0.0 (no fixes) blank pane, slot 1×1 IsUnMapped, GTK 200×200 IsUnviewable same — bug reproduces, and Wayland session also blocks adapter init via GDK_BACKEND
with this PR red paints first show, slot WxH IsViewable, GTK matches, no GDK_BACKEND=x11 needed same — both fixes work

Two divergent platforms (Plasma 5 X11-native and Plasma 6 Wayland-via-XWayland, two webkit2gtk-4.1 minor versions apart) both go from blank → red with only the package change. Same bug signature, same fix outcome.

A Wayland-native Avalonia run (no XWayland) wasn't tested — both Linux GTK adapters in this package are X11-only by construction, so that's a different code path / different fix discussion.

The GDK_BACKEND override / refcount logic is also covered by GdkBackendOverrideTests (5 facts, Linux-only). Each assertion checks both the managed env cache and libc's environ via getenv, so a silent libc no-op — the actual failure mode that motivated the libc P/Invoke — would fail the test rather than pass at the managed layer.

Anything else

  • The first-paint fix lives in NativeWebView rather than a specific X11 adapter, so it benefits any future native-host-backed adapter, not just the current one.
  • The EnsureX11GdkBackendForGtkInit helper is Linux-only and shared between the X11 and offscreen adapters so they can't drift; both get the fix from a single source.

Comment on lines +611 to +613
// Workaround for https://github.com/AvaloniaUI/Avalonia.Controls.WebView/issues/5
// On Linux, Avalonia's INativeControlHostImpl creates the X11 slot
// at 1×1 IsUnMapped on first attach, and the reparented WebKit
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it make sense to fix on the Avalonia side?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried to start an upstream Avalonia fix, but the diagnostic work flipped the diagnosis. Avalonia's X11 INativeControlHostImpl is fine; the bug is in NativeWebView. It adds NativeWebViewControlHost to VisualChildren but its MeasureOverride doesn't measure the child and there's no ArrangeOverride, so controlHostImpl.Bounds stays default(Rect), the bounds=0,0 guard in TryUpdateNativeControlPosition bails, and ShowInBounds is never called. Reproduced the same 1×1 IsUnMapped xwininfo signature in a stand-alone Sandbox without WebView at all (plain Control + NativeControlHost in VisualChildren without measure/arrange) which rules Avalonia core out.

Win32/macOS don't show it because their holder windows are visible-by-default. The original nudge worked by accident: top.InvalidateMeasure intersected with NativeWebViewControlHost.WebViewAdapterOnInitialized's internal retry enough times to catch non-zero bounds once.

I've amended this PR so that MeasureOverride/ArrangeOverride drive Measure/Arrange on VisualChildren, plus InvalidateMeasure() after the deferred VisualChildren.Add. All #if AVALONIA. Verified end-to-end with Avalonia.Controls.WebView.Samples.Desktop on X11 (slot now correctly sized + IsViewable); WPF cross-compiles clean.

The EnsureX11GdkBackendForGtkInit half is unchanged because that one really does belong in this package.

)

Two related fixes for the same broken-on-Linux symptom — both contained
inside the package, both no-op on platforms that already work.

1. Layout participation for the inner NativeControlHost

NativeWebView is a Control (not a NativeControlHost) that adds its
inner NativeWebViewControlHost to VisualChildren after async adapter
creation. In Avalonia, adding to VisualChildren puts a control in the
visual tree but does NOT make it a layout participant — the parent
must explicitly Measure/Arrange visual children from
MeasureOverride/ArrangeOverride.

NativeWebView previously did neither, so the inner NativeControlHost's
Bounds stayed default(Rect) and the cross-platform NativeControlHost
flow's `if (bounds.Width == 0 && bounds.Height == 0) return false`
guard in TryUpdateNativeControlPosition silently skipped the call to
ShowInBounds. On Win32/macOS this is invisible because their holder
windows are visible-by-default; on X11 the holder stays 1×1 IsUnMapped,
the reparented WebKit GTK toplevel is IsUnviewable, and navigation
completes ok=True with zero pixels visible.

xwininfo on the broken case:

    Avalonia toplevel       (window size)  IsViewable
      Native-host slot      1×1            IsUnMapped       <-- bug
        Reparented GTK      200×200        IsUnviewable     <-- bug

Fix:

  - NativeWebView.MeasureOverride now drives Measure() on each
    Layoutable in VisualChildren with the size it returns.
  - A new NativeWebView.ArrangeOverride drives Arrange() on each
    Layoutable in VisualChildren to the final size.
  - InvalidateMeasure() is called immediately after
    VisualChildren.Add((Control)controlHostImpl) so the layout pass
    actually re-runs for the freshly-added child (VisualChildren.Add
    does not itself invalidate layout).

The bug was reproduced in a standalone Avalonia Sandbox without
WebView — a plain Control adding a NativeControlHost to VisualChildren
without measure/arrange, which produces the same 1×1 IsUnMapped slot
xwininfo signature. The Measure/Arrange + InvalidateMeasure shape was
verified to fix the symptom there before being applied here. xwininfo
on the Avalonia.Controls.WebView.Samples.Desktop app with this fix:
slot is the parent's bounds, IsViewable, GTK toplevel matches.

WPF code paths (Avalonia.Xpf.Controls.WebView) are unchanged — every
addition is gated behind #if AVALONIA. Verified to compile clean for
net8.0-windows / net10.0-windows.

2. Override GDK_BACKEND=x11 around GTK init

Both GtkX11WebViewAdapter and GtkOffscreenAvaloniaWebViewAdapter use
Avalonia's GTK bridge (Avalonia.X11.NativeDialogs.Gtk.StartGtk) to
initialize GTK, and both rely on X11/GDK-X11-only entry points. Under a
Wayland session the desktop typically pre-sets GDK_BACKEND=wayland, so
the bridge's gtk_init picks the Wayland backend and fails with "Unable
to initialize GTK". Consumers previously had to set GDK_BACKEND=x11 in
the shell before launching.

Add a shared helper AvaloniaGtk.EnsureX11GdkBackendForGtkInit() that
overrides GDK_BACKEND for the duration of the bridge call, then
restores the previous value. Used via `using var _ = ...` at the top
of each adapter's CreateBuilder so the override is visible at the call
site and tightly scoped to GTK init.

Notes:

- Only overrides the unset case and the implicit "wayland" case (the
  default under a Wayland session). If the user has explicitly set
  some other backend like "broadway", that's a deliberate choice in
  conflict with loading an X11-only adapter and we let gtk_init fail
  with its own error rather than silently override.

- Concurrent CreateBuilder calls share a single override window via
  refcounting: first call captures the previous env value and applies
  the override, subsequent overlapping calls only refcount, and the
  override is restored when the last scope is disposed. Without
  refcounting, two concurrent calls could each snapshot the other's
  already-overridden value and leave the env stuck at "x11".

- Environment.SetEnvironmentVariable is not sufficient on Linux: it
  updates the .NET managed env cache but does not propagate to libc's
  environ, which is what gtk_init reads via getenv. A direct libc
  setenv call is required (verified empirically — setting only the
  managed value left gtk_init still failing despite the C# side
  reading "x11"). gdk_set_allowed_backends was tried first as the
  GTK-blessed alternative but the call returned cleanly without
  constraining Avalonia's GTK init helper in practice.

Verified on KDE Neon 24.04 (Plasma 5 / X11) and Arch Linux + KDE
Plasma 6 (Wayland session, XWayland for X11) using a minimal Avalonia
12 standalone repro app: stock 12.0.0 paints blank with the slot/GTK
state above, with this PR paints first-show without any consumer-side
GDK_BACKEND=x11 in the environment.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@christopherthompson81 christopherthompson81 force-pushed the fix/issue-5-linux-x11-initial-layout branch from 72f643f to 4b670ee Compare May 4, 2026 16:39
@maxkatz6 maxkatz6 requested a review from Copilot May 11, 2026 04:56
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Addresses Linux-specific failures in the GTK-based WebView adapters by ensuring the embedded native host participates in Avalonia layout immediately (fixing “blank until resize”), and by forcing GTK initialization to use the X11 GDK backend in Wayland sessions where GDK_BACKEND=wayland would otherwise break gtk_init.

Changes:

  • Force an initial layout pass for the newly added native host visual by invalidating measure and explicitly measuring/arranging VisualChildren in NativeWebView.
  • Add AvaloniaGtk.EnsureX11GdkBackendForGtkInit() to temporarily set GDK_BACKEND=x11 (with refcounted restore) during adapter initialization.
  • Apply the GTK init backend helper in both GtkX11WebViewAdapter and GtkOffscreenAvaloniaWebViewAdapter builder creation paths.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 4 comments.

File Description
src/Avalonia.Controls.WebView/NativeWebView.cs Ensures the native host visual is measured/arranged and triggers layout invalidation after attaching.
src/Avalonia.Controls.WebView.Core/Gtk/GtkX11WebViewAdapter.cs Wraps adapter creation in the X11-backend GTK-init scope.
src/Avalonia.Controls.WebView.Core/Gtk/GtkOffscreenAvaloniaWebViewAdapter.cs Wraps offscreen adapter creation in the X11-backend GTK-init scope.
src/Avalonia.Controls.WebView.Core/Gtk/AvaloniaGtk.cs Adds refcounted, scoped GDK_BACKEND override using libc env mutation for GTK init.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +112 to +114
try { setenv("GDK_BACKEND", "x11", 1); }
catch (DllNotFoundException) { return EmptyScope.Instance; }
catch (EntryPointNotFoundException) { return EmptyScope.Instance; }
Comment on lines +123 to +131
private static readonly object s_overrideLock = new();
private static int s_overrideCount;
private static string? s_savedBackend;

[LibraryImport("libc", EntryPoint = "setenv", StringMarshalling = StringMarshalling.Utf8)]
private static partial int setenv(string name, string value, int overwrite);

[LibraryImport("libc", EntryPoint = "unsetenv", StringMarshalling = StringMarshalling.Utf8)]
private static partial int unsetenv(string name);
// layout participant — Avalonia only measures/arranges visual children that
// the parent explicitly drives. Without this, the inner NativeControlHost's
// Bounds stay default(Rect) and the X11 INativeControlHostImpl never maps
// its slot. See issue #5 / PR #38.
Comment on lines +97 to +101
public static IDisposable EnsureX11GdkBackendForGtkInit()
{
if (!OperatingSystem.IsLinux())
return EmptyScope.Instance;

…env, add tests

- Reuse Linux.Interop.LibC for setenv/unsetenv and add getenv; remove the
  duplicate P/Invoke declarations in AvaloniaGtk.
- Check the libc setenv return code in EnsureX11GdkBackendForGtkInit so a
  non-zero result bails without desyncing the managed env cache or the
  saved-state, and logs a warning.
- Drop the stale "PR AvaloniaUI#38" reference from the NativeWebView layout comment.
- Add GdkBackendOverrideTests covering the four decision branches plus
  nested-scope refcounting. Each assertion checks both the managed env
  cache and libc's environ via getenv, so a silent libc no-op (the
  original failure mode this fix targets) would fail the test rather
  than pass at the managed layer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@christopherthompson81
Copy link
Copy Markdown
Author

Thanks for the review — pushed 9f73ac3 addressing all four comments.

C1: ignored setenv return
Now checked. On non-zero rc we log via Avalonia.Logging and return EmptyScope.Instance without touching s_savedBackend or s_overrideCount, so managed and native env stay in sync. The pre-existing DllNotFound / EntryPointNotFound catches keep the same bail behavior.

C2: duplicate libc P/Invoke
Good catch — Avalonia.Controls.Linux.Interop.LibC already had setenv. Removed the duplicate declarations from AvaloniaGtk, added unsetenv and getenv to LibC, and routed both adapters through the shared interop.

C3: stale "PR #38" reference
Replaced with a one-line symptom description ("blank pane on Linux until the user resizes the window"). Symptoms rot slower than PR numbers.

C4: no tests for the override/refcount logic
Added GdkBackendOverrideTests with 5 facts covering all four decision branches (wayland → override, unset → override + restore-to-unset, x11 → no-op, explicit non-x11/non-wayland → no-op) plus nested-scope refcount restore. They use Assert.SkipUnless(OperatingSystem.IsLinux(), …) so they skip cleanly on Windows/macOS CI.

The non-trivial design choice: every assertion checks both Environment.GetEnvironmentVariable and libc's environ (via the new LibC.getenv). The whole reason this code exists is that Environment.SetEnvironmentVariable doesn't reach what gtk_init reads — so asserting only at the managed layer would let a silent libc no-op pass while the real failure mode (the one I empirically hit when first writing this) goes undetected. The contract boundary for this fix is getenv.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants