Skip to content

test: make rendering testable from master - #2212

Open
jcelerier wants to merge 25 commits into
masterfrom
test/infra
Open

test: make rendering testable from master#2212
jcelerier wants to merge 25 commits into
masterfrom
test/infra

Conversation

@jcelerier

Copy link
Copy Markdown
Member

Groundwork so that rendering can be tested at all, on master, ahead of the gfx PR stack.

Today no pixel test can run below #2120: WindowDevice::grabTo and the golden-render/ harness enter at pland/scene, OffscreenDevice at planc/gfx-infra. That makes 47 commits of the stack build-only, and a rendering regression in #2160 or #2119 impossible to bisect. This PR puts the minimum on master.

Silent failures fixed — each of these turned a broken run into a green one:

  • js: report --script failures instead of exiting 0 — a script that threw ran to completion and exited 0. Every result it produced looked like a success. This is what hid the three bugs below for a full session.
  • app: keep the platform the caller asked for when there is no displaymain.cpp overwrote an explicit QT_QPA_PLATFORM with eglfs whenever no X11/Wayland display was found.
  • app: treat offscreen as a rendering session, like eglfs and vkkhrdisplayrunningUnderAnUISession() did not list offscreen, so --no-gui under it took the no-UI path. --no-gui means "do not open score's main widget UI", not "do not render".

Vulkan under a null platform integrationQVulkanInstancePrivate::ensureVulkan() dereferences QGuiApplicationPrivate::platform_integration with no null check, so building a Vulkan QRhi before/without a platform integration segfaults (confirmed under gdb: mov (%rdi),%rax, rdi=0).

  • gfx: do not return a Vulkan instance when creation failed
  • gfx: don't create a Vulkan QRhi without an instance
  • gfx: don't return a half-initialized render state when QRhi::create fails

Test entry points

  • gfx: make headless frame capture available from mastergrabTo / grabFrame on the Window device.
  • gfx: render a fixed number of frames on demandGfxContext::renderFrames(int), the hook a deterministic frame clock needs.

Known gap, tracked separately: the window path of grabTo uses screen()->grabWindow(), which on X11 reads the framebuffer at the window's geometry rather than the window's own buffer — it captures whatever is on top. Golden images must come from the offscreen readback instead. That is why OffscreenDevice is here.

🤖 Generated with Claude Code

https://claude.ai/code/session_014rZgzE8JjWvHDtaVUhxpLE

@jcelerier
jcelerier force-pushed the test/infra branch 4 times, most recently from 390717e to 458f2b6 Compare August 16, 2026 18:43
jcelerier and others added 20 commits August 16, 2026 22:46
GCC 16.2 segfaults emitting CodeView type records for any boost::container
container, and this codebase uses those in around thirty translation units, so
the flag makes a gcc Debug or RelWithDebInfo build impossible to finish. A
two-line file reproduces it:

  #include <boost/container/vector.hpp>
  void f() { boost::container::vector<int> v; (void)v; }

  REPRO.cpp:2:54: internal compiler error: Segmentation fault

The flag on its own is enough; DWARF is fine, and so is clang, which is the
compiler we actually want CodeView from on Windows. gcc keeps its normal DWARF
debug info.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014rZgzE8JjWvHDtaVUhxpLE
Those two objects now live in ossia/score-addon-synthimi (18ec263). Master
already dropped the CMake half that built them out of the avendish tree, so all
that is left here is to carry the submodule to the commit where the sources
themselves go away.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
--script took the raw option value and evaluated it, so passing a file path
evaluated the path itself as a program, and the result of evaluate() was
discarded, so the syntax error that produced went nowhere. Any throw in a script
behaved the same way. Combined with readFile returning an empty string for a
path that does not resolve -- and eval("") being a no-op -- a script whose
include path was wrong ran to completion and reported success.

That is how a wrong <LIBRARY>: prefix broke 122 tester scripts while every run
exited 0. A harness cannot tell a pass from a silent failure, which makes every
green result unfalsifiable.

A path is now read as a file, an unreadable one exits 2, an error in the script
is printed with its name and line and exits 3, and readFile says which path it
could not read -- showing the resolved form when a prefix changed it.

