This page audits the current routing engine (src/features/mapping/useMappingRouter.ts, src/features/inputs/inputBus.ts, src/features/inputs/useResolumeSubscriptions.ts). Every claim cites a file:line so future agents can verify before changing it.
TL;DR
| Question | Today's answer |
|---|---|
| How long can a chain be? | Effectively unlimited at one hop. XOSC isn't a multi-hop pipeline — it's a one-hop dispatcher: source → sinks. Every "chain" in the UI is source-node → edge → sink-node, only one edge deep. |
| Can sinks chain into other sinks? | No. Sinks (output, resolume-out) don't emit on inputBus. The graph type only allows edges from a SourceNode to a SinkNode — src/store/store.ts:340-347. |
| Can a feedback loop happen? | Yes, externally. A resolume-out that calls set on the same parameter a resolume-in is subscribed to will re-fire that input — Resolume itself broadcasts the change to all subscribers. The router has no echo suppression. See "Loop scenarios" below. |
| What's the maximum fan-out from one event? | No hard cap. The router iterates every edge whose source matches the event (useMappingRouter.ts:189-203). Each match becomes one async send. |
| Is the dispatch ordered or parallel? | Issued sequentially in edge-array order, but each send is async and not awaited — they all start back-to-back without waiting for each to complete. UI doesn't expose any sequencing controls today. |
| Throttle? | Yes, per host:port:address key, not per node. Continuous OSC at electron/osc-engine.ts; continuous Resolume set at electron/resolume-bridge.ts. Discrete events bypass. |
How an event flows today
device → backend (keyboard/MIDI/gamepad/serial) → inputBus.emit(evt)
│
┌────────────┴────────────┐
│ useMappingRouter │
│ subscribe() │
│ • find matching sources│
│ • walk graph.edges │
│ • fan out to sinks │
└────────────┬────────────┘
│
┌────────────────────────────┼────────────────────────────┐
▼ ▼ ▼
fireOscOutput fireResolumeOutput (no other sinks today)
│ │
osc:send IPC resolume:send IPC
│ │
UDP packet ws:JSON frame
resolume-in parameter feedback also enters via inputBus.emit from useResolumeSubscriptions.ts:107 — so the router treats it identically to a keyboard press. That's where loops become possible.
Loop scenarios
Loop 1 — resolume-in → resolume-out writing the same parameter
User wires a resolume-in subscribed to /composition/layers/1/video/opacity to a resolume-out whose set action targets the same path.
What actually happens:
1. User moves the opacity slider in Resolume.
2. Resolume → WS parameter_update → useResolumeSubscriptions.ts:107 → inputBus.emit.
3. Router → fireResolumeOutput → WS set /composition/layers/1/video/opacity = v.
4. Resolume applies the set → broadcasts a fresh parameter_update to every subscriber, including XOSC's own subscription.
5. Goto 2.
Mitigation today: the throttle in electron/resolume-bridge.ts:208-249 coalesces continuous sets per host:port:parameter to one every 25 ms, so the loop runs at ~40 Hz instead of CPU speed. That's enough to bog down the API thread but not enough to lock up XOSC. There is no echo suppression that drops a value Resolume just told us about because we just told Resolume about it.
Loop 2 — round-trip via OSC out → external app → OSC in
Once we ship the inbound OSC listener (see osc-inbound.md), the same loop becomes possible across the OSC boundary: an OSC out fires Resolume → Resolume's OSC out feeds back into XOSC's inbound listener → router fires again.
Loop 3 — resolume-out SET on a parameter another XOSC node subscribes to
Two XOSC nodes on the same composition. Node A's resolume-out writes /composition/layers/1/video/opacity. Node B is a resolume-in subscribed to the same path. A press on a key wired to A → A writes → Resolume → B fires → if B is wired to anything, it fires. If B is wired back to A, see Loop 1.
Why nothing currently catches this
useMappingRouter.ts carries no per-event identity. There's no "trace ID" attached to events that lets a sink say "I caused this; if I see it bounce back within 100 ms, drop it." Every event is treated as fresh.
Concretely, neither inputBus.InputFiredEvent nor ResolumeMessage carries provenance. We'd need:
interface InputFiredEvent {
signature: string;
descriptor: InputDescriptor;
ts: number;
released?: boolean;
value?: number;
// NEW: dispatch lineage. Empty for device-originated events; populated when
// a sink synthesised this event by re-emitting (e.g. for inbound feedback).
causedBy?: { nodeId: string; depth: number };
}
Plus a MAX_DISPATCH_DEPTH (say 8) checked in the router, and an opt-in "echo window" per sink that suppresses inbound events matching its own recent send.
Where chains do break today
Even with single-hop dispatch, three real limits exist:
1. Renderer event-loop saturation. Continuous values from gamepad axes can fire at 60 Hz × N axes; if you wire one axis to twenty sinks, the router does 60 × 20 = 1200 IPC calls per second. Each IPC roundtrip is small (~50 µs in the renderer), so 1200/s = 60 ms of CPU. Tolerable. At ~5000/s the renderer starts dropping React frames. No protection today; we'd add a per-edge throttle setting.
2. Resolume's API thread is single-threaded. docs/resolume/rest-and-websocket.md flags this. The 25 ms continuous throttle is per (host:port:address) — a flow that wires a single slider to N different parameters still produces N simultaneous streams. Resolume can take it to about 10 streams at 30 Hz before it complains.
3. Toggle state is per-sink, never reset. toggleState in useMappingRouter.ts:36 is keyed by sink-node-id. If you copy a sink (Ctrl+C/V), the new sink gets a fresh ID and starts at false. Good. But if you change a sink's input via re-wiring, its toggle state persists from before — so a rebind can land mid-flip. Listed in HANDOFF backlog item #10 already.
Recommended improvements (ranked by leverage)
| # | Change | File | Effort | Notes |
|---|---|---|---|---|
| 1 | Add causedBy lineage to InputFiredEvent and a MAX_DISPATCH_DEPTH=8 cap in useMappingRouter. | inputBus.ts, useMappingRouter.ts | S | Foundation for every other loop-related feature. |
| 2 | Echo-suppression on resolume-in. When the bridge writes to parameter P at time t, suppress incoming parameter_update P for [t, t + 80ms] for nodes whose target host:port:parameter matches. | useResolumeSubscriptions.ts, new resolumeEchoFilter.ts | M | Solves Loop 1 / 3. Window must be configurable; some users want the round-trip. |
| 3 | Per-edge throttle override. Today the throttle is global per stream; allow MappingEdge to carry throttleMs?: number for fine control. | store.ts, useMappingRouter.ts, edge inspector UI | M | Surfaces existing engine capability. |
| 4 | Cycle warning in the UI (not blocker). Detect at edge-add time whether the new edge would create a feedback path through Resolume; show a yellow pill on both endpoints. | store.ts addEdge, new cycleDetect.ts | M | Static graph cycles aren't possible (source→sink only) but logical cycles via Resolume are; we can still detect them by parameter equality. |
| 5 | Toggle state cleanup hook on rebind / sink-input change. | store.ts | XS | HANDOFF backlog #10. |
| 6 | Dispatch-mode per edge: parallel (today's behaviour, default) / sequential (await previous). See dispatch-semantics.md. | router rewrite | M-L | Matters for DMX, see dmx-feasibility.md. |
| 7 | Per-sink "max events / sec" gauge in the inspector. When user wires a 60Hz axis to a sink that's flooded, the gauge turns red. | inspector + router stats | M | Diagnostic, not a fix. |
Concretely: how to size a chain
Given the engine described above, a "chain" in XOSC is at most 2 hops today:
- Hop 1 (always): device → source node → router → sink.
- Hop 2 (only via Resolume's broadcast): Resolume
setfrom sink → Resolumeparameter_update→ resolume-in source.
inputBus.emit), the upper bound becomes MAX_DISPATCH_DEPTH. With depth 8 and 60 Hz device input, the worst-case spike is 8 × 60 = 480 events/sec — easy. The real limit will remain the OSC/WS receiver, not the graph.
Open questions
- Do we want a debug "loop detector" that turns on when an event is suppressed? Useful when a user can't figure out why their slider feels sluggish.
- Should
causedBybe visible in the debug-panel row so users can see the lineage? - Where does serialised config land for echo-suppression windows? Per-node spec, or a top-level "Resolume echo guard" setting in
Settings?