Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Does the compositor’s half of the QML GPU path still work?

cd dev/wirecheck && cargo build        # natively, or in the build image
./target/debug/wirecheck               # on the host, which is where the GPU is

dev/gate.sh runs both steps (see “The gate” in dev/README.md), passing the first /dev/dri/renderD* the machine has and writing the output to dev/wirecheck/target/wirecheck.log. Exit status is the result; there is nothing to read unless it fails, and then the gate prints the log’s last 25 lines.

This drives the real crates/solium/qml/host.cpp — compiled from source by build.rs, not linked out of target/, because a glob over target/debug/build/solium-*/out/ silently picks a stale archive — and the real crates/solium/src/qml/target.rs, included by path. Both are relative to this crate, so a worktree or somebody else’s clone checks its own code.

Against them it stands up a genuine smithay GlesRenderer, built the way tty::State::open_gpu builds one, and runs the sequence a hosted scene’s Gpu::element runs on a frame Qt has something new for — through Gpu::sample, Gpu::refresh and Gpu::render:

scene_new_gpu → restore our EGL context → render_gpu → restore
  → EGLFence::import → Renderer::wait → import_dmabuf
  → TextureRenderElement::draw into an offscreen target → read back

That sequence lives in crates/solium/src/qml/paint.rs, which is where the wallpaper, the window frames and the pointer all reach it from — the harness runs the same order by hand because nothing here links the compositor crate.

It needs a GPU and a Qt installation at run time, which is why it is here and not a #[test]. It sets the QT_QPA_EGLFS_* environment itself, the same way qml::keep_qt_off_the_hardware does, so it opens the render node and never the card — it is safe to run inside a live session.

The cases, in the order they run

The sections below are not in this order; this is the order src/main.rs runs them in. Each one stops the run at its first failure, except the byte comparisons of the first frame and the frame loop, which are recorded and judged at the very end.