locateFilePath gains an overload for callers with no document: a --script file
is read while the application is still starting up, and <LIBRARY>: is a settings
lookup that does not need one.
WindowDevice::grabTo and the offscreen device were written for the scene rework
and only exist above pland/scene, so no pixel test can run against the 47
commits below it -- which is exactly where the pixel-format and caching work
lives, and where a rendering regression would be hardest to find later.

Nothing here depends on that rework: BackgroundNode already exposes
shared_readback on master, and grabTo just reads it. The window path reuses the
existing window() accessor rather than reaching through the device.

grabTo now says why it produced nothing -- not rendered yet, short readback,
unwritable path -- because a silently missing PNG is indistinguishable from a
test that did not run.

Also brings in the golden-render harness, which is scripts only; it reports
NORENDER until something drives a render.
Rendering is driven by wall-clock timers, so "how many frames have been drawn"
depends on how long the caller waited and how fast the machine is. The tester
harness sleeps five seconds and then grabs, which means a slow machine grabs a
different frame than a fast one, and an animated shader gives a different image
every run. References cannot be stored against that.

renderFrames(n) steps the render side exactly n times and returns, so frame N
is the same frame everywhere. grabFrame(n, path) is the pair of it, replacing
sleep-then-grab.

This covers the render side only. Whatever a node derives from execution time
still comes from the execution clock, so a graph that depends on it is only as
reproducible as that clock -- which is what frame-determinism.sh measures:
same case, same frame, two processes, byte-identical PNGs or it has not been
achieved.
With neither DISPLAY nor WAYLAND_DISPLAY set, QT_QPA_PLATFORM was overwritten
with eglfs unconditionally -- including when the caller had explicitly asked
for something else. has_platform was computed for this and only consulted in
the xcb branch further down, so a headless machine could not select offscreen:
eglfs found no display device, and Qt then qFatal()s on the first window for
having no screens.

frame-determinism.sh also now rejects a frame that is a single flat colour.
Two blank frames are byte-identical, so without that check the easiest way to
pass a determinism test is to render nothing.
staticVulkanInstance() could hand back an instance whose create() had failed;
return null instead so callers fall back cleanly rather than crashing.

Moved down from split/gfx-fixes-and-formats (35eb25e, itself folding
6b0b345 and 25d183b) so the Vulkan hardening sits once, at the bottom of
the stack, where headless configurations reach it. #2160 keeps the identical
patch; rebasing it onto this branch drops the duplicate by patch-id.

Note this covers create() failing, not the probe above it: supportedApiVersion()
is called eighteen lines earlier and still crashes under a platform with no
Vulkan support. That is a separate defect, unfixed here.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
runningUnderAnUISession() decides whether score builds a QGuiApplication or a
QCoreApplication. It already recognises eglfs, vkkhrdisplay, linuxfb and vnc as
platforms that render without DISPLAY -- but not offscreen, which is equally
real: it draws and reads back, it just never shows anything.

So on a machine with no DISPLAY and XDG_SESSION_TYPE=tty, QT_QPA_PLATFORM=
offscreen fell through to QCoreApplication. QGuiApplicationPrivate::
platform_integration is then never assigned, and the first gfx render segfaults
in QVulkanInstancePrivate::ensureVulkan(), which loads that global and
dereferences it with no check.

Confirmed under gdb: the faulting instruction is `mov (%rdi),%rax` with
rdi = platform_integration = 0, while QCoreApplication::self is non-null -- so
the application object exists and the windowing layer does not.

The crash presents three layers below the cause, and every guard nearer the
crash would have hidden it by disabling GPU rendering under offscreen instead.
When the platform plugin cannot provide a Vulkan instance (offscreen QPA,
headless CI), score::gfx::staticVulkanInstance() returns nullptr and the
Vulkan branch handed it straight to QRhi::create, which dereferences it in
QVulkanInstance::isValid() and segfaults. Bail out to the null-rhi state
instead, like the other unavailable-backend paths.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014rZgzE8JjWvHDtaVUhxpLE
Score.readFile has resolved <LIBRARY>: and <PROJECT>: since the file-path
helper landed, but createProcess hands its third argument straight to the
process factory, and the processes that take a file open it themselves without
knowing about the prefixes.

