← All docs
Ideas

Dispatch semantics — fan-out, ordering, parallel vs. sequential

The user's question, restated

> Can an input trigger multiple outputs at once, or must they always be in sequence? For something like DMX, we might want it in the chain to happen simultaneously with another node, or wait until after, or wait until the DMX finishes to go to the next node.

Today's behaviour

Looking at src/features/mapping/useMappingRouter.ts:181-206:

for (const e of graph.edges) {
  const src = sourceById.get(e.source);
  if (!src) continue;
  const sink = graph.nodes.find((n) => n.id === e.target);
  if (!sink) continue;
  // …
  if (sink.kind === "output") {
    fireOscOutput(sink.id, sink.spec, !!evt.released, transformed);
  } else if (sink.kind === "resolume-out") {
    fireResolumeOutput(sink.id, sink.spec, !!evt.released, transformed);
  }
}

fireOscOutput / fireResolumeOutput return Promise<void> but the loop does not await them. So:

  • All sinks are kicked off in edge-array order.
  • Each one's IPC roundtrip happens concurrently from JavaScript's perspective.
  • The send order on the wire is determined by the main process: osc:send and resolume:send are independent IPC channels backed by independent throttles, so OSC and WS can land in either order. Within OSC, node-osc flushes in send order. Within WS, ws.send is FIFO per socket.
In practice this means:
  • ✓ Fan-out works. Press one key → fire 10 OSC outs + 10 Resolume-outs. They all leave XOSC within ~1 ms.
  • ✗ No way to say "send A before B."
  • ✗ No way to say "send A, wait 50 ms, send B."
  • ✗ No way to say "send A, then send B only if A succeeded."
For OSC at performance speeds this rarely matters — fire and forget. For DMX it definitely matters: you frequently want the lighting cue to settle before the audio cue, or vice versa.

Proposed dispatch-mode model (v0.5)

Add an optional dispatch field to MappingEdge:

export interface MappingEdge {
  id: string;
  source: string;
  target: string;
  // NEW (default "parallel" if absent):
  dispatch?: {
    mode: "parallel" | "sequential" | "after-prev";
    // For sequential / after-prev: how long to wait after the previous edge fires.
    delayMs?: number;
    // For after-prev: skip this edge if the previous one's send returned an error.
    requirePrevOk?: boolean;
    // Sequence ordering: edges with the same source share a queue keyed by source-id;
    // ties on order are resolved by edge insertion order.
    order?: number;
  };
}

Modes

  • parallel (default) — current behaviour. All sinks for one input event start simultaneously.
  • sequential — sinks for one input event start in order ascending (then insertion order). The router awaits each sink's send before issuing the next, with optional delayMs between.
  • after-prev — like sequential, but each step also gates on requirePrevOk. If the previous send returned { ok: false }, this edge is skipped.
Crucially: groups are scoped per (source-event). If two unrelated inputs fire at the same time, their dispatch queues don't interleave — each input-event runs its own little pipeline.

Router rewrite sketch

async function dispatchForEvent(evt, sources) {
  // Group edges by source node; within each group, sort by dispatch.order.
  for (const src of sources) {
    const edges = graph.edges
      .filter((e) => e.source === src.id)
      .sort(byDispatchOrder);
    const transformed = applyTransform(evt.value, src.transform);
    let lastOk = true;
    for (const e of edges) {
      const mode = e.dispatch?.mode ?? "parallel";
      if (mode === "after-prev" && e.dispatch?.requirePrevOk && !lastOk) continue;
      if (e.dispatch?.delayMs) await sleep(e.dispatch.delayMs);
      const sink = nodeById.get(e.target);
      const send = fireSink(sink, evt, transformed);
      if (mode === "parallel") { send.catch(() => {}); / fire and forget / }
      else { lastOk = (await send).ok; }
    }
  }
}

This is roughly 40 LoC of change in useMappingRouter.ts plus a UI affordance on edges.

UI affordance

ColorMixEdge already supports custom data; we can add a small badge on the wire showing dispatch order (▶1, ▶2, …) and a different stroke for sequential edges (dashed) vs. parallel (solid). Edge inspector picks parallel | sequential | after-prev, sets order + delay.

DMX-specific concern: "wait until DMX finishes"

DMX universes refresh at most ~44 Hz (one full frame is ~22 ms over a serial DMX line). If we add DMX nodes (see dmx-feasibility.md), "wait until the DMX frame flushes" is a real semantics: you don't want to fire an OSC packet until the lighting state has actually been applied to the universe.

The proposed model handles this with mode: "sequential", delayMs: 25. The DMX sink resolves its promise once the frame is queued (not necessarily transmitted, but that's typically fine — the next 25 ms wait covers the actual transmit).

For full rigor we could add awaitFlush: true on a DMX edge and have the sink's promise resolve only after the universe's outbound buffer empties. Not necessary for v0.5; flag for v0.6.

What this doesn't address

  • Multi-hop chains. Edges still go source-node → sink-node only. If you want sink-A's success to fire sink-B as a separate event, you currently can't. We'd need either:
- A "logic node" that's both a sink (consumes events) and a source (emits onto inputBus); or - Edges that allow sink → sink with a small re-emit shim.

See logic-nodes.md for the proposal — that's a separate v0.5 item.

  • Cross-input synchronisation. "Wait until both MIDI knobs have moved before firing." Niche; not on the v0.5 list.

Migration considerations

  • MappingEdge.dispatch is optional; absent → "parallel" → behaviour is unchanged. Old configs hydrate cleanly.
  • The router rewrite must keep continuous-value semantics intact. Today the throttling is per-stream in the engine, so the renderer just kicks off send calls; sequential mode interacts with that fine — await send resolves once IPC returns, which is before the wire flush, but the engine still throttles.

Open questions

  • Default delayMs for sequential0? Or 1 to make absolutely sure the OS scheduler interleaves?
  • Should order survive copy-paste? (Yes, since edge data including order is structured-cloned in the paste flow at store.ts pasteGraph.)
  • For DMX — do we also need a cue-fade primitive, or does the existing transform/curve cover it? (Probably cue-fade is a sink-side concern; not router.)