OM-M1.3 (protocol half): a persistent broadcast subscription - #20
Merged
Conversation
The transport's three inbound hooks are all one-shot and scoped to a trigger:
request() correlates one reply, await_broadcast() waits for one push, collect()
gathers for a fixed number of seconds. A message no waiter expects is dropped at
debug level. A push-fed cache needs the opposite - every decoded message, for the
life of the connection - so add_listener() provides it.
* Transport.add_listener / remove_listener, with QuadCortex passing both
through so the layer above never reaches into _t.
* Listeners are notified first and consume nothing: collectors and waiters
behave exactly as they do with no listener registered.
* Listeners run on the RX thread, so the RX rules cover them. One that raises
is logged and skipped; its peers and the message's waiter lose nothing.
* request, await_broadcast and collect now refuse to run on the RX thread.
That is what makes "a listener never reads from the device" enforced rather
than requested, and it can refuse nothing that ever worked: the RX thread is
the thread that delivers replies, so a wait from inside it only times out.
* protocol.connect(before_handshake=...) calls back with the started transport
before the handshake, the only moment early enough to hear its state burst.
Measured on the unit (d14e / CorOS 4.0.1) and recorded in protocol.md: connect()
returns at 2.0 s, the ModelRepo lands at 4.9 s and the seed preset at 10.1 s -
474 messages of 24 types by 15 s. A listener attached to the client connect()
hands back has already missed the burst, which is why the hook exists.
Offline tests cover the push nobody asked for, the reply a listener must not
steal, the ordering a cache depends on, a raising listener, the refused read, and
removal. tests/hardware/test_broadcast_listener.py proves it on the unit; it only
listens, so it writes nothing and has nothing to restore (ADR-0005).
ADR-0008 records why listeners run on the RX thread rather than behind a queue,
and why the refusal is enforced instead of documented.
Protocol-layer half of #11. The model-side cache is the other half and is not
here.
Fixed:
* add_listener now says the message is not a copy. It is the same object the
next listener and the waiter receive, so a listener that tidies it in place
changes what they see - and the first consumer is a cache that merges fields
out of partial pushes, which is exactly the code most likely to want to.
* The refusal's error text said the call "can only ever time out". True of
request and await_broadcast, wrong for collect, which would return empty
having stalled the link for its full duration. Corrected in the message, the
docstring, ADR-0008 and the changelog.
* remove_listener documents what removal-by-equality opens: the same callable
registered twice is called twice and needs two removals, and a listener class
with __eq__ can have an equal-but-different registration removed.
* The _lock comment still read "guards _pending / _ids (state only)" and had
been understating for a while.
* test_before_handshake's "runs once, not once per handshake attempt" was
vacuous - the fake handshake succeeded first try, so one attempt happened. Now
a separate test drives three attempts, and it fails if the hook moves inside
session.connect's retry loop (verified by moving it).
The hardware suite's burst recorder was asserting on "everything since connect",
not on the burst: it never stopped recording, so the tally held the tempo stream
and every other test's traffic, and "the seed preset is in there" was true only
because of alphabetical file order. Now the connection fixture waits for the burst
to finish and stops the recorder before the first test runs, so the recording is
the burst whatever order the suite runs in.
That wait costs about 8 s once and buys more than it costs: connect() returns
about 3 s before the unit starts streaming several hundred messages, so without it
every latency measurement in the suite is taken on a link still answering the
handshake. Suite wall time is unchanged, because the burst test used to do this
waiting itself.
The stop cannot be checked with a unit attached - the hardware test reads the
recording afterwards and its assertions are floors, which contamination satisfies
too - so tests/test_handshake_burst_recorder.py pins it offline, the way
test_scene_echo_predicates.py pins the echo predicates.
Declined, with reasons:
* Skipping the listener snapshot's lock when no listeners are registered. Two
uncontended lock operations at a peak of about 80 messages/s is not a cost
worth paying for a GIL-dependent fast path and the comment explaining why the
race is benign.
* Forwarding before_handshake through the model's connect(). The model half of
#11 is being written separately and will need it in the file it is already
editing; adding it here only makes a conflict.
jonathanstokes
marked this pull request as ready for review
August 12, 2026 23:15
The re-review mutation-tested the previous round's fix rather than reading it,
which found the hole below and two claims that were the wrong way round.
* A listener raising outside Exception killed the RX thread. _notify_listeners
caught Exception, so pytest.fail() and sys.exit() - both ordinary things for
caller code to do, and BaseException subclasses - went through it, out of
_handle_message, and out of the read loop. What a caller saw next was a
TimeoutError with device_lost unset: the connection dead and nothing saying
why. Listeners are the first arbitrary caller code to run on that thread, and
"the RX thread never dies" is absolute, so this one site catches
BaseException. The new test fails against the narrow catch (verified).
* The coverage split for the burst recorder was documented backwards in two
places. The hardware burst test covers the WIRING: its `closed` and
`settled_in` assertions are the only ones that are not floors, so they are
what fails if the fixture stops waiting for the burst. Both files now say
that, and say not to delete those lines as redundant. The offline pin covers
the stopping itself, which nothing on hardware can see.
* test_the_recorder_is_safe_to_call_from_more_than_one_thread claimed to prove
the lock covers both sides. list.append and list() are atomic under the GIL,
so it passes with no lock at all - the same vacuous shape the first review
caught, in the commit that fixed it. Renamed to what it does prove, with the
limit written down.
* HandshakeBurst's docstring still said removing a listener from inside a
listener is safe by contract, which is true but no longer describes this class
- and it invited the natural next edit (close as soon as the sentinel lands,
from inside __call__) which deadlocks permanently on the non-reentrant lock.
Replaced with a warning on close() saying where the stop must not go.
* record_until copied the whole recording every 100 ms to test one membership.
Scans in place now, so the poll stops contending with the RX thread at its
busiest.
* STEERING's change-log entry was missing the new offline test file.
Declined: pinning the fixture WIRING offline as well, via the fixture's
__wrapped__ and a fake connect. The wiring is already covered where it fails
loudly - see the second point above - and reaching into pytest fixture internals
to cover it twice buys less than it costs to read.
PR #19 landed first and took ADR-0008 for the generator floor, so the listener record is renumbered ADR-0009 - in ADR.md, and in every reference to it (transport.py, CLAUDE.md, STEERING.md, architecture.md, changelog.md). The two review commits on this branch predate the renumber and still say 0008; STEERING's change-log entry says so rather than leaving a reader to work it out. Both change-log entries and both ADR table rows are kept. Nothing else conflicted: main's changes are the build/gencode guard and its docs, which touch no file this branch changes for a shared reason. Offline suite after the merge, including main's new packaging tests: 529 passed, 1 skipped.
jonathanstokes
added a commit
that referenced
this pull request
Aug 13, 2026
Two text conflicts and one substantive one. ADR numbering: main took 0008 (grpcio-tools floor) and 0009 (persistent listeners), so this branch's record renumbers to ADR-0010. The lifecycle is append-only, and theirs landed first. Every reference that meant this branch's ADR moved with it; the grpcio ones are left alone. The substantive conflict is that PR #20 shipped exactly the hook this branch's harness needed. state_snapshot._Tap monkey-patched transport._dispatch, reaching past the public surface and unhookable out of order; it now subscribes through Transport.add_listener (ADR-0009). Verified the two are equivalent rather than assuming it: run side by side against the same 14-second window they saw the identical 478 messages, the same 23 types, in the same order. docs/capture.md's listener recipe monkey-patched _dispatch too, and it is the document that teaches this. Rewritten onto add_listener, with the two rules that come with a listener - do not block, do not read from the device - both of which the transport now enforces. That comparison turned up a real defect. A capture's type coverage varies run to run, because the handshake's reply burst lands lazily (the File enumeration takes 10-25 s) and a fixed window catches a different tail each time: two runs here saw 23 types and 12. diff() was rendering a type present in one snapshot and absent from the other field by field as "<absent> -> value", which reads exactly like a discovery. It now reports one line in the noise bucket naming what actually happened. Hardware, after the merge: the capture and the write regression both pass through the listener-based harness, and the unit is left in PRESET. 557 offline.
jonathanstokes
added a commit
that referenced
this pull request
Aug 13, 2026
Three change logs conflicted, all in the same place: main's Unreleased section and this branch's both grew an entry after the `Device` one. Kept both, in the order they landed. - changelog.md: the broadcast listener (#20) then the translation groundwork - docs/STEERING.md: the boundary entry sits above TEMPO MODE (#22) and the listener (#20), which is where the newest entry goes in that file - docs/domain-model.md: TEMPO MODE closing then principle 5 being built, which is the order that file reads in
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Part of #11 (M1 Epic #8). This is only the protocol-layer half of that story. The model-side cache is being written separately and is not here.
The gap
The unit talks without being asked. Turn a knob on its touchscreen, recall a preset, let the metronome run, and it pushes messages saying so.
The transport had three ways to hear inbound messages, and all three are one-shot and tied to something the caller just did:
request()waits for the reply to one message it sent.await_broadcast()fires a trigger and waits for one push.collect()fires a trigger and gathers for a fixed number of seconds.Anything else reaches
_dispatch, matches no waiter, and is dropped with a debug log line. So nothing could watch the link for the life of a connection, which is exactly what a cache fed by the unit's own pushes needs.What this adds
add_listener(fn)registersfnto be called with every decoded message until it is removed:Nothing existing changes behaviour. A listener consumes nothing: it is notified first, and the message then reaches every collector and waiter exactly as it would have with no listener registered.
QuadCortexpasses both methods through so the layer above never has to reach into a private attribute.A listener runs on the thread that reads from the USB device, and that thread must never block and never die. Two consequences, both handled:
request,await_broadcastandcollectnow raiseRuntimeErrorwhen called from the read thread. This refuses nothing that ever worked: the read thread is the one that delivers replies, so a wait from inside it can never be satisfied, with the whole connection stalled behind it for as long as it waits. A listener applies what a push carries and notes what needs re-reading; the caller's thread does the re-reading. The rule was already in the design doc (section 9); this makes it enforced rather than requested, and the reason is inadd_listener's docstring.sendis deliberately not refused. It is fire-and-forget and cannot deadlock, though a listener that writes owns the delay it adds.The second addition, and why it is needed
protocol.connect(before_handshake=...)calls back with the started transport before the connect handshake runs.The handshake is what makes the unit start pushing state, and it answers with a burst of nearly everything it knows. Measured on the unit for this change:
connect()returned, with 2 messages seenSo a listener registered on the client
connect()hands back is about 8 seconds too late for the preset. Without this hook, anything wanting the burst would have to assemble the layers by hand and reimplement the handshake retry logic. It is called once, before the first handshake attempt, and a failure in it releases the device like any other bring-up failure.Tests
Offline, against the fake HID device and fake transport:
request()RuntimeErrorrather than a timeout, and the link still works afterwardstests/test_transport.py::test_collect_gathers_every_matching_push_without_consuming_themnow builds a real (unstarted)Transportinstead of a half-initialised one made with__new__.On the unit,
tests/hardware/test_broadcast_listener.py(5 tests): the pre-handshake listener sees the burst, a live listener hears the tempo stream nobody asked for, a listener does not takeversion()'s reply, removal stops delivery while a second listener proves the unit was still pushing, and the refused read is refused on a real read loop.That file only listens. It sends no write, so there is nothing to snapshot and nothing to restore - it meets ADR-0005's contract rather than changing it.
The suite's connection fixture records the burst on every run, because a listener cannot be attached on demand later. It then waits for the burst to finish before handing the connection to the first test and stops the recorder there, so the recording is the burst itself rather than the traffic other tests have provoked since. That wait costs about 8 seconds once and buys more than it costs:
connect()returns about 3 seconds before the unit starts streaming several hundred messages, so without it every latency measurement in the suite would be taken on a link still answering the handshake. Suite wall time is unchanged, because the burst test used to do that waiting itself.The stop cannot be checked with a unit attached, since the hardware test reads the recording afterwards and its assertions are floors that contamination satisfies too. So
tests/test_handshake_burst_recorder.pypins it offline, the waytest_scene_echo_predicates.pypins the echo predicates.Results
Offline suite, no unit attached:
Whole hardware suite on the unit (d14e / CorOS 4.0.1), including the existing write-echo tests:
The skip is pre-existing: a block-bypass measurement that needs a preset whose first block already carries a stored bypass entry.
Documentation
CLAUDE.mdcarries the rule for future work.STEERING.mdgets the change-log entry and the ADR row.architecture.md: the transport section, the message-flow diagram, the hook-choosing table, and thesession.pysection.protocol.md: the re-measured connect burst, including the fact thatconnect()returns before it arrives.api.mdandchangelog.mdfor people using the library.No version bump and no release.
Review
Reviewed twice, the second time over the full diff because the first round's fix commit had not been looked at. No merge blockers either time. Ten findings closed in
68d358e, seven more inb22a9ee.Fixed:
add_listenernow says the message is not a copy. It is the same object the next listener and the waiter receive, so a listener that tidies it in place changes what they see. The first consumer is a cache that merges fields out of partial pushes, which is exactly the code most likely to want to.requestandawait_broadcast, wrong forcollect, which would return empty having stalled the link for its full duration. Corrected in the message, the docstring, the ADR and the changelog.remove_listenerdocuments what removal-by-equality opens: the same callable registered twice is called twice and needs two removals, and a listener class defining__eq__can have an equal-but-different registration removed._lockcomment still read "guards_pending/_ids(state only)", which had been understating for a while.test_before_handshake's "runs once, not once per handshake attempt" was vacuous - the fake handshake succeeded first try, so one attempt happened either way. A separate test now drives three attempts, and it fails if the hook moves insideconnect's retry loop. Verified by moving it.Answered, not changed:
before_handshakethrough the model'sconnect(). Deliberate boundary. The model half of OM-M1.3: The model stays current with the unit without asking twice #11 is being written separately and will need it in a file it is already editing; adding it here only makes a conflict.Second round
The second pass mutation-tested the first round's fix instead of reading it - it copied the recorder into a scratch directory, broke one thing at a time, and checked whether the new offline test noticed. That found one real hole and two claims that were the wrong way round.
Exceptionkilled the RX thread._notify_listenerscaughtException, sopytest.fail()andsys.exit()- both ordinary things for caller code to do, bothBaseExceptionsubclasses - went straight through it and out of the read loop. What a caller saw next was aTimeoutErrorwithdevice_lostunset: the connection dead and nothing saying why. Listeners are the first arbitrary caller code to run on that thread and "the RX thread never dies" is absolute, so that one site now catchesBaseException, with the reason written down. The new test fails against the narrow catch.closedandsettled_inassertions are the only ones there that are not floors, so they are what fails if the fixture stops waiting for the burst. Both files now say so, and say not to delete those lines as redundant. The offline pin covers the stopping itself, which nothing on hardware can see.test_the_recorder_is_safe_to_call_from_more_than_one_threadcould not fail for the reason it claimed -list.appendandlist()are atomic under the GIL, so it passes with no lock at all. The same vacuous shape the first round caught, in the commit that fixed it. Renamed to what it does prove, with the limit written down.HandshakeBurst's docstring still said self-removal from inside a listener is safe by contract. True, but it no longer describes this class, and it invited the natural next edit - close as soon as the sentinel lands, from inside__call__- which deadlocks permanently on the non-reentrant lock. Replaced with a warning onclose()saying where the stop must not go.record_untilcopied the whole recording every 100 ms to test one membership. It scans in place now, so the poll stops contending with the RX thread at its busiest moment.Declined: pinning the fixture wiring offline as well, through the fixture's
__wrapped__and a fakeconnect. The wiring is already covered where it fails loudly, and reaching into pytest's fixture internals to cover it twice buys less than it costs to read.Both rounds independently confirmed the two findings declined in the first round still stand.
Merged main in
PR #19 landed while this was in review and took ADR-0008 for the generator floor, so the listener record here is renumbered ADR-0009 - in
ADR.mdand in every reference to it. The two review commits on this branch predate the renumber and still say 0008; STEERING's change-log entry says so rather than leaving a reader to work it out.Both change-log entries and both ADR table rows are kept. Nothing else conflicted. Test numbers above are from after the merge, so they include main's new packaging tests, and the hardware suite was re-run on the merged tree.
Not in this change
before_handshakethroughpyquadcortex.connect(). The model half of OM-M1.3: The model stays current with the unit without asking twice #11 needs it and is being written separately.tests/hardware/test_write_echo.pystill taps_dispatchby monkeypatching it, which predates this and could now be an ordinary listener. Left alone on purpose: it is a working measurement harness, and an offline test imports it.