So a script that builds a shader process from "<LIBRARY>:/packages/.../x.fs"
got a process whose Fragment was empty, and the only symptom was an "ISF error:
Missing start comment" further down the log -- the parser had been handed
nothing. Every document the csf-testers corpus generated was empty this way:
one process, no shader, and a Window outlet addressed at a device that
therefore had nothing to render.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014rZgzE8JjWvHDtaVUhxpLE
SCORE_FORCE_OFFSCREEN_WINDOW=Name1,Name2 sends a matching WindowDevice down the
headless offscreen path whatever its Single/Background/MultiWindow mode, so
grabTo reads the render target back instead of grabbing a platform window.

The offscreen device has been in the tree since headless capture landed, but
nothing selected it, which left every grab on the window branch. That branch
calls QScreen::grabWindow, which reads the framebuffer at the window's geometry
rather than the window's own buffer: it captures whatever is on top. Measured
here, two runs of the same tester at the same frame returned 1280x720 images of
the screensaver. It now says so in the log, because a screenshot of a quiet
desktop is byte-stable and will otherwise pass a comparison against a stored
reference.

Also split the empty-readback warning in two. BackgroundNode::render clears the
readback when its render list holds only the output itself, which surfaced as
"readback is 0 bytes for -1 x -1" -- a default-constructed QSize, not a size
anything computed. That case means nothing is connected to the device, and now
reports it.

Recovered from 52aa6e6 on the pre-reorganisation split/gpu-interop line; the
WindowDevice half of that commit never reached master or the current stack.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014rZgzE8JjWvHDtaVUhxpLE
renderFrames() drew N frames but never touched time, so every time-ish uniform
kept whatever the transport last delivered. Frame N was reproducible only for a
shader that ignores time; anything animated gave a different picture each run,
which is the thing a stored reference cannot tolerate.

Each step now hands every process node a token of frame / stepRate(), after
updateGraph so it wins over the date the transport last sent. TIME, TIMEDELTA
and PROGRESS follow from it, the same stepping the shader sweeps have used all
along.

The rest of the audit the reference images depend on: FRAMEINDEX is already
incremented by the renderers per render, so it counts renders rather than
seconds, and ProcessUBO::date is never written anywhere in the plugin, so it
stays at its zero initialiser. No wall clock is left on this path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014rZgzE8JjWvHDtaVUhxpLE
The gate passed twice on screenshots of my desktop. It checked that the frame
was not a single flat colour, which a desktop is not, and that two runs matched,
which two screenshots of a quiet desktop do.

Four changes, each closing one way it could pass without rendering:

- SCORE_FORCE_OFFSCREEN_WINDOW=Window, so the grab reads the render target.
- fail on the "capturing the SCREEN" warning, so the window fallback can never
  quietly supply the reference again.
- fail on "nothing rendered into", which is what an unwired Window device says.
- Score.play() in the generated script: the gfx nodes only exist while the score
  executes, so a grab on a stopped document has nothing to read.

The default case is now isf-time-uniforms rather than isf-solid-color. It draws
TIME, TIMEDELTA, PROGRESS and FRAMEINDEX as bars, so it fails if any of them
still follows a wall clock; a static case passes whether or not the step clock
works, which is exactly the reassurance that was wrong before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014rZgzE8JjWvHDtaVUhxpLE
Qt.exit(code) already shut the app down cleanly -- the handler marks the
documents saved first, which is what keeps a modified document from popping a
save prompt on the way out -- but it then called quit(), i.e. exit(0), and threw
the code away. A script could stop the app and could not say whether the run had
succeeded.

Harnesses had to infer the verdict from side effects, or wrap every run in a
timeout and read the kill as normal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014rZgzE8JjWvHDtaVUhxpLE
setStepRate(fps) on the Window device, next to renderFrames, so a harness can
step at the rate the material wants instead of the built-in 60.

renderFrames already supported frame-by-frame use -- the step counter carries
across calls, so renderFrames(1) in a loop walks the same timeline
renderFrames(60) does. That is the more useful shape for a harness: it can grab
several frames, or read and set controls between them, without paying process
startup for each one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014rZgzE8JjWvHDtaVUhxpLE
The runs ended by being killed: --wait defers autoplay so that slow media have
time to load, it is not an exit timer, and nothing ever asked the app to quit.
Each of the two runs therefore sat until the 120s guard fired.

Qt.exit(0) at the end of the generated script ends the run when the work is
done. The pair now takes 2.0s rather than about 240s, the timeout goes back to
being a hang guard, and a nonzero exit is a real failure rather than the
expected outcome.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014rZgzE8JjWvHDtaVUhxpLE
One process per case, so a case that crashes or hangs takes only itself down,
and each tester builds its document from a clean application either way.

