Skip to content

Sessions where one peer edits a score another machine runs - #9

Draft
jcelerier wants to merge 59 commits into
masterfrom
remote-control
Draft

Sessions where one peer edits a score another machine runs#9
jcelerier wants to merge 59 commits into
masterfrom
remote-control

Conversation

@jcelerier

Copy link
Copy Markdown
Member

Paired with ossia/score#2180 — this will not build against score's master,
and score's master add-on will not build against that branch. score's
ci/common.deps.sh pins this branch for the duration.

A peer may now join as a terminal: it shows the score, drives it, and edits
it, while the machine hosting the session keeps the devices, the execution and
the files.

  • rpc channel alongside command replication: fs.*, device.*,
    library.processes, object.state, script.eval
  • the host reports what only it can see — transport position, device
    connection state, device trees, device values, and its message log — each
    coalesced, and sent only to the peers that asked to watch
  • a peer's edits to a device tree are performed where the devices are
  • what a peer cannot construct is carried verbatim and handed back, so a
    browser can add an evdev device it has no factory for
  • wss listener alongside ws, and a failed join says why rather than
    failing silently

Peer bytes go through one guard (readingWireData), including rpc answers,
because what a caller does with an answer is deserialize it through factories
written for our own save files.

Tests run both peers in one process. score::test::absent_factory hides a
factory for a scope, which is the only way to express the case that matters
here — a peer that cannot build what the other side can.

jcelerier and others added 30 commits August 6, 2026 08:37
Both replication policies deserialized and instantiated commands coming
off the socket with no error handling at all:

    score::CommandData cmd;
    DataStreamWriter writer{m.data};
    writer.writeTo(cmd);
    stack.redoAndPushQuiet(m_ctx.app.instantiateUndoCommand(cmd));

Peers do not necessarily run the same build -- Syphon exists only on
macOS, Spout only on Windows, and both are compiled into a plug-in that
ships everywhere -- so a peer can legitimately send a command we cannot
read or cannot instantiate. Every one of those took the process down:
instantiateUndoCommand aborts in debug and throws in release, and the
throw escaped a Qt signal handler. This was not client-only; the master
ran the identical handler, so a client adding a Spout device killed the
host mid-show.

Commands from peers now go through applyRemoteCommand, which reports
rather than aborts, using the checked instantiateUndoCommandIfAvailable
added to score for the purpose. The abort is right for a local
programming error and wrong for untrusted input off the network.

Failing to apply is not recoverable by ignoring it. The two ends are
then editing different documents, and since paths resolve by position,
later commands would silently retarget the wrong objects. A client that
cannot apply a relayed command marks itself diverged and stops applying
anything further, undo and index moves included, until the session is
rejoined.

The master's failure is a different situation: it declines the command
and does not relay it, so it and every other client stay consistent and
only the sender is ahead. It marks nothing and replies /command/rejected,
on which the sender marks itself diverged.

Joining is now checked too. Peers mirror each other's document as raw
QDataStream payloads, so both ends must agree on how those bytes are
encoded and on what the model means; a mismatch reads the wrong bytes
into the right fields rather than failing. The handshake carries the
document format version and the Qt stream version, and the master
refuses a client that does not match, or one too old to say.

Also removed three fixed values that made two sessions on one machine,
or two clients on one host, collide: the client's listening port was
hardcoded to 9090 while the setting for it went unread, and every
session called itself 1234.

Full-document resync is deliberately not attempted: it means hot-swapping
a live document under an open presenter, panels, selection and undo
stack, and no such path exists.

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

Replication binds a Session to a DocumentContext rather than to an
application, so a master and a client can be two documents in one
process talking over a real socket. That exercises the sockets, the
serialization and both edition policies without the cost of driving two
applications, and it is what the previous commit's divergence handling
had no way to demonstrate.

Three cases: a client joins and receives the document, an edit on the
master reaches the client, and a command the client cannot read leaves
it diverged and no longer following -- which is the behaviour that keeps
a gap from turning into silent corruption.

Two changes fell out of writing it.

The builder now asks for an id when its socket comes up, instead of
emitting `connected` and waiting to be told. Both callers did exactly
that and nothing else, and it cannot be done from outside a shared
library anyway: verdigris signals do not resolve by member-pointer
across that boundary, because the metaobject's IndexOfMethod handler is
not exported.

The session classes the test drives are exported now. They were only
ever used from inside the add-on.

Tests/Repartition is deleted rather than fixed: it was commented out in
its entirety, never built -- the add-on's CMakeLists had no
add_subdirectory for it -- and written against an API removed years ago.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011wXVcUPzvcDTVzLpWJzeqP
Peers are not the same program. Protocols and processes are registered
conditionally inside plug-ins that ship everywhere -- Syphon exists only
on macOS, Spout only on Windows, VST and LV2 not at all in the browser
-- so two score instances in one session routinely differ in what they
can construct.

Until now that difference was only discovered by hitting it: a command
arrived naming a factory the receiver did not have, and the best it
could do was stop and report that it had diverged, in the middle of
somebody's edit.

