Skip to content
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ Verbs the daemon adds
| `pin_accept` | `<uri>` | `resp 0` | pin_mode first-use only: take the plugin's files as they are now as its pin, after a pin_changed event; the layout is learned again by the next add and the verdict is dropped; -508 when the plugin has no pin, -509 when its files cannot be collected, -516 when its pins_dir pin carries verdict=refused, -902 under pin_mode strict |
| `pin_layout` | `<instance>` | `resp 0 <scheme>:<sha256>` | sent by the daemon to a worker of a format that checks layouts, after it accepted the add of a plugin whose layout pin is still unknown: the layout the worker found after init, which the daemon records in the pin |
| `pin_expect` | `<instance> <scheme>:<sha256>` | `resp 0` | sent by the daemon to a worker of a format that checks layouts, just before the instance's add: the layout pin that add checks after init and before activate; any other reply than resp 0 refuses the add with that code, and the add is never forwarded |
| `chain` | `<chain> <instance> [<instance> ...]` | `resp 0` | the instances, in this order, are one chain, run in one worker of place chain:<chain>: each member's state is saved, it is removed from the worker it was in and replayed in the chain's. A chain already declared takes the new order, and a member left out of it goes back to a worker of its own. A worker that runs chains in one client (clap, lv2-clap) is sent this line, with the members it holds, after the adds of its replay; one that does not (mod-host) never is, and a refusal of it is written to stderr and is no failure. Every instance verb still names one member by its own number. A member that crashes the worker is quarantined to a worker of its own and the chain goes on without it; quarantine_clear puts it back. -518 for an instance no add made, -519 for one named twice, -520 for one in another chain, -521 for members of more than one format, -501 for a plugin that may not share a worker while require_pins is on (every LV2 plugin, a chain of one as well, until a chain's worker is given the union of its members' bundles), -902 for a chain or an instance that is not a number, or fewer than chain_members_min (1) instances |
| `chain` | `<chain> <instance> [<instance> ...]` | `resp 0` | the instances, in this order, are one chain, run in one worker of place chain:<chain>: each member's state is saved, it is removed from the worker it was in and replayed in the chain's. A chain already declared takes the new order, and a member left out of it goes back to a worker of its own. A worker that runs chains in one client (clap, lv2-clap) is sent this line, with the members it holds, after the adds of its replay; one that does not (mod-host) never is, and a refusal of it is written to stderr and is no failure. Every instance verb still names one member by its own number. A member that crashes the worker is quarantined to a worker of its own and the chain goes on without it; quarantine_clear puts it back. -518 for an instance no add made, -519 for one named twice, -520 for one in another chain, -521 for members of more than one format, -501 for a plugin that may not share a worker while require_pins is on (a CLAP plugin whose first-use pin is not in-process; an LV2 member whose bundle is not found, since a chain's worker sees a world narrowed to the union of its members' bundles), -902 for a chain or an instance that is not a number, or fewer than chain_members_min (1) instances |
| `chain_clear` | `<chain>` | `resp 0` | forget the chain: each member goes to a worker of its own; -522 for a chain no chain declared |

A `<state>` is `up` (accepting commands), `starting` (spawned, not yet accepting), `backoff` (dead, waiting to be respawned), `given-up` (the storm bound is spent for its placement).
Expand Down Expand Up @@ -223,7 +223,10 @@ The ledger keeps the latest `track_info` of an instance and replays it after the
<!-- END GENERATED protocol:instance-verbs -->

What each host profile owes every verb a controller sends: mod-host's commands, the two that Zynthian's mod-ui still
names, and the verbs the daemon adds. A verb has a row here before the daemon serves it.
names, and the verbs the daemon adds. A verb has a row here before the daemon serves it. The daemon relays a worker's
verb, an instance verb to the worker that holds the instance and any other to every worker, and passes its answer
back: a required verb (presets, bundles, MIDI learn and CV addressing for the CLAP worker and Zynthian, by the ruling of
2026-10-22) answers -902 only while the worker does.