Verdicts are separated by what actually happened rather than lumped into
pass/fail: RENDER, BLANK, NORENDER, SCREEN, FAIL. SCREEN exists because a grab
that falls back to capturing the desktop is not a weaker result, it is a
meaningless one, and the sweep exits nonzero if any case produced one.

First run, 130 cases: 81 render on the stack against 60 on master, the
difference being shaders whose ISF features only exist above #2120 -- the
measured form of the claim that no pixel test can run below it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014rZgzE8JjWvHDtaVUhxpLE
At 450 the draw-parameter builtins only exist under their ARB names --
gl_BaseInstanceARB and friends -- because GL_ARB_shader_draw_parameters spells
them that way; the unsuffixed gl_BaseInstance is core from 460. A shader author
writing the name that appears in every reference gets "undeclared identifier",
and since a shader that fails to compile yields a process with no ports, the
error surfaces much later as a null port.

Validated on both Qt versions we build against, whole corpus of 130 testers:
- Qt 6.4.2:  RENDER 64  BLANK 32  NORENDER 34  CRASH 0
- Qt 6.12.0: RENDER 64  BLANK 32  NORENDER 34  CRASH 0
byte-identical, and no case changed verdict against the 450 build. The frame
determinism gate still passes. On the gfx stack, where the shaders that use
these builtins actually compile, scene-mdi renders with the unsuffixed spelling.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014rZgzE8JjWvHDtaVUhxpLE
One process per case already contains a crash -- the sweep carries on and only
that case is lost -- but the summary counted it with ordinary script failures, so
a SCORE_ASSERT firing in render-target creation looked like a bad tester.

A signal now reports as CRASH with the signal number and the first file:line in
the trace, which is enough to tell two different aborts apart in the table.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014rZgzE8JjWvHDtaVUhxpLE
@coveralls

Copy link
Copy Markdown

Coverage Status

Coverage is 19.411%test/infra into master. No base build found for master.

jcelerier and others added 4 commits August 17, 2026 09:13
Catch2 exits with 4 when every case in a binary was skipped. ctest did not know
that, so a test that skipped because its precondition is absent -- no display, no
shader library, no capture device -- was counted as a failure. The three shader
sweeps have been reported as failing on every commit of this branch and every
commit above it while in fact never running once, which is worse than a silent
skip: it hides the gap behind a number that looks like a known problem.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
score::PluginLoader::pluginsDir() probes <appdir>/plugins, <appdir>/../lib/score,
/usr/lib/score and <cwd>/plugins. Test executables live in <build>/tests/..., so on
a machine with no installed score the only path that can match is <cwd>/plugins:
ctest gets this right through WORKING_DIRECTORY, but the same binary run by hand
from anywhere else boots an application with no plug-ins at all and then aborts
somewhere unrelated -- settings<T>() on a model whose plug-in never registered.
Pass the build root through to the fixture and chdir when ./plugins is absent, so
a hand-run behaves like the ctest run it is meant to reproduce.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
closeDocument() raised a modal QMessageBox on a modified document. With
applicationSettings.gui false -- headless, --script, offscreen QPA -- nothing can
answer it and QMessageBox::exec() aborts, so a scripted /exit on a document that
had been edited died in teardown with SIGABRT instead of exiting 0. Every
score::MessageBox helper already guards on that flag; this was the one raw
QMessageBox left on the exit path. No GUI means no one to save for, so proceed as
Discard -- which is the outcome forceExit() already produced anyway, since it
quits 500 ms later whatever the answer would have been.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
settings<T>() aborts when no plug-in registered that model, which is right for
application code -- the plug-in is missing and nothing downstream can work. A test
harness would rather say so itself, with a message naming the cause. findSettings<T>()
returns the pointer or null; settings<T>() now delegates to it and is otherwise
unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The sweep took its graphics API from ctx.settings<Gfx::Settings::Model>(), which
aborts the process when the gfx plug-in registered no settings model -- which is
what happens when the binary is run from a directory without ./plugins. Use
findSettings and FAIL with a message naming the cause instead of dying in a
settings lookup.

SCORE_SHADER_SWEEP_DUMP_DIR writes each frame out, so that "it rendered" can be
checked against what the shader is supposed to draw rather than taken on faith.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

2 participants