Skip to content

tests: cover the QML protocol bindings and QML application use cases - #2278

Merged
jcelerier merged 15 commits into
masterfrom
tests/qml-protocols-and-sat-usecases
Sep 14, 2026
Merged

jcelerier merged 15 commits into
masterfrom
tests/qml-protocols-and-sat-usecases

Conversation

@jcelerier

@jcelerier jcelerier commented Sep 14, 2026

Copy link
Copy Markdown
Member

Rebased on master and split into one commit per change. Depends on
ossia/libossia#939, now merged: the submodule pointer here is libossia master.

Fixes, and the test that covers each

commit fix covered by
build: asio buffer debugging libossia sets BOOST_ASIO_ENABLE_BUFFER_DEBUGGING in Debug while the vendored liblsl does not, and the flag changes asio's internal layout — an ODR violation in any binary linking both build-level; no runtime oracle
protocols: a mapper's script cannot outlive its device tree queued slots and polling timers ran after stop() released the QQmlEngine; a script's resolved addresses were cached with no invalidation, so a device's destruction left dangling parameter_base* its onClose then wrote through test_unit_mapper_protocols — "read and write callbacks survive repeated device removal", "an unqualified mapper address resolves in the script's own device"
js: a device identifier outlives the enumeration that made it identifiers were deleted on every re-enumeration although the contract is to read them once and reuse their settings test_integration_device_enumeration_lifecycle
js: an enumerator is a subscription, and a name is taken or it is not the enumerator was parentless and collectible while still discovering; createDevice shadowed an existing name instead of refusing same
js: one Qt Quick runtime per renderer, one publisher the runtime was keyed on (thread, QRhi), so a second render list on the same RHI got the first one's runtime and tripped an assert; and several runtimes of one process each published its outputs test_integration_texture_source_lifecycle
js: a destroyed texture source takes its preview node unregistered through the item, which Qt has already destroyed by then, so the node and its render list stayed in the graph same
avnd: a worker result is applied at the start of the node's own tick results were applied before init_node cleared the outlets, with the previous tick's frame index, so objects had to re-emit from operator() test_integration_avnd_worker_lifecycle
avnd: a CPU-only buffer producer is one object it was classified as a GPU node and built once per RenderList, so two output windows meant two of whatever it owns same

Tests

  • The manual QML protocol corpus — 45 scenarios vendored under tests/testdata/protocols-qml, byte-identical to the originals apart from four import QtQml lines needed to parse, run by real Mapper devices over real sockets against independent peers. Framing, encoding and OSC oracles are written out in the tests, so score's encoder and decoder cannot agree on the same wrong bytes.
  • Four use-case suites — texture source lifecycle, device enumeration lifecycle, Protocols.* from the --ui console engine, and the avendish worker/buffer contract.

Changes from the original single commit

  • src/plugins/score-plugin-avnd/AvndProcesses/Test123.hpp removed: a scratch Distortion object, referenced by nothing.
  • The submodule points at libossia master rather than the MIDI out apparently broken on mac #939 branch.
  • Everything else is byte-identical to the original commit's tree.
  • The four use-case targets were given the TIMEOUT 180 the three protocol targets already had, so a blocking test fails the run instead of holding it for the default twenty-five minutes.

Verification

Full build clean. 174 tests across the changed areas pass; the only failure is test_integration_js_remove_cable, which is red on master as well.

One case does not pass on a machine with a webcam, and it is not this branch's doing: test_integration_device_enumeration_lifecycle, "a device name already taken is refused rather than shadowed", stands two live Camera devices up and hangs on stop, in Video::CameraInput::close_file() joining its decode thread —

#7  std::thread::join
#8  Video::CameraInput::close_file   (CameraInput.cpp:240)
#9  Video::CameraInput::stop         (CameraInput.cpp:182)
#10 Gfx::video_texture_input_protocol::stop_execution
#11 Execution::DocumentPlugin::clear

