From fcf268d249e5e97008f321fd6afc724defbf75b2 Mon Sep 17 00:00:00 2001
From: Martino Ferrari
Date: Wed, 28 Oct 2026 16:03:05 +0100
Subject: [PATCH] Add --renderer for displays with no usable GPU; gate the
header reading
GTK4 defaults to Vulkan, and an X11-forwarded display has no device to make a
swapchain from; the plot is a GtkGLArea, so it then asks for a GL context that
is not there either. --renderer cairo is the software preset for that display:
it exports GSK_RENDERER=cairo and LIBGL_ALWAYS_SOFTWARE=1 in main() before
gtk_init()/adw_init(), because both are read when the renderer and the context
are created. --renderer gl is the faster choice when indirect GL works, and
--renderer vulkan makes the default explicit. Nothing is exported, and GTK's
own choice stands, when the option is absent.
The once-a-second header line is now behind UDPSCOPE_PROBE_HEADER, following
the [notrace] rule: its widths are still sampled for the exit dump, but the
line is detail behind that reading rather than output every run prints.
UDPSCOPE_PROBE_RENDERER prints the exported pair before GTK starts and the
GskRenderer GTK actually built.
README, the manual's overview and docs/claude record the two symptoms and the
cure.
---
README.md | 28 +++++++++++++++++++++++++-
docs/claude/build-and-run.md | 6 ++++++
docs/claude/probe-readings.md | 18 +++++++++++++++--
docs/claude/verification.md | 11 ++++++++++
help/C/org.udpscope.app/overview.page | 15 +++++++++++++-
src/config.cpp | 15 ++++++++++++++
src/config.hpp | 9 +++++++++
src/main.cpp | 27 +++++++++++++++++++++++++
src/ui.cpp | 29 ++++++++++++++++++++++++---
9 files changed, 151 insertions(+), 7 deletions(-)
diff --git a/README.md b/README.md
index 50c2e46..25a2a25 100644
--- a/README.md
+++ b/README.md
@@ -27,7 +27,10 @@ Options: `--server`, `--port`, `--multicast`, `--interface`, `--buffer-mb`
window the toolbar offers, default 600), `--holdoff` (default trigger hold-off),
`--recv-buffer`, `--plots` (the number of stacked plots to start with, 1..8; rows are
added and removed from the **Layout editor** afterwards), `--relative-time` (see below),
-`--capture-packets N` (see "Packet capture" below).
+`--capture-packets N` (see "Packet capture" below), `--renderer NAME` (GTK's renderer:
+`cairo`, `gl` or `vulkan`; `cairo` is the software preset that exports both
+`GSK_RENDERER` and `LIBGL_ALWAYS_SOFTWARE` before GTK initialises — see the SSH
+limitation below).
**What a large `--buffer-mb` costs at startup, and what the window says while it happens.** The
buffer's rings are value-initialised, so allocating one touches every byte of `--buffer-mb` --- about
@@ -2922,6 +2925,29 @@ silence timeout and then resumes, which is the EMI case end to end.
inner popover takes the grab, the menu closes under the click, and the bubble is
left parented to a row in a hidden popover. Export is described in the
Documentation instead.
+- **Over SSH with X11 forwarding the window may never paint or allocate, and the cure is
+ `--renderer cairo`.** There are two walls, in order.
+ GTK4 defaults to Vulkan and a forwarded display has no usable Vulkan device, so the
+ log shows `vkCreateSwapchainKHR(): Initialization of an object could not be completed
+ ... (VK_ERROR_INITIALIZATION_FAILED)` and nothing is drawn. `GSK_RENDERER` alone gets
+ past that, but the plot is a `GtkGLArea` and asks GDK for a GL context of its own
+ whatever GSK draws with; over forwarding there is no hardware one, and the window then
+ stays unallocated — `header: window_button_w=0` with a zero `bar_w`, and no
+ `[width-probe]` line (it prints only once the plot has a viewport wider than 100 px) —
+ while the stream side is fine. Absence of `buffer allocated:` is *not* part of that
+ reading: signals arrive disabled, so a start with nothing enabled plans no buffer and
+ prints no line even on a healthy display, and the plot stays empty until a signal's
+ switch is turned on.
+ `--renderer cairo` is the preset for exactly this display: it exports
+ `GSK_RENDERER=cairo` **and** `LIBGL_ALWAYS_SOFTWARE=1` (llvmpipe answers the GL area),
+ the same pair `tools/make-help-shots.sh` uses. `--renderer gl` is the faster
+ alternative when indirect GL works. The variables are exported in `main()` **before**
+ `gtk_init()`/`adw_init()`, which is the whole trap: they are read when the renderer and
+ the GL context are created, so setting them later is a silent no-op. Without the option
+ nothing is set and GTK's own choice stands. No `GDK_BACKEND` companion exists on
+ purpose: an X11-forwarded session carries `DISPLAY` and no `WAYLAND_DISPLAY`, so GDK
+ already selects X11; forcing it would override a working local Wayland session for no
+ gain (if both are set and Wayland is broken, export `GDK_BACKEND=x11` yourself).
- **The tour card can cover its own target, and in the documented headless run it
does.** The card is placed under, over or beside the hole whenever one of those has
room; when the target covers the whole content -- which is what the plot does at the
diff --git a/docs/claude/build-and-run.md b/docs/claude/build-and-run.md
index f52482d..5e23f4a 100644
--- a/docs/claude/build-and-run.md
+++ b/docs/claude/build-and-run.md
@@ -103,6 +103,12 @@ count to *start* with, 1..`RowSet::kMaxRows`; the layout editor owns it afterwar
the value is required, and the popover button uses 1000 when the flag was absent).
The flag is **not** persisted in the session file: persistence of the enable state
belongs with `todo.md` item 3, alongside the relative-time toggle.
+`--renderer cairo|gl|vulkan` exports `GSK_RENDERER` **at the top of `main()`** —
+before `choose_source()`'s `adw_init()` and `run_ui()`'s, because GSK reads the variable when
+it creates its renderer and setting it later is a silent no-op. `cairo` is the software preset
+and also exports `LIBGL_ALWAYS_SOFTWARE=1`: the plot is a `GtkGLArea`, so a display with no
+usable GPU fails twice, and this is the pair `tools/make-help-shots.sh` already sets. Nothing
+is exported when the option is absent, so GTK's own choice stands.
`tools/mock_udpstreamer.py` has waveform-injection options used by the probes:
diff --git a/docs/claude/probe-readings.md b/docs/claude/probe-readings.md
index 777032d..0f20a67 100644
--- a/docs/claude/probe-readings.md
+++ b/docs/claude/probe-readings.md
@@ -450,11 +450,25 @@ Two lines of the self-check output are assertions in disguise:
floor are the two refusals, and because the probe emits `::clicked` straight at the
widget it reaches the handler even when the button is insensitive, so `add above 0` there
must leave `rows=` and `traces=` untouched rather than growing a ninth row.
-- `header: window_button_w=W bar_w=B` (stderr, once a second) — **`W` must be
+- `[udpscope] header: window_button_w=W bar_w=B` (stderr, once a second, **only when
+ `UDPSCOPE_PROBE_HEADER` is set**) — **`W` must be
a small constant and must not track `B`**: the window control is a header-bar child,
and an expanding one is measured into the title's centre box, so it stretched from the
label to the title (516 px at a 1270 px bar before the fix, 121 px after). A `W` that
- grows with the window width is that bug back.
+ grows with the window width is that bug back. The widths are still sampled every second
+ in every run, because the exit dump's `header:` line reads them; only the line is behind
+ the variable, since a reading printed on every run is a log flood (the `[notrace]` rule).
+ An unallocated window reads `0` for both, which is the reading for "the toplevel never
+ got a size", not for a layout drift.
+- `[renderer] option=NAME GSK_RENDERER=V LIBGL_ALWAYS_SOFTWARE=V` (stderr, at startup,
+ before GTK initialises; only when `UDPSCOPE_PROBE_RENDERER` is set) — what `--renderer`
+ exported, printed from `main()` so the reading is of the environment a pre-init GTK will
+ see. `option=(unset)` with both variables `(unset)` is the default and the assertion that
+ the flag changes nothing unless asked.
+- `[renderer] realized=TYPE` (stderr, once, from the first tick that finds a window renderer;
+ same probe) — the class GTK actually built (`GskCairoRenderer` for `--renderer cairo`),
+ which is the half of the claim the export alone cannot make. It is null until the window is
+ realized, so a tick that finds none tries again rather than printing a false reading.
- `toolbar icons: trigger=NAME(ok) events=... pause=... cursors=... sidebar=... menu=...` (in the
exit dump, beside `panel controls:`) — the icon name every toolbar button
carries, with `gtk_icon_theme_has_icon()`'s verdict. **Every entry must read `(ok)`.** Three
diff --git a/docs/claude/verification.md b/docs/claude/verification.md
index e91556e..87525f4 100644
--- a/docs/claude/verification.md
+++ b/docs/claude/verification.md
@@ -155,6 +155,17 @@ precedent, not an exception.
(the frame's ink is the image) and would otherwise be counted as well. The counter itself is in
every build; see [probe-readings.md](probe-readings.md).
+- `UDPSCOPE_PROBE_HEADER` (no value; **not** a scheduled probe) — the once-a-second
+ `[udpscope] header: window_button_w=… bar_w=…` line. The widths are sampled either way (the
+ exit dump reads them); the line is behind this variable because it is detail behind that
+ reading, not a line every run should print. An unallocated window reads `0` for both.
+- `UDPSCOPE_PROBE_RENDERER` (no value; **not** a scheduled probe) — the renderer pair
+ `--renderer` should have exported, printed from `main()` before GTK initialises
+ (`[renderer] option=… GSK_RENDERER=… LIBGL_ALWAYS_SOFTWARE=…`), then the renderer GTK
+ built (`[renderer] realized=…`) once the window has one. Run it with `--renderer cairo` to
+ check the option reaches the environment before `gtk_init()`/`adw_init()`; run it without
+ the option to check the default is untouched.
+
- `UDPSCOPE_PROBE_TRIGRENUM_MS` — the trigger's capture must survive a **renumbering of its own
channel**. It sets the reported geometry through the real widgets (1 s window, 10 % pre share,
hold-off from `--holdoff`) on the mock's *square* alone, polls for a capture to open, and then
diff --git a/help/C/org.udpscope.app/overview.page b/help/C/org.udpscope.app/overview.page
index 6ea5685..43c2d4d 100644
--- a/help/C/org.udpscope.app/overview.page
+++ b/help/C/org.udpscope.app/overview.page
@@ -55,7 +55,20 @@
The signals arrive disabled. Nothing is acquired until you turn one on in the
signal list, and enabling is what starts acquisition — so a freshly opened scope with an
- idle side panel is working correctly, not failing to connect.
+ idle side panel is working correctly, not failing to connect. For the same reason a start
+ that has enabled nothing prints no buffer allocated: line on stderr.
+
+
+
Over SSH with X11 forwarding the window may never appear, and the cure is
+ udpscope --renderer cairo. Two symptoms tell the two halves apart: a
+ vkCreateSwapchainKHR() ... VK_ERROR_INITIALIZATION_FAILED warning is
+ GTK's default Vulkan renderer failing to make a swapchain and is what the option's
+ GSK_RENDERER=cairo answers; a window whose allocation stays zero
+ (window_button_w=0) means the plot's GL area has no context either, and
+ that is what its LIBGL_ALWAYS_SOFTWARE=1 answers. Both variables are
+ exported before GTK initialises, because they are read when the renderer and the GL
+ context are created. --renderer gl is faster when indirect GL works.
+
See Choosing a source for the chooser and the multicast
diff --git a/src/config.cpp b/src/config.cpp
index 66c1a72..1b71402 100644
--- a/src/config.cpp
+++ b/src/config.cpp
@@ -62,6 +62,12 @@ void print_usage(std::FILE* out, const char* argv0) {
" time is then kept for export only\n"
" --open FILE open an exported waveform (.npy) instead of a live source:\n"
" signals from its columns, no socket, no acquisition\n"
+ " --renderer NAME render with cairo (software), gl or vulkan. cairo also\n"
+ " exports LIBGL_ALWAYS_SOFTWARE=1, because the plot is a\n"
+ " GtkGLArea and still needs a GL context. Over SSH/X11\n"
+ " forwarding use cairo: Vulkan cannot make a swapchain there\n"
+ " and the GL area has no hardware context, so the window\n"
+ " never paints or allocates\n"
" -h, --help this text\n",
argv0);
}
@@ -113,6 +119,15 @@ Config parse_args(int argc, char** argv) {
c.capture_packets = static_cast(n);
} else if (a == "--open" || a.rfind("--open=", 0) == 0) {
c.open_waveform = value_of(argc, argv, i, "--open");
+ } else if (a == "--renderer" || a.rfind("--renderer=", 0) == 0) {
+ const std::string v = value_of(argc, argv, i, "--renderer");
+ // Only the three a source or a forwarded display actually chooses between. Anything
+ // GTK does not know would fall back with a warning and leave the user reading a
+ // renderer failure that is really a typo, so it is refused here instead.
+ if (v != "cairo" && v != "gl" && v != "vulkan") {
+ fail("--renderer must be cairo, gl or vulkan: " + v);
+ }
+ c.renderer = v;
} else if (a == "--relative-time") {
c.relative_time = true;
c.relative_time_specified = true;
diff --git a/src/config.hpp b/src/config.hpp
index 640ae16..3304834 100644
--- a/src/config.hpp
+++ b/src/config.hpp
@@ -60,6 +60,15 @@ struct Config {
/// and the chooser's Waveforms page sets this rather than the connection fields.
std::string open_waveform;
+ /// `--renderer NAME`: the GSK renderer to force, by exporting `GSK_RENDERER` **before any GTK
+ /// call**. Empty leaves GTK's own choice alone. `cairo` is the software preset over SSH/X11
+ /// forwarding: it also exports `LIBGL_ALWAYS_SOFTWARE=1`, because the plot's `GtkGLArea` asks
+ /// for a GL context whatever GSK draws with. `gl` is the faster alternative when indirect GL
+ /// works. The variables are read when the renderer/context is created, so setting them anywhere
+ /// after `gtk_init()`/`adw_init()` is a no-op -- which is why this is a `Config` field applied
+ /// at the top of `main()` and not a preference.
+ std::string renderer;
+
/// The source chooser's "Guided tour" entry. The tour is drawn on the application's own window,
/// which does not exist while the chooser runs, so the request rides on the one value `main()`
/// owns and hands to both the chooser and the front end (`open_waveform` and the preferences'
diff --git a/src/main.cpp b/src/main.cpp
index 9672625..b4e439c 100644
--- a/src/main.cpp
+++ b/src/main.cpp
@@ -10,6 +10,8 @@
#include "source_dialog.hpp"
#include "ui.hpp"
+#include
+
#include
#include
@@ -26,6 +28,31 @@ int main(int argc, char** argv) {
return 2;
}
+ // `--renderer` is exported here and nowhere else, because GSK reads `GSK_RENDERER` when it
+ // *creates* its renderer: inside the first `adw_init()`/`gtk_init()`, which `choose_source()`
+ // below may call. Setting it after that point is a silent no-op, so the one place it can be
+ // honoured is before anything touches GTK. Overwrite: a flag on the command line is a
+ // statement about this run, and the environment is only a default it may override.
+ //
+ // `cairo` is the whole software preset and not just GSK's half: the plot is a `GtkGLArea`,
+ // which asks GDK for a GL context of its own whatever GSK draws with. Over X11 forwarding
+ // that request is what leaves the window unallocated (no `buffer allocated:` line, zero
+ // width) once the Vulkan swapchain failure is out of the way, so `LIBGL_ALWAYS_SOFTWARE`
+ // makes Mesa answer it with llvmpipe. This is the pair `tools/make-help-shots.sh` sets.
+ if (!cfg.renderer.empty()) g_setenv("GSK_RENDERER", cfg.renderer.c_str(), TRUE);
+ if (cfg.renderer == "cairo") g_setenv("LIBGL_ALWAYS_SOFTWARE", "1", TRUE);
+ // The startup half of the renderer probe, printed before GTK exists: the request and the
+ // variables this process actually exported. The window's `[renderer] realized=` line is the
+ // other half and says which renderer GTK built.
+ if (g_getenv("UDPSCOPE_PROBE_RENDERER") != nullptr) {
+ const char* gsk = g_getenv("GSK_RENDERER");
+ const char* gl = g_getenv("LIBGL_ALWAYS_SOFTWARE");
+ std::fprintf(stderr, "[renderer] option=%s GSK_RENDERER=%s LIBGL_ALWAYS_SOFTWARE=%s\n",
+ cfg.renderer.empty() ? "(unset)" : cfg.renderer.c_str(),
+ gsk != nullptr ? gsk : "(unset)", gl != nullptr ? gl : "(unset)");
+ std::fflush(stderr);
+ }
+
// No `--server` means the user has not said which source to open, so ask. Before the model:
// declining must cost no buffer and no socket, and nothing below has to cope with "no source".
// `--open` is the other answer to that question, and the chooser knows it: its Waveforms page
diff --git a/src/ui.cpp b/src/ui.cpp
index efd928e..19d4f8e 100644
--- a/src/ui.cpp
+++ b/src/ui.cpp
@@ -3465,6 +3465,22 @@ gboolean tick(gpointer user) {
ScopeModel& model = *app->model;
const gint64 tick_start_us = g_get_monotonic_time();
+ // The renderer GTK actually built, printed once and only under the probe: `--renderer` exports
+ // `GSK_RENDERER` before any GTK call, and this is the other half of that claim -- the class GTK
+ // chose, not the name it was asked for. The renderer is null until the window is realized, so a
+ // tick that finds none simply tries again.
+ {
+ static const bool renderer_probe = g_getenv("UDPSCOPE_PROBE_RENDERER") != nullptr;
+ static bool renderer_printed = false;
+ if (renderer_probe && !renderer_printed) {
+ if (GskRenderer* r = gtk_native_get_renderer(GTK_NATIVE(app->window))) {
+ std::fprintf(stderr, "[renderer] realized=%s\n", G_OBJECT_TYPE_NAME(r));
+ std::fflush(stderr);
+ renderer_printed = true;
+ }
+ }
+ }
+
// The paint probe's clock, claimed on the first tick that finds one: a widget has no
// frame clock until it is realized, and the area is realized on its first draw.
if (app->dbg_clock == nullptr && app->area_widget != nullptr) {
@@ -3500,14 +3516,21 @@ gboolean tick(gpointer user) {
// the title's centre box, i.e. window_button_w tracked the window width).
if (app->window_button != nullptr) {
static gint64 next_header_log = 0;
+ // The widths stay sampled on this one-second cadence whatever the environment, because the
+ // exit dump's `header:` line reads them; only the *line* is behind a variable. A reading
+ // printed on every run is a log flood and not a reading -- the same rule the `[notrace]`
+ // per-paint line follows.
+ static const bool header_print = g_getenv("UDPSCOPE_PROBE_HEADER") != nullptr;
const gint64 now = g_get_monotonic_time();
if (now >= next_header_log) {
next_header_log = now + G_TIME_SPAN_SECOND;
app->dbg_win_btn_w = gtk_widget_get_width(app->window_button);
app->dbg_bar_w = gtk_widget_get_width(app->window);
- std::fprintf(stderr, "[udpscope] header: window_button_w=%d bar_w=%d\n",
- app->dbg_win_btn_w, app->dbg_bar_w);
- std::fflush(stderr);
+ if (header_print) {
+ std::fprintf(stderr, "[udpscope] header: window_button_w=%d bar_w=%d\n",
+ app->dbg_win_btn_w, app->dbg_bar_w);
+ std::fflush(stderr);
+ }
}
}
// The help layer's own readings and the bounds the help's annotated figures crop, on the same