<!-- BEGIN GENERATED protocol:host-verbs -->
| verb | sent by | lv2-worker | clap-worker | console | zynthian |
Expand Down Expand Up @@ -366,6 +369,7 @@ Events on the feedback port, one NUL-terminated line each:
| `remote_pages_changed` | `<instance>` | relayed from a worker: the plugin changed its remote pages; read them again |
| `remote_page_suggest` | `<instance> <page_id>` | relayed from a worker: the plugin suggests the remote page of that id (a page_id of remote_page_get); a surface may follow it |
| `param_gesture` | `<instance> <symbol> begin \| end` | relayed from a worker: the user took hold of a control of the plugin's own, or let it go; a controller writes no automation of that parameter in between. The values the plugin moves meanwhile, or at any time by itself, are mod-host's param_set feedback lines, relayed as written. A gesture whose worker died, or whose instance was removed, before its end is ended by the daemon, before the worker_died event |
| `param_set` | `<instance> <symbol> <value>` | relayed from a worker: parameter feedback, a value the plugin moved by itself (its automation, an LFO, a preset load, a MIDI map, the user's knob of the plugin's own), as mod-host writes it; a controller updates its control to it. A worker never writes it for a value a controller's param_set gave it, and a worker that writes none changes nothing: the daemon adds no line of its own |
<!-- END GENERATED protocol:events -->

The workers' own feedback
Expand Down
30 changes: 15 additions & 15 deletions docs/design/notes/2026-10-21-chains.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,23 +70,23 @@ cannot be named by `add`: an instance joins a chain only through `chain`.

## LV2 chains under require_pins: the union-narrowed world

While `require_pins` is on, a chain is refused with `PLACEMENT_INVALID` when a member's plugin may not share a worker,
by the rule a pool keeps. Every LV2 plugin falls under it, on the `lv2` row and on the `lv2-clap` row (omx-clap-host's
adapter), because an LV2 pin holds only in a world narrowed to the plugin's own bundle, and a chain's worker is given
the whole `lv2_path`. A chain of one LV2 member is refused too: its worker is a chain's, not an own one, so its world
is not narrowed. So today a chain of LV2 plugins runs only with `require_pins 0`, and CLAP plugins chain as they pool
(under `pin_mode first-use`, only those whose pin says `verdict=in-process`).

Required (operator ruling 2026-10-21, a precondition of removing mod-host): a chain's worker gets a world narrowed to
the UNION of its members' bundles, so that each member's pin still holds in it. The format's `narrow_world` takes the
list of the members' uris and links each one's bundle into the worker's world; a member whose bundle is not found
leaves the chain unformed rather than widening the world. With it, a chain of LV2 members is accepted under
`require_pins`, and a chain of one gets the world an own worker gets.
An LV2 pin holds only in a world narrowed to the plugin's own bundle, on the `lv2` row and on the `lv2-clap` row
(omx-clap-host's adapter). Until 0.8.x a chain's worker was given the whole `lv2_path`, so while `require_pins` was on
every chain of LV2 members, a chain of one as well, was refused with `PLACEMENT_INVALID`.

Built (operator ruling 2026-10-21, a precondition of removing mod-host): a chain's worker gets a world narrowed to the
UNION of its members' bundles, so that each member's pin still holds in it. The format's `narrow_world` takes the list
of the members' uris and links each one's bundle into the worker's world once; a member whose bundle is not found
leaves the world unbuilt rather than widening it, and `chain` refuses that chain with `PLACEMENT_INVALID` before
anything moves. A chain of LV2 members is accepted under `require_pins`, and a chain of one gets the world an own
worker gets. CLAP plugins still chain as they pool (under `pin_mode first-use`, only those whose pin says
`verdict=in-process`).

What it must not do, as measured: in omx-clap-core's Stage 0 bench (eight LV2 plugins chained through the adapter), a
chain member in an `LV2_PATH` of a single bundle answered `resp -101` (no plugin of that uri). A world narrowed to one
member's bundle is that failure; the union is the fix.

Held by `under_require_pins_a_chain_of_lv2_members_is_refused_until_its_worker_gets_the_union_of_their_bundles`
(tests/daemon_test.py): the refusal, for a chain of one and of two, until the union world is built; that test then
turns into its proof (the chain forms, and each member's worker world holds exactly the members' bundles).
Held by `under_require_pins_a_chain_of_lv2_members_runs_in_a_world_of_the_union_of_their_bundles` (tests/daemon_test.py):
a chain of two LV2 members forms on the adapter row, its worker's `LV2_PATH` holds exactly the two bundles, and a chain
with a member whose bundle is gone is refused and moves nothing. tests/sabotage.py narrows the union to the first
member's bundle and requires that test to go red.
24 changes: 24 additions & 0 deletions docs/design/notes/2026-10-22-worker-side-131.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# What omx-clap-host must send for plugin-hostd#131

plugin-hostd's side of #131 is built (the union-narrowed LV2 world, the relay of the preset, bundle and learn verbs,
parameter feedback declared). These are the parts that belong to the worker, omx-clap-core's lane. plugin-hostd does not
edit that repo; each line below is what the daemon already relays or expects.