The handshake now carries what each end can make, and both report what
the other has that they do not. It is not an error and does not refuse
the join -- unlike the format versions, which cannot be worked around --
it is the reason a host's process will appear here as a stand-in.

It is also what the client side needs in order to eventually offer the
host's protocols rather than its own: a browser has no ALSA card or V4L2
node to enumerate, but it can be told which ones the host has.

Stored sorted and as opaque strings, since the whole point is naming
things this build has no type for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011wXVcUPzvcDTVzLpWJzeqP
Logging what the other end can build tells the person running it and
nobody else. A client has no way to enumerate the host's audio cards,
cameras or MIDI ports -- a browser has none of its own to look at -- but
it can be told which protocols exist there, and that is only useful if
something can read it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011wXVcUPzvcDTVzLpWJzeqP
The mapper belongs to the Session, and nothing owns a Session -- it is
created with no parent and outlives everything. The policies that
install handlers on it die with their document. So a message arriving
between the two ran a lambda holding references to freed objects.

It showed up as an intermittent crash in Session::findClient, reached
from a MasterEditionPolicy handler, once two peers shared a process and
their teardowns interleaved. Real before that, just hard to provoke with
one peer per process.

Handlers now name their owner and are dropped when it goes. Passing one
is not optional: an unowned overload would let the next handler
reintroduce this quietly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011wXVcUPzvcDTVzLpWJzeqP
Command replication is the wrong shape for a question. It is one-way,
ordered against the document, and every message is a change everyone
must apply. "Which cameras do you have?" is none of those: it is
addressed to one peer, it expects a reply, and the answer changes
nothing.

RpcChannel runs beside it on the same socket, as its own pair of message
addresses. Requests carry a number the reply quotes back, so several can
be in flight without the answers being confusable.

Payloads are JSON rather than the QDataStream the document channel uses.
What travels here describes another machine's world -- protocols and
devices this build may have no type for -- and JSON can be carried
through by something that cannot parse it, which is exactly the case
that matters.

Two things a peer must hear rather than wait for: a method the other end
does not offer, which is as ordinary as a plug-in it does not have, and
a handler that fails, which reports back rather than taking the peer
down.

Nothing binds a method yet; this is the channel the host-side queries
will go on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011wXVcUPzvcDTVzLpWJzeqP
Devices are the least portable part of a score: an ALSA card, a V4L2
node, a Syphon server. Which ones exist is a fact about one machine, and
a peer editing from elsewhere had no way to find out. A browser has no
hardware of its own to enumerate, and the protocol that would do the
enumerating is often not even compiled into it.

Two methods on the rpc channel answer that. device.protocols reports
what this machine could make a device with, by name and category: the
capability exchange at join already carries the uuids, but nothing a
person can read, and a list has to be readable to be offered. Then
device.enumerate reports what is plugged in right now for one of them.

The settings come back as the protocol itself writes them, which a peer
without that protocol cannot parse and does not need to: it holds them
as they are and hands them back in the command that creates the device,
so the machine that has the protocol is the one that reads them. That
only works because settings for an unknown protocol are preserved rather
than dropped, which is what the opaque work earlier made true.

Bound on both ends rather than only on a host, because in a peer session
either side may be the one with the camera.

The test asks about every protocol the host reports and requires that at
least one really found hardware: a path that always returned an empty
list would satisfy a shape check just as well.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011wXVcUPzvcDTVzLpWJzeqP
fs.list, fs.read and fs.write on the rpc channel, and a RemoteEnvironment
that speaks them, so a peer editing from elsewhere can see the project
folder, read what a process refers to, and put a file where the score
will find it.

They speak only in score::Uri, never in paths, and that is the whole of
the access control. A Uri names somewhere relative to the project, the
user's library or the media cache; there is no spelling of one that
reaches the rest of the filesystem, so a peer cannot ask for /etc/passwd
however it phrases the request. Absolute paths are refused rather than
resolved, and a resolved path is checked for having stayed inside the
place its scheme names, since ".." would otherwise walk out of it.

Writing to the library is refused as well: it is the user's own
collection, not something a peer rearranges.

Transfers are capped and refused rather than truncated, so a caller
cannot mistake half a file for the file. Anything bigger belongs on a
channel of its own -- a request shares the socket with command
replication, and a hundred megabytes of audio would stall every edit
behind it.

The test drives LocalEnvironment and RemoteEnvironment through the same
checks, so what is asserted is the contract rather than either one's
internals, and separately that a remote peer cannot address anything
outside the schemes -- locally an absolute path is just a path the user
chose, which is why that check belongs only to the remote case.

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

Installed after setEditPolicy, which is what gives the plug-in a session
to speak over -- before that there is no channel to carry the requests.

The test keeps both ends in view: the host resolves <PROJECT>:sound.wav
to a real path, the client resolves it to nothing and reads the same
bytes through the session instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011wXVcUPzvcDTVzLpWJzeqP
fs.write created directories, opened the file -- which truncates -- wrote
the data, and only then checked that it had stayed inside the location
its uri named, deleting it if not. The comment called that undoing the
write. It is not: by then the file is already destroyed, and the removal
finishes the job.

