From 183dfe43f9541f299f12ff5343ec8de67683ad8f Mon Sep 17 00:00:00 2001 From: Pau Aliagas Date: Sat, 24 Oct 2026 12:45:34 +0200 Subject: [PATCH 01/10] json: free a parsed value A daemon that reads a JSON file on every scan must give the tree back; the probe never needed to. --- src/json.c | 18 ++++++++++++++++++ src/json.h | 2 ++ 2 files changed, 20 insertions(+) diff --git a/src/json.c b/src/json.c index ee24dd2..9e85755 100644 --- a/src/json.c +++ b/src/json.c @@ -163,6 +163,24 @@ static jnode_t *jnew(jtype_t t) return n; } +void jfree(jnode_t *n) +{ + size_t i; + + if (n == NULL) + return; + for (i = 0; i < n->n; i++) + { + jfree(n->kid[i]); + if (n->key) + free(n->key[i]); + } + free(n->kid); + free(n->key); + free(n->str); + free(n); +} + static jnode_t *jparse_value(const char **p, int depth) { jnode_t *n; diff --git a/src/json.h b/src/json.h index 7ef5695..a940d45 100644 --- a/src/json.h +++ b/src/json.h @@ -39,6 +39,8 @@ typedef struct jnode /* the whole text is one value, or NULL */ jnode_t *jparse(const char *text); +/* a value jparse returned, and everything under it */ +void jfree(jnode_t *n); jnode_t *jget(const jnode_t *o, const char *key); const char *jstr(const jnode_t *o, const char *key); int jnum(const jnode_t *o, const char *key, double *out); From 3e88b3d1e22939493c7b54fdabf3848c2cbb68d4 Mon Sep 17 00:00:00 2001 From: Pau Aliagas Date: Sat, 24 Oct 2026 12:45:34 +0200 Subject: [PATCH 02/10] describe: name the plugin id a target holds The CLAP id after the last '#', or the LV2 URI: what a scanned descriptor's file is named by. --- src/describe.c | 9 +++++++++ src/describe.h | 3 +++ 2 files changed, 12 insertions(+) diff --git a/src/describe.c b/src/describe.c index eb49c3c..039a57a 100644 --- a/src/describe.c +++ b/src/describe.c @@ -884,6 +884,15 @@ const char *describe_format(const char *target) return NULL; } +const char *describe_id(const char *target) +{ + const char *format = describe_format(target); + + if (format == NULL) + return NULL; + return !strcmp(format, "clap") ? strrchr(target, '#') + 1 : target + 4; +} + int describe_dump(const char *table_text, const char *dump_text, const char *target, sb_t *out, char *why, size_t why_size) { diff --git a/src/describe.h b/src/describe.h index e856807..3ab2a8e 100644 --- a/src/describe.h +++ b/src/describe.h @@ -18,6 +18,9 @@ /* the format of a target, clap:# or lv2:: "clap", "lv2", or NULL */ const char *describe_format(const char *target); +/* the plugin id a target names: the CLAP id after its last '#', or the LV2 URI; a pointer into target, or NULL */ +const char *describe_id(const char *target); + /* the scanned descriptor of a dump: the format table's text, the dump's text and the target asked for; 0 with the * descriptor in out, -1 refused (a table it cannot read, a dump it refuses) with why */ int describe_dump(const char *table_text, const char *dump_text, const char *target, sb_t *out, char *why, From bc9e191a7cf91f57a101ef2bbd55eaa25894db64 Mon Sep 17 00:00:00 2001 From: Pau Aliagas Date: Sat, 24 Oct 2026 12:45:34 +0200 Subject: [PATCH 03/10] formats: the file a plugin's code is loaded from A row says which one file holds the plugin's code: the .clap of a CLAP uri, the file an LV2 bundle's manifest names with lv2:binary. The name of a scanned descriptor hashes it, as plugin-qualify's scanners do. --- src/formats.c | 15 +++++++++++++++ src/formats.h | 7 +++++++ src/lv2.c | 33 +++++++++++++++++++++++++++++---- 3 files changed, 51 insertions(+), 4 deletions(-) diff --git a/src/formats.c b/src/formats.c index 513c9e2..b4d3ed5 100644 --- a/src/formats.c +++ b/src/formats.c @@ -62,6 +62,18 @@ int clap_pin_collect(const char *uri, const char *search, char *root, size_t siz return SUCCESS; } +int clap_binary(const char *uri, const char *search, char *path, size_t size) +{ + const char *file = uri + strlen(CLAP_PREFIX), *hash = strrchr(file, '#'); + size_t len = hash ? (size_t)(hash - file) : strlen(file); + + (void)search; + if (strncmp(uri, CLAP_PREFIX, strlen(CLAP_PREFIX)) || !len || file[0] != '/' || len >= size) + return -1; + snprintf(path, size, "%.*s", (int)len, file); + return 0; +} + /* The built-in rows, in the order of PHD_FORMATS. */ static const format_t g_builtin[] = { { @@ -74,6 +86,7 @@ static const format_t g_builtin[] = { .world_env = "LV2_PATH", .narrow_world = lv2_narrow_world, .pin_collect = lv2_pin_collect, + .binary = lv2_binary, .route = lv2_route, .layout_check = 0, .pinned_own_only = 1, @@ -87,6 +100,7 @@ static const format_t g_builtin[] = { .fallback = 0, .worker = clap_worker, .pin_collect = clap_pin_collect, + .binary = clap_binary, .layout_check = 1, .pinned_own_only = 0, .native_instance_verbs = 1, @@ -108,6 +122,7 @@ static const format_t g_builtin[] = { .world_env = "LV2_PATH", .narrow_world = lv2_narrow_world, .pin_collect = lv2_pin_collect, + .binary = lv2_binary, .layout_check = 0, .pinned_own_only = 1, .native_instance_verbs = 1, diff --git a/src/formats.h b/src/formats.h index 7dd5ad6..941b6db 100644 --- a/src/formats.h +++ b/src/formats.h @@ -52,6 +52,9 @@ typedef struct FORMAT_T { /* the root and the relative files a host loads for the uri: what a pin holds. SUCCESS, or * PHD_ERR_PIN_BINARY_MISMATCH with the reason on stderr for what cannot be pinned */ int (*pin_collect)(const char *uri, const char *search, char *root, size_t size, names_t *files); + /* the one file the host loads the plugin's code from, as a path: what the name of its scanned descriptor hashes. 0, + * or -1 when it cannot be told; NULL for a format whose plugins get no descriptor */ + int (*binary)(const char *uri, const char *search, char *path, size_t size); int layout_check; /* the worker takes pin_expect before the add and pin_layout after it */ int pinned_own_only; /* a pinned plugin runs in a worker of its own: the pin holds in no shared world */ @@ -79,7 +82,11 @@ int format_route(const conf_t *conf, int format, const char *uri); /* the pin root and files of a CLAP: the directory of the .clap and the file */ int clap_pin_collect(const char *uri, const char *search, char *root, size_t size, names_t *files); +/* the .clap file of clap:# */ +int clap_binary(const char *uri, const char *search, char *path, size_t size); + /* the LV2 format's parts, in lv2.c */ +int lv2_binary(const char *uri, const char *search, char *path, size_t size); int lv2_pin_collect(const char *uri, const char *search, char *root, size_t size, names_t *files); char *lv2_narrow_world(const conf_t *conf, const char *dir, const char *const *uris, int n); char *lv2_param_info(int id, const char *symbol); diff --git a/src/lv2.c b/src/lv2.c index 5866154..afa9c37 100644 --- a/src/lv2.c +++ b/src/lv2.c @@ -240,8 +240,10 @@ static void bundle_path(const scan_t *s, const char *bundle, const char *iri, ch enum { WANT_SUBJECT, WANT_PREDICATE, WANT_OBJECT, AFTER_OBJECT }; -/* the files the manifest names with lv2:binary and rdfs:seeAlso for the plugin; whether it sets a base */ -static int manifest_files(const char *text, size_t size, const char *bundle, const char *uri, names_t *names) +/* the files the manifest names with lv2:binary and rdfs:seeAlso for the plugin, or with lv2:binary alone when + * binary_only; whether it sets a base */ +static int manifest_files(const char *text, size_t size, const char *bundle, const char *uri, names_t *names, + int binary_only) { scan_t *s = calloc(1, sizeof(scan_t)); token_t t; @@ -284,7 +286,7 @@ static int manifest_files(const char *text, size_t size, const char *bundle, con } else if (state == WANT_PREDICATE) { - pred = t.kind == T_IRI && (!strcmp(t.text, LV2_BINARY) || !strcmp(t.text, RDFS_SEEALSO)); + pred = t.kind == T_IRI && (!strcmp(t.text, LV2_BINARY) || (!binary_only && !strcmp(t.text, RDFS_SEEALSO))); state = WANT_OBJECT; } else if (state == WANT_OBJECT) @@ -351,7 +353,7 @@ int lv2_pin_collect(const char *uri, const char *search, char *root, size_t size return pin_refuse(uri, MANIFEST, "can't be read as a manifest"); /* the host resolves a base and decodes a percent-encoded name; the daemon does neither, so it cannot know the file * the host would load */ - if (manifest_files(manifest, manifest_size, root, bare(uri), &names)) + if (manifest_files(manifest, manifest_size, root, bare(uri), &names, 0)) rc = pin_refuse(uri, MANIFEST, "sets a base the daemon does not resolve"); names_add(files, MANIFEST); for (n = 0; n < names.n && rc == SUCCESS; n++) @@ -366,6 +368,29 @@ int lv2_pin_collect(const char *uri, const char *search, char *root, size_t size return rc; } +/* the one lv2:binary the manifest of the plugin's bundle names for it */ +int lv2_binary(const char *uri, const char *search, char *path, size_t size) +{ + names_t names = { NULL, 0 }; + char *bundle = proc_find_lv2_bundle(search, bare(uri)), *manifest = NULL; + size_t manifest_size = 0; + int rc = -1; + + if (!bundle) + return -1; + if (read_manifest(bundle, &manifest, &manifest_size) == 0 && + !manifest_files(manifest, manifest_size, bundle, bare(uri), &names, 1) && names.n == 1 && + !strstr(names.name[0], PHD_PIN_PERCENT)) + { + snprintf(path, size, "%s/%s", bundle, names.name[0]); + rc = 0; + } + names_free(&names); + free(manifest); + free(bundle); + return rc; +} + /* an own LV2 worker sees a world of one bundle, a chain's worker the union of its members': a directory holding a link * to each bundle that names one of the URIs, once; NULL when a URI's bundle is not found */ char *lv2_narrow_world(const conf_t *conf, const char *dir, const char *const *uris, int n) From a297e94bf428afe29272f24c8616dff9cb99783c Mon Sep 17 00:00:00 2001 From: Pau Aliagas Date: Sat, 24 Oct 2026 12:45:44 +0200 Subject: [PATCH 04/10] protocol: declare descriptor_dir, qualification_dir and descriptor_get Two state directories beside state_root, with the defaults of the directory table of the contract-definitions spec (6.9): $XDG_STATE_HOME/omx/descriptors and $XDG_STATE_HOME/omx/qualification, /var/lib/omx/... for a daemon run as root, else ~/.local/state/omx/...; OMX_DESCRIPTOR_DIR and OMX_QUALIFICATION_DIR stand in for the defaults in tests. The probe key names the scanner an add runs. A scanned descriptor is named @-.json, the rule of plugin-qualify's scanners: characters outside [A-Za-z0-9._-] as _, 'unversioned' when the plugin states none, and the first 12 hex digits of the binary's SHA-256. descriptor_get answers the file's path, or one of four new codes: -528 a directory that cannot be written, -529 no descriptor, -530 the scan runs, -531 the scan wrote nothing. The man page gains a DIRECTORIES section generated from the same rows, so the documented defaults are the code's. --- README.md | 9 +++++ doc/plugin-hostd.1 | 34 +++++++++++++++++- include/plugin-hostd/protocol.h | 61 ++++++++++++++++++++++++++++++--- protocol/plugin-hostd.json | 15 ++++++-- tools/protocol-gen.c | 33 +++++++++++++++++- 5 files changed, 143 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 426134f..4e41410 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,9 @@ The settings live in a file, `-c`, `$PLUGIN_HOSTD_CONF` or | `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 | +| `descriptor_dir` | `$OMX_DESCRIPTOR_DIR`, else `` | the scanned descriptors, one file per plugin binary (@-.json, the hash the binary's SHA-256), written by the scan of an add and read by descriptor_get; empty is $XDG_STATE_HOME/omx/descriptors, else /var/lib/omx/descriptors run as root (a system service), else $HOME/.local/state/omx/descriptors; its variable is for tests only | +| `qualification_dir` | `$OMX_QUALIFICATION_DIR`, else `` | plugin-qualify's qualification records (verdict, cost, version tested), kept apart from the descriptors; empty is $XDG_STATE_HOME/omx/qualification, else /var/lib/omx/qualification run as root (a system service), else $HOME/.local/state/omx/qualification; its variable is for tests only | +| `probe` | `plugin-hostd-probe` | the scanner an add runs, as describe , in a child of its own once per plugin binary; its stdout is the descriptor | | `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 | @@ -169,6 +172,7 @@ Verbs the daemon adds | `pin_expect` | ` :` | `resp 0` | sent by the daemon to a worker of a format that checks layouts, just before the instance's add: the layout pin that add checks after init and before activate; any other reply than resp 0 refuses the add with that code, and the add is never forwarded | | `chain` | ` [ ...]` | `resp 0` | the instances, in this order, are one chain, run in one worker of place chain:: each member's state is saved, it is removed from the worker it was in and replayed in the chain's. A chain already declared takes the new order, and a member left out of it goes back to a worker of its own. A worker that runs chains in one client (clap, lv2-clap) is sent this line, with the members it holds, after the adds of its replay; one that does not (mod-host) never is, and a refusal of it is written to stderr and is no failure. Every instance verb still names one member by its own number. A member that crashes the worker is quarantined to a worker of its own and the chain goes on without it; quarantine_clear puts it back. -518 for an instance no add made, -519 for one named twice, -520 for one in another chain, -521 for members of more than one format, -501 for a plugin that may not share a worker while require_pins is on (a CLAP plugin whose first-use pin is not in-process; an LV2 member whose bundle is not found, since a chain's worker sees a world narrowed to the union of its members' bundles), -902 for a chain or an instance that is not a number, or fewer than chain_members_min (1) instances | | `chain_clear` | `` | `resp 0` | forget the chain: each member goes to a worker of its own; -522 for a chain no chain declared | +| `descriptor_get` | `` | `resp 0 ""` | the scanned descriptor of the plugin, answered from descriptor_dir: the file named for its binary as it is now (@-.json), written by the scan the plugin's add ran. -529 when there is none, -530 while its scan runs, -531 when its last scan wrote nothing, -528 when the directory could not be written | A `` is `up` (accepting commands), `starting` (spawned, not yet accepting), `backoff` (dead, waiting to be respawned), `given-up` (the storm bound is spent for its placement). @@ -301,6 +305,7 @@ mechanism, and they stay optional. The preset verbs and the preset file are [doc | `lv2_worker_set` | daemon | required | required | required | required | | `chain` | daemon | required | required | required | required | | `chain_clear` | daemon | required | required | required | required | +| `descriptor_get` | daemon | required | required | required | required | The profiles: `lv2-worker`, an LV2 plugin's worker: mod-host, or omx-clap-host's LV2 adapter; `clap-worker`, a CLAP plugin's worker, omx-clap-host; `console`, the openmixer console as a controller of the daemon; `zynthian`, Zynthian's mod-ui and its plugind engine as a controller of the daemon. `required` is answered as its grammar says; `optional` is answered, or refused with -902, and never misread; `absent` is refused on purpose, and no client of the profile may need it. @@ -337,6 +342,10 @@ Error codes the daemon adds to mod-host's: | `-525` | `PHD_ERR_PRESET_NEEDS_STATE` | the worker: a preset file that carries state for a worker that cannot take state yet | | `-526` | `PHD_ERR_PRESET_PLUGIN_CHANGED` | the worker: the preset file's plugin id, version or fingerprint is not the instance's; nothing of it is applied | | `-527` | `PHD_ERR_LOAD_REFUSED` | the worker: the plugin refused the preset or the state its file carries | +| `-528` | `PHD_ERR_DIR_NOT_WRITABLE` | descriptor_dir or qualification_dir cannot be made or written: the daemon does not start, and says which key and which path on stderr with this code; a directory that turns read-only later makes the scan of an add write nothing, and descriptor_get of that plugin answers this code | +| `-529` | `PHD_ERR_DESCRIPTOR_ABSENT` | descriptor_get of a plugin that has no descriptor in descriptor_dir for its binary as it is now, and none being written: no add since it changed, or a binary that cannot be found | +| `-530` | `PHD_ERR_DESCRIPTOR_PENDING` | descriptor_get of a plugin whose scan, started by its add, has not ended: ask again | +| `-531` | `PHD_ERR_DESCRIPTOR_SCAN_FAILED` | descriptor_get of a plugin whose last scan wrote no descriptor: the scanner refused the plugin, made no dump, or is not installed; the add it ran for stands, and stderr and the scan's log say why. The next add scans 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/doc/plugin-hostd.1 b/doc/plugin-hostd.1 index 91ab5fd..59cac04 100644 --- a/doc/plugin-hostd.1 +++ b/doc/plugin-hostd.1 @@ -72,8 +72,40 @@ 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, 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. +per line: mod_host, clap_host, lv2_worker, lv2_path, state_root, pin_mode, pin_store, pin_file, pins_dir, descriptor_dir, qualification_dir, probe, 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 DIRECTORIES +What the scan of an +.B add +gathers is state, kept across a reboot, in two directories of their own: +.\" BEGIN GENERATED protocol:man-dirs +.TP +.B descriptor_dir +the scanned descriptors, one file per plugin binary (@\-.json, the hash the binary's SHA\-256), written by the scan of an add and read by descriptor_get; empty is $XDG_STATE_HOME/omx/descriptors, else /var/lib/omx/descriptors run as root (a system service), else $HOME/.local/state/omx/descriptors; its variable is for tests only. +The variable +.B $OMX_DESCRIPTOR_DIR +stands in for the empty default; the key in the settings file holds over it. +.TP +.B qualification_dir +plugin\-qualify's qualification records (verdict, cost, version tested), kept apart from the descriptors; empty is $XDG_STATE_HOME/omx/qualification, else /var/lib/omx/qualification run as root (a system service), else $HOME/.local/state/omx/qualification; its variable is for tests only. +The variable +.B $OMX_QUALIFICATION_DIR +stands in for the empty default; the key in the settings file holds over it. +.PP +A missing directory is made, mode 0700, with its parents. One that cannot be made or written stops the daemon before it is ready: stderr names the key and the path with resp \-528. +.PP +The answered +.B add +of a plugin whose files have no descriptor yet runs +.I probe +.B describe +in a child of its own and files its stdout as +.IR "@\-.json" , +written once; the add never waits for it. +.B descriptor_get +.I uri +answers the file's path, or \-529 (none), \-530 (the scan runs), \-531 (the scan wrote nothing). +.\" END GENERATED protocol:man-dirs .SH PINS With require_pins on, .B add diff --git a/include/plugin-hostd/protocol.h b/include/plugin-hostd/protocol.h index d12f650..7d037ab 100644 --- a/include/plugin-hostd/protocol.h +++ b/include/plugin-hostd/protocol.h @@ -280,6 +280,10 @@ static inline int phd_pool_name_valid(const char *name) #define PHD_ERR_PRESET_NEEDS_STATE (-525) #define PHD_ERR_PRESET_PLUGIN_CHANGED (-526) #define PHD_ERR_LOAD_REFUSED (-527) +#define PHD_ERR_DIR_NOT_WRITABLE (-528) +#define PHD_ERR_DESCRIPTOR_ABSENT (-529) +#define PHD_ERR_DESCRIPTOR_PENDING (-530) +#define PHD_ERR_DESCRIPTOR_SCAN_FAILED (-531) /* X(id, meaning) */ #define PHD_ERRORS(X) \ @@ -339,7 +343,17 @@ static inline int phd_pool_name_valid(const char *name) X(PRESET_NEEDS_STATE, "the worker: a preset file that carries state for a worker that cannot take state yet") \ X(PRESET_PLUGIN_CHANGED, "the worker: the preset file's plugin id, version or fingerprint is not the instance's; " \ "nothing of it is applied") \ - X(LOAD_REFUSED, "the worker: the plugin refused the preset or the state its file carries") + X(LOAD_REFUSED, "the worker: the plugin refused the preset or the state its file carries") \ + X(DIR_NOT_WRITABLE, "descriptor_dir or qualification_dir cannot be made or written: the daemon does not start, and " \ + "says which key and which path on stderr with this code; a directory that turns read-only " \ + "later makes the scan of an add write nothing, and " PHD_VERB_DESCRIPTOR_GET " of that plugin " \ + "answers this code") \ + X(DESCRIPTOR_ABSENT, PHD_VERB_DESCRIPTOR_GET " of a plugin that has no descriptor in descriptor_dir for its binary " \ + "as it is now, and none being written: no add since it changed, or a binary that cannot be found") \ + X(DESCRIPTOR_PENDING, PHD_VERB_DESCRIPTOR_GET " of a plugin whose scan, started by its add, has not ended: ask again") \ + X(DESCRIPTOR_SCAN_FAILED, PHD_VERB_DESCRIPTOR_GET " of a plugin whose last scan wrote no descriptor: the scanner " \ + "refused the plugin, made no dump, or is not installed; the add it ran for stands, and " \ + "stderr and the scan's log say why. The next add scans again") /* ---------------------------------------------------------------- verbs */ @@ -359,6 +373,7 @@ static inline int phd_pool_name_valid(const char *name) #define PHD_VERB_LV2_WORKER_SET "lv2_worker_set" #define PHD_VERB_CHAIN "chain" #define PHD_VERB_CHAIN_CLEAR "chain_clear" +#define PHD_VERB_DESCRIPTOR_GET "descriptor_get" /* the words of a pin: =, joined by ',', and : */ #define PHD_PIN_FILE_SEPARATOR "," @@ -466,7 +481,11 @@ static inline int phd_pool_name_valid(const char *name) "a world narrowed to the union of its members' bundles), -902 for a chain " \ "or an instance that is not a number, or fewer than chain_members_min (" PHD_STR(PHD_CHAIN_MEMBERS_MIN) ") instances") \ X(CHAIN_CLEAR, PHD_VERB_CHAIN_CLEAR, "", "resp 0", \ - "forget the chain: each member goes to a worker of its own; -522 for a chain no " PHD_VERB_CHAIN " declared") + "forget the chain: each member goes to a worker of its own; -522 for a chain no " PHD_VERB_CHAIN " declared") \ + X(DESCRIPTOR_GET, PHD_VERB_DESCRIPTOR_GET, "", "resp 0 \"\"", \ + "the scanned descriptor of the plugin, answered from descriptor_dir: the file named for its binary as it is now " \ + "(" PHD_DESCRIPTOR_NAME_SYNTAX "), written by the scan the plugin's add ran. -529 when there is none, -530 while " \ + "its scan runs, -531 when its last scan wrote nothing, -528 when the directory could not be written") /* ---------------------------------------------------------------- instance verbs */ @@ -794,7 +813,8 @@ static inline int phd_is_note_verb(const char *verb) X(PHD_VERB_PIN_ACCEPT, DAEMON, REQUIRED, REQUIRED, REQUIRED, REQUIRED) \ X(PHD_VERB_LV2_WORKER_SET, DAEMON, REQUIRED, REQUIRED, REQUIRED, REQUIRED) \ X(PHD_VERB_CHAIN, DAEMON, REQUIRED, REQUIRED, REQUIRED, REQUIRED) \ - X(PHD_VERB_CHAIN_CLEAR, DAEMON, REQUIRED, REQUIRED, REQUIRED, REQUIRED) + X(PHD_VERB_CHAIN_CLEAR, DAEMON, REQUIRED, REQUIRED, REQUIRED, REQUIRED) \ + X(PHD_VERB_DESCRIPTOR_GET, DAEMON, REQUIRED, REQUIRED, REQUIRED, REQUIRED) /* ---------------------------------------------------------------- host settings */ @@ -938,6 +958,31 @@ static inline int phd_is_note_verb(const char *verb) #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" +#define PHD_DEFAULT_PROBE "plugin-hostd-probe" + +/* The directories of what the scan of an add gathers (openmixer spec 2026-10-17-contract-definitions-installed-by-owners, + * 6.9): state, kept across a reboot. An empty setting is the default: /, where the state home is + * $XDG_STATE_HOME, else /var/lib for a daemon run as root (a system service), else $HOME/.local/state. A missing + * directory is made, mode 0700; one that cannot be written stops the daemon with PHD_ERR_DIR_NOT_WRITABLE. */ +#define PHD_DIR_DESCRIPTORS_SUBDIR "omx/descriptors" +#define PHD_DIR_QUALIFICATION_SUBDIR "omx/qualification" +#define PHD_DIR_SYSTEM_STATE_HOME "/var/lib" +#define PHD_DIR_USER_STATE_HOME ".local/state" +#define PHD_DIR_MODE 0700 +#define PHD_DIR_DEFAULT_TEXT(subdir) \ + "empty is $XDG_STATE_HOME/" subdir ", else " PHD_DIR_SYSTEM_STATE_HOME "/" subdir " run as root (a system " \ + "service), else $HOME/" PHD_DIR_USER_STATE_HOME "/" subdir + +/* A scanned descriptor's file in descriptor_dir: @-.json, the rule plugin-qualify's scanners name + * theirs by. The id is the CLAP plugin id or the LV2 URI, the version the one the descriptor states + * (PHD_DESCRIPTOR_NO_VERSION when it states none), each character outside [A-Za-z0-9._-] of both written + * PHD_DESCRIPTOR_SAFE_CHAR; the hash is the first PHD_DESCRIPTOR_HASH_LEN hex digits of the SHA-256 of the plugin's + * binary (the .clap, or the file the LV2 bundle's manifest names with lv2:binary). Written once, atomically; a changed + * binary gets a new name and the old file stays. */ +#define PHD_DESCRIPTOR_NAME_SYNTAX "@-.json" +#define PHD_DESCRIPTOR_NO_VERSION "unversioned" +#define PHD_DESCRIPTOR_SAFE_CHAR '_' +#define PHD_DESCRIPTOR_HASH_LEN 12 /* 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 @@ -958,7 +1003,15 @@ static inline int phd_is_note_verb(const char *verb) 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") + "empty is none") \ + X(descriptor_dir, path, "", "OMX_DESCRIPTOR_DIR", "the scanned descriptors, one file per plugin binary (" \ + PHD_DESCRIPTOR_NAME_SYNTAX ", the hash the binary's SHA-256), written by the scan of an add and read by " PHD_VERB_DESCRIPTOR_GET "; " \ + PHD_DIR_DEFAULT_TEXT(PHD_DIR_DESCRIPTORS_SUBDIR) "; its variable is for tests only") \ + X(qualification_dir, path, "", "OMX_QUALIFICATION_DIR", "plugin-qualify's qualification records (verdict, cost, " \ + "version tested), kept apart from the descriptors; " PHD_DIR_DEFAULT_TEXT(PHD_DIR_QUALIFICATION_SUBDIR) \ + "; its variable is for tests only") \ + X(probe, path, PHD_DEFAULT_PROBE, "", "the scanner an add runs, as describe , in a child of its " \ + "own once per plugin binary; its stdout is the descriptor") /* 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 b9e4cea..3b7c43a 100644 --- a/protocol/plugin-hostd.json +++ b/protocol/plugin-hostd.json @@ -52,7 +52,11 @@ {"name": "PRESET_INVALID", "macro": "PHD_ERR_PRESET_INVALID", "code": -524, "meaning": "preset_save with a file path that is not absolute or with no name, or preset_load or preset_show of a file that cannot be read, is no JSON, is of a format version other than org.openmixer.preset/1 and org.openmixer.preset/2, or breaks the format (docs/presets.md): the daemon answers it and no worker sees the file"}, {"name": "PRESET_NEEDS_STATE", "macro": "PHD_ERR_PRESET_NEEDS_STATE", "code": -525, "meaning": "the worker: a preset file that carries state for a worker that cannot take state yet"}, {"name": "PRESET_PLUGIN_CHANGED", "macro": "PHD_ERR_PRESET_PLUGIN_CHANGED", "code": -526, "meaning": "the worker: the preset file's plugin id, version or fingerprint is not the instance's; nothing of it is applied"}, - {"name": "LOAD_REFUSED", "macro": "PHD_ERR_LOAD_REFUSED", "code": -527, "meaning": "the worker: the plugin refused the preset or the state its file carries"} + {"name": "LOAD_REFUSED", "macro": "PHD_ERR_LOAD_REFUSED", "code": -527, "meaning": "the worker: the plugin refused the preset or the state its file carries"}, + {"name": "DIR_NOT_WRITABLE", "macro": "PHD_ERR_DIR_NOT_WRITABLE", "code": -528, "meaning": "descriptor_dir or qualification_dir cannot be made or written: the daemon does not start, and says which key and which path on stderr with this code; a directory that turns read-only later makes the scan of an add write nothing, and descriptor_get of that plugin answers this code"}, + {"name": "DESCRIPTOR_ABSENT", "macro": "PHD_ERR_DESCRIPTOR_ABSENT", "code": -529, "meaning": "descriptor_get of a plugin that has no descriptor in descriptor_dir for its binary as it is now, and none being written: no add since it changed, or a binary that cannot be found"}, + {"name": "DESCRIPTOR_PENDING", "macro": "PHD_ERR_DESCRIPTOR_PENDING", "code": -530, "meaning": "descriptor_get of a plugin whose scan, started by its add, has not ended: ask again"}, + {"name": "DESCRIPTOR_SCAN_FAILED", "macro": "PHD_ERR_DESCRIPTOR_SCAN_FAILED", "code": -531, "meaning": "descriptor_get of a plugin whose last scan wrote no descriptor: the scanner refused the plugin, made no dump, or is not installed; the add it ran for stands, and stderr and the scan's log say why. The next add scans 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"}, @@ -70,7 +74,8 @@ {"name": "pin_layout", "arguments": "", "reply": "resp 0 :", "meaning": "sent by the daemon to a worker of a format that checks layouts, after it accepted the add of a plugin whose layout pin is still unknown: the layout the worker found after init, which the daemon records in the pin"}, {"name": "pin_expect", "arguments": " :", "reply": "resp 0", "meaning": "sent by the daemon to a worker of a format that checks layouts, just before the instance's add: the layout pin that add checks after init and before activate; any other reply than resp 0 refuses the add with that code, and the add is never forwarded"}, {"name": "chain", "arguments": " [ ...]", "reply": "resp 0", "meaning": "the instances, in this order, are one chain, run in one worker of place chain:: each member's state is saved, it is removed from the worker it was in and replayed in the chain's. A chain already declared takes the new order, and a member left out of it goes back to a worker of its own. A worker that runs chains in one client (clap, lv2-clap) is sent this line, with the members it holds, after the adds of its replay; one that does not (mod-host) never is, and a refusal of it is written to stderr and is no failure. Every instance verb still names one member by its own number. A member that crashes the worker is quarantined to a worker of its own and the chain goes on without it; quarantine_clear puts it back. -518 for an instance no add made, -519 for one named twice, -520 for one in another chain, -521 for members of more than one format, -501 for a plugin that may not share a worker while require_pins is on (a CLAP plugin whose first-use pin is not in-process; an LV2 member whose bundle is not found, since a chain's worker sees a world narrowed to the union of its members' bundles), -902 for a chain or an instance that is not a number, or fewer than chain_members_min (1) instances"}, - {"name": "chain_clear", "arguments": "", "reply": "resp 0", "meaning": "forget the chain: each member goes to a worker of its own; -522 for a chain no chain declared"} + {"name": "chain_clear", "arguments": "", "reply": "resp 0", "meaning": "forget the chain: each member goes to a worker of its own; -522 for a chain no chain declared"}, + {"name": "descriptor_get", "arguments": "", "reply": "resp 0 \"\"", "meaning": "the scanned descriptor of the plugin, answered from descriptor_dir: the file named for its binary as it is now (@-.json), written by the scan the plugin's add ran. -529 when there is none, -530 while its scan runs, -531 when its last scan wrote nothing, -528 when the directory could not be written"} ], "instance_verbs": [ {"name": "track_info", "arguments": " <#RRGGBB | -> [bus | return | master]", "reply": "resp 0", "ledger": "replace", "meaning": "the strip's name (\"\" for none), colour and kind, absent for an input channel; a worker whose plugin format has a track-info extension passes them on; any other word is -902"}, @@ -159,7 +164,8 @@ {"name": "pin_accept", "source": "daemon", "levels": {"lv2-worker": "required", "clap-worker": "required", "console": "required", "zynthian": "required"}}, {"name": "lv2_worker_set", "source": "daemon", "levels": {"lv2-worker": "required", "clap-worker": "required", "console": "required", "zynthian": "required"}}, {"name": "chain", "source": "daemon", "levels": {"lv2-worker": "required", "clap-worker": "required", "console": "required", "zynthian": "required"}}, - {"name": "chain_clear", "source": "daemon", "levels": {"lv2-worker": "required", "clap-worker": "required", "console": "required", "zynthian": "required"}} + {"name": "chain_clear", "source": "daemon", "levels": {"lv2-worker": "required", "clap-worker": "required", "console": "required", "zynthian": "required"}}, + {"name": "descriptor_get", "source": "daemon", "levels": {"lv2-worker": "required", "clap-worker": "required", "console": "required", "zynthian": "required"}} ], "track_kinds": ["bus", "return", "master"], "param_scales": [ @@ -218,6 +224,9 @@ {"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": "descriptor_dir", "type": "string", "default": "", "env": "OMX_DESCRIPTOR_DIR", "meaning": "the scanned descriptors, one file per plugin binary (@-.json, the hash the binary's SHA-256), written by the scan of an add and read by descriptor_get; empty is $XDG_STATE_HOME/omx/descriptors, else /var/lib/omx/descriptors run as root (a system service), else $HOME/.local/state/omx/descriptors; its variable is for tests only"}, + {"key": "qualification_dir", "type": "string", "default": "", "env": "OMX_QUALIFICATION_DIR", "meaning": "plugin-qualify's qualification records (verdict, cost, version tested), kept apart from the descriptors; empty is $XDG_STATE_HOME/omx/qualification, else /var/lib/omx/qualification run as root (a system service), else $HOME/.local/state/omx/qualification; its variable is for tests only"}, + {"key": "probe", "type": "string", "default": "plugin-hostd-probe", "env": "", "meaning": "the scanner an add runs, as describe , in a child of its own once per plugin binary; its stdout is the descriptor"}, {"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/tools/protocol-gen.c b/tools/protocol-gen.c index 7f1dd9a..423b12a 100644 --- a/tools/protocol-gen.c +++ b/tools/protocol-gen.c @@ -525,6 +525,37 @@ static void region_man_keys(void) printf(".\n"); } +/* the directories of what an add gathers: each key with its default, and what the daemon does with them */ +static void region_man_dirs(void) +{ + char buffer[256]; + +#define X(key, size, def, env, meaning) \ + if (!strcmp(#key, "descriptor_dir") || !strcmp(#key, "qualification_dir")) \ + { \ + printf(".TP\n.B " #key "\n"); \ + roff_escape(meaning); \ + printf(".\n"); \ + if (*(env)) \ + printf("The variable\n.B $" env "\nstands in for the empty default; the key in the settings file holds over it.\n"); \ + } + PHD_CONF_STRINGS(X) +#undef X + printf(".PP\nA missing directory is made, mode %04o, with its parents. One that cannot be made or written stops the " + "daemon before it is ready: stderr names the key and the path with resp ", PHD_DIR_MODE); + snprintf(buffer, sizeof(buffer), "%d", PHD_ERR_DIR_NOT_WRITABLE); + roff_escape(buffer); + printf(".\n.PP\nThe answered\n.B add\nof a plugin whose files have no descriptor yet runs\n.I probe\n.B describe\n" + "in a child of its own and files its stdout as\n.IR \""); + roff_escape(PHD_DESCRIPTOR_NAME_SYNTAX); + printf("\" ,\nwritten once; the add never waits for it.\n.B " PHD_VERB_DESCRIPTOR_GET "\n.I uri\n" + "answers the file's path, or "); + snprintf(buffer, sizeof(buffer), "%d (none), %d (the scan runs), %d (the scan wrote nothing)", + PHD_ERR_DESCRIPTOR_ABSENT, PHD_ERR_DESCRIPTOR_PENDING, PHD_ERR_DESCRIPTOR_SCAN_FAILED); + roff_escape(buffer); + printf(".\n"); +} + static void region_man_ready(void) { printf(".B "); @@ -552,7 +583,7 @@ static const struct { { "verbs", region_verbs }, { "instance-verbs", region_instance_verbs }, { "host-verbs", region_host_verbs }, { "errors", region_errors }, { "events", region_events }, { "pin-limits", region_pin_limits }, { "man-pin-limits", region_man_pin_limits }, { "readiness", region_readiness }, { "worker-ready", region_worker_ready }, - { "man-keys", region_man_keys }, { "man-ready", region_man_ready }, { "man-port", region_man_port }, + { "man-keys", region_man_keys }, { "man-dirs", region_man_dirs }, { "man-ready", region_man_ready }, { "man-port", region_man_port }, { "relay", region_relay }, { "man-relay", region_man_relay }, { "host-settings", region_host_settings }, { "man-host-settings", region_man_host_settings }, }; From 4335813f3e9c5158ae5107e796c6feb3041f9f51 Mon Sep 17 00:00:00 2001 From: Pau Aliagas Date: Sat, 24 Oct 2026 12:45:44 +0200 Subject: [PATCH 05/10] daemon: scan an added plugin into descriptor_dir At start both directories are resolved, made 0700 when missing and tried for a write; one that cannot be written stops the daemon with -528 and names the key and the path. After an add is answered, a plugin whose binary has no descriptor yet is scanned by ' describe ' in a child of its own; its stdout goes to a temporary file, which is named by the plugin id, the version it states and the binary's hash and renamed into place once. The add never waits for the scan and is never refused by it. descriptor_get answers from the file in the directory. The tests' daemons keep both directories in their own temporary tree. --- Makefile | 7 +- src/descriptors.c | 446 ++++++++++++++++++++++++++++++++++++++++++++ src/descriptors.h | 34 ++++ src/main.c | 6 + src/supervisor.c | 5 + src/supervisor.h | 2 + src/verbs.c | 9 +- tests/harness.py | 5 + tests/socket_e2e.sh | 6 +- 9 files changed, 515 insertions(+), 5 deletions(-) create mode 100644 src/descriptors.c create mode 100644 src/descriptors.h diff --git a/Makefile b/Makefile index bcd0173..1ce81c1 100644 --- a/Makefile +++ b/Makefile @@ -56,14 +56,15 @@ INCS = -Iinclude $(PROTOCOL_CFLAGS) LDFLAGS += -Wl,--no-undefined # source and object files -SRC = src/main.c src/conf.c src/proc.c src/supervisor.c src/verbs.c src/pins.c src/relay.c src/formats.c src/lv2.c +SRC = src/main.c src/conf.c src/proc.c src/supervisor.c src/verbs.c src/pins.c src/relay.c src/formats.c src/lv2.c \ + src/descriptors.c src/describe.c src/transforms.c src/json.c OBJ = $(SRC:.c=.o) # default build all: $(PROG) $(PROBE) $(PROG): $(OBJ) $(PROTOCOL_LIB) - $(CC) $(OBJ) $(PROTOCOL_LIBS) $(LDFLAGS) -pthread -o $@ + $(CC) $(OBJ) $(PROTOCOL_LIBS) $(LDFLAGS) -pthread -lm -o $@ # the probe runner: it loads no plugin and links nothing of the daemon or of mod-host # describe reads the installed table of a format by default (docs/formats.md) @@ -80,6 +81,8 @@ endif # the declared protocol is compiled into every object $(OBJ): include/plugin-hostd/protocol.h include/plugin-hostd/pin.h +src/descriptors.o: src/descriptors.h src/describe.h src/json.h src/formats.h src/supervisor.h + # meta-rule to generate the object files %.o: %.c $(CC) $(INCS) $(CFLAGS) -c -o $@ $< diff --git a/src/descriptors.c b/src/descriptors.c new file mode 100644 index 0000000..ff1faf4 --- /dev/null +++ b/src/descriptors.c @@ -0,0 +1,446 @@ +/* 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 + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include "describe.h" +#include "descriptors.h" +#include "formats.h" +#include "json.h" +#include "proc.h" +#include "supervisor.h" + +/* one scan, by the files it describes: running while pid > 0, then SUCCESS or the code it ended with */ +typedef struct SCAN_T { + char format[16]; + char *id; + char hash[PHD_DESCRIPTOR_HASH_LEN + 1]; + char *uri; + char tmp[PATH_MAX]; + char log[PATH_MAX]; + pid_t pid; + int code; +} scan_t; + +static conf_t *g_conf; +static scan_t *g_scans; +static int g_nscans; +static unsigned g_seq; + +/* ---------------------------------------------------------------- the directories */ + +static void state_default(char *dir, size_t size, const char *subdir) +{ + const char *state = getenv("XDG_STATE_HOME"), *home = getenv("HOME"); + + if (state && *state) + snprintf(dir, size, "%s/%s", state, subdir); + else if (geteuid() == 0 || !home || !*home) + snprintf(dir, size, PHD_DIR_SYSTEM_STATE_HOME "/%s", subdir); + else + snprintf(dir, size, "%s/" PHD_DIR_USER_STATE_HOME "/%s", home, subdir); +} + +/* the directory and its parents, each made PHD_DIR_MODE when missing; 0, or -1 with errno */ +static int make_dirs(const char *path) +{ + char buf[PATH_MAX], *slash; + struct stat st; + + if (snprintf(buf, sizeof(buf), "%s", path) >= (int)sizeof(buf)) + { + errno = ENAMETOOLONG; + return -1; + } + for (slash = buf + 1; (slash = strchr(slash, '/')); slash++) + { + *slash = '\0'; + if (mkdir(buf, PHD_DIR_MODE) != 0 && errno != EEXIST) + return -1; + *slash = '/'; + } + if (mkdir(buf, PHD_DIR_MODE) != 0 && errno != EEXIST) + return -1; + if (stat(buf, &st) != 0) + return -1; + if (!S_ISDIR(st.st_mode)) + { + errno = ENOTDIR; + return -1; + } + return 0; +} + +/* whether a file can be made in the directory: a write is tried, which a read-only mount refuses even to root */ +static int writable(const char *dir) +{ + char probe[PATH_MAX]; + int fd; + + snprintf(probe, sizeof(probe), "%s/.plugin-hostd-write.%d", dir, (int)getpid()); + fd = open(probe, O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC, 0600); + if (fd < 0) + return -1; + close(fd); + unlink(probe); + return 0; +} + +static int dir_ready(const char *key, char *dir, size_t size, const char *subdir) +{ + if (!*dir) + state_default(dir, size, subdir); + if (make_dirs(dir) != 0 || writable(dir) != 0) + { + fprintf(stderr, "plugin-hostd: %s %s: %s: resp %d\n", key, dir, strerror(errno), PHD_ERR_DIR_NOT_WRITABLE); + return PHD_ERR_DIR_NOT_WRITABLE; + } + return SUCCESS; +} + +int descriptors_init(conf_t *conf) +{ + g_conf = conf; + if (dir_ready("descriptor_dir", conf->descriptor_dir, sizeof(conf->descriptor_dir), PHD_DIR_DESCRIPTORS_SUBDIR)) + return PHD_ERR_DIR_NOT_WRITABLE; + if (dir_ready("qualification_dir", conf->qualification_dir, sizeof(conf->qualification_dir), + PHD_DIR_QUALIFICATION_SUBDIR)) + return PHD_ERR_DIR_NOT_WRITABLE; + return SUCCESS; +} + +/* ---------------------------------------------------------------- what a plugin's files are */ + +/* every character outside [A-Za-z0-9._-] as PHD_DESCRIPTOR_SAFE_CHAR, as plugin-qualify's scanners write it */ +static void encode(const char *in, char *out, size_t size) +{ + size_t o = 0; + + for (; *in && o + 1 < size; in++) + { + unsigned char c = (unsigned char)*in; + + out[o++] = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '.' || + c == '_' || c == '-' ? (char)c : PHD_DESCRIPTOR_SAFE_CHAR; + } + out[o] = '\0'; +} + +/* The scan's target (describe's clap:# or lv2:), its format, id and the hash of its binary as it is + * now. 0, or -1 for a plugin whose binary cannot be found or read, or a format with no binary or that describe does + * not know. */ +static int identify(const char *uri, char *target, size_t tsize, const char **format, const char **id, + char hash[PHD_DESCRIPTOR_HASH_LEN + 1]) +{ + const char *fwd; + int fmt = format_of_uri(uri, &fwd), fd, rc = -1; + const format_t *f = fmt >= 0 ? format_at(fmt) : NULL; + char binary[PATH_MAX], hex[PHD_SHA256_HEX_LEN + 1]; + struct stat st; + + if (!f || !f->binary) + return -1; + if (f->prefix && strncmp(fwd, f->prefix, strlen(f->prefix))) + snprintf(target, tsize, "%s%s", f->prefix, fwd); + else + snprintf(target, tsize, "%s", fwd); + if ((*format = describe_format(target)) == NULL || (*id = describe_id(target)) == NULL) + return -1; + if (f->binary(fwd, f->search ? f->search(g_conf) : NULL, binary, sizeof(binary)) != 0) + return -1; + if ((fd = open(binary, O_RDONLY | O_CLOEXEC)) < 0) + return -1; + if (fstat(fd, &st) == 0 && S_ISREG(st.st_mode) && phd_pin_fd_digest(fd, hex) == 0) + { + snprintf(hash, PHD_DESCRIPTOR_HASH_LEN + 1, "%s", hex); + rc = 0; + } + close(fd); + return rc; +} + +/* the descriptor of the binary, by its name's id and hash, into path; 0, or -1 when there is none */ +static int lookup(const char *id, const char *hash, char *path, size_t size) +{ + const char *dir = g_conf->descriptor_dir; + char prefix[PATH_MAX], suffix[PHD_DESCRIPTOR_HASH_LEN + 8]; + size_t plen, slen; + struct dirent *e; + DIR *d; + int found = -1; + + encode(id, prefix, sizeof(prefix) - 1); + strcat(prefix, "@"); + snprintf(suffix, sizeof(suffix), "-%s.json", hash); + plen = strlen(prefix); + slen = strlen(suffix); + if ((d = opendir(dir)) == NULL) + return -1; + while (found && (e = readdir(d)) != NULL) + { + size_t len = strlen(e->d_name); + + if (len > plen + slen && !strncmp(e->d_name, prefix, plen) && !strcmp(e->d_name + len - slen, suffix)) + { + snprintf(path, size, "%s/%s", dir, e->d_name); + found = 0; + } + } + closedir(d); + return found; +} + +static scan_t *scan_of(const char *format, const char *id, const char *hash) +{ + int n; + + for (n = 0; n < g_nscans; n++) + if (!strcmp(g_scans[n].format, format) && !strcmp(g_scans[n].id, id) && !strcmp(g_scans[n].hash, hash)) + return &g_scans[n]; + return NULL; +} + +/* ---------------------------------------------------------------- the scan */ + +static void scan_failed(scan_t *s, int code, const char *why) +{ + fprintf(stderr, "plugin-hostd: %s: the scan wrote no descriptor (%s), its log %s: resp %d\n", s->uri, why, + s->log, code); + if (*s->tmp) + unlink(s->tmp); + s->tmp[0] = '\0'; + s->pid = 0; + s->code = code; +} + +static void scan_start(scan_t *s, const char *target) +{ + const char *dir = g_conf->descriptor_dir; + int out, log, null; + pid_t pid; + + s->code = PHD_ERR_DESCRIPTOR_PENDING; + s->tmp[0] = '\0'; + snprintf(s->log, sizeof(s->log), "%s/scan-%s-%s.log", sup_root(), s->format, s->hash); + if (!proc_have_binary(g_conf->probe)) + { + scan_failed(s, PHD_ERR_DESCRIPTOR_SCAN_FAILED, "the probe is not installed"); + return; + } + snprintf(s->tmp, sizeof(s->tmp), "%s/.%s.%d.%u.tmp", dir, s->hash, (int)getpid(), g_seq++); + if (make_dirs(dir) != 0 || (out = open(s->tmp, O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC, 0600)) < 0) + { + char why[PATH_MAX + 64]; + + snprintf(why, sizeof(why), "descriptor_dir %s: %s", dir, strerror(errno)); + s->tmp[0] = '\0'; + scan_failed(s, PHD_ERR_DIR_NOT_WRITABLE, why); + return; + } + log = open(s->log, O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC, 0644); + null = open("/dev/null", O_RDONLY | O_CLOEXEC); + pid = fork(); + if (pid == 0) + { + prctl(PR_SET_PDEATHSIG, SIGTERM); + if (getppid() == 1) + _exit(127); + dup2(null >= 0 ? null : out, STDIN_FILENO); + dup2(out, STDOUT_FILENO); + dup2(log >= 0 ? log : out, STDERR_FILENO); + signal(SIGPIPE, SIG_DFL); + execlp(g_conf->probe, g_conf->probe, "describe", target, (char *)NULL); + _exit(127); + } + close(out); + if (log >= 0) + close(log); + if (null >= 0) + close(null); + if (pid < 0) + { + scan_failed(s, PHD_ERR_DESCRIPTOR_SCAN_FAILED, strerror(errno)); + return; + } + s->pid = pid; +} + +void descriptors_add(const char *uri) +{ + char target[PATH_MAX + 16], hash[PHD_DESCRIPTOR_HASH_LEN + 1], path[PATH_MAX]; + const char *format, *id; + scan_t *s; + + if (!g_conf || identify(uri, target, sizeof(target), &format, &id, hash) != 0) + return; + if (lookup(id, hash, path, sizeof(path)) == 0) + return; + s = scan_of(format, id, hash); + if (s && s->pid > 0) + return; + if (!s) + { + g_scans = realloc(g_scans, (size_t)(g_nscans + 1) * sizeof(scan_t)); + s = &g_scans[g_nscans++]; + memset(s, 0, sizeof(*s)); + snprintf(s->format, sizeof(s->format), "%s", format); + snprintf(s->hash, sizeof(s->hash), "%s", hash); + s->id = strdup(id); + } + free(s->uri); + s->uri = strdup(uri); + scan_start(s, target); +} + +/* the scan's stdout is a descriptor: named for its version and moved into place, written once */ +static void scan_ended(scan_t *s, int status) +{ + char final[PATH_MAX], enc_id[PATH_MAX / 2], enc_version[256], why[64]; + const char *dir = g_conf->descriptor_dir; + sb_t text = { 0 }; + jnode_t *doc; + const char *version; + int fd; + + s->pid = 0; + if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) + { + if (WIFSIGNALED(status)) + snprintf(why, sizeof(why), "killed by signal %d", WTERMSIG(status)); + else + snprintf(why, sizeof(why), "the probe exited %d", WEXITSTATUS(status)); + scan_failed(s, PHD_ERR_DESCRIPTOR_SCAN_FAILED, why); + return; + } + if ((fd = open(s->tmp, O_RDONLY | O_CLOEXEC)) >= 0) + { + char buf[65536]; + ssize_t n; + + while ((n = read(fd, buf, sizeof(buf))) > 0) + sb_put(&text, buf, (size_t)n); + if (fsync(fd) != 0) + text.len = 0; + close(fd); + } + doc = text.len ? jparse(text.s) : NULL; + free(text.s); + if (!doc || doc->type != J_OBJ || !jget(doc, "plugin")) + { + jfree(doc); + scan_failed(s, PHD_ERR_DESCRIPTOR_SCAN_FAILED, "its output is no descriptor"); + return; + } + version = jstr(jget(doc, "plugin"), "version"); + encode(s->id, enc_id, sizeof(enc_id)); + encode(version && *version ? version : PHD_DESCRIPTOR_NO_VERSION, enc_version, sizeof(enc_version)); + jfree(doc); + snprintf(final, sizeof(final), "%s/%s@%s-%s.json", dir, enc_id, enc_version, s->hash); + if (access(final, F_OK) == 0) + unlink(s->tmp); + else if (rename(s->tmp, final) != 0) + { + snprintf(why, sizeof(why), "%s", strerror(errno)); + scan_failed(s, PHD_ERR_DIR_NOT_WRITABLE, why); + return; + } + else if ((fd = open(dir, O_RDONLY | O_DIRECTORY | O_CLOEXEC)) >= 0) + { + fsync(fd); + close(fd); + } + s->tmp[0] = '\0'; + s->code = SUCCESS; + unlink(s->log); +} + +void descriptors_tick(void) +{ + int n, status; + + for (n = 0; n < g_nscans; n++) + if (g_scans[n].pid > 0 && waitpid(g_scans[n].pid, &status, WNOHANG) == g_scans[n].pid) + scan_ended(&g_scans[n], status); +} + +/* ---------------------------------------------------------------- descriptor_get */ + +char *descriptors_get(const char *uri) +{ + char target[PATH_MAX + 16], hash[PHD_DESCRIPTOR_HASH_LEN + 1], path[PATH_MAX], *r, *o; + const char *format, *id, *p; + scan_t *s; + + if (!g_conf || identify(uri, target, sizeof(target), &format, &id, hash) != 0) + return sup_resp(PHD_ERR_DESCRIPTOR_ABSENT); + if (lookup(id, hash, path, sizeof(path)) != 0) + { + s = scan_of(format, id, hash); + return sup_resp(s ? s->code : PHD_ERR_DESCRIPTOR_ABSENT); + } + /* the path is one quoted word, as mod-host's tokenizer reads it */ + r = malloc(strlen(path) * 2 + 16); + if (!r) + return NULL; + o = r + sprintf(r, "resp 0 \""); + for (p = path; *p; p++) + { + if (*p == '"' || *p == '\\') + *o++ = '\\'; + *o++ = *p; + } + strcpy(o, "\""); + return r; +} + +void descriptors_finish(void) +{ + int n; + + for (n = 0; n < g_nscans; n++) + { + if (g_scans[n].pid > 0) + { + kill(g_scans[n].pid, SIGTERM); + waitpid(g_scans[n].pid, NULL, 0); + } + if (*g_scans[n].tmp) + unlink(g_scans[n].tmp); + free(g_scans[n].id); + free(g_scans[n].uri); + } + free(g_scans); + g_scans = NULL; + g_nscans = 0; +} diff --git a/src/descriptors.h b/src/descriptors.h new file mode 100644 index 0000000..c47b644 --- /dev/null +++ b/src/descriptors.h @@ -0,0 +1,34 @@ +/* SPDX-License-Identifier: GPL-3.0-or-later */ +/* + * This file is part of plugin-hostd. + * + * Copyright (C) 2026 Pau Aliagas + */ + +/* The scanned descriptors of the plugins a controller adds: descriptor_dir and qualification_dir, the scan an add runs + * in a child of its own, and descriptor_get (openmixer spec 2026-10-17-contract-definitions-installed-by-owners 6.6, + * 6.9). A descriptor describes a plugin; it gates no load, so no add waits for its scan or is refused by it. */ + +#ifndef PLUGIN_HOSTD_DESCRIPTORS_H +#define PLUGIN_HOSTD_DESCRIPTORS_H + +#include "conf.h" + +/* descriptor_dir and qualification_dir resolved (an empty setting is the default of protocol.h), made when missing + * and tried for a write; SUCCESS, or PHD_ERR_DIR_NOT_WRITABLE with the key, the path and why on stderr. The resolved + * paths are written back into conf, which is kept for the scans. */ +int descriptors_init(conf_t *conf); + +/* the add of uri was answered: unless its files already have a descriptor or a scan of them runs, start one */ +void descriptors_add(const char *uri); + +/* reap the scans that ended: a descriptor is named and moved into place, a failure is recorded */ +void descriptors_tick(void); + +/* descriptor_get : resp 0 "", or the code of protocol.h; malloc'd */ +char *descriptors_get(const char *uri); + +/* stop the scans still running and remove what they were writing */ +void descriptors_finish(void); + +#endif diff --git a/src/main.c b/src/main.c index e7022de..8cd9ed7 100644 --- a/src/main.c +++ b/src/main.c @@ -28,6 +28,7 @@ #include #include "conf.h" +#include "descriptors.h" #include "pins.h" #include "relay.h" #include "supervisor.h" @@ -46,6 +47,7 @@ static conf_t g_conf; static void idle_cb(void) { sup_tick(); + descriptors_tick(); if (g_quitting) { running = 0; @@ -138,6 +140,9 @@ int main(int argc, char **argv) if (conf_check(&g_conf) != 0) return EXIT_FAILURE; + /* the directories of what an add gathers are made, or the daemon does not start: never a silent fallback */ + if (descriptors_init(&g_conf) != 0) + return EXIT_FAILURE; if (g_conf.require_pins && pins_init(g_conf.pin_store, g_conf.pin_file, g_conf.pins_dir) != 0) return EXIT_FAILURE; @@ -168,6 +173,7 @@ int main(int argc, char **argv) } socket_finish(); + descriptors_finish(); sup_finish(); relay_finish(); return 0; diff --git a/src/supervisor.c b/src/supervisor.c index 1509009..6bf6e9c 100644 --- a/src/supervisor.c +++ b/src/supervisor.c @@ -1104,6 +1104,11 @@ void sup_init(const conf_t *conf) } } +const char *sup_root(void) +{ + return g_root; +} + void sup_finish(void) { int n; diff --git a/src/supervisor.h b/src/supervisor.h index dc44401..e5a1b44 100644 --- a/src/supervisor.h +++ b/src/supervisor.h @@ -107,6 +107,8 @@ typedef struct CHAIN_T { } chain_t; void sup_init(const conf_t *conf); +/* this daemon's own directory under state_root: its checkpoints and the logs of its workers and scans */ +const char *sup_root(void); void sup_finish(void); /* mod-host's own "resp " text for a refusal; malloc'd */ diff --git a/src/verbs.c b/src/verbs.c index 1ad9a26..cede3bc 100644 --- a/src/verbs.c +++ b/src/verbs.c @@ -27,6 +27,7 @@ #include #include +#include "descriptors.h" #include "formats.h" #include "pins.h" #include "supervisor.h" @@ -138,8 +139,14 @@ static char *handle(char *line, char **tok, int ntok) client = tok[next++]; if (ntok > next) return sup_resp(ERR_INVALID_OPERATION); - return sup_add(tok[1], atoi(tok[2]), placement, client); + reply_text = sup_add(tok[1], atoi(tok[2]), placement, client); + /* the plugin is running: its files are scanned after the reply, never before it */ + if (reply_text && sup_reply_code(reply_text) >= 0) + descriptors_add(tok[1]); + return reply_text; } + if (!strcmp(verb, PHD_VERB_DESCRIPTOR_GET)) + return ntok == 2 ? descriptors_get(tok[1]) : sup_resp(ERR_INVALID_OPERATION); if (verb_is(verb, EFFECT_REMOVE)) { if (ntok != 2) diff --git a/tests/harness.py b/tests/harness.py index a25586e..83fe4d5 100644 --- a/tests/harness.py +++ b/tests/harness.py @@ -83,6 +83,11 @@ def __init__(self, exe, worker, clap_worker=None, conf=None, env=None, feedback= "lv2_worker": "mod-host", # no package's pins: the system's pins_dir is not the test's "pins_dir": "", + # what an add gathers goes to the test's own directories, never the user's state; no scanner runs unless + # a test names one + "descriptor_dir": os.path.join(self.tmp, "descriptors"), + "qualification_dir": os.path.join(self.tmp, "qualification"), + "probe": os.path.join(self.tmp, "no-probe"), } settings.update(conf or {}) self.conf = os.path.join(self.tmp, "plugin-hostd.conf") diff --git a/tests/socket_e2e.sh b/tests/socket_e2e.sh index f3b24d2..89b2cb9 100755 --- a/tests/socket_e2e.sh +++ b/tests/socket_e2e.sh @@ -23,9 +23,11 @@ for f in "$daemon" "$script"; do done log=$(mktemp) -"$daemon" -n -p "$port" >"$log" 2>&1 & +# what an add gathers goes to a directory of the test's own, never the user's state +state=$(mktemp -d) +OMX_DESCRIPTOR_DIR=$state/descriptors OMX_QUALIFICATION_DIR=$state/qualification "$daemon" -n -p "$port" >"$log" 2>&1 & pid=$! -trap 'kill "$pid" 2>/dev/null; wait "$pid" 2>/dev/null; rm -f "$log"' EXIT +trap 'kill "$pid" 2>/dev/null; wait "$pid" 2>/dev/null; rm -rf "$log" "$state"' EXIT for _ in $(seq 50); do grep -q "ready" "$log" && break; sleep 0.1; done grep -q "ready" "$log" || { echo "plugin-hostd did not come up" >&2; cat "$log" >&2; exit 2; } echo "plugin-hostd on port $port, protocol library: $(grep -o '/[^ ]*libmod-host-protocol[^ ]*' /proc/$pid/maps | sort -u)" From 2e8e74f07d0a03692800fab6e9bd313b8a8ee6a8 Mon Sep 17 00:00:00 2001 From: Pau Aliagas Date: Sat, 24 Oct 2026 12:45:44 +0200 Subject: [PATCH 06/10] tests: the descriptor of an add, its directories and their refusals Values over the wire and on disk: the file a CLAP and an LV2 add write, named as the header says and byte-identical to describe's output; one scan per binary; a changed binary gets a new file and the old one stays, a changed LV2 data file keeps the name; pending and failed scans answer their codes while the add stands; a read-only or missing-parent directory stops the daemon with -528; a missing key lands in the default the protocol documents, the variable stands in for it and the key holds over the variable. Six sabotage arms break each of these guards and see its test go red. --- tests/daemon_test.py | 279 +++++++++++++++++++++++++++++++++++++++++++ tests/sabotage.py | 20 ++++ 2 files changed, 299 insertions(+) diff --git a/tests/daemon_test.py b/tests/daemon_test.py index 6fa241d..55982ef 100644 --- a/tests/daemon_test.py +++ b/tests/daemon_test.py @@ -2794,4 +2794,283 @@ def a_worker_that_runs_no_chain_refuses_the_line_and_the_members_still_share_it( d.close() + +# ---------------------------------------------------------------- descriptors (openmixer spec 2026-10-17 6.6, 6.9) + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +PROBE = os.path.abspath(os.environ.get("PROBE", os.path.join(ROOT, "plugin-hostd-probe"))) +FIELDS = os.path.join(ROOT, "tests", "fixtures", "fields") +CLAP_ID = "org.freemixer.plugin-qualify.fixture.controls" +LV2_COMPRESSOR = "urn:example:compressor" +SCANNER = """#!/bin/sh +# a stand-in for the probe: the real describe, over the recorded dump of the target's format +echo "$2" >> "$SCAN_LOG" +if [ -n "$SCAN_HOLD" ]; then while [ ! -e "$SCAN_HOLD" ]; do sleep 0.02; done; fi +if [ -n "$SCAN_EXIT" ]; then exit "$SCAN_EXIT"; fi +case "$2" in + clap:*) exec %(probe)s describe --table %(tables)s/clap.json --fields-from %(fields)s/clap-controls.tsv "$2" ;; + *) exec %(probe)s describe --table %(tables)s/lv2.json --fields-from %(fields)s/lv2-compressor.tsv "$2" ;; +esac +""" + + +def config_meaning(key): + return [c for c in harness.DECLARED["config"] if c["key"] == key][0] + + +def safe(text): + """protocol.h's PHD_DESCRIPTOR_NAME_SYNTAX, plugin-qualify's rule: every character outside [A-Za-z0-9._-] as _""" + return "".join(c if c.isascii() and (c.isalnum() or c in "._-") else "_" for c in text) + + +def descriptor_name(root, plugin_id, version, binary): + """@-.json""" + return os.path.join(root, "%s@%s-%s.json" % (safe(plugin_id), safe(version), sha(binary)[:12])) + + +class Scan: + """a CLAP file, an LV2 bundle of the compressor, and a scanner that logs every target it is run for""" + + def __init__(self): + self.dir = tempfile.mkdtemp(prefix="plugin-hostd-scan.") + self.clap = os.path.join(self.dir, "clap", "controls.clap") + os.makedirs(os.path.dirname(self.clap)) + write(self.clap, b"a clap build") + self.clap_uri = "clap:%s#%s" % (self.clap, CLAP_ID) + self.lv2_dir = os.path.join(self.dir, "lv2") + self.bundle = os.path.join(self.lv2_dir, "compressor.lv2") + os.makedirs(self.bundle) + write(os.path.join(self.bundle, "manifest.ttl"), (MANIFEST % LV2_COMPRESSOR).encode()) + write(os.path.join(self.bundle, "pinned.so"), b"lv2 binary") + write(os.path.join(self.bundle, "pinned.ttl"), b"lv2:port [ lv2:index 0 ] .") + self.scanner = os.path.join(self.dir, "scanner") + write(self.scanner, (SCANNER % {"probe": PROBE, "tables": os.path.join(ROOT, "data", "formats"), + "fields": FIELDS}).encode()) + os.chmod(self.scanner, 0o755) + self.log = os.path.join(self.dir, "scans.log") + self.descriptors = os.path.join(self.dir, "descriptors") + self.qualification = os.path.join(self.dir, "qualification") + + def daemon(self, conf=None, env=None): + c = {"probe": self.scanner, "lv2_path": self.lv2_dir, "descriptor_dir": self.descriptors, + "qualification_dir": self.qualification} + c.update(conf or {}) + e = {"SCAN_LOG": self.log} + e.update(env or {}) + return daemon(conf=c, env=e) + + def scans(self): + if not os.path.exists(self.log): + return [] + with open(self.log) as f: + return [l.strip() for l in f if l.strip()] + + def clap_name(self, root=None): + return descriptor_name(root or self.descriptors, CLAP_ID, "1", self.clap) + + def lv2_name(self): + # the compressor states no version; its binary is the file its manifest names with lv2:binary + return descriptor_name(self.descriptors, LV2_COMPRESSOR, "unversioned", os.path.join(self.bundle, "pinned.so")) + + def close(self): + shutil.rmtree(self.dir, ignore_errors=True) + + +def answered(d, uri, path): + return d.send("descriptor_get %s" % uri) == 'resp 0 "%s"' % path + + +def same_bytes(a, b): + with open(a, "rb") as f, open(b, "rb") as g: + return f.read() == g.read() + + +@test +def an_add_writes_the_descriptor_by_id_version_and_hash_and_descriptor_get_answers_from_it(): + s = Scan() + d = s.daemon() + try: + d.expect("descriptor_get %s" % s.clap_uri, resp("DESCRIPTOR_ABSENT")) + d.expect("add %s 0" % s.clap_uri, "resp 0") + name = s.clap_name() + wait_for(lambda: answered(d, s.clap_uri, name), "descriptor_get answers %s" % name) + check(same_bytes(name, os.path.join(FIELDS, "clap-controls.json")), + "the file is what describe writes for the plugin") + check(os.listdir(os.path.dirname(name)) == [os.path.basename(name)], "nothing else is left beside it: %s" + % os.listdir(os.path.dirname(name))) + for path in (s.descriptors, s.qualification): + check(os.stat(path).st_mode & 0o777 == 0o700, "%s is made 0700" % path) + # written once: a second instance of the same files reads the file and runs no scan + d.expect("add %s 1" % s.clap_uri, "resp 1") + d.expect("add lv2:%s 2" % LV2_COMPRESSOR, "resp 2") + wait_for(lambda: answered(d, "lv2:" + LV2_COMPRESSOR, s.lv2_name()), "the LV2 descriptor %s" % s.lv2_name()) + check(same_bytes(s.lv2_name(), os.path.join(FIELDS, "lv2-compressor.json")), "the LV2 file is describe's") + check(s.scans() == [s.clap_uri, "lv2:" + LV2_COMPRESSOR], "one scan per plugin: %s" % s.scans()) + # the name hashes the binary alone: a changed data file of the bundle keeps it + write(os.path.join(s.bundle, "pinned.ttl"), b"lv2:port [ lv2:index 1 ] .") + check(answered(d, "lv2:" + LV2_COMPRESSOR, s.lv2_name()), "a changed .ttl keeps the LV2 binary's descriptor") + # a changed binary is no longer described: its next add scans again under a new name; the old file stays + write(s.clap, b"another clap build") + d.expect("descriptor_get %s" % s.clap_uri, resp("DESCRIPTOR_ABSENT")) + d.expect("add %s 3" % s.clap_uri, "resp 3") + wait_for(lambda: answered(d, s.clap_uri, s.clap_name()), "the new binary's descriptor") + check(s.clap_name() != name and os.path.exists(name), "a new file, and the old one kept") + check(len(s.scans()) == 3, "the changed binary was scanned: %s" % s.scans()) + finally: + d.close() + s.close() + + +@test +def a_scan_that_runs_answers_pending_and_one_that_fails_answers_its_code_while_the_add_stands(): + s = Scan() + hold = os.path.join(s.dir, "go") + d = s.daemon(env={"SCAN_HOLD": hold}) + try: + d.expect("add %s 0" % s.clap_uri, "resp 0") + wait_for(lambda: s.scans(), "the scan starts") + d.expect("descriptor_get %s" % s.clap_uri, resp("DESCRIPTOR_PENDING")) + d.expect("add %s 1" % s.clap_uri, "resp 1") + write(hold, b"") + wait_for(lambda: answered(d, s.clap_uri, s.clap_name()), "the held scan ends") + check(len(s.scans()) == 1, "an add while the scan runs starts no second one: %s" % s.scans()) + finally: + d.close() + d = s.daemon(conf={"descriptor_dir": os.path.join(s.dir, "other")}, env={"SCAN_EXIT": "2"}) + try: + d.expect("add %s 0" % s.clap_uri, "resp 0") + wait_for(lambda: d.send("descriptor_get %s" % s.clap_uri) == resp("DESCRIPTOR_SCAN_FAILED"), + "the failed scan's code") + check(d.workers() and list(d.workers().values())[0]["inst"] == [0], "the instance runs") + check("the scan wrote no descriptor" in d.log() and "resp %d" % harness.CODE["DESCRIPTOR_SCAN_FAILED"] in d.log(), + "stderr says so with the code: %s" % d.log()[-400:]) + check(not [f for r, _, fs in os.walk(os.path.join(s.dir, "other")) for f in fs], "no file is left behind") + finally: + d.close() + d = s.daemon(conf={"descriptor_dir": os.path.join(s.dir, "third"), "probe": os.path.join(s.dir, "absent")}) + try: + d.expect("add %s 0" % s.clap_uri, "resp 0") + d.expect("descriptor_get %s" % s.clap_uri, resp("DESCRIPTOR_SCAN_FAILED")) + d.expect("descriptor_get clap:fake#x", resp("DESCRIPTOR_ABSENT")) + d.expect("descriptor_get", "resp -902") + finally: + d.close() + s.close() + + +def start_refused(conf, env=None): + """the daemon run with a settings file that must stop it before it is ready: (exit status, output)""" + tmp = tempfile.mkdtemp(prefix="plugin-hostd-refused.") + path = os.path.join(tmp, "plugin-hostd.conf") + settings = {"state_root": tmp, "require_pins": 0, "pins_dir": "", "mod_host": FAKE, "clap_host": FAKE} + settings.update(conf) + with open(path, "w") as f: + for k, v in settings.items(): + if v is not None: + f.write("%s %s\n" % (k, v)) + e = dict(os.environ) + e.update(env or {}) + cmd, fb = harness.port_pair() + try: + p = subprocess.run([EXE, "-n", "-p", str(cmd), "-f", str(fb), "-c", path], capture_output=True, text=True, + timeout=10, env=e) + return p.returncode, p.stdout + p.stderr + finally: + shutil.rmtree(tmp, ignore_errors=True) + + +@test +def a_read_only_directory_stops_the_daemon_with_its_code_and_names_the_key(): + if os.geteuid() == 0: + print(" skip: root writes a 0500 directory") + return + s = Scan() + try: + for key, other in (("descriptor_dir", "qualification_dir"), ("qualification_dir", "descriptor_dir")): + ro = os.path.join(s.dir, "ro-" + key) + os.makedirs(ro) + os.chmod(ro, 0o500) + for path in (ro, os.path.join(ro, "below")): + rc, out = start_refused({key: path, other: os.path.join(s.dir, "fine")}) + check(rc != 0 and harness.READY_LINE not in out, "%s %s: the daemon does not start: %d %r" % (key, path, rc, out)) + check("%s %s" % (key, path) in out and "resp %d" % harness.CODE["DIR_NOT_WRITABLE"] in out, + "stderr names the key, the path and the code: %r" % out) + os.chmod(ro, 0o700) + # a path that is a file is no directory either + write(os.path.join(s.dir, "plain"), b"") + rc, out = start_refused({"descriptor_dir": os.path.join(s.dir, "plain"), + "qualification_dir": os.path.join(s.dir, "fine")}) + check(rc != 0 and "resp %d" % harness.CODE["DIR_NOT_WRITABLE"] in out, "a file in its place: %r" % out) + finally: + s.close() + + +@test +def a_directory_that_turns_read_only_makes_the_scan_answer_its_code(): + if os.geteuid() == 0: + print(" skip: root writes a 0500 directory") + return + s = Scan() + d = s.daemon() + try: + os.chmod(s.descriptors, 0o500) + d.expect("add %s 0" % s.clap_uri, "resp 0") + d.expect("descriptor_get %s" % s.clap_uri, resp("DIR_NOT_WRITABLE")) + check(s.scans() == [], "no scan ran: %s" % s.scans()) + os.chmod(s.descriptors, 0o700) + d.expect("add %s 1" % s.clap_uri, "resp 1") + wait_for(lambda: answered(d, s.clap_uri, s.clap_name()), "the next add scans again") + finally: + os.chmod(s.descriptors, 0o700) + d.close() + s.close() + + +@test +def a_missing_key_falls_to_the_documented_default_and_the_key_holds_over_the_variable(): + s = Scan() + try: + # the defaults as protocol.json documents them, the same text the man page and the README carry + declared = {} + for key in ("descriptor_dir", "qualification_dir"): + meaning = config_meaning(key)["meaning"] + check(config_meaning(key)["default"] == "", "%s's declared default is empty, resolved at start" % key) + xdg = meaning.split("empty is $XDG_STATE_HOME/", 1)[1].split(",", 1)[0] + home = meaning.split("else $HOME/", 1)[1].split(";", 1)[0] + declared[key] = (xdg, home) + cases = [ + ({"XDG_STATE_HOME": os.path.join(s.dir, "xdg"), "HOME": os.path.join(s.dir, "home")}, 0), + ({"XDG_STATE_HOME": "", "HOME": os.path.join(s.dir, "home")}, 1), + ] + for env, which in cases: + env = dict(env, OMX_DESCRIPTOR_DIR="", OMX_QUALIFICATION_DIR="") + base = env["XDG_STATE_HOME"] or env["HOME"] + d = s.daemon(conf={"descriptor_dir": None, "qualification_dir": None}, env=env) + try: + want = {k: os.path.join(base, v[which]) for k, v in declared.items()} + check(os.path.isdir(want["qualification_dir"]), "qualification_dir is made at %s" % want["qualification_dir"]) + d.expect("add %s 0" % s.clap_uri, "resp 0") + name = s.clap_name(want["descriptor_dir"]) + wait_for(lambda: answered(d, s.clap_uri, name), "the descriptor at the default %s" % name) + finally: + d.close() + # the variable stands in for the default; the key in the settings file holds over it + env = {"OMX_DESCRIPTOR_DIR": os.path.join(s.dir, "by-env"), "OMX_QUALIFICATION_DIR": os.path.join(s.dir, "q-env")} + d = s.daemon(conf={"descriptor_dir": None, "qualification_dir": None}, env=env) + try: + d.expect("add %s 0" % s.clap_uri, "resp 0") + wait_for(lambda: answered(d, s.clap_uri, s.clap_name(env["OMX_DESCRIPTOR_DIR"])), "the variable's directory") + check(os.path.isdir(env["OMX_QUALIFICATION_DIR"]), "the qualification variable's directory") + finally: + d.close() + d = s.daemon(conf={"descriptor_dir": os.path.join(s.dir, "by-key")}, env=env) + try: + d.expect("add %s 0" % s.clap_uri, "resp 0") + wait_for(lambda: answered(d, s.clap_uri, s.clap_name(os.path.join(s.dir, "by-key"))), "the key's directory") + finally: + d.close() + finally: + s.close() + + sys.exit(run_tests(TESTS)) diff --git a/tests/sabotage.py b/tests/sabotage.py index 3ea074a..d2e9685 100644 --- a/tests/sabotage.py +++ b/tests/sabotage.py @@ -661,6 +661,26 @@ 'chain_line(w, msg, sizeof(msg)) && replay_line(w, msg) != RPC_OK)', 'chain_line(w, msg, sizeof(msg)) && (replay_line_code(w, msg, &code) != RPC_OK || code < 0))', "a_worker_that_runs_no_chain_refuses_the_line_and_the_members_still_share_it"), + ("a descriptor_dir or qualification_dir that cannot be written is let through", "src/descriptors.c", + " if (make_dirs(dir) != 0 || writable(dir) != 0)\n", " if (make_dirs(dir) != 0 && 0)\n", + "a_read_only_directory_stops_the_daemon_with_its_code_and_names_the_key"), + ("an empty descriptor_dir ignores $XDG_STATE_HOME", "src/descriptors.c", + " if (state && *state)\n snprintf(dir, size, \"%s/%s\", state, subdir);", + " if (0)\n snprintf(dir, size, \"%s/%s\", state, subdir);", + "a_missing_key_falls_to_the_documented_default_and_the_key_holds_over_the_variable"), + ("the binary of an LV2 plugin is looked for among its data files too", "src/lv2.c", + "(!binary_only && !strcmp(t.text, RDFS_SEEALSO))", "!strcmp(t.text, RDFS_SEEALSO)", + "an_add_writes_the_descriptor_by_id_version_and_hash_and_descriptor_get_answers_from_it"), + ("an add scans a binary that already has a descriptor", "src/descriptors.c", + " if (lookup(id, hash, path, sizeof(path)) == 0)\n return;\n", "", + "an_add_writes_the_descriptor_by_id_version_and_hash_and_descriptor_get_answers_from_it"), + ("a scan that runs is answered absent", "src/descriptors.c", + "return sup_resp(s ? s->code : PHD_ERR_DESCRIPTOR_ABSENT);", "return sup_resp(PHD_ERR_DESCRIPTOR_ABSENT);", + "a_scan_that_runs_answers_pending_and_one_that_fails_answers_its_code_while_the_add_stands"), + ("a directory that turns read-only is answered absent", "src/descriptors.c", + " scan_failed(s, PHD_ERR_DIR_NOT_WRITABLE, why);\n return;\n }\n log =", + " scan_failed(s, PHD_ERR_DESCRIPTOR_ABSENT, why);\n return;\n }\n log =", + "a_directory_that_turns_read_only_makes_the_scan_answer_its_code"), ("the preset schema takes a format version it does not know", "protocol/preset.schema.json", '"enum": ["org.openmixer.preset/1", "org.openmixer.preset/2"]', '"type": "string"', "an unknown format version is refused", "presetschema"), From ab3c67bb37cdc0e5a067dc7097bb6d12a1d220e1 Mon Sep 17 00:00:00 2001 From: Pau Aliagas Date: Sat, 24 Oct 2026 13:12:19 +0200 Subject: [PATCH 07/10] State the state-directory rule without citing a private spec The descriptor and qualification directory comments named a private specification; they now say what the rule is. --- include/plugin-hostd/protocol.h | 7 +++---- src/descriptors.h | 3 +-- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/include/plugin-hostd/protocol.h b/include/plugin-hostd/protocol.h index 7d037ab..0d4dbb0 100644 --- a/include/plugin-hostd/protocol.h +++ b/include/plugin-hostd/protocol.h @@ -960,10 +960,9 @@ static inline int phd_is_note_verb(const char *verb) #define PHD_DEFAULT_PINS_DIR "/usr/share/plugin-hostd/pins.d" #define PHD_DEFAULT_PROBE "plugin-hostd-probe" -/* The directories of what the scan of an add gathers (openmixer spec 2026-10-17-contract-definitions-installed-by-owners, - * 6.9): state, kept across a reboot. An empty setting is the default: /, where the state home is - * $XDG_STATE_HOME, else /var/lib for a daemon run as root (a system service), else $HOME/.local/state. A missing - * directory is made, mode 0700; one that cannot be written stops the daemon with PHD_ERR_DIR_NOT_WRITABLE. */ +/* The directories of what the scan of an add gathers: state, kept across a reboot. An empty setting is the default: + * /, where the state home is $XDG_STATE_HOME, else /var/lib for a daemon run as root (a system + * service), else $HOME/.local/state. A missing directory is made, mode 0700; one that cannot be written stops the daemon with PHD_ERR_DIR_NOT_WRITABLE. */ #define PHD_DIR_DESCRIPTORS_SUBDIR "omx/descriptors" #define PHD_DIR_QUALIFICATION_SUBDIR "omx/qualification" #define PHD_DIR_SYSTEM_STATE_HOME "/var/lib" diff --git a/src/descriptors.h b/src/descriptors.h index c47b644..e3edb00 100644 --- a/src/descriptors.h +++ b/src/descriptors.h @@ -6,8 +6,7 @@ */ /* The scanned descriptors of the plugins a controller adds: descriptor_dir and qualification_dir, the scan an add runs - * in a child of its own, and descriptor_get (openmixer spec 2026-10-17-contract-definitions-installed-by-owners 6.6, - * 6.9). A descriptor describes a plugin; it gates no load, so no add waits for its scan or is refused by it. */ + * in a child of its own, and descriptor_get. A descriptor describes a plugin; it gates no load, so no add waits for its scan or is refused by it. */ #ifndef PLUGIN_HOSTD_DESCRIPTORS_H #define PLUGIN_HOSTD_DESCRIPTORS_H From 0048ad4e61ffcebaeed0a5e1ee0948b21532145f Mon Sep 17 00:00:00 2001 From: Pau Aliagas Date: Sat, 24 Oct 2026 13:12:19 +0200 Subject: [PATCH 08/10] tests: a root daemon keeps its default state in /var/lib, so the $HOME default is not run as root The test expected the $HOME path from whatever euid it ran under. CI runs as root, where the daemon picks /var/lib; that branch would write there, so the test skips it as root and checks the $HOME one otherwise. --- tests/daemon_test.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/daemon_test.py b/tests/daemon_test.py index 55982ef..d94923a 100644 --- a/tests/daemon_test.py +++ b/tests/daemon_test.py @@ -2795,7 +2795,7 @@ def a_worker_that_runs_no_chain_refuses_the_line_and_the_members_still_share_it( -# ---------------------------------------------------------------- descriptors (openmixer spec 2026-10-17 6.6, 6.9) +# ---------------------------------------------------------------- descriptors ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) PROBE = os.path.abspath(os.environ.get("PROBE", os.path.join(ROOT, "plugin-hostd-probe"))) @@ -3043,6 +3043,11 @@ def a_missing_key_falls_to_the_documented_default_and_the_key_holds_over_the_var ({"XDG_STATE_HOME": "", "HOME": os.path.join(s.dir, "home")}, 1), ] for env, which in cases: + if which == 1 and os.geteuid() == 0: + # a root daemon without $XDG_STATE_HOME keeps its state in /var/lib, which a test must not write; + # the $HOME branch is exercised by the non-root runs + print("skip: the $HOME default, the euid is 0 and the daemon then picks /var/lib") + continue env = dict(env, OMX_DESCRIPTOR_DIR="", OMX_QUALIFICATION_DIR="") base = env["XDG_STATE_HOME"] or env["HOME"] d = s.daemon(conf={"descriptor_dir": None, "qualification_dir": None}, env=env) From f6f43a703f4778c6355b9523ab778f88e39e8b17 Mon Sep 17 00:00:00 2001 From: Pau Aliagas Date: Sat, 24 Oct 2026 13:55:25 +0200 Subject: [PATCH 09/10] tests: the sabotage arms follow the adapter row's binary field and skip as root where the test does --- tests/sabotage.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/sabotage.py b/tests/sabotage.py index d2e9685..9d609e5 100644 --- a/tests/sabotage.py +++ b/tests/sabotage.py @@ -205,8 +205,8 @@ " .layout_check = 0,\n .pinned_own_only = 1,\n .native_instance_verbs = 1,", " .layout_check = 0,\n .pinned_own_only = 0,\n .native_instance_verbs = 1,", "the_adapter_row_keeps_the_pin_rules_of_the_lv2_row_and_checks_no_layout"), ("the adapter row's worker sees every bundle", "src/formats.c", - " .narrow_world = lv2_narrow_world,\n .pin_collect = lv2_pin_collect,\n .layout_check = 0,", - " .pin_collect = lv2_pin_collect,\n .layout_check = 0,", + " .narrow_world = lv2_narrow_world,\n .pin_collect = lv2_pin_collect,\n .binary = lv2_binary,\n .layout_check = 0,", + " .pin_collect = lv2_pin_collect,\n .binary = lv2_binary,\n .layout_check = 0,", "the_adapter_row_keeps_the_pin_rules_of_the_lv2_row_and_checks_no_layout"), ("the adapter's refusal answers the worker's own error", "src/supervisor.c", " if (!automatic)\n return sup_resp(PHD_ERR_LV2_ADAPTER_REFUSED);", " if (!automatic)\n return sup_resp(ERR_HOST_INSTANTIATION);", @@ -809,6 +809,9 @@ def run_test(tree, name, jack=False): failed = 0 +# the tests that need a directory root cannot be refused, and so skip themselves as root +ROOT_WRITES_READ_ONLY = ("a_read_only_directory_stops_the_daemon_with_its_code_and_names_the_key", + "a_directory_that_turns_read_only_makes_the_scan_answer_its_code") work = tempfile.mkdtemp(prefix="plugin-hostd-sabotage.") try: for entry in SABOTAGE: @@ -817,6 +820,9 @@ def run_test(tree, name, jack=False): if os.environ.get("SABOTAGE_ONLY") and os.environ["SABOTAGE_ONLY"] not in name: continue jack = entry[5] if len(entry) > 5 else False + if os.geteuid() == 0 and name in ROOT_WRITES_READ_ONLY: + print("skip sabotage (root writes a 0500 directory, so the test skips itself): %s" % what) + continue if jack == "meters" and not (os.environ.get("FAKE_COMPRESSOR_CLAP") and os.environ.get("JACK_METER_SOURCE")): print("skip sabotage (no FAKE_COMPRESSOR_CLAP and JACK_METER_SOURCE of omx-clap-host): %s" % what) continue From 81530c04c17f25cecfd84ec710494be7bcc25b93 Mon Sep 17 00:00:00 2001 From: Pau Aliagas Date: Sat, 24 Oct 2026 13:56:22 +0200 Subject: [PATCH 10/10] ci: the installed-package smoke test scans with the installed probe and the format tables beside the tests --- .github/workflows/ci.yml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a184d72..830ffe9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -269,8 +269,10 @@ jobs: set -euo pipefail mkdir -p /tmp/clean-root/smoke cp -r tests /tmp/clean-root/smoke/tests + # the scan an add runs reads the format tables and the recorded field dumps beside the tests + cp -r data /tmp/clean-root/smoke/data # the tests read the declared protocol from the installed package's JSON - chroot /tmp/clean-root env PLUGIN_HOSTD=/usr/bin/plugin-hostd FAKE_HOST=/smoke/tests/fake-host \ + chroot /tmp/clean-root env PLUGIN_HOSTD=/usr/bin/plugin-hostd PROBE=/usr/bin/plugin-hostd-probe FAKE_HOST=/smoke/tests/fake-host \ PLUGIN_HOSTD_PROTOCOL=/usr/share/plugin-hostd/protocol.json python3 /smoke/tests/daemon_test.py # the installed fake worker prints its layout and serves the daemon, as plugin-qualify drives it @@ -279,7 +281,7 @@ jobs: set -euo pipefail chroot /tmp/clean-root /usr/libexec/plugin-hostd/tests/fake-host -L | tee layout.txt test -s layout.txt - chroot /tmp/clean-root env PLUGIN_HOSTD=/usr/bin/plugin-hostd FAKE_HOST=/usr/libexec/plugin-hostd/tests/fake-host \ + chroot /tmp/clean-root env PLUGIN_HOSTD=/usr/bin/plugin-hostd PROBE=/usr/bin/plugin-hostd-probe FAKE_HOST=/usr/libexec/plugin-hostd/tests/fake-host \ PLUGIN_HOSTD_PROTOCOL=/usr/share/plugin-hostd/protocol.json python3 /smoke/tests/daemon_test.py - uses: actions/upload-artifact@v4 @@ -333,6 +335,7 @@ jobs: make tests/fake-host mkdir -p smoke cp -r tests smoke/tests + cp -r data smoke/data - uses: actions/upload-artifact@v4 with: @@ -404,7 +407,7 @@ jobs: set -euo pipefail # an artifact does not keep the executable bit chmod +x smoke/tests/fake-host - PLUGIN_HOSTD=/usr/bin/plugin-hostd FAKE_HOST=$PWD/smoke/tests/fake-host \ + PLUGIN_HOSTD=/usr/bin/plugin-hostd PROBE=/usr/bin/plugin-hostd-probe FAKE_HOST=$PWD/smoke/tests/fake-host \ PLUGIN_HOSTD_PROTOCOL=/usr/share/plugin-hostd/protocol.json python3 smoke/tests/daemon_test.py # docs/own-host.md, command by command, as a reader would type it: a clean Debian with nothing but what the page