Skip to content

Add the CBOR snapshot channel, expand the control model, ship MetersApp, and take the discovery port exclusively - #2

Merged
gotwalt merged 5 commits into
mainfrom
feat/cbor-channel-control-model-and-meters-app
Aug 22, 2026
Merged

Add the CBOR snapshot channel, expand the control model, ship MetersApp, and take the discovery port exclusively#2
gotwalt merged 5 commits into
mainfrom
feat/cbor-channel-control-model-and-meters-app

Conversation

@gotwalt

@gotwalt gotwalt commented Aug 22, 2026

Copy link
Copy Markdown
Owner

Lands the work that had accumulated in the working tree — five themed commits, reviewable in order.

What's here

1. The CBOR state-snapshot channel — the device speaks a native CBOR control channel alongside MIDI3. This implements exactly one route on it: the state-dump snapshot reporting the current bank and rig index, which MIDI3 never volunteers. The channel's wider management grammar is documented but deliberately not implemented.

Reading it needs a second, short-lived TCP connection, which surfaced a device constraint worth encoding: it refuses to greet — or resets — a session opened too soon after a previous socket closed. CONNECTION_COOLDOWN is now a shared spec constant, so all three implementations space the CBOR fetch and the MIDI3 session identically.

2. Control model, parameter registry and state tree — three related additions:

  • Rig navigation is momentary, and the release is not optional. A press alone loads the target rig, but without the matching release the device abandons the change and reloads the previous rig about two seconds later — which reads as the device spontaneously undoing the navigation. Control now renders up/down/load_slot as a press immediately followed by its release.
  • Registry: volume parameters (main/monitor/headphone plus the read-only master), the bank-preview extended-string page, and effect-type categories. Type values are allocated in 16-value blocks, one per group of the device's type knob, so a UI can name a slot's family without knowing the individual type.
  • State tree: tracks the device's own position reports and the bank name preview, so a client can follow rig changes made anywhere — including at the front panel.

3. MetersApp — the meters terminal example rebuilt as a native SwiftUI macOS app. The high-rate lane is deliberately not published: status events fold into a private frame that a 33 ms tick publishes once, so a 300 Hz stream doesn't drive 300 view updates a second. Every restart bumps an epoch counter that captured tasks check before writing, so a connection still unwinding can't write into its successor's state.

4. Spec 0.5.0 — wires the new surfaces into each package's public API and bumps the shared version.

5. Exclusive ownership of the discovery port — the one behavioural fix, and worth reading closely.

The discovery bug

Discovery bound UDP 5727 with SO_REUSEPORT, sharing the port with anything already listening. That's the wrong default here. The device answers a poll only on 5727 — it ignores the poll's source port — and when several sockets are bound to one UDP port the kernel delivers each datagram to exactly one of them. A second listener doesn't see a copy of the reply; it takes it.

In practice: with Kemper's Rig Manager running, discovery returned no devices while the network and the Profiler were both healthy, and the app reported "No Profiler found on the network" — sending you to debug the network instead of the other program holding the port.

The fix is to bind without SO_REUSEADDR or SO_REUSEPORT. Verified on macOS in both directions:

  • Port free → the exclusive bind succeeds and every subsequent bind is refused, including SO_REUSEPORT. Replies can't be stolen mid-session.
  • Port held → the bind fails EADDRINUSE, reported as a port conflict naming the likely holder rather than worked around.

Each implementation gains a DiscoveryPort handle, acquired before a session and held for its lifetime, pollable repeatedly so a long-running client can notice devices appearing and disappearing without releasing the port. discover/find_first still acquire-poll-release, so one-shot callers are unchanged.

I checked whether polling from an ephemeral source port could sidestep the clash entirely — it can't, the device replies only to 5727 (0 replies from an ephemeral port on an otherwise-idle machine, verified twice). Contention is unavoidable; exclusivity is the only real fix.

Swift needed a transport change to allow this at all. It used one NWListener plus one NWConnection per target, all co-bound to 5727 — a design that requires allowLocalEndpointReuse, which is precisely the SO_REUSEPORT causing the bug. Network.framework offers no way to decline to share, so discovery is now a single exclusively-bound BSD socket. That incidentally removes a latent mislabel: recvfrom reports the true source address, where the sender connection reported the broadcast address it was constructed with, so a reply could have been recorded as a device at 255.255.255.255.

Verification

Against a real Profiler ("Mini", 192.168.7.220):

  • App in auto mode: acquired UDP 5727 exclusivelydiscovered 192.168.7.220 Mini → connected, rig streaming. lsof confirms it holds the port.
  • App under contention: fails in 72 ms with the actionable message instead of a 3-second empty window, then recovers automatically once the port frees.
  • Reliability: 8/8 clean Swift runs; Python and Rust confirmed live.
  • The failure was reproduced on demand with a stand-in process holding the port (3/3 runs returned 0 replies while the stand-in logged the stolen packets).

Full CI-equivalent run is green locally: codegen drift clean, spec version consistent across all three generated modules, Rust (fmt + clippy -D warnings + 101 tests), Python (ruff check + format + 366 passed), Swift (format lint --strict + 95 tests + both products).

Notes for review

  • Two formatting-only fixes were folded in (rust/src/control.rs, python/src/libkp/state.py); both predated this work and would have failed CI on arrival.
  • Manual mode doesn't claim the discovery port — it connects straight over TCP and never polls, so requiring UDP 5727 there would block running alongside Rig Manager with a known IP for no benefit. Easy to make unconditional if you'd rather.
  • Two copies of MetersApp can no longer run at once; the second gets the port-conflict error. Inherent to exclusivity, but worth knowing.