A uri had to be non-absolute, which is not the same as staying put.
"../../.ssh/authorized_keys" parses as Relative, and resolving a relative
uri joins it onto the project root. So a peer could name any file the
user can write, and have it truncated and then deleted.

Now: only the three schemes that name somewhere inside the score are
addressable at all, ".." is refused by spelling, and for a write the
nearest existing ancestor is canonicalised and checked before anything is
created or opened -- which also catches a symlinked directory inside the
project, since resolving happens on the real path.

Reads were already safe; requireContained ran before opening.

The test attacks it rather than describing it: it puts a file outside the
project, tries six ways to reach it, and requires the file to be
untouched afterwards rather than merely restored. Reinstating the old
ordering makes it fail.

Still true and not addressed here: joining a session requires no
authentication at all, so these methods are as reachable as the session
is. That has to be fixed before a session is exposed to a network.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011wXVcUPzvcDTVzLpWJzeqP
Several ways a request could be left hanging with neither callback ever
run: Session::sendMessage drops a message whose target is gone, a peer
can simply not answer, and a peer that leaves took its pending questions
with it. Requests now carry a timeout and are failed when the peer is
removed.

Answers are matched on who was asked as well as on the request number.
The numbers are sequential and shared across peers, so on a master with
several clients any of them could answer a question put to another and
win the race.

Every rapidjson accessor on peer-supplied JSON now checks the type it is
about to read. RAPIDJSON_ASSERT is assert(), compiled out in release, so
{"id":1,"error":{}} had GetString() reinterpret an object's member
pointer as a char* and GetStringLength() read the overlapping size --
an out-of-bounds read straight into a string shown to the user.

Session::master() was `throw;` with nothing in flight, which is
std::terminate, and RpcChannel called it on every send. The special case
was also unnecessary: ClientSession adds its master to the remote
clients, so sendMessage finds it like any other peer.

RemoteEnvironment held the channel by reference, and setEditPolicy
replaces it -- hosting from a document that was joined does exactly that.
It holds a QPointer now and reports rather than following a dangling one.

A diverged peer stops sending as well as applying: its edits are
expressed against a document nobody else has, and the paths in them name
different objects on the other side, so one command we could not apply
would otherwise become open-ended corruption of everyone's copy.

The master applied undo, redo and index movements from the wire without
checking them. undoQuiet pops whether or not there is anything to pop,
and setIndexQuiet walks toward whatever number arrives.

A command that threw part-way through has already changed the model and
nothing puts it back, so that is a divergence and not something to
decline. It also leaked the command; it is owned until the stack takes
it now.

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

Refusing a client meant nothing: session_join never checked that an id
had been offered, so a peer that ignored the rejection -- or never asked
in the first place -- was still sent the document and the command stack,
and joining twice made two clients on one socket.

Player mode was broken by the version handshake and failed silently.
PlayerSessionBuilder still sent only its name, so every player was
refused as too old to say what it speaks, and it had no handler for the
refusal, so it waited forever with nothing said. It also still hardcoded
the client port that ClientSessionBuilder had been fixed to read from
the settings.

The media cache not existing yet is ordinary -- it is made on first use
-- and reported as if the request pointed somewhere it should not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011wXVcUPzvcDTVzLpWJzeqP
Two things stopped it, both the same shape: work that spins the event
loop, on a stack the browser cannot suspend.

"Join Server" opened its dialog with exec(), which runs a nested event
loop. The browser's main thread cannot provide one, so exec() returned a
rejection immediately and clicking the menu entry did nothing at all --
no dialog, no error, nothing. Shown asynchronously it works on both.

Then receiving the document called loadDocument straight from the
WebSocket message handler. Loading closes the current document if it is
still untouched, and closing spins the event loop -- which needs a stack
the runtime can suspend, and a message handler invoked from JS is not
one: "trying to suspend without WebAssembly.promising". Queued onto Qt's
own event loop, where it can, it loads.

"Make Server" is disabled there and says why. A page cannot listen for
connections: LocalClient has no server in that build and there is no
QWebSocketServer at all. The web build joins a session; it does not host
one.

Verified against a native host: the browser joins, receives the
document, and the host reports what it cannot build -- 17 protocols, 14
processes and 16 commands fewer, which is the whole reason the rest of
this work exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011wXVcUPzvcDTVzLpWJzeqP
A session has always meant every peer running the score on its own
machine. A terminal is the other thing a session can be for: editing and
watching a score that runs elsewhere. It mirrors the document and
replicates commands like any peer -- that part is the point and is
inherited unchanged -- but it never executes, so it opens no ports,
claims no MIDI or camera, and renders nothing.

The document plug-in arrives inside the received document and cannot be
declined, but everything in it that serves execution is already
optional: the exec policy is null-checked, netpit contexts are only
registered by executing processes, and the trigger maps are only filled
during execution setup. So a terminal needs no new structure there --
one EditionPolicy subclass, and setExecPolicy simply never called.

TerminalEditionPolicy inherits every command, undo and lock handler from
ClientEditionPolicy and only redefines what transport means: Play is a
request addressed to the host, and the host's /play does not start
anything here. It connects the ordinary Play/Stop actions as well as the
network ones, since on a terminal there is nothing else they could mean.