close_file() does call m_interrupt.abort() before joining, so the buffer thread is blocked somewhere the interrupt callback does not reach — plausibly the v4l2 open of a second stream on a busy device. None of that code is touched by this branch, and the sibling case that stands up a single camera during playback passes. Reproducible 3/3 here. Worth fixing before this merges, since the new suite is the first thing to reach it.

🤖 Generated with Claude Code

https://claude.ai/code/session_019QWYTEhFJonuUNTEmBfv7Q

@jcelerier
jcelerier force-pushed the tests/qml-protocols-and-sat-usecases branch 2 times, most recently from 40ea6be to 4c5db55 Compare September 14, 2026 14:58
jcelerier and others added 15 commits September 14, 2026 12:21
…ebugging

BOOST_ASIO_ENABLE_BUFFER_DEBUGGING changes the layout of asio's internal types.
libossia sets it on its own target in Debug while the vendored liblsl compiles
boost.asio without it, and score is what links the two together, so score is
where it has to be made uniform.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019QWYTEhFJonuUNTEmBfv7Q
… into

Two ways the engine survived what it reads and writes.

Queued push and receive slots, and the polling timers, kept firing after stop()
released the QQmlEngine: they now bail on a null engine, which is set before it
goes away.

And a script's resolved addresses were cached with nothing invalidating them, so
a device's destruction left dangling parameter_base* that a script's onClose
then wrote through. The cache libossia keeps is dropped on every device-list
mutation, and the mapper maintains that list synchronously from the main thread
-- rootsChanged is queued, which is already too late, and its raw node pointers
have crossed a thread by the time they arrive. Script access is disabled before
the tree is destroyed, which waits for any script currently inside read or
write.

The script-facing object is also told which device owns it before the script
loads, so an unqualified address resolves in that device rather than in a
sibling exposing the same leaf name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019QWYTEhFJonuUNTEmBfv7Q
…hat made it

The contract QML scripts are written against is to read the enumerated sources
once and reuse their settings later. Every re-enumeration deleted the
identifiers and allocated new ones, so a script holding one was reading freed
memory -- the QML wrapper does not keep the C++ object alive.

The enumerator owns them now and refreshes an existing one in place, matched on
(protocol, category, name), which is what a source is. Leaving the enumerated
list no longer destroys an identifier, only unlists it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019QWYTEhFJonuUNTEmBfv7Q
… is not

Score.enumerateDevices() returned an enumerator with no parent, which the QML
garbage collector was free to take while it was still discovering: an
asynchronous backend such as NDI or Spout would then simply never report. It
belongs to the scripting context, for as long as that lives.

Score.createDevice() with a name already in the document added a second device
under it. A name is the only handle the rest of the document has on a device --
"Camera:/" on a port, Score.device("Camera"), the explorer tree -- so the second
does not replace the first, it shadows it, and the addresses of the shadowed one
stay live and unreachable. Refused on the name; adding a device during execution
is still allowed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019QWYTEhFJonuUNTEmBfv7Q
…process

The runtime was keyed on (thread, QRhi). Two sinks on one node -- two QML
TextureSources previewing the same process, or a preview panel open in two
views -- are two render lists that may share both, so the second was handed the
first one's runtime, whose QQuickWindow is bound to the other's render target,
and tripped an assert. Keyed on the renderer instead, which also means a
renderer only ever inserts and erases its own entry: under SCORE_THREADED_GFX
the release can run on a different thread than the acquire.

Several runtimes are several instances of the script, but still one process.
Its value outlets and its messages to the UI are written by exactly one of them
-- the first to run claims the role until its render list goes away -- or
opening a second preview of a mapping doubles every value it sends. The others
still clear their own outlets, or a non-publishing script's `values` would grow
without bound.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019QWYTEhFJonuUNTEmBfv7Q
The renderer unregistered its preview node through the item, but Qt destroys
the renderer after the item and on the render thread, so that pointer was
already null and the node and its render list stayed in the graph for the rest
of the session. It remembers the document plugin it registered with instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019QWYTEhFJonuUNTEmBfv7Q
avendish's contract is that what work() returns is invoked back in the
processing thread against the object, which may write its outlets from there.
The execution queue cannot honour that: it is drained before the graph runs, and
init_node then clears every outlet of the node and sets the tick's frame
indices, so a result's writes were erased and carried the previous tick's frame
index. Objects had to work around it by re-emitting from operator().