1. **A chain of one.** `chain <chain> <instance>` with a single member is a valid line (`chain_members_min` is 1). The
worker answers `resp 0` when it holds the member, as it does for a chain of two or more (omx-clap-core#104).
2. **A world of several bundles.** A chain's LV2 members run in one worker whose `LV2_PATH` is one directory holding a
link to each member's bundle, each once. The adapter finds every member's uri in that world; a chain member must
never answer `resp -101` because another member's bundle sits beside its own.
3. **Presets and bundles.** `preset_load <n> <uri>`, `preset_save <n> <name> <dir> <file>`, `preset_show <uri>`,
`bundle_add <path>` and `bundle_remove <path> <resource>` are answered by mod-host's grammar instead of `resp -902`:
preset_save writes the plugin's state as a preset file, preset_load loads it through the CLAP preset-load extension.
An instance verb goes only to the worker that holds the instance; `preset_show` and the bundle verbs go to every
worker. The daemon hands back whatever the worker answers.
4. **MIDI learn and CV addressing.** `midi_learn`, `midi_map`, `midi_unmap`, `cv_map` and `cv_unmap` are answered by
mod-host's grammar instead of `resp -902`. The daemon's ledger keeps them and replays them after a respawn.
5. **Parameter feedback.** For every value the plugin moves by itself (its automation, an LFO, a preset load, a MIDI
map, the user's knob of the plugin's own: the CLAP parameter out-events the plugin flushes), the worker writes
`param_set <instance> <symbol> <value>` on its feedback port, as mod-host writes it. It never writes one for a
value a controller's `param_set` gave it. The daemon relays each line as written, in order (protocol.h
`PHD_EVENT_PARAM_SET`; held by
`a_value_the_plugin_moved_by_itself_reaches_the_controller_and_a_controllers_value_writes_no_feedback`).
21 changes: 16 additions & 5 deletions include/plugin-hostd/protocol.h
Original file line number Diff line number Diff line change
Expand Up @@ -446,8 +446,9 @@ static inline int phd_pool_name_valid(const char *name)
"and is no failure. Every instance verb still names one member by its own number. A member that crashes the " \
"worker is quarantined to a worker of its own and the chain goes on without it; quarantine_clear puts it back. " \
"-518 for an instance no add made, -519 for one named twice, -520 for one in another chain, -521 for members of " \
"more than one format, -501 for a plugin that may not share a worker while require_pins is on (every LV2 plugin, " \
"a chain of one as well, until a chain's worker is given the union of its members' bundles), -902 for a chain " \
"more than one format, -501 for a plugin that may not share a worker while require_pins is on (a CLAP plugin " \
"whose first-use pin is not in-process; an LV2 member whose bundle is not found, since a chain's worker sees " \
"a world narrowed to the union of its members' bundles), -902 for a chain " \
"or an instance that is not a number, or fewer than chain_members_min (" PHD_STR(PHD_CHAIN_MEMBERS_MIN) ") instances") \
X(CHAIN_CLEAR, PHD_VERB_CHAIN_CLEAR, "<chain>", "resp 0", \
"forget the chain: each member goes to a worker of its own; -522 for a chain no " PHD_VERB_CHAIN " declared")
Expand Down Expand Up @@ -696,8 +697,10 @@ static inline int phd_is_note_verb(const char *verb)
#define PHD_HOST_VERB_SOURCE_DAEMON "daemon"