The role is requested in session_askNewId and confirmed in
session_idOffer, both appended after the existing payload so a peer that
predates roles sends none and is taken for a performer. The host's
answer is what the client uses, which leaves somewhere to refuse a role
later.

Also stop counting terminals in group consensus. A shared trigger waits
for every member of its group to agree, and a terminal never has an
opinion about one: with it counted, OnAll never becomes true and the
trigger never fires for anybody. Ids naming nobody stay counted -- what
a group means after a peer disconnects is a separate question and not
one to answer silently here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011wXVcUPzvcDTVzLpWJzeqP
A terminal has no executor, so nothing moves its cursor -- yet where the
score has got to is most of what someone watching a remote score is
watching. It costs one number: IntervalDurations::setPlayPercentage is a
plain model setter and the executor is merely one of its callers.

The host broadcasts on playPercentageChanged rather than
positionChanged: the model already only emits it once the score has
moved by 32ms, which is about thirty messages a second, where
positionChanged fires on every tick.

Root interval only. Nested intervals get their positions from their own
executor components, which a terminal does not have, so the global
cursor moves and the ones inside stay put.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011wXVcUPzvcDTVzLpWJzeqP
play()/stop() were no-ops, so the buttons sat in whatever state the
person left them in while the host played. Drive TransportActions from
the host's own play/stop, and put the cursor back to zero on stop.

Also log which role a peer joined with, on both ends: it decides what
was built when the document was read, and nothing said it out loud.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011wXVcUPzvcDTVzLpWJzeqP
Caught a vacuous wait while writing it: the base interval already holds
its Scenario, so spinning until processes.size() == 1 was satisfied
before the command ever arrived.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011wXVcUPzvcDTVzLpWJzeqP
Layer three of the heterogeneity story. The first two keep the session
alive when a command names a factory this build lacks: the payload can
be skipped by length, and creation falls back to a stand-in. Neither
gives the stand-in anything to show, because the command carries what a
factory would be given rather than what the object would write.

object.state closes it: the peer that made the object answers with its
serialization, and the stand-in loads it. That is what turns "the client
sees the process but without its controls" into seeing the real ports
and values, and it removes the incomplete state that would otherwise
have been dangerous to save.

Asked after applying a replicated command, of the peer that sent it.
Answers are applied through a QPointer: the reply arrives later, and by
then an undo of the very command that made the object may have removed
it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011wXVcUPzvcDTVzLpWJzeqP
Capabilities already says which process factories a peer has, but only
as uuids -- enough to warn, not enough to pick from. Someone editing a
score that runs elsewhere wants the processes *that* machine has, which
are exactly the ones the library here does not list.

library.processes answers with names and categories; the client adds the
ones it lacks under a category of their own, so it is clear they come
from elsewhere and will not run here. Dragging one in then works because
of the layers underneath: the command names a uuid this build cannot
make, creation falls back to a stand-in, and the stand-in is filled from
the peer that could make it.

Only the factory list, not the library's presets and files -- those are
paths on the other machine and belong with the fs.* work.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011wXVcUPzvcDTVzLpWJzeqP
…dding to it

Showing the peer's extras under a category of their own was the wrong
shape for a terminal. Nothing it lists will run here: the score runs on
the other machine, so this machine's own processes are not an
alternative to the peer's, they are beside the point. Offering them
offers something that cannot happen.

So in terminal mode the tree is replaced outright, keeping the peer's
categories, and what a person browses is that machine's world. A
performer keeps the union, because there its own processes are as real
as the peer's.

This is the first of the panels: the file browsers, presets and the
protocol lists in the device dialogs still describe this machine.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011wXVcUPzvcDTVzLpWJzeqP
The library panel resets itself to this build's processes whenever a
document that runs here becomes visible, so a terminal's view has to be
put back when it returns. Replaces the one-shot import at join, which
was wrong as soon as a second document was open.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011wXVcUPzvcDTVzLpWJzeqP
RemoteDeviceCatalog answers over device.protocols and device.enumerate,
both of which were built in phase 2 and never called by anything. The
protocol list is asked for once -- what a machine is built with does not
change while it runs -- and enumeration every time, because what is
plugged into it does.

Enumerated settings are carried verbatim: nothing here can parse what a
protocol it lacks wrote, and it does not need to. They go back in the
command that creates the device, where the machine that has the protocol
reads them. That is what makes "add the host's MIDI port" work without
this build understanding MIDI settings at all.

Parented to the network document plug-in: the document keeps a bare
pointer, and the session builder is thrown away as soon as the session
is up.

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

The library import for a terminal ran only from on_documentChanged,
which fires while loadDocument is still running -- before setEditPolicy
has given the plug-in an rpc channel to ask over. So it found none and
did nothing, every time: the host's processes never appeared. Import
from buildDocument as well, once the channel exists.

Device status is broadcast as devices connect and disconnect, and once
for everything when a peer joins, so a terminal arriving mid-session is
not left with a blank picture until something happens to change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011wXVcUPzvcDTVzLpWJzeqP
Two symptoms, one cause. Categories showed as "Plugins/Faust" instead of
nesting, because the local model splits a category on '/' into levels
and the mirror made one node named after the whole path. And nothing
file-derived appeared at all -- ISF shaders, Faust programs, presets --
because those are not factories: the LibraryInterfaces build them by
scanning the library folder, and they carry which file in customData.