The results are queued on the node and drained at the top of its run() instead
-- outlets cleared, frame indices this tick's, operator() not yet run -- so what
a result writes to a port is delivered for this tick, and what it writes to an
output field is flushed by the usual finish_run().

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019QWYTEhFJonuUNTEmBfv7Q
An object whose only gfx ports are CPU buffer outputs never touches the RHI:
everything a renderer owns for it is renderer-side, and nothing in the object
is. It was still constructed once per RenderList, so two output windows meant
two of whatever the object owns -- two TCP listeners, two of any other CPU
identity -- and rebuilding a renderer restarted it.

Such a node now gets a single instance shared by every renderer of it, while a
node that does keep renderer-side state still gets one each. The buffers stay
per renderer, so the shared object's upload callbacks are rebound to the
renderer about to run it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019QWYTEhFJonuUNTEmBfv7Q
45 scenarios written by hand against the Protocols.* bindings, vendored as they
are apart from four `import QtQml` lines they need to parse at all, plus the
fixture that runs one: it stands a Mapper device up on a real socket, drives it
and reads the values back out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019QWYTEhFJonuUNTEmBfv7Q
Each scenario runs against an independent local peer, and the framing, encoding
and OSC oracles are written out by hand in the test rather than reused from
score, so its encoder and decoder cannot agree on the same wrong bytes. Covers
the line, size-prefix, SLIP, Intel HEX and S-record framings, the hex, base64
and ascii85 encodings, HTTP verbs, status codes, query parameters and bearer
auth, UDP and Unix datagrams, virtual MIDI and UMP, and nine hardware control
protocols emulated by their own peers.

This is what the mapper teardown defects were found with: the use-after-free
through a script's cached addresses crashed about one run in three here while
every assertion passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019QWYTEhFJonuUNTEmBfv7Q
Worker-thread jobs and the callback outlets they write, container payloads,
messages dispatch, a buffer outlet whose payload is resized every tick, and
destroy/recreate under MALLOC_PERTURB_.

Covers both avnd fixes: a worker result reaching an outlet for the tick it is
applied in, and a CPU-only buffer producer being constructed once rather than
once per render list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019QWYTEhFJonuUNTEmBfv7Q
A laid-out source that resizes, at integer and fractional sizes; a large
fixed-size source rendered while hidden; device pixel ratio; two sources on one
process; and the process destroyed and rebuilt underneath them.

This is what the two texture-source defects were found with: two sources on one
process tripped an assert, and a destroyed source left its preview node and
render list in the graph. Reaching the JS application plugin across the plug-in
boundary needs its typeinfo exported, like the other plugins' already is.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019QWYTEhFJonuUNTEmBfv7Q
Enumerate, read the settings back, hand them to createDevice; the enumerator
collected mid-discovery; a device added while the transport plays; a backend
switch as one undo step.

Covers the three enumeration defects: identifiers deleted under a script that
kept one, an enumerator collectible while still discovering, and createDevice
shadowing an existing name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019QWYTEhFJonuUNTEmBfv7Q
The --ui console engine is a different QQmlEngine on a different io_context
from a Mapper device's. OSC in and out byte-exact, and ports rebound across
listen toggles.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019QWYTEhFJonuUNTEmBfv7Q
The three protocol suites were given a timeout and the four use-case ones were
not, so a test that blocks holds the whole run for the default twenty-five
minutes. The enumeration suite stands real capture devices up, and a machine
that has one reaches driver teardown paths that can block outright.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019QWYTEhFJonuUNTEmBfv7Q
@jcelerier
jcelerier force-pushed the tests/qml-protocols-and-sat-usecases branch from 4c5db55 to 1713909 Compare September 14, 2026 16:21
@jcelerier
jcelerier merged commit 8591aa0 into master Sep 14, 2026
0 of 37 checks passed
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.

1 participant