diff --git a/README.md b/README.md index 545f810..9d6a8c2 100644 --- a/README.md +++ b/README.md @@ -167,7 +167,7 @@ Verbs the daemon adds | `pin_accept` | `` | `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` | `` | `resp 0 :` | 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` | ` :` | `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` | ` [ ...]` | `resp 0` | the instances, in this order, are one chain, run in one worker of place 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` | ` [ ...]` | `resp 0` | the instances, in this order, are one chain, run in one worker of place 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` | `` | `resp 0` | forget the chain: each member goes to a worker of its own; -522 for a chain no chain declared | A `` 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). @@ -223,7 +223,10 @@ The ledger keeps the latest `track_info` of an instance and replays it after the 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. | verb | sent by | lv2-worker | clap-worker | console | zynthian | @@ -366,6 +369,7 @@ Events on the feedback port, one NUL-terminated line each: | `remote_pages_changed` | `` | relayed from a worker: the plugin changed its remote pages; read them again | | `remote_page_suggest` | ` ` | 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` | ` 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` | ` ` | 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 | The workers' own feedback diff --git a/docs/design/notes/2026-10-21-chains.md b/docs/design/notes/2026-10-21-chains.md index 31836e5..1e5f818 100644 --- a/docs/design/notes/2026-10-21-chains.md +++ b/docs/design/notes/2026-10-21-chains.md @@ -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. diff --git a/docs/design/notes/2026-10-22-worker-side-131.md b/docs/design/notes/2026-10-22-worker-side-131.md new file mode 100644 index 0000000..5473535 --- /dev/null +++ b/docs/design/notes/2026-10-22-worker-side-131.md @@ -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 ` 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 `, `preset_save `, `preset_show `, + `bundle_add ` and `bundle_remove ` 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 ` 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`). diff --git a/include/plugin-hostd/protocol.h b/include/plugin-hostd/protocol.h index 3845f25..28c60e8 100644 --- a/include/plugin-hostd/protocol.h +++ b/include/plugin-hostd/protocol.h @@ -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, "", "resp 0", \ "forget the chain: each member goes to a worker of its own; -522 for a chain no " PHD_VERB_CHAIN " declared") @@ -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) \ @@ -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" @@ -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" @@ -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, " ", \ + "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 */ diff --git a/protocol/plugin-hostd.json b/protocol/plugin-hostd.json index 36f9b4c..3fff294 100644 --- a/protocol/plugin-hostd.json +++ b/protocol/plugin-hostd.json @@ -64,7 +64,7 @@ {"name": "pin_accept", "arguments": "", "reply": "resp 0", "meaning": "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"}, {"name": "pin_layout", "arguments": "", "reply": "resp 0 :", "meaning": "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"}, {"name": "pin_expect", "arguments": " :", "reply": "resp 0", "meaning": "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"}, - {"name": "chain", "arguments": " [ ...]", "reply": "resp 0", "meaning": "the instances, in this order, are one chain, run in one worker of place 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"}, + {"name": "chain", "arguments": " [ ...]", "reply": "resp 0", "meaning": "the instances, in this order, are one chain, run in one worker of place 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"}, {"name": "chain_clear", "arguments": "", "reply": "resp 0", "meaning": "forget the chain: each member goes to a worker of its own; -522 for a chain no chain declared"} ], "instance_verbs": [ @@ -179,7 +179,8 @@ {"name": "lv2_adapter_refused", "fields": " auto | omx-clap-host", "meaning": "the LV2 adapter of the lv2-clap worker refused a plugin: is the hosting.* code after -102 on the worker's reply to that add (- when it named none); the last word is the lv2_worker in force, auto for a plugin the lv2 row runs from now on, omx-clap-host for an add answered (-512)"}, {"name": "remote_pages_changed", "fields": "", "meaning": "relayed from a worker: the plugin changed its remote pages; read them again"}, {"name": "remote_page_suggest", "fields": " ", "meaning": "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"}, - {"name": "param_gesture", "fields": " begin | end", "meaning": "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"} + {"name": "param_gesture", "fields": " begin | end", "meaning": "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"}, + {"name": "param_set", "fields": " ", "meaning": "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"} ], "pin_limits": [ {"name": "PATH", "meaning": "a path holding ',', '=' or whitespace cannot be pinned: they are the separators of pin_set's words, and pin_set refuses it as a token outside its grammar"}, diff --git a/src/formats.h b/src/formats.h index e37e5ce..7dd5ad6 100644 --- a/src/formats.h +++ b/src/formats.h @@ -45,8 +45,9 @@ typedef struct FORMAT_T { const char *(*worker)(const conf_t *conf); /* the program that serves it */ const char *(*search)(const conf_t *conf); /* where its plugins are searched, NULL for nowhere */ const char *world_env; /* the worker's variable for the search path, NULL for none */ - /* a directory under `dir` that makes the worker's world the one plugin's, malloc'd; NULL for the whole search path */ - char *(*narrow_world)(const conf_t *conf, const char *dir, const char *uri); + /* a directory under `dir` that makes the worker's world the union of the n plugins' (one for an own worker, a chain's + * members for a chain's), malloc'd; NULL when a plugin is not found: never a wider world than the plugins' */ + char *(*narrow_world)(const conf_t *conf, const char *dir, const char *const *uris, int n); /* the root and the relative files a host loads for the uri: what a pin holds. SUCCESS, or * PHD_ERR_PIN_BINARY_MISMATCH with the reason on stderr for what cannot be pinned */ @@ -80,7 +81,7 @@ int clap_pin_collect(const char *uri, const char *search, char *root, size_t siz /* the LV2 format's parts, in lv2.c */ int lv2_pin_collect(const char *uri, const char *search, char *root, size_t size, names_t *files); -char *lv2_narrow_world(const conf_t *conf, const char *dir, const char *uri); +char *lv2_narrow_world(const conf_t *conf, const char *dir, const char *const *uris, int n); char *lv2_param_info(int id, const char *symbol); /* which LV2 row runs a uri: the lv2_worker_set override of the uri, else the lv2_worker setting; auto is the adapter's row diff --git a/src/lv2.c b/src/lv2.c index 670aec2..5866154 100644 --- a/src/lv2.c +++ b/src/lv2.c @@ -366,23 +366,29 @@ int lv2_pin_collect(const char *uri, const char *search, char *root, size_t size return rc; } -/* an own LV2 worker sees a world of one bundle: a directory holding a link to the bundle that names the URI */ -char *lv2_narrow_world(const conf_t *conf, const char *dir, const char *uri) +/* an own LV2 worker sees a world of one bundle, a chain's worker the union of its members': a directory holding a link + * to each bundle that names one of the URIs, once; NULL when a URI's bundle is not found */ +char *lv2_narrow_world(const conf_t *conf, const char *dir, const char *const *uris, int n) { - char *bundle = proc_find_lv2_bundle(conf->lv2_path, uri), *base, link_path[PATH_MAX]; + char link_path[PATH_MAX]; + int k; - if (!bundle) - return NULL; mkdir(dir, 0755); - base = strrchr(bundle, '/'); - snprintf(link_path, sizeof(link_path), "%s/%s", dir, base ? base + 1 : bundle); - unlink(link_path); - if (symlink(bundle, link_path) != 0) + for (k = 0; k < n; k++) { + char *bundle = proc_find_lv2_bundle(conf->lv2_path, uris[k]), *base; + int ok; + + if (!bundle) + return NULL; + base = strrchr(bundle, '/'); + snprintf(link_path, sizeof(link_path), "%s/%s", dir, base ? base + 1 : bundle); + unlink(link_path); + ok = symlink(bundle, link_path) == 0; free(bundle); - return NULL; + if (!ok) + return NULL; } - free(bundle); return strdup(dir); } diff --git a/src/supervisor.c b/src/supervisor.c index 3fe7082..1509009 100644 --- a/src/supervisor.c +++ b/src/supervisor.c @@ -406,17 +406,40 @@ static const char *search_of(int fmt) return f->search ? f->search(&g_conf) : NULL; } -/* a format that can narrow its worker's world gives an own worker the world of the one plugin; a pool, and a plugin the - * format cannot find, gets the whole search path */ +/* the uris of a chain's members that run in its worker, in order; malloc'd, *n of them */ +static const char **chain_uris(const worker_t *w, int *n) +{ + const char **uris = malloc((w->ninst ? w->ninst : 1) * sizeof(char *)); + int k; + + for (k = 0; k < w->ninst; k++) + uris[k] = w->inst[k]->uri; + *n = w->ninst; + return uris; +} + +/* a format that can narrow its worker's world gives an own worker the world of the one plugin and a chain's worker the + * union of its members'; a pool, and a plugin the format cannot find, gets the whole search path */ static char *narrow_world(worker_t *w, const char *uri) { const format_t *f = format_at(w->fmt); - char dir[PATH_MAX]; + char dir[PATH_MAX], *world; + const char **uris; + int n; - if (!f->narrow_world || w->pool || !uri) + if (!f->narrow_world) return NULL; snprintf(dir, sizeof(dir), "%s/world-w%d", g_root, w->k); - return f->narrow_world(&g_conf, dir, uri); + if (w->chain && w->ninst) + { + uris = chain_uris(w, &n); + world = f->narrow_world(&g_conf, dir, uris, n); + free(uris); + return world; + } + if (w->pool || !uri) + return NULL; + return f->narrow_world(&g_conf, dir, &uri, 1); } /* how many times one start asks for ports before it is a failed spawn */ @@ -2009,11 +2032,37 @@ static void chain_form(chain_t *c) respawn(w); } -/* a plugin that may not share a worker: the rule of a pool, while require_pins is on */ +/* a plugin that may not share a worker: the rule of a pool, while require_pins is on; a chain's worker sees the union of + * its members' worlds, so what a format pins in a world of its own holds there and is judged by chain_world_found */ static int must_run_own(const instance_t *i) { - return g_conf.require_pins && - (format_at(i->fmt)->pinned_own_only || (conf_pins_first_use(&g_conf) && !pins_in_process(i->uri))); + return g_conf.require_pins && !format_at(i->fmt)->pinned_own_only && conf_pins_first_use(&g_conf) && + !pins_in_process(i->uri); +} + +/* while require_pins is on, a chain of a format that pins in a world of its own forms only if the format can narrow its + * worker's world to the union of the members': it is built once in a scratch directory, every member found */ +static int chain_world_found(const int *ids, int n) +{ + const format_t *f = format_at(g_inst[ids[0]]->fmt); + const char **uris; + char dir[PATH_MAX], *world; + int k, found; + + if (!g_conf.require_pins || !f->pinned_own_only) + return 1; + if (!f->narrow_world) + return 0; + uris = malloc(n * sizeof(char *)); + for (k = 0; k < n; k++) + uris[k] = g_inst[ids[k]]->uri; + snprintf(dir, sizeof(dir), "%s/world-check", g_root); + world = f->narrow_world(&g_conf, dir, uris, n); + free(uris); + rm_tree(dir); + found = world != NULL; + free(world); + return found; } char *sup_chain(int id, const int *ids, int n) @@ -2039,6 +2088,8 @@ char *sup_chain(int id, const int *ids, int n) if (must_run_own(i)) return sup_resp(PHD_ERR_PLACEMENT_INVALID); } + if (!chain_world_found(ids, n)) + return sup_resp(PHD_ERR_PLACEMENT_INVALID); if (!c) { c = calloc(1, sizeof(chain_t)); diff --git a/tests/daemon_test.py b/tests/daemon_test.py index caf7f52..6fa241d 100644 --- a/tests/daemon_test.py +++ b/tests/daemon_test.py @@ -105,6 +105,45 @@ def a_command_whose_first_argument_is_a_uri_reaches_every_worker(): os.unlink(log) +PRESET_LEARN_INSTANCE = ("preset_load 0 file:///presets/warm.preset", "preset_save 0 warm /tmp/presets warm.ttl", + "midi_learn 0 gain 0.0 1.0", "midi_map 0 gain 0 7 0.0 1.0", "midi_unmap 0 gain", + "cv_map 0 gain /cv/lfo 0.0 1.0 linear", "cv_unmap 0 gain") +PRESET_LEARN_WORLD = ("preset_show file:///presets/warm.preset", "bundle_add /usr/lib/lv2/warm.lv2", + "bundle_remove /usr/lib/lv2/warm.lv2 warm.ttl") + + +@test +def the_preset_bundle_and_learn_verbs_are_relayed_and_the_workers_answer_comes_back(): + """the host verb table requires preset_load, preset_save, preset_show, bundle_add, bundle_remove, midi_learn, + midi_map, midi_unmap, cv_map and cv_unmap of a CLAP worker and of Zynthian: the daemon relays each one, an instance + verb to the worker that holds the instance and the others to every worker, and passes the worker's answer back. It + answers -902 only while the worker does""" + for answers, want in ((True, "resp 0"), (False, "resp -902")): + log = tempfile.mktemp(prefix="plugin-hostd-log.") + d = daemon(env={"FAKE_LOG": log, "FAKE_PRESETS_LEARN": "answer" if answers else "refuse"}) + try: + d.expect("add clap:fake#a 0", "resp 0") + d.expect("add clap:fake#b 1", "resp 1") + holder = d.holder(0)[1]["pid"] + pids = {w["pid"] for w in d.workers().values()} + for line in PRESET_LEARN_INSTANCE: + d.expect(line, want) + got = {pid for pid in pids if line in sent_to(log, pid)} + check(got == {holder}, "%s reaches the holder of 0 only: %s of %s" % (line, sorted(got), sorted(pids))) + for line in PRESET_LEARN_WORLD: + d.expect(line, want) + got = {pid for pid in pids if line in sent_to(log, pid)} + check(got == pids, "%s reaches every worker: %s of %s" % (line, sorted(got), sorted(pids))) + if answers: + # an instance no add made is the daemon's own answer, mod-host's ERR_INSTANCE_NON_EXISTS, and no worker is asked + d.expect("midi_learn 9 gain 0.0 1.0", "resp -3") + check(not any("midi_learn 9" in c for c in sent_to(log)), "no worker was asked about 9") + finally: + d.close() + if os.path.exists(log): + os.unlink(log) + + @test def remove_all_and_quit(): d = daemon() @@ -978,6 +1017,27 @@ def a_gesture_the_value_it_moved_and_a_suggested_page_reach_the_controller_uncha d.close() +@test +def a_value_the_plugin_moved_by_itself_reaches_the_controller_and_a_controllers_value_writes_no_feedback(): + """parameter feedback: a worker reports a value its plugin moved by itself (automation, an LFO, a preset load) as + mod-host's param_set feedback line, and the daemon relays each one as written and in order. A value a controller + set is answered and nothing more: a worker that reports nothing makes the daemon write no line of its own""" + d = daemon() + try: + d.expect("add clap:fake#a 3", "resp 3") + d.expect("add clap:fake#b 4", "resp 4") + mark = d.mark() + for line in ("param_set 3 0 0.5", "param_set 4 0 0.75", "bypass 3 1", "preset_load 4 urn:fake:bright"): + d.expect(line, "resp 0") + d.expect("param_set 3 moves 0.6000", "resp 0") + wait_for(lambda: len(from_plugin(d, mark)) >= 2, "the two values the plugin moved") + time.sleep(0.2) + check(from_plugin(d, mark) == ["param_set 3 0 0.6000", "param_set 3 0 0.25"], + "the plugin's values only, as written and in order, none for the controller's: %s" % d.events[mark:]) + finally: + d.close() + + @test def a_gesture_its_worker_did_not_end_is_ended_by_the_daemon_before_worker_died(): d = daemon() @@ -2584,28 +2644,58 @@ def a_chain_of_one_runs_in_a_worker_of_its_own_and_a_chain_of_none_is_refused(): @test -def under_require_pins_a_chain_of_lv2_members_is_refused_until_its_worker_gets_the_union_of_their_bundles(): - """an LV2 pin holds only in a world narrowed to the plugin's bundle, and a chain's worker is given the whole - lv2_path: a chain of pinned LV2 members, of one or of two, is refused with PLACEMENT_INVALID and nothing moves. - A worker world narrowed to the union of the members' bundles is what lets it form; this holds the refusal - until then""" +def under_require_pins_a_chain_of_lv2_members_runs_in_a_world_of_the_union_of_their_bundles(): + """an LV2 pin holds only in a world narrowed to the plugin's bundle: a chain's worker sees a world narrowed to the union + of its members' bundles, each linked once, so a chain of pinned LV2 members forms under require_pins; a chain with a + member whose bundle is gone is refused with PLACEMENT_INVALID and nothing moves""" + programs = Programs() p = Plugins() - d = p.daemon() + uri = "urn:test:second" + second = os.path.join(p.lv2_dir, "second.lv2") + os.makedirs(second) + write(os.path.join(second, "manifest.ttl"), (MANIFEST % uri).encode()) + write(os.path.join(second, "pinned.so"), b"second lv2 binary") + write(os.path.join(second, "pinned.ttl"), b"lv2:port [ lv2:index 0 ; lv2:symbol \"gain\" ] .") + write(os.path.join(p.lv2_dir, "stray.lv2.txt"), b"no bundle") + d = p.daemon(conf=programs.conf(lv2_worker="omx-clap-host")) try: d.expect(p.lv2_pin(), "resp 0") + d.expect("pin_set %s %s %s" % (uri, ",".join("%s=%s" % (f, sha(os.path.join(second, f))) + for f in ("manifest.ttl", "pinned.so", "pinned.ttl")), fake_layout()), "resp 0") d.expect("add %s 1" % LV2_URI, "resp 1") - d.expect("add %s 2" % LV2_URI, "resp 2") + d.expect("add %s 2" % uri, "resp 2") + d.expect("add %s 3" % LV2_URI, "resp 3") + mark = d.mark() + d.expect("chain 4 1 2 3", "resp 0") + d.wait_event("chain_formed 4 ", since=mark) + k, w = list(chain_of(d, 4).items())[0] + check(w["inst"] == [1, 2, 3] and w["fmt"] == "lv2-clap", "one adapter worker runs the chain: %s" % d.workers()) + program, world = programs.of(w["pid"]) + check(program == "omx-clap-host" and world != p.lv2_dir, "the chain's world is narrowed: %s %s" % (program, world)) + check(sorted(os.listdir(world)) == ["pinned.lv2", "second.lv2"], + "it holds the union of the members' bundles, each once, and nothing else: %s" % os.listdir(world)) + check(os.path.exists(os.path.join(world, "second.lv2", "manifest.ttl")), "each link reaches its bundle") + check("chain 4 1 2 3" in [c for _, c in p.received()], "the worker is told the chain: %s" % p.received()[-4:]) + # a chain of one gets the world of its one member + d.expect("add %s 5" % uri, "resp 5") + mark = d.mark() + d.expect("chain 6 5", "resp 0") + d.wait_event("chain_formed 6 ", since=mark) + _, w = list(chain_of(d, 6).items())[0] + check(os.listdir(programs.of(w["pid"])[1]) == ["second.lv2"], "a chain of one sees its own bundle only") + # a member whose bundle is gone leaves no union to build: the chain is refused and nothing moves + d.expect("add %s 7" % uri, "resp 7") + d.expect("add %s 8" % LV2_URI, "resp 8") + shutil.move(second, second + ".gone") before, seen = d.workers(), len(p.received()) - check(all(w["place"] == "own" for w in before.values()) and len(before) == 2, - "each pinned LV2 member starts in a world of its own: %s" % before) - for line in ("chain 4 1 2", "chain 4 1"): - d.expect(line, resp("PLACEMENT_INVALID")) - check(d.workers() == before and chain_of(d, 4) == {}, "a refused chain moves nothing: %s" % d.workers()) + d.expect("chain 9 8 7", resp("PLACEMENT_INVALID")) + check(d.workers() == before and chain_of(d, 9) == {}, "a refused chain moves nothing: %s" % d.workers()) check(p.received()[seen:] == [], "no worker saw a refused chain: %s" % p.received()[seen:]) - d.expect("chain_clear 4", resp("NO_SUCH_CHAIN")) + d.expect("chain_clear 9", resp("NO_SUCH_CHAIN")) finally: d.close() p.close() + programs.close() @test diff --git a/tests/fake_host.c b/tests/fake_host.c index 60e3fd6..e336f55 100644 --- a/tests/fake_host.c +++ b/tests/fake_host.c @@ -49,6 +49,9 @@ * own: "param_gesture 0 begin", "param_set 0 " (v as sent), "param_gesture 0 end", then * "remote_page_suggest 8", in that order * param_set hold v other than 0 writes "param_gesture 0 begin" and never its end + * param_set moves v other than 0 writes "param_set 0 " (v as sent) and "param_set 0 0.25" on the + * feedback port, with no gesture, as a worker reports a value its plugin moved by itself + * (an LFO, a preset load); no other param_set writes any feedback * preset_list "resp 2", and preset_get <0|1> one preset each, -902 for any other index * track_info ... "resp 0"; the fake keeps nothing of it (FAKE_LOG shows what it was sent) * note_input "resp 0 midi_in" for an instance whose uri holds "synth", "resp 0 -" for any other @@ -60,6 +63,12 @@ * shows what it was sent) * set_bpm, set_bpb, transport, transport_sync "resp 0", as mod-host answers them, -902 with FAKE_REFUSE_SETTINGS, as * omx-clap-host answers them; the fake keeps nothing of them + * preset_load, preset_save, preset_show, bundle_add, bundle_remove, midi_learn, midi_map, midi_unmap, cv_map, cv_unmap + * with FAKE_PRESETS_LEARN=answer "resp 0" (-3 for an instance verb of an instance it does + * not hold), as a worker that builds them answers; with FAKE_PRESETS_LEARN=refuse + * "resp -902", as the released omx-clap-host answers them; the fake keeps nothing of + * them (FAKE_LOG shows what it was sent). Without it preset_load is the state verb below + * and the others are unknown, -902 * remote_pages "resp 2", and remote_page_get <0|1> one page each, -902 for any other page * param_info for the symbol 0 the worked vector of the param contract, for the symbol 1 -511 (a parameter * the worker holds no contract for, as the released omx-clap-host answers every one), -103 for any other @@ -242,7 +251,7 @@ static void receive(msg_t *msg) { char *line = strndup(msg->data, msg->data_size), *copy = strdup(line), *tok[16], *save = NULL, *t; int ntok = 0, fd = msg->sender_id, n, k; - const char *verb; + const char *verb, *presets_learn = getenv("FAKE_PRESETS_LEARN"); log_command(line); for (t = strtok_r(copy, " \t\n", &save); t && ntok < 16; t = strtok_r(NULL, " \t\n", &save)) @@ -351,6 +360,15 @@ static void receive(msg_t *msg) else if (verb_is(verb, EFFECT_SET_BPM) || verb_is(verb, EFFECT_SET_BPB) || verb_is(verb, TRANSPORT) || verb_is(verb, TRANSPORT_SYNC)) answer(fd, getenv("FAKE_REFUSE_SETTINGS") ? "resp -902" : "resp 0"); + else if (presets_learn && (verb_is(verb, EFFECT_PRESET_SHOW) || verb_is(verb, BUNDLE_ADD) || + verb_is(verb, BUNDLE_REMOVE))) + answer(fd, strcmp(presets_learn, "answer") ? "resp -902" : "resp 0"); + else if (presets_learn && + (verb_is(verb, EFFECT_PRESET_LOAD) || verb_is(verb, EFFECT_PRESET_SAVE) || verb_is(verb, MIDI_LEARN) || + verb_is(verb, MIDI_MAP) || verb_is(verb, MIDI_UNMAP) || verb_is(verb, CV_MAP) || verb_is(verb, CV_UNMAP))) + answer(fd, strcmp(presets_learn, "answer") ? "resp -902" + : n >= 0 && n < MAX_INST && g_inst[n].exists ? "resp 0" + : "resp -3"); else if (n >= 0 && n < MAX_INST && !g_inst[n].exists && !verb_is(verb, STATE_SAVE) && !verb_is(verb, STATE_LOAD) && !verb_is(verb, EFFECT_CONNECT) && !verb_is(verb, EFFECT_DISCONNECT) && strcmp(verb, "list_connections") && !verb_is(verb, QUIT)) @@ -394,6 +412,15 @@ static void receive(msg_t *msg) snprintf(event, sizeof(event), PHD_EVENT_REMOTE_PAGE_SUGGEST_FMT, n, 8u); socket_send_feedback(event); } + if (!strcmp(tok[2], "moves") && atof(tok[3]) != 0.0) + { + char event[128]; + + snprintf(event, sizeof(event), PHD_EVENT_PARAM_SET_FMT, n, "0", tok[3]); + socket_send_feedback(event); + snprintf(event, sizeof(event), PHD_EVENT_PARAM_SET_FMT, n, "0", "0.25"); + socket_send_feedback(event); + } if (!strcmp(tok[2], "hold") && atof(tok[3]) != 0.0) { char event[128]; diff --git a/tests/format_test.c b/tests/format_test.c index 5ea8cd9..a48e3f7 100644 --- a/tests/format_test.c +++ b/tests/format_test.c @@ -113,10 +113,11 @@ static format_t fake4(void) return f; } -static char *fake3_narrow(const conf_t *conf, const char *dir, const char *uri) +static char *fake3_narrow(const conf_t *conf, const char *dir, const char *const *uris, int n) { (void)conf; - (void)uri; + (void)uris; + (void)n; return strdup(dir); } diff --git a/tests/sabotage.py b/tests/sabotage.py index 6930787..526797c 100644 --- a/tests/sabotage.py +++ b/tests/sabotage.py @@ -74,7 +74,7 @@ ("connections are not replayed, so a killed strip never gets its audio back", "src/supervisor.c", "for (m = 0; m < i->nconns; m++)\n {\n int code;", "for (m = 0; m < 0; m++)\n {\n int code;", "audio_of_the_other_strip_never_changes_when_one_worker_is_killed", "jack"), - ("an own lv2 worker keeps the whole bundle set", "src/supervisor.c", " if (!f->narrow_world || w->pool || !uri)\n return NULL;", + ("an own lv2 worker keeps the whole bundle set", "src/supervisor.c", " return f->narrow_world(&g_conf, dir, &uri, 1);", " return NULL;", "lv2_own_worker_sees_one_bundle_and_replays_after_a_kill", "lv2"), ("an LV2 strip's parameter writes are not replayed to the respawned mod-host", "src/supervisor.c", " for (m = 0; m < i->ntail; m++)\n if (replay_line(w, i->tail[m].line) != RPC_OK)", @@ -628,10 +628,34 @@ ("the supervisor asks two members of a chain, not the declared floor", "src/supervisor.c", "|| n < PHD_CHAIN_MEMBERS_MIN)", "|| n < 2)", "a_chain_of_one_runs_in_a_worker_of_its_own_and_a_chain_of_none_is_refused"), - ("a chain of pinned LV2 members forms in a world where their pins do not hold", "src/supervisor.c", - " if (must_run_own(i))\n return sup_resp(PHD_ERR_PLACEMENT_INVALID);\n }\n if (!c)", - " }\n if (!c)", - "under_require_pins_a_chain_of_lv2_members_is_refused_until_its_worker_gets_the_union_of_their_bundles"), + ("midi_learn is broadcast to every worker, not relayed to the instance's", "src/verbs.c", + "MONITOR_OUTPUT, MIDI_LEARN, MIDI_MAP,", "MONITOR_OUTPUT, MIDI_MAP,", + "the_preset_bundle_and_learn_verbs_are_relayed_and_the_workers_answer_comes_back"), + ("the daemon answers bundle_add itself and no worker hears of it", "src/verbs.c", + " reply_text = sup_broadcast(line);\n", + " reply_text = verb_is(verb, BUNDLE_ADD) ? sup_resp(SUCCESS) : sup_broadcast(line);\n", + "the_preset_bundle_and_learn_verbs_are_relayed_and_the_workers_answer_comes_back"), + ("the relay drops the parameter feedback a worker writes", "src/relay.c", + " send_line(start, nul - start + 1);\n gesture_seen(s, start);", + " if (strncmp(start, PHD_EVENT_PARAM_SET \" \", 10))\n send_line(start, nul - start + 1);\n" + " gesture_seen(s, start);", + "a_value_the_plugin_moved_by_itself_reaches_the_controller_and_a_controllers_value_writes_no_feedback"), + ("the daemon echoes a controller's param_set as feedback", "src/supervisor.c", + " return sup_resp(ERR_INSTANCE_NON_EXISTS);\n w = i->w;\n if (w->state != W_UP)\n return refuse_worker(w);\n rc = proc_rpc", + " return sup_resp(ERR_INSTANCE_NON_EXISTS);\n if (verb_is(line, EFFECT_PARAM_SET))\n event(\"%s\", line);\n" + " w = i->w;\n if (w->state != W_UP)\n return refuse_worker(w);\n rc = proc_rpc", + "a_value_the_plugin_moved_by_itself_reaches_the_controller_and_a_controllers_value_writes_no_feedback"), + ("a chain with a pinned LV2 member whose bundle is gone forms in a world without it", "src/supervisor.c", + " if (!chain_world_found(ids, n))\n", " if (0)\n", + "under_require_pins_a_chain_of_lv2_members_runs_in_a_world_of_the_union_of_their_bundles"), + ("a chain's LV2 world is narrowed to its first member's bundle, not the union", "src/lv2.c", + " for (k = 0; k < n; k++)\n {\n char *bundle = proc_find_lv2_bundle(conf->lv2_path, uris[k]), *base;", + " for (k = 0; k < 1; k++)\n {\n char *bundle = proc_find_lv2_bundle(conf->lv2_path, uris[k]), *base;", + "under_require_pins_a_chain_of_lv2_members_runs_in_a_world_of_the_union_of_their_bundles"), + ("a chain's worker gets the whole lv2_path", "src/supervisor.c", + " world = f->narrow_world(&g_conf, dir, uris, n);\n free(uris);\n return world;", + " world = NULL;\n free(uris);\n return world;", + "under_require_pins_a_chain_of_lv2_members_runs_in_a_world_of_the_union_of_their_bundles"), ("a worker's refusal of the order fails its replay", "src/supervisor.c", 'chain_line(w, msg, sizeof(msg)) && replay_line(w, msg) != RPC_OK)', 'chain_line(w, msg, sizeof(msg)) && (replay_line_code(w, msg, &code) != RPC_OK || code < 0))',