So send the model's tree itself, names, keys, customData and all. The
terminal reproduces it exactly, and dragging one produces the same
ProcessData the host would, which is what makes the command create the
real thing there.

Read through a model built here when there is no library panel. A host
run with --no-gui has none -- which is the whole point of that mode, and
the shape a score box takes -- so reading the panel's model would have
meant the machines most likely to be hosts are the ones that could not
answer.

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

A stand-in can be told its state. A real process built from creation
data that described another machine cannot: what is here is the wrong
object rather than an empty one, so it is replaced by the peer's
serialization -- same id, same interval, so cables and paths still name
it.

Icons are computed on this side rather than sent: they are score's own
resources, identical in both builds, and a QIcon does not travel. Only
the top level carries one, which is what addCategory does when the tree
is built locally.

The two session-level tests for this were dropped: fillStandIns drains
the registry synchronously in the command handler, so nothing could
observe it and both passed with the mechanism removed. Tested where
nothing drains it instead, in TerminalRoleTest.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011wXVcUPzvcDTVzLpWJzeqP
Three separate reasons a terminal saw stale or missing state:

 - Play on the host only told the session when the *network* Play action
   was used. The ordinary one means the same thing to anyone watching,
   so it says so too; local playback still happens through
   ExecutionController either way.

 - State was fetched only when a command arrived from someone else, so a
   process the terminal added itself stayed empty until the next remote
   edit -- "add a shader, see nothing; add another on the host, both
   appear". Its own commands need the same treatment, asked after
   sending so the master has applied it by the time the question lands.

 - Device connection state was pushed when a peer joined, which races
   the joiner: it registers its handlers only once the document has
   arrived, so the broadcast was thrown away and everything read as
   disconnected until something changed. device.statuses lets it ask
   instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011wXVcUPzvcDTVzLpWJzeqP
playPercentageChanged fires only when a single update moves the score by
more than 32ms, and an executor tick is one audio buffer -- about ten.
So it almost never fired, and the terminal's clock advanced a little at
the start and then stopped. Its own comment says it exists for
DurationSectionWidget; score's time display does not use it either, it
reads playPercentage on a timer. So does this now, at 20Hz, sending only
when the number changes.

rootInterval() also has to check closing(): the model outlives its base
scenario while a document is torn down, and a timer keeps firing until
it is gone. Same trap as the timing widget.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011wXVcUPzvcDTVzLpWJzeqP
Carried with the connection state, since it is the same question about
the same devices: a terminal cannot cast to a plug-in type it does not
have, so the machine that has the device answers for it.

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

The reported crash. ObjectPath identifies by (objectName, id), and a
stand-in is constructed as "OpaqueProcess" rather than named after the
process it replaces -- so the path a peer without the factory builds
names something the host does not have. Looking that up hits
SCORE_BREAKPOINT *before* it throws, which on a host with no debugger
attached is a SIGTRAP: the rpc's error handling never got a chance, and
adding a C++ texture generator took the host down.

Objects are now addressed as interval path + process id. Both are stable
across peers: the interval is a real object on both sides and the id
comes from the command. And the lookup is try_find, so a path naming
nothing is an error reply rather than a trap -- which is what it always
should have been for something arriving over a socket.

Device kinds also travel with the per-device status broadcast, not only
in the query answered at join. A camera plugged in after a peer arrived
was otherwise known to be connected and not known to be a camera, so no
combo box would offer it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011wXVcUPzvcDTVzLpWJzeqP
A terminal has no executor, so nothing inside the score moved: the global
cursor advanced and every interval underneath sat at zero and never lit up,
which is most of what tells you which part of a score is running.

Each interval is now sent by path, with its position and whether it is
executing, and only when one of the two changes -- a stopped score still
sends nothing. Paths are resolved with try_find on the way in: an interval
inside a process the terminal cannot build has no counterpart there, and
find() breakpoints before it throws.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011wXVcUPzvcDTVzLpWJzeqP
jcelerier and others added 29 commits August 7, 2026 08:07
A peer asking the host to stop reached for Actions::Stop, which a --no-gui
host does not register: ActionManager::at threw and the process died, taking
the session with it. Reproduced from a browser terminal against a headless
host -- one click and every peer saw "the remote host closed the connection".

Both policies now call ExecutionController::request_stop, which is what the
action does and what play() already used.

Not covered by a test: SessionReplicationTest's fixture registers the
transport actions, so the lookup succeeds there whether or not this is fixed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011wXVcUPzvcDTVzLpWJzeqP
A terminal cannot trust the creation data a command carries -- it described
another machine -- so it asks the peer for the process's real state and
applies the answer, which replaces the object. Two tests now pin that: the
headless one that the process survives, and a GUI one (a new binary, since
the two application fixtures cannot share a process) that it is still drawn,
with the process selected so the inspector is holding it when it is replaced.

The session fixture moves to SessionFixture.hpp so both can use it.