casewhat it provesfails with
1a child started after Qta child started with a plain Command once Qt is up on the GPU, as smithay starts Xwayland, holds none of Qt’s descriptors, so the byte 15 it writes into each one it holds reaches nothing (#175)a child started after Qt holds descriptors …
2the appear animation (appear.qml)an animation started from rest takes steps, rather than landing in one tickthe appear animation never took a step
3a Timer between frames (timer.qml)with no tick and no render, a 50 ms Timer served the way qml::wake serves one (solium_qml_next_due_ms, then solium_qml_drain) fires at least 6 times in 600 ms, a drain reports the scene it changed, and the compositor’s EGL context is still current after every draina 50ms Timer fired … with no frame drawn, the Timer recoloured the scene …, or solium_qml_drain took the compositor's EGL context off the thread …
4the first frame (quadrants.qml)Qt builds and renders a scene into the compositor’s dmabuf; an independent EGL display, with neither Qt nor smithay in it, sees the pixels; and the picture is byte for byte the software path’san independent EGL display sees nothing in the buffer Qt reported rendering, or the GPU path does not match the software path at the end
5a Timer beside an animation (timer.qml again)between the first frame’s render and the checks of its pixels, with quadrants.qml’s endless animation running, the same Timer still fires at least 6 times in 600 ms on drains alone, and the compositor’s EGL context stays currenta 50ms Timer beside a running animation fired …, nothing is animating, so this proves nothing …, or advancing the animation clock between frames took the compositor's EGL context off the thread
6the pointer’s routethat frame, read back and uploaded as memory, is byte for byte what the software path uploadsreading a scene's dmabuf back does not give the bytes the software path uploads
7frames 2..Nevery later frame, into a buffer wiped first, matches the referencethe GPU path does not match the software path, at the end of the run
8the resizea rebind keeps the object tree and its running animation, the scene reads as animating, the new size draws right, and freeing the scene takes none of the compositor’s GL objectsthe QML tree was rebuilt by a resize, or freeing the resized scene destroyed …
9the compositor’s own scenescursor.qml, panes/top/Frame.qml and delegate.qml build and draw on a GPU host; the first two read as not animating, and delegate.qml, whose one animation is in a Repeater delegate, reads as animatinga GPU host could not build the …, or `solium_qml_scene_animating` says …
10the pointer’s sizecursor.qml at 16, 24, 48 and 96 draws from the corner and fills the same fraction at eachthe pointer fills …
11a pointer scene’s glow (glow.qml)a scene sized by its own root, as cursor.scene is (#213), keeps its root’s 48x48 on the 24-pixel buffer it is built on and is rebound onto 48, and its MultiEffect draws a glow beside its squarea scene sized by its root read …, or MultiEffect drew no glow beside the square on the GPU path
12the rounded-corner shaderit compiles in all three variants, and draws the middle intact, all four corners cut, four radii in the right quadrants and a zero radius squarethe rounded-corner shader did not compile, or a message naming the variant or corner
11bGPU timestamps (FX0)GL_EXT_disjoint_timer_query is found, both entry points load, and 200 fills of 1024² in one pass resolve within 20 polls of the timer’s idle, 10 ms apart, to 100 µs ≤ ns < 1 s: the two timestamps bracket the fills, which a region closed before them (about 1 µs) does not. Second half: ten passes stamped as the TTY stamps an output (open before a frame, close after its finish, then the frame’s fence waited on as the kernel waits on it before the flip, which flushes nothing) each resolve at the first idle 10 ms later, however slow the GPUGL_EXT_disjoint_timer_query is not usable here…, pass 1's GPU time did not resolve…, or passes […] were not resolved at the first idle…
11ca capture sampled with no CPU wait, through smithay (FX0)100 rounds of 500 fills into 1024², the fence dropped, sampled at once with render_texture_from_to: every pixel is the round’s colourround N: a capture sampled with no CPU wait read pixels it had not finished
11dthe same through raw GL, as the warp samples (FX0)the same, sampled with the raw texture name in a program of its own, no glWaitSyncround N: a capture sampled through raw GL …
11ethe warp program (FX0)warp/gl.rs’s program compiles and draws a texture through a full-target meshthe warp drew … at the centre, not the texture
11fa pooled target (FX0)pool.rs’s target is drawn through its own framebuffer object in a frame opened on a 1x1 carrier, and the carrier keeps its blue; painted again with nothing, the target reads back transparentthe pooled target is not what was drawn into it, a pooled target painted with nothing is not transparent, the carrier is no longer blue…
11gpartial damage on a warp (FX0)a translucent warp redrawn under one damage rectangle leaves the pixels outside it as they were… blended twice
11hthe clipped-surface programs (FX0)both compile in every variant; a root surface’s corners are cut, a subsurface is cut only where its corner is the client’s, a single-pixel buffer is cuta message naming the surface and the pixel
13the first rebinda scene built at 1x1 and never rendered rebinds and draws its new buffer righta scene rebound before it had ever rendered does not draw its new buffer
14build and freea scene built and freed without rendering takes none of the compositor’s GL objectsbuilding and freeing a scene without rendering destroyed …
15C-1a scene freed with the compositor’s context current takes none of the compositor’s GL objects, by a census of GL namesQt's teardown destroyed …

C-1 is the name the harness prints for case 15 (=== C-1: free a scene with the compositor's context current ===), and it is the name the controls below use.

What it asserts, and why each one is not obvious

The picture matches the software path, byte for byte. Not “looks right”: the same known image is uploaded through import_memory — which is top-down by definition and is what the software shell path uses — and drawn through identical element parameters. Comparing the two readbacks cancels whatever convention the offscreen target itself has, which is the only way to check orientation without asserting one. The gate runs it at scale 1 only; run it at WIRECHECK_SCALE=2 and 1.25 too after touching the element transform, because the element’s src is in device pixels and its size is logical, and a transform that is right at 1 can be wrong everywhere else.

Frames 2..N, not just the first. The first frame after a scene is built is safe under bugs the rest are not, because initialize() left Qt’s context current and nothing has taken it yet. Every one-frame probe this project has written passed while the second frame was broken.

A resize keeps the QML object tree. solium_qml_scene_rebind puts a scene on a new buffer without rebuilding it, and the cost of the alternative was never mainly the allocation: a rebuilt scene is a new object tree, so every animation, transition and stored property in it restarts from zero. A pane is sized from an animating rectangle, so that happened once per frame for the length of every window animation — an animation restarted every frame never advances. Asserted with a counter quadrants.qml holds and nothing outside the tree remembers, so a fresh tree hands back the declared default; and then with the picture at the new size, through the same reference comparison as above, because a rebind that returned true and left Qt on the old texture would carry the counter across perfectly.

And a running animation keeps running across it. The counter is a proxy and was always only a proxy: a tree that survived with every animation reset to its from: carries the counter across perfectly and is still the bug. So quadrants.qml also holds a NumberAnimation on spin, the harness advances the compositor’s clock with solium_qml_tick once per frame the way render::prepare does, and the case asserts spin is larger after the rebind than before it. Two instrument checks go with it, because a property that never moves reads the same number twice whichever way the rebind went: spin must be non-zero before the rebind (or nothing was animating and nothing was ticking), and WIRECHECK_STOP_THE_CLOCK below is its negative control.

Measured at scale 1: spin reads 80 after five ticked frames and 96 after the rebind and one more. Under WIRECHECK_REBUILD_ON_RESIZE it reads 80 -> 0, which is printed in that control’s own failure message.

The pointer and a pane layer, on a GPU host. The compositor’s real qml/cursor.qml and qml/panes/top/Frame.qml, built through solium_qml_scene_new_gpu and rendered. Before they moved to the GPU path, those two went down the software constructor, which a GPU host refuses outright — so SOLIUM_QML_GPU=1 gave a desktop with a wallpaper on it and no window frames and no pointer, each refusal logged by its own caller as its own unrelated failure.

The files themselves and not a stand-in, because what is in question is whether these come up under the RHI scene graph: cursor.qml draws through QtQuick.Shapes with the curve renderer and panes/top/Frame.qml puts glyphs through a text atlas, neither of which the four flat rectangles in quadrants.qml touch. The byte count cannot see the glyphs — they are drawn inside an opaque band — so what it measures is the band, and the glyph path is exercised rather than asserted.

A pane layer has to be dressed before it draws at all. A style’s insets live in its Pane.qml and are written onto each layer, so a Frame.qml built standalone has insetTop at 0 — a bar of no height, with the title centred in it and the hairline on top of it. Measured, that left 544 of 1228800 bytes non-zero against 81920 before the conversion, so the check went on passing while seeing almost nothing. It now writes insetTop and title first, which is what the compositor writes, and the bar and its glyphs come back.

What it cannot check is the picture — there is no reference for a titlebar here, and inventing one would assert today’s design system rather than the path — so it wipes the buffer through the compositor’s own renderer first and asks whether anything came back. That proves Qt built the component, brought up an RHI for it, imported our dmabuf and wrote into it. It proves nothing about what it drew.

It does not stand in for the defect on the Rust side. Nothing in this binary links the compositor crate, so which constructor cursor.rs calls is invisible here; what stops that regressing is that Scene::gpu_sized and Scene::with_properties are now private to qml.rs and Scene::for_host is the only way in from outside it.

And that the pointer is drawn at the size it was given. cursor.qml is built at 16, 24, 48 and 96, and what is measured is the bounding box of everything drawn in a buffer wiped transparent first. The arrow is laid out in a 24-unit square scaled to the item, so it fills the same fraction of the buffer at every size — 0.60 across by 0.83 down geometrically, 0.61–0.62 by 0.83–0.88 as read back, the top of that spread being the small buffers where an antialiased edge is a larger share of the box.

This is the one claim in #81 that nothing else in the gate could see, in either direction. cursor.rs sizes the scene to the configured cursor.size and host.cpp puts that logical size on the root item; while the arrow was drawn in literal units in a hard-coded 24-unit item it ignored both, so size = 48 padded the buffer around a 24-pixel arrow and XCURSOR_SIZE=16 cut the arrow’s tail off. cargo test cannot ask — the_configured_size_reaches_the_qml_pointer_scaled asserts the arithmetic Cursor::element does and nothing about what Qt then draws, so it passed through both — and neither can the byte count above, because a 24-pixel arrow in a 48-pixel buffer is exactly as many non-zero bytes as a 24-pixel arrow in a 24-pixel one.

The two sizes on the ends are what make it an instrument rather than a snapshot of today. Measured both ways in one process, against the pre-fix cursor.qml with nothing else in the tree changed:

16x16: (0, 0)..(14, 15) -- 0.94 across, 1.00 down   [clipped]
48x48: (0, 0)..(14, 20) -- 0.31 across, 0.44 down   [inert]

At 24 alone it passes, which is exactly how this shipped. The bounding box also has to start at (0, 0) at every size: cursor.rs places the buffer by subtracting HOTSPOT, which is (0, 0), so the arrow’s tip has to be in the corner or the pointer points a few pixels away from what it is over.

And that a pointer scene’s glow is drawn. cursor.scene (#213) is a scene sized by its own root, and MultiEffect is a shader, so it draws on this path and nothing on the software one; cargo test has no GPU and a nested run has no GBM device, so this is the only place it can be seen. glow.qml is a 48x48 root with a white 16-pixel square in the middle and a MultiEffect shadow of it. It is built on a 24-pixel buffer, as Cursor::configured builds a scene at cursor.size, where its root has to still read 48x48, and then rebound onto a 48-pixel one, as the first Gpu::sample rebinds it. What is read back is the alpha inside the square, 255, and four pixels beside it, which only the glow can reach: 27 on this machine. Measured with size_root in host.cpp writing every root, as it did before, the root reads 24x24 and the case fails.

And whether the host can say that a scene is animating — both ways. solium_qml_scene_animating, which is what render::Drawn gates the next frame on. It is a claim about Qt and not about anything in this repository: that a QQuickAbstractAnimation driven by a Behavior or a NumberAnimation on sets its own running property and clears it when it is done. cargo test cannot ask — asking needs a live Qt — and the compositor’s whole animation loop hangs on the answer, because Qt’s dirty flag means “something changed”, which a running animation does not do on every tick.

The two readings are taken in one run, in one process, against one driver, and they are this instrument’s negative control: no edited copy of anything is needed, because a stub cannot satisfy both. quadrants.qml carries an animation with loops: Animation.Infinite and is asserted to read 1, in the resize case, on the same line that already asserts spin has moved. cursor.qml and panes/top/Frame.qml are built and rendered in the scene case above and are asserted to read 0. The pane layer is written to — it draws nothing otherwise, see below — but only on insetTop and title, neither of which carries a Behavior. That is the sharper claim, and a Behavior added to either in Frame.qml would red this line.

Verified by stubbing solium_qml_scene_animating’s return in a control copy, both ways, with the harness otherwise untouched:

return 1;  ->  Error: `solium_qml_scene_animating` says the cursor scene
               (crates/solium/qml/cursor.qml) is animating, and nothing has
               written a property on it or declared an animation in it.
return 0;  ->  Error: `solium_qml_scene_animating` says nothing is animating in
               a scene whose `spin` just moved 80 units under an
               `Animation.Infinite`.

The cursor.qml reading is the one that matters most, and it is there to rule out a specific wrong answer rather than a hypothetical one. QAnimationDriver::isRunning() reads like the direct question and is not: a driver is one object for the whole process, and quadrants.qml has been animating since long before the scene case runs, so a process-wide answer is true on that line. This case fails on it. Before host.cpp kept a pause registered for the life of the process it was worse: QAnimationDriver::advanceAnimation ends in QUnifiedTimer::localRestart, which starts the driver again whenever it is not running and nothing is registered at all (qtbase v6.11.2, src/corelib/animation/qabstractanimation.cpp:333), so the answer was true for ever, and a compositor gated on it never slept again — measured then at 400 frames of 400, never idle, once anything else damaged the screen while an animation was finishing.

Neither assertion allocates a GL object, so the teardown control’s destroyed list is unmoved by them: re-measured after adding this case, C-1 still reports the same [('b', 1), ('f', 1), ('r', 1), ('b', 2), ('r', 2), ('b', 3)].

And whether an animation started from rest actually runs. The question both of the readings above are blind to, and the one the hardware was still failing after them: an animation advanced past its own end in a single tick satisfies dirty and solium_qml_scene_animating perfectly. dirty is raised, because the value did change; animating is true on the frame the Behavior starts and false a tick later, because the animation really has finished. The compositor then asks for exactly the frames it should, and every one of them shows the final value — a titlebar that does not slide out, it is simply there.

So this case reads the animated value itself. appear.qml beside this file is one property with a Behavior on it, sliding 34 units over 260ms on a linear curve, mirrored into an int so it can be read back out of the object tree. It is settled for forty frames, pointerInside is written the way Decoration::tell writes it, and the twenty readings that follow must include at least one strictly between the two ends. Not that it reaches its end — it does that either way, instantly, which is the bug.

It has to run first, before any other scene exists, and that is not a stylistic choice. Qt takes the driver’s elapsed() into its own clock only when the driver starts (QUnifiedTimer::startAnimationDriver, qtbase v6.11.2, src/corelib/animation/qabstractanimation.cpp:242-266), which is on the edge from nothing on the clock to something — host.cpp keeps a pause registered so that the driver stops at all — and quadrants.qml’s Animation.Infinite keeps the driver running from the moment it is built until the process exits. On any later line the edge never happens, every delta is 16ms, and this case passes with the defect fully present. The precondition, a stopped driver, is asserted through wirecheck_anything_animating rather than left to a comment, so a case added ahead of it fails loudly instead of quietly making this one vacuous.

It also has to settle for long enough that the clock passes the animation’s own duration. A harness whose clock starts at zero cannot see this defect at all, because the bad delta is the clock: the forty settling frames put it at 640ms, and a desktop where somebody hovers a window has been up for minutes.

The clock control — put the driver back to reporting the compositor’s uptime, which is a two-line diff and touches nothing else:

cd dev/wirecheck
sed -e 's|return m_elapsed - m_origin;|return m_elapsed; /* control */|' \
    -e 's|^            m_origin = elapsed;$|            /* control */ (void) anything_animating;|' \
    ../../crates/solium/qml/host.cpp > host-control-clock.cpp

WIRECHECK_HOST_CPP="$PWD/host-control-clock.cpp" cargo build --target-dir target-control-clock
./target-control-clock/debug/wirecheck   # must fail
  slid, frame by frame: [-34, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
  readings strictly between -34 and 0: 0
Error: the appear animation never took a step: ... from -34 to 0 with nothing
in between, over 20 frames of a 260ms animation.

against the shipped file’s

  slid, frame by frame: [-34, -30, -28, -26, -24, -21, -19, -17, -15, -13, -11, -9, -7, -5, -3, -1, 0, 0, 0, 0]
  readings strictly between -34 and 0: 15

Both runs print scene animating = true on the frame that writes the property. That is the point of printing it: the reading the previous case exists for separates nothing here, and neither does dirty. The control’s scene is running on that frame and settled one tick later, which is exactly what a healthy animation that has just finished looks like — because it has.

The scene is kept alive rather than freed, like the two in the scene case below and for the same reason — a free here would reach C-1’s defect in C-1’s own ordering, ahead of C-1’s census.

The pointer’s route: the dmabuf read back and uploaded as memory. The one scene that does not reach the screen as a texture. smithay reaches a DRM cursor plane only through RenderElement::underlying_storage, whose two variants are Wayland and Memory (renderer/element/mod.rs:103-109); a TextureRenderElement implements none and inherits None, so copy_element_to_cursor_bo gives up on its first line. And nothing catches it underneath: smithay’s pixman fallback is behind #[cfg(feature = "renderer_pixman")], which is not in the compositor’s feature list (crates/solium/Cargo.toml:24-43), so the arm that compiles is the #[cfg(not(...))] one at drm/compositor/mod.rs:3244 and its failure branch is a plain return None. A GPU pointer drawn as a texture loses the plane silently, and every pointer motion on a TTY becomes a full composite and page flip. So cursor.rs lets Qt draw into the dmabuf and then reads it straight back into a MemoryRenderBuffer.

That hangs on a claim nobody had checked: what copy_framebuffer hands back is byte-identical to what import_memory is given on the software path. Three conventions meet there — Qt’s render target, which mirror_for_the_compositor flips; smithay’s readback; and MemoryRenderBuffer’s top-down rows — and two of them cancelling is not the same as all three agreeing. Checked against the known picture directly and then through an element built from it.

It sits before the frame loop, deliberately, and reads the first frame: that is the one frame every configuration of this harness draws correctly, so this case fails only on its own defect. Reading a buffer the frame loop had already found wrong would make it fail on the loop’s defect and report it as a readback fault — and an all-zero buffer, which is what the render control leaves, is called out separately and sent back to the frame comparison rather than described as a flipped or swizzled one.

It also prints what the round trip costs, because the whole argument for the route is that the pointer pays it once per size per change rather than per frame. The cost is ~65 µs fixed plus 3.5–4 ns per pixel on this machine — fitted over a sweep rather than read off a pair, since one run at two sizes cannot separate the two terms:

sizepixelseach
24x2457666.9 µs
48x48230469.1 µs
96x969216110.8 µs
192x19236864192.3 µs
384x384147456605.8 µs

The pixel term is negligible at cursor sizes and only at cursor sizes. At 24x24 it is ~2 µs of ~67, three per cent, so what the pointer pays really is the round trip — and the Kept cache in cursor.rs makes even that once per size per change. Read that scoped, because it expires immediately above the pointer: at 384x384 the pixel term alone is ~540 µs and the whole call is 606, nine times what the cursor pays for its entire readback. “It is the round trip, not the pixels” is a claim about 24- and 48-pixel squares, and is not a reason to trim the cache.

Set WIRECHECK_LOGICAL and WIRECHECK_SCALE to move the size this reports, and re-run it several times at one size before comparing two: the spread within a single size is wider than the gap between 24 and 48. Seven runs each gave medians of 68.0 µs at 24x24 and 67.1 at 48x48, with 24x24 spanning 60.2–72.1 and 48x48 spanning 65.6–80.2. This file previously reported a single run in which 24x24 (63.5 µs) came out slower than 48x48 (59.8 µs) and treated that inversion as evidence; it was noise, and at n=1 there was nothing there to read.

If the fixed term were ever milliseconds, the cache would not be enough and the design would need revisiting — which is why the number is printed rather than remembered.

The first rebind, on a scene that has never rendered. The shape production is actually in, and the one the resize case above cannot reach — it renders five frames first. Nothing on screen does: ShellSurface::new builds at 1x1 with its size recorded as (0, 0), and a Decoration is built at the client’s size and has to end up on the outer rect at the monitor’s scale. Both take the rebind branch on their very first frame, with QQuickRenderControl::initialize() the only thing that has ever made the scene’s context current — so take_the_thread is working from what the build recorded, and the thread-local clear_stale_current_context reasons about is either null or another scene’s. Checked with the same reference comparison as the resize case, because a rebind that returned true and left Qt on the 1x1 texture would sail past a non-zero check.

Neither of those two cases frees its scenes. That is deliberate: a free with the compositor’s context current is C-1’s own ordering, so freeing here would reach that defect before C-1’s census is taken — and under the teardown control with WIRECHECK_KEEP_RESIZED_SCENE set it would stop the run in the resize case’s place, putting C-1 back to only ever executing in the passing state. Nothing is leaked past the run; Qt keeps its own reference to each buffer and the process is about to exit. Verified: with those cases in place, the teardown control still reaches C-1 and C-1 still reports the identical five objects.

That byte comparison is not a second check on the render path, and it is worth knowing before anyone trims the frame loop on the strength of it. Measured under the render control: the resize case reports 0 of 65536 bytes differ and passes, three runs of three, while the frame loop above it reports frame 2: 10240 of 16384 bytes differ and fails the run. It cannot see that defect and it is right not to — release_the_thread at the end of a rebind leaves the thread empty, so the beginFrame after one correctly takes it whether or not solium_qml_scene_render_gpu clears a stale belief first. The frame loop is still the only thing that catches it.

A scene built and freed without ever rendering. The path that reaches clear_stale_current_context’s null-thread-local early return. It used to be the hot path by accident, when every resize built one scene and freed another; now that a resize does neither, this case is the only thing that takes it on the free side.

A scene freed with the compositor’s context current — the ordering the rest of the harness cannot produce, since building the replacement first leaves the new scene’s context current and Qt then does the right thing by accident.

That last one is checked with a census of live GL object names (glIsTexture/glIsBuffer/glIsFramebuffer/glIsProgram/glIsRenderbuffer over 1..64) in the compositor’s context, taken before and after the free and diffed. A third census is taken before Qt is started at all, so anything destroyed that appears in it is unambiguously the compositor’s.

The census is the assertion and it was not the first thing tried. Guard textures drawn and compared across the free once passed with the bug present and absent, because Qt’s deferred releases for that scene were buffers and a framebuffer and a texture-only check cannot see a glDeleteBuffers.

Do not read them as an expectation in either direction. Whether a guard is visibly clobbered depends on Qt’s deferred deletes landing on the integers the compositor’s own textures happen to have been given, and that collision is not stable run to run. The census is: it has been identical in every run of every configuration anyone has logged here.

The frequency, since a number is what stops the guessing. Sample: 30 consecutive runs on this machine of the one configuration this file gives an Expect for (WIRECHECK_KEEP_RESIZED_SCENE=1, below). The guards fired in 5 of 30, and every one of those five fired completely — => 0 of 8 compositor textures survived, (8 of 8 guard textures visibly clobbered). The other 25 read => 8 of 8 compositor textures survived and (0 of 8 …). All 30 failed on the census, with the identical five objects and the identical attribution to [('b', 1), ('b', 2)].

Quote the sample size alongside the number, because this number moves. This file previously reported 0 of 8 in 30 of 30 here, and 6 of 8 when they fire elsewhere, and neither reproduces: the magnitude measured now is all-or-nothing. That is not a regression, it is the paragraph above being true. What Qt’s deferred deletes destroy depends on which integers the compositor’s own objects happen to have by the time the free runs, and every case added ahead of C-1 since those runs shifts exactly that. Expect the next case added here to move it again — re-measure rather than reasoning from this paragraph.

So neither reading is a finding. A control run reporting 8 of 8 compositor textures survived has not stopped working, and one reporting 0 of 8 compositor textures survived has not found a new bug — it is the same defect, caught by an instrument that only sometimes has anything to catch it with. Check the census. The guards illustrate what the damage means; the census is the proof that it happened.

The free-path case also asserts its own precondition, through wirecheck_belief_names_scene: Qt’s thread-local must name this scene’s context and EGL must disagree. Neither half alone is enough. A null belief is safe with or without the fix, and so is a belief naming a different live scene, because ensureContext() compares it against its own ctx and corrects. The first version of that guard asked only whether some belief existed and passed with the fix reverted.

Negative controls

A harness that passes both ways proves nothing, and this one has been in that state five times: four blind instruments, and once a control that could not observe the thing it named. A sixth was a different shape and is worth knowing about separately — the instrument was truthful and this file overclaimed it, so a documented expectation that reproduced about one run in six taught the reader to distrust a working control. Hence the frequencies quoted below rather than whichever result the last run happened to give.

clear_stale_current_context has two call sites and each guards a different defect, so there are two controls there and both are worth running after any change here.

Keep the copies inside the repository. The build runs in the container, which mounts $HOME and nothing else, so a control under /tmp compiles to fatal error: no such file. Both filenames and both target directories are gitignored.

The teardown control — drop the call in solium_qml_scene_free, leaving the one in solium_qml_scene_render_gpu:

cd dev/wirecheck
awk '/^extern "C" void solium_qml_scene_free/ {f=1}
     /^extern "C" void solium_qml_scene_resize/ {f=0}
     f && /clear_stale_current_context\(scene\);/ {print "    /* control */"; next}
     {print}' ../../crates/solium/qml/host.cpp > host-control.cpp

WIRECHECK_HOST_CPP="$PWD/host-control.cpp" cargo build --target-dir target-control
./target-control/debug/wirecheck        # must fail

Expect DESTROYED in our context by freeing the resized scene: [('b', 1), ('f', 1), ('r', 1), ('b', 2), ('r', 2), ('b', 3)], of which buffers 1 and 2 appear in the pre-Qt census. Measured identical in 5 of 5 runs.

That list was seven objects until the pointer’s readback case was added above it — ('f', 2) is no longer among them — and the reason is worth knowing rather than being surprised by later. What Qt’s deferred deletes destroy depends on which integers the compositor’s own objects happen to have been given by the time the free runs, and any case added before this one shifts that. So the list is illustrative and the emptiness is the assertion: the harness fails on !lost.is_empty(), not on a particular set. Re-measure it after adding a case here rather than treating a changed list as a finding. The pre-Qt census below has not moved.

Two of them and not three, and that distinction is the whole value of the line. The pre-Qt census is [('b', 1), ('b', 2), ('p', 3), ('p', 4), ('p', 5), ('p', 6), ('p', 7), ('p', 8), ('p', 9)] — invariant across 25 logged runs of every configuration. ('p', 3) is a program named 3, and programs and buffers are separate GL namespaces, so there is no buffer 3 for Qt to have destroyed: ('b', 3) is Qt’s own.

That comparison is yours to make in this run, and the two lines to make it from are both printed: the pre-Qt census above, and the DESTROYED … list. This run does not attribute them for you. It ends:

  DESTROYED in our context by freeing the resized scene: [('b', 1), ('f', 1), ('r', 1), ('b', 2), ('r', 2), ('b', 3)]
Error: freeing the resized scene destroyed 6 of the compositor's GL objects

and that is the whole of it. The attributed line — ...of which existed before Qt was started, so are certainly ours — is printed at src/main.rs:1603, which is inside C-1, and this run never reaches C-1. Do not go looking for it here; read it off the WIRECHECK_KEEP_RESIZED_SCENE=1 run below, which does print it, against the same unchanged pre-Qt census.

That is the resize case failing, not C-1: the resized scene is freed in exactly C-1’s ordering, so it reaches the same defect first and the run stops there — C-1 was not reached in any of those 6 runs. Run the control a second time with the free skipped, so C-1 executes its own instrument:

WIRECHECK_KEEP_RESIZED_SCENE=1 ./target-control/debug/wirecheck   # must fail

Expect DESTROYED by Qt's teardown, in our context: [('b', 1), ('f', 1), ('r', 1), ('b', 2), ('f', 2)] and ...of which existed before Qt was started, so are certainly ours: [('b', 1), ('b', 2)] — identical in 30 of 30 runs on this machine. The guard line is not part of the expectation: across those same 30 it read 8 of 8 compositor textures survived in 25 and 0 of 8 in 5, with nothing in between, and see the guards paragraph above for why either is neither surprising nor a problem.

Both runs matter and they check different things: the first that the defect is caught, the second that C-1’s own census, precondition assertion and guard textures still execute at all. Without the knob they only ever run in the passing state, where a regression in the instrument itself would be invisible.

Two things measurement settled against what was written here first, both of which had been reasoned rather than run:

  • C-1 does not depend on the resize case’s census to protect its baseline. With that free skipped, C-1’s diff is the full five objects rather than a clean one. The resize case’s census earns its place by catching the damage where it happens, not by shielding C-1.
  • “Most of the guard textures clobbered outright” was right that the guards are unreliable and has now been wrong twice about the magnitude — first “most of them”, then “6 of 8”. Measured here across 30 runs of this configuration: they fire in 5, and when they fire it is all eight. The reliability half is the durable part; the magnitude is whatever this build’s GL name allocation happens to produce.

The render control — drop the call in solium_qml_scene_render_gpu instead:

awk '/^extern "C" int solium_qml_scene_render_gpu/ {f=1}
     f && /clear_stale_current_context\(scene\);/ {print "    /* control */"; f=0; next}
     {print}' ../../crates/solium/qml/host.cpp > host-control-render.cpp

WIRECHECK_HOST_CPP="$PWD/host-control-render.cpp" cargo build --target-dir target-control-render
./target-control-render/debug/wirecheck  # must fail

Expect frame 2: 10240 of 16384 bytes differ from the reference, 10240 being the reference’s exact non-zero byte count — Qt issues the frame against the compositor’s context and writes nothing, so what is read back is the wipe.

That line is the expectation, not the exit: the frame loop only records, and worst is checked at the very end of the run.

It used to stop well before that, at the resize case, with the resize left GL error 0x501 in the compositor's context — and that was the harness’s fault, not the control’s. glGetError pops one error per call, and none of the four probes drained before the operation they were probing, so an error generated hundreds of lines earlier was reported against the rebind and the reader was sent to code that is fine. Each probed operation is now bracketed: drain_gl_errors empties the queue immediately before it and prints anything it found as not the operation’s, and the probe reads once immediately after. Under this control the run now prints GL error(s) ["0x501"] already pending BEFORE the rebind and glGetError after it: 0x0, and goes on to fail where it should, on the GPU path does not match the software path.

Dormant on this machine in the passing configuration — 3 of 3 runs drain nothing — so this is an instrument that was wrong rather than one that was failing.

That control only works because of the wipe. quadrants.qml paints an unchanging picture, so before the wipe existed the dmabuf still held frame 1’s identical pixels and the comparison read zero: “Qt did not write” and “Qt wrote the same thing” were the same measurement, and this control exited 0 with the defect present. Do not remove the wipe, and do not make the probe scene static in a way that survives it.

Verify the substitution reached the binary

Two different questions, and for a long time only the first was asked here:

# 1. Was the control written? Exactly one changed line each.
diff ../../crates/solium/qml/host.cpp host-control.cpp
diff ../../crates/solium/qml/host.cpp host-control-render.cpp

# 2. Was the binary built from it? The build must be NEWER than the file.
for c in control control-render; do
    if [ "target-$c/debug/wirecheck" -nt "host-$c.cpp" ]; then
        echo "$c: binary is newer than the control it was built from"
    else
        echo "$c: STALE — this binary predates its own control"
    fi
done

The second check is not pedantry, it is a bug this harness actually had. build.rs emitted rerun-if-changed for host.cpp and rerun-if-env-changed for WIRECHECK_HOST_CPP, but nothing for the file that variable points at. So editing a control copy while host.cpp was untouched changed neither, cargo never re-ran the build script, and the stale object file was linked.

Measured on this machine, with that line missing: host-control-render.cpp was overwritten with a byte-identical copy of host.cpp — no control in it at all — and rebuilt with the same env var and target directory. The build reported Finished ... in 0.04s and compiled nothing, diff then declared the two files identical, and the binary went on failing with frame 2: 10240 of 16384 bytes differ — biting on a control that was no longer in its source. With the rerun-if-changed for host restored, the identical sequence recompiled (Compiling wirecheck, 6.5s) and the run came back EXIT=0, frame 2: 0 of 16384 bytes differ, which is the correct answer for a binary with no control in it.

strings on the binary cannot substitute for this: the awk replaces a call with a comment, and neither is a string literal, so nothing distinguishes the two binaries by content. The timestamp is what answers “was this built from what is on disk now”.

The animation control needs no copy of anything either. WIRECHECK_STOP_THE_CLOCK=1 leaves the rebind alone and simply stops ticking from the rebind onward, so the tree is kept, the counter still counts, and the animation assertion has to fail on its own:

WIRECHECK_STOP_THE_CLOCK=1 ./target/debug/wirecheck   # must fail

Expect `spin` went 80 -> 80 across a rebind and a frame the clock was deliberately stopped for, with the counter reads 6 on the line above it — the counter passing is half the point, since it is what shows the two instruments are independent. The message is a different one from the unticked case’s on purpose: saying “across a ticked frame” under a control whose whole content is that the frame was not ticked is the harness lying about its own run.

The resize control needs no copy of anything, because what it inverts is a choice and not a line of C++. WIRECHECK_REBUILD_ON_RESIZE=1 answers the resize the way render_on_gpu used to — a new scene on the new buffer, the old one freed after it exists — instead of calling solium_qml_scene_rebind:

WIRECHECK_REBUILD_ON_RESIZE=1 ./target/debug/wirecheck   # must fail

Expect the QML tree was rebuilt by a resize: frames went 5 -> 1. Run it at more than one scale; it has been checked at 1, 2 and 1.25.

It is a knob here rather than a revert because nothing in this binary links the compositor crate. The choice between rebinding and rebuilding is made in Gpu::render, in crates/solium/src/qml/paint.rs: one comparison of the size the scene is bound at against the size asked for, then rebind_sized on a new buffer. Changing it there changes nothing this runs.

The fence control. WIRECHECK_ONLY=fx0 WIRECHECK_FX0_CROSS=1 reads the same draw from an independent EGL display with no wait, and says in how many of 100 rounds it saw an unfinished picture: the race 11c and 11d would see if GL’s one-context order did not hold. The readback prints as it goes, and its lines and the verdict can interleave in a pipe, so read the verdict with grep -o 'a reader on another display saw.*rounds'. Measured on NVIDIA: 0 of 100. Each round’s reader brings up a display of its own before it reads (about 50 ms, against about 4 ms for the 500 fills), so the draw is done before the read starts, and 11c and 11d are regression guards rather than cases this control has shown can fail.

Knobs

argv[1]render node, default /dev/dri/renderD128; honoured by the independent readback too, which used to hardcode it. dev/gate.sh passes the first /dev/dri/renderD* the machine has
WIRECHECK_SCALE, WIRECHECK_LOGICALthe scale and logical size to run at, default 1 and 64. The gate sets neither
WIRECHECK_FRAMESthe number of the last frame the frame loop draws, default 3: frames 2 and 3 are compared after the first, and 1 compares none
WIRECHECK_QMLthe scene the first frame, the frame loop, the resize, the first rebind, build-and-free and C-1 use, default quadrants.qml beside this file. Every one of them compares against the quadrants picture, so another file fails them by design
WIRECHECK_HOST_CPPa different host.cpp, for the controls above; host-control-clock.cpp beside this file is the appear case’s
WIRECHECK_REBUILD_ON_RESIZErebuild the scene on a resize instead of rebinding it — the resize control above
WIRECHECK_STOP_THE_CLOCKstop ticking from the rebind onward — the animation control above
WIRECHECK_KEEP_RESIZED_SCENEdo not free the resized scene, so the teardown control reaches C-1
WIRECHECK_ONLY=fx0runs only the FX0 cases (11b onwards), with no Qt started; that is how the Surface Pro 7 runs them from a copied binary
WIRECHECK_FX0_CROSSwith WIRECHECK_ONLY=fx0, also runs the fence control above
WIRECHECK_RESTORE_EARLY=0skip the restore after scene_new_gpu
WIRECHECK_NO_RESTOREskip both restores of the compositor’s context around the first scene’s build and first render, the early one included. Every restore after that still runs
WIRECHECK_LATE_RENDERER, WIRECHECK_SEPARATE_GBMbuild the renderer after Qt, or on its own device
WIRECHECK_RENDERER_NODEthe node the renderer’s own device comes from, with WIRECHECK_SEPARATE_GBM; defaults to argv[1]

The last four exist because each was once a hypothesis for a failure that turned out to be something else, and re-testing them is cheaper than arguing about them again.