diff --git a/Makefile b/Makefile index f669a3b..7dad4a4 100644 --- a/Makefile +++ b/Makefile @@ -149,7 +149,7 @@ install_man: # No tracked file points into a private repository, issue, branch or path (tools/private-refs-check.sh, with its # --self-test, which turns each kind of pointer red in each kind of file). -.PHONY: private-refs +.PHONY: private-refs check-verb-table private-refs: tools/private-refs-check.sh tools/private-refs-check.sh --self-test @@ -160,7 +160,7 @@ clean: # the daemon against workers that are not plugin hosts at all (tests/fake-host speaks the same protocol and can be # made to die on cue): placement, forwarding, ledger, replay, attribution, the storm bound; no jack, no plugin -test: test-pin test-transforms test-format-coverage test-describe test-relay test-alloc test-format test-daemon check-generated check-schema test-probe-schema test-vocabulary test-probe test-contract test-consumer test-perturbation check-readme check-own-host-page check-no-pins check-ci-guards test-mod-host-json test-socket +test: test-pin test-transforms test-format-coverage test-describe test-relay test-alloc test-format test-daemon check-generated check-schema test-probe-schema test-vocabulary test-probe test-contract test-consumer test-perturbation check-readme check-own-host-page check-no-pins check-ci-guards test-mod-host-json test-socket check-verb-table MOD_HOST_DIR=$(MOD_HOST_DIR) python3 tests/verbs_contract.py # the same without the verb table, which is read from a mod-host checkout's README @@ -322,6 +322,11 @@ check-ci-guards: test-mod-host-json: python3 tests/mod_host_json.py $(MOD_HOST_INCLUDE) +# the host verb table has a row for every verb a controller sends the daemon, no more and no fewer, and requires of every +# profile the verbs the daemon answers itself +check-verb-table: + python3 tests/verb_table.py + # the JSON against its schema, for which the jsonschema module is needed check-schema: python3 tests/protocol_schema.py protocol/plugin-hostd.json protocol/plugin-hostd.schema.json diff --git a/README.md b/README.md index f7184a8..e778f95 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,8 @@ The settings live in a file, `-c`, `$PLUGIN_HOSTD_CONF` or | `default_command_port` | 5555 port | the command port without -p; the feedback port is the next one, unless -n is given | | `connect_retry_ms` | 5000 ms | how long the idle tick asks again for a connect a respawned worker could not make yet | | `line_max` | 4096 bytes | the protocol socket's buffer: the longest pin_set line the daemon takes | +| `line_words_max` | 64 words | the words of one command line the daemon reads, the verb included; a word past them is not read | +| `chain_members_min` | 1 instances | the fewest members of a chain: a chain of one is valid, so one isolated plugin is a chain like any other | Placement @@ -165,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, -902 for a chain or an instance that is not a number, or fewer than two 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 (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_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). @@ -220,6 +222,85 @@ stays held at the controller. omx-clap-host writes these lines from FreeMixer/om The ledger keeps the latest `track_info` of an instance and replays it after the add; the others are never replayed. A `` is `linear` (the value is linear in position), `log` (the value is geometric in position; both bounds are positive), `stepped` (the whole numbers from min to max, step 1). A string word is at most 255 bytes of UTF-8 with no control character. +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. + + +| verb | sent by | lv2-worker | clap-worker | console | zynthian | +|---|---|---|---|---|---| +| `add` | mod-ui | required | required | required | required | +| `remove` | mod-ui | required | required | required | required | +| `bypass` | mod-ui | required | required | required | required | +| `connect` | mod-ui | required | required | required | required | +| `param_set` | mod-ui | required | required | required | required | +| `state_load` | mod-ui | required | required | required | required | +| `state_save` | mod-ui | required | required | required | required | +| `state_tmpdir` | mod-ui | required | required | required | required | +| `feature_enable` | mod-ui | required | required | required | required | +| `bundle_add` | mod-ui | required | required | required | required | +| `bundle_remove` | mod-ui | required | required | required | required | +| `preset_load` | mod-ui | required | required | required | required | +| `preset_save` | mod-ui | required | required | required | required | +| `preset_show` | mod-ui | required | required | required | required | +| `transport` | mod-ui | required | required | required | required | +| `transport_sync` | mod-ui | required | required | required | required | +| `cpu_load` | mod-ui | required | required | required | required | +| `midi_learn` | mod-ui | optional | required | optional | required | +| `midi_map` | mod-ui | optional | required | optional | required | +| `midi_unmap` | mod-ui | optional | required | optional | required | +| `cv_map` | mod-ui | optional | required | optional | required | +| `cv_unmap` | mod-ui | optional | required | optional | required | +| `monitor_midi_program` | mod-ui | optional | optional | optional | optional | +| `monitor_output` | mod-ui | optional | optional | optional | optional | +| `output_data_ready` | mod-ui | optional | optional | optional | optional | +| `patch_get` | mod-ui | optional | optional | optional | optional | +| `patch_set` | mod-ui | optional | optional | optional | optional | +| `cc_map` | mod-ui | absent | absent | absent | absent | +| `cc_value_set` | mod-ui | absent | absent | absent | absent | +| `cc_unmap` | mod-ui | absent | absent | absent | absent | +| `hmi_map` | mod-ui | absent | absent | absent | absent | +| `hmi_unmap` | mod-ui | absent | absent | absent | absent | +| `show_external_ui` | mod-ui | absent | absent | absent | absent | +| `set_midi_program_change_pedalboard_bank_channel` | mod-ui | absent | absent | absent | absent | +| `set_midi_program_change_pedalboard_snapshot_channel` | mod-ui | absent | absent | absent | absent | +| `disconnect` | mod-host | required | required | required | required | +| `set_bpm` | mod-host | required | required | required | required | +| `set_bpb` | mod-host | required | required | required | required | +| `quit` | mod-host | required | required | required | required | +| `param_get` | mod-host | optional | optional | optional | optional | +| `param_monitor` | mod-host | optional | optional | optional | optional | +| `licensee` | mod-host | optional | optional | optional | optional | +| `monitor` | mod-host | optional | optional | optional | optional | +| `load` | mod-host | optional | optional | optional | optional | +| `save` | mod-host | optional | optional | optional | optional | +| `help` | mod-host | optional | optional | optional | optional | +| `track_info` | mod-host | optional | optional | optional | optional | +| `remote_pages` | mod-host | optional | optional | optional | optional | +| `remote_page_get` | mod-host | optional | optional | optional | optional | +| `param_info` | mod-host | optional | optional | optional | optional | +| `preset_list` | daemon | optional | optional | optional | optional | +| `preset_get` | daemon | optional | optional | optional | optional | +| `note_input` | daemon | optional | optional | optional | optional | +| `note_on` | daemon | optional | optional | optional | optional | +| `note_off` | daemon | optional | optional | optional | optional | +| `all_notes_off` | daemon | optional | optional | optional | optional | +| `worker_list` | daemon | required | required | required | required | +| `instance_info` | daemon | required | required | required | required | +| `supervisor_reset` | daemon | required | required | required | required | +| `quarantine_clear` | daemon | required | required | required | required | +| `policy_set` | daemon | required | required | required | required | +| `pool_config` | daemon | required | required | required | required | +| `worker_env` | daemon | required | required | required | required | +| `pin_set` | daemon | required | required | required | required | +| `pin_clear` | daemon | required | required | required | required | +| `pin_accept` | daemon | required | required | required | required | +| `lv2_worker_set` | daemon | required | required | required | required | +| `chain` | daemon | required | required | required | required | +| `chain_clear` | daemon | required | required | required | required | + +The profiles: `lv2-worker`, an LV2 plugin's worker: mod-host, or omx-clap-host's LV2 adapter; `clap-worker`, a CLAP plugin's worker, omx-clap-host; `console`, the openmixer console as a controller of the daemon; `zynthian`, Zynthian's mod-ui and its plugind engine as a controller of the daemon. `required` is answered as its grammar says; `optional` is answered, or refused with -902, and never misread; `absent` is refused on purpose, and no client of the profile may need it. + + Error codes the daemon adds to mod-host's: diff --git a/docs/design/notes/2026-10-21-chains.md b/docs/design/notes/2026-10-21-chains.md index e977cd3..31836e5 100644 --- a/docs/design/notes/2026-10-21-chains.md +++ b/docs/design/notes/2026-10-21-chains.md @@ -13,7 +13,7 @@ down. plugin-hostd exists to isolate crashes, so the member that crashed must le ## The verbs - chain [ ...] resp 0 + chain [ ...] resp 0 chain_clear resp 0 `` is a number, as an instance is. `chain` declares the chain's members in order, and they must be instances an @@ -34,7 +34,8 @@ A refusal changes nothing: | `PLACEMENT_INVALID` | a member whose plugin may not share a worker while `require_pins` is on, as for a pool | | `NO_SUCH_CHAIN` | `chain_clear` of a chain no `chain` declared | -A list of fewer than two instances, or a word that is not a number, is outside the grammar (-902). +A chain of one is valid: one isolated plugin is a chain like any other (`chain_members_min`, 1, in the declared +constants). A list of no instance, or a word that is not a number, is outside the grammar (-902). ## What the worker is told @@ -67,12 +68,25 @@ chain, and the chain's worker is sent the order without it. A chain whose last m What omx-clap-host does with the `chain` line, one client and one callback, is omx-clap-core#104. A `chain:` place cannot be named by `add`: an instance joins a chain only through `chain`. -## Open question +## 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. So 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`). omx-clap-core#104's bench is eight LV2 plugins through the adapter. Should a chain's worker get a -world narrowed to the union of its members' bundles, so that each member's pin still holds in it? That needs the -format's `narrow_world` to take a list of uris. +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. + +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). diff --git a/include/plugin-hostd/protocol.h b/include/plugin-hostd/protocol.h index 6bc09b7..65900ae 100644 --- a/include/plugin-hostd/protocol.h +++ b/include/plugin-hostd/protocol.h @@ -44,13 +44,19 @@ #define PHD_DEFAULT_COMMAND_PORT 5555 #define PHD_CONNECT_RETRY_MS 5000 #define PHD_LINE_MAX 4096 +#define PHD_LINE_WORDS_MAX 64 +#define PHD_CHAIN_MEMBERS_MIN 1 #define PHD_CONSTANTS(X) \ X(default_command_port, PHD_DEFAULT_COMMAND_PORT, "port", \ "the command port without -p; the feedback port is the next one, unless -n is given") \ X(connect_retry_ms, PHD_CONNECT_RETRY_MS, "ms", \ "how long the idle tick asks again for a connect a respawned worker could not make yet") \ - X(line_max, PHD_LINE_MAX, "bytes", "the protocol socket's buffer: the longest pin_set line the daemon takes") + X(line_max, PHD_LINE_MAX, "bytes", "the protocol socket's buffer: the longest pin_set line the daemon takes") \ + X(line_words_max, PHD_LINE_WORDS_MAX, "words", \ + "the words of one command line the daemon reads, the verb included; a word past them is not read") \ + X(chain_members_min, PHD_CHAIN_MEMBERS_MIN, "instances", \ + "the fewest members of a chain: a chain of one is valid, so one isolated plugin is a chain like any other") /* ---------------------------------------------------------------- placement */ @@ -431,7 +437,7 @@ static inline int phd_pool_name_valid(const char *name) "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") \ - X(CHAIN, PHD_VERB_CHAIN, " [ ...]", "resp 0", \ + X(CHAIN, PHD_VERB_CHAIN, " [ ...]", "resp 0", \ "the instances, in this order, are one chain, run in one worker of place " PHD_PLACE_CHAIN_PREFIX ": 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 " \ @@ -440,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, -902 for a chain " \ - "or an instance that is not a number, or fewer than two instances") \ + "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 (" 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") @@ -662,6 +669,106 @@ static inline int phd_is_note_verb(const char *verb) !strcmp(verb, PHD_VERB_NOTE_INPUT); } +/* ---------------------------------------------------------------- the host verb table */ + +/* What each host profile owes every verb a controller sends the daemon: the commands of mod-host's protocol, the two + * that Zynthian's mod-ui still names and mod-host never had, and the verbs this daemon adds. tests/verb_table.py holds + * the table to that list, no more and no fewer, so a verb cannot be added to the daemon without its row. + * + * required: answered as its grammar says. optional: answered, or refused with -902; never misread. absent: refused on + * purpose, and no client of the profile may need it. */ +#define PHD_LEVEL_REQUIRED "required" +#define PHD_LEVEL_OPTIONAL "optional" +#define PHD_LEVEL_ABSENT "absent" +#define PHD_LEVELS(X) X(REQUIRED, PHD_LEVEL_REQUIRED) X(OPTIONAL, PHD_LEVEL_OPTIONAL) X(ABSENT, PHD_LEVEL_ABSENT) + +/* the profiles, a column each: X(id, name, meaning) */ +#define PHD_HOST_PROFILES(X) \ + X(LV2, "lv2-worker", "an LV2 plugin's worker: mod-host, or omx-clap-host's LV2 adapter") \ + X(CLAP, "clap-worker", "a CLAP plugin's worker, omx-clap-host") \ + X(CONSOLE, "console", "the openmixer console as a controller of the daemon") \ + X(ZYNTHIAN, "zynthian", "Zynthian's mod-ui and its plugind engine as a controller of the daemon") + +/* who sends the verb: mod-ui (Zynthian's mod-ui sends it to its host), mod-host (a command of mod-host.h that mod-ui + * does not send), daemon (a verb this daemon adds: it answers it itself, whatever the worker) */ +#define PHD_HOST_VERB_SOURCE_MOD_UI "mod-ui" +#define PHD_HOST_VERB_SOURCE_MOD_HOST "mod-host" +#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. */ +#define PHD_HOST_VERBS(X) \ + X("add", MOD_UI, REQUIRED, REQUIRED, REQUIRED, REQUIRED) \ + X("remove", MOD_UI, REQUIRED, REQUIRED, REQUIRED, REQUIRED) \ + X("bypass", MOD_UI, REQUIRED, REQUIRED, REQUIRED, REQUIRED) \ + X("connect", MOD_UI, REQUIRED, REQUIRED, REQUIRED, REQUIRED) \ + X("param_set", MOD_UI, REQUIRED, REQUIRED, REQUIRED, REQUIRED) \ + X("state_load", MOD_UI, REQUIRED, REQUIRED, REQUIRED, REQUIRED) \ + X("state_save", MOD_UI, REQUIRED, REQUIRED, REQUIRED, REQUIRED) \ + X("state_tmpdir", MOD_UI, REQUIRED, REQUIRED, REQUIRED, REQUIRED) \ + X("feature_enable", MOD_UI, REQUIRED, REQUIRED, REQUIRED, REQUIRED) \ + X("bundle_add", MOD_UI, REQUIRED, REQUIRED, REQUIRED, REQUIRED) \ + X("bundle_remove", MOD_UI, REQUIRED, REQUIRED, REQUIRED, REQUIRED) \ + X("preset_load", MOD_UI, REQUIRED, REQUIRED, REQUIRED, REQUIRED) \ + X("preset_save", MOD_UI, REQUIRED, REQUIRED, REQUIRED, REQUIRED) \ + X("preset_show", MOD_UI, REQUIRED, REQUIRED, REQUIRED, REQUIRED) \ + X("transport", MOD_UI, REQUIRED, REQUIRED, REQUIRED, REQUIRED) \ + X("transport_sync", MOD_UI, REQUIRED, REQUIRED, REQUIRED, REQUIRED) \ + X("cpu_load", MOD_UI, REQUIRED, REQUIRED, REQUIRED, REQUIRED) \ + X("midi_learn", MOD_UI, OPTIONAL, REQUIRED, OPTIONAL, REQUIRED) \ + X("midi_map", MOD_UI, OPTIONAL, REQUIRED, OPTIONAL, REQUIRED) \ + X("midi_unmap", MOD_UI, OPTIONAL, REQUIRED, OPTIONAL, REQUIRED) \ + X("cv_map", MOD_UI, OPTIONAL, REQUIRED, OPTIONAL, REQUIRED) \ + X("cv_unmap", MOD_UI, OPTIONAL, REQUIRED, OPTIONAL, REQUIRED) \ + X("monitor_midi_program", MOD_UI, OPTIONAL, OPTIONAL, OPTIONAL, OPTIONAL) \ + X("monitor_output", MOD_UI, OPTIONAL, OPTIONAL, OPTIONAL, OPTIONAL) \ + X("output_data_ready", MOD_UI, OPTIONAL, OPTIONAL, OPTIONAL, OPTIONAL) \ + X("patch_get", MOD_UI, OPTIONAL, OPTIONAL, OPTIONAL, OPTIONAL) \ + X("patch_set", MOD_UI, OPTIONAL, OPTIONAL, OPTIONAL, OPTIONAL) \ + X("cc_map", MOD_UI, ABSENT, ABSENT, ABSENT, ABSENT) \ + X("cc_value_set", MOD_UI, ABSENT, ABSENT, ABSENT, ABSENT) \ + X("cc_unmap", MOD_UI, ABSENT, ABSENT, ABSENT, ABSENT) \ + X("hmi_map", MOD_UI, ABSENT, ABSENT, ABSENT, ABSENT) \ + X("hmi_unmap", MOD_UI, ABSENT, ABSENT, ABSENT, ABSENT) \ + X("show_external_ui", MOD_UI, ABSENT, ABSENT, ABSENT, ABSENT) \ + X("set_midi_program_change_pedalboard_bank_channel", MOD_UI, ABSENT, ABSENT, ABSENT, ABSENT) \ + X("set_midi_program_change_pedalboard_snapshot_channel", MOD_UI, ABSENT, ABSENT, ABSENT, ABSENT) \ + X("disconnect", MOD_HOST, REQUIRED, REQUIRED, REQUIRED, REQUIRED) \ + X("set_bpm", MOD_HOST, REQUIRED, REQUIRED, REQUIRED, REQUIRED) \ + X("set_bpb", MOD_HOST, REQUIRED, REQUIRED, REQUIRED, REQUIRED) \ + X("quit", MOD_HOST, REQUIRED, REQUIRED, REQUIRED, REQUIRED) \ + X("param_get", MOD_HOST, OPTIONAL, OPTIONAL, OPTIONAL, OPTIONAL) \ + X("param_monitor", MOD_HOST, OPTIONAL, OPTIONAL, OPTIONAL, OPTIONAL) \ + X("licensee", MOD_HOST, OPTIONAL, OPTIONAL, OPTIONAL, OPTIONAL) \ + X("monitor", MOD_HOST, OPTIONAL, OPTIONAL, OPTIONAL, OPTIONAL) \ + X("load", MOD_HOST, OPTIONAL, OPTIONAL, OPTIONAL, OPTIONAL) \ + X("save", MOD_HOST, OPTIONAL, OPTIONAL, OPTIONAL, OPTIONAL) \ + X("help", MOD_HOST, OPTIONAL, OPTIONAL, OPTIONAL, OPTIONAL) \ + X(PHD_VERB_TRACK_INFO, MOD_HOST, OPTIONAL, OPTIONAL, OPTIONAL, OPTIONAL) \ + X(PHD_VERB_REMOTE_PAGES, MOD_HOST, OPTIONAL, OPTIONAL, OPTIONAL, OPTIONAL) \ + X(PHD_VERB_REMOTE_PAGE_GET, MOD_HOST, OPTIONAL, OPTIONAL, OPTIONAL, OPTIONAL) \ + X(PHD_VERB_PARAM_INFO, MOD_HOST, OPTIONAL, OPTIONAL, OPTIONAL, OPTIONAL) \ + X(PHD_VERB_PRESET_LIST, DAEMON, OPTIONAL, OPTIONAL, OPTIONAL, OPTIONAL) \ + X(PHD_VERB_PRESET_GET, DAEMON, OPTIONAL, OPTIONAL, OPTIONAL, OPTIONAL) \ + X(PHD_VERB_NOTE_INPUT, DAEMON, OPTIONAL, OPTIONAL, OPTIONAL, OPTIONAL) \ + X(PHD_VERB_NOTE_ON, DAEMON, OPTIONAL, OPTIONAL, OPTIONAL, OPTIONAL) \ + X(PHD_VERB_NOTE_OFF, DAEMON, OPTIONAL, OPTIONAL, OPTIONAL, OPTIONAL) \ + X(PHD_VERB_ALL_NOTES_OFF, DAEMON, OPTIONAL, OPTIONAL, OPTIONAL, OPTIONAL) \ + X(PHD_VERB_WORKER_LIST, DAEMON, REQUIRED, REQUIRED, REQUIRED, REQUIRED) \ + X(PHD_VERB_INSTANCE_INFO, DAEMON, REQUIRED, REQUIRED, REQUIRED, REQUIRED) \ + X(PHD_VERB_SUPERVISOR_RESET, DAEMON, REQUIRED, REQUIRED, REQUIRED, REQUIRED) \ + X(PHD_VERB_QUARANTINE_CLEAR, DAEMON, REQUIRED, REQUIRED, REQUIRED, REQUIRED) \ + X(PHD_VERB_POLICY_SET, DAEMON, REQUIRED, REQUIRED, REQUIRED, REQUIRED) \ + X(PHD_VERB_POOL_CONFIG, DAEMON, REQUIRED, REQUIRED, REQUIRED, REQUIRED) \ + X(PHD_VERB_WORKER_ENV, DAEMON, REQUIRED, REQUIRED, REQUIRED, REQUIRED) \ + X(PHD_VERB_PIN_SET, DAEMON, REQUIRED, REQUIRED, REQUIRED, REQUIRED) \ + X(PHD_VERB_PIN_CLEAR, DAEMON, REQUIRED, REQUIRED, REQUIRED, REQUIRED) \ + X(PHD_VERB_PIN_ACCEPT, DAEMON, REQUIRED, REQUIRED, REQUIRED, REQUIRED) \ + X(PHD_VERB_LV2_WORKER_SET, DAEMON, REQUIRED, REQUIRED, REQUIRED, REQUIRED) \ + X(PHD_VERB_CHAIN, DAEMON, REQUIRED, REQUIRED, REQUIRED, REQUIRED) \ + X(PHD_VERB_CHAIN_CLEAR, DAEMON, REQUIRED, REQUIRED, REQUIRED, REQUIRED) + /* ---------------------------------------------------------------- host settings */ /* what the daemon keeps of a command that names no instance and is broadcast to every worker: X(id, meaning) */ diff --git a/protocol/plugin-hostd.json b/protocol/plugin-hostd.json index 5b8c124..b8f458d 100644 --- a/protocol/plugin-hostd.json +++ b/protocol/plugin-hostd.json @@ -5,7 +5,9 @@ "constants": [ {"name": "default_command_port", "value": 5555, "unit": "port", "meaning": "the command port without -p; the feedback port is the next one, unless -n is given"}, {"name": "connect_retry_ms", "value": 5000, "unit": "ms", "meaning": "how long the idle tick asks again for a connect a respawned worker could not make yet"}, - {"name": "line_max", "value": 4096, "unit": "bytes", "meaning": "the protocol socket's buffer: the longest pin_set line the daemon takes"} + {"name": "line_max", "value": 4096, "unit": "bytes", "meaning": "the protocol socket's buffer: the longest pin_set line the daemon takes"}, + {"name": "line_words_max", "value": 64, "unit": "words", "meaning": "the words of one command line the daemon reads, the verb included; a word past them is not read"}, + {"name": "chain_members_min", "value": 1, "unit": "instances", "meaning": "the fewest members of a chain: a chain of one is valid, so one isolated plugin is a chain like any other"} ], "placement": { "own": "own", @@ -62,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, -902 for a chain or an instance that is not a number, or fewer than two 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 (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_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": [ @@ -77,6 +79,83 @@ {"name": "note_off", "arguments": " ", "reply": "resp 0", "ledger": "none", "meaning": "release a key, velocity 0 to 127, a MIDI note off; answered as note_on is"}, {"name": "all_notes_off", "arguments": "", "reply": "resp 0", "ledger": "none", "meaning": "release every key of every channel, MIDI's all notes off (controller 123) on each; answered as note_on is"} ], + "host_profiles": [ + {"name": "lv2-worker", "meaning": "an LV2 plugin's worker: mod-host, or omx-clap-host's LV2 adapter"}, + {"name": "clap-worker", "meaning": "a CLAP plugin's worker, omx-clap-host"}, + {"name": "console", "meaning": "the openmixer console as a controller of the daemon"}, + {"name": "zynthian", "meaning": "Zynthian's mod-ui and its plugind engine as a controller of the daemon"} + ], + "host_verbs": [ + {"name": "add", "source": "mod-ui", "levels": {"lv2-worker": "required", "clap-worker": "required", "console": "required", "zynthian": "required"}}, + {"name": "remove", "source": "mod-ui", "levels": {"lv2-worker": "required", "clap-worker": "required", "console": "required", "zynthian": "required"}}, + {"name": "bypass", "source": "mod-ui", "levels": {"lv2-worker": "required", "clap-worker": "required", "console": "required", "zynthian": "required"}}, + {"name": "connect", "source": "mod-ui", "levels": {"lv2-worker": "required", "clap-worker": "required", "console": "required", "zynthian": "required"}}, + {"name": "param_set", "source": "mod-ui", "levels": {"lv2-worker": "required", "clap-worker": "required", "console": "required", "zynthian": "required"}}, + {"name": "state_load", "source": "mod-ui", "levels": {"lv2-worker": "required", "clap-worker": "required", "console": "required", "zynthian": "required"}}, + {"name": "state_save", "source": "mod-ui", "levels": {"lv2-worker": "required", "clap-worker": "required", "console": "required", "zynthian": "required"}}, + {"name": "state_tmpdir", "source": "mod-ui", "levels": {"lv2-worker": "required", "clap-worker": "required", "console": "required", "zynthian": "required"}}, + {"name": "feature_enable", "source": "mod-ui", "levels": {"lv2-worker": "required", "clap-worker": "required", "console": "required", "zynthian": "required"}}, + {"name": "bundle_add", "source": "mod-ui", "levels": {"lv2-worker": "required", "clap-worker": "required", "console": "required", "zynthian": "required"}}, + {"name": "bundle_remove", "source": "mod-ui", "levels": {"lv2-worker": "required", "clap-worker": "required", "console": "required", "zynthian": "required"}}, + {"name": "preset_load", "source": "mod-ui", "levels": {"lv2-worker": "required", "clap-worker": "required", "console": "required", "zynthian": "required"}}, + {"name": "preset_save", "source": "mod-ui", "levels": {"lv2-worker": "required", "clap-worker": "required", "console": "required", "zynthian": "required"}}, + {"name": "preset_show", "source": "mod-ui", "levels": {"lv2-worker": "required", "clap-worker": "required", "console": "required", "zynthian": "required"}}, + {"name": "transport", "source": "mod-ui", "levels": {"lv2-worker": "required", "clap-worker": "required", "console": "required", "zynthian": "required"}}, + {"name": "transport_sync", "source": "mod-ui", "levels": {"lv2-worker": "required", "clap-worker": "required", "console": "required", "zynthian": "required"}}, + {"name": "cpu_load", "source": "mod-ui", "levels": {"lv2-worker": "required", "clap-worker": "required", "console": "required", "zynthian": "required"}}, + {"name": "midi_learn", "source": "mod-ui", "levels": {"lv2-worker": "optional", "clap-worker": "required", "console": "optional", "zynthian": "required"}}, + {"name": "midi_map", "source": "mod-ui", "levels": {"lv2-worker": "optional", "clap-worker": "required", "console": "optional", "zynthian": "required"}}, + {"name": "midi_unmap", "source": "mod-ui", "levels": {"lv2-worker": "optional", "clap-worker": "required", "console": "optional", "zynthian": "required"}}, + {"name": "cv_map", "source": "mod-ui", "levels": {"lv2-worker": "optional", "clap-worker": "required", "console": "optional", "zynthian": "required"}}, + {"name": "cv_unmap", "source": "mod-ui", "levels": {"lv2-worker": "optional", "clap-worker": "required", "console": "optional", "zynthian": "required"}}, + {"name": "monitor_midi_program", "source": "mod-ui", "levels": {"lv2-worker": "optional", "clap-worker": "optional", "console": "optional", "zynthian": "optional"}}, + {"name": "monitor_output", "source": "mod-ui", "levels": {"lv2-worker": "optional", "clap-worker": "optional", "console": "optional", "zynthian": "optional"}}, + {"name": "output_data_ready", "source": "mod-ui", "levels": {"lv2-worker": "optional", "clap-worker": "optional", "console": "optional", "zynthian": "optional"}}, + {"name": "patch_get", "source": "mod-ui", "levels": {"lv2-worker": "optional", "clap-worker": "optional", "console": "optional", "zynthian": "optional"}}, + {"name": "patch_set", "source": "mod-ui", "levels": {"lv2-worker": "optional", "clap-worker": "optional", "console": "optional", "zynthian": "optional"}}, + {"name": "cc_map", "source": "mod-ui", "levels": {"lv2-worker": "absent", "clap-worker": "absent", "console": "absent", "zynthian": "absent"}}, + {"name": "cc_value_set", "source": "mod-ui", "levels": {"lv2-worker": "absent", "clap-worker": "absent", "console": "absent", "zynthian": "absent"}}, + {"name": "cc_unmap", "source": "mod-ui", "levels": {"lv2-worker": "absent", "clap-worker": "absent", "console": "absent", "zynthian": "absent"}}, + {"name": "hmi_map", "source": "mod-ui", "levels": {"lv2-worker": "absent", "clap-worker": "absent", "console": "absent", "zynthian": "absent"}}, + {"name": "hmi_unmap", "source": "mod-ui", "levels": {"lv2-worker": "absent", "clap-worker": "absent", "console": "absent", "zynthian": "absent"}}, + {"name": "show_external_ui", "source": "mod-ui", "levels": {"lv2-worker": "absent", "clap-worker": "absent", "console": "absent", "zynthian": "absent"}}, + {"name": "set_midi_program_change_pedalboard_bank_channel", "source": "mod-ui", "levels": {"lv2-worker": "absent", "clap-worker": "absent", "console": "absent", "zynthian": "absent"}}, + {"name": "set_midi_program_change_pedalboard_snapshot_channel", "source": "mod-ui", "levels": {"lv2-worker": "absent", "clap-worker": "absent", "console": "absent", "zynthian": "absent"}}, + {"name": "disconnect", "source": "mod-host", "levels": {"lv2-worker": "required", "clap-worker": "required", "console": "required", "zynthian": "required"}}, + {"name": "set_bpm", "source": "mod-host", "levels": {"lv2-worker": "required", "clap-worker": "required", "console": "required", "zynthian": "required"}}, + {"name": "set_bpb", "source": "mod-host", "levels": {"lv2-worker": "required", "clap-worker": "required", "console": "required", "zynthian": "required"}}, + {"name": "quit", "source": "mod-host", "levels": {"lv2-worker": "required", "clap-worker": "required", "console": "required", "zynthian": "required"}}, + {"name": "param_get", "source": "mod-host", "levels": {"lv2-worker": "optional", "clap-worker": "optional", "console": "optional", "zynthian": "optional"}}, + {"name": "param_monitor", "source": "mod-host", "levels": {"lv2-worker": "optional", "clap-worker": "optional", "console": "optional", "zynthian": "optional"}}, + {"name": "licensee", "source": "mod-host", "levels": {"lv2-worker": "optional", "clap-worker": "optional", "console": "optional", "zynthian": "optional"}}, + {"name": "monitor", "source": "mod-host", "levels": {"lv2-worker": "optional", "clap-worker": "optional", "console": "optional", "zynthian": "optional"}}, + {"name": "load", "source": "mod-host", "levels": {"lv2-worker": "optional", "clap-worker": "optional", "console": "optional", "zynthian": "optional"}}, + {"name": "save", "source": "mod-host", "levels": {"lv2-worker": "optional", "clap-worker": "optional", "console": "optional", "zynthian": "optional"}}, + {"name": "help", "source": "mod-host", "levels": {"lv2-worker": "optional", "clap-worker": "optional", "console": "optional", "zynthian": "optional"}}, + {"name": "track_info", "source": "mod-host", "levels": {"lv2-worker": "optional", "clap-worker": "optional", "console": "optional", "zynthian": "optional"}}, + {"name": "remote_pages", "source": "mod-host", "levels": {"lv2-worker": "optional", "clap-worker": "optional", "console": "optional", "zynthian": "optional"}}, + {"name": "remote_page_get", "source": "mod-host", "levels": {"lv2-worker": "optional", "clap-worker": "optional", "console": "optional", "zynthian": "optional"}}, + {"name": "param_info", "source": "mod-host", "levels": {"lv2-worker": "optional", "clap-worker": "optional", "console": "optional", "zynthian": "optional"}}, + {"name": "preset_list", "source": "daemon", "levels": {"lv2-worker": "optional", "clap-worker": "optional", "console": "optional", "zynthian": "optional"}}, + {"name": "preset_get", "source": "daemon", "levels": {"lv2-worker": "optional", "clap-worker": "optional", "console": "optional", "zynthian": "optional"}}, + {"name": "note_input", "source": "daemon", "levels": {"lv2-worker": "optional", "clap-worker": "optional", "console": "optional", "zynthian": "optional"}}, + {"name": "note_on", "source": "daemon", "levels": {"lv2-worker": "optional", "clap-worker": "optional", "console": "optional", "zynthian": "optional"}}, + {"name": "note_off", "source": "daemon", "levels": {"lv2-worker": "optional", "clap-worker": "optional", "console": "optional", "zynthian": "optional"}}, + {"name": "all_notes_off", "source": "daemon", "levels": {"lv2-worker": "optional", "clap-worker": "optional", "console": "optional", "zynthian": "optional"}}, + {"name": "worker_list", "source": "daemon", "levels": {"lv2-worker": "required", "clap-worker": "required", "console": "required", "zynthian": "required"}}, + {"name": "instance_info", "source": "daemon", "levels": {"lv2-worker": "required", "clap-worker": "required", "console": "required", "zynthian": "required"}}, + {"name": "supervisor_reset", "source": "daemon", "levels": {"lv2-worker": "required", "clap-worker": "required", "console": "required", "zynthian": "required"}}, + {"name": "quarantine_clear", "source": "daemon", "levels": {"lv2-worker": "required", "clap-worker": "required", "console": "required", "zynthian": "required"}}, + {"name": "policy_set", "source": "daemon", "levels": {"lv2-worker": "required", "clap-worker": "required", "console": "required", "zynthian": "required"}}, + {"name": "pool_config", "source": "daemon", "levels": {"lv2-worker": "required", "clap-worker": "required", "console": "required", "zynthian": "required"}}, + {"name": "worker_env", "source": "daemon", "levels": {"lv2-worker": "required", "clap-worker": "required", "console": "required", "zynthian": "required"}}, + {"name": "pin_set", "source": "daemon", "levels": {"lv2-worker": "required", "clap-worker": "required", "console": "required", "zynthian": "required"}}, + {"name": "pin_clear", "source": "daemon", "levels": {"lv2-worker": "required", "clap-worker": "required", "console": "required", "zynthian": "required"}}, + {"name": "pin_accept", "source": "daemon", "levels": {"lv2-worker": "required", "clap-worker": "required", "console": "required", "zynthian": "required"}}, + {"name": "lv2_worker_set", "source": "daemon", "levels": {"lv2-worker": "required", "clap-worker": "required", "console": "required", "zynthian": "required"}}, + {"name": "chain", "source": "daemon", "levels": {"lv2-worker": "required", "clap-worker": "required", "console": "required", "zynthian": "required"}}, + {"name": "chain_clear", "source": "daemon", "levels": {"lv2-worker": "required", "clap-worker": "required", "console": "required", "zynthian": "required"}} + ], "track_kinds": ["bus", "return", "master"], "param_scales": [ {"name": "linear", "meaning": "the value is linear in position"}, diff --git a/protocol/plugin-hostd.schema.json b/protocol/plugin-hostd.schema.json index fa390cc..3fbffef 100644 --- a/protocol/plugin-hostd.schema.json +++ b/protocol/plugin-hostd.schema.json @@ -446,6 +446,92 @@ ] }, "minItems": 1 + }, + "host_profiles": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "meaning": { + "type": "string" + } + }, + "required": [ + "name", + "meaning" + ], + "additionalProperties": false + }, + "minItems": 1 + }, + "host_verbs": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "pattern": "^[a-z_0-9]+$" + }, + "source": { + "enum": [ + "mod-ui", + "mod-host", + "daemon" + ] + }, + "levels": { + "type": "object", + "properties": { + "lv2-worker": { + "enum": [ + "required", + "optional", + "absent" + ] + }, + "clap-worker": { + "enum": [ + "required", + "optional", + "absent" + ] + }, + "console": { + "enum": [ + "required", + "optional", + "absent" + ] + }, + "zynthian": { + "enum": [ + "required", + "optional", + "absent" + ] + } + }, + "required": [ + "lv2-worker", + "clap-worker", + "console", + "zynthian" + ], + "additionalProperties": false + } + }, + "required": [ + "name", + "source", + "levels" + ], + "additionalProperties": false + }, + "minItems": 1 } }, "required": [ @@ -460,6 +546,8 @@ "errors", "verbs", "instance_verbs", + "host_profiles", + "host_verbs", "track_kinds", "param_scales", "remote_page_slots", diff --git a/src/supervisor.c b/src/supervisor.c index 0f4ba06..3fe7082 100644 --- a/src/supervisor.c +++ b/src/supervisor.c @@ -2021,7 +2021,7 @@ char *sup_chain(int id, const int *ids, int n) chain_t *c = (id >= 0 && id < MAX_INSTANCE) ? g_chain[id] : NULL; int k, m; - if (id < 0 || id >= MAX_INSTANCE || n < 2) + if (id < 0 || id >= MAX_INSTANCE || n < PHD_CHAIN_MEMBERS_MIN) return sup_resp(ERR_INVALID_OPERATION); for (k = 0; k < n; k++) { diff --git a/src/verbs.c b/src/verbs.c index 7f1fcbb..1ad9a26 100644 --- a/src/verbs.c +++ b/src/verbs.c @@ -32,7 +32,7 @@ #include "supervisor.h" #include "verbs.h" -#define MAX_TOKENS 64 +#define MAX_TOKENS PHD_LINE_WORDS_MAX volatile int g_quitting; @@ -101,12 +101,12 @@ static int number(const char *word, int *out) return 1; } -/* chain [ ...] */ +/* chain [ ...]: at least PHD_CHAIN_MEMBERS_MIN members */ static char *chain_words(char **tok, int ntok) { int id, ids[MAX_TOKENS], n; - if (ntok < 4 || !number(tok[1], &id)) + if (ntok < 2 + PHD_CHAIN_MEMBERS_MIN || !number(tok[1], &id)) return sup_resp(ERR_INVALID_OPERATION); for (n = 2; n < ntok; n++) if (!number(tok[n], &ids[n - 2])) diff --git a/tests/daemon_test.py b/tests/daemon_test.py index 061835f..68d6801 100644 --- a/tests/daemon_test.py +++ b/tests/daemon_test.py @@ -2465,7 +2465,7 @@ def a_chain_is_declared_over_existing_instances_and_each_refusal_is_named(): d.expect("chain 1 1 2 77", resp("CHAIN_UNKNOWN_INSTANCE")) d.expect("chain 1 1 2 1", resp("CHAIN_DUPLICATE")) d.expect("chain 1 1 9", resp("CHAIN_MIXED_FORMAT")) - for bad in ("chain 1 1", "chain 1", "chain x 1 2", "chain 1 1 two", "chain -1 1 2", "chain_clear", "chain_clear x"): + for bad in ("chain 1", "chain x 1 2", "chain 1 1 two", "chain -1 1 2", "chain_clear", "chain_clear x"): d.expect(bad, "resp -902") check(d.workers() == before, "a refused chain moves nothing: %s" % d.workers()) old = d.holder(1)[1]["pid"] @@ -2523,6 +2523,62 @@ def a_chain_is_declared_over_existing_instances_and_each_refusal_is_named(): os.unlink(log) +@test +def a_chain_of_one_runs_in_a_worker_of_its_own_and_a_chain_of_none_is_refused(): + """chain_members_min is 1: one isolated plugin is a chain like any other, and a chain with no member is no line""" + log = tempfile.mktemp(prefix="plugin-hostd-fake-log.") + d = daemon(env={"FAKE_LOG": log}) + try: + d.expect("add clap:fake#solo 21", "resp 21") + d.expect("param_set 21 gain 0.7000", "resp 0") + before = d.workers() + d.expect("chain 5", "resp -902") + check(d.workers() == before and chain_of(d, 5) == {}, "a chain of none moves nothing: %s" % d.workers()) + d.expect("chain_clear 5", resp("NO_SUCH_CHAIN")) + mark = d.mark() + d.expect("chain 5 21", "resp 0") + formed = d.wait_event("chain_formed 5 ", since=mark) + w = chain_of(d, 5) + check(len(w) == 1, "one worker runs the chain of one: %s" % d.workers()) + k, w = list(w.items())[0] + check(w["inst"] == [21] and w["state"] == "up", "its one member: %s" % w) + check(formed == "chain_formed 5 %s 21" % k, "the event names the chain of one: " + formed) + d.expect("param_get 21 gain", "resp 0 0.7000") + check("chain 5 21" in sent_to(log, w["pid"]), "the worker is told the chain of one: %s" % sent_to(log, w["pid"])) + d.expect("chain_clear 5", "resp 0") + _, w = d.wait_up(21) + check(w["place"] == "own" and w["inst"] == [21], "chain_clear gives the member a worker of its own: %s" % w) + finally: + d.close() + if os.path.exists(log): + os.unlink(log) + + +@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""" + p = Plugins() + d = p.daemon() + try: + d.expect(p.lv2_pin(), "resp 0") + d.expect("add %s 1" % LV2_URI, "resp 1") + d.expect("add %s 2" % LV2_URI, "resp 2") + 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()) + check(p.received()[seen:] == [], "no worker saw a refused chain: %s" % p.received()[seen:]) + d.expect("chain_clear 4", resp("NO_SUCH_CHAIN")) + finally: + d.close() + p.close() + + @test def a_chain_of_three_runs_in_one_worker_and_its_crashed_member_is_quarantined_while_the_others_go_on_in_order(): log = tempfile.mktemp(prefix="plugin-hostd-fake-log.") diff --git a/tests/sabotage.py b/tests/sabotage.py index b1ace1e..6930787 100644 --- a/tests/sabotage.py +++ b/tests/sabotage.py @@ -15,7 +15,7 @@ # tests/readme_build.py, with no daemon to build; "probeschema" breaks protocol/probe-output.schema.json and runs # tests/probe_schema.py; "relay" breaks src/relay.c and runs tests/relay_test.c; "alloc" breaks src/relay.c or src/lv2.c and runs make test-alloc; "contract" breaks the probe runner or the generator and runs tests/contract_derived.py; "consumer" breaks the header and compiles tests/consumer.c against it; "probe" breaks src/probe.c and runs tests/probe_test.py; "nopins" breaks the packaging or a workflow and runs tests/no_pins.py; "ciguards" breaks a workflow and runs tests/ci_guards.py; # "vocabulary" breaks the probe schema, its template or a copy of omx-contract's omx-vocabulary.schema.json and runs -# tests/vocabulary_derived.py +# tests/vocabulary_derived.py; "verbtable" breaks the header and runs tests/verb_table.py SABOTAGE = [ ("a runner that refuses unshare passes build-and-test with a warning", ".github/workflows/ci.yml", ' echo "::error::test-jack did not run: this runner refuses a private user and pid namespace with its own /proc"\n exit 1\n', @@ -606,6 +606,32 @@ ' if (i->chain && !chain_whole(i->chain))\n i->chain->rejoin = 1;\n', '', "a_chain_that_dies_naming_no_culprit_is_split_and_rejoins_without_the_one_that_dies_again"), + ("a row of the host verb table is dropped", "include/plugin-hostd/protocol.h", + ' X("cpu_load", MOD_UI, REQUIRED, REQUIRED, REQUIRED, REQUIRED) \\\n', "", "verb table", "verbtable"), + ("a verb is added to the daemon with no row in the host verb table", "include/plugin-hostd/protocol.h", + ' X(CHAIN_CLEAR, PHD_VERB_CHAIN_CLEAR, "", "resp 0", \\\n', + ' X(SABOTAGE, "verb_table_sabotage", "", "resp 0", "a verb with no row") \\\n' + ' X(CHAIN_CLEAR, PHD_VERB_CHAIN_CLEAR, "", "resp 0", \\\n', "verb table", "verbtable"), + ("a verb the daemon answers itself is absent from a profile", "include/plugin-hostd/protocol.h", + " X(PHD_VERB_CHAIN, DAEMON, REQUIRED, REQUIRED, REQUIRED, REQUIRED) \\\n", + " X(PHD_VERB_CHAIN, DAEMON, REQUIRED, REQUIRED, ABSENT, REQUIRED) \\\n", "verb table", "verbtable"), + ("a verb mod-host never had is required of a profile", "include/plugin-hostd/protocol.h", + ' X("set_midi_program_change_pedalboard_bank_channel", MOD_UI, ABSENT, ABSENT, ABSENT, ABSENT) \\\n', + ' X("set_midi_program_change_pedalboard_bank_channel", MOD_UI, ABSENT, REQUIRED, ABSENT, ABSENT) \\\n', + "verb table", "verbtable"), + ("the declared floor of a chain is two again", "include/plugin-hostd/protocol.h", + "#define PHD_CHAIN_MEMBERS_MIN 1\n", "#define PHD_CHAIN_MEMBERS_MIN 2\n", + "a_chain_of_one_runs_in_a_worker_of_its_own_and_a_chain_of_none_is_refused"), + ("the grammar of chain asks two members, not the declared floor", "src/verbs.c", + "if (ntok < 2 + PHD_CHAIN_MEMBERS_MIN || ", "if (ntok < 4 || ", + "a_chain_of_one_runs_in_a_worker_of_its_own_and_a_chain_of_none_is_refused"), + ("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"), ("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))', @@ -709,6 +735,10 @@ def run_test(tree, name, jack=False): p = subprocess.run([sys.executable, os.path.join(ROOT, "tests", "vocabulary_derived.py"), tree], capture_output=True, text=True, timeout=60, env=dict(os.environ, OMX_VOCABULARY=os.path.join(tree, "omx-vocabulary.schema.json"))) return p.returncode, p.stdout + if jack == "verbtable": + p = subprocess.run([sys.executable, os.path.join(ROOT, "tests", "verb_table.py"), tree], capture_output=True, + text=True, timeout=60) + return p.returncode, p.stdout if jack in ("nopins", "ciguards"): script = {"nopins": "no_pins.py", "ciguards": "ci_guards.py"}[jack] p = subprocess.run([sys.executable, os.path.join(ROOT, "tests", script), tree], capture_output=True, @@ -778,7 +808,7 @@ def run_test(tree, name, jack=False): shutil.copy(os.path.join(ROOT, f), tree) os.makedirs(os.path.join(tree, "packaging")) shutil.copy(os.path.join(ROOT, "packaging", "plugin-hostd.spec"), os.path.join(tree, "packaging")) - elif jack == "consumer": + elif jack in ("consumer", "verbtable"): shutil.copytree(os.path.join(ROOT, "include"), os.path.join(tree, "include")) elif jack == "nopins": for f in ("packaging/plugin-hostd.spec", "debian/control", ".github/workflows/ci.yml"): @@ -804,7 +834,8 @@ def run_test(tree, name, jack=False): open(os.path.join(tree, path), "w").write(src.replace(text, repl)) if jack == "probe": build_probe(tree) - elif jack not in ("readme", "nopins", "ciguards", "format", "relay", "contract", "consumer", "probeschema", "vocabulary", "alloc"): + elif jack not in ("readme", "nopins", "ciguards", "format", "relay", "contract", "consumer", "probeschema", "vocabulary", "alloc", + "verbtable"): for f in os.listdir(os.path.join(tree, "src")): if f.endswith(".o"): os.unlink(os.path.join(tree, "src", f)) diff --git a/tests/verb_table.py b/tests/verb_table.py new file mode 100644 index 0000000..db4f819 --- /dev/null +++ b/tests/verb_table.py @@ -0,0 +1,104 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# The host verb table (protocol.h's PHD_HOST_VERBS) has a row for every verb a controller sends the daemon, no more and +# no fewer: every command of mod-host's protocol (protocol/mod-host.json, written from mod-host.h), every verb of +# PHD_VERBS a controller sends, and every one of PHD_INSTANCE_VERBS. A row for a verb that none of them declares is a +# verb of Zynthian's mod-ui that mod-host never had, and it is absent from every profile. A verb the daemon answers +# itself is required of every profile, since no worker can take it away. The table is read as the compiler sees it: a +# small program built against the header prints it. +# +# python3 tests/verb_table.py [] the tree whose include/ is read (default: this one) +import json, os, subprocess, sys, tempfile + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +TREE = os.path.abspath(sys.argv[1]) if len(sys.argv) > 1 else ROOT +CC = os.environ.get("CC", "cc") + +DUMP = r''' +#include +#include +#include + +int main(void) +{ +#define X(id, name, meaning) printf("profile\t%s\n", name); + PHD_HOST_PROFILES(X) +#undef X +#define X(name, source, lv2, clap, console, zynthian) \ + printf("row\t%s\t%s\t%s\t%s\t%s\t%s\n", name, PHD_HOST_VERB_SOURCE_##source, PHD_LEVEL_##lv2, PHD_LEVEL_##clap, \ + PHD_LEVEL_##console, PHD_LEVEL_##zynthian); + PHD_HOST_VERBS(X) +#undef X +#define X(id, name, args, reply, meaning) \ + printf("verb\t%s\t%s\n", name, strstr(meaning, "sent by the daemon") ? "daemon-sends" : "controller-sends"); + PHD_VERBS(X) +#undef X +#define X(id, name, args, reply, ledger, meaning) printf("instance\t%s\n", name); + PHD_INSTANCE_VERBS(X) +#undef X + return 0; +} +''' + +failed = 0 + + +def fail(what): + global failed + print("FAIL verb table: " + what) + failed += 1 + + +def dump(tree): + with tempfile.TemporaryDirectory() as tmp: + src, exe = os.path.join(tmp, "dump.c"), os.path.join(tmp, "dump") + with open(src, "w") as f: + f.write(DUMP) + subprocess.run([CC, "-std=gnu99", "-Wall", "-Werror", "-I" + os.path.join(tree, "include"), "-o", exe, src], + check=True) + return subprocess.run([exe], check=True, capture_output=True, text=True).stdout + + +profiles, rows, own, instance = [], [], {}, set() +for line in dump(TREE).splitlines(): + f = line.split("\t") + if f[0] == "profile": + profiles.append(f[1]) + elif f[0] == "row": + rows.append((f[1], f[2], dict(zip(profiles, f[3:])))) + elif f[0] == "verb": + own[f[1]] = f[2] + elif f[0] == "instance": + instance.add(f[1]) + +with open(os.path.join(ROOT, "protocol", "mod-host.json")) as f: + commands = {c["verb"] for c in json.load(f)["commands"]} +controller = {v for v, who in own.items() if who == "controller-sends"} +daemon_answers = controller - commands +universe = commands | controller | instance + +if len(profiles) != 4 or len(rows) < 60 or len(commands) < 40 or len(controller) < 10 or len(instance) < 8: + fail("read %d profiles, %d rows, %d mod-host commands, %d controller verbs and %d instance verbs" + % (len(profiles), len(rows), len(commands), len(controller), len(instance))) + +names = [r[0] for r in rows] +for n in sorted({n for n in names if names.count(n) > 1}): + fail("%s has more than one row" % n) +for n in sorted(universe - set(names)): + fail("%s is a verb the daemon is sent, with no row in PHD_HOST_VERBS" % n) +for name, source, levels in rows: + if len(levels) != len(profiles) or set(levels.values()) - {"required", "optional", "absent"}: + fail("%s: levels %s are not one of required, optional, absent per profile" % (name, levels)) + if name not in universe and (source != "mod-ui" or set(levels.values()) != {"absent"}): + fail("%s is declared by neither mod-host.h nor protocol.h, so it is a mod-ui verb absent from every profile, " + "not %s %s" % (name, source, levels)) + if source == "daemon" and name in commands: + fail("%s is a command of mod-host.h, so the daemon is not its only source" % name) + if source != "daemon" and name in universe and name not in commands: + fail("%s is declared by protocol.h alone, so its source is the daemon, not %s" % (name, source)) + if name in daemon_answers and set(levels.values()) != {"required"}: + fail("%s is answered by the daemon itself, so it is required of every profile, not %s" % (name, levels)) + +if failed: + sys.exit(1) +print("ok verb table: %d rows over %d profiles cover %d mod-host commands, %d controller verbs and %d instance verbs" + % (len(rows), len(profiles), len(commands), len(controller), len(instance))) diff --git a/tools/protocol-gen.c b/tools/protocol-gen.c index bdb8f8b..7f1dd9a 100644 --- a/tools/protocol-gen.c +++ b/tools/protocol-gen.c @@ -176,6 +176,29 @@ static void json(void) printf("}"); \ sep = ",\n"; PHD_INSTANCE_VERBS(X) +#undef X + printf("\n ],\n \"host_profiles\": ["); + sep = "\n"; +#define X(id, name, meaning) \ + printf("%s {\"name\": ", sep); \ + jstr(name); \ + printf(", \"meaning\": "); \ + jstr(meaning); \ + printf("}"); \ + sep = ",\n"; + PHD_HOST_PROFILES(X) +#undef X + printf("\n ],\n \"host_verbs\": ["); + sep = "\n"; +#define X(name, source, lv2, clap, console, zynthian) \ + printf("%s {\"name\": ", sep); \ + jstr(name); \ + printf(", \"source\": "); \ + jstr(PHD_HOST_VERB_SOURCE_##source); \ + printf(", \"levels\": {\"lv2-worker\": \"%s\", \"clap-worker\": \"%s\", \"console\": \"%s\", \"zynthian\": \"%s\"}}", \ + PHD_LEVEL_##lv2, PHD_LEVEL_##clap, PHD_LEVEL_##console, PHD_LEVEL_##zynthian); \ + sep = ",\n"; + PHD_HOST_VERBS(X) #undef X printf("\n ],\n \"track_kinds\": ["); sep = ""; @@ -320,6 +343,35 @@ static void region_constants(void) #undef X } +/* the host verb table: a row per verb, a column per profile */ +static void region_host_verbs(void) +{ + printf("| verb | sent by"); +#define X(id, name, meaning) printf(" | %s", name); + PHD_HOST_PROFILES(X) +#undef X + printf(" |\n|---|---"); +#define X(id, name, meaning) printf("|---"); + PHD_HOST_PROFILES(X) +#undef X + printf("|\n"); +#define X(name, source, lv2, clap, console, zynthian) \ + printf("| `%s` | %s | %s | %s | %s | %s |\n", name, PHD_HOST_VERB_SOURCE_##source, PHD_LEVEL_##lv2, PHD_LEVEL_##clap, \ + PHD_LEVEL_##console, PHD_LEVEL_##zynthian); + PHD_HOST_VERBS(X) +#undef X + printf("\nThe profiles: "); + { + const char *sep = ""; +#define X(id, name, meaning) printf("%s`%s`, %s", sep, name, meaning); sep = "; "; + PHD_HOST_PROFILES(X) +#undef X + } + printf(". `%s` is answered as its grammar says; `%s` is answered, or refused with -902, and never misread; `%s` " + "is refused on purpose, and no client of the profile may need it.\n", PHD_LEVEL_REQUIRED, PHD_LEVEL_OPTIONAL, + PHD_LEVEL_ABSENT); +} + static void region_placement(void) { printf("The placement of an `add` is `%s`. `%s` is a worker for that instance alone; `%s` is the pool's worker, " @@ -497,7 +549,7 @@ static const struct { void (*emit)(void); } REGIONS[] = { { "config", region_config }, { "constants", region_constants }, { "placement", region_placement }, - { "verbs", region_verbs }, { "instance-verbs", region_instance_verbs }, { "errors", region_errors }, { "events", region_events }, + { "verbs", region_verbs }, { "instance-verbs", region_instance_verbs }, { "host-verbs", region_host_verbs }, { "errors", region_errors }, { "events", region_events }, { "pin-limits", region_pin_limits }, { "man-pin-limits", region_man_pin_limits }, { "readiness", region_readiness }, { "worker-ready", region_worker_ready }, { "man-keys", region_man_keys }, { "man-ready", region_man_ready }, { "man-port", region_man_port },