Neither reproduces the reported browser fault -- an automation flickering
and the wasm peer then throwing -- but they establish that the replacement
itself is sound in a single process with one factory set.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011wXVcUPzvcDTVzLpWJzeqP
Dropping an automation into a new box left the session diverged on the next
edit: "command UpdateCurve failed part-way through: Assertion failure:
slot.frontProcess".

A terminal asks the peer what a process it just made really is, and applies
the answer by replacing the object. Removing a process takes its layer out
of every slot; adding one puts it in none. So the slot the drop had just
created was left with no processes and no frontProcess -- a state the
document is not allowed to be in. ScenarioValidityChecker asserts
frontProcess on every command, so the very next one threw part-way through,
which is unrecoverable for a session.

applyState now records where the process sat in the small view and puts the
replacement back there, front layer included.

Reproduced by SessionLayerTest, which fails on both counts without this.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011wXVcUPzvcDTVzLpWJzeqP
Pasting it onto the name gave the dialog one flat list; it groups by
enumerator, like it does for local hardware, and needs the category apart
from the name to do that.

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

Editing a value in the device explorer on a terminal did nothing: there is
no device object to send it to. It now travels to the host as a
State::Message and is applied there through the same NodeUpdateProxy call
the explorer makes locally -- so the protocol, and the OSC packet it sends,
are the host's, not a second client's.

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

Enumerating everything made the case depend on the machine's hardware and
crashed the suite through the network-discovery enumerators. It now asks the
probe protocol, which reports two devices under a fixed category.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011wXVcUPzvcDTVzLpWJzeqP
The master refuses any peer whose session_askNewId says nothing about the
formats it speaks -- that is how it recognises a build too old to answer.
PlayerSessionBuilder still sent only its name, so every score-player was
refused by its own version. The Capabilities include had been added without
the write that was supposed to use it.

PlayerSessionBuilder is exported so a test can reach it; nothing outside the
plug-in could construct one before, which is why this went unnoticed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011wXVcUPzvcDTVzLpWJzeqP
Mirroring every interval's position had no visible effect. An interval's
progress is drawn by ScenarioPresenter::on_intervalExecutionTimer, which runs
off the document's execution timer, and that timer is started only by playing
here -- which a terminal never does. The values arrived and nothing moved.

The global clock worked because it goes through another path entirely
(execution_time() falls back to the mirrored position and the transport widget
polls it), so only half the display was wired.

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

/device/value on the host and /exec/position on the terminal deserialized
straight from the message. A QDataStream throws on a truncated or garbled
frame -- a length prefix off the wire is used to resize a vector -- and these
run inside Qt slots, so the exception unwound through the event loop and
ended the process. The command path already had this; the two newer handlers
did not.

/exec/position also trusted its own count, so a garbage length spun the
terminal for as long as the number said. It now stops when the payload does.

The guard also marks the read as untrusted, so a failed delimiter check
throws instead of breaking into the debugger. applyRemoteCommand does the
same: it had the catch, but on a debug build the breakpoint got there first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011wXVcUPzvcDTVzLpWJzeqP
rapidjson checks types with assertions, and release builds compile those out:
GetString() on a number reads the union as a pointer and a length. The
handlers checked HasMember almost everywhere and Is<Type> almost nowhere, so
one field of the wrong type was a wild read -- on the host, in the RPC that
answers object.state, and on the terminal in the response callbacks, which
have no try/catch at all.

WireJson.hpp answers "not there" instead, and the paths a peer sends are
checked for shape before deserializing rather than caught after: nothing can
be caught, since the failure is an assertion that is not there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011wXVcUPzvcDTVzLpWJzeqP
applyState took the id out of the peer's reply while re-pointing the rack
slots with the id it had asked about. A reply naming a different process --
another build, a corrupted frame -- inserted one process and left the slots
referring to another, which is the invariant ScenarioValidityChecker asserts
on the very next command: the empty-slot bug again, through a bad message.

The function is exposed as applyRemoteProcessState so a test can drive it;
the test replaces a real process's answer with a foreign id and checks the
document is left alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011wXVcUPzvcDTVzLpWJzeqP
The poll was there because playPercentageChanged almost never fires, and I
took that to mean the model had no usable notification. It does:
IntervalDurations declares positionChanged "Always fire" right above
playPercentageChanged "Rate-limited" -- the latter exists for the duration
widget. Connecting to the wrong one cost a timer that walked every QObject in
the document twenty times a second, forever, whether or not a peer was
listening or anything had moved.

Every interval is now subscribed to positionChanged and executingChanged, and
scenarios are watched so intervals created later are picked up too. What is
sent is coalesced to the rate score itself redraws at, since the executor
moves an interval once per audio buffer -- far more often than anyone can look
at it. Idle costs nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011wXVcUPzvcDTVzLpWJzeqP
The client-side mirror was covered; the host-side reporting was not, and
could be removed entirely without a failure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011wXVcUPzvcDTVzLpWJzeqP
Code review rejects multi-paragraph explanations of why a change was made;
that belongs in the commit message. First pass over the worst offenders.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011wXVcUPzvcDTVzLpWJzeqP
Down to short what-comments plus API doc lines. The reasoning behind each
change is in its commit message.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011wXVcUPzvcDTVzLpWJzeqP
A terminal showed devices with no children. LoadDevice replicates the node as
it was when the command was made, and every peer then calls
createDeviceFromNode, which refreshes and discovers the children locally -- so
a peer that builds no device keeps an empty one. Anything found later, by
OSCQuery or by learning, was never sent at all.