/* X(name, source, lv2-worker, clap-worker, console, zynthian). The mod-ui rows are the operator's ruling of
* 2026-10-21 (FreeMixer/plugin-hostd#131), read from Zynthian's verbs.tsv, with the ruling of 2026-10-22: MIDI learn
* and CV addressing are required of the CLAP worker and of Zynthian. The others are this tree's. */
* 2026-10-21 (FreeMixer/plugin-hostd#131), read from Zynthian's verbs.tsv, with the ruling of 2026-10-22: presets,
* bundles, MIDI learn and CV addressing are required of the CLAP worker and of Zynthian. The others are this tree's.
* The daemon relays a worker's verb (an instance verb to the worker that holds the instance, any other to every worker)
* and passes its answer back, so a required verb answers -902 only while the worker does. */
#define PHD_HOST_VERBS(X) \
X("add", MOD_UI, REQUIRED, REQUIRED, REQUIRED, REQUIRED) \
X("remove", MOD_UI, REQUIRED, REQUIRED, REQUIRED, REQUIRED) \
Expand Down Expand Up @@ -797,6 +800,8 @@ static inline int phd_is_note_verb(const char *verb)
#define PHD_EVENT_REMOTE_PAGES_CHANGED "remote_pages_changed"
#define PHD_EVENT_REMOTE_PAGE_SUGGEST "remote_page_suggest"
#define PHD_EVENT_PARAM_GESTURE "param_gesture"
/* mod-host's own feedback line for a parameter, the same word as its command */
#define PHD_EVENT_PARAM_SET "param_set"

/* the last word of a param_gesture line */
#define PHD_GESTURE_BEGIN "begin"
Expand All @@ -817,6 +822,7 @@ static inline int phd_is_note_verb(const char *verb)
#define PHD_EVENT_REMOTE_PAGES_CHANGED_FMT PHD_EVENT_REMOTE_PAGES_CHANGED " %d"
#define PHD_EVENT_REMOTE_PAGE_SUGGEST_FMT PHD_EVENT_REMOTE_PAGE_SUGGEST " %d %u"
#define PHD_EVENT_PARAM_GESTURE_FMT PHD_EVENT_PARAM_GESTURE " %d %s %s"
#define PHD_EVENT_PARAM_SET_FMT PHD_EVENT_PARAM_SET " %d %s %s"
#define PHD_EVENT_PIN_CHANGED_FMT PHD_EVENT_PIN_CHANGED " %s %s"
#define PHD_EVENT_LV2_ADAPTER_REFUSED_FMT PHD_EVENT_LV2_ADAPTER_REFUSED " %s %s %s"
#define PHD_EVENT_CHAIN_FORMED_FMT PHD_EVENT_CHAIN_FORMED " %d w%d %s"
Expand Down Expand Up @@ -859,7 +865,12 @@ static inline int phd_is_note_verb(const char *verb)
"relayed from a worker: the user took hold of a control of the plugin's own, or let it go; a controller writes no " \
"automation of that parameter in between. The values the plugin moves meanwhile, or at any time by itself, are " \
"mod-host's param_set feedback lines, relayed as written. A gesture whose worker died, or whose instance was " \
"removed, before its " PHD_GESTURE_END " is ended by the daemon, before the worker_died event")
"removed, before its " PHD_GESTURE_END " is ended by the daemon, before the worker_died event") \
X(PHD_EVENT_PARAM_SET, "<instance> <symbol> <value>", \
"relayed from a worker: parameter feedback, a value the plugin moved by itself (its automation, an LFO, a " \
"preset load, a MIDI map, the user's knob of the plugin's own), as mod-host writes it; a controller updates its " \
"control to it. A worker never writes it for a value a controller's param_set gave it, and a worker that writes " \
"none changes nothing: the daemon adds no line of its own")

/* ---------------------------------------------------------------- worker feedback, relayed */

Expand Down
Loading
Loading