From fb2852d50600670b17184ab12c97dc2c6350d7ca Mon Sep 17 00:00:00 2001 From: Pau Aliagas Date: Sun, 18 Oct 2026 11:55:38 +0200 Subject: [PATCH 01/16] A pin whose verdict is refused refuses the add with PIN_REFUSED (-516) verdict=refused was parsed, stored and then ignored. A plugin whose files failed qualification was added like any other. pins_check now refuses such a plugin with the new code PHD_ERR_PIN_REFUSED (-516), after the files have been checked against the pin: the verdict applies only to the pinned files, so files that differ still answer -509 first. Because the add and the replay share pins_check, an instance whose pin turns refused while it runs is refused at its next replay (instance_replay_refused with -516). The code is declared in protocol.h, and the README and protocol/plugin-hostd.json are regenerated from it. The test fails on main (the add answers resp 0). It covers pin_set, a pin_file under first-use (the plugin is not learned into the store), the replay, and changed files. A sabotage arm removes the check. Closes #71 --- README.md | 6 ++++- include/plugin-hostd/protocol.h | 6 ++++- protocol/plugin-hostd.json | 3 ++- src/pins.c | 6 +++++ src/pins.h | 3 ++- tests/daemon_test.py | 45 +++++++++++++++++++++++++++++++++ tests/sabotage.py | 3 +++ 7 files changed, 68 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index cd2386d..ef56cdb 100644 --- a/README.md +++ b/README.md @@ -237,6 +237,7 @@ Error codes the daemon adds to mod-host's: | `-513` | `PHD_ERR_NO_NOTE_INPUT` | the instance's plugin takes no notes (an effect): its worker answers note_on, note_off and all_notes_off with this code | | `-514` | `PHD_ERR_NOTES_BY_JACK` | the instance's worker takes no notes on its socket (mod-host): the daemon answers the note verbs and note_input with this code, and the plugin is played through its jack MIDI input with connect | | `-515` | `PHD_ERR_NO_PRESET_LIST` | the instance's worker cannot list the plugin's presets (mod-host): the daemon answers preset_list and preset_get with this code; preset_load still takes a preset the controller knows | +| `-516` | `PHD_ERR_PIN_REFUSED` | the plugin's pin carries verdict=refused: its files are the ones that failed qualification, so add is refused and no worker sees it, and a replayed add is refused the same way; files that differ from the pin answer (-509) first | A worker's own refusal is returned as it said it. An `add` that killed its worker answers mod-host's `-102`. @@ -362,7 +363,10 @@ files, keeps the pin in the pin store (`$XDG_STATE_HOME/plugin-hostd/pins`) and asked of the worker after the add (`pin_layout`) and recorded too. A host that pins by itself sets `pin_mode strict`. The store, a `pin_file` and `pin_set` carry one line format, `pin_set [verdict=]` with the verb optional in a file. In `first-use` only a pin with `verdict=in-process`, which plugin-qualify writes, lets a plugin -share a worker. A plugin that changed since it was pinned is refused (-509, or -510 from the worker), the +share a worker. A pin with `verdict=refused` names files that failed qualification. Their `add` answers `-516` +(`PIN_REFUSED`), no worker sees it, and a replay is refused with the same code. A pin that changes to `refused` while +the instance runs takes effect at its next replay. Files that differ from the pin answer `-509` first, because the +verdict was given for the pinned files only. A plugin that changed since it was pinned is refused (-509, or -510 from the worker), the `pin_changed files|layout` event names what changed, and `pin_accept ` takes the new pin. What cannot be pinned: diff --git a/include/plugin-hostd/protocol.h b/include/plugin-hostd/protocol.h index 33f2f37..cc0cd29 100644 --- a/include/plugin-hostd/protocol.h +++ b/include/plugin-hostd/protocol.h @@ -260,6 +260,7 @@ static inline int phd_pool_name_valid(const char *name) #define PHD_ERR_NO_NOTE_INPUT (-513) #define PHD_ERR_NOTES_BY_JACK (-514) #define PHD_ERR_NO_PRESET_LIST (-515) +#define PHD_ERR_PIN_REFUSED (-516) /* X(id, meaning) */ #define PHD_ERRORS(X) \ @@ -294,7 +295,10 @@ static inline int phd_pool_name_valid(const char *name) "with connect") \ X(NO_PRESET_LIST, "the instance's worker cannot list the plugin's presets (mod-host): the daemon answers " \ PHD_VERB_PRESET_LIST " and " PHD_VERB_PRESET_GET " with this code; preset_load still takes a preset " \ - "the controller knows") + "the controller knows") \ + X(PIN_REFUSED, "the plugin's pin carries " PHD_PIN_VERDICT_KEY PHD_PIN_VERDICT_REFUSED ": its files are the ones that " \ + "failed qualification, so add is refused and no worker sees it, and a replayed add is refused the " \ + "same way; files that differ from the pin answer " PHD_STR(PHD_ERR_PIN_BINARY_MISMATCH) " first") /* ---------------------------------------------------------------- verbs */ diff --git a/protocol/plugin-hostd.json b/protocol/plugin-hostd.json index fabe69b..0c6c096 100644 --- a/protocol/plugin-hostd.json +++ b/protocol/plugin-hostd.json @@ -38,7 +38,8 @@ {"name": "LV2_ADAPTER_REFUSED", "macro": "PHD_ERR_LV2_ADAPTER_REFUSED", "code": -512, "meaning": "the lv2-clap worker's LV2 adapter cannot run this plugin (MIDI it does not feed, a required feature it lacks, a layout it does not take): the worker answered the add -102, the lv2_adapter_refused event carries the hosting.* code of that reply, and under lv2_worker auto the plugin is run by the lv2 row instead and this code is never answered; a plugin no bundle of the worker's world holds is no refusal: the worker's -101 is answered"}, {"name": "NO_NOTE_INPUT", "macro": "PHD_ERR_NO_NOTE_INPUT", "code": -513, "meaning": "the instance's plugin takes no notes (an effect): its worker answers note_on, note_off and all_notes_off with this code"}, {"name": "NOTES_BY_JACK", "macro": "PHD_ERR_NOTES_BY_JACK", "code": -514, "meaning": "the instance's worker takes no notes on its socket (mod-host): the daemon answers the note verbs and note_input with this code, and the plugin is played through its jack MIDI input with connect"}, - {"name": "NO_PRESET_LIST", "macro": "PHD_ERR_NO_PRESET_LIST", "code": -515, "meaning": "the instance's worker cannot list the plugin's presets (mod-host): the daemon answers preset_list and preset_get with this code; preset_load still takes a preset the controller knows"} + {"name": "NO_PRESET_LIST", "macro": "PHD_ERR_NO_PRESET_LIST", "code": -515, "meaning": "the instance's worker cannot list the plugin's presets (mod-host): the daemon answers preset_list and preset_get with this code; preset_load still takes a preset the controller knows"}, + {"name": "PIN_REFUSED", "macro": "PHD_ERR_PIN_REFUSED", "code": -516, "meaning": "the plugin's pin carries verdict=refused: its files are the ones that failed qualification, so add is refused and no worker sees it, and a replayed add is refused the same way; files that differ from the pin answer (-509) first"} ], "verbs": [ {"name": "add", "arguments": " [own | pool: | default] [client_name]", "reply": "resp ", "meaning": "mod-host's add with a placement; a token after the instance that is not a placement is the jack client name"}, diff --git a/src/pins.c b/src/pins.c index d3484db..f1b3583 100644 --- a/src/pins.c +++ b/src/pins.c @@ -663,6 +663,12 @@ int pins_check(const format_t *format, const char *uri, const char *search, cons names_free(&files); for (n = 0; n < p->nfiles && rc == SUCCESS; n++) rc = hash_file(root, p->path[n], p->digest[n], uri); + /* the verdict is about these very files: only once they are the pinned ones does a refused one refuse the add */ + if (rc == SUCCESS && !strcmp(p->verdict, PHD_PIN_VERDICT_REFUSED)) + { + fprintf(stderr, "plugin-hostd: %s: its pin carries " PHD_PIN_VERDICT_KEY PHD_PIN_VERDICT_REFUSED "\n", uri); + rc = PHD_ERR_PIN_REFUSED; + } if (rc == SUCCESS) *layout = strcmp(p->layout, PHD_PIN_LAYOUT_UNKNOWN) ? p->layout : NULL; return rc; diff --git a/src/pins.h b/src/pins.h index 65027df..53ff752 100644 --- a/src/pins.h +++ b/src/pins.h @@ -58,7 +58,8 @@ int pins_set_layout(const char *uri, const char *layout); * loads for it (format->pin_collect, searched in `search`) all held by the pin, then every file the pin names hashed * over one open descriptor. SUCCESS, with *layout the * layout pin ":" (the table's, valid until the next pin verb) or NULL while the pin's layout is - * unknown, or PHD_ERR_PIN_ABSENT or PHD_ERR_PIN_BINARY_MISMATCH. */ + * unknown, or PHD_ERR_PIN_ABSENT or PHD_ERR_PIN_BINARY_MISMATCH, or PHD_ERR_PIN_REFUSED for the pinned files under a + * verdict of refused. */ int pins_check(const format_t *format, const char *uri, const char *search, const char **layout); /* a refusal of a pin, with the reason on stderr: PHD_ERR_PIN_BINARY_MISMATCH */ diff --git a/tests/daemon_test.py b/tests/daemon_test.py index 3c425dc..5ef7682 100644 --- a/tests/daemon_test.py +++ b/tests/daemon_test.py @@ -1464,6 +1464,51 @@ def a_layout_pinned_again_while_its_worker_was_down_is_refused_by_the_replayed_a p.close() +@test +def a_pin_whose_verdict_is_refused_refuses_the_add_with_its_own_code(): + """issue 71: verdict=refused names bytes that failed qualification; an add of them is refused by code, no worker + sees it, and a replay of an instance whose pin turned refused is refused the same way""" + p = Plugins() + d = p.daemon() + refused = harness.CODE.get("PIN_REFUSED") + try: + check(refused is not None, "the protocol declares PIN_REFUSED: %s" % sorted(harness.CODE)) + d.expect(p.clap_pin() + " verdict=refused", "resp 0") + r = d.send("add %s 0" % p.clap_uri) + check(r == "resp %d" % refused, "the add of a refused plugin answers PIN_REFUSED: %r" % r) + check(d.workers() == {} and p.received() == [], "no worker started, none was told: %s %s" % (d.workers(), p.received())) + # positive control: the same bytes qualified again run + d.expect(p.clap_pin() + " verdict=isolated", "resp 0") + d.expect("add %s 0" % p.clap_uri, "resp 0") + # a pin turned refused while the instance runs: its replay is refused with the same code + _, w = d.holder(0) + d.expect(p.clap_pin() + " verdict=refused", "resp 0") + mark = d.mark() + os.kill(w["pid"], signal.SIGKILL) + d.wait_event("instance_replay_refused 0 %d" % refused, since=mark) + check(not [e for e in d.events[mark:] if e.startswith("pin_changed")], "a refusal is no change of the files") + finally: + d.close() + # bytes that changed are a mismatch first: a verdict is about the bytes it was given for + d = p.daemon() + try: + d.expect(p.clap_pin().replace(sha(p.clap)[:8], "00000000") + " verdict=refused", "resp 0") + d.expect("add %s 1" % p.clap_uri, resp("PIN_BINARY_MISMATCH")) + finally: + d.close() + # a pin_file carries the verdict as pin_set does + pins = os.path.join(p.dir, "pins.txt") + write(pins, ("%s verdict=refused\n" % p.clap_pin().split(" ", 1)[1]).encode()) + d = p.daemon(conf={"pin_mode": "first-use", "pin_file": pins}) + try: + r = d.send("add %s 2" % p.clap_uri) + check(r == "resp %d" % refused, "a pin_file's refused verdict refuses the add: %r" % r) + check(store_lines(p) == [], "and the refused plugin is not learned into the store") + finally: + d.close() + p.close() + + def own_worker_of(d, inst): return d.holder(inst)[1]["place"] == "own" diff --git a/tests/sabotage.py b/tests/sabotage.py index 8bb7529..884f3a8 100644 --- a/tests/sabotage.py +++ b/tests/sabotage.py @@ -266,6 +266,9 @@ ("an add the worker died on is not named by an event", "src/supervisor.c", " if (code != PHD_ERR_VERB_DROPPED)\n event(PHD_EVENT_VERB_DROPPED_FMT, id, line);\n", "", "under_auto_a_refused_plugin_that_kills_mod_host_on_add_costs_only_its_own_worker"), + ("a pin whose verdict is refused admits the add", "src/pins.c", + " if (rc == SUCCESS && !strcmp(p->verdict, PHD_PIN_VERDICT_REFUSED))\n", " if (0)\n", + "a_pin_whose_verdict_is_refused_refuses_the_add_with_its_own_code"), ("the build steps are back on the README", "README.md", "Building from source: see [BUILDING.md](BUILDING.md).", "Building from source:\n\n make\n\nsee [BUILDING.md](BUILDING.md).", "README.md runs no build tool", "readme"), ("BUILDING.md lost its build command", "BUILDING.md", " make [MOD_HOST_DIR=]\n", "", From 733ffe97160c4b71734a65c7c7d1b1137e4ae556 Mon Sep 17 00:00:00 2001 From: Pau Aliagas Date: Sun, 18 Oct 2026 13:27:32 +0200 Subject: [PATCH 02/16] Declare NOTE_QUEUE_FULL (-517): a note the worker had no room for omx-clap-host refuses a note_on, note_off or all_notes_off that its note queue for the instance has no room for before the next block, and answers -901 today, a code no table declares. Errors go by code: the protocol now declares PHD_ERR_NOTE_QUEUE_FULL (-517), the next free code after PIN_REFUSED (-516). The line was not taken and nothing of it is kept; the controller may send it again. The daemon passes a worker's reply through unchanged, so nothing in it changes; README.md and protocol/plugin-hostd.json are regenerated. The fake host answers it for a synth whose uri holds "synth_full", and the daemon test checks that the code reaches the controller for the three verbs. tests/consumer.c now also refuses two errors that share a code, or a code outside the -5xx hundred; a sabotage arm (a new "consumer" kind) gives this code -516 and turns that test red. --- README.md | 1 + include/plugin-hostd/protocol.h | 6 +++++- protocol/plugin-hostd.json | 3 ++- tests/consumer.c | 20 ++++++++++++++++++++ tests/daemon_test.py | 17 +++++++++++++++++ tests/fake_host.c | 7 +++++-- tests/sabotage.py | 19 +++++++++++++++++-- 7 files changed, 67 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index ef56cdb..f77b86e 100644 --- a/README.md +++ b/README.md @@ -238,6 +238,7 @@ Error codes the daemon adds to mod-host's: | `-514` | `PHD_ERR_NOTES_BY_JACK` | the instance's worker takes no notes on its socket (mod-host): the daemon answers the note verbs and note_input with this code, and the plugin is played through its jack MIDI input with connect | | `-515` | `PHD_ERR_NO_PRESET_LIST` | the instance's worker cannot list the plugin's presets (mod-host): the daemon answers preset_list and preset_get with this code; preset_load still takes a preset the controller knows | | `-516` | `PHD_ERR_PIN_REFUSED` | the plugin's pin carries verdict=refused: its files are the ones that failed qualification, so add is refused and no worker sees it, and a replayed add is refused the same way; files that differ from the pin answer (-509) first | +| `-517` | `PHD_ERR_NOTE_QUEUE_FULL` | the instance's worker has no room for this note_on, note_off or all_notes_off before its next block: the line was not taken and nothing of it is kept; the controller may send it again | A worker's own refusal is returned as it said it. An `add` that killed its worker answers mod-host's `-102`. diff --git a/include/plugin-hostd/protocol.h b/include/plugin-hostd/protocol.h index cc0cd29..e86b401 100644 --- a/include/plugin-hostd/protocol.h +++ b/include/plugin-hostd/protocol.h @@ -261,6 +261,7 @@ static inline int phd_pool_name_valid(const char *name) #define PHD_ERR_NOTES_BY_JACK (-514) #define PHD_ERR_NO_PRESET_LIST (-515) #define PHD_ERR_PIN_REFUSED (-516) +#define PHD_ERR_NOTE_QUEUE_FULL (-517) /* X(id, meaning) */ #define PHD_ERRORS(X) \ @@ -298,7 +299,10 @@ static inline int phd_pool_name_valid(const char *name) "the controller knows") \ X(PIN_REFUSED, "the plugin's pin carries " PHD_PIN_VERDICT_KEY PHD_PIN_VERDICT_REFUSED ": its files are the ones that " \ "failed qualification, so add is refused and no worker sees it, and a replayed add is refused the " \ - "same way; files that differ from the pin answer " PHD_STR(PHD_ERR_PIN_BINARY_MISMATCH) " first") + "same way; files that differ from the pin answer " PHD_STR(PHD_ERR_PIN_BINARY_MISMATCH) " first") \ + X(NOTE_QUEUE_FULL, "the instance's worker has no room for this " PHD_VERB_NOTE_ON ", " PHD_VERB_NOTE_OFF " or " \ + PHD_VERB_ALL_NOTES_OFF " before its next block: the line was not taken and nothing of it is kept; the " \ + "controller may send it again") /* ---------------------------------------------------------------- verbs */ diff --git a/protocol/plugin-hostd.json b/protocol/plugin-hostd.json index 0c6c096..e53434f 100644 --- a/protocol/plugin-hostd.json +++ b/protocol/plugin-hostd.json @@ -39,7 +39,8 @@ {"name": "NO_NOTE_INPUT", "macro": "PHD_ERR_NO_NOTE_INPUT", "code": -513, "meaning": "the instance's plugin takes no notes (an effect): its worker answers note_on, note_off and all_notes_off with this code"}, {"name": "NOTES_BY_JACK", "macro": "PHD_ERR_NOTES_BY_JACK", "code": -514, "meaning": "the instance's worker takes no notes on its socket (mod-host): the daemon answers the note verbs and note_input with this code, and the plugin is played through its jack MIDI input with connect"}, {"name": "NO_PRESET_LIST", "macro": "PHD_ERR_NO_PRESET_LIST", "code": -515, "meaning": "the instance's worker cannot list the plugin's presets (mod-host): the daemon answers preset_list and preset_get with this code; preset_load still takes a preset the controller knows"}, - {"name": "PIN_REFUSED", "macro": "PHD_ERR_PIN_REFUSED", "code": -516, "meaning": "the plugin's pin carries verdict=refused: its files are the ones that failed qualification, so add is refused and no worker sees it, and a replayed add is refused the same way; files that differ from the pin answer (-509) first"} + {"name": "PIN_REFUSED", "macro": "PHD_ERR_PIN_REFUSED", "code": -516, "meaning": "the plugin's pin carries verdict=refused: its files are the ones that failed qualification, so add is refused and no worker sees it, and a replayed add is refused the same way; files that differ from the pin answer (-509) first"}, + {"name": "NOTE_QUEUE_FULL", "macro": "PHD_ERR_NOTE_QUEUE_FULL", "code": -517, "meaning": "the instance's worker has no room for this note_on, note_off or all_notes_off before its next block: the line was not taken and nothing of it is kept; the controller may send it again"} ], "verbs": [ {"name": "add", "arguments": " [own | pool: | default] [client_name]", "reply": "resp ", "meaning": "mod-host's add with a placement; a token after the instance that is not a placement is the jack client name"}, diff --git a/tests/consumer.c b/tests/consumer.c index f4bd2a1..7d1f47c 100644 --- a/tests/consumer.c +++ b/tests/consumer.c @@ -26,6 +26,26 @@ int main(void) #define X(id, meaning) errors++; check(PHD_ERR_##id < 0, #id " is a refusal"); PHD_ERRORS(X) #undef X + /* each code is the supervisor's own, in its hundred, and no two rows share one: a reply names one refusal */ + { + static const int codes[] = { +#define X(id, meaning) PHD_ERR_##id, + PHD_ERRORS(X) +#undef X + }; + size_t a, b; + + for (a = 0; a < sizeof(codes) / sizeof(codes[0]); a++) + { + check(codes[a] <= -501 && codes[a] >= -599, "every code is in the -5xx hundred"); + for (b = a + 1; b < sizeof(codes) / sizeof(codes[0]); b++) + if (codes[a] == codes[b]) + { + printf("FAIL two errors share the code %d\n", codes[a]); + failures++; + } + } + } #define X(id, name, args, rep, meaning) verbs++; check(*name && *rep, #id " is named and answers"); PHD_VERBS(X) #undef X diff --git a/tests/daemon_test.py b/tests/daemon_test.py index 5ef7682..4bd1f9c 100644 --- a/tests/daemon_test.py +++ b/tests/daemon_test.py @@ -1016,6 +1016,23 @@ def notes_reach_a_clap_worker_unchanged_and_an_effect_refuses_them(): os.unlink(log) +@test +def a_worker_whose_note_queue_is_full_answers_the_declared_code_and_the_daemon_passes_it(): + """omx-clap-host refuses a note it has no room for in the block with NOTE_QUEUE_FULL; the daemon hands the code to the + controller unchanged and keeps nothing of the note""" + d = daemon() + try: + full = resp("NOTE_QUEUE_FULL") + check(full != resp("NO_NOTE_INPUT") and int(full.split()[1]) <= -501, "a code of its own: %s" % full) + d.expect("add clap:fake#synth_full 0", "resp 0") + d.expect("note_on 0 0 60 100", full) + d.expect("note_off 0 0 60 0", full) + d.expect("all_notes_off 0", full) + d.expect("note_input 0", "resp 0 midi_in") + finally: + d.close() + + @test def notes_are_never_replayed(): log = tempfile.mktemp(prefix="plugin-hostd-fake-log.") diff --git a/tests/fake_host.c b/tests/fake_host.c index ce5f181..d878488 100644 --- a/tests/fake_host.c +++ b/tests/fake_host.c @@ -52,7 +52,8 @@ * 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 - * note_on, note_off, all_notes_off ... "resp 0" for a synth, PHD_ERR_NO_NOTE_INPUT for any other; the fake + * note_on, note_off, all_notes_off ... "resp 0" for a synth, PHD_ERR_NOTE_QUEUE_FULL for one whose uri holds + * "synth_full", PHD_ERR_NO_NOTE_INPUT for any other; the fake * plays nothing (FAKE_LOG 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 @@ -101,6 +102,7 @@ typedef struct INST_T { int exists; int nostate; int synth; + int full; /* a synth whose note queue is always full */ char client[64]; char name[MAX_PARAMS][64]; char text[MAX_PARAMS][128]; @@ -310,6 +312,7 @@ static void receive(msg_t *msg) g_expect[id][0] = '\0'; g_inst[id].nostate = strstr(tok[1], "nostate") != NULL; g_inst[id].synth = strstr(tok[1], "synth") != NULL; + g_inst[id].full = strstr(tok[1], "synth_full") != NULL; snprintf(g_inst[id].client, sizeof(g_inst[id].client), "%s", ntok > 3 ? tok[3] : ""); answer(fd, "resp %d", id); } @@ -449,7 +452,7 @@ static void receive(msg_t *msg) else if (!strcmp(verb, PHD_VERB_NOTE_INPUT) && ntok == 2) answer(fd, "resp 0 %s", g_inst[n].synth ? "midi_in" : PHD_NO_NOTE_INPUT); else if (!strcmp(verb, PHD_VERB_NOTE_ON) || !strcmp(verb, PHD_VERB_NOTE_OFF) || !strcmp(verb, PHD_VERB_ALL_NOTES_OFF)) - answer(fd, "resp %d", g_inst[n].synth ? 0 : PHD_ERR_NO_NOTE_INPUT); + answer(fd, "resp %d", !g_inst[n].synth ? PHD_ERR_NO_NOTE_INPUT : g_inst[n].full ? PHD_ERR_NOTE_QUEUE_FULL : 0); else if (!strcmp(verb, PHD_VERB_REMOTE_PAGES) && ntok == 2) answer(fd, "resp 2"); else if (!strcmp(verb, PHD_VERB_REMOTE_PAGE_GET) && ntok == 3 && !strcmp(tok[2], "0")) diff --git a/tests/sabotage.py b/tests/sabotage.py index 884f3a8..e370622 100644 --- a/tests/sabotage.py +++ b/tests/sabotage.py @@ -13,7 +13,7 @@ # an LV2 bundle (MOD_HOST, LV2_DIR, LV2_URI, LV2_BUNDLE, LV2_PARAM) and is skipped without them; "pin" breaks # include/plugin-hostd/pin.h and runs tests/pin_test.c; "readme" breaks README.md or BUILDING.md and runs # tests/readme_build.py, with no daemon to build; "probeschema" breaks protocol/probe-output.schema.json and runs -# tests/probe_schema.py; "probe" breaks src/probe.c and runs tests/probe_test.py; "nopins" breaks the packaging or a workflow and runs tests/no_pins.py +# tests/probe_schema.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 SABOTAGE = [ ("the verb tail is not replayed", "src/supervisor.c", "if (replay_line(w, i->tail[m].line) != RPC_OK)", "if (0 && replay_line(w, i->tail[m].line) != RPC_OK)", @@ -269,6 +269,8 @@ ("a pin whose verdict is refused admits the add", "src/pins.c", " if (rc == SUCCESS && !strcmp(p->verdict, PHD_PIN_VERDICT_REFUSED))\n", " if (0)\n", "a_pin_whose_verdict_is_refused_refuses_the_add_with_its_own_code"), + ("two errors share a code", "include/plugin-hostd/protocol.h", "#define PHD_ERR_NOTE_QUEUE_FULL (-517)", + "#define PHD_ERR_NOTE_QUEUE_FULL (-516)", "consumer", "consumer"), ("the build steps are back on the README", "README.md", "Building from source: see [BUILDING.md](BUILDING.md).", "Building from source:\n\n make\n\nsee [BUILDING.md](BUILDING.md).", "README.md runs no build tool", "readme"), ("BUILDING.md lost its build command", "BUILDING.md", " make [MOD_HOST_DIR=]\n", "", @@ -420,7 +422,18 @@ def run_format_test(tree): return p.returncode, p.stdout + ("ok format_test\n" if p.returncode == 0 else "") +def run_consumer_test(tree): + """tests/consumer.c against the tree's header, as a consumer compiles it; "ok consumer" when it passes""" + exe = os.path.join(tree, "consumer") + subprocess.run(["cc", "-I" + os.path.join(tree, "include"), "-std=c99", "-Wall", "-Wextra", "-o", exe, + os.path.join(ROOT, "tests", "consumer.c")], check=True) + p = subprocess.run([exe], capture_output=True, text=True, timeout=60) + return p.returncode, p.stdout + ("ok consumer\n" if p.returncode == 0 else "") + + def run_test(tree, name, jack=False): + if jack == "consumer": + return run_consumer_test(tree) if jack == "format": return run_format_test(tree) if jack == "pin": @@ -480,6 +493,8 @@ def run_test(tree, name, jack=False): elif jack == "probeschema": os.makedirs(os.path.join(tree, "protocol")) shutil.copy(os.path.join(ROOT, "protocol", "probe-output.schema.json"), os.path.join(tree, "protocol")) + elif jack == "consumer": + 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"): os.makedirs(os.path.dirname(os.path.join(tree, f)), exist_ok=True) @@ -502,7 +517,7 @@ 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", "format", "probeschema"): + elif jack not in ("readme", "nopins", "format", "consumer", "probeschema"): for f in os.listdir(os.path.join(tree, "src")): if f.endswith(".o"): os.unlink(os.path.join(tree, "src", f)) From c4d294fc4f772bb92cabbc684ac223e80629f007 Mon Sep 17 00:00:00 2001 From: Pau Aliagas Date: Sun, 18 Oct 2026 19:19:39 +0200 Subject: [PATCH 03/16] plugin-hostd-tests: drop 'of no use to anyone else' from the description (#43) --- debian/control | 2 +- packaging/plugin-hostd.spec | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/debian/control b/debian/control index fe9e0ea..753165f 100644 --- a/debian/control +++ b/debian/control @@ -37,7 +37,7 @@ Description: stand-in plugin worker to test a program that drives plugin-hostd be tested against a real daemon without a plugin, a JACK server or any audio. Run it with -L to print the layout it reports. It is installed in /usr/libexec/plugin-hostd/tests, away from the directories programs run - from, and is of no use to anyone else. + from. Package: plugin-hostd-dev Section: libdevel diff --git a/packaging/plugin-hostd.spec b/packaging/plugin-hostd.spec index 08a51a0..7a1289d 100644 --- a/packaging/plugin-hostd.spec +++ b/packaging/plugin-hostd.spec @@ -57,7 +57,7 @@ can be made to die on cue, so a program that talks to plugin-hostd can be tested against a real daemon without a plugin, a JACK server or any audio. Run it with -L to print the layout it reports. It is installed in %{_libexecdir}/plugin-hostd/tests, away from the directories programs run -from, and is of no use to anyone else. +from. %prep %autosetup From a232c9b88755988fca95e6421505f4849fb4c852 Mon Sep 17 00:00:00 2001 From: Pau Aliagas Date: Sun, 18 Oct 2026 19:19:39 +0200 Subject: [PATCH 04/16] ci: the tests package requires plugin-hostd(isa) = version-release of the same build (#43) --- .github/workflows/ci.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 581f93c..930f38a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -209,7 +209,10 @@ jobs: rpm -qp --qf '[%{FILEMODES:perms} %{FILENAMES}\n]' "$rpm" | grep -v -e '^d' -e '/\.build-id/' | cut -d' ' -f2 | sort > files.txt printf '%s\n' /usr/libexec/plugin-hostd/tests/fake-host /usr/share/licenses/plugin-hostd-tests/COPYING | sort > expected.txt diff -u expected.txt files.txt - rpm -qp --requires "$rpm" | grep -q '^plugin-hostd' + # tied to the same build: the requirement names the exact version-release and the isa of this very package + case "${{ matrix.arch }}" in x86_64) isa=x86-64 ;; aarch64) isa=aarch-64 ;; *) echo "no isa for ${{ matrix.arch }}"; exit 1 ;; esac + vr=$(rpm -qp --qf '%{VERSION}-%{RELEASE}' "$rpm") + rpm -qp --requires "$rpm" | grep -qxF "plugin-hostd($isa) = $vr" done ls rpms/plugin-hostd-tests-[0-9]*.${{ matrix.arch }}.rpm ls rpms/plugin-hostd-*.src.rpm From f04a284f1496e2ebbad7c79e068dc2063fa1ecce Mon Sep 17 00:00:00 2001 From: Pau Aliagas Date: Sun, 18 Oct 2026 19:20:09 +0200 Subject: [PATCH 05/16] A failed realloc keeps the relay's gesture table and the LV2 refusal and pick tables whole (#88) --- .gitignore | 3 + Makefile | 19 ++++- src/formats.h | 2 +- src/lv2.c | 26 +++++-- src/relay.c | 12 +++- tests/gesture_alloc_test.c | 89 +++++++++++++++++++++++ tests/lv2_alloc_test.c | 142 +++++++++++++++++++++++++++++++++++++ tests/sabotage.py | 29 +++++++- 8 files changed, 311 insertions(+), 11 deletions(-) create mode 100644 tests/gesture_alloc_test.c create mode 100644 tests/lv2_alloc_test.c diff --git a/.gitignore b/.gitignore index 06b32d8..50ab05b 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,6 @@ tests/plugin-hostd-fixture.lv2/*.so tests/probe-child tests/lv2-probe-child tests/rt_interpose.so +tests/gesture-alloc-test +tests/lv2-alloc-test +tests/lv2-alloc-lv2.o diff --git a/Makefile b/Makefile index 1f3571a..55ac4bb 100644 --- a/Makefile +++ b/Makefile @@ -7,6 +7,8 @@ VERSION = $(shell sed -n "s/^Version: *//p" packaging/plugin-hostd.spec) FAKE = tests/fake-host FORMAT_TEST = tests/format-test PIN_TEST = tests/pin-test +GESTURE_ALLOC_TEST = tests/gesture-alloc-test +LV2_ALLOC_TEST = tests/lv2-alloc-test PROBE = plugin-hostd-probe PKG_CONFIG ?= pkg-config @@ -121,11 +123,11 @@ private-refs: # clean rule clean: - @rm -f src/*.o $(PROG) $(PROBE) tests/probe-child tests/lv2-probe-child tests/rt_interpose.so $(FAKE) $(PIN_TEST) $(FORMAT_TEST) $(GEN) tests/stress.clap tests/jack_levels tests/host_scenarios $(LV2_FIXTURE) + @rm -f src/*.o $(PROG) $(PROBE) tests/probe-child tests/lv2-probe-child tests/rt_interpose.so $(FAKE) $(PIN_TEST) $(FORMAT_TEST) $(GESTURE_ALLOC_TEST) $(LV2_ALLOC_TEST) tests/lv2-alloc-lv2.o $(GEN) tests/stress.clap tests/jack_levels tests/host_scenarios $(LV2_FIXTURE) # 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-format test-daemon check-generated check-schema test-probe-schema test-probe test-consumer test-perturbation check-readme check-own-host-page check-no-pins test-mod-host-json test-socket +test: test-pin test-alloc test-format test-daemon check-generated check-schema test-probe-schema test-probe test-consumer test-perturbation check-readme check-own-host-page check-no-pins test-mod-host-json test-socket 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 @@ -160,6 +162,19 @@ test-pin: $(PIN_TEST) $(PIN_TEST): tests/pin_test.c include/plugin-hostd/pin.h $(CC) -Iinclude $(CFLAGS) -Werror -o $@ tests/pin_test.c +# the relay's gesture table and lv2.c's refusal and pick tables when memory runs out: each is built with its realloc +# calls renamed, so the test picks the one that fails +test-alloc: $(GESTURE_ALLOC_TEST) $(LV2_ALLOC_TEST) + ./$(GESTURE_ALLOC_TEST) + ./$(LV2_ALLOC_TEST) + +$(GESTURE_ALLOC_TEST): tests/gesture_alloc_test.c src/relay.c src/relay.h include/plugin-hostd/protocol.h + $(CC) -Iinclude -Isrc $(CFLAGS) -Werror -Drealloc=alloc_test_realloc -o $@ tests/gesture_alloc_test.c -pthread + +$(LV2_ALLOC_TEST): tests/lv2_alloc_test.c src/lv2.c src/formats.h + $(CC) $(INCS) -Isrc $(CFLAGS) -Werror -Drealloc=alloc_test_realloc -c -o tests/lv2-alloc-lv2.o src/lv2.c + $(CC) $(INCS) -Isrc $(CFLAGS) -Werror -o $@ tests/lv2_alloc_test.c tests/lv2-alloc-lv2.o + tests/stress.clap: tests/stress_plugin.c $(CC) $(CLAP_CFLAGS) $(CFLAGS) -Werror -shared -o $@ $< diff --git a/src/formats.h b/src/formats.h index 734caea..686166f 100644 --- a/src/formats.h +++ b/src/formats.h @@ -85,7 +85,7 @@ 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 * until the adapter has refused the uri. NULL names the lv2 row itself. */ const char *lv2_route(const conf_t *conf, const char *uri); -/* lv2_worker_set: the worker of one uri, or PHD_WORD_NONE to take the override back; -1 for a word that is no worker */ +/* lv2_worker_set: the worker of one uri, or PHD_WORD_NONE to take the override back; -1 for a word that is no worker, or when there is no memory to record it */ int lv2_worker_pick(const char *uri, const char *worker); /* the adapter refused the uri: auto runs it by the lv2 row from now on */ void lv2_adapter_refused(const char *uri); diff --git a/src/lv2.c b/src/lv2.c index 516c239..670aec2 100644 --- a/src/lv2.c +++ b/src/lv2.c @@ -492,8 +492,17 @@ int lv2_worker_pick(const char *uri, const char *worker) } if (n == g_npick) { - g_pick = realloc(g_pick, (g_npick + 1) * sizeof(*g_pick)); - g_pick[n].uri = strdup(uri); + pick_t *grown = realloc(g_pick, (g_npick + 1) * sizeof(*g_pick)); + char *copy; + + /* no memory for one more: the table keeps what it holds and this override is refused */ + if (grown == NULL) + return -1; + g_pick = grown; + copy = strdup(uri); + if (copy == NULL) + return -1; + g_pick[n].uri = copy; g_npick++; } snprintf(g_pick[n].worker, sizeof(g_pick[n].worker), "%s", worker); @@ -502,10 +511,19 @@ int lv2_worker_pick(const char *uri, const char *worker) void lv2_adapter_refused(const char *uri) { + char **grown; + char *copy; + if (refused(uri)) return; - g_refused = realloc(g_refused, (g_nrefused + 1) * sizeof(*g_refused)); - g_refused[g_nrefused++] = strdup(uri); + /* no memory for one more: the table keeps what it holds and this refusal is not remembered, so the adapter is asked again */ + grown = realloc(g_refused, (g_nrefused + 1) * sizeof(*g_refused)); + if (grown == NULL) + return; + g_refused = grown; + copy = strdup(uri); + if (copy != NULL) + g_refused[g_nrefused++] = copy; } /* the adapter may be asked again: about `uri`, or about every plugin when it is NULL */ diff --git a/src/relay.c b/src/relay.c index ea7e601..2cf1d0b 100644 --- a/src/relay.c +++ b/src/relay.c @@ -99,8 +99,16 @@ static void gesture_seen(source_t *s, const char *line) break; if (begin && n == s->nheld) { - s->held = realloc(s->held, (s->nheld + 1) * sizeof(char *)); - s->held[s->nheld++] = strndup(line, last - line); + /* no memory for one more: the table keeps what it holds and this gesture is not remembered */ + char **held = realloc(s->held, (s->nheld + 1) * sizeof(char *)); + char *copy; + + if (held == NULL) + return; + s->held = held; + copy = strndup(line, last - line); + if (copy != NULL) + s->held[s->nheld++] = copy; } else if (!begin && n < s->nheld) { diff --git a/tests/gesture_alloc_test.c b/tests/gesture_alloc_test.c new file mode 100644 index 0000000..2ee0cca --- /dev/null +++ b/tests/gesture_alloc_test.c @@ -0,0 +1,89 @@ +/* SPDX-License-Identifier: GPL-3.0-or-later */ +/* + * This file is part of plugin-hostd. + * + * plugin-hostd is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * plugin-hostd is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with plugin-hostd. If not, see . + * + * Copyright (C) 2026 Pau Aliagas + */ + + +/* src/relay.c's gesture table when memory runs out. The test includes the relay with its realloc calls renamed to + * alloc_test_realloc (the Makefile's -Drealloc), so it decides which allocation fails. A begin that cannot be recorded + * is dropped; what the table already holds stays. */ + +#include +#include +#include + +#include "../src/relay.c" + +/* the C library's realloc, by its own symbol: the name realloc is renamed in this file */ +void *libc_realloc(void *ptr, size_t size) __asm__("realloc"); + +static int failures, checks; + +/* how many reallocs succeed before the next one fails; -1 is never */ +static int g_fail_after = -1; + +void *alloc_test_realloc(void *ptr, size_t size); + +void *alloc_test_realloc(void *ptr, size_t size) +{ + if (g_fail_after == 0) + return NULL; + if (g_fail_after > 0) + g_fail_after--; + return libc_realloc(ptr, size); +} + +static void check(int ok, const char *what) +{ + checks++; + if (!ok) + { + failures++; + printf("FAIL gesture table: %s\n", what); + } +} + +int main(void) +{ + source_t s; + + memset(&s, 0, sizeof(s)); + gesture_seen(&s, "param_gesture 1 a begin"); + check(s.nheld == 1 && s.held && !strcmp(s.held[0], "param_gesture 1 a"), "positive control: a begin is held with memory"); + + g_fail_after = 0; + gesture_seen(&s, "param_gesture 1 b begin"); + g_fail_after = -1; + check(s.nheld == 1, "a begin with no memory is dropped, the table does not grow"); + check(s.held && !strcmp(s.held[0], "param_gesture 1 a"), "the gesture held before is still there, whole"); + + gesture_seen(&s, "param_gesture 1 a end"); + check(s.nheld == 0, "the held gesture still ends"); + + memset(&s, 0, sizeof(s)); + g_fail_after = 0; + gesture_seen(&s, "param_gesture 1 c begin"); + g_fail_after = -1; + check(s.nheld == 0 && s.held == NULL, "a begin with no memory into an empty table holds nothing"); + gesture_seen(&s, "param_gesture 1 c end"); + check(s.nheld == 0, "the end of a gesture never held is none"); + + gestures_end(&s); + printf("gesture table: %d checks, %s\n", checks, failures ? "FAILED" : "ok"); + return failures ? 1 : 0; +} diff --git a/tests/lv2_alloc_test.c b/tests/lv2_alloc_test.c new file mode 100644 index 0000000..d940cf2 --- /dev/null +++ b/tests/lv2_alloc_test.c @@ -0,0 +1,142 @@ +/* SPDX-License-Identifier: GPL-3.0-or-later */ +/* + * This file is part of plugin-hostd. + * + * plugin-hostd is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * plugin-hostd is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with plugin-hostd. If not, see . + * + * Copyright (C) 2026 Pau Aliagas + */ + + +/* src/lv2.c's refusal and pick tables when memory runs out. lv2.c is built for this test with its realloc calls renamed + * to alloc_test_realloc (the Makefile's -Drealloc), so the test decides which allocation fails. The stubs below stand for + * the daemon parts lv2.c calls and these tables never reach. */ + +#include +#include +#include + +#include + +#include "conf.h" +#include "formats.h" +#include "pins.h" +#include "proc.h" +#include "supervisor.h" + +static int failures, checks; + +/* how many reallocs succeed before the next one fails; -1 is never */ +static int g_fail_after = -1; + +void *alloc_test_realloc(void *ptr, size_t size); + +void *alloc_test_realloc(void *ptr, size_t size) +{ + if (g_fail_after == 0) + return NULL; + if (g_fail_after > 0) + g_fail_after--; + return realloc(ptr, size); +} + +int format_by_name(const char *name) +{ + (void)name; + return 0; +} + +int pin_refuse(const char *uri, const char *path, const char *why) +{ + (void)uri; + (void)path; + (void)why; + return 0; +} + +char *proc_find_lv2_bundle(const char *lv2_path, const char *uri) +{ + (void)lv2_path; + (void)uri; + return NULL; +} + +char *sup_call(int id, const char *line) +{ + (void)id; + (void)line; + return NULL; +} + +int sup_reply_code(const char *reply) +{ + (void)reply; + return 0; +} + +char *sup_resp(int code) +{ + (void)code; + return NULL; +} + +static void check(int ok, const char *what) +{ + checks++; + if (!ok) + { + failures++; + printf("FAIL lv2 tables: %s\n", what); + } +} + +static const char *route(const char *uri, const char *worker) +{ + conf_t conf; + + memset(&conf, 0, sizeof(conf)); + snprintf(conf.lv2_worker, sizeof(conf.lv2_worker), "%s", worker); + return lv2_route(&conf, uri); +} + +int main(void) +{ + const char *u1 = "urn:test:one", *u2 = "urn:test:two"; + const char *clap = "omx-clap-host"; + + /* the refusal table */ + lv2_adapter_refused(u1); + check(route(u1, "auto") == NULL, "positive control: a refusal is remembered with memory"); + g_fail_after = 0; + lv2_adapter_refused(u2); + g_fail_after = -1; + check(route(u2, "auto") != NULL, "a refusal with no memory is not remembered (the adapter is asked again)"); + check(route(u1, "auto") == NULL, "the refusal held before is still there"); + lv2_adapter_refused(u2); + check(route(u2, "auto") == NULL, "with memory again the refusal is remembered"); + lv2_refusals_forget(NULL); + + /* the pick table: under mod-host a uri is on the lv2 row (no route) unless a pick sends it to the CLAP worker */ + check(lv2_worker_pick(u1, clap) == 0 && route(u1, "mod-host") != NULL, "positive control: a pick is kept with memory"); + g_fail_after = 0; + check(lv2_worker_pick(u2, clap) == -1, "a pick with no memory is refused"); + g_fail_after = -1; + check(route(u2, "mod-host") == NULL, "the refused pick left no entry: the uri still follows the lv2_worker setting"); + check(route(u1, "mod-host") != NULL, "the pick held before is still there"); + check(lv2_worker_pick(u2, clap) == 0 && route(u2, "mod-host") != NULL, "with memory again the pick is kept"); + check(lv2_worker_pick(u1, PHD_WORD_NONE) == 0 && route(u1, "mod-host") == NULL, "a pick is taken back"); + + printf("lv2 tables: %d checks, %s\n", checks, failures ? "FAILED" : "ok"); + return failures ? 1 : 0; +} diff --git a/tests/sabotage.py b/tests/sabotage.py index 8bb7529..0df3c9e 100644 --- a/tests/sabotage.py +++ b/tests/sabotage.py @@ -13,7 +13,7 @@ # an LV2 bundle (MOD_HOST, LV2_DIR, LV2_URI, LV2_BUNDLE, LV2_PARAM) and is skipped without them; "pin" breaks # include/plugin-hostd/pin.h and runs tests/pin_test.c; "readme" breaks README.md or BUILDING.md and runs # tests/readme_build.py, with no daemon to build; "probeschema" breaks protocol/probe-output.schema.json and runs -# tests/probe_schema.py; "probe" breaks src/probe.c and runs tests/probe_test.py; "nopins" breaks the packaging or a workflow and runs tests/no_pins.py +# tests/probe_schema.py; "alloc" breaks src/relay.c or src/lv2.c and runs make test-alloc; "probe" breaks src/probe.c and runs tests/probe_test.py; "nopins" breaks the packaging or a workflow and runs tests/no_pins.py SABOTAGE = [ ("the verb tail is not replayed", "src/supervisor.c", "if (replay_line(w, i->tail[m].line) != RPC_OK)", "if (0 && replay_line(w, i->tail[m].line) != RPC_OK)", @@ -266,6 +266,12 @@ ("an add the worker died on is not named by an event", "src/supervisor.c", " if (code != PHD_ERR_VERB_DROPPED)\n event(PHD_EVENT_VERB_DROPPED_FMT, id, line);\n", "", "under_auto_a_refused_plugin_that_kills_mod_host_on_add_costs_only_its_own_worker"), + ("a gesture the relay has no memory to hold takes the table with it", "src/relay.c", + " if (held == NULL)\n return;\n", "", "alloc_test", "alloc"), + ("a refusal the lv2 table has no memory for takes the table with it", "src/lv2.c", + " if (grown == NULL)\n return;\n", "", "alloc_test", "alloc"), + ("a pick the lv2 table has no memory for takes the table with it", "src/lv2.c", + " if (grown == NULL)\n return -1;\n", "", "alloc_test", "alloc"), ("the build steps are back on the README", "README.md", "Building from source: see [BUILDING.md](BUILDING.md).", "Building from source:\n\n make\n\nsee [BUILDING.md](BUILDING.md).", "README.md runs no build tool", "readme"), ("BUILDING.md lost its build command", "BUILDING.md", " make [MOD_HOST_DIR=]\n", "", @@ -417,7 +423,19 @@ def run_format_test(tree): return p.returncode, p.stdout + ("ok format_test\n" if p.returncode == 0 else "") +def run_alloc_test(tree): + """make test-alloc: the tables of src/relay.c and src/lv2.c when realloc fails; prints "ok alloc_test" when no check failed""" + for f in ("tests/gesture-alloc-test", "tests/lv2-alloc-test", "tests/lv2-alloc-lv2.o"): + if os.path.exists(os.path.join(tree, f)): + os.unlink(os.path.join(tree, f)) + p = subprocess.run(["make", "-s", "MOD_HOST_DIR=" + MOD_HOST_DIR, "test-alloc"], cwd=tree, capture_output=True, text=True, + timeout=120) + return p.returncode, p.stdout + p.stderr + ("ok alloc_test\n" if p.returncode == 0 else "") + + def run_test(tree, name, jack=False): + if jack == "alloc": + return run_alloc_test(tree) if jack == "format": return run_format_test(tree) if jack == "pin": @@ -469,6 +487,13 @@ def run_test(tree, name, jack=False): os.makedirs(tree) for f in ("README.md", "BUILDING.md"): shutil.copy(os.path.join(ROOT, f), tree) + elif jack == "alloc": + shutil.copytree(os.path.join(ROOT, "src"), os.path.join(tree, "src")) + shutil.copytree(os.path.join(ROOT, "include"), os.path.join(tree, "include")) + shutil.copy(os.path.join(ROOT, "Makefile"), tree) + os.makedirs(os.path.join(tree, "tests")) + for f in ("gesture_alloc_test.c", "lv2_alloc_test.c"): + shutil.copy(os.path.join(ROOT, "tests", f), os.path.join(tree, "tests")) elif jack == "probe": os.makedirs(os.path.join(tree, "src")) shutil.copy(os.path.join(ROOT, "src", "probe.c"), os.path.join(tree, "src")) @@ -499,7 +524,7 @@ 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", "format", "probeschema"): + elif jack not in ("readme", "nopins", "format", "probeschema", "alloc"): for f in os.listdir(os.path.join(tree, "src")): if f.endswith(".o"): os.unlink(os.path.join(tree, "src", f)) From e908827825c07987fdc4f47d4a5732235cbd793e Mon Sep 17 00:00:00 2001 From: Pau Aliagas Date: Sun, 18 Oct 2026 19:21:16 +0200 Subject: [PATCH 06/16] test-lv2-adapter: the two refusal arms use a fixture that requires a feature the adapter lacks (#89) --- tests/lv2_adapter_e2e.py | 14 ++++++++------ tests/lv2_fixture.c | 7 +++++-- tests/plugin-hostd-fixture.lv2/manifest.ttl | 5 +++++ .../plugin-hostd-fixture.ttl | 14 ++++++++++++++ 4 files changed, 32 insertions(+), 8 deletions(-) diff --git a/tests/lv2_adapter_e2e.py b/tests/lv2_adapter_e2e.py index 4ee5980..eaede3c 100644 --- a/tests/lv2_adapter_e2e.py +++ b/tests/lv2_adapter_e2e.py @@ -13,7 +13,9 @@ FIXTURE = os.environ["LV2_FIXTURE"] # the directory of plugin-hostd-fixture.lv2, built ADAPTER_BUNDLES = os.environ["ADAPTER_BUNDLES"] # omx-clap-core's build/lv2: omx-host-fixture.lv2 and its refused twins GAIN = "urn:plugin-hostd:test:gain" -ATOM_IN = "urn:plugin-hostd:test:atom-in" +ATOM_IN = "urn:plugin-hostd:test:atom-in" # the adapter takes it; mod-host runs it with the notes by jack +FIXED_BLOCK = "urn:plugin-hostd:test:fixed-block" # requires a host feature the adapter lacks +MISSING = "hosting.features.missing" BASED = "http://plugin-hostd.invalid/fixture/based" BASE = "http://plugin-hostd.invalid/fixture/" HOST_FIXTURE = "urn:openmixer:test:host-fixture" @@ -101,7 +103,7 @@ def lv2_worker_omx_clap_host_runs_an_lv2_plugin_through_the_adapter(): def a_plugin_the_adapter_refuses_answers_512_and_the_event_carries_the_code_of_its_reply(): r = Run(both(), lv2_worker="omx-clap-host") try: - for i, (uri, code) in enumerate(((ATOM_IN, "hosting.features.midi-in-fed-empty"), + for i, (uri, code) in enumerate(((FIXED_BLOCK, MISSING), (HOST_FIXTURE + "#wide", "hosting.topology.wider-than-strip"), (HOST_FIXTURE + "#fixed", "hosting.features.missing"))): mark = r.d.mark() @@ -113,7 +115,7 @@ def a_plugin_the_adapter_refuses_answers_512_and_the_event_carries_the_code_of_i r.d.expect("add lv2:%s 0 pool:fx" % GAIN, "resp 0") for i, (uri, code) in enumerate(((HOST_FIXTURE + "#fixed", "hosting.features.missing"), (HOST_FIXTURE + "#wide", "hosting.topology.wider-than-strip"), - (ATOM_IN, "hosting.features.midi-in-fed-empty")), 1): + (FIXED_BLOCK, MISSING)), 1): mark = r.d.mark() r.d.expect("add lv2:%s %d pool:fx" % (uri, i), resp("LV2_ADAPTER_REFUSED")) event = r.d.wait_event(REFUSED + " ", since=mark) @@ -130,15 +132,15 @@ def under_auto_a_plugin_the_adapter_refuses_runs_on_mod_host(): r.d.expect("add lv2:%s 0" % GAIN, "resp 0") check(r.program(0) == "omx-clap-host", "auto asks the adapter first: %s" % r.d.workers()) mark = r.d.mark() - r.d.expect("add lv2:%s 1" % ATOM_IN, "resp 1") + r.d.expect("add lv2:%s 1" % FIXED_BLOCK, "resp 1") event = r.d.wait_event(REFUSED + " ", since=mark) - check(event == "%s %s hosting.features.midi-in-fed-empty auto" % (REFUSED, ATOM_IN), "the event: %r" % event) + check(event == "%s %s %s auto" % (REFUSED, FIXED_BLOCK, MISSING), "the event: %r" % event) check(r.d.holder(1)[1]["fmt"] == "lv2" and r.program(1) == "mod-host", "mod-host runs it: %s" % r.d.workers()) wait_for(lambda: client_ports(1), "effect_1's ports on the graph") r.d.expect("param_set 1 gain 0.25", "resp 0") r.d.expect("param_get 1 gain", "resp 0 0.2500") before = r.started().count("omx-clap-host") - r.d.expect("add lv2:%s 2" % ATOM_IN, "resp 2") + r.d.expect("add lv2:%s 2" % FIXED_BLOCK, "resp 2") check(r.program(2) == "mod-host" and r.started().count("omx-clap-host") == before, "the next add goes straight to mod-host: %s" % r.started()) check(len(r.refusals(mark)) == 1, "and the event was sent once: %s" % r.refusals(mark)) diff --git a/tests/lv2_fixture.c b/tests/lv2_fixture.c index 1e9005a..36fab3b 100644 --- a/tests/lv2_fixture.c +++ b/tests/lv2_fixture.c @@ -1,8 +1,10 @@ /* SPDX-License-Identifier: GPL-3.0-or-later */ /* The LV2 plugin of the real-worker test of the adapter (tests/lv2_adapter_e2e.py): a mono gain that every LV2 host - * runs, under three URIs. "gain" is the one omx-clap-host's adapter takes; "atom-in" adds an atom input that supports - * no MIDI, which the adapter refuses (hosting.features.midi-in-fed-empty) and mod-host runs; "based" is "gain" again + * runs, under these URIs. "gain" is the one omx-clap-host's adapter takes; "atom-in" adds an atom input that supports + * no MIDI, which the adapter takes too (it feeds the input empty sequences) and mod-host runs with the notes by jack; + * "fixed-block" requires the host feature buf-size#fixedBlockLength, which the adapter lacks and refuses + * (hosting.features.missing) and mod-host provides; "based" is "gain" again * under http://plugin-hostd.invalid/fixture/based, named in a manifest the test writes with an @base, so lilv finds it * and the adapter's search of the manifest text does not (resp -101). "stress" is "gain" that breaks on cue, for the * probe runner's LV2 tests (tests/probe_test.py). The ports, by index: 0 in, 1 out, 2 gain (a factor), 3 events @@ -110,6 +112,7 @@ static void cleanup(LV2_Handle instance) static const LV2_Descriptor DESCRIPTORS[] = { { FIXTURE_URI "gain", instantiate, connect_port, NULL, run, NULL, cleanup, NULL }, { FIXTURE_URI "atom-in", instantiate, connect_port, NULL, run, NULL, cleanup, NULL }, + { FIXTURE_URI "fixed-block", instantiate, connect_port, NULL, run, NULL, cleanup, NULL }, { FIXTURE_URI "stress", instantiate, connect_port, NULL, run_stress, NULL, cleanup, NULL }, { FIXTURE_BASE "based", instantiate, connect_port, NULL, run, NULL, cleanup, NULL }, }; diff --git a/tests/plugin-hostd-fixture.lv2/manifest.ttl b/tests/plugin-hostd-fixture.lv2/manifest.ttl index ee09b8b..d821494 100644 --- a/tests/plugin-hostd-fixture.lv2/manifest.ttl +++ b/tests/plugin-hostd-fixture.lv2/manifest.ttl @@ -13,6 +13,11 @@ lv2:binary ; rdfs:seeAlso . + + a lv2:Plugin ; + lv2:binary ; + rdfs:seeAlso . + a lv2:Plugin ; lv2:binary ; diff --git a/tests/plugin-hostd-fixture.lv2/plugin-hostd-fixture.ttl b/tests/plugin-hostd-fixture.lv2/plugin-hostd-fixture.ttl index b58a0da..7f1d3f7 100644 --- a/tests/plugin-hostd-fixture.lv2/plugin-hostd-fixture.ttl +++ b/tests/plugin-hostd-fixture.lv2/plugin-hostd-fixture.ttl @@ -1,5 +1,6 @@ # SPDX-License-Identifier: GPL-3.0-or-later @prefix atom: . +@prefix bufsz: . @prefix doap: . @prefix lv2: . @@ -30,6 +31,19 @@ lv2:index 3 ; lv2:symbol "events" ; lv2:name "Events" ] . + + a lv2:Plugin , lv2:AmplifierPlugin ; + doap:name "plugin-hostd fixture gain that requires a fixed block length" ; + lv2:requiredFeature bufsz:fixedBlockLength ; + lv2:port [ + a lv2:AudioPort , lv2:InputPort ; lv2:index 0 ; lv2:symbol "in" ; lv2:name "In" + ] , [ + a lv2:AudioPort , lv2:OutputPort ; lv2:index 1 ; lv2:symbol "out" ; lv2:name "Out" + ] , [ + a lv2:ControlPort , lv2:InputPort ; lv2:index 2 ; lv2:symbol "gain" ; lv2:name "Gain" ; + lv2:default 1.0 ; lv2:minimum 0.0 ; lv2:maximum 2.0 + ] . + a lv2:Plugin , lv2:AmplifierPlugin ; doap:name "plugin-hostd fixture gain that breaks on cue (STRESS_FAULT)" ; From 71cc593881b10c04b3dfef4238a0118d6213d93e Mon Sep 17 00:00:00 2001 From: Pau Aliagas Date: Sun, 18 Oct 2026 19:22:58 +0200 Subject: [PATCH 07/16] README: a worker_env nice the daemon cannot apply is logged (#87) --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index cd2386d..38413b1 100644 --- a/README.md +++ b/README.md @@ -245,6 +245,10 @@ A worker's own refusal is returned as it said it. An `add` that killed its worke driver it ran at 55, unasked), and a FIFO priority given to the whole process puts the plugin's own threads above it, where a busy thread starves the graph. +A daemon without privilege cannot give a worker a nice lower than the worker has (a new worker has the daemon's own). The +verb still answers `resp 0`, the worker keeps its nice, and the daemon logs `worker_env: worker keeps nice , the +nice was not applied` on stderr. + Like mod-host, the daemon serves one controller at a time, and `remove` of an instance it does not hold answers `resp 0`. With `-n` and no `-f` there is no feedback port (mod-host's own rule), and a controller need not open one; without `-n`, or with `-f`, the daemon waits for the controller to open both. A command that finds its From cc03fe8a3d4006f2d81c28f9dbecec4d857930fe Mon Sep 17 00:00:00 2001 From: Pau Aliagas Date: Sun, 18 Oct 2026 19:22:58 +0200 Subject: [PATCH 08/16] worker_env: the daemon logs a nice it could not apply, and the test expects what an unprivileged daemon can set (#87) --- src/proc.c | 13 +++++++++++++ src/proc.h | 4 ++++ src/supervisor.c | 4 ++++ tests/daemon_test.py | 20 ++++++++++++++++++-- tests/sabotage.py | 2 ++ 5 files changed, 41 insertions(+), 2 deletions(-) diff --git a/src/proc.c b/src/proc.c index df747b3..63d6c6c 100644 --- a/src/proc.c +++ b/src/proc.c @@ -108,6 +108,19 @@ void proc_apply_env(pid_t pid, const worker_env_t *env) closedir(dir); } +void proc_env_report(pid_t pid, const worker_env_t *env) +{ + int have; + + if (env->nice == INT_MIN) + return; + errno = 0; + have = getpriority(PRIO_PROCESS, pid); + if (errno == 0 && have != env->nice) + fprintf(stderr, "worker_env: worker %d keeps nice %d, the nice %d was not applied (a daemon without privilege cannot lower " + "a worker's nice below its own)\n", (int)pid, have, env->nice); +} + int proc_have_binary(const char *bin) { char *path, *copy, *dir, *save = NULL; diff --git a/src/proc.h b/src/proc.h index 52f3b02..1bfa343 100644 --- a/src/proc.h +++ b/src/proc.h @@ -63,6 +63,10 @@ void proc_stop(pid_t pid); void proc_apply_env(pid_t pid, const worker_env_t *env); +/* Logs on stderr a nice of `env` that the worker `pid` does not have: setpriority is refused to a process without + * privilege that lowers a nice below its own. */ +void proc_env_report(pid_t pid, const worker_env_t *env); + /* The directory of LV2_PATH holding the bundle whose manifest names `uri`; malloc'd, NULL if none. */ char *proc_find_lv2_bundle(const char *lv2_path, const char *uri); diff --git a/src/supervisor.c b/src/supervisor.c index 98d0358..c4d062c 100644 --- a/src/supervisor.c +++ b/src/supervisor.c @@ -512,6 +512,7 @@ static int worker_start(worker_t *w, const char *uri) w->pid = 0; return PHD_ERR_WORKER_SPAWN; } + proc_env_report(w->pid, &g_env[w->fmt]); /* what the worker reports goes to the controller from now on, the lines of its replay among them */ relay_add(w->fb_fd); w->state = W_UP; @@ -1806,7 +1807,10 @@ char *sup_worker_env(const char *format, const char *cpus, const char *nice) g_env[f].nice = atoi(nice); for (n = 0; n < g_nworkers; n++) if (g_workers[n]->fmt == f && g_workers[n]->state == W_UP) + { proc_apply_env(g_workers[n]->pid, &g_env[f]); + proc_env_report(g_workers[n]->pid, &g_env[f]); + } } return sup_resp(SUCCESS); } diff --git a/tests/daemon_test.py b/tests/daemon_test.py index 3c425dc..43accb7 100644 --- a/tests/daemon_test.py +++ b/tests/daemon_test.py @@ -601,6 +601,12 @@ def policy_and_pool_config(): @test def worker_env_reaches_new_and_running_workers(): cpus = sorted(os.sched_getaffinity(0)) + own = os.getpriority(os.PRIO_PROCESS, 0) # the daemon's nice is the test's + + def applied(nice, current=own): + """a daemon without privilege cannot lower a worker's nice below the one the worker has: its own, for a new worker""" + return nice if os.geteuid() == 0 else max(nice, current) + d = daemon() try: d.expect("worker_env lv2 %d 5" % cpus[0], "resp 0") @@ -611,9 +617,19 @@ def stat(): f = open("/proc/%d/stat" % pid).read().rsplit(")", 1)[1].split() allowed = [l for l in open("/proc/%d/status" % pid) if l.startswith("Cpus_allowed_list")][0].split()[1] return int(f[16]), allowed - check(stat() == (5, str(cpus[0])), "a new worker has nice 5 and cpu %d: %s" % (cpus[0], stat())) + check(stat() == (applied(5), str(cpus[0])), "a new worker has nice %d and cpu %d: %s" % (applied(5), cpus[0], stat())) + # the daemon says so when a nice is not applied, and only then + kept = "worker_env: worker %d keeps nice" % pid + check((kept in d.log()) == (applied(5) != 5), "a nice 5 not applied is logged, one applied is not: %r" % d.log()) d.expect("worker_env lv2 - 7", "resp 0") - check(stat()[0] == 7, "a running worker follows: %s" % (stat(),)) + check(stat()[0] == applied(7, applied(5)), "a running worker follows: %s" % (stat(),)) + check((kept in d.log()) == (applied(5) != 5 or applied(7, applied(5)) != 7), "the nice of a running worker is logged the same way") + # lower than the worker has: only a privileged daemon can, and one that cannot says so + have = stat()[0] + d.expect("worker_env lv2 - %d" % (have - 1), "resp 0") + check(stat()[0] == applied(have - 1, have), "a nice below the worker's: %s" % (stat(),)) + check(("keeps nice %d, the nice %d was not applied" % (have, have - 1) in d.log()) == (applied(have - 1, have) != have - 1), + "a nice below the worker's is logged when it is not applied: %r" % d.log()) d.expect("worker_env lv2 abc -", "resp -902") d.expect("worker_env lv2 - 99", "resp -902") d.expect("worker_env lv2 - - 50", "resp -902") # no fifth word: a priority is not offered diff --git a/tests/sabotage.py b/tests/sabotage.py index 8bb7529..4f3d02d 100644 --- a/tests/sabotage.py +++ b/tests/sabotage.py @@ -56,6 +56,8 @@ "a_command_whose_first_argument_is_a_uri_reaches_every_worker"), ("a reply is not passed through", "src/supervisor.c", " return reply;\n}\n\nchar *sup_remove(int id)", " free(reply);\n return sup_resp(0);\n}\n\nchar *sup_remove(int id)", "plain_verbs_pass_through_unchanged"), + ("a nice the daemon could not apply is not logged", "src/proc.c", " if (errno == 0 && have != env->nice)\n", + " if (0)\n", "worker_env_reaches_new_and_running_workers"), ("worker_env is not applied to a running worker", "src/supervisor.c", "proc_apply_env(g_workers[n]->pid, &g_env[f]);", ";", "worker_env_reaches_new_and_running_workers"), ("a refused connect is never asked again", "src/supervisor.c", " else if (w->state == W_UP && w->npend)\n pend_retry(w, now);", From 71a74562860364613b6d9f3a601f7c78d89b1510 Mon Sep 17 00:00:00 2001 From: Pau Aliagas Date: Sun, 18 Oct 2026 20:37:03 +0200 Subject: [PATCH 09/16] README, manual: the pins.d layer packages ship pins in (#42) --- README.md | 18 ++++++++++++++++++ doc/plugin-hostd.1 | 27 +++++++++++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/README.md b/README.md index ef56cdb..4ca73f8 100644 --- a/README.md +++ b/README.md @@ -369,6 +369,24 @@ the instance runs takes effect at its next replay. Files that differ from the pi verdict was given for the pinned files only. A plugin that changed since it was pinned is refused (-509, or -510 from the worker), the `pin_changed files|layout` event names what changed, and `pin_accept ` takes the new pin. +Pins that ship with packages live in a read-only layer of their own, under the host's. At start the daemon reads +every `*.pins` file of `pins_dir` (`/usr/share/plugin-hostd/pins.d` by default; empty is none), in byte order of +their names, in the same line format. A package drops its own file there, so its plugins and their pins are replaced +in one transaction and removed together; plugin-hostd owns the directory and another package may co-own it. The +daemon never writes it. A pin from this layer is trusted as it is, in both pin modes. The rules: + +- A pins.d pin with `verdict=refused` always holds. The pin store, a `pin_file` and `pin_set` never override it: + `pin_set` and `pin_accept` of its uri answer `-516` (`PIN_REFUSED`), and a line of the store or of the `pin_file` + for it is kept but has no effect, which stderr says at start. +- Any other pins.d pin is under the host's layer. A pin of the same uri in the store, the `pin_file` or from + `pin_set` holds over it, and `pin_accept` of a plugin whose files differ from its package pin records the new files + in the store, which then hold. `pin_clear` forgets the host's pin only: the package pin holds again. +- When two pins.d files hold the same uri, a refusal wins; otherwise the file last in name order wins. Two lines of + one file follow the same rule, the later line in place of the later file. +- A line outside the grammar, or longer than a pin line can be, is skipped and named on stderr; the rest of the file + is read. A file that cannot be read is named on stderr and skipped. A directory that is missing or empty holds no + pins and is no error. + What cannot be pinned: diff --git a/doc/plugin-hostd.1 b/doc/plugin-hostd.1 index 51818b9..19a88fc 100644 --- a/doc/plugin-hostd.1 +++ b/doc/plugin-hostd.1 @@ -90,6 +90,33 @@ A change after that is refused and announced by the event until .B pin_accept takes the new pin. +.PP +Packages ship pins in a read\-only layer: every +.I *.pins +file of +.B pins_dir +(by default +.IR /usr/share/plugin\-hostd/pins.d ) +is read at start, in name order, in the same line format. +A pin there with verdict=refused always holds: neither the pin store, the +.B pin_file +nor +.B pin_set +overrides it, and +.B pin_set +and +.B pin_accept +of its uri answer \-516. +Any other package pin is under the host's: a pin of the same uri in the store, the +.B pin_file +or from +.B pin_set +holds over it, and +.B pin_clear +lets the package pin hold again. +When two files hold the same uri a refusal wins, otherwise the last file by name. +A bad line is skipped and named on stderr; a missing or empty directory holds no pins. +.PP What cannot be pinned: .\" BEGIN GENERATED protocol:man-pin-limits .IP \(bu 2 From 26ed1bbc7e243ab91e6ac05ce966e8ba810e13df Mon Sep 17 00:00:00 2001 From: Pau Aliagas Date: Sun, 18 Oct 2026 20:41:39 +0200 Subject: [PATCH 10/16] pins_dir: read the packages' pins as a read-only layer under the host's (#42) Every *.pins file of pins_dir (/usr/share/plugin-hostd/pins.d by default) is read at start in byte order of its name. A pin there with verdict=refused always holds: the store, the pin_file, pin_set and pin_accept never override it, and pin_set and pin_accept of its uri answer PIN_REFUSED (-516). Any other package pin is under the host's pins. Of two package pins of one uri a refusal wins, otherwise the later file by name. A bad line or an unreadable file is skipped and named on stderr; a missing or empty directory holds no pins. --- README.md | 11 +- doc/plugin-hostd.1 | 2 +- include/plugin-hostd/protocol.h | 24 ++-- protocol/plugin-hostd.json | 11 +- src/main.c | 2 +- src/pins.c | 192 ++++++++++++++++++++++++---- src/pins.h | 29 +++-- tests/daemon_test.py | 214 +++++++++++++++++++++++++++++++- tests/format_test.c | 2 +- tests/harness.py | 2 + tests/sabotage.py | 21 ++++ 11 files changed, 455 insertions(+), 55 deletions(-) diff --git a/README.md b/README.md index 4ca73f8..8d33f76 100644 --- a/README.md +++ b/README.md @@ -103,9 +103,10 @@ The settings live in a file, `-c`, `$PLUGIN_HOSTD_CONF` or | `lv2_worker` | `mod-host` | who runs an LV2 plugin: mod-host (the mod_host program), omx-clap-host (the clap_host program, through its LV2 adapter, a plugin it cannot run refused with a code) or auto (the adapter first, mod-host for what it refuses, each such plugin in a worker of its own unless its add names a placement); lv2_worker_set overrides it for one plugin | | `lv2_path` | `$LV2_PATH`, else `/usr/lib64/lv2:/usr/lib/lv2:/usr/local/lib/lv2` | where bundles are searched | | `state_root` | `$XDG_RUNTIME_DIR`, else `/tmp` | the daemon's checkpoints and worker logs | -| `pin_mode` | `first-use` | strict or first-use, while require_pins is 1: first-use records the pin of a plugin on its first add, strict admits only a plugin a controller or a pin_file pinned | +| `pin_mode` | `first-use` | strict or first-use, while require_pins is 1: first-use records the pin of a plugin on its first add, strict admits only a plugin a controller, a pin_file or pins_dir pinned | | `pin_store` | `` | the file first-use pins are kept in; empty is $XDG_STATE_HOME/plugin-hostd/pins, else $HOME/.local/state/plugin-hostd/pins | | `pin_file` | `` | a file of pin lines loaded at start, as plugin-qualify --all --pins writes it; empty is none | +| `pins_dir` | `/usr/share/plugin-hostd/pins.d` | the read-only pins of packages: every file in it whose name ends in .pins, read at start in name order, under the store, the pin_file and pin_set, except that a pin of verdict=refused there always holds; empty is none | | `ready_timeout_ms` | 5000 ms | a worker that does not accept in this long is a failed spawn | | `rpc_timeout_ms` | 5000 ms | a worker that does not answer in this long is killed | | `backoff_base_ms` | 250 ms | respawn backoff, doubling per death in the window | @@ -158,10 +159,10 @@ Verbs the daemon adds | `policy_set` | ` own \| pool:` | `resp 0` | the placement of an add that says default | | `pool_config` | ` ` | `resp 0` | the instances a pool holds before a sibling opens | | `worker_env` | ` ` | `resp 0` | the cpu list and the nice value of the workers of a format, at spawn and on the ones running | -| `pin_set` | ` =[,=...] : [verdict=]` | `resp 0` | the pin of one plugin: the SHA-256 of every file the host loads for it, a path relative to the bundle (the directory of a single-file format's file), its layout fingerprint and, when it is known, the verdict of its qualification; it replaces an earlier pin of the uri. The pin store and a pin_file hold the same line, with or without the verb | -| `pin_clear` | ` \| all` | `resp 0` | forget the pin of one plugin, or of every one | +| `pin_set` | ` =[,=...] : [verdict=]` | `resp 0` | the pin of one plugin: the SHA-256 of every file the host loads for it, a path relative to the bundle (the directory of a single-file format's file), its layout fingerprint and, when it is known, the verdict of its qualification; it replaces an earlier pin of the uri; -516 for a plugin whose pins_dir pin carries verdict=refused, which nothing overrides. The pin store, a pin_file and a pins_dir file hold the same line, with or without the verb | +| `pin_clear` | ` \| all` | `resp 0` | forget the pin of one plugin, or of every one; a pins_dir pin is read-only and holds again | | `lv2_worker_set` | ` ` | `resp 0` | the worker of one LV2 plugin, for its adds from now on: it overrides the lv2_worker setting, - takes the override back; -902 for a word that is none of these. Like policy_set it is the controller's, not in the ledger, gone with the daemon | -| `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, -902 under pin_mode strict | +| `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 | @@ -237,7 +238,7 @@ Error codes the daemon adds to mod-host's: | `-513` | `PHD_ERR_NO_NOTE_INPUT` | the instance's plugin takes no notes (an effect): its worker answers note_on, note_off and all_notes_off with this code | | `-514` | `PHD_ERR_NOTES_BY_JACK` | the instance's worker takes no notes on its socket (mod-host): the daemon answers the note verbs and note_input with this code, and the plugin is played through its jack MIDI input with connect | | `-515` | `PHD_ERR_NO_PRESET_LIST` | the instance's worker cannot list the plugin's presets (mod-host): the daemon answers preset_list and preset_get with this code; preset_load still takes a preset the controller knows | -| `-516` | `PHD_ERR_PIN_REFUSED` | the plugin's pin carries verdict=refused: its files are the ones that failed qualification, so add is refused and no worker sees it, and a replayed add is refused the same way; files that differ from the pin answer (-509) first | +| `-516` | `PHD_ERR_PIN_REFUSED` | the plugin's pin carries verdict=refused: its files are the ones that failed qualification, so add is refused and no worker sees it, and a replayed add is refused the same way; files that differ from the pin answer (-509) first. A pins_dir pin of that verdict is never overridden: pin_set and pin_accept of its plugin answer this code | A worker's own refusal is returned as it said it. An `add` that killed its worker answers mod-host's `-102`. diff --git a/doc/plugin-hostd.1 b/doc/plugin-hostd.1 index 19a88fc..ee60e02 100644 --- a/doc/plugin-hostd.1 +++ b/doc/plugin-hostd.1 @@ -72,7 +72,7 @@ or in one .\" BEGIN GENERATED protocol:man-keys .RI \(dq key " value" \(dq -per line: mod_host, clap_host, lv2_worker, lv2_path, state_root, pin_mode, pin_store, pin_file, ready_timeout_ms, rpc_timeout_ms, backoff_base_ms, backoff_max_ms, storm_deaths, storm_window_ms, suspect_window_ms, checkpoint_ms, idle_ms, pool_max, require_pins. +per line: mod_host, clap_host, lv2_worker, lv2_path, state_root, pin_mode, pin_store, pin_file, pins_dir, ready_timeout_ms, rpc_timeout_ms, backoff_base_ms, backoff_max_ms, storm_deaths, storm_window_ms, suspect_window_ms, checkpoint_ms, idle_ms, pool_max, require_pins. .\" END GENERATED protocol:man-keys .SH PINS With require_pins on, diff --git a/include/plugin-hostd/protocol.h b/include/plugin-hostd/protocol.h index cc0cd29..4ed7e50 100644 --- a/include/plugin-hostd/protocol.h +++ b/include/plugin-hostd/protocol.h @@ -298,7 +298,9 @@ static inline int phd_pool_name_valid(const char *name) "the controller knows") \ X(PIN_REFUSED, "the plugin's pin carries " PHD_PIN_VERDICT_KEY PHD_PIN_VERDICT_REFUSED ": its files are the ones that " \ "failed qualification, so add is refused and no worker sees it, and a replayed add is refused the " \ - "same way; files that differ from the pin answer " PHD_STR(PHD_ERR_PIN_BINARY_MISMATCH) " first") + "same way; files that differ from the pin answer " PHD_STR(PHD_ERR_PIN_BINARY_MISMATCH) " first. A pins_dir " \ + "pin of that verdict is never overridden: " PHD_VERB_PIN_SET " and " PHD_VERB_PIN_ACCEPT " of its plugin " \ + "answer this code") /* ---------------------------------------------------------------- verbs */ @@ -346,7 +348,7 @@ static inline int phd_pool_name_valid(const char *name) #define PHD_PIN_MODE_STRICT "strict" #define PHD_PIN_MODE_FIRST_USE "first-use" #define PHD_PIN_MODES(X) \ - X(STRICT, PHD_PIN_MODE_STRICT, "add admits only a plugin that has a pin; a pin comes from pin_set or pin_file") \ + X(STRICT, PHD_PIN_MODE_STRICT, "add admits only a plugin that has a pin; a pin comes from pin_set, pin_file or pins_dir") \ X(FIRST_USE, PHD_PIN_MODE_FIRST_USE, "add of a plugin with no pin records its pin, keeps it in the pin store and places " \ "the instance " PHD_PLACE_OWN "; a later change is refused until pin_accept") @@ -388,8 +390,11 @@ static inline int phd_pool_name_valid(const char *name) "]", "resp 0", \ "the pin of one plugin: the SHA-256 of every file the host loads for it, a path relative to the bundle (the " \ "directory of a single-file format's file), its layout fingerprint and, when it is known, the verdict of its qualification; " \ - "it replaces an earlier pin of the uri. The pin store and a pin_file hold the same line, with or without the verb") \ - X(PIN_CLEAR, PHD_VERB_PIN_CLEAR, " | " PHD_WORD_ALL, "resp 0", "forget the pin of one plugin, or of every one") \ + "it replaces an earlier pin of the uri; -516 for a plugin whose pins_dir pin carries " PHD_PIN_VERDICT_KEY \ + PHD_PIN_VERDICT_REFUSED ", which nothing overrides. The pin store, a pin_file and a pins_dir file hold the same " \ + "line, with or without the verb") \ + X(PIN_CLEAR, PHD_VERB_PIN_CLEAR, " | " PHD_WORD_ALL, "resp 0", "forget the pin of one plugin, or of every one; a pins_dir " \ + "pin is read-only and holds again") \ X(LV2_WORKER_SET, PHD_VERB_LV2_WORKER_SET, " <" PHD_LV2_WORKER_MOD_HOST " | " PHD_LV2_WORKER_CLAP_HOST " | " \ PHD_LV2_WORKER_AUTO " | " PHD_WORD_NONE ">", "resp 0", \ "the worker of one LV2 plugin, for its adds from now on: it overrides the lv2_worker setting, " PHD_WORD_NONE " takes the " \ @@ -397,7 +402,8 @@ static inline int phd_pool_name_valid(const char *name) X(PIN_ACCEPT, PHD_VERB_PIN_ACCEPT, "", "resp 0", \ "pin_mode " PHD_PIN_MODE_FIRST_USE " only: take the plugin's files as they are now as its pin, after a " \ PHD_EVENT_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, -902 under pin_mode strict") \ + "the plugin has no pin, -509 when its files cannot be collected, -516 when its pins_dir pin carries " \ + PHD_PIN_VERDICT_KEY PHD_PIN_VERDICT_REFUSED ", -902 under pin_mode strict") \ X(PIN_LAYOUT, PHD_VERB_PIN_LAYOUT, "", "resp 0 " PHD_PIN_SCHEME_SEPARATOR "", \ "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") \ @@ -750,6 +756,7 @@ static inline int phd_is_note_verb(const char *verb) #define PHD_DEFAULT_POOL_MAX 8 #define PHD_DEFAULT_REQUIRE_PINS 1 #define PHD_DEFAULT_PIN_MODE PHD_PIN_MODE_FIRST_USE +#define PHD_DEFAULT_PINS_DIR "/usr/share/plugin-hostd/pins.d" /* the settings file: one "key value" per line, '#' starts a comment. * A string setting: X(key, size, default, env, meaning); env names the variable that stands in for the default when @@ -764,10 +771,13 @@ static inline int phd_is_note_verb(const char *verb) X(lv2_path, pathlist, PHD_DEFAULT_LV2_PATH, "LV2_PATH", "where bundles are searched") \ X(state_root, path, PHD_DEFAULT_STATE_ROOT, "XDG_RUNTIME_DIR", "the daemon's checkpoints and worker logs") \ X(pin_mode, word, PHD_DEFAULT_PIN_MODE, "", "strict or first-use, while require_pins is 1: first-use records the pin " \ - "of a plugin on its first add, strict admits only a plugin a controller or a pin_file pinned") \ + "of a plugin on its first add, strict admits only a plugin a controller, a pin_file or pins_dir pinned") \ X(pin_store, path, "", "", "the file first-use pins are kept in; empty is $XDG_STATE_HOME/plugin-hostd/pins, " \ "else $HOME/.local/state/plugin-hostd/pins") \ - X(pin_file, path, "", "", "a file of pin lines loaded at start, as plugin-qualify --all --pins writes it; empty is none") + X(pin_file, path, "", "", "a file of pin lines loaded at start, as plugin-qualify --all --pins writes it; empty is none") \ + X(pins_dir, path, PHD_DEFAULT_PINS_DIR, "", "the read-only pins of packages: every file in it whose name ends in .pins, read at start " \ + "in name order, under the store, the pin_file and pin_set, except that a pin of verdict=refused there always holds; " \ + "empty is none") /* A whole-number setting: X(key, default, unit, meaning); 0 turns off where the meaning says so */ #define PHD_CONF_INTS(X) \ diff --git a/protocol/plugin-hostd.json b/protocol/plugin-hostd.json index 0c6c096..2249d41 100644 --- a/protocol/plugin-hostd.json +++ b/protocol/plugin-hostd.json @@ -39,7 +39,7 @@ {"name": "NO_NOTE_INPUT", "macro": "PHD_ERR_NO_NOTE_INPUT", "code": -513, "meaning": "the instance's plugin takes no notes (an effect): its worker answers note_on, note_off and all_notes_off with this code"}, {"name": "NOTES_BY_JACK", "macro": "PHD_ERR_NOTES_BY_JACK", "code": -514, "meaning": "the instance's worker takes no notes on its socket (mod-host): the daemon answers the note verbs and note_input with this code, and the plugin is played through its jack MIDI input with connect"}, {"name": "NO_PRESET_LIST", "macro": "PHD_ERR_NO_PRESET_LIST", "code": -515, "meaning": "the instance's worker cannot list the plugin's presets (mod-host): the daemon answers preset_list and preset_get with this code; preset_load still takes a preset the controller knows"}, - {"name": "PIN_REFUSED", "macro": "PHD_ERR_PIN_REFUSED", "code": -516, "meaning": "the plugin's pin carries verdict=refused: its files are the ones that failed qualification, so add is refused and no worker sees it, and a replayed add is refused the same way; files that differ from the pin answer (-509) first"} + {"name": "PIN_REFUSED", "macro": "PHD_ERR_PIN_REFUSED", "code": -516, "meaning": "the plugin's pin carries verdict=refused: its files are the ones that failed qualification, so add is refused and no worker sees it, and a replayed add is refused the same way; files that differ from the pin answer (-509) first. A pins_dir pin of that verdict is never overridden: pin_set and pin_accept of its plugin answer this code"} ], "verbs": [ {"name": "add", "arguments": " [own | pool: | default] [client_name]", "reply": "resp ", "meaning": "mod-host's add with a placement; a token after the instance that is not a placement is the jack client name"}, @@ -50,10 +50,10 @@ {"name": "policy_set", "arguments": " own | pool:", "reply": "resp 0", "meaning": "the placement of an add that says default"}, {"name": "pool_config", "arguments": " ", "reply": "resp 0", "meaning": "the instances a pool holds before a sibling opens"}, {"name": "worker_env", "arguments": " ", "reply": "resp 0", "meaning": "the cpu list and the nice value of the workers of a format, at spawn and on the ones running"}, - {"name": "pin_set", "arguments": " =[,=...] : [verdict=]", "reply": "resp 0", "meaning": "the pin of one plugin: the SHA-256 of every file the host loads for it, a path relative to the bundle (the directory of a single-file format's file), its layout fingerprint and, when it is known, the verdict of its qualification; it replaces an earlier pin of the uri. The pin store and a pin_file hold the same line, with or without the verb"}, - {"name": "pin_clear", "arguments": " | all", "reply": "resp 0", "meaning": "forget the pin of one plugin, or of every one"}, + {"name": "pin_set", "arguments": " =[,=...] : [verdict=]", "reply": "resp 0", "meaning": "the pin of one plugin: the SHA-256 of every file the host loads for it, a path relative to the bundle (the directory of a single-file format's file), its layout fingerprint and, when it is known, the verdict of its qualification; it replaces an earlier pin of the uri; -516 for a plugin whose pins_dir pin carries verdict=refused, which nothing overrides. The pin store, a pin_file and a pins_dir file hold the same line, with or without the verb"}, + {"name": "pin_clear", "arguments": " | all", "reply": "resp 0", "meaning": "forget the pin of one plugin, or of every one; a pins_dir pin is read-only and holds again"}, {"name": "lv2_worker_set", "arguments": " ", "reply": "resp 0", "meaning": "the worker of one LV2 plugin, for its adds from now on: it overrides the lv2_worker setting, - takes the override back; -902 for a word that is none of these. Like policy_set it is the controller's, not in the ledger, gone with the daemon"}, - {"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, -902 under pin_mode strict"}, + {"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"} ], @@ -120,9 +120,10 @@ {"key": "lv2_worker", "type": "string", "default": "mod-host", "env": "", "meaning": "who runs an LV2 plugin: mod-host (the mod_host program), omx-clap-host (the clap_host program, through its LV2 adapter, a plugin it cannot run refused with a code) or auto (the adapter first, mod-host for what it refuses, each such plugin in a worker of its own unless its add names a placement); lv2_worker_set overrides it for one plugin"}, {"key": "lv2_path", "type": "string", "default": "/usr/lib64/lv2:/usr/lib/lv2:/usr/local/lib/lv2", "env": "LV2_PATH", "meaning": "where bundles are searched"}, {"key": "state_root", "type": "string", "default": "/tmp", "env": "XDG_RUNTIME_DIR", "meaning": "the daemon's checkpoints and worker logs"}, - {"key": "pin_mode", "type": "string", "default": "first-use", "env": "", "meaning": "strict or first-use, while require_pins is 1: first-use records the pin of a plugin on its first add, strict admits only a plugin a controller or a pin_file pinned"}, + {"key": "pin_mode", "type": "string", "default": "first-use", "env": "", "meaning": "strict or first-use, while require_pins is 1: first-use records the pin of a plugin on its first add, strict admits only a plugin a controller, a pin_file or pins_dir pinned"}, {"key": "pin_store", "type": "string", "default": "", "env": "", "meaning": "the file first-use pins are kept in; empty is $XDG_STATE_HOME/plugin-hostd/pins, else $HOME/.local/state/plugin-hostd/pins"}, {"key": "pin_file", "type": "string", "default": "", "env": "", "meaning": "a file of pin lines loaded at start, as plugin-qualify --all --pins writes it; empty is none"}, + {"key": "pins_dir", "type": "string", "default": "/usr/share/plugin-hostd/pins.d", "env": "", "meaning": "the read-only pins of packages: every file in it whose name ends in .pins, read at start in name order, under the store, the pin_file and pin_set, except that a pin of verdict=refused there always holds; empty is none"}, {"key": "ready_timeout_ms", "type": "int", "default": 5000, "unit": "ms", "meaning": "a worker that does not accept in this long is a failed spawn"}, {"key": "rpc_timeout_ms", "type": "int", "default": 5000, "unit": "ms", "meaning": "a worker that does not answer in this long is killed"}, {"key": "backoff_base_ms", "type": "int", "default": 250, "unit": "ms", "meaning": "respawn backoff, doubling per death in the window"}, diff --git a/src/main.c b/src/main.c index e2c3e1c..309c2b4 100644 --- a/src/main.c +++ b/src/main.c @@ -138,7 +138,7 @@ int main(int argc, char **argv) if (conf_check(&g_conf) != 0) return EXIT_FAILURE; - if (g_conf.require_pins && pins_init(g_conf.pin_store, g_conf.pin_file) != 0) + if (g_conf.require_pins && pins_init(g_conf.pin_store, g_conf.pin_file, g_conf.pins_dir) != 0) return EXIT_FAILURE; signal(SIGPIPE, SIG_IGN); diff --git a/src/pins.c b/src/pins.c index f1b3583..87fe4c4 100644 --- a/src/pins.c +++ b/src/pins.c @@ -18,6 +18,7 @@ * Copyright (C) 2026 Pau Aliagas */ +#include #include #include #include @@ -41,6 +42,7 @@ #define LINE_MAX_PIN (PHD_LINE_MAX * 2) /* what a line grows by when its layout (omx-layout/1:) and a verdict are added */ #define LINE_RESERVE 128 +#define PINS_SUFFIX ".pins" typedef struct PIN_T { char *uri; @@ -53,10 +55,15 @@ typedef struct PIN_T { int learned; /* the daemon recorded it: the store keeps it */ } pin_t; +/* The host's pins: the store, the pin_file and pin_set, the one table the daemon writes and saves from */ static pin_t *g_pins; static int g_npins; static char g_store[PATH_MAX]; +/* The packages' pins: the *.pins files of pins_dir, read at start and never written */ +static pin_t *g_pkg; +static int g_npkg; + /* the uri as the worker is given it: the key of a pin, whichever way a controller wrote it */ static const char *bare(const char *uri) { @@ -78,16 +85,39 @@ static void pin_free(pin_t *p) free(p->layout); } -static pin_t *find(const char *uri) +static pin_t *find_in(pin_t *table, int count, const char *uri) { int n; - for (n = 0; n < g_npins; n++) - if (!strcmp(g_pins[n].uri, uri)) - return &g_pins[n]; + for (n = 0; n < count; n++) + if (!strcmp(table[n].uri, uri)) + return &table[n]; return NULL; } +static int refused(const pin_t *p) +{ + return !strcmp(p->verdict, PHD_PIN_VERDICT_REFUSED); +} + +/* the package pin of the uri when it carries verdict=refused, which nothing overrides */ +static pin_t *pkg_refusal(const char *uri) +{ + pin_t *p = find_in(g_pkg, g_npkg, uri); + + return p && refused(p) ? p : NULL; +} + +/* the pin that holds for a uri: a package refusal, else the host's pin, else the package's */ +static pin_t *find(const char *uri) +{ + pin_t *p = pkg_refusal(uri); + + if (!p) + p = find_in(g_pins, g_npins, uri); + return p ? p : find_in(g_pkg, g_npkg, uri); +} + /* a path of a pin: relative to the bundle, never out of it, and none of the separators of pin_set's words */ static int path_valid(const char *path) { @@ -171,10 +201,10 @@ static int pin_build(pin_t *pin, const char *uri, const char *files, const char return ERR_INVALID_OPERATION; } -/* the pin in the table, replacing the one of its uri */ +/* the pin in the host's table, replacing the one of its uri */ static void pin_put(pin_t *pin) { - pin_t *old = find(pin->uri); + pin_t *old = find_in(g_pins, g_npins, pin->uri); if (old) { @@ -188,6 +218,30 @@ static void pin_put(pin_t *pin) } } +/* the pin in the packages' table: of two pins of one uri a refusal wins, else the later one, read from the file later in + * name order or the later line of one file */ +static void pkg_put(pin_t *pin, const char *path, int lineno) +{ + pin_t *old = find_in(g_pkg, g_npkg, pin->uri); + + if (old && refused(old) && !refused(pin)) + { + fprintf(stderr, "plugin-hostd: %s:%d: %s keeps the " PHD_PIN_VERDICT_KEY PHD_PIN_VERDICT_REFUSED " of an earlier " + "pins.d line\n", path, lineno, pin->uri); + pin_free(pin); + } + else if (old) + { + pin_free(old); + *old = *pin; + } + else + { + g_pkg = realloc(g_pkg, (g_npkg + 1) * sizeof(pin_t)); + g_pkg[g_npkg++] = *pin; + } +} + /* ---------------------------------------------------------------- the store and the pin lines */ static const char *store_path(const char *configured) @@ -372,8 +426,11 @@ static void store_save(void) sync_dir(g_store); } +/* where a file of pin lines goes, and what a line outside the grammar costs there */ +typedef enum { LAYER_STORE, LAYER_FILE, LAYER_PKG } layer_t; + /* one line: the words of pin_set, the verb in front or not; a blank or comment line is SUCCESS and records nothing */ -static int line_read(char *line, int learned) +static int line_read(char *line, layer_t layer, const char *path, int lineno) { char *tok[8], *save = NULL, *t; int ntok = 0, first = 0, rc; @@ -393,13 +450,22 @@ static int line_read(char *line, int learned) ntok - first == 4 ? tok[first + 3] + strlen(PHD_PIN_VERDICT_KEY) : NULL, 1); if (rc != SUCCESS) return rc; - pin.learned = learned; + if (layer == LAYER_PKG) + { + pkg_put(&pin, path, lineno); + return SUCCESS; + } + pin.learned = layer == LAYER_STORE; pin_put(&pin); return SUCCESS; } -static int file_read(const char *path, int learned, int strict) +/* a pin_file is strict: a bad line ends the start. The store keeps a bad line verbatim and blocks its uri; a pins.d file + * skips it. Either names it on stderr */ +static int file_read(const char *path, layer_t layer) { + int strict = layer == LAYER_FILE, learned = layer == LAYER_STORE; + char *line = NULL; size_t cap = 0; ssize_t len; @@ -410,6 +476,9 @@ static int file_read(const char *path, int learned, int strict) { if (strict) fprintf(stderr, "plugin-hostd: can't open the pin file %s: %s\n", path, strerror(errno)); + else if (layer == LAYER_PKG) + fprintf(stderr, "plugin-hostd: can't open the pins.d file %s: %s; its pins are skipped\n", path, + strerror(errno)); else if (errno != ENOENT && errno != ENOTDIR) { /* a store that exists and can't be opened is not a first run */ @@ -428,11 +497,12 @@ static int file_read(const char *path, int learned, int strict) /* a line longer than the buffer is one line: never cut into pieces that parse on their own */ if (!too_long) memcpy(buf, line, len + 1); - if (too_long || line_read(buf, learned) != SUCCESS) + if (too_long || line_read(buf, layer, path, lineno) != SUCCESS) { fprintf(stderr, "plugin-hostd: %s:%d: not a pin%s%s\n", path, lineno, too_long ? " (the line is too long)" : "", - strict ? "" : "; the line is kept as it is and its plugin is not learned again until the operator " - "repairs or removes it"); + strict ? "" : layer == LAYER_PKG ? "; the line is skipped" + : "; the line is kept as it is and its plugin is not learned again until the operator repairs or " + "removes it"); if (strict) { free(line); @@ -444,7 +514,10 @@ static int file_read(const char *path, int learned, int strict) } } free(line); - if (ferror(file) && !strict) + if (ferror(file) && layer == LAYER_PKG) + fprintf(stderr, "plugin-hostd: can't read the pins.d file %s: %s; the pins after line %d are skipped\n", path, + strerror(errno), lineno); + else if (ferror(file) && !strict) { fprintf(stderr, "plugin-hostd: can't read the pin store %s: %s; learning and saving are blocked until it is " "repaired\n", path, strerror(errno)); @@ -454,12 +527,67 @@ static int file_read(const char *path, int learned, int strict) return 0; } -int pins_init(const char *store, const char *file) +/* a *.pins file, by its name: a hidden one (an editor's or a package manager's temporary) is not one */ +static int pins_name(const struct dirent *entry) +{ + size_t n = strlen(entry->d_name), suffix = strlen(PINS_SUFFIX); + + return entry->d_name[0] != '.' && n > suffix && !strcmp(entry->d_name + n - suffix, PINS_SUFFIX); +} + +/* byte order, whatever the locale */ +static int name_order(const struct dirent **a, const struct dirent **b) +{ + return strcmp((*a)->d_name, (*b)->d_name); +} + +static void pkg_read(const char *dir) +{ + struct dirent **names; + char path[PATH_MAX * 2]; + int n, count = scandir(dir, &names, pins_name, name_order); + + if (count < 0) + { + if (errno != ENOENT && errno != ENOTDIR) + fprintf(stderr, "plugin-hostd: can't read the pins directory %s: %s; its pins are skipped\n", dir, + strerror(errno)); + return; + } + for (n = 0; n < count; n++) + { + snprintf(path, sizeof(path), "%s/%s", dir, names[n]->d_name); + file_read(path, LAYER_PKG); + free(names[n]); + } + free(names); +} + +static void pkg_free(void) { + while (g_npkg) + pin_free(&g_pkg[--g_npkg]); + free(g_pkg); + g_pkg = NULL; +} + +int pins_init(const char *store, const char *file, const char *dir) +{ + int n, rc; + held_free(); + pkg_free(); + if (*dir) + pkg_read(dir); if (*store_path(store)) - file_read(g_store, 1, 0); - return *file ? file_read(file, 0, 1) : 0; + file_read(g_store, LAYER_STORE); + rc = *file ? file_read(file, LAYER_FILE) : 0; + for (n = 0; n < g_npins; n++) + if (pkg_refusal(g_pins[n].uri)) + fprintf(stderr, "plugin-hostd: %s: its pins.d pin carries " PHD_PIN_VERDICT_KEY PHD_PIN_VERDICT_REFUSED + ", which the pin of the %s does not override\n", g_pins[n].uri, + g_pins[n].learned ? "pin store" : "pin_file"); + return rc; } int pins_set(const char *uri, const char *files, const char *layout, const char *verdict) @@ -469,10 +597,25 @@ int pins_set(const char *uri, const char *files, const char *layout, const char if (rc != SUCCESS) return rc; + if (pkg_refusal(pin.uri)) + { + fprintf(stderr, "plugin-hostd: %s: its pins.d pin carries " PHD_PIN_VERDICT_KEY PHD_PIN_VERDICT_REFUSED + ", which pin_set does not override\n", pin.uri); + pin_free(&pin); + return PHD_ERR_PIN_REFUSED; + } pin_put(&pin); return SUCCESS; } +static void own_free(void) +{ + while (g_npins) + pin_free(&g_pins[--g_npins]); + free(g_pins); + g_pins = NULL; +} + int pins_known(const char *uri) { return find(bare(uri)) != NULL; @@ -494,12 +637,13 @@ int pins_clear(const char *uri) { for (p = g_pins; p < g_pins + g_npins; p++) learned |= p->learned; - pins_finish(); + own_free(); if (learned) store_save(); return SUCCESS; } - p = find(bare(uri)); + /* the host's pin: a package pin is read-only and holds again */ + p = find_in(g_pins, g_npins, bare(uri)); if (p) { learned = p->learned; @@ -513,10 +657,8 @@ int pins_clear(const char *uri) void pins_finish(void) { - while (g_npins) - pin_free(&g_pins[--g_npins]); - free(g_pins); - g_pins = NULL; + own_free(); + pkg_free(); } /* ---------------------------------------------------------------- the check */ @@ -624,6 +766,12 @@ int pins_accept(const format_t *format, const char *uri, const char *search) { if (!find(bare(uri))) return PHD_ERR_PIN_ABSENT; + if (pkg_refusal(bare(uri))) + { + fprintf(stderr, "plugin-hostd: %s: its pins.d pin carries " PHD_PIN_VERDICT_KEY PHD_PIN_VERDICT_REFUSED + ", which pin_accept does not override\n", uri); + return PHD_ERR_PIN_REFUSED; + } return record(format, uri, search); } diff --git a/src/pins.h b/src/pins.h index 53ff752..b18acba 100644 --- a/src/pins.h +++ b/src/pins.h @@ -23,20 +23,26 @@ #include "formats.h" -/* The pins by URI: policy, like placement, and not in the ledger. They come from pin_set, from a pin file at start, and - * (pin_mode first-use) from the daemon itself, which keeps what it learned in the pin store. One line format for the - * three: =[,...] : [verdict=], the verb pin_set in front or not. */ +/* The pins by URI: policy, like placement, and not in the ledger. Two layers. The host's: pin_set, a pin file at start, + * and (pin_mode first-use) the daemon itself, which keeps what it learned in the pin store. The packages': the *.pins + * files of a directory, read at start and never written. The host's pin of a uri holds over the package's, except a + * package pin of verdict=refused, which always holds. One line format for all: =[,...] + * : [verdict=], the verb pin_set in front or not. */ -/* start: the store (empty is the XDG default) is read, then `file` (empty is none), which wins. -1 with the line named on - * stderr when `file` has a line outside the grammar; a bad line of the store is kept verbatim by every save, announced on - * stderr, and its uri is not learned again; a store that can't be read blocks learning and saving */ -int pins_init(const char *store, const char *file); +/* start: the *.pins files of `dir` (empty is none) in byte order of their names, of two pins of one uri a refusal + * winning, else the later; then the store (empty is the XDG default), then `file` (empty is none), which wins over the + * store. -1 with the line named on stderr when `file` has a line outside the grammar; a bad line of the store is kept + * verbatim by every save, announced on stderr, and its uri is not learned again; a store that can't be read blocks + * learning and saving; a bad line or an unreadable file of `dir` is announced and skipped, and a missing `dir` holds + * no pins */ +int pins_init(const char *store, const char *file, const char *dir); -/* pin_set: SUCCESS, or ERR_INVALID_OPERATION for a word outside the grammar; it replaces an earlier pin of the uri. - * `verdict` is NULL, or one of PHD_PIN_VERDICTS */ +/* pin_set: SUCCESS, or ERR_INVALID_OPERATION for a word outside the grammar, or PHD_ERR_PIN_REFUSED when the package pin + * of the uri carries verdict=refused; it replaces an earlier pin of the uri. `verdict` is NULL, or one of + * PHD_PIN_VERDICTS */ int pins_set(const char *uri, const char *files, const char *layout, const char *verdict); -/* pin_clear of one uri, or of every one for "all"; SUCCESS */ +/* pin_clear of the host's pin of one uri, or of every one for "all"; a package pin holds again. SUCCESS */ int pins_clear(const char *uri); /* whether the uri has a pin, and whether it is one a verdict of in-process qualified */ @@ -48,7 +54,8 @@ int pins_in_process(const char *uri); int pins_learn(const format_t *format, const char *uri, const char *search); /* pin_accept: the same collection for a uri that has a pin, which it replaces; the layout is unknown again and the - * verdict gone. PHD_ERR_PIN_ABSENT when there is no pin to replace */ + * verdict gone; over a package pin it is the host's that then holds. PHD_ERR_PIN_ABSENT when there is no pin to replace, + * PHD_ERR_PIN_REFUSED when the package pin carries verdict=refused */ int pins_accept(const format_t *format, const char *uri, const char *search); /* the layout a worker found after init, for a pin that has none; false for a layout that is not omx-layout/1 */ diff --git a/tests/daemon_test.py b/tests/daemon_test.py index 5ef7682..8f4eb46 100644 --- a/tests/daemon_test.py +++ b/tests/daemon_test.py @@ -1164,6 +1164,7 @@ def __init__(self): write(os.path.join(self.bundle, "pinned.ttl"), b"lv2:port [ lv2:index 0 ; lv2:symbol \"gain\" ] .") self.log = os.path.join(self.dir, "commands.log") self.store = os.path.join(self.dir, "state", "plugin-hostd", "pins") + self.pins_dir = os.path.join(self.dir, "pins.d") def clap_pin(self, layout=None): return "pin_set %s omx-fault.clap=%s %s" % (self.clap_uri, sha(self.clap), layout or fake_layout()) @@ -1173,8 +1174,10 @@ def lv2_pin(self, files=("manifest.ttl", "pinned.so", "pinned.ttl")): fake_layout()) def daemon(self, conf=None, env=None): - # strict unless a test asks for first-use; the store is the test's own, never the user's - c = {"require_pins": 1, "pin_mode": "strict", "lv2_path": self.lv2_dir, "pin_store": self.store} + # strict unless a test asks for first-use; the store and the pins_dir are the test's own, never the user's or + # the system's + c = {"require_pins": 1, "pin_mode": "strict", "lv2_path": self.lv2_dir, "pin_store": self.store, + "pins_dir": self.pins_dir} c.update(conf or {}) e = {"FAKE_LOG": self.log} e.update(env or {}) @@ -1509,6 +1512,213 @@ def a_pin_whose_verdict_is_refused_refuses_the_add_with_its_own_code(): p.close() +def pin_line(p, verdict=None, good=True, uri=None, clap=None): + """a pin line as a pins.d file holds it, no verb: the CLAP's real digest, or one that is not its own""" + clap = clap or p.clap + digest = sha(clap) if good else "0" * 64 + line = "%s %s=%s %s" % (uri or p.clap_uri, os.path.basename(clap), digest, fake_layout()) + return line + (" verdict=%s" % verdict if verdict else "") + + +def pins_d(p, files): + """the test's pins_dir holding exactly these files, written in reverse name order so the directory's own order is + not the order of their names""" + shutil.rmtree(p.pins_dir, ignore_errors=True) + os.makedirs(p.pins_dir) + for name in sorted(files, reverse=True): + write(os.path.join(p.pins_dir, name), "".join(l + "\n" for l in files[name]).encode()) + + +def other_clap(p): + """a second CLAP beside the first, for a uri that is not the one under test""" + path = os.path.join(os.path.dirname(p.clap), "omx-other.clap") + write(path, b"another clean build") + return path, "clap:%s#org.openmixer.other" % path + + +def add_answer(p, conf, inst=0, uri=None): + """the reply to one add of a fresh daemon over the test's pins_dir""" + d = p.daemon(conf=conf) + try: + return d.send("add %s %d" % (uri or p.clap_uri, inst)) + finally: + d.close() + + +@test +def a_pins_d_pin_admits_its_plugin_and_of_two_files_the_last_by_name_wins(): + """issue 42: the *.pins files of pins_dir are read at start in name order; a pin there admits its plugin under + strict, and of two pins of one uri that are not refusals the file last by name, or the later line, holds""" + p = Plugins() + try: + pins_d(p, {"10-a.pins": [pin_line(p, good=False)], "20-b.pins": [pin_line(p)]}) + r = add_answer(p, {}) + check(r == "resp 0", "the later file's good pin holds over the earlier bad one: %r" % r) + pins_d(p, {"10-a.pins": [pin_line(p)], "20-b.pins": [pin_line(p, good=False)]}) + r = add_answer(p, {}) + check(r == resp("PIN_BINARY_MISMATCH"), "the later file's bad pin holds over the earlier good one: %r" % r) + pins_d(p, {"10-a.pins": [pin_line(p, good=False), pin_line(p)]}) + r = add_answer(p, {}) + check(r == "resp 0", "in one file the later line holds: %r" % r) + # only *.pins files that are not hidden are read: a package manager's leftovers and an editor's are not pins (the + # hidden one is a refusal, which would win whatever its place in name order) + pins_d(p, {"10-a.pins": [pin_line(p)], "20-b.pins.rpmnew": [pin_line(p, good=False)], + ".30-c.pins": [pin_line(p, "refused")], "README": [pin_line(p, good=False)]}) + r = add_answer(p, {}) + check(r == "resp 0", "a file that is not a .pins file holds no pin: %r" % r) + # first-use trusts a package pin as it is: nothing is learned into the store + d = p.daemon(conf={"pin_mode": "first-use"}) + try: + d.expect("add %s 0" % p.clap_uri, "resp 0") + check(store_lines(p) == [], "a package pin is not copied into the store: %s" % store_lines(p)) + finally: + d.close() + finally: + p.close() + + +@test +def a_pins_d_refusal_wins_over_any_other_pins_d_pin_and_over_the_host(): + """issue 42, decisions of 2026-10-18: a pins.d pin of verdict=refused wins over another pins.d pin of its uri in + either name order, and nothing of the host overrides it: not the store, not a pin_file, not pin_set, not + pin_accept, not pin_clear""" + p = Plugins() + refused = resp("PIN_REFUSED") + try: + other, other_uri = other_clap(p) + for files in ({"10-a.pins": [pin_line(p, "refused")], "20-b.pins": [pin_line(p, "isolated")]}, + {"10-a.pins": [pin_line(p, "isolated")], "20-b.pins": [pin_line(p, "refused")]}, + {"10-a.pins": [pin_line(p, "refused"), pin_line(p, "isolated")]}): + pins_d(p, files) + r = add_answer(p, {}) + check(r == refused, "a refusal wins whichever file or line comes last (%s): %r" % (sorted(files), r)) + pins_d(p, {"10-a.pins": [pin_line(p, "refused"), pin_line(p, "isolated", uri=other_uri, clap=other)]}) + # the store and a pin_file both hold the same files as isolated + os.makedirs(os.path.dirname(p.store), exist_ok=True) + write(p.store, ("pin_set %s\n" % pin_line(p, "isolated")).encode()) + pins = os.path.join(p.dir, "pins.txt") + write(pins, (pin_line(p, "isolated") + "\n").encode()) + d = p.daemon(conf={"pin_mode": "first-use", "pin_file": pins}) + try: + r = d.send("add %s 0" % p.clap_uri) + check(r == refused, "the store and the pin_file do not override a pins.d refusal: %r" % r) + r = d.send("pin_set %s" % pin_line(p, "isolated")) + check(r == refused, "pin_set of a uri a pins.d pin refuses answers PIN_REFUSED: %r" % r) + r = d.send("pin_accept %s" % p.clap_uri) + check(r == refused, "pin_accept of it answers PIN_REFUSED: %r" % r) + d.expect("pin_clear %s" % p.clap_uri, "resp 0") + d.expect("pin_clear all", "resp 0") + r = d.send("add %s 0" % p.clap_uri) + check(r == refused, "after pin_set, pin_accept and pin_clear the refusal still holds: %r" % r) + check(d.workers() == {} and p.received() == [], "no worker saw it: %s %s" % (d.workers(), p.received())) + # positive control: the other plugin of the same file runs + d.expect("add %s 1" % other_uri, "resp 1") + check("which the pin of the pin_file does not override" in d.log(), + "stderr names the pin_file line that has no effect: %s" % d.log()) + finally: + d.close() + # the store alone: its line has no effect and is not dropped from the store either + write(p.store, ("pin_set %s\n" % pin_line(p, "isolated")).encode()) + d = p.daemon(conf={"pin_mode": "first-use"}) + try: + r = d.send("add %s 0" % p.clap_uri) + check(r == refused, "the store does not override a pins.d refusal: %r" % r) + check("which the pin of the pin store does not override" in d.log(), "stderr names the store line") + d.expect("add %s 1" % other_uri, "resp 1") + check([l[1] for l in store_lines(p)] == [p.clap_uri], "the store line is kept: %s" % store_lines(p)) + finally: + d.close() + finally: + p.close() + + +@test +def the_host_holds_over_a_pins_d_pin_that_is_not_a_refusal(): + """issue 42: a pins.d pin that is not a refusal is under the host's layer: pin_set holds over it, a change of the + files is announced by pin_changed until pin_accept records the new files in the store, which then hold across a + restart, and pin_clear lets the package pin hold again""" + p = Plugins() + try: + pins_d(p, {"50-pkg.pins": [pin_line(p, "isolated", good=False)]}) + d = p.daemon(conf={"pin_mode": "first-use"}) + try: + mark = d.mark() + d.expect("add %s 0" % p.clap_uri, resp("PIN_BINARY_MISMATCH")) + d.wait_event("pin_changed %s files" % p.clap_uri, since=mark) + check(store_lines(p) == [], "a plugin with a package pin is not learned: %s" % store_lines(p)) + d.expect("pin_accept %s" % p.clap_uri, "resp 0") + d.expect("add %s 0" % p.clap_uri, "resp 0") + check([l for l in store_lines(p) if l[1] == p.clap_uri][0][2] == "omx-fault.clap=%s" % sha(p.clap), + "the accepted files are in the store: %s" % store_lines(p)) + finally: + d.close() + d = p.daemon(conf={"pin_mode": "first-use"}) + try: + d.expect("add %s 0" % p.clap_uri, "resp 0") + d.expect("remove 0", "resp 0") + d.expect("pin_clear %s" % p.clap_uri, "resp 0") + d.expect("add %s 0" % p.clap_uri, resp("PIN_BINARY_MISMATCH")) + finally: + d.close() + d = p.daemon() + try: + d.expect("add %s 0" % p.clap_uri, resp("PIN_BINARY_MISMATCH")) + d.expect("pin_set %s" % pin_line(p), "resp 0") + d.expect("add %s 0" % p.clap_uri, "resp 0") + finally: + d.close() + finally: + p.close() + + +@test +def a_bad_pins_d_line_or_file_is_skipped_and_the_rest_is_read(): + """issue 42: a line outside the grammar, a line longer than a pin line can be and a file that cannot be read are + named on stderr and skipped; the daemon starts and the good lines hold""" + p = Plugins() + try: + long_refusal = pin_line(p, "refused") + " " * 9000 + pins_d(p, {"10-a.pins": ["this is not a pin", long_refusal, pin_line(p)]}) + os.makedirs(os.path.join(p.pins_dir, "20-dir.pins")) + d = p.daemon() + try: + d.expect("add %s 0" % p.clap_uri, "resp 0") + log = d.log() + path = os.path.join(p.pins_dir, "10-a.pins") + check("%s:1: not a pin; the line is skipped" % path in log, "the bad line is named: %s" % log) + check("%s:2: not a pin (the line is too long); the line is skipped" % path in log, "the long line is named") + check("can't read the pins.d file %s" % os.path.join(p.pins_dir, "20-dir.pins") in log, + "the file that cannot be read is named: %s" % log) + finally: + d.close() + finally: + p.close() + + +@test +def an_empty_or_missing_pins_dir_holds_no_pins(): + """issue 42: a pins_dir that is missing, empty or not set holds no pins and is no error""" + p = Plugins() + try: + for what, conf in (("missing", {}), ("empty", {}), ("not set", {"pins_dir": ""})): + if what == "empty": + os.makedirs(p.pins_dir) + d = p.daemon(conf=conf) + try: + d.expect("add %s 0" % p.clap_uri, resp("PIN_ABSENT")) + check("pins directory" not in d.log(), "a %s pins_dir is no error: %s" % (what, d.log())) + finally: + d.close() + d = p.daemon(conf={"pin_mode": "first-use"}) + try: + d.expect("add %s 0" % p.clap_uri, "resp 0") + check(len(store_lines(p)) == 1, "first-use learns as before: %s" % store_lines(p)) + finally: + d.close() + finally: + p.close() + + def own_worker_of(d, inst): return d.holder(inst)[1]["place"] == "own" diff --git a/tests/format_test.c b/tests/format_test.c index 720fc1d..5ea8cd9 100644 --- a/tests/format_test.c +++ b/tests/format_test.c @@ -244,7 +244,7 @@ int main(int argc, char **argv) conf.require_pins = 1; conf.ready_timeout_ms = 4000; check(conf_check(&conf) == 0, "the settings are consistent"); - check(pins_init(conf.pin_store, "") == 0, "the pin store starts empty"); + check(pins_init(conf.pin_store, "", "") == 0, "the pin store starts empty"); sup_init(&conf); write_file(plugin, "third format, build one"); diff --git a/tests/harness.py b/tests/harness.py index 7f5c691..02b2e88 100644 --- a/tests/harness.py +++ b/tests/harness.py @@ -78,6 +78,8 @@ def __init__(self, exe, worker, clap_worker=None, conf=None, env=None, feedback= # the tests of placement, replay and attribution add plugins no one pinned; the pin tests turn it back on, # and a None leaves a setting out of the file, so the daemon's own default holds "require_pins": 0, + # no package's pins: the system's pins_dir is not the test's + "pins_dir": "", } settings.update(conf or {}) self.conf = os.path.join(self.tmp, "plugin-hostd.conf") diff --git a/tests/sabotage.py b/tests/sabotage.py index 884f3a8..e86672d 100644 --- a/tests/sabotage.py +++ b/tests/sabotage.py @@ -269,6 +269,27 @@ ("a pin whose verdict is refused admits the add", "src/pins.c", " if (rc == SUCCESS && !strcmp(p->verdict, PHD_PIN_VERDICT_REFUSED))\n", " if (0)\n", "a_pin_whose_verdict_is_refused_refuses_the_add_with_its_own_code"), + ("the host's pin overrides a pins.d refusal", "src/pins.c", " pin_t *p = pkg_refusal(uri);\n\n if (!p)\n", + " pin_t *p = NULL;\n\n if (!p)\n", "a_pins_d_refusal_wins_over_any_other_pins_d_pin_and_over_the_host"), + ("pin_set overrides a pins.d refusal", "src/pins.c", " if (pkg_refusal(pin.uri))\n", " if (0)\n", "a_pins_d_refusal_wins_over_any_other_pins_d_pin_and_over_the_host"), + ("pin_accept overrides a pins.d refusal", "src/pins.c", " if (pkg_refusal(bare(uri)))\n", " if (0)\n", "a_pins_d_refusal_wins_over_any_other_pins_d_pin_and_over_the_host"), + ("of two pins.d pins of one uri the later wins over a refusal", "src/pins.c", + " if (old && refused(old) && !refused(pin))\n", " if (0)\n", "a_pins_d_refusal_wins_over_any_other_pins_d_pin_and_over_the_host"), + ("pins.d files are read in reverse name order", "src/pins.c", " return strcmp((*a)->d_name, (*b)->d_name);", + " return strcmp((*b)->d_name, (*a)->d_name);", "a_pins_d_pin_admits_its_plugin_and_of_two_files_the_last_by_name_wins"), + ("a pins.d file need not end in .pins", "src/pins.c", + "n > suffix && !strcmp(entry->d_name + n - suffix, PINS_SUFFIX)", "n > 0", + "a_pins_d_pin_admits_its_plugin_and_of_two_files_the_last_by_name_wins"), + ("a package pin holds over the host's", "src/pins.c", + " p = find_in(g_pins, g_npins, uri);\n return p ? p : find_in(g_pkg, g_npkg, uri);", + " p = find_in(g_pkg, g_npkg, uri);\n return p ? p : find_in(g_pins, g_npins, uri);", + "the_host_holds_over_a_pins_d_pin_that_is_not_a_refusal"), + ("a bad pins.d line ends the start", "src/pins.c", " int strict = layer == LAYER_FILE,", + " int strict = layer != LAYER_STORE,", "a_bad_pins_d_line_or_file_is_skipped_and_the_rest_is_read"), + ("a hidden pins.d file is read", "src/pins.c", "entry->d_name[0] != '.' && ", "", + "a_pins_d_pin_admits_its_plugin_and_of_two_files_the_last_by_name_wins"), + ("pins_dir is not read", "src/pins.c", " if (*dir)\n pkg_read(dir);\n", "", + "a_pins_d_pin_admits_its_plugin_and_of_two_files_the_last_by_name_wins"), ("the build steps are back on the README", "README.md", "Building from source: see [BUILDING.md](BUILDING.md).", "Building from source:\n\n make\n\nsee [BUILDING.md](BUILDING.md).", "README.md runs no build tool", "readme"), ("BUILDING.md lost its build command", "BUILDING.md", " make [MOD_HOST_DIR=]\n", "", From 60fcc83d272a9bbe767fcce5a99e12358cf66bfd Mon Sep 17 00:00:00 2001 From: Pau Aliagas Date: Sun, 18 Oct 2026 20:41:53 +0200 Subject: [PATCH 11/16] packaging: plugin-hostd owns /usr/share/plugin-hostd/pins.d (#42) --- Makefile | 4 +++- debian/plugin-hostd.dirs | 1 + packaging/plugin-hostd.spec | 2 ++ 3 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 debian/plugin-hostd.dirs diff --git a/Makefile b/Makefile index 1f3571a..a13f077 100644 --- a/Makefile +++ b/Makefile @@ -89,13 +89,15 @@ install: $(PROG) install_man install_protocol install_tests install -m 755 $(PROBE) $(DESTDIR)$(BINDIR) # the declared protocol: the JSON for readers that are not C, which the package carries; the header, its pkg-config -# file and the schema of the JSON, which the development package carries +# file and the schema of the JSON, which the development package carries; and the pins_dir other packages drop their +# pins in install_protocol: install -d $(DESTDIR)$(DATADIR)/plugin-hostd install -m 644 protocol/plugin-hostd.json $(DESTDIR)$(DATADIR)/plugin-hostd/protocol.json install -m 644 protocol/mod-host.json $(DESTDIR)$(DATADIR)/plugin-hostd/mod-host.json install -m 644 protocol/plugin-hostd.schema.json $(DESTDIR)$(DATADIR)/plugin-hostd/protocol.schema.json install -m 644 protocol/probe-output.schema.json $(DESTDIR)$(DATADIR)/plugin-hostd/probe-output.schema.json + install -d $(DESTDIR)$(DATADIR)/plugin-hostd/pins.d install -d $(DESTDIR)$(INCLUDEDIR)/plugin-hostd install -m 644 include/plugin-hostd/protocol.h $(DESTDIR)$(INCLUDEDIR)/plugin-hostd/protocol.h install -m 644 include/plugin-hostd/pin.h $(DESTDIR)$(INCLUDEDIR)/plugin-hostd/pin.h diff --git a/debian/plugin-hostd.dirs b/debian/plugin-hostd.dirs new file mode 100644 index 0000000..b075d71 --- /dev/null +++ b/debian/plugin-hostd.dirs @@ -0,0 +1 @@ +usr/share/plugin-hostd/pins.d diff --git a/packaging/plugin-hostd.spec b/packaging/plugin-hostd.spec index 08a51a0..a1d325b 100644 --- a/packaging/plugin-hostd.spec +++ b/packaging/plugin-hostd.spec @@ -90,6 +90,8 @@ make test-daemon check-generated %{_mandir}/man1/plugin-hostd-probe.1* %{_datadir}/plugin-hostd/protocol.json %{_datadir}/plugin-hostd/mod-host.json +# the read-only pins of packages: a package that ships pins co-owns it with %dir +%dir %{_datadir}/plugin-hostd/pins.d %files tests %license COPYING From 0796c7b55148f3b21e4c872d98da3669c1528478 Mon Sep 17 00:00:00 2001 From: Pau Aliagas Date: Sun, 18 Oct 2026 20:49:26 +0200 Subject: [PATCH 12/16] worker_env test: whether a nice can be lowered is asked of the kernel, not read from the euid (#87) --- tests/daemon_test.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/tests/daemon_test.py b/tests/daemon_test.py index 43accb7..e780270 100644 --- a/tests/daemon_test.py +++ b/tests/daemon_test.py @@ -598,14 +598,28 @@ def policy_and_pool_config(): d.close() +def can_lower(): + """whether this identity may give a process a nice below the one it has (CAP_SYS_NICE, or RLIMIT_NICE reaching it), + asked of the kernel: a root without CAP_SYS_NICE, as in a container, cannot""" + p = subprocess.Popen(["sleep", "30"]) + try: + os.setpriority(os.PRIO_PROCESS, p.pid, os.getpriority(os.PRIO_PROCESS, p.pid) - 1) + return True + except PermissionError: + return False + finally: + p.kill() + p.wait() + + @test def worker_env_reaches_new_and_running_workers(): cpus = sorted(os.sched_getaffinity(0)) own = os.getpriority(os.PRIO_PROCESS, 0) # the daemon's nice is the test's def applied(nice, current=own): - """a daemon without privilege cannot lower a worker's nice below the one the worker has: its own, for a new worker""" - return nice if os.geteuid() == 0 else max(nice, current) + """a daemon that cannot lower a nice cannot give a worker one below the one the worker has: its own, for a new worker""" + return nice if can_lower() else max(nice, current) d = daemon() try: From 2162c938bf735e804917e7774562b1f0c8385241 Mon Sep 17 00:00:00 2001 From: Pau Aliagas Date: Sun, 18 Oct 2026 20:51:14 +0200 Subject: [PATCH 13/16] own-host page: an LV2 plugin through omx-clap-host (#52) --- docs/own-host.md | 41 ++++++++++++++++++++++++++++++++++++----- 1 file changed, 36 insertions(+), 5 deletions(-) diff --git a/docs/own-host.md b/docs/own-host.md index 0bbe982..edf6186 100644 --- a/docs/own-host.md +++ b/docs/own-host.md @@ -2,8 +2,8 @@ Use plugin-hostd in your own host ================================= This page takes a Debian machine (Raspberry Pi OS, Zynthian, any Debian 12 or 13, arm64 or amd64) from nothing to a -plugin running under plugin-hostd, driven over its socket, in nine commands. CI runs every command below, as written, in -a clean Debian container on both architectures; a step that stops working fails that job. +plugin running under plugin-hostd, driven over its socket, in nine commands, and then runs an LV2 plugin the same way in eight more. CI runs every command below, as written, in +a clean Debian container on Debian 12 and 13, on both architectures; a step that stops working fails that job. The plugin is a CLAP effect from the `omx-plugins-clap` package, so nothing else has to be installed to try it. Any host that already speaks mod-host's socket protocol can send the same lines. @@ -43,6 +43,38 @@ What each step did: 8. `param_set` sets Ratio to 8. 9. `param_get` reads it back. +LV2 plugins +----------- + +Zynthian's plugins are mostly LV2. `omx-clap-host`, which you already installed, also runs an LV2 plugin through its LV2 +adapter; the LV2 build of the same plugins is the package `omx-plugins-lv2`. Continue in the same shell, with the +`send` helper of the first block and JACK still running. `lv2_worker` names the program that runs LV2 plugins, and +`mod-host` is the default, so the daemon is restarted with `omx-clap-host` named in its configuration file. An LV2 +plugin is added by its URI with an `lv2:` prefix, and its parameters are named by their LV2 symbol (`ratio`), not by a +number. + +```sh own-host-lv2 +send "quit"; sleep 1 # resp 0 +sudo apt-get install -y omx-plugins-lv2 +echo "lv2_worker omx-clap-host" > plugin-hostd.conf +plugin-hostd -n -p 5555 -c plugin-hostd.conf >plugin-hostd-lv2.log 2>&1 & sleep 2 +send "add lv2:urn:openmixer:comp 0" # resp 0 +send "param_set 0 ratio 8" # resp 0 +send "param_get 0 ratio" # resp 0 8.0000 +send "worker_list" # has :lv2-clap: +``` + +1. `quit` stops the daemon and the worker of the first block. +2. `apt-get install` adds the LV2 bundles under `/usr/lib/lv2`, where the daemon searches. +3. The configuration file makes `omx-clap-host` the worker of LV2 plugins. +4. The daemon starts again with it. +5. `add` loads the plugin by its URI as instance `0`, in a worker of its own. +6. and 7. `param_set` and `param_get` set and read back the compressor's Ratio by its symbol. +8. `worker_list` names the worker of each instance; `lv2-clap` is `omx-clap-host` running an LV2 plugin. + +CI also sends a sine into the instance's JACK ports and measures what comes out, with the Makeup gain set to 6 dB and +Ratio to 1: the output peak must be 1.995 times the input's. + Pins need nothing from you. A plugin has to be pinned (its files' hashes and its parameter layout) before the daemon lets it run; in the default `first-use` mode the first `add` records the pin in `$XDG_STATE_HOME/plugin-hostd/pins`, and a later `add` of a changed file is refused with a `pin_changed` event. A host that pins by itself sets `pin_mode strict`. @@ -58,9 +90,8 @@ What a Zynthian integration still needs from us Checked against the published packages (0.2.1 of the daemon, 0.8.0 of the worker and plugins) in a clean Debian trixie container: -- LV2 needs mod-host, which neither Debian nor the FreeMixer channel packages. Zynthian's plugins are mostly LV2, so - until the channel carries an LV2 worker (mod-host, or `omx-clap-host` with `libomx-clap-lv2`, issue #45) the page - above only covers CLAP. +- An LV2 plugin that the LV2 adapter of `omx-clap-host` refuses needs mod-host, which neither Debian nor the FreeMixer + channel packages. The add answers `-512`; `lv2_worker auto` runs such a plugin in mod-host when one is installed. - `param_info` answers `-511` (the worker holds no checked unit and scale) for every parameter of a CLAP instance with the released worker, 0.8.0; the daemon passes the answer through. A host reads ranges and defaults from `omx-clap-scan` (or `omx-clap-scan --json`) and not from the socket, until a worker answers `resp 0`. From 4d0e47a310328664343889e8ea96b1a2e3ad531e Mon Sep 17 00:00:00 2001 From: Pau Aliagas Date: Sun, 18 Oct 2026 20:51:14 +0200 Subject: [PATCH 14/16] own-host CI runs the page's LV2 block and measures the audio through it (#52) --- .github/workflows/ci.yml | 7 ++-- tests/own_host_lv2_probe.py | 80 +++++++++++++++++++++++++++++++++++++ tests/own_host_page.py | 78 ++++++++++++++++++++++++++---------- 3 files changed, 140 insertions(+), 25 deletions(-) create mode 100644 tests/own_host_lv2_probe.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 581f93c..f8b5728 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -383,7 +383,8 @@ jobs: # docs/own-host.md, command by command, as a reader would type it: a clean Debian with nothing but what the page # lists, the published packages of the FreeMixer channel, on both architectures (arm64 is a native runner). - # A step of the page that stops working fails this job. + # A step of the page that stops working fails this job. The page's LV2 block runs an LV2 URI with + # `lv2_worker omx-clap-host`, and --audio then measures the gain of a sine that went through that instance. own-host-page: name: own-host page (${{ matrix.suite }}, ${{ matrix.arch }}), run as written in a clean container runs-on: ${{ matrix.arch == 'arm64' && 'ubuntu-24.04-arm' || 'ubuntu-latest' }} @@ -411,13 +412,13 @@ jobs: - name: The reader stops on a wrong reply run: python3 tests/own_host_page.py selftest - - name: The page's commands + - name: The page's commands, the LV2 plugin included, and its audio run: | set -euo pipefail export XDG_RUNTIME_DIR=/tmp/xdg XDG_STATE_HOME=/tmp/state mkdir -p "$XDG_RUNTIME_DIR" "$XDG_STATE_HOME" /tmp/own-host cd /tmp/own-host - python3 "$GITHUB_WORKSPACE/tests/own_host_page.py" run + python3 "$GITHUB_WORKSPACE/tests/own_host_page.py" run --audio test "$(dpkg --print-architecture)" = "${{ matrix.arch }}" # The release workflow as a tag would run it, publishing nothing: both architectures build through the diff --git a/tests/own_host_lv2_probe.py b/tests/own_host_lv2_probe.py new file mode 100644 index 0000000..972124c --- /dev/null +++ b/tests/own_host_lv2_probe.py @@ -0,0 +1,80 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Proves a JACK client processes audio: plays a sine into :in_1 and in_2 and measures the peak that comes out of +# :out_1 and out_2 through libjack, with nothing but python3 and the JACK library the page installs. It exits +# 0 only when out/in is within of , the linear gain the plugin was set to (a silent or unprocessed +# output measures 0 or 1). +# +# own_host_lv2_probe.py +import ctypes, math, sys, time + +PORT_IS_INPUT, PORT_IS_OUTPUT = 1, 2 +AMPLITUDE = 0.25 +HZ = 1000.0 +SETTLE_S, MEASURE_S = 1.5, 1.5 + +CB = ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_uint32, ctypes.c_void_p) + + +def main(): + if len(sys.argv) != 4: + sys.exit("usage: own_host_lv2_probe.py ") + target, want, tol = sys.argv[1], float(sys.argv[2]), float(sys.argv[3]) + jack = ctypes.CDLL("libjack.so.0") + jack.jack_client_open.restype = ctypes.c_void_p + jack.jack_client_open.argtypes = [ctypes.c_char_p, ctypes.c_int, ctypes.c_void_p] + jack.jack_port_register.restype = ctypes.c_void_p + jack.jack_port_register.argtypes = [ctypes.c_void_p, ctypes.c_char_p, ctypes.c_char_p, ctypes.c_ulong, ctypes.c_ulong] + jack.jack_port_get_buffer.restype = ctypes.c_void_p + jack.jack_port_get_buffer.argtypes = [ctypes.c_void_p, ctypes.c_uint32] + jack.jack_set_process_callback.argtypes = [ctypes.c_void_p, CB, ctypes.c_void_p] + jack.jack_get_sample_rate.argtypes = [ctypes.c_void_p] + jack.jack_activate.argtypes = [ctypes.c_void_p] + jack.jack_connect.argtypes = [ctypes.c_void_p, ctypes.c_char_p, ctypes.c_char_p] + + c = jack.jack_client_open(b"own-host-probe", 0, None) + if not c: + sys.exit("own_host_lv2_probe: cannot open a JACK client (is jackd running?)") + audio = b"32 bit float mono audio" + outs = [jack.jack_port_register(c, b"out_%d" % i, audio, PORT_IS_OUTPUT, 0) for i in (1, 2)] + ins = [jack.jack_port_register(c, b"in_%d" % i, audio, PORT_IS_INPUT, 0) for i in (1, 2)] + rate = jack.jack_get_sample_rate(c) + state = {"phase": 0, "measuring": False, "in_peak": 0.0, "out_peak": [0.0, 0.0], "cycles": 0} + step = 2.0 * math.pi * HZ / rate + + def process(n, _arg): + sig = [] + for k in range(n): + sig.append(AMPLITUDE * math.sin(state["phase"])) + state["phase"] = (state["phase"] + step) % (2.0 * math.pi) + for p in outs: + (ctypes.c_float * n).from_address(jack.jack_port_get_buffer(p, n))[:] = sig + if state["measuring"]: + state["cycles"] += 1 + state["in_peak"] = max(state["in_peak"], max(abs(x) for x in sig)) + for i, p in enumerate(ins): + buf = (ctypes.c_float * n).from_address(jack.jack_port_get_buffer(p, n)) + state["out_peak"][i] = max(state["out_peak"][i], max(abs(x) for x in buf)) + return 0 + + cb = CB(process) + jack.jack_set_process_callback(c, cb, None) + if jack.jack_activate(c) != 0: + sys.exit("own_host_lv2_probe: cannot activate the JACK client") + for i in (1, 2): + for a, b in (("own-host-probe:out_%d" % i, "%s:in_%d" % (target, i)), ("%s:out_%d" % (target, i), "own-host-probe:in_%d" % i)): + if jack.jack_connect(c, a.encode(), b.encode()) != 0: + sys.exit("own_host_lv2_probe: cannot connect %s to %s" % (a, b)) + time.sleep(SETTLE_S) + state["measuring"] = True + time.sleep(MEASURE_S) + state["measuring"] = False + if state["cycles"] == 0: + sys.exit("own_host_lv2_probe: JACK ran no process cycle") + ratios = [p / state["in_peak"] for p in state["out_peak"]] + print("own_host_lv2_probe: %s in peak %.4f, out peak %s, gain %s (wanted %.4f +- %.4f)" + % (target, state["in_peak"], ["%.4f" % p for p in state["out_peak"]], ["%.4f" % r for r in ratios], want, tol)) + if any(abs(r - want) > tol for r in ratios): + sys.exit("own_host_lv2_probe: the output does not carry the gain the plugin was set to") + + +main() diff --git a/tests/own_host_page.py b/tests/own_host_page.py index bb33875..fd39422 100644 --- a/tests/own_host_page.py +++ b/tests/own_host_page.py @@ -4,17 +4,29 @@ # # A line of the block is a command. `send() ...` is a helper and is not counted. A trailing `# resp ` says the # command's output must begin with ; `# has ` says it must contain . Anything else after `#` is -# a comment. The page promises "nine commands"; the block may not grow past ten. +# a comment. The page promises "nine commands"; the block may not grow past ten. The page has a second block, +# `sh own-host-lv2`, that continues the first in the same shell (the `send` helper, JACK running); both run as one +# script. `run --audio` then also proves with libjack that the LV2 instance processes audio: it sets the compressor +# to a 6 dB makeup gain and checks the output peak of a sine against the input's. import os, re, subprocess, sys, tempfile PAGE = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "docs", "own-host.md") MAX_COMMANDS = 10 -def block(text): - m = re.search(r"^```sh own-host\n(.*?)^```", text, re.S | re.M) +BLOCKS = ("own-host", "own-host-lv2") + +# after the page's blocks, with the LV2 instance 0 of the second one running: the sine goes in, the gain must come out +AUDIO = """send "param_set 0 ratio 1" | grep -qx 'resp 0' +send "param_set 0 makeupDb 6" | grep -qx 'resp 0' +python3 "$OWN_HOST_PROBE" effect_0 1.9953 0.03 +""" + + +def block(text, name="own-host"): + m = re.search(r"^```sh %s\n(.*?)^```" % re.escape(name), text, re.S | re.M) if not m: - sys.exit("own_host_page: no ```sh own-host block in the page") + sys.exit("own_host_page: no ```sh %s block in the page" % name) return [l for l in m.group(1).splitlines() if l.strip()] @@ -53,6 +65,17 @@ def script(lines): return "\n".join(out) + "\n", n +def page_script(text): + """the bash script of every block in order, and the command count of each""" + parts, counts = [], [] + for name in BLOCKS: + sc, n = script(block(text, name)) + commands_ok(n) + parts.append(sc) + counts.append(n) + return "\n".join(parts), counts + + def commands_ok(n): if n > MAX_COMMANDS: sys.exit("own_host_page: %d commands, the page promises under ten (%d at most)" % (n, MAX_COMMANDS)) @@ -70,48 +93,59 @@ def commands_ok(n): """ -def stubbed(sc, lines, add, setp, get, scan): - send = ('send() { case "$1" in add*) echo "%s";; param_set*) echo "%s";; param_get*) echo "%s";; esac; }' % (add, setp, get)) - return (STUBS + "omx-clap-scan() { echo '%s'; }\n" % scan) + sc.replace(lines[0], send, 1) +REPLIES = ("add_clap", "set_clap", "get_clap", "scan", "quit", "add_lv2", "set_lv2", "get_lv2", "worker_list") +RIGHT = ("resp 0", "resp 0", "resp 0 8.0000", " param 1 Ratio [1 .. 20] default 4 automatable", + "resp 0", "resp 0", "resp 0", "resp 0 8.0000", "resp 1 w1:42:lv2-clap:up:own:0") +WRONG = {"scan": " param 1 Wrong", "worker_list": "resp 1 w1:42:mod-host:up:own:0"} + + +def stubbed(sc, lines, replies): + r = dict(zip(REPLIES, replies)) + send = ('send() { case "$1" in "add clap"*) echo "%(add_clap)s";; "add lv2"*) echo "%(add_lv2)s";; ' + '"param_set 0 ratio"*) echo "%(set_lv2)s";; param_set*) echo "%(set_clap)s";; ' + '"param_get 0 ratio"*) echo "%(get_lv2)s";; param_get*) echo "%(get_clap)s";; ' + 'quit*) echo "%(quit)s";; worker_list*) echo "%(worker_list)s";; esac; }' % r) + return (STUBS + "omx-clap-scan() { echo '%s'; }\n" % r["scan"]) + sc.replace(lines[0], send, 1) def selftest(): - lines = block(open(PAGE).read()) - sc, n = script(lines) - commands_ok(n) - right = ("resp 0", "resp 0", "resp 0 8.0000", " param 1 Ratio [1 .. 20] default 4 automatable") - cases = [("right replies", right, 0)] - for i, name in enumerate(("add", "param_set", "param_get", "scan")): - wrong = list(right) - wrong[i] = "resp -103" if i < 3 else " param 1 Wrong" + text = open(PAGE).read() + lines = block(text) + sc, counts = page_script(text) + cases = [("right replies", RIGHT, 0)] + for i, name in enumerate(REPLIES): + wrong = list(RIGHT) + wrong[i] = WRONG.get(name, "resp -103") cases.append(("a wrong " + name, tuple(wrong), 1)) failed = 0 for label, vals, want in cases: with tempfile.NamedTemporaryFile("w", suffix=".sh", delete=False) as f: - f.write(stubbed(sc, lines, *vals)) + f.write(stubbed(sc, lines, vals)) rc = subprocess.run(["bash", f.name], capture_output=True, text=True, cwd=tempfile.gettempdir()).returncode os.unlink(f.name) if (rc != 0) != (want != 0): print("FAIL own_host_page selftest: %s exited %d" % (label, rc)) failed = 1 if not failed: - print("ok own_host_page: %d commands; the right replies pass and each wrong one stops the script" % n) + print("ok own_host_page: %s commands; the right replies pass and each wrong one stops the script" + % " + ".join(str(n) for n in counts)) return failed def main(): cmd = sys.argv[1] if len(sys.argv) > 1 else "selftest" - lines = block(open(PAGE).read()) - sc, n = script(lines) - commands_ok(n) + sc, _ = page_script(open(PAGE).read()) if cmd == "script": sys.stdout.write(sc) elif cmd == "run": - sys.exit(subprocess.run(["bash", "-c", sc]).returncode) + if "--audio" in sys.argv[2:]: + sc += AUDIO + env = dict(os.environ, OWN_HOST_PROBE=os.path.join(os.path.dirname(os.path.abspath(__file__)), "own_host_lv2_probe.py")) + sys.exit(subprocess.run(["bash", "-c", sc], env=env).returncode) elif cmd == "selftest": sys.exit(selftest()) else: - sys.exit("usage: own_host_page.py script|run|selftest") + sys.exit("usage: own_host_page.py script|run [--audio]|selftest") main() From 2251df5260b5f548b2d6cae7b4db872bd28dbc5a Mon Sep 17 00:00:00 2001 From: Pau Aliagas Date: Sun, 18 Oct 2026 22:42:06 +0200 Subject: [PATCH 15/16] own-host page: say which version each checked item was checked against; argue the probe tolerance (#52) --- docs/own-host.md | 5 +++-- tests/own_host_lv2_probe.py | 2 ++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/own-host.md b/docs/own-host.md index edf6186..62d50be 100644 --- a/docs/own-host.md +++ b/docs/own-host.md @@ -87,8 +87,9 @@ consumer can include `plugin-hostd/protocol.h` (`pkg-config --cflags plugin-host What a Zynthian integration still needs from us ----------------------------------------------- -Checked against the published packages (0.2.1 of the daemon, 0.8.0 of the worker and plugins) in a clean Debian -trixie container: +Checked in a clean Debian container against published packages. The first item against 0.4.0 of the daemon and 0.10.0 of +the worker and plugins (the page's CI); the others against 0.2.1 of the daemon and 0.8.0 of the worker and plugins, on +trixie, and not re-checked since: - An LV2 plugin that the LV2 adapter of `omx-clap-host` refuses needs mod-host, which neither Debian nor the FreeMixer channel packages. The add answers `-512`; `lv2_worker auto` runs such a plugin in mod-host when one is installed. diff --git a/tests/own_host_lv2_probe.py b/tests/own_host_lv2_probe.py index 972124c..9b0b0a6 100644 --- a/tests/own_host_lv2_probe.py +++ b/tests/own_host_lv2_probe.py @@ -70,6 +70,8 @@ def process(n, _arg): state["measuring"] = False if state["cycles"] == 0: sys.exit("own_host_lv2_probe: JACK ran no process cycle") + # the tolerance: float32 and the plugin's own smoothing leave the measured gain within about 0.005 of the set one + # (1.9953 measured), while the nearest wrong value, an unprocessed signal, measures 1.0 and silence 0.0 ratios = [p / state["in_peak"] for p in state["out_peak"]] print("own_host_lv2_probe: %s in peak %.4f, out peak %s, gain %s (wanted %.4f +- %.4f)" % (target, state["in_peak"], ["%.4f" % p for p in state["out_peak"]], ["%.4f" % r for r in ratios], want, tol)) From 4cfd5b8e1d34729f454de536d948aa7740a2430c Mon Sep 17 00:00:00 2001 From: Pau Aliagas Date: Mon, 19 Oct 2026 00:35:26 +0200 Subject: [PATCH 16/16] plugin-hostd 0.5.0 plugin-hostd-probe measures LV2 plugins through lv2_measure; a pin whose verdict is refused refuses the add with PIN_REFUSED (-516); packages ship pins in pins_dir, a read-only layer under the host's; NOTE_QUEUE_FULL (-517) is declared; a plugin the LV2 adapter refused under auto gets a worker of its own; a worker whose feedback the relay cannot follow is not started, and a failed realloc no longer loses the relay's or the LV2 adapter's tables; a worker_env nice that cannot be applied is logged. The build now requires omx-contract-devel >= 2.16.0, which the probe runner's rates and the protocol's scales are read from. --- debian/changelog | 44 +++++++++++++++++++++++++++++++++++++ doc/plugin-hostd-probe.1 | 2 +- doc/plugin-hostd.1 | 2 +- packaging/plugin-hostd.spec | 43 +++++++++++++++++++++++++++++++++++- src/main.c | 2 +- 5 files changed, 89 insertions(+), 4 deletions(-) diff --git a/debian/changelog b/debian/changelog index 41cb426..60866fc 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,47 @@ +plugin-hostd (0.5.0) unstable; urgency=medium + + * plugin-hostd-probe runs LV2 plugins too: `run lv2:` measures a + plugin through plugin-qualify's lv2_measure, the pin pass and then the + measurement, each a child under the same limits and wall clock. The + bundle's files become the document's binary. A plugin of which nothing + can be pinned exits 2 with no document, and stderr starts with its + outcome code (probe.unpinnable). + * A pin whose verdict is refused refuses the add with the new code + PIN_REFUSED (-516), after the files are checked against the pin; an + instance whose pin turns refused is refused at its next replay. + * Packages ship pins: the new key pins_dir (default + /usr/share/plugin-hostd/pins.d, which the packages own) names a + directory whose *.pins files the daemon reads at start, in name order, + as a read-only layer under the host's pins. A refused pin there always + holds, and pin_set and pin_accept of its plugin answer -516; any other + package pin gives way to a host pin of the same plugin. + * New code NOTE_QUEUE_FULL (-517): the worker had no room for a note_on, + note_off or all_notes_off before its next block, and nothing of the line + was kept. The daemon passes it to the controller. + * Under lv2_worker auto, a plugin the LV2 adapter refused gets a worker of + its own unless the add names a placement, so a plugin that aborts on + mod-host no longer takes a pool's other instances with it. An add whose + worker dies on it also sends instance_verb_dropped, which names it. + * A worker whose feedback the relay cannot follow is not started: the add + answers -503. With no memory the relay closes a worker's socket instead + of leaving it unread, and its poll set no longer crashes the daemon. A + failed realloc no longer loses the relay's gesture table or the LV2 + adapter's refusal and pick tables: only the one entry is dropped. + * A worker_env nice the daemon cannot apply is logged on stderr; the reply + stays resp 0. + * The probe runner's default rates and the protocol's parameter scales are + read from omx-contract's C header (ORACLE_FLOOR_RATES, PARAM_SCALES), so + the build now requires omx-contract-devel >= 2.16.0 (libomx-contract-dev + >= 2.16.0 on Debian). Nothing is linked. + * plugin-hostd-tests requires plugin-hostd of the same build, and CI + checks it. The packaging check refuses any ceiling on + omx-clap-host-tests ('<=', '=' or '<'). + * The own-host page gains an LV2 block run through omx-clap-host, and CI + measures the audio through the plugin on Debian bookworm and trixie. The + LV2 adapter tests' refusal arms use a fixture the adapter still refuses. + + -- Pau Aliagas Mon, 19 Oct 2026 00:00:00 +0000 + plugin-hostd (0.4.0) unstable; urgency=medium * New tool plugin-hostd-probe: it starts a program that loads a plugin diff --git a/doc/plugin-hostd-probe.1 b/doc/plugin-hostd-probe.1 index 81ed2bf..8074f84 100644 --- a/doc/plugin-hostd-probe.1 +++ b/doc/plugin-hostd-probe.1 @@ -1,4 +1,4 @@ -.TH PLUGIN\-HOSTD\-PROBE 1 "October 2026" "plugin-hostd 0.4.0" "User Commands" +.TH PLUGIN\-HOSTD\-PROBE 1 "October 2026" "plugin-hostd 0.5.0" "User Commands" .SH NAME plugin\-hostd\-probe \- run a plugin prober under limits and write what happened .SH SYNOPSIS diff --git a/doc/plugin-hostd.1 b/doc/plugin-hostd.1 index ee60e02..98714ae 100644 --- a/doc/plugin-hostd.1 +++ b/doc/plugin-hostd.1 @@ -1,4 +1,4 @@ -.TH PLUGIN\-HOSTD 1 "October 2026" "plugin-hostd 0.4.0" "User Commands" +.TH PLUGIN\-HOSTD 1 "October 2026" "plugin-hostd 0.5.0" "User Commands" .SH NAME plugin\-hostd \- supervisor of plugin host processes behind one mod\-host protocol socket .SH SYNOPSIS diff --git a/packaging/plugin-hostd.spec b/packaging/plugin-hostd.spec index 6b7bedf..8b0b6a7 100644 --- a/packaging/plugin-hostd.spec +++ b/packaging/plugin-hostd.spec @@ -1,7 +1,7 @@ %bcond_without check Name: plugin-hostd -Version: 0.4.0 +Version: 0.5.0 Release: 1%{?dist} License: GPL-3.0-or-later Summary: Run LV2 and CLAP plugins in a live rig without one crash taking down the show @@ -108,6 +108,47 @@ make test-daemon check-generated %{_datadir}/plugin-hostd/probe-output.schema.json %changelog +* Mon Oct 19 2026 Pau Aliagas - 0.5.0-1 +- plugin-hostd-probe runs LV2 plugins too: `run lv2:` measures a + plugin through plugin-qualify's lv2_measure, the pin pass and then the + measurement, each a child under the same limits and wall clock. The + bundle's files become the document's binary. A plugin of which nothing + can be pinned exits 2 with no document, and stderr starts with its + outcome code (probe.unpinnable). +- A pin whose verdict is refused refuses the add with the new code + PIN_REFUSED (-516), after the files are checked against the pin; an + instance whose pin turns refused is refused at its next replay. +- Packages ship pins: the new key pins_dir (default + /usr/share/plugin-hostd/pins.d, which the packages own) names a + directory whose *.pins files the daemon reads at start, in name order, + as a read-only layer under the host's pins. A refused pin there always + holds, and pin_set and pin_accept of its plugin answer -516; any other + package pin gives way to a host pin of the same plugin. +- New code NOTE_QUEUE_FULL (-517): the worker had no room for a note_on, + note_off or all_notes_off before its next block, and nothing of the line + was kept. The daemon passes it to the controller. +- Under lv2_worker auto, a plugin the LV2 adapter refused gets a worker of + its own unless the add names a placement, so a plugin that aborts on + mod-host no longer takes a pool's other instances with it. An add whose + worker dies on it also sends instance_verb_dropped, which names it. +- A worker whose feedback the relay cannot follow is not started: the add + answers -503. With no memory the relay closes a worker's socket instead + of leaving it unread, and its poll set no longer crashes the daemon. A + failed realloc no longer loses the relay's gesture table or the LV2 + adapter's refusal and pick tables: only the one entry is dropped. +- A worker_env nice the daemon cannot apply is logged on stderr; the reply + stays resp 0. +- The probe runner's default rates and the protocol's parameter scales are + read from omx-contract's C header (ORACLE_FLOOR_RATES, PARAM_SCALES), so + the build now requires omx-contract-devel >= 2.16.0 (libomx-contract-dev + >= 2.16.0 on Debian). Nothing is linked. +- plugin-hostd-tests requires plugin-hostd of the same build, and CI + checks it. The packaging check refuses any ceiling on + omx-clap-host-tests ('<=', '=' or '<'). +- The own-host page gains an LV2 block run through omx-clap-host, and CI + measures the audio through the plugin on Debian bookworm and trixie. The + LV2 adapter tests' refusal arms use a fixture the adapter still refuses. + * Sun Oct 18 2026 Pau Aliagas - 0.4.0-1 - New tool plugin-hostd-probe: it starts a program that loads a plugin (clap-qualify by default) under limits of CPU time, memory, open files, diff --git a/src/main.c b/src/main.c index 309c2b4..7843eb1 100644 --- a/src/main.c +++ b/src/main.c @@ -34,7 +34,7 @@ #include "verbs.h" #ifndef VERSION -#define VERSION "0.4.0" +#define VERSION "0.5.0" #endif #define SOCKET_DEFAULT_PORT PHD_DEFAULT_COMMAND_PORT