The host now broadcasts a device's tree when it changes, coalesced, and peers
put it in place of theirs. Pushed rather than asked for: the machine that has
the device is the only one that knows.

Devices already in the document at join were never affected -- they arrive in
the snapshot -- which is why this looked like a regression.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011wXVcUPzvcDTVzLpWJzeqP
RemoteEnvironment::resolve answered "nowhere" for every scheme, which is
the honest answer for a file on the other machine and the wrong one for
the media cache: it is content-addressed and each machine keeps its own
copy, so <CACHE>:<entry> names the same bytes on both. That is the whole
point of importing through it.

Answering nowhere for these had a quiet consequence. ChangeAudioFile's
redo is wrapped in a duration check, and the duration comes from a
decoder-database lookup keyed by the resolved path -- so on a terminal
the lookup missed, the duration came out zero, and the file was never
set on the process. No error, no warning: a dropped file simply did
nothing.

Everything else still resolves to nothing here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011wXVcUPzvcDTVzLpWJzeqP
A terminal can only add hardware devices through what the host
enumerates, so an empty list there is the difference between a usable
device and one that cannot be added at all.

Checks every protocol that enumerates anything on this machine, not the
first one found: a wire that carries one protocol's list and drops
another's looks perfectly healthy from a single sample. Also asserts the
protocols offered are the host's, including those this build cannot
construct.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011wXVcUPzvcDTVzLpWJzeqP
Adding a device is the one thing a terminal does that only the other
machine can carry out, so "it appeared in my tree" is not the property
worth asserting -- "it arrived over there" is.

Uses a protocol no build has a factory for, which is what a terminal
sees for most of them: the settings are never decoded here, only
carried. This crashed until createDeviceFromNode stopped calling
makeDevice through a null factory.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011wXVcUPzvcDTVzLpWJzeqP
The host broadcasts what its devices report; a terminal shows it.

Two things this is careful about:

  - It is a notification, not a request, and carries its own address for
    that reason. Applying it the way a peer's edit is applied would
    perform it on the host's device, which reports it again, which
    notifies the peer: one mouse would saturate the session.
  - Coalesced per address on the UI timer, last value wins. A device
    reports at its own pace -- a mouse or a meter, hundreds a second --
    and what a peer needs is where the value is now, not every one it
    passed through.

The test lets the session settle before changing a value: a device
appearing announces its tree, and that tree carries the addresses'
values, so a value changed before that flush rides along with it and
proves nothing. Without the settle, removing the broadcast entirely
still passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011wXVcUPzvcDTVzLpWJzeqP
The client installs the document's script sink; the host binds
script.eval and runs it through whatever registered itself as able to.
What comes back is what a console would have printed -- the value, or
the error.

Bound on both of the plugin's rpc channels, since a document that joins
a session later builds a second one.

test_network_remote_script: the script arrives verbatim rather than
paraphrased, a document running its own score keeps its local console,
and a machine with no evaluator says so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011wXVcUPzvcDTVzLpWJzeqP
The score runs on the host, so everything that complains about it is
printed there: a shader that would not compile, a device that would not
open, a file that is not where the document says. A terminal driving
that score saw none of it, which is the half of the console story the
script forwarding did not cover.

The handler is chained rather than replaced, so the console output, the
crash log and the Messages panel all still get what they got before.
Two things it is careful about:

  - Reporting a failure to send would log, which would report a failure
    to send. Guarded, because the machine that must not fall over is the
    one running the score.
  - Batched on the UI timer. A log can burst, and one message per line
    would put the socket to work saying nothing: the test asserts fewer
    batches than lines, and sending per line gives 204 for 204.

Lines arrive marked as the host's -- a message about a missing file is
about the other machine's files. NetworkDocumentPlugin::onHostLog
carries them for whoever has no panel to show them in: a headless
terminal has none, and neither does a test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011wXVcUPzvcDTVzLpWJzeqP
The file was never in the repo. It was written into the build directory
by mistake, and CMake resolved the relative source against the binary
dir, so it compiled and passed here and existed nowhere else -- a fresh
clone would have failed to configure the addon tests, with git reporting
a clean tree throughout.

While recovering it, one assertion was wrong in a way that mattered: the
evaluator is a process-global and both peers share it in the in-process
harness, so recording the code proves only that somebody ran it. An
implementation that evaluated the script on the terminal -- the bug this
test exists to prevent -- recorded the same line and passed. It now
checks which document it ran against.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011wXVcUPzvcDTVzLpWJzeqP
The Qt message handler is process-wide; a session is per document. The
broadcaster installed one itself, so hosting a second document handed
the new broadcaster the previous handler -- which was that same function
-- and the next line logged recursed until the stack ran out. Reachable
from the menu: "Make Server" on a second document, and do_makeServer
logs on entry, so it died almost immediately. Re-hosting one document
had a quieter version: the survivor was orphaned and every Qt message in
the process was swallowed from then on.

There is a hub now. It installs the handler once, chains to whatever was
there, and keeps a mutex-guarded list of broadcasters -- the handler
runs on whichever thread logged, and decoders and devices log while
documents are being torn down, so the old plain static pointer was a
data race on top of the recursion.

Two smaller things from the same review: the pending batch is capped, so
a log that outruns the flush cannot grow without bound and then send one
enormous message; and the re-entrancy guard now covers the send, since
a broken socket reports itself by logging.

test_network_remote_log hosts two documents and logs; the previous
design segfaults on it.

Found by an adversarial review of this branch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011wXVcUPzvcDTVzLpWJzeqP
Two findings from an adversarial review of this branch.

The wire guard covered less than it looked like it did. On the device
tree it wrapped only the outer unwrap, leaving the part that matters --
where a peer's bytes reach the device and protocol deserializers --
outside it; the same was true of the settings in an enumerate answer.
Those deserializers are written for our own save files and assert their
way through anything else, and rapidjson's assertions are compiled out
in release, so a missing member is a wild read rather than a stop. An
older or differently-built peer is enough to produce one.

RPC answers are now guarded centrally in resolve() rather than at each
call site: what a caller does with an answer is deserialize it, and the
asking direction already worked this way. That covers the third site,
where a peer's answer is handed to a process factory.

Separately: the register of objects waiting for their state is one per
process, and documents are many. Draining it wholesale meant one
document asking its own peer about another document's processes, and
applying the answer with a context belonging to a document that may
close first. A document now takes only its own and leaves the rest.

test_network_standin_scope fails when the drain takes everything.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011wXVcUPzvcDTVzLpWJzeqP
A performer runs the score itself: it binds none of the observation
channels, so every position, value, tree and log line sent to it was
bandwidth it discarded, a "not handled" line in its console, and one
serialisation per client on the host. They go to terminals now.
Command replication still goes to everyone, which is the point of it.

The same question asked once more: with no terminal connected there is
nothing to send at all, so the flushes drop their pending state instead
of building messages nobody receives. A hosted session with no peers now
does no observation work.

And the value channel batches, as the transport already did. Coalescing
bounds how often an address is sent and says nothing about how many
messages that is: a control surface moving a few hundred parameters was
a few hundred frames per flush, per peer.

What the new test does not assert is the message count -- the socket and
session types are not exported from the plug-in, so a test cannot count
frames, and arrival alone would pass against a per-address encoding
since that decodes as a batch of one. It pins the batch encoding: a
hundred addresses in one payload, decoded back in full and in order.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011wXVcUPzvcDTVzLpWJzeqP
A request's params and an answer's result are already JSON: they were
parsed into a document and written out again, producing identical bytes
at the cost of a full parse and two more buffers the size of the
payload. For a file read near the inline limit that is ~10 MB parsed and
copied twice for nothing, on a browser's single thread, next to the
base64 that made it 10 MB in the first place.

They are spliced now. What remains is a validity check, so a corrupt
body becomes a null rather than a malformed envelope around it -- and
that check no longer builds a document either, since the answer it wants
is one bit.

Found by an adversarial review of this branch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011wXVcUPzvcDTVzLpWJzeqP
A browser served over https cannot open a plain WebSocket to anything
but itself -- it refuses outright, and Qt reports it as "Unsupported
WebSocket scheme: ws". Which meant the wasm terminal worked from this
machine and nowhere else: over a VPN or a LAN, the page loaded and the
session could not be joined.

The host now listens twice when it has a certificate to offer: the plain
port as before, and the next free one above it with TLS. Both are
served, so an ordinary peer is unaffected and nothing has to decide
which the session "really" runs on. The certificate is the pair the wasm
build is already served from (SCORE_NETWORK_CERT_DIR to point
elsewhere): a peer that accepted the page has already accepted this.

The address may now carry its own scheme -- wss://host:port -- because
only the peer knows how it reached the page, and the split on ':' would
otherwise have taken "wss" for the host.

Verified over tailscale: page and session both from 100.x, the terminal
joins and the host sees it. A browser has no TLS to offer, so all of the
server-side code is compiled out there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011wXVcUPzvcDTVzLpWJzeqP
A secure socket that never opens is almost always a certificate the
browser will not take on trust, and there is no way to accept one from a
socket: the prompt exists only for a page. So the failure was silent --
the page loaded, the session never appeared, and nothing said why.

The socket now reports a connection that never opened, carrying the
address it tried, and the join path shows it. When the address was a
secure one, the dialog offers to open the same host and port as a page,
which is what shows the certificate warning and records the exception
the socket will then be allowed to use. Opened from a click, so the
browser does not treat it as a popup.

Shown, not exec'd: a browser has no nested event loop to run a modal in,
and asking for one throws out of the wasm runtime -- which is how the
first version of this failed.

Only a connection that never opened is reported this way; a session that
drops later is a different problem with its own handling.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011wXVcUPzvcDTVzLpWJzeqP
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