Fix Linux WebView first-paint and Wayland-session GTK init (#5)#38
Conversation
| // 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 |
There was a problem hiding this comment.
Would it make sense to fix on the Avalonia side?
There was a problem hiding this comment.
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>
72f643f to
4b670ee
Compare
There was a problem hiding this comment.
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
VisualChildreninNativeWebView. - Add
AvaloniaGtk.EnsureX11GdkBackendForGtkInit()to temporarily setGDK_BACKEND=x11(with refcounted restore) during adapter initialization. - Apply the GTK init backend helper in both
GtkX11WebViewAdapterandGtkOffscreenAvaloniaWebViewAdapterbuilder 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.
| try { setenv("GDK_BACKEND", "x11", 1); } | ||
| catch (DllNotFoundException) { return EmptyScope.Instance; } | ||
| catch (EntryPointNotFoundException) { return EmptyScope.Instance; } |
| 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. |
| 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>
|
Thanks for the review — pushed C1: ignored C2: duplicate libc P/Invoke C3: stale "PR #38" reference C4: no tests for the override/refcount logic The non-trivial design choice: every assertion checks both |
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
NativeWebViewloads adapters cleanly on Linux —installed=True,AdapterCreated handle=XID,NavigationCompleted ok=True— but paints zero pixels until the user resizes the window. Setting a colouredBackgrounddoesn't tint either.Root cause
NativeWebViewconstructs an internalNativeWebViewControlHost(an AvaloniaNativeControlHost) and adds it viaVisualChildren.Add. Adding toVisualChildrenmakes a control a visual descendant but not a layout participant — Avalonia only measures and arranges children the parent explicitly drives. The baseMeasureOverridereturned size based on the requested control size but didn't measure its visual children, and there was noArrangeOverrideat all.Cascading consequence:
controlHostImpl.Boundsstaysdefault(Rect)(itsMeasure/Arrangeare never called).NativeControlHost.TryUpdateNativeControlPositionbails (a zero-size slot isn't worth committing to the platform).INativeControlHostImpl.ShowInBoundsis never called.IsUnMapped. AfterGtkX11WebViewAdapter.SetParentreparents the GTK toplevel under that slot,xwininfoshows:The slot's descendants are
IsUnviewableper 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
INativeControlHostImplis fine.Fix
Drive
MeasureandArrangeoverVisualChildrenexplicitly insideNativeWebView.MeasureOverride/ArrangeOverride. With this wiring,controlHostImpl.Boundsis populated on first layout, the bounds-zero guard passes, andShowInBoundsmaps the slot at the correct size on first show.Gated to the Avalonia code path via
#if AVALONIAso 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=x11around GTK initSymptom
Under a Wayland session (e.g. KDE Plasma 6 Wayland, default GNOME on most distros)
GDK_BACKENDis pre-set towaylandby the desktop. With the package as-is, Avalonia's GTK bridge (Avalonia.X11.NativeDialogs.Gtk.StartGtk) consequently picks the Wayland backend andgtk_initfails:Both
GtkX11WebViewAdapterandGtkOffscreenAvaloniaWebViewAdaptergo through the same bridge and both rely on X11/GDK-X11-only entry points, so the failure mode is shared. Consumers had to setGDK_BACKEND=x11in the shell before launching, which is awkward to document for an end-user app.Fix
Add a shared helper
AvaloniaGtk.EnsureX11GdkBackendForGtkInit()and use it viausing var _ = ...at the top of each adapter'sCreateBuilder:Properties of the helper:
Scoped via IDisposable. The override only persists for the duration of the bridge call. By the time
CreateBuilderreturns,GDK_BACKENDis 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 readsGDK_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 letgtk_initfail 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
setenvdirectly via P/Invoke.Environment.SetEnvironmentVariableis not sufficient on Linux: it updates .NET's managed env cache but does not propagate to libc'senviron, which is whatgtk_initreads viagetenv. Verified empirically — setting it viaEnvironment.SetEnvironmentVariablefromProgram.Mainleftgtk_initstill failing despite the managed cache readingx11. A direct libcsetenvcall (routed through the existingAvalonia.Controls.Linux.Interop.LibC) is what actually takes effect.gdk_set_allowed_backendswas attempted first as the GTK-blessed alternative but did not constrain Avalonia's GTK init helper in practice — the call returned cleanly andgtk_initcontinued to pick Wayland. Thesetenvroute 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/EntryPointNotFoundExceptionare swallowed defensively in case a future Linux runtime ships without a libc binding under that exact name. A non-zerosetenvreturn is also treated as a bail-out, logged viaAvalonia.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:webkit2gtk-4.1.NETGDK_BACKENDGDK_BACKEND=x11neededTwo divergent platforms (Plasma 5 X11-native and Plasma 6 Wayland-via-XWayland, two
webkit2gtk-4.1minor 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_BACKENDoverride / refcount logic is also covered byGdkBackendOverrideTests(5 facts, Linux-only). Each assertion checks both the managed env cache and libc'senvironviagetenv, 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
NativeWebViewrather than a specific X11 adapter, so it benefits any future native-host-backed adapter, not just the current one.EnsureX11GdkBackendForGtkInithelper is Linux-only and shared between the X11 and offscreen adapters so they can't drift; both get the fix from a single source.