🤖 Generated with Claude Code

https://claude.ai/code/session_013XSn94RpApv9AYMQABGM8S

gotwalt and others added 5 commits August 22, 2026 16:12
The device speaks a native CBOR control channel alongside MIDI3. This
implements exactly one route on it: the state-dump snapshot that reports
the current bank and rig index. The channel's wider management grammar is
documented but deliberately not implemented.

The snapshot matters because the device never volunteers its rig position
over MIDI3, so a client that wants to show where the device actually is
has to ask for it once at connect.

Reading it needs a second, short-lived TCP connection, which surfaced a
device constraint worth encoding: it refuses to greet — or resets — a
session opened too soon after a previous socket closed. `CONNECTION_COOLDOWN`
(from the shared spec) is the minimum quiet gap, so every implementation
spaces the CBOR fetch and the MIDI3 session identically and never overlaps
them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013XSn94RpApv9AYMQABGM8S
Three related additions across the spec and all three implementations.

Rig navigation. CC 48/49 and CC 50-54 are momentary buttons, and the
release is not optional: a press alone loads the target rig, but without
the matching release the device abandons the change and reloads the
previous rig about two seconds later — which reads as the device
spontaneously undoing the navigation. `Control` now renders up, down and
load_slot as a press immediately followed by its release in one message.

Parameter registry. Adds the volume parameters (main/monitor/headphone,
plus the read-only master), the bank-preview extended-string page, and
effect-type categories. Appendix B allocates type values in 16-value
blocks, one per group of the device's type knob, so the registry carries
those blocks as a second lookup and a UI can name a slot's family without
knowing the individual type. Declaring ranges rather than rows keeps the
spec small and lets the generator expand them.

State tree. Tracks the device's own position reports and the bank name
preview, so a client can follow rig changes made anywhere — including at
the front panel — rather than only the ones it made itself.

Vectors and generated output are regenerated to match.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013XSn94RpApv9AYMQABGM8S
The `meters` terminal example rebuilt as a macOS app: rig header, amp and
cabinet, the eight effect blocks with their on/off state and type, the
tuner strobe and the level bars. Unlike the terminal example it is not
read-only — clicking a signal-chain block toggles that effect — but
everything else it sends is a value request.

Two things shape the design. The high-rate lane is deliberately not
published: status events fold into a private frame and a 33 ms tick
publishes it once, so a 300 Hz stream does not drive 300 view updates a
second. And every restart bumps an epoch counter that captured tasks check
before writing, so a connection still unwinding can never write into the
state of its successor.

Rig position comes from the device wherever possible — seeded from the
CBOR snapshot at connect, then kept live by the reports the device sends
on every rig change — falling back to matching the loaded rig name against
the bank preview. The app's own optimistic slot hint clears when a change
arrives that it did not make.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013XSn94RpApv9AYMQABGM8S
Wires the new CBOR and registry surfaces into each package's public API,
updates the overview, credits and per-language READMEs to describe them,
and bumps the shared spec version — every implementation asserts it in its
conformance suite and CI cross-checks that all three agree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013XSn94RpApv9AYMQABGM8S
Discovery bound UDP 5727 with SO_REUSEPORT, so it shared the port happily
with anything else already listening. That is the wrong default here. The
device answers a poll only on 5727 — it ignores the poll's source port —
and when several sockets are bound to one UDP port the kernel delivers
each datagram to exactly one of them. A second listener does not see a
copy of the reply; it takes it.

The practical failure: with Kemper's Rig Manager running, discovery
returned no devices while the network and the Profiler were both healthy,
and the app reported "No Profiler found on the network" — which sends you
looking at the network rather than at the other program holding the port.

So bind without SO_REUSEADDR or SO_REUSEPORT. If the port is free the bind
succeeds and nothing else can take it while the socket is open, so replies
cannot be stolen mid-session. If it is already held the bind fails with
EADDRINUSE, which is reported as a port conflict naming the likely holder
rather than being worked around.

Each implementation gains a `DiscoveryPort` handle, acquired before a
session and held for its lifetime, that can be polled repeatedly — a
long-running client re-polls to notice devices appearing and disappearing
without ever letting go of the port. `discover` and `find_first` still
acquire, poll once and release, so one-shot callers are unchanged.

Swift needed a transport change to allow this at all: it used one
NWListener plus one NWConnection per target, all co-bound to 5727, which
requires allowLocalEndpointReuse — precisely the SO_REUSEPORT that causes
the bug. Network.framework cannot decline to share a port, so discovery is
now a single exclusively-bound BSD socket. That also removes a latent
mislabel, since recvfrom reports the true source address where the sender
connection reported the broadcast address it was created with.

MetersApp holds the port across its session and surfaces the conflict
message. Manual mode connects straight over TCP and never polls, so it
does not claim the port.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013XSn94RpApv9AYMQABGM8S
@gotwalt
gotwalt merged commit 2c39352 into main Aug 22, 2026
5 checks passed
gotwalt added a commit that referenced this pull request Aug 24, 2026
…d-meters-app

Add the CBOR snapshot channel, expand the control model, ship MetersApp, and take the discovery port exclusively
@gotwalt
gotwalt deleted the feat/cbor-channel-control-model-and-meters-app branch August 24, 2026